entari-plugin-hyw 3.2.105__py3-none-any.whl → 3.2.107__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.

@@ -12,19 +12,21 @@ import json
12
12
  from pathlib import Path
13
13
  from playwright.async_api import async_playwright, TimeoutError as PlaywrightTimeoutError
14
14
  from loguru import logger
15
+ from jinja2 import Environment, FileSystemLoader, select_autoescape
15
16
 
16
17
  class ContentRenderer:
17
18
  def __init__(self, template_path: str = None):
18
19
  if template_path is None:
19
- # Default to assets/template.html in the plugin root
20
+ # Default to assets/template.j2 in the plugin root
20
21
  current_dir = os.path.dirname(os.path.abspath(__file__))
21
22
  plugin_root = os.path.dirname(current_dir)
22
- template_path = os.path.join(plugin_root, "assets", "template.html")
23
+ template_path = os.path.join(plugin_root, "assets", "template.j2")
24
+
23
25
  self.template_path = template_path
24
- # Re-resolve plugin_root if template_path was passed, or just use the logic above
25
26
  current_dir = os.path.dirname(os.path.abspath(__file__))
26
27
  plugin_root = os.path.dirname(current_dir)
27
28
  self.assets_dir = os.path.join(plugin_root, "assets", "icon")
29
+
28
30
  # Load JS libraries (CSS is now inline in template)
29
31
  libs_dir = os.path.join(plugin_root, "assets", "libs")
30
32
 
@@ -52,10 +54,21 @@ class ContentRenderer:
52
54
 
53
55
  logger.info(f"ContentRenderer: loaded {len(assets_map)} libs ({total_size} bytes)")
54
56
 
57
+ # Initialize Jinja2 Environment
58
+ template_dir = os.path.dirname(self.template_path)
59
+ template_name = os.path.basename(self.template_path)
60
+ logger.info(f"ContentRenderer: initializing Jinja2 from {template_dir} / {template_name}")
61
+
62
+ self.env = Environment(
63
+ loader=FileSystemLoader(template_dir),
64
+ autoescape=select_autoescape(['html', 'xml'])
65
+ )
66
+ self.template = self.env.get_template(template_name)
67
+
55
68
  async def _set_content_safe(self, page, html: str, timeout_ms: int) -> bool:
56
69
  html_size = len(html)
57
70
  try:
58
- await page.set_content(html, wait_until="domcontentloaded", timeout=timeout_ms)
71
+ await page.set_content(html, wait_until="networkidle", timeout=timeout_ms)
59
72
  return True
60
73
  except PlaywrightTimeoutError:
61
74
  logger.warning(f"ContentRenderer: page.set_content timed out after {timeout_ms}ms (html_size={html_size})")
@@ -63,7 +76,6 @@ class ContentRenderer:
63
76
  except Exception as exc:
64
77
  logger.warning(f"ContentRenderer: page.set_content failed (html_size={html_size}): {exc}")
65
78
  return False
66
-
67
79
 
68
80
  def _get_icon_data_url(self, icon_name: str) -> str:
69
81
  if not icon_name:
@@ -72,11 +84,6 @@ class ContentRenderer:
72
84
  if icon_name.startswith(("http://", "https://")):
73
85
  try:
74
86
  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
87
  resp = httpx.get(icon_name, timeout=5.0)
81
88
  if resp.status_code == 200:
82
89
  mime_type = resp.headers.get("content-type", "image/png")
@@ -117,421 +124,6 @@ class ContentRenderer:
117
124
  b64_data = base64.b64encode(data).decode("utf-8")
118
125
  return f"data:{mime_type};base64,{b64_data}"
119
126
 
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
127
  def _get_domain(self, url: str) -> str:
536
128
  try:
537
129
  parsed = urlparse(url)
@@ -564,142 +156,277 @@ class ContentRenderer:
564
156
  billing_info: Dict[str, Any] = None,
565
157
  render_timeout_ms: int = 6000):
566
158
  """
567
- Render markdown content to an image using Playwright.
159
+ Render markdown content to an image using Playwright and Jinja2.
568
160
  """
569
161
  render_start_time = asyncio.get_event_loop().time()
570
162
 
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
163
+ # Preprocess to fix common markdown issues
574
164
  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
165
 
587
- # 1. Prepare Template Variables
588
- timestamp = datetime.now().strftime("%H:%M:%S")
166
+ # AGGRESSIVE CLEANING: Strip out "References" section and "[code]" blocks from the text
167
+ # because we are rendering them as structured UI elements now.
589
168
 
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
169
+ # 1. Remove "References" or "Citations" header and everything after it specific to the end of file
170
+ # Matches ### References, ## References, **References**, etc., followed by list items
171
+ markdown_content = re.sub(r'(?i)^\s*(#{1,3}|\*\*)\s*(References|Citations|Sources).*$', '', markdown_content, flags=re.MULTILINE | re.DOTALL)
600
172
 
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
173
+ # 2. Remove isolated "[code] ..." lines (checking for the specific format seen in user screenshot)
174
+ # Matches lines starting with [code] or [CODE]
175
+ markdown_content = re.sub(r'(?i)^\s*\[code\].*?(\n|$)', '', markdown_content, flags=re.MULTILINE)
176
+
177
+ max_attempts = 1
650
178
  last_exc = None
651
179
  for attempt in range(1, max_attempts + 1):
652
- content_html = None
653
- final_html = None
654
- template = None
655
- parts = None
656
180
  try:
657
- # Server-side Markdown Rendering
181
+ # 1. Protect math blocks
182
+ # We look for $$...$$, \[...\], \(...\)
183
+ # We'll replace them with placeholders so markdown extensions (like nl2br) don't touch them.
184
+ math_blocks = {}
185
+
186
+ def protect_math(match):
187
+ key = f"__MATH_BLOCK_{len(math_blocks)}__"
188
+ math_blocks[key] = match.group(0)
189
+ return key
190
+
191
+ # Patterns for math:
192
+ # 1) $$ ... $$ (display math)
193
+ # 2) \[ ... \] (display math)
194
+ # 3) \( ... \) (inline math)
195
+ # Note: We must handle multiline for $$ and \[
196
+
197
+ # Regex for $$...$$
198
+ markdown_content = re.sub(r'\$\$(.*?)\$\$\s*', protect_math, markdown_content, flags=re.DOTALL)
199
+
200
+ # Regex for \[...\]
201
+ markdown_content = re.sub(r'\\\[(.*?)\\\]\s*', protect_math, markdown_content, flags=re.DOTALL)
202
+
203
+ # Regex for \(...\) (usually single line, but DOTALL is safest if user wraps lines)
204
+ markdown_content = re.sub(r'\\\((.*?)\\\)', protect_math, markdown_content, flags=re.DOTALL)
205
+
206
+ # 2. Render Markdown
207
+ # Use 'nl2br' to turn newlines into <br>, 'fenced_code' for code blocks
658
208
  content_html = markdown.markdown(
659
- markdown_content.strip(),
209
+ markdown_content.strip(),
660
210
  extensions=['fenced_code', 'tables', 'nl2br', 'sane_lists']
661
211
  )
662
212
 
663
- # Post-process to style citation markers [1], [2]...
213
+ # 3. Restore math blocks
214
+ def restore_math(text):
215
+ # We assume placeholders are intact. We do a simple string replace or regex.
216
+ # Since placeholders are unique strings, we can just replace them.
217
+ for key, val in math_blocks.items():
218
+ text = text.replace(key, val)
219
+ return text
220
+
221
+ content_html = restore_math(content_html)
222
+
223
+ # Post-process to style citation markers
664
224
  parts = re.split(r'(<code.*?>.*?</code>)', content_html, flags=re.DOTALL)
665
225
  for i, part in enumerate(parts):
666
226
  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)
227
+ # 1. Numeric Citations [1] -> Blue Style (References)
228
+ part = 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-blue-600 bg-blue-50 border border-blue-200 rounded mx-0.5 align-top relative -top-0.5">\1</span>', part)
229
+ # 2. Alphabetical Citations [a] -> Orange Style (MCP Flow)
230
+ part = re.sub(r'\[([a-zA-Z]+)\](?![^<]*>)', r'<span class="inline-flex items-center justify-center min-w-[16px] h-4 px-0.5 text-[10px] font-bold text-orange-600 bg-orange-50 border border-orange-200 rounded mx-0.5 align-top relative -top-0.5">\1</span>', part)
231
+ parts[i] = part
668
232
  content_html = "".join(parts)
669
233
 
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]}")
234
+ # Strip out the structured JSON blocks if they leaked into the content
235
+ # Look for <pre>... containing "mcp_steps" or "references" at the end
236
+ # Make regex robust to any language class or no class
237
+ content_html = re.sub(r'<pre><code[^>]*>[^<]*(mcp_steps|references)[^<]*</code></pre>\s*$', '', content_html, flags=re.DOTALL | re.IGNORECASE)
238
+ # Loop to remove multiple if present
239
+ while re.search(r'<pre><code[^>]*>[^<]*(mcp_steps|references)[^<]*</code></pre>\s*$', content_html, flags=re.DOTALL | re.IGNORECASE):
240
+ content_html = re.sub(r'<pre><code[^>]*>[^<]*(mcp_steps|references)[^<]*</code></pre>\s*$', '', content_html, flags=re.DOTALL | re.IGNORECASE)
241
+
242
+ # --- PREPARE DATA FOR JINJA TEMPLATE ---
691
243
 
692
- logger.info(f"Injected assets: {', '.join(injected_assets)}")
244
+ # 1. Pipeline Stages (with Nested Data)
245
+ processed_stages = []
693
246
 
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 []))
247
+ # Unified Search Icon (RemixIcon)
248
+ SEARCH_ICON = '<i class="ri-search-line text-[16px]"></i>'
249
+ DEFAULT_ICON = '<i class="ri-box-3-line text-[16px]"></i>'
250
+
251
+ # Helper to infer provider/icon name from model string
252
+ def infer_icon_name(model_str):
253
+ if not model_str: return None
254
+ m = model_str.lower()
255
+ if "claude" in m or "anthropic" in m: return "anthropic"
256
+ if "gpt" in m or "openai" in m or "o1" in m: return "openai"
257
+ if "gemini" in m or "google" in m: return "google"
258
+ if "deepseek" in m: return "deepseek"
259
+ if "mistral" in m: return "mistral"
260
+ if "llama" in m: return "meta"
261
+ if "qwen" in m: return "qwen"
262
+ if "grok" in m: return "grok"
263
+ if "perplexity" in m: return "perplexity"
264
+ if "minimax" in m: return "minimax"
265
+ if "nvidia" in m: return "nvidia"
266
+ return None
267
+
268
+ # 2. Reference Processing (Moved up for nesting)
269
+ processed_refs = []
270
+ if references:
271
+ for ref in references[:8]:
272
+ url = ref.get("url", "#")
273
+ try:
274
+ domain = urlparse(url).netloc
275
+ if domain.startswith("www."): domain = domain[4:]
276
+ except:
277
+ domain = "unknown"
278
+
279
+ processed_refs.append({
280
+ "title": ref.get("title", "No Title"),
281
+ "url": url,
282
+ "domain": domain,
283
+ "favicon_url": f"https://www.google.com/s2/favicons?domain={domain}&sz=32"
284
+ })
285
+
286
+ if stages_used:
287
+ for stage in stages_used:
288
+ name = stage.get("name", "Step")
289
+ model = stage.get("model", "")
290
+
291
+ icon_html = ""
292
+
293
+ if name == "Search":
294
+ icon_html = SEARCH_ICON
295
+ else:
296
+ # Try to find vendor logo
297
+ # 1. Check explicit icon_config
298
+ icon_key = stage.get("icon_config", "")
299
+ # 2. Infer from model name if not present
300
+ if not icon_key:
301
+ icon_key = infer_icon_name(model)
302
+
303
+ icon_data_url = ""
304
+ if icon_key:
305
+ icon_data_url = self._get_icon_data_url(icon_key)
306
+
307
+ if icon_data_url:
308
+ icon_html = f'<img src="{icon_data_url}" class="w-5 h-5 object-contain rounded">'
309
+ else:
310
+ icon_html = DEFAULT_ICON
311
+
312
+ # Model Short
313
+ model_short = model.split("/")[-1] if "/" in model else model
314
+ if len(model_short) > 25:
315
+ model_short = model_short[:23] + "…"
316
+
317
+ time_val = stage.get("time", 0)
318
+ cost_val = stage.get("cost", 0.0)
319
+ if name == "Search": cost_val = 0.0
320
+
321
+ # --- NESTED DATA ---
322
+ stage_children = {}
323
+
324
+ # References go to "Search"
325
+ if name == "Search" and processed_refs:
326
+ stage_children['references'] = processed_refs
327
+
328
+ # MCP Steps go to "Agent"
329
+ # Process MCP steps here for the template
330
+ stage_mcp_steps = []
331
+ if name == "Agent" and mcp_steps:
332
+ # RemixIcon Mapping
333
+ STEP_ICONS = {
334
+ "navigate": '<i class="ri-compass-3-line"></i>',
335
+ "snapshot": '<i class="ri-camera-lens-line"></i>',
336
+ "click": '<i class="ri-cursor-fill"></i>',
337
+ "type": '<i class="ri-keyboard-line"></i>',
338
+ "code": '<i class="ri-code-line"></i>',
339
+ "search": SEARCH_ICON,
340
+ "default": '<i class="ri-arrow-right-s-line"></i>',
341
+ }
342
+ for step in mcp_steps:
343
+ icon_key = step.get("icon", "").lower()
344
+ if "search" in icon_key: icon_key = "search"
345
+ elif "nav" in icon_key or "visit" in icon_key: icon_key = "navigate"
346
+ elif "click" in icon_key: icon_key = "click"
347
+ elif "type" in icon_key or "input" in icon_key: icon_key = "type"
348
+ elif "shot" in icon_key: icon_key = "snapshot"
349
+
350
+ stage_mcp_steps.append({
351
+ "name": step.get("name", "unknown"),
352
+ "description": step.get("description", ""),
353
+ "icon_svg": STEP_ICONS.get(icon_key, STEP_ICONS["default"])
354
+ })
355
+ stage_children['mcp_steps'] = stage_mcp_steps
356
+
357
+ processed_stages.append({
358
+ "name": name,
359
+ "model": model,
360
+ "model_short": model_short,
361
+ "provider": stage.get("provider", ""),
362
+ "icon_html": icon_html,
363
+ "time_str": f"{time_val:.2f}s",
364
+ "cost_str": f"${cost_val:.6f}" if cost_val > 0 else "$0",
365
+ **stage_children # Merge children
366
+ })
367
+
368
+
369
+
370
+
371
+
372
+ # 4. Stats Footer Logic
373
+ processed_stats = {}
374
+ if stats:
375
+ # Assuming standard 'stats' dict structure, handle list if needed
376
+ if isinstance(stats, list):
377
+ stats_dict = stats[0] if stats else {}
378
+ else:
379
+ stats_dict = stats
380
+
381
+ agent_total_time = stats_dict.get("time", 0)
382
+ vision_time = stats_dict.get("vision_duration", 0)
383
+ llm_time = max(0, agent_total_time - vision_time)
384
+
385
+ vision_html = ""
386
+ if vision_time > 0:
387
+ vision_html = f'''
388
+ <div class="flex items-center gap-1.5 bg-white/60 px-2 py-1 rounded shadow-sm">
389
+ <span class="w-2 h-2 rounded-full bg-purple-400"></span>
390
+ <span>{vision_time:.1f}s</span>
391
+ </div>
392
+ '''
393
+
394
+ llm_html = f'''
395
+ <div class="flex items-center gap-1.5 bg-white/60 px-2 py-1 rounded shadow-sm">
396
+ <span class="w-2 h-2 rounded-full bg-green-400"></span>
397
+ <span>{llm_time:.1f}s</span>
398
+ </div>
399
+ '''
400
+
401
+ billing_html = ""
402
+ if billing_info and billing_info.get("total_cost", 0) > 0:
403
+ cost_cents = billing_info["total_cost"] * 100
404
+ billing_html = f'''
405
+ <div class="flex items-center gap-1.5 bg-white/60 px-2 py-1 rounded shadow-sm">
406
+ <span class="w-2 h-2 rounded-full bg-pink-500"></span>
407
+ <span>{cost_cents:.4f}¢</span>
408
+ </div>
409
+ '''
410
+
411
+ processed_stats = {
412
+ "vision_html": vision_html,
413
+ "llm_html": llm_html,
414
+ "billing_html": billing_html
415
+ }
416
+
417
+ # Render Template
418
+ context = {
419
+ "content_html": content_html,
420
+ "suggestions": suggestions or [],
421
+ "stages": processed_stages,
422
+ "references": processed_refs,
423
+ "references_json": json.dumps(references or []),
424
+ "stats": processed_stats,
425
+ **self.assets
426
+ }
427
+
428
+ final_html = self.template.render(**context)
429
+
703
430
  except MemoryError:
704
431
  last_exc = "memory"
705
432
  logger.warning(f"ContentRenderer: out of memory while building HTML (attempt {attempt}/{max_attempts})")
@@ -710,23 +437,19 @@ class ContentRenderer:
710
437
  continue
711
438
 
712
439
  try:
713
- logger.info("ContentRenderer: launching playwright...")
440
+ # logger.info("ContentRenderer: launching playwright...")
714
441
  async with async_playwright() as p:
715
- logger.info("ContentRenderer: playwright context ready, launching browser...")
442
+ # logger.info("ContentRenderer: playwright context ready, launching browser...")
716
443
  browser = await p.chromium.launch(headless=True)
717
444
  try:
718
445
  # Use device_scale_factor=2 for high DPI rendering (better quality)
719
446
  page = await browser.new_page(viewport={"width": 450, "height": 1200}, device_scale_factor=2)
720
447
 
721
- logger.debug("ContentRenderer: page created, setting content...")
722
-
723
448
  # Set content (10s timeout to handle slow CDN loading)
724
449
  set_ok = await self._set_content_safe(page, final_html, 10000)
725
450
  if not set_ok or page.is_closed():
726
451
  raise RuntimeError("set_content failed")
727
452
 
728
- logger.debug("ContentRenderer: content set, waiting for images...")
729
-
730
453
  # Wait for images with user-configured timeout (render_timeout_ms)
731
454
  image_timeout_sec = render_timeout_ms / 1000.0
732
455
  try:
@@ -760,15 +483,9 @@ class ContentRenderer:
760
483
  except asyncio.TimeoutError:
761
484
  logger.warning(f"ContentRenderer: image loading timed out after {image_timeout_sec}s, continuing...")
762
485
 
763
- logger.debug("ContentRenderer: images done, updating stats...")
764
-
765
-
766
-
767
- # Brief wait for layout to stabilize (CSS is pre-compiled)
486
+ # Brief wait for layout to stabilize
768
487
  await asyncio.sleep(0.1)
769
488
 
770
- logger.debug("ContentRenderer: taking screenshot...")
771
-
772
489
  # Try element screenshot first, fallback to full page
773
490
  element = await page.query_selector("#main-container")
774
491
 
@@ -778,11 +495,9 @@ class ContentRenderer:
778
495
  else:
779
496
  await page.screenshot(path=output_path, full_page=True)
780
497
  except Exception as screenshot_exc:
781
- # Fallback to full page screenshot if element screenshot fails
782
498
  logger.warning(f"ContentRenderer: element screenshot failed ({screenshot_exc}), trying full page...")
783
499
  await page.screenshot(path=output_path, full_page=True)
784
500
 
785
- logger.debug("ContentRenderer: screenshot done")
786
501
  finally:
787
502
  try:
788
503
  await browser.close()
@@ -795,132 +510,4 @@ class ContentRenderer:
795
510
  finally:
796
511
  content_html = None
797
512
  final_html = None
798
- template = None
799
- parts = None
800
513
  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()