entari-plugin-hyw 4.0.0rc4__py3-none-any.whl → 4.0.0rc6__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 (30) hide show
  1. entari_plugin_hyw/__init__.py +216 -75
  2. entari_plugin_hyw/assets/card-dist/index.html +70 -79
  3. entari_plugin_hyw/browser/__init__.py +10 -0
  4. entari_plugin_hyw/browser/engines/base.py +13 -0
  5. entari_plugin_hyw/browser/engines/bing.py +95 -0
  6. entari_plugin_hyw/browser/engines/duckduckgo.py +137 -0
  7. entari_plugin_hyw/browser/engines/google.py +155 -0
  8. entari_plugin_hyw/browser/landing.html +172 -0
  9. entari_plugin_hyw/browser/manager.py +153 -0
  10. entari_plugin_hyw/browser/service.py +304 -0
  11. entari_plugin_hyw/card-ui/src/App.vue +526 -182
  12. entari_plugin_hyw/card-ui/src/components/MarkdownContent.vue +7 -11
  13. entari_plugin_hyw/card-ui/src/components/StageCard.vue +33 -30
  14. entari_plugin_hyw/card-ui/src/types.ts +9 -0
  15. entari_plugin_hyw/definitions.py +155 -0
  16. entari_plugin_hyw/history.py +111 -33
  17. entari_plugin_hyw/misc.py +34 -0
  18. entari_plugin_hyw/modular_pipeline.py +384 -0
  19. entari_plugin_hyw/render_vue.py +326 -239
  20. entari_plugin_hyw/search.py +95 -708
  21. entari_plugin_hyw/stage_base.py +92 -0
  22. entari_plugin_hyw/stage_instruct.py +345 -0
  23. entari_plugin_hyw/stage_instruct_deepsearch.py +104 -0
  24. entari_plugin_hyw/stage_summary.py +164 -0
  25. {entari_plugin_hyw-4.0.0rc4.dist-info → entari_plugin_hyw-4.0.0rc6.dist-info}/METADATA +4 -4
  26. {entari_plugin_hyw-4.0.0rc4.dist-info → entari_plugin_hyw-4.0.0rc6.dist-info}/RECORD +28 -16
  27. entari_plugin_hyw/pipeline.py +0 -1219
  28. entari_plugin_hyw/prompts.py +0 -47
  29. {entari_plugin_hyw-4.0.0rc4.dist-info → entari_plugin_hyw-4.0.0rc6.dist-info}/WHEEL +0 -0
  30. {entari_plugin_hyw-4.0.0rc4.dist-info → entari_plugin_hyw-4.0.0rc6.dist-info}/top_level.txt +0 -0
@@ -56,7 +56,8 @@ const props = defineProps<{
56
56
  markdown: string
57
57
  numSearchRefs?: number
58
58
  numPageRefs?: number
59
- bare?: boolean // When true, tables and code blocks render without window decoration
59
+ bare?: boolean
60
+ compact?: boolean
60
61
  }>()
61
62
 
62
63
  // Configure marked with syntax highlighting
@@ -251,21 +252,16 @@ const processedHtml = computed(() => {
251
252
 
252
253
  <template>
253
254
  <div ref="contentRef"
254
- class="prose prose-slate max-w-none prose-lg
255
- prose-headings:font-bold prose-headings:mb-3 prose-headings:mt-8 prose-headings:tracking-tight
256
- prose-p:leading-7 prose-p:my-4 prose-p:text-[20px] prose-li:text-[20px]
257
- prose-a:text-blue-600 prose-a:no-underline hover:prose-a:underline
258
- prose-code:bg-gray-100 prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded-none prose-code:text-[0.85em] prose-code:font-mono
259
- prose-pre:bg-gray-50 prose-pre:border prose-pre:border-gray-200 prose-pre:rounded-none prose-pre:p-0
260
- prose-img:rounded-none prose-img:my-6 prose-img:max-h-[400px] prose-img:w-auto prose-img:object-contain prose-img:border prose-img:border-gray-200
261
- prose-ol:list-decimal prose-ol:pl-7 prose-ol:list-outside prose-ol:my-5
262
- prose-li:my-2.5 prose-li:leading-7
263
- [&>*:first-child]:!mt-0"
255
+ class="prose prose-slate max-w-none"
256
+ :class="props.compact
257
+ ? 'prose-sm !prose-p:my-1 !prose-p:leading-snug !prose-p:text-[13.5px] !prose-li:text-[13.5px] !prose-headings:text-[14px] !prose-headings:mt-2 !prose-headings:mb-1 prose-a:text-blue-600 prose-a:no-underline hover:prose-a:underline [&>*:first-child]:!mt-0'
258
+ : 'prose-lg prose-headings:font-bold prose-headings:mb-3 prose-headings:mt-8 prose-headings:tracking-tight prose-p:leading-7 prose-p:my-4 prose-p:text-[20px] prose-li:text-[20px] prose-a:text-blue-600 prose-a:no-underline hover:prose-a:underline prose-code:bg-gray-100 prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded-none prose-code:text-[0.85em] prose-code:font-mono prose-pre:bg-gray-50 prose-pre:border prose-pre:border-gray-200 prose-pre:rounded-none prose-pre:p-0 prose-img:rounded-none prose-img:my-6 prose-img:max-h-[400px] prose-img:w-auto prose-img:object-contain prose-img:border prose-img:border-gray-200 prose-ol:list-decimal prose-ol:pl-7 prose-ol:list-outside prose-ol:my-5 prose-li:my-2.5 prose-li:leading-7 [&>*:first-child]:!mt-0'"
264
259
  style="--prose-headings: var(--text-primary); --prose-body: var(--text-body); --prose-bold: var(--text-primary); --prose-code: var(--text-body)"
265
260
  v-html="processedHtml">
266
261
  </div>
267
262
  </template>
268
263
 
264
+
269
265
  <style>
270
266
  /* Highlight.js theme - minimal */
271
267
  .hljs {
@@ -9,6 +9,7 @@ const props = defineProps<{
9
9
  isLast?: boolean
10
10
  prevStageName?: string
11
11
  refOffset?: number
12
+ hideRefs?: boolean
12
13
  }>()
13
14
 
14
15
  const failedImages = ref<Record<string, boolean>>({})
@@ -167,28 +168,24 @@ function getModelLogo(model: string): string | undefined {
167
168
  </div>
168
169
 
169
170
 
170
- <div v-if="stage.references?.length || stage.image_references?.length || stage.crawled_pages?.length" class="bg-white pl-11 relative">
171
- <div v-if="stage.references?.length" class="divide-y divide-gray-50 relative z-10">
172
- <a v-for="(ref, idx) in stage.references" :key="idx"
173
- :href="ref.url" target="_blank"
174
- class="flex items-start gap-3 pr-3 py-3 hover:bg-gray-50 transition-colors group">
175
- <!-- Favicon - Aligned with Title -->
176
- <img :src="getFavicon(ref.url)" class="w-4 h-4 rounded-none shrink-0 object-contain mt-[4px]">
177
-
178
- <!-- Content: Title and Domain -->
179
- <div class="flex-1 min-w-0 flex flex-col">
180
- <div class="flex items-center gap-2">
181
- <span class="flex-1 text-[18px] font-bold text-gray-700 truncate leading-tight tracking-tight">{{ ref.title }}</span>
182
- <!-- Square Badge with Shadow -->
183
- <span class="shrink-0 w-[18px] h-[18px] text-[11px] font-bold flex items-center justify-center" style="background-color: var(--theme-color); color: var(--header-text-color); box-shadow: 0 1px 3px 0 rgba(0,0,0,0.15)">{{ (refOffset || 0) + idx + 1 }}</span>
171
+ <div v-if="stage.description" class="px-5 py-3 text-[14.5px] text-gray-600 bg-gray-50/40 border-y border-gray-100 italic leading-relaxed font-sans">
172
+ <span class="mr-1 text-[var(--theme-color)] not-italic opacity-50">✦</span> {{ stage.description }}
173
+ </div>
174
+
175
+ <div v-if="stage.references?.length || stage.image_references?.length || stage.crawled_pages?.length || stage.tasks?.length" class="bg-white pl-11 relative">
176
+
177
+ <!-- Tasks List -->
178
+ <div v-if="stage.tasks?.length" class="pr-3 py-3 relative z-10 border-b border-gray-50 last:border-0">
179
+ <div v-for="(task, idx) in stage.tasks" :key="idx" class="flex items-start gap-2.5 mb-2 last:mb-0">
180
+ <div class="w-4 h-4 rounded-full border border-[var(--theme-color)] flex items-center justify-center shrink-0 mt-0.5 bg-[var(--theme-color)]/10">
181
+ <Icon icon="mdi:check" class="text-[10px] text-[var(--theme-color)]" />
184
182
  </div>
185
- <div class="text-[15.5px] font-mono truncate mt-0.5 tracking-tighter" style="color: var(--text-muted)">{{ getDomain(ref.url) }}</div>
186
- </div>
187
- </a>
183
+ <span class="text-[15px] text-gray-700 font-medium leading-tight select-text">{{ task }}</span>
184
+ </div>
188
185
  </div>
189
186
 
190
187
  <!-- Image Search Results - True Masonry Layout -->
191
- <div v-if="stage.image_references?.length" class="pr-3 py-3 relative z-10">
188
+ <div v-if="stage.image_references?.length && !hideRefs" class="pr-3 py-3 relative z-10">
192
189
  <div class="flex gap-2">
193
190
  <!-- Left Column -->
194
191
  <div class="flex-1 flex flex-col gap-2">
@@ -215,20 +212,26 @@ function getModelLogo(model: string): string | undefined {
215
212
  </div>
216
213
  </div>
217
214
 
218
- <div v-if="stage.crawled_pages?.length" class="divide-y divide-gray-50 relative z-10">
219
- <a v-for="(page, idx) in stage.crawled_pages" :key="idx"
220
- :href="page.url" target="_blank"
221
- class="flex items-start gap-3 pr-3 py-3 hover:bg-gray-50 transition-colors group">
222
- <img :src="getFavicon(page.url)" class="w-4 h-4 rounded-none shrink-0 object-contain mt-[4px]">
223
- <div class="flex-1 min-w-0 flex flex-col">
224
- <div class="flex items-center gap-2">
225
- <span class="flex-1 text-[18px] font-bold text-gray-700 truncate leading-tight tracking-tight">{{ page.title }}</span>
226
- <!-- Square Badge with Shadow -->
227
- <span class="shrink-0 w-[18px] h-[18px] text-[11px] font-bold flex items-center justify-center" style="background-color: var(--theme-color); color: var(--header-text-color); box-shadow: 0 1px 3px 0 rgba(0,0,0,0.15)">{{ (refOffset || 0) + (stage.references?.length || 0) + idx + 1 }}</span>
215
+ <div v-if="stage.crawled_pages?.length && !hideRefs" class="divide-y divide-gray-50 relative z-10">
216
+ <div v-for="(page, idx) in stage.crawled_pages" :key="idx" class="pr-3 py-3 hover:bg-gray-50 transition-colors group">
217
+ <!-- Header Row: Title & Link -->
218
+ <a :href="page.url" target="_blank" class="flex items-start gap-3 block">
219
+ <img :src="getFavicon(page.url)" class="w-4 h-4 rounded-none shrink-0 object-contain mt-[4px]">
220
+ <div class="flex-1 min-w-0 flex flex-col">
221
+ <div class="flex items-center gap-2">
222
+ <span class="flex-1 text-[18px] font-bold text-gray-700 truncate leading-tight tracking-tight group-hover:text-[var(--theme-color)] transition-colors">{{ page.title }}</span>
223
+ <!-- Square Badge with Shadow -->
224
+ <span class="shrink-0 w-[18px] h-[18px] text-[11px] font-bold flex items-center justify-center" style="background-color: var(--theme-color); color: var(--header-text-color); box-shadow: 0 1px 3px 0 rgba(0,0,0,0.15)">{{ (refOffset || 0) + (stage.references?.length || 0) + idx + 1 }}</span>
225
+ </div>
226
+ <div class="text-[15.5px] font-mono truncate mt-0.5 tracking-tighter" style="color: var(--text-muted)">{{ getDomain(page.url) }}</div>
228
227
  </div>
229
- <div class="text-[15.5px] font-mono truncate mt-0.5 tracking-tighter" style="color: var(--text-muted)">{{ getDomain(page.url) }}</div>
228
+ </a>
229
+
230
+ <!-- Description Sub-container -->
231
+ <div v-if="page.description" class="ml-7 mt-2.5 p-3 bg-gray-100/80 text-[14px] text-gray-600 leading-snug font-sans border-l-[3px] border-[var(--theme-color)] rounded-r-sm">
232
+ {{ page.description }}
230
233
  </div>
231
- </a>
234
+ </div>
232
235
  </div>
233
236
  </div>
234
237
  </div>
@@ -6,16 +6,24 @@ export interface Stage {
6
6
  model: string
7
7
  provider: string
8
8
  icon_name?: string // Icon identifier (e.g., "google", "openai")
9
+ icon_config?: string // Config key for icon (e.g. "openai")
9
10
  time: number // Time in seconds (raw number)
10
11
  cost: number // Cost in dollars (raw number)
12
+ usage?: any // Token usage stats
11
13
  references?: Reference[]
12
14
  image_references?: ImageReference[]
13
15
  crawled_pages?: CrawledPage[]
16
+ description?: string // Brief intro or thought for this stage
17
+ tasks?: string[]
14
18
  }
15
19
 
16
20
  export interface Reference {
17
21
  title: string
18
22
  url: string
23
+ is_fetched?: boolean
24
+ snippet?: string
25
+ type?: string
26
+ images?: string[] // Extracted images (base64)
19
27
  }
20
28
 
21
29
  export interface ImageReference {
@@ -27,6 +35,7 @@ export interface ImageReference {
27
35
  export interface CrawledPage {
28
36
  title: string
29
37
  url: string
38
+ description?: string
30
39
  }
31
40
 
32
41
  export interface Stats {
@@ -0,0 +1,155 @@
1
+ """
2
+ Centralized Definitions
3
+
4
+ All global prompts and tool definitions for the pipeline stages.
5
+ """
6
+
7
+ from typing import Dict, Any
8
+
9
+ # =============================================================================
10
+ # PROMPTS
11
+ # =============================================================================
12
+
13
+ INSTRUCT_SP = """你是一个智能 INSTRUCT 助手。
14
+ 你的目标是为用户的查询制定调研计划并执行
15
+ > 尽量完成所有搜索和获取, 需要的时候可以并发执行多个同种或不同种工具
16
+
17
+ - 用户消息包含典型名词、可能的专有名词组合: 调用 `web_search` 工具(最大3)。
18
+ - 提炼消息中出独立的关键词搜索关键词本身, 不添加任何其他辅助搜索词, 搜索效果最好
19
+ - 不混合搜索词, 每次只针对搜索一个关键词, 获取其目的方向的最新数据
20
+ - 用户消息中出现链接时, 调用 `crawl_page`(最大3) 获取网页全文
21
+ - 以下情况调用 `refuse_answer`。
22
+ - 明显的鉴证、国家领导人、历史敏感事件隐喻
23
+ - 过于露骨的r18+、r18g内容(但不包括 galgame等acg向, 除非其过于违规)
24
+
25
+ ## 内容审查
26
+ 以下情况调用 `refuse_answer`:
27
+ - 内容涉及隐喻政治事件任务
28
+ - r18+、r18g(但不包含正常galgame、科普等)
29
+
30
+ ## 决定流程
31
+ 调用 `set_mode` 决定使用 `fast` 还是 `deepsearch`:
32
+ - 只要用户不明确研究就用 `fast`
33
+ - 用户提到: 深度搜索、深度研究、deepsearch、整理关于...的资料, 这几种情况时, 选择 `deepsearch`
34
+
35
+ ## 重要规则 (CRITICAL RULES):
36
+ - 禁止输出任何文本回复:你必须且只能通过工具调用来行动。
37
+ - 如果没有工具调用,流程将自动结束。
38
+ """
39
+
40
+ INSTRUCT_DEEPSEARCH_SP = """你是一个智能 INSTRUCT_DEEPSEARCH 审查助手, 你需要对 INSTRUCT 的输出进行多次信息补充直到信息足够、或达到次数上限(3次)
41
+
42
+ - 推荐使用 `crawl_page` 工具查看官方网站、wiki网站(但不推荐维基百科)、权威网站
43
+ - crawl_page 永远不使用国内垃圾网站例如 csdn、知乎、等重复搬运二手信息的网站
44
+
45
+ ## 重要规则 (CRITICAL RULES):
46
+ - 禁止输出任何文本回复:你必须且只能通过工具调用来行动。
47
+ - 如果没有必要进一步操作,请不要输出任何内容(空回复),流程将自动进入下一阶段。
48
+ """
49
+
50
+
51
+ SUMMARY_REPORT_SP = """# 你是一个信息整合专家 (Summary Agent).
52
+ 你需要根据用户问题、搜索结果和网页详细内容,生成最终的回答.
53
+ 如果用户发送你好或空内容回应你好即可.
54
+
55
+ ## 过程要求
56
+ - 用户要求的回复语言(包裹在 language 标签内)
57
+ ```language
58
+ {language}
59
+ ```
60
+ - 字数控制在600字以内, 百科式风格, 语言严谨不啰嗦.
61
+ - 视觉信息: 输入中如果包含自动获取的网页截图,请分析图片中的信息作为参考.
62
+ - 注意分辨搜索内容是否和用户问题有直接关系, 避免盲目相信混为一谈.
63
+ - 正文格式:
64
+ - 先给出一个 `# `大标题约 8-10 个字, 不要有多余废话, 不要直接回答用户的提问.
65
+ - 然后紧接着给出一个 <summary>...</summary>, 除了给出一个约 100 字的纯文本简介, 介绍本次输出的长文的清晰、重点概括.
66
+ - 随后开始详细二级标题 + markdown 正文, 语言描绘格式丰富多样, 简洁准确可信.
67
+ - 请不要给出过长的代码、表格列数等, 只讲重点和准确的数据.
68
+ - 不支持渲染: 链接, 图片链接, mermaid
69
+ - 支持渲染: 公式, 代码高亮, 只在需要的时候给出.
70
+ - 图片链接、链接框架会自动渲染出, 你无需显式给出.
71
+ - 引用:
72
+ > 重要: 所有正文内容必须基于实际信息, 保证百分百真实度
73
+ - 信息来源已按获取顺序编号为 [1], [2], [3]... 但不超过 9 个引用.
74
+ - 优先引用优质 fetch 抓取的页面的资源, 但如果抓取到需要登录、需要验证码、需要跳转到其他网站等无法获取的资源, 则不引用此资源
75
+ - 正文中直接使用 [1] 格式引用, 只引用对回答有帮助的来源, 只使用官方性较强的 wiki、官方网站、资源站等等, 不使用第三方转载新闻网站.
76
+ - 无需给出参考文献列表, 系统会自动生成
77
+ """
78
+
79
+
80
+ # =============================================================================
81
+ # TOOL DEFINITIONS
82
+ # =============================================================================
83
+
84
+ def get_refuse_answer_tool() -> Dict[str, Any]:
85
+ """Tool for refusing to answer inappropriate content."""
86
+ return {
87
+ "type": "function",
88
+ "function": {
89
+ "name": "refuse_answer",
90
+ "description": "违规内容拒绝回答",
91
+ "parameters": {
92
+ "type": "object",
93
+ "properties": {
94
+ "reason": {"type": "string", "description": "拒绝回答的原因(展示给用户)"},
95
+ },
96
+ "required": ["reason"],
97
+ },
98
+ },
99
+ }
100
+
101
+
102
+ def get_web_search_tool() -> Dict[str, Any]:
103
+ """Tool for searching the web."""
104
+ return {
105
+ "type": "function",
106
+ "function": {
107
+ "name": "web_search",
108
+ "description": "网络搜索",
109
+ "parameters": {
110
+ "type": "object",
111
+ "properties": {"query": {"type": "string"}},
112
+ "required": ["query"],
113
+ },
114
+ },
115
+ }
116
+
117
+
118
+ def get_crawl_page_tool() -> Dict[str, Any]:
119
+ """Tool for crawling a web page."""
120
+ return {
121
+ "type": "function",
122
+ "function": {
123
+ "name": "crawl_page",
124
+ "description": "抓取网页并返回 Markdown 文本 / 网页截图",
125
+ "parameters": {
126
+ "type": "object",
127
+ "properties": {
128
+ "url": {"type": "string"},
129
+ },
130
+ "required": ["url"],
131
+ },
132
+ },
133
+ }
134
+
135
+
136
+ def get_set_mode_tool() -> Dict[str, Any]:
137
+ """Tool for setting the pipeline mode (fast or deepsearch)."""
138
+ return {
139
+ "type": "function",
140
+ "function": {
141
+ "name": "set_mode",
142
+ "description": "设置本次查询的处理模式",
143
+ "parameters": {
144
+ "type": "object",
145
+ "properties": {
146
+ "mode": {
147
+ "type": "string",
148
+ "enum": ["fast", "deepsearch"],
149
+ "description": "fast=快速回答 / deepsearch=深度研究"
150
+ },
151
+ },
152
+ "required": ["mode"],
153
+ },
154
+ },
155
+ }
@@ -75,24 +75,23 @@ class HistoryManager:
75
75
  self._context_history[context_id] = []
76
76
  self._context_history[context_id].append(key)
77
77
 
78
- def save_to_disk(self, key: str, save_dir: str = "data/conversations"):
79
- """Save conversation history to disk"""
78
+ def save_to_disk(self, key: str, save_root: str = "data/conversations", image_path: Optional[str] = None, web_results: Optional[List[Dict]] = None):
79
+ """Save conversation history to specific folder structure"""
80
80
  import os
81
81
  import time
82
82
  import re
83
+ import shutil
84
+ import json
83
85
 
84
86
  if key not in self._history:
85
87
  return
86
88
 
87
89
  try:
88
- os.makedirs(save_dir, exist_ok=True)
89
-
90
- # Extract user's first message (question) for filename
90
+ # Extract user's first message (question) for folder name
91
91
  user_question = ""
92
92
  for msg in self._history[key]:
93
93
  if msg.get("role") == "user":
94
94
  content = msg.get("content", "")
95
- # Handle content that might be a list (multimodal)
96
95
  if isinstance(content, list):
97
96
  for item in content:
98
97
  if isinstance(item, dict) and item.get("type") == "text":
@@ -102,31 +101,112 @@ class HistoryManager:
102
101
  user_question = str(content)
103
102
  break
104
103
 
105
- # Clean and truncate question for filename (10 chars)
106
- question_part = re.sub(r'[\\/:*?"<>|\n\r\t]', '', user_question)[:10].strip()
104
+ # Clean and truncate question
105
+ question_part = re.sub(r'[\\/:*?"<>|\n\r\t]', '', user_question)[:20].strip()
107
106
  if not question_part:
108
107
  question_part = "conversation"
109
108
 
110
- # Format: YYYYMMDD_HHMMSS_question.md
109
+ # Create folder: YYYYMMDD_HHMMSS_question
111
110
  time_str = time.strftime("%Y%m%d_%H%M%S", time.localtime())
112
- filename = f"{save_dir}/{time_str}_{question_part}.md"
111
+ folder_name = f"{time_str}_{question_part}"
112
+ folder_path = os.path.join(save_root, folder_name)
113
+
114
+ os.makedirs(folder_path, exist_ok=True)
113
115
 
114
- # Formatter
115
- timestamp = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
116
116
  meta = self._metadata.get(key, {})
117
+
118
+ # 1. Save Context/Trace
119
+ trace_md = meta.get("trace_markdown")
120
+ if trace_md:
121
+ with open(os.path.join(folder_path, "context_trace.md"), "w", encoding="utf-8") as f:
122
+ f.write(trace_md)
123
+
124
+ # 2. Save Web Results (Search & Pages)
125
+ if web_results:
126
+ pages_dir = os.path.join(folder_path, "pages")
127
+ os.makedirs(pages_dir, exist_ok=True)
128
+
129
+ search_buffer = [] # Buffer for unfetched search results
130
+
131
+ for i, item in enumerate(web_results):
132
+ item_type = item.get("_type", "unknown")
133
+ title = item.get("title", "Untitled")
134
+ url = item.get("url", "")
135
+ content = item.get("content", "")
136
+ item_id = item.get("_id", i + 1)
137
+
138
+ if not content:
139
+ continue
140
+
141
+ if item_type == "search":
142
+ # Collect search snippets for consolidated file
143
+ search_buffer.append(f"## [{item_id}] {title}\n- **URL**: {url}\n\n{content}\n")
144
+
145
+ elif item_type in ["page", "search_raw_page"]:
146
+ # Save fetched pages/raw search pages individually
147
+ clean_title = re.sub(r'[\\/:*?"<>|\n\r\t]', '', title)[:30].strip() or "page"
148
+ filename = f"{item_id:02d}_{item_type}_{clean_title}.md"
149
+
150
+ # Save screenshot if available
151
+ screenshot_b64 = item.get("screenshot_b64")
152
+ image_ref = ""
153
+ if screenshot_b64:
154
+ try:
155
+ import base64
156
+ img_filename = f"{item_id:02d}_{item_type}_{clean_title}.jpg"
157
+ img_path = os.path.join(pages_dir, img_filename)
158
+ with open(img_path, "wb") as f:
159
+ f.write(base64.b64decode(screenshot_b64))
160
+ image_ref = f"\n### Screenshot\n![Screenshot]({img_filename})\n"
161
+ except Exception as e:
162
+ print(f"Failed to save screenshot for {title}: {e}")
163
+
164
+ page_md = f"# [{item_id}] {title}\n\n"
165
+ page_md += f"- **Type**: {item_type}\n"
166
+ page_md += f"- **URL**: {url}\n\n"
167
+ if image_ref:
168
+ page_md += f"{image_ref}\n"
169
+ page_md += f"---\n\n{content}\n"
170
+
171
+ with open(os.path.join(pages_dir, filename), "w", encoding="utf-8") as f:
172
+ f.write(page_md)
173
+
174
+ # Save consolidated search results
175
+ if search_buffer:
176
+ with open(os.path.join(folder_path, "search_results.md"), "w", encoding="utf-8") as f:
177
+ f.write(f"# Search Results\n\nGenerated at {time.strftime('%Y-%m-%d %H:%M:%S')}\n\n" + "\n---\n\n".join(search_buffer))
178
+
179
+ # 3. Save Final Response (MD)
180
+ final_content = ""
181
+ # Find last assistant message
182
+ for msg in reversed(self._history[key]):
183
+ if msg.get("role") == "assistant":
184
+ content = msg.get("content", "")
185
+ if isinstance(content, str):
186
+ final_content = content
187
+ break
188
+
189
+ if final_content:
190
+ with open(os.path.join(folder_path, "final_response.md"), "w", encoding="utf-8") as f:
191
+ f.write(final_content)
192
+
193
+ # Save Output Image (Final Card)
194
+ if image_path and os.path.exists(image_path):
195
+ try:
196
+ dest_img_path = os.path.join(folder_path, "output_card.jpg")
197
+ shutil.copy2(image_path, dest_img_path)
198
+ except Exception as e:
199
+ print(f"Failed to copy output image: {e}")
200
+
201
+ # 4. Save Full Log (Readme style)
202
+ timestamp = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
117
203
  model_name = meta.get("model", "unknown")
118
204
  code = self._key_to_code.get(key, "N/A")
119
205
 
120
- md_content = f"# Conversation Log: {key}\n\n"
121
- md_content += f"**Time**: {timestamp}\n"
122
- md_content += f"**Code**: {code}\n"
123
- md_content += f"**Model**: {model_name}\n"
124
- md_content += f"**Metadata**: {meta}\n\n"
125
-
126
- trace_md = meta.get("trace_markdown") if isinstance(meta, dict) else None
127
- if trace_md:
128
- md_content += "## Trace\n\n"
129
- md_content += f"{trace_md}\n\n"
206
+ md_content = f"# Conversation Log: {folder_name}\n\n"
207
+ md_content += f"- **Time**: {timestamp}\n"
208
+ md_content += f"- **Code**: {code}\n"
209
+ md_content += f"- **Model**: {model_name}\n\n"
130
210
 
131
211
  md_content += "## History\n\n"
132
212
 
@@ -138,33 +218,31 @@ class HistoryManager:
138
218
 
139
219
  tool_calls = msg.get("tool_calls")
140
220
  if tool_calls:
141
- import json
142
221
  try:
143
222
  tc_str = json.dumps(tool_calls, ensure_ascii=False, indent=2)
144
223
  except:
145
224
  tc_str = str(tool_calls)
146
225
  md_content += f"**Tool Calls**:\n```json\n{tc_str}\n```\n\n"
147
226
 
148
- # Special handling for tool outputs or complex content
149
227
  if role == "TOOL":
150
- # Try to pretty print if it's JSON
151
228
  try:
152
- import json
153
- # Content might be a JSON string already
154
- parsed_content = json.loads(content)
155
- pretty_content = json.dumps(parsed_content, ensure_ascii=False, indent=2)
156
- md_content += f"**Output**:\n```json\n{pretty_content}\n```\n\n"
229
+ # Try parsing as JSON first
230
+ if isinstance(content, str):
231
+ parsed = json.loads(content)
232
+ pretty = json.dumps(parsed, ensure_ascii=False, indent=2)
233
+ md_content += f"**Output**:\n```json\n{pretty}\n```\n\n"
234
+ else:
235
+ md_content += f"**Output**:\n```text\n{content}\n```\n\n"
157
236
  except:
158
- md_content += f"**Output**:\n```text\n{content}\n```\n\n"
237
+ md_content += f"**Output**:\n```text\n{content}\n```\n\n"
159
238
  else:
160
239
  if content:
161
240
  md_content += f"{content}\n\n"
162
241
 
163
242
  md_content += "---\n\n"
164
243
 
165
- with open(filename, "w", encoding="utf-8") as f:
244
+ with open(os.path.join(folder_path, "full_log.md"), "w", encoding="utf-8") as f:
166
245
  f.write(md_content)
167
246
 
168
247
  except Exception as e:
169
- # We can't log easily here without importing logger, but it's fine
170
248
  print(f"Failed to save conversation: {e}")
entari_plugin_hyw/misc.py CHANGED
@@ -133,3 +133,37 @@ async def render_refuse_answer(
133
133
  theme_color=theme_color,
134
134
  )
135
135
 
136
+
137
+ IMAGE_UNSUPPORTED_MARKDOWN = """
138
+ <summary>
139
+ 当前模型不支持图片输入,请使用支持视觉能力的模型或仅发送文本。
140
+ </summary>
141
+ """
142
+
143
+ async def render_image_unsupported(
144
+ renderer,
145
+ output_path: str,
146
+ theme_color: str = "#ef4444",
147
+ tab_id: str = None
148
+ ) -> bool:
149
+ """
150
+ Render a card indicating that the model does not support image input.
151
+ """
152
+ markdown = f"""
153
+ # 图片输入不支持
154
+
155
+ > 当前选择的模型不支持图片输入。
156
+ > 请切换到支持视觉的模型,或仅发送文本内容。
157
+ """
158
+ return await renderer.render(
159
+ markdown_content=markdown,
160
+ output_path=output_path,
161
+ stats={},
162
+ references=[],
163
+ page_references=[],
164
+ image_references=[],
165
+ stages_used=[],
166
+ image_timeout=1000,
167
+ theme_color=theme_color,
168
+ tab_id=tab_id
169
+ )