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.
- entari_plugin_hyw/__init__.py +126 -7
- entari_plugin_hyw/assets/libs/tailwind.css +1 -1
- entari_plugin_hyw/assets/package-lock.json +953 -0
- entari_plugin_hyw/assets/package.json +16 -0
- entari_plugin_hyw/assets/tailwind.config.js +1 -1
- entari_plugin_hyw/assets/tailwind.input.css +8 -8
- entari_plugin_hyw/assets/template.html +39 -43
- entari_plugin_hyw/assets/template.html.bak +157 -0
- entari_plugin_hyw/assets/template.j2 +259 -0
- entari_plugin_hyw/core/config.py +2 -4
- entari_plugin_hyw/core/pipeline.py +192 -23
- entari_plugin_hyw/core/render.py +235 -571
- entari_plugin_hyw/core/render.py.bak +926 -0
- entari_plugin_hyw/utils/prompts.py +6 -6
- {entari_plugin_hyw-3.2.104.dist-info → entari_plugin_hyw-3.2.106.dist-info}/METADATA +2 -1
- {entari_plugin_hyw-3.2.104.dist-info → entari_plugin_hyw-3.2.106.dist-info}/RECORD +18 -13
- {entari_plugin_hyw-3.2.104.dist-info → entari_plugin_hyw-3.2.106.dist-info}/WHEEL +0 -0
- {entari_plugin_hyw-3.2.104.dist-info → entari_plugin_hyw-3.2.106.dist-info}/top_level.txt +0 -0
entari_plugin_hyw/core/render.py
CHANGED
|
@@ -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.
|
|
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.
|
|
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="
|
|
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
|
-
|
|
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
|
|
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
|
-
#
|
|
501
|
-
|
|
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
|
-
#
|
|
514
|
-
|
|
515
|
-
|
|
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
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
#
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
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
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
if
|
|
582
|
-
|
|
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 =
|
|
585
|
-
|
|
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
|
-
|
|
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()
|