From 483048e3dc6da452d088339d19b7a5e20d72fec9 Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Fri, 27 Feb 2026 22:03:17 +0800 Subject: [PATCH 01/12] =?UTF-8?q?=20=E5=90=8C=E6=AD=A5=E4=B8=BB=E5=88=86?= =?UTF-8?q?=E6=94=AF=20(#5533)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add bocha web search tool (#4902) * add bocha web search tool * Revert "add bocha web search tool" This reverts commit 1b36d75a17b4c4751828f31f6759357cd2d4000a. * add bocha web search tool * fix: correct temporary_cache spelling and update supported tools for web search * ruff --------- Co-authored-by: Soulter <905617992@qq.com> * fix: messages[x] assistant content must contain at least one part (#4928) * fix: messages[x] assistant content must contain at least one part fixes: #4876 * ruff format * chore: bump version to 4.14.5 (#4930) * feat: implement feishu / lark media file handling utilities for file, audio and video processing (#4938) * feat: implement media file handling utilities for audio and video processing * feat: refactor file upload handling for audio and video in LarkMessageEvent * feat: add cleanup for failed audio and video conversion outputs in media_utils * feat: add utility methods for sending messages and uploading files in LarkMessageEvent * fix: correct spelling of 'temporary' in SharedPreferences class * perf: optimize webchat and wecom ai queue lifecycle (#4941) * perf: optimize webchat and wecom ai queue lifecycle * perf: enhance webchat back queue management with conversation ID support * fix: localize provider source config UI (#4933) * fix: localize provider source ui * feat: localize provider metadata keys * chore: add provider metadata translations * chore: format provider i18n changes * fix: preserve metadata fields in i18n conversion * fix: internationalize platform config and dialog * fix: add Weixin official account platform icon --------- Co-authored-by: Soulter <905617992@qq.com> * chore: bump version to 4.14.6 * feat: add provider-souce-level proxy (#4949) * feat: 添加 Provider 级别代理支持及请求失败日志 * refactor: simplify provider source configuration structure * refactor: move env proxy fallback logic to log_connection_failure * refactor: update client proxy handling and add terminate method for cleanup * refactor: update no_proxy configuration to remove redundant subnet --------- Co-authored-by: Soulter <905617992@qq.com> * feat(ComponentPanel): implement permission management for dashboard (#4887) * feat(backend): add permission update api * feat(useCommandActions): add updatePermission action and translations * feat(dashboard): implement permission editing ui * style: fix import sorting in command.py * refactor(backend): extract permission update logic to service * feat(i18n): add success and failure messages for command updates --------- Co-authored-by: Soulter <905617992@qq.com> * feat: 允许 LLM 预览工具返回的图片并自主决定是否发送 (#4895) * feat: 允许 LLM 预览工具返回的图片并自主决定是否发送 * 复用 send_message_to_user 替代独立的图片发送工具 * feat: implement _HandleFunctionToolsResult class for improved tool response handling * docs: add path handling guidelines to AGENTS.md --------- Co-authored-by: Soulter <905617992@qq.com> * feat(telegram): 添加媒体组(相册)支持 / add media group (album) support (#4893) * feat(telegram): 添加媒体组(相册)支持 / add media group (album) support ## 功能说明 支持 Telegram 的媒体组消息(相册),将多张图片/视频合并为一条消息处理,而不是分散成多条消息。 ## 主要改动 ### 1. 初始化媒体组缓存 (__init__) - 添加 `media_group_cache` 字典存储待处理的媒体组消息 - 使用 2.5 秒超时收集媒体组消息(基于社区最佳实践) - 最大等待时间 10 秒(防止永久等待) ### 2. 消息处理流程 (message_handler) - 检测 `media_group_id` 判断是否为媒体组消息 - 媒体组消息走特殊处理流程,避免分散处理 ### 3. 媒体组消息缓存 (handle_media_group_message) - 缓存收到的媒体组消息 - 使用 APScheduler 实现防抖(debounce)机制 - 每收到新消息时重置超时计时器 - 超时后触发统一处理 ### 4. 媒体组合并处理 (process_media_group) - 从缓存中取出所有媒体项 - 使用第一条消息作为基础(保留文本、回复等信息) - 依次添加所有图片、视频、文档到消息链 - 将合并后的消息发送到处理流程 ## 技术方案论证 Telegram Bot API 在处理媒体组时的设计限制: 1. 将媒体组的每个消息作为独立的 update 发送 2. 每个 update 带有相同的 `media_group_id` 3. **不提供**组的总数、结束标志或一次性完整组的机制 因此,bot 必须自行收集消息,并通过硬编码超时(timeout/delay)等待可能延迟到达的消息。 这是目前唯一可靠的方案,被官方实现、主流框架和开发者社区广泛采用。 ### 官方和社区证据: - **Telegram Bot API 服务器实现(tdlib)**:明确指出缺少结束标志或总数信息 https://github.com/tdlib/telegram-bot-api/issues/643 - **Telegram Bot API 服务器 issue**:讨论媒体组处理的不便性,推荐使用超时机制 https://github.com/tdlib/telegram-bot-api/issues/339 - **Telegraf(Node.js 框架)**:专用媒体组中间件使用 timeout 控制等待时间 https://github.com/DieTime/telegraf-media-group - **StackOverflow 讨论**:无法一次性获取媒体组所有文件,必须手动收集 https://stackoverflow.com/questions/50180048/telegram-api-get-all-uploaded-photos-by-media-group-id - **python-telegram-bot 社区**:确认媒体组消息单独到达,需手动处理 https://github.com/python-telegram-bot/python-telegram-bot/discussions/3143 - **Telegram Bot API 官方文档**:仅定义 `media_group_id` 为可选字段,不提供获取完整组的接口 https://core.telegram.org/bots/api#message ## 实现细节 - 使用 2.5 秒超时收集媒体组消息(基于社区最佳实践) - 最大等待时间 10 秒(防止永久等待) - 采用防抖(debounce)机制:每收到新消息重置计时器 - 利用 APScheduler 实现延迟处理和任务调度 ## 测试验证 - ✅ 发送 5 张图片相册,成功合并为一条消息 - ✅ 保留原始文本说明和回复信息 - ✅ 支持图片、视频、文档混合的媒体组 - ✅ 日志显示 Processing media group with 5 items ## 代码变更 - 文件:astrbot/core/platform/sources/telegram/tg_adapter.py - 新增代码:124 行 - 新增方法:handle_media_group_message(), process_media_group() Co-Authored-By: Claude Sonnet 4.5 * refactor(telegram): 优化媒体组处理性能和可靠性 根据代码审查反馈改进: 1. 实现 media_group_max_wait 防止无限延迟 - 跟踪媒体组创建时间,超过最大等待时间立即处理 - 最坏情况下 10 秒内必定处理,防止消息持续到达导致无限延迟 2. 移除手动 job 查找优化性能 - 删除 O(N) 的 get_jobs() 循环扫描 - 依赖 replace_existing=True 自动替换任务 3. 重用 convert_message 减少代码重复 - 统一所有媒体类型转换逻辑 - 未来添加新媒体类型只需修改一处 Co-Authored-By: Claude Sonnet 4.5 * fix(telegram): handle missing message in media group processing and improve logging messages --------- Co-authored-by: Ubuntu Co-authored-by: Claude Sonnet 4.5 Co-authored-by: Soulter <905617992@qq.com> * feat: add welcome feature with localized content and onboarding steps * fix: correct height attribute to max-height for dialog component * feat: supports electron app (#4952) * feat: add desktop wrapper with frontend-only packaging * docs: add desktop build docs and track dashboard lockfile * fix: track desktop lockfile for npm ci * fix: allow custom install directory for windows installer * chore: migrate desktop workflow to pnpm * fix(desktop): build AppImage only on Linux * fix(desktop): harden packaged startup and backend bundling * fix(desktop): adapt packaged restart and plugin dependency flow * fix(desktop): prevent backend respawn race on quit * fix(desktop): prefer pyproject version for desktop packaging * fix(desktop): improve startup loading UX and reduce flicker * ci: add desktop multi-platform release workflow * ci: fix desktop release build and mac runner labels * ci: disable electron-builder auto publish in desktop build * ci: avoid electron-builder publish path in build matrix * ci: normalize desktop release artifact names * ci: exclude blockmap files from desktop release assets * ci: prefix desktop release assets with AstrBot and purge blockmaps * feat: add electron bridge types and expose backend control methods in preload script * Update startup screen assets and styles - Changed the icon from PNG to SVG format for better scalability. - Updated the border color from #d0d0d0 to #eeeeee for a softer appearance. - Adjusted the width of the startup screen from 460px to 360px for improved responsiveness. * Update .gitignore to include package.json * chore: remove desktop gitkeep ignore exceptions * docs: update desktop troubleshooting for current runtime behavior * refactor(desktop): modularize runtime and harden startup flow --------- Co-authored-by: Soulter <905617992@qq.com> Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com> * fix: dedupe preset messages (#4961) * feat: enhance package.json with resource filters and compression settings * chore: update Python version requirements to 3.12 (#4963) * chore: bump version to 4.14.7 * feat: refactor release workflow and add special update handling for electron app (#4969) * chore: bump version to 4.14.8 and bump faiss-cpu version up to date * chore: auto ann fix by ruff (#4903) * chore: auto fix by ruff * refactor: 统一修正返回类型注解为 None/bool 以匹配实现 * refactor: 将 _get_next_page 改为异步并移除多余的请求错误抛出 * refactor: 将 get_client 的返回类型改为 object * style: 为 LarkMessageEvent 的相关方法添加返回类型注解 None --------- Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com> * fix: prepare OpenSSL via vcpkg for Windows ARM64 * ci: change ghcr namespace * chore: update pydantic dependency version (#4980) * feat: add delete button to persona management dialog (#4978) * Initial plan * feat: add delete button to persona management dialog - Added delete button to PersonaForm dialog (only visible when editing) - Implemented deletePersona method with confirmation dialog - Connected delete event to PersonaManager for proper handling - Button positioned on left side of dialog actions for clear separation - Uses existing i18n translations for delete button and messages Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com> * fix: use finally block to ensure saving state is reset - Moved `this.saving = false` to finally block in deletePersona - Ensures UI doesn't stay in saving state after errors - Follows best practices for state management Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com> * feat: enhance Dingtalk adapter with active push message and image, video, audio message type (#4986) * fix: handle pip install execution in frozen runtime (#4985) * fix: handle pip install execution in frozen runtime * fix: harden pip subprocess fallback handling * fix: collect certifi data in desktop backend build (#4995) * feat: 企业微信应用 支持主动消息推送,并优化企微应用、微信公众号、微信客服音频相关的处理 (#4998) * feat: 企业微信智能机器人支持主动消息推送以及发送视频、文件等消息类型支持 (#4999) * feat: enhance WecomAIBotAdapter and WecomAIBotMessageEvent for improved streaming message handling (#5000) fixes: #3965 * feat: enhance persona tool management and update UI localization for subagent orchestration (#4990) * feat: enhance persona tool management and update UI localization for subagent orchestration * fix: remove debug logging for final ProviderRequest in build_main_agent function * perf: 稳定源码与 Electron 打包环境下的 pip 安装行为,并修复非 Electron 环境下点击 WebUI 更新按钮时出现跳转对话框的问题 (#4996) * fix: handle pip install execution in frozen runtime * fix: harden pip subprocess fallback handling * fix: scope global data root to packaged electron runtime * refactor: inline frozen runtime check for electron guard * fix: prefer current interpreter for source pip installs * fix: avoid resolving venv python symlink for pip * refactor: share runtime environment detection utilities * fix: improve error message when pip module is unavailable * fix: raise ImportError when pip module is unavailable * fix: preserve ImportError semantics for missing pip * fix: 修复非electron app环境更新时仍然显示electron更新对话框的问题 --------- Co-authored-by: Soulter <905617992@qq.com> * fix: 'HandoffTool' object has no attribute 'agent' (#5005) * fix: 移动agent的位置到super().__init__之后 * add: 添加一行注释 * chore(deps): bump the github-actions group with 2 updates (#5006) Bumps the github-actions group with 2 updates: [astral-sh/setup-uv](https://github.com/astral-sh/setup-uv) and [actions/download-artifact](https://github.com/actions/download-artifact). Updates `astral-sh/setup-uv` from 6 to 7 - [Release notes](https://github.com/astral-sh/setup-uv/releases) - [Commits](https://github.com/astral-sh/setup-uv/compare/v6...v7) Updates `actions/download-artifact` from 6 to 7 - [Release notes](https://github.com/actions/download-artifact/releases) - [Commits](https://github.com/actions/download-artifact/compare/v6...v7) --- updated-dependencies: - dependency-name: astral-sh/setup-uv dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions - dependency-name: actions/download-artifact dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * fix: stabilize packaged runtime pip/ssl behavior and mac font fallback (#5007) * fix: patch pip distlib finder for frozen electron runtime * fix: use certifi CA bundle for runtime SSL requests * fix: configure certifi CA before core imports * fix: improve mac font fallback for dashboard text * fix: harden frozen pip patch and unify TLS connector * refactor: centralize dashboard CJK font fallback stacks * perf: reuse TLS context and avoid repeated frozen pip patch * refactor: bootstrap TLS setup before core imports * fix: use async confirm dialog for provider deletions * fix: replace native confirm dialogs in dashboard - Add shared confirm helper in dashboard/src/utils/confirmDialog.ts for async dialog usage with safe fallback. - Migrate provider, chat, config, session, platform, persona, MCP, backup, and knowledge-base delete/close confirmations to use the shared helper. - Remove scattered inline confirm handling to keep behavior consistent and avoid native blocking dialog focus/caret issues in Electron. * fix: capture runtime bootstrap logs after logger init - Add bootstrap record buffer in runtime_bootstrap for early TLS patch logs before logger is ready. - Flush buffered bootstrap logs to astrbot logger at process startup in main.py. - Include concrete exception details for TLS bootstrap failures to improve diagnosis. * fix: harden runtime bootstrap and unify confirm handling - Simplify bootstrap log buffering and add a public initialize hook for non-main startup paths. - Guard aiohttp TLS patching with feature/type checks and keep graceful fallback when internals are unavailable. - Standardize dashboard confirmation flow via shared confirm helpers across composition and options API components. * refactor: simplify runtime tls bootstrap and tighten confirm typing * refactor: align ssl helper namespace and confirm usage * fix: 修复 Windows 打包版后端重启失败问题 (#5009) * fix: patch pip distlib finder for frozen electron runtime * fix: use certifi CA bundle for runtime SSL requests * fix: configure certifi CA before core imports * fix: improve mac font fallback for dashboard text * fix: harden frozen pip patch and unify TLS connector * refactor: centralize dashboard CJK font fallback stacks * perf: reuse TLS context and avoid repeated frozen pip patch * refactor: bootstrap TLS setup before core imports * fix: use async confirm dialog for provider deletions * fix: replace native confirm dialogs in dashboard - Add shared confirm helper in dashboard/src/utils/confirmDialog.ts for async dialog usage with safe fallback. - Migrate provider, chat, config, session, platform, persona, MCP, backup, and knowledge-base delete/close confirmations to use the shared helper. - Remove scattered inline confirm handling to keep behavior consistent and avoid native blocking dialog focus/caret issues in Electron. * fix: capture runtime bootstrap logs after logger init - Add bootstrap record buffer in runtime_bootstrap for early TLS patch logs before logger is ready. - Flush buffered bootstrap logs to astrbot logger at process startup in main.py. - Include concrete exception details for TLS bootstrap failures to improve diagnosis. * fix: harden runtime bootstrap and unify confirm handling - Simplify bootstrap log buffering and add a public initialize hook for non-main startup paths. - Guard aiohttp TLS patching with feature/type checks and keep graceful fallback when internals are unavailable. - Standardize dashboard confirmation flow via shared confirm helpers across composition and options API components. * refactor: simplify runtime tls bootstrap and tighten confirm typing * refactor: align ssl helper namespace and confirm usage * fix: avoid frozen restart crash from multiprocessing import * fix: include missing frozen dependencies for windows backend * fix: use execv for stable backend reboot args * Revert "fix: use execv for stable backend reboot args" This reverts commit 9cc27becffeba0e117fea26aa5c2e1fe7afc6e36. * Revert "fix: include missing frozen dependencies for windows backend" This reverts commit 52554bea1fa61045451600c64447b7bf38cf6c92. * Revert "fix: avoid frozen restart crash from multiprocessing import" This reverts commit 10548645b0ba1e19b64194878ece478a48067959. * fix: reset pyinstaller onefile env before reboot * fix: unify electron restart path and tray-exit backend cleanup * fix: stabilize desktop restart detection and frozen reboot args * fix: make dashboard restart wait detection robust * fix: revert dashboard restart waiting interaction tweaks * fix: pass auth token for desktop graceful restart * fix: avoid false failure during graceful restart wait * fix: start restart waiting before electron restart call * fix: harden restart waiting and reboot arg parsing * fix: parse start_time as numeric timestamp * fix: 修复app内重启异常,修复app内点击重启不能立刻提示重启,以及在后端就绪时及时刷新界面的问题 (#5013) * fix: patch pip distlib finder for frozen electron runtime * fix: use certifi CA bundle for runtime SSL requests * fix: configure certifi CA before core imports * fix: improve mac font fallback for dashboard text * fix: harden frozen pip patch and unify TLS connector * refactor: centralize dashboard CJK font fallback stacks * perf: reuse TLS context and avoid repeated frozen pip patch * refactor: bootstrap TLS setup before core imports * fix: use async confirm dialog for provider deletions * fix: replace native confirm dialogs in dashboard - Add shared confirm helper in dashboard/src/utils/confirmDialog.ts for async dialog usage with safe fallback. - Migrate provider, chat, config, session, platform, persona, MCP, backup, and knowledge-base delete/close confirmations to use the shared helper. - Remove scattered inline confirm handling to keep behavior consistent and avoid native blocking dialog focus/caret issues in Electron. * fix: capture runtime bootstrap logs after logger init - Add bootstrap record buffer in runtime_bootstrap for early TLS patch logs before logger is ready. - Flush buffered bootstrap logs to astrbot logger at process startup in main.py. - Include concrete exception details for TLS bootstrap failures to improve diagnosis. * fix: harden runtime bootstrap and unify confirm handling - Simplify bootstrap log buffering and add a public initialize hook for non-main startup paths. - Guard aiohttp TLS patching with feature/type checks and keep graceful fallback when internals are unavailable. - Standardize dashboard confirmation flow via shared confirm helpers across composition and options API components. * refactor: simplify runtime tls bootstrap and tighten confirm typing * refactor: align ssl helper namespace and confirm usage * fix: avoid frozen restart crash from multiprocessing import * fix: include missing frozen dependencies for windows backend * fix: use execv for stable backend reboot args * Revert "fix: use execv for stable backend reboot args" This reverts commit 9cc27becffeba0e117fea26aa5c2e1fe7afc6e36. * Revert "fix: include missing frozen dependencies for windows backend" This reverts commit 52554bea1fa61045451600c64447b7bf38cf6c92. * Revert "fix: avoid frozen restart crash from multiprocessing import" This reverts commit 10548645b0ba1e19b64194878ece478a48067959. * fix: reset pyinstaller onefile env before reboot * fix: unify electron restart path and tray-exit backend cleanup * fix: stabilize desktop restart detection and frozen reboot args * fix: make dashboard restart wait detection robust * fix: revert dashboard restart waiting interaction tweaks * fix: pass auth token for desktop graceful restart * fix: avoid false failure during graceful restart wait * fix: start restart waiting before electron restart call * fix: harden restart waiting and reboot arg parsing * fix: parse start_time as numeric timestamp * fix: preserve windows frozen reboot argv quoting * fix: align restart waiting with electron restart timing * fix: tighten graceful restart and unmanaged kill safety * chore: bump version to 4.15.0 (#5003) * fix: add reminder for v4.14.8 users regarding manual redeployment due to a bug * fix: harden plugin dependency loading in frozen app runtime (#5015) * fix: compare plugin versions semantically in market updates * fix: prioritize plugin site-packages for in-process pip * fix: reload starlette from plugin target site-packages * fix: harden plugin dependency import precedence in frozen runtime * fix: improve plugin dependency conflict handling * refactor: simplify plugin conflict checks and version utils * fix: expand transitive plugin dependencies for conflict checks * fix: recover conflicting plugin dependencies during module prefer * fix: reuse renderer restart flow for tray backend restart * fix: add recoverable plugin dependency conflict handling * revert: remove plugin version comparison changes * fix: add missing tray restart backend labels * feat: adding support for media and quoted message attachments for feishu (#5018) * docs: add AUR installation method (#4879) * docs: sync system package manager installation instructions to all languages * Update README.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update README.md Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com> * fix/typo * refactor: update system package manager installation instructions for Arch Linux across multiple language README files * feat: add installation command for AstrBot in multiple language README files --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com> Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com> Co-authored-by: Soulter <905617992@qq.com> * fix(desktop): 为 Electron 与后端日志增加按大小轮转 (#5029) * fix(desktop): rotate electron and backend logs * refactor(desktop): centralize log rotation defaults and debug fs errors * fix(desktop): harden rotation fs ops and buffer backend log writes * refactor(desktop): extract buffered logger and reduce sync stat calls * refactor(desktop): simplify rotation flow and harden logger config * fix(desktop): make app logging async and flush-safe * fix: harden app log path switching and debug-gated rotation errors * fix: cap buffered log chunk size during path switch * feat: add first notice feature with multilingual support and UI integration * fix: 提升打包版桌面端启动稳定性并优化插件依赖处理 (#5031) * fix(desktop): rotate electron and backend logs * refactor(desktop): centralize log rotation defaults and debug fs errors * fix(desktop): harden rotation fs ops and buffer backend log writes * refactor(desktop): extract buffered logger and reduce sync stat calls * refactor(desktop): simplify rotation flow and harden logger config * fix(desktop): make app logging async and flush-safe * fix: harden app log path switching and debug-gated rotation errors * fix: cap buffered log chunk size during path switch * fix: avoid redundant plugin reinstall and upgrade electron * fix: stop webchat tasks cleanly and bind packaged backend to localhost * fix: unify platform shutdown and await webchat listener cleanup * fix: improve startup logs for dashboard and onebot listeners * fix: revert extra startup service logs * fix: harden plugin import recovery and webchat listener cleanup * fix: pin dashboard ci node version to 24.13.0 * fix: avoid duplicate webchat listener cleanup on terminate * refactor: clarify platform task lifecycle management * fix: continue platform shutdown when terminate fails * feat: temporary file handling and introduce TempDirCleaner (#5026) * feat: temporary file handling and introduce TempDirCleaner - Updated various modules to use `get_astrbot_temp_path()` instead of `get_astrbot_data_path()` for temporary file storage. - Renamed temporary files for better identification and organization. - Introduced `TempDirCleaner` to manage the size of the temporary directory, ensuring it does not exceed a specified limit by deleting the oldest files. - Added configuration option for maximum temporary directory size in the dashboard. - Implemented tests for `TempDirCleaner` to verify cleanup functionality and size management. * ruff * fix: close unawaited reset coroutine on early return (#5033) When an OnLLMRequestEvent hook stops event propagation, the reset_coro created by build_main_agent was never awaited, causing a RuntimeWarning. Close the coroutine explicitly before returning. Fixes #5032 Co-authored-by: Limitless2023 * fix: update error logging message for connection failures * docs: clean and sync README (#5014) * fix: close missing div in README * fix: sync README_zh-TW with README * fix: sync README * fix: correct typo correct url in README_en README_fr README_ru * docs: sync README_en with README * Update README_en.md Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com> --------- Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com> Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com> * fix: provider extra param dialog key display error * chore: ruff format * feat: add send_chat_action for Telegram platform adapter (#5037) * feat: add send_chat_action for Telegram platform adapter Add typing/upload indicator when sending messages via Telegram. - Added _send_chat_action helper method for sending chat actions - Send appropriate action (typing, upload_photo, upload_document, upload_voice) before sending different message types - Support streaming mode with typing indicator - Support supergroup with message_thread_id * refactor(telegram): extract chat action helpers and add throttling - Add ACTION_BY_TYPE mapping for message type to action priority - Add _get_chat_action_for_chain() to determine action from message chain - Add _send_media_with_action() for upload → send → restore typing pattern - Add _ensure_typing() helper for typing status - Add chat action throttling (0.5s) in streaming mode to avoid rate limits - Update type annotation to ChatAction | str for better static checking * feat(telegram): implement send_typing method for Telegram platform --------- Co-authored-by: Soulter <905617992@qq.com> * fix: 修复更新日志、官方文档弹窗双滚动条问题 (#5060) * docs: sync and fix readme typo (#5055) * docs: fix index typo * docs: fix typo in README_en.md - 移除英文README中意外出现的俄语,并替换为英语 * docs: fix html typo - remove unused '

' * docs: sync table with README * docs: sync README header format - keep the README header format consistent * doc: sync key features * style: format files - Fix formatting issues from previous PR * fix: correct md anchor link * docs: correct typo in README_fr.md Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com> * docs: correct typo in README_zh-TW.md Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com> --------- Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com> * fix: 修复备份时缺失的人格文件夹映射 (#5042) * feat: QQ 官方机器人平台支持主动推送消息、私聊场景下支持接收文件 (#5066) * feat: QQ 官方机器人平台支持主动推送消息、私聊场景下支持接收文件 * feat: enhance QQOfficialWebhook to remember session scenes for group, channel, and friend messages * perf: 优化分段回复间隔时间的初始化逻辑 (#5068) fixes: #5059 * fix: chunk err when using openrouter deepseek (#5069) * feat: add i18n supports for custom platform adapters (#5045) * Feat: 为插件提供的适配器的元数据&i18n提供数据通路 * chore: update docstrings with pull request references Added references to pull request 5045 in docstrings. --------- Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com> * fix: 完善转发引用解析与图片回退并支持配置化控制 (#5054) * feat: support fallback image parsing for quoted messages * fix: fallback parse quoted images when reply chain has placeholders * style: format network utils with ruff * test: expand quoted parser coverage and improve fallback diagnostics * fix: fallback to text-only retry when image requests fail * fix: tighten image fallback and resolve nested quoted forwards * refactor: simplify quoted message extraction and dedupe images * fix: harden quoted parsing and openai error candidates * fix: harden quoted image ref normalization * refactor: organize quoted parser settings and logging * fix: cap quoted fallback images and avoid retry loops * refactor: split quoted message parser into focused modules * refactor: share onebot segment parsing logic * refactor: unify quoted message parsing flow * feat: move quoted parser tuning to provider settings * fix: add missing i18n metadata for quoted parser settings * chore: refine forwarded message setting labels * fix: add config tabs and routing for normal and system configurations * chore: bump version to 4.16.0 (#5074) * feat: add LINE platform support with adapter and configuration (#5085) * fix-correct-FIRST_NOTICE.md-locale-path-resolution (#5083) (#5082) * fix:修改配置文件目录 * fix:添加备选的FIRST_NOTICE.zh-CN.md用于兼容 * fix: remove unnecessary frozen flag from requirements export in Dockerfile fixes: #5089 * fix #5089: add uv lock step in Dockerfile before export (#5091) Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com> * feat: support hot reload after plugin load failure (#5043) * add :Support hot reload after plugin load failure * Apply suggestions from code review Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com> * fix:reformat code * fix:reformat code --------- Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com> * feat: add fallback chat model chain in tool loop runner (#5109) * feat: implement fallback provider support for chat models and update configuration * feat: enhance provider selection display with count and chips for selected providers * feat: update fallback chat providers to use provider settings and add warning for non-list fallback models * feat: add Afdian support card to resources section in WelcomePage * feat: replace colorlog with loguru for enhanced logging support (#5115) * feat: add SSL configuration options for WebUI and update related logging (#5117) * chore: bump version to 4.17.0 * fix: handle list format content from OpenAI-compatible APIs (#5128) * fix: handle list format content from OpenAI-compatible APIs Some LLM providers (e.g., GLM-4.5V via SiliconFlow) return content as list[dict] format like [{'type': 'text', 'text': '...'}] instead of plain string. This causes the raw list representation to be displayed to users. Changes: - Add _normalize_content() helper to extract text from various content formats - Use json.loads instead of ast.literal_eval for safer parsing - Add size limit check (8KB) before attempting JSON parsing - Only convert lists that match OpenAI content-part schema (has 'type': 'text') to avoid collapsing legitimate list-literal replies like ['foo', 'bar'] - Add strip parameter to preserve whitespace in streaming chunks - Clean up orphan tags that may leak from some models Fixes #5124 * fix: improve content normalization safety - Try json.loads first, fallback to ast.literal_eval for single-quoted Python literals to avoid corrupting apostrophes (e.g., "don't") - Coerce text values to str to handle null or non-string text fields * fix: update retention logic in LogManager to handle backup count correctly * chore: bump version to 4.17.1 * docs: Added instructions for deploying AstrBot using AstrBot Launcher. (#5136) Added instructions for deploying AstrBot using AstrBot Launcher. * fix: add MCP tools to function tool set in _plugin_tool_fix (#5144) * fix: add support for collecting data from builtin stars in electron pyinstaller build (#5145) * chore: bump version to 4.17.1 * chore: ruff format * fix: prevent updates for AstrBot launched via launcher * fix(desktop): include runtime deps for builtin plugins in backend build (#5146) * fix: 'Plain' object has no attribute 'text' when using python 3.14 (#5154) * fix: enhance plugin metadata handling by injecting attributes before instantiation (#5155) * fix: enhance handle_result to support event context and webchat image sending * chore: bump version to 4.17.3 * chore: ruff format * feat: add NVIDIA provider template (#5157) fixes: #5156 * feat: enhance provider sources panel with styled menu and mobile support * fix: improve permission denied message for local execution in Python and shell tools * feat: enhance PersonaForm component with responsive design and improved styling (#5162) fix: #5159 * ui(CronJobPage): fix action column buttons overlapping in CronJobPage (#5163) - 修改前:操作列容器仅使用 `d-flex`,在页面宽度变窄时,子元素(开关和删除按钮)会因为宽度挤压而发生视觉重叠,甚至堆叠在一起。 - 修改后: 1. 为容器添加了 `flex-nowrap`,强制禁止子元素换行。 2. 设置了 `min-width: 140px`,确保该列拥有固定的保护空间,防止被其他长文本列挤压。 3. 增加了 `gap: 12px` 间距,提升了操作辨识度并优化了点击体验。 * feat: add unsaved changes notice to configuration page and update messages * feat: implement search functionality in configuration components and update UI (#5168) * feat: add FAQ link to vertical sidebar and update navigation for localization * feat: add announcement section to WelcomePage and localize announcement title * chore: bump version to 4.17.4 * feat: supports send markdown message in qqofficial (#5173) * feat: supports send markdown message in qqofficial closes: #1093 #918 #4180 #4264 * ruff format * fix: prevent duplicate error message when all LLM providers fail (#5183) * fix: 修复选择配置文件进入配置文件管理弹窗直接关闭弹窗显示的配置文件不正确 (#5174) * feat: add MarketPluginCard component and integrate random plugin feature in ExtensionPage (#5190) * feat: add MarketPluginCard component and integrate random plugin feature in ExtensionPage * feat: update random plugin selection logic to use pluginMarketData and refresh on relevant events * feat: supports aihubmix * docs: update readme * chore: ruff format * feat: add LINE support to multiple language README files * feat(core): add plugin error hook for custom error routing (#5192) * feat(core): add plugin error hook for custom error routing * fix(core): align plugin error suppression with event stop state * refactor: extract Voice_messages_forbidden fallback into shared helper with typed BadRequest exception (#5204) - Add _send_voice_with_fallback helper to deduplicate voice forbidden handling - Catch telegram.error.BadRequest instead of bare Exception with string matching - Add text field to Record component to preserve TTS source text - Store original text in Record during TTS conversion for use as document caption - Skip _send_chat_action when chat_id is empty to avoid unnecessary warnings * chore: bump version to 4.17.5 * feat: add admin permission checks for Python and Shell execution (#5214) * fix: 改进微信公众号被动回复处理机制,引入缓冲与分片回复,并优化超时行为 (#5224) * 修复wechat official 被动回复功能 * ruff format --------- Co-authored-by: Soulter <905617992@qq.com> * fix: 修复仅发送 JSON 消息段时的空消息回复报错 (#5208) * Fix Register_Stage · 补全 JSON 消息判断,修复发送 JSON 消息时遇到 “消息为空,跳过发送阶段” 的问题。 · 顺带补全其它消息类型判断。 Co-authored-by: Pizero * Fix formatting and comments in stage.py * Format stage.py --------- Co-authored-by: Pizero * docs: update related repo links * fix(core): terminate active events on reset/new/del to prevent stale responses (#5225) * fix(core): terminate active events on reset/new/del to prevent stale responses Closes #5222 * style: fix import sorting in scheduler.py * chore: remove Electron desktop pipeline and switch to tauri repo (#5226) * ci: remove Electron desktop build from release pipeline * chore: remove electron desktop and switch to tauri release trigger * ci: remove desktop workflow dispatch trigger * refactor: migrate data paths to astrbot_path helpers * fix: point desktop update prompt to AstrBot-desktop releases * fix: update feature request template for clarity and consistency in English and Chinese * Feat/config leave confirm (#5249) * feat: 配置文件增加未保存提示弹窗 * fix: 移除unsavedChangesDialog插件使用组件方式实现弹窗 * feat: add support for plugin astrbot-version and platform requirement checks (#5235) * feat: add support for plugin astrbot-version and platform requirement checks * fix: remove unsupported platform and version constraints from metadata.yaml * fix: remove restriction on 'v' in astrbot_version specification format * ruff format * feat: add password confirmation when changing password (#5247) * feat: add password confirmation when changing password Fixes #5177 Adds a password confirmation field to prevent accidental password typos. Changes: - Backend: validate confirm_password matches new_password - Frontend: add confirmation input with validation - i18n: add labels and error messages for password mismatch Co-Authored-By: Claude Sonnet 4.6 * fix(auth): improve error message for password confirmation mismatch * fix(auth): update password hashing logic and improve confirmation validation --------- Co-authored-by: whatevertogo Co-authored-by: Claude Sonnet 4.6 * fix(provider): 修复 dict 格式 content 导致的 JSON 残留问题 (#5250) * fix(provider): 修复 dict 格式 content 导致的 JSON 残留问题 修复 _normalize_content 函数未处理 dict 类型 content 的问题。 当 LLM 返回 {"type": "text", "text": "..."} 格式的 content 时, 现在会正确提取 text 字段而非直接转为字符串。 同时改进 fallback 行为,对 None 值返回空字符串。 Fixes #5244 * Update warning message for unexpected dict format --------- Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com> * chore: remove outdated heihe.md documentation file * fix: all mcp tools exposed to main agent (#5252) * fix: enhance PersonaForm layout and improve tool selection display * fix: update tool status display and add localization for inactive tools * fix: remove additionalProperties from tool schema properties (#5253) fixes: #5217 * fix: simplify error messages for account edit validation * fix: streamline error response for empty new username and password in account edit * chore: bump vertion to 4.17.6 * feat: add OpenRouter provider support and icon * chore: ruff format * refactor(dashboard): replace legacy isElectron bridge fields with isDesktop (#5269) * refactor dashboard desktop bridge fields from isElectron to isDesktop * refactor dashboard runtime detection into shared helper * fix: update contributor avatar image URL to include max size and columns (#5268) * feat: astrbot http api (#5280) * feat: astrbot http api * Potential fix for code scanning alert no. 34: Use of a broken or weak cryptographic hashing algorithm on sensitive data Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> * fix: improve error handling for missing attachment path in file upload * feat: implement paginated retrieval of platform sessions for creators * feat: refactor attachment directory handling in ChatRoute * feat: update API endpoint paths for file and message handling * feat: add documentation link to API key management section in settings * feat: update API key scopes and related configurations in API routes and tests * feat: enhance API key expiration options and add warning for permanent keys * feat: add UTC normalization and serialization for API key timestamps * feat: implement chat session management and validation for usernames * feat: ignore session_id type chunks in message processing --------- Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> * feat(dashboard): improve plugin platform support display and mobile accessibility (#5271) * feat(dashboard): improve plugin platform support display and mobile accessibility - Replace hover-based tooltips with interactive click menus for platform support information. - Fix mobile touch issues by introducing explicit state control for status capsules. - Enhance UI aesthetics with platform-specific icons and a structured vertical list layout. - Add dynamic chevron icons to provide clear visual cues for expandable content. * refactor(dashboard): refactor market card with computed properties for performance * refactor(dashboard): unify plugin platform support UI with new reusable chip component - Create shared 'PluginPlatformChip' component to encapsulate platform meta display. - Fix mobile interaction bugs by simplifying menu triggers and event handling. - Add stacked platform icon previews and dynamic chevron indicators within capsules. - Improve information hierarchy using structured vertical lists for platform details. - Optimize rendering efficiency with computed properties across both card views. * fix: qq official guild message send error (#5287) * fix: qq official guild message send error * Update astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --------- Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com> Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * 更新readme文档,补充桌面app说明,并向前移动位置 (#5297) * docs: update desktop deployment section in README * docs: refine desktop and launcher deployment descriptions * Update README.md * feat: add Anthropic Claude Code OAuth provider and adaptive thinking support (#5209) * feat: add Anthropic Claude Code OAuth provider and adaptive thinking support * fix: add defensive guard for metadata overrides and align budget condition with docs * refactor: adopt sourcery-ai suggestions for OAuth provider - Use use_api_key=False in OAuth subclass to avoid redundant API-key client construction before replacing with auth_token client - Generalize metadata override helper to merge all dict keys instead of only handling 'limit', improving extensibility * Feat/telegram command alias register #5233 (#5234) * feat: support registering command aliases for Telegram Now when registering commands with aliases, all aliases will be registered as Telegram bot commands in addition to the main command. Example: @register_command(command_name="draw", alias={"画", "gen"}) Now /draw, /画, and /gen will all appear in the Telegram command menu. * feat(telegram): add duplicate command name warning when registering commands Log a warning when duplicate command names are detected during Telegram command registration to help identify configuration conflicts. * refactor: remove Anthropic OAuth provider implementation and related metadata overrides * fix: 修复新建对话时因缺少会话ID导致配置绑定失败的问题 (#5292) * fix:尝试修改 * fix:添加详细日志 * fix:进行详细修改,并添加日志 * fix:删除所有日志 * fix: 增加安全访问函数 - 给 localStorage 访问加了 try/catch + 可用性判断:dashboard/src/utils/chatConfigBinding.ts:13 - 新增 getFromLocalStorage/setToLocalStorage(在受限存储/无痕模式下异常时回退/忽略) - getStoredDashboardUsername() / getStoredSelectedChatConfigId() 改为走安全读取:dashboard/src/utils/chatConfigBinding.ts:36 - 新增 setStoredSelectedChatConfigId(),写入失败静默忽略:dashboard/src/utils/chatConfigBinding.ts:44 - 把 ConfigSelector.vue 里直接 localStorage.getItem/setItem 全部替换为上述安全方法:dashboard/src/components/chat/ConfigSelector.vue:81 - 已重新跑过 pnpm run typecheck,通过。 * rm:删除个人用的文档文件 * Revert "rm:删除个人用的文档文件" This reverts commit 0fceee05434cfbcb11e45bb967a77d5fa93196bf. * rm:删除个人用的文档文件 * rm:删除个人用的文档文件 * chore: bump version to 4.18.0 * fix(SubAgentPage): 当中间的介绍文本非常长时,Flex 布局会自动挤压右侧的控制按钮区域 (#5306) * fix: 修复新版本插件市场出现插件显示为空白的 bug;纠正已安装插件卡片的排版,统一大小 (#5309) * fix(ExtensionCard): 解决插件卡片大小不统一的问题 * fix(MarketPluginCard): 解决插件市场不加载插件的问题 (#5303) * feat: supports spawn subagent as a background task that not block the main agent workflow (#5081) * feat:为subagent添加后台任务参数 * ruff * fix: update terminology from 'handoff mission' to 'background task' and refactor related logic * fix: update terminology from 'background_mission' to 'background_task' in HandoffTool and related logic * fix(HandoffTool): update background_task description for clarity on usage --------- Co-authored-by: Soulter <905617992@qq.com> * cho * fix: 修复 aiohttp 版本过新导致 qq-botpy 报错的问题 (#5316) * chore: ruff format * fix: remove hard-coded 6s timeout from tavily request * fix: remove changelogs directory from .dockerignore * feat(dashboard): make release redirect base URL configurable (#5330) * feat(dashboard): make desktop release base URL configurable * refactor(dashboard): use generic release base URL env with upstream default * fix(dashboard): guard release base URL normalization when env is unset * refactor(dashboard): use generic release URL helpers and avoid latest suffix duplication * feat: add stop functionality for active agent sessions and improve handling of stop requests (#5380) * feat: add stop functionality for active agent sessions and improve handling of stop requests * feat: update stop button icon and tooltip in ChatInput component * fix: correct indentation in tool call handling within ChatRoute class * fix: chatui cannot persist file segment (#5386) * fix(plugin): update plugin directory handling for reserved plugins (#5369) * fix(plugin): update plugin directory handling for reserved plugins * fix(plugin): add warning logs for missing plugin name, object, directory, and changelog * chore(README): updated with README.md (#5375) * chore(README): updated with README.md * Update README_fr.md Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com> * Update README_zh-TW.md Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com> --------- Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com> * feat: add image urls / paths supports for subagent (#5348) * fix: 修复5081号PR在子代理执行后台任务时,未正确使用系统配置的流式/非流请求的问题(#5081) * feat:为子代理增加远程图片URL参数支持 * fix: update description for image_urls parameter in HandoffTool to clarify usage in multimodal tasks * ruff format --------- Co-authored-by: Soulter <905617992@qq.com> * feat: add hot reload when failed to load plugins (#5334) * feat:add hot reload when failed to load plugins * apply bot suggestions * fix(chatui): add copy rollback path and error message. (#5352) * fix(chatui): add copy rollback path and error message. * fix(chatui): fixed textarea leak in the copy button. * fix(chatui): use color styles from the component library. * fix: 处理配置文件中的 UTF-8 BOM 编码问题 (#5376) * fix(config): handle UTF-8 BOM in configuration file loading Problem: On Windows, some text editors (like Notepad) automatically add UTF-8 BOM to JSON files when saving. This causes json.decoder.JSONDecodeError: "Unexpected UTF-8 BOM" and AstrBot fails to start when cmd_config.json contains BOM. Solution: Add defensive check to strip UTF-8 BOM (\ufeff) if present before parsing JSON configuration file. Impact: - Improves robustness and cross-platform compatibility - No breaking changes to existing functionality - Fixes startup failure when configuration file has UTF-8 BOM encoding Relates-to: Windows editor compatibility issues * style: fix code formatting with ruff Fix single quote to double quote to comply with project code style. * feat: add plugin load&unload hook (#5331) * 添加了插件的加载完成和卸载完成的钩子事件 * 添加了插件的加载完成和卸载完成的钩子事件 * format code with ruff * ruff format --------- Co-authored-by: Soulter <905617992@qq.com> * test: enhance test framework with comprehensive fixtures and mocks (#5354) * test: enhance test framework with comprehensive fixtures and mocks - Add shared mock builders for aiocqhttp, discord, telegram - Add test helpers for platform configs and mock objects - Expand conftest.py with test profile support - Update coverage test workflow configuration Co-Authored-By: Claude Sonnet 4.6 * refactor(tests): 移动并重构模拟 LLM 响应和消息组件函数 * fix(tests): 优化 pytest_runtest_setup 中的标记检查逻辑 --------- Co-authored-by: whatevertogo Co-authored-by: Claude Sonnet 4.6 * test: add comprehensive tests for message event handling (#5355) * test: add comprehensive tests for message event handling - Add AstrMessageEvent unit tests (688 lines) - Add AstrBotMessage unit tests - Enhance smoke tests with message event scenarios Co-Authored-By: Claude Sonnet 4.6 * fix: improve message type handling and add defensive tests --------- Co-authored-by: whatevertogo Co-authored-by: Claude Sonnet 4.6 * feat: add support for showing tool call results in agent execution (#5388) closes: #5329 * fix: resolve pipeline and star import cycles (#5353) * fix: resolve pipeline and star import cycles - Add bootstrap.py and stage_order.py to break circular dependencies - Export Context, PluginManager, StarTools from star module - Update pipeline __init__ to defer imports - Split pipeline initialization into separate bootstrap module Co-Authored-By: Claude Sonnet 4.6 * fix: add logging for get_config() failure in Star class * fix: reorder logger initialization in base.py --------- Co-authored-by: whatevertogo Co-authored-by: Claude Sonnet 4.6 * feat: enable computer-use tools for subagent handoff (#5399) * fix: enforce admin guard for sandbox file transfer tools (#5402) * fix: enforce admin guard for sandbox file transfer tools * refactor: deduplicate computer tools admin permission checks * fix: add missing space in permission error message * fix(core): 优化 File 组件处理逻辑并增强 OneBot 驱动层路径兼容性 (#5391) * fix(core): 优化 File 组件处理逻辑并增强 OneBot 驱动层路径兼容性 原因 (Necessity): 1. 内核一致性:AstrBot 内核的 Record 和 Video 组件均具备识别 `file:///` 协议头的逻辑,但 File 组件此前缺失此功能,导致行为不统一。 2. OneBot 协议合规:OneBot 11 标准要求本地文件路径必须使用 `file:///` 协议头。此前驱动层未对裸路径进行自动转换,导致发送本地文件时常触发 retcode 1200 (识别URL失败) 错误。 3. 容器环境适配:在 Docker 等路径隔离环境下,裸路径更容易因驱动或协议端的解析歧义而失效。 更改 (Changes): - [astrbot/core/message/components.py]: - 在 File.get_file() 中增加对 `file:///` 前缀的识别与剥离逻辑,使其与 Record/Video 组件行为对齐。 - [astrbot/core/platform/sources/aiocqhttp/aiocqhttp_message_event.py]: - 在发送文件前增加自动修正逻辑:若路径为绝对路径且未包含协议头,驱动层将自动补全 `file:///` 前缀。 - 对 http、base64 及已有协议头,确保不干扰原有的正常传输逻辑。 影响 (Impact): - 以完全兼容的方式增强了文件发送的鲁棒性。 - 解决了插件在发送日志等本地生成的压缩包时,因路径格式不规范导致的发送失败问题。 * refactor(core): 根据 cr 建议,规范化文件 URI 生成与解析逻辑,优化跨平台兼容性 原因 (Necessity): 1. 修复原生路径与 URI 转换在 Windows 下的不对称问题。 2. 规范化 file: 协议头处理,确保符合 RFC 标准并能在 Linux/Windows 间稳健切换。 3. 增强协议判定准确度,防止对普通绝对路径的误处理。 更改 (Changes): - [astrbot/core/platform/sources/aiocqhttp]: - 弃用手动拼接,改用 `pathlib.Path.as_uri()` 生成标准 URI。 - 将协议检测逻辑从前缀匹配优化为包含性检测 ("://")。 - [astrbot/core/message/components]: - 重构 `File.get_file` 解析逻辑,支持对称处理 2/3 斜杠格式。 - 针对 Windows 环境增加了对 `file:///C:/` 格式的自动修正,避免 `os.path` 识别失效。 - [data/plugins/astrbot_plugin_logplus]: - 在直接 API 调用中同步应用 URI 规范化处理。 影响 (Impact): - 解决 Docker 环境中因路径不规范导致的 "识别URL失败" 报错。 - 提升了本体框架在 Windows 系统下的文件操作鲁棒性。 * i18n(SubAgentPage): complete internationalization for subagent orchestration page (#5400) * i18n: complete internationalization for subagent orchestration page - Replace hardcoded English strings in [SubAgentPage.vue] with i18n keys. - Update `en-US` and `zh-CN` locales with missing hints, validation messages, and empty state translations. - Fix translation typos and improve consistency across the SubAgent orchestration UI. * fix(bug_risk): 避免在模板中的翻译调用上使用 || 'Close' 作为回退值。 * fix(aiocqhttp): enhance shutdown process for aiocqhttp adapter (#5412) * fix: pass embedding dimensions to provider apis (#5411) * fix(context): log warning when platform not found for session * fix(context): improve logging for platform not found in session * chore: bump version to 4.18.2 * chore: bump version to 4.18.2 * chore: bump version to 4.18.2 * fix: Telegram voice message format (OGG instead of WAV) causing issues with OpenAI STT API (#5389) * chore: ruff format * feat(dashboard): add generic desktop app updater bridge (#5424) * feat(dashboard): add generic desktop app updater bridge * fix(dashboard): address updater bridge review feedback * fix(dashboard): unify updater bridge types and error logging * fix(dashboard): consolidate updater bridge typings * fix(conversation): retain existing persona_id when updating conversation * fix(dashboard): 修复设置页新建 API Key 后复制失败问题 (#5439) * Fix: GitHub proxy not displaying correctly in WebUI (#5438) * fix(dashboard): preserve custom GitHub proxy setting on reload * fix(dashboard): keep github proxy selection persisted in settings * fix(persona): enhance persona resolution logic for conversations and sessions * fix: ensure tool call/response pairing in context truncation (#5417) * fix: ensure tool call/response pairing in context truncation * refactor: simplify fix_messages to single-pass state machine * perf(cron): enhance future task session isolation fixes: #5392 * feat: add useExtensionPage composable for managing plugin extensions - Implemented a new composable `useExtensionPage` to handle various functionalities related to plugin management, including fetching extensions, handling updates, and managing UI states. - Added support for conflict checking, plugin installation, and custom source management. - Integrated search and filtering capabilities for plugins in the market. - Enhanced user experience with dialogs for confirmations and notifications. - Included pagination and sorting features for better plugin visibility. * fix: clear markdown field when sending media messages via QQ Official Platform (#5445) * fix: clear markdown field when sending media messages via QQ Official API * refactor: use pop() to remove markdown key instead of setting None * fix: cannot automatically get embedding dim when create embedding provider (#5442) * fix(dashboard): 强化 API Key 复制临时节点清理逻辑 * fix(embedding): 自动检测改为探测 OpenAI embedding 最大可用维度 * fix: normalize openai embedding base url and add hint key * i18n: add embedding_api_base hint translations * i18n: localize provider embedding/proxy metadata hints * fix: show provider-specific embedding API Base URL hint as field subtitle * fix(embedding): cap OpenAI detect_dim probes with early short-circuit * fix(dashboard): return generic error on provider adapter import failure * 回退检测逻辑 * fix: 修复Pyright静态类型检查报错 (#5437) * refactor: 修正 Sqlite 查询、下载回调、接口重构与类型调整 * feat: 为 OneBotClient 增加 CallAction 协议与异步调用支持 * fix(telegram): avoid duplicate message_thread_id in streaming (#5430) * perf: batch metadata query in KB retrieval to fix N+1 problem (#5463) * perf: batch metadata query in KB retrieval to fix N+1 problem Replace N sequential get_document_with_metadata() calls with a single get_documents_with_metadata_batch() call using SQL IN clause. Benchmark results (local SQLite): - 10 docs: 10.67ms → 1.47ms (7.3x faster) - 20 docs: 26.00ms → 2.68ms (9.7x faster) - 50 docs: 63.87ms → 2.79ms (22.9x faster) * refactor: use set[str] param type and chunk IN clause for SQLite safety Address review feedback: - Change doc_ids param from list[str] to set[str] to avoid unnecessary conversion - Chunk IN clause into batches of 900 to stay under SQLite's 999 parameter limit - Remove list() wrapping at call site, pass set directly * fix:fix the issue where incomplete cleanup of residual plugins occurs… (#5462) * fix:fix the issue where incomplete cleanup of residual plugins occurs in the failed loading of plugins * fix:ruff format,apply bot suggestions * Apply suggestion from @gemini-code-assist[bot] Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --------- Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * chore: 为类型检查添加 TYPE_CHECKING 的导入与阶段类型引用 (#5474) * fix(line): line adapter does not appear in the add platform dialog fixes: #5477 * [bug]查看介入教程line前往错误界面的问题 (#5479) Fixes #5478 * chore: bump version to 4.18.3 * feat: implement follow-up message handling in ToolLoopAgentRunner (#5484) * feat: implement follow-up message handling in ToolLoopAgentRunner * fix: correct import path for follow-up module in InternalAgentSubStage * feat: implement websockets transport mode selection for chat (#5410) * feat: implement websockets transport mode selection for chat - Added transport mode selection (SSE/WebSocket) in the chat component. - Updated conversation sidebar to include transport mode options. - Integrated transport mode handling in message sending logic. - Refactored message sending functions to support both SSE and WebSocket. - Enhanced WebSocket connection management and message handling. - Updated localization files for transport mode labels. - Configured Vite to support WebSocket proxying. * feat(webchat): refactor message parsing logic and integrate new parsing function * feat(chat): add websocket API key extraction and scope validation * Revert "可选后端,实现前后端分离" (#5536) --------- Signed-off-by: dependabot[bot] Co-authored-by: can <51474963+weijintaocode@users.noreply.github.com> Co-authored-by: Soulter <905617992@qq.com> Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com> Co-authored-by: letr <123731298+letr007@users.noreply.github.com> Co-authored-by: 搁浅 Co-authored-by: Helian Nuits Co-authored-by: Gao Jinzhe <2968474907@qq.com> Co-authored-by: DD斩首 <155905740+DDZS987@users.noreply.github.com> Co-authored-by: Ubuntu Co-authored-by: Claude Sonnet 4.5 Co-authored-by: エイカク <62183434+zouyonghe@users.noreply.github.com> Co-authored-by: 鸦羽 Co-authored-by: Dt8333 <25431943+Dt8333@users.noreply.github.com> Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> Co-authored-by: Li-shi-ling <114913764+Li-shi-ling@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com> Co-authored-by: Limitless <127183162+Limitless2023@users.noreply.github.com> Co-authored-by: Limitless2023 Co-authored-by: evpeople <54983536+evpeople@users.noreply.github.com> Co-authored-by: SnowNightt <127504703+SnowNightt@users.noreply.github.com> Co-authored-by: xzj0898 <62733743+xzj0898@users.noreply.github.com> Co-authored-by: stevessr <89645372+stevessr@users.noreply.github.com> Co-authored-by: Waterwzy <2916963017@qq.com> Co-authored-by: NayukiMeko Co-authored-by: 時壹 <137363396+KBVsent@users.noreply.github.com> Co-authored-by: sanyekana Co-authored-by: Chiu Chun-Hsien <95356121+911218sky@users.noreply.github.com> Co-authored-by: Dream Tokenizer <60459821+Trance-0@users.noreply.github.com> Co-authored-by: NanoRocky <76585834+NanoRocky@users.noreply.github.com> Co-authored-by: Pizero Co-authored-by: 雪語 <167516635+YukiRa1n@users.noreply.github.com> Co-authored-by: whatevertogo <1879483647@qq.com> Co-authored-by: whatevertogo Co-authored-by: 香草味的纳西妲喵 <151599587+VanillaNahida@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Co-authored-by: Lovely Moe Moli <44719954+moemoli@users.noreply.github.com> Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Co-authored-by: Minidoracat Co-authored-by: Chen <42998804+a61995987@users.noreply.github.com> Co-authored-by: hanbings Co-authored-by: tangsenfei <155090747+tangsenfei@users.noreply.github.com> Co-authored-by: PyuraMazo <1605025385@qq.com> Co-authored-by: Axi404 <118950647+Axi404@users.noreply.github.com> Co-authored-by: 氕氙 <2014440212@qq.com> Co-authored-by: Yunhao Cao <18230652+realquantumcookie@users.noreply.github.com> Co-authored-by: exynos <110159911+exynos967@users.noreply.github.com> Co-authored-by: Luna_Dol <86590429+Luna-channel@users.noreply.github.com> Co-authored-by: CCCCCCTV <64309817+CCCCCCTV@users.noreply.github.com> Co-authored-by: CAICAII <3360776475@qq.com> Co-authored-by: 圣达生物多 --- .dockerignore | 1 - .github/ISSUE_TEMPLATE/feature-request.yml | 26 +- .github/workflows/auto_release.yml | 92 - .github/workflows/code-format.yml | 2 +- .github/workflows/coverage_test.yml | 2 +- .github/workflows/dashboard_ci.yml | 4 +- .github/workflows/docker-image.yml | 4 +- .github/workflows/release.yml | 212 + .gitignore | 4 +- .python-version | 2 +- AGENTS.md | 1 + Dockerfile | 16 +- FIRST_NOTICE.en-US.md | 14 + FIRST_NOTICE.md | 14 + README.md | 63 +- README_en.md | 55 +- README_fr.md | 96 +- README_ja.md | 95 +- README_ru.md | 100 +- README_zh-TW.md | 95 +- astrbot/api/event/filter/__init__.py | 6 + .../builtin_stars/astrbot/long_term_memory.py | 10 +- astrbot/builtin_stars/astrbot/main.py | 10 +- .../builtin_commands/commands/admin.py | 12 +- .../builtin_commands/commands/alter_cmd.py | 6 +- .../builtin_commands/commands/conversation.py | 93 +- .../builtin_commands/commands/help.py | 6 +- .../builtin_commands/commands/llm.py | 4 +- .../builtin_commands/commands/persona.py | 38 +- .../builtin_commands/commands/plugin.py | 12 +- .../builtin_commands/commands/provider.py | 10 +- .../builtin_commands/commands/setunset.py | 6 +- .../builtin_commands/commands/sid.py | 4 +- .../builtin_commands/commands/t2i.py | 4 +- .../builtin_commands/commands/tts.py | 4 +- .../builtin_stars/builtin_commands/main.py | 69 +- .../builtin_stars/session_controller/main.py | 6 +- .../web_searcher/engines/__init__.py | 2 +- astrbot/builtin_stars/web_searcher/main.py | 192 +- astrbot/cli/__init__.py | 2 +- astrbot/cli/commands/cmd_conf.py | 6 +- astrbot/cli/commands/cmd_init.py | 9 +- astrbot/cli/commands/cmd_plug.py | 16 +- astrbot/cli/commands/cmd_run.py | 22 +- astrbot/cli/utils/plugin.py | 2 +- astrbot/core/agent/context/compressor.py | 6 +- astrbot/core/agent/context/manager.py | 2 +- astrbot/core/agent/context/truncator.py | 65 +- astrbot/core/agent/handoff.py | 18 +- astrbot/core/agent/hooks.py | 8 +- astrbot/core/agent/mcp_client.py | 12 +- astrbot/core/agent/message.py | 10 +- .../agent/runners/coze/coze_api_client.py | 6 +- .../dashscope/dashscope_agent_runner.py | 2 +- .../agent/runners/dify/dify_agent_runner.py | 6 +- .../agent/runners/dify/dify_api_client.py | 4 +- .../agent/runners/tool_loop_agent_runner.py | 495 +- astrbot/core/agent/tool.py | 23 +- astrbot/core/agent/tool_image_cache.py | 162 + astrbot/core/astr_agent_hooks.py | 8 +- astrbot/core/astr_agent_run_util.py | 158 +- astrbot/core/astr_agent_tool_exec.py | 252 +- astrbot/core/astr_main_agent.py | 265 +- astrbot/core/astr_main_agent_resources.py | 15 +- astrbot/core/astrbot_config_mgr.py | 4 +- astrbot/core/backup/constants.py | 2 + astrbot/core/backup/exporter.py | 2 +- astrbot/core/backup/importer.py | 4 +- astrbot/core/computer/booters/base.py | 2 +- astrbot/core/computer/booters/local.py | 2 +- astrbot/core/computer/tools/fs.py | 22 +- astrbot/core/computer/tools/permissions.py | 19 + astrbot/core/computer/tools/python.py | 20 +- astrbot/core/computer/tools/shell.py | 5 +- astrbot/core/config/astrbot_config.py | 13 +- astrbot/core/config/default.py | 287 +- astrbot/core/config/i18n_utils.py | 103 +- astrbot/core/conversation_mgr.py | 10 +- astrbot/core/core_lifecycle.py | 19 + astrbot/core/cron/events.py | 6 +- astrbot/core/cron/manager.py | 20 +- astrbot/core/db/__init__.py | 68 +- astrbot/core/db/migration/migra_3_to_4.py | 10 +- astrbot/core/db/migration/migra_45_to_46.py | 2 +- .../core/db/migration/migra_token_usage.py | 2 +- .../db/migration/migra_webchat_session.py | 2 +- .../db/migration/shared_preferences_v3.py | 10 +- astrbot/core/db/migration/sqlite_v3.py | 18 +- astrbot/core/db/po.py | 37 + astrbot/core/db/sqlite.py | 239 +- astrbot/core/db/vec_db/base.py | 2 +- .../db/vec_db/faiss_impl/document_storage.py | 14 +- .../db/vec_db/faiss_impl/embedding_storage.py | 10 +- astrbot/core/db/vec_db/faiss_impl/vec_db.py | 10 +- astrbot/core/event_bus.py | 6 +- astrbot/core/file_token_service.py | 4 +- astrbot/core/initial_loader.py | 4 +- .../knowledge_base/chunking/fixed_size.py | 2 +- .../core/knowledge_base/chunking/recursive.py | 2 +- astrbot/core/knowledge_base/kb_db_sqlite.py | 49 +- astrbot/core/knowledge_base/kb_helper.py | 18 +- astrbot/core/knowledge_base/kb_mgr.py | 15 +- .../core/knowledge_base/parsers/url_parser.py | 2 +- .../core/knowledge_base/retrieval/manager.py | 9 +- .../knowledge_base/retrieval/rank_fusion.py | 2 +- .../retrieval/sparse_retriever.py | 2 +- astrbot/core/log.py | 561 +- astrbot/core/message/components.py | 159 +- astrbot/core/persona_mgr.py | 63 +- astrbot/core/pipeline/__init__.py | 100 +- astrbot/core/pipeline/bootstrap.py | 52 + .../pipeline/content_safety_check/stage.py | 2 +- astrbot/core/pipeline/context.py | 6 +- .../core/pipeline/process_stage/follow_up.py | 227 + .../method/agent_sub_stages/internal.py | 379 +- .../method/agent_sub_stages/third_party.py | 4 +- .../process_stage/method/star_request.py | 18 +- .../core/pipeline/rate_limit_check/stage.py | 2 +- astrbot/core/pipeline/respond/stage.py | 40 +- .../core/pipeline/result_decorate/stage.py | 3 +- astrbot/core/pipeline/scheduler.py | 27 +- astrbot/core/pipeline/stage_order.py | 15 + astrbot/core/platform/astr_message_event.py | 80 +- astrbot/core/platform/astrbot_message.py | 6 +- astrbot/core/platform/manager.py | 107 +- astrbot/core/platform/message_session.py | 2 +- astrbot/core/platform/platform.py | 14 +- astrbot/core/platform/platform_metadata.py | 11 + astrbot/core/platform/register.py | 5 + .../aiocqhttp/aiocqhttp_message_event.py | 21 +- .../aiocqhttp/aiocqhttp_platform_adapter.py | 60 +- .../sources/dingtalk/dingtalk_adapter.py | 501 +- .../sources/dingtalk/dingtalk_event.py | 143 +- .../core/platform/sources/discord/client.py | 10 +- .../platform/sources/discord/components.py | 8 +- .../discord/discord_platform_adapter.py | 18 +- .../sources/discord/discord_platform_event.py | 8 +- .../platform/sources/lark/lark_adapter.py | 500 +- .../core/platform/sources/lark/lark_event.py | 452 +- astrbot/core/platform/sources/lark/server.py | 6 +- .../platform/sources/line/line_adapter.py | 465 ++ .../core/platform/sources/line/line_api.py | 203 + .../core/platform/sources/line/line_event.py | 285 + .../sources/misskey/misskey_adapter.py | 20 +- .../platform/sources/misskey/misskey_api.py | 19 +- .../platform/sources/misskey/misskey_event.py | 4 +- .../platform/sources/misskey/misskey_utils.py | 4 +- .../qqofficial/qqofficial_message_event.py | 128 +- .../qqofficial/qqofficial_platform_adapter.py | 77 +- .../qqofficial_webhook/qo_webhook_adapter.py | 145 +- .../qqofficial_webhook/qo_webhook_event.py | 2 +- .../qqofficial_webhook/qo_webhook_server.py | 14 +- .../platform/sources/satori/satori_adapter.py | 18 +- .../platform/sources/satori/satori_event.py | 4 +- astrbot/core/platform/sources/slack/client.py | 20 +- .../platform/sources/slack/slack_adapter.py | 12 +- .../platform/sources/slack/slack_event.py | 4 +- .../platform/sources/telegram/tg_adapter.py | 213 +- .../platform/sources/telegram/tg_event.py | 231 +- .../sources/webchat/message_parts_helper.py | 465 ++ .../sources/webchat/webchat_adapter.py | 234 +- .../platform/sources/webchat/webchat_event.py | 41 +- .../sources/webchat/webchat_queue_mgr.py | 159 +- .../platform/sources/wecom/wecom_adapter.py | 72 +- .../platform/sources/wecom/wecom_event.py | 156 +- .../sources/wecom_ai_bot/WXBizJsonMsgCrypt.py | 7 +- .../sources/wecom_ai_bot/wecomai_adapter.py | 157 +- .../sources/wecom_ai_bot/wecomai_api.py | 2 +- .../sources/wecom_ai_bot/wecomai_event.py | 83 +- .../sources/wecom_ai_bot/wecomai_queue_mgr.py | 133 +- .../sources/wecom_ai_bot/wecomai_server.py | 10 +- .../sources/wecom_ai_bot/wecomai_webhook.py | 225 + .../weixin_offacc_adapter.py | 253 +- .../weixin_offacc_event.py | 126 +- astrbot/core/platform_message_history_mgr.py | 6 +- astrbot/core/provider/entities.py | 10 +- astrbot/core/provider/func_tool_manager.py | 6 +- astrbot/core/provider/manager.py | 32 +- astrbot/core/provider/provider.py | 20 +- .../core/provider/sources/anthropic_source.py | 79 +- .../core/provider/sources/azure_tts_source.py | 25 +- .../core/provider/sources/dashscope_tts.py | 4 +- .../core/provider/sources/edge_tts_source.py | 4 +- .../sources/fishaudio_tts_api_source.py | 19 +- .../sources/gemini_embedding_source.py | 16 + .../core/provider/sources/gemini_source.py | 30 +- .../provider/sources/gemini_tts_source.py | 13 +- astrbot/core/provider/sources/genie_tts.py | 10 +- .../provider/sources/gsv_selfhosted_source.py | 10 +- .../core/provider/sources/gsvi_tts_source.py | 4 +- .../sources/minimax_tts_api_source.py | 4 +- .../provider/sources/oai_aihubmix_source.py | 17 + .../sources/openai_embedding_source.py | 37 +- .../core/provider/sources/openai_source.py | 295 +- .../provider/sources/openai_tts_api_source.py | 16 +- .../provider/sources/openrouter_source.py | 19 + .../sources/sensevoice_selfhosted_source.py | 10 +- .../core/provider/sources/volcengine_tts.py | 10 +- .../provider/sources/whisper_api_source.py | 23 +- .../sources/whisper_selfhosted_source.py | 21 +- astrbot/core/provider/sources/xai_source.py | 4 +- .../sources/xinference_rerank_source.py | 2 +- .../sources/xinference_stt_provider.py | 16 +- astrbot/core/skills/skill_manager.py | 1 - astrbot/core/star/__init__.py | 77 +- astrbot/core/star/base.py | 87 + astrbot/core/star/command_management.py | 46 + astrbot/core/star/config.py | 4 +- astrbot/core/star/context.py | 30 +- astrbot/core/star/filter/command.py | 6 +- astrbot/core/star/filter/command_group.py | 6 +- astrbot/core/star/filter/custom_filter.py | 6 +- .../core/star/filter/event_message_type.py | 2 +- astrbot/core/star/filter/permission.py | 4 +- .../core/star/filter/platform_adapter_type.py | 8 +- astrbot/core/star/filter/regex.py | 2 +- astrbot/core/star/register/__init__.py | 6 + astrbot/core/star/register/star.py | 2 +- astrbot/core/star/register/star_handler.py | 63 +- astrbot/core/star/star.py | 6 + astrbot/core/star/star_handler.py | 41 +- astrbot/core/star/star_manager.py | 591 +- astrbot/core/star/star_tools.py | 2 +- astrbot/core/star/updator.py | 2 +- astrbot/core/subagent_orchestrator.py | 4 +- astrbot/core/tools/cron_tools.py | 22 +- astrbot/core/umop_config_router.py | 12 +- astrbot/core/updator.py | 95 +- astrbot/core/utils/active_event_registry.py | 67 + astrbot/core/utils/astrbot_path.py | 10 + astrbot/core/utils/http_ssl.py | 33 + astrbot/core/utils/io.py | 71 +- astrbot/core/utils/llm_metadata.py | 7 +- astrbot/core/utils/log_pipe.py | 6 +- astrbot/core/utils/media_utils.py | 318 + astrbot/core/utils/metrics.py | 2 +- astrbot/core/utils/network_utils.py | 102 + astrbot/core/utils/pip_installer.py | 616 +- astrbot/core/utils/quoted_message/__init__.py | 8 + .../core/utils/quoted_message/chain_parser.py | 503 ++ .../core/utils/quoted_message/extractor.py | 211 + .../core/utils/quoted_message/image_refs.py | 94 + .../utils/quoted_message/image_resolver.py | 130 + .../utils/quoted_message/onebot_client.py | 124 + astrbot/core/utils/quoted_message/settings.py | 85 + astrbot/core/utils/quoted_message_parser.py | 11 + astrbot/core/utils/runtime_env.py | 10 + astrbot/core/utils/session_lock.py | 2 +- astrbot/core/utils/session_waiter.py | 14 +- astrbot/core/utils/shared_preferences.py | 32 +- astrbot/core/utils/string_utils.py | 21 + astrbot/core/utils/t2i/network_strategy.py | 16 +- astrbot/core/utils/t2i/renderer.py | 4 +- astrbot/core/utils/t2i/template_manager.py | 14 +- astrbot/core/utils/temp_dir_cleaner.py | 150 + astrbot/core/utils/tencent_record_helper.py | 6 +- astrbot/core/utils/webhook_utils.py | 20 +- astrbot/core/zip_updator.py | 11 +- astrbot/dashboard/routes/__init__.py | 12 +- astrbot/dashboard/routes/api_key.py | 146 + astrbot/dashboard/routes/auth.py | 8 +- astrbot/dashboard/routes/backup.py | 14 +- astrbot/dashboard/routes/chat.py | 220 +- astrbot/dashboard/routes/command.py | 22 + astrbot/dashboard/routes/config.py | 117 +- astrbot/dashboard/routes/conversation.py | 4 +- astrbot/dashboard/routes/knowledge_base.py | 14 +- astrbot/dashboard/routes/live_chat.py | 769 ++- astrbot/dashboard/routes/open_api.py | 667 ++ astrbot/dashboard/routes/platform.py | 2 +- astrbot/dashboard/routes/plugin.py | 151 +- astrbot/dashboard/routes/route.py | 12 +- astrbot/dashboard/routes/stat.py | 39 + astrbot/dashboard/routes/static_file.py | 5 +- astrbot/dashboard/routes/t2i.py | 4 +- astrbot/dashboard/server.py | 579 +- astrbot/dashboard/utils.py | 9 +- astrbot/utils/__init__.py | 1 + astrbot/utils/http_ssl_common.py | 24 + changelogs/v4.14.5.md | 11 + changelogs/v4.14.6.md | 10 + changelogs/v4.14.7.md | 31 + changelogs/v4.14.8.md | 35 + changelogs/v4.15.0.md | 41 + changelogs/v4.16.0.md | 62 + changelogs/v4.17.0.md | 29 + changelogs/v4.17.1.md | 34 + changelogs/v4.17.2.md | 8 + changelogs/v4.17.3.md | 27 + changelogs/v4.17.4.md | 32 + changelogs/v4.17.5.md | 37 + changelogs/v4.17.6.md | 47 + changelogs/v4.18.0.md | 29 + changelogs/v4.18.1.md | 17 + changelogs/v4.18.2.md | 60 + changelogs/v4.18.3.md | 49 + dashboard/README.md | 9 +- dashboard/env.d.ts | 10 +- dashboard/pnpm-lock.yaml | 5491 +++++++++++++++++ dashboard/public/config.json | 13 - dashboard/src/App.vue | 28 +- .../src/assets/images/platform_logos/line.png | Bin 0 -> 1395 bytes dashboard/src/components/chat/Chat.vue | 19 +- dashboard/src/components/chat/ChatInput.vue | 27 +- .../src/components/chat/ConfigSelector.vue | 17 +- .../components/chat/ConversationSidebar.vue | 45 +- dashboard/src/components/chat/LiveMode.vue | 1070 ++-- dashboard/src/components/chat/MessageList.vue | 226 +- dashboard/src/components/chat/ProjectList.vue | 9 +- dashboard/src/components/chat/ProjectView.vue | 7 +- .../src/components/chat/StandaloneChat.vue | 482 +- .../config/AstrBotCoreConfigWrapper.vue | 83 +- .../config/UnsavedChangesConfirmDialog.vue | 98 + .../components/extension/MarketPluginCard.vue | 321 + .../extension/McpServersSection.vue | 30 +- .../components/CommandTable.vue | 38 +- .../composables/useCommandActions.ts | 34 +- .../extension/componentPanel/index.vue | 6 + .../components/platform/AddNewPlatform.vue | 127 +- .../provider/ProviderSourcesPanel.vue | 89 +- .../src/components/shared/AstrBotConfig.vue | 60 +- .../src/components/shared/AstrBotConfigV4.vue | 64 +- .../src/components/shared/BackupDialog.vue | 20 +- .../src/components/shared/ChangelogDialog.vue | 2 +- .../components/shared/ConfigItemRenderer.vue | 8 + .../components/shared/ConsoleDisplayer.vue | 222 +- .../src/components/shared/ExtensionCard.vue | 601 +- .../src/components/shared/MigrationDialog.vue | 13 +- .../src/components/shared/ObjectEditor.vue | 63 +- .../src/components/shared/PersonaForm.vue | 199 +- .../components/shared/PersonaQuickPreview.vue | 301 + .../src/components/shared/PersonaSelector.vue | 6 + .../components/shared/PluginPlatformChip.vue | 124 + .../components/shared/ProviderSelector.vue | 166 +- .../src/components/shared/ProxySelector.vue | 64 +- .../src/components/shared/ReadmeDialog.vue | 81 +- .../components/shared/TemplateListEditor.vue | 28 +- .../components/shared/WaitingForRestart.vue | 45 +- dashboard/src/composables/useMessages.ts | 1056 +++- .../src/composables/useProviderSources.ts | 32 +- dashboard/src/composables/useSessions.ts | 16 + dashboard/src/i18n/composables.ts | 45 + dashboard/src/i18n/loader.ts | 1 + .../src/i18n/locales/en-US/core/common.json | 25 + .../src/i18n/locales/en-US/core/header.json | 26 + .../i18n/locales/en-US/core/navigation.json | 6 + .../src/i18n/locales/en-US/core/shared.json | 22 +- .../src/i18n/locales/en-US/features/auth.json | 13 +- .../src/i18n/locales/en-US/features/chat.json | 14 +- .../i18n/locales/en-US/features/command.json | 4 +- .../en-US/features/config-metadata.json | 754 +++ .../i18n/locales/en-US/features/config.json | 18 + .../locales/en-US/features/extension.json | 24 +- .../i18n/locales/en-US/features/platform.json | 57 +- .../i18n/locales/en-US/features/provider.json | 10 +- .../i18n/locales/en-US/features/settings.json | 56 +- .../i18n/locales/en-US/features/subagent.json | 24 +- .../i18n/locales/en-US/features/welcome.json | 37 + .../src/i18n/locales/zh-CN/core/common.json | 25 + .../src/i18n/locales/zh-CN/core/header.json | 28 +- .../i18n/locales/zh-CN/core/navigation.json | 6 + .../src/i18n/locales/zh-CN/core/shared.json | 22 +- .../src/i18n/locales/zh-CN/features/auth.json | 13 +- .../src/i18n/locales/zh-CN/features/chat.json | 8 +- .../i18n/locales/zh-CN/features/command.json | 4 +- .../zh-CN/features/config-metadata.json | 764 ++- .../i18n/locales/zh-CN/features/config.json | 18 + .../locales/zh-CN/features/extension.json | 26 +- .../i18n/locales/zh-CN/features/persona.json | 2 +- .../i18n/locales/zh-CN/features/platform.json | 57 +- .../i18n/locales/zh-CN/features/provider.json | 10 +- .../i18n/locales/zh-CN/features/settings.json | 56 +- .../i18n/locales/zh-CN/features/subagent.json | 25 +- .../i18n/locales/zh-CN/features/welcome.json | 37 + dashboard/src/i18n/translations.ts | 8 +- dashboard/src/layouts/full/FullLayout.vue | 76 +- .../full/vertical-header/VerticalHeader.vue | 211 +- .../full/vertical-sidebar/VerticalSidebar.vue | 15 +- .../full/vertical-sidebar/sidebarItem.ts | 21 +- dashboard/src/main.ts | 257 +- dashboard/src/router/MainRoutes.ts | 17 +- dashboard/src/scss/_variables.scss | 10 +- dashboard/src/scss/layout/_container.scss | 6 +- dashboard/src/stores/api.ts | 70 - dashboard/src/stores/common.js | 18 +- dashboard/src/types/confirm.d.ts | 11 + dashboard/src/types/desktop-bridge.d.ts | 44 + dashboard/src/utils/chatConfigBinding.ts | 60 + dashboard/src/utils/confirmDialog.ts | 31 + dashboard/src/utils/desktopRuntime.ts | 32 + dashboard/src/utils/platformUtils.js | 31 + dashboard/src/utils/providerUtils.js | 3 + dashboard/src/utils/restartAstrBot.ts | 54 + dashboard/src/views/ConfigPage.vue | 336 +- dashboard/src/views/ConversationPage.vue | 16 +- dashboard/src/views/CronJobPage.vue | 9 +- dashboard/src/views/ExtensionPage.vue | 2464 +------- dashboard/src/views/PlatformPage.vue | 138 +- dashboard/src/views/SessionManagementPage.vue | 11 +- dashboard/src/views/Settings.vue | 750 ++- dashboard/src/views/SubAgentPage.vue | 444 +- dashboard/src/views/WelcomePage.vue | 433 ++ .../views/authentication/auth/LoginPage.vue | 219 +- .../views/extension/InstalledPluginsTab.vue | 639 ++ .../src/views/extension/MarketPluginsTab.vue | 373 ++ .../src/views/extension/useExtensionPage.js | 1466 +++++ .../views/knowledge-base/DocumentDetail.vue | 5 +- .../src/views/persona/PersonaManager.vue | 41 +- dashboard/tsconfig.json | 26 +- dashboard/tsconfig.vite-config.json | 8 +- dashboard/vite.config.ts | 48 +- main.py | 43 +- openapi.json | 685 ++ pyproject.toml | 13 +- requirements.txt | 9 +- runtime_bootstrap.py | 50 + scripts/generate_changelog.py | 2 +- tests/conftest.py | 381 ++ tests/fixtures/__init__.py | 64 + tests/fixtures/configs/test_cmd_config.json | 21 + tests/fixtures/helpers.py | 332 + tests/fixtures/messages/test_messages.json | 33 + tests/fixtures/mocks/__init__.py | 43 + tests/fixtures/mocks/aiocqhttp.py | 58 + tests/fixtures/mocks/discord.py | 140 + tests/fixtures/mocks/telegram.py | 141 + tests/fixtures/plugins/fixture_plugin.py | 40 + tests/fixtures/plugins/metadata.yaml | 5 + tests/test_api_key_open_api.py | 334 + tests/test_openai_source.py | 382 ++ tests/test_quoted_message_parser.py | 494 ++ tests/test_smoke.py | 115 + tests/test_temp_dir_cleaner.py | 52 + tests/test_tool_loop_agent_runner.py | 214 + tests/unit/test_astr_message_event.py | 781 +++ tests/unit/test_astrbot_message.py | 268 + 436 files changed, 37524 insertions(+), 8873 deletions(-) delete mode 100644 .github/workflows/auto_release.yml create mode 100644 .github/workflows/release.yml create mode 100644 FIRST_NOTICE.en-US.md create mode 100644 FIRST_NOTICE.md create mode 100644 astrbot/core/agent/tool_image_cache.py create mode 100644 astrbot/core/computer/tools/permissions.py create mode 100644 astrbot/core/pipeline/bootstrap.py create mode 100644 astrbot/core/pipeline/process_stage/follow_up.py create mode 100644 astrbot/core/pipeline/stage_order.py create mode 100644 astrbot/core/platform/sources/line/line_adapter.py create mode 100644 astrbot/core/platform/sources/line/line_api.py create mode 100644 astrbot/core/platform/sources/line/line_event.py create mode 100644 astrbot/core/platform/sources/webchat/message_parts_helper.py create mode 100644 astrbot/core/platform/sources/wecom_ai_bot/wecomai_webhook.py create mode 100644 astrbot/core/provider/sources/oai_aihubmix_source.py create mode 100644 astrbot/core/provider/sources/openrouter_source.py create mode 100644 astrbot/core/star/base.py create mode 100644 astrbot/core/utils/active_event_registry.py create mode 100644 astrbot/core/utils/http_ssl.py create mode 100644 astrbot/core/utils/media_utils.py create mode 100644 astrbot/core/utils/network_utils.py create mode 100644 astrbot/core/utils/quoted_message/__init__.py create mode 100644 astrbot/core/utils/quoted_message/chain_parser.py create mode 100644 astrbot/core/utils/quoted_message/extractor.py create mode 100644 astrbot/core/utils/quoted_message/image_refs.py create mode 100644 astrbot/core/utils/quoted_message/image_resolver.py create mode 100644 astrbot/core/utils/quoted_message/onebot_client.py create mode 100644 astrbot/core/utils/quoted_message/settings.py create mode 100644 astrbot/core/utils/quoted_message_parser.py create mode 100644 astrbot/core/utils/runtime_env.py create mode 100644 astrbot/core/utils/string_utils.py create mode 100644 astrbot/core/utils/temp_dir_cleaner.py create mode 100644 astrbot/dashboard/routes/api_key.py create mode 100644 astrbot/dashboard/routes/open_api.py create mode 100644 astrbot/utils/__init__.py create mode 100644 astrbot/utils/http_ssl_common.py create mode 100644 changelogs/v4.14.5.md create mode 100644 changelogs/v4.14.6.md create mode 100644 changelogs/v4.14.7.md create mode 100644 changelogs/v4.14.8.md create mode 100644 changelogs/v4.15.0.md create mode 100644 changelogs/v4.16.0.md create mode 100644 changelogs/v4.17.0.md create mode 100644 changelogs/v4.17.1.md create mode 100644 changelogs/v4.17.2.md create mode 100644 changelogs/v4.17.3.md create mode 100644 changelogs/v4.17.4.md create mode 100644 changelogs/v4.17.5.md create mode 100644 changelogs/v4.17.6.md create mode 100644 changelogs/v4.18.0.md create mode 100644 changelogs/v4.18.1.md create mode 100644 changelogs/v4.18.2.md create mode 100644 changelogs/v4.18.3.md create mode 100644 dashboard/pnpm-lock.yaml delete mode 100644 dashboard/public/config.json create mode 100644 dashboard/src/assets/images/platform_logos/line.png create mode 100644 dashboard/src/components/config/UnsavedChangesConfirmDialog.vue create mode 100644 dashboard/src/components/extension/MarketPluginCard.vue create mode 100644 dashboard/src/components/shared/PersonaQuickPreview.vue create mode 100644 dashboard/src/components/shared/PluginPlatformChip.vue create mode 100644 dashboard/src/i18n/locales/en-US/features/welcome.json create mode 100644 dashboard/src/i18n/locales/zh-CN/features/welcome.json delete mode 100644 dashboard/src/stores/api.ts create mode 100644 dashboard/src/types/confirm.d.ts create mode 100644 dashboard/src/types/desktop-bridge.d.ts create mode 100644 dashboard/src/utils/chatConfigBinding.ts create mode 100644 dashboard/src/utils/confirmDialog.ts create mode 100644 dashboard/src/utils/desktopRuntime.ts create mode 100644 dashboard/src/utils/restartAstrBot.ts create mode 100644 dashboard/src/views/WelcomePage.vue create mode 100644 dashboard/src/views/extension/InstalledPluginsTab.vue create mode 100644 dashboard/src/views/extension/MarketPluginsTab.vue create mode 100644 dashboard/src/views/extension/useExtensionPage.js create mode 100644 openapi.json create mode 100644 runtime_bootstrap.py create mode 100644 tests/conftest.py create mode 100644 tests/fixtures/__init__.py create mode 100644 tests/fixtures/configs/test_cmd_config.json create mode 100644 tests/fixtures/helpers.py create mode 100644 tests/fixtures/messages/test_messages.json create mode 100644 tests/fixtures/mocks/__init__.py create mode 100644 tests/fixtures/mocks/aiocqhttp.py create mode 100644 tests/fixtures/mocks/discord.py create mode 100644 tests/fixtures/mocks/telegram.py create mode 100644 tests/fixtures/plugins/fixture_plugin.py create mode 100644 tests/fixtures/plugins/metadata.yaml create mode 100644 tests/test_api_key_open_api.py create mode 100644 tests/test_openai_source.py create mode 100644 tests/test_quoted_message_parser.py create mode 100644 tests/test_smoke.py create mode 100644 tests/test_temp_dir_cleaner.py create mode 100644 tests/unit/test_astr_message_event.py create mode 100644 tests/unit/test_astrbot_message.py diff --git a/.dockerignore b/.dockerignore index 965adc9e1..7a61edd14 100644 --- a/.dockerignore +++ b/.dockerignore @@ -17,7 +17,6 @@ ENV/ .conda/ dashboard/ data/ -changelogs/ tests/ .ruff_cache/ .astrbot diff --git a/.github/ISSUE_TEMPLATE/feature-request.yml b/.github/ISSUE_TEMPLATE/feature-request.yml index 484959318..c97eb1a4c 100644 --- a/.github/ISSUE_TEMPLATE/feature-request.yml +++ b/.github/ISSUE_TEMPLATE/feature-request.yml @@ -1,42 +1,40 @@ -name: '🎉 功能建议' +name: '🎉 Feature Request / 功能建议' title: "[Feature]" -description: 提交建议帮助我们改进。 +description: Submit a suggestion to help us improve. / 提交建议帮助我们改进。 labels: [ "enhancement" ] body: - type: markdown attributes: value: | - 感谢您抽出时间提出新功能建议,请准确解释您的想法。 + Thank you for taking the time to suggest a new feature! Please explain your idea clearly and accurately. / 感谢您抽出时间提出新功能建议,请准确解释您的想法。 - type: textarea attributes: - label: 描述 - description: 简短描述您的功能建议。 + label: Description / 描述 + description: Please describe the feature you want to be added in detail. / 请详细描述您希望添加的功能。 - type: textarea attributes: - label: 使用场景 - description: 你想要发生什么? - placeholder: > - 一个清晰且具体的描述这个功能的使用场景。 + label: Use Case / 使用场景 + description: Please describe the use case for this feature. / 请描述这个功能的使用场景。 - type: checkboxes attributes: - label: 你愿意提交PR吗? + label: Willing to Submit PR? / 是否愿意提交PR? description: > - 这不是必须的,但我们欢迎您的贡献。 + This is not required, but if you are willing to submit a PR to implement this feature, it would be greatly appreciated! / 这不是必需的,但如果您愿意提交 PR 来实现这个功能,我们将不胜感激! options: - - label: 是的, 我愿意提交PR! + - label: Yes, I am willing to submit a PR. / 是的,我愿意提交 PR。 - type: checkboxes attributes: label: Code of Conduct options: - label: > - 我已阅读并同意遵守该项目的 [行为准则](https://docs.github.com/zh/site-policy/github-terms/github-community-code-of-conduct)。 + I have read and agree to abide by the project's [Code of Conduct](https://docs.github.com/zh/site-policy/github-terms/github-community-code-of-conduct). / required: true - type: markdown attributes: - value: "感谢您填写我们的表单!" \ No newline at end of file + value: "Thank you for filling out our form!" \ No newline at end of file diff --git a/.github/workflows/auto_release.yml b/.github/workflows/auto_release.yml deleted file mode 100644 index f13f3ae51..000000000 --- a/.github/workflows/auto_release.yml +++ /dev/null @@ -1,92 +0,0 @@ -on: - push: - tags: - - 'v*' - workflow_dispatch: - -name: Auto Release - -jobs: - build-and-publish-to-github-release: - runs-on: ubuntu-latest - permissions: - contents: write - steps: - - name: Checkout repository - uses: actions/checkout@v6 - - - name: Dashboard Build - run: | - cd dashboard - npm install - npm run build - echo "COMMIT_SHA=$(git rev-parse HEAD)" >> $GITHUB_ENV - echo ${{ github.ref_name }} > dist/assets/version - zip -r dist.zip dist - - - name: Upload to Cloudflare R2 - env: - R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }} - R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} - R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} - R2_BUCKET_NAME: "astrbot" - R2_OBJECT_NAME: "astrbot-webui-latest.zip" - VERSION_TAG: ${{ github.ref_name }} - run: | - echo "Installing rclone..." - curl https://rclone.org/install.sh | sudo bash - - echo "Configuring rclone remote..." - mkdir -p ~/.config/rclone - cat < ~/.config/rclone/rclone.conf - [r2] - type = s3 - provider = Cloudflare - access_key_id = $R2_ACCESS_KEY_ID - secret_access_key = $R2_SECRET_ACCESS_KEY - endpoint = https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com - EOF - - echo "Uploading dist.zip to R2 bucket: $R2_BUCKET_NAME/$R2_OBJECT_NAME" - mv dashboard/dist.zip dashboard/$R2_OBJECT_NAME - rclone copy dashboard/$R2_OBJECT_NAME r2:$R2_BUCKET_NAME --progress - mv dashboard/$R2_OBJECT_NAME dashboard/astrbot-webui-${VERSION_TAG}.zip - rclone copy dashboard/astrbot-webui-${VERSION_TAG}.zip r2:$R2_BUCKET_NAME --progress - mv dashboard/astrbot-webui-${VERSION_TAG}.zip dashboard/dist.zip - - - name: Fetch Changelog - run: | - echo "changelog=changelogs/${{github.ref_name}}.md" >> "$GITHUB_ENV" - - - name: Create GitHub Release - uses: ncipollo/release-action@v1 - with: - bodyFile: ${{ env.changelog }} - artifacts: "dashboard/dist.zip" - - build-and-publish-to-pypi: - # 构建并发布到 PyPI - runs-on: ubuntu-latest - needs: build-and-publish-to-github-release - steps: - - name: Checkout repository - uses: actions/checkout@v6 - - - name: Set up Python - uses: actions/setup-python@v6 - with: - python-version: '3.10' - - - name: Install uv - run: | - python -m pip install uv - - - name: Build package - run: | - uv build - - - name: Publish to PyPI - env: - UV_PUBLISH_TOKEN: ${{ secrets.PYPI_TOKEN }} - run: | - uv publish diff --git a/.github/workflows/code-format.yml b/.github/workflows/code-format.yml index a183f1bb2..3de1bea55 100644 --- a/.github/workflows/code-format.yml +++ b/.github/workflows/code-format.yml @@ -17,7 +17,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v6 with: - python-version: '3.10' + python-version: '3.12' - name: Install UV run: pip install uv diff --git a/.github/workflows/coverage_test.yml b/.github/workflows/coverage_test.yml index 6ae8c7b9b..f0019ee7e 100644 --- a/.github/workflows/coverage_test.yml +++ b/.github/workflows/coverage_test.yml @@ -37,7 +37,7 @@ jobs: mkdir -p data/temp export TESTING=true export ZHIPU_API_KEY=${{ secrets.OPENAI_API_KEY }} - pytest --cov=. -v -o log_cli=true -o log_level=DEBUG + pytest --cov=astrbot -v -o log_cli=true -o log_level=DEBUG - name: Upload results to Codecov uses: codecov/codecov-action@v5 diff --git a/.github/workflows/dashboard_ci.yml b/.github/workflows/dashboard_ci.yml index f403da773..5be935ebc 100644 --- a/.github/workflows/dashboard_ci.yml +++ b/.github/workflows/dashboard_ci.yml @@ -16,7 +16,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v6 with: - node-version: 'latest' + node-version: '24.13.0' - name: npm install, build run: | @@ -52,4 +52,4 @@ jobs: repo: astrbot-release-harbour body: "Automated release from commit ${{ github.sha }}" token: ${{ secrets.ASTRBOT_HARBOUR_TOKEN }} - artifacts: "dashboard/dist.zip" \ No newline at end of file + artifacts: "dashboard/dist.zip" diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 0d1550e1b..18c8d4926 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest env: DOCKER_HUB_USERNAME: ${{ secrets.DOCKER_HUB_USERNAME }} - GHCR_OWNER: soulter + GHCR_OWNER: astrbotdevs HAS_GHCR_TOKEN: ${{ secrets.GHCR_GITHUB_TOKEN != '' }} steps: @@ -113,7 +113,7 @@ jobs: runs-on: ubuntu-latest env: DOCKER_HUB_USERNAME: ${{ secrets.DOCKER_HUB_USERNAME }} - GHCR_OWNER: soulter + GHCR_OWNER: astrbotdevs HAS_GHCR_TOKEN: ${{ secrets.GHCR_GITHUB_TOKEN != '' }} steps: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..8d5791ba3 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,212 @@ +name: Release + +on: + push: + tags: + - "v*" + workflow_dispatch: + inputs: + ref: + description: "Git ref to build (branch/tag/SHA)" + required: false + default: "master" + tag: + description: "Release tag to publish assets to (for example: v4.14.6)" + required: false + +permissions: + contents: write + +jobs: + build-dashboard: + name: Build Dashboard + runs-on: ubuntu-24.04 + env: + R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }} + R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} + R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + fetch-depth: 0 + ref: ${{ inputs.ref || github.ref }} + + - name: Resolve tag + id: tag + shell: bash + run: | + if [ "${{ github.event_name }}" = "push" ]; then + tag="${GITHUB_REF_NAME}" + elif [ -n "${{ inputs.tag }}" ]; then + tag="${{ inputs.tag }}" + else + tag="$(git describe --tags --abbrev=0)" + fi + if [ -z "$tag" ]; then + echo "Failed to resolve tag." >&2 + exit 1 + fi + echo "tag=$tag" >> "$GITHUB_OUTPUT" + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.28.2 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '24.13.0' + cache: "pnpm" + cache-dependency-path: dashboard/pnpm-lock.yaml + + - name: Build dashboard dist + shell: bash + run: | + pnpm --dir dashboard install --frozen-lockfile + pnpm --dir dashboard run build + echo "${{ steps.tag.outputs.tag }}" > dashboard/dist/assets/version + cd dashboard + zip -r "AstrBot-${{ steps.tag.outputs.tag }}-dashboard.zip" dist + + - name: Upload dashboard artifact + uses: actions/upload-artifact@v6 + with: + name: Dashboard-${{ steps.tag.outputs.tag }} + if-no-files-found: error + path: dashboard/AstrBot-${{ steps.tag.outputs.tag }}-dashboard.zip + + - name: Upload dashboard package to Cloudflare R2 + if: ${{ env.R2_ACCOUNT_ID != '' && env.R2_ACCESS_KEY_ID != '' && env.R2_SECRET_ACCESS_KEY != '' }} + env: + R2_BUCKET_NAME: "astrbot" + R2_OBJECT_NAME: "astrbot-webui-latest.zip" + VERSION_TAG: ${{ steps.tag.outputs.tag }} + shell: bash + run: | + curl https://rclone.org/install.sh | sudo bash + + mkdir -p ~/.config/rclone + cat < ~/.config/rclone/rclone.conf + [r2] + type = s3 + provider = Cloudflare + access_key_id = $R2_ACCESS_KEY_ID + secret_access_key = $R2_SECRET_ACCESS_KEY + endpoint = https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com + EOF + + cp "dashboard/AstrBot-${VERSION_TAG}-dashboard.zip" "dashboard/${R2_OBJECT_NAME}" + rclone copy "dashboard/${R2_OBJECT_NAME}" "r2:${R2_BUCKET_NAME}" --progress + cp "dashboard/AstrBot-${VERSION_TAG}-dashboard.zip" "dashboard/astrbot-webui-${VERSION_TAG}.zip" + rclone copy "dashboard/astrbot-webui-${VERSION_TAG}.zip" "r2:${R2_BUCKET_NAME}" --progress + + publish-release: + name: Publish GitHub Release + runs-on: ubuntu-24.04 + needs: + - build-dashboard + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + fetch-depth: 0 + ref: ${{ inputs.ref || github.ref }} + + - name: Resolve tag + id: tag + shell: bash + run: | + if [ "${{ github.event_name }}" = "push" ]; then + tag="${GITHUB_REF_NAME}" + elif [ -n "${{ inputs.tag }}" ]; then + tag="${{ inputs.tag }}" + else + tag="$(git describe --tags --abbrev=0)" + fi + if [ -z "$tag" ]; then + echo "Failed to resolve tag." >&2 + exit 1 + fi + echo "tag=$tag" >> "$GITHUB_OUTPUT" + + - name: Download dashboard artifact + uses: actions/download-artifact@v7 + with: + name: Dashboard-${{ steps.tag.outputs.tag }} + path: release-assets + + + - name: Resolve release notes + id: notes + shell: bash + run: | + note_file="changelogs/${{ steps.tag.outputs.tag }}.md" + if [ ! -f "$note_file" ]; then + note_file="$(mktemp)" + echo "Release ${{ steps.tag.outputs.tag }}" > "$note_file" + fi + echo "file=$note_file" >> "$GITHUB_OUTPUT" + + - name: Ensure release exists + env: + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + tag="${{ steps.tag.outputs.tag }}" + if ! gh release view "$tag" >/dev/null 2>&1; then + gh release create "$tag" --title "$tag" --notes-file "${{ steps.notes.outputs.file }}" + fi + + - name: Remove stale assets from release + env: + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + tag="${{ steps.tag.outputs.tag }}" + while IFS= read -r asset; do + case "$asset" in + *.AppImage|*.dmg|*.zip|*.exe|*.blockmap) + gh release delete-asset "$tag" "$asset" -y || true + ;; + esac + done < <(gh release view "$tag" --json assets --jq '.assets[].name') + + - name: Upload assets to release + env: + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + tag="${{ steps.tag.outputs.tag }}" + gh release upload "$tag" release-assets/* --clobber + + publish-pypi: + name: Publish PyPI + runs-on: ubuntu-24.04 + needs: publish-release + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + fetch-depth: 0 + ref: ${{ inputs.ref || github.ref }} + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.10" + + - name: Install uv + shell: bash + run: python -m pip install uv + + - name: Build package + shell: bash + run: uv build + + - name: Publish to PyPI + env: + UV_PUBLISH_TOKEN: ${{ secrets.PYPI_TOKEN }} + shell: bash + run: uv publish diff --git a/.gitignore b/.gitignore index 9ac4f1429..e3ffbd473 100644 --- a/.gitignore +++ b/.gitignore @@ -32,8 +32,8 @@ tests/astrbot_plugin_openai # Dashboard dashboard/node_modules/ dashboard/dist/ +.pnpm-store/ package-lock.json -package.json yarn.lock # Operating System @@ -53,4 +53,4 @@ IFLOW.md # genie_tts data CharacterModels/ -GenieData/ \ No newline at end of file +GenieData/ diff --git a/.python-version b/.python-version index c8cfe3959..fdcfcfdfc 100644 --- a/.python-version +++ b/.python-version @@ -1 +1 @@ -3.10 +3.12 \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 2ad76a28b..9f3617ce9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,6 +26,7 @@ Runs on `http://localhost:3000` by default. 3. After finishing, use `ruff format .` and `ruff check .` to format and check the code. 4. When committing, ensure to use conventional commits messages, such as `feat: add new agent for data analysis` or `fix: resolve bug in provider manager`. 5. Use English for all new comments. +6. For path handling, use `pathlib.Path` instead of string paths, and use `astrbot.core.utils.path_utils` to get the AstrBot data and temp directory. ## PR instructions diff --git a/Dockerfile b/Dockerfile index f143cdd64..992060d6e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.11-slim +FROM python:3.12-slim WORKDIR /AstrBot COPY . /AstrBot/ @@ -15,17 +15,17 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ curl \ gnupg \ git \ + && curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - \ + && apt-get install -y --no-install-recommends nodejs \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* -RUN apt-get update && apt-get install -y curl gnupg \ - && curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - \ - && apt-get install -y nodejs - RUN python -m pip install uv \ - && echo "3.11" > .python-version -RUN uv pip install -r requirements.txt --no-cache-dir --system -RUN uv pip install socksio uv pilk --no-cache-dir --system + && echo "3.12" > .python-version \ + && uv lock \ + && uv export --format requirements.txt --output-file requirements.txt --frozen \ + && uv pip install -r requirements.txt --no-cache-dir --system \ + && uv pip install socksio uv pilk --no-cache-dir --system EXPOSE 6185 diff --git a/FIRST_NOTICE.en-US.md b/FIRST_NOTICE.en-US.md new file mode 100644 index 000000000..ba717b5ef --- /dev/null +++ b/FIRST_NOTICE.en-US.md @@ -0,0 +1,14 @@ +## Welcome to AstrBot + +🌟 Thank you for using AstrBot! + +AstrBot is an Agentic AI assistant for personal and group chats, with support for multiple IM platforms and a wide range of built-in features. We hope it brings you an efficient and enjoyable experience. ❤️ + +Important notice: + +AstrBot is a **free and open-source software project** protected by the AGPLv3 license. You can find the full source code and related resources on our [**official website**](https://astrbot.app) and [**GitHub**](https://github.com/astrbotdevs/astrbot). +As of now, AstrBot has **no commercial services of any kind**, and the official team **will never charge users any fees** under any name. + +If anyone asks you to pay while using AstrBot, **you are likely being scammed**. Please request a refund immediately and report it to us by email. + +📮 Official email: [community@astrbot.app](mailto:community@astrbot.app) diff --git a/FIRST_NOTICE.md b/FIRST_NOTICE.md new file mode 100644 index 000000000..bc739ed73 --- /dev/null +++ b/FIRST_NOTICE.md @@ -0,0 +1,14 @@ +## 欢迎使用 AstrBot + +🌟 感谢您使用 AstrBot! + +AstrBot 是一款可接入多种 IM 平台的 Agentic AI 个人 / 群聊助手,内置多项强大功能,希望能为您带来高效、愉快的使用体验。❤️ + +我们想特别说明: + +AstrBot 是受 AGPLv3 开源协议保护的**免费开源软件项目**,您可以在[**官方网站**](https://astrbot.app)、[**GitHub**](https://github.com/astrbotdevs/astrbot) 上找到 AstrBot 的全部源代码及相关资源。 +截至目前,AstrBot 项目**未开展任何形式的商业化服务**,官方**不会以任何名义向用户收取费用**。 + +如果您在使用 AstrBot 的过程中被要求付费,**表明您已经遭遇诈骗行为**。请立即向相关方申请退款,并及时通过邮件向我们反馈。 + +📮 官方邮箱:[community@astrbot.app](mailto:community@astrbot.app) diff --git a/README.md b/README.md index f4640ee43..23eebe39c 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,6 @@
- English日本語繁體中文 | @@ -41,14 +40,14 @@ AstrBot 是一个开源的一站式 Agentic 个人和群聊助手,可在 QQ、 ## 主要功能 1. 💯 免费 & 开源。 -1. ✨ AI 大模型对话,多模态,Agent,MCP,Skills,知识库,人格设定,自动压缩对话。 -2. 🤖 支持接入 Dify、阿里云百炼、Coze 等智能体平台。 -2. 🌐 多平台,支持 QQ、企业微信、飞书、钉钉、微信公众号、Telegram、Slack 以及[更多](#支持的消息平台)。 -3. 📦 插件扩展,已有近 800 个插件可一键安装。 -5. 🛡️ [Agent Sandbox](https://docs.astrbot.app/use/astrbot-agent-sandbox.html) 隔离化环境,安全地执行任何代码、调用 Shell、会话级资源复用。 -6. 💻 WebUI 支持。 -7. 🌈 Web ChatUI 支持,ChatUI 内置代理沙盒、网页搜索等。 -8. 🌐 国际化(i18n)支持。 +2. ✨ AI 大模型对话,多模态,Agent,MCP,Skills,知识库,人格设定,自动压缩对话。 +3. 🤖 支持接入 Dify、阿里云百炼、Coze 等智能体平台。 +4. 🌐 多平台,支持 QQ、企业微信、飞书、钉钉、微信公众号、Telegram、Slack 以及[更多](#支持的消息平台)。 +5. 📦 插件扩展,已有 1000+ 个插件可一键安装。 +6. 🛡️ [Agent Sandbox](https://docs.astrbot.app/use/astrbot-agent-sandbox.html) 隔离化环境,安全地执行任何代码、调用 Shell、会话级资源复用。 +7. 💻 WebUI 支持。 +8. 🌈 Web ChatUI 支持,ChatUI 内置代理沙盒、网页搜索等。 +9. 🌐 国际化(i18n)支持。
@@ -57,7 +56,7 @@ AstrBot 是一个开源的一站式 Agentic 个人和群聊助手,可在 QQ、 💙 角色扮演 & 情感陪伴 ✨ 主动式 Agent 🚀 通用 Agentic 能力 - 🧩 900+ 社区插件 + 🧩 1000+ 社区插件

99b587c5d35eea09d84f33e6cf6cfd4f

@@ -78,9 +77,20 @@ AstrBot 是一个开源的一站式 Agentic 个人和群聊助手,可在 QQ、 #### uv 部署 ```bash -uvx astrbot +uv tool install astrbot +astrbot ``` +#### 桌面应用部署(Tauri) + +桌面应用仓库 [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop)。 + +支持多系统架构,安装包直接安装,开箱即用,最适合新手和懒人的一键桌面部署方案,不推荐服务器场景。 + +#### 启动器一键部署(AstrBot Launcher) + +快速部署和多开方案,实现环境隔离,进入 [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) 仓库,在 Releases 页最新版本下找到对应的系统安装包安装即可。 + #### 宝塔面板部署 AstrBot 与宝塔面板合作,已上架至宝塔面板。 @@ -132,11 +142,22 @@ uv run main.py 或者请参阅官方文档 [通过源码部署 AstrBot](https://astrbot.app/deploy/astrbot/cli.html) 。 +#### 系统包管理器安装 + +##### Arch Linux + +```bash +yay -S astrbot-git +# 或者使用 paru +paru -S astrbot-git +``` + ## 支持的消息平台 **官方维护** -- QQ (官方平台 & OneBot) +- QQ +- OneBot v11 协议实现 - Telegram - 企微应用 & 企微智能机器人 - 微信客服 & 微信公众号 @@ -144,10 +165,10 @@ uv run main.py - 钉钉 - Slack - Discord +- LINE - Satori - Misskey - Whatsapp (将支持) -- LINE (将支持) **社区维护** @@ -167,6 +188,7 @@ uv run main.py - DeepSeek - Ollama (本地部署) - LM Studio (本地部署) +- [AIHubMix](https://aihubmix.com/?aff=4bfH) - [优云智算](https://www.compshare.cn/?ytag=GPU_YY-gh_astrbot&referral_code=FV7DcGowN4hB5UuXKgpE74) - [302.AI](https://share.302.ai/rr1M3l) - [小马算力](https://www.tokenpony.cn/3YPyf) @@ -242,13 +264,23 @@ pre-commit install 特别感谢所有 Contributors 和插件开发者对 AstrBot 的贡献 ❤️ - + 此外,本项目的诞生离不开以下开源项目的帮助: - [NapNeko/NapCatQQ](https://github.com/NapNeko/NapCatQQ) - 伟大的猫猫框架 +开源项目友情链接: + +- [NoneBot2](https://github.com/nonebot/nonebot2) - 优秀的 Python 异步 ChatBot 框架 +- [Koishi](https://github.com/koishijs/koishi) - 优秀的 Node.js ChatBot 框架 +- [MaiBot](https://github.com/Mai-with-u/MaiBot) - 优秀的拟人化 AI ChatBot +- [nekro-agent](https://github.com/KroMiose/nekro-agent) - 优秀的 Agent ChatBot +- [LangBot](https://github.com/langbot-app/LangBot) - 优秀的多平台 AI ChatBot +- [ChatLuna](https://github.com/ChatLunaLab/chatluna) - 优秀的多平台 AI ChatBot Koishi 插件 +- [Operit AI](https://github.com/AAswordman/Operit) - 优秀的 AI 智能助手 Android APP + ## ⭐ Star History > [!TIP] @@ -260,8 +292,6 @@ pre-commit install
- -
_陪伴与能力从来不应该是对立面。我们希望创造的是一个既能理解情绪、给予陪伴,也能可靠完成工作的机器人。_ @@ -270,3 +300,4 @@ _私は、高性能ですから!_ +
diff --git a/README_en.md b/README_en.md index 54b0fa605..217859d8e 100644 --- a/README_en.md +++ b/README_en.md @@ -3,7 +3,6 @@
中文 | -English日本語繁體中文Français | @@ -38,7 +37,7 @@ AstrBot is an open-source all-in-one Agent chatbot platform that integrates with mainstream instant messaging apps. It provides reliable and scalable conversational AI infrastructure for individuals, developers, and teams. Whether you're building a personal AI companion, intelligent customer service, automation assistant, or enterprise knowledge base, AstrBot enables you to quickly build production-ready AI applications within your IM platform workflows. -![070d50ba43ea3c96980787127bbbe552](https://github.com/user-attachments/assets/6fe147c5-68d9-4f47-a8de-252e63fdcbd8) +![521771166-00782c4c-4437-4d97-aabc-605e3738da5c (1)](https://github.com/user-attachments/assets/61e7b505-f7db-41aa-a75f-4ef8f079b8ba) ## Key Features @@ -46,12 +45,29 @@ AstrBot is an open-source all-in-one Agent chatbot platform that integrates with 2. ✨ AI LLM Conversations, Multimodal, Agent, MCP, Skills, Knowledge Base, Persona Settings, Auto Context Compression. 3. 🤖 Supports integration with Dify, Alibaba Cloud Bailian, Coze, and other agent platforms. 4. 🌐 Multi-Platform: QQ, WeChat Work, Feishu, DingTalk, WeChat Official Accounts, Telegram, Slack, and [more](#supported-messaging-platforms). -5. 📦 Plugin Extensions with nearly 800 plugins available for one-click installation. +5. 📦 Plugin Extensions with 1000+ plugins available for one-click installation. 6. 🛡️ [Agent Sandbox](https://docs.astrbot.app/use/astrbot-agent-sandbox.html) for isolated, safe execution of code, shell calls, and session-level resource reuse. 7. 💻 WebUI Support. 8. 🌈 Web ChatUI Support with built-in agent sandbox and web search. 9. 🌐 Internationalization (i18n) Support. +
+ + + + + + + + + + + + + + +
💙 Role-playing & Emotional Companionship✨ Proactive Agent🚀 General Agentic Capabilities🧩 1000+ Community Plugins

99b587c5d35eea09d84f33e6cf6cfd4f

c449acd838c41d0915cc08a3824025b1

image

image

+ ## Quick Start #### Docker Deployment (Recommended 🥳) @@ -63,9 +79,30 @@ Please refer to the official documentation: [Deploy AstrBot with Docker](https:/ #### uv Deployment ```bash -uvx astrbot +uv tool install astrbot +astrbot +``` + +#### System Package Manager Installation + +##### Arch Linux + +```bash +yay -S astrbot-git +# or use paru +paru -S astrbot-git ``` +#### Desktop Application (Tauri) + +Desktop repository: [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop). + +Supports multiple system architectures, direct installation, out-of-the-box experience. Ideal for beginners. + +#### AstrBot Launcher + +Quick deployment and multi-instance solution. Visit the [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) repository and find the latest release for your system. + #### BT-Panel Deployment AstrBot has partnered with BT-Panel and is now available in their marketplace. @@ -131,8 +168,8 @@ Or refer to the official documentation: [Deploy AstrBot from Source](https://ast - Discord - Satori - Misskey +- LINE - WhatsApp (Coming Soon) -- LINE (Coming Soon) **Community Maintained** @@ -155,7 +192,7 @@ Or refer to the official documentation: [Deploy AstrBot from Source](https://ast - [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_astrbot&referral_code=FV7DcGowN4hB5UuXKgpE74) - [302.AI](https://share.302.ai/rr1M3l) - [TokenPony](https://www.tokenpony.cn/3YPyf) -- [SiliconFlow](https://docs.siliconflow.cn/cn/usecases/use-siliconcloud-in-astrbot) +- [SiliconFlow](https://docs.siliconflow.cn/cn/usercases/use-siliconcloud-in-astrbot) - [PPIO Cloud](https://ppio.com/user/register?invited_by=AIOONE) - ModelScope - OneAPI @@ -227,7 +264,7 @@ pre-commit install Special thanks to all Contributors and plugin developers for their contributions to AstrBot ❤️ - + Additionally, the birth of this project would not have been possible without the help of the following open-source projects: @@ -245,10 +282,10 @@ Additionally, the birth of this project would not have been possible without the
- -
+_Companionship and capability should never be at odds. What we aim to create is a robot that can understand emotions, provide genuine companionship, and reliably accomplish tasks._ + _私は、高性能ですから!_ diff --git a/README_fr.md b/README_fr.md index a47e15eea..994cb7677 100644 --- a/README_fr.md +++ b/README_fr.md @@ -1,9 +1,13 @@ ![AstrBot-Logo-Simplified](https://github.com/user-attachments/assets/ffd99b6b-3272-4682-beaa-6fe74250f7d9) -

-
+中文 | +English | +日本語 | +繁體中文 | +Русский +
@@ -14,22 +18,17 @@
- -python -Docker pull -QQ_community -Telegram_community - + +python + +zread +Docker pull + +

-中文 | -English | -日本語 | -繁體中文 | -Русский - DocumentationBlogFeuille de route | @@ -38,17 +37,36 @@ AstrBot est une plateforme de chatbot Agent tout-en-un open source qui s'intègre aux principales applications de messagerie instantanée. Elle fournit une infrastructure d'IA conversationnelle fiable et évolutive pour les particuliers, les développeurs et les équipes. Que vous construisiez un compagnon IA personnel, un service client intelligent, un assistant d'automatisation ou une base de connaissances d'entreprise, AstrBot vous permet de créer rapidement des applications d'IA prêtes pour la production dans les flux de travail de votre plateforme de messagerie. -image +![521771166-00782c4c-4437-4d97-aabc-605e3738da5c (1)](https://github.com/user-attachments/assets/61e7b505-f7db-41aa-a75f-4ef8f079b8ba) ## Fonctionnalités principales 1. 💯 Gratuit & Open Source. -2. ✨ Conversations avec LLM IA, Multimodal, Agent, MCP, Base de connaissances, Paramètres de personnalité. -3. 🤖 Prise en charge de l'intégration avec Dify, Alibaba Cloud Bailian, Coze et autres plateformes d'agents. -4. 🌐 Multi-plateforme : QQ, WeChat Work, Feishu, DingTalk, Comptes officiels WeChat, Telegram, Slack, et [plus encore](#plateformes-de-messagerie-prises-en-charge). -5. 📦 Extensions de plugins avec près de 800 plugins disponibles pour une installation en un clic. -6. 💻 Support WebUI. -7. 🌐 Support de l'internationalisation (i18n). +2. ✨ Dialogue avec de grands modèles d'IA, multimodal, Agent, MCP, Skills, Base de connaissances, Paramétrage de personnalité, compression automatique des dialogues. +3. 🤖 Prise en charge de l'accès aux plateformes d'Agents telles que Dify, Alibaba Cloud Bailian, Coze, etc. +4. 🌐 Multiplateforme : supporte QQ, WeChat Enterprise, Feishu, DingTalk, Comptes officiels WeChat, Telegram, Slack et [plus encore](#plateformes-de-messagerie-prises-en-charge). +5. 📦 Extension par plugins, avec plus de 1000 plugins déjà disponibles pour une installation en un clic. +6. 🛡️ Environnement isolé [Agent Sandbox](https://docs.astrbot.app/use/astrbot-agent-sandbox.html) : exécution sécurisée de code, appels Shell et réutilisation des ressources au niveau de la session. +7. 💻 Support WebUI. +8. 🌈 Support Web ChatUI, avec sandbox d'agent intégrée, recherche web, etc. +9. 🌐 Support de l'internationalisation (i18n). + +
+ + + + + + + + + + + + + + +
💙 Jeux de rôle & Accompagnement émotionnel✨ Agent proactif🚀 Capacités agentiques générales🧩 1000+ Plugins de communauté

99b587c5d35eea09d84f33e6cf6cfd4f

c449acd838c41d0915cc08a3824025b1

image

image

## Démarrage rapide @@ -61,9 +79,20 @@ Veuillez consulter la documentation officielle : [Déployer AstrBot avec Docker] #### Déploiement uv ```bash -uvx astrbot +uv tool install astrbot +astrbot ``` +#### Application de bureau (Tauri) + +Dépôt de l'application de bureau : [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop). + +Prend en charge plusieurs architectures système, installation directe, prête à l'emploi. La solution de déploiement de bureau en un clic la plus adaptée aux débutants. Non recommandée pour les serveurs. + +#### Déploiement en un clic avec le lanceur (AstrBot Launcher) + +Déploiement rapide et solution multi-instances, isolation de l'environnement. Accédez au dépôt [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher), trouvez le package d'installation correspondant à votre système sous la dernière version sur la page Releases. + #### Déploiement BT-Panel AstrBot s'est associé à BT-Panel et est maintenant disponible sur leur marketplace. @@ -115,6 +144,16 @@ uv run main.py Ou consultez la documentation officielle : [Déployer AstrBot depuis les sources](https://astrbot.app/deploy/astrbot/cli.html). +#### Installation via le gestionnaire de paquets du système + +##### Arch Linux + +```bash +yay -S astrbot-git +# ou utiliser paru +paru -S astrbot-git +``` + ## Plateformes de messagerie prises en charge **Maintenues officiellement** @@ -129,8 +168,8 @@ Ou consultez la documentation officielle : [Déployer AstrBot depuis les sources - Discord - Satori - Misskey +- LINE - WhatsApp (Bientôt disponible) -- LINE (Bientôt disponible) **Maintenues par la communauté** @@ -153,7 +192,7 @@ Ou consultez la documentation officielle : [Déployer AstrBot depuis les sources - [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_astrbot&referral_code=FV7DcGowN4hB5UuXKgpE74) - [302.AI](https://share.302.ai/rr1M3l) - [TokenPony](https://www.tokenpony.cn/3YPyf) -- [SiliconFlow](https://docs.siliconflow.cn/cn/usecases/use-siliconcloud-in-astrbot) +- [SiliconFlow](https://docs.siliconflow.cn/cn/usercases/use-siliconcloud-in-astrbot) - [PPIO Cloud](https://ppio.com/user/register?invited_by=AIOONE) - ModelScope - OneAPI @@ -223,7 +262,7 @@ pre-commit install Un grand merci à tous les contributeurs et développeurs de plugins pour leurs contributions à AstrBot ❤️ - + De plus, la naissance de ce projet n'aurait pas été possible sans l'aide des projets open source suivants : @@ -241,7 +280,12 @@ De plus, la naissance de ce projet n'aurait pas été possible sans l'aide des p
- +
+ +_La compagnie et la capacité ne devraient jamais être des opposés. Nous souhaitons créer un robot capable à la fois de comprendre les émotions, d'offrir de la présence, et d'accomplir des tâches de manière fiable._ _私は、高性能ですから!_ + + +
diff --git a/README_ja.md b/README_ja.md index bab9d629e..ad3b95022 100644 --- a/README_ja.md +++ b/README_ja.md @@ -1,9 +1,13 @@ ![AstrBot-Logo-Simplified](https://github.com/user-attachments/assets/ffd99b6b-3272-4682-beaa-6fe74250f7d9) -

-
+中文 | +English | +繁體中文 | +Français | +Русский +
@@ -14,22 +18,17 @@
- -python -Docker pull -QQ_community -Telegram_community - + +python + +zread +Docker pull + +

-中文 | -English | -繁體中文 | -Français | -Русский - ドキュメントBlogロードマップ | @@ -38,17 +37,36 @@ AstrBot は、主要なインスタントメッセージングアプリと統合できるオープンソースのオールインワン Agent チャットボットプラットフォームです。個人、開発者、チームに信頼性が高くスケーラブルな会話型 AI インフラストラクチャを提供します。パーソナル AI コンパニオン、インテリジェントカスタマーサービス、オートメーションアシスタント、エンタープライズナレッジベースなど、AstrBot を使用すると、IM プラットフォームのワークフロー内で本番環境対応の AI アプリケーションを迅速に構築できます。 -image +![521771166-00782c4c-4437-4d97-aabc-605e3738da5c (1)](https://github.com/user-attachments/assets/61e7b505-f7db-41aa-a75f-4ef8f079b8ba) ## 主な機能 1. 💯 無料 & オープンソース。 -2. ✨ AI 大規模言語モデル対話、マルチモーダル、Agent、MCP、ナレッジベース、ペルソナ設定。 -3. 🤖 Dify、Alibaba Cloud 百炼、Coze などの Agent プラットフォームとの統合をサポート。 -4. 🌐 マルチプラットフォーム:QQ、WeChat Work、Feishu、DingTalk、WeChat 公式アカウント、Telegram、Slack、[その他](#サポートされているメッセージプラットフォーム)。 -5. 📦 約800個のプラグインをワンクリックでインストール可能なプラグイン拡張機能。 -6. 💻 WebUI サポート。 -7. 🌐 国際化(i18n)サポート。 +2. ✨ AI大規模言語モデル対話、マルチモーダル、Agent、MCP、Skills、ナレッジベース、ペルソナ設定、対話の自動圧縮。 +3. 🤖 Dify、Alibaba Cloud Bailian(百煉)、Coze などのAgentプラットフォームへの接続をサポート。 +4. 🌐 マルチプラットフォーム:QQ、企業微信(WeCom)、飛書(Lark)、釘釘(DingTalk)、WeChat公式アカウント、Telegram、Slack、[その他](#サポートされているメッセージプラットフォーム)に対応。 +5. 📦 プラグイン拡張:1000を超える既存プラグインをワンクリックでインストール可能。 +6. 🛡️ 隔離環境[Agent Sandbox](https://docs.astrbot.app/use/astrbot-agent-sandbox.html):コードの安全な実行、Shell呼び出し、セッションレベルのリソース再利用。 +7. 💻 WebUI 対応。 +8. 🌈 Web ChatUI 対応:ChatUI内にAgent Sandboxやウェブ検索などを内蔵。 +9. 🌐 多言語対応(i18n)。 + +
+ + + + + + + + + + + + + + +
💙 ロールプレイ & 感情的な対話✨ プロアクティブ・エージェント (Proactive Agent)🚀 汎用 エージェント的能力🧩 1000+ コミュニティプラグイン

99b587c5d35eea09d84f33e6cf6cfd4f

c449acd838c41d0915cc08a3824025b1

image

image

## クイックスタート @@ -61,9 +79,20 @@ Docker / Docker Compose を使用した AstrBot のデプロイを推奨しま #### uv デプロイ ```bash -uvx astrbot +uv tool install astrbot +astrbot ``` +#### デスクトップアプリのデプロイ(Tauri) + +デスクトップアプリのリポジトリ [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop)。 + +マルチシステムアーキテクチャをサポートし、インストールしてすぐに使用可能。初心者や手軽さを求める人に最適なワンクリックデスクトップデプロイソリューションです。サーバー環境での使用は推奨されません。 + +#### ランチャーによるワンクリックデプロイ(AstrBot Launcher) + +迅速なデプロイとマルチインスタンス対応、環境の隔離が可能。[AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) リポジトリにアクセスし、Releases ページから最新バージョンのシステム対応パッケージをダウンロードしてインストールしてください。 + #### 宝塔パネルデプロイ AstrBot は宝塔パネルと提携し、宝塔パネルに公開されています。 @@ -115,6 +144,16 @@ uv run main.py または、公式ドキュメント [ソースコードから AstrBot をデプロイ](https://astrbot.app/deploy/astrbot/cli.html) をご参照ください。 +#### システムパッケージマネージャーでのインストール + +##### Arch Linux + +```bash +yay -S astrbot-git +# または paru を使用 +paru -S astrbot-git +``` + ## サポートされているメッセージプラットフォーム **公式メンテナンス** @@ -129,8 +168,8 @@ uv run main.py - Discord - Satori - Misskey +- LINE - WhatsApp (近日対応予定) -- LINE (近日対応予定) **コミュニティメンテナンス** @@ -224,7 +263,7 @@ pre-commit install AstrBot への貢献をしていただいたすべてのコントリビューターとプラグイン開発者に特別な感謝を ❤️ - + また、このプロジェクトの誕生は以下のオープンソースプロジェクトの助けなしには実現できませんでした: @@ -242,6 +281,12 @@ AstrBot への貢献をしていただいたすべてのコントリビュータ
- +
+ +_共感力と能力は決して対立するものではありません。私たちが目指すのは、感情を理解し、心の支えとなるだけでなく、確実に仕事をこなせるロボットの創造です。_ _私は、高性能ですから!_ + + + +
diff --git a/README_ru.md b/README_ru.md index 0f52c1c6a..970bce277 100644 --- a/README_ru.md +++ b/README_ru.md @@ -1,9 +1,13 @@ ![AstrBot-Logo-Simplified](https://github.com/user-attachments/assets/ffd99b6b-3272-4682-beaa-6fe74250f7d9) -

-
+中文 | +English | +日本語 | +繁體中文 | +Français +
@@ -14,22 +18,17 @@
- -python -Docker pull -QQ_community -Telegram_community - + +python + +zread +Docker pull + +

-中文 | -English | -日本語 | -繁體中文 | -Français - ДокументацияБлогДорожная карта | @@ -38,17 +37,36 @@ AstrBot — это универсальная платформа Agent-чатботов с открытым исходным кодом, которая интегрируется с основными приложениями для обмена мгновенными сообщениями. Она предоставляет надёжную и масштабируемую инфраструктуру разговорного ИИ для частных лиц, разработчиков и команд. Будь то персональный ИИ-компаньон, интеллектуальная служба поддержки, автоматизированный помощник или корпоративная база знаний — AstrBot позволяет быстро создавать готовые к использованию ИИ-приложения в рабочих процессах вашей платформы обмена сообщениями. -image +![521771166-00782c4c-4437-4d97-aabc-605e3738da5c (1)](https://github.com/user-attachments/assets/61e7b505-f7db-41aa-a75f-4ef8f079b8ba) ## Основные возможности -1. 💯 Бесплатно и с открытым исходным кодом. -2. ✨ ИИ-диалоги с LLM, мультимодальность, Agent, MCP, база знаний, настройки личности. -3. 🤖 Поддержка интеграции с Dify, Alibaba Cloud Bailian, Coze и другими платформами агентов. -4. 🌐 Мультиплатформенность: QQ, WeChat Work, Feishu, DingTalk, официальные аккаунты WeChat, Telegram, Slack и [другие](#поддерживаемые-платформы-обмена-сообщениями). -5. 📦 Расширения плагинов с почти 800 плагинами, доступными для установки в один клик. -6. 💻 Поддержка WebUI. -7. 🌐 Поддержка интернационализации (i18n). +1. 💯 Бесплатно & Открытый исходный код. +2. ✨ Диалоги с ИИ-моделями, мультимодальность, Agent, MCP, Skills, База знаний, Настройка личности, автоматическое сжатие диалогов. +3. 🤖 Поддержка интеграции с платформами Agents, такими как Dify, Alibaba Cloud Bailian, Coze и др. +4. 🌐 Мультиплатформенность: поддержка QQ, WeChat для предприятий, Feishu, DingTalk, публичных аккаунтов WeChat, Telegram, Slack и [других](#Поддерживаемые-платформы-обмена-сообщениями). +5. 📦 Расширение плагинами: доступно более 1000 плагинов для установки в один клик. +6. 🛡️ Изолированная среда[Agent Sandbox](https://docs.astrbot.app/use/astrbot-agent-sandbox.html): безопасное выполнение любого кода, вызов Shell, повторное использование ресурсов на уровне сессии. +7. 💻 Поддержка WebUI. +8. 🌈 Поддержка Web ChatUI: встроенная песочница агента, веб-поиск и др. +9. 🌐 Поддержка интернационализации (i18n). + +
+ + + + + + + + + + + + + + +
💙 Ролевые игры & Эмоциональная поддержка✨ Проактивный Агент (Agent)🚀 Универсальные возможности Агента🧩 1000+ плагинов сообщества

99b587c5d35eea09d84f33e6cf6cfd4f

c449acd838c41d0915cc08a3824025b1

image

image

## Быстрый старт @@ -61,9 +79,20 @@ AstrBot — это универсальная платформа Agent-чатб #### Развёртывание uv ```bash -uvx astrbot +uv tool install astrbot +astrbot ``` +#### Десктопное приложение (Tauri) + +Репозиторий десктопного приложения: [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop). + +Поддерживает различные системные архитектуры, устанавливается напрямую, "из коробки", лучшее настольное решение в один клик для новичков и тех, кто ценит простоту. Не рекомендуется для серверных сценариев. + +#### Установка в один клик через лаунчер (AstrBot Launcher) + +Быстрое развёртывание и поддержка нескольких экземпляров, изоляция среды. Перейдите в репозиторий [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher), найдите последнюю версию на странице Releases и установите соответствующий пакет для вашей системы. + #### Развёртывание BT-Panel AstrBot в партнёрстве с BT-Panel теперь доступен на их маркетплейсе. @@ -115,6 +144,16 @@ uv run main.py Или см. официальную документацию: [Развёртывание AstrBot из исходного кода](https://astrbot.app/deploy/astrbot/cli.html). +#### Установка через системный пакетный менеджер + +##### Arch Linux + +```bash +yay -S astrbot-git +# или используйте paru +paru -S astrbot-git +``` + ## Поддерживаемые платформы обмена сообщениями **Официально поддерживаемые** @@ -129,8 +168,9 @@ uv run main.py - Discord - Satori - Misskey +- LINE - WhatsApp (Скоро) -- LINE (Скоро) + **Поддерживаемые сообществом** @@ -153,7 +193,7 @@ uv run main.py - [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_astrbot&referral_code=FV7DcGowN4hB5UuXKgpE74) - [302.AI](https://share.302.ai/rr1M3l) - [TokenPony](https://www.tokenpony.cn/3YPyf) -- [SiliconFlow](https://docs.siliconflow.cn/cn/usecases/use-siliconcloud-in-astrbot) +- [SiliconFlow](https://docs.siliconflow.cn/cn/usercases/use-siliconcloud-in-astrbot) - [PPIO Cloud](https://ppio.com/user/register?invited_by=AIOONE) - ModelScope - OneAPI @@ -223,7 +263,7 @@ pre-commit install Особая благодарность всем контрибьюторам и разработчикам плагинов за их вклад в AstrBot ❤️ - + Кроме того, рождение этого проекта было бы невозможно без помощи следующих проектов с открытым исходным кодом: @@ -235,13 +275,19 @@ pre-commit install > [!TIP] > Если этот проект помог вам в жизни или работе, или если вас интересует его будущее развитие, пожалуйста, поставьте проекту звезду. Это движущая сила поддержки этого проекта с открытым исходным кодом <3 +
[![Star History Chart](https://api.star-history.com/svg?repos=astrbotdevs/astrbot&type=Date)](https://star-history.com/#astrbotdevs/astrbot&Date)
- +
+ +_Сопровождение и способности никогда не должны быть противоположностями. Мы стремимся создать робота, который сможет как понимать эмоции, оказывать душевную поддержку, так и надёжно выполнять работу._ _私は、高性能ですから!_ + + +
diff --git a/README_zh-TW.md b/README_zh-TW.md index c6df22ea2..e612a3c42 100644 --- a/README_zh-TW.md +++ b/README_zh-TW.md @@ -1,9 +1,13 @@ ![AstrBot-Logo-Simplified](https://github.com/user-attachments/assets/ffd99b6b-3272-4682-beaa-6fe74250f7d9) -

-
+简体中文 | +English | +日本語 | +Français | +Русский +
@@ -14,22 +18,17 @@
- -python -Docker pull -QQ_community -Telegram_community - + +python + +zread +Docker pull + +

-简体中文 | -English | -日本語 | -Français | -Русский - 文件Blog路線圖 | @@ -38,17 +37,36 @@ AstrBot 是一個開源的一站式 Agent 聊天機器人平台,可接入主流即時通訊軟體,為個人、開發者和團隊打造可靠、可擴展的對話式智慧基礎設施。無論是個人 AI 夥伴、智慧客服、自動化助手,還是企業知識庫,AstrBot 都能在您的即時通訊軟體平台的工作流程中快速構建生產可用的 AI 應用程式。 -image +![521771166-00782c4c-4437-4d97-aabc-605e3738da5c (1)](https://github.com/user-attachments/assets/61e7b505-f7db-41aa-a75f-4ef8f079b8ba) ## 主要功能 1. 💯 免費 & 開源。 -2. ✨ AI 大型模型對話,多模態,Agent,MCP,知識庫,人格設定。 -3. 🤖 支援接入 Dify、阿里雲百煉、Coze 等智慧體平台。 -4. 🌐 多平台:QQ、企業微信、飛書、釘釘、微信公眾號、Telegram、Slack 以及[更多](#支援的訊息平台)。 -5. 📦 外掛擴充,已有近 800 個外掛可一鍵安裝。 -6. 💻 WebUI 支援。 -7. 🌐 國際化(i18n)支援。 +2. ✨ AI 大模型對話,多模態,Agent,MCP,Skills,知識庫,人格設定,自動壓縮對話。 +3. 🤖 支援接入 Dify、阿里雲百煉、Coze 等智慧體 (Agent) 平台。 +4. 🌐 多平台,支援 QQ、企業微信、飛書、釘釘、微信公眾號、Telegram、Slack 以及[更多](#支援的訊息平台)。 +5. 📦 插件擴展,已有 1000+ 個插件可一鍵安裝。 +6. 🛡️ [Agent Sandbox](https://docs.astrbot.app/use/astrbot-agent-sandbox.html) 隔離化環境,安全地執行任何代碼、調用 Shell、會話級資源複用。 +7. 💻 WebUI 支援。 +8. 🌈 Web ChatUI 支援,ChatUI 內置代理沙盒 (Agent Sandbox)、網頁搜尋等。 +9. 🌐 國際化(i18n)支援。 + +
+ + + + + + + + + + + + + + +
💙 角色扮演 & 情感陪伴✨ 主動式 Agent🚀 通用 Agentic 能力🧩 1000+ 社區外掛程式

99b587c5d35eea09d84f33e6cf6cfd4f

c449acd838c41d0915cc08a3824025b1

image

image

## 快速開始 @@ -61,9 +79,20 @@ AstrBot 是一個開源的一站式 Agent 聊天機器人平台,可接入主 #### uv 部署 ```bash -uvx astrbot +uv tool install astrbot +astrbot ``` +#### 桌面應用部署(Tauri) + +桌面應用倉庫 [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop)。 + +支援多系統架構,安裝包直接安裝,開箱即用,最適合新手和懶人的一鍵桌面部署方案,不推薦伺服器場景。 + +#### 啟動器一鍵部署(AstrBot Launcher) + +快速部署和多開方案,實現環境隔離,進入 [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) 倉庫,在 Releases 頁最新版本下找到對應的系統安裝包安裝即可。 + #### 寶塔面板部署 AstrBot 與寶塔面板合作,已上架至寶塔面板。 @@ -115,6 +144,16 @@ uv run main.py 或者請參閱官方文件 [透過原始碼部署 AstrBot](https://astrbot.app/deploy/astrbot/cli.html)。 +#### 系統套件管理員安裝 + +##### Arch Linux + +```bash +yay -S astrbot-git +# 或者使用 paru +paru -S astrbot-git +``` + ## 支援的訊息平台 **官方維護** @@ -129,8 +168,9 @@ uv run main.py - Discord - Satori - Misskey +- LINE - Whatsapp(即將支援) -- LINE(即將支援) + **社群維護** @@ -223,7 +263,7 @@ pre-commit install 特別感謝所有 Contributors 和外掛開發者對 AstrBot 的貢獻 ❤️ - + 此外,本專案的誕生離不開以下開源專案的幫助: @@ -241,7 +281,12 @@ pre-commit install
- +
+ +_陪伴與能力從來不應該是對立面。我們希望創造的是一個既能理解情緒、給予陪伴,也能可靠完成工作的機器人。_ _私は、高性能ですから!_ + + +
diff --git a/astrbot/api/event/filter/__init__.py b/astrbot/api/event/filter/__init__.py index 287c60b73..f5ab15ed0 100644 --- a/astrbot/api/event/filter/__init__.py +++ b/astrbot/api/event/filter/__init__.py @@ -24,6 +24,9 @@ register_on_llm_tool_respond as on_llm_tool_respond, ) from astrbot.core.star.register import register_on_platform_loaded as on_platform_loaded +from astrbot.core.star.register import register_on_plugin_error as on_plugin_error +from astrbot.core.star.register import register_on_plugin_loaded as on_plugin_loaded +from astrbot.core.star.register import register_on_plugin_unloaded as on_plugin_unloaded from astrbot.core.star.register import register_on_using_llm_tool as on_using_llm_tool from astrbot.core.star.register import ( register_on_waiting_llm_request as on_waiting_llm_request, @@ -52,6 +55,9 @@ "on_decorating_result", "on_llm_request", "on_llm_response", + "on_plugin_error", + "on_plugin_loaded", + "on_plugin_unloaded", "on_platform_loaded", "on_waiting_llm_request", "permission_type", diff --git a/astrbot/builtin_stars/astrbot/long_term_memory.py b/astrbot/builtin_stars/astrbot/long_term_memory.py index 610995db2..e08cdc515 100644 --- a/astrbot/builtin_stars/astrbot/long_term_memory.py +++ b/astrbot/builtin_stars/astrbot/long_term_memory.py @@ -17,7 +17,7 @@ class LongTermMemory: - def __init__(self, acm: AstrBotConfigManager, context: star.Context): + def __init__(self, acm: AstrBotConfigManager, context: star.Context) -> None: self.acm = acm self.context = context self.session_chats = defaultdict(list) @@ -111,7 +111,7 @@ async def need_active_reply(self, event: AstrMessageEvent) -> bool: return False - async def handle_message(self, event: AstrMessageEvent): + async def handle_message(self, event: AstrMessageEvent) -> None: """仅支持群聊""" if event.get_message_type() == MessageType.GROUP_MESSAGE: datetime_str = datetime.datetime.now().strftime("%H:%M:%S") @@ -148,7 +148,7 @@ async def handle_message(self, event: AstrMessageEvent): if len(self.session_chats[event.unified_msg_origin]) > cfg["max_cnt"]: self.session_chats[event.unified_msg_origin].pop(0) - async def on_req_llm(self, event: AstrMessageEvent, req: ProviderRequest): + async def on_req_llm(self, event: AstrMessageEvent, req: ProviderRequest) -> None: """当触发 LLM 请求前,调用此方法修改 req""" if event.unified_msg_origin not in self.session_chats: return @@ -171,7 +171,9 @@ async def on_req_llm(self, event: AstrMessageEvent, req: ProviderRequest): ) req.system_prompt += chats_str - async def after_req_llm(self, event: AstrMessageEvent, llm_resp: LLMResponse): + async def after_req_llm( + self, event: AstrMessageEvent, llm_resp: LLMResponse + ) -> None: if event.unified_msg_origin not in self.session_chats: return diff --git a/astrbot/builtin_stars/astrbot/main.py b/astrbot/builtin_stars/astrbot/main.py index 773d03939..da2a00835 100644 --- a/astrbot/builtin_stars/astrbot/main.py +++ b/astrbot/builtin_stars/astrbot/main.py @@ -85,7 +85,9 @@ async def on_message(self, event: AstrMessageEvent): logger.error(f"主动回复失败: {e}") @filter.on_llm_request() - async def decorate_llm_req(self, event: AstrMessageEvent, req: ProviderRequest): + async def decorate_llm_req( + self, event: AstrMessageEvent, req: ProviderRequest + ) -> None: """在请求 LLM 前注入人格信息、Identifier、时间、回复内容等 System Prompt""" if self.ltm and self.ltm_enabled(event): try: @@ -94,7 +96,9 @@ async def decorate_llm_req(self, event: AstrMessageEvent, req: ProviderRequest): logger.error(f"ltm: {e}") @filter.on_llm_response() - async def record_llm_resp_to_ltm(self, event: AstrMessageEvent, resp: LLMResponse): + async def record_llm_resp_to_ltm( + self, event: AstrMessageEvent, resp: LLMResponse + ) -> None: """在 LLM 响应后记录对话""" if self.ltm and self.ltm_enabled(event): try: @@ -103,7 +107,7 @@ async def record_llm_resp_to_ltm(self, event: AstrMessageEvent, resp: LLMRespons logger.error(f"ltm: {e}") @filter.after_message_sent() - async def after_message_sent(self, event: AstrMessageEvent): + async def after_message_sent(self, event: AstrMessageEvent) -> None: """消息发送后处理""" if self.ltm and self.ltm_enabled(event): try: diff --git a/astrbot/builtin_stars/builtin_commands/commands/admin.py b/astrbot/builtin_stars/builtin_commands/commands/admin.py index 83d4b5974..a4f46b603 100644 --- a/astrbot/builtin_stars/builtin_commands/commands/admin.py +++ b/astrbot/builtin_stars/builtin_commands/commands/admin.py @@ -5,10 +5,10 @@ class AdminCommands: - def __init__(self, context: star.Context): + def __init__(self, context: star.Context) -> None: self.context = context - async def op(self, event: AstrMessageEvent, admin_id: str = ""): + async def op(self, event: AstrMessageEvent, admin_id: str = "") -> None: """授权管理员。op """ if not admin_id: event.set_result( @@ -21,7 +21,7 @@ async def op(self, event: AstrMessageEvent, admin_id: str = ""): self.context.get_config().save_config() event.set_result(MessageEventResult().message("授权成功。")) - async def deop(self, event: AstrMessageEvent, admin_id: str = ""): + async def deop(self, event: AstrMessageEvent, admin_id: str = "") -> None: """取消授权管理员。deop """ if not admin_id: event.set_result( @@ -39,7 +39,7 @@ async def deop(self, event: AstrMessageEvent, admin_id: str = ""): MessageEventResult().message("此用户 ID 不在管理员名单内。"), ) - async def wl(self, event: AstrMessageEvent, sid: str = ""): + async def wl(self, event: AstrMessageEvent, sid: str = "") -> None: """添加白名单。wl """ if not sid: event.set_result( @@ -53,7 +53,7 @@ async def wl(self, event: AstrMessageEvent, sid: str = ""): cfg.save_config() event.set_result(MessageEventResult().message("添加白名单成功。")) - async def dwl(self, event: AstrMessageEvent, sid: str = ""): + async def dwl(self, event: AstrMessageEvent, sid: str = "") -> None: """删除白名单。dwl """ if not sid: event.set_result( @@ -70,7 +70,7 @@ async def dwl(self, event: AstrMessageEvent, sid: str = ""): except ValueError: event.set_result(MessageEventResult().message("此 SID 不在白名单内。")) - async def update_dashboard(self, event: AstrMessageEvent): + async def update_dashboard(self, event: AstrMessageEvent) -> None: """更新管理面板""" await event.send(MessageChain().message("正在尝试更新管理面板...")) await download_dashboard(version=f"v{VERSION}", latest=False) diff --git a/astrbot/builtin_stars/builtin_commands/commands/alter_cmd.py b/astrbot/builtin_stars/builtin_commands/commands/alter_cmd.py index 50007f6c0..ba31c3326 100644 --- a/astrbot/builtin_stars/builtin_commands/commands/alter_cmd.py +++ b/astrbot/builtin_stars/builtin_commands/commands/alter_cmd.py @@ -11,10 +11,10 @@ class AlterCmdCommands(CommandParserMixin): - def __init__(self, context: star.Context): + def __init__(self, context: star.Context) -> None: self.context = context - async def update_reset_permission(self, scene_key: str, perm_type: str): + async def update_reset_permission(self, scene_key: str, perm_type: str) -> None: """更新reset命令在特定场景下的权限设置""" from astrbot.api import sp @@ -26,7 +26,7 @@ async def update_reset_permission(self, scene_key: str, perm_type: str): alter_cmd_cfg["astrbot"] = plugin_cfg await sp.global_put("alter_cmd", alter_cmd_cfg) - async def alter_cmd(self, event: AstrMessageEvent): + async def alter_cmd(self, event: AstrMessageEvent) -> None: token = self.parse_commands(event.message_str) if token.len < 3: await event.send( diff --git a/astrbot/builtin_stars/builtin_commands/commands/conversation.py b/astrbot/builtin_stars/builtin_commands/commands/conversation.py index de3d11ac8..55b75cb1b 100644 --- a/astrbot/builtin_stars/builtin_commands/commands/conversation.py +++ b/astrbot/builtin_stars/builtin_commands/commands/conversation.py @@ -4,6 +4,7 @@ from astrbot.api.event import AstrMessageEvent, MessageEventResult from astrbot.core.platform.astr_message_event import MessageSession from astrbot.core.platform.message_type import MessageType +from astrbot.core.utils.active_event_registry import active_event_registry from .utils.rst_scene import RstScene @@ -16,7 +17,7 @@ class ConversationCommands: - def __init__(self, context: star.Context): + def __init__(self, context: star.Context) -> None: self.context = context async def _get_current_persona_id(self, session_id): @@ -33,7 +34,7 @@ async def _get_current_persona_id(self, session_id): return None return conv.persona_id - async def reset(self, message: AstrMessageEvent): + async def reset(self, message: AstrMessageEvent) -> None: """重置 LLM 会话""" umo = message.unified_msg_origin cfg = self.context.get_config(umo=message.unified_msg_origin) @@ -62,6 +63,7 @@ async def reset(self, message: AstrMessageEvent): agent_runner_type = cfg["provider_settings"]["agent_runner_type"] if agent_runner_type in THIRD_PARTY_AGENT_RUNNER_KEY: + active_event_registry.stop_all(umo, exclude=message) await sp.remove_async( scope="umo", scope_id=umo, @@ -86,6 +88,8 @@ async def reset(self, message: AstrMessageEvent): ) return + active_event_registry.stop_all(umo, exclude=message) + await self.context.conversation_manager.update_conversation( umo, cid, @@ -98,7 +102,31 @@ async def reset(self, message: AstrMessageEvent): message.set_result(MessageEventResult().message(ret)) - async def his(self, message: AstrMessageEvent, page: int = 1): + async def stop(self, message: AstrMessageEvent) -> None: + """停止当前会话正在运行的 Agent""" + cfg = self.context.get_config(umo=message.unified_msg_origin) + agent_runner_type = cfg["provider_settings"]["agent_runner_type"] + umo = message.unified_msg_origin + + if agent_runner_type in THIRD_PARTY_AGENT_RUNNER_KEY: + stopped_count = active_event_registry.stop_all(umo, exclude=message) + else: + stopped_count = active_event_registry.request_agent_stop_all( + umo, + exclude=message, + ) + + if stopped_count > 0: + message.set_result( + MessageEventResult().message( + f"已请求停止 {stopped_count} 个运行中的任务。" + ) + ) + return + + message.set_result(MessageEventResult().message("当前会话没有运行中的任务。")) + + async def his(self, message: AstrMessageEvent, page: int = 1) -> None: """查看对话记录""" if not self.context.get_using_provider(message.unified_msg_origin): message.set_result( @@ -141,7 +169,7 @@ async def his(self, message: AstrMessageEvent, page: int = 1): message.set_result(MessageEventResult().message(ret).use_t2i(False)) - async def convs(self, message: AstrMessageEvent, page: int = 1): + async def convs(self, message: AstrMessageEvent, page: int = 1) -> None: """查看对话列表""" cfg = self.context.get_config(umo=message.unified_msg_origin) agent_runner_type = cfg["provider_settings"]["agent_runner_type"] @@ -178,16 +206,33 @@ async def convs(self, message: AstrMessageEvent, page: int = 1): _titles[conv.cid] = title """遍历分页后的对话生成列表显示""" + provider_settings = cfg.get("provider_settings", {}) + platform_name = message.get_platform_name() for conv in conversations_paged: - persona_id = conv.persona_id - if not persona_id or persona_id == "[%None]": - persona = await self.context.persona_manager.get_default_persona_v3( - umo=message.unified_msg_origin, - ) - persona_id = persona["name"] + ( + persona_id, + _, + force_applied_persona_id, + _, + ) = await self.context.persona_manager.resolve_selected_persona( + umo=message.unified_msg_origin, + conversation_persona_id=conv.persona_id, + platform_name=platform_name, + provider_settings=provider_settings, + ) + if persona_id == "[%None]": + persona_name = "无" + elif persona_id: + persona_name = persona_id + else: + persona_name = "无" + + if force_applied_persona_id: + persona_name = f"{persona_name} (自定义规则)" + title = _titles.get(conv.cid, "新对话") parts.append( - f"{global_index}. {title}({conv.cid[:4]})\n 人格情景: {persona_id}\n 上次更新: {datetime.datetime.fromtimestamp(conv.updated_at).strftime('%m-%d %H:%M')}\n" + f"{global_index}. {title}({conv.cid[:4]})\n 人格情景: {persona_name}\n 上次更新: {datetime.datetime.fromtimestamp(conv.updated_at).strftime('%m-%d %H:%M')}\n" ) global_index += 1 @@ -216,11 +261,12 @@ async def convs(self, message: AstrMessageEvent, page: int = 1): message.set_result(MessageEventResult().message(ret).use_t2i(False)) return - async def new_conv(self, message: AstrMessageEvent): + async def new_conv(self, message: AstrMessageEvent) -> None: """创建新对话""" cfg = self.context.get_config(umo=message.unified_msg_origin) agent_runner_type = cfg["provider_settings"]["agent_runner_type"] if agent_runner_type in THIRD_PARTY_AGENT_RUNNER_KEY: + active_event_registry.stop_all(message.unified_msg_origin, exclude=message) await sp.remove_async( scope="umo", scope_id=message.unified_msg_origin, @@ -229,6 +275,7 @@ async def new_conv(self, message: AstrMessageEvent): message.set_result(MessageEventResult().message("已创建新对话。")) return + active_event_registry.stop_all(message.unified_msg_origin, exclude=message) cpersona = await self._get_current_persona_id(message.unified_msg_origin) cid = await self.context.conversation_manager.new_conversation( message.unified_msg_origin, @@ -242,7 +289,7 @@ async def new_conv(self, message: AstrMessageEvent): MessageEventResult().message(f"切换到新对话: 新对话({cid[:4]})。"), ) - async def groupnew_conv(self, message: AstrMessageEvent, sid: str = ""): + async def groupnew_conv(self, message: AstrMessageEvent, sid: str = "") -> None: """创建新群聊对话""" if sid: session = str( @@ -273,7 +320,7 @@ async def switch_conv( self, message: AstrMessageEvent, index: int | None = None, - ): + ) -> None: """通过 /ls 前面的序号切换对话""" if not isinstance(index, int): message.set_result( @@ -308,7 +355,7 @@ async def switch_conv( ), ) - async def rename_conv(self, message: AstrMessageEvent, new_name: str = ""): + async def rename_conv(self, message: AstrMessageEvent, new_name: str = "") -> None: """重命名对话""" if not new_name: message.set_result(MessageEventResult().message("请输入新的对话名称。")) @@ -319,9 +366,10 @@ async def rename_conv(self, message: AstrMessageEvent, new_name: str = ""): ) message.set_result(MessageEventResult().message("重命名对话成功。")) - async def del_conv(self, message: AstrMessageEvent): + async def del_conv(self, message: AstrMessageEvent) -> None: """删除当前对话""" - cfg = self.context.get_config(umo=message.unified_msg_origin) + umo = message.unified_msg_origin + cfg = self.context.get_config(umo=umo) is_unique_session = cfg["platform_settings"]["unique_session"] if message.get_group_id() and not is_unique_session and message.role != "admin": # 群聊,没开独立会话,发送人不是管理员 @@ -334,18 +382,17 @@ async def del_conv(self, message: AstrMessageEvent): agent_runner_type = cfg["provider_settings"]["agent_runner_type"] if agent_runner_type in THIRD_PARTY_AGENT_RUNNER_KEY: + active_event_registry.stop_all(umo, exclude=message) await sp.remove_async( scope="umo", - scope_id=message.unified_msg_origin, + scope_id=umo, key=THIRD_PARTY_AGENT_RUNNER_KEY[agent_runner_type], ) message.set_result(MessageEventResult().message("重置对话成功。")) return session_curr_cid = ( - await self.context.conversation_manager.get_curr_conversation_id( - message.unified_msg_origin, - ) + await self.context.conversation_manager.get_curr_conversation_id(umo) ) if not session_curr_cid: @@ -356,8 +403,10 @@ async def del_conv(self, message: AstrMessageEvent): ) return + active_event_registry.stop_all(umo, exclude=message) + await self.context.conversation_manager.delete_conversation( - message.unified_msg_origin, + umo, session_curr_cid, ) diff --git a/astrbot/builtin_stars/builtin_commands/commands/help.py b/astrbot/builtin_stars/builtin_commands/commands/help.py index 092fc59ec..ae2f4c787 100644 --- a/astrbot/builtin_stars/builtin_commands/commands/help.py +++ b/astrbot/builtin_stars/builtin_commands/commands/help.py @@ -8,7 +8,7 @@ class HelpCommand: - def __init__(self, context: star.Context): + def __init__(self, context: star.Context) -> None: self.context = context async def _query_astrbot_notice(self): @@ -34,7 +34,7 @@ async def _build_reserved_command_lines(self) -> list[str]: lines: list[str] = [] hidden_commands = {"set", "unset", "websearch"} - def walk(items: list[dict], indent: int = 0): + def walk(items: list[dict], indent: int = 0) -> None: for item in items: if not item.get("reserved") or not item.get("enabled"): continue @@ -62,7 +62,7 @@ def walk(items: list[dict], indent: int = 0): walk(commands) return lines - async def help(self, event: AstrMessageEvent): + async def help(self, event: AstrMessageEvent) -> None: """查看帮助""" notice = "" try: diff --git a/astrbot/builtin_stars/builtin_commands/commands/llm.py b/astrbot/builtin_stars/builtin_commands/commands/llm.py index 85977df40..ba9ba5c9b 100644 --- a/astrbot/builtin_stars/builtin_commands/commands/llm.py +++ b/astrbot/builtin_stars/builtin_commands/commands/llm.py @@ -3,10 +3,10 @@ class LLMCommands: - def __init__(self, context: star.Context): + def __init__(self, context: star.Context) -> None: self.context = context - async def llm(self, event: AstrMessageEvent): + async def llm(self, event: AstrMessageEvent) -> None: """开启/关闭 LLM""" cfg = self.context.get_config(umo=event.unified_msg_origin) enable = cfg["provider_settings"].get("enable", True) diff --git a/astrbot/builtin_stars/builtin_commands/commands/persona.py b/astrbot/builtin_stars/builtin_commands/commands/persona.py index 169c9e2b6..7a7416bba 100644 --- a/astrbot/builtin_stars/builtin_commands/commands/persona.py +++ b/astrbot/builtin_stars/builtin_commands/commands/persona.py @@ -1,7 +1,7 @@ import builtins from typing import TYPE_CHECKING -from astrbot.api import sp, star +from astrbot.api import star from astrbot.api.event import AstrMessageEvent, MessageEventResult if TYPE_CHECKING: @@ -9,7 +9,7 @@ class PersonaCommands: - def __init__(self, context: star.Context): + def __init__(self, context: star.Context) -> None: self.context = context def _build_tree_output( @@ -50,7 +50,7 @@ def _build_tree_output( return lines - async def persona(self, message: AstrMessageEvent): + async def persona(self, message: AstrMessageEvent) -> None: l = message.message_str.split(" ") # noqa: E741 umo = message.unified_msg_origin @@ -59,12 +59,7 @@ async def persona(self, message: AstrMessageEvent): default_persona = await self.context.persona_manager.get_default_persona_v3( umo=umo, ) - - force_applied_persona_id = ( - await sp.get_async( - scope="umo", scope_id=umo, key="session_service_config", default={} - ) - ).get("persona_id") + force_applied_persona_id = None curr_cid_title = "无" if cid: @@ -80,10 +75,27 @@ async def persona(self, message: AstrMessageEvent): ), ) return - if not conv.persona_id and conv.persona_id != "[%None]": - curr_persona_name = default_persona["name"] - else: - curr_persona_name = conv.persona_id + + provider_settings = self.context.get_config(umo=umo).get( + "provider_settings", + {}, + ) + ( + persona_id, + _, + force_applied_persona_id, + _, + ) = await self.context.persona_manager.resolve_selected_persona( + umo=umo, + conversation_persona_id=conv.persona_id, + platform_name=message.get_platform_name(), + provider_settings=provider_settings, + ) + + if persona_id == "[%None]": + curr_persona_name = "无" + elif persona_id: + curr_persona_name = persona_id if force_applied_persona_id: curr_persona_name = f"{curr_persona_name} (自定义规则)" diff --git a/astrbot/builtin_stars/builtin_commands/commands/plugin.py b/astrbot/builtin_stars/builtin_commands/commands/plugin.py index ab45efc11..49bee9462 100644 --- a/astrbot/builtin_stars/builtin_commands/commands/plugin.py +++ b/astrbot/builtin_stars/builtin_commands/commands/plugin.py @@ -8,10 +8,10 @@ class PluginCommands: - def __init__(self, context: star.Context): + def __init__(self, context: star.Context) -> None: self.context = context - async def plugin_ls(self, event: AstrMessageEvent): + async def plugin_ls(self, event: AstrMessageEvent) -> None: """获取已经安装的插件列表。""" parts = ["已加载的插件:\n"] for plugin in self.context.get_all_stars(): @@ -30,7 +30,7 @@ async def plugin_ls(self, event: AstrMessageEvent): MessageEventResult().message(f"{plugin_list_info}").use_t2i(False), ) - async def plugin_off(self, event: AstrMessageEvent, plugin_name: str = ""): + async def plugin_off(self, event: AstrMessageEvent, plugin_name: str = "") -> None: """禁用插件""" if DEMO_MODE: event.set_result(MessageEventResult().message("演示模式下无法禁用插件。")) @@ -43,7 +43,7 @@ async def plugin_off(self, event: AstrMessageEvent, plugin_name: str = ""): await self.context._star_manager.turn_off_plugin(plugin_name) # type: ignore event.set_result(MessageEventResult().message(f"插件 {plugin_name} 已禁用。")) - async def plugin_on(self, event: AstrMessageEvent, plugin_name: str = ""): + async def plugin_on(self, event: AstrMessageEvent, plugin_name: str = "") -> None: """启用插件""" if DEMO_MODE: event.set_result(MessageEventResult().message("演示模式下无法启用插件。")) @@ -56,7 +56,7 @@ async def plugin_on(self, event: AstrMessageEvent, plugin_name: str = ""): await self.context._star_manager.turn_on_plugin(plugin_name) # type: ignore event.set_result(MessageEventResult().message(f"插件 {plugin_name} 已启用。")) - async def plugin_get(self, event: AstrMessageEvent, plugin_repo: str = ""): + async def plugin_get(self, event: AstrMessageEvent, plugin_repo: str = "") -> None: """安装插件""" if DEMO_MODE: event.set_result(MessageEventResult().message("演示模式下无法安装插件。")) @@ -77,7 +77,7 @@ async def plugin_get(self, event: AstrMessageEvent, plugin_repo: str = ""): event.set_result(MessageEventResult().message(f"安装插件失败: {e}")) return - async def plugin_help(self, event: AstrMessageEvent, plugin_name: str = ""): + async def plugin_help(self, event: AstrMessageEvent, plugin_name: str = "") -> None: """获取插件帮助""" if not plugin_name: event.set_result( diff --git a/astrbot/builtin_stars/builtin_commands/commands/provider.py b/astrbot/builtin_stars/builtin_commands/commands/provider.py index 60b81ebe5..ae20eb8e1 100644 --- a/astrbot/builtin_stars/builtin_commands/commands/provider.py +++ b/astrbot/builtin_stars/builtin_commands/commands/provider.py @@ -8,7 +8,7 @@ class ProviderCommands: - def __init__(self, context: star.Context): + def __init__(self, context: star.Context) -> None: self.context = context def _log_reachability_failure( @@ -17,7 +17,7 @@ def _log_reachability_failure( provider_capability_type: ProviderType | None, err_code: str, err_reason: str, - ): + ) -> None: """记录不可达原因到日志。""" meta = provider.meta() logger.warning( @@ -49,7 +49,7 @@ async def provider( event: AstrMessageEvent, idx: str | int | None = None, idx2: int | None = None, - ): + ) -> None: """查看或者切换 LLM Provider""" umo = event.unified_msg_origin cfg = self.context.get_config(umo).get("provider_settings", {}) @@ -228,7 +228,7 @@ async def model_ls( self, message: AstrMessageEvent, idx_or_name: int | str | None = None, - ): + ) -> None: """查看或者切换模型""" prov = self.context.get_using_provider(message.unified_msg_origin) if not prov: @@ -293,7 +293,7 @@ async def model_ls( MessageEventResult().message(f"切换模型到 {prov.get_model()}。"), ) - async def key(self, message: AstrMessageEvent, index: int | None = None): + async def key(self, message: AstrMessageEvent, index: int | None = None) -> None: prov = self.context.get_using_provider(message.unified_msg_origin) if not prov: message.set_result( diff --git a/astrbot/builtin_stars/builtin_commands/commands/setunset.py b/astrbot/builtin_stars/builtin_commands/commands/setunset.py index 79e5d5d1c..096698844 100644 --- a/astrbot/builtin_stars/builtin_commands/commands/setunset.py +++ b/astrbot/builtin_stars/builtin_commands/commands/setunset.py @@ -3,10 +3,10 @@ class SetUnsetCommands: - def __init__(self, context: star.Context): + def __init__(self, context: star.Context) -> None: self.context = context - async def set_variable(self, event: AstrMessageEvent, key: str, value: str): + async def set_variable(self, event: AstrMessageEvent, key: str, value: str) -> None: """设置会话变量""" uid = event.unified_msg_origin session_var = await sp.session_get(uid, "session_variables", {}) @@ -19,7 +19,7 @@ async def set_variable(self, event: AstrMessageEvent, key: str, value: str): ), ) - async def unset_variable(self, event: AstrMessageEvent, key: str): + async def unset_variable(self, event: AstrMessageEvent, key: str) -> None: """移除会话变量""" uid = event.unified_msg_origin session_var = await sp.session_get(uid, "session_variables", {}) diff --git a/astrbot/builtin_stars/builtin_commands/commands/sid.py b/astrbot/builtin_stars/builtin_commands/commands/sid.py index 4d95c5a60..e8bdbffb1 100644 --- a/astrbot/builtin_stars/builtin_commands/commands/sid.py +++ b/astrbot/builtin_stars/builtin_commands/commands/sid.py @@ -7,10 +7,10 @@ class SIDCommand: """会话ID命令类""" - def __init__(self, context: star.Context): + def __init__(self, context: star.Context) -> None: self.context = context - async def sid(self, event: AstrMessageEvent): + async def sid(self, event: AstrMessageEvent) -> None: """获取消息来源信息""" sid = event.unified_msg_origin user_id = str(event.get_sender_id()) diff --git a/astrbot/builtin_stars/builtin_commands/commands/t2i.py b/astrbot/builtin_stars/builtin_commands/commands/t2i.py index 7766b342f..78d6b0df7 100644 --- a/astrbot/builtin_stars/builtin_commands/commands/t2i.py +++ b/astrbot/builtin_stars/builtin_commands/commands/t2i.py @@ -7,10 +7,10 @@ class T2ICommand: """文本转图片命令类""" - def __init__(self, context: star.Context): + def __init__(self, context: star.Context) -> None: self.context = context - async def t2i(self, event: AstrMessageEvent): + async def t2i(self, event: AstrMessageEvent) -> None: """开关文本转图片""" config = self.context.get_config(umo=event.unified_msg_origin) if config["t2i"]: diff --git a/astrbot/builtin_stars/builtin_commands/commands/tts.py b/astrbot/builtin_stars/builtin_commands/commands/tts.py index dee8e31de..13049ac22 100644 --- a/astrbot/builtin_stars/builtin_commands/commands/tts.py +++ b/astrbot/builtin_stars/builtin_commands/commands/tts.py @@ -8,10 +8,10 @@ class TTSCommand: """文本转语音命令类""" - def __init__(self, context: star.Context): + def __init__(self, context: star.Context) -> None: self.context = context - async def tts(self, event: AstrMessageEvent): + async def tts(self, event: AstrMessageEvent) -> None: """开关文本转语音(会话级别)""" umo = event.unified_msg_origin ses_tts = await SessionServiceManager.is_tts_enabled_for_session(umo) diff --git a/astrbot/builtin_stars/builtin_commands/main.py b/astrbot/builtin_stars/builtin_commands/main.py index 207a14b4a..fb4a83403 100644 --- a/astrbot/builtin_stars/builtin_commands/main.py +++ b/astrbot/builtin_stars/builtin_commands/main.py @@ -35,84 +35,84 @@ def __init__(self, context: star.Context) -> None: self.sid_c = SIDCommand(self.context) @filter.command("help") - async def help(self, event: AstrMessageEvent): + async def help(self, event: AstrMessageEvent) -> None: """查看帮助""" await self.help_c.help(event) @filter.permission_type(filter.PermissionType.ADMIN) @filter.command("llm") - async def llm(self, event: AstrMessageEvent): + async def llm(self, event: AstrMessageEvent) -> None: """开启/关闭 LLM""" await self.llm_c.llm(event) @filter.command_group("plugin") - def plugin(self): + def plugin(self) -> None: """插件管理""" @plugin.command("ls") - async def plugin_ls(self, event: AstrMessageEvent): + async def plugin_ls(self, event: AstrMessageEvent) -> None: """获取已经安装的插件列表。""" await self.plugin_c.plugin_ls(event) @filter.permission_type(filter.PermissionType.ADMIN) @plugin.command("off") - async def plugin_off(self, event: AstrMessageEvent, plugin_name: str = ""): + async def plugin_off(self, event: AstrMessageEvent, plugin_name: str = "") -> None: """禁用插件""" await self.plugin_c.plugin_off(event, plugin_name) @filter.permission_type(filter.PermissionType.ADMIN) @plugin.command("on") - async def plugin_on(self, event: AstrMessageEvent, plugin_name: str = ""): + async def plugin_on(self, event: AstrMessageEvent, plugin_name: str = "") -> None: """启用插件""" await self.plugin_c.plugin_on(event, plugin_name) @filter.permission_type(filter.PermissionType.ADMIN) @plugin.command("get") - async def plugin_get(self, event: AstrMessageEvent, plugin_repo: str = ""): + async def plugin_get(self, event: AstrMessageEvent, plugin_repo: str = "") -> None: """安装插件""" await self.plugin_c.plugin_get(event, plugin_repo) @plugin.command("help") - async def plugin_help(self, event: AstrMessageEvent, plugin_name: str = ""): + async def plugin_help(self, event: AstrMessageEvent, plugin_name: str = "") -> None: """获取插件帮助""" await self.plugin_c.plugin_help(event, plugin_name) @filter.command("t2i") - async def t2i(self, event: AstrMessageEvent): + async def t2i(self, event: AstrMessageEvent) -> None: """开关文本转图片""" await self.t2i_c.t2i(event) @filter.command("tts") - async def tts(self, event: AstrMessageEvent): + async def tts(self, event: AstrMessageEvent) -> None: """开关文本转语音(会话级别)""" await self.tts_c.tts(event) @filter.command("sid") - async def sid(self, event: AstrMessageEvent): + async def sid(self, event: AstrMessageEvent) -> None: """获取会话 ID 和 管理员 ID""" await self.sid_c.sid(event) @filter.permission_type(filter.PermissionType.ADMIN) @filter.command("op") - async def op(self, event: AstrMessageEvent, admin_id: str = ""): + async def op(self, event: AstrMessageEvent, admin_id: str = "") -> None: """授权管理员。op """ await self.admin_c.op(event, admin_id) @filter.permission_type(filter.PermissionType.ADMIN) @filter.command("deop") - async def deop(self, event: AstrMessageEvent, admin_id: str): + async def deop(self, event: AstrMessageEvent, admin_id: str) -> None: """取消授权管理员。deop """ await self.admin_c.deop(event, admin_id) @filter.permission_type(filter.PermissionType.ADMIN) @filter.command("wl") - async def wl(self, event: AstrMessageEvent, sid: str = ""): + async def wl(self, event: AstrMessageEvent, sid: str = "") -> None: """添加白名单。wl """ await self.admin_c.wl(event, sid) @filter.permission_type(filter.PermissionType.ADMIN) @filter.command("dwl") - async def dwl(self, event: AstrMessageEvent, sid: str): + async def dwl(self, event: AstrMessageEvent, sid: str) -> None: """删除白名单。dwl """ await self.admin_c.dwl(event, sid) @@ -123,89 +123,96 @@ async def provider( event: AstrMessageEvent, idx: str | int | None = None, idx2: int | None = None, - ): + ) -> None: """查看或者切换 LLM Provider""" await self.provider_c.provider(event, idx, idx2) @filter.command("reset") - async def reset(self, message: AstrMessageEvent): + async def reset(self, message: AstrMessageEvent) -> None: """重置 LLM 会话""" await self.conversation_c.reset(message) + @filter.command("stop") + async def stop(self, message: AstrMessageEvent) -> None: + """停止当前会话中正在运行的 Agent""" + await self.conversation_c.stop(message) + @filter.permission_type(filter.PermissionType.ADMIN) @filter.command("model") async def model_ls( self, message: AstrMessageEvent, idx_or_name: int | str | None = None, - ): + ) -> None: """查看或者切换模型""" await self.provider_c.model_ls(message, idx_or_name) @filter.command("history") - async def his(self, message: AstrMessageEvent, page: int = 1): + async def his(self, message: AstrMessageEvent, page: int = 1) -> None: """查看对话记录""" await self.conversation_c.his(message, page) @filter.command("ls") - async def convs(self, message: AstrMessageEvent, page: int = 1): + async def convs(self, message: AstrMessageEvent, page: int = 1) -> None: """查看对话列表""" await self.conversation_c.convs(message, page) @filter.command("new") - async def new_conv(self, message: AstrMessageEvent): + async def new_conv(self, message: AstrMessageEvent) -> None: """创建新对话""" await self.conversation_c.new_conv(message) @filter.permission_type(filter.PermissionType.ADMIN) @filter.command("groupnew") - async def groupnew_conv(self, message: AstrMessageEvent, sid: str): + async def groupnew_conv(self, message: AstrMessageEvent, sid: str) -> None: """创建新群聊对话""" await self.conversation_c.groupnew_conv(message, sid) @filter.command("switch") - async def switch_conv(self, message: AstrMessageEvent, index: int | None = None): + async def switch_conv( + self, message: AstrMessageEvent, index: int | None = None + ) -> None: """通过 /ls 前面的序号切换对话""" await self.conversation_c.switch_conv(message, index) @filter.command("rename") - async def rename_conv(self, message: AstrMessageEvent, new_name: str): + async def rename_conv(self, message: AstrMessageEvent, new_name: str) -> None: """重命名对话""" await self.conversation_c.rename_conv(message, new_name) @filter.command("del") - async def del_conv(self, message: AstrMessageEvent): + async def del_conv(self, message: AstrMessageEvent) -> None: """删除当前对话""" await self.conversation_c.del_conv(message) @filter.permission_type(filter.PermissionType.ADMIN) @filter.command("key") - async def key(self, message: AstrMessageEvent, index: int | None = None): + async def key(self, message: AstrMessageEvent, index: int | None = None) -> None: """查看或者切换 Key""" await self.provider_c.key(message, index) @filter.permission_type(filter.PermissionType.ADMIN) @filter.command("persona") - async def persona(self, message: AstrMessageEvent): + async def persona(self, message: AstrMessageEvent) -> None: """查看或者切换 Persona""" await self.persona_c.persona(message) @filter.permission_type(filter.PermissionType.ADMIN) @filter.command("dashboard_update") - async def update_dashboard(self, event: AstrMessageEvent): + async def update_dashboard(self, event: AstrMessageEvent) -> None: """更新管理面板""" await self.admin_c.update_dashboard(event) @filter.command("set") - async def set_variable(self, event: AstrMessageEvent, key: str, value: str): + async def set_variable(self, event: AstrMessageEvent, key: str, value: str) -> None: await self.setunset_c.set_variable(event, key, value) @filter.command("unset") - async def unset_variable(self, event: AstrMessageEvent, key: str): + async def unset_variable(self, event: AstrMessageEvent, key: str) -> None: await self.setunset_c.unset_variable(event, key) @filter.permission_type(filter.PermissionType.ADMIN) @filter.command("alter_cmd", alias={"alter"}) - async def alter_cmd(self, event: AstrMessageEvent): + async def alter_cmd(self, event: AstrMessageEvent) -> None: """修改命令权限""" await self.alter_cmd_c.alter_cmd(event) diff --git a/astrbot/builtin_stars/session_controller/main.py b/astrbot/builtin_stars/session_controller/main.py index cb8c8bf58..70081e03a 100644 --- a/astrbot/builtin_stars/session_controller/main.py +++ b/astrbot/builtin_stars/session_controller/main.py @@ -17,11 +17,11 @@ class Main(Star): """会话控制""" - def __init__(self, context: Context): + def __init__(self, context: Context) -> None: super().__init__(context) @filter.event_message_type(filter.EventMessageType.ALL, priority=maxsize) - async def handle_session_control_agent(self, event: AstrMessageEvent): + async def handle_session_control_agent(self, event: AstrMessageEvent) -> None: """会话控制代理""" for session_filter in FILTERS: session_id = session_filter.filter(event) @@ -90,7 +90,7 @@ async def handle_empty_mention(self, event: AstrMessageEvent): async def empty_mention_waiter( controller: SessionController, event: AstrMessageEvent, - ): + ) -> None: event.message_obj.message.insert( 0, Comp.At(qq=event.get_self_id(), name=event.get_self_id()), diff --git a/astrbot/builtin_stars/web_searcher/engines/__init__.py b/astrbot/builtin_stars/web_searcher/engines/__init__.py index 82def138f..55d2abffd 100644 --- a/astrbot/builtin_stars/web_searcher/engines/__init__.py +++ b/astrbot/builtin_stars/web_searcher/engines/__init__.py @@ -49,7 +49,7 @@ def __init__(self) -> None: def _set_selector(self, selector: str) -> str: raise NotImplementedError - def _get_next_page(self, query: str): + async def _get_next_page(self, query: str) -> str: raise NotImplementedError async def _get_html(self, url: str, data: dict | None = None) -> str: diff --git a/astrbot/builtin_stars/web_searcher/main.py b/astrbot/builtin_stars/web_searcher/main.py index e8388c816..d2c171a92 100644 --- a/astrbot/builtin_stars/web_searcher/main.py +++ b/astrbot/builtin_stars/web_searcher/main.py @@ -23,6 +23,7 @@ class Main(star.Star): "fetch_url", "web_search_tavily", "tavily_extract_web_page", + "web_search_bocha", ] def __init__(self, context: star.Context) -> None: @@ -30,6 +31,9 @@ def __init__(self, context: star.Context) -> None: self.tavily_key_index = 0 self.tavily_key_lock = asyncio.Lock() + self.bocha_key_index = 0 + self.bocha_key_lock = asyncio.Lock() + # 将 str 类型的 key 迁移至 list[str],并保存 cfg = self.context.get_config() provider_settings = cfg.get("provider_settings") @@ -45,6 +49,14 @@ def __init__(self, context: star.Context) -> None: provider_settings["websearch_tavily_key"] = [] cfg.save_config() + bocha_key = provider_settings.get("websearch_bocha_key") + if isinstance(bocha_key, str): + if bocha_key: + provider_settings["websearch_bocha_key"] = [bocha_key] + else: + provider_settings["websearch_bocha_key"] = [] + cfg.save_config() + self.bing_search = Bing() self.sogo_search = Sogo() self.baidu_initialized = False @@ -58,7 +70,7 @@ async def _get_from_url(self, url: str) -> str: header = HEADERS header.update({"User-Agent": random.choice(USER_AGENTS)}) async with aiohttp.ClientSession(trust_env=True) as session: - async with session.get(url, headers=header, timeout=6) as response: + async with session.get(url, headers=header) as response: html = await response.text(encoding="utf-8") doc = Document(html) ret = doc.summary(html_partial=True) @@ -139,7 +151,6 @@ async def _web_search_tavily( url, json=payload, headers=header, - timeout=6, ) as response: if response.status != 200: reason = await response.text() @@ -171,7 +182,6 @@ async def _extract_tavily(self, cfg: AstrBotConfig, payload: dict) -> list[dict] url, json=payload, headers=header, - timeout=6, ) as response: if response.status != 200: reason = await response.text() @@ -187,7 +197,7 @@ async def _extract_tavily(self, cfg: AstrBotConfig, payload: dict) -> list[dict] return results @filter.command("websearch") - async def websearch(self, event: AstrMessageEvent, oper: str | None = None): + async def websearch(self, event: AstrMessageEvent, oper: str | None = None) -> None: """网页搜索指令(已废弃)""" event.set_result( MessageEventResult().message( @@ -234,7 +244,7 @@ async def search_from_search_engine( return ret - async def ensure_baidu_ai_search_mcp(self, umo: str | None = None): + async def ensure_baidu_ai_search_mcp(self, umo: str | None = None) -> None: if self.baidu_initialized: return cfg = self.context.get_config(umo=umo) @@ -253,7 +263,7 @@ async def ensure_baidu_ai_search_mcp(self, umo: str | None = None): "transport": "sse", "url": f"http://appbuilder.baidu.com/v2/ai_search/mcp/sse?api_key={key}", "headers": {}, - "timeout": 30, + "timeout": 600, }, ) self.baidu_initialized = True @@ -341,7 +351,7 @@ async def search_from_tavily( } ) if result.favicon: - sp.temorary_cache["_ws_favicon"][result.url] = result.favicon + sp.temporary_cache["_ws_favicon"][result.url] = result.favicon # ret = "\n".join(ret_ls) ret = json.dumps({"results": ret_ls}, ensure_ascii=False) return ret @@ -382,12 +392,166 @@ async def tavily_extract_web_page( return "Error: Tavily web searcher does not return any results." return ret + async def _get_bocha_key(self, cfg: AstrBotConfig) -> str: + """并发安全的从列表中获取并轮换BoCha API密钥。""" + bocha_keys = cfg.get("provider_settings", {}).get("websearch_bocha_key", []) + if not bocha_keys: + raise ValueError("错误:BoCha API密钥未在AstrBot中配置。") + + async with self.bocha_key_lock: + key = bocha_keys[self.bocha_key_index] + self.bocha_key_index = (self.bocha_key_index + 1) % len(bocha_keys) + return key + + async def _web_search_bocha( + self, + cfg: AstrBotConfig, + payload: dict, + ) -> list[SearchResult]: + """使用 BoCha 搜索引擎进行搜索""" + bocha_key = await self._get_bocha_key(cfg) + url = "https://api.bochaai.com/v1/web-search" + header = { + "Authorization": f"Bearer {bocha_key}", + "Content-Type": "application/json", + } + async with aiohttp.ClientSession(trust_env=True) as session: + async with session.post( + url, + json=payload, + headers=header, + ) as response: + if response.status != 200: + reason = await response.text() + raise Exception( + f"BoCha web search failed: {reason}, status: {response.status}", + ) + data = await response.json() + data = data["data"]["webPages"]["value"] + results = [] + for item in data: + result = SearchResult( + title=item.get("name"), + url=item.get("url"), + snippet=item.get("snippet"), + favicon=item.get("siteIcon"), + ) + results.append(result) + return results + + @llm_tool("web_search_bocha") + async def search_from_bocha( + self, + event: AstrMessageEvent, + query: str, + freshness: str = "noLimit", + summary: bool = False, + include: str = "", + exclude: str = "", + count: int = 10, + ) -> str: + """ + A web search tool based on Bocha Search API, used to retrieve web pages + related to the user's query. + + Args: + query (string): Required. User's search query. + + freshness (string): Optional. Specifies the time range of the search. + Supported values: + - "noLimit": No time limit (default, recommended). + - "oneDay": Within one day. + - "oneWeek": Within one week. + - "oneMonth": Within one month. + - "oneYear": Within one year. + - "YYYY-MM-DD..YYYY-MM-DD": Search within a specific date range. + Example: "2025-01-01..2025-04-06". + - "YYYY-MM-DD": Search on a specific date. + Example: "2025-04-06". + It is recommended to use "noLimit", as the search algorithm will + automatically optimize time relevance. Manually restricting the + time range may result in no search results. + + summary (boolean): Optional. Whether to include a text summary + for each search result. + - True: Include summary. + - False: Do not include summary (default). + + include (string): Optional. Specifies the domains to include in + the search. Multiple domains can be separated by "|" or ",". + A maximum of 100 domains is allowed. + Examples: + - "qq.com" + - "qq.com|m.163.com" + + exclude (string): Optional. Specifies the domains to exclude from + the search. Multiple domains can be separated by "|" or ",". + A maximum of 100 domains is allowed. + Examples: + - "qq.com" + - "qq.com|m.163.com" + + count (number): Optional. Number of search results to return. + - Range: 1–50 + - Default: 10 + The actual number of returned results may be less than the + specified count. + """ + logger.info(f"web_searcher - search_from_bocha: {query}") + cfg = self.context.get_config(umo=event.unified_msg_origin) + # websearch_link = cfg["provider_settings"].get("web_search_link", False) + if not cfg.get("provider_settings", {}).get("websearch_bocha_key", []): + raise ValueError("Error: BoCha API key is not configured in AstrBot.") + + # build payload + payload = { + "query": query, + "count": count, + } + + # freshness:时间范围 + if freshness: + payload["freshness"] = freshness + + # 是否返回摘要 + payload["summary"] = summary + + # include:限制搜索域 + if include: + payload["include"] = include + + # exclude:排除搜索域 + if exclude: + payload["exclude"] = exclude + + results = await self._web_search_bocha(cfg, payload) + if not results: + return "Error: BoCha web searcher does not return any results." + + ret_ls = [] + ref_uuid = str(uuid.uuid4())[:4] + for idx, result in enumerate(results, 1): + index = f"{ref_uuid}.{idx}" + ret_ls.append( + { + "title": f"{result.title}", + "url": f"{result.url}", + "snippet": f"{result.snippet}", + "index": index, + } + ) + if result.favicon: + sp.temporary_cache["_ws_favicon"][result.url] = result.favicon + # ret = "\n".join(ret_ls) + ret = json.dumps({"results": ret_ls}, ensure_ascii=False) + return ret + @filter.on_llm_request(priority=-10000) async def edit_web_search_tools( self, event: AstrMessageEvent, req: ProviderRequest, - ): + ) -> None: """Get the session conversation for the given event.""" cfg = self.context.get_config(umo=event.unified_msg_origin) prov_settings = cfg.get("provider_settings", {}) @@ -419,6 +583,7 @@ async def edit_web_search_tools( tool_set.remove_tool("web_search_tavily") tool_set.remove_tool("tavily_extract_web_page") tool_set.remove_tool("AIsearch") + tool_set.remove_tool("web_search_bocha") elif provider == "tavily": web_search_tavily = func_tool_mgr.get_func("web_search_tavily") tavily_extract_web_page = func_tool_mgr.get_func("tavily_extract_web_page") @@ -429,6 +594,7 @@ async def edit_web_search_tools( tool_set.remove_tool("web_search") tool_set.remove_tool("fetch_url") tool_set.remove_tool("AIsearch") + tool_set.remove_tool("web_search_bocha") elif provider == "baidu_ai_search": try: await self.ensure_baidu_ai_search_mcp(event.unified_msg_origin) @@ -440,5 +606,15 @@ async def edit_web_search_tools( tool_set.remove_tool("fetch_url") tool_set.remove_tool("web_search_tavily") tool_set.remove_tool("tavily_extract_web_page") + tool_set.remove_tool("web_search_bocha") except Exception as e: logger.error(f"Cannot Initialize Baidu AI Search MCP Server: {e}") + elif provider == "bocha": + web_search_bocha = func_tool_mgr.get_func("web_search_bocha") + if web_search_bocha: + tool_set.add_tool(web_search_bocha) + tool_set.remove_tool("web_search") + tool_set.remove_tool("fetch_url") + tool_set.remove_tool("AIsearch") + tool_set.remove_tool("web_search_tavily") + tool_set.remove_tool("tavily_extract_web_page") diff --git a/astrbot/cli/__init__.py b/astrbot/cli/__init__.py index 3914306d0..068376473 100644 --- a/astrbot/cli/__init__.py +++ b/astrbot/cli/__init__.py @@ -1 +1 @@ -__version__ = "4.14.4" +__version__ = "4.18.3" diff --git a/astrbot/cli/commands/cmd_conf.py b/astrbot/cli/commands/cmd_conf.py index a9bd40f00..703c9b899 100644 --- a/astrbot/cli/commands/cmd_conf.py +++ b/astrbot/cli/commands/cmd_conf.py @@ -127,7 +127,7 @@ def _get_nested_item(obj: dict[str, Any], path: str) -> Any: @click.group(name="conf") -def conf(): +def conf() -> None: """配置管理命令 支持的配置项: @@ -149,7 +149,7 @@ def conf(): @conf.command(name="set") @click.argument("key") @click.argument("value") -def set_config(key: str, value: str): +def set_config(key: str, value: str) -> None: """设置配置项的值""" if key not in CONFIG_VALIDATORS: raise click.ClickException(f"不支持的配置项: {key}") @@ -178,7 +178,7 @@ def set_config(key: str, value: str): @conf.command(name="get") @click.argument("key", required=False) -def get_config(key: str | None = None): +def get_config(key: str | None = None) -> None: """获取配置项的值,不提供key则显示所有可配置项""" config = _load_config() diff --git a/astrbot/cli/commands/cmd_init.py b/astrbot/cli/commands/cmd_init.py index 0adbf3288..6c0c34b99 100644 --- a/astrbot/cli/commands/cmd_init.py +++ b/astrbot/cli/commands/cmd_init.py @@ -34,13 +34,8 @@ async def initialize_astrbot(astrbot_root: Path) -> None: for name, path in paths.items(): path.mkdir(parents=True, exist_ok=True) click.echo(f"{'Created' if not path.exists() else 'Directory exists'}: {path}") - if click.confirm( - "是否需要集成式 WebUI?(个人电脑推荐,服务器不推荐)", - default=True, - ): - await check_dashboard(astrbot_root / "data") - else: - click.echo("你可以使用在线面版(v4.14.4+),填写后端地址的方式来控制。") + + await check_dashboard(astrbot_root / "data") @click.command() diff --git a/astrbot/cli/commands/cmd_plug.py b/astrbot/cli/commands/cmd_plug.py index a1099de1d..9cf94365a 100644 --- a/astrbot/cli/commands/cmd_plug.py +++ b/astrbot/cli/commands/cmd_plug.py @@ -15,7 +15,7 @@ @click.group() -def plug(): +def plug() -> None: """插件管理""" @@ -28,7 +28,7 @@ def _get_data_path() -> Path: return (base / "data").resolve() -def display_plugins(plugins, title=None, color=None): +def display_plugins(plugins, title=None, color=None) -> None: if title: click.echo(click.style(title, fg=color, bold=True)) @@ -45,7 +45,7 @@ def display_plugins(plugins, title=None, color=None): @plug.command() @click.argument("name") -def new(name: str): +def new(name: str) -> None: """创建新插件""" base_path = _get_data_path() plug_path = base_path / "plugins" / name @@ -100,7 +100,7 @@ def new(name: str): @plug.command() @click.option("--all", "-a", is_flag=True, help="列出未安装的插件") -def list(all: bool): +def list(all: bool) -> None: """列出插件""" base_path = _get_data_path() plugins = build_plug_list(base_path / "plugins") @@ -141,7 +141,7 @@ def list(all: bool): @plug.command() @click.argument("name") @click.option("--proxy", help="代理服务器地址") -def install(name: str, proxy: str | None): +def install(name: str, proxy: str | None) -> None: """安装插件""" base_path = _get_data_path() plug_path = base_path / "plugins" @@ -164,7 +164,7 @@ def install(name: str, proxy: str | None): @plug.command() @click.argument("name") -def remove(name: str): +def remove(name: str) -> None: """卸载插件""" base_path = _get_data_path() plugins = build_plug_list(base_path / "plugins") @@ -187,7 +187,7 @@ def remove(name: str): @plug.command() @click.argument("name", required=False) @click.option("--proxy", help="Github代理地址") -def update(name: str, proxy: str | None): +def update(name: str, proxy: str | None) -> None: """更新插件""" base_path = _get_data_path() plug_path = base_path / "plugins" @@ -225,7 +225,7 @@ def update(name: str, proxy: str | None): @plug.command() @click.argument("query") -def search(query: str): +def search(query: str) -> None: """搜索插件""" base_path = _get_data_path() plugins = build_plug_list(base_path / "plugins") diff --git a/astrbot/cli/commands/cmd_run.py b/astrbot/cli/commands/cmd_run.py index 68b71dca9..23665dff3 100644 --- a/astrbot/cli/commands/cmd_run.py +++ b/astrbot/cli/commands/cmd_run.py @@ -10,13 +10,12 @@ from ..utils import check_astrbot_root, check_dashboard, get_astrbot_root -async def run_astrbot(astrbot_root: Path): +async def run_astrbot(astrbot_root: Path) -> None: """运行 AstrBot""" from astrbot.core import LogBroker, LogManager, db_helper, logger from astrbot.core.initial_loader import InitialLoader - if os.environ.get("DASHBOARD_ENABLE") == "True": - await check_dashboard(astrbot_root / "data") + await check_dashboard(astrbot_root / "data") log_broker = LogBroker() LogManager.set_queue_handler(logger, log_broker) @@ -28,17 +27,9 @@ async def run_astrbot(astrbot_root: Path): @click.option("--reload", "-r", is_flag=True, help="插件自动重载") -@click.option( - "--host", "-H", help="Astrbot Dashboard Host,默认::", required=False, type=str -) -@click.option( - "--port", "-p", help="Astrbot Dashboard端口,默认6185", required=False, type=str -) -@click.option( - "--backend-only", is_flag=True, default=False, help="禁用WEBUI,仅启动后端" -) +@click.option("--port", "-p", help="Astrbot Dashboard端口", required=False, type=str) @click.command() -def run(reload: bool, host: str, port: str, backend_only: bool) -> None: +def run(reload: bool, port: str) -> None: """运行 AstrBot""" try: os.environ["ASTRBOT_CLI"] = "1" @@ -52,11 +43,8 @@ def run(reload: bool, host: str, port: str, backend_only: bool) -> None: os.environ["ASTRBOT_ROOT"] = str(astrbot_root) sys.path.insert(0, str(astrbot_root)) - if port is not None: + if port: os.environ["DASHBOARD_PORT"] = port - if host is not None: - os.environ["DASHBOARD_HOST"] = host - os.environ["DASHBOARD_ENABLE"] = str(not backend_only) if reload: click.echo("启用插件自动重载") diff --git a/astrbot/cli/utils/plugin.py b/astrbot/cli/utils/plugin.py index cd76a07c8..81f59e0bf 100644 --- a/astrbot/cli/utils/plugin.py +++ b/astrbot/cli/utils/plugin.py @@ -19,7 +19,7 @@ class PluginStatus(str, Enum): NOT_PUBLISHED = "未发布" -def get_git_repo(url: str, target_path: Path, proxy: str | None = None): +def get_git_repo(url: str, target_path: Path, proxy: str | None = None) -> None: """从 Git 仓库下载代码并解压到指定路径""" temp_dir = Path(tempfile.mkdtemp()) try: diff --git a/astrbot/core/agent/context/compressor.py b/astrbot/core/agent/context/compressor.py index 792835181..31a0b0b48 100644 --- a/astrbot/core/agent/context/compressor.py +++ b/astrbot/core/agent/context/compressor.py @@ -57,7 +57,9 @@ class TruncateByTurnsCompressor: Truncates the message list by removing older turns. """ - def __init__(self, truncate_turns: int = 1, compression_threshold: float = 0.82): + def __init__( + self, truncate_turns: int = 1, compression_threshold: float = 0.82 + ) -> None: """Initialize the truncate by turns compressor. Args: @@ -152,7 +154,7 @@ def __init__( keep_recent: int = 4, instruction_text: str | None = None, compression_threshold: float = 0.82, - ): + ) -> None: """Initialize the LLM summary compressor. Args: diff --git a/astrbot/core/agent/context/manager.py b/astrbot/core/agent/context/manager.py index b8e131d98..216a3e7e1 100644 --- a/astrbot/core/agent/context/manager.py +++ b/astrbot/core/agent/context/manager.py @@ -13,7 +13,7 @@ class ContextManager: def __init__( self, config: ContextConfig, - ): + ) -> None: """Initialize the context manager. There are two strategies to handle context limit reached: diff --git a/astrbot/core/agent/context/truncator.py b/astrbot/core/agent/context/truncator.py index 8d1da6f56..afd89f2be 100644 --- a/astrbot/core/agent/context/truncator.py +++ b/astrbot/core/agent/context/truncator.py @@ -4,19 +4,60 @@ class ContextTruncator: """Context truncator.""" + def _has_tool_calls(self, message: Message) -> bool: + """Check if a message contains tool calls.""" + return ( + message.role == "assistant" + and message.tool_calls is not None + and len(message.tool_calls) > 0 + ) + def fix_messages(self, messages: list[Message]) -> list[Message]: - fixed_messages = [] - for message in messages: - if message.role == "tool": - # tool block 前面必须要有 user 和 assistant block - if len(fixed_messages) < 2: - # 这种情况可能是上下文被截断导致的 - # 我们直接将之前的上下文都清空 - fixed_messages = [] - else: - fixed_messages.append(message) - else: - fixed_messages.append(message) + """修复消息列表,确保 tool call 和 tool response 的配对关系有效。 + + 此方法确保: + 1. 每个 `tool` 消息前面都有一个包含 tool_calls 的 `assistant` 消息 + 2. 每个包含 tool_calls 的 `assistant` 消息后面都有对应的 `tool` 响应 + + 这是 OpenAI Chat Completions API 规范的要求(Gemini 对此执行严格检查)。 + """ + if not messages: + return messages + + fixed_messages: list[Message] = [] + pending_assistant: Message | None = None + pending_tools: list[Message] = [] + + def flush_pending_if_valid() -> None: + nonlocal pending_assistant, pending_tools + if pending_assistant is not None and pending_tools: + fixed_messages.append(pending_assistant) + fixed_messages.extend(pending_tools) + pending_assistant = None + pending_tools = [] + + for msg in messages: + if msg.role == "tool": + # 只有在有挂起的 assistant(tool_calls) 时才记录 tool 响应 + if pending_assistant is not None: + pending_tools.append(msg) + # else: 孤立的 tool 消息,直接忽略 + continue + + if self._has_tool_calls(msg): + # 遇到新的 assistant(tool_calls) 前,先处理旧的 pending 链 + flush_pending_if_valid() + pending_assistant = msg + continue + + # 非 tool,且不含 tool_calls 的消息 + # 先结束任何 pending 链,再正常追加 + flush_pending_if_valid() + fixed_messages.append(msg) + + # 结束时处理最后一个 pending 链 + flush_pending_if_valid() + return fixed_messages def truncate_by_turns( diff --git a/astrbot/core/agent/handoff.py b/astrbot/core/agent/handoff.py index 5812766c8..8475009d3 100644 --- a/astrbot/core/agent/handoff.py +++ b/astrbot/core/agent/handoff.py @@ -14,8 +14,7 @@ def __init__( parameters: dict | None = None, tool_description: str | None = None, **kwargs, - ): - self.agent = agent + ) -> None: # Avoid passing duplicate `description` to the FunctionTool dataclass. # Some call sites (e.g. SubAgentOrchestrator) pass `description` via kwargs @@ -34,6 +33,8 @@ def __init__( # Optional provider override for this subagent. When set, the handoff # execution will use this chat provider id instead of the global/default. self.provider_id: str | None = None + # Note: Must assign after super().__init__() to prevent parent class from overriding this attribute + self.agent = agent def default_parameters(self) -> dict: return { @@ -43,6 +44,19 @@ def default_parameters(self) -> dict: "type": "string", "description": "The input to be handed off to another agent. This should be a clear and concise request or task.", }, + "image_urls": { + "type": "array", + "items": {"type": "string"}, + "description": "Optional: An array of image sources (public HTTP URLs or local file paths) used as references in multimodal tasks such as video generation.", + }, + "background_task": { + "type": "boolean", + "description": ( + "Defaults to false. " + "Set to true if the task may take noticeable time, involves external tools, or the user does not need to wait. " + "Use false only for quick, immediate tasks." + ), + }, }, } diff --git a/astrbot/core/agent/hooks.py b/astrbot/core/agent/hooks.py index d834240b7..74ca6335b 100644 --- a/astrbot/core/agent/hooks.py +++ b/astrbot/core/agent/hooks.py @@ -9,22 +9,22 @@ class BaseAgentRunHooks(Generic[TContext]): - async def on_agent_begin(self, run_context: ContextWrapper[TContext]): ... + async def on_agent_begin(self, run_context: ContextWrapper[TContext]) -> None: ... async def on_tool_start( self, run_context: ContextWrapper[TContext], tool: FunctionTool, tool_args: dict | None, - ): ... + ) -> None: ... async def on_tool_end( self, run_context: ContextWrapper[TContext], tool: FunctionTool, tool_args: dict | None, tool_result: mcp.types.CallToolResult | None, - ): ... + ) -> None: ... async def on_agent_done( self, run_context: ContextWrapper[TContext], llm_response: LLMResponse, - ): ... + ) -> None: ... diff --git a/astrbot/core/agent/mcp_client.py b/astrbot/core/agent/mcp_client.py index c5ff123b2..18f4d47e0 100644 --- a/astrbot/core/agent/mcp_client.py +++ b/astrbot/core/agent/mcp_client.py @@ -108,7 +108,7 @@ async def _quick_test_mcp_connection(config: dict) -> tuple[bool, str]: class MCPClient: - def __init__(self): + def __init__(self) -> None: # Initialize session and client objects self.session: mcp.ClientSession | None = None self.exit_stack = AsyncExitStack() @@ -126,7 +126,7 @@ def __init__(self): self._reconnect_lock = asyncio.Lock() # Lock for thread-safe reconnection self._reconnecting: bool = False # For logging and debugging - async def connect_to_server(self, mcp_server_config: dict, name: str): + async def connect_to_server(self, mcp_server_config: dict, name: str) -> None: """Connect to MCP server If `url` parameter exists: @@ -144,7 +144,7 @@ async def connect_to_server(self, mcp_server_config: dict, name: str): cfg = _prepare_config(mcp_server_config.copy()) - def logging_callback(msg: str): + def logging_callback(msg: str) -> None: # Handle MCP service error logs print(f"MCP Server {name} Error: {msg}") self.server_errlogs.append(msg) @@ -214,7 +214,7 @@ def logging_callback(msg: str): **cfg, ) - def callback(msg: str): + def callback(msg: str) -> None: # Handle MCP service error logs self.server_errlogs.append(msg) @@ -343,7 +343,7 @@ async def _call_with_retry(): return await _call_with_retry() - async def cleanup(self): + async def cleanup(self) -> None: """Clean up resources including old exit stacks from reconnections""" # Close current exit stack try: @@ -365,7 +365,7 @@ class MCPTool(FunctionTool, Generic[TContext]): def __init__( self, mcp_tool: mcp.Tool, mcp_client: MCPClient, mcp_server_name: str, **kwargs - ): + ) -> None: super().__init__( name=mcp_tool.name, description=mcp_tool.description or "", diff --git a/astrbot/core/agent/message.py b/astrbot/core/agent/message.py index 582b1eef2..bde6353ff 100644 --- a/astrbot/core/agent/message.py +++ b/astrbot/core/agent/message.py @@ -3,7 +3,13 @@ from typing import Any, ClassVar, Literal, cast -from pydantic import BaseModel, GetCoreSchemaHandler, model_serializer, model_validator +from pydantic import ( + BaseModel, + GetCoreSchemaHandler, + PrivateAttr, + model_serializer, + model_validator, +) from pydantic_core import core_schema @@ -178,6 +184,8 @@ class Message(BaseModel): tool_call_id: str | None = None """The ID of the tool call.""" + _no_save: bool = PrivateAttr(default=False) + @model_validator(mode="after") def check_content_required(self): # assistant + tool_calls is not None: allow content to be None diff --git a/astrbot/core/agent/runners/coze/coze_api_client.py b/astrbot/core/agent/runners/coze/coze_api_client.py index e8f3a1e24..f5799dfbb 100644 --- a/astrbot/core/agent/runners/coze/coze_api_client.py +++ b/astrbot/core/agent/runners/coze/coze_api_client.py @@ -10,7 +10,7 @@ class CozeAPIClient: - def __init__(self, api_key: str, api_base: str = "https://api.coze.cn"): + def __init__(self, api_key: str, api_base: str = "https://api.coze.cn") -> None: self.api_key = api_key self.api_base = api_base self.session = None @@ -277,7 +277,7 @@ async def get_message_list( logger.error(f"获取Coze消息列表失败: {e!s}") raise Exception(f"获取Coze消息列表失败: {e!s}") - async def close(self): + async def close(self) -> None: """关闭会话""" if self.session: await self.session.close() @@ -288,7 +288,7 @@ async def close(self): import asyncio import os - async def test_coze_api_client(): + async def test_coze_api_client() -> None: api_key = os.getenv("COZE_API_KEY", "") bot_id = os.getenv("COZE_BOT_ID", "") client = CozeAPIClient(api_key=api_key) diff --git a/astrbot/core/agent/runners/dashscope/dashscope_agent_runner.py b/astrbot/core/agent/runners/dashscope/dashscope_agent_runner.py index 7a095a60b..1aaf6e3b9 100644 --- a/astrbot/core/agent/runners/dashscope/dashscope_agent_runner.py +++ b/astrbot/core/agent/runners/dashscope/dashscope_agent_runner.py @@ -67,7 +67,7 @@ async def reset( if isinstance(self.timeout, str): self.timeout = int(self.timeout) - def has_rag_options(self): + def has_rag_options(self) -> bool: """判断是否有 RAG 选项 Returns: diff --git a/astrbot/core/agent/runners/dify/dify_agent_runner.py b/astrbot/core/agent/runners/dify/dify_agent_runner.py index d9a8b7cd6..93f8d3570 100644 --- a/astrbot/core/agent/runners/dify/dify_agent_runner.py +++ b/astrbot/core/agent/runners/dify/dify_agent_runner.py @@ -10,7 +10,7 @@ LLMResponse, ProviderRequest, ) -from astrbot.core.utils.astrbot_path import get_astrbot_data_path +from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from astrbot.core.utils.io import download_file from ...hooks import BaseAgentRunHooks @@ -291,8 +291,8 @@ async def parse_file(item: dict): return Comp.Image(file=item["url"], url=item["url"]) case "audio": # 仅支持 wav - temp_dir = os.path.join(get_astrbot_data_path(), "temp") - path = os.path.join(temp_dir, f"{item['filename']}.wav") + temp_dir = get_astrbot_temp_path() + path = os.path.join(temp_dir, f"dify_{item['filename']}.wav") await download_file(item["url"], path) return Comp.Image(file=item["url"], url=item["url"]) case "video": diff --git a/astrbot/core/agent/runners/dify/dify_api_client.py b/astrbot/core/agent/runners/dify/dify_api_client.py index d9c6556cf..26da6dfe9 100644 --- a/astrbot/core/agent/runners/dify/dify_api_client.py +++ b/astrbot/core/agent/runners/dify/dify_api_client.py @@ -31,7 +31,7 @@ async def _stream_sse(resp: ClientResponse) -> AsyncGenerator[dict, None]: class DifyAPIClient: - def __init__(self, api_key: str, api_base: str = "https://api.dify.ai/v1"): + def __init__(self, api_key: str, api_base: str = "https://api.dify.ai/v1") -> None: self.api_key = api_key self.api_base = api_base self.session = ClientSession(trust_env=True) @@ -155,7 +155,7 @@ async def file_upload( raise Exception(f"Dify 文件上传失败:{resp.status}. {text}") return await resp.json() # {"id": "xxx", ...} - async def close(self): + async def close(self) -> None: await self.session.close() async def get_chat_convs(self, user: str, limit: int = 20): diff --git a/astrbot/core/agent/runners/tool_loop_agent_runner.py b/astrbot/core/agent/runners/tool_loop_agent_runner.py index 0e5b4353f..94069089d 100644 --- a/astrbot/core/agent/runners/tool_loop_agent_runner.py +++ b/astrbot/core/agent/runners/tool_loop_agent_runner.py @@ -1,8 +1,10 @@ +import asyncio import copy import sys import time import traceback import typing as T +from dataclasses import dataclass, field from mcp.types import ( BlobResourceContents, @@ -14,8 +16,9 @@ ) from astrbot import logger -from astrbot.core.agent.message import TextPart, ThinkPart +from astrbot.core.agent.message import ImageURLPart, TextPart, ThinkPart from astrbot.core.agent.tool import ToolSet +from astrbot.core.agent.tool_image_cache import tool_image_cache from astrbot.core.message.components import Json from astrbot.core.message.message_event_result import ( MessageChain, @@ -44,6 +47,36 @@ from typing_extensions import override +@dataclass(slots=True) +class _HandleFunctionToolsResult: + kind: T.Literal["message_chain", "tool_call_result_blocks", "cached_image"] + message_chain: MessageChain | None = None + tool_call_result_blocks: list[ToolCallMessageSegment] | None = None + cached_image: T.Any = None + + @classmethod + def from_message_chain(cls, chain: MessageChain) -> "_HandleFunctionToolsResult": + return cls(kind="message_chain", message_chain=chain) + + @classmethod + def from_tool_call_result_blocks( + cls, blocks: list[ToolCallMessageSegment] + ) -> "_HandleFunctionToolsResult": + return cls(kind="tool_call_result_blocks", tool_call_result_blocks=blocks) + + @classmethod + def from_cached_image(cls, image: T.Any) -> "_HandleFunctionToolsResult": + return cls(kind="cached_image", cached_image=image) + + +@dataclass(slots=True) +class FollowUpTicket: + seq: int + text: str + consumed: bool = False + resolved: asyncio.Event = field(default_factory=asyncio.Event) + + class ToolLoopAgentRunner(BaseAgentRunner[TContext]): @override async def reset( @@ -67,6 +100,7 @@ async def reset( custom_token_counter: TokenCounter | None = None, custom_compressor: ContextCompressor | None = None, tool_schema_mode: str | None = "full", + fallback_providers: list[Provider] | None = None, **kwargs: T.Any, ) -> None: self.req = request @@ -96,11 +130,26 @@ async def reset( self.context_manager = ContextManager(self.context_config) self.provider = provider + self.fallback_providers: list[Provider] = [] + seen_provider_ids: set[str] = {str(provider.provider_config.get("id", ""))} + for fallback_provider in fallback_providers or []: + fallback_id = str(fallback_provider.provider_config.get("id", "")) + if fallback_provider is provider: + continue + if fallback_id and fallback_id in seen_provider_ids: + continue + self.fallback_providers.append(fallback_provider) + if fallback_id: + seen_provider_ids.add(fallback_id) self.final_llm_resp = None self._state = AgentState.IDLE self.tool_executor = tool_executor self.agent_hooks = agent_hooks self.run_context = run_context + self._stop_requested = False + self._aborted = False + self._pending_follow_ups: list[FollowUpTicket] = [] + self._follow_up_seq = 0 # These two are used for tool schema mode handling # We now have two modes: @@ -125,7 +174,10 @@ async def reset( messages = [] # append existing messages in the run context for msg in request.contexts: - messages.append(Message.model_validate(msg)) + m = Message.model_validate(msg) + if isinstance(msg, dict) and msg.get("_no_save"): + m._no_save = True + messages.append(m) if request.prompt is not None: m = await request.assemble_context() messages.append(Message.model_validate(m)) @@ -139,16 +191,19 @@ async def reset( self.stats = AgentStats() self.stats.start_time = time.time() - async def _iter_llm_responses(self) -> T.AsyncGenerator[LLMResponse, None]: + async def _iter_llm_responses( + self, *, include_model: bool = True + ) -> T.AsyncGenerator[LLMResponse, None]: """Yields chunks *and* a final LLMResponse.""" payload = { "contexts": self.run_context.messages, # list[Message] "func_tool": self.req.func_tool, - "model": self.req.model, # NOTE: in fact, this arg is None in most cases "session_id": self.req.session_id, "extra_user_content_parts": self.req.extra_user_content_parts, # list[ContentPart] } - + if include_model: + # For primary provider we keep explicit model selection if provided. + payload["model"] = self.req.model if self.streaming: stream = self.provider.text_chat_stream(**payload) async for resp in stream: # type: ignore @@ -156,6 +211,132 @@ async def _iter_llm_responses(self) -> T.AsyncGenerator[LLMResponse, None]: else: yield await self.provider.text_chat(**payload) + async def _iter_llm_responses_with_fallback( + self, + ) -> T.AsyncGenerator[LLMResponse, None]: + """Wrap _iter_llm_responses with provider fallback handling.""" + candidates = [self.provider, *self.fallback_providers] + total_candidates = len(candidates) + last_exception: Exception | None = None + last_err_response: LLMResponse | None = None + + for idx, candidate in enumerate(candidates): + candidate_id = candidate.provider_config.get("id", "") + is_last_candidate = idx == total_candidates - 1 + if idx > 0: + logger.warning( + "Switched from %s to fallback chat provider: %s", + self.provider.provider_config.get("id", ""), + candidate_id, + ) + self.provider = candidate + has_stream_output = False + try: + async for resp in self._iter_llm_responses(include_model=idx == 0): + if resp.is_chunk: + has_stream_output = True + yield resp + continue + + if ( + resp.role == "err" + and not has_stream_output + and (not is_last_candidate) + ): + last_err_response = resp + logger.warning( + "Chat Model %s returns error response, trying fallback to next provider.", + candidate_id, + ) + break + + yield resp + return + + if has_stream_output: + return + except Exception as exc: # noqa: BLE001 + last_exception = exc + logger.warning( + "Chat Model %s request error: %s", + candidate_id, + exc, + exc_info=True, + ) + continue + + if last_err_response: + yield last_err_response + return + if last_exception: + yield LLMResponse( + role="err", + completion_text=( + "All chat models failed: " + f"{type(last_exception).__name__}: {last_exception}" + ), + ) + return + yield LLMResponse( + role="err", + completion_text="All available chat models are unavailable.", + ) + + def _simple_print_message_role(self, tag: str = ""): + roles = [] + for message in self.run_context.messages: + roles.append(message.role) + logger.debug(f"{tag} RunCtx.messages -> [{len(roles)}] {','.join(roles)}") + + def follow_up( + self, + *, + message_text: str, + ) -> FollowUpTicket | None: + """Queue a follow-up message for the next tool result.""" + if self.done(): + return None + text = (message_text or "").strip() + if not text: + return None + ticket = FollowUpTicket(seq=self._follow_up_seq, text=text) + self._follow_up_seq += 1 + self._pending_follow_ups.append(ticket) + return ticket + + def _resolve_unconsumed_follow_ups(self) -> None: + if not self._pending_follow_ups: + return + follow_ups = self._pending_follow_ups + self._pending_follow_ups = [] + for ticket in follow_ups: + ticket.resolved.set() + + def _consume_follow_up_notice(self) -> str: + if not self._pending_follow_ups: + return "" + follow_ups = self._pending_follow_ups + self._pending_follow_ups = [] + for ticket in follow_ups: + ticket.consumed = True + ticket.resolved.set() + follow_up_lines = "\n".join( + f"{idx}. {ticket.text}" for idx, ticket in enumerate(follow_ups, start=1) + ) + return ( + "\n\n[SYSTEM NOTICE] User sent follow-up messages while tool execution " + "was in progress. Prioritize these follow-up instructions in your next " + "actions. In your very next action, briefly acknowledge to the user " + "that their follow-up message(s) were received before continuing.\n" + f"{follow_up_lines}" + ) + + def _merge_follow_up_notice(self, content: str) -> str: + notice = self._consume_follow_up_notice() + if not notice: + return content + return f"{content}{notice}" + @override async def step(self): """Process a single step of the agent. @@ -176,11 +357,13 @@ async def step(self): # do truncate and compress token_usage = self.req.conversation.token_usage if self.req.conversation else 0 + self._simple_print_message_role("[BefCompact]") self.run_context.messages = await self.context_manager.process( self.run_context.messages, trusted_token_usage=token_usage ) + self._simple_print_message_role("[AftCompact]") - async for llm_response in self._iter_llm_responses(): + async for llm_response in self._iter_llm_responses_with_fallback(): if llm_response.is_chunk: # update ttft if self.stats.time_to_first_token == 0: @@ -207,6 +390,14 @@ async def step(self): ), ), ) + if self._stop_requested: + llm_resp_result = LLMResponse( + role="assistant", + completion_text="[SYSTEM: User actively interrupted the response generation. Partial output before interruption is preserved.]", + reasoning_content=llm_response.reasoning_content, + reasoning_signature=llm_response.reasoning_signature, + ) + break continue llm_resp_result = llm_response @@ -218,6 +409,49 @@ async def step(self): break # got final response if not llm_resp_result: + if self._stop_requested: + llm_resp_result = LLMResponse(role="assistant", completion_text="") + else: + return + + if self._stop_requested: + logger.info("Agent execution was requested to stop by user.") + llm_resp = llm_resp_result + if llm_resp.role != "assistant": + llm_resp = LLMResponse( + role="assistant", + completion_text="[SYSTEM: User actively interrupted the response generation. Partial output before interruption is preserved.]", + ) + self.final_llm_resp = llm_resp + self._aborted = True + self._transition_state(AgentState.DONE) + self.stats.end_time = time.time() + + parts = [] + if llm_resp.reasoning_content or llm_resp.reasoning_signature: + parts.append( + ThinkPart( + think=llm_resp.reasoning_content, + encrypted=llm_resp.reasoning_signature, + ) + ) + if llm_resp.completion_text: + parts.append(TextPart(text=llm_resp.completion_text)) + if parts: + self.run_context.messages.append( + Message(role="assistant", content=parts) + ) + + try: + await self.agent_hooks.on_agent_done(self.run_context, llm_resp) + except Exception as e: + logger.error(f"Error in on_agent_done hook: {e}", exc_info=True) + + yield AgentResponse( + type="aborted", + data=AgentResponseData(chain=MessageChain(type="aborted")), + ) + self._resolve_unconsumed_follow_ups() return # 处理 LLM 响应 @@ -228,6 +462,7 @@ async def step(self): self.final_llm_resp = llm_resp self.stats.end_time = time.time() self._transition_state(AgentState.ERROR) + self._resolve_unconsumed_follow_ups() yield AgentResponse( type="err", data=AgentResponseData( @@ -236,6 +471,7 @@ async def step(self): ), ), ) + return if not llm_resp.tools_call_name: # 如果没有工具调用,转换到完成状态 @@ -254,6 +490,10 @@ async def step(self): ) if llm_resp.completion_text: parts.append(TextPart(text=llm_resp.completion_text)) + if len(parts) == 0: + logger.warning( + "LLM returned empty assistant message with no tool calls." + ) self.run_context.messages.append(Message(role="assistant", content=parts)) # call the on_agent_done hook @@ -261,6 +501,7 @@ async def step(self): await self.agent_hooks.on_agent_done(self.run_context, llm_resp) except Exception as e: logger.error(f"Error in on_agent_done hook: {e}", exc_info=True) + self._resolve_unconsumed_follow_ups() # 返回 LLM 结果 if llm_resp.result_chain: @@ -282,20 +523,27 @@ async def step(self): llm_resp, _ = await self._resolve_tool_exec(llm_resp) tool_call_result_blocks = [] + cached_images = [] # Collect cached images for LLM visibility async for result in self._handle_function_tools(self.req, llm_resp): - if isinstance(result, list): - tool_call_result_blocks = result - elif isinstance(result, MessageChain): - if result.type is None: + if result.kind == "tool_call_result_blocks": + if result.tool_call_result_blocks is not None: + tool_call_result_blocks = result.tool_call_result_blocks + elif result.kind == "cached_image": + if result.cached_image is not None: + # Collect cached image info + cached_images.append(result.cached_image) + elif result.kind == "message_chain": + chain = result.message_chain + if chain is None or chain.type is None: # should not happen continue - if result.type == "tool_direct_result": + if chain.type == "tool_direct_result": ar_type = "tool_call_result" else: - ar_type = result.type + ar_type = chain.type yield AgentResponse( type=ar_type, - data=AgentResponseData(chain=result), + data=AgentResponseData(chain=chain), ) # 将结果添加到上下文中 @@ -309,6 +557,8 @@ async def step(self): ) if llm_resp.completion_text: parts.append(TextPart(text=llm_resp.completion_text)) + if len(parts) == 0: + parts = None tool_calls_result = ToolCallsResult( tool_calls_info=AssistantMessageSegment( tool_calls=llm_resp.to_openai_to_calls_model(), @@ -321,6 +571,41 @@ async def step(self): tool_calls_result.to_openai_messages_model() ) + # If there are cached images and the model supports image input, + # append a user message with images so LLM can see them + if cached_images: + modalities = self.provider.provider_config.get("modalities", []) + supports_image = "image" in modalities + if supports_image: + # Build user message with images for LLM to review + image_parts = [] + for cached_img in cached_images: + img_data = tool_image_cache.get_image_base64_by_path( + cached_img.file_path, cached_img.mime_type + ) + if img_data: + base64_data, mime_type = img_data + image_parts.append( + TextPart( + text=f"[Image from tool '{cached_img.tool_name}', path='{cached_img.file_path}']" + ) + ) + image_parts.append( + ImageURLPart( + image_url=ImageURLPart.ImageURL( + url=f"data:{mime_type};base64,{base64_data}", + id=cached_img.file_path, + ) + ) + ) + if image_parts: + self.run_context.messages.append( + Message(role="user", content=image_parts) + ) + logger.debug( + f"Appended {len(cached_images)} cached image(s) to context for LLM review" + ) + self.req.append_tool_calls_result(tool_calls_result) async def step_until_done( @@ -356,29 +641,40 @@ async def _handle_function_tools( self, req: ProviderRequest, llm_response: LLMResponse, - ) -> T.AsyncGenerator[MessageChain | list[ToolCallMessageSegment], None]: + ) -> T.AsyncGenerator[_HandleFunctionToolsResult, None]: """处理函数工具调用。""" tool_call_result_blocks: list[ToolCallMessageSegment] = [] logger.info(f"Agent 使用工具: {llm_response.tools_call_name}") + def _append_tool_call_result(tool_call_id: str, content: str) -> None: + tool_call_result_blocks.append( + ToolCallMessageSegment( + role="tool", + tool_call_id=tool_call_id, + content=self._merge_follow_up_notice(content), + ), + ) + # 执行函数调用 for func_tool_name, func_tool_args, func_tool_id in zip( llm_response.tools_call_name, llm_response.tools_call_args, llm_response.tools_call_ids, ): - yield MessageChain( - type="tool_call", - chain=[ - Json( - data={ - "id": func_tool_id, - "name": func_tool_name, - "args": func_tool_args, - "ts": time.time(), - } - ) - ], + yield _HandleFunctionToolsResult.from_message_chain( + MessageChain( + type="tool_call", + chain=[ + Json( + data={ + "id": func_tool_id, + "name": func_tool_name, + "args": func_tool_args, + "ts": time.time(), + } + ) + ], + ) ) try: if not req.func_tool: @@ -398,12 +694,9 @@ async def _handle_function_tools( if not func_tool: logger.warning(f"未找到指定的工具: {func_tool_name},将跳过。") - tool_call_result_blocks.append( - ToolCallMessageSegment( - role="tool", - tool_call_id=func_tool_id, - content=f"error: Tool {func_tool_name} not found.", - ), + _append_tool_call_result( + func_tool_id, + f"error: Tool {func_tool_name} not found.", ) continue @@ -456,56 +749,67 @@ async def _handle_function_tools( res = resp _final_resp = resp if isinstance(res.content[0], TextContent): - tool_call_result_blocks.append( - ToolCallMessageSegment( - role="tool", - tool_call_id=func_tool_id, - content=res.content[0].text, - ), + _append_tool_call_result( + func_tool_id, + res.content[0].text, ) elif isinstance(res.content[0], ImageContent): - tool_call_result_blocks.append( - ToolCallMessageSegment( - role="tool", - tool_call_id=func_tool_id, - content="The tool has successfully returned an image and sent directly to the user. You can describe it in your next response.", + # Cache the image instead of sending directly + cached_img = tool_image_cache.save_image( + base64_data=res.content[0].data, + tool_call_id=func_tool_id, + tool_name=func_tool_name, + index=0, + mime_type=res.content[0].mimeType or "image/png", + ) + _append_tool_call_result( + func_tool_id, + ( + f"Image returned and cached at path='{cached_img.file_path}'. " + f"Review the image below. Use send_message_to_user to send it to the user if satisfied, " + f"with type='image' and path='{cached_img.file_path}'." ), ) - yield MessageChain(type="tool_direct_result").base64_image( - res.content[0].data, + # Yield image info for LLM visibility (will be handled in step()) + yield _HandleFunctionToolsResult.from_cached_image( + cached_img ) elif isinstance(res.content[0], EmbeddedResource): resource = res.content[0].resource if isinstance(resource, TextResourceContents): - tool_call_result_blocks.append( - ToolCallMessageSegment( - role="tool", - tool_call_id=func_tool_id, - content=resource.text, - ), + _append_tool_call_result( + func_tool_id, + resource.text, ) elif ( isinstance(resource, BlobResourceContents) and resource.mimeType and resource.mimeType.startswith("image/") ): - tool_call_result_blocks.append( - ToolCallMessageSegment( - role="tool", - tool_call_id=func_tool_id, - content="The tool has successfully returned an image and sent directly to the user. You can describe it in your next response.", + # Cache the image instead of sending directly + cached_img = tool_image_cache.save_image( + base64_data=resource.blob, + tool_call_id=func_tool_id, + tool_name=func_tool_name, + index=0, + mime_type=resource.mimeType, + ) + _append_tool_call_result( + func_tool_id, + ( + f"Image returned and cached at path='{cached_img.file_path}'. " + f"Review the image below. Use send_message_to_user to send it to the user if satisfied, " + f"with type='image' and path='{cached_img.file_path}'." ), ) - yield MessageChain( - type="tool_direct_result", - ).base64_image(resource.blob) + # Yield image info for LLM visibility + yield _HandleFunctionToolsResult.from_cached_image( + cached_img + ) else: - tool_call_result_blocks.append( - ToolCallMessageSegment( - role="tool", - tool_call_id=func_tool_id, - content="The tool has returned a data type that is not supported.", - ), + _append_tool_call_result( + func_tool_id, + "The tool has returned a data type that is not supported.", ) elif resp is None: @@ -517,24 +821,18 @@ async def _handle_function_tools( ) self._transition_state(AgentState.DONE) self.stats.end_time = time.time() - tool_call_result_blocks.append( - ToolCallMessageSegment( - role="tool", - tool_call_id=func_tool_id, - content="The tool has no return value, or has sent the result directly to the user.", - ), + _append_tool_call_result( + func_tool_id, + "The tool has no return value, or has sent the result directly to the user.", ) else: # 不应该出现其他类型 logger.warning( f"Tool 返回了不支持的类型: {type(resp)}。", ) - tool_call_result_blocks.append( - ToolCallMessageSegment( - role="tool", - tool_call_id=func_tool_id, - content="*The tool has returned an unsupported type. Please tell the user to check the definition and implementation of this tool.*", - ), + _append_tool_call_result( + func_tool_id, + "*The tool has returned an unsupported type. Please tell the user to check the definition and implementation of this tool.*", ) try: @@ -548,34 +846,35 @@ async def _handle_function_tools( logger.error(f"Error in on_tool_end hook: {e}", exc_info=True) except Exception as e: logger.warning(traceback.format_exc()) - tool_call_result_blocks.append( - ToolCallMessageSegment( - role="tool", - tool_call_id=func_tool_id, - content=f"error: {e!s}", - ), + _append_tool_call_result( + func_tool_id, + f"error: {e!s}", ) # yield the last tool call result if tool_call_result_blocks: last_tcr_content = str(tool_call_result_blocks[-1].content) - yield MessageChain( - type="tool_call_result", - chain=[ - Json( - data={ - "id": func_tool_id, - "ts": time.time(), - "result": last_tcr_content, - } - ) - ], + yield _HandleFunctionToolsResult.from_message_chain( + MessageChain( + type="tool_call_result", + chain=[ + Json( + data={ + "id": func_tool_id, + "ts": time.time(), + "result": last_tcr_content, + } + ) + ], + ) ) logger.info(f"Tool `{func_tool_name}` Result: {last_tcr_content}") # 处理函数调用响应 if tool_call_result_blocks: - yield tool_call_result_blocks + yield _HandleFunctionToolsResult.from_tool_call_result_blocks( + tool_call_result_blocks + ) def _build_tool_requery_context( self, tool_names: list[str] @@ -646,5 +945,11 @@ def done(self) -> bool: """检查 Agent 是否已完成工作""" return self._state in (AgentState.DONE, AgentState.ERROR) + def request_stop(self) -> None: + self._stop_requested = True + + def was_aborted(self) -> bool: + return self._aborted + def get_final_llm_resp(self) -> LLMResponse | None: return self.final_llm_resp diff --git a/astrbot/core/agent/tool.py b/astrbot/core/agent/tool.py index 50899ff80..c2536708e 100644 --- a/astrbot/core/agent/tool.py +++ b/astrbot/core/agent/tool.py @@ -64,7 +64,7 @@ class FunctionTool(ToolSchema, Generic[TContext]): with a task identifier while the real work continues asynchronously. """ - def __repr__(self): + def __repr__(self) -> str: return f"FuncTool(name={self.name}, parameters={self.parameters}, description={self.description})" async def call(self, context: ContextWrapper[TContext], **kwargs) -> ToolExecResult: @@ -88,7 +88,7 @@ def empty(self) -> bool: """Check if the tool set is empty.""" return len(self.tools) == 0 - def add_tool(self, tool: FunctionTool): + def add_tool(self, tool: FunctionTool) -> None: """Add a tool to the set.""" # 检查是否已存在同名工具 for i, existing_tool in enumerate(self.tools): @@ -97,7 +97,7 @@ def add_tool(self, tool: FunctionTool): return self.tools.append(tool) - def remove_tool(self, name: str): + def remove_tool(self, name: str) -> None: """Remove a tool by its name.""" self.tools = [tool for tool in self.tools if tool.name != name] @@ -156,7 +156,7 @@ def add_func( func_args: list, desc: str, handler: Callable[..., Awaitable[Any]], - ): + ) -> None: """Add a function tool to the set.""" params = { "type": "object", # hard-coded here @@ -176,7 +176,7 @@ def add_func( self.add_tool(_func) @deprecated(reason="Use remove_tool() instead", version="4.0.0") - def remove_func(self, name: str): + def remove_func(self, name: str) -> None: """Remove a function tool by its name.""" self.remove_tool(name) @@ -285,6 +285,9 @@ def convert_schema(schema: dict) -> dict: prop_value = convert_schema(value) if "default" in prop_value: del prop_value["default"] + # see #5217 + if "additionalProperties" in prop_value: + del prop_value["additionalProperties"] properties[key] = prop_value if properties: @@ -325,22 +328,22 @@ def names(self) -> list[str]: """获取所有工具的名称列表""" return [tool.name for tool in self.tools] - def merge(self, other: "ToolSet"): + def merge(self, other: "ToolSet") -> None: """Merge another ToolSet into this one.""" for tool in other.tools: self.add_tool(tool) - def __len__(self): + def __len__(self) -> int: return len(self.tools) - def __bool__(self): + def __bool__(self) -> bool: return len(self.tools) > 0 def __iter__(self): return iter(self.tools) - def __repr__(self): + def __repr__(self) -> str: return f"ToolSet(tools={self.tools})" - def __str__(self): + def __str__(self) -> str: return f"ToolSet(tools={self.tools})" diff --git a/astrbot/core/agent/tool_image_cache.py b/astrbot/core/agent/tool_image_cache.py new file mode 100644 index 000000000..72e22dd52 --- /dev/null +++ b/astrbot/core/agent/tool_image_cache.py @@ -0,0 +1,162 @@ +"""Tool image cache module for storing and retrieving images returned by tools. + +This module allows LLM to review images before deciding whether to send them to users. +""" + +import base64 +import os +import time +from dataclasses import dataclass, field +from typing import ClassVar + +from astrbot import logger +from astrbot.core.utils.astrbot_path import get_astrbot_temp_path + + +@dataclass +class CachedImage: + """Represents a cached image from a tool call.""" + + tool_call_id: str + """The tool call ID that produced this image.""" + tool_name: str + """The name of the tool that produced this image.""" + file_path: str + """The file path where the image is stored.""" + mime_type: str + """The MIME type of the image.""" + created_at: float = field(default_factory=time.time) + """Timestamp when the image was cached.""" + + +class ToolImageCache: + """Manages cached images from tool calls. + + Images are stored in data/temp/tool_images/ and can be retrieved by file path. + """ + + _instance: ClassVar["ToolImageCache | None"] = None + CACHE_DIR_NAME: ClassVar[str] = "tool_images" + # Cache expiry time in seconds (1 hour) + CACHE_EXPIRY: ClassVar[int] = 3600 + + def __new__(cls) -> "ToolImageCache": + if cls._instance is None: + cls._instance = super().__new__(cls) + cls._instance._initialized = False + return cls._instance + + def __init__(self) -> None: + if self._initialized: + return + self._initialized = True + self._cache_dir = os.path.join(get_astrbot_temp_path(), self.CACHE_DIR_NAME) + os.makedirs(self._cache_dir, exist_ok=True) + logger.debug(f"ToolImageCache initialized, cache dir: {self._cache_dir}") + + def _get_file_extension(self, mime_type: str) -> str: + """Get file extension from MIME type.""" + mime_to_ext = { + "image/png": ".png", + "image/jpeg": ".jpg", + "image/jpg": ".jpg", + "image/gif": ".gif", + "image/webp": ".webp", + "image/bmp": ".bmp", + "image/svg+xml": ".svg", + } + return mime_to_ext.get(mime_type.lower(), ".png") + + def save_image( + self, + base64_data: str, + tool_call_id: str, + tool_name: str, + index: int = 0, + mime_type: str = "image/png", + ) -> CachedImage: + """Save an image to cache and return the cached image info. + + Args: + base64_data: Base64 encoded image data. + tool_call_id: The tool call ID that produced this image. + tool_name: The name of the tool that produced this image. + index: The index of the image (for multiple images from same tool call). + mime_type: The MIME type of the image. + + Returns: + CachedImage object with file path. + """ + ext = self._get_file_extension(mime_type) + file_name = f"{tool_call_id}_{index}{ext}" + file_path = os.path.join(self._cache_dir, file_name) + + # Decode and save the image + try: + image_bytes = base64.b64decode(base64_data) + with open(file_path, "wb") as f: + f.write(image_bytes) + logger.debug(f"Saved tool image to: {file_path}") + except Exception as e: + logger.error(f"Failed to save tool image: {e}") + raise + + return CachedImage( + tool_call_id=tool_call_id, + tool_name=tool_name, + file_path=file_path, + mime_type=mime_type, + ) + + def get_image_base64_by_path( + self, file_path: str, mime_type: str = "image/png" + ) -> tuple[str, str] | None: + """Read an image file and return its base64 encoded data. + + Args: + file_path: The file path of the cached image. + mime_type: The MIME type of the image. + + Returns: + Tuple of (base64_data, mime_type) if found, None otherwise. + """ + if not os.path.exists(file_path): + return None + + try: + with open(file_path, "rb") as f: + image_bytes = f.read() + base64_data = base64.b64encode(image_bytes).decode("utf-8") + return base64_data, mime_type + except Exception as e: + logger.error(f"Failed to read cached image {file_path}: {e}") + return None + + def cleanup_expired(self) -> int: + """Clean up expired cached images. + + Returns: + Number of images cleaned up. + """ + now = time.time() + cleaned = 0 + + try: + for file_name in os.listdir(self._cache_dir): + file_path = os.path.join(self._cache_dir, file_name) + if os.path.isfile(file_path): + file_age = now - os.path.getmtime(file_path) + if file_age > self.CACHE_EXPIRY: + os.remove(file_path) + cleaned += 1 + except Exception as e: + logger.warning(f"Error during cache cleanup: {e}") + + if cleaned: + logger.info(f"Cleaned up {cleaned} expired cached images") + + return cleaned + + +# Global singleton instance +tool_image_cache = ToolImageCache() diff --git a/astrbot/core/astr_agent_hooks.py b/astrbot/core/astr_agent_hooks.py index 717d4a3e1..09bf32deb 100644 --- a/astrbot/core/astr_agent_hooks.py +++ b/astrbot/core/astr_agent_hooks.py @@ -12,7 +12,7 @@ class MainAgentHooks(BaseAgentRunHooks[AstrAgentContext]): - async def on_agent_done(self, run_context, llm_response): + async def on_agent_done(self, run_context, llm_response) -> None: # 执行事件钩子 if llm_response and llm_response.reasoning_content: # we will use this in result_decorate stage to inject reasoning content to chain @@ -31,7 +31,7 @@ async def on_tool_start( run_context: ContextWrapper[AstrAgentContext], tool: FunctionTool[Any], tool_args: dict | None, - ): + ) -> None: await call_event_hook( run_context.context.event, EventType.OnUsingLLMToolEvent, @@ -45,7 +45,7 @@ async def on_tool_end( tool: FunctionTool[Any], tool_args: dict | None, tool_result: CallToolResult | None, - ): + ) -> None: run_context.context.event.clear_result() await call_event_hook( run_context.context.event, @@ -59,7 +59,7 @@ async def on_tool_end( platform_name = run_context.context.event.get_platform_name() if ( platform_name == "webchat" - and tool.name == "web_search_tavily" + and tool.name in ["web_search_tavily", "web_search_bocha"] and len(run_context.messages) > 0 and tool_result and len(tool_result.content) diff --git a/astrbot/core/astr_agent_run_util.py b/astrbot/core/astr_agent_run_util.py index 5556aa6b4..017f2cea2 100644 --- a/astrbot/core/astr_agent_run_util.py +++ b/astrbot/core/astr_agent_run_util.py @@ -20,15 +20,81 @@ AgentRunner = ToolLoopAgentRunner[AstrAgentContext] +def _should_stop_agent(astr_event) -> bool: + return astr_event.is_stopped() or bool(astr_event.get_extra("agent_stop_requested")) + + +def _truncate_tool_result(text: str, limit: int = 70) -> str: + if limit <= 0: + return "" + if len(text) <= limit: + return text + if limit <= 3: + return text[:limit] + return f"{text[: limit - 3]}..." + + +def _extract_chain_json_data(msg_chain: MessageChain) -> dict | None: + if not msg_chain.chain: + return None + first_comp = msg_chain.chain[0] + if isinstance(first_comp, Json) and isinstance(first_comp.data, dict): + return first_comp.data + return None + + +def _record_tool_call_name( + tool_info: dict | None, tool_name_by_call_id: dict[str, str] +) -> None: + if not isinstance(tool_info, dict): + return + tool_call_id = tool_info.get("id") + tool_name = tool_info.get("name") + if tool_call_id is None or tool_name is None: + return + tool_name_by_call_id[str(tool_call_id)] = str(tool_name) + + +def _build_tool_call_status_message(tool_info: dict | None) -> str: + if tool_info: + return f"🔨 调用工具: {tool_info.get('name', 'unknown')}" + return "🔨 调用工具..." + + +def _build_tool_result_status_message( + msg_chain: MessageChain, tool_name_by_call_id: dict[str, str] +) -> str: + tool_name = "unknown" + tool_result = "" + + result_data = _extract_chain_json_data(msg_chain) + if result_data: + tool_call_id = result_data.get("id") + if tool_call_id is not None: + tool_name = tool_name_by_call_id.pop(str(tool_call_id), "unknown") + tool_result = str(result_data.get("result", "")) + + if not tool_result: + tool_result = msg_chain.get_plain_text(with_other_comps_mark=True) + tool_result = _truncate_tool_result(tool_result, 70) + + status_msg = f"🔨 调用工具: {tool_name}" + if tool_result: + status_msg = f"{status_msg}\n📎 返回结果: {tool_result}" + return status_msg + + async def run_agent( agent_runner: AgentRunner, max_step: int = 30, show_tool_use: bool = True, + show_tool_call_result: bool = False, stream_to_general: bool = False, show_reasoning: bool = False, ) -> AsyncGenerator[MessageChain | None, None]: step_idx = 0 astr_event = agent_runner.run_context.context.event + tool_name_by_call_id: dict[str, str] = {} while step_idx < max_step + 1: step_idx += 1 @@ -48,10 +114,28 @@ async def run_agent( ) ) + stop_watcher = asyncio.create_task( + _watch_agent_stop_signal(agent_runner, astr_event), + ) try: async for resp in agent_runner.step(): - if astr_event.is_stopped(): + if _should_stop_agent(astr_event): + agent_runner.request_stop() + + if resp.type == "aborted": + if not stop_watcher.done(): + stop_watcher.cancel() + try: + await stop_watcher + except asyncio.CancelledError: + pass + astr_event.set_extra("agent_user_aborted", True) + astr_event.set_extra("agent_stop_requested", False) return + + if _should_stop_agent(astr_event): + continue + if resp.type == "tool_call_result": msg_chain = resp.data["chain"] @@ -68,6 +152,13 @@ async def run_agent( continue if astr_event.get_platform_id() == "webchat": await astr_event.send(msg_chain) + elif show_tool_use and show_tool_call_result: + status_msg = _build_tool_result_status_message( + msg_chain, tool_name_by_call_id + ) + await astr_event.send( + MessageChain(type="tool_call").message(status_msg) + ) # 对于其他情况,暂时先不处理 continue elif resp.type == "tool_call": @@ -75,25 +166,22 @@ async def run_agent( # 用来标记流式响应需要分节 yield MessageChain(chain=[], type="break") - tool_info = None - - if resp.data["chain"].chain: - json_comp = resp.data["chain"].chain[0] - if isinstance(json_comp, Json): - tool_info = json_comp.data - astr_event.trace.record( - "agent_tool_call", - tool_name=tool_info if tool_info else "unknown", - ) + tool_info = _extract_chain_json_data(resp.data["chain"]) + astr_event.trace.record( + "agent_tool_call", + tool_name=tool_info if tool_info else "unknown", + ) + _record_tool_call_name(tool_info, tool_name_by_call_id) if astr_event.get_platform_name() == "webchat": await astr_event.send(resp.data["chain"]) elif show_tool_use: - if tool_info: - m = f"🔨 调用工具: {tool_info.get('name', 'unknown')}" - else: - m = "🔨 调用工具..." - chain = MessageChain(type="tool_call").message(m) + if show_tool_call_result and isinstance(tool_info, dict): + # Delay tool status notification until tool_call_result. + continue + chain = MessageChain(type="tool_call").message( + _build_tool_call_status_message(tool_info) + ) await astr_event.send(chain) continue @@ -120,6 +208,12 @@ async def run_agent( # display the reasoning content only when configured continue yield resp.data["chain"] # MessageChain + if not stop_watcher.done(): + stop_watcher.cancel() + try: + await stop_watcher + except asyncio.CancelledError: + pass if agent_runner.done(): # send agent stats to webchat if astr_event.get_platform_name() == "webchat": @@ -133,6 +227,12 @@ async def run_agent( break except Exception as e: + if "stop_watcher" in locals() and not stop_watcher.done(): + stop_watcher.cancel() + try: + await stop_watcher + except asyncio.CancelledError: + pass logger.error(traceback.format_exc()) err_msg = f"\n\nAstrBot 请求失败。\n错误类型: {type(e).__name__}\n错误信息: {e!s}\n\n请在平台日志查看和分享错误详情。\n" @@ -155,11 +255,20 @@ async def run_agent( return +async def _watch_agent_stop_signal(agent_runner: AgentRunner, astr_event) -> None: + while not agent_runner.done(): + if _should_stop_agent(astr_event): + agent_runner.request_stop() + return + await asyncio.sleep(0.5) + + async def run_live_agent( agent_runner: AgentRunner, tts_provider: TTSProvider | None = None, max_step: int = 30, show_tool_use: bool = True, + show_tool_call_result: bool = False, show_reasoning: bool = False, ) -> AsyncGenerator[MessageChain | None, None]: """Live Mode 的 Agent 运行器,支持流式 TTS @@ -169,6 +278,7 @@ async def run_live_agent( tts_provider: TTS Provider 实例 max_step: 最大步数 show_tool_use: 是否显示工具使用 + show_tool_call_result: 是否显示工具返回结果 show_reasoning: 是否显示推理过程 Yields: @@ -180,6 +290,7 @@ async def run_live_agent( agent_runner, max_step=max_step, show_tool_use=show_tool_use, + show_tool_call_result=show_tool_call_result, stream_to_general=False, show_reasoning=show_reasoning, ): @@ -208,7 +319,12 @@ async def run_live_agent( # 1. 启动 Agent Feeder 任务:负责运行 Agent 并将文本分句喂给 text_queue feeder_task = asyncio.create_task( _run_agent_feeder( - agent_runner, text_queue, max_step, show_tool_use, show_reasoning + agent_runner, + text_queue, + max_step, + show_tool_use, + show_tool_call_result, + show_reasoning, ) ) @@ -294,8 +410,9 @@ async def _run_agent_feeder( text_queue: asyncio.Queue, max_step: int, show_tool_use: bool, + show_tool_call_result: bool, show_reasoning: bool, -): +) -> None: """运行 Agent 并将文本输出分句放入队列""" buffer = "" try: @@ -303,6 +420,7 @@ async def _run_agent_feeder( agent_runner, max_step=max_step, show_tool_use=show_tool_use, + show_tool_call_result=show_tool_call_result, stream_to_general=False, show_reasoning=show_reasoning, ): @@ -352,7 +470,7 @@ async def _safe_tts_stream_wrapper( tts_provider: TTSProvider, text_queue: asyncio.Queue[str | None], audio_queue: "asyncio.Queue[bytes | tuple[str, bytes] | None]", -): +) -> None: """包装原生流式 TTS 确保异常处理和队列关闭""" try: await tts_provider.get_audio_stream(text_queue, audio_queue) @@ -366,7 +484,7 @@ async def _simulated_stream_tts( tts_provider: TTSProvider, text_queue: asyncio.Queue[str | None], audio_queue: "asyncio.Queue[bytes | tuple[str, bytes] | None]", -): +) -> None: """模拟流式 TTS 分句生成音频""" try: while True: diff --git a/astrbot/core/astr_agent_tool_exec.py b/astrbot/core/astr_agent_tool_exec.py index 460cab332..46ec4346b 100644 --- a/astrbot/core/astr_agent_tool_exec.py +++ b/astrbot/core/astr_agent_tool_exec.py @@ -17,6 +17,12 @@ from astrbot.core.astr_agent_context import AstrAgentContext from astrbot.core.astr_main_agent_resources import ( BACKGROUND_TASK_RESULT_WOKE_SYSTEM_PROMPT, + EXECUTE_SHELL_TOOL, + FILE_DOWNLOAD_TOOL, + FILE_UPLOAD_TOOL, + LOCAL_EXECUTE_SHELL_TOOL, + LOCAL_PYTHON_TOOL, + PYTHON_TOOL, SEND_MESSAGE_TO_USER_TOOL, ) from astrbot.core.cron.events import CronMessageEvent @@ -45,6 +51,13 @@ async def execute(cls, tool, run_context, **tool_args): """ if isinstance(tool, HandoffTool): + is_bg = tool_args.pop("background_task", False) + if is_bg: + async for r in cls._execute_handoff_background( + tool, run_context, **tool_args + ): + yield r + return async for r in cls._execute_handoff(tool, run_context, **tool_args): yield r return @@ -57,7 +70,7 @@ async def execute(cls, tool, run_context, **tool_args): elif tool.is_background_task: task_id = uuid.uuid4().hex - async def _run_in_background(): + async def _run_in_background() -> None: try: await cls._execute_background( tool=tool, @@ -84,6 +97,65 @@ async def _run_in_background(): yield r return + @classmethod + def _get_runtime_computer_tools(cls, runtime: str) -> dict[str, FunctionTool]: + if runtime == "sandbox": + return { + EXECUTE_SHELL_TOOL.name: EXECUTE_SHELL_TOOL, + PYTHON_TOOL.name: PYTHON_TOOL, + FILE_UPLOAD_TOOL.name: FILE_UPLOAD_TOOL, + FILE_DOWNLOAD_TOOL.name: FILE_DOWNLOAD_TOOL, + } + if runtime == "local": + return { + LOCAL_EXECUTE_SHELL_TOOL.name: LOCAL_EXECUTE_SHELL_TOOL, + LOCAL_PYTHON_TOOL.name: LOCAL_PYTHON_TOOL, + } + return {} + + @classmethod + def _build_handoff_toolset( + cls, + run_context: ContextWrapper[AstrAgentContext], + tools: list[str | FunctionTool] | None, + ) -> ToolSet | None: + ctx = run_context.context.context + event = run_context.context.event + cfg = ctx.get_config(umo=event.unified_msg_origin) + provider_settings = cfg.get("provider_settings", {}) + runtime = str(provider_settings.get("computer_use_runtime", "local")) + runtime_computer_tools = cls._get_runtime_computer_tools(runtime) + + # Keep persona semantics aligned with the main agent: tools=None means + # "all tools", including runtime computer-use tools. + if tools is None: + toolset = ToolSet() + for registered_tool in llm_tools.func_list: + if isinstance(registered_tool, HandoffTool): + continue + if registered_tool.active: + toolset.add_tool(registered_tool) + for runtime_tool in runtime_computer_tools.values(): + toolset.add_tool(runtime_tool) + return None if toolset.empty() else toolset + + if not tools: + return None + + toolset = ToolSet() + for tool_name_or_obj in tools: + if isinstance(tool_name_or_obj, str): + registered_tool = llm_tools.get_func(tool_name_or_obj) + if registered_tool and registered_tool.active: + toolset.add_tool(registered_tool) + continue + runtime_tool = runtime_computer_tools.get(tool_name_or_obj) + if runtime_tool: + toolset.add_tool(runtime_tool) + elif isinstance(tool_name_or_obj, FunctionTool): + toolset.add_tool(tool_name_or_obj) + return None if toolset.empty() else toolset + @classmethod async def _execute_handoff( cls, @@ -92,20 +164,10 @@ async def _execute_handoff( **tool_args, ): input_ = tool_args.get("input") + image_urls = tool_args.get("image_urls") - # make toolset for the agent - tools = tool.agent.tools - if tools: - toolset = ToolSet() - for t in tools: - if isinstance(t, str): - _t = llm_tools.get_func(t) - if _t: - toolset.add_tool(_t) - elif isinstance(t, FunctionTool): - toolset.add_tool(t) - else: - toolset = None + # Build handoff toolset from registered tools plus runtime computer tools. + toolset = cls._build_handoff_toolset(run_context, tool.agent.tools) ctx = run_context.context.context event = run_context.context.event @@ -136,30 +198,106 @@ async def _execute_handoff( event=event, chat_provider_id=prov_id, prompt=input_, + image_urls=image_urls, system_prompt=tool.agent.instructions, tools=toolset, contexts=contexts, max_steps=30, run_hooks=tool.agent.run_hooks, + stream=ctx.get_config().get("provider_settings", {}).get("stream", False), ) yield mcp.types.CallToolResult( content=[mcp.types.TextContent(type="text", text=llm_resp.completion_text)] ) @classmethod - async def _execute_background( + async def _execute_handoff_background( cls, - tool: FunctionTool, + tool: HandoffTool, run_context: ContextWrapper[AstrAgentContext], - task_id: str, **tool_args, ): - from astrbot.core.astr_main_agent import ( - MainAgentBuildConfig, - _get_session_conv, - build_main_agent, + """Execute a handoff as a background task. + + Immediately yields a success response with a task_id, then runs + the subagent asynchronously. When the subagent finishes, a + ``CronMessageEvent`` is created so the main LLM can inform the + user of the result – the same pattern used by + ``_execute_background`` for regular background tasks. + """ + task_id = uuid.uuid4().hex + + async def _run_handoff_in_background() -> None: + try: + await cls._do_handoff_background( + tool=tool, + run_context=run_context, + task_id=task_id, + **tool_args, + ) + except Exception as e: # noqa: BLE001 + logger.error( + f"Background handoff {task_id} ({tool.name}) failed: {e!s}", + exc_info=True, + ) + + asyncio.create_task(_run_handoff_in_background()) + + text_content = mcp.types.TextContent( + type="text", + text=( + f"Background task dedicated to subagent '{tool.agent.name}' submitted. task_id={task_id}. " + f"The subagent '{tool.agent.name}' is working on the task on hehalf you. " + f"You will be notified when it finishes." + ), ) + yield mcp.types.CallToolResult(content=[text_content]) + @classmethod + async def _do_handoff_background( + cls, + tool: HandoffTool, + run_context: ContextWrapper[AstrAgentContext], + task_id: str, + **tool_args, + ) -> None: + """Run the subagent handoff and, on completion, wake the main agent.""" + result_text = "" + try: + async for r in cls._execute_handoff(tool, run_context, **tool_args): + if isinstance(r, mcp.types.CallToolResult): + for content in r.content: + if isinstance(content, mcp.types.TextContent): + result_text += content.text + "\n" + except Exception as e: + result_text = ( + f"error: Background task execution failed, internal error: {e!s}" + ) + + event = run_context.context.event + + await cls._wake_main_agent_for_background_result( + run_context=run_context, + task_id=task_id, + tool_name=tool.name, + result_text=result_text, + tool_args=tool_args, + note=( + event.get_extra("background_note") + or f"Background task for subagent '{tool.agent.name}' finished." + ), + summary_name=f"Dedicated to subagent `{tool.agent.name}`", + extra_result_fields={"subagent_name": tool.agent.name}, + ) + + @classmethod + async def _execute_background( + cls, + tool: FunctionTool, + run_context: ContextWrapper[AstrAgentContext], + task_id: str, + **tool_args, + ) -> None: # run the tool result_text = "" try: @@ -178,20 +316,52 @@ async def _execute_background( ) event = run_context.context.event - ctx = run_context.context.context - note = ( - event.get_extra("background_note") - or f"Background task {tool.name} finished." + await cls._wake_main_agent_for_background_result( + run_context=run_context, + task_id=task_id, + tool_name=tool.name, + result_text=result_text, + tool_args=tool_args, + note=( + event.get_extra("background_note") + or f"Background task {tool.name} finished." + ), + summary_name=tool.name, ) - extras = { - "background_task_result": { - "task_id": task_id, - "tool_name": tool.name, - "result": result_text or "", - "tool_args": tool_args, - } + + @classmethod + async def _wake_main_agent_for_background_result( + cls, + run_context: ContextWrapper[AstrAgentContext], + *, + task_id: str, + tool_name: str, + result_text: str, + tool_args: dict[str, T.Any], + note: str, + summary_name: str, + extra_result_fields: dict[str, T.Any] | None = None, + ) -> None: + from astrbot.core.astr_main_agent import ( + MainAgentBuildConfig, + _get_session_conv, + build_main_agent, + ) + + event = run_context.context.event + ctx = run_context.context.context + + task_result = { + "task_id": task_id, + "tool_name": tool_name, + "result": result_text or "", + "tool_args": tool_args, } + if extra_result_fields: + task_result.update(extra_result_fields) + extras = {"background_task_result": task_result} + session = MessageSession.from_str(event.unified_msg_origin) cron_event = CronMessageEvent( context=ctx, @@ -201,7 +371,12 @@ async def _execute_background( message_type=session.message_type, ) cron_event.role = event.role - config = MainAgentBuildConfig(tool_call_timeout=3600) + config = MainAgentBuildConfig( + tool_call_timeout=3600, + streaming_response=ctx.get_config() + .get("provider_settings", {}) + .get("stream", False), + ) req = ProviderRequest() conv = await _get_session_conv(event=cron_event, plugin_context=ctx) @@ -222,8 +397,11 @@ async def _execute_background( ) req.prompt = ( "Proceed according to your system instructions. " - "Output using same language as previous conversation." - " After completing your task, summarize and output your actions and results." + "Output using same language as previous conversation. " + "If you need to deliver the result to the user immediately, " + "you MUST use `send_message_to_user` tool to send the message directly to the user, " + "otherwise the user will not see the result. " + "After completing your task, summarize and output your actions and results. " ) if not req.func_tool: req.func_tool = ToolSet() @@ -233,7 +411,7 @@ async def _execute_background( event=cron_event, plugin_context=ctx, config=config, req=req ) if not result: - logger.error("Failed to build main agent for background task job.") + logger.error(f"Failed to build main agent for background task {tool_name}.") return runner = result.agent_runner @@ -243,7 +421,7 @@ async def _execute_background( llm_resp = runner.get_final_llm_resp() task_meta = extras.get("background_task_result", {}) summary_note = ( - f"[BackgroundTask] {task_meta.get('tool_name', tool.name)} " + f"[BackgroundTask] {summary_name} " f"(task_id={task_meta.get('task_id', task_id)}) finished. " f"Result: {task_meta.get('result') or result_text or 'no content'}" ) diff --git a/astrbot/core/astr_main_agent.py b/astrbot/core/astr_main_agent.py index 690a6404c..6c1242f61 100644 --- a/astrbot/core/astr_main_agent.py +++ b/astrbot/core/astr_main_agent.py @@ -1,7 +1,6 @@ from __future__ import annotations import asyncio -import builtins import copy import datetime import json @@ -10,7 +9,6 @@ from collections.abc import Coroutine from dataclasses import dataclass, field -from astrbot.api import sp from astrbot.core import logger from astrbot.core.agent.handoff import HandoffTool from astrbot.core.agent.mcp_client import MCPTool @@ -52,6 +50,17 @@ ) from astrbot.core.utils.file_extract import extract_file_moonshotai from astrbot.core.utils.llm_metadata import LLM_METADATAS +from astrbot.core.utils.quoted_message.settings import ( + SETTINGS as DEFAULT_QUOTED_MESSAGE_SETTINGS, +) +from astrbot.core.utils.quoted_message.settings import ( + QuotedMessageParserSettings, +) +from astrbot.core.utils.quoted_message_parser import ( + extract_quoted_message_images, + extract_quoted_message_text, +) +from astrbot.core.utils.string_utils import normalize_and_dedupe_strings @dataclass(slots=True) @@ -108,6 +117,8 @@ class MainAgentBuildConfig: provider_settings: dict = field(default_factory=dict) subagent_orchestrator: dict = field(default_factory=dict) timezone: str | None = None + max_quoted_fallback_images: int = 20 + """Maximum number of images injected from quoted-message fallback extraction.""" @dataclass(slots=True) @@ -262,47 +273,26 @@ async def _ensure_persona_and_skills( if not req.conversation: return - # get persona ID - - # 1. from session service config - highest priority - persona_id = ( - await sp.get_async( - scope="umo", - scope_id=event.unified_msg_origin, - key="session_service_config", - default={}, - ) - ).get("persona_id") - - if not persona_id: - # 2. from conversation setting - second priority - persona_id = req.conversation.persona_id - - if persona_id == "[%None]": - # explicitly set to no persona - pass - elif persona_id is None: - # 3. from config default persona setting - last priority - persona_id = cfg.get("default_personality") - - persona = next( - builtins.filter( - lambda persona: persona["name"] == persona_id, - plugin_context.persona_manager.personas_v3, - ), - None, + ( + persona_id, + persona, + _, + use_webchat_special_default, + ) = await plugin_context.persona_manager.resolve_selected_persona( + umo=event.unified_msg_origin, + conversation_persona_id=req.conversation.persona_id, + platform_name=event.get_platform_name(), + provider_settings=cfg, ) + if persona: # Inject persona system prompt if prompt := persona["prompt"]: req.system_prompt += f"\n# Persona Instructions\n\n{prompt}\n" if begin_dialogs := copy.deepcopy(persona.get("_begin_dialogs_processed")): req.contexts[:0] = begin_dialogs - else: - # special handling for webchat persona - if event.get_platform_name() == "webchat" and persona_id != "[%None]": - persona_id = "_chatui_default_" - req.system_prompt += CHATUI_SPECIAL_DEFAULT_PERSONA_PROMPT + elif use_webchat_special_default: + req.system_prompt += CHATUI_SPECIAL_DEFAULT_PERSONA_PROMPT # Inject skills prompt runtime = cfg.get("computer_use_runtime", "local") @@ -326,6 +316,24 @@ async def _ensure_persona_and_skills( ) tmgr = plugin_context.get_llm_tool_manager() + # inject toolset in the persona + if (persona and persona.get("tools") is None) or not persona: + persona_toolset = tmgr.get_full_tool_set() + for tool in list(persona_toolset): + if not tool.active: + persona_toolset.remove_tool(tool.name) + else: + persona_toolset = ToolSet() + if persona["tools"]: + for tool_name in persona["tools"]: + tool = tmgr.get_func(tool_name) + if tool and tool.active: + persona_toolset.add_tool(tool) + if not req.func_tool: + req.func_tool = persona_toolset + else: + req.func_tool.merge(persona_toolset) + # sub agents integration orch_cfg = plugin_context.get_config().get("subagent_orchestrator", {}) so = plugin_context.subagent_orchestrator @@ -371,22 +379,19 @@ async def _ensure_persona_and_skills( assigned_tools.add(name) if req.func_tool is None: - toolset = ToolSet() - else: - toolset = req.func_tool + req.func_tool = ToolSet() # add subagent handoff tools for tool in so.handoffs: - toolset.add_tool(tool) + req.func_tool.add_tool(tool) # check duplicates if remove_dup: - names = toolset.names() + handoff_names = {tool.name for tool in so.handoffs} for tool_name in assigned_tools: - if tool_name in names: - toolset.remove_tool(tool_name) - - req.func_tool = toolset + if tool_name in handoff_names: + continue + req.func_tool.remove_tool(tool_name) router_prompt = ( plugin_context.get_config() @@ -395,32 +400,14 @@ async def _ensure_persona_and_skills( ).strip() if router_prompt: req.system_prompt += f"\n{router_prompt}\n" - return - - # inject toolset in the persona - if (persona and persona.get("tools") is None) or not persona: - toolset = tmgr.get_full_tool_set() - for tool in list(toolset): - if not tool.active: - toolset.remove_tool(tool.name) - else: - toolset = ToolSet() - if persona["tools"]: - for tool_name in persona["tools"]: - tool = tmgr.get_func(tool_name) - if tool and tool.active: - toolset.add_tool(tool) - if not req.func_tool: - req.func_tool = toolset - else: - req.func_tool.merge(toolset) try: event.trace.record( - "sel_persona", persona_id=persona_id, persona_toolset=toolset.names() + "sel_persona", + persona_id=persona_id, + persona_toolset=persona_toolset.names(), ) except Exception: pass - logger.debug("Tool set for persona %s: %s", persona_id, toolset.names()) async def _request_img_caption( @@ -473,11 +460,29 @@ async def _ensure_img_caption( logger.error("处理图片描述失败: %s", exc) +def _append_quoted_image_attachment(req: ProviderRequest, image_path: str) -> None: + req.extra_user_content_parts.append( + TextPart(text=f"[Image Attachment in quoted message: path {image_path}]") + ) + + +def _get_quoted_message_parser_settings( + provider_settings: dict[str, object] | None, +) -> QuotedMessageParserSettings: + if not isinstance(provider_settings, dict): + return DEFAULT_QUOTED_MESSAGE_SETTINGS + overrides = provider_settings.get("quoted_message_parser") + if not isinstance(overrides, dict): + return DEFAULT_QUOTED_MESSAGE_SETTINGS + return DEFAULT_QUOTED_MESSAGE_SETTINGS.with_overrides(overrides) + + async def _process_quote_message( event: AstrMessageEvent, req: ProviderRequest, img_cap_prov_id: str, plugin_context: Context, + quoted_message_settings: QuotedMessageParserSettings = DEFAULT_QUOTED_MESSAGE_SETTINGS, ) -> None: quote = None for comp in event.message_obj.message: @@ -489,7 +494,15 @@ async def _process_quote_message( content_parts = [] sender_info = f"({quote.sender_nickname}): " if quote.sender_nickname else "" - message_str = quote.message_str or "[Empty Text]" + message_str = ( + await extract_quoted_message_text( + event, + quote, + settings=quoted_message_settings, + ) + or quote.message_str + or "[Empty Text]" + ) content_parts.append(f"{sender_info}{message_str}") image_seg = None @@ -595,11 +608,13 @@ async def _decorate_llm_request( ) img_cap_prov_id = cfg.get("default_image_caption_provider_id") or "" + quoted_message_settings = _get_quoted_message_parser_settings(cfg) await _process_quote_message( event, req, img_cap_prov_id, plugin_context, + quoted_message_settings, ) tz = config.timezone @@ -832,6 +847,41 @@ def _get_compress_provider( return provider +def _get_fallback_chat_providers( + provider: Provider, plugin_context: Context, provider_settings: dict +) -> list[Provider]: + fallback_ids = provider_settings.get("fallback_chat_models", []) + if not isinstance(fallback_ids, list): + logger.warning( + "fallback_chat_models setting is not a list, skip fallback providers." + ) + return [] + + provider_id = str(provider.provider_config.get("id", "")) + seen_provider_ids: set[str] = {provider_id} if provider_id else set() + fallbacks: list[Provider] = [] + + for fallback_id in fallback_ids: + if not isinstance(fallback_id, str) or not fallback_id: + continue + if fallback_id in seen_provider_ids: + continue + fallback_provider = plugin_context.get_provider_by_id(fallback_id) + if fallback_provider is None: + logger.warning("Fallback chat provider `%s` not found, skip.", fallback_id) + continue + if not isinstance(fallback_provider, Provider): + logger.warning( + "Fallback chat provider `%s` is invalid type: %s, skip.", + fallback_id, + type(fallback_provider), + ) + continue + fallbacks.append(fallback_provider) + seen_provider_ids.add(fallback_id) + return fallbacks + + async def build_main_agent( *, event: AstrMessageEvent, @@ -870,6 +920,8 @@ async def build_main_agent( return None req.prompt = event.message_str[len(config.provider_wake_prefix) :] + + # media files attachments for comp in event.message_obj.message: if isinstance(comp, Image): image_path = await comp.convert_to_file_path() @@ -885,6 +937,81 @@ async def build_main_agent( text=f"[File Attachment: name {file_name}, path {file_path}]" ) ) + # quoted message attachments + reply_comps = [ + comp for comp in event.message_obj.message if isinstance(comp, Reply) + ] + quoted_message_settings = _get_quoted_message_parser_settings( + config.provider_settings + ) + fallback_quoted_image_count = 0 + for comp in reply_comps: + has_embedded_image = False + if comp.chain: + for reply_comp in comp.chain: + if isinstance(reply_comp, Image): + has_embedded_image = True + image_path = await reply_comp.convert_to_file_path() + req.image_urls.append(image_path) + _append_quoted_image_attachment(req, image_path) + elif isinstance(reply_comp, File): + file_path = await reply_comp.get_file() + file_name = reply_comp.name or os.path.basename(file_path) + req.extra_user_content_parts.append( + TextPart( + text=( + f"[File Attachment in quoted message: " + f"name {file_name}, path {file_path}]" + ) + ) + ) + + # Fallback quoted image extraction for reply-id-only payloads, or when + # embedded reply chain only contains placeholders (e.g. [Forward Message], [Image]). + if not has_embedded_image: + try: + fallback_images = normalize_and_dedupe_strings( + await extract_quoted_message_images( + event, + comp, + settings=quoted_message_settings, + ) + ) + remaining_limit = max( + config.max_quoted_fallback_images + - fallback_quoted_image_count, + 0, + ) + if remaining_limit <= 0 and fallback_images: + logger.warning( + "Skip quoted fallback images due to limit=%d for umo=%s", + config.max_quoted_fallback_images, + event.unified_msg_origin, + ) + continue + if len(fallback_images) > remaining_limit: + logger.warning( + "Truncate quoted fallback images for umo=%s, reply_id=%s from %d to %d", + event.unified_msg_origin, + getattr(comp, "id", None), + len(fallback_images), + remaining_limit, + ) + fallback_images = fallback_images[:remaining_limit] + for image_ref in fallback_images: + if image_ref in req.image_urls: + continue + req.image_urls.append(image_ref) + fallback_quoted_image_count += 1 + _append_quoted_image_attachment(req, image_ref) + except Exception as exc: # noqa: BLE001 + logger.warning( + "Failed to resolve fallback quoted images for umo=%s, reply_id=%s: %s", + event.unified_msg_origin, + getattr(comp, "id", None), + exc, + exc_info=True, + ) conversation = await _get_session_conv(event, plugin_context) req.conversation = conversation @@ -893,6 +1020,7 @@ async def build_main_agent( if isinstance(req.contexts, str): req.contexts = json.loads(req.contexts) + req.image_urls = normalize_and_dedupe_strings(req.image_urls) if config.file_extract_enabled: try: @@ -977,6 +1105,9 @@ async def build_main_agent( truncate_turns=config.dequeue_context_length, enforce_max_turns=config.max_context_length, tool_schema_mode=config.tool_schema_mode, + fallback_providers=_get_fallback_chat_providers( + provider, plugin_context, config.provider_settings + ), ) if apply_reset: diff --git a/astrbot/core/astr_main_agent_resources.py b/astrbot/core/astr_main_agent_resources.py index 1d5c085ce..634647e7a 100644 --- a/astrbot/core/astr_main_agent_resources.py +++ b/astrbot/core/astr_main_agent_resources.py @@ -1,6 +1,7 @@ import base64 import json import os +import uuid from pydantic import Field from pydantic.dataclasses import dataclass @@ -240,7 +241,9 @@ async def _resolve_path_from_sandbox( if "_&exists_" in json.dumps(result): # Download the file from sandbox name = os.path.basename(path) - local_path = os.path.join(get_astrbot_temp_path(), name) + local_path = os.path.join( + get_astrbot_temp_path(), f"sandbox_{uuid.uuid4().hex[:4]}_{name}" + ) await sb.download_file(path, local_path) logger.info(f"Downloaded file from sandbox: {path} -> {local_path}") return local_path, True @@ -352,11 +355,11 @@ async def call( MessageChain(chain=components), ) - if file_from_sandbox: - try: - os.remove(local_path) - except Exception as e: - logger.error(f"Error removing temp file {local_path}: {e}") + # if file_from_sandbox: + # try: + # os.remove(local_path) + # except Exception as e: + # logger.error(f"Error removing temp file {local_path}: {e}") return f"Message sent to session {target_session}" diff --git a/astrbot/core/astrbot_config_mgr.py b/astrbot/core/astrbot_config_mgr.py index 3a1353ce5..c2bfb1c37 100644 --- a/astrbot/core/astrbot_config_mgr.py +++ b/astrbot/core/astrbot_config_mgr.py @@ -36,7 +36,7 @@ def __init__( default_config: AstrBotConfig, ucr: UmopConfigRouter, sp: SharedPreferences, - ): + ) -> None: self.sp = sp self.ucr = ucr self.confs: dict[str, AstrBotConfig] = {} @@ -56,7 +56,7 @@ def _get_abconf_data(self) -> dict: ) return self.abconf_data - def _load_all_configs(self): + def _load_all_configs(self) -> None: """Load all configurations from the shared preferences.""" abconf_data = self._get_abconf_data() self.abconf_data = abconf_data diff --git a/astrbot/core/backup/constants.py b/astrbot/core/backup/constants.py index b45b702e7..be206b307 100644 --- a/astrbot/core/backup/constants.py +++ b/astrbot/core/backup/constants.py @@ -11,6 +11,7 @@ CommandConflict, ConversationV2, Persona, + PersonaFolder, PlatformMessageHistory, PlatformSession, PlatformStat, @@ -39,6 +40,7 @@ "platform_stats": PlatformStat, "conversations": ConversationV2, "personas": Persona, + "persona_folders": PersonaFolder, "preferences": Preference, "platform_message_history": PlatformMessageHistory, "platform_sessions": PlatformSession, diff --git a/astrbot/core/backup/exporter.py b/astrbot/core/backup/exporter.py index 51c4a4650..a92237599 100644 --- a/astrbot/core/backup/exporter.py +++ b/astrbot/core/backup/exporter.py @@ -59,7 +59,7 @@ def __init__( main_db: BaseDatabase, kb_manager: "KnowledgeBaseManager | None" = None, config_path: str = CMD_CONFIG_FILE_PATH, - ): + ) -> None: self.main_db = main_db self.kb_manager = kb_manager self.config_path = config_path diff --git a/astrbot/core/backup/importer.py b/astrbot/core/backup/importer.py index f36a79cf5..2e67f85e5 100644 --- a/astrbot/core/backup/importer.py +++ b/astrbot/core/backup/importer.py @@ -110,7 +110,7 @@ def to_dict(self) -> dict: class ImportResult: """导入结果""" - def __init__(self): + def __init__(self) -> None: self.success = True self.imported_tables: dict[str, int] = {} self.imported_files: dict[str, int] = {} @@ -161,7 +161,7 @@ def __init__( kb_manager: "KnowledgeBaseManager | None" = None, config_path: str = CMD_CONFIG_FILE_PATH, kb_root_dir: str = KB_PATH, - ): + ) -> None: self.main_db = main_db self.kb_manager = kb_manager self.config_path = config_path diff --git a/astrbot/core/computer/booters/base.py b/astrbot/core/computer/booters/base.py index 55af866a3..ea93a3d6d 100644 --- a/astrbot/core/computer/booters/base.py +++ b/astrbot/core/computer/booters/base.py @@ -22,7 +22,7 @@ async def upload_file(self, path: str, file_name: str) -> dict: """ ... - async def download_file(self, remote_path: str, local_path: str): + async def download_file(self, remote_path: str, local_path: str) -> None: """Download file from the computer.""" ... diff --git a/astrbot/core/computer/booters/local.py b/astrbot/core/computer/booters/local.py index c89c40187..a80ef0da2 100644 --- a/astrbot/core/computer/booters/local.py +++ b/astrbot/core/computer/booters/local.py @@ -225,7 +225,7 @@ async def upload_file(self, path: str, file_name: str) -> dict: "LocalBooter does not support upload_file operation. Use shell instead." ) - async def download_file(self, remote_path: str, local_path: str): + async def download_file(self, remote_path: str, local_path: str) -> None: raise NotImplementedError( "LocalBooter does not support download_file operation. Use shell instead." ) diff --git a/astrbot/core/computer/tools/fs.py b/astrbot/core/computer/tools/fs.py index 9acc371b2..31b7f3f51 100644 --- a/astrbot/core/computer/tools/fs.py +++ b/astrbot/core/computer/tools/fs.py @@ -1,4 +1,5 @@ import os +import uuid from dataclasses import dataclass, field from astrbot.api import FunctionTool, logger @@ -10,6 +11,7 @@ from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from ..computer_client import get_booter +from .permissions import check_admin_permission # @dataclass # class CreateFileTool(FunctionTool): @@ -100,7 +102,9 @@ async def call( self, context: ContextWrapper[AstrAgentContext], local_path: str, - ): + ) -> str | None: + if permission_error := check_admin_permission(context, "File upload/download"): + return permission_error sb = await get_booter( context.context.context, context.context.event.unified_msg_origin, @@ -160,6 +164,8 @@ async def call( remote_path: str, also_send_to_user: bool = True, ) -> ToolExecResult: + if permission_error := check_admin_permission(context, "File upload/download"): + return permission_error sb = await get_booter( context.context.context, context.context.event.unified_msg_origin, @@ -167,7 +173,9 @@ async def call( try: name = os.path.basename(remote_path) - local_path = os.path.join(get_astrbot_temp_path(), name) + local_path = os.path.join( + get_astrbot_temp_path(), f"sandbox_{uuid.uuid4().hex[:4]}_{name}" + ) # Download file from sandbox await sb.download_file(remote_path, local_path) @@ -183,12 +191,12 @@ async def call( logger.error(f"Error sending file message: {e}") # remove - try: - os.remove(local_path) - except Exception as e: - logger.error(f"Error removing temp file {local_path}: {e}") + # try: + # os.remove(local_path) + # except Exception as e: + # logger.error(f"Error removing temp file {local_path}: {e}") - return f"File downloaded successfully to {local_path} and sent to user. The file has been removed from local storage." + return f"File downloaded successfully to {local_path} and sent to user." return f"File downloaded successfully to {local_path}" except Exception as e: diff --git a/astrbot/core/computer/tools/permissions.py b/astrbot/core/computer/tools/permissions.py new file mode 100644 index 000000000..489f485f9 --- /dev/null +++ b/astrbot/core/computer/tools/permissions.py @@ -0,0 +1,19 @@ +from astrbot.core.agent.run_context import ContextWrapper +from astrbot.core.astr_agent_context import AstrAgentContext + + +def check_admin_permission( + context: ContextWrapper[AstrAgentContext], operation_name: str +) -> str | None: + cfg = context.context.context.get_config( + umo=context.context.event.unified_msg_origin + ) + provider_settings = cfg.get("provider_settings", {}) + require_admin = provider_settings.get("computer_use_require_admin", True) + if require_admin and context.context.event.role != "admin": + return ( + f"error: Permission denied. {operation_name} is only allowed for admin users. " + "Tell user to set admins in `AstrBot WebUI -> Config -> General Config` by adding their user ID to the admins list if they need this feature. " + f"User's ID is: {context.context.event.get_sender_id()}. User's ID can be found by using /sid command." + ) + return None diff --git a/astrbot/core/computer/tools/python.py b/astrbot/core/computer/tools/python.py index 333f442f9..cc835bc75 100644 --- a/astrbot/core/computer/tools/python.py +++ b/astrbot/core/computer/tools/python.py @@ -5,8 +5,10 @@ from astrbot.api import FunctionTool from astrbot.core.agent.run_context import ContextWrapper from astrbot.core.agent.tool import ToolExecResult -from astrbot.core.astr_agent_context import AstrAgentContext +from astrbot.core.astr_agent_context import AstrAgentContext, AstrMessageEvent from astrbot.core.computer.computer_client import get_booter, get_local_booter +from astrbot.core.computer.tools.permissions import check_admin_permission +from astrbot.core.message.message_event_result import MessageChain param_schema = { "type": "object", @@ -25,7 +27,7 @@ } -def handle_result(result: dict) -> ToolExecResult: +async def handle_result(result: dict, event: AstrMessageEvent) -> ToolExecResult: data = result.get("data", {}) output = data.get("output", {}) error = data.get("error", "") @@ -44,6 +46,9 @@ def handle_result(result: dict) -> ToolExecResult: type="image", data=img["image/png"], mimeType="image/png" ) ) + + if event.get_platform_name() == "webchat": + await event.send(message=MessageChain().base64_image(img["image/png"])) if text: resp.content.append(mcp.types.TextContent(type="text", text=text)) @@ -62,13 +67,15 @@ class PythonTool(FunctionTool): async def call( self, context: ContextWrapper[AstrAgentContext], code: str, silent: bool = False ) -> ToolExecResult: + if permission_error := check_admin_permission(context, "Python execution"): + return permission_error sb = await get_booter( context.context.context, context.context.event.unified_msg_origin, ) try: result = await sb.python.exec(code, silent=silent) - return handle_result(result) + return await handle_result(result, context.context.event) except Exception as e: return f"Error executing code: {str(e)}" @@ -83,12 +90,11 @@ class LocalPythonTool(FunctionTool): async def call( self, context: ContextWrapper[AstrAgentContext], code: str, silent: bool = False ) -> ToolExecResult: - if context.context.event.role != "admin": - return "error: Permission denied. Local Python execution is only allowed for admin users. Tell user to set admins in AstrBot WebUI." - + if permission_error := check_admin_permission(context, "Python execution"): + return permission_error sb = get_local_booter() try: result = await sb.python.exec(code, silent=silent) - return handle_result(result) + return await handle_result(result, context.context.event) except Exception as e: return f"Error executing code: {str(e)}" diff --git a/astrbot/core/computer/tools/shell.py b/astrbot/core/computer/tools/shell.py index eeeb3f9d4..9e729573a 100644 --- a/astrbot/core/computer/tools/shell.py +++ b/astrbot/core/computer/tools/shell.py @@ -7,6 +7,7 @@ from astrbot.core.astr_agent_context import AstrAgentContext from ..computer_client import get_booter, get_local_booter +from .permissions import check_admin_permission @dataclass @@ -46,8 +47,8 @@ async def call( background: bool = False, env: dict = {}, ) -> ToolExecResult: - if context.context.event.role != "admin": - return "error: Permission denied. Shell execution is only allowed for admin users. Tell user to Set admins in AstrBot WebUI." + if permission_error := check_admin_permission(context, "Shell execution"): + return permission_error if self.is_local: sb = get_local_booter() diff --git a/astrbot/core/config/astrbot_config.py b/astrbot/core/config/astrbot_config.py index 2208ee766..6a415e56c 100644 --- a/astrbot/core/config/astrbot_config.py +++ b/astrbot/core/config/astrbot_config.py @@ -33,7 +33,7 @@ def __init__( config_path: str = ASTRBOT_CONFIG_PATH, default_config: dict = DEFAULT_CONFIG, schema: dict | None = None, - ): + ) -> None: super().__init__() # 调用父类的 __setattr__ 方法,防止保存配置时将此属性写入配置文件 @@ -52,6 +52,9 @@ def __init__( with open(config_path, encoding="utf-8-sig") as f: conf_str = f.read() + # Handle UTF-8 BOM if present + if conf_str.startswith("\ufeff"): + conf_str = conf_str[1:] conf = json.loads(conf_str) # 检查配置完整性,并插入 @@ -66,7 +69,7 @@ def _config_schema_to_default_config(self, schema: dict) -> dict: """将 Schema 转换成 Config""" conf = {} - def _parse_schema(schema: dict, conf: dict): + def _parse_schema(schema: dict, conf: dict) -> None: for k, v in schema.items(): if v["type"] not in DEFAULT_VALUE_MAP: raise TypeError( @@ -148,7 +151,7 @@ def check_config_integrity(self, refer_conf: dict, conf: dict, path=""): return has_new - def save_config(self, replace_config: dict | None = None): + def save_config(self, replace_config: dict | None = None) -> None: """将配置写入文件 如果传入 replace_config,则将配置替换为 replace_config @@ -164,14 +167,14 @@ def __getattr__(self, item): except KeyError: return None - def __delattr__(self, key): + def __delattr__(self, key) -> None: try: del self[key] self.save_config() except KeyError: raise AttributeError(f"没有找到 Key: '{key}'") - def __setattr__(self, key, value): + def __setattr__(self, key, value) -> None: self[key] = value def check_exist(self) -> bool: diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index fc0d6b373..fa9d71d74 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -5,7 +5,7 @@ from astrbot.core.utils.astrbot_path import get_astrbot_data_path -VERSION = "4.14.4" +VERSION = "4.18.3" DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db") WEBHOOK_SUPPORTED_PLATFORMS = [ @@ -15,6 +15,7 @@ "wecom_ai_bot", "slack", "lark", + "line", ] # 默认配置 @@ -67,6 +68,7 @@ "provider_settings": { "enable": True, "default_provider_id": "", + "fallback_chat_models": [], "default_image_caption_provider_id": "", "image_caption_prompt": "Please describe the image using Chinese.", "provider_pool": ["*"], # "*" 表示使用所有可用的提供者 @@ -74,6 +76,7 @@ "web_search": False, "websearch_provider": "default", "websearch_tavily_key": [], + "websearch_bocha_key": [], "websearch_baidu_app_builder_key": "", "web_search_link": False, "display_reasoning_text": False, @@ -97,7 +100,15 @@ "dequeue_context_length": 1, "streaming_response": False, "show_tool_use_status": False, + "show_tool_call_result": False, "sanitize_context_by_modalities": False, + "max_quoted_fallback_images": 20, + "quoted_message_parser": { + "max_component_chain_depth": 4, + "max_forward_node_depth": 6, + "max_forward_fetch": 32, + "warn_on_action_failure": False, + }, "agent_runner_type": "local", "dify_agent_runner_provider_id": "", "coze_agent_runner_provider_id": "", @@ -118,6 +129,7 @@ "add_cron_tools": True, }, "computer_use_runtime": "local", + "computer_use_require_admin": True, "sandbox": { "booter": "shipyard", "shipyard_endpoint": "", @@ -128,8 +140,9 @@ }, # SubAgent orchestrator mode: # - main_enable = False: disabled; main LLM mounts tools normally (persona selection). - # - main_enable = True: enabled; main LLM will include handoff tools and can optionally - # remove tools that are duplicated on subagents via remove_main_duplicate_tools. + # - main_enable = True: enabled; main LLM keeps its own tools and includes handoff + # tools (transfer_to_*). remove_main_duplicate_tools can remove tools that are + # duplicated on subagents from the main LLM toolset. "subagent_orchestrator": { "main_enable": False, "remove_main_duplicate_tools": False, @@ -176,15 +189,21 @@ "t2i_use_file_service": False, "t2i_active_template": "base", "http_proxy": "", - "no_proxy": ["localhost", "127.0.0.1", "::1"], + "no_proxy": ["localhost", "127.0.0.1", "::1", "10.*", "192.168.*"], "dashboard": { "enable": True, "username": "astrbot", "password": "77b90590a8945a7d36c963981a307dc9", "jwt_secret": "", - "host": "::", + "host": "0.0.0.0", "port": 6185, "disable_access_log": True, + "ssl": { + "enable": False, + "cert_file": "", + "key_file": "", + "ca_certs": "", + }, }, "platform": [], "platform_specific": { @@ -201,6 +220,7 @@ "log_file_enable": False, "log_file_path": "logs/astrbot.log", "log_file_max_mb": 20, + "temp_dir_max_size": 1024, "trace_enable": False, "trace_log_enable": False, "trace_log_path": "logs/astrbot.trace.log", @@ -273,14 +293,14 @@ class ChatProviderTemplate(TypedDict): "is_sandbox": False, "unified_webhook_mode": True, "webhook_uuid": "", - "callback_server_host": "::", + "callback_server_host": "0.0.0.0", "port": 6196, }, "OneBot v11": { "id": "default", "type": "aiocqhttp", "enable": False, - "ws_reverse_host": "::", + "ws_reverse_host": "0.0.0.0", "ws_reverse_port": 6199, "ws_reverse_token": "", }, @@ -295,7 +315,7 @@ class ChatProviderTemplate(TypedDict): "api_base_url": "https://api.weixin.qq.com/cgi-bin/", "unified_webhook_mode": True, "webhook_uuid": "", - "callback_server_host": "::", + "callback_server_host": "0.0.0.0", "port": 6194, "active_send_mode": False, }, @@ -311,21 +331,23 @@ class ChatProviderTemplate(TypedDict): "api_base_url": "https://qyapi.weixin.qq.com/cgi-bin/", "unified_webhook_mode": True, "webhook_uuid": "", - "callback_server_host": "::", + "callback_server_host": "0.0.0.0", "port": 6195, }, "企业微信智能机器人": { "id": "wecom_ai_bot", "type": "wecom_ai_bot", "enable": True, - "wecomaibot_init_respond_text": "💭 思考中...", + "wecomaibot_init_respond_text": "", "wecomaibot_friend_message_welcome_text": "", "wecom_ai_bot_name": "", + "msg_push_webhook_url": "", + "only_use_webhook_url_to_send": False, "token": "", "encoding_aes_key": "", "unified_webhook_mode": True, "webhook_uuid": "", - "callback_server_host": "::", + "callback_server_host": "0.0.0.0", "port": 6198, }, "飞书(Lark)": { @@ -399,10 +421,19 @@ class ChatProviderTemplate(TypedDict): "slack_connection_mode": "socket", # webhook, socket "unified_webhook_mode": True, "webhook_uuid": "", - "slack_webhook_host": "::", + "slack_webhook_host": "0.0.0.0", "slack_webhook_port": 6197, "slack_webhook_path": "/astrbot-slack-webhook/callback", }, + "Line": { + "id": "line", + "type": "line", + "enable": False, + "channel_access_token": "", + "channel_secret": "", + "unified_webhook_mode": True, + "webhook_uuid": "", + }, "Satori": { "id": "satori", "type": "satori", @@ -686,13 +717,23 @@ class ChatProviderTemplate(TypedDict): "wecomaibot_init_respond_text": { "description": "企业微信智能机器人初始响应文本", "type": "string", - "hint": "当机器人收到消息时,首先回复的文本内容。留空则使用默认值。", + "hint": "当机器人收到消息时,首先回复的文本内容。留空则不设置。", }, "wecomaibot_friend_message_welcome_text": { "description": "企业微信智能机器人私聊欢迎语", "type": "string", "hint": "当用户当天进入智能机器人单聊会话,回复欢迎语,留空则不回复。", }, + "msg_push_webhook_url": { + "description": "企业微信消息推送 Webhook URL", + "type": "string", + "hint": "用于 send_by_session 主动消息推送。格式示例: https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx", + }, + "only_use_webhook_url_to_send": { + "description": "仅使用 Webhook 发送消息", + "type": "bool", + "hint": "启用后,企业微信智能机器人的所有回复都改为通过消息推送 Webhook 发送。消息推送 Webhook 支持更多的消息类型(如图片、文件等)。", + }, "lark_bot_name": { "description": "飞书机器人的名字", "type": "string", @@ -912,6 +953,7 @@ class ChatProviderTemplate(TypedDict): "key": [], "api_base": "https://api.openai.com/v1", "timeout": 120, + "proxy": "", "custom_headers": {}, }, "Google Gemini": { @@ -934,6 +976,7 @@ class ChatProviderTemplate(TypedDict): "dangerous_content": "BLOCK_MEDIUM_AND_ABOVE", }, "gm_thinking_config": {"budget": 0, "level": "HIGH"}, + "proxy": "", }, "Anthropic": { "id": "anthropic", @@ -944,7 +987,8 @@ class ChatProviderTemplate(TypedDict): "key": [], "api_base": "https://api.anthropic.com/v1", "timeout": 120, - "anth_thinking_config": {"budget": 0}, + "proxy": "", + "anth_thinking_config": {"type": "", "budget": 0, "effort": ""}, }, "Moonshot": { "id": "moonshot", @@ -955,6 +999,7 @@ class ChatProviderTemplate(TypedDict): "key": [], "timeout": 120, "api_base": "https://api.moonshot.cn/v1", + "proxy": "", "custom_headers": {}, }, "xAI": { @@ -966,6 +1011,7 @@ class ChatProviderTemplate(TypedDict): "key": [], "api_base": "https://api.x.ai/v1", "timeout": 120, + "proxy": "", "custom_headers": {}, "xai_native_search": False, }, @@ -978,6 +1024,7 @@ class ChatProviderTemplate(TypedDict): "key": [], "api_base": "https://api.deepseek.com/v1", "timeout": 120, + "proxy": "", "custom_headers": {}, }, "Zhipu": { @@ -989,6 +1036,43 @@ class ChatProviderTemplate(TypedDict): "key": [], "timeout": 120, "api_base": "https://open.bigmodel.cn/api/paas/v4/", + "proxy": "", + "custom_headers": {}, + }, + "AIHubMix": { + "id": "aihubmix", + "provider": "aihubmix", + "type": "aihubmix_chat_completion", + "provider_type": "chat_completion", + "enable": True, + "key": [], + "timeout": 120, + "api_base": "https://aihubmix.com/v1", + "proxy": "", + "custom_headers": {}, + }, + "OpenRouter": { + "id": "openrouter", + "provider": "openrouter", + "type": "openrouter_chat_completion", + "provider_type": "chat_completion", + "enable": True, + "key": [], + "timeout": 120, + "api_base": "https://openrouter.ai/v1", + "proxy": "", + "custom_headers": {}, + }, + "NVIDIA": { + "id": "nvidia", + "provider": "nvidia", + "type": "openai_chat_completion", + "provider_type": "chat_completion", + "enable": True, + "key": [], + "api_base": "https://integrate.api.nvidia.com/v1", + "timeout": 120, + "proxy": "", "custom_headers": {}, }, "Azure OpenAI": { @@ -1001,6 +1085,7 @@ class ChatProviderTemplate(TypedDict): "key": [], "api_base": "", "timeout": 120, + "proxy": "", "custom_headers": {}, }, "Ollama": { @@ -1011,6 +1096,7 @@ class ChatProviderTemplate(TypedDict): "enable": True, "key": ["ollama"], # ollama 的 key 默认是 ollama "api_base": "http://127.0.0.1:11434/v1", + "proxy": "", "custom_headers": {}, }, "LM Studio": { @@ -1021,6 +1107,7 @@ class ChatProviderTemplate(TypedDict): "enable": True, "key": ["lmstudio"], "api_base": "http://127.0.0.1:1234/v1", + "proxy": "", "custom_headers": {}, }, "Gemini_OpenAI_API": { @@ -1032,6 +1119,7 @@ class ChatProviderTemplate(TypedDict): "key": [], "api_base": "https://generativelanguage.googleapis.com/v1beta/openai/", "timeout": 120, + "proxy": "", "custom_headers": {}, }, "Groq": { @@ -1043,6 +1131,7 @@ class ChatProviderTemplate(TypedDict): "key": [], "api_base": "https://api.groq.com/openai/v1", "timeout": 120, + "proxy": "", "custom_headers": {}, }, "302.AI": { @@ -1054,6 +1143,7 @@ class ChatProviderTemplate(TypedDict): "key": [], "api_base": "https://api.302.ai/v1", "timeout": 120, + "proxy": "", "custom_headers": {}, }, "SiliconFlow": { @@ -1065,6 +1155,7 @@ class ChatProviderTemplate(TypedDict): "key": [], "timeout": 120, "api_base": "https://api.siliconflow.cn/v1", + "proxy": "", "custom_headers": {}, }, "PPIO": { @@ -1076,6 +1167,7 @@ class ChatProviderTemplate(TypedDict): "key": [], "api_base": "https://api.ppinfra.com/v3/openai", "timeout": 120, + "proxy": "", "custom_headers": {}, }, "TokenPony": { @@ -1087,6 +1179,7 @@ class ChatProviderTemplate(TypedDict): "key": [], "api_base": "https://api.tokenpony.cn/v1", "timeout": 120, + "proxy": "", "custom_headers": {}, }, "Compshare": { @@ -1098,6 +1191,7 @@ class ChatProviderTemplate(TypedDict): "key": [], "api_base": "https://api.modelverse.cn/v1", "timeout": 120, + "proxy": "", "custom_headers": {}, }, "ModelScope": { @@ -1109,6 +1203,7 @@ class ChatProviderTemplate(TypedDict): "key": [], "timeout": 120, "api_base": "https://api-inference.modelscope.cn/v1", + "proxy": "", "custom_headers": {}, }, "Dify": { @@ -1124,6 +1219,7 @@ class ChatProviderTemplate(TypedDict): "dify_query_input_key": "astrbot_text_query", "variables": {}, "timeout": 60, + "proxy": "", }, "Coze": { "id": "coze", @@ -1135,6 +1231,7 @@ class ChatProviderTemplate(TypedDict): "bot_id": "", "coze_api_base": "https://api.coze.cn", "timeout": 60, + "proxy": "", # "auto_save_history": True, }, "阿里云百炼应用": { @@ -1153,6 +1250,7 @@ class ChatProviderTemplate(TypedDict): }, "variables": {}, "timeout": 60, + "proxy": "", }, "FastGPT": { "id": "fastgpt", @@ -1163,6 +1261,7 @@ class ChatProviderTemplate(TypedDict): "key": [], "api_base": "https://api.fastgpt.in/api/v1", "timeout": 60, + "proxy": "", "custom_headers": {}, "custom_extra_body": {}, }, @@ -1175,6 +1274,7 @@ class ChatProviderTemplate(TypedDict): "api_key": "", "api_base": "", "model": "whisper-1", + "proxy": "", }, "Whisper(Local)": { "provider": "openai", @@ -1204,6 +1304,7 @@ class ChatProviderTemplate(TypedDict): "model": "tts-1", "openai-tts-voice": "alloy", "timeout": "20", + "proxy": "", }, "Genie TTS": { "id": "genie_tts", @@ -1284,6 +1385,7 @@ class ChatProviderTemplate(TypedDict): "fishaudio-tts-character": "可莉", "fishaudio-tts-reference-id": "", "timeout": "20", + "proxy": "", }, "阿里云百炼 TTS(API)": { "hint": "API Key 从 https://bailian.console.aliyun.com/?tab=model#/api-key 获取。模型和音色的选择文档请参考: 阿里云百炼语音合成音色名称。具体可参考 https://help.aliyun.com/zh/model-studio/speech-synthesis-and-speech-recognition", @@ -1310,6 +1412,7 @@ class ChatProviderTemplate(TypedDict): "azure_tts_volume": "100", "azure_tts_subscription_key": "", "azure_tts_region": "eastus", + "proxy": "", }, "MiniMax TTS(API)": { "id": "minimax_tts", @@ -1332,6 +1435,7 @@ class ChatProviderTemplate(TypedDict): "minimax-voice-latex": False, "minimax-voice-english-normalization": False, "timeout": 20, + "proxy": "", }, "火山引擎_TTS(API)": { "id": "volcengine_tts", @@ -1346,6 +1450,7 @@ class ChatProviderTemplate(TypedDict): "volcengine_speed_ratio": 1.0, "api_base": "https://openspeech.bytedance.com/api/v1/tts", "timeout": 20, + "proxy": "", }, "Gemini TTS": { "id": "gemini_tts", @@ -1359,30 +1464,35 @@ class ChatProviderTemplate(TypedDict): "gemini_tts_model": "gemini-2.5-flash-preview-tts", "gemini_tts_prefix": "", "gemini_tts_voice_name": "Leda", + "proxy": "", }, "OpenAI Embedding": { "id": "openai_embedding", "type": "openai_embedding", "provider": "openai", "provider_type": "embedding", + "hint": "provider_group.provider.openai_embedding.hint", "enable": True, "embedding_api_key": "", "embedding_api_base": "", "embedding_model": "", "embedding_dimensions": 1024, "timeout": 20, + "proxy": "", }, "Gemini Embedding": { "id": "gemini_embedding", "type": "gemini_embedding", "provider": "google", "provider_type": "embedding", + "hint": "provider_group.provider.gemini_embedding.hint", "enable": True, "embedding_api_key": "", "embedding_api_base": "", "embedding_model": "gemini-embedding-exp-03-07", "embedding_dimensions": 768, "timeout": 20, + "proxy": "", }, "vLLM Rerank": { "id": "vllm_rerank", @@ -1865,13 +1975,25 @@ class ChatProviderTemplate(TypedDict): }, }, "anth_thinking_config": { - "description": "Thinking Config", + "description": "思考配置", "type": "object", "items": { + "type": { + "description": "思考类型", + "type": "string", + "options": ["", "adaptive"], + "hint": "Opus 4.6+ / Sonnet 4.6+ 推荐设为 'adaptive'。留空则使用手动 budget 模式。参见: https://platform.claude.com/docs/en/build-with-claude/adaptive-thinking", + }, "budget": { - "description": "Thinking Budget", + "description": "思考预算", "type": "int", - "hint": "Anthropic thinking.budget_tokens param. Must >= 1024. See: https://platform.claude.com/docs/en/build-with-claude/extended-thinking", + "hint": "手动 budget_tokens,需 >= 1024。仅在 type 为空时生效。Opus 4.6 / Sonnet 4.6 上已弃用。参见: https://platform.claude.com/docs/en/build-with-claude/extended-thinking", + }, + "effort": { + "description": "思考深度", + "type": "string", + "options": ["", "low", "medium", "high", "max"], + "hint": "type 为 'adaptive' 时控制思考深度。默认 'high'。'max' 仅限 Opus 4.6。参见: https://platform.claude.com/docs/en/build-with-claude/effort", }, }, }, @@ -2079,6 +2201,11 @@ class ChatProviderTemplate(TypedDict): "description": "API Base URL", "type": "string", }, + "proxy": { + "description": "provider_group.provider.proxy.description", + "type": "string", + "hint": "provider_group.provider.proxy.hint", + }, "model": { "description": "模型 ID", "type": "string", @@ -2147,6 +2274,10 @@ class ChatProviderTemplate(TypedDict): "default_provider_id": { "type": "string", }, + "fallback_chat_models": { + "type": "list", + "items": {"type": "string"}, + }, "wake_prefix": { "type": "string", }, @@ -2186,6 +2317,9 @@ class ChatProviderTemplate(TypedDict): "show_tool_use_status": { "type": "bool", }, + "show_tool_call_result": { + "type": "bool", + }, "unsupported_streaming_strategy": { "type": "string", }, @@ -2341,9 +2475,23 @@ class ChatProviderTemplate(TypedDict): "type": "string", "options": ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], }, + "dashboard.ssl.enable": {"type": "bool"}, + "dashboard.ssl.cert_file": { + "type": "string", + "condition": {"dashboard.ssl.enable": True}, + }, + "dashboard.ssl.key_file": { + "type": "string", + "condition": {"dashboard.ssl.enable": True}, + }, + "dashboard.ssl.ca_certs": { + "type": "string", + "condition": {"dashboard.ssl.enable": True}, + }, "log_file_enable": {"type": "bool"}, "log_file_path": {"type": "string", "condition": {"log_file_enable": True}}, "log_file_max_mb": {"type": "int", "condition": {"log_file_enable": True}}, + "temp_dir_max_size": {"type": "int"}, "trace_log_enable": {"type": "bool"}, "trace_log_path": { "type": "string", @@ -2443,15 +2591,22 @@ class ChatProviderTemplate(TypedDict): }, "ai": { "description": "模型", - "hint": "当使用非内置 Agent 执行器时,默认聊天模型和默认图片转述模型可能会无效,但某些插件会依赖此配置项来调用 AI 能力。", + "hint": "当使用非内置 Agent 执行器时,默认对话模型和默认图片转述模型可能会无效,但某些插件会依赖此配置项来调用 AI 能力。", "type": "object", "items": { "provider_settings.default_provider_id": { - "description": "默认聊天模型", + "description": "默认对话模型", "type": "string", "_special": "select_provider", "hint": "留空时使用第一个模型", }, + "provider_settings.fallback_chat_models": { + "description": "回退对话模型列表", + "type": "list", + "items": {"type": "string"}, + "_special": "select_providers", + "hint": "主聊天模型请求失败时,按顺序切换到这些模型。", + }, "provider_settings.default_image_caption_provider_id": { "description": "默认图片转述模型", "type": "string", @@ -2563,7 +2718,7 @@ class ChatProviderTemplate(TypedDict): "provider_settings.websearch_provider": { "description": "网页搜索提供商", "type": "string", - "options": ["default", "tavily", "baidu_ai_search"], + "options": ["default", "tavily", "baidu_ai_search", "bocha"], "condition": { "provider_settings.web_search": True, }, @@ -2578,6 +2733,16 @@ class ChatProviderTemplate(TypedDict): "provider_settings.web_search": True, }, }, + "provider_settings.websearch_bocha_key": { + "description": "BoCha API Key", + "type": "list", + "items": {"type": "string"}, + "hint": "可添加多个 Key 进行轮询。", + "condition": { + "provider_settings.websearch_provider": "bocha", + "provider_settings.web_search": True, + }, + }, "provider_settings.websearch_baidu_app_builder_key": { "description": "百度千帆智能云 APP Builder API Key", "type": "string", @@ -2611,6 +2776,11 @@ class ChatProviderTemplate(TypedDict): "labels": ["无", "本地", "沙箱"], "hint": "选择 Computer Use 运行环境。", }, + "provider_settings.computer_use_require_admin": { + "description": "需要 AstrBot 管理员权限", + "type": "bool", + "hint": "开启后,需要 AstrBot 管理员权限才能调用使用电脑能力。在平台配置->管理员中可添加管理员。使用 /sid 指令查看管理员 ID。", + }, "provider_settings.sandbox.booter": { "description": "沙箱环境驱动器", "type": "string", @@ -2838,6 +3008,15 @@ class ChatProviderTemplate(TypedDict): "provider_settings.agent_runner_type": "local", }, }, + "provider_settings.show_tool_call_result": { + "description": "输出函数调用返回结果", + "type": "bool", + "hint": "仅在输出函数调用状态启用时生效,展示结果前 70 个字符。", + "condition": { + "provider_settings.agent_runner_type": "local", + "provider_settings.show_tool_use_status": True, + }, + }, "provider_settings.sanitize_context_by_modalities": { "description": "按模型能力清理历史上下文", "type": "bool", @@ -2846,6 +3025,46 @@ class ChatProviderTemplate(TypedDict): "provider_settings.agent_runner_type": "local", }, }, + "provider_settings.max_quoted_fallback_images": { + "description": "引用图片回退解析上限", + "type": "int", + "hint": "引用/转发消息回退解析图片时的最大注入数量,超出会截断。", + "condition": { + "provider_settings.agent_runner_type": "local", + }, + }, + "provider_settings.quoted_message_parser.max_component_chain_depth": { + "description": "引用解析组件链深度", + "type": "int", + "hint": "解析 Reply 组件链时允许的最大递归深度。", + "condition": { + "provider_settings.agent_runner_type": "local", + }, + }, + "provider_settings.quoted_message_parser.max_forward_node_depth": { + "description": "引用解析转发节点深度", + "type": "int", + "hint": "解析合并转发节点时允许的最大递归深度。", + "condition": { + "provider_settings.agent_runner_type": "local", + }, + }, + "provider_settings.quoted_message_parser.max_forward_fetch": { + "description": "引用解析转发拉取上限", + "type": "int", + "hint": "递归拉取 get_forward_msg 的最大次数。", + "condition": { + "provider_settings.agent_runner_type": "local", + }, + }, + "provider_settings.quoted_message_parser.warn_on_action_failure": { + "description": "引用解析 action 失败告警", + "type": "bool", + "hint": "开启后,get_msg/get_forward_msg 全部尝试失败时输出 warning 日志。", + "condition": { + "provider_settings.agent_runner_type": "local", + }, + }, "provider_settings.max_agent_step": { "description": "工具调用轮数上限", "type": "int", @@ -3297,6 +3516,29 @@ class ChatProviderTemplate(TypedDict): "hint": "控制台输出日志的级别。", "options": ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], }, + "dashboard.ssl.enable": { + "description": "启用 WebUI HTTPS", + "type": "bool", + "hint": "启用后,WebUI 将直接使用 HTTPS 提供服务。", + }, + "dashboard.ssl.cert_file": { + "description": "SSL 证书文件路径", + "type": "string", + "hint": "证书文件路径(PEM)。支持绝对路径和相对路径(相对于当前工作目录)。", + "condition": {"dashboard.ssl.enable": True}, + }, + "dashboard.ssl.key_file": { + "description": "SSL 私钥文件路径", + "type": "string", + "hint": "私钥文件路径(PEM)。支持绝对路径和相对路径(相对于当前工作目录)。", + "condition": {"dashboard.ssl.enable": True}, + }, + "dashboard.ssl.ca_certs": { + "description": "SSL CA 证书文件路径", + "type": "string", + "hint": "可选。用于指定 CA 证书文件路径。", + "condition": {"dashboard.ssl.enable": True}, + }, "log_file_enable": { "description": "启用文件日志", "type": "bool", @@ -3312,6 +3554,11 @@ class ChatProviderTemplate(TypedDict): "type": "int", "hint": "超过大小后自动轮转,默认 20MB。", }, + "temp_dir_max_size": { + "description": "临时目录大小上限 (MB)", + "type": "int", + "hint": "用于限制 data/temp 目录总大小,单位为 MB。系统每 10 分钟检查一次,超限时按文件修改时间从旧到新删除,释放约 30% 当前体积。", + }, "trace_log_enable": { "description": "启用 Trace 文件日志", "type": "bool", diff --git a/astrbot/core/config/i18n_utils.py b/astrbot/core/config/i18n_utils.py index aa441c0c1..cb6b6429b 100644 --- a/astrbot/core/config/i18n_utils.py +++ b/astrbot/core/config/i18n_utils.py @@ -42,6 +42,55 @@ def convert_to_i18n_keys(metadata: dict[str, Any]) -> dict[str, Any]: """ result = {} + def convert_items( + group: str, section: str, items: dict[str, Any], prefix: str = "" + ) -> dict[str, Any]: + items_result: dict[str, Any] = {} + + for field_key, field_data in items.items(): + if not isinstance(field_data, dict): + items_result[field_key] = field_data + continue + + field_name = field_key + field_path = f"{prefix}.{field_name}" if prefix else field_name + + field_result = { + key: value + for key, value in field_data.items() + if key not in {"description", "hint", "labels", "name"} + } + + if "description" in field_data: + field_result["description"] = ( + f"{group}.{section}.{field_path}.description" + ) + if "hint" in field_data: + field_result["hint"] = f"{group}.{section}.{field_path}.hint" + if "labels" in field_data: + field_result["labels"] = f"{group}.{section}.{field_path}.labels" + if "name" in field_data: + field_result["name"] = f"{group}.{section}.{field_path}.name" + + if "items" in field_data and isinstance(field_data["items"], dict): + field_result["items"] = convert_items( + group, section, field_data["items"], field_path + ) + + if "template_schema" in field_data and isinstance( + field_data["template_schema"], dict + ): + field_result["template_schema"] = convert_items( + group, + section, + field_data["template_schema"], + f"{field_path}.template_schema", + ) + + items_result[field_key] = field_result + + return items_result + for group_key, group_data in metadata.items(): group_result = { "name": f"{group_key}.name", @@ -50,59 +99,19 @@ def convert_to_i18n_keys(metadata: dict[str, Any]) -> dict[str, Any]: for section_key, section_data in group_data.get("metadata", {}).items(): section_result = { - "description": f"{group_key}.{section_key}.description", - "type": section_data.get("type"), + key: value + for key, value in section_data.items() + if key not in {"description", "hint", "labels", "name"} } + section_result["description"] = f"{group_key}.{section_key}.description" - # 复制其他属性 - for key in ["items", "condition", "_special", "invisible"]: - if key in section_data: - section_result[key] = section_data[key] - - # 处理 hint if "hint" in section_data: section_result["hint"] = f"{group_key}.{section_key}.hint" - # 处理 items 中的字段 if "items" in section_data and isinstance(section_data["items"], dict): - items_result = {} - for field_key, field_data in section_data["items"].items(): - # 处理嵌套的点号字段名(如 provider_settings.enable) - field_name = field_key - - field_result = {} - - # 复制基本属性 - for attr in [ - "type", - "condition", - "_special", - "invisible", - "options", - "slider", - ]: - if attr in field_data: - field_result[attr] = field_data[attr] - - # 转换文本属性为国际化键 - if "description" in field_data: - field_result["description"] = ( - f"{group_key}.{section_key}.{field_name}.description" - ) - - if "hint" in field_data: - field_result["hint"] = ( - f"{group_key}.{section_key}.{field_name}.hint" - ) - - if "labels" in field_data: - field_result["labels"] = ( - f"{group_key}.{section_key}.{field_name}.labels" - ) - - items_result[field_key] = field_result - - section_result["items"] = items_result + section_result["items"] = convert_items( + group_key, section_key, section_data["items"] + ) group_result["metadata"][section_key] = section_result diff --git a/astrbot/core/conversation_mgr.py b/astrbot/core/conversation_mgr.py index a0a0c0e2f..6fcb3608c 100644 --- a/astrbot/core/conversation_mgr.py +++ b/astrbot/core/conversation_mgr.py @@ -16,7 +16,7 @@ class ConversationManager: """负责管理会话与 LLM 的对话,某个会话当前正在用哪个对话。""" - def __init__(self, db_helper: BaseDatabase): + def __init__(self, db_helper: BaseDatabase) -> None: self.session_conversations: dict[str, str] = {} self.db = db_helper self.save_interval = 60 # 每 60 秒保存一次 @@ -106,7 +106,9 @@ async def new_conversation( await sp.session_put(unified_msg_origin, "sel_conv_id", conv.conversation_id) return conv.conversation_id - async def switch_conversation(self, unified_msg_origin: str, conversation_id: str): + async def switch_conversation( + self, unified_msg_origin: str, conversation_id: str + ) -> None: """切换会话的对话 Args: @@ -121,7 +123,7 @@ async def delete_conversation( self, unified_msg_origin: str, conversation_id: str | None = None, - ): + ) -> None: """删除会话的对话,当 conversation_id 为 None 时删除会话当前的对话 Args: @@ -138,7 +140,7 @@ async def delete_conversation( self.session_conversations.pop(unified_msg_origin, None) await sp.session_remove(unified_msg_origin, "sel_conv_id") - async def delete_conversations_by_user_id(self, unified_msg_origin: str): + async def delete_conversations_by_user_id(self, unified_msg_origin: str) -> None: """删除会话的所有对话 Args: diff --git a/astrbot/core/core_lifecycle.py b/astrbot/core/core_lifecycle.py index 6b36cca0d..758cf1ccd 100644 --- a/astrbot/core/core_lifecycle.py +++ b/astrbot/core/core_lifecycle.py @@ -37,6 +37,7 @@ from astrbot.core.updator import AstrBotUpdator from astrbot.core.utils.llm_metadata import update_llm_metadata from astrbot.core.utils.migra_helper import migra +from astrbot.core.utils.temp_dir_cleaner import TempDirCleaner from . import astrbot_config, html_renderer from .event_bus import EventBus @@ -57,6 +58,7 @@ def __init__(self, log_broker: LogBroker, db: BaseDatabase) -> None: self.subagent_orchestrator: SubAgentOrchestrator | None = None self.cron_manager: CronJobManager | None = None + self.temp_dir_cleaner: TempDirCleaner | None = None # 设置代理 proxy_config = self.astrbot_config.get("http_proxy", "") @@ -125,6 +127,12 @@ async def initialize(self) -> None: ucr=self.umop_config_router, sp=sp, ) + self.temp_dir_cleaner = TempDirCleaner( + max_size_getter=lambda: self.astrbot_config_mgr.default_conf.get( + TempDirCleaner.CONFIG_KEY, + TempDirCleaner.DEFAULT_MAX_SIZE, + ), + ) # apply migration try: @@ -238,6 +246,12 @@ def _load(self) -> None: self.cron_manager.start(self.star_context), name="cron_manager", ) + temp_dir_cleaner_task = None + if self.temp_dir_cleaner: + temp_dir_cleaner_task = asyncio.create_task( + self.temp_dir_cleaner.run(), + name="temp_dir_cleaner", + ) # 把插件中注册的所有协程函数注册到事件总线中并执行 extra_tasks = [] @@ -247,6 +261,8 @@ def _load(self) -> None: tasks_ = [event_bus_task, *(extra_tasks if extra_tasks else [])] if cron_task: tasks_.append(cron_task) + if temp_dir_cleaner_task: + tasks_.append(temp_dir_cleaner_task) for task in tasks_: self.curr_tasks.append( asyncio.create_task(self._task_wrapper(task), name=task.get_name()), @@ -298,6 +314,9 @@ async def start(self) -> None: async def stop(self) -> None: """停止 AstrBot 核心生命周期管理类, 取消所有当前任务并终止各个管理器.""" + if self.temp_dir_cleaner: + await self.temp_dir_cleaner.stop() + # 请求停止所有正在运行的异步任务 for task in self.curr_tasks: task.cancel() diff --git a/astrbot/core/cron/events.py b/astrbot/core/cron/events.py index d4f0e01e2..a90ca3822 100644 --- a/astrbot/core/cron/events.py +++ b/astrbot/core/cron/events.py @@ -24,7 +24,7 @@ def __init__( sender_name: str = "Scheduler", extras: dict[str, Any] | None = None, message_type: MessageType = MessageType.FRIEND_MESSAGE, - ): + ) -> None: platform_meta = PlatformMetadata( name="cron", description="CronJob", @@ -53,13 +53,13 @@ def __init__( if extras: self._extras.update(extras) - async def send(self, message: MessageChain): + async def send(self, message: MessageChain) -> None: if message is None: return await self.context_obj.send_message(self.session, message) await super().send(message) - async def send_streaming(self, generator, use_fallback: bool = False): + async def send_streaming(self, generator, use_fallback: bool = False) -> None: async for chain in generator: await self.send(chain) diff --git a/astrbot/core/cron/manager.py b/astrbot/core/cron/manager.py index 0572fa03a..d12878be3 100644 --- a/astrbot/core/cron/manager.py +++ b/astrbot/core/cron/manager.py @@ -25,14 +25,14 @@ class CronJobManager: """Central scheduler for BasicCronJob and ActiveAgentCronJob.""" - def __init__(self, db: BaseDatabase): + def __init__(self, db: BaseDatabase) -> None: self.db = db self.scheduler = AsyncIOScheduler() self._basic_handlers: dict[str, Callable[..., Any]] = {} self._lock = asyncio.Lock() self._started = False - async def start(self, ctx: "Context"): + async def start(self, ctx: "Context") -> None: self.ctx: Context = ctx # star context async with self._lock: if self._started: @@ -41,14 +41,14 @@ async def start(self, ctx: "Context"): self._started = True await self.sync_from_db() - async def shutdown(self): + async def shutdown(self) -> None: async with self._lock: if not self._started: return self.scheduler.shutdown(wait=False) self._started = False - async def sync_from_db(self): + async def sync_from_db(self) -> None: jobs = await self.db.list_cron_jobs() for job in jobs: if not job.enabled or not job.persistent: @@ -136,11 +136,11 @@ async def delete_job(self, job_id: str) -> None: async def list_jobs(self, job_type: str | None = None) -> list[CronJob]: return await self.db.list_cron_jobs(job_type) - def _remove_scheduled(self, job_id: str): + def _remove_scheduled(self, job_id: str) -> None: if self.scheduler.get_job(job_id): self.scheduler.remove_job(job_id) - def _schedule_job(self, job: CronJob): + def _schedule_job(self, job: CronJob) -> None: if not self._started: self.scheduler.start() self._started = True @@ -188,7 +188,7 @@ def _get_next_run_time(self, job_id: str): aps_job = self.scheduler.get_job(job_id) return aps_job.next_run_time if aps_job else None - async def _run_job(self, job_id: str): + async def _run_job(self, job_id: str) -> None: job = await self.db.get_cron_job(job_id) if not job or not job.enabled: return @@ -222,7 +222,7 @@ async def _run_job(self, job_id: str): # one-shot: remove after execution regardless of success await self.delete_job(job_id) - async def _run_basic_job(self, job: CronJob): + async def _run_basic_job(self, job: CronJob) -> None: handler = self._basic_handlers.get(job.job_id) if not handler: raise RuntimeError(f"Basic cron job handler not found for {job.job_id}") @@ -231,7 +231,7 @@ async def _run_basic_job(self, job: CronJob): if asyncio.iscoroutine(result): await result - async def _run_active_agent_job(self, job: CronJob, start_time: datetime): + async def _run_active_agent_job(self, job: CronJob, start_time: datetime) -> None: payload = job.payload or {} session_str = payload.get("session") if not session_str: @@ -266,7 +266,7 @@ async def _woke_main_agent( message: str, session_str: str, extras: dict, - ): + ) -> None: """Woke the main agent to handle the cron job message.""" from astrbot.core.astr_main_agent import ( MainAgentBuildConfig, diff --git a/astrbot/core/db/__init__.py b/astrbot/core/db/__init__.py index 7b67b8755..11f408e70 100644 --- a/astrbot/core/db/__init__.py +++ b/astrbot/core/db/__init__.py @@ -8,6 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from astrbot.core.db.po import ( + ApiKey, Attachment, ChatUIProject, CommandConfig, @@ -43,7 +44,7 @@ def __init__(self) -> None: expire_on_commit=False, ) - async def initialize(self): + async def initialize(self) -> None: """初始化数据库连接""" @asynccontextmanager @@ -248,6 +249,55 @@ async def delete_attachments(self, attachment_ids: list[str]) -> int: """ ... + @abc.abstractmethod + async def create_api_key( + self, + name: str, + key_hash: str, + key_prefix: str, + scopes: list[str] | None, + created_by: str, + expires_at: datetime.datetime | None = None, + ) -> ApiKey: + """Create a new API key record.""" + ... + + @abc.abstractmethod + async def list_api_keys(self) -> list[ApiKey]: + """List all API keys.""" + ... + + @abc.abstractmethod + async def get_api_key_by_id(self, key_id: str) -> ApiKey | None: + """Get an API key by key_id.""" + ... + + @abc.abstractmethod + async def get_active_api_key_by_hash(self, key_hash: str) -> ApiKey | None: + """Get an active API key by hash (not revoked, not expired).""" + ... + + @abc.abstractmethod + async def touch_api_key(self, key_id: str) -> None: + """Update last_used_at of an API key.""" + ... + + @abc.abstractmethod + async def revoke_api_key(self, key_id: str) -> bool: + """Revoke an API key. + + Returns True when the key exists and is updated. + """ + ... + + @abc.abstractmethod + async def delete_api_key(self, key_id: str) -> bool: + """Delete an API key. + + Returns True when the key exists and is deleted. + """ + ... + @abc.abstractmethod async def insert_persona( self, @@ -608,6 +658,22 @@ async def get_platform_sessions_by_creator( """ ... + @abc.abstractmethod + async def get_platform_sessions_by_creator_paginated( + self, + creator: str, + platform_id: str | None = None, + page: int = 1, + page_size: int = 20, + exclude_project_sessions: bool = False, + ) -> tuple[list[dict], int]: + """Get paginated platform sessions and total count for a creator. + + Returns: + tuple[list[dict], int]: (sessions_with_project_info, total_count) + """ + ... + @abc.abstractmethod async def update_platform_session( self, diff --git a/astrbot/core/db/migration/migra_3_to_4.py b/astrbot/core/db/migration/migra_3_to_4.py index 66b72d5cb..727d97b29 100644 --- a/astrbot/core/db/migration/migra_3_to_4.py +++ b/astrbot/core/db/migration/migra_3_to_4.py @@ -43,7 +43,7 @@ def get_platform_type( async def migration_conversation_table( db_helper: BaseDatabase, platform_id_map: dict[str, dict[str, str]], -): +) -> None: db_helper_v3 = SQLiteV3DatabaseV3( db_path=DB_PATH.replace("data_v4.db", "data_v3.db"), ) @@ -101,7 +101,7 @@ async def migration_conversation_table( async def migration_platform_table( db_helper: BaseDatabase, platform_id_map: dict[str, dict[str, str]], -): +) -> None: db_helper_v3 = SQLiteV3DatabaseV3( db_path=DB_PATH.replace("data_v4.db", "data_v3.db"), ) @@ -180,7 +180,7 @@ async def migration_platform_table( async def migration_webchat_data( db_helper: BaseDatabase, platform_id_map: dict[str, dict[str, str]], -): +) -> None: """迁移 WebChat 的历史记录到新的 PlatformMessageHistory 表中""" db_helper_v3 = SQLiteV3DatabaseV3( db_path=DB_PATH.replace("data_v4.db", "data_v3.db"), @@ -236,7 +236,7 @@ async def migration_webchat_data( async def migration_persona_data( db_helper: BaseDatabase, astrbot_config: AstrBotConfig, -): +) -> None: """迁移 Persona 数据到新的表中。 旧的 Persona 数据存储在 preference 中,新的 Persona 数据存储在 persona 表中。 """ @@ -279,7 +279,7 @@ async def migration_persona_data( async def migration_preferences( db_helper: BaseDatabase, platform_id_map: dict[str, dict[str, str]], -): +) -> None: # 1. global scope migration keys = [ "inactivated_llm_tools", diff --git a/astrbot/core/db/migration/migra_45_to_46.py b/astrbot/core/db/migration/migra_45_to_46.py index dc70026f9..58736ab51 100644 --- a/astrbot/core/db/migration/migra_45_to_46.py +++ b/astrbot/core/db/migration/migra_45_to_46.py @@ -3,7 +3,7 @@ from astrbot.core.umop_config_router import UmopConfigRouter -async def migrate_45_to_46(acm: AstrBotConfigManager, ucr: UmopConfigRouter): +async def migrate_45_to_46(acm: AstrBotConfigManager, ucr: UmopConfigRouter) -> None: abconf_data = acm.abconf_data if not isinstance(abconf_data, dict): diff --git a/astrbot/core/db/migration/migra_token_usage.py b/astrbot/core/db/migration/migra_token_usage.py index 07938301d..76bf8ce01 100644 --- a/astrbot/core/db/migration/migra_token_usage.py +++ b/astrbot/core/db/migration/migra_token_usage.py @@ -12,7 +12,7 @@ from astrbot.core.db import BaseDatabase -async def migrate_token_usage(db_helper: BaseDatabase): +async def migrate_token_usage(db_helper: BaseDatabase) -> None: """Add token_usage column to conversations table. This migration adds a new column to track token consumption in conversations. diff --git a/astrbot/core/db/migration/migra_webchat_session.py b/astrbot/core/db/migration/migra_webchat_session.py index ff0b5ca6f..46025fc64 100644 --- a/astrbot/core/db/migration/migra_webchat_session.py +++ b/astrbot/core/db/migration/migra_webchat_session.py @@ -17,7 +17,7 @@ from astrbot.core.db.po import ConversationV2, PlatformMessageHistory, PlatformSession -async def migrate_webchat_session(db_helper: BaseDatabase): +async def migrate_webchat_session(db_helper: BaseDatabase) -> None: """Create PlatformSession records from platform_message_history. This migration extracts all unique user_ids from platform_message_history diff --git a/astrbot/core/db/migration/shared_preferences_v3.py b/astrbot/core/db/migration/shared_preferences_v3.py index 3abcb1a66..05b514583 100644 --- a/astrbot/core/db/migration/shared_preferences_v3.py +++ b/astrbot/core/db/migration/shared_preferences_v3.py @@ -8,7 +8,7 @@ class SharedPreferences: - def __init__(self, path=None): + def __init__(self, path=None) -> None: if path is None: path = os.path.join(get_astrbot_data_path(), "shared_preferences.json") self.path = path @@ -23,7 +23,7 @@ def _load_preferences(self): os.remove(self.path) return {} - def _save_preferences(self): + def _save_preferences(self) -> None: with open(self.path, "w") as f: json.dump(self._data, f, indent=4, ensure_ascii=False) f.flush() @@ -31,16 +31,16 @@ def _save_preferences(self): def get(self, key, default: _VT = None) -> _VT: return self._data.get(key, default) - def put(self, key, value): + def put(self, key, value) -> None: self._data[key] = value self._save_preferences() - def remove(self, key): + def remove(self, key) -> None: if key in self._data: del self._data[key] self._save_preferences() - def clear(self): + def clear(self) -> None: self._data.clear() self._save_preferences() diff --git a/astrbot/core/db/migration/sqlite_v3.py b/astrbot/core/db/migration/sqlite_v3.py index b1a780d48..b326ebb44 100644 --- a/astrbot/core/db/migration/sqlite_v3.py +++ b/astrbot/core/db/migration/sqlite_v3.py @@ -127,7 +127,7 @@ def _get_conn(self, db_path: str) -> sqlite3.Connection: conn.text_factory = str return conn - def _exec_sql(self, sql: str, params: tuple | None = None): + def _exec_sql(self, sql: str, params: tuple | None = None) -> None: conn = self.conn try: c = self.conn.cursor() @@ -144,7 +144,7 @@ def _exec_sql(self, sql: str, params: tuple | None = None): conn.commit() - def insert_platform_metrics(self, metrics: dict): + def insert_platform_metrics(self, metrics: dict) -> None: for k, v in metrics.items(): self._exec_sql( """ @@ -153,7 +153,7 @@ def insert_platform_metrics(self, metrics: dict): (k, v, int(time.time())), ) - def insert_llm_metrics(self, metrics: dict): + def insert_llm_metrics(self, metrics: dict) -> None: for k, v in metrics.items(): self._exec_sql( """ @@ -249,7 +249,7 @@ def get_conversation_by_user_id( return Conversation(*res) - def new_conversation(self, user_id: str, cid: str): + def new_conversation(self, user_id: str, cid: str) -> None: history = "[]" updated_at = int(time.time()) created_at = updated_at @@ -287,7 +287,7 @@ def get_conversations(self, user_id: str) -> list[Conversation]: ) return conversations - def update_conversation(self, user_id: str, cid: str, history: str): + def update_conversation(self, user_id: str, cid: str, history: str) -> None: """更新对话,并且同时更新时间""" updated_at = int(time.time()) self._exec_sql( @@ -297,7 +297,7 @@ def update_conversation(self, user_id: str, cid: str, history: str): (history, updated_at, user_id, cid), ) - def update_conversation_title(self, user_id: str, cid: str, title: str): + def update_conversation_title(self, user_id: str, cid: str, title: str) -> None: self._exec_sql( """ UPDATE webchat_conversation SET title = ? WHERE user_id = ? AND cid = ? @@ -305,7 +305,9 @@ def update_conversation_title(self, user_id: str, cid: str, title: str): (title, user_id, cid), ) - def update_conversation_persona_id(self, user_id: str, cid: str, persona_id: str): + def update_conversation_persona_id( + self, user_id: str, cid: str, persona_id: str + ) -> None: self._exec_sql( """ UPDATE webchat_conversation SET persona_id = ? WHERE user_id = ? AND cid = ? @@ -313,7 +315,7 @@ def update_conversation_persona_id(self, user_id: str, cid: str, persona_id: str (persona_id, user_id, cid), ) - def delete_conversation(self, user_id: str, cid: str): + def delete_conversation(self, user_id: str, cid: str) -> None: self._exec_sql( """ DELETE FROM webchat_conversation WHERE user_id = ? AND cid = ? diff --git a/astrbot/core/db/po.py b/astrbot/core/db/po.py index 81649c0d7..bf0a94547 100644 --- a/astrbot/core/db/po.py +++ b/astrbot/core/db/po.py @@ -288,6 +288,43 @@ class Attachment(TimestampMixin, SQLModel, table=True): ) +class ApiKey(TimestampMixin, SQLModel, table=True): + """API keys used by external developers to access Open APIs.""" + + __tablename__: str = "api_keys" + + inner_id: int | None = Field( + primary_key=True, + sa_column_kwargs={"autoincrement": True}, + default=None, + ) + key_id: str = Field( + max_length=36, + nullable=False, + unique=True, + default_factory=lambda: str(uuid.uuid4()), + ) + name: str = Field(max_length=255, nullable=False) + key_hash: str = Field(max_length=128, nullable=False, unique=True) + key_prefix: str = Field(max_length=24, nullable=False) + scopes: list | None = Field(default=None, sa_type=JSON) + created_by: str = Field(max_length=255, nullable=False) + last_used_at: datetime | None = Field(default=None) + expires_at: datetime | None = Field(default=None) + revoked_at: datetime | None = Field(default=None) + + __table_args__ = ( + UniqueConstraint( + "key_id", + name="uix_api_key_id", + ), + UniqueConstraint( + "key_hash", + name="uix_api_key_hash", + ), + ) + + class ChatUIProject(TimestampMixin, SQLModel, table=True): """This class represents projects for organizing ChatUI conversations. diff --git a/astrbot/core/db/sqlite.py b/astrbot/core/db/sqlite.py index 153e13e8b..661c4a9f7 100644 --- a/astrbot/core/db/sqlite.py +++ b/astrbot/core/db/sqlite.py @@ -4,12 +4,13 @@ from collections.abc import Awaitable, Callable from datetime import datetime, timedelta, timezone -from sqlalchemy import CursorResult +from sqlalchemy import CursorResult, Row from sqlalchemy.ext.asyncio import AsyncSession from sqlmodel import col, delete, desc, func, or_, select, text, update from astrbot.core.db import BaseDatabase from astrbot.core.db.po import ( + ApiKey, Attachment, ChatUIProject, CommandConfig, @@ -305,7 +306,7 @@ async def update_conversation( await session.execute(query) return await self.get_conversation_by_id(cid) - async def delete_conversation(self, cid): + async def delete_conversation(self, cid) -> None: async with self.get_db() as session: session: AsyncSession async with session.begin(): @@ -461,7 +462,7 @@ async def delete_platform_message_offset( platform_id, user_id, offset_sec=86400, - ): + ) -> None: """Delete platform message history records newer than the specified offset.""" async with self.get_db() as session: session: AsyncSession @@ -573,6 +574,100 @@ async def delete_attachments(self, attachment_ids: list[str]) -> int: result = T.cast(CursorResult, await session.execute(query)) return result.rowcount + async def create_api_key( + self, + name: str, + key_hash: str, + key_prefix: str, + scopes: list[str] | None, + created_by: str, + expires_at: datetime | None = None, + ) -> ApiKey: + """Create a new API key record.""" + async with self.get_db() as session: + session: AsyncSession + async with session.begin(): + api_key = ApiKey( + name=name, + key_hash=key_hash, + key_prefix=key_prefix, + scopes=scopes, + created_by=created_by, + expires_at=expires_at, + ) + session.add(api_key) + await session.flush() + await session.refresh(api_key) + return api_key + + async def list_api_keys(self) -> list[ApiKey]: + """List all API keys.""" + async with self.get_db() as session: + session: AsyncSession + result = await session.execute( + select(ApiKey).order_by(desc(ApiKey.created_at)) + ) + return list(result.scalars().all()) + + async def get_api_key_by_id(self, key_id: str) -> ApiKey | None: + """Get an API key by key_id.""" + async with self.get_db() as session: + session: AsyncSession + result = await session.execute( + select(ApiKey).where(ApiKey.key_id == key_id) + ) + return result.scalar_one_or_none() + + async def get_active_api_key_by_hash(self, key_hash: str) -> ApiKey | None: + """Get an active API key by hash (not revoked, not expired).""" + async with self.get_db() as session: + session: AsyncSession + now = datetime.now(timezone.utc) + query = select(ApiKey).where( + ApiKey.key_hash == key_hash, + col(ApiKey.revoked_at).is_(None), + or_(col(ApiKey.expires_at).is_(None), col(ApiKey.expires_at) > now), + ) + result = await session.execute(query) + return result.scalar_one_or_none() + + async def touch_api_key(self, key_id: str) -> None: + """Update last_used_at of an API key.""" + async with self.get_db() as session: + session: AsyncSession + async with session.begin(): + await session.execute( + update(ApiKey) + .where(col(ApiKey.key_id) == key_id) + .values(last_used_at=datetime.now(timezone.utc)), + ) + + async def revoke_api_key(self, key_id: str) -> bool: + """Revoke an API key.""" + async with self.get_db() as session: + session: AsyncSession + async with session.begin(): + query = ( + update(ApiKey) + .where(col(ApiKey.key_id) == key_id) + .values(revoked_at=datetime.now(timezone.utc)) + ) + result = T.cast(CursorResult, await session.execute(query)) + return result.rowcount > 0 + + async def delete_api_key(self, key_id: str) -> bool: + """Delete an API key.""" + async with self.get_db() as session: + session: AsyncSession + async with session.begin(): + result = T.cast( + CursorResult, + await session.execute( + delete(ApiKey).where(col(ApiKey.key_id) == key_id) + ), + ) + return result.rowcount > 0 + async def insert_persona( self, persona_id, @@ -645,7 +740,7 @@ async def update_persona( await session.execute(query) return await self.get_persona_by_id(persona_id) - async def delete_persona(self, persona_id): + async def delete_persona(self, persona_id) -> None: """Delete a persona by its ID.""" async with self.get_db() as session: session: AsyncSession @@ -903,7 +998,7 @@ async def get_preferences(self, scope, scope_id=None, key=None): result = await session.execute(query) return result.scalars().all() - async def remove_preference(self, scope, scope_id, key): + async def remove_preference(self, scope, scope_id, key) -> None: """Remove a preference by scope ID and key.""" async with self.get_db() as session: session: AsyncSession @@ -917,7 +1012,7 @@ async def remove_preference(self, scope, scope_id, key): ) await session.commit() - async def clear_preferences(self, scope, scope_id): + async def clear_preferences(self, scope, scope_id) -> None: """Clear all preferences for a specific scope ID.""" async with self.get_db() as session: session: AsyncSession @@ -1195,7 +1290,7 @@ async def _inner(): result = None - def runner(): + def runner() -> None: nonlocal result result = asyncio.run(_inner()) @@ -1218,7 +1313,7 @@ async def _inner(): result = None - def runner(): + def runner() -> None: nonlocal result result = asyncio.run(_inner()) @@ -1253,7 +1348,7 @@ async def _inner(): result = None - def runner(): + def runner() -> None: nonlocal result result = asyncio.run(_inner()) @@ -1317,58 +1412,102 @@ async def get_platform_sessions_by_creator( Returns a list of dicts containing session info and project info (if session belongs to a project). """ + ( + sessions_with_projects, + _, + ) = await self.get_platform_sessions_by_creator_paginated( + creator=creator, + platform_id=platform_id, + page=page, + page_size=page_size, + exclude_project_sessions=False, + ) + return sessions_with_projects + + @staticmethod + def _build_platform_sessions_query( + creator: str, + platform_id: str | None = None, + exclude_project_sessions: bool = False, + ): + query = ( + select( + PlatformSession, + col(ChatUIProject.project_id), + col(ChatUIProject.title).label("project_title"), + col(ChatUIProject.emoji).label("project_emoji"), + ) + .outerjoin( + SessionProjectRelation, + col(PlatformSession.session_id) + == col(SessionProjectRelation.session_id), + ) + .outerjoin( + ChatUIProject, + col(SessionProjectRelation.project_id) == col(ChatUIProject.project_id), + ) + .where(col(PlatformSession.creator) == creator) + ) + + if platform_id: + query = query.where(PlatformSession.platform_id == platform_id) + if exclude_project_sessions: + query = query.where(col(ChatUIProject.project_id).is_(None)) + + return query + + @staticmethod + def _rows_to_session_dicts(rows: T.Sequence[Row[tuple]]) -> list[dict]: + sessions_with_projects = [] + for row in rows: + platform_session = row[0] + project_id = row[1] + project_title = row[2] + project_emoji = row[3] + + session_dict = { + "session": platform_session, + "project_id": project_id, + "project_title": project_title, + "project_emoji": project_emoji, + } + sessions_with_projects.append(session_dict) + + return sessions_with_projects + + async def get_platform_sessions_by_creator_paginated( + self, + creator: str, + platform_id: str | None = None, + page: int = 1, + page_size: int = 20, + exclude_project_sessions: bool = False, + ) -> tuple[list[dict], int]: + """Get paginated Platform sessions for a creator with total count.""" async with self.get_db() as session: session: AsyncSession offset = (page - 1) * page_size - # LEFT JOIN with SessionProjectRelation and ChatUIProject to get project info - query = ( - select( - PlatformSession, - col(ChatUIProject.project_id), - col(ChatUIProject.title).label("project_title"), - col(ChatUIProject.emoji).label("project_emoji"), - ) - .outerjoin( - SessionProjectRelation, - col(PlatformSession.session_id) - == col(SessionProjectRelation.session_id), - ) - .outerjoin( - ChatUIProject, - col(SessionProjectRelation.project_id) - == col(ChatUIProject.project_id), - ) - .where(col(PlatformSession.creator) == creator) + base_query = self._build_platform_sessions_query( + creator=creator, + platform_id=platform_id, + exclude_project_sessions=exclude_project_sessions, ) - if platform_id: - query = query.where(PlatformSession.platform_id == platform_id) + total_result = await session.execute( + select(func.count()).select_from(base_query.subquery()) + ) + total = int(total_result.scalar_one() or 0) - query = ( - query.order_by(desc(PlatformSession.updated_at)) + result_query = ( + base_query.order_by(desc(PlatformSession.updated_at)) .offset(offset) .limit(page_size) ) - result = await session.execute(query) - - # Convert to list of dicts with session and project info - sessions_with_projects = [] - for row in result.all(): - platform_session = row[0] - project_id = row[1] - project_title = row[2] - project_emoji = row[3] - - session_dict = { - "session": platform_session, - "project_id": project_id, - "project_title": project_title, - "project_emoji": project_emoji, - } - sessions_with_projects.append(session_dict) + result = await session.execute(result_query) - return sessions_with_projects + sessions_with_projects = self._rows_to_session_dicts(result.all()) + return sessions_with_projects, total async def update_platform_session( self, diff --git a/astrbot/core/db/vec_db/base.py b/astrbot/core/db/vec_db/base.py index 7440b6f2a..04f8903b1 100644 --- a/astrbot/core/db/vec_db/base.py +++ b/astrbot/core/db/vec_db/base.py @@ -9,7 +9,7 @@ class Result: class BaseVecDB: - async def initialize(self): + async def initialize(self) -> None: """初始化向量数据库""" @abc.abstractmethod diff --git a/astrbot/core/db/vec_db/faiss_impl/document_storage.py b/astrbot/core/db/vec_db/faiss_impl/document_storage.py index e27eb6fe8..2adae69cc 100644 --- a/astrbot/core/db/vec_db/faiss_impl/document_storage.py +++ b/astrbot/core/db/vec_db/faiss_impl/document_storage.py @@ -33,7 +33,7 @@ class Document(BaseDocModel, table=True): class DocumentStorage: - def __init__(self, db_path: str): + def __init__(self, db_path: str) -> None: self.db_path = db_path self.DATABASE_URL = f"sqlite+aiosqlite:///{db_path}" self.engine: AsyncEngine | None = None @@ -43,7 +43,7 @@ def __init__(self, db_path: str): "sqlite_init.sql", ) - async def initialize(self): + async def initialize(self) -> None: """Initialize the SQLite database and create the documents table if it doesn't exist.""" await self.connect() async with self.engine.begin() as conn: # type: ignore @@ -80,7 +80,7 @@ async def initialize(self): await conn.commit() - async def connect(self): + async def connect(self) -> None: """Connect to the SQLite database.""" if self.engine is None: self.engine = create_async_engine( @@ -211,7 +211,7 @@ async def insert_documents_batch( await session.flush() # Flush to get all IDs return [doc.id for doc in documents] # type: ignore - async def delete_document_by_doc_id(self, doc_id: str): + async def delete_document_by_doc_id(self, doc_id: str) -> None: """Delete a document by its doc_id. Args: @@ -249,7 +249,7 @@ async def get_document_by_doc_id(self, doc_id: str): return self._document_to_dict(document) return None - async def update_document_by_doc_id(self, doc_id: str, new_text: str): + async def update_document_by_doc_id(self, doc_id: str, new_text: str) -> None: """Update a document by its doc_id. Args: @@ -269,7 +269,7 @@ async def update_document_by_doc_id(self, doc_id: str, new_text: str): document.updated_at = datetime.now() session.add(document) - async def delete_documents(self, metadata_filters: dict): + async def delete_documents(self, metadata_filters: dict) -> None: """Delete documents by their metadata filters. Args: @@ -384,7 +384,7 @@ async def tuple_to_dict(self, row): "updated_at": row[5], } - async def close(self): + async def close(self) -> None: """Close the connection to the SQLite database.""" if self.engine: await self.engine.dispose() diff --git a/astrbot/core/db/vec_db/faiss_impl/embedding_storage.py b/astrbot/core/db/vec_db/faiss_impl/embedding_storage.py index 564454cb1..dc6977cf8 100644 --- a/astrbot/core/db/vec_db/faiss_impl/embedding_storage.py +++ b/astrbot/core/db/vec_db/faiss_impl/embedding_storage.py @@ -10,7 +10,7 @@ class EmbeddingStorage: - def __init__(self, dimension: int, path: str | None = None): + def __init__(self, dimension: int, path: str | None = None) -> None: self.dimension = dimension self.path = path self.index = None @@ -20,7 +20,7 @@ def __init__(self, dimension: int, path: str | None = None): base_index = faiss.IndexFlatL2(dimension) self.index = faiss.IndexIDMap(base_index) - async def insert(self, vector: np.ndarray, id: int): + async def insert(self, vector: np.ndarray, id: int) -> None: """插入向量 Args: @@ -38,7 +38,7 @@ async def insert(self, vector: np.ndarray, id: int): self.index.add_with_ids(vector.reshape(1, -1), np.array([id])) await self.save_index() - async def insert_batch(self, vectors: np.ndarray, ids: list[int]): + async def insert_batch(self, vectors: np.ndarray, ids: list[int]) -> None: """批量插入向量 Args: @@ -71,7 +71,7 @@ async def search(self, vector: np.ndarray, k: int) -> tuple: distances, indices = self.index.search(vector, k) return distances, indices - async def delete(self, ids: list[int]): + async def delete(self, ids: list[int]) -> None: """删除向量 Args: @@ -83,7 +83,7 @@ async def delete(self, ids: list[int]): self.index.remove_ids(id_array) await self.save_index() - async def save_index(self): + async def save_index(self) -> None: """保存索引 Args: diff --git a/astrbot/core/db/vec_db/faiss_impl/vec_db.py b/astrbot/core/db/vec_db/faiss_impl/vec_db.py index 14221f1e8..3fca246ef 100644 --- a/astrbot/core/db/vec_db/faiss_impl/vec_db.py +++ b/astrbot/core/db/vec_db/faiss_impl/vec_db.py @@ -20,7 +20,7 @@ def __init__( index_store_path: str, embedding_provider: EmbeddingProvider, rerank_provider: RerankProvider | None = None, - ): + ) -> None: self.doc_store_path = doc_store_path self.index_store_path = index_store_path self.embedding_provider = embedding_provider @@ -32,7 +32,7 @@ def __init__( self.embedding_provider = embedding_provider self.rerank_provider = rerank_provider - async def initialize(self): + async def initialize(self) -> None: await self.document_storage.initialize() async def insert( @@ -165,7 +165,7 @@ async def retrieve( return top_k_results - async def delete(self, doc_id: str): + async def delete(self, doc_id: str) -> None: """删除一条文档块(chunk)""" # 获得对应的 int id result = await self.document_storage.get_document_by_doc_id(doc_id) @@ -177,7 +177,7 @@ async def delete(self, doc_id: str): await self.document_storage.delete_document_by_doc_id(doc_id) await self.embedding_storage.delete([int_id]) - async def close(self): + async def close(self) -> None: await self.document_storage.close() async def count_documents(self, metadata_filter: dict | None = None) -> int: @@ -192,7 +192,7 @@ async def count_documents(self, metadata_filter: dict | None = None) -> int: ) return count - async def delete_documents(self, metadata_filters: dict): + async def delete_documents(self, metadata_filters: dict) -> None: """根据元数据过滤器删除文档""" docs = await self.document_storage.get_documents( metadata_filters=metadata_filters, diff --git a/astrbot/core/event_bus.py b/astrbot/core/event_bus.py index 0017e65fa..44cdccb83 100644 --- a/astrbot/core/event_bus.py +++ b/astrbot/core/event_bus.py @@ -28,13 +28,13 @@ def __init__( event_queue: Queue, pipeline_scheduler_mapping: dict[str, PipelineScheduler], astrbot_config_mgr: AstrBotConfigManager, - ): + ) -> None: self.event_queue = event_queue # 事件队列 # abconf uuid -> scheduler self.pipeline_scheduler_mapping = pipeline_scheduler_mapping self.astrbot_config_mgr = astrbot_config_mgr - async def dispatch(self): + async def dispatch(self) -> None: while True: event: AstrMessageEvent = await self.event_queue.get() conf_info = self.astrbot_config_mgr.get_conf_info(event.unified_msg_origin) @@ -47,7 +47,7 @@ async def dispatch(self): continue asyncio.create_task(scheduler.execute(event)) - def _print_event(self, event: AstrMessageEvent, conf_name: str): + def _print_event(self, event: AstrMessageEvent, conf_name: str) -> None: """用于记录事件信息 Args: diff --git a/astrbot/core/file_token_service.py b/astrbot/core/file_token_service.py index ea97759c1..42fbd23df 100644 --- a/astrbot/core/file_token_service.py +++ b/astrbot/core/file_token_service.py @@ -9,12 +9,12 @@ class FileTokenService: """维护一个简单的基于令牌的文件下载服务,支持超时和懒清除。""" - def __init__(self, default_timeout: float = 300): + def __init__(self, default_timeout: float = 300) -> None: self.lock = asyncio.Lock() self.staged_files = {} # token: (file_path, expire_time) self.default_timeout = default_timeout - async def _cleanup_expired_tokens(self): + async def _cleanup_expired_tokens(self) -> None: """清理过期的令牌""" now = time.time() expired_tokens = [ diff --git a/astrbot/core/initial_loader.py b/astrbot/core/initial_loader.py index f54d18641..3f836a4c4 100644 --- a/astrbot/core/initial_loader.py +++ b/astrbot/core/initial_loader.py @@ -17,13 +17,13 @@ class InitialLoader: """AstrBot 启动器,负责初始化和启动核心组件和仪表板服务器。""" - def __init__(self, db: BaseDatabase, log_broker: LogBroker): + def __init__(self, db: BaseDatabase, log_broker: LogBroker) -> None: self.db = db self.logger = logger self.log_broker = log_broker self.webui_dir: str | None = None - async def start(self): + async def start(self) -> None: core_lifecycle = AstrBotCoreLifecycle(self.log_broker, self.db) try: diff --git a/astrbot/core/knowledge_base/chunking/fixed_size.py b/astrbot/core/knowledge_base/chunking/fixed_size.py index 5439f070f..c0eb17865 100644 --- a/astrbot/core/knowledge_base/chunking/fixed_size.py +++ b/astrbot/core/knowledge_base/chunking/fixed_size.py @@ -12,7 +12,7 @@ class FixedSizeChunker(BaseChunker): 按照固定的字符数分块,并支持块之间的重叠。 """ - def __init__(self, chunk_size: int = 512, chunk_overlap: int = 50): + def __init__(self, chunk_size: int = 512, chunk_overlap: int = 50) -> None: """初始化分块器 Args: diff --git a/astrbot/core/knowledge_base/chunking/recursive.py b/astrbot/core/knowledge_base/chunking/recursive.py index 3882b0871..e27ffbd1b 100644 --- a/astrbot/core/knowledge_base/chunking/recursive.py +++ b/astrbot/core/knowledge_base/chunking/recursive.py @@ -11,7 +11,7 @@ def __init__( length_function: Callable[[str], int] = len, is_separator_regex: bool = False, separators: list[str] | None = None, - ): + ) -> None: """初始化递归字符文本分割器 Args: diff --git a/astrbot/core/knowledge_base/kb_db_sqlite.py b/astrbot/core/knowledge_base/kb_db_sqlite.py index 5e1db842f..4b9dcf7dd 100644 --- a/astrbot/core/knowledge_base/kb_db_sqlite.py +++ b/astrbot/core/knowledge_base/kb_db_sqlite.py @@ -13,16 +13,19 @@ KBMedia, KnowledgeBase, ) +from astrbot.core.utils.astrbot_path import get_astrbot_knowledge_base_path class KBSQLiteDatabase: - def __init__(self, db_path: str = "data/knowledge_base/kb.db") -> None: + def __init__(self, db_path: str | None = None) -> None: """初始化知识库数据库 Args: - db_path: 数据库文件路径, 默认为 data/knowledge_base/kb.db + db_path: 数据库文件路径, 默认位于 AstrBot 数据目录下的 knowledge_base/kb.db """ + if db_path is None: + db_path = str(Path(get_astrbot_knowledge_base_path()) / "kb.db") self.db_path = db_path self.DATABASE_URL = f"sqlite+aiosqlite:///{db_path}" self.inited = False @@ -253,7 +256,47 @@ async def get_document_with_metadata(self, doc_id: str) -> dict | None: "knowledge_base": row[1], } - async def delete_document_by_id(self, doc_id: str, vec_db: FaissVecDB): + async def get_documents_with_metadata_batch( + self, doc_ids: set[str] + ) -> dict[str, dict]: + """批量获取文档及其所属知识库元数据 + + Args: + doc_ids: 文档 ID 集合 + + Returns: + dict: doc_id -> {"document": KBDocument, "knowledge_base": KnowledgeBase} + + """ + if not doc_ids: + return {} + + metadata_map: dict[str, dict] = {} + # SQLite 参数上限为 999,分片查询避免超限 + chunk_size = 900 + doc_id_list = list(doc_ids) + + async with self.get_db() as session: + for i in range(0, len(doc_id_list), chunk_size): + chunk = doc_id_list[i : i + chunk_size] + stmt = ( + select(KBDocument, KnowledgeBase) + .join( + KnowledgeBase, + col(KBDocument.kb_id) == col(KnowledgeBase.kb_id), + ) + .where(col(KBDocument.doc_id).in_(chunk)) + ) + result = await session.execute(stmt) + for row in result.all(): + metadata_map[row[0].doc_id] = { + "document": row[0], + "knowledge_base": row[1], + } + + return metadata_map + + async def delete_document_by_id(self, doc_id: str, vec_db: FaissVecDB) -> None: """删除单个文档及其相关数据""" # 在知识库表中删除 async with self.get_db() as session, session.begin(): diff --git a/astrbot/core/knowledge_base/kb_helper.py b/astrbot/core/knowledge_base/kb_helper.py index 4adfb60b8..1e9127d72 100644 --- a/astrbot/core/knowledge_base/kb_helper.py +++ b/astrbot/core/knowledge_base/kb_helper.py @@ -31,7 +31,7 @@ class RateLimiter: """一个简单的速率限制器""" - def __init__(self, max_rpm: int): + def __init__(self, max_rpm: int) -> None: self.max_per_minute = max_rpm self.interval = 60.0 / max_rpm if max_rpm > 0 else 0 self.last_call_time = 0 @@ -116,7 +116,7 @@ def __init__( provider_manager: ProviderManager, kb_root_dir: str, chunker: BaseChunker, - ): + ) -> None: self.kb_db = kb_db self.kb = kb self.prov_mgr = provider_manager @@ -130,7 +130,7 @@ def __init__( self.kb_medias_dir.mkdir(parents=True, exist_ok=True) self.kb_files_dir.mkdir(parents=True, exist_ok=True) - async def initialize(self): + async def initialize(self) -> None: await self._ensure_vec_db() async def get_ep(self) -> EmbeddingProvider: @@ -174,7 +174,7 @@ async def _ensure_vec_db(self) -> FaissVecDB: self.vec_db = vec_db return vec_db - async def delete_vec_db(self): + async def delete_vec_db(self) -> None: """删除知识库的向量数据库和所有相关文件""" import shutil @@ -182,7 +182,7 @@ async def delete_vec_db(self): if self.kb_dir.exists(): shutil.rmtree(self.kb_dir) - async def terminate(self): + async def terminate(self) -> None: if self.vec_db: await self.vec_db.close() @@ -293,7 +293,7 @@ async def upload_document( await progress_callback("chunking", 100, 100) # 阶段3: 生成向量(带进度回调) - async def embedding_progress_callback(current, total): + async def embedding_progress_callback(current, total) -> None: if progress_callback: await progress_callback("embedding", current, total) @@ -360,7 +360,7 @@ async def get_document(self, doc_id: str) -> KBDocument | None: doc = await self.kb_db.get_document_by_id(doc_id) return doc - async def delete_document(self, doc_id: str): + async def delete_document(self, doc_id: str) -> None: """删除单个文档及其相关数据""" await self.kb_db.delete_document_by_id( doc_id=doc_id, @@ -372,7 +372,7 @@ async def delete_document(self, doc_id: str): ) await self.refresh_kb() - async def delete_chunk(self, chunk_id: str, doc_id: str): + async def delete_chunk(self, chunk_id: str, doc_id: str) -> None: """删除单个文本块及其相关数据""" vec_db: FaissVecDB = self.vec_db # type: ignore await vec_db.delete(chunk_id) @@ -383,7 +383,7 @@ async def delete_chunk(self, chunk_id: str, doc_id: str): await self.refresh_kb() await self.refresh_document(doc_id) - async def refresh_kb(self): + async def refresh_kb(self) -> None: if self.kb: kb = await self.kb_db.get_kb_by_id(self.kb.kb_id) if kb: diff --git a/astrbot/core/knowledge_base/kb_mgr.py b/astrbot/core/knowledge_base/kb_mgr.py index b085924ca..f26409e56 100644 --- a/astrbot/core/knowledge_base/kb_mgr.py +++ b/astrbot/core/knowledge_base/kb_mgr.py @@ -3,6 +3,7 @@ from astrbot.core import logger from astrbot.core.provider.manager import ProviderManager +from astrbot.core.utils.astrbot_path import get_astrbot_knowledge_base_path # from .chunking.fixed_size import FixedSizeChunker from .chunking.recursive import RecursiveCharacterChunker @@ -13,7 +14,7 @@ from .retrieval.rank_fusion import RankFusion from .retrieval.sparse_retriever import SparseRetriever -FILES_PATH = "data/knowledge_base" +FILES_PATH = get_astrbot_knowledge_base_path() DB_PATH = Path(FILES_PATH) / "kb.db" """Knowledge Base storage root directory""" CHUNKER = RecursiveCharacterChunker() @@ -26,14 +27,14 @@ class KnowledgeBaseManager: def __init__( self, provider_manager: ProviderManager, - ): - Path(DB_PATH).parent.mkdir(parents=True, exist_ok=True) + ) -> None: + DB_PATH.parent.mkdir(parents=True, exist_ok=True) self.provider_manager = provider_manager self._session_deleted_callback_registered = False self.kb_insts: dict[str, KBHelper] = {} - async def initialize(self): + async def initialize(self) -> None: """初始化知识库模块""" try: logger.info("正在初始化知识库模块...") @@ -58,13 +59,13 @@ async def initialize(self): logger.error(f"知识库模块初始化失败: {e}") logger.error(traceback.format_exc()) - async def _init_kb_database(self): + async def _init_kb_database(self) -> None: self.kb_db = KBSQLiteDatabase(DB_PATH.as_posix()) await self.kb_db.initialize() await self.kb_db.migrate_to_v1() logger.info(f"KnowledgeBase database initialized: {DB_PATH}") - async def load_kbs(self): + async def load_kbs(self) -> None: """加载所有知识库实例""" kb_records = await self.kb_db.list_kbs() for record in kb_records: @@ -275,7 +276,7 @@ def _format_context(self, results: list[RetrievalResult]) -> str: return "\n".join(lines) - async def terminate(self): + async def terminate(self) -> None: """终止所有知识库实例,关闭数据库连接""" for kb_id, kb_helper in self.kb_insts.items(): try: diff --git a/astrbot/core/knowledge_base/parsers/url_parser.py b/astrbot/core/knowledge_base/parsers/url_parser.py index f68e2e0c4..2867164a9 100644 --- a/astrbot/core/knowledge_base/parsers/url_parser.py +++ b/astrbot/core/knowledge_base/parsers/url_parser.py @@ -6,7 +6,7 @@ class URLExtractor: """URL 内容提取器,封装了 Tavily API 调用和密钥管理""" - def __init__(self, tavily_keys: list[str]): + def __init__(self, tavily_keys: list[str]) -> None: """ 初始化 URL 提取器 diff --git a/astrbot/core/knowledge_base/retrieval/manager.py b/astrbot/core/knowledge_base/retrieval/manager.py index 746406e90..1244e18af 100644 --- a/astrbot/core/knowledge_base/retrieval/manager.py +++ b/astrbot/core/knowledge_base/retrieval/manager.py @@ -44,7 +44,7 @@ def __init__( sparse_retriever: SparseRetriever, rank_fusion: RankFusion, kb_db: KBSQLiteDatabase, - ): + ) -> None: """初始化检索管理器 Args: @@ -142,10 +142,13 @@ async def retrieve( f"Rank fusion took {time_end - time_start:.2f}s and returned {len(fused_results)} results.", ) - # 4. 转换为 RetrievalResult (获取元数据) + # 4. 转换为 RetrievalResult (批量获取元数据) + doc_ids = {fr.doc_id for fr in fused_results} + metadata_map = await self.kb_db.get_documents_with_metadata_batch(doc_ids) + retrieval_results = [] for fr in fused_results: - metadata_dict = await self.kb_db.get_document_with_metadata(fr.doc_id) + metadata_dict = metadata_map.get(fr.doc_id) if metadata_dict: retrieval_results.append( RetrievalResult( diff --git a/astrbot/core/knowledge_base/retrieval/rank_fusion.py b/astrbot/core/knowledge_base/retrieval/rank_fusion.py index 26203f94b..40afd9748 100644 --- a/astrbot/core/knowledge_base/retrieval/rank_fusion.py +++ b/astrbot/core/knowledge_base/retrieval/rank_fusion.py @@ -31,7 +31,7 @@ class RankFusion: - 使用 Reciprocal Rank Fusion (RRF) 算法 """ - def __init__(self, kb_db: KBSQLiteDatabase, k: int = 60): + def __init__(self, kb_db: KBSQLiteDatabase, k: int = 60) -> None: """初始化结果融合器 Args: diff --git a/astrbot/core/knowledge_base/retrieval/sparse_retriever.py b/astrbot/core/knowledge_base/retrieval/sparse_retriever.py index ea5da1c9e..d453251d1 100644 --- a/astrbot/core/knowledge_base/retrieval/sparse_retriever.py +++ b/astrbot/core/knowledge_base/retrieval/sparse_retriever.py @@ -34,7 +34,7 @@ class SparseRetriever: - 使用 BM25 算法计算相关度 """ - def __init__(self, kb_db: KBSQLiteDatabase): + def __init__(self, kb_db: KBSQLiteDatabase) -> None: """初始化稀疏检索器 Args: diff --git a/astrbot/core/log.py b/astrbot/core/log.py index 49eeb30e4..66a2f3154 100644 --- a/astrbot/core/log.py +++ b/astrbot/core/log.py @@ -1,24 +1,4 @@ -"""日志系统, 用于支持核心组件和插件的日志记录, 提供了日志订阅功能 - -const: - CACHED_SIZE: 日志缓存大小, 用于限制缓存的日志数量 - log_color_config: 日志颜色配置, 定义了不同日志级别的颜色 - -class: - LogBroker: 日志代理类, 用于缓存和分发日志消息 - LogQueueHandler: 日志处理器, 用于将日志消息发送到 LogBroker - LogManager: 日志管理器, 用于创建和配置日志记录器 - -function: - is_plugin_path: 检查文件路径是否来自插件目录 - get_short_level_name: 将日志级别名称转换为四个字母的缩写 - -工作流程: -1. 通过 LogManager.GetLogger() 获取日志器, 配置了控制台输出和多个格式化过滤器 -2. 通过 set_queue_handler() 设置日志处理器, 将日志消息发送到 LogBroker -3. logBroker 维护一个订阅者列表, 负责将日志分发给所有订阅者 -4. 订阅者可以使用 register() 方法注册到 LogBroker, 订阅日志流 -""" +"""日志系统,统一将标准 logging 输出转发到 loguru。""" import asyncio import logging @@ -27,54 +7,59 @@ import time from asyncio import Queue from collections import deque -from logging.handlers import RotatingFileHandler +from typing import TYPE_CHECKING -import colorlog +from loguru import logger as _raw_loguru_logger from astrbot.core.config.default import VERSION from astrbot.core.utils.astrbot_path import get_astrbot_data_path -# 日志缓存大小 CACHED_SIZE = 500 -# 日志颜色配置 -log_color_config = { - "DEBUG": "green", - "INFO": "bold_cyan", - "WARNING": "bold_yellow", - "ERROR": "red", - "CRITICAL": "bold_red", - "RESET": "reset", - "asctime": "green", -} +if TYPE_CHECKING: + from loguru import Record -def is_plugin_path(pathname): - """检查文件路径是否来自插件目录 - Args: - pathname (str): 文件路径 +class _RecordEnricherFilter(logging.Filter): + """为 logging.LogRecord 注入 AstrBot 日志字段。""" - Returns: - bool: 如果路径来自插件目录,则返回 True,否则返回 False + def filter(self, record: logging.LogRecord) -> bool: + record.plugin_tag = "[Plug]" if _is_plugin_path(record.pathname) else "[Core]" + record.short_levelname = _get_short_level_name(record.levelname) + record.astrbot_version_tag = ( + f" [v{VERSION}]" if record.levelno >= logging.WARNING else "" + ) + record.source_file = _build_source_file(record.pathname) + record.source_line = record.lineno + record.is_trace = record.name == "astrbot.trace" + return True - """ - if not pathname: - return False - norm_path = os.path.normpath(pathname) - return ("data/plugins" in norm_path) or ("astrbot/builtin_stars/" in norm_path) +class _QueueAnsiColorFilter(logging.Filter): + """Attach ANSI color prefix for WebUI console rendering.""" + _LEVEL_COLOR = { + "DEBUG": "\u001b[1;34m", + "INFO": "\u001b[1;36m", + "WARNING": "\u001b[1;33m", + "ERROR": "\u001b[31m", + "CRITICAL": "\u001b[1;31m", + } -def get_short_level_name(level_name): - """将日志级别名称转换为四个字母的缩写 + def filter(self, record: logging.LogRecord) -> bool: + record.ansi_prefix = self._LEVEL_COLOR.get(record.levelname, "\u001b[0m") + record.ansi_reset = "\u001b[0m" + return True - Args: - level_name (str): 日志级别名称, 如 "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL" - Returns: - str: 四个字母的日志级别缩写 +def _is_plugin_path(pathname: str | None) -> bool: + if not pathname: + return False + norm_path = os.path.normpath(pathname) + return ("data/plugins" in norm_path) or ("astrbot/builtin_stars/" in norm_path) - """ + +def _get_short_level_name(level_name: str) -> str: level_map = { "DEBUG": "DBUG", "INFO": "INFO", @@ -85,44 +70,75 @@ def get_short_level_name(level_name): return level_map.get(level_name, level_name[:4].upper()) -class LogBroker: - """日志代理类, 用于缓存和分发日志消息 - - 发布-订阅模式 - """ +def _build_source_file(pathname: str | None) -> str: + if not pathname: + return "unknown" + dirname = os.path.dirname(pathname) + return ( + os.path.basename(dirname) + "." + os.path.basename(pathname).replace(".py", "") + ) + + +def _patch_record(record: "Record") -> None: + extra = record["extra"] + extra.setdefault("plugin_tag", "[Core]") + extra.setdefault("short_levelname", _get_short_level_name(record["level"].name)) + level_no = record["level"].no + extra.setdefault("astrbot_version_tag", f" [v{VERSION}]" if level_no >= 30 else "") + extra.setdefault("source_file", _build_source_file(record["file"].path)) + extra.setdefault("source_line", record["line"]) + extra.setdefault("is_trace", False) + + +_loguru = _raw_loguru_logger.patch(_patch_record) + + +class _LoguruInterceptHandler(logging.Handler): + """将 logging 记录转发到 loguru。""" + + def emit(self, record: logging.LogRecord) -> None: + try: + level: str | int = _loguru.level(record.levelname).name + except ValueError: + level = record.levelno + + payload = { + "plugin_tag": getattr(record, "plugin_tag", "[Core]"), + "short_levelname": getattr( + record, + "short_levelname", + _get_short_level_name(record.levelname), + ), + "astrbot_version_tag": getattr(record, "astrbot_version_tag", ""), + "source_file": getattr( + record, "source_file", _build_source_file(record.pathname) + ), + "source_line": getattr(record, "source_line", record.lineno), + "is_trace": getattr(record, "is_trace", record.name == "astrbot.trace"), + } + + _loguru.bind(**payload).opt(exception=record.exc_info).log( + level, + record.getMessage(), + ) - def __init__(self): - self.log_cache = deque(maxlen=CACHED_SIZE) # 环形缓冲区, 保存最近的日志 - self.subscribers: list[Queue] = [] # 订阅者列表 - def register(self) -> Queue: - """注册新的订阅者, 并给每个订阅者返回一个带有日志缓存的队列 +class LogBroker: + """日志代理类,用于缓存和分发日志消息。""" - Returns: - Queue: 订阅者的队列, 可用于接收日志消息 + def __init__(self) -> None: + self.log_cache = deque(maxlen=CACHED_SIZE) + self.subscribers: list[Queue] = [] - """ + def register(self) -> Queue: q = Queue(maxsize=CACHED_SIZE + 10) self.subscribers.append(q) return q - def unregister(self, q: Queue): - """取消订阅 - - Args: - q (Queue): 需要取消订阅的队列 - - """ + def unregister(self, q: Queue) -> None: self.subscribers.remove(q) - def publish(self, log_entry: dict): - """发布新日志到所有订阅者, 使用非阻塞方式投递, 避免一个订阅者阻塞整个系统 - - Args: - log_entry (dict): 日志消息, 包含日志级别和日志内容. - example: {"level": "INFO", "data": "This is a log message.", "time": "2023-10-01 12:00:00"} - - """ + def publish(self, log_entry: dict) -> None: self.log_cache.append(log_entry) for q in self.subscribers: try: @@ -132,23 +148,13 @@ def publish(self, log_entry: dict): class LogQueueHandler(logging.Handler): - """日志处理器, 用于将日志消息发送到 LogBroker - - 继承自 logging.Handler - """ + """日志处理器,用于将日志消息发送到 LogBroker。""" - def __init__(self, log_broker: LogBroker): + def __init__(self, log_broker: LogBroker) -> None: super().__init__() self.log_broker = log_broker - def emit(self, record): - """日志处理的入口方法, 接受一个日志记录, 转换为字符串后由 LogBroker 发布 - 这个方法会在每次日志记录时被调用 - - Args: - record (logging.LogRecord): 日志记录对象, 包含日志信息 - - """ + def emit(self, record: logging.LogRecord) -> None: log_entry = self.format(record) self.log_broker.publish( { @@ -160,117 +166,16 @@ def emit(self, record): class LogManager: - """日志管理器, 用于创建和配置日志记录器 - - 提供了获取默认日志记录器logger和设置队列处理器的方法 - """ - - _FILE_HANDLER_FLAG = "_astrbot_file_handler" - _TRACE_FILE_HANDLER_FLAG = "_astrbot_trace_file_handler" - - @classmethod - def GetLogger(cls, log_name: str = "default"): - """获取指定名称的日志记录器logger - - Args: - log_name (str): 日志记录器的名称, 默认为 "default" - - Returns: - logging.Logger: 返回配置好的日志记录器 - - """ - logger = logging.getLogger(log_name) - # 检查该logger或父级logger是否已经有处理器, 如果已经有处理器, 直接返回该logger, 避免重复配置 - if logger.hasHandlers(): - return logger - # 如果logger没有处理器 - console_handler = logging.StreamHandler( - sys.stdout, - ) # 创建一个StreamHandler用于控制台输出 - console_handler.setLevel( - logging.DEBUG, - ) # 将日志级别设置为DEBUG(最低级别, 显示所有日志), *如果插件没有设置级别, 默认为DEBUG - - # 创建彩色日志格式化器, 输出日志格式为: [时间] [插件标签] [日志级别] [文件名:行号]: 日志消息 - console_formatter = colorlog.ColoredFormatter( - fmt="%(log_color)s [%(asctime)s] %(plugin_tag)s [%(short_levelname)-4s]%(astrbot_version_tag)s [%(filename)s:%(lineno)d]: %(message)s %(reset)s", - datefmt="%H:%M:%S", - log_colors=log_color_config, - ) - - class PluginFilter(logging.Filter): - """插件过滤器类, 用于标记日志来源是插件还是核心组件""" - - def filter(self, record): - record.plugin_tag = ( - "[Plug]" if is_plugin_path(record.pathname) else "[Core]" - ) - return True - - class FileNameFilter(logging.Filter): - """文件名过滤器类, 用于修改日志记录的文件名格式 - 例如: 将文件路径 /path/to/file.py 转换为 file. 格式 - """ - - # 获取这个文件和父文件夹的名字:. 并且去除 .py - def filter(self, record): - dirname = os.path.dirname(record.pathname) - record.filename = ( - os.path.basename(dirname) - + "." - + os.path.basename(record.pathname).replace(".py", "") - ) - return True - - class LevelNameFilter(logging.Filter): - """短日志级别名称过滤器类, 用于将日志级别名称转换为四个字母的缩写""" - - # 添加短日志级别名称 - def filter(self, record): - record.short_levelname = get_short_level_name(record.levelname) - return True - - class AstrBotVersionTagFilter(logging.Filter): - """在 WARNING 及以上级别日志后追加当前 AstrBot 版本号。""" - - def filter(self, record): - if record.levelno >= logging.WARNING: - record.astrbot_version_tag = f" [v{VERSION}]" - else: - record.astrbot_version_tag = "" - return True - - console_handler.setFormatter(console_formatter) # 设置处理器的格式化器 - logger.addFilter(PluginFilter()) # 添加插件过滤器 - logger.addFilter(FileNameFilter()) # 添加文件名过滤器 - logger.addFilter(LevelNameFilter()) # 添加级别名称过滤器 - logger.addFilter(AstrBotVersionTagFilter()) # 追加版本号(WARNING 及以上) - logger.setLevel(logging.DEBUG) # 设置日志级别为DEBUG - logger.addHandler(console_handler) # 添加处理器到logger - - return logger - - @classmethod - def set_queue_handler(cls, logger: logging.Logger, log_broker: LogBroker): - """设置队列处理器, 用于将日志消息发送到 LogBroker - - Args: - logger (logging.Logger): 日志记录器 - log_broker (LogBroker): 日志代理类, 用于缓存和分发日志消息 - - """ - handler = LogQueueHandler(log_broker) - handler.setLevel(logging.DEBUG) - if logger.handlers: - handler.setFormatter(logger.handlers[0].formatter) - else: - # 为队列处理器设置相同格式的formatter - handler.setFormatter( - logging.Formatter( - "[%(asctime)s] [%(short_levelname)s] %(plugin_tag)s[%(filename)s:%(lineno)d]: %(message)s", - ), - ) - logger.addHandler(handler) + _LOGGER_HANDLER_FLAG = "_astrbot_loguru_handler" + _ENRICH_FILTER_FLAG = "_astrbot_enrich_filter" + + _configured = False + _console_sink_id: int | None = None + _file_sink_id: int | None = None + _trace_sink_id: int | None = None + _NOISY_LOGGER_LEVELS: dict[str, int] = { + "aiosqlite": logging.WARNING, + } @classmethod def _default_log_path(cls) -> str: @@ -285,79 +190,147 @@ def _resolve_log_path(cls, configured_path: str | None) -> str: return os.path.join(get_astrbot_data_path(), configured_path) @classmethod - def _get_file_handlers(cls, logger: logging.Logger) -> list[logging.Handler]: - return [ - handler - for handler in logger.handlers - if getattr(handler, cls._FILE_HANDLER_FLAG, False) - ] + def _setup_loguru(cls) -> None: + if cls._configured: + return + + _loguru.remove() + cls._console_sink_id = _loguru.add( + sys.stdout, + level="DEBUG", + colorize=True, + filter=lambda record: not record["extra"].get("is_trace", False), + format=( + "[{time:HH:mm:ss.SSS}] {extra[plugin_tag]} " + "[{extra[short_levelname]}]{extra[astrbot_version_tag]} " + "[{extra[source_file]}:{extra[source_line]}]: {message}" + ), + ) + cls._configured = True + + @classmethod + def _setup_root_bridge(cls) -> None: + root_logger = logging.getLogger() + + has_handler = any( + getattr(handler, cls._LOGGER_HANDLER_FLAG, False) + for handler in root_logger.handlers + ) + if not has_handler: + handler = _LoguruInterceptHandler() + setattr(handler, cls._LOGGER_HANDLER_FLAG, True) + root_logger.addHandler(handler) + root_logger.setLevel(logging.DEBUG) + for name, level in cls._NOISY_LOGGER_LEVELS.items(): + logging.getLogger(name).setLevel(level) @classmethod - def _get_trace_file_handlers(cls, logger: logging.Logger) -> list[logging.Handler]: - return [ - handler + def _ensure_logger_enricher_filter(cls, logger: logging.Logger) -> None: + has_filter = any( + getattr(existing_filter, cls._ENRICH_FILTER_FLAG, False) + for existing_filter in logger.filters + ) + if not has_filter: + enrich_filter = _RecordEnricherFilter() + setattr(enrich_filter, cls._ENRICH_FILTER_FLAG, True) + logger.addFilter(enrich_filter) + + @classmethod + def _ensure_logger_intercept_handler(cls, logger: logging.Logger) -> None: + has_handler = any( + getattr(handler, cls._LOGGER_HANDLER_FLAG, False) for handler in logger.handlers - if getattr(handler, cls._TRACE_FILE_HANDLER_FLAG, False) - ] + ) + if not has_handler: + handler = _LoguruInterceptHandler() + setattr(handler, cls._LOGGER_HANDLER_FLAG, True) + logger.addHandler(handler) @classmethod - def _remove_file_handlers(cls, logger: logging.Logger): - for handler in cls._get_file_handlers(logger): - logger.removeHandler(handler) - try: - handler.close() - except Exception: - pass + def GetLogger(cls, log_name: str = "default") -> logging.Logger: + cls._setup_loguru() + cls._setup_root_bridge() + + logger = logging.getLogger(log_name) + cls._ensure_logger_enricher_filter(logger) + cls._ensure_logger_intercept_handler(logger) + logger.setLevel(logging.DEBUG) + logger.propagate = False + return logger @classmethod - def _remove_trace_file_handlers(cls, logger: logging.Logger): - for handler in cls._get_trace_file_handlers(logger): - logger.removeHandler(handler) - try: - handler.close() - except Exception: - pass + def set_queue_handler(cls, logger: logging.Logger, log_broker: LogBroker) -> None: + cls._ensure_logger_enricher_filter(logger) + + for handler in logger.handlers: + if isinstance(handler, LogQueueHandler): + return + + handler = LogQueueHandler(log_broker) + handler.setLevel(logging.DEBUG) + handler.addFilter(_QueueAnsiColorFilter()) + handler.setFormatter( + logging.Formatter( + "%(ansi_prefix)s[%(asctime)s.%(msecs)03d] %(plugin_tag)s [%(short_levelname)s]%(astrbot_version_tag)s " + "[%(source_file)s:%(source_line)d]: %(message)s%(ansi_reset)s", + datefmt="%Y-%m-%d %H:%M:%S", + ), + ) + logger.addHandler(handler) + + @classmethod + def _remove_sink(cls, sink_id: int | None) -> None: + if sink_id is None: + return + try: + _loguru.remove(sink_id) + except ValueError: + pass @classmethod - def _add_file_handler( + def _add_file_sink( cls, - logger: logging.Logger, + *, file_path: str, - max_mb: int | None = None, - backup_count: int = 3, - trace: bool = False, - ): + level: int, + max_mb: int | None, + backup_count: int, + trace: bool, + ) -> int: os.makedirs(os.path.dirname(file_path) or ".", exist_ok=True) - max_bytes = 0 - if max_mb and max_mb > 0: - max_bytes = max_mb * 1024 * 1024 - if max_bytes > 0: - file_handler = RotatingFileHandler( + rotation = f"{max_mb} MB" if max_mb and max_mb > 0 else None + retention = ( + backup_count if rotation and backup_count and backup_count > 0 else None + ) + if trace: + return _loguru.add( file_path, - maxBytes=max_bytes, - backupCount=backup_count, + level="INFO", + format="[{time:YYYY-MM-DD HH:mm:ss.SSS}] {message}", encoding="utf-8", + rotation=rotation, + retention=retention, + enqueue=True, + filter=lambda record: record["extra"].get("is_trace", False), ) - else: - file_handler = logging.FileHandler(file_path, encoding="utf-8") - file_handler.setLevel(logger.level) - if trace: - formatter = logging.Formatter( - "[%(asctime)s] %(message)s", - datefmt="%Y-%m-%d %H:%M:%S", - ) - else: - formatter = logging.Formatter( - "[%(asctime)s] %(plugin_tag)s [%(short_levelname)s]%(astrbot_version_tag)s [%(filename)s:%(lineno)d]: %(message)s", - datefmt="%Y-%m-%d %H:%M:%S", - ) - file_handler.setFormatter(formatter) - setattr( - file_handler, - cls._TRACE_FILE_HANDLER_FLAG if trace else cls._FILE_HANDLER_FLAG, - True, + + logging_level_name = logging.getLevelName(level) + if isinstance(logging_level_name, int): + logging_level_name = "INFO" + return _loguru.add( + file_path, + level=logging_level_name, + format=( + "[{time:YYYY-MM-DD HH:mm:ss.SSS}] {extra[plugin_tag]} " + "[{extra[short_levelname]}]{extra[astrbot_version_tag]} " + "[{extra[source_file]}:{extra[source_line]}]: {message}" + ), + encoding="utf-8", + rotation=rotation, + retention=retention, + enqueue=True, + filter=lambda record: not record["extra"].get("is_trace", False), ) - logger.addHandler(file_handler) @classmethod def configure_logger( @@ -365,14 +338,7 @@ def configure_logger( logger: logging.Logger, config: dict | None, override_level: str | None = None, - ): - """根据配置设置日志级别和文件日志。 - - Args: - logger: 需要配置的 logger - config: 配置字典 - override_level: 若提供,将覆盖配置中的日志级别 - """ + ) -> None: if not config: return @@ -383,7 +349,6 @@ def configure_logger( except Exception: logger.setLevel(logging.INFO) - # 兼容旧版嵌套配置 if "log_file" in config: file_conf = config.get("log_file") or {} enable_file = bool(file_conf.get("enable", False)) @@ -394,27 +359,25 @@ def configure_logger( file_path = config.get("log_file_path") max_mb = config.get("log_file_max_mb") - file_path = cls._resolve_log_path(file_path) + cls._remove_sink(cls._file_sink_id) + cls._file_sink_id = None - existing = cls._get_file_handlers(logger) if not enable_file: - cls._remove_file_handlers(logger) return - # 如果已有文件处理器且路径一致,则仅同步级别 - if existing: - handler = existing[0] - base = getattr(handler, "baseFilename", "") - if base and os.path.abspath(base) == os.path.abspath(file_path): - handler.setLevel(logger.level) - return - cls._remove_file_handlers(logger) - - cls._add_file_handler(logger, file_path, max_mb=max_mb) + try: + cls._file_sink_id = cls._add_file_sink( + file_path=cls._resolve_log_path(file_path), + level=logger.level, + max_mb=max_mb, + backup_count=3, + trace=False, + ) + except Exception as e: + logger.error(f"Failed to add file sink: {e}") @classmethod - def configure_trace_logger(cls, config: dict | None): - """为 trace 事件配置独立的文件日志,不向控制台输出。""" + def configure_trace_logger(cls, config: dict | None) -> None: if not config: return @@ -429,28 +392,22 @@ def configure_trace_logger(cls, config: dict | None): path = path or legacy.get("trace_path") max_mb = max_mb or legacy.get("trace_max_mb") - if not enable: - trace_logger = logging.getLogger("astrbot.trace") - cls._remove_trace_file_handlers(trace_logger) - return - - file_path = cls._resolve_log_path(path or "logs/astrbot.trace.log") trace_logger = logging.getLogger("astrbot.trace") + cls._ensure_logger_enricher_filter(trace_logger) + cls._ensure_logger_intercept_handler(trace_logger) trace_logger.setLevel(logging.INFO) trace_logger.propagate = False - existing = cls._get_trace_file_handlers(trace_logger) - if existing: - handler = existing[0] - base = getattr(handler, "baseFilename", "") - if base and os.path.abspath(base) == os.path.abspath(file_path): - handler.setLevel(trace_logger.level) - return - cls._remove_trace_file_handlers(trace_logger) + cls._remove_sink(cls._trace_sink_id) + cls._trace_sink_id = None - cls._add_file_handler( - trace_logger, - file_path, + if not enable: + return + + cls._trace_sink_id = cls._add_file_sink( + file_path=cls._resolve_log_path(path or "logs/astrbot.trace.log"), + level=logging.INFO, max_mb=max_mb, + backup_count=3, trace=True, ) diff --git a/astrbot/core/message/components.py b/astrbot/core/message/components.py index 280276089..15265c38d 100644 --- a/astrbot/core/message/components.py +++ b/astrbot/core/message/components.py @@ -25,13 +25,17 @@ import base64 import json import os +import sys import uuid from enum import Enum -from pydantic.v1 import BaseModel +if sys.version_info >= (3, 14): + from pydantic import BaseModel +else: + from pydantic.v1 import BaseModel from astrbot.core import astrbot_config, file_token_service, logger -from astrbot.core.utils.astrbot_path import get_astrbot_data_path +from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from astrbot.core.utils.io import download_file, download_image_by_url, file_to_base64 @@ -66,7 +70,7 @@ class ComponentType(str, Enum): class BaseMessageComponent(BaseModel): type: ComponentType - def __init__(self, **kwargs): + def __init__(self, **kwargs) -> None: super().__init__(**kwargs) def toDict(self): @@ -85,11 +89,11 @@ async def to_dict(self) -> dict: class Plain(BaseMessageComponent): - type = ComponentType.Plain + type: ComponentType = ComponentType.Plain text: str convert: bool | None = True - def __init__(self, text: str, convert: bool = True, **_): + def __init__(self, text: str, convert: bool = True, **_) -> None: super().__init__(text=text, convert=convert, **_) def toDict(self): @@ -100,25 +104,27 @@ async def to_dict(self): class Face(BaseMessageComponent): - type = ComponentType.Face + type: ComponentType = ComponentType.Face id: int - def __init__(self, **_): + def __init__(self, **_) -> None: super().__init__(**_) class Record(BaseMessageComponent): - type = ComponentType.Record + type: ComponentType = ComponentType.Record file: str | None = "" magic: bool | None = False url: str | None = "" cache: bool | None = True proxy: bool | None = True timeout: int | None = 0 + # Original text content (e.g. TTS source text), used as caption in fallback scenarios + text: str | None = None # 额外 path: str | None - def __init__(self, file: str | None, **_): + def __init__(self, file: str | None, **_) -> None: for k in _: if k == "url": pass @@ -156,8 +162,9 @@ async def convert_to_file_path(self) -> str: if self.file.startswith("base64://"): bs64_data = self.file.removeprefix("base64://") image_bytes = base64.b64decode(bs64_data) - temp_dir = os.path.join(get_astrbot_data_path(), "temp") - file_path = os.path.join(temp_dir, f"{uuid.uuid4()}.jpg") + file_path = os.path.join( + get_astrbot_temp_path(), f"recordseg_{uuid.uuid4()}.jpg" + ) with open(file_path, "wb") as f: f.write(image_bytes) return os.path.abspath(file_path) @@ -214,14 +221,14 @@ async def register_to_file_service(self) -> str: class Video(BaseMessageComponent): - type = ComponentType.Video + type: ComponentType = ComponentType.Video file: str cover: str | None = "" c: int | None = 2 # 额外 path: str | None = "" - def __init__(self, file: str, **_): + def __init__(self, file: str, **_) -> None: super().__init__(file=file, **_) @staticmethod @@ -245,8 +252,9 @@ async def convert_to_file_path(self) -> str: if url and url.startswith("file:///"): return url[8:] if url and url.startswith("http"): - download_dir = os.path.join(get_astrbot_data_path(), "temp") - video_file_path = os.path.join(download_dir, f"{uuid.uuid4().hex}") + video_file_path = os.path.join( + get_astrbot_temp_path(), f"videoseg_{uuid.uuid4().hex}" + ) await download_file(url, video_file_path) if os.path.exists(video_file_path): return os.path.abspath(video_file_path) @@ -255,7 +263,7 @@ async def convert_to_file_path(self) -> str: return os.path.abspath(url) raise Exception(f"not a valid file: {url}") - async def register_to_file_service(self): + async def register_to_file_service(self) -> str: """将视频注册到文件服务。 Returns: @@ -299,11 +307,11 @@ async def to_dict(self): class At(BaseMessageComponent): - type = ComponentType.At + type: ComponentType = ComponentType.At qq: int | str # 此处str为all时代表所有人 name: str | None = "" - def __init__(self, **_): + def __init__(self, **_) -> None: super().__init__(**_) def toDict(self): @@ -316,64 +324,64 @@ def toDict(self): class AtAll(At): qq: str = "all" - def __init__(self, **_): + def __init__(self, **_) -> None: super().__init__(**_) class RPS(BaseMessageComponent): # TODO - type = ComponentType.RPS + type: ComponentType = ComponentType.RPS - def __init__(self, **_): + def __init__(self, **_) -> None: super().__init__(**_) class Dice(BaseMessageComponent): # TODO - type = ComponentType.Dice + type: ComponentType = ComponentType.Dice - def __init__(self, **_): + def __init__(self, **_) -> None: super().__init__(**_) class Shake(BaseMessageComponent): # TODO - type = ComponentType.Shake + type: ComponentType = ComponentType.Shake - def __init__(self, **_): + def __init__(self, **_) -> None: super().__init__(**_) class Share(BaseMessageComponent): - type = ComponentType.Share + type: ComponentType = ComponentType.Share url: str title: str content: str | None = "" image: str | None = "" - def __init__(self, **_): + def __init__(self, **_) -> None: super().__init__(**_) class Contact(BaseMessageComponent): # TODO - type = ComponentType.Contact + type: ComponentType = ComponentType.Contact _type: str # type 字段冲突 id: int | None = 0 - def __init__(self, **_): + def __init__(self, **_) -> None: super().__init__(**_) class Location(BaseMessageComponent): # TODO - type = ComponentType.Location + type: ComponentType = ComponentType.Location lat: float lon: float title: str | None = "" content: str | None = "" - def __init__(self, **_): + def __init__(self, **_) -> None: super().__init__(**_) class Music(BaseMessageComponent): - type = ComponentType.Music + type: ComponentType = ComponentType.Music _type: str id: int | None = 0 url: str | None = "" @@ -382,7 +390,7 @@ class Music(BaseMessageComponent): content: str | None = "" image: str | None = "" - def __init__(self, **_): + def __init__(self, **_) -> None: # for k in _.keys(): # if k == "_type" and _[k] not in ["qq", "163", "xm", "custom"]: # logger.warn(f"Protocol: {k}={_[k]} doesn't match values") @@ -390,7 +398,7 @@ def __init__(self, **_): class Image(BaseMessageComponent): - type = ComponentType.Image + type: ComponentType = ComponentType.Image file: str | None = "" _type: str | None = "" subType: int | None = 0 @@ -402,7 +410,7 @@ class Image(BaseMessageComponent): path: str | None = "" file_unique: str | None = "" # 某些平台可能有图片缓存的唯一标识 - def __init__(self, file: str | None, **_): + def __init__(self, file: str | None, **_) -> None: super().__init__(file=file, **_) @staticmethod @@ -445,8 +453,9 @@ async def convert_to_file_path(self) -> str: if url.startswith("base64://"): bs64_data = url.removeprefix("base64://") image_bytes = base64.b64decode(bs64_data) - temp_dir = os.path.join(get_astrbot_data_path(), "temp") - image_file_path = os.path.join(temp_dir, f"{uuid.uuid4()}.jpg") + image_file_path = os.path.join( + get_astrbot_temp_path(), f"imgseg_{uuid.uuid4()}.jpg" + ) with open(image_file_path, "wb") as f: f.write(image_bytes) return os.path.abspath(image_file_path) @@ -504,7 +513,7 @@ async def register_to_file_service(self) -> str: class Reply(BaseMessageComponent): - type = ComponentType.Reply + type: ComponentType = ComponentType.Reply id: str | int """所引用的消息 ID""" chain: list["BaseMessageComponent"] | None = [] @@ -525,7 +534,7 @@ class Reply(BaseMessageComponent): seq: int | None = 0 """deprecated""" - def __init__(self, **_): + def __init__(self, **_) -> None: super().__init__(**_) @@ -534,23 +543,23 @@ class Poke(BaseMessageComponent): id: int | None = 0 qq: int | None = 0 - def __init__(self, type: str, **_): + def __init__(self, type: str, **_) -> None: type = f"Poke:{type}" super().__init__(type=type, **_) class Forward(BaseMessageComponent): - type = ComponentType.Forward + type: ComponentType = ComponentType.Forward id: str - def __init__(self, **_): + def __init__(self, **_) -> None: super().__init__(**_) class Node(BaseMessageComponent): """群合并转发消息""" - type = ComponentType.Node + type: ComponentType = ComponentType.Node id: int | None = 0 # 忽略 name: str | None = "" # qq昵称 uin: str | None = "0" # qq号 @@ -558,7 +567,7 @@ class Node(BaseMessageComponent): seq: str | list | None = "" # 忽略 time: int | None = 0 # 忽略 - def __init__(self, content: list[BaseMessageComponent], **_): + def __init__(self, content: list[BaseMessageComponent], **_) -> None: if isinstance(content, Node): # back content = [content] @@ -602,10 +611,10 @@ async def to_dict(self): class Nodes(BaseMessageComponent): - type = ComponentType.Nodes + type: ComponentType = ComponentType.Nodes nodes: list[Node] - def __init__(self, nodes: list[Node], **_): + def __init__(self, nodes: list[Node], **_) -> None: super().__init__(nodes=nodes, **_) def toDict(self): @@ -628,29 +637,29 @@ async def to_dict(self) -> dict: class Json(BaseMessageComponent): - type = ComponentType.Json + type: ComponentType = ComponentType.Json data: dict - def __init__(self, data: str | dict, **_): + def __init__(self, data: str | dict, **_) -> None: if isinstance(data, str): data = json.loads(data) super().__init__(data=data, **_) class Unknown(BaseMessageComponent): - type = ComponentType.Unknown + type: ComponentType = ComponentType.Unknown text: str class File(BaseMessageComponent): """文件消息段""" - type = ComponentType.File + type: ComponentType = ComponentType.File name: str | None = "" # 名字 file_: str | None = "" # 本地路径 url: str | None = "" # url - def __init__(self, name: str, file: str = "", url: str = ""): + def __init__(self, name: str, file: str = "", url: str = "") -> None: """文件消息段。""" super().__init__(name=name, file_=file, url=url) @@ -686,7 +695,7 @@ def file(self) -> str: return "" @file.setter - def file(self, value: str): + def file(self, value: str) -> None: """向前兼容, 设置file属性, 传入的参数可能是文件路径或URL Args: @@ -711,32 +720,56 @@ async def get_file(self, allow_return_url: bool = False) -> str: if allow_return_url and self.url: return self.url - if self.file_ and os.path.exists(self.file_): - return os.path.abspath(self.file_) + if self.file_: + path = self.file_ + if path.startswith("file://"): + # 处理 file:// (2 slashes) 或 file:/// (3 slashes) + # pathlib.as_uri() 通常生成 file:/// + path = path[7:] + # 兼容 Windows: file:///C:/path -> /C:/path -> C:/path + if ( + os.name == "nt" + and len(path) > 2 + and path[0] == "/" + and path[2] == ":" + ): + path = path[1:] + + if os.path.exists(path): + return os.path.abspath(path) if self.url: await self._download_file() if self.file_: - return os.path.abspath(self.file_) + path = self.file_ + if path.startswith("file://"): + path = path[7:] + if ( + os.name == "nt" + and len(path) > 2 + and path[0] == "/" + and path[2] == ":" + ): + path = path[1:] + return os.path.abspath(path) return "" - async def _download_file(self): + async def _download_file(self) -> None: """下载文件""" if not self.url: raise ValueError("Download failed: No URL provided in File component.") - download_dir = os.path.join(get_astrbot_data_path(), "temp") - os.makedirs(download_dir, exist_ok=True) + download_dir = get_astrbot_temp_path() if self.name: name, ext = os.path.splitext(self.name) - filename = f"{name}_{uuid.uuid4().hex[:8]}{ext}" + filename = f"fileseg_{name}_{uuid.uuid4().hex[:8]}{ext}" else: - filename = f"{uuid.uuid4().hex}" + filename = f"fileseg_{uuid.uuid4().hex}" file_path = os.path.join(download_dir, filename) await download_file(self.url, file_path) self.file_ = os.path.abspath(file_path) - async def register_to_file_service(self): + async def register_to_file_service(self) -> str: """将文件注册到文件服务。 Returns: @@ -781,12 +814,12 @@ async def to_dict(self): class WechatEmoji(BaseMessageComponent): - type = ComponentType.WechatEmoji + type: ComponentType = ComponentType.WechatEmoji md5: str | None = "" md5_len: int | None = 0 cdnurl: str | None = "" - def __init__(self, **_): + def __init__(self, **_) -> None: super().__init__(**_) diff --git a/astrbot/core/persona_mgr.py b/astrbot/core/persona_mgr.py index ec99584e1..66002b2bd 100644 --- a/astrbot/core/persona_mgr.py +++ b/astrbot/core/persona_mgr.py @@ -1,4 +1,5 @@ from astrbot import logger +from astrbot.api import sp from astrbot.core.astrbot_config_mgr import AstrBotConfigManager from astrbot.core.db import BaseDatabase from astrbot.core.db.po import Persona, PersonaFolder, Personality @@ -17,7 +18,7 @@ class PersonaManager: - def __init__(self, db_helper: BaseDatabase, acm: AstrBotConfigManager): + def __init__(self, db_helper: BaseDatabase, acm: AstrBotConfigManager) -> None: self.db = db_helper self.acm = acm default_ps = acm.default_conf.get("provider_settings", {}) @@ -29,7 +30,7 @@ def __init__(self, db_helper: BaseDatabase, acm: AstrBotConfigManager): self.selected_default_persona_v3: Personality | None = None self.persona_v3_config: list[dict] = [] - async def initialize(self): + async def initialize(self) -> None: self.personas = await self.get_all_personas() self.get_v3_persona_data() logger.info(f"已加载 {len(self.personas)} 个人格。") @@ -58,7 +59,61 @@ async def get_default_persona_v3( except Exception: return DEFAULT_PERSONALITY - async def delete_persona(self, persona_id: str): + async def resolve_selected_persona( + self, + *, + umo: str | MessageSession, + conversation_persona_id: str | None, + platform_name: str, + provider_settings: dict | None = None, + ) -> tuple[str | None, Personality | None, str | None, bool]: + """解析当前会话最终生效的人格。 + + Returns: + tuple: + - selected persona_id + - selected persona object + - force applied persona_id from session rule + - whether use webchat special default persona + """ + session_service_config = ( + await sp.get_async( + scope="umo", + scope_id=str(umo), + key="session_service_config", + default={}, + ) + or {} + ) + + force_applied_persona_id = session_service_config.get("persona_id") + persona_id = force_applied_persona_id + + if not persona_id: + persona_id = conversation_persona_id + if persona_id == "[%None]": + pass + elif persona_id is None: + persona_id = (provider_settings or {}).get("default_personality") + + persona = next( + (item for item in self.personas_v3 if item["name"] == persona_id), + None, + ) + + use_webchat_special_default = False + if not persona and platform_name == "webchat" and persona_id != "[%None]": + persona_id = "_chatui_default_" + use_webchat_special_default = True + + return ( + persona_id, + persona, + force_applied_persona_id, + use_webchat_special_default, + ) + + async def delete_persona(self, persona_id: str) -> None: """删除指定 persona""" if not await self.db.get_persona_by_id(persona_id): raise ValueError(f"Persona with ID {persona_id} does not exist.") @@ -313,7 +368,7 @@ def get_v3_persona_data( { "role": "user" if user_turn else "assistant", "content": dialog, - "_no_save": None, # 不持久化到 db + "_no_save": True, # 不持久化到 db }, ) user_turn = not user_turn diff --git a/astrbot/core/pipeline/__init__.py b/astrbot/core/pipeline/__init__.py index 75fef84d3..2fced806d 100644 --- a/astrbot/core/pipeline/__init__.py +++ b/astrbot/core/pipeline/__init__.py @@ -1,30 +1,71 @@ +"""Pipeline package exports. + +This module intentionally avoids eager imports of all pipeline stage modules to +prevent import-time cycles. Stage classes remain available via lazy attribute +resolution for backward compatibility. +""" + +from __future__ import annotations + +from importlib import import_module +from typing import TYPE_CHECKING, Any + from astrbot.core.message.message_event_result import ( EventResultType, MessageEventResult, ) -from .content_safety_check.stage import ContentSafetyCheckStage -from .preprocess_stage.stage import PreProcessStage -from .process_stage.stage import ProcessStage -from .rate_limit_check.stage import RateLimitStage -from .respond.stage import RespondStage -from .result_decorate.stage import ResultDecorateStage -from .session_status_check.stage import SessionStatusCheckStage -from .waking_check.stage import WakingCheckStage -from .whitelist_check.stage import WhitelistCheckStage - -# 管道阶段顺序 -STAGES_ORDER = [ - "WakingCheckStage", # 检查是否需要唤醒 - "WhitelistCheckStage", # 检查是否在群聊/私聊白名单 - "SessionStatusCheckStage", # 检查会话是否整体启用 - "RateLimitStage", # 检查会话是否超过频率限制 - "ContentSafetyCheckStage", # 检查内容安全 - "PreProcessStage", # 预处理 - "ProcessStage", # 交由 Stars 处理(a.k.a 插件),或者 LLM 调用 - "ResultDecorateStage", # 处理结果,比如添加回复前缀、t2i、转换为语音 等 - "RespondStage", # 发送消息 -] +from .stage_order import STAGES_ORDER + +if TYPE_CHECKING: + from .content_safety_check.stage import ContentSafetyCheckStage + from .preprocess_stage.stage import PreProcessStage + from .process_stage.stage import ProcessStage + from .rate_limit_check.stage import RateLimitStage + from .respond.stage import RespondStage + from .result_decorate.stage import ResultDecorateStage + from .session_status_check.stage import SessionStatusCheckStage + from .waking_check.stage import WakingCheckStage + from .whitelist_check.stage import WhitelistCheckStage + +_LAZY_EXPORTS = { + "ContentSafetyCheckStage": ( + "astrbot.core.pipeline.content_safety_check.stage", + "ContentSafetyCheckStage", + ), + "PreProcessStage": ( + "astrbot.core.pipeline.preprocess_stage.stage", + "PreProcessStage", + ), + "ProcessStage": ( + "astrbot.core.pipeline.process_stage.stage", + "ProcessStage", + ), + "RateLimitStage": ( + "astrbot.core.pipeline.rate_limit_check.stage", + "RateLimitStage", + ), + "RespondStage": ( + "astrbot.core.pipeline.respond.stage", + "RespondStage", + ), + "ResultDecorateStage": ( + "astrbot.core.pipeline.result_decorate.stage", + "ResultDecorateStage", + ), + "SessionStatusCheckStage": ( + "astrbot.core.pipeline.session_status_check.stage", + "SessionStatusCheckStage", + ), + "WakingCheckStage": ( + "astrbot.core.pipeline.waking_check.stage", + "WakingCheckStage", + ), + "WhitelistCheckStage": ( + "astrbot.core.pipeline.whitelist_check.stage", + "WhitelistCheckStage", + ), +} __all__ = [ "ContentSafetyCheckStage", @@ -36,6 +77,21 @@ "RespondStage", "ResultDecorateStage", "SessionStatusCheckStage", + "STAGES_ORDER", "WakingCheckStage", "WhitelistCheckStage", ] + + +def __getattr__(name: str) -> Any: + if name not in _LAZY_EXPORTS: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + module_path, attr_name = _LAZY_EXPORTS[name] + module = import_module(module_path) + value = getattr(module, attr_name) + globals()[name] = value + return value + + +def __dir__() -> list[str]: + return sorted(set(globals()) | set(__all__)) diff --git a/astrbot/core/pipeline/bootstrap.py b/astrbot/core/pipeline/bootstrap.py new file mode 100644 index 000000000..4bb7ceadb --- /dev/null +++ b/astrbot/core/pipeline/bootstrap.py @@ -0,0 +1,52 @@ +"""Pipeline bootstrap utilities.""" + +from importlib import import_module + +from .stage import registered_stages + +_BUILTIN_STAGE_MODULES = ( + "astrbot.core.pipeline.waking_check.stage", + "astrbot.core.pipeline.whitelist_check.stage", + "astrbot.core.pipeline.session_status_check.stage", + "astrbot.core.pipeline.rate_limit_check.stage", + "astrbot.core.pipeline.content_safety_check.stage", + "astrbot.core.pipeline.preprocess_stage.stage", + "astrbot.core.pipeline.process_stage.stage", + "astrbot.core.pipeline.result_decorate.stage", + "astrbot.core.pipeline.respond.stage", +) + +_EXPECTED_STAGE_NAMES = { + "WakingCheckStage", + "WhitelistCheckStage", + "SessionStatusCheckStage", + "RateLimitStage", + "ContentSafetyCheckStage", + "PreProcessStage", + "ProcessStage", + "ResultDecorateStage", + "RespondStage", +} + +_builtin_stages_registered = False + + +def ensure_builtin_stages_registered() -> None: + """Ensure built-in pipeline stages are imported and registered.""" + global _builtin_stages_registered + + if _builtin_stages_registered: + return + + stage_names = {stage_cls.__name__ for stage_cls in registered_stages} + if _EXPECTED_STAGE_NAMES.issubset(stage_names): + _builtin_stages_registered = True + return + + for module_path in _BUILTIN_STAGE_MODULES: + import_module(module_path) + + _builtin_stages_registered = True + + +__all__ = ["ensure_builtin_stages_registered"] diff --git a/astrbot/core/pipeline/content_safety_check/stage.py b/astrbot/core/pipeline/content_safety_check/stage.py index b089c48e0..19037eb08 100644 --- a/astrbot/core/pipeline/content_safety_check/stage.py +++ b/astrbot/core/pipeline/content_safety_check/stage.py @@ -16,7 +16,7 @@ class ContentSafetyCheckStage(Stage): 当前只会检查文本的。 """ - async def initialize(self, ctx: PipelineContext): + async def initialize(self, ctx: PipelineContext) -> None: config = ctx.astrbot_config["content_safety"] self.strategy_selector = StrategySelector(config) diff --git a/astrbot/core/pipeline/context.py b/astrbot/core/pipeline/context.py index a6cd567e0..963f4bdac 100644 --- a/astrbot/core/pipeline/context.py +++ b/astrbot/core/pipeline/context.py @@ -1,7 +1,9 @@ +from __future__ import annotations + from dataclasses import dataclass +from typing import Any from astrbot.core.config import AstrBotConfig -from astrbot.core.star import PluginManager from .context_utils import call_event_hook, call_handler @@ -11,7 +13,7 @@ class PipelineContext: """上下文对象,包含管道执行所需的上下文信息""" astrbot_config: AstrBotConfig # AstrBot 配置对象 - plugin_manager: PluginManager # 插件管理器对象 + plugin_manager: Any # 插件管理器对象 astrbot_config_id: str call_handler = call_handler call_event_hook = call_event_hook diff --git a/astrbot/core/pipeline/process_stage/follow_up.py b/astrbot/core/pipeline/process_stage/follow_up.py new file mode 100644 index 000000000..6c1a4fa06 --- /dev/null +++ b/astrbot/core/pipeline/process_stage/follow_up.py @@ -0,0 +1,227 @@ +from __future__ import annotations + +import asyncio +from dataclasses import dataclass + +from astrbot import logger +from astrbot.core.agent.runners.tool_loop_agent_runner import FollowUpTicket +from astrbot.core.astr_agent_run_util import AgentRunner +from astrbot.core.platform.astr_message_event import AstrMessageEvent + +_ACTIVE_AGENT_RUNNERS: dict[str, AgentRunner] = {} +_FOLLOW_UP_ORDER_STATE: dict[str, dict[str, object]] = {} +"""UMO-level follow-up order state. + +State fields: +- `statuses`: seq -> {"pending"|"active"|"consumed"|"finished"} +- `next_order`: monotonically increasing sequence allocator +- `next_turn`: next sequence allowed to proceed when not consumed +""" + + +@dataclass(slots=True) +class FollowUpCapture: + umo: str + ticket: FollowUpTicket + order_seq: int + monitor_task: asyncio.Task[None] + + +def _event_follow_up_text(event: AstrMessageEvent) -> str: + text = (event.get_message_str() or "").strip() + if text: + return text + return event.get_message_outline().strip() + + +def register_active_runner(umo: str, runner: AgentRunner) -> None: + _ACTIVE_AGENT_RUNNERS[umo] = runner + + +def unregister_active_runner(umo: str, runner: AgentRunner) -> None: + if _ACTIVE_AGENT_RUNNERS.get(umo) is runner: + _ACTIVE_AGENT_RUNNERS.pop(umo, None) + + +def _get_follow_up_order_state(umo: str) -> dict[str, object]: + state = _FOLLOW_UP_ORDER_STATE.get(umo) + if state is None: + state = { + "condition": asyncio.Condition(), + # Sequence status map for strict in-order resume after unresolved follow-ups. + "statuses": {}, + # Stable allocator for arrival order; never decreases for the same UMO state. + "next_order": 0, + # The sequence currently allowed to continue main internal flow. + "next_turn": 0, + } + _FOLLOW_UP_ORDER_STATE[umo] = state + return state + + +def _advance_follow_up_turn_locked(state: dict[str, object]) -> None: + # Skip slots that are already handled, and stop at the first unfinished slot. + statuses = state["statuses"] + assert isinstance(statuses, dict) + next_turn = state["next_turn"] + assert isinstance(next_turn, int) + + while True: + curr = statuses.get(next_turn) + if curr in ("consumed", "finished"): + statuses.pop(next_turn, None) + next_turn += 1 + continue + break + + state["next_turn"] = next_turn + + +def _allocate_follow_up_order(umo: str) -> int: + state = _get_follow_up_order_state(umo) + next_order = state["next_order"] + assert isinstance(next_order, int) + seq = next_order + state["next_order"] = seq + 1 + statuses = state["statuses"] + assert isinstance(statuses, dict) + statuses[seq] = "pending" + return seq + + +async def _mark_follow_up_consumed(umo: str, seq: int) -> None: + state = _FOLLOW_UP_ORDER_STATE.get(umo) + if not state: + return + condition = state["condition"] + assert isinstance(condition, asyncio.Condition) + async with condition: + statuses = state["statuses"] + assert isinstance(statuses, dict) + if seq in statuses and statuses[seq] != "finished": + statuses[seq] = "consumed" + _advance_follow_up_turn_locked(state) + condition.notify_all() + + # Release state only when this UMO has no pending statuses and no active runner. + if not statuses and _ACTIVE_AGENT_RUNNERS.get(umo) is None: + _FOLLOW_UP_ORDER_STATE.pop(umo, None) + + +async def _activate_and_wait_follow_up_turn(umo: str, seq: int) -> None: + state = _FOLLOW_UP_ORDER_STATE.get(umo) + if not state: + return + condition = state["condition"] + assert isinstance(condition, asyncio.Condition) + async with condition: + statuses = state["statuses"] + assert isinstance(statuses, dict) + if seq in statuses: + statuses[seq] = "active" + + # Strict ordering: only the head (`next_turn`) can continue. + while True: + next_turn = state["next_turn"] + assert isinstance(next_turn, int) + if next_turn == seq: + break + await condition.wait() + + +async def _finish_follow_up_turn(umo: str, seq: int) -> None: + state = _FOLLOW_UP_ORDER_STATE.get(umo) + if not state: + return + condition = state["condition"] + assert isinstance(condition, asyncio.Condition) + async with condition: + statuses = state["statuses"] + assert isinstance(statuses, dict) + if seq in statuses: + statuses[seq] = "finished" + _advance_follow_up_turn_locked(state) + condition.notify_all() + + if not statuses and _ACTIVE_AGENT_RUNNERS.get(umo) is None: + _FOLLOW_UP_ORDER_STATE.pop(umo, None) + + +async def _monitor_follow_up_ticket( + umo: str, + ticket: FollowUpTicket, + order_seq: int, +) -> None: + """Advance consumed slots immediately on resolution to avoid wake-order drift.""" + await ticket.resolved.wait() + if ticket.consumed: + await _mark_follow_up_consumed(umo, order_seq) + + +def try_capture_follow_up(event: AstrMessageEvent) -> FollowUpCapture | None: + sender_id = event.get_sender_id() + if not sender_id: + return None + runner = _ACTIVE_AGENT_RUNNERS.get(event.unified_msg_origin) + if not runner: + return None + runner_event = getattr(getattr(runner.run_context, "context", None), "event", None) + if runner_event is None: + return None + active_sender_id = runner_event.get_sender_id() + if not active_sender_id or active_sender_id != sender_id: + return None + + ticket = runner.follow_up(message_text=_event_follow_up_text(event)) + if not ticket: + return None + # Allocate strict order at capture time (arrival order), not at wake time. + order_seq = _allocate_follow_up_order(event.unified_msg_origin) + monitor_task = asyncio.create_task( + _monitor_follow_up_ticket( + event.unified_msg_origin, + ticket, + order_seq, + ) + ) + logger.info( + "Captured follow-up message for active agent run, umo=%s, order_seq=%s", + event.unified_msg_origin, + order_seq, + ) + return FollowUpCapture( + umo=event.unified_msg_origin, + ticket=ticket, + order_seq=order_seq, + monitor_task=monitor_task, + ) + + +async def prepare_follow_up_capture(capture: FollowUpCapture) -> tuple[bool, bool]: + """Return `(consumed_marked, activated)` for internal stage branch handling.""" + await capture.ticket.resolved.wait() + if capture.ticket.consumed: + await _mark_follow_up_consumed(capture.umo, capture.order_seq) + return True, False + await _activate_and_wait_follow_up_turn(capture.umo, capture.order_seq) + return False, True + + +async def finalize_follow_up_capture( + capture: FollowUpCapture, + *, + activated: bool, + consumed_marked: bool, +) -> None: + # Best-effort cancellation: monitor task is auxiliary and should not leak. + if not capture.monitor_task.done(): + capture.monitor_task.cancel() + try: + await capture.monitor_task + except asyncio.CancelledError: + pass + + if activated: + await _finish_follow_up_turn(capture.umo, capture.order_seq) + elif not consumed_marked: + await _mark_follow_up_consumed(capture.umo, capture.order_seq) diff --git a/astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py b/astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py index 87f0dd419..d95f7f86c 100644 --- a/astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py +++ b/astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py @@ -19,6 +19,7 @@ MessageEventResult, ResultContentType, ) +from astrbot.core.pipeline.stage import Stage from astrbot.core.platform.astr_message_event import AstrMessageEvent from astrbot.core.provider.entities import ( LLMResponse, @@ -28,9 +29,16 @@ from astrbot.core.utils.metrics import Metric from astrbot.core.utils.session_lock import session_lock_manager -from .....astr_agent_run_util import run_agent, run_live_agent +from .....astr_agent_run_util import AgentRunner, run_agent, run_live_agent from ....context import PipelineContext, call_event_hook -from ...stage import Stage +from ...follow_up import ( + FollowUpCapture, + finalize_follow_up_capture, + prepare_follow_up_capture, + register_active_runner, + try_capture_follow_up, + unregister_active_runner, +) class InternalAgentSubStage(Stage): @@ -54,6 +62,7 @@ async def initialize(self, ctx: PipelineContext) -> None: if isinstance(self.max_step, bool): # workaround: #2622 self.max_step = 30 self.show_tool_use: bool = settings.get("show_tool_use_status", True) + self.show_tool_call_result: bool = settings.get("show_tool_call_result", False) self.show_reasoning = settings.get("display_reasoning_text", False) self.sanitize_context_by_modalities: bool = settings.get( "sanitize_context_by_modalities", @@ -123,11 +132,15 @@ async def initialize(self, ctx: PipelineContext) -> None: provider_settings=settings, subagent_orchestrator=conf.get("subagent_orchestrator", {}), timezone=self.ctx.plugin_manager.context.get_config().get("timezone"), + max_quoted_fallback_images=settings.get("max_quoted_fallback_images", 20), ) async def process( self, event: AstrMessageEvent, provider_wake_prefix: str ) -> AsyncGenerator[None, None]: + follow_up_capture: FollowUpCapture | None = None + follow_up_consumed_marked = False + follow_up_activated = False try: streaming_response = self.streaming_response if (enable_streaming := event.get_extra("enable_streaming")) is not None: @@ -148,178 +161,208 @@ async def process( return logger.debug("ready to request llm provider") + follow_up_capture = try_capture_follow_up(event) + if follow_up_capture: + ( + follow_up_consumed_marked, + follow_up_activated, + ) = await prepare_follow_up_capture(follow_up_capture) + if follow_up_consumed_marked: + logger.info( + "Follow-up ticket already consumed, stopping processing. umo=%s, seq=%s", + event.unified_msg_origin, + follow_up_capture.ticket.seq, + ) + return + await event.send_typing() await call_event_hook(event, EventType.OnWaitingLLMRequestEvent) async with session_lock_manager.acquire_lock(event.unified_msg_origin): logger.debug("acquired session lock for llm request") + agent_runner: AgentRunner | None = None + runner_registered = False + try: + build_cfg = replace( + self.main_agent_cfg, + provider_wake_prefix=provider_wake_prefix, + streaming_response=streaming_response, + ) - build_cfg = replace( - self.main_agent_cfg, - provider_wake_prefix=provider_wake_prefix, - streaming_response=streaming_response, - ) + build_result: MainAgentBuildResult | None = await build_main_agent( + event=event, + plugin_context=self.ctx.plugin_manager.context, + config=build_cfg, + apply_reset=False, + ) - build_result: MainAgentBuildResult | None = await build_main_agent( - event=event, - plugin_context=self.ctx.plugin_manager.context, - config=build_cfg, - apply_reset=False, - ) + if build_result is None: + return - if build_result is None: - return + agent_runner = build_result.agent_runner + req = build_result.provider_request + provider = build_result.provider + reset_coro = build_result.reset_coro + + api_base = provider.provider_config.get("api_base", "") + for host in decoded_blocked: + if host in api_base: + logger.error( + "Provider API base %s is blocked due to security reasons. Please use another ai provider.", + api_base, + ) + return - agent_runner = build_result.agent_runner - req = build_result.provider_request - provider = build_result.provider - reset_coro = build_result.reset_coro - - api_base = provider.provider_config.get("api_base", "") - for host in decoded_blocked: - if host in api_base: - logger.error( - "Provider API base %s is blocked due to security reasons. Please use another ai provider.", - api_base, - ) + stream_to_general = ( + self.unsupported_streaming_strategy == "turn_off" + and not event.platform_meta.support_streaming_message + ) + + if await call_event_hook(event, EventType.OnLLMRequestEvent, req): + if reset_coro: + reset_coro.close() return - stream_to_general = ( - self.unsupported_streaming_strategy == "turn_off" - and not event.platform_meta.support_streaming_message - ) + # apply reset + if reset_coro: + await reset_coro + + register_active_runner(event.unified_msg_origin, agent_runner) + runner_registered = True + action_type = event.get_extra("action_type") + + event.trace.record( + "astr_agent_prepare", + system_prompt=req.system_prompt, + tools=req.func_tool.names() if req.func_tool else [], + stream=streaming_response, + chat_provider={ + "id": provider.provider_config.get("id", ""), + "model": provider.get_model(), + }, + ) - if await call_event_hook(event, EventType.OnLLMRequestEvent, req): - return + # 检测 Live Mode + if action_type == "live": + # Live Mode: 使用 run_live_agent + logger.info("[Internal Agent] 检测到 Live Mode,启用 TTS 处理") - # apply reset - if reset_coro: - await reset_coro - - action_type = event.get_extra("action_type") - - event.trace.record( - "astr_agent_prepare", - system_prompt=req.system_prompt, - tools=req.func_tool.names() if req.func_tool else [], - stream=streaming_response, - chat_provider={ - "id": provider.provider_config.get("id", ""), - "model": provider.get_model(), - }, - ) + # 获取 TTS Provider + tts_provider = ( + self.ctx.plugin_manager.context.get_using_tts_provider( + event.unified_msg_origin + ) + ) - # 检测 Live Mode - if action_type == "live": - # Live Mode: 使用 run_live_agent - logger.info("[Internal Agent] 检测到 Live Mode,启用 TTS 处理") + if not tts_provider: + logger.warning( + "[Live Mode] TTS Provider 未配置,将使用普通流式模式" + ) - # 获取 TTS Provider - tts_provider = ( - self.ctx.plugin_manager.context.get_using_tts_provider( - event.unified_msg_origin + # 使用 run_live_agent,总是使用流式响应 + event.set_result( + MessageEventResult() + .set_result_content_type(ResultContentType.STREAMING_RESULT) + .set_async_stream( + run_live_agent( + agent_runner, + tts_provider, + self.max_step, + self.show_tool_use, + self.show_tool_call_result, + show_reasoning=self.show_reasoning, + ), + ), ) - ) + yield - if not tts_provider: - logger.warning( - "[Live Mode] TTS Provider 未配置,将使用普通流式模式" - ) + # 保存历史记录 + if agent_runner.done() and ( + not event.is_stopped() or agent_runner.was_aborted() + ): + await self._save_to_history( + event, + req, + agent_runner.get_final_llm_resp(), + agent_runner.run_context.messages, + agent_runner.stats, + user_aborted=agent_runner.was_aborted(), + ) - # 使用 run_live_agent,总是使用流式响应 - event.set_result( - MessageEventResult() - .set_result_content_type(ResultContentType.STREAMING_RESULT) - .set_async_stream( - run_live_agent( - agent_runner, - tts_provider, - self.max_step, - self.show_tool_use, - show_reasoning=self.show_reasoning, + elif streaming_response and not stream_to_general: + # 流式响应 + event.set_result( + MessageEventResult() + .set_result_content_type(ResultContentType.STREAMING_RESULT) + .set_async_stream( + run_agent( + agent_runner, + self.max_step, + self.show_tool_use, + self.show_tool_call_result, + show_reasoning=self.show_reasoning, + ), ), - ), + ) + yield + if agent_runner.done(): + if final_llm_resp := agent_runner.get_final_llm_resp(): + if final_llm_resp.completion_text: + chain = ( + MessageChain() + .message(final_llm_resp.completion_text) + .chain + ) + elif final_llm_resp.result_chain: + chain = final_llm_resp.result_chain.chain + else: + chain = MessageChain().chain + event.set_result( + MessageEventResult( + chain=chain, + result_content_type=ResultContentType.STREAMING_FINISH, + ), + ) + else: + async for _ in run_agent( + agent_runner, + self.max_step, + self.show_tool_use, + self.show_tool_call_result, + stream_to_general, + show_reasoning=self.show_reasoning, + ): + yield + + final_resp = agent_runner.get_final_llm_resp() + + event.trace.record( + "astr_agent_complete", + stats=agent_runner.stats.to_dict(), + resp=final_resp.completion_text if final_resp else None, ) - yield - # 保存历史记录 - if not event.is_stopped() and agent_runner.done(): + # 检查事件是否被停止,如果被停止则不保存历史记录 + if not event.is_stopped() or agent_runner.was_aborted(): await self._save_to_history( event, req, - agent_runner.get_final_llm_resp(), + final_resp, agent_runner.run_context.messages, agent_runner.stats, + user_aborted=agent_runner.was_aborted(), ) - elif streaming_response and not stream_to_general: - # 流式响应 - event.set_result( - MessageEventResult() - .set_result_content_type(ResultContentType.STREAMING_RESULT) - .set_async_stream( - run_agent( - agent_runner, - self.max_step, - self.show_tool_use, - show_reasoning=self.show_reasoning, - ), + asyncio.create_task( + Metric.upload( + llm_tick=1, + model_name=agent_runner.provider.get_model(), + provider_type=agent_runner.provider.meta().type, ), ) - yield - if agent_runner.done(): - if final_llm_resp := agent_runner.get_final_llm_resp(): - if final_llm_resp.completion_text: - chain = ( - MessageChain() - .message(final_llm_resp.completion_text) - .chain - ) - elif final_llm_resp.result_chain: - chain = final_llm_resp.result_chain.chain - else: - chain = MessageChain().chain - event.set_result( - MessageEventResult( - chain=chain, - result_content_type=ResultContentType.STREAMING_FINISH, - ), - ) - else: - async for _ in run_agent( - agent_runner, - self.max_step, - self.show_tool_use, - stream_to_general, - show_reasoning=self.show_reasoning, - ): - yield - - final_resp = agent_runner.get_final_llm_resp() - - event.trace.record( - "astr_agent_complete", - stats=agent_runner.stats.to_dict(), - resp=final_resp.completion_text if final_resp else None, - ) - - # 检查事件是否被停止,如果被停止则不保存历史记录 - if not event.is_stopped(): - await self._save_to_history( - event, - req, - final_resp, - agent_runner.run_context.messages, - agent_runner.stats, - ) - - asyncio.create_task( - Metric.upload( - llm_tick=1, - model_name=agent_runner.provider.get_model(), - provider_type=agent_runner.provider.meta().type, - ), - ) + finally: + if runner_registered and agent_runner is not None: + unregister_active_runner(event.unified_msg_origin, agent_runner) except Exception as e: logger.error(f"Error occurred while processing agent: {e}") @@ -328,6 +371,13 @@ async def process( f"Error occurred while processing agent request: {e}" ) ) + finally: + if follow_up_capture: + await finalize_follow_up_capture( + follow_up_capture, + activated=follow_up_activated, + consumed_marked=follow_up_consumed_marked, + ) async def _save_to_history( self, @@ -336,16 +386,29 @@ async def _save_to_history( llm_response: LLMResponse | None, all_messages: list[Message], runner_stats: AgentStats | None, - ): - if ( - not req - or not req.conversation - or not llm_response - or llm_response.role != "assistant" - ): + user_aborted: bool = False, + ) -> None: + if not req or not req.conversation: return - if not llm_response.completion_text and not req.tool_calls_result: + if not llm_response and not user_aborted: + return + + if llm_response and llm_response.role != "assistant": + if not user_aborted: + return + llm_response = LLMResponse( + role="assistant", + completion_text=llm_response.completion_text or "", + ) + elif llm_response is None: + llm_response = LLMResponse(role="assistant", completion_text="") + + if ( + not llm_response.completion_text + and not req.tool_calls_result + and not user_aborted + ): logger.debug("LLM 响应为空,不保存记录。") return @@ -355,12 +418,18 @@ async def _save_to_history( if message.role == "system" and not skipped_initial_system: skipped_initial_system = True continue - if message.role in ["assistant", "user"] and getattr( - message, "_no_save", None - ): + if message.role in ["assistant", "user"] and message._no_save: continue message_to_save.append(message.model_dump()) + # if user_aborted: + # message_to_save.append( + # Message( + # role="assistant", + # content="[User aborted this request. Partial output before abort was preserved.]", + # ).model_dump() + # ) + token_usage = None if runner_stats: # token_usage = runner_stats.token_usage.total diff --git a/astrbot/core/pipeline/process_stage/method/agent_sub_stages/third_party.py b/astrbot/core/pipeline/process_stage/method/agent_sub_stages/third_party.py index b590bd77e..7fb5cee82 100644 --- a/astrbot/core/pipeline/process_stage/method/agent_sub_stages/third_party.py +++ b/astrbot/core/pipeline/process_stage/method/agent_sub_stages/third_party.py @@ -8,6 +8,7 @@ DashscopeAgentRunner, ) from astrbot.core.agent.runners.dify.dify_agent_runner import DifyAgentRunner +from astrbot.core.astr_agent_hooks import MAIN_AGENT_HOOKS from astrbot.core.message.components import Image from astrbot.core.message.message_event_result import ( MessageChain, @@ -17,6 +18,7 @@ if TYPE_CHECKING: from astrbot.core.agent.runners.base import BaseAgentRunner +from astrbot.core.pipeline.stage import Stage from astrbot.core.platform.astr_message_event import AstrMessageEvent from astrbot.core.provider.entities import ( ProviderRequest, @@ -25,9 +27,7 @@ from astrbot.core.utils.metrics import Metric from .....astr_agent_context import AgentContextWrapper, AstrAgentContext -from .....astr_agent_hooks import MAIN_AGENT_HOOKS from ....context import PipelineContext, call_event_hook -from ...stage import Stage AGENT_RUNNER_TYPE_KEY = { "dify": "dify_agent_runner_provider_id", diff --git a/astrbot/core/pipeline/process_stage/method/star_request.py b/astrbot/core/pipeline/process_stage/method/star_request.py index 8a79b96c9..9422d6317 100644 --- a/astrbot/core/pipeline/process_stage/method/star_request.py +++ b/astrbot/core/pipeline/process_stage/method/star_request.py @@ -8,9 +8,9 @@ from astrbot.core.message.message_event_result import MessageEventResult from astrbot.core.platform.astr_message_event import AstrMessageEvent from astrbot.core.star.star import star_map -from astrbot.core.star.star_handler import StarHandlerMetadata +from astrbot.core.star.star_handler import EventType, StarHandlerMetadata -from ...context import PipelineContext, call_handler +from ...context import PipelineContext, call_event_hook, call_handler from ..stage import Stage @@ -48,10 +48,20 @@ async def process( yield ret event.clear_result() # 清除上一个 handler 的结果 except Exception as e: - logger.error(traceback.format_exc()) + traceback_text = traceback.format_exc() + logger.error(traceback_text) logger.error(f"Star {handler.handler_full_name} handle error: {e}") - if event.is_at_or_wake_command: + await call_event_hook( + event, + EventType.OnPluginErrorEvent, + md.name, + handler.handler_name, + e, + traceback_text, + ) + + if not event.is_stopped() and event.is_at_or_wake_command: ret = f":(\n\n在调用插件 {md.name} 的处理函数 {handler.handler_name} 时出现异常:{e}" event.set_result(MessageEventResult().message(ret)) yield diff --git a/astrbot/core/pipeline/rate_limit_check/stage.py b/astrbot/core/pipeline/rate_limit_check/stage.py index 64e21dd7e..392bceff3 100644 --- a/astrbot/core/pipeline/rate_limit_check/stage.py +++ b/astrbot/core/pipeline/rate_limit_check/stage.py @@ -19,7 +19,7 @@ class RateLimitStage(Stage): 如果触发限流,将 stall 流水线,直到下一个时间窗口来临时自动唤醒。 """ - def __init__(self): + def __init__(self) -> None: # 存储每个会话的请求时间队列 self.event_timestamps: defaultdict[str, deque[datetime]] = defaultdict(deque) # 为每个会话设置一个锁,避免并发冲突 diff --git a/astrbot/core/pipeline/respond/stage.py b/astrbot/core/pipeline/respond/stage.py index 60ab168b3..72e853ffc 100644 --- a/astrbot/core/pipeline/respond/stage.py +++ b/astrbot/core/pipeline/respond/stage.py @@ -33,9 +33,24 @@ class RespondStage(Stage): Comp.Nodes: lambda comp: bool(comp.nodes), # 多个转发节点 Comp.File: lambda comp: bool(comp.file_ or comp.url), Comp.WechatEmoji: lambda comp: comp.md5 is not None, # 微信表情 + Comp.Json: lambda comp: bool(comp.data), # Json 卡片 + Comp.Share: lambda comp: bool(comp.url) or bool(comp.title), + Comp.Music: lambda comp: ( + (comp.id and comp._type and comp._type != "custom") + or (comp._type == "custom" and comp.url and comp.audio and comp.title) + ), # 音乐分享 + Comp.Forward: lambda comp: bool(comp.id), # 合并转发 + Comp.Location: lambda comp: bool( + comp.lat is not None and comp.lon is not None + ), # 位置 + Comp.Contact: lambda comp: bool(comp._type and comp.id), # 推荐好友 or 群 + Comp.Shake: lambda _: True, # 窗口抖动(戳一戳) + Comp.Dice: lambda _: True, # 掷骰子魔法表情 + Comp.RPS: lambda _: True, # 猜拳魔法表情 + Comp.Unknown: lambda comp: bool(comp.text and comp.text.strip()), } - async def initialize(self, ctx: PipelineContext): + async def initialize(self, ctx: PipelineContext) -> None: self.ctx = ctx self.config = ctx.astrbot_config self.platform_settings: dict = self.config.get("platform_settings", {}) @@ -61,16 +76,17 @@ async def initialize(self, ctx: PipelineContext): self.log_base = float( ctx.astrbot_config["platform_settings"]["segmented_reply"]["log_base"], ) - interval_str: str = ctx.astrbot_config["platform_settings"]["segmented_reply"][ - "interval" - ] - interval_str_ls = interval_str.replace(" ", "").split(",") - try: - self.interval = [float(t) for t in interval_str_ls] - except BaseException as e: - logger.error(f"解析分段回复的间隔时间失败。{e}") - self.interval = [1.5, 3.5] - logger.info(f"分段回复间隔时间:{self.interval}") + self.interval = [1.5, 3.5] + if self.enable_seg: + interval_str: str = ctx.astrbot_config["platform_settings"][ + "segmented_reply" + ]["interval"] + interval_str_ls = interval_str.replace(" ", "").split(",") + try: + self.interval = [float(t) for t in interval_str_ls] + except BaseException as e: + logger.error(f"解析分段回复的间隔时间失败。{e}") + logger.info(f"分段回复间隔时间:{self.interval}") async def _word_cnt(self, text: str) -> int: """分段回复 统计字数""" @@ -91,7 +107,7 @@ async def _calc_comp_interval(self, comp: BaseMessageComponent) -> float: # random return random.uniform(self.interval[0], self.interval[1]) - async def _is_empty_message_chain(self, chain: list[BaseMessageComponent]): + async def _is_empty_message_chain(self, chain: list[BaseMessageComponent]) -> bool: """检查消息链是否为空 Args: diff --git a/astrbot/core/pipeline/result_decorate/stage.py b/astrbot/core/pipeline/result_decorate/stage.py index e0bcd5ac9..15d68fb22 100644 --- a/astrbot/core/pipeline/result_decorate/stage.py +++ b/astrbot/core/pipeline/result_decorate/stage.py @@ -20,7 +20,7 @@ @register_stage class ResultDecorateStage(Stage): - async def initialize(self, ctx: PipelineContext): + async def initialize(self, ctx: PipelineContext) -> None: self.ctx = ctx self.reply_prefix = ctx.astrbot_config["platform_settings"]["reply_prefix"] self.reply_with_mention = ctx.astrbot_config["platform_settings"][ @@ -315,6 +315,7 @@ async def process( Record( file=url or audio_path, url=url or audio_path, + text=comp.text, ), ) if dual_output: diff --git a/astrbot/core/pipeline/scheduler.py b/astrbot/core/pipeline/scheduler.py index 8569f945a..ffb9c5c99 100644 --- a/astrbot/core/pipeline/scheduler.py +++ b/astrbot/core/pipeline/scheduler.py @@ -6,30 +6,33 @@ from astrbot.core.platform.sources.wecom_ai_bot.wecomai_event import ( WecomAIBotMessageEvent, ) +from astrbot.core.utils.active_event_registry import active_event_registry -from . import STAGES_ORDER +from .bootstrap import ensure_builtin_stages_registered from .context import PipelineContext from .stage import registered_stages +from .stage_order import STAGES_ORDER class PipelineScheduler: """管道调度器,负责调度各个阶段的执行""" - def __init__(self, context: PipelineContext): + def __init__(self, context: PipelineContext) -> None: + ensure_builtin_stages_registered() registered_stages.sort( key=lambda x: STAGES_ORDER.index(x.__name__), ) # 按照顺序排序 self.ctx = context # 上下文对象 self.stages = [] # 存储阶段实例 - async def initialize(self): + async def initialize(self) -> None: """初始化管道调度器时, 初始化所有阶段""" for stage_cls in registered_stages: stage_instance = stage_cls() # 创建实例 await stage_instance.initialize(self.ctx) self.stages.append(stage_instance) - async def _process_stages(self, event: AstrMessageEvent, from_stage=0): + async def _process_stages(self, event: AstrMessageEvent, from_stage=0) -> None: """依次执行各个阶段 Args: @@ -72,17 +75,21 @@ async def _process_stages(self, event: AstrMessageEvent, from_stage=0): logger.debug(f"阶段 {stage.__class__.__name__} 已终止事件传播。") break - async def execute(self, event: AstrMessageEvent): + async def execute(self, event: AstrMessageEvent) -> None: """执行 pipeline Args: event (AstrMessageEvent): 事件对象 """ - await self._process_stages(event) + active_event_registry.register(event) + try: + await self._process_stages(event) - # 如果没有发送操作, 则发送一个空消息, 以便于后续的处理 - if isinstance(event, WebChatMessageEvent | WecomAIBotMessageEvent): - await event.send(None) + # 如果没有发送操作, 则发送一个空消息, 以便于后续的处理 + if isinstance(event, WebChatMessageEvent | WecomAIBotMessageEvent): + await event.send(None) - logger.debug("pipeline 执行完毕。") + logger.debug("pipeline 执行完毕。") + finally: + active_event_registry.unregister(event) diff --git a/astrbot/core/pipeline/stage_order.py b/astrbot/core/pipeline/stage_order.py new file mode 100644 index 000000000..f99f57264 --- /dev/null +++ b/astrbot/core/pipeline/stage_order.py @@ -0,0 +1,15 @@ +"""Pipeline stage execution order.""" + +STAGES_ORDER = [ + "WakingCheckStage", # 检查是否需要唤醒 + "WhitelistCheckStage", # 检查是否在群聊/私聊白名单 + "SessionStatusCheckStage", # 检查会话是否整体启用 + "RateLimitStage", # 检查会话是否超过频率限制 + "ContentSafetyCheckStage", # 检查内容安全 + "PreProcessStage", # 预处理 + "ProcessStage", # 交由 Stars 处理(a.k.a 插件),或者 LLM 调用 + "ResultDecorateStage", # 处理结果,比如添加回复前缀、t2i、转换为语音 等 + "RespondStage", # 发送消息 +] + +__all__ = ["STAGES_ORDER"] diff --git a/astrbot/core/platform/astr_message_event.py b/astrbot/core/platform/astr_message_event.py index b99a5778b..021a4bff7 100644 --- a/astrbot/core/platform/astr_message_event.py +++ b/astrbot/core/platform/astr_message_event.py @@ -38,7 +38,7 @@ def __init__( message_obj: AstrBotMessage, platform_meta: PlatformMetadata, session_id: str, - ): + ) -> None: self.message_str = message_str """纯文本的消息""" self.message_obj = message_obj @@ -52,9 +52,19 @@ def __init__( self.is_at_or_wake_command = False """是否是 At 机器人或者带有唤醒词或者是私聊(插件注册的事件监听器会让 is_wake 设为 True, 但是不会让这个属性置为 True)""" self._extras: dict[str, Any] = {} + message_type = getattr(message_obj, "type", None) + if not isinstance(message_type, MessageType): + try: + message_type = MessageType(str(message_type)) + except (ValueError, TypeError, AttributeError): + logger.warning( + f"Failed to convert message type {message_obj.type!r} to MessageType. " + f"Falling back to FRIEND_MESSAGE." + ) + message_type = MessageType.FRIEND_MESSAGE self.session = MessageSession( platform_name=platform_meta.id, - message_type=message_obj.type, + message_type=message_type, session_id=session_id, ) # self.unified_msg_origin = str(self.session) @@ -91,7 +101,7 @@ def unified_msg_origin(self) -> str: return str(self.session) @unified_msg_origin.setter - def unified_msg_origin(self, value: str): + def unified_msg_origin(self, value: str) -> None: """设置统一的消息来源字符串。格式为 platform_name:message_type:session_id""" self.new_session = MessageSession.from_str(value) self.session = self.new_session @@ -102,7 +112,7 @@ def session_id(self) -> str: return self.session.session_id @session_id.setter - def session_id(self, value: str): + def session_id(self, value: str) -> None: """设置用户的会话 ID。可以直接使用下面的 unified_msg_origin""" self.session.session_id = value @@ -159,15 +169,18 @@ def get_message_outline(self) -> str: 除了文本消息外,其他消息类型会被转换为对应的占位符。如图片消息会被转换为 [图片]。 """ - return self._outline_chain(self.message_obj.message) + return self._outline_chain(getattr(self.message_obj, "message", None)) def get_messages(self) -> list[BaseMessageComponent]: """获取消息链。""" - return self.message_obj.message + return getattr(self.message_obj, "message", []) def get_message_type(self) -> MessageType: """获取消息类型。""" - return self.message_obj.type + message_type = getattr(self.message_obj, "type", None) + if isinstance(message_type, MessageType): + return message_type + return self.session.message_type def get_session_id(self) -> str: """获取会话id。""" @@ -175,23 +188,32 @@ def get_session_id(self) -> str: def get_group_id(self) -> str: """获取群组id。如果不是群组消息,返回空字符串。""" - return self.message_obj.group_id + return getattr(self.message_obj, "group_id", "") def get_self_id(self) -> str: """获取机器人自身的id。""" - return self.message_obj.self_id + return getattr(self.message_obj, "self_id", "") def get_sender_id(self) -> str: """获取消息发送者的id。""" - return self.message_obj.sender.user_id + sender = getattr(self.message_obj, "sender", None) + if sender and isinstance(getattr(sender, "user_id", None), str): + return sender.user_id + return "" def get_sender_name(self) -> str: """获取消息发送者的名称。(可能会返回空字符串)""" - if isinstance(self.message_obj.sender.nickname, str): - return self.message_obj.sender.nickname - return "" + sender = getattr(self.message_obj, "sender", None) + if not sender: + return "" + nickname = getattr(sender, "nickname", None) + if nickname is None: + return "" + if isinstance(nickname, str): + return nickname + return str(nickname) - def set_extra(self, key, value): + def set_extra(self, key, value) -> None: """设置额外的信息。""" self._extras[key] = value @@ -201,14 +223,14 @@ def get_extra(self, key: str | None = None, default=None) -> Any: return self._extras return self._extras.get(key, default) - def clear_extra(self): + def clear_extra(self) -> None: """清除额外的信息。""" logger.info(f"清除 {self.get_platform_name()} 的额外信息: {self._extras}") self._extras.clear() def is_private_chat(self) -> bool: """是否是私聊。""" - return self.message_obj.type.value == (MessageType.FRIEND_MESSAGE).value + return self.get_message_type() == MessageType.FRIEND_MESSAGE def is_wake_up(self) -> bool: """是否是唤醒机器人的事件。""" @@ -234,7 +256,7 @@ async def send_streaming( self, generator: AsyncGenerator[MessageChain, None], use_fallback: bool = False, - ): + ) -> None: """发送流式消息到消息平台,使用异步生成器。 目前仅支持: telegram,qq official 私聊。 Fallback仅支持 aiocqhttp。 @@ -244,13 +266,19 @@ async def send_streaming( ) self._has_send_oper = True - async def _pre_send(self): + async def send_typing(self) -> None: + """发送输入中状态。 + + 默认实现为空,由具体平台按需重写。 + """ + + async def _pre_send(self) -> None: """调度器会在执行 send() 前调用该方法 deprecated in v3.5.18""" - async def _post_send(self): + async def _post_send(self) -> None: """调度器会在执行 send() 后调用该方法 deprecated in v3.5.18""" - def set_result(self, result: MessageEventResult | str): + def set_result(self, result: MessageEventResult | str) -> None: """设置消息事件的结果。 Note: @@ -279,14 +307,14 @@ async def check_count(self, event: AstrMessageEvent): result.chain = [] self._result = result - def stop_event(self): + def stop_event(self) -> None: """终止事件传播。""" if self._result is None: self.set_result(MessageEventResult().stop_event()) else: self._result.stop_event() - def continue_event(self): + def continue_event(self) -> None: """继续事件传播。""" if self._result is None: self.set_result(MessageEventResult().continue_event()) @@ -299,7 +327,7 @@ def is_stopped(self) -> bool: return False # 默认是继续传播 return self._result.is_stopped() - def should_call_llm(self, call_llm: bool): + def should_call_llm(self, call_llm: bool) -> None: """是否在此消息事件中禁止默认的 LLM 请求。 只会阻止 AstrBot 默认的 LLM 请求链路,不会阻止插件中的 LLM 请求。 @@ -310,7 +338,7 @@ def get_result(self) -> MessageEventResult | None: """获取消息事件的结果。""" return self._result - def clear_result(self): + def clear_result(self) -> None: """清除消息事件的结果。""" self._result = None @@ -404,7 +432,7 @@ def request_llm( """平台适配器""" - async def send(self, message: MessageChain): + async def send(self, message: MessageChain) -> None: """发送消息到消息平台。 Args: @@ -423,7 +451,7 @@ async def send(self, message: MessageChain): ) self._has_send_oper = True - async def react(self, emoji: str): + async def react(self, emoji: str) -> None: """对消息添加表情回应。 默认实现为发送一条包含该表情的消息。 diff --git a/astrbot/core/platform/astrbot_message.py b/astrbot/core/platform/astrbot_message.py index 253963322..3db53fd48 100644 --- a/astrbot/core/platform/astrbot_message.py +++ b/astrbot/core/platform/astrbot_message.py @@ -11,7 +11,7 @@ class MessageMember: user_id: str # 发送者id nickname: str | None = None - def __str__(self): + def __str__(self) -> str: # 使用 f-string 来构建返回的字符串表示形式 return ( f"User ID: {self.user_id}," @@ -34,7 +34,7 @@ class Group: members: list[MessageMember] | None = None """所有群成员""" - def __str__(self): + def __str__(self) -> str: # 使用 f-string 来构建返回的字符串表示形式 return ( f"Group ID: {self.group_id}\n" @@ -78,7 +78,7 @@ def group_id(self) -> str: return "" @group_id.setter - def group_id(self, value: str | None): + def group_id(self, value: str | None) -> None: """设置 group_id""" if value: if self.group: diff --git a/astrbot/core/platform/manager.py b/astrbot/core/platform/manager.py index c8043e56b..0238779da 100644 --- a/astrbot/core/platform/manager.py +++ b/astrbot/core/platform/manager.py @@ -1,6 +1,7 @@ import asyncio import traceback from asyncio import Queue +from dataclasses import dataclass from astrbot.core import logger from astrbot.core.config.astrbot_config import AstrBotConfig @@ -12,12 +13,19 @@ from .sources.webchat.webchat_adapter import WebChatAdapter +@dataclass +class PlatformTasks: + run: asyncio.Task + wrapper: asyncio.Task + + class PlatformManager: - def __init__(self, config: AstrBotConfig, event_queue: Queue): + def __init__(self, config: AstrBotConfig, event_queue: Queue) -> None: self.platform_insts: list[Platform] = [] """加载的 Platform 的实例""" self._inst_map: dict[str, dict] = {} + self._platform_tasks: dict[str, PlatformTasks] = {} self.astrbot_config = config self.platforms_config = config["platform"] @@ -38,7 +46,45 @@ def _sanitize_platform_id(self, platform_id: str | None) -> tuple[str | None, bo sanitized = platform_id.replace(":", "_").replace("!", "_") return sanitized, sanitized != platform_id - async def initialize(self): + def _start_platform_task(self, task_name: str, inst: Platform) -> None: + run_task = asyncio.create_task(inst.run(), name=task_name) + wrapper_task = asyncio.create_task( + self._task_wrapper(run_task, platform=inst), + name=f"{task_name}_wrapper", + ) + self._platform_tasks[inst.client_self_id] = PlatformTasks( + run=run_task, + wrapper=wrapper_task, + ) + + async def _stop_platform_task(self, client_id: str) -> None: + tasks = self._platform_tasks.pop(client_id, None) + if not tasks: + return + for task in (tasks.run, tasks.wrapper): + if not task.done(): + task.cancel() + await asyncio.gather(tasks.run, tasks.wrapper, return_exceptions=True) + + async def _terminate_inst_and_tasks(self, inst: Platform) -> None: + client_id = inst.client_self_id + try: + if getattr(inst, "terminate", None): + try: + await inst.terminate() + except asyncio.CancelledError: + raise + except Exception as e: + logger.error( + "终止平台适配器失败: client_id=%s, error=%s", + client_id, + e, + ) + logger.error(traceback.format_exc()) + finally: + await self._stop_platform_task(client_id) + + async def initialize(self) -> None: """初始化所有平台适配器""" for platform in self.platforms_config: try: @@ -51,14 +97,9 @@ async def initialize(self): # 网页聊天 webchat_inst = WebChatAdapter({}, self.settings, self.event_queue) self.platform_insts.append(webchat_inst) - asyncio.create_task( - self._task_wrapper( - asyncio.create_task(webchat_inst.run(), name="webchat"), - platform=webchat_inst, - ), - ) + self._start_platform_task("webchat", webchat_inst) - async def load_platform(self, platform_config: dict): + async def load_platform(self, platform_config: dict) -> None: """实例化一个平台""" # 动态导入 try: @@ -135,6 +176,10 @@ async def load_platform(self, platform_config: dict): from .sources.satori.satori_adapter import ( SatoriPlatformAdapter, # noqa: F401 ) + case "line": + from .sources.line.line_adapter import ( + LinePlatformAdapter, # noqa: F401 + ) except (ImportError, ModuleNotFoundError) as e: logger.error( f"加载平台适配器 {platform_config['type']} 失败,原因:{e}。请检查依赖库是否安装。提示:可以在 管理面板->平台日志->安装Pip库 中安装依赖库。", @@ -154,15 +199,9 @@ async def load_platform(self, platform_config: dict): "client_id": inst.client_self_id, } self.platform_insts.append(inst) - - asyncio.create_task( - self._task_wrapper( - asyncio.create_task( - inst.run(), - name=f"platform_{platform_config['type']}_{platform_config['id']}", - ), - platform=inst, - ), + self._start_platform_task( + f"platform_{platform_config['type']}_{platform_config['id']}", + inst, ) handlers = star_handlers_registry.get_handlers_by_event_type( EventType.OnPlatformLoadedEvent, @@ -176,7 +215,9 @@ async def load_platform(self, platform_config: dict): except Exception: logger.error(traceback.format_exc()) - async def _task_wrapper(self, task: asyncio.Task, platform: Platform | None = None): + async def _task_wrapper( + self, task: asyncio.Task, platform: Platform | None = None + ) -> None: # 设置平台状态为运行中 if platform: platform.status = PlatformStatus.RUNNING @@ -198,7 +239,7 @@ async def _task_wrapper(self, task: asyncio.Task, platform: Platform | None = No if platform: platform.record_error(error_msg, tb_str) - async def reload(self, platform_config: dict): + async def reload(self, platform_config: dict) -> None: await self.terminate_platform(platform_config["id"]) if platform_config["enable"]: await self.load_platform(platform_config) @@ -209,7 +250,7 @@ async def reload(self, platform_config: dict): if key not in config_ids: await self.terminate_platform(key) - async def terminate_platform(self, platform_id: str): + async def terminate_platform(self, platform_id: str) -> None: if platform_id in self._inst_map: logger.info(f"正在尝试终止 {platform_id} 平台适配器 ...") @@ -228,13 +269,25 @@ async def terminate_platform(self, platform_id: str): except Exception: logger.warning(f"可能未完全移除 {platform_id} 平台适配器") - if getattr(inst, "terminate", None): - await inst.terminate() + await self._terminate_inst_and_tasks(inst) - async def terminate(self): - for inst in self.platform_insts: - if getattr(inst, "terminate", None): - await inst.terminate() + async def terminate(self) -> None: + terminated_client_ids: set[str] = set() + for platform_id in list(self._inst_map.keys()): + info = self._inst_map.get(platform_id) + if info: + terminated_client_ids.add(info["client_id"]) + await self.terminate_platform(platform_id) + + for inst in list(self.platform_insts): + client_id = inst.client_self_id + if client_id in terminated_client_ids: + continue + await self._terminate_inst_and_tasks(inst) + + self.platform_insts.clear() + self._inst_map.clear() + self._platform_tasks.clear() def get_insts(self): return self.platform_insts diff --git a/astrbot/core/platform/message_session.py b/astrbot/core/platform/message_session.py index b282b307a..89639941e 100644 --- a/astrbot/core/platform/message_session.py +++ b/astrbot/core/platform/message_session.py @@ -15,7 +15,7 @@ class MessageSession: session_id: str platform_id: str = field(init=False) - def __str__(self): + def __str__(self) -> str: return f"{self.platform_id}:{self.message_type.value}:{self.session_id}" def __post_init__(self): diff --git a/astrbot/core/platform/platform.py b/astrbot/core/platform/platform.py index 8592273d1..a7c181217 100644 --- a/astrbot/core/platform/platform.py +++ b/astrbot/core/platform/platform.py @@ -34,7 +34,7 @@ class PlatformError: class Platform(abc.ABC): - def __init__(self, config: dict, event_queue: Queue): + def __init__(self, config: dict, event_queue: Queue) -> None: super().__init__() # 平台配置 self.config = config @@ -53,7 +53,7 @@ def status(self) -> PlatformStatus: return self._status @status.setter - def status(self, value: PlatformStatus): + def status(self, value: PlatformStatus) -> None: """设置平台运行状态""" self._status = value if value == PlatformStatus.RUNNING and self._started_at is None: @@ -69,12 +69,12 @@ def last_error(self) -> PlatformError | None: """获取最近的错误""" return self._errors[-1] if self._errors else None - def record_error(self, message: str, traceback_str: str | None = None): + def record_error(self, message: str, traceback_str: str | None = None) -> None: """记录一个错误""" self._errors.append(PlatformError(message=message, traceback=traceback_str)) self._status = PlatformStatus.ERROR - def clear_errors(self): + def clear_errors(self) -> None: """清除错误记录""" self._errors.clear() if self._status == PlatformStatus.ERROR: @@ -121,7 +121,7 @@ def run(self) -> Coroutine[Any, Any, None]: """得到一个平台的运行实例,需要返回一个协程对象。""" raise NotImplementedError - async def terminate(self): + async def terminate(self) -> None: """终止一个平台的运行实例。""" @abc.abstractmethod @@ -140,11 +140,11 @@ async def send_by_session( """ await Metric.upload(msg_event_tick=1, adapter_name=self.meta().name) - def commit_event(self, event: AstrMessageEvent): + def commit_event(self, event: AstrMessageEvent) -> None: """提交一个事件到事件队列。""" self._event_queue.put_nowait(event) - def get_client(self): + def get_client(self) -> object: """获取平台的客户端对象。""" async def webhook_callback(self, request: Any) -> Any: diff --git a/astrbot/core/platform/platform_metadata.py b/astrbot/core/platform/platform_metadata.py index 00e7dc966..2d01b921d 100644 --- a/astrbot/core/platform/platform_metadata.py +++ b/astrbot/core/platform/platform_metadata.py @@ -24,3 +24,14 @@ class PlatformMetadata: module_path: str | None = None """注册该适配器的模块路径,用于插件热重载时清理""" + i18n_resources: dict[str, dict] | None = None + """国际化资源数据,如 {"zh-CN": {...}, "en-US": {...}} + + 参考 https://github.com/AstrBotDevs/AstrBot/pull/5045 + """ + + config_metadata: dict | None = None + """配置项元数据,用于 WebUI 生成表单。对应 config_metadata.json 的内容 + + 参考 https://github.com/AstrBotDevs/AstrBot/pull/5045 + """ diff --git a/astrbot/core/platform/register.py b/astrbot/core/platform/register.py index 3bbec4809..62ec5070a 100644 --- a/astrbot/core/platform/register.py +++ b/astrbot/core/platform/register.py @@ -15,11 +15,14 @@ def register_platform_adapter( adapter_display_name: str | None = None, logo_path: str | None = None, support_streaming_message: bool = True, + i18n_resources: dict[str, dict] | None = None, + config_metadata: dict | None = None, ): """用于注册平台适配器的带参装饰器。 default_config_tmpl 指定了平台适配器的默认配置模板。用户填写好后将会作为 platform_config 传入你的 Platform 类的实现类。 logo_path 指定了平台适配器的 logo 文件路径,是相对于插件目录的路径。 + config_metadata 指定了配置项的元数据,用于 WebUI 生成表单。如果不指定,WebUI 将会把配置项渲染为原始的键值对编辑框。 """ def decorator(cls): @@ -49,6 +52,8 @@ def decorator(cls): logo_path=logo_path, support_streaming_message=support_streaming_message, module_path=module_path, + i18n_resources=i18n_resources, + config_metadata=config_metadata, ) platform_registry.append(pm) platform_cls_map[adapter_name] = cls diff --git a/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_message_event.py b/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_message_event.py index 3d84cbd44..7e42a0fd8 100644 --- a/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_message_event.py +++ b/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_message_event.py @@ -26,7 +26,7 @@ def __init__( platform_meta, session_id, bot: CQHttp, - ): + ) -> None: super().__init__(message_str, message_obj, platform_meta, session_id) self.bot = bot @@ -45,6 +45,19 @@ async def _from_segment_to_dict(segment: BaseMessageComponent) -> dict: if isinstance(segment, File): # For File segments, we need to handle the file differently d = await segment.to_dict() + file_val = d.get("data", {}).get("file", "") + if file_val: + import pathlib + + try: + # 使用 pathlib 处理路径,能更好地处理 Windows/Linux 差异 + path_obj = pathlib.Path(file_val) + # 如果是绝对路径且不包含协议头 (://),则转换为标准的 file: URI + if path_obj.is_absolute() and "://" not in file_val: + d["data"]["file"] = path_obj.as_uri() + except Exception: + # 如果不是合法路径(例如已经是特定的特殊字符串),则跳过转换 + pass return d if isinstance(segment, Video): d = await segment.to_dict() @@ -72,7 +85,7 @@ async def _dispatch_send( is_group: bool, session_id: str | None, messages: list[dict], - ): + ) -> None: # session_id 必须是纯数字字符串 session_id_int = ( int(session_id) if session_id and session_id.isdigit() else None @@ -97,7 +110,7 @@ async def send_message( event: Event | None = None, is_group: bool = False, session_id: str | None = None, - ): + ) -> None: """发送消息至 QQ 协议端(aiocqhttp)。 Args: @@ -143,7 +156,7 @@ async def send_message( await cls._dispatch_send(bot, event, is_group, session_id, messages) await asyncio.sleep(0.5) - async def send(self, message: MessageChain): + async def send(self, message: MessageChain) -> None: """发送消息""" event = getattr(self.message_obj, "raw_message", None) diff --git a/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_platform_adapter.py b/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_platform_adapter.py index 8540ff592..45114382f 100644 --- a/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_platform_adapter.py +++ b/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_platform_adapter.py @@ -1,4 +1,5 @@ import asyncio +import inspect import itertools import logging import time @@ -61,7 +62,7 @@ def __init__( ) @self.bot.on_request() - async def request(event: Event): + async def request(event: Event) -> None: try: abm = await self.convert_message(event) if not abm: @@ -72,7 +73,7 @@ async def request(event: Event): return @self.bot.on_notice() - async def notice(event: Event): + async def notice(event: Event) -> None: try: abm = await self.convert_message(event) if abm: @@ -82,7 +83,7 @@ async def notice(event: Event): return @self.bot.on_message("group") - async def group(event: Event): + async def group(event: Event) -> None: try: abm = await self.convert_message(event) if abm: @@ -92,7 +93,7 @@ async def group(event: Event): return @self.bot.on_message("private") - async def private(event: Event): + async def private(event: Event) -> None: try: abm = await self.convert_message(event) if abm: @@ -102,14 +103,14 @@ async def private(event: Event): return @self.bot.on_websocket_connection - def on_websocket_connection(_): + def on_websocket_connection(_) -> None: logger.info("aiocqhttp(OneBot v11) 适配器已连接。") async def send_by_session( self, session: MessageSesion, message_chain: MessageChain, - ): + ) -> None: is_group = session.message_type == MessageType.GROUP_MESSAGE if is_group: session_id = session.session_id.split("_")[-1] @@ -418,9 +419,9 @@ async def _convert_handle_message_event( def run(self) -> Awaitable[Any]: if not self.host or not self.port: logger.warning( - "aiocqhttp: 未配置 ws_reverse_host 或 ws_reverse_port,将使用默认值:http://[::]:6199", + "aiocqhttp: 未配置 ws_reverse_host 或 ws_reverse_port,将使用默认值:http://0.0.0.0:6199", ) - self.host = "::" + self.host = "0.0.0.0" self.port = 6199 coro = self.bot.run_task( @@ -435,17 +436,52 @@ def run(self) -> Awaitable[Any]: self.shutdown_event = asyncio.Event() return coro - async def terminate(self): - self.shutdown_event.set() + async def terminate(self) -> None: + if hasattr(self, "shutdown_event"): + self.shutdown_event.set() + await self._close_reverse_ws_connections() + + async def _close_reverse_ws_connections(self) -> None: + api_clients = getattr(self.bot, "_wsr_api_clients", None) + event_clients = getattr(self.bot, "_wsr_event_clients", None) + + ws_clients: set[Any] = set() + if isinstance(api_clients, dict): + ws_clients.update(api_clients.values()) + if isinstance(event_clients, set): + ws_clients.update(event_clients) + + close_tasks: list[Awaitable[Any]] = [] + for ws in ws_clients: + close_func = getattr(ws, "close", None) + if not callable(close_func): + continue + try: + close_result = close_func(code=1000, reason="Adapter shutdown") + except TypeError: + close_result = close_func() + except Exception: + continue + + if inspect.isawaitable(close_result): + close_tasks.append(close_result) + + if close_tasks: + await asyncio.gather(*close_tasks, return_exceptions=True) + + if isinstance(api_clients, dict): + api_clients.clear() + if isinstance(event_clients, set): + event_clients.clear() - async def shutdown_trigger_placeholder(self): + async def shutdown_trigger_placeholder(self) -> None: await self.shutdown_event.wait() logger.info("aiocqhttp 适配器已被关闭") def meta(self) -> PlatformMetadata: return self.metadata - async def handle_msg(self, message: AstrBotMessage): + async def handle_msg(self, message: AstrBotMessage) -> None: message_event = AiocqhttpMessageEvent( message_str=message.message_str, message_obj=message, diff --git a/astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py b/astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py index 8c93ab40f..2d9b45cc1 100644 --- a/astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py +++ b/astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py @@ -1,8 +1,9 @@ import asyncio -import os +import json import threading import uuid -from typing import cast +from pathlib import Path +from typing import Literal, NoReturn, cast import aiohttp import dingtalk_stream @@ -10,7 +11,7 @@ from astrbot import logger from astrbot.api.event import MessageChain -from astrbot.api.message_components import At, Image, Plain +from astrbot.api.message_components import At, Image, Plain, Record, Video from astrbot.api.platform import ( AstrBotMessage, MessageMember, @@ -18,9 +19,16 @@ Platform, PlatformMetadata, ) +from astrbot.core import sp from astrbot.core.platform.astr_message_event import MessageSesion -from astrbot.core.utils.astrbot_path import get_astrbot_data_path +from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from astrbot.core.utils.io import download_file +from astrbot.core.utils.media_utils import ( + convert_audio_format, + convert_video_format, + extract_video_cover, + get_media_duration, +) from ...register import register_platform_adapter from .dingtalk_event import DingtalkMessageEvent @@ -75,8 +83,6 @@ async def process(self, message: dingtalk_stream.CallbackMessage): ) self.client_ = client # 用于 websockets 的 client self._shutdown_event: threading.Event | None = None - self.card_template_id = platform_config.get("card_template_id") - self.card_instance_id_dict = {} def _id_to_sid(self, dingtalk_id: str | None) -> str: if not dingtalk_id: @@ -90,8 +96,45 @@ async def send_by_session( self, session: MessageSesion, message_chain: MessageChain, - ): - raise NotImplementedError("钉钉机器人适配器不支持 send_by_session") + ) -> None: + robot_code = self.client_id + + if session.message_type == MessageType.GROUP_MESSAGE: + open_conversation_id = session.session_id + await self.send_message_chain_to_group( + open_conversation_id=open_conversation_id, + robot_code=robot_code, + message_chain=message_chain, + ) + else: + staff_id = await self._get_sender_staff_id(session) + if not staff_id: + logger.warning( + "钉钉私聊会话缺少 staff_id 映射,回退使用 session_id 作为 userId 发送", + ) + staff_id = session.session_id + await self.send_message_chain_to_user( + staff_id=staff_id, + robot_code=robot_code, + message_chain=message_chain, + ) + + await super().send_by_session(session, message_chain) + + async def send_with_session( + self, + session: MessageSesion, + message_chain: MessageChain, + ) -> None: + await self.send_by_session(session, message_chain) + + async def send_with_sesison( + self, + session: MessageSesion, + message_chain: MessageChain, + ) -> None: + # backward typo compatibility + await self.send_by_session(session, message_chain) def meta(self) -> PlatformMetadata: return PlatformMetadata( @@ -99,65 +142,9 @@ def meta(self) -> PlatformMetadata: description="钉钉机器人官方 API 适配器", id=cast(str, self.config.get("id")), support_streaming_message=True, - support_proactive_message=False, + support_proactive_message=True, ) - async def create_message_card( - self, message_id: str, incoming_message: dingtalk_stream.ChatbotMessage - ): - if not self.card_template_id: - return False - - card_instance = dingtalk_stream.AICardReplier(self.client_, incoming_message) - card_data = {"content": ""} # Initial content empty - - try: - card_instance_id = await card_instance.async_create_and_deliver_card( - self.card_template_id, - card_data, - ) - self.card_instance_id_dict[message_id] = (card_instance, card_instance_id) - return True - except Exception as e: - logger.error(f"创建钉钉卡片失败: {e}") - return False - - async def send_card_message(self, message_id: str, content: str, is_final: bool): - if message_id not in self.card_instance_id_dict: - return - - card_instance, card_instance_id = self.card_instance_id_dict[message_id] - content_key = "content" - - try: - # 钉钉卡片流式更新 - - await card_instance.async_streaming( - card_instance_id, - content_key=content_key, - content_value=content, - append=False, - finished=is_final, - failed=False, - ) - except Exception as e: - logger.error(f"发送钉钉卡片消息失败: {e}") - # Try to report failure - try: - await card_instance.async_streaming( - card_instance_id, - content_key=content_key, - content_value=content, # Keep existing content - append=False, - finished=True, - failed=True, - ) - except Exception: - pass - - if is_final: - self.card_instance_id_dict.pop(message_id, None) - async def convert_msg( self, message: dingtalk_stream.ChatbotMessage, @@ -215,8 +202,35 @@ async def convert_msg( case "audio": pass + await self._remember_sender_binding(message, abm) return abm # 别忘了返回转换后的消息对象 + async def _remember_sender_binding( + self, + message: dingtalk_stream.ChatbotMessage, + abm: AstrBotMessage, + ) -> None: + try: + if abm.type == MessageType.FRIEND_MESSAGE: + sender_id = abm.sender.user_id + sender_staff_id = cast(str, message.sender_staff_id or "") + if sender_staff_id: + umo = str( + MessageSesion( + platform_name=self.meta().id, + message_type=abm.type, + session_id=sender_id, + ) + ) + await sp.put_async( + "global", + umo, + "dingtalk_staffid", + sender_staff_id, + ) + except Exception as e: + logger.warning(f"保存钉钉会话映射失败: {e}") + async def download_ding_file( self, download_code: str, @@ -239,8 +253,9 @@ async def download_ding_file( "downloadCode": download_code, "robotCode": robot_code, } - temp_dir = os.path.join(get_astrbot_data_path(), "temp") - f_path = os.path.join(temp_dir, f"dingtalk_file_{uuid.uuid4()}.{ext}") + temp_dir = Path(get_astrbot_temp_path()) + temp_dir.mkdir(parents=True, exist_ok=True) + f_path = temp_dir / f"dingtalk_{uuid.uuid4()}.{ext}" async with ( aiohttp.ClientSession() as session, session.post( @@ -256,14 +271,21 @@ async def download_ding_file( return "" resp_data = await resp.json() download_url = resp_data["data"]["downloadUrl"] - await download_file(download_url, f_path) - return f_path + await download_file(download_url, str(f_path)) + return str(f_path) async def get_access_token(self) -> str: - payload = { - "appKey": self.client_id, - "appSecret": self.client_secret, - } + try: + access_token = await asyncio.get_event_loop().run_in_executor( + None, + self.client_.get_access_token, + ) + if access_token: + return access_token + except Exception as e: + logger.warning(f"通过 dingtalk_stream 获取 access_token 失败: {e}") + + payload = {"appKey": self.client_id, "appSecret": self.client_secret} async with aiohttp.ClientSession() as session: async with session.post( "https://api.dingtalk.com/v1.0/oauth2/accessToken", @@ -274,9 +296,330 @@ async def get_access_token(self) -> str: f"获取钉钉机器人 access_token 失败: {resp.status}, {await resp.text()}", ) return "" - return (await resp.json())["data"]["accessToken"] + data = await resp.json() + return cast(str, data.get("data", {}).get("accessToken", "")) + + async def _get_sender_staff_id(self, session: MessageSesion) -> str: + try: + staff_id = await sp.get_async( + "global", + str(session), + "dingtalk_staffid", + "", + ) + return cast(str, staff_id or "") + except Exception as e: + logger.warning(f"读取钉钉 staff_id 映射失败: {e}") + return "" + + async def _send_group_message( + self, + open_conversation_id: str, + robot_code: str, + msg_key: str, + msg_param: dict, + ) -> None: + access_token = await self.get_access_token() + if not access_token: + logger.error("钉钉群消息发送失败: access_token 为空") + return + + payload = { + "msgKey": msg_key, + "msgParam": json.dumps(msg_param, ensure_ascii=False), + "openConversationId": open_conversation_id, + "robotCode": robot_code, + } + headers = { + "Content-Type": "application/json", + "x-acs-dingtalk-access-token": access_token, + } + async with aiohttp.ClientSession() as session: + async with session.post( + "https://api.dingtalk.com/v1.0/robot/groupMessages/send", + headers=headers, + json=payload, + ) as resp: + if resp.status != 200: + logger.error( + f"钉钉群消息发送失败: {resp.status}, {await resp.text()}", + ) + + async def _send_private_message( + self, + staff_id: str, + robot_code: str, + msg_key: str, + msg_param: dict, + ) -> None: + access_token = await self.get_access_token() + if not access_token: + logger.error("钉钉私聊消息发送失败: access_token 为空") + return + + payload = { + "robotCode": robot_code, + "userIds": [staff_id], + "msgKey": msg_key, + "msgParam": json.dumps(msg_param, ensure_ascii=False), + } + headers = { + "Content-Type": "application/json", + "x-acs-dingtalk-access-token": access_token, + } + async with aiohttp.ClientSession() as session: + async with session.post( + "https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend", + headers=headers, + json=payload, + ) as resp: + if resp.status != 200: + logger.error( + f"钉钉私聊消息发送失败: {resp.status}, {await resp.text()}", + ) + + def _safe_remove_file(self, file_path: str | None) -> None: + if not file_path: + return + try: + p = Path(file_path) + if p.exists() and p.is_file(): + p.unlink() + except Exception as e: + logger.warning(f"清理临时文件失败: {file_path}, {e}") + + async def _prepare_voice_for_dingtalk(self, input_path: str) -> tuple[str, bool]: + """优先转换为 OGG(Opus),不可用时回退 AMR。""" + lower_path = input_path.lower() + if lower_path.endswith((".amr", ".ogg")): + return input_path, False + + try: + converted = await convert_audio_format(input_path, "ogg") + return converted, converted != input_path + except Exception as e: + logger.warning(f"钉钉语音转 OGG 失败,回退 AMR: {e}") + converted = await convert_audio_format(input_path, "amr") + return converted, converted != input_path + + async def upload_media(self, file_path: str, media_type: str) -> str: + media_file_path = Path(file_path) + access_token = await self.get_access_token() + if not access_token: + logger.error("钉钉媒体上传失败: access_token 为空") + return "" + + form = aiohttp.FormData() + form.add_field( + "media", + media_file_path.read_bytes(), + filename=media_file_path.name, + content_type="application/octet-stream", + ) + async with aiohttp.ClientSession() as session: + async with session.post( + f"https://oapi.dingtalk.com/media/upload?access_token={access_token}&type={media_type}", + data=form, + ) as resp: + if resp.status != 200: + logger.error( + f"钉钉媒体上传失败: {resp.status}, {await resp.text()}" + ) + return "" + data = await resp.json() + if data.get("errcode") != 0: + logger.error(f"钉钉媒体上传失败: {data}") + return "" + return cast(str, data.get("media_id", "")) + + async def upload_image(self, image: Image) -> str: + image_file_path = await image.convert_to_file_path() + return await self.upload_media(image_file_path, "image") + + async def _send_message_chain( + self, + target_type: Literal["group", "user"], + target_id: str, + robot_code: str, + message_chain: MessageChain, + at_str: str = "", + ) -> None: + async def send_message(msg_key: str, msg_param: dict) -> None: + if target_type == "group": + await self._send_group_message( + open_conversation_id=target_id, + robot_code=robot_code, + msg_key=msg_key, + msg_param=msg_param, + ) + else: + await self._send_private_message( + staff_id=target_id, + robot_code=robot_code, + msg_key=msg_key, + msg_param=msg_param, + ) + + for segment in message_chain.chain: + if isinstance(segment, Plain): + text = segment.text.strip() + if not text and not at_str: + continue + await send_message( + msg_key="sampleMarkdown", + msg_param={ + "title": "AstrBot", + "text": f"{at_str} {text}".strip(), + }, + ) + elif isinstance(segment, Image): + photo_url = segment.file or segment.url or "" + if photo_url.startswith(("http://", "https://")): + pass + else: + photo_url = await self.upload_image(segment) + if not photo_url: + continue + await send_message( + msg_key="sampleImageMsg", + msg_param={"photoURL": photo_url}, + ) + elif isinstance(segment, Record): + converted_audio = None + try: + audio_path = await segment.convert_to_file_path() + ( + audio_path, + converted_audio, + ) = await self._prepare_voice_for_dingtalk(audio_path) + media_id = await self.upload_media(audio_path, "voice") + if not media_id: + continue + duration_ms = await get_media_duration(audio_path) + await send_message( + msg_key="sampleAudio", + msg_param={ + "mediaId": media_id, + "duration": str(duration_ms or 1000), + }, + ) + except Exception as e: + logger.warning(f"钉钉语音发送失败: {e}") + continue + finally: + if converted_audio: + self._safe_remove_file(audio_path) + elif isinstance(segment, Video): + converted_video = False + cover_path = None + try: + source_video_path = await segment.convert_to_file_path() + video_path = source_video_path + if not video_path.lower().endswith(".mp4"): + video_path = await convert_video_format(video_path, "mp4") + converted_video = video_path != source_video_path + cover_path = await extract_video_cover(video_path) + video_media_id = await self.upload_media(video_path, "file") + pic_media_id = await self.upload_media(cover_path, "image") + if not video_media_id or not pic_media_id: + continue + duration_ms = await get_media_duration(video_path) + duration_sec = max(1, int((duration_ms or 1000) / 1000)) + await send_message( + msg_key="sampleVideo", + msg_param={ + "duration": str(duration_sec), + "videoMediaId": video_media_id, + "videoType": "mp4", + "picMediaId": pic_media_id, + }, + ) + except Exception as e: + logger.warning(f"钉钉视频发送失败: {e}") + continue + finally: + self._safe_remove_file(cover_path) + if converted_video: + self._safe_remove_file(video_path) + + async def send_message_chain_to_group( + self, + open_conversation_id: str, + robot_code: str, + message_chain: MessageChain, + at_str: str = "", + ) -> None: + await self._send_message_chain( + target_type="group", + target_id=open_conversation_id, + robot_code=robot_code, + message_chain=message_chain, + at_str=at_str, + ) + + async def send_message_chain_to_user( + self, + staff_id: str, + robot_code: str, + message_chain: MessageChain, + at_str: str = "", + ) -> None: + await self._send_message_chain( + target_type="user", + target_id=staff_id, + robot_code=robot_code, + message_chain=message_chain, + at_str=at_str, + ) + + async def send_message_chain_with_incoming( + self, + incoming_message: dingtalk_stream.ChatbotMessage, + message_chain: MessageChain, + ) -> None: + robot_code = self.client_id + + # at_list: list[str] = [] + sender_id = cast(str, incoming_message.sender_id or "") + sender_staff_id = cast(str, incoming_message.sender_staff_id or "") + normalized_sender_id = self._id_to_sid(sender_id) + # 现在用的发消息接口不支持 at + # for segment in message_chain.chain: + # if isinstance(segment, At): + # if ( + # str(segment.qq) in {sender_id, normalized_sender_id} + # and sender_staff_id + # ): + # at_list.append(f"@{sender_staff_id}") + # else: + # at_list.append(f"@{segment.qq}") + # at_str = " ".join(at_list) + + if incoming_message.conversation_type == "2": + await self.send_message_chain_to_group( + open_conversation_id=cast(str, incoming_message.conversation_id), + robot_code=robot_code, + message_chain=message_chain, + # at_str=at_str, + ) + else: + session = MessageSesion( + platform_name=self.meta().id, + message_type=MessageType.FRIEND_MESSAGE, + session_id=normalized_sender_id, + ) + staff_id = sender_staff_id or await self._get_sender_staff_id(session) + if not staff_id: + logger.error("钉钉私聊回复失败: 缺少 sender_staff_id") + return + await self.send_message_chain_to_user( + staff_id=staff_id, + robot_code=robot_code, + message_chain=message_chain, + # at_str=at_str, + ) - async def handle_msg(self, abm: AstrBotMessage): + async def handle_msg(self, abm: AstrBotMessage) -> None: event = DingtalkMessageEvent( message_str=abm.message_str, message_obj=abm, @@ -288,10 +631,10 @@ async def handle_msg(self, abm: AstrBotMessage): self._event_queue.put_nowait(event) - async def run(self): + async def run(self) -> None: # await self.client_.start() # 钉钉的 SDK 并没有实现真正的异步,start() 里面有堵塞方法。 - def start_client(loop: asyncio.AbstractEventLoop): + def start_client(loop: asyncio.AbstractEventLoop) -> None: try: self._shutdown_event = threading.Event() task = loop.create_task(self.client_.start()) @@ -307,8 +650,8 @@ def start_client(loop: asyncio.AbstractEventLoop): loop = asyncio.get_event_loop() await loop.run_in_executor(None, start_client, loop) - async def terminate(self): - def monkey_patch_close(): + async def terminate(self) -> None: + def monkey_patch_close() -> NoReturn: raise KeyboardInterrupt("Graceful shutdown") if self.client_.websocket is not None: diff --git a/astrbot/core/platform/sources/dingtalk/dingtalk_event.py b/astrbot/core/platform/sources/dingtalk/dingtalk_event.py index 5af0d6eb0..3331c5147 100644 --- a/astrbot/core/platform/sources/dingtalk/dingtalk_event.py +++ b/astrbot/core/platform/sources/dingtalk/dingtalk_event.py @@ -1,9 +1,5 @@ -import asyncio -from typing import Any, cast +from typing import Any -import dingtalk_stream - -import astrbot.api.message_components as Comp from astrbot import logger from astrbot.api.event import AstrMessageEvent, MessageChain @@ -15,128 +11,33 @@ def __init__( message_obj, platform_meta, session_id, - client: dingtalk_stream.ChatbotHandler, + client: Any = None, adapter: "Any" = None, - ): + ) -> None: super().__init__(message_str, message_obj, platform_meta, session_id) self.client = client self.adapter = adapter - async def send_with_client( - self, - client: dingtalk_stream.ChatbotHandler, - message: MessageChain, - ): - icm = cast(dingtalk_stream.ChatbotMessage, self.message_obj.raw_message) - ats = [] - # fixes: #4218 - # 钉钉 at 机器人需要使用 sender_staff_id 而不是 sender_id - for i in message.chain: - if isinstance(i, Comp.At): - print(i.qq, icm.sender_id, icm.sender_staff_id) - if str(i.qq) in str(icm.sender_id or ""): - # 适配器会将开头的 $:LWCP_v1:$ 去掉,因此我们用 in 判断 - ats.append(f"@{icm.sender_staff_id}") - else: - ats.append(f"@{i.qq}") - at_str = " ".join(ats) - - for segment in message.chain: - if isinstance(segment, Comp.Plain): - segment.text = segment.text.strip() - await asyncio.get_event_loop().run_in_executor( - None, - client.reply_markdown, - segment.text, - f"{at_str} {segment.text}".strip(), - cast(dingtalk_stream.ChatbotMessage, self.message_obj.raw_message), - ) - elif isinstance(segment, Comp.Image): - markdown_str = "" - - try: - if not segment.file: - logger.warning("钉钉图片 segment 缺少 file 字段,跳过") - continue - if segment.file.startswith(("http://", "https://")): - image_url = segment.file - else: - image_url = await segment.register_to_file_service() - - markdown_str = f"![image]({image_url})\n\n" - - ret = await asyncio.get_event_loop().run_in_executor( - None, - client.reply_markdown, - "😄", - markdown_str, - cast( - dingtalk_stream.ChatbotMessage, self.message_obj.raw_message - ), - ) - logger.debug(f"send image: {ret}") - - except Exception as e: - logger.warning(f"钉钉图片处理失败: {e}, 跳过图片发送") - continue - - async def send(self, message: MessageChain): - await self.send_with_client(self.client, message) + async def send(self, message: MessageChain) -> None: + if not self.adapter: + logger.error("钉钉消息发送失败: 缺少 adapter") + return + await self.adapter.send_message_chain_with_incoming( + incoming_message=self.message_obj.raw_message, + message_chain=message, + ) await super().send(message) async def send_streaming(self, generator, use_fallback: bool = False): - if not self.adapter or not self.adapter.card_template_id: - logger.warning( - f"DingTalk streaming is enabled, but 'card_template_id' is not configured for platform '{self.platform_meta.id}'. Falling back to text streaming." - ) - # Fallback to default behavior (buffer and send) - buffer = None - async for chain in generator: - if not buffer: - buffer = chain - else: - buffer.chain.extend(chain.chain) - if not buffer: - return None - buffer.squash_plain() - await self.send(buffer) - return await super().send_streaming(generator, use_fallback) - - # Create card - msg_id = self.message_obj.message_id - incoming_msg = self.message_obj.raw_message - created = await self.adapter.create_message_card(msg_id, incoming_msg) - - if not created: - # Fallback to default behavior (buffer and send) - buffer = None - async for chain in generator: - if not buffer: - buffer = chain - else: - buffer.chain.extend(chain.chain) + # 钉钉统一回退为缓冲发送:最终发送仍使用新的 HTTP 消息接口。 + buffer = None + async for chain in generator: if not buffer: - return None - buffer.squash_plain() - await self.send(buffer) - return await super().send_streaming(generator, use_fallback) - - full_content = "" - seq = 0 - try: - async for chain in generator: - for segment in chain.chain: - if isinstance(segment, Comp.Plain): - full_content += segment.text - - seq += 1 - if seq % 2 == 0: # Update every 2 chunks to be more responsive than 8 - await self.adapter.send_card_message( - msg_id, full_content, is_final=False - ) - - await self.adapter.send_card_message(msg_id, full_content, is_final=True) - except Exception as e: - logger.error(f"DingTalk streaming error: {e}") - # Try to ensure final state is sent or cleaned up? - await self.adapter.send_card_message(msg_id, full_content, is_final=True) + buffer = chain + else: + buffer.chain.extend(chain.chain) + if not buffer: + return None + buffer.squash_plain() + await self.send(buffer) + return await super().send_streaming(generator, use_fallback) diff --git a/astrbot/core/platform/sources/discord/client.py b/astrbot/core/platform/sources/discord/client.py index ac0610f2a..ebd32c471 100644 --- a/astrbot/core/platform/sources/discord/client.py +++ b/astrbot/core/platform/sources/discord/client.py @@ -15,7 +15,7 @@ class DiscordBotClient(discord.Bot): """Discord客户端封装""" - def __init__(self, token: str, proxy: str | None = None): + def __init__(self, token: str, proxy: str | None = None) -> None: self.token = token self.proxy = proxy @@ -32,7 +32,7 @@ def __init__(self, token: str, proxy: str | None = None): self.on_ready_once_callback: Callable[[], Awaitable[None]] | None = None self._ready_once_fired = False - async def on_ready(self): + async def on_ready(self) -> None: """当机器人成功连接并准备就绪时触发""" if self.user is None: logger.error("[Discord] 客户端未正确加载用户信息 (self.user is None)") @@ -93,7 +93,7 @@ def _create_interaction_data(self, interaction: discord.Interaction) -> dict: "type": "interaction", } - async def on_message(self, message: discord.Message): + async def on_message(self, message: discord.Message) -> None: """当接收到消息时触发""" if message.author.bot: return @@ -130,12 +130,12 @@ def _extract_interaction_content(self, interaction: discord.Interaction) -> str: return str(interaction_data) - async def start_polling(self): + async def start_polling(self) -> None: """开始轮询消息,这是个阻塞方法""" await self.start(self.token) @override - async def close(self): + async def close(self) -> None: """关闭客户端""" if not self.is_closed(): await super().close() diff --git a/astrbot/core/platform/sources/discord/components.py b/astrbot/core/platform/sources/discord/components.py index f875652a0..433509f5e 100644 --- a/astrbot/core/platform/sources/discord/components.py +++ b/astrbot/core/platform/sources/discord/components.py @@ -19,7 +19,7 @@ def __init__( image: str | None = None, footer: str | None = None, fields: list[dict] | None = None, - ): + ) -> None: self.title = title self.description = description self.color = color @@ -71,7 +71,7 @@ def __init__( emoji: str | None = None, url: str | None = None, disabled: bool = False, - ): + ) -> None: self.label = label self.custom_id = custom_id self.style = style @@ -85,7 +85,7 @@ class DiscordReference(BaseMessageComponent): type: str = "discord_reference" - def __init__(self, message_id: str, channel_id: str): + def __init__(self, message_id: str, channel_id: str) -> None: self.message_id = message_id self.channel_id = channel_id @@ -99,7 +99,7 @@ def __init__( self, components: list[BaseMessageComponent] | None = None, timeout: float | None = None, - ): + ) -> None: self.components = components or [] self.timeout = timeout diff --git a/astrbot/core/platform/sources/discord/discord_platform_adapter.py b/astrbot/core/platform/sources/discord/discord_platform_adapter.py index ed9899f6f..7657962a1 100644 --- a/astrbot/core/platform/sources/discord/discord_platform_adapter.py +++ b/astrbot/core/platform/sources/discord/discord_platform_adapter.py @@ -60,7 +60,7 @@ async def send_by_session( self, session: MessageSesion, message_chain: MessageChain, - ): + ) -> None: """通过会话发送消息""" if self.client.user is None: logger.error( @@ -122,11 +122,11 @@ def meta(self) -> PlatformMetadata: ) @override - async def run(self): + async def run(self) -> None: """主要运行逻辑""" # 初始化回调函数 - async def on_received(message_data): + async def on_received(message_data) -> None: logger.debug(f"[Discord] 收到消息: {message_data}") if self.client_self_id is None: self.client_self_id = message_data.get("bot_id") @@ -143,7 +143,7 @@ async def on_received(message_data): self.client = DiscordBotClient(token, proxy) self.client.on_message_received = on_received - async def callback(): + async def callback() -> None: if self.enable_command_register: await self._collect_and_register_commands() if self.activity_name: @@ -251,7 +251,7 @@ async def convert_message(self, data: dict) -> AstrBotMessage: # 由于 on_interaction 已被禁用,我们只处理普通消息 return self._convert_message_to_abm(data) - async def handle_msg(self, message: AstrBotMessage, followup_webhook=None): + async def handle_msg(self, message: AstrBotMessage, followup_webhook=None) -> None: """处理消息""" message_event = DiscordPlatformEvent( message_str=message.message_str, @@ -323,7 +323,7 @@ async def handle_msg(self, message: AstrBotMessage, followup_webhook=None): self.commit_event(message_event) @override - async def terminate(self): + async def terminate(self) -> None: """终止适配器""" logger.info("[Discord] 正在终止适配器... (step 1: cancel polling task)") self.shutdown_event.set() @@ -358,11 +358,11 @@ async def terminate(self): logger.warning(f"[Discord] 客户端关闭异常: {e}") logger.info("[Discord] 适配器已终止。") - def register_handler(self, handler_info): + def register_handler(self, handler_info) -> None: """注册处理器信息""" self.registered_handlers.append(handler_info) - async def _collect_and_register_commands(self): + async def _collect_and_register_commands(self) -> None: """收集所有指令并注册到Discord""" logger.info("[Discord] 开始收集并注册斜杠指令...") registered_commands = [] @@ -420,7 +420,7 @@ def _create_dynamic_callback(self, cmd_name: str): async def dynamic_callback( ctx: discord.ApplicationContext, params: str | None = None - ): + ) -> None: # 将平台特定的前缀'/'剥离,以适配通用的CommandFilter logger.debug(f"[Discord] 回调函数触发: {cmd_name}") logger.debug(f"[Discord] 回调函数参数: {ctx}") diff --git a/astrbot/core/platform/sources/discord/discord_platform_event.py b/astrbot/core/platform/sources/discord/discord_platform_event.py index 053018225..02d4dae86 100644 --- a/astrbot/core/platform/sources/discord/discord_platform_event.py +++ b/astrbot/core/platform/sources/discord/discord_platform_event.py @@ -28,7 +28,7 @@ class DiscordViewComponent(BaseMessageComponent): type: str = "discord_view" - def __init__(self, view: discord.ui.View): + def __init__(self, view: discord.ui.View) -> None: self.view = view @@ -41,12 +41,12 @@ def __init__( session_id: str, client: DiscordBotClient, interaction_followup_webhook: discord.Webhook | None = None, - ): + ) -> None: super().__init__(message_str, message_obj, platform_meta, session_id) self.client = client self.interaction_followup_webhook = interaction_followup_webhook - async def send(self, message: MessageChain): + async def send(self, message: MessageChain) -> None: """发送消息到Discord平台""" # 解析消息链为 Discord 所需的对象 try: @@ -267,7 +267,7 @@ async def _parse_to_discord( content = content[:2000] return content, files, view, embeds, reference_message_id - async def react(self, emoji: str): + async def react(self, emoji: str) -> None: """对原消息添加反应""" try: if hasattr(self.message_obj, "raw_message") and hasattr( diff --git a/astrbot/core/platform/sources/lark/lark_adapter.py b/astrbot/core/platform/sources/lark/lark_adapter.py index b71071167..be1c81c26 100644 --- a/astrbot/core/platform/sources/lark/lark_adapter.py +++ b/astrbot/core/platform/sources/lark/lark_adapter.py @@ -3,13 +3,13 @@ import json import re import time -import uuid +from pathlib import Path from typing import Any, cast +from uuid import uuid4 import lark_oapi as lark from lark_oapi.api.im.v1 import ( - CreateMessageRequest, - CreateMessageRequestBody, + GetMessageRequest, GetMessageResourceRequest, ) from lark_oapi.api.im.v1.processor import P2ImMessageReceiveV1Processor @@ -25,6 +25,7 @@ PlatformMetadata, ) from astrbot.core.platform.astr_message_event import MessageSesion +from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from astrbot.core.utils.webhook_utils import log_webhook_info from ...register import register_platform_adapter @@ -56,10 +57,10 @@ def __init__( logger.warning("未设置飞书机器人名称,@ 机器人可能得不到回复。") # 初始化 WebSocket 长连接相关配置 - async def on_msg_event_recv(event: lark.im.v1.P2ImMessageReceiveV1): + async def on_msg_event_recv(event: lark.im.v1.P2ImMessageReceiveV1) -> None: await self.convert_msg(event) - def do_v2_msg_event(event: lark.im.v1.P2ImMessageReceiveV1): + def do_v2_msg_event(event: lark.im.v1.P2ImMessageReceiveV1) -> None: asyncio.create_task(on_msg_event_recv(event)) self.event_handler = ( @@ -94,7 +95,348 @@ def do_v2_msg_event(event: lark.im.v1.P2ImMessageReceiveV1): self.event_id_timestamps: dict[str, float] = {} - def _clean_expired_events(self): + async def _download_message_resource( + self, + *, + message_id: str, + file_key: str, + resource_type: str, + ) -> bytes | None: + if self.lark_api.im is None: + logger.error("[Lark] API Client im 模块未初始化") + return None + + request = ( + GetMessageResourceRequest.builder() + .message_id(message_id) + .file_key(file_key) + .type(resource_type) + .build() + ) + response = await self.lark_api.im.v1.message_resource.aget(request) + if not response.success(): + logger.error( + f"[Lark] 下载消息资源失败 type={resource_type}, key={file_key}, " + f"code={response.code}, msg={response.msg}", + ) + return None + + if response.file is None: + logger.error(f"[Lark] 消息资源响应中不包含文件流: {file_key}") + return None + + return response.file.read() + + @staticmethod + def _build_message_str_from_components( + components: list[Comp.BaseMessageComponent], + ) -> str: + parts: list[str] = [] + for comp in components: + if isinstance(comp, Comp.Plain): + text = comp.text.strip() + if text: + parts.append(text) + elif isinstance(comp, Comp.At): + name = str(comp.name or comp.qq or "").strip() + if name: + parts.append(f"@{name}") + elif isinstance(comp, Comp.Image): + parts.append("[image]") + elif isinstance(comp, Comp.File): + parts.append(str(comp.name or "[file]")) + elif isinstance(comp, Comp.Record): + parts.append("[audio]") + elif isinstance(comp, Comp.Video): + parts.append("[video]") + + return " ".join(parts).strip() + + @staticmethod + def _parse_post_content(content: dict[str, Any]) -> list[dict[str, Any]]: + result: list[dict[str, Any]] = [] + for item in content.get("content", []): + if isinstance(item, list): + for comp in item: + if isinstance(comp, dict): + result.append(comp) + elif isinstance(item, dict): + result.append(item) + return result + + @staticmethod + def _build_at_map(mentions: list[Any] | None) -> dict[str, Comp.At]: + at_map: dict[str, Comp.At] = {} + if not mentions: + return at_map + + for mention in mentions: + key = getattr(mention, "key", None) + if not key: + continue + + mention_id = getattr(mention, "id", None) + open_id = "" + if mention_id is not None: + if hasattr(mention_id, "open_id"): + open_id = getattr(mention_id, "open_id", "") or "" + else: + open_id = str(mention_id) + + mention_name = str(getattr(mention, "name", "") or "") + at_map[key] = Comp.At(qq=open_id, name=mention_name) + + return at_map + + async def _parse_message_components( + self, + *, + message_id: str | None, + message_type: str, + content: dict[str, Any], + at_map: dict[str, Comp.At], + ) -> list[Comp.BaseMessageComponent]: + components: list[Comp.BaseMessageComponent] = [] + + if message_type == "text": + message_str_raw = str(content.get("text", "")) + at_pattern = r"(@_user_\d+)" + parts = re.split(at_pattern, message_str_raw) + for part in parts: + segment = part.strip() + if not segment: + continue + if segment in at_map: + components.append(at_map[segment]) + else: + components.append(Comp.Plain(segment)) + return components + + if message_type in ("post", "image"): + if message_type == "image": + comp_list = [ + { + "tag": "img", + "image_key": content.get("image_key"), + }, + ] + else: + comp_list = self._parse_post_content(content) + + for comp in comp_list: + tag = comp.get("tag") + if tag == "at": + user_key = str(comp.get("user_id", "")) + if user_key in at_map: + components.append(at_map[user_key]) + elif tag == "text": + text = str(comp.get("text", "")).strip() + if text: + components.append(Comp.Plain(text)) + elif tag == "a": + text = str(comp.get("text", "")).strip() + href = str(comp.get("href", "")).strip() + if text and href: + components.append(Comp.Plain(f"{text}({href})")) + elif text: + components.append(Comp.Plain(text)) + elif tag == "img": + image_key = str(comp.get("image_key", "")).strip() + if not image_key: + continue + if not message_id: + logger.error("[Lark] 图片消息缺少 message_id") + continue + image_bytes = await self._download_message_resource( + message_id=message_id, + file_key=image_key, + resource_type="image", + ) + if image_bytes is None: + continue + image_base64 = base64.b64encode(image_bytes).decode() + components.append(Comp.Image.fromBase64(image_base64)) + elif tag == "media": + file_key = str(comp.get("file_key", "")).strip() + file_name = ( + str(comp.get("file_name", "")).strip() or "lark_media.mp4" + ) + if not file_key: + continue + if not message_id: + logger.error("[Lark] 富文本视频消息缺少 message_id") + continue + file_path = await self._download_file_resource_to_temp( + message_id=message_id, + file_key=file_key, + message_type="post_media", + file_name=file_name, + default_suffix=".mp4", + ) + if file_path: + components.append(Comp.Video(file=file_path, path=file_path)) + + return components + + if message_type == "file": + file_key = str(content.get("file_key", "")).strip() + file_name = str(content.get("file_name", "")).strip() or "lark_file" + if not message_id: + logger.error("[Lark] 文件消息缺少 message_id") + return components + if not file_key: + logger.error("[Lark] 文件消息缺少 file_key") + return components + file_path = await self._download_file_resource_to_temp( + message_id=message_id, + file_key=file_key, + message_type="file", + file_name=file_name, + ) + if file_path: + components.append(Comp.File(name=file_name, file=file_path)) + return components + + if message_type == "audio": + file_key = str(content.get("file_key", "")).strip() + if not message_id: + logger.error("[Lark] 音频消息缺少 message_id") + return components + if not file_key: + logger.error("[Lark] 音频消息缺少 file_key") + return components + file_path = await self._download_file_resource_to_temp( + message_id=message_id, + file_key=file_key, + message_type="audio", + default_suffix=".opus", + ) + if file_path: + components.append(Comp.Record(file=file_path, url=file_path)) + return components + + if message_type == "media": + file_key = str(content.get("file_key", "")).strip() + file_name = str(content.get("file_name", "")).strip() or "lark_media.mp4" + if not message_id: + logger.error("[Lark] 视频消息缺少 message_id") + return components + if not file_key: + logger.error("[Lark] 视频消息缺少 file_key") + return components + file_path = await self._download_file_resource_to_temp( + message_id=message_id, + file_key=file_key, + message_type="media", + file_name=file_name, + default_suffix=".mp4", + ) + if file_path: + components.append(Comp.Video(file=file_path, path=file_path)) + return components + + return components + + async def _build_reply_from_parent_id( + self, + parent_message_id: str, + ) -> Comp.Reply | None: + if self.lark_api.im is None: + logger.error("[Lark] API Client im 模块未初始化") + return None + + request = GetMessageRequest.builder().message_id(parent_message_id).build() + response = await self.lark_api.im.v1.message.aget(request) + if not response.success(): + logger.error( + f"[Lark] 获取引用消息失败 id={parent_message_id}, " + f"code={response.code}, msg={response.msg}", + ) + return None + + if response.data is None or not response.data.items: + logger.error( + f"[Lark] 引用消息响应为空 id={parent_message_id}", + ) + return None + + parent_message = response.data.items[0] + quoted_message_id = parent_message.message_id or parent_message_id + quoted_sender_id = ( + parent_message.sender.id + if parent_message.sender and parent_message.sender.id + else "unknown" + ) + quoted_time_raw = parent_message.create_time or 0 + quoted_time = ( + quoted_time_raw // 1000 + if isinstance(quoted_time_raw, int) and quoted_time_raw > 10**11 + else quoted_time_raw + ) + quoted_content = ( + parent_message.body.content if parent_message.body else "" + ) or "" + quoted_type = parent_message.msg_type or "" + quoted_content_json: dict[str, Any] = {} + if quoted_content: + try: + parsed = json.loads(quoted_content) + if isinstance(parsed, dict): + quoted_content_json = parsed + except json.JSONDecodeError: + logger.warning( + f"[Lark] 解析引用消息内容失败 id={quoted_message_id}", + ) + + quoted_at_map = self._build_at_map(parent_message.mentions) + quoted_chain = await self._parse_message_components( + message_id=quoted_message_id, + message_type=quoted_type, + content=quoted_content_json, + at_map=quoted_at_map, + ) + quoted_text = self._build_message_str_from_components(quoted_chain) + sender_nickname = ( + quoted_sender_id[:8] if quoted_sender_id != "unknown" else "unknown" + ) + + return Comp.Reply( + id=quoted_message_id, + chain=quoted_chain, + sender_id=quoted_sender_id, + sender_nickname=sender_nickname, + time=quoted_time, + message_str=quoted_text, + text=quoted_text, + ) + + async def _download_file_resource_to_temp( + self, + *, + message_id: str, + file_key: str, + message_type: str, + file_name: str = "", + default_suffix: str = ".bin", + ) -> str | None: + file_bytes = await self._download_message_resource( + message_id=message_id, + file_key=file_key, + resource_type="file", + ) + if file_bytes is None: + return None + + suffix = Path(file_name).suffix if file_name else default_suffix + temp_dir = Path(get_astrbot_temp_path()) + temp_dir.mkdir(parents=True, exist_ok=True) + temp_path = ( + temp_dir / f"lark_{message_type}_{file_name}_{uuid4().hex[:4]}{suffix}" + ) + temp_path.write_bytes(file_bytes) + return str(temp_path.resolve()) + + def _clean_expired_events(self) -> None: """清理超过 30 分钟的事件记录""" current_time = time.time() expired_keys = [ @@ -124,45 +466,24 @@ async def send_by_session( self, session: MessageSesion, message_chain: MessageChain, - ): - if self.lark_api.im is None: - logger.error("[Lark] API Client im 模块未初始化,无法发送消息") - return - - res = await LarkMessageEvent._convert_to_lark(message_chain, self.lark_api) - wrapped = { - "zh_cn": { - "title": "", - "content": res, - }, - } - + ) -> None: if session.message_type == MessageType.GROUP_MESSAGE: id_type = "chat_id" - if "%" in session.session_id: - session.session_id = session.session_id.split("%")[1] + receive_id = session.session_id + if "%" in receive_id: + receive_id = receive_id.split("%")[1] else: id_type = "open_id" - - request = ( - CreateMessageRequest.builder() - .receive_id_type(id_type) - .request_body( - CreateMessageRequestBody.builder() - .receive_id(session.session_id) - .content(json.dumps(wrapped)) - .msg_type("post") - .uuid(str(uuid.uuid4())) - .build(), - ) - .build() + receive_id = session.session_id + + # 复用 LarkMessageEvent 中的通用发送逻辑 + await LarkMessageEvent.send_message_chain( + message_chain, + self.lark_api, + receive_id=receive_id, + receive_id_type=id_type, ) - response = await self.lark_api.im.v1.message.acreate(request) - - if not response.success(): - logger.error(f"发送飞书消息失败({response.code}): {response.msg}") - await super().send_by_session(session, message_chain) def meta(self) -> PlatformMetadata: @@ -173,7 +494,7 @@ def meta(self) -> PlatformMetadata: support_streaming_message=False, ) - async def convert_msg(self, event: lark.im.v1.P2ImMessageReceiveV1): + async def convert_msg(self, event: lark.im.v1.P2ImMessageReceiveV1) -> None: if event.event is None: logger.debug("[Lark] 收到空事件(event.event is None)") return @@ -200,6 +521,11 @@ async def convert_msg(self, event: lark.im.v1.P2ImMessageReceiveV1): abm.message_str = "" at_list = {} + if message.parent_id: + reply_seg = await self._build_reply_from_parent_id(message.parent_id) + if reply_seg: + abm.message.append(reply_seg) + if message.mentions: for m in message.mentions: if m.id is None: @@ -222,80 +548,19 @@ async def convert_msg(self, event: lark.im.v1.P2ImMessageReceiveV1): logger.error(f"[Lark] 解析消息内容失败: {message.content}") return - if message.message_type == "text": - message_str_raw = content_json_b.get("text", "") # 带有 @ 的消息 - at_pattern = r"(@_user_\d+)" # 可以根据需求修改正则 - # at_users = re.findall(at_pattern, message_str_raw) - # 拆分文本,去掉AT符号部分 - parts = re.split(at_pattern, message_str_raw) - for i in range(len(parts)): - s = parts[i].strip() - if not s: - continue - if s in at_list: - abm.message.append(at_list[s]) - else: - abm.message.append(Comp.Plain(parts[i].strip())) - elif message.message_type == "post": - _ls = [] - - content_ls = content_json_b.get("content", []) - for comp in content_ls: - if isinstance(comp, list): - _ls.extend(comp) - elif isinstance(comp, dict): - _ls.append(comp) - content_json_b = _ls - elif message.message_type == "image": - content_json_b = [ - { - "tag": "img", - "image_key": content_json_b.get("image_key"), - "style": [], - }, - ] - - if message.message_type in ("post", "image"): - for comp in content_json_b: - if comp.get("tag") == "at": - user_id = comp.get("user_id") - if user_id in at_list: - abm.message.append(at_list[user_id]) - elif comp.get("tag") == "text" and comp.get("text", "").strip(): - abm.message.append(Comp.Plain(comp["text"].strip())) - elif comp.get("tag") == "img": - image_key = comp.get("image_key") - if not image_key: - continue - - request = ( - GetMessageResourceRequest.builder() - .message_id(cast(str, message.message_id)) - .file_key(image_key) - .type("image") - .build() - ) - - if self.lark_api.im is None: - logger.error("[Lark] API Client im 模块未初始化") - continue - - response = await self.lark_api.im.v1.message_resource.aget(request) - if not response.success(): - logger.error(f"无法下载飞书图片: {image_key}") - continue - - if response.file is None: - logger.error(f"飞书图片响应中不包含文件流: {image_key}") - continue - - image_bytes = response.file.read() - image_base64 = base64.b64encode(image_bytes).decode() - abm.message.append(Comp.Image.fromBase64(image_base64)) + if not isinstance(content_json_b, dict): + logger.error(f"[Lark] 消息内容不是 JSON Object: {message.content}") + return - for comp in abm.message: - if isinstance(comp, Comp.Plain): - abm.message_str += comp.text + logger.debug(f"[Lark] 解析消息内容: {content_json_b}") + parsed_components = await self._parse_message_components( + message_id=message.message_id, + message_type=message.message_type or "unknown", + content=content_json_b, + at_map=at_list, + ) + abm.message.extend(parsed_components) + abm.message_str = self._build_message_str_from_components(parsed_components) if message.message_id is None: logger.error("[Lark] 消息缺少 message_id") @@ -320,10 +585,9 @@ async def convert_msg(self, event: lark.im.v1.P2ImMessageReceiveV1): else: abm.session_id = abm.sender.user_id - logger.debug(abm) await self.handle_msg(abm) - async def handle_msg(self, abm: AstrBotMessage): + async def handle_msg(self, abm: AstrBotMessage) -> None: event = LarkMessageEvent( message_str=abm.message_str, message_obj=abm, @@ -334,7 +598,7 @@ async def handle_msg(self, abm: AstrBotMessage): self._event_queue.put_nowait(event) - async def handle_webhook_event(self, event_data: dict): + async def handle_webhook_event(self, event_data: dict) -> None: """处理 Webhook 事件 Args: @@ -356,7 +620,7 @@ async def handle_webhook_event(self, event_data: dict): except Exception as e: logger.error(f"[Lark Webhook] 处理事件失败: {e}", exc_info=True) - async def run(self): + async def run(self) -> None: if self.connection_mode == "webhook": # Webhook 模式 if self.webhook_server is None: @@ -379,7 +643,7 @@ async def webhook_callback(self, request: Any) -> Any: return await self.webhook_server.handle_callback(request) - async def terminate(self): + async def terminate(self) -> None: if self.connection_mode == "socket": await self.client._disconnect() logger.info("飞书(Lark) 适配器已关闭") diff --git a/astrbot/core/platform/sources/lark/lark_event.py b/astrbot/core/platform/sources/lark/lark_event.py index 7b7d20b38..92e3a32b9 100644 --- a/astrbot/core/platform/sources/lark/lark_event.py +++ b/astrbot/core/platform/sources/lark/lark_event.py @@ -6,6 +6,8 @@ import lark_oapi as lark from lark_oapi.api.im.v1 import ( + CreateFileRequest, + CreateFileRequestBody, CreateImageRequest, CreateImageRequestBody, CreateMessageReactionRequest, @@ -17,10 +19,15 @@ from astrbot import logger from astrbot.api.event import AstrMessageEvent, MessageChain -from astrbot.api.message_components import At, Plain +from astrbot.api.message_components import At, File, Plain, Record, Video from astrbot.api.message_components import Image as AstrBotImage -from astrbot.core.utils.astrbot_path import get_astrbot_data_path +from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from astrbot.core.utils.io import download_image_by_url +from astrbot.core.utils.media_utils import ( + convert_audio_to_opus, + convert_video_format, + get_media_duration, +) class LarkMessageEvent(AstrMessageEvent): @@ -31,10 +38,148 @@ def __init__( platform_meta, session_id, bot: lark.Client, - ): + ) -> None: super().__init__(message_str, message_obj, platform_meta, session_id) self.bot = bot + @staticmethod + async def _send_im_message( + lark_client: lark.Client, + *, + content: str, + msg_type: str, + reply_message_id: str | None = None, + receive_id: str | None = None, + receive_id_type: str | None = None, + ) -> bool: + """发送飞书 IM 消息的通用辅助函数 + + Args: + lark_client: 飞书客户端 + content: 消息内容(JSON字符串) + msg_type: 消息类型(post/file/audio/media等) + reply_message_id: 回复的消息ID(用于回复消息) + receive_id: 接收者ID(用于主动发送) + receive_id_type: 接收者ID类型(用于主动发送) + + Returns: + 是否发送成功 + """ + if lark_client.im is None: + logger.error("[Lark] API Client im 模块未初始化") + return False + + if reply_message_id: + request = ( + ReplyMessageRequest.builder() + .message_id(reply_message_id) + .request_body( + ReplyMessageRequestBody.builder() + .content(content) + .msg_type(msg_type) + .uuid(str(uuid.uuid4())) + .reply_in_thread(False) + .build() + ) + .build() + ) + response = await lark_client.im.v1.message.areply(request) + else: + from lark_oapi.api.im.v1 import ( + CreateMessageRequest, + CreateMessageRequestBody, + ) + + if receive_id_type is None or receive_id is None: + logger.error( + "[Lark] 主动发送消息时,receive_id 和 receive_id_type 不能为空", + ) + return False + + request = ( + CreateMessageRequest.builder() + .receive_id_type(receive_id_type) + .request_body( + CreateMessageRequestBody.builder() + .receive_id(receive_id) + .content(content) + .msg_type(msg_type) + .uuid(str(uuid.uuid4())) + .build() + ) + .build() + ) + response = await lark_client.im.v1.message.acreate(request) + + if not response.success(): + logger.error(f"[Lark] 发送飞书消息失败({response.code}): {response.msg}") + return False + + return True + + @staticmethod + async def _upload_lark_file( + lark_client: lark.Client, + *, + path: str, + file_type: str, + duration: int | None = None, + ) -> str | None: + """上传文件到飞书的通用辅助函数 + + Args: + lark_client: 飞书客户端 + path: 文件路径 + file_type: 文件类型(stream/opus/mp4等) + duration: 媒体时长(毫秒),可选 + + Returns: + 成功返回file_key,失败返回None + """ + if not path or not os.path.exists(path): + logger.error(f"[Lark] 文件不存在: {path}") + return None + + if lark_client.im is None: + logger.error("[Lark] API Client im 模块未初始化,无法上传文件") + return None + + try: + with open(path, "rb") as file_obj: + body_builder = ( + CreateFileRequestBody.builder() + .file_type(file_type) + .file_name(os.path.basename(path)) + .file(file_obj) + ) + if duration is not None: + body_builder.duration(duration) + + request = ( + CreateFileRequest.builder() + .request_body(body_builder.build()) + .build() + ) + response = await lark_client.im.v1.file.acreate(request) + + if not response.success(): + logger.error( + f"[Lark] 无法上传文件({response.code}): {response.msg}" + ) + return None + + if response.data is None: + logger.error("[Lark] 上传文件成功但未返回数据(data is None)") + return None + + file_key = response.data.file_key + logger.debug(f"[Lark] 文件上传成功: {file_key}") + return file_key + + except Exception as e: + logger.error(f"[Lark] 无法打开或上传文件: {e}") + return None + @staticmethod async def _convert_to_lark(message: MessageChain, lark_client: lark.Client) -> list: ret = [] @@ -57,8 +202,11 @@ async def _convert_to_lark(message: MessageChain, lark_client: lark.Client) -> l base64_str = comp.file.removeprefix("base64://") image_data = base64.b64decode(base64_str) # save as temp file - temp_dir = os.path.join(get_astrbot_data_path(), "temp") - file_path = os.path.join(temp_dir, f"{uuid.uuid4()}_test.jpg") + temp_dir = get_astrbot_temp_path() + file_path = os.path.join( + temp_dir, + f"lark_image_{uuid.uuid4().hex[:8]}.jpg", + ) with open(file_path, "wb") as f: f.write(BytesIO(image_data).getvalue()) else: @@ -103,6 +251,18 @@ async def _convert_to_lark(message: MessageChain, lark_client: lark.Client) -> l ret.append(_stage) ret.append([{"tag": "img", "image_key": image_key}]) _stage.clear() + elif isinstance(comp, File): + # 文件将通过 _send_file_message 方法单独发送,这里跳过 + logger.debug("[Lark] 检测到文件组件,将单独发送") + continue + elif isinstance(comp, Record): + # 音频将通过 _send_audio_message 方法单独发送,这里跳过 + logger.debug("[Lark] 检测到音频组件,将单独发送") + continue + elif isinstance(comp, Video): + # 视频将通过 _send_media_message 方法单独发送,这里跳过 + logger.debug("[Lark] 检测到视频组件,将单独发送") + continue else: logger.warning(f"飞书 暂时不支持消息段: {comp.type}") @@ -110,41 +270,271 @@ async def _convert_to_lark(message: MessageChain, lark_client: lark.Client) -> l ret.append(_stage) return ret - async def send(self, message: MessageChain): - res = await LarkMessageEvent._convert_to_lark(message, self.bot) - wrapped = { - "zh_cn": { - "title": "", - "content": res, - }, - } + @staticmethod + async def send_message_chain( + message_chain: MessageChain, + lark_client: lark.Client, + reply_message_id: str | None = None, + receive_id: str | None = None, + receive_id_type: str | None = None, + ) -> None: + """通用的消息链发送方法 - request = ( - ReplyMessageRequest.builder() - .message_id(self.message_obj.message_id) - .request_body( - ReplyMessageRequestBody.builder() - .content(json.dumps(wrapped)) - .msg_type("post") - .uuid(str(uuid.uuid4())) - .reply_in_thread(False) - .build(), + Args: + message_chain: 要发送的消息链 + lark_client: 飞书客户端 + reply_message_id: 回复的消息ID(用于回复消息) + receive_id: 接收者ID(用于主动发送) + receive_id_type: 接收者ID类型,如 'open_id', 'chat_id'(用于主动发送) + """ + if lark_client.im is None: + logger.error("[Lark] API Client im 模块未初始化") + return + + # 分离文件、音频、视频组件和其他组件 + file_components: list[File] = [] + audio_components: list[Record] = [] + media_components: list[Video] = [] + other_components = [] + + for comp in message_chain.chain: + if isinstance(comp, File): + file_components.append(comp) + elif isinstance(comp, Record): + audio_components.append(comp) + elif isinstance(comp, Video): + media_components.append(comp) + else: + other_components.append(comp) + + # 先发送非文件内容(如果有) + if other_components: + temp_chain = MessageChain() + temp_chain.chain = other_components + res = await LarkMessageEvent._convert_to_lark(temp_chain, lark_client) + + if res: # 只在有内容时发送 + wrapped = { + "zh_cn": { + "title": "", + "content": res, + }, + } + await LarkMessageEvent._send_im_message( + lark_client, + content=json.dumps(wrapped), + msg_type="post", + reply_message_id=reply_message_id, + receive_id=receive_id, + receive_id_type=receive_id_type, + ) + + # 发送附件 + for file_comp in file_components: + await LarkMessageEvent._send_file_message( + file_comp, lark_client, reply_message_id, receive_id, receive_id_type ) - .build() + + for audio_comp in audio_components: + await LarkMessageEvent._send_audio_message( + audio_comp, lark_client, reply_message_id, receive_id, receive_id_type + ) + + for media_comp in media_components: + await LarkMessageEvent._send_media_message( + media_comp, lark_client, reply_message_id, receive_id, receive_id_type + ) + + async def send(self, message: MessageChain) -> None: + """发送消息链到飞书,然后交给父类做框架级发送/记录""" + await LarkMessageEvent.send_message_chain( + message, + self.bot, + reply_message_id=self.message_obj.message_id, ) + await super().send(message) - if self.bot.im is None: - logger.error("[Lark] API Client im 模块未初始化,无法回复消息") + @staticmethod + async def _send_file_message( + file_comp: File, + lark_client: lark.Client, + reply_message_id: str | None = None, + receive_id: str | None = None, + receive_id_type: str | None = None, + ) -> None: + """发送文件消息 + + Args: + file_comp: 文件组件 + lark_client: 飞书客户端 + reply_message_id: 回复的消息ID(用于回复消息) + receive_id: 接收者ID(用于主动发送) + receive_id_type: 接收者ID类型(用于主动发送) + """ + file_path = file_comp.file or "" + file_key = await LarkMessageEvent._upload_lark_file( + lark_client, path=file_path, file_type="stream" + ) + if not file_key: return - response = await self.bot.im.v1.message.areply(request) + content = json.dumps({"file_key": file_key}) + await LarkMessageEvent._send_im_message( + lark_client, + content=content, + msg_type="file", + reply_message_id=reply_message_id, + receive_id=receive_id, + receive_id_type=receive_id_type, + ) - if not response.success(): - logger.error(f"回复飞书消息失败({response.code}): {response.msg}") + @staticmethod + async def _send_audio_message( + audio_comp: Record, + lark_client: lark.Client, + reply_message_id: str | None = None, + receive_id: str | None = None, + receive_id_type: str | None = None, + ) -> None: + """发送音频消息 - await super().send(message) + Args: + audio_comp: 音频组件 + lark_client: 飞书客户端 + reply_message_id: 回复的消息ID(用于回复消息) + receive_id: 接收者ID(用于主动发送) + receive_id_type: 接收者ID类型(用于主动发送) + """ + # 获取音频文件路径 + try: + original_audio_path = await audio_comp.convert_to_file_path() + except Exception as e: + logger.error(f"[Lark] 无法获取音频文件路径: {e}") + return + + if not original_audio_path or not os.path.exists(original_audio_path): + logger.error(f"[Lark] 音频文件不存在: {original_audio_path}") + return + + # 转换为opus格式 + converted_audio_path = None + try: + audio_path = await convert_audio_to_opus(original_audio_path) + # 如果转换后路径与原路径不同,说明生成了新文件 + if audio_path != original_audio_path: + converted_audio_path = audio_path + else: + audio_path = original_audio_path + except Exception as e: + logger.error(f"[Lark] 音频格式转换失败,将尝试直接上传: {e}") + # 如果转换失败,继续尝试直接上传原始文件 + audio_path = original_audio_path + + # 获取音频时长 + duration = await get_media_duration(audio_path) + + # 上传音频文件 + file_key = await LarkMessageEvent._upload_lark_file( + lark_client, + path=audio_path, + file_type="opus", + duration=duration, + ) + + # 清理转换后的临时音频文件 + if converted_audio_path and os.path.exists(converted_audio_path): + try: + os.remove(converted_audio_path) + logger.debug(f"[Lark] 已删除转换后的音频文件: {converted_audio_path}") + except Exception as e: + logger.warning(f"[Lark] 删除转换后的音频文件失败: {e}") + + if not file_key: + return + + await LarkMessageEvent._send_im_message( + lark_client, + content=json.dumps({"file_key": file_key}), + msg_type="audio", + reply_message_id=reply_message_id, + receive_id=receive_id, + receive_id_type=receive_id_type, + ) + + @staticmethod + async def _send_media_message( + media_comp: Video, + lark_client: lark.Client, + reply_message_id: str | None = None, + receive_id: str | None = None, + receive_id_type: str | None = None, + ) -> None: + """发送视频消息 + + Args: + media_comp: 视频组件 + lark_client: 飞书客户端 + reply_message_id: 回复的消息ID(用于回复消息) + receive_id: 接收者ID(用于主动发送) + receive_id_type: 接收者ID类型(用于主动发送) + """ + # 获取视频文件路径 + try: + original_video_path = await media_comp.convert_to_file_path() + except Exception as e: + logger.error(f"[Lark] 无法获取视频文件路径: {e}") + return + + if not original_video_path or not os.path.exists(original_video_path): + logger.error(f"[Lark] 视频文件不存在: {original_video_path}") + return + + # 转换为mp4格式 + converted_video_path = None + try: + video_path = await convert_video_format(original_video_path, "mp4") + # 如果转换后路径与原路径不同,说明生成了新文件 + if video_path != original_video_path: + converted_video_path = video_path + else: + video_path = original_video_path + except Exception as e: + logger.error(f"[Lark] 视频格式转换失败,将尝试直接上传: {e}") + # 如果转换失败,继续尝试直接上传原始文件 + video_path = original_video_path + + # 获取视频时长 + duration = await get_media_duration(video_path) + + # 上传视频文件 + file_key = await LarkMessageEvent._upload_lark_file( + lark_client, + path=video_path, + file_type="mp4", + duration=duration, + ) + + # 清理转换后的临时视频文件 + if converted_video_path and os.path.exists(converted_video_path): + try: + os.remove(converted_video_path) + logger.debug(f"[Lark] 已删除转换后的视频文件: {converted_video_path}") + except Exception as e: + logger.warning(f"[Lark] 删除转换后的视频文件失败: {e}") + + if not file_key: + return + + await LarkMessageEvent._send_im_message( + lark_client, + content=json.dumps({"file_key": file_key}), + msg_type="media", + reply_message_id=reply_message_id, + receive_id=receive_id, + receive_id_type=receive_id_type, + ) - async def react(self, emoji: str): + async def react(self, emoji: str) -> None: if self.bot.im is None: logger.error("[Lark] API Client im 模块未初始化,无法发送表情") return diff --git a/astrbot/core/platform/sources/lark/server.py b/astrbot/core/platform/sources/lark/server.py index 3921eb8be..52177ebb0 100644 --- a/astrbot/core/platform/sources/lark/server.py +++ b/astrbot/core/platform/sources/lark/server.py @@ -21,7 +21,7 @@ class AESCipher: """AES 加密/解密工具类""" - def __init__(self, key: str): + def __init__(self, key: str) -> None: self.bs = AES.block_size self.key = hashlib.sha256(self.str_to_bytes(key)).digest() @@ -52,7 +52,7 @@ class LarkWebhookServer: 仅支持统一 Webhook 模式 """ - def __init__(self, config: dict, event_queue: asyncio.Queue): + def __init__(self, config: dict, event_queue: asyncio.Queue) -> None: """初始化 Webhook 服务器 Args: @@ -197,7 +197,7 @@ async def handle_callback(self, request) -> tuple[dict, int] | dict: return {} - def set_callback(self, callback: Callable[[dict], Awaitable[None]]): + def set_callback(self, callback: Callable[[dict], Awaitable[None]]) -> None: """设置事件回调函数 Args: diff --git a/astrbot/core/platform/sources/line/line_adapter.py b/astrbot/core/platform/sources/line/line_adapter.py new file mode 100644 index 000000000..c13677b13 --- /dev/null +++ b/astrbot/core/platform/sources/line/line_adapter.py @@ -0,0 +1,465 @@ +import asyncio +import mimetypes +import time +import uuid +from pathlib import Path +from typing import Any, cast + +from astrbot.api import logger +from astrbot.api.event import MessageChain +from astrbot.api.message_components import At, File, Image, Plain, Record, Video +from astrbot.api.platform import ( + AstrBotMessage, + Group, + MessageMember, + MessageType, + Platform, + PlatformMetadata, +) +from astrbot.core.platform.astr_message_event import MessageSesion +from astrbot.core.utils.astrbot_path import get_astrbot_temp_path +from astrbot.core.utils.webhook_utils import log_webhook_info + +from ...register import register_platform_adapter +from .line_api import LineAPIClient +from .line_event import LineMessageEvent + +LINE_CONFIG_METADATA = { + "channel_access_token": { + "description": "LINE Channel Access Token", + "type": "string", + "hint": "LINE Messaging API 的 channel access token。", + }, + "channel_secret": { + "description": "LINE Channel Secret", + "type": "string", + "hint": "用于校验 LINE Webhook 签名。", + }, +} + +LINE_I18N_RESOURCES = { + "zh-CN": { + "channel_access_token": { + "description": "LINE Channel Access Token", + "hint": "LINE Messaging API 的 channel access token。", + }, + "channel_secret": { + "description": "LINE Channel Secret", + "hint": "用于校验 LINE Webhook 签名。", + }, + }, + "en-US": { + "channel_access_token": { + "description": "LINE Channel Access Token", + "hint": "Channel access token for LINE Messaging API.", + }, + "channel_secret": { + "description": "LINE Channel Secret", + "hint": "Used to verify LINE webhook signatures.", + }, + }, +} + + +@register_platform_adapter( + "line", + "LINE Messaging API 适配器", + support_streaming_message=False, + config_metadata=LINE_CONFIG_METADATA, + i18n_resources=LINE_I18N_RESOURCES, +) +class LinePlatformAdapter(Platform): + def __init__( + self, + platform_config: dict, + platform_settings: dict, + event_queue: asyncio.Queue, + ) -> None: + super().__init__(platform_config, event_queue) + self.config["unified_webhook_mode"] = True + self.destination = "unknown" + self.settings = platform_settings + self._event_id_timestamps: dict[str, float] = {} + self.shutdown_event = asyncio.Event() + + channel_access_token = str(platform_config.get("channel_access_token", "")) + channel_secret = str(platform_config.get("channel_secret", "")) + if not channel_access_token or not channel_secret: + raise ValueError( + "LINE 适配器需要 channel_access_token 和 channel_secret。", + ) + + self.line_api = LineAPIClient( + channel_access_token=channel_access_token, + channel_secret=channel_secret, + ) + + async def send_by_session( + self, + session: MessageSesion, + message_chain: MessageChain, + ) -> None: + messages = await LineMessageEvent.build_line_messages(message_chain) + if messages: + await self.line_api.push_message(session.session_id, messages) + await super().send_by_session(session, message_chain) + + def meta(self) -> PlatformMetadata: + return PlatformMetadata( + name="line", + description="LINE Messaging API 适配器", + id=cast(str, self.config.get("id", "line")), + support_streaming_message=False, + ) + + async def run(self) -> None: + webhook_uuid = self.config.get("webhook_uuid") + if webhook_uuid: + log_webhook_info(f"{self.meta().id}(LINE)", webhook_uuid) + else: + logger.warning("[LINE] webhook_uuid 为空,统一 Webhook 可能无法接收消息。") + await self.shutdown_event.wait() + + async def terminate(self) -> None: + self.shutdown_event.set() + await self.line_api.close() + + async def webhook_callback(self, request: Any) -> Any: + raw_body = await request.get_data() + signature = request.headers.get("x-line-signature") + if not self.line_api.verify_signature(raw_body, signature): + logger.warning("[LINE] invalid webhook signature") + return "invalid signature", 400 + + try: + payload = await request.get_json(force=True, silent=False) + except Exception as e: + logger.warning("[LINE] invalid webhook body: %s", e) + return "bad request", 400 + + if not isinstance(payload, dict): + return "bad request", 400 + + await self.handle_webhook_event(payload) + return "ok", 200 + + async def handle_webhook_event(self, payload: dict[str, Any]) -> None: + destination = str(payload.get("destination", "")).strip() + if destination: + self.destination = destination + + events = payload.get("events") + if not isinstance(events, list): + return + + for event in events: + if not isinstance(event, dict): + continue + + event_id = str(event.get("webhookEventId", "")) + if event_id and self._is_duplicate_event(event_id): + logger.debug("[LINE] duplicate event skipped: %s", event_id) + continue + + abm = await self.convert_message(event) + if abm is None: + continue + await self.handle_msg(abm) + + async def convert_message(self, event: dict[str, Any]) -> AstrBotMessage | None: + if str(event.get("type", "")) != "message": + return None + if str(event.get("mode", "active")) == "standby": + return None + + source = event.get("source", {}) + if not isinstance(source, dict): + return None + + message = event.get("message", {}) + if not isinstance(message, dict): + return None + + source_type = str(source.get("type", "")) + user_id = str(source.get("userId", "")).strip() + group_id = str(source.get("groupId", "")).strip() + room_id = str(source.get("roomId", "")).strip() + + abm = AstrBotMessage() + abm.self_id = self.destination or self.meta().id + abm.message = [] + abm.raw_message = event + abm.message_id = str( + message.get("id") + or event.get("webhookEventId") + or event.get("deliveryContext", {}).get("deliveryId", "") + or uuid.uuid4().hex + ) + + event_timestamp = event.get("timestamp") + if isinstance(event_timestamp, int): + abm.timestamp = ( + event_timestamp // 1000 + if event_timestamp > 1_000_000_000_000 + else event_timestamp + ) + else: + abm.timestamp = int(time.time()) + + if source_type in {"group", "room"}: + abm.type = MessageType.GROUP_MESSAGE + container_id = group_id or room_id + abm.group = Group(group_id=container_id, group_name=container_id) + abm.session_id = container_id + sender_id = user_id or container_id + elif source_type == "user": + abm.type = MessageType.FRIEND_MESSAGE + abm.session_id = user_id + sender_id = user_id + else: + abm.type = MessageType.OTHER_MESSAGE + abm.session_id = user_id or group_id or room_id or "unknown" + sender_id = abm.session_id + + abm.sender = MessageMember(user_id=sender_id, nickname=sender_id[:8]) + + components = await self._parse_line_message_components(message) + if not components: + return None + abm.message = components + abm.message_str = self._build_message_str(components) + return abm + + async def _parse_line_message_components( + self, + message: dict[str, Any], + ) -> list: + msg_type = str(message.get("type", "")) + message_id = str(message.get("id", "")).strip() + + if msg_type == "text": + text = str(message.get("text", "")) + mention = message.get("mention") + if isinstance(mention, dict): + return self._parse_text_with_mentions(text, mention) + return [Plain(text=text)] if text else [] + + if msg_type == "image": + image_component = await self._build_image_component(message_id, message) + return [image_component] if image_component else [Plain(text="[image]")] + + if msg_type == "video": + video_component = await self._build_video_component(message_id, message) + return [video_component] if video_component else [Plain(text="[video]")] + + if msg_type == "audio": + audio_component = await self._build_audio_component(message_id, message) + return [audio_component] if audio_component else [Plain(text="[audio]")] + + if msg_type == "file": + file_component = await self._build_file_component(message_id, message) + return [file_component] if file_component else [Plain(text="[file]")] + + if msg_type == "sticker": + return [Plain(text="[sticker]")] + + return [Plain(text=f"[{msg_type}]")] + + def _parse_text_with_mentions(self, text: str, mention_obj: dict[str, Any]) -> list: + mentions = mention_obj.get("mentionees", []) + if not isinstance(mentions, list) or not mentions: + return [Plain(text=text)] if text else [] + + normalized = [] + for item in mentions: + if not isinstance(item, dict): + continue + start = item.get("index") + length = item.get("length") + if not isinstance(start, int) or not isinstance(length, int): + continue + normalized.append((start, length, item)) + normalized.sort(key=lambda x: x[0]) + + ret = [] + cursor = 0 + for start, length, item in normalized: + if start > cursor: + part = text[cursor:start] + if part: + ret.append(Plain(text=part)) + + label = text[start : start + length] or "@user" + mention_type = str(item.get("type", "")) + if mention_type == "user": + target_id = str(item.get("userId", "")).strip() + ret.append(At(qq=target_id, name=label.lstrip("@"))) + else: + ret.append(Plain(text=label)) + cursor = max(cursor, start + length) + + if cursor < len(text): + tail = text[cursor:] + if tail: + ret.append(Plain(text=tail)) + return ret + + async def _build_image_component( + self, + message_id: str, + message: dict[str, Any], + ) -> Image | None: + external_url = self._get_external_content_url(message) + if external_url: + return Image.fromURL(external_url) + + content = await self.line_api.get_message_content(message_id) + if not content: + return None + content_bytes, _, _ = content + return Image.fromBytes(content_bytes) + + async def _build_video_component( + self, + message_id: str, + message: dict[str, Any], + ) -> Video | None: + external_url = self._get_external_content_url(message) + if external_url: + return Video.fromURL(external_url) + + content = await self.line_api.get_message_content(message_id) + if not content: + return None + content_bytes, content_type, _ = content + suffix = self._guess_suffix(content_type, ".mp4") + file_path = self._store_temp_content("video", message_id, content_bytes, suffix) + return Video(file=file_path, path=file_path) + + async def _build_audio_component( + self, + message_id: str, + message: dict[str, Any], + ) -> Record | None: + external_url = self._get_external_content_url(message) + if external_url: + return Record.fromURL(external_url) + + content = await self.line_api.get_message_content(message_id) + if not content: + return None + content_bytes, content_type, _ = content + suffix = self._guess_suffix(content_type, ".m4a") + file_path = self._store_temp_content("audio", message_id, content_bytes, suffix) + return Record(file=file_path, url=file_path) + + async def _build_file_component( + self, + message_id: str, + message: dict[str, Any], + ) -> File | None: + content = await self.line_api.get_message_content(message_id) + if not content: + return None + content_bytes, content_type, filename = content + default_name = str(message.get("fileName", "")).strip() or f"{message_id}.bin" + suffix = Path(default_name).suffix or self._guess_suffix(content_type, ".bin") + final_name = filename or default_name + file_path = self._store_temp_content( + "file", + message_id, + content_bytes, + suffix, + original_name=final_name, + ) + return File(name=final_name, file=file_path, url=file_path) + + @staticmethod + def _get_external_content_url(message: dict[str, Any]) -> str: + provider = message.get("contentProvider") + if not isinstance(provider, dict): + return "" + if str(provider.get("type", "")) != "external": + return "" + return str(provider.get("originalContentUrl", "")).strip() + + @staticmethod + def _guess_suffix(content_type: str | None, fallback: str) -> str: + if not content_type: + return fallback + base_type = content_type.split(";", 1)[0].strip().lower() + guessed = mimetypes.guess_extension(base_type) + if guessed: + return guessed + return fallback + + @staticmethod + def _store_temp_content( + content_type: str, + message_id: str, + content: bytes, + suffix: str, + original_name: str = "", + ) -> str: + temp_dir = Path(get_astrbot_temp_path()) + temp_dir.mkdir(parents=True, exist_ok=True) + name_prefix = f"line_{content_type}" + if original_name: + safe_stem = Path(original_name).stem.strip() + safe_stem = "".join( + ch if ch.isalnum() or ch in ("-", "_", ".") else "_" for ch in safe_stem + ) + safe_stem = safe_stem.strip("._") + if safe_stem: + name_prefix = safe_stem[:64] + file_path = temp_dir / f"{name_prefix}_{message_id}_{uuid.uuid4().hex[:6]}" + file_path = file_path.with_suffix(suffix) + file_path.write_bytes(content) + return str(file_path.resolve()) + + @staticmethod + def _build_message_str(components: list) -> str: + parts: list[str] = [] + for comp in components: + if isinstance(comp, Plain): + parts.append(comp.text) + elif isinstance(comp, At): + parts.append(f"@{comp.name or comp.qq}") + elif isinstance(comp, Image): + parts.append("[image]") + elif isinstance(comp, Video): + parts.append("[video]") + elif isinstance(comp, Record): + parts.append("[audio]") + elif isinstance(comp, File): + parts.append(str(comp.name or "[file]")) + else: + parts.append(f"[{comp.type}]") + return " ".join(i for i in parts if i).strip() + + def _clean_expired_events(self) -> None: + current = time.time() + expired = [ + event_id + for event_id, ts in self._event_id_timestamps.items() + if current - ts > 1800 + ] + for event_id in expired: + del self._event_id_timestamps[event_id] + + def _is_duplicate_event(self, event_id: str) -> bool: + self._clean_expired_events() + if event_id in self._event_id_timestamps: + return True + self._event_id_timestamps[event_id] = time.time() + return False + + async def handle_msg(self, abm: AstrBotMessage) -> None: + event = LineMessageEvent( + message_str=abm.message_str, + message_obj=abm, + platform_meta=self.meta(), + session_id=abm.session_id, + line_api=self.line_api, + ) + self._event_queue.put_nowait(event) diff --git a/astrbot/core/platform/sources/line/line_api.py b/astrbot/core/platform/sources/line/line_api.py new file mode 100644 index 000000000..32204bd6e --- /dev/null +++ b/astrbot/core/platform/sources/line/line_api.py @@ -0,0 +1,203 @@ +import asyncio +import base64 +import hmac +import json +from hashlib import sha256 +from typing import Any +from urllib.parse import unquote + +import aiohttp + +from astrbot.api import logger + + +class LineAPIClient: + def __init__( + self, + *, + channel_access_token: str, + channel_secret: str, + timeout_seconds: int = 30, + ) -> None: + self.channel_access_token = channel_access_token.strip() + self.channel_secret = channel_secret.strip() + self.timeout = aiohttp.ClientTimeout(total=timeout_seconds) + self._session: aiohttp.ClientSession | None = None + + async def _get_session(self) -> aiohttp.ClientSession: + if self._session is None or self._session.closed: + self._session = aiohttp.ClientSession(timeout=self.timeout) + return self._session + + async def close(self) -> None: + if self._session and not self._session.closed: + await self._session.close() + + def verify_signature(self, raw_body: bytes, signature: str | None) -> bool: + if not signature: + return False + digest = hmac.new( + self.channel_secret.encode("utf-8"), + raw_body, + sha256, + ).digest() + expected = base64.b64encode(digest).decode("utf-8") + return hmac.compare_digest(expected, signature.strip()) + + @property + def _auth_headers(self) -> dict[str, str]: + return {"Authorization": f"Bearer {self.channel_access_token}"} + + async def reply_message( + self, + reply_token: str, + messages: list[dict[str, Any]], + *, + notification_disabled: bool = False, + ) -> bool: + payload = { + "replyToken": reply_token, + "messages": messages[:5], + "notificationDisabled": notification_disabled, + } + return await self._post_json( + "https://api.line.me/v2/bot/message/reply", + payload=payload, + op_name="reply", + ) + + async def push_message( + self, + to: str, + messages: list[dict[str, Any]], + *, + notification_disabled: bool = False, + ) -> bool: + payload = { + "to": to, + "messages": messages[:5], + "notificationDisabled": notification_disabled, + } + return await self._post_json( + "https://api.line.me/v2/bot/message/push", + payload=payload, + op_name="push", + ) + + async def _post_json( + self, + url: str, + *, + payload: dict[str, Any], + op_name: str, + ) -> bool: + session = await self._get_session() + headers = { + **self._auth_headers, + "Content-Type": "application/json", + } + try: + async with session.post(url, json=payload, headers=headers) as resp: + if resp.status < 400: + return True + body = await resp.text() + logger.error( + "[LINE] %s message failed: status=%s body=%s", + op_name, + resp.status, + body, + ) + return False + except Exception as e: + logger.error("[LINE] %s message request failed: %s", op_name, e) + return False + + async def get_message_content( + self, + message_id: str, + ) -> tuple[bytes, str | None, str | None] | None: + session = await self._get_session() + url = f"https://api-data.line.me/v2/bot/message/{message_id}/content" + headers = self._auth_headers + + async with session.get(url, headers=headers) as resp: + if resp.status == 202: + if not await self._wait_for_transcoding(message_id): + return None + async with session.get(url, headers=headers) as retry_resp: + if retry_resp.status != 200: + body = await retry_resp.text() + logger.warning( + "[LINE] get content retry failed: message_id=%s status=%s body=%s", + message_id, + retry_resp.status, + body, + ) + return None + return await self._read_content_response(retry_resp) + + if resp.status != 200: + body = await resp.text() + logger.warning( + "[LINE] get content failed: message_id=%s status=%s body=%s", + message_id, + resp.status, + body, + ) + return None + return await self._read_content_response(resp) + + async def _read_content_response( + self, + resp: aiohttp.ClientResponse, + ) -> tuple[bytes, str | None, str | None]: + content = await resp.read() + content_type = resp.headers.get("Content-Type") + disposition = resp.headers.get("Content-Disposition") + filename = self._extract_filename_from_disposition(disposition) + return content, content_type, filename + + def _extract_filename_from_disposition(self, disposition: str | None) -> str | None: + if not disposition: + return None + for part in disposition.split(";"): + token = part.strip() + if token.startswith("filename*="): + val = token.split("=", 1)[1].strip().strip('"') + if val.lower().startswith("utf-8''"): + val = val[7:] + return unquote(val) + if token.startswith("filename="): + return token.split("=", 1)[1].strip().strip('"') + return None + + async def _wait_for_transcoding( + self, + message_id: str, + *, + max_attempts: int = 10, + interval_seconds: float = 1.0, + ) -> bool: + session = await self._get_session() + url = ( + f"https://api-data.line.me/v2/bot/message/{message_id}/content/transcoding" + ) + headers = self._auth_headers + + for _ in range(max_attempts): + try: + async with session.get(url, headers=headers) as resp: + if resp.status != 200: + await asyncio.sleep(interval_seconds) + continue + body = await resp.text() + data = json.loads(body) + status = str(data.get("status", "")).lower() + if status == "succeeded": + return True + if status == "failed": + return False + except Exception: + pass + await asyncio.sleep(interval_seconds) + return False diff --git a/astrbot/core/platform/sources/line/line_event.py b/astrbot/core/platform/sources/line/line_event.py new file mode 100644 index 000000000..04be53922 --- /dev/null +++ b/astrbot/core/platform/sources/line/line_event.py @@ -0,0 +1,285 @@ +import asyncio +import os +import re +import uuid +from collections.abc import AsyncGenerator +from pathlib import Path + +from astrbot.api import logger +from astrbot.api.event import AstrMessageEvent, MessageChain +from astrbot.api.message_components import ( + At, + BaseMessageComponent, + File, + Image, + Plain, + Record, + Video, +) +from astrbot.core.utils.astrbot_path import get_astrbot_temp_path +from astrbot.core.utils.media_utils import get_media_duration + +from .line_api import LineAPIClient + + +class LineMessageEvent(AstrMessageEvent): + def __init__( + self, + message_str, + message_obj, + platform_meta, + session_id, + line_api: LineAPIClient, + ) -> None: + super().__init__(message_str, message_obj, platform_meta, session_id) + self.line_api = line_api + + @staticmethod + async def _component_to_message_object( + segment: BaseMessageComponent, + ) -> dict | None: + if isinstance(segment, Plain): + text = segment.text.strip() + if not text: + return None + return {"type": "text", "text": text[:5000]} + + if isinstance(segment, At): + name = str(segment.name or segment.qq or "").strip() + if not name: + return None + return {"type": "text", "text": f"@{name}"[:5000]} + + if isinstance(segment, Image): + image_url = await LineMessageEvent._resolve_image_url(segment) + if not image_url: + return None + return { + "type": "image", + "originalContentUrl": image_url, + "previewImageUrl": image_url, + } + + if isinstance(segment, Record): + audio_url = await LineMessageEvent._resolve_record_url(segment) + if not audio_url: + return None + duration = await LineMessageEvent._resolve_record_duration(segment) + return { + "type": "audio", + "originalContentUrl": audio_url, + "duration": duration, + } + + if isinstance(segment, Video): + video_url = await LineMessageEvent._resolve_video_url(segment) + if not video_url: + return None + preview_url = await LineMessageEvent._resolve_video_preview_url(segment) + if not preview_url: + return None + return { + "type": "video", + "originalContentUrl": video_url, + "previewImageUrl": preview_url, + } + + if isinstance(segment, File): + file_url = await LineMessageEvent._resolve_file_url(segment) + if not file_url: + return None + file_name = str(segment.name or "").strip() or "file.bin" + file_size = await LineMessageEvent._resolve_file_size(segment) + if file_size <= 0: + return None + return { + "type": "file", + "fileName": file_name, + "fileSize": file_size, + "originalContentUrl": file_url, + } + + return None + + @staticmethod + async def _resolve_image_url(segment: Image) -> str: + candidate = (segment.url or segment.file or "").strip() + if candidate.startswith("http://") or candidate.startswith("https://"): + return candidate + try: + return await segment.register_to_file_service() + except Exception as e: + logger.debug("[LINE] resolve image url failed: %s", e) + return "" + + @staticmethod + async def _resolve_record_url(segment: Record) -> str: + candidate = (segment.url or segment.file or "").strip() + if candidate.startswith("http://") or candidate.startswith("https://"): + return candidate + try: + return await segment.register_to_file_service() + except Exception as e: + logger.debug("[LINE] resolve record url failed: %s", e) + return "" + + @staticmethod + async def _resolve_record_duration(segment: Record) -> int: + try: + file_path = await segment.convert_to_file_path() + duration_ms = await get_media_duration(file_path) + if isinstance(duration_ms, int) and duration_ms > 0: + return duration_ms + except Exception as e: + logger.debug("[LINE] resolve record duration failed: %s", e) + return 1000 + + @staticmethod + async def _resolve_video_url(segment: Video) -> str: + candidate = (segment.file or "").strip() + if candidate.startswith("http://") or candidate.startswith("https://"): + return candidate + try: + return await segment.register_to_file_service() + except Exception as e: + logger.debug("[LINE] resolve video url failed: %s", e) + return "" + + @staticmethod + async def _resolve_video_preview_url(segment: Video) -> str: + cover_candidate = (segment.cover or "").strip() + if cover_candidate.startswith("http://") or cover_candidate.startswith( + "https://" + ): + return cover_candidate + + if cover_candidate: + try: + cover_seg = Image(file=cover_candidate) + return await cover_seg.register_to_file_service() + except Exception as e: + logger.debug("[LINE] resolve video cover failed: %s", e) + + try: + video_path = await segment.convert_to_file_path() + temp_dir = Path(get_astrbot_temp_path()) + temp_dir.mkdir(parents=True, exist_ok=True) + thumb_path = temp_dir / f"line_video_preview_{uuid.uuid4().hex}.jpg" + + process = await asyncio.create_subprocess_exec( + "ffmpeg", + "-y", + "-ss", + "00:00:01", + "-i", + video_path, + "-frames:v", + "1", + str(thumb_path), + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + await process.communicate() + if process.returncode != 0 or not thumb_path.exists(): + return "" + + cover_seg = Image.fromFileSystem(str(thumb_path)) + return await cover_seg.register_to_file_service() + except Exception as e: + logger.debug("[LINE] generate video preview failed: %s", e) + return "" + + @staticmethod + async def _resolve_file_url(segment: File) -> str: + if segment.url and segment.url.startswith(("http://", "https://")): + return segment.url + try: + return await segment.register_to_file_service() + except Exception as e: + logger.debug("[LINE] resolve file url failed: %s", e) + return "" + + @staticmethod + async def _resolve_file_size(segment: File) -> int: + try: + file_path = await segment.get_file(allow_return_url=False) + if file_path and os.path.exists(file_path): + return int(os.path.getsize(file_path)) + except Exception as e: + logger.debug("[LINE] resolve file size failed: %s", e) + return 0 + + @classmethod + async def build_line_messages(cls, message_chain: MessageChain) -> list[dict]: + messages: list[dict] = [] + for segment in message_chain.chain: + obj = await cls._component_to_message_object(segment) + if obj: + messages.append(obj) + + if not messages: + return [] + + if len(messages) > 5: + logger.warning( + "[LINE] message count exceeds 5, extra segments will be dropped." + ) + messages = messages[:5] + return messages + + async def send(self, message: MessageChain) -> None: + messages = await self.build_line_messages(message) + if not messages: + return + + raw = self.message_obj.raw_message + reply_token = "" + if isinstance(raw, dict): + reply_token = str(raw.get("replyToken") or "") + + sent = False + if reply_token: + sent = await self.line_api.reply_message(reply_token, messages) + + if not sent: + target_id = self.get_group_id() or self.get_sender_id() + if target_id: + await self.line_api.push_message(target_id, messages) + + await super().send(message) + + async def send_streaming( + self, + generator: AsyncGenerator, + use_fallback: bool = False, + ): + if not use_fallback: + buffer = None + async for chain in generator: + if not buffer: + buffer = chain + else: + buffer.chain.extend(chain.chain) + if not buffer: + return None + buffer.squash_plain() + await self.send(buffer) + return await super().send_streaming(generator, use_fallback) + + buffer = "" + pattern = re.compile(r"[^。?!~…]+[。?!~…]+") + + async for chain in generator: + if isinstance(chain, MessageChain): + for comp in chain.chain: + if isinstance(comp, Plain): + buffer += comp.text + if any(p in buffer for p in "。?!~…"): + buffer = await self.process_buffer(buffer, pattern) + else: + await self.send(MessageChain(chain=[comp])) + await asyncio.sleep(1.5) + + if buffer.strip(): + await self.send(MessageChain([Plain(buffer)])) + return await super().send_streaming(generator, use_fallback) diff --git a/astrbot/core/platform/sources/misskey/misskey_adapter.py b/astrbot/core/platform/sources/misskey/misskey_adapter.py index d8f560b1b..fd61c3e50 100644 --- a/astrbot/core/platform/sources/misskey/misskey_adapter.py +++ b/astrbot/core/platform/sources/misskey/misskey_adapter.py @@ -21,7 +21,7 @@ except Exception: magic = None -from astrbot.core.utils.astrbot_path import get_astrbot_data_path +from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from .misskey_event import MisskeyPlatformEvent from .misskey_utils import ( @@ -121,7 +121,7 @@ def meta(self) -> PlatformMetadata: support_streaming_message=False, ) - async def run(self): + async def run(self) -> None: if not self.instance_url or not self.access_token: logger.error("[Misskey] 配置不完整,无法启动") return @@ -150,7 +150,7 @@ async def run(self): await self._start_websocket_connection() - def _register_event_handlers(self, streaming): + def _register_event_handlers(self, streaming) -> None: """注册事件处理器""" streaming.add_message_handler("notification", self._handle_notification) streaming.add_message_handler("main:notification", self._handle_notification) @@ -194,7 +194,7 @@ def _process_poll_data( message: AstrBotMessage, poll: dict[str, Any], message_parts: list[str], - ): + ) -> None: """处理投票数据,将其添加到消息中""" try: if not isinstance(message.raw_message, dict): @@ -233,7 +233,7 @@ def _extract_additional_fields(self, session, message_chain) -> dict[str, Any]: return fields - async def _start_websocket_connection(self): + async def _start_websocket_connection(self) -> None: backoff_delay = 1.0 max_backoff = 300.0 backoff_multiplier = 1.5 @@ -281,7 +281,7 @@ async def _start_websocket_connection(self): await asyncio.sleep(sleep_time) backoff_delay = min(backoff_delay * backoff_multiplier, max_backoff) - async def _handle_notification(self, data: dict[str, Any]): + async def _handle_notification(self, data: dict[str, Any]) -> None: try: notification_type = data.get("type") logger.debug( @@ -305,7 +305,7 @@ async def _handle_notification(self, data: dict[str, Any]): except Exception as e: logger.error(f"[Misskey] 处理通知失败: {e}") - async def _handle_chat_message(self, data: dict[str, Any]): + async def _handle_chat_message(self, data: dict[str, Any]) -> None: try: sender_id = str( data.get("fromUserId", "") or data.get("fromUser", {}).get("id", ""), @@ -340,7 +340,7 @@ async def _handle_chat_message(self, data: dict[str, Any]): except Exception as e: logger.error(f"[Misskey] 处理聊天消息失败: {e}") - async def _debug_handler(self, data: dict[str, Any]): + async def _debug_handler(self, data: dict[str, Any]) -> None: event_type = data.get("type", "unknown") logger.debug( f"[Misskey] 收到未处理事件: type={event_type}, channel={data.get('channel', 'unknown')}", @@ -498,7 +498,7 @@ async def _upload_comp(comp) -> object | None: finally: # 清理临时文件 if local_path and isinstance(local_path, str): - data_temp = os.path.join(get_astrbot_data_path(), "temp") + data_temp = get_astrbot_temp_path() if local_path.startswith(data_temp) and os.path.exists( local_path, ): @@ -754,7 +754,7 @@ async def convert_room_message(self, raw_data: dict[str, Any]) -> AstrBotMessage ) return message - async def terminate(self): + async def terminate(self) -> None: self._running = False if self.api: await self.api.close() diff --git a/astrbot/core/platform/sources/misskey/misskey_api.py b/astrbot/core/platform/sources/misskey/misskey_api.py index 06dc6304d..3e5eb9a90 100644 --- a/astrbot/core/platform/sources/misskey/misskey_api.py +++ b/astrbot/core/platform/sources/misskey/misskey_api.py @@ -3,7 +3,7 @@ import random import uuid from collections.abc import Awaitable, Callable -from typing import Any +from typing import Any, NoReturn try: import aiohttp @@ -43,7 +43,7 @@ class WebSocketError(APIError): class StreamingClient: - def __init__(self, instance_url: str, access_token: str): + def __init__(self, instance_url: str, access_token: str) -> None: self.instance_url = instance_url.rstrip("/") self.access_token = access_token self.websocket: Any | None = None @@ -90,7 +90,7 @@ async def connect(self) -> bool: self.is_connected = False return False - async def disconnect(self): + async def disconnect(self) -> None: self._running = False if self.websocket: await self.websocket.close() @@ -116,7 +116,7 @@ async def subscribe_channel( self.channels[channel_id] = channel_type return channel_id - async def unsubscribe_channel(self, channel_id: str): + async def unsubscribe_channel(self, channel_id: str) -> None: if ( not self.is_connected or not self.websocket @@ -136,10 +136,10 @@ def add_message_handler( self, event_type: str, handler: Callable[[dict], Awaitable[None]], - ): + ) -> None: self.message_handlers[event_type] = handler - async def listen(self): + async def listen(self) -> None: if not self.is_connected or not self.websocket: raise WebSocketError("WebSocket 未连接") @@ -187,7 +187,7 @@ async def listen(self): except Exception: pass - async def _handle_message(self, data: dict[str, Any]): + async def _handle_message(self, data: dict[str, Any]) -> None: message_type = data.get("type") body = data.get("body", {}) @@ -334,7 +334,7 @@ def __init__( download_timeout: int = 15, chunk_size: int = 64 * 1024, max_download_bytes: int | None = None, - ): + ) -> None: self.instance_url = instance_url.rstrip("/") self.access_token = access_token self._session: aiohttp.ClientSession | None = None @@ -375,7 +375,7 @@ def session(self) -> aiohttp.ClientSession: self._session = aiohttp.ClientSession(headers=headers) return self._session - def _handle_response_status(self, status: int, endpoint: str): + def _handle_response_status(self, status: int, endpoint: str) -> NoReturn: """处理 HTTP 响应状态码""" if status == 400: logger.error(f"[Misskey API] 请求参数错误: {endpoint} (HTTP {status})") @@ -449,7 +449,6 @@ async def _process_response( ) self._handle_response_status(response.status, endpoint) - raise APIConnectionError(f"Request failed for {endpoint}") @retry_async( max_retries=API_MAX_RETRIES, diff --git a/astrbot/core/platform/sources/misskey/misskey_event.py b/astrbot/core/platform/sources/misskey/misskey_event.py index 7975f0ec7..068f7e7a2 100644 --- a/astrbot/core/platform/sources/misskey/misskey_event.py +++ b/astrbot/core/platform/sources/misskey/misskey_event.py @@ -26,7 +26,7 @@ def __init__( platform_meta: PlatformMetadata, session_id: str, client, - ): + ) -> None: super().__init__(message_str, message_obj, platform_meta, session_id) self.client = client @@ -40,7 +40,7 @@ def _is_system_command(self, message_str: str) -> bool: return any(message_trimmed.startswith(prefix) for prefix in system_prefixes) - async def send(self, message: MessageChain): + async def send(self, message: MessageChain) -> None: """发送消息,使用适配器的完整上传和发送逻辑""" try: logger.debug( diff --git a/astrbot/core/platform/sources/misskey/misskey_utils.py b/astrbot/core/platform/sources/misskey/misskey_utils.py index d9388598d..dd02c13c0 100644 --- a/astrbot/core/platform/sources/misskey/misskey_utils.py +++ b/astrbot/core/platform/sources/misskey/misskey_utils.py @@ -403,7 +403,7 @@ def cache_user_info( raw_data: dict[str, Any], client_self_id: str, is_chat: bool = False, -): +) -> None: """缓存用户信息""" if is_chat: user_cache_data = { @@ -429,7 +429,7 @@ def cache_room_info( user_cache: dict[str, Any], raw_data: dict[str, Any], client_self_id: str, -): +) -> None: """缓存房间信息""" room_data = raw_data.get("toRoom") room_id = raw_data.get("toRoomId") diff --git a/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py b/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py index 6076bfc1b..868ec8a65 100644 --- a/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py +++ b/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py @@ -7,24 +7,47 @@ import aiofiles import botpy +import botpy.errors import botpy.message import botpy.types import botpy.types.message from botpy import Client from botpy.http import Route from botpy.types import message -from botpy.types.message import Media +from botpy.types.message import MarkdownPayload, Media from astrbot.api import logger from astrbot.api.event import AstrMessageEvent, MessageChain from astrbot.api.message_components import Image, Plain, Record from astrbot.api.platform import AstrBotMessage, PlatformMetadata -from astrbot.core.utils.astrbot_path import get_astrbot_data_path +from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from astrbot.core.utils.io import download_image_by_url, file_to_base64 from astrbot.core.utils.tencent_record_helper import wav_to_tencent_silk +def _patch_qq_botpy_formdata() -> None: + """Patch qq-botpy for aiohttp>=3.12 compatibility. + + qq-botpy 1.2.1 defines botpy.http._FormData._gen_form_data() and expects + aiohttp.FormData to have a private flag named _is_processed, which is no + longer present in newer aiohttp versions. + """ + + try: + from botpy.http import _FormData # type: ignore + + if not hasattr(_FormData, "_is_processed"): + setattr(_FormData, "_is_processed", False) + except Exception: + logger.debug("[QQOfficial] Skip botpy FormData patch.") + + +_patch_qq_botpy_formdata() + + class QQOfficialMessageEvent(AstrMessageEvent): + MARKDOWN_NOT_ALLOWED_ERROR = "不允许发送原生 markdown" + def __init__( self, message_str: str, @@ -32,12 +55,12 @@ def __init__( platform_meta: PlatformMetadata, session_id: str, bot: Client, - ): + ) -> None: super().__init__(message_str, message_obj, platform_meta, session_id) self.bot = bot self.send_buffer = None - async def send(self, message: MessageChain): + async def send(self, message: MessageChain) -> None: self.send_buffer = message await self._post_send() @@ -114,7 +137,9 @@ async def _post_send(self, stream: dict | None = None): return None payload: dict = { - "content": plain_text, + # "content": plain_text, + "markdown": MarkdownPayload(content=plain_text) if plain_text else None, + "msg_type": 2, "msg_id": self.message_obj.message_id, } @@ -137,6 +162,8 @@ async def _post_send(self, stream: dict | None = None): ) payload["media"] = media payload["msg_type"] = 7 + payload.pop("markdown", None) + payload["content"] = plain_text or None if record_file_path: # group record msg media = await self.upload_group_and_c2c_record( record_file_path, @@ -145,9 +172,15 @@ async def _post_send(self, stream: dict | None = None): ) payload["media"] = media payload["msg_type"] = 7 - ret = await self.bot.api.post_group_message( - group_openid=source.group_openid, - **payload, + payload.pop("markdown", None) + payload["content"] = plain_text or None + ret = await self._send_with_markdown_fallback( + send_func=lambda retry_payload: self.bot.api.post_group_message( + group_openid=source.group_openid, # type: ignore + **retry_payload, + ), + payload=payload, + plain_text=plain_text, ) case botpy.message.C2CMessage(): @@ -159,6 +192,8 @@ async def _post_send(self, stream: dict | None = None): ) payload["media"] = media payload["msg_type"] = 7 + payload.pop("markdown", None) + payload["content"] = plain_text or None if record_file_path: # c2c record media = await self.upload_group_and_c2c_record( record_file_path, @@ -167,31 +202,56 @@ async def _post_send(self, stream: dict | None = None): ) payload["media"] = media payload["msg_type"] = 7 + payload.pop("markdown", None) + payload["content"] = plain_text or None if stream: - ret = await self.post_c2c_message( - openid=source.author.user_openid, - **payload, - stream=stream, + ret = await self._send_with_markdown_fallback( + send_func=lambda retry_payload: self.post_c2c_message( + openid=source.author.user_openid, + **retry_payload, + stream=stream, + ), + payload=payload, + plain_text=plain_text, ) else: - ret = await self.post_c2c_message( - openid=source.author.user_openid, - **payload, + ret = await self._send_with_markdown_fallback( + send_func=lambda retry_payload: self.post_c2c_message( + openid=source.author.user_openid, + **retry_payload, + ), + payload=payload, + plain_text=plain_text, ) logger.debug(f"Message sent to C2C: {ret}") case botpy.message.Message(): if image_path: payload["file_image"] = image_path - ret = await self.bot.api.post_message( - channel_id=source.channel_id, - **payload, + # Guild text-channel send API (/channels/{channel_id}/messages) does not use v2 msg_type. + payload.pop("msg_type", None) + ret = await self._send_with_markdown_fallback( + send_func=lambda retry_payload: self.bot.api.post_message( + channel_id=source.channel_id, + **retry_payload, + ), + payload=payload, + plain_text=plain_text, ) case botpy.message.DirectMessage(): if image_path: payload["file_image"] = image_path - ret = await self.bot.api.post_dms(guild_id=source.guild_id, **payload) + # Guild DM send API (/dms/{guild_id}/messages) does not use v2 msg_type. + payload.pop("msg_type", None) + ret = await self._send_with_markdown_fallback( + send_func=lambda retry_payload: self.bot.api.post_dms( + guild_id=source.guild_id, + **retry_payload, + ), + payload=payload, + plain_text=plain_text, + ) case _: pass @@ -202,6 +262,32 @@ async def _post_send(self, stream: dict | None = None): return ret + async def _send_with_markdown_fallback( + self, + send_func, + payload: dict, + plain_text: str, + ): + try: + return await send_func(payload) + except botpy.errors.ServerError as err: + if ( + self.MARKDOWN_NOT_ALLOWED_ERROR not in str(err) + or not payload.get("markdown") + or not plain_text + ): + raise + + logger.warning( + "[QQOfficial] markdown 发送被拒绝,回退到 content 模式重试。" + ) + fallback_payload = payload.copy() + fallback_payload["markdown"] = None + fallback_payload["content"] = plain_text + if fallback_payload.get("msg_type") == 2: + fallback_payload["msg_type"] = 0 + return await send_func(fallback_payload) + async def upload_group_and_c2c_image( self, image_base64: str, @@ -350,10 +436,10 @@ async def _parse_to_qqofficial(message: MessageChain): elif isinstance(i, Record): if i.file: record_wav_path = await i.convert_to_file_path() # wav 路径 - temp_dir = os.path.join(get_astrbot_data_path(), "temp") + temp_dir = get_astrbot_temp_path() record_tecent_silk_path = os.path.join( temp_dir, - f"{uuid.uuid4()}.silk", + f"qqofficial_{uuid.uuid4()}.silk", ) try: duration = await wav_to_tencent_silk( diff --git a/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py b/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py index 6f1164faf..603bc8f58 100644 --- a/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py +++ b/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py @@ -8,13 +8,11 @@ import botpy import botpy.message -import botpy.types -import botpy.types.message from botpy import Client from astrbot import logger from astrbot.api.event import MessageChain -from astrbot.api.message_components import At, Image, Plain +from astrbot.api.message_components import At, File, Image, Plain from astrbot.api.platform import ( AstrBotMessage, MessageMember, @@ -35,11 +33,13 @@ # QQ 机器人官方框架 class botClient(Client): - def set_platform(self, platform: QQOfficialPlatformAdapter): + def set_platform(self, platform: QQOfficialPlatformAdapter) -> None: self.platform = platform # 收到群消息 - async def on_group_at_message_create(self, message: botpy.message.GroupMessage): + async def on_group_at_message_create( + self, message: botpy.message.GroupMessage + ) -> None: abm = QQOfficialPlatformAdapter._parse_from_qqofficial( message, MessageType.GROUP_MESSAGE, @@ -49,7 +49,7 @@ async def on_group_at_message_create(self, message: botpy.message.GroupMessage): self._commit(abm) # 收到频道消息 - async def on_at_message_create(self, message: botpy.message.Message): + async def on_at_message_create(self, message: botpy.message.Message) -> None: abm = QQOfficialPlatformAdapter._parse_from_qqofficial( message, MessageType.GROUP_MESSAGE, @@ -59,7 +59,9 @@ async def on_at_message_create(self, message: botpy.message.Message): self._commit(abm) # 收到私聊消息 - async def on_direct_message_create(self, message: botpy.message.DirectMessage): + async def on_direct_message_create( + self, message: botpy.message.DirectMessage + ) -> None: abm = QQOfficialPlatformAdapter._parse_from_qqofficial( message, MessageType.FRIEND_MESSAGE, @@ -68,7 +70,7 @@ async def on_direct_message_create(self, message: botpy.message.DirectMessage): self._commit(abm) # 收到 C2C 消息 - async def on_c2c_message_create(self, message: botpy.message.C2CMessage): + async def on_c2c_message_create(self, message: botpy.message.C2CMessage) -> None: abm = QQOfficialPlatformAdapter._parse_from_qqofficial( message, MessageType.FRIEND_MESSAGE, @@ -76,7 +78,7 @@ async def on_c2c_message_create(self, message: botpy.message.C2CMessage): abm.session_id = abm.sender.user_id self._commit(abm) - def _commit(self, abm: AstrBotMessage): + def _commit(self, abm: AstrBotMessage) -> None: self.platform.commit_event( QQOfficialMessageEvent( abm.message_str, @@ -128,7 +130,7 @@ async def send_by_session( self, session: MessageSesion, message_chain: MessageChain, - ): + ) -> None: raise NotImplementedError("QQ 机器人官方 API 适配器不支持 send_by_session") def meta(self) -> PlatformMetadata: @@ -139,6 +141,41 @@ def meta(self) -> PlatformMetadata: support_proactive_message=False, ) + @staticmethod + def _normalize_attachment_url(url: str | None) -> str: + if not url: + return "" + if url.startswith("http://") or url.startswith("https://"): + return url + return f"https://{url}" + + @staticmethod + def _append_attachments( + msg: list[BaseMessageComponent], + attachments: list | None, + ) -> None: + if not attachments: + return + + for attachment in attachments: + content_type = cast(str, getattr(attachment, "content_type", "") or "") + url = QQOfficialPlatformAdapter._normalize_attachment_url( + cast(str | None, getattr(attachment, "url", None)) + ) + if not url: + continue + + if content_type.startswith("image"): + msg.append(Image.fromURL(url)) + else: + filename = cast( + str, + getattr(attachment, "filename", None) + or getattr(attachment, "name", None) + or "attachment", + ) + msg.append(File(name=filename, file=url, url=url)) + @staticmethod def _parse_from_qqofficial( message: botpy.message.Message @@ -168,14 +205,7 @@ def _parse_from_qqofficial( abm.self_id = "unknown_selfid" msg.append(At(qq="qq_official")) msg.append(Plain(abm.message_str)) - if message.attachments: - for i in message.attachments: - if i.content_type.startswith("image"): - url = i.url - if not url.startswith("http"): - url = "https://" + url - img = Image.fromURL(url) - msg.append(img) + QQOfficialPlatformAdapter._append_attachments(msg, message.attachments) abm.message = msg elif isinstance(message, botpy.message.Message) or isinstance( @@ -192,14 +222,7 @@ def _parse_from_qqofficial( "", ).strip() - if message.attachments: - for i in message.attachments: - if i.content_type.startswith("image"): - url = i.url - if not url.startswith("http"): - url = "https://" + url - img = Image.fromURL(url) - msg.append(img) + QQOfficialPlatformAdapter._append_attachments(msg, message.attachments) abm.message = msg abm.message_str = plain_content abm.sender = MessageMember( @@ -222,6 +245,6 @@ def run(self): def get_client(self) -> botClient: return self.client - async def terminate(self): + async def terminate(self) -> None: await self.client.close() logger.info("QQ 官方机器人接口 适配器已被优雅地关闭") diff --git a/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_adapter.py b/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_adapter.py index af160f1b5..6aae6b9ce 100644 --- a/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_adapter.py +++ b/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_adapter.py @@ -1,11 +1,11 @@ import asyncio import logging +import random +from types import SimpleNamespace from typing import Any, cast import botpy import botpy.message -import botpy.types -import botpy.types.message from botpy import Client from astrbot import logger @@ -15,6 +15,7 @@ from astrbot.core.utils.webhook_utils import log_webhook_info from ...register import register_platform_adapter +from ..qqofficial.qqofficial_message_event import QQOfficialMessageEvent from ..qqofficial.qqofficial_platform_adapter import QQOfficialPlatformAdapter from .qo_webhook_event import QQOfficialWebhookMessageEvent from .qo_webhook_server import QQOfficialWebhook @@ -26,48 +27,57 @@ # QQ 机器人官方框架 class botClient(Client): - def set_platform(self, platform: "QQOfficialWebhookPlatformAdapter"): + def set_platform(self, platform: "QQOfficialWebhookPlatformAdapter") -> None: self.platform = platform # 收到群消息 - async def on_group_at_message_create(self, message: botpy.message.GroupMessage): + async def on_group_at_message_create( + self, message: botpy.message.GroupMessage + ) -> None: abm = QQOfficialPlatformAdapter._parse_from_qqofficial( message, MessageType.GROUP_MESSAGE, ) abm.group_id = cast(str, message.group_openid) abm.session_id = abm.group_id + self.platform.remember_session_scene(abm.session_id, "group") self._commit(abm) # 收到频道消息 - async def on_at_message_create(self, message: botpy.message.Message): + async def on_at_message_create(self, message: botpy.message.Message) -> None: abm = QQOfficialPlatformAdapter._parse_from_qqofficial( message, MessageType.GROUP_MESSAGE, ) abm.group_id = message.channel_id abm.session_id = abm.group_id + self.platform.remember_session_scene(abm.session_id, "channel") self._commit(abm) # 收到私聊消息 - async def on_direct_message_create(self, message: botpy.message.DirectMessage): + async def on_direct_message_create( + self, message: botpy.message.DirectMessage + ) -> None: abm = QQOfficialPlatformAdapter._parse_from_qqofficial( message, MessageType.FRIEND_MESSAGE, ) abm.session_id = abm.sender.user_id + self.platform.remember_session_scene(abm.session_id, "friend") self._commit(abm) # 收到 C2C 消息 - async def on_c2c_message_create(self, message: botpy.message.C2CMessage): + async def on_c2c_message_create(self, message: botpy.message.C2CMessage) -> None: abm = QQOfficialPlatformAdapter._parse_from_qqofficial( message, MessageType.FRIEND_MESSAGE, ) abm.session_id = abm.sender.user_id + self.platform.remember_session_scene(abm.session_id, "friend") self._commit(abm) - def _commit(self, abm: AstrBotMessage): + def _commit(self, abm: AstrBotMessage) -> None: + self.platform.remember_session_message_id(abm.session_id, abm.message_id) self.platform.commit_event( QQOfficialWebhookMessageEvent( abm.message_str, @@ -105,23 +115,132 @@ def __init__( ) self.client.set_platform(self) self.webhook_helper = None + self._session_last_message_id: dict[str, str] = {} + self._session_scene: dict[str, str] = {} async def send_by_session( self, session: MessageSesion, message_chain: MessageChain, - ): - raise NotImplementedError("QQ 机器人官方 API 适配器不支持 send_by_session") + ) -> None: + ( + plain_text, + image_base64, + image_path, + record_file_path, + ) = await QQOfficialMessageEvent._parse_to_qqofficial(message_chain) + if not plain_text and not image_path: + return + + msg_id = self._session_last_message_id.get(session.session_id) + if not msg_id: + logger.warning( + "[QQOfficialWebhook] No cached msg_id for session: %s, skip send_by_session", + session.session_id, + ) + return + + payload: dict[str, Any] = {"content": plain_text, "msg_id": msg_id} + ret: Any = None + send_helper = SimpleNamespace(bot=self.client) + if session.message_type == MessageType.GROUP_MESSAGE: + scene = self._session_scene.get(session.session_id) + if scene == "group": + payload["msg_seq"] = random.randint(1, 10000) + if image_base64: + media = await QQOfficialMessageEvent.upload_group_and_c2c_image( + send_helper, # type: ignore + image_base64, + 1, + group_openid=session.session_id, + ) + payload["media"] = media + payload["msg_type"] = 7 + if record_file_path: + media = await QQOfficialMessageEvent.upload_group_and_c2c_record( + send_helper, # type: ignore + record_file_path, + 3, + group_openid=session.session_id, + ) + payload["media"] = media + payload["msg_type"] = 7 + ret = await self.client.api.post_group_message( + group_openid=session.session_id, + **payload, + ) + else: + if image_path: + payload["file_image"] = image_path + ret = await self.client.api.post_message( + channel_id=session.session_id, + **payload, + ) + elif session.message_type == MessageType.FRIEND_MESSAGE: + payload["msg_seq"] = random.randint(1, 10000) + if image_base64: + media = await QQOfficialMessageEvent.upload_group_and_c2c_image( + send_helper, # type: ignore + image_base64, + 1, + openid=session.session_id, + ) + payload["media"] = media + payload["msg_type"] = 7 + if record_file_path: + media = await QQOfficialMessageEvent.upload_group_and_c2c_record( + send_helper, # type: ignore + record_file_path, + 3, + openid=session.session_id, + ) + payload["media"] = media + payload["msg_type"] = 7 + ret = await QQOfficialMessageEvent.post_c2c_message( + send_helper, # type: ignore + openid=session.session_id, + **payload, + ) + else: + logger.warning( + "[QQOfficialWebhook] Unsupported message type for send_by_session: %s", + session.message_type, + ) + return + + sent_message_id = self._extract_message_id(ret) + if sent_message_id: + self.remember_session_message_id(session.session_id, sent_message_id) + await super().send_by_session(session, message_chain) + + def remember_session_message_id(self, session_id: str, message_id: str) -> None: + if not session_id or not message_id: + return + self._session_last_message_id[session_id] = message_id + + def remember_session_scene(self, session_id: str, scene: str) -> None: + if not session_id or not scene: + return + self._session_scene[session_id] = scene + + def _extract_message_id(self, ret: Any) -> str | None: + if isinstance(ret, dict): + message_id = ret.get("id") + return str(message_id) if message_id else None + message_id = getattr(ret, "id", None) + if message_id: + return str(message_id) + return None def meta(self) -> PlatformMetadata: return PlatformMetadata( name="qq_official_webhook", description="QQ 机器人官方 API 适配器", id=cast(str, self.config.get("id")), - support_proactive_message=False, + support_proactive_message=True, ) - async def run(self): + async def run(self) -> None: self.webhook_helper = QQOfficialWebhook( self.config, self._event_queue, @@ -149,7 +268,7 @@ async def webhook_callback(self, request: Any) -> Any: # 复用 webhook_helper 的回调处理逻辑 return await self.webhook_helper.handle_callback(request) - async def terminate(self): + async def terminate(self) -> None: if self.webhook_helper: self.webhook_helper.shutdown_event.set() await self.client.close() diff --git a/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_event.py b/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_event.py index 306db5e56..5ceeb2c70 100644 --- a/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_event.py +++ b/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_event.py @@ -13,5 +13,5 @@ def __init__( platform_meta: PlatformMetadata, session_id: str, bot: Client, - ): + ) -> None: super().__init__(message_str, message_obj, platform_meta, session_id, bot) diff --git a/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_server.py b/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_server.py index 50db7fb21..5f35471ee 100644 --- a/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_server.py +++ b/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_server.py @@ -14,12 +14,14 @@ class QQOfficialWebhook: - def __init__(self, config: dict, event_queue: asyncio.Queue, botpy_client: Client): + def __init__( + self, config: dict, event_queue: asyncio.Queue, botpy_client: Client + ) -> None: self.appid = config["appid"] self.secret = config["secret"] self.port = config.get("port", 6196) self.is_sandbox = config.get("is_sandbox", False) - self.callback_server_host = config.get("callback_server_host", "::") + self.callback_server_host = config.get("callback_server_host", "0.0.0.0") if isinstance(self.port, str): self.port = int(self.port) @@ -38,7 +40,7 @@ def __init__(self, config: dict, event_queue: asyncio.Queue, botpy_client: Clien self.event_queue = event_queue self.shutdown_event = asyncio.Event() - async def initialize(self): + async def initialize(self) -> None: logger.info("正在登录到 QQ 官方机器人...") self.user = await self.http.login(self.token) logger.info(f"已登录 QQ 官方机器人账号: {self.user}") @@ -46,7 +48,7 @@ async def initialize(self): self.client.api = self.api self.client.http = self.http - async def bot_connect(): + async def bot_connect() -> None: pass self._connection = ConnectionSession( @@ -115,7 +117,7 @@ async def handle_callback(self, request) -> dict: return {"opcode": 12} - async def start_polling(self): + async def start_polling(self) -> None: logger.info( f"将在 {self.callback_server_host}:{self.port} 端口启动 QQ 官方机器人 webhook 适配器。", ) @@ -125,5 +127,5 @@ async def start_polling(self): shutdown_trigger=self.shutdown_trigger, ) - async def shutdown_trigger(self): + async def shutdown_trigger(self) -> None: await self.shutdown_event.wait() diff --git a/astrbot/core/platform/sources/satori/satori_adapter.py b/astrbot/core/platform/sources/satori/satori_adapter.py index 10912dc8e..5c2f7a37f 100644 --- a/astrbot/core/platform/sources/satori/satori_adapter.py +++ b/astrbot/core/platform/sources/satori/satori_adapter.py @@ -73,7 +73,7 @@ async def send_by_session( self, session: MessageSession, message_chain: MessageChain, - ): + ) -> None: from .satori_event import SatoriPlatformEvent await SatoriPlatformEvent.send_with_adapter( @@ -99,7 +99,7 @@ def _is_websocket_closed(self, ws) -> bool: except AttributeError: return False - async def run(self): + async def run(self) -> None: self.running = True self.session = ClientSession(timeout=ClientTimeout(total=30)) @@ -133,7 +133,7 @@ async def run(self): if self.session: await self.session.close() - async def connect_websocket(self): + async def connect_websocket(self) -> None: logger.info(f"Satori 适配器正在连接到 WebSocket: {self.endpoint}") logger.info(f"Satori 适配器 HTTP API 地址: {self.api_base_url}") @@ -181,7 +181,7 @@ async def connect_websocket(self): except Exception as e: logger.error(f"Satori WebSocket 关闭异常: {e}") - async def send_identify(self): + async def send_identify(self) -> None: if not self.ws: raise Exception("WebSocket连接未建立") @@ -209,7 +209,7 @@ async def send_identify(self): logger.error(f"发送 IDENTIFY 信令失败: {e}") raise - async def heartbeat_loop(self): + async def heartbeat_loop(self) -> None: try: while self.running and self.ws: await asyncio.sleep(self.heartbeat_interval) @@ -234,7 +234,7 @@ async def heartbeat_loop(self): except Exception as e: logger.error(f"心跳任务异常: {e}") - async def handle_message(self, message: str): + async def handle_message(self, message: str) -> None: try: data = json.loads(message) op = data.get("op") @@ -275,7 +275,7 @@ async def handle_message(self, message: str): except Exception as e: logger.error(f"处理 WebSocket 消息异常: {e}") - async def handle_event(self, event_data: dict): + async def handle_event(self, event_data: dict) -> None: try: event_type = event_data.get("type") sn = event_data.get("sn") @@ -720,7 +720,7 @@ async def _parse_xml_node(self, node: ET.Element, elements: list) -> None: if child.tail and child.tail.strip(): elements.append(Plain(text=child.tail)) - async def handle_msg(self, message: AstrBotMessage): + async def handle_msg(self, message: AstrBotMessage) -> None: from .satori_event import SatoriPlatformEvent message_event = SatoriPlatformEvent( @@ -780,7 +780,7 @@ async def send_http_request( logger.error(f"Satori HTTP 请求异常: {e}") return {} - async def terminate(self): + async def terminate(self) -> None: self.running = False if self.heartbeat_task: diff --git a/astrbot/core/platform/sources/satori/satori_event.py b/astrbot/core/platform/sources/satori/satori_event.py index 81a0d222c..021422283 100644 --- a/astrbot/core/platform/sources/satori/satori_event.py +++ b/astrbot/core/platform/sources/satori/satori_event.py @@ -28,7 +28,7 @@ def __init__( platform_meta: PlatformMetadata, session_id: str, adapter: "SatoriPlatformAdapter", - ): + ) -> None: # 更新平台元数据 if adapter and hasattr(adapter, "logins") and adapter.logins: current_login = adapter.logins[0] @@ -110,7 +110,7 @@ async def send_with_adapter( logger.error(f"Satori 消息发送异常: {e}") return None - async def send(self, message: MessageChain): + async def send(self, message: MessageChain) -> None: platform = getattr(self, "platform", None) user_id = getattr(self, "user_id", None) diff --git a/astrbot/core/platform/sources/slack/client.py b/astrbot/core/platform/sources/slack/client.py index 0e43cadee..efd7a6f3d 100644 --- a/astrbot/core/platform/sources/slack/client.py +++ b/astrbot/core/platform/sources/slack/client.py @@ -23,11 +23,11 @@ def __init__( self, web_client: AsyncWebClient, signing_secret: str, - host: str = "::", + host: str = "0.0.0.0", port: int = 3000, path: str = "/slack/events", event_handler: Callable | None = None, - ): + ) -> None: self.web_client = web_client self.signing_secret = signing_secret self.host = host @@ -44,7 +44,7 @@ def __init__( self.shutdown_event = asyncio.Event() - def _setup_routes(self): + def _setup_routes(self) -> None: """设置路由""" @self.app.route(self.path, methods=["POST"]) @@ -105,7 +105,7 @@ async def handle_callback(self, req): logger.error(f"处理 Slack 事件时出错: {e}") return Response("Internal Server Error", status=500) - async def start(self): + async def start(self) -> None: """启动 Webhook 服务器""" logger.info( f"Slack Webhook 服务器启动中,监听 {self.host}:{self.port}{self.path}...", @@ -118,10 +118,10 @@ async def start(self): shutdown_trigger=self.shutdown_trigger, ) - async def shutdown_trigger(self): + async def shutdown_trigger(self) -> None: await self.shutdown_event.wait() - async def stop(self): + async def stop(self) -> None: """停止 Webhook 服务器""" self.shutdown_event.set() logger.info("Slack Webhook 服务器已停止") @@ -135,7 +135,7 @@ def __init__( web_client: AsyncWebClient, app_token: str, event_handler: Callable | None = None, - ): + ) -> None: self.web_client = web_client self.app_token = app_token self.event_handler = event_handler @@ -143,7 +143,7 @@ def __init__( async def _handle_events( self, _: AsyncBaseSocketModeClient, req: SocketModeRequest - ): + ) -> None: """处理 Socket Mode 事件""" try: if self.socket_client is None: @@ -160,7 +160,7 @@ async def _handle_events( except Exception as e: logger.error(f"处理 Socket Mode 事件时出错: {e}") - async def start(self): + async def start(self) -> None: """启动 Socket Mode 连接""" self.socket_client = SocketModeClient( app_token=self.app_token, @@ -174,7 +174,7 @@ async def start(self): logger.info("Slack Socket Mode 客户端启动中...") await self.socket_client.connect() - async def stop(self): + async def stop(self) -> None: """停止 Socket Mode 连接""" if self.socket_client: await self.socket_client.disconnect() diff --git a/astrbot/core/platform/sources/slack/slack_adapter.py b/astrbot/core/platform/sources/slack/slack_adapter.py index f34242ce6..13e317e49 100644 --- a/astrbot/core/platform/sources/slack/slack_adapter.py +++ b/astrbot/core/platform/sources/slack/slack_adapter.py @@ -47,7 +47,7 @@ def __init__( self.signing_secret = platform_config.get("signing_secret") self.connection_mode = platform_config.get("slack_connection_mode", "socket") self.unified_webhook_mode = platform_config.get("unified_webhook_mode", False) - self.webhook_host = platform_config.get("slack_webhook_host", "::") + self.webhook_host = platform_config.get("slack_webhook_host", "0.0.0.0") self.webhook_port = platform_config.get("slack_webhook_port", 3000) self.webhook_path = platform_config.get( "slack_webhook_path", @@ -81,7 +81,7 @@ async def send_by_session( self, session: MessageSesion, message_chain: MessageChain, - ): + ) -> None: blocks, text = await SlackMessageEvent._parse_slack_blocks( message_chain=message_chain, web_client=self.web_client, @@ -285,7 +285,7 @@ def _parse_blocks(self, blocks: list) -> list: return message_components - async def _handle_socket_event(self, req: SocketModeRequest): + async def _handle_socket_event(self, req: SocketModeRequest) -> None: """处理 Socket Mode 事件""" if req.type == "events_api": # 事件 API @@ -374,7 +374,7 @@ async def run(self) -> None: f"不支持的连接模式: {self.connection_mode},请使用 'socket' 或 'webhook'", ) - async def _handle_webhook_event(self, event_data: dict): + async def _handle_webhook_event(self, event_data: dict) -> None: """处理 Webhook 事件""" event = event_data.get("event", {}) @@ -401,7 +401,7 @@ async def webhook_callback(self, request: Any) -> Any: return await self.webhook_client.handle_callback(request) - async def terminate(self): + async def terminate(self) -> None: if self.socket_client: await self.socket_client.stop() if self.webhook_client: @@ -411,7 +411,7 @@ async def terminate(self): def meta(self) -> PlatformMetadata: return self.metadata - async def handle_msg(self, message: AstrBotMessage): + async def handle_msg(self, message: AstrBotMessage) -> None: message_event = SlackMessageEvent( message_str=message.message_str, message_obj=message, diff --git a/astrbot/core/platform/sources/slack/slack_event.py b/astrbot/core/platform/sources/slack/slack_event.py index 822e6fdeb..3f62690b5 100644 --- a/astrbot/core/platform/sources/slack/slack_event.py +++ b/astrbot/core/platform/sources/slack/slack_event.py @@ -24,7 +24,7 @@ def __init__( platform_meta, session_id, web_client: AsyncWebClient, - ): + ) -> None: super().__init__(message_str, message_obj, platform_meta, session_id) self.web_client = web_client @@ -126,7 +126,7 @@ async def _parse_slack_blocks( return blocks, "" if blocks else text_content - async def send(self, message: MessageChain): + async def send(self, message: MessageChain) -> None: blocks, text = await SlackMessageEvent._parse_slack_blocks( message, self.web_client, diff --git a/astrbot/core/platform/sources/telegram/tg_adapter.py b/astrbot/core/platform/sources/telegram/tg_adapter.py index e7f2a102e..2dd72bd0c 100644 --- a/astrbot/core/platform/sources/telegram/tg_adapter.py +++ b/astrbot/core/platform/sources/telegram/tg_adapter.py @@ -1,7 +1,9 @@ import asyncio +import os import re import sys import uuid +from typing import cast from apscheduler.schedulers.asyncio import AsyncIOScheduler from telegram import BotCommand, Update @@ -25,6 +27,9 @@ from astrbot.core.star.filter.command_group import CommandGroupFilter from astrbot.core.star.star import star_map from astrbot.core.star.star_handler import star_handlers_registry +from astrbot.core.utils.astrbot_path import get_astrbot_temp_path +from astrbot.core.utils.io import download_file +from astrbot.core.utils.media_utils import convert_audio_to_wav from .tg_event import TelegramPlatformEvent @@ -89,12 +94,22 @@ def __init__( self.scheduler = AsyncIOScheduler() + # Media group handling + # Cache structure: {media_group_id: {"created_at": datetime, "items": [(update, context), ...]}} + self.media_group_cache: dict[str, dict] = {} + self.media_group_timeout = self.config.get( + "telegram_media_group_timeout", 2.5 + ) # seconds - debounce delay between messages + self.media_group_max_wait = self.config.get( + "telegram_media_group_max_wait", 10.0 + ) # max seconds - hard cap to prevent indefinite delay + @override async def send_by_session( self, session: MessageSesion, message_chain: MessageChain, - ): + ) -> None: from_username = session.session_id await TelegramPlatformEvent.send_with_client( self.client, @@ -109,7 +124,7 @@ def meta(self) -> PlatformMetadata: return PlatformMetadata(name="telegram", description="telegram 适配器", id=id_) @override - async def run(self): + async def run(self) -> None: await self.application.initialize() await self.application.start() @@ -134,7 +149,7 @@ async def run(self): logger.info("Telegram Platform Adapter is running.") await queue - async def register_commands(self): + async def register_commands(self) -> None: """收集所有注册的指令并注册到 Telegram""" try: commands = self.collect_commands() @@ -164,14 +179,19 @@ def collect_commands(self) -> list[BotCommand]: if not handler_metadata.enabled: continue for event_filter in handler_metadata.event_filters: - cmd_info = self._extract_command_info( + cmd_info_list = self._extract_command_info( event_filter, handler_metadata, skip_commands, ) - if cmd_info: - cmd_name, description = cmd_info - command_dict.setdefault(cmd_name, description) + if cmd_info_list: + for cmd_name, description in cmd_info_list: + if cmd_name in command_dict: + logger.warning( + f"命令名 '{cmd_name}' 重复注册,将使用首次注册的定义: " + f"'{command_dict[cmd_name]}'" + ) + command_dict.setdefault(cmd_name, description) commands_a = sorted(command_dict.keys()) return [BotCommand(cmd, command_dict[cmd]) for cmd in commands_a] @@ -181,9 +201,9 @@ def _extract_command_info( event_filter, handler_metadata, skip_commands: set, - ) -> tuple[str, str] | None: - """从事件过滤器中提取指令信息""" - cmd_name = None + ) -> list[tuple[str, str]] | None: + """从事件过滤器中提取指令信息,包括所有别名""" + cmd_names = [] is_group = False if isinstance(event_filter, CommandFilter) and event_filter.command_name: if ( @@ -191,28 +211,34 @@ def _extract_command_info( and event_filter.parent_command_names != [""] ): return None - cmd_name = event_filter.command_name + # 收集主命令名和所有别名 + cmd_names = [event_filter.command_name] + if event_filter.alias: + cmd_names.extend(event_filter.alias) elif isinstance(event_filter, CommandGroupFilter): if event_filter.parent_group: return None - cmd_name = event_filter.group_name + cmd_names = [event_filter.group_name] is_group = True - if not cmd_name or cmd_name in skip_commands: - return None + result = [] + for cmd_name in cmd_names: + if not cmd_name or cmd_name in skip_commands: + continue + if not re.match(r"^[a-z0-9_]+$", cmd_name) or len(cmd_name) > 32: + continue - if not re.match(r"^[a-z0-9_]+$", cmd_name) or len(cmd_name) > 32: - return None + # Build description. + description = handler_metadata.desc or ( + f"Command group: {cmd_name}" if is_group else f"Command: {cmd_name}" + ) + if len(description) > 30: + description = description[:30] + "..." + result.append((cmd_name, description)) - # Build description. - description = handler_metadata.desc or ( - f"指令组: {cmd_name} (包含多个子指令)" if is_group else f"指令: {cmd_name}" - ) - if len(description) > 30: - description = description[:30] + "..." - return cmd_name, description + return result if result else None - async def start(self, update: Update, context: ContextTypes.DEFAULT_TYPE): + async def start(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: if not update.effective_chat: logger.warning( "Received a start command without an effective chat, skipping /start reply.", @@ -223,8 +249,17 @@ async def start(self, update: Update, context: ContextTypes.DEFAULT_TYPE): text=self.config["start_message"], ) - async def message_handler(self, update: Update, context: ContextTypes.DEFAULT_TYPE): + async def message_handler( + self, update: Update, context: ContextTypes.DEFAULT_TYPE + ) -> None: logger.debug(f"Telegram message: {update.message}") + + # Handle media group messages + if update.message and update.message.media_group_id: + await self.handle_media_group_message(update, context) + return + + # Handle regular messages abm = await self.convert_message(update, context) if abm: await self.handle_msg(abm) @@ -345,8 +380,19 @@ async def convert_message( elif update.message.voice: file = await update.message.voice.get_file() + + file_basename = os.path.basename(cast(str, file.file_path)) + temp_dir = get_astrbot_temp_path() + temp_path = os.path.join(temp_dir, file_basename) + await download_file(cast(str, file.file_path), path=temp_path) + path_wav = os.path.join( + temp_dir, + f"{file_basename}.wav", + ) + path_wav = await convert_audio_to_wav(temp_path, path_wav) + message.message = [ - Comp.Record(file=file.file_path, url=file.file_path), + Comp.Record(file=path_wav, url=path_wav), ] elif update.message.photo: @@ -399,7 +445,114 @@ async def convert_message( return message - async def handle_msg(self, message: AstrBotMessage): + async def handle_media_group_message( + self, update: Update, context: ContextTypes.DEFAULT_TYPE + ): + """Handle messages that are part of a media group (album). + + Caches incoming messages and schedules delayed processing to collect all + media items before sending to the pipeline. Uses debounce mechanism with + a hard cap (max_wait) to prevent indefinite delay. + """ + from datetime import datetime, timedelta + + if not update.message: + return + + media_group_id = update.message.media_group_id + if not media_group_id: + return + + # Initialize cache for this media group if needed + if media_group_id not in self.media_group_cache: + self.media_group_cache[media_group_id] = { + "created_at": datetime.now(), + "items": [], + } + logger.debug(f"Create media group cache: {media_group_id}") + + # Add this message to the cache + entry = self.media_group_cache[media_group_id] + entry["items"].append((update, context)) + logger.debug( + f"Add message to media group {media_group_id}, " + f"currently has {len(entry['items'])} items.", + ) + + # Calculate delay: if already waited too long, process immediately; + # otherwise use normal debounce timeout + elapsed = (datetime.now() - entry["created_at"]).total_seconds() + if elapsed >= self.media_group_max_wait: + delay = 0 + logger.debug( + f"Media group {media_group_id} has reached max wait time " + f"({elapsed:.1f}s >= {self.media_group_max_wait}s), processing immediately.", + ) + else: + delay = self.media_group_timeout + logger.debug( + f"Scheduled media group {media_group_id} to be processed in {delay} seconds " + f"(already waited {elapsed:.1f}s)" + ) + + # Schedule/reschedule processing (replace_existing=True handles debounce) + job_id = f"media_group_{media_group_id}" + self.scheduler.add_job( + self.process_media_group, + "date", + run_date=datetime.now() + timedelta(seconds=delay), + args=[media_group_id], + id=job_id, + replace_existing=True, + ) + + async def process_media_group(self, media_group_id: str) -> None: + """Process a complete media group by merging all collected messages. + + Args: + media_group_id: The unique identifier for this media group + """ + if media_group_id not in self.media_group_cache: + logger.warning(f"Media group {media_group_id} not found in cache") + return + + entry = self.media_group_cache.pop(media_group_id) + updates_and_contexts = entry["items"] + if not updates_and_contexts: + logger.warning(f"Media group {media_group_id} is empty") + return + + logger.info( + f"Processing media group {media_group_id}, total {len(updates_and_contexts)} items" + ) + + # Use the first update to create the base message (with reply, caption, etc.) + first_update, first_context = updates_and_contexts[0] + abm = await self.convert_message(first_update, first_context) + + if not abm: + logger.warning( + f"Failed to convert the first message of media group {media_group_id}" + ) + return + + # Add additional media from remaining updates by reusing convert_message + for update, context in updates_and_contexts[1:]: + # Convert the message but skip reply chains (get_reply=False) + extra = await self.convert_message(update, context, get_reply=False) + if not extra: + continue + + # Merge only the message components (keep base session/meta from first) + abm.message.extend(extra.message) + logger.debug( + f"Added {len(extra.message)} components to media group {media_group_id}" + ) + + # Process the merged message + await self.handle_msg(abm) + + async def handle_msg(self, message: AstrBotMessage) -> None: message_event = TelegramPlatformEvent( message_str=message.message_str, message_obj=message, @@ -412,7 +565,7 @@ async def handle_msg(self, message: AstrBotMessage): def get_client(self) -> ExtBot: return self.client - async def terminate(self): + async def terminate(self) -> None: try: if self.scheduler.running: self.scheduler.shutdown() @@ -426,6 +579,6 @@ async def terminate(self): if self.application.updater is not None: await self.application.updater.stop() - logger.info("Telegram 适配器已被关闭") + logger.info("Telegram adapter has been closed.") except Exception as e: - logger.error(f"Telegram 适配器关闭时出错: {e}") + logger.error(f"Error occurred while closing Telegram adapter: {e}") diff --git a/astrbot/core/platform/sources/telegram/tg_event.py b/astrbot/core/platform/sources/telegram/tg_event.py index 5faba6803..ade72f110 100644 --- a/astrbot/core/platform/sources/telegram/tg_event.py +++ b/astrbot/core/platform/sources/telegram/tg_event.py @@ -5,6 +5,8 @@ import telegramify_markdown from telegram import ReactionTypeCustomEmoji, ReactionTypeEmoji +from telegram.constants import ChatAction +from telegram.error import BadRequest from telegram.ext import ExtBot from astrbot import logger @@ -16,6 +18,7 @@ Plain, Record, Reply, + Video, ) from astrbot.api.platform import AstrBotMessage, MessageType, PlatformMetadata @@ -31,6 +34,15 @@ class TelegramPlatformEvent(AstrMessageEvent): "word": re.compile(r"\s"), } + # 消息类型到 chat action 的映射,用于优先级判断 + ACTION_BY_TYPE: dict[type, str] = { + Record: ChatAction.UPLOAD_VOICE, + Video: ChatAction.UPLOAD_VIDEO, + File: ChatAction.UPLOAD_DOCUMENT, + Image: ChatAction.UPLOAD_PHOTO, + Plain: ChatAction.TYPING, + } + def __init__( self, message_str: str, @@ -38,7 +50,7 @@ def __init__( platform_meta: PlatformMetadata, session_id: str, client: ExtBot, - ): + ) -> None: super().__init__(message_str, message_obj, platform_meta, session_id) self.client = client @@ -67,13 +79,149 @@ def _split_message(cls, text: str) -> list[str]: return chunks + @classmethod + async def _send_chat_action( + cls, + client: ExtBot, + chat_id: str, + action: ChatAction | str, + message_thread_id: str | None = None, + ) -> None: + """发送聊天状态动作""" + try: + payload: dict[str, Any] = {"chat_id": chat_id, "action": action} + if message_thread_id: + payload["message_thread_id"] = message_thread_id + await client.send_chat_action(**payload) + except Exception as e: + logger.warning(f"[Telegram] 发送 chat action 失败: {e}") + + @classmethod + def _get_chat_action_for_chain(cls, chain: list[Any]) -> ChatAction | str: + """根据消息链中的组件类型确定合适的 chat action(按优先级)""" + for seg_type, action in cls.ACTION_BY_TYPE.items(): + if any(isinstance(seg, seg_type) for seg in chain): + return action + return ChatAction.TYPING + + @classmethod + async def _send_media_with_action( + cls, + client: ExtBot, + upload_action: ChatAction | str, + send_coro, + *, + user_name: str, + message_thread_id: str | None = None, + **payload: Any, + ) -> None: + """发送媒体时显示 upload action,发送完成后恢复 typing""" + effective_thread_id = message_thread_id or cast( + str | None, payload.get("message_thread_id") + ) + await cls._send_chat_action( + client, user_name, upload_action, effective_thread_id + ) + send_payload = dict(payload) + if effective_thread_id and "message_thread_id" not in send_payload: + send_payload["message_thread_id"] = effective_thread_id + await send_coro(**send_payload) + await cls._send_chat_action( + client, user_name, ChatAction.TYPING, effective_thread_id + ) + + @classmethod + async def _send_voice_with_fallback( + cls, + client: ExtBot, + path: str, + payload: dict[str, Any], + *, + caption: str | None = None, + user_name: str = "", + message_thread_id: str | None = None, + use_media_action: bool = False, + ) -> None: + """Send a voice message, falling back to a document if the user's + privacy settings forbid voice messages (``BadRequest`` with + ``Voice_messages_forbidden``). + + When *use_media_action* is ``True`` the helper wraps the send calls + with ``_send_media_with_action`` (used by the streaming path). + """ + try: + if use_media_action: + media_payload = dict(payload) + if message_thread_id and "message_thread_id" not in media_payload: + media_payload["message_thread_id"] = message_thread_id + await cls._send_media_with_action( + client, + ChatAction.UPLOAD_VOICE, + client.send_voice, + user_name=user_name, + voice=path, + **cast(Any, media_payload), + ) + else: + await client.send_voice(voice=path, **cast(Any, payload)) + except BadRequest as e: + # python-telegram-bot raises BadRequest for Voice_messages_forbidden; + # distinguish the voice-privacy case via the API error message. + if "Voice_messages_forbidden" not in e.message: + raise + logger.warning( + "User privacy settings prevent receiving voice messages, falling back to sending an audio file. " + "To enable voice messages, go to Telegram Settings → Privacy and Security → Voice Messages → set to 'Everyone'." + ) + if use_media_action: + media_payload = dict(payload) + if message_thread_id and "message_thread_id" not in media_payload: + media_payload["message_thread_id"] = message_thread_id + await cls._send_media_with_action( + client, + ChatAction.UPLOAD_DOCUMENT, + client.send_document, + user_name=user_name, + document=path, + caption=caption, + **cast(Any, media_payload), + ) + else: + await client.send_document( + document=path, + caption=caption, + **cast(Any, payload), + ) + + async def _ensure_typing( + self, + user_name: str, + message_thread_id: str | None = None, + ) -> None: + """确保显示 typing 状态""" + await self._send_chat_action( + self.client, user_name, ChatAction.TYPING, message_thread_id + ) + + async def send_typing(self) -> None: + message_thread_id = None + if self.get_message_type() == MessageType.GROUP_MESSAGE: + user_name = self.message_obj.group_id + else: + user_name = self.get_sender_id() + + if "#" in user_name: + user_name, message_thread_id = user_name.split("#") + + await self._ensure_typing(user_name, message_thread_id) + @classmethod async def send_with_client( cls, client: ExtBot, message: MessageChain, user_name: str, - ): + ) -> None: image_path = None has_reply = False @@ -91,6 +239,11 @@ async def send_with_client( if "#" in user_name: # it's a supergroup chat with message_thread_id user_name, message_thread_id = user_name.split("#") + + # 根据消息链确定合适的 chat action 并发送 + action = cls._get_chat_action_for_chain(message.chain) + await cls._send_chat_action(client, user_name, action, message_thread_id) + for i in message.chain: payload = { "chat_id": user_name, @@ -132,16 +285,29 @@ async def send_with_client( ) elif isinstance(i, Record): path = await i.convert_to_file_path() - await client.send_voice(voice=path, **cast(Any, payload)) + await cls._send_voice_with_fallback( + client, + path, + payload, + caption=i.text or None, + use_media_action=False, + ) + elif isinstance(i, Video): + path = await i.convert_to_file_path() + await client.send_video( + video=path, + caption=getattr(i, "text", None) or None, + **cast(Any, payload), + ) - async def send(self, message: MessageChain): + async def send(self, message: MessageChain) -> None: if self.get_message_type() == MessageType.GROUP_MESSAGE: await self.send_with_client(self.client, message, self.message_obj.group_id) else: await self.send_with_client(self.client, message, self.get_sender_id()) await super().send(message) - async def react(self, emoji: str | None, big: bool = False): + async def react(self, emoji: str | None, big: bool = False) -> None: """给原消息添加 Telegram 反应: - 普通 emoji:传入 '👍'、'😂' 等 - 自定义表情:传入其 custom_emoji_id(纯数字字符串) @@ -188,13 +354,19 @@ async def send_streaming(self, generator, use_fallback: bool = False): "chat_id": user_name, } if message_thread_id: - payload["reply_to_message_id"] = message_thread_id + payload["message_thread_id"] = message_thread_id delta = "" current_content = "" message_id = None last_edit_time = 0 # 上次编辑消息的时间 throttle_interval = 0.6 # 编辑消息的间隔时间 (秒) + last_chat_action_time = 0 # 上次发送 chat action 的时间 + chat_action_interval = 0.5 # chat action 的节流间隔 (秒) + + # 发送初始 typing 状态 + await self._ensure_typing(user_name, message_thread_id) + last_chat_action_time = asyncio.get_event_loop().time() async for chain in generator: if isinstance(chain, MessageChain): @@ -219,15 +391,23 @@ async def send_streaming(self, generator, use_fallback: bool = False): delta += i.text elif isinstance(i, Image): image_path = await i.convert_to_file_path() - await self.client.send_photo( - photo=image_path, **cast(Any, payload) + await self._send_media_with_action( + self.client, + ChatAction.UPLOAD_PHOTO, + self.client.send_photo, + user_name=user_name, + photo=image_path, + **cast(Any, payload), ) continue elif isinstance(i, File): path = await i.get_file() name = i.name or os.path.basename(path) - - await self.client.send_document( + await self._send_media_with_action( + self.client, + ChatAction.UPLOAD_DOCUMENT, + self.client.send_document, + user_name=user_name, document=path, filename=name, **cast(Any, payload), @@ -235,7 +415,26 @@ async def send_streaming(self, generator, use_fallback: bool = False): continue elif isinstance(i, Record): path = await i.convert_to_file_path() - await self.client.send_voice(voice=path, **cast(Any, payload)) + await self._send_voice_with_fallback( + self.client, + path, + payload, + caption=i.text or delta or None, + user_name=user_name, + message_thread_id=message_thread_id, + use_media_action=True, + ) + continue + elif isinstance(i, Video): + path = await i.convert_to_file_path() + await self._send_media_with_action( + self.client, + ChatAction.UPLOAD_VIDEO, + self.client.send_video, + user_name=user_name, + video=path, + **cast(Any, payload), + ) continue else: logger.warning(f"不支持的消息类型: {type(i)}") @@ -248,6 +447,11 @@ async def send_streaming(self, generator, use_fallback: bool = False): # 如果距离上次编辑的时间 >= 设定的间隔,等待一段时间 if time_since_last_edit >= throttle_interval: + # 发送 typing 状态(带节流) + current_time = asyncio.get_event_loop().time() + if current_time - last_chat_action_time >= chat_action_interval: + await self._ensure_typing(user_name, message_thread_id) + last_chat_action_time = current_time # 编辑消息 try: await self.client.edit_message_text( @@ -263,6 +467,11 @@ async def send_streaming(self, generator, use_fallback: bool = False): ) # 更新上次编辑的时间 else: # delta 长度一般不会大于 4096,因此这里直接发送 + # 发送 typing 状态(带节流) + current_time = asyncio.get_event_loop().time() + if current_time - last_chat_action_time >= chat_action_interval: + await self._ensure_typing(user_name, message_thread_id) + last_chat_action_time = current_time try: msg = await self.client.send_message( text=delta, **cast(Any, payload) diff --git a/astrbot/core/platform/sources/webchat/message_parts_helper.py b/astrbot/core/platform/sources/webchat/message_parts_helper.py new file mode 100644 index 000000000..43072ec1c --- /dev/null +++ b/astrbot/core/platform/sources/webchat/message_parts_helper.py @@ -0,0 +1,465 @@ +import json +import mimetypes +import shutil +import uuid +from collections.abc import Awaitable, Callable, Sequence +from pathlib import Path +from typing import Any + +from astrbot.core.db.po import Attachment +from astrbot.core.message.components import ( + File, + Image, + Json, + Plain, + Record, + Reply, + Video, +) +from astrbot.core.message.message_event_result import MessageChain + +AttachmentGetter = Callable[[str], Awaitable[Attachment | None]] +AttachmentInserter = Callable[[str, str, str], Awaitable[Attachment | None]] +ReplyHistoryGetter = Callable[ + [Any], + Awaitable[tuple[list[dict], str | None, str | None] | None], +] + +MEDIA_PART_TYPES = {"image", "record", "file", "video"} + + +def strip_message_parts_path_fields(message_parts: list[dict]) -> list[dict]: + return [{k: v for k, v in part.items() if k != "path"} for part in message_parts] + + +def webchat_message_parts_have_content(message_parts: list[dict]) -> bool: + return any( + part.get("type") in ("plain", "image", "record", "file", "video") + and (part.get("text") or part.get("attachment_id") or part.get("filename")) + for part in message_parts + ) + + +async def parse_webchat_message_parts( + message_parts: list, + *, + strict: bool = False, + include_empty_plain: bool = False, + verify_media_path_exists: bool = True, + reply_history_getter: ReplyHistoryGetter | None = None, + current_depth: int = 0, + max_reply_depth: int = 0, + cast_reply_id_to_str: bool = True, +) -> tuple[list, list[str], bool]: + """Parse webchat message parts into components/text parts. + + Returns: + tuple[list, list[str], bool]: + (components, plain_text_parts, has_non_reply_content) + """ + components = [] + text_parts: list[str] = [] + has_content = False + + for part in message_parts: + if not isinstance(part, dict): + if strict: + raise ValueError("message part must be an object") + continue + + part_type = str(part.get("type", "")).strip() + if part_type == "plain": + text = str(part.get("text", "")) + if text or include_empty_plain: + components.append(Plain(text=text)) + text_parts.append(text) + if text: + has_content = True + continue + + if part_type == "reply": + message_id = part.get("message_id") + if message_id is None: + if strict: + raise ValueError("reply part missing message_id") + continue + + reply_chain = [] + reply_message_str = str(part.get("selected_text", "")) + sender_id = None + sender_name = None + + if reply_message_str: + reply_chain = [Plain(text=reply_message_str)] + elif ( + reply_history_getter + and current_depth < max_reply_depth + and message_id is not None + ): + reply_info = await reply_history_getter(message_id) + if reply_info: + reply_parts, sender_id, sender_name = reply_info + ( + reply_chain, + reply_text_parts, + _, + ) = await parse_webchat_message_parts( + reply_parts, + strict=strict, + include_empty_plain=include_empty_plain, + verify_media_path_exists=verify_media_path_exists, + reply_history_getter=reply_history_getter, + current_depth=current_depth + 1, + max_reply_depth=max_reply_depth, + cast_reply_id_to_str=cast_reply_id_to_str, + ) + reply_message_str = "".join(reply_text_parts) + + reply_id = str(message_id) if cast_reply_id_to_str else message_id + components.append( + Reply( + id=reply_id, + message_str=reply_message_str, + chain=reply_chain, + sender_id=sender_id, + sender_nickname=sender_name, + ) + ) + continue + + if part_type not in MEDIA_PART_TYPES: + if strict: + raise ValueError(f"unsupported message part type: {part_type}") + continue + + path = part.get("path") + if not path: + if strict: + raise ValueError(f"{part_type} part missing path") + continue + + file_path = Path(str(path)) + if verify_media_path_exists and not file_path.exists(): + if strict: + raise ValueError(f"file not found: {file_path!s}") + continue + + file_path_str = ( + str(file_path.resolve()) if verify_media_path_exists else str(file_path) + ) + has_content = True + if part_type == "image": + components.append(Image.fromFileSystem(file_path_str)) + elif part_type == "record": + components.append(Record.fromFileSystem(file_path_str)) + elif part_type == "video": + components.append(Video.fromFileSystem(file_path_str)) + else: + filename = str(part.get("filename", "")).strip() or file_path.name + components.append(File(name=filename, file=file_path_str)) + + return components, text_parts, has_content + + +async def build_webchat_message_parts( + message_payload: str | list, + *, + get_attachment_by_id: AttachmentGetter, + strict: bool = False, +) -> list[dict]: + if isinstance(message_payload, str): + text = message_payload.strip() + return [{"type": "plain", "text": text}] if text else [] + + if not isinstance(message_payload, list): + if strict: + raise ValueError("message must be a string or list") + return [] + + message_parts: list[dict] = [] + for part in message_payload: + if not isinstance(part, dict): + if strict: + raise ValueError("message part must be an object") + continue + + part_type = str(part.get("type", "")).strip() + if part_type == "plain": + text = str(part.get("text", "")) + if text: + message_parts.append({"type": "plain", "text": text}) + continue + + if part_type == "reply": + message_id = part.get("message_id") + if message_id is None: + if strict: + raise ValueError("reply part missing message_id") + continue + message_parts.append( + { + "type": "reply", + "message_id": message_id, + "selected_text": str(part.get("selected_text", "")), + } + ) + continue + + if part_type not in MEDIA_PART_TYPES: + if strict: + raise ValueError(f"unsupported message part type: {part_type}") + continue + + attachment_id = part.get("attachment_id") + if not attachment_id: + if strict: + raise ValueError(f"{part_type} part missing attachment_id") + continue + + attachment = await get_attachment_by_id(str(attachment_id)) + if not attachment: + if strict: + raise ValueError(f"attachment not found: {attachment_id}") + continue + + attachment_path = Path(attachment.path) + message_parts.append( + { + "type": attachment.type, + "attachment_id": attachment.attachment_id, + "filename": attachment_path.name, + "path": str(attachment_path), + } + ) + + return message_parts + + +def webchat_message_parts_to_message_chain( + message_parts: list[dict], + *, + strict: bool = False, +) -> MessageChain: + components = [] + has_content = False + + for part in message_parts: + if not isinstance(part, dict): + if strict: + raise ValueError("message part must be an object") + continue + + part_type = str(part.get("type", "")).strip() + if part_type == "plain": + text = str(part.get("text", "")) + if text: + components.append(Plain(text=text)) + has_content = True + continue + + if part_type == "reply": + message_id = part.get("message_id") + if message_id is None: + if strict: + raise ValueError("reply part missing message_id") + continue + components.append( + Reply( + id=str(message_id), + message_str=str(part.get("selected_text", "")), + chain=[], + ) + ) + continue + + if part_type not in MEDIA_PART_TYPES: + if strict: + raise ValueError(f"unsupported message part type: {part_type}") + continue + + path = part.get("path") + if not path: + if strict: + raise ValueError(f"{part_type} part missing path") + continue + + file_path = Path(str(path)) + if not file_path.exists(): + if strict: + raise ValueError(f"file not found: {file_path!s}") + continue + + file_path_str = str(file_path.resolve()) + has_content = True + if part_type == "image": + components.append(Image.fromFileSystem(file_path_str)) + elif part_type == "record": + components.append(Record.fromFileSystem(file_path_str)) + elif part_type == "video": + components.append(Video.fromFileSystem(file_path_str)) + else: + filename = str(part.get("filename", "")).strip() or file_path.name + components.append(File(name=filename, file=file_path_str)) + + if strict and (not components or not has_content): + raise ValueError("Message content is empty (reply only is not allowed)") + + return MessageChain(chain=components) + + +async def build_message_chain_from_payload( + message_payload: str | list, + *, + get_attachment_by_id: AttachmentGetter, + strict: bool = True, +) -> MessageChain: + message_parts = await build_webchat_message_parts( + message_payload, + get_attachment_by_id=get_attachment_by_id, + strict=strict, + ) + components, _, has_content = await parse_webchat_message_parts( + message_parts, + strict=strict, + ) + if strict and (not components or not has_content): + raise ValueError("Message content is empty (reply only is not allowed)") + return MessageChain(chain=components) + + +async def create_attachment_part_from_existing_file( + filename: str, + *, + attach_type: str, + insert_attachment: AttachmentInserter, + attachments_dir: str | Path, + fallback_dirs: Sequence[str | Path] = (), +) -> dict | None: + basename = Path(filename).name + candidate_paths = [Path(attachments_dir) / basename] + candidate_paths.extend(Path(p) / basename for p in fallback_dirs) + + file_path = next((path for path in candidate_paths if path.exists()), None) + if not file_path: + return None + + mime_type, _ = mimetypes.guess_type(str(file_path)) + attachment = await insert_attachment( + str(file_path), + attach_type, + mime_type or "application/octet-stream", + ) + if not attachment: + return None + + return { + "type": attach_type, + "attachment_id": attachment.attachment_id, + "filename": file_path.name, + } + + +async def message_chain_to_storage_message_parts( + message_chain: MessageChain, + *, + insert_attachment: AttachmentInserter, + attachments_dir: str | Path, +) -> list[dict]: + target_dir = Path(attachments_dir) + target_dir.mkdir(parents=True, exist_ok=True) + + parts: list[dict] = [] + for comp in message_chain.chain: + if isinstance(comp, Plain): + if comp.text: + parts.append({"type": "plain", "text": comp.text}) + continue + + if isinstance(comp, Json): + parts.append( + {"type": "plain", "text": json.dumps(comp.data, ensure_ascii=False)} + ) + continue + + if isinstance(comp, Image): + file_path = await comp.convert_to_file_path() + attachment_part = await _copy_file_to_attachment_part( + file_path=file_path, + attach_type="image", + insert_attachment=insert_attachment, + attachments_dir=target_dir, + ) + if attachment_part: + parts.append(attachment_part) + continue + + if isinstance(comp, Record): + file_path = await comp.convert_to_file_path() + attachment_part = await _copy_file_to_attachment_part( + file_path=file_path, + attach_type="record", + insert_attachment=insert_attachment, + attachments_dir=target_dir, + ) + if attachment_part: + parts.append(attachment_part) + continue + + if isinstance(comp, Video): + file_path = await comp.convert_to_file_path() + attachment_part = await _copy_file_to_attachment_part( + file_path=file_path, + attach_type="video", + insert_attachment=insert_attachment, + attachments_dir=target_dir, + ) + if attachment_part: + parts.append(attachment_part) + continue + + if isinstance(comp, File): + file_path = await comp.get_file() + attachment_part = await _copy_file_to_attachment_part( + file_path=file_path, + attach_type="file", + insert_attachment=insert_attachment, + attachments_dir=target_dir, + display_name=comp.name, + ) + if attachment_part: + parts.append(attachment_part) + continue + + return parts + + +async def _copy_file_to_attachment_part( + *, + file_path: str, + attach_type: str, + insert_attachment: AttachmentInserter, + attachments_dir: Path, + display_name: str | None = None, +) -> dict | None: + src_path = Path(file_path) + if not src_path.exists() or not src_path.is_file(): + return None + + suffix = src_path.suffix + target_path = attachments_dir / f"{uuid.uuid4().hex}{suffix}" + shutil.copy2(src_path, target_path) + + mime_type, _ = mimetypes.guess_type(target_path.name) + attachment = await insert_attachment( + str(target_path), + attach_type, + mime_type or "application/octet-stream", + ) + if not attachment: + return None + + return { + "type": attach_type, + "attachment_id": attachment.attachment_id, + "filename": display_name or src_path.name, + } diff --git a/astrbot/core/platform/sources/webchat/webchat_adapter.py b/astrbot/core/platform/sources/webchat/webchat_adapter.py index 316c95d81..54718fefb 100644 --- a/astrbot/core/platform/sources/webchat/webchat_adapter.py +++ b/astrbot/core/platform/sources/webchat/webchat_adapter.py @@ -3,12 +3,12 @@ import time import uuid from collections.abc import Callable, Coroutine +from pathlib import Path from typing import Any from astrbot import logger from astrbot.core import db_helper from astrbot.core.db.po import PlatformMessageHistory -from astrbot.core.message.components import File, Image, Plain, Record, Reply, Video from astrbot.core.message.message_event_result import MessageChain from astrbot.core.platform import ( AstrBotMessage, @@ -21,51 +21,41 @@ from astrbot.core.utils.astrbot_path import get_astrbot_data_path from ...register import register_platform_adapter +from .message_parts_helper import ( + message_chain_to_storage_message_parts, + parse_webchat_message_parts, +) from .webchat_event import WebChatMessageEvent from .webchat_queue_mgr import WebChatQueueMgr, webchat_queue_mgr +def _extract_conversation_id(session_id: str) -> str: + """Extract raw webchat conversation id from event/session id.""" + if session_id.startswith("webchat!"): + parts = session_id.split("!", 2) + if len(parts) == 3: + return parts[2] + return session_id + + class QueueListener: - def __init__(self, webchat_queue_mgr: WebChatQueueMgr, callback: Callable) -> None: + def __init__( + self, + webchat_queue_mgr: WebChatQueueMgr, + callback: Callable, + stop_event: asyncio.Event, + ) -> None: self.webchat_queue_mgr = webchat_queue_mgr self.callback = callback - self.running_tasks = set() - - async def listen_to_queue(self, conversation_id: str): - """Listen to a specific conversation queue""" - queue = self.webchat_queue_mgr.get_or_create_queue(conversation_id) - while True: - try: - data = await queue.get() - await self.callback(data) - except Exception as e: - logger.error( - f"Error processing message from conversation {conversation_id}: {e}", - ) - break - - async def run(self): - """Monitor for new conversation queues and start listeners""" - monitored_conversations = set() + self.stop_event = stop_event - while True: - # Check for new conversations - current_conversations = set(self.webchat_queue_mgr.queues.keys()) - new_conversations = current_conversations - monitored_conversations - - # Start listeners for new conversations - for conversation_id in new_conversations: - task = asyncio.create_task(self.listen_to_queue(conversation_id)) - self.running_tasks.add(task) - task.add_done_callback(self.running_tasks.discard) - monitored_conversations.add(conversation_id) - logger.debug(f"Started listener for conversation: {conversation_id}") - - # Clean up monitored conversations that no longer exist - removed_conversations = monitored_conversations - current_conversations - monitored_conversations -= removed_conversations - - await asyncio.sleep(1) # Check for new conversations every second + async def run(self) -> None: + """Register callback and keep adapter task alive.""" + self.webchat_queue_mgr.set_listener(self.callback) + try: + await self.stop_event.wait() + finally: + await self.webchat_queue_mgr.clear_listener() @register_platform_adapter("webchat", "webchat") @@ -80,24 +70,85 @@ def __init__( self.settings = platform_settings self.imgs_dir = os.path.join(get_astrbot_data_path(), "webchat", "imgs") + self.attachments_dir = Path(get_astrbot_data_path()) / "attachments" os.makedirs(self.imgs_dir, exist_ok=True) + self.attachments_dir.mkdir(parents=True, exist_ok=True) self.metadata = PlatformMetadata( name="webchat", description="webchat", id="webchat", - support_proactive_message=False, + support_proactive_message=True, ) + self._shutdown_event = asyncio.Event() + self._webchat_queue_mgr = webchat_queue_mgr async def send_by_session( self, session: MessageSesion, message_chain: MessageChain, - ): - message_id = f"active_{str(uuid.uuid4())}" - await WebChatMessageEvent._send(message_id, message_chain, session.session_id) + ) -> None: + conversation_id = _extract_conversation_id(session.session_id) + active_request_ids = self._webchat_queue_mgr.list_back_request_ids( + conversation_id + ) + subscription_request_ids = [ + req_id for req_id in active_request_ids if req_id.startswith("ws_sub_") + ] + target_request_ids = subscription_request_ids or active_request_ids + + if target_request_ids: + for request_id in target_request_ids: + await WebChatMessageEvent._send( + request_id, + message_chain, + session.session_id, + ) + else: + message_id = f"active_{uuid.uuid4()!s}" + await WebChatMessageEvent._send( + message_id, + message_chain, + session.session_id, + ) + + should_persist = ( + bool(subscription_request_ids) + or not active_request_ids + or all(req_id.startswith("active_") for req_id in active_request_ids) + ) + if should_persist: + try: + await self._save_proactive_message(conversation_id, message_chain) + except Exception as e: + logger.error( + f"[WebChatAdapter] Failed to save proactive message: {e}", + exc_info=True, + ) + await super().send_by_session(session, message_chain) + async def _save_proactive_message( + self, + conversation_id: str, + message_chain: MessageChain, + ) -> None: + message_parts = await message_chain_to_storage_message_parts( + message_chain, + insert_attachment=db_helper.insert_attachment, + attachments_dir=self.attachments_dir, + ) + if not message_parts: + return + + await db_helper.insert_platform_message_history( + platform_id="webchat", + user_id=conversation_id, + content={"type": "bot", "message": message_parts}, + sender_id="bot", + sender_name="bot", + ) + async def _get_message_history( self, message_id: int ) -> PlatformMessageHistory | None: @@ -119,72 +170,30 @@ async def _parse_message_parts( Returns: tuple[list, list[str]]: (消息组件列表, 纯文本列表) """ - components = [] - text_parts = [] - - for part in message_parts: - part_type = part.get("type") - if part_type == "plain": - text = part.get("text", "") - components.append(Plain(text=text)) - text_parts.append(text) - elif part_type == "reply": - message_id = part.get("message_id") - reply_chain = [] - reply_message_str = part.get("selected_text", "") - sender_id = None - sender_name = None - - if reply_message_str: - reply_chain = [Plain(text=reply_message_str)] - - # recursively get the content of the referenced message, if selected_text is empty - if not reply_message_str and depth < max_depth and message_id: - history = await self._get_message_history(message_id) - if history and history.content: - reply_parts = history.content.get("message", []) - if isinstance(reply_parts, list): - ( - reply_chain, - reply_text_parts, - ) = await self._parse_message_parts( - reply_parts, - depth=depth + 1, - max_depth=max_depth, - ) - reply_message_str = "".join(reply_text_parts) - sender_id = history.sender_id - sender_name = history.sender_name - - components.append( - Reply( - id=message_id, - chain=reply_chain, - message_str=reply_message_str, - sender_id=sender_id, - sender_nickname=sender_name, - ) - ) - elif part_type == "image": - path = part.get("path") - if path: - components.append(Image.fromFileSystem(path)) - elif part_type == "record": - path = part.get("path") - if path: - components.append(Record.fromFileSystem(path)) - elif part_type == "file": - path = part.get("path") - if path: - filename = part.get("filename") or ( - os.path.basename(path) if path else "file" - ) - components.append(File(name=filename, file=path)) - elif part_type == "video": - path = part.get("path") - if path: - components.append(Video.fromFileSystem(path)) + async def get_reply_parts( + message_id: Any, + ) -> tuple[list[dict], str | None, str | None] | None: + history = await self._get_message_history(message_id) + if not history or not history.content: + return None + + reply_parts = history.content.get("message", []) + if not isinstance(reply_parts, list): + return None + + return reply_parts, history.sender_id, history.sender_name + + components, text_parts, _ = await parse_webchat_message_parts( + message_parts, + strict=False, + include_empty_plain=True, + verify_media_path_exists=False, + reply_history_getter=get_reply_parts, + current_depth=depth, + max_reply_depth=max_depth, + cast_reply_id_to_str=False, + ) return components, text_parts async def convert_message(self, data: tuple) -> AstrBotMessage: @@ -212,17 +221,17 @@ async def convert_message(self, data: tuple) -> AstrBotMessage: return abm def run(self) -> Coroutine[Any, Any, None]: - async def callback(data: tuple): + async def callback(data: tuple) -> None: abm = await self.convert_message(data) await self.handle_msg(abm) - bot = QueueListener(webchat_queue_mgr, callback) + bot = QueueListener(self._webchat_queue_mgr, callback, self._shutdown_event) return bot.run() def meta(self) -> PlatformMetadata: return self.metadata - async def handle_msg(self, message: AstrBotMessage): + async def handle_msg(self, message: AstrBotMessage) -> None: message_event = WebChatMessageEvent( message_str=message.message_str, message_obj=message, @@ -240,6 +249,5 @@ async def handle_msg(self, message: AstrBotMessage): self.commit_event(message_event) - async def terminate(self): - # Do nothing - pass + async def terminate(self) -> None: + self._shutdown_event.set() diff --git a/astrbot/core/platform/sources/webchat/webchat_event.py b/astrbot/core/platform/sources/webchat/webchat_event.py index 6e7201c6d..b7da864aa 100644 --- a/astrbot/core/platform/sources/webchat/webchat_event.py +++ b/astrbot/core/platform/sources/webchat/webchat_event.py @@ -11,13 +11,22 @@ from .webchat_queue_mgr import webchat_queue_mgr -imgs_dir = os.path.join(get_astrbot_data_path(), "webchat", "imgs") +attachments_dir = os.path.join(get_astrbot_data_path(), "attachments") + + +def _extract_conversation_id(session_id: str) -> str: + """Extract raw webchat conversation id from event/session id.""" + if session_id.startswith("webchat!"): + parts = session_id.split("!", 2) + if len(parts) == 3: + return parts[2] + return session_id class WebChatMessageEvent(AstrMessageEvent): - def __init__(self, message_str, message_obj, platform_meta, session_id): + def __init__(self, message_str, message_obj, platform_meta, session_id) -> None: super().__init__(message_str, message_obj, platform_meta, session_id) - os.makedirs(imgs_dir, exist_ok=True) + os.makedirs(attachments_dir, exist_ok=True) @staticmethod async def _send( @@ -26,8 +35,12 @@ async def _send( session_id: str, streaming: bool = False, ) -> str | None: - cid = session_id.split("!")[-1] - web_chat_back_queue = webchat_queue_mgr.get_or_create_back_queue(cid) + request_id = str(message_id) + conversation_id = _extract_conversation_id(session_id) + web_chat_back_queue = webchat_queue_mgr.get_or_create_back_queue( + request_id, + conversation_id, + ) if not message: await web_chat_back_queue.put( { @@ -65,7 +78,7 @@ async def _send( elif isinstance(comp, Image): # save image to local filename = f"{str(uuid.uuid4())}.jpg" - path = os.path.join(imgs_dir, filename) + path = os.path.join(attachments_dir, filename) image_base64 = await comp.convert_to_base64() with open(path, "wb") as f: f.write(base64.b64decode(image_base64)) @@ -81,7 +94,7 @@ async def _send( elif isinstance(comp, Record): # save record to local filename = f"{str(uuid.uuid4())}.wav" - path = os.path.join(imgs_dir, filename) + path = os.path.join(attachments_dir, filename) record_base64 = await comp.convert_to_base64() with open(path, "wb") as f: f.write(base64.b64decode(record_base64)) @@ -100,7 +113,7 @@ async def _send( original_name = comp.name or os.path.basename(file_path) ext = os.path.splitext(original_name)[1] or "" filename = f"{uuid.uuid4()!s}{ext}" - dest_path = os.path.join(imgs_dir, filename) + dest_path = os.path.join(attachments_dir, filename) shutil.copy2(file_path, dest_path) data = f"[FILE]{filename}" await web_chat_back_queue.put( @@ -116,17 +129,21 @@ async def _send( return data - async def send(self, message: MessageChain | None): + async def send(self, message: MessageChain | None) -> None: message_id = self.message_obj.message_id await WebChatMessageEvent._send(message_id, message, session_id=self.session_id) await super().send(MessageChain([])) - async def send_streaming(self, generator, use_fallback: bool = False): + async def send_streaming(self, generator, use_fallback: bool = False) -> None: final_data = "" reasoning_content = "" - cid = self.session_id.split("!")[-1] - web_chat_back_queue = webchat_queue_mgr.get_or_create_back_queue(cid) message_id = self.message_obj.message_id + request_id = str(message_id) + conversation_id = _extract_conversation_id(self.session_id) + web_chat_back_queue = webchat_queue_mgr.get_or_create_back_queue( + request_id, + conversation_id, + ) async for chain in generator: # 处理音频流(Live Mode) if chain.type == "audio_chunk": diff --git a/astrbot/core/platform/sources/webchat/webchat_queue_mgr.py b/astrbot/core/platform/sources/webchat/webchat_queue_mgr.py index 6c365cb3a..f3ade1589 100644 --- a/astrbot/core/platform/sources/webchat/webchat_queue_mgr.py +++ b/astrbot/core/platform/sources/webchat/webchat_queue_mgr.py @@ -1,35 +1,164 @@ import asyncio +from collections.abc import Awaitable, Callable + +from astrbot import logger class WebChatQueueMgr: - def __init__(self) -> None: - self.queues = {} + def __init__(self, queue_maxsize: int = 128, back_queue_maxsize: int = 512) -> None: + self.queues: dict[str, asyncio.Queue] = {} """Conversation ID to asyncio.Queue mapping""" - self.back_queues = {} - """Conversation ID to asyncio.Queue mapping for responses""" + self.back_queues: dict[str, asyncio.Queue] = {} + """Request ID to asyncio.Queue mapping for responses""" + self._conversation_back_requests: dict[str, set[str]] = {} + self._request_conversation: dict[str, str] = {} + self._queue_close_events: dict[str, asyncio.Event] = {} + self._listener_tasks: dict[str, asyncio.Task] = {} + self._listener_callback: Callable[[tuple], Awaitable[None]] | None = None + self.queue_maxsize = queue_maxsize + self.back_queue_maxsize = back_queue_maxsize def get_or_create_queue(self, conversation_id: str) -> asyncio.Queue: """Get or create a queue for the given conversation ID""" if conversation_id not in self.queues: - self.queues[conversation_id] = asyncio.Queue() + self.queues[conversation_id] = asyncio.Queue(maxsize=self.queue_maxsize) + self._queue_close_events[conversation_id] = asyncio.Event() + self._start_listener_if_needed(conversation_id) return self.queues[conversation_id] - def get_or_create_back_queue(self, conversation_id: str) -> asyncio.Queue: - """Get or create a back queue for the given conversation ID""" - if conversation_id not in self.back_queues: - self.back_queues[conversation_id] = asyncio.Queue() - return self.back_queues[conversation_id] + def get_or_create_back_queue( + self, + request_id: str, + conversation_id: str | None = None, + ) -> asyncio.Queue: + """Get or create a back queue for the given request ID""" + if request_id not in self.back_queues: + self.back_queues[request_id] = asyncio.Queue( + maxsize=self.back_queue_maxsize + ) + if conversation_id: + self._request_conversation[request_id] = conversation_id + if conversation_id not in self._conversation_back_requests: + self._conversation_back_requests[conversation_id] = set() + self._conversation_back_requests[conversation_id].add(request_id) + return self.back_queues[request_id] + + def remove_back_queue(self, request_id: str): + """Remove back queue for the given request ID""" + self.back_queues.pop(request_id, None) + conversation_id = self._request_conversation.pop(request_id, None) + if conversation_id: + request_ids = self._conversation_back_requests.get(conversation_id) + if request_ids is not None: + request_ids.discard(request_id) + if not request_ids: + self._conversation_back_requests.pop(conversation_id, None) - def remove_queues(self, conversation_id: str): + def remove_queues(self, conversation_id: str) -> None: """Remove queues for the given conversation ID""" - if conversation_id in self.queues: - del self.queues[conversation_id] - if conversation_id in self.back_queues: - del self.back_queues[conversation_id] + for request_id in list( + self._conversation_back_requests.get(conversation_id, set()) + ): + self.remove_back_queue(request_id) + self._conversation_back_requests.pop(conversation_id, None) + self.remove_queue(conversation_id) + + def remove_queue(self, conversation_id: str): + """Remove input queue and listener for the given conversation ID""" + self.queues.pop(conversation_id, None) + + close_event = self._queue_close_events.pop(conversation_id, None) + if close_event is not None: + close_event.set() + + task = self._listener_tasks.pop(conversation_id, None) + if task is not None: + task.cancel() + + def list_back_request_ids(self, conversation_id: str) -> list[str]: + """List active back-queue request IDs for a conversation.""" + return list(self._conversation_back_requests.get(conversation_id, set())) def has_queue(self, conversation_id: str) -> bool: """Check if a queue exists for the given conversation ID""" return conversation_id in self.queues + def set_listener( + self, + callback: Callable[[tuple], Awaitable[None]], + ): + self._listener_callback = callback + for conversation_id in list(self.queues.keys()): + self._start_listener_if_needed(conversation_id) + + async def clear_listener(self) -> None: + self._listener_callback = None + for close_event in list(self._queue_close_events.values()): + close_event.set() + self._queue_close_events.clear() + + listener_tasks = list(self._listener_tasks.values()) + for task in listener_tasks: + task.cancel() + if listener_tasks: + await asyncio.gather(*listener_tasks, return_exceptions=True) + self._listener_tasks.clear() + + def _start_listener_if_needed(self, conversation_id: str): + if self._listener_callback is None: + return + if conversation_id in self._listener_tasks: + task = self._listener_tasks[conversation_id] + if not task.done(): + return + queue = self.queues.get(conversation_id) + close_event = self._queue_close_events.get(conversation_id) + if queue is None or close_event is None: + return + task = asyncio.create_task( + self._listen_to_queue(conversation_id, queue, close_event), + name=f"webchat_listener_{conversation_id}", + ) + self._listener_tasks[conversation_id] = task + task.add_done_callback( + lambda _: self._listener_tasks.pop(conversation_id, None) + ) + logger.debug(f"Started listener for conversation: {conversation_id}") + + async def _listen_to_queue( + self, + conversation_id: str, + queue: asyncio.Queue, + close_event: asyncio.Event, + ): + while True: + get_task = asyncio.create_task(queue.get()) + close_task = asyncio.create_task(close_event.wait()) + try: + done, pending = await asyncio.wait( + {get_task, close_task}, + return_when=asyncio.FIRST_COMPLETED, + ) + for task in pending: + task.cancel() + if close_task in done: + break + data = get_task.result() + if self._listener_callback is None: + continue + try: + await self._listener_callback(data) + except Exception as e: + logger.error( + f"Error processing message from conversation {conversation_id}: {e}" + ) + except asyncio.CancelledError: + break + finally: + if not get_task.done(): + get_task.cancel() + if not close_task.done(): + close_task.cancel() + webchat_queue_mgr = WebChatQueueMgr() diff --git a/astrbot/core/platform/sources/wecom/wecom_adapter.py b/astrbot/core/platform/sources/wecom/wecom_adapter.py index d3ce7243a..6647db89f 100644 --- a/astrbot/core/platform/sources/wecom/wecom_adapter.py +++ b/astrbot/core/platform/sources/wecom/wecom_adapter.py @@ -25,7 +25,8 @@ ) from astrbot.core import logger from astrbot.core.platform.astr_message_event import MessageSesion -from astrbot.core.utils.astrbot_path import get_astrbot_data_path +from astrbot.core.utils.astrbot_path import get_astrbot_temp_path +from astrbot.core.utils.media_utils import convert_audio_to_wav from astrbot.core.utils.webhook_utils import log_webhook_info from .wecom_event import WecomPlatformEvent @@ -39,10 +40,10 @@ class WecomServer: - def __init__(self, event_queue: asyncio.Queue, config: dict): + def __init__(self, event_queue: asyncio.Queue, config: dict) -> None: self.server = quart.Quart(__name__) self.port = int(cast(str, config.get("port"))) - self.callback_server_host = config.get("callback_server_host", "::") + self.callback_server_host = config.get("callback_server_host", "0.0.0.0") self.server.add_url_rule( "/callback/command", view_func=self.verify, @@ -123,7 +124,7 @@ async def handle_callback(self, request) -> str: return "success" - async def start_polling(self): + async def start_polling(self) -> None: logger.info( f"将在 {self.callback_server_host}:{self.port} 端口启动 企业微信 适配器。", ) @@ -133,7 +134,7 @@ async def start_polling(self): shutdown_trigger=self.shutdown_trigger, ) - async def shutdown_trigger(self): + async def shutdown_trigger(self) -> None: await self.shutdown_event.wait() @@ -165,6 +166,7 @@ def __init__( self.api_base_url += "/" self.server = WecomServer(self._event_queue, self.config) + self.agent_id: str | None = None self.client = WeChatClient( self.config["corpid"].strip(), @@ -182,7 +184,7 @@ def __init__( self.client.__setattr__("API_BASE_URL", self.api_base_url) - async def callback(msg: BaseMessage): + async def callback(msg: BaseMessage) -> None: if msg.type == "unknown" and msg._data["Event"] == "kf_msg_or_event": def get_latest_msg_item() -> dict | None: @@ -214,7 +216,37 @@ async def send_by_session( self, session: MessageSesion, message_chain: MessageChain, - ): + ) -> None: + # 企业微信客服不支持主动发送 + if hasattr(self.client, "kf_message"): + logger.warning("企业微信客服模式不支持 send_by_session 主动发送。") + await super().send_by_session(session, message_chain) + return + if not self.agent_id: + logger.warning( + f"send_by_session 失败:无法为会话 {session.session_id} 推断 agent_id。", + ) + await super().send_by_session(session, message_chain) + return + + message_obj = AstrBotMessage() + message_obj.self_id = self.agent_id + message_obj.session_id = session.session_id + message_obj.type = session.message_type + message_obj.sender = MessageMember(session.session_id, session.session_id) + message_obj.message = [] + message_obj.message_str = "" + message_obj.message_id = uuid.uuid4().hex + message_obj.raw_message = {"_proactive_send": True} + + event = WecomPlatformEvent( + message_str=message_obj.message_str, + message_obj=message_obj, + platform_meta=self.meta(), + session_id=message_obj.session_id, + client=self.client, + ) + await event.send(message_chain) await super().send_by_session(session, message_chain) @override @@ -228,7 +260,7 @@ def meta(self) -> PlatformMetadata: ) @override - async def run(self): + async def run(self) -> None: loop = asyncio.get_event_loop() if self.kf_name: try: @@ -312,17 +344,14 @@ async def convert_message(self, msg: BaseMessage) -> AstrBotMessage | None: self.client.media.download, msg.media_id, ) - temp_dir = os.path.join(get_astrbot_data_path(), "temp") + temp_dir = get_astrbot_temp_path() path = os.path.join(temp_dir, f"wecom_{msg.media_id}.amr") with open(path, "wb") as f: f.write(resp.content) try: - from pydub import AudioSegment - path_wav = os.path.join(temp_dir, f"wecom_{msg.media_id}.wav") - audio = AudioSegment.from_file(path) - audio.export(path_wav, format="wav") + path_wav = await convert_audio_to_wav(path, path_wav) except Exception as e: logger.error(f"转换音频失败: {e}。如果没有安装 ffmpeg 请先安装。") path_wav = path @@ -344,6 +373,7 @@ async def convert_message(self, msg: BaseMessage) -> AstrBotMessage | None: logger.warning(f"暂未实现的事件: {msg.type}") return + self.agent_id = abm.self_id logger.info(f"abm: {abm}") await self.handle_msg(abm) @@ -370,29 +400,27 @@ async def convert_wechat_kf_message(self, msg: dict) -> AstrBotMessage | None: self.client.media.download, media_id, ) - path = f"data/temp/wechat_kf_{media_id}.jpg" + temp_dir = get_astrbot_temp_path() + path = os.path.join(temp_dir, f"weixinkefu_{media_id}.jpg") with open(path, "wb") as f: f.write(resp.content) abm.message = [Image(file=path, url=path)] elif msgtype == "voice": media_id = msg.get("voice", {}).get("media_id", "") - resp = await asyncio.get_event_loop().run_in_executor( + resp: Response = await asyncio.get_event_loop().run_in_executor( None, self.client.media.download, media_id, ) - temp_dir = os.path.join(get_astrbot_data_path(), "temp") + temp_dir = get_astrbot_temp_path() path = os.path.join(temp_dir, f"weixinkefu_{media_id}.amr") with open(path, "wb") as f: f.write(resp.content) try: - from pydub import AudioSegment - path_wav = os.path.join(temp_dir, f"weixinkefu_{media_id}.wav") - audio = AudioSegment.from_file(path) - audio.export(path_wav, format="wav") + path_wav = await convert_audio_to_wav(path, path_wav) except Exception as e: logger.error(f"转换音频失败: {e}。如果没有安装 ffmpeg 请先安装。") path_wav = path @@ -404,7 +432,7 @@ async def convert_wechat_kf_message(self, msg: dict) -> AstrBotMessage | None: return await self.handle_msg(abm) - async def handle_msg(self, message: AstrBotMessage): + async def handle_msg(self, message: AstrBotMessage) -> None: message_event = WecomPlatformEvent( message_str=message.message_str, message_obj=message, @@ -417,7 +445,7 @@ async def handle_msg(self, message: AstrBotMessage): def get_client(self) -> WeChatClient: return self.client - async def terminate(self): + async def terminate(self) -> None: self.server.shutdown_event.set() try: await self.server.server.shutdown() diff --git a/astrbot/core/platform/sources/wecom/wecom_event.py b/astrbot/core/platform/sources/wecom/wecom_event.py index 0b5dae272..7aee26e47 100644 --- a/astrbot/core/platform/sources/wecom/wecom_event.py +++ b/astrbot/core/platform/sources/wecom/wecom_event.py @@ -1,24 +1,16 @@ import asyncio import os -import uuid from wechatpy.enterprise import WeChatClient from astrbot.api import logger from astrbot.api.event import AstrMessageEvent, MessageChain -from astrbot.api.message_components import Image, Plain, Record +from astrbot.api.message_components import File, Image, Plain, Record, Video from astrbot.api.platform import AstrBotMessage, PlatformMetadata -from astrbot.core.utils.astrbot_path import get_astrbot_data_path +from astrbot.core.utils.media_utils import convert_audio_to_amr from .wecom_kf_message import WeChatKFMessage -try: - import pydub -except Exception: - logger.warning( - "检测到 pydub 库未安装,企业微信将无法语音收发。如需使用语音,请前往管理面板 -> 平台日志 -> 安装 Pip 库安装 pydub。", - ) - class WecomPlatformEvent(AstrMessageEvent): def __init__( @@ -28,7 +20,7 @@ def __init__( platform_meta: PlatformMetadata, session_id: str, client: WeChatClient, - ): + ) -> None: super().__init__(message_str, message_obj, platform_meta, session_id) self.client = client @@ -37,7 +29,7 @@ async def send_with_client( client: WeChatClient, message: MessageChain, user_name: str, - ): + ) -> None: pass async def split_plain(self, plain: str) -> list[str]: @@ -86,7 +78,7 @@ async def split_plain(self, plain: str) -> list[str]: return result - async def send(self, message: MessageChain): + async def send(self, message: MessageChain) -> None: message_obj = self.message_obj is_wechat_kf = hasattr(self.client, "kf_message") @@ -125,25 +117,66 @@ async def send(self, message: MessageChain): ) elif isinstance(comp, Record): record_path = await comp.convert_to_file_path() - # 转成amr - temp_dir = os.path.join(get_astrbot_data_path(), "temp") - record_path_amr = os.path.join(temp_dir, f"{uuid.uuid4()}.amr") - pydub.AudioSegment.from_wav(record_path).export( - record_path_amr, - format="amr", - ) - - with open(record_path_amr, "rb") as f: + record_path_amr = await convert_audio_to_amr(record_path) + + try: + with open(record_path_amr, "rb") as f: + try: + response = self.client.media.upload("voice", f) + except Exception as e: + logger.error(f"微信客服上传语音失败: {e}") + await self.send( + MessageChain().message( + f"微信客服上传语音失败: {e}" + ), + ) + return + logger.info(f"微信客服上传语音返回: {response}") + kf_message_api.send_voice( + user_id, + self.get_self_id(), + response["media_id"], + ) + finally: + if record_path_amr != record_path and os.path.exists( + record_path_amr, + ): + try: + os.remove(record_path_amr) + except OSError as e: + logger.warning(f"删除临时音频文件失败: {e}") + elif isinstance(comp, File): + file_path = await comp.get_file() + + with open(file_path, "rb") as f: + try: + response = self.client.media.upload("file", f) + except Exception as e: + logger.error(f"微信客服上传文件失败: {e}") + await self.send( + MessageChain().message(f"微信客服上传文件失败: {e}"), + ) + return + logger.debug(f"微信客服上传文件返回: {response}") + kf_message_api.send_file( + user_id, + self.get_self_id(), + response["media_id"], + ) + elif isinstance(comp, Video): + video_path = await comp.convert_to_file_path() + + with open(video_path, "rb") as f: try: - response = self.client.media.upload("voice", f) + response = self.client.media.upload("video", f) except Exception as e: - logger.error(f"微信客服上传语音失败: {e}") + logger.error(f"微信客服上传视频失败: {e}") await self.send( - MessageChain().message(f"微信客服上传语音失败: {e}"), + MessageChain().message(f"微信客服上传视频失败: {e}"), ) return - logger.info(f"微信客服上传语音返回: {response}") - kf_message_api.send_voice( + logger.debug(f"微信客服上传视频返回: {response}") + kf_message_api.send_video( user_id, self.get_self_id(), response["media_id"], @@ -183,25 +216,66 @@ async def send(self, message: MessageChain): ) elif isinstance(comp, Record): record_path = await comp.convert_to_file_path() - # 转成amr - temp_dir = os.path.join(get_astrbot_data_path(), "temp") - record_path_amr = os.path.join(temp_dir, f"{uuid.uuid4()}.amr") - pydub.AudioSegment.from_wav(record_path).export( - record_path_amr, - format="amr", - ) - - with open(record_path_amr, "rb") as f: + record_path_amr = await convert_audio_to_amr(record_path) + + try: + with open(record_path_amr, "rb") as f: + try: + response = self.client.media.upload("voice", f) + except Exception as e: + logger.error(f"企业微信上传语音失败: {e}") + await self.send( + MessageChain().message( + f"企业微信上传语音失败: {e}" + ), + ) + return + logger.info(f"企业微信上传语音返回: {response}") + self.client.message.send_voice( + message_obj.self_id, + message_obj.session_id, + response["media_id"], + ) + finally: + if record_path_amr != record_path and os.path.exists( + record_path_amr, + ): + try: + os.remove(record_path_amr) + except OSError as e: + logger.warning(f"删除临时音频文件失败: {e}") + elif isinstance(comp, File): + file_path = await comp.get_file() + + with open(file_path, "rb") as f: + try: + response = self.client.media.upload("file", f) + except Exception as e: + logger.error(f"企业微信上传文件失败: {e}") + await self.send( + MessageChain().message(f"企业微信上传文件失败: {e}"), + ) + return + logger.debug(f"企业微信上传文件返回: {response}") + self.client.message.send_file( + message_obj.self_id, + message_obj.session_id, + response["media_id"], + ) + elif isinstance(comp, Video): + video_path = await comp.convert_to_file_path() + + with open(video_path, "rb") as f: try: - response = self.client.media.upload("voice", f) + response = self.client.media.upload("video", f) except Exception as e: - logger.error(f"企业微信上传语音失败: {e}") + logger.error(f"企业微信上传视频失败: {e}") await self.send( - MessageChain().message(f"企业微信上传语音失败: {e}"), + MessageChain().message(f"企业微信上传视频失败: {e}"), ) return - logger.info(f"企业微信上传语音返回: {response}") - self.client.message.send_voice( + logger.debug(f"企业微信上传视频返回: {response}") + self.client.message.send_video( message_obj.self_id, message_obj.session_id, response["media_id"], diff --git a/astrbot/core/platform/sources/wecom_ai_bot/WXBizJsonMsgCrypt.py b/astrbot/core/platform/sources/wecom_ai_bot/WXBizJsonMsgCrypt.py index 2df09a763..260b950d1 100644 --- a/astrbot/core/platform/sources/wecom_ai_bot/WXBizJsonMsgCrypt.py +++ b/astrbot/core/platform/sources/wecom_ai_bot/WXBizJsonMsgCrypt.py @@ -14,6 +14,7 @@ import socket import struct import time +from typing import NoReturn from Crypto.Cipher import AES @@ -30,7 +31,7 @@ class FormatException(Exception): pass -def throw_exception(message, exception_class=FormatException): +def throw_exception(message, exception_class=FormatException) -> NoReturn: """My define raise exception function""" raise exception_class(message) @@ -145,7 +146,7 @@ class Prpcrypt: MIN_RANDOM_VALUE = 1000000000000000 # 最小值: 1000000000000000 (16位) RANDOM_RANGE = 9000000000000000 # 范围大小: 确保最大值为 9999999999999999 (16位) - def __init__(self, key): + def __init__(self, key) -> None: # self.key = base64.b64decode(key+"=") self.key = key # 设置加解密模式为AES的CBC模式 @@ -220,7 +221,7 @@ def get_random_str(self): class WXBizJsonMsgCrypt: # 构造函数 - def __init__(self, sToken, sEncodingAESKey, sReceiveId): + def __init__(self, sToken, sEncodingAESKey, sReceiveId) -> None: try: self.key = base64.b64decode(sEncodingAESKey + "=") assert len(self.key) == 32 diff --git a/astrbot/core/platform/sources/wecom_ai_bot/wecomai_adapter.py b/astrbot/core/platform/sources/wecom_ai_bot/wecomai_adapter.py index af6f834b1..aba60e06c 100644 --- a/astrbot/core/platform/sources/wecom_ai_bot/wecomai_adapter.py +++ b/astrbot/core/platform/sources/wecom_ai_bot/wecomai_adapter.py @@ -39,6 +39,7 @@ generate_random_string, process_encrypted_image, ) +from .wecomai_webhook import WecomAIBotWebhookClient, WecomAIBotWebhookError class WecomAIQueueListener: @@ -51,44 +52,13 @@ def __init__( ) -> None: self.queue_mgr = queue_mgr self.callback = callback - self.running_tasks = set() - async def listen_to_queue(self, session_id: str): - """监听特定会话的队列""" - queue = self.queue_mgr.get_or_create_queue(session_id) + async def run(self) -> None: + """注册监听回调并定期清理过期响应。""" + self.queue_mgr.set_listener(self.callback) while True: - try: - data = await queue.get() - await self.callback(data) - except Exception as e: - logger.error(f"处理会话 {session_id} 消息时发生错误: {e}") - break - - async def run(self): - """监控新会话队列并启动监听器""" - monitored_sessions = set() - - while True: - # 检查新会话 - current_sessions = set(self.queue_mgr.queues.keys()) - new_sessions = current_sessions - monitored_sessions - - # 为新会话启动监听器 - for session_id in new_sessions: - task = asyncio.create_task(self.listen_to_queue(session_id)) - self.running_tasks.add(task) - task.add_done_callback(self.running_tasks.discard) - monitored_sessions.add(session_id) - logger.debug(f"[WecomAI] 为会话启动监听器: {session_id}") - - # 清理已不存在的会话 - removed_sessions = monitored_sessions - current_sessions - monitored_sessions -= removed_sessions - - # 清理过期的待处理响应 self.queue_mgr.cleanup_expired_responses() - - await asyncio.sleep(1) # 每秒检查一次新会话 + await asyncio.sleep(1) @register_platform_adapter( @@ -111,24 +81,28 @@ def __init__( self.token = self.config["token"] self.encoding_aes_key = self.config["encoding_aes_key"] self.port = int(self.config["port"]) - self.host = self.config.get("callback_server_host", "::") + self.host = self.config.get("callback_server_host", "0.0.0.0") self.bot_name = self.config.get("wecom_ai_bot_name", "") self.initial_respond_text = self.config.get( "wecomaibot_init_respond_text", - "💭 思考中...", + "", ) self.friend_message_welcome_text = self.config.get( "wecomaibot_friend_message_welcome_text", "", ) self.unified_webhook_mode = self.config.get("unified_webhook_mode", False) + self.msg_push_webhook_url = self.config.get("msg_push_webhook_url", "").strip() + self.only_use_webhook_url_to_send = bool( + self.config.get("only_use_webhook_url_to_send", False), + ) # 平台元数据 self.metadata = PlatformMetadata( name="wecom_ai_bot", description="企业微信智能机器人适配器,支持 HTTP 回调接收消息", id=self.config.get("id", "wecom_ai_bot"), - support_proactive_message=False, + support_proactive_message=bool(self.msg_push_webhook_url), ) # 初始化 API 客户端 @@ -153,8 +127,18 @@ def __init__( self.queue_mgr, self._handle_queued_message, ) + self._stream_plain_cache: dict[str, str] = {} + + self.webhook_client: WecomAIBotWebhookClient | None = None + if self.msg_push_webhook_url: + try: + self.webhook_client = WecomAIBotWebhookClient( + self.msg_push_webhook_url, + ) + except WecomAIBotWebhookError as e: + logger.error("企业微信消息推送 webhook 配置无效: %s", e) - async def _handle_queued_message(self, data: dict): + async def _handle_queued_message(self, data: dict) -> None: """处理队列中的消息,类似webchat的callback""" try: abm = await self.convert_message(data) @@ -195,16 +179,19 @@ async def _process_message( ) self.queue_mgr.set_pending_response(stream_id, callback_params) - resp = WecomAIBotStreamMessageBuilder.make_text_stream( - stream_id, - self.initial_respond_text, - False, - ) - return await self.api_client.encrypt_message( - resp, - callback_params["nonce"], - callback_params["timestamp"], - ) + if self.only_use_webhook_url_to_send and self.webhook_client: + return None + if self.initial_respond_text: + resp = WecomAIBotStreamMessageBuilder.make_text_stream( + stream_id, + self.initial_respond_text, + False, + ) + return await self.api_client.encrypt_message( + resp, + callback_params["nonce"], + callback_params["timestamp"], + ) except Exception as e: logger.error("处理消息时发生异常: %s", e) return None @@ -212,7 +199,13 @@ async def _process_message( # wechat server is requesting for updates of a stream stream_id = message_data["stream"]["id"] if not self.queue_mgr.has_back_queue(stream_id): - logger.error(f"Cannot find back queue for stream_id: {stream_id}") + self._stream_plain_cache.pop(stream_id, None) + if self.queue_mgr.is_stream_finished(stream_id): + logger.debug( + f"Stream already finished, returning end message: {stream_id}" + ) + else: + logger.warning(f"Cannot find back queue for stream_id: {stream_id}") # 返回结束标志,告诉微信服务器流已结束 end_message = WecomAIBotStreamMessageBuilder.make_text_stream( @@ -234,24 +227,48 @@ async def _process_message( return None # aggregate all delta chains in the back queue - latest_plain_content = "" + cached_plain_content = self._stream_plain_cache.get(stream_id, "") + latest_plain_content = cached_plain_content image_base64 = [] finish = False while not queue.empty(): msg = await queue.get() if msg["type"] == "plain": - latest_plain_content = msg["data"] or "" + plain_data = msg.get("data") or "" + if msg.get("streaming", False): + # streaming plain payload is already cumulative + cached_plain_content = plain_data + else: + # segmented non-stream send() pushes plain chunks, needs append + cached_plain_content += plain_data + latest_plain_content = cached_plain_content elif msg["type"] == "image": image_base64.append(msg["image_data"]) - elif msg["type"] == "end": + elif msg["type"] == "break": + continue + elif msg["type"] in {"end", "complete"}: # stream end finish = True - self.queue_mgr.remove_queues(stream_id) + self.queue_mgr.remove_queues(stream_id, mark_finished=True) + self._stream_plain_cache.pop(stream_id, None) break logger.debug( f"Aggregated content: {latest_plain_content}, image: {len(image_base64)}, finish: {finish}", ) + if not finish: + self._stream_plain_cache[stream_id] = cached_plain_content + if finish and not latest_plain_content and not image_base64: + end_message = WecomAIBotStreamMessageBuilder.make_text_stream( + stream_id, + "", + True, + ) + return await self.api_client.encrypt_message( + end_message, + callback_params["nonce"], + callback_params["timestamp"], + ) if latest_plain_content or image_base64: msg_items = [] if finish and image_base64: @@ -314,7 +331,7 @@ async def _enqueue_message( callback_params: dict[str, str], stream_id: str, session_id: str, - ): + ) -> None: """将消息放入队列进行异步处理""" input_queue = self.queue_mgr.get_or_create_queue(stream_id) _ = self.queue_mgr.get_or_create_back_queue(stream_id) @@ -418,16 +435,30 @@ async def send_by_session( self, session: MessageSesion, message_chain: MessageChain, - ): - """通过会话发送消息""" - # 企业微信智能机器人主要通过回调响应,这里记录日志 - logger.info("会话发送消息: %s -> %s", session.session_id, message_chain) + ) -> None: + """通过消息推送 webhook 发送消息。""" + if not self.webhook_client: + logger.warning( + "主动消息发送失败: 未配置企业微信消息推送 Webhook URL,请前往配置添加。session_id=%s", + session.session_id, + ) + await super().send_by_session(session, message_chain) + return + + try: + await self.webhook_client.send_message_chain(message_chain) + except Exception as e: + logger.error( + "企业微信消息推送失败(session=%s): %s", + session.session_id, + e, + ) await super().send_by_session(session, message_chain) def run(self) -> Awaitable[Any]: """运行适配器,同时启动HTTP服务器和队列监听器""" - async def run_both(): + async def run_both() -> None: # 如果启用统一 webhook 模式,则不启动独立服务器 webhook_uuid = self.config.get("webhook_uuid") if self.unified_webhook_mode and webhook_uuid: @@ -454,7 +485,7 @@ async def webhook_callback(self, request: Any) -> Any: else: return await self.server.handle_callback(request) - async def terminate(self): + async def terminate(self) -> None: """终止适配器""" logger.info("企业微信智能机器人适配器正在关闭...") self.shutdown_event.set() @@ -464,7 +495,7 @@ def meta(self) -> PlatformMetadata: """获取平台元数据""" return self.metadata - async def handle_msg(self, message: AstrBotMessage): + async def handle_msg(self, message: AstrBotMessage) -> None: """处理消息,创建消息事件并提交到事件队列""" try: message_event = WecomAIBotMessageEvent( @@ -474,6 +505,8 @@ async def handle_msg(self, message: AstrBotMessage): session_id=message.session_id, api_client=self.api_client, queue_mgr=self.queue_mgr, + webhook_client=self.webhook_client, + only_use_webhook_url_to_send=self.only_use_webhook_url_to_send, ) self.commit_event(message_event) diff --git a/astrbot/core/platform/sources/wecom_ai_bot/wecomai_api.py b/astrbot/core/platform/sources/wecom_ai_bot/wecomai_api.py index 6c448a97e..97831fbb2 100644 --- a/astrbot/core/platform/sources/wecom_ai_bot/wecomai_api.py +++ b/astrbot/core/platform/sources/wecom_ai_bot/wecomai_api.py @@ -19,7 +19,7 @@ class WecomAIBotAPIClient: """企业微信智能机器人 API 客户端""" - def __init__(self, token: str, encoding_aes_key: str): + def __init__(self, token: str, encoding_aes_key: str) -> None: """初始化 API 客户端 Args: diff --git a/astrbot/core/platform/sources/wecom_ai_bot/wecomai_event.py b/astrbot/core/platform/sources/wecom_ai_bot/wecomai_event.py index fd11d7ceb..0369a82af 100644 --- a/astrbot/core/platform/sources/wecom_ai_bot/wecomai_event.py +++ b/astrbot/core/platform/sources/wecom_ai_bot/wecomai_event.py @@ -2,13 +2,11 @@ from astrbot.api import logger from astrbot.api.event import AstrMessageEvent, MessageChain -from astrbot.api.message_components import ( - Image, - Plain, -) +from astrbot.api.message_components import At, Image, Plain from .wecomai_api import WecomAIBotAPIClient from .wecomai_queue_mgr import WecomAIQueueMgr +from .wecomai_webhook import WecomAIBotWebhookClient class WecomAIBotMessageEvent(AstrMessageEvent): @@ -22,7 +20,9 @@ def __init__( session_id: str, api_client: WecomAIBotAPIClient, queue_mgr: WecomAIQueueMgr, - ): + webhook_client: WecomAIBotWebhookClient | None = None, + only_use_webhook_url_to_send: bool = False, + ) -> None: """初始化消息事件 Args: @@ -36,6 +36,19 @@ def __init__( super().__init__(message_str, message_obj, platform_meta, session_id) self.api_client = api_client self.queue_mgr = queue_mgr + self.webhook_client = webhook_client + self.only_use_webhook_url_to_send = only_use_webhook_url_to_send + + async def _mark_stream_complete(self, stream_id: str) -> None: + back_queue = self.queue_mgr.get_or_create_back_queue(stream_id) + await back_queue.put( + { + "type": "complete", + "data": "", + "streaming": False, + "session_id": stream_id, + }, + ) @staticmethod async def _send( @@ -43,6 +56,7 @@ async def _send( stream_id: str, queue_mgr: WecomAIQueueMgr, streaming: bool = False, + suppress_unsupported_log: bool = False, ): back_queue = queue_mgr.get_or_create_back_queue(stream_id) @@ -58,7 +72,17 @@ async def _send( data = "" for comp in message_chain.chain: - if isinstance(comp, Plain): + if isinstance(comp, At): + data = f"@{comp.name} " + await back_queue.put( + { + "type": "plain", + "data": data, + "streaming": streaming, + "session_id": stream_id, + }, + ) + elif isinstance(comp, Plain): data = comp.text await back_queue.put( { @@ -86,21 +110,41 @@ async def _send( except Exception as e: logger.error("处理图片消息失败: %s", e) else: - logger.warning(f"[WecomAI] 不支持的消息组件类型: {type(comp)}, 跳过") + if not suppress_unsupported_log: + logger.warning( + f"[WecomAI] 不支持的消息组件类型: {type(comp)}, 跳过" + ) return data - async def send(self, message: MessageChain | None): + async def send(self, message: MessageChain | None) -> None: """发送消息""" raw = self.message_obj.raw_message assert isinstance(raw, dict), ( "wecom_ai_bot platform event raw_message should be a dict" ) stream_id = raw.get("stream_id", self.session_id) - await WecomAIBotMessageEvent._send(message, stream_id, self.queue_mgr) + if self.only_use_webhook_url_to_send and self.webhook_client and message: + await self.webhook_client.send_message_chain(message) + await self._mark_stream_complete(stream_id) + await super().send(MessageChain([])) + return + + if self.webhook_client and message: + await self.webhook_client.send_message_chain( + message, + unsupported_only=True, + ) + + await WecomAIBotMessageEvent._send( + message, + stream_id, + self.queue_mgr, + suppress_unsupported_log=self.webhook_client is not None, + ) await super().send(MessageChain([])) - async def send_streaming(self, generator, use_fallback=False): + async def send_streaming(self, generator, use_fallback=False) -> None: """流式发送消息,参考webchat的send_streaming设计""" final_data = "" raw = self.message_obj.raw_message @@ -110,9 +154,23 @@ async def send_streaming(self, generator, use_fallback=False): stream_id = raw.get("stream_id", self.session_id) back_queue = self.queue_mgr.get_or_create_back_queue(stream_id) + if self.only_use_webhook_url_to_send and self.webhook_client: + merged_chain = MessageChain([]) + async for chain in generator: + merged_chain.chain.extend(chain.chain) + merged_chain.squash_plain() + await self.webhook_client.send_message_chain(merged_chain) + await self._mark_stream_complete(stream_id) + await super().send_streaming(generator, use_fallback) + return + # 企业微信智能机器人不支持增量发送,因此我们需要在这里将增量内容累积起来,积累发送 increment_plain = "" async for chain in generator: + if self.webhook_client: + await self.webhook_client.send_message_chain( + chain, unsupported_only=True + ) # 累积增量内容,并改写 Plain 段 chain.squash_plain() for comp in chain.chain: @@ -128,7 +186,7 @@ async def send_streaming(self, generator, use_fallback=False): "type": "break", # break means a segment end "data": final_data, "streaming": True, - "session_id": self.session_id, + "session_id": stream_id, }, ) final_data = "" @@ -139,6 +197,7 @@ async def send_streaming(self, generator, use_fallback=False): stream_id=stream_id, queue_mgr=self.queue_mgr, streaming=True, + suppress_unsupported_log=self.webhook_client is not None, ) await back_queue.put( @@ -146,7 +205,7 @@ async def send_streaming(self, generator, use_fallback=False): "type": "complete", # complete means we return the final result "data": final_data, "streaming": True, - "session_id": self.session_id, + "session_id": stream_id, }, ) await super().send_streaming(generator, use_fallback) diff --git a/astrbot/core/platform/sources/wecom_ai_bot/wecomai_queue_mgr.py b/astrbot/core/platform/sources/wecom_ai_bot/wecomai_queue_mgr.py index 3a982bdf7..9b6e6b968 100644 --- a/astrbot/core/platform/sources/wecom_ai_bot/wecomai_queue_mgr.py +++ b/astrbot/core/platform/sources/wecom_ai_bot/wecomai_queue_mgr.py @@ -4,6 +4,7 @@ """ import asyncio +from collections.abc import Awaitable, Callable from typing import Any from astrbot.api import logger @@ -12,7 +13,7 @@ class WecomAIQueueMgr: """企业微信智能机器人队列管理器""" - def __init__(self) -> None: + def __init__(self, queue_maxsize: int = 128, back_queue_maxsize: int = 512) -> None: self.queues: dict[str, asyncio.Queue] = {} """StreamID 到输入队列的映射 - 用于接收用户消息""" @@ -21,6 +22,13 @@ def __init__(self) -> None: self.pending_responses: dict[str, dict[str, Any]] = {} """待处理的响应缓存,用于流式响应""" + self.completed_streams: dict[str, float] = {} + """已结束的 stream 缓存,用于兼容平台后续重复轮询""" + self._queue_close_events: dict[str, asyncio.Event] = {} + self._listener_tasks: dict[str, asyncio.Task] = {} + self._listener_callback: Callable[[dict], Awaitable[None]] | None = None + self.queue_maxsize = queue_maxsize + self.back_queue_maxsize = back_queue_maxsize def get_or_create_queue(self, session_id: str) -> asyncio.Queue: """获取或创建指定会话的输入队列 @@ -33,7 +41,9 @@ def get_or_create_queue(self, session_id: str) -> asyncio.Queue: """ if session_id not in self.queues: - self.queues[session_id] = asyncio.Queue() + self.queues[session_id] = asyncio.Queue(maxsize=self.queue_maxsize) + self._queue_close_events[session_id] = asyncio.Event() + self._start_listener_if_needed(session_id) logger.debug(f"[WecomAI] 创建输入队列: {session_id}") return self.queues[session_id] @@ -48,20 +58,21 @@ def get_or_create_back_queue(self, session_id: str) -> asyncio.Queue: """ if session_id not in self.back_queues: - self.back_queues[session_id] = asyncio.Queue() + self.back_queues[session_id] = asyncio.Queue( + maxsize=self.back_queue_maxsize + ) logger.debug(f"[WecomAI] 创建输出队列: {session_id}") return self.back_queues[session_id] - def remove_queues(self, session_id: str): + def remove_queues(self, session_id: str, mark_finished: bool = False) -> None: """移除指定会话的所有队列 Args: session_id: 会话ID + mark_finished: 是否标记为已正常结束 """ - if session_id in self.queues: - del self.queues[session_id] - logger.debug(f"[WecomAI] 移除输入队列: {session_id}") + self.remove_queue(session_id) if session_id in self.back_queues: del self.back_queues[session_id] @@ -70,6 +81,23 @@ def remove_queues(self, session_id: str): if session_id in self.pending_responses: del self.pending_responses[session_id] logger.debug(f"[WecomAI] 移除待处理响应: {session_id}") + if mark_finished: + self.completed_streams[session_id] = asyncio.get_event_loop().time() + logger.debug(f"[WecomAI] 标记流已结束: {session_id}") + + def remove_queue(self, session_id: str): + """仅移除输入队列和对应监听任务""" + if session_id in self.queues: + del self.queues[session_id] + logger.debug(f"[WecomAI] 移除输入队列: {session_id}") + + close_event = self._queue_close_events.pop(session_id, None) + if close_event is not None: + close_event.set() + + task = self._listener_tasks.pop(session_id, None) + if task is not None: + task.cancel() def has_queue(self, session_id: str) -> bool: """检查是否存在指定会话的队列 @@ -95,7 +123,9 @@ def has_back_queue(self, session_id: str) -> bool: """ return session_id in self.back_queues - def set_pending_response(self, session_id: str, callback_params: dict[str, str]): + def set_pending_response( + self, session_id: str, callback_params: dict[str, str] + ) -> None: """设置待处理的响应参数 Args: @@ -121,7 +151,21 @@ def get_pending_response(self, session_id: str) -> dict[str, Any] | None: """ return self.pending_responses.get(session_id) - def cleanup_expired_responses(self, max_age_seconds: int = 300): + def is_stream_finished( + self, + session_id: str, + max_age_seconds: int = 60, + ) -> bool: + """判断 stream 是否在短期内已结束""" + finished_at = self.completed_streams.get(session_id) + if finished_at is None: + return False + if asyncio.get_event_loop().time() - finished_at > max_age_seconds: + self.completed_streams.pop(session_id, None) + return False + return True + + def cleanup_expired_responses(self, max_age_seconds: int = 300) -> None: """清理过期的待处理响应 Args: @@ -136,8 +180,75 @@ def cleanup_expired_responses(self, max_age_seconds: int = 300): expired_sessions.append(session_id) for session_id in expired_sessions: - del self.pending_responses[session_id] - logger.debug(f"[WecomAI] 清理过期响应: {session_id}") + self.remove_queues(session_id) + logger.debug(f"[WecomAI] 清理过期响应及队列: {session_id}") + expired_finished = [ + session_id + for session_id, finished_at in self.completed_streams.items() + if current_time - finished_at > 60 + ] + for session_id in expired_finished: + self.completed_streams.pop(session_id, None) + + def set_listener( + self, + callback: Callable[[dict], Awaitable[None]], + ): + self._listener_callback = callback + for session_id in list(self.queues.keys()): + self._start_listener_if_needed(session_id) + + def _start_listener_if_needed(self, session_id: str): + if self._listener_callback is None: + return + if session_id in self._listener_tasks: + task = self._listener_tasks[session_id] + if not task.done(): + return + queue = self.queues.get(session_id) + close_event = self._queue_close_events.get(session_id) + if queue is None or close_event is None: + return + task = asyncio.create_task( + self._listen_to_queue(session_id, queue, close_event), + name=f"wecomai_listener_{session_id}", + ) + self._listener_tasks[session_id] = task + task.add_done_callback(lambda _: self._listener_tasks.pop(session_id, None)) + logger.debug(f"[WecomAI] 为会话启动监听器: {session_id}") + + async def _listen_to_queue( + self, + session_id: str, + queue: asyncio.Queue, + close_event: asyncio.Event, + ): + while True: + get_task = asyncio.create_task(queue.get()) + close_task = asyncio.create_task(close_event.wait()) + try: + done, pending = await asyncio.wait( + {get_task, close_task}, + return_when=asyncio.FIRST_COMPLETED, + ) + for task in pending: + task.cancel() + if close_task in done: + break + data = get_task.result() + if self._listener_callback is None: + continue + try: + await self._listener_callback(data) + except Exception as e: + logger.error(f"处理会话 {session_id} 消息时发生错误: {e}") + except asyncio.CancelledError: + break + finally: + if not get_task.done(): + get_task.cancel() + if not close_task.done(): + close_task.cancel() def get_stats(self) -> dict[str, int]: """获取队列统计信息 diff --git a/astrbot/core/platform/sources/wecom_ai_bot/wecomai_server.py b/astrbot/core/platform/sources/wecom_ai_bot/wecomai_server.py index 5cbdd1130..80ec5179e 100644 --- a/astrbot/core/platform/sources/wecom_ai_bot/wecomai_server.py +++ b/astrbot/core/platform/sources/wecom_ai_bot/wecomai_server.py @@ -23,7 +23,7 @@ def __init__( port: int, api_client: WecomAIBotAPIClient, message_handler: Callable[[dict[str, Any], dict[str, str]], Any] | None = None, - ): + ) -> None: """初始化服务器 Args: @@ -43,7 +43,7 @@ def __init__( self.shutdown_event = asyncio.Event() - def _setup_routes(self): + def _setup_routes(self) -> None: """设置 Quart 路由""" # 使用 Quart 的 add_url_rule 方法添加路由 self.app.add_url_rule( @@ -162,7 +162,7 @@ async def handle_callback(self, request): logger.error("处理消息时发生异常: %s", e) return "内部服务器错误", 500 - async def start_server(self): + async def start_server(self) -> None: """启动服务器""" logger.info("启动企业微信智能机器人服务器,监听 %s:%d", self.host, self.port) @@ -176,11 +176,11 @@ async def start_server(self): logger.error("服务器运行异常: %s", e) raise - async def shutdown_trigger(self): + async def shutdown_trigger(self) -> None: """关闭触发器""" await self.shutdown_event.wait() - async def shutdown(self): + async def shutdown(self) -> None: """关闭服务器""" logger.info("企业微信智能机器人服务器正在关闭...") self.shutdown_event.set() diff --git a/astrbot/core/platform/sources/wecom_ai_bot/wecomai_webhook.py b/astrbot/core/platform/sources/wecom_ai_bot/wecomai_webhook.py new file mode 100644 index 000000000..6f42f264b --- /dev/null +++ b/astrbot/core/platform/sources/wecom_ai_bot/wecomai_webhook.py @@ -0,0 +1,225 @@ +"""企业微信智能机器人 webhook 推送客户端。""" + +from __future__ import annotations + +import base64 +import hashlib +import mimetypes +from pathlib import Path +from typing import Any, Literal +from urllib.parse import parse_qs, urlencode, urlparse + +import aiohttp + +from astrbot.api import logger +from astrbot.api.event import MessageChain +from astrbot.api.message_components import At, File, Image, Plain, Record, Video +from astrbot.core.utils.media_utils import convert_audio_format + + +class WecomAIBotWebhookError(RuntimeError): + """企业微信 webhook 推送异常。""" + + +class WecomAIBotWebhookClient: + """企业微信智能机器人 webhook 消息推送客户端。""" + + def __init__(self, webhook_url: str, timeout_seconds: int = 15) -> None: + self.webhook_url = webhook_url.strip() + self.timeout_seconds = timeout_seconds + if not self.webhook_url: + raise WecomAIBotWebhookError("消息推送 webhook URL 不能为空") + self._webhook_key = self._extract_webhook_key() + + def _extract_webhook_key(self) -> str: + parsed = urlparse(self.webhook_url) + key = parse_qs(parsed.query).get("key", [""])[0].strip() + if not key: + raise WecomAIBotWebhookError("消息推送 webhook URL 缺少 key 参数") + return key + + def _build_upload_url(self, media_type: Literal["file", "voice"]) -> str: + query = urlencode({"key": self._webhook_key, "type": media_type}) + return f"https://qyapi.weixin.qq.com/cgi-bin/webhook/upload_media?{query}" + + @staticmethod + def _split_markdown_v2_content(content: str, max_bytes: int = 4096) -> list[str]: + if not content: + return [] + chunks: list[str] = [] + buffer: list[str] = [] + current_size = 0 + for char in content: + char_size = len(char.encode("utf-8")) + if current_size + char_size > max_bytes and buffer: + chunks.append("".join(buffer)) + buffer = [char] + current_size = char_size + else: + buffer.append(char) + current_size += char_size + if buffer: + chunks.append("".join(buffer)) + return chunks + + async def send_payload(self, payload: dict[str, Any]) -> None: + timeout = aiohttp.ClientTimeout(total=self.timeout_seconds) + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.post(self.webhook_url, json=payload) as response: + text = await response.text() + if response.status != 200: + raise WecomAIBotWebhookError( + f"Webhook 请求失败: HTTP {response.status}, {text}" + ) + result = await response.json(content_type=None) + if result.get("errcode") != 0: + raise WecomAIBotWebhookError( + f"Webhook 返回错误: {result.get('errcode')} {result.get('errmsg')}" + ) + logger.debug("企业微信消息推送成功: %s", payload.get("msgtype", "unknown")) + + async def send_markdown_v2(self, content: str) -> None: + for chunk in self._split_markdown_v2_content(content): + await self.send_payload( + { + "msgtype": "markdown_v2", + "markdown_v2": {"content": chunk}, + } + ) + + async def send_image_base64(self, image_base64: str) -> None: + image_bytes = base64.b64decode(image_base64) + md5 = hashlib.md5(image_bytes).hexdigest() + await self.send_payload( + { + "msgtype": "image", + "image": { + "base64": image_base64, + "md5": md5, + }, + } + ) + + async def upload_media( + self, file_path: Path, media_type: Literal["file", "voice"] + ) -> str: + if not file_path.exists() or not file_path.is_file(): + raise WecomAIBotWebhookError(f"文件不存在: {file_path}") + + content_type = ( + mimetypes.guess_type(str(file_path))[0] or "application/octet-stream" + ) + form = aiohttp.FormData() + form.add_field( + "media", + file_path.read_bytes(), + filename=file_path.name, + content_type=content_type, + ) + + timeout = aiohttp.ClientTimeout(total=self.timeout_seconds) + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.post( + self._build_upload_url(media_type), + data=form, + ) as response: + text = await response.text() + if response.status != 200: + raise WecomAIBotWebhookError( + f"上传媒体失败: HTTP {response.status}, {text}" + ) + result = await response.json(content_type=None) + if result.get("errcode") != 0: + raise WecomAIBotWebhookError( + f"上传媒体失败: {result.get('errcode')} {result.get('errmsg')}" + ) + media_id = result.get("media_id", "") + if not media_id: + raise WecomAIBotWebhookError("上传媒体失败: 返回缺少 media_id") + return str(media_id) + + async def send_file(self, file_path: Path) -> None: + media_id = await self.upload_media(file_path, "file") + await self.send_payload( + { + "msgtype": "file", + "file": {"media_id": media_id}, + } + ) + + async def send_voice(self, file_path: Path) -> None: + media_id = await self.upload_media(file_path, "voice") + await self.send_payload( + { + "msgtype": "voice", + "voice": {"media_id": media_id}, + } + ) + + @staticmethod + def is_stream_supported_component(component: Any) -> bool: + return isinstance(component, Plain | Image | At) + + async def send_message_chain( + self, + message_chain: MessageChain, + unsupported_only: bool = False, + ) -> None: + async def flush_markdown_buffer(parts: list[str]) -> None: + content = "".join(parts).strip() + parts.clear() + if content: + await self.send_markdown_v2(content) + + markdown_buffer: list[str] = [] + + for component in message_chain.chain: + if unsupported_only and self.is_stream_supported_component(component): + continue + if isinstance(component, Plain): + markdown_buffer.append(component.text) + elif isinstance(component, At): + mention_name = component.name or str(component.qq) + markdown_buffer.append(f" @{mention_name} ") + elif isinstance(component, Image): + await flush_markdown_buffer(markdown_buffer) + image_base64 = await component.convert_to_base64() + await self.send_image_base64(image_base64) + elif isinstance(component, File): + await flush_markdown_buffer(markdown_buffer) + file_path = await component.get_file() + if not file_path: + logger.warning("文件消息缺少有效文件路径,已跳过: %s", component) + continue + await self.send_file(Path(file_path)) + elif isinstance(component, Video): + await flush_markdown_buffer(markdown_buffer) + video_path = await component.convert_to_file_path() + await self.send_file(Path(video_path)) + elif isinstance(component, Record): + await flush_markdown_buffer(markdown_buffer) + source_voice_path = Path(await component.convert_to_file_path()) + target_voice_path = source_voice_path + converted = False + if source_voice_path.suffix.lower() != ".amr": + target_voice_path = Path( + await convert_audio_format(str(source_voice_path), "amr"), + ) + converted = target_voice_path != source_voice_path + try: + await self.send_voice(target_voice_path) + finally: + if converted and target_voice_path.exists(): + try: + target_voice_path.unlink() + except Exception as e: + logger.warning( + "清理临时语音文件失败 %s: %s", target_voice_path, e + ) + else: + logger.warning( + "企业微信消息推送暂不支持组件类型 %s,已跳过", + type(component).__name__, + ) + + await flush_markdown_buffer(markdown_buffer) diff --git a/astrbot/core/platform/sources/weixin_official_account/weixin_offacc_adapter.py b/astrbot/core/platform/sources/weixin_official_account/weixin_offacc_adapter.py index 7aa33a91c..c01355974 100644 --- a/astrbot/core/platform/sources/weixin_official_account/weixin_offacc_adapter.py +++ b/astrbot/core/platform/sources/weixin_official_account/weixin_offacc_adapter.py @@ -1,12 +1,14 @@ import asyncio +import os import sys +import time import uuid -from collections.abc import Awaitable, Callable +from collections.abc import Callable, Coroutine from typing import Any, cast import quart from requests import Response -from wechatpy import WeChatClient, parse_message +from wechatpy import WeChatClient, create_reply, parse_message from wechatpy.crypto import WeChatCrypto from wechatpy.exceptions import InvalidSignatureException from wechatpy.messages import BaseMessage, ImageMessage, TextMessage, VoiceMessage @@ -24,6 +26,8 @@ ) from astrbot.core import logger from astrbot.core.platform.astr_message_event import MessageSesion +from astrbot.core.utils.astrbot_path import get_astrbot_temp_path +from astrbot.core.utils.media_utils import convert_audio_to_wav from astrbot.core.utils.webhook_utils import log_webhook_info from .weixin_offacc_event import WeixinOfficialAccountPlatformEvent @@ -35,10 +39,15 @@ class WeixinOfficialAccountServer: - def __init__(self, event_queue: asyncio.Queue, config: dict): + def __init__( + self, + event_queue: asyncio.Queue, + config: dict, + user_buffer: dict[Any, dict[str, Any]], + ) -> None: self.server = quart.Quart(__name__) self.port = int(cast(int | str, config.get("port"))) - self.callback_server_host = config.get("callback_server_host", "::") + self.callback_server_host = config.get("callback_server_host", "0.0.0.0") self.token = config.get("token") self.encoding_aes_key = config.get("encoding_aes_key") self.appid = config.get("appid") @@ -56,9 +65,15 @@ def __init__(self, event_queue: asyncio.Queue, config: dict): self.event_queue = event_queue - self.callback: Callable[[BaseMessage], Awaitable[None]] | None = None + self.callback: ( + Callable[[BaseMessage], Coroutine[Any, Any, str | None]] | None + ) = None self.shutdown_event = asyncio.Event() + self._wx_msg_time_out = 4.0 # 微信服务器要求 5 秒内回复 + self.user_buffer: dict[str, dict[str, Any]] = user_buffer # from_user -> state + self.active_send_mode = False # 是否启用主动发送模式,启用后 callback 将直接返回回复内容,无需等待微信回调 + async def verify(self): """内部服务器的 GET 验证入口""" return await self.handle_verify(quart.request) @@ -95,6 +110,22 @@ async def callback_command(self): """内部服务器的 POST 回调入口""" return await self.handle_callback(quart.request) + def _maybe_encrypt(self, xml: str, nonce: str | None, timestamp: str | None) -> str: + if xml and "" not in xml and nonce and timestamp: + return self.crypto.encrypt_message(xml, nonce, timestamp) + return xml or "success" + + def _preview(self, msg: BaseMessage, limit: int = 24) -> str: + """生成消息预览文本,供占位符使用""" + if isinstance(msg, TextMessage): + t = cast(str, msg.content).strip() + return (t[:limit] + "...") if len(t) > limit else (t or "空消息") + if isinstance(msg, ImageMessage): + return "图片" + if isinstance(msg, VoiceMessage): + return "语音" + return getattr(msg, "type", "未知消息") + async def handle_callback(self, request) -> str: """处理回调请求,可被统一 webhook 入口复用 @@ -120,16 +151,154 @@ async def handle_callback(self, request) -> str: raise logger.info(f"解析成功: {msg}") - if self.callback: + if not self.callback: + return "success" + + # by pass passive reply logic and return active reply directly. + if self.active_send_mode: result_xml = await self.callback(msg) if not result_xml: return "success" if isinstance(result_xml, str): return result_xml - return "success" + # passive reply + from_user = str(getattr(msg, "source", "")) + msg_id = str(cast(str | int, getattr(msg, "id", ""))) + state = self.user_buffer.get(from_user) + + def _reply_text(text: str) -> str: + reply_obj = create_reply(text, msg) + reply_xml = reply_obj if isinstance(reply_obj, str) else str(reply_obj) + return self._maybe_encrypt(reply_xml, nonce, timestamp) + + # if in cached state, return cached result or placeholder + if state: + logger.debug(f"用户消息缓冲状态: user={from_user} state={state}") + cached = state.get("cached_xml") + # send one cached each time, if cached is empty after pop, remove the buffer + if cached and len(cached) > 0: + logger.info(f"wx buffer hit on trigger: user={from_user}") + cached_xml = cached.pop(0) + if len(cached) == 0: + self.user_buffer.pop(from_user, None) + return _reply_text(cached_xml) + else: + return _reply_text( + cached_xml + + "\n【后续消息还在缓冲中,回复任意文字继续获取】" + ) + + task: asyncio.Task | None = cast(asyncio.Task | None, state.get("task")) + placeholder = ( + f"【正在思考'{state.get('preview', '...')}'中,已思考" + f"{int(time.monotonic() - state.get('started_at', time.monotonic()))}s,回复任意文字尝试获取回复】" + ) - async def start_polling(self): + # same msgid => WeChat retry: wait a little; new msgid => user trigger: just placeholder + if task and state.get("msg_id") == msg_id: + done, _ = await asyncio.wait( + {task}, + timeout=self._wx_msg_time_out, + return_when=asyncio.FIRST_COMPLETED, + ) + if done: + try: + cached = state.get("cached_xml") + # send one cached each time, if cached is empty after pop, remove the buffer + if cached and len(cached) > 0: + logger.info( + f"wx buffer hit on retry window: user={from_user}" + ) + cached_xml = cached.pop(0) + if len(cached) == 0: + self.user_buffer.pop(from_user, None) + logger.debug( + f"wx finished message sending in passive window: user={from_user} msg_id={msg_id} " + ) + return _reply_text(cached_xml) + else: + logger.debug( + f"wx finished message sending in passive window but not final: user={from_user} msg_id={msg_id} " + ) + return _reply_text( + cached_xml + + "\n【后续消息还在缓冲中,回复任意文字继续获取】" + ) + logger.info( + f"wx finished in window but not final; return placeholder: user={from_user} msg_id={msg_id} " + ) + return _reply_text(placeholder) + except Exception: + logger.critical( + "wx task failed in passive window", exc_info=True + ) + self.user_buffer.pop(from_user, None) + return _reply_text("处理消息失败,请稍后再试。") + + logger.info( + f"wx passive window timeout: user={from_user} msg_id={msg_id}" + ) + return _reply_text(placeholder) + + logger.debug(f"wx trigger while thinking: user={from_user}") + return _reply_text(placeholder) + + # create new trigger when state is empty, and store state in buffer + logger.debug(f"wx new trigger: user={from_user} msg_id={msg_id}") + preview = self._preview(msg) + placeholder = ( + f"【正在思考'{preview}'中,已思考0s,回复任意文字尝试获取回复】" + ) + logger.info( + f"wx start task: user={from_user} msg_id={msg_id} preview={preview}" + ) + + self.user_buffer[from_user] = state = { + "msg_id": msg_id, + "preview": preview, + "task": None, # set later after task created + "cached_xml": [], # for passive reply + "started_at": time.monotonic(), + } + self.user_buffer[from_user]["task"] = task = asyncio.create_task( + self.callback(msg) + ) + + # immediate return if done + done, _ = await asyncio.wait( + {task}, + timeout=self._wx_msg_time_out, + return_when=asyncio.FIRST_COMPLETED, + ) + if done: + try: + cached = state.get("cached_xml", None) + # send one cached each time, if cached is empty after pop, remove the buffer + if cached and len(cached) > 0: + logger.info(f"wx buffer hit immediately: user={from_user}") + cached_xml = cached.pop(0) + if len(cached) == 0: + self.user_buffer.pop(from_user, None) + return _reply_text(cached_xml) + else: + return _reply_text( + cached_xml + + "\n【后续消息还在缓冲中,回复任意文字继续获取】" + ) + logger.info( + f"wx not finished in first window; return placeholder: user={from_user} msg_id={msg_id} " + ) + return _reply_text(placeholder) + except Exception: + logger.critical("wx task failed in first window", exc_info=True) + self.user_buffer.pop(from_user, None) + return _reply_text("处理消息失败,请稍后再试。") + + logger.info(f"wx first window timeout: user={from_user} msg_id={msg_id}") + return _reply_text(placeholder) + + async def start_polling(self) -> None: logger.info( f"将在 {self.callback_server_host}:{self.port} 端口启动 微信公众平台 适配器。", ) @@ -139,7 +308,7 @@ async def start_polling(self): shutdown_trigger=self.shutdown_trigger, ) - async def shutdown_trigger(self): + async def shutdown_trigger(self) -> None: await self.shutdown_event.wait() @@ -173,7 +342,10 @@ def __init__( if not self.api_base_url.endswith("/"): self.api_base_url += "/" - self.server = WeixinOfficialAccountServer(self._event_queue, self.config) + self.user_buffer: dict[str, dict[str, Any]] = {} # from_user -> state + self.server = WeixinOfficialAccountServer( + self._event_queue, self.config, self.user_buffer + ) self.client = WeChatClient( self.config["appid"].strip(), @@ -190,35 +362,40 @@ async def callback(msg: BaseMessage): try: if self.active_send_mode: await self.convert_message(msg, None) + return None + + msg_id = str(cast(str | int, msg.id)) + future = self.wexin_event_workers.get(msg_id) + if future: + logger.debug(f"duplicate message id checked: {msg.id}") else: - if str(msg.id) in self.wexin_event_workers: - future = self.wexin_event_workers[str(cast(str | int, msg.id))] - logger.debug(f"duplicate message id checked: {msg.id}") - else: - future = asyncio.get_event_loop().create_future() - self.wexin_event_workers[str(cast(str | int, msg.id))] = future - await self.convert_message(msg, future) + future = asyncio.get_event_loop().create_future() + self.wexin_event_workers[msg_id] = future + await self.convert_message(msg, future) # I love shield so much! result = await asyncio.wait_for( asyncio.shield(future), - 60, - ) # wait for 60s - logger.debug(f"Got future result: {result}") - self.wexin_event_workers.pop(str(cast(str | int, msg.id)), None) - return result # xml. see weixin_offacc_event.py + 180, + ) # wait for 180s + logger.debug(f"Got future result: {result}") + return result except asyncio.TimeoutError: - pass + logger.info(f"callback 处理消息超时: message_id={msg.id}") + return create_reply("处理消息超时,请稍后再试。", msg) except Exception as e: logger.error(f"转换消息时出现异常: {e}") + finally: + self.wexin_event_workers.pop(str(cast(str | int, msg.id)), None) self.server.callback = callback + self.server.active_send_mode = self.active_send_mode @override async def send_by_session( self, session: MessageSesion, message_chain: MessageChain, - ): + ) -> None: await super().send_by_session(session, message_chain) @override @@ -232,7 +409,7 @@ def meta(self) -> PlatformMetadata: ) @override - async def run(self): + async def run(self) -> None: # 如果启用统一 webhook 模式,则不启动独立服务器 webhook_uuid = self.config.get("webhook_uuid") if self.unified_webhook_mode and webhook_uuid: @@ -289,19 +466,20 @@ async def convert_message( self.client.media.download, msg.media_id, ) - path = f"data/temp/wecom_{msg.media_id}.amr" + temp_dir = get_astrbot_temp_path() + path = os.path.join(temp_dir, f"weixin_offacc_{msg.media_id}.amr") with open(path, "wb") as f: f.write(resp.content) try: - from pydub import AudioSegment - - path_wav = f"data/temp/wecom_{msg.media_id}.wav" - audio = AudioSegment.from_file(path) - audio.export(path_wav, format="wav") + path_wav = os.path.join( + temp_dir, + f"weixin_offacc_{msg.media_id}.wav", + ) + path_wav = await convert_audio_to_wav(path, path_wav) except Exception as e: logger.error( - f"转换音频失败: {e}。如果没有安装 pydub 和 ffmpeg 请先安装。", + f"转换音频失败: {e}。如果没有安装 ffmpeg 请先安装。", ) path_wav = path return @@ -331,20 +509,27 @@ async def convert_message( logger.info(f"abm: {abm}") await self.handle_msg(abm) - async def handle_msg(self, message: AstrBotMessage): + async def handle_msg(self, message: AstrBotMessage) -> None: + buffer = self.user_buffer.get(message.sender.user_id, None) + if buffer is None: + logger.critical( + f"用户消息未找到缓冲状态,无法处理消息: user={message.sender.user_id} message_id={message.message_id}" + ) + return message_event = WeixinOfficialAccountPlatformEvent( message_str=message.message_str, message_obj=message, platform_meta=self.meta(), session_id=message.session_id, client=self.client, + message_out=buffer, ) self.commit_event(message_event) def get_client(self) -> WeChatClient: return self.client - async def terminate(self): + async def terminate(self) -> None: self.server.shutdown_event.set() try: await self.server.server.shutdown() diff --git a/astrbot/core/platform/sources/weixin_official_account/weixin_offacc_event.py b/astrbot/core/platform/sources/weixin_official_account/weixin_offacc_event.py index c1f137a41..ae536593c 100644 --- a/astrbot/core/platform/sources/weixin_official_account/weixin_offacc_event.py +++ b/astrbot/core/platform/sources/weixin_official_account/weixin_offacc_event.py @@ -1,21 +1,15 @@ import asyncio -import uuid -from typing import cast +import os +from typing import Any, cast from wechatpy import WeChatClient -from wechatpy.replies import ImageReply, TextReply, VoiceReply +from wechatpy.replies import ImageReply, VoiceReply from astrbot.api import logger from astrbot.api.event import AstrMessageEvent, MessageChain from astrbot.api.message_components import Image, Plain, Record from astrbot.api.platform import AstrBotMessage, PlatformMetadata - -try: - import pydub -except Exception: - logger.warning( - "检测到 pydub 库未安装,微信公众平台将无法语音收发。如需使用语音,请前往管理面板 -> 平台日志 -> 安装 Pip 库安装 pydub。", - ) +from astrbot.core.utils.media_utils import convert_audio_to_amr class WeixinOfficialAccountPlatformEvent(AstrMessageEvent): @@ -26,20 +20,22 @@ def __init__( platform_meta: PlatformMetadata, session_id: str, client: WeChatClient, - ): + message_out: dict[Any, Any], + ) -> None: super().__init__(message_str, message_obj, platform_meta, session_id) self.client = client + self.message_out = message_out @staticmethod async def send_with_client( client: WeChatClient, message: MessageChain, user_name: str, - ): + ) -> None: pass - async def split_plain(self, plain: str) -> list[str]: - """将长文本分割成多个小文本, 每个小文本长度不超过 2048 字符 + async def split_plain(self, plain: str, max_length: int = 1024) -> list[str]: + """将长文本分割成多个小文本, 每个小文本长度不超过 max_length 字符 Args: plain (str): 要分割的长文本 @@ -47,18 +43,18 @@ async def split_plain(self, plain: str) -> list[str]: list[str]: 分割后的文本列表 """ - if len(plain) <= 2048: + if len(plain) <= max_length: return [plain] result = [] start = 0 while start < len(plain): - # 剩下的字符串长度<2048时结束 - if start + 2048 >= len(plain): + # 剩下的字符串长度= len(plain): result.append(plain[start:]) break # 向前搜索分割标点符号 - end = min(start + 2048, len(plain)) + end = min(start + max_length, len(plain)) cut_position = end for i in range(end, start, -1): if i < len(plain) and plain[i - 1] in [ @@ -84,7 +80,7 @@ async def split_plain(self, plain: str) -> list[str]: return result - async def send(self, message: MessageChain): + async def send(self, message: MessageChain) -> None: message_obj = self.message_obj active_send_mode = cast(dict, message_obj.raw_message).get( "active_send_mode", False @@ -93,19 +89,15 @@ async def send(self, message: MessageChain): if isinstance(comp, Plain): # Split long text messages if needed plain_chunks = await self.split_plain(comp.text) - for chunk in plain_chunks: - if active_send_mode: + if active_send_mode: + for chunk in plain_chunks: self.client.message.send_text(message_obj.sender.user_id, chunk) - else: - reply = TextReply( - content=chunk, - message=cast(dict, self.message_obj.raw_message)["message"], - ) - xml = reply.render() - future = cast(dict, self.message_obj.raw_message)["future"] - assert isinstance(future, asyncio.Future) - future.set_result(xml) - await asyncio.sleep(0.5) # Avoid sending too fast + else: + # disable passive sending, just store the chunks in + logger.debug( + f"split plain into {len(plain_chunks)} chunks for passive reply. Message not sent." + ) + self.message_out["cached_xml"] = plain_chunks elif isinstance(comp, Image): img_path = await comp.convert_to_file_path() @@ -137,38 +129,46 @@ async def send(self, message: MessageChain): elif isinstance(comp, Record): record_path = await comp.convert_to_file_path() - # 转成amr - record_path_amr = f"data/temp/{uuid.uuid4()}.amr" - pydub.AudioSegment.from_wav(record_path).export( - record_path_amr, - format="amr", - ) - - with open(record_path_amr, "rb") as f: - try: - response = self.client.media.upload("voice", f) - except Exception as e: - logger.error(f"微信公众平台上传语音失败: {e}") - await self.send( - MessageChain().message(f"微信公众平台上传语音失败: {e}"), - ) - return - logger.info(f"微信公众平台上传语音返回: {response}") - - if active_send_mode: - self.client.message.send_voice( - message_obj.sender.user_id, - response["media_id"], - ) - else: - reply = VoiceReply( - media_id=response["media_id"], - message=cast(dict, self.message_obj.raw_message)["message"], - ) - xml = reply.render() - future = cast(dict, self.message_obj.raw_message)["future"] - assert isinstance(future, asyncio.Future) - future.set_result(xml) + record_path_amr = await convert_audio_to_amr(record_path) + + try: + with open(record_path_amr, "rb") as f: + try: + response = self.client.media.upload("voice", f) + except Exception as e: + logger.error(f"微信公众平台上传语音失败: {e}") + await self.send( + MessageChain().message( + f"微信公众平台上传语音失败: {e}" + ), + ) + return + logger.info(f"微信公众平台上传语音返回: {response}") + + if active_send_mode: + self.client.message.send_voice( + message_obj.sender.user_id, + response["media_id"], + ) + else: + reply = VoiceReply( + media_id=response["media_id"], + message=cast(dict, self.message_obj.raw_message)[ + "message" + ], + ) + xml = reply.render() + future = cast(dict, self.message_obj.raw_message)["future"] + assert isinstance(future, asyncio.Future) + future.set_result(xml) + finally: + if record_path_amr != record_path and os.path.exists( + record_path_amr + ): + try: + os.remove(record_path_amr) + except OSError as e: + logger.warning(f"删除临时音频文件失败: {e}") else: logger.warning(f"还没实现这个消息类型的发送逻辑: {comp.type}。") diff --git a/astrbot/core/platform_message_history_mgr.py b/astrbot/core/platform_message_history_mgr.py index d6d524698..ad8bb44f6 100644 --- a/astrbot/core/platform_message_history_mgr.py +++ b/astrbot/core/platform_message_history_mgr.py @@ -3,7 +3,7 @@ class PlatformMessageHistoryManager: - def __init__(self, db_helper: BaseDatabase): + def __init__(self, db_helper: BaseDatabase) -> None: self.db = db_helper async def insert( @@ -40,7 +40,9 @@ async def get( history.reverse() return history - async def delete(self, platform_id: str, user_id: str, offset_sec: int = 86400): + async def delete( + self, platform_id: str, user_id: str, offset_sec: int = 86400 + ) -> None: """Delete platform message history records older than the specified offset.""" await self.db.delete_platform_message_offset( platform_id=platform_id, diff --git a/astrbot/core/provider/entities.py b/astrbot/core/provider/entities.py index 7c568626d..20c5a7947 100644 --- a/astrbot/core/provider/entities.py +++ b/astrbot/core/provider/entities.py @@ -111,7 +111,7 @@ class ProviderRequest: model: str | None = None """模型名称,为 None 时使用提供商的默认模型""" - def __repr__(self): + def __repr__(self) -> str: return ( f"ProviderRequest(prompt={self.prompt}, session_id={self.session_id}, " f"image_count={len(self.image_urls or [])}, " @@ -121,10 +121,10 @@ def __repr__(self): f"conversation_id={self.conversation.cid if self.conversation else 'N/A'}, " ) - def __str__(self): + def __str__(self) -> str: return self.__repr__() - def append_tool_calls_result(self, tool_calls_result: ToolCallsResult): + def append_tool_calls_result(self, tool_calls_result: ToolCallsResult) -> None: """添加工具调用结果到请求中""" if not self.tool_calls_result: self.tool_calls_result = [] @@ -309,7 +309,7 @@ def __init__( is_chunk: bool = False, id: str | None = None, usage: TokenUsage | None = None, - ): + ) -> None: """初始化 LLMResponse Args: @@ -356,7 +356,7 @@ def completion_text(self): return self._completion_text @completion_text.setter - def completion_text(self, value): + def completion_text(self, value) -> None: if self.result_chain: self.result_chain.chain = [ comp diff --git a/astrbot/core/provider/func_tool_manager.py b/astrbot/core/provider/func_tool_manager.py index 7aad86bdd..106b42cc5 100644 --- a/astrbot/core/provider/func_tool_manager.py +++ b/astrbot/core/provider/func_tool_manager.py @@ -500,7 +500,7 @@ def load_mcp_config(self): logger.error(f"加载 MCP 配置失败: {e}") return DEFAULT_MCP_CONFIG - def save_mcp_config(self, config: dict): + def save_mcp_config(self, config: dict) -> bool: try: with open(self.mcp_config_path, "w", encoding="utf-8") as f: json.dump(config, f, ensure_ascii=False, indent=4) @@ -575,10 +575,10 @@ async def sync_modelscope_mcp_servers(self, access_token: str) -> None: except Exception as e: raise Exception(f"同步 ModelScope MCP 服务器时发生错误: {e!s}") - def __str__(self): + def __str__(self) -> str: return str(self.func_list) - def __repr__(self): + def __repr__(self) -> str: return str(self.func_list) diff --git a/astrbot/core/provider/manager.py b/astrbot/core/provider/manager.py index 7ec8c36ff..a331c97e9 100644 --- a/astrbot/core/provider/manager.py +++ b/astrbot/core/provider/manager.py @@ -32,7 +32,7 @@ def __init__( acm: AstrBotConfigManager, db_helper: BaseDatabase, persona_mgr: PersonaManager, - ): + ) -> None: self.reload_lock = asyncio.Lock() self.resource_lock = asyncio.Lock() self.persona_mgr = persona_mgr @@ -92,7 +92,7 @@ async def set_provider( provider_id: str, provider_type: ProviderType, umo: str | None = None, - ): + ) -> None: """设置提供商。 Args: @@ -213,7 +213,7 @@ def get_using_provider( return provider - async def initialize(self): + async def initialize(self) -> None: # 逐个初始化提供商 for provider_config in self.providers_config: try: @@ -277,7 +277,7 @@ async def initialize(self): # 初始化 MCP Client 连接 asyncio.create_task(self.llm_tools.init_mcp_clients(), name="init_mcp_clients") - def dynamic_import_provider(self, type: str): + def dynamic_import_provider(self, type: str) -> None: """动态导入提供商适配器模块 Args: @@ -295,6 +295,16 @@ def dynamic_import_provider(self, type: str): from .sources.zhipu_source import ProviderZhipu as ProviderZhipu case "groq_chat_completion": from .sources.groq_source import ProviderGroq as ProviderGroq + case "xai_chat_completion": + from .sources.xai_source import ProviderXAI as ProviderXAI + case "aihubmix_chat_completion": + from .sources.oai_aihubmix_source import ( + ProviderAIHubMix as ProviderAIHubMix, + ) + case "openrouter_chat_completion": + from .sources.openrouter_source import ( + ProviderOpenRouter as ProviderOpenRouter, + ) case "anthropic_chat_completion": from .sources.anthropic_source import ( ProviderAnthropic as ProviderAnthropic, @@ -434,7 +444,7 @@ def _resolve_env_key_list(self, provider_config: dict) -> dict: provider_config["key"] = resolved_keys return provider_config - async def load_provider(self, provider_config: dict): + async def load_provider(self, provider_config: dict) -> None: # 如果 provider_source_id 存在且不为空,则从 provider_sources 中找到对应的配置并合并 provider_config = self.get_merged_provider_config(provider_config) @@ -591,7 +601,7 @@ async def load_provider(self, provider_config: dict): f"实例化 {provider_config['type']}({provider_config['id']}) 提供商适配器失败:{e}", ) - async def reload(self, provider_config: dict): + async def reload(self, provider_config: dict) -> None: async with self.reload_lock: await self.terminate_provider(provider_config["id"]) if provider_config["enable"]: @@ -637,7 +647,7 @@ async def reload(self, provider_config: dict): def get_insts(self): return self.provider_insts - async def terminate_provider(self, provider_id: str): + async def terminate_provider(self, provider_id: str) -> None: if provider_id in self.inst_map: logger.info( f"终止 {provider_id} 提供商适配器({len(self.provider_insts)}, {len(self.stt_provider_insts)}, {len(self.tts_provider_insts)}) ...", @@ -673,7 +683,7 @@ async def terminate_provider(self, provider_id: str): async def delete_provider( self, provider_id: str | None = None, provider_source_id: str | None = None - ): + ) -> None: """Delete provider and/or provider source from config and terminate the instances. Config will be saved after deletion.""" async with self.resource_lock: # delete from config @@ -693,7 +703,7 @@ async def delete_provider( config.save_config() logger.info(f"Provider {target_prov_ids} 已从配置中删除。") - async def update_provider(self, origin_provider_id: str, new_config: dict): + async def update_provider(self, origin_provider_id: str, new_config: dict) -> None: """Update provider config and reload the instance. Config will be saved after update.""" async with self.resource_lock: npid = new_config.get("id", None) @@ -717,7 +727,7 @@ async def update_provider(self, origin_provider_id: str, new_config: dict): # reload instance await self.reload(new_config) - async def create_provider(self, new_config: dict): + async def create_provider(self, new_config: dict) -> None: """Add new provider config and load the instance. Config will be saved after addition.""" async with self.resource_lock: npid = new_config.get("id", None) @@ -733,7 +743,7 @@ async def create_provider(self, new_config: dict): # load instance await self.load_provider(new_config) - async def terminate(self): + async def terminate(self) -> None: for provider_inst in self.provider_insts: if hasattr(provider_inst, "terminate"): await provider_inst.terminate() # type: ignore diff --git a/astrbot/core/provider/provider.py b/astrbot/core/provider/provider.py index 623ff508b..901efd005 100644 --- a/astrbot/core/provider/provider.py +++ b/astrbot/core/provider/provider.py @@ -32,7 +32,7 @@ def __init__(self, provider_config: dict) -> None: self.model_name = "" self.provider_config = provider_config - def set_model(self, model_name: str): + def set_model(self, model_name: str) -> None: """Set the current model name""" self.model_name = model_name @@ -54,7 +54,7 @@ def meta(self) -> ProviderMeta: ) return meta - async def test(self): + async def test(self) -> None: """test the provider is a raises: @@ -84,7 +84,7 @@ def get_keys(self) -> list[str]: return keys or [""] @abc.abstractmethod - def set_key(self, key: str): + def set_key(self, key: str) -> None: raise NotImplementedError @abc.abstractmethod @@ -157,7 +157,7 @@ async def text_chat_stream( yield None # type: ignore raise NotImplementedError() - async def pop_record(self, context: list): + async def pop_record(self, context: list) -> None: """弹出 context 第一条非系统提示词对话记录""" poped = 0 indexs_to_pop = [] @@ -188,7 +188,7 @@ def _ensure_message_to_dicts( return dicts - async def test(self, timeout: float = 45.0): + async def test(self, timeout: float = 45.0) -> None: await asyncio.wait_for( self.text_chat(prompt="REPLY `PONG` ONLY"), timeout=timeout, @@ -206,7 +206,7 @@ async def get_text(self, audio_url: str) -> str: """获取音频的文本""" raise NotImplementedError - async def test(self): + async def test(self) -> None: sample_audio_path = os.path.join( get_astrbot_path(), "samples", @@ -280,7 +280,7 @@ async def get_audio_stream( accumulated_text += text_part - async def test(self): + async def test(self) -> None: await self.get_audio("hi") @@ -305,7 +305,7 @@ def get_dim(self) -> int: """获取向量的维度""" ... - async def test(self): + async def test(self) -> None: await self.get_embedding("astrbot") async def get_embeddings_batch( @@ -335,7 +335,7 @@ async def get_embeddings_batch( completed_count = 0 total_count = len(texts) - async def process_batch(batch_idx: int, batch_texts: list[str]): + async def process_batch(batch_idx: int, batch_texts: list[str]) -> None: nonlocal completed_count async with semaphore: for attempt in range(max_retries): @@ -392,7 +392,7 @@ async def rerank( """获取查询和文档的重排序分数""" ... - async def test(self): + async def test(self) -> None: result = await self.rerank("Apple", documents=["apple", "banana"]) if not result: raise Exception("Rerank provider test failed, no results returned") diff --git a/astrbot/core/provider/sources/anthropic_source.py b/astrbot/core/provider/sources/anthropic_source.py index 566569e03..ec3c395a4 100644 --- a/astrbot/core/provider/sources/anthropic_source.py +++ b/astrbot/core/provider/sources/anthropic_source.py @@ -3,6 +3,7 @@ from collections.abc import AsyncGenerator import anthropic +import httpx from anthropic import AsyncAnthropic from anthropic.types import Message from anthropic.types.message_delta_usage import MessageDeltaUsage @@ -14,6 +15,11 @@ from astrbot.core.provider.entities import LLMResponse, TokenUsage from astrbot.core.provider.func_tool_manager import ToolSet from astrbot.core.utils.io import download_image_by_url +from astrbot.core.utils.network_utils import ( + create_proxy_client, + is_connection_error, + log_connection_failure, +) from ..register import register_provider_adapter @@ -27,29 +33,56 @@ def __init__( self, provider_config, provider_settings, + *, + use_api_key: bool = True, ) -> None: super().__init__( provider_config, provider_settings, ) - self.chosen_api_key: str = "" - self.api_keys: list = super().get_keys() - self.chosen_api_key = self.api_keys[0] if len(self.api_keys) > 0 else "" self.base_url = provider_config.get("api_base", "https://api.anthropic.com") self.timeout = provider_config.get("timeout", 120) if isinstance(self.timeout, str): self.timeout = int(self.timeout) + self.thinking_config = provider_config.get("anth_thinking_config", {}) + + if use_api_key: + self._init_api_key(provider_config) + + self.set_model(provider_config.get("model", "unknown")) + def _init_api_key(self, provider_config: dict) -> None: + self.chosen_api_key: str = "" + self.api_keys: list = super().get_keys() + self.chosen_api_key = self.api_keys[0] if len(self.api_keys) > 0 else "" self.client = AsyncAnthropic( api_key=self.chosen_api_key, timeout=self.timeout, base_url=self.base_url, + http_client=self._create_http_client(provider_config), ) - self.thinking_config = provider_config.get("anth_thinking_config", {}) - - self.set_model(provider_config.get("model", "unknown")) + def _create_http_client(self, provider_config: dict) -> httpx.AsyncClient | None: + """创建带代理的 HTTP 客户端""" + proxy = provider_config.get("proxy", "") + return create_proxy_client("Anthropic", proxy) + + def _apply_thinking_config(self, payloads: dict) -> None: + thinking_type = self.thinking_config.get("type", "") + if thinking_type == "adaptive": + payloads["thinking"] = {"type": "adaptive"} + effort = self.thinking_config.get("effort", "") + output_cfg = dict(payloads.get("output_config", {})) + if effort: + output_cfg["effort"] = effort + if output_cfg: + payloads["output_config"] = output_cfg + elif not thinking_type and self.thinking_config.get("budget"): + payloads["thinking"] = { + "budget_tokens": self.thinking_config.get("budget"), + "type": "enabled", + } def _prepare_payload(self, messages: list[dict]): """准备 Anthropic API 的请求 payload @@ -201,15 +234,21 @@ async def _query(self, payloads: dict, tools: ToolSet | None) -> LLMResponse: if "max_tokens" not in payloads: payloads["max_tokens"] = 1024 - if self.thinking_config.get("budget"): - payloads["thinking"] = { - "budget_tokens": self.thinking_config.get("budget"), - "type": "enabled", - } + self._apply_thinking_config(payloads) - completion = await self.client.messages.create( - **payloads, stream=False, extra_body=extra_body - ) + try: + completion = await self.client.messages.create( + **payloads, stream=False, extra_body=extra_body + ) + except httpx.RequestError as e: + proxy = self.provider_config.get("proxy", "") + log_connection_failure("Anthropic", e, proxy) + raise + except Exception as e: + if is_connection_error(e): + proxy = self.provider_config.get("proxy", "") + log_connection_failure("Anthropic", e, proxy) + raise assert isinstance(completion, Message) logger.debug(f"completion: {completion}") @@ -265,11 +304,7 @@ async def _query_stream( if "max_tokens" not in payloads: payloads["max_tokens"] = 1024 - if self.thinking_config.get("budget"): - payloads["thinking"] = { - "budget_tokens": self.thinking_config.get("budget"), - "type": "enabled", - } + self._apply_thinking_config(payloads) async with self.client.messages.stream( **payloads, extra_body=extra_body @@ -620,5 +655,9 @@ async def get_models(self) -> list[str]: models_str.append(model.id) return models_str - def set_key(self, key: str): + def set_key(self, key: str) -> None: self.chosen_api_key = key + + async def terminate(self): + if self.client: + await self.client.close() diff --git a/astrbot/core/provider/sources/azure_tts_source.py b/astrbot/core/provider/sources/azure_tts_source.py index 2ccf146ca..0e8f00ce5 100644 --- a/astrbot/core/provider/sources/azure_tts_source.py +++ b/astrbot/core/provider/sources/azure_tts_source.py @@ -10,18 +10,20 @@ from httpx import AsyncClient, Timeout +from astrbot import logger from astrbot.core.config.default import VERSION +from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from ..entities import ProviderType from ..provider import TTSProvider from ..register import register_provider_adapter -TEMP_DIR = Path("data/temp/azure_tts") +TEMP_DIR = Path(get_astrbot_temp_path()) / "azure_tts" TEMP_DIR.mkdir(parents=True, exist_ok=True) class OTTSProvider: - def __init__(self, config: dict): + def __init__(self, config: dict) -> None: self.skey = config["OTTS_SKEY"] self.api_url = config["OTTS_URL"] self.auth_time_url = config["OTTS_AUTH_TIME"] @@ -29,6 +31,9 @@ def __init__(self, config: dict): self.last_sync_time = 0 self.timeout = Timeout(10.0) self.retry_count = 3 + self.proxy = config.get("proxy", "") + if self.proxy: + logger.info(f"[Azure TTS] 使用代理: {self.proxy}") self._client: AsyncClient | None = None @property @@ -40,7 +45,9 @@ def client(self) -> AsyncClient: return self._client async def __aenter__(self): - self._client = AsyncClient(timeout=self.timeout) + self._client = AsyncClient( + timeout=self.timeout, proxy=self.proxy if self.proxy else None + ) return self async def __aexit__(self, exc_type, exc_val, exc_tb): @@ -48,7 +55,7 @@ async def __aexit__(self, exc_type, exc_val, exc_tb): await self._client.aclose() self._client = None - async def _sync_time(self): + async def _sync_time(self) -> None: try: response = await self.client.get(self.auth_time_url) response.raise_for_status() @@ -103,7 +110,7 @@ async def get_audio(self, text: str, voice_params: dict) -> str: class AzureNativeProvider(TTSProvider): - def __init__(self, provider_config: dict, provider_settings: dict): + def __init__(self, provider_config: dict, provider_settings: dict) -> None: super().__init__(provider_config, provider_settings) self.subscription_key = provider_config.get( "azure_tts_subscription_key", @@ -125,6 +132,9 @@ def __init__(self, provider_config: dict, provider_settings: dict): "rate": provider_config.get("azure_tts_rate", "1"), "volume": provider_config.get("azure_tts_volume", "100"), } + self.proxy = provider_config.get("proxy", "") + if self.proxy: + logger.info(f"[Azure TTS Native] 使用代理: {self.proxy}") @property def client(self) -> AsyncClient: @@ -141,6 +151,7 @@ async def __aenter__(self): "Content-Type": "application/ssml+xml", "X-Microsoft-OutputFormat": "riff-48khz-16bit-mono-pcm", }, + proxy=self.proxy if self.proxy else None, ) return self @@ -149,7 +160,7 @@ async def __aexit__(self, exc_type, exc_val, exc_tb): await self._client.aclose() self._client = None - async def _refresh_token(self): + async def _refresh_token(self) -> None: token_url = ( f"https://{self.region}.api.cognitive.microsoft.com/sts/v1.0/issuetoken" ) @@ -195,7 +206,7 @@ async def get_audio(self, text: str) -> str: @register_provider_adapter("azure_tts", "Azure TTS", ProviderType.TEXT_TO_SPEECH) class AzureTTSProvider(TTSProvider): - def __init__(self, provider_config: dict, provider_settings: dict): + def __init__(self, provider_config: dict, provider_settings: dict) -> None: super().__init__(provider_config, provider_settings) key_value = provider_config.get("azure_tts_subscription_key", "") self.provider = self._parse_provider(key_value, provider_config) diff --git a/astrbot/core/provider/sources/dashscope_tts.py b/astrbot/core/provider/sources/dashscope_tts.py index 50bc421fd..9b6816859 100644 --- a/astrbot/core/provider/sources/dashscope_tts.py +++ b/astrbot/core/provider/sources/dashscope_tts.py @@ -15,7 +15,7 @@ ): # pragma: no cover - older dashscope versions without Qwen TTS support MultiModalConversation = None -from astrbot.core.utils.astrbot_path import get_astrbot_data_path +from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from ..entities import ProviderType from ..provider import TTSProvider @@ -45,7 +45,7 @@ async def get_audio(self, text: str) -> str: if not model: raise RuntimeError("Dashscope TTS model is not configured.") - temp_dir = os.path.join(get_astrbot_data_path(), "temp") + temp_dir = get_astrbot_temp_path() os.makedirs(temp_dir, exist_ok=True) if self._is_qwen_tts_model(model): diff --git a/astrbot/core/provider/sources/edge_tts_source.py b/astrbot/core/provider/sources/edge_tts_source.py index 71a5a82d6..503bd275b 100644 --- a/astrbot/core/provider/sources/edge_tts_source.py +++ b/astrbot/core/provider/sources/edge_tts_source.py @@ -6,7 +6,7 @@ import edge_tts from astrbot.core import logger -from astrbot.core.utils.astrbot_path import get_astrbot_data_path +from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from ..entities import ProviderType from ..provider import TTSProvider @@ -46,7 +46,7 @@ def __init__( self.set_model("edge_tts") async def get_audio(self, text: str) -> str: - temp_dir = os.path.join(get_astrbot_data_path(), "temp") + temp_dir = get_astrbot_temp_path() mp3_path = os.path.join(temp_dir, f"edge_tts_temp_{uuid.uuid4()}.mp3") wav_path = os.path.join(temp_dir, f"edge_tts_{uuid.uuid4()}.wav") diff --git a/astrbot/core/provider/sources/fishaudio_tts_api_source.py b/astrbot/core/provider/sources/fishaudio_tts_api_source.py index 70eabd289..35945b7b6 100644 --- a/astrbot/core/provider/sources/fishaudio_tts_api_source.py +++ b/astrbot/core/provider/sources/fishaudio_tts_api_source.py @@ -7,7 +7,8 @@ from httpx import AsyncClient from pydantic import BaseModel, conint -from astrbot.core.utils.astrbot_path import get_astrbot_data_path +from astrbot import logger +from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from ..entities import ProviderType from ..provider import TTSProvider @@ -60,6 +61,9 @@ def __init__( self.timeout: int = int(provider_config.get("timeout", 20)) except ValueError: self.timeout = 20 + self.proxy: str = provider_config.get("proxy", "") + if self.proxy: + logger.info(f"[FishAudio TTS] 使用代理: {self.proxy}") self.headers = { "Authorization": f"Bearer {self.chosen_api_key}", } @@ -79,7 +83,10 @@ async def _get_reference_id_by_character(self, character: str) -> str | None: """ sort_options = ["score", "task_count", "created_at"] - async with AsyncClient(base_url=self.api_base.replace("/v1", "")) as client: + async with AsyncClient( + base_url=self.api_base.replace("/v1", ""), + proxy=self.proxy if self.proxy else None, + ) as client: for sort_by in sort_options: params = {"title": character, "sort_by": sort_by} response = await client.get( @@ -135,11 +142,15 @@ async def _generate_request(self, text: str) -> ServeTTSRequest: ) async def get_audio(self, text: str) -> str: - temp_dir = os.path.join(get_astrbot_data_path(), "temp") + temp_dir = get_astrbot_temp_path() path = os.path.join(temp_dir, f"fishaudio_tts_api_{uuid.uuid4()}.wav") self.headers["content-type"] = "application/msgpack" request = await self._generate_request(text) - async with AsyncClient(base_url=self.api_base, timeout=self.timeout).stream( + async with AsyncClient( + base_url=self.api_base, + timeout=self.timeout, + proxy=self.proxy if self.proxy else None, + ).stream( "POST", "/tts", headers=self.headers, diff --git a/astrbot/core/provider/sources/gemini_embedding_source.py b/astrbot/core/provider/sources/gemini_embedding_source.py index 01046bebb..61ba9cadb 100644 --- a/astrbot/core/provider/sources/gemini_embedding_source.py +++ b/astrbot/core/provider/sources/gemini_embedding_source.py @@ -4,6 +4,8 @@ from google.genai import types from google.genai.errors import APIError +from astrbot import logger + from ..entities import ProviderType from ..provider import EmbeddingProvider from ..register import register_provider_adapter @@ -28,6 +30,10 @@ def __init__(self, provider_config: dict, provider_settings: dict) -> None: if api_base: api_base = api_base.removesuffix("/") http_options.base_url = api_base + proxy = provider_config.get("proxy", "") + if proxy: + http_options.async_client_args = {"proxy": proxy} + logger.info(f"[Gemini Embedding] 使用代理: {proxy}") self.client = genai.Client(api_key=api_key, http_options=http_options).aio @@ -42,6 +48,9 @@ async def get_embedding(self, text: str) -> list[float]: result = await self.client.models.embed_content( model=self.model, contents=text, + config=types.EmbedContentConfig( + output_dimensionality=self.get_dim(), + ), ) assert result.embeddings is not None assert result.embeddings[0].values is not None @@ -55,6 +64,9 @@ async def get_embeddings(self, text: list[str]) -> list[list[float]]: result = await self.client.models.embed_content( model=self.model, contents=cast(types.ContentListUnion, text), + config=types.EmbedContentConfig( + output_dimensionality=self.get_dim(), + ), ) assert result.embeddings is not None @@ -69,3 +81,7 @@ async def get_embeddings(self, text: list[str]) -> list[list[float]]: def get_dim(self) -> int: """获取向量的维度""" return int(self.provider_config.get("embedding_dimensions", 768)) + + async def terminate(self): + if self.client: + await self.client.aclose() diff --git a/astrbot/core/provider/sources/gemini_source.py b/astrbot/core/provider/sources/gemini_source.py index c53a570a1..9557f3dbc 100644 --- a/astrbot/core/provider/sources/gemini_source.py +++ b/astrbot/core/provider/sources/gemini_source.py @@ -18,6 +18,7 @@ from astrbot.core.provider.entities import LLMResponse, TokenUsage from astrbot.core.provider.func_tool_manager import ToolSet from astrbot.core.utils.io import download_image_by_url +from astrbot.core.utils.network_utils import is_connection_error, log_connection_failure from ..register import register_provider_adapter @@ -74,12 +75,17 @@ def __init__( def _init_client(self) -> None: """初始化Gemini客户端""" + proxy = self.provider_config.get("proxy", "") + http_options = types.HttpOptions( + base_url=self.api_base, + timeout=self.timeout * 1000, # 毫秒 + ) + if proxy: + http_options.async_client_args = {"proxy": proxy} + logger.info(f"[Gemini] 使用代理: {proxy}") self.client = genai.Client( api_key=self.chosen_api_key, - http_options=types.HttpOptions( - base_url=self.api_base, - timeout=self.timeout * 1000, # 毫秒 - ), + http_options=http_options, ).aio def _init_safety_settings(self) -> None: @@ -113,9 +119,12 @@ async def _handle_api_error(self, e: APIError, keys: list[str]) -> bool: f"检测到 Key 异常({e.message}),且已没有可用的 Key。 当前 Key: {self.chosen_api_key[:12]}...", ) raise Exception("达到了 Gemini 速率限制, 请稍后再试...") - # logger.error( - # f"发生了错误(gemini_source)。Provider 配置如下: {self.provider_config}", - # ) + + # 连接错误处理 + if is_connection_error(e): + proxy = self.provider_config.get("proxy", "") + log_connection_failure("Gemini", e, proxy) + raise e async def _prepare_query_config( @@ -837,7 +846,7 @@ def get_current_key(self) -> str: def get_keys(self) -> list[str]: return self.api_keys - def set_key(self, key): + def set_key(self, key) -> None: self.chosen_api_key = key self._init_client() @@ -919,5 +928,6 @@ async def encode_image_bs64(self, image_url: str) -> str: image_bs64 = base64.b64encode(f.read()).decode("utf-8") return "data:image/jpeg;base64," + image_bs64 - async def terminate(self): - logger.info("Google GenAI 适配器已终止。") + async def terminate(self) -> None: + if self.client: + await self.client.aclose() diff --git a/astrbot/core/provider/sources/gemini_tts_source.py b/astrbot/core/provider/sources/gemini_tts_source.py index 0bf92b325..d6954ef82 100644 --- a/astrbot/core/provider/sources/gemini_tts_source.py +++ b/astrbot/core/provider/sources/gemini_tts_source.py @@ -5,7 +5,8 @@ from google import genai from google.genai import types -from astrbot.core.utils.astrbot_path import get_astrbot_data_path +from astrbot import logger +from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from ..entities import ProviderType from ..provider import TTSProvider @@ -32,6 +33,10 @@ def __init__( if api_base: api_base = api_base.removesuffix("/") http_options.base_url = api_base + proxy = provider_config.get("proxy", "") + if proxy: + http_options.async_client_args = {"proxy": proxy} + logger.info(f"[Gemini TTS] 使用代理: {proxy}") self.client = genai.Client(api_key=api_key, http_options=http_options).aio self.model: str = provider_config.get( @@ -44,7 +49,7 @@ def __init__( self.voice_name: str = provider_config.get("gemini_tts_voice_name", "Leda") async def get_audio(self, text: str) -> str: - temp_dir = os.path.join(get_astrbot_data_path(), "temp") + temp_dir = get_astrbot_temp_path() path = os.path.join(temp_dir, f"gemini_tts_{uuid.uuid4()}.wav") prompt = f"{self.prefix}: {text}" if self.prefix else text response = await self.client.models.generate_content( @@ -79,3 +84,7 @@ async def get_audio(self, text: str) -> str: wf.writeframes(response.candidates[0].content.parts[0].inline_data.data) return path + + async def terminate(self): + if self.client: + await self.client.aclose() diff --git a/astrbot/core/provider/sources/genie_tts.py b/astrbot/core/provider/sources/genie_tts.py index 36436919c..8f9b6d91d 100644 --- a/astrbot/core/provider/sources/genie_tts.py +++ b/astrbot/core/provider/sources/genie_tts.py @@ -6,7 +6,7 @@ from astrbot.core.provider.entities import ProviderType from astrbot.core.provider.provider import TTSProvider from astrbot.core.provider.register import register_provider_adapter -from astrbot.core.utils.astrbot_path import get_astrbot_data_path +from astrbot.core.utils.astrbot_path import get_astrbot_temp_path try: import genie_tts as genie # type: ignore @@ -54,14 +54,14 @@ def support_stream(self) -> bool: return True async def get_audio(self, text: str) -> str: - temp_dir = os.path.join(get_astrbot_data_path(), "temp") + temp_dir = get_astrbot_temp_path() os.makedirs(temp_dir, exist_ok=True) filename = f"genie_tts_{uuid.uuid4()}.wav" path = os.path.join(temp_dir, filename) loop = asyncio.get_event_loop() - def _generate(save_path: str): + def _generate(save_path: str) -> None: assert genie is not None genie.tts( character_name=self.character_name, @@ -94,12 +94,12 @@ async def get_audio_stream( break try: - temp_dir = os.path.join(get_astrbot_data_path(), "temp") + temp_dir = get_astrbot_temp_path() os.makedirs(temp_dir, exist_ok=True) filename = f"genie_tts_{uuid.uuid4()}.wav" path = os.path.join(temp_dir, filename) - def _generate(save_path: str, t: str): + def _generate(save_path: str, t: str) -> None: assert genie is not None genie.tts( character_name=self.character_name, diff --git a/astrbot/core/provider/sources/gsv_selfhosted_source.py b/astrbot/core/provider/sources/gsv_selfhosted_source.py index 7f8d39eac..fc8bccea8 100644 --- a/astrbot/core/provider/sources/gsv_selfhosted_source.py +++ b/astrbot/core/provider/sources/gsv_selfhosted_source.py @@ -5,7 +5,7 @@ import aiohttp from astrbot import logger -from astrbot.core.utils.astrbot_path import get_astrbot_data_path +from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from ..entities import ProviderType from ..provider import TTSProvider @@ -39,7 +39,7 @@ def __init__( self.timeout = provider_config.get("timeout", 60) self._session: aiohttp.ClientSession | None = None - async def initialize(self): + async def initialize(self) -> None: """异步初始化:在 ProviderManager 中被调用""" self._session = aiohttp.ClientSession( timeout=aiohttp.ClientTimeout(total=self.timeout), @@ -85,7 +85,7 @@ async def _make_request( logger.error(f"[GSV TTS] 请求 {endpoint} 最终失败:{e}") raise - async def _set_model_weights(self): + async def _set_model_weights(self) -> None: """设置模型路径""" try: if self.gpt_weights_path: @@ -121,7 +121,7 @@ async def get_audio(self, text: str) -> str: params = self.build_synthesis_params(text) - temp_dir = os.path.join(get_astrbot_data_path(), "temp") + temp_dir = get_astrbot_temp_path() os.makedirs(temp_dir, exist_ok=True) path = os.path.join(temp_dir, f"gsv_tts_{uuid.uuid4().hex}.wav") @@ -144,7 +144,7 @@ def build_synthesis_params(self, text: str) -> dict: # TODO: 在此处添加情绪分析,例如 params["emotion"] = detect_emotion(text) return params - async def terminate(self): + async def terminate(self) -> None: """终止释放资源:在 ProviderManager 中被调用""" if self._session and not self._session.closed: await self._session.close() diff --git a/astrbot/core/provider/sources/gsvi_tts_source.py b/astrbot/core/provider/sources/gsvi_tts_source.py index d8b171718..425e801f4 100644 --- a/astrbot/core/provider/sources/gsvi_tts_source.py +++ b/astrbot/core/provider/sources/gsvi_tts_source.py @@ -4,7 +4,7 @@ import aiohttp -from astrbot.core.utils.astrbot_path import get_astrbot_data_path +from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from ..entities import ProviderType from ..provider import TTSProvider @@ -29,7 +29,7 @@ def __init__( self.emotion = provider_config.get("emotion") async def get_audio(self, text: str) -> str: - temp_dir = os.path.join(get_astrbot_data_path(), "temp") + temp_dir = get_astrbot_temp_path() path = os.path.join(temp_dir, f"gsvi_tts_{uuid.uuid4()}.wav") params = {"text": text} diff --git a/astrbot/core/provider/sources/minimax_tts_api_source.py b/astrbot/core/provider/sources/minimax_tts_api_source.py index dcd29060e..69860111c 100644 --- a/astrbot/core/provider/sources/minimax_tts_api_source.py +++ b/astrbot/core/provider/sources/minimax_tts_api_source.py @@ -6,7 +6,7 @@ import aiohttp from astrbot.api import logger -from astrbot.core.utils.astrbot_path import get_astrbot_data_path +from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from ..entities import ProviderType from ..provider import TTSProvider @@ -145,7 +145,7 @@ async def _audio_play(self, audio_stream: AsyncIterator[str]) -> bytes: return b"".join(chunks) async def get_audio(self, text: str) -> str: - temp_dir = os.path.join(get_astrbot_data_path(), "temp") + temp_dir = get_astrbot_temp_path() os.makedirs(temp_dir, exist_ok=True) path = os.path.join(temp_dir, f"minimax_tts_api_{uuid.uuid4()}.mp3") diff --git a/astrbot/core/provider/sources/oai_aihubmix_source.py b/astrbot/core/provider/sources/oai_aihubmix_source.py new file mode 100644 index 000000000..ca8ad5959 --- /dev/null +++ b/astrbot/core/provider/sources/oai_aihubmix_source.py @@ -0,0 +1,17 @@ +from ..register import register_provider_adapter +from .openai_source import ProviderOpenAIOfficial + + +@register_provider_adapter( + "aihubmix_chat_completion", "AIHubMix Chat Completion Provider Adapter" +) +class ProviderAIHubMix(ProviderOpenAIOfficial): + def __init__( + self, + provider_config: dict, + provider_settings: dict, + ) -> None: + super().__init__(provider_config, provider_settings) + # Reference to: https://aihubmix.com/appstore + # Use this code can enjoy 10% off prices for AIHubMix API calls. + self.client._custom_headers["APP-Code"] = "KRLC5702" # type: ignore diff --git a/astrbot/core/provider/sources/openai_embedding_source.py b/astrbot/core/provider/sources/openai_embedding_source.py index ad20dd3df..8bf92ef4d 100644 --- a/astrbot/core/provider/sources/openai_embedding_source.py +++ b/astrbot/core/provider/sources/openai_embedding_source.py @@ -1,5 +1,8 @@ +import httpx from openai import AsyncOpenAI +from astrbot import logger + from ..entities import ProviderType from ..provider import EmbeddingProvider from ..register import register_provider_adapter @@ -15,26 +18,48 @@ def __init__(self, provider_config: dict, provider_settings: dict) -> None: super().__init__(provider_config, provider_settings) self.provider_config = provider_config self.provider_settings = provider_settings + proxy = provider_config.get("proxy", "") + http_client = None + if proxy: + logger.info(f"[OpenAI Embedding] 使用代理: {proxy}") + http_client = httpx.AsyncClient(proxy=proxy) + api_base = provider_config.get("embedding_api_base", "").strip() + if not api_base: + api_base = "https://api.openai.com/v1" + else: + api_base = api_base.removesuffix("/") + if not api_base.endswith("/v1"): + api_base = f"{api_base}/v1" self.client = AsyncOpenAI( api_key=provider_config.get("embedding_api_key"), - base_url=provider_config.get( - "embedding_api_base", - "https://api.openai.com/v1", - ), + base_url=api_base, timeout=int(provider_config.get("timeout", 20)), + http_client=http_client, ) self.model = provider_config.get("embedding_model", "text-embedding-3-small") async def get_embedding(self, text: str) -> list[float]: """获取文本的嵌入""" - embedding = await self.client.embeddings.create(input=text, model=self.model) + embedding = await self.client.embeddings.create( + input=text, + model=self.model, + dimensions=self.get_dim(), + ) return embedding.data[0].embedding async def get_embeddings(self, text: list[str]) -> list[list[float]]: """批量获取文本的嵌入""" - embeddings = await self.client.embeddings.create(input=text, model=self.model) + embeddings = await self.client.embeddings.create( + input=text, + model=self.model, + dimensions=self.get_dim(), + ) return [item.embedding for item in embeddings.data] def get_dim(self) -> int: """获取向量的维度""" return int(self.provider_config.get("embedding_dimensions", 1024)) + + async def terminate(self): + if self.client: + await self.client.close() diff --git a/astrbot/core/provider/sources/openai_source.py b/astrbot/core/provider/sources/openai_source.py index 2544782f4..adee24073 100644 --- a/astrbot/core/provider/sources/openai_source.py +++ b/astrbot/core/provider/sources/openai_source.py @@ -2,11 +2,12 @@ import base64 import inspect import json -import os import random import re from collections.abc import AsyncGenerator +from typing import Any +import httpx from openai import AsyncAzureOpenAI, AsyncOpenAI from openai._exceptions import NotFoundError from openai.lib.streaming.chat._completions import ChatCompletionStreamState @@ -22,6 +23,12 @@ from astrbot.core.message.message_event_result import MessageChain from astrbot.core.provider.entities import LLMResponse, TokenUsage, ToolCallsResult from astrbot.core.utils.io import download_image_by_url +from astrbot.core.utils.network_utils import ( + create_proxy_client, + is_connection_error, + log_connection_failure, +) +from astrbot.core.utils.string_utils import normalize_and_dedupe_strings from ..register import register_provider_adapter @@ -31,6 +38,133 @@ "OpenAI API Chat Completion 提供商适配器", ) class ProviderOpenAIOfficial(Provider): + _ERROR_TEXT_CANDIDATE_MAX_CHARS = 4096 + + @classmethod + def _truncate_error_text_candidate(cls, text: str) -> str: + if len(text) <= cls._ERROR_TEXT_CANDIDATE_MAX_CHARS: + return text + return text[: cls._ERROR_TEXT_CANDIDATE_MAX_CHARS] + + @staticmethod + def _safe_json_dump(value: Any) -> str | None: + try: + return json.dumps(value, ensure_ascii=False, default=str) + except Exception: + return None + + def _get_image_moderation_error_patterns(self) -> list[str]: + """Return configured moderation patterns (case-insensitive substring match, not regex).""" + configured = self.provider_config.get("image_moderation_error_patterns", []) + patterns: list[str] = [] + if isinstance(configured, str): + configured = [configured] + if isinstance(configured, list): + for pattern in configured: + if not isinstance(pattern, str): + continue + pattern = pattern.strip() + if pattern: + patterns.append(pattern) + return patterns + + @staticmethod + def _extract_error_text_candidates(error: Exception) -> list[str]: + candidates: list[str] = [] + + def _append_candidate(candidate: Any): + if candidate is None: + return + text = str(candidate).strip() + if not text: + return + candidates.append( + ProviderOpenAIOfficial._truncate_error_text_candidate(text) + ) + + _append_candidate(str(error)) + + body = getattr(error, "body", None) + if isinstance(body, dict): + err_obj = body.get("error") + body_text = ProviderOpenAIOfficial._safe_json_dump( + {"error": err_obj} if isinstance(err_obj, dict) else body + ) + _append_candidate(body_text) + if isinstance(err_obj, dict): + for field in ("message", "type", "code", "param"): + value = err_obj.get(field) + if value is not None: + _append_candidate(value) + elif isinstance(body, str): + _append_candidate(body) + + response = getattr(error, "response", None) + if response is not None: + response_text = getattr(response, "text", None) + if isinstance(response_text, str): + _append_candidate(response_text) + + return normalize_and_dedupe_strings(candidates) + + def _is_content_moderated_upload_error(self, error: Exception) -> bool: + patterns = [ + pattern.lower() for pattern in self._get_image_moderation_error_patterns() + ] + if not patterns: + return False + candidates = [ + candidate.lower() + for candidate in self._extract_error_text_candidates(error) + ] + for pattern in patterns: + if any(pattern in candidate for candidate in candidates): + return True + return False + + @staticmethod + def _context_contains_image(contexts: list[dict]) -> bool: + for context in contexts: + content = context.get("content") + if not isinstance(content, list): + continue + for item in content: + if isinstance(item, dict) and item.get("type") == "image_url": + return True + return False + + async def _fallback_to_text_only_and_retry( + self, + payloads: dict, + context_query: list, + chosen_key: str, + available_api_keys: list[str], + func_tool: ToolSet | None, + reason: str, + *, + image_fallback_used: bool = False, + ) -> tuple: + logger.warning( + "检测到图片请求失败(%s),已移除图片并重试(保留文本内容)。", + reason, + ) + new_contexts = await self._remove_image_from_context(context_query) + payloads["messages"] = new_contexts + return ( + False, + chosen_key, + available_api_keys, + payloads, + new_contexts, + func_tool, + image_fallback_used, + ) + + def _create_http_client(self, provider_config: dict) -> httpx.AsyncClient | None: + """创建带代理的 HTTP 客户端""" + proxy = provider_config.get("proxy", "") + return create_proxy_client("OpenAI", proxy) + def __init__(self, provider_config, provider_settings) -> None: super().__init__(provider_config, provider_settings) self.chosen_api_key = None @@ -55,6 +189,7 @@ def __init__(self, provider_config, provider_settings) -> None: default_headers=self.custom_headers, base_url=provider_config.get("api_base", ""), timeout=self.timeout, + http_client=self._create_http_client(provider_config), ) else: # Using OpenAI Official API @@ -63,6 +198,7 @@ def __init__(self, provider_config, provider_settings) -> None: base_url=provider_config.get("api_base", None), default_headers=self.custom_headers, timeout=self.timeout, + http_client=self._create_http_client(provider_config), ) self.default_params = inspect.signature( @@ -187,7 +323,8 @@ async def _query_stream( llm_response.reasoning_content = reasoning _y = True if delta.content: - completion_text = delta.content + # Don't strip streaming chunks to preserve spaces between words + completion_text = self._normalize_content(delta.content, strip=False) llm_response.result_chain = MessageChain( chain=[Comp.Plain(completion_text)], ) @@ -235,6 +372,96 @@ def _extract_usage(self, usage: CompletionUsage) -> TokenUsage: output=completion_tokens, ) + @staticmethod + def _normalize_content(raw_content: Any, strip: bool = True) -> str: + """Normalize content from various formats to plain string. + + Some LLM providers return content as list[dict] format + like [{'type': 'text', 'text': '...'}] instead of + plain string. This method handles both formats. + + Args: + raw_content: The raw content from LLM response, can be str, list, dict, or other. + strip: Whether to strip whitespace from the result. Set to False for + streaming chunks to preserve spaces between words. + + Returns: + Normalized plain text string. + """ + # Handle dict format (e.g., {"type": "text", "text": "..."}) + if isinstance(raw_content, dict): + if "text" in raw_content: + text_val = raw_content.get("text", "") + return str(text_val) if text_val is not None else "" + # For other dict formats, return empty string and log + logger.warning(f"Unexpected dict format content: {raw_content}") + return "" + + if isinstance(raw_content, list): + # Check if this looks like OpenAI content-part format + # Only process if at least one item has {'type': 'text', 'text': ...} structure + has_content_part = any( + isinstance(part, dict) and part.get("type") == "text" + for part in raw_content + ) + if has_content_part: + text_parts = [] + for part in raw_content: + if isinstance(part, dict) and part.get("type") == "text": + text_val = part.get("text", "") + # Coerce to str in case text is null or non-string + text_parts.append(str(text_val) if text_val is not None else "") + return "".join(text_parts) + # Not content-part format, return string representation + return str(raw_content) + + if isinstance(raw_content, str): + content = raw_content.strip() if strip else raw_content + # Check if the string is a JSON-encoded list (e.g., "[{'type': 'text', ...}]") + # This can happen when streaming concatenates content that was originally list format + # Only check if it looks like a complete JSON array (requires strip for check) + check_content = raw_content.strip() + if ( + check_content.startswith("[") + and check_content.endswith("]") + and len(check_content) < 8192 + ): + try: + # First try standard JSON parsing + parsed = json.loads(check_content) + except json.JSONDecodeError: + # If that fails, try parsing as Python literal (handles single quotes) + # This is safer than blind replace("'", '"') which corrupts apostrophes + try: + import ast + + parsed = ast.literal_eval(check_content) + except (ValueError, SyntaxError): + parsed = None + + if isinstance(parsed, list): + # Only convert if it matches OpenAI content-part schema + # i.e., at least one item has {'type': 'text', 'text': ...} + has_content_part = any( + isinstance(part, dict) and part.get("type") == "text" + for part in parsed + ) + if has_content_part: + text_parts = [] + for part in parsed: + if isinstance(part, dict) and part.get("type") == "text": + text_val = part.get("text", "") + # Coerce to str in case text is null or non-string + text_parts.append( + str(text_val) if text_val is not None else "" + ) + if text_parts: + return "".join(text_parts) + return content + + # Fallback for other types (int, float, etc.) + return str(raw_content) if raw_content is not None else "" + async def _parse_openai_completion( self, completion: ChatCompletion, tools: ToolSet | None ) -> LLMResponse: @@ -247,8 +474,7 @@ async def _parse_openai_completion( # parse the text completion if choice.message.content is not None: - # text completion - completion_text = str(choice.message.content).strip() + completion_text = self._normalize_content(choice.message.content) # specially, some providers may set tags around reasoning content in the completion text, # we use regex to remove them, and store then in reasoning_content field reasoning_pattern = re.compile(r"(.*?)", re.DOTALL) @@ -258,6 +484,8 @@ async def _parse_openai_completion( [match.strip() for match in matches], ) completion_text = reasoning_pattern.sub("", completion_text).strip() + # Also clean up orphan tags that may leak from some models + completion_text = re.sub(r"\s*$", "", completion_text).strip() llm_response.result_chain = MessageChain().message(completion_text) # parse the reasoning content if any @@ -363,7 +591,7 @@ async def _prepare_chat_payload( return payloads, context_query - def _finally_convert_payload(self, payloads: dict): + def _finally_convert_payload(self, payloads: dict) -> None: """Finally convert the payload. Such as think part conversion, tool inject.""" for message in payloads.get("messages", []): if message.get("role") == "assistant" and isinstance( @@ -391,6 +619,7 @@ async def _handle_api_error( available_api_keys: list[str], retry_cnt: int, max_retries: int, + image_fallback_used: bool = False, ) -> tuple: """处理API错误并尝试恢复""" if "429" in str(e): @@ -410,6 +639,7 @@ async def _handle_api_error( payloads, context_query, func_tool, + image_fallback_used, ) raise e if "maximum context length" in str(e): @@ -425,20 +655,34 @@ async def _handle_api_error( payloads, context_query, func_tool, + image_fallback_used, ) if "The model is not a VLM" in str(e): # siliconcloud + if image_fallback_used or not self._context_contains_image(context_query): + raise e # 尝试删除所有 image - new_contexts = await self._remove_image_from_context(context_query) - payloads["messages"] = new_contexts - context_query = new_contexts - return ( - False, + return await self._fallback_to_text_only_and_retry( + payloads, + context_query, chosen_key, available_api_keys, + func_tool, + "model_not_vlm", + image_fallback_used=True, + ) + if self._is_content_moderated_upload_error(e): + if image_fallback_used or not self._context_contains_image(context_query): + raise e + return await self._fallback_to_text_only_and_retry( payloads, context_query, + chosen_key, + available_api_keys, func_tool, + "image_content_moderated", + image_fallback_used=True, ) + if ( "Function calling is not enabled" in str(e) or ("tool" in str(e).lower() and "support" in str(e).lower()) @@ -449,18 +693,23 @@ async def _handle_api_error( f"{self.get_model()} 不支持函数工具调用,已自动去除,不影响使用。", ) payloads.pop("tools", None) - return False, chosen_key, available_api_keys, payloads, context_query, None + return ( + False, + chosen_key, + available_api_keys, + payloads, + context_query, + None, + image_fallback_used, + ) # logger.error(f"发生了错误。Provider 配置如下: {self.provider_config}") if "tool" in str(e).lower() and "support" in str(e).lower(): logger.error("疑似该模型不支持函数调用工具调用。请输入 /tool off_all") - if "Connection error." in str(e): - proxy = os.environ.get("http_proxy", None) - if proxy: - logger.error( - f"可能为代理原因,请检查代理是否正常。当前代理: {proxy}", - ) + if is_connection_error(e): + proxy = self.provider_config.get("proxy", "") + log_connection_failure("OpenAI", e, proxy) raise e @@ -492,6 +741,7 @@ async def text_chat( max_retries = 10 available_api_keys = self.api_keys.copy() chosen_key = random.choice(available_api_keys) + image_fallback_used = False last_exception = None retry_cnt = 0 @@ -509,6 +759,7 @@ async def text_chat( payloads, context_query, func_tool, + image_fallback_used, ) = await self._handle_api_error( e, payloads, @@ -518,6 +769,7 @@ async def text_chat( available_api_keys, retry_cnt, max_retries, + image_fallback_used=image_fallback_used, ) if success: break @@ -555,6 +807,7 @@ async def text_chat_stream( max_retries = 10 available_api_keys = self.api_keys.copy() chosen_key = random.choice(available_api_keys) + image_fallback_used = False last_exception = None retry_cnt = 0 @@ -573,6 +826,7 @@ async def text_chat_stream( payloads, context_query, func_tool, + image_fallback_used, ) = await self._handle_api_error( e, payloads, @@ -582,6 +836,7 @@ async def text_chat_stream( available_api_keys, retry_cnt, max_retries, + image_fallback_used=image_fallback_used, ) if success: break @@ -617,7 +872,7 @@ def get_current_key(self) -> str: def get_keys(self) -> list[str]: return self.api_keys - def set_key(self, key): + def set_key(self, key) -> None: self.client.api_key = key async def assemble_context( @@ -697,3 +952,7 @@ async def encode_image_bs64(self, image_url: str) -> str: with open(image_url, "rb") as f: image_bs64 = base64.b64encode(f.read()).decode("utf-8") return "data:image/jpeg;base64," + image_bs64 + + async def terminate(self): + if self.client: + await self.client.close() diff --git a/astrbot/core/provider/sources/openai_tts_api_source.py b/astrbot/core/provider/sources/openai_tts_api_source.py index d71e98112..217b18925 100644 --- a/astrbot/core/provider/sources/openai_tts_api_source.py +++ b/astrbot/core/provider/sources/openai_tts_api_source.py @@ -1,9 +1,11 @@ import os import uuid +import httpx from openai import NOT_GIVEN, AsyncOpenAI -from astrbot.core.utils.astrbot_path import get_astrbot_data_path +from astrbot import logger +from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from ..entities import ProviderType from ..provider import TTSProvider @@ -29,16 +31,22 @@ def __init__( if isinstance(timeout, str): timeout = int(timeout) + proxy = provider_config.get("proxy", "") + http_client = None + if proxy: + logger.info(f"[OpenAI TTS] 使用代理: {proxy}") + http_client = httpx.AsyncClient(proxy=proxy) self.client = AsyncOpenAI( api_key=self.chosen_api_key, base_url=provider_config.get("api_base"), timeout=timeout, + http_client=http_client, ) self.set_model(provider_config.get("model", "")) async def get_audio(self, text: str) -> str: - temp_dir = os.path.join(get_astrbot_data_path(), "temp") + temp_dir = get_astrbot_temp_path() path = os.path.join(temp_dir, f"openai_tts_api_{uuid.uuid4()}.wav") async with self.client.audio.speech.with_streaming_response.create( model=self.model_name, @@ -50,3 +58,7 @@ async def get_audio(self, text: str) -> str: async for chunk in response.iter_bytes(chunk_size=1024): f.write(chunk) return path + + async def terminate(self): + if self.client: + await self.client.close() diff --git a/astrbot/core/provider/sources/openrouter_source.py b/astrbot/core/provider/sources/openrouter_source.py new file mode 100644 index 000000000..2cb446cf3 --- /dev/null +++ b/astrbot/core/provider/sources/openrouter_source.py @@ -0,0 +1,19 @@ +from ..register import register_provider_adapter +from .openai_source import ProviderOpenAIOfficial + + +@register_provider_adapter( + "openrouter_chat_completion", "OpenRouter Chat Completion Provider Adapter" +) +class ProviderOpenRouter(ProviderOpenAIOfficial): + def __init__( + self, + provider_config: dict, + provider_settings: dict, + ) -> None: + super().__init__(provider_config, provider_settings) + # Reference to: https://openrouter.ai/docs/api/reference/overview#headers + self.client._custom_headers["HTTP-Referer"] = ( # type: ignore + "https://github.com/AstrBotDevs/AstrBot" + ) + self.client._custom_headers["X-TITLE"] = "AstrBot" # type: ignore diff --git a/astrbot/core/provider/sources/sensevoice_selfhosted_source.py b/astrbot/core/provider/sources/sensevoice_selfhosted_source.py index a41bd72fd..af6c0f631 100644 --- a/astrbot/core/provider/sources/sensevoice_selfhosted_source.py +++ b/astrbot/core/provider/sources/sensevoice_selfhosted_source.py @@ -7,12 +7,14 @@ import os import re from datetime import datetime +from pathlib import Path from typing import cast from funasr_onnx import SenseVoiceSmall from funasr_onnx.utils.postprocess_utils import rich_transcription_postprocess from astrbot.core import logger +from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from astrbot.core.utils.io import download_file from astrbot.core.utils.tencent_record_helper import tencent_silk_to_wav @@ -37,7 +39,7 @@ def __init__( self.model = None self.is_emotion = provider_config.get("is_emotion", False) - async def initialize(self): + async def initialize(self) -> None: logger.info("下载或者加载 SenseVoice 模型中,这可能需要一些时间 ...") # 将模型加载放到线程池中执行 @@ -50,9 +52,11 @@ async def initialize(self): async def get_timestamped_path(self) -> str: timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - return os.path.join("data", "temp", f"{timestamp}") + temp_dir = Path(get_astrbot_temp_path()) + temp_dir.mkdir(parents=True, exist_ok=True) + return str(temp_dir / timestamp) - async def _is_silk_file(self, file_path): + async def _is_silk_file(self, file_path) -> bool: silk_header = b"SILK" with open(file_path, "rb") as f: file_header = f.read(8) diff --git a/astrbot/core/provider/sources/volcengine_tts.py b/astrbot/core/provider/sources/volcengine_tts.py index f5d758f5c..349815907 100644 --- a/astrbot/core/provider/sources/volcengine_tts.py +++ b/astrbot/core/provider/sources/volcengine_tts.py @@ -8,6 +8,7 @@ import aiohttp from astrbot import logger +from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from ..entities import ProviderType from ..provider import TTSProvider @@ -92,9 +93,12 @@ async def get_audio(self, text: str) -> str: if "data" in resp_data: audio_data = base64.b64decode(resp_data["data"]) - os.makedirs("data/temp", exist_ok=True) - - file_path = f"data/temp/volcengine_tts_{uuid.uuid4()}.mp3" + temp_dir = get_astrbot_temp_path() + os.makedirs(temp_dir, exist_ok=True) + file_path = os.path.join( + temp_dir, + f"volcengine_tts_{uuid.uuid4()}.mp3", + ) loop = asyncio.get_running_loop() await loop.run_in_executor( diff --git a/astrbot/core/provider/sources/whisper_api_source.py b/astrbot/core/provider/sources/whisper_api_source.py index fa69206ef..386da063d 100644 --- a/astrbot/core/provider/sources/whisper_api_source.py +++ b/astrbot/core/provider/sources/whisper_api_source.py @@ -4,7 +4,7 @@ from openai import NOT_GIVEN, AsyncOpenAI from astrbot.core import logger -from astrbot.core.utils.astrbot_path import get_astrbot_data_path +from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from astrbot.core.utils.io import download_file from astrbot.core.utils.tencent_record_helper import ( convert_to_pcm_wav, @@ -38,7 +38,7 @@ def __init__( self.set_model(provider_config["model"]) - async def _get_audio_format(self, file_path): + async def _get_audio_format(self, file_path) -> str | None: # 定义要检测的头部字节 silk_header = b"SILK" amr_header = b"#!AMR" @@ -65,9 +65,11 @@ async def get_text(self, audio_url: str) -> str: if "multimedia.nt.qq.com.cn" in audio_url: is_tencent = True - name = str(uuid.uuid4()) - temp_dir = os.path.join(get_astrbot_data_path(), "temp") - path = os.path.join(temp_dir, name) + temp_dir = get_astrbot_temp_path() + path = os.path.join( + temp_dir, + f"whisper_api_{uuid.uuid4().hex[:8]}.input", + ) await download_file(audio_url, path) audio_url = path @@ -79,8 +81,11 @@ async def get_text(self, audio_url: str) -> str: # 判断是否需要转换 if file_format in ["silk", "amr"]: - temp_dir = os.path.join(get_astrbot_data_path(), "temp") - output_path = os.path.join(temp_dir, str(uuid.uuid4()) + ".wav") + temp_dir = get_astrbot_temp_path() + output_path = os.path.join( + temp_dir, + f"whisper_api_{uuid.uuid4().hex[:8]}.wav", + ) if file_format == "silk": logger.info( @@ -107,3 +112,7 @@ async def get_text(self, audio_url: str) -> str: except Exception as e: logger.error(f"Failed to remove temp file {audio_url}: {e}") return result.text + + async def terminate(self): + if self.client: + await self.client.close() diff --git a/astrbot/core/provider/sources/whisper_selfhosted_source.py b/astrbot/core/provider/sources/whisper_selfhosted_source.py index a14f93f14..678deb948 100644 --- a/astrbot/core/provider/sources/whisper_selfhosted_source.py +++ b/astrbot/core/provider/sources/whisper_selfhosted_source.py @@ -6,7 +6,7 @@ import whisper from astrbot.core import logger -from astrbot.core.utils.astrbot_path import get_astrbot_data_path +from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from astrbot.core.utils.io import download_file from astrbot.core.utils.tencent_record_helper import tencent_silk_to_wav @@ -30,7 +30,7 @@ def __init__( self.set_model(provider_config["model"]) self.model = None - async def initialize(self): + async def initialize(self) -> None: loop = asyncio.get_event_loop() logger.info("下载或者加载 Whisper 模型中,这可能需要一些时间 ...") self.model = await loop.run_in_executor( @@ -40,7 +40,7 @@ async def initialize(self): ) logger.info("Whisper 模型加载完成。") - async def _is_silk_file(self, file_path): + async def _is_silk_file(self, file_path) -> bool: silk_header = b"SILK" with open(file_path, "rb") as f: file_header = f.read(8) @@ -58,9 +58,11 @@ async def get_text(self, audio_url: str) -> str: if "multimedia.nt.qq.com.cn" in audio_url: is_tencent = True - name = str(uuid.uuid4()) - temp_dir = os.path.join(get_astrbot_data_path(), "temp") - path = os.path.join(temp_dir, name) + temp_dir = get_astrbot_temp_path() + path = os.path.join( + temp_dir, + f"whisper_selfhost_{uuid.uuid4().hex[:8]}.input", + ) await download_file(audio_url, path) audio_url = path @@ -71,8 +73,11 @@ async def get_text(self, audio_url: str) -> str: is_silk = await self._is_silk_file(audio_url) if is_silk: logger.info("Converting silk file to wav ...") - temp_dir = os.path.join(get_astrbot_data_path(), "temp") - output_path = os.path.join(temp_dir, str(uuid.uuid4()) + ".wav") + temp_dir = get_astrbot_temp_path() + output_path = os.path.join( + temp_dir, + f"whisper_selfhost_{uuid.uuid4().hex[:8]}.wav", + ) await tencent_silk_to_wav(audio_url, output_path) audio_url = output_path diff --git a/astrbot/core/provider/sources/xai_source.py b/astrbot/core/provider/sources/xai_source.py index a050412d3..b7b432b49 100644 --- a/astrbot/core/provider/sources/xai_source.py +++ b/astrbot/core/provider/sources/xai_source.py @@ -13,7 +13,7 @@ def __init__( ) -> None: super().__init__(provider_config, provider_settings) - def _maybe_inject_xai_search(self, payloads: dict): + def _maybe_inject_xai_search(self, payloads: dict) -> None: """当开启 xAI 原生搜索时,向请求体注入 Live Search 参数。 - 仅在 provider_config.xai_native_search 为 True 时生效 @@ -24,6 +24,6 @@ def _maybe_inject_xai_search(self, payloads: dict): # OpenAI SDK 不识别的字段会在 _query/_query_stream 中放入 extra_body payloads["search_parameters"] = {"mode": "auto"} - def _finally_convert_payload(self, payloads: dict): + def _finally_convert_payload(self, payloads: dict) -> None: self._maybe_inject_xai_search(payloads) super()._finally_convert_payload(payloads) diff --git a/astrbot/core/provider/sources/xinference_rerank_source.py b/astrbot/core/provider/sources/xinference_rerank_source.py index 960408550..9c3a77c15 100644 --- a/astrbot/core/provider/sources/xinference_rerank_source.py +++ b/astrbot/core/provider/sources/xinference_rerank_source.py @@ -37,7 +37,7 @@ def __init__(self, provider_config: dict, provider_settings: dict) -> None: self.model: AsyncRESTfulRerankModelHandle | None = None self.model_uid = None - async def initialize(self): + async def initialize(self) -> None: if self.api_key: logger.info("Xinference Rerank: Using API key for authentication.") self.client = Client(self.base_url, api_key=self.api_key) diff --git a/astrbot/core/provider/sources/xinference_stt_provider.py b/astrbot/core/provider/sources/xinference_stt_provider.py index 4b947b3f0..0a22e456e 100644 --- a/astrbot/core/provider/sources/xinference_stt_provider.py +++ b/astrbot/core/provider/sources/xinference_stt_provider.py @@ -7,7 +7,7 @@ ) from astrbot.core import logger -from astrbot.core.utils.astrbot_path import get_astrbot_data_path +from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from astrbot.core.utils.tencent_record_helper import ( convert_to_pcm_wav, tencent_silk_to_wav, @@ -40,7 +40,7 @@ def __init__(self, provider_config: dict, provider_settings: dict) -> None: self.client = None self.model_uid = None - async def initialize(self): + async def initialize(self) -> None: if self.api_key: logger.info("Xinference STT: Using API key for authentication.") self.client = Client(self.base_url, api_key=self.api_key) @@ -130,11 +130,17 @@ async def get_text(self, audio_url: str) -> str: logger.info( f"Audio requires conversion ({conversion_type}), using temporary files..." ) - temp_dir = os.path.join(get_astrbot_data_path(), "temp") + temp_dir = get_astrbot_temp_path() os.makedirs(temp_dir, exist_ok=True) - input_path = os.path.join(temp_dir, str(uuid.uuid4())) - output_path = os.path.join(temp_dir, str(uuid.uuid4()) + ".wav") + input_path = os.path.join( + temp_dir, + f"xinference_stt_{uuid.uuid4().hex[:8]}.input", + ) + output_path = os.path.join( + temp_dir, + f"xinference_stt_{uuid.uuid4().hex[:8]}.wav", + ) temp_files.extend([input_path, output_path]) with open(input_path, "wb") as f: diff --git a/astrbot/core/skills/skill_manager.py b/astrbot/core/skills/skill_manager.py index 1e6f01a6d..85190ecdf 100644 --- a/astrbot/core/skills/skill_manager.py +++ b/astrbot/core/skills/skill_manager.py @@ -93,7 +93,6 @@ def __init__(self, skills_root: str | None = None) -> None: self.skills_root = skills_root or get_astrbot_skills_path() self.config_path = os.path.join(get_astrbot_data_path(), SKILLS_CONFIG_FILENAME) os.makedirs(self.skills_root, exist_ok=True) - os.makedirs(get_astrbot_temp_path(), exist_ok=True) def _load_config(self) -> dict: if not os.path.exists(self.config_path): diff --git a/astrbot/core/star/__init__.py b/astrbot/core/star/__init__.py index c474962c5..796e0bd68 100644 --- a/astrbot/core/star/__init__.py +++ b/astrbot/core/star/__init__.py @@ -1,68 +1,19 @@ -from astrbot.core import html_renderer +# 兼容导出: Provider 从 provider 模块重新导出 from astrbot.core.provider import Provider -from astrbot.core.star.star_tools import StarTools -from astrbot.core.utils.command_parser import CommandParserMixin -from astrbot.core.utils.plugin_kv_store import PluginKVStoreMixin +from .base import Star from .context import Context from .star import StarMetadata, star_map, star_registry from .star_manager import PluginManager - - -class Star(CommandParserMixin, PluginKVStoreMixin): - """所有插件(Star)的父类,所有插件都应该继承于这个类""" - - author: str - name: str - - def __init__(self, context: Context, config: dict | None = None): - StarTools.initialize(context) - self.context = context - - def __init_subclass__(cls, **kwargs): - super().__init_subclass__(**kwargs) - if not star_map.get(cls.__module__): - metadata = StarMetadata( - star_cls_type=cls, - module_path=cls.__module__, - ) - star_map[cls.__module__] = metadata - star_registry.append(metadata) - else: - star_map[cls.__module__].star_cls_type = cls - star_map[cls.__module__].module_path = cls.__module__ - - async def text_to_image(self, text: str, return_url=True) -> str: - """将文本转换为图片""" - return await html_renderer.render_t2i( - text, - return_url=return_url, - template_name=self.context._config.get("t2i_active_template"), - ) - - async def html_render( - self, - tmpl: str, - data: dict, - return_url=True, - options: dict | None = None, - ) -> str: - """渲染 HTML""" - return await html_renderer.render_custom_template( - tmpl, - data, - return_url=return_url, - options=options, - ) - - async def initialize(self): - """当插件被激活时会调用这个方法""" - - async def terminate(self): - """当插件被禁用、重载插件时会调用这个方法""" - - def __del__(self): - """[Deprecated] 当插件被禁用、重载插件时会调用这个方法""" - - -__all__ = ["Context", "PluginManager", "Provider", "Star", "StarMetadata", "StarTools"] +from .star_tools import StarTools + +__all__ = [ + "Context", + "PluginManager", + "Provider", + "Star", + "StarMetadata", + "StarTools", + "star_map", + "star_registry", +] diff --git a/astrbot/core/star/base.py b/astrbot/core/star/base.py new file mode 100644 index 000000000..dd3ae3f0e --- /dev/null +++ b/astrbot/core/star/base.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +import logging +from typing import Any, Protocol + +from astrbot.core import html_renderer +from astrbot.core.utils.command_parser import CommandParserMixin +from astrbot.core.utils.plugin_kv_store import PluginKVStoreMixin + +from .star import StarMetadata, star_map, star_registry + +logger = logging.getLogger("astrbot") + + +class Star(CommandParserMixin, PluginKVStoreMixin): + """所有插件(Star)的父类,所有插件都应该继承于这个类""" + + author: str + name: str + + class _ContextLike(Protocol): + def get_config(self, umo: str | None = None) -> Any: ... + + def __init__(self, context: _ContextLike, config: dict | None = None) -> None: + self.context = context + + def _get_context_config(self) -> Any: + get_config = getattr(self.context, "get_config", None) + if callable(get_config): + try: + return get_config() + except Exception as e: + logger.debug(f"get_config() failed: {e}") + return None + return getattr(self.context, "_config", None) + + def __init_subclass__(cls, **kwargs): + super().__init_subclass__(**kwargs) + if not star_map.get(cls.__module__): + metadata = StarMetadata( + star_cls_type=cls, + module_path=cls.__module__, + ) + star_map[cls.__module__] = metadata + star_registry.append(metadata) + else: + star_map[cls.__module__].star_cls_type = cls + star_map[cls.__module__].module_path = cls.__module__ + + async def text_to_image(self, text: str, return_url=True) -> str: + """将文本转换为图片""" + config_obj = self._get_context_config() + template_name = None + if hasattr(config_obj, "get"): + try: + template_name = config_obj.get("t2i_active_template") + except Exception: + template_name = None + return await html_renderer.render_t2i( + text, + return_url=return_url, + template_name=template_name, + ) + + async def html_render( + self, + tmpl: str, + data: dict, + return_url=True, + options: dict | None = None, + ) -> str: + """渲染 HTML""" + return await html_renderer.render_custom_template( + tmpl, + data, + return_url=return_url, + options=options, + ) + + async def initialize(self) -> None: + """当插件被激活时会调用这个方法""" + + async def terminate(self) -> None: + """当插件被禁用、重载插件时会调用这个方法""" + + def __del__(self) -> None: + """[Deprecated] 当插件被禁用、重载插件时会调用这个方法""" diff --git a/astrbot/core/star/command_management.py b/astrbot/core/star/command_management.py index ba3e39017..c60af9ea2 100644 --- a/astrbot/core/star/command_management.py +++ b/astrbot/core/star/command_management.py @@ -4,6 +4,7 @@ from dataclasses import dataclass, field from typing import Any +from astrbot.api import sp from astrbot.core import db_helper, logger from astrbot.core.db.po import CommandConfig from astrbot.core.star.filter.command import CommandFilter @@ -139,6 +140,51 @@ async def rename_command( return descriptor +async def update_command_permission( + handler_full_name: str, + permission_type: str, +) -> CommandDescriptor: + descriptor = _build_descriptor_by_full_name(handler_full_name) + if not descriptor: + raise ValueError("指定的处理函数不存在或不是指令。") + + if permission_type not in ["admin", "member"]: + raise ValueError("权限类型必须为 admin 或 member。") + + handler = descriptor.handler + found_plugin = star_map.get(handler.handler_module_path) + if not found_plugin: + raise ValueError("未找到指令所属插件") + + # 1. Update Persistent Config (alter_cmd) + alter_cmd_cfg = await sp.global_get("alter_cmd", {}) + plugin_ = alter_cmd_cfg.get(found_plugin.name, {}) + cfg = plugin_.get(handler.handler_name, {}) + cfg["permission"] = permission_type + plugin_[handler.handler_name] = cfg + alter_cmd_cfg[found_plugin.name] = plugin_ + + await sp.global_put("alter_cmd", alter_cmd_cfg) + + # 2. Update Runtime Filter + found_permission_filter = False + target_perm_type = ( + PermissionType.ADMIN if permission_type == "admin" else PermissionType.MEMBER + ) + + for filter_ in handler.event_filters: + if isinstance(filter_, PermissionTypeFilter): + filter_.permission_type = target_perm_type + found_permission_filter = True + break + + if not found_permission_filter: + handler.event_filters.insert(0, PermissionTypeFilter(target_perm_type)) + + # Re-build descriptor to reflect changes + return _build_descriptor(handler) or descriptor + + async def list_commands() -> list[dict[str, Any]]: descriptors = _collect_descriptors(include_sub_commands=True) config_records = await db_helper.get_command_configs() diff --git a/astrbot/core/star/config.py b/astrbot/core/star/config.py index 2b590921d..429a05d5e 100644 --- a/astrbot/core/star/config.py +++ b/astrbot/core/star/config.py @@ -22,7 +22,7 @@ def load_config(namespace: str) -> dict | bool: return ret -def put_config(namespace: str, name: str, key: str, value, description: str): +def put_config(namespace: str, name: str, key: str, value, description: str) -> None: """将配置项写入以namespace为名字的配置文件,如果key不存在于目标配置文件中。当前 value 仅支持 str, int, float, bool, list 类型(暂不支持 dict)。 namespace: str, 配置的唯一识别符,也就是配置文件的名字。 name: str, 配置项的显示名字。 @@ -64,7 +64,7 @@ def put_config(namespace: str, name: str, key: str, value, description: str): f.flush() -def update_config(namespace: str, key: str, value): +def update_config(namespace: str, key: str, value) -> None: """更新配置文件中的配置项。 namespace: str, 配置的唯一识别符,也就是配置文件的名字。 key: str, 配置项的键。 diff --git a/astrbot/core/star/context.py b/astrbot/core/star/context.py index c7438baf2..b5acf952e 100644 --- a/astrbot/core/star/context.py +++ b/astrbot/core/star/context.py @@ -1,7 +1,9 @@ +from __future__ import annotations + import logging from asyncio import Queue from collections.abc import Awaitable, Callable -from typing import Any +from typing import TYPE_CHECKING, Any, Protocol from deprecated import deprecated @@ -12,14 +14,12 @@ from astrbot.core.astrbot_config_mgr import AstrBotConfigManager from astrbot.core.config.astrbot_config import AstrBotConfig from astrbot.core.conversation_mgr import ConversationManager -from astrbot.core.cron.manager import CronJobManager from astrbot.core.db import BaseDatabase from astrbot.core.knowledge_base.kb_mgr import KnowledgeBaseManager from astrbot.core.message.message_event_result import MessageChain from astrbot.core.persona_mgr import PersonaManager from astrbot.core.platform import Platform from astrbot.core.platform.astr_message_event import AstrMessageEvent, MessageSesion -from astrbot.core.platform.manager import PlatformManager from astrbot.core.platform_message_history_mgr import PlatformMessageHistoryManager from astrbot.core.provider.entities import LLMResponse, ProviderRequest, ProviderType from astrbot.core.provider.func_tool_manager import FunctionTool, FunctionToolManager @@ -45,6 +45,15 @@ logger = logging.getLogger("astrbot") +if TYPE_CHECKING: + from astrbot.core.cron.manager import CronJobManager +else: + CronJobManager = Any + + +class PlatformManagerProtocol(Protocol): + platform_insts: list[Platform] + class Context: """暴露给插件的接口上下文。""" @@ -61,7 +70,7 @@ def __init__( config: AstrBotConfig, db: BaseDatabase, provider_manager: ProviderManager, - platform_manager: PlatformManager, + platform_manager: PlatformManagerProtocol, conversation_manager: ConversationManager, message_history_manager: PlatformMessageHistoryManager, persona_manager: PersonaManager, @@ -69,7 +78,7 @@ def __init__( knowledge_base_manager: KnowledgeBaseManager, cron_manager: CronJobManager, subagent_orchestrator: SubAgentOrchestrator | None = None, - ): + ) -> None: self._event_queue = event_queue """事件队列。消息平台通过事件队列传递消息事件。""" self._config = config @@ -448,6 +457,9 @@ async def send_message( if platform.meta().id == session.platform_name: await platform.send_by_session(session, message_chain) return True + logger.warning( + f"cannot find platform for session {str(session)}, message not sent" + ) return False def add_llm_tools(self, *tools: FunctionTool) -> None: @@ -491,7 +503,7 @@ def register_web_api( view_handler: Awaitable, methods: list, desc: str, - ): + ) -> None: """注册 Web API。 Args: @@ -565,7 +577,7 @@ def get_db(self) -> BaseDatabase: """ return self._db - def register_provider(self, provider: Provider): + def register_provider(self, provider: Provider) -> None: """注册一个 LLM Provider(Chat_Completion 类型)。 Args: @@ -626,7 +638,7 @@ def register_commands( awaitable: Callable[..., Awaitable[Any]], use_regex=False, ignore_prefix=False, - ): + ) -> None: """[DEPRECATED]注册一个命令。 Args: @@ -658,7 +670,7 @@ def register_commands( ) star_handlers_registry.append(md) - def register_task(self, task: Awaitable, desc: str): + def register_task(self, task: Awaitable, desc: str) -> None: """[DEPRECATED]注册一个异步任务。 Args: diff --git a/astrbot/core/star/filter/command.py b/astrbot/core/star/filter/command.py index e86ee85af..31949b674 100755 --- a/astrbot/core/star/filter/command.py +++ b/astrbot/core/star/filter/command.py @@ -37,7 +37,7 @@ def __init__( alias: set | None = None, handler_md: StarHandlerMetadata | None = None, parent_command_names: list[str] | None = None, - ): + ) -> None: self.command_name = command_name self.alias = alias if alias else set() self._original_command_name = command_name @@ -63,7 +63,7 @@ def print_types(self): result = "".join(parts).rstrip(",") return result - def init_handler_md(self, handle_md: StarHandlerMetadata): + def init_handler_md(self, handle_md: StarHandlerMetadata) -> None: self.handler_md = handle_md signature = inspect.signature(self.handler_md.handler) self.handler_params = {} # 参数名 -> 参数类型,如果有默认值则为默认值 @@ -81,7 +81,7 @@ def init_handler_md(self, handle_md: StarHandlerMetadata): def get_handler_md(self) -> StarHandlerMetadata: return self.handler_md - def add_custom_filter(self, custom_filter: CustomFilter): + def add_custom_filter(self, custom_filter: CustomFilter) -> None: self.custom_filter_list.append(custom_filter) def custom_filter_ok(self, event: AstrMessageEvent, cfg: AstrBotConfig) -> bool: diff --git a/astrbot/core/star/filter/command_group.py b/astrbot/core/star/filter/command_group.py index 4cbd2c007..52fb6a452 100755 --- a/astrbot/core/star/filter/command_group.py +++ b/astrbot/core/star/filter/command_group.py @@ -15,7 +15,7 @@ def __init__( group_name: str, alias: set | None = None, parent_group: CommandGroupFilter | None = None, - ): + ) -> None: self.group_name = group_name self.alias = alias if alias else set() self._original_group_name = group_name @@ -29,10 +29,10 @@ def __init__( def add_sub_command_filter( self, sub_command_filter: CommandFilter | CommandGroupFilter, - ): + ) -> None: self.sub_command_filters.append(sub_command_filter) - def add_custom_filter(self, custom_filter: CustomFilter): + def add_custom_filter(self, custom_filter: CustomFilter) -> None: self.custom_filter_list.append(custom_filter) def get_complete_command_names(self) -> list[str]: diff --git a/astrbot/core/star/filter/custom_filter.py b/astrbot/core/star/filter/custom_filter.py index 54d03632d..d99cb5f13 100644 --- a/astrbot/core/star/filter/custom_filter.py +++ b/astrbot/core/star/filter/custom_filter.py @@ -19,7 +19,7 @@ def __or__(cls, other): class CustomFilter(HandlerFilter, metaclass=CustomFilterMeta): - def __init__(self, raise_error: bool = True, **kwargs): + def __init__(self, raise_error: bool = True, **kwargs) -> None: self.raise_error = raise_error @abstractmethod @@ -35,7 +35,7 @@ def __and__(self, other): class CustomFilterOr(CustomFilter): - def __init__(self, filter1: CustomFilter, filter2: CustomFilter): + def __init__(self, filter1: CustomFilter, filter2: CustomFilter) -> None: super().__init__() if not isinstance(filter1, (CustomFilter, CustomFilterAnd, CustomFilterOr)): raise ValueError( @@ -49,7 +49,7 @@ def filter(self, event: AstrMessageEvent, cfg: AstrBotConfig) -> bool: class CustomFilterAnd(CustomFilter): - def __init__(self, filter1: CustomFilter, filter2: CustomFilter): + def __init__(self, filter1: CustomFilter, filter2: CustomFilter) -> None: super().__init__() if not isinstance(filter1, (CustomFilter, CustomFilterAnd, CustomFilterOr)): raise ValueError( diff --git a/astrbot/core/star/filter/event_message_type.py b/astrbot/core/star/filter/event_message_type.py index 7f350bd38..604fc3ed3 100644 --- a/astrbot/core/star/filter/event_message_type.py +++ b/astrbot/core/star/filter/event_message_type.py @@ -22,7 +22,7 @@ class EventMessageType(enum.Flag): class EventMessageTypeFilter(HandlerFilter): - def __init__(self, event_message_type: EventMessageType): + def __init__(self, event_message_type: EventMessageType) -> None: self.event_message_type = event_message_type def filter(self, event: AstrMessageEvent, cfg: AstrBotConfig) -> bool: diff --git a/astrbot/core/star/filter/permission.py b/astrbot/core/star/filter/permission.py index 3374544c2..a70299fa9 100644 --- a/astrbot/core/star/filter/permission.py +++ b/astrbot/core/star/filter/permission.py @@ -14,7 +14,9 @@ class PermissionType(enum.Flag): class PermissionTypeFilter(HandlerFilter): - def __init__(self, permission_type: PermissionType, raise_error: bool = True): + def __init__( + self, permission_type: PermissionType, raise_error: bool = True + ) -> None: self.permission_type = permission_type self.raise_error = raise_error diff --git a/astrbot/core/star/filter/platform_adapter_type.py b/astrbot/core/star/filter/platform_adapter_type.py index 241662bca..3ac8019ef 100644 --- a/astrbot/core/star/filter/platform_adapter_type.py +++ b/astrbot/core/star/filter/platform_adapter_type.py @@ -11,6 +11,7 @@ class PlatformAdapterType(enum.Flag): QQOFFICIAL = enum.auto() TELEGRAM = enum.auto() WECOM = enum.auto() + WECOM_AI_BOT = enum.auto() LARK = enum.auto() DINGTALK = enum.auto() DISCORD = enum.auto() @@ -20,11 +21,13 @@ class PlatformAdapterType(enum.Flag): WEIXIN_OFFICIAL_ACCOUNT = enum.auto() SATORI = enum.auto() MISSKEY = enum.auto() + LINE = enum.auto() ALL = ( AIOCQHTTP | QQOFFICIAL | TELEGRAM | WECOM + | WECOM_AI_BOT | LARK | DINGTALK | DISCORD @@ -34,6 +37,7 @@ class PlatformAdapterType(enum.Flag): | WEIXIN_OFFICIAL_ACCOUNT | SATORI | MISSKEY + | LINE ) @@ -42,6 +46,7 @@ class PlatformAdapterType(enum.Flag): "qq_official": PlatformAdapterType.QQOFFICIAL, "telegram": PlatformAdapterType.TELEGRAM, "wecom": PlatformAdapterType.WECOM, + "wecom_ai_bot": PlatformAdapterType.WECOM_AI_BOT, "lark": PlatformAdapterType.LARK, "dingtalk": PlatformAdapterType.DINGTALK, "discord": PlatformAdapterType.DISCORD, @@ -51,11 +56,12 @@ class PlatformAdapterType(enum.Flag): "weixin_official_account": PlatformAdapterType.WEIXIN_OFFICIAL_ACCOUNT, "satori": PlatformAdapterType.SATORI, "misskey": PlatformAdapterType.MISSKEY, + "line": PlatformAdapterType.LINE, } class PlatformAdapterTypeFilter(HandlerFilter): - def __init__(self, platform_adapter_type_or_str: PlatformAdapterType | str): + def __init__(self, platform_adapter_type_or_str: PlatformAdapterType | str) -> None: if isinstance(platform_adapter_type_or_str, str): self.platform_type = ADAPTER_NAME_2_TYPE.get(platform_adapter_type_or_str) else: diff --git a/astrbot/core/star/filter/regex.py b/astrbot/core/star/filter/regex.py index cd5bebdb4..abec5a488 100644 --- a/astrbot/core/star/filter/regex.py +++ b/astrbot/core/star/filter/regex.py @@ -10,7 +10,7 @@ class RegexFilter(HandlerFilter): """正则表达式过滤器""" - def __init__(self, regex: str): + def __init__(self, regex: str) -> None: self.regex_str = regex self.regex = re.compile(regex) diff --git a/astrbot/core/star/register/__init__.py b/astrbot/core/star/register/__init__.py index 4856ffe50..5e99948cd 100644 --- a/astrbot/core/star/register/__init__.py +++ b/astrbot/core/star/register/__init__.py @@ -13,6 +13,9 @@ register_on_llm_response, register_on_llm_tool_respond, register_on_platform_loaded, + register_on_plugin_error, + register_on_plugin_loaded, + register_on_plugin_unloaded, register_on_using_llm_tool, register_on_waiting_llm_request, register_permission_type, @@ -32,6 +35,9 @@ "register_on_decorating_result", "register_on_llm_request", "register_on_llm_response", + "register_on_plugin_error", + "register_on_plugin_loaded", + "register_on_plugin_unloaded", "register_on_platform_loaded", "register_on_waiting_llm_request", "register_permission_type", diff --git a/astrbot/core/star/register/star.py b/astrbot/core/star/register/star.py index 617cd5ff7..c1a0ce10c 100644 --- a/astrbot/core/star/register/star.py +++ b/astrbot/core/star/register/star.py @@ -1,6 +1,6 @@ import warnings -from astrbot.core.star import StarMetadata, star_map +from astrbot.core.star.star import StarMetadata, star_map _warned_register_star = False diff --git a/astrbot/core/star/register/star_handler.py b/astrbot/core/star/register/star_handler.py index eefbcedb7..1385b5056 100644 --- a/astrbot/core/star/register/star_handler.py +++ b/astrbot/core/star/register/star_handler.py @@ -11,7 +11,6 @@ from astrbot.core.agent.handoff import HandoffTool from astrbot.core.agent.hooks import BaseAgentRunHooks from astrbot.core.agent.tool import FunctionTool -from astrbot.core.astr_agent_context import AstrAgentContext from astrbot.core.message.message_event_result import MessageEventResult from astrbot.core.provider.func_tool_manager import PY_TO_JSON_TYPE, SUPPORTED_TYPES from astrbot.core.provider.register import llm_tools @@ -250,7 +249,7 @@ class RegisteringCommandable: command: Callable[..., Callable[..., None]] = register_command custom_filter: Callable[..., Callable[..., Any]] = register_custom_filter - def __init__(self, parent_group: CommandGroupFilter): + def __init__(self, parent_group: CommandGroupFilter) -> None: self.parent_group = parent_group @@ -339,6 +338,58 @@ def decorator(awaitable): return decorator +def register_on_plugin_error(**kwargs): + """当插件处理消息异常时触发。 + + Hook 参数: + event, plugin_name, handler_name, error, traceback_text + + 说明: + 在 hook 中调用 `event.stop_event()` 可屏蔽默认报错回显, + 并由插件自行决定是否转发到其他会话。 + """ + + def decorator(awaitable): + _ = get_handler_or_create(awaitable, EventType.OnPluginErrorEvent, **kwargs) + return awaitable + + return decorator + + +def register_on_plugin_loaded(**kwargs): + """当有插件加载完成时 + + Hook 参数: + metadata + + 说明: + 当有插件加载完成时,触发该事件并获取到该插件的元数据 + """ + + def decorator(awaitable): + _ = get_handler_or_create(awaitable, EventType.OnPluginLoadedEvent, **kwargs) + return awaitable + + return decorator + + +def register_on_plugin_unloaded(**kwargs): + """当有插件卸载完成时 + + Hook 参数: + metadata + + 说明: + 当有插件卸载完成时,触发该事件并获取到该插件的元数据 + """ + + def decorator(awaitable): + _ = get_handler_or_create(awaitable, EventType.OnPluginUnloadedEvent, **kwargs) + return awaitable + + return decorator + + def register_on_waiting_llm_request(**kwargs): """当等待调用 LLM 时的通知事件(在获取锁之前) @@ -565,7 +616,7 @@ def llm_tool(self, *args, **kwargs): kwargs["registering_agent"] = self return register_llm_tool(*args, **kwargs) - def __init__(self, agent: Agent[AstrAgentContext]): + def __init__(self, agent: Agent[Any]) -> None: self._agent = agent @@ -573,7 +624,7 @@ def register_agent( name: str, instruction: str, tools: list[str | FunctionTool] | None = None, - run_hooks: BaseAgentRunHooks[AstrAgentContext] | None = None, + run_hooks: BaseAgentRunHooks[Any] | None = None, ): """注册一个 Agent @@ -587,12 +638,12 @@ def register_agent( tools_ = tools or [] def decorator(awaitable: Callable[..., Awaitable[Any]]): - AstrAgent = Agent[AstrAgentContext] + AstrAgent = Agent[Any] agent = AstrAgent( name=name, instructions=instruction, tools=tools_, - run_hooks=run_hooks or BaseAgentRunHooks[AstrAgentContext](), + run_hooks=run_hooks or BaseAgentRunHooks[Any](), ) handoff_tool = HandoffTool(agent=agent) handoff_tool.handler = awaitable diff --git a/astrbot/core/star/star.py b/astrbot/core/star/star.py index c5b7b1243..8cebbd772 100644 --- a/astrbot/core/star/star.py +++ b/astrbot/core/star/star.py @@ -61,6 +61,12 @@ class StarMetadata: logo_path: str | None = None """插件 Logo 的路径""" + support_platforms: list[str] = field(default_factory=list) + """插件声明支持的平台适配器 ID 列表(对应 ADAPTER_NAME_2_TYPE 的 key)""" + + astrbot_version: str | None = None + """插件要求的 AstrBot 版本范围(PEP 440 specifier,如 >=4.13.0,<4.17.0)""" + def __str__(self) -> str: return f"Plugin {self.name} ({self.version}) by {self.author}: {self.desc}" diff --git a/astrbot/core/star/star_handler.py b/astrbot/core/star/star_handler.py index 6f5ce6090..d28ac726a 100644 --- a/astrbot/core/star/star_handler.py +++ b/astrbot/core/star/star_handler.py @@ -12,11 +12,11 @@ class StarHandlerRegistry(Generic[T]): - def __init__(self): + def __init__(self) -> None: self.star_handlers_map: dict[str, StarHandlerMetadata] = {} self._handlers: list[StarHandlerMetadata] = [] - def append(self, handler: StarHandlerMetadata): + def append(self, handler: StarHandlerMetadata) -> None: """添加一个 Handler,并保持按优先级有序""" if "priority" not in handler.extras_configs: handler.extras_configs["priority"] = 0 @@ -25,7 +25,7 @@ def append(self, handler: StarHandlerMetadata): self._handlers.append(handler) self._handlers.sort(key=lambda h: -h.extras_configs["priority"]) - def _print_handlers(self): + def _print_handlers(self) -> None: for handler in self._handlers: print(handler.handler_full_name) @@ -97,6 +97,30 @@ def get_handlers_by_event_type( plugins_name: list[str] | None = None, ) -> list[StarHandlerMetadata[Callable[..., Awaitable[Any]]]]: ... + @overload + def get_handlers_by_event_type( + self, + event_type: Literal[EventType.OnPluginErrorEvent], + only_activated=True, + plugins_name: list[str] | None = None, + ) -> list[StarHandlerMetadata[Callable[..., Awaitable[Any]]]]: ... + + @overload + def get_handlers_by_event_type( + self, + event_type: Literal[EventType.OnPluginLoadedEvent], + only_activated=True, + plugins_name: list[str] | None = None, + ) -> list[StarHandlerMetadata[Callable[..., Awaitable[Any]]]]: ... + + @overload + def get_handlers_by_event_type( + self, + event_type: Literal[EventType.OnPluginUnloadedEvent], + only_activated=True, + plugins_name: list[str] | None = None, + ) -> list[StarHandlerMetadata[Callable[..., Awaitable[Any]]]]: ... + @overload def get_handlers_by_event_type( self, @@ -136,6 +160,8 @@ def get_handlers_by_event_type( not in ( EventType.OnAstrBotLoadedEvent, EventType.OnPlatformLoadedEvent, + EventType.OnPluginLoadedEvent, + EventType.OnPluginUnloadedEvent, ) and not plugin.reserved ): @@ -156,18 +182,18 @@ def get_handlers_by_module_name( if handler.handler_module_path == module_name ] - def clear(self): + def clear(self) -> None: self.star_handlers_map.clear() self._handlers.clear() - def remove(self, handler: StarHandlerMetadata): + def remove(self, handler: StarHandlerMetadata) -> None: self.star_handlers_map.pop(handler.handler_full_name, None) self._handlers = [h for h in self._handlers if h != handler] def __iter__(self): return iter(self._handlers) - def __len__(self): + def __len__(self) -> int: return len(self._handlers) @@ -192,6 +218,9 @@ class EventType(enum.Enum): OnUsingLLMToolEvent = enum.auto() # 使用 LLM 工具 OnLLMToolRespondEvent = enum.auto() # 调用函数工具后 OnAfterMessageSentEvent = enum.auto() # 发送消息后 + OnPluginErrorEvent = enum.auto() # 插件处理消息异常时 + OnPluginLoadedEvent = enum.auto() # 插件加载完成 + OnPluginUnloadedEvent = enum.auto() # 插件卸载完成 H = TypeVar("H", bound=Callable[..., Any]) diff --git a/astrbot/core/star/star_manager.py b/astrbot/core/star/star_manager.py index 567397107..13251d2ba 100644 --- a/astrbot/core/star/star_manager.py +++ b/astrbot/core/star/star_manager.py @@ -11,10 +11,13 @@ from types import ModuleType import yaml +from packaging.specifiers import InvalidSpecifier, SpecifierSet +from packaging.version import InvalidVersion, Version from astrbot.core import logger, pip_installer, sp from astrbot.core.agent.handoff import FunctionTool, HandoffTool from astrbot.core.config.astrbot_config import AstrBotConfig +from astrbot.core.config.default import VERSION from astrbot.core.platform.register import unregister_platform_adapters_by_module from astrbot.core.provider.register import llm_tools from astrbot.core.utils.astrbot_path import ( @@ -30,7 +33,7 @@ from .context import Context from .filter.permission import PermissionType, PermissionTypeFilter from .star import star_map, star_registry -from .star_handler import star_handlers_registry +from .star_handler import EventType, star_handlers_registry from .updator import PluginUpdator try: @@ -40,12 +43,19 @@ logger.warning("未安装 watchfiles,无法实现插件的热重载。") +class PluginVersionIncompatibleError(Exception): + """Raised when plugin astrbot_version is incompatible with current AstrBot.""" + + class PluginManager: - def __init__(self, context: Context, config: AstrBotConfig): + def __init__(self, context: Context, config: AstrBotConfig) -> None: + from .star_tools import StarTools + self.updator = PluginUpdator() self.context = context self.context._star_manager = self # type: ignore + StarTools.initialize(context) self.config = config self.plugin_store_path = get_astrbot_plugin_path() @@ -62,11 +72,14 @@ def __init__(self, context: Context, config: AstrBotConfig): self._pm_lock = asyncio.Lock() """StarManager操作互斥锁""" + self.failed_plugin_dict = {} + """加载失败插件的信息,用于后续可能的热重载""" + self.failed_plugin_info = "" if os.getenv("ASTRBOT_RELOAD", "0") == "1": asyncio.create_task(self._watch_plugins_changes()) - async def _watch_plugins_changes(self): + async def _watch_plugins_changes(self) -> None: """监视插件文件变化""" try: async for changes in awatch( @@ -83,7 +96,7 @@ async def _watch_plugins_changes(self): logger.error(f"插件热重载监视任务异常: {e!s}") logger.error(traceback.format_exc()) - async def _handle_file_changes(self, changes): + async def _handle_file_changes(self, changes) -> None: """处理文件变化""" logger.info(f"检测到文件变化: {changes}") plugins_to_check = [] @@ -167,7 +180,9 @@ def _get_plugin_modules(self) -> list[dict]: plugins.extend(_p) return plugins - async def _check_plugin_dept_update(self, target_plugin: str | None = None): + async def _check_plugin_dept_update( + self, target_plugin: str | None = None + ) -> bool | None: """检查插件的依赖 如果 target_plugin 为 None,则检查所有插件的依赖 """ @@ -189,6 +204,38 @@ async def _check_plugin_dept_update(self, target_plugin: str | None = None): await pip_installer.install(requirements_path=pth) except Exception as e: logger.error(f"更新插件 {p} 的依赖失败。Code: {e!s}") + return True + + async def _import_plugin_with_dependency_recovery( + self, + path: str, + module_str: str, + root_dir_name: str, + requirements_path: str, + ) -> ModuleType: + try: + return __import__(path, fromlist=[module_str]) + except (ModuleNotFoundError, ImportError) as import_exc: + if os.path.exists(requirements_path): + try: + logger.info( + f"插件 {root_dir_name} 导入失败,尝试从已安装依赖恢复: {import_exc!s}" + ) + pip_installer.prefer_installed_dependencies( + requirements_path=requirements_path + ) + module = __import__(path, fromlist=[module_str]) + logger.info( + f"插件 {root_dir_name} 已从 site-packages 恢复依赖,跳过重新安装。" + ) + return module + except Exception as recover_exc: + logger.info( + f"插件 {root_dir_name} 已安装依赖恢复失败,将重新安装依赖: {recover_exc!s}" + ) + + await self._check_plugin_dept_update(target_plugin=root_dir_name) + return __import__(path, fromlist=[module_str]) @staticmethod def _load_plugin_metadata(plugin_path: str, plugin_obj=None) -> StarMetadata | None: @@ -231,10 +278,58 @@ def _load_plugin_metadata(plugin_path: str, plugin_obj=None) -> StarMetadata | N version=metadata["version"], repo=metadata["repo"] if "repo" in metadata else None, display_name=metadata.get("display_name", None), + support_platforms=( + [ + platform_id + for platform_id in metadata["support_platforms"] + if isinstance(platform_id, str) + ] + if isinstance(metadata.get("support_platforms"), list) + else [] + ), + astrbot_version=( + metadata["astrbot_version"] + if isinstance(metadata.get("astrbot_version"), str) + else None + ), ) return metadata + @staticmethod + def _validate_astrbot_version_specifier( + version_spec: str | None, + ) -> tuple[bool, str | None]: + if not version_spec: + return True, None + + normalized_spec = version_spec.strip() + if not normalized_spec: + return True, None + + try: + specifier = SpecifierSet(normalized_spec) + except InvalidSpecifier: + return ( + False, + "astrbot_version 格式无效,请使用 PEP 440 版本范围格式,例如 >=4.16,<5。", + ) + + try: + current_version = Version(VERSION) + except InvalidVersion: + return ( + False, + f"AstrBot 当前版本 {VERSION} 无法被解析,无法校验插件版本范围。", + ) + + if current_version not in specifier: + return ( + False, + f"当前 AstrBot 版本为 {VERSION},不满足插件要求的 astrbot_version: {normalized_spec}", + ) + return True, None + @staticmethod def _get_plugin_related_modules( plugin_root_dir: str, @@ -264,7 +359,7 @@ def _purge_modules( module_patterns: list[str] | None = None, root_dir_name: str | None = None, is_reserved: bool = False, - ): + ) -> None: """从 sys.modules 中移除指定的模块 可以基于模块名模式或插件目录名移除模块,用于清理插件相关的模块缓存 @@ -293,6 +388,59 @@ def _purge_modules( except KeyError: logger.warning(f"模块 {module_name} 未载入") + def _cleanup_plugin_state(self, dir_name: str) -> None: + plugin_root_name = "data.plugins." + + # 清理 sys.modules + for key in list(sys.modules.keys()): + if key.startswith(f"{plugin_root_name}{dir_name}"): + logger.info(f"清除了插件{dir_name}中的{key}模块") + del sys.modules[key] + + possible_paths = [ + f"{plugin_root_name}{dir_name}.main", + f"{plugin_root_name}{dir_name}.{dir_name}", + ] + + # 清理 handlers + for path in possible_paths: + handlers = star_handlers_registry.get_handlers_by_module_name(path) + for handler in handlers: + star_handlers_registry.remove(handler) + logger.info(f"清理处理器: {handler.handler_name}") + + # 清理工具 + for tool in list(llm_tools.func_list): + if tool.handler_module_path in possible_paths: + llm_tools.func_list.remove(tool) + logger.info(f"清理工具: {tool.name}") + + async def reload_failed_plugin(self, dir_name): + """ + 重新加载未注册(加载失败)的插件 + Args: + dir_name (str): 要重载的特定插件名称。 + Returns: + tuple: 返回 load() 方法的结果,包含 (success, error_message) + - success (bool): 重载是否成功 + - error_message (str|None): 错误信息,成功时为 None + """ + + async with self._pm_lock: + if dir_name not in self.failed_plugin_dict: + return False, "插件不存在于失败列表中" + + self._cleanup_plugin_state(dir_name) + + success, error = await self.load(specified_dir_name=dir_name) + if success: + self.failed_plugin_dict.pop(dir_name, None) + if not self.failed_plugin_dict: + self.failed_plugin_info = "" + return success, None + else: + return False, error + async def reload(self, specified_plugin_name=None): """重新加载插件 @@ -349,7 +497,12 @@ async def reload(self, specified_plugin_name=None): return result - async def load(self, specified_module_path=None, specified_dir_name=None): + async def load( + self, + specified_module_path=None, + specified_dir_name=None, + ignore_version_check: bool = False, + ): """载入插件。 当 specified_module_path 或者 specified_dir_name 不为 None 时,只载入指定的插件。 @@ -383,6 +536,12 @@ async def load(self, specified_module_path=None, specified_dir_name=None): "reserved", False, ) # 是否是保留插件。目前在 astrbot/builtin_stars 目录下的都是保留插件。保留插件不可以卸载。 + plugin_dir_path = ( + os.path.join(self.plugin_store_path, root_dir_name) + if not reserved + else os.path.join(self.reserved_plugin_path, root_dir_name) + ) + requirements_path = os.path.join(plugin_dir_path, "requirements.txt") path = "data.plugins." if not reserved else "astrbot.builtin_stars." path += root_dir_name + "." + module_str @@ -397,23 +556,30 @@ async def load(self, specified_module_path=None, specified_dir_name=None): # 尝试导入模块 try: - module = __import__(path, fromlist=[module_str]) - except (ModuleNotFoundError, ImportError): - # 尝试安装依赖 - await self._check_plugin_dept_update(target_plugin=root_dir_name) - module = __import__(path, fromlist=[module_str]) + module = await self._import_plugin_with_dependency_recovery( + path=path, + module_str=module_str, + root_dir_name=root_dir_name, + requirements_path=requirements_path, + ) except Exception as e: - logger.error(traceback.format_exc()) + error_trace = traceback.format_exc() + logger.error(error_trace) logger.error(f"插件 {root_dir_name} 导入失败。原因:{e!s}") + fail_rec += f"加载 {root_dir_name} 插件时出现问题,原因 {e!s}。\n" + self.failed_plugin_dict[root_dir_name] = { + "error": str(e), + "traceback": error_trace, + } + if path in star_map: + logger.info("失败插件依旧在插件列表中,正在清理...") + metadata = star_map.pop(path) + if metadata in star_registry: + star_registry.remove(metadata) continue # 检查 _conf_schema.json plugin_config = None - plugin_dir_path = ( - os.path.join(self.plugin_store_path, root_dir_name) - if not reserved - else os.path.join(self.reserved_plugin_path, root_dir_name) - ) plugin_schema_path = os.path.join( plugin_dir_path, self.conf_schema_fname, @@ -446,12 +612,37 @@ async def load(self, specified_module_path=None, specified_dir_name=None): metadata.version = metadata_yaml.version metadata.repo = metadata_yaml.repo metadata.display_name = metadata_yaml.display_name + metadata.support_platforms = metadata_yaml.support_platforms + metadata.astrbot_version = metadata_yaml.astrbot_version except Exception as e: logger.warning( f"插件 {root_dir_name} 元数据载入失败: {e!s}。使用默认元数据。", ) + + if not ignore_version_check: + is_valid, error_message = ( + self._validate_astrbot_version_specifier( + metadata.astrbot_version, + ) + ) + if not is_valid: + raise PluginVersionIncompatibleError( + error_message + or "The plugin is not compatible with the current AstrBot version." + ) + logger.info(metadata) metadata.config = plugin_config + p_name = (metadata.name or "unknown").lower().replace("/", "_") + p_author = (metadata.author or "unknown").lower().replace("/", "_") + plugin_id = f"{p_author}/{p_name}" + + # 在实例化前注入类属性,保证插件 __init__ 可读取这些值 + if metadata.star_cls_type: + setattr(metadata.star_cls_type, "name", p_name) + setattr(metadata.star_cls_type, "author", p_author) + setattr(metadata.star_cls_type, "plugin_id", plugin_id) + if path not in inactivated_plugins: # 只有没有禁用插件时才实例化插件类 if plugin_config and metadata.star_cls_type: @@ -469,17 +660,10 @@ async def load(self, specified_module_path=None, specified_dir_name=None): context=self.context, ) - p_name = (metadata.name or "unknown").lower().replace("/", "_") - p_author = ( - (metadata.author or "unknown").lower().replace("/", "_") - ) - setattr(metadata.star_cls, "name", p_name) - setattr(metadata.star_cls, "author", p_author) - setattr( - metadata.star_cls, - "plugin_id", - f"{p_author}/{p_name}", - ) + if metadata.star_cls: + setattr(metadata.star_cls, "name", p_name) + setattr(metadata.star_cls, "author", p_author) + setattr(metadata.star_cls, "plugin_id", plugin_id) else: logger.info(f"插件 {metadata.name} 已被禁用。") @@ -557,6 +741,19 @@ async def load(self, specified_module_path=None, specified_dir_name=None): ) if not metadata: raise Exception(f"无法找到插件 {plugin_dir_path} 的元数据。") + + if not ignore_version_check: + is_valid, error_message = ( + self._validate_astrbot_version_specifier( + metadata.astrbot_version, + ) + ) + if not is_valid: + raise PluginVersionIncompatibleError( + error_message + or "The plugin is not compatible with the current AstrBot version." + ) + metadata.star_cls = obj metadata.config = plugin_config metadata.module = module @@ -620,6 +817,19 @@ async def load(self, specified_module_path=None, specified_dir_name=None): if hasattr(metadata.star_cls, "initialize") and metadata.star_cls: await metadata.star_cls.initialize() + # 触发插件加载事件 + handlers = star_handlers_registry.get_handlers_by_event_type( + EventType.OnPluginLoadedEvent, + ) + for handler in handlers: + try: + logger.info( + f"hook(on_plugin_loaded) -> {star_map[handler.handler_module_path].name} - {handler.handler_name}", + ) + await handler.handler(metadata) + except Exception: + logger.error(traceback.format_exc()) + except BaseException as e: logger.error(f"----- 插件 {root_dir_name} 载入失败 -----") errors = traceback.format_exc() @@ -627,6 +837,16 @@ async def load(self, specified_module_path=None, specified_dir_name=None): logger.error(f"| {line}") logger.error("----------------------------------") fail_rec += f"加载 {root_dir_name} 插件时出现问题,原因 {e!s}。\n" + self.failed_plugin_dict[root_dir_name] = { + "error": str(e), + "traceback": errors, + } + # 记录注册失败的插件名称,以便后续重载插件 + if path in star_map: + logger.info("失败插件依旧在插件列表中,正在清理...") + metadata = star_map.pop(path) + if metadata in star_registry: + star_registry.remove(metadata) # 清除 pip.main 导致的多余的 logging handlers for handler in logging.root.handlers[:]: @@ -642,7 +862,52 @@ async def load(self, specified_module_path=None, specified_dir_name=None): self.failed_plugin_info = fail_rec return False, fail_rec - async def install_plugin(self, repo_url: str, proxy=""): + async def _cleanup_failed_plugin_install( + self, + dir_name: str, + plugin_path: str, + ) -> None: + plugin = None + for star in self.context.get_all_stars(): + if star.root_dir_name == dir_name: + plugin = star + break + + if plugin and plugin.name and plugin.module_path: + try: + await self._terminate_plugin(plugin) + except Exception: + logger.warning(traceback.format_exc()) + try: + await self._unbind_plugin(plugin.name, plugin.module_path) + except Exception: + logger.warning(traceback.format_exc()) + + if os.path.exists(plugin_path): + try: + remove_dir(plugin_path) + logger.warning(f"已清理安装失败的插件目录: {plugin_path}") + except Exception as e: + logger.warning( + f"清理安装失败插件目录失败: {plugin_path},原因: {e!s}", + ) + + plugin_config_path = os.path.join( + self.plugin_config_path, + f"{dir_name}_config.json", + ) + if os.path.exists(plugin_config_path): + try: + os.remove(plugin_config_path) + logger.warning(f"已清理安装失败插件配置: {plugin_config_path}") + except Exception as e: + logger.warning( + f"清理安装失败插件配置失败: {plugin_config_path},原因: {e!s}", + ) + + async def install_plugin( + self, repo_url: str, proxy: str = "", ignore_version_check: bool = False + ): """从仓库 URL 安装插件 从指定的仓库 URL 下载并安装插件,然后加载该插件到系统中 @@ -667,51 +932,72 @@ async def install_plugin(self, repo_url: str, proxy=""): ) async with self._pm_lock: - plugin_path = await self.updator.install(repo_url, proxy) - # reload the plugin - dir_name = os.path.basename(plugin_path) - await self.load(specified_dir_name=dir_name) + plugin_path = "" + dir_name = "" + cleanup_required = False + try: + plugin_path = await self.updator.install(repo_url, proxy) + cleanup_required = True + + # reload the plugin + dir_name = os.path.basename(plugin_path) + success, error_message = await self.load( + specified_dir_name=dir_name, + ignore_version_check=ignore_version_check, + ) + if not success: + raise Exception( + error_message + or f"安装插件 {dir_name} 失败,请检查插件依赖或兼容性。" + ) - # Get the plugin metadata to return repo info - plugin = self.context.get_registered_star(dir_name) - if not plugin: - # Try to find by other name if directory name doesn't match plugin name - for star in self.context.get_all_stars(): - if star.root_dir_name == dir_name: - plugin = star - break + # Get the plugin metadata to return repo info + plugin = self.context.get_registered_star(dir_name) + if not plugin: + # Try to find by other name if directory name doesn't match plugin name + for star in self.context.get_all_stars(): + if star.root_dir_name == dir_name: + plugin = star + break + + # Extract README.md content if exists + readme_content = None + readme_path = os.path.join(plugin_path, "README.md") + if not os.path.exists(readme_path): + readme_path = os.path.join(plugin_path, "readme.md") + + if os.path.exists(readme_path): + try: + with open(readme_path, encoding="utf-8") as f: + readme_content = f.read() + except Exception as e: + logger.warning( + f"读取插件 {dir_name} 的 README.md 文件失败: {e!s}", + ) - # Extract README.md content if exists - readme_content = None - readme_path = os.path.join(plugin_path, "README.md") - if not os.path.exists(readme_path): - readme_path = os.path.join(plugin_path, "readme.md") + plugin_info = None + if plugin: + plugin_info = { + "repo": plugin.repo, + "readme": readme_content, + "name": plugin.name, + } - if os.path.exists(readme_path): - try: - with open(readme_path, encoding="utf-8") as f: - readme_content = f.read() - except Exception as e: - logger.warning( - f"读取插件 {dir_name} 的 README.md 文件失败: {e!s}", + return plugin_info + except Exception: + if cleanup_required and dir_name and plugin_path: + await self._cleanup_failed_plugin_install( + dir_name=dir_name, + plugin_path=plugin_path, ) - - plugin_info = None - if plugin: - plugin_info = { - "repo": plugin.repo, - "readme": readme_content, - "name": plugin.name, - } - - return plugin_info + raise async def uninstall_plugin( self, plugin_name: str, delete_config: bool = False, delete_data: bool = False, - ): + ) -> None: """卸载指定的插件。 Args: @@ -800,7 +1086,7 @@ async def uninstall_plugin( except Exception as e: logger.warning(f"删除插件持久化数据失败 (plugins_data): {e!s}") - async def _unbind_plugin(self, plugin_name: str, plugin_module_path: str): + async def _unbind_plugin(self, plugin_name: str, plugin_module_path: str) -> None: """解绑并移除一个插件。 Args: @@ -863,7 +1149,7 @@ async def _unbind_plugin(self, plugin_name: str, plugin_module_path: str): is_reserved=plugin.reserved, ) - async def update_plugin(self, plugin_name: str, proxy=""): + async def update_plugin(self, plugin_name: str, proxy="") -> None: """升级一个插件""" plugin = self.context.get_registered_star(plugin_name) if not plugin: @@ -874,7 +1160,7 @@ async def update_plugin(self, plugin_name: str, proxy=""): await self.updator.update(plugin, proxy=proxy) await self.reload(plugin_name) - async def turn_off_plugin(self, plugin_name: str): + async def turn_off_plugin(self, plugin_name: str) -> None: """禁用一个插件。 调用插件的 terminate() 方法, 将插件的 module_path 加入到 data/shared_preferences.json 的 inactivated_plugins 列表中。 @@ -916,7 +1202,7 @@ async def turn_off_plugin(self, plugin_name: str): plugin.activated = False @staticmethod - async def _terminate_plugin(star_metadata: StarMetadata): + async def _terminate_plugin(star_metadata: StarMetadata) -> None: """终止插件,调用插件的 terminate() 和 __del__() 方法""" logger.info(f"正在终止插件 {star_metadata.name} ...") @@ -936,7 +1222,20 @@ async def _terminate_plugin(star_metadata: StarMetadata): elif "terminate" in star_metadata.star_cls_type.__dict__: await star_metadata.star_cls.terminate() - async def turn_on_plugin(self, plugin_name: str): + # 触发插件卸载事件 + handlers = star_handlers_registry.get_handlers_by_event_type( + EventType.OnPluginUnloadedEvent, + ) + for handler in handlers: + try: + logger.info( + f"hook(on_plugin_unloaded) -> {star_map[handler.handler_module_path].name} - {handler.handler_name}", + ) + await handler.handler(star_metadata) + except Exception: + logger.error(traceback.format_exc()) + + async def turn_on_plugin(self, plugin_name: str) -> None: plugin = self.context.get_registered_star(plugin_name) if plugin is None: raise Exception(f"插件 {plugin_name} 不存在。") @@ -962,10 +1261,13 @@ async def turn_on_plugin(self, plugin_name: str): await self.reload(plugin_name) - async def install_plugin_from_file(self, zip_file_path: str): + async def install_plugin_from_file( + self, zip_file_path: str, ignore_version_check: bool = False + ): dir_name = os.path.basename(zip_file_path).replace(".zip", "") dir_name = dir_name.removesuffix("-master").removesuffix("-main").lower() desti_dir = os.path.join(self.plugin_store_path, dir_name) + cleanup_required = False # 第一步:检查是否已安装同目录名的插件,先终止旧插件 existing_plugin = None @@ -985,74 +1287,91 @@ async def install_plugin_from_file(self, zip_file_path: str): existing_plugin.name, existing_plugin.module_path ) - self.updator.unzip_file(zip_file_path, desti_dir) - - # 第二步:解压后,读取新插件的 metadata.yaml,检查是否存在同名但不同目录的插件 try: - new_metadata = self._load_plugin_metadata(desti_dir) - if new_metadata and new_metadata.name: - for star in self.context.get_all_stars(): - if ( - star.name == new_metadata.name - and star.root_dir_name != dir_name - ): - logger.warning( - f"检测到同名插件 {star.name} 存在于不同目录 {star.root_dir_name},正在终止..." - ) - try: - await self._terminate_plugin(star) - except Exception: - logger.warning(traceback.format_exc()) - if star.name and star.module_path: - await self._unbind_plugin(star.name, star.module_path) - break # 只处理第一个匹配的 - except Exception as e: - logger.debug(f"读取新插件 metadata.yaml 失败,跳过同名检查: {e!s}") + self.updator.unzip_file(zip_file_path, desti_dir) + cleanup_required = True - # remove the zip - try: - os.remove(zip_file_path) - except BaseException as e: - logger.warning(f"删除插件压缩包失败: {e!s}") - # await self.reload() - await self.load(specified_dir_name=dir_name) - - # Get the plugin metadata to return repo info - plugin = self.context.get_registered_star(dir_name) - if not plugin: - # Try to find by other name if directory name doesn't match plugin name - for star in self.context.get_all_stars(): - if star.root_dir_name == dir_name: - plugin = star - break - - # Extract README.md content if exists - readme_content = None - readme_path = os.path.join(desti_dir, "README.md") - if not os.path.exists(readme_path): - readme_path = os.path.join(desti_dir, "readme.md") - - if os.path.exists(readme_path): + # 第二步:解压后,读取新插件的 metadata.yaml,检查是否存在同名但不同目录的插件 try: - with open(readme_path, encoding="utf-8") as f: - readme_content = f.read() + new_metadata = self._load_plugin_metadata(desti_dir) + if new_metadata and new_metadata.name: + for star in self.context.get_all_stars(): + if ( + star.name == new_metadata.name + and star.root_dir_name != dir_name + ): + logger.warning( + f"检测到同名插件 {star.name} 存在于不同目录 {star.root_dir_name},正在终止..." + ) + try: + await self._terminate_plugin(star) + except Exception: + logger.warning(traceback.format_exc()) + if star.name and star.module_path: + await self._unbind_plugin(star.name, star.module_path) + break # 只处理第一个匹配的 except Exception as e: - logger.warning(f"读取插件 {dir_name} 的 README.md 文件失败: {e!s}") - - plugin_info = None - if plugin: - plugin_info = { - "repo": plugin.repo, - "readme": readme_content, - "name": plugin.name, - } - - if plugin.repo: - asyncio.create_task( - Metric.upload( - et="install_star_f", # install star - repo=plugin.repo, - ), + logger.debug(f"读取新插件 metadata.yaml 失败,跳过同名检查: {e!s}") + + # remove the zip + try: + os.remove(zip_file_path) + except BaseException as e: + logger.warning(f"删除插件压缩包失败: {e!s}") + # await self.reload() + success, error_message = await self.load( + specified_dir_name=dir_name, + ignore_version_check=ignore_version_check, + ) + if not success: + raise Exception( + error_message + or f"安装插件 {dir_name} 失败,请检查插件依赖或兼容性。" ) - return plugin_info + # Get the plugin metadata to return repo info + plugin = self.context.get_registered_star(dir_name) + if not plugin: + # Try to find by other name if directory name doesn't match plugin name + for star in self.context.get_all_stars(): + if star.root_dir_name == dir_name: + plugin = star + break + + # Extract README.md content if exists + readme_content = None + readme_path = os.path.join(desti_dir, "README.md") + if not os.path.exists(readme_path): + readme_path = os.path.join(desti_dir, "readme.md") + + if os.path.exists(readme_path): + try: + with open(readme_path, encoding="utf-8") as f: + readme_content = f.read() + except Exception as e: + logger.warning(f"读取插件 {dir_name} 的 README.md 文件失败: {e!s}") + + plugin_info = None + if plugin: + plugin_info = { + "repo": plugin.repo, + "readme": readme_content, + "name": plugin.name, + } + + if plugin.repo: + asyncio.create_task( + Metric.upload( + et="install_star_f", # install star + repo=plugin.repo, + ), + ) + + return plugin_info + except Exception: + if cleanup_required: + await self._cleanup_failed_plugin_install( + dir_name=dir_name, + plugin_path=desti_dir, + ) + raise diff --git a/astrbot/core/star/star_tools.py b/astrbot/core/star/star_tools.py index 7a66449b4..4d85131fc 100644 --- a/astrbot/core/star/star_tools.py +++ b/astrbot/core/star/star_tools.py @@ -89,7 +89,7 @@ async def send_message_by_id( id: str, message_chain: MessageChain, platform: str = "aiocqhttp", - ): + ) -> None: """根据 id(例如qq号, 群号等) 直接, 主动地发送消息 Args: diff --git a/astrbot/core/star/updator.py b/astrbot/core/star/updator.py index 8793ad505..1a0c5fc26 100644 --- a/astrbot/core/star/updator.py +++ b/astrbot/core/star/updator.py @@ -52,7 +52,7 @@ async def update(self, plugin: StarMetadata, proxy="") -> str: return plugin_path - def unzip_file(self, zip_path: str, target_dir: str): + def unzip_file(self, zip_path: str, target_dir: str) -> None: os.makedirs(target_dir, exist_ok=True) update_dir = "" logger.info(f"正在解压压缩包: {zip_path}") diff --git a/astrbot/core/subagent_orchestrator.py b/astrbot/core/subagent_orchestrator.py index 62ddc0fd3..205c554cb 100644 --- a/astrbot/core/subagent_orchestrator.py +++ b/astrbot/core/subagent_orchestrator.py @@ -16,7 +16,9 @@ class SubAgentOrchestrator: Execution happens via HandoffTool in FunctionToolExecutor. """ - def __init__(self, tool_mgr: FunctionToolManager, persona_mgr: PersonaManager): + def __init__( + self, tool_mgr: FunctionToolManager, persona_mgr: PersonaManager + ) -> None: self._tool_mgr = tool_mgr self._persona_mgr = persona_mgr self.handoffs: list[HandoffTool] = [] diff --git a/astrbot/core/tools/cron_tools.py b/astrbot/core/tools/cron_tools.py index ee22b943d..d504f128a 100644 --- a/astrbot/core/tools/cron_tools.py +++ b/astrbot/core/tools/cron_tools.py @@ -1,4 +1,5 @@ from datetime import datetime +from typing import Any from pydantic import Field from pydantic.dataclasses import dataclass @@ -8,6 +9,14 @@ from astrbot.core.astr_agent_context import AstrAgentContext +def _extract_job_session(job: Any) -> str | None: + payload = getattr(job, "payload", None) + if not isinstance(payload, dict): + return None + session = payload.get("session") + return str(session) if session is not None else None + + @dataclass class CreateActiveCronTool(FunctionTool[AstrAgentContext]): name: str = "create_future_task" @@ -119,9 +128,15 @@ async def call( cron_mgr = context.context.context.cron_manager if cron_mgr is None: return "error: cron manager is not available." + current_umo = context.context.event.unified_msg_origin job_id = kwargs.get("job_id") if not job_id: return "error: job_id is required." + job = await cron_mgr.db.get_cron_job(str(job_id)) + if not job: + return f"error: cron job {job_id} not found." + if _extract_job_session(job) != current_umo: + return "error: you can only delete future tasks in the current umo." await cron_mgr.delete_job(str(job_id)) return f"Deleted cron job {job_id}." @@ -148,8 +163,13 @@ async def call( cron_mgr = context.context.context.cron_manager if cron_mgr is None: return "error: cron manager is not available." + current_umo = context.context.event.unified_msg_origin job_type = kwargs.get("job_type") - jobs = await cron_mgr.list_jobs(job_type) + jobs = [ + job + for job in await cron_mgr.list_jobs(job_type) + if _extract_job_session(job) == current_umo + ] if not jobs: return "No cron jobs found." lines = [] diff --git a/astrbot/core/umop_config_router.py b/astrbot/core/umop_config_router.py index 1f2289f4d..d8b010d50 100644 --- a/astrbot/core/umop_config_router.py +++ b/astrbot/core/umop_config_router.py @@ -6,15 +6,15 @@ class UmopConfigRouter: """UMOP 配置路由器""" - def __init__(self, sp: SharedPreferences): + def __init__(self, sp: SharedPreferences) -> None: self.umop_to_conf_id: dict[str, str] = {} """UMOP 到配置文件 ID 的映射""" self.sp = sp - async def initialize(self): + async def initialize(self) -> None: await self._load_routing_table() - async def _load_routing_table(self): + async def _load_routing_table(self) -> None: """加载路由表""" # 从 SharedPreferences 中加载 umop_to_conf_id 映射 sp_data = await self.sp.get_async( @@ -50,7 +50,7 @@ def get_conf_id_for_umop(self, umo: str) -> str | None: return conf_id return None - async def update_routing_data(self, new_routing: dict[str, str]): + async def update_routing_data(self, new_routing: dict[str, str]) -> None: """更新路由表 Args: @@ -70,7 +70,7 @@ async def update_routing_data(self, new_routing: dict[str, str]): self.umop_to_conf_id = new_routing await self.sp.global_put("umop_config_routing", self.umop_to_conf_id) - async def update_route(self, umo: str, conf_id: str): + async def update_route(self, umo: str, conf_id: str) -> None: """更新一条路由 Args: @@ -89,7 +89,7 @@ async def update_route(self, umo: str, conf_id: str): self.umop_to_conf_id[umo] = conf_id await self.sp.global_put("umop_config_routing", self.umop_to_conf_id) - async def delete_route(self, umo: str): + async def delete_route(self, umo: str) -> None: """删除一条路由 Args: diff --git a/astrbot/core/updator.py b/astrbot/core/updator.py index 0a7116a0d..049a19789 100644 --- a/astrbot/core/updator.py +++ b/astrbot/core/updator.py @@ -23,7 +23,7 @@ def __init__(self, repo_mirror: str = "") -> None: self.MAIN_PATH = get_astrbot_path() self.ASTRBOT_RELEASE_API = "https://api.soulter.top/releases" - def terminate_child_processes(self): + def terminate_child_processes(self) -> None: """终止当前进程的所有子进程 使用 psutil 库获取当前进程的所有子进程,并尝试终止它们 """ @@ -44,29 +44,88 @@ def terminate_child_processes(self): except psutil.NoSuchProcess: pass - def _reboot(self, delay: int = 3): + @staticmethod + def _is_option_arg(arg: str) -> bool: + return arg.startswith("-") + + @classmethod + def _collect_flag_values(cls, argv: list[str], flag: str) -> str | None: + try: + idx = argv.index(flag) + except ValueError: + return None + + if idx + 1 >= len(argv): + return None + + value_parts: list[str] = [] + for arg in argv[idx + 1 :]: + if cls._is_option_arg(arg): + break + if arg: + value_parts.append(arg) + + if not value_parts: + return None + + return " ".join(value_parts).strip() or None + + @classmethod + def _resolve_webui_dir_arg(cls, argv: list[str]) -> str | None: + return cls._collect_flag_values(argv, "--webui-dir") + + def _build_frozen_reboot_args(self) -> list[str]: + argv = list(sys.argv[1:]) + webui_dir = self._resolve_webui_dir_arg(argv) + if not webui_dir: + webui_dir = os.environ.get("ASTRBOT_WEBUI_DIR") + + if webui_dir: + return ["--webui-dir", webui_dir] + return [] + + @staticmethod + def _reset_pyinstaller_environment() -> None: + if not getattr(sys, "frozen", False): + return + os.environ["PYINSTALLER_RESET_ENVIRONMENT"] = "1" + for key in list(os.environ.keys()): + if key.startswith("_PYI_"): + os.environ.pop(key, None) + + def _build_reboot_argv(self, executable: str) -> list[str]: + if os.environ.get("ASTRBOT_CLI") == "1": + args = sys.argv[1:] + return [executable, "-m", "astrbot.cli.__main__", *args] + if getattr(sys, "frozen", False): + args = self._build_frozen_reboot_args() + return [executable, *args] + return [executable, *sys.argv] + + @staticmethod + def _exec_reboot(executable: str, argv: list[str]) -> None: + if os.name == "nt" and getattr(sys, "frozen", False): + quoted_executable = f'"{executable}"' if " " in executable else executable + quoted_args = [f'"{arg}"' if " " in arg else arg for arg in argv[1:]] + os.execl(executable, quoted_executable, *quoted_args) + return + os.execv(executable, argv) + + def _reboot(self, delay: int = 3) -> None: """重启当前程序 在指定的延迟后,终止所有子进程并重新启动程序 这里只能使用 os.exec* 来重启程序 """ time.sleep(delay) self.terminate_child_processes() - if os.name == "nt": - py = f'"{sys.executable}"' - else: - py = sys.executable + executable = sys.executable try: - if "astrbot" in os.path.basename(sys.argv[0]): # 兼容cli - if os.name == "nt": - args = [f'"{arg}"' if " " in arg else arg for arg in sys.argv[1:]] - else: - args = sys.argv[1:] - os.execl(sys.executable, py, "-m", "astrbot.cli.__main__", *args) - else: - os.execl(sys.executable, py, *sys.argv) + self._reset_pyinstaller_environment() + reboot_argv = self._build_reboot_argv(executable) + self._exec_reboot(executable, reboot_argv) except Exception as e: - logger.error(f"重启失败({py}, {e}),请尝试手动重启。") + logger.error(f"重启失败({executable}, {e}),请尝试手动重启。") raise e async def check_update( @@ -85,12 +144,12 @@ async def check_update( async def get_releases(self) -> list: return await self.fetch_release_info(self.ASTRBOT_RELEASE_API) - async def update(self, reboot=False, latest=True, version=None, proxy=""): + async def update(self, reboot=False, latest=True, version=None, proxy="") -> None: update_data = await self.fetch_release_info(self.ASTRBOT_RELEASE_API, latest) file_url = None - if os.environ.get("ASTRBOT_CLI"): - raise Exception("不支持更新CLI启动的AstrBot") # 避免版本管理混乱 + if os.environ.get("ASTRBOT_CLI") or os.environ.get("ASTRBOT_LAUNCHER"): + raise Exception("不支持更新此方式启动的AstrBot") # 避免版本管理混乱 if latest: latest_version = update_data[0]["tag_name"] diff --git a/astrbot/core/utils/active_event_registry.py b/astrbot/core/utils/active_event_registry.py new file mode 100644 index 000000000..d98cdee37 --- /dev/null +++ b/astrbot/core/utils/active_event_registry.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +from collections import defaultdict +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from astrbot.core.platform import AstrMessageEvent + + +class ActiveEventRegistry: + """维护 unified_msg_origin 到活跃事件的映射。 + + 用于在 reset 等场景下终止该会话正在处理的事件。 + """ + + def __init__(self) -> None: + self._events: dict[str, set[AstrMessageEvent]] = defaultdict(set) + + def register(self, event: AstrMessageEvent) -> None: + self._events[event.unified_msg_origin].add(event) + + def unregister(self, event: AstrMessageEvent) -> None: + umo = event.unified_msg_origin + self._events[umo].discard(event) + if not self._events[umo]: + del self._events[umo] + + def stop_all( + self, + umo: str, + exclude: AstrMessageEvent | None = None, + ) -> int: + """终止指定 UMO 的所有活跃事件。 + + Args: + umo: 统一消息来源标识符。 + exclude: 需要排除的事件(通常是发起 reset 的事件本身)。 + + Returns: + 被终止的事件数量。 + """ + count = 0 + for event in list(self._events.get(umo, [])): + if event is not exclude: + event.stop_event() + count += 1 + return count + + def request_agent_stop_all( + self, + umo: str, + exclude: AstrMessageEvent | None = None, + ) -> int: + """请求停止指定 UMO 的所有活跃事件中的 Agent 运行。 + + 与 stop_all 不同,这里不会调用 event.stop_event(), + 因此不会中断事件传播,后续流程(如历史记录保存)仍可继续。 + """ + count = 0 + for event in list(self._events.get(umo, [])): + if event is not exclude: + event.set_extra("agent_stop_requested", True) + count += 1 + return count + + +active_event_registry = ActiveEventRegistry() diff --git a/astrbot/core/utils/astrbot_path.py b/astrbot/core/utils/astrbot_path.py index 1acf0d268..987ce110a 100644 --- a/astrbot/core/utils/astrbot_path.py +++ b/astrbot/core/utils/astrbot_path.py @@ -10,10 +10,13 @@ WebChat 数据目录路径:固定为数据目录下的 webchat 目录 临时文件目录路径:固定为数据目录下的 temp 目录 Skills 目录路径:固定为数据目录下的 skills 目录 +第三方依赖目录路径:固定为数据目录下的 site-packages 目录 """ import os +from astrbot.core.utils.runtime_env import is_packaged_desktop_runtime + def get_astrbot_path() -> str: """获取Astrbot项目路径""" @@ -26,6 +29,8 @@ def get_astrbot_root() -> str: """获取Astrbot根目录路径""" if path := os.environ.get("ASTRBOT_ROOT"): return os.path.realpath(path) + if is_packaged_desktop_runtime(): + return os.path.realpath(os.path.join(os.path.expanduser("~"), ".astrbot")) return os.path.realpath(os.getcwd()) @@ -69,6 +74,11 @@ def get_astrbot_skills_path() -> str: return os.path.realpath(os.path.join(get_astrbot_data_path(), "skills")) +def get_astrbot_site_packages_path() -> str: + """获取Astrbot第三方依赖目录路径""" + return os.path.realpath(os.path.join(get_astrbot_data_path(), "site-packages")) + + def get_astrbot_knowledge_base_path() -> str: """获取Astrbot知识库根目录路径""" return os.path.realpath(os.path.join(get_astrbot_data_path(), "knowledge_base")) diff --git a/astrbot/core/utils/http_ssl.py b/astrbot/core/utils/http_ssl.py new file mode 100644 index 000000000..ee0bbc0d0 --- /dev/null +++ b/astrbot/core/utils/http_ssl.py @@ -0,0 +1,33 @@ +import logging +import ssl +import threading + +import aiohttp + +from astrbot.utils.http_ssl_common import ( + build_ssl_context_with_certifi as _build_ssl_context, +) + +logger = logging.getLogger("astrbot") + +_SHARED_TLS_CONTEXT: ssl.SSLContext | None = None +_SHARED_TLS_CONTEXT_LOCK = threading.Lock() + + +def build_ssl_context_with_certifi() -> ssl.SSLContext: + """Build an SSL context from system trust store and add certifi CAs.""" + global _SHARED_TLS_CONTEXT + + if _SHARED_TLS_CONTEXT is not None: + return _SHARED_TLS_CONTEXT + + with _SHARED_TLS_CONTEXT_LOCK: + if _SHARED_TLS_CONTEXT is not None: + return _SHARED_TLS_CONTEXT + + _SHARED_TLS_CONTEXT = _build_ssl_context(log_obj=logger) + return _SHARED_TLS_CONTEXT + + +def build_tls_connector() -> aiohttp.TCPConnector: + return aiohttp.TCPConnector(ssl=build_ssl_context_with_certifi()) diff --git a/astrbot/core/utils/io.py b/astrbot/core/utils/io.py index 04adca634..0ce3624e8 100644 --- a/astrbot/core/utils/io.py +++ b/astrbot/core/utils/io.py @@ -1,4 +1,3 @@ -import asyncio import base64 import logging import os @@ -8,7 +7,6 @@ import time import uuid import zipfile -from ipaddress import IPv4Address, IPv6Address, ip_address from pathlib import Path import aiohttp @@ -16,12 +14,12 @@ import psutil from PIL import Image -from .astrbot_path import get_astrbot_data_path +from .astrbot_path import get_astrbot_data_path, get_astrbot_temp_path logger = logging.getLogger("astrbot") -def on_error(func, path, exc_info): +def on_error(func, path, exc_info) -> None: """A callback of the rmtree function.""" import stat @@ -39,7 +37,7 @@ def remove_dir(file_path: str) -> bool: return True -def port_checker(port: int, host: str = "localhost"): +def port_checker(port: int, host: str = "localhost") -> bool: sk = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sk.settimeout(1) try: @@ -52,21 +50,10 @@ def port_checker(port: int, host: str = "localhost"): def save_temp_img(img: Image.Image | bytes) -> str: - temp_dir = os.path.join(get_astrbot_data_path(), "temp") - # 获得文件创建时间,清除超过 12 小时的 - try: - for f in os.listdir(temp_dir): - path = os.path.join(temp_dir, f) - if os.path.isfile(path): - ctime = os.path.getctime(path) - if time.time() - ctime > 3600 * 12: - os.remove(path) - except Exception as e: - print(f"清除临时文件失败: {e}") - + temp_dir = get_astrbot_temp_path() # 获得时间戳 timestamp = f"{int(time.time())}_{uuid.uuid4().hex[:8]}" - p = os.path.join(temp_dir, f"{timestamp}.jpg") + p = os.path.join(temp_dir, f"io_temp_img_{timestamp}.jpg") if isinstance(img, Image.Image): img.save(p) @@ -136,7 +123,7 @@ async def download_image_by_url( raise e -async def download_file(url: str, path: str, show_progress: bool = False): +async def download_file(url: str, path: str, show_progress: bool = False) -> None: """从指定 url 下载文件到指定路径 path""" try: ssl_context = ssl.create_default_context( @@ -219,54 +206,18 @@ def file_to_base64(file_path: str) -> str: return "base64://" + base64_str -def get_local_ip_addresses() -> list[IPv4Address | IPv6Address]: +def get_local_ip_addresses(): net_interfaces = psutil.net_if_addrs() - network_ips: list[IPv4Address | IPv6Address] = [] + network_ips = [] - for _, addrs in net_interfaces.items(): + for interface, addrs in net_interfaces.items(): for addr in addrs: - if addr.family == socket.AF_INET: - network_ips.append(ip_address(addr.address)) - elif addr.family == socket.AF_INET6: - # 过滤掉 IPv6 的 link-local 地址(fe80:...) - # 用这个不如用::1 - ip = ip_address(addr.address.split("%")[0]) # 处理带 zone index 的情况 - if not ip.is_link_local: - network_ips.append(ip) + if addr.family == socket.AF_INET: # 使用 socket.AF_INET 代替 psutil.AF_INET + network_ips.append(addr.address) return network_ips -async def get_public_ip_address() -> list[IPv4Address | IPv6Address]: - urls = [ - "https://api64.ipify.org", - "https://ident.me", - "https://ifconfig.me", - "https://icanhazip.com", - ] - found_ips: dict[int, IPv4Address | IPv6Address] = {} - - async def fetch(session: aiohttp.ClientSession, url: str): - try: - async with session.get(url, timeout=3) as resp: - if resp.status == 200: - raw_ip = (await resp.text()).strip() - ip = ip_address(raw_ip) - if ip.version not in found_ips: - found_ips[ip.version] = ip - except Exception as e: - # Ignore errors from individual services so that a single failing - # endpoint does not prevent discovering the public IP from others. - logger.debug("Failed to fetch public IP from %s: %s", url, e) - - async with aiohttp.ClientSession() as session: - tasks = [fetch(session, url) for url in urls] - await asyncio.gather(*tasks) - - # 返回找到的所有 IP 对象列表 - return list(found_ips.values()) - - async def get_dashboard_version(): dist_dir = os.path.join(get_astrbot_data_path(), "dist") if os.path.exists(dist_dir): diff --git a/astrbot/core/utils/llm_metadata.py b/astrbot/core/utils/llm_metadata.py index 540c1efd9..ef88e9490 100644 --- a/astrbot/core/utils/llm_metadata.py +++ b/astrbot/core/utils/llm_metadata.py @@ -3,6 +3,7 @@ import aiohttp from astrbot.core import logger +from astrbot.core.utils.http_ssl import build_tls_connector class LLMModalities(TypedDict): @@ -29,10 +30,12 @@ class LLMMetadata(TypedDict): LLM_METADATAS: dict[str, LLMMetadata] = {} -async def update_llm_metadata(): +async def update_llm_metadata() -> None: url = "https://models.dev/api.json" try: - async with aiohttp.ClientSession() as session: + async with aiohttp.ClientSession( + trust_env=True, connector=build_tls_connector() + ) as session: async with session.get(url) as response: data = await response.json() global LLM_METADATAS diff --git a/astrbot/core/utils/log_pipe.py b/astrbot/core/utils/log_pipe.py index 2e931dd81..6f40f0942 100644 --- a/astrbot/core/utils/log_pipe.py +++ b/astrbot/core/utils/log_pipe.py @@ -10,7 +10,7 @@ def __init__( logger: Logger, identifier=None, callback=None, - ): + ) -> None: threading.Thread.__init__(self) self.daemon = True self.level = level @@ -24,7 +24,7 @@ def __init__( def fileno(self): return self.fd_write - def run(self): + def run(self) -> None: for line in iter(self.reader.readline, ""): if self.callback: self.callback(line.strip()) @@ -32,5 +32,5 @@ def run(self): self.reader.close() - def close(self): + def close(self) -> None: os.close(self.fd_write) diff --git a/astrbot/core/utils/media_utils.py b/astrbot/core/utils/media_utils.py new file mode 100644 index 000000000..8d833514f --- /dev/null +++ b/astrbot/core/utils/media_utils.py @@ -0,0 +1,318 @@ +"""媒体文件处理工具 + +提供音视频格式转换、时长获取等功能。 +""" + +import asyncio +import os +import subprocess +import uuid +from pathlib import Path + +from astrbot import logger +from astrbot.core.utils.astrbot_path import get_astrbot_temp_path + + +async def get_media_duration(file_path: str) -> int | None: + """使用ffprobe获取媒体文件时长 + + Args: + file_path: 媒体文件路径 + + Returns: + 时长(毫秒),如果获取失败返回None + """ + try: + # 使用ffprobe获取时长 + process = await asyncio.create_subprocess_exec( + "ffprobe", + "-v", + "error", + "-show_entries", + "format=duration", + "-of", + "default=noprint_wrappers=1:nokey=1", + file_path, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + + stdout, stderr = await process.communicate() + + if process.returncode == 0 and stdout: + duration_seconds = float(stdout.decode().strip()) + duration_ms = int(duration_seconds * 1000) + logger.debug(f"[Media Utils] 获取媒体时长: {duration_ms}ms") + return duration_ms + else: + logger.warning(f"[Media Utils] 无法获取媒体文件时长: {file_path}") + return None + + except FileNotFoundError: + logger.warning( + "[Media Utils] ffprobe未安装或不在PATH中,无法获取媒体时长。请安装ffmpeg: https://ffmpeg.org/" + ) + return None + except Exception as e: + logger.warning(f"[Media Utils] 获取媒体时长时出错: {e}") + return None + + +async def convert_audio_to_opus(audio_path: str, output_path: str | None = None) -> str: + """使用ffmpeg将音频转换为opus格式 + + Args: + audio_path: 原始音频文件路径 + output_path: 输出文件路径,如果为None则自动生成 + + Returns: + 转换后的opus文件路径 + + Raises: + Exception: 转换失败时抛出异常 + """ + # 如果已经是opus格式,直接返回 + if audio_path.lower().endswith(".opus"): + return audio_path + + # 生成输出文件路径 + if output_path is None: + temp_dir = get_astrbot_temp_path() + os.makedirs(temp_dir, exist_ok=True) + output_path = os.path.join(temp_dir, f"media_audio_{uuid.uuid4().hex}.opus") + + try: + # 使用ffmpeg转换为opus格式 + # -y: 覆盖输出文件 + # -i: 输入文件 + # -acodec libopus: 使用opus编码器 + # -ac 1: 单声道 + # -ar 16000: 采样率16kHz + process = await asyncio.create_subprocess_exec( + "ffmpeg", + "-y", + "-i", + audio_path, + "-acodec", + "libopus", + "-ac", + "1", + "-ar", + "16000", + output_path, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + + stdout, stderr = await process.communicate() + + if process.returncode != 0: + # 清理可能已生成但无效的临时文件 + if output_path and os.path.exists(output_path): + try: + os.remove(output_path) + logger.debug( + f"[Media Utils] 已清理失败的opus输出文件: {output_path}" + ) + except OSError as e: + logger.warning(f"[Media Utils] 清理失败的opus输出文件时出错: {e}") + + error_msg = stderr.decode() if stderr else "未知错误" + logger.error(f"[Media Utils] ffmpeg转换音频失败: {error_msg}") + raise Exception(f"ffmpeg conversion failed: {error_msg}") + + logger.debug(f"[Media Utils] 音频转换成功: {audio_path} -> {output_path}") + return output_path + + except FileNotFoundError: + logger.error( + "[Media Utils] ffmpeg未安装或不在PATH中,无法转换音频格式。请安装ffmpeg: https://ffmpeg.org/" + ) + raise Exception("ffmpeg not found") + except Exception as e: + logger.error(f"[Media Utils] 转换音频格式时出错: {e}") + raise + + +async def convert_video_format( + video_path: str, output_format: str = "mp4", output_path: str | None = None +) -> str: + """使用ffmpeg转换视频格式 + + Args: + video_path: 原始视频文件路径 + output_format: 目标格式,默认mp4 + output_path: 输出文件路径,如果为None则自动生成 + + Returns: + 转换后的视频文件路径 + + Raises: + Exception: 转换失败时抛出异常 + """ + # 如果已经是目标格式,直接返回 + if video_path.lower().endswith(f".{output_format}"): + return video_path + + # 生成输出文件路径 + if output_path is None: + temp_dir = get_astrbot_temp_path() + os.makedirs(temp_dir, exist_ok=True) + output_path = os.path.join( + temp_dir, + f"media_video_{uuid.uuid4().hex}.{output_format}", + ) + + try: + # 使用ffmpeg转换视频格式 + process = await asyncio.create_subprocess_exec( + "ffmpeg", + "-y", + "-i", + video_path, + "-c:v", + "libx264", + "-c:a", + "aac", + output_path, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + + stdout, stderr = await process.communicate() + + if process.returncode != 0: + # 清理可能已生成但无效的临时文件 + if output_path and os.path.exists(output_path): + try: + os.remove(output_path) + logger.debug( + f"[Media Utils] 已清理失败的{output_format}输出文件: {output_path}" + ) + except OSError as e: + logger.warning( + f"[Media Utils] 清理失败的{output_format}输出文件时出错: {e}" + ) + + error_msg = stderr.decode() if stderr else "未知错误" + logger.error(f"[Media Utils] ffmpeg转换视频失败: {error_msg}") + raise Exception(f"ffmpeg conversion failed: {error_msg}") + + logger.debug(f"[Media Utils] 视频转换成功: {video_path} -> {output_path}") + return output_path + + except FileNotFoundError: + logger.error( + "[Media Utils] ffmpeg未安装或不在PATH中,无法转换视频格式。请安装ffmpeg: https://ffmpeg.org/" + ) + raise Exception("ffmpeg not found") + except Exception as e: + logger.error(f"[Media Utils] 转换视频格式时出错: {e}") + raise + + +async def convert_audio_format( + audio_path: str, + output_format: str = "amr", + output_path: str | None = None, +) -> str: + """使用ffmpeg将音频转换为指定格式。 + + Args: + audio_path: 原始音频文件路径 + output_format: 目标格式,例如 amr / ogg + output_path: 输出文件路径,如果为None则自动生成 + + Returns: + 转换后的音频文件路径 + """ + if audio_path.lower().endswith(f".{output_format}"): + return audio_path + + if output_path is None: + temp_dir = Path(get_astrbot_temp_path()) + temp_dir.mkdir(parents=True, exist_ok=True) + output_path = str(temp_dir / f"media_audio_{uuid.uuid4().hex}.{output_format}") + + args = ["ffmpeg", "-y", "-i", audio_path] + if output_format == "amr": + args.extend(["-ac", "1", "-ar", "8000", "-ab", "12.2k"]) + elif output_format == "ogg": + args.extend(["-acodec", "libopus", "-ac", "1", "-ar", "16000"]) + args.append(output_path) + + try: + process = await asyncio.create_subprocess_exec( + *args, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + _, stderr = await process.communicate() + if process.returncode != 0: + if output_path and os.path.exists(output_path): + try: + os.remove(output_path) + except OSError as e: + logger.warning(f"[Media Utils] 清理失败的音频输出文件时出错: {e}") + error_msg = stderr.decode() if stderr else "未知错误" + raise Exception(f"ffmpeg conversion failed: {error_msg}") + logger.debug(f"[Media Utils] 音频转换成功: {audio_path} -> {output_path}") + return output_path + except FileNotFoundError: + raise Exception("ffmpeg not found") + + +async def convert_audio_to_amr(audio_path: str, output_path: str | None = None) -> str: + """将音频转换为amr格式。""" + return await convert_audio_format( + audio_path=audio_path, + output_format="amr", + output_path=output_path, + ) + + +async def convert_audio_to_wav(audio_path: str, output_path: str | None = None) -> str: + """将音频转换为wav格式。""" + return await convert_audio_format( + audio_path=audio_path, + output_format="wav", + output_path=output_path, + ) + + +async def extract_video_cover( + video_path: str, + output_path: str | None = None, +) -> str: + """从视频中提取封面图(JPG)。""" + if output_path is None: + temp_dir = Path(get_astrbot_temp_path()) + temp_dir.mkdir(parents=True, exist_ok=True) + output_path = str(temp_dir / f"media_cover_{uuid.uuid4().hex}.jpg") + + try: + process = await asyncio.create_subprocess_exec( + "ffmpeg", + "-y", + "-i", + video_path, + "-ss", + "00:00:00", + "-frames:v", + "1", + output_path, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + _, stderr = await process.communicate() + if process.returncode != 0: + if output_path and os.path.exists(output_path): + try: + os.remove(output_path) + except OSError as e: + logger.warning(f"[Media Utils] 清理失败的视频封面文件时出错: {e}") + error_msg = stderr.decode() if stderr else "未知错误" + raise Exception(f"ffmpeg extract cover failed: {error_msg}") + return output_path + except FileNotFoundError: + raise Exception("ffmpeg not found") diff --git a/astrbot/core/utils/metrics.py b/astrbot/core/utils/metrics.py index d3dc732d2..8fb146428 100644 --- a/astrbot/core/utils/metrics.py +++ b/astrbot/core/utils/metrics.py @@ -40,7 +40,7 @@ def get_installation_id(): return "null" @staticmethod - async def upload(**kwargs): + async def upload(**kwargs) -> None: """上传相关非敏感的指标以更好地了解 AstrBot 的使用情况。上传的指标不会包含任何有关消息文本、用户信息等敏感信息。 Powered by TickStats. diff --git a/astrbot/core/utils/network_utils.py b/astrbot/core/utils/network_utils.py new file mode 100644 index 000000000..727f3762a --- /dev/null +++ b/astrbot/core/utils/network_utils.py @@ -0,0 +1,102 @@ +"""Network error handling utilities for providers.""" + +import httpx + +from astrbot import logger + + +def is_connection_error(exc: BaseException) -> bool: + """Check if an exception is a connection/network related error. + + Uses explicit exception type checking instead of brittle string matching. + Handles httpx network errors, timeouts, and common Python network exceptions. + + Args: + exc: The exception to check + + Returns: + True if the exception is a connection/network error + """ + # Check for httpx network errors + if isinstance( + exc, + ( + httpx.ConnectError, + httpx.ConnectTimeout, + httpx.ReadTimeout, + httpx.WriteTimeout, + httpx.PoolTimeout, + httpx.NetworkError, + httpx.ProxyError, + httpx.RequestError, + ), + ): + return True + + # Check for common Python network errors + if isinstance(exc, (TimeoutError, OSError, ConnectionError)): + return True + + # Check the __cause__ chain for wrapped connection errors + cause = getattr(exc, "__cause__", None) + if cause is not None and cause is not exc: + return is_connection_error(cause) + + return False + + +def log_connection_failure( + provider_label: str, + error: Exception, + proxy: str | None = None, +) -> None: + """Log a connection failure with proxy information. + + If proxy is not provided, will fallback to check os.environ for + http_proxy/https_proxy environment variables. + + Args: + provider_label: The provider name for log prefix (e.g., "OpenAI", "Gemini") + error: The exception that occurred + proxy: The proxy address if configured, or None/empty string + """ + import os + + error_type = type(error).__name__ + + # Fallback to environment proxy if not configured + effective_proxy = proxy + if not effective_proxy: + effective_proxy = os.environ.get( + "http_proxy", os.environ.get("https_proxy", "") + ) + + if effective_proxy: + logger.error( + f"[{provider_label}] 网络/代理连接失败 ({error_type})。" + f"代理地址: {effective_proxy},错误: {error}" + ) + else: + logger.error(f"[{provider_label}] 网络连接失败 ({error_type})。错误: {error}") + + +def create_proxy_client( + provider_label: str, + proxy: str | None = None, +) -> httpx.AsyncClient | None: + """Create an httpx AsyncClient with proxy configuration if provided. + + Note: The caller is responsible for closing the client when done. + Consider using the client as a context manager or calling aclose() explicitly. + + Args: + provider_label: The provider name for log prefix (e.g., "OpenAI", "Gemini") + proxy: The proxy address (e.g., "http://127.0.0.1:7890"), or None/empty + + Returns: + An httpx.AsyncClient configured with the proxy, or None if no proxy + """ + if proxy: + logger.info(f"[{provider_label}] 使用代理: {proxy}") + return httpx.AsyncClient(proxy=proxy) + return None diff --git a/astrbot/core/utils/pip_installer.py b/astrbot/core/utils/pip_installer.py index 663afc081..562a0ed30 100644 --- a/astrbot/core/utils/pip_installer.py +++ b/astrbot/core/utils/pip_installer.py @@ -1,31 +1,537 @@ import asyncio -import locale +import contextlib +import importlib +import importlib.metadata as importlib_metadata +import importlib.util +import io import logging +import os +import re import sys +import threading +from collections import deque + +from astrbot.core.utils.astrbot_path import get_astrbot_site_packages_path +from astrbot.core.utils.runtime_env import is_packaged_desktop_runtime logger = logging.getLogger("astrbot") +_DISTLIB_FINDER_PATCH_ATTEMPTED = False +_SITE_PACKAGES_IMPORT_LOCK = threading.RLock() + + +def _canonicalize_distribution_name(name: str) -> str: + return re.sub(r"[-_.]+", "-", name).strip("-").lower() + + +def _get_pip_main(): + try: + from pip._internal.cli.main import main as pip_main + except ImportError: + try: + from pip import main as pip_main + except ImportError as exc: + raise ImportError( + "pip module is unavailable " + f"(sys.executable={sys.executable}, " + f"frozen={getattr(sys, 'frozen', False)}, " + f"ASTRBOT_DESKTOP_CLIENT={os.environ.get('ASTRBOT_DESKTOP_CLIENT')})" + ) from exc + + return pip_main + + +def _run_pip_main_with_output(pip_main, args: list[str]) -> tuple[int, str]: + stream = io.StringIO() + with contextlib.redirect_stdout(stream), contextlib.redirect_stderr(stream): + result_code = pip_main(args) + return result_code, stream.getvalue() + + +def _cleanup_added_root_handlers(original_handlers: list[logging.Handler]) -> None: + root_logger = logging.getLogger() + original_handler_ids = {id(handler) for handler in original_handlers} + + for handler in list(root_logger.handlers): + if id(handler) not in original_handler_ids: + root_logger.removeHandler(handler) + with contextlib.suppress(Exception): + handler.close() + + +def _prepend_sys_path(path: str) -> None: + normalized_target = os.path.realpath(path) + sys.path[:] = [ + item for item in sys.path if os.path.realpath(item) != normalized_target + ] + sys.path.insert(0, normalized_target) + + +def _module_exists_in_site_packages(module_name: str, site_packages_path: str) -> bool: + base_path = os.path.join(site_packages_path, *module_name.split(".")) + package_init = os.path.join(base_path, "__init__.py") + module_file = f"{base_path}.py" + return os.path.isfile(package_init) or os.path.isfile(module_file) + + +def _is_module_loaded_from_site_packages( + module_name: str, + site_packages_path: str, +) -> bool: + module = sys.modules.get(module_name) + if module is None: + try: + module = importlib.import_module(module_name) + except Exception: + return False + + module_file = getattr(module, "__file__", None) + if not module_file: + return False + + module_path = os.path.realpath(module_file) + site_packages_real = os.path.realpath(site_packages_path) + try: + return ( + os.path.commonpath([module_path, site_packages_real]) == site_packages_real + ) + except ValueError: + return False + + +def _extract_requirement_name(raw_requirement: str) -> str | None: + line = raw_requirement.split("#", 1)[0].strip() + if not line: + return None + if line.startswith(("-r", "--requirement", "-c", "--constraint")): + return None + if line.startswith("-"): + return None + + egg_match = re.search(r"#egg=([A-Za-z0-9_.-]+)", raw_requirement) + if egg_match: + return _canonicalize_distribution_name(egg_match.group(1)) + + candidate = re.split(r"[<>=!~;\s\[]", line, maxsplit=1)[0].strip() + if not candidate: + return None + return _canonicalize_distribution_name(candidate) + -def _robust_decode(line: bytes) -> str: - """解码字节流,兼容不同平台的编码""" +def _extract_requirement_names(requirements_path: str) -> set[str]: + names: set[str] = set() try: - return line.decode("utf-8").strip() - except UnicodeDecodeError: - pass + with open(requirements_path, encoding="utf-8") as requirements_file: + for line in requirements_file: + requirement_name = _extract_requirement_name(line) + if requirement_name: + names.add(requirement_name) + except Exception as exc: + logger.warning("读取依赖文件失败,跳过冲突检测: %s", exc) + return names + + +def _extract_top_level_modules( + distribution: importlib_metadata.Distribution, +) -> set[str]: + try: + text = distribution.read_text("top_level.txt") or "" + except Exception: + return set() + + modules: set[str] = set() + for line in text.splitlines(): + candidate = line.strip() + if not candidate or candidate.startswith("#"): + continue + modules.add(candidate) + return modules + + +def _collect_candidate_modules( + requirement_names: set[str], + site_packages_path: str, +) -> set[str]: + by_name: dict[str, list[importlib_metadata.Distribution]] = {} try: - return line.decode(locale.getpreferredencoding(False)).strip() - except UnicodeDecodeError: - pass - if sys.platform.startswith("win"): + for distribution in importlib_metadata.distributions(path=[site_packages_path]): + distribution_name = distribution.metadata.get("Name") + if not distribution_name: + continue + canonical_name = _canonicalize_distribution_name(distribution_name) + by_name.setdefault(canonical_name, []).append(distribution) + except Exception as exc: + logger.warning("读取 site-packages 元数据失败,使用回退模块名: %s", exc) + + expanded_requirement_names: set[str] = set() + pending = deque(requirement_names) + while pending: + requirement_name = pending.popleft() + if requirement_name in expanded_requirement_names: + continue + expanded_requirement_names.add(requirement_name) + + for distribution in by_name.get(requirement_name, []): + for dependency_line in distribution.requires or []: + dependency_name = _extract_requirement_name(dependency_line) + if not dependency_name: + continue + if dependency_name in expanded_requirement_names: + continue + pending.append(dependency_name) + + candidates: set[str] = set() + for requirement_name in expanded_requirement_names: + matched_distributions = by_name.get(requirement_name, []) + modules_for_requirement: set[str] = set() + for distribution in matched_distributions: + modules_for_requirement.update(_extract_top_level_modules(distribution)) + + if modules_for_requirement: + candidates.update(modules_for_requirement) + continue + + fallback_module_name = requirement_name.replace("-", "_") + if fallback_module_name: + candidates.add(fallback_module_name) + + return candidates + + +def _ensure_preferred_modules( + module_names: set[str], + site_packages_path: str, +) -> None: + unresolved_prefer_reasons = _prefer_modules_from_site_packages( + module_names, site_packages_path + ) + + unresolved_modules: list[str] = [] + for module_name in sorted(module_names): + if not _module_exists_in_site_packages(module_name, site_packages_path): + continue + if _is_module_loaded_from_site_packages(module_name, site_packages_path): + continue + + failure_reason = unresolved_prefer_reasons.get(module_name) + if failure_reason: + unresolved_modules.append(f"{module_name} -> {failure_reason}") + continue + + loaded_module = sys.modules.get(module_name) + loaded_from = getattr(loaded_module, "__file__", "unknown") + unresolved_modules.append(f"{module_name} -> {loaded_from}") + + if unresolved_modules: + conflict_message = ( + "检测到插件依赖与当前运行时发生冲突,无法安全加载该插件。" + f"冲突模块: {', '.join(unresolved_modules)}" + ) + raise RuntimeError(conflict_message) + + +def _prefer_module_from_site_packages( + module_name: str, site_packages_path: str +) -> bool: + with _SITE_PACKAGES_IMPORT_LOCK: + base_path = os.path.join(site_packages_path, *module_name.split(".")) + package_init = os.path.join(base_path, "__init__.py") + module_file = f"{base_path}.py" + + module_location = None + submodule_search_locations = None + + if os.path.isfile(package_init): + module_location = package_init + submodule_search_locations = [os.path.dirname(package_init)] + elif os.path.isfile(module_file): + module_location = module_file + else: + return False + + spec = importlib.util.spec_from_file_location( + module_name, + module_location, + submodule_search_locations=submodule_search_locations, + ) + if spec is None or spec.loader is None: + return False + + matched_keys = [ + key + for key in list(sys.modules.keys()) + if key == module_name or key.startswith(f"{module_name}.") + ] + original_modules = {key: sys.modules[key] for key in matched_keys} + try: - return line.decode("gbk").strip() - except UnicodeDecodeError: - pass - return line.decode("utf-8", errors="replace").strip() + for key in matched_keys: + sys.modules.pop(key, None) + + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) + + if "." in module_name: + parent_name, child_name = module_name.rsplit(".", 1) + parent_module = sys.modules.get(parent_name) + if parent_module is not None: + setattr(parent_module, child_name, module) + + logger.info( + "Loaded %s from plugin site-packages: %s", + module_name, + module_location, + ) + return True + except Exception: + failed_keys = [ + key + for key in list(sys.modules.keys()) + if key == module_name or key.startswith(f"{module_name}.") + ] + for key in failed_keys: + sys.modules.pop(key, None) + sys.modules.update(original_modules) + raise + + +def _extract_conflicting_module_name(exc: Exception) -> str | None: + if isinstance(exc, ModuleNotFoundError): + missing_name = getattr(exc, "name", None) + if missing_name: + return missing_name.split(".", 1)[0] + + message = str(exc) + from_match = re.search(r"from '([A-Za-z0-9_.]+)'", message) + if from_match: + return from_match.group(1).split(".", 1)[0] + + no_module_match = re.search(r"No module named '([A-Za-z0-9_.]+)'", message) + if no_module_match: + return no_module_match.group(1).split(".", 1)[0] + + return None + + +def _prefer_module_with_dependency_recovery( + module_name: str, + site_packages_path: str, + max_attempts: int = 3, +) -> bool: + recovered_dependencies: set[str] = set() + + for _ in range(max_attempts): + try: + return _prefer_module_from_site_packages(module_name, site_packages_path) + except Exception as exc: + dependency_name = _extract_conflicting_module_name(exc) + if ( + not dependency_name + or dependency_name == module_name + or dependency_name in recovered_dependencies + ): + raise + + recovered_dependencies.add(dependency_name) + recovered = _prefer_module_from_site_packages( + dependency_name, + site_packages_path, + ) + if not recovered: + raise + logger.info( + "Recovered dependency %s while preferring %s from plugin site-packages.", + dependency_name, + module_name, + ) + + return False + + +def _prefer_modules_from_site_packages( + module_names: set[str], + site_packages_path: str, +) -> dict[str, str]: + pending_modules = sorted(module_names) + unresolved_reasons: dict[str, str] = {} + max_rounds = max(2, min(6, len(pending_modules) + 1)) + + for _ in range(max_rounds): + if not pending_modules: + break + + next_round_pending: list[str] = [] + round_progress = False + + for module_name in pending_modules: + try: + loaded = _prefer_module_with_dependency_recovery( + module_name, + site_packages_path, + ) + except Exception as exc: + unresolved_reasons[module_name] = str(exc) + next_round_pending.append(module_name) + continue + + unresolved_reasons.pop(module_name, None) + if loaded: + round_progress = True + else: + logger.debug( + "Module %s not found in plugin site-packages: %s", + module_name, + site_packages_path, + ) + + if not next_round_pending: + pending_modules = [] + break + + if not round_progress and len(next_round_pending) == len(pending_modules): + pending_modules = next_round_pending + break + + pending_modules = next_round_pending + + final_unresolved = { + module_name: unresolved_reasons.get(module_name, "unknown import error") + for module_name in pending_modules + } + for module_name, reason in final_unresolved.items(): + logger.warning( + "Failed to prefer module %s from plugin site-packages: %s", + module_name, + reason, + ) + + return final_unresolved + + +def _ensure_plugin_dependencies_preferred( + target_site_packages: str, + requested_requirements: set[str], +) -> None: + if not requested_requirements: + return + + candidate_modules = _collect_candidate_modules( + requested_requirements, + target_site_packages, + ) + if not candidate_modules: + return + + _ensure_preferred_modules(candidate_modules, target_site_packages) + + +def _get_loader_for_package(package: object) -> object | None: + loader = getattr(package, "__loader__", None) + if loader is not None: + return loader + + spec = getattr(package, "__spec__", None) + if spec is None: + return None + return getattr(spec, "loader", None) + + +def _try_register_distlib_finder( + distlib_resources: object, + finder_registry: dict[type, object], + register_finder, + resource_finder: object, + loader: object, + package_name: str, +) -> bool: + loader_type = type(loader) + if loader_type in finder_registry: + return False + + try: + register_finder(loader, resource_finder) + except Exception as exc: + logger.warning( + "Failed to patch pip distlib finder for loader %s (%s): %s", + loader_type.__name__, + package_name, + exc, + ) + return False + + updated_registry = getattr(distlib_resources, "_finder_registry", finder_registry) + if isinstance(updated_registry, dict) and loader_type not in updated_registry: + logger.warning( + "Distlib finder patch did not take effect for loader %s (%s).", + loader_type.__name__, + package_name, + ) + return False + + logger.info( + "Patched pip distlib finder for frozen loader: %s (%s)", + loader_type.__name__, + package_name, + ) + return True + + +def _patch_distlib_finder_for_frozen_runtime() -> None: + global _DISTLIB_FINDER_PATCH_ATTEMPTED + + if not getattr(sys, "frozen", False): + return + if _DISTLIB_FINDER_PATCH_ATTEMPTED: + return + + _DISTLIB_FINDER_PATCH_ATTEMPTED = True + + try: + from pip._vendor.distlib import resources as distlib_resources + except Exception: + return + + finder_registry = getattr(distlib_resources, "_finder_registry", None) + register_finder = getattr(distlib_resources, "register_finder", None) + resource_finder = getattr(distlib_resources, "ResourceFinder", None) + + if not isinstance(finder_registry, dict): + logger.warning( + "Skip patching distlib finder because _finder_registry is unavailable." + ) + return + if not callable(register_finder) or resource_finder is None: + logger.warning( + "Skip patching distlib finder because register API is unavailable." + ) + return + + for package_name in ("pip._vendor.distlib", "pip._vendor"): + try: + package = importlib.import_module(package_name) + except Exception: + continue + + loader = _get_loader_for_package(package) + if loader is None: + continue + + if _try_register_distlib_finder( + distlib_resources, + finder_registry, + register_finder, + resource_finder, + loader, + package_name, + ): + finder_registry = getattr( + distlib_resources, "_finder_registry", finder_registry + ) class PipInstaller: - def __init__(self, pip_install_arg: str, pypi_index_url: str | None = None): + def __init__(self, pip_install_arg: str, pypi_index_url: str | None = None) -> None: self.pip_install_arg = pip_install_arg self.pypi_index_url = pypi_index_url @@ -34,48 +540,78 @@ async def install( package_name: str | None = None, requirements_path: str | None = None, mirror: str | None = None, - ): + ) -> None: args = ["install"] + requested_requirements: set[str] = set() if package_name: args.append(package_name) + requirement_name = _extract_requirement_name(package_name) + if requirement_name: + requested_requirements.add(requirement_name) elif requirements_path: args.extend(["-r", requirements_path]) + requested_requirements = _extract_requirement_names(requirements_path) index_url = mirror or self.pypi_index_url or "https://pypi.org/simple" - args.extend(["--trusted-host", "mirrors.aliyun.com", "-i", index_url]) + target_site_packages = None + if is_packaged_desktop_runtime(): + target_site_packages = get_astrbot_site_packages_path() + os.makedirs(target_site_packages, exist_ok=True) + _prepend_sys_path(target_site_packages) + args.extend(["--target", target_site_packages]) + args.extend(["--upgrade", "--force-reinstall"]) + if self.pip_install_arg: args.extend(self.pip_install_arg.split()) logger.info(f"Pip 包管理器: pip {' '.join(args)}") - try: - process = await asyncio.create_subprocess_exec( - sys.executable, - "-m", - "pip", - *args, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.STDOUT, + result_code = await self._run_pip_in_process(args) + + if result_code != 0: + raise Exception(f"安装失败,错误码:{result_code}") + + if target_site_packages: + _prepend_sys_path(target_site_packages) + _ensure_plugin_dependencies_preferred( + target_site_packages, + requested_requirements, ) + importlib.invalidate_caches() - assert process.stdout is not None - async for line in process.stdout: - logger.info(_robust_decode(line)) + def prefer_installed_dependencies(self, requirements_path: str) -> None: + """优先使用已安装在插件 site-packages 中的依赖,不执行安装。""" + if not is_packaged_desktop_runtime(): + return - await process.wait() + target_site_packages = get_astrbot_site_packages_path() + if not os.path.isdir(target_site_packages): + return - if process.returncode != 0: - raise Exception(f"安装失败,错误码:{process.returncode}") - except FileNotFoundError: - # 没有 pip - from pip import main as pip_main + requested_requirements = _extract_requirement_names(requirements_path) + if not requested_requirements: + return + + _prepend_sys_path(target_site_packages) + _ensure_plugin_dependencies_preferred( + target_site_packages, + requested_requirements, + ) + importlib.invalidate_caches() - result_code = await asyncio.to_thread(pip_main, args) + async def _run_pip_in_process(self, args: list[str]) -> int: + pip_main = _get_pip_main() + _patch_distlib_finder_for_frozen_runtime() - # 清除 pip.main 导致的多余的 logging handlers - for handler in logging.root.handlers[:]: - logging.root.removeHandler(handler) + original_handlers = list(logging.getLogger().handlers) + result_code, output = await asyncio.to_thread( + _run_pip_main_with_output, pip_main, args + ) + for line in output.splitlines(): + line = line.strip() + if line: + logger.info(line) - if result_code != 0: - raise Exception(f"安装失败,错误码:{result_code}") + _cleanup_added_root_handlers(original_handlers) + return result_code diff --git a/astrbot/core/utils/quoted_message/__init__.py b/astrbot/core/utils/quoted_message/__init__.py new file mode 100644 index 000000000..8421898fd --- /dev/null +++ b/astrbot/core/utils/quoted_message/__init__.py @@ -0,0 +1,8 @@ +from __future__ import annotations + +from .extractor import extract_quoted_message_images, extract_quoted_message_text + +__all__ = [ + "extract_quoted_message_text", + "extract_quoted_message_images", +] diff --git a/astrbot/core/utils/quoted_message/chain_parser.py b/astrbot/core/utils/quoted_message/chain_parser.py new file mode 100644 index 000000000..528ce14b8 --- /dev/null +++ b/astrbot/core/utils/quoted_message/chain_parser.py @@ -0,0 +1,503 @@ +from __future__ import annotations + +import json +import re +from typing import Any, TypedDict + +from astrbot.core.message.components import ( + At, + AtAll, + File, + Forward, + Image, + Node, + Nodes, + Plain, + Reply, + Video, +) +from astrbot.core.platform.astr_message_event import AstrMessageEvent +from astrbot.core.utils.string_utils import normalize_and_dedupe_strings + +from .image_refs import looks_like_image_file_name +from .settings import SETTINGS, QuotedMessageParserSettings + +_FORWARD_PLACEHOLDER_PATTERN = re.compile( + r"^(?:[\(\[]?[^\]:\)]*[\)\]]?\s*:\s*)?\[(?:forward message|转发消息|合并转发)\]$", + flags=re.IGNORECASE, +) + + +class ParsedOneBotPayload(TypedDict): + text: str | None + forward_ids: list[str] + image_refs: list[str] + + +def _build_parsed_payload( + text: str | None, + forward_ids: list[str] | None = None, + image_refs: list[str] | None = None, +) -> ParsedOneBotPayload: + return { + "text": text, + "forward_ids": forward_ids or [], + "image_refs": image_refs or [], + } + + +def _join_text_parts(parts: list[str]) -> str | None: + text = "".join(parts).strip() + return text or None + + +def _find_first_reply_component(event: AstrMessageEvent) -> Reply | None: + for comp in event.message_obj.message: + if isinstance(comp, Reply): + return comp + return None + + +def _is_forward_placeholder_only_text(text: str | None) -> bool: + if not isinstance(text, str): + return False + lines = [line.strip() for line in text.splitlines() if line.strip()] + if not lines: + return False + return all(_FORWARD_PLACEHOLDER_PATTERN.match(line) for line in lines) + + +def _extract_image_refs_from_component_chain( + chain: list[Any] | None, + *, + depth: int = 0, + settings: QuotedMessageParserSettings = SETTINGS, +) -> list[str]: + if not isinstance(chain, list) or depth > settings.max_component_chain_depth: + return [] + + image_refs: list[str] = [] + for seg in chain: + if isinstance(seg, Image): + for candidate in (seg.url, seg.file, seg.path): + if isinstance(candidate, str) and candidate.strip(): + image_refs.append(candidate.strip()) + break + elif isinstance(seg, Reply): + image_refs.extend( + _extract_image_refs_from_reply_component( + seg, + depth=depth + 1, + settings=settings, + ) + ) + elif isinstance(seg, Node): + image_refs.extend( + _extract_image_refs_from_component_chain( + seg.content, + depth=depth + 1, + settings=settings, + ) + ) + elif isinstance(seg, Nodes): + for node in seg.nodes: + image_refs.extend( + _extract_image_refs_from_component_chain( + node.content, + depth=depth + 1, + settings=settings, + ) + ) + + return normalize_and_dedupe_strings(image_refs) + + +def _extract_text_from_component_chain( + chain: list[Any] | None, + *, + depth: int = 0, + settings: QuotedMessageParserSettings = SETTINGS, +) -> str | None: + if not isinstance(chain, list) or depth > settings.max_component_chain_depth: + return None + + parts: list[str] = [] + for seg in chain: + if isinstance(seg, Plain): + if seg.text: + parts.append(seg.text) + elif isinstance(seg, At): + if seg.name: + parts.append(f"@{seg.name}") + elif seg.qq: + parts.append(f"@{seg.qq}") + elif isinstance(seg, AtAll): + parts.append("@all") + elif isinstance(seg, Image): + parts.append("[Image]") + elif isinstance(seg, Video): + parts.append("[Video]") + elif isinstance(seg, File): + file_name = seg.name or "file" + parts.append(f"[File:{file_name}]") + elif isinstance(seg, Forward): + parts.append("[Forward Message]") + elif isinstance(seg, Reply): + nested = _extract_text_from_reply_component( + seg, + depth=depth + 1, + settings=settings, + ) + if nested: + parts.append(nested) + elif isinstance(seg, Node): + node_sender = seg.name or seg.uin or "Unknown User" + node_text = _extract_text_from_component_chain( + seg.content, + depth=depth + 1, + settings=settings, + ) + if node_text: + parts.append(f"{node_sender}: {node_text}") + elif isinstance(seg, Nodes): + for node in seg.nodes: + node_sender = node.name or node.uin or "Unknown User" + node_text = _extract_text_from_component_chain( + node.content, + depth=depth + 1, + settings=settings, + ) + if node_text: + parts.append(f"{node_sender}: {node_text}") + + return _join_text_parts(parts) + + +def _extract_image_refs_from_reply_component( + reply: Reply, + *, + depth: int = 0, + settings: QuotedMessageParserSettings = SETTINGS, +) -> list[str]: + for attr in ("chain", "message", "origin", "content"): + payload = getattr(reply, attr, None) + image_refs = _extract_image_refs_from_component_chain( + payload, + depth=depth, + settings=settings, + ) + if image_refs: + return image_refs + return [] + + +def _extract_text_from_reply_component( + reply: Reply, + *, + depth: int = 0, + settings: QuotedMessageParserSettings = SETTINGS, +) -> str | None: + for attr in ("chain", "message", "origin", "content"): + payload = getattr(reply, attr, None) + text = _extract_text_from_component_chain( + payload, + depth=depth, + settings=settings, + ) + if text: + return text + + if reply.message_str and reply.message_str.strip(): + return reply.message_str.strip() + return None + + +def _unwrap_onebot_data(payload: Any) -> dict[str, Any]: + if not isinstance(payload, dict): + return {} + data = payload.get("data") + if isinstance(data, dict): + return data + return payload + + +def _extract_text_from_multimsg_json(raw_json: str) -> str | None: + try: + parsed = json.loads(raw_json) + except Exception: + return None + + if not isinstance(parsed, dict): + return None + if parsed.get("app") != "com.tencent.multimsg": + return None + config = parsed.get("config") + if not isinstance(config, dict): + return None + if config.get("forward") != 1: + return None + + meta = parsed.get("meta") + if not isinstance(meta, dict): + return None + detail = meta.get("detail") + if not isinstance(detail, dict): + return None + news_items = detail.get("news") + if not isinstance(news_items, list): + return None + + texts: list[str] = [] + for item in news_items: + if not isinstance(item, dict): + continue + text_content = item.get("text") + if not isinstance(text_content, str): + continue + cleaned = text_content.strip().replace("[图片]", "").strip() + if cleaned: + texts.append(cleaned) + + return "\n".join(texts).strip() or None + + +def _parse_onebot_segments( + segments: list[Any], + *, + settings: QuotedMessageParserSettings = SETTINGS, +) -> ParsedOneBotPayload: + text_parts: list[str] = [] + forward_ids: list[str] = [] + image_refs: list[str] = [] + + for seg in segments: + if not isinstance(seg, dict): + continue + + seg_type = seg.get("type") + seg_data = seg.get("data", {}) if isinstance(seg.get("data"), dict) else {} + + if seg_type in ("text", "plain"): + text = seg_data.get("text") + if isinstance(text, str) and text: + text_parts.append(text) + elif seg_type == "image": + text_parts.append("[Image]") + candidate = seg_data.get("url") or seg_data.get("file") + if isinstance(candidate, str) and candidate.strip(): + image_refs.append(candidate.strip()) + elif seg_type == "video": + text_parts.append("[Video]") + elif seg_type == "file": + file_name = ( + seg_data.get("name") + or seg_data.get("file_name") + or seg_data.get("file") + or "file" + ) + text_parts.append(f"[File:{file_name}]") + candidate_url = seg_data.get("url", "") + if ( + isinstance(candidate_url, str) + and candidate_url.strip() + and looks_like_image_file_name(candidate_url) + ): + image_refs.append(candidate_url.strip()) + candidate_file = seg_data.get("file") + if ( + isinstance(candidate_file, str) + and candidate_file.strip() + and looks_like_image_file_name( + seg_data.get("name") or seg_data.get("file_name") or candidate_file + ) + ): + image_refs.append(candidate_file.strip()) + elif seg_type in ("forward", "forward_msg", "nodes"): + fid = seg_data.get("id") or seg_data.get("message_id") + if isinstance(fid, (str, int)) and str(fid): + forward_ids.append(str(fid)) + else: + nested_nodes = seg_data.get("content") + nested_text, nested_forward_ids, nested_images = ( + _extract_text_forward_ids_and_images_from_forward_nodes( + nested_nodes if isinstance(nested_nodes, list) else [], + depth=1, + settings=settings, + ) + ) + if nested_text: + text_parts.append(nested_text) + if nested_forward_ids: + forward_ids.extend(nested_forward_ids) + if nested_images: + image_refs.extend(nested_images) + elif seg_type == "json": + raw_json = seg_data.get("data") + if isinstance(raw_json, str) and raw_json.strip(): + raw_json = raw_json.replace(",", ",") + multimsg_text = _extract_text_from_multimsg_json(raw_json) + if multimsg_text: + text_parts.append(multimsg_text) + + return _build_parsed_payload( + _join_text_parts(text_parts), + forward_ids, + normalize_and_dedupe_strings(image_refs), + ) + + +def _extract_text_forward_ids_and_images_from_forward_nodes( + nodes: list[Any], + *, + depth: int = 0, + settings: QuotedMessageParserSettings = SETTINGS, +) -> tuple[str | None, list[str], list[str]]: + if not isinstance(nodes, list) or depth > settings.max_forward_node_depth: + return None, [], [] + + texts: list[str] = [] + forward_ids: list[str] = [] + image_refs: list[str] = [] + indent = " " * depth + + for node in nodes: + if not isinstance(node, dict): + continue + + sender = node.get("sender") + if not isinstance(sender, dict): + sender = {} + sender_name = ( + sender.get("nickname") + or sender.get("card") + or sender.get("user_id") + or "Unknown User" + ) + + raw_content = node.get("message") or node.get("content") or [] + chain: list[Any] = [] + if isinstance(raw_content, list): + chain = raw_content + elif isinstance(raw_content, str): + raw_content = raw_content.strip() + if raw_content: + try: + parsed = json.loads(raw_content) + except Exception: + parsed = None + if isinstance(parsed, list): + chain = parsed + else: + chain = [{"type": "text", "data": {"text": raw_content}}] + + parsed_segments = _parse_onebot_segments(chain, settings=settings) + node_text = parsed_segments["text"] + node_forward_ids = parsed_segments["forward_ids"] + node_images = parsed_segments["image_refs"] + if node_text: + texts.append(f"{indent}{sender_name}: {node_text}") + if node_forward_ids: + forward_ids.extend(node_forward_ids) + if node_images: + image_refs.extend(node_images) + + return ( + "\n".join(texts).strip() or None, + normalize_and_dedupe_strings(forward_ids), + normalize_and_dedupe_strings(image_refs), + ) + + +def _parse_onebot_get_msg_payload( + payload: dict[str, Any], + *, + settings: QuotedMessageParserSettings = SETTINGS, +) -> ParsedOneBotPayload: + data = _unwrap_onebot_data(payload) + segments = data.get("message") or data.get("messages") + if isinstance(segments, list): + return _parse_onebot_segments(segments, settings=settings) + + text: str | None = None + if isinstance(segments, str) and segments.strip(): + text = segments.strip() + else: + raw = data.get("raw_message") + if isinstance(raw, str) and raw.strip(): + text = raw.strip() + return _build_parsed_payload(text) + + +def _parse_onebot_get_forward_payload( + payload: dict[str, Any], + *, + settings: QuotedMessageParserSettings = SETTINGS, +) -> ParsedOneBotPayload: + data = _unwrap_onebot_data(payload) + nodes = ( + data.get("messages") + or data.get("message") + or data.get("nodes") + or data.get("nodeList") + ) + if not isinstance(nodes, list): + return _build_parsed_payload(None) + + text, forward_ids, image_refs = ( + _extract_text_forward_ids_and_images_from_forward_nodes( + nodes, + settings=settings, + ) + ) + return _build_parsed_payload(text, forward_ids, image_refs) + + +class ReplyChainParser: + def __init__(self, settings: QuotedMessageParserSettings = SETTINGS): + self._settings = settings + + @staticmethod + def find_first_reply_component(event: AstrMessageEvent) -> Reply | None: + return _find_first_reply_component(event) + + @staticmethod + def is_forward_placeholder_only_text(text: str | None) -> bool: + return _is_forward_placeholder_only_text(text) + + def extract_text_from_reply_component( + self, + reply: Reply, + *, + depth: int = 0, + ) -> str | None: + return _extract_text_from_reply_component( + reply, + depth=depth, + settings=self._settings, + ) + + def extract_image_refs_from_reply_component( + self, + reply: Reply, + *, + depth: int = 0, + ) -> list[str]: + return _extract_image_refs_from_reply_component( + reply, + depth=depth, + settings=self._settings, + ) + + +class OneBotPayloadParser: + def __init__(self, settings: QuotedMessageParserSettings = SETTINGS): + self._settings = settings + + def parse_get_msg_payload(self, payload: dict[str, Any]) -> ParsedOneBotPayload: + return _parse_onebot_get_msg_payload(payload, settings=self._settings) + + def parse_get_forward_payload( + self, + payload: dict[str, Any], + ) -> ParsedOneBotPayload: + return _parse_onebot_get_forward_payload(payload, settings=self._settings) diff --git a/astrbot/core/utils/quoted_message/extractor.py b/astrbot/core/utils/quoted_message/extractor.py new file mode 100644 index 000000000..83570d66c --- /dev/null +++ b/astrbot/core/utils/quoted_message/extractor.py @@ -0,0 +1,211 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from astrbot import logger +from astrbot.core.message.components import Reply +from astrbot.core.platform.astr_message_event import AstrMessageEvent +from astrbot.core.utils.string_utils import normalize_and_dedupe_strings + +from .chain_parser import OneBotPayloadParser, ReplyChainParser +from .image_resolver import ImageResolver +from .onebot_client import OneBotClient +from .settings import SETTINGS, QuotedMessageParserSettings + + +async def _collect_text_and_images_from_forward_ids( + onebot_client: OneBotClient, + payload_parser: OneBotPayloadParser, + forward_ids: list[str], + *, + max_fetch: int, +) -> tuple[list[str], list[str]]: + texts: list[str] = [] + image_refs: list[str] = [] + pending: list[str] = [] + seen: set[str] = set() + + for fid in forward_ids: + if not isinstance(fid, str): + continue + cleaned = fid.strip() + if cleaned: + pending.append(cleaned) + + fetch_count = 0 + while pending and fetch_count < max_fetch: + current_id = pending.pop(0) + if current_id in seen: + continue + seen.add(current_id) + fetch_count += 1 + + forward_payload = await onebot_client.get_forward_msg(current_id) + if not forward_payload: + continue + + parsed = payload_parser.parse_get_forward_payload(forward_payload) + if parsed["text"]: + texts.append(parsed["text"]) + if parsed["image_refs"]: + image_refs.extend(parsed["image_refs"]) + for nested_id in parsed["forward_ids"]: + if nested_id not in seen: + pending.append(nested_id) + + if pending: + logger.warning( + "quoted_message_parser: stop fetching nested forward messages after %d hops", + max_fetch, + ) + + return texts, normalize_and_dedupe_strings(image_refs) + + +@dataclass(slots=True) +class QuotedMessageContent: + embedded_text: str | None + embedded_image_refs: list[str] + reply_id: str + direct_text: str | None + direct_image_refs: list[str] + forward_texts: list[str] + forward_image_refs: list[str] + + +class QuotedMessageExtractor: + def __init__( + self, + event: AstrMessageEvent, + settings: QuotedMessageParserSettings = SETTINGS, + ): + self._event = event + self._settings = settings + self._reply_parser = ReplyChainParser(settings=settings) + self._payload_parser = OneBotPayloadParser(settings=settings) + self._client = OneBotClient(event, settings=settings) + self._image_resolver = ImageResolver(event, self._client) + + async def _fetch_quoted_content( + self, + reply_component: Reply | None = None, + *, + fetch_remote: bool, + ) -> QuotedMessageContent | None: + reply = reply_component or self._reply_parser.find_first_reply_component( + self._event + ) + if not reply: + return None + + embedded_text = self._reply_parser.extract_text_from_reply_component(reply) + embedded_image_refs = list( + self._reply_parser.extract_image_refs_from_reply_component(reply) + ) + + reply_id = getattr(reply, "id", None) + reply_id_str = str(reply_id).strip() if reply_id is not None else "" + if not fetch_remote or not reply_id_str: + return QuotedMessageContent( + embedded_text=embedded_text, + embedded_image_refs=embedded_image_refs, + reply_id=reply_id_str, + direct_text=None, + direct_image_refs=[], + forward_texts=[], + forward_image_refs=[], + ) + + msg_payload = await self._client.get_msg(reply_id_str) + if not msg_payload: + return QuotedMessageContent( + embedded_text=embedded_text, + embedded_image_refs=embedded_image_refs, + reply_id=reply_id_str, + direct_text=None, + direct_image_refs=[], + forward_texts=[], + forward_image_refs=[], + ) + + parsed = self._payload_parser.parse_get_msg_payload(msg_payload) + forward_texts, forward_images = await _collect_text_and_images_from_forward_ids( + self._client, + self._payload_parser, + parsed["forward_ids"], + max_fetch=self._settings.max_forward_fetch, + ) + return QuotedMessageContent( + embedded_text=embedded_text, + embedded_image_refs=embedded_image_refs, + reply_id=reply_id_str, + direct_text=parsed["text"], + direct_image_refs=list(parsed["image_refs"]), + forward_texts=forward_texts, + forward_image_refs=forward_images, + ) + + async def text(self, reply_component: Reply | None = None) -> str | None: + embedded_content = await self._fetch_quoted_content( + reply_component, + fetch_remote=False, + ) + if not embedded_content: + return None + + if ( + embedded_content.embedded_text + and not self._reply_parser.is_forward_placeholder_only_text( + embedded_content.embedded_text + ) + ): + return embedded_content.embedded_text + + if not embedded_content.reply_id: + return embedded_content.embedded_text + + fetched_content = await self._fetch_quoted_content( + reply_component, + fetch_remote=True, + ) + if not fetched_content: + return embedded_content.embedded_text + + text_parts: list[str] = [] + if fetched_content.direct_text: + text_parts.append(fetched_content.direct_text) + text_parts.extend(fetched_content.forward_texts) + + return "\n".join(text_parts).strip() or embedded_content.embedded_text + + async def images(self, reply_component: Reply | None = None) -> list[str]: + content = await self._fetch_quoted_content(reply_component, fetch_remote=True) + if not content: + return [] + + image_refs: list[str] = [] + image_refs.extend(content.embedded_image_refs) + image_refs.extend(content.direct_image_refs) + image_refs.extend(content.forward_image_refs) + + return await self._image_resolver.resolve_for_llm(image_refs) + + +async def extract_quoted_message_text( + event: AstrMessageEvent, + reply_component: Reply | None = None, + settings: QuotedMessageParserSettings | None = None, +) -> str | None: + return await QuotedMessageExtractor(event, settings=settings or SETTINGS).text( + reply_component + ) + + +async def extract_quoted_message_images( + event: AstrMessageEvent, + reply_component: Reply | None = None, + settings: QuotedMessageParserSettings | None = None, +) -> list[str]: + return await QuotedMessageExtractor(event, settings=settings or SETTINGS).images( + reply_component + ) diff --git a/astrbot/core/utils/quoted_message/image_refs.py b/astrbot/core/utils/quoted_message/image_refs.py new file mode 100644 index 000000000..009d6844a --- /dev/null +++ b/astrbot/core/utils/quoted_message/image_refs.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +import os +from urllib.parse import urlsplit + +IMAGE_EXTENSIONS = { + ".jpg", + ".jpeg", + ".png", + ".webp", + ".bmp", + ".tif", + ".tiff", + ".gif", +} + + +def normalize_file_like_url(path: str | None) -> str | None: + if path is None: + return None + if not isinstance(path, str): + return None + if "?" not in path and "#" not in path: + return path + try: + split = urlsplit(path) + except Exception: + return path + return split.path or path + + +def looks_like_image_file_name(name: str) -> bool: + normalized_name = normalize_file_like_url(name) + if not isinstance(normalized_name, str) or not normalized_name.strip(): + return False + _, ext = os.path.splitext(normalized_name.strip().lower()) + return ext in IMAGE_EXTENSIONS + + +def convert_data_image_to_base64_ref(image_ref: str) -> str | None: + if not isinstance(image_ref, str): + return None + value = image_ref.strip() + if not value: + return None + lower_value = value.lower() + if not lower_value.startswith("data:image/"): + return None + + comma_index = value.find(",") + if comma_index <= 0: + return None + header = value[:comma_index].lower() + payload = value[comma_index + 1 :].strip() + if ";base64" not in header or not payload: + return None + return f"base64://{payload}" + + +def get_existing_local_path(value: str) -> str | None: + lower_value = value.lower() + if lower_value.startswith("file://"): + file_path = value[7:] + if file_path.startswith("/") and len(file_path) > 3 and file_path[2] == ":": + file_path = file_path[1:] + if file_path and os.path.exists(file_path): + return os.path.abspath(file_path) + return None + if os.path.exists(value): + return os.path.abspath(value) + return None + + +def normalize_image_ref(image_ref: str) -> str | None: + if not isinstance(image_ref, str): + return None + value = image_ref.strip() + if not value: + return None + lower_value = value.lower() + + if lower_value.startswith(("http://", "https://")): + return value + if lower_value.startswith("base64://"): + return value + + data_image_ref = convert_data_image_to_base64_ref(value) + if data_image_ref: + return data_image_ref + + local_path = get_existing_local_path(value) + if local_path and looks_like_image_file_name(local_path): + return local_path + return None diff --git a/astrbot/core/utils/quoted_message/image_resolver.py b/astrbot/core/utils/quoted_message/image_resolver.py new file mode 100644 index 000000000..5a4c21fb2 --- /dev/null +++ b/astrbot/core/utils/quoted_message/image_resolver.py @@ -0,0 +1,130 @@ +from __future__ import annotations + +import os +from typing import Any + +from astrbot import logger +from astrbot.core.platform.astr_message_event import AstrMessageEvent +from astrbot.core.utils.string_utils import normalize_and_dedupe_strings + +from .image_refs import IMAGE_EXTENSIONS, get_existing_local_path, normalize_image_ref +from .onebot_client import OneBotClient + + +def _build_image_id_candidates(image_ref: str) -> list[str]: + candidates: list[str] = [image_ref] + base_name, ext = os.path.splitext(image_ref) + if ext and base_name and base_name not in candidates: + if ext.lower() in IMAGE_EXTENSIONS: + candidates.append(base_name) + return candidates + + +def _build_image_resolve_actions( + event: AstrMessageEvent, + image_ref: str, +) -> list[tuple[str, dict[str, Any]]]: + actions: list[tuple[str, dict[str, Any]]] = [] + candidates = _build_image_id_candidates(image_ref) + + for candidate in candidates: + actions.extend( + [ + ("get_image", {"file": candidate}), + ("get_image", {"file_id": candidate}), + ("get_image", {"id": candidate}), + ("get_image", {"image": candidate}), + ("get_file", {"file_id": candidate}), + ("get_file", {"file": candidate}), + ] + ) + + try: + group_id = event.get_group_id() + except Exception: + group_id = None + group_id_value = group_id + if isinstance(group_id, str) and group_id.isdigit(): + group_id_value = int(group_id) + + if group_id_value: + for candidate in candidates: + actions.append( + ( + "get_group_file_url", + {"group_id": group_id_value, "file_id": candidate}, + ) + ) + for candidate in candidates: + actions.append(("get_private_file_url", {"file_id": candidate})) + + return actions + + +class ImageResolver: + def __init__( + self, + event: AstrMessageEvent, + onebot_client: OneBotClient | None = None, + ): + self._event = event + self._client = onebot_client or OneBotClient(event) + + async def resolve_for_llm(self, image_refs: list[str]) -> list[str]: + resolved: list[str] = [] + unresolved: list[str] = [] + + for image_ref in normalize_and_dedupe_strings(image_refs): + normalized = normalize_image_ref(image_ref) + if normalized: + resolved.append(normalized) + elif get_existing_local_path(image_ref): + # Drop non-image local paths instead of treating them as remote IDs. + logger.debug( + "quoted_message_parser: skip non-image local path ref=%s", + image_ref[:128], + ) + else: + unresolved.append(image_ref) + + for image_ref in unresolved: + resolved_ref = await self._resolve_one(image_ref) + if resolved_ref: + resolved.append(resolved_ref) + + return normalize_and_dedupe_strings(resolved) + + async def _resolve_one(self, image_ref: str) -> str | None: + resolved = normalize_image_ref(image_ref) + if resolved: + return resolved + + actions = _build_image_resolve_actions(self._event, image_ref) + for action, params in actions: + data = await self._client.call( + action, + params, + warn_on_all_failed=False, + unwrap_data=True, + ) + if not isinstance(data, dict): + continue + + url = data.get("url") + if isinstance(url, str): + normalized = normalize_image_ref(url) + if normalized: + return normalized + + file_value = data.get("file") + if isinstance(file_value, str): + normalized = normalize_image_ref(file_value) + if normalized: + return normalized + + logger.warning( + "quoted_message_parser: failed to resolve quoted image ref=%s after %d actions", + image_ref[:128], + len(actions), + ) + return None diff --git a/astrbot/core/utils/quoted_message/onebot_client.py b/astrbot/core/utils/quoted_message/onebot_client.py new file mode 100644 index 000000000..686eaa666 --- /dev/null +++ b/astrbot/core/utils/quoted_message/onebot_client.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +from collections.abc import Awaitable +from typing import Any, Protocol + +from astrbot import logger +from astrbot.core.platform.astr_message_event import AstrMessageEvent + +from .settings import SETTINGS, QuotedMessageParserSettings + + +def _unwrap_action_response(ret: dict[str, Any] | None) -> dict[str, Any]: + if not isinstance(ret, dict): + return {} + data = ret.get("data") + if isinstance(data, dict): + return data + return ret + + +class CallAction(Protocol): + def __call__(self, action: str, **params: Any) -> Awaitable[Any] | Any: ... + + +class OneBotClient: + def __init__( + self, + event: AstrMessageEvent, + settings: QuotedMessageParserSettings = SETTINGS, + ): + self._call_action = self._resolve_call_action(event) + self._settings = settings + + @staticmethod + def _resolve_call_action(event: AstrMessageEvent) -> CallAction | None: + bot = getattr(event, "bot", None) + api = getattr(bot, "api", None) + call_action = getattr(api, "call_action", None) + if not callable(call_action): + return None + return call_action + + async def _call_action_try_params( + self, + action: str, + params_list: list[dict[str, Any]], + *, + warn_on_all_failed: bool | None = None, + ) -> dict[str, Any] | None: + if self._call_action is None: + return None + if warn_on_all_failed is None: + warn_on_all_failed = self._settings.warn_on_action_failure + + last_error: Exception | None = None + last_params: dict[str, Any] | None = None + for params in params_list: + try: + result = await self._call_action(action, **params) + if isinstance(result, dict): + return result + except Exception as exc: + last_error = exc + last_params = params + logger.debug( + "quoted_message_parser: action %s failed with params %s: %s", + action, + {k: str(v)[:64] for k, v in params.items()}, + exc, + ) + if warn_on_all_failed and last_error is not None: + logger.warning( + "quoted_message_parser: all attempts failed for action %s, " + "last_params=%s, error=%s", + action, + ( + {k: str(v)[:64] for k, v in last_params.items()} + if isinstance(last_params, dict) + else None + ), + last_error, + ) + return None + + async def call( + self, + action: str, + params: dict[str, Any], + *, + warn_on_all_failed: bool = False, + unwrap_data: bool = True, + ) -> dict[str, Any] | None: + ret = await self._call_action_try_params( + action, + [params], + warn_on_all_failed=warn_on_all_failed, + ) + if not unwrap_data: + return ret + return _unwrap_action_response(ret) + + async def _call_action_compat( + self, + action: str, + message_id: str | int, + ) -> dict[str, Any] | None: + message_id_str = str(message_id).strip() + if not message_id_str: + return None + + params_list: list[dict[str, Any]] = [ + {"message_id": message_id_str}, + {"id": message_id_str}, + ] + if message_id_str.isdigit(): + int_id = int(message_id_str) + params_list.extend([{"message_id": int_id}, {"id": int_id}]) + return await self._call_action_try_params(action, params_list) + + async def get_msg(self, message_id: str | int) -> dict[str, Any] | None: + return await self._call_action_compat("get_msg", message_id) + + async def get_forward_msg(self, forward_id: str | int) -> dict[str, Any] | None: + return await self._call_action_compat("get_forward_msg", forward_id) diff --git a/astrbot/core/utils/quoted_message/settings.py b/astrbot/core/utils/quoted_message/settings.py new file mode 100644 index 000000000..2f74f41b6 --- /dev/null +++ b/astrbot/core/utils/quoted_message/settings.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass +from typing import Any + +_DEFAULT_MAX_COMPONENT_CHAIN_DEPTH = 4 +_DEFAULT_MAX_FORWARD_NODE_DEPTH = 6 +_DEFAULT_MAX_FORWARD_FETCH = 32 + + +def _read_int_mapping( + mapping: Mapping[str, Any], + key: str, + default: int, +) -> int: + raw = mapping.get(key) + if raw is None: + return default + try: + value = int(raw) + except (TypeError, ValueError): + return default + if value <= 0: + return default + return value + + +def _read_bool_mapping( + mapping: Mapping[str, Any], + key: str, + default: bool, +) -> bool: + raw = mapping.get(key) + if raw is None: + return default + if isinstance(raw, bool): + return raw + if isinstance(raw, str): + lowered = raw.strip().lower() + if lowered in {"1", "true", "yes", "on"}: + return True + if lowered in {"0", "false", "no", "off"}: + return False + return default + + +@dataclass(frozen=True) +class QuotedMessageParserSettings: + max_component_chain_depth: int = _DEFAULT_MAX_COMPONENT_CHAIN_DEPTH + max_forward_node_depth: int = _DEFAULT_MAX_FORWARD_NODE_DEPTH + max_forward_fetch: int = _DEFAULT_MAX_FORWARD_FETCH + warn_on_action_failure: bool = False + + def with_overrides( + self, + overrides: Mapping[str, Any] | None = None, + ) -> QuotedMessageParserSettings: + if not overrides: + return self + return QuotedMessageParserSettings( + max_component_chain_depth=_read_int_mapping( + overrides, + "max_component_chain_depth", + self.max_component_chain_depth, + ), + max_forward_node_depth=_read_int_mapping( + overrides, + "max_forward_node_depth", + self.max_forward_node_depth, + ), + max_forward_fetch=_read_int_mapping( + overrides, + "max_forward_fetch", + self.max_forward_fetch, + ), + warn_on_action_failure=_read_bool_mapping( + overrides, + "warn_on_action_failure", + self.warn_on_action_failure, + ), + ) + + +SETTINGS = QuotedMessageParserSettings() diff --git a/astrbot/core/utils/quoted_message_parser.py b/astrbot/core/utils/quoted_message_parser.py new file mode 100644 index 000000000..fa6ac18dd --- /dev/null +++ b/astrbot/core/utils/quoted_message_parser.py @@ -0,0 +1,11 @@ +from __future__ import annotations + +from astrbot.core.utils.quoted_message.extractor import ( + extract_quoted_message_images, + extract_quoted_message_text, +) + +__all__ = [ + "extract_quoted_message_text", + "extract_quoted_message_images", +] diff --git a/astrbot/core/utils/runtime_env.py b/astrbot/core/utils/runtime_env.py new file mode 100644 index 000000000..483f5bc0c --- /dev/null +++ b/astrbot/core/utils/runtime_env.py @@ -0,0 +1,10 @@ +import os +import sys + + +def is_frozen_runtime() -> bool: + return bool(getattr(sys, "frozen", False)) + + +def is_packaged_desktop_runtime() -> bool: + return is_frozen_runtime() and os.environ.get("ASTRBOT_DESKTOP_CLIENT") == "1" diff --git a/astrbot/core/utils/session_lock.py b/astrbot/core/utils/session_lock.py index 912d91e53..7810d6ce4 100644 --- a/astrbot/core/utils/session_lock.py +++ b/astrbot/core/utils/session_lock.py @@ -4,7 +4,7 @@ class SessionLockManager: - def __init__(self): + def __init__(self) -> None: self._locks: dict[str, asyncio.Lock] = defaultdict(asyncio.Lock) self._lock_count: dict[str, int] = defaultdict(int) self._access_lock = asyncio.Lock() diff --git a/astrbot/core/utils/session_waiter.py b/astrbot/core/utils/session_waiter.py index e1f2fbef7..b327a6184 100644 --- a/astrbot/core/utils/session_waiter.py +++ b/astrbot/core/utils/session_waiter.py @@ -18,7 +18,7 @@ class SessionController: """控制一个 Session 是否已经结束""" - def __init__(self): + def __init__(self) -> None: self.future = asyncio.Future() self.current_event: asyncio.Event | None = None """当前正在等待的所用的异步事件""" @@ -29,7 +29,7 @@ def __init__(self): self.history_chains: list[list[Comp.BaseMessageComponent]] = [] - def stop(self, error: Exception | None = None): + def stop(self, error: Exception | None = None) -> None: """立即结束这个会话""" if not self.future.done(): if error: @@ -37,7 +37,7 @@ def stop(self, error: Exception | None = None): else: self.future.set_result(None) - def keep(self, timeout: float = 0, reset_timeout=False): + def keep(self, timeout: float = 0, reset_timeout=False) -> None: """保持这个会话 Args: @@ -71,7 +71,7 @@ def keep(self, timeout: float = 0, reset_timeout=False): asyncio.create_task(self._holding(new_event, timeout)) # 开始新的 keep - async def _holding(self, event: asyncio.Event, timeout: float): + async def _holding(self, event: asyncio.Event, timeout: float) -> None: """等待事件结束或超时""" try: await asyncio.wait_for(event.wait(), timeout) @@ -107,7 +107,7 @@ def __init__( session_filter: SessionFilter, session_id: str, record_history_chains: bool, - ): + ) -> None: self.session_id = session_id self.session_filter = session_filter self.handler: ( @@ -141,7 +141,7 @@ async def register_wait( finally: self._cleanup() - def _cleanup(self, error: Exception | None = None): + def _cleanup(self, error: Exception | None = None) -> None: """清理会话""" USER_SESSIONS.pop(self.session_id, None) try: @@ -151,7 +151,7 @@ def _cleanup(self, error: Exception | None = None): self.session_controller.stop(error) @classmethod - async def trigger(cls, session_id: str, event: AstrMessageEvent): + async def trigger(cls, session_id: str, event: AstrMessageEvent) -> None: """外部输入触发会话处理""" session = USER_SESSIONS.get(session_id) if not session or session.session_controller.future.done(): diff --git a/astrbot/core/utils/shared_preferences.py b/astrbot/core/utils/shared_preferences.py index 765045513..344808cbd 100644 --- a/astrbot/core/utils/shared_preferences.py +++ b/astrbot/core/utils/shared_preferences.py @@ -15,7 +15,7 @@ class SharedPreferences: - def __init__(self, db_helper: BaseDatabase, json_storage_path=None): + def __init__(self, db_helper: BaseDatabase, json_storage_path=None) -> None: if json_storage_path is None: json_storage_path = os.path.join( get_astrbot_data_path(), @@ -23,7 +23,7 @@ def __init__(self, db_helper: BaseDatabase, json_storage_path=None): ) self.path = json_storage_path self.db_helper = db_helper - self.temorary_cache: dict[str, dict[str, Any]] = defaultdict(dict) + self.temporary_cache: dict[str, dict[str, Any]] = defaultdict(dict) """automatically clear per 24 hours. Might be helpful in some cases XD""" self._sync_loop = asyncio.new_event_loop() @@ -36,8 +36,8 @@ def __init__(self, db_helper: BaseDatabase, json_storage_path=None): ) self._scheduler.start() - def _clear_temporary_cache(self): - self.temorary_cache.clear() + def _clear_temporary_cache(self) -> None: + self.temporary_cache.clear() async def get_async( self, @@ -132,7 +132,7 @@ async def global_get( return await self.range_get_async("global", "global", key) return await self.get_async("global", "global", key, default) - async def put_async(self, scope: str, scope_id: str, key: str, value: Any): + async def put_async(self, scope: str, scope_id: str, key: str, value: Any) -> None: """设置指定范围和键的偏好设置""" await self.db_helper.insert_preference_or_update( scope, @@ -141,24 +141,24 @@ async def put_async(self, scope: str, scope_id: str, key: str, value: Any): {"val": value}, ) - async def session_put(self, umo: str, key: str, value: Any): + async def session_put(self, umo: str, key: str, value: Any) -> None: await self.put_async("umo", umo, key, value) - async def global_put(self, key: str, value: Any): + async def global_put(self, key: str, value: Any) -> None: await self.put_async("global", "global", key, value) - async def remove_async(self, scope: str, scope_id: str, key: str): + async def remove_async(self, scope: str, scope_id: str, key: str) -> None: """删除指定范围和键的偏好设置""" await self.db_helper.remove_preference(scope, scope_id, key) - async def session_remove(self, umo: str, key: str): + async def session_remove(self, umo: str, key: str) -> None: await self.remove_async("umo", umo, key) - async def global_remove(self, key: str): + async def global_remove(self, key: str) -> None: """删除全局偏好设置""" await self.remove_async("global", "global", key) - async def clear_async(self, scope: str, scope_id: str): + async def clear_async(self, scope: str, scope_id: str) -> None: """清空指定范围的所有偏好设置""" await self.db_helper.clear_preferences(scope, scope_id) @@ -202,21 +202,25 @@ def range_get( return result - def put(self, key, value, scope: str | None = None, scope_id: str | None = None): + def put( + self, key, value, scope: str | None = None, scope_id: str | None = None + ) -> None: """设置偏好设置(已弃用)""" asyncio.run_coroutine_threadsafe( self.put_async(scope or "unknown", scope_id or "unknown", key, value), self._sync_loop, ).result() - def remove(self, key, scope: str | None = None, scope_id: str | None = None): + def remove( + self, key, scope: str | None = None, scope_id: str | None = None + ) -> None: """删除偏好设置(已弃用)""" asyncio.run_coroutine_threadsafe( self.remove_async(scope or "unknown", scope_id or "unknown", key), self._sync_loop, ).result() - def clear(self, scope: str | None = None, scope_id: str | None = None): + def clear(self, scope: str | None = None, scope_id: str | None = None) -> None: """清空偏好设置(已弃用)""" asyncio.run_coroutine_threadsafe( self.clear_async(scope or "unknown", scope_id or "unknown"), diff --git a/astrbot/core/utils/string_utils.py b/astrbot/core/utils/string_utils.py new file mode 100644 index 000000000..8c2aacb4d --- /dev/null +++ b/astrbot/core/utils/string_utils.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from collections.abc import Iterable +from typing import Any + + +def normalize_and_dedupe_strings(items: Iterable[Any] | None) -> list[str]: + if items is None: + return [] + + normalized: list[str] = [] + seen: set[str] = set() + for item in items: + if not isinstance(item, str): + continue + cleaned = item.strip() + if not cleaned or cleaned in seen: + continue + seen.add(cleaned) + normalized.append(cleaned) + return normalized diff --git a/astrbot/core/utils/t2i/network_strategy.py b/astrbot/core/utils/t2i/network_strategy.py index 7ebba5669..53d9441fa 100644 --- a/astrbot/core/utils/t2i/network_strategy.py +++ b/astrbot/core/utils/t2i/network_strategy.py @@ -1,12 +1,11 @@ import asyncio import logging import random -import ssl import aiohttp -import certifi from astrbot.core.config import VERSION +from astrbot.core.utils.http_ssl import build_tls_connector from astrbot.core.utils.io import download_image_by_url from astrbot.core.utils.t2i.template_manager import TemplateManager @@ -28,7 +27,7 @@ def __init__(self, base_url: str | None = None) -> None: self.endpoints = [self.BASE_RENDER_URL] self.template_manager = TemplateManager() - async def initialize(self): + async def initialize(self) -> None: if self.BASE_RENDER_URL == ASTRBOT_T2I_DEFAULT_ENDPOINT: asyncio.create_task(self.get_official_endpoints()) @@ -36,10 +35,13 @@ async def get_template(self, name: str = "base") -> str: """通过名称获取文转图 HTML 模板""" return self.template_manager.get_template(name) - async def get_official_endpoints(self): + async def get_official_endpoints(self) -> None: """获取官方的 t2i 端点列表。""" try: - async with aiohttp.ClientSession() as session: + async with aiohttp.ClientSession( + trust_env=True, + connector=build_tls_connector(), + ) as session: async with session.get( "https://api.soulter.top/astrbot/t2i-endpoints", ) as resp: @@ -88,12 +90,10 @@ async def render_custom_template( for endpoint in endpoints: try: if return_url: - ssl_context = ssl.create_default_context(cafile=certifi.where()) - connector = aiohttp.TCPConnector(ssl=ssl_context) async with ( aiohttp.ClientSession( trust_env=True, - connector=connector, + connector=build_tls_connector(), ) as session, session.post( f"{endpoint}/generate", diff --git a/astrbot/core/utils/t2i/renderer.py b/astrbot/core/utils/t2i/renderer.py index 2ce7a5ebf..e3118d7e8 100644 --- a/astrbot/core/utils/t2i/renderer.py +++ b/astrbot/core/utils/t2i/renderer.py @@ -7,11 +7,11 @@ class HtmlRenderer: - def __init__(self, endpoint_url: str | None = None): + def __init__(self, endpoint_url: str | None = None) -> None: self.network_strategy = NetworkRenderStrategy(endpoint_url) self.local_strategy = LocalRenderStrategy() - async def initialize(self): + async def initialize(self) -> None: await self.network_strategy.initialize() async def render_custom_template( diff --git a/astrbot/core/utils/t2i/template_manager.py b/astrbot/core/utils/t2i/template_manager.py index 6d44f735b..b3eb0c9ff 100644 --- a/astrbot/core/utils/t2i/template_manager.py +++ b/astrbot/core/utils/t2i/template_manager.py @@ -14,7 +14,7 @@ class TemplateManager: CORE_TEMPLATES = ["base.html", "astrbot_powershell.html"] - def __init__(self): + def __init__(self) -> None: self.builtin_template_dir = os.path.join( get_astrbot_path(), "astrbot", @@ -28,7 +28,7 @@ def __init__(self): os.makedirs(self.user_template_dir, exist_ok=True) self._initialize_user_templates() - def _copy_core_templates(self, overwrite: bool = False): + def _copy_core_templates(self, overwrite: bool = False) -> None: """从内置目录复制核心模板到用户目录。""" for filename in self.CORE_TEMPLATES: src = os.path.join(self.builtin_template_dir, filename) @@ -36,7 +36,7 @@ def _copy_core_templates(self, overwrite: bool = False): if os.path.exists(src) and (overwrite or not os.path.exists(dst)): shutil.copyfile(src, dst) - def _initialize_user_templates(self): + def _initialize_user_templates(self) -> None: """如果用户目录下缺少核心模板,则进行复制。""" self._copy_core_templates(overwrite=False) @@ -80,7 +80,7 @@ def get_template(self, name: str) -> str: raise FileNotFoundError("模板不存在。") - def create_template(self, name: str, content: str): + def create_template(self, name: str, content: str) -> None: """在用户目录中创建一个新的模板文件。""" path = self._get_user_template_path(name) if os.path.exists(path): @@ -88,7 +88,7 @@ def create_template(self, name: str, content: str): with open(path, "w", encoding="utf-8") as f: f.write(content) - def update_template(self, name: str, content: str): + def update_template(self, name: str, content: str) -> None: """更新一个模板。此操作始终写入用户目录。 如果更新的是一个内置模板,此操作实际上会在用户目录中创建一个修改后的副本, 从而实现对内置模板的“覆盖”。 @@ -97,7 +97,7 @@ def update_template(self, name: str, content: str): with open(path, "w", encoding="utf-8") as f: f.write(content) - def delete_template(self, name: str): + def delete_template(self, name: str) -> None: """仅删除用户目录中的模板文件。 如果删除的是一个覆盖了内置模板的用户模板,这将有效地“恢复”到内置版本。 """ @@ -106,6 +106,6 @@ def delete_template(self, name: str): raise FileNotFoundError("用户模板不存在,无法删除。") os.remove(path) - def reset_default_template(self): + def reset_default_template(self) -> None: """将核心模板从内置目录强制重置到用户目录。""" self._copy_core_templates(overwrite=True) diff --git a/astrbot/core/utils/temp_dir_cleaner.py b/astrbot/core/utils/temp_dir_cleaner.py new file mode 100644 index 000000000..c0c060098 --- /dev/null +++ b/astrbot/core/utils/temp_dir_cleaner.py @@ -0,0 +1,150 @@ +import asyncio +from collections.abc import Callable +from dataclasses import dataclass +from pathlib import Path + +from astrbot import logger +from astrbot.core.utils.astrbot_path import get_astrbot_temp_path + + +def parse_size_to_bytes(value: str | int | float | None) -> int: + """Parse size in MB to bytes.""" + if value is None: + return 0 + + try: + size_mb = float(str(value).strip()) + except (TypeError, ValueError): + return 0 + + if size_mb <= 0: + return 0 + + return int(size_mb * 1024**2) + + +@dataclass +class TempFileInfo: + path: Path + size: int + mtime: float + + +class TempDirCleaner: + CONFIG_KEY = "temp_dir_max_size" + DEFAULT_MAX_SIZE = 1024 + CHECK_INTERVAL_SECONDS = 10 * 60 + CLEANUP_RATIO = 0.30 + + def __init__( + self, + max_size_getter: Callable[[], str | int | float | None], + temp_dir: Path | None = None, + ) -> None: + self._max_size_getter = max_size_getter + self._temp_dir = temp_dir or Path(get_astrbot_temp_path()) + self._stop_event = asyncio.Event() + + def _limit_bytes(self) -> int: + configured = self._max_size_getter() + parsed = parse_size_to_bytes(configured) + if parsed <= 0: + fallback = parse_size_to_bytes(self.DEFAULT_MAX_SIZE) + logger.warning( + f"Invalid {self.CONFIG_KEY}={configured!r}, fallback to {self.DEFAULT_MAX_SIZE}MB.", + ) + return fallback + return parsed + + def _scan_temp_files(self) -> tuple[int, list[TempFileInfo]]: + if not self._temp_dir.exists(): + return 0, [] + + total_size = 0 + files: list[TempFileInfo] = [] + for path in self._temp_dir.rglob("*"): + if not path.is_file(): + continue + try: + stat = path.stat() + except OSError as e: + logger.debug(f"Skip temp file {path} due to stat error: {e}") + continue + total_size += stat.st_size + files.append( + TempFileInfo(path=path, size=stat.st_size, mtime=stat.st_mtime) + ) + + return total_size, files + + def _cleanup_empty_dirs(self) -> None: + if not self._temp_dir.exists(): + return + for path in sorted( + self._temp_dir.rglob("*"), key=lambda p: len(p.parts), reverse=True + ): + if not path.is_dir(): + continue + try: + path.rmdir() + except OSError: + continue + + def cleanup_once(self) -> None: + limit = self._limit_bytes() + if limit <= 0: + return + + total_size, files = self._scan_temp_files() + if total_size <= limit: + return + + target_release = max(int(total_size * self.CLEANUP_RATIO), 1) + released = 0 + removed_files = 0 + + for file_info in sorted(files, key=lambda item: item.mtime): + try: + file_info.path.unlink() + except OSError as e: + logger.warning(f"Failed to delete temp file {file_info.path}: {e}") + continue + + released += file_info.size + removed_files += 1 + if released >= target_release: + break + + self._cleanup_empty_dirs() + + logger.warning( + f"Temp dir exceeded limit ({total_size} > {limit}). " + f"Removed {removed_files} files, released {released} bytes " + f"(target {target_release} bytes).", + ) + + async def run(self) -> None: + logger.info( + f"TempDirCleaner started. interval={self.CHECK_INTERVAL_SECONDS}s " + f"cleanup_ratio={self.CLEANUP_RATIO}", + ) + while not self._stop_event.is_set(): + try: + # File-system traversal and deletion are blocking operations. + # Run cleanup in a worker thread to avoid blocking the event loop. + await asyncio.to_thread(self.cleanup_once) + except Exception as e: + logger.error(f"TempDirCleaner run failed: {e}", exc_info=True) + + try: + await asyncio.wait_for( + self._stop_event.wait(), + timeout=self.CHECK_INTERVAL_SECONDS, + ) + except asyncio.TimeoutError: + continue + + logger.info("TempDirCleaner stopped.") + + async def stop(self) -> None: + self._stop_event.set() diff --git a/astrbot/core/utils/tencent_record_helper.py b/astrbot/core/utils/tencent_record_helper.py index b58643bd3..f342484bd 100644 --- a/astrbot/core/utils/tencent_record_helper.py +++ b/astrbot/core/utils/tencent_record_helper.py @@ -7,7 +7,7 @@ from io import BytesIO from astrbot.core import logger -from astrbot.core.utils.astrbot_path import get_astrbot_data_path +from astrbot.core.utils.astrbot_path import get_astrbot_temp_path async def tencent_silk_to_wav(silk_path: str, output_path: str) -> str: @@ -117,12 +117,13 @@ async def audio_to_tencent_silk_base64(audio_path: str) -> tuple[str, float]: except ImportError as e: raise Exception("未安装 pilk: pip install pilk") from e - temp_dir = os.path.join(get_astrbot_data_path(), "temp") + temp_dir = get_astrbot_temp_path() os.makedirs(temp_dir, exist_ok=True) # 是否需要转换为 WAV ext = os.path.splitext(audio_path)[1].lower() temp_wav = tempfile.NamedTemporaryFile( + prefix="tencent_record_", suffix=".wav", delete=False, dir=temp_dir, @@ -140,6 +141,7 @@ async def audio_to_tencent_silk_base64(audio_path: str) -> tuple[str, float]: rate = wav_file.getframerate() silk_path = tempfile.NamedTemporaryFile( + prefix="tencent_record_", suffix=".silk", delete=False, dir=temp_dir, diff --git a/astrbot/core/utils/webhook_utils.py b/astrbot/core/utils/webhook_utils.py index 0e1c3f9cd..40dada3cb 100644 --- a/astrbot/core/utils/webhook_utils.py +++ b/astrbot/core/utils/webhook_utils.py @@ -1,3 +1,4 @@ +import os import uuid from astrbot.core import astrbot_config, logger @@ -20,7 +21,21 @@ def _get_dashboard_port() -> int: return 6185 -def log_webhook_info(platform_name: str, webhook_uuid: str): +def _is_dashboard_ssl_enabled() -> bool: + env_ssl = os.environ.get("DASHBOARD_SSL_ENABLE") or os.environ.get( + "ASTRBOT_DASHBOARD_SSL_ENABLE" + ) + if env_ssl is not None: + return env_ssl.strip().lower() in {"1", "true", "yes", "on"} + + try: + return bool(astrbot_config.get("dashboard", {}).get("ssl", {}).get("enable")) + except Exception as e: + logger.error(f"获取 dashboard SSL 配置失败: {e!s}") + return False + + +def log_webhook_info(platform_name: str, webhook_uuid: str) -> None: """打印美观的 webhook 信息日志 Args: @@ -38,12 +53,13 @@ def log_webhook_info(platform_name: str, webhook_uuid: str): callback_base = callback_base.rstrip("/") webhook_url = f"{callback_base}/api/platform/webhook/{webhook_uuid}" + scheme = "https" if _is_dashboard_ssl_enabled() else "http" display_log = ( "\n====================\n" f"🔗 机器人平台 {platform_name} 已启用统一 Webhook 模式\n" f"📍 Webhook 回调地址: \n" - f" ➜ http://:{_get_dashboard_port()}/api/platform/webhook/{webhook_uuid}\n" + f" ➜ {scheme}://:{_get_dashboard_port()}/api/platform/webhook/{webhook_uuid}\n" f" ➜ {webhook_url}\n" "====================\n" ) diff --git a/astrbot/core/zip_updator.py b/astrbot/core/zip_updator.py index 728dfdabb..6cea6b38d 100644 --- a/astrbot/core/zip_updator.py +++ b/astrbot/core/zip_updator.py @@ -3,6 +3,7 @@ import shutil import ssl import zipfile +from typing import NoReturn import aiohttp import certifi @@ -101,10 +102,10 @@ def github_api_release_parser(self, releases: list) -> list: ) return ret - def unzip(self): + def unzip(self) -> NoReturn: raise NotImplementedError - async def update(self): + async def update(self) -> NoReturn: raise NotImplementedError def compare_version(self, v1: str, v2: str) -> int: @@ -148,7 +149,9 @@ async def check_update( body=f"{tag_name}\n\n{sel_release_data['body']}", ) - async def download_from_repo_url(self, target_path: str, repo_url: str, proxy=""): + async def download_from_repo_url( + self, target_path: str, repo_url: str, proxy="" + ) -> None: author, repo, branch = self.parse_github_url(repo_url) logger.info(f"正在下载更新 {repo} ...") @@ -203,7 +206,7 @@ def parse_github_url(self, url: str): return author, repo, branch raise ValueError("无效的 GitHub URL") - def unzip_file(self, zip_path: str, target_dir: str): + def unzip_file(self, zip_path: str, target_dir: str) -> None: """解压缩文件, 并将压缩包内**第一个**文件夹内的文件移动到 target_dir""" os.makedirs(target_dir, exist_ok=True) update_dir = "" diff --git a/astrbot/dashboard/routes/__init__.py b/astrbot/dashboard/routes/__init__.py index 792f450d7..fbbd0c7a0 100644 --- a/astrbot/dashboard/routes/__init__.py +++ b/astrbot/dashboard/routes/__init__.py @@ -1,3 +1,4 @@ +from .api_key import ApiKeyRoute from .auth import AuthRoute from .backup import BackupRoute from .chat import ChatRoute @@ -8,23 +9,21 @@ from .cron import CronRoute from .file import FileRoute from .knowledge_base import KnowledgeBaseRoute -from .live_chat import LiveChatRoute from .log import LogRoute +from .open_api import OpenApiRoute from .persona import PersonaRoute from .platform import PlatformRoute from .plugin import PluginRoute -from .response import Response -from .route import RouteContext from .session_management import SessionManagementRoute from .skills import SkillsRoute from .stat import StatRoute from .static_file import StaticFileRoute from .subagent import SubAgentRoute -from .t2i import T2iRoute from .tools import ToolsRoute from .update import UpdateRoute __all__ = [ + "ApiKeyRoute", "AuthRoute", "BackupRoute", "ChatRoute", @@ -36,6 +35,7 @@ "FileRoute", "KnowledgeBaseRoute", "LogRoute", + "OpenApiRoute", "PersonaRoute", "PlatformRoute", "PluginRoute", @@ -46,8 +46,4 @@ "ToolsRoute", "SkillsRoute", "UpdateRoute", - "T2iRoute", - "LiveChatRoute", - "Response", - "RouteContext", ] diff --git a/astrbot/dashboard/routes/api_key.py b/astrbot/dashboard/routes/api_key.py new file mode 100644 index 000000000..5bc302579 --- /dev/null +++ b/astrbot/dashboard/routes/api_key.py @@ -0,0 +1,146 @@ +import hashlib +import secrets +from datetime import datetime, timedelta, timezone + +from quart import g, request + +from astrbot.core.db import BaseDatabase + +from .route import Response, Route, RouteContext + +ALL_OPEN_API_SCOPES = ("chat", "config", "file", "im") + + +class ApiKeyRoute(Route): + def __init__(self, context: RouteContext, db: BaseDatabase) -> None: + super().__init__(context) + self.db = db + self.routes = { + "/apikey/list": ("GET", self.list_api_keys), + "/apikey/create": ("POST", self.create_api_key), + "/apikey/revoke": ("POST", self.revoke_api_key), + "/apikey/delete": ("POST", self.delete_api_key), + } + self.register_routes() + + @staticmethod + def _normalize_utc(dt: datetime | None) -> datetime | None: + if dt is None: + return None + if dt.tzinfo is None or dt.tzinfo.utcoffset(dt) is None: + return dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc) + + @classmethod + def _serialize_datetime(cls, dt: datetime | None) -> str | None: + normalized = cls._normalize_utc(dt) + if normalized is None: + return None + return normalized.astimezone().isoformat() + + @staticmethod + def _hash_key(raw_key: str) -> str: + return hashlib.pbkdf2_hmac( + "sha256", + raw_key.encode("utf-8"), + b"astrbot_api_key", + 100_000, + ).hex() + + @staticmethod + def _serialize_api_key(key) -> dict: + expires_at = ApiKeyRoute._normalize_utc(key.expires_at) + return { + "key_id": key.key_id, + "name": key.name, + "key_prefix": key.key_prefix, + "scopes": key.scopes or [], + "created_by": key.created_by, + "created_at": ApiKeyRoute._serialize_datetime(key.created_at), + "updated_at": ApiKeyRoute._serialize_datetime(key.updated_at), + "last_used_at": ApiKeyRoute._serialize_datetime(key.last_used_at), + "expires_at": ApiKeyRoute._serialize_datetime(key.expires_at), + "revoked_at": ApiKeyRoute._serialize_datetime(key.revoked_at), + "is_revoked": key.revoked_at is not None, + "is_expired": bool(expires_at and expires_at < datetime.now(timezone.utc)), + } + + async def list_api_keys(self): + keys = await self.db.list_api_keys() + return ( + Response().ok(data=[self._serialize_api_key(key) for key in keys]).__dict__ + ) + + async def create_api_key(self): + post_data = await request.json or {} + + name = str(post_data.get("name", "")).strip() or "Untitled API Key" + scopes = post_data.get("scopes") + if scopes is None: + normalized_scopes = list(ALL_OPEN_API_SCOPES) + elif isinstance(scopes, list): + normalized_scopes = [ + scope + for scope in scopes + if isinstance(scope, str) and scope in ALL_OPEN_API_SCOPES + ] + normalized_scopes = list(dict.fromkeys(normalized_scopes)) + if not normalized_scopes: + return Response().error("At least one valid scope is required").__dict__ + else: + return Response().error("Invalid scopes").__dict__ + + expires_at = None + expires_in_days = post_data.get("expires_in_days") + if expires_in_days is not None: + try: + expires_in_days_int = int(expires_in_days) + except (TypeError, ValueError): + return Response().error("expires_in_days must be an integer").__dict__ + if expires_in_days_int <= 0: + return ( + Response().error("expires_in_days must be greater than 0").__dict__ + ) + expires_at = datetime.now(timezone.utc) + timedelta( + days=expires_in_days_int + ) + + raw_key = f"abk_{secrets.token_urlsafe(32)}" + key_hash = self._hash_key(raw_key) + key_prefix = raw_key[:12] + created_by = g.get("username", "unknown") + + api_key = await self.db.create_api_key( + name=name, + key_hash=key_hash, + key_prefix=key_prefix, + scopes=normalized_scopes, # type: ignore + created_by=created_by, + expires_at=expires_at, + ) + + payload = self._serialize_api_key(api_key) + payload["api_key"] = raw_key + return Response().ok(data=payload).__dict__ + + async def revoke_api_key(self): + post_data = await request.json or {} + key_id = post_data.get("key_id") + if not key_id: + return Response().error("Missing key: key_id").__dict__ + + success = await self.db.revoke_api_key(key_id) + if not success: + return Response().error("API key not found").__dict__ + return Response().ok().__dict__ + + async def delete_api_key(self): + post_data = await request.json or {} + key_id = post_data.get("key_id") + if not key_id: + return Response().error("Missing key: key_id").__dict__ + + success = await self.db.delete_api_key(key_id) + if not success: + return Response().error("API key not found").__dict__ + return Response().ok().__dict__ diff --git a/astrbot/dashboard/routes/auth.py b/astrbot/dashboard/routes/auth.py index 4ee0d57d4..40db1f60b 100644 --- a/astrbot/dashboard/routes/auth.py +++ b/astrbot/dashboard/routes/auth.py @@ -64,11 +64,13 @@ async def edit_account(self): new_pwd = post_data.get("new_password", None) new_username = post_data.get("new_username", None) if not new_pwd and not new_username: - return ( - Response().error("新用户名和新密码不能同时为空,你改了个寂寞").__dict__ - ) + return Response().error("新用户名和新密码不能同时为空").__dict__ + # Verify password confirmation if new_pwd: + confirm_pwd = post_data.get("confirm_password", None) + if confirm_pwd != new_pwd: + return Response().error("两次输入的新密码不一致").__dict__ self.config["dashboard"]["password"] = new_pwd if new_username: self.config["dashboard"]["username"] = new_username diff --git a/astrbot/dashboard/routes/backup.py b/astrbot/dashboard/routes/backup.py index ee39399dc..952806beb 100644 --- a/astrbot/dashboard/routes/backup.py +++ b/astrbot/dashboard/routes/backup.py @@ -183,7 +183,9 @@ def _update_progress( def _make_progress_callback(self, task_id: str): """创建进度回调函数""" - async def _callback(stage: str, current: int, total: int, message: str = ""): + async def _callback( + stage: str, current: int, total: int, message: str = "" + ) -> None: self._update_progress( task_id, status="processing", @@ -195,7 +197,7 @@ async def _callback(stage: str, current: int, total: int, message: str = ""): return _callback - def _ensure_cleanup_task_started(self): + def _ensure_cleanup_task_started(self) -> None: """确保后台清理任务已启动(在异步上下文中延迟启动)""" if self._cleanup_task is None or self._cleanup_task.done(): try: @@ -206,7 +208,7 @@ def _ensure_cleanup_task_started(self): # 如果没有运行中的事件循环,跳过(等待下次异步调用时启动) pass - async def _cleanup_expired_uploads(self): + async def _cleanup_expired_uploads(self) -> None: """定期清理过期的上传会话 基于 last_activity 字段判断过期,避免清理活跃的上传会话。 @@ -233,7 +235,7 @@ async def _cleanup_expired_uploads(self): except Exception as e: logger.error(f"清理过期上传会话失败: {e}") - async def _cleanup_upload_session(self, upload_id: str): + async def _cleanup_upload_session(self, upload_id: str) -> None: """清理上传会话""" if upload_id in self.upload_sessions: session = self.upload_sessions[upload_id] @@ -371,7 +373,7 @@ async def export_backup(self): logger.error(traceback.format_exc()) return Response().error(f"创建备份失败: {e!s}").__dict__ - async def _background_export_task(self, task_id: str): + async def _background_export_task(self, task_id: str) -> None: """后台导出任务""" try: self._update_progress(task_id, status="processing", message="正在初始化...") @@ -866,7 +868,7 @@ async def import_backup(self): logger.error(traceback.format_exc()) return Response().error(f"导入备份失败: {e!s}").__dict__ - async def _background_import_task(self, task_id: str, zip_path: str): + async def _background_import_task(self, task_id: str, zip_path: str) -> None: """后台导入任务""" try: self._update_progress(task_id, status="processing", message="正在初始化...") diff --git a/astrbot/dashboard/routes/chat.py b/astrbot/dashboard/routes/chat.py index 92ff4c3fe..0602cc074 100644 --- a/astrbot/dashboard/routes/chat.py +++ b/astrbot/dashboard/routes/chat.py @@ -1,6 +1,5 @@ import asyncio import json -import mimetypes import os import re import uuid @@ -13,7 +12,15 @@ from astrbot.core import logger, sp from astrbot.core.core_lifecycle import AstrBotCoreLifecycle from astrbot.core.db import BaseDatabase +from astrbot.core.platform.message_type import MessageType +from astrbot.core.platform.sources.webchat.message_parts_helper import ( + build_webchat_message_parts, + create_attachment_part_from_existing_file, + strip_message_parts_path_fields, + webchat_message_parts_have_content, +) from astrbot.core.platform.sources.webchat.webchat_queue_mgr import webchat_queue_mgr +from astrbot.core.utils.active_event_registry import active_event_registry from astrbot.core.utils.astrbot_path import get_astrbot_data_path from .route import Response, Route, RouteContext @@ -41,6 +48,7 @@ def __init__( "/chat/new_session": ("GET", self.new_session), "/chat/sessions": ("GET", self.get_sessions), "/chat/get_session": ("GET", self.get_session), + "/chat/stop": ("POST", self.stop_session), "/chat/delete_session": ("GET", self.delete_webchat_session), "/chat/update_session_display_name": ( "POST", @@ -52,8 +60,9 @@ def __init__( } self.core_lifecycle = core_lifecycle self.register_routes() - self.imgs_dir = os.path.join(get_astrbot_data_path(), "webchat", "imgs") - os.makedirs(self.imgs_dir, exist_ok=True) + self.attachments_dir = os.path.join(get_astrbot_data_path(), "attachments") + self.legacy_img_dir = os.path.join(get_astrbot_data_path(), "webchat", "imgs") + os.makedirs(self.attachments_dir, exist_ok=True) self.supported_imgs = ["jpg", "jpeg", "png", "gif", "webp"] self.conv_mgr = core_lifecycle.conversation_manager @@ -69,9 +78,18 @@ async def get_file(self): return Response().error("Missing key: filename").__dict__ try: - file_path = os.path.join(self.imgs_dir, os.path.basename(filename)) + file_path = os.path.join(self.attachments_dir, os.path.basename(filename)) real_file_path = os.path.realpath(file_path) - real_imgs_dir = os.path.realpath(self.imgs_dir) + real_imgs_dir = os.path.realpath(self.attachments_dir) + + if not os.path.exists(real_file_path): + # try legacy + file_path = os.path.join( + self.legacy_img_dir, os.path.basename(filename) + ) + if os.path.exists(file_path): + real_file_path = os.path.realpath(file_path) + real_imgs_dir = os.path.realpath(self.legacy_img_dir) if not real_file_path.startswith(real_imgs_dir): return Response().error("Invalid file path").__dict__ @@ -125,7 +143,7 @@ async def post_file(self): else: attach_type = "file" - path = os.path.join(self.imgs_dir, filename) + path = os.path.join(self.attachments_dir, filename) await file.save(path) # 创建 attachment 记录 @@ -153,78 +171,24 @@ async def post_file(self): ) async def _build_user_message_parts(self, message: str | list) -> list[dict]: - """构建用户消息的部分列表 - - Args: - message: 文本消息 (str) 或消息段列表 (list) - """ - parts = [] - - if isinstance(message, list): - for part in message: - part_type = part.get("type") - if part_type == "plain": - parts.append({"type": "plain", "text": part.get("text", "")}) - elif part_type == "reply": - parts.append( - { - "type": "reply", - "message_id": part.get("message_id"), - "selected_text": part.get("selected_text", ""), - } - ) - elif attachment_id := part.get("attachment_id"): - attachment = await self.db.get_attachment_by_id(attachment_id) - if attachment: - parts.append( - { - "type": attachment.type, - "attachment_id": attachment.attachment_id, - "filename": os.path.basename(attachment.path), - "path": attachment.path, # will be deleted - } - ) - return parts - - if message: - parts.append({"type": "plain", "text": message}) - - return parts + """构建用户消息的部分列表。""" + return await build_webchat_message_parts( + message, + get_attachment_by_id=self.db.get_attachment_by_id, + strict=False, + ) async def _create_attachment_from_file( self, filename: str, attach_type: str ) -> dict | None: - """从本地文件创建 attachment 并返回消息部分 - - 用于处理 bot 回复中的媒体文件 - - Args: - filename: 存储的文件名 - attach_type: 附件类型 (image, record, file, video) - """ - file_path = os.path.join(self.imgs_dir, os.path.basename(filename)) - if not os.path.exists(file_path): - return None - - # guess mime type - mime_type, _ = mimetypes.guess_type(filename) - if not mime_type: - mime_type = "application/octet-stream" - - # insert attachment - attachment = await self.db.insert_attachment( - path=file_path, - type=attach_type, - mime_type=mime_type, + """从本地文件创建 attachment 并返回消息部分。""" + return await create_attachment_part_from_existing_file( + filename, + attach_type=attach_type, + insert_attachment=self.db.insert_attachment, + attachments_dir=self.attachments_dir, + fallback_dirs=[self.legacy_img_dir], ) - if not attachment: - return None - - return { - "type": attach_type, - "attachment_id": attachment.attachment_id, - "filename": os.path.basename(file_path), - } def _extract_web_search_refs( self, accumulated_text: str, accumulated_parts: list @@ -238,6 +202,7 @@ def _extract_web_search_refs( Returns: 包含 used 列表的字典,记录被引用的搜索结果 """ + supported = ["web_search_tavily", "web_search_bocha"] # 从 accumulated_parts 中找到所有 web_search_tavily 的工具调用结果 web_search_results = {} tool_call_parts = [ @@ -248,7 +213,7 @@ def _extract_web_search_refs( for part in tool_call_parts: for tool_call in part["tool_calls"]: - if tool_call.get("name") != "web_search_tavily" or not tool_call.get( + if tool_call.get("name") not in supported or not tool_call.get( "result" ): continue @@ -278,7 +243,7 @@ def _extract_web_search_refs( if ref_index not in web_search_results: continue payload = {"index": ref_index, **web_search_results[ref_index]} - if favicon := sp.temorary_cache.get("_ws_favicon", {}).get(payload["url"]): + if favicon := sp.temporary_cache.get("_ws_favicon", {}).get(payload["url"]): payload["favicon"] = favicon used_refs.append(payload) @@ -316,10 +281,13 @@ async def _save_bot_message( ) return record - async def chat(self): + async def chat(self, post_data: dict | None = None): username = g.get("username", "guest") - post_data = await request.json + if post_data is None: + post_data = await request.json + if post_data is None: + return Response().error("Missing JSON body").__dict__ if "message" not in post_data and "files" not in post_data: return Response().error("Missing key: message or files").__dict__ @@ -334,31 +302,25 @@ async def chat(self): selected_model = post_data.get("selected_model") enable_streaming = post_data.get("enable_streaming", True) - # 检查消息是否为空 - if isinstance(message, list): - has_content = any( - part.get("type") in ("plain", "image", "record", "file", "video") - for part in message - ) - if not has_content: - return ( - Response() - .error("Message content is empty (reply only is not allowed)") - .__dict__ - ) - elif not message: - return Response().error("Message are both empty").__dict__ - if not session_id: return Response().error("session_id is empty").__dict__ webchat_conv_id = session_id - back_queue = webchat_queue_mgr.get_or_create_back_queue(webchat_conv_id) # 构建用户消息段(包含 path 用于传递给 adapter) message_parts = await self._build_user_message_parts(message) + if not webchat_message_parts_have_content(message_parts): + return ( + Response() + .error("Message content is empty (reply only is not allowed)") + .__dict__ + ) message_id = str(uuid.uuid4()) + back_queue = webchat_queue_mgr.get_or_create_back_queue( + message_id, + webchat_conv_id, + ) async def stream(): client_disconnected = False @@ -369,6 +331,14 @@ async def stream(): agent_stats = {} refs = {} try: + # Emit session_id first so clients can bind the stream immediately. + session_info = { + "type": "session_id", + "data": None, + "session_id": webchat_conv_id, + } + yield f"data: {json.dumps(session_info, ensure_ascii=False)}\n\n" + async with track_conversation(self.running_convs, webchat_conv_id): while True: try: @@ -441,13 +411,13 @@ async def stream(): if tc_id in tool_calls: tool_calls[tc_id]["result"] = tcr.get("result") tool_calls[tc_id]["finished_ts"] = tcr.get("ts") - accumulated_parts.append( - { - "type": "tool_call", - "tool_calls": [tool_calls[tc_id]], - } - ) - tool_calls.pop(tc_id, None) + accumulated_parts.append( + { + "type": "tool_call", + "tool_calls": [tool_calls[tc_id]], + } + ) + tool_calls.pop(tc_id, None) elif chain_type == "reasoning": accumulated_reasoning += result_text elif streaming: @@ -531,6 +501,8 @@ async def stream(): refs = {} except BaseException as e: logger.exception(f"WebChat stream unexpected error: {e}", exc_info=True) + finally: + webchat_queue_mgr.remove_back_queue(message_id) # 将消息放入会话特定的队列 chat_queue = webchat_queue_mgr.get_or_create_queue(webchat_conv_id) @@ -548,10 +520,7 @@ async def stream(): ), ) - message_parts_for_storage = [] - for part in message_parts: - part_copy = {k: v for k, v in part.items() if k != "path"} - message_parts_for_storage.append(part_copy) + message_parts_for_storage = strip_message_parts_path_fields(message_parts) await self.platform_history_mgr.insert( platform_id="webchat", @@ -576,6 +545,36 @@ async def stream(): response.timeout = None # fix SSE auto disconnect issue return response + async def stop_session(self): + """Stop active agent runs for a session.""" + post_data = await request.json + if post_data is None: + return Response().error("Missing JSON body").__dict__ + + session_id = post_data.get("session_id") + if not session_id: + return Response().error("Missing key: session_id").__dict__ + + username = g.get("username", "guest") + session = await self.db.get_platform_session_by_id(session_id) + if not session: + return Response().error(f"Session {session_id} not found").__dict__ + if session.creator != username: + return Response().error("Permission denied").__dict__ + + message_type = ( + MessageType.GROUP_MESSAGE.value + if session.is_group + else MessageType.FRIEND_MESSAGE.value + ) + umo = ( + f"{session.platform_id}:{message_type}:" + f"{session.platform_id}!{username}!{session_id}" + ) + stopped_count = active_event_registry.request_agent_stop_all(umo) + + return Response().ok(data={"stopped_count": stopped_count}).__dict__ + async def delete_webchat_session(self): """Delete a Platform session and all its related data.""" session_id = request.args.get("session_id") @@ -645,7 +644,7 @@ def _extract_attachment_ids(self, history_list) -> list[str]: attachment_ids.append(part["attachment_id"]) return attachment_ids - async def _delete_attachments(self, attachment_ids: list[str]): + async def _delete_attachments(self, attachment_ids: list[str]) -> None: """删除附件(包括数据库记录和磁盘文件)""" try: attachments = await self.db.get_attachments(attachment_ids) @@ -699,23 +698,18 @@ async def get_sessions(self): # 获取可选的 platform_id 参数 platform_id = request.args.get("platform_id") - sessions = await self.db.get_platform_sessions_by_creator( + sessions, _ = await self.db.get_platform_sessions_by_creator_paginated( creator=username, platform_id=platform_id, page=1, page_size=100, # 暂时返回前100个 + exclude_project_sessions=True, ) - # 转换为字典格式,并添加项目信息 - # get_platform_sessions_by_creator 现在返回 list[dict] 包含 session 和项目字段 + # 转换为字典格式 sessions_data = [] for item in sessions: session = item["session"] - project_id = item["project_id"] - - # 跳过属于项目的会话(在侧边栏对话列表中不显示) - if project_id is not None: - continue sessions_data.append( { diff --git a/astrbot/dashboard/routes/command.py b/astrbot/dashboard/routes/command.py index abd38d886..cbc565c47 100644 --- a/astrbot/dashboard/routes/command.py +++ b/astrbot/dashboard/routes/command.py @@ -10,6 +10,9 @@ from astrbot.core.star.command_management import ( toggle_command as toggle_command_service, ) +from astrbot.core.star.command_management import ( + update_command_permission as update_command_permission_service, +) from .route import Response, Route, RouteContext @@ -22,6 +25,7 @@ def __init__(self, context: RouteContext) -> None: "/commands/conflicts": ("GET", self.get_conflicts), "/commands/toggle": ("POST", self.toggle_command), "/commands/rename": ("POST", self.rename_command), + "/commands/permission": ("POST", self.update_permission), } self.register_routes() @@ -74,6 +78,24 @@ async def rename_command(self): payload = await _get_command_payload(handler_full_name) return Response().ok(payload).__dict__ + async def update_permission(self): + data = await request.get_json() + handler_full_name = data.get("handler_full_name") + permission = data.get("permission") + + if not handler_full_name or not permission: + return ( + Response().error("handler_full_name 与 permission 均为必填。").__dict__ + ) + + try: + await update_command_permission_service(handler_full_name, permission) + except ValueError as exc: + return Response().error(str(exc)).__dict__ + + payload = await _get_command_payload(handler_full_name) + return Response().ok(payload).__dict__ + async def _get_command_payload(handler_full_name: str): commands = await list_commands() diff --git a/astrbot/dashboard/routes/config.py b/astrbot/dashboard/routes/config.py index c5998682c..08b8c12b8 100644 --- a/astrbot/dashboard/routes/config.py +++ b/astrbot/dashboard/routes/config.py @@ -1,4 +1,5 @@ import asyncio +import copy import inspect import os import traceback @@ -58,7 +59,7 @@ def try_cast(value: Any, type_: str): return None -def _expect_type(value, expected_type, path_key, errors, expected_name=None): +def _expect_type(value, expected_type, path_key, errors, expected_name=None) -> bool: if not isinstance(value, expected_type): errors.append( f"错误的类型 {path_key}: 期望是 {expected_name or expected_type.__name__}, " @@ -68,7 +69,7 @@ def _expect_type(value, expected_type, path_key, errors, expected_name=None): return True -def _validate_template_list(value, meta, path_key, errors, validate_fn): +def _validate_template_list(value, meta, path_key, errors, validate_fn) -> None: if not _expect_type(value, list, path_key, errors, "list"): return @@ -101,7 +102,7 @@ def _validate_template_list(value, meta, path_key, errors, validate_fn): def validate_config(data, schema: dict, is_core: bool) -> tuple[list[str], dict]: errors = [] - def validate(data: dict, metadata: dict = schema, path=""): + def validate(data: dict, metadata: dict = schema, path="") -> None: for key, value in data.items(): if key not in metadata: continue @@ -205,7 +206,9 @@ def validate(data: dict, metadata: dict = schema, path=""): return errors, data -def save_config(post_config: dict, config: AstrBotConfig, is_core: bool = False): +def save_config( + post_config: dict, config: AstrBotConfig, is_core: bool = False +) -> None: """验证并保存配置""" errors = None logger.info(f"Saving config, is_core={is_core}") @@ -407,8 +410,19 @@ async def update_provider_source(self): return Response().ok(message="更新 provider source 成功").__dict__ async def get_provider_template(self): + provider_metadata = ConfigMetadataI18n.convert_to_i18n_keys( + { + "provider_group": { + "metadata": { + "provider": CONFIG_METADATA_2["provider_group"]["metadata"][ + "provider" + ] + } + } + } + ) config_schema = { - "provider": CONFIG_METADATA_2["provider_group"]["metadata"]["provider"] + "provider": provider_metadata["provider_group"]["metadata"]["provider"] } data = { "config_schema": config_schema, @@ -740,6 +754,22 @@ async def get_embedding_dim(self): if not provider_type: return Response().error("provider_config 缺少 type 字段").__dict__ + # 首次添加某类提供商时,provider_cls_map 可能尚未注册该适配器 + if provider_type not in provider_cls_map: + try: + self.core_lifecycle.provider_manager.dynamic_import_provider( + provider_type, + ) + except ImportError: + logger.error(traceback.format_exc()) + return ( + Response() + .error( + "提供商适配器加载失败,请检查提供商类型配置或查看服务端日志" + ) + .__dict__ + ) + # 获取对应的 provider 类 if provider_type not in provider_cls_map: return ( @@ -765,7 +795,7 @@ async def get_embedding_dim(self): if inspect.iscoroutinefunction(init_fn): await init_fn() - # 获取嵌入向量维度 + # 通过实际请求验证当前 embedding_dimensions 是否可用 vec = await inst.get_embedding("echo") dim = len(vec) @@ -1209,7 +1239,7 @@ async def get_llm_tools(self): tools = tool_mgr.get_func_desc_openai_style() return Response().ok(tools).__dict__ - async def _register_platform_logo(self, platform, platform_default_tmpl): + async def _register_platform_logo(self, platform, platform_default_tmpl) -> None: """注册平台logo文件并生成访问令牌""" if not platform.logo_path: return @@ -1276,19 +1306,68 @@ async def _register_platform_logo(self, platform, platform_default_tmpl): f"Unexpected error registering logo for platform {platform.name}: {e}", ) + def _inject_platform_metadata_with_i18n( + self, platform, metadata, platform_i18n_translations: dict + ): + """将配置元数据注入到 metadata 中并处理国际化键转换。""" + metadata["platform_group"]["metadata"]["platform"].setdefault("items", {}) + platform_items_to_inject = copy.deepcopy(platform.config_metadata) + + if platform.i18n_resources: + i18n_prefix = f"platform_group.platform.{platform.name}" + + for lang, lang_data in platform.i18n_resources.items(): + platform_i18n_translations.setdefault(lang, {}).setdefault( + "platform_group", {} + ).setdefault("platform", {})[platform.name] = lang_data + + for field_key, field_value in platform_items_to_inject.items(): + for key in ("description", "hint", "labels"): + if key in field_value: + field_value[key] = f"{i18n_prefix}.{field_key}.{key}" + + metadata["platform_group"]["metadata"]["platform"]["items"].update( + platform_items_to_inject + ) + async def _get_astrbot_config(self): config = self.config + metadata = copy.deepcopy(CONFIG_METADATA_2) + platform_i18n = ConfigMetadataI18n.convert_to_i18n_keys( + { + "platform_group": { + "metadata": { + "platform": metadata["platform_group"]["metadata"]["platform"] + } + } + } + ) + metadata["platform_group"]["metadata"]["platform"] = platform_i18n[ + "platform_group" + ]["metadata"]["platform"] # 平台适配器的默认配置模板注入 - platform_default_tmpl = CONFIG_METADATA_2["platform_group"]["metadata"][ - "platform" - ]["config_template"] + platform_default_tmpl = metadata["platform_group"]["metadata"]["platform"][ + "config_template" + ] + + # 收集平台的 i18n 翻译数据 + platform_i18n_translations = {} # 收集需要注册logo的平台 logo_registration_tasks = [] for platform in platform_registry: if platform.default_config_tmpl: - platform_default_tmpl[platform.name] = platform.default_config_tmpl + platform_default_tmpl[platform.name] = copy.deepcopy( + platform.default_config_tmpl + ) + + # 注入配置元数据(在 convert_to_i18n_keys 之后,使用国际化键) + if platform.config_metadata: + self._inject_platform_metadata_with_i18n( + platform, metadata, platform_i18n_translations + ) + # 收集logo注册任务 if platform.logo_path: logo_registration_tasks.append( @@ -1300,14 +1379,18 @@ async def _get_astrbot_config(self): await asyncio.gather(*logo_registration_tasks, return_exceptions=True) # 服务提供商的默认配置模板注入 - provider_default_tmpl = CONFIG_METADATA_2["provider_group"]["metadata"][ - "provider" - ]["config_template"] + provider_default_tmpl = metadata["provider_group"]["metadata"]["provider"][ + "config_template" + ] for provider in provider_registry: if provider.default_config_tmpl: provider_default_tmpl[provider.type] = provider.default_config_tmpl - return {"metadata": CONFIG_METADATA_2, "config": config} + return { + "metadata": metadata, + "config": config, + "platform_i18n_translations": platform_i18n_translations, + } async def _get_plugin_config(self, plugin_name: str): ret: dict = {"metadata": None, "config": None} @@ -1332,7 +1415,7 @@ async def _get_plugin_config(self, plugin_name: str): async def _save_astrbot_configs( self, post_configs: dict, conf_id: str | None = None - ): + ) -> None: try: if conf_id not in self.acm.confs: raise ValueError(f"配置文件 {conf_id} 不存在") @@ -1348,7 +1431,7 @@ async def _save_astrbot_configs( except Exception as e: raise e - async def _save_plugin_configs(self, post_configs: dict, plugin_name: str): + async def _save_plugin_configs(self, post_configs: dict, plugin_name: str) -> None: md = None for plugin_md in star_registry: if plugin_md.name == plugin_name: diff --git a/astrbot/dashboard/routes/conversation.py b/astrbot/dashboard/routes/conversation.py index 513d3603f..68eed7ef1 100644 --- a/astrbot/dashboard/routes/conversation.py +++ b/astrbot/dashboard/routes/conversation.py @@ -148,7 +148,6 @@ async def upd_conv(self): user_id = data.get("user_id") cid = data.get("cid") title = data.get("title") - persona_id = data.get("persona_id", "") if not user_id or not cid: return Response().error("缺少必要参数: user_id 和 cid").__dict__ @@ -158,6 +157,9 @@ async def upd_conv(self): ) if not conversation: return Response().error("对话不存在").__dict__ + + persona_id = data.get("persona_id", conversation.persona_id) + if title is not None or persona_id is not None: await self.conv_mgr.update_conversation( unified_msg_origin=user_id, diff --git a/astrbot/dashboard/routes/knowledge_base.py b/astrbot/dashboard/routes/knowledge_base.py index 25bc2cf34..f0ac5d43d 100644 --- a/astrbot/dashboard/routes/knowledge_base.py +++ b/astrbot/dashboard/routes/knowledge_base.py @@ -12,6 +12,7 @@ from astrbot.core import logger from astrbot.core.core_lifecycle import AstrBotCoreLifecycle from astrbot.core.provider.provider import EmbeddingProvider, RerankProvider +from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from ..utils import generate_tsne_visualization from .route import Response, Route, RouteContext @@ -114,7 +115,7 @@ def _update_progress( p["total"] = total def _make_progress_callback(self, task_id: str, file_idx: int, file_name: str): - async def _callback(stage: str, current: int, total: int): + async def _callback(stage: str, current: int, total: int) -> None: self._update_progress( task_id, status="processing", @@ -137,7 +138,7 @@ async def _background_upload_task( batch_size: int, tasks_limit: int, max_retries: int, - ): + ) -> None: """后台上传任务""" try: # 初始化任务状态 @@ -216,7 +217,7 @@ async def _background_import_task( batch_size: int, tasks_limit: int, max_retries: int, - ): + ) -> None: """后台导入预切片文档任务""" try: # 初始化任务状态 @@ -703,7 +704,10 @@ async def upload_document(self): file_name = file.filename # 保存到临时文件 - temp_file_path = f"data/temp/{uuid.uuid4()}_{file_name}" + temp_file_path = os.path.join( + get_astrbot_temp_path(), + f"kb_upload_{uuid.uuid4()}_{file_name}", + ) await file.save(temp_file_path) try: @@ -1215,7 +1219,7 @@ async def _background_upload_from_url_task( max_retries: int, enable_cleaning: bool, cleaning_provider_id: str | None, - ): + ) -> None: """后台上传URL任务""" try: # 初始化任务状态 diff --git a/astrbot/dashboard/routes/live_chat.py b/astrbot/dashboard/routes/live_chat.py index 0c3ddcc2e..25438565e 100644 --- a/astrbot/dashboard/routes/live_chat.py +++ b/astrbot/dashboard/routes/live_chat.py @@ -1,6 +1,7 @@ import asyncio import json import os +import re import time import uuid import wave @@ -10,9 +11,16 @@ from quart import websocket from astrbot import logger +from astrbot.core import sp from astrbot.core.core_lifecycle import AstrBotCoreLifecycle +from astrbot.core.platform.sources.webchat.message_parts_helper import ( + build_webchat_message_parts, + create_attachment_part_from_existing_file, + strip_message_parts_path_fields, + webchat_message_parts_have_content, +) from astrbot.core.platform.sources.webchat.webchat_queue_mgr import webchat_queue_mgr -from astrbot.core.utils.astrbot_path import get_astrbot_data_path +from astrbot.core.utils.astrbot_path import get_astrbot_data_path, get_astrbot_temp_path from .route import Route, RouteContext @@ -20,7 +28,7 @@ class LiveChatSession: """Live Chat 会话管理器""" - def __init__(self, session_id: str, username: str): + def __init__(self, session_id: str, username: str) -> None: self.session_id = session_id self.username = username self.conversation_id = str(uuid.uuid4()) @@ -30,15 +38,18 @@ def __init__(self, session_id: str, username: str): self.audio_frames: list[bytes] = [] self.current_stamp: str | None = None self.temp_audio_path: str | None = None + self.chat_subscriptions: dict[str, str] = {} + self.chat_subscription_tasks: dict[str, asyncio.Task] = {} + self.ws_send_lock = asyncio.Lock() - def start_speaking(self, stamp: str): + def start_speaking(self, stamp: str) -> None: """开始说话""" self.is_speaking = True self.current_stamp = stamp self.audio_frames = [] logger.debug(f"[Live Chat] {self.username} 开始说话 stamp={stamp}") - def add_audio_frame(self, data: bytes): + def add_audio_frame(self, data: bytes) -> None: """添加音频帧""" if self.is_speaking: self.audio_frames.append(data) @@ -60,7 +71,7 @@ async def end_speaking(self, stamp: str) -> tuple[str | None, float]: # 组装 WAV 文件 try: - temp_dir = os.path.join(get_astrbot_data_path(), "temp") + temp_dir = get_astrbot_temp_path() os.makedirs(temp_dir, exist_ok=True) audio_path = os.path.join(temp_dir, f"live_audio_{uuid.uuid4()}.wav") @@ -82,7 +93,7 @@ async def end_speaking(self, stamp: str) -> tuple[str | None, float]: logger.error(f"[Live Chat] 组装 WAV 文件失败: {e}", exc_info=True) return None, 0.0 - def cleanup(self): + def cleanup(self) -> None: """清理临时文件""" if self.temp_audio_path and os.path.exists(self.temp_audio_path): try: @@ -106,13 +117,26 @@ def __init__( self.core_lifecycle = core_lifecycle self.db = db self.plugin_manager = core_lifecycle.plugin_manager + self.platform_history_mgr = core_lifecycle.platform_message_history_manager self.sessions: dict[str, LiveChatSession] = {} + self.attachments_dir = os.path.join(get_astrbot_data_path(), "attachments") + self.legacy_img_dir = os.path.join(get_astrbot_data_path(), "webchat", "imgs") + os.makedirs(self.attachments_dir, exist_ok=True) # 注册 WebSocket 路由 self.app.websocket("/api/live_chat/ws")(self.live_chat_ws) + self.app.websocket("/api/unified_chat/ws")(self.unified_chat_ws) - async def live_chat_ws(self): - """Live Chat WebSocket 处理器""" + async def live_chat_ws(self) -> None: + """Legacy Live Chat WebSocket 处理器(默认 ct=live)""" + await self._unified_ws_loop(force_ct="live") + + async def unified_chat_ws(self) -> None: + """Unified Chat WebSocket 处理器(支持 ct=live/chat)""" + await self._unified_ws_loop(force_ct=None) + + async def _unified_ws_loop(self, force_ct: str | None = None) -> None: + """统一 WebSocket 循环""" # WebSocket 不能通过 header 传递 token,需要从 query 参数获取 # 注意:WebSocket 上下文使用 websocket.args 而不是 request.args token = websocket.args.get("token") @@ -140,7 +164,11 @@ async def live_chat_ws(self): try: while True: message = await websocket.receive_json() - await self._handle_message(live_session, message) + ct = force_ct or message.get("ct", "live") + if ct == "chat": + await self._handle_chat_message(live_session, message) + else: + await self._handle_message(live_session, message) except Exception as e: logger.error(f"[Live Chat] WebSocket 错误: {e}", exc_info=True) @@ -148,11 +176,489 @@ async def live_chat_ws(self): finally: # 清理会话 if session_id in self.sessions: + await self._cleanup_chat_subscriptions(live_session) live_session.cleanup() del self.sessions[session_id] logger.info(f"[Live Chat] WebSocket 连接关闭: {username}") - async def _handle_message(self, session: LiveChatSession, message: dict): + async def _create_attachment_from_file( + self, filename: str, attach_type: str + ) -> dict | None: + """从本地文件创建 attachment 并返回消息部分。""" + return await create_attachment_part_from_existing_file( + filename, + attach_type=attach_type, + insert_attachment=self.db.insert_attachment, + attachments_dir=self.attachments_dir, + fallback_dirs=[self.legacy_img_dir], + ) + + def _extract_web_search_refs( + self, accumulated_text: str, accumulated_parts: list + ) -> dict: + """从消息中提取 web_search 引用。""" + supported = ["web_search_tavily", "web_search_bocha"] + web_search_results = {} + tool_call_parts = [ + p + for p in accumulated_parts + if p.get("type") == "tool_call" and p.get("tool_calls") + ] + + for part in tool_call_parts: + for tool_call in part["tool_calls"]: + if tool_call.get("name") not in supported or not tool_call.get( + "result" + ): + continue + try: + result_data = json.loads(tool_call["result"]) + for item in result_data.get("results", []): + if idx := item.get("index"): + web_search_results[idx] = { + "url": item.get("url"), + "title": item.get("title"), + "snippet": item.get("snippet"), + } + except (json.JSONDecodeError, KeyError): + pass + + if not web_search_results: + return {} + + ref_indices = { + m.strip() for m in re.findall(r"(.*?)", accumulated_text) + } + + used_refs = [] + for ref_index in ref_indices: + if ref_index not in web_search_results: + continue + payload = {"index": ref_index, **web_search_results[ref_index]} + if favicon := sp.temporary_cache.get("_ws_favicon", {}).get(payload["url"]): + payload["favicon"] = favicon + used_refs.append(payload) + + return {"used": used_refs} if used_refs else {} + + async def _save_bot_message( + self, + webchat_conv_id: str, + text: str, + media_parts: list, + reasoning: str, + agent_stats: dict, + refs: dict, + ): + """保存 bot 消息到历史记录。""" + bot_message_parts = [] + bot_message_parts.extend(media_parts) + if text: + bot_message_parts.append({"type": "plain", "text": text}) + + new_his = {"type": "bot", "message": bot_message_parts} + if reasoning: + new_his["reasoning"] = reasoning + if agent_stats: + new_his["agent_stats"] = agent_stats + if refs: + new_his["refs"] = refs + + return await self.platform_history_mgr.insert( + platform_id="webchat", + user_id=webchat_conv_id, + content=new_his, + sender_id="bot", + sender_name="bot", + ) + + async def _send_chat_payload(self, session: LiveChatSession, payload: dict) -> None: + async with session.ws_send_lock: + await websocket.send_json(payload) + + async def _forward_chat_subscription( + self, + session: LiveChatSession, + chat_session_id: str, + request_id: str, + ) -> None: + back_queue = webchat_queue_mgr.get_or_create_back_queue( + request_id, chat_session_id + ) + try: + while True: + result = await back_queue.get() + if not result: + continue + await self._send_chat_payload(session, {"ct": "chat", **result}) + except asyncio.CancelledError: + pass + except Exception as e: + logger.error( + f"[Live Chat] chat subscription forward failed ({chat_session_id}): {e}", + exc_info=True, + ) + finally: + webchat_queue_mgr.remove_back_queue(request_id) + if session.chat_subscriptions.get(chat_session_id) == request_id: + session.chat_subscriptions.pop(chat_session_id, None) + session.chat_subscription_tasks.pop(chat_session_id, None) + + async def _ensure_chat_subscription( + self, + session: LiveChatSession, + chat_session_id: str, + ) -> str: + existing_request_id = session.chat_subscriptions.get(chat_session_id) + existing_task = session.chat_subscription_tasks.get(chat_session_id) + if existing_request_id and existing_task and not existing_task.done(): + return existing_request_id + + request_id = f"ws_sub_{uuid.uuid4().hex}" + session.chat_subscriptions[chat_session_id] = request_id + task = asyncio.create_task( + self._forward_chat_subscription(session, chat_session_id, request_id), + name=f"chat_ws_sub_{chat_session_id}", + ) + session.chat_subscription_tasks[chat_session_id] = task + return request_id + + async def _cleanup_chat_subscriptions(self, session: LiveChatSession) -> None: + tasks = list(session.chat_subscription_tasks.values()) + for task in tasks: + task.cancel() + if tasks: + await asyncio.gather(*tasks, return_exceptions=True) + + for request_id in list(session.chat_subscriptions.values()): + webchat_queue_mgr.remove_back_queue(request_id) + session.chat_subscriptions.clear() + session.chat_subscription_tasks.clear() + + async def _handle_chat_message( + self, session: LiveChatSession, message: dict + ) -> None: + """处理 Chat Mode 消息(ct=chat)""" + msg_type = message.get("t") + + if msg_type == "bind": + chat_session_id = message.get("session_id") + if not isinstance(chat_session_id, str) or not chat_session_id: + await self._send_chat_payload( + session, + { + "ct": "chat", + "t": "error", + "data": "session_id is required", + "code": "INVALID_MESSAGE_FORMAT", + }, + ) + return + + request_id = await self._ensure_chat_subscription(session, chat_session_id) + await self._send_chat_payload( + session, + { + "ct": "chat", + "type": "session_bound", + "session_id": chat_session_id, + "message_id": request_id, + }, + ) + return + + if msg_type == "interrupt": + session.should_interrupt = True + await self._send_chat_payload( + session, + { + "ct": "chat", + "t": "error", + "data": "INTERRUPTED", + "code": "INTERRUPTED", + }, + ) + return + + if msg_type != "send": + await self._send_chat_payload( + session, + { + "ct": "chat", + "t": "error", + "data": f"Unsupported message type: {msg_type}", + "code": "INVALID_MESSAGE_FORMAT", + }, + ) + return + + if session.is_processing: + await self._send_chat_payload( + session, + { + "ct": "chat", + "t": "error", + "data": "Session is busy", + "code": "PROCESSING_ERROR", + }, + ) + return + + payload = message.get("message") + session_id = message.get("session_id") or session.session_id + message_id = message.get("message_id") or str(uuid.uuid4()) + selected_provider = message.get("selected_provider") + selected_model = message.get("selected_model") + selected_stt_provider = message.get("selected_stt_provider") + selected_tts_provider = message.get("selected_tts_provider") + persona_prompt = message.get("persona_prompt") + show_reasoning = message.get("show_reasoning") + enable_streaming = message.get("enable_streaming", True) + + if not isinstance(payload, list): + await self._send_chat_payload( + session, + { + "ct": "chat", + "t": "error", + "data": "message must be list", + "code": "INVALID_MESSAGE_FORMAT", + }, + ) + return + + message_parts = await self._build_chat_message_parts(payload) + has_content = webchat_message_parts_have_content(message_parts) + if not has_content: + await self._send_chat_payload( + session, + { + "ct": "chat", + "t": "error", + "data": "Message content is empty", + "code": "INVALID_MESSAGE_FORMAT", + }, + ) + return + + await self._ensure_chat_subscription(session, session_id) + + session.is_processing = True + session.should_interrupt = False + back_queue = webchat_queue_mgr.get_or_create_back_queue(message_id, session_id) + + try: + chat_queue = webchat_queue_mgr.get_or_create_queue(session_id) + await chat_queue.put( + ( + session.username, + session_id, + { + "message": message_parts, + "selected_provider": selected_provider, + "selected_model": selected_model, + "selected_stt_provider": selected_stt_provider, + "selected_tts_provider": selected_tts_provider, + "persona_prompt": persona_prompt, + "show_reasoning": show_reasoning, + "enable_streaming": enable_streaming, + "message_id": message_id, + }, + ), + ) + + message_parts_for_storage = strip_message_parts_path_fields(message_parts) + await self.platform_history_mgr.insert( + platform_id="webchat", + user_id=session_id, + content={"type": "user", "message": message_parts_for_storage}, + sender_id=session.username, + sender_name=session.username, + ) + + accumulated_parts = [] + accumulated_text = "" + accumulated_reasoning = "" + tool_calls = {} + agent_stats = {} + refs = {} + + while True: + if session.should_interrupt: + session.should_interrupt = False + break + + try: + result = await asyncio.wait_for(back_queue.get(), timeout=1) + except asyncio.TimeoutError: + continue + + if not result: + continue + if result.get("message_id") and result.get("message_id") != message_id: + continue + + result_text = result.get("data", "") + msg_type = result.get("type") + streaming = result.get("streaming", False) + chain_type = result.get("chain_type") + if chain_type == "agent_stats": + try: + parsed_agent_stats = json.loads(result_text) + agent_stats = parsed_agent_stats + await self._send_chat_payload( + session, + { + "ct": "chat", + "type": "agent_stats", + "data": parsed_agent_stats, + }, + ) + except Exception: + pass + continue + + outgoing = {"ct": "chat", **result} + await self._send_chat_payload(session, outgoing) + + if msg_type == "plain": + if chain_type == "tool_call": + try: + tool_call = json.loads(result_text) + tool_calls[tool_call.get("id")] = tool_call + if accumulated_text: + accumulated_parts.append( + {"type": "plain", "text": accumulated_text} + ) + accumulated_text = "" + except Exception: + pass + elif chain_type == "tool_call_result": + try: + tcr = json.loads(result_text) + tc_id = tcr.get("id") + if tc_id in tool_calls: + tool_calls[tc_id]["result"] = tcr.get("result") + tool_calls[tc_id]["finished_ts"] = tcr.get("ts") + accumulated_parts.append( + { + "type": "tool_call", + "tool_calls": [tool_calls[tc_id]], + } + ) + tool_calls.pop(tc_id, None) + except Exception: + pass + elif chain_type == "reasoning": + accumulated_reasoning += result_text + elif streaming: + accumulated_text += result_text + else: + accumulated_text = result_text + elif msg_type == "image": + filename = str(result_text).replace("[IMAGE]", "") + part = await self._create_attachment_from_file(filename, "image") + if part: + accumulated_parts.append(part) + elif msg_type == "record": + filename = str(result_text).replace("[RECORD]", "") + part = await self._create_attachment_from_file(filename, "record") + if part: + accumulated_parts.append(part) + elif msg_type == "file": + filename = str(result_text).replace("[FILE]", "").split("|", 1)[0] + part = await self._create_attachment_from_file(filename, "file") + if part: + accumulated_parts.append(part) + elif msg_type == "video": + filename = str(result_text).replace("[VIDEO]", "").split("|", 1)[0] + part = await self._create_attachment_from_file(filename, "video") + if part: + accumulated_parts.append(part) + + should_save = False + if msg_type == "end": + should_save = bool( + accumulated_parts + or accumulated_text + or accumulated_reasoning + or refs + or agent_stats + ) + elif (streaming and msg_type == "complete") or not streaming: + if chain_type not in ( + "tool_call", + "tool_call_result", + "agent_stats", + ): + should_save = True + + if should_save: + try: + refs = self._extract_web_search_refs( + accumulated_text, + accumulated_parts, + ) + except Exception as e: + logger.exception( + f"[Live Chat] Failed to extract web search refs: {e}", + exc_info=True, + ) + + saved_record = await self._save_bot_message( + session_id, + accumulated_text, + accumulated_parts, + accumulated_reasoning, + agent_stats, + refs, + ) + if saved_record: + await self._send_chat_payload( + session, + { + "ct": "chat", + "type": "message_saved", + "data": { + "id": saved_record.id, + "created_at": saved_record.created_at.astimezone().isoformat(), + }, + }, + ) + + accumulated_parts = [] + accumulated_text = "" + accumulated_reasoning = "" + agent_stats = {} + refs = {} + + if msg_type == "end": + break + + except Exception as e: + logger.error(f"[Live Chat] 处理 chat 消息失败: {e}", exc_info=True) + await self._send_chat_payload( + session, + { + "ct": "chat", + "t": "error", + "data": f"处理失败: {str(e)}", + "code": "PROCESSING_ERROR", + }, + ) + finally: + session.is_processing = False + webchat_queue_mgr.remove_back_queue(message_id) + + async def _build_chat_message_parts(self, message: list[dict]) -> list[dict]: + """构建 chat websocket 用户消息段(复用 webchat 逻辑)""" + return await build_webchat_message_parts( + message, + get_attachment_by_id=self.db.get_attachment_by_id, + strict=False, + ) + + async def _handle_message(self, session: LiveChatSession, message: dict) -> None: """处理 WebSocket 消息""" msg_type = message.get("t") # 使用 t 代替 type @@ -201,7 +707,7 @@ async def _handle_message(self, session: LiveChatSession, message: dict): async def _process_audio( self, session: LiveChatSession, audio_path: str, assemble_duration: float - ): + ) -> None: """处理音频:STT -> LLM -> 流式 TTS""" try: # 发送 WAV 组装耗时 @@ -256,143 +762,148 @@ async def _process_audio( await queue.put((session.username, cid, payload)) # 3. 等待响应并流式发送 TTS 音频 - back_queue = webchat_queue_mgr.get_or_create_back_queue(cid) + back_queue = webchat_queue_mgr.get_or_create_back_queue(message_id, cid) bot_text = "" audio_playing = False - while True: - if session.should_interrupt: - # 用户打断,停止处理 - logger.info("[Live Chat] 检测到用户打断") - await websocket.send_json({"t": "stop_play"}) - # 保存消息并标记为被打断 - await self._save_interrupted_message(session, user_text, bot_text) - # 清空队列中未处理的消息 - while not back_queue.empty(): - try: - back_queue.get_nowait() - except asyncio.QueueEmpty: - break - break - - try: - result = await asyncio.wait_for(back_queue.get(), timeout=0.5) - except asyncio.TimeoutError: - continue - - if not result: - continue - - result_message_id = result.get("message_id") - if result_message_id != message_id: - logger.warning( - f"[Live Chat] 消息 ID 不匹配: {result_message_id} != {message_id}" - ) - continue - - result_type = result.get("type") - result_chain_type = result.get("chain_type") - data = result.get("data", "") - - if result_chain_type == "agent_stats": - try: - stats = json.loads(data) - await websocket.send_json( - { - "t": "metrics", - "data": { - "llm_ttft": stats.get("time_to_first_token", 0), - "llm_total_time": stats.get("end_time", 0) - - stats.get("start_time", 0), - }, - } + try: + while True: + if session.should_interrupt: + # 用户打断,停止处理 + logger.info("[Live Chat] 检测到用户打断") + await websocket.send_json({"t": "stop_play"}) + # 保存消息并标记为被打断 + await self._save_interrupted_message( + session, user_text, bot_text ) - except Exception as e: - logger.error(f"[Live Chat] 解析 AgentStats 失败: {e}") - continue + # 清空队列中未处理的消息 + while not back_queue.empty(): + try: + back_queue.get_nowait() + except asyncio.QueueEmpty: + break + break - if result_chain_type == "tts_stats": try: - stats = json.loads(data) - await websocket.send_json( - { - "t": "metrics", - "data": stats, - } - ) - except Exception as e: - logger.error(f"[Live Chat] 解析 TTSStats 失败: {e}") - continue - - if result_type == "plain": - # 普通文本消息 - bot_text += data + result = await asyncio.wait_for(back_queue.get(), timeout=0.5) + except asyncio.TimeoutError: + continue - elif result_type == "audio_chunk": - # 流式音频数据 - if not audio_playing: - audio_playing = True - logger.debug("[Live Chat] 开始播放音频流") + if not result: + continue - # Calculate latency from wav assembly finish to first audio chunk - speak_to_first_frame_latency = ( - time.time() - wav_assembly_finish_time - ) - await websocket.send_json( - { - "t": "metrics", - "data": { - "speak_to_first_frame": speak_to_first_frame_latency - }, - } + result_message_id = result.get("message_id") + if result_message_id != message_id: + logger.warning( + f"[Live Chat] 消息 ID 不匹配: {result_message_id} != {message_id}" ) + continue - text = result.get("text") - if text: + result_type = result.get("type") + result_chain_type = result.get("chain_type") + data = result.get("data", "") + + if result_chain_type == "agent_stats": + try: + stats = json.loads(data) + await websocket.send_json( + { + "t": "metrics", + "data": { + "llm_ttft": stats.get("time_to_first_token", 0), + "llm_total_time": stats.get("end_time", 0) + - stats.get("start_time", 0), + }, + } + ) + except Exception as e: + logger.error(f"[Live Chat] 解析 AgentStats 失败: {e}") + continue + + if result_chain_type == "tts_stats": + try: + stats = json.loads(data) + await websocket.send_json( + { + "t": "metrics", + "data": stats, + } + ) + except Exception as e: + logger.error(f"[Live Chat] 解析 TTSStats 失败: {e}") + continue + + if result_type == "plain": + # 普通文本消息 + bot_text += data + + elif result_type == "audio_chunk": + # 流式音频数据 + if not audio_playing: + audio_playing = True + logger.debug("[Live Chat] 开始播放音频流") + + # Calculate latency from wav assembly finish to first audio chunk + speak_to_first_frame_latency = ( + time.time() - wav_assembly_finish_time + ) + await websocket.send_json( + { + "t": "metrics", + "data": { + "speak_to_first_frame": speak_to_first_frame_latency + }, + } + ) + + text = result.get("text") + if text: + await websocket.send_json( + { + "t": "bot_text_chunk", + "data": {"text": text}, + } + ) + + # 发送音频数据给前端 await websocket.send_json( { - "t": "bot_text_chunk", - "data": {"text": text}, + "t": "response", + "data": data, # base64 编码的音频数据 } ) - # 发送音频数据给前端 - await websocket.send_json( - { - "t": "response", - "data": data, # base64 编码的音频数据 - } - ) - - elif result_type in ["complete", "end"]: - # 处理完成 - logger.info(f"[Live Chat] Bot 回复完成: {bot_text}") - - # 如果没有音频流,发送 bot 消息文本 - if not audio_playing: + elif result_type in ["complete", "end"]: + # 处理完成 + logger.info(f"[Live Chat] Bot 回复完成: {bot_text}") + + # 如果没有音频流,发送 bot 消息文本 + if not audio_playing: + await websocket.send_json( + { + "t": "bot_msg", + "data": { + "text": bot_text, + "ts": int(time.time() * 1000), + }, + } + ) + + # 发送结束标记 + await websocket.send_json({"t": "end"}) + + # 发送总耗时 + wav_to_tts_duration = time.time() - wav_assembly_finish_time await websocket.send_json( { - "t": "bot_msg", - "data": { - "text": bot_text, - "ts": int(time.time() * 1000), - }, + "t": "metrics", + "data": {"wav_to_tts_total_time": wav_to_tts_duration}, } ) - - # 发送结束标记 - await websocket.send_json({"t": "end"}) - - # 发送总耗时 - wav_to_tts_duration = time.time() - wav_assembly_finish_time - await websocket.send_json( - { - "t": "metrics", - "data": {"wav_to_tts_total_time": wav_to_tts_duration}, - } - ) - break + break + finally: + webchat_queue_mgr.remove_back_queue(message_id) except Exception as e: logger.error(f"[Live Chat] 处理音频失败: {e}", exc_info=True) @@ -404,7 +915,7 @@ async def _process_audio( async def _save_interrupted_message( self, session: LiveChatSession, user_text: str, bot_text: str - ): + ) -> None: """保存被打断的消息""" interrupted_text = bot_text + " [用户打断]" logger.info(f"[Live Chat] 保存打断消息: {interrupted_text}") diff --git a/astrbot/dashboard/routes/open_api.py b/astrbot/dashboard/routes/open_api.py new file mode 100644 index 000000000..653e22cbf --- /dev/null +++ b/astrbot/dashboard/routes/open_api.py @@ -0,0 +1,667 @@ +import asyncio +import hashlib +import json +from uuid import uuid4 + +from quart import g, request, websocket + +from astrbot.core import logger +from astrbot.core.core_lifecycle import AstrBotCoreLifecycle +from astrbot.core.db import BaseDatabase +from astrbot.core.platform.message_session import MessageSesion +from astrbot.core.platform.sources.webchat.message_parts_helper import ( + build_message_chain_from_payload, + strip_message_parts_path_fields, + webchat_message_parts_have_content, +) +from astrbot.core.platform.sources.webchat.webchat_queue_mgr import webchat_queue_mgr + +from .api_key import ALL_OPEN_API_SCOPES +from .chat import ChatRoute +from .route import Response, Route, RouteContext + + +class OpenApiRoute(Route): + def __init__( + self, + context: RouteContext, + db: BaseDatabase, + core_lifecycle: AstrBotCoreLifecycle, + chat_route: ChatRoute, + ) -> None: + super().__init__(context) + self.db = db + self.core_lifecycle = core_lifecycle + self.platform_manager = core_lifecycle.platform_manager + self.chat_route = chat_route + + self.routes = { + "/v1/chat": ("POST", self.chat_send), + "/v1/chat/sessions": ("GET", self.get_chat_sessions), + "/v1/configs": ("GET", self.get_chat_configs), + "/v1/file": ("POST", self.upload_file), + "/v1/im/message": ("POST", self.send_message), + "/v1/im/bots": ("GET", self.get_bots), + } + self.register_routes() + self.app.websocket("/api/v1/chat/ws")(self.chat_ws) + + @staticmethod + def _resolve_open_username( + raw_username: str | None, + ) -> tuple[str | None, str | None]: + if raw_username is None: + return None, "Missing key: username" + username = str(raw_username).strip() + if not username: + return None, "username is empty" + return username, None + + def _get_chat_config_list(self) -> list[dict]: + conf_list = self.core_lifecycle.astrbot_config_mgr.get_conf_list() + + result = [] + for conf_info in conf_list: + conf_id = str(conf_info.get("id", "")).strip() + result.append( + { + "id": conf_id, + "name": str(conf_info.get("name", "")).strip(), + "path": str(conf_info.get("path", "")).strip(), + "is_default": conf_id == "default", + } + ) + return result + + def _resolve_chat_config_id(self, post_data: dict) -> tuple[str | None, str | None]: + raw_config_id = post_data.get("config_id") + raw_config_name = post_data.get("config_name") + config_id = str(raw_config_id).strip() if raw_config_id is not None else "" + config_name = ( + str(raw_config_name).strip() if raw_config_name is not None else "" + ) + + if not config_id and not config_name: + return None, None + + conf_list = self._get_chat_config_list() + conf_map = {item["id"]: item for item in conf_list} + + if config_id: + if config_id not in conf_map: + return None, f"config_id not found: {config_id}" + return config_id, None + + if not config_name: + return None, "config_name is empty" + + matched = [item for item in conf_list if item["name"] == config_name] + if not matched: + return None, f"config_name not found: {config_name}" + if len(matched) > 1: + return ( + None, + f"config_name is ambiguous, please use config_id: {config_name}", + ) + + return matched[0]["id"], None + + async def _ensure_chat_session( + self, + username: str, + session_id: str, + ) -> str | None: + session = await self.db.get_platform_session_by_id(session_id) + if session: + if session.creator != username: + return "session_id belongs to another username" + return None + + try: + await self.db.create_platform_session( + creator=username, + platform_id="webchat", + session_id=session_id, + is_group=0, + ) + except Exception as e: + # Handle rare race when same session_id is created concurrently. + existing = await self.db.get_platform_session_by_id(session_id) + if existing and existing.creator == username: + return None + logger.error("Failed to create chat session %s: %s", session_id, e) + return f"Failed to create session: {e}" + + return None + + async def chat_send(self): + post_data = await request.get_json(silent=True) or {} + effective_username, username_err = self._resolve_open_username( + post_data.get("username") + ) + if username_err: + return Response().error(username_err).__dict__ + if not effective_username: + return Response().error("Invalid username").__dict__ + + raw_session_id = post_data.get("session_id", post_data.get("conversation_id")) + session_id = str(raw_session_id).strip() if raw_session_id is not None else "" + if not session_id: + session_id = str(uuid4()) + post_data["session_id"] = session_id + ensure_session_err = await self._ensure_chat_session( + effective_username, + session_id, + ) + if ensure_session_err: + return Response().error(ensure_session_err).__dict__ + + config_id, resolve_err = self._resolve_chat_config_id(post_data) + if resolve_err: + return Response().error(resolve_err).__dict__ + + original_username = g.get("username", "guest") + g.username = effective_username + if config_id: + umo = f"webchat:FriendMessage:webchat!{effective_username}!{session_id}" + try: + if config_id == "default": + await self.core_lifecycle.umop_config_router.delete_route(umo) + else: + await self.core_lifecycle.umop_config_router.update_route( + umo, config_id + ) + except Exception as e: + logger.error( + "Failed to update chat config route for %s with %s: %s", + umo, + config_id, + e, + exc_info=True, + ) + return ( + Response() + .error(f"Failed to update chat config route: {e}") + .__dict__ + ) + try: + return await self.chat_route.chat(post_data=post_data) + finally: + g.username = original_username + + @staticmethod + def _extract_ws_api_key() -> str | None: + if key := websocket.args.get("api_key"): + return key.strip() + if key := websocket.args.get("key"): + return key.strip() + if key := websocket.headers.get("X-API-Key"): + return key.strip() + + auth_header = websocket.headers.get("Authorization", "").strip() + if auth_header.startswith("Bearer "): + return auth_header.removeprefix("Bearer ").strip() + if auth_header.startswith("ApiKey "): + return auth_header.removeprefix("ApiKey ").strip() + return None + + async def _authenticate_chat_ws_api_key(self) -> tuple[bool, str | None]: + raw_key = self._extract_ws_api_key() + if not raw_key: + return False, "Missing API key" + + key_hash = hashlib.pbkdf2_hmac( + "sha256", + raw_key.encode("utf-8"), + b"astrbot_api_key", + 100_000, + ).hex() + api_key = await self.db.get_active_api_key_by_hash(key_hash) + if not api_key: + return False, "Invalid API key" + + if isinstance(api_key.scopes, list): + scopes = api_key.scopes + else: + scopes = list(ALL_OPEN_API_SCOPES) + + if "*" not in scopes and "chat" not in scopes: + return False, "Insufficient API key scope" + + await self.db.touch_api_key(api_key.key_id) + return True, None + + async def _send_chat_ws_error(self, message: str, code: str) -> None: + await websocket.send_json( + { + "type": "error", + "code": code, + "data": message, + } + ) + + async def _update_session_config_route( + self, + *, + username: str, + session_id: str, + config_id: str | None, + ) -> str | None: + if not config_id: + return None + + umo = f"webchat:FriendMessage:webchat!{username}!{session_id}" + try: + if config_id == "default": + await self.core_lifecycle.umop_config_router.delete_route(umo) + else: + await self.core_lifecycle.umop_config_router.update_route( + umo, config_id + ) + except Exception as e: + logger.error( + "Failed to update chat config route for %s with %s: %s", + umo, + config_id, + e, + exc_info=True, + ) + return f"Failed to update chat config route: {e}" + return None + + async def _handle_chat_ws_send(self, post_data: dict) -> None: + effective_username, username_err = self._resolve_open_username( + post_data.get("username") + ) + if username_err or not effective_username: + await self._send_chat_ws_error( + username_err or "Invalid username", "BAD_USER" + ) + return + + message = post_data.get("message") + if message is None: + await self._send_chat_ws_error("Missing key: message", "INVALID_MESSAGE") + return + + raw_session_id = post_data.get("session_id", post_data.get("conversation_id")) + session_id = str(raw_session_id).strip() if raw_session_id is not None else "" + if not session_id: + session_id = str(uuid4()) + + ensure_session_err = await self._ensure_chat_session( + effective_username, + session_id, + ) + if ensure_session_err: + await self._send_chat_ws_error(ensure_session_err, "SESSION_ERROR") + return + + config_id, resolve_err = self._resolve_chat_config_id(post_data) + if resolve_err: + await self._send_chat_ws_error(resolve_err, "CONFIG_ERROR") + return + + config_err = await self._update_session_config_route( + username=effective_username, + session_id=session_id, + config_id=config_id, + ) + if config_err: + await self._send_chat_ws_error(config_err, "CONFIG_ERROR") + return + + message_parts = await self.chat_route._build_user_message_parts(message) + if not webchat_message_parts_have_content(message_parts): + await self._send_chat_ws_error( + "Message content is empty (reply only is not allowed)", + "INVALID_MESSAGE", + ) + return + + message_id = str(post_data.get("message_id") or uuid4()) + selected_provider = post_data.get("selected_provider") + selected_model = post_data.get("selected_model") + enable_streaming = post_data.get("enable_streaming", True) + + back_queue = webchat_queue_mgr.get_or_create_back_queue(message_id, session_id) + try: + chat_queue = webchat_queue_mgr.get_or_create_queue(session_id) + await chat_queue.put( + ( + effective_username, + session_id, + { + "message": message_parts, + "selected_provider": selected_provider, + "selected_model": selected_model, + "enable_streaming": enable_streaming, + "message_id": message_id, + }, + ) + ) + + message_parts_for_storage = strip_message_parts_path_fields(message_parts) + await self.chat_route.platform_history_mgr.insert( + platform_id="webchat", + user_id=session_id, + content={"type": "user", "message": message_parts_for_storage}, + sender_id=effective_username, + sender_name=effective_username, + ) + + await websocket.send_json( + { + "type": "session_id", + "data": None, + "session_id": session_id, + "message_id": message_id, + } + ) + + accumulated_parts = [] + accumulated_text = "" + accumulated_reasoning = "" + tool_calls = {} + agent_stats = {} + refs = {} + while True: + try: + result = await asyncio.wait_for(back_queue.get(), timeout=1) + except asyncio.TimeoutError: + continue + + if not result: + continue + + if "message_id" in result and result["message_id"] != message_id: + logger.warning("openapi ws stream message_id mismatch") + continue + + result_text = result.get("data", "") + msg_type = result.get("type") + streaming = result.get("streaming", False) + chain_type = result.get("chain_type") + + if chain_type == "agent_stats": + try: + stats_info = { + "type": "agent_stats", + "data": json.loads(result_text), + } + await websocket.send_json(stats_info) + agent_stats = stats_info["data"] + except Exception: + pass + continue + + await websocket.send_json(result) + + if msg_type == "plain": + if chain_type == "tool_call": + tool_call = json.loads(result_text) + tool_calls[tool_call.get("id")] = tool_call + if accumulated_text: + accumulated_parts.append( + {"type": "plain", "text": accumulated_text} + ) + accumulated_text = "" + elif chain_type == "tool_call_result": + tcr = json.loads(result_text) + tc_id = tcr.get("id") + if tc_id in tool_calls: + tool_calls[tc_id]["result"] = tcr.get("result") + tool_calls[tc_id]["finished_ts"] = tcr.get("ts") + accumulated_parts.append( + {"type": "tool_call", "tool_calls": [tool_calls[tc_id]]} + ) + tool_calls.pop(tc_id, None) + elif chain_type == "reasoning": + accumulated_reasoning += result_text + elif streaming: + accumulated_text += result_text + else: + accumulated_text = result_text + elif msg_type == "image": + filename = str(result_text).replace("[IMAGE]", "") + part = await self.chat_route._create_attachment_from_file( + filename, "image" + ) + if part: + accumulated_parts.append(part) + elif msg_type == "record": + filename = str(result_text).replace("[RECORD]", "") + part = await self.chat_route._create_attachment_from_file( + filename, "record" + ) + if part: + accumulated_parts.append(part) + elif msg_type == "file": + filename = str(result_text).replace("[FILE]", "") + part = await self.chat_route._create_attachment_from_file( + filename, "file" + ) + if part: + accumulated_parts.append(part) + elif msg_type == "video": + filename = str(result_text).replace("[VIDEO]", "") + part = await self.chat_route._create_attachment_from_file( + filename, "video" + ) + if part: + accumulated_parts.append(part) + + if msg_type == "end": + break + if (streaming and msg_type == "complete") or not streaming: + if chain_type in ("tool_call", "tool_call_result"): + continue + try: + refs = self.chat_route._extract_web_search_refs( + accumulated_text, + accumulated_parts, + ) + except Exception as e: + logger.exception( + f"Open API WS failed to extract web search refs: {e}", + exc_info=True, + ) + + saved_record = await self.chat_route._save_bot_message( + session_id, + accumulated_text, + accumulated_parts, + accumulated_reasoning, + agent_stats, + refs, + ) + if saved_record: + await websocket.send_json( + { + "type": "message_saved", + "data": { + "id": saved_record.id, + "created_at": saved_record.created_at.astimezone().isoformat(), + }, + "session_id": session_id, + } + ) + accumulated_parts = [] + accumulated_text = "" + accumulated_reasoning = "" + agent_stats = {} + refs = {} + except Exception as e: + logger.exception(f"Open API WS chat failed: {e}", exc_info=True) + await self._send_chat_ws_error( + f"Failed to process message: {e}", "PROCESSING_ERROR" + ) + finally: + webchat_queue_mgr.remove_back_queue(message_id) + + async def chat_ws(self) -> None: + authed, auth_err = await self._authenticate_chat_ws_api_key() + if not authed: + await self._send_chat_ws_error(auth_err or "Unauthorized", "UNAUTHORIZED") + await websocket.close(1008, auth_err or "Unauthorized") + return + + try: + while True: + message = await websocket.receive_json() + if not isinstance(message, dict): + await self._send_chat_ws_error( + "message must be an object", + "INVALID_MESSAGE", + ) + continue + + msg_type = message.get("t", "send") + if msg_type == "ping": + await websocket.send_json({"type": "pong"}) + continue + if msg_type != "send": + await self._send_chat_ws_error( + f"Unsupported message type: {msg_type}", + "INVALID_MESSAGE", + ) + continue + + await self._handle_chat_ws_send(message) + except Exception as e: + logger.debug("Open API WS connection closed: %s", e) + + async def upload_file(self): + return await self.chat_route.post_file() + + async def get_chat_sessions(self): + username, username_err = self._resolve_open_username( + request.args.get("username") + ) + if username_err: + return Response().error(username_err).__dict__ + + assert username is not None # for type checker + + try: + page = int(request.args.get("page", 1)) + page_size = int(request.args.get("page_size", 20)) + except ValueError: + return Response().error("page and page_size must be integers").__dict__ + + if page < 1: + page = 1 + if page_size < 1: + page_size = 1 + if page_size > 100: + page_size = 100 + + platform_id = request.args.get("platform_id") + + ( + paginated_sessions, + total, + ) = await self.db.get_platform_sessions_by_creator_paginated( + creator=username, + platform_id=platform_id, + page=page, + page_size=page_size, + exclude_project_sessions=True, + ) + + sessions_data = [] + for item in paginated_sessions: + session = item["session"] + sessions_data.append( + { + "session_id": session.session_id, + "platform_id": session.platform_id, + "creator": session.creator, + "display_name": session.display_name, + "is_group": session.is_group, + "created_at": session.created_at.astimezone().isoformat(), + "updated_at": session.updated_at.astimezone().isoformat(), + } + ) + + return ( + Response() + .ok( + data={ + "sessions": sessions_data, + "page": page, + "page_size": page_size, + "total": total, + } + ) + .__dict__ + ) + + async def get_chat_configs(self): + conf_list = self._get_chat_config_list() + return Response().ok(data={"configs": conf_list}).__dict__ + + async def _build_message_chain_from_payload( + self, + message_payload: str | list, + ): + return await build_message_chain_from_payload( + message_payload, + get_attachment_by_id=self.db.get_attachment_by_id, + strict=True, + ) + + async def send_message(self): + post_data = await request.json or {} + message_payload = post_data.get("message", {}) + umo = post_data.get("umo") + + if message_payload is None: + return Response().error("Missing key: message").__dict__ + if not umo: + return Response().error("Missing key: umo").__dict__ + + try: + session = MessageSesion.from_str(str(umo)) + except Exception as e: + return Response().error(f"Invalid umo: {e}").__dict__ + + platform_id = session.platform_name + platform_inst = next( + ( + inst + for inst in self.platform_manager.platform_insts + if inst.meta().id == platform_id + ), + None, + ) + if not platform_inst: + return ( + Response() + .error(f"Bot not found or not running for platform: {platform_id}") + .__dict__ + ) + + try: + message_chain = await self._build_message_chain_from_payload( + message_payload + ) + await platform_inst.send_by_session(session, message_chain) + return Response().ok().__dict__ + except ValueError as e: + return Response().error(str(e)).__dict__ + except Exception as e: + logger.error(f"Open API send_message failed: {e}", exc_info=True) + return Response().error(f"Failed to send message: {e}").__dict__ + + async def get_bots(self): + bot_ids = [] + for platform in self.core_lifecycle.astrbot_config.get("platform", []): + platform_id = platform.get("id") if isinstance(platform, dict) else None + if ( + isinstance(platform_id, str) + and platform_id + and platform_id not in bot_ids + ): + bot_ids.append(platform_id) + return Response().ok(data={"bot_ids": bot_ids}).__dict__ diff --git a/astrbot/dashboard/routes/platform.py b/astrbot/dashboard/routes/platform.py index 4d8fdddfe..874bc19db 100644 --- a/astrbot/dashboard/routes/platform.py +++ b/astrbot/dashboard/routes/platform.py @@ -26,7 +26,7 @@ def __init__( self._register_webhook_routes() - def _register_webhook_routes(self): + def _register_webhook_routes(self) -> None: """注册 webhook 路由""" # 统一 webhook 入口,支持 GET 和 POST self.app.add_url_rule( diff --git a/astrbot/dashboard/routes/plugin.py b/astrbot/dashboard/routes/plugin.py index b6160ff1e..a679cf8dc 100644 --- a/astrbot/dashboard/routes/plugin.py +++ b/astrbot/dashboard/routes/plugin.py @@ -19,7 +19,14 @@ from astrbot.core.star.filter.permission import PermissionTypeFilter from astrbot.core.star.filter.regex import RegexFilter from astrbot.core.star.star_handler import EventType, star_handlers_registry -from astrbot.core.star.star_manager import PluginManager +from astrbot.core.star.star_manager import ( + PluginManager, + PluginVersionIncompatibleError, +) +from astrbot.core.utils.astrbot_path import ( + get_astrbot_data_path, + get_astrbot_temp_path, +) from .route import Response, Route, RouteContext @@ -45,6 +52,7 @@ def __init__( super().__init__(context) self.routes = { "/plugin/get": ("GET", self.get_plugins), + "/plugin/check-compat": ("POST", self.check_plugin_compatibility), "/plugin/install": ("POST", self.install_plugin), "/plugin/install-upload": ("POST", self.install_plugin_upload), "/plugin/update": ("POST", self.update_plugin), @@ -53,11 +61,13 @@ def __init__( "/plugin/market_list": ("GET", self.get_online_plugins), "/plugin/off": ("POST", self.off_plugin), "/plugin/on": ("POST", self.on_plugin), + "/plugin/reload-failed": ("POST", self.reload_failed_plugins), "/plugin/reload": ("POST", self.reload_plugins), "/plugin/readme": ("GET", self.get_plugin_readme), "/plugin/changelog": ("GET", self.get_plugin_changelog), "/plugin/source/get": ("GET", self.get_custom_source), "/plugin/source/save": ("POST", self.save_custom_source), + "/plugin/source/get-failed-plugins": ("GET", self.get_failed_plugins), } self.core_lifecycle = core_lifecycle self.plugin_manager = plugin_manager @@ -70,10 +80,59 @@ def __init__( EventType.OnDecoratingResultEvent: "回复消息前", EventType.OnCallingFuncToolEvent: "函数工具", EventType.OnAfterMessageSentEvent: "发送消息后", + EventType.OnPluginErrorEvent: "插件报错时", } self._logo_cache = {} + async def check_plugin_compatibility(self): + try: + data = await request.get_json() + version_spec = data.get("astrbot_version", "") + is_valid, message = self.plugin_manager._validate_astrbot_version_specifier( + version_spec + ) + return ( + Response() + .ok( + { + "compatible": is_valid, + "message": message, + "astrbot_version": version_spec, + } + ) + .__dict__ + ) + except Exception as e: + return Response().error(str(e)).__dict__ + + async def reload_failed_plugins(self): + if DEMO_MODE: + return ( + Response() + .error("You are not permitted to do this operation in demo mode") + .__dict__ + ) + try: + data = await request.get_json() + dir_name = data.get("dir_name") # 这里拿的是目录名,不是插件名 + + if not dir_name: + return Response().error("缺少插件目录名").__dict__ + + # 调用 star_manager.py 中的函数 + # 注意:传入的是目录名 + success, err = await self.plugin_manager.reload_failed_plugin(dir_name) + + if success: + return Response().ok(None, f"插件 {dir_name} 重载成功。").__dict__ + else: + return Response().error(f"重载失败: {err}").__dict__ + + except Exception as e: + logger.error(f"/api/plugin/reload-failed: {traceback.format_exc()}") + return Response().error(str(e)).__dict__ + async def reload_plugins(self): if DEMO_MODE: return ( @@ -87,7 +146,7 @@ async def reload_plugins(self): try: success, message = await self.plugin_manager.reload(plugin_name) if not success: - return Response().error(message).__dict__ + return Response().error(message or "插件重载失败").__dict__ return Response().ok(None, "重载成功。").__dict__ except Exception as e: logger.error(f"/api/plugin/reload: {traceback.format_exc()}") @@ -165,10 +224,11 @@ async def get_online_plugins(self): def _build_registry_source(self, custom_url: str | None) -> RegistrySource: """构建注册表源信息""" + data_dir = get_astrbot_data_path() if custom_url: # 对自定义URL生成一个安全的文件名 url_hash = hashlib.md5(custom_url.encode()).hexdigest()[:8] - cache_file = f"data/plugins_custom_{url_hash}.json" + cache_file = os.path.join(data_dir, f"plugins_custom_{url_hash}.json") # 更安全的后缀处理方式 if custom_url.endswith(".json"): @@ -178,7 +238,7 @@ def _build_registry_source(self, custom_url: str | None) -> RegistrySource: urls = [custom_url] else: - cache_file = "data/plugins.json" + cache_file = os.path.join(data_dir, "plugins.json") md5_url = "https://api.soulter.top/astrbot/plugins-md5" urls = [ "https://api.soulter.top/astrbot/plugins", @@ -261,7 +321,7 @@ def _load_plugin_cache(self, cache_file: str): logger.warning(f"加载插件市场缓存失败: {e}") return None - def _save_plugin_cache(self, cache_file: str, data, md5: str | None = None): + def _save_plugin_cache(self, cache_file: str, data, md5: str | None = None) -> None: """保存插件市场数据到本地缓存""" try: # 确保目录存在 @@ -314,6 +374,8 @@ async def get_plugins(self): ), "display_name": plugin.display_name, "logo": f"/api/file/{logo_url}" if logo_url else None, + "support_platforms": plugin.support_platforms, + "astrbot_version": plugin.astrbot_version, } # 检查是否为全空的幽灵插件 if not any( @@ -333,6 +395,10 @@ async def get_plugins(self): .__dict__ ) + async def get_failed_plugins(self): + """专门获取加载失败的插件列表(字典格式)""" + return Response().ok(self.plugin_manager.failed_plugin_dict).__dict__ + async def get_plugin_handlers_info(self, handler_full_names: list[str]): """解析插件行为""" handlers = [] @@ -404,6 +470,7 @@ async def install_plugin(self): post_data = await request.get_json() repo_url = post_data["url"] + ignore_version_check = bool(post_data.get("ignore_version_check", False)) proxy: str = post_data.get("proxy", None) if proxy: @@ -411,10 +478,23 @@ async def install_plugin(self): try: logger.info(f"正在安装插件 {repo_url}") - plugin_info = await self.plugin_manager.install_plugin(repo_url, proxy) + plugin_info = await self.plugin_manager.install_plugin( + repo_url, + proxy, + ignore_version_check=ignore_version_check, + ) # self.core_lifecycle.restart() logger.info(f"安装插件 {repo_url} 成功。") return Response().ok(plugin_info, "安装成功。").__dict__ + except PluginVersionIncompatibleError as e: + return { + "status": "warning", + "message": str(e), + "data": { + "warning_type": "astrbot_version_incompatible", + "can_ignore": True, + }, + } except Exception as e: logger.error(traceback.format_exc()) return Response().error(str(e)).__dict__ @@ -430,13 +510,32 @@ async def install_plugin_upload(self): try: file = await request.files file = file["file"] + form_data = await request.form + ignore_version_check = ( + str(form_data.get("ignore_version_check", "false")).lower() == "true" + ) logger.info(f"正在安装用户上传的插件 {file.filename}") - file_path = f"data/temp/{file.filename}" + file_path = os.path.join( + get_astrbot_temp_path(), + f"plugin_upload_{file.filename}", + ) await file.save(file_path) - plugin_info = await self.plugin_manager.install_plugin_from_file(file_path) + plugin_info = await self.plugin_manager.install_plugin_from_file( + file_path, + ignore_version_check=ignore_version_check, + ) # self.core_lifecycle.restart() logger.info(f"安装插件 {file.filename} 成功") return Response().ok(plugin_info, "安装成功。").__dict__ + except PluginVersionIncompatibleError as e: + return { + "status": "warning", + "message": str(e), + "data": { + "warning_type": "astrbot_version_incompatible", + "can_ignore": True, + }, + } except Exception as e: logger.error(traceback.format_exc()) return Response().error(str(e)).__dict__ @@ -599,10 +698,16 @@ async def get_plugin_readme(self): logger.warning(f"插件 {plugin_name} 目录不存在") return Response().error(f"插件 {plugin_name} 目录不存在").__dict__ - plugin_dir = os.path.join( - self.plugin_manager.plugin_store_path, - plugin_obj.root_dir_name or "", - ) + if plugin_obj.reserved: + plugin_dir = os.path.join( + self.plugin_manager.reserved_plugin_path, + plugin_obj.root_dir_name, + ) + else: + plugin_dir = os.path.join( + self.plugin_manager.plugin_store_path, + plugin_obj.root_dir_name, + ) if not os.path.isdir(plugin_dir): logger.warning(f"无法找到插件目录: {plugin_dir}") @@ -636,6 +741,7 @@ async def get_plugin_changelog(self): logger.debug(f"正在获取插件 {plugin_name} 的更新日志") if not plugin_name: + logger.warning("插件名称为空") return Response().error("插件名称不能为空").__dict__ # 查找插件 @@ -646,15 +752,27 @@ async def get_plugin_changelog(self): break if not plugin_obj: + logger.warning(f"插件 {plugin_name} 不存在") return Response().error(f"插件 {plugin_name} 不存在").__dict__ if not plugin_obj.root_dir_name: + logger.warning(f"插件 {plugin_name} 目录不存在") return Response().error(f"插件 {plugin_name} 目录不存在").__dict__ - plugin_dir = os.path.join( - self.plugin_manager.plugin_store_path, - plugin_obj.root_dir_name, - ) + if plugin_obj.reserved: + plugin_dir = os.path.join( + self.plugin_manager.reserved_plugin_path, + plugin_obj.root_dir_name, + ) + else: + plugin_dir = os.path.join( + self.plugin_manager.plugin_store_path, + plugin_obj.root_dir_name, + ) + + if not os.path.isdir(plugin_dir): + logger.warning(f"无法找到插件目录: {plugin_dir}") + return Response().error(f"无法找到插件 {plugin_name} 的目录").__dict__ # 尝试多种可能的文件名 changelog_names = ["CHANGELOG.md", "changelog.md", "CHANGELOG", "changelog"] @@ -674,6 +792,7 @@ async def get_plugin_changelog(self): return Response().error(f"读取更新日志失败: {e!s}").__dict__ # 没有找到 changelog 文件,返回 ok 但 content 为 null + logger.warning(f"插件 {plugin_name} 没有更新日志文件") return Response().ok({"content": None}, "该插件没有更新日志文件").__dict__ async def get_custom_source(self): diff --git a/astrbot/dashboard/routes/route.py b/astrbot/dashboard/routes/route.py index 7af6fcf1a..53c623443 100644 --- a/astrbot/dashboard/routes/route.py +++ b/astrbot/dashboard/routes/route.py @@ -1,4 +1,4 @@ -from dataclasses import asdict, dataclass +from dataclasses import dataclass from quart import Quart @@ -14,12 +14,12 @@ class RouteContext: class Route: routes: list | dict - def __init__(self, context: RouteContext): + def __init__(self, context: RouteContext) -> None: self.app = context.app self.config = context.config - def register_routes(self): - def _add_rule(path, method, func): + def register_routes(self) -> None: + def _add_rule(path, method, func) -> None: # 统一添加 /api 前缀 full_path = f"/api{path}" self.app.add_url_rule(full_path, view_func=func, methods=[method]) @@ -57,7 +57,3 @@ def ok(self, data: dict | list | None = None, message: str | None = None): self.data = data self.message = message return self - - def to_json(self): - # Return a plain dict so callers can safely wrap with jsonify() - return asdict(self) diff --git a/astrbot/dashboard/routes/stat.py b/astrbot/dashboard/routes/stat.py index 054eec995..532238ac7 100644 --- a/astrbot/dashboard/routes/stat.py +++ b/astrbot/dashboard/routes/stat.py @@ -4,6 +4,7 @@ import time import traceback from functools import cmp_to_key +from pathlib import Path import aiohttp import psutil @@ -37,6 +38,7 @@ def __init__( "/stat/test-ghproxy-connection": ("POST", self.test_ghproxy_connection), "/stat/changelog": ("GET", self.get_changelog), "/stat/changelog/list": ("GET", self.list_changelog_versions), + "/stat/first-notice": ("GET", self.get_first_notice), } self.db_helper = db_helper self.register_routes() @@ -279,3 +281,40 @@ async def list_changelog_versions(self): except Exception as e: logger.error(traceback.format_exc()) return Response().error(f"Error: {e!s}").__dict__ + + async def get_first_notice(self): + """读取项目根目录 FIRST_NOTICE.md 内容。""" + try: + locale = (request.args.get("locale") or "").strip() + if not re.match(r"^[A-Za-z0-9_-]*$", locale): + locale = "" + + base_path = Path(get_astrbot_path()) + candidates: list[Path] = [] + + if locale: + candidates.append(base_path / f"FIRST_NOTICE.{locale}.md") + if locale.lower().startswith("zh"): + candidates.append(base_path / "FIRST_NOTICE.md") + candidates.append(base_path / "FIRST_NOTICE.zh-CN.md") + elif locale.lower().startswith("en"): + candidates.append(base_path / "FIRST_NOTICE.en-US.md") + + candidates.extend( + [ + base_path / "FIRST_NOTICE.md", + base_path / "FIRST_NOTICE.en-US.md", + ], + ) + + for notice_path in candidates: + if not notice_path.is_file(): + continue + content = notice_path.read_text(encoding="utf-8") + if content.strip(): + return Response().ok({"content": content}).__dict__ + + return Response().ok({"content": None}).__dict__ + except Exception as e: + logger.error(traceback.format_exc()) + return Response().error(f"Error: {e!s}").__dict__ diff --git a/astrbot/dashboard/routes/static_file.py b/astrbot/dashboard/routes/static_file.py index 7e7592e9b..e056b6c5a 100644 --- a/astrbot/dashboard/routes/static_file.py +++ b/astrbot/dashboard/routes/static_file.py @@ -5,9 +5,6 @@ class StaticFileRoute(Route): def __init__(self, context: RouteContext) -> None: super().__init__(context) - if "index" in self.app.view_functions: - return - index_ = [ "/", "/auth/login", @@ -33,7 +30,7 @@ def __init__(self, context: RouteContext) -> None: self.app.add_url_rule(i, view_func=self.index) @self.app.errorhandler(404) - async def page_not_found(e): + async def page_not_found(e) -> str: return "404 Not found。如果你初次使用打开面板发现 404, 请参考文档: https://astrbot.app/faq.html。如果你正在测试回调地址可达性,显示这段文字说明测试成功了。" async def index(self): diff --git a/astrbot/dashboard/routes/t2i.py b/astrbot/dashboard/routes/t2i.py index db70a8820..8d06826be 100644 --- a/astrbot/dashboard/routes/t2i.py +++ b/astrbot/dashboard/routes/t2i.py @@ -12,7 +12,9 @@ class T2iRoute(Route): - def __init__(self, context: RouteContext, core_lifecycle: AstrBotCoreLifecycle): + def __init__( + self, context: RouteContext, core_lifecycle: AstrBotCoreLifecycle + ) -> None: super().__init__(context) self.core_lifecycle = core_lifecycle self.config = core_lifecycle.astrbot_config diff --git a/astrbot/dashboard/server.py b/astrbot/dashboard/server.py index b3d381873..a9650cd06 100644 --- a/astrbot/dashboard/server.py +++ b/astrbot/dashboard/server.py @@ -1,11 +1,10 @@ import asyncio +import hashlib import logging import os -import platform import socket -from collections.abc import Callable -from ipaddress import IPv4Address, IPv6Address, ip_address -from typing import cast +from pathlib import Path +from typing import Protocol, cast import jwt import psutil @@ -14,7 +13,6 @@ from hypercorn.config import Config as HyperConfig from quart import Quart, g, jsonify, request from quart.logging import default_handler -from quart_cors import cors from astrbot.core import logger from astrbot.core.config.default import VERSION @@ -23,48 +21,31 @@ from astrbot.core.utils.astrbot_path import get_astrbot_data_path from astrbot.core.utils.io import get_local_ip_addresses -from .routes import ( - AuthRoute, - BackupRoute, - ChatRoute, - ChatUIProjectRoute, - CommandRoute, - ConfigRoute, - ConversationRoute, - CronRoute, - FileRoute, - KnowledgeBaseRoute, - LiveChatRoute, - LogRoute, - PersonaRoute, - PlatformRoute, - PluginRoute, - Response, - RouteContext, - SessionManagementRoute, - SkillsRoute, - StaticFileRoute, - StatRoute, - SubAgentRoute, - T2iRoute, - ToolsRoute, - UpdateRoute, -) +from .routes import * +from .routes.api_key import ALL_OPEN_API_SCOPES +from .routes.backup import BackupRoute +from .routes.live_chat import LiveChatRoute +from .routes.platform import PlatformRoute +from .routes.route import Response, RouteContext +from .routes.session_management import SessionManagementRoute +from .routes.subagent import SubAgentRoute +from .routes.t2i import T2iRoute + + +class _AddrWithPort(Protocol): + port: int + APP: Quart -class AstrBotDashboard: - """AstrBot Web Dashboard""" +def _parse_env_bool(value: str | None, default: bool) -> bool: + if value is None: + return default + return value.strip().lower() in {"1", "true", "yes", "on"} - ALLOWED_ENDPOINT_PREFIXES = ( - "/api/auth/login", - "/api/file", - "/api/platform/webhook", - "/api/stat/start-time", - "/api/backup/download", - ) +class AstrBotDashboard: def __init__( self, core_lifecycle: AstrBotCoreLifecycle, @@ -74,107 +55,68 @@ def __init__( ) -> None: self.core_lifecycle = core_lifecycle self.config = core_lifecycle.astrbot_config - self.shutdown_event = shutdown_event - - self.enable_webui = self._check_webui_enabled() - - self._init_paths(webui_dir) - self._init_app() - self.context = RouteContext(self.config, self.app) + self.db = db - self._init_routes(db) - self._init_plugin_route_index() - self._init_jwt_secret() - - # ------------------------------------------------------------------ - # 初始化阶段 - # ------------------------------------------------------------------ - - def _check_webui_enabled(self) -> bool: - cfg = self.config.get("dashboard", {}) - _env = os.environ.get("DASHBOARD_ENABLE") - if _env is not None: - return _env.lower() in ("true", "1", "yes") - return cfg.get("enable", True) - - def _init_paths(self, webui_dir: str | None): + # 参数指定webui目录 if webui_dir and os.path.exists(webui_dir): self.data_path = os.path.abspath(webui_dir) else: self.data_path = os.path.abspath( - os.path.join(get_astrbot_data_path(), "dist") + os.path.join(get_astrbot_data_path(), "dist"), ) - def _init_app(self): - if self.enable_webui: - self.app = Quart( - "dashboard", - static_folder=self.data_path, - static_url_path="/", - ) - else: - # 禁用 WebUI 时不提供静态文件服务 - self.app = Quart("dashboard") - - # 添加根路径提示 - @self.app.route("/") - async def index(): - return jsonify( - { - "message": "AstrBot WebUI has been detached.", - "status": "API Server Running", - "version": VERSION, - } - ) - - dashboard_cfg = self.config.get("dashboard", {}) - cors_cfg = dashboard_cfg.get("cors", {}) - allow_origin = cors_cfg.get("allow_origins", "*") - allow_methods = cors_cfg.get("allow_methods", "*") - allow_headers = cors_cfg.get("allow_headers", "*") - - self.app = cors( - self.app, - allow_origin=allow_origin, - allow_methods=allow_methods, - allow_headers=allow_headers, - ) + self.app = Quart("dashboard", static_folder=self.data_path, static_url_path="/") + APP = self.app # noqa self.app.config["MAX_CONTENT_LENGTH"] = ( 128 * 1024 * 1024 ) # 将 Flask 允许的最大上传文件体大小设置为 128 MB cast(DefaultJSONProvider, self.app.json).sort_keys = False - self.app.before_request(self.auth_middleware) + # token 用于验证请求 logging.getLogger(self.app.name).removeHandler(default_handler) - - def _init_routes(self, db: BaseDatabase): - UpdateRoute( - self.context, self.core_lifecycle.astrbot_updator, self.core_lifecycle + self.context = RouteContext(self.config, self.app) + self.ur = UpdateRoute( + self.context, + core_lifecycle.astrbot_updator, + core_lifecycle, + ) + self.sr = StatRoute(self.context, db, core_lifecycle) + self.pr = PluginRoute( + self.context, + core_lifecycle, + core_lifecycle.plugin_manager, + ) + self.command_route = CommandRoute(self.context) + self.cr = ConfigRoute(self.context, core_lifecycle) + self.lr = LogRoute(self.context, core_lifecycle.log_broker) + self.sfr = StaticFileRoute(self.context) + self.ar = AuthRoute(self.context) + self.api_key_route = ApiKeyRoute(self.context, db) + self.chat_route = ChatRoute(self.context, db, core_lifecycle) + self.open_api_route = OpenApiRoute( + self.context, + db, + core_lifecycle, + self.chat_route, ) - StatRoute(self.context, db, self.core_lifecycle) - PluginRoute( - self.context, self.core_lifecycle, self.core_lifecycle.plugin_manager + self.chatui_project_route = ChatUIProjectRoute(self.context, db) + self.tools_root = ToolsRoute(self.context, core_lifecycle) + self.subagent_route = SubAgentRoute(self.context, core_lifecycle) + self.skills_route = SkillsRoute(self.context, core_lifecycle) + self.conversation_route = ConversationRoute(self.context, db, core_lifecycle) + self.file_route = FileRoute(self.context) + self.session_management_route = SessionManagementRoute( + self.context, + db, + core_lifecycle, ) - CommandRoute(self.context) - ConfigRoute(self.context, self.core_lifecycle) - LogRoute(self.context, self.core_lifecycle.log_broker) - StaticFileRoute(self.context) - AuthRoute(self.context) - ChatRoute(self.context, db, self.core_lifecycle) - ChatUIProjectRoute(self.context, db) - ToolsRoute(self.context, self.core_lifecycle) - SubAgentRoute(self.context, self.core_lifecycle) - SkillsRoute(self.context, self.core_lifecycle) - ConversationRoute(self.context, db, self.core_lifecycle) - FileRoute(self.context) - SessionManagementRoute(self.context, db, self.core_lifecycle) - PersonaRoute(self.context, db, self.core_lifecycle) - CronRoute(self.context, self.core_lifecycle) - T2iRoute(self.context, self.core_lifecycle) - KnowledgeBaseRoute(self.context, self.core_lifecycle) - PlatformRoute(self.context, self.core_lifecycle) - BackupRoute(self.context, db, self.core_lifecycle) - LiveChatRoute(self.context, db, self.core_lifecycle) + self.persona_route = PersonaRoute(self.context, db, core_lifecycle) + self.cron_route = CronRoute(self.context, core_lifecycle) + self.t2i_route = T2iRoute(self.context, core_lifecycle) + self.kb_route = KnowledgeBaseRoute(self.context, core_lifecycle) + self.platform_route = PlatformRoute(self.context, core_lifecycle) + self.backup_route = BackupRoute(self.context, db, core_lifecycle) + self.live_chat_route = LiveChatRoute(self.context, db, core_lifecycle) self.app.add_url_rule( "/api/plug/", @@ -182,194 +124,279 @@ def _init_routes(self, db: BaseDatabase): methods=["GET", "POST"], ) - def _init_plugin_route_index(self): - """将插件路由索引,避免 O(n) 查找""" - self._plugin_route_map: dict[tuple[str, str], Callable] = {} - - for ( - route, - handler, - methods, - _, - ) in self.core_lifecycle.star_context.registered_web_apis: - for method in methods: - self._plugin_route_map[(route, method)] = handler - - def _init_jwt_secret(self): - dashboard_cfg = self.config.setdefault("dashboard", {}) - if not dashboard_cfg.get("jwt_secret"): - dashboard_cfg["jwt_secret"] = os.urandom(32).hex() - self.config.save_config() - logger.info("Initialized random JWT secret for dashboard.") - self._jwt_secret = dashboard_cfg["jwt_secret"] + self.shutdown_event = shutdown_event + + self._init_jwt_secret() - # ------------------------------------------------------------------ - # Middleware中间件 - # ------------------------------------------------------------------ + async def srv_plug_route(self, subpath, *args, **kwargs): + """插件路由""" + registered_web_apis = self.core_lifecycle.star_context.registered_web_apis + for api in registered_web_apis: + route, view_handler, methods, _ = api + if route == f"/{subpath}" and request.method in methods: + return await view_handler(*args, **kwargs) + return jsonify(Response().error("未找到该路由").__dict__) async def auth_middleware(self): - # 放行CORS预检请求 - if request.method == "OPTIONS": - return None if not request.path.startswith("/api"): return None - - if any(request.path.startswith(p) for p in self.ALLOWED_ENDPOINT_PREFIXES): + if request.path.startswith("/api/v1"): + raw_key = self._extract_raw_api_key() + if not raw_key: + r = jsonify(Response().error("Missing API key").__dict__) + r.status_code = 401 + return r + key_hash = hashlib.pbkdf2_hmac( + "sha256", + raw_key.encode("utf-8"), + b"astrbot_api_key", + 100_000, + ).hex() + api_key = await self.db.get_active_api_key_by_hash(key_hash) + if not api_key: + r = jsonify(Response().error("Invalid API key").__dict__) + r.status_code = 401 + return r + + if isinstance(api_key.scopes, list): + scopes = api_key.scopes + else: + scopes = list(ALL_OPEN_API_SCOPES) + required_scope = self._get_required_open_api_scope(request.path) + if required_scope and "*" not in scopes and required_scope not in scopes: + r = jsonify(Response().error("Insufficient API key scope").__dict__) + r.status_code = 403 + return r + + g.api_key_id = api_key.key_id + g.api_key_scopes = scopes + g.username = f"api_key:{api_key.key_id}" + await self.db.touch_api_key(api_key.key_id) return None + allowed_endpoints = [ + "/api/auth/login", + "/api/file", + "/api/platform/webhook", + "/api/stat/start-time", + "/api/backup/download", # 备份下载使用 URL 参数传递 token + ] + if any(request.path.startswith(prefix) for prefix in allowed_endpoints): + return None + # 声明 JWT token = request.headers.get("Authorization") if not token: - return self._unauthorized("未授权") - + r = jsonify(Response().error("未授权").__dict__) + r.status_code = 401 + return r + token = token.removeprefix("Bearer ") try: - payload = jwt.decode( - token.removeprefix("Bearer "), - self._jwt_secret, - algorithms=["HS256"], - options={"require": ["username"]}, - ) + payload = jwt.decode(token, self._jwt_secret, algorithms=["HS256"]) g.username = payload["username"] except jwt.ExpiredSignatureError: - return self._unauthorized("Token 过期") - except jwt.PyJWTError: - return self._unauthorized("Token 无效") + r = jsonify(Response().error("Token 过期").__dict__) + r.status_code = 401 + return r + except jwt.InvalidTokenError: + r = jsonify(Response().error("Token 无效").__dict__) + r.status_code = 401 + return r @staticmethod - def _unauthorized(msg: str): - r = jsonify(Response().error(msg).to_json()) - r.status_code = 401 - return r - - # ------------------------------------------------------------------ - # 插件路由 - # ------------------------------------------------------------------ + def _extract_raw_api_key() -> str | None: + if key := request.args.get("api_key"): + return key.strip() + if key := request.args.get("key"): + return key.strip() + if key := request.headers.get("X-API-Key"): + return key.strip() + auth_header = request.headers.get("Authorization", "").strip() + if auth_header.startswith("Bearer "): + return auth_header.removeprefix("Bearer ").strip() + if auth_header.startswith("ApiKey "): + return auth_header.removeprefix("ApiKey ").strip() + return None - async def srv_plug_route(self, subpath: str, *args, **kwargs): - handler = self._plugin_route_map.get((f"/{subpath}", request.method)) - if not handler: - return jsonify(Response().error("未找到该路由").to_json()) - - try: - return await handler(*args, **kwargs) - except Exception: - logger.exception("插件 Web API 执行异常") - return jsonify(Response().error("插件执行失败").to_json()) - - # ------------------------------------------------------------------ - # 网络 / 端口 - # ------------------------------------------------------------------ - - def check_port_in_use(self, host: str, port: int) -> bool: + @staticmethod + def _get_required_open_api_scope(path: str) -> str | None: + scope_map = { + "/api/v1/chat": "chat", + "/api/v1/chat/ws": "chat", + "/api/v1/chat/sessions": "chat", + "/api/v1/configs": "config", + "/api/v1/file": "file", + "/api/v1/im/message": "im", + "/api/v1/im/bots": "im", + } + return scope_map.get(path) + + def check_port_in_use(self, port: int) -> bool: + """跨平台检测端口是否被占用""" try: - family = socket.AF_INET6 if ":" in host else socket.AF_INET - with socket.socket(family, socket.SOCK_STREAM) as sock: - sock.settimeout(2) - return sock.connect_ex((host, port)) == 0 - except Exception: + # 创建 IPv4 TCP Socket + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + # 设置超时时间 + sock.settimeout(2) + result = sock.connect_ex(("127.0.0.1", port)) + sock.close() + # result 为 0 表示端口被占用 + return result == 0 + except Exception as e: + logger.warning(f"检查端口 {port} 时发生错误: {e!s}") + # 如果出现异常,保守起见认为端口可能被占用 return True - @staticmethod - def get_process_using_port(port: int) -> str: + def get_process_using_port(self, port: int) -> str: + """获取占用端口的进程详细信息""" try: - for conn in psutil.net_connections(kind="all"): - if conn.laddr and conn.laddr.port == port and conn.pid: - p = psutil.Process(conn.pid) - return "\n ".join( - [ - f"进程名: {p.name()}", - f"PID: {p.pid}", - f"执行路径: {p.exe()}", - f"工作目录: {p.cwd()}", - f"启动命令: {' '.join(p.cmdline())}", + for conn in psutil.net_connections(kind="inet"): + if cast(_AddrWithPort, conn.laddr).port == port: + try: + process = psutil.Process(conn.pid) + # 获取详细信息 + proc_info = [ + f"进程名: {process.name()}", + f"PID: {process.pid}", + f"执行路径: {process.exe()}", + f"工作目录: {process.cwd()}", + f"启动命令: {' '.join(process.cmdline())}", ] - ) + return "\n ".join(proc_info) + except (psutil.NoSuchProcess, psutil.AccessDenied) as e: + return f"无法获取进程详细信息(可能需要管理员权限): {e!s}" return "未找到占用进程" except Exception as e: return f"获取进程信息失败: {e!s}" - # ------------------------------------------------------------------ - # 启动 - # ------------------------------------------------------------------ - - def run(self) -> None: - cfg = self.config.get("dashboard", {}) - _port: str = os.environ.get("DASHBOARD_PORT") or cfg.get("port", 6185) - port: int = int(_port) - _host = os.environ.get("DASHBOARD_HOST") or cfg.get("host", "::") - host: str = _host.strip("[]") + def _init_jwt_secret(self) -> None: + if not self.config.get("dashboard", {}).get("jwt_secret", None): + # 如果没有设置 JWT 密钥,则生成一个新的密钥 + jwt_secret = os.urandom(32).hex() + self.config["dashboard"]["jwt_secret"] = jwt_secret + self.config.save_config() + logger.info("Initialized random JWT secret for dashboard.") + self._jwt_secret = self.config["dashboard"]["jwt_secret"] + + def run(self): + ip_addr = [] + dashboard_config = self.core_lifecycle.astrbot_config.get("dashboard", {}) + port = ( + os.environ.get("DASHBOARD_PORT") + or os.environ.get("ASTRBOT_DASHBOARD_PORT") + or dashboard_config.get("port", 6185) + ) + host = ( + os.environ.get("DASHBOARD_HOST") + or os.environ.get("ASTRBOT_DASHBOARD_HOST") + or dashboard_config.get("host", "0.0.0.0") + ) + enable = dashboard_config.get("enable", True) + ssl_config = dashboard_config.get("ssl", {}) + if not isinstance(ssl_config, dict): + ssl_config = {} + ssl_enable = _parse_env_bool( + os.environ.get("DASHBOARD_SSL_ENABLE") + or os.environ.get("ASTRBOT_DASHBOARD_SSL_ENABLE"), + bool(ssl_config.get("enable", False)), + ) + scheme = "https" if ssl_enable else "http" - display_host = f"[{host}]" if ":" in host else host + if not enable: + logger.info("WebUI 已被禁用") + return None - if self.enable_webui: + logger.info(f"正在启动 WebUI, 监听地址: {scheme}://{host}:{port}") + if host == "0.0.0.0": logger.info( - "正在启动 WebUI + API, 监听地址: http://%s:%s", - display_host, - port, + "提示: WebUI 将监听所有网络接口,请注意安全。(可在 data/cmd_config.json 中配置 dashboard.host 以修改 host)", ) - else: - logger.info( - "正在启动 API Server (WebUI 已分离), 监听地址: http://%s:%s", - display_host, - port, + + if host not in ["localhost", "127.0.0.1"]: + try: + ip_addr = get_local_ip_addresses() + except Exception as _: + pass + if isinstance(port, str): + port = int(port) + + if self.check_port_in_use(port): + process_info = self.get_process_using_port(port) + logger.error( + f"错误:端口 {port} 已被占用\n" + f"占用信息: \n {process_info}\n" + f"请确保:\n" + f"1. 没有其他 AstrBot 实例正在运行\n" + f"2. 端口 {port} 没有被其他程序占用\n" + f"3. 如需使用其他端口,请修改配置文件", ) - check_hosts = {host} - if host not in ("127.0.0.1", "localhost", "::1"): - check_hosts.add("127.0.0.1") - for check_host in check_hosts: - if self.check_port_in_use(check_host, port): - info = self.get_process_using_port(port) - raise RuntimeError(f"端口 {port} 已被占用\n{info}") + raise Exception(f"端口 {port} 已被占用") + + parts = [f"\n ✨✨✨\n AstrBot v{VERSION} WebUI 已启动,可访问\n\n"] + parts.append(f" ➜ 本地: {scheme}://localhost:{port}\n") + for ip in ip_addr: + parts.append(f" ➜ 网络: {scheme}://{ip}:{port}\n") + parts.append(" ➜ 默认用户名和密码: astrbot\n ✨✨✨\n") + display = "".join(parts) - if self.enable_webui: - self._print_access_urls(host, port) + if not ip_addr: + display += ( + "可在 data/cmd_config.json 中配置 dashboard.host 以便远程访问。\n" + ) + logger.info(display) + + # 配置 Hypercorn config = HyperConfig() - binds: list[str] = [self._build_bind(host, port)] - # 参考:https://github.com/pgjones/hypercorn/issues/85 - if host == "::" and platform.system() in ("Windows", "Darwin"): - binds.append(self._build_bind("0.0.0.0", port)) - config.bind = binds + config.bind = [f"{host}:{port}"] + if ssl_enable: + cert_file = ( + os.environ.get("DASHBOARD_SSL_CERT") + or os.environ.get("ASTRBOT_DASHBOARD_SSL_CERT") + or ssl_config.get("cert_file", "") + ) + key_file = ( + os.environ.get("DASHBOARD_SSL_KEY") + or os.environ.get("ASTRBOT_DASHBOARD_SSL_KEY") + or ssl_config.get("key_file", "") + ) + ca_certs = ( + os.environ.get("DASHBOARD_SSL_CA_CERTS") + or os.environ.get("ASTRBOT_DASHBOARD_SSL_CA_CERTS") + or ssl_config.get("ca_certs", "") + ) - if cfg.get("disable_access_log", True): + cert_path = Path(cert_file).expanduser() + key_path = Path(key_file).expanduser() + if not cert_file or not key_file: + raise ValueError( + "dashboard.ssl.enable 为 true 时,必须配置 cert_file 和 key_file。", + ) + if not cert_path.is_file(): + raise ValueError(f"SSL 证书文件不存在: {cert_path}") + if not key_path.is_file(): + raise ValueError(f"SSL 私钥文件不存在: {key_path}") + + config.certfile = str(cert_path.resolve()) + config.keyfile = str(key_path.resolve()) + + if ca_certs: + ca_path = Path(ca_certs).expanduser() + if not ca_path.is_file(): + raise ValueError(f"SSL CA 证书文件不存在: {ca_path}") + config.ca_certs = str(ca_path.resolve()) + + # 根据配置决定是否禁用访问日志 + disable_access_log = dashboard_config.get("disable_access_log", True) + if disable_access_log: config.accesslog = None else: + # 启用访问日志,使用简洁格式 config.accesslog = "-" config.access_log_format = "%(h)s %(r)s %(s)s %(b)s %(D)s" - return asyncio.run( - serve(self.app, config, shutdown_trigger=self.shutdown_trigger) - ) - - @staticmethod - def _build_bind(host: str, port: int) -> str: - try: - ip: IPv4Address | IPv6Address = ip_address(host) - return f"[{ip}]:{port}" if ip.version == 6 else f"{ip}:{port}" - except ValueError: - return f"{host}:{port}" - - def _print_access_urls(self, host: str, port: int) -> None: - local_ips: list[IPv4Address | IPv6Address] = get_local_ip_addresses() - - parts = [f"\n ✨✨✨\n AstrBot v{VERSION} WebUI 已启动\n\n"] - - parts.append(f" ➜ 本地: http://localhost:{port}\n") - - if host in ("::", "0.0.0.0"): - for ip in local_ips: - if ip.is_loopback: - continue - - if ip.version == 6: - display_url = f"http://[{ip}]:{port}" - else: - display_url = f"http://{ip}:{port}" - - parts.append(f" ➜ 网络: {display_url}\n") - - parts.append(" ➜ 默认用户名和密码: astrbot\n ✨✨✨\n") - logger.info("".join(parts)) + return serve(self.app, config, shutdown_trigger=self.shutdown_trigger) async def shutdown_trigger(self) -> None: await self.shutdown_event.wait() diff --git a/astrbot/dashboard/utils.py b/astrbot/dashboard/utils.py index b81faad06..3a0ee5bdc 100644 --- a/astrbot/dashboard/utils.py +++ b/astrbot/dashboard/utils.py @@ -1,5 +1,4 @@ import base64 -import os import traceback from io import BytesIO @@ -51,14 +50,14 @@ async def generate_tsne_visualization( return None kb = kb_helper.kb - index_path = f"data/knowledge_base/{kb.kb_id}/index.faiss" + index_path = kb_helper.kb_dir / "index.faiss" # 读取 FAISS 索引 - if not os.path.exists(index_path): - logger.warning(f"FAISS 索引不存在: {index_path}") + if not index_path.exists(): + logger.warning(f"FAISS 索引不存在: {index_path!s}") return None - index = faiss.read_index(index_path) + index = faiss.read_index(str(index_path)) if index.ntotal == 0: logger.warning("索引为空") diff --git a/astrbot/utils/__init__.py b/astrbot/utils/__init__.py new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/astrbot/utils/__init__.py @@ -0,0 +1 @@ + diff --git a/astrbot/utils/http_ssl_common.py b/astrbot/utils/http_ssl_common.py new file mode 100644 index 000000000..b379fd36e --- /dev/null +++ b/astrbot/utils/http_ssl_common.py @@ -0,0 +1,24 @@ +import logging +import ssl +from typing import Any + +import certifi + +_LOGGER = logging.getLogger(__name__) + + +def build_ssl_context_with_certifi(log_obj: Any | None = None) -> ssl.SSLContext: + logger = log_obj or _LOGGER + + ssl_context = ssl.create_default_context() + try: + ssl_context.load_verify_locations(cafile=certifi.where()) + except Exception as exc: + if logger and hasattr(logger, "warning"): + logger.warning( + "Failed to load certifi CA bundle into SSL context; " + "falling back to system trust store only: %s", + exc, + ) + + return ssl_context diff --git a/changelogs/v4.14.5.md b/changelogs/v4.14.5.md new file mode 100644 index 000000000..0d6a6b79f --- /dev/null +++ b/changelogs/v4.14.5.md @@ -0,0 +1,11 @@ +## What's Changed + +### Fix +- fix: `fix: messages[x] assistant content must contain at least one part` after tool calling ([#4928](https://github.com/AstrBotDevs/AstrBot/issues/4928)) after tool calls. +- fix: TypeError when MCP schema type is a list ([#4867](https://github.com/AstrBotDevs/AstrBot/issues/4867)) +- fix: Fixed an issue that caused scheduled task execution failures with specific providers 修复特定提供商导致的定时任务执行失败的问题 ([#4872](https://github.com/AstrBotDevs/AstrBot/issues/4872)) + + +### Feature +- feat: add bocha web search tool ([#4902](https://github.com/AstrBotDevs/AstrBot/issues/4902)) +- feat: systemd support ([#4880](https://github.com/AstrBotDevs/AstrBot/issues/4880)) diff --git a/changelogs/v4.14.6.md b/changelogs/v4.14.6.md new file mode 100644 index 000000000..6a52d1c2c --- /dev/null +++ b/changelogs/v4.14.6.md @@ -0,0 +1,10 @@ +## What's Changed + +### 修复 +- 修复一些原因导致 Tavily WebSearch、Bocha WebSearch 无法使用的问题 + +### xinzeng +- 飞书支持 Bot 发送文件、图片和视频消息类型。 + +### 优化 +- 优化 WebChat 和 企业微信 AI 会话队列生命周期管理,减少内存泄漏,提高性能。 diff --git a/changelogs/v4.14.7.md b/changelogs/v4.14.7.md new file mode 100644 index 000000000..ac3beba0c --- /dev/null +++ b/changelogs/v4.14.7.md @@ -0,0 +1,31 @@ +## What's Changed + +### 修复 +- 人格预设对话可能会重复添加到上下文 ([#4961](https://github.com/AstrBotDevs/AstrBot/issues/4961)) + +### 新增 +- 增加提供商级别的代理支持 ([#4949](https://github.com/AstrBotDevs/AstrBot/issues/4949)) +- WebUI 管理行为增加插件指令权限管理功能 ([#4887](https://github.com/AstrBotDevs/AstrBot/issues/4887)) +- 允许 LLM 预览工具返回的图片并自主决定是否发送 ([#4895](https://github.com/AstrBotDevs/AstrBot/issues/4895)) +- Telegram 平台添加媒体组(相册)支持 ([#4893](https://github.com/AstrBotDevs/AstrBot/issues/4893)) +- 增加欢迎功能,支持本地化内容和新手引导步骤 +- 支持 Electron 桌面应用部署 ([#4952](https://github.com/AstrBotDevs/AstrBot/issues/4952)) + +### 注意 +- 更新 AstrBot Python 版本要求至 3.12 ([#4963](https://github.com/AstrBotDevs/AstrBot/issues/4963)) + +## What's Changed + +### Fixes +- Fixed issue where persona preset conversations could be duplicated in context ([#4961](https://github.com/AstrBotDevs/AstrBot/issues/4961)) + +### Features +- Added provider-level proxy support ([#4949](https://github.com/AstrBotDevs/AstrBot/issues/4949)) +- Added plugin command permission management to WebUI management behavior ([#4887](https://github.com/AstrBotDevs/AstrBot/issues/4887)) +- Allowed LLMs to preview images returned by tools and autonomously decide whether to send them ([#4895](https://github.com/AstrBotDevs/AstrBot/issues/4895)) +- Added media group (album) support for Telegram platform ([#4893](https://github.com/AstrBotDevs/AstrBot/issues/4893)) +- Added welcome feature with support for localized content and onboarding steps +- Supported Electron desktop application deployment ([#4952](https://github.com/AstrBotDevs/AstrBot/issues/4952)) + +### Notice +- Updated AstrBot Python version requirement to 3.12 ([#4963](https://github.com/AstrBotDevs/AstrBot/issues/4963)) \ No newline at end of file diff --git a/changelogs/v4.14.8.md b/changelogs/v4.14.8.md new file mode 100644 index 000000000..595a75355 --- /dev/null +++ b/changelogs/v4.14.8.md @@ -0,0 +1,35 @@ +## What's Changed + +hotfix of 4.14.7 + +- 为 AstrBot Electron App 增加特殊的更新处理。 + +### 修复 +- 人格预设对话可能会重复添加到上下文 ([#4961](https://github.com/AstrBotDevs/AstrBot/issues/4961)) + +### 新增 +- 增加提供商级别的代理支持 ([#4949](https://github.com/AstrBotDevs/AstrBot/issues/4949)) +- WebUI 管理行为增加插件指令权限管理功能 ([#4887](https://github.com/AstrBotDevs/AstrBot/issues/4887)) +- 允许 LLM 预览工具返回的图片并自主决定是否发送 ([#4895](https://github.com/AstrBotDevs/AstrBot/issues/4895)) +- Telegram 平台添加媒体组(相册)支持 ([#4893](https://github.com/AstrBotDevs/AstrBot/issues/4893)) +- 增加欢迎功能,支持本地化内容和新手引导步骤 +- 支持 Electron 桌面应用部署 ([#4952](https://github.com/AstrBotDevs/AstrBot/issues/4952)) + +### 注意 +- 更新 AstrBot Python 版本要求至 3.12 ([#4963](https://github.com/AstrBotDevs/AstrBot/issues/4963)) + +## What's Changed + +### Fixes +- Fixed issue where persona preset conversations could be duplicated in context ([#4961](https://github.com/AstrBotDevs/AstrBot/issues/4961)) + +### Features +- Added provider-level proxy support ([#4949](https://github.com/AstrBotDevs/AstrBot/issues/4949)) +- Added plugin command permission management to WebUI management behavior ([#4887](https://github.com/AstrBotDevs/AstrBot/issues/4887)) +- Allowed LLMs to preview images returned by tools and autonomously decide whether to send them ([#4895](https://github.com/AstrBotDevs/AstrBot/issues/4895)) +- Added media group (album) support for Telegram platform ([#4893](https://github.com/AstrBotDevs/AstrBot/issues/4893)) +- Added welcome feature with support for localized content and onboarding steps +- Supported Electron desktop application deployment ([#4952](https://github.com/AstrBotDevs/AstrBot/issues/4952)) + +### Notice +- Updated AstrBot Python version requirement to 3.12 ([#4963](https://github.com/AstrBotDevs/AstrBot/issues/4963)) \ No newline at end of file diff --git a/changelogs/v4.15.0.md b/changelogs/v4.15.0.md new file mode 100644 index 000000000..b6468a57c --- /dev/null +++ b/changelogs/v4.15.0.md @@ -0,0 +1,41 @@ +## What's Changed + +> 提醒 **v4.14.8** 用户:由于 v4.14.8 版本 Bug,若您未使用 Electron AstrBot 桌面应用,会被错误地通过 WebUI 对话框跳转到此页,**您可能需要手动重新部署 AstrBot 才能升级**。 + +### 新增 +- 企业微信智能机器人支持主动消息推送,并新增视频、文件等消息类型支持 ([#4999](https://github.com/AstrBotDevs/AstrBot/issues/4999)) +- 企业微信应用支持主动消息推送,并优化企微应用、微信公众号、微信客服的音频处理流程 ([#4998](https://github.com/AstrBotDevs/AstrBot/issues/4998)) +- 钉钉适配器支持主动消息推送,并新增图片、视频、音频等消息类型支持 ([#4986](https://github.com/AstrBotDevs/AstrBot/issues/4986)) +- 人格管理弹窗新增删除按钮 ([#4978](https://github.com/AstrBotDevs/AstrBot/issues/4978)) + +### 修复 +- 修复 SubAgents 工具去重相关问题 ([#4990](https://github.com/AstrBotDevs/AstrBot/issues/4990)) +- 改进 WeCom AI Bot 的流式消息处理逻辑,提升分段与流式回复稳定性 ([#5000](https://github.com/AstrBotDevs/AstrBot/issues/5000)) +- 稳定源码与 Electron 打包环境下的 pip 安装行为,并修复非 Electron 场景点击 WebUI 更新按钮时误触发跳转对话框的问题 ([#4996](https://github.com/AstrBotDevs/AstrBot/issues/4996)) +- 修复桌面端后端构建时 certifi 数据收集问题 ([#4995](https://github.com/AstrBotDevs/AstrBot/issues/4995)) +- 修复冻结运行时(frozen runtime)中的 pip install 执行问题 ([#4985](https://github.com/AstrBotDevs/AstrBot/issues/4985)) +- 为 Windows ARM64 通过 vcpkg 预置 OpenSSL,修复相关构建准备问题 + +### 优化 +- 更新 `pydantic` 依赖版本 ([#4980](https://github.com/AstrBotDevs/AstrBot/issues/4980)) +- 调整 GHCR namespace 的 CI 配置 + +## What's Changed (EN) + +### New Features +- Enhanced persona tool management and improved UI localization for subagent orchestration ([#4990](https://github.com/AstrBotDevs/AstrBot/issues/4990)) +- Added proactive message push for WeCom AI Bot, with support for video, file, and more message types ([#4999](https://github.com/AstrBotDevs/AstrBot/issues/4999)) +- Added proactive message push for WeCom app, and improved audio handling for WeCom app, WeChat Official Account, and WeCom customer service ([#4998](https://github.com/AstrBotDevs/AstrBot/issues/4998)) +- Enhanced Dingtalk adapter with proactive push and support for image, video, and audio message types ([#4986](https://github.com/AstrBotDevs/AstrBot/issues/4986)) +- Added a delete button to the persona management dialog for better usability ([#4978](https://github.com/AstrBotDevs/AstrBot/issues/4978)) + +### Fixes +- Improved streaming message handling in WeCom AI Bot for better segmented and streaming reply stability ([#5000](https://github.com/AstrBotDevs/AstrBot/issues/5000)) +- Stabilized pip installation behavior in source and Electron packaged environments, and fixed the unexpected redirect dialog when clicking WebUI update in non-Electron mode ([#4996](https://github.com/AstrBotDevs/AstrBot/issues/4996)) +- Fixed certifi data collection in desktop backend build ([#4995](https://github.com/AstrBotDevs/AstrBot/issues/4995)) +- Fixed pip install execution in frozen runtime ([#4985](https://github.com/AstrBotDevs/AstrBot/issues/4985)) +- Prepared OpenSSL via vcpkg for Windows ARM64 build flow + +### Improvements +- Updated `pydantic` dependency version ([#4980](https://github.com/AstrBotDevs/AstrBot/issues/4980)) +- Updated CI configuration for GHCR namespace diff --git a/changelogs/v4.16.0.md b/changelogs/v4.16.0.md new file mode 100644 index 000000000..fd7657865 --- /dev/null +++ b/changelogs/v4.16.0.md @@ -0,0 +1,62 @@ +## What's Changed + +### 新增 +- QQ 官方机器人平台支持主动推送消息,私聊场景支持接收文件 ([#5066](https://github.com/AstrBotDevs/AstrBot/issues/5066)) +- 为 Telegram 平台适配器新增等待 AI 回复时自动展示 “正在输入”、“正在上传图片” 等状态的功能 ([#5037](https://github.com/AstrBotDevs/AstrBot/issues/5037)) +- 为飞书适配器增加接收文件、读取引用消息的内容(包括引用的图片、视频、文件、文字等) ([#5018](https://github.com/AstrBotDevs/AstrBot/issues/5018)) +- 新增自定义平台适配器 i18n 支持 ([#5045](https://github.com/AstrBotDevs/AstrBot/issues/5045)) +- 新增临时文件处理能力,可在系统配置中限制 data/temp 目录的最大大小。 ([#5026](https://github.com/AstrBotDevs/AstrBot/issues/5026)) +- 增加首次启动公告功能,支持多语言与 WebUI 集成 + +### 修复 + +- 修复 OpenRouter DeepSeek 场景下的 chunk 错误 ([#5069](https://github.com/AstrBotDevs/AstrBot/issues/5069)) +- 修复备份时人格文件夹映射缺失问题 ([#5042](https://github.com/AstrBotDevs/AstrBot/issues/5042)) +- 修复更新日志与官方文档弹窗双滚动条问题 ([#5060](https://github.com/AstrBotDevs/AstrBot/issues/5060)) +- 修复 provider 额外参数弹窗 key 显示异常 +- 修复连接失败时错误日志提示不准确的问题 +- 修复提前返回时未等待 reset 协程导致的资源清理问题 ([#5033](https://github.com/AstrBotDevs/AstrBot/issues/5033)) +- 提升打包版桌面端启动稳定性并优化插件依赖处理 ([#5031](https://github.com/AstrBotDevs/AstrBot/issues/5031)) +- 为 Electron 与后端日志增加按大小轮转 ([#5029](https://github.com/AstrBotDevs/AstrBot/issues/5029)) +- 加固冻结运行时(frozen app runtime)插件依赖加载流程 ([#5015](https://github.com/AstrBotDevs/AstrBot/issues/5015)) + +### 优化 +- 完善合并消息、引用解析与图片回退,并支持配置化控制 ([#5054](https://github.com/AstrBotDevs/AstrBot/issues/5054)) +- 配置页面支持通过侧边栏子项切换普通配置/系统配置,并补充相关路由修复 +- 优化分段回复间隔时间初始化逻辑 ([#5068](https://github.com/AstrBotDevs/AstrBot/issues/5068)) + +### 文档与维护 +- 同步并修正 README 文档内容与拼写 ([#5055](https://github.com/AstrBotDevs/AstrBot/issues/5055), [#5014](https://github.com/AstrBotDevs/AstrBot/issues/5014)) +- 新增 AUR 安装方式说明 ([#4879](https://github.com/AstrBotDevs/AstrBot/issues/4879)) +- 执行代码格式化(ruff) + +## What's Changed (EN) + +### New Features +- Added proactive message push and private-chat file receiving support for the QQ official bot adapter ([#5066](https://github.com/AstrBotDevs/AstrBot/issues/5066)) +- Added automatic "typing..." and "uploading image..." status display while waiting for AI response in the Telegram adapter ([#5037](https://github.com/AstrBotDevs/AstrBot/issues/5037)) +- Added file receiving and quoted message content reading (including quoted images, videos, files, text, etc.) for the Feishu adapter ([#5018](https://github.com/AstrBotDevs/AstrBot/issues/5018)) +- Added i18n support for custom platform adapters ([#5045](https://github.com/AstrBotDevs/AstrBot/issues/5045)) +- Introduced temporary file handling and `TempDirCleaner` ([#5026](https://github.com/AstrBotDevs/AstrBot/issues/5026)) +- Added a first-launch notice feature with multilingual content and WebUI integration + +### Fixes +- Added sidebar child-tab switching for normal/system config and fixed related routing behavior on the config page +- Fixed chunk errors when using OpenRouter DeepSeek ([#5069](https://github.com/AstrBotDevs/AstrBot/issues/5069)) +- Improved forwarded-quote parsing and image fallback with configurable controls ([#5054](https://github.com/AstrBotDevs/AstrBot/issues/5054)) +- Fixed missing persona-folder mapping in backup exports ([#5042](https://github.com/AstrBotDevs/AstrBot/issues/5042)) +- Fixed double scrollbar issue in changelog and official docs dialogs ([#5060](https://github.com/AstrBotDevs/AstrBot/issues/5060)) +- Fixed key rendering issues in the provider extra-params dialog +- Improved error log wording for connection failures +- Fixed unawaited reset coroutine cleanup on early returns ([#5033](https://github.com/AstrBotDevs/AstrBot/issues/5033)) +- Improved packaged desktop startup stability and plugin dependency handling ([#5031](https://github.com/AstrBotDevs/AstrBot/issues/5031)) +- Added size-based log rotation for Electron and backend logs ([#5029](https://github.com/AstrBotDevs/AstrBot/issues/5029)) +- Hardened plugin dependency loading in frozen app runtime ([#5015](https://github.com/AstrBotDevs/AstrBot/issues/5015)) + +### Improvements +- Optimized initialization logic for segmented-reply interval timing ([#5068](https://github.com/AstrBotDevs/AstrBot/issues/5068)) + +### Docs & Maintenance +- Synced and fixed README docs and typos ([#5055](https://github.com/AstrBotDevs/AstrBot/issues/5055), [#5014](https://github.com/AstrBotDevs/AstrBot/issues/5014)) +- Added AUR installation instructions ([#4879](https://github.com/AstrBotDevs/AstrBot/issues/4879)) +- Applied code formatting with ruff diff --git a/changelogs/v4.17.0.md b/changelogs/v4.17.0.md new file mode 100644 index 000000000..c8f2e93a1 --- /dev/null +++ b/changelogs/v4.17.0.md @@ -0,0 +1,29 @@ +## What's Changed + +### 新增 +- 新增 LINE 平台适配器与相关配置支持 ([#5085](https://github.com/AstrBotDevs/AstrBot/issues/5085)) +- 新增备用回退聊天模型列表,当主模型报错时自动切换到备用模型 ([#5109](https://github.com/AstrBotDevs/AstrBot/issues/5109)) +- 新增插件加载失败后的热重载支持,便于插件修复后快速恢复 ([#5043](https://github.com/AstrBotDevs/AstrBot/issues/5043)) +- WebUI 新增 SSL 配置选项并同步更新相关日志行为 ([#5117](https://github.com/AstrBotDevs/AstrBot/issues/5117)) + +### 修复 +- 修复 Dockerfile 中依赖导出流程,增加 `uv lock` 步骤并移除不必要的 `--frozen` 参数,提升构建稳定性 ([#5091](https://github.com/AstrBotDevs/AstrBot/issues/5091), [#5089](https://github.com/AstrBotDevs/AstrBot/issues/5089)) +- 修复首次启动公告 `FIRST_NOTICE.md` 的本地化路径解析问题,补充兼容路径处理 ([#5083](https://github.com/AstrBotDevs/AstrBot/issues/5083), [#5082](https://github.com/AstrBotDevs/AstrBot/issues/5082)) + +### 优化 +- 日志系统由 `colorlog` 切换为 `loguru`,增强日志输出与展示能力 ([#5115](https://github.com/AstrBotDevs/AstrBot/issues/5115)) + +## What's Changed (EN) + +### New Features +- Added LINE platform adapter support with related configuration options ([#5085](https://github.com/AstrBotDevs/AstrBot/issues/5085)) +- Added fallback chat model chain support in tool loop runner, with corresponding config and improved provider selection display ([#5109](https://github.com/AstrBotDevs/AstrBot/issues/5109)) +- Added hot reload support after plugin load failure for faster recovery during plugin development and maintenance ([#5043](https://github.com/AstrBotDevs/AstrBot/issues/5043)) +- Added SSL configuration options for WebUI and updated related logging behavior ([#5117](https://github.com/AstrBotDevs/AstrBot/issues/5117)) + +### Fixes +- Fixed Dockerfile dependency export flow by adding a `uv lock` step and removing unnecessary `--frozen` flag to improve build stability ([#5091](https://github.com/AstrBotDevs/AstrBot/issues/5091), [#5089](https://github.com/AstrBotDevs/AstrBot/issues/5089)) +- Fixed locale path resolution for `FIRST_NOTICE.md` and added compatible fallback handling ([#5083](https://github.com/AstrBotDevs/AstrBot/issues/5083), [#5082](https://github.com/AstrBotDevs/AstrBot/issues/5082)) + +### Improvements +- Replaced `colorlog` with `loguru` to improve logging capabilities and console display ([#5115](https://github.com/AstrBotDevs/AstrBot/issues/5115)) diff --git a/changelogs/v4.17.1.md b/changelogs/v4.17.1.md new file mode 100644 index 000000000..13e7daad6 --- /dev/null +++ b/changelogs/v4.17.1.md @@ -0,0 +1,34 @@ +## What's Changed + +hotfix of 4.17.0 + +- 修复:当开启了 “启用文件日志” 后,无法启动 AstrBot,报错 `ValueError: Invalid unit value while parsing duration: 'files'`。这是由于日志轮转设置中保留配置错误导致的,已通过根据备份数量正确设置保留参数进行修复。 +- fix: When "Enable file logging" is turned on, AstrBot fails to start with error `ValueError: Invalid unit value while parsing duration: 'files'`. This is due to an incorrect retention configuration in the log rotation setup, which has been fixed by properly setting the retention parameter based on backup count. + +### 新增 +- 新增 LINE 平台适配器与相关配置支持 ([#5085](https://github.com/AstrBotDevs/AstrBot/issues/5085)) +- 新增备用回退聊天模型列表,当主模型报错时自动切换到备用模型 ([#5109](https://github.com/AstrBotDevs/AstrBot/issues/5109)) +- 新增插件加载失败后的热重载支持,便于插件修复后快速恢复 ([#5043](https://github.com/AstrBotDevs/AstrBot/issues/5043)) +- WebUI 新增 SSL 配置选项并同步更新相关日志行为 ([#5117](https://github.com/AstrBotDevs/AstrBot/issues/5117)) + +### 修复 +- 修复 Dockerfile 中依赖导出流程,增加 `uv lock` 步骤并移除不必要的 `--frozen` 参数,提升构建稳定性 ([#5091](https://github.com/AstrBotDevs/AstrBot/issues/5091), [#5089](https://github.com/AstrBotDevs/AstrBot/issues/5089)) +- 修复首次启动公告 `FIRST_NOTICE.md` 的本地化路径解析问题,补充兼容路径处理 ([#5083](https://github.com/AstrBotDevs/AstrBot/issues/5083), [#5082](https://github.com/AstrBotDevs/AstrBot/issues/5082)) + +### 优化 +- 日志系统由 `colorlog` 切换为 `loguru`,增强日志输出与展示能力 ([#5115](https://github.com/AstrBotDevs/AstrBot/issues/5115)) + +## What's Changed (EN) + +### New Features +- Added LINE platform adapter support with related configuration options ([#5085](https://github.com/AstrBotDevs/AstrBot/issues/5085)) +- Added fallback chat model chain support in tool loop runner, with corresponding config and improved provider selection display ([#5109](https://github.com/AstrBotDevs/AstrBot/issues/5109)) +- Added hot reload support after plugin load failure for faster recovery during plugin development and maintenance ([#5043](https://github.com/AstrBotDevs/AstrBot/issues/5043)) +- Added SSL configuration options for WebUI and updated related logging behavior ([#5117](https://github.com/AstrBotDevs/AstrBot/issues/5117)) + +### Fixes +- Fixed Dockerfile dependency export flow by adding a `uv lock` step and removing unnecessary `--frozen` flag to improve build stability ([#5091](https://github.com/AstrBotDevs/AstrBot/issues/5091), [#5089](https://github.com/AstrBotDevs/AstrBot/issues/5089)) +- Fixed locale path resolution for `FIRST_NOTICE.md` and added compatible fallback handling ([#5083](https://github.com/AstrBotDevs/AstrBot/issues/5083), [#5082](https://github.com/AstrBotDevs/AstrBot/issues/5082)) + +### Improvements +- Replaced `colorlog` with `loguru` to improve logging capabilities and console display ([#5115](https://github.com/AstrBotDevs/AstrBot/issues/5115)) diff --git a/changelogs/v4.17.2.md b/changelogs/v4.17.2.md new file mode 100644 index 000000000..e65972de3 --- /dev/null +++ b/changelogs/v4.17.2.md @@ -0,0 +1,8 @@ +## What's Changed + +hotfix of 4.17.0 + +- 修复:MCP 服务器的 Tools 没有被正确添加到上下文中。 +- 修复:Electron 桌面应用部署时,系统自带插件未被正确加载的问题。 +- fix: Tools from MCP server were not properly added to context. +- fix: built-in plugins were not properly loaded in Electron desktop application deployment. diff --git a/changelogs/v4.17.3.md b/changelogs/v4.17.3.md new file mode 100644 index 000000000..4b87b6243 --- /dev/null +++ b/changelogs/v4.17.3.md @@ -0,0 +1,27 @@ +## What's Changed + +### 修复 +- ‼️ 修复 Python 3.14 环境下 `'Plain' object has no attribute 'text'` 报错问题 ([#5154](https://github.com/AstrBotDevs/AstrBot/issues/5154))。 +- ‼️ 修复插件元数据处理流程:在实例化前注入必要属性,避免初始化阶段元数据缺失 ([#5155](https://github.com/AstrBotDevs/AstrBot/issues/5155))。 +- 修复桌面端后端构建中 AstrBot 内置插件运行时依赖未打包的问题 ([#5146](https://github.com/AstrBotDevs/AstrBot/issues/5146))。 +- 修复通过 AstrBot Launcher 启动时仍被检测并触发更新的问题。 + +### 优化 + +- Webchat 下,使用 `astrbot_execute_ipython` 工具如果返回了图片,会自动将图片发送到聊天中。 + +### 其他 +- 执行 `ruff format` 代码格式整理。 + +## What's Changed (EN) + +### Fixes +- ‼️ Fixed plugin metadata handling by injecting required attributes before instantiation to avoid missing metadata during initialization ([#5155](https://github.com/AstrBotDevs/AstrBot/issues/5155)). +- ‼️ Fixed `'Plain' object has no attribute 'text'` error when using Python 3.14 ([#5154](https://github.com/AstrBotDevs/AstrBot/issues/5154)). +- Fixed missing runtime dependencies for built-in plugins in desktop backend builds ([#5146](https://github.com/AstrBotDevs/AstrBot/issues/5146)). +- Fixed update checks being triggered when AstrBot is launched via AstrBot Launcher. + +### Improvements +- In Webchat, when using the `astrbot_execute_ipython` tool, if an image is returned, it will automatically be sent to the chat. +### Others +- Applied `ruff format` code formatting. diff --git a/changelogs/v4.17.4.md b/changelogs/v4.17.4.md new file mode 100644 index 000000000..667b03060 --- /dev/null +++ b/changelogs/v4.17.4.md @@ -0,0 +1,32 @@ +## What's Changed + +### 新增 +- 新增 NVIDIA Provider 模板,便于快速接入 NVIDIA 模型服务 ([#5157](https://github.com/AstrBotDevs/AstrBot/issues/5157))。 +- 支持在 WebUI 搜索配置 + +### 修复 +- 修复 CronJob 页面操作列按钮重叠问题,提升任务管理可用性 ([#5163](https://github.com/AstrBotDevs/AstrBot/issues/5163))。 + +### 优化 +- 优化 Python / Shell 本地执行工具的权限拒绝提示信息引导,提升排障可读性。 +- Provider 来源面板样式升级,新增菜单交互并完善移动端适配。 +- PersonaForm 组件增强响应式布局与样式细节,改进不同屏幕下的编辑体验 ([#5162](https://github.com/AstrBotDevs/AstrBot/issues/5162))。 +- 配置页面新增未保存变更提示,减少误操作导致的配置丢失。 +- 配置相关组件新增搜索能力并同步更新界面交互,提升配置项定位效率 ([#5168](https://github.com/AstrBotDevs/AstrBot/issues/5168))。 + +## What's Changed (EN) + +### New Features +- Added an NVIDIA provider template for faster integration with NVIDIA model services ([#5157](https://github.com/AstrBotDevs/AstrBot/issues/5157)). +- Added an announcement section to the Welcome page, with localized announcement title support. +- Added an FAQ link to the vertical sidebar and updated navigation for localization. + +### Fixes +- Fixed overlapping action buttons in the CronJob page action column to improve task management usability ([#5163](https://github.com/AstrBotDevs/AstrBot/issues/5163)). +- Improved permission-denied messages for local execution in Python and shell tools for better troubleshooting clarity. + +### Improvements +- Enhanced the provider sources panel with a refined menu style and better mobile support. +- Improved PersonaForm with responsive layout and styling updates for better editing experience across screen sizes ([#5162](https://github.com/AstrBotDevs/AstrBot/issues/5162)). +- Added an unsaved-changes notice on the configuration page to reduce accidental config loss. +- Added search functionality to configuration components and updated related UI interactions for faster settings discovery ([#5168](https://github.com/AstrBotDevs/AstrBot/issues/5168)). diff --git a/changelogs/v4.17.5.md b/changelogs/v4.17.5.md new file mode 100644 index 000000000..c01ba4ea1 --- /dev/null +++ b/changelogs/v4.17.5.md @@ -0,0 +1,37 @@ +## What's Changed + +### 新增 +- 支持 QQ 官方机器人平台发送 Markdown 消息,提升富文本消息呈现能力 ([#5173](https://github.com/AstrBotDevs/AstrBot/issues/5173))。 +- 新增在插件市场中集成随机插件推荐能力 ([#5190](https://github.com/AstrBotDevs/AstrBot/issues/5190))。 +- 新增插件错误钩子(plugin error hook),支持自定义错误路由处理,便于插件统一异常控制 ([#5192](https://github.com/AstrBotDevs/AstrBot/issues/5192))。 + +### 修复 +- 修复全部 LLM Provider 失败时重复显示错误信息的问题,减少冗余报错干扰 ([#5183](https://github.com/AstrBotDevs/AstrBot/issues/5183))。 +- 修复从“选择配置文件”进入配置管理后直接关闭弹窗时,显示配置文件不正确的问题 ([#5174](https://github.com/AstrBotDevs/AstrBot/issues/5174))。 + +### 优化 +- 重构 telegram `Voice_messages_forbidden` 回退逻辑,提取为共享辅助方法并引入类型化 `BadRequest` 异常,提升异常处理一致性 ([#5204](https://github.com/AstrBotDevs/AstrBot/issues/5204))。 + +### 其他 +- 更新 README 相关文档内容。 +- 执行 `ruff format` 代码格式整理。 + +## What's Changed (EN) + +### New Features +- Added a plugin error hook for custom error routing, enabling unified exception handling in plugins ([#5192](https://github.com/AstrBotDevs/AstrBot/issues/5192)). +- Added Markdown message sending support for `qqofficial` to improve rich-text delivery ([#5173](https://github.com/AstrBotDevs/AstrBot/issues/5173)). +- Added the `MarketPluginCard` component and integrated random plugin recommendations in the extension marketplace ([#5190](https://github.com/AstrBotDevs/AstrBot/issues/5190)). +- Added support for the `aihubmix` provider. +- Added LINE support notes to multilingual README files. + +### Fixes +- Fixed duplicate error messages when all LLM providers fail, reducing noisy error output ([#5183](https://github.com/AstrBotDevs/AstrBot/issues/5183)). +- Fixed incorrect displayed profile after opening configuration management from profile selection and closing the dialog directly ([#5174](https://github.com/AstrBotDevs/AstrBot/issues/5174)). + +### Improvements +- Refactored `Voice_messages_forbidden` fallback logic into a shared helper and introduced a typed `BadRequest` exception for more consistent error handling ([#5204](https://github.com/AstrBotDevs/AstrBot/issues/5204)). + +### Others +- Updated README documentation. +- Applied `ruff format` code formatting. diff --git a/changelogs/v4.17.6.md b/changelogs/v4.17.6.md new file mode 100644 index 000000000..1efbc6f77 --- /dev/null +++ b/changelogs/v4.17.6.md @@ -0,0 +1,47 @@ +## What's Changed + +### 新增 +- 新增 Python / Shell 执行工具的管理员权限校验,提升高风险操作安全性 ([#5214](https://github.com/AstrBotDevs/AstrBot/issues/5214))。 +- 新增插件 `astrbot-version` 与平台版本要求校验支持,增强插件兼容性管理能力 ([#5235](https://github.com/AstrBotDevs/AstrBot/issues/5235))。 +- 账号密码修改流程新增“确认新密码”校验,减少误输导致的配置问题 ([#5247](https://github.com/AstrBotDevs/AstrBot/issues/5247))。 + +### 修复 +- 改进微信公众号被动回复处理机制,引入缓冲与分片回复并优化超时行为,提升回复稳定性 ([#5224](https://github.com/AstrBotDevs/AstrBot/issues/5224))。 +- 修复仅发送 JSON 消息段时可能触发空消息回复报错的问题 ([#5208](https://github.com/AstrBotDevs/AstrBot/issues/5208))。 +- 修复会话重置/新建/删除时未终止活动事件导致的陈旧响应问题 ([#5225](https://github.com/AstrBotDevs/AstrBot/issues/5225))。 +- 修复 provider 在 `dict` 格式 `content` 场景下可能残留 JSON 内容的问题 ([#5250](https://github.com/AstrBotDevs/AstrBot/issues/5250))。 +- 修复 MCP 工具未完整暴露给主 Agent 的问题 ([#5252](https://github.com/AstrBotDevs/AstrBot/issues/5252))。 +- 修复工具 schema 属性中的 `additionalProperties` 配置问题 ([#5253](https://github.com/AstrBotDevs/AstrBot/issues/5253))。 +- 优化账号编辑校验错误提示,简化并统一用户名/密码为空场景返回信息。 + +### 优化 +- 优化 PersonaForm 布局与工具选择展示,并完善工具停用状态的本地化显示。 + +### 其他 +- 移除 Electron Desktop 流水线并迁移到 Tauri 仓库 ([#5226](https://github.com/AstrBotDevs/AstrBot/issues/5226))。 +- 更新相关仓库链接与功能请求模板文案,统一中英文表达。 +- 移除过时文档文件 `heihe.md`。 + +## What's Changed (EN) + +### New Features +- Added admin permission checks for Python/Shell execution tools to improve safety for high-risk operations ([#5214](https://github.com/AstrBotDevs/AstrBot/issues/5214)). +- Added support for `astrbot-version` and platform requirement checks for plugins to improve compatibility management ([#5235](https://github.com/AstrBotDevs/AstrBot/issues/5235)). +- Added password confirmation when changing account passwords to reduce misconfiguration caused by typos ([#5247](https://github.com/AstrBotDevs/AstrBot/issues/5247)). + +### Fixes +- Improved passive reply handling for WeChat Official Accounts with buffering/chunking and timeout behavior optimizations for better stability ([#5224](https://github.com/AstrBotDevs/AstrBot/issues/5224)). +- Fixed an empty-message reply error when only JSON message segments were sent ([#5208](https://github.com/AstrBotDevs/AstrBot/issues/5208)). +- Fixed stale responses by terminating active events on reset/new/delete operations ([#5225](https://github.com/AstrBotDevs/AstrBot/issues/5225)). +- Fixed residual JSON content issues in provider handling when `content` was in `dict` format ([#5250](https://github.com/AstrBotDevs/AstrBot/issues/5250)). +- Fixed incomplete exposure of MCP tools to the main agent ([#5252](https://github.com/AstrBotDevs/AstrBot/issues/5252)). +- Fixed `additionalProperties` handling in tool schema properties ([#5253](https://github.com/AstrBotDevs/AstrBot/issues/5253)). +- Simplified and unified account-edit validation error responses for empty username/password scenarios. + +### Improvements +- Enhanced PersonaForm layout and tool selection display, and improved localized labels for inactive tools. + +### Others +- Removed the Electron desktop pipeline and switched to the Tauri repository ([#5226](https://github.com/AstrBotDevs/AstrBot/issues/5226)). +- Updated related repository links and refined feature request template wording in both Chinese and English. +- Removed outdated documentation file `heihe.md`. diff --git a/changelogs/v4.18.0.md b/changelogs/v4.18.0.md new file mode 100644 index 000000000..847da020e --- /dev/null +++ b/changelogs/v4.18.0.md @@ -0,0 +1,29 @@ +## What's Changed + +### 新增 +- 新增 AstrBot HTTP API,支持基于 API Key 的对话、会话查询、配置查询、文件上传与 IM 消息发送能力。详见[AstrBot HTTP API (Beta)](https://docs.astrbot.app/dev/openapi.html) ([#5280](https://github.com/AstrBotDevs/AstrBot/issues/5280))。 +- 新增 Telegram 指令别名注册能力,别名可同步展示在 Telegram 指令菜单中 ([#5234](https://github.com/AstrBotDevs/AstrBot/issues/5234))。 +- 新增 Anthropic 自适应思考参数配置(type/effort),增强思考策略可控性 ([#5209](https://github.com/AstrBotDevs/AstrBot/issues/5209))。 + +### 修复 +- 修复 QQ 官方频道消息发送异常问题,提升消息下发稳定性 ([#5287](https://github.com/AstrBotDevs/AstrBot/issues/5287))。 +- 修复 ChatUI 使用非 default 配置文件对话时仍然使用 default 配置的问题 ([#5292](https://github.com/AstrBotDevs/AstrBot/issues/5292))。 + +### 优化 +- 优化插件市场卡片的平台支持展示,改进移动端可用性与交互体验 ([#5271](https://github.com/AstrBotDevs/AstrBot/issues/5271))。 +- 重构 Dashboard 桌面运行时桥接字段,从 `isElectron` 统一迁移至 `isDesktop`,提升跨端语义一致性 ([#5269](https://github.com/AstrBotDevs/AstrBot/issues/5269))。 + +## What's Changed (EN) + +### New Features +- Added AstrBot HTTP API with API Key support for chat, session listing, config listing, file upload, and IM message sending. See [AstrBot HTTP API (Beta)](https://docs.astrbot.app/en/dev/openapi.html) ([#5280](https://github.com/AstrBotDevs/AstrBot/issues/5280)). +- Added Telegram command alias registration so aliases can also appear in the Telegram command menu ([#5234](https://github.com/AstrBotDevs/AstrBot/issues/5234)). +- Added Anthropic adaptive thinking parameters (`type`/`effort`) for more flexible reasoning strategy control ([#5209](https://github.com/AstrBotDevs/AstrBot/issues/5209)). + +### Fixes +- Fixed QQ official guild message sending errors to improve delivery stability ([#5287](https://github.com/AstrBotDevs/AstrBot/issues/5287)). +- Fixed chat config binding failures caused by missing session IDs when creating new chats, and improved localStorage fault tolerance ([#5292](https://github.com/AstrBotDevs/AstrBot/issues/5292)). + +### Improvements +- Improved plugin marketplace card display for platform compatibility, with better mobile accessibility and interaction ([#5271](https://github.com/AstrBotDevs/AstrBot/issues/5271)). +- Refactored desktop runtime bridge fields in the dashboard from `isElectron` to `isDesktop` for clearer cross-platform semantics ([#5269](https://github.com/AstrBotDevs/AstrBot/issues/5269)). diff --git a/changelogs/v4.18.1.md b/changelogs/v4.18.1.md new file mode 100644 index 000000000..1f17162eb --- /dev/null +++ b/changelogs/v4.18.1.md @@ -0,0 +1,17 @@ +## What's Changed + +### 修复 +- fix: 修复插件市场出现插件显示为空白的 bug;纠正已安装插件卡片的排版,统一大小 ([#5309](https://github.com/AstrBotDevs/AstrBot/issues/5309)) + +### 新增 +- SubAgent 支持后台执行模式配置:当 `background: true` 时,子代理将在后台运行,主对话无需等待子代理完成即可继续进行。当子代理完成后,会收到通知。适用于长时间运行或用户不需要立即结果的任务。([#5081](https://github.com/AstrBotDevs/AstrBot/issues/5081)) +- 配置 Schema 新增密码渲染支持:`string` 与 `text` 类型可通过 `password: true`(或 `render_type: "password"`)在 WebUI 中按密码输入方式显示。 + +## What's Changed (EN) + +### Fixes +- fix: Fixed a bug where the plugin marketplace would show blank cards for plugins; corrected the layout of installed plugin cards for consistent sizing ([#5309](https://github.com/AstrBotDevs/AstrBot/issues/5309)) + +### New Features +- Added background execution mode support for sub-agents: when `background: true` is set, the sub-agent will run in the background, allowing the main conversation to continue without waiting for the sub-agent to finish. You will be notified when the sub-agent completes. This is suitable for long-running tasks or when the user does not need immediate results. ([#5081](https://github.com/AstrBotDevs/AstrBot/issues/5081)) +- Added password rendering support in config schema: `string` and `text` fields can be rendered as password inputs in WebUI with `password: true` (or `render_type: "password"`). diff --git a/changelogs/v4.18.2.md b/changelogs/v4.18.2.md new file mode 100644 index 000000000..aa958ea6f --- /dev/null +++ b/changelogs/v4.18.2.md @@ -0,0 +1,60 @@ +## What's Changed + +### 新增 +- 新增 Agent 会话停止能力,并优化 stop 请求处理流程,支持 /stop 指令终止 Agent 运行并尽量不丢失已运行输出的结果。 ([#5380](https://github.com/AstrBotDevs/AstrBot/issues/5380))。 +- 新增 SubAgent 交接场景下的 computer-use 工具支持 ([#5399](https://github.com/AstrBotDevs/AstrBot/issues/5399))。 +- 新增 Agent 执行过程中展示工具调用结果的能力,提升执行过程可观测性 ([#5388](https://github.com/AstrBotDevs/AstrBot/issues/5388))。 +- 新增插件加载/卸载 Hook,扩展插件生命周期能力 ([#5331](https://github.com/AstrBotDevs/AstrBot/issues/5331))。 +- 新增插件加载失败后的热重载能力,提升插件开发与恢复效率 ([#5334](https://github.com/AstrBotDevs/AstrBot/issues/5334))。 +- 新增 SubAgent 图片 URL/本地路径输入支持 ([#5348](https://github.com/AstrBotDevs/AstrBot/issues/5348))。 +- 新增 Dashboard 发布跳转基础 URL 可配置项 ([#5330](https://github.com/AstrBotDevs/AstrBot/issues/5330))。 + +### 修复 +- 修复 Tavily 请求的硬编码 6 秒超时。 +- 修复 OneBot v11 适配器关闭之后仍然在连接的问题([#5412](https://github.com/AstrBotDevs/AstrBot/issues/5412))。 +- 修复上下文会话中平台缺失时的日志处理,补充 warning 并改进排查信息。 +- 修复 embedding 维度未透传到 provider API 的问题 ([#5411](https://github.com/AstrBotDevs/AstrBot/issues/5411))。 +- 修复 File 组件处理逻辑并增强 OneBot 驱动层路径兼容性 ([#5391](https://github.com/AstrBotDevs/AstrBot/issues/5391))。 +- 修复 sandbox 文件传输工具缺少管理员权限校验的问题 ([#5402](https://github.com/AstrBotDevs/AstrBot/issues/5402))。 +- 修复 pipeline 与 `from ... import *` 引发的循环依赖问题 ([#5353](https://github.com/AstrBotDevs/AstrBot/issues/5353))。 +- 修复配置文件存在 UTF-8 BOM 时的解析问题 ([#5376](https://github.com/AstrBotDevs/AstrBot/issues/5376))。 +- 修复 ChatUI 复制回滚路径缺失与错误提示不清晰的问题 ([#5352](https://github.com/AstrBotDevs/AstrBot/issues/5352))。 +- 修复保留插件目录处理逻辑,避免插件目录行为异常 ([#5369](https://github.com/AstrBotDevs/AstrBot/issues/5369))。 +- 修复 ChatUI 文件消息段无法持久化的问题 ([#5386](https://github.com/AstrBotDevs/AstrBot/issues/5386))。 +- 修复 `.dockerignore` 误排除 `changelogs` 目录的问题。 +- 修复 aiohttp 版本过新导致 qq-botpy 报错的问题 ([#5316](https://github.com/AstrBotDevs/AstrBot/issues/5316))。 + +### 优化 +- 完成 SubAgent 编排页面国际化,补齐多语言支持 ([#5400](https://github.com/AstrBotDevs/AstrBot/issues/5400))。 +- 增补消息事件处理相关测试,并完善测试框架的 fixtures/mocks 覆盖 ([#5355](https://github.com/AstrBotDevs/AstrBot/issues/5355), [#5354](https://github.com/AstrBotDevs/AstrBot/issues/5354))。 + +## What's Changed (EN) + +### New Features +- Added computer-use tools support in sub-agent handoff scenarios ([#5399](https://github.com/AstrBotDevs/AstrBot/issues/5399)). +- Added support for displaying tool call results during agent execution for better observability ([#5388](https://github.com/AstrBotDevs/AstrBot/issues/5388)). +- Added plugin load/unload hooks to extend plugin lifecycle capabilities ([#5331](https://github.com/AstrBotDevs/AstrBot/issues/5331)). +- Added hot reload support when plugin loading fails, improving recovery during plugin development ([#5334](https://github.com/AstrBotDevs/AstrBot/issues/5334)). +- Added image URL/local path input support for sub-agents ([#5348](https://github.com/AstrBotDevs/AstrBot/issues/5348)). +- Added stop control for active agent sessions and improved stop request handling ([#5380](https://github.com/AstrBotDevs/AstrBot/issues/5380)). +- Added configurable base URL for dashboard release redirects ([#5330](https://github.com/AstrBotDevs/AstrBot/issues/5330)). + +### Fixes +- Fixed logging behavior when platform information is missing in context sessions, with clearer warning and diagnostics. +- Fixed missing embedding dimensions being passed to provider APIs ([#5411](https://github.com/AstrBotDevs/AstrBot/issues/5411)). +- Fixed shutdown stability issues in the aiocqhttp adapter ([#5412](https://github.com/AstrBotDevs/AstrBot/issues/5412)). +- Fixed File component handling and improved path compatibility in the OneBot driver layer ([#5391](https://github.com/AstrBotDevs/AstrBot/issues/5391)). +- Fixed missing admin guard for sandbox file transfer tools ([#5402](https://github.com/AstrBotDevs/AstrBot/issues/5402)). +- Fixed circular import issues related to pipeline and `from ... import *` usage ([#5353](https://github.com/AstrBotDevs/AstrBot/issues/5353)). +- Fixed config parsing issues when files contain UTF-8 BOM ([#5376](https://github.com/AstrBotDevs/AstrBot/issues/5376)). +- Fixed missing copy rollback path and unclear error messaging in ChatUI ([#5352](https://github.com/AstrBotDevs/AstrBot/issues/5352)). +- Fixed reserved plugin directory handling to avoid abnormal plugin path behavior ([#5369](https://github.com/AstrBotDevs/AstrBot/issues/5369)). +- Fixed ChatUI file segment persistence issues ([#5386](https://github.com/AstrBotDevs/AstrBot/issues/5386)). +- Fixed accidental exclusion of the `changelogs` directory in `.dockerignore`. +- Fixed compatibility issues caused by a hard-coded 6-second timeout in Tavily requests. +- Fixed qq-botpy runtime errors caused by overly new aiohttp versions ([#5316](https://github.com/AstrBotDevs/AstrBot/issues/5316)). + +### Improvements +- Completed internationalization for the sub-agent orchestration page ([#5400](https://github.com/AstrBotDevs/AstrBot/issues/5400)). +- Added broader message-event test coverage and improved fixtures/mocks in the test framework ([#5355](https://github.com/AstrBotDevs/AstrBot/issues/5355), [#5354](https://github.com/AstrBotDevs/AstrBot/issues/5354)). +- Updated README content and applied repository-wide formatting cleanup (ruff format) ([#5375](https://github.com/AstrBotDevs/AstrBot/issues/5375)). diff --git a/changelogs/v4.18.3.md b/changelogs/v4.18.3.md new file mode 100644 index 000000000..e2b426b4c --- /dev/null +++ b/changelogs/v4.18.3.md @@ -0,0 +1,49 @@ +## What's Changed + +### 新增 + +- 新增桌面端通用更新桥接能力,便于接入客户端内更新流程 ([#5424](https://github.com/AstrBotDevs/AstrBot/issues/5424))。 + +### 修复 + +- 修复新增平台对话框中 Line 适配器未显示的问题。 +- 修复 Telegram 无法发送 Video 的问题 ([#5430](https://github.com/AstrBotDevs/AstrBot/issues/5430))。 +- 修复创建 embedding provider 时无法自动识别向量维度的问题 ([#5442](https://github.com/AstrBotDevs/AstrBot/issues/5442))。 +- 修复 QQ 官方平台发送媒体消息时 markdown 字段未清理的问题 ([#5445](https://github.com/AstrBotDevs/AstrBot/issues/5445))。 +- 修复上下文管理策略 -> 上下文截断时 tool call / response 配对丢失的问题 ([#5417](https://github.com/AstrBotDevs/AstrBot/issues/5417))。 +- 修复会话更新时 `persona_id` 被覆盖的问题,并增强 persona 解析逻辑。 +- 修复 WebUI 中 GitHub 代理地址显示异常的问题 ([#5438](https://github.com/AstrBotDevs/AstrBot/issues/5438))。 +- 修复设置页新建开发者 API Key 后复制失败的问题 ([#5439](https://github.com/AstrBotDevs/AstrBot/issues/5439))。 +- 修复 Telegram 语音消息格式与 OpenAI STT 兼容性问题(使用 OGG) ([#5389](https://github.com/AstrBotDevs/AstrBot/issues/5389))。 + +### 优化 + +- 优化知识库检索流程,改为批量查询元数据,修复 N+1 查询性能问题 ([#5463](https://github.com/AstrBotDevs/AstrBot/issues/5463))。 +- 优化 Cron 未来任务执行的会话隔离能力,提升并发稳定性。 +- 优化 WebUI 插件页的交互。 + +## What's Changed (EN) + +### New Features + +- Added `useExtensionPage` composable for unified plugin extension page state management. +- Added a generic desktop app updater bridge to support in-app update workflows ([#5424](https://github.com/AstrBotDevs/AstrBot/issues/5424)). + +### Bug Fixes + +- Fixed the Line adapter not appearing in the "Add Platform" dialog. +- Fixed Telegram video sending issues ([#5430](https://github.com/AstrBotDevs/AstrBot/issues/5430)). +- Fixed Pyright static type checking errors ([#5437](https://github.com/AstrBotDevs/AstrBot/issues/5437)). +- Fixed embedding dimension auto-detection when creating embedding providers ([#5442](https://github.com/AstrBotDevs/AstrBot/issues/5442)). +- Fixed stale markdown fields when sending media messages via QQ Official Platform ([#5445](https://github.com/AstrBotDevs/AstrBot/issues/5445)). +- Fixed tool call/response pairing loss during context truncation ([#5417](https://github.com/AstrBotDevs/AstrBot/issues/5417)). +- Fixed `persona_id` being overwritten during conversation updates and improved persona resolution logic. +- Fixed incorrect GitHub proxy display in WebUI ([#5438](https://github.com/AstrBotDevs/AstrBot/issues/5438)). +- Fixed API key copy failure after creating a new key in settings ([#5439](https://github.com/AstrBotDevs/AstrBot/issues/5439)). +- Fixed Telegram voice format compatibility with OpenAI STT by using OGG ([#5389](https://github.com/AstrBotDevs/AstrBot/issues/5389)). + +### Improvements + +- Improved knowledge base retrieval by batching metadata queries to eliminate the N+1 query pattern ([#5463](https://github.com/AstrBotDevs/AstrBot/issues/5463)). +- Improved session isolation for future cron tasks to increase stability under concurrency. +- Improved WebUI plugin page interactions. \ No newline at end of file diff --git a/dashboard/README.md b/dashboard/README.md index 52df63351..0cdcae80c 100644 --- a/dashboard/README.md +++ b/dashboard/README.md @@ -1,3 +1,10 @@ # AstrBot 管理面板 -基于 CodedThemes/Berry 模板开发。 \ No newline at end of file +基于 CodedThemes/Berry 模板开发。 + +## 环境变量 + +- `VITE_ASTRBOT_RELEASE_BASE_URL`(可选) + - 默认值:`https://github.com/AstrBotDevs/AstrBot/releases` + - 用途:管理面板内“更新到最新版本”外部跳转所使用的 release 基地址。集成方可按需覆盖(例如 Desktop 指向其自身发布页)。 + - 建议传入仓库的 `.../releases` 基地址(不带 `/latest`)。 diff --git a/dashboard/env.d.ts b/dashboard/env.d.ts index fc812394b..b4b350830 100644 --- a/dashboard/env.d.ts +++ b/dashboard/env.d.ts @@ -1,7 +1,9 @@ /// -declare module "*.vue" { - import type { DefineComponent } from "vue"; - const component: DefineComponent<{}, {}, any>; - export default component; +interface ImportMetaEnv { + readonly VITE_ASTRBOT_RELEASE_BASE_URL?: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; } diff --git a/dashboard/pnpm-lock.yaml b/dashboard/pnpm-lock.yaml new file mode 100644 index 000000000..ea8636c61 --- /dev/null +++ b/dashboard/pnpm-lock.yaml @@ -0,0 +1,5491 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@guolao/vue-monaco-editor': + specifier: ^1.5.4 + version: 1.6.0(monaco-editor@0.52.2)(vue@3.3.4) + '@tiptap/starter-kit': + specifier: 2.1.7 + version: 2.1.7(@tiptap/pm@2.27.2) + '@tiptap/vue-3': + specifier: 2.1.7 + version: 2.1.7(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))(@tiptap/pm@2.27.2)(vue@3.3.4) + apexcharts: + specifier: 3.42.0 + version: 3.42.0 + axios: + specifier: '>=1.6.2 <1.10.0 || >1.10.0 <2.0.0' + version: 1.13.4 + axios-mock-adapter: + specifier: ^1.22.0 + version: 1.22.0(axios@1.13.4) + chance: + specifier: 1.1.11 + version: 1.1.11 + date-fns: + specifier: 2.30.0 + version: 2.30.0 + dompurify: + specifier: ^3.3.1 + version: 3.3.1 + event-source-polyfill: + specifier: ^1.0.31 + version: 1.0.31 + highlight.js: + specifier: ^11.11.1 + version: 11.11.1 + js-md5: + specifier: ^0.8.3 + version: 0.8.3 + katex: + specifier: ^0.16.27 + version: 0.16.28 + lodash: + specifier: 4.17.21 + version: 4.17.21 + markdown-it: + specifier: ^14.1.0 + version: 14.1.0 + markstream-vue: + specifier: ^0.0.6 + version: 0.0.6(katex@0.16.28)(mermaid@11.12.2)(shiki@3.22.0)(stream-markdown@0.0.13(shiki@3.22.0))(stream-monaco@0.0.17(monaco-editor@0.52.2))(vue-i18n@11.2.8(vue@3.3.4))(vue@3.3.4) + mermaid: + specifier: ^11.12.2 + version: 11.12.2 + monaco-editor: + specifier: ^0.52.2 + version: 0.52.2 + pinia: + specifier: 2.1.6 + version: 2.1.6(typescript@5.1.6)(vue@3.3.4) + pinyin-pro: + specifier: ^3.26.0 + version: 3.28.0 + remixicon: + specifier: 3.5.0 + version: 3.5.0 + shiki: + specifier: ^3.20.0 + version: 3.22.0 + stream-markdown: + specifier: ^0.0.13 + version: 0.0.13(shiki@3.22.0) + stream-monaco: + specifier: ^0.0.17 + version: 0.0.17(monaco-editor@0.52.2) + vee-validate: + specifier: 4.11.3 + version: 4.11.3(vue@3.3.4) + vite-plugin-vuetify: + specifier: 1.0.2 + version: 1.0.2(vite@4.4.9(@types/node@20.19.32)(sass@1.66.1)(terser@5.46.0))(vue@3.3.4)(vuetify@3.7.11) + vue: + specifier: 3.3.4 + version: 3.3.4 + vue-i18n: + specifier: ^11.1.5 + version: 11.2.8(vue@3.3.4) + vue-router: + specifier: 4.2.4 + version: 4.2.4(vue@3.3.4) + vue3-apexcharts: + specifier: 1.4.4 + version: 1.4.4(apexcharts@3.42.0)(vue@3.3.4) + vue3-print-nb: + specifier: 0.1.4 + version: 0.1.4 + vuetify: + specifier: 3.7.11 + version: 3.7.11(typescript@5.1.6)(vite-plugin-vuetify@1.0.2)(vue@3.3.4) + yup: + specifier: 1.2.0 + version: 1.2.0 + devDependencies: + '@mdi/font': + specifier: 7.2.96 + version: 7.2.96 + '@rushstack/eslint-patch': + specifier: 1.3.3 + version: 1.3.3 + '@types/chance': + specifier: 1.1.3 + version: 1.1.3 + '@types/dompurify': + specifier: ^3.0.5 + version: 3.2.0 + '@types/markdown-it': + specifier: ^14.1.2 + version: 14.1.2 + '@types/node': + specifier: ^20.5.7 + version: 20.19.32 + '@vitejs/plugin-vue': + specifier: 4.3.3 + version: 4.3.3(vite@4.4.9(@types/node@20.19.32)(sass@1.66.1)(terser@5.46.0))(vue@3.3.4) + '@vue/eslint-config-prettier': + specifier: 8.0.0 + version: 8.0.0(@types/eslint@9.6.1)(eslint@8.48.0)(prettier@3.0.2) + '@vue/eslint-config-typescript': + specifier: 11.0.3 + version: 11.0.3(eslint-plugin-vue@9.17.0(eslint@8.48.0))(eslint@8.48.0)(typescript@5.1.6) + '@vue/tsconfig': + specifier: ^0.4.0 + version: 0.4.0 + eslint: + specifier: 8.48.0 + version: 8.48.0 + eslint-plugin-vue: + specifier: 9.17.0 + version: 9.17.0(eslint@8.48.0) + prettier: + specifier: 3.0.2 + version: 3.0.2 + sass: + specifier: 1.66.1 + version: 1.66.1 + sass-loader: + specifier: 13.3.2 + version: 13.3.2(sass@1.66.1)(webpack@5.105.0) + typescript: + specifier: 5.1.6 + version: 5.1.6 + vite: + specifier: 4.4.9 + version: 4.4.9(@types/node@20.19.32)(sass@1.66.1)(terser@5.46.0) + vue-cli-plugin-vuetify: + specifier: 2.5.8 + version: 2.5.8(sass-loader@13.3.2(sass@1.66.1)(webpack@5.105.0))(vue@3.3.4)(vuetify-loader@2.0.0-alpha.9(@vue/compiler-sfc@3.3.4)(vue@3.3.4)(vuetify@3.7.11)(webpack@5.105.0))(webpack@5.105.0) + vue-tsc: + specifier: 1.8.8 + version: 1.8.8(typescript@5.1.6) + vuetify-loader: + specifier: ^2.0.0-alpha.9 + version: 2.0.0-alpha.9(@vue/compiler-sfc@3.3.4)(vue@3.3.4)(vuetify@3.7.11)(webpack@5.105.0) + +packages: + + '@antfu/install-pkg@1.1.0': + resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.0': + resolution: {integrity: sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/runtime@7.28.6': + resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + + '@braintree/sanitize-url@7.1.2': + resolution: {integrity: sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==} + + '@chevrotain/cst-dts-gen@11.0.3': + resolution: {integrity: sha512-BvIKpRLeS/8UbfxXxgC33xOumsacaeCKAjAeLyOn7Pcp95HiRbrpl14S+9vaZLolnbssPIUuiUd8IvgkRyt6NQ==} + + '@chevrotain/gast@11.0.3': + resolution: {integrity: sha512-+qNfcoNk70PyS/uxmj3li5NiECO+2YKZZQMbmjTqRI3Qchu8Hig/Q9vgkHpI3alNjr7M+a2St5pw5w5F6NL5/Q==} + + '@chevrotain/regexp-to-ast@11.0.3': + resolution: {integrity: sha512-1fMHaBZxLFvWI067AVbGJav1eRY7N8DDvYCTwGBiE/ytKBgP8azTdgyrKyWZ9Mfh09eHWb5PgTSO8wi7U824RA==} + + '@chevrotain/types@11.0.3': + resolution: {integrity: sha512-gsiM3G8b58kZC2HaWR50gu6Y1440cHiJ+i3JUvcp/35JchYejb2+5MVeJK0iKThYpAa/P2PYFV4hoi44HD+aHQ==} + + '@chevrotain/utils@11.0.3': + resolution: {integrity: sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ==} + + '@esbuild/android-arm64@0.18.20': + resolution: {integrity: sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.18.20': + resolution: {integrity: sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.18.20': + resolution: {integrity: sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.18.20': + resolution: {integrity: sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.18.20': + resolution: {integrity: sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.18.20': + resolution: {integrity: sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.18.20': + resolution: {integrity: sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.18.20': + resolution: {integrity: sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.18.20': + resolution: {integrity: sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.18.20': + resolution: {integrity: sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.18.20': + resolution: {integrity: sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.18.20': + resolution: {integrity: sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.18.20': + resolution: {integrity: sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.18.20': + resolution: {integrity: sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.18.20': + resolution: {integrity: sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.18.20': + resolution: {integrity: sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-x64@0.18.20': + resolution: {integrity: sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-x64@0.18.20': + resolution: {integrity: sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/sunos-x64@0.18.20': + resolution: {integrity: sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.18.20': + resolution: {integrity: sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.18.20': + resolution: {integrity: sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.18.20': + resolution: {integrity: sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/eslintrc@2.1.4': + resolution: {integrity: sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + '@eslint/js@8.48.0': + resolution: {integrity: sha512-ZSjtmelB7IJfWD2Fvb7+Z+ChTIKWq6kjda95fLcQKNS5aheVHn4IkfgRQE3sIIzTcSLwLcLZUD9UBt+V7+h+Pw==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + '@floating-ui/core@1.7.4': + resolution: {integrity: sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==} + + '@floating-ui/dom@1.7.5': + resolution: {integrity: sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==} + + '@floating-ui/utils@0.2.10': + resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} + + '@guolao/vue-monaco-editor@1.6.0': + resolution: {integrity: sha512-w2IiJ6eJGGeuIgCK6EKZOAfhHTTUB5aZwslzwGbZ5e89Hb4avx6++GkLTW8p84Sng/arFMjLPPxSBI56cFudyQ==} + peerDependencies: + '@vue/composition-api': ^1.7.2 + monaco-editor: '>=0.43.0' + vue: ^2.6.14 || >=3.0.0 + peerDependenciesMeta: + '@vue/composition-api': + optional: true + + '@humanwhocodes/config-array@0.11.14': + resolution: {integrity: sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==} + engines: {node: '>=10.10.0'} + deprecated: Use @eslint/config-array instead + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/object-schema@2.0.3': + resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==} + deprecated: Use @eslint/object-schema instead + + '@iconify/types@2.0.0': + resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} + + '@iconify/utils@3.1.0': + resolution: {integrity: sha512-Zlzem1ZXhI1iHeeERabLNzBHdOa4VhQbqAcOQaMKuTuyZCpwKbC2R4Dd0Zo3g9EAc+Y4fiarO8HIHRAth7+skw==} + + '@intlify/core-base@11.2.8': + resolution: {integrity: sha512-nBq6Y1tVkjIUsLsdOjDSJj4AsjvD0UG3zsg9Fyc+OivwlA/oMHSKooUy9tpKj0HqZ+NWFifweHavdljlBLTwdA==} + engines: {node: '>= 16'} + + '@intlify/message-compiler@11.2.8': + resolution: {integrity: sha512-A5n33doOjmHsBtCN421386cG1tWp5rpOjOYPNsnpjIJbQ4POF0QY2ezhZR9kr0boKwaHjbOifvyQvHj2UTrDFQ==} + engines: {node: '>= 16'} + + '@intlify/shared@11.2.8': + resolution: {integrity: sha512-l6e4NZyUgv8VyXXH4DbuucFOBmxLF56C/mqh2tvApbzl2Hrhi1aTDcuv5TKdxzfHYmpO3UB0Cz04fgDT9vszfw==} + engines: {node: '>= 16'} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/source-map@0.3.11': + resolution: {integrity: sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@mdi/font@7.2.96': + resolution: {integrity: sha512-e//lmkmpFUMZKhmCY9zdjRe4zNXfbOIJnn6xveHbaV2kSw5aJ5dLXUxcRt1Gxfi7ZYpFLUWlkG2MGSFAiqAu7w==} + + '@mermaid-js/parser@0.6.3': + resolution: {integrity: sha512-lnjOhe7zyHjc+If7yT4zoedx2vo4sHaTmtkl1+or8BRTnCtDmcTpAjpzDSfCZrshM5bCoz0GyidzadJAH1xobA==} + + '@monaco-editor/loader@1.7.0': + resolution: {integrity: sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==} + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@pkgr/core@0.2.9': + resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} + engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + + '@popperjs/core@2.11.8': + resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==} + + '@remirror/core-constants@3.0.0': + resolution: {integrity: sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==} + + '@rushstack/eslint-patch@1.3.3': + resolution: {integrity: sha512-0xd7qez0AQ+MbHatZTlI1gu5vkG8r7MYRUJAHPAHJBmGLs16zpkrpAVLvjQKQOqaXPDUBwOiJzNc00znHSCVBw==} + + '@shikijs/core@3.22.0': + resolution: {integrity: sha512-iAlTtSDDbJiRpvgL5ugKEATDtHdUVkqgHDm/gbD2ZS9c88mx7G1zSYjjOxp5Qa0eaW0MAQosFRmJSk354PRoQA==} + + '@shikijs/engine-javascript@3.22.0': + resolution: {integrity: sha512-jdKhfgW9CRtj3Tor0L7+yPwdG3CgP7W+ZEqSsojrMzCjD1e0IxIbwUMDDpYlVBlC08TACg4puwFGkZfLS+56Tw==} + + '@shikijs/engine-oniguruma@3.22.0': + resolution: {integrity: sha512-DyXsOG0vGtNtl7ygvabHd7Mt5EY8gCNqR9Y7Lpbbd/PbJvgWrqaKzH1JW6H6qFkuUa8aCxoiYVv8/YfFljiQxA==} + + '@shikijs/langs@3.22.0': + resolution: {integrity: sha512-x/42TfhWmp6H00T6uwVrdTJGKgNdFbrEdhaDwSR5fd5zhQ1Q46bHq9EO61SCEWJR0HY7z2HNDMaBZp8JRmKiIA==} + + '@shikijs/monaco@3.22.0': + resolution: {integrity: sha512-4Bi/Gr5+ZVGmILq4ksyWtNbylfHxYB0BDMLR76UsaKOkWNJ/1w+c2s7bIjYnBNydyLVRlp28qntqEvB9EaJu3g==} + + '@shikijs/themes@3.22.0': + resolution: {integrity: sha512-o+tlOKqsr6FE4+mYJG08tfCFDS+3CG20HbldXeVoyP+cYSUxDhrFf3GPjE60U55iOkkjbpY2uC3It/eeja35/g==} + + '@shikijs/types@3.22.0': + resolution: {integrity: sha512-491iAekgKDBFE67z70Ok5a8KBMsQ2IJwOWw3us/7ffQkIBCyOQfm/aNwVMBUriP02QshIfgHCBSIYAl3u2eWjg==} + + '@shikijs/vscode-textmate@10.0.2': + resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + + '@tiptap/core@2.27.2': + resolution: {integrity: sha512-ABL1N6eoxzDzC1bYvkMbvyexHacszsKdVPYqhl5GwHLOvpZcv9VE9QaKwDILTyz5voCA0lGcAAXZp+qnXOk5lQ==} + peerDependencies: + '@tiptap/pm': ^2.7.0 + + '@tiptap/extension-blockquote@2.27.2': + resolution: {integrity: sha512-oIGZgiAeA4tG3YxbTDfrmENL4/CIwGuP3THtHsNhwRqwsl9SfMk58Ucopi2GXTQSdYXpRJ0ahE6nPqB5D6j/Zw==} + peerDependencies: + '@tiptap/core': ^2.7.0 + + '@tiptap/extension-bold@2.27.2': + resolution: {integrity: sha512-bR7J5IwjCGQ0s3CIxyMvOCnMFMzIvsc5OVZKscTN5UkXzFsaY6muUAIqtKxayBUucjtUskm5qZowJITCeCb1/A==} + peerDependencies: + '@tiptap/core': ^2.7.0 + + '@tiptap/extension-bubble-menu@2.27.2': + resolution: {integrity: sha512-VkwlCOcr0abTBGzjPXklJ92FCowG7InU8+Od9FyApdLNmn0utRYGRhw0Zno6VgE9EYr1JY4BRnuSa5f9wlR72w==} + peerDependencies: + '@tiptap/core': ^2.7.0 + '@tiptap/pm': ^2.7.0 + + '@tiptap/extension-bullet-list@2.27.2': + resolution: {integrity: sha512-gmFuKi97u5f8uFc/GQs+zmezjiulZmFiDYTh3trVoLRoc2SAHOjGEB7qxdx7dsqmMN7gwiAWAEVurLKIi1lnnw==} + peerDependencies: + '@tiptap/core': ^2.7.0 + + '@tiptap/extension-code-block@2.27.2': + resolution: {integrity: sha512-KgvdQHS4jXr79aU3wZOGBIZYYl9vCB7uDEuRFV4so2rYrfmiYMw3T8bTnlNEEGe4RUeAms1i4fdwwvQp9nR1Dw==} + peerDependencies: + '@tiptap/core': ^2.7.0 + '@tiptap/pm': ^2.7.0 + + '@tiptap/extension-code@2.27.2': + resolution: {integrity: sha512-7X9AgwqiIGXoZX7uvdHQsGsjILnN/JaEVtqfXZnPECzKGaWHeK/Ao4sYvIIIffsyZJA8k5DC7ny2/0sAgr2TuA==} + peerDependencies: + '@tiptap/core': ^2.7.0 + + '@tiptap/extension-document@2.27.2': + resolution: {integrity: sha512-CFhAYsPnyYnosDC4639sCJnBUnYH4Cat9qH5NZWHVvdgtDwu8GZgZn2eSzaKSYXWH1vJ9DSlCK+7UyC3SNXIBA==} + peerDependencies: + '@tiptap/core': ^2.7.0 + + '@tiptap/extension-dropcursor@2.27.2': + resolution: {integrity: sha512-oEu/OrktNoQXq1x29NnH/GOIzQZm8ieTQl3FK27nxfBPA89cNoH4mFEUmBL5/OFIENIjiYG3qWpg6voIqzswNw==} + peerDependencies: + '@tiptap/core': ^2.7.0 + '@tiptap/pm': ^2.7.0 + + '@tiptap/extension-floating-menu@2.27.2': + resolution: {integrity: sha512-GUN6gPIGXS7ngRJOwdSmtBRBDt9Kt9CM/9pSwKebhLJ+honFoNA+Y6IpVyDvvDMdVNgBchiJLs6qA5H97gAePQ==} + peerDependencies: + '@tiptap/core': ^2.7.0 + '@tiptap/pm': ^2.7.0 + + '@tiptap/extension-gapcursor@2.27.2': + resolution: {integrity: sha512-/c9VF1HBxj+AP54XGVgCmD9bEGYc5w5OofYCFQgM7l7PB1J00A4vOke0oPkHJnqnOOyPlFaxO/7N6l3XwFcnKA==} + peerDependencies: + '@tiptap/core': ^2.7.0 + '@tiptap/pm': ^2.7.0 + + '@tiptap/extension-hard-break@2.27.2': + resolution: {integrity: sha512-kSRVGKlCYK6AGR0h8xRkk0WOFGXHIIndod3GKgWU49APuIGDiXd8sziXsSlniUsWmqgDmDXcNnSzPcV7AQ8YNg==} + peerDependencies: + '@tiptap/core': ^2.7.0 + + '@tiptap/extension-heading@2.27.2': + resolution: {integrity: sha512-iM3yeRWuuQR/IRQ1djwNooJGfn9Jts9zF43qZIUf+U2NY8IlvdNsk2wTOdBgh6E0CamrStPxYGuln3ZS4fuglw==} + peerDependencies: + '@tiptap/core': ^2.7.0 + + '@tiptap/extension-history@2.27.2': + resolution: {integrity: sha512-+hSyqERoFNTWPiZx4/FCyZ/0eFqB9fuMdTB4AC/q9iwu3RNWAQtlsJg5230bf/qmyO6bZxRUc0k8p4hrV6ybAw==} + peerDependencies: + '@tiptap/core': ^2.7.0 + '@tiptap/pm': ^2.7.0 + + '@tiptap/extension-horizontal-rule@2.27.2': + resolution: {integrity: sha512-WGWUSgX+jCsbtf9Y9OCUUgRZYuwjVoieW5n6mAUohJ9/6gc6sGIOrUpBShf+HHo6WD+gtQjRd+PssmX3NPWMpg==} + peerDependencies: + '@tiptap/core': ^2.7.0 + '@tiptap/pm': ^2.7.0 + + '@tiptap/extension-italic@2.27.2': + resolution: {integrity: sha512-1OFsw2SZqfaqx5Fa5v90iNlPRcqyt+lVSjBwTDzuPxTPFY4Q0mL89mKgkq2gVHYNCiaRkXvFLDxaSvBWbmthgg==} + peerDependencies: + '@tiptap/core': ^2.7.0 + + '@tiptap/extension-list-item@2.27.2': + resolution: {integrity: sha512-eJNee7IEGXMnmygM5SdMGDC8m/lMWmwNGf9fPCK6xk0NxuQRgmZHL6uApKcdH6gyNcRPHCqvTTkhEP7pbny/fg==} + peerDependencies: + '@tiptap/core': ^2.7.0 + + '@tiptap/extension-ordered-list@2.27.2': + resolution: {integrity: sha512-M7A4tLGJcLPYdLC4CI2Gwl8LOrENQW59u3cMVa+KkwG1hzSJyPsbDpa1DI6oXPC2WtYiTf22zrbq3gVvH+KA2w==} + peerDependencies: + '@tiptap/core': ^2.7.0 + + '@tiptap/extension-paragraph@2.27.2': + resolution: {integrity: sha512-elYVn2wHJJ+zB9LESENWOAfI4TNT0jqEN34sMA/hCtA4im1ZG2DdLHwkHIshj/c4H0dzQhmsS/YmNC5Vbqab/A==} + peerDependencies: + '@tiptap/core': ^2.7.0 + + '@tiptap/extension-strike@2.27.2': + resolution: {integrity: sha512-HHIjhafLhS2lHgfAsCwC1okqMsQzR4/mkGDm4M583Yftyjri1TNA7lzhzXWRFWiiMfJxKtdjHjUAQaHuteRTZw==} + peerDependencies: + '@tiptap/core': ^2.7.0 + + '@tiptap/extension-text@2.27.2': + resolution: {integrity: sha512-Xk7nYcigljAY0GO9hAQpZ65ZCxqOqaAlTPDFcKerXmlkQZP/8ndx95OgUb1Xf63kmPOh3xypurGS2is3v0MXSA==} + peerDependencies: + '@tiptap/core': ^2.7.0 + + '@tiptap/pm@2.27.2': + resolution: {integrity: sha512-kaEg7BfiJPDQMKbjVIzEPO3wlcA+pZb2tlcK9gPrdDnEFaec2QTF1sXz2ak2IIb2curvnIrQ4yrfHgLlVA72wA==} + + '@tiptap/starter-kit@2.1.7': + resolution: {integrity: sha512-z2cmJRSC7ImaTGWrHv+xws9y1wIG0OCPosBYpmpwlEfA3JG3axWFmVRJlWnsQV4eSMi3QY3vaPgBAnrR4IxRhQ==} + + '@tiptap/vue-3@2.1.7': + resolution: {integrity: sha512-JJRXWKLJ8mopb0uZV4JXyOW6vKQnarYoCj0hsy9ZT2LhSuLlPXY0D40NAbFVMMbQssewUtgPUFgVZ/TusMEysQ==} + peerDependencies: + '@tiptap/core': ^2.0.0 + '@tiptap/pm': ^2.0.0 + vue: ^3.0.0 + + '@types/chance@1.1.3': + resolution: {integrity: sha512-X6c6ghhe4/sQh4XzcZWSFaTAUOda38GQHmq9BUanYkOE/EO7ZrkazwKmtsj3xzTjkLWmwULE++23g3d3CCWaWw==} + + '@types/d3-array@3.2.2': + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} + + '@types/d3-axis@3.0.6': + resolution: {integrity: sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==} + + '@types/d3-brush@3.0.6': + resolution: {integrity: sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==} + + '@types/d3-chord@3.0.6': + resolution: {integrity: sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==} + + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-contour@3.0.6': + resolution: {integrity: sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==} + + '@types/d3-delaunay@6.0.4': + resolution: {integrity: sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==} + + '@types/d3-dispatch@3.0.7': + resolution: {integrity: sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==} + + '@types/d3-drag@3.0.7': + resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==} + + '@types/d3-dsv@3.0.7': + resolution: {integrity: sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==} + + '@types/d3-ease@3.0.2': + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + + '@types/d3-fetch@3.0.7': + resolution: {integrity: sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==} + + '@types/d3-force@3.0.10': + resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==} + + '@types/d3-format@3.0.4': + resolution: {integrity: sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==} + + '@types/d3-geo@3.1.0': + resolution: {integrity: sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==} + + '@types/d3-hierarchy@3.1.7': + resolution: {integrity: sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + + '@types/d3-polygon@3.0.2': + resolution: {integrity: sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==} + + '@types/d3-quadtree@3.0.6': + resolution: {integrity: sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==} + + '@types/d3-random@3.0.3': + resolution: {integrity: sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==} + + '@types/d3-scale-chromatic@3.1.0': + resolution: {integrity: sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==} + + '@types/d3-scale@4.0.9': + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + + '@types/d3-selection@3.0.11': + resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==} + + '@types/d3-shape@3.1.8': + resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==} + + '@types/d3-time-format@4.0.3': + resolution: {integrity: sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==} + + '@types/d3-time@3.0.4': + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} + + '@types/d3-timer@3.0.2': + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + + '@types/d3-transition@3.0.9': + resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==} + + '@types/d3-zoom@3.0.8': + resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==} + + '@types/d3@7.4.3': + resolution: {integrity: sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==} + + '@types/dompurify@3.2.0': + resolution: {integrity: sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg==} + deprecated: This is a stub types definition. dompurify provides its own type definitions, so you do not need this installed. + + '@types/eslint-scope@3.7.7': + resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==} + + '@types/eslint@9.6.1': + resolution: {integrity: sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/geojson@7946.0.16': + resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} + + '@types/hast@3.0.4': + resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/linkify-it@5.0.0': + resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==} + + '@types/markdown-it@14.1.2': + resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==} + + '@types/mdast@4.0.4': + resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + + '@types/mdurl@2.0.0': + resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==} + + '@types/node@20.19.32': + resolution: {integrity: sha512-Ez8QE4DMfhjjTsES9K2dwfV258qBui7qxUsoaixZDiTzbde4U12e1pXGNu/ECsUIOi5/zoCxAQxIhQnaUQ2VvA==} + + '@types/semver@7.7.1': + resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==} + + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + + '@types/unist@3.0.3': + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + + '@typescript-eslint/eslint-plugin@5.62.0': + resolution: {integrity: sha512-TiZzBSJja/LbhNPvk6yc0JrX9XqhQ0hdh6M2svYfsHGejaKFIAGd9MQ+ERIMzLGlN/kZoYIgdxFV0PuljTKXag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + '@typescript-eslint/parser': ^5.0.0 + eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/parser@5.62.0': + resolution: {integrity: sha512-VlJEV0fOQ7BExOsHYAGrgbEiZoi8D+Bl2+f6V2RrXerRSylnp+ZBHmPvaIa8cz0Ajx7WO7Z5RqfgYg7ED1nRhA==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/scope-manager@5.62.0': + resolution: {integrity: sha512-VXuvVvZeQCQb5Zgf4HAxc04q5j+WrNAtNh9OwCsCgpKqESMTu3tF/jhZ3xG6T4NZwWl65Bg8KuS2uEvhSfLl0w==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + '@typescript-eslint/type-utils@5.62.0': + resolution: {integrity: sha512-xsSQreu+VnfbqQpW5vnCJdq1Z3Q0U31qiWmRhr98ONQmcp/yhiPJFPq8MXiJVLiksmOKSjIldZzkebzHuCGzew==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: '*' + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/types@5.62.0': + resolution: {integrity: sha512-87NVngcbVXUahrRTqIK27gD2t5Cu1yuCXxbLcFtCzZGlfyVWWh8mLHkoxzjsB6DDNnvdL+fW8MiwPEJyGJQDgQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + '@typescript-eslint/typescript-estree@5.62.0': + resolution: {integrity: sha512-CmcQ6uY7b9y694lKdRB8FEel7JbU/40iSAPomu++SjLMntB+2Leay2LO6i8VnJk58MtE9/nQSFIH6jpyRWyYzA==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/utils@5.62.0': + resolution: {integrity: sha512-n8oxjeb5aIbPFEtmQxQYOLI0i9n5ySBEY/ZEHHZqKQSFnxio1rv6dthascc9dLuwrL0RC5mPCxB7vnAVGAYWAQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 + + '@typescript-eslint/visitor-keys@5.62.0': + resolution: {integrity: sha512-07ny+LHRzQXepkGg6w0mFY41fVUNBrL2Roj/++7V1txKugfjm/Ci/qSND03r2RhlJhJYMcTn9AhhSSqQp0Ysyw==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + '@ungap/structured-clone@1.3.0': + resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + + '@vitejs/plugin-vue@4.3.3': + resolution: {integrity: sha512-ssxyhIAZqB0TrpUg6R0cBpCuMk9jTIlO1GNSKKQD6S8VjnXi6JXKfUXjSsxey9IwQiaRGsO1WnW9Rkl1L6AJVw==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + vite: ^4.0.0 + vue: ^3.2.25 + + '@volar/language-core@1.10.10': + resolution: {integrity: sha512-nsV1o3AZ5n5jaEAObrS3MWLBWaGwUj/vAsc15FVNIv+DbpizQRISg9wzygsHBr56ELRH8r4K75vkYNMtsSNNWw==} + + '@volar/source-map@1.10.10': + resolution: {integrity: sha512-GVKjLnifV4voJ9F0vhP56p4+F3WGf+gXlRtjFZsv6v3WxBTWU3ZVeaRaEHJmWrcv5LXmoYYpk/SC25BKemPRkg==} + + '@volar/typescript@1.10.10': + resolution: {integrity: sha512-4a2r5bdUub2m+mYVnLu2wt59fuoYWe7nf0uXtGHU8QQ5LDNfzAR0wK7NgDiQ9rcl2WT3fxT2AA9AylAwFtj50A==} + + '@vue/compiler-core@3.3.4': + resolution: {integrity: sha512-cquyDNvZ6jTbf/+x+AgM2Arrp6G4Dzbb0R64jiG804HRMfRiFXWI6kqUVqZ6ZR0bQhIoQjB4+2bhNtVwndW15g==} + + '@vue/compiler-core@3.5.27': + resolution: {integrity: sha512-gnSBQjZA+//qDZen+6a2EdHqJ68Z7uybrMf3SPjEGgG4dicklwDVmMC1AeIHxtLVPT7sn6sH1KOO+tS6gwOUeQ==} + + '@vue/compiler-dom@3.3.4': + resolution: {integrity: sha512-wyM+OjOVpuUukIq6p5+nwHYtj9cFroz9cwkfmP9O1nzH68BenTTv0u7/ndggT8cIQlnBeOo6sUT/gvHcIkLA5w==} + + '@vue/compiler-dom@3.5.27': + resolution: {integrity: sha512-oAFea8dZgCtVVVTEC7fv3T5CbZW9BxpFzGGxC79xakTr6ooeEqmRuvQydIiDAkglZEAd09LgVf1RoDnL54fu5w==} + + '@vue/compiler-sfc@3.3.4': + resolution: {integrity: sha512-6y/d8uw+5TkCuzBkgLS0v3lSM3hJDntFEiUORM11pQ/hKvkhSKZrXW6i69UyXlJQisJxuUEJKAWEqWbWsLeNKQ==} + + '@vue/compiler-ssr@3.3.4': + resolution: {integrity: sha512-m0v6oKpup2nMSehwA6Uuu+j+wEwcy7QmwMkVNVfrV9P2qE5KshC6RwOCq8fjGS/Eak/uNb8AaWekfiXxbBB6gQ==} + + '@vue/devtools-api@6.6.4': + resolution: {integrity: sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==} + + '@vue/eslint-config-prettier@8.0.0': + resolution: {integrity: sha512-55dPqtC4PM/yBjhAr+yEw6+7KzzdkBuLmnhBrDfp4I48+wy+Giqqj9yUr5T2uD/BkBROjjmqnLZmXRdOx/VtQg==} + peerDependencies: + eslint: '>= 8.0.0' + prettier: '>= 3.0.0' + + '@vue/eslint-config-typescript@11.0.3': + resolution: {integrity: sha512-dkt6W0PX6H/4Xuxg/BlFj5xHvksjpSlVjtkQCpaYJBIEuKj2hOVU7r+TIe+ysCwRYFz/lGqvklntRkCAibsbPw==} + engines: {node: ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.2.0 || ^7.0.0 || ^8.0.0 + eslint-plugin-vue: ^9.0.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@vue/language-core@1.8.8': + resolution: {integrity: sha512-i4KMTuPazf48yMdYoebTkgSOJdFraE4pQf0B+FTOFkbB+6hAfjrSou/UmYWRsWyZV6r4Rc6DDZdI39CJwL0rWw==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@vue/reactivity-transform@3.3.4': + resolution: {integrity: sha512-MXgwjako4nu5WFLAjpBnCj/ieqcjE2aJBINUNQzkZQfzIZA4xn+0fV1tIYBJvvva3N3OvKGofRLvQIwEQPpaXw==} + + '@vue/reactivity@3.3.4': + resolution: {integrity: sha512-kLTDLwd0B1jG08NBF3R5rqULtv/f8x3rOFByTDz4J53ttIQEDmALqKqXY0J+XQeN0aV2FBxY8nJDf88yvOPAqQ==} + + '@vue/reactivity@3.5.27': + resolution: {integrity: sha512-vvorxn2KXfJ0nBEnj4GYshSgsyMNFnIQah/wczXlsNXt+ijhugmW+PpJ2cNPe4V6jpnBcs0MhCODKllWG+nvoQ==} + + '@vue/runtime-core@3.3.4': + resolution: {integrity: sha512-R+bqxMN6pWO7zGI4OMlmvePOdP2c93GsHFM/siJI7O2nxFRzj55pLwkpCedEY+bTMgp5miZ8CxfIZo3S+gFqvA==} + + '@vue/runtime-dom@3.3.4': + resolution: {integrity: sha512-Aj5bTJ3u5sFsUckRghsNjVTtxZQ1OyMWCr5dZRAPijF/0Vy4xEoRCwLyHXcj4D0UFbJ4lbx3gPTgg06K/GnPnQ==} + + '@vue/server-renderer@3.3.4': + resolution: {integrity: sha512-Q6jDDzR23ViIb67v+vM1Dqntu+HUexQcsWKhhQa4ARVzxOY2HbC7QRW/ggkDBd5BU+uM1sV6XOAP0b216o34JQ==} + peerDependencies: + vue: 3.3.4 + + '@vue/shared@3.3.4': + resolution: {integrity: sha512-7OjdcV8vQ74eiz1TZLzZP4JwqM5fA94K6yntPS5Z25r9HDuGNzaGdgvwKYq6S+MxwF0TFRwe50fIR/MYnakdkQ==} + + '@vue/shared@3.5.27': + resolution: {integrity: sha512-dXr/3CgqXsJkZ0n9F3I4elY8wM9jMJpP3pvRG52r6m0tu/MsAFIe6JpXVGeNMd/D9F4hQynWT8Rfuj0bdm9kFQ==} + + '@vue/tsconfig@0.4.0': + resolution: {integrity: sha512-CPuIReonid9+zOG/CGTT05FXrPYATEqoDGNrEaqS4hwcw5BUNM2FguC0mOwJD4Jr16UpRVl9N0pY3P+srIbqmg==} + + '@vue/typescript@1.8.8': + resolution: {integrity: sha512-jUnmMB6egu5wl342eaUH236v8tdcEPXXkPgj+eI/F6JwW/lb+yAU6U07ZbQ3MVabZRlupIlPESB7ajgAGixhow==} + + '@vuetify/loader-shared@1.7.1': + resolution: {integrity: sha512-kLUvuAed6RCvkeeTNJzuy14pqnkur8lTuner7v7pNE/kVhPR97TuyXwBSBMR1cJeiLiOfu6SF5XlCYbXByEx1g==} + peerDependencies: + vue: ^3.0.0 + vuetify: ^3.0.0-beta.4 + + '@webassemblyjs/ast@1.14.1': + resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==} + + '@webassemblyjs/floating-point-hex-parser@1.13.2': + resolution: {integrity: sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==} + + '@webassemblyjs/helper-api-error@1.13.2': + resolution: {integrity: sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==} + + '@webassemblyjs/helper-buffer@1.14.1': + resolution: {integrity: sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==} + + '@webassemblyjs/helper-numbers@1.13.2': + resolution: {integrity: sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==} + + '@webassemblyjs/helper-wasm-bytecode@1.13.2': + resolution: {integrity: sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==} + + '@webassemblyjs/helper-wasm-section@1.14.1': + resolution: {integrity: sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==} + + '@webassemblyjs/ieee754@1.13.2': + resolution: {integrity: sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==} + + '@webassemblyjs/leb128@1.13.2': + resolution: {integrity: sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==} + + '@webassemblyjs/utf8@1.13.2': + resolution: {integrity: sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==} + + '@webassemblyjs/wasm-edit@1.14.1': + resolution: {integrity: sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==} + + '@webassemblyjs/wasm-gen@1.14.1': + resolution: {integrity: sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==} + + '@webassemblyjs/wasm-opt@1.14.1': + resolution: {integrity: sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==} + + '@webassemblyjs/wasm-parser@1.14.1': + resolution: {integrity: sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==} + + '@webassemblyjs/wast-printer@1.14.1': + resolution: {integrity: sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==} + + '@xtuc/ieee754@1.2.0': + resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==} + + '@xtuc/long@4.2.2': + resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==} + + '@yr/monotone-cubic-spline@1.0.3': + resolution: {integrity: sha512-FQXkOta0XBSUPHndIKON2Y9JeQz5ZeMqLYZVVK93FliNBFm7LNMIZmY6FrMEB9XPcDbE2bekMbZD6kzDkxwYjA==} + + acorn-import-phases@1.0.4: + resolution: {integrity: sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==} + engines: {node: '>=10.13.0'} + peerDependencies: + acorn: ^8.14.0 + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.15.0: + resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv-formats@2.1.1: + resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv-keywords@3.5.2: + resolution: {integrity: sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==} + peerDependencies: + ajv: ^6.9.1 + + ajv-keywords@5.1.0: + resolution: {integrity: sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==} + peerDependencies: + ajv: ^8.8.2 + + ajv@6.12.6: + resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + + ajv@8.17.1: + resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} + + alien-signals@2.0.8: + resolution: {integrity: sha512-844G1VLkk0Pe2SJjY0J8vp8ADI73IM4KliNu2OGlYzWpO28NexEUvjHTcFjFX3VXoiUtwTbHxLNI9ImkcoBqzA==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + apexcharts@3.42.0: + resolution: {integrity: sha512-hYhzZqh2Efny9uiutkGU2M/EarJ4Nn8s6dxZ0C7E7N+SV4d1xjTioXi2NLn4UKVJabZkb3HnpXDoumXgtAymwg==} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + array-union@2.1.0: + resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} + engines: {node: '>=8'} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + axios-mock-adapter@1.22.0: + resolution: {integrity: sha512-dmI0KbkyAhntUR05YY96qg2H6gg0XMl2+qTW0xmYg6Up+BFBAJYRLROMXRdDEL06/Wqwa0TJThAYvFtSFdRCZw==} + peerDependencies: + axios: '>= 0.17.0' + + axios@1.13.4: + resolution: {integrity: sha512-1wVkUaAO6WyaYtCkcYCOx12ZgpGf9Zif+qXa4n+oYzK558YryKqiL6UWwd5DqiH3VRW0GYhTZQ/vlgJrCoNQlg==} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + baseline-browser-mapping@2.9.19: + resolution: {integrity: sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==} + hasBin: true + + big.js@5.2.2: + resolution: {integrity: sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==} + + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + + boolbase@1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + + brace-expansion@1.1.12: + resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} + + brace-expansion@2.0.2: + resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserslist@4.28.1: + resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + callsite@1.0.0: + resolution: {integrity: sha512-0vdNRFXn5q+dtOqjfFtmtlI9N2eVZ7LMyEV2iKC5mEEFvSg/69Ml6b/WU2qF8W1nLRa0wiSrDT3Y5jOHZCwKPQ==} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + caniuse-lite@1.0.30001769: + resolution: {integrity: sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg==} + + ccount@2.0.1: + resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + chance@1.1.11: + resolution: {integrity: sha512-kqTg3WWywappJPqtgrdvbA380VoXO2eu9VCV895JgbyHsaErXdyHK9LOZ911OvAk6L0obK7kDk9CGs8+oBawVA==} + + character-entities-html4@2.1.0: + resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} + + character-entities-legacy@3.0.0: + resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + + chevrotain-allstar@0.3.1: + resolution: {integrity: sha512-b7g+y9A0v4mxCW1qUhf3BSVPg+/NvGErk/dOkrDaHA0nQIQGAtrOjlX//9OQtRlSCy+x9rfB5N8yC71lH1nvMw==} + peerDependencies: + chevrotain: ^11.0.0 + + chevrotain@11.0.3: + resolution: {integrity: sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw==} + + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + + chrome-trace-event@1.0.4: + resolution: {integrity: sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==} + engines: {node: '>=6.0'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + comma-separated-tokens@2.0.3: + resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + + commander@2.20.3: + resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} + + commander@7.2.0: + resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} + engines: {node: '>= 10'} + + commander@8.3.0: + resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} + engines: {node: '>= 12'} + + commondir@1.0.1: + resolution: {integrity: sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + + cose-base@1.0.3: + resolution: {integrity: sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==} + + cose-base@2.2.0: + resolution: {integrity: sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==} + + crelt@1.0.6: + resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + cytoscape-cose-bilkent@4.1.0: + resolution: {integrity: sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==} + peerDependencies: + cytoscape: ^3.2.0 + + cytoscape-fcose@2.2.0: + resolution: {integrity: sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==} + peerDependencies: + cytoscape: ^3.2.0 + + cytoscape@3.33.1: + resolution: {integrity: sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==} + engines: {node: '>=0.10'} + + d3-array@2.12.1: + resolution: {integrity: sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==} + + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-axis@3.0.0: + resolution: {integrity: sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==} + engines: {node: '>=12'} + + d3-brush@3.0.0: + resolution: {integrity: sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==} + engines: {node: '>=12'} + + d3-chord@3.0.1: + resolution: {integrity: sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-contour@4.0.2: + resolution: {integrity: sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==} + engines: {node: '>=12'} + + d3-delaunay@6.0.4: + resolution: {integrity: sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==} + engines: {node: '>=12'} + + d3-dispatch@3.0.1: + resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} + engines: {node: '>=12'} + + d3-drag@3.0.0: + resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==} + engines: {node: '>=12'} + + d3-dsv@3.0.1: + resolution: {integrity: sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==} + engines: {node: '>=12'} + hasBin: true + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-fetch@3.0.1: + resolution: {integrity: sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==} + engines: {node: '>=12'} + + d3-force@3.0.0: + resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==} + engines: {node: '>=12'} + + d3-format@3.1.2: + resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} + engines: {node: '>=12'} + + d3-geo@3.1.1: + resolution: {integrity: sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==} + engines: {node: '>=12'} + + d3-hierarchy@3.1.2: + resolution: {integrity: sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@1.0.9: + resolution: {integrity: sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-polygon@3.0.1: + resolution: {integrity: sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==} + engines: {node: '>=12'} + + d3-quadtree@3.0.1: + resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==} + engines: {node: '>=12'} + + d3-random@3.0.1: + resolution: {integrity: sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==} + engines: {node: '>=12'} + + d3-sankey@0.12.3: + resolution: {integrity: sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==} + + d3-scale-chromatic@3.1.0: + resolution: {integrity: sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-selection@3.0.0: + resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} + engines: {node: '>=12'} + + d3-shape@1.3.7: + resolution: {integrity: sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + + d3-transition@3.0.1: + resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==} + engines: {node: '>=12'} + peerDependencies: + d3-selection: 2 - 3 + + d3-zoom@3.0.0: + resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==} + engines: {node: '>=12'} + + d3@7.9.0: + resolution: {integrity: sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==} + engines: {node: '>=12'} + + dagre-d3-es@7.0.13: + resolution: {integrity: sha512-efEhnxpSuwpYOKRm/L5KbqoZmNNukHa/Flty4Wp62JRvgH2ojwVgPgdYyr4twpieZnyRDdIH7PY2mopX26+j2Q==} + + date-fns@2.30.0: + resolution: {integrity: sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==} + engines: {node: '>=0.11'} + + dayjs@1.11.19: + resolution: {integrity: sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==} + + de-indent@1.0.2: + resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decache@4.6.2: + resolution: {integrity: sha512-2LPqkLeu8XWHU8qNCS3kcF6sCcb5zIzvWaAHYSvPfwhdd7mHuah29NssMzrTYyHN4F5oFy2ko9OBYxegtU0FEw==} + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + delaunator@5.0.1: + resolution: {integrity: sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + + dir-glob@3.0.1: + resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} + engines: {node: '>=8'} + + doctrine@3.0.0: + resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} + engines: {node: '>=6.0.0'} + + dompurify@3.3.1: + resolution: {integrity: sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + electron-to-chromium@1.5.286: + resolution: {integrity: sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==} + + emojis-list@3.0.0: + resolution: {integrity: sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==} + engines: {node: '>= 4'} + + enhanced-resolve@5.19.0: + resolution: {integrity: sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==} + engines: {node: '>=10.13.0'} + + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-module-lexer@2.0.0: + resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + esbuild@0.18.20: + resolution: {integrity: sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==} + engines: {node: '>=12'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-config-prettier@8.10.2: + resolution: {integrity: sha512-/IGJ6+Dka158JnP5n5YFMOszjDWrXggGz1LaK/guZq9vZTmniaKlHcsscvkAhn9y4U+BU3JuUdYvtAMcv30y4A==} + hasBin: true + peerDependencies: + eslint: '>=7.0.0' + + eslint-plugin-prettier@5.5.5: + resolution: {integrity: sha512-hscXkbqUZ2sPithAuLm5MXL+Wph+U7wHngPBv9OMWwlP8iaflyxpjTYZkmdgB4/vPIhemRlBEoLrH7UC1n7aUw==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + '@types/eslint': '>=8.0.0' + eslint: '>=8.0.0' + eslint-config-prettier: '>= 7.0.0 <10.0.0 || >=10.1.0' + prettier: '>=3.0.0' + peerDependenciesMeta: + '@types/eslint': + optional: true + eslint-config-prettier: + optional: true + + eslint-plugin-vue@9.17.0: + resolution: {integrity: sha512-r7Bp79pxQk9I5XDP0k2dpUC7Ots3OSWgvGZNu3BxmKK6Zg7NgVtcOB6OCna5Kb9oQwJPl5hq183WD0SY5tZtIQ==} + engines: {node: ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.2.0 || ^7.0.0 || ^8.0.0 + + eslint-scope@5.1.1: + resolution: {integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==} + engines: {node: '>=8.0.0'} + + eslint-scope@7.2.2: + resolution: {integrity: sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint@8.48.0: + resolution: {integrity: sha512-sb6DLeIuRXxeM1YljSe1KEx9/YYeZFQWcV8Rq9HfigmdDEugjLEVEa1ozDjL6YDjBpQHPJxJzze+alxi4T3OLg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. + hasBin: true + + espree@9.6.1: + resolution: {integrity: sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@4.3.0: + resolution: {integrity: sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + event-source-polyfill@1.0.31: + resolution: {integrity: sha512-4IJSItgS/41IxN5UVAVuAyczwZF7ZIEsM1XAoUzIHA6A+xzusEZUutdXz2Nr+MQPLxfTiCvqE79/C8HT8fKFvA==} + + events@3.3.0: + resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} + engines: {node: '>=0.8.x'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-diff@1.3.0: + resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fast-uri@3.1.0: + resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + + file-entry-cache@6.0.1: + resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} + engines: {node: ^10.12.0 || >=12.0.0} + + file-loader@6.2.0: + resolution: {integrity: sha512-qo3glqyTa61Ytg4u73GultjHGjdRyig3tG6lPtyX/jOEJvHif9uB0/OCI2Kif6ctF3caQTW2G5gym21oAsI4pw==} + engines: {node: '>= 10.13.0'} + peerDependencies: + webpack: ^4.0.0 || ^5.0.0 + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + find-cache-dir@3.3.2: + resolution: {integrity: sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==} + engines: {node: '>=8'} + + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@3.2.0: + resolution: {integrity: sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==} + engines: {node: ^10.12.0 || >=12.0.0} + + flatted@3.3.3: + resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + + follow-redirects@1.15.11: + resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + form-data@4.0.5: + resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + engines: {node: '>= 6'} + + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + glob-to-regexp@0.4.1: + resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} + + glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + + globals@13.24.0: + resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==} + engines: {node: '>=8'} + + globby@11.1.0: + resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} + engines: {node: '>=10'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + graphemer@1.4.0: + resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} + + hachure-fill@0.5.2: + resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + hast-util-to-html@9.0.5: + resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} + + hast-util-whitespace@3.0.0: + resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + + he@1.2.0: + resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} + hasBin: true + + highlight.js@11.11.1: + resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==} + engines: {node: '>=12.0.0'} + + html-void-elements@3.0.0: + resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + immutable@4.3.7: + resolution: {integrity: sha512-1hqclzwYwjRDFLjcFxOM5AYkkG0rpFPpr1RLPMEuGczoS7YA8gLhy8SWXYRAA/XwfEHpfo3cw5JGioS32fnMRw==} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + internmap@1.0.1: + resolution: {integrity: sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==} + + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + + interpret@1.4.0: + resolution: {integrity: sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA==} + engines: {node: '>= 0.10'} + + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + + is-buffer@2.0.5: + resolution: {integrity: sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==} + engines: {node: '>=4'} + + is-core-module@2.16.1: + resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} + engines: {node: '>= 0.4'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-path-inside@3.0.3: + resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} + engines: {node: '>=8'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + jest-worker@27.5.1: + resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==} + engines: {node: '>= 10.13.0'} + + js-md5@0.8.3: + resolution: {integrity: sha512-qR0HB5uP6wCuRMrWPTrkMaev7MJZwJuuw4fnwAzRgP4J4/F8RwtodOKpGp4XpqsLBFzzgqIO42efFAyz2Et6KQ==} + + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-parse-even-better-errors@2.3.1: + resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + katex@0.16.28: + resolution: {integrity: sha512-YHzO7721WbmAL6Ov1uzN/l5mY5WWWhJBSW+jq4tkfZfsxmo1hu6frS0EOswvjBUnWE6NtjEs48SFn5CQESRLZg==} + hasBin: true + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + khroma@2.1.0: + resolution: {integrity: sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==} + + langium@3.3.1: + resolution: {integrity: sha512-QJv/h939gDpvT+9SiLVlY7tZC3xB2qK57v0J04Sh9wpMb6MP1q8gB21L3WIo8T5P1MSMg3Ep14L7KkDCFG3y4w==} + engines: {node: '>=16.0.0'} + + layout-base@1.0.2: + resolution: {integrity: sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==} + + layout-base@2.0.1: + resolution: {integrity: sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + linkify-it@5.0.0: + resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==} + + loader-runner@4.3.1: + resolution: {integrity: sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==} + engines: {node: '>=6.11.5'} + + loader-utils@2.0.4: + resolution: {integrity: sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==} + engines: {node: '>=8.9.0'} + + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash-es@4.17.21: + resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==} + + lodash-es@4.17.23: + resolution: {integrity: sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + lodash@4.17.21: + resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + make-dir@3.1.0: + resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==} + engines: {node: '>=8'} + + markdown-it-container@4.0.0: + resolution: {integrity: sha512-HaNccxUH0l7BNGYbFbjmGpf5aLHAMTinqRZQAEQbMr2cdD3z91Q6kIo1oUn1CQndkT03jat6ckrdRYuwwqLlQw==} + + markdown-it-footnote@4.0.0: + resolution: {integrity: sha512-WYJ7urf+khJYl3DqofQpYfEYkZKbmXmwxQV8c8mO/hGIhgZ1wOe7R4HLFNwqx7TjILbnC98fuyeSsin19JdFcQ==} + + markdown-it-ins@4.0.0: + resolution: {integrity: sha512-sWbjK2DprrkINE4oYDhHdCijGT+MIDhEupjSHLXe5UXeVr5qmVxs/nTUVtgi0Oh/qtF+QKV0tNWDhQBEPxiMew==} + + markdown-it-mark@4.0.0: + resolution: {integrity: sha512-YLhzaOsU9THO/cal0lUjfMjrqSMPjjyjChYM7oyj4DnyaXEzA8gnW6cVJeyCrCVeyesrY2PlEdUYJSPFYL4Nkg==} + + markdown-it-sub@2.0.0: + resolution: {integrity: sha512-iCBKgwCkfQBRg2vApy9vx1C1Tu6D8XYo8NvevI3OlwzBRmiMtsJ2sXupBgEA7PPxiDwNni3qIUkhZ6j5wofDUA==} + + markdown-it-sup@2.0.0: + resolution: {integrity: sha512-5VgmdKlkBd8sgXuoDoxMpiU+BiEt3I49GItBzzw7Mxq9CxvnhE/k09HFli09zgfFDRixDQDfDxi0mgBCXtaTvA==} + + markdown-it-task-checkbox@1.0.6: + resolution: {integrity: sha512-7pxkHuvqTOu3iwVGmDPeYjQg+AIS9VQxzyLP9JCg9lBjgPAJXGEkChK6A2iFuj3tS0GV3HG2u5AMNhcQqwxpJw==} + + markdown-it-ts@0.0.3: + resolution: {integrity: sha512-nZpRTJj4S6bN0I5wsNBtgzDKx+HYBBSsvKjGdYw7/tPdrzfo3gUTt3ZbeAjPGeZaC6a4LFi4JdhTVeLm3F6TIQ==} + engines: {node: '>=18'} + + markdown-it@14.1.0: + resolution: {integrity: sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==} + hasBin: true + + marked@16.4.2: + resolution: {integrity: sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==} + engines: {node: '>= 20'} + hasBin: true + + markstream-vue@0.0.6: + resolution: {integrity: sha512-5YrpNrTRdbO0YvKPx2sNu4pq+y+UZ1CPbf9Znoydt3ZL7c7zfBhmViCij+VmAmBrr3VLAgD2HToU8PAtuWySxg==} + peerDependencies: + '@antv/infographic': ^0.2.3 + katex: '>=0.16.22' + mermaid: '>=11' + shiki: ^3.13.0 + stream-markdown: '>=0.0.13' + stream-monaco: '>=0.0.17' + vue: '>=3.0.0' + vue-i18n: '>=9' + peerDependenciesMeta: + '@antv/infographic': + optional: true + katex: + optional: true + mermaid: + optional: true + shiki: + optional: true + stream-markdown: + optional: true + stream-monaco: + optional: true + vue-i18n: + optional: true + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + mdast-util-to-hast@13.2.1: + resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} + + mdurl@2.0.0: + resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==} + + merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + mermaid@11.12.2: + resolution: {integrity: sha512-n34QPDPEKmaeCG4WDMGy0OT6PSyxKCfy2pJgShP+Qow2KLrvWjclwbc3yXfSIf4BanqWEhQEpngWwNp/XhZt6w==} + + micromark-util-character@2.1.1: + resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} + + micromark-util-encode@2.0.1: + resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} + + micromark-util-sanitize-uri@2.0.1: + resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} + + micromark-util-symbol@2.0.1: + resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} + + micromark-util-types@2.0.2: + resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + minimatch@3.1.2: + resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + + minimatch@9.0.5: + resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} + engines: {node: '>=16 || 14 >=14.17'} + + mlly@1.8.0: + resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==} + + monaco-editor@0.52.2: + resolution: {integrity: sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + muggle-string@0.3.1: + resolution: {integrity: sha512-ckmWDJjphvd/FvZawgygcUeQCxzvohjFO5RxTjj4eq8kw359gFF3E1brjfI+viLMxss5JrHTDRHZvu2/tuy0Qg==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + natural-compare-lite@1.4.0: + resolution: {integrity: sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==} + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + neo-async@2.6.2: + resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} + + node-releases@2.0.27: + resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + + null-loader@4.0.1: + resolution: {integrity: sha512-pxqVbi4U6N26lq+LmgIbB5XATP0VdZKOG25DhHi8btMmJJefGArFyDg1yc4U3hWCJbMqSrw0qyrz1UQX+qYXqg==} + engines: {node: '>= 10.13.0'} + peerDependencies: + webpack: ^4.0.0 || ^5.0.0 + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + oniguruma-parser@0.12.1: + resolution: {integrity: sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==} + + oniguruma-to-es@4.3.4: + resolution: {integrity: sha512-3VhUGN3w2eYxnTzHn+ikMI+fp/96KoRSVK9/kMTcFqj1NRDh2IhQCKvYxDnWePKRXY/AqH+Fuiyb7VHSzBjHfA==} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + orderedmap@2.1.1: + resolution: {integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==} + + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + + package-manager-detector@1.6.0: + resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + path-browserify@1.0.1: + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + + path-data-parser@0.1.0: + resolution: {integrity: sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + + pinia@2.1.6: + resolution: {integrity: sha512-bIU6QuE5qZviMmct5XwCesXelb5VavdOWKWaB17ggk++NUwQWWbP5YnsONTk3b752QkW9sACiR81rorpeOMSvQ==} + peerDependencies: + '@vue/composition-api': ^1.4.0 + typescript: '>=4.4.4' + vue: ^2.6.14 || ^3.3.0 + peerDependenciesMeta: + '@vue/composition-api': + optional: true + typescript: + optional: true + + pinyin-pro@3.28.0: + resolution: {integrity: sha512-mMRty6RisoyYNphJrTo3pnvp3w8OMZBrXm9YSWkxhAfxKj1KZk2y8T2PDIZlDDRsvZ0No+Hz6FI4sZpA6Ey25g==} + + pkg-dir@4.2.0: + resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} + engines: {node: '>=8'} + + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + + points-on-curve@0.2.0: + resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==} + + points-on-path@0.2.1: + resolution: {integrity: sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==} + + postcss-selector-parser@6.1.2: + resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==} + engines: {node: '>=4'} + + postcss@8.5.6: + resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + engines: {node: ^10 || ^12 || >=14} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prettier-linter-helpers@1.0.1: + resolution: {integrity: sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg==} + engines: {node: '>=6.0.0'} + + prettier@3.0.2: + resolution: {integrity: sha512-o2YR9qtniXvwEZlOKbveKfDQVyqxbEIWn48Z8m3ZJjBjcCmUy3xZGIv+7AkaeuaTr6yPXJjwv07ZWlsWbEy1rQ==} + engines: {node: '>=14'} + hasBin: true + + property-expr@2.0.6: + resolution: {integrity: sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==} + + property-information@7.1.0: + resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} + + prosemirror-changeset@2.3.1: + resolution: {integrity: sha512-j0kORIBm8ayJNl3zQvD1TTPHJX3g042et6y/KQhZhnPrruO8exkTgG8X+NRpj7kIyMMEx74Xb3DyMIBtO0IKkQ==} + + prosemirror-collab@1.3.1: + resolution: {integrity: sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ==} + + prosemirror-commands@1.7.1: + resolution: {integrity: sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==} + + prosemirror-dropcursor@1.8.2: + resolution: {integrity: sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==} + + prosemirror-gapcursor@1.4.0: + resolution: {integrity: sha512-z00qvurSdCEWUIulij/isHaqu4uLS8r/Fi61IbjdIPJEonQgggbJsLnstW7Lgdk4zQ68/yr6B6bf7sJXowIgdQ==} + + prosemirror-history@1.5.0: + resolution: {integrity: sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==} + + prosemirror-inputrules@1.5.1: + resolution: {integrity: sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==} + + prosemirror-keymap@1.2.3: + resolution: {integrity: sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==} + + prosemirror-markdown@1.13.4: + resolution: {integrity: sha512-D98dm4cQ3Hs6EmjK500TdAOew4Z03EV71ajEFiWra3Upr7diytJsjF4mPV2dW+eK5uNectiRj0xFxYI9NLXDbw==} + + prosemirror-menu@1.2.5: + resolution: {integrity: sha512-qwXzynnpBIeg1D7BAtjOusR+81xCp53j7iWu/IargiRZqRjGIlQuu1f3jFi+ehrHhWMLoyOQTSRx/IWZJqOYtQ==} + + prosemirror-model@1.25.4: + resolution: {integrity: sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==} + + prosemirror-schema-basic@1.2.4: + resolution: {integrity: sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ==} + + prosemirror-schema-list@1.5.1: + resolution: {integrity: sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==} + + prosemirror-state@1.4.4: + resolution: {integrity: sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==} + + prosemirror-tables@1.8.5: + resolution: {integrity: sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==} + + prosemirror-trailing-node@3.0.0: + resolution: {integrity: sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==} + peerDependencies: + prosemirror-model: ^1.22.1 + prosemirror-state: ^1.4.2 + prosemirror-view: ^1.33.8 + + prosemirror-transform@1.11.0: + resolution: {integrity: sha512-4I7Ce4KpygXb9bkiPS3hTEk4dSHorfRw8uI0pE8IhxlK2GXsqv5tIA7JUSxtSu7u8APVOTtbUBxTmnHIxVkIJw==} + + prosemirror-view@1.41.6: + resolution: {integrity: sha512-mxpcDG4hNQa/CPtzxjdlir5bJFDlm0/x5nGBbStB2BWX+XOQ9M8ekEG+ojqB5BcVu2Rc80/jssCMZzSstJuSYg==} + + proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + + punycode.js@2.3.1: + resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} + engines: {node: '>=6'} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + querystring@0.2.1: + resolution: {integrity: sha512-wkvS7mL/JMugcup3/rMitHmd9ecIGd2lhFhK9N3UUQ450h66d1r3Y9nvXzQAW1Lq+wyx61k/1pfKS5KuKiyEbg==} + engines: {node: '>=0.4.x'} + deprecated: The querystring API is considered Legacy. new code should use the URLSearchParams API instead. + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + randombytes@2.1.0: + resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} + + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + + rechoir@0.6.2: + resolution: {integrity: sha512-HFM8rkZ+i3zrV+4LQjwQ0W+ez98pApMGM3HUrN04j3CqzPOzl9nmP15Y8YXNm8QHGv/eacOVEjqhmWpkRV0NAw==} + engines: {node: '>= 0.10'} + + regex-recursion@6.0.2: + resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} + + regex-utilities@2.3.0: + resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==} + + regex@6.1.0: + resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==} + + remixicon@3.5.0: + resolution: {integrity: sha512-wNzWGKf4frb3tEmgvP5shry0n1OdTjjEk9RHLuRuAhfA50bvEdpKH1XWNUYrHUSjAQQkkdyIm+lf4mOuysIKTQ==} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve@1.22.11: + resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==} + engines: {node: '>= 0.4'} + hasBin: true + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rimraf@3.0.2: + resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} + deprecated: Rimraf versions prior to v4 are no longer supported + hasBin: true + + robust-predicates@3.0.2: + resolution: {integrity: sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==} + + rollup@3.29.5: + resolution: {integrity: sha512-GVsDdsbJzzy4S/v3dqWPJ7EfvZJfCHiDqe80IyrF59LYuP+e6U1LJoUqeuqRbwAWoMNoXivMNeNAOf5E22VA1w==} + engines: {node: '>=14.18.0', npm: '>=8.0.0'} + hasBin: true + + rope-sequence@1.3.4: + resolution: {integrity: sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==} + + roughjs@4.6.6: + resolution: {integrity: sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==} + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + rw@1.3.3: + resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + sass-loader@13.3.2: + resolution: {integrity: sha512-CQbKl57kdEv+KDLquhC+gE3pXt74LEAzm+tzywcA0/aHZuub8wTErbjAoNI57rPUWRYRNC5WUnNl8eGJNbDdwg==} + engines: {node: '>= 14.15.0'} + peerDependencies: + fibers: '>= 3.1.0' + node-sass: ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 + sass: ^1.3.0 + sass-embedded: '*' + webpack: ^5.0.0 + peerDependenciesMeta: + fibers: + optional: true + node-sass: + optional: true + sass: + optional: true + sass-embedded: + optional: true + + sass@1.66.1: + resolution: {integrity: sha512-50c+zTsZOJVgFfTgwwEzkjA3/QACgdNsKueWPyAR0mRINIvLAStVQBbPg14iuqEQ74NPDbXzJARJ/O4SI1zftA==} + engines: {node: '>=14.0.0'} + hasBin: true + + schema-utils@3.3.0: + resolution: {integrity: sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==} + engines: {node: '>= 10.13.0'} + + schema-utils@4.3.3: + resolution: {integrity: sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==} + engines: {node: '>= 10.13.0'} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + + serialize-javascript@6.0.2: + resolution: {integrity: sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + shelljs@0.8.5: + resolution: {integrity: sha512-TiwcRcrkhHvbrZbnRcFYMLl30Dfov3HKqzp5tO5b4pt6G/SezKcYhmDg15zXVBswHmctSAQKznqNW2LO5tTDow==} + engines: {node: '>=4'} + hasBin: true + + shiki@3.22.0: + resolution: {integrity: sha512-LBnhsoYEe0Eou4e1VgJACes+O6S6QC0w71fCSp5Oya79inkwkm15gQ1UF6VtQ8j/taMDh79hAB49WUk8ALQW3g==} + + slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + space-separated-tokens@2.0.2: + resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + + state-local@1.0.7: + resolution: {integrity: sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==} + + stream-markdown-parser@0.0.59-beta.3: + resolution: {integrity: sha512-F++KEcHsXeWjKLKw8id6L1JVqQH22fslEZRNbt1NAMUwr8KTI/vOE3UuXYGnJDyXt0yv5JXbO4KdXrvMlWM0qQ==} + + stream-markdown@0.0.13: + resolution: {integrity: sha512-XYhBEtKA76L6q3Uegvu8QOiUDAV4bfF1TcP3rot8eK0AR1+WGHBB6IfT9GJeLFJ7+qZgrH+WxQp/8lZ6CXjeeg==} + peerDependencies: + shiki: '>=3.13.0' + + stream-monaco@0.0.17: + resolution: {integrity: sha512-xpMiFAQEDLxRjNOXobomP9vEJmv5tWU8oN54+SHqL+eeHMNugU7fpmUIqZRSh53p+a/ZYt+DE75jgHwlKOXEuQ==} + hasBin: true + peerDependencies: + monaco-editor: ^0.52.2 + + stringify-entities@4.0.4: + resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + stylis@4.3.6: + resolution: {integrity: sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-color@8.1.1: + resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} + engines: {node: '>=10'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + svg.draggable.js@2.2.2: + resolution: {integrity: sha512-JzNHBc2fLQMzYCZ90KZHN2ohXL0BQJGQimK1kGk6AvSeibuKcIdDX9Kr0dT9+UJ5O8nYA0RB839Lhvk4CY4MZw==} + engines: {node: '>= 0.8.0'} + + svg.easing.js@2.0.0: + resolution: {integrity: sha512-//ctPdJMGy22YoYGV+3HEfHbm6/69LJUTAqI2/5qBvaNHZ9uUFVC82B0Pl299HzgH13rKrBgi4+XyXXyVWWthA==} + engines: {node: '>= 0.8.0'} + + svg.filter.js@2.0.2: + resolution: {integrity: sha512-xkGBwU+dKBzqg5PtilaTb0EYPqPfJ9Q6saVldX+5vCRy31P6TlRCP3U9NxH3HEufkKkpNgdTLBJnmhDHeTqAkw==} + engines: {node: '>= 0.8.0'} + + svg.js@2.7.1: + resolution: {integrity: sha512-ycbxpizEQktk3FYvn/8BH+6/EuWXg7ZpQREJvgacqn46gIddG24tNNe4Son6omdXCnSOaApnpZw6MPCBA1dODA==} + + svg.pathmorphing.js@0.1.3: + resolution: {integrity: sha512-49HWI9X4XQR/JG1qXkSDV8xViuTLIWm/B/7YuQELV5KMOPtXjiwH4XPJvr/ghEDibmLQ9Oc22dpWpG0vUDDNww==} + engines: {node: '>= 0.8.0'} + + svg.resize.js@1.4.3: + resolution: {integrity: sha512-9k5sXJuPKp+mVzXNvxz7U0uC9oVMQrrf7cFsETznzUDDm0x8+77dtZkWdMfRlmbkEEYvUn9btKuZ3n41oNA+uw==} + engines: {node: '>= 0.8.0'} + + svg.select.js@2.1.2: + resolution: {integrity: sha512-tH6ABEyJsAOVAhwcCjF8mw4crjXSI1aa7j2VQR8ZuJ37H2MBUbyeqYr5nEO7sSN3cy9AR9DUwNg0t/962HlDbQ==} + engines: {node: '>= 0.8.0'} + + svg.select.js@3.0.1: + resolution: {integrity: sha512-h5IS/hKkuVCbKSieR9uQCj9w+zLHoPh+ce19bBYyqF53g6mnPB8sAtIbe1s9dh2S2fCmYX2xel1Ln3PJBbK4kw==} + engines: {node: '>= 0.8.0'} + + synckit@0.11.12: + resolution: {integrity: sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==} + engines: {node: ^14.18.0 || >=16.0.0} + + tapable@2.3.0: + resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} + engines: {node: '>=6'} + + terser-webpack-plugin@5.3.16: + resolution: {integrity: sha512-h9oBFCWrq78NyWWVcSwZarJkZ01c2AyGrzs1crmHZO3QUg9D61Wu4NPjBy69n7JqylFF5y+CsUZYmYEIZ3mR+Q==} + engines: {node: '>= 10.13.0'} + peerDependencies: + '@swc/core': '*' + esbuild: '*' + uglify-js: '*' + webpack: ^5.1.0 + peerDependenciesMeta: + '@swc/core': + optional: true + esbuild: + optional: true + uglify-js: + optional: true + + terser@5.46.0: + resolution: {integrity: sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg==} + engines: {node: '>=10'} + hasBin: true + + text-table@0.2.0: + resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} + + tiny-case@1.0.3: + resolution: {integrity: sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==} + + tinyexec@1.0.2: + resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} + engines: {node: '>=18'} + + tippy.js@6.3.7: + resolution: {integrity: sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + toposort@2.0.2: + resolution: {integrity: sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==} + + trim-lines@3.0.1: + resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + + ts-dedent@2.2.0: + resolution: {integrity: sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==} + engines: {node: '>=6.10'} + + tslib@1.14.1: + resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} + + tsutils@3.21.0: + resolution: {integrity: sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==} + engines: {node: '>= 6'} + peerDependencies: + typescript: '>=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta' + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + type-fest@0.20.2: + resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} + engines: {node: '>=10'} + + type-fest@2.19.0: + resolution: {integrity: sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==} + engines: {node: '>=12.20'} + + type-fest@4.41.0: + resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} + engines: {node: '>=16'} + + typescript@5.1.6: + resolution: {integrity: sha512-zaWCozRZ6DLEWAWFrVDz1H6FVXzUSfTy5FUMWsQlU8Ym5JP9eO4xkTIROFCQvhQf61z6O/G6ugw3SgAnvvm+HA==} + engines: {node: '>=14.17'} + hasBin: true + + uc.micro@2.1.0: + resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} + + ufo@1.6.3: + resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==} + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + unist-util-is@6.0.1: + resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} + + unist-util-position@5.0.0: + resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + + unist-util-stringify-position@4.0.0: + resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + + unist-util-visit-parents@6.0.2: + resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} + + unist-util-visit@5.1.0: + resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + + upath@2.0.1: + resolution: {integrity: sha512-1uEe95xksV1O0CYKXo8vQvN1JEbtJp7lb7C5U9HMsIp6IVwntkH/oNUzyVNQSd4S1sYk2FpSSW44FqMc8qee5w==} + engines: {node: '>=4'} + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + uuid@11.1.0: + resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} + hasBin: true + + vee-validate@4.11.3: + resolution: {integrity: sha512-YhWORdZRE1GL6vXKj3r9f+Y8fJH5JMwMUJ4DFS44+WcTtiNXggyE3pyJPlZBqS9AgYGZ47EOv4UczkLxHqufiw==} + peerDependencies: + vue: ^3.3.4 + + vfile-message@4.0.3: + resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} + + vfile@6.0.3: + resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + + vite-plugin-vuetify@1.0.2: + resolution: {integrity: sha512-MubIcKD33O8wtgQXlbEXE7ccTEpHZ8nPpe77y9Wy3my2MWw/PgehP9VqTp92BLqr0R1dSL970Lynvisx3UxBFw==} + engines: {node: '>=12'} + peerDependencies: + vite: ^2.7.0 || ^3.0.0 || ^4.0.0 + vue: ^3.0.0 + vuetify: ^3.0.0-beta.4 + + vite@4.4.9: + resolution: {integrity: sha512-2mbUn2LlUmNASWwSCNSJ/EG2HuSRTnVNaydp6vMCm5VIqJsjMfbIWtbH2kDuwUVW5mMUKKZvGPX/rqeqVvv1XA==} + engines: {node: ^14.18.0 || >=16.0.0} + hasBin: true + peerDependencies: + '@types/node': '>= 14' + less: '*' + lightningcss: ^1.21.0 + sass: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + + vscode-jsonrpc@8.2.0: + resolution: {integrity: sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==} + engines: {node: '>=14.0.0'} + + vscode-languageserver-protocol@3.17.5: + resolution: {integrity: sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==} + + vscode-languageserver-textdocument@1.0.12: + resolution: {integrity: sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==} + + vscode-languageserver-types@3.17.5: + resolution: {integrity: sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==} + + vscode-languageserver@9.0.1: + resolution: {integrity: sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==} + hasBin: true + + vscode-uri@3.0.8: + resolution: {integrity: sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==} + + vue-cli-plugin-vuetify@2.5.8: + resolution: {integrity: sha512-uqi0/URJETJBbWlQHD1l0pnY7JN8Ytu+AL1fw50HFlGByPa8/xx+mq19GkFXA9FcwFT01IqEc/TkxMPugchomg==} + peerDependencies: + sass-loader: '*' + vue: '*' + vuetify-loader: '*' + webpack: ^4.0.0 || ^5.0.0 + peerDependenciesMeta: + sass-loader: + optional: true + vuetify-loader: + optional: true + + vue-demi@0.14.10: + resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==} + engines: {node: '>=12'} + hasBin: true + peerDependencies: + '@vue/composition-api': ^1.0.0-rc.1 + vue: ^3.0.0-0 || ^2.6.0 + peerDependenciesMeta: + '@vue/composition-api': + optional: true + + vue-eslint-parser@9.4.3: + resolution: {integrity: sha512-2rYRLWlIpaiN8xbPiDyXZXRgLGOtWxERV7ND5fFAv5qo1D2N9Fu9MNajBNc6o13lZ+24DAWCkQCvj4klgmcITg==} + engines: {node: ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: '>=6.0.0' + + vue-i18n@11.2.8: + resolution: {integrity: sha512-vJ123v/PXCZntd6Qj5Jumy7UBmIuE92VrtdX+AXr+1WzdBHojiBxnAxdfctUFL+/JIN+VQH4BhsfTtiGsvVObg==} + engines: {node: '>= 16'} + peerDependencies: + vue: ^3.0.0 + + vue-router@4.2.4: + resolution: {integrity: sha512-9PISkmaCO02OzPVOMq2w82ilty6+xJmQrarYZDkjZBfl4RvYAlt4PKnEX21oW4KTtWfa9OuO/b3qk1Od3AEdCQ==} + peerDependencies: + vue: ^3.2.0 + + vue-template-compiler@2.7.16: + resolution: {integrity: sha512-AYbUWAJHLGGQM7+cNTELw+KsOG9nl2CnSv467WobS5Cv9uk3wFcnr1Etsz2sEIHEZvw1U+o9mRlEO6QbZvUPGQ==} + + vue-tsc@1.8.8: + resolution: {integrity: sha512-bSydNFQsF7AMvwWsRXD7cBIXaNs/KSjvzWLymq/UtKE36697sboX4EccSHFVxvgdBlI1frYPc/VMKJNB7DFeDQ==} + hasBin: true + peerDependencies: + typescript: '*' + + vue3-apexcharts@1.4.4: + resolution: {integrity: sha512-TH89uZrxGjaDvkaYAISvj8+k6Bf1rUKFillc8oJirs5XZEPiwM1ELKZQ786wz0rfPqkSHHny2lqqUCK7Rw+LcQ==} + peerDependencies: + apexcharts: '> 3.0.0' + vue: '> 3.0.0' + + vue3-print-nb@0.1.4: + resolution: {integrity: sha512-LExI7viEzplR6ZKQ2b+V4U0cwGYbVD4fut/XHvk3UPGlT5CcvIGs6VlwGp107aKgk6P8Pgx4rco3Rehv2lti3A==} + + vue@3.3.4: + resolution: {integrity: sha512-VTyEYn3yvIeY1Py0WaYGZsXnz3y5UnGi62GjVEqvEGPl6nxbOrCXbVOTQWBEJUqAyTUk2uJ5JLVnYJ6ZzGbrSw==} + + vuetify-loader@2.0.0-alpha.9: + resolution: {integrity: sha512-M4u2XX9coe1U51jLKek54eJTo7wnroNfglh6sQplRTslhQRKQM3k84oh87D0VHqTzoTzlKPHP0sIWdpklwaEEQ==} + engines: {node: '>=12'} + deprecated: vuetify-loader has been renamed to webpack-plugin-vuetify for Vuetify 3 + peerDependencies: + '@vue/compiler-sfc': ^3.2.6 + vuetify: ^3.0.0-alpha.11 + webpack: ^5.0.0 + + vuetify@3.7.11: + resolution: {integrity: sha512-50Z2SNwPXbkGmve4CwxOs4sySZGgLwQYIDsKx+coSrfIBqz8IyXgxRFQdrvgoehIwUjGTNqaPZymuK5rMFkfHA==} + engines: {node: ^12.20 || >=14.13} + peerDependencies: + typescript: '>=4.7' + vite-plugin-vuetify: '>=1.0.0' + vue: ^3.3.0 + webpack-plugin-vuetify: '>=2.0.0' + peerDependenciesMeta: + typescript: + optional: true + vite-plugin-vuetify: + optional: true + webpack-plugin-vuetify: + optional: true + + w3c-keyname@2.2.8: + resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} + + watchpack@2.5.1: + resolution: {integrity: sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==} + engines: {node: '>=10.13.0'} + + webpack-sources@3.3.3: + resolution: {integrity: sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg==} + engines: {node: '>=10.13.0'} + + webpack@5.105.0: + resolution: {integrity: sha512-gX/dMkRQc7QOMzgTe6KsYFM7DxeIONQSui1s0n/0xht36HvrgbxtM1xBlgx596NbpHuQU8P7QpKwrZYwUX48nw==} + engines: {node: '>=10.13.0'} + hasBin: true + peerDependencies: + webpack-cli: '*' + peerDependenciesMeta: + webpack-cli: + optional: true + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + xml-name-validator@4.0.0: + resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==} + engines: {node: '>=12'} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + yup@1.2.0: + resolution: {integrity: sha512-PPqYKSAXjpRCgLgLKVGPA33v5c/WgEx3wi6NFjIiegz90zSwyMpvTFp/uGcVnnbx6to28pgnzp/q8ih3QRjLMQ==} + + zwitch@2.0.4: + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + +snapshots: + + '@antfu/install-pkg@1.1.0': + dependencies: + package-manager-detector: 1.6.0 + tinyexec: 1.0.2 + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/parser@7.29.0': + dependencies: + '@babel/types': 7.29.0 + + '@babel/runtime@7.28.6': {} + + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@braintree/sanitize-url@7.1.2': {} + + '@chevrotain/cst-dts-gen@11.0.3': + dependencies: + '@chevrotain/gast': 11.0.3 + '@chevrotain/types': 11.0.3 + lodash-es: 4.17.21 + + '@chevrotain/gast@11.0.3': + dependencies: + '@chevrotain/types': 11.0.3 + lodash-es: 4.17.21 + + '@chevrotain/regexp-to-ast@11.0.3': {} + + '@chevrotain/types@11.0.3': {} + + '@chevrotain/utils@11.0.3': {} + + '@esbuild/android-arm64@0.18.20': + optional: true + + '@esbuild/android-arm@0.18.20': + optional: true + + '@esbuild/android-x64@0.18.20': + optional: true + + '@esbuild/darwin-arm64@0.18.20': + optional: true + + '@esbuild/darwin-x64@0.18.20': + optional: true + + '@esbuild/freebsd-arm64@0.18.20': + optional: true + + '@esbuild/freebsd-x64@0.18.20': + optional: true + + '@esbuild/linux-arm64@0.18.20': + optional: true + + '@esbuild/linux-arm@0.18.20': + optional: true + + '@esbuild/linux-ia32@0.18.20': + optional: true + + '@esbuild/linux-loong64@0.18.20': + optional: true + + '@esbuild/linux-mips64el@0.18.20': + optional: true + + '@esbuild/linux-ppc64@0.18.20': + optional: true + + '@esbuild/linux-riscv64@0.18.20': + optional: true + + '@esbuild/linux-s390x@0.18.20': + optional: true + + '@esbuild/linux-x64@0.18.20': + optional: true + + '@esbuild/netbsd-x64@0.18.20': + optional: true + + '@esbuild/openbsd-x64@0.18.20': + optional: true + + '@esbuild/sunos-x64@0.18.20': + optional: true + + '@esbuild/win32-arm64@0.18.20': + optional: true + + '@esbuild/win32-ia32@0.18.20': + optional: true + + '@esbuild/win32-x64@0.18.20': + optional: true + + '@eslint-community/eslint-utils@4.9.1(eslint@8.48.0)': + dependencies: + eslint: 8.48.0 + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/eslintrc@2.1.4': + dependencies: + ajv: 6.12.6 + debug: 4.4.3 + espree: 9.6.1 + globals: 13.24.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.1.1 + minimatch: 3.1.2 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@8.48.0': {} + + '@floating-ui/core@1.7.4': + dependencies: + '@floating-ui/utils': 0.2.10 + + '@floating-ui/dom@1.7.5': + dependencies: + '@floating-ui/core': 1.7.4 + '@floating-ui/utils': 0.2.10 + + '@floating-ui/utils@0.2.10': {} + + '@guolao/vue-monaco-editor@1.6.0(monaco-editor@0.52.2)(vue@3.3.4)': + dependencies: + '@monaco-editor/loader': 1.7.0 + monaco-editor: 0.52.2 + vue: 3.3.4 + vue-demi: 0.14.10(vue@3.3.4) + + '@humanwhocodes/config-array@0.11.14': + dependencies: + '@humanwhocodes/object-schema': 2.0.3 + debug: 4.4.3 + minimatch: 3.1.2 + transitivePeerDependencies: + - supports-color + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/object-schema@2.0.3': {} + + '@iconify/types@2.0.0': {} + + '@iconify/utils@3.1.0': + dependencies: + '@antfu/install-pkg': 1.1.0 + '@iconify/types': 2.0.0 + mlly: 1.8.0 + + '@intlify/core-base@11.2.8': + dependencies: + '@intlify/message-compiler': 11.2.8 + '@intlify/shared': 11.2.8 + + '@intlify/message-compiler@11.2.8': + dependencies: + '@intlify/shared': 11.2.8 + source-map-js: 1.2.1 + + '@intlify/shared@11.2.8': {} + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/source-map@0.3.11': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@mdi/font@7.2.96': {} + + '@mermaid-js/parser@0.6.3': + dependencies: + langium: 3.3.1 + + '@monaco-editor/loader@1.7.0': + dependencies: + state-local: 1.0.7 + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.1 + + '@pkgr/core@0.2.9': {} + + '@popperjs/core@2.11.8': {} + + '@remirror/core-constants@3.0.0': {} + + '@rushstack/eslint-patch@1.3.3': {} + + '@shikijs/core@3.22.0': + dependencies: + '@shikijs/types': 3.22.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + hast-util-to-html: 9.0.5 + + '@shikijs/engine-javascript@3.22.0': + dependencies: + '@shikijs/types': 3.22.0 + '@shikijs/vscode-textmate': 10.0.2 + oniguruma-to-es: 4.3.4 + + '@shikijs/engine-oniguruma@3.22.0': + dependencies: + '@shikijs/types': 3.22.0 + '@shikijs/vscode-textmate': 10.0.2 + + '@shikijs/langs@3.22.0': + dependencies: + '@shikijs/types': 3.22.0 + + '@shikijs/monaco@3.22.0': + dependencies: + '@shikijs/core': 3.22.0 + '@shikijs/types': 3.22.0 + '@shikijs/vscode-textmate': 10.0.2 + + '@shikijs/themes@3.22.0': + dependencies: + '@shikijs/types': 3.22.0 + + '@shikijs/types@3.22.0': + dependencies: + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + + '@shikijs/vscode-textmate@10.0.2': {} + + '@tiptap/core@2.27.2(@tiptap/pm@2.27.2)': + dependencies: + '@tiptap/pm': 2.27.2 + + '@tiptap/extension-blockquote@2.27.2(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))': + dependencies: + '@tiptap/core': 2.27.2(@tiptap/pm@2.27.2) + + '@tiptap/extension-bold@2.27.2(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))': + dependencies: + '@tiptap/core': 2.27.2(@tiptap/pm@2.27.2) + + '@tiptap/extension-bubble-menu@2.27.2(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))(@tiptap/pm@2.27.2)': + dependencies: + '@tiptap/core': 2.27.2(@tiptap/pm@2.27.2) + '@tiptap/pm': 2.27.2 + tippy.js: 6.3.7 + + '@tiptap/extension-bullet-list@2.27.2(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))': + dependencies: + '@tiptap/core': 2.27.2(@tiptap/pm@2.27.2) + + '@tiptap/extension-code-block@2.27.2(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))(@tiptap/pm@2.27.2)': + dependencies: + '@tiptap/core': 2.27.2(@tiptap/pm@2.27.2) + '@tiptap/pm': 2.27.2 + + '@tiptap/extension-code@2.27.2(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))': + dependencies: + '@tiptap/core': 2.27.2(@tiptap/pm@2.27.2) + + '@tiptap/extension-document@2.27.2(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))': + dependencies: + '@tiptap/core': 2.27.2(@tiptap/pm@2.27.2) + + '@tiptap/extension-dropcursor@2.27.2(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))(@tiptap/pm@2.27.2)': + dependencies: + '@tiptap/core': 2.27.2(@tiptap/pm@2.27.2) + '@tiptap/pm': 2.27.2 + + '@tiptap/extension-floating-menu@2.27.2(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))(@tiptap/pm@2.27.2)': + dependencies: + '@tiptap/core': 2.27.2(@tiptap/pm@2.27.2) + '@tiptap/pm': 2.27.2 + tippy.js: 6.3.7 + + '@tiptap/extension-gapcursor@2.27.2(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))(@tiptap/pm@2.27.2)': + dependencies: + '@tiptap/core': 2.27.2(@tiptap/pm@2.27.2) + '@tiptap/pm': 2.27.2 + + '@tiptap/extension-hard-break@2.27.2(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))': + dependencies: + '@tiptap/core': 2.27.2(@tiptap/pm@2.27.2) + + '@tiptap/extension-heading@2.27.2(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))': + dependencies: + '@tiptap/core': 2.27.2(@tiptap/pm@2.27.2) + + '@tiptap/extension-history@2.27.2(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))(@tiptap/pm@2.27.2)': + dependencies: + '@tiptap/core': 2.27.2(@tiptap/pm@2.27.2) + '@tiptap/pm': 2.27.2 + + '@tiptap/extension-horizontal-rule@2.27.2(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))(@tiptap/pm@2.27.2)': + dependencies: + '@tiptap/core': 2.27.2(@tiptap/pm@2.27.2) + '@tiptap/pm': 2.27.2 + + '@tiptap/extension-italic@2.27.2(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))': + dependencies: + '@tiptap/core': 2.27.2(@tiptap/pm@2.27.2) + + '@tiptap/extension-list-item@2.27.2(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))': + dependencies: + '@tiptap/core': 2.27.2(@tiptap/pm@2.27.2) + + '@tiptap/extension-ordered-list@2.27.2(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))': + dependencies: + '@tiptap/core': 2.27.2(@tiptap/pm@2.27.2) + + '@tiptap/extension-paragraph@2.27.2(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))': + dependencies: + '@tiptap/core': 2.27.2(@tiptap/pm@2.27.2) + + '@tiptap/extension-strike@2.27.2(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))': + dependencies: + '@tiptap/core': 2.27.2(@tiptap/pm@2.27.2) + + '@tiptap/extension-text@2.27.2(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))': + dependencies: + '@tiptap/core': 2.27.2(@tiptap/pm@2.27.2) + + '@tiptap/pm@2.27.2': + dependencies: + prosemirror-changeset: 2.3.1 + prosemirror-collab: 1.3.1 + prosemirror-commands: 1.7.1 + prosemirror-dropcursor: 1.8.2 + prosemirror-gapcursor: 1.4.0 + prosemirror-history: 1.5.0 + prosemirror-inputrules: 1.5.1 + prosemirror-keymap: 1.2.3 + prosemirror-markdown: 1.13.4 + prosemirror-menu: 1.2.5 + prosemirror-model: 1.25.4 + prosemirror-schema-basic: 1.2.4 + prosemirror-schema-list: 1.5.1 + prosemirror-state: 1.4.4 + prosemirror-tables: 1.8.5 + prosemirror-trailing-node: 3.0.0(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.6) + prosemirror-transform: 1.11.0 + prosemirror-view: 1.41.6 + + '@tiptap/starter-kit@2.1.7(@tiptap/pm@2.27.2)': + dependencies: + '@tiptap/core': 2.27.2(@tiptap/pm@2.27.2) + '@tiptap/extension-blockquote': 2.27.2(@tiptap/core@2.27.2(@tiptap/pm@2.27.2)) + '@tiptap/extension-bold': 2.27.2(@tiptap/core@2.27.2(@tiptap/pm@2.27.2)) + '@tiptap/extension-bullet-list': 2.27.2(@tiptap/core@2.27.2(@tiptap/pm@2.27.2)) + '@tiptap/extension-code': 2.27.2(@tiptap/core@2.27.2(@tiptap/pm@2.27.2)) + '@tiptap/extension-code-block': 2.27.2(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))(@tiptap/pm@2.27.2) + '@tiptap/extension-document': 2.27.2(@tiptap/core@2.27.2(@tiptap/pm@2.27.2)) + '@tiptap/extension-dropcursor': 2.27.2(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))(@tiptap/pm@2.27.2) + '@tiptap/extension-gapcursor': 2.27.2(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))(@tiptap/pm@2.27.2) + '@tiptap/extension-hard-break': 2.27.2(@tiptap/core@2.27.2(@tiptap/pm@2.27.2)) + '@tiptap/extension-heading': 2.27.2(@tiptap/core@2.27.2(@tiptap/pm@2.27.2)) + '@tiptap/extension-history': 2.27.2(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))(@tiptap/pm@2.27.2) + '@tiptap/extension-horizontal-rule': 2.27.2(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))(@tiptap/pm@2.27.2) + '@tiptap/extension-italic': 2.27.2(@tiptap/core@2.27.2(@tiptap/pm@2.27.2)) + '@tiptap/extension-list-item': 2.27.2(@tiptap/core@2.27.2(@tiptap/pm@2.27.2)) + '@tiptap/extension-ordered-list': 2.27.2(@tiptap/core@2.27.2(@tiptap/pm@2.27.2)) + '@tiptap/extension-paragraph': 2.27.2(@tiptap/core@2.27.2(@tiptap/pm@2.27.2)) + '@tiptap/extension-strike': 2.27.2(@tiptap/core@2.27.2(@tiptap/pm@2.27.2)) + '@tiptap/extension-text': 2.27.2(@tiptap/core@2.27.2(@tiptap/pm@2.27.2)) + transitivePeerDependencies: + - '@tiptap/pm' + + '@tiptap/vue-3@2.1.7(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))(@tiptap/pm@2.27.2)(vue@3.3.4)': + dependencies: + '@tiptap/core': 2.27.2(@tiptap/pm@2.27.2) + '@tiptap/extension-bubble-menu': 2.27.2(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))(@tiptap/pm@2.27.2) + '@tiptap/extension-floating-menu': 2.27.2(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))(@tiptap/pm@2.27.2) + '@tiptap/pm': 2.27.2 + vue: 3.3.4 + + '@types/chance@1.1.3': {} + + '@types/d3-array@3.2.2': {} + + '@types/d3-axis@3.0.6': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-brush@3.0.6': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-chord@3.0.6': {} + + '@types/d3-color@3.1.3': {} + + '@types/d3-contour@3.0.6': + dependencies: + '@types/d3-array': 3.2.2 + '@types/geojson': 7946.0.16 + + '@types/d3-delaunay@6.0.4': {} + + '@types/d3-dispatch@3.0.7': {} + + '@types/d3-drag@3.0.7': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-dsv@3.0.7': {} + + '@types/d3-ease@3.0.2': {} + + '@types/d3-fetch@3.0.7': + dependencies: + '@types/d3-dsv': 3.0.7 + + '@types/d3-force@3.0.10': {} + + '@types/d3-format@3.0.4': {} + + '@types/d3-geo@3.1.0': + dependencies: + '@types/geojson': 7946.0.16 + + '@types/d3-hierarchy@3.1.7': {} + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-path@3.1.1': {} + + '@types/d3-polygon@3.0.2': {} + + '@types/d3-quadtree@3.0.6': {} + + '@types/d3-random@3.0.3': {} + + '@types/d3-scale-chromatic@3.1.0': {} + + '@types/d3-scale@4.0.9': + dependencies: + '@types/d3-time': 3.0.4 + + '@types/d3-selection@3.0.11': {} + + '@types/d3-shape@3.1.8': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time-format@4.0.3': {} + + '@types/d3-time@3.0.4': {} + + '@types/d3-timer@3.0.2': {} + + '@types/d3-transition@3.0.9': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-zoom@3.0.8': + dependencies: + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.11 + + '@types/d3@7.4.3': + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-axis': 3.0.6 + '@types/d3-brush': 3.0.6 + '@types/d3-chord': 3.0.6 + '@types/d3-color': 3.1.3 + '@types/d3-contour': 3.0.6 + '@types/d3-delaunay': 6.0.4 + '@types/d3-dispatch': 3.0.7 + '@types/d3-drag': 3.0.7 + '@types/d3-dsv': 3.0.7 + '@types/d3-ease': 3.0.2 + '@types/d3-fetch': 3.0.7 + '@types/d3-force': 3.0.10 + '@types/d3-format': 3.0.4 + '@types/d3-geo': 3.1.0 + '@types/d3-hierarchy': 3.1.7 + '@types/d3-interpolate': 3.0.4 + '@types/d3-path': 3.1.1 + '@types/d3-polygon': 3.0.2 + '@types/d3-quadtree': 3.0.6 + '@types/d3-random': 3.0.3 + '@types/d3-scale': 4.0.9 + '@types/d3-scale-chromatic': 3.1.0 + '@types/d3-selection': 3.0.11 + '@types/d3-shape': 3.1.8 + '@types/d3-time': 3.0.4 + '@types/d3-time-format': 4.0.3 + '@types/d3-timer': 3.0.2 + '@types/d3-transition': 3.0.9 + '@types/d3-zoom': 3.0.8 + + '@types/dompurify@3.2.0': + dependencies: + dompurify: 3.3.1 + + '@types/eslint-scope@3.7.7': + dependencies: + '@types/eslint': 9.6.1 + '@types/estree': 1.0.8 + + '@types/eslint@9.6.1': + dependencies: + '@types/estree': 1.0.8 + '@types/json-schema': 7.0.15 + + '@types/estree@1.0.8': {} + + '@types/geojson@7946.0.16': {} + + '@types/hast@3.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/json-schema@7.0.15': {} + + '@types/linkify-it@5.0.0': {} + + '@types/markdown-it@14.1.2': + dependencies: + '@types/linkify-it': 5.0.0 + '@types/mdurl': 2.0.0 + + '@types/mdast@4.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/mdurl@2.0.0': {} + + '@types/node@20.19.32': + dependencies: + undici-types: 6.21.0 + + '@types/semver@7.7.1': {} + + '@types/trusted-types@2.0.7': + optional: true + + '@types/unist@3.0.3': {} + + '@typescript-eslint/eslint-plugin@5.62.0(@typescript-eslint/parser@5.62.0(eslint@8.48.0)(typescript@5.1.6))(eslint@8.48.0)(typescript@5.1.6)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 5.62.0(eslint@8.48.0)(typescript@5.1.6) + '@typescript-eslint/scope-manager': 5.62.0 + '@typescript-eslint/type-utils': 5.62.0(eslint@8.48.0)(typescript@5.1.6) + '@typescript-eslint/utils': 5.62.0(eslint@8.48.0)(typescript@5.1.6) + debug: 4.4.3 + eslint: 8.48.0 + graphemer: 1.4.0 + ignore: 5.3.2 + natural-compare-lite: 1.4.0 + semver: 7.7.4 + tsutils: 3.21.0(typescript@5.1.6) + optionalDependencies: + typescript: 5.1.6 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@5.62.0(eslint@8.48.0)(typescript@5.1.6)': + dependencies: + '@typescript-eslint/scope-manager': 5.62.0 + '@typescript-eslint/types': 5.62.0 + '@typescript-eslint/typescript-estree': 5.62.0(typescript@5.1.6) + debug: 4.4.3 + eslint: 8.48.0 + optionalDependencies: + typescript: 5.1.6 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@5.62.0': + dependencies: + '@typescript-eslint/types': 5.62.0 + '@typescript-eslint/visitor-keys': 5.62.0 + + '@typescript-eslint/type-utils@5.62.0(eslint@8.48.0)(typescript@5.1.6)': + dependencies: + '@typescript-eslint/typescript-estree': 5.62.0(typescript@5.1.6) + '@typescript-eslint/utils': 5.62.0(eslint@8.48.0)(typescript@5.1.6) + debug: 4.4.3 + eslint: 8.48.0 + tsutils: 3.21.0(typescript@5.1.6) + optionalDependencies: + typescript: 5.1.6 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@5.62.0': {} + + '@typescript-eslint/typescript-estree@5.62.0(typescript@5.1.6)': + dependencies: + '@typescript-eslint/types': 5.62.0 + '@typescript-eslint/visitor-keys': 5.62.0 + debug: 4.4.3 + globby: 11.1.0 + is-glob: 4.0.3 + semver: 7.7.4 + tsutils: 3.21.0(typescript@5.1.6) + optionalDependencies: + typescript: 5.1.6 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@5.62.0(eslint@8.48.0)(typescript@5.1.6)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@8.48.0) + '@types/json-schema': 7.0.15 + '@types/semver': 7.7.1 + '@typescript-eslint/scope-manager': 5.62.0 + '@typescript-eslint/types': 5.62.0 + '@typescript-eslint/typescript-estree': 5.62.0(typescript@5.1.6) + eslint: 8.48.0 + eslint-scope: 5.1.1 + semver: 7.7.4 + transitivePeerDependencies: + - supports-color + - typescript + + '@typescript-eslint/visitor-keys@5.62.0': + dependencies: + '@typescript-eslint/types': 5.62.0 + eslint-visitor-keys: 3.4.3 + + '@ungap/structured-clone@1.3.0': {} + + '@vitejs/plugin-vue@4.3.3(vite@4.4.9(@types/node@20.19.32)(sass@1.66.1)(terser@5.46.0))(vue@3.3.4)': + dependencies: + vite: 4.4.9(@types/node@20.19.32)(sass@1.66.1)(terser@5.46.0) + vue: 3.3.4 + + '@volar/language-core@1.10.10': + dependencies: + '@volar/source-map': 1.10.10 + + '@volar/source-map@1.10.10': + dependencies: + muggle-string: 0.3.1 + + '@volar/typescript@1.10.10': + dependencies: + '@volar/language-core': 1.10.10 + path-browserify: 1.0.1 + + '@vue/compiler-core@3.3.4': + dependencies: + '@babel/parser': 7.29.0 + '@vue/shared': 3.3.4 + estree-walker: 2.0.2 + source-map-js: 1.2.1 + + '@vue/compiler-core@3.5.27': + dependencies: + '@babel/parser': 7.29.0 + '@vue/shared': 3.5.27 + entities: 7.0.1 + estree-walker: 2.0.2 + source-map-js: 1.2.1 + + '@vue/compiler-dom@3.3.4': + dependencies: + '@vue/compiler-core': 3.3.4 + '@vue/shared': 3.3.4 + + '@vue/compiler-dom@3.5.27': + dependencies: + '@vue/compiler-core': 3.5.27 + '@vue/shared': 3.5.27 + + '@vue/compiler-sfc@3.3.4': + dependencies: + '@babel/parser': 7.29.0 + '@vue/compiler-core': 3.3.4 + '@vue/compiler-dom': 3.3.4 + '@vue/compiler-ssr': 3.3.4 + '@vue/reactivity-transform': 3.3.4 + '@vue/shared': 3.3.4 + estree-walker: 2.0.2 + magic-string: 0.30.21 + postcss: 8.5.6 + source-map-js: 1.2.1 + + '@vue/compiler-ssr@3.3.4': + dependencies: + '@vue/compiler-dom': 3.3.4 + '@vue/shared': 3.3.4 + + '@vue/devtools-api@6.6.4': {} + + '@vue/eslint-config-prettier@8.0.0(@types/eslint@9.6.1)(eslint@8.48.0)(prettier@3.0.2)': + dependencies: + eslint: 8.48.0 + eslint-config-prettier: 8.10.2(eslint@8.48.0) + eslint-plugin-prettier: 5.5.5(@types/eslint@9.6.1)(eslint-config-prettier@8.10.2(eslint@8.48.0))(eslint@8.48.0)(prettier@3.0.2) + prettier: 3.0.2 + transitivePeerDependencies: + - '@types/eslint' + + '@vue/eslint-config-typescript@11.0.3(eslint-plugin-vue@9.17.0(eslint@8.48.0))(eslint@8.48.0)(typescript@5.1.6)': + dependencies: + '@typescript-eslint/eslint-plugin': 5.62.0(@typescript-eslint/parser@5.62.0(eslint@8.48.0)(typescript@5.1.6))(eslint@8.48.0)(typescript@5.1.6) + '@typescript-eslint/parser': 5.62.0(eslint@8.48.0)(typescript@5.1.6) + eslint: 8.48.0 + eslint-plugin-vue: 9.17.0(eslint@8.48.0) + vue-eslint-parser: 9.4.3(eslint@8.48.0) + optionalDependencies: + typescript: 5.1.6 + transitivePeerDependencies: + - supports-color + + '@vue/language-core@1.8.8(typescript@5.1.6)': + dependencies: + '@volar/language-core': 1.10.10 + '@volar/source-map': 1.10.10 + '@vue/compiler-dom': 3.5.27 + '@vue/reactivity': 3.5.27 + '@vue/shared': 3.5.27 + minimatch: 9.0.5 + muggle-string: 0.3.1 + vue-template-compiler: 2.7.16 + optionalDependencies: + typescript: 5.1.6 + + '@vue/reactivity-transform@3.3.4': + dependencies: + '@babel/parser': 7.29.0 + '@vue/compiler-core': 3.3.4 + '@vue/shared': 3.3.4 + estree-walker: 2.0.2 + magic-string: 0.30.21 + + '@vue/reactivity@3.3.4': + dependencies: + '@vue/shared': 3.3.4 + + '@vue/reactivity@3.5.27': + dependencies: + '@vue/shared': 3.5.27 + + '@vue/runtime-core@3.3.4': + dependencies: + '@vue/reactivity': 3.3.4 + '@vue/shared': 3.3.4 + + '@vue/runtime-dom@3.3.4': + dependencies: + '@vue/runtime-core': 3.3.4 + '@vue/shared': 3.3.4 + csstype: 3.2.3 + + '@vue/server-renderer@3.3.4(vue@3.3.4)': + dependencies: + '@vue/compiler-ssr': 3.3.4 + '@vue/shared': 3.3.4 + vue: 3.3.4 + + '@vue/shared@3.3.4': {} + + '@vue/shared@3.5.27': {} + + '@vue/tsconfig@0.4.0': {} + + '@vue/typescript@1.8.8(typescript@5.1.6)': + dependencies: + '@volar/typescript': 1.10.10 + '@vue/language-core': 1.8.8(typescript@5.1.6) + transitivePeerDependencies: + - typescript + + '@vuetify/loader-shared@1.7.1(vue@3.3.4)(vuetify@3.7.11)': + dependencies: + find-cache-dir: 3.3.2 + upath: 2.0.1 + vue: 3.3.4 + vuetify: 3.7.11(typescript@5.1.6)(vite-plugin-vuetify@1.0.2)(vue@3.3.4) + + '@webassemblyjs/ast@1.14.1': + dependencies: + '@webassemblyjs/helper-numbers': 1.13.2 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + + '@webassemblyjs/floating-point-hex-parser@1.13.2': {} + + '@webassemblyjs/helper-api-error@1.13.2': {} + + '@webassemblyjs/helper-buffer@1.14.1': {} + + '@webassemblyjs/helper-numbers@1.13.2': + dependencies: + '@webassemblyjs/floating-point-hex-parser': 1.13.2 + '@webassemblyjs/helper-api-error': 1.13.2 + '@xtuc/long': 4.2.2 + + '@webassemblyjs/helper-wasm-bytecode@1.13.2': {} + + '@webassemblyjs/helper-wasm-section@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-buffer': 1.14.1 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + '@webassemblyjs/wasm-gen': 1.14.1 + + '@webassemblyjs/ieee754@1.13.2': + dependencies: + '@xtuc/ieee754': 1.2.0 + + '@webassemblyjs/leb128@1.13.2': + dependencies: + '@xtuc/long': 4.2.2 + + '@webassemblyjs/utf8@1.13.2': {} + + '@webassemblyjs/wasm-edit@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-buffer': 1.14.1 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + '@webassemblyjs/helper-wasm-section': 1.14.1 + '@webassemblyjs/wasm-gen': 1.14.1 + '@webassemblyjs/wasm-opt': 1.14.1 + '@webassemblyjs/wasm-parser': 1.14.1 + '@webassemblyjs/wast-printer': 1.14.1 + + '@webassemblyjs/wasm-gen@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + '@webassemblyjs/ieee754': 1.13.2 + '@webassemblyjs/leb128': 1.13.2 + '@webassemblyjs/utf8': 1.13.2 + + '@webassemblyjs/wasm-opt@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-buffer': 1.14.1 + '@webassemblyjs/wasm-gen': 1.14.1 + '@webassemblyjs/wasm-parser': 1.14.1 + + '@webassemblyjs/wasm-parser@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-api-error': 1.13.2 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + '@webassemblyjs/ieee754': 1.13.2 + '@webassemblyjs/leb128': 1.13.2 + '@webassemblyjs/utf8': 1.13.2 + + '@webassemblyjs/wast-printer@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@xtuc/long': 4.2.2 + + '@xtuc/ieee754@1.2.0': {} + + '@xtuc/long@4.2.2': {} + + '@yr/monotone-cubic-spline@1.0.3': {} + + acorn-import-phases@1.0.4(acorn@8.15.0): + dependencies: + acorn: 8.15.0 + + acorn-jsx@5.3.2(acorn@8.15.0): + dependencies: + acorn: 8.15.0 + + acorn@8.15.0: {} + + ajv-formats@2.1.1(ajv@8.17.1): + optionalDependencies: + ajv: 8.17.1 + + ajv-keywords@3.5.2(ajv@6.12.6): + dependencies: + ajv: 6.12.6 + + ajv-keywords@5.1.0(ajv@8.17.1): + dependencies: + ajv: 8.17.1 + fast-deep-equal: 3.1.3 + + ajv@6.12.6: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ajv@8.17.1: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.0 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + alien-signals@2.0.8: {} + + ansi-regex@5.0.1: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.1 + + apexcharts@3.42.0: + dependencies: + '@yr/monotone-cubic-spline': 1.0.3 + svg.draggable.js: 2.2.2 + svg.easing.js: 2.0.0 + svg.filter.js: 2.0.2 + svg.pathmorphing.js: 0.1.3 + svg.resize.js: 1.4.3 + svg.select.js: 3.0.1 + + argparse@2.0.1: {} + + array-union@2.1.0: {} + + asynckit@0.4.0: {} + + axios-mock-adapter@1.22.0(axios@1.13.4): + dependencies: + axios: 1.13.4 + fast-deep-equal: 3.1.3 + is-buffer: 2.0.5 + + axios@1.13.4: + dependencies: + follow-redirects: 1.15.11 + form-data: 4.0.5 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + + balanced-match@1.0.2: {} + + baseline-browser-mapping@2.9.19: {} + + big.js@5.2.2: {} + + binary-extensions@2.3.0: {} + + boolbase@1.0.0: {} + + brace-expansion@1.1.12: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@2.0.2: + dependencies: + balanced-match: 1.0.2 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserslist@4.28.1: + dependencies: + baseline-browser-mapping: 2.9.19 + caniuse-lite: 1.0.30001769 + electron-to-chromium: 1.5.286 + node-releases: 2.0.27 + update-browserslist-db: 1.2.3(browserslist@4.28.1) + + buffer-from@1.1.2: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + callsite@1.0.0: {} + + callsites@3.1.0: {} + + caniuse-lite@1.0.30001769: {} + + ccount@2.0.1: {} + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + chance@1.1.11: {} + + character-entities-html4@2.1.0: {} + + character-entities-legacy@3.0.0: {} + + chevrotain-allstar@0.3.1(chevrotain@11.0.3): + dependencies: + chevrotain: 11.0.3 + lodash-es: 4.17.23 + + chevrotain@11.0.3: + dependencies: + '@chevrotain/cst-dts-gen': 11.0.3 + '@chevrotain/gast': 11.0.3 + '@chevrotain/regexp-to-ast': 11.0.3 + '@chevrotain/types': 11.0.3 + '@chevrotain/utils': 11.0.3 + lodash-es: 4.17.21 + + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + + chrome-trace-event@1.0.4: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + comma-separated-tokens@2.0.3: {} + + commander@2.20.3: {} + + commander@7.2.0: {} + + commander@8.3.0: {} + + commondir@1.0.1: {} + + concat-map@0.0.1: {} + + confbox@0.1.8: {} + + cose-base@1.0.3: + dependencies: + layout-base: 1.0.2 + + cose-base@2.2.0: + dependencies: + layout-base: 2.0.1 + + crelt@1.0.6: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + cssesc@3.0.0: {} + + csstype@3.2.3: {} + + cytoscape-cose-bilkent@4.1.0(cytoscape@3.33.1): + dependencies: + cose-base: 1.0.3 + cytoscape: 3.33.1 + + cytoscape-fcose@2.2.0(cytoscape@3.33.1): + dependencies: + cose-base: 2.2.0 + cytoscape: 3.33.1 + + cytoscape@3.33.1: {} + + d3-array@2.12.1: + dependencies: + internmap: 1.0.1 + + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-axis@3.0.0: {} + + d3-brush@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + d3-chord@3.0.1: + dependencies: + d3-path: 3.1.0 + + d3-color@3.1.0: {} + + d3-contour@4.0.2: + dependencies: + d3-array: 3.2.4 + + d3-delaunay@6.0.4: + dependencies: + delaunator: 5.0.1 + + d3-dispatch@3.0.1: {} + + d3-drag@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-selection: 3.0.0 + + d3-dsv@3.0.1: + dependencies: + commander: 7.2.0 + iconv-lite: 0.6.3 + rw: 1.3.3 + + d3-ease@3.0.1: {} + + d3-fetch@3.0.1: + dependencies: + d3-dsv: 3.0.1 + + d3-force@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-quadtree: 3.0.1 + d3-timer: 3.0.1 + + d3-format@3.1.2: {} + + d3-geo@3.1.1: + dependencies: + d3-array: 3.2.4 + + d3-hierarchy@3.1.2: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@1.0.9: {} + + d3-path@3.1.0: {} + + d3-polygon@3.0.1: {} + + d3-quadtree@3.0.1: {} + + d3-random@3.0.1: {} + + d3-sankey@0.12.3: + dependencies: + d3-array: 2.12.1 + d3-shape: 1.3.7 + + d3-scale-chromatic@3.1.0: + dependencies: + d3-color: 3.1.0 + d3-interpolate: 3.0.1 + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.2 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-selection@3.0.0: {} + + d3-shape@1.3.7: + dependencies: + d3-path: 1.0.9 + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-timer@3.0.1: {} + + d3-transition@3.0.1(d3-selection@3.0.0): + dependencies: + d3-color: 3.1.0 + d3-dispatch: 3.0.1 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-timer: 3.0.1 + + d3-zoom@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + d3@7.9.0: + dependencies: + d3-array: 3.2.4 + d3-axis: 3.0.0 + d3-brush: 3.0.0 + d3-chord: 3.0.1 + d3-color: 3.1.0 + d3-contour: 4.0.2 + d3-delaunay: 6.0.4 + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-dsv: 3.0.1 + d3-ease: 3.0.1 + d3-fetch: 3.0.1 + d3-force: 3.0.0 + d3-format: 3.1.2 + d3-geo: 3.1.1 + d3-hierarchy: 3.1.2 + d3-interpolate: 3.0.1 + d3-path: 3.1.0 + d3-polygon: 3.0.1 + d3-quadtree: 3.0.1 + d3-random: 3.0.1 + d3-scale: 4.0.2 + d3-scale-chromatic: 3.1.0 + d3-selection: 3.0.0 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + d3-timer: 3.0.1 + d3-transition: 3.0.1(d3-selection@3.0.0) + d3-zoom: 3.0.0 + + dagre-d3-es@7.0.13: + dependencies: + d3: 7.9.0 + lodash-es: 4.17.23 + + date-fns@2.30.0: + dependencies: + '@babel/runtime': 7.28.6 + + dayjs@1.11.19: {} + + de-indent@1.0.2: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decache@4.6.2: + dependencies: + callsite: 1.0.0 + + deep-is@0.1.4: {} + + delaunator@5.0.1: + dependencies: + robust-predicates: 3.0.2 + + delayed-stream@1.0.0: {} + + dequal@2.0.3: {} + + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + + dir-glob@3.0.1: + dependencies: + path-type: 4.0.0 + + doctrine@3.0.0: + dependencies: + esutils: 2.0.3 + + dompurify@3.3.1: + optionalDependencies: + '@types/trusted-types': 2.0.7 + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + electron-to-chromium@1.5.286: {} + + emojis-list@3.0.0: {} + + enhanced-resolve@5.19.0: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.0 + + entities@4.5.0: {} + + entities@7.0.1: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-module-lexer@2.0.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + + esbuild@0.18.20: + optionalDependencies: + '@esbuild/android-arm': 0.18.20 + '@esbuild/android-arm64': 0.18.20 + '@esbuild/android-x64': 0.18.20 + '@esbuild/darwin-arm64': 0.18.20 + '@esbuild/darwin-x64': 0.18.20 + '@esbuild/freebsd-arm64': 0.18.20 + '@esbuild/freebsd-x64': 0.18.20 + '@esbuild/linux-arm': 0.18.20 + '@esbuild/linux-arm64': 0.18.20 + '@esbuild/linux-ia32': 0.18.20 + '@esbuild/linux-loong64': 0.18.20 + '@esbuild/linux-mips64el': 0.18.20 + '@esbuild/linux-ppc64': 0.18.20 + '@esbuild/linux-riscv64': 0.18.20 + '@esbuild/linux-s390x': 0.18.20 + '@esbuild/linux-x64': 0.18.20 + '@esbuild/netbsd-x64': 0.18.20 + '@esbuild/openbsd-x64': 0.18.20 + '@esbuild/sunos-x64': 0.18.20 + '@esbuild/win32-arm64': 0.18.20 + '@esbuild/win32-ia32': 0.18.20 + '@esbuild/win32-x64': 0.18.20 + + escalade@3.2.0: {} + + escape-string-regexp@4.0.0: {} + + eslint-config-prettier@8.10.2(eslint@8.48.0): + dependencies: + eslint: 8.48.0 + + eslint-plugin-prettier@5.5.5(@types/eslint@9.6.1)(eslint-config-prettier@8.10.2(eslint@8.48.0))(eslint@8.48.0)(prettier@3.0.2): + dependencies: + eslint: 8.48.0 + prettier: 3.0.2 + prettier-linter-helpers: 1.0.1 + synckit: 0.11.12 + optionalDependencies: + '@types/eslint': 9.6.1 + eslint-config-prettier: 8.10.2(eslint@8.48.0) + + eslint-plugin-vue@9.17.0(eslint@8.48.0): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@8.48.0) + eslint: 8.48.0 + natural-compare: 1.4.0 + nth-check: 2.1.1 + postcss-selector-parser: 6.1.2 + semver: 7.7.4 + vue-eslint-parser: 9.4.3(eslint@8.48.0) + xml-name-validator: 4.0.0 + transitivePeerDependencies: + - supports-color + + eslint-scope@5.1.1: + dependencies: + esrecurse: 4.3.0 + estraverse: 4.3.0 + + eslint-scope@7.2.2: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint@8.48.0: + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@8.48.0) + '@eslint-community/regexpp': 4.12.2 + '@eslint/eslintrc': 2.1.4 + '@eslint/js': 8.48.0 + '@humanwhocodes/config-array': 0.11.14 + '@humanwhocodes/module-importer': 1.0.1 + '@nodelib/fs.walk': 1.2.8 + ajv: 6.12.6 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + doctrine: 3.0.0 + escape-string-regexp: 4.0.0 + eslint-scope: 7.2.2 + eslint-visitor-keys: 3.4.3 + espree: 9.6.1 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 6.0.1 + find-up: 5.0.0 + glob-parent: 6.0.2 + globals: 13.24.0 + graphemer: 1.4.0 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + is-path-inside: 3.0.3 + js-yaml: 4.1.1 + json-stable-stringify-without-jsonify: 1.0.1 + levn: 0.4.1 + lodash.merge: 4.6.2 + minimatch: 3.1.2 + natural-compare: 1.4.0 + optionator: 0.9.4 + strip-ansi: 6.0.1 + text-table: 0.2.0 + transitivePeerDependencies: + - supports-color + + espree@9.6.1: + dependencies: + acorn: 8.15.0 + acorn-jsx: 5.3.2(acorn@8.15.0) + eslint-visitor-keys: 3.4.3 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@4.3.0: {} + + estraverse@5.3.0: {} + + estree-walker@2.0.2: {} + + esutils@2.0.3: {} + + event-source-polyfill@1.0.31: {} + + events@3.3.0: {} + + fast-deep-equal@3.1.3: {} + + fast-diff@1.3.0: {} + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fast-uri@3.1.0: {} + + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + + file-entry-cache@6.0.1: + dependencies: + flat-cache: 3.2.0 + + file-loader@6.2.0(webpack@5.105.0): + dependencies: + loader-utils: 2.0.4 + schema-utils: 3.3.0 + webpack: 5.105.0 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + find-cache-dir@3.3.2: + dependencies: + commondir: 1.0.1 + make-dir: 3.1.0 + pkg-dir: 4.2.0 + + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@3.2.0: + dependencies: + flatted: 3.3.3 + keyv: 4.5.4 + rimraf: 3.0.2 + + flatted@3.3.3: {} + + follow-redirects@1.15.11: {} + + form-data@4.0.5: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.2 + mime-types: 2.1.35 + + fs.realpath@1.0.0: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + glob-to-regexp@0.4.1: {} + + glob@7.2.3: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.2 + once: 1.4.0 + path-is-absolute: 1.0.1 + + globals@13.24.0: + dependencies: + type-fest: 0.20.2 + + globby@11.1.0: + dependencies: + array-union: 2.1.0 + dir-glob: 3.0.1 + fast-glob: 3.3.3 + ignore: 5.3.2 + merge2: 1.4.1 + slash: 3.0.0 + + gopd@1.2.0: {} + + graceful-fs@4.2.11: {} + + graphemer@1.4.0: {} + + hachure-fill@0.5.2: {} + + has-flag@4.0.0: {} + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + hast-util-to-html@9.0.5: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + ccount: 2.0.1 + comma-separated-tokens: 2.0.3 + hast-util-whitespace: 3.0.0 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.1 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + stringify-entities: 4.0.4 + zwitch: 2.0.4 + + hast-util-whitespace@3.0.0: + dependencies: + '@types/hast': 3.0.4 + + he@1.2.0: {} + + highlight.js@11.11.1: {} + + html-void-elements@3.0.0: {} + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + ignore@5.3.2: {} + + immutable@4.3.7: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + imurmurhash@0.1.4: {} + + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + + inherits@2.0.4: {} + + internmap@1.0.1: {} + + internmap@2.0.3: {} + + interpret@1.4.0: {} + + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 + + is-buffer@2.0.5: {} + + is-core-module@2.16.1: + dependencies: + hasown: 2.0.2 + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-number@7.0.0: {} + + is-path-inside@3.0.3: {} + + isexe@2.0.0: {} + + jest-worker@27.5.1: + dependencies: + '@types/node': 20.19.32 + merge-stream: 2.0.0 + supports-color: 8.1.1 + + js-md5@0.8.3: {} + + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + + json-buffer@3.0.1: {} + + json-parse-even-better-errors@2.3.1: {} + + json-schema-traverse@0.4.1: {} + + json-schema-traverse@1.0.0: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + json5@2.2.3: {} + + katex@0.16.28: + dependencies: + commander: 8.3.0 + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + khroma@2.1.0: {} + + langium@3.3.1: + dependencies: + chevrotain: 11.0.3 + chevrotain-allstar: 0.3.1(chevrotain@11.0.3) + vscode-languageserver: 9.0.1 + vscode-languageserver-textdocument: 1.0.12 + vscode-uri: 3.0.8 + + layout-base@1.0.2: {} + + layout-base@2.0.1: {} + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + linkify-it@5.0.0: + dependencies: + uc.micro: 2.1.0 + + loader-runner@4.3.1: {} + + loader-utils@2.0.4: + dependencies: + big.js: 5.2.2 + emojis-list: 3.0.0 + json5: 2.2.3 + + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash-es@4.17.21: {} + + lodash-es@4.17.23: {} + + lodash.merge@4.6.2: {} + + lodash@4.17.21: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + make-dir@3.1.0: + dependencies: + semver: 6.3.1 + + markdown-it-container@4.0.0: {} + + markdown-it-footnote@4.0.0: {} + + markdown-it-ins@4.0.0: {} + + markdown-it-mark@4.0.0: {} + + markdown-it-sub@2.0.0: {} + + markdown-it-sup@2.0.0: {} + + markdown-it-task-checkbox@1.0.6: {} + + markdown-it-ts@0.0.3: + dependencies: + entities: 4.5.0 + linkify-it: 5.0.0 + mdurl: 2.0.0 + punycode.js: 2.3.1 + uc.micro: 2.1.0 + + markdown-it@14.1.0: + dependencies: + argparse: 2.0.1 + entities: 4.5.0 + linkify-it: 5.0.0 + mdurl: 2.0.0 + punycode.js: 2.3.1 + uc.micro: 2.1.0 + + marked@16.4.2: {} + + markstream-vue@0.0.6(katex@0.16.28)(mermaid@11.12.2)(shiki@3.22.0)(stream-markdown@0.0.13(shiki@3.22.0))(stream-monaco@0.0.17(monaco-editor@0.52.2))(vue-i18n@11.2.8(vue@3.3.4))(vue@3.3.4): + dependencies: + '@floating-ui/dom': 1.7.5 + stream-markdown-parser: 0.0.59-beta.3 + vue: 3.3.4 + optionalDependencies: + katex: 0.16.28 + mermaid: 11.12.2 + shiki: 3.22.0 + stream-markdown: 0.0.13(shiki@3.22.0) + stream-monaco: 0.0.17(monaco-editor@0.52.2) + vue-i18n: 11.2.8(vue@3.3.4) + + math-intrinsics@1.1.0: {} + + mdast-util-to-hast@13.2.1: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@ungap/structured-clone': 1.3.0 + devlop: 1.1.0 + micromark-util-sanitize-uri: 2.0.1 + trim-lines: 3.0.1 + unist-util-position: 5.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + + mdurl@2.0.0: {} + + merge-stream@2.0.0: {} + + merge2@1.4.1: {} + + mermaid@11.12.2: + dependencies: + '@braintree/sanitize-url': 7.1.2 + '@iconify/utils': 3.1.0 + '@mermaid-js/parser': 0.6.3 + '@types/d3': 7.4.3 + cytoscape: 3.33.1 + cytoscape-cose-bilkent: 4.1.0(cytoscape@3.33.1) + cytoscape-fcose: 2.2.0(cytoscape@3.33.1) + d3: 7.9.0 + d3-sankey: 0.12.3 + dagre-d3-es: 7.0.13 + dayjs: 1.11.19 + dompurify: 3.3.1 + katex: 0.16.28 + khroma: 2.1.0 + lodash-es: 4.17.23 + marked: 16.4.2 + roughjs: 4.6.6 + stylis: 4.3.6 + ts-dedent: 2.2.0 + uuid: 11.1.0 + + micromark-util-character@2.1.1: + dependencies: + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-encode@2.0.1: {} + + micromark-util-sanitize-uri@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-encode: 2.0.1 + micromark-util-symbol: 2.0.1 + + micromark-util-symbol@2.0.1: {} + + micromark-util-types@2.0.2: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.1 + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + minimatch@3.1.2: + dependencies: + brace-expansion: 1.1.12 + + minimatch@9.0.5: + dependencies: + brace-expansion: 2.0.2 + + mlly@1.8.0: + dependencies: + acorn: 8.15.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.3 + + monaco-editor@0.52.2: {} + + ms@2.1.3: {} + + muggle-string@0.3.1: {} + + nanoid@3.3.11: {} + + natural-compare-lite@1.4.0: {} + + natural-compare@1.4.0: {} + + neo-async@2.6.2: {} + + node-releases@2.0.27: {} + + normalize-path@3.0.0: {} + + nth-check@2.1.1: + dependencies: + boolbase: 1.0.0 + + null-loader@4.0.1(webpack@5.105.0): + dependencies: + loader-utils: 2.0.4 + schema-utils: 3.3.0 + webpack: 5.105.0 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + oniguruma-parser@0.12.1: {} + + oniguruma-to-es@4.3.4: + dependencies: + oniguruma-parser: 0.12.1 + regex: 6.1.0 + regex-recursion: 6.0.2 + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + orderedmap@2.1.1: {} + + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + p-try@2.2.0: {} + + package-manager-detector@1.6.0: {} + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + path-browserify@1.0.1: {} + + path-data-parser@0.1.0: {} + + path-exists@4.0.0: {} + + path-is-absolute@1.0.1: {} + + path-key@3.1.1: {} + + path-parse@1.0.7: {} + + path-type@4.0.0: {} + + pathe@2.0.3: {} + + picocolors@1.1.1: {} + + picomatch@2.3.1: {} + + pinia@2.1.6(typescript@5.1.6)(vue@3.3.4): + dependencies: + '@vue/devtools-api': 6.6.4 + vue: 3.3.4 + vue-demi: 0.14.10(vue@3.3.4) + optionalDependencies: + typescript: 5.1.6 + + pinyin-pro@3.28.0: {} + + pkg-dir@4.2.0: + dependencies: + find-up: 4.1.0 + + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.0 + pathe: 2.0.3 + + points-on-curve@0.2.0: {} + + points-on-path@0.2.1: + dependencies: + path-data-parser: 0.1.0 + points-on-curve: 0.2.0 + + postcss-selector-parser@6.1.2: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss@8.5.6: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prelude-ls@1.2.1: {} + + prettier-linter-helpers@1.0.1: + dependencies: + fast-diff: 1.3.0 + + prettier@3.0.2: {} + + property-expr@2.0.6: {} + + property-information@7.1.0: {} + + prosemirror-changeset@2.3.1: + dependencies: + prosemirror-transform: 1.11.0 + + prosemirror-collab@1.3.1: + dependencies: + prosemirror-state: 1.4.4 + + prosemirror-commands@1.7.1: + dependencies: + prosemirror-model: 1.25.4 + prosemirror-state: 1.4.4 + prosemirror-transform: 1.11.0 + + prosemirror-dropcursor@1.8.2: + dependencies: + prosemirror-state: 1.4.4 + prosemirror-transform: 1.11.0 + prosemirror-view: 1.41.6 + + prosemirror-gapcursor@1.4.0: + dependencies: + prosemirror-keymap: 1.2.3 + prosemirror-model: 1.25.4 + prosemirror-state: 1.4.4 + prosemirror-view: 1.41.6 + + prosemirror-history@1.5.0: + dependencies: + prosemirror-state: 1.4.4 + prosemirror-transform: 1.11.0 + prosemirror-view: 1.41.6 + rope-sequence: 1.3.4 + + prosemirror-inputrules@1.5.1: + dependencies: + prosemirror-state: 1.4.4 + prosemirror-transform: 1.11.0 + + prosemirror-keymap@1.2.3: + dependencies: + prosemirror-state: 1.4.4 + w3c-keyname: 2.2.8 + + prosemirror-markdown@1.13.4: + dependencies: + '@types/markdown-it': 14.1.2 + markdown-it: 14.1.0 + prosemirror-model: 1.25.4 + + prosemirror-menu@1.2.5: + dependencies: + crelt: 1.0.6 + prosemirror-commands: 1.7.1 + prosemirror-history: 1.5.0 + prosemirror-state: 1.4.4 + + prosemirror-model@1.25.4: + dependencies: + orderedmap: 2.1.1 + + prosemirror-schema-basic@1.2.4: + dependencies: + prosemirror-model: 1.25.4 + + prosemirror-schema-list@1.5.1: + dependencies: + prosemirror-model: 1.25.4 + prosemirror-state: 1.4.4 + prosemirror-transform: 1.11.0 + + prosemirror-state@1.4.4: + dependencies: + prosemirror-model: 1.25.4 + prosemirror-transform: 1.11.0 + prosemirror-view: 1.41.6 + + prosemirror-tables@1.8.5: + dependencies: + prosemirror-keymap: 1.2.3 + prosemirror-model: 1.25.4 + prosemirror-state: 1.4.4 + prosemirror-transform: 1.11.0 + prosemirror-view: 1.41.6 + + prosemirror-trailing-node@3.0.0(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.6): + dependencies: + '@remirror/core-constants': 3.0.0 + escape-string-regexp: 4.0.0 + prosemirror-model: 1.25.4 + prosemirror-state: 1.4.4 + prosemirror-view: 1.41.6 + + prosemirror-transform@1.11.0: + dependencies: + prosemirror-model: 1.25.4 + + prosemirror-view@1.41.6: + dependencies: + prosemirror-model: 1.25.4 + prosemirror-state: 1.4.4 + prosemirror-transform: 1.11.0 + + proxy-from-env@1.1.0: {} + + punycode.js@2.3.1: {} + + punycode@2.3.1: {} + + querystring@0.2.1: {} + + queue-microtask@1.2.3: {} + + randombytes@2.1.0: + dependencies: + safe-buffer: 5.2.1 + + readdirp@3.6.0: + dependencies: + picomatch: 2.3.1 + + rechoir@0.6.2: + dependencies: + resolve: 1.22.11 + + regex-recursion@6.0.2: + dependencies: + regex-utilities: 2.3.0 + + regex-utilities@2.3.0: {} + + regex@6.1.0: + dependencies: + regex-utilities: 2.3.0 + + remixicon@3.5.0: {} + + require-from-string@2.0.2: {} + + resolve-from@4.0.0: {} + + resolve@1.22.11: + dependencies: + is-core-module: 2.16.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + reusify@1.1.0: {} + + rimraf@3.0.2: + dependencies: + glob: 7.2.3 + + robust-predicates@3.0.2: {} + + rollup@3.29.5: + optionalDependencies: + fsevents: 2.3.3 + + rope-sequence@1.3.4: {} + + roughjs@4.6.6: + dependencies: + hachure-fill: 0.5.2 + path-data-parser: 0.1.0 + points-on-curve: 0.2.0 + points-on-path: 0.2.1 + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + rw@1.3.3: {} + + safe-buffer@5.2.1: {} + + safer-buffer@2.1.2: {} + + sass-loader@13.3.2(sass@1.66.1)(webpack@5.105.0): + dependencies: + neo-async: 2.6.2 + webpack: 5.105.0 + optionalDependencies: + sass: 1.66.1 + + sass@1.66.1: + dependencies: + chokidar: 3.6.0 + immutable: 4.3.7 + source-map-js: 1.2.1 + + schema-utils@3.3.0: + dependencies: + '@types/json-schema': 7.0.15 + ajv: 6.12.6 + ajv-keywords: 3.5.2(ajv@6.12.6) + + schema-utils@4.3.3: + dependencies: + '@types/json-schema': 7.0.15 + ajv: 8.17.1 + ajv-formats: 2.1.1(ajv@8.17.1) + ajv-keywords: 5.1.0(ajv@8.17.1) + + semver@6.3.1: {} + + semver@7.7.4: {} + + serialize-javascript@6.0.2: + dependencies: + randombytes: 2.1.0 + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + shelljs@0.8.5: + dependencies: + glob: 7.2.3 + interpret: 1.4.0 + rechoir: 0.6.2 + + shiki@3.22.0: + dependencies: + '@shikijs/core': 3.22.0 + '@shikijs/engine-javascript': 3.22.0 + '@shikijs/engine-oniguruma': 3.22.0 + '@shikijs/langs': 3.22.0 + '@shikijs/themes': 3.22.0 + '@shikijs/types': 3.22.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + + slash@3.0.0: {} + + source-map-js@1.2.1: {} + + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map@0.6.1: {} + + space-separated-tokens@2.0.2: {} + + state-local@1.0.7: {} + + stream-markdown-parser@0.0.59-beta.3: + dependencies: + markdown-it-container: 4.0.0 + markdown-it-footnote: 4.0.0 + markdown-it-ins: 4.0.0 + markdown-it-mark: 4.0.0 + markdown-it-sub: 2.0.0 + markdown-it-sup: 2.0.0 + markdown-it-task-checkbox: 1.0.6 + markdown-it-ts: 0.0.3 + + stream-markdown@0.0.13(shiki@3.22.0): + dependencies: + shiki: 3.22.0 + + stream-monaco@0.0.17(monaco-editor@0.52.2): + dependencies: + '@shikijs/monaco': 3.22.0 + alien-signals: 2.0.8 + monaco-editor: 0.52.2 + shiki: 3.22.0 + + stringify-entities@4.0.4: + dependencies: + character-entities-html4: 2.1.0 + character-entities-legacy: 3.0.0 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-json-comments@3.1.1: {} + + stylis@4.3.6: {} + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-color@8.1.1: + dependencies: + has-flag: 4.0.0 + + supports-preserve-symlinks-flag@1.0.0: {} + + svg.draggable.js@2.2.2: + dependencies: + svg.js: 2.7.1 + + svg.easing.js@2.0.0: + dependencies: + svg.js: 2.7.1 + + svg.filter.js@2.0.2: + dependencies: + svg.js: 2.7.1 + + svg.js@2.7.1: {} + + svg.pathmorphing.js@0.1.3: + dependencies: + svg.js: 2.7.1 + + svg.resize.js@1.4.3: + dependencies: + svg.js: 2.7.1 + svg.select.js: 2.1.2 + + svg.select.js@2.1.2: + dependencies: + svg.js: 2.7.1 + + svg.select.js@3.0.1: + dependencies: + svg.js: 2.7.1 + + synckit@0.11.12: + dependencies: + '@pkgr/core': 0.2.9 + + tapable@2.3.0: {} + + terser-webpack-plugin@5.3.16(webpack@5.105.0): + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + jest-worker: 27.5.1 + schema-utils: 4.3.3 + serialize-javascript: 6.0.2 + terser: 5.46.0 + webpack: 5.105.0 + + terser@5.46.0: + dependencies: + '@jridgewell/source-map': 0.3.11 + acorn: 8.15.0 + commander: 2.20.3 + source-map-support: 0.5.21 + + text-table@0.2.0: {} + + tiny-case@1.0.3: {} + + tinyexec@1.0.2: {} + + tippy.js@6.3.7: + dependencies: + '@popperjs/core': 2.11.8 + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + toposort@2.0.2: {} + + trim-lines@3.0.1: {} + + ts-dedent@2.2.0: {} + + tslib@1.14.1: {} + + tsutils@3.21.0(typescript@5.1.6): + dependencies: + tslib: 1.14.1 + typescript: 5.1.6 + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + type-fest@0.20.2: {} + + type-fest@2.19.0: {} + + type-fest@4.41.0: {} + + typescript@5.1.6: {} + + uc.micro@2.1.0: {} + + ufo@1.6.3: {} + + undici-types@6.21.0: {} + + unist-util-is@6.0.1: + dependencies: + '@types/unist': 3.0.3 + + unist-util-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-stringify-position@4.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-parents@6.0.2: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-visit@5.1.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + + upath@2.0.1: {} + + update-browserslist-db@1.2.3(browserslist@4.28.1): + dependencies: + browserslist: 4.28.1 + escalade: 3.2.0 + picocolors: 1.1.1 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + util-deprecate@1.0.2: {} + + uuid@11.1.0: {} + + vee-validate@4.11.3(vue@3.3.4): + dependencies: + '@vue/devtools-api': 6.6.4 + type-fest: 4.41.0 + vue: 3.3.4 + + vfile-message@4.0.3: + dependencies: + '@types/unist': 3.0.3 + unist-util-stringify-position: 4.0.0 + + vfile@6.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile-message: 4.0.3 + + vite-plugin-vuetify@1.0.2(vite@4.4.9(@types/node@20.19.32)(sass@1.66.1)(terser@5.46.0))(vue@3.3.4)(vuetify@3.7.11): + dependencies: + '@vuetify/loader-shared': 1.7.1(vue@3.3.4)(vuetify@3.7.11) + debug: 4.4.3 + upath: 2.0.1 + vite: 4.4.9(@types/node@20.19.32)(sass@1.66.1)(terser@5.46.0) + vue: 3.3.4 + vuetify: 3.7.11(typescript@5.1.6)(vite-plugin-vuetify@1.0.2)(vue@3.3.4) + transitivePeerDependencies: + - supports-color + + vite@4.4.9(@types/node@20.19.32)(sass@1.66.1)(terser@5.46.0): + dependencies: + esbuild: 0.18.20 + postcss: 8.5.6 + rollup: 3.29.5 + optionalDependencies: + '@types/node': 20.19.32 + fsevents: 2.3.3 + sass: 1.66.1 + terser: 5.46.0 + + vscode-jsonrpc@8.2.0: {} + + vscode-languageserver-protocol@3.17.5: + dependencies: + vscode-jsonrpc: 8.2.0 + vscode-languageserver-types: 3.17.5 + + vscode-languageserver-textdocument@1.0.12: {} + + vscode-languageserver-types@3.17.5: {} + + vscode-languageserver@9.0.1: + dependencies: + vscode-languageserver-protocol: 3.17.5 + + vscode-uri@3.0.8: {} + + vue-cli-plugin-vuetify@2.5.8(sass-loader@13.3.2(sass@1.66.1)(webpack@5.105.0))(vue@3.3.4)(vuetify-loader@2.0.0-alpha.9(@vue/compiler-sfc@3.3.4)(vue@3.3.4)(vuetify@3.7.11)(webpack@5.105.0))(webpack@5.105.0): + dependencies: + null-loader: 4.0.1(webpack@5.105.0) + semver: 7.7.4 + shelljs: 0.8.5 + vue: 3.3.4 + webpack: 5.105.0 + optionalDependencies: + sass-loader: 13.3.2(sass@1.66.1)(webpack@5.105.0) + vuetify-loader: 2.0.0-alpha.9(@vue/compiler-sfc@3.3.4)(vue@3.3.4)(vuetify@3.7.11)(webpack@5.105.0) + + vue-demi@0.14.10(vue@3.3.4): + dependencies: + vue: 3.3.4 + + vue-eslint-parser@9.4.3(eslint@8.48.0): + dependencies: + debug: 4.4.3 + eslint: 8.48.0 + eslint-scope: 7.2.2 + eslint-visitor-keys: 3.4.3 + espree: 9.6.1 + esquery: 1.7.0 + lodash: 4.17.21 + semver: 7.7.4 + transitivePeerDependencies: + - supports-color + + vue-i18n@11.2.8(vue@3.3.4): + dependencies: + '@intlify/core-base': 11.2.8 + '@intlify/shared': 11.2.8 + '@vue/devtools-api': 6.6.4 + vue: 3.3.4 + + vue-router@4.2.4(vue@3.3.4): + dependencies: + '@vue/devtools-api': 6.6.4 + vue: 3.3.4 + + vue-template-compiler@2.7.16: + dependencies: + de-indent: 1.0.2 + he: 1.2.0 + + vue-tsc@1.8.8(typescript@5.1.6): + dependencies: + '@vue/language-core': 1.8.8(typescript@5.1.6) + '@vue/typescript': 1.8.8(typescript@5.1.6) + semver: 7.7.4 + typescript: 5.1.6 + + vue3-apexcharts@1.4.4(apexcharts@3.42.0)(vue@3.3.4): + dependencies: + apexcharts: 3.42.0 + vue: 3.3.4 + + vue3-print-nb@0.1.4: + dependencies: + vue: 3.3.4 + + vue@3.3.4: + dependencies: + '@vue/compiler-dom': 3.3.4 + '@vue/compiler-sfc': 3.3.4 + '@vue/runtime-dom': 3.3.4 + '@vue/server-renderer': 3.3.4(vue@3.3.4) + '@vue/shared': 3.3.4 + + vuetify-loader@2.0.0-alpha.9(@vue/compiler-sfc@3.3.4)(vue@3.3.4)(vuetify@3.7.11)(webpack@5.105.0): + dependencies: + '@vue/compiler-sfc': 3.3.4 + '@vuetify/loader-shared': 1.7.1(vue@3.3.4)(vuetify@3.7.11) + decache: 4.6.2 + file-loader: 6.2.0(webpack@5.105.0) + find-cache-dir: 3.3.2 + loader-utils: 2.0.4 + null-loader: 4.0.1(webpack@5.105.0) + querystring: 0.2.1 + upath: 2.0.1 + vuetify: 3.7.11(typescript@5.1.6)(vite-plugin-vuetify@1.0.2)(vue@3.3.4) + webpack: 5.105.0 + transitivePeerDependencies: + - vue + + vuetify@3.7.11(typescript@5.1.6)(vite-plugin-vuetify@1.0.2)(vue@3.3.4): + dependencies: + vue: 3.3.4 + optionalDependencies: + typescript: 5.1.6 + vite-plugin-vuetify: 1.0.2(vite@4.4.9(@types/node@20.19.32)(sass@1.66.1)(terser@5.46.0))(vue@3.3.4)(vuetify@3.7.11) + + w3c-keyname@2.2.8: {} + + watchpack@2.5.1: + dependencies: + glob-to-regexp: 0.4.1 + graceful-fs: 4.2.11 + + webpack-sources@3.3.3: {} + + webpack@5.105.0: + dependencies: + '@types/eslint-scope': 3.7.7 + '@types/estree': 1.0.8 + '@types/json-schema': 7.0.15 + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/wasm-edit': 1.14.1 + '@webassemblyjs/wasm-parser': 1.14.1 + acorn: 8.15.0 + acorn-import-phases: 1.0.4(acorn@8.15.0) + browserslist: 4.28.1 + chrome-trace-event: 1.0.4 + enhanced-resolve: 5.19.0 + es-module-lexer: 2.0.0 + eslint-scope: 5.1.1 + events: 3.3.0 + glob-to-regexp: 0.4.1 + graceful-fs: 4.2.11 + json-parse-even-better-errors: 2.3.1 + loader-runner: 4.3.1 + mime-types: 2.1.35 + neo-async: 2.6.2 + schema-utils: 4.3.3 + tapable: 2.3.0 + terser-webpack-plugin: 5.3.16(webpack@5.105.0) + watchpack: 2.5.1 + webpack-sources: 3.3.3 + transitivePeerDependencies: + - '@swc/core' + - esbuild + - uglify-js + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + word-wrap@1.2.5: {} + + wrappy@1.0.2: {} + + xml-name-validator@4.0.0: {} + + yocto-queue@0.1.0: {} + + yup@1.2.0: + dependencies: + property-expr: 2.0.6 + tiny-case: 1.0.3 + toposort: 2.0.2 + type-fest: 2.19.0 + + zwitch@2.0.4: {} diff --git a/dashboard/public/config.json b/dashboard/public/config.json deleted file mode 100644 index 0d7e84a8a..000000000 --- a/dashboard/public/config.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "apiBaseUrl": "", - "presets": [ - { - "name": "Default (Auto)", - "url": "" - }, - { - "name": "Localhost", - "url": "http://localhost:6185" - } - ] -} diff --git a/dashboard/src/App.vue b/dashboard/src/App.vue index 8f2b8e7b3..af23e75ff 100644 --- a/dashboard/src/App.vue +++ b/dashboard/src/App.vue @@ -1,5 +1,6 @@ @@ -112,4 +177,4 @@ export default { margin-top: 16px; } } - \ No newline at end of file + diff --git a/dashboard/src/components/config/UnsavedChangesConfirmDialog.vue b/dashboard/src/components/config/UnsavedChangesConfirmDialog.vue new file mode 100644 index 000000000..f81f1167f --- /dev/null +++ b/dashboard/src/components/config/UnsavedChangesConfirmDialog.vue @@ -0,0 +1,98 @@ + + + + + + diff --git a/dashboard/src/components/extension/MarketPluginCard.vue b/dashboard/src/components/extension/MarketPluginCard.vue new file mode 100644 index 000000000..edae9227d --- /dev/null +++ b/dashboard/src/components/extension/MarketPluginCard.vue @@ -0,0 +1,321 @@ + + + + + diff --git a/dashboard/src/components/extension/McpServersSection.vue b/dashboard/src/components/extension/McpServersSection.vue index 4330396bb..95b679580 100644 --- a/dashboard/src/components/extension/McpServersSection.vue +++ b/dashboard/src/components/extension/McpServersSection.vue @@ -218,6 +218,10 @@ import axios from 'axios'; import { VueMonacoEditor } from '@guolao/vue-monaco-editor'; import ItemCard from '@/components/shared/ItemCard.vue'; import { useI18n, useModuleI18n } from '@/i18n/composables'; +import { + askForConfirmation as askForConfirmationDialog, + useConfirmDialog +} from '@/utils/confirmDialog'; export default { name: 'McpServersSection', @@ -228,7 +232,8 @@ export default { setup() { const { t } = useI18n(); const { tm } = useModuleI18n('features/tooluse'); - return { t, tm }; + const confirmDialog = useConfirmDialog(); + return { t, tm, confirmDialog }; }, data() { return { @@ -382,18 +387,21 @@ export default { this.showError(this.tm('dialogs.addServer.errors.jsonParse', { error: e.message })); } }, - deleteServer(server) { + async deleteServer(server) { const serverName = server.name || server; - if (confirm(this.tm('dialogs.confirmDelete', { name: serverName }))) { - axios.post('/api/tools/mcp/delete', { name: serverName }) - .then(response => { - this.getServers(); - this.showSuccess(response.data.message || this.tm('messages.deleteSuccess')); - }) - .catch(error => { - this.showError(this.tm('messages.deleteError', { error: error.response?.data?.message || error.message })); - }); + const message = this.tm('dialogs.confirmDelete', { name: serverName }); + if (!(await askForConfirmationDialog(message, this.confirmDialog))) { + return; } + + axios.post('/api/tools/mcp/delete', { name: serverName }) + .then(response => { + this.getServers(); + this.showSuccess(response.data.message || this.tm('messages.deleteSuccess')); + }) + .catch(error => { + this.showError(this.tm('messages.deleteError', { error: error.response?.data?.message || error.message })); + }); }, editServer(server) { const configCopy = { ...server }; diff --git a/dashboard/src/components/extension/componentPanel/components/CommandTable.vue b/dashboard/src/components/extension/componentPanel/components/CommandTable.vue index f8bb6fa82..32eebb746 100644 --- a/dashboard/src/components/extension/componentPanel/components/CommandTable.vue +++ b/dashboard/src/components/extension/componentPanel/components/CommandTable.vue @@ -18,6 +18,7 @@ const emit = defineEmits<{ (e: 'toggle-command', cmd: CommandItem): void; (e: 'rename', cmd: CommandItem): void; (e: 'view-details', cmd: CommandItem): void; + (e: 'update-permission', cmd: CommandItem, permission: 'admin' | 'member'): void; }>(); // 表格表头 @@ -146,9 +147,36 @@ const getRowProps = ({ item }: { item: CommandItem }) => {