entari-plugin-hyw 0.3.5__py3-none-any.whl → 4.0.0rc14__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.

Potentially problematic release.


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

Files changed (78) hide show
  1. entari_plugin_hyw/Untitled-1 +1865 -0
  2. entari_plugin_hyw/__init__.py +979 -116
  3. entari_plugin_hyw/filters.py +83 -0
  4. entari_plugin_hyw/history.py +251 -0
  5. entari_plugin_hyw/misc.py +214 -0
  6. entari_plugin_hyw/search_cache.py +154 -0
  7. entari_plugin_hyw-4.0.0rc14.dist-info/METADATA +118 -0
  8. entari_plugin_hyw-4.0.0rc14.dist-info/RECORD +72 -0
  9. {entari_plugin_hyw-0.3.5.dist-info → entari_plugin_hyw-4.0.0rc14.dist-info}/WHEEL +1 -1
  10. {entari_plugin_hyw-0.3.5.dist-info → entari_plugin_hyw-4.0.0rc14.dist-info}/top_level.txt +1 -0
  11. hyw_core/__init__.py +94 -0
  12. hyw_core/agent.py +768 -0
  13. hyw_core/browser_control/__init__.py +63 -0
  14. hyw_core/browser_control/assets/card-dist/index.html +425 -0
  15. hyw_core/browser_control/assets/card-dist/logos/anthropic.svg +1 -0
  16. hyw_core/browser_control/assets/card-dist/logos/cerebras.svg +9 -0
  17. hyw_core/browser_control/assets/card-dist/logos/deepseek.png +0 -0
  18. hyw_core/browser_control/assets/card-dist/logos/gemini.svg +1 -0
  19. hyw_core/browser_control/assets/card-dist/logos/google.svg +1 -0
  20. hyw_core/browser_control/assets/card-dist/logos/grok.png +0 -0
  21. hyw_core/browser_control/assets/card-dist/logos/huggingface.png +0 -0
  22. hyw_core/browser_control/assets/card-dist/logos/microsoft.svg +15 -0
  23. hyw_core/browser_control/assets/card-dist/logos/minimax.png +0 -0
  24. hyw_core/browser_control/assets/card-dist/logos/mistral.png +0 -0
  25. hyw_core/browser_control/assets/card-dist/logos/nvida.png +0 -0
  26. hyw_core/browser_control/assets/card-dist/logos/openai.svg +1 -0
  27. hyw_core/browser_control/assets/card-dist/logos/openrouter.png +0 -0
  28. hyw_core/browser_control/assets/card-dist/logos/perplexity.svg +24 -0
  29. hyw_core/browser_control/assets/card-dist/logos/qwen.png +0 -0
  30. hyw_core/browser_control/assets/card-dist/logos/xai.png +0 -0
  31. hyw_core/browser_control/assets/card-dist/logos/xiaomi.png +0 -0
  32. hyw_core/browser_control/assets/card-dist/logos/zai.png +0 -0
  33. hyw_core/browser_control/assets/card-dist/vite.svg +1 -0
  34. hyw_core/browser_control/assets/index.html +5691 -0
  35. hyw_core/browser_control/assets/logos/anthropic.svg +1 -0
  36. hyw_core/browser_control/assets/logos/cerebras.svg +9 -0
  37. hyw_core/browser_control/assets/logos/deepseek.png +0 -0
  38. hyw_core/browser_control/assets/logos/gemini.svg +1 -0
  39. hyw_core/browser_control/assets/logos/google.svg +1 -0
  40. hyw_core/browser_control/assets/logos/grok.png +0 -0
  41. hyw_core/browser_control/assets/logos/huggingface.png +0 -0
  42. hyw_core/browser_control/assets/logos/microsoft.svg +15 -0
  43. hyw_core/browser_control/assets/logos/minimax.png +0 -0
  44. hyw_core/browser_control/assets/logos/mistral.png +0 -0
  45. hyw_core/browser_control/assets/logos/nvida.png +0 -0
  46. hyw_core/browser_control/assets/logos/openai.svg +1 -0
  47. hyw_core/browser_control/assets/logos/openrouter.png +0 -0
  48. hyw_core/browser_control/assets/logos/perplexity.svg +24 -0
  49. hyw_core/browser_control/assets/logos/qwen.png +0 -0
  50. hyw_core/browser_control/assets/logos/xai.png +0 -0
  51. hyw_core/browser_control/assets/logos/xiaomi.png +0 -0
  52. hyw_core/browser_control/assets/logos/zai.png +0 -0
  53. hyw_core/browser_control/engines/__init__.py +15 -0
  54. hyw_core/browser_control/engines/base.py +13 -0
  55. hyw_core/browser_control/engines/default.py +166 -0
  56. hyw_core/browser_control/engines/duckduckgo.py +171 -0
  57. hyw_core/browser_control/landing.html +172 -0
  58. hyw_core/browser_control/manager.py +173 -0
  59. hyw_core/browser_control/renderer.py +446 -0
  60. hyw_core/browser_control/service.py +940 -0
  61. hyw_core/config.py +154 -0
  62. hyw_core/core.py +462 -0
  63. hyw_core/crawling/__init__.py +18 -0
  64. hyw_core/crawling/completeness.py +437 -0
  65. hyw_core/crawling/models.py +88 -0
  66. hyw_core/definitions.py +104 -0
  67. hyw_core/image_cache.py +274 -0
  68. hyw_core/pipeline.py +502 -0
  69. hyw_core/search.py +171 -0
  70. hyw_core/stages/__init__.py +21 -0
  71. hyw_core/stages/base.py +95 -0
  72. hyw_core/stages/summary.py +191 -0
  73. entari_plugin_hyw/agent.py +0 -419
  74. entari_plugin_hyw/compressor.py +0 -59
  75. entari_plugin_hyw/tools.py +0 -236
  76. entari_plugin_hyw/vision.py +0 -35
  77. entari_plugin_hyw-0.3.5.dist-info/METADATA +0 -112
  78. entari_plugin_hyw-0.3.5.dist-info/RECORD +0 -9
@@ -1,142 +1,1005 @@
1
- from dataclasses import dataclass
2
- import html
3
- from typing import List, Text, Union
4
- from typing_extensions import override
5
- from arclet.entari import metadata
6
- from arclet.entari import MessageChain, Session
7
- from arclet.entari.event.base import MessageEvent
8
- from loguru import logger
9
- from satori.exception import ActionFailed
10
- from arclet.entari import MessageChain, At, Image, Quote, Text
11
- from satori import Element
12
- import arclet.letoderea as leto
13
- from arclet.entari import MessageCreatedEvent, Session
14
- from arclet.entari import BasicConfModel, metadata, plugin_config
15
- import httpx
1
+ """
2
+ entari-plugin-hyw - Entari Plugin for HYW
3
+
4
+ Use large language models to interpret chat messages.
5
+ """
6
+
7
+ from dataclasses import dataclass, field
8
+ from importlib.metadata import version as get_version
9
+ from typing import List, Dict, Any, Optional
16
10
  import asyncio
17
- import json
11
+ import os
12
+ import base64
18
13
  import re
19
- from arclet.alconna import (
20
- Args,
21
- Alconna,
22
- AllParam,
23
- MultiVar,
24
- CommandMeta,
25
- )
26
- from arclet.entari import MessageChain, Session, command
27
- from satori.element import Custom, E
14
+ import tempfile
28
15
 
29
- # 导入AI服务模块
30
- from .agent import AgentService, HywConfig
16
+ from arclet.alconna import Alconna, Args, AllParam, Arparma
17
+ from arclet.entari import metadata, listen, Session, plugin_config, BasicConfModel, command
18
+ from arclet.entari import MessageChain, Text, Image, MessageCreatedEvent, Quote, At
19
+ from satori.element import Custom
20
+ from loguru import logger
21
+ from arclet.entari.event.command import CommandReceive
22
+ from arclet.entari.event.lifespan import Cleanup
31
23
 
24
+ # Import from internal hyw_core
25
+ from hyw_core import HywCore, HywCoreConfig, QueryRequest
26
+ from hyw_core.browser_control import (
27
+ ContentRenderer,
28
+ get_content_renderer,
29
+ set_global_renderer,
30
+ close_screenshot_service,
31
+ )
32
+ from hyw_core.browser_control.manager import close_shared_browser
32
33
 
33
- metadata(
34
- name="hyw",
35
- author=[{"name": "kumoSleeping", "email": "zjr2992@outlook.com"}],
36
- version="0.1.0",
37
- description="",
38
- config=HywConfig,
34
+ # Local modules
35
+ from .history import HistoryManager
36
+ from .misc import (
37
+ process_onebot_json,
38
+ process_images,
39
+ resolve_model_name,
40
+ render_refuse_answer,
41
+ render_image_unsupported,
42
+ parse_color,
43
+ RecentEventDeduper,
39
44
  )
45
+ from .filters import parse_filter_syntax
46
+ from .search_cache import SearchResultCache, parse_single_index, parse_multi_indices
47
+
48
+
49
+ try:
50
+ __version__ = get_version("entari_plugin_hyw")
51
+ except Exception:
52
+ __version__ = "4.0.0-rc8"
53
+
54
+
55
+ _event_deduper = RecentEventDeduper()
56
+
57
+
58
+
59
+ @dataclass
60
+ class HywConfig(BasicConfModel):
61
+ """Plugin configuration"""
62
+ admins: List[str] = field(default_factory=list)
63
+ models: List[Dict[str, Any]] = field(default_factory=list)
64
+ question_command: str = "/q"
65
+ web_command: str = "/w"
66
+ help_command: str = "/h"
67
+ language: str = "Simplified Chinese"
68
+ temperature: float = 0.4
69
+
70
+ model_name: Optional[str] = None
71
+ api_key: Optional[str] = None
72
+ base_url: str = "https://openrouter.ai/api/v1"
73
+
74
+ search_engine: str = "duckduckgo"
75
+
76
+ headless: bool = False
77
+ save_conversation: bool = False
78
+ reaction: bool = False
79
+ quote: bool = False
80
+ theme_color: str = "#ff0000"
81
+
82
+ # Main model configuration (used for summary/main LLM calls)
83
+ main: Optional[Dict[str, Any]] = None
84
+
85
+ def __post_init__(self):
86
+ self.theme_color = parse_color(self.theme_color)
87
+
88
+ def to_hyw_core_config(self) -> HywCoreConfig:
89
+ main_cfg = self.main or {}
90
+
91
+ return HywCoreConfig.from_dict({
92
+ "models": self.models,
93
+ "model_name": self.model_name or "",
94
+ "api_key": self.api_key or "",
95
+ "base_url": self.base_url,
96
+ "temperature": self.temperature,
97
+ "search_engine": self.search_engine,
98
+ "headless": self.headless,
99
+ "language": self.language,
100
+ "theme_color": self.theme_color,
101
+
102
+ # Map nested 'main' config to summary stage
103
+ "summary_model": main_cfg.get("model_name"),
104
+ "summary_api_key": main_cfg.get("api_key"),
105
+ "summary_base_url": main_cfg.get("base_url"),
106
+ "summary_extra_body": main_cfg.get("extra_body"),
107
+ })
108
+
40
109
 
41
110
  conf = plugin_config(HywConfig)
42
- agent_service = AgentService(conf)
43
- command_name_list = [conf.hyw_command_name] if isinstance(conf.hyw_command_name, str) else conf.hyw_command_name
44
- alc = Alconna(command_name_list, Args["all_param;?", AllParam], meta=CommandMeta(compact=True,))
45
-
46
-
47
- # Emoji到代码的映射字典
48
- EMOJI_TO_CODE = {
49
- "🐳": "128051",
50
- "❌": "10060",
51
- "🍧": "127847",
52
- "✨": "10024",
53
- "📫": "128235"
54
- }
55
-
56
- async def download_image(url: str) -> bytes:
57
- """下载图片"""
111
+ history_manager = HistoryManager()
112
+ renderer = ContentRenderer(headless=conf.headless)
113
+ set_global_renderer(renderer)
114
+ search_cache = SearchResultCache(ttl_seconds=600.0) # 10 minutes
115
+
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())
119
+
120
+ def get_hyw_core() -> HywCore:
121
+ return _hyw_core
122
+
123
+
124
+ @listen(Cleanup)
125
+ async def cleanup_screenshot_service():
126
+ global _hyw_core
58
127
  try:
59
- async with httpx.AsyncClient(timeout=30.0) as client:
60
- resp = await client.get(url)
61
- if resp.status_code == 200:
62
- return resp.content
63
- else:
64
- raise ActionFailed(f"下载图片失败,状态码: {resp.status_code}")
128
+ if _hyw_core:
129
+ await _hyw_core.close()
130
+ _hyw_core = None
131
+ await close_screenshot_service()
132
+ close_shared_browser()
65
133
  except Exception as e:
66
- raise ActionFailed(f"下载图片失败: {url}, 错误: {str(e)}")
134
+ logger.warning(f"Failed to cleanup: {e}")
67
135
 
68
136
 
69
- def process_onebot_json(json_data_str: str) -> str:
137
+ async def react(session: Session, emoji: str):
138
+ if not conf.reaction: return
70
139
  try:
71
- # 解码HTML实体
72
- json_str = html.unescape(json_data_str)
73
- return json_str
140
+ await session.reaction_create(emoji=emoji)
74
141
  except Exception as e:
75
- return json_data_str
142
+ logger.warning(f"Reaction failed: {e}")
76
143
 
77
144
 
78
- @leto.on(MessageCreatedEvent)
79
- async def on_message_created(message_chain: MessageChain, session: Session[MessageEvent]):
80
- async def react(emoji: str):
145
+ async def process_request(
146
+ session: Session[MessageCreatedEvent],
147
+ all_param: Optional[MessageChain] = None,
148
+ selected_model: Optional[str] = None,
149
+ ) -> None:
150
+ mc = MessageChain(all_param)
151
+ if session.reply:
81
152
  try:
82
- if session.event.login.platform == "onebot":
83
- code = EMOJI_TO_CODE.get(emoji, "10024")
84
- await session.account.protocol.call_api("internal/set_group_reaction", {"group_id": int(session.guild.id), "message_id": int(session.event.message.id), "code": code, "is_add": True})
85
- else:
86
- await session.reaction_create(emoji=emoji)
87
- except ActionFailed:
88
- pass
153
+ reply_msg_id = str(session.reply.origin.id) if hasattr(session.reply.origin, 'id') else None
154
+ if not (reply_msg_id and history_manager.is_bot_message(reply_msg_id)):
155
+ mc.extend(MessageChain(" ") + session.reply.origin.message)
156
+ except Exception:
157
+ mc.extend(MessageChain(" ") + session.reply.origin.message)
158
+
159
+ filtered = mc.get(Text) + mc.get(Image) + mc.get(Custom)
160
+ mc = MessageChain(filtered)
161
+
162
+ text_content = str(mc.get(Text)).strip()
163
+ text_content = re.sub(r'<img[^>]+>', '', text_content, flags=re.IGNORECASE)
164
+
165
+ if not text_content and not mc.get(Image) and not mc.get(Custom):
166
+ return
89
167
 
90
- if session.reply:
168
+ hist_key = None
169
+ if session.reply and hasattr(session.reply.origin, 'id'):
170
+ hist_key = history_manager.get_conversation_id(str(session.reply.origin.id))
171
+
172
+ hist_payload = history_manager.get_history(hist_key) if hist_key else []
173
+ context_id = f"guild_{session.guild.id}" if session.guild else f"user_{session.user.id}"
174
+
175
+ if conf.reaction: await react(session, "✨")
176
+
177
+ try:
178
+ msg_text = str(mc.get(Text)).strip() if mc.get(Text) else ""
179
+ msg_text = re.sub(r'<img[^>]+>', '', msg_text, flags=re.IGNORECASE)
180
+
181
+ if not msg_text and (mc.get(Image) or mc.get(Custom)):
182
+ msg_text = "[图片]"
183
+
184
+ for custom in [e for e in mc if isinstance(e, Custom)]:
185
+ if custom.tag == 'onebot:json':
186
+ if decoded := process_onebot_json(custom.attributes()):
187
+ msg_text += f"\n{decoded}"
188
+ break
189
+
190
+ model = selected_model
191
+ if model:
192
+ resolved, _ = resolve_model_name(model, conf.models)
193
+ if resolved:
194
+ model = resolved
195
+
196
+ images, _ = await process_images(mc, None)
197
+
198
+ # Prepare renderer
199
+ local_renderer = await get_content_renderer()
200
+ render_tab_task = asyncio.create_task(local_renderer.prepare_tab())
201
+
202
+ async def send_noti(msg: str):
203
+ try:
204
+ if conf.quote:
205
+ await session.send([Quote(session.event.message.id), msg])
206
+ else:
207
+ await session.send(msg)
208
+ except Exception as e:
209
+ logger.warning(f"Failed to send notification: {e}")
210
+
211
+ request = QueryRequest(
212
+ user_input=msg_text,
213
+ images=images,
214
+ conversation_history=hist_payload,
215
+ model_name=model,
216
+ send_notification=send_noti
217
+ )
218
+
219
+ with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as tf:
220
+ output_path = tf.name
221
+
222
+ core = get_hyw_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)
226
+
227
+ # 2. Get the warmed-up tab
91
228
  try:
92
- message_chain.extend(session.reply.origin.message)
229
+ tab_id = await render_tab_task
93
230
  except Exception:
94
- pass
95
- message_chain = message_chain.get(Text) + message_chain.get(Image) + message_chain.get(Custom)
96
- res = alc.parse(message_chain)
97
- if not res.matched:
98
- # logger.info(res.error_info)
231
+ tab_id = None
232
+
233
+ display_session_id = history_manager.generate_short_code()
234
+
235
+ if response.should_refuse:
236
+ render_ok = await render_refuse_answer(
237
+ renderer=local_renderer,
238
+ output_path=output_path,
239
+ reason=response.refuse_reason or 'Refused',
240
+ theme_color=conf.theme_color,
241
+ tab_id=tab_id,
242
+ )
243
+ elif not response.success:
244
+ await session.send(f"Error: {response.error}")
245
+ return
246
+ else:
247
+ # 3. Explicit External Render using the Parallel Tab
248
+ render_ok = await core.render(
249
+ markdown_content=response.content,
250
+ output_path=output_path,
251
+ stats={"total_time": response.total_time},
252
+ references=response.references,
253
+ page_references=response.page_references,
254
+ image_references=response.image_references,
255
+ stages_used=response.stages_used,
256
+ tab_id=tab_id
257
+ )
258
+ if render_ok:
259
+ response.image_path = output_path
260
+
261
+ if render_ok:
262
+ with open(output_path, "rb") as f:
263
+ img_data = base64.b64encode(f.read()).decode()
264
+
265
+ msg_chain = MessageChain(Image(src=f'data:image/png;base64,{img_data}'))
266
+ if conf.quote:
267
+ msg_chain = MessageChain(Quote(session.event.message.id)) + msg_chain
268
+
269
+ sent = await session.send(msg_chain)
270
+
271
+ sent_id = next((str(e.id) for e in sent if hasattr(e, 'id')), None) if sent else None
272
+ msg_id = str(session.event.message.id) if hasattr(session.event, 'message') else str(session.event.id)
273
+
274
+ updated_history = hist_payload + [
275
+ {"role": "user", "content": msg_text},
276
+ {"role": "assistant", "content": response.content}
277
+ ]
278
+
279
+ # Save to Memory
280
+ history_manager.remember(
281
+ sent_id, updated_history, [msg_id],
282
+ {"model": model}, context_id, code=display_session_id,
283
+ )
284
+
285
+ # Save to Disk (Debug/Logging)
286
+ if conf.save_conversation:
287
+ # Extract traces from response
288
+ trace = response.stages_trace
289
+ instruct_traces = trace.get("instruct_rounds") if trace else None
290
+
291
+ # Check for web_results in response (needs Core update)
292
+ web_results = getattr(response, "web_results", [])
293
+
294
+ history_manager.save_to_disk(
295
+ key=sent_id,
296
+ image_path=output_path,
297
+ web_results=web_results,
298
+ instruct_traces=instruct_traces,
299
+ vision_trace=None # Vision integrated into Instruct now
300
+ )
301
+
302
+ if os.path.exists(output_path):
303
+ os.remove(output_path)
304
+
305
+ except Exception as e:
306
+ logger.exception(f"Error: {e}")
307
+ await session.send(f"Error: {e}")
308
+
309
+
310
+
311
+ alc = Alconna(conf.question_command, Args["all_param;?", AllParam])
312
+
313
+ @command.on(alc)
314
+ async def handle_question_command(session: Session[MessageCreatedEvent], result: Arparma):
315
+ try:
316
+ mid = str(session.event.message.id) if getattr(session.event, "message", None) else str(session.event.id)
317
+ dedupe_key = f"{getattr(session.account, 'id', 'account')}:{mid}"
318
+ if _event_deduper.seen_recently(dedupe_key):
319
+ return
320
+ except Exception:
321
+ pass
322
+
323
+ args = result.all_matched_args
324
+ all_param = args.get("all_param")
325
+
326
+ # Extract query text
327
+ if all_param:
328
+ if isinstance(all_param, MessageChain):
329
+ query_text = str(all_param.get(Text)).strip()
330
+ else:
331
+ query_text = str(all_param).strip()
332
+ else:
333
+ query_text = ""
334
+
335
+ # Check if replying to a cached search result
336
+ reply_msg_id = None
337
+ if session.reply and hasattr(session.reply.origin, 'id'):
338
+ reply_msg_id = str(session.reply.origin.id)
339
+
340
+ # Quote mode: Use cached search results
341
+ if reply_msg_id:
342
+ cached = search_cache.get(reply_msg_id)
343
+ if cached:
344
+ # Parse indices if provided
345
+ indices = parse_multi_indices(query_text, max_count=3) if query_text else None
346
+
347
+ # Check if too many indices requested (parse_multi_indices returns None if > max_count)
348
+ if query_text and indices is None:
349
+ # Check if it looks like indices but exceeded limit
350
+ if re.match(r'^[\d,、\s\-–]+$', query_text):
351
+ await session.send("最多选择3个结果进行总结")
352
+ search_cache.cleanup()
353
+ return
354
+
355
+ if conf.reaction:
356
+ asyncio.create_task(react(session, "✨"))
357
+
358
+ core = get_hyw_core()
359
+ local_renderer = await get_content_renderer()
360
+ tab_task = asyncio.create_task(local_renderer.prepare_tab())
361
+
362
+ # Collect screenshots for selected pages
363
+ screenshots = []
364
+ if indices:
365
+ # Screenshot mode: capture pages for selected indices
366
+ for idx in indices:
367
+ if idx < len(cached.results):
368
+ url = cached.results[idx].get("url", "")
369
+ if url:
370
+ b64_img = await core.screenshot(url)
371
+ if b64_img:
372
+ screenshots.append(b64_img)
373
+
374
+ if not screenshots:
375
+ try: await tab_task
376
+ except: pass
377
+ await session.send("无法截图所选页面")
378
+ search_cache.cleanup()
379
+ return
380
+
381
+ user_query = f"总结关于 \"{cached.query}\" 的内容"
382
+ else:
383
+ # No indices - summarize based on cached snippets (no screenshots)
384
+ context_parts = []
385
+ for i, res in enumerate(cached.results[:10]):
386
+ title = res.get("title", f"Result {i+1}")
387
+ snippet = res.get("content", "") or res.get("snippet", "")
388
+ context_parts.append(f"## {title}\n{snippet}")
389
+
390
+ context_message = f"基于搜索 \"{cached.query}\" 的结果摘要回答用户问题:\n\n" + "\n\n".join(context_parts)
391
+ user_query = query_text if query_text else f"总结关于 \"{cached.query}\" 的搜索结果"
392
+
393
+ # Build request with screenshots (if any)
394
+ if screenshots:
395
+ request = QueryRequest(
396
+ user_input=user_query,
397
+ images=screenshots,
398
+ conversation_history=[],
399
+ model_name=None,
400
+ )
401
+ else:
402
+ request = QueryRequest(
403
+ user_input=f"{context_message}\n\n用户问题: {user_query}",
404
+ images=[],
405
+ conversation_history=[],
406
+ model_name=None,
407
+ )
408
+
409
+ with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as tf:
410
+ output_path = tf.name
411
+
412
+ response = await core.query(request, output_path=None)
413
+
414
+ try:
415
+ tab_id = await tab_task
416
+ except Exception:
417
+ tab_id = None
418
+
419
+ if response.success and response.content:
420
+ render_ok = await core.render(
421
+ markdown_content=response.content,
422
+ output_path=output_path,
423
+ stats={"total_time": response.total_time},
424
+ references=[],
425
+ page_references=[],
426
+ tab_id=tab_id
427
+ )
428
+
429
+ if render_ok and os.path.exists(output_path):
430
+ with open(output_path, "rb") as f:
431
+ img_data = base64.b64encode(f.read()).decode()
432
+
433
+ msg_chain = MessageChain(Image(src=f'data:image/png;base64,{img_data}'))
434
+ if conf.quote:
435
+ msg_chain = MessageChain(Quote(session.event.message.id)) + msg_chain
436
+
437
+ await session.send(msg_chain)
438
+ os.remove(output_path)
439
+ else:
440
+ await session.send(response.content[:500])
441
+ else:
442
+ await session.send(f"总结失败: {response.error or 'Unknown error'}")
443
+
444
+ search_cache.cleanup()
445
+ return
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
+
524
+ # === Filter Mode: Search + Find matching links + Summarize ===
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)
532
+
533
+ if filter_error:
534
+ await session.send(filter_error)
535
+ return
536
+
537
+ if filters:
538
+ if conf.reaction:
539
+ asyncio.create_task(react(session, "✨"))
540
+
541
+ core = get_hyw_core()
542
+ local_renderer = await get_content_renderer()
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
+
548
+ # Run search and prepare tab in parallel
549
+ search_task = asyncio.create_task(core.search([search_query]))
550
+ tab_task = asyncio.create_task(local_renderer.prepare_tab())
551
+
552
+ results = await search_task
553
+ flat_results = results[0] if results else []
554
+
555
+ if not flat_results:
556
+ try: await tab_task
557
+ except: pass
558
+ await session.send("Search returned no results.")
559
+ return
560
+
561
+ visible = [r for r in flat_results if not r.get("_hidden", False)]
562
+
563
+ # Collect URLs to screenshot
564
+ urls_to_screenshot = []
565
+ for filter_type, filter_value, count in filters:
566
+ if filter_type == 'index':
567
+ idx = filter_value - 1
568
+ if 0 <= idx < len(visible):
569
+ url = visible[idx].get("url", "")
570
+ if url and url not in urls_to_screenshot:
571
+ urls_to_screenshot.append(url)
572
+ else:
573
+ try: await tab_task
574
+ except: pass
575
+ await session.send(f"⚠️ 序号 {filter_value} 超出范围 (1-{len(visible)})")
576
+ return
577
+ else:
578
+ found_count = 0
579
+ for res in visible:
580
+ url = res.get("url", "")
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:
584
+ urls_to_screenshot.append(url)
585
+ found_count += 1
586
+ if found_count >= count:
587
+ break
588
+
589
+ if found_count == 0:
590
+ try: await tab_task
591
+ except: pass
592
+ await session.send(f"⚠️ 未找到包含 \"{filter_value}\" 的链接")
593
+ return
594
+
595
+ if not urls_to_screenshot:
596
+ try: await tab_task
597
+ except: pass
598
+ await session.send("⚠️ 未找到匹配的链接")
599
+ return
600
+
601
+ # Take screenshots with content extraction
602
+ screenshot_tasks = [core.screenshot_with_content(url) for url in urls_to_screenshot]
603
+ screenshot_results = await asyncio.gather(*screenshot_tasks)
604
+
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
+ try: await tab_task
626
+ except: pass
627
+ await session.send("无法截图页面")
628
+ return
629
+
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]}"
636
+
637
+ request = QueryRequest(
638
+ user_input=user_query,
639
+ images=[], # No images, use text content instead
640
+ conversation_history=[],
641
+ model_name=None,
642
+ )
643
+
644
+ with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as tf:
645
+ output_path = tf.name
646
+
647
+ response = await core.query(request, output_path=None)
648
+
649
+ try:
650
+ tab_id = await tab_task
651
+ except Exception:
652
+ tab_id = None
653
+
654
+ if response.success and response.content:
655
+ render_ok = await core.render(
656
+ markdown_content=response.content,
657
+ output_path=output_path,
658
+ stats={"total_time": response.total_time},
659
+ references=[],
660
+ page_references=page_references, # Pass screenshots for rendering
661
+ tab_id=tab_id
662
+ )
663
+
664
+ if render_ok and os.path.exists(output_path):
665
+ with open(output_path, "rb") as f:
666
+ img_data = base64.b64encode(f.read()).decode()
667
+
668
+ msg_chain = MessageChain(Image(src=f'data:image/png;base64,{img_data}'))
669
+ if conf.quote:
670
+ msg_chain = MessageChain(Quote(session.event.message.id)) + msg_chain
671
+
672
+ await session.send(msg_chain)
673
+ os.remove(output_path)
674
+ else:
675
+ await session.send(response.content[:500])
676
+ else:
677
+ await session.send(f"总结失败: {response.error or 'Unknown error'}")
678
+
679
+ return
680
+
681
+ # Normal query mode (no cache context)
682
+ await process_request(session, all_param)
683
+
684
+
685
+ # Search/Web Command (/w)
686
+ alc_search = Alconna(conf.web_command, Args["query;?", AllParam])
687
+
688
+ @command.on(alc_search)
689
+ async def handle_web_command(session: Session[MessageCreatedEvent], result: Arparma):
690
+ """
691
+ Handle web command /w:
692
+ - If query is index + Quote -> Screenshot cached result
693
+ - If query is URL -> Screenshot
694
+ - If query is text -> Search
695
+ """
696
+ query = result.all_matched_args.get("query")
697
+
698
+ # Extract query text
699
+ if query:
700
+ if isinstance(query, MessageChain):
701
+ query = str(query.get(Text)).strip()
702
+ query = str(query).strip()
703
+ else:
704
+ query = ""
705
+
706
+ # Check if replying to a cached search result
707
+ reply_msg_id = None
708
+ if session.reply and hasattr(session.reply.origin, 'id'):
709
+ reply_msg_id = str(session.reply.origin.id)
710
+
711
+ # Quote + Index mode: Screenshot specific cached result
712
+ if reply_msg_id:
713
+ cached = search_cache.get(reply_msg_id)
714
+ if cached:
715
+ # Parse index from query
716
+ idx = parse_single_index(query)
717
+ if idx is None:
718
+ # No valid index - show prompt
719
+ await session.send("请指定序号 (1-10)")
720
+ search_cache.cleanup() # Lazy cleanup
721
+ return
722
+
723
+ if idx >= len(cached.results):
724
+ await session.send(f"序号超出范围 (1-{len(cached.results)})")
725
+ search_cache.cleanup()
726
+ return
727
+
728
+ # Screenshot the cached URL
729
+ target_result = cached.results[idx]
730
+ target_url = target_result.get("url", "")
731
+ if not target_url:
732
+ await session.send("该结果无有效URL")
733
+ search_cache.cleanup()
734
+ return
735
+
736
+ if conf.reaction:
737
+ asyncio.create_task(react(session, "📸"))
738
+
739
+ core = get_hyw_core()
740
+ b64_img = await core.screenshot(target_url)
741
+
742
+ if b64_img:
743
+ msg_chain = MessageChain(Image(src=f'data:image/jpeg;base64,{b64_img}'))
744
+ if conf.quote:
745
+ msg_chain = MessageChain(Quote(session.event.message.id)) + msg_chain
746
+ await session.send(msg_chain)
747
+ else:
748
+ await session.send(f"截图失败: {target_url}")
749
+
750
+ search_cache.cleanup()
751
+ return
752
+ else:
753
+ # Reply to a non-cached message: append reply content to query
754
+ try:
755
+ # session.reply.origin.message is a list, wrap it in MessageChain
756
+ reply_msg = MessageChain(session.reply.origin.message)
757
+ reply_content = str(reply_msg.get(Text)).strip()
758
+ if reply_content:
759
+ query = f"{query} {reply_content}".strip() if query else reply_content
760
+ logger.info(f"/w appended reply content, new query: '{query}'")
761
+ except Exception as e:
762
+ logger.warning(f"/w failed to extract reply content: {e}")
763
+
764
+ # No query and no cache context - nothing to do
765
+ if not query:
99
766
  return
100
767
 
101
- mc = MessageChain(res.all_param) # type: ignore
102
768
  try:
103
- # 获取文本和图片
104
- msg = mc.get(Text).strip() if mc.get(Text) else ""
105
- logger.info(msg)
106
-
107
- # 检查是否有 onebot:json 元素(QQ小程序分享)
108
- if mc.get(Custom):
109
- custom_elements = [e for e in mc if isinstance(e, Custom)]
110
-
111
- # 删除 当前QQ版本不支持此应用,请升级 这句话
112
- for i in msg:
113
- i = str(i).replace("当前QQ版本不支持此应用,请升级", "")
114
- logger.info("删除不支持应用提示")
769
+ core = get_hyw_core()
770
+
771
+ # 1. URL Detection
772
+ url_pattern = re.compile(r'^https?://(?:[-\w./?=&%#]+)')
773
+ if url_pattern.match(query):
774
+ # === URL Screenshot Mode ===
775
+ if conf.reaction: asyncio.create_task(react(session, "📸"))
776
+
777
+ with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as tf:
778
+ output_path = tf.name
779
+
780
+ b64_img = await core.screenshot(query)
781
+
782
+ if b64_img:
783
+ with open(output_path, "wb") as f:
784
+ f.write(base64.b64decode(b64_img))
785
+
786
+ msg_chain = MessageChain(Image(src=f'data:image/jpeg;base64,{b64_img}'))
787
+ if conf.quote:
788
+ msg_chain = MessageChain(Quote(session.event.message.id)) + msg_chain
115
789
 
116
- for custom in custom_elements:
117
- if custom.tag == 'onebot:json':
118
- # await react("📫")
119
- decoded_json = process_onebot_json(custom.attributes())
120
- msg += decoded_json
121
- # 这里假设都是小程序分享,直接返回第一个(只有可能是一个)
122
- break
123
- images = None
124
- if mc.get(Image):
125
- urls = mc[Image].map(lambda x: x.src)
126
- tasks = [download_image(url) for url in urls]
127
- images = await asyncio.gather(*tasks)
128
- await react("🍧")
129
- await react("✨")
130
- # 调用AI服务
131
- res_agent = await agent_service.unified_completion(str(msg), images)
132
- # await react("🐳")
133
- # 发送回复
134
- response_content = str(res_agent.content) if hasattr(res_agent, 'content') else ""
135
- if not response_content.strip():
136
- response_content = "[KEY] :: 信息处理 | 内容获取\n>> [search enable]\n抱歉,获取到的内容可能包含敏感信息,暂时无法显示完整结果。\n[LLM] :: 安全过滤"
137
- await session.send([Quote(session.event.message.id), response_content])
790
+ await session.send(msg_chain)
791
+
792
+ if conf.save_conversation:
793
+ mid = str(session.event.message.id) if getattr(session.event, "message", None) else str(session.event.id)
794
+ context_id = f"guild_{session.guild.id}" if session.guild else "user"
795
+ history_manager.remember(mid, [{"role": "user", "content": f"/w {query}"}], [], {}, context_id=context_id)
796
+ history_manager.save_to_disk(mid, image_path=output_path, web_results=[{"url": query, "title": "Screenshot", "_type": "screenshot"}])
797
+
798
+ os.remove(output_path)
799
+ else:
800
+ await session.send(f"Failed to screenshot URL: {query}")
801
+ return
802
+
803
+ # 2. Search Mode (Fallthrough)
804
+
805
+ # Parse enhanced filter syntax
806
+ filters, search_query, filter_error = parse_filter_syntax(query, max_count=3)
807
+
808
+ if filter_error:
809
+ await session.send(filter_error)
810
+ return
811
+
812
+ # Start search first
813
+ local_renderer = await get_content_renderer()
814
+ search_task = asyncio.create_task(core.search([search_query]))
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())
820
+
821
+ if conf.reaction:
822
+ asyncio.create_task(react(session, "🔍"))
823
+
824
+ results = await search_task
825
+ flat_results = results[0] if results else []
826
+
827
+ if not flat_results:
828
+ if tab_task:
829
+ try: await tab_task
830
+ except: pass
831
+ await session.send("Search returned no results.")
832
+ return
833
+
834
+ visible = [r for r in flat_results if not r.get("_hidden", False)]
835
+
836
+ if not visible:
837
+ if tab_task:
838
+ try: await tab_task
839
+ except: pass
840
+ await session.send("Search returned no visible results.")
841
+ return
842
+
843
+ # === Filter Mode: Screenshot matching links (NO tab needed) ===
844
+ if filters:
845
+
846
+ urls_to_screenshot = []
847
+
848
+ for filter_type, filter_value, count in filters:
849
+ if filter_type == 'index':
850
+ # Index-based (1-based)
851
+ idx = filter_value - 1
852
+ if 0 <= idx < len(visible):
853
+ url = visible[idx].get("url", "")
854
+ if url and url not in urls_to_screenshot:
855
+ urls_to_screenshot.append(url)
856
+ else:
857
+ await session.send(f"⚠️ 序号 {filter_value} 超出范围 (1-{len(visible)})")
858
+ return
859
+ else:
860
+ # Link filter: find URLs containing filter term
861
+ found_count = 0
862
+ for res in visible:
863
+ url = res.get("url", "")
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:
867
+ urls_to_screenshot.append(url)
868
+ found_count += 1
869
+ if found_count >= count:
870
+ break
871
+
872
+ if found_count == 0:
873
+ await session.send(f"⚠️ 未找到包含 \"{filter_value}\" 的链接")
874
+ return
875
+
876
+ if not urls_to_screenshot:
877
+ await session.send("⚠️ 未找到匹配的链接")
878
+ return
879
+
880
+ if conf.reaction:
881
+ asyncio.create_task(react(session, "📸"))
882
+
883
+ # Take screenshots concurrently
884
+ screenshot_tasks = [core.screenshot(url) for url in urls_to_screenshot]
885
+ screenshot_results = await asyncio.gather(*screenshot_tasks)
886
+
887
+ images = [Image(src=f'data:image/jpeg;base64,{b64}') for b64 in screenshot_results if b64]
888
+
889
+ if images:
890
+ msg_chain = MessageChain(images)
891
+ if conf.quote:
892
+ msg_chain = MessageChain(Quote(session.event.message.id)) + msg_chain
893
+ await session.send(msg_chain)
894
+
895
+ if conf.save_conversation:
896
+ mid = str(session.event.message.id) if getattr(session.event, "message", None) else str(session.event.id)
897
+ context_id = f"guild_{session.guild.id}" if session.guild else "user"
898
+ history_manager.remember(mid, [{"role": "user", "content": f"/w {query}"}], [], {}, context_id=context_id)
899
+ else:
900
+ await session.send("截图失败")
901
+ return
902
+
903
+ # === Normal Search Mode: Render search results as Sources card ===
904
+
905
+ # Build references from search results for Sources card
906
+ references = []
907
+ for i, res in enumerate(visible[:10]):
908
+ references.append({
909
+ "title": res.get("title", f"Result {i+1}"),
910
+ "url": res.get("url", ""),
911
+ "snippet": res.get("content", "") or res.get("snippet", ""),
912
+ "original_idx": i + 1,
913
+ })
914
+
915
+ try:
916
+ tab_id = await tab_task
917
+ except Exception:
918
+ tab_id = None
919
+
920
+ with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as tf:
921
+ output_path = tf.name
922
+
923
+ # Render Sources card with search results (no markdown content, just references)
924
+ render_ok = await core.render(
925
+ markdown_content=f"# 搜索结果: {search_query}",
926
+ output_path=output_path,
927
+ stats={"total_time": 0},
928
+ references=references,
929
+ page_references=[],
930
+ stages_used=[{"name": "search", "description": f"搜索 \"{search_query}\"", "time": 0}],
931
+ tab_id=tab_id
932
+ )
933
+
934
+ if render_ok and os.path.exists(output_path):
935
+ with open(output_path, "rb") as f:
936
+ img_data = base64.b64encode(f.read()).decode()
937
+
938
+ msg_chain = MessageChain(Image(src=f'data:image/png;base64,{img_data}'))
939
+ if conf.quote:
940
+ msg_chain = MessageChain(Quote(session.event.message.id)) + msg_chain
941
+
942
+ sent = await session.send(msg_chain)
943
+
944
+ # Store in cache for future /w and /q lookups
945
+ sent_id = next((str(e.id) for e in sent if hasattr(e, 'id')), None) if sent else None
946
+ if sent_id:
947
+ search_cache.store(sent_id, visible[:10], search_query)
948
+
949
+ if conf.save_conversation:
950
+ mid = str(session.event.message.id) if getattr(session.event, "message", None) else str(session.event.id)
951
+ context_id = f"guild_{session.guild.id}" if session.guild else "user"
952
+ history_manager.remember(mid, [{"role": "user", "content": f"/w {query}"}], [], {}, context_id=context_id)
953
+
954
+ os.remove(output_path)
955
+ else:
956
+ await session.send("渲染搜索结果失败")
957
+
958
+ search_cache.cleanup() # Lazy cleanup
959
+
138
960
  except Exception as e:
139
- await react("")
140
- raise e
961
+ logger.error(f"Search command failed: {e}")
962
+ await session.send(f"Search error: {e}")
963
+
964
+
965
+ metadata("hyw", author=[{"name": "kumoSleeping", "email": "zjr2992@outlook.com"}], version=__version__, config=HywConfig)
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)
141
1002
 
142
-
1003
+ @listen(CommandReceive)
1004
+ async def remove_at(content: MessageChain):
1005
+ return content.lstrip(At)