entari-plugin-hyw 2.2.5__py3-none-any.whl → 3.5.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.
Files changed (91) hide show
  1. entari_plugin_hyw/__init__.py +371 -315
  2. entari_plugin_hyw/assets/card-dist/index.html +396 -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/assets/icon/anthropic.svg +1 -0
  23. entari_plugin_hyw/assets/icon/cerebras.svg +9 -0
  24. entari_plugin_hyw/assets/icon/deepseek.png +0 -0
  25. entari_plugin_hyw/assets/icon/gemini.svg +1 -0
  26. entari_plugin_hyw/assets/icon/google.svg +1 -0
  27. entari_plugin_hyw/assets/icon/grok.png +0 -0
  28. entari_plugin_hyw/assets/icon/huggingface.png +0 -0
  29. entari_plugin_hyw/assets/icon/microsoft.svg +15 -0
  30. entari_plugin_hyw/assets/icon/minimax.png +0 -0
  31. entari_plugin_hyw/assets/icon/mistral.png +0 -0
  32. entari_plugin_hyw/assets/icon/nvida.png +0 -0
  33. entari_plugin_hyw/assets/icon/openai.svg +1 -0
  34. entari_plugin_hyw/assets/icon/openrouter.png +0 -0
  35. entari_plugin_hyw/assets/icon/perplexity.svg +24 -0
  36. entari_plugin_hyw/assets/icon/qwen.png +0 -0
  37. entari_plugin_hyw/assets/icon/xai.png +0 -0
  38. entari_plugin_hyw/assets/icon/xiaomi.png +0 -0
  39. entari_plugin_hyw/assets/icon/zai.png +0 -0
  40. entari_plugin_hyw/card-ui/.gitignore +24 -0
  41. entari_plugin_hyw/card-ui/README.md +5 -0
  42. entari_plugin_hyw/card-ui/index.html +16 -0
  43. entari_plugin_hyw/card-ui/package-lock.json +2342 -0
  44. entari_plugin_hyw/card-ui/package.json +31 -0
  45. entari_plugin_hyw/card-ui/public/logos/anthropic.svg +1 -0
  46. entari_plugin_hyw/card-ui/public/logos/cerebras.svg +9 -0
  47. entari_plugin_hyw/card-ui/public/logos/deepseek.png +0 -0
  48. entari_plugin_hyw/card-ui/public/logos/gemini.svg +1 -0
  49. entari_plugin_hyw/card-ui/public/logos/google.svg +1 -0
  50. entari_plugin_hyw/card-ui/public/logos/grok.png +0 -0
  51. entari_plugin_hyw/card-ui/public/logos/huggingface.png +0 -0
  52. entari_plugin_hyw/card-ui/public/logos/microsoft.svg +15 -0
  53. entari_plugin_hyw/card-ui/public/logos/minimax.png +0 -0
  54. entari_plugin_hyw/card-ui/public/logos/mistral.png +0 -0
  55. entari_plugin_hyw/card-ui/public/logos/nvida.png +0 -0
  56. entari_plugin_hyw/card-ui/public/logos/openai.svg +1 -0
  57. entari_plugin_hyw/card-ui/public/logos/openrouter.png +0 -0
  58. entari_plugin_hyw/card-ui/public/logos/perplexity.svg +24 -0
  59. entari_plugin_hyw/card-ui/public/logos/qwen.png +0 -0
  60. entari_plugin_hyw/card-ui/public/logos/xai.png +0 -0
  61. entari_plugin_hyw/card-ui/public/logos/xiaomi.png +0 -0
  62. entari_plugin_hyw/card-ui/public/logos/zai.png +0 -0
  63. entari_plugin_hyw/card-ui/public/vite.svg +1 -0
  64. entari_plugin_hyw/card-ui/src/App.vue +412 -0
  65. entari_plugin_hyw/card-ui/src/assets/vue.svg +1 -0
  66. entari_plugin_hyw/card-ui/src/components/HelloWorld.vue +41 -0
  67. entari_plugin_hyw/card-ui/src/components/MarkdownContent.vue +386 -0
  68. entari_plugin_hyw/card-ui/src/components/SectionCard.vue +41 -0
  69. entari_plugin_hyw/card-ui/src/components/StageCard.vue +237 -0
  70. entari_plugin_hyw/card-ui/src/main.ts +5 -0
  71. entari_plugin_hyw/card-ui/src/style.css +29 -0
  72. entari_plugin_hyw/card-ui/src/test_regex.js +103 -0
  73. entari_plugin_hyw/card-ui/src/types.ts +52 -0
  74. entari_plugin_hyw/card-ui/tsconfig.app.json +16 -0
  75. entari_plugin_hyw/card-ui/tsconfig.json +7 -0
  76. entari_plugin_hyw/card-ui/tsconfig.node.json +26 -0
  77. entari_plugin_hyw/card-ui/vite.config.ts +16 -0
  78. entari_plugin_hyw/history.py +170 -0
  79. entari_plugin_hyw/image_cache.py +274 -0
  80. entari_plugin_hyw/misc.py +128 -0
  81. entari_plugin_hyw/pipeline.py +1338 -0
  82. entari_plugin_hyw/prompts.py +108 -0
  83. entari_plugin_hyw/render_vue.py +314 -0
  84. entari_plugin_hyw/search.py +696 -0
  85. entari_plugin_hyw-3.5.0rc6.dist-info/METADATA +116 -0
  86. entari_plugin_hyw-3.5.0rc6.dist-info/RECORD +88 -0
  87. entari_plugin_hyw/hyw_core.py +0 -555
  88. entari_plugin_hyw-2.2.5.dist-info/METADATA +0 -135
  89. entari_plugin_hyw-2.2.5.dist-info/RECORD +0 -6
  90. {entari_plugin_hyw-2.2.5.dist-info → entari_plugin_hyw-3.5.0rc6.dist-info}/WHEEL +0 -0
  91. {entari_plugin_hyw-2.2.5.dist-info → entari_plugin_hyw-3.5.0rc6.dist-info}/top_level.txt +0 -0
@@ -1,353 +1,409 @@
1
- from dataclasses import dataclass
2
- import html
1
+ from dataclasses import dataclass, field
2
+ from importlib.metadata import version as get_version
3
+ from typing import List, Dict, Any, Optional, Union
3
4
  import time
4
- from collections import deque
5
- from typing import Any, Deque, Dict, List, Optional, Set, Text, Tuple, Union, TYPE_CHECKING, cast
6
- from typing_extensions import override
7
- from arclet.entari import metadata
8
- from arclet.entari import MessageChain, Session
9
- from arclet.entari.event.base import MessageEvent
5
+ import asyncio
6
+
7
+ # pyproject.toml 读取版本号,避免重复维护
8
+ try:
9
+ __version__ = get_version("entari_plugin_hyw")
10
+ except Exception:
11
+ __version__ = "0.0.0"
12
+
13
+ from arclet.alconna import Alconna, Args, AllParam, CommandMeta, Option, Arparma, MultiVar, store_true
14
+ from arclet.entari import metadata, listen, Session, plugin_config, BasicConfModel, plugin, command
15
+ from arclet.letoderea import on
16
+ from arclet.entari import MessageChain, Text, Image, MessageCreatedEvent, Quote, At
17
+ from satori.element import Custom
10
18
  from loguru import logger
11
- from satori.exception import ActionFailed
12
- from arclet.entari import MessageChain, Image, Quote, Text
13
19
  import arclet.letoderea as leto
14
- from arclet.entari import MessageCreatedEvent, Session
15
- from arclet.entari import BasicConfModel, metadata, plugin_config
16
- import httpx
17
- import asyncio
18
- import json
19
- import re
20
- from arclet.alconna import (
21
- Args,
22
- Alconna,
23
- AllParam,
24
- MultiVar,
25
- CommandMeta,
26
- Option,
27
- )
28
- from arclet.entari import MessageChain, Session, command
29
- from arclet.entari import plugin, Ready, Cleanup, Startup
30
- from satori.element import Custom, E
31
- from .hyw_core import HYW, HYWConfig
32
-
33
- # 全局变量
34
- hyw_core = None
35
-
36
- class HistoryManager:
37
- def __init__(self, max_records: int = 20):
38
- self.max_records = max_records
39
- self._order: Deque[str] = deque()
40
- self._store: Dict[str, List[dict]] = {}
41
- self._bindings: Dict[str, Set[str]] = {}
42
- self._msg_map: Dict[str, str] = {}
43
-
44
- def extract_message_id(self, message_like: Any) -> Optional[str]:
45
- if message_like is None:
46
- return None
47
- if isinstance(message_like, (list, tuple)):
48
- for item in message_like:
49
- mid = self.extract_message_id(item)
50
- if mid:
51
- return mid
52
- return None
53
- if isinstance(message_like, dict):
54
- for key in ("message_id", "id"):
55
- value = message_like.get(key)
56
- if value:
57
- return str(value)
58
- for attr in ("message_id", "id"):
59
- value = getattr(message_like, attr, None)
60
- if value:
61
- return str(value)
62
- nested = getattr(message_like, "message", None)
63
- if nested is not None and nested is not message_like:
64
- return self.extract_message_id(nested)
65
- return None
66
-
67
- def remove(self, conversation_id: Optional[str], *, remove_from_order: bool = True) -> None:
68
- if not conversation_id:
69
- return
70
- cid = str(conversation_id)
71
- if remove_from_order:
72
- try:
73
- self._order.remove(cid)
74
- except ValueError:
75
- pass
76
- bindings = self._bindings.pop(cid, set())
77
- for msg_id in bindings:
78
- self._msg_map.pop(msg_id, None)
79
- self._store.pop(cid, None)
80
-
81
- def _enforce_limit(self) -> None:
82
- while len(self._order) > self.max_records:
83
- obsolete = self._order.popleft()
84
- self.remove(obsolete, remove_from_order=False)
85
-
86
- def remember(self, conversation_id: Optional[str], history: Optional[List[dict]], related_ids: List[Optional[str]]) -> None:
87
- if not conversation_id or not history:
88
- return
89
- cid = str(conversation_id)
90
- self._store[cid] = list(history)
91
- binding_ids = {str(mid) for mid in related_ids if mid}
92
- self._bindings[cid] = binding_ids
93
- for mid in binding_ids:
94
- self._msg_map[mid] = cid
95
- self._order.append(cid)
96
- self._enforce_limit()
97
-
98
- def get_history(self, msg_id: str) -> Optional[List[dict]]:
99
- cid = self._msg_map.get(msg_id)
100
- if cid:
101
- return list(self._store.get(cid, []))
102
- return None
103
-
104
- def get_conversation_id(self, msg_id: str) -> Optional[str]:
105
- return self._msg_map.get(msg_id)
20
+ from arclet.entari.event.command import CommandReceive
106
21
 
107
- history_manager = HistoryManager()
22
+ from .pipeline import ProcessingPipeline
23
+ from .history import HistoryManager
24
+ from .render_vue import ContentRenderer
25
+ from .misc import process_onebot_json, process_images, resolve_model_name, render_refuse_answer, REFUSE_ANSWER_MARKDOWN
26
+ from arclet.entari.event.lifespan import Cleanup
108
27
 
109
- # Request lock for HYW agent
110
- _hyw_request_lock: Optional[asyncio.Lock] = None
28
+ import os
29
+ import secrets
30
+ import base64
111
31
 
112
- def _get_hyw_request_lock() -> asyncio.Lock:
113
- global _hyw_request_lock
114
- if _hyw_request_lock is None:
115
- _hyw_request_lock = asyncio.Lock()
116
- return _hyw_request_lock
32
+ import re
117
33
 
118
34
 
35
+ def parse_color(color: str) -> str:
36
+ """
37
+ Parse color from hex or RGB tuple to hex format.
38
+ Supports: #ff0000, ff0000, (255, 0, 0), 255,0,0
39
+ """
40
+ if not color:
41
+ return "#ef4444"
42
+
43
+ color = str(color).strip()
44
+
45
+ # Hex format: #fff or #ffffff or ffffff
46
+ if color.startswith('#') and len(color) in [4, 7]:
47
+ return color
48
+ if re.match(r'^[0-9a-fA-F]{6}$', color):
49
+ return f'#{color}'
50
+
51
+ # RGB tuple: (r, g, b) or r,g,b
52
+ rgb_match = re.match(r'^\(?(\d+)[,\s]+(\d+)[,\s]+(\d+)\)?$', color)
53
+ if rgb_match:
54
+ r, g, b = (max(0, min(255, int(x))) for x in rgb_match.groups())
55
+ return f'#{r:02x}{g:02x}{b:02x}'
56
+
57
+ logger.warning(f"Invalid color '{color}', using default #ef4444")
58
+ return "#ef4444"
59
+
60
+ class _RecentEventDeduper:
61
+ def __init__(self, ttl_seconds: float = 30.0, max_size: int = 2048):
62
+ self.ttl_seconds = ttl_seconds
63
+ self.max_size = max_size
64
+ self._seen: Dict[str, float] = {}
65
+
66
+ def seen_recently(self, key: str) -> bool:
67
+ now = time.time()
68
+ if len(self._seen) > self.max_size:
69
+ self._prune(now)
70
+ ts = self._seen.get(key)
71
+ if ts is None or now - ts > self.ttl_seconds:
72
+ self._seen[key] = now
73
+ return False
74
+ return True
75
+
76
+ def _prune(self, now: float):
77
+ expired = [k for k, ts in self._seen.items() if now - ts > self.ttl_seconds]
78
+ for k in expired:
79
+ self._seen.pop(k, None)
80
+ if len(self._seen) > self.max_size:
81
+ for k, _ in sorted(self._seen.items(), key=lambda kv: kv[1])[: len(self._seen) - self.max_size]:
82
+ self._seen.pop(k, None)
83
+
84
+ _event_deduper = _RecentEventDeduper()
85
+
86
+ @dataclass
119
87
  class HywConfig(BasicConfModel):
120
- command_name_list: Union[str, List[str]] = "hyw"
121
- model_name: str
122
- api_key: str
88
+ admins: List[str] = field(default_factory=list)
89
+ models: List[Dict[str, Any]] = field(default_factory=list)
90
+ question_command: str = "/q"
91
+ model_name: Optional[str] = None
92
+ api_key: Optional[str] = None
123
93
  base_url: str = "https://openrouter.ai/api/v1"
94
+ vision_model_name: Optional[str] = None
95
+ vision_api_key: Optional[str] = None
96
+ language: str = "Simplified Chinese"
97
+ vision_base_url: Optional[str] = None
98
+ instruct_model_name: Optional[str] = None
99
+ instruct_api_key: Optional[str] = None
100
+ instruct_base_url: Optional[str] = None
101
+ search_base_url: str = "https://lite.duckduckgo.com/lite/?q={query}"
102
+ image_search_base_url: str = "https://duckduckgo.com/?q={query}&iax=images&ia=images"
124
103
  headless: bool = False
125
104
  save_conversation: bool = False
105
+ icon: str = "openai"
106
+ render_timeout_ms: int = 6000
107
+ render_image_timeout_ms: int = 3000
108
+ extra_body: Optional[Dict[str, Any]] = None
109
+ vision_extra_body: Optional[Dict[str, Any]] = None
110
+ instruct_extra_body: Optional[Dict[str, Any]] = None
111
+ enable_browser_fallback: bool = False
112
+ reaction: bool = False
113
+ quote: bool = True
114
+ temperature: float = 0.4
115
+ # Billing configuration (price per million tokens)
116
+ input_price: Optional[float] = None # $ per 1M input tokens
117
+ output_price: Optional[float] = None # $ per 1M output tokens
118
+ # Vision model pricing overrides (defaults to main model pricing if not set)
119
+ vision_input_price: Optional[float] = None
120
+ vision_output_price: Optional[float] = None
121
+ # Instruct model pricing overrides (defaults to main model pricing if not set)
122
+ instruct_input_price: Optional[float] = None
123
+ instruct_output_price: Optional[float] = None
124
+ # Provider Names
125
+ search_name: str = "DuckDuckGo"
126
+ search_provider: str = "crawl4ai" # crawl4ai | httpx | ddgs
127
+ fetch_provider: str = "crawl4ai" # crawl4ai | jinaai
128
+ jina_api_key: Optional[str] = None # Optional API key for Jina AI
129
+ model_provider: Optional[str] = None
130
+ vision_model_provider: Optional[str] = None
131
+ instruct_model_provider: Optional[str] = None
132
+ # UI Theme
133
+ theme_color: str = "#ef4444" # Tailwind red-500, supports hex/RGB/color names
126
134
 
127
- browser_tool: str = "jina"
128
- jina_api_key: Optional[str] = None
129
-
130
- vision_model_name: Optional[str] = None
131
- vision_base_url: Optional[str] = None
132
- vision_api_key: Optional[str] = None
133
- # verbose: bool = False
134
-
135
- metadata(
136
- "hyw",
137
- author=[{"name": "kumoSleeping", "email": "zjr2992@outlook.com"}],
138
- version="0.1.0",
139
- description="",
140
- config=HywConfig,
141
- )
142
-
143
- conf = plugin_config(HywConfig)
144
- alc = Alconna(
145
- conf.command_name_list,
146
- Option("-t|--text", dest="text_only", default=False, help_text="仅文本模式(禁用图片识别)"),
147
- Args["all_param", AllParam],
148
- # Option("-v|--verbose", dest="verbose", default=False, help_text="启用详细日志输出"),
149
- meta=CommandMeta(compact=False)
150
- )
151
-
152
- # Create HYW configuration
153
- hyw_config = HYWConfig(
154
- api_key=conf.api_key,
155
- model_name=conf.model_name,
156
- base_url=conf.base_url,
157
- save_conversation=conf.save_conversation,
158
- headless=conf.headless,
159
- browser_tool=conf.browser_tool,
160
- jina_api_key=conf.jina_api_key,
161
- vision_model_name=conf.vision_model_name,
162
- vision_base_url=conf.vision_base_url,
163
- vision_api_key=conf.vision_api_key
164
- )
165
-
166
- hyw = HYW(config=hyw_config)
135
+ def __post_init__(self):
136
+ """Parse and normalize theme color after initialization."""
137
+ self.theme_color = parse_color(self.theme_color)
167
138
 
168
139
 
169
140
 
170
- # Emoji到代码的映射字典
171
- EMOJI_TO_CODE = {
172
- "🐳": "128051",
173
- "❌": "10060",
174
- "🍧": "127847",
175
- "✨": "10024",
176
- "📫": "128235"
177
- }
178
-
179
- async def download_image(url: str) -> bytes:
180
- """下载图片"""
181
- try:
182
- async with httpx.AsyncClient(timeout=30.0) as client:
183
- resp = await client.get(url)
184
- if resp.status_code == 200:
185
- return resp.content
186
- else:
187
- raise ActionFailed(f"下载图片失败,状态码: {resp.status_code}")
188
- except Exception as e:
189
- raise ActionFailed(f"下载图片失败: {url}, 错误: {str(e)}")
141
+ conf = plugin_config(HywConfig)
142
+ history_manager = HistoryManager()
143
+ renderer = ContentRenderer()
190
144
 
191
145
 
192
- def process_onebot_json(json_data_str: str) -> str:
193
- try:
194
- # 解码HTML实体
195
- json_str = html.unescape(json_data_str)
196
- return json_str
197
- except Exception as e:
198
- return json_data_str
146
+ class GlobalCache:
147
+ models_image_path: Optional[str] = None
199
148
 
149
+ global_cache = GlobalCache()
200
150
 
201
151
  async def react(session: Session, emoji: str):
152
+ if not conf.reaction: return
202
153
  try:
203
- if session.event.login.platform == "onebot":
204
- code = EMOJI_TO_CODE.get(emoji, "10024")
205
- await session.account.protocol.call_api("internal/set_group_reaction", {"group_id": int(session.guild.id), "message_id": int(session.event.message.id), "code": code, "is_add": True})
206
- else:
207
- await session.reaction_create(emoji=emoji)
208
- except ActionFailed:
209
- pass
210
-
211
- def handle_shortcut(message_chain: MessageChain) -> Tuple[bool, str]:
212
- current_msg_text = str(message_chain.get(Text)) if message_chain.get(Text) else ""
213
- is_shortcut = False
214
- shortcut_replacement = ""
215
- if current_msg_text.strip().startswith("/"):
216
- is_shortcut = True
217
- shortcut_replacement = current_msg_text.strip()[1:]
218
- return is_shortcut, shortcut_replacement
219
-
220
- async def process_images(mc: MessageChain, parse_result: Any) -> Tuple[List[str], Optional[str]]:
221
- is_text_only = False
222
- if parse_result.matched:
223
- def get_bool_value(val):
224
- if hasattr(val, 'value'):
225
- return bool(val.value)
226
- return bool(val)
227
- is_text_only = get_bool_value(getattr(parse_result, 'text_only', False))
228
-
229
- text_str = str(mc.get(Text) or "")
230
- if not is_text_only and re.search(r'(?:^|\s)(-t|--text)(?:$|\s)', text_str):
231
- is_text_only = True
232
-
233
- if is_text_only:
234
- logger.info("检测到仅文本模式参数,跳过图片分析")
235
- return [], None
236
-
237
- has_images = bool(mc.get(Image))
238
- images = []
239
- if has_images:
240
- urls = mc[Image].map(lambda x: x.src)
241
- tasks = [download_image(url) for url in urls]
242
- raw_images = await asyncio.gather(*tasks)
243
- import base64
244
- images = [base64.b64encode(img).decode('utf-8') for img in raw_images]
245
-
246
- return images, None
247
-
248
- @leto.on(MessageCreatedEvent)
249
- async def on_message_created(message_chain: MessageChain, session: Session[MessageEvent]):
250
- # Skip if no substantial content in original message
251
- original_text = str(message_chain.get(Text)).strip()
252
- has_images = bool(message_chain.get(Image))
253
- has_custom = bool(message_chain.get(Custom))
254
- if not original_text and not has_images and not has_custom:
255
- return
256
-
154
+ await session.reaction_create(emoji=emoji)
155
+ except Exception as e:
156
+ logger.warning(f"Reaction failed: {e}")
157
+
158
+ async def process_request(
159
+ session: Session[MessageCreatedEvent],
160
+ all_param: Optional[MessageChain] = None,
161
+ selected_model: Optional[str] = None,
162
+ selected_vision_model: Optional[str] = None,
163
+ conversation_key_override: Optional[str] = None,
164
+ local_mode: bool = False,
165
+ ) -> None:
166
+ logger.info(f"Processing request: {all_param}")
167
+ mc = MessageChain(all_param)
168
+ logger.info(f"reply: {session.reply}")
257
169
  if session.reply:
258
170
  try:
259
- message_chain.extend(MessageChain(" ") + session.reply.origin.message)
260
- except Exception:
261
- pass
171
+ # Check if reply is from self (the bot)
172
+ # 1. Check by Message ID (reliable for bot's own messages if recorded)
173
+ reply_msg_id = str(session.reply.origin.id) if hasattr(session.reply.origin, 'id') else None
174
+ is_bot = False
262
175
 
263
- message_chain = message_chain.get(Text) + message_chain.get(Image) + message_chain.get(Custom)
264
-
265
- quoted_message_id: Optional[str] = None
266
- conversation_history_key: Optional[str] = None
267
- conversation_history_payload: List[dict] = []
268
-
269
- if session.reply:
270
- try:
271
- quoted_message_id = str(session.reply.origin.id) if hasattr(session.reply.origin, 'id') else None
176
+ if reply_msg_id and history_manager.is_bot_message(reply_msg_id):
177
+ is_bot = True
178
+ logger.info(f"Reply target {reply_msg_id} identified as bot message via history")
179
+
180
+ if is_bot:
181
+ logger.info("Reply is from me - ignoring content")
182
+ else:
183
+ logger.info(f"Reply is from user (or unknown) - including content")
184
+ mc.extend(MessageChain(" ") + session.reply.origin.message)
272
185
  except Exception as e:
273
- logger.warning(f"提取引用消息ID失败: {e}")
274
- quoted_message_id = None
275
-
276
- if quoted_message_id:
277
- conversation_history_key = history_manager.get_conversation_id(quoted_message_id)
278
- if conversation_history_key:
279
- conversation_history_payload = history_manager.get_history(quoted_message_id) or []
280
- logger.info(f"继续对话模式触发, 引用消息ID: {quoted_message_id}, 历史长度: {len(conversation_history_payload)}")
281
-
282
- parse_result = alc.parse(message_chain)
283
- is_shortcut, shortcut_replacement = handle_shortcut(message_chain)
284
-
285
- should_process = parse_result.matched or (bool(conversation_history_key) and is_shortcut)
186
+ logger.warning(f"Failed to process reply origin: {e}")
187
+ mc.extend(MessageChain(" ") + session.reply.origin.message)
286
188
 
287
- if not should_process:
189
+ # Filter and reconstruct MessageChain
190
+ filtered_elements = mc.get(Text) + mc.get(Image) + mc.get(Custom)
191
+ mc = MessageChain(filtered_elements)
192
+ logger.info(f"mc: {mc}")
193
+
194
+ text_content = str(mc.get(Text)).strip()
195
+ # Remove HTML image tags from text content to prevent "unreasonable code behavior"
196
+ text_content = re.sub(r'<img[^>]+>', '', text_content, flags=re.IGNORECASE)
197
+
198
+ if not text_content and not mc.get(Image) and not mc.get(Custom):
288
199
  return
289
200
 
290
- raw_param_chain: MessageChain = parse_result.all_param if parse_result.matched else message_chain # type: ignore
291
- if not parse_result.matched and is_shortcut:
292
- logger.debug(f"触发快捷指令,替换内容: {shortcut_replacement}")
293
-
294
- mc = MessageChain(raw_param_chain)
201
+ # History & Context
202
+ hist_key = conversation_key_override
203
+ if not hist_key and session.reply and hasattr(session.reply.origin, 'id'):
204
+ hist_key = history_manager.get_conversation_id(str(session.reply.origin.id))
295
205
 
296
- async def process_request() -> None:
297
- await react(session, "✨")
206
+ hist_payload = history_manager.get_history(hist_key) if hist_key else []
207
+ meta = history_manager.get_metadata(hist_key) if hist_key else {}
208
+ context_id = f"guild_{session.guild.id}" if session.guild else f"user_{session.user.id}"
209
+
210
+ if conf.reaction: await react(session, "✨")
211
+
212
+ try:
213
+ msg_text = str(mc.get(Text)).strip() if mc.get(Text) else ""
214
+ msg_text = re.sub(r'<img[^>]+>', '', msg_text, flags=re.IGNORECASE)
215
+
216
+ # If message is empty but has images, use a placeholder
217
+ if not msg_text and (mc.get(Image) or mc.get(Custom)):
218
+ msg_text = "[图片]"
219
+
220
+ for custom in [e for e in mc if isinstance(e, Custom)]:
221
+ if custom.tag == 'onebot:json':
222
+ if decoded := process_onebot_json(custom.attributes()): msg_text += f"\n{decoded}"
223
+ break
224
+
225
+ # Model Selection (Step 1)
226
+ # Resolve model names from config if they are short names/keywords
227
+ model = selected_model or meta.get("model")
228
+ if model and model != "off":
229
+ resolved, err = resolve_model_name(model, conf.models)
230
+ if resolved:
231
+ model = resolved
232
+ elif err:
233
+ logger.warning(f"Model resolution warning for {model}: {err}")
234
+
235
+ vision_model = selected_vision_model or meta.get("vision_model")
236
+ if vision_model and vision_model != "off":
237
+ resolved_v, err_v = resolve_model_name(vision_model, conf.models)
238
+ if resolved_v:
239
+ vision_model = resolved_v
240
+ elif err_v:
241
+ logger.warning(f"Vision model resolution warning for {vision_model}: {err_v}")
242
+
243
+ images, err = await process_images(mc, vision_model)
244
+
245
+ # Call Pipeline directly
246
+ safe_input = msg_text
247
+ pipeline = ProcessingPipeline(conf)
298
248
  try:
299
- if is_shortcut and not parse_result.matched:
300
- msg = shortcut_replacement
301
- else:
302
- msg = mc.get(Text).strip() if mc.get(Text) else ""
303
-
304
- if mc.get(Custom): # type: ignore
305
- custom_elements = [e for e in mc if isinstance(e, Custom)]
306
- for custom in custom_elements:
307
- if custom.tag == 'onebot:json':
308
- decoded_json = process_onebot_json(custom.attributes())
309
- msg += decoded_json
310
- break
311
-
312
- time_start = time.perf_counter()
313
- images, error_msg = await process_images(mc, parse_result)
314
-
315
- if error_msg:
316
- await session.send(error_msg)
317
- return
249
+ resp = await pipeline.execute(
250
+ safe_input,
251
+ hist_payload,
252
+ model_name=model,
253
+ images=images,
254
+ selected_vision_model=vision_model,
255
+ )
256
+ finally:
257
+ await pipeline.close()
258
+
259
+ # Step 1 Results
260
+ step1_vision_model = resp.get("vision_model_used")
261
+ step1_model = resp.get("model_used")
262
+ step1_history = resp.get("conversation_history", [])
263
+ step1_stats = resp.get("stats", {})
264
+
265
+ final_resp = resp
266
+
267
+ # Step 2 (Optional)
318
268
 
319
- lock = _get_hyw_request_lock()
320
- async with lock:
321
- response = await hyw.agent(str(msg), conversation_history=conversation_history_payload, images=images)
322
269
 
323
- response_content = response.get("llm_response", "") if isinstance(response, dict) else ""
324
- new_history = response.get("conversation_history", []) if isinstance(response, dict) else []
270
+
271
+ # Extract Response Data
272
+ content = final_resp.get("llm_response", "")
273
+ structured = final_resp.get("structured_response", {})
274
+
275
+ # Render
276
+ import tempfile
277
+ with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as tf:
278
+ output_path = tf.name
279
+ model_used = final_resp.get("model_used")
280
+
281
+ # Determine session short code
282
+ if hist_key:
283
+ display_session_id = history_manager.get_code_by_key(hist_key)
284
+ if not display_session_id:
285
+ display_session_id = history_manager.generate_short_code()
286
+ else:
287
+ display_session_id = history_manager.generate_short_code()
288
+
289
+ # Use stats_list if available, otherwise standard stats
290
+ stats_to_render = final_resp.get("stats_list", final_resp.get("stats", {}))
291
+
292
+ # Check if refuse_answer was triggered
293
+ if final_resp.get("refuse_answer"):
294
+ logger.info(f"Refuse answer triggered. Rendering refuse image. Reason: {final_resp.get('refuse_reason', '')}")
295
+ render_ok = await render_refuse_answer(
296
+ renderer=renderer,
297
+ output_path=output_path,
298
+ theme_color=conf.theme_color,
299
+ )
300
+ else:
301
+ render_ok = await renderer.render(
302
+ markdown_content=content,
303
+ output_path=output_path,
304
+ stats=stats_to_render,
305
+ references=structured.get("references", []),
306
+ page_references=structured.get("page_references", []),
307
+ image_references=structured.get("image_references", []),
308
+ stages_used=final_resp.get("stages_used", []),
309
+ image_timeout=conf.render_image_timeout_ms,
310
+ theme_color=conf.theme_color,
311
+ )
312
+
313
+ # Send & Save
314
+ if not render_ok:
315
+ logger.error("Render failed; skipping reply. Check Crawl4AI rendering status.")
316
+ if os.path.exists(output_path):
317
+ try:
318
+ os.remove(output_path)
319
+ except Exception as exc:
320
+ logger.warning(f"Failed to delete render output {output_path}: {exc}")
321
+ sent = None
322
+ else:
323
+ # Convert to base64
324
+ with open(output_path, "rb") as f:
325
+ img_data = base64.b64encode(f.read()).decode()
326
+
327
+ # Build single reply chain (image only now)
328
+ elements = []
329
+ elements.append(Image(src=f'data:image/png;base64,{img_data}'))
330
+
331
+ msg_chain = MessageChain(*elements)
325
332
 
333
+ if conf.quote:
334
+ msg_chain = MessageChain(Quote(session.event.message.id)) + msg_chain
335
+
336
+ # Use reply_to instead of manual Quote insertion to avoid ActionFailed errors
337
+ sent = await session.send(msg_chain)
338
+
339
+ sent_id = next((str(e.id) for e in sent if hasattr(e, 'id')), None) if sent else None
340
+ msg_id = str(session.event.message.id) if hasattr(session.event, 'message') else str(session.event.id)
341
+ related = [msg_id] + ([str(session.reply.origin.id)] if session.reply and hasattr(session.reply.origin, 'id') else [])
342
+
343
+ history_manager.remember(
344
+ sent_id,
345
+ final_resp.get("conversation_history", []),
346
+ related,
347
+ {
348
+ "model": model_used,
349
+ "trace_markdown": final_resp.get("trace_markdown"),
350
+ },
351
+ context_id,
352
+ code=display_session_id,
353
+ )
354
+
355
+ if conf.save_conversation and sent_id:
356
+ history_manager.save_to_disk(sent_id)
357
+
358
+
359
+ except Exception as e:
360
+ logger.exception(f"Error: {e}")
361
+ err_msg = f"Error: {e}"
362
+ if conf.quote:
363
+ await session.send([Quote(session.event.message.id), err_msg])
364
+ else:
365
+ await session.send(err_msg)
366
+
367
+ # Save conversation on error if response was generated
368
+ if 'resp' in locals() and resp and conf.save_conversation:
326
369
  try:
327
- send_result = await session.send([Quote(session.event.message.id), response_content])
328
- except ActionFailed as e:
329
- if "9057" in str(e):
330
- logger.warning(f"发送消息失败(9057),尝试截断发送: {e}")
331
- truncated_content = response_content[:1000] + "\n\n[...内容过长,已大幅截断...]"
332
- send_result = await session.send([Quote(session.event.message.id), truncated_content])
333
- else:
334
- raise e
335
-
336
- sent_message_id = history_manager.extract_message_id(send_result)
337
- current_user_message_id = str(session.event.message.id)
338
- related_ids: List[Optional[str]] = [current_user_message_id, sent_message_id]
339
-
340
- if conversation_history_key:
341
- history_manager.remove(conversation_history_key)
342
- related_ids.append(quoted_message_id)
343
-
344
- history_manager.remember(sent_message_id, new_history, related_ids)
345
-
346
- except Exception as exc:
347
- await react(session, "❌")
348
- logger.exception("处理HYW消息失败: {}", exc)
370
+ # Use a temporary ID for error cases
371
+ error_id = f"error_{int(time.time())}_{secrets.token_hex(4)}"
372
+ history_manager.remember(error_id, resp.get("conversation_history", []), [], {"model": model_used if 'model_used' in locals() else "unknown", "error": str(e)}, context_id, code=display_session_id if 'display_session_id' in locals() else None)
373
+ history_manager.save_to_disk(error_id)
374
+ logger.info(f"Saved error conversation to {error_id}")
375
+ except Exception as save_err:
376
+ logger.error(f"Failed to save error conversation: {save_err}")
377
+
378
+
379
+ alc = Alconna(
380
+ conf.question_command,
381
+ Args["all_param;?", AllParam],
382
+ )
383
+
384
+ @command.on(alc)
385
+ async def handle_question_command(session: Session[MessageCreatedEvent], result: Arparma):
386
+ """Handle main Question command"""
387
+ try:
388
+ logger.info(f"Question Command Triggered. Message: {result}")
389
+ mid = str(session.event.message.id) if getattr(session.event, "message", None) else str(session.event.id)
390
+ dedupe_key = f"{getattr(session.account, 'id', 'account')}:{mid}"
391
+ if _event_deduper.seen_recently(dedupe_key):
392
+ logger.warning(f"Duplicate command event ignored: {dedupe_key}")
393
+ return
394
+ except Exception:
395
+ pass
396
+
397
+ logger.info(f"Question Command Triggered. Message: {session.event.message}")
398
+
399
+ args = result.all_matched_args
400
+ logger.info(f"Matched Args: {args}")
401
+
402
+ await process_request(session, args.get("all_param"), selected_model=None, selected_vision_model=None, conversation_key_override=None, local_mode=False)
349
403
 
350
- asyncio.create_task(process_request())
351
- return
404
+ metadata("hyw", author=[{"name": "kumoSleeping", "email": "zjr2992@outlook.com"}], version=__version__, config=HywConfig)
352
405
 
353
406
 
407
+ @listen(CommandReceive)
408
+ async def remove_at(content: MessageChain):
409
+ return content.lstrip(At)