entari-plugin-hyw 3.3.1__py3-none-any.whl → 3.3.2__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 (48) hide show
  1. entari_plugin_hyw/__init__.py +763 -309
  2. entari_plugin_hyw/assets/icon/anthropic.svg +1 -0
  3. entari_plugin_hyw/assets/icon/deepseek.png +0 -0
  4. entari_plugin_hyw/assets/icon/gemini.svg +1 -0
  5. entari_plugin_hyw/assets/icon/google.svg +1 -0
  6. entari_plugin_hyw/assets/icon/grok.png +0 -0
  7. entari_plugin_hyw/assets/icon/microsoft.svg +15 -0
  8. entari_plugin_hyw/assets/icon/minimax.png +0 -0
  9. entari_plugin_hyw/assets/icon/mistral.png +0 -0
  10. entari_plugin_hyw/assets/icon/nvida.png +0 -0
  11. entari_plugin_hyw/assets/icon/openai.svg +1 -0
  12. entari_plugin_hyw/assets/icon/openrouter.png +0 -0
  13. entari_plugin_hyw/assets/icon/perplexity.svg +24 -0
  14. entari_plugin_hyw/assets/icon/qwen.png +0 -0
  15. entari_plugin_hyw/assets/icon/xai.png +0 -0
  16. entari_plugin_hyw/assets/icon/zai.png +0 -0
  17. entari_plugin_hyw/assets/libs/highlight.css +10 -0
  18. entari_plugin_hyw/assets/libs/highlight.js +1213 -0
  19. entari_plugin_hyw/assets/libs/katex-auto-render.js +1 -0
  20. entari_plugin_hyw/assets/libs/katex.css +1 -0
  21. entari_plugin_hyw/assets/libs/katex.js +1 -0
  22. entari_plugin_hyw/assets/libs/tailwind.css +1 -0
  23. entari_plugin_hyw/assets/package-lock.json +953 -0
  24. entari_plugin_hyw/assets/package.json +16 -0
  25. entari_plugin_hyw/assets/tailwind.config.js +12 -0
  26. entari_plugin_hyw/assets/tailwind.input.css +235 -0
  27. entari_plugin_hyw/assets/template.html +157 -0
  28. entari_plugin_hyw/assets/template.html.bak +157 -0
  29. entari_plugin_hyw/assets/template.j2 +307 -0
  30. entari_plugin_hyw/core/__init__.py +0 -0
  31. entari_plugin_hyw/core/config.py +35 -0
  32. entari_plugin_hyw/core/history.py +146 -0
  33. entari_plugin_hyw/core/hyw.py +41 -0
  34. entari_plugin_hyw/core/pipeline.py +1065 -0
  35. entari_plugin_hyw/core/render.py +596 -0
  36. entari_plugin_hyw/core/render.py.bak +926 -0
  37. entari_plugin_hyw/utils/__init__.py +2 -0
  38. entari_plugin_hyw/utils/browser.py +40 -0
  39. entari_plugin_hyw/utils/misc.py +93 -0
  40. entari_plugin_hyw/utils/playwright_tool.py +36 -0
  41. entari_plugin_hyw/utils/prompts.py +129 -0
  42. entari_plugin_hyw/utils/search.py +241 -0
  43. {entari_plugin_hyw-3.3.1.dist-info → entari_plugin_hyw-3.3.2.dist-info}/METADATA +20 -28
  44. entari_plugin_hyw-3.3.2.dist-info/RECORD +46 -0
  45. entari_plugin_hyw/hyw_core.py +0 -700
  46. entari_plugin_hyw-3.3.1.dist-info/RECORD +0 -6
  47. {entari_plugin_hyw-3.3.1.dist-info → entari_plugin_hyw-3.3.2.dist-info}/WHEEL +0 -0
  48. {entari_plugin_hyw-3.3.1.dist-info → entari_plugin_hyw-3.3.2.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,926 @@
1
+ import asyncio
2
+ import gc
3
+ import os
4
+ import markdown
5
+ import base64
6
+ import mimetypes
7
+ from datetime import datetime
8
+ from urllib.parse import urlparse
9
+ from typing import List, Dict, Optional, Any, Union
10
+ import re
11
+ import json
12
+ from pathlib import Path
13
+ from playwright.async_api import async_playwright, TimeoutError as PlaywrightTimeoutError
14
+ from loguru import logger
15
+
16
+ class ContentRenderer:
17
+ def __init__(self, template_path: str = None):
18
+ if template_path is None:
19
+ # Default to assets/template.html in the plugin root
20
+ current_dir = os.path.dirname(os.path.abspath(__file__))
21
+ plugin_root = os.path.dirname(current_dir)
22
+ template_path = os.path.join(plugin_root, "assets", "template.html")
23
+ self.template_path = template_path
24
+ # Re-resolve plugin_root if template_path was passed, or just use the logic above
25
+ current_dir = os.path.dirname(os.path.abspath(__file__))
26
+ plugin_root = os.path.dirname(current_dir)
27
+ self.assets_dir = os.path.join(plugin_root, "assets", "icon")
28
+ # Load JS libraries (CSS is now inline in template)
29
+ libs_dir = os.path.join(plugin_root, "assets", "libs")
30
+
31
+ # Define all assets to load
32
+ self.assets = {}
33
+ assets_map = {
34
+ "highlight_css": os.path.join(libs_dir, "highlight.css"),
35
+ "highlight_js": os.path.join(libs_dir, "highlight.js"),
36
+ "katex_css": os.path.join(libs_dir, "katex.css"),
37
+ "katex_js": os.path.join(libs_dir, "katex.js"),
38
+ "katex_auto_render_js": os.path.join(libs_dir, "katex-auto-render.js"),
39
+ "tailwind_css": os.path.join(libs_dir, "tailwind.css"),
40
+ }
41
+
42
+ total_size = 0
43
+ for key, path in assets_map.items():
44
+ try:
45
+ with open(path, "r", encoding="utf-8") as f:
46
+ content = f.read()
47
+ self.assets[key] = content
48
+ total_size += len(content)
49
+ except Exception as exc:
50
+ logger.warning(f"ContentRenderer: failed to load {key} ({exc})")
51
+ self.assets[key] = ""
52
+
53
+ logger.info(f"ContentRenderer: loaded {len(assets_map)} libs ({total_size} bytes)")
54
+
55
+ async def _set_content_safe(self, page, html: str, timeout_ms: int) -> bool:
56
+ html_size = len(html)
57
+ try:
58
+ await page.set_content(html, wait_until="domcontentloaded", timeout=timeout_ms)
59
+ return True
60
+ except PlaywrightTimeoutError:
61
+ logger.warning(f"ContentRenderer: page.set_content timed out after {timeout_ms}ms (html_size={html_size})")
62
+ return False
63
+ except Exception as exc:
64
+ logger.warning(f"ContentRenderer: page.set_content failed (html_size={html_size}): {exc}")
65
+ return False
66
+
67
+
68
+ def _get_icon_data_url(self, icon_name: str) -> str:
69
+ if not icon_name:
70
+ return ""
71
+ # 1. Check if it's a URL
72
+ if icon_name.startswith(("http://", "https://")):
73
+ try:
74
+ import httpx
75
+ # Synchronous download for simplicity in this context, or use async if possible.
76
+ # Since this method is not async, we use httpx.get (sync).
77
+ # Note: This might block the event loop slightly, but usually icons are small.
78
+ # Ideally this method should be async, but that requires refactoring callers.
79
+ # Given the constraints, we'll try a quick sync fetch with timeout.
80
+ resp = httpx.get(icon_name, timeout=5.0)
81
+ if resp.status_code == 200:
82
+ mime_type = resp.headers.get("content-type", "image/png")
83
+ b64_data = base64.b64encode(resp.content).decode("utf-8")
84
+ return f"data:{mime_type};base64,{b64_data}"
85
+ except Exception as e:
86
+ print(f"Failed to download icon from {icon_name}: {e}")
87
+ # Fallback to local lookup
88
+
89
+ # 2. Local file lookup
90
+ filename = None
91
+
92
+ if "." in icon_name:
93
+ filename = icon_name
94
+ else:
95
+ # Try extensions
96
+ for ext in [".svg", ".png"]:
97
+ if os.path.exists(os.path.join(self.assets_dir, icon_name + ext)):
98
+ filename = icon_name + ext
99
+ break
100
+ if not filename:
101
+ filename = icon_name + ".svg" # Default fallback
102
+
103
+ filepath = os.path.join(self.assets_dir, filename)
104
+
105
+ if not os.path.exists(filepath):
106
+ # Fallback to openai.svg if specific file not found
107
+ filepath = os.path.join(self.assets_dir, "openai.svg")
108
+ if not os.path.exists(filepath):
109
+ return ""
110
+
111
+ mime_type, _ = mimetypes.guess_type(filepath)
112
+ if not mime_type:
113
+ mime_type = "image/png"
114
+
115
+ with open(filepath, "rb") as f:
116
+ data = f.read()
117
+ b64_data = base64.b64encode(data).decode("utf-8")
118
+ return f"data:{mime_type};base64,{b64_data}"
119
+
120
+ def _generate_card_header(self, title: str, icon_data_url: str = None, icon_emoji: str = None, custom_icon_html: str = None, badge_text: str = None, badge_class: str = "llm", provider_text: str = None, behavior_summary: str = None, is_plain: bool = False, icon_box_class: str = None) -> str:
121
+ # LLM Icon
122
+ icon_html = ""
123
+ if custom_icon_html:
124
+ icon_html = custom_icon_html
125
+ elif icon_data_url:
126
+ icon_html = f'<img src="{icon_data_url}" class="w-8 h-8 object-contain rounded-md">'
127
+ elif icon_emoji:
128
+ icon_html = f'<span class="text-2xl shrink-0">{icon_emoji}</span>'
129
+ elif badge_text: # Fallback icon based on badge type if no image
130
+ icon_symbol = "🔎" if "search" in badge_class else "🤖"
131
+ icon_html = f'<span class="text-2xl shrink-0">{icon_symbol}</span>'
132
+
133
+ # Text Content
134
+ # MC Advancement Style:
135
+ # Title (Model Name)
136
+ # Description (Provider • Behavior)
137
+
138
+ main_text_color = "text-pink-600"
139
+ sub_text_color = "text-gray-500"
140
+
141
+ if is_plain:
142
+ # For suggestions/MCP, usually simpler
143
+ # Use the same layout but maybe smaller?
144
+ # existing is_plain used for title color text-gray-900.
145
+ pass
146
+
147
+ # Restore behavior_display definition
148
+ behavior_display = behavior_summary if behavior_summary is not None else "Text Generation"
149
+
150
+ # If is_plain (like Suggestions/MCP), we usually just have a title.
151
+ # But if we want to reuse this for model card, we need the subtitle.
152
+
153
+ subtitle_html = ""
154
+ if not is_plain:
155
+ if provider_text and provider_text.lower() not in ("unknown", "unknown provider"):
156
+ subtitle_html = f'<div class="text-xs {sub_text_color} font-medium">{provider_text} &bull; {behavior_display}</div>'
157
+ else:
158
+ subtitle_html = f'<div class="text-xs {sub_text_color} font-medium">{behavior_display}</div>'
159
+
160
+ # Default icon box class if not provided
161
+ if not icon_box_class:
162
+ icon_box_class = "bg-gray-50 rounded-lg border border-gray-100"
163
+
164
+ return f'''
165
+ <div class="flex items-center gap-3 pb-3 mb-3 border-b border-gray-100 { '!bg-transparent !border-none !p-0 !mb-0 !pb-0' if is_plain else '' }">
166
+ <div class="flex items-center justify-center w-10 h-10 {icon_box_class} shrink-0">
167
+ {icon_html}
168
+ </div>
169
+ <div class="flex flex-col min-w-0">
170
+ <div class="text-sm font-bold {main_text_color} uppercase tracking-wide whitespace-nowrap overflow-hidden text-ellipsis">{title}</div>
171
+ {subtitle_html}
172
+ </div>
173
+ </div>
174
+ '''
175
+
176
+ def _generate_suggestions_html(self, suggestions: List[str]) -> str:
177
+ if not suggestions:
178
+ return ""
179
+
180
+ # Pink Sparkles SVG
181
+ icon_svg = '''
182
+ <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-5 h-5 text-pink-500">
183
+ <path stroke-linecap="round" stroke-linejoin="round" d="M9.813 15.904 9 18.75l-.813-2.846a4.5 4.5 0 0 0-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 0 0 3.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 0 0 3.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 0 0-3.09 3.09ZM18.259 8.715 18 9.75l-.259-1.035a3.375 3.375 0 0 0-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 0 0 2.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 0 0 2.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 0 0-2.456 2.456ZM16.894 20.567 16.5 21.75l-.394-1.183a2.25 2.25 0 0 0-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 0 0 1.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 0 0 1.423 1.423l1.183.394-1.183.394a2.25 2.25 0 0 0-1.423 1.423Z" />
184
+ </svg>
185
+ '''
186
+ header = self._generate_card_header("SUGGESTIONS", badge_text=None, custom_icon_html=icon_svg, is_plain=True)
187
+
188
+ html_parts = ['<div class="flex flex-col gap-2 bg-[#f2f2f2] rounded-2xl p-5 overflow-hidden">']
189
+ html_parts.append(header)
190
+ html_parts.append('<div class="grid grid-cols-2 gap-2.5">')
191
+
192
+ for i, sug in enumerate(suggestions):
193
+ html_parts.append(f'''
194
+ <div class="flex items-baseline gap-2 text-sm text-gray-600 px-3.5 py-2.5 bg-white/80 backdrop-blur-sm rounded-full shadow-sm hover:shadow transition-shadow cursor-default">
195
+ <span class="text-pink-600 font-mono font-bold text-[13px] whitespace-nowrap">{i+1}</span>
196
+ <span class="flex-1 text-[13px] text-gray-600 font-medium whitespace-nowrap overflow-hidden text-ellipsis">{sug}</span>
197
+ </div>
198
+ ''')
199
+ html_parts.append('</div></div>')
200
+ return "".join(html_parts)
201
+
202
+ def _generate_status_footer(self, stats: Union[Dict[str, Any], List[Dict[str, Any]]], billing_info: Dict[str, Any] = None) -> str:
203
+ if not stats:
204
+ return ""
205
+
206
+ # Check if multi-step
207
+ is_multi_step = isinstance(stats, list)
208
+
209
+ # Billing HTML
210
+ billing_html = ""
211
+ if billing_info:
212
+ total_cost = billing_info.get("total_cost", 0)
213
+ if total_cost > 0:
214
+ cost_cents = total_cost * 100
215
+ cost_str = f"{cost_cents:.4f}¢"
216
+ billing_html = f'''
217
+ <div class="flex items-center gap-1.5 bg-white/60 px-2 py-1 rounded shadow-sm">
218
+ <span class="w-2 h-2 rounded-full bg-pink-500"></span>
219
+ <span>{cost_str}</span>
220
+ </div>
221
+ '''
222
+
223
+ if is_multi_step:
224
+ # Multi-step Layout
225
+ step1_stats = stats[0]
226
+ step2_stats = stats[1] if len(stats) > 1 else {}
227
+
228
+ step1_time = step1_stats.get("time", 0)
229
+ step2_time = step2_stats.get("time", 0)
230
+
231
+ # Step 1 Time (Purple)
232
+ step1_html = f'''
233
+ <div class="flex items-center gap-1.5 bg-white/60 px-2 py-1 rounded shadow-sm">
234
+ <span class="w-2 h-2 rounded-full bg-purple-400"></span>
235
+ <span>{step1_time:.1f}s</span>
236
+ </div>
237
+ '''
238
+
239
+ # Step 2 Time (Green)
240
+ step2_html = f'''
241
+ <div class="flex items-center gap-1.5 bg-white/60 px-2 py-1 rounded shadow-sm">
242
+ <span class="w-2 h-2 rounded-full bg-green-400"></span>
243
+ <span>{step2_time:.1f}s</span>
244
+ </div>
245
+ '''
246
+
247
+ return f'''
248
+ <div class="flex flex-col gap-2 bg-[#f2f2f2] rounded-2xl p-3 overflow-hidden">
249
+ <div class="flex flex-wrap items-center gap-2 text-[10px] text-gray-600 font-bold font-mono uppercase tracking-wide">
250
+ {step1_html}
251
+ {step2_html}
252
+ {billing_html}
253
+ </div>
254
+ </div>
255
+ '''
256
+
257
+ else:
258
+ # Single Step Layout
259
+ agent_total_time = stats.get("time", 0)
260
+ vision_time = stats.get("vision_duration", 0)
261
+ llm_time = max(0, agent_total_time - vision_time)
262
+
263
+ # Vision Time Block
264
+ vision_html = ""
265
+ if vision_time > 0:
266
+ vision_html = f'''
267
+ <div class="flex items-center gap-1.5 bg-white/60 px-2 py-1 rounded shadow-sm">
268
+ <span class="w-2 h-2 rounded-full bg-purple-400"></span>
269
+ <span>{vision_time:.1f}s</span>
270
+ </div>
271
+ '''
272
+
273
+ # Agent Time Block
274
+ agent_html = f'''
275
+ <div class="flex items-center gap-1.5 bg-white/60 px-2 py-1 rounded shadow-sm">
276
+ <span class="w-2 h-2 rounded-full bg-green-400"></span>
277
+ <span>{llm_time:.1f}s</span>
278
+ </div>
279
+ '''
280
+
281
+ return f'''
282
+ <div class="flex flex-col gap-2 bg-[#f2f2f2] rounded-2xl p-3 overflow-hidden">
283
+ <div class="flex flex-wrap items-center gap-2 text-[10px] text-gray-600 font-bold font-mono uppercase tracking-wide">
284
+ {vision_html}
285
+ {agent_html}
286
+ {billing_html}
287
+ </div>
288
+ </div>
289
+ '''
290
+
291
+ total_html = f'''
292
+ <div class="flex items-center gap-1.5 bg-white/60 px-2 py-1 rounded shadow-sm">
293
+ <span class="w-2 h-2 rounded-full bg-gray-400"></span>
294
+ <span id="total-time-display">...</span>
295
+ </div>
296
+ '''
297
+
298
+ return f'''
299
+ <div class="flex flex-col gap-2 bg-[#f2f2f2] rounded-2xl p-3 overflow-hidden">
300
+ <div class="flex flex-wrap items-center gap-2 text-[10px] text-gray-600 font-bold font-mono uppercase tracking-wide">
301
+ {vision_html}
302
+ {agent_html}
303
+ {render_html}
304
+ {total_html}
305
+ {billing_html}
306
+ </div>
307
+ </div>
308
+ '''
309
+
310
+ def _render_list_card(
311
+ self,
312
+ icon_html: str,
313
+ title_html: str, # Allow HTML for complex titles (like stage name + model)
314
+ subtitle_html: str = None,
315
+ link_url: str = None,
316
+ right_content_html: str = None,
317
+ is_link: bool = False,
318
+ icon_box_class: str = "bg-gray-50 rounded-md shrink-0 ring-1 ring-inset ring-black/5",
319
+ is_compact: bool = False
320
+ ) -> str:
321
+ tag = "a" if link_url else "div"
322
+ href_attr = f'href="{link_url}" target="_blank"' if link_url else ""
323
+ hover_class = "transition-colors hover:bg-gray-50" if (link_url or is_link) else ""
324
+
325
+ # Compact Logic
326
+ padding_class = "px-4 py-3.5"
327
+ align_class = "items-start"
328
+ icon_size_class = "w-8 h-8"
329
+
330
+ if is_compact:
331
+ padding_class = "p-2.5"
332
+ align_class = "items-center"
333
+ icon_size_class = "w-6 h-6"
334
+
335
+ if icon_box_class and "w-" in icon_box_class and "h-" in icon_box_class:
336
+ # If custom class provides size, don't override unless necessary.
337
+ # Actually, just trust the caller's class if they provide one?
338
+ # For now, let's append icon_size_class ONLY if icon_box_class is default
339
+ pass
340
+ else:
341
+ # If default class was passed (or user passed class without size), we might want to enforce size?
342
+ # But the default argument includes size? No, default arg is "bg-gray-50...".
343
+ # Wait, default arg doesn't include w-8 h-8.
344
+ # The old code had: class="flex items-center justify-center w-8 h-8 ...
345
+ pass
346
+
347
+ # Consistent Card Style
348
+ return f'''
349
+ <{tag} {href_attr} class="flex {align_class} gap-3 {padding_class} rounded-lg border border-gray-100 bg-white shadow-sm no-underline text-inherit {hover_class}">
350
+ <div class="flex items-center justify-center {icon_size_class} {icon_box_class}">
351
+ {icon_html}
352
+ </div>
353
+ <div class="flex flex-col flex-1 min-w-0 gap-0.5">
354
+ <div class="flex items-center gap-2 leading-tight min-w-0">
355
+ {title_html}
356
+ </div>
357
+ {f'<div>{subtitle_html}</div>' if subtitle_html else ''}
358
+ </div>
359
+ {f'<div class="shrink-0 ml-2">{right_content_html}</div>' if right_content_html else ''}
360
+ </{tag}>
361
+ '''
362
+
363
+
364
+
365
+ def _generate_pipeline_html(self, stages: List[Dict[str, Any]], mcp_steps: List[Dict[str, Any]] = None, references: List[Dict[str, Any]] = None) -> str:
366
+ """Generate HTML for unified pipeline display containing Stages, MCP Flow, and References.
367
+ """
368
+ if not stages and not mcp_steps and not references:
369
+ return ""
370
+
371
+ html_parts = ['<div class="flex flex-col gap-1.5">']
372
+
373
+ # --- 1. Pipeline Stages ---
374
+ STAGE_COLORS = {
375
+ "Vision": "bg-purple-50 text-purple-600 border-purple-100",
376
+ "Instruct": "bg-blue-50 text-blue-600 border-blue-100",
377
+ "Search": "bg-orange-50 text-orange-600 border-orange-100",
378
+ "Agent": "bg-green-50 text-green-600 border-green-100",
379
+ }
380
+
381
+ if stages:
382
+ for i, stage in enumerate(stages):
383
+ name = stage.get("name", "Step")
384
+ model = stage.get("model", "")
385
+ icon_config = stage.get("icon_config", "")
386
+ provider = stage.get("provider", "")
387
+
388
+ # Get model icon using the existing method
389
+ icon_data_url = self._get_icon_data_url(icon_config) if icon_config else ""
390
+
391
+ # Extract short model name for display
392
+ if "/" in model:
393
+ model_short = model.split("/")[-1]
394
+ else:
395
+ model_short = model
396
+ # Truncate if too long
397
+ if len(model_short) > 25:
398
+ model_short = model_short[:23] + "…"
399
+
400
+ # Build icon HTML
401
+ if name == "Search":
402
+ # Use Pink Globe SVG for Search stage, consistent with References
403
+ icon_html = '''<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-5 h-5 text-pink-500"><path stroke-linecap="round" stroke-linejoin="round" d="M12 21a9.004 9.004 0 0 0 8.716-6.747M12 21a9.004 9.004 0 0 1-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S12 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S12 3 12 3m0 0a8.997 8.997 0 0 1 7.843 4.582M12 3a8.997 8.997 0 0 0-7.843 4.582m15.686 0A11.953 11.953 0 0 1 12 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0 1 21 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0 1 12 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 0 1 3 12c0-1.605.42-3.113 1.157-4.418" /></svg>'''
404
+ elif icon_data_url:
405
+ icon_html = f'<img src="{icon_data_url}" class="w-5 h-5 object-contain rounded">'
406
+ else:
407
+ # Fallback
408
+ stage_emoji = {"Vision": "👁️", "Search": "🔍", "Agent": "✨", "Instruct": "📝"}
409
+ emoji = stage_emoji.get(name, "⚙️")
410
+ icon_html = f'<span class="text-sm">{emoji}</span>'
411
+
412
+ # Define color
413
+ color = STAGE_COLORS.get(name, "bg-gray-50 text-gray-600 border-gray-100")
414
+
415
+ provider_html = ""
416
+ if provider and provider.lower() not in ("unknown", "unknown provider", ""):
417
+ provider_html = f'<span class="text-[10px] text-gray-400 shrink-0 truncate max-w-[80px]">{provider}</span>'
418
+
419
+ time_val = stage.get("time", 0)
420
+ cost_val = stage.get("cost", 0.0)
421
+ time_str = f"{time_val:.2f}s"
422
+ cost_str = f"${cost_val:.6f}" if cost_val > 0 else "$0"
423
+ if name == "Search": cost_str = "$0"
424
+
425
+ stats_html = f'<div class="flex items-center gap-3 text-[11px] text-gray-500 font-mono mt-0.5"><span>{time_str}</span><span>{cost_str}</span></div>'
426
+
427
+ icon_box_class = f"{color} rounded-md border shrink-0"
428
+
429
+ title_html = f'''
430
+ <span class="text-[11px] font-bold uppercase text-pink-600 shrink-0">{name}</span>
431
+ <span class="text-[11px] font-medium text-gray-700 truncate min-w-0" title="{model}">{model_short}</span>
432
+ <span class="ml-auto">{provider_html}</span>
433
+ '''
434
+
435
+ html_parts.append(self._render_list_card(
436
+ icon_html=icon_html,
437
+ title_html=title_html,
438
+ subtitle_html=stats_html,
439
+ right_content_html=None,
440
+ is_compact=True,
441
+ icon_box_class=icon_box_class
442
+ ))
443
+
444
+ # --- 2. MCP Flow ---
445
+ if mcp_steps:
446
+ # Header with Separator logic (or just simple header)
447
+ # Use the simple header from _generate_card_header, but maybe simplified margin?
448
+ # Pink Terminal Icon
449
+ mcp_icon_svg = '''<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-5 h-5 text-pink-500"><path stroke-linecap="round" stroke-linejoin="round" d="m6.75 7.5 3 2.25-3 2.25m4.5 0h3m-9 8.25h13.5A2.25 2.25 0 0 0 21 18V6a2.25 2.25 0 0 0-2.25-2.25H5.25A2.25 2.25 0 0 0 3 6v12a2.25 2.25 0 0 0 2.25 2.25Z" /></svg>'''
450
+ mcp_header = self._generate_card_header("MCP FLOW", badge_text=None, custom_icon_html=mcp_icon_svg, is_plain=True)
451
+ # Add some spacing before section if there were stages
452
+ if stages:
453
+ html_parts.append('<div class="mt-2"></div>')
454
+ html_parts.append(mcp_header)
455
+
456
+ # SVG icons
457
+ I_STYLE = "width:14px;height:14px"
458
+ STEP_ICONS = {
459
+ "navigate": f'''<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" style="{I_STYLE}"><path stroke-linecap="round" stroke-linejoin="round" d="M12 21a9.004 9.004 0 0 0 8.716-6.747M12 21a9.004 9.004 0 0 1-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S12 3 12 3m0 18a9 9 0 0 1-9-9" /></svg>''',
460
+ "snapshot": f'''<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" style="{I_STYLE}"><path stroke-linecap="round" stroke-linejoin="round" d="M6.827 6.175A2.31 2.31 0 0 1 5.186 7.23c-.38.054-.757.112-1.134.175C2.999 7.58 2.25 8.507 2.25 9.574V18a2.25 2.25 0 0 0 2.25 2.25h15A2.25 2.25 0 0 0 21.75 18V9.574" /></svg>''',
461
+ "click": f'''<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" style="{I_STYLE}"><path stroke-linecap="round" stroke-linejoin="round" d="M15.042 21.672 13.684 16.6m0 0-2.51 2.225.569-9.47 5.227 7.917-3.286-.672" /></svg>''',
462
+ "type": f'''<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" style="{I_STYLE}"><path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652" /></svg>''',
463
+ "code": f'''<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" style="{I_STYLE}"><path stroke-linecap="round" stroke-linejoin="round" d="M17.25 6.75 22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25" /></svg>''',
464
+ "default": f'''<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" style="{I_STYLE}"><path stroke-linecap="round" stroke-linejoin="round" d="M11.42 15.17 17.25 21" /></svg>''',
465
+ }
466
+
467
+ for i, step in enumerate(mcp_steps):
468
+ name = step.get("name", "unknown")
469
+ desc = step.get("description", "")
470
+ icon_key = step.get("icon", "").lower()
471
+
472
+ icon_svg = STEP_ICONS.get(icon_key, STEP_ICONS["default"])
473
+
474
+ icon_box_class = "rounded-md border border-pink-100 bg-pink-50 text-pink-600 shrink-0"
475
+ title_html = f'<div class="text-[12px] font-semibold text-gray-800 font-mono">{name}</div>'
476
+ subtitle_html = f'<div class="text-[10px] text-gray-400">{desc}</div>' if desc else None
477
+
478
+ html_parts.append(self._render_list_card(
479
+ icon_html=icon_svg,
480
+ title_html=title_html,
481
+ subtitle_html=subtitle_html,
482
+ is_compact=True,
483
+ icon_box_class=icon_box_class
484
+ ))
485
+
486
+ # --- 3. References ---
487
+ if references:
488
+ # Pink Globe SVG
489
+ ref_icon_svg = '''
490
+ <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-5 h-5 text-pink-500">
491
+ <path stroke-linecap="round" stroke-linejoin="round" d="M12 21a9.004 9.004 0 0 0 8.716-6.747M12 21a9.004 9.004 0 0 1-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S12 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S12 3 12 3m0 0a8.997 8.997 0 0 1 7.843 4.582M12 3a8.997 8.997 0 0 0-7.843 4.582m15.686 0A11.953 11.953 0 0 1 12 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0 1 21 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0 1 12 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 0 1 3 12c0-1.605.42-3.113 1.157-4.418" />
492
+ </svg>
493
+ '''
494
+ ref_header = self._generate_card_header("REFERENCES", badge_text=None, custom_icon_html=ref_icon_svg, is_plain=True)
495
+ if stages or mcp_steps:
496
+ html_parts.append('<div class="mt-2"></div>')
497
+ html_parts.append(ref_header)
498
+
499
+ # Limit to 8 references
500
+ refs = references[:8]
501
+
502
+ for i, ref in enumerate(refs):
503
+ title = ref.get("title", "No Title")
504
+ url = ref.get("url", "#")
505
+ try:
506
+ domain = urlparse(url).netloc
507
+ if domain.startswith("www."): domain = domain[4:]
508
+ except Exception:
509
+ domain = "unknown"
510
+
511
+ favicon_url = f"https://www.google.com/s2/favicons?domain={domain}&sz=32"
512
+
513
+ icon_box = f'<div class="w-full h-full flex items-center justify-center text-[10px] font-bold text-pink-600 bg-pink-50 rounded-md border border-pink-100">{i+1}</div>'
514
+ title_html = f'<div class="text-[12px] font-medium text-gray-800 truncate" title="{title}">{title}</div>'
515
+
516
+ subtitle_html = f'''
517
+ <div class="flex items-center gap-1 text-[10px] text-gray-400">
518
+ <img src="{favicon_url}" class="w-3 h-3 rounded-sm opacity-60" onerror="this.style.display='none'">
519
+ <span>{domain}</span>
520
+ </div>
521
+ '''
522
+
523
+ html_parts.append(self._render_list_card(
524
+ icon_html=icon_box,
525
+ title_html=title_html,
526
+ subtitle_html=subtitle_html,
527
+ link_url=url,
528
+ is_compact=True,
529
+ icon_box_class="bg-pink-50 border border-pink-100 rounded-md shrink-0 ring-0" # Override default ring
530
+ ))
531
+
532
+ html_parts.append('</div>')
533
+ return "".join(html_parts)
534
+
535
+ def _get_domain(self, url: str) -> str:
536
+ try:
537
+ parsed = urlparse(url)
538
+ domain = parsed.netloc
539
+ if "openrouter" in domain: return "openrouter.ai"
540
+ if "openai" in domain: return "openai.com"
541
+ if "anthropic" in domain: return "anthropic.com"
542
+ if "google" in domain: return "google.com"
543
+ if "deepseek" in domain: return "deepseek.com"
544
+ return domain
545
+ except:
546
+ return "unknown"
547
+
548
+ async def render(self,
549
+ markdown_content: str,
550
+ output_path: str,
551
+ suggestions: List[str] = None,
552
+ stats: Dict[str, Any] = None,
553
+ references: List[Dict[str, Any]] = None,
554
+ mcp_steps: List[Dict[str, Any]] = None,
555
+ stages_used: List[Dict[str, Any]] = None,
556
+ model_name: str = "",
557
+ provider_name: str = "Unknown",
558
+ behavior_summary: str = "Text Generation",
559
+ icon_config: str = "openai",
560
+ vision_model_name: str = None,
561
+ vision_icon_config: str = None,
562
+ vision_base_url: str = None,
563
+ base_url: str = "https://openrouter.ai/api/v1",
564
+ billing_info: Dict[str, Any] = None,
565
+ render_timeout_ms: int = 6000):
566
+ """
567
+ Render markdown content to an image using Playwright.
568
+ """
569
+ render_start_time = asyncio.get_event_loop().time()
570
+
571
+ # Preprocess to fix common markdown issues (like lists without preceding newline)
572
+ # Ensure a blank line before a list item if the previous line is not empty
573
+ # Matches a newline preceded by non-whitespace, followed by a list marker
574
+ markdown_content = re.sub(r'(?<=\S)\n(?=\s*(\d+\.|[-*+]) )', r'\n\n', markdown_content)
575
+
576
+ # Replace Chinese colon with English colon + space to avoid list rendering issues
577
+ # markdown_content = markdown_content.replace(":", ":")
578
+
579
+ # Replace other full-width punctuation with half-width + space
580
+ # markdown_content = markdown_content.replace(",", ",").replace("。", ".").replace("?", "?").replace("!", "!")
581
+
582
+ # Remove bold markers around Chinese text (or text containing CJK characters)
583
+ # This addresses rendering issues where bold Chinese fonts look bad or fail to render.
584
+ # Matches **...** where content includes at least one CJK character.
585
+ # markdown_content = re.sub(r'\*\*([^*]*[\u4e00-\u9fa5\u3000-\u303f\uff00-\uffef][^*]*)\*\*', r'\1', markdown_content)
586
+
587
+ # 1. Prepare Template Variables
588
+ timestamp = datetime.now().strftime("%H:%M:%S")
589
+
590
+ # Header for Response Card
591
+ if "/" in model_name:
592
+ model_display = model_name.split("/")[-1].upper()
593
+ else:
594
+ model_display = model_name.upper()
595
+
596
+ icon_data_url = self._get_icon_data_url(icon_config)
597
+
598
+ # provider_domain = self._get_domain(base_url)
599
+ # We now use passed provider_name instead of strict domain inference
600
+
601
+ # Prepare Vision Info if vision model was used
602
+ vision_display = None
603
+ vision_icon_url = None
604
+ vision_provider_domain = None
605
+
606
+ if vision_model_name:
607
+ if "/" in vision_model_name:
608
+ vision_display = vision_model_name.split("/")[-1].upper()
609
+ else:
610
+ vision_display = vision_model_name.upper()
611
+
612
+ # Use provided icon config or default to 'openai' (or generic eye icon logic inside _get_icon_data_url if we pass a generic name)
613
+ # Actually _get_icon_data_url handles fallback to openai.svg if file not found.
614
+ # But we need to pass something.
615
+ v_icon = vision_icon_config if vision_icon_config else "openai"
616
+ vision_icon_url = self._get_icon_data_url(v_icon)
617
+
618
+ # vision_provider_domain = self._get_domain(vision_base_url or base_url)
619
+ # New behavior: we ignore explicit vision provider text in header, just merge logic if needed or just use behavior summary.
620
+ # But the user might want to see the specific vision model name?
621
+ # User request: "Model UI Name Service Provider + Behavior Summary"
622
+ # It implies a SINGLE model card.
623
+ # If vision is used, we usually care about the main model responding.
624
+ # We can mention Vision in behavior summary.
625
+
626
+ # New "Automated Pipeline" Header
627
+ # Pink/White style
628
+ flowchart_icon = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-6 h-6 text-pink-600"><rect x="3" y="3" width="7" height="7"></rect><rect x="14" y="3" width="7" height="7"></rect><rect x="14" y="14" width="7" height="7"></rect><rect x="3" y="14" width="7" height="7"></rect></svg>'
629
+
630
+ response_header = self._generate_card_header(
631
+ title="Automated Pipeline",
632
+ custom_icon_html=flowchart_icon,
633
+ badge_text="PIPELINE",
634
+ provider_text="",
635
+ behavior_summary="",
636
+ is_plain=False,
637
+ icon_box_class="bg-gray-50 rounded-lg border border-gray-100"
638
+ )
639
+
640
+ suggestions_html = self._generate_suggestions_html(suggestions or [])
641
+ # Stats Footer REMOVED (now displayed in stages)
642
+ stats_html = ""
643
+ # Pass search_provider to references generation
644
+ mcp_steps_html = "" # self._generate_mcp_steps_html(mcp_steps or [])
645
+ # Generate pipeline (stages + mcp + references) HTML
646
+ pipeline_html = self._generate_pipeline_html(stages_used or [], mcp_steps or [], references or [])
647
+
648
+ # 2. Render with Playwright
649
+ max_attempts = 1 # No retry - if set_content fails, retrying won't help
650
+ last_exc = None
651
+ for attempt in range(1, max_attempts + 1):
652
+ content_html = None
653
+ final_html = None
654
+ template = None
655
+ parts = None
656
+ try:
657
+ # Server-side Markdown Rendering
658
+ content_html = markdown.markdown(
659
+ markdown_content.strip(),
660
+ extensions=['fenced_code', 'tables', 'nl2br', 'sane_lists']
661
+ )
662
+
663
+ # Post-process to style citation markers [1], [2]...
664
+ parts = re.split(r'(<code.*?>.*?</code>)', content_html, flags=re.DOTALL)
665
+ for i, part in enumerate(parts):
666
+ if not part.startswith('<code'):
667
+ parts[i] = re.sub(r'\[(\d+)\](?![^<]*>)', r'<span class="inline-flex items-center justify-center min-w-[16px] h-4 px-0.5 text-[10px] font-bold text-pink-600 bg-pink-50 border border-pink-100 rounded mx-0.5 align-middle">\1</span>', part)
668
+ content_html = "".join(parts)
669
+
670
+ # Load and Fill Template
671
+ logger.info(f"Loading template from {self.template_path}")
672
+ with open(self.template_path, "r", encoding="utf-8") as f:
673
+ template = f.read()
674
+ logger.info(f"Template size: {len(template)} bytes, header: {template[:100]}")
675
+
676
+ # Inject all pre-compiled assets (CSS + JS)
677
+ final_html = template
678
+ injected_assets = []
679
+ for key, content in self.assets.items():
680
+ # Regex to match {{ key }} allowing for whitespace even between braces
681
+ # Matches {{key}}, { { key } }, {{ key }}, etc.
682
+ pattern = r"\{\s*\{\s*" + re.escape(key) + r"\s*\}\s*\}"
683
+ match = re.search(pattern, final_html)
684
+ if match:
685
+ final_html = re.sub(pattern, lambda _: content, final_html)
686
+ injected_assets.append(f"{key}({len(content)})")
687
+ else:
688
+ logger.warning(f"Asset placeholder NOT FOUND: {key}, pattern: {pattern}")
689
+ # Debug: show first 500 chars of template to see placeholders
690
+ logger.debug(f"Template start: {template[:500]}")
691
+
692
+ logger.info(f"Injected assets: {', '.join(injected_assets)}")
693
+
694
+ final_html = final_html.replace("{{ content_html }}", content_html)
695
+ final_html = final_html.replace("{{ timestamp }}", timestamp)
696
+ final_html = final_html.replace("{{ suggestions }}", suggestions_html)
697
+ final_html = final_html.replace("{{ stats }}", stats_html)
698
+ final_html = final_html.replace("{{ references }}", "") # Removed
699
+ final_html = final_html.replace("{{ mcp_steps }}", "") # Removed
700
+ final_html = final_html.replace("{{ stages }}", pipeline_html)
701
+ final_html = final_html.replace("{{ response_header }}", response_header)
702
+ final_html = final_html.replace("{{ references_json }}", json.dumps(references or []))
703
+ except MemoryError:
704
+ last_exc = "memory"
705
+ logger.warning(f"ContentRenderer: out of memory while building HTML (attempt {attempt}/{max_attempts})")
706
+ continue
707
+ except Exception as exc:
708
+ last_exc = exc
709
+ logger.warning(f"ContentRenderer: failed to build HTML (attempt {attempt}/{max_attempts}) ({exc})")
710
+ continue
711
+
712
+ try:
713
+ logger.info("ContentRenderer: launching playwright...")
714
+ async with async_playwright() as p:
715
+ logger.info("ContentRenderer: playwright context ready, launching browser...")
716
+ browser = await p.chromium.launch(headless=True)
717
+ try:
718
+ # Use device_scale_factor=2 for high DPI rendering (better quality)
719
+ page = await browser.new_page(viewport={"width": 450, "height": 1200}, device_scale_factor=2)
720
+
721
+ logger.debug("ContentRenderer: page created, setting content...")
722
+
723
+ # Set content (10s timeout to handle slow CDN loading)
724
+ set_ok = await self._set_content_safe(page, final_html, 10000)
725
+ if not set_ok or page.is_closed():
726
+ raise RuntimeError("set_content failed")
727
+
728
+ logger.debug("ContentRenderer: content set, waiting for images...")
729
+
730
+ # Wait for images with user-configured timeout (render_timeout_ms)
731
+ image_timeout_sec = render_timeout_ms / 1000.0
732
+ try:
733
+ await asyncio.wait_for(
734
+ page.evaluate("""
735
+ () => Promise.all(
736
+ Array.from(document.images).map(img => {
737
+ if (img.complete) {
738
+ if (img.naturalWidth === 0 || img.naturalHeight === 0) {
739
+ img.style.display = 'none';
740
+ }
741
+ return Promise.resolve();
742
+ }
743
+ return new Promise((resolve) => {
744
+ img.onload = () => {
745
+ if (img.naturalWidth === 0 || img.naturalHeight === 0) {
746
+ img.style.display = 'none';
747
+ }
748
+ resolve();
749
+ };
750
+ img.onerror = () => {
751
+ img.style.display = 'none';
752
+ resolve();
753
+ };
754
+ });
755
+ })
756
+ )
757
+ """),
758
+ timeout=image_timeout_sec
759
+ )
760
+ except asyncio.TimeoutError:
761
+ logger.warning(f"ContentRenderer: image loading timed out after {image_timeout_sec}s, continuing...")
762
+
763
+ logger.debug("ContentRenderer: images done, updating stats...")
764
+
765
+
766
+
767
+ # Brief wait for layout to stabilize (CSS is pre-compiled)
768
+ await asyncio.sleep(0.1)
769
+
770
+ logger.debug("ContentRenderer: taking screenshot...")
771
+
772
+ # Try element screenshot first, fallback to full page
773
+ element = await page.query_selector("#main-container")
774
+
775
+ try:
776
+ if element:
777
+ await element.screenshot(path=output_path)
778
+ else:
779
+ await page.screenshot(path=output_path, full_page=True)
780
+ except Exception as screenshot_exc:
781
+ # Fallback to full page screenshot if element screenshot fails
782
+ logger.warning(f"ContentRenderer: element screenshot failed ({screenshot_exc}), trying full page...")
783
+ await page.screenshot(path=output_path, full_page=True)
784
+
785
+ logger.debug("ContentRenderer: screenshot done")
786
+ finally:
787
+ try:
788
+ await browser.close()
789
+ except Exception as exc:
790
+ logger.warning(f"ContentRenderer: failed to close browser ({exc})")
791
+ return True
792
+ except Exception as exc:
793
+ last_exc = exc
794
+ logger.warning(f"ContentRenderer: render attempt {attempt}/{max_attempts} failed ({exc})")
795
+ finally:
796
+ content_html = None
797
+ final_html = None
798
+ template = None
799
+ parts = None
800
+ gc.collect()
801
+
802
+ logger.error(f"ContentRenderer: render failed after {max_attempts} attempts ({last_exc})")
803
+ return False
804
+
805
+ def _generate_model_card_html(self, index: int, model: Dict[str, Any]) -> str:
806
+ name = model.get("name", "Unknown")
807
+ provider = model.get("provider", "Unknown")
808
+ icon_data = model.get("provider_icon", "")
809
+ is_default = model.get("is_default", False)
810
+ is_vision_default = model.get("is_vision_default", False)
811
+
812
+ # Badges
813
+ badges_html = ""
814
+ if is_default:
815
+ badges_html += '<span class="px-1.5 py-0.5 rounded text-[9px] font-bold bg-blue-50 text-blue-600 border border-blue-100">DEFAULT</span>'
816
+ if is_vision_default:
817
+ badges_html += '<span class="px-1.5 py-0.5 rounded text-[9px] font-bold bg-purple-50 text-purple-600 border border-purple-100">DEFAULT</span>'
818
+
819
+ # Capability Badges
820
+ if model.get("vision"):
821
+ badges_html += '<span class="px-1.5 py-0.5 rounded text-[9px] font-bold bg-purple-50 text-purple-600 border border-purple-100">VISION</span>'
822
+ if model.get("tools"):
823
+ badges_html += '<span class="px-1.5 py-0.5 rounded text-[9px] font-bold bg-green-50 text-green-600 border border-green-100">TOOLS</span>'
824
+ if model.get("online"):
825
+ badges_html += '<span class="px-1.5 py-0.5 rounded text-[9px] font-bold bg-cyan-50 text-cyan-600 border border-cyan-100">ONLINE</span>'
826
+ if model.get("reasoning"):
827
+ badges_html += '<span class="px-1.5 py-0.5 rounded text-[9px] font-bold bg-orange-50 text-orange-600 border border-orange-100">REASONING</span>'
828
+
829
+ # Icon
830
+ icon_html = ""
831
+ if icon_data:
832
+ icon_html = f'<img src="{icon_data}" class="w-5 h-5 object-contain rounded-sm">'
833
+ else:
834
+ icon_html = '<span class="w-5 h-5 flex items-center justify-center text-xs">📦</span>'
835
+
836
+ return f'''
837
+ <div class="bg-white rounded-xl p-4 shadow-sm border border-gray-200 flex flex-col gap-2 transition-all hover:shadow-md">
838
+ <div class="flex justify-between items-start">
839
+ <div class="flex items-center gap-2">
840
+ <span class="flex items-center justify-center w-5 h-5 rounded bg-gray-100 text-gray-500 text-xs font-mono font-bold">{index}</span>
841
+ {icon_html}
842
+ <h3 class="font-bold text-gray-800 text-sm">{name}</h3>
843
+ </div>
844
+ </div>
845
+
846
+ <div class="pl-7 flex flex-col gap-1.5">
847
+ <div class="flex flex-wrap gap-1">
848
+ {badges_html}
849
+ </div>
850
+ <div class="flex items-center gap-1.5 text-xs text-gray-500">
851
+ <span class="font-mono text-gray-400">Provider:</span>
852
+ <div class="flex items-center gap-1.5 bg-gray-50 px-2 py-1 rounded border border-gray-100">
853
+ <span class="font-medium text-gray-700">{provider}</span>
854
+ </div>
855
+ </div>
856
+ </div>
857
+ </div>
858
+ '''
859
+
860
+ async def render_models_list(self, models: List[Dict[str, Any]], output_path: str, default_base_url: str = "https://openrouter.ai/api/v1", render_timeout_ms: int = 6000):
861
+ """
862
+ Render the list of models to an image.
863
+ """
864
+ # Resolve template path
865
+ current_dir = os.path.dirname(os.path.abspath(__file__))
866
+ plugin_root = os.path.dirname(current_dir)
867
+ template_path = os.path.join(plugin_root, "assets", "template.html")
868
+
869
+ # Generate HTML for models
870
+ models_html_parts = []
871
+ for i, m in enumerate(models, 1):
872
+ m_copy = m.copy()
873
+ if not m_copy.get("provider"):
874
+ url = m_copy.get("base_url")
875
+ if not url:
876
+ url = default_base_url
877
+
878
+ m_copy["provider"] = self._get_domain(url)
879
+
880
+ # Determine provider icon
881
+ icon_name = m_copy.get("icon")
882
+ if icon_name:
883
+ icon_name = icon_name.lower()
884
+
885
+ if not icon_name:
886
+ provider = m_copy.get("provider", "")
887
+
888
+ if "." in provider:
889
+ # Fallback: strip TLD
890
+ parts = provider.split(".")
891
+ if len(parts) >= 2:
892
+ icon_name = parts[-2]
893
+ else:
894
+ icon_name = provider
895
+ else:
896
+ icon_name = provider
897
+
898
+ # Get icon data URL
899
+ m_copy["provider_icon"] = self._get_icon_data_url(icon_name or "openai")
900
+
901
+ models_html_parts.append(self._generate_model_card_html(i, m_copy))
902
+
903
+ models_html = "\n".join(models_html_parts)
904
+
905
+ with open(template_path, "r", encoding="utf-8") as f:
906
+ template = f.read()
907
+
908
+ # Inject all pre-compiled assets (CSS + JS)
909
+ final_html = template
910
+ for key, content in self.assets.items():
911
+ final_html = final_html.replace("{{ " + key + " }}", content)
912
+ final_html = final_html.replace("{{ models_list }}", models_html)
913
+
914
+ async with async_playwright() as p:
915
+ browser = await p.chromium.launch(headless=True)
916
+ page = await browser.new_page(viewport={"width": 450, "height": 800}, device_scale_factor=2)
917
+
918
+ await self._set_content_safe(page, final_html, render_timeout_ms)
919
+
920
+ element = await page.query_selector("#main-container")
921
+ if element:
922
+ await element.screenshot(path=output_path)
923
+ else:
924
+ await page.screenshot(path=output_path, full_page=True)
925
+
926
+ await browser.close()