entari-plugin-hyw 4.0.0rc5__py3-none-any.whl → 4.0.0rc7__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of entari-plugin-hyw might be problematic. Click here for more details.

@@ -75,7 +75,7 @@ class HistoryManager:
75
75
  self._context_history[context_id] = []
76
76
  self._context_history[context_id].append(key)
77
77
 
78
- def save_to_disk(self, key: str, save_root: str = "data/conversations", image_path: Optional[str] = None, web_results: Optional[List[Dict]] = None):
78
+ def save_to_disk(self, key: str, save_root: str = "data/conversations", image_path: Optional[str] = None, web_results: Optional[List[Dict]] = None, vision_trace: Optional[Dict] = None, instruct_traces: Optional[List[Dict]] = None):
79
79
  """Save conversation history to specific folder structure"""
80
80
  import os
81
81
  import time
@@ -198,51 +198,41 @@ class HistoryManager:
198
198
  except Exception as e:
199
199
  print(f"Failed to copy output image: {e}")
200
200
 
201
- # 4. Save Full Log (Readme style)
202
- timestamp = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
203
- model_name = meta.get("model", "unknown")
204
- code = self._key_to_code.get(key, "N/A")
205
-
206
- md_content = f"# Conversation Log: {folder_name}\n\n"
207
- md_content += f"- **Time**: {timestamp}\n"
208
- md_content += f"- **Code**: {code}\n"
209
- md_content += f"- **Model**: {model_name}\n\n"
210
-
211
- md_content += "## History\n\n"
212
-
213
- for msg in self._history[key]:
214
- role = msg.get("role", "unknown").upper()
215
- content = msg.get("content", "")
216
-
217
- md_content += f"### {role}\n\n"
201
+ # 4. Save Vision Log (if vision stage was used)
202
+ if vision_trace and not vision_trace.get("skipped"):
203
+ vision_md = "# Vision Stage Log\n\n"
204
+ vision_md += f"- **Model**: {vision_trace.get('model', 'unknown')}\n"
205
+ vision_md += f"- **Time**: {vision_trace.get('time', 0):.2f}s\n"
206
+ vision_md += f"- **Images Count**: {vision_trace.get('images_count', 0)}\n"
207
+ vision_md += f"- **Input Tokens**: {vision_trace.get('usage', {}).get('input_tokens', 0)}\n"
208
+ vision_md += f"- **Output Tokens**: {vision_trace.get('usage', {}).get('output_tokens', 0)}\n\n"
209
+ vision_md += "## Vision Description Output\n\n"
210
+ vision_md += f"```\n{vision_trace.get('output', '')}\n```\n"
218
211
 
219
- tool_calls = msg.get("tool_calls")
220
- if tool_calls:
221
- try:
222
- tc_str = json.dumps(tool_calls, ensure_ascii=False, indent=2)
223
- except:
224
- tc_str = str(tool_calls)
225
- md_content += f"**Tool Calls**:\n```json\n{tc_str}\n```\n\n"
226
-
227
- if role == "TOOL":
228
- try:
229
- # Try parsing as JSON first
230
- if isinstance(content, str):
231
- parsed = json.loads(content)
232
- pretty = json.dumps(parsed, ensure_ascii=False, indent=2)
233
- md_content += f"**Output**:\n```json\n{pretty}\n```\n\n"
234
- else:
235
- md_content += f"**Output**:\n```text\n{content}\n```\n\n"
236
- except:
237
- md_content += f"**Output**:\n```text\n{content}\n```\n\n"
238
- else:
239
- if content:
240
- md_content += f"{content}\n\n"
241
-
242
- md_content += "---\n\n"
212
+ with open(os.path.join(folder_path, "vision_log.md"), "w", encoding="utf-8") as f:
213
+ f.write(vision_md)
243
214
 
244
- with open(os.path.join(folder_path, "full_log.md"), "w", encoding="utf-8") as f:
245
- f.write(md_content)
215
+ # 5. Save Instruct Log (all instruct rounds)
216
+ if instruct_traces:
217
+ instruct_md = "# Instruct Stage Log\n\n"
218
+ for i, trace in enumerate(instruct_traces):
219
+ stage_name = trace.get("stage_name", f"Round {i+1}")
220
+ instruct_md += f"## {stage_name}\n\n"
221
+ instruct_md += f"- **Model**: {trace.get('model', 'unknown')}\n"
222
+ instruct_md += f"- **Time**: {trace.get('time', 0):.2f}s\n"
223
+ instruct_md += f"- **Tool Calls**: {trace.get('tool_calls', 0)}\n"
224
+ instruct_md += f"- **Input Tokens**: {trace.get('usage', {}).get('input_tokens', 0)}\n"
225
+ instruct_md += f"- **Output Tokens**: {trace.get('usage', {}).get('output_tokens', 0)}\n\n"
226
+
227
+ output = trace.get("output", "")
228
+ if output:
229
+ instruct_md += "### Reasoning Output\n\n"
230
+ instruct_md += f"```\n{output}\n```\n\n"
231
+
232
+ instruct_md += "---\n\n"
233
+
234
+ with open(os.path.join(folder_path, "instruct_log.md"), "w", encoding="utf-8") as f:
235
+ f.write(instruct_md)
246
236
 
247
237
  except Exception as e:
248
238
  print(f"Failed to save conversation: {e}")
entari_plugin_hyw/misc.py CHANGED
@@ -133,3 +133,37 @@ async def render_refuse_answer(
133
133
  theme_color=theme_color,
134
134
  )
135
135
 
136
+
137
+ IMAGE_UNSUPPORTED_MARKDOWN = """
138
+ <summary>
139
+ 当前模型不支持图片输入,请使用支持视觉能力的模型或仅发送文本。
140
+ </summary>
141
+ """
142
+
143
+ async def render_image_unsupported(
144
+ renderer,
145
+ output_path: str,
146
+ theme_color: str = "#ef4444",
147
+ tab_id: str = None
148
+ ) -> bool:
149
+ """
150
+ Render a card indicating that the model does not support image input.
151
+ """
152
+ markdown = f"""
153
+ # 图片输入不支持
154
+
155
+ > 当前选择的模型不支持图片输入。
156
+ > 请切换到支持视觉的模型,或仅发送文本内容。
157
+ """
158
+ return await renderer.render(
159
+ markdown_content=markdown,
160
+ output_path=output_path,
161
+ stats={},
162
+ references=[],
163
+ page_references=[],
164
+ image_references=[],
165
+ stages_used=[],
166
+ image_timeout=1000,
167
+ theme_color=theme_color,
168
+ tab_id=tab_id
169
+ )
@@ -7,15 +7,16 @@ Simpler flow with self-correction/feedback loop.
7
7
 
8
8
  import asyncio
9
9
  import time
10
- from typing import Any, Dict, List, Optional
10
+ from typing import Any, Dict, List, Optional, Callable, Awaitable
11
11
 
12
12
  from loguru import logger
13
13
  from openai import AsyncOpenAI
14
14
 
15
15
  from .stage_base import StageContext
16
16
  from .stage_instruct import InstructStage
17
- from .stage_instruct_review import InstructReviewStage
17
+ from .stage_instruct_deepsearch import InstructDeepsearchStage
18
18
  from .stage_summary import SummaryStage
19
+ from .stage_vision import VisionStage
19
20
  from .search import SearchService
20
21
 
21
22
 
@@ -24,20 +25,27 @@ class ModularPipeline:
24
25
  Modular Pipeline.
25
26
 
26
27
  Flow:
27
- 1. Instruct (Round 1): Initial Discovery.
28
- 2. Instruct Review (Round 2): Review & Refine.
28
+ 1. Instruct: Initial Discovery + Mode Decision (fast/deepsearch).
29
+ 2. [Deepsearch only] Instruct Deepsearch Loop: Supplement info (max 3 iterations).
29
30
  3. Summary: Generate final response.
30
31
  """
31
32
 
32
- def __init__(self, config: Any):
33
+ def __init__(self, config: Any, send_func: Optional[Callable[[str], Awaitable[None]]] = None):
33
34
  self.config = config
35
+ self.send_func = send_func
34
36
  self.search_service = SearchService(config)
35
37
  self.client = AsyncOpenAI(base_url=config.base_url, api_key=config.api_key)
36
38
 
37
39
  # Initialize stages
38
- self.instruct_stage = InstructStage(config, self.search_service, self.client)
39
- self.instruct_review_stage = InstructReviewStage(config, self.search_service, self.client)
40
+ self.instruct_stage = InstructStage(config, self.search_service, self.client, send_func=send_func)
41
+ self.instruct_deepsearch_stage = InstructDeepsearchStage(config, self.search_service, self.client)
40
42
  self.summary_stage = SummaryStage(config, self.search_service, self.client)
43
+ self.vision_stage = VisionStage(config, self.search_service, self.client)
44
+
45
+ def _has_vision_model(self) -> bool:
46
+ """Check if a vision model is configured."""
47
+ vision_cfg = self.config.get_model_config("vision")
48
+ return bool(vision_cfg.get("model_name"))
41
49
 
42
50
  async def execute(
43
51
  self,
@@ -53,6 +61,9 @@ class ModularPipeline:
53
61
  stats = {"start_time": start_time}
54
62
  usage_totals = {"input_tokens": 0, "output_tokens": 0}
55
63
  active_model = model_name or self.config.model_name
64
+ if not active_model:
65
+ # Fallback to instruct model for logging/context
66
+ active_model = self.config.get_model_config("instruct").get("model_name")
56
67
 
57
68
  context = StageContext(
58
69
  user_input=user_input,
@@ -60,6 +71,16 @@ class ModularPipeline:
60
71
  conversation_history=conversation_history,
61
72
  )
62
73
 
74
+ # Determine if model supports image input
75
+ model_cfg_dict = next((m for m in self.config.models if m.get("name") == active_model), None)
76
+ if model_cfg_dict:
77
+ context.image_input_supported = model_cfg_dict.get("image_input", True)
78
+ else:
79
+ context.image_input_supported = True # Default to True if unknown
80
+
81
+ logger.info(f"Pipeline Execution: Model '{active_model}' Image Input Supported: {context.image_input_supported}")
82
+
83
+
63
84
  trace: Dict[str, Any] = {
64
85
  "instruct_rounds": [],
65
86
  "summary": None,
@@ -68,6 +89,24 @@ class ModularPipeline:
68
89
  try:
69
90
  logger.info(f"Pipeline: Processing '{user_input[:30]}...'")
70
91
 
92
+ # === Stage 0: Vision (if images and vision model configured) ===
93
+ if images and self._has_vision_model():
94
+ logger.info("Pipeline: Stage 0 - Vision (generating image description)")
95
+ vision_result = await self.vision_stage.execute(context, images)
96
+
97
+ if vision_result.success and vision_result.data.get("description"):
98
+ context.vision_description = vision_result.data["description"]
99
+ logger.info(f"Pipeline: Vision description generated ({len(context.vision_description)} chars)")
100
+
101
+ # Add vision trace
102
+ trace["vision"] = vision_result.trace
103
+ usage_totals["input_tokens"] += vision_result.usage.get("input_tokens", 0)
104
+ usage_totals["output_tokens"] += vision_result.usage.get("output_tokens", 0)
105
+
106
+ # Clear images since we have the description now
107
+ # (don't pass raw images to later stages when using vision model)
108
+ images = []
109
+
71
110
  # === Stage 1: Instruct (Initial Discovery) ===
72
111
  logger.info("Pipeline: Stage 1 - Instruct")
73
112
  instruct_result = await self.instruct_stage.execute(context)
@@ -82,30 +121,95 @@ class ModularPipeline:
82
121
  if context.should_refuse:
83
122
  return self._build_refusal_response(context, conversation_history, active_model, stats)
84
123
 
85
- # === Stage 2: Instruct Review (Refine) ===
86
- logger.info("Pipeline: Stage 2 - Instruct Review")
87
- review_result = await self.instruct_review_stage.execute(context)
124
+ # === Stage 2: Deepsearch Loop (if mode is deepsearch) ===
125
+ if context.selected_mode == "deepsearch":
126
+ MAX_DEEPSEARCH_ITERATIONS = 3
127
+ logger.info(f"Pipeline: Mode is 'deepsearch', starting loop (max {MAX_DEEPSEARCH_ITERATIONS} iterations)")
128
+
129
+ for i in range(MAX_DEEPSEARCH_ITERATIONS):
130
+ logger.info(f"Pipeline: Stage 2 - Deepsearch Iteration {i + 1}")
131
+ deepsearch_result = await self.instruct_deepsearch_stage.execute(context)
132
+
133
+ # Trace & Usage
134
+ deepsearch_result.trace["stage_name"] = f"Deepsearch (Iteration {i + 1})"
135
+ trace["instruct_rounds"].append(deepsearch_result.trace)
136
+ usage_totals["input_tokens"] += deepsearch_result.usage.get("input_tokens", 0)
137
+ usage_totals["output_tokens"] += deepsearch_result.usage.get("output_tokens", 0)
138
+
139
+ # Check if should stop
140
+ if deepsearch_result.data.get("should_stop"):
141
+ logger.info(f"Pipeline: Deepsearch loop ended at iteration {i + 1}")
142
+ break
143
+ else:
144
+ logger.info("Pipeline: Mode is 'fast', skipping deepsearch stage")
145
+
146
+ # === Parallel Execution: Summary Generation + Image Prefetching ===
147
+ # We run image prefetching concurrently with Summary generation to save time.
148
+
149
+ # 1. Prepare candidates for prefetch (all images in search results)
150
+ all_candidate_urls = set()
151
+ for r in context.web_results:
152
+ # Add images from search results/pages
153
+ if r.get("images"):
154
+ for img in r["images"]:
155
+ if img and isinstance(img, str) and img.startswith("http"):
156
+ all_candidate_urls.add(img)
157
+
158
+ prefetch_list = list(all_candidate_urls)
159
+ logger.info(f"Pipeline: Starting parallel execution (Summary + Prefetch {len(prefetch_list)} images)")
160
+
161
+ # 2. Define parallel tasks with timing
162
+ async def timed_summary():
163
+ t0 = time.time()
164
+ # Collect page screenshots if image mode
165
+ summary_input_images = list(images) if images else []
166
+ if context.image_input_supported:
167
+ # Collect pre-rendered screenshots from web_results
168
+ for r in context.web_results:
169
+ if r.get("_type") == "page" and r.get("screenshot_b64"):
170
+ summary_input_images.append(r["screenshot_b64"])
171
+
172
+ res = await self.summary_stage.execute(
173
+ context,
174
+ images=summary_input_images if summary_input_images else None
175
+ )
176
+ duration = time.time() - t0
177
+ return res, duration
178
+
179
+ async def timed_prefetch():
180
+ t0 = time.time()
181
+ if not prefetch_list:
182
+ return {}, 0.0
183
+ try:
184
+ from .image_cache import get_image_cache
185
+ cache = get_image_cache()
186
+ # Start prefetch (non-blocking kickoff)
187
+ cache.start_prefetch(prefetch_list)
188
+ # Wait for results (blocking until done)
189
+ res = await cache.get_all_cached(prefetch_list)
190
+ duration = time.time() - t0
191
+ return res, duration
192
+ except Exception as e:
193
+ logger.warning(f"Pipeline: Prefetch failed: {e}")
194
+ return {}, time.time() - t0
195
+
196
+ # 3. Execute concurrently
197
+ summary_task = asyncio.create_task(timed_summary())
198
+ prefetch_task = asyncio.create_task(timed_prefetch())
199
+
200
+ # Wait for both to complete
201
+ await asyncio.wait([summary_task, prefetch_task])
202
+
203
+ # 4. Process results and log timing
204
+ summary_result, summary_time = await summary_task
205
+ cached_map, prefetch_time = await prefetch_task
206
+
207
+ time_diff = abs(summary_time - prefetch_time)
208
+ if summary_time > prefetch_time:
209
+ logger.info(f"Pipeline: Image Prefetch finished first ({prefetch_time:.2f}s). Summary took {summary_time:.2f}s. (Waited {time_diff:.2f}s for Summary)")
210
+ else:
211
+ logger.info(f"Pipeline: Summary finished first ({summary_time:.2f}s). Image Prefetch took {prefetch_time:.2f}s. (Waited {time_diff:.2f}s for Prefetch)")
88
212
 
89
- # Trace & Usage
90
- review_result.trace["stage_name"] = "Instruct Review (Round 2)"
91
- trace["instruct_rounds"].append(review_result.trace)
92
- usage_totals["input_tokens"] += review_result.usage.get("input_tokens", 0)
93
- usage_totals["output_tokens"] += review_result.usage.get("output_tokens", 0)
94
-
95
- # === Stage 3: Summary ===
96
- # Collect page screenshots if image mode (already rendered in InstructStage)
97
- all_images = list(images) if images else []
98
-
99
- if getattr(self.config, "page_content_mode", "text") == "image":
100
- # Collect pre-rendered screenshots from web_results
101
- for r in context.web_results:
102
- if r.get("_type") == "page" and r.get("screenshot_b64"):
103
- all_images.append(r["screenshot_b64"])
104
-
105
- summary_result = await self.summary_stage.execute(
106
- context,
107
- images=all_images if all_images else None
108
- )
109
213
  trace["summary"] = summary_result.trace
110
214
  usage_totals["input_tokens"] += summary_result.usage.get("input_tokens", 0)
111
215
  usage_totals["output_tokens"] += summary_result.usage.get("output_tokens", 0)
@@ -116,34 +220,34 @@ class ModularPipeline:
116
220
  stats["total_time"] = time.time() - start_time
117
221
  structured = self._parse_response(summary_content, context)
118
222
 
119
- # === Image Caching (Prefetch images for UI) ===
120
- try:
121
- from .image_cache import get_image_cache
122
- cache = get_image_cache()
123
-
124
- # 1. Collect all image URLs from structured response
125
- all_image_urls = []
126
- for ref in structured.get("references", []):
127
- if ref.get("images"):
128
- all_image_urls.extend([img for img in ref["images"] if img and img.startswith("http")])
129
-
130
- if all_image_urls:
131
- # 2. Prefetch (wait for them as we are about to render)
132
- cached_map = await cache.get_all_cached(all_image_urls)
133
-
134
- # 3. Update structured response with cached (base64) URLs
223
+ # === Apply Cached Images ===
224
+ # Update structured response using the map from parallel prefetch
225
+ if cached_map:
226
+ try:
227
+ total_replaced = 0
135
228
  for ref in structured.get("references", []):
136
229
  if ref.get("images"):
137
- # Filter: Only keep images that were successfully cached (starts with data:)
138
- # Discard original URLs if download failed, to prevent broken images in UI
139
230
  new_images = []
140
231
  for img in ref["images"]:
232
+ # 1. Already Base64 -> Keep it
233
+ if img.startswith("data:"):
234
+ new_images.append(img)
235
+ continue
236
+
237
+ # 2. Check cache
141
238
  cached_val = cached_map.get(img)
142
239
  if cached_val and cached_val.startswith("data:"):
143
240
  new_images.append(cached_val)
241
+ total_replaced += 1
242
+ # 3. Else -> DROP IT (as per policy)
144
243
  ref["images"] = new_images
145
- except Exception as e:
146
- logger.warning(f"Pipeline: Image caching failed: {e}")
244
+ logger.debug(f"Pipeline: Replaced {total_replaced} images with cached versions")
245
+ except Exception as e:
246
+ logger.warning(f"Pipeline: Applying cached images failed: {e}")
247
+
248
+ # Debug: Log image counts
249
+ total_ref_images = sum(len(ref.get("images", []) or []) for ref in structured.get("references", []))
250
+ logger.info(f"Pipeline: Final structured response has {len(structured.get('references', []))} refs with {total_ref_images} images total")
147
251
 
148
252
  stages_used = self._build_stages_ui(trace, context, images)
149
253
 
@@ -164,6 +268,8 @@ class ModularPipeline:
164
268
  },
165
269
  "stages_used": stages_used,
166
270
  "web_results": context.web_results,
271
+ "vision_trace": trace.get("vision"),
272
+ "instruct_traces": trace.get("instruct_rounds", []),
167
273
  }
168
274
 
169
275
  except Exception as e:
@@ -281,6 +387,27 @@ class ModularPipeline:
281
387
  "references": search_refs,
282
388
  "description": f"Found {len(search_refs)} results."
283
389
  })
390
+
391
+ # 2. Vision Stage (if used)
392
+ if trace.get("vision"):
393
+ v = trace["vision"]
394
+ if not v.get("skipped"):
395
+ usage = v.get("usage", {})
396
+ vision_cfg = self.config.get_model_config("vision")
397
+ input_price = vision_cfg.get("input_price") or 0
398
+ output_price = vision_cfg.get("output_price") or 0
399
+ cost = (usage.get("input_tokens", 0) * input_price + usage.get("output_tokens", 0) * output_price) / 1_000_000
400
+
401
+ stages.append({
402
+ "name": "Vision",
403
+ "model": v.get("model"),
404
+ "icon_config": "google",
405
+ "provider": "Vision",
406
+ "time": v.get("time", 0),
407
+ "description": f"Analyzed {v.get('images_count', 0)} image(s).",
408
+ "usage": usage,
409
+ "cost": cost
410
+ })
284
411
 
285
412
  # 2. Instruct Rounds
286
413
  for i, t in enumerate(trace.get("instruct_rounds", [])):
@@ -8,7 +8,9 @@ from loguru import logger
8
8
  from .browser.service import get_screenshot_service
9
9
  # New engines
10
10
  from .browser.engines.bing import BingEngine
11
- from .browser.engines.searxng import SearXNGEngine
11
+ from .browser.engines.duckduckgo import DuckDuckGoEngine
12
+ from .browser.engines.google import GoogleEngine
13
+ from .browser.engines.default import DefaultEngine
12
14
 
13
15
  class SearchService:
14
16
  def __init__(self, config: Any):
@@ -20,12 +22,21 @@ class SearchService:
20
22
  # Domain blocking
21
23
  self._blocked_domains = getattr(config, "blocked_domains", []) or []
22
24
 
23
- # Select Engine
24
- self._engine_name = getattr(config, "search_engine", "bing").lower()
25
+ # Select Engine - DefaultEngine when not specified
26
+ self._engine_name = getattr(config, "search_engine", None)
27
+ if self._engine_name:
28
+ self._engine_name = self._engine_name.lower()
29
+
25
30
  if self._engine_name == "bing":
26
31
  self._engine = BingEngine()
32
+ elif self._engine_name == "google":
33
+ self._engine = GoogleEngine()
34
+ elif self._engine_name == "duckduckgo":
35
+ self._engine = DuckDuckGoEngine()
27
36
  else:
28
- self._engine = SearXNGEngine()
37
+ # Default: use browser address bar search (Google-based)
38
+ self._engine = DefaultEngine()
39
+ self._engine_name = "default"
29
40
 
30
41
  logger.info(f"SearchService initialized with engine: {self._engine_name}")
31
42
 
@@ -33,51 +44,82 @@ class SearchService:
33
44
  return self._engine.build_url(query, self._default_limit)
34
45
 
35
46
  async def search_batch(self, queries: List[str]) -> List[List[Dict[str, Any]]]:
36
- """Execute multiple searches concurrently."""
47
+ """Execute multiple searches concurrently using standard URL navigation."""
48
+ logger.info(f"SearchService: Batch searching {len(queries)} queries in parallel...")
37
49
  tasks = [self.search(q) for q in queries]
38
50
  return await asyncio.gather(*tasks)
39
51
 
40
52
  async def search(self, query: str) -> List[Dict[str, Any]]:
41
53
  """
42
54
  Main search entry point.
43
- Returns parsed results + 1 raw page item (marked hidden).
55
+ Returns parsed search results only.
44
56
  """
45
57
  if not query:
46
58
  return []
47
59
 
48
60
  # Apply blocking
49
61
  final_query = query
50
- enable_blocking = getattr(self.config, "enable_domain_blocking", True)
51
- if enable_blocking and self._blocked_domains and "-site:" not in query:
62
+ if self._blocked_domains and "-site:" not in query:
52
63
  exclusions = " ".join([f"-site:{d}" for d in self._blocked_domains])
53
64
  final_query = f"{query} {exclusions}"
54
65
 
55
66
  url = self._build_search_url(final_query)
56
- logger.info(f"Search: '{query}' -> {url}")
57
-
67
+
58
68
  results = []
59
69
  try:
60
- # Fetch - Search parsing doesn't need screenshot, only HTML
61
- page_data = await self.fetch_page_raw(url, include_screenshot=False)
70
+ # Check if this is an address bar search (DefaultEngine)
71
+ if url.startswith("__ADDRESS_BAR_SEARCH__:"):
72
+ # Extract query from marker
73
+ search_query = url.replace("__ADDRESS_BAR_SEARCH__:", "")
74
+ logger.info(f"Search: '{query}' -> [Address Bar Search]")
75
+
76
+ # Use address bar input method
77
+ service = get_screenshot_service(headless=self._headless)
78
+ page_data = await service.search_via_address_bar(search_query)
79
+ else:
80
+ logger.info(f"Search: '{query}' -> {url}")
81
+ # Standard URL navigation
82
+ page_data = await self.fetch_page_raw(url, include_screenshot=False)
83
+
62
84
  content = page_data.get("html", "") or page_data.get("content", "")
63
85
 
64
- # 1. Add Raw Page Item (Always)
65
- # This allows history manager to save the raw search page for debugging
66
- raw_item = {
67
- "title": f"Raw Search: {query}",
68
- "url": url,
69
- "content": content, # Keep original content
70
- "type": "search_raw_page", # Special type for history
71
- "_hidden": False, # Unhidden to allow LLM access if needed
72
- "query": query,
73
- "images": page_data.get("images", [])
74
- }
75
- results.append(raw_item)
86
+ # Debug: Log content length
87
+ logger.debug(f"Search: Raw content length = {len(content)} chars")
88
+ if len(content) < 500:
89
+ logger.warning(f"Search: Content too short, may be empty/blocked. First 500 chars: {content[:500]}")
76
90
 
77
- # 2. Parse Results
91
+ # Parse Results (skip raw page - only return parsed results)
78
92
  if content and not content.startswith("Error"):
79
93
  parsed = self._engine.parse(content)
94
+
95
+ # Debug: Log parse result
96
+ logger.info(f"Search: Engine {self._engine_name} parsed {len(parsed)} results from {len(content)} chars")
97
+
98
+ # JAVASCRIPT IMAGE INJECTION
99
+ # Inject base64 images from JS extraction if available
100
+ # This provides robust fallback if HTTP URLs fail to load
101
+ js_images = page_data.get("images", [])
102
+ if js_images:
103
+ logger.info(f"Search: Injecting {len(js_images)} base64 images into top results")
104
+ for i, img_b64 in enumerate(js_images):
105
+ if i < len(parsed):
106
+ b64_src = f"data:image/jpeg;base64,{img_b64}" if not img_b64.startswith("data:") else img_b64
107
+ if "images" not in parsed[i]: parsed[i]["images"] = []
108
+ # Prepend to prioritize base64 (guaranteed render) over HTTP URLs
109
+ parsed[i]["images"].insert(0, b64_src)
110
+
80
111
  logger.info(f"Search parsed {len(parsed)} results for '{query}' using {self._engine_name}")
112
+
113
+ # ALWAYS add raw search page as hidden item for debug saving
114
+ # (even when 0 results, so we can debug the parser)
115
+ results.append({
116
+ "title": f"[DEBUG] Raw Search: {query}",
117
+ "url": url,
118
+ "content": content[:50000], # Limit to 50KB
119
+ "_type": "search_raw_page",
120
+ "_hidden": True, # Don't show to LLM
121
+ })
122
+
81
123
  results.extend(parsed)
82
124
  else:
83
125
  logger.warning(f"Search failed/empty for '{query}': {content[:100]}")
@@ -31,10 +31,17 @@ class StageContext:
31
31
  # Control flags
32
32
  should_refuse: bool = False
33
33
  refuse_reason: str = ""
34
+ selected_mode: str = "fast" # "fast" or "deepsearch"
34
35
 
35
36
  # ID counter for unified referencing
36
37
  global_id_counter: int = 0
37
38
 
39
+ # Model capabilities
40
+ image_input_supported: bool = True
41
+
42
+ # Vision description (from VisionStage)
43
+ vision_description: str = ""
44
+
38
45
  def next_id(self) -> int:
39
46
  """Get next global ID."""
40
47
  self.global_id_counter += 1