entari-plugin-hyw 4.0.0rc4__py3-none-any.whl → 4.0.0rc6__py3-none-any.whl

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

Potentially problematic release.


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

Files changed (30) hide show
  1. entari_plugin_hyw/__init__.py +216 -75
  2. entari_plugin_hyw/assets/card-dist/index.html +70 -79
  3. entari_plugin_hyw/browser/__init__.py +10 -0
  4. entari_plugin_hyw/browser/engines/base.py +13 -0
  5. entari_plugin_hyw/browser/engines/bing.py +95 -0
  6. entari_plugin_hyw/browser/engines/duckduckgo.py +137 -0
  7. entari_plugin_hyw/browser/engines/google.py +155 -0
  8. entari_plugin_hyw/browser/landing.html +172 -0
  9. entari_plugin_hyw/browser/manager.py +153 -0
  10. entari_plugin_hyw/browser/service.py +304 -0
  11. entari_plugin_hyw/card-ui/src/App.vue +526 -182
  12. entari_plugin_hyw/card-ui/src/components/MarkdownContent.vue +7 -11
  13. entari_plugin_hyw/card-ui/src/components/StageCard.vue +33 -30
  14. entari_plugin_hyw/card-ui/src/types.ts +9 -0
  15. entari_plugin_hyw/definitions.py +155 -0
  16. entari_plugin_hyw/history.py +111 -33
  17. entari_plugin_hyw/misc.py +34 -0
  18. entari_plugin_hyw/modular_pipeline.py +384 -0
  19. entari_plugin_hyw/render_vue.py +326 -239
  20. entari_plugin_hyw/search.py +95 -708
  21. entari_plugin_hyw/stage_base.py +92 -0
  22. entari_plugin_hyw/stage_instruct.py +345 -0
  23. entari_plugin_hyw/stage_instruct_deepsearch.py +104 -0
  24. entari_plugin_hyw/stage_summary.py +164 -0
  25. {entari_plugin_hyw-4.0.0rc4.dist-info → entari_plugin_hyw-4.0.0rc6.dist-info}/METADATA +4 -4
  26. {entari_plugin_hyw-4.0.0rc4.dist-info → entari_plugin_hyw-4.0.0rc6.dist-info}/RECORD +28 -16
  27. entari_plugin_hyw/pipeline.py +0 -1219
  28. entari_plugin_hyw/prompts.py +0 -47
  29. {entari_plugin_hyw-4.0.0rc4.dist-info → entari_plugin_hyw-4.0.0rc6.dist-info}/WHEEL +0 -0
  30. {entari_plugin_hyw-4.0.0rc4.dist-info → entari_plugin_hyw-4.0.0rc6.dist-info}/top_level.txt +0 -0
@@ -19,10 +19,10 @@ from loguru import logger
19
19
  import arclet.letoderea as leto
20
20
  from arclet.entari.event.command import CommandReceive
21
21
 
22
- from .pipeline import ProcessingPipeline
22
+ from .modular_pipeline import ModularPipeline
23
23
  from .history import HistoryManager
24
- from .render_vue import ContentRenderer
25
- from .misc import process_onebot_json, process_images, resolve_model_name, render_refuse_answer, REFUSE_ANSWER_MARKDOWN
24
+ from .render_vue import ContentRenderer, get_content_renderer
25
+ from .misc import process_onebot_json, process_images, resolve_model_name, render_refuse_answer, render_image_unsupported, REFUSE_ANSWER_MARKDOWN
26
26
  from arclet.entari.event.lifespan import Cleanup
27
27
 
28
28
  import os
@@ -83,86 +83,128 @@ class _RecentEventDeduper:
83
83
 
84
84
  _event_deduper = _RecentEventDeduper()
85
85
 
86
+ @dataclass
87
+ class ModelConfig:
88
+ """Model configuration for a specific stage."""
89
+ model_name: Optional[str] = None
90
+ api_key: Optional[str] = None
91
+ base_url: Optional[str] = None
92
+ extra_body: Optional[Dict[str, Any]] = None
93
+ model_provider: Optional[str] = None
94
+ input_price: Optional[float] = None
95
+ output_price: Optional[float] = None
96
+ image_input: bool = True
97
+
98
+
86
99
  @dataclass
87
100
  class HywConfig(BasicConfModel):
101
+ # Core Settings
88
102
  admins: List[str] = field(default_factory=list)
89
103
  models: List[Dict[str, Any]] = field(default_factory=list)
90
104
  question_command: str = "/q"
105
+ language: str = "Simplified Chinese"
106
+ temperature: float = 0.4
107
+
108
+ # Root-level defaults (backward compatible)
91
109
  model_name: Optional[str] = None
92
110
  api_key: Optional[str] = None
93
111
  base_url: str = "https://openrouter.ai/api/v1"
94
- vision_model_name: Optional[str] = None
95
- vision_api_key: Optional[str] = None
96
- language: str = "Simplified Chinese"
97
- vision_base_url: Optional[str] = None
98
- instruct_model_name: Optional[str] = None
99
- instruct_api_key: Optional[str] = None
100
- instruct_base_url: Optional[str] = None
101
- search_base_url: str = "https://lite.duckduckgo.com/lite/?q={query}"
102
- image_search_base_url: str = "https://duckduckgo.com/?q={query}&iax=images&ia=images"
103
- headless: bool = False
104
- save_conversation: bool = False
105
- icon: str = "openai"
106
- render_timeout_ms: int = 6000
107
- render_image_timeout_ms: int = 3000
108
112
  extra_body: Optional[Dict[str, Any]] = None
109
- vision_extra_body: Optional[Dict[str, Any]] = None
110
- instruct_extra_body: Optional[Dict[str, Any]] = None
111
- enable_browser_fallback: bool = False
112
- reaction: bool = False
113
- quote: bool = True
114
- temperature: float = 0.4
115
- # Billing configuration (price per million tokens)
116
- input_price: Optional[float] = None # $ per 1M input tokens
117
- output_price: Optional[float] = None # $ per 1M output tokens
118
- # Vision model pricing overrides (defaults to main model pricing if not set)
119
- vision_input_price: Optional[float] = None
120
- vision_output_price: Optional[float] = None
121
- # Instruct model pricing overrides (defaults to main model pricing if not set)
122
- instruct_input_price: Optional[float] = None
123
- instruct_output_price: Optional[float] = None
124
- # Provider Names
125
- search_name: str = "DuckDuckGo"
126
- search_provider: str = "crawl4ai" # crawl4ai | httpx | ddgs
127
- fetch_provider: str = "crawl4ai" # crawl4ai | jinaai
128
- jina_api_key: Optional[str] = None # Optional API key for Jina AI
129
113
  model_provider: Optional[str] = None
130
- vision_model_provider: Optional[str] = None
131
- instruct_model_provider: Optional[str] = None
114
+ input_price: Optional[float] = None
115
+ output_price: Optional[float] = None
116
+
117
+ # Nested Stage Configs
118
+ instruct: Optional[ModelConfig] = None
119
+ qa: Optional[ModelConfig] = None
120
+ main: Optional[ModelConfig] = None # Summary stage
132
121
 
133
122
  # Search/Fetch Settings
134
- search_timeout: float = 10.0
135
- search_retries: int = 2
136
- fetch_timeout: float = 15.0
137
- fetch_max_results: int = 5
138
- fetch_blocked_domains: Optional[List[str]] = None
123
+ search_engine: str = "google"
124
+
125
+ # Rendering Settings
126
+ headless: bool = False
127
+ render_timeout_ms: int = 6000
128
+ render_image_timeout_ms: int = 3000
129
+
130
+ # Bot Behavior
131
+ save_conversation: bool = False
132
+ reaction: bool = False
133
+ quote: bool = False
139
134
 
140
- # Fetch Model Config
141
- fetch_model_name: Optional[str] = None
142
- fetch_api_key: Optional[str] = None
143
- fetch_base_url: Optional[str] = None
144
- fetch_extra_body: Optional[Dict[str, Any]] = None
145
- fetch_input_price: Optional[float] = None
146
- fetch_output_price: Optional[float] = None
147
- # Summary Model Config
148
- summary_model_name: Optional[str] = None
149
- summary_api_key: Optional[str] = None
150
- summary_base_url: Optional[str] = None
151
- summary_extra_body: Optional[Dict[str, Any]] = None
152
- summary_input_price: Optional[float] = None
153
- summary_output_price: Optional[float] = None
154
135
  # UI Theme
155
- theme_color: str = "#ef4444" # Tailwind red-500, supports hex/RGB/color names
136
+ theme_color: str = "#ff0000"
156
137
 
157
138
  def __post_init__(self):
158
139
  """Parse and normalize theme color after initialization."""
159
140
  self.theme_color = parse_color(self.theme_color)
160
-
141
+ # Convert dicts to ModelConfig if needed
142
+ if isinstance(self.instruct, dict):
143
+ self.instruct = ModelConfig(**self.instruct)
144
+ if isinstance(self.qa, dict):
145
+ self.qa = ModelConfig(**self.qa)
146
+ if isinstance(self.main, dict):
147
+ self.main = ModelConfig(**self.main)
148
+
149
+ def get_model_config(self, stage: str) -> Dict[str, Any]:
150
+ """
151
+ Get resolved model config for a stage.
152
+
153
+ Args:
154
+ stage: "instruct", "qa", or "main" (summary)
155
+
156
+ Returns:
157
+ Dict with model_name, api_key, base_url, extra_body, etc.
158
+ """
159
+ # Determine primary and secondary config sources
160
+ primary = None
161
+ secondary = None
162
+
163
+ if stage == "instruct":
164
+ primary = self.instruct
165
+ secondary = self.main # Fallback to main
166
+ elif stage == "qa":
167
+ # QA fallback to main as well if ever used
168
+ primary = self.qa
169
+ secondary = self.main
170
+ elif stage == "main":
171
+ primary = self.main
172
+
173
+ # Build result with fallback logic
174
+ def resolve(field_name: str, is_essential: bool = True):
175
+ """Resolve a field with fallback: Primary -> Secondary -> Root."""
176
+ # 1. Try Primary
177
+ val = getattr(primary, field_name, None) if primary else None
178
+
179
+ # 2. Try Secondary (if value missing)
180
+ if val is None and secondary:
181
+ val = getattr(secondary, field_name, None)
182
+
183
+ # 3. Try Root (if value still missing)
184
+ if val is None:
185
+ val = getattr(self, field_name, None)
186
+ return val
187
+
188
+ return {
189
+ "model_name": resolve("model_name"),
190
+ "api_key": resolve("api_key"),
191
+ "base_url": resolve("base_url"),
192
+ "extra_body": resolve("extra_body", is_essential=False),
193
+ "model_provider": resolve("model_provider", is_essential=False),
194
+ "input_price": resolve("input_price", is_essential=False),
195
+ "output_price": resolve("output_price", is_essential=False),
196
+ }
161
197
 
162
198
 
163
199
  conf = plugin_config(HywConfig)
164
200
  history_manager = HistoryManager()
165
- renderer = ContentRenderer()
201
+ renderer = ContentRenderer(headless=conf.headless)
202
+ from .render_vue import set_global_renderer
203
+ set_global_renderer(renderer)
204
+
205
+ # Pre-start Crawl4AI browser for fast fetching/screenshots
206
+ from .browser.service import prestart_browser, close_screenshot_service
207
+ # prestart_browser(headless=conf.headless) # Removed to avoid RuntimeError: no running event loop
166
208
 
167
209
 
168
210
  class GlobalCache:
@@ -170,6 +212,18 @@ class GlobalCache:
170
212
 
171
213
  global_cache = GlobalCache()
172
214
 
215
+
216
+ @listen(Cleanup)
217
+ async def cleanup_screenshot_service():
218
+ """Cleanup shared browser on shutdown."""
219
+ try:
220
+ await close_screenshot_service()
221
+ # Also close the shared browser manager
222
+ from .browser.manager import close_shared_browser
223
+ await close_shared_browser()
224
+ except Exception as e:
225
+ logger.warning(f"Failed to cleanup browser services: {e}")
226
+
173
227
  async def react(session: Session, emoji: str):
174
228
  if not conf.reaction: return
175
229
  try:
@@ -185,9 +239,7 @@ async def process_request(
185
239
  conversation_key_override: Optional[str] = None,
186
240
  local_mode: bool = False,
187
241
  ) -> None:
188
- logger.info(f"Processing request: {all_param}")
189
242
  mc = MessageChain(all_param)
190
- logger.info(f"reply: {session.reply}")
191
243
  if session.reply:
192
244
  try:
193
245
  # Check if reply is from self (the bot)
@@ -197,12 +249,10 @@ async def process_request(
197
249
 
198
250
  if reply_msg_id and history_manager.is_bot_message(reply_msg_id):
199
251
  is_bot = True
200
- logger.info(f"Reply target {reply_msg_id} identified as bot message via history")
201
252
 
202
253
  if is_bot:
203
- logger.info("Reply is from me - ignoring content")
254
+ pass # Reply is from bot - ignoring
204
255
  else:
205
- logger.info(f"Reply is from user (or unknown) - including content")
206
256
  mc.extend(MessageChain(" ") + session.reply.origin.message)
207
257
  except Exception as e:
208
258
  logger.warning(f"Failed to process reply origin: {e}")
@@ -211,7 +261,7 @@ async def process_request(
211
261
  # Filter and reconstruct MessageChain
212
262
  filtered_elements = mc.get(Text) + mc.get(Image) + mc.get(Custom)
213
263
  mc = MessageChain(filtered_elements)
214
- logger.info(f"mc: {mc}")
264
+
215
265
 
216
266
  text_content = str(mc.get(Text)).strip()
217
267
  # Remove HTML image tags from text content to prevent "unreasonable code behavior"
@@ -263,10 +313,56 @@ async def process_request(
263
313
  logger.warning(f"Vision model resolution warning for {vision_model}: {err_v}")
264
314
 
265
315
  images, err = await process_images(mc, vision_model)
316
+
317
+ # Check image input support
318
+ model_cfg_dict = next((m for m in conf.models if m.get("name") == model), None)
319
+ image_input_supported = True
320
+ if model_cfg_dict:
321
+ image_input_supported = model_cfg_dict.get("image_input", True)
322
+
323
+ # Log inferenced content mode
324
+ inferred_content_mode = "image" if image_input_supported else "text"
325
+ logger.info(f"Process Request: Model '{model}' Image Input: {image_input_supported} -> Mode: {inferred_content_mode}")
326
+
327
+ if images and not image_input_supported:
328
+ logger.warning(f"Model '{model}' does not support images, but user sent {len(images)} images.")
329
+
330
+ # Start renderer for the unsupported card
331
+ renderer = await get_content_renderer()
332
+ render_tab_task = asyncio.create_task(renderer.prepare_tab())
333
+
334
+ # Wait for tab and render unsupported
335
+ try:
336
+ tab_id = await render_tab_task
337
+ except Exception as e:
338
+ tab_id = None
339
+
340
+ import tempfile
341
+ with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as tf:
342
+ output_path = tf.name
343
+
344
+ render_ok = await render_image_unsupported(
345
+ renderer=renderer,
346
+ output_path=output_path,
347
+ theme_color=conf.theme_color,
348
+ tab_id=tab_id
349
+ )
350
+
351
+ if render_ok:
352
+ with open(output_path, "rb") as f:
353
+ img_data = base64.b64encode(f.read()).decode()
354
+ await session.send(MessageChain(Image(src=f'data:image/png;base64,{img_data}')))
355
+ if os.path.exists(output_path):
356
+ os.remove(output_path)
357
+ return
358
+
359
+ renderer = await get_content_renderer()
360
+ render_tab_task = asyncio.create_task(renderer.prepare_tab())
361
+ tab_id = None
266
362
 
267
363
  # Call Pipeline directly
268
364
  safe_input = msg_text
269
- pipeline = ProcessingPipeline(conf)
365
+ pipeline = ModularPipeline(conf)
270
366
  try:
271
367
  resp = await pipeline.execute(
272
368
  safe_input,
@@ -294,6 +390,13 @@ async def process_request(
294
390
  content = final_resp.get("llm_response", "")
295
391
  structured = final_resp.get("structured_response", {})
296
392
 
393
+ # Wait for tab preparation if needed (should be ready by now)
394
+ try:
395
+ tab_id = await render_tab_task
396
+ except Exception as e:
397
+ logger.warning(f"Failed to prepare render tab: {e}")
398
+ tab_id = None
399
+
297
400
  # Render
298
401
  import tempfile
299
402
  with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as tf:
@@ -319,23 +422,24 @@ async def process_request(
319
422
  output_path=output_path,
320
423
  reason=final_resp.get('refuse_reason', 'Instruct 专家分配此任务流程失败,请尝试提出其他问题~'),
321
424
  theme_color=conf.theme_color,
425
+ tab_id=tab_id,
322
426
  )
323
427
  else:
324
428
  render_ok = await renderer.render(
325
429
  markdown_content=content,
326
430
  output_path=output_path,
431
+ tab_id=tab_id,
327
432
  stats=stats_to_render,
328
433
  references=structured.get("references", []),
329
434
  page_references=structured.get("page_references", []),
330
435
  image_references=structured.get("image_references", []),
331
436
  stages_used=final_resp.get("stages_used", []),
332
- image_timeout=conf.render_image_timeout_ms,
333
437
  theme_color=conf.theme_color,
334
438
  )
335
439
 
336
440
  # Send & Save
337
441
  if not render_ok:
338
- logger.error("Render failed; skipping reply. Check Crawl4AI rendering status.")
442
+ logger.error("Render failed; skipping reply.")
339
443
  if os.path.exists(output_path):
340
444
  try:
341
445
  os.remove(output_path)
@@ -375,6 +479,24 @@ async def process_request(
375
479
  code=display_session_id,
376
480
  )
377
481
 
482
+ if conf.save_conversation and sent_id:
483
+ try:
484
+ # Pass web_results to save fetched pages as markdown, and output image
485
+ history_manager.save_to_disk(
486
+ sent_id,
487
+ web_results=final_resp.get("web_results"),
488
+ image_path=output_path if 'output_path' in locals() else None
489
+ )
490
+ except Exception as e:
491
+ logger.warning(f"Failed to save conversation: {e}")
492
+
493
+ # Cleanup temp image
494
+ if 'output_path' in locals() and output_path and os.path.exists(output_path):
495
+ try:
496
+ os.remove(output_path)
497
+ except Exception:
498
+ pass
499
+
378
500
 
379
501
 
380
502
 
@@ -391,9 +513,29 @@ async def process_request(
391
513
  try:
392
514
  # Use a temporary ID for error cases
393
515
  error_id = f"error_{int(time.time())}_{secrets.token_hex(4)}"
394
- history_manager.remember(error_id, resp.get("conversation_history", []), [], {"model": model_used if 'model_used' in locals() else "unknown", "error": str(e)}, context_id, code=display_session_id if 'display_session_id' in locals() else None)
395
- # history_manager.save_to_disk(error_id)
396
- logger.info(f"Saved error conversation memory to {error_id}")
516
+
517
+ # Try to salvage history
518
+ partial_hist = []
519
+ if 'resp' in locals() and resp:
520
+ partial_hist = resp.get("conversation_history", [])
521
+ elif 'context' in locals() and context and hasattr(context, 'instruct_history'):
522
+ partial_hist = context.instruct_history
523
+
524
+ related_ids = []
525
+ if 'session' in locals():
526
+ msg_id = str(session.event.message.id) if hasattr(session.event, 'message') else str(session.event.id)
527
+ related_ids = [msg_id]
528
+
529
+ history_manager.remember(error_id, partial_hist, related_ids, {"model": "error", "error": str(e)}, context_id, code=display_session_id if 'display_session_id' in locals() else None)
530
+
531
+ # Save debug data on error
532
+ web_res = context.web_results if 'context' in locals() and context else []
533
+
534
+ history_manager.save_to_disk(
535
+ error_id,
536
+ web_results=web_res
537
+ )
538
+
397
539
  except Exception as save_err:
398
540
  logger.error(f"Failed to save error conversation: {save_err}")
399
541
 
@@ -419,9 +561,8 @@ async def handle_question_command(session: Session[MessageCreatedEvent], result:
419
561
  logger.info(f"Question Command Triggered. Message: {session.event.message}")
420
562
 
421
563
  args = result.all_matched_args
422
- logger.info(f"Matched Args: {args}")
423
564
 
424
- await process_request(session, args.get("all_param"), selected_model=None, selected_vision_model=None, conversation_key_override=None, local_mode=False)
565
+ await process_request(session, args.get("all_param"), selected_model=None, selected_vision_model=None, conversation_key_override=None)
425
566
 
426
567
  metadata("hyw", author=[{"name": "kumoSleeping", "email": "zjr2992@outlook.com"}], version=__version__, config=HywConfig)
427
568