entari-plugin-hyw 3.4.2__py3-none-any.whl → 3.5.0rc1__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 +14 -89
- entari_plugin_hyw/assets/card-dist/index.html +135 -0
- entari_plugin_hyw/assets/card-dist/logos/anthropic.svg +1 -0
- entari_plugin_hyw/assets/card-dist/logos/cerebras.svg +9 -0
- entari_plugin_hyw/assets/card-dist/logos/deepseek.png +0 -0
- entari_plugin_hyw/assets/card-dist/logos/gemini.svg +1 -0
- entari_plugin_hyw/assets/card-dist/logos/google.svg +1 -0
- entari_plugin_hyw/assets/card-dist/logos/grok.png +0 -0
- entari_plugin_hyw/assets/card-dist/logos/huggingface.png +0 -0
- entari_plugin_hyw/assets/card-dist/logos/microsoft.svg +15 -0
- entari_plugin_hyw/assets/card-dist/logos/minimax.png +0 -0
- entari_plugin_hyw/assets/card-dist/logos/mistral.png +0 -0
- entari_plugin_hyw/assets/card-dist/logos/nvida.png +0 -0
- entari_plugin_hyw/assets/card-dist/logos/openai.svg +1 -0
- entari_plugin_hyw/assets/card-dist/logos/openrouter.png +0 -0
- entari_plugin_hyw/assets/card-dist/logos/perplexity.svg +24 -0
- entari_plugin_hyw/assets/card-dist/logos/qwen.png +0 -0
- entari_plugin_hyw/assets/card-dist/logos/xai.png +0 -0
- entari_plugin_hyw/assets/card-dist/logos/xiaomi.png +0 -0
- entari_plugin_hyw/assets/card-dist/logos/zai.png +0 -0
- entari_plugin_hyw/assets/card-dist/vite.svg +1 -0
- entari_plugin_hyw/card-ui/.gitignore +24 -0
- entari_plugin_hyw/card-ui/README.md +5 -0
- entari_plugin_hyw/card-ui/index.html +16 -0
- entari_plugin_hyw/card-ui/package-lock.json +2342 -0
- entari_plugin_hyw/card-ui/package.json +31 -0
- entari_plugin_hyw/card-ui/public/logos/anthropic.svg +1 -0
- entari_plugin_hyw/card-ui/public/logos/cerebras.svg +9 -0
- entari_plugin_hyw/card-ui/public/logos/deepseek.png +0 -0
- entari_plugin_hyw/card-ui/public/logos/gemini.svg +1 -0
- entari_plugin_hyw/card-ui/public/logos/google.svg +1 -0
- entari_plugin_hyw/card-ui/public/logos/grok.png +0 -0
- entari_plugin_hyw/card-ui/public/logos/huggingface.png +0 -0
- entari_plugin_hyw/card-ui/public/logos/microsoft.svg +15 -0
- entari_plugin_hyw/card-ui/public/logos/minimax.png +0 -0
- entari_plugin_hyw/card-ui/public/logos/mistral.png +0 -0
- entari_plugin_hyw/card-ui/public/logos/nvida.png +0 -0
- entari_plugin_hyw/card-ui/public/logos/openai.svg +1 -0
- entari_plugin_hyw/card-ui/public/logos/openrouter.png +0 -0
- entari_plugin_hyw/card-ui/public/logos/perplexity.svg +24 -0
- entari_plugin_hyw/card-ui/public/logos/qwen.png +0 -0
- entari_plugin_hyw/card-ui/public/logos/xai.png +0 -0
- entari_plugin_hyw/card-ui/public/logos/xiaomi.png +0 -0
- entari_plugin_hyw/card-ui/public/logos/zai.png +0 -0
- entari_plugin_hyw/card-ui/public/vite.svg +1 -0
- entari_plugin_hyw/card-ui/src/App.vue +216 -0
- entari_plugin_hyw/card-ui/src/assets/vue.svg +1 -0
- entari_plugin_hyw/card-ui/src/components/HelloWorld.vue +41 -0
- entari_plugin_hyw/card-ui/src/components/MarkdownContent.vue +330 -0
- entari_plugin_hyw/card-ui/src/components/SectionCard.vue +41 -0
- entari_plugin_hyw/card-ui/src/components/StageCard.vue +163 -0
- entari_plugin_hyw/card-ui/src/main.ts +5 -0
- entari_plugin_hyw/card-ui/src/style.css +8 -0
- entari_plugin_hyw/card-ui/src/types.ts +51 -0
- entari_plugin_hyw/card-ui/tsconfig.app.json +16 -0
- entari_plugin_hyw/card-ui/tsconfig.json +7 -0
- entari_plugin_hyw/card-ui/tsconfig.node.json +26 -0
- entari_plugin_hyw/card-ui/vite.config.ts +16 -0
- entari_plugin_hyw/core/config.py +0 -3
- entari_plugin_hyw/core/pipeline.py +136 -61
- entari_plugin_hyw/core/render_vue.py +255 -0
- entari_plugin_hyw/test_output/render_0.jpg +0 -0
- entari_plugin_hyw/test_output/render_1.jpg +0 -0
- entari_plugin_hyw/test_output/render_2.jpg +0 -0
- entari_plugin_hyw/test_output/render_3.jpg +0 -0
- entari_plugin_hyw/test_output/render_4.jpg +0 -0
- entari_plugin_hyw/tests/ui_test_output.jpg +0 -0
- entari_plugin_hyw/tests/verify_ui.py +139 -0
- entari_plugin_hyw/utils/misc.py +0 -3
- entari_plugin_hyw/utils/prompts.py +65 -63
- {entari_plugin_hyw-3.4.2.dist-info → entari_plugin_hyw-3.5.0rc1.dist-info}/METADATA +5 -2
- entari_plugin_hyw-3.5.0rc1.dist-info/RECORD +99 -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 -400
- entari_plugin_hyw/core/render.py +0 -630
- entari_plugin_hyw/utils/prompts_cn.py +0 -119
- entari_plugin_hyw-3.4.2.dist-info/RECORD +0 -49
- {entari_plugin_hyw-3.4.2.dist-info → entari_plugin_hyw-3.5.0rc1.dist-info}/WHEEL +0 -0
- {entari_plugin_hyw-3.4.2.dist-info → entari_plugin_hyw-3.5.0rc1.dist-info}/top_level.txt +0 -0
entari_plugin_hyw/core/config.py
CHANGED
|
@@ -24,9 +24,6 @@ class HYWConfig:
|
|
|
24
24
|
instruct_extra_body: Optional[Dict[str, Any]] = None
|
|
25
25
|
temperature: float = 0.4
|
|
26
26
|
max_turns: int = 10
|
|
27
|
-
icon: str = "openai" # logo for primary model
|
|
28
|
-
vision_icon: Optional[str] = None # logo for vision model (falls back to icon when absent)
|
|
29
|
-
instruct_icon: Optional[str] = None # logo for instruct model
|
|
30
27
|
enable_browser_fallback: bool = False
|
|
31
28
|
language: str = "Simplified Chinese"
|
|
32
29
|
input_price: Optional[float] = None # $ per 1M input tokens
|
|
@@ -122,6 +122,7 @@ class ProcessingPipeline:
|
|
|
122
122
|
|
|
123
123
|
# Reset search cache and ID counters for this execution
|
|
124
124
|
self.all_web_results = []
|
|
125
|
+
self.global_id_counter = 0
|
|
125
126
|
self.search_id_counter = 0
|
|
126
127
|
self.page_id_counter = 0
|
|
127
128
|
self.image_id_counter = 0
|
|
@@ -428,7 +429,7 @@ class ProcessingPipeline:
|
|
|
428
429
|
stages_used.append({
|
|
429
430
|
"name": "Vision",
|
|
430
431
|
"model": v_model,
|
|
431
|
-
"icon_config":
|
|
432
|
+
"icon_config": infer_icon(v_model, v_base_url),
|
|
432
433
|
"provider": infer_provider(v_base_url),
|
|
433
434
|
"time": v.get("time", 0),
|
|
434
435
|
"cost": v.get("cost", 0.0)
|
|
@@ -441,20 +442,33 @@ class ProcessingPipeline:
|
|
|
441
442
|
stages_used.append({
|
|
442
443
|
"name": "Instruct",
|
|
443
444
|
"model": i_model,
|
|
444
|
-
"icon_config":
|
|
445
|
+
"icon_config": infer_icon(i_model, i_base_url),
|
|
445
446
|
"provider": infer_provider(i_base_url),
|
|
446
447
|
"time": i.get("time", 0),
|
|
447
448
|
"cost": i.get("cost", 0.0)
|
|
448
449
|
})
|
|
449
450
|
|
|
450
|
-
if
|
|
451
|
+
# Show Search stage if we have ANY search results (text OR image)
|
|
452
|
+
if (has_search_results or has_image_results) and search_payloads:
|
|
453
|
+
# Collect initial search results for the Search stage card
|
|
454
|
+
initial_refs = [
|
|
455
|
+
{"title": r.get("title", ""), "url": r.get("url", ""), "domain": r.get("domain", "")}
|
|
456
|
+
for r in self.all_web_results if r.get("_type") == "search"
|
|
457
|
+
]
|
|
458
|
+
initial_images = [
|
|
459
|
+
{"title": r.get("title", ""), "url": r.get("url", ""), "thumbnail": r.get("thumbnail", "")}
|
|
460
|
+
for r in self.all_web_results if r.get("_type") == "image"
|
|
461
|
+
]
|
|
462
|
+
|
|
451
463
|
stages_used.append({
|
|
452
464
|
"name": "Search",
|
|
453
465
|
"model": getattr(self.config, "search_name", "DuckDuckGo"),
|
|
454
466
|
"icon_config": "search",
|
|
455
467
|
"provider": getattr(self.config, 'search_provider', 'Crawl4AI'),
|
|
456
468
|
"time": search_time,
|
|
457
|
-
"cost": 0.0
|
|
469
|
+
"cost": 0.0,
|
|
470
|
+
"references": initial_refs,
|
|
471
|
+
"image_references": initial_images
|
|
458
472
|
})
|
|
459
473
|
|
|
460
474
|
# Add Crawler stage if Instruct used crawl_page
|
|
@@ -496,7 +510,7 @@ class ProcessingPipeline:
|
|
|
496
510
|
a_model = a.get("model", "") or active_model
|
|
497
511
|
a_base_url = a.get("base_url", "") or self.config.base_url
|
|
498
512
|
steps = a.get("steps", [])
|
|
499
|
-
agent_icon =
|
|
513
|
+
agent_icon = infer_icon(a_model, a_base_url)
|
|
500
514
|
agent_provider = infer_provider(a_base_url)
|
|
501
515
|
|
|
502
516
|
for s in steps:
|
|
@@ -587,12 +601,25 @@ class ProcessingPipeline:
|
|
|
587
601
|
"time": 0, "cost": 0
|
|
588
602
|
})
|
|
589
603
|
|
|
590
|
-
|
|
604
|
+
# Assign total time/cost to last Agent stage
|
|
591
605
|
last_agent = next((s for s in reversed(stages_used) if s["name"] == "Agent"), None)
|
|
592
606
|
if last_agent:
|
|
593
607
|
last_agent["time"] = a.get("time", 0)
|
|
594
608
|
last_agent["cost"] = a.get("cost", 0.0)
|
|
595
609
|
|
|
610
|
+
# --- Final Filter: Only show cited items in workflow cards ---
|
|
611
|
+
cited_urls = {ref['url'] for ref in (structured.get("references", []) +
|
|
612
|
+
structured.get("page_references", []) +
|
|
613
|
+
structured.get("image_references", []))}
|
|
614
|
+
|
|
615
|
+
for s in stages_used:
|
|
616
|
+
if "references" in s and s["references"]:
|
|
617
|
+
s["references"] = [r for r in s["references"] if r.get("url") in cited_urls]
|
|
618
|
+
# if "image_references" in s and s["image_references"]:
|
|
619
|
+
# s["image_references"] = [r for r in s["image_references"] if r.get("url") in cited_urls]
|
|
620
|
+
if "crawled_pages" in s and s["crawled_pages"]:
|
|
621
|
+
s["crawled_pages"] = [r for r in s["crawled_pages"] if r.get("url") in cited_urls]
|
|
622
|
+
|
|
596
623
|
# Clean up conversation history: Remove tool calls and results to save tokens and avoid ID conflicts
|
|
597
624
|
# Keep only 'user' messages and 'assistant' messages without tool_calls (final answers)
|
|
598
625
|
cleaned_history = []
|
|
@@ -627,11 +654,7 @@ class ProcessingPipeline:
|
|
|
627
654
|
}
|
|
628
655
|
|
|
629
656
|
def _parse_tagged_response(self, text: str) -> Dict[str, Any]:
|
|
630
|
-
"""Parse response and auto-infer references from
|
|
631
|
-
|
|
632
|
-
New simplified format:
|
|
633
|
-
- Body text uses [1][2] format for citations
|
|
634
|
-
- No ref code block needed - we auto-infer from citations
|
|
657
|
+
"""Parse response and auto-infer references from citations and markdown images.
|
|
635
658
|
"""
|
|
636
659
|
parsed = {"response": "", "references": [], "page_references": [], "image_references": [], "flow_steps": []}
|
|
637
660
|
if not text:
|
|
@@ -639,9 +662,14 @@ class ProcessingPipeline:
|
|
|
639
662
|
|
|
640
663
|
import re
|
|
641
664
|
|
|
642
|
-
|
|
665
|
+
# 1. Strip trailing reference/source list
|
|
666
|
+
body_text = text
|
|
667
|
+
ref_list_pattern = re.compile(r'(?:\n\s*|^)\s*(?:#{1,3}|\*\*)\s*(?:References|Citations|Sources|参考资料|引用)[\s\S]*$', re.IGNORECASE | re.MULTILINE)
|
|
668
|
+
body_text = ref_list_pattern.sub('', body_text)
|
|
669
|
+
|
|
670
|
+
remaining_text = body_text.strip()
|
|
643
671
|
|
|
644
|
-
#
|
|
672
|
+
# 2. Unwrap JSON if necessary
|
|
645
673
|
try:
|
|
646
674
|
if remaining_text.strip().startswith("{") and "action" in remaining_text:
|
|
647
675
|
data = json.loads(remaining_text)
|
|
@@ -650,67 +678,114 @@ class ProcessingPipeline:
|
|
|
650
678
|
except Exception:
|
|
651
679
|
pass
|
|
652
680
|
|
|
653
|
-
#
|
|
681
|
+
# 3. Identify all citations [N] and direct markdown images ![]()
|
|
682
|
+
cited_ids = []
|
|
654
683
|
body_pattern = re.compile(r'\[(\d+)\]')
|
|
655
|
-
id_order = [] # Preserve citation order
|
|
656
|
-
|
|
657
684
|
for match in body_pattern.finditer(remaining_text):
|
|
658
685
|
try:
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
686
|
+
cited_ids.append(int(match.group(1)))
|
|
687
|
+
except ValueError: pass
|
|
688
|
+
|
|
689
|
+
# Also find direct URLs in ![]()
|
|
690
|
+
direct_image_urls = []
|
|
691
|
+
img_pattern = re.compile(r'!\[.*?\]\((.*?)\)')
|
|
692
|
+
for match in img_pattern.finditer(remaining_text):
|
|
693
|
+
url = match.group(1).strip()
|
|
694
|
+
if url and not url.startswith('['): # Not a [N] citation
|
|
695
|
+
direct_image_urls.append(url)
|
|
696
|
+
|
|
697
|
+
# 4. Build Citation Maps and Reference Lists
|
|
698
|
+
unified_id_map = {}
|
|
699
|
+
# Keep track of what we've already added to avoid duplicates
|
|
700
|
+
seen_urls = set()
|
|
668
701
|
|
|
702
|
+
# id_order needs to be unique and preserve appearance order
|
|
703
|
+
id_order = []
|
|
704
|
+
for id_val in cited_ids:
|
|
705
|
+
if id_val not in id_order:
|
|
706
|
+
id_order.append(id_val)
|
|
707
|
+
|
|
708
|
+
# Process [N] citations first to determine numbering
|
|
669
709
|
for old_id in id_order:
|
|
670
|
-
# Find in all_web_results by _id
|
|
671
710
|
result_item = next((r for r in self.all_web_results if r.get("_id") == old_id), None)
|
|
711
|
+
if not result_item: continue
|
|
712
|
+
|
|
713
|
+
url = result_item.get("url", "")
|
|
714
|
+
item_type = result_item.get("_type", "")
|
|
715
|
+
|
|
716
|
+
entry = {
|
|
717
|
+
"title": result_item.get("title", ""),
|
|
718
|
+
"url": url,
|
|
719
|
+
"domain": result_item.get("domain", "")
|
|
720
|
+
}
|
|
672
721
|
|
|
722
|
+
if item_type == "search":
|
|
723
|
+
parsed["references"].append(entry)
|
|
724
|
+
unified_id_map[old_id] = len(parsed["references"]) + len(parsed["page_references"])
|
|
725
|
+
seen_urls.add(url)
|
|
726
|
+
elif item_type == "page":
|
|
727
|
+
parsed["page_references"].append(entry)
|
|
728
|
+
unified_id_map[old_id] = len(parsed["references"]) + len(parsed["page_references"])
|
|
729
|
+
seen_urls.add(url)
|
|
730
|
+
elif item_type == "image":
|
|
731
|
+
entry["thumbnail"] = result_item.get("thumbnail", "")
|
|
732
|
+
if url not in seen_urls:
|
|
733
|
+
parsed["image_references"].append(entry)
|
|
734
|
+
seen_urls.add(url)
|
|
735
|
+
# Note: Images cited as [N] might be used in text like 
|
|
736
|
+
# We'll handle this in replacement
|
|
737
|
+
|
|
738
|
+
# Now handle direct image URLs from ![]() that weren't cited as [N]
|
|
739
|
+
for url in direct_image_urls:
|
|
740
|
+
if url in seen_urls: continue
|
|
741
|
+
# Find in all_web_results
|
|
742
|
+
result_item = next((r for r in self.all_web_results if (r.get("url") == url or r.get("image") == url) and r.get("_type") == "image"), None)
|
|
673
743
|
if result_item:
|
|
674
744
|
entry = {
|
|
675
745
|
"title": result_item.get("title", ""),
|
|
676
|
-
"url":
|
|
677
|
-
"domain": result_item.get("domain", "")
|
|
746
|
+
"url": url,
|
|
747
|
+
"domain": result_item.get("domain", ""),
|
|
748
|
+
"thumbnail": result_item.get("thumbnail", "")
|
|
678
749
|
}
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
# Auto-classify by type
|
|
683
|
-
if item_type == "search":
|
|
684
|
-
parsed["references"].append(entry)
|
|
685
|
-
old_to_new_map[old_id] = len(parsed["references"])
|
|
686
|
-
elif item_type == "page":
|
|
687
|
-
parsed["page_references"].append(entry)
|
|
688
|
-
old_to_new_map[old_id] = len(parsed["page_references"])
|
|
689
|
-
elif item_type == "image":
|
|
690
|
-
# Collect image but don't add to map (will be stripped from text)
|
|
691
|
-
entry["thumbnail"] = result_item.get("thumbnail", "")
|
|
692
|
-
parsed["image_references"].append(entry)
|
|
693
|
-
# Note: no old_to_new_map entry - image citations will be removed
|
|
694
|
-
|
|
695
|
-
# 4. Replace [old_id] with [new_id] in text, or remove if image
|
|
696
|
-
def replace_id(match):
|
|
697
|
-
try:
|
|
698
|
-
old_id = int(match.group(1))
|
|
699
|
-
new_id = old_to_new_map.get(old_id)
|
|
700
|
-
if new_id is not None:
|
|
701
|
-
return f"[{new_id}]"
|
|
702
|
-
else:
|
|
703
|
-
# Check if it's an image reference (not in map)
|
|
704
|
-
item = next((r for r in self.all_web_results if r.get("_id") == old_id), None)
|
|
705
|
-
if item and item.get("_type") == "image":
|
|
706
|
-
return "" # Remove image citations from text
|
|
707
|
-
except ValueError:
|
|
708
|
-
pass
|
|
709
|
-
return match.group(0)
|
|
750
|
+
parsed["image_references"].append(entry)
|
|
751
|
+
seen_urls.add(url)
|
|
710
752
|
|
|
711
|
-
|
|
753
|
+
# 5. Replacement Logic
|
|
754
|
+
# Define image replacement map separately to handle 
|
|
755
|
+
image_url_map = {} # old_id -> raw_url
|
|
756
|
+
for old_id in id_order:
|
|
757
|
+
item = next((r for r in self.all_web_results if r.get("_id") == old_id), None)
|
|
758
|
+
if item and item.get("_type") == "image":
|
|
759
|
+
image_url_map[old_id] = item.get("url", "")
|
|
760
|
+
|
|
761
|
+
def refined_replace(text):
|
|
762
|
+
# First, handle  specifically
|
|
763
|
+
# We want to replace the [N] with the actual URL so the markdown renders
|
|
764
|
+
def sub_img_ref(match):
|
|
765
|
+
alt = match.group(1)
|
|
766
|
+
ref = match.group(2)
|
|
767
|
+
inner_match = body_pattern.match(ref)
|
|
768
|
+
if inner_match:
|
|
769
|
+
oid = int(inner_match.group(1))
|
|
770
|
+
if oid in image_url_map:
|
|
771
|
+
return f""
|
|
772
|
+
return match.group(0)
|
|
773
|
+
|
|
774
|
+
text = re.sub(r'!\[(.*?)\]\((.*?)\)', sub_img_ref, text)
|
|
775
|
+
|
|
776
|
+
# Then handle normal [N] replacements
|
|
777
|
+
def sub_norm_ref(match):
|
|
778
|
+
oid = int(match.group(1))
|
|
779
|
+
if oid in unified_id_map:
|
|
780
|
+
return f"[{unified_id_map[oid]}]"
|
|
781
|
+
if oid in image_url_map:
|
|
782
|
+
return "" # Remove standalone image citations like [5] if they aren't in ![]()
|
|
783
|
+
return match.group(0)
|
|
784
|
+
|
|
785
|
+
return body_pattern.sub(sub_norm_ref, text)
|
|
712
786
|
|
|
713
|
-
|
|
787
|
+
final_text = refined_replace(remaining_text)
|
|
788
|
+
parsed["response"] = final_text.strip()
|
|
714
789
|
return parsed
|
|
715
790
|
|
|
716
791
|
async def _safe_route_tool(self, tool_call):
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Vue-based Card Renderer (Minimal Python)
|
|
3
|
+
|
|
4
|
+
Python only provides raw data. All frontend logic (markdown, syntax highlighting,
|
|
5
|
+
math rendering, citations) is handled by the Vue frontend.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import gc
|
|
10
|
+
import uuid
|
|
11
|
+
import os
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import List, Dict, Any
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
from loguru import logger
|
|
17
|
+
from playwright.async_api import async_playwright
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ContentRenderer:
|
|
21
|
+
"""Minimal renderer - only passes raw data to Vue template."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def __init__(self, template_path: str = None):
|
|
25
|
+
if template_path is None:
|
|
26
|
+
current_dir = Path(__file__).parent
|
|
27
|
+
plugin_root = current_dir.parent
|
|
28
|
+
template_path = plugin_root / "assets" / "card-dist" / "index.html"
|
|
29
|
+
|
|
30
|
+
self.template_path = Path(template_path)
|
|
31
|
+
if not self.template_path.exists():
|
|
32
|
+
raise FileNotFoundError(f"Vue template not found: {self.template_path}")
|
|
33
|
+
|
|
34
|
+
self.template_content = self.template_path.read_text(encoding="utf-8")
|
|
35
|
+
logger.info(f"ContentRenderer: loaded Vue template ({len(self.template_content)} bytes)")
|
|
36
|
+
|
|
37
|
+
# Persistent state
|
|
38
|
+
self.playwright = None
|
|
39
|
+
self.browser = None
|
|
40
|
+
self.context = None
|
|
41
|
+
self.page = None
|
|
42
|
+
self._lock = asyncio.Lock()
|
|
43
|
+
self._render_count = 0
|
|
44
|
+
self._max_renders_before_restart = 50 # Prevent memory leaks
|
|
45
|
+
|
|
46
|
+
async def start(self):
|
|
47
|
+
"""Initialize the browser and page."""
|
|
48
|
+
if self.page:
|
|
49
|
+
return
|
|
50
|
+
|
|
51
|
+
logger.info("ContentRenderer: Starting persistent browser...")
|
|
52
|
+
try:
|
|
53
|
+
self.playwright = await async_playwright().start()
|
|
54
|
+
self.browser = await self.playwright.chromium.launch(headless=True, args=['--no-sandbox', '--disable-setuid-sandbox'])
|
|
55
|
+
self.context = await self.browser.new_context(
|
|
56
|
+
viewport={"width": 520, "height": 1400},
|
|
57
|
+
device_scale_factor=2.4,
|
|
58
|
+
)
|
|
59
|
+
self.page = await self.context.new_page()
|
|
60
|
+
|
|
61
|
+
# Load the template once
|
|
62
|
+
await self.page.goto(self.template_path.as_uri(), wait_until="networkidle")
|
|
63
|
+
logger.info("ContentRenderer: Browser started and template loaded.")
|
|
64
|
+
|
|
65
|
+
except Exception as e:
|
|
66
|
+
logger.error(f"ContentRenderer: Failed to start browser: {e}")
|
|
67
|
+
await self.close()
|
|
68
|
+
raise
|
|
69
|
+
|
|
70
|
+
async def close(self):
|
|
71
|
+
"""Clean up browser resources."""
|
|
72
|
+
if self.page:
|
|
73
|
+
await self.page.close()
|
|
74
|
+
self.page = None
|
|
75
|
+
if self.context:
|
|
76
|
+
await self.context.close()
|
|
77
|
+
self.context = None
|
|
78
|
+
if self.browser:
|
|
79
|
+
await self.browser.close()
|
|
80
|
+
self.browser = None
|
|
81
|
+
if self.playwright:
|
|
82
|
+
await self.playwright.stop()
|
|
83
|
+
self.playwright = None
|
|
84
|
+
logger.info("ContentRenderer: Browser closed.")
|
|
85
|
+
|
|
86
|
+
async def _get_page(self):
|
|
87
|
+
"""Get or recreate the persistent page."""
|
|
88
|
+
if self._render_count >= self._max_renders_before_restart:
|
|
89
|
+
logger.info(f"ContentRenderer: Restarting browser after {self._render_count} renders...")
|
|
90
|
+
await self.close()
|
|
91
|
+
self._render_count = 0
|
|
92
|
+
|
|
93
|
+
if not self.page:
|
|
94
|
+
await self.start()
|
|
95
|
+
|
|
96
|
+
return self.page
|
|
97
|
+
|
|
98
|
+
async def render(
|
|
99
|
+
self,
|
|
100
|
+
markdown_content: str,
|
|
101
|
+
output_path: str,
|
|
102
|
+
stats: Dict[str, Any] = None,
|
|
103
|
+
references: List[Dict[str, Any]] = None,
|
|
104
|
+
page_references: List[Dict[str, Any]] = None,
|
|
105
|
+
image_references: List[Dict[str, Any]] = None,
|
|
106
|
+
stages_used: List[Dict[str, Any]] = None,
|
|
107
|
+
image_timeout: int = 3000,
|
|
108
|
+
**kwargs
|
|
109
|
+
) -> bool:
|
|
110
|
+
"""Render content to image using persistent browser."""
|
|
111
|
+
|
|
112
|
+
resolved_output_path = Path(output_path).resolve()
|
|
113
|
+
resolved_output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
114
|
+
|
|
115
|
+
# Prepare data
|
|
116
|
+
stats_dict = stats[0] if isinstance(stats, list) and stats else (stats or {})
|
|
117
|
+
|
|
118
|
+
render_data = {
|
|
119
|
+
"markdown": markdown_content,
|
|
120
|
+
"total_time": stats_dict.get("total_time", 0) or 0,
|
|
121
|
+
"stages": [
|
|
122
|
+
{
|
|
123
|
+
"name": s.get("name", "Step"),
|
|
124
|
+
"model": s.get("model", ""),
|
|
125
|
+
"provider": s.get("provider", ""),
|
|
126
|
+
"time": s.get("time", 0),
|
|
127
|
+
"cost": s.get("cost", 0),
|
|
128
|
+
"references": s.get("references") or s.get("search_results"),
|
|
129
|
+
"image_references": s.get("image_references"),
|
|
130
|
+
"crawled_pages": s.get("crawled_pages"),
|
|
131
|
+
}
|
|
132
|
+
for s in (stages_used or [])
|
|
133
|
+
],
|
|
134
|
+
"references": references or [],
|
|
135
|
+
"page_references": page_references or [],
|
|
136
|
+
"image_references": image_references or [],
|
|
137
|
+
"stats": stats_dict,
|
|
138
|
+
}
|
|
139
|
+
import time
|
|
140
|
+
start_time = time.time()
|
|
141
|
+
|
|
142
|
+
# Reorder images
|
|
143
|
+
self._reorder_images_in_stages(render_data["markdown"], render_data["stages"])
|
|
144
|
+
|
|
145
|
+
async with self._lock:
|
|
146
|
+
try:
|
|
147
|
+
page = await self._get_page()
|
|
148
|
+
|
|
149
|
+
# Update data via JS
|
|
150
|
+
# Using evaluate to call window.updateRenderData
|
|
151
|
+
await page.evaluate("(data) => window.updateRenderData(data)", render_data)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# Wait for Vue to update DOM
|
|
155
|
+
# Give Vue a moment to patch the DOM (insert img tags)
|
|
156
|
+
await asyncio.sleep(0.1)
|
|
157
|
+
|
|
158
|
+
# Wait for all images to load
|
|
159
|
+
try:
|
|
160
|
+
await page.wait_for_function(
|
|
161
|
+
"() => Array.from(document.images).every(img => img.complete)",
|
|
162
|
+
timeout=image_timeout
|
|
163
|
+
)
|
|
164
|
+
except Exception:
|
|
165
|
+
logger.warning(f"ContentRenderer: Timeout waiting for images to load ({image_timeout}ms), taking screenshot anyway.")
|
|
166
|
+
|
|
167
|
+
# Resize height if needed?
|
|
168
|
+
# The page height might change. We capture full page or specific element.
|
|
169
|
+
# If capturing element:
|
|
170
|
+
element = await page.query_selector("#main-container")
|
|
171
|
+
if element:
|
|
172
|
+
# Clean previous screenshots? No, overwrite.
|
|
173
|
+
await element.screenshot(path=str(resolved_output_path), type="jpeg", quality=98)
|
|
174
|
+
else:
|
|
175
|
+
await page.screenshot(path=str(resolved_output_path), full_page=True, type="jpeg", quality=98)
|
|
176
|
+
|
|
177
|
+
self._render_count += 1
|
|
178
|
+
|
|
179
|
+
duration = time.time() - start_time
|
|
180
|
+
logger.success(f"ContentRenderer: Rendered in {duration:.3f}s (No.{self._render_count})")
|
|
181
|
+
return True
|
|
182
|
+
|
|
183
|
+
except Exception as exc:
|
|
184
|
+
logger.error(f"ContentRenderer: render failed ({exc})")
|
|
185
|
+
# If render failed, maybe browser is dead. Close it to force restart next time.
|
|
186
|
+
await self.close()
|
|
187
|
+
return False
|
|
188
|
+
finally:
|
|
189
|
+
gc.collect()
|
|
190
|
+
|
|
191
|
+
async def render_models_list(
|
|
192
|
+
self,
|
|
193
|
+
models: List[Dict[str, Any]],
|
|
194
|
+
output_path: str,
|
|
195
|
+
default_base_url: str = "https://openrouter.ai/api/v1",
|
|
196
|
+
**kwargs
|
|
197
|
+
) -> bool:
|
|
198
|
+
"""Render models list."""
|
|
199
|
+
lines = ["# 模型列表"]
|
|
200
|
+
for idx, model in enumerate(models or [], start=1):
|
|
201
|
+
name = model.get("name", "unknown")
|
|
202
|
+
base_url = model.get("base_url") or default_base_url
|
|
203
|
+
provider = model.get("provider", "")
|
|
204
|
+
lines.append(f"{idx}. **{name}** \n - base_url: {base_url} \n - provider: {provider}")
|
|
205
|
+
|
|
206
|
+
markdown_content = "\n\n".join(lines) if len(lines) > 1 else "# 模型列表\n暂无模型"
|
|
207
|
+
|
|
208
|
+
return await self.render(
|
|
209
|
+
markdown_content=markdown_content,
|
|
210
|
+
output_path=output_path,
|
|
211
|
+
stats={},
|
|
212
|
+
references=[],
|
|
213
|
+
stages_used=[],
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
def _reorder_images_in_stages(self, markdown: str, stages: List[Dict[str, Any]]) -> None:
|
|
217
|
+
"""Reorder image references in stages based on appearance in markdown."""
|
|
218
|
+
import re
|
|
219
|
+
|
|
220
|
+
# 1. Extract clean URLs from markdown
|
|
221
|
+
# Matches: 
|
|
222
|
+
img_urls = []
|
|
223
|
+
for match in re.finditer(r'!\[.*?\]\((.*?)\)', markdown):
|
|
224
|
+
# Url might be followed by title: "url" "title"
|
|
225
|
+
url_part = match.group(1).split()[0].strip()
|
|
226
|
+
if url_part and url_part not in img_urls:
|
|
227
|
+
img_urls.append(url_part)
|
|
228
|
+
|
|
229
|
+
if not img_urls:
|
|
230
|
+
return
|
|
231
|
+
|
|
232
|
+
# 2. Reorder each stage's image_references
|
|
233
|
+
for stage in stages:
|
|
234
|
+
refs = stage.get("image_references")
|
|
235
|
+
if not refs:
|
|
236
|
+
continue
|
|
237
|
+
|
|
238
|
+
# Map url -> ref object
|
|
239
|
+
ref_map = {r["url"]: r for r in refs}
|
|
240
|
+
|
|
241
|
+
new_refs = []
|
|
242
|
+
seen_urls = set()
|
|
243
|
+
|
|
244
|
+
# First, add images found in markdown in order
|
|
245
|
+
for url in img_urls:
|
|
246
|
+
if url in ref_map:
|
|
247
|
+
new_refs.append(ref_map[url])
|
|
248
|
+
seen_urls.add(url)
|
|
249
|
+
|
|
250
|
+
# Then add remaining images not found in markdown
|
|
251
|
+
for r in refs:
|
|
252
|
+
if r["url"] not in seen_urls:
|
|
253
|
+
new_refs.append(r)
|
|
254
|
+
|
|
255
|
+
stage["image_references"] = new_refs
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|