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.
- entari_plugin_hyw/__init__.py +371 -315
- entari_plugin_hyw/assets/card-dist/index.html +396 -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/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 +412 -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 +386 -0
- entari_plugin_hyw/card-ui/src/components/SectionCard.vue +41 -0
- entari_plugin_hyw/card-ui/src/components/StageCard.vue +237 -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 +52 -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/history.py +170 -0
- entari_plugin_hyw/image_cache.py +274 -0
- entari_plugin_hyw/misc.py +128 -0
- entari_plugin_hyw/pipeline.py +1338 -0
- entari_plugin_hyw/prompts.py +108 -0
- entari_plugin_hyw/render_vue.py +314 -0
- entari_plugin_hyw/search.py +696 -0
- entari_plugin_hyw-3.5.0rc6.dist-info/METADATA +116 -0
- entari_plugin_hyw-3.5.0rc6.dist-info/RECORD +88 -0
- entari_plugin_hyw/hyw_core.py +0 -555
- entari_plugin_hyw-2.2.5.dist-info/METADATA +0 -135
- entari_plugin_hyw-2.2.5.dist-info/RECORD +0 -6
- {entari_plugin_hyw-2.2.5.dist-info → entari_plugin_hyw-3.5.0rc6.dist-info}/WHEEL +0 -0
- {entari_plugin_hyw-2.2.5.dist-info → entari_plugin_hyw-3.5.0rc6.dist-info}/top_level.txt +0 -0
entari_plugin_hyw/__init__.py
CHANGED
|
@@ -1,353 +1,409 @@
|
|
|
1
|
-
from dataclasses import dataclass
|
|
2
|
-
import
|
|
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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
110
|
-
|
|
28
|
+
import os
|
|
29
|
+
import secrets
|
|
30
|
+
import base64
|
|
111
31
|
|
|
112
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
128
|
-
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
193
|
-
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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"
|
|
274
|
-
|
|
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
|
-
|
|
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
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
-
|
|
297
|
-
|
|
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
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
-
|
|
324
|
-
|
|
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
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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
|
-
|
|
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)
|