entari-plugin-hyw 3.2.105__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 +97 -1
- 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.bak +157 -0
- entari_plugin_hyw/assets/template.j2 +259 -0
- entari_plugin_hyw/core/pipeline.py +41 -17
- entari_plugin_hyw/core/render.py +232 -681
- entari_plugin_hyw/core/render.py.bak +926 -0
- entari_plugin_hyw/utils/prompts.py +5 -4
- {entari_plugin_hyw-3.2.105.dist-info → entari_plugin_hyw-3.2.106.dist-info}/METADATA +2 -1
- {entari_plugin_hyw-3.2.105.dist-info → entari_plugin_hyw-3.2.106.dist-info}/RECORD +16 -11
- {entari_plugin_hyw-3.2.105.dist-info → entari_plugin_hyw-3.2.106.dist-info}/WHEEL +0 -0
- {entari_plugin_hyw-3.2.105.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,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} • {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,95 +156,27 @@ 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
|
|
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
|
-
#
|
|
588
|
-
|
|
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
|
-
#
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
else:
|
|
594
|
-
model_display = model_name.upper()
|
|
595
|
-
|
|
596
|
-
icon_data_url = self._get_icon_data_url(icon_config)
|
|
597
|
-
|
|
598
|
-
# provider_domain = self._get_domain(base_url)
|
|
599
|
-
# We now use passed provider_name instead of strict domain inference
|
|
600
|
-
|
|
601
|
-
# Prepare Vision Info if vision model was used
|
|
602
|
-
vision_display = None
|
|
603
|
-
vision_icon_url = None
|
|
604
|
-
vision_provider_domain = None
|
|
605
|
-
|
|
606
|
-
if vision_model_name:
|
|
607
|
-
if "/" in vision_model_name:
|
|
608
|
-
vision_display = vision_model_name.split("/")[-1].upper()
|
|
609
|
-
else:
|
|
610
|
-
vision_display = vision_model_name.upper()
|
|
611
|
-
|
|
612
|
-
# Use provided icon config or default to 'openai' (or generic eye icon logic inside _get_icon_data_url if we pass a generic name)
|
|
613
|
-
# Actually _get_icon_data_url handles fallback to openai.svg if file not found.
|
|
614
|
-
# But we need to pass something.
|
|
615
|
-
v_icon = vision_icon_config if vision_icon_config else "openai"
|
|
616
|
-
vision_icon_url = self._get_icon_data_url(v_icon)
|
|
617
|
-
|
|
618
|
-
# vision_provider_domain = self._get_domain(vision_base_url or base_url)
|
|
619
|
-
# New behavior: we ignore explicit vision provider text in header, just merge logic if needed or just use behavior summary.
|
|
620
|
-
# But the user might want to see the specific vision model name?
|
|
621
|
-
# User request: "Model UI Name Service Provider + Behavior Summary"
|
|
622
|
-
# It implies a SINGLE model card.
|
|
623
|
-
# If vision is used, we usually care about the main model responding.
|
|
624
|
-
# We can mention Vision in behavior summary.
|
|
625
|
-
|
|
626
|
-
# New "Automated Pipeline" Header
|
|
627
|
-
# Pink/White style
|
|
628
|
-
flowchart_icon = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-6 h-6 text-pink-600"><rect x="3" y="3" width="7" height="7"></rect><rect x="14" y="3" width="7" height="7"></rect><rect x="14" y="14" width="7" height="7"></rect><rect x="3" y="14" width="7" height="7"></rect></svg>'
|
|
629
|
-
|
|
630
|
-
response_header = self._generate_card_header(
|
|
631
|
-
title="Automated Pipeline",
|
|
632
|
-
custom_icon_html=flowchart_icon,
|
|
633
|
-
badge_text="PIPELINE",
|
|
634
|
-
provider_text="",
|
|
635
|
-
behavior_summary="",
|
|
636
|
-
is_plain=False,
|
|
637
|
-
icon_box_class="bg-gray-50 rounded-lg border border-gray-100"
|
|
638
|
-
)
|
|
639
|
-
|
|
640
|
-
suggestions_html = self._generate_suggestions_html(suggestions or [])
|
|
641
|
-
# Stats Footer REMOVED (now displayed in stages)
|
|
642
|
-
stats_html = ""
|
|
643
|
-
# Pass search_provider to references generation
|
|
644
|
-
mcp_steps_html = "" # self._generate_mcp_steps_html(mcp_steps or [])
|
|
645
|
-
# Generate pipeline (stages + mcp + references) HTML
|
|
646
|
-
pipeline_html = self._generate_pipeline_html(stages_used or [], mcp_steps or [], references or [])
|
|
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)
|
|
647
172
|
|
|
648
|
-
# 2.
|
|
649
|
-
|
|
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
181
|
# Server-side Markdown Rendering
|
|
658
182
|
content_html = markdown.markdown(
|
|
@@ -660,46 +184,213 @@ class ContentRenderer:
|
|
|
660
184
|
extensions=['fenced_code', 'tables', 'nl2br', 'sane_lists']
|
|
661
185
|
)
|
|
662
186
|
|
|
663
|
-
# Post-process to style citation markers
|
|
187
|
+
# Post-process to style citation markers
|
|
664
188
|
parts = re.split(r'(<code.*?>.*?</code>)', content_html, flags=re.DOTALL)
|
|
665
189
|
for i, part in enumerate(parts):
|
|
666
190
|
if not part.startswith('<code'):
|
|
667
|
-
|
|
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
|
|
668
196
|
content_html = "".join(parts)
|
|
669
197
|
|
|
670
|
-
#
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
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]}")
|
|
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 ---
|
|
691
207
|
|
|
692
|
-
|
|
208
|
+
# 1. Pipeline Stages (with Nested Data)
|
|
209
|
+
processed_stages = []
|
|
693
210
|
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
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
|
|
344
|
+
|
|
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
|
+
}
|
|
391
|
+
|
|
392
|
+
final_html = self.template.render(**context)
|
|
393
|
+
|
|
703
394
|
except MemoryError:
|
|
704
395
|
last_exc = "memory"
|
|
705
396
|
logger.warning(f"ContentRenderer: out of memory while building HTML (attempt {attempt}/{max_attempts})")
|
|
@@ -710,23 +401,19 @@ class ContentRenderer:
|
|
|
710
401
|
continue
|
|
711
402
|
|
|
712
403
|
try:
|
|
713
|
-
logger.info("ContentRenderer: launching playwright...")
|
|
404
|
+
# logger.info("ContentRenderer: launching playwright...")
|
|
714
405
|
async with async_playwright() as p:
|
|
715
|
-
logger.info("ContentRenderer: playwright context ready, launching browser...")
|
|
406
|
+
# logger.info("ContentRenderer: playwright context ready, launching browser...")
|
|
716
407
|
browser = await p.chromium.launch(headless=True)
|
|
717
408
|
try:
|
|
718
409
|
# Use device_scale_factor=2 for high DPI rendering (better quality)
|
|
719
410
|
page = await browser.new_page(viewport={"width": 450, "height": 1200}, device_scale_factor=2)
|
|
720
411
|
|
|
721
|
-
logger.debug("ContentRenderer: page created, setting content...")
|
|
722
|
-
|
|
723
412
|
# Set content (10s timeout to handle slow CDN loading)
|
|
724
413
|
set_ok = await self._set_content_safe(page, final_html, 10000)
|
|
725
414
|
if not set_ok or page.is_closed():
|
|
726
415
|
raise RuntimeError("set_content failed")
|
|
727
416
|
|
|
728
|
-
logger.debug("ContentRenderer: content set, waiting for images...")
|
|
729
|
-
|
|
730
417
|
# Wait for images with user-configured timeout (render_timeout_ms)
|
|
731
418
|
image_timeout_sec = render_timeout_ms / 1000.0
|
|
732
419
|
try:
|
|
@@ -760,15 +447,9 @@ class ContentRenderer:
|
|
|
760
447
|
except asyncio.TimeoutError:
|
|
761
448
|
logger.warning(f"ContentRenderer: image loading timed out after {image_timeout_sec}s, continuing...")
|
|
762
449
|
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
# Brief wait for layout to stabilize (CSS is pre-compiled)
|
|
450
|
+
# Brief wait for layout to stabilize
|
|
768
451
|
await asyncio.sleep(0.1)
|
|
769
452
|
|
|
770
|
-
logger.debug("ContentRenderer: taking screenshot...")
|
|
771
|
-
|
|
772
453
|
# Try element screenshot first, fallback to full page
|
|
773
454
|
element = await page.query_selector("#main-container")
|
|
774
455
|
|
|
@@ -778,11 +459,9 @@ class ContentRenderer:
|
|
|
778
459
|
else:
|
|
779
460
|
await page.screenshot(path=output_path, full_page=True)
|
|
780
461
|
except Exception as screenshot_exc:
|
|
781
|
-
# Fallback to full page screenshot if element screenshot fails
|
|
782
462
|
logger.warning(f"ContentRenderer: element screenshot failed ({screenshot_exc}), trying full page...")
|
|
783
463
|
await page.screenshot(path=output_path, full_page=True)
|
|
784
464
|
|
|
785
|
-
logger.debug("ContentRenderer: screenshot done")
|
|
786
465
|
finally:
|
|
787
466
|
try:
|
|
788
467
|
await browser.close()
|
|
@@ -795,132 +474,4 @@ class ContentRenderer:
|
|
|
795
474
|
finally:
|
|
796
475
|
content_html = None
|
|
797
476
|
final_html = None
|
|
798
|
-
template = None
|
|
799
|
-
parts = None
|
|
800
477
|
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()
|