IPF commited on
Commit
529598a
·
verified ·
1 Parent(s): 1622f2f

Upload 4 files

Browse files
Files changed (4) hide show
  1. README.md +3 -3
  2. app.py +2569 -0
  3. openresearcher-title.svg +12 -0
  4. requirements.txt +11 -0
README.md CHANGED
@@ -1,8 +1,8 @@
1
  ---
2
  title: OpenResearcher
3
- emoji: 💻
4
- colorFrom: indigo
5
- colorTo: gray
6
  sdk: gradio
7
  sdk_version: 6.5.1
8
  app_file: app.py
 
1
  ---
2
  title: OpenResearcher
3
+ emoji: 🏃
4
+ colorFrom: purple
5
+ colorTo: green
6
  sdk: gradio
7
  sdk_version: 6.5.1
8
  app_file: app.py
app.py ADDED
@@ -0,0 +1,2569 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ OpenResearcher DeepSearch Agent - Hugging Face Space
4
+ Uses ZeroGPU for efficient inference with the Nemotron model
5
+ Aligned with app_local.py frontend and logic
6
+ """
7
+ import os
8
+ import gradio as gr
9
+ import httpx
10
+ import json
11
+ import json5
12
+ import re
13
+ import time
14
+ import html
15
+ import asyncio
16
+ from datetime import datetime
17
+ from typing import List, Dict, Any, Optional, Tuple, Generator
18
+ import traceback
19
+ import base64
20
+ from transformers import AutoTokenizer
21
+
22
+
23
+
24
+ try:
25
+ from dotenv import load_dotenv
26
+ load_dotenv()
27
+ except ImportError:
28
+ pass
29
+
30
+ # ============================================================
31
+ # Configuration
32
+ # ============================================================
33
+ MODEL_NAME = os.getenv("MODEL_NAME", "OpenResearcher/Nemotron-3-Nano-30B-A3B")
34
+ REMOTE_API_BASE = os.getenv("REMOTE_API_BASE", "")
35
+ SERPER_API_KEY = os.getenv("SERPER_API_KEY", "")
36
+ MAX_NEW_TOKENS = int(os.getenv("MAX_NEW_TOKENS", "4096")) # Safe limit for ZeroGPU
37
+
38
+ # ============================================================
39
+ # System Prompt & Tools
40
+ # ============================================================
41
+ DEVELOPER_CONTENT = """
42
+ You are a helpful assistant and harmless assistant.
43
+
44
+ You will be able to use a set of browsering tools to answer user queries.
45
+
46
+ Tool for browsing.
47
+ The `cursor` appears in brackets before each browsing display: `[{cursor}]`.
48
+ Cite information from the tool using the following format:
49
+ `【{cursor}†L{line_start}(-L{line_end})?】`, for example: `【6†L9-L11】` or `【8†L3】`.
50
+ Do not quote more than 10 words directly from the tool output.
51
+ sources=web
52
+ """.strip()
53
+
54
+ TOOL_CONTENT = """
55
+ [
56
+ {
57
+ "type": "function",
58
+ "function": {
59
+ "name": "browser.search",
60
+ "description": "Searches for information related to a query and displays top N results. Returns a list of search results with titles, URLs, and summaries.",
61
+ "parameters": {
62
+ "type": "object",
63
+ "properties": {
64
+ "query": {
65
+ "type": "string",
66
+ "description": "The search query string"
67
+ },
68
+ "topn": {
69
+ "type": "integer",
70
+ "description": "Number of results to display",
71
+ "default": 10
72
+ }
73
+ },
74
+ "required": [
75
+ "query"
76
+ ]
77
+ }
78
+ }
79
+ },
80
+ {
81
+ "type": "function",
82
+ "function": {
83
+ "name": "browser.open",
84
+ "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}†.*】.",
85
+ "parameters": {
86
+ "type": "object",
87
+ "properties": {
88
+ "id": {
89
+ "type": [
90
+ "integer",
91
+ "string"
92
+ ],
93
+ "description": "Link id from current page (integer) or fully qualified URL (string). Default is -1 (most recent page)",
94
+ "default": -1
95
+ },
96
+ "cursor": {
97
+ "type": "integer",
98
+ "description": "Page cursor to operate on. If not provided, the most recent page is implied",
99
+ "default": -1
100
+ },
101
+ "loc": {
102
+ "type": "integer",
103
+ "description": "Starting line number. If not provided, viewport will be positioned at the beginning or centered on relevant passage",
104
+ "default": -1
105
+ },
106
+ "num_lines": {
107
+ "type": "integer",
108
+ "description": "Number of lines to display",
109
+ "default": -1
110
+ },
111
+ "view_source": {
112
+ "type": "boolean",
113
+ "description": "Whether to view page source",
114
+ "default": false
115
+ },
116
+ "source": {
117
+ "type": "string",
118
+ "description": "The source identifier (e.g., 'web')"
119
+ }
120
+ },
121
+ "required": []
122
+ }
123
+ }
124
+ },
125
+ {
126
+ "type": "function",
127
+ "function": {
128
+ "name": "browser.find",
129
+ "description": "Finds exact matches of a pattern in the current page or a specified page by cursor.",
130
+ "parameters": {
131
+ "type": "object",
132
+ "properties": {
133
+ "pattern": {
134
+ "type": "string",
135
+ "description": "The exact text pattern to search for"
136
+ },
137
+ "cursor": {
138
+ "type": "integer",
139
+ "description": "Page cursor to search in. If not provided, searches in the current page",
140
+ "default": -1
141
+ }
142
+ },
143
+ "required": [
144
+ "pattern"
145
+ ]
146
+ }
147
+ }
148
+ }
149
+ ]
150
+ """.strip()
151
+
152
+ # ============================================================
153
+ # Browser Tool Implementation
154
+ # ============================================================
155
+ class SimpleBrowser:
156
+ """Browser tool using Serper API."""
157
+
158
+ def __init__(self, serper_key: str):
159
+ self.serper_key = serper_key
160
+ self.pages: Dict[str, Dict] = {}
161
+ self.page_stack: List[str] = []
162
+ self.link_map: Dict[int, Dict] = {} # Map from cursor ID (int) to {url, title}
163
+ self.used_citations = [] # List of cursor IDs (int) in order of first appearance
164
+
165
+ @property
166
+ def current_cursor(self) -> int:
167
+ return len(self.page_stack) - 1
168
+
169
+ def add_link(self, cursor: int, url: str, title: str = ""):
170
+ self.link_map[cursor] = {'url': url, 'title': title}
171
+
172
+ def get_link_info(self, cursor: int) -> Optional[dict]:
173
+ return self.link_map.get(cursor)
174
+
175
+ def get_citation_index(self, cursor: int) -> int:
176
+ if cursor not in self.used_citations:
177
+ self.used_citations.append(cursor)
178
+ return self.used_citations.index(cursor)
179
+
180
+ def get_page_info(self, cursor: int) -> Optional[Dict[str, str]]:
181
+ # Prioritize link_map as it stores search result metadata
182
+ if cursor in self.link_map:
183
+ return self.link_map[cursor]
184
+
185
+ # Fallback to page_stack for opened pages
186
+ if 0 <= cursor < len(self.page_stack):
187
+ url = self.page_stack[cursor]
188
+ page = self.pages.get(url)
189
+ if page:
190
+ return {'url': url, 'title': page.get('title', '')}
191
+ return None
192
+
193
+ def _format_line_numbers(self, text: str, offset: int = 0) -> str:
194
+ lines = text.split('\n')
195
+ return '\n'.join(f"L{i + offset}: {line}" for i, line in enumerate(lines))
196
+
197
+ def _clean_links(self, results: List[Dict], query: str) -> Tuple[str, Dict[int, str]]:
198
+ link_map = {}
199
+ lines = []
200
+
201
+ for i, r in enumerate(results):
202
+ title = html.escape(r.get('title', 'No Title'))
203
+ url = r.get('link', r.get('url', ''))
204
+ snippet = html.escape(r.get('snippet', r.get('summary', '')))
205
+
206
+ try:
207
+ domain = url.split('/')[2] if url else ''
208
+ except:
209
+ domain = ''
210
+
211
+ try:
212
+ domain = url.split('/')[2] if url else ''
213
+ except:
214
+ domain = ''
215
+
216
+ self.link_map[i] = {'url': url, 'title': title}
217
+ link_map[i] = {'url': url, 'title': title}
218
+ link_text = f"【{i}†{title}†{domain}】" if domain else f"【{i}†{title}】"
219
+ lines.append(f"{link_text}")
220
+ lines.append(f" {snippet}")
221
+ lines.append("")
222
+
223
+ return '\n'.join(lines), link_map
224
+
225
+ async def search(self, query: str, topn: int = 10) -> str:
226
+ url = "https://google.serper.dev/search"
227
+ headers = {'X-API-KEY': self.serper_key, 'Content-Type': 'application/json'}
228
+ payload = json.dumps({"q": query, "num": topn})
229
+
230
+ async with httpx.AsyncClient() as client:
231
+ try:
232
+ response = await client.post(url, headers=headers, data=payload, timeout=20.0)
233
+ if response.status_code != 200:
234
+ return f"Error: Search failed with status {response.status_code}"
235
+
236
+ data = response.json()
237
+ results = data.get("organic", [])
238
+ if not results:
239
+ return f"No results found for: '{query}'"
240
+
241
+ content, new_link_map = self._clean_links(results, query)
242
+ self.link_map.update(new_link_map) # Merge new links
243
+ pseudo_url = f"web-search://q={query}&ts={int(time.time())}"
244
+ cursor = self.current_cursor + 1
245
+
246
+ page_data = {
247
+ 'url': pseudo_url,
248
+ 'title': f"Search Results: {query}",
249
+ 'text': content,
250
+ 'urls': {str(k): v['url'] for k, v in new_link_map.items()}
251
+ }
252
+ self.pages[pseudo_url] = page_data
253
+ self.page_stack.append(pseudo_url)
254
+
255
+ header = f"{page_data['title']} ({pseudo_url})\n**viewing lines [0 - {len(content.split(chr(10)))-1}]**\n\n"
256
+ body = self._format_line_numbers(content)
257
+
258
+ return f"[{cursor}] {header}{body}"
259
+
260
+ except Exception as e:
261
+ return f"Error during search: {str(e)}"
262
+
263
+ async def open(self, id: int | str = -1, cursor: int = -1, loc: int = -1, num_lines: int = -1, **kwargs) -> str:
264
+ target_url = None
265
+
266
+ if isinstance(id, str) and id.startswith("http"):
267
+ target_url = id
268
+ elif isinstance(id, int) and id >= 0:
269
+ info = self.link_map.get(id)
270
+ target_url = info['url'] if info else None
271
+ if not target_url:
272
+ return f"Error: Invalid link id '{id}'. Available: {list(self.link_map.keys())}"
273
+ elif cursor >= 0 and cursor < len(self.page_stack):
274
+ page_url = self.page_stack[cursor]
275
+ page = self.pages.get(page_url)
276
+ if page:
277
+ text = page['text']
278
+ lines = text.split('\n')
279
+ start = max(0, loc) if loc >= 0 else 0
280
+ end = min(len(lines), start + num_lines) if num_lines > 0 else len(lines)
281
+
282
+ header = f"{page['title']} ({page['url']})\n**viewing lines [{start} - {end-1}] of {len(lines)-1}**\n\n"
283
+ body = self._format_line_numbers('\n'.join(lines[start:end]), offset=start)
284
+ return f"[{cursor}] {header}{body}"
285
+ else:
286
+ return "Error: No valid target specified"
287
+
288
+ if not target_url:
289
+ return "Error: Could not determine target URL"
290
+
291
+ headers = {'X-API-KEY': self.serper_key, 'Content-Type': 'application/json'}
292
+ payload = json.dumps({"url": target_url})
293
+
294
+ async with httpx.AsyncClient() as client:
295
+ try:
296
+ response = await client.post("https://scrape.serper.dev/", headers=headers, data=payload, timeout=30.0)
297
+ if response.status_code != 200:
298
+ return f"Error fetching URL: {response.status_code}"
299
+
300
+ data = response.json()
301
+ text = data.get("text", "")
302
+ title = data.get("metadata", {}).get("title", "") if isinstance(data.get("metadata"), dict) else ""
303
+
304
+ if not text:
305
+ return f"No content found at URL"
306
+
307
+ lines = text.split('\n')
308
+ content = '\n'.join(lines)
309
+
310
+ max_lines = 150
311
+ if len(lines) > max_lines:
312
+ content = '\n'.join(lines[:max_lines]) + "\n\n...(content truncated)..."
313
+
314
+ new_cursor = self.current_cursor + 1
315
+ page_data = {
316
+ 'url': target_url,
317
+ 'title': title or target_url,
318
+ 'text': content,
319
+ 'urls': {}
320
+ }
321
+ self.pages[target_url] = page_data
322
+ self.page_stack.append(target_url)
323
+
324
+ start = max(0, loc) if loc >= 0 else 0
325
+ display_lines = content.split('\n')
326
+ end = min(len(display_lines), start + num_lines) if num_lines > 0 else len(display_lines)
327
+
328
+ header = f"{title or target_url} ({target_url})\n**viewing lines [{start} - {end-1}] of {len(display_lines)-1}**\n\n"
329
+ body = self._format_line_numbers('\n'.join(display_lines[start:end]), offset=start)
330
+
331
+ return f"[{new_cursor}] {header}{body}"
332
+
333
+ except Exception as e:
334
+ return f"Error fetching URL: {str(e)}"
335
+
336
+ def find(self, pattern: str, cursor: int = -1) -> str:
337
+ if not self.page_stack:
338
+ return "Error: No page open"
339
+
340
+ page_url = self.page_stack[cursor] if cursor >= 0 and cursor < len(self.page_stack) else self.page_stack[-1]
341
+ page = self.pages.get(page_url)
342
+
343
+ if not page:
344
+ return "Error: Page not found"
345
+
346
+ text = page['text']
347
+ lines = text.split('\n')
348
+ matches = []
349
+
350
+ for i, line in enumerate(lines):
351
+ if str(pattern).lower() in line.lower():
352
+ start = max(0, i - 1)
353
+ end = min(len(lines), i + 3)
354
+ context = '\n'.join(f"L{j}: {lines[j]}" for j in range(start, end))
355
+ matches.append(f"# 【{len(matches)}†match at L{i}】\n{context}")
356
+ if len(matches) >= 10:
357
+ break
358
+
359
+ if not matches:
360
+ return f"No matches found for: '{pattern}'"
361
+
362
+ result_url = f"{page_url}/find?pattern={pattern}"
363
+ new_cursor = self.current_cursor + 1
364
+ result_content = '\n\n'.join(matches)
365
+
366
+ page_data = {
367
+ 'url': result_url,
368
+ 'title': f"Find results for: '{pattern}'",
369
+ 'text': result_content,
370
+ 'urls': {}
371
+ }
372
+ self.pages[result_url] = page_data
373
+ self.page_stack.append(result_url)
374
+
375
+ header = f"Find results for text: `{pattern}` in `{page['title']}`\n\n"
376
+ return f"[{new_cursor}] {header}{result_content}"
377
+
378
+ def get_cursor_url(self, cursor: int) -> Optional[str]:
379
+ if cursor >= 0 and cursor < len(self.page_stack):
380
+ return self.page_stack[cursor]
381
+ return None
382
+
383
+
384
+ # ============================================================
385
+ # Tokenizer Loading
386
+ # ============================================================
387
+ tokenizer = None
388
+
389
+ def load_tokenizer():
390
+ global tokenizer
391
+ if tokenizer is None:
392
+ print(f"Loading tokenizer: {MODEL_NAME}")
393
+ try:
394
+ tokenizer = AutoTokenizer.from_pretrained(
395
+ MODEL_NAME,
396
+ trust_remote_code=True
397
+ )
398
+ print("Tokenizer loaded successfully!")
399
+ except Exception as e:
400
+ print(f"Error loading tokenizer: {e}")
401
+ import traceback
402
+ traceback.print_exc()
403
+ raise
404
+ return tokenizer
405
+
406
+ # ============================================================
407
+ # Text Processing
408
+ # ============================================================
409
+ def extract_thinking(text: str) -> Tuple[Optional[str], str]:
410
+ reasoning_content = None
411
+ content = text
412
+
413
+ if '<think>' in content and '</think>' in content:
414
+ match = re.search(r'<think>(.*?)</think>', content, re.DOTALL)
415
+ if match:
416
+ reasoning_content = match.group(1).strip()
417
+ content = content.replace(match.group(0), "").strip()
418
+ elif '</think>' in content:
419
+ match = re.search(r'^(.*?)</think>', content, re.DOTALL)
420
+ if match:
421
+ reasoning_content = match.group(1).strip()
422
+ content = content.replace(match.group(0), "").strip()
423
+
424
+ return reasoning_content, content
425
+
426
+
427
+ def parse_tool_call(text: str) -> Tuple[Optional[Dict], str]:
428
+ tool_call_text = None
429
+ content = text
430
+
431
+ if '<tool_call>' in content and '</tool_call>' in content:
432
+ match = re.search(r'<tool_call>(.*?)</tool_call>', content, re.DOTALL)
433
+ if match:
434
+ tool_call_text = match.group(1).strip()
435
+ content = content.replace(match.group(0), "").strip()
436
+ elif '</tool_call>' in content:
437
+ match = re.search(r'^(.*?)</tool_call>', content, re.DOTALL)
438
+ if match:
439
+ tool_call_text = match.group(1).strip()
440
+ content = content.replace(match.group(0), "").strip()
441
+
442
+ if tool_call_text:
443
+ try:
444
+ if "```json" in tool_call_text:
445
+ tool_call_text = tool_call_text.split("```json")[1].split("```")[0].strip()
446
+ elif "```" in tool_call_text:
447
+ tool_call_text = tool_call_text.split("```")[1].split("```")[0].strip()
448
+
449
+ parsed = json5.loads(tool_call_text)
450
+ return parsed, content
451
+ except:
452
+ pass
453
+
454
+ func_match = re.search(r'<function=([\w.]+)>', tool_call_text)
455
+ if func_match:
456
+ tool_name = func_match.group(1)
457
+ tool_args = {}
458
+ params = re.finditer(r'<parameter=([\w]+)>\s*(.*?)\s*</parameter>', tool_call_text, re.DOTALL)
459
+ for p in params:
460
+ param_name = p.group(1)
461
+ param_value = p.group(2).strip()
462
+ if param_value.startswith('"') and param_value.endswith('"'):
463
+ param_value = param_value[1:-1]
464
+ try:
465
+ if param_value.isdigit():
466
+ param_value = int(param_value)
467
+ except:
468
+ pass
469
+ tool_args[param_name] = param_value
470
+
471
+ return {"name": tool_name, "arguments": tool_args}, content
472
+
473
+ return None, content
474
+
475
+
476
+ def is_final_answer(text: str) -> bool:
477
+ t = text.lower()
478
+ return (
479
+ ('<answer>' in t and '</answer>' in t) or
480
+ 'final answer:' in t or
481
+ ('exact answer:' in t and 'confidence:' in t)
482
+ )
483
+
484
+ # ============================================================
485
+ # HTML Rendering Helpers (From app_local.py)
486
+ # ============================================================
487
+ def render_citations(text: str, browser: SimpleBrowser) -> str:
488
+ """Convert citation markers to clickable HTML links."""
489
+ def replace_citation(m):
490
+ cursor_str = m.group(1)
491
+ # l1 = m.group(2)
492
+ # l2 = m.group(3)
493
+
494
+ try:
495
+ cursor = int(cursor_str)
496
+ index = browser.get_citation_index(cursor)
497
+
498
+ # Check if we have URL info
499
+ info = browser.get_page_info(cursor)
500
+ if info and info.get('url'):
501
+ # Return clickable index link pointing to reference section
502
+ # Aligned with generate_html_example.py style (green via CSS class)
503
+ url = info.get('url')
504
+ return f'<a href="{html.escape(url)}" target="_blank" class="citation-link">[{index}]</a>'
505
+
506
+ # Fallback if no URL
507
+ return f'<span class="citation-link">[{index}]</span>'
508
+ except Exception as e:
509
+ # print(f"Error in replace_citation: {e}, match: {m.group(0)}")
510
+ pass
511
+ return m.group(0)
512
+
513
+ # First pass: replace citations with linked citations
514
+ result = re.sub(r'[【\[](\d+)†.*?[】\]]', replace_citation, text)
515
+
516
+ # Second pass: Deduplicate adjacent identical citations
517
+ # Matches: <a ...>[N]</a> followed by optional whitespace and same link
518
+ # We repeat this until no more changes to handle multiple duplicates
519
+ while True:
520
+ new_result = re.sub(r'(<a [^>]+>\[\d+\]</a>)(\s*)\1', r'\1', result)
521
+ if new_result == result:
522
+ break
523
+ result = new_result
524
+
525
+ # Convert basic markdown to HTML
526
+ result = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', result)
527
+ result = re.sub(r'\*(.+?)\*', r'<em>\1</em>', result)
528
+ result = re.sub(r'`(.+?)`', r'<code>\1</code>', result)
529
+ result = result.replace('\n\n', '</p><p>').replace('\n', '<br>')
530
+ if not result.startswith('<p>'):
531
+ result = f'<p>{result}</p>'
532
+
533
+ return result
534
+
535
+ def render_thinking_streaming(text: str) -> str:
536
+ """Render thinking content in streaming mode (visible, with animation)."""
537
+ escaped = html.escape(text)
538
+ return f'<div class="thinking-streaming">{escaped}</div>'
539
+
540
+ def render_thinking_collapsed(text: str) -> str:
541
+ """Render thinking content in collapsed mode after completion."""
542
+ escaped = html.escape(text)
543
+ preview = text[:100] + "..." if len(text) > 100 else text
544
+ preview_escaped = html.escape(preview)
545
+ return f'''<details class="thinking-collapsed">
546
+ <summary>Thought process: "{preview_escaped}"</summary>
547
+ <div class="thinking-content">{escaped}</div>
548
+ </details>'''
549
+
550
+ def render_tool_call(fn_name: str, args: dict, browser: SimpleBrowser = None) -> str:
551
+ """Render a tool call card with unified format and subtle distinction."""
552
+ border_colors = {
553
+ "browser.search": "#667eea",
554
+ "browser.open": "#4facfe",
555
+ "browser.find": "#fa709a"
556
+ }
557
+ border_color = border_colors.get(fn_name, "#9ca3af")
558
+
559
+ if fn_name == "browser.search":
560
+ query = str(args.get('query', ''))
561
+ return f'''<div class="tool-call-card" style="border-left: 3px solid {border_color};">
562
+ <div class="tool-info">
563
+ <div class="tool-name">Searching the web</div>
564
+ <div class="tool-detail">Query: "{html.escape(query)}"</div>
565
+ </div>
566
+ </div>'''
567
+ elif fn_name == "browser.open":
568
+ link_id = args.get('id', '')
569
+ url_info = ""
570
+ if browser and isinstance(link_id, int) and link_id >= 0:
571
+ info = browser.link_map.get(link_id)
572
+ url = info.get('url', "") if info else ""
573
+ if url:
574
+ try:
575
+ domain = url.split('/')[2]
576
+ url_info = f" → {domain}"
577
+ except:
578
+ url_info = ""
579
+ return f'''<div class="tool-call-card" style="border-left: 3px solid {border_color};">
580
+ <div class="tool-info">
581
+ <div class="tool-name">Opening page</div>
582
+ <div class="tool-detail">Link #{link_id}{url_info}</div>
583
+ </div>
584
+ </div>'''
585
+ elif fn_name == "browser.find":
586
+ pattern = str(args.get('pattern', ''))
587
+ return f'''<div class="tool-call-card" style="border-left: 3px solid {border_color};">
588
+ <div class="tool-info">
589
+ <div class="tool-name">Finding in page</div>
590
+ <div class="tool-detail">Pattern: "{html.escape(pattern)}"</div>
591
+ </div>
592
+ </div>'''
593
+ else:
594
+ return f'''<div class="tool-call-card" style="border-left: 3px solid {border_color};">
595
+ <div class="tool-info">
596
+ <div class="tool-name">{html.escape(str(fn_name))}</div>
597
+ <div class="tool-detail">{html.escape(json.dumps(args))}</div>
598
+ </div>
599
+ </div>'''
600
+
601
+ def render_tool_result(result: str, fn_name: str) -> str:
602
+ """Render tool result in an expanded card with direct HTML rendering."""
603
+ import uuid
604
+ tool_label = {
605
+ "browser.search": "🔍 Search Results",
606
+ "browser.open": "📄 Page Content",
607
+ "browser.find": "🔎 Find Results"
608
+ }.get(fn_name, "📋 Result")
609
+
610
+ border_colors = {
611
+ "browser.search": "#667eea",
612
+ "browser.open": "#4facfe",
613
+ "browser.find": "#86efac"
614
+ }
615
+ border_color = border_colors.get(fn_name, "#9ca3af")
616
+
617
+ # ===== SEARCH RESULTS =====
618
+ if fn_name == "browser.search" and "<html>" in result and "<ul>" in result:
619
+ ul_match = re.search(r'<ul>(.*?)</ul>', result, re.DOTALL)
620
+ if ul_match:
621
+ ul_content = ul_match.group(1)
622
+ items = re.findall(r"<li><a href='([^']+)'>([^<]+)</a>\s*([^<]*)</li>", ul_content)
623
+
624
+ if items:
625
+ lines = result.split('\n')
626
+ search_title = ""
627
+ if lines and re.match(r'^\[\d+\]\s+Search Results:', lines[0]):
628
+ match = re.match(r'^\[(\d+)\]\s+(.+?)\s+\(web-search://', lines[0])
629
+ if match:
630
+ ref_num, title = match.groups()
631
+ title = re.sub(r'\s+\(web-search://.*$', '', lines[0])
632
+ title = re.sub(r'^\[\d+\]\s+', '', title)
633
+ search_title = f'''
634
+ <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;">
635
+ <div style="display: flex; align-items: center; gap: 0.5rem;">
636
+ <span style="background: #667eea; color: white; padding: 0.125rem 0.5rem; border-radius: 4px; font-size: 0.75rem; font-weight: 600;">【{ref_num}】</span>
637
+ <span style="color: #1e40af; font-weight: 600; font-size: 0.95rem;">{html.escape(title)}</span>
638
+ </div>
639
+ </div>
640
+ '''
641
+
642
+ result_html = '<div style="display: flex; flex-direction: column; gap: 0.75rem;">'
643
+ for idx, (url, title, summary) in enumerate(items, 1):
644
+ card_id = f"search-card-{uuid.uuid4().hex[:8]}"
645
+ result_html += f'''
646
+ <div class="search-result-card" style="background: white; border: 1px solid #e5e7eb; border-radius: 8px; overflow: hidden; transition: all 0.2s ease;">
647
+ <div style="display: flex; align-items: center; gap: 0.5rem; padding: 0.875rem;">
648
+ <a href="{html.escape(url)}" target="_blank"
649
+ style="color: #667eea; font-weight: 600; font-size: 0.75rem; min-width: 30px; text-decoration: none;">【{idx}】</a>
650
+ <a href="{html.escape(url)}" target="_blank"
651
+ style="color: #1f2937; font-weight: 600; font-size: 0.9rem; text-decoration: none; flex: 1;">
652
+ {html.escape(title)}
653
+ </a>
654
+ </div>
655
+ <div style="padding: 0 0.875rem 0.875rem 0.875rem; border-top: 1px solid #f3f4f6;">
656
+ <div style="color: #6b7280; font-size: 0.85rem; line-height: 1.5; margin-top: 0.75rem;">
657
+ {html.escape(summary)}
658
+ </div>
659
+ <div style="color: #9ca3af; font-size: 0.75rem; margin-top: 0.5rem; font-family: monospace; word-break: break-all;">
660
+ {html.escape(url)}
661
+ </div>
662
+ </div>
663
+ </div>
664
+ '''
665
+ result_html += '</div>'
666
+ return f'''<div class="result-card-expanded" style="border-left: 3px solid {border_color};">
667
+ <div class="result-header-expanded">{tool_label}</div>
668
+ <div class="result-content-expanded" style="font-family: inherit;">{search_title}{result_html}</div>
669
+ </div>'''
670
+
671
+ # ===== BROWSER.OPEN and BROWSER.FIND =====
672
+ lines = result.split('\n')
673
+ title_html = ""
674
+ content_start_idx = 0
675
+ pattern_to_highlight = None
676
+
677
+ if lines and re.match(r'^\[\d+\]\s+.+\s+\(.+\)$', lines[0]):
678
+ first_line = lines[0]
679
+ match = re.match(r'^\[(\d+)\]\s+(.+?)\s+\((.+)\)$', first_line)
680
+ if match:
681
+ ref_num, title, url = match.groups()
682
+
683
+ if fn_name == "browser.find":
684
+ pattern_match = re.search(r'Find Results:\s*(.+)', title)
685
+ if pattern_match:
686
+ pattern_to_highlight = pattern_match.group(1).strip()
687
+
688
+ is_clickable = not url.startswith('web-search://')
689
+
690
+ if is_clickable:
691
+ title_html = f'''
692
+ <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;">
693
+ <div style="display: flex; align-items: center; gap: 0.5rem;">
694
+ <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>
695
+ <a href="{html.escape(url)}" target="_blank"
696
+ style="color: #1e40af; font-weight: 600; font-size: 0.95rem; text-decoration: none; flex: 1;">
697
+ {html.escape(title)}
698
+ </a>
699
+ </div>
700
+ <div style="color: #64748b; font-size: 0.75rem; margin-top: 0.25rem; font-family: monospace;">
701
+ {html.escape(url)}
702
+ </div>
703
+ </div>
704
+ '''
705
+ else:
706
+ title_html = f'''
707
+ <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;">
708
+ <div style="display: flex; align-items: center; gap: 0.5rem;">
709
+ <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>
710
+ <span style="color: #1e40af; font-weight: 600; font-size: 0.95rem;">{html.escape(title)}</span>
711
+ </div>
712
+ </div>
713
+ '''
714
+
715
+ content_start_idx = 1
716
+ if content_start_idx < len(lines) and lines[content_start_idx].startswith('**viewing lines'):
717
+ content_start_idx += 1
718
+ if content_start_idx < len(lines) and lines[content_start_idx].strip() == '':
719
+ content_start_idx += 1
720
+
721
+ cleaned_lines = []
722
+ for line in lines[content_start_idx:]:
723
+ cleaned_line = re.sub(r'^L\d+:\s*', '', line)
724
+ cleaned_lines.append(cleaned_line)
725
+
726
+ cleaned_content = '\n'.join(cleaned_lines)
727
+ formatted_result = html.escape(cleaned_content)
728
+
729
+ if pattern_to_highlight and fn_name == "browser.find":
730
+ escaped_pattern = re.escape(pattern_to_highlight)
731
+ def highlight_match(match):
732
+ return f'<mark style="background: #86efac; padding: 0.125rem 0.25rem; border-radius: 2px; font-weight: 600; color: #064e3b;">{match.group(0)}</mark>'
733
+ formatted_result = re.sub(
734
+ escaped_pattern,
735
+ highlight_match,
736
+ formatted_result,
737
+ flags=re.IGNORECASE
738
+ )
739
+
740
+ def make_citation_clickable(match):
741
+ full_text = match.group(0)
742
+ parts_match = re.match(r'【(\d+)†([^†]+)†([^】]+)】', full_text)
743
+ if parts_match:
744
+ ref_num = parts_match.group(1)
745
+ title = parts_match.group(2)
746
+ domain = parts_match.group(3)
747
+ url = f"https://{domain}" if not domain.startswith('http') else domain
748
+ 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>'
749
+ else:
750
+ simple_match = re.match(r'【(\d+)†([^】]+)】', full_text)
751
+ if simple_match:
752
+ ref_num = simple_match.group(1)
753
+ text = simple_match.group(2)
754
+ 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>'
755
+ return full_text
756
+
757
+ formatted_result = re.sub(r'【\d+†[^】]+】', make_citation_clickable, formatted_result)
758
+ formatted_result = formatted_result.replace('\n\n', '</p><p style="margin: 0.75rem 0;">')
759
+ formatted_result = formatted_result.replace('\n', '<br>')
760
+
761
+ if not formatted_result.startswith('<p'):
762
+ formatted_result = f'<p style="margin: 0.75rem 0;">{formatted_result}</p>'
763
+
764
+ max_length = 5000
765
+ if len(result) > max_length:
766
+ formatted_result = formatted_result[:max_length] + '<br><br><em style="color: #9ca3af;">...(content truncated for display)...</em>'
767
+
768
+ return f'''<div class="result-card-expanded" style="border-left: 3px solid {border_color};">
769
+ <div class="result-header-expanded">{tool_label}</div>
770
+ <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>
771
+ </div>'''
772
+
773
+ def render_round_badge(round_num: int, max_rounds: int) -> str:
774
+ return f'<div class="round-badge">Round {round_num}/{max_rounds}</div>'
775
+
776
+ def render_answer(text: str, browser: SimpleBrowser) -> str:
777
+ rendered = render_citations(text, browser)
778
+ return f'<div class="answer-section">{rendered}</div>'
779
+
780
+ def render_completion() -> str:
781
+ return '<div class="completion-msg">Research Complete</div>'
782
+
783
+ def render_user_message(question: str) -> str:
784
+ escaped = html.escape(question)
785
+ return f'''<div class="user-message-bubble">
786
+ <div class="user-message-content">{escaped}</div>
787
+ </div>'''
788
+
789
+
790
+
791
+ # ============================================================
792
+ # Remote API Generation (via vLLM-compatible endpoint)
793
+ # ============================================================
794
+
795
+
796
+ async def generate_response(prompt: str, max_new_tokens: int = MAX_NEW_TOKENS) -> str:
797
+ """Generate response using vLLM OpenAI-compatible API."""
798
+ # Use /completions endpoint for raw prompt
799
+ url = f"{REMOTE_API_BASE}/completions"
800
+ headers = {
801
+ "Content-Type": "application/json",
802
+ "ngrok-skip-browser-warning": "true", # 绕过 ngrok 免费版的浏览器警告页面
803
+ }
804
+ payload = {
805
+ "model": MODEL_NAME,
806
+ "prompt": prompt,
807
+ "max_tokens": max_new_tokens,
808
+ "temperature": 0.7,
809
+ "top_p": 0.9,
810
+ "stop": ["\n<tool_response>", "<tool_response>"],
811
+ }
812
+
813
+ async with httpx.AsyncClient() as client:
814
+ response = await client.post(url, json=payload, headers=headers, timeout=300.0)
815
+
816
+ if response.status_code != 200:
817
+ raise Exception(f"vLLM API error {response.status_code}: {response.text}")
818
+
819
+ data = response.json()
820
+ return data["choices"][0]["text"]
821
+
822
+
823
+ # ============================================================
824
+ # Streaming Agent Runner
825
+ # ============================================================
826
+ async def run_agent_streaming(
827
+ question: str,
828
+ serper_key: str,
829
+ max_rounds: int
830
+ ) -> Generator[str, None, None]:
831
+ global tokenizer
832
+
833
+ if not question.strip():
834
+ yield "<p style='color: var(--body-text-color-subdued); text-align: center; padding: 2rem;'>Please enter a question to begin.</p>"
835
+ return
836
+
837
+ if not serper_key:
838
+ yield """<div class="error-message">
839
+ <p><strong>Serper API Key Required</strong></p>
840
+ <p>Please configure your Serper API Key in the left sidebar under <strong>Settings</strong>.</p>
841
+ <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>
842
+ </div>"""
843
+ return
844
+
845
+ # Load tokenizer for prompt formatting
846
+ try:
847
+ load_tokenizer()
848
+ except Exception as e:
849
+ yield f"<p style='color:#dc2626;'>Error loading tokenizer: {html.escape(str(e))}</p>"
850
+ return
851
+
852
+ browser = SimpleBrowser(serper_key)
853
+ tools = json.loads(TOOL_CONTENT)
854
+
855
+ system_prompt = DEVELOPER_CONTENT + f"\n\nToday's date: {datetime.now().strftime('%Y-%m-%d')}"
856
+ messages = [
857
+ {"role": "system", "content": system_prompt},
858
+ {"role": "user", "content": question}
859
+ ]
860
+
861
+ stop_strings = ["\n<tool_response>", "<tool_response>"]
862
+
863
+ html_parts = [render_user_message(question)]
864
+ yield ''.join(html_parts)
865
+
866
+ round_num = 0
867
+
868
+ try:
869
+ while round_num < max_rounds:
870
+ round_num += 1
871
+ html_parts.append(render_round_badge(round_num, max_rounds))
872
+ yield ''.join(html_parts)
873
+
874
+ prompt = tokenizer.apply_chat_template(
875
+ messages,
876
+ tools=tools,
877
+ tokenize=False,
878
+ add_generation_prompt=True
879
+ )
880
+
881
+ try:
882
+ print(f"\n{'='*60}")
883
+ print(f"Round {round_num}")
884
+ print(f"{'='*60}")
885
+
886
+ html_parts.append('<div class="thinking-streaming">Processing...</div>')
887
+ yield ''.join(html_parts)
888
+
889
+ # Call ZeroGPU function
890
+ generated = await generate_response(prompt, max_new_tokens=MAX_NEW_TOKENS)
891
+
892
+ # Remove placeholder
893
+ html_parts.pop()
894
+
895
+ except Exception as e:
896
+ html_parts.pop() # Remove placeholder
897
+ html_parts.append(f"<p style='color:#dc2626;'>Generation Error: {html.escape(str(e))}</p>")
898
+ yield ''.join(html_parts)
899
+ return
900
+
901
+ for stop_str in stop_strings:
902
+ if stop_str in generated:
903
+ generated = generated[:generated.find(stop_str)]
904
+
905
+ reasoning, content = extract_thinking(generated)
906
+ tool_call, clean_content = parse_tool_call(content)
907
+
908
+ if reasoning:
909
+ html_parts.append(render_thinking_collapsed(reasoning))
910
+ yield ''.join(html_parts)
911
+
912
+ if tool_call:
913
+ fn_name = tool_call.get("name", "unknown")
914
+ args = tool_call.get("arguments", {})
915
+ html_parts.append(render_tool_call(fn_name, args, browser))
916
+ yield ''.join(html_parts)
917
+
918
+ if clean_content.strip() and not tool_call:
919
+ rendered = render_citations(clean_content, browser)
920
+ html_parts.append(f'<div class="answer-section">{rendered}</div>')
921
+ yield ''.join(html_parts)
922
+
923
+ non_thinking = generated.split('</think>', 1)[1].strip() if '</think>' in generated else generated.strip()
924
+ messages.append({
925
+ "role": "assistant",
926
+ "content": non_thinking if tool_call is None else "",
927
+ "reasoning_content": reasoning,
928
+ "tool_calls": [{
929
+ "id": str(round_num),
930
+ "type": "function",
931
+ "function": {
932
+ "name": tool_call.get("name", ""),
933
+ "arguments": tool_call.get("arguments", {})
934
+ }
935
+ }] if tool_call else None
936
+ })
937
+
938
+ if tool_call:
939
+ fn_name = tool_call.get("name", "")
940
+ args = tool_call.get("arguments", {})
941
+
942
+ if fn_name.startswith("browser."):
943
+ actual_fn = fn_name.split(".", 1)[1]
944
+ else:
945
+ actual_fn = fn_name
946
+
947
+ result = ""
948
+ try:
949
+ if actual_fn == "search":
950
+ result = await browser.search(args.get("query", ""), args.get("topn", 10))
951
+ elif actual_fn == "open":
952
+ result = await browser.open(**args)
953
+ elif actual_fn == "find":
954
+ result = browser.find(args.get("pattern", ""), args.get("cursor", -1))
955
+ else:
956
+ result = f"Unknown tool: {fn_name}"
957
+ except Exception as e:
958
+ result = f"Tool error: {str(e)}\n{traceback.format_exc()}"
959
+
960
+ html_parts.append(render_tool_result(result, fn_name))
961
+ yield ''.join(html_parts)
962
+
963
+ messages.append({
964
+ "role": "tool",
965
+ "tool_call_id": str(round_num),
966
+ "content": result
967
+ })
968
+ continue
969
+
970
+ if is_final_answer(generated):
971
+ html_parts.append(render_completion())
972
+ yield ''.join(html_parts)
973
+ break
974
+
975
+ if round_num >= max_rounds:
976
+ html_parts.append('<div class="completion-msg" style="background:#fef3c7;border-color:#f59e0b;color:#92400e;">Maximum rounds reached</div>')
977
+ yield ''.join(html_parts)
978
+
979
+ # Generate Reference Section
980
+ if browser.used_citations:
981
+ html_parts.append('<details class="reference-section">')
982
+ html_parts.append('<summary class="reference-title">References</summary>')
983
+
984
+ for i, cursor in enumerate(browser.used_citations):
985
+ info = browser.get_page_info(cursor)
986
+ if info:
987
+ url = info.get('url', '#')
988
+ title = info.get('title', 'Unknown Source')
989
+ else:
990
+ url = "#"
991
+ title = "Unknown Source"
992
+
993
+ ref_item = f'''
994
+ <div class="reference-item">
995
+ <div style="display: flex; align-items: baseline;">
996
+ <span class="ref-number">[{i}]</span>
997
+ <a href="{html.escape(url)}" target="_blank" class="ref-text">{html.escape(title)}</a>
998
+ </div>
999
+ <div class="ref-url" style="text-align: left;">{html.escape(url)}</div>
1000
+ </div>
1001
+ '''
1002
+ html_parts.append(ref_item)
1003
+
1004
+ html_parts.append('</details>')
1005
+ yield ''.join(html_parts)
1006
+
1007
+ except Exception as e:
1008
+ tb = traceback.format_exc()
1009
+ html_parts.append(f'<div style="color:#dc2626;"><p>Error: {html.escape(str(e))}</p><pre>{html.escape(tb)}</pre></div>')
1010
+ yield ''.join(html_parts)
1011
+
1012
+
1013
+ # ============================================================
1014
+ # Gradio Interface
1015
+ # ============================================================
1016
+ CAROUSEL_JS = r"""
1017
+ (function() {
1018
+ let currentExample = 0;
1019
+ const totalExamples = 3;
1020
+ let carouselInitialized = false;
1021
+ let layoutInitialized = false;
1022
+
1023
+ function updateCarousel() {
1024
+ const items = document.querySelectorAll('.carousel-item');
1025
+ const dots = document.querySelectorAll('.carousel-dot');
1026
+
1027
+ items.forEach((item, index) => {
1028
+ if (index === currentExample) {
1029
+ item.classList.add('active');
1030
+ } else {
1031
+ item.classList.remove('active');
1032
+ }
1033
+ });
1034
+
1035
+ dots.forEach((dot, index) => {
1036
+ if (index === currentExample) {
1037
+ dot.classList.add('active');
1038
+ } else {
1039
+ dot.classList.remove('active');
1040
+ }
1041
+ });
1042
+ }
1043
+
1044
+ function setExample(text) {
1045
+ const container = document.querySelector('#question-input');
1046
+ if (container) {
1047
+ const textbox = container.querySelector('textarea');
1048
+ if (textbox) {
1049
+ const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set;
1050
+ nativeInputValueSetter.call(textbox, text);
1051
+ textbox.dispatchEvent(new Event('input', { bubbles: true }));
1052
+ textbox.focus();
1053
+ }
1054
+ }
1055
+ }
1056
+
1057
+ function initCarousel() {
1058
+ if (carouselInitialized) return;
1059
+
1060
+ const prevBtn = document.getElementById('prev-btn');
1061
+ const nextBtn = document.getElementById('next-btn');
1062
+ const items = document.querySelectorAll('.carousel-item');
1063
+ const dots = document.querySelectorAll('.carousel-dot');
1064
+
1065
+ if (!prevBtn || !nextBtn || items.length === 0) return;
1066
+
1067
+ carouselInitialized = true;
1068
+
1069
+ prevBtn.onclick = function(e) {
1070
+ e.preventDefault();
1071
+ e.stopPropagation();
1072
+ currentExample = (currentExample - 1 + totalExamples) % totalExamples;
1073
+ updateCarousel();
1074
+ };
1075
+
1076
+ nextBtn.onclick = function(e) {
1077
+ e.preventDefault();
1078
+ e.stopPropagation();
1079
+ currentExample = (currentExample + 1) % totalExamples;
1080
+ updateCarousel();
1081
+ };
1082
+
1083
+ dots.forEach((dot, index) => {
1084
+ dot.onclick = function(e) {
1085
+ e.preventDefault();
1086
+ e.stopPropagation();
1087
+ currentExample = index;
1088
+ updateCarousel();
1089
+ };
1090
+ });
1091
+
1092
+ items.forEach((item, index) => {
1093
+ item.onclick = function(e) {
1094
+ e.preventDefault();
1095
+ e.stopPropagation();
1096
+ const text = this.getAttribute('data-text');
1097
+ if (text) {
1098
+ setExample(text);
1099
+ }
1100
+ };
1101
+ });
1102
+ }
1103
+
1104
+ function isAutoScrollEnabled() {
1105
+ const checkbox = document.querySelector('#auto-scroll-checkbox input[type="checkbox"]');
1106
+ return checkbox ? checkbox.checked : true;
1107
+ }
1108
+
1109
+ function scrollToBottom() {
1110
+ if (!isAutoScrollEnabled()) return;
1111
+ const outputArea = document.querySelector('#output-area');
1112
+ if (outputArea) {
1113
+ // 直接滚动 #output-area
1114
+ outputArea.scrollTop = outputArea.scrollHeight;
1115
+ }
1116
+ }
1117
+
1118
+ // 监听输出区域的内容变化,自动滚动
1119
+ function setupAutoScroll() {
1120
+ const outputArea = document.querySelector('#output-area');
1121
+ if (outputArea) {
1122
+ const observer = new MutationObserver(function() {
1123
+ // 延迟滚动以确保 DOM 已更新
1124
+ requestAnimationFrame(function() {
1125
+ setTimeout(scrollToBottom, 50);
1126
+ });
1127
+ });
1128
+ observer.observe(outputArea, { childList: true, subtree: true, characterData: true });
1129
+ }
1130
+ }
1131
+
1132
+ function updateOutputVisibility() {
1133
+ const outputArea = document.getElementById('output-area');
1134
+ if (outputArea) {
1135
+ const content = outputArea.innerHTML.trim();
1136
+ // 检查是否有实际内容(不只是空的 div 或空白)
1137
+ const hasContent = content !== '' && content !== '<div></div>' && !/^<div[^>]*>\s*<\/div>$/.test(content);
1138
+ if (hasContent) {
1139
+ outputArea.classList.remove('hidden-output');
1140
+ outputArea.classList.add('has-content');
1141
+ 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;';
1142
+ } else {
1143
+ outputArea.classList.add('hidden-output');
1144
+ outputArea.classList.remove('has-content');
1145
+ outputArea.style.cssText = 'display: none !important; visibility: hidden !important; opacity: 0 !important; height: 0 !important; min-height: 0 !important; max-height: 0 !important;';
1146
+ }
1147
+ }
1148
+ }
1149
+
1150
+ function initLayout() {
1151
+ if (layoutInitialized) return;
1152
+
1153
+ const mainContent = document.getElementById('main-content');
1154
+ const outputArea = document.getElementById('output-area');
1155
+
1156
+ if (!mainContent) return;
1157
+
1158
+ layoutInitialized = true;
1159
+ mainContent.classList.add('initial-state');
1160
+
1161
+ // 初始化时立即隐藏空的输出区域 - 使用内联样式确保生效
1162
+ if (outputArea) {
1163
+ const content = outputArea.innerHTML.trim();
1164
+ const hasContent = content !== '' && content !== '<div></div>' && !/^<div[^>]*>\s*<\/div>$/.test(content);
1165
+ if (!hasContent) {
1166
+ 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;';
1167
+ outputArea.classList.add('hidden-output');
1168
+ outputArea.classList.remove('has-content');
1169
+ }
1170
+ }
1171
+
1172
+ // 设置自动滚动监听
1173
+ setupAutoScroll();
1174
+
1175
+ const outputObserver = new MutationObserver(function() {
1176
+ const content = outputArea ? outputArea.innerHTML.trim() : '';
1177
+ const hasContent = content !== '' && content !== '<div></div>' && !/^<div[^>]*>\s*<\/div>$/.test(content);
1178
+
1179
+ if (hasContent) {
1180
+ mainContent.classList.remove('initial-state');
1181
+ outputArea.classList.remove('hidden-output');
1182
+ outputArea.classList.add('has-content');
1183
+ 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;';
1184
+ setTimeout(scrollToBottom, 100);
1185
+ } else {
1186
+ mainContent.classList.add('initial-state');
1187
+ if (outputArea) {
1188
+ outputArea.classList.add('hidden-output');
1189
+ outputArea.classList.remove('has-content');
1190
+ outputArea.style.cssText = 'display: none !important; visibility: hidden !important; opacity: 0 !important; height: 0 !important; min-height: 0 !important; max-height: 0 !important;';
1191
+ }
1192
+ }
1193
+ });
1194
+
1195
+ if (outputArea) {
1196
+ outputObserver.observe(outputArea, { childList: true, subtree: true, characterData: true });
1197
+ }
1198
+
1199
+ const questionInput = document.querySelector('#question-input textarea');
1200
+ if (questionInput) {
1201
+ questionInput.focus();
1202
+ }
1203
+ }
1204
+
1205
+ const observer = new MutationObserver(function(mutations, obs) {
1206
+ initCarousel();
1207
+ initLayout();
1208
+ if (carouselInitialized && layoutInitialized) {
1209
+ obs.disconnect();
1210
+ }
1211
+ });
1212
+
1213
+ observer.observe(document.body, {
1214
+ childList: true,
1215
+ subtree: true
1216
+ });
1217
+
1218
+ // 立即尝试隐藏输出区域(在 DOM 完全加载之前)
1219
+ function hideOutputAreaEarly() {
1220
+ const outputArea = document.getElementById('output-area');
1221
+ if (outputArea) {
1222
+ const content = outputArea.innerHTML.trim();
1223
+ const hasContent = content !== '' && content !== '<div></div>' && !/^<div[^>]*>\s*<\/div>$/.test(content);
1224
+ if (!hasContent) {
1225
+ 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;';
1226
+ outputArea.classList.add('hidden-output');
1227
+ outputArea.classList.remove('has-content');
1228
+ }
1229
+ }
1230
+ }
1231
+
1232
+ // 多次尝试隐藏,确保在各种时机都能生效
1233
+ hideOutputAreaEarly();
1234
+ document.addEventListener('DOMContentLoaded', hideOutputAreaEarly);
1235
+ setTimeout(hideOutputAreaEarly, 0);
1236
+ setTimeout(hideOutputAreaEarly, 100);
1237
+ setTimeout(hideOutputAreaEarly, 300);
1238
+ setTimeout(hideOutputAreaEarly, 500);
1239
+ setTimeout(function() { initCarousel(); initLayout(); }, 1000);
1240
+ setTimeout(function() { initCarousel(); initLayout(); }, 2000);
1241
+
1242
+ // 深色模式适配 - 动态更新 output-area 背景色(防闪烁优化版)
1243
+ let lastUpdateTime = 0;
1244
+ const UPDATE_THROTTLE = 50; // 最小更新间隔 50ms
1245
+
1246
+ function updateOutputAreaDarkMode() {
1247
+ const now = Date.now();
1248
+ if (now - lastUpdateTime < UPDATE_THROTTLE) {
1249
+ return; // 跳过过于频繁的更新
1250
+ }
1251
+ lastUpdateTime = now;
1252
+
1253
+ const outputArea = document.getElementById('output-area');
1254
+ if (!outputArea) return;
1255
+
1256
+ // 检查是否是深色模式
1257
+ const isDark = document.documentElement.classList.contains('dark') ||
1258
+ document.body.classList.contains('dark') ||
1259
+ window.matchMedia('(prefers-color-scheme: dark)').matches;
1260
+
1261
+ if (isDark) {
1262
+ // 深色模式:深色背景
1263
+ outputArea.style.setProperty('background', '#111827', 'important');
1264
+ outputArea.style.setProperty('border-color', '#374151', 'important');
1265
+ } else {
1266
+ // 浅色模式:浅色背景
1267
+ outputArea.style.setProperty('background', '#fafafa', 'important');
1268
+ outputArea.style.setProperty('border-color', '#e5e7eb', 'important');
1269
+ }
1270
+ }
1271
+
1272
+ // 延迟初始化,避免页面加载时闪烁
1273
+ setTimeout(function() {
1274
+ updateOutputAreaDarkMode();
1275
+
1276
+ // 监听深色模式变化
1277
+ const darkModeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
1278
+ darkModeMediaQuery.addEventListener('change', updateOutputAreaDarkMode);
1279
+
1280
+ // 监听 DOM class 变化
1281
+ const darkModeObserver = new MutationObserver(updateOutputAreaDarkMode);
1282
+ darkModeObserver.observe(document.documentElement, {
1283
+ attributes: true,
1284
+ attributeFilter: ['class']
1285
+ });
1286
+ darkModeObserver.observe(document.body, {
1287
+ attributes: true,
1288
+ attributeFilter: ['class']
1289
+ });
1290
+
1291
+ // 监听 output-area 的内容变化
1292
+ const outputArea = document.getElementById('output-area');
1293
+ if (outputArea) {
1294
+ const outputContentObserver = new MutationObserver(function() {
1295
+ // 内容变化时立即应用深色模式样式
1296
+ requestAnimationFrame(updateOutputAreaDarkMode);
1297
+ });
1298
+
1299
+ outputContentObserver.observe(outputArea, {
1300
+ childList: true,
1301
+ subtree: true
1302
+ });
1303
+ }
1304
+ }, 500); // 延迟 500ms 后再启动监听
1305
+ })();
1306
+ """
1307
+
1308
+ def create_interface():
1309
+ # Get the directory where this script is located for static files
1310
+ script_dir = os.path.dirname(os.path.abspath(__file__))
1311
+
1312
+ # Helper function to convert image to base64 for embedding in HTML
1313
+ def image_to_base64(image_path):
1314
+ """Convert image file to base64 string for HTML embedding."""
1315
+ try:
1316
+ with open(image_path, 'rb') as img_file:
1317
+ img_data = img_file.read()
1318
+ b64_string = base64.b64encode(img_data).decode('utf-8')
1319
+ # Determine MIME type based on file extension
1320
+ ext = image_path.lower().split('.')[-1]
1321
+ mime_types = {
1322
+ 'png': 'image/png',
1323
+ 'jpg': 'image/jpeg',
1324
+ 'jpeg': 'image/jpeg',
1325
+ 'svg': 'image/svg+xml',
1326
+ 'gif': 'image/gif'
1327
+ }
1328
+ mime_type = mime_types.get(ext, 'image/png')
1329
+ return f"data:{mime_type};base64,{b64_string}"
1330
+ except Exception as e:
1331
+ print(f"Error loading image {image_path}: {e}")
1332
+ return ""
1333
+
1334
+ # Inline CSS - all styles embedded directly
1335
+ INLINE_CSS = """
1336
+ /* Global Styles */
1337
+ .gradio-container {
1338
+ font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif !important;
1339
+ }
1340
+
1341
+ /* Thinking Styles */
1342
+ .thinking-collapsed {
1343
+ background: #f9fafb;
1344
+ border: 1px solid #e5e7eb;
1345
+ border-radius: 8px;
1346
+ padding: 0.75rem;
1347
+ margin: 0.5rem 0;
1348
+ }
1349
+
1350
+ .thinking-collapsed summary {
1351
+ cursor: pointer;
1352
+ font-weight: 500;
1353
+ color: #6b7280;
1354
+ font-size: 0.875rem;
1355
+ }
1356
+
1357
+ .thinking-collapsed summary:hover {
1358
+ color: #374151;
1359
+ }
1360
+
1361
+ .thinking-content {
1362
+ margin-top: 0.5rem;
1363
+ color: #374151;
1364
+ white-space: pre-wrap;
1365
+ font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
1366
+ font-size: 0.875rem;
1367
+ line-height: 1.5;
1368
+ }
1369
+
1370
+ .thinking-streaming {
1371
+ background: #f0f9ff;
1372
+ border: 1px solid #bae6fd;
1373
+ border-radius: 8px;
1374
+ padding: 0.875rem;
1375
+ margin: 0.5rem 0;
1376
+ color: #0c4a6e;
1377
+ white-space: pre-wrap;
1378
+ font-family: 'SF Mono', Monaco, monospace;
1379
+ font-size: 0.875rem;
1380
+ line-height: 1.5;
1381
+ }
1382
+
1383
+ /* Tool Call Card */
1384
+ .tool-call-card {
1385
+ background: #f9fafb;
1386
+ border-radius: 8px;
1387
+ padding: 1rem;
1388
+ margin: 0.75rem 0;
1389
+ }
1390
+
1391
+ .tool-info {
1392
+ display: flex;
1393
+ flex-direction: column;
1394
+ gap: 0.25rem;
1395
+ }
1396
+
1397
+ .tool-name {
1398
+ font-weight: 600;
1399
+ color: #374151;
1400
+ font-size: 0.875rem;
1401
+ }
1402
+
1403
+ .tool-detail {
1404
+ color: #6b7280;
1405
+ font-size: 0.8rem;
1406
+ }
1407
+
1408
+ /* Result Card */
1409
+ .result-card-expanded {
1410
+ background: white;
1411
+ border-radius: 8px;
1412
+ padding: 1.25rem;
1413
+ margin: 1rem 0;
1414
+ box-shadow: 0 1px 3px rgba(0,0,0,0.1);
1415
+ }
1416
+
1417
+ .result-header-expanded {
1418
+ font-weight: 600;
1419
+ color: #374151;
1420
+ margin-bottom: 1.25rem;
1421
+ padding-bottom: 0.5rem;
1422
+ border-bottom: 1px solid #e5e7eb;
1423
+ font-size: 1rem;
1424
+ }
1425
+
1426
+ .result-content-expanded {
1427
+ color: #4b5563;
1428
+ line-height: 1.6;
1429
+ }
1430
+
1431
+ .result-content-expanded p {
1432
+ margin: 0.5rem 0;
1433
+ }
1434
+
1435
+ .result-content-expanded code {
1436
+ background: #f3f4f6;
1437
+ padding: 0.125rem 0.375rem;
1438
+ border-radius: 3px;
1439
+ font-family: monospace;
1440
+ font-size: 0.875em;
1441
+ }
1442
+
1443
+ /* Search Result Card Hover */
1444
+ .search-result-card {
1445
+ transition: all 0.2s ease;
1446
+ }
1447
+
1448
+ .search-result-card:hover {
1449
+ box-shadow: 0 2px 8px rgba(102, 126, 234, 0.15) !important;
1450
+ border-color: #667eea !important;
1451
+ }
1452
+
1453
+ /* Answer Section */
1454
+ .answer-section {
1455
+ background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%);
1456
+ border-left: 4px solid #10a37f;
1457
+ border-radius: 8px;
1458
+ padding: 1.5rem;
1459
+ margin: 1rem 0;
1460
+ }
1461
+
1462
+ .answer-section p {
1463
+ color: #374151;
1464
+ line-height: 1.7;
1465
+ margin: 0.5rem 0;
1466
+ }
1467
+
1468
+ .answer-section strong {
1469
+ color: #1e293b;
1470
+ font-weight: 600;
1471
+ }
1472
+
1473
+ .answer-section a {
1474
+ color: #10a37f;
1475
+ text-decoration: none;
1476
+ font-weight: 500;
1477
+ }
1478
+
1479
+ .answer-section a:hover {
1480
+ text-decoration: underline;
1481
+ }
1482
+
1483
+ /* User Message Bubble - 淡蓝色背景,右对齐 */
1484
+ .user-message-bubble {
1485
+ background: linear-gradient(135deg, #e0f2fe 0%, #bae6fd 100%);
1486
+ color: #0c4a6e;
1487
+ border-radius: 1rem 1rem 0.25rem 1rem;
1488
+ padding: 1rem 1.25rem;
1489
+ margin: 1rem 0 1rem auto;
1490
+ max-width: 80%;
1491
+ box-shadow: 0 2px 8px rgba(14, 165, 233, 0.2);
1492
+ border: 1px solid #7dd3fc;
1493
+ text-align: left;
1494
+ }
1495
+
1496
+ /* Reference Section Collapsible */
1497
+ .reference-section {
1498
+ margin-top: 40px;
1499
+ border-top: 1px solid #e5e7eb;
1500
+ padding-top: 20px;
1501
+ }
1502
+
1503
+ .reference-title {
1504
+ font-size: 1.2rem;
1505
+ font-weight: 600;
1506
+ margin-bottom: 16px;
1507
+ color: #111827;
1508
+ cursor: pointer;
1509
+ outline: none;
1510
+ }
1511
+
1512
+ .ref-url {
1513
+ text-align: left !important;
1514
+ }
1515
+
1516
+ .user-message-content {
1517
+ line-height: 1.6;
1518
+ font-size: 0.95rem;
1519
+ }
1520
+
1521
+ /* Output area - 固定高度可滚动 */
1522
+ /* 强制固定高度,内容在里面滚动 */
1523
+ #output-area,
1524
+ #output-area.output-box,
1525
+ div#output-area {
1526
+ height: 50vh !important;
1527
+ min-height: 250px !important;
1528
+ max-height: 50vh !important;
1529
+ overflow-y: scroll !important;
1530
+ overflow-x: hidden !important;
1531
+ padding: 1rem !important;
1532
+ border: 1px solid #e5e7eb !important;
1533
+ border-radius: 8px !important;
1534
+ background: #fafafa !important;
1535
+ scroll-behavior: smooth;
1536
+ flex-shrink: 0 !important;
1537
+ flex-grow: 0 !important;
1538
+ }
1539
+
1540
+ /* 内部所有元素不能撑破容器 */
1541
+ #output-area * {
1542
+ max-height: none !important;
1543
+ overflow: visible !important;
1544
+ }
1545
+
1546
+ #output-area > div {
1547
+ height: auto !important;
1548
+ max-height: none !important;
1549
+ overflow: visible !important;
1550
+ border: none !important;
1551
+ background: transparent !important;
1552
+ padding: 0 !important;
1553
+ margin: 0 !important;
1554
+ box-shadow: none !important;
1555
+ }
1556
+
1557
+ /* 初始状态和空内容时隐藏 output-area */
1558
+ #output-area:empty,
1559
+ #output-area.hidden-output,
1560
+ #output-area:not(.has-content),
1561
+ .hidden-output#output-area,
1562
+ div.hidden-output#output-area,
1563
+ #main-content #output-area.hidden-output,
1564
+ #main-content .output-box.hidden-output {
1565
+ display: none !important;
1566
+ visibility: hidden !important;
1567
+ height: 0 !important;
1568
+ min-height: 0 !important;
1569
+ max-height: 0 !important;
1570
+ padding: 0 !important;
1571
+ margin: 0 !important;
1572
+ border: none !important;
1573
+ opacity: 0 !important;
1574
+ overflow: hidden !important;
1575
+ }
1576
+
1577
+ /* 防止内部内容撑破容器 */
1578
+ #output-area > * {
1579
+ max-width: 100%;
1580
+ word-wrap: break-word;
1581
+ overflow-wrap: break-word;
1582
+ }
1583
+
1584
+ /* 主内容区域布局 - 限制整体高度 */
1585
+ #main-content {
1586
+ display: flex !important;
1587
+ flex-direction: column !important;
1588
+ height: auto !important;
1589
+ max-height: none !important;
1590
+ overflow: visible !important;
1591
+ }
1592
+
1593
+ /* Gradio 包装容器限制 */
1594
+ #main-content > div {
1595
+ flex-shrink: 0;
1596
+ }
1597
+
1598
+ #output-area::-webkit-scrollbar {
1599
+ width: 8px;
1600
+ }
1601
+
1602
+ #output-area::-webkit-scrollbar-track {
1603
+ background: #f1f1f1;
1604
+ border-radius: 4px;
1605
+ }
1606
+
1607
+ #output-area::-webkit-scrollbar-thumb {
1608
+ background: #c1c1c1;
1609
+ border-radius: 4px;
1610
+ }
1611
+
1612
+ #output-area::-webkit-scrollbar-thumb:hover {
1613
+ background: #a1a1a1;
1614
+ }
1615
+
1616
+ /* 自动滚动控制按钮样式 */
1617
+ #auto-scroll-checkbox {
1618
+ margin-top: 0.5rem;
1619
+ }
1620
+
1621
+ #auto-scroll-checkbox label {
1622
+ font-size: 0.85rem;
1623
+ color: #4b5563;
1624
+ }
1625
+
1626
+ /* Completion Message */
1627
+ .completion-msg {
1628
+ text-align: center;
1629
+ color: #10a37f;
1630
+ font-weight: 600;
1631
+ padding: 1rem;
1632
+ margin: 1rem 0;
1633
+ background: #f0fdf4;
1634
+ border-radius: 8px;
1635
+ border: 1px solid #86efac;
1636
+ }
1637
+
1638
+ /* Error Message */
1639
+ .error-message {
1640
+ background: #fee2e2;
1641
+ border-left: 4px solid #dc2626;
1642
+ border-radius: 8px;
1643
+ padding: 1rem;
1644
+ margin: 1rem 0;
1645
+ color: #991b1b;
1646
+ }
1647
+
1648
+ .error-message strong {
1649
+ color: #7f1d1d;
1650
+ }
1651
+
1652
+ .error-message a {
1653
+ color: #dc2626;
1654
+ font-weight: 500;
1655
+ }
1656
+
1657
+ /* Round Badge - 淡蓝色背景 */
1658
+ .round-badge {
1659
+ display: inline-block;
1660
+ background: linear-gradient(135deg, #e0f2fe 0%, #bae6fd 100%);
1661
+ color: #0369a1;
1662
+ padding: 0.25rem 0.75rem;
1663
+ border-radius: 999px;
1664
+ font-size: 0.75rem;
1665
+ font-weight: 600;
1666
+ margin: 0.5rem 0;
1667
+ box-shadow: 0 2px 4px rgba(14, 165, 233, 0.15);
1668
+ border: 1px solid #7dd3fc;
1669
+ }
1670
+
1671
+ /* Settings Section */
1672
+ #settings-group {
1673
+ background: transparent !important;
1674
+ border: none !important;
1675
+ padding: 0 !important;
1676
+ box-shadow: none !important;
1677
+ gap: 0 !important;
1678
+ }
1679
+
1680
+ #max-rounds-slider, #auto-scroll-checkbox {
1681
+ background: #f9fafb;
1682
+ border: 1px solid #e5e7eb;
1683
+ border-radius: 6px;
1684
+ padding: 0.75rem;
1685
+ margin-bottom: 0.5rem;
1686
+ }
1687
+
1688
+ .settings-header {
1689
+ display: flex;
1690
+ align-items: center;
1691
+ gap: 0.5rem;
1692
+ margin-bottom: 0.875rem;
1693
+ }
1694
+
1695
+ .settings-title {
1696
+ font-size: 0.875rem;
1697
+ font-weight: 600;
1698
+ color: #374151;
1699
+ }
1700
+
1701
+ .settings-api-row {
1702
+ display: flex;
1703
+ align-items: center;
1704
+ justify-content: space-between;
1705
+ margin-bottom: 0.375rem;
1706
+ }
1707
+
1708
+ .settings-label {
1709
+ font-size: 0.8rem;
1710
+ font-weight: 500;
1711
+ color: #4b5563;
1712
+ }
1713
+
1714
+ .settings-help-link {
1715
+ display: inline-flex;
1716
+ align-items: center;
1717
+ gap: 0.25rem;
1718
+ font-size: 0.7rem;
1719
+ color: #667eea;
1720
+ text-decoration: none;
1721
+ transition: opacity 0.2s;
1722
+ }
1723
+
1724
+ .settings-help-icon {
1725
+ display: inline-flex;
1726
+ align-items: center;
1727
+ justify-content: center;
1728
+ width: 14px;
1729
+ height: 14px;
1730
+ border-radius: 50%;
1731
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
1732
+ color: white;
1733
+ font-size: 0.6rem;
1734
+ font-weight: bold;
1735
+ }
1736
+
1737
+ /* Tools Section */
1738
+ .tools-section {
1739
+ margin-top: 0;
1740
+ }
1741
+
1742
+ .tools-title {
1743
+ font-size: 0.875rem;
1744
+ font-weight: 600;
1745
+ color: #374151;
1746
+ margin-bottom: 0.5rem;
1747
+ margin-top: 0;
1748
+ }
1749
+
1750
+ /* Tool Item */
1751
+ .tool-item {
1752
+ background: #f9fafb;
1753
+ padding: 0.75rem;
1754
+ border-radius: 6px;
1755
+ margin-bottom: 0.5rem;
1756
+ border: 1px solid #e5e7eb;
1757
+ }
1758
+
1759
+ .tool-item strong {
1760
+ color: #374151;
1761
+ font-size: 0.85rem;
1762
+ }
1763
+
1764
+ .tool-item span {
1765
+ color: #6b7280;
1766
+ font-size: 0.8rem;
1767
+ }
1768
+
1769
+ /* Examples Section */
1770
+ .examples-section {
1771
+ margin-top: -0.5rem;
1772
+ }
1773
+
1774
+ .examples-title {
1775
+ font-size: 0.875rem;
1776
+ font-weight: 600;
1777
+ color: #374151;
1778
+ margin-bottom: 0.5rem;
1779
+ }
1780
+
1781
+ /* Example Carousel */
1782
+ .example-carousel {
1783
+ background: white;
1784
+ border-radius: 8px;
1785
+ padding: 1rem;
1786
+ border: 1px solid #e5e7eb;
1787
+ }
1788
+
1789
+ .carousel-container {
1790
+ position: relative;
1791
+ min-height: 60px;
1792
+ margin-bottom: 0.75rem;
1793
+ }
1794
+
1795
+ .carousel-item {
1796
+ display: none;
1797
+ opacity: 0;
1798
+ transition: opacity 0.3s ease;
1799
+ }
1800
+
1801
+ .carousel-item.active {
1802
+ display: block;
1803
+ opacity: 1;
1804
+ }
1805
+
1806
+ .carousel-item-text {
1807
+ background: linear-gradient(135deg, #f0f4ff 0%, #e8eeff 100%);
1808
+ padding: 1rem;
1809
+ border-radius: 6px;
1810
+ color: #374151;
1811
+ font-size: 0.875rem;
1812
+ line-height: 1.5;
1813
+ border: 1px solid #e0e7ff;
1814
+ }
1815
+
1816
+ .carousel-controls {
1817
+ display: flex;
1818
+ align-items: center;
1819
+ justify-content: center;
1820
+ gap: 1rem;
1821
+ }
1822
+
1823
+ .carousel-btn {
1824
+ cursor: pointer;
1825
+ width: 32px;
1826
+ height: 32px;
1827
+ border-radius: 50%;
1828
+ background: #f3f4f6;
1829
+ display: flex;
1830
+ align-items: center;
1831
+ justify-content: center;
1832
+ font-size: 1.25rem;
1833
+ color: #6b7280;
1834
+ transition: all 0.2s ease;
1835
+ user-select: none;
1836
+ }
1837
+
1838
+ .carousel-btn:hover {
1839
+ background: #667eea;
1840
+ color: white;
1841
+ }
1842
+
1843
+ .carousel-indicators {
1844
+ display: flex;
1845
+ gap: 0.5rem;
1846
+ }
1847
+
1848
+ .carousel-dot {
1849
+ width: 8px;
1850
+ height: 8px;
1851
+ border-radius: 50%;
1852
+ background: #d1d5db;
1853
+ cursor: pointer;
1854
+ transition: all 0.2s ease;
1855
+ }
1856
+
1857
+ .carousel-dot.active {
1858
+ background: #667eea;
1859
+ width: 24px;
1860
+ border-radius: 4px;
1861
+ }
1862
+
1863
+ /* Welcome Message */
1864
+ .welcome-container {
1865
+ text-align: center;
1866
+ padding: 3rem 2rem;
1867
+ }
1868
+
1869
+ .welcome-title {
1870
+ font-size: 2rem;
1871
+ font-weight: 700;
1872
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
1873
+ -webkit-background-clip: text;
1874
+ -webkit-text-fill-color: transparent;
1875
+ background-clip: text;
1876
+ margin-bottom: 1rem;
1877
+ }
1878
+
1879
+ .welcome-subtitle {
1880
+ color: #6b7280;
1881
+ font-size: 1.1rem;
1882
+ margin-bottom: 2rem;
1883
+ }
1884
+
1885
+ /* Footer */
1886
+ .footer-container {
1887
+ text-align: center;
1888
+ padding: 1.5rem;
1889
+ color: #9ca3af;
1890
+ font-size: 0.875rem;
1891
+ border-top: 1px solid #e5e7eb;
1892
+ margin-top: 2rem;
1893
+ }
1894
+
1895
+ .footer-container a {
1896
+ color: #667eea;
1897
+ text-decoration: none;
1898
+ }
1899
+
1900
+ .footer-container a:hover {
1901
+ text-decoration: underline;
1902
+ }
1903
+
1904
+ /* Disclaimer */
1905
+ .disclaimer {
1906
+ text-align: center;
1907
+ padding: 1rem;
1908
+ color: #6b7280;
1909
+ font-size: 0.875rem;
1910
+ border-top: 1px solid #e5e7eb;
1911
+ margin-top: 1rem;
1912
+ }
1913
+
1914
+ /* ========== 深色模式适配 ========== */
1915
+ @media (prefers-color-scheme: dark) {
1916
+ /* Settings 区域 */
1917
+ #settings-group {
1918
+ background: transparent !important;
1919
+ border: none !important;
1920
+ padding: 0 !important;
1921
+ gap: 0 !important;
1922
+ }
1923
+
1924
+ #max-rounds-slider, #auto-scroll-checkbox {
1925
+ background: #1f2937 !important;
1926
+ border: 1px solid #374151 !important;
1927
+ border-radius: 6px !important;
1928
+ padding: 0.75rem !important;
1929
+ }
1930
+
1931
+ .settings-title,
1932
+ .tools-title,
1933
+ .examples-title {
1934
+ color: #e5e7eb !important;
1935
+ }
1936
+
1937
+ .settings-label {
1938
+ color: #9ca3af !important;
1939
+ }
1940
+
1941
+ .settings-help-link {
1942
+ color: #818cf8 !important;
1943
+ }
1944
+
1945
+ /* Available Tools 区域 */
1946
+ .tool-item {
1947
+ background: #1f2937 !important;
1948
+ border-color: #374151 !important;
1949
+ }
1950
+
1951
+ .tool-item strong {
1952
+ color: #e5e7eb !important;
1953
+ }
1954
+
1955
+ .tool-item span {
1956
+ color: #9ca3af !important;
1957
+ }
1958
+
1959
+ /* Example Carousel 区域 */
1960
+ .example-carousel {
1961
+ background: #1f2937 !important;
1962
+ border-color: #374151 !important;
1963
+ }
1964
+
1965
+ .carousel-item-text {
1966
+ background: linear-gradient(135deg, #1e3a5f 0%, #1e40af 100%) !important;
1967
+ border-color: #3b82f6 !important;
1968
+ color: #e0f2fe !important;
1969
+ }
1970
+
1971
+ .carousel-btn {
1972
+ background: #374151 !important;
1973
+ color: #9ca3af !important;
1974
+ }
1975
+
1976
+ .carousel-btn:hover {
1977
+ background: #667eea !important;
1978
+ color: white !important;
1979
+ }
1980
+
1981
+ .carousel-dot {
1982
+ background: #4b5563 !important;
1983
+ }
1984
+
1985
+ .carousel-dot.active {
1986
+ background: #667eea !important;
1987
+ }
1988
+
1989
+ /* Output area 深色模式 */
1990
+ #output-area,
1991
+ #output-area.output-box,
1992
+ div#output-area {
1993
+ background: #111827 !important;
1994
+ border: 1px solid #374151 !important;
1995
+ }
1996
+
1997
+ #output-area::-webkit-scrollbar-track {
1998
+ background: #1f2937 !important;
1999
+ }
2000
+
2001
+ #output-area::-webkit-scrollbar-thumb {
2002
+ background: #4b5563 !important;
2003
+ }
2004
+
2005
+ #output-area::-webkit-scrollbar-thumb:hover {
2006
+ background: #6b7280 !important;
2007
+ }
2008
+
2009
+ /* Tool call card 深色模式 */
2010
+ .tool-call-card {
2011
+ background: #1f2937 !important;
2012
+ }
2013
+
2014
+ .tool-name {
2015
+ color: #e5e7eb !important;
2016
+ }
2017
+
2018
+ .tool-detail {
2019
+ color: #9ca3af !important;
2020
+ }
2021
+
2022
+ /* Result card 深色模式 */
2023
+ .result-card-expanded {
2024
+ background: #1f2937 !important;
2025
+ }
2026
+
2027
+ .result-header-expanded {
2028
+ color: #e5e7eb !important;
2029
+ border-bottom-color: #374151 !important;
2030
+ }
2031
+
2032
+ .result-content-expanded {
2033
+ color: #d1d5db !important;
2034
+ }
2035
+
2036
+ /* Thinking 深色模式 */
2037
+ .thinking-collapsed {
2038
+ background: #1f2937 !important;
2039
+ border-color: #374151 !important;
2040
+ }
2041
+
2042
+ .thinking-collapsed summary {
2043
+ color: #9ca3af !important;
2044
+ }
2045
+
2046
+ .thinking-content {
2047
+ color: #d1d5db !important;
2048
+ }
2049
+
2050
+ .thinking-streaming {
2051
+ background: #0c4a6e !important;
2052
+ border-color: #0369a1 !important;
2053
+ color: #bae6fd !important;
2054
+ }
2055
+
2056
+ /* Answer section 深色模式 */
2057
+ .answer-section {
2058
+ background: linear-gradient(135deg, #0c4a6e 0%, #164e63 100%) !important;
2059
+ }
2060
+
2061
+ .answer-section p {
2062
+ color: #e0f2fe !important;
2063
+ }
2064
+
2065
+ .answer-section strong {
2066
+ color: #f0f9ff !important;
2067
+ }
2068
+
2069
+ /* 用户问题气泡深色模式 */
2070
+ .user-message-bubble {
2071
+ background: linear-gradient(135deg, #1e3a5f 0%, #1e40af 100%) !important;
2072
+ color: #e0f2fe !important;
2073
+ border-color: #3b82f6 !important;
2074
+ box-shadow: 0 2px 8px rgba(59, 130, 246, 0.3) !important;
2075
+ }
2076
+
2077
+ .user-message-content {
2078
+ color: #e0f2fe !important;
2079
+ }
2080
+
2081
+ /* Round Badge 深色模式 */
2082
+ .round-badge {
2083
+ background: linear-gradient(135deg, #1e3a5f 0%, #1e40af 100%) !important;
2084
+ color: #93c5fd !important;
2085
+ border-color: #3b82f6 !important;
2086
+ box-shadow: 0 2px 4px rgba(59, 130, 246, 0.2) !important;
2087
+ }
2088
+
2089
+ /* 搜索结果卡片深色模式 */
2090
+ .search-result-card {
2091
+ background: #1f2937 !important;
2092
+ border-color: #374151 !important;
2093
+ }
2094
+
2095
+ .search-result-card:hover {
2096
+ border-color: #667eea !important;
2097
+ }
2098
+
2099
+ /* 工具结果标题区域深色模式 */
2100
+ .result-card-expanded div[style*="background: linear-gradient"] {
2101
+ background: linear-gradient(135deg, #1e3a5f 0%, #1e40af 100%) !important;
2102
+ border-color: #3b82f6 !important;
2103
+ }
2104
+
2105
+ .result-card-expanded div[style*="background: linear-gradient"] span[style*="color: #1e40af"],
2106
+ .result-card-expanded div[style*="background: linear-gradient"] a[style*="color: #1e40af"] {
2107
+ color: #93c5fd !important;
2108
+ }
2109
+
2110
+ .result-card-expanded div[style*="color: #64748b"] {
2111
+ color: #9ca3af !important;
2112
+ }
2113
+
2114
+ /* 完成消息深色模式 */
2115
+ .completion-msg {
2116
+ background: #064e3b !important;
2117
+ border-color: #059669 !important;
2118
+ color: #6ee7b7 !important;
2119
+ }
2120
+
2121
+ /* 错误消息深色模式 */
2122
+ .error-message {
2123
+ background: #450a0a !important;
2124
+ border-color: #b91c1c !important;
2125
+ color: #fca5a5 !important;
2126
+ }
2127
+
2128
+ /* 侧边栏标题深色模式 */
2129
+ div[style*="font-weight: 600"][style*="color: #374151"] {
2130
+ color: #e5e7eb !important;
2131
+ }
2132
+
2133
+ /* Disclaimer 深色模式 */
2134
+ .disclaimer {
2135
+ color: #9ca3af !important;
2136
+ border-color: #374151 !important;
2137
+ }
2138
+ }
2139
+
2140
+ /* Gradio 深色主题类名适配 */
2141
+ .dark #settings-group,
2142
+ .dark .tool-item,
2143
+ .dark .example-carousel {
2144
+ background: #1f2937 !important;
2145
+ border-color: #374151 !important;
2146
+ }
2147
+
2148
+ .dark .settings-title,
2149
+ .dark .tools-title,
2150
+ .dark .examples-title,
2151
+ .dark .tool-item strong,
2152
+ .dark .result-header-expanded,
2153
+ .dark .tool-name {
2154
+ color: #e5e7eb !important;
2155
+ }
2156
+
2157
+ .dark .settings-label,
2158
+ .dark .tool-item span,
2159
+ .dark .tool-detail,
2160
+ .dark .thinking-collapsed summary {
2161
+ color: #9ca3af !important;
2162
+ }
2163
+
2164
+ .dark .settings-help-link {
2165
+ color: #818cf8 !important;
2166
+ }
2167
+
2168
+ .dark .carousel-item-text {
2169
+ background: linear-gradient(135deg, #1e3a5f 0%, #1e40af 100%) !important;
2170
+ border-color: #3b82f6 !important;
2171
+ color: #e0f2fe !important;
2172
+ }
2173
+
2174
+ .dark #output-area,
2175
+ .dark #output-area.output-box,
2176
+ .dark div#output-area {
2177
+ background: #111827 !important;
2178
+ border: 1px solid #374151 !important;
2179
+ }
2180
+
2181
+ .dark .tool-call-card,
2182
+ .dark .result-card-expanded,
2183
+ .dark .thinking-collapsed {
2184
+ background: #1f2937 !important;
2185
+ border-color: #374151 !important;
2186
+ }
2187
+
2188
+ .dark .result-content-expanded,
2189
+ .dark .thinking-content {
2190
+ color: #d1d5db !important;
2191
+ }
2192
+
2193
+ .dark .answer-section {
2194
+ background: linear-gradient(135deg, #0c4a6e 0%, #164e63 100%) !important;
2195
+ }
2196
+
2197
+ .dark .answer-section p {
2198
+ color: #e0f2fe !important;
2199
+ }
2200
+
2201
+ .dark .search-result-card {
2202
+ background: #1f2937 !important;
2203
+ border-color: #374151 !important;
2204
+ }
2205
+
2206
+ .dark .completion-msg {
2207
+ background: #064e3b !important;
2208
+ border-color: #059669 !important;
2209
+ color: #6ee7b7 !important;
2210
+ }
2211
+
2212
+ /* 用户问题气泡 Gradio dark 模式 */
2213
+ .dark .user-message-bubble {
2214
+ background: linear-gradient(135deg, #1e3a5f 0%, #1e40af 100%) !important;
2215
+ color: #e0f2fe !important;
2216
+ border-color: #3b82f6 !important;
2217
+ box-shadow: 0 2px 8px rgba(59, 130, 246, 0.3) !important;
2218
+ }
2219
+
2220
+ .dark .user-message-content {
2221
+ color: #e0f2fe !important;
2222
+ }
2223
+
2224
+ /* Round Badge Gradio dark 模式 */
2225
+ .dark .round-badge {
2226
+ background: linear-gradient(135deg, #1e3a5f 0%, #1e40af 100%) !important;
2227
+ color: #93c5fd !important;
2228
+ border-color: #3b82f6 !important;
2229
+ box-shadow: 0 2px 4px rgba(59, 130, 246, 0.2) !important;
2230
+ }
2231
+
2232
+ /* 工具结果标题 Gradio dark 模式 */
2233
+ .dark .result-card-expanded div[style*="background: linear-gradient"] {
2234
+ background: linear-gradient(135deg, #1e3a5f 0%, #1e40af 100%) !important;
2235
+ border-color: #3b82f6 !important;
2236
+ }
2237
+
2238
+ /* Disclaimer Gradio dark 模式 */
2239
+ .dark .disclaimer {
2240
+ color: #9ca3af !important;
2241
+ border-color: #374151 !important;
2242
+ }
2243
+ /* Reference Section */
2244
+ .reference-section {
2245
+ margin-top: 40px;
2246
+ border-top: 1px solid #e5e7eb;
2247
+ padding-top: 20px;
2248
+ }
2249
+ .reference-title {
2250
+ font-size: 1.2rem;
2251
+ font-weight: 600;
2252
+ margin-bottom: 16px;
2253
+ color: #111827;
2254
+ }
2255
+ .reference-item {
2256
+ display: block;
2257
+ background-color: #fff;
2258
+ border: 1px solid #e5e7eb;
2259
+ border-radius: 12px;
2260
+ padding: 12px 16px;
2261
+ margin-bottom: 12px;
2262
+ text-decoration: none;
2263
+ color: #374151;
2264
+ transition: all 0.2s;
2265
+ box-shadow: 0 1px 2px rgba(0,0,0,0.05);
2266
+ }
2267
+ .reference-item:hover {
2268
+ border-color: #3b82f6;
2269
+ box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1);
2270
+ transform: translateY(-1px);
2271
+ text-decoration: none;
2272
+ }
2273
+ .ref-number {
2274
+ display: inline-block;
2275
+ background-color: #eff6ff;
2276
+ color: #2563eb;
2277
+ font-weight: 600;
2278
+ padding: 2px 6px;
2279
+ border-radius: 6px;
2280
+ margin-right: 8px;
2281
+ font-size: 0.85em;
2282
+ }
2283
+ .ref-text {
2284
+ font-weight: 500;
2285
+ color: #1f2937;
2286
+ }
2287
+ .ref-url {
2288
+ display: block;
2289
+ margin-top: 4px;
2290
+ font-size: 0.8em;
2291
+ color: #6b7280;
2292
+ overflow: hidden;
2293
+ text-overflow: ellipsis;
2294
+ white-space: nowrap;
2295
+ }
2296
+
2297
+ /* 深色模式适配 Reference Section */
2298
+ @media (prefers-color-scheme: dark) {
2299
+ .reference-title {
2300
+ color: #e5e7eb !important;
2301
+ }
2302
+ .reference-item {
2303
+ background-color: #1f2937 !important;
2304
+ border-color: #374151 !important;
2305
+ color: #d1d5db !important;
2306
+ }
2307
+ .reference-item:hover {
2308
+ border-color: #667eea !important;
2309
+ }
2310
+ .ref-number {
2311
+ background-color: #1e3a5f !important;
2312
+ color: #93c5fd !important;
2313
+ }
2314
+ .ref-text {
2315
+ color: #e5e7eb !important;
2316
+ }
2317
+ .ref-url {
2318
+ color: #9ca3af !important;
2319
+ }
2320
+ }
2321
+
2322
+ /* Gradio dark 模式适配 Reference Section */
2323
+ .dark .reference-title {
2324
+ color: #e5e7eb !important;
2325
+ }
2326
+ .dark .reference-item {
2327
+ background-color: #1f2937 !important;
2328
+ border-color: #374151 !important;
2329
+ color: #d1d5db !important;
2330
+ }
2331
+ .dark .reference-item:hover {
2332
+ border-color: #667eea !important;
2333
+ }
2334
+ .dark .ref-number {
2335
+ background-color: #1e3a5f !important;
2336
+ color: #93c5fd !important;
2337
+ }
2338
+ .dark .ref-text {
2339
+ color: #e5e7eb !important;
2340
+ }
2341
+ .dark .ref-url {
2342
+ color: #9ca3af !important;
2343
+ }
2344
+ """
2345
+
2346
+ with gr.Blocks(css=INLINE_CSS, theme=gr.themes.Soft(), js=CAROUSEL_JS) as demo:
2347
+ # Header with logo and title images - convert to base64 for proper rendering
2348
+ # Files are in the same directory as app.py (test1/)
2349
+ logo_path = os.path.join(script_dir, "or-logo1.png")
2350
+ title_path = os.path.join(script_dir, "openresearcher-title.svg")
2351
+
2352
+ logo_base64 = image_to_base64(logo_path)
2353
+ title_base64 = image_to_base64(title_path)
2354
+
2355
+ # Build header HTML with base64 images
2356
+ header_html = f"""
2357
+ <div style="
2358
+ text-align: center;
2359
+ padding: 0.5rem 1rem 0.5rem 1rem;
2360
+ background: transparent;
2361
+ display: flex;
2362
+ flex-direction: row;
2363
+ align-items: center;
2364
+ justify-content: center;
2365
+ gap: 1.5rem;
2366
+ ">
2367
+ """
2368
+
2369
+ if logo_base64:
2370
+ header_html += f'<img src="{logo_base64}" alt="OpenResearcher Logo" style="height: 84px;">'
2371
+ if title_base64:
2372
+ header_html += f'<img src="{title_base64}" alt="OpenResearcher" style="height: 84px;">'
2373
+
2374
+ header_html += "</div>"
2375
+
2376
+ gr.HTML(header_html)
2377
+
2378
+ gr.HTML("""
2379
+ <div style="display: flex; gap: 0px; justify-content: center; flex-wrap: wrap; margin-top: 0px; margin-bottom: 24px;">
2380
+ <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">
2381
+ <img src="https://img.shields.io/badge/Blog-4285F4?style=for-the-badge&logo=google-chrome&logoColor=white" alt="Blog" style="height: 28px;">
2382
+ </a>
2383
+ <a href="https://github.com/TIGER-AI-Lab/OpenResearcher" target="_blank">
2384
+ <img src="https://img.shields.io/badge/Github-181717?style=for-the-badge&logo=github&logoColor=white" alt="Github" style="height: 28px;">
2385
+ </a>
2386
+ <a href="https://huggingface.co/datasets/OpenResearcher/OpenResearcher-Dataset" target="_blank">
2387
+ <img src="https://img.shields.io/badge/Dataset-FFB7B2?style=for-the-badge&logo=huggingface&logoColor=ffffff" alt="Dataset" style="height: 28px;">
2388
+ </a>
2389
+ <a href="https://huggingface.co/OpenResearcher/Nemotron-3-Nano-30B-A3B" target="_blank">
2390
+ <img src="https://img.shields.io/badge/Model-FFD966?style=for-the-badge&logo=huggingface&logoColor=ffffff" alt="Model" style="height: 28px;">
2391
+ </a>
2392
+ <a href="https://huggingface.co/datasets/OpenResearcher/OpenResearcher-Eval-Logs/tree/main" target="_blank">
2393
+ <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;">
2394
+ </a>
2395
+ </div>
2396
+ """)
2397
+
2398
+ # Main layout: Left sidebar + Right content
2399
+ with gr.Row():
2400
+ # Left Sidebar (settings & tools)
2401
+ with gr.Column(scale=1, min_width=280):
2402
+ # API Settings in a unified box
2403
+ with gr.Group(elem_id="settings-group"):
2404
+ gr.HTML('''
2405
+ <div class="settings-header">
2406
+ <span class="settings-title">⚙️ Settings</span>
2407
+ </div>
2408
+ ''')
2409
+ serper_input = gr.Textbox(
2410
+ label="",
2411
+ value=SERPER_API_KEY,
2412
+ type="password",
2413
+ placeholder="Enter your Serper API key...",
2414
+ show_label=False,
2415
+ elem_id="serper-api-input",
2416
+ container=False,
2417
+ visible=False
2418
+ )
2419
+ max_rounds_input = gr.Slider(
2420
+ minimum=1,
2421
+ maximum=200,
2422
+ value=50,
2423
+ step=1,
2424
+ label="Max Rounds",
2425
+ elem_id="max-rounds-slider"
2426
+ )
2427
+ auto_scroll_checkbox = gr.Checkbox(
2428
+ label="Auto Scroll",
2429
+ value=True,
2430
+ elem_id="auto-scroll-checkbox",
2431
+ interactive=True
2432
+ )
2433
+
2434
+ # Tools info
2435
+ gr.HTML("""
2436
+ <div class="tools-section">
2437
+ <div class="tools-title">🛠️ Available Tools</div>
2438
+ <div class="tool-item"><strong>browser.search</strong><br><span>Search the web</span></div>
2439
+ <div class="tool-item"><strong>browser.open</strong><br><span>Open & read pages</span></div>
2440
+ <div class="tool-item"><strong>browser.find</strong><br><span>Find text in page</span></div>
2441
+ </div>
2442
+ """)
2443
+
2444
+ # Example carousel with navigation
2445
+ gr.HTML("""
2446
+ <div class="examples-section">
2447
+ <div class="examples-title">💡 Try Examples</div>
2448
+ <div class="example-carousel" id="example-carousel">
2449
+ <div class="carousel-container">
2450
+ <div class="carousel-item active" data-index="0" data-text="Who won the Nobel Prize in Physics 2024?">
2451
+ <div class="carousel-item-text">🏆 Who won the Nobel Prize in Physics 2024?</div>
2452
+ </div>
2453
+ <div class="carousel-item" data-index="1" data-text="What are the latest breakthroughs in quantum computing in 2024?">
2454
+ <div class="carousel-item-text">🔬 What are the latest breakthroughs in quantum computing in 2024?</div>
2455
+ </div>
2456
+ <div class="carousel-item" data-index="2" data-text="What are the new features in Python 3.12?">
2457
+ <div class="carousel-item-text">🐍 What are the new features in Python 3.12?</div>
2458
+ </div>
2459
+ </div>
2460
+ <div class="carousel-controls">
2461
+ <div class="carousel-btn" id="prev-btn">‹</div>
2462
+ <div class="carousel-indicators">
2463
+ <div class="carousel-dot active" data-index="0"></div>
2464
+ <div class="carousel-dot" data-index="1"></div>
2465
+ <div class="carousel-dot" data-index="2"></div>
2466
+ </div>
2467
+ <div class="carousel-btn" id="next-btn">›</div>
2468
+ </div>
2469
+ </div>
2470
+ </div>
2471
+ """)
2472
+
2473
+ # Main content area (30-70)
2474
+ with gr.Column(scale=3, elem_id="main-content"):
2475
+ # Output area (on top, hidden initially)
2476
+ output_area = gr.HTML(
2477
+ value="",
2478
+ elem_classes=["output-box"],
2479
+ elem_id="output-area",
2480
+ visible=True
2481
+ )
2482
+
2483
+ # Welcome message (will be hidden after first search)
2484
+ welcome_html = gr.HTML(
2485
+ value="""
2486
+ <div id="welcome-section" class="welcome-section">
2487
+ <h2>What Would You Like to Research?</h2>
2488
+ <p>I am OpenResearcher, a leading open-source Deep Research Agent, welcome to try!</p>
2489
+ <p style="color: red;">Due to high traffic, if your submission has no response, please refresh the page and resubmit. Thank you!</p>
2490
+ </div>
2491
+ """,
2492
+ elem_id="welcome-container"
2493
+ )
2494
+
2495
+ # Input area at bottom
2496
+ question_input = gr.Textbox(
2497
+ label="",
2498
+ placeholder="Ask me anything and I'll handle the rest...",
2499
+ lines=2,
2500
+ show_label=False,
2501
+ elem_id="question-input",
2502
+ autofocus=True
2503
+ )
2504
+
2505
+ with gr.Row(elem_id="button-row"):
2506
+ submit_btn = gr.Button(
2507
+ "🔍 Start DeepResearch",
2508
+ variant="primary",
2509
+ elem_classes=["primary-btn"],
2510
+ scale=3
2511
+ )
2512
+ stop_btn = gr.Button("⏹ Stop", variant="stop", scale=1)
2513
+ clear_btn = gr.Button("🗑 Clear", scale=1)
2514
+
2515
+ # Function to hide welcome and show output
2516
+ async def start_research(question, serper_key, max_rounds):
2517
+ # Generator that first hides welcome, then streams results
2518
+ # Also clears the input box for the next question
2519
+
2520
+ # Initial yield to immediately clear welcome, show loading in output, and clear input
2521
+ # IMPORTANT: Don't use empty string for output, or JS will hide the output area!
2522
+ yield "", '<div style="text-align: center; padding: 2rem; color: #6b7280;">Delving into it...</div>', ""
2523
+
2524
+ async for result in run_agent_streaming(question, serper_key, max_rounds):
2525
+ yield "", result, ""
2526
+
2527
+ # Event handlers
2528
+ submit_event = submit_btn.click(
2529
+ fn=start_research,
2530
+ inputs=[question_input, serper_input, max_rounds_input],
2531
+ outputs=[welcome_html, output_area, question_input],
2532
+ show_progress="hidden",
2533
+ concurrency_limit=20
2534
+ )
2535
+
2536
+ question_input.submit(
2537
+ fn=start_research,
2538
+ inputs=[question_input, serper_input, max_rounds_input],
2539
+ outputs=[welcome_html, output_area, question_input],
2540
+ show_progress="hidden",
2541
+ concurrency_limit=20
2542
+ )
2543
+
2544
+ stop_btn.click(fn=None, inputs=None, outputs=None, cancels=[submit_event])
2545
+ clear_btn.click(
2546
+ fn=lambda: ("""
2547
+ <div id="welcome-section" class="welcome-section">
2548
+ <h2>What would you like to research?</h2>
2549
+ <p>Ask any question and I'll search the web to find answers</p>
2550
+ </div>
2551
+ """, "", ""),
2552
+ outputs=[welcome_html, output_area, question_input]
2553
+ )
2554
+
2555
+ # Disclaimer
2556
+ gr.HTML('''
2557
+ <div class="disclaimer">
2558
+ ⚠️ AI may generate incorrect information or citations. Please double-check important facts.
2559
+ </div>
2560
+ ''')
2561
+
2562
+ return demo
2563
+
2564
+ if __name__ == "__main__":
2565
+ print("="*60)
2566
+ print("OpenResearcher DeepSearch Agent - ZeroGPU Space")
2567
+ print("="*60)
2568
+ demo = create_interface()
2569
+ demo.queue(default_concurrency_limit=20).launch()
openresearcher-title.svg ADDED
requirements.txt ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ gradio>=4.0.0
2
+ transformers<5.0.0
3
+ torch>=2.0.0
4
+ httpx
5
+ spaces
6
+ # OpenResearcher DeepSearch Agent - Hugging Face Space
7
+ bitsandbytes
8
+ sentencepiece
9
+ protobuf
10
+ json5
11
+ accelerate