entari-plugin-hyw 3.2.104__py3-none-any.whl → 3.2.106__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,336 +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, vision_title: str = None, vision_icon_data_url: str = None, vision_provider_text: str = None, is_plain: bool = False) -> 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-5 h-5 object-contain">'
127
- elif icon_emoji:
128
- icon_html = f'<span class="text-lg 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-lg shrink-0">{icon_symbol}</span>'
132
-
133
- # Badge (e.g. Search)
134
- badge_html = ""
135
- if badge_text:
136
- # Map badge class to tailwind colors
137
- bg_color = "bg-blue-50"
138
- text_color = "text-blue-700"
139
- if "search" in badge_class:
140
- bg_color = "bg-pink-50"
141
- text_color = "text-pink-700"
142
- elif "vision" in badge_class:
143
- bg_color = "bg-rose-50"
144
- text_color = "text-rose-600"
145
-
146
- badge_html = f'<span class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md text-[11px] font-semibold uppercase tracking-wide {bg_color} {text_color}">{badge_text}</span>'
147
-
148
- # Provider Badge
149
- provider_html = ""
150
- if provider_text:
151
- provider_html = f'<span class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md text-[11px] font-medium uppercase tracking-wide bg-gray-100 text-gray-500 lowercase">{provider_text}</span>'
152
-
153
- # Determine main badge styles
154
- main_badge_bg = "bg-white"
155
- title_color = "text-pink-600"
156
- if vision_title:
157
- main_badge_bg = "bg-white"
158
-
159
- if is_plain:
160
- title_color = "text-gray-900"
161
-
162
- # Main Title Group
163
- main_title_group = ""
164
- if title:
165
- main_title_group = f'''
166
- <div class="{main_badge_bg} shadow-sm rounded-md px-2.5 py-1 flex-none flex items-center gap-2 { '!bg-transparent !shadow-none !p-0' if is_plain else '' }">
167
- {icon_html}
168
- <span class="text-sm font-bold {title_color} uppercase tracking-wide whitespace-nowrap overflow-hidden text-ellipsis">{title}</span>
169
- </div>
170
- '''
171
-
172
- # Vision Title Group (if present)
173
- vision_row_html = ""
174
- if vision_title:
175
- vision_icon_html = ""
176
- if vision_icon_data_url:
177
- vision_icon_html = f'<img src="{vision_icon_data_url}" class="w-5 h-5 object-contain">'
178
- else:
179
- vision_icon_html = '<span class="text-lg shrink-0">👁️</span>'
180
-
181
- vision_provider_html = ""
182
- if vision_provider_text:
183
- vision_provider_html = f'<span class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md text-[11px] font-medium uppercase tracking-wide bg-gray-100 text-gray-500 lowercase">{vision_provider_text}</span>'
184
-
185
- vision_row_html = f'''
186
- <div class="mb-2 flex items-center justify-between w-full">
187
- <div class="bg-gradient-to-r from-pink-300 to-pink-200 text-white w-fit rounded-md px-2.5 py-1 flex items-center gap-2 flex-none">
188
- {vision_icon_html}
189
- <span class="text-sm font-bold text-white uppercase tracking-wide whitespace-nowrap overflow-hidden text-ellipsis">{vision_title}</span>
190
- </div>
191
- <div class="flex items-center gap-2">
192
- {vision_provider_html}
193
- </div>
194
- </div>
195
- '''
196
-
197
- # If no main title (vision only), we might need to adjust layout
198
- main_row_html = ""
199
- if main_title_group:
200
- main_row_html = f'''
201
- <div class="flex items-center justify-between w-full">
202
- {main_title_group}
203
- <div class="flex items-center gap-2">
204
- {provider_html}
205
- {badge_html}
206
- </div>
207
- </div>
208
- '''
209
-
210
- return f'''
211
- <div class="flex items-center justify-between pb-3 mb-3 border-b border-gray-100 gap-3 { '!bg-transparent !border-none !p-0 !mb-0 !pb-0' if is_plain else '' }">
212
- <div class="flex flex-col w-full">
213
- {vision_row_html}
214
- {main_row_html}
215
- </div>
216
- </div>
217
- '''
218
-
219
- def _generate_suggestions_html(self, suggestions: List[str]) -> str:
220
- if not suggestions:
221
- return ""
222
-
223
- # Pink Sparkles SVG
224
- icon_svg = '''
225
- <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">
226
- <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" />
227
- </svg>
228
- '''
229
- header = self._generate_card_header("SUGGESTIONS", badge_text=None, custom_icon_html=icon_svg, is_plain=True)
230
-
231
- html_parts = ['<div class="flex flex-col gap-2 bg-[#f2f2f2] rounded-2xl p-5 overflow-hidden">']
232
- html_parts.append(header)
233
- html_parts.append('<div class="grid grid-cols-2 gap-2.5">')
234
-
235
- for i, sug in enumerate(suggestions):
236
- html_parts.append(f'''
237
- <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">
238
- <span class="text-pink-600 font-mono font-bold text-[13px] whitespace-nowrap">{i+1}</span>
239
- <span class="flex-1 text-[13px] text-gray-600 font-medium whitespace-nowrap overflow-hidden text-ellipsis">{sug}</span>
240
- </div>
241
- ''')
242
- html_parts.append('</div></div>')
243
- return "".join(html_parts)
244
-
245
- def _generate_status_footer(self, stats: Union[Dict[str, Any], List[Dict[str, Any]]], billing_info: Dict[str, Any] = None) -> str:
246
- if not stats:
247
- return ""
248
-
249
- # Check if multi-step
250
- is_multi_step = isinstance(stats, list)
251
-
252
- # Billing HTML
253
- billing_html = ""
254
- if billing_info:
255
- total_cost = billing_info.get("total_cost", 0)
256
- if total_cost > 0:
257
- cost_cents = total_cost * 100
258
- cost_str = f"{cost_cents:.4f}¢"
259
- billing_html = f'''
260
- <div class="flex items-center gap-1.5 bg-white/60 px-2 py-1 rounded shadow-sm">
261
- <span class="w-2 h-2 rounded-full bg-pink-500"></span>
262
- <span>{cost_str}</span>
263
- </div>
264
- '''
265
-
266
- if is_multi_step:
267
- # Multi-step Layout
268
- step1_stats = stats[0]
269
- step2_stats = stats[1] if len(stats) > 1 else {}
270
-
271
- step1_time = step1_stats.get("time", 0)
272
- step2_time = step2_stats.get("time", 0)
273
-
274
- # Step 1 Time (Purple)
275
- step1_html = f'''
276
- <div class="flex items-center gap-1.5 bg-white/60 px-2 py-1 rounded shadow-sm">
277
- <span class="w-2 h-2 rounded-full bg-purple-400"></span>
278
- <span>{step1_time:.1f}s</span>
279
- </div>
280
- '''
281
-
282
- # Step 2 Time (Green)
283
- step2_html = f'''
284
- <div class="flex items-center gap-1.5 bg-white/60 px-2 py-1 rounded shadow-sm">
285
- <span class="w-2 h-2 rounded-full bg-green-400"></span>
286
- <span>{step2_time:.1f}s</span>
287
- </div>
288
- '''
289
-
290
- return f'''
291
- <div class="flex flex-col gap-2 bg-[#f2f2f2] rounded-2xl p-3 overflow-hidden">
292
- <div class="flex flex-wrap items-center gap-2 text-[10px] text-gray-600 font-bold font-mono uppercase tracking-wide">
293
- {step1_html}
294
- {step2_html}
295
- {billing_html}
296
- </div>
297
- </div>
298
- '''
299
-
300
- else:
301
- # Single Step Layout
302
- agent_total_time = stats.get("time", 0)
303
- vision_time = stats.get("vision_duration", 0)
304
- llm_time = max(0, agent_total_time - vision_time)
305
-
306
- # Vision Time Block
307
- vision_html = ""
308
- if vision_time > 0:
309
- vision_html = f'''
310
- <div class="flex items-center gap-1.5 bg-white/60 px-2 py-1 rounded shadow-sm">
311
- <span class="w-2 h-2 rounded-full bg-purple-400"></span>
312
- <span>{vision_time:.1f}s</span>
313
- </div>
314
- '''
315
-
316
- # Agent Time Block
317
- agent_html = f'''
318
- <div class="flex items-center gap-1.5 bg-white/60 px-2 py-1 rounded shadow-sm">
319
- <span class="w-2 h-2 rounded-full bg-green-400"></span>
320
- <span>{llm_time:.1f}s</span>
321
- </div>
322
- '''
323
-
324
- return f'''
325
- <div class="flex flex-col gap-2 bg-[#f2f2f2] rounded-2xl p-3 overflow-hidden">
326
- <div class="flex flex-wrap items-center gap-2 text-[10px] text-gray-600 font-bold font-mono uppercase tracking-wide">
327
- {vision_html}
328
- {agent_html}
329
- {billing_html}
330
- </div>
331
- </div>
332
- '''
333
-
334
- total_html = f'''
335
- <div class="flex items-center gap-1.5 bg-white/60 px-2 py-1 rounded shadow-sm">
336
- <span class="w-2 h-2 rounded-full bg-gray-400"></span>
337
- <span id="total-time-display">...</span>
338
- </div>
339
- '''
340
-
341
- return f'''
342
- <div class="flex flex-col gap-2 bg-[#f2f2f2] rounded-2xl p-3 overflow-hidden">
343
- <div class="flex flex-wrap items-center gap-2 text-[10px] text-gray-600 font-bold font-mono uppercase tracking-wide">
344
- {vision_html}
345
- {agent_html}
346
- {render_html}
347
- {total_html}
348
- {billing_html}
349
- </div>
350
- </div>
351
- '''
352
-
353
- def _generate_references_html(self, references: List[Dict[str, Any]], search_provider: str) -> str:
354
- if not references:
355
- return ""
356
-
357
- # Limit to 8 references
358
- refs = references[:8]
359
-
360
- # provider_display = search_provider.replace("_", " ").title()
361
- provider_display = "REFERENCES"
362
-
363
- # Pink Globe SVG
364
- icon_svg = '''
365
- <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">
366
- <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" />
367
- </svg>
368
- '''
369
- header = self._generate_card_header(provider_display, badge_text=None, custom_icon_html=icon_svg, is_plain=True) # Title is the provider
370
-
371
- html_parts = ['<div class="flex flex-col gap-3 bg-[#f2f2f2] rounded-2xl p-5 overflow-hidden">']
372
- html_parts.append(header)
373
- html_parts.append('<div class="grid grid-cols-2 gap-2.5">')
374
-
375
- for i, ref in enumerate(refs):
376
- title = ref.get("title", "No Title")
377
- url = ref.get("url", "#")
378
- try:
379
- domain = urlparse(url).netloc
380
- if domain.startswith("www."): domain = domain[4:]
381
- except Exception:
382
- domain = "unknown"
383
-
384
- favicon_url = f"https://www.google.com/s2/favicons?domain={domain}&sz=32"
385
-
386
- html_parts.append(f'''
387
- <a href="{url}" class="flex items-center gap-2.5 p-2.5 bg-white rounded-lg border border-gray-100 no-underline text-inherit transition-colors hover:bg-gray-50 shadow-sm" target="_blank">
388
- <div class="w-5 h-5 bg-pink-50 text-pink-600 border border-pink-100 rounded-md flex items-center justify-center text-[11px] font-bold shrink-0">{i+1}</div>
389
- <div class="flex-1 overflow-hidden flex flex-col gap-0.5">
390
- <div class="text-[13px] font-semibold text-gray-800 line-clamp-2" title="{title}">{title}</div>
391
- <div class="text-[11px] text-gray-400 flex items-center gap-1 whitespace-nowrap overflow-hidden text-ellipsis">
392
- <img src="{favicon_url}" class="w-3 h-3 rounded-sm" onerror="this.style.display='none'">
393
- {domain}
394
- </div>
395
- </div>
396
- </a>
397
- ''')
398
-
399
- html_parts.append('</div></div>')
400
- return "".join(html_parts)
401
-
402
- def _generate_mcp_steps_html(self, mcp_steps: List[Dict[str, Any]]) -> str:
403
- if not mcp_steps:
404
- return ""
405
-
406
- # Pink Terminal Icon
407
- 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>'''
408
-
409
- # Using the same header helper with plain style
410
- header = self._generate_card_header("MCP FLOW", badge_text=None, custom_icon_html=icon_svg, is_plain=True)
411
-
412
- # SVG icons with minimal footprint
413
- I_STYLE = "width:14px;height:14px"
414
- STEP_ICONS = {
415
- "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>''',
416
- "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>''',
417
- "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>''',
418
- "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>''',
419
- "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>''',
420
- "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>''',
421
- }
422
-
423
- html_parts = ['<div class="flex flex-col gap-3 bg-[#f2f2f2] rounded-2xl p-5 overflow-hidden">']
424
- html_parts.append(header)
425
- html_parts.append('<div class="flex flex-col gap-2">')
426
-
427
- for i, step in enumerate(mcp_steps):
428
- name = step.get("name", "unknown")
429
- desc = step.get("description", "")
430
- icon_key = step.get("icon", "").lower()
431
-
432
- icon_svg = STEP_ICONS.get(icon_key, STEP_ICONS["default"])
433
- desc_html = f'<div class="text-[10px] text-gray-400 mt-0.5">{desc}</div>' if desc else ''
434
-
435
- html_parts.append(f'''
436
- <div class="flex items-center gap-2.5 p-2.5 bg-white rounded-lg border border-gray-100 shadow-sm">
437
- <div class="w-6 h-6 flex items-center justify-center rounded-md border border-pink-100 bg-pink-50 text-pink-600 shrink-0">
438
- {icon_svg}
439
- </div>
440
- <div class="flex-1 min-w-0">
441
- <div class="text-[12px] font-semibold text-gray-800 font-mono">{name}</div>
442
- {desc_html}
443
- </div>
444
- </div>
445
- ''')
446
-
447
- html_parts.append('</div></div>')
448
- return "".join(html_parts)
449
-
450
127
  def _get_domain(self, url: str) -> str:
451
128
  try:
452
129
  parsed = urlparse(url)
@@ -467,8 +144,10 @@ class ContentRenderer:
467
144
  stats: Dict[str, Any] = None,
468
145
  references: List[Dict[str, Any]] = None,
469
146
  mcp_steps: List[Dict[str, Any]] = None,
147
+ stages_used: List[Dict[str, Any]] = None,
470
148
  model_name: str = "",
471
- search_provider: str = "Unknown Provider",
149
+ provider_name: str = "Unknown",
150
+ behavior_summary: str = "Text Generation",
472
151
  icon_config: str = "openai",
473
152
  vision_model_name: str = None,
474
153
  vision_icon_config: str = None,
@@ -477,82 +156,27 @@ class ContentRenderer:
477
156
  billing_info: Dict[str, Any] = None,
478
157
  render_timeout_ms: int = 6000):
479
158
  """
480
- Render markdown content to an image using Playwright.
159
+ Render markdown content to an image using Playwright and Jinja2.
481
160
  """
482
161
  render_start_time = asyncio.get_event_loop().time()
483
162
 
484
- # Preprocess to fix common markdown issues (like lists without preceding newline)
485
- # Ensure a blank line before a list item if the previous line is not empty
486
- # Matches a newline preceded by non-whitespace, followed by a list marker
163
+ # Preprocess to fix common markdown issues
487
164
  markdown_content = re.sub(r'(?<=\S)\n(?=\s*(\d+\.|[-*+]) )', r'\n\n', markdown_content)
488
-
489
- # Replace Chinese colon with English colon + space to avoid list rendering issues
490
- # markdown_content = markdown_content.replace(":", ":")
491
-
492
- # Replace other full-width punctuation with half-width + space
493
- # markdown_content = markdown_content.replace(",", ",").replace("。", ".").replace("?", "?").replace("!", "!")
494
-
495
- # Remove bold markers around Chinese text (or text containing CJK characters)
496
- # This addresses rendering issues where bold Chinese fonts look bad or fail to render.
497
- # Matches **...** where content includes at least one CJK character.
498
- # markdown_content = re.sub(r'\*\*([^*]*[\u4e00-\u9fa5\u3000-\u303f\uff00-\uffef][^*]*)\*\*', r'\1', markdown_content)
499
165
 
500
- # 1. Prepare Template Variables
501
- timestamp = datetime.now().strftime("%H:%M:%S")
502
-
503
- # Header for Response Card
504
- if "/" in model_name:
505
- model_display = model_name.split("/")[-1].upper()
506
- else:
507
- model_display = model_name.upper()
508
-
509
- icon_data_url = self._get_icon_data_url(icon_config)
510
-
511
- provider_domain = self._get_domain(base_url)
166
+ # AGGRESSIVE CLEANING: Strip out "References" section and "[code]" blocks from the text
167
+ # because we are rendering them as structured UI elements now.
512
168
 
513
- # Prepare Vision Info if vision model was used
514
- vision_display = None
515
- vision_icon_url = None
516
- vision_provider_domain = None
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)
517
172
 
518
- if vision_model_name:
519
- if "/" in vision_model_name:
520
- vision_display = vision_model_name.split("/")[-1].upper()
521
- else:
522
- vision_display = vision_model_name.upper()
523
-
524
- # Use provided icon config or default to 'openai' (or generic eye icon logic inside _get_icon_data_url if we pass a generic name)
525
- # Actually _get_icon_data_url handles fallback to openai.svg if file not found.
526
- # But we need to pass something.
527
- v_icon = vision_icon_config if vision_icon_config else "openai"
528
- vision_icon_url = self._get_icon_data_url(v_icon)
529
-
530
- vision_provider_domain = self._get_domain(vision_base_url or base_url)
531
-
532
- response_header = self._generate_card_header(
533
- model_display,
534
- icon_data_url=icon_data_url,
535
- provider_text=provider_domain,
536
- vision_title=vision_display,
537
- vision_icon_data_url=vision_icon_url,
538
- vision_provider_text=vision_provider_domain
539
- )
540
-
541
- suggestions_html = self._generate_suggestions_html(suggestions or [])
542
- stats_html = self._generate_status_footer(stats or {}, billing_info=billing_info)
543
- # Pass search_provider to references generation
544
- references_html = self._generate_references_html(references or [], search_provider)
545
- # Generate MCP steps HTML
546
- mcp_steps_html = self._generate_mcp_steps_html(mcp_steps or [])
547
-
548
- # 2. Render with Playwright
549
- 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
550
178
  last_exc = None
551
179
  for attempt in range(1, max_attempts + 1):
552
- content_html = None
553
- final_html = None
554
- template = None
555
- parts = None
556
180
  try:
557
181
  # Server-side Markdown Rendering
558
182
  content_html = markdown.markdown(
@@ -560,35 +184,213 @@ class ContentRenderer:
560
184
  extensions=['fenced_code', 'tables', 'nl2br', 'sane_lists']
561
185
  )
562
186
 
563
- # Post-process to style citation markers [1], [2]...
187
+ # Post-process to style citation markers
564
188
  parts = re.split(r'(<code.*?>.*?</code>)', content_html, flags=re.DOTALL)
565
189
  for i, part in enumerate(parts):
566
190
  if not part.startswith('<code'):
567
- parts[i] = re.sub(r'\[(\d+)\](?![^<]*>)', r'<sup class="text-pink-600 font-bold text-[10px] ml-0.5">\1</sup>', part)
191
+ # 1. Numeric Citations [1] -> Blue Style (References)
192
+ 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)
193
+ # 2. Alphabetical Citations [a] -> Orange Style (MCP Flow)
194
+ 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)
195
+ parts[i] = part
568
196
  content_html = "".join(parts)
569
197
 
570
- # Load and Fill Template
571
- logger.info(f"Loading template from {self.template_path}")
572
- with open(self.template_path, "r", encoding="utf-8") as f:
573
- template = f.read()
574
- logger.info(f"Template header: {template[:100]}")
198
+ # Strip out the structured JSON blocks if they leaked into the content
199
+ # Look for <pre>... containing "mcp_steps" or "references" at the end
200
+ # Make regex robust to any language class or no class
201
+ content_html = re.sub(r'<pre><code[^>]*>[^<]*(mcp_steps|references)[^<]*</code></pre>\s*$', '', content_html, flags=re.DOTALL | re.IGNORECASE)
202
+ # Loop to remove multiple if present
203
+ while re.search(r'<pre><code[^>]*>[^<]*(mcp_steps|references)[^<]*</code></pre>\s*$', content_html, flags=re.DOTALL | re.IGNORECASE):
204
+ content_html = re.sub(r'<pre><code[^>]*>[^<]*(mcp_steps|references)[^<]*</code></pre>\s*$', '', content_html, flags=re.DOTALL | re.IGNORECASE)
205
+
206
+ # --- PREPARE DATA FOR JINJA TEMPLATE ---
207
+
208
+ # 1. Pipeline Stages (with Nested Data)
209
+ processed_stages = []
210
+
211
+ # Unified Search Icon (RemixIcon)
212
+ SEARCH_ICON = '<i class="ri-search-line text-[16px]"></i>'
213
+ DEFAULT_ICON = '<i class="ri-box-3-line text-[16px]"></i>'
214
+
215
+ # Helper to infer provider/icon name from model string
216
+ def infer_icon_name(model_str):
217
+ if not model_str: return None
218
+ m = model_str.lower()
219
+ if "claude" in m or "anthropic" in m: return "anthropic"
220
+ if "gpt" in m or "openai" in m or "o1" in m: return "openai"
221
+ if "gemini" in m or "google" in m: return "google"
222
+ if "deepseek" in m: return "deepseek"
223
+ if "mistral" in m: return "mistral"
224
+ if "llama" in m: return "meta"
225
+ if "qwen" in m: return "qwen"
226
+ if "grok" in m: return "grok"
227
+ if "perplexity" in m: return "perplexity"
228
+ if "minimax" in m: return "minimax"
229
+ if "nvidia" in m: return "nvidia"
230
+ return None
231
+
232
+ # 2. Reference Processing (Moved up for nesting)
233
+ processed_refs = []
234
+ if references:
235
+ for ref in references[:8]:
236
+ url = ref.get("url", "#")
237
+ try:
238
+ domain = urlparse(url).netloc
239
+ if domain.startswith("www."): domain = domain[4:]
240
+ except:
241
+ domain = "unknown"
242
+
243
+ processed_refs.append({
244
+ "title": ref.get("title", "No Title"),
245
+ "url": url,
246
+ "domain": domain,
247
+ "favicon_url": f"https://www.google.com/s2/favicons?domain={domain}&sz=32"
248
+ })
249
+
250
+ if stages_used:
251
+ for stage in stages_used:
252
+ name = stage.get("name", "Step")
253
+ model = stage.get("model", "")
254
+
255
+ icon_html = ""
256
+
257
+ if name == "Search":
258
+ icon_html = SEARCH_ICON
259
+ else:
260
+ # Try to find vendor logo
261
+ # 1. Check explicit icon_config
262
+ icon_key = stage.get("icon_config", "")
263
+ # 2. Infer from model name if not present
264
+ if not icon_key:
265
+ icon_key = infer_icon_name(model)
266
+
267
+ icon_data_url = ""
268
+ if icon_key:
269
+ icon_data_url = self._get_icon_data_url(icon_key)
270
+
271
+ if icon_data_url:
272
+ icon_html = f'<img src="{icon_data_url}" class="w-5 h-5 object-contain rounded">'
273
+ else:
274
+ icon_html = DEFAULT_ICON
275
+
276
+ # Model Short
277
+ model_short = model.split("/")[-1] if "/" in model else model
278
+ if len(model_short) > 25:
279
+ model_short = model_short[:23] + "…"
280
+
281
+ time_val = stage.get("time", 0)
282
+ cost_val = stage.get("cost", 0.0)
283
+ if name == "Search": cost_val = 0.0
284
+
285
+ # --- NESTED DATA ---
286
+ stage_children = {}
287
+
288
+ # References go to "Search"
289
+ if name == "Search" and processed_refs:
290
+ stage_children['references'] = processed_refs
291
+
292
+ # MCP Steps go to "Agent"
293
+ # Process MCP steps here for the template
294
+ stage_mcp_steps = []
295
+ if name == "Agent" and mcp_steps:
296
+ # RemixIcon Mapping
297
+ STEP_ICONS = {
298
+ "navigate": '<i class="ri-compass-3-line"></i>',
299
+ "snapshot": '<i class="ri-camera-lens-line"></i>',
300
+ "click": '<i class="ri-cursor-fill"></i>',
301
+ "type": '<i class="ri-keyboard-line"></i>',
302
+ "code": '<i class="ri-code-line"></i>',
303
+ "search": SEARCH_ICON,
304
+ "default": '<i class="ri-arrow-right-s-line"></i>',
305
+ }
306
+ for step in mcp_steps:
307
+ icon_key = step.get("icon", "").lower()
308
+ if "search" in icon_key: icon_key = "search"
309
+ elif "nav" in icon_key or "visit" in icon_key: icon_key = "navigate"
310
+ elif "click" in icon_key: icon_key = "click"
311
+ elif "type" in icon_key or "input" in icon_key: icon_key = "type"
312
+ elif "shot" in icon_key: icon_key = "snapshot"
313
+
314
+ stage_mcp_steps.append({
315
+ "name": step.get("name", "unknown"),
316
+ "description": step.get("description", ""),
317
+ "icon_svg": STEP_ICONS.get(icon_key, STEP_ICONS["default"])
318
+ })
319
+ stage_children['mcp_steps'] = stage_mcp_steps
320
+
321
+ processed_stages.append({
322
+ "name": name,
323
+ "model": model,
324
+ "model_short": model_short,
325
+ "provider": stage.get("provider", ""),
326
+ "icon_html": icon_html,
327
+ "time_str": f"{time_val:.2f}s",
328
+ "cost_str": f"${cost_val:.6f}" if cost_val > 0 else "$0",
329
+ **stage_children # Merge children
330
+ })
331
+
332
+
333
+
334
+
335
+
336
+ # 4. Stats Footer Logic
337
+ processed_stats = {}
338
+ if stats:
339
+ # Assuming standard 'stats' dict structure, handle list if needed
340
+ if isinstance(stats, list):
341
+ stats_dict = stats[0] if stats else {}
342
+ else:
343
+ stats_dict = stats
575
344
 
576
- # Inject all pre-compiled assets (CSS + JS)
577
- final_html = template
578
- for key, content in self.assets.items():
579
- # Regex to match {{ key }} with optional whitespace
580
- pattern = r"\{\{\s*" + re.escape(key) + r"\s*\}\}"
581
- if re.search(pattern, final_html):
582
- final_html = re.sub(pattern, lambda _: content, final_html)
345
+ agent_total_time = stats_dict.get("time", 0)
346
+ vision_time = stats_dict.get("vision_duration", 0)
347
+ llm_time = max(0, agent_total_time - vision_time)
348
+
349
+ vision_html = ""
350
+ if vision_time > 0:
351
+ vision_html = f'''
352
+ <div class="flex items-center gap-1.5 bg-white/60 px-2 py-1 rounded shadow-sm">
353
+ <span class="w-2 h-2 rounded-full bg-purple-400"></span>
354
+ <span>{vision_time:.1f}s</span>
355
+ </div>
356
+ '''
357
+
358
+ llm_html = f'''
359
+ <div class="flex items-center gap-1.5 bg-white/60 px-2 py-1 rounded shadow-sm">
360
+ <span class="w-2 h-2 rounded-full bg-green-400"></span>
361
+ <span>{llm_time:.1f}s</span>
362
+ </div>
363
+ '''
364
+
365
+ billing_html = ""
366
+ if billing_info and billing_info.get("total_cost", 0) > 0:
367
+ cost_cents = billing_info["total_cost"] * 100
368
+ billing_html = f'''
369
+ <div class="flex items-center gap-1.5 bg-white/60 px-2 py-1 rounded shadow-sm">
370
+ <span class="w-2 h-2 rounded-full bg-pink-500"></span>
371
+ <span>{cost_cents:.4f}¢</span>
372
+ </div>
373
+ '''
374
+
375
+ processed_stats = {
376
+ "vision_html": vision_html,
377
+ "llm_html": llm_html,
378
+ "billing_html": billing_html
379
+ }
380
+
381
+ # Render Template
382
+ context = {
383
+ "content_html": content_html,
384
+ "suggestions": suggestions or [],
385
+ "stages": processed_stages,
386
+ "references": processed_refs,
387
+ "references_json": json.dumps(references or []),
388
+ "stats": processed_stats,
389
+ **self.assets
390
+ }
583
391
 
584
- final_html = final_html.replace("{{ content_html }}", content_html)
585
- final_html = final_html.replace("{{ timestamp }}", timestamp)
586
- final_html = final_html.replace("{{ suggestions }}", suggestions_html)
587
- final_html = final_html.replace("{{ stats }}", stats_html)
588
- final_html = final_html.replace("{{ references }}", references_html)
589
- final_html = final_html.replace("{{ mcp_steps }}", mcp_steps_html)
590
- final_html = final_html.replace("{{ response_header }}", response_header)
591
- final_html = final_html.replace("{{ references_json }}", json.dumps(references or []))
392
+ final_html = self.template.render(**context)
393
+
592
394
  except MemoryError:
593
395
  last_exc = "memory"
594
396
  logger.warning(f"ContentRenderer: out of memory while building HTML (attempt {attempt}/{max_attempts})")
@@ -599,21 +401,19 @@ class ContentRenderer:
599
401
  continue
600
402
 
601
403
  try:
404
+ # logger.info("ContentRenderer: launching playwright...")
602
405
  async with async_playwright() as p:
406
+ # logger.info("ContentRenderer: playwright context ready, launching browser...")
603
407
  browser = await p.chromium.launch(headless=True)
604
408
  try:
605
409
  # Use device_scale_factor=2 for high DPI rendering (better quality)
606
410
  page = await browser.new_page(viewport={"width": 450, "height": 1200}, device_scale_factor=2)
607
411
 
608
- logger.debug("ContentRenderer: page created, setting content...")
609
-
610
412
  # Set content (10s timeout to handle slow CDN loading)
611
413
  set_ok = await self._set_content_safe(page, final_html, 10000)
612
414
  if not set_ok or page.is_closed():
613
415
  raise RuntimeError("set_content failed")
614
416
 
615
- logger.debug("ContentRenderer: content set, waiting for images...")
616
-
617
417
  # Wait for images with user-configured timeout (render_timeout_ms)
618
418
  image_timeout_sec = render_timeout_ms / 1000.0
619
419
  try:
@@ -647,15 +447,9 @@ class ContentRenderer:
647
447
  except asyncio.TimeoutError:
648
448
  logger.warning(f"ContentRenderer: image loading timed out after {image_timeout_sec}s, continuing...")
649
449
 
650
- logger.debug("ContentRenderer: images done, updating stats...")
651
-
652
-
653
-
654
- # Brief wait for layout to stabilize (CSS is pre-compiled)
450
+ # Brief wait for layout to stabilize
655
451
  await asyncio.sleep(0.1)
656
452
 
657
- logger.debug("ContentRenderer: taking screenshot...")
658
-
659
453
  # Try element screenshot first, fallback to full page
660
454
  element = await page.query_selector("#main-container")
661
455
 
@@ -665,11 +459,9 @@ class ContentRenderer:
665
459
  else:
666
460
  await page.screenshot(path=output_path, full_page=True)
667
461
  except Exception as screenshot_exc:
668
- # Fallback to full page screenshot if element screenshot fails
669
462
  logger.warning(f"ContentRenderer: element screenshot failed ({screenshot_exc}), trying full page...")
670
463
  await page.screenshot(path=output_path, full_page=True)
671
464
 
672
- logger.debug("ContentRenderer: screenshot done")
673
465
  finally:
674
466
  try:
675
467
  await browser.close()
@@ -682,132 +474,4 @@ class ContentRenderer:
682
474
  finally:
683
475
  content_html = None
684
476
  final_html = None
685
- template = None
686
- parts = None
687
477
  gc.collect()
688
-
689
- logger.error(f"ContentRenderer: render failed after {max_attempts} attempts ({last_exc})")
690
- return False
691
-
692
- def _generate_model_card_html(self, index: int, model: Dict[str, Any]) -> str:
693
- name = model.get("name", "Unknown")
694
- provider = model.get("provider", "Unknown")
695
- icon_data = model.get("provider_icon", "")
696
- is_default = model.get("is_default", False)
697
- is_vision_default = model.get("is_vision_default", False)
698
-
699
- # Badges
700
- badges_html = ""
701
- if is_default:
702
- 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>'
703
- if is_vision_default:
704
- 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>'
705
-
706
- # Capability Badges
707
- if model.get("vision"):
708
- 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>'
709
- if model.get("tools"):
710
- 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>'
711
- if model.get("online"):
712
- 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>'
713
- if model.get("reasoning"):
714
- 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>'
715
-
716
- # Icon
717
- icon_html = ""
718
- if icon_data:
719
- icon_html = f'<img src="{icon_data}" class="w-5 h-5 object-contain rounded-sm">'
720
- else:
721
- icon_html = '<span class="w-5 h-5 flex items-center justify-center text-xs">📦</span>'
722
-
723
- return f'''
724
- <div class="bg-white rounded-xl p-4 shadow-sm border border-gray-200 flex flex-col gap-2 transition-all hover:shadow-md">
725
- <div class="flex justify-between items-start">
726
- <div class="flex items-center gap-2">
727
- <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>
728
- {icon_html}
729
- <h3 class="font-bold text-gray-800 text-sm">{name}</h3>
730
- </div>
731
- </div>
732
-
733
- <div class="pl-7 flex flex-col gap-1.5">
734
- <div class="flex flex-wrap gap-1">
735
- {badges_html}
736
- </div>
737
- <div class="flex items-center gap-1.5 text-xs text-gray-500">
738
- <span class="font-mono text-gray-400">Provider:</span>
739
- <div class="flex items-center gap-1.5 bg-gray-50 px-2 py-1 rounded border border-gray-100">
740
- <span class="font-medium text-gray-700">{provider}</span>
741
- </div>
742
- </div>
743
- </div>
744
- </div>
745
- '''
746
-
747
- 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):
748
- """
749
- Render the list of models to an image.
750
- """
751
- # Resolve template path
752
- current_dir = os.path.dirname(os.path.abspath(__file__))
753
- plugin_root = os.path.dirname(current_dir)
754
- template_path = os.path.join(plugin_root, "assets", "template.html")
755
-
756
- # Generate HTML for models
757
- models_html_parts = []
758
- for i, m in enumerate(models, 1):
759
- m_copy = m.copy()
760
- if not m_copy.get("provider"):
761
- url = m_copy.get("base_url")
762
- if not url:
763
- url = default_base_url
764
-
765
- m_copy["provider"] = self._get_domain(url)
766
-
767
- # Determine provider icon
768
- icon_name = m_copy.get("icon")
769
- if icon_name:
770
- icon_name = icon_name.lower()
771
-
772
- if not icon_name:
773
- provider = m_copy.get("provider", "")
774
-
775
- if "." in provider:
776
- # Fallback: strip TLD
777
- parts = provider.split(".")
778
- if len(parts) >= 2:
779
- icon_name = parts[-2]
780
- else:
781
- icon_name = provider
782
- else:
783
- icon_name = provider
784
-
785
- # Get icon data URL
786
- m_copy["provider_icon"] = self._get_icon_data_url(icon_name or "openai")
787
-
788
- models_html_parts.append(self._generate_model_card_html(i, m_copy))
789
-
790
- models_html = "\n".join(models_html_parts)
791
-
792
- with open(template_path, "r", encoding="utf-8") as f:
793
- template = f.read()
794
-
795
- # Inject all pre-compiled assets (CSS + JS)
796
- final_html = template
797
- for key, content in self.assets.items():
798
- final_html = final_html.replace("{{ " + key + " }}", content)
799
- final_html = final_html.replace("{{ models_list }}", models_html)
800
-
801
- async with async_playwright() as p:
802
- browser = await p.chromium.launch(headless=True)
803
- page = await browser.new_page(viewport={"width": 450, "height": 800}, device_scale_factor=2)
804
-
805
- await self._set_content_safe(page, final_html, render_timeout_ms)
806
-
807
- element = await page.query_selector("#main-container")
808
- if element:
809
- await element.screenshot(path=output_path)
810
- else:
811
- await page.screenshot(path=output_path, full_page=True)
812
-
813
- await browser.close()