entari-plugin-hyw 4.0.0rc10__tar.gz → 4.0.0rc12__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of entari-plugin-hyw might be problematic. Click here for more details.

Files changed (79) hide show
  1. {entari_plugin_hyw-4.0.0rc10/src/entari_plugin_hyw.egg-info → entari_plugin_hyw-4.0.0rc12}/PKG-INFO +1 -1
  2. {entari_plugin_hyw-4.0.0rc10 → entari_plugin_hyw-4.0.0rc12}/pyproject.toml +1 -1
  3. {entari_plugin_hyw-4.0.0rc10 → entari_plugin_hyw-4.0.0rc12}/src/entari_plugin_hyw/__init__.py +191 -140
  4. entari_plugin_hyw-4.0.0rc12/src/entari_plugin_hyw/filters.py +83 -0
  5. {entari_plugin_hyw-4.0.0rc10 → entari_plugin_hyw-4.0.0rc12}/src/entari_plugin_hyw/misc.py +42 -0
  6. {entari_plugin_hyw-4.0.0rc10 → entari_plugin_hyw-4.0.0rc12/src/entari_plugin_hyw.egg-info}/PKG-INFO +1 -1
  7. {entari_plugin_hyw-4.0.0rc10 → entari_plugin_hyw-4.0.0rc12}/src/entari_plugin_hyw.egg-info/SOURCES.txt +2 -0
  8. entari_plugin_hyw-4.0.0rc12/src/hyw_core/agent.py +648 -0
  9. {entari_plugin_hyw-4.0.0rc10 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/browser_control/service.py +283 -130
  10. {entari_plugin_hyw-4.0.0rc10 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/core.py +148 -8
  11. {entari_plugin_hyw-4.0.0rc10 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/crawling/completeness.py +99 -10
  12. entari_plugin_hyw-4.0.0rc12/src/hyw_core/definitions.py +101 -0
  13. {entari_plugin_hyw-4.0.0rc10 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/search.py +10 -0
  14. {entari_plugin_hyw-4.0.0rc10 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/stages/summary.py +1 -3
  15. entari_plugin_hyw-4.0.0rc10/src/hyw_core/definitions.py +0 -83
  16. {entari_plugin_hyw-4.0.0rc10 → entari_plugin_hyw-4.0.0rc12}/MANIFEST.in +0 -0
  17. {entari_plugin_hyw-4.0.0rc10 → entari_plugin_hyw-4.0.0rc12}/README.md +0 -0
  18. {entari_plugin_hyw-4.0.0rc10 → entari_plugin_hyw-4.0.0rc12}/setup.cfg +0 -0
  19. {entari_plugin_hyw-4.0.0rc10 → entari_plugin_hyw-4.0.0rc12}/src/entari_plugin_hyw/Untitled-1 +0 -0
  20. {entari_plugin_hyw-4.0.0rc10 → entari_plugin_hyw-4.0.0rc12}/src/entari_plugin_hyw/history.py +0 -0
  21. {entari_plugin_hyw-4.0.0rc10 → entari_plugin_hyw-4.0.0rc12}/src/entari_plugin_hyw/search_cache.py +0 -0
  22. {entari_plugin_hyw-4.0.0rc10 → entari_plugin_hyw-4.0.0rc12}/src/entari_plugin_hyw.egg-info/dependency_links.txt +0 -0
  23. {entari_plugin_hyw-4.0.0rc10 → entari_plugin_hyw-4.0.0rc12}/src/entari_plugin_hyw.egg-info/requires.txt +0 -0
  24. {entari_plugin_hyw-4.0.0rc10 → entari_plugin_hyw-4.0.0rc12}/src/entari_plugin_hyw.egg-info/top_level.txt +0 -0
  25. {entari_plugin_hyw-4.0.0rc10 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/__init__.py +0 -0
  26. {entari_plugin_hyw-4.0.0rc10 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/browser_control/__init__.py +0 -0
  27. {entari_plugin_hyw-4.0.0rc10 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/browser_control/assets/card-dist/index.html +0 -0
  28. {entari_plugin_hyw-4.0.0rc10 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/browser_control/assets/card-dist/logos/anthropic.svg +0 -0
  29. {entari_plugin_hyw-4.0.0rc10 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/browser_control/assets/card-dist/logos/cerebras.svg +0 -0
  30. {entari_plugin_hyw-4.0.0rc10 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/browser_control/assets/card-dist/logos/deepseek.png +0 -0
  31. {entari_plugin_hyw-4.0.0rc10 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/browser_control/assets/card-dist/logos/gemini.svg +0 -0
  32. {entari_plugin_hyw-4.0.0rc10 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/browser_control/assets/card-dist/logos/google.svg +0 -0
  33. {entari_plugin_hyw-4.0.0rc10 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/browser_control/assets/card-dist/logos/grok.png +0 -0
  34. {entari_plugin_hyw-4.0.0rc10 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/browser_control/assets/card-dist/logos/huggingface.png +0 -0
  35. {entari_plugin_hyw-4.0.0rc10 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/browser_control/assets/card-dist/logos/microsoft.svg +0 -0
  36. {entari_plugin_hyw-4.0.0rc10 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/browser_control/assets/card-dist/logos/minimax.png +0 -0
  37. {entari_plugin_hyw-4.0.0rc10 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/browser_control/assets/card-dist/logos/mistral.png +0 -0
  38. {entari_plugin_hyw-4.0.0rc10 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/browser_control/assets/card-dist/logos/nvida.png +0 -0
  39. {entari_plugin_hyw-4.0.0rc10 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/browser_control/assets/card-dist/logos/openai.svg +0 -0
  40. {entari_plugin_hyw-4.0.0rc10 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/browser_control/assets/card-dist/logos/openrouter.png +0 -0
  41. {entari_plugin_hyw-4.0.0rc10 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/browser_control/assets/card-dist/logos/perplexity.svg +0 -0
  42. {entari_plugin_hyw-4.0.0rc10 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/browser_control/assets/card-dist/logos/qwen.png +0 -0
  43. {entari_plugin_hyw-4.0.0rc10 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/browser_control/assets/card-dist/logos/xai.png +0 -0
  44. {entari_plugin_hyw-4.0.0rc10 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/browser_control/assets/card-dist/logos/xiaomi.png +0 -0
  45. {entari_plugin_hyw-4.0.0rc10 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/browser_control/assets/card-dist/logos/zai.png +0 -0
  46. {entari_plugin_hyw-4.0.0rc10 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/browser_control/assets/card-dist/vite.svg +0 -0
  47. {entari_plugin_hyw-4.0.0rc10 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/browser_control/assets/index.html +0 -0
  48. {entari_plugin_hyw-4.0.0rc10 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/browser_control/assets/logos/anthropic.svg +0 -0
  49. {entari_plugin_hyw-4.0.0rc10 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/browser_control/assets/logos/cerebras.svg +0 -0
  50. {entari_plugin_hyw-4.0.0rc10 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/browser_control/assets/logos/deepseek.png +0 -0
  51. {entari_plugin_hyw-4.0.0rc10 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/browser_control/assets/logos/gemini.svg +0 -0
  52. {entari_plugin_hyw-4.0.0rc10 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/browser_control/assets/logos/google.svg +0 -0
  53. {entari_plugin_hyw-4.0.0rc10 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/browser_control/assets/logos/grok.png +0 -0
  54. {entari_plugin_hyw-4.0.0rc10 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/browser_control/assets/logos/huggingface.png +0 -0
  55. {entari_plugin_hyw-4.0.0rc10 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/browser_control/assets/logos/microsoft.svg +0 -0
  56. {entari_plugin_hyw-4.0.0rc10 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/browser_control/assets/logos/minimax.png +0 -0
  57. {entari_plugin_hyw-4.0.0rc10 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/browser_control/assets/logos/mistral.png +0 -0
  58. {entari_plugin_hyw-4.0.0rc10 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/browser_control/assets/logos/nvida.png +0 -0
  59. {entari_plugin_hyw-4.0.0rc10 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/browser_control/assets/logos/openai.svg +0 -0
  60. {entari_plugin_hyw-4.0.0rc10 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/browser_control/assets/logos/openrouter.png +0 -0
  61. {entari_plugin_hyw-4.0.0rc10 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/browser_control/assets/logos/perplexity.svg +0 -0
  62. {entari_plugin_hyw-4.0.0rc10 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/browser_control/assets/logos/qwen.png +0 -0
  63. {entari_plugin_hyw-4.0.0rc10 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/browser_control/assets/logos/xai.png +0 -0
  64. {entari_plugin_hyw-4.0.0rc10 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/browser_control/assets/logos/xiaomi.png +0 -0
  65. {entari_plugin_hyw-4.0.0rc10 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/browser_control/assets/logos/zai.png +0 -0
  66. {entari_plugin_hyw-4.0.0rc10 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/browser_control/engines/__init__.py +0 -0
  67. {entari_plugin_hyw-4.0.0rc10 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/browser_control/engines/base.py +0 -0
  68. {entari_plugin_hyw-4.0.0rc10 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/browser_control/engines/default.py +0 -0
  69. {entari_plugin_hyw-4.0.0rc10 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/browser_control/engines/duckduckgo.py +0 -0
  70. {entari_plugin_hyw-4.0.0rc10 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/browser_control/landing.html +0 -0
  71. {entari_plugin_hyw-4.0.0rc10 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/browser_control/manager.py +0 -0
  72. {entari_plugin_hyw-4.0.0rc10 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/browser_control/renderer.py +0 -0
  73. {entari_plugin_hyw-4.0.0rc10 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/config.py +0 -0
  74. {entari_plugin_hyw-4.0.0rc10 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/crawling/__init__.py +0 -0
  75. {entari_plugin_hyw-4.0.0rc10 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/crawling/models.py +0 -0
  76. {entari_plugin_hyw-4.0.0rc10 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/image_cache.py +0 -0
  77. {entari_plugin_hyw-4.0.0rc10 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/pipeline.py +0 -0
  78. {entari_plugin_hyw-4.0.0rc10 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/stages/__init__.py +0 -0
  79. {entari_plugin_hyw-4.0.0rc10 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/stages/base.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: entari_plugin_hyw
3
- Version: 4.0.0rc10
3
+ Version: 4.0.0rc12
4
4
  Summary: Use large language models to interpret chat messages
5
5
  Author-email: kumoSleeping <zjr2992@outlook.com>
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "entari_plugin_hyw"
7
- version = "4.0.0-rc10"
7
+ version = "4.0.0-rc12"
8
8
  description = "Use large language models to interpret chat messages"
9
9
  authors = [{name = "kumoSleeping", email = "zjr2992@outlook.com"}]
10
10
  dependencies = [
@@ -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
- def parse_color(color: str) -> str:
121
- if not color:
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
- _hyw_core: Optional[HywCore] = None
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
- # 1. Query ONLY (no render path provided)
324
- # Pass output_path=None so it returns raw response without internal rendering
325
- response = await core.query(request, output_path=None)
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
- filters, search_query, filter_error = parse_filter_syntax(query_text, max_count=3)
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
- if filter_value in url.lower() and url not in urls_to_screenshot:
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 concurrently
614
- screenshot_tasks = [core.screenshot(url) for url in urls_to_screenshot]
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
- if not screenshots:
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
- # Pass screenshots to LLM for summarization
625
- user_query = f"总结关于 \"{search_query}\" 的内容"
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=screenshots,
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 and tab pre-warming in parallel
812
+ # Start search first
803
813
  local_renderer = await get_content_renderer()
804
814
  search_task = asyncio.create_task(core.search([search_query]))
805
- tab_task = asyncio.create_task(local_renderer.prepare_tab())
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
- try: await tab_task
815
- except: pass
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
- try: await tab_task
823
- except: pass
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
- if filter_value in url.lower() and url not in urls_to_screenshot:
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
@@ -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)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: entari_plugin_hyw
3
- Version: 4.0.0rc10
3
+ Version: 4.0.0rc12
4
4
  Summary: Use large language models to interpret chat messages
5
5
  Author-email: kumoSleeping <zjr2992@outlook.com>
6
6
  License: MIT
@@ -3,6 +3,7 @@ README.md
3
3
  pyproject.toml
4
4
  src/entari_plugin_hyw/Untitled-1
5
5
  src/entari_plugin_hyw/__init__.py
6
+ src/entari_plugin_hyw/filters.py
6
7
  src/entari_plugin_hyw/history.py
7
8
  src/entari_plugin_hyw/misc.py
8
9
  src/entari_plugin_hyw/search_cache.py
@@ -12,6 +13,7 @@ src/entari_plugin_hyw.egg-info/dependency_links.txt
12
13
  src/entari_plugin_hyw.egg-info/requires.txt
13
14
  src/entari_plugin_hyw.egg-info/top_level.txt
14
15
  src/hyw_core/__init__.py
16
+ src/hyw_core/agent.py
15
17
  src/hyw_core/config.py
16
18
  src/hyw_core/core.py
17
19
  src/hyw_core/definitions.py