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