entari-plugin-hyw 4.0.0rc5__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 +532 -0
- entari_plugin_hyw/assets/card-dist/index.html +387 -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/assets/icon/anthropic.svg +1 -0
- entari_plugin_hyw/assets/icon/cerebras.svg +9 -0
- entari_plugin_hyw/assets/icon/deepseek.png +0 -0
- entari_plugin_hyw/assets/icon/gemini.svg +1 -0
- entari_plugin_hyw/assets/icon/google.svg +1 -0
- entari_plugin_hyw/assets/icon/grok.png +0 -0
- entari_plugin_hyw/assets/icon/huggingface.png +0 -0
- entari_plugin_hyw/assets/icon/microsoft.svg +15 -0
- 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 +1 -0
- entari_plugin_hyw/assets/icon/openrouter.png +0 -0
- entari_plugin_hyw/assets/icon/perplexity.svg +24 -0
- entari_plugin_hyw/assets/icon/qwen.png +0 -0
- entari_plugin_hyw/assets/icon/xai.png +0 -0
- entari_plugin_hyw/assets/icon/xiaomi.png +0 -0
- entari_plugin_hyw/assets/icon/zai.png +0 -0
- entari_plugin_hyw/browser/__init__.py +10 -0
- entari_plugin_hyw/browser/engines/base.py +13 -0
- entari_plugin_hyw/browser/engines/bing.py +95 -0
- entari_plugin_hyw/browser/engines/searxng.py +137 -0
- entari_plugin_hyw/browser/landing.html +172 -0
- entari_plugin_hyw/browser/manager.py +153 -0
- entari_plugin_hyw/browser/service.py +275 -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 +756 -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 +382 -0
- entari_plugin_hyw/card-ui/src/components/SectionCard.vue +41 -0
- entari_plugin_hyw/card-ui/src/components/StageCard.vue +240 -0
- entari_plugin_hyw/card-ui/src/main.ts +5 -0
- entari_plugin_hyw/card-ui/src/style.css +29 -0
- entari_plugin_hyw/card-ui/src/test_regex.js +103 -0
- entari_plugin_hyw/card-ui/src/types.ts +61 -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/definitions.py +130 -0
- entari_plugin_hyw/history.py +248 -0
- entari_plugin_hyw/image_cache.py +274 -0
- entari_plugin_hyw/misc.py +135 -0
- entari_plugin_hyw/modular_pipeline.py +351 -0
- entari_plugin_hyw/render_vue.py +401 -0
- entari_plugin_hyw/search.py +116 -0
- entari_plugin_hyw/stage_base.py +88 -0
- entari_plugin_hyw/stage_instruct.py +328 -0
- entari_plugin_hyw/stage_instruct_review.py +92 -0
- entari_plugin_hyw/stage_summary.py +164 -0
- entari_plugin_hyw-4.0.0rc5.dist-info/METADATA +116 -0
- entari_plugin_hyw-4.0.0rc5.dist-info/RECORD +99 -0
- entari_plugin_hyw-4.0.0rc5.dist-info/WHEEL +5 -0
- entari_plugin_hyw-4.0.0rc5.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import random
|
|
2
|
+
import string
|
|
3
|
+
from typing import Dict, List, Any, Optional
|
|
4
|
+
|
|
5
|
+
class HistoryManager:
|
|
6
|
+
def __init__(self):
|
|
7
|
+
self._history: Dict[str, List[Dict[str, Any]]] = {}
|
|
8
|
+
self._metadata: Dict[str, Dict[str, Any]] = {}
|
|
9
|
+
self._mapping: Dict[str, str] = {}
|
|
10
|
+
self._context_latest: Dict[str, str] = {}
|
|
11
|
+
|
|
12
|
+
# New: Short code management
|
|
13
|
+
self._short_codes: Dict[str, str] = {} # code -> key
|
|
14
|
+
self._key_to_code: Dict[str, str] = {} # key -> code
|
|
15
|
+
self._context_history: Dict[str, List[str]] = {} # context_id -> list of keys
|
|
16
|
+
|
|
17
|
+
def is_bot_message(self, message_id: str) -> bool:
|
|
18
|
+
"""Check if the message ID belongs to a bot message"""
|
|
19
|
+
return message_id in self._history
|
|
20
|
+
|
|
21
|
+
def generate_short_code(self) -> str:
|
|
22
|
+
"""Generate a unique 4-digit hex code"""
|
|
23
|
+
while True:
|
|
24
|
+
code = ''.join(random.choices(string.hexdigits.lower(), k=4))
|
|
25
|
+
if code not in self._short_codes:
|
|
26
|
+
return code
|
|
27
|
+
|
|
28
|
+
def get_conversation_id(self, message_id: str) -> Optional[str]:
|
|
29
|
+
return self._mapping.get(message_id)
|
|
30
|
+
|
|
31
|
+
def get_key_by_code(self, code: str) -> Optional[str]:
|
|
32
|
+
return self._short_codes.get(code.lower())
|
|
33
|
+
|
|
34
|
+
def get_code_by_key(self, key: str) -> Optional[str]:
|
|
35
|
+
return self._key_to_code.get(key)
|
|
36
|
+
|
|
37
|
+
def get_history(self, key: str) -> List[Dict[str, Any]]:
|
|
38
|
+
return self._history.get(key, [])
|
|
39
|
+
|
|
40
|
+
def get_metadata(self, key: str) -> Dict[str, Any]:
|
|
41
|
+
return self._metadata.get(key, {})
|
|
42
|
+
|
|
43
|
+
def get_latest_from_context(self, context_id: str) -> Optional[str]:
|
|
44
|
+
return self._context_latest.get(context_id)
|
|
45
|
+
|
|
46
|
+
def list_by_context(self, context_id: str, limit: int = 10) -> List[str]:
|
|
47
|
+
"""Return list of keys for a context, most recent first"""
|
|
48
|
+
keys = self._context_history.get(context_id, [])
|
|
49
|
+
return keys[-limit:][::-1]
|
|
50
|
+
|
|
51
|
+
def remember(self, message_id: Optional[str], history: List[Dict[str, Any]], related_ids: List[str], metadata: Optional[Dict[str, Any]] = None, context_id: Optional[str] = None, code: Optional[str] = None):
|
|
52
|
+
if not message_id:
|
|
53
|
+
return
|
|
54
|
+
|
|
55
|
+
key = message_id
|
|
56
|
+
self._history[key] = history
|
|
57
|
+
if metadata:
|
|
58
|
+
self._metadata[key] = metadata
|
|
59
|
+
|
|
60
|
+
self._mapping[key] = key
|
|
61
|
+
for rid in related_ids:
|
|
62
|
+
if rid:
|
|
63
|
+
self._mapping[rid] = key
|
|
64
|
+
|
|
65
|
+
# Generate or use provided short code
|
|
66
|
+
if key not in self._key_to_code:
|
|
67
|
+
if not code:
|
|
68
|
+
code = self.generate_short_code()
|
|
69
|
+
self._short_codes[code] = key
|
|
70
|
+
self._key_to_code[key] = code
|
|
71
|
+
|
|
72
|
+
if context_id:
|
|
73
|
+
self._context_latest[context_id] = key
|
|
74
|
+
if context_id not in self._context_history:
|
|
75
|
+
self._context_history[context_id] = []
|
|
76
|
+
self._context_history[context_id].append(key)
|
|
77
|
+
|
|
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
|
+
import os
|
|
81
|
+
import time
|
|
82
|
+
import re
|
|
83
|
+
import shutil
|
|
84
|
+
import json
|
|
85
|
+
|
|
86
|
+
if key not in self._history:
|
|
87
|
+
return
|
|
88
|
+
|
|
89
|
+
try:
|
|
90
|
+
# Extract user's first message (question) for folder name
|
|
91
|
+
user_question = ""
|
|
92
|
+
for msg in self._history[key]:
|
|
93
|
+
if msg.get("role") == "user":
|
|
94
|
+
content = msg.get("content", "")
|
|
95
|
+
if isinstance(content, list):
|
|
96
|
+
for item in content:
|
|
97
|
+
if isinstance(item, dict) and item.get("type") == "text":
|
|
98
|
+
user_question = item.get("text", "")
|
|
99
|
+
break
|
|
100
|
+
else:
|
|
101
|
+
user_question = str(content)
|
|
102
|
+
break
|
|
103
|
+
|
|
104
|
+
# Clean and truncate question
|
|
105
|
+
question_part = re.sub(r'[\\/:*?"<>|\n\r\t]', '', user_question)[:20].strip()
|
|
106
|
+
if not question_part:
|
|
107
|
+
question_part = "conversation"
|
|
108
|
+
|
|
109
|
+
# Create folder: YYYYMMDD_HHMMSS_question
|
|
110
|
+
time_str = time.strftime("%Y%m%d_%H%M%S", time.localtime())
|
|
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)
|
|
115
|
+
|
|
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\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())
|
|
203
|
+
model_name = meta.get("model", "unknown")
|
|
204
|
+
code = self._key_to_code.get(key, "N/A")
|
|
205
|
+
|
|
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"
|
|
210
|
+
|
|
211
|
+
md_content += "## History\n\n"
|
|
212
|
+
|
|
213
|
+
for msg in self._history[key]:
|
|
214
|
+
role = msg.get("role", "unknown").upper()
|
|
215
|
+
content = msg.get("content", "")
|
|
216
|
+
|
|
217
|
+
md_content += f"### {role}\n\n"
|
|
218
|
+
|
|
219
|
+
tool_calls = msg.get("tool_calls")
|
|
220
|
+
if tool_calls:
|
|
221
|
+
try:
|
|
222
|
+
tc_str = json.dumps(tool_calls, ensure_ascii=False, indent=2)
|
|
223
|
+
except:
|
|
224
|
+
tc_str = str(tool_calls)
|
|
225
|
+
md_content += f"**Tool Calls**:\n```json\n{tc_str}\n```\n\n"
|
|
226
|
+
|
|
227
|
+
if role == "TOOL":
|
|
228
|
+
try:
|
|
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"
|
|
236
|
+
except:
|
|
237
|
+
md_content += f"**Output**:\n```text\n{content}\n```\n\n"
|
|
238
|
+
else:
|
|
239
|
+
if content:
|
|
240
|
+
md_content += f"{content}\n\n"
|
|
241
|
+
|
|
242
|
+
md_content += "---\n\n"
|
|
243
|
+
|
|
244
|
+
with open(os.path.join(folder_path, "full_log.md"), "w", encoding="utf-8") as f:
|
|
245
|
+
f.write(md_content)
|
|
246
|
+
|
|
247
|
+
except Exception as e:
|
|
248
|
+
print(f"Failed to save conversation: {e}")
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Image Caching Module for Pre-downloading Images
|
|
3
|
+
|
|
4
|
+
This module provides async image pre-download functionality to reduce render time.
|
|
5
|
+
Images are downloaded in the background when search results are obtained,
|
|
6
|
+
and cached as base64 data URLs for instant use during rendering.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import base64
|
|
11
|
+
import hashlib
|
|
12
|
+
from typing import Dict, List, Optional, Any
|
|
13
|
+
from loguru import logger
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ImageCache:
|
|
19
|
+
"""
|
|
20
|
+
Async image cache that pre-downloads images as base64.
|
|
21
|
+
|
|
22
|
+
Usage:
|
|
23
|
+
cache = ImageCache()
|
|
24
|
+
|
|
25
|
+
# Start pre-downloading images (non-blocking)
|
|
26
|
+
cache.start_prefetch(image_urls)
|
|
27
|
+
|
|
28
|
+
# Later, get cached image (blocking if not ready)
|
|
29
|
+
cached_url = await cache.get_cached(url) # Returns data:image/... or original URL
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
max_size_kb: int = 500, # Max image size to cache (KB)
|
|
35
|
+
max_concurrent: int = 6, # Max concurrent downloads
|
|
36
|
+
):
|
|
37
|
+
self.max_size_bytes = max_size_kb * 1024
|
|
38
|
+
self.max_concurrent = max_concurrent
|
|
39
|
+
|
|
40
|
+
# Cache storage: url -> base64_data_url or None (if failed)
|
|
41
|
+
self._cache: Dict[str, Optional[str]] = {}
|
|
42
|
+
# Pending downloads: url -> asyncio.Task
|
|
43
|
+
self._pending: Dict[str, asyncio.Task] = {}
|
|
44
|
+
# Semaphore for concurrent downloads
|
|
45
|
+
self._semaphore = asyncio.Semaphore(max_concurrent)
|
|
46
|
+
# Lock for cache access
|
|
47
|
+
self._lock = asyncio.Lock()
|
|
48
|
+
|
|
49
|
+
def start_prefetch(self, urls: List[str]) -> None:
|
|
50
|
+
"""
|
|
51
|
+
Start pre-downloading images in the background (non-blocking).
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
urls: List of image URLs to prefetch
|
|
55
|
+
"""
|
|
56
|
+
if not httpx:
|
|
57
|
+
logger.warning("ImageCache: httpx not installed, prefetch disabled")
|
|
58
|
+
return
|
|
59
|
+
|
|
60
|
+
for url in urls:
|
|
61
|
+
if not url or not url.startswith("http"):
|
|
62
|
+
continue
|
|
63
|
+
if url in self._cache or url in self._pending:
|
|
64
|
+
continue
|
|
65
|
+
|
|
66
|
+
# Create background task
|
|
67
|
+
task = asyncio.create_task(self._download_image(url))
|
|
68
|
+
self._pending[url] = task
|
|
69
|
+
|
|
70
|
+
async def _download_image(self, url: str) -> Optional[str]:
|
|
71
|
+
"""
|
|
72
|
+
Download a single image and convert to base64.
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
Base64 data URL or None if failed/too large
|
|
76
|
+
"""
|
|
77
|
+
async with self._semaphore:
|
|
78
|
+
try:
|
|
79
|
+
# No timeout - images download until agent ends
|
|
80
|
+
async with httpx.AsyncClient(timeout=None, follow_redirects=True) as client:
|
|
81
|
+
resp = await client.get(url, headers={
|
|
82
|
+
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"
|
|
83
|
+
})
|
|
84
|
+
resp.raise_for_status()
|
|
85
|
+
|
|
86
|
+
# Check content length
|
|
87
|
+
content_length = resp.headers.get("content-length")
|
|
88
|
+
if content_length and int(content_length) > self.max_size_bytes:
|
|
89
|
+
logger.debug(f"ImageCache: Skipping {url} (too large: {content_length} bytes)")
|
|
90
|
+
async with self._lock:
|
|
91
|
+
self._cache[url] = None
|
|
92
|
+
self._pending.pop(url, None)
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
# Read content
|
|
96
|
+
content = resp.content
|
|
97
|
+
if len(content) > self.max_size_bytes:
|
|
98
|
+
logger.debug(f"ImageCache: Skipping {url} (content too large: {len(content)} bytes)")
|
|
99
|
+
async with self._lock:
|
|
100
|
+
self._cache[url] = None
|
|
101
|
+
self._pending.pop(url, None)
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
# Determine MIME type
|
|
105
|
+
content_type = resp.headers.get("content-type", "").lower()
|
|
106
|
+
if "jpeg" in content_type or "jpg" in content_type:
|
|
107
|
+
mime = "image/jpeg"
|
|
108
|
+
elif "png" in content_type:
|
|
109
|
+
mime = "image/png"
|
|
110
|
+
elif "gif" in content_type:
|
|
111
|
+
mime = "image/gif"
|
|
112
|
+
elif "webp" in content_type:
|
|
113
|
+
mime = "image/webp"
|
|
114
|
+
elif "svg" in content_type:
|
|
115
|
+
mime = "image/svg+xml"
|
|
116
|
+
else:
|
|
117
|
+
# Try to infer from URL
|
|
118
|
+
url_lower = url.lower()
|
|
119
|
+
if ".jpg" in url_lower or ".jpeg" in url_lower:
|
|
120
|
+
mime = "image/jpeg"
|
|
121
|
+
elif ".png" in url_lower:
|
|
122
|
+
mime = "image/png"
|
|
123
|
+
elif ".gif" in url_lower:
|
|
124
|
+
mime = "image/gif"
|
|
125
|
+
elif ".webp" in url_lower:
|
|
126
|
+
mime = "image/webp"
|
|
127
|
+
elif ".svg" in url_lower:
|
|
128
|
+
mime = "image/svg+xml"
|
|
129
|
+
else:
|
|
130
|
+
mime = "image/jpeg" # Default fallback
|
|
131
|
+
|
|
132
|
+
# Encode to base64
|
|
133
|
+
b64 = base64.b64encode(content).decode("utf-8")
|
|
134
|
+
data_url = f"data:{mime};base64,{b64}"
|
|
135
|
+
|
|
136
|
+
async with self._lock:
|
|
137
|
+
self._cache[url] = data_url
|
|
138
|
+
self._pending.pop(url, None)
|
|
139
|
+
|
|
140
|
+
logger.debug(f"ImageCache: Cached {url} ({len(content)} bytes)")
|
|
141
|
+
return data_url
|
|
142
|
+
|
|
143
|
+
except Exception as e:
|
|
144
|
+
logger.debug(f"ImageCache: Failed to download {url}: {e}")
|
|
145
|
+
|
|
146
|
+
async with self._lock:
|
|
147
|
+
self._cache[url] = None
|
|
148
|
+
self._pending.pop(url, None)
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
async def get_cached(self, url: str, wait: bool = True) -> str:
|
|
152
|
+
"""
|
|
153
|
+
Get cached image data URL, or original URL if not cached.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
url: Original image URL
|
|
157
|
+
wait: If True, wait for pending download to complete (no timeout - waits until agent ends)
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
Cached data URL or original URL
|
|
161
|
+
"""
|
|
162
|
+
if not url:
|
|
163
|
+
return url
|
|
164
|
+
|
|
165
|
+
# Check if already cached
|
|
166
|
+
async with self._lock:
|
|
167
|
+
if url in self._cache:
|
|
168
|
+
cached = self._cache[url]
|
|
169
|
+
return cached if cached else url # Return original if cached as None (failed)
|
|
170
|
+
|
|
171
|
+
pending_task = self._pending.get(url)
|
|
172
|
+
|
|
173
|
+
# Wait for pending download if requested (no timeout - waits until cancelled)
|
|
174
|
+
if pending_task and wait:
|
|
175
|
+
try:
|
|
176
|
+
await pending_task
|
|
177
|
+
async with self._lock:
|
|
178
|
+
cached = self._cache.get(url)
|
|
179
|
+
return cached if cached else url
|
|
180
|
+
except asyncio.CancelledError:
|
|
181
|
+
logger.debug(f"ImageCache: Download cancelled for {url}")
|
|
182
|
+
return url
|
|
183
|
+
except Exception:
|
|
184
|
+
return url
|
|
185
|
+
|
|
186
|
+
return url
|
|
187
|
+
|
|
188
|
+
async def get_all_cached(self, urls: List[str]) -> Dict[str, str]:
|
|
189
|
+
"""
|
|
190
|
+
Get cached URLs for multiple images.
|
|
191
|
+
|
|
192
|
+
Args:
|
|
193
|
+
urls: List of original URLs
|
|
194
|
+
|
|
195
|
+
Returns:
|
|
196
|
+
Dict mapping original URL to cached data URL (or original if not cached)
|
|
197
|
+
"""
|
|
198
|
+
result = {}
|
|
199
|
+
|
|
200
|
+
# Wait for all pending downloads first (no timeout - waits until cancelled)
|
|
201
|
+
pending_tasks = []
|
|
202
|
+
async with self._lock:
|
|
203
|
+
for url in urls:
|
|
204
|
+
if url in self._pending:
|
|
205
|
+
pending_tasks.append(self._pending[url])
|
|
206
|
+
|
|
207
|
+
if pending_tasks:
|
|
208
|
+
try:
|
|
209
|
+
await asyncio.gather(*pending_tasks, return_exceptions=True)
|
|
210
|
+
except asyncio.CancelledError:
|
|
211
|
+
logger.debug(f"ImageCache: Batch download cancelled")
|
|
212
|
+
|
|
213
|
+
# Collect results
|
|
214
|
+
for url in urls:
|
|
215
|
+
async with self._lock:
|
|
216
|
+
cached = self._cache.get(url)
|
|
217
|
+
result[url] = cached if cached else url
|
|
218
|
+
|
|
219
|
+
return result
|
|
220
|
+
|
|
221
|
+
def get_stats(self) -> Dict[str, Any]:
|
|
222
|
+
"""Get cache statistics."""
|
|
223
|
+
cached_count = sum(1 for v in self._cache.values() if v is not None)
|
|
224
|
+
failed_count = sum(1 for v in self._cache.values() if v is None)
|
|
225
|
+
return {
|
|
226
|
+
"cached": cached_count,
|
|
227
|
+
"failed": failed_count,
|
|
228
|
+
"pending": len(self._pending),
|
|
229
|
+
"total": len(self._cache) + len(self._pending),
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
def clear(self) -> None:
|
|
233
|
+
"""Clear all cached data."""
|
|
234
|
+
self._cache.clear()
|
|
235
|
+
for task in self._pending.values():
|
|
236
|
+
task.cancel()
|
|
237
|
+
self._pending.clear()
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
# Global cache instance for reuse across requests
|
|
241
|
+
_global_cache: Optional[ImageCache] = None
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def get_image_cache() -> ImageCache:
|
|
245
|
+
"""Get or create the global image cache instance."""
|
|
246
|
+
global _global_cache
|
|
247
|
+
if _global_cache is None:
|
|
248
|
+
_global_cache = ImageCache()
|
|
249
|
+
return _global_cache
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
async def prefetch_images(urls: List[str]) -> None:
|
|
253
|
+
"""
|
|
254
|
+
Convenience function to start prefetching images.
|
|
255
|
+
|
|
256
|
+
Args:
|
|
257
|
+
urls: List of image URLs to prefetch
|
|
258
|
+
"""
|
|
259
|
+
cache = get_image_cache()
|
|
260
|
+
cache.start_prefetch(urls)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
async def get_cached_images(urls: List[str]) -> Dict[str, str]:
|
|
264
|
+
"""
|
|
265
|
+
Convenience function to get cached images.
|
|
266
|
+
|
|
267
|
+
Args:
|
|
268
|
+
urls: List of original URLs
|
|
269
|
+
|
|
270
|
+
Returns:
|
|
271
|
+
Dict mapping original URL to cached data URL
|
|
272
|
+
"""
|
|
273
|
+
cache = get_image_cache()
|
|
274
|
+
return await cache.get_all_cached(urls)
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import base64
|
|
3
|
+
import httpx
|
|
4
|
+
from typing import Dict, Any, List, Optional
|
|
5
|
+
from loguru import logger
|
|
6
|
+
from arclet.entari import MessageChain, Image
|
|
7
|
+
from typing import Tuple
|
|
8
|
+
import asyncio
|
|
9
|
+
from satori.exception import ActionFailed
|
|
10
|
+
|
|
11
|
+
def process_onebot_json(data: Dict[str, Any]) -> str:
|
|
12
|
+
"""Process OneBot JSON elements"""
|
|
13
|
+
try:
|
|
14
|
+
if "data" in data:
|
|
15
|
+
json_str = data["data"]
|
|
16
|
+
if isinstance(json_str, str):
|
|
17
|
+
json_str = json_str.replace(""", '"').replace(",", ",")
|
|
18
|
+
content = json.loads(json_str)
|
|
19
|
+
if "meta" in content and "detail_1" in content["meta"]:
|
|
20
|
+
detail = content["meta"]["detail_1"]
|
|
21
|
+
if "desc" in detail and "qqdocurl" in detail:
|
|
22
|
+
return f"[Shared Document] {detail['desc']}: {detail['qqdocurl']}"
|
|
23
|
+
except Exception as e:
|
|
24
|
+
logger.warning(f"Failed to process JSON element: {e}")
|
|
25
|
+
return ""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
async def download_image(url: str) -> bytes:
|
|
29
|
+
"""下载图片"""
|
|
30
|
+
try:
|
|
31
|
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
32
|
+
resp = await client.get(url)
|
|
33
|
+
if resp.status_code == 200:
|
|
34
|
+
return resp.content
|
|
35
|
+
else:
|
|
36
|
+
raise ActionFailed(f"下载图片失败,状态码: {resp.status_code}")
|
|
37
|
+
except Exception as e:
|
|
38
|
+
raise ActionFailed(f"下载图片失败: {url}, 错误: {str(e)}")
|
|
39
|
+
|
|
40
|
+
async def process_images(mc: MessageChain, vision_model: Optional[str] = None) -> Tuple[List[str], Optional[str]]:
|
|
41
|
+
# If vision model is explicitly set to "off", skip image processing
|
|
42
|
+
if vision_model == "off":
|
|
43
|
+
return [], None
|
|
44
|
+
|
|
45
|
+
has_images = bool(mc.get(Image))
|
|
46
|
+
images = []
|
|
47
|
+
if has_images:
|
|
48
|
+
urls = mc[Image].map(lambda x: x.src)
|
|
49
|
+
tasks = [download_image(url) for url in urls]
|
|
50
|
+
raw_images = await asyncio.gather(*tasks)
|
|
51
|
+
import base64
|
|
52
|
+
images = [base64.b64encode(img).decode('utf-8') for img in raw_images]
|
|
53
|
+
|
|
54
|
+
return images, None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def resolve_model_name(name: str, models_config: List[Dict[str, Any]]) -> Tuple[Optional[str], Optional[str]]:
|
|
58
|
+
"""
|
|
59
|
+
Resolve a user input model name to the full API model name from config.
|
|
60
|
+
Supports partial matching if unique.
|
|
61
|
+
"""
|
|
62
|
+
if not name:
|
|
63
|
+
return None, "No model name provided"
|
|
64
|
+
|
|
65
|
+
name = name.lower()
|
|
66
|
+
|
|
67
|
+
# 1. Exact match (name or id or shortname)
|
|
68
|
+
for m in models_config:
|
|
69
|
+
if m.get("name") == name or m.get("id") == name:
|
|
70
|
+
return m.get("name"), None
|
|
71
|
+
|
|
72
|
+
# 2. Key/Shortcut match
|
|
73
|
+
# Assuming the config might have keys like 'gpt4' mapping to full name
|
|
74
|
+
# But usually models list is [{'name': '...', 'provider': '...'}, ...]
|
|
75
|
+
|
|
76
|
+
# Check if 'name' matches any model 'name' partially?
|
|
77
|
+
# Or just return the name itself if it looks like a valid model ID (contains / or -)
|
|
78
|
+
if "/" in name or "-" in name or "." in name:
|
|
79
|
+
return name, None
|
|
80
|
+
|
|
81
|
+
# If not found in config specific list, and doesn't look like an ID, maybe return error
|
|
82
|
+
# But let's look for partial match in config names
|
|
83
|
+
matches = [m["name"] for m in models_config if name in m.get("name", "").lower()]
|
|
84
|
+
if len(matches) == 1:
|
|
85
|
+
return matches[0], None
|
|
86
|
+
elif len(matches) > 1:
|
|
87
|
+
return None, f"Model name '{name}' is ambiguous. Matches: {', '.join(matches[:3])}..."
|
|
88
|
+
|
|
89
|
+
# Default: assume it's a valid ID passed directly
|
|
90
|
+
return name, None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# Hardcoded markdown for refuse answer
|
|
94
|
+
REFUSE_ANSWER_MARKDOWN = """
|
|
95
|
+
<summary>
|
|
96
|
+
Instruct 专家分配此任务流程失败,请尝试提出其他问题~
|
|
97
|
+
</summary>
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
async def render_refuse_answer(
|
|
102
|
+
renderer,
|
|
103
|
+
output_path: str,
|
|
104
|
+
reason: str = "Instruct 专家分配此任务流程失败,请尝试提出其他问题~",
|
|
105
|
+
theme_color: str = "#ef4444",
|
|
106
|
+
) -> bool:
|
|
107
|
+
"""
|
|
108
|
+
Render a refuse-to-answer image using the provided reason.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
renderer: ContentRenderer instance
|
|
112
|
+
output_path: Path to save the output image
|
|
113
|
+
reason: The refusal reason to display
|
|
114
|
+
theme_color: Theme color for the card
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
True if render succeeded, False otherwise
|
|
118
|
+
"""
|
|
119
|
+
markdown = f"""
|
|
120
|
+
# 任务中止
|
|
121
|
+
|
|
122
|
+
> {reason}
|
|
123
|
+
"""
|
|
124
|
+
return await renderer.render(
|
|
125
|
+
markdown_content=markdown,
|
|
126
|
+
output_path=output_path,
|
|
127
|
+
stats={},
|
|
128
|
+
references=[],
|
|
129
|
+
page_references=[],
|
|
130
|
+
image_references=[],
|
|
131
|
+
stages_used=[],
|
|
132
|
+
image_timeout=1000,
|
|
133
|
+
theme_color=theme_color,
|
|
134
|
+
)
|
|
135
|
+
|