entari-plugin-hyw 4.0.0rc4__py3-none-any.whl → 4.0.0rc6__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.

Files changed (30) hide show
  1. entari_plugin_hyw/__init__.py +216 -75
  2. entari_plugin_hyw/assets/card-dist/index.html +70 -79
  3. entari_plugin_hyw/browser/__init__.py +10 -0
  4. entari_plugin_hyw/browser/engines/base.py +13 -0
  5. entari_plugin_hyw/browser/engines/bing.py +95 -0
  6. entari_plugin_hyw/browser/engines/duckduckgo.py +137 -0
  7. entari_plugin_hyw/browser/engines/google.py +155 -0
  8. entari_plugin_hyw/browser/landing.html +172 -0
  9. entari_plugin_hyw/browser/manager.py +153 -0
  10. entari_plugin_hyw/browser/service.py +304 -0
  11. entari_plugin_hyw/card-ui/src/App.vue +526 -182
  12. entari_plugin_hyw/card-ui/src/components/MarkdownContent.vue +7 -11
  13. entari_plugin_hyw/card-ui/src/components/StageCard.vue +33 -30
  14. entari_plugin_hyw/card-ui/src/types.ts +9 -0
  15. entari_plugin_hyw/definitions.py +155 -0
  16. entari_plugin_hyw/history.py +111 -33
  17. entari_plugin_hyw/misc.py +34 -0
  18. entari_plugin_hyw/modular_pipeline.py +384 -0
  19. entari_plugin_hyw/render_vue.py +326 -239
  20. entari_plugin_hyw/search.py +95 -708
  21. entari_plugin_hyw/stage_base.py +92 -0
  22. entari_plugin_hyw/stage_instruct.py +345 -0
  23. entari_plugin_hyw/stage_instruct_deepsearch.py +104 -0
  24. entari_plugin_hyw/stage_summary.py +164 -0
  25. {entari_plugin_hyw-4.0.0rc4.dist-info → entari_plugin_hyw-4.0.0rc6.dist-info}/METADATA +4 -4
  26. {entari_plugin_hyw-4.0.0rc4.dist-info → entari_plugin_hyw-4.0.0rc6.dist-info}/RECORD +28 -16
  27. entari_plugin_hyw/pipeline.py +0 -1219
  28. entari_plugin_hyw/prompts.py +0 -47
  29. {entari_plugin_hyw-4.0.0rc4.dist-info → entari_plugin_hyw-4.0.0rc6.dist-info}/WHEEL +0 -0
  30. {entari_plugin_hyw-4.0.0rc4.dist-info → entari_plugin_hyw-4.0.0rc6.dist-info}/top_level.txt +0 -0
@@ -1,27 +1,26 @@
1
1
  """
2
- Vue-based Card Renderer (Minimal Python)
2
+ Vue-based Card Renderer (DrissionPage-based)
3
3
 
4
- Python only provides raw data. All frontend logic (markdown, syntax highlighting,
5
- math rendering, citations) is handled by the Vue frontend.
4
+ Renders content to image using the shared DrissionPage browser.
5
+ Wraps synchronous DrissionPage operations in a thread pool.
6
6
  """
7
7
 
8
8
  import json
9
- import gc
10
- import os
11
- import threading
12
9
  import asyncio
13
10
  from pathlib import Path
14
- from typing import List, Dict, Any
15
- from concurrent.futures import Future
11
+ from typing import List, Dict, Any, Optional
12
+ from concurrent.futures import ThreadPoolExecutor
16
13
 
17
14
  from loguru import logger
18
- from playwright.async_api import async_playwright
15
+ from .browser.manager import SharedBrowserManager
19
16
 
20
17
 
21
18
  class ContentRenderer:
22
- """Minimal renderer with background browser thread for instant startup."""
19
+ """Renderer using DrissionPage with thread pool for async interface."""
23
20
 
24
- def __init__(self, template_path: str = None, auto_start: bool = True):
21
+ def __init__(self, template_path: str = None, auto_start: bool = True, headless: bool = True):
22
+ self.headless = headless
23
+
25
24
  if template_path is None:
26
25
  current_dir = Path(__file__).parent
27
26
  template_path = current_dir / "assets" / "card-dist" / "index.html"
@@ -33,282 +32,370 @@ class ContentRenderer:
33
32
  self.template_content = self.template_path.read_text(encoding="utf-8")
34
33
  logger.info(f"ContentRenderer: loaded Vue template ({len(self.template_content)} bytes)")
35
34
 
36
- # Browser state (managed by background thread)
37
- self._playwright = None
38
- self._browser = None
39
- self._context = None
40
- self._page = None
41
- self._render_count = 0
42
- self._max_renders_before_restart = 50
43
-
44
- # Background event loop for playwright
45
- self._loop: asyncio.AbstractEventLoop = None
46
- self._thread: threading.Thread = None
47
- self._ready = threading.Event()
48
- self._lock = threading.Lock()
35
+ self._manager = None
36
+ self._executor = ThreadPoolExecutor(max_workers=10) # Enough for batch crawls
37
+ self._render_tab = None
49
38
 
50
39
  if auto_start:
51
- self._start_background_loop()
40
+ self._ensure_manager()
52
41
 
53
- def _start_background_loop(self):
54
- """Start dedicated event loop in background thread."""
55
- def _run_loop():
56
- self._loop = asyncio.new_event_loop()
57
- asyncio.set_event_loop(self._loop)
58
- # Start browser immediately
59
- self._loop.run_until_complete(self._init_browser())
60
- self._ready.set()
61
- # Keep loop running for future tasks
62
- self._loop.run_forever()
63
-
64
- self._thread = threading.Thread(target=_run_loop, daemon=True, name="ContentRenderer-Browser")
65
- self._thread.start()
66
- logger.info("ContentRenderer: Background browser thread started")
42
+ def _ensure_manager(self):
43
+ """Ensure shared browser manager exists."""
44
+ if not self._manager:
45
+ from .browser.manager import get_shared_browser_manager
46
+ self._manager = get_shared_browser_manager(headless=self.headless)
67
47
 
68
- async def _init_browser(self, timeout: int = 6000):
69
- """Initialize browser and page with warmup render (runs in background loop)."""
70
- logger.info("ContentRenderer: Starting browser...")
48
+ async def start(self, timeout: int = 6000):
49
+ """Initialize renderer manager (async wrapper)."""
50
+ loop = asyncio.get_running_loop()
51
+ await loop.run_in_executor(self._executor, self._ensure_manager)
52
+
53
+ async def prepare_tab(self) -> str:
54
+ """Async wrapper to prepare a new render tab."""
55
+ loop = asyncio.get_running_loop()
56
+ return await loop.run_in_executor(self._executor, self._prepare_tab_sync)
57
+
58
+ def _prepare_tab_sync(self) -> str:
59
+ """Create and warm up a new tab, return its ID."""
60
+ import time as pytimeout
61
+ start = pytimeout.time()
62
+ self._ensure_manager()
71
63
  try:
72
- self._playwright = await async_playwright().start()
73
- self._browser = await self._playwright.chromium.launch(
74
- headless=True,
75
- args=['--no-sandbox', '--disable-setuid-sandbox']
76
- )
77
- self._context = await self._browser.new_context(
78
- viewport={"width": 540, "height": 1400},
79
- device_scale_factor=2.0,
80
- )
81
- self._page = await self._context.new_page()
82
- await self._page.goto(self.template_path.as_uri(), wait_until="domcontentloaded", timeout=timeout)
64
+ tab = self._manager.new_tab(self.template_path.as_uri())
65
+ tab_id = tab.tab_id
66
+
67
+ # Basic wait
68
+ tab.wait(1)
83
69
 
84
- # Pre-warm the page with initial data so Vue compiles and renders
70
+ # Pre-warm
85
71
  warmup_data = {
86
72
  "markdown": "# Ready",
87
73
  "total_time": 0,
88
74
  "stages": [],
89
75
  "references": [],
90
- "page_references": [],
91
- "image_references": [],
92
76
  "stats": {},
93
77
  "theme_color": "#ef4444",
94
78
  }
95
- await self._page.evaluate("(data) => window.updateRenderData(data)", warmup_data)
96
- # await asyncio.sleep(0.1) # Removed as requested
97
- logger.success("ContentRenderer: Browser + page ready!")
79
+
80
+ if tab.ele('#app', timeout=5):
81
+ tab.run_js(f"window.updateRenderData({json.dumps(warmup_data)})")
82
+
83
+ elapsed = pytimeout.time() - start
84
+ logger.info(f"ContentRenderer: Prepared tab {tab_id} in {elapsed:.2f}s")
85
+ return tab_id
98
86
  except Exception as e:
99
- logger.error(f"ContentRenderer: Failed to start browser: {e}")
87
+ logger.error(f"ContentRenderer: Failed to prepare tab: {e}")
100
88
  raise
101
89
 
102
- def _run_in_background(self, coro) -> Future:
103
- """Schedule coroutine in background loop and return Future."""
104
- if not self._loop or not self._loop.is_running():
105
- raise RuntimeError("Background loop not running")
106
- return asyncio.run_coroutine_threadsafe(coro, self._loop)
90
+ async def render_pages_batch(
91
+ self,
92
+ pages: List[Dict[str, Any]],
93
+ theme_color: str = "#ef4444"
94
+ ) -> List[str]:
95
+ """
96
+ Render multiple page markdown contents to images concurrently.
97
+
98
+ Args:
99
+ pages: List of dicts with 'title', 'content', 'url' keys
100
+ theme_color: Theme color for rendering
101
+
102
+ Returns:
103
+ List of base64-encoded JPG images
104
+ """
105
+ if not pages:
106
+ return []
107
+
108
+ loop = asyncio.get_running_loop()
109
+
110
+ # Prepare tabs concurrently
111
+ logger.info(f"ContentRenderer: Preparing {len(pages)} tabs for batch render")
112
+ tab_tasks = [
113
+ loop.run_in_executor(self._executor, self._prepare_tab_sync)
114
+ for _ in pages
115
+ ]
116
+ tab_ids = await asyncio.gather(*tab_tasks, return_exceptions=True)
117
+
118
+ # Filter out failed tab preparations
119
+ valid_pairs = []
120
+ for i, (page, tab_id) in enumerate(zip(pages, tab_ids)):
121
+ if isinstance(tab_id, Exception):
122
+ logger.warning(f"ContentRenderer: Failed to prepare tab for page {i}: {tab_id}")
123
+ else:
124
+ valid_pairs.append((page, tab_id))
125
+
126
+ if not valid_pairs:
127
+ return []
128
+
129
+ # Render concurrently
130
+ render_tasks = [
131
+ loop.run_in_executor(
132
+ self._executor,
133
+ self._render_page_to_b64_sync,
134
+ page,
135
+ tab_id,
136
+ theme_color
137
+ )
138
+ for page, tab_id in valid_pairs
139
+ ]
140
+
141
+ results = await asyncio.gather(*render_tasks, return_exceptions=True)
142
+
143
+ # Process results
144
+ screenshots = []
145
+ for i, res in enumerate(results):
146
+ if isinstance(res, Exception):
147
+ logger.warning(f"ContentRenderer: Batch render error for page {i}: {res}")
148
+ screenshots.append(None)
149
+ else:
150
+ screenshots.append(res)
151
+
152
+ logger.info(f"ContentRenderer: Batch rendered {len([s for s in screenshots if s])} pages")
153
+ return screenshots
107
154
 
108
- async def start(self, timeout: int = 6000):
109
- """Wait for browser to be ready (for compatibility)."""
110
- ready = await asyncio.to_thread(self._ready.wait, timeout / 1000)
111
- if not ready:
112
- raise TimeoutError("Browser startup timeout")
155
+ def _render_page_to_b64_sync(
156
+ self,
157
+ page_data: Dict[str, Any],
158
+ tab_id: str,
159
+ theme_color: str
160
+ ) -> Optional[str]:
161
+ """Render a single page's markdown to base64 image."""
162
+ tab = None
163
+ try:
164
+ self._ensure_manager()
165
+ browser_page = self._manager.page
166
+
167
+ try:
168
+ tab = browser_page.get_tab(tab_id)
169
+ except Exception:
170
+ return None
171
+
172
+ if not tab:
173
+ return None
174
+
175
+ # Build render data for this page
176
+ markdown = f"# {page_data.get('title', 'Page')}\n\n{page_data.get('content', '')}"
177
+
178
+ render_data = {
179
+ "markdown": markdown,
180
+ "total_time": 0,
181
+ "stages": [],
182
+ "references": [],
183
+ "page_references": [],
184
+ "image_references": [],
185
+ "stats": {},
186
+ "theme_color": theme_color,
187
+ }
188
+
189
+ # 1. Update Data & Settle
190
+ tab.run_js(f"window.updateRenderData({json.dumps(render_data)})")
191
+ tab.wait(0.5) # Since images are Base64, decoding is nearly instant once injected
113
192
 
114
- async def close(self):
115
- """Clean up browser resources."""
116
- if self._loop and self._loop.is_running():
117
- future = self._run_in_background(self._close_internal())
118
- # Use asyncio.to_thread to wait without blocking the event loop
119
- await asyncio.to_thread(future.result, 10)
120
- if self._loop:
121
- self._loop.call_soon_threadsafe(self._loop.stop)
122
- if self._thread:
123
- # Use asyncio.to_thread to wait without blocking the event loop
124
- await asyncio.to_thread(self._thread.join, 5)
125
- logger.info("ContentRenderer: Browser closed.")
193
+ # 2. Dynamic Resize
194
+ # Get actual content height to prevent clipping
195
+ scroll_height = tab.run_js('return Math.max(document.body.scrollHeight, document.documentElement.scrollHeight);')
196
+ viewport_height = int(scroll_height) + 200
197
+
198
+ tab.run_cdp('Emulation.setDeviceMetricsOverride',
199
+ width=1920, height=viewport_height, deviceScaleFactor=1, mobile=False
200
+ )
201
+
202
+ # 3. Hide Scrollbars (Now that viewport is large enough, overflow:hidden won't clip)
203
+ tab.run_js('document.documentElement.style.overflow = "hidden"')
204
+ tab.run_js('document.body.style.overflow = "hidden"')
205
+
206
+ # Use element's actual position and size
207
+ main_ele = tab.ele('#main-container', timeout=3)
208
+ if main_ele:
209
+ # Robustly hide scrollbars via CDP and Style Injection
210
+ SharedBrowserManager.hide_scrollbars(tab)
211
+
212
+ # Force root styles to eliminate gutter and ensure full width
213
+ tab.run_js('document.documentElement.style.overflow = "hidden";')
214
+ tab.run_js('document.body.style.overflow = "hidden";')
215
+ tab.run_js('document.documentElement.style.scrollbarGutter = "unset";')
216
+ tab.run_js('document.documentElement.style.width = "100%";')
126
217
 
127
- async def _close_internal(self):
128
- """Internal close (runs in background loop)."""
129
- if self._page:
130
- await self._page.close()
131
- self._page = None
132
- if self._context:
133
- await self._context.close()
134
- self._context = None
135
- if self._browser:
136
- await self._browser.close()
137
- self._browser = None
138
- if self._playwright:
139
- await self._playwright.stop()
140
- self._playwright = None
218
+ orig_overflow = "auto" # just a placeholder, we rely on full refresh usually or don't care about restoring for single-purpose tabs
219
+
220
+ b64_img = main_ele.get_screenshot(as_base64='jpg')
221
+
222
+ # Restore not strictly needed for throwaway render tabs, but good practice
223
+ # tab.run_js(f'document.documentElement.style.overflow = "{orig_overflow}";')
224
+ try:
225
+ tab.set.scroll_bars(True)
226
+ except:
227
+ pass
228
+ return b64_img
229
+ else:
230
+ return tab.get_screenshot(as_base64='jpg', full_page=False)
231
+
232
+ except Exception as e:
233
+ logger.error(f"ContentRenderer: Failed to render page: {e}")
234
+ return None
235
+ finally:
236
+ if tab:
237
+ try:
238
+ tab.close()
239
+ except Exception:
240
+ pass
141
241
 
142
- async def _ensure_page(self):
143
- """Ensure page is ready, restart if needed (runs in background loop)."""
144
- if self._render_count >= self._max_renders_before_restart:
145
- logger.info(f"ContentRenderer: Restarting browser after {self._render_count} renders...")
146
- await self._close_internal()
147
- self._render_count = 0
148
-
149
- if not self._page:
150
- await self._init_browser()
151
242
 
152
243
  async def render(
153
244
  self,
154
245
  markdown_content: str,
155
246
  output_path: str,
247
+ tab_id: Optional[str] = None,
156
248
  stats: Dict[str, Any] = None,
157
249
  references: List[Dict[str, Any]] = None,
158
250
  page_references: List[Dict[str, Any]] = None,
159
251
  image_references: List[Dict[str, Any]] = None,
160
252
  stages_used: List[Dict[str, Any]] = None,
161
- image_timeout: int = 3000,
162
253
  theme_color: str = "#ef4444",
163
254
  **kwargs
164
255
  ) -> bool:
165
- """Render content to image."""
166
- # Wait for browser ready (non-blocking)
167
- ready = await asyncio.to_thread(self._ready.wait, 30)
168
- if not ready:
169
- logger.error("ContentRenderer: Browser not ready after 30s")
170
- return False
171
-
172
- # Prepare data
173
- resolved_output_path = Path(output_path).resolve()
174
- resolved_output_path.parent.mkdir(parents=True, exist_ok=True)
175
-
176
- stats_dict = stats[0] if isinstance(stats, list) and stats else (stats or {})
177
-
178
- render_data = {
179
- "markdown": markdown_content,
180
- "total_time": stats_dict.get("total_time", 0) or 0,
181
- "stages": [
182
- {
183
- "name": s.get("name", "Step"),
184
- "model": s.get("model", ""),
185
- "provider": s.get("provider", ""),
186
- "time": s.get("time", 0),
187
- "cost": s.get("cost", 0),
188
- "references": s.get("references") or s.get("search_results"),
189
- "image_references": s.get("image_references"),
190
- "crawled_pages": s.get("crawled_pages"),
191
- }
192
- for s in (stages_used or [])
193
- ],
194
- "references": references or [],
195
- "page_references": page_references or [],
196
- "image_references": image_references or [],
197
- "stats": stats_dict,
198
- "theme_color": theme_color,
199
- }
200
-
201
- # Reorder images in stages
202
- self._reorder_images_in_stages(render_data["markdown"], render_data["stages"])
203
-
204
- # Run render in background loop (non-blocking wait for result)
205
- try:
206
- future = self._run_in_background(
207
- self._render_internal(render_data, str(resolved_output_path), image_timeout)
208
- )
209
- # Use asyncio.to_thread to wait for the future without blocking the event loop
210
- return await asyncio.to_thread(future.result, 60)
211
- except Exception as e:
212
- logger.error(f"ContentRenderer: render failed ({e})")
213
- return False
256
+ """Render content to image using a specific (pre-warmed) tab or a temp one."""
257
+ loop = asyncio.get_running_loop()
258
+ return await loop.run_in_executor(
259
+ self._executor,
260
+ self._render_sync,
261
+ markdown_content,
262
+ output_path,
263
+ tab_id,
264
+ stats,
265
+ references,
266
+ page_references,
267
+ image_references,
268
+ stages_used,
269
+ theme_color
270
+ )
214
271
 
215
- async def _render_internal(self, render_data: dict, output_path: str, image_timeout: int) -> bool:
216
- """Internal render (runs in background loop)."""
217
- import time
218
- start_time = time.time()
272
+ def _render_sync(
273
+ self,
274
+ markdown_content: str,
275
+ output_path: str,
276
+ tab_id: Optional[str],
277
+ stats: Dict[str, Any],
278
+ references: List[Dict[str, Any]],
279
+ page_references: List[Dict[str, Any]],
280
+ image_references: List[Dict[str, Any]],
281
+ stages_used: List[Dict[str, Any]],
282
+ theme_color: str
283
+ ) -> bool:
284
+ """Synchronous render implementation."""
285
+ tab = None
219
286
 
220
287
  try:
221
- await self._ensure_page()
288
+ self._ensure_manager()
289
+ page = self._manager.page
222
290
 
223
- # Update data via JS
224
- await self._page.evaluate("(data) => window.updateRenderData(data)", render_data)
291
+ if tab_id:
292
+ try:
293
+ tab = page.get_tab(tab_id)
294
+ except Exception:
295
+ pass
225
296
 
226
- # Wait for Vue to update DOM
227
- # await asyncio.sleep(0.1) # Removed as requested
297
+ if not tab:
298
+ logger.warning("ContentRenderer: Pre-warmed tab not found, creating new.")
299
+ tab = page.new_tab(self.template_path.as_uri())
300
+ tab.wait(0.5)
228
301
 
229
- # Wait for images to load
230
- try:
231
- await self._page.wait_for_function(
232
- "() => Array.from(document.images).every(img => img.complete)",
233
- timeout=image_timeout
234
- )
235
- except Exception:
236
- logger.warning(f"ContentRenderer: Timeout waiting for images ({image_timeout}ms)")
302
+ resolved_output_path = Path(output_path).resolve()
303
+ resolved_output_path.parent.mkdir(parents=True, exist_ok=True)
304
+
305
+ stats_dict = stats[0] if isinstance(stats, list) and stats else (stats or {})
237
306
 
238
- # Take screenshot
239
- element = await self._page.query_selector("#main-container")
240
- if element:
241
- await element.screenshot(path=output_path, type="jpeg", quality=88)
307
+ render_data = {
308
+ "markdown": markdown_content,
309
+ "total_time": stats_dict.get("total_time", 0) or 0,
310
+ "stages": stages_used or [],
311
+ "references": references or [],
312
+ "page_references": page_references or [],
313
+ "image_references": image_references or [],
314
+ "stats": stats_dict,
315
+ "theme_color": theme_color,
316
+ }
317
+
318
+ tab.run_js(f"window.updateRenderData({json.dumps(render_data)})")
319
+
320
+ # Brief settle wait for masonry/images
321
+ tab.wait(0.6)
322
+
323
+ # Dynamic Resize
324
+ scroll_height = tab.run_js('return Math.max(document.body.scrollHeight, document.documentElement.scrollHeight);')
325
+ viewport_height = int(scroll_height) + 200
326
+
327
+ tab.run_cdp('Emulation.setDeviceMetricsOverride',
328
+ width=1920, height=viewport_height, deviceScaleFactor=1, mobile=False
329
+ )
330
+
331
+ # Hide scrollbars
332
+ tab.run_js('document.documentElement.style.overflow = "hidden"')
333
+ tab.run_js('document.body.style.overflow = "hidden"')
334
+
335
+ # Use element's actual position and size
336
+ main_ele = tab.ele('#main-container', timeout=5)
337
+ if main_ele:
338
+ import base64
339
+
340
+ # Robustly hide scrollbars via CDP and Style Injection
341
+ SharedBrowserManager.hide_scrollbars(tab)
342
+
343
+ # Force root styles to eliminate gutter and ensure full width
344
+ tab.run_js('document.documentElement.style.overflow = "hidden";')
345
+ tab.run_js('document.body.style.overflow = "hidden";')
346
+ tab.run_js('document.documentElement.style.scrollbarGutter = "unset";')
347
+ tab.run_js('document.documentElement.style.width = "100%";')
348
+
349
+ b64_img = main_ele.get_screenshot(as_base64='jpg')
350
+
351
+ # Restore scrollbars (optional here since we often close or navigate away)
352
+ try:
353
+ tab.set.scroll_bars(True)
354
+ except:
355
+ pass
356
+
357
+ with open(str(resolved_output_path), 'wb') as f:
358
+ f.write(base64.b64decode(b64_img))
242
359
  else:
243
- await self._page.screenshot(path=output_path, full_page=True, type="jpeg", quality=88)
360
+ logger.warning("ContentRenderer: #main-container not found, using fallback")
361
+ tab.get_screenshot(path=str(resolved_output_path.parent), name=resolved_output_path.name, full_page=True)
244
362
 
245
- self._render_count += 1
246
- duration = time.time() - start_time
247
- logger.success(f"ContentRenderer: Rendered in {duration:.3f}s (No.{self._render_count})")
248
363
  return True
249
-
250
- except Exception as exc:
251
- logger.error(f"ContentRenderer: render failed ({exc})")
252
- # Reset page to force restart next time
253
- self._page = None
364
+ except Exception as e:
365
+ logger.error(f"ContentRenderer: Render failed: {e}")
254
366
  return False
255
367
  finally:
256
- gc.collect()
368
+ if tab:
369
+ try:
370
+ tab.close()
371
+ except Exception:
372
+ pass
257
373
 
258
- async def render_models_list(
259
- self,
260
- models: List[Dict[str, Any]],
261
- output_path: str,
262
- default_base_url: str = "https://openrouter.ai/api/v1",
263
- **kwargs
264
- ) -> bool:
265
- """Render models list."""
266
- lines = ["# 模型列表"]
267
- for idx, model in enumerate(models or [], start=1):
268
- name = model.get("name", "unknown")
269
- base_url = model.get("base_url") or default_base_url
270
- provider = model.get("provider", "")
271
- lines.append(f"{idx}. **{name}** \n - base_url: {base_url} \n - provider: {provider}")
374
+ async def close(self):
375
+ """Close renderer."""
376
+ self._executor.shutdown(wait=False)
377
+ if self._render_tab:
378
+ try:
379
+ self._render_tab.close()
380
+ except Exception:
381
+ pass
382
+ self._render_tab = None
272
383
 
273
- markdown_content = "\n\n".join(lines) if len(lines) > 1 else "# 模型列表\n暂无模型"
274
384
 
275
- return await self.render(
276
- markdown_content=markdown_content,
277
- output_path=output_path,
278
- stats={},
279
- references=[],
280
- stages_used=[],
281
- )
385
+ # Singleton
386
+ _content_renderer: Optional[ContentRenderer] = None
282
387
 
283
- def _reorder_images_in_stages(self, markdown: str, stages: List[Dict[str, Any]]) -> None:
284
- """Reorder image references in stages based on appearance in markdown."""
285
- import re
286
-
287
- img_urls = []
288
- for match in re.finditer(r'!\[.*?\]\((.*?)\)', markdown):
289
- url_part = match.group(1).split()[0].strip()
290
- if url_part and url_part not in img_urls:
291
- img_urls.append(url_part)
292
-
293
- if not img_urls:
294
- return
295
388
 
296
- for stage in stages:
297
- refs = stage.get("image_references")
298
- if not refs:
299
- continue
300
-
301
- ref_map = {r["url"]: r for r in refs}
302
- new_refs = []
303
- seen_urls = set()
304
-
305
- for url in img_urls:
306
- if url in ref_map:
307
- new_refs.append(ref_map[url])
308
- seen_urls.add(url)
309
-
310
- for r in refs:
311
- if r["url"] not in seen_urls:
312
- new_refs.append(r)
313
-
314
- stage["image_references"] = new_refs
389
+
390
+ async def get_content_renderer() -> ContentRenderer:
391
+ global _content_renderer
392
+ if _content_renderer is None:
393
+ _content_renderer = ContentRenderer()
394
+ await _content_renderer.start()
395
+ return _content_renderer
396
+
397
+
398
+ def set_global_renderer(renderer: ContentRenderer):
399
+ """Set the global renderer instance."""
400
+ global _content_renderer
401
+ _content_renderer = renderer