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.

Files changed (90) hide show
  1. entari_plugin_hyw/__init__.py +14 -89
  2. entari_plugin_hyw/assets/card-dist/index.html +135 -0
  3. entari_plugin_hyw/assets/card-dist/logos/anthropic.svg +1 -0
  4. entari_plugin_hyw/assets/card-dist/logos/cerebras.svg +9 -0
  5. entari_plugin_hyw/assets/card-dist/logos/deepseek.png +0 -0
  6. entari_plugin_hyw/assets/card-dist/logos/gemini.svg +1 -0
  7. entari_plugin_hyw/assets/card-dist/logos/google.svg +1 -0
  8. entari_plugin_hyw/assets/card-dist/logos/grok.png +0 -0
  9. entari_plugin_hyw/assets/card-dist/logos/huggingface.png +0 -0
  10. entari_plugin_hyw/assets/card-dist/logos/microsoft.svg +15 -0
  11. entari_plugin_hyw/assets/card-dist/logos/minimax.png +0 -0
  12. entari_plugin_hyw/assets/card-dist/logos/mistral.png +0 -0
  13. entari_plugin_hyw/assets/card-dist/logos/nvida.png +0 -0
  14. entari_plugin_hyw/assets/card-dist/logos/openai.svg +1 -0
  15. entari_plugin_hyw/assets/card-dist/logos/openrouter.png +0 -0
  16. entari_plugin_hyw/assets/card-dist/logos/perplexity.svg +24 -0
  17. entari_plugin_hyw/assets/card-dist/logos/qwen.png +0 -0
  18. entari_plugin_hyw/assets/card-dist/logos/xai.png +0 -0
  19. entari_plugin_hyw/assets/card-dist/logos/xiaomi.png +0 -0
  20. entari_plugin_hyw/assets/card-dist/logos/zai.png +0 -0
  21. entari_plugin_hyw/assets/card-dist/vite.svg +1 -0
  22. entari_plugin_hyw/card-ui/.gitignore +24 -0
  23. entari_plugin_hyw/card-ui/README.md +5 -0
  24. entari_plugin_hyw/card-ui/index.html +16 -0
  25. entari_plugin_hyw/card-ui/package-lock.json +2342 -0
  26. entari_plugin_hyw/card-ui/package.json +31 -0
  27. entari_plugin_hyw/card-ui/public/logos/anthropic.svg +1 -0
  28. entari_plugin_hyw/card-ui/public/logos/cerebras.svg +9 -0
  29. entari_plugin_hyw/card-ui/public/logos/deepseek.png +0 -0
  30. entari_plugin_hyw/card-ui/public/logos/gemini.svg +1 -0
  31. entari_plugin_hyw/card-ui/public/logos/google.svg +1 -0
  32. entari_plugin_hyw/card-ui/public/logos/grok.png +0 -0
  33. entari_plugin_hyw/card-ui/public/logos/huggingface.png +0 -0
  34. entari_plugin_hyw/card-ui/public/logos/microsoft.svg +15 -0
  35. entari_plugin_hyw/card-ui/public/logos/minimax.png +0 -0
  36. entari_plugin_hyw/card-ui/public/logos/mistral.png +0 -0
  37. entari_plugin_hyw/card-ui/public/logos/nvida.png +0 -0
  38. entari_plugin_hyw/card-ui/public/logos/openai.svg +1 -0
  39. entari_plugin_hyw/card-ui/public/logos/openrouter.png +0 -0
  40. entari_plugin_hyw/card-ui/public/logos/perplexity.svg +24 -0
  41. entari_plugin_hyw/card-ui/public/logos/qwen.png +0 -0
  42. entari_plugin_hyw/card-ui/public/logos/xai.png +0 -0
  43. entari_plugin_hyw/card-ui/public/logos/xiaomi.png +0 -0
  44. entari_plugin_hyw/card-ui/public/logos/zai.png +0 -0
  45. entari_plugin_hyw/card-ui/public/vite.svg +1 -0
  46. entari_plugin_hyw/card-ui/src/App.vue +216 -0
  47. entari_plugin_hyw/card-ui/src/assets/vue.svg +1 -0
  48. entari_plugin_hyw/card-ui/src/components/HelloWorld.vue +41 -0
  49. entari_plugin_hyw/card-ui/src/components/MarkdownContent.vue +330 -0
  50. entari_plugin_hyw/card-ui/src/components/SectionCard.vue +41 -0
  51. entari_plugin_hyw/card-ui/src/components/StageCard.vue +163 -0
  52. entari_plugin_hyw/card-ui/src/main.ts +5 -0
  53. entari_plugin_hyw/card-ui/src/style.css +8 -0
  54. entari_plugin_hyw/card-ui/src/types.ts +51 -0
  55. entari_plugin_hyw/card-ui/tsconfig.app.json +16 -0
  56. entari_plugin_hyw/card-ui/tsconfig.json +7 -0
  57. entari_plugin_hyw/card-ui/tsconfig.node.json +26 -0
  58. entari_plugin_hyw/card-ui/vite.config.ts +16 -0
  59. entari_plugin_hyw/core/config.py +0 -3
  60. entari_plugin_hyw/core/pipeline.py +136 -61
  61. entari_plugin_hyw/core/render_vue.py +255 -0
  62. entari_plugin_hyw/test_output/render_0.jpg +0 -0
  63. entari_plugin_hyw/test_output/render_1.jpg +0 -0
  64. entari_plugin_hyw/test_output/render_2.jpg +0 -0
  65. entari_plugin_hyw/test_output/render_3.jpg +0 -0
  66. entari_plugin_hyw/test_output/render_4.jpg +0 -0
  67. entari_plugin_hyw/tests/ui_test_output.jpg +0 -0
  68. entari_plugin_hyw/tests/verify_ui.py +139 -0
  69. entari_plugin_hyw/utils/misc.py +0 -3
  70. entari_plugin_hyw/utils/prompts.py +65 -63
  71. {entari_plugin_hyw-3.4.2.dist-info → entari_plugin_hyw-3.5.0rc1.dist-info}/METADATA +5 -2
  72. entari_plugin_hyw-3.5.0rc1.dist-info/RECORD +99 -0
  73. entari_plugin_hyw/assets/libs/highlight.css +0 -10
  74. entari_plugin_hyw/assets/libs/highlight.js +0 -1213
  75. entari_plugin_hyw/assets/libs/katex-auto-render.js +0 -1
  76. entari_plugin_hyw/assets/libs/katex.css +0 -1
  77. entari_plugin_hyw/assets/libs/katex.js +0 -1
  78. entari_plugin_hyw/assets/libs/tailwind.css +0 -1
  79. entari_plugin_hyw/assets/package-lock.json +0 -953
  80. entari_plugin_hyw/assets/package.json +0 -16
  81. entari_plugin_hyw/assets/tailwind.config.js +0 -12
  82. entari_plugin_hyw/assets/tailwind.input.css +0 -235
  83. entari_plugin_hyw/assets/template.html +0 -157
  84. entari_plugin_hyw/assets/template.html.bak +0 -157
  85. entari_plugin_hyw/assets/template.j2 +0 -400
  86. entari_plugin_hyw/core/render.py +0 -630
  87. entari_plugin_hyw/utils/prompts_cn.py +0 -119
  88. entari_plugin_hyw-3.4.2.dist-info/RECORD +0 -49
  89. {entari_plugin_hyw-3.4.2.dist-info → entari_plugin_hyw-3.5.0rc1.dist-info}/WHEEL +0 -0
  90. {entari_plugin_hyw-3.4.2.dist-info → entari_plugin_hyw-3.5.0rc1.dist-info}/top_level.txt +0 -0
@@ -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": getattr(self.config, "vision_icon", None) or infer_icon(v_model, v_base_url),
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": getattr(self.config, "instruct_icon", None) or infer_icon(i_model, i_base_url),
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 has_search_results and search_payloads:
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 = getattr(self.config, "icon", None) or infer_icon(a_model, a_base_url)
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
- # Assign total time/cost to last Agent stage
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 [N] citations in body text.
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
- remaining_text = text
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
- # 1. Try to unwrap JSON if the model acted like a ReAct agent
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
- # 2. Extract all [N] citations from body text (scan left to right for order)
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
- id_val = int(match.group(1))
660
- if id_val not in id_order:
661
- id_order.append(id_val)
662
- except ValueError:
663
- pass
664
-
665
- # 3. Build references by looking up cited IDs in all_web_results
666
- # Order by appearance in text
667
- old_to_new_map = {} # old_id -> new_id (for search & page only)
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 ![...]([N])
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": result_item.get("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
- item_type = result_item.get("_type", "")
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
- remaining_text = body_pattern.sub(replace_id, remaining_text)
753
+ # 5. Replacement Logic
754
+ # Define image replacement map separately to handle ![...]([N])
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 ![...]([N]) 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"![{alt}]({image_url_map[oid]})"
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
- parsed["response"] = remaining_text.strip()
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: ![...](https://...)
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