entari-plugin-hyw 4.0.0rc11__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.0rc11/src/entari_plugin_hyw.egg-info → entari_plugin_hyw-4.0.0rc12}/PKG-INFO +1 -1
  2. {entari_plugin_hyw-4.0.0rc11 → entari_plugin_hyw-4.0.0rc12}/pyproject.toml +1 -1
  3. {entari_plugin_hyw-4.0.0rc11 → entari_plugin_hyw-4.0.0rc12}/src/entari_plugin_hyw/__init__.py +175 -133
  4. entari_plugin_hyw-4.0.0rc12/src/entari_plugin_hyw/filters.py +83 -0
  5. {entari_plugin_hyw-4.0.0rc11 → entari_plugin_hyw-4.0.0rc12}/src/entari_plugin_hyw/misc.py +42 -0
  6. {entari_plugin_hyw-4.0.0rc11 → entari_plugin_hyw-4.0.0rc12/src/entari_plugin_hyw.egg-info}/PKG-INFO +1 -1
  7. {entari_plugin_hyw-4.0.0rc11 → 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.0rc11 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/browser_control/service.py +75 -11
  10. {entari_plugin_hyw-4.0.0rc11 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/core.py +148 -1
  11. entari_plugin_hyw-4.0.0rc12/src/hyw_core/definitions.py +101 -0
  12. {entari_plugin_hyw-4.0.0rc11 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/search.py +10 -0
  13. {entari_plugin_hyw-4.0.0rc11 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/stages/summary.py +1 -3
  14. entari_plugin_hyw-4.0.0rc11/src/hyw_core/definitions.py +0 -83
  15. {entari_plugin_hyw-4.0.0rc11 → entari_plugin_hyw-4.0.0rc12}/MANIFEST.in +0 -0
  16. {entari_plugin_hyw-4.0.0rc11 → entari_plugin_hyw-4.0.0rc12}/README.md +0 -0
  17. {entari_plugin_hyw-4.0.0rc11 → entari_plugin_hyw-4.0.0rc12}/setup.cfg +0 -0
  18. {entari_plugin_hyw-4.0.0rc11 → entari_plugin_hyw-4.0.0rc12}/src/entari_plugin_hyw/Untitled-1 +0 -0
  19. {entari_plugin_hyw-4.0.0rc11 → entari_plugin_hyw-4.0.0rc12}/src/entari_plugin_hyw/history.py +0 -0
  20. {entari_plugin_hyw-4.0.0rc11 → entari_plugin_hyw-4.0.0rc12}/src/entari_plugin_hyw/search_cache.py +0 -0
  21. {entari_plugin_hyw-4.0.0rc11 → entari_plugin_hyw-4.0.0rc12}/src/entari_plugin_hyw.egg-info/dependency_links.txt +0 -0
  22. {entari_plugin_hyw-4.0.0rc11 → entari_plugin_hyw-4.0.0rc12}/src/entari_plugin_hyw.egg-info/requires.txt +0 -0
  23. {entari_plugin_hyw-4.0.0rc11 → entari_plugin_hyw-4.0.0rc12}/src/entari_plugin_hyw.egg-info/top_level.txt +0 -0
  24. {entari_plugin_hyw-4.0.0rc11 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/__init__.py +0 -0
  25. {entari_plugin_hyw-4.0.0rc11 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/browser_control/__init__.py +0 -0
  26. {entari_plugin_hyw-4.0.0rc11 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/browser_control/assets/card-dist/index.html +0 -0
  27. {entari_plugin_hyw-4.0.0rc11 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/browser_control/assets/card-dist/logos/anthropic.svg +0 -0
  28. {entari_plugin_hyw-4.0.0rc11 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/browser_control/assets/card-dist/logos/cerebras.svg +0 -0
  29. {entari_plugin_hyw-4.0.0rc11 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/browser_control/assets/card-dist/logos/deepseek.png +0 -0
  30. {entari_plugin_hyw-4.0.0rc11 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/browser_control/assets/card-dist/logos/gemini.svg +0 -0
  31. {entari_plugin_hyw-4.0.0rc11 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/browser_control/assets/card-dist/logos/google.svg +0 -0
  32. {entari_plugin_hyw-4.0.0rc11 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/browser_control/assets/card-dist/logos/grok.png +0 -0
  33. {entari_plugin_hyw-4.0.0rc11 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/browser_control/assets/card-dist/logos/huggingface.png +0 -0
  34. {entari_plugin_hyw-4.0.0rc11 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/browser_control/assets/card-dist/logos/microsoft.svg +0 -0
  35. {entari_plugin_hyw-4.0.0rc11 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/browser_control/assets/card-dist/logos/minimax.png +0 -0
  36. {entari_plugin_hyw-4.0.0rc11 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/browser_control/assets/card-dist/logos/mistral.png +0 -0
  37. {entari_plugin_hyw-4.0.0rc11 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/browser_control/assets/card-dist/logos/nvida.png +0 -0
  38. {entari_plugin_hyw-4.0.0rc11 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/browser_control/assets/card-dist/logos/openai.svg +0 -0
  39. {entari_plugin_hyw-4.0.0rc11 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/browser_control/assets/card-dist/logos/openrouter.png +0 -0
  40. {entari_plugin_hyw-4.0.0rc11 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/browser_control/assets/card-dist/logos/perplexity.svg +0 -0
  41. {entari_plugin_hyw-4.0.0rc11 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/browser_control/assets/card-dist/logos/qwen.png +0 -0
  42. {entari_plugin_hyw-4.0.0rc11 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/browser_control/assets/card-dist/logos/xai.png +0 -0
  43. {entari_plugin_hyw-4.0.0rc11 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/browser_control/assets/card-dist/logos/xiaomi.png +0 -0
  44. {entari_plugin_hyw-4.0.0rc11 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/browser_control/assets/card-dist/logos/zai.png +0 -0
  45. {entari_plugin_hyw-4.0.0rc11 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/browser_control/assets/card-dist/vite.svg +0 -0
  46. {entari_plugin_hyw-4.0.0rc11 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/browser_control/assets/index.html +0 -0
  47. {entari_plugin_hyw-4.0.0rc11 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/browser_control/assets/logos/anthropic.svg +0 -0
  48. {entari_plugin_hyw-4.0.0rc11 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/browser_control/assets/logos/cerebras.svg +0 -0
  49. {entari_plugin_hyw-4.0.0rc11 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/browser_control/assets/logos/deepseek.png +0 -0
  50. {entari_plugin_hyw-4.0.0rc11 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/browser_control/assets/logos/gemini.svg +0 -0
  51. {entari_plugin_hyw-4.0.0rc11 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/browser_control/assets/logos/google.svg +0 -0
  52. {entari_plugin_hyw-4.0.0rc11 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/browser_control/assets/logos/grok.png +0 -0
  53. {entari_plugin_hyw-4.0.0rc11 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/browser_control/assets/logos/huggingface.png +0 -0
  54. {entari_plugin_hyw-4.0.0rc11 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/browser_control/assets/logos/microsoft.svg +0 -0
  55. {entari_plugin_hyw-4.0.0rc11 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/browser_control/assets/logos/minimax.png +0 -0
  56. {entari_plugin_hyw-4.0.0rc11 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/browser_control/assets/logos/mistral.png +0 -0
  57. {entari_plugin_hyw-4.0.0rc11 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/browser_control/assets/logos/nvida.png +0 -0
  58. {entari_plugin_hyw-4.0.0rc11 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/browser_control/assets/logos/openai.svg +0 -0
  59. {entari_plugin_hyw-4.0.0rc11 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/browser_control/assets/logos/openrouter.png +0 -0
  60. {entari_plugin_hyw-4.0.0rc11 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/browser_control/assets/logos/perplexity.svg +0 -0
  61. {entari_plugin_hyw-4.0.0rc11 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/browser_control/assets/logos/qwen.png +0 -0
  62. {entari_plugin_hyw-4.0.0rc11 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/browser_control/assets/logos/xai.png +0 -0
  63. {entari_plugin_hyw-4.0.0rc11 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/browser_control/assets/logos/xiaomi.png +0 -0
  64. {entari_plugin_hyw-4.0.0rc11 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/browser_control/assets/logos/zai.png +0 -0
  65. {entari_plugin_hyw-4.0.0rc11 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/browser_control/engines/__init__.py +0 -0
  66. {entari_plugin_hyw-4.0.0rc11 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/browser_control/engines/base.py +0 -0
  67. {entari_plugin_hyw-4.0.0rc11 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/browser_control/engines/default.py +0 -0
  68. {entari_plugin_hyw-4.0.0rc11 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/browser_control/engines/duckduckgo.py +0 -0
  69. {entari_plugin_hyw-4.0.0rc11 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/browser_control/landing.html +0 -0
  70. {entari_plugin_hyw-4.0.0rc11 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/browser_control/manager.py +0 -0
  71. {entari_plugin_hyw-4.0.0rc11 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/browser_control/renderer.py +0 -0
  72. {entari_plugin_hyw-4.0.0rc11 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/config.py +0 -0
  73. {entari_plugin_hyw-4.0.0rc11 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/crawling/__init__.py +0 -0
  74. {entari_plugin_hyw-4.0.0rc11 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/crawling/completeness.py +0 -0
  75. {entari_plugin_hyw-4.0.0rc11 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/crawling/models.py +0 -0
  76. {entari_plugin_hyw-4.0.0rc11 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/image_cache.py +0 -0
  77. {entari_plugin_hyw-4.0.0rc11 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/pipeline.py +0 -0
  78. {entari_plugin_hyw-4.0.0rc11 → entari_plugin_hyw-4.0.0rc12}/src/hyw_core/stages/__init__.py +0 -0
  79. {entari_plugin_hyw-4.0.0rc11 → 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.0rc11
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-rc11"
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,126 +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
- # Append filter names to search query
112
- # Extract filter names (only 'link' type, skip 'index' type)
113
- filter_names = [f[1] for f in filters if f[0] == 'link']
114
- if filter_names:
115
- # Append filter names to search query: "search_query filter1 filter2"
116
- search_query = f"{search_query} {' '.join(filter_names)}"
117
-
118
- return filters, search_query, None
119
-
120
-
121
49
  try:
122
50
  __version__ = get_version("entari_plugin_hyw")
123
51
  except Exception:
124
52
  __version__ = "4.0.0-rc8"
125
53
 
126
54
 
127
- def parse_color(color: str) -> str:
128
- if not color:
129
- return "#ef4444"
130
- color = str(color).strip()
131
- if color.startswith('#') and len(color) in [4, 7]:
132
- return color
133
- if re.match(r'^[0-9a-fA-F]{6}$', color):
134
- return f'#{color}'
135
- rgb_match = re.match(r'^\(?(\d+)[,\s]+(\d+)[,\s]+(\d+)\)?$', color)
136
- if rgb_match:
137
- r, g, b = (max(0, min(255, int(x))) for x in rgb_match.groups())
138
- return f'#{r:02x}{g:02x}{b:02x}'
139
- return "#ef4444"
140
-
141
-
142
- class _RecentEventDeduper:
143
- def __init__(self, ttl_seconds: float = 30.0, max_size: int = 2048):
144
- self.ttl_seconds = ttl_seconds
145
- self.max_size = max_size
146
- self._seen: Dict[str, float] = {}
147
-
148
- def seen_recently(self, key: str) -> bool:
149
- now = time.time()
150
- if len(self._seen) > self.max_size:
151
- self._prune(now)
152
- ts = self._seen.get(key)
153
- if ts is None or now - ts > self.ttl_seconds:
154
- self._seen[key] = now
155
- return False
156
- return True
157
-
158
- def _prune(self, now: float):
159
- expired = [k for k, ts in self._seen.items() if now - ts > self.ttl_seconds]
160
- for k in expired:
161
- self._seen.pop(k, None)
162
-
163
- _event_deduper = _RecentEventDeduper()
55
+ _event_deduper = RecentEventDeduper()
56
+
164
57
 
165
58
 
166
59
  @dataclass
@@ -170,6 +63,7 @@ class HywConfig(BasicConfModel):
170
63
  models: List[Dict[str, Any]] = field(default_factory=list)
171
64
  question_command: str = "/q"
172
65
  web_command: str = "/w"
66
+ help_command: str = "/h"
173
67
  language: str = "Simplified Chinese"
174
68
  temperature: float = 0.4
175
69
 
@@ -326,9 +220,9 @@ async def process_request(
326
220
  output_path = tf.name
327
221
 
328
222
  core = get_hyw_core()
329
- # 1. Query ONLY (no render path provided)
330
- # Pass output_path=None so it returns raw response without internal rendering
331
- 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)
332
226
 
333
227
  # 2. Get the warmed-up tab
334
228
  try:
@@ -453,7 +347,6 @@ async def handle_question_command(session: Session[MessageCreatedEvent], result:
453
347
  # Check if too many indices requested (parse_multi_indices returns None if > max_count)
454
348
  if query_text and indices is None:
455
349
  # Check if it looks like indices but exceeded limit
456
- import re
457
350
  if re.match(r'^[\d,、\s\-–]+$', query_text):
458
351
  await session.send("最多选择3个结果进行总结")
459
352
  search_cache.cleanup()
@@ -551,8 +444,91 @@ async def handle_question_command(session: Session[MessageCreatedEvent], result:
551
444
  search_cache.cleanup()
552
445
  return
553
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
+
554
524
  # === Filter Mode: Search + Find matching links + Summarize ===
555
- 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)
556
532
 
557
533
  if filter_error:
558
534
  await session.send(filter_error)
@@ -565,6 +541,10 @@ async def handle_question_command(session: Session[MessageCreatedEvent], result:
565
541
  core = get_hyw_core()
566
542
  local_renderer = await get_content_renderer()
567
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
+
568
548
  # Run search and prepare tab in parallel
569
549
  search_task = asyncio.create_task(core.search([search_query]))
570
550
  tab_task = asyncio.create_task(local_renderer.prepare_tab())
@@ -592,13 +572,15 @@ async def handle_question_command(session: Session[MessageCreatedEvent], result:
592
572
  else:
593
573
  try: await tab_task
594
574
  except: pass
595
- await session.send(f"序号 {filter_value} 超出范围 (1-{len(visible)})")
575
+ await session.send(f"⚠️ 序号 {filter_value} 超出范围 (1-{len(visible)})")
596
576
  return
597
577
  else:
598
578
  found_count = 0
599
579
  for res in visible:
600
580
  url = res.get("url", "")
601
- 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:
602
584
  urls_to_screenshot.append(url)
603
585
  found_count += 1
604
586
  if found_count >= count:
@@ -607,32 +589,54 @@ async def handle_question_command(session: Session[MessageCreatedEvent], result:
607
589
  if found_count == 0:
608
590
  try: await tab_task
609
591
  except: pass
610
- await session.send(f"未找到包含 \"{filter_value}\" 的链接")
592
+ await session.send(f"⚠️ 未找到包含 \"{filter_value}\" 的链接")
611
593
  return
612
594
 
613
595
  if not urls_to_screenshot:
614
596
  try: await tab_task
615
597
  except: pass
616
- await session.send("未找到匹配的链接")
598
+ await session.send("⚠️ 未找到匹配的链接")
617
599
  return
618
600
 
619
- # Take screenshots concurrently
620
- 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]
621
603
  screenshot_results = await asyncio.gather(*screenshot_tasks)
622
- screenshots = [b64 for b64 in screenshot_results if b64]
623
604
 
624
- 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:
625
625
  try: await tab_task
626
626
  except: pass
627
627
  await session.send("无法截图页面")
628
628
  return
629
629
 
630
- # Pass screenshots to LLM for summarization
631
- 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]}"
632
636
 
633
637
  request = QueryRequest(
634
638
  user_input=user_query,
635
- images=screenshots,
639
+ images=[], # No images, use text content instead
636
640
  conversation_history=[],
637
641
  model_name=None,
638
642
  )
@@ -653,7 +657,7 @@ async def handle_question_command(session: Session[MessageCreatedEvent], result:
653
657
  output_path=output_path,
654
658
  stats={"total_time": response.total_time},
655
659
  references=[],
656
- page_references=[],
660
+ page_references=page_references, # Pass screenshots for rendering
657
661
  tab_id=tab_id
658
662
  )
659
663
 
@@ -850,25 +854,27 @@ async def handle_web_command(session: Session[MessageCreatedEvent], result: Arpa
850
854
  if url and url not in urls_to_screenshot:
851
855
  urls_to_screenshot.append(url)
852
856
  else:
853
- await session.send(f"序号 {filter_value} 超出范围 (1-{len(visible)})")
857
+ await session.send(f"⚠️ 序号 {filter_value} 超出范围 (1-{len(visible)})")
854
858
  return
855
859
  else:
856
860
  # Link filter: find URLs containing filter term
857
861
  found_count = 0
858
862
  for res in visible:
859
863
  url = res.get("url", "")
860
- 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:
861
867
  urls_to_screenshot.append(url)
862
868
  found_count += 1
863
869
  if found_count >= count:
864
870
  break
865
871
 
866
872
  if found_count == 0:
867
- await session.send(f"未找到包含 \"{filter_value}\" 的链接")
873
+ await session.send(f"⚠️ 未找到包含 \"{filter_value}\" 的链接")
868
874
  return
869
875
 
870
876
  if not urls_to_screenshot:
871
- await session.send("未找到匹配的链接")
877
+ await session.send("⚠️ 未找到匹配的链接")
872
878
  return
873
879
 
874
880
  if conf.reaction:
@@ -958,6 +964,42 @@ async def handle_web_command(session: Session[MessageCreatedEvent], result: Arpa
958
964
 
959
965
  metadata("hyw", author=[{"name": "kumoSleeping", "email": "zjr2992@outlook.com"}], version=__version__, config=HywConfig)
960
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
+
961
1003
  @listen(CommandReceive)
962
1004
  async def remove_at(content: MessageChain):
963
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.0rc11
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