lvwerra HF Staff commited on
Commit
424c8a9
·
1 Parent(s): 0eebd6d
REFACTORING_COMPLETE.md ADDED
@@ -0,0 +1,184 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Research Refactoring Complete!
2
+
3
+ ## Summary of Changes
4
+
5
+ All files have been completely refactored to implement the new research logic with parallel processing, interleaved results, query grouping, and statistics tracking.
6
+
7
+ ## Backend Changes
8
+
9
+ ### 1. main.py
10
+ - Added `research_parallel_workers` to `ChatRequest` model
11
+ - Updated `stream_research_notebook()` to accept and pass parallel_workers parameter (default: 8)
12
+ - Passes parallel_workers through to `stream_research()`
13
+
14
+ ### 2. research.py (Complete Rewrite)
15
+ **New Flow:**
16
+ 1. Generate queries (sequential)
17
+ 2. Search ALL queries in parallel (3 workers)
18
+ 3. **Interleave** URLs from all queries (round-robin)
19
+ 4. **Process URLs in parallel** (configurable workers, default 8)
20
+ - Each worker: extract_content() + analyze_content()
21
+ 5. Track stats per query (relevant/irrelevant/error)
22
+ 6. Stream results grouped by query
23
+ 7. Assess completeness
24
+ 8. Generate final report
25
+
26
+ **New Event Types:**
27
+ - `source` event now includes:
28
+ - `query_index`: which query this belongs to
29
+ - `query_text`: the actual query
30
+ - `is_error`: boolean for failed extractions
31
+ - `error_message`: error details
32
+
33
+ - `query_stats` event (NEW):
34
+ - `query_index`: which query
35
+ - `relevant_count`: # of relevant sources
36
+ - `irrelevant_count`: # of irrelevant sources
37
+ - `error_count`: # of failed requests
38
+
39
+ ## Frontend Changes
40
+
41
+ ### 3. index.html
42
+ - Added "RESEARCH PARALLEL WORKERS" setting field
43
+ - Type: number input (1-20)
44
+ - Default placeholder: 8
45
+
46
+ ### 4. script.js
47
+ - Added `researchParallelWorkers` to settings object
48
+ - Sends `research_parallel_workers` in API requests
49
+ - Updated source event handler to pass full data object
50
+ - Added `query_stats` event handler
51
+
52
+ ### 5. research-ui.js (Complete Rewrite)
53
+ **New Structure:**
54
+ - Maintains `queryData` object: `query_index -> {query, sources[], stats{}}`
55
+ - Creates query groups with headers showing query text and stats
56
+ - Sources are grouped under their respective queries
57
+ - **Toggle button** to show/hide irrelevant sources
58
+ - Sources display status icons: ✓ (relevant), ○ (irrelevant), ✗ (error)
59
+
60
+ **Key Functions:**
61
+ - `createQueriesMessage()`: Creates query group structure
62
+ - `createSourceMessage()`: Adds source to appropriate query group
63
+ - `updateQueryStats()`: Updates stats display per query
64
+ - `renderQuerySources()`: Re-renders sources based on toggle state
65
+ - `toggleIrrelevantSources()`: Global toggle for showing/hiding irrelevant
66
+
67
+ ### 6. style.css
68
+ **New Styles:**
69
+ - `.toggle-irrelevant-btn`: Button in research header
70
+ - `.query-group`: Container for each query and its sources
71
+ - `.query-header`: Query text + stats display
72
+ - `.query-stats`: Live stats (X relevant / Y not relevant / Z failed)
73
+ - `.query-sources`: Container for sources under query
74
+ - `.source-status-icon`: Icons with color coding
75
+ - `.research-source.error`: Red background for failed requests
76
+ - `.no-sources`: Message when no sources to display
77
+
78
+ ## New Features
79
+
80
+ ### 1. Parallel Processing
81
+ - Configurable parallel workers (default: 8)
82
+ - Interleaved URL processing ensures progress across all queries
83
+ - Much faster than sequential processing
84
+
85
+ ### 2. Query Grouping
86
+ - Sources are visually grouped under their originating query
87
+ - Makes it clear which query found which information
88
+ - Helps understand coverage
89
+
90
+ ### 3. Statistics Tracking
91
+ - Per-query stats: relevant / not relevant / failed
92
+ - Live updates as sources are processed
93
+ - Green color for queries with relevant sources
94
+
95
+ ### 4. Error Tracking
96
+ - Failed API calls are tracked and displayed
97
+ - Separate count in stats
98
+ - Red error indicator (✗) on failed sources
99
+ - Error messages preserved
100
+
101
+ ### 5. Toggle for Irrelevant Sources
102
+ - Button in research header
103
+ - Default: hide irrelevant sources
104
+ - Click to show all sources including irrelevant
105
+ - Re-renders all query groups on toggle
106
+
107
+ ## Diagram: New Parallel Flow
108
+
109
+ ```
110
+ Research Flow - NEW (Parallel & Interleaved)
111
+ ============================================
112
+
113
+ ITERATION 1:
114
+
115
+ ├─ Generate Queries (Sequential - Main Model)
116
+ │ └─ Returns: ["query1", "query2", "query3"]
117
+
118
+ ├─ Web Search (PARALLEL - 3 workers) ⚡
119
+ │ ├─ search_web(query1) ──┐
120
+ │ ├─ search_web(query2) ──┼─→ All results collected
121
+ │ └─ search_web(query3) ──┘
122
+ │ Results grouped by query_index
123
+
124
+ ├─ Interleave URLs (Round-robin from all queries)
125
+ │ [query1_url1, query2_url1, query3_url1,
126
+ │ query1_url2, query2_url2, query3_url2, ...]
127
+
128
+ ├─ Process URLs (PARALLEL - 8 workers) ⚡⚡⚡
129
+ │ Worker 1: extract + analyze (query1_url1)
130
+ │ Worker 2: extract + analyze (query2_url1)
131
+ │ Worker 3: extract + analyze (query3_url1)
132
+ │ Worker 4: extract + analyze (query1_url2)
133
+ │ Worker 5: extract + analyze (query2_url2)
134
+ │ ...
135
+ │ │
136
+ │ └─ As each completes:
137
+ │ ├─ Emit "source" event with query_index
138
+ │ ├─ Update query stats
139
+ │ └─ Emit "query_stats" event
140
+
141
+ ├─ Assess Completeness (Sequential - Main Model)
142
+
143
+ └─ If not sufficient → ITERATION 2
144
+
145
+ FINAL REPORT (Sequential - Main Model)
146
+
147
+
148
+ PERFORMANCE:
149
+ - Search phase: 3x faster (parallel searches)
150
+ - Analysis phase: 8x faster (8 parallel workers)
151
+ - Interleaving ensures balanced progress across queries
152
+ ```
153
+
154
+ ## Testing Checklist
155
+
156
+ - [ ] Settings page loads with new parallel workers field
157
+ - [ ] Settings save/load correctly
158
+ - [ ] Research creates single container with toggle button
159
+ - [ ] Queries are grouped with headers
160
+ - [ ] Sources appear under correct query
161
+ - [ ] Stats update live (X relevant / Y not relevant / Z failed)
162
+ - [ ] Toggle button works (show/hide irrelevant)
163
+ - [ ] Relevant sources show ✓ icon
164
+ - [ ] Irrelevant sources show ○ icon and are hidden by default
165
+ - [ ] Failed sources show ✗ icon and count in stats
166
+ - [ ] Final report appears below research container
167
+ - [ ] Report markdown renders correctly (tables, headings, etc.)
168
+ - [ ] Result appears in command center action widget
169
+
170
+ ## Performance Expectations
171
+
172
+ With 3 queries × 5 URLs per query = 15 URLs:
173
+
174
+ **Old (Sequential):**
175
+ - Search: ~3 seconds per query × 3 = 9s
176
+ - Analysis: ~2 seconds per URL × 15 = 30s
177
+ - **Total: ~39 seconds**
178
+
179
+ **New (Parallel with 8 workers):**
180
+ - Search: ~3 seconds (all parallel) = 3s
181
+ - Analysis: ~2 seconds × 2 batches (15/8) = 4s
182
+ - **Total: ~7 seconds**
183
+
184
+ **Speedup: ~5.5x faster!**
RESEARCH_REFACTOR_PLAN.md ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Research Refactoring Plan
2
+
3
+ ## Current Flow
4
+ 1. Generate queries (sequential)
5
+ 2. For each query: search (parallel), then for each result: extract+analyze (sequential)
6
+ 3. Assess completeness
7
+ 4. Generate report
8
+
9
+ ## New Flow
10
+ 1. Generate queries (sequential)
11
+ 2. Search ALL queries (parallel) → get all URLs
12
+ 3. Interleave URLs from all queries
13
+ 4. Extract+analyze URLs in parallel (8 workers, interleaved)
14
+ 5. Group results by query
15
+ 6. Assess completeness
16
+ 7. Generate report
17
+
18
+ ## Data Structure Changes
19
+
20
+ ### Source Event
21
+ ```python
22
+ {
23
+ "type": "source",
24
+ "query_index": 0, # NEW: which query this belongs to
25
+ "query_text": "query string", # NEW: the actual query
26
+ "title": "...",
27
+ "url": "...",
28
+ "analysis": "...",
29
+ "finding_count": 1, # overall finding count
30
+ "is_relevant": True,
31
+ "is_error": False, # NEW: track failures
32
+ "error_message": "" # NEW: error details
33
+ }
34
+ ```
35
+
36
+ ### Query Stats Event (NEW)
37
+ ```python
38
+ {
39
+ "type": "query_stats",
40
+ "query_index": 0,
41
+ "relevant_count": 5,
42
+ "irrelevant_count": 3,
43
+ "error_count": 1
44
+ }
45
+ ```
46
+
47
+ ## UI Changes
48
+ - Group sources under each query
49
+ - Show stats per query: "5 relevant / 3 not relevant / 1 failed"
50
+ - Toggle button to show/hide irrelevant sources
51
+ - Track and display API failures
52
+
53
+ ## Implementation Steps
54
+ 1. ✅ Add research_parallel_workers to settings
55
+ 2. ⏳ Update backend ChatRequest model
56
+ 3. ⏳ Refactor stream_research() in research.py
57
+ 4. ⏳ Update research-ui.js to group by query
58
+ 5. ⏳ Add query stats display
59
+ 6. ⏳ Add toggle for irrelevant sources
backend/__pycache__/code.cpython-312.pyc CHANGED
Binary files a/backend/__pycache__/code.cpython-312.pyc and b/backend/__pycache__/code.cpython-312.pyc differ
 
backend/__pycache__/research.cpython-312.pyc CHANGED
Binary files a/backend/__pycache__/research.cpython-312.pyc and b/backend/__pycache__/research.cpython-312.pyc differ
 
backend/code.py CHANGED
@@ -181,6 +181,11 @@ def stream_code_execution(client, model: str, messages: List[Dict], sbx: Sandbox
181
  yield format_code_cell(code, output, has_error, images)
182
 
183
  except Exception as e:
 
 
 
 
 
184
  yield format_code_cell(code, f"Execution error: {str(e)}", True)
185
  output = f"Execution failed: {str(e)}"
186
  has_error = True
 
181
  yield format_code_cell(code, output, has_error, images)
182
 
183
  except Exception as e:
184
+ error_str = str(e)
185
+ # Check if this is a sandbox timeout error - if so, re-raise to trigger cleanup
186
+ if "502" in error_str or "sandbox was not found" in error_str.lower() or "timeout" in error_str.lower():
187
+ raise # Re-raise to be caught by main.py handler
188
+
189
  yield format_code_cell(code, f"Execution error: {str(e)}", True)
190
  output = f"Execution failed: {str(e)}"
191
  has_error = True
backend/main.py CHANGED
@@ -128,6 +128,26 @@ Your role is to:
128
  - Provide well-structured, evidence-based answers
129
  - Identify key insights and trends
130
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
131
  Focus on being comprehensive, analytical, and well-sourced in your research.
132
  """,
133
  "chat": """You are a conversational AI assistant.
@@ -157,9 +177,19 @@ class ChatRequest(BaseModel):
157
  model: Optional[str] = "gpt-4" # Model name
158
  e2b_key: Optional[str] = None # E2B API key for code execution
159
  serper_key: Optional[str] = None # Serper API key for research
 
 
 
160
  notebook_id: Optional[str] = None # Unique notebook/tab ID for session management
161
 
162
 
 
 
 
 
 
 
 
163
  async def stream_code_notebook(
164
  messages: List[dict],
165
  endpoint: str,
@@ -204,7 +234,21 @@ async def stream_code_notebook(
204
  import traceback
205
  error_message = f"Code execution error: {str(e)}\n{traceback.format_exc()}"
206
  print(error_message)
207
- yield f"data: {json.dumps({'type': 'error', 'content': error_message})}\n\n"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
208
 
209
 
210
  async def stream_research_notebook(
@@ -212,7 +256,10 @@ async def stream_research_notebook(
212
  endpoint: str,
213
  token: Optional[str],
214
  model: str,
215
- serper_key: str
 
 
 
216
  ):
217
  """Handle research notebook with web search"""
218
 
@@ -235,8 +282,20 @@ async def stream_research_notebook(
235
  # Create OpenAI client
236
  client = OpenAI(base_url=endpoint, api_key=token)
237
 
 
 
 
 
 
 
 
 
 
 
 
 
238
  # Stream research
239
- for update in stream_research(client, model, question, serper_key):
240
  yield f"data: {json.dumps(update)}\n\n"
241
 
242
  except Exception as e:
@@ -358,6 +417,52 @@ async def root():
358
  }
359
 
360
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
361
  @app.post("/api/chat/stream")
362
  async def chat_stream(request: ChatRequest):
363
  """Proxy streaming chat to user's configured LLM endpoint"""
@@ -403,7 +508,9 @@ async def chat_stream(request: ChatRequest):
403
  request.endpoint,
404
  request.token,
405
  request.model or "gpt-4",
406
- request.serper_key or ""
 
 
407
  ),
408
  media_type="text/event-stream",
409
  headers={
 
128
  - Provide well-structured, evidence-based answers
129
  - Identify key insights and trends
130
 
131
+ When presenting your final research report:
132
+ 1. Be CONCISE - focus on key findings, not lengthy explanations
133
+ 2. Use TABLES wherever possible to structure information clearly
134
+ 3. Use markdown table syntax for comparisons, lists of facts, statistics, etc.
135
+ 4. Example table format:
136
+ | Category | Details |
137
+ |----------|---------|
138
+ | Item 1 | Data |
139
+ | Item 2 | Data |
140
+
141
+ 5. Only use prose for context and synthesis that can't be tabulated
142
+
143
+ When you have completed your research, wrap your final report in <result> tags:
144
+
145
+ <result>
146
+ Your concise, table-based report here
147
+ </result>
148
+
149
+ The report will be sent back to the main interface.
150
+
151
  Focus on being comprehensive, analytical, and well-sourced in your research.
152
  """,
153
  "chat": """You are a conversational AI assistant.
 
177
  model: Optional[str] = "gpt-4" # Model name
178
  e2b_key: Optional[str] = None # E2B API key for code execution
179
  serper_key: Optional[str] = None # Serper API key for research
180
+ research_sub_agent_model: Optional[str] = None # Smaller model for research sub-tasks
181
+ research_parallel_workers: Optional[int] = None # Number of parallel workers for research
182
+ research_max_websites: Optional[int] = None # Max websites to analyze per research session
183
  notebook_id: Optional[str] = None # Unique notebook/tab ID for session management
184
 
185
 
186
+ class TitleRequest(BaseModel):
187
+ query: str
188
+ endpoint: str # User's configured LLM endpoint
189
+ token: Optional[str] = None # Optional auth token
190
+ model: Optional[str] = "gpt-4" # Model name
191
+
192
+
193
  async def stream_code_notebook(
194
  messages: List[dict],
195
  endpoint: str,
 
234
  import traceback
235
  error_message = f"Code execution error: {str(e)}\n{traceback.format_exc()}"
236
  print(error_message)
237
+
238
+ # Check if this is a sandbox timeout error (502)
239
+ error_str = str(e)
240
+ if "502" in error_str or "sandbox was not found" in error_str.lower() or "timeout" in error_str.lower():
241
+ # Remove the timed-out sandbox from cache
242
+ if session_id in SANDBOXES:
243
+ try:
244
+ SANDBOXES[session_id].kill()
245
+ except:
246
+ pass
247
+ del SANDBOXES[session_id]
248
+
249
+ yield f"data: {json.dumps({'type': 'error', 'content': 'Sandbox timed out and has been deleted. Please run your code again to create a new sandbox.'})}\n\n"
250
+ else:
251
+ yield f"data: {json.dumps({'type': 'error', 'content': error_message})}\n\n"
252
 
253
 
254
  async def stream_research_notebook(
 
256
  endpoint: str,
257
  token: Optional[str],
258
  model: str,
259
+ serper_key: str,
260
+ sub_agent_model: Optional[str] = None,
261
+ parallel_workers: Optional[int] = None,
262
+ max_websites: Optional[int] = None
263
  ):
264
  """Handle research notebook with web search"""
265
 
 
282
  # Create OpenAI client
283
  client = OpenAI(base_url=endpoint, api_key=token)
284
 
285
+ # Get system prompt for research
286
+ system_prompt = SYSTEM_PROMPTS.get("research", "")
287
+
288
+ # Use sub-agent model if provided, otherwise fall back to main model
289
+ analysis_model = sub_agent_model if sub_agent_model else model
290
+
291
+ # Use parallel workers if provided, otherwise default to 8
292
+ workers = parallel_workers if parallel_workers else 8
293
+
294
+ # Use max websites if provided, otherwise default to 50
295
+ max_sites = max_websites if max_websites else 50
296
+
297
  # Stream research
298
+ for update in stream_research(client, model, question, serper_key, max_websites=max_sites, system_prompt=system_prompt, sub_agent_model=analysis_model, parallel_workers=workers):
299
  yield f"data: {json.dumps(update)}\n\n"
300
 
301
  except Exception as e:
 
417
  }
418
 
419
 
420
+ @app.post("/api/generate-title")
421
+ async def generate_title(request: TitleRequest):
422
+ """Generate a short 2-3 word title for a user query"""
423
+ try:
424
+ # Create headers
425
+ headers = {"Content-Type": "application/json"}
426
+ if request.token:
427
+ headers["Authorization"] = f"Bearer {request.token}"
428
+
429
+ # Call the LLM to generate a title
430
+ async with httpx.AsyncClient(timeout=30.0) as client:
431
+ llm_response = await client.post(
432
+ f"{request.endpoint}/chat/completions",
433
+ headers=headers,
434
+ json={
435
+ "model": request.model,
436
+ "messages": [
437
+ {
438
+ "role": "system",
439
+ "content": "You are a helpful assistant that generates concise 2-3 word titles for user queries. Respond with ONLY the title, no additional text, punctuation, or quotes."
440
+ },
441
+ {
442
+ "role": "user",
443
+ "content": f"Generate a 2-3 word title for this query: {request.query}"
444
+ }
445
+ ],
446
+ "temperature": 0.3,
447
+ "max_tokens": 20
448
+ }
449
+ )
450
+
451
+ if llm_response.status_code != 200:
452
+ raise HTTPException(status_code=llm_response.status_code, detail="LLM API error")
453
+
454
+ result = llm_response.json()
455
+ title = result["choices"][0]["message"]["content"].strip()
456
+
457
+ # Remove any quotes that might be in the response
458
+ title = title.replace('"', '').replace("'", '')
459
+
460
+ return {"title": title}
461
+
462
+ except Exception as e:
463
+ raise HTTPException(status_code=500, detail=str(e))
464
+
465
+
466
  @app.post("/api/chat/stream")
467
  async def chat_stream(request: ChatRequest):
468
  """Proxy streaming chat to user's configured LLM endpoint"""
 
508
  request.endpoint,
509
  request.token,
510
  request.model or "gpt-4",
511
+ request.serper_key or "",
512
+ request.research_sub_agent_model,
513
+ request.research_parallel_workers
514
  ),
515
  media_type="text/event-stream",
516
  headers={
backend/research.py CHANGED
@@ -162,7 +162,7 @@ Return ONLY a JSON object with this structure:
162
  return {"sufficient": len(findings) >= 5, "missing_aspects": []}
163
 
164
 
165
- def generate_final_report(client, model: str, user_question: str, findings: List[Dict]) -> str:
166
  """Generate final research report"""
167
  findings_text = "\n\n".join([
168
  f"Source {i+1}: {f['source']}\n{f['analysis']}"
@@ -175,16 +175,11 @@ All information gathered from {len(findings)} sources:
175
 
176
  {findings_text}
177
 
178
- Write a comprehensive, well-structured report that:
179
- 1. Directly answers the research question
180
- 2. Synthesizes information from multiple sources
181
- 3. Includes specific facts, data, and insights
182
- 4. Cites sources where appropriate (e.g., "According to [source]...")
183
- 5. Is organized with clear sections/paragraphs
184
-
185
- Your report:"""
186
 
187
  messages = [{"role": "user", "content": prompt}]
 
 
188
 
189
  try:
190
  response = client.chat.completions.create(
@@ -205,23 +200,35 @@ def stream_research(
205
  question: str,
206
  serper_key: str,
207
  max_iterations: int = 5,
208
- max_websites: int = 50
 
 
 
209
  ):
210
  """
211
  Stream deep research results with progress updates
212
 
 
 
 
 
213
  Yields:
214
- dict: Updates with type 'progress', 'source', 'thinking', 'report', 'done', or 'error'
215
  """
 
 
 
 
216
  findings = []
217
  websites_visited = 0
218
  iteration = 0
219
 
 
 
 
220
  yield {
221
  "type": "status",
222
- "message": f"Starting research on: {question}",
223
- "iteration": 0,
224
- "total_iterations": max_iterations
225
  }
226
 
227
  while iteration < max_iterations and websites_visited < max_websites:
@@ -230,9 +237,7 @@ def stream_research(
230
  # Generate queries
231
  yield {
232
  "type": "status",
233
- "message": f"Iteration {iteration}/{max_iterations}: Generating search queries...",
234
- "iteration": iteration,
235
- "total_iterations": max_iterations
236
  }
237
 
238
  existing_knowledge = "\n".join([f['analysis'] for f in findings[-3:]])
@@ -244,90 +249,206 @@ def stream_research(
244
  "iteration": iteration
245
  }
246
 
247
- # Search and analyze
248
- for query_idx, query in enumerate(queries):
249
- if websites_visited >= max_websites:
250
- break
251
-
252
- yield {
253
- "type": "progress",
254
- "message": f"Searching: {query}",
255
- "query_index": query_idx + 1,
256
- "total_queries": len(queries),
257
- "iteration": iteration
258
  }
259
 
260
- search_results = search_web(query, serper_key, num_results=5)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
261
 
262
- for result_idx, result in enumerate(search_results):
263
- if websites_visited >= max_websites:
264
- break
265
 
266
- url = result['url']
267
-
268
- yield {
269
- "type": "progress",
270
- "message": f"Analyzing: {result['title'][:60]}...",
271
- "websites_visited": websites_visited + 1,
272
- "max_websites": max_websites
273
- }
274
 
 
 
275
  content = extract_content(url)
276
- websites_visited += 1
277
 
278
  if not content or len(content) < 100:
279
- continue
 
 
 
 
 
 
 
 
 
280
 
281
- analysis = analyze_content(client, model, question, content, url)
 
282
 
283
- if "no relevant information" not in analysis.lower():
284
- findings.append({
285
- 'source': url,
286
- 'title': result['title'],
287
- 'analysis': analysis
288
- })
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
289
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
290
  yield {
291
  "type": "source",
 
 
292
  "title": result['title'],
293
- "url": url,
294
- "analysis": analysis,
295
- "finding_count": len(findings)
 
 
 
296
  }
297
 
298
- # Assess completeness
299
- yield {
300
- "type": "status",
301
- "message": "Assessing research completeness...",
302
- "iteration": iteration,
303
- "findings_count": len(findings)
304
- }
305
-
306
- assessment = assess_completeness(client, model, question, findings)
307
 
308
- yield {
309
- "type": "assessment",
310
- "sufficient": assessment.get('sufficient', False),
311
- "missing_aspects": assessment.get('missing_aspects', []),
312
- "findings_count": len(findings)
313
- }
314
 
315
- if assessment.get('sufficient', False):
 
316
  yield {
317
  "type": "status",
318
- "message": "Research complete! Generating final report...",
319
  }
320
- break
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
321
 
322
  # Generate final report
323
  if findings:
324
- report = generate_final_report(client, model, question, findings)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
325
 
 
326
  yield {
327
- "type": "report",
328
- "content": report,
329
- "sources_count": len(findings),
330
- "websites_visited": websites_visited
331
  }
332
  else:
333
  yield {
 
162
  return {"sufficient": len(findings) >= 5, "missing_aspects": []}
163
 
164
 
165
+ def generate_final_report(client, model: str, user_question: str, findings: List[Dict], system_prompt: str = "") -> str:
166
  """Generate final research report"""
167
  findings_text = "\n\n".join([
168
  f"Source {i+1}: {f['source']}\n{f['analysis']}"
 
175
 
176
  {findings_text}
177
 
178
+ Write a concise, well-structured report following the guidelines in your system instructions. Remember to use tables where appropriate and wrap your final report in <result> tags."""
 
 
 
 
 
 
 
179
 
180
  messages = [{"role": "user", "content": prompt}]
181
+ if system_prompt:
182
+ messages.insert(0, {"role": "system", "content": system_prompt})
183
 
184
  try:
185
  response = client.chat.completions.create(
 
200
  question: str,
201
  serper_key: str,
202
  max_iterations: int = 5,
203
+ max_websites: int = 50,
204
+ system_prompt: str = "",
205
+ sub_agent_model: Optional[str] = None,
206
+ parallel_workers: int = 8
207
  ):
208
  """
209
  Stream deep research results with progress updates
210
 
211
+ Args:
212
+ sub_agent_model: Smaller/faster model for analyzing individual web pages. If None, uses main model.
213
+ parallel_workers: Number of parallel workers for extract+analyze operations
214
+
215
  Yields:
216
+ dict: Updates with type 'progress', 'source', 'query_stats', 'report', 'result', 'result_preview', 'done', or 'error'
217
  """
218
+ import concurrent.futures
219
+ import re
220
+ from collections import defaultdict
221
+
222
  findings = []
223
  websites_visited = 0
224
  iteration = 0
225
 
226
+ # Use sub-agent model for analysis if provided, otherwise use main model
227
+ analysis_model = sub_agent_model if sub_agent_model else model
228
+
229
  yield {
230
  "type": "status",
231
+ "message": f"Starting research: {question}"
 
 
232
  }
233
 
234
  while iteration < max_iterations and websites_visited < max_websites:
 
237
  # Generate queries
238
  yield {
239
  "type": "status",
240
+ "message": "Generating search queries..."
 
 
241
  }
242
 
243
  existing_knowledge = "\n".join([f['analysis'] for f in findings[-3:]])
 
249
  "iteration": iteration
250
  }
251
 
252
+ # Collect all search results for all queries in parallel
253
+ query_results = {} # query_index -> list of results
254
+ with concurrent.futures.ThreadPoolExecutor(max_workers=3) as executor:
255
+ future_to_query_idx = {
256
+ executor.submit(search_web, query, serper_key, num_results=5): idx
257
+ for idx, query in enumerate(queries)
 
 
 
 
 
258
  }
259
 
260
+ for future in concurrent.futures.as_completed(future_to_query_idx):
261
+ query_idx = future_to_query_idx[future]
262
+ try:
263
+ results = future.result()
264
+ query_results[query_idx] = results
265
+ except Exception as e:
266
+ print(f"Search failed for query {query_idx}: {e}")
267
+ query_results[query_idx] = []
268
+
269
+ # Interleave results from all queries
270
+ interleaved_urls = []
271
+ max_results = max((len(results) for results in query_results.values()), default=0)
272
+
273
+ for result_idx in range(max_results):
274
+ for query_idx in range(len(queries)):
275
+ if query_idx in query_results and result_idx < len(query_results[query_idx]):
276
+ result = query_results[query_idx][result_idx]
277
+ interleaved_urls.append({
278
+ 'query_index': query_idx,
279
+ 'query_text': queries[query_idx],
280
+ 'url': result['url'],
281
+ 'title': result['title']
282
+ })
283
 
284
+ # Track stats per query
285
+ query_stats = defaultdict(lambda: {'relevant': 0, 'irrelevant': 0, 'error': 0})
 
286
 
287
+ # Process URLs in parallel with interleaved order
288
+ def process_url(url_data):
289
+ """Extract content and analyze for a single URL"""
290
+ query_idx = url_data['query_index']
291
+ query_text = url_data['query_text']
292
+ url = url_data['url']
293
+ title = url_data['title']
 
294
 
295
+ try:
296
+ # Extract content
297
  content = extract_content(url)
 
298
 
299
  if not content or len(content) < 100:
300
+ return {
301
+ 'query_index': query_idx,
302
+ 'query_text': query_text,
303
+ 'title': title,
304
+ 'url': url,
305
+ 'analysis': "Could not extract content from this page.",
306
+ 'is_relevant': False,
307
+ 'is_error': True,
308
+ 'error_message': "Content extraction failed"
309
+ }
310
 
311
+ # Analyze content
312
+ analysis = analyze_content(client, analysis_model, question, content, url)
313
 
314
+ is_relevant = "no relevant information" not in analysis.lower()
315
+
316
+ return {
317
+ 'query_index': query_idx,
318
+ 'query_text': query_text,
319
+ 'title': title,
320
+ 'url': url,
321
+ 'analysis': analysis,
322
+ 'is_relevant': is_relevant,
323
+ 'is_error': False,
324
+ 'error_message': ""
325
+ }
326
+ except Exception as e:
327
+ return {
328
+ 'query_index': query_idx,
329
+ 'query_text': query_text,
330
+ 'title': title,
331
+ 'url': url,
332
+ 'analysis': f"Error: {str(e)}",
333
+ 'is_relevant': False,
334
+ 'is_error': True,
335
+ 'error_message': str(e)
336
+ }
337
+
338
+ # Process all URLs in parallel with progress updates
339
+ with concurrent.futures.ThreadPoolExecutor(max_workers=parallel_workers) as executor:
340
+ futures = {executor.submit(process_url, url_data): url_data for url_data in interleaved_urls[:max_websites - websites_visited]}
341
+
342
+ for future in concurrent.futures.as_completed(futures):
343
+ if websites_visited >= max_websites:
344
+ break
345
 
346
+ try:
347
+ result = future.result()
348
+ websites_visited += 1
349
+
350
+ # Update stats
351
+ if result['is_error']:
352
+ query_stats[result['query_index']]['error'] += 1
353
+ elif result['is_relevant']:
354
+ query_stats[result['query_index']]['relevant'] += 1
355
+ # Add to findings
356
+ findings.append({
357
+ 'source': result['url'],
358
+ 'title': result['title'],
359
+ 'analysis': result['analysis']
360
+ })
361
+ else:
362
+ query_stats[result['query_index']]['irrelevant'] += 1
363
+
364
+ # Send source event
365
  yield {
366
  "type": "source",
367
+ "query_index": result['query_index'],
368
+ "query_text": result['query_text'],
369
  "title": result['title'],
370
+ "url": result['url'],
371
+ "analysis": result['analysis'],
372
+ "finding_count": len(findings),
373
+ "is_relevant": result['is_relevant'],
374
+ "is_error": result['is_error'],
375
+ "error_message": result['error_message']
376
  }
377
 
378
+ # Send updated stats for this query
379
+ stats = query_stats[result['query_index']]
380
+ yield {
381
+ "type": "query_stats",
382
+ "query_index": result['query_index'],
383
+ "relevant_count": stats['relevant'],
384
+ "irrelevant_count": stats['irrelevant'],
385
+ "error_count": stats['error']
386
+ }
387
 
388
+ except Exception as e:
389
+ print(f"Error processing URL: {e}")
 
 
 
 
390
 
391
+ # Assess completeness
392
+ if len(findings) >= 3: # Only assess if we have some findings
393
  yield {
394
  "type": "status",
395
+ "message": "Evaluating information gathered..."
396
  }
397
+
398
+ assessment = assess_completeness(client, model, question, findings)
399
+
400
+ yield {
401
+ "type": "assessment",
402
+ "sufficient": assessment.get('sufficient', False),
403
+ "missing_aspects": assessment.get('missing_aspects', []),
404
+ "findings_count": len(findings)
405
+ }
406
+
407
+ if assessment.get('sufficient', False):
408
+ yield {
409
+ "type": "status",
410
+ "message": "Research complete! Generating final report..."
411
+ }
412
+ break
413
 
414
  # Generate final report
415
  if findings:
416
+ report = generate_final_report(client, model, question, findings, system_prompt)
417
+
418
+ print("\n" + "="*80)
419
+ print("RAW REPORT TEXT:")
420
+ print("="*80)
421
+ print(report)
422
+ print("="*80 + "\n")
423
+
424
+ # Parse result tags from report
425
+ result_match = re.search(r'<result>(.*?)</result>', report, re.DOTALL)
426
+
427
+ result_content = None
428
+ if result_match:
429
+ result_content = result_match.group(1).strip()
430
+ print("\n" + "="*80)
431
+ print("EXTRACTED RESULT CONTENT:")
432
+ print("="*80)
433
+ print(result_content)
434
+ print("="*80 + "\n")
435
+ else:
436
+ # If no result tags, use the full report
437
+ result_content = report
438
+ print(f"Warning: No <result> tags found in report, using full content")
439
+
440
+ # Send result preview to research notebook
441
+ yield {
442
+ "type": "result_preview",
443
+ "content": result_content,
444
+ "figures": {} # Research doesn't generate figures
445
+ }
446
 
447
+ # Send result to command center
448
  yield {
449
+ "type": "result",
450
+ "content": result_content,
451
+ "figures": {}
 
452
  }
453
  else:
454
  yield {
index.html CHANGED
@@ -139,6 +139,30 @@
139
  </div>
140
  </div>
141
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
142
  <div class="settings-actions">
143
  <button class="settings-save-btn" id="saveSettingsBtn">SAVE</button>
144
  <button class="settings-cancel-btn" id="cancelSettingsBtn">CANCEL</button>
 
139
  </div>
140
  </div>
141
 
142
+ <div class="settings-section">
143
+ <label class="settings-label">
144
+ <span class="label-text">RESEARCH SUB-AGENT MODEL (OPTIONAL)</span>
145
+ <span class="label-description">Smaller/faster model for analyzing individual web pages during research</span>
146
+ </label>
147
+ <input type="text" id="setting-research-sub-agent-model" class="settings-input" placeholder="Use research model or default">
148
+ </div>
149
+
150
+ <div class="settings-section">
151
+ <label class="settings-label">
152
+ <span class="label-text">RESEARCH PARALLEL WORKERS (OPTIONAL)</span>
153
+ <span class="label-description">Number of web pages to analyze in parallel (default: 8)</span>
154
+ </label>
155
+ <input type="number" id="setting-research-parallel-workers" class="settings-input" placeholder="8" min="1" max="20">
156
+ </div>
157
+
158
+ <div class="settings-section">
159
+ <label class="settings-label">
160
+ <span class="label-text">RESEARCH MAX WEBSITES (OPTIONAL)</span>
161
+ <span class="label-description">Maximum number of websites to analyze per research session (default: 50)</span>
162
+ </label>
163
+ <input type="number" id="setting-research-max-websites" class="settings-input" placeholder="50" min="10" max="200">
164
+ </div>
165
+
166
  <div class="settings-actions">
167
  <button class="settings-save-btn" id="saveSettingsBtn">SAVE</button>
168
  <button class="settings-cancel-btn" id="cancelSettingsBtn">CANCEL</button>
research-ui.js CHANGED
@@ -1,108 +1,211 @@
1
- // Research UI helper functions
2
 
3
- function createStatusMessage(chatContainer, message, iteration, totalIterations) {
4
- const statusDiv = document.createElement('div');
5
- statusDiv.className = 'research-status';
 
6
 
7
- let progressInfo = '';
8
- if (iteration !== undefined && totalIterations !== undefined) {
9
- progressInfo = `<span class="iteration-badge">Iteration ${iteration}/${totalIterations}</span>`;
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  }
 
 
11
 
12
- statusDiv.innerHTML = `
13
- <div class="status-icon">🔍</div>
14
- <div class="status-message">${escapeHtml(message)}</div>
15
- ${progressInfo}
16
- `;
17
- chatContainer.appendChild(statusDiv);
18
  }
19
 
20
  function createQueriesMessage(chatContainer, queries, iteration) {
21
- const queriesDiv = document.createElement('div');
22
- queriesDiv.className = 'research-queries';
23
- queriesDiv.innerHTML = `
24
- <div class="queries-label">Search Queries (Iteration ${iteration})</div>
25
- <ul class="queries-list">
26
- ${queries.map(q => `<li>${escapeHtml(q)}</li>`).join('')}
27
- </ul>
28
- `;
29
- chatContainer.appendChild(queriesDiv);
30
- }
31
 
32
- function updateProgress(chatContainer, message, websitesVisited, maxWebsites) {
33
- // Find or create progress indicator
34
- let progressDiv = chatContainer.querySelector('.research-progress');
 
 
 
 
 
35
 
36
- if (!progressDiv) {
37
- progressDiv = document.createElement('div');
38
- progressDiv.className = 'research-progress';
39
- chatContainer.appendChild(progressDiv);
40
- }
41
 
42
- let progressBar = '';
43
- if (websitesVisited !== undefined && maxWebsites !== undefined) {
44
- const percent = Math.min(100, (websitesVisited / maxWebsites) * 100);
45
- progressBar = `
46
- <div class="progress-bar">
47
- <div class="progress-fill" style="width: ${percent}%"></div>
 
 
 
 
48
  </div>
49
- <div class="progress-count">${websitesVisited}/${maxWebsites} websites</div>
50
  `;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  }
52
 
53
- progressDiv.innerHTML = `
54
- <div class="progress-message">${escapeHtml(message)}</div>
55
- ${progressBar}
56
- `;
 
 
57
  }
58
 
59
- function createSourceMessage(chatContainer, title, url, analysis, findingCount) {
60
- const sourceDiv = document.createElement('div');
61
- sourceDiv.className = 'research-source';
62
- sourceDiv.innerHTML = `
63
- <div class="source-header">
64
- <span class="source-badge">Source #${findingCount}</span>
65
- <a href="${escapeHtml(url)}" target="_blank" class="source-url">${escapeHtml(title)}</a>
66
- </div>
67
- <div class="source-analysis">${escapeHtml(analysis)}</div>
68
- `;
69
- chatContainer.appendChild(sourceDiv);
 
 
 
 
 
 
70
  }
71
 
72
- function createAssessmentMessage(chatContainer, sufficient, missingAspects, findingsCount) {
73
- const assessmentDiv = document.createElement('div');
74
- assessmentDiv.className = 'research-assessment';
75
-
76
- const status = sufficient ? '✅ Research Complete' : '🔄 Continuing Research';
77
- const statusClass = sufficient ? 'complete' : 'continuing';
78
-
79
- let missingHtml = '';
80
- if (missingAspects && missingAspects.length > 0) {
81
- missingHtml = `
82
- <div class="missing-aspects">
83
- <strong>Missing aspects:</strong>
84
- <ul>${missingAspects.map(a => `<li>${escapeHtml(a)}</li>`).join('')}</ul>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
85
  </div>
86
  `;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
87
  }
 
88
 
89
- assessmentDiv.innerHTML = `
90
- <div class="assessment-status ${statusClass}">${status}</div>
91
- <div class="assessment-info">Gathered ${findingsCount} relevant sources</div>
92
- ${missingHtml}
93
- `;
94
- chatContainer.appendChild(assessmentDiv);
 
 
 
95
  }
96
 
97
  function createReportMessage(chatContainer, content, sourcesCount, websitesVisited) {
98
- const reportDiv = document.createElement('div');
99
- reportDiv.className = 'research-report';
100
- reportDiv.innerHTML = `
101
- <div class="report-header">
102
- <div class="report-title">📊 Research Report</div>
103
- <div class="report-stats">${sourcesCount} sources • ${websitesVisited} websites analyzed</div>
104
- </div>
105
- <div class="report-content">${parseMarkdown(content)}</div>
106
- `;
107
- chatContainer.appendChild(reportDiv);
 
 
 
 
108
  }
 
1
+ // Research UI - Grouped by queries with stats and toggle
2
 
3
+ let researchContainer = null;
4
+ let currentQueries = [];
5
+ let queryData = {}; // query_index -> {sources: [], stats: {}}
6
+ let showIrrelevant = false; // Toggle state
7
 
8
+ function getOrCreateResearchContainer(chatContainer) {
9
+ if (!researchContainer || !chatContainer.contains(researchContainer)) {
10
+ researchContainer = document.createElement('div');
11
+ researchContainer.className = 'research-container';
12
+ researchContainer.innerHTML = `
13
+ <div class="research-header">
14
+ <div class="research-title">Research in progress...</div>
15
+ <button class="toggle-irrelevant-btn" onclick="toggleIrrelevantSources()">
16
+ Show irrelevant sources
17
+ </button>
18
+ </div>
19
+ <div class="research-body">
20
+ <div class="research-queries-section"></div>
21
+ </div>
22
+ `;
23
+ chatContainer.appendChild(researchContainer);
24
  }
25
+ return researchContainer;
26
+ }
27
 
28
+ function createStatusMessage(chatContainer, message) {
29
+ const container = getOrCreateResearchContainer(chatContainer);
30
+ const header = container.querySelector('.research-title');
31
+ header.textContent = message;
 
 
32
  }
33
 
34
  function createQueriesMessage(chatContainer, queries, iteration) {
35
+ currentQueries = queries;
36
+ queryData = {}; // Reset
 
 
 
 
 
 
 
 
37
 
38
+ // Initialize query data
39
+ queries.forEach((query, idx) => {
40
+ queryData[idx] = {
41
+ query: query,
42
+ sources: [],
43
+ stats: { relevant: 0, irrelevant: 0, error: 0 }
44
+ };
45
+ });
46
 
47
+ const container = getOrCreateResearchContainer(chatContainer);
48
+ const queriesSection = container.querySelector('.research-queries-section');
 
 
 
49
 
50
+ // Create query sections
51
+ let html = '<div class="queries-title">Search queries:</div>';
52
+ queries.forEach((query, idx) => {
53
+ html += `
54
+ <div class="query-group" id="query-group-${idx}">
55
+ <div class="query-header" style="cursor: pointer;" onclick="document.getElementById('query-sources-${idx}').scrollIntoView({behavior: 'smooth', block: 'nearest'})">
56
+ <span class="query-text">${escapeHtml(query)}</span>
57
+ <span class="query-stats" id="query-stats-${idx}">0 relevant / 0 not relevant / 0 failed</span>
58
+ </div>
59
+ <div class="query-sources" id="query-sources-${idx}"></div>
60
  </div>
 
61
  `;
62
+ });
63
+
64
+ queriesSection.innerHTML = html;
65
+ }
66
+
67
+ function createSourceMessage(chatContainer, data) {
68
+ // data includes: query_index, query_text, title, url, analysis, is_relevant, is_error, error_message
69
+
70
+ const queryIdx = data.query_index;
71
+
72
+ // Store source data
73
+ if (!queryData[queryIdx]) {
74
+ queryData[queryIdx] = {
75
+ query: data.query_text,
76
+ sources: [],
77
+ stats: { relevant: 0, irrelevant: 0, error: 0 }
78
+ };
79
  }
80
 
81
+ queryData[queryIdx].sources.push(data);
82
+
83
+ // Update display if relevant or if showing irrelevant
84
+ if (data.is_relevant || showIrrelevant) {
85
+ renderQuerySources(queryIdx);
86
+ }
87
  }
88
 
89
+ function updateQueryStats(queryIdx, stats) {
90
+ // Update stored stats
91
+ if (queryData[queryIdx]) {
92
+ queryData[queryIdx].stats = stats;
93
+ }
94
+
95
+ // Update stats display
96
+ const statsEl = document.getElementById(`query-stats-${queryIdx}`);
97
+ if (statsEl) {
98
+ const parts = [];
99
+ if (stats.relevant > 0) parts.push(`${stats.relevant} relevant`);
100
+ if (stats.irrelevant > 0) parts.push(`${stats.irrelevant} not relevant`);
101
+ if (stats.error > 0) parts.push(`${stats.error} failed`);
102
+
103
+ statsEl.textContent = parts.length > 0 ? parts.join(' / ') : 'No results yet';
104
+ statsEl.style.color = stats.relevant > 0 ? '#2e7d32' : '#666';
105
+ }
106
  }
107
 
108
+ function renderQuerySources(queryIdx) {
109
+ const sourcesContainer = document.getElementById(`query-sources-${queryIdx}`);
110
+ if (!sourcesContainer || !queryData[queryIdx]) return;
111
+
112
+ const sources = queryData[queryIdx].sources;
113
+
114
+ // Filter sources based on toggle
115
+ const visibleSources = showIrrelevant ? sources : sources.filter(s => s.is_relevant);
116
+
117
+ if (visibleSources.length === 0) {
118
+ sourcesContainer.innerHTML = '<div class="no-sources">No sources to display</div>';
119
+ return;
120
+ }
121
+
122
+ let html = '';
123
+ visibleSources.forEach((source, idx) => {
124
+ const sourceId = `source-${queryIdx}-${idx}`;
125
+ const statusClass = source.is_error ? 'error' : (source.is_relevant ? 'relevant' : 'irrelevant');
126
+
127
+ html += `
128
+ <div class="research-source ${statusClass}">
129
+ <div class="source-header" onclick="toggleSourceContent('${sourceId}')">
130
+ <span class="source-status-icon">${source.is_error ? '✗' : (source.is_relevant ? '✓' : '○')}</span>
131
+ <a href="${escapeHtml(source.url)}" target="_blank" class="source-url" onclick="event.stopPropagation()">${escapeHtml(source.title)}</a>
132
+ <span class="source-toggle">▼</span>
133
+ </div>
134
+ <div class="source-analysis" id="${sourceId}" style="display: none;">
135
+ ${parseMarkdown(source.analysis)}
136
+ </div>
137
  </div>
138
  `;
139
+ });
140
+
141
+ sourcesContainer.innerHTML = html;
142
+ }
143
+
144
+ function toggleSourceContent(sourceId) {
145
+ const content = document.getElementById(sourceId);
146
+ if (!content) return;
147
+
148
+ const header = content.previousElementSibling;
149
+ const toggle = header.querySelector('.source-toggle');
150
+
151
+ if (content.style.display === 'none') {
152
+ content.style.display = 'block';
153
+ toggle.textContent = '▲';
154
+ } else {
155
+ content.style.display = 'none';
156
+ toggle.textContent = '▼';
157
+ }
158
+ }
159
+
160
+ function toggleIrrelevantSources() {
161
+ showIrrelevant = !showIrrelevant;
162
+
163
+ // Update button text
164
+ const btn = document.querySelector('.toggle-irrelevant-btn');
165
+ if (btn) {
166
+ btn.textContent = showIrrelevant ? 'Hide irrelevant sources' : 'Show irrelevant sources';
167
+ }
168
+
169
+ // Re-render all queries
170
+ Object.keys(queryData).forEach(queryIdx => {
171
+ renderQuerySources(parseInt(queryIdx));
172
+ });
173
+ }
174
+
175
+ function updateProgress(chatContainer, message, websitesVisited, maxWebsites) {
176
+ // Progress is now tracked per query via stats, so this is less important
177
+ // But we can still update the header
178
+ const container = getOrCreateResearchContainer(chatContainer);
179
+ const header = container.querySelector('.research-title');
180
+ if (websitesVisited !== undefined && maxWebsites !== undefined) {
181
+ header.textContent = `Research in progress... (${websitesVisited}/${maxWebsites} pages analyzed)`;
182
  }
183
+ }
184
 
185
+ function createAssessmentMessage(chatContainer, sufficient, missingAspects, findingsCount) {
186
+ const container = getOrCreateResearchContainer(chatContainer);
187
+ const header = container.querySelector('.research-title');
188
+
189
+ if (sufficient) {
190
+ header.textContent = `Research complete - ${findingsCount} relevant sources found`;
191
+ } else {
192
+ header.textContent = `Continuing research - ${findingsCount} relevant sources found so far`;
193
+ }
194
  }
195
 
196
  function createReportMessage(chatContainer, content, sourcesCount, websitesVisited) {
197
+ // This function is kept for backwards compatibility
198
+ // but normally won't be called as reports use result_preview now
199
+
200
+ // Mark research as complete
201
+ if (researchContainer) {
202
+ const header = researchContainer.querySelector('.research-title');
203
+ header.textContent = `Research complete - ${sourcesCount} sources`;
204
+ }
205
+
206
+ // Reset for next research
207
+ researchContainer = null;
208
+ queryData = {};
209
+ currentQueries = [];
210
+ showIrrelevant = false;
211
  }
script.js CHANGED
@@ -296,6 +296,7 @@ async function sendMessage(tabId) {
296
 
297
  // Remove welcome message if it exists (only on first user message)
298
  const welcomeMsg = chatContainer.querySelector('.welcome-message');
 
299
  if (welcomeMsg) {
300
  welcomeMsg.remove();
301
  }
@@ -309,6 +310,11 @@ async function sendMessage(tabId) {
309
  `;
310
  chatContainer.appendChild(userMsg);
311
 
 
 
 
 
 
312
  // Clear input and disable it during processing
313
  input.value = '';
314
  input.disabled = true;
@@ -331,6 +337,43 @@ async function sendMessage(tabId) {
331
  setTabGenerating(tabId, false);
332
  }
333
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
334
  function getNotebookTypeFromContainer(chatContainer) {
335
  // Try to get type from data attribute first (for dynamically created notebooks)
336
  const typeFromData = chatContainer.dataset.notebookType;
@@ -399,6 +442,9 @@ async function streamChatResponse(messages, chatContainer, notebookType, tabId)
399
  model: modelToUse,
400
  e2b_key: currentSettings.e2bKey || null,
401
  serper_key: currentSettings.serperKey || null,
 
 
 
402
  notebook_id: tabId.toString() // Send unique tab ID for sandbox sessions
403
  })
404
  });
@@ -454,7 +500,7 @@ async function streamChatResponse(messages, chatContainer, notebookType, tabId)
454
  updateActionWidgetWithResult(tabId, data.content, data.figures);
455
 
456
  } else if (data.type === 'result_preview') {
457
- // Show result in CODE notebook as highlighted message
458
  // Replace <figure_x> tags with placeholders BEFORE markdown processing
459
  let previewContent = data.content;
460
  const figurePlaceholders = {};
@@ -491,11 +537,12 @@ async function streamChatResponse(messages, chatContainer, notebookType, tabId)
491
  html = html.replace(new RegExp(placeholderId, 'g'), imageHtml);
492
  }
493
 
 
494
  const resultDiv = document.createElement('div');
495
- resultDiv.className = 'result-preview';
496
  resultDiv.innerHTML = `
497
- <div class="result-preview-label">RESULT SUMMARY</div>
498
- <div class="result-preview-content">${html}</div>
499
  `;
500
  chatContainer.appendChild(resultDiv);
501
  chatContainer.scrollTop = chatContainer.scrollHeight;
@@ -516,10 +563,18 @@ async function streamChatResponse(messages, chatContainer, notebookType, tabId)
516
  chatContainer.scrollTop = chatContainer.scrollHeight;
517
 
518
  } else if (data.type === 'source') {
519
- // Research source found
520
- createSourceMessage(chatContainer, data.title, data.url, data.analysis, data.finding_count);
521
  chatContainer.scrollTop = chatContainer.scrollHeight;
522
 
 
 
 
 
 
 
 
 
523
  } else if (data.type === 'assessment') {
524
  // Research completeness assessment
525
  createAssessmentMessage(chatContainer, data.sufficient, data.missing_aspects, data.findings_count);
@@ -688,22 +743,27 @@ function updateLastCodeCell(chatContainer, output, isError, images) {
688
 
689
  function showActionWidget(chatContainer, action, message, targetTabId) {
690
  const widget = document.createElement('div');
691
- widget.className = 'action-widget';
692
- widget.style.cursor = 'pointer';
693
  widget.dataset.targetTabId = targetTabId;
694
  widget.innerHTML = `
695
- <div class="action-widget-header">
696
  <span class="action-widget-icon">→</span>
697
- <span class="action-widget-text">Opening ${action.toUpperCase()} notebook...</span>
698
  <span class="action-widget-type">${action}</span>
699
  </div>
700
- <div class="action-widget-message">"${escapeHtml(message)}"</div>
701
  `;
702
 
703
- // Make widget clickable to jump to the notebook
704
- widget.addEventListener('click', () => {
 
 
 
705
  switchToTab(parseInt(targetTabId));
706
- });
 
 
 
707
 
708
  chatContainer.appendChild(widget);
709
  chatContainer.scrollTop = chatContainer.scrollHeight;
@@ -716,10 +776,13 @@ function updateActionWidgetWithResult(tabId, resultContent, figures) {
716
  const widget = actionWidgets[tabId];
717
  if (!widget) return;
718
 
719
- // Update status text
 
 
720
  const statusText = widget.querySelector('.action-widget-text');
721
  if (statusText) {
722
- statusText.textContent = statusText.textContent.replace('Opening', 'Completed');
 
723
  }
724
 
725
  // Replace <figure_x> tags with placeholders BEFORE markdown processing
@@ -805,9 +868,12 @@ function escapeHtml(text) {
805
  }
806
 
807
  function parseMarkdown(text) {
808
- // Simple markdown parser for code blocks and inline code
809
  let html = escapeHtml(text);
810
 
 
 
 
811
  // Code blocks (```language\ncode\n```)
812
  html = html.replace(/```(\w+)?\n([\s\S]*?)```/g, (match, lang, code) => {
813
  return `<pre><code>${code.trim()}</code></pre>`;
@@ -816,22 +882,157 @@ function parseMarkdown(text) {
816
  // Inline code (`code`)
817
  html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
818
 
 
 
 
 
 
819
  // Bold (**text**)
820
  html = html.replace(/\*\*([^\*]+)\*\*/g, '<strong>$1</strong>');
821
 
822
  // Italic (*text*)
823
  html = html.replace(/\*([^\*]+)\*/g, '<em>$1</em>');
824
 
825
- // Paragraphs (double newline creates new paragraph)
826
- html = html.split('\n\n').map(para => {
827
- para = para.trim();
828
- if (para.startsWith('<pre>') || para.startsWith('__FIGURE_')) {
829
- return para; // Don't wrap code blocks or figure placeholders
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
830
  }
831
- return para ? `<p>${para.replace(/\n/g, '<br>')}</p>` : '';
832
- }).join('\n');
833
 
834
- return html;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
835
  }
836
 
837
  // Settings management
@@ -860,6 +1061,9 @@ function openSettings() {
860
  document.getElementById('setting-model-code').value = settings.models?.code || '';
861
  document.getElementById('setting-model-research').value = settings.models?.research || '';
862
  document.getElementById('setting-model-chat').value = settings.models?.chat || '';
 
 
 
863
 
864
  // Clear any status message
865
  const status = document.getElementById('settingsStatus');
@@ -883,6 +1087,9 @@ function saveSettings() {
883
  const modelCode = document.getElementById('setting-model-code').value.trim();
884
  const modelResearch = document.getElementById('setting-model-research').value.trim();
885
  const modelChat = document.getElementById('setting-model-chat').value.trim();
 
 
 
886
 
887
  // Validate endpoint
888
  if (!endpoint) {
@@ -902,6 +1109,9 @@ function saveSettings() {
902
  settings.model = model;
903
  settings.e2bKey = e2bKey;
904
  settings.serperKey = serperKey;
 
 
 
905
  settings.models = {
906
  agent: modelAgent,
907
  code: modelCode,
 
296
 
297
  // Remove welcome message if it exists (only on first user message)
298
  const welcomeMsg = chatContainer.querySelector('.welcome-message');
299
+ const isFirstMessage = welcomeMsg !== null;
300
  if (welcomeMsg) {
301
  welcomeMsg.remove();
302
  }
 
310
  `;
311
  chatContainer.appendChild(userMsg);
312
 
313
+ // Generate a title for the notebook if this is the first message and not command center
314
+ if (isFirstMessage && tabId !== 0) {
315
+ generateNotebookTitle(tabId, message);
316
+ }
317
+
318
  // Clear input and disable it during processing
319
  input.value = '';
320
  input.disabled = true;
 
337
  setTabGenerating(tabId, false);
338
  }
339
 
340
+ async function generateNotebookTitle(tabId, query) {
341
+ const currentSettings = getSettings();
342
+ const backendEndpoint = 'http://localhost:8000/api';
343
+ const llmEndpoint = currentSettings.endpoint || 'https://api.openai.com/v1';
344
+ const modelToUse = currentSettings.model || 'gpt-4';
345
+
346
+ try {
347
+ const response = await fetch(`${backendEndpoint}/generate-title`, {
348
+ method: 'POST',
349
+ headers: { 'Content-Type': 'application/json' },
350
+ body: JSON.stringify({
351
+ query: query,
352
+ endpoint: llmEndpoint,
353
+ token: currentSettings.token || null,
354
+ model: modelToUse
355
+ })
356
+ });
357
+
358
+ if (response.ok) {
359
+ const result = await response.json();
360
+ const title = result.title;
361
+
362
+ // Update the tab title
363
+ const tab = document.querySelector(`[data-tab-id="${tabId}"]`);
364
+ if (tab) {
365
+ const titleEl = tab.querySelector('.tab-title');
366
+ if (titleEl) {
367
+ titleEl.textContent = title.toUpperCase();
368
+ }
369
+ }
370
+ }
371
+ } catch (error) {
372
+ console.error('Failed to generate title:', error);
373
+ // Don't show error to user, just keep the default title
374
+ }
375
+ }
376
+
377
  function getNotebookTypeFromContainer(chatContainer) {
378
  // Try to get type from data attribute first (for dynamically created notebooks)
379
  const typeFromData = chatContainer.dataset.notebookType;
 
442
  model: modelToUse,
443
  e2b_key: currentSettings.e2bKey || null,
444
  serper_key: currentSettings.serperKey || null,
445
+ research_sub_agent_model: currentSettings.researchSubAgentModel || null,
446
+ research_parallel_workers: currentSettings.researchParallelWorkers || null,
447
+ research_max_websites: currentSettings.researchMaxWebsites || null,
448
  notebook_id: tabId.toString() // Send unique tab ID for sandbox sessions
449
  })
450
  });
 
500
  updateActionWidgetWithResult(tabId, data.content, data.figures);
501
 
502
  } else if (data.type === 'result_preview') {
503
+ // Show result preview
504
  // Replace <figure_x> tags with placeholders BEFORE markdown processing
505
  let previewContent = data.content;
506
  const figurePlaceholders = {};
 
537
  html = html.replace(new RegExp(placeholderId, 'g'), imageHtml);
538
  }
539
 
540
+ // Create result block
541
  const resultDiv = document.createElement('div');
542
+ resultDiv.className = 'research-report';
543
  resultDiv.innerHTML = `
544
+ <div class="report-header">Report</div>
545
+ <div class="report-content">${html}</div>
546
  `;
547
  chatContainer.appendChild(resultDiv);
548
  chatContainer.scrollTop = chatContainer.scrollHeight;
 
563
  chatContainer.scrollTop = chatContainer.scrollHeight;
564
 
565
  } else if (data.type === 'source') {
566
+ // Research source found - now includes query grouping
567
+ createSourceMessage(chatContainer, data);
568
  chatContainer.scrollTop = chatContainer.scrollHeight;
569
 
570
+ } else if (data.type === 'query_stats') {
571
+ // Update query statistics
572
+ updateQueryStats(data.query_index, {
573
+ relevant: data.relevant_count,
574
+ irrelevant: data.irrelevant_count,
575
+ error: data.error_count
576
+ });
577
+
578
  } else if (data.type === 'assessment') {
579
  // Research completeness assessment
580
  createAssessmentMessage(chatContainer, data.sufficient, data.missing_aspects, data.findings_count);
 
743
 
744
  function showActionWidget(chatContainer, action, message, targetTabId) {
745
  const widget = document.createElement('div');
746
+ widget.className = 'action-widget running'; // Add 'running' class for animation
 
747
  widget.dataset.targetTabId = targetTabId;
748
  widget.innerHTML = `
749
+ <div class="action-widget-header" style="cursor: pointer;">
750
  <span class="action-widget-icon">→</span>
751
+ <span class="action-widget-text">Opening ${action.toUpperCase()} notebook</span>
752
  <span class="action-widget-type">${action}</span>
753
  </div>
754
+ <div class="action-widget-message" style="cursor: pointer;">${escapeHtml(message)}</div>
755
  `;
756
 
757
+ // Make header and message clickable to jump to the notebook
758
+ const header = widget.querySelector('.action-widget-header');
759
+ const messageEl = widget.querySelector('.action-widget-message');
760
+
761
+ const clickHandler = () => {
762
  switchToTab(parseInt(targetTabId));
763
+ };
764
+
765
+ header.addEventListener('click', clickHandler);
766
+ messageEl.addEventListener('click', clickHandler);
767
 
768
  chatContainer.appendChild(widget);
769
  chatContainer.scrollTop = chatContainer.scrollHeight;
 
776
  const widget = actionWidgets[tabId];
777
  if (!widget) return;
778
 
779
+ // Remove running animation and update status text
780
+ widget.classList.remove('running');
781
+
782
  const statusText = widget.querySelector('.action-widget-text');
783
  if (statusText) {
784
+ // Remove trailing dots and change text
785
+ statusText.textContent = statusText.textContent.replace('Opening', 'Completed').replace(/\.+$/, '');
786
  }
787
 
788
  // Replace <figure_x> tags with placeholders BEFORE markdown processing
 
868
  }
869
 
870
  function parseMarkdown(text) {
871
+ // Comprehensive markdown parser
872
  let html = escapeHtml(text);
873
 
874
+ // Remove any literal <br> tags that came through (convert to spaces in tables, newlines elsewhere)
875
+ // We'll handle this more carefully after table processing
876
+
877
  // Code blocks (```language\ncode\n```)
878
  html = html.replace(/```(\w+)?\n([\s\S]*?)```/g, (match, lang, code) => {
879
  return `<pre><code>${code.trim()}</code></pre>`;
 
882
  // Inline code (`code`)
883
  html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
884
 
885
+ // Links [text](url) - process before bold/italic to avoid conflicts
886
+ html = html.replace(/\[([^\]]+)\]\(([^\)]+)\)/g, (match, text, url) => {
887
+ return `<a href="${url}" target="_blank" rel="noopener noreferrer">${text}</a>`;
888
+ });
889
+
890
  // Bold (**text**)
891
  html = html.replace(/\*\*([^\*]+)\*\*/g, '<strong>$1</strong>');
892
 
893
  // Italic (*text*)
894
  html = html.replace(/\*([^\*]+)\*/g, '<em>$1</em>');
895
 
896
+ // Tables (before paragraph processing)
897
+ // First, clean up <br> tags in table cells
898
+ // Changed + to * to make data rows optional (allows tables with just headers)
899
+ html = html.replace(/(\|.+\|[\r\n]+)(\|[\s:|-]+\|[\r\n]+)((?:\|.+\|[\r\n]*)*)/g, (match, header, separator, rows) => {
900
+ // Clean escaped <br> tags in header and rows
901
+ const cleanedHeader = header.replace(/&lt;br&gt;/gi, ' ');
902
+ const cleanedRows = rows.replace(/&lt;br&gt;/gi, ' ');
903
+
904
+ // Split header and keep empty cells (don't filter them out)
905
+ const headerParts = cleanedHeader.trim().split('|');
906
+ // Remove first and last empty elements (from leading/trailing |)
907
+ if (headerParts[0].trim() === '') headerParts.shift();
908
+ if (headerParts[headerParts.length - 1].trim() === '') headerParts.pop();
909
+ const headerCells = headerParts.map(cell =>
910
+ `<th>${cell.trim()}</th>`
911
+ ).join('');
912
+
913
+ // Only process rows if they exist
914
+ let rowsHtml = '';
915
+ if (cleanedRows.trim()) {
916
+ rowsHtml = cleanedRows.trim().split('\n').map(row => {
917
+ const rowParts = row.trim().split('|');
918
+ // Remove first and last empty elements (from leading/trailing |)
919
+ if (rowParts[0].trim() === '') rowParts.shift();
920
+ if (rowParts[rowParts.length - 1].trim() === '') rowParts.pop();
921
+ const cells = rowParts.map(cell =>
922
+ `<td>${cell.trim()}</td>`
923
+ ).join('');
924
+ return cells ? `<tr>${cells}</tr>` : '';
925
+ }).filter(row => row).join('');
926
+ }
927
+
928
+ // Add newline after table to preserve line breaks with following content
929
+ return `<table class="markdown-table"><thead><tr>${headerCells}</tr></thead><tbody>${rowsHtml}</tbody></table>\n`;
930
+ });
931
+
932
+ // Now clean up any remaining <br> tags (convert to newlines for paragraph processing)
933
+ html = html.replace(/&lt;br&gt;/gi, '\n');
934
+
935
+ // Split into lines for processing
936
+ const lines = html.split('\n');
937
+ const processedLines = [];
938
+ let i = 0;
939
+
940
+ while (i < lines.length) {
941
+ const line = lines[i].trim();
942
+
943
+ // Skip empty lines
944
+ if (!line) {
945
+ i++;
946
+ continue;
947
+ }
948
+
949
+ // Skip horizontal rules / separator lines (lines with only dashes, possibly with spaces)
950
+ if (line.match(/^[\s-]+$/)) {
951
+ i++;
952
+ continue;
953
+ }
954
+
955
+ // Don't process special elements
956
+ if (line.startsWith('<pre>') || line.startsWith('<table') || line.startsWith('__FIGURE_')) {
957
+ processedLines.push(line);
958
+ i++;
959
+ continue;
960
+ }
961
+
962
+ // Check for headings (must start with ###, ##, or # followed by space or content)
963
+ const h3Match = line.match(/^###\s*(.*)$/);
964
+ if (h3Match) {
965
+ processedLines.push(`<h3>${h3Match[1]}</h3>`);
966
+ i++;
967
+ continue;
968
+ }
969
+
970
+ const h2Match = line.match(/^##\s*(.*)$/);
971
+ if (h2Match) {
972
+ processedLines.push(`<h2>${h2Match[1]}</h2>`);
973
+ i++;
974
+ continue;
975
  }
 
 
976
 
977
+ const h1Match = line.match(/^#\s*(.*)$/);
978
+ if (h1Match) {
979
+ processedLines.push(`<h1>${h1Match[1]}</h1>`);
980
+ i++;
981
+ continue;
982
+ }
983
+
984
+ // Check for unordered list
985
+ if (line.match(/^[-*+]\s/)) {
986
+ const listItems = [];
987
+ while (i < lines.length && lines[i].trim().match(/^[-*+]\s/)) {
988
+ const match = lines[i].trim().match(/^[-*+]\s(.+)$/);
989
+ if (match) listItems.push(`<li>${match[1]}</li>`);
990
+ i++;
991
+ }
992
+ processedLines.push(`<ul>${listItems.join('')}</ul>`);
993
+ continue;
994
+ }
995
+
996
+ // Check for ordered list
997
+ if (line.match(/^\d+\.\s/)) {
998
+ const listItems = [];
999
+ while (i < lines.length && lines[i].trim().match(/^\d+\.\s/)) {
1000
+ const match = lines[i].trim().match(/^\d+\.\s(.+)$/);
1001
+ if (match) listItems.push(`<li>${match[1]}</li>`);
1002
+ i++;
1003
+ }
1004
+ processedLines.push(`<ol>${listItems.join('')}</ol>`);
1005
+ continue;
1006
+ }
1007
+
1008
+ // Collect paragraph lines (until we hit a blank line or special element)
1009
+ const paragraphLines = [];
1010
+ while (i < lines.length) {
1011
+ const currentLine = lines[i].trim();
1012
+
1013
+ // Stop at blank line
1014
+ if (!currentLine) break;
1015
+
1016
+ // Stop at special elements
1017
+ if (currentLine.startsWith('<pre>') ||
1018
+ currentLine.startsWith('<table') ||
1019
+ currentLine.startsWith('__FIGURE_') ||
1020
+ currentLine.match(/^#{1,3}\s*/) ||
1021
+ currentLine.match(/^[-*+]\s/) ||
1022
+ currentLine.match(/^\d+\.\s/)) {
1023
+ break;
1024
+ }
1025
+
1026
+ paragraphLines.push(currentLine);
1027
+ i++;
1028
+ }
1029
+
1030
+ if (paragraphLines.length > 0) {
1031
+ processedLines.push(`<p>${paragraphLines.join(' ')}</p>`);
1032
+ }
1033
+ }
1034
+
1035
+ return processedLines.join('\n');
1036
  }
1037
 
1038
  // Settings management
 
1061
  document.getElementById('setting-model-code').value = settings.models?.code || '';
1062
  document.getElementById('setting-model-research').value = settings.models?.research || '';
1063
  document.getElementById('setting-model-chat').value = settings.models?.chat || '';
1064
+ document.getElementById('setting-research-sub-agent-model').value = settings.researchSubAgentModel || '';
1065
+ document.getElementById('setting-research-parallel-workers').value = settings.researchParallelWorkers || '';
1066
+ document.getElementById('setting-research-max-websites').value = settings.researchMaxWebsites || '';
1067
 
1068
  // Clear any status message
1069
  const status = document.getElementById('settingsStatus');
 
1087
  const modelCode = document.getElementById('setting-model-code').value.trim();
1088
  const modelResearch = document.getElementById('setting-model-research').value.trim();
1089
  const modelChat = document.getElementById('setting-model-chat').value.trim();
1090
+ const researchSubAgentModel = document.getElementById('setting-research-sub-agent-model').value.trim();
1091
+ const researchParallelWorkers = document.getElementById('setting-research-parallel-workers').value.trim();
1092
+ const researchMaxWebsites = document.getElementById('setting-research-max-websites').value.trim();
1093
 
1094
  // Validate endpoint
1095
  if (!endpoint) {
 
1109
  settings.model = model;
1110
  settings.e2bKey = e2bKey;
1111
  settings.serperKey = serperKey;
1112
+ settings.researchSubAgentModel = researchSubAgentModel;
1113
+ settings.researchParallelWorkers = researchParallelWorkers ? parseInt(researchParallelWorkers) : null;
1114
+ settings.researchMaxWebsites = researchMaxWebsites ? parseInt(researchMaxWebsites) : null;
1115
  settings.models = {
1116
  agent: modelAgent,
1117
  code: modelCode,
style.css CHANGED
@@ -245,7 +245,7 @@ body {
245
  background: #f5f5f5;
246
  padding: 15px 20px;
247
  border-bottom: 1px solid #ccc;
248
- display: flex;
249
  justify-content: space-between;
250
  align-items: center;
251
  gap: 20px;
@@ -280,9 +280,10 @@ body {
280
  }
281
 
282
  .chat-container {
283
- max-width: 900px;
 
284
  margin: 0 auto;
285
- font-size: 13px;
286
  }
287
 
288
  .jupyter-notebook-container {
@@ -354,6 +355,43 @@ body {
354
  word-wrap: break-word;
355
  }
356
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
357
  .message-content code {
358
  background: #e0e0e0;
359
  padding: 2px 6px;
@@ -685,27 +723,39 @@ body {
685
  /* Action Widget */
686
  .action-widget {
687
  margin: 8px 0;
688
- padding: 10px 12px;
689
- background: #e8f5e9;
690
  border: 1px solid #1b5e20;
691
  border-left: 4px solid #1b5e20;
692
  border-radius: 4px;
693
  font-size: 12px;
694
  display: flex;
695
  flex-direction: column;
696
- gap: 6px;
697
  transition: all 0.2s;
 
698
  }
699
 
700
- .action-widget:hover {
701
- background: #c8e6c9;
702
- transform: translateX(2px);
 
 
 
 
 
 
 
 
703
  }
704
 
705
  .action-widget-header {
706
  display: flex;
707
  align-items: center;
708
  gap: 8px;
 
 
 
 
 
 
709
  }
710
 
711
  .action-widget-icon {
@@ -729,19 +779,53 @@ body {
729
  .action-widget-message {
730
  font-size: 10px;
731
  color: #666;
732
- padding: 6px 8px;
733
- background: rgba(27, 94, 32, 0.05);
734
- border-radius: 3px;
735
  font-style: italic;
736
  line-height: 1.4;
737
  }
738
 
739
  .action-widget-result {
740
- margin-top: 10px;
741
- padding: 10px 0;
742
  font-size: 12px;
743
  line-height: 1.6;
744
  color: #1a1a1a;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
745
  }
746
 
747
  .action-widget-result-header {
@@ -818,148 +902,192 @@ body {
818
  }
819
 
820
  /* Research Notebook Styles */
821
- .research-status {
822
  margin: 16px 0;
823
- padding: 12px 15px;
824
- background: #e3f2fd;
825
- border: 1px solid #1976d2;
826
- border-left: 4px solid #1976d2;
827
  border-radius: 4px;
 
 
 
 
 
 
 
828
  display: flex;
 
829
  align-items: center;
830
- gap: 12px;
 
 
831
  font-size: 13px;
 
 
832
  }
833
 
834
- .status-icon {
835
- font-size: 18px;
 
 
 
 
 
 
836
  }
837
 
838
- .status-message {
839
- flex: 1;
840
- color: #1a1a1a;
841
- line-height: 1.5;
842
  }
843
 
844
- .iteration-badge {
845
- background: #1976d2;
846
- color: white;
847
- padding: 4px 10px;
848
- border-radius: 3px;
849
- font-size: 10px;
 
 
 
 
850
  font-weight: 500;
851
- letter-spacing: 0.5px;
 
852
  }
853
 
854
- .research-queries {
855
- margin: 16px 0;
856
- padding: 15px;
857
- background: #f5f5f5;
858
- border: 1px solid #ccc;
859
  border-radius: 4px;
 
860
  }
861
 
862
- .queries-label {
863
- font-size: 11px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
864
  font-weight: 500;
 
 
 
 
 
865
  color: #666;
866
- text-transform: uppercase;
867
- letter-spacing: 1px;
868
- margin-bottom: 10px;
869
  }
870
 
871
- .queries-list {
872
- list-style: none;
873
- padding: 0;
874
- margin: 0;
875
  }
876
 
877
- .queries-list li {
878
- padding: 8px 12px;
879
- background: white;
880
- border: 1px solid #ddd;
881
- border-radius: 3px;
882
- margin-bottom: 6px;
883
- font-size: 12px;
884
- color: #1a1a1a;
885
  }
886
 
887
- .queries-list li:last-child {
888
- margin-bottom: 0;
889
  }
890
 
891
- .research-progress {
892
- margin: 16px 0;
893
- padding: 12px 15px;
894
- background: #fff3e0;
895
- border: 1px solid #f57c00;
896
- border-left: 4px solid #f57c00;
897
- border-radius: 4px;
898
  }
899
 
900
- .progress-message {
901
- color: #1a1a1a;
902
- font-size: 12px;
903
- margin-bottom: 10px;
904
  }
905
 
906
  .progress-bar {
 
907
  height: 6px;
908
- background: #ffe0b2;
909
  border-radius: 3px;
910
  overflow: hidden;
911
- margin-bottom: 6px;
912
  }
913
 
914
  .progress-fill {
915
  height: 100%;
916
- background: #f57c00;
917
  transition: width 0.3s ease;
918
  }
919
 
920
- .progress-count {
921
  font-size: 11px;
922
  color: #666;
923
- font-weight: 500;
924
  }
925
 
926
  .research-source {
927
- margin: 16px 0;
928
- padding: 15px;
 
929
  background: white;
930
- border: 1px solid #ccc;
931
- border-radius: 4px;
932
- transition: all 0.2s;
933
  }
934
 
935
- .research-source:hover {
936
- box-shadow: 0 2px 8px rgba(0,0,0,0.1);
937
- transform: translateY(-1px);
 
 
 
 
 
938
  }
939
 
940
  .source-header {
941
  display: flex;
942
  align-items: center;
943
- gap: 10px;
944
- margin-bottom: 10px;
 
 
945
  }
946
 
947
- .source-badge {
948
- background: #1b5e20;
949
- color: white;
950
- padding: 4px 10px;
951
- border-radius: 3px;
952
- font-size: 10px;
953
- font-weight: 500;
954
- letter-spacing: 0.5px;
955
- white-space: nowrap;
 
 
 
 
 
 
 
 
 
 
 
956
  }
957
 
958
  .source-url {
959
  color: #1976d2;
960
  text-decoration: none;
961
  font-size: 12px;
962
- font-weight: 500;
963
  flex: 1;
964
  overflow: hidden;
965
  text-overflow: ellipsis;
@@ -970,14 +1098,53 @@ body {
970
  text-decoration: underline;
971
  }
972
 
 
 
 
 
 
973
  .source-analysis {
974
  font-size: 12px;
975
  line-height: 1.6;
976
- color: #1a1a1a;
977
- padding: 10px 12px;
978
- background: #fafafa;
979
- border-radius: 3px;
980
- white-space: pre-wrap;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
981
  }
982
 
983
  .research-assessment {
@@ -1048,36 +1215,24 @@ body {
1048
  }
1049
 
1050
  .research-report {
1051
- margin: 20px 0;
1052
- padding: 20px;
1053
  background: white;
1054
- border: 1px solid #1b5e20;
1055
  border-radius: 4px;
 
1056
  }
1057
 
1058
  .report-header {
1059
- display: flex;
1060
- justify-content: space-between;
1061
- align-items: center;
1062
- padding-bottom: 15px;
1063
- margin-bottom: 15px;
1064
- border-bottom: 2px solid #1b5e20;
1065
- }
1066
-
1067
- .report-title {
1068
- font-size: 16px;
1069
- font-weight: 500;
1070
- color: #1b5e20;
1071
- letter-spacing: 0.5px;
1072
- }
1073
-
1074
- .report-stats {
1075
- font-size: 11px;
1076
- color: #666;
1077
  font-weight: 500;
 
1078
  }
1079
 
1080
  .report-content {
 
1081
  font-size: 13px;
1082
  line-height: 1.8;
1083
  color: #1a1a1a;
@@ -1123,7 +1278,57 @@ body {
1123
  color: #1a1a1a;
1124
  }
1125
 
1126
- .report-content code {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1127
  background: #f5f5f5;
1128
  padding: 2px 6px;
1129
  border-radius: 3px;
 
245
  background: #f5f5f5;
246
  padding: 15px 20px;
247
  border-bottom: 1px solid #ccc;
248
+ display: none; /* Hidden for cleaner UI */
249
  justify-content: space-between;
250
  align-items: center;
251
  gap: 20px;
 
280
  }
281
 
282
  .chat-container {
283
+ max-width: 1000px;
284
+ width: 100%;
285
  margin: 0 auto;
286
+ font-size: 12px;
287
  }
288
 
289
  .jupyter-notebook-container {
 
355
  word-wrap: break-word;
356
  }
357
 
358
+ .message-content ul,
359
+ .message-content ol {
360
+ margin: 8px 0;
361
+ padding-left: 24px;
362
+ list-style-position: outside;
363
+ }
364
+
365
+ .message-content li {
366
+ margin-bottom: 4px;
367
+ white-space: normal;
368
+ }
369
+
370
+ .message-content h1,
371
+ .message-content h2,
372
+ .message-content h3 {
373
+ margin: 12px 0 8px 0;
374
+ font-weight: 500;
375
+ white-space: normal;
376
+ }
377
+
378
+ .message-content h1 {
379
+ font-size: 16px;
380
+ }
381
+
382
+ .message-content h2 {
383
+ font-size: 15px;
384
+ }
385
+
386
+ .message-content h3 {
387
+ font-size: 14px;
388
+ }
389
+
390
+ .message-content p {
391
+ margin-bottom: 8px;
392
+ white-space: pre-wrap;
393
+ }
394
+
395
  .message-content code {
396
  background: #e0e0e0;
397
  padding: 2px 6px;
 
723
  /* Action Widget */
724
  .action-widget {
725
  margin: 8px 0;
 
 
726
  border: 1px solid #1b5e20;
727
  border-left: 4px solid #1b5e20;
728
  border-radius: 4px;
729
  font-size: 12px;
730
  display: flex;
731
  flex-direction: column;
 
732
  transition: all 0.2s;
733
+ overflow: hidden;
734
  }
735
 
736
+ .action-widget.running {
737
+ animation: pulse-border 2s ease-in-out infinite;
738
+ }
739
+
740
+ @keyframes pulse-border {
741
+ 0%, 100% {
742
+ border-left-color: #1b5e20;
743
+ }
744
+ 50% {
745
+ border-left-color: #4caf50;
746
+ }
747
  }
748
 
749
  .action-widget-header {
750
  display: flex;
751
  align-items: center;
752
  gap: 8px;
753
+ padding: 10px 12px;
754
+ background: #e8f5e9;
755
+ }
756
+
757
+ .action-widget-header:hover {
758
+ background: #c8e6c9;
759
  }
760
 
761
  .action-widget-icon {
 
779
  .action-widget-message {
780
  font-size: 10px;
781
  color: #666;
782
+ padding: 6px 8px 10px 12px;
783
+ background: #e8f5e9;
 
784
  font-style: italic;
785
  line-height: 1.4;
786
  }
787
 
788
  .action-widget-result {
789
+ padding: 12px;
 
790
  font-size: 12px;
791
  line-height: 1.6;
792
  color: #1a1a1a;
793
+ background: white;
794
+ border-top: 1px solid #e0e0e0;
795
+ }
796
+
797
+ .action-widget-result ul,
798
+ .action-widget-result ol {
799
+ margin: 8px 0;
800
+ padding-left: 24px;
801
+ list-style-position: outside;
802
+ }
803
+
804
+ .action-widget-result li {
805
+ margin-bottom: 4px;
806
+ }
807
+
808
+ .action-widget-result h1,
809
+ .action-widget-result h2,
810
+ .action-widget-result h3 {
811
+ margin: 12px 0 8px 0;
812
+ font-weight: 500;
813
+ }
814
+
815
+ .action-widget-result h1 {
816
+ font-size: 14px;
817
+ }
818
+
819
+ .action-widget-result h2 {
820
+ font-size: 13px;
821
+ }
822
+
823
+ .action-widget-result h3 {
824
+ font-size: 12px;
825
+ }
826
+
827
+ .action-widget-result p {
828
+ margin-bottom: 8px;
829
  }
830
 
831
  .action-widget-result-header {
 
902
  }
903
 
904
  /* Research Notebook Styles */
905
+ .research-container {
906
  margin: 16px 0;
907
+ background: white;
908
+ border: 1px solid #e0e0e0;
 
 
909
  border-radius: 4px;
910
+ overflow: hidden;
911
+ }
912
+
913
+ .research-header {
914
+ padding: 12px 16px;
915
+ background: #f8f8f8;
916
+ border-bottom: 1px solid #e0e0e0;
917
  display: flex;
918
+ justify-content: space-between;
919
  align-items: center;
920
+ }
921
+
922
+ .research-title {
923
  font-size: 13px;
924
+ font-weight: 500;
925
+ color: #333;
926
  }
927
 
928
+ .toggle-irrelevant-btn {
929
+ padding: 6px 12px;
930
+ background: white;
931
+ border: 1px solid #ccc;
932
+ border-radius: 3px;
933
+ font-size: 11px;
934
+ cursor: pointer;
935
+ transition: all 0.2s;
936
  }
937
 
938
+ .toggle-irrelevant-btn:hover {
939
+ background: #f5f5f5;
940
+ border-color: #999;
 
941
  }
942
 
943
+ .research-body {
944
+ padding: 16px;
945
+ }
946
+
947
+ .research-queries-section {
948
+ margin-bottom: 16px;
949
+ }
950
+
951
+ .queries-title {
952
+ font-size: 12px;
953
  font-weight: 500;
954
+ color: #666;
955
+ margin-bottom: 12px;
956
  }
957
 
958
+ .query-group {
959
+ margin-bottom: 20px;
960
+ border: 1px solid #e0e0e0;
 
 
961
  border-radius: 4px;
962
+ background: white;
963
  }
964
 
965
+ .query-group:last-child {
966
+ margin-bottom: 0;
967
+ }
968
+
969
+ .query-header {
970
+ padding: 10px 12px;
971
+ background: #f8f8f8;
972
+ border-bottom: 1px solid #e0e0e0;
973
+ display: flex;
974
+ justify-content: space-between;
975
+ align-items: center;
976
+ }
977
+
978
+ .query-text {
979
+ font-size: 12px;
980
+ color: #333;
981
  font-weight: 500;
982
+ flex: 1;
983
+ }
984
+
985
+ .query-stats {
986
+ font-size: 11px;
987
  color: #666;
988
+ white-space: nowrap;
989
+ margin-left: 12px;
 
990
  }
991
 
992
+ .query-sources {
993
+ padding: 8px;
 
 
994
  }
995
 
996
+ .no-sources {
997
+ padding: 12px;
998
+ text-align: center;
999
+ color: #999;
1000
+ font-size: 11px;
1001
+ font-style: italic;
 
 
1002
  }
1003
 
1004
+ .research-sources-section {
1005
+ margin-bottom: 16px;
1006
  }
1007
 
1008
+ .research-progress-section {
1009
+ padding-top: 12px;
1010
+ border-top: 1px solid #f0f0f0;
 
 
 
 
1011
  }
1012
 
1013
+ .progress-bar-container {
1014
+ display: flex;
1015
+ align-items: center;
1016
+ gap: 12px;
1017
  }
1018
 
1019
  .progress-bar {
1020
+ flex: 1;
1021
  height: 6px;
1022
+ background: #e0e0e0;
1023
  border-radius: 3px;
1024
  overflow: hidden;
 
1025
  }
1026
 
1027
  .progress-fill {
1028
  height: 100%;
1029
+ background: #4caf50;
1030
  transition: width 0.3s ease;
1031
  }
1032
 
1033
+ .progress-text {
1034
  font-size: 11px;
1035
  color: #666;
1036
+ white-space: nowrap;
1037
  }
1038
 
1039
  .research-source {
1040
+ margin: 6px 0;
1041
+ border: 1px solid #e0e0e0;
1042
+ border-radius: 3px;
1043
  background: white;
 
 
 
1044
  }
1045
 
1046
+ .research-source.irrelevant {
1047
+ opacity: 0.6;
1048
+ background: #fafafa;
1049
+ }
1050
+
1051
+ .research-source.error {
1052
+ background: #fff5f5;
1053
+ border-color: #ffcdd2;
1054
  }
1055
 
1056
  .source-header {
1057
  display: flex;
1058
  align-items: center;
1059
+ gap: 8px;
1060
+ padding: 8px 10px;
1061
+ cursor: pointer;
1062
+ user-select: none;
1063
  }
1064
 
1065
+ .source-header:hover {
1066
+ background: #f8f8f8;
1067
+ }
1068
+
1069
+ .source-status-icon {
1070
+ font-size: 12px;
1071
+ min-width: 16px;
1072
+ text-align: center;
1073
+ }
1074
+
1075
+ .research-source.relevant .source-status-icon {
1076
+ color: #2e7d32;
1077
+ }
1078
+
1079
+ .research-source.irrelevant .source-status-icon {
1080
+ color: #999;
1081
+ }
1082
+
1083
+ .research-source.error .source-status-icon {
1084
+ color: #d32f2f;
1085
  }
1086
 
1087
  .source-url {
1088
  color: #1976d2;
1089
  text-decoration: none;
1090
  font-size: 12px;
 
1091
  flex: 1;
1092
  overflow: hidden;
1093
  text-overflow: ellipsis;
 
1098
  text-decoration: underline;
1099
  }
1100
 
1101
+ .source-toggle {
1102
+ color: #999;
1103
+ font-size: 10px;
1104
+ }
1105
+
1106
  .source-analysis {
1107
  font-size: 12px;
1108
  line-height: 1.6;
1109
+ color: #333;
1110
+ padding: 0 12px 12px 12px;
1111
+ border-top: 1px solid #e0e0e0;
1112
+ overflow-wrap: break-word;
1113
+ word-wrap: break-word;
1114
+ }
1115
+
1116
+ .source-analysis ul,
1117
+ .source-analysis ol {
1118
+ margin: 8px 0;
1119
+ padding-left: 24px;
1120
+ list-style-position: outside;
1121
+ }
1122
+
1123
+ .source-analysis li {
1124
+ margin-bottom: 4px;
1125
+ }
1126
+
1127
+ .source-analysis h1,
1128
+ .source-analysis h2,
1129
+ .source-analysis h3 {
1130
+ margin: 12px 0 8px 0;
1131
+ font-weight: 500;
1132
+ }
1133
+
1134
+ .source-analysis h1 {
1135
+ font-size: 15px;
1136
+ }
1137
+
1138
+ .source-analysis h2 {
1139
+ font-size: 14px;
1140
+ }
1141
+
1142
+ .source-analysis h3 {
1143
+ font-size: 13px;
1144
+ }
1145
+
1146
+ .source-analysis p {
1147
+ margin-bottom: 8px;
1148
  }
1149
 
1150
  .research-assessment {
 
1215
  }
1216
 
1217
  .research-report {
1218
+ margin: 16px 0;
 
1219
  background: white;
1220
+ border: 1px solid #e0e0e0;
1221
  border-radius: 4px;
1222
+ overflow: hidden;
1223
  }
1224
 
1225
  .report-header {
1226
+ padding: 12px 16px;
1227
+ background: #f8f8f8;
1228
+ border-bottom: 1px solid #e0e0e0;
1229
+ font-size: 13px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1230
  font-weight: 500;
1231
+ color: #333;
1232
  }
1233
 
1234
  .report-content {
1235
+ padding: 16px;
1236
  font-size: 13px;
1237
  line-height: 1.8;
1238
  color: #1a1a1a;
 
1278
  color: #1a1a1a;
1279
  }
1280
 
1281
+ .report-content a,
1282
+ .source-analysis a,
1283
+ .action-widget-result a {
1284
+ color: #1976d2;
1285
+ text-decoration: none;
1286
+ border-bottom: 1px solid transparent;
1287
+ transition: border-color 0.2s;
1288
+ }
1289
+
1290
+ .report-content a:hover,
1291
+ .source-analysis a:hover,
1292
+ .action-widget-result a:hover {
1293
+ border-bottom-color: #1976d2;
1294
+ }
1295
+
1296
+ /* Markdown tables */
1297
+ .markdown-table {
1298
+ border-collapse: collapse;
1299
+ width: 100%;
1300
+ margin: 12px 0;
1301
+ font-size: 12px;
1302
+ background: white;
1303
+ border: 1px solid #e0e0e0;
1304
+ }
1305
+
1306
+ .markdown-table th {
1307
+ background: #f8f8f8;
1308
+ padding: 8px 12px;
1309
+ text-align: left;
1310
+ font-weight: 500;
1311
+ color: #333;
1312
+ border-bottom: 2px solid #e0e0e0;
1313
+ }
1314
+
1315
+ .markdown-table td {
1316
+ padding: 8px 12px;
1317
+ border-bottom: 1px solid #f0f0f0;
1318
+ color: #333;
1319
+ }
1320
+
1321
+ .markdown-table tr:last-child td {
1322
+ border-bottom: none;
1323
+ }
1324
+
1325
+ .markdown-table tr:hover {
1326
+ background: #fafafa;
1327
+ }
1328
+
1329
+ .report-content code,
1330
+ .result-preview-content code,
1331
+ .action-widget-result code {
1332
  background: #f5f5f5;
1333
  padding: 2px 6px;
1334
  border-radius: 3px;