entari-plugin-hyw 4.0.0rc10__py3-none-any.whl → 4.0.0rc12__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 +191 -140
- entari_plugin_hyw/filters.py +83 -0
- entari_plugin_hyw/misc.py +42 -0
- {entari_plugin_hyw-4.0.0rc10.dist-info → entari_plugin_hyw-4.0.0rc12.dist-info}/METADATA +1 -1
- {entari_plugin_hyw-4.0.0rc10.dist-info → entari_plugin_hyw-4.0.0rc12.dist-info}/RECORD +14 -12
- hyw_core/agent.py +648 -0
- hyw_core/browser_control/service.py +283 -130
- hyw_core/core.py +148 -8
- hyw_core/crawling/completeness.py +99 -10
- hyw_core/definitions.py +70 -52
- hyw_core/search.py +10 -0
- hyw_core/stages/summary.py +1 -3
- {entari_plugin_hyw-4.0.0rc10.dist-info → entari_plugin_hyw-4.0.0rc12.dist-info}/WHEEL +0 -0
- {entari_plugin_hyw-4.0.0rc10.dist-info → entari_plugin_hyw-4.0.0rc12.dist-info}/top_level.txt +0 -0
entari_plugin_hyw/__init__.py
CHANGED
|
@@ -7,10 +7,8 @@ Use large language models to interpret chat messages.
|
|
|
7
7
|
from dataclasses import dataclass, field
|
|
8
8
|
from importlib.metadata import version as get_version
|
|
9
9
|
from typing import List, Dict, Any, Optional
|
|
10
|
-
import time
|
|
11
10
|
import asyncio
|
|
12
11
|
import os
|
|
13
|
-
import secrets
|
|
14
12
|
import base64
|
|
15
13
|
import re
|
|
16
14
|
import tempfile
|
|
@@ -41,119 +39,21 @@ from .misc import (
|
|
|
41
39
|
resolve_model_name,
|
|
42
40
|
render_refuse_answer,
|
|
43
41
|
render_image_unsupported,
|
|
42
|
+
parse_color,
|
|
43
|
+
RecentEventDeduper,
|
|
44
44
|
)
|
|
45
|
+
from .filters import parse_filter_syntax
|
|
45
46
|
from .search_cache import SearchResultCache, parse_single_index, parse_multi_indices
|
|
46
47
|
|
|
47
48
|
|
|
48
|
-
def parse_filter_syntax(query: str, max_count: int = 3):
|
|
49
|
-
"""
|
|
50
|
-
Parse enhanced filter syntax supporting:
|
|
51
|
-
- Chinese/English colons (: :) and commas (, ,)
|
|
52
|
-
- Multiple filters: "mcmod=2, github=1 : xxx"
|
|
53
|
-
- Index lists: "1, 2, 3 : xxx"
|
|
54
|
-
- Max total selections
|
|
55
|
-
|
|
56
|
-
Returns:
|
|
57
|
-
(filters, search_query, error_msg)
|
|
58
|
-
filters: list of (filter_type, filter_value, count) tuples
|
|
59
|
-
filter_type: 'index' or 'link'
|
|
60
|
-
count: how many to get (default 1)
|
|
61
|
-
search_query: the actual search query
|
|
62
|
-
error_msg: error message if exceeded max
|
|
63
|
-
"""
|
|
64
|
-
if not query:
|
|
65
|
-
return [], query, None
|
|
66
|
-
|
|
67
|
-
# Normalize Chinese punctuation to English
|
|
68
|
-
normalized = query.replace(':', ':').replace(',', ',').replace('、', ',')
|
|
69
|
-
|
|
70
|
-
# Handle escaped colons: \: or /: -> placeholder
|
|
71
|
-
normalized = re.sub(r'[/\\]:', '\x00COLON\x00', normalized)
|
|
72
|
-
|
|
73
|
-
# Split by colon - last part is the search query
|
|
74
|
-
parts = normalized.split(':')
|
|
75
|
-
if len(parts) < 2:
|
|
76
|
-
# No colon found, restore escaped colons and return as-is
|
|
77
|
-
return [], query.replace('\\:', ':').replace('/:', ':'), None
|
|
78
|
-
|
|
79
|
-
# Everything after the last colon is the search query
|
|
80
|
-
search_query = parts[-1].strip().replace('\x00COLON\x00', ':')
|
|
81
|
-
|
|
82
|
-
# Everything before is the filter specification
|
|
83
|
-
filter_spec = ':'.join(parts[:-1]).strip().replace('\x00COLON\x00', ':')
|
|
84
|
-
|
|
85
|
-
if not filter_spec or not search_query:
|
|
86
|
-
return [], query.replace('\\:', ':').replace('/:', ':'), None
|
|
87
|
-
|
|
88
|
-
# Parse filter specifications (comma-separated)
|
|
89
|
-
filter_items = [f.strip() for f in filter_spec.split(',') if f.strip()]
|
|
90
|
-
|
|
91
|
-
filters = []
|
|
92
|
-
for item in filter_items:
|
|
93
|
-
# Check for "filter=count" pattern (e.g., "mcmod=2")
|
|
94
|
-
eq_match = re.match(r'^(\w+)\s*=\s*(\d+)$', item)
|
|
95
|
-
if eq_match:
|
|
96
|
-
filter_name = eq_match.group(1).lower()
|
|
97
|
-
count = int(eq_match.group(2))
|
|
98
|
-
filters.append(('link', filter_name, count))
|
|
99
|
-
elif item.isdigit():
|
|
100
|
-
# Pure index
|
|
101
|
-
filters.append(('index', int(item), 1))
|
|
102
|
-
else:
|
|
103
|
-
# Filter name without count (default count=1)
|
|
104
|
-
filters.append(('link', item.lower(), 1))
|
|
105
|
-
|
|
106
|
-
# Calculate total count
|
|
107
|
-
total = sum(f[2] for f in filters)
|
|
108
|
-
if total > max_count:
|
|
109
|
-
return [], search_query, f"最多选择{max_count}个结果 (当前选择了{total}个)"
|
|
110
|
-
|
|
111
|
-
return filters, search_query, None
|
|
112
|
-
|
|
113
|
-
|
|
114
49
|
try:
|
|
115
50
|
__version__ = get_version("entari_plugin_hyw")
|
|
116
51
|
except Exception:
|
|
117
52
|
__version__ = "4.0.0-rc8"
|
|
118
53
|
|
|
119
54
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
return "#ef4444"
|
|
123
|
-
color = str(color).strip()
|
|
124
|
-
if color.startswith('#') and len(color) in [4, 7]:
|
|
125
|
-
return color
|
|
126
|
-
if re.match(r'^[0-9a-fA-F]{6}$', color):
|
|
127
|
-
return f'#{color}'
|
|
128
|
-
rgb_match = re.match(r'^\(?(\d+)[,\s]+(\d+)[,\s]+(\d+)\)?$', color)
|
|
129
|
-
if rgb_match:
|
|
130
|
-
r, g, b = (max(0, min(255, int(x))) for x in rgb_match.groups())
|
|
131
|
-
return f'#{r:02x}{g:02x}{b:02x}'
|
|
132
|
-
return "#ef4444"
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
class _RecentEventDeduper:
|
|
136
|
-
def __init__(self, ttl_seconds: float = 30.0, max_size: int = 2048):
|
|
137
|
-
self.ttl_seconds = ttl_seconds
|
|
138
|
-
self.max_size = max_size
|
|
139
|
-
self._seen: Dict[str, float] = {}
|
|
140
|
-
|
|
141
|
-
def seen_recently(self, key: str) -> bool:
|
|
142
|
-
now = time.time()
|
|
143
|
-
if len(self._seen) > self.max_size:
|
|
144
|
-
self._prune(now)
|
|
145
|
-
ts = self._seen.get(key)
|
|
146
|
-
if ts is None or now - ts > self.ttl_seconds:
|
|
147
|
-
self._seen[key] = now
|
|
148
|
-
return False
|
|
149
|
-
return True
|
|
150
|
-
|
|
151
|
-
def _prune(self, now: float):
|
|
152
|
-
expired = [k for k, ts in self._seen.items() if now - ts > self.ttl_seconds]
|
|
153
|
-
for k in expired:
|
|
154
|
-
self._seen.pop(k, None)
|
|
155
|
-
|
|
156
|
-
_event_deduper = _RecentEventDeduper()
|
|
55
|
+
_event_deduper = RecentEventDeduper()
|
|
56
|
+
|
|
157
57
|
|
|
158
58
|
|
|
159
59
|
@dataclass
|
|
@@ -163,6 +63,7 @@ class HywConfig(BasicConfModel):
|
|
|
163
63
|
models: List[Dict[str, Any]] = field(default_factory=list)
|
|
164
64
|
question_command: str = "/q"
|
|
165
65
|
web_command: str = "/w"
|
|
66
|
+
help_command: str = "/h"
|
|
166
67
|
language: str = "Simplified Chinese"
|
|
167
68
|
temperature: float = 0.4
|
|
168
69
|
|
|
@@ -212,12 +113,11 @@ renderer = ContentRenderer(headless=conf.headless)
|
|
|
212
113
|
set_global_renderer(renderer)
|
|
213
114
|
search_cache = SearchResultCache(ttl_seconds=600.0) # 10 minutes
|
|
214
115
|
|
|
215
|
-
|
|
116
|
+
# Initialize HywCore immediately at plugin load time (not lazy)
|
|
117
|
+
# This avoids the 2s delay on first user request caused by AsyncOpenAI client creation
|
|
118
|
+
_hyw_core: HywCore = HywCore(conf.to_hyw_core_config())
|
|
216
119
|
|
|
217
120
|
def get_hyw_core() -> HywCore:
|
|
218
|
-
global _hyw_core
|
|
219
|
-
if _hyw_core is None:
|
|
220
|
-
_hyw_core = HywCore(conf.to_hyw_core_config())
|
|
221
121
|
return _hyw_core
|
|
222
122
|
|
|
223
123
|
|
|
@@ -320,9 +220,9 @@ async def process_request(
|
|
|
320
220
|
output_path = tf.name
|
|
321
221
|
|
|
322
222
|
core = get_hyw_core()
|
|
323
|
-
#
|
|
324
|
-
#
|
|
325
|
-
response = await core.
|
|
223
|
+
# Use agent mode with tool-calling capability
|
|
224
|
+
# Agent can autonomously call web_tool up to 2 times, with IM notifications
|
|
225
|
+
response = await core.query_agent(request, output_path=None)
|
|
326
226
|
|
|
327
227
|
# 2. Get the warmed-up tab
|
|
328
228
|
try:
|
|
@@ -447,7 +347,6 @@ async def handle_question_command(session: Session[MessageCreatedEvent], result:
|
|
|
447
347
|
# Check if too many indices requested (parse_multi_indices returns None if > max_count)
|
|
448
348
|
if query_text and indices is None:
|
|
449
349
|
# Check if it looks like indices but exceeded limit
|
|
450
|
-
import re
|
|
451
350
|
if re.match(r'^[\d,、\s\-–]+$', query_text):
|
|
452
351
|
await session.send("最多选择3个结果进行总结")
|
|
453
352
|
search_cache.cleanup()
|
|
@@ -545,8 +444,91 @@ async def handle_question_command(session: Session[MessageCreatedEvent], result:
|
|
|
545
444
|
search_cache.cleanup()
|
|
546
445
|
return
|
|
547
446
|
|
|
447
|
+
# === URL Mode: Direct URL Screenshot + Summarize ===
|
|
448
|
+
# Detect URL in query and handle directly without Agent
|
|
449
|
+
url_match = re.search(r'https?://\S+', query_text)
|
|
450
|
+
if url_match:
|
|
451
|
+
url = url_match.group(0)
|
|
452
|
+
# Extract user intent (text before/after URL)
|
|
453
|
+
user_intent = query_text.replace(url, '').strip()
|
|
454
|
+
if not user_intent:
|
|
455
|
+
user_intent = "总结这个页面的内容"
|
|
456
|
+
|
|
457
|
+
if conf.reaction:
|
|
458
|
+
asyncio.create_task(react(session, "✨"))
|
|
459
|
+
|
|
460
|
+
core = get_hyw_core()
|
|
461
|
+
local_renderer = await get_content_renderer()
|
|
462
|
+
|
|
463
|
+
# Run screenshot and prepare tab in parallel
|
|
464
|
+
screenshot_task = asyncio.create_task(core.screenshot(url))
|
|
465
|
+
tab_task = asyncio.create_task(local_renderer.prepare_tab())
|
|
466
|
+
|
|
467
|
+
# Send notification
|
|
468
|
+
short_url = url[:40] + "..." if len(url) > 40 else url
|
|
469
|
+
await session.send(f"📸 正在截图: {short_url}")
|
|
470
|
+
|
|
471
|
+
b64_img = await screenshot_task
|
|
472
|
+
|
|
473
|
+
if not b64_img:
|
|
474
|
+
try: await tab_task
|
|
475
|
+
except: pass
|
|
476
|
+
await session.send(f"❌ 截图失败: {url}")
|
|
477
|
+
return
|
|
478
|
+
|
|
479
|
+
# Summarize with screenshot
|
|
480
|
+
request = QueryRequest(
|
|
481
|
+
user_input=f"{user_intent}\n\nURL: {url}",
|
|
482
|
+
images=[b64_img],
|
|
483
|
+
conversation_history=[],
|
|
484
|
+
model_name=None,
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as tf:
|
|
488
|
+
output_path = tf.name
|
|
489
|
+
|
|
490
|
+
response = await core.query(request, output_path=None)
|
|
491
|
+
|
|
492
|
+
try:
|
|
493
|
+
tab_id = await tab_task
|
|
494
|
+
except Exception:
|
|
495
|
+
tab_id = None
|
|
496
|
+
|
|
497
|
+
if response.success and response.content:
|
|
498
|
+
render_ok = await core.render(
|
|
499
|
+
markdown_content=response.content,
|
|
500
|
+
output_path=output_path,
|
|
501
|
+
stats={"total_time": response.total_time},
|
|
502
|
+
references=[],
|
|
503
|
+
page_references=[{"url": url, "title": "截图页面", "raw_screenshot_b64": b64_img}],
|
|
504
|
+
tab_id=tab_id
|
|
505
|
+
)
|
|
506
|
+
|
|
507
|
+
if render_ok and os.path.exists(output_path):
|
|
508
|
+
with open(output_path, "rb") as f:
|
|
509
|
+
img_data = base64.b64encode(f.read()).decode()
|
|
510
|
+
|
|
511
|
+
msg_chain = MessageChain(Image(src=f'data:image/png;base64,{img_data}'))
|
|
512
|
+
if conf.quote:
|
|
513
|
+
msg_chain = MessageChain(Quote(session.event.message.id)) + msg_chain
|
|
514
|
+
|
|
515
|
+
await session.send(msg_chain)
|
|
516
|
+
os.remove(output_path)
|
|
517
|
+
else:
|
|
518
|
+
await session.send(response.content[:500])
|
|
519
|
+
else:
|
|
520
|
+
await session.send(f"总结失败: {response.error or 'Unknown error'}")
|
|
521
|
+
|
|
522
|
+
return
|
|
523
|
+
|
|
548
524
|
# === Filter Mode: Search + Find matching links + Summarize ===
|
|
549
|
-
|
|
525
|
+
# Only trigger filter syntax for short queries (≤20 chars, excluding URLs), otherwise use Agent
|
|
526
|
+
filters = []
|
|
527
|
+
filter_error = None
|
|
528
|
+
# Calculate length excluding URLs
|
|
529
|
+
query_without_urls = re.sub(r'https?://\S+', '', query_text).strip()
|
|
530
|
+
if len(query_without_urls) <= 20:
|
|
531
|
+
filters, search_query, filter_error = parse_filter_syntax(query_text, max_count=3)
|
|
550
532
|
|
|
551
533
|
if filter_error:
|
|
552
534
|
await session.send(filter_error)
|
|
@@ -559,6 +541,10 @@ async def handle_question_command(session: Session[MessageCreatedEvent], result:
|
|
|
559
541
|
core = get_hyw_core()
|
|
560
542
|
local_renderer = await get_content_renderer()
|
|
561
543
|
|
|
544
|
+
# Send pre-notification BEFORE search
|
|
545
|
+
filter_desc = ", ".join([f[1] if f[0] != 'index' else f"第{f[1]}个" for f in filters])
|
|
546
|
+
await session.send(f"🔍 正在搜索 \"{search_query}\" 并匹配 [{filter_desc}]...")
|
|
547
|
+
|
|
562
548
|
# Run search and prepare tab in parallel
|
|
563
549
|
search_task = asyncio.create_task(core.search([search_query]))
|
|
564
550
|
tab_task = asyncio.create_task(local_renderer.prepare_tab())
|
|
@@ -586,13 +572,15 @@ async def handle_question_command(session: Session[MessageCreatedEvent], result:
|
|
|
586
572
|
else:
|
|
587
573
|
try: await tab_task
|
|
588
574
|
except: pass
|
|
589
|
-
await session.send(f"序号 {filter_value} 超出范围 (1-{len(visible)})")
|
|
575
|
+
await session.send(f"⚠️ 序号 {filter_value} 超出范围 (1-{len(visible)})")
|
|
590
576
|
return
|
|
591
577
|
else:
|
|
592
578
|
found_count = 0
|
|
593
579
|
for res in visible:
|
|
594
580
|
url = res.get("url", "")
|
|
595
|
-
|
|
581
|
+
title = res.get("title", "")
|
|
582
|
+
# Match filter against both URL and title
|
|
583
|
+
if (filter_value in url.lower() or filter_value in title.lower()) and url not in urls_to_screenshot:
|
|
596
584
|
urls_to_screenshot.append(url)
|
|
597
585
|
found_count += 1
|
|
598
586
|
if found_count >= count:
|
|
@@ -601,32 +589,54 @@ async def handle_question_command(session: Session[MessageCreatedEvent], result:
|
|
|
601
589
|
if found_count == 0:
|
|
602
590
|
try: await tab_task
|
|
603
591
|
except: pass
|
|
604
|
-
await session.send(f"未找到包含 \"{filter_value}\" 的链接")
|
|
592
|
+
await session.send(f"⚠️ 未找到包含 \"{filter_value}\" 的链接")
|
|
605
593
|
return
|
|
606
594
|
|
|
607
595
|
if not urls_to_screenshot:
|
|
608
596
|
try: await tab_task
|
|
609
597
|
except: pass
|
|
610
|
-
await session.send("未找到匹配的链接")
|
|
598
|
+
await session.send("⚠️ 未找到匹配的链接")
|
|
611
599
|
return
|
|
612
600
|
|
|
613
|
-
# Take screenshots
|
|
614
|
-
screenshot_tasks = [core.
|
|
601
|
+
# Take screenshots with content extraction
|
|
602
|
+
screenshot_tasks = [core.screenshot_with_content(url) for url in urls_to_screenshot]
|
|
615
603
|
screenshot_results = await asyncio.gather(*screenshot_tasks)
|
|
616
|
-
screenshots = [b64 for b64 in screenshot_results if b64]
|
|
617
604
|
|
|
618
|
-
|
|
605
|
+
# Build page references for rendering (with screenshots)
|
|
606
|
+
# and collect text content for LLM
|
|
607
|
+
page_references = []
|
|
608
|
+
text_contents = []
|
|
609
|
+
successful_count = 0
|
|
610
|
+
|
|
611
|
+
for url, result in zip(urls_to_screenshot, screenshot_results):
|
|
612
|
+
if isinstance(result, dict) and result.get("screenshot_b64"):
|
|
613
|
+
successful_count += 1
|
|
614
|
+
page_references.append({
|
|
615
|
+
"url": url,
|
|
616
|
+
"title": result.get("title", "截图页面"),
|
|
617
|
+
"raw_screenshot_b64": result.get("screenshot_b64"),
|
|
618
|
+
})
|
|
619
|
+
# Collect text for LLM
|
|
620
|
+
content = result.get("content", "")
|
|
621
|
+
if content:
|
|
622
|
+
text_contents.append(f"## 来源: {result.get('title', url)}\n\n{content}")
|
|
623
|
+
|
|
624
|
+
if not page_references:
|
|
619
625
|
try: await tab_task
|
|
620
626
|
except: pass
|
|
621
627
|
await session.send("无法截图页面")
|
|
622
628
|
return
|
|
623
629
|
|
|
624
|
-
#
|
|
625
|
-
|
|
630
|
+
# Send result notification
|
|
631
|
+
await session.send(f"🔍 搜索 \"{search_query}\" 并截图 {successful_count} 个匹配结果")
|
|
632
|
+
|
|
633
|
+
# Pass TEXT content to LLM for summarization (not images)
|
|
634
|
+
combined_content = "\n\n---\n\n".join(text_contents) if text_contents else "无法提取网页内容"
|
|
635
|
+
user_query = f"总结关于 \"{search_query}\" 的内容。\n\n网页内容:\n{combined_content[:8000]}"
|
|
626
636
|
|
|
627
637
|
request = QueryRequest(
|
|
628
638
|
user_input=user_query,
|
|
629
|
-
images=
|
|
639
|
+
images=[], # No images, use text content instead
|
|
630
640
|
conversation_history=[],
|
|
631
641
|
model_name=None,
|
|
632
642
|
)
|
|
@@ -647,7 +657,7 @@ async def handle_question_command(session: Session[MessageCreatedEvent], result:
|
|
|
647
657
|
output_path=output_path,
|
|
648
658
|
stats={"total_time": response.total_time},
|
|
649
659
|
references=[],
|
|
650
|
-
page_references=
|
|
660
|
+
page_references=page_references, # Pass screenshots for rendering
|
|
651
661
|
tab_id=tab_id
|
|
652
662
|
)
|
|
653
663
|
|
|
@@ -799,10 +809,14 @@ async def handle_web_command(session: Session[MessageCreatedEvent], result: Arpa
|
|
|
799
809
|
await session.send(filter_error)
|
|
800
810
|
return
|
|
801
811
|
|
|
802
|
-
# Start search
|
|
812
|
+
# Start search first
|
|
803
813
|
local_renderer = await get_content_renderer()
|
|
804
814
|
search_task = asyncio.create_task(core.search([search_query]))
|
|
805
|
-
|
|
815
|
+
|
|
816
|
+
# Only pre-warm tab if NOT in filter mode (filter mode = screenshots only, no card render)
|
|
817
|
+
tab_task = None
|
|
818
|
+
if not filters:
|
|
819
|
+
tab_task = asyncio.create_task(local_renderer.prepare_tab())
|
|
806
820
|
|
|
807
821
|
if conf.reaction:
|
|
808
822
|
asyncio.create_task(react(session, "🔍"))
|
|
@@ -811,24 +825,23 @@ async def handle_web_command(session: Session[MessageCreatedEvent], result: Arpa
|
|
|
811
825
|
flat_results = results[0] if results else []
|
|
812
826
|
|
|
813
827
|
if not flat_results:
|
|
814
|
-
|
|
815
|
-
|
|
828
|
+
if tab_task:
|
|
829
|
+
try: await tab_task
|
|
830
|
+
except: pass
|
|
816
831
|
await session.send("Search returned no results.")
|
|
817
832
|
return
|
|
818
833
|
|
|
819
834
|
visible = [r for r in flat_results if not r.get("_hidden", False)]
|
|
820
835
|
|
|
821
836
|
if not visible:
|
|
822
|
-
|
|
823
|
-
|
|
837
|
+
if tab_task:
|
|
838
|
+
try: await tab_task
|
|
839
|
+
except: pass
|
|
824
840
|
await session.send("Search returned no visible results.")
|
|
825
841
|
return
|
|
826
842
|
|
|
827
|
-
# === Filter Mode: Screenshot matching links ===
|
|
843
|
+
# === Filter Mode: Screenshot matching links (NO tab needed) ===
|
|
828
844
|
if filters:
|
|
829
|
-
# No need for tab in filter/screenshot mode, cancel it
|
|
830
|
-
try: await tab_task
|
|
831
|
-
except: pass
|
|
832
845
|
|
|
833
846
|
urls_to_screenshot = []
|
|
834
847
|
|
|
@@ -841,25 +854,27 @@ async def handle_web_command(session: Session[MessageCreatedEvent], result: Arpa
|
|
|
841
854
|
if url and url not in urls_to_screenshot:
|
|
842
855
|
urls_to_screenshot.append(url)
|
|
843
856
|
else:
|
|
844
|
-
await session.send(f"序号 {filter_value} 超出范围 (1-{len(visible)})")
|
|
857
|
+
await session.send(f"⚠️ 序号 {filter_value} 超出范围 (1-{len(visible)})")
|
|
845
858
|
return
|
|
846
859
|
else:
|
|
847
860
|
# Link filter: find URLs containing filter term
|
|
848
861
|
found_count = 0
|
|
849
862
|
for res in visible:
|
|
850
863
|
url = res.get("url", "")
|
|
851
|
-
|
|
864
|
+
title = res.get("title", "")
|
|
865
|
+
# Match filter against both URL and title
|
|
866
|
+
if (filter_value in url.lower() or filter_value in title.lower()) and url not in urls_to_screenshot:
|
|
852
867
|
urls_to_screenshot.append(url)
|
|
853
868
|
found_count += 1
|
|
854
869
|
if found_count >= count:
|
|
855
870
|
break
|
|
856
871
|
|
|
857
872
|
if found_count == 0:
|
|
858
|
-
await session.send(f"未找到包含 \"{filter_value}\" 的链接")
|
|
873
|
+
await session.send(f"⚠️ 未找到包含 \"{filter_value}\" 的链接")
|
|
859
874
|
return
|
|
860
875
|
|
|
861
876
|
if not urls_to_screenshot:
|
|
862
|
-
await session.send("未找到匹配的链接")
|
|
877
|
+
await session.send("⚠️ 未找到匹配的链接")
|
|
863
878
|
return
|
|
864
879
|
|
|
865
880
|
if conf.reaction:
|
|
@@ -949,6 +964,42 @@ async def handle_web_command(session: Session[MessageCreatedEvent], result: Arpa
|
|
|
949
964
|
|
|
950
965
|
metadata("hyw", author=[{"name": "kumoSleeping", "email": "zjr2992@outlook.com"}], version=__version__, config=HywConfig)
|
|
951
966
|
|
|
967
|
+
# Help command (/h)
|
|
968
|
+
alc_help = Alconna(conf.help_command)
|
|
969
|
+
|
|
970
|
+
@command.on(alc_help)
|
|
971
|
+
async def handle_help_command(session: Session[MessageCreatedEvent], result: Arparma):
|
|
972
|
+
"""Display help information for all commands."""
|
|
973
|
+
help_text = f"""HYW Plugin v{__version__}
|
|
974
|
+
|
|
975
|
+
Question Agent:
|
|
976
|
+
• {conf.question_command} tell me...
|
|
977
|
+
• {conf.question_command} [picture] tell me...
|
|
978
|
+
• [quote] {conf.question_command} tell me...
|
|
979
|
+
Question Filter:
|
|
980
|
+
• {conf.question_command} github: fastapi
|
|
981
|
+
• {conf.question_command} 1,2: minecraft
|
|
982
|
+
• {conf.question_command} mcmod=2: forge mod
|
|
983
|
+
Question Context:
|
|
984
|
+
• [quote: question] + {conf.question_command} tell me more...
|
|
985
|
+
Web_tool Search:
|
|
986
|
+
• {conf.web_command} query
|
|
987
|
+
Web_tool Screenshot:
|
|
988
|
+
• {conf.web_command} https://example.com
|
|
989
|
+
Web_tool Filter(search and screenshot):
|
|
990
|
+
• {conf.web_command} github: fastapi
|
|
991
|
+
• {conf.web_command} 1,2: minecraft
|
|
992
|
+
• {conf.web_command} mcmod=2: forge mod
|
|
993
|
+
Web_tool Context(screenshot):
|
|
994
|
+
• [quote: web_tool search] + {conf.web_command} 1
|
|
995
|
+
• [quote: web_tool search] + {conf.web_command} 1, 3
|
|
996
|
+
Web_tool Context(question):
|
|
997
|
+
• [quote: web_tool screenshot] + {conf.question_command} tell me...
|
|
998
|
+
• [quote: web_tool search] + {conf.question_command} tell me...
|
|
999
|
+
"""
|
|
1000
|
+
|
|
1001
|
+
await session.send(help_text)
|
|
1002
|
+
|
|
952
1003
|
@listen(CommandReceive)
|
|
953
1004
|
async def remove_at(content: MessageChain):
|
|
954
1005
|
return content.lstrip(At)
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Filter syntax parsing utilities.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import List, Tuple, Optional
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def parse_filter_syntax(query: str, max_count: int = 3):
|
|
10
|
+
"""
|
|
11
|
+
Parse enhanced filter syntax supporting:
|
|
12
|
+
- Chinese/English colons (: :) and commas (, ,)
|
|
13
|
+
- Multiple filters: "mcmod=2, github=1 : xxx"
|
|
14
|
+
- Index lists: "1, 2, 3 : xxx"
|
|
15
|
+
- Max total selections
|
|
16
|
+
|
|
17
|
+
Returns:
|
|
18
|
+
(filters, search_query, error_msg)
|
|
19
|
+
filters: list of (filter_type, filter_value, count) tuples
|
|
20
|
+
filter_type: 'index' or 'link'
|
|
21
|
+
count: how many to get (default 1)
|
|
22
|
+
search_query: the actual search query
|
|
23
|
+
error_msg: error message if exceeded max
|
|
24
|
+
"""
|
|
25
|
+
if not query:
|
|
26
|
+
return [], query, None
|
|
27
|
+
|
|
28
|
+
# Skip filter parsing if query contains URL (has :// pattern)
|
|
29
|
+
if re.search(r'https?://', query):
|
|
30
|
+
return [], query.strip(), None
|
|
31
|
+
|
|
32
|
+
# Normalize Chinese punctuation to English
|
|
33
|
+
normalized = query.replace(':', ':').replace(',', ',').replace('、', ',')
|
|
34
|
+
|
|
35
|
+
# Handle escaped colons: \: or /: -> placeholder
|
|
36
|
+
normalized = re.sub(r'[/\\]:', '\x00COLON\x00', normalized)
|
|
37
|
+
|
|
38
|
+
# Split by colon - last part is the search query
|
|
39
|
+
parts = normalized.split(':')
|
|
40
|
+
if len(parts) < 2:
|
|
41
|
+
# No colon found, restore escaped colons and return as-is
|
|
42
|
+
return [], query.replace('\\:', ':').replace('/:', ':'), None
|
|
43
|
+
|
|
44
|
+
# Everything after the last colon is the search query
|
|
45
|
+
search_query = parts[-1].strip().replace('\x00COLON\x00', ':')
|
|
46
|
+
|
|
47
|
+
# Everything before is the filter specification
|
|
48
|
+
filter_spec = ':'.join(parts[:-1]).strip().replace('\x00COLON\x00', ':')
|
|
49
|
+
|
|
50
|
+
if not filter_spec or not search_query:
|
|
51
|
+
return [], query.replace('\\:', ':').replace('/:', ':'), None
|
|
52
|
+
|
|
53
|
+
# Parse filter specifications (comma-separated)
|
|
54
|
+
filter_items = [f.strip() for f in filter_spec.split(',') if f.strip()]
|
|
55
|
+
|
|
56
|
+
filters = []
|
|
57
|
+
for item in filter_items:
|
|
58
|
+
# Check for "filter=count" pattern (e.g., "mcmod=2")
|
|
59
|
+
eq_match = re.match(r'^(\w+)\s*=\s*(\d+)$', item)
|
|
60
|
+
if eq_match:
|
|
61
|
+
filter_name = eq_match.group(1).lower()
|
|
62
|
+
count = int(eq_match.group(2))
|
|
63
|
+
filters.append(('link', filter_name, count))
|
|
64
|
+
elif item.isdigit():
|
|
65
|
+
# Pure index
|
|
66
|
+
filters.append(('index', int(item), 1))
|
|
67
|
+
else:
|
|
68
|
+
# Filter name without count (default count=1)
|
|
69
|
+
filters.append(('link', item.lower(), 1))
|
|
70
|
+
|
|
71
|
+
# Calculate total count
|
|
72
|
+
total = sum(f[2] for f in filters)
|
|
73
|
+
if total > max_count:
|
|
74
|
+
return [], search_query, f"最多选择{max_count}个结果 (当前选择了{total}个)"
|
|
75
|
+
|
|
76
|
+
# Append filter names to search query
|
|
77
|
+
# Extract filter names (only 'link' type, skip 'index' type)
|
|
78
|
+
filter_names = [f[1] for f in filters if f[0] == 'link']
|
|
79
|
+
if filter_names:
|
|
80
|
+
# Append filter names to search query: "search_query filter1 filter2"
|
|
81
|
+
search_query = f"{search_query} {' '.join(filter_names)}"
|
|
82
|
+
|
|
83
|
+
return filters, search_query, None
|
entari_plugin_hyw/misc.py
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import json
|
|
2
2
|
import base64
|
|
3
3
|
import httpx
|
|
4
|
+
import re
|
|
5
|
+
import time
|
|
4
6
|
from typing import Dict, Any, List, Optional
|
|
5
7
|
from loguru import logger
|
|
6
8
|
from arclet.entari import MessageChain, Image
|
|
@@ -170,3 +172,43 @@ async def render_image_unsupported(
|
|
|
170
172
|
theme_color=theme_color,
|
|
171
173
|
tab_id=tab_id
|
|
172
174
|
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def parse_color(color: str) -> str:
|
|
178
|
+
"""Parse color string to hex format."""
|
|
179
|
+
if not color:
|
|
180
|
+
return "#ef4444"
|
|
181
|
+
color = str(color).strip()
|
|
182
|
+
if color.startswith('#') and len(color) in [4, 7]:
|
|
183
|
+
return color
|
|
184
|
+
if re.match(r'^[0-9a-fA-F]{6}$', color):
|
|
185
|
+
return f'#{color}'
|
|
186
|
+
rgb_match = re.match(r'^\(?(\d+)[,\s]+(\d+)[,\s]+(\d+)\)?$', color)
|
|
187
|
+
if rgb_match:
|
|
188
|
+
r, g, b = (max(0, min(255, int(x))) for x in rgb_match.groups())
|
|
189
|
+
return f'#{r:02x}{g:02x}{b:02x}'
|
|
190
|
+
return "#ef4444"
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class RecentEventDeduper:
|
|
194
|
+
"""Deduplicates recent events based on a key with TTL."""
|
|
195
|
+
|
|
196
|
+
def __init__(self, ttl_seconds: float = 30.0, max_size: int = 2048):
|
|
197
|
+
self.ttl_seconds = ttl_seconds
|
|
198
|
+
self.max_size = max_size
|
|
199
|
+
self._seen: Dict[str, float] = {}
|
|
200
|
+
|
|
201
|
+
def seen_recently(self, key: str) -> bool:
|
|
202
|
+
now = time.time()
|
|
203
|
+
if len(self._seen) > self.max_size:
|
|
204
|
+
self._prune(now)
|
|
205
|
+
ts = self._seen.get(key)
|
|
206
|
+
if ts is None or now - ts > self.ttl_seconds:
|
|
207
|
+
self._seen[key] = now
|
|
208
|
+
return False
|
|
209
|
+
return True
|
|
210
|
+
|
|
211
|
+
def _prune(self, now: float):
|
|
212
|
+
expired = [k for k, ts in self._seen.items() if now - ts > self.ttl_seconds]
|
|
213
|
+
for k in expired:
|
|
214
|
+
self._seen.pop(k, None)
|