Compare commits

...

71 Commits

Author SHA1 Message Date
Soulter a0656483b0 chore: update readme 2026-02-27 22:47:10 +08:00
Soulter f6ac6b9007 docs: update readme
- Updated links in Japanese, Russian, and Traditional Chinese README files to include a new Simplified Chinese README.
- Enhanced the description and features of AstrBot across all language versions.
- Improved formatting of supported messaging platforms and model services in Japanese, Russian, and Traditional Chinese README files.
- Added a new README file in Simplified Chinese with comprehensive details about AstrBot, including features, deployment methods, and community contributions.
2026-02-27 22:40:38 +08:00
LIghtJUNction b8c73430fb Revert "可选后端,实现前后端分离" (#5536) 2026-02-27 22:02:37 +08:00
LIghtJUNction 3141ed52bd Merge branch 'feat/optional-backend' into master 2026-02-27 21:53:56 +08:00
Soulter 63ff234f10 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
2026-02-27 14:02:10 +08:00
Soulter 5219ba5c4e 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
2026-02-26 21:38:47 +08:00
Soulter 84994b5d98 chore: bump version to 4.18.3 2026-02-26 19:12:09 +08:00
圣达生物多 1554f71106 [bug]查看介入教程line前往错误界面的问题 (#5479)
Fixes #5478
2026-02-26 19:04:53 +08:00
Soulter 476c01469f fix(line): line adapter does not appear in the add platform dialog
fixes: #5477
2026-02-26 15:26:37 +08:00
Dt8333 10163ec78a chore: 为类型检查添加 TYPE_CHECKING 的导入与阶段类型引用 (#5474) 2026-02-26 14:19:52 +08:00
Waterwzy 98b89ebcc5 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>
2026-02-26 10:02:43 +08:00
CAICAII 39b9e55434 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
2026-02-26 09:59:37 +08:00
CCCCCCTV 3eb15089af fix(telegram): avoid duplicate message_thread_id in streaming (#5430) 2026-02-25 19:53:17 +08:00
Dt8333 c5b23d12a8 fix: 修复Pyright静态类型检查报错 (#5437)
* refactor: 修正 Sqlite 查询、下载回调、接口重构与类型调整

* feat: 为 OneBotClient 增加 CallAction 协议与异步调用支持
2026-02-25 19:49:16 +08:00
exynos 69f2fb291a 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

* 回退检测逻辑
2026-02-25 19:48:03 +08:00
時壹 78660da995 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
2026-02-25 19:45:27 +08:00
Soulter c951b14aa2 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.
2026-02-25 19:42:51 +08:00
Soulter c384439b44 perf(cron): enhance future task session isolation
fixes: #5392
2026-02-25 16:32:09 +08:00
鸦羽 87d2750ff8 Merge pull request #5440 from Raven95676/fix/persona-loss
fix(persona): preserve conversation persona_id and unify session/conversation resolution
2026-02-25 16:11:19 +08:00
Luna_Dol 6d76d55452 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
2026-02-25 15:21:30 +08:00
Raven95676 d80598b9c3 fix(persona): enhance persona resolution logic for conversations and sessions 2026-02-25 15:14:46 +08:00
letr c7d318304b 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
2026-02-25 14:54:54 +08:00
exynos bcdbc15635 fix(dashboard): 修复设置页新建 API Key 后复制失败问题 (#5439) 2026-02-25 14:54:06 +08:00
Raven95676 4749159bb9 fix(conversation): retain existing persona_id when updating conversation 2026-02-25 14:48:46 +08:00
エイカク 5530a2260a 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
2026-02-25 10:01:13 +09:00
Soulter c24de24ca4 chore: ruff format 2026-02-24 23:12:18 +08:00
Yunhao Cao b54b4c79ed fix: Telegram voice message format (OGG instead of WAV) causing issues with OpenAI STT API (#5389) 2026-02-24 23:11:56 +08:00
Soulter c6cc7aae84 chore: bump version to 4.18.2 2026-02-24 23:08:53 +08:00
Soulter 84cd209074 chore: bump version to 4.18.2 2026-02-24 22:48:27 +08:00
Soulter afda44fbe3 chore: bump version to 4.18.2 2026-02-24 22:44:35 +08:00
Soulter f5d3b93437 fix(context): improve logging for platform not found in session 2026-02-24 22:37:51 +08:00
Soulter 069a3628fa fix(context): log warning when platform not found for session 2026-02-24 22:37:10 +08:00
氕氙 c81ef2672a fix: pass embedding dimensions to provider apis (#5411) 2026-02-24 22:09:44 +08:00
Soulter a5ae27cae0 fix(aiocqhttp): enhance shutdown process for aiocqhttp adapter (#5412) 2026-02-24 22:07:42 +08:00
Helian Nuits 73faaf6577 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' 作为回退值。
2026-02-24 21:04:01 +08:00
Helian Nuits 29dbd085d4 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 系统下的文件操作鲁棒性。
2026-02-24 21:03:06 +08:00
Axi404 00b011809a 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
2026-02-24 20:59:44 +08:00
Axi404 0b46ca7ff3 feat: enable computer-use tools for subagent handoff (#5399) 2026-02-24 16:32:12 +08:00
whatevertogo 9294b44831 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 <noreply@anthropic.com>

* fix: add logging for get_config() failure in Star class

* fix: reorder logger initialization in base.py

---------

Co-authored-by: whatevertogo <whatevertogo@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 13:53:29 +08:00
Soulter 80fd51119b feat: add support for showing tool call results in agent execution (#5388)
closes: #5329
2026-02-24 00:46:45 +08:00
whatevertogo 5af5ad9e36 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 <noreply@anthropic.com>

* fix: improve message type handling and add defensive tests

---------

Co-authored-by: whatevertogo <whatevertogo@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 23:36:39 +08:00
whatevertogo 7b731ebda8 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 <noreply@anthropic.com>

* refactor(tests): 移动并重构模拟 LLM 响应和消息组件函数

* fix(tests): 优化 pytest_runtest_setup 中的标记检查逻辑

---------

Co-authored-by: whatevertogo <whatevertogo@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 23:35:15 +08:00
PyuraMazo 28bfb3b8b2 feat: add plugin load&unload hook (#5331)
* 添加了插件的加载完成和卸载完成的钩子事件

* 添加了插件的加载完成和卸载完成的钩子事件

* format code with ruff

* ruff format

---------

Co-authored-by: Soulter <905617992@qq.com>
2026-02-23 23:13:41 +08:00
tangsenfei 351895ae66 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.
2026-02-23 22:27:56 +08:00
hanbings c1009adf52 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.
2026-02-23 22:24:41 +08:00
Waterwzy ecaec41208 feat: add hot reload when failed to load plugins (#5334)
* feat:add hot reload when failed to load plugins

* apply bot suggestions
2026-02-23 22:17:48 +08:00
Chen 997b51102b 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>
2026-02-23 22:16:14 +08:00
Helian Nuits c5bd074c28 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>
2026-02-23 22:05:22 +08:00
鸦羽 4c09ed3c09 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
2026-02-23 22:04:47 +08:00
Soulter a56e43d17e fix: chatui cannot persist file segment (#5386) 2026-02-23 22:02:49 +08:00
Soulter e357d9de74 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
2026-02-23 20:21:30 +08:00
エイカク 94736ff199 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
2026-02-22 20:23:32 +09:00
LIghtJUNction 48c2d98dde 删除 bun.lock,让行数看起来没那么夸张 2026-02-09 00:22:46 +08:00
LIghtJUNction af09b5cb16 Update astrbot/dashboard/server.py
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-09 00:21:37 +08:00
LIghtJUNction 31f46045d7 修正/修正一个错误 2026-02-07 20:17:44 +08:00
LIghtJUNction d6455d774b 修正/一个导入问题 2026-02-07 20:02:44 +08:00
LIghtJUNction 3e928b9659 修正/CI工作流反馈的一些问题 2026-02-07 19:54:54 +08:00
LIghtJUNction df1299b192 移除自定义的一个协议 2026-02-06 12:38:55 +08:00
LIghtJUNction 15ee17724d Merge branch 'feat/optional-backend' of https://github.com/AstrBotDevs/AstrBot into feat/optional-backend 2026-02-06 12:37:58 +08:00
LIghtJUNction 437c186a66 类型标注 2026-02-06 12:37:41 +08:00
LIghtJUNction 3610a42ebf Merge branch 'master' into feat/optional-backend 2026-02-06 04:22:49 +08:00
LIghtJUNction bf1bde79ec Update astrbot/core/utils/io.py
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-06 04:22:03 +08:00
LIghtJUNction f309638192 Update astrbot/dashboard/server.py
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-06 04:21:40 +08:00
LIghtJUNction 6439e4e152 Update astrbot/cli/commands/cmd_run.py
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-06 04:21:02 +08:00
LIghtJUNction 4b1395b2c9 Update astrbot/dashboard/routes/route.py
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-06 04:19:41 +08:00
LIghtJUNction 1859206007 feat: 支持前后端分离部署与动态后端地址配置
后端:
- 支持通过 DASHBOARD_ENABLE 环境变量分离 WebUI 服务
- 修复 CORS 跨域预检请求(OPTIONS) 鉴权问题
- 优化 IPv6 地址过滤逻辑
- CLI 新增 --backend-only 模式支持

前端:
- 新增 API 地址动态配置与预设管理功能 (登录页/设置页)
- 修复 WebSocket/SSE 连接地址适配非同源环境
- 修复 TypeScript 配置报错
- 完善新增功能的国际化支持
2026-02-06 04:06:52 +08:00
LIghtJUNction 3b93429353 新增cors配置项 2026-02-06 04:01:30 +08:00
LIghtJUNction d68ccfcc96 1.前端的后端配置页面新增新增按钮,允许新增后端,自由切换后端。2.一些必要的改进,比如astrbot init初始化时候询问是否下载前端,可选择不下载,使用--backend-only选项时候,不再提示要下载前端 2026-02-06 03:47:53 +08:00
LIghtJUNction 68b8a1a01c 将enable变量含义释为:是否启用集成前端,如果为False,保留后端能力,而不是后端也关闭了 2026-02-06 03:13:46 +08:00
LIghtJUNction 75ee46715a 支持ipv6并完善astrbot run子命令
* 默认host修改为::,同时新增两个环境变量DASHBOARD_HOST,DASHBOARD_ENABLE,和DASHBOARD_PORT对齐

* feat: systemd support (#4880)

* fix: pyright lint (#4874)

* feat: 将 MessageSession 的 platform_id 改为 init=False,实例化时无需传入

Co-authored-by: aider (openai/gpt-5.2) <aider@aider.chat>

* refactor: 将 isinstance 检查改为元组、将默认模型值设为空字符串、将类型注解改为 Any 并导入

* refactor: 为 _serialize_job 增加返回类型注解 dict

* fix: 使用 cast 获取百度 AIP 的 msg 并对 psutil_addr 引入 type: ignore

Co-authored-by: aider (openai/gpt-5.2) <aider@aider.chat>

* refactor: 引入 _AddrWithPort 协议并替换 conn.laddr 的 cast

Co-authored-by: aider (openai/gpt-5.2) <aider@aider.chat>

* fix: 在构建 AstrBotMessage 时对 ctx.channel 可能为 None 进行兜底处理

Co-authored-by: aider (openai/gpt-5.2) <aider@aider.chat>

---------

Co-authored-by: aider (openai/gpt-5.2) <aider@aider.chat>

* fix: TypeError when MCP schema type is a list (#4867)

* Fix TypeError when MCP schema type is a list

Fixes crash in Gemini native tools with VRChat MCP.

* Refactor: avoid modifying schema in place per feedback

* Fix formatting and cleanup comments

* docs: update watashiwakoseinodesukara

Removed duplicate text and added a new image.

* 修复/跨平台一致性

* 琐事/类型标注和一些简单错误修正

* 修复/检查端口时候包含ipv6

* 修复/enable变量的赋值逻辑

---------

Co-authored-by: Dt8333 <25431943+Dt8333@users.noreply.github.com>
Co-authored-by: aider (openai/gpt-5.2) <aider@aider.chat>
Co-authored-by: boushi1111 <95118141+boushi1111@users.noreply.github.com>
Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>
2026-02-06 02:46:16 +08:00
LIghtJUNction a8cad50f27 新功能/可选的分离前后端 2026-02-06 02:38:58 +08:00
123 changed files with 11304 additions and 4217 deletions
+1 -1
View File
@@ -37,7 +37,7 @@ jobs:
mkdir -p data/temp mkdir -p data/temp
export TESTING=true export TESTING=true
export ZHIPU_API_KEY=${{ secrets.OPENAI_API_KEY }} 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 - name: Upload results to Codecov
uses: codecov/codecov-action@v5 uses: codecov/codecov-action@v5
+139 -157
View File
@@ -2,12 +2,14 @@
<div align="center"> <div align="center">
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_en.md">English</a> <a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_zh.md">中文</a>
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ja.md">日本語</a> <a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ja.md">日本語</a>
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_zh-TW.md">繁體中文</a> <a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_zh-TW.md">繁體中文</a>
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_fr.md">Français</a> <a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_fr.md">Français</a>
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ru.md">Русский</a> <a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ru.md">Русский</a>
<br>
<div> <div>
<a href="https://trendshift.io/repositories/12875" target="_blank"><img src="https://trendshift.io/api/badge/repositories/12875" alt="Soulter%2FAstrBot | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a> <a href="https://trendshift.io/repositories/12875" target="_blank"><img src="https://trendshift.io/api/badge/repositories/12875" alt="Soulter%2FAstrBot | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
<a href="https://hellogithub.com/repository/AstrBotDevs/AstrBot" target="_blank"><img src="https://api.hellogithub.com/v1/widgets/recommend.svg?rid=d127d50cd5e54c5382328acc3bb25483&claim_uid=ZO9by7qCXgSd6Lp&t=2" alt="FeaturedHelloGitHub" style="width: 250px; height: 54px;" width="250" height="54" /></a> <a href="https://hellogithub.com/repository/AstrBotDevs/AstrBot" target="_blank"><img src="https://api.hellogithub.com/v1/widgets/recommend.svg?rid=d127d50cd5e54c5382328acc3bb25483&claim_uid=ZO9by7qCXgSd6Lp&t=2" alt="FeaturedHelloGitHub" style="width: 250px; height: 54px;" width="250" height="54" /></a>
@@ -21,42 +23,42 @@
<img src="https://deepwiki.com/badge.svg" href="https://deepwiki.com/AstrBotDevs/AstrBot"> <img src="https://deepwiki.com/badge.svg" href="https://deepwiki.com/AstrBotDevs/AstrBot">
<a href="https://zread.ai/AstrBotDevs/AstrBot" target="_blank"><img src="https://img.shields.io/badge/Ask_Zread-_.svg?style=flat&color=00b0aa&labelColor=000000&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTQuOTYxNTYgMS42MDAxSDIuMjQxNTZDMS44ODgxIDEuNjAwMSAxLjYwMTU2IDEuODg2NjQgMS42MDE1NiAyLjI0MDFWNC45NjAxQzEuNjAxNTYgNS4zMTM1NiAxLjg4ODEgNS42MDAxIDIuMjQxNTYgNS42MDAxSDQuOTYxNTZDNS4zMTUwMiA1LjYwMDEgNS42MDE1NiA1LjMxMzU2IDUuNjAxNTYgNC45NjAxVjIuMjQwMUM1LjYwMTU2IDEuODg2NjQgNS4zMTUwMiAxLjYwMDEgNC45NjE1NiAxLjYwMDFaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00Ljk2MTU2IDEwLjM5OTlIMi4yNDE1NkMxLjg4ODEgMTAuMzk5OSAxLjYwMTU2IDEwLjY4NjQgMS42MDE1NiAxMS4wMzk5VjEzLjc1OTlDMS42MDE1NiAxNC4xMTM0IDEuODg4MSAxNC4zOTk5IDIuMjQxNTYgMTQuMzk5OUg0Ljk2MTU2QzUuMzE1MDIgMTQuMzk5OSA1LjYwMTU2IDE0LjExMzQgNS42MDE1NiAxMy43NTk5VjExLjAzOTlDNS42MDE1NiAxMC42ODY0IDUuMzE1MDIgMTAuMzk5OSA0Ljk2MTU2IDEwLjM5OTlaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik0xMy43NTg0IDEuNjAwMUgxMS4wMzg0QzEwLjY4NSAxLjYwMDEgMTAuMzk4NCAxLjg4NjY0IDEwLjM5ODQgMi4yNDAxVjQuOTYwMUMxMC4zOTg0IDUuMzEzNTYgMTAuNjg1IDUuNjAwMSAxMS4wMzg0IDUuNjAwMUgxMy43NTg0QzE0LjExMTkgNS42MDAxIDE0LjM5ODQgNS4zMTM1NiAxNC4zOTg0IDQuOTYwMVYyLjI0MDFDMTQuMzk4NCAxLjg4NjY0IDE0LjExMTkgMS42MDAxIDEzLjc1ODQgMS42MDAxWiIgZmlsbD0iI2ZmZiIvPgo8cGF0aCBkPSJNNCAxMkwxMiA0TDQgMTJaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00IDEyTDEyIDQiIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLXdpZHRoPSIxLjUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPgo8L3N2Zz4K&logoColor=ffffff" alt="zread"/></a> <a href="https://zread.ai/AstrBotDevs/AstrBot" target="_blank"><img src="https://img.shields.io/badge/Ask_Zread-_.svg?style=flat&color=00b0aa&labelColor=000000&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTQuOTYxNTYgMS42MDAxSDIuMjQxNTZDMS44ODgxIDEuNjAwMSAxLjYwMTU2IDEuODg2NjQgMS42MDE1NiAyLjI0MDFWNC45NjAxQzEuNjAxNTYgNS4zMTM1NiAxLjg4ODEgNS42MDAxIDIuMjQxNTYgNS42MDAxSDQuOTYxNTZDNS4zMTUwMiA1LjYwMDEgNS42MDE1NiA1LjMxMzU2IDUuNjAxNTYgNC45NjAxVjIuMjQwMUM1LjYwMTU2IDEuODg2NjQgNS4zMTUwMiAxLjYwMDEgNC45NjE1NiAxLjYwMDFaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00Ljk2MTU2IDEwLjM5OTlIMi4yNDE1NkMxLjg4ODEgMTAuMzk5OSAxLjYwMTU2IDEwLjY4NjQgMS42MDE1NiAxMS4wMzk5VjEzLjc1OTlDMS42MDE1NiAxNC4xMTM0IDEuODg4MSAxNC4zOTk5IDIuMjQxNTYgMTQuMzk5OUg0Ljk2MTU2QzUuMzE1MDIgMTQuMzk5OSA1LjYwMTU2IDE0LjExMzQgNS42MDE1NiAxMy43NTk5VjExLjAzOTlDNS42MDE1NiAxMC42ODY0IDUuMzE1MDIgMTAuMzk5OSA0Ljk2MTU2IDEwLjM5OTlaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik0xMy43NTg0IDEuNjAwMUgxMS4wMzg0QzEwLjY4NSAxLjYwMDEgMTAuMzk4NCAxLjg4NjY0IDEwLjM5ODQgMi4yNDAxVjQuOTYwMUMxMC4zOTg0IDUuMzEzNTYgMTAuNjg1IDUuNjAwMSAxMS4wMzg0IDUuNjAwMUgxMy43NTg0QzE0LjExMTkgNS42MDAxIDE0LjM5ODQgNS4zMTM1NiAxNC4zOTg0IDQuOTYwMVYyLjI0MDFDMTQuMzk4NCAxLjg4NjY0IDE0LjExMTkgMS42MDAxIDEzLjc1ODQgMS42MDAxWiIgZmlsbD0iI2ZmZiIvPgo8cGF0aCBkPSJNNCAxMkwxMiA0TDQgMTJaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00IDEyTDEyIDQiIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLXdpZHRoPSIxLjUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPgo8L3N2Zz4K&logoColor=ffffff" alt="zread"/></a>
<a href="https://hub.docker.com/r/soulter/astrbot"><img alt="Docker pull" src="https://img.shields.io/docker/pulls/soulter/astrbot.svg?color=76bad9"/></a> <a href="https://hub.docker.com/r/soulter/astrbot"><img alt="Docker pull" src="https://img.shields.io/docker/pulls/soulter/astrbot.svg?color=76bad9"/></a>
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.soulter.top%2Fastrbot%2Fplugin-num&query=%24.result&suffix=%E4%B8%AA&label=%E6%8F%92%E4%BB%B6%E5%B8%82%E5%9C%BA&cacheSeconds=3600"> <img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.soulter.top%2Fastrbot%2Fplugin-num&query=%24.result&suffix=%20plugins&label=Marketplace&cacheSeconds=3600">
<img src="https://gitcode.com/Soulter/AstrBot/star/badge.svg" href="https://gitcode.com/Soulter/AstrBot"> <img src="https://gitcode.com/Soulter/AstrBot/star/badge.svg" href="https://gitcode.com/Soulter/AstrBot">
</div> </div>
<br> <br>
<a href="https://astrbot.app/">文档</a> <a href="https://astrbot.app/">Documentation</a>
<a href="https://blog.astrbot.app/">Blog</a> <a href="https://blog.astrbot.app/">Blog</a>
<a href="https://astrbot.featurebase.app/roadmap">路线图</a> <a href="https://astrbot.featurebase.app/roadmap">Roadmap</a>
<a href="https://github.com/AstrBotDevs/AstrBot/issues">问题提交</a> <a href="https://github.com/AstrBotDevs/AstrBot/issues">Issue Tracker</a>
</div> </div>
AstrBot 是一个开源的一站式 Agentic 个人和群聊助手,可在 QQ、Telegram、企业微信、飞书、钉钉、Slack、等数十款主流即时通讯软件上部署,此外还内置类似 OpenWebUI 的轻量化 ChatUI,为个人、开发者和团队打造可靠、可扩展的对话式智能基础设施。无论是个人 AI 伙伴、智能客服、自动化助手,还是企业知识库,AstrBot 都能在你的即时通讯软件平台的工作流中快速构建 AI 应用。 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.
![521771166-00782c4c-4437-4d97-aabc-605e3738da5c (1)](https://github.com/user-attachments/assets/61e7b505-f7db-41aa-a75f-4ef8f079b8ba) ![screenshot_1 5x_postspark_2026-02-27_22-37-45](https://github.com/user-attachments/assets/f17cdb90-52d7-4773-be2e-ff64b566af6b)
## 主要功能 ## Key Features
1. 💯 免费 & 开源。 1. 💯 Free & Open Source.
2. ✨ AI 大模型对话,多模态,AgentMCPSkills,知识库,人格设定,自动压缩对话。 2. ✨ AI LLM Conversations, Multimodal, Agent, MCP, Skills, Knowledge Base, Persona Settings, Auto Context Compression.
3. 🤖 支持接入 Dify、阿里云百炼、Coze 等智能体平台。 3. 🤖 Supports integration with Dify, Alibaba Cloud Bailian, Coze, and other agent platforms.
4. 🌐 多平台,支持 QQ、企业微信、飞书、钉钉、微信公众号、TelegramSlack 以及[更多](#支持的消息平台) 4. 🌐 Multi-Platform: QQ, WeChat Work, Feishu, DingTalk, WeChat Official Accounts, Telegram, Slack, and [more](#supported-messaging-platforms).
5. 📦 插件扩展,已有近 800 个插件可一键安装。 5. 📦 Plugin Extensions with 1000+ plugins available for one-click installation.
6. 🛡️ [Agent Sandbox](https://docs.astrbot.app/use/astrbot-agent-sandbox.html) 隔离化环境,安全地执行任何代码、调用 Shell、会话级资源复用。 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 支持。 7. 💻 WebUI Support.
8. 🌈 Web ChatUI 支持,ChatUI 内置代理沙盒、网页搜索等。 8. 🌈 Web ChatUI Support with built-in agent sandbox and web search.
9. 🌐 国际化(i18n)支持。 9. 🌐 Internationalization (i18n) Support.
<br> <br>
<table align="center"> <table align="center">
<tr align="center"> <tr align="center">
<th>💙 角色扮演 & 情感陪伴</th> <th>💙 Role-playing & Emotional Companionship</th>
<th>✨ 主动式 Agent</th> <th>✨ Proactive Agent</th>
<th>🚀 通用 Agentic 能力</th> <th>🚀 General Agentic Capabilities</th>
<th>🧩 900+ 社区插件</th> <th>🧩 1000+ Community Plugins</th>
</tr> </tr>
<tr> <tr>
<td align="center"><p align="center"><img width="984" height="1746" alt="99b587c5d35eea09d84f33e6cf6cfd4f" src="https://github.com/user-attachments/assets/89196061-3290-458d-b51f-afa178049f84" /></p></td> <td align="center"><p align="center"><img width="984" height="1746" alt="99b587c5d35eea09d84f33e6cf6cfd4f" src="https://github.com/user-attachments/assets/89196061-3290-458d-b51f-afa178049f84" /></p></td>
@@ -66,172 +68,163 @@ AstrBot 是一个开源的一站式 Agentic 个人和群聊助手,可在 QQ、
</tr> </tr>
</table> </table>
## 快速开始 ## Quick Start
#### Docker 部署(推荐 🥳) #### Docker Deployment (Recommended 🥳)
推荐使用 Docker / Docker Compose 方式部署 AstrBot。 We recommend deploying AstrBot using Docker or Docker Compose.
请参阅官方文档 [使用 Docker 部署 AstrBot](https://astrbot.app/deploy/astrbot/docker.html#%E4%BD%BF%E7%94%A8-docker-%E9%83%A8%E7%BD%B2-astrbot) Please refer to the official documentation: [Deploy AstrBot with Docker](https://astrbot.app/deploy/astrbot/docker.html#%E4%BD%BF%E7%94%A8-docker-%E9%83%A8%E7%BD%B2-astrbot).
#### uv 部署 #### uv Deployment
```bash ```bash
uv tool install astrbot uv tool install astrbot
astrbot astrbot
``` ```
#### 桌面应用部署(Tauri #### System Package Manager Installation
桌面应用仓库 [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop)。 ##### Arch Linux
支持多系统架构,安装包直接安装,开箱即用,最适合新手和懒人的一键桌面部署方案,不推荐服务器场景。 ```bash
yay -S astrbot-git
# or use paru
paru -S astrbot-git
```
#### 启动器一键部署(AstrBot Launcher #### Desktop Application (Tauri)
快速部署和多开方案,实现环境隔离,进入 [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) 仓库,在 Releases 页最新版本下找到对应的系统安装包安装即可。 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 与宝塔面板合作,已上架至宝塔面板。 #### AstrBot Launcher
请参阅官方文档 [宝塔面板部署](https://astrbot.app/deploy/astrbot/btpanel.html) 。 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.
#### 1Panel 部署 #### BT-Panel Deployment
AstrBot 已由 1Panel 官方上架至 1Panel 面板。 AstrBot has partnered with BT-Panel and is now available in their marketplace.
请参阅官方文档 [1Panel 部署](https://astrbot.app/deploy/astrbot/1panel.html) Please refer to the official documentation: [BT-Panel Deployment](https://astrbot.app/deploy/astrbot/btpanel.html).
#### 在 雨云 上部署 #### 1Panel Deployment
AstrBot 已由雨云官方上架至云应用平台,可一键部署。 AstrBot has been officially listed on the 1Panel marketplace.
Please refer to the official documentation: [1Panel Deployment](https://astrbot.app/deploy/astrbot/1panel.html).
#### Deploy on RainYun
For Chinese users:
AstrBot has been officially listed on RainYun's cloud application platform with one-click deployment.
[![Deploy on RainYun](https://rainyun-apps.cn-nb1.rains3.com/materials/deploy-on-rainyun-en.svg)](https://app.rainyun.com/apps/rca/store/5994?ref=NjU1ODg0) [![Deploy on RainYun](https://rainyun-apps.cn-nb1.rains3.com/materials/deploy-on-rainyun-en.svg)](https://app.rainyun.com/apps/rca/store/5994?ref=NjU1ODg0)
#### Replit 上部署 #### Deploy on Replit
社区贡献的部署方式。 Community-contributed deployment method.
[![Run on Repl.it](https://repl.it/badge/github/AstrBotDevs/AstrBot)](https://repl.it/github/AstrBotDevs/AstrBot) [![Run on Repl.it](https://repl.it/badge/github/AstrBotDevs/AstrBot)](https://repl.it/github/AstrBotDevs/AstrBot)
#### Windows 一键安装器部署 #### Windows One-Click Installer
请参阅官方文档 [使用 Windows 一键安装器部署 AstrBot](https://astrbot.app/deploy/astrbot/windows.html) Please refer to the official documentation: [Deploy AstrBot with Windows One-Click Installer](https://astrbot.app/deploy/astrbot/windows.html).
#### CasaOS 部署 #### CasaOS Deployment
社区贡献的部署方式。 Community-contributed deployment method.
请参阅官方文档 [CasaOS 部署](https://astrbot.app/deploy/astrbot/casaos.html) Please refer to the official documentation: [CasaOS Deployment](https://astrbot.app/deploy/astrbot/casaos.html).
#### 手动部署 #### Manual Deployment
首先安装 uv First, install uv:
```bash ```bash
pip install uv pip install uv
``` ```
通过 Git Clone 安装 AstrBot Install AstrBot via Git Clone:
```bash ```bash
git clone https://github.com/AstrBotDevs/AstrBot && cd AstrBot git clone https://github.com/AstrBotDevs/AstrBot && cd AstrBot
uv run main.py uv run main.py
``` ```
或者请参阅官方文档 [通过源码部署 AstrBot](https://astrbot.app/deploy/astrbot/cli.html) Or refer to the official documentation: [Deploy AstrBot from Source](https://astrbot.app/deploy/astrbot/cli.html).
#### 系统包管理器安装 ## Supported Messaging Platforms
##### Arch Linux Connect AstrBot to your favorite chat platform.
```bash | Platform | Maintainer |
yay -S astrbot-git |---------|---------------|
# 或者使用 paru | QQ | Official |
paru -S astrbot-git | OneBot v11 protocol implementation | Official |
``` | Telegram | Official |
| WeChat Work Application & WeChat Work Intelligent Bot | Official |
| WeChat Customer Service & WeChat Official Accounts | Official |
| Feishu (Lark) | Official |
| DingTalk | Official |
| Slack | Official |
| Discord | Official |
| LINE | Official |
| Satori | Official |
| Misskey | Official |
| WhatsApp (Coming Soon) | Official |
| [Matrix](https://github.com/stevessr/astrbot_plugin_matrix_adapter) | Community |
| [KOOK](https://github.com/wuyan1003/astrbot_plugin_kook_adapter) | Community |
| [VoceChat](https://github.com/HikariFroya/astrbot_plugin_vocechat) | Community |
## 支持的消息平台 ## Supported Model Services
**官方维护** | Service | Type |
|---------|---------------|
| OpenAI and Compatible Services | LLM Services |
| Anthropic | LLM Services |
| Google Gemini | LLM Services |
| Moonshot AI | LLM Services |
| Zhipu AI | LLM Services |
| DeepSeek | LLM Services |
| Ollama (Self-hosted) | LLM Services |
| LM Studio (Self-hosted) | LLM Services |
| [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_astrbot&referral_code=FV7DcGowN4hB5UuXKgpE74) | LLM Services |
| [302.AI](https://share.302.ai/rr1M3l) | LLM Services |
| [TokenPony](https://www.tokenpony.cn/3YPyf) | LLM Services |
| [SiliconFlow](https://docs.siliconflow.cn/cn/usercases/use-siliconcloud-in-astrbot) | LLM Services |
| [PPIO Cloud](https://ppio.com/user/register?invited_by=AIOONE) | LLM Services |
| ModelScope | LLM Services |
| OneAPI | LLM Services |
| Dify | LLMOps Platforms |
| Alibaba Cloud Bailian Applications | LLMOps Platforms |
| Coze | LLMOps Platforms |
| OpenAI Whisper | Speech-to-Text Services |
| SenseVoice | Speech-to-Text Services |
| OpenAI TTS | Text-to-Speech Services |
| Gemini TTS | Text-to-Speech Services |
| GPT-Sovits-Inference | Text-to-Speech Services |
| GPT-Sovits | Text-to-Speech Services |
| FishAudio | Text-to-Speech Services |
| Edge TTS | Text-to-Speech Services |
| Alibaba Cloud Bailian TTS | Text-to-Speech Services |
| Azure TTS | Text-to-Speech Services |
| Minimax TTS | Text-to-Speech Services |
| Volcano Engine TTS | Text-to-Speech Services |
- QQ ## ❤️ Contributing
- OneBot v11 协议实现
- Telegram
- 企微应用 & 企微智能机器人
- 微信客服 & 微信公众号
- 飞书
- 钉钉
- Slack
- Discord
- LINE
- Satori
- Misskey
- Whatsapp (将支持)
**社区维护** Issues and Pull Requests are always welcome! Feel free to submit your changes to this project :)
- [Matrix](https://github.com/stevessr/astrbot_plugin_matrix_adapter) ### How to Contribute
- [KOOK](https://github.com/wuyan1003/astrbot_plugin_kook_adapter)
- [VoceChat](https://github.com/HikariFroya/astrbot_plugin_vocechat)
## 支持的模型服务 You can contribute by reviewing issues or helping with pull request reviews. Any issues or PRs are welcome to encourage community participation. Of course, these are just suggestions—you can contribute in any way you like. For adding new features, please discuss through an Issue first.
**大模型服务** ### Development Environment
- OpenAI 及兼容服务 AstrBot uses `ruff` for code formatting and linting.
- Anthropic
- Google Gemini
- Moonshot AI
- 智谱 AI
- 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)
- [硅基流动](https://docs.siliconflow.cn/cn/usercases/use-siliconcloud-in-astrbot)
- [PPIO 派欧云](https://ppio.com/user/register?invited_by=AIOONE)
- ModelScope
- OneAPI
**LLMOps 平台**
- Dify
- 阿里云百炼应用
- Coze
**语音转文本服务**
- OpenAI Whisper
- SenseVoice
**文本转语音服务**
- OpenAI TTS
- Gemini TTS
- GPT-Sovits-Inference
- GPT-Sovits
- FishAudio
- Edge TTS
- 阿里云百炼 TTS
- Azure TTS
- Minimax TTS
- 火山引擎 TTS
## ❤️ 贡献
欢迎任何 Issues/Pull Requests!只需要将你的更改提交到此项目 :)
### 如何贡献
你可以通过查看问题或帮助审核 PR(拉取请求)来贡献。任何问题或 PR 都欢迎参与,以促进社区贡献。当然,这些只是建议,你可以以任何方式进行贡献。对于新功能的添加,请先通过 Issue 讨论。
### 开发环境
AstrBot 使用 `ruff` 进行代码格式化和检查。
```bash ```bash
git clone https://github.com/AstrBotDevs/AstrBot git clone https://github.com/AstrBotDevs/AstrBot
@@ -239,52 +232,42 @@ pip install pre-commit
pre-commit install pre-commit install
``` ```
## 🌍 社区 ## 🌍 Community
### QQ 群组 ### QQ Groups
- 1 群:322154837 - Group 1: 322154837
- 3 群:630166526 - Group 3: 630166526
- 5 群:822130018 - Group 5: 822130018
- 6 群:753075035 - Group 6: 753075035
- 7 群:743746109 - Group 7: 743746109
- 8 群:1030353265 - Group 8: 1030353265
- 开发者群:975206796 - Developer Group: 975206796
### Telegram 群组 ### Telegram Group
<a href="https://t.me/+hAsD2Ebl5as3NmY1"><img alt="Telegram_community" src="https://img.shields.io/badge/Telegram-AstrBot-purple?style=for-the-badge&color=76bad9"></a> <a href="https://t.me/+hAsD2Ebl5as3NmY1"><img alt="Telegram_community" src="https://img.shields.io/badge/Telegram-AstrBot-purple?style=for-the-badge&color=76bad9"></a>
### Discord 群组 ### Discord Server
<a href="https://discord.gg/hAVk6tgV36"><img alt="Discord_community" src="https://img.shields.io/badge/Discord-AstrBot-purple?style=for-the-badge&color=76bad9"></a> <a href="https://discord.gg/hAVk6tgV36"><img alt="Discord_community" src="https://img.shields.io/badge/Discord-AstrBot-purple?style=for-the-badge&color=76bad9"></a>
## ❤️ Special Thanks ## ❤️ Special Thanks
特别感谢所有 Contributors 和插件开发者对 AstrBot 的贡献 ❤️ Special thanks to all Contributors and plugin developers for their contributions to AstrBot ❤️
<a href="https://github.com/AstrBotDevs/AstrBot/graphs/contributors"> <a href="https://github.com/AstrBotDevs/AstrBot/graphs/contributors">
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot&max=200&columns=14" /> <img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot&max=200&columns=14" />
</a> </a>
此外,本项目的诞生离不开以下开源项目的帮助: Additionally, the birth of this project would not have been possible without the help of the following open-source projects:
- [NapNeko/NapCatQQ](https://github.com/NapNeko/NapCatQQ) - 伟大的猫猫框架 - [NapNeko/NapCatQQ](https://github.com/NapNeko/NapCatQQ) - The amazing cat framework
开源项目友情链接:
- [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 ## ⭐ Star History
> [!TIP] > [!TIP]
> 如果本项目对您的生活 / 工作产生了帮助,或者您关注本项目的未来发展,请给项目 Star,这是我们维护这个开源项目的动力 <3 > If this project has helped you in your life or work, or if you're interested in its future development, please give the project a Star. It's the driving force behind maintaining this open-source project <3
<div align="center"> <div align="center">
@@ -294,10 +277,9 @@ pre-commit install
<div align="center"> <div align="center">
_陪伴与能力从来不应该是对立面。我们希望创造的是一个既能理解情绪、给予陪伴,也能可靠完成工作的机器人。_ _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._
_私は、高性能ですから!_ _私は、高性能ですから!_
<img src="https://files.astrbot.app/watashiwa-koseino-desukara.gif" width="100"/> <img src="https://files.astrbot.app/watashiwa-koseino-desukara.gif" width="100"/>
</div> </div>
+67 -78
View File
@@ -2,7 +2,7 @@
<div align="center"> <div align="center">
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README.md">中文</a> <a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_zh.md">中文</a>
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ja.md">日本語</a> <a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ja.md">日本語</a>
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_zh-TW.md">繁體中文</a> <a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_zh-TW.md">繁體中文</a>
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_fr.md">Français</a> <a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_fr.md">Français</a>
@@ -37,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. 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) ![screenshot_1 5x_postspark_2026-02-27_22-37-45](https://github.com/user-attachments/assets/f17cdb90-52d7-4773-be2e-ff64b566af6b)
## Key Features ## Key Features
@@ -45,7 +45,7 @@ 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. 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. 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). 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. 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. 7. 💻 WebUI Support.
8. 🌈 Web ChatUI Support with built-in agent sandbox and web search. 8. 🌈 Web ChatUI Support with built-in agent sandbox and web search.
@@ -58,7 +58,7 @@ AstrBot is an open-source all-in-one Agent chatbot platform that integrates with
<th>💙 Role-playing & Emotional Companionship</th> <th>💙 Role-playing & Emotional Companionship</th>
<th>✨ Proactive Agent</th> <th>✨ Proactive Agent</th>
<th>🚀 General Agentic Capabilities</th> <th>🚀 General Agentic Capabilities</th>
<th>🧩 900+ Community Plugins</th> <th>🧩 1000+ Community Plugins</th>
</tr> </tr>
<tr> <tr>
<td align="center"><p align="center"><img width="984" height="1746" alt="99b587c5d35eea09d84f33e6cf6cfd4f" src="https://github.com/user-attachments/assets/89196061-3290-458d-b51f-afa178049f84" /></p></td> <td align="center"><p align="center"><img width="984" height="1746" alt="99b587c5d35eea09d84f33e6cf6cfd4f" src="https://github.com/user-attachments/assets/89196061-3290-458d-b51f-afa178049f84" /></p></td>
@@ -93,6 +93,16 @@ yay -S astrbot-git
paru -S astrbot-git 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 #### BT-Panel Deployment
AstrBot has partnered with BT-Panel and is now available in their marketplace. AstrBot has partnered with BT-Panel and is now available in their marketplace.
@@ -107,6 +117,8 @@ Please refer to the official documentation: [1Panel Deployment](https://astrbot.
#### Deploy on RainYun #### Deploy on RainYun
For Chinese users:
AstrBot has been officially listed on RainYun's cloud application platform with one-click deployment. AstrBot has been officially listed on RainYun's cloud application platform with one-click deployment.
[![Deploy on RainYun](https://rainyun-apps.cn-nb1.rains3.com/materials/deploy-on-rainyun-en.svg)](https://app.rainyun.com/apps/rca/store/5994?ref=NjU1ODg0) [![Deploy on RainYun](https://rainyun-apps.cn-nb1.rains3.com/materials/deploy-on-rainyun-en.svg)](https://app.rainyun.com/apps/rca/store/5994?ref=NjU1ODg0)
@@ -144,86 +156,63 @@ uv run main.py
Or refer to the official documentation: [Deploy AstrBot from Source](https://astrbot.app/deploy/astrbot/cli.html). Or refer to the official documentation: [Deploy AstrBot from Source](https://astrbot.app/deploy/astrbot/cli.html).
#### System Package Manager Installation
##### Arch Linux
```bash
yay -S astrbot-git
# or use paru
paru -S astrbot-git
```
#### Desktop (Tauri)
Desktop packaging has moved to a standalone Tauri repository: [https://github.com/AstrBotDevs/AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop).
## Supported Messaging Platforms ## Supported Messaging Platforms
**Officially Maintained** Connect AstrBot to your favorite chat platform.
- QQ (Official Platform & OneBot) | Platform | Maintainer |
- Telegram |---------|---------------|
- WeChat Work Application & WeChat Work Intelligent Bot | QQ | Official |
- WeChat Customer Service & WeChat Official Accounts | OneBot v11 protocol implementation | Official |
- Feishu (Lark) | Telegram | Official |
- DingTalk | WeChat Work Application & WeChat Work Intelligent Bot | Official |
- Slack | WeChat Customer Service & WeChat Official Accounts | Official |
- Discord | Feishu (Lark) | Official |
- Satori | DingTalk | Official |
- Misskey | Slack | Official |
- LINE | Discord | Official |
- WhatsApp (Coming Soon) | LINE | Official |
| Satori | Official |
**Community Maintained** | Misskey | Official |
| WhatsApp (Coming Soon) | Official |
- [Matrix](https://github.com/stevessr/astrbot_plugin_matrix_adapter) | [Matrix](https://github.com/stevessr/astrbot_plugin_matrix_adapter) | Community |
- [KOOK](https://github.com/wuyan1003/astrbot_plugin_kook_adapter) | [KOOK](https://github.com/wuyan1003/astrbot_plugin_kook_adapter) | Community |
- [VoceChat](https://github.com/HikariFroya/astrbot_plugin_vocechat) | [VoceChat](https://github.com/HikariFroya/astrbot_plugin_vocechat) | Community |
## Supported Model Services ## Supported Model Services
**LLM Services** | Service | Type |
|---------|---------------|
- OpenAI and Compatible Services | OpenAI and Compatible Services | LLM Services |
- Anthropic | Anthropic | LLM Services |
- Google Gemini | Google Gemini | LLM Services |
- Moonshot AI | Moonshot AI | LLM Services |
- Zhipu AI | Zhipu AI | LLM Services |
- DeepSeek | DeepSeek | LLM Services |
- Ollama (Self-hosted) | Ollama (Self-hosted) | LLM Services |
- LM Studio (Self-hosted) | LM Studio (Self-hosted) | LLM Services |
- [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_astrbot&referral_code=FV7DcGowN4hB5UuXKgpE74) | [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_astrbot&referral_code=FV7DcGowN4hB5UuXKgpE74) | LLM Services |
- [302.AI](https://share.302.ai/rr1M3l) | [302.AI](https://share.302.ai/rr1M3l) | LLM Services |
- [TokenPony](https://www.tokenpony.cn/3YPyf) | [TokenPony](https://www.tokenpony.cn/3YPyf) | LLM Services |
- [SiliconFlow](https://docs.siliconflow.cn/cn/usercases/use-siliconcloud-in-astrbot) | [SiliconFlow](https://docs.siliconflow.cn/cn/usercases/use-siliconcloud-in-astrbot) | LLM Services |
- [PPIO Cloud](https://ppio.com/user/register?invited_by=AIOONE) | [PPIO Cloud](https://ppio.com/user/register?invited_by=AIOONE) | LLM Services |
- ModelScope | ModelScope | LLM Services |
- OneAPI | OneAPI | LLM Services |
| Dify | LLMOps Platforms |
**LLMOps Platforms** | Alibaba Cloud Bailian Applications | LLMOps Platforms |
| Coze | LLMOps Platforms |
- Dify | OpenAI Whisper | Speech-to-Text Services |
- Alibaba Cloud Bailian Applications | SenseVoice | Speech-to-Text Services |
- Coze | OpenAI TTS | Text-to-Speech Services |
| Gemini TTS | Text-to-Speech Services |
**Speech-to-Text Services** | GPT-Sovits-Inference | Text-to-Speech Services |
| GPT-Sovits | Text-to-Speech Services |
- OpenAI Whisper | FishAudio | Text-to-Speech Services |
- SenseVoice | Edge TTS | Text-to-Speech Services |
| Alibaba Cloud Bailian TTS | Text-to-Speech Services |
**Text-to-Speech Services** | Azure TTS | Text-to-Speech Services |
| Minimax TTS | Text-to-Speech Services |
- OpenAI TTS | Volcano Engine TTS | Text-to-Speech Services |
- Gemini TTS
- GPT-Sovits-Inference
- GPT-Sovits
- FishAudio
- Edge TTS
- Alibaba Cloud Bailian TTS
- Azure TTS
- Minimax TTS
- Volcano Engine TTS
## ❤️ Contributing ## ❤️ Contributing
+69 -76
View File
@@ -2,8 +2,8 @@
<div align="center"> <div align="center">
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README.md">中文</a> <a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_zh.md">中文</a>
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_en.md">English</a> <a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README.md">English</a>
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ja.md">日本語</a> <a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ja.md">日本語</a>
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_zh-TW.md">繁體中文</a> <a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_zh-TW.md">繁體中文</a>
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ru.md">Русский</a> <a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ru.md">Русский</a>
@@ -21,9 +21,9 @@
<img src="https://img.shields.io/github/v/release/AstrBotDevs/AstrBot?color=76bad9" href="https://github.com/AstrBotDevs/AstrBot/releases/latest"> <img src="https://img.shields.io/github/v/release/AstrBotDevs/AstrBot?color=76bad9" href="https://github.com/AstrBotDevs/AstrBot/releases/latest">
<img src="https://img.shields.io/badge/python-3.10+-blue.svg" alt="python"> <img src="https://img.shields.io/badge/python-3.10+-blue.svg" alt="python">
<img src="https://deepwiki.com/badge.svg" href="https://deepwiki.com/AstrBotDevs/AstrBot"> <img src="https://deepwiki.com/badge.svg" href="https://deepwiki.com/AstrBotDevs/AstrBot">
<a href="https://zread.ai/AstrBotDevs/AstrBot" target="_blank"><img src="https://img.shields.io/badge/Ask_Zread-_.svg?style=flat&color=00b0aa&labelColor=000000&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTQuOTYxNTYgMS42MDAxSDIuMjQxNTZDMS44ODgxIDEuNjAwMSAxLjYwMTU2IDEuODg2NjQgMS42MDE1NiAyLjI0MDFWNC45NjAxQzEuNjAxNTYgNS4zMTM1NiAxLjg4ODEgNS42MDAxIDIuMjQxNTYgNS42MDAxSDQuOTYxNTZDNS4zMTUwMiA1LjYwMDEgNS42MDE1NiA1LjMxMzU2IDUuNjAxNTYgNC45NjAxVjIuMjQwMUM1LjYwMTU2IDEuODg2NjQgNS4zMTUwMiAxLjYwMDEgNC45NjE1NiAxLjYwMDFaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00Ljk2MTU2IDEwLjM5OTlIMi4yNDE1NkMxLjg4ODEgMTAuMzk5OSAxLjYwMTU2IDEwLjY4NjQgMS42MDE1NiAxMS4wMzk5VjEzLjc1OTlDMS42MDE1NiAxNC4xMTM0IDEuODg4MSAxNC4zOTk5IDIuMjQxNTYgMTQuMzk5OUg0Ljk2MTU2QzUuMzE1MDIgMTQuMzk5OSA1LjYwMTU2IDE0LjExMzQgNS42MDE1NiAxMy43NTk5VjExLjAzOTlDNS42MDE1NiAxMC42ODY0IDUuMzE1MDIgMTAuMzk5OSA0Ljk2MTU2IDEwLjM5OTlaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik0xMy43NTg0IDEuNjAwMUgxMS4wMzg0QzEwLjY4NSAxLjYwMDEgMTAuMzk4NCAxLjg4NjY0IDEwLjM5ODQgMi4yNDAxVjQuOTYwMUMxMC4zOTg0IDUuMzEzNTYgMTAuNjg1IDUuNjAwMSAxMS4wMzg0IDUuNjAwMUgxMy43NTg0QzE0LjExMTkgNS42MDAxIDE0LjM5ODQgNS4zMTM1NiAxNC4zOTg0IDQuOTYwMVYyLjI0MDFDMTQuMzk4NCAxLjg4NjY0IDE0LjExMTkgMS42MDAxIDEzLjc1ODQgMS42MDAxWiIgZmlsbD0iI2ZmZiIvPgo8cGF0aCBkPSJNNCAxMkwxMiA0TDQgMTJaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00IDEyTDEyIDQiIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLXdpZHRoPSIxLjUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPgo8L3N2Zz4K&logoColor=ffffff" alt="zread"/></a> <a href="https://zread.ai/AstrBotDevs/AstrBot" target="_blank"><img src="https://img.shields.io/badge/Ask_Zread-_.svg?style=flat&color=00b0aa&labelColor=000000&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTQuOTYxNTYgMS42MDAxSDIuMjQxNTZDMS44ODgxIDEuNjAwMSAxLjYwMTU2IDEuODg2NjQgMS42MDE1NiAyLjI0MDFWNC45NjAxQzEuNjAxNTYgNS4zMTM1NiAxLjg4ODEgNS42MDAxIDIuMjQxNTYgNS42MDAxSDQuOTYxNTZDNS4zMTUwMiA1LjYwMDEgNS42MDE1NiA1LjMxMzU2IDUuNjAxNTYgNC45NjAxVjIuMjQwMUM1LjYwMTU2IDEuODg2NjQgNS4zMTUwMiAxLjYwMDEgNC45NjE1NiAxLjYwMDFZIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00Ljk2MTU2IDEwLjM5OTlIMi4yNDE1NkMxLjg4ODEgMTAuMzk5OSAxLjYwMTU2IDEwLjY4NjQgMS42MDE1NiAxMS4wMzk5VjEzLjc1OTlDMS42MDE1NiAxNC4xMTM0IDEuODg4MSAxNC4zOTk5IDIuMjQxNTYgMTQuMzk5OUg0Ljk2MTU2QzUuMzE1MDIgMTQuMzk5OSA1LjYwMTU2IDE0LjExMzQgNS42MDE1NiAxMy43NTk5VjExLjAzOTlDNS42MDE1NiAxMC42ODY0IDUuMzE1MDIgMTAuMzk5OSA0Ljk2MTU2IDEwLjM5OTlaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik0xMy43NTg0IDEuNjAwMUgxMS4wMzg0QzEwLjY4NSAxLjYwMDEgMTAuMzk4NCAxLjg4NjY0IDEwLjM5ODQgMi4yNDAxVjQuOTYwMUMxMC4zOTg0IDUuMzEzNTYgMTAuNjg1IDUuNjAwMSAxMS4wMzg0IDUuNjAwMUgxMy43NTg0QzE0LjExMTkgNS42MDAxIDE0LjM5ODQgNS4zMTM1NiAxNC4zOTg0IDQuOTYwMVYyLjI0MDFDMTQuMzk4NCAxLjg4NjY0IDE0LjExMTkgMS42MDAxIDEzLjc1ODQgMS42MDAxWiIgZmlsbD0iI2ZmZiIvPgo8cGF0aCBkPSJNNCAxMkwxMiA0TDQgMTJaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00IDEyTDEyIDQiIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLXdpZHRoPSIxLjUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPgo8L3N2Zz4K&logoColor=ffffff" alt="zread"/></a>
<a href="https://hub.docker.com/r/soulter/astrbot"><img alt="Docker pull" src="https://img.shields.io/docker/pulls/soulter/astrbot.svg?color=76bad9"/></a> <a href="https://hub.docker.com/r/soulter/astrbot"><img alt="Docker pull" src="https://img.shields.io/docker/pulls/soulter/astrbot.svg?color=76bad9"/></a>
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.soulter.top%2Fastrbot%2Fplugin-num&query=%24.result&suffix=%E4%B8%AA&label=%E6%8F%92%E4%BB%B6%E5%B8%82%E5%9C%BA&cacheSeconds=3600"> <img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.soulter.top%2Fastrbot%2Fplugin-num&query=%24.result&suffix=%20&label=Marketplace&cacheSeconds=3600">
<img src="https://gitcode.com/Soulter/AstrBot/star/badge.svg" href="https://gitcode.com/Soulter/AstrBot"> <img src="https://gitcode.com/Soulter/AstrBot/star/badge.svg" href="https://gitcode.com/Soulter/AstrBot">
</div> </div>
@@ -37,7 +37,7 @@
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. 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.
<img width="1776" height="1080" alt="image" src="https://github.com/user-attachments/assets/00782c4c-4437-4d97-aabc-605e3738da5c" /> ![521771166-00782c4c-4437-4d97-aabc-605e3738da5c (1)](https://github.com/user-attachments/assets/61e7b505-f7db-41aa-a75f-4ef8f079b8ba)
## Fonctionnalités principales ## Fonctionnalités principales
@@ -45,7 +45,7 @@ AstrBot est une plateforme de chatbot Agent tout-en-un open source qui s'intègr
2. ✨ Dialogue avec de grands modèles d'IA, multimodal, Agent, MCP, Skills, Base de connaissances, Paramétrage de personnalité, compression automatique des dialogues. 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. 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). 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 ps de 800 plugins déjà disponibles pour une installation en un clic. 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. 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. 7. 💻 Support WebUI.
8. 🌈 Support Web ChatUI, avec sandbox d'agent intégrée, recherche web, etc. 8. 🌈 Support Web ChatUI, avec sandbox d'agent intégrée, recherche web, etc.
@@ -58,7 +58,7 @@ AstrBot est une plateforme de chatbot Agent tout-en-un open source qui s'intègr
<th>💙 Jeux de rôle & Accompagnement émotionnel</th> <th>💙 Jeux de rôle & Accompagnement émotionnel</th>
<th>✨ Agent proactif</th> <th>✨ Agent proactif</th>
<th>🚀 Capacités agentiques générales</th> <th>🚀 Capacités agentiques générales</th>
<th>🧩 900+ Plugins de communauté</th> <th>🧩 1000+ Plugins de communauté</th>
</tr> </tr>
<tr> <tr>
<td align="center"><p align="center"><img width="984" height="1746" alt="99b587c5d35eea09d84f33e6cf6cfd4f" src="https://github.com/user-attachments/assets/89196061-3290-458d-b51f-afa178049f84" /></p></td> <td align="center"><p align="center"><img width="984" height="1746" alt="99b587c5d35eea09d84f33e6cf6cfd4f" src="https://github.com/user-attachments/assets/89196061-3290-458d-b51f-afa178049f84" /></p></td>
@@ -83,15 +83,15 @@ uv tool install astrbot
astrbot astrbot
``` ```
#### Installation via le gestionnaire de paquets du système #### Application de bureau (Tauri)
##### Arch Linux Dépôt de l'application de bureau : [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop).
```bash 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.
yay -S astrbot-git
# ou utiliser paru #### Déploiement en un clic avec le lanceur (AstrBot Launcher)
paru -S astrbot-git
``` 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 #### Déploiement BT-Panel
@@ -107,6 +107,8 @@ Veuillez consulter la documentation officielle : [Déploiement 1Panel](https://a
#### Déployer sur RainYun #### Déployer sur RainYun
For Chinese users:
AstrBot a été officiellement listé sur la plateforme d'applications cloud de RainYun avec un déploiement en un clic. AstrBot a été officiellement listé sur la plateforme d'applications cloud de RainYun avec un déploiement en un clic.
[![Deploy on RainYun](https://rainyun-apps.cn-nb1.rains3.com/materials/deploy-on-rainyun-en.svg)](https://app.rainyun.com/apps/rca/store/5994?ref=NjU1ODg0) [![Deploy on RainYun](https://rainyun-apps.cn-nb1.rains3.com/materials/deploy-on-rainyun-en.svg)](https://app.rainyun.com/apps/rca/store/5994?ref=NjU1ODg0)
@@ -144,82 +146,73 @@ uv run main.py
Ou consultez la documentation officielle : [Déployer AstrBot depuis les sources](https://astrbot.app/deploy/astrbot/cli.html). 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 ##### Arch Linux
```bash ```bash
yay -S astrbot-git yay -S astrbot-git
# или используйте paru # ou utiliser paru
paru -S astrbot-git paru -S astrbot-git
``` ```
## Plateformes de messagerie prises en charge ## Plateformes de messagerie prises en charge
**Maintenues officiellement** Connectez AstrBot à vos plateformes de chat préférées.
- QQ (Plateforme officielle & OneBot) | Plateforme | Maintenance |
- Telegram |---------|---------------|
- Application WeChat Work & Bot intelligent WeChat Work | QQ | Officielle |
- Service client WeChat & Comptes officiels WeChat | Implémentation du protocole OneBot v11 | Officielle |
- Feishu (Lark) | Telegram | Officielle |
- DingTalk | Application WeChat Work & Bot intelligent WeChat Work | Officielle |
- Slack | Service client WeChat & Comptes officiels WeChat | Officielle |
- Discord | Feishu (Lark) | Officielle |
- Satori | DingTalk | Officielle |
- Misskey | Slack | Officielle |
- LINE | Discord | Officielle |
- WhatsApp (Bientôt disponible) | LINE | Officielle |
| Satori | Officielle |
**Maintenues par la communauté** | Misskey | Officielle |
| WhatsApp (Bientôt disponible) | Officielle |
- [Matrix](https://github.com/stevessr/astrbot_plugin_matrix_adapter) | [Matrix](https://github.com/stevessr/astrbot_plugin_matrix_adapter) | Communauté |
- [KOOK](https://github.com/wuyan1003/astrbot_plugin_kook_adapter) | [KOOK](https://github.com/wuyan1003/astrbot_plugin_kook_adapter) | Communauté |
- [VoceChat](https://github.com/HikariFroya/astrbot_plugin_vocechat) | [VoceChat](https://github.com/HikariFroya/astrbot_plugin_vocechat) | Communauté |
## Services de modèles pris en charge ## Services de modèles pris en charge
**Services LLM** | Service | Type |
|---------|---------------|
- OpenAI et services compatibles | OpenAI et services compatibles | Services LLM |
- Anthropic | Anthropic | Services LLM |
- Google Gemini | Google Gemini | Services LLM |
- Moonshot AI | Moonshot AI | Services LLM |
- Zhipu AI | Zhipu AI | Services LLM |
- DeepSeek | DeepSeek | Services LLM |
- Ollama (Auto-hébergé) | Ollama (Auto-hébergé) | Services LLM |
- LM Studio (Auto-hébergé) | LM Studio (Auto-hébergé) | Services LLM |
- [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_astrbot&referral_code=FV7DcGowN4hB5UuXKgpE74) | [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_astrbot&referral_code=FV7DcGowN4hB5UuXKgpE74) | Services LLM |
- [302.AI](https://share.302.ai/rr1M3l) | [302.AI](https://share.302.ai/rr1M3l) | Services LLM |
- [TokenPony](https://www.tokenpony.cn/3YPyf) | [TokenPony](https://www.tokenpony.cn/3YPyf) | Services LLM |
- [SiliconFlow](https://docs.siliconflow.cn/cn/usercases/use-siliconcloud-in-astrbot) | [SiliconFlow](https://docs.siliconflow.cn/cn/usercases/use-siliconcloud-in-astrbot) | Services LLM |
- [PPIO Cloud](https://ppio.com/user/register?invited_by=AIOONE) | [PPIO Cloud](https://ppio.com/user/register?invited_by=AIOONE) | Services LLM |
- ModelScope | ModelScope | Services LLM |
- OneAPI | OneAPI | Services LLM |
| Dify | Plateformes LLMOps |
**Plateformes LLMOps** | Applications Alibaba Cloud Bailian | Plateformes LLMOps |
| Coze | Plateformes LLMOps |
- Dify | OpenAI Whisper | Services de reconnaissance vocale |
- Applications Alibaba Cloud Bailian | SenseVoice | Services de reconnaissance vocale |
- Coze | OpenAI TTS | Services de synthèse vocale |
| Gemini TTS | Services de synthèse vocale |
**Services de reconnaissance vocale** | GPT-Sovits-Inference | Services de synthèse vocale |
| GPT-Sovits | Services de synthèse vocale |
- OpenAI Whisper | FishAudio | Services de synthèse vocale |
- SenseVoice | Edge TTS | Services de synthèse vocale |
| Alibaba Cloud Bailian TTS | Services de synthèse vocale |
**Services de synthèse vocale** | Azure TTS | Services de synthèse vocale |
| Minimax TTS | Services de synthèse vocale |
- OpenAI TTS | Volcano Engine TTS | Services de synthèse vocale |
- Gemini TTS
- GPT-Sovits-Inference
- GPT-Sovits
- FishAudio
- Edge TTS
- Alibaba Cloud Bailian TTS
- Azure TTS
- Minimax TTS
- Volcano Engine TTS
## ❤️ Contribuer ## ❤️ Contribuer
+69 -76
View File
@@ -2,8 +2,8 @@
<div align="center"> <div align="center">
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README.md">中文</a> <a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_zh.md">中文</a>
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_en.md">English</a> <a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README.md">English</a>
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_zh-TW.md">繁體中文</a> <a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_zh-TW.md">繁體中文</a>
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_fr.md">Français</a> <a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_fr.md">Français</a>
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ru.md">Русский</a> <a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ru.md">Русский</a>
@@ -21,9 +21,9 @@
<img src="https://img.shields.io/github/v/release/AstrBotDevs/AstrBot?color=76bad9" href="https://github.com/AstrBotDevs/AstrBot/releases/latest"> <img src="https://img.shields.io/github/v/release/AstrBotDevs/AstrBot?color=76bad9" href="https://github.com/AstrBotDevs/AstrBot/releases/latest">
<img src="https://img.shields.io/badge/python-3.10+-blue.svg" alt="python"> <img src="https://img.shields.io/badge/python-3.10+-blue.svg" alt="python">
<img src="https://deepwiki.com/badge.svg" href="https://deepwiki.com/AstrBotDevs/AstrBot"> <img src="https://deepwiki.com/badge.svg" href="https://deepwiki.com/AstrBotDevs/AstrBot">
<a href="https://zread.ai/AstrBotDevs/AstrBot" target="_blank"><img src="https://img.shields.io/badge/Ask_Zread-_.svg?style=flat&color=00b0aa&labelColor=000000&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTQuOTYxNTYgMS42MDAxSDIuMjQxNTZDMS44ODgxIDEuNjAwMSAxLjYwMTU2IDEuODg2NjQgMS42MDE1NiAyLjI0MDFWNC45NjAxQzEuNjAxNTYgNS4zMTM1NiAxLjg4ODEgNS42MDAxIDIuMjQxNTYgNS42MDAxSDQuOTYxNTZDNS4zMTUwMiA1LjYwMDEgNS42MDE1NiA1LjMxMzU2IDUuNjAxNTYgNC45NjAxVjIuMjQwMUM1LjYwMTU2IDEuODg2NjQgNS4zMTUwMiAxLjYwMDEgNC45NjE1NiAxLjYwMDFaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00Ljk2MTU2IDEwLjM5OTlIMi4yNDE1NkMxLjg4ODEgMTAuMzk5OSAxLjYwMTU2IDEwLjY4NjQgMS42MDE1NiAxMS4wMzk5VjEzLjc1OTlDMS42MDE1NiAxNC4xMTM0IDEuODg4MSAxNC4zOTk5IDIuMjQxNTYgMTQuMzk5OUg0Ljk2MTU2QzUuMzE1MDIgMTQuMzk5OSA1LjYwMTU2IDE0LjExMzQgNS42MDE1NiAxMy43NTk5VjExLjAzOTlDNS42MDE1NiAxMC42ODY0IDUuMzE1MDIgMTAuMzk5OSA0Ljk2MTU2IDEwLjM5OTlaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik0xMy43NTg0IDEuNjAwMUgxMS4wMzg0QzEwLjY4NSAxLjYwMDEgMTAuMzk4NCAxLjg4NjY0IDEwLjM5ODQgMi4yNDAxVjQuOTYwMUMxMC4zOTg0IDUuMzEzNTYgMTAuNjg1IDUuNjAwMSAxMS4wMzg0IDUuNjAwMUgxMy43NTg0QzE0LjExMTkgNS42MDAxIDE0LjM5ODQgNS4zMTM1NiAxNC4zOTg0IDQuOTYwMVYyLjI0MDFDMTQuMzk4NCAxLjg4NjY0IDE0LjExMTkgMS42MDAxIDEzLjc1ODQgMS42MDAxWiIgZmlsbD0iI2ZmZiIvPgo8cGF0aCBkPSJNNCAxMkwxMiA0TDQgMTJaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00IDEyTDEyIDQiIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLXdpZHRoPSIxLjUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPgo8L3N2Zz4K&logoColor=ffffff" alt="zread"/></a> <a href="https://zread.ai/AstrBotDevs/AstrBot" target="_blank"><img src="https://img.shields.io/badge/Ask_Zread-_.svg?style=flat&color=00b0aa&labelColor=000000&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTQuOTYxNTYgMS42MDAxSDIuMjQxNTZDMS44ODgxIDEuNjAwMSAxLjYwMTU2IDEuODg2NjQgMS42MDE1NiAyLjI0MDFWNC45NjAxQzEuNjAxNTYgNS4zMTM1NiAxLjg4ODEgNS42MDAxIDIuMjQxNTYgNS42MDAxSDQuOTYxNTZDNS4zMTUwMiA1LjYwMDEgNS42MDE1NiA1LjMxMzU2IDUuNjAxNTYgNC45NjAxVjIuMjQwMUM1LjYwMTU2IDEuODg2NjQgNS4zMTUwMiAxLjYwMDEgNC45NjE1NiAxLjYwMDFZIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00Ljk2MTU2IDEwLjM5OTlIMi4yNDE1NkMxLjg4ODEgMTAuMzk5OSAxLjYwMTU2IDEwLjY4NjQgMS42MDE1NiAxMS4wMzk5VjEzLjc1OTlDMS42MDE1NiAxNC4xMTM0IDEuODg4MSAxNC4zOTk5IDIuMjQxNTYgMTQuMzk5OUg0LjYxNTZDNS4zMTUwMiAxNC4zOTk5IDUuNjAxNTYgMTQuMTEzNCA1LjYwMTU2IDEzLjc1OTlWMTEuMDM5OUM1LjYwMTU2IDEwLjY4NjQgNS4zMTUwMiAxMC4zOTk5IDQuOTYxNTYgMTAuMzk5OVoiIGZpbGw9IiNmZmYiLz4KPHBhdGggZD0iTTEzLjc1ODQgMS42MDAxSDExLjAzODRDMTAuNjg1IDEuNjAwMSAxMC4zOTg0IDEuODg2NjQgMTAuMzk4NCAyLjI0MDFWNC45NjAxQzEwLjM5ODQgNS4zMTM1NiAxMC42ODUgNS42MDAxIDExLjAzODQgNS42MDAxSDEzLjc1ODRDMTQuMTExOSA1LjYwMDEgMTQuMzk4NCA1LjMxMzU2IDE0LjM5ODQgNC45NjAxVjIuMjQwMUMxNC4zOTg0IDEuODg2NjQgMTQuMTExOSAxLjYwMDEgMTMuNzU4NCAxLjYwMDFZIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00IDEyTDEyIDRMNCAxMlpFIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00IDEyTDEyIDQiIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLXdpZHRoPSIxLjUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPgo8L3N2Zz4K&logoColor=ffffff" alt="zread"/></a>
<a href="https://hub.docker.com/r/soulter/astrbot"><img alt="Docker pull" src="https://img.shields.io/docker/pulls/soulter/astrbot.svg?color=76bad9"/></a> <a href="https://hub.docker.com/r/soulter/astrbot"><img alt="Docker pull" src="https://img.shields.io/docker/pulls/soulter/astrbot.svg?color=76bad9"/></a>
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.soulter.top%2Fastrbot%2Fplugin-num&query=%24.result&suffix=%E4%B8%AA&label=%E6%8F%92%E4%BB%B6%E5%B8%82%E5%9C%BA&cacheSeconds=3600"> <img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.soulter.top%2Fastrbot%2Fplugin-num&query=%24.result&suffix=%20&label=%E3%83%97%E3%83%A9%E3%82%B0%E3%82%A4%E3%83%B3%E3%83%9E%E3%83%BC%E3%82%B1%E3%83%83%E3%83%88&cacheSeconds=3600">
<img src="https://gitcode.com/Soulter/AstrBot/star/badge.svg" href="https://gitcode.com/Soulter/AstrBot"> <img src="https://gitcode.com/Soulter/AstrBot/star/badge.svg" href="https://gitcode.com/Soulter/AstrBot">
</div> </div>
@@ -37,7 +37,7 @@
AstrBot は、主要なインスタントメッセージングアプリと統合できるオープンソースのオールインワン Agent チャットボットプラットフォームです。個人、開発者、チームに信頼性が高くスケーラブルな会話型 AI インフラストラクチャを提供します。パーソナル AI コンパニオン、インテリジェントカスタマーサービス、オートメーションアシスタント、エンタープライズナレッジベースなど、AstrBot を使用すると、IM プラットフォームのワークフロー内で本番環境対応の AI アプリケーションを迅速に構築できます。 AstrBot は、主要なインスタントメッセージングアプリと統合できるオープンソースのオールインワン Agent チャットボットプラットフォームです。個人、開発者、チームに信頼性が高くスケーラブルな会話型 AI インフラストラクチャを提供します。パーソナル AI コンパニオン、インテリジェントカスタマーサービス、オートメーションアシスタント、エンタープライズナレッジベースなど、AstrBot を使用すると、IM プラットフォームのワークフロー内で本番環境対応の AI アプリケーションを迅速に構築できます。
<img width="1776" height="1080" alt="image" src="https://github.com/user-attachments/assets/00782c4c-4437-4d97-aabc-605e3738da5c" /> ![screenshot_1 5x_postspark_2026-02-27_22-37-45](https://github.com/user-attachments/assets/f17cdb90-52d7-4773-be2e-ff64b566af6b)
## 主な機能 ## 主な機能
@@ -45,7 +45,7 @@ AstrBot は、主要なインスタントメッセージングアプリと統合
2. ✨ AI大規模言語モデル対話、マルチモーダル、Agent、MCP、Skills、ナレッジベース、ペルソナ設定、対話の自動圧縮。 2. ✨ AI大規模言語モデル対話、マルチモーダル、Agent、MCP、Skills、ナレッジベース、ペルソナ設定、対話の自動圧縮。
3. 🤖 Dify、Alibaba Cloud Bailian(百煉)、Coze などのAgentプラットフォームへの接続をサポート。 3. 🤖 Dify、Alibaba Cloud Bailian(百煉)、Coze などのAgentプラットフォームへの接続をサポート。
4. 🌐 マルチプラットフォーム:QQ、企業微信(WeCom)、飛書(Lark)、釘釘(DingTalk)、WeChat公式アカウント、Telegram、Slack、[その他](#サポートされているメッセージプラットフォーム)に対応。 4. 🌐 マルチプラットフォーム:QQ、企業微信(WeCom)、飛書(Lark)、釘釘(DingTalk)、WeChat公式アカウント、Telegram、Slack、[その他](#サポートされているメッセージプラットフォーム)に対応。
5. 📦 プラグイン拡張:800近い既存プラグインをワンクリックでインストール可能。 5. 📦 プラグイン拡張:1000を超える既存プラグインをワンクリックでインストール可能。
6. 🛡️ 隔離環境[Agent Sandbox](https://docs.astrbot.app/use/astrbot-agent-sandbox.html):コードの安全な実行、Shell呼び出し、セッションレベルのリソース再利用。 6. 🛡️ 隔離環境[Agent Sandbox](https://docs.astrbot.app/use/astrbot-agent-sandbox.html):コードの安全な実行、Shell呼び出し、セッションレベルのリソース再利用。
7. 💻 WebUI 対応。 7. 💻 WebUI 対応。
8. 🌈 Web ChatUI 対応:ChatUI内にAgent Sandboxやウェブ検索などを内蔵。 8. 🌈 Web ChatUI 対応:ChatUI内にAgent Sandboxやウェブ検索などを内蔵。
@@ -58,7 +58,7 @@ AstrBot は、主要なインスタントメッセージングアプリと統合
<th>💙 ロールプレイ & 感情的な対話</th> <th>💙 ロールプレイ & 感情的な対話</th>
<th>✨ プロアクティブ・エージェント (Proactive Agent)</th> <th>✨ プロアクティブ・エージェント (Proactive Agent)</th>
<th>🚀 汎用 エージェント的能力</th> <th>🚀 汎用 エージェント的能力</th>
<th>🧩 900+ コミュニティプラグイン</th> <th>🧩 1000+ コミュニティプラグイン</th>
</tr> </tr>
<tr> <tr>
<td align="center"><p align="center"><img width="984" height="1746" alt="99b587c5d35eea09d84f33e6cf6cfd4f" src="https://github.com/user-attachments/assets/89196061-3290-458d-b51f-afa178049f84" /></p></td> <td align="center"><p align="center"><img width="984" height="1746" alt="99b587c5d35eea09d84f33e6cf6cfd4f" src="https://github.com/user-attachments/assets/89196061-3290-458d-b51f-afa178049f84" /></p></td>
@@ -83,15 +83,15 @@ uv tool install astrbot
astrbot astrbot
``` ```
#### システムパッケージマネージャーでのインストール #### デスクトップアプリのデプロイ(Tauri)
##### Arch Linux デスクトップアプリのリポジトリ [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop)。
```bash マルチシステムアーキテクチャをサポートし、インストールしてすぐに使用可能。初心者や手軽さを求める人に最適なワンクリックデスクトップデプロイソリューションです。サーバー環境での使用は推奨されません。
yay -S astrbot-git
# または paru を使用 #### ランチャーによるワンクリックデプロイ(AstrBot Launcher
paru -S astrbot-git
``` 迅速なデプロイとマルチインスタンス対応、環境の隔離が可能。[AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) リポジトリにアクセスし、Releases ページから最新バージョンのシステム対応パッケージをダウンロードしてインストールしてください。
#### 宝塔パネルデプロイ #### 宝塔パネルデプロイ
@@ -107,6 +107,8 @@ AstrBot は 1Panel 公式により 1Panel パネルに公開されています
#### 雨云でのデプロイ #### 雨云でのデプロイ
For Chinese users:
AstrBot は雨云公式によりクラウドアプリケーションプラットフォームに公開され、ワンクリックでデプロイ可能です。 AstrBot は雨云公式によりクラウドアプリケーションプラットフォームに公開され、ワンクリックでデプロイ可能です。
[![Deploy on RainYun](https://rainyun-apps.cn-nb1.rains3.com/materials/deploy-on-rainyun-en.svg)](https://app.rainyun.com/apps/rca/store/5994?ref=NjU1ODg0) [![Deploy on RainYun](https://rainyun-apps.cn-nb1.rains3.com/materials/deploy-on-rainyun-en.svg)](https://app.rainyun.com/apps/rca/store/5994?ref=NjU1ODg0)
@@ -144,83 +146,74 @@ uv run main.py
または、公式ドキュメント [ソースコードから AstrBot をデプロイ](https://astrbot.app/deploy/astrbot/cli.html) をご参照ください。 または、公式ドキュメント [ソースコードから AstrBot をデプロイ](https://astrbot.app/deploy/astrbot/cli.html) をご参照ください。
#### Установка через системный пакетный менеджер #### システムパッケージマネージャーでのインストール
##### Arch Linux ##### Arch Linux
```bash ```bash
yay -S astrbot-git yay -S astrbot-git
# или используйте paru # または paru を使用
paru -S astrbot-git paru -S astrbot-git
``` ```
## サポートされているメッセージプラットフォーム ## サポートされているメッセージプラットフォーム
**公式メンテナンス** AstrBot をよく使うチャットプラットフォームに接続できます。
- QQ (公式プラットフォーム & OneBot) | プラットフォーム | 保守 |
- Telegram |---------|---------------|
- WeChat Work アプリケーション & WeChat Work インテリジェントボット | QQ | 公式 |
- WeChat カスタマーサービス & WeChat 公式アカウント | OneBot v11 プロトコル実装 | 公式 |
- Feishu (Lark) | Telegram | 公式 |
- DingTalk | WeChat Work アプリケーション & WeChat Work インテリジェントボット | 公式 |
- Slack | WeChat カスタマーサービス & WeChat 公式アカウント | 公式 |
- Discord | Feishu (Lark) | 公式 |
- Satori | DingTalk | 公式 |
- Misskey | Slack | 公式 |
- LINE | Discord | 公式 |
- WhatsApp (近日対応予定) | LINE | 公式 |
| Satori | 公式 |
**コミュニティメンテナンス** | Misskey | 公式 |
| WhatsApp (近日対応予定) | 公式 |
- [Matrix](https://github.com/stevessr/astrbot_plugin_matrix_adapter) | [Matrix](https://github.com/stevessr/astrbot_plugin_matrix_adapter) | コミュニティ |
- [KOOK](https://github.com/wuyan1003/astrbot_plugin_kook_adapter) | [KOOK](https://github.com/wuyan1003/astrbot_plugin_kook_adapter) | コミュニティ |
- [VoceChat](https://github.com/HikariFroya/astrbot_plugin_vocechat) | [VoceChat](https://github.com/HikariFroya/astrbot_plugin_vocechat) | コミュニティ |
## サポートされているモデルサービス ## サポートされているモデルサービス
**大規模言語モデルサービス** | サービス | 種類 |
|---------|---------------|
- OpenAI および互換サービス | OpenAI および互換サービス | 大規模言語モデルサービス |
- Anthropic | Anthropic | 大規模言語モデルサービス |
- Google Gemini | Google Gemini | 大規模言語モデルサービス |
- Moonshot AI | Moonshot AI | 大規模言語モデルサービス |
- 智谱 AI | 智谱 AI | 大規模言語モデルサービス |
- DeepSeek | DeepSeek | 大規模言語モデルサービス |
- Ollama (セルフホスト) | Ollama (セルフホスト) | 大規模言語モデルサービス |
- LM Studio (セルフホスト) | LM Studio (セルフホスト) | 大規模言語モデルサービス |
- [優云智算](https://www.compshare.cn/?ytag=GPU_YY-gh_astrbot&referral_code=FV7DcGowN4hB5UuXKgpE74) | [優云智算](https://www.compshare.cn/?ytag=GPU_YY-gh_astrbot&referral_code=FV7DcGowN4hB5UuXKgpE74) | 大規模言語モデルサービス |
- [302.AI](https://share.302.ai/rr1M3l) | [302.AI](https://share.302.ai/rr1M3l) | 大規模言語モデルサービス |
- [小馬算力](https://www.tokenpony.cn/3YPyf) | [小馬算力](https://www.tokenpony.cn/3YPyf) | 大規模言語モデルサービス |
- [硅基流動](https://docs.siliconflow.cn/cn/usercases/use-siliconcloud-in-astrbot) | [硅基流動](https://docs.siliconflow.cn/cn/usercases/use-siliconcloud-in-astrbot) | 大規模言語モデルサービス |
- [PPIO 派欧云](https://ppio.com/user/register?invited_by=AIOONE) | [PPIO 派欧云](https://ppio.com/user/register?invited_by=AIOONE) | 大規模言語モデルサービス |
- ModelScope | ModelScope | 大規模言語モデルサービス |
- OneAPI | OneAPI | 大規模言語モデルサービス |
| Dify | LLMOps プラットフォーム |
**LLMOps プラットフォーム** | Alibaba Cloud 百炼アプリケーション | LLMOps プラットフォーム |
| Coze | LLMOps プラットフォーム |
- Dify | OpenAI Whisper | 音声認識サービス |
- Alibaba Cloud 百炼アプリケーション | SenseVoice | 音声認識サービス |
- Coze | OpenAI TTS | 音声合成サービス |
| Gemini TTS | 音声合成サービス |
**音声認識サービス** | GPT-Sovits-Inference | 音声合成サービス |
| GPT-Sovits | 音声合成サービス |
- OpenAI Whisper | FishAudio | 音声合成サービス |
- SenseVoice | Edge TTS | 音声合成サービス |
| Alibaba Cloud 百炼 TTS | 音声合成サービス |
**音声合成サービス** | Azure TTS | 音声合成サービス |
| Minimax TTS | 音声合成サービス |
- OpenAI TTS | Volcano Engine TTS | 音声合成サービス |
- Gemini TTS
- GPT-Sovits-Inference
- GPT-Sovits
- FishAudio
- Edge TTS
- Alibaba Cloud 百炼 TTS
- Azure TTS
- Minimax TTS
- Volcano Engine TTS
## ❤️ コントリビューション ## ❤️ コントリビューション
+72 -70
View File
@@ -2,8 +2,8 @@
<div align="center"> <div align="center">
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README.md">中文</a> <a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_zh.md">中文</a>
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_en.md">English</a> <a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README.md">English</a>
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ja.md">日本語</a> <a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ja.md">日本語</a>
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_zh-TW.md">繁體中文</a> <a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_zh-TW.md">繁體中文</a>
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_fr.md">Français</a> <a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_fr.md">Français</a>
@@ -21,9 +21,9 @@
<img src="https://img.shields.io/github/v/release/AstrBotDevs/AstrBot?color=76bad9" href="https://github.com/AstrBotDevs/AstrBot/releases/latest"> <img src="https://img.shields.io/github/v/release/AstrBotDevs/AstrBot?color=76bad9" href="https://github.com/AstrBotDevs/AstrBot/releases/latest">
<img src="https://img.shields.io/badge/python-3.10+-blue.svg" alt="python"> <img src="https://img.shields.io/badge/python-3.10+-blue.svg" alt="python">
<img src="https://deepwiki.com/badge.svg" href="https://deepwiki.com/AstrBotDevs/AstrBot"> <img src="https://deepwiki.com/badge.svg" href="https://deepwiki.com/AstrBotDevs/AstrBot">
<a href="https://zread.ai/AstrBotDevs/AstrBot" target="_blank"><img src="https://img.shields.io/badge/Ask_Zread-_.svg?style=flat&color=00b0aa&labelColor=000000&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTQuOTYxNTYgMS42MDAxSDIuMjQxNTZDMS44ODgxIDEuNjAwMSAxLjYwMTU2IDEuODg2NjQgMS42MDE1NiAyLjI0MDFWNC45NjAxQzEuNjAxNTYgNS4zMTM1NiAxLjg4ODEgNS42MDAxIDIuMjQxNTYgNS42MDAxSDQuOTYxNTZDNS4zMTUwMiA1LjYwMDEgNS42MDE1NiA1LjMxMzU2IDUuNjAxNTYgNC45NjAxVjIuMjQwMUM1LjYwMTU2IDEuODg2NjQgNS4zMTUwMiAxLjYwMDEgNC45NjE1NiAxLjYwMDFaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00Ljk2MTU2IDEwLjM5OTlIMi4yNDE1NkMxLjg4ODEgMTAuMzk5OSAxLjYwMTU2IDEwLjY4NjQgMS42MDE1NiAxMS4wMzk5VjEzLjc1OTlDMS42MDE1NiAxNC4xMTM0IDEuODg4MSAxNC4zOTk5IDIuMjQxNTYgMTQuMzk5OUg0Ljk2MTU2QzUuMzE1MDIgMTQuMzk5OSA1LjYwMTU2IDE0LjExMzQgNS42MDE1NiAxMy43NTk5VjExLjAzOTlDNS42MDE1NiAxMC42ODY0IDUuMzE1MDIgMTAuMzk5OSA0Ljk2MTU2IDEwLjM5OTlaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik0xMy43NTg0IDEuNjAwMUgxMS4wMzg0QzEwLjY4NSAxLjYwMDEgMTAuMzk4NCAxLjg4NjY0IDEwLjM5ODQgMi4yNDAxVjQuOTYwMUMxMC4zOTg0IDUuMzEzNTYgMTAuNjg1IDUuNjAwMSAxMS4wMzg0IDUuNjAwMUgxMy43NTg0QzE0LjExMTkgNS42MDAxIDE0LjM5ODQgNS4zMTM1NiAxNC4zOTg0IDQuOTYwMVYyLjI0MDFDMTQuMzk4NCAxLjg4NjY0IDE0LjExMTkgMS42MDAxIDEzLjc1ODQgMS42MDAxWiIgZmlsbD0iI2ZmZiIvPgo8cGF0aCBkPSJNNCAxMkwxMiA0TDQgMTJaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00IDEyTDEyIDQiIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLXdpZHRoPSIxLjUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPgo8L3N2Zz4K&logoColor=ffffff" alt="zread"/></a> <a href="https://zread.ai/AstrBotDevs/AstrBot" target="_blank"><img src="https://img.shields.io/badge/Ask_Zread-_.svg?style=flat&color=00b0aa&labelColor=000000&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTQuOTYxNTYgMS42MDAxSDIuMjQxNTZDMS44ODgxIDEuNjAwMSAxLjYwMTU2IDEuODg2NjQgMS42MDE1NiAyLjI0MDFWNC45NjAxQzEuNjAxNTYgNS4zMTM1NiAxLjg4ODEgNS42MDAxIDIuMjQxNTYgNS42MDAxSDQuOTYxNTZDNS4zMTUwMiA1LjYwMDEgNS42MDE1NiA1LjMxMzU2IDUuNjAxNTYgNC45NjAxVjIuMjQwMUM1LjYwMTU2IDEuODg2NjQgNS4zMTUwMiAxLjYwMDEgNC45NjE1NiAxLjYwMDFZIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00Ljk2MTU2IDEwLjM5OTlIMi4yNDE1NkMxLjg4ODEgMTAuMzk5OSAxLjYwMTU2IDEwLjY4NjQgMS42MDE1NiAxMS4wMzk5VjEzLjc1OTlDMS42MDE1NiAxNC4xMTM0IDEuODg4MSAxNC4zOTk5IDIuMjQxNTYgMTQuMzk5OUg0Ljk2MTU2QzUuMzE1MDIgMTQuMzk5OSA1LjYwMTU2IDE0LjExMzQgNS42MDE1NiAxMy43NTk5VjExLjAzOTlDNS42MDE1NiAxMC42ODY0IDUuMzE1MDIgMTAuMzk5OSA0Ljk2MTU2IDEwLjM5OTlaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik0xMy43NTg0IDEuNjAwMUgxMS4wMzg0QzEwLjY4NSAxLjYwMDEgMTAuMzk4NCAxLjg4NjY0IDEwLjM5ODQgMi4yNDAxVjQuOTYwMUMxMC4zOTg0IDUuMzEzNTYgMTAuNjg1IDUuNjAwMSAxMS4wMzg0IDUuNjAwMUgxMy43NTg0QzE0LjExMTkgNS42MDAxIDE0LjM5ODQgNS4zMTM1NiAxNC4zOTg0IDQuOTYwMVYyLjI0MDFDMTQuMzk4NCAxLjg4NjY0IDE0LjExMTkgMS42MDAxIDEzLjczODQgMS42MDAxWiIgZmlsbD0iI2ZmZiIvPgo8cGF0aCBkPSJNNCAxMkwxMiA0TDQgMTJaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00IDEyTDEyIDQiIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLXdpZHRoPSIxLjUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPgo8L3N2Zz4K&logoColor=ffffff" alt="zread"/></a>
<a href="https://hub.docker.com/r/soulter/astrbot"><img alt="Docker pull" src="https://img.shields.io/docker/pulls/soulter/astrbot.svg?color=76bad9"/></a> <a href="https://hub.docker.com/r/soulter/astrbot"><img alt="Docker pull" src="https://img.shields.io/docker/pulls/soulter/astrbot.svg?color=76bad9"/></a>
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.soulter.top%2Fastrbot%2Fplugin-num&query=%24.result&suffix=%E4%B8%AA&label=%E6%8F%92%E4%BB%B6%E5%B8%82%E5%9C%BA&cacheSeconds=3600"> <img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.soulter.top%2Fastrbot%2Fplugin-num&query=%24.result&suffix=%20&label=%D0%9C%D0%B0%D1%80%D0%BA%D0%B5%D1%82%D0%BF%D0%BB%D0%B5%D0%B9%D1%81&cacheSeconds=3600">
<img src="https://gitcode.com/Soulter/AstrBot/star/badge.svg" href="https://gitcode.com/Soulter/AstrBot"> <img src="https://gitcode.com/Soulter/AstrBot/star/badge.svg" href="https://gitcode.com/Soulter/AstrBot">
</div> </div>
@@ -37,7 +37,7 @@
AstrBot — это универсальная платформа Agent-чатботов с открытым исходным кодом, которая интегрируется с основными приложениями для обмена мгновенными сообщениями. Она предоставляет надёжную и масштабируемую инфраструктуру разговорного ИИ для частных лиц, разработчиков и команд. Будь то персональный ИИ-компаньон, интеллектуальная служба поддержки, автоматизированный помощник или корпоративная база знаний — AstrBot позволяет быстро создавать готовые к использованию ИИ-приложения в рабочих процессах вашей платформы обмена сообщениями. AstrBot — это универсальная платформа Agent-чатботов с открытым исходным кодом, которая интегрируется с основными приложениями для обмена мгновенными сообщениями. Она предоставляет надёжную и масштабируемую инфраструктуру разговорного ИИ для частных лиц, разработчиков и команд. Будь то персональный ИИ-компаньон, интеллектуальная служба поддержки, автоматизированный помощник или корпоративная база знаний — AstrBot позволяет быстро создавать готовые к использованию ИИ-приложения в рабочих процессах вашей платформы обмена сообщениями.
<img width="1776" height="1080" alt="image" src="https://github.com/user-attachments/assets/00782c4c-4437-4d97-aabc-605e3738da5c" /> ![521771166-00782c4c-4437-4d97-aabc-605e3738da5c (1)](https://github.com/user-attachments/assets/61e7b505-f7db-41aa-a75f-4ef8f079b8ba)
## Основные возможности ## Основные возможности
@@ -45,7 +45,7 @@ AstrBot — это универсальная платформа Agent-чатб
2. ✨ Диалоги с ИИ-моделями, мультимодальность, Agent, MCP, Skills, База знаний, Настройка личности, автоматическое сжатие диалогов. 2. ✨ Диалоги с ИИ-моделями, мультимодальность, Agent, MCP, Skills, База знаний, Настройка личности, автоматическое сжатие диалогов.
3. 🤖 Поддержка интеграции с платформами Agents, такими как Dify, Alibaba Cloud Bailian, Coze и др. 3. 🤖 Поддержка интеграции с платформами Agents, такими как Dify, Alibaba Cloud Bailian, Coze и др.
4. 🌐 Мультиплатформенность: поддержка QQ, WeChat для предприятий, Feishu, DingTalk, публичных аккаунтов WeChat, Telegram, Slack и [других](#Поддерживаемые-платформы-обмена-сообщениями). 4. 🌐 Мультиплатформенность: поддержка QQ, WeChat для предприятий, Feishu, DingTalk, публичных аккаунтов WeChat, Telegram, Slack и [других](#Поддерживаемые-платформы-обмена-сообщениями).
5. 📦 Расширение плагинами: доступно почти 800 плагинов для установки в один клик. 5. 📦 Расширение плагинами: доступно более 1000 плагинов для установки в один клик.
6. 🛡️ Изолированная среда[Agent Sandbox](https://docs.astrbot.app/use/astrbot-agent-sandbox.html): безопасное выполнение любого кода, вызов Shell, повторное использование ресурсов на уровне сессии. 6. 🛡️ Изолированная среда[Agent Sandbox](https://docs.astrbot.app/use/astrbot-agent-sandbox.html): безопасное выполнение любого кода, вызов Shell, повторное использование ресурсов на уровне сессии.
7. 💻 Поддержка WebUI. 7. 💻 Поддержка WebUI.
8. 🌈 Поддержка Web ChatUI: встроенная песочница агента, веб-поиск и др. 8. 🌈 Поддержка Web ChatUI: встроенная песочница агента, веб-поиск и др.
@@ -56,9 +56,9 @@ AstrBot — это универсальная платформа Agent-чатб
<table align="center"> <table align="center">
<tr align="center"> <tr align="center">
<th>💙 Ролевые игры & Эмоциональная поддержка</th> <th>💙 Ролевые игры & Эмоциональная поддержка</th>
<th>✨ Проактивный Агент(Agent)</th> <th>✨ Проактивный Агент (Agent)</th>
<th>🚀 Универсальные Агентные возможности</th> <th>🚀 Универсальные возможности Агента</th>
<th>🧩 Универсальные Агентные (Agentic) возможности</th> <th>🧩 1000+ плагинов сообщества</th>
</tr> </tr>
<tr> <tr>
<td align="center"><p align="center"><img width="984" height="1746" alt="99b587c5d35eea09d84f33e6cf6cfd4f" src="https://github.com/user-attachments/assets/89196061-3290-458d-b51f-afa178049f84" /></p></td> <td align="center"><p align="center"><img width="984" height="1746" alt="99b587c5d35eea09d84f33e6cf6cfd4f" src="https://github.com/user-attachments/assets/89196061-3290-458d-b51f-afa178049f84" /></p></td>
@@ -83,6 +83,16 @@ uv tool install astrbot
astrbot astrbot
``` ```
#### Десктопное приложение (Tauri)
Репозиторий десктопного приложения: [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop).
Поддерживает различные системные архитектуры, устанавливается напрямую, "из коробки", лучшее настольное решение в один клик для новичков и тех, кто ценит простоту. Не рекомендуется для серверных сценариев.
#### Установка в один клик через лаунчер (AstrBot Launcher)
Быстрое развёртывание и поддержка нескольких экземпляров, изоляция среды. Перейдите в репозиторий [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher), найдите последнюю версию на странице Releases и установите соответствующий пакет для вашей системы.
#### Развёртывание BT-Panel #### Развёртывание BT-Panel
AstrBot в партнёрстве с BT-Panel теперь доступен на их маркетплейсе. AstrBot в партнёрстве с BT-Panel теперь доступен на их маркетплейсе.
@@ -97,6 +107,8 @@ AstrBot официально размещён на маркетплейсе 1Pan
#### Развёртывание на RainYun #### Развёртывание на RainYun
For Chinese users:
AstrBot официально размещён на облачной платформе приложений RainYun с развёртыванием в один клик. AstrBot официально размещён на облачной платформе приложений RainYun с развёртыванием в один клик.
[![Deploy on RainYun](https://rainyun-apps.cn-nb1.rains3.com/materials/deploy-on-rainyun-en.svg)](https://app.rainyun.com/apps/rca/store/5994?ref=NjU1ODg0) [![Deploy on RainYun](https://rainyun-apps.cn-nb1.rains3.com/materials/deploy-on-rainyun-en.svg)](https://app.rainyun.com/apps/rca/store/5994?ref=NjU1ODg0)
@@ -146,71 +158,61 @@ paru -S astrbot-git
## Поддерживаемые платформы обмена сообщениями ## Поддерживаемые платформы обмена сообщениями
**Официально поддерживаемые** Подключите AstrBot к вашим любимым чат-платформам.
- QQ (Официальная платформа и OneBot) | Платформа | Поддержка |
- Telegram |---------|---------------|
- Приложение WeChat Work и интеллектуальный бот WeChat Work | QQ | Официальная |
- Служба поддержки WeChat и официальные аккаунты WeChat | Реализация протокола OneBot v11 | Официальная |
- Feishu (Lark) | Telegram | Официальная |
- DingTalk | Приложение WeChat Work и интеллектуальный бот WeChat Work | Официальная |
- Slack | Служба поддержки WeChat и официальные аккаунты WeChat | Официальная |
- Discord | Feishu (Lark) | Официальная |
- Satori | DingTalk | Официальная |
- Misskey | Slack | Официальная |
- LINE | Discord | Официальная |
- WhatsApp (Скоро) | LINE | Официальная |
| Satori | Официальная |
| Misskey | Официальная |
**Поддерживаемые сообществом** | WhatsApp (Скоро) | Официальная |
| [Matrix](https://github.com/stevessr/astrbot_plugin_matrix_adapter) | Сообщество |
- [Matrix](https://github.com/stevessr/astrbot_plugin_matrix_adapter) | [KOOK](https://github.com/wuyan1003/astrbot_plugin_kook_adapter) | Сообщество |
- [KOOK](https://github.com/wuyan1003/astrbot_plugin_kook_adapter) | [VoceChat](https://github.com/HikariFroya/astrbot_plugin_vocechat) | Сообщество |
- [VoceChat](https://github.com/HikariFroya/astrbot_plugin_vocechat)
## Поддерживаемые сервисы моделей ## Поддерживаемые сервисы моделей
**Сервисы LLM** | Сервис | Тип |
|---------|---------------|
- OpenAI и совместимые сервисы | OpenAI и совместимые сервисы | Сервисы LLM |
- Anthropic | Anthropic | Сервисы LLM |
- Google Gemini | Google Gemini | Сервисы LLM |
- Moonshot AI | Moonshot AI | Сервисы LLM |
- Zhipu AI | Zhipu AI | Сервисы LLM |
- DeepSeek | DeepSeek | Сервисы LLM |
- Ollama (Самостоятельное размещение) | Ollama (Самостоятельное размещение) | Сервисы LLM |
- LM Studio (Самостоятельное размещение) | LM Studio (Самостоятельное размещение) | Сервисы LLM |
- [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_astrbot&referral_code=FV7DcGowN4hB5UuXKgpE74) | [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_astrbot&referral_code=FV7DcGowN4hB5UuXKgpE74) | Сервисы LLM |
- [302.AI](https://share.302.ai/rr1M3l) | [302.AI](https://share.302.ai/rr1M3l) | Сервисы LLM |
- [TokenPony](https://www.tokenpony.cn/3YPyf) | [TokenPony](https://www.tokenpony.cn/3YPyf) | Сервисы LLM |
- [SiliconFlow](https://docs.siliconflow.cn/cn/usercases/use-siliconcloud-in-astrbot) | [SiliconFlow](https://docs.siliconflow.cn/cn/usercases/use-siliconcloud-in-astrbot) | Сервисы LLM |
- [PPIO Cloud](https://ppio.com/user/register?invited_by=AIOONE) | [PPIO Cloud](https://ppio.com/user/register?invited_by=AIOONE) | Сервисы LLM |
- ModelScope | ModelScope | Сервисы LLM |
- OneAPI | OneAPI | Сервисы LLM |
| Dify | Платформы LLMOps |
**Платформы LLMOps** | Приложения Alibaba Cloud Bailian | Платформы LLMOps |
| Coze | Платформы LLMOps |
- Dify | OpenAI Whisper | Сервисы распознавания речи |
- Приложения Alibaba Cloud Bailian | SenseVoice | Сервисы распознавания речи |
- Coze | OpenAI TTS | Сервисы синтеза речи |
| Gemini TTS | Сервисы синтеза речи |
**Сервисы распознавания речи** | GPT-Sovits-Inference | Сервисы синтеза речи |
| GPT-Sovits | Сервисы синтеза речи |
- OpenAI Whisper | FishAudio | Сервисы синтеза речи |
- SenseVoice | Edge TTS | Сервисы синтеза речи |
| Alibaba Cloud Bailian TTS | Сервисы синтеза речи |
**Сервисы синтеза речи** | Azure TTS | Сервисы синтеза речи |
| Minimax TTS | Сервисы синтеза речи |
- OpenAI TTS | Volcano Engine TTS | Сервисы синтеза речи |
- Gemini TTS
- GPT-Sovits-Inference
- GPT-Sovits
- FishAudio
- Edge TTS
- Alibaba Cloud Bailian TTS
- Azure TTS
- Minimax TTS
- Volcano Engine TTS
## ❤️ Вклад в проект ## ❤️ Вклад в проект
+68 -66
View File
@@ -2,8 +2,8 @@
<div align="center"> <div align="center">
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README.md">简体中文</a> <a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_zh.md">简体中文</a>
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_en.md">English</a> <a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README.md">English</a>
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ja.md">日本語</a> <a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ja.md">日本語</a>
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_fr.md">Français</a> <a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_fr.md">Français</a>
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ru.md">Русский</a> <a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ru.md">Русский</a>
@@ -37,7 +37,7 @@
AstrBot 是一個開源的一站式 Agent 聊天機器人平台,可接入主流即時通訊軟體,為個人、開發者和團隊打造可靠、可擴展的對話式智慧基礎設施。無論是個人 AI 夥伴、智慧客服、自動化助手,還是企業知識庫,AstrBot 都能在您的即時通訊軟體平台的工作流程中快速構建生產可用的 AI 應用程式。 AstrBot 是一個開源的一站式 Agent 聊天機器人平台,可接入主流即時通訊軟體,為個人、開發者和團隊打造可靠、可擴展的對話式智慧基礎設施。無論是個人 AI 夥伴、智慧客服、自動化助手,還是企業知識庫,AstrBot 都能在您的即時通訊軟體平台的工作流程中快速構建生產可用的 AI 應用程式。
<img width="1776" height="1080" alt="image" src="https://github.com/user-attachments/assets/00782c4c-4437-4d97-aabc-605e3738da5c" /> ![screenshot_1 5x_postspark_2026-02-27_22-37-45](https://github.com/user-attachments/assets/f17cdb90-52d7-4773-be2e-ff64b566af6b)
## 主要功能 ## 主要功能
@@ -45,7 +45,7 @@ AstrBot 是一個開源的一站式 Agent 聊天機器人平台,可接入主
2. ✨ AI 大模型對話,多模態,Agent,MCP,Skills,知識庫,人格設定,自動壓縮對話。 2. ✨ AI 大模型對話,多模態,Agent,MCP,Skills,知識庫,人格設定,自動壓縮對話。
3. 🤖 支援接入 Dify、阿里雲百煉、Coze 等智慧體 (Agent) 平台。 3. 🤖 支援接入 Dify、阿里雲百煉、Coze 等智慧體 (Agent) 平台。
4. 🌐 多平台,支援 QQ、企業微信、飛書、釘釘、微信公眾號、Telegram、Slack 以及[更多](#支援的訊息平台)。 4. 🌐 多平台,支援 QQ、企業微信、飛書、釘釘、微信公眾號、Telegram、Slack 以及[更多](#支援的訊息平台)。
5. 📦 插件擴展,已有近 800 個插件可一鍵安裝。 5. 📦 插件擴展,已有 1000+ 個插件可一鍵安裝。
6. 🛡️ [Agent Sandbox](https://docs.astrbot.app/use/astrbot-agent-sandbox.html) 隔離化環境,安全地執行任何代碼、調用 Shell、會話級資源複用。 6. 🛡️ [Agent Sandbox](https://docs.astrbot.app/use/astrbot-agent-sandbox.html) 隔離化環境,安全地執行任何代碼、調用 Shell、會話級資源複用。
7. 💻 WebUI 支援。 7. 💻 WebUI 支援。
8. 🌈 Web ChatUI 支援,ChatUI 內置代理沙盒 (Agent Sandbox)、網頁搜尋等。 8. 🌈 Web ChatUI 支援,ChatUI 內置代理沙盒 (Agent Sandbox)、網頁搜尋等。
@@ -58,7 +58,7 @@ AstrBot 是一個開源的一站式 Agent 聊天機器人平台,可接入主
<th>💙 角色扮演 & 情感陪伴</th> <th>💙 角色扮演 & 情感陪伴</th>
<th>✨ 主動式 Agent</th> <th>✨ 主動式 Agent</th>
<th>🚀 通用 Agentic 能力</th> <th>🚀 通用 Agentic 能力</th>
<th>🧩 900+ 社區外掛程式</th> <th>🧩 1000+ 社區外掛程式</th>
</tr> </tr>
<tr> <tr>
<td align="center"><p align="center"><img width="984" height="1746" alt="99b587c5d35eea09d84f33e6cf6cfd4f" src="https://github.com/user-attachments/assets/89196061-3290-458d-b51f-afa178049f84" /></p></td> <td align="center"><p align="center"><img width="984" height="1746" alt="99b587c5d35eea09d84f33e6cf6cfd4f" src="https://github.com/user-attachments/assets/89196061-3290-458d-b51f-afa178049f84" /></p></td>
@@ -83,6 +83,16 @@ uv tool install astrbot
astrbot astrbot
``` ```
#### 桌面應用部署(Tauri
桌面應用倉庫 [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop)。
支援多系統架構,安裝包直接安裝,開箱即用,最適合新手和懶人的一鍵桌面部署方案,不推薦伺服器場景。
#### 啟動器一鍵部署(AstrBot Launcher
快速部署和多開方案,實現環境隔離,進入 [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) 倉庫,在 Releases 頁最新版本下找到對應的系統安裝包安裝即可。
#### 寶塔面板部署 #### 寶塔面板部署
AstrBot 與寶塔面板合作,已上架至寶塔面板。 AstrBot 與寶塔面板合作,已上架至寶塔面板。
@@ -97,6 +107,8 @@ AstrBot 已由 1Panel 官方上架至 1Panel 面板。
#### 在雨雲上部署 #### 在雨雲上部署
For Chinese users:
AstrBot 已由雨雲官方上架至雲端應用程式平台,可一鍵部署。 AstrBot 已由雨雲官方上架至雲端應用程式平台,可一鍵部署。
[![Deploy on RainYun](https://rainyun-apps.cn-nb1.rains3.com/materials/deploy-on-rainyun-en.svg)](https://app.rainyun.com/apps/rca/store/5994?ref=NjU1ODg0) [![Deploy on RainYun](https://rainyun-apps.cn-nb1.rains3.com/materials/deploy-on-rainyun-en.svg)](https://app.rainyun.com/apps/rca/store/5994?ref=NjU1ODg0)
@@ -146,71 +158,61 @@ paru -S astrbot-git
## 支援的訊息平台 ## 支援的訊息平台
**官方維護** 將 AstrBot 連接到你常用的聊天平台。
- QQ(官方平台 & OneBot | 平台 | 維護方 |
- Telegram |---------|---------------|
- 企微應用 & 企微智慧機器人 | QQ | 官方維護 |
- 微信客服 & 微信公眾號 | OneBot v11 協議實作 | 官方維護 |
- 飛書 | Telegram | 官方維護 |
- 釘釘 | 企微應用 & 企微智慧機器人 | 官方維護 |
- Slack | 微信客服 & 微信公眾號 | 官方維護 |
- Discord | 飛書 | 官方維護 |
- Satori | 釘釘 | 官方維護 |
- Misskey | Slack | 官方維護 |
- LINE | Discord | 官方維護 |
- Whatsapp(即將支援) | LINE | 官方維護 |
| Satori | 官方維護 |
| Misskey | 官方維護 |
**社群維護** | Whatsapp(即將支援) | 官方維護 |
| [Matrix](https://github.com/stevessr/astrbot_plugin_matrix_adapter) | 社群維護 |
- [Matrix](https://github.com/stevessr/astrbot_plugin_matrix_adapter) | [KOOK](https://github.com/wuyan1003/astrbot_plugin_kook_adapter) | 社群維護 |
- [KOOK](https://github.com/wuyan1003/astrbot_plugin_kook_adapter) | [VoceChat](https://github.com/HikariFroya/astrbot_plugin_vocechat) | 社群維護 |
- [VoceChat](https://github.com/HikariFroya/astrbot_plugin_vocechat)
## 支援的模型服務 ## 支援的模型服務
**大型模型服務** | 服務 | 類型 |
|---------|---------------|
- OpenAI 及相容服務 | OpenAI 及相容服務 | 大型模型服務 |
- Anthropic | Anthropic | 大型模型服務 |
- Google Gemini | Google Gemini | 大型模型服務 |
- Moonshot AI | Moonshot AI | 大型模型服務 |
- 智譜 AI | 智譜 AI | 大型模型服務 |
- DeepSeek | DeepSeek | 大型模型服務 |
- Ollama(本機部署) | Ollama(本機部署) | 大型模型服務 |
- LM Studio(本機部署) | LM Studio(本機部署) | 大型模型服務 |
- [優雲智算](https://www.compshare.cn/?ytag=GPU_YY-gh_astrbot&referral_code=FV7DcGowN4hB5UuXKgpE74) | [優雲智算](https://www.compshare.cn/?ytag=GPU_YY-gh_astrbot&referral_code=FV7DcGowN4hB5UuXKgpE74) | 大型模型服務 |
- [302.AI](https://share.302.ai/rr1M3l) | [302.AI](https://share.302.ai/rr1M3l) | 大型模型服務 |
- [小馬算力](https://www.tokenpony.cn/3YPyf) | [小馬算力](https://www.tokenpony.cn/3YPyf) | 大型模型服務 |
- [矽基流動](https://docs.siliconflow.cn/cn/usercases/use-siliconcloud-in-astrbot) | [矽基流動](https://docs.siliconflow.cn/cn/usercases/use-siliconcloud-in-astrbot) | 大型模型服務 |
- [PPIO 派歐雲](https://ppio.com/user/register?invited_by=AIOONE) | [PPIO 派歐雲](https://ppio.com/user/register?invited_by=AIOONE) | 大型模型服務 |
- ModelScope | ModelScope | 大型模型服務 |
- OneAPI | OneAPI | 大型模型服務 |
| Dify | LLMOps 平台 |
**LLMOps 平台** | 阿里雲百煉應用 | LLMOps 平台 |
| Coze | LLMOps 平台 |
- Dify | OpenAI Whisper | 語音轉文字服務 |
- 阿里雲百煉應用 | SenseVoice | 語音轉文字服務 |
- Coze | OpenAI TTS | 文字轉語音服務 |
| Gemini TTS | 文字轉語音服務 |
**語音轉文字服務** | GPT-Sovits-Inference | 文字轉語音服務 |
| GPT-Sovits | 文字轉語音服務 |
- OpenAI Whisper | FishAudio | 文字轉語音服務 |
- SenseVoice | Edge TTS | 文字轉語音服務 |
| 阿里雲百煉 TTS | 文字轉語音服務 |
**文字轉語音服務** | Azure TTS | 文字轉語音服務 |
| Minimax TTS | 文字轉語音服務 |
- OpenAI TTS | 火山引擎 TTS | 文字轉語音服務 |
- Gemini TTS
- GPT-Sovits-Inference
- GPT-Sovits
- FishAudio
- Edge TTS
- 阿里雲百煉 TTS
- Azure TTS
- Minimax TTS
- 火山引擎 TTS
## ❤️ 貢獻 ## ❤️ 貢獻
+252
View File
@@ -0,0 +1,252 @@
![AstrBot-Logo-Simplified](https://github.com/user-attachments/assets/ffd99b6b-3272-4682-beaa-6fe74250f7d9)
<div align="center">
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README.md">English</a>
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ja.md">日本語</a>
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_zh-TW.md">繁體中文</a>
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_fr.md">Français</a>
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ru.md">Русский</a>
<div>
<a href="https://trendshift.io/repositories/12875" target="_blank"><img src="https://trendshift.io/api/badge/repositories/12875" alt="Soulter%2FAstrBot | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
<a href="https://hellogithub.com/repository/AstrBotDevs/AstrBot" target="_blank"><img src="https://api.hellogithub.com/v1/widgets/recommend.svg?rid=d127d50cd5e54c5382328acc3bb25483&claim_uid=ZO9by7qCXgSd6Lp&t=2" alt="FeaturedHelloGitHub" style="width: 250px; height: 54px;" width="250" height="54" /></a>
</div>
<br>
<div>
<img src="https://img.shields.io/github/v/release/AstrBotDevs/AstrBot?color=76bad9" href="https://github.com/AstrBotDevs/AstrBot/releases/latest">
<img src="https://img.shields.io/badge/python-3.10+-blue.svg" alt="python">
<img src="https://deepwiki.com/badge.svg" href="https://deepwiki.com/AstrBotDevs/AstrBot">
<a href="https://zread.ai/AstrBotDevs/AstrBot" target="_blank"><img src="https://img.shields.io/badge/Ask_Zread-_.svg?style=flat&color=00b0aa&labelColor=000000&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTQuOTYxNTYgMS42MDAxSDIuMjQxNTZDMS44ODgxIDEuNjAwMSAxLjYwMTU2IDEuODg2NjQgMS42MDE1NiAyLjI0MDFWNC45NjAxQzEuNjAxNTYgNS4zMTM1NiAxLjg4ODEgNS42MDAxIDIuMjQxNTYgNS42MDAxSDQuOTYxNTZDNS4zMTUwMiA1LjYwMDEgNS42MDE1NiA1LjMxMzU2IDUuNjAxNTYgNC45NjAxVjIuMjQwMUM1LjYwMTU2IDEuODg2NjQgNS4zMTUwMiAxLjYwMDEgNC45NjE1NiAxLjYwMDFaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00Ljk2MTU2IDEwLjM5OTlIMi4yNDE1NkMxLjg4ODEgMTAuMzk5OSAxLjYwMTU2IDEwLjY4NjQgMS42MDE1NiAxMS4wMzk5VjEzLjc1OTlDMS42MDE1NiAxNC4xMTM0IDEuODg4MSAxNC4zOTk5IDIuMjQxNTYgMTQuMzk5OUg0Ljk2MTU2QzUuMzE1MDIgMTQuMzk5OSA1LjYwMTU2IDE0LjExMzQgNS42MDE1NiAxMy43NTk5VjExLjAzOTlDNS42MDE1NiAxMC42ODY0IDUuMzE1MDIgMTAuMzk5OSA0Ljk2MTU2IDEwLjM5OTlaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik0xMy43NTg0IDEuNjAwMUgxMS4wMzg0QzEwLjY4NSAxLjYwMDEgMTAuMzk4NCAxLjg4NjY0IDEwLjM5ODQgMi4yNDAxVjQuOTYwMUMxMC4zOTg0IDUuMzEzNTYgMTAuNjg1IDUuNjAwMSAxMS4wMzg0IDUuNjAwMUgxMy43NTg0QzE0LjExMTkgNS42MDAxIDE0LjM5ODQgNS4zMTM1NiAxNC4zOTg0IDQuOTYwMVYyLjI0MDFDMTQuMzk4NCAxLjg4NjY0IDE0LjExMTkgMS42MDAxIDEzLjc1ODQgMS42MDAxWiIgZmlsbD0iI2ZmZiIvPgo8cGF0aCBkPSJNNCAxMkwxMiA0TDQgMTJaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00IDEyTDEyIDQiIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLXdpZHRoPSIxLjUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPgo8L3N2Zz4K&logoColor=ffffff" alt="zread"/></a>
<a href="https://hub.docker.com/r/soulter/astrbot"><img alt="Docker pull" src="https://img.shields.io/docker/pulls/soulter/astrbot.svg?color=76bad9"/></a>
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.soulter.top%2Fastrbot%2Fplugin-num&query=%24.result&suffix=%E4%B8%AA&label=%E6%8F%92%E4%BB%B6%E5%B8%82%E5%9C%BA&cacheSeconds=3600">
<img src="https://gitcode.com/Soulter/AstrBot/star/badge.svg" href="https://gitcode.com/Soulter/AstrBot">
</div>
<br>
<a href="https://astrbot.app/">主页</a>
<a href="https://astrbot.app/">文档</a>
<a href="https://blog.astrbot.app/">博客</a>
<a href="https://astrbot.featurebase.app/roadmap">路线图</a>
<a href="https://github.com/AstrBotDevs/AstrBot/issues">问题提交</a>
</div>
AstrBot 是一个开源的一站式 Agentic 个人和群聊助手,可在 QQ、Telegram、企业微信、飞书、钉钉、Slack、等数十款主流即时通讯软件上部署,此外还内置类似 OpenWebUI 的轻量化 ChatUI,为个人、开发者和团队打造可靠、可扩展的对话式智能基础设施。无论是个人 AI 伙伴、智能客服、自动化助手,还是企业知识库,AstrBot 都能在你的即时通讯软件平台的工作流中快速构建 AI 应用。
![landingpage](https://github.com/user-attachments/assets/45fc5699-cddf-4e21-af35-13040706f6c0)
## 主要功能
1. 💯 免费 & 开源。
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)支持。
<br>
<table align="center">
<tr align="center">
<th>💙 角色扮演 & 情感陪伴</th>
<th>✨ 主动式 Agent</th>
<th>🚀 通用 Agentic 能力</th>
<th>🧩 1000+ 社区插件</th>
</tr>
<tr>
<td align="center"><p align="center"><img width="984" height="1746" alt="99b587c5d35eea09d84f33e6cf6cfd4f" src="https://github.com/user-attachments/assets/89196061-3290-458d-b51f-afa178049f84" /></p></td>
<td align="center"><p align="center"><img width="976" height="1612" alt="c449acd838c41d0915cc08a3824025b1" src="https://github.com/user-attachments/assets/f75368b4-e022-41dc-a9e0-131c3e73e32e" /></p></td>
<td align="center"><p align="center"><img width="974" height="1732" alt="image" src="https://github.com/user-attachments/assets/e22a3968-87d7-4708-a7cd-e7f198c7c32e" /></p></td>
<td align="center"><p align="center"><img width="976" height="1734" alt="image" src="https://github.com/user-attachments/assets/0952b395-6b4a-432a-8a50-c294b7f89750" /></p></td>
</tr>
</table>
## 快速开始
### 一键部署
```bash
uv tool install astrbot
astrbot
```
> 需要安装 [uv](https://docs.astral.sh/uv/)。
### Docker 部署
推荐使用 Docker / Docker Compose 方式部署 AstrBot。
请参阅官方文档 [使用 Docker 部署 AstrBot](https://astrbot.app/deploy/astrbot/docker.html#%E4%BD%BF%E7%94%A8-docker-%E9%83%A8%E7%BD%B2-astrbot) 。
### 在 雨云 上部署
AstrBot 已由雨云官方上架至云应用平台,可一键部署。
[![Deploy on RainYun](https://rainyun-apps.cn-nb1.rains3.com/materials/deploy-on-rainyun-en.svg)](https://app.rainyun.com/apps/rca/store/5994?ref=NjU1ODg0)
### 桌面客户端(Tauri
桌面应用仓库 [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop)。
支持多系统架构,安装包直接安装,开箱即用,最适合新手和懒人的一键桌面部署方案,不推荐服务器场景。
### 启动器一键部署(AstrBot Launcher
快速部署和多开方案,实现环境隔离,进入 [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) 仓库,在 Releases 页最新版本下找到对应的系统安装包安装即可。
### 在 Replit 上部署
社区贡献的部署方式。
[![Run on Repl.it](https://repl.it/badge/github/AstrBotDevs/AstrBot)](https://repl.it/github/AstrBotDevs/AstrBot)
### AUR
```bash
yay -S astrbot-git
```
**更多部署方式**[宝塔面板](https://astrbot.app/deploy/astrbot/btpanel.html) | [1Panel](https://astrbot.app/deploy/astrbot/1panel.html) | [CasaOS](https://astrbot.app/deploy/astrbot/casaos.html) | [手动部署](https://astrbot.app/deploy/astrbot/cli.html)
## 支持的消息平台
将 AstrBot 连接到你常用的聊天平台。
| 平台 | 维护方 |
|---------|---------------|
| **QQ** | 官方维护 |
| **OneBot v11** | 官方维护 |
| **Telegram** | 官方维护 |
| **企微应用 & 企微智能机器人** | 官方维护 |
| **微信客服 & 微信公众号** | 官方维护 |
| **飞书** | 官方维护 |
| **钉钉** | 官方维护 |
| **Slack** | 官方维护 |
| **Discord** | 官方维护 |
| **LINE** | 官方维护 |
| **Satori** | 官方维护 |
| **Misskey** | 官方维护 |
| **Whatsapp (将支持)** | 官方维护 |
| [**Matrix**](https://github.com/stevessr/astrbot_plugin_matrix_adapter) | 社区维护 |
| [**KOOK**](https://github.com/wuyan1003/astrbot_plugin_kook_adapter) | 社区维护 |
| [**VoceChat**](https://github.com/HikariFroya/astrbot_plugin_vocechat) | 社区维护 |
## 支持的模型提供商
| 提供商 | 类型 |
|---------|---------------|
| 自定义 | 任何 OpenAI API 兼容的服务 |
| OpenAI | LLM |
| Anthropic | LLM |
| Google Gemini | LLM |
| Moonshot AI | LLM |
| 智谱 AI | LLM |
| DeepSeek | LLM |
| Ollama (本地部署) | LLM |
| LM Studio (本地部署) | LLM |
| [AIHubMix](https://aihubmix.com/?aff=4bfH) | LLM (API 网关, 支持所有模型) |
| [优云智算](https://www.compshare.cn/?ytag=GPU_YY-gh_astrbot&referral_code=FV7DcGowN4hB5UuXKgpE74) | LLM (API 网关, 支持所有模型) |
| [硅基流动](https://docs.siliconflow.cn/cn/usercases/use-siliconcloud-in-astrbot) | LLM (API 网关, 支持所有模型) |
| [PPIO 派欧云](https://ppio.com/user/register?invited_by=AIOONE) | LLM (API 网关, 支持所有模型) |
| [302.AI](https://share.302.ai/rr1M3l) | LLM (API 网关, 支持所有模型)|
| [小马算力](https://www.tokenpony.cn/3YPyf) | LLM (API 网关, 支持所有模型)|
| ModelScope | LLM |
| OneAPI | LLM |
| Dify | LLMOps 平台 |
| 阿里云百炼应用 | LLMOps 平台 |
| Coze | LLMOps 平台 |
| OpenAI Whisper | 语音转文本 |
| SenseVoice | 语音转文本 |
| OpenAI TTS | 文本转语音 |
| Gemini TTS | 文本转语音 |
| GPT-Sovits-Inference | 文本转语音 |
| GPT-Sovits | 文本转语音 |
| FishAudio | 文本转语音 |
| Edge TTS | 文本转语音 |
| 阿里云百炼 TTS | 文本转语音 |
| Azure TTS | 文本转语音 |
| Minimax TTS | 文本转语音 |
| 火山引擎 TTS | 文本转语音 |
## ❤️ 贡献
欢迎任何 Issues/Pull Requests!只需要将你的更改提交到此项目 :)
### 如何贡献
你可以通过查看问题或帮助审核 PR(拉取请求)来贡献。任何问题或 PR 都欢迎参与,以促进社区贡献。当然,这些只是建议,你可以以任何方式进行贡献。对于新功能的添加,请先通过 Issue 讨论。
### 开发环境
AstrBot 使用 `ruff` 进行代码格式化和检查。
```bash
git clone https://github.com/AstrBotDevs/AstrBot
pip install pre-commit
pre-commit install
```
## 🌍 社区
### QQ 群组
- 1 群:322154837
- 3 群:630166526
- 5 群:822130018
- 6 群:753075035
- 7 群:743746109
- 8 群:1030353265
- 开发者群:975206796
### Discord 频道
- [Discord](https://discord.gg/hAVk6tgV36)
## ❤️ Special Thanks
特别感谢所有 Contributors 和插件开发者对 AstrBot 的贡献 ❤️
<a href="https://github.com/AstrBotDevs/AstrBot/graphs/contributors">
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot&max=200&columns=14" />
</a>
此外,本项目的诞生离不开以下开源项目的帮助:
- [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]
> 如果本项目对您的生活 / 工作产生了帮助,或者您关注本项目的未来发展,请给项目 Star,这是我们维护这个开源项目的动力 <3
<div align="center">
[![Star History Chart](https://api.star-history.com/svg?repos=astrbotdevs/astrbot&type=Date)](https://star-history.com/#astrbotdevs/astrbot&Date)
</div>
<div align="center">
_陪伴与能力从来不应该是对立面。我们希望创造的是一个既能理解情绪、给予陪伴,也能可靠完成工作的机器人。_
_私は、高性能ですから!_
<img src="https://files.astrbot.app/watashiwa-koseino-desukara.gif" width="100"/>
</div>
+4
View File
@@ -25,6 +25,8 @@ from astrbot.core.star.register import (
) )
from astrbot.core.star.register import register_on_platform_loaded as on_platform_loaded 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_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_using_llm_tool as on_using_llm_tool
from astrbot.core.star.register import ( from astrbot.core.star.register import (
register_on_waiting_llm_request as on_waiting_llm_request, register_on_waiting_llm_request as on_waiting_llm_request,
@@ -54,6 +56,8 @@ __all__ = [
"on_llm_request", "on_llm_request",
"on_llm_response", "on_llm_response",
"on_plugin_error", "on_plugin_error",
"on_plugin_loaded",
"on_plugin_unloaded",
"on_platform_loaded", "on_platform_loaded",
"on_waiting_llm_request", "on_waiting_llm_request",
"permission_type", "permission_type",
@@ -102,6 +102,30 @@ class ConversationCommands:
message.set_result(MessageEventResult().message(ret)) message.set_result(MessageEventResult().message(ret))
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: async def his(self, message: AstrMessageEvent, page: int = 1) -> None:
"""查看对话记录""" """查看对话记录"""
if not self.context.get_using_provider(message.unified_msg_origin): if not self.context.get_using_provider(message.unified_msg_origin):
@@ -182,16 +206,33 @@ class ConversationCommands:
_titles[conv.cid] = title _titles[conv.cid] = title
"""遍历分页后的对话生成列表显示""" """遍历分页后的对话生成列表显示"""
provider_settings = cfg.get("provider_settings", {})
platform_name = message.get_platform_name()
for conv in conversations_paged: for conv in conversations_paged:
persona_id = conv.persona_id (
if not persona_id or persona_id == "[%None]": persona_id,
persona = await self.context.persona_manager.get_default_persona_v3( _,
umo=message.unified_msg_origin, force_applied_persona_id,
) _,
persona_id = persona["name"] ) = 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, "新对话") title = _titles.get(conv.cid, "新对话")
parts.append( 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 global_index += 1
@@ -1,7 +1,7 @@
import builtins import builtins
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from astrbot.api import sp, star from astrbot.api import star
from astrbot.api.event import AstrMessageEvent, MessageEventResult from astrbot.api.event import AstrMessageEvent, MessageEventResult
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -59,12 +59,7 @@ class PersonaCommands:
default_persona = await self.context.persona_manager.get_default_persona_v3( default_persona = await self.context.persona_manager.get_default_persona_v3(
umo=umo, umo=umo,
) )
force_applied_persona_id = None
force_applied_persona_id = (
await sp.get_async(
scope="umo", scope_id=umo, key="session_service_config", default={}
)
).get("persona_id")
curr_cid_title = "" curr_cid_title = ""
if cid: if cid:
@@ -80,10 +75,27 @@ class PersonaCommands:
), ),
) )
return return
if not conv.persona_id and conv.persona_id != "[%None]":
curr_persona_name = default_persona["name"] provider_settings = self.context.get_config(umo=umo).get(
else: "provider_settings",
curr_persona_name = conv.persona_id {},
)
(
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: if force_applied_persona_id:
curr_persona_name = f"{curr_persona_name} (自定义规则)" curr_persona_name = f"{curr_persona_name} (自定义规则)"
@@ -132,6 +132,11 @@ class Main(star.Star):
"""重置 LLM 会话""" """重置 LLM 会话"""
await self.conversation_c.reset(message) 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.permission_type(filter.PermissionType.ADMIN)
@filter.command("model") @filter.command("model")
async def model_ls( async def model_ls(
+1 -1
View File
@@ -1 +1 @@
__version__ = "4.18.1" __version__ = "4.18.3"
+53 -12
View File
@@ -4,19 +4,60 @@ from ..message import Message
class ContextTruncator: class ContextTruncator:
"""Context truncator.""" """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]: def fix_messages(self, messages: list[Message]) -> list[Message]:
fixed_messages = [] """修复消息列表,确保 tool call 和 tool response 的配对关系有效。
for message in messages:
if message.role == "tool": 此方法确保:
# tool block 前面必须要有 user 和 assistant block 1. 每个 `tool` 消息前面都有一个包含 tool_calls 的 `assistant` 消息
if len(fixed_messages) < 2: 2. 每个包含 tool_calls 的 `assistant` 消息后面都有对应的 `tool` 响应
# 这种情况可能是上下文被截断导致的
# 我们直接将之前的上下文都清空 这是 OpenAI Chat Completions API 规范的要求(Gemini 对此执行严格检查)。
fixed_messages = [] """
else: if not messages:
fixed_messages.append(message) return messages
else:
fixed_messages.append(message) 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 return fixed_messages
def truncate_by_turns( def truncate_by_turns(
+5
View File
@@ -44,6 +44,11 @@ class HandoffTool(FunctionTool, Generic[TContext]):
"type": "string", "type": "string",
"description": "The input to be handed off to another agent. This should be a clear and concise request or task.", "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": { "background_task": {
"type": "boolean", "type": "boolean",
"description": ( "description": (
@@ -1,9 +1,10 @@
import asyncio
import copy import copy
import sys import sys
import time import time
import traceback import traceback
import typing as T import typing as T
from dataclasses import dataclass from dataclasses import dataclass, field
from mcp.types import ( from mcp.types import (
BlobResourceContents, BlobResourceContents,
@@ -68,6 +69,14 @@ class _HandleFunctionToolsResult:
return cls(kind="cached_image", cached_image=image) 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]): class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
@override @override
async def reset( async def reset(
@@ -137,6 +146,10 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
self.tool_executor = tool_executor self.tool_executor = tool_executor
self.agent_hooks = agent_hooks self.agent_hooks = agent_hooks
self.run_context = run_context 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 # These two are used for tool schema mode handling
# We now have two modes: # We now have two modes:
@@ -275,6 +288,55 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
roles.append(message.role) roles.append(message.role)
logger.debug(f"{tag} RunCtx.messages -> [{len(roles)}] {','.join(roles)}") 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 @override
async def step(self): async def step(self):
"""Process a single step of the agent. """Process a single step of the agent.
@@ -328,6 +390,14 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
), ),
), ),
) )
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 continue
llm_resp_result = llm_response llm_resp_result = llm_response
@@ -339,6 +409,49 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
break # got final response break # got final response
if not llm_resp_result: 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 return
# 处理 LLM 响应 # 处理 LLM 响应
@@ -349,6 +462,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
self.final_llm_resp = llm_resp self.final_llm_resp = llm_resp
self.stats.end_time = time.time() self.stats.end_time = time.time()
self._transition_state(AgentState.ERROR) self._transition_state(AgentState.ERROR)
self._resolve_unconsumed_follow_ups()
yield AgentResponse( yield AgentResponse(
type="err", type="err",
data=AgentResponseData( data=AgentResponseData(
@@ -387,6 +501,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
await self.agent_hooks.on_agent_done(self.run_context, llm_resp) await self.agent_hooks.on_agent_done(self.run_context, llm_resp)
except Exception as e: except Exception as e:
logger.error(f"Error in on_agent_done hook: {e}", exc_info=True) logger.error(f"Error in on_agent_done hook: {e}", exc_info=True)
self._resolve_unconsumed_follow_ups()
# 返回 LLM 结果 # 返回 LLM 结果
if llm_resp.result_chain: if llm_resp.result_chain:
@@ -531,6 +646,15 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
tool_call_result_blocks: list[ToolCallMessageSegment] = [] tool_call_result_blocks: list[ToolCallMessageSegment] = []
logger.info(f"Agent 使用工具: {llm_response.tools_call_name}") 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( for func_tool_name, func_tool_args, func_tool_id in zip(
llm_response.tools_call_name, llm_response.tools_call_name,
@@ -570,12 +694,9 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
if not func_tool: if not func_tool:
logger.warning(f"未找到指定的工具: {func_tool_name},将跳过。") logger.warning(f"未找到指定的工具: {func_tool_name},将跳过。")
tool_call_result_blocks.append( _append_tool_call_result(
ToolCallMessageSegment( func_tool_id,
role="tool", f"error: Tool {func_tool_name} not found.",
tool_call_id=func_tool_id,
content=f"error: Tool {func_tool_name} not found.",
),
) )
continue continue
@@ -628,12 +749,9 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
res = resp res = resp
_final_resp = resp _final_resp = resp
if isinstance(res.content[0], TextContent): if isinstance(res.content[0], TextContent):
tool_call_result_blocks.append( _append_tool_call_result(
ToolCallMessageSegment( func_tool_id,
role="tool", res.content[0].text,
tool_call_id=func_tool_id,
content=res.content[0].text,
),
) )
elif isinstance(res.content[0], ImageContent): elif isinstance(res.content[0], ImageContent):
# Cache the image instead of sending directly # Cache the image instead of sending directly
@@ -644,15 +762,12 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
index=0, index=0,
mime_type=res.content[0].mimeType or "image/png", mime_type=res.content[0].mimeType or "image/png",
) )
tool_call_result_blocks.append( _append_tool_call_result(
ToolCallMessageSegment( func_tool_id,
role="tool", (
tool_call_id=func_tool_id, f"Image returned and cached at path='{cached_img.file_path}'. "
content=( f"Review the image below. Use send_message_to_user to send it to the user if satisfied, "
f"Image returned and cached at path='{cached_img.file_path}'. " f"with type='image' and 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 image info for LLM visibility (will be handled in step()) # Yield image info for LLM visibility (will be handled in step())
@@ -662,12 +777,9 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
elif isinstance(res.content[0], EmbeddedResource): elif isinstance(res.content[0], EmbeddedResource):
resource = res.content[0].resource resource = res.content[0].resource
if isinstance(resource, TextResourceContents): if isinstance(resource, TextResourceContents):
tool_call_result_blocks.append( _append_tool_call_result(
ToolCallMessageSegment( func_tool_id,
role="tool", resource.text,
tool_call_id=func_tool_id,
content=resource.text,
),
) )
elif ( elif (
isinstance(resource, BlobResourceContents) isinstance(resource, BlobResourceContents)
@@ -682,15 +794,12 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
index=0, index=0,
mime_type=resource.mimeType, mime_type=resource.mimeType,
) )
tool_call_result_blocks.append( _append_tool_call_result(
ToolCallMessageSegment( func_tool_id,
role="tool", (
tool_call_id=func_tool_id, f"Image returned and cached at path='{cached_img.file_path}'. "
content=( f"Review the image below. Use send_message_to_user to send it to the user if satisfied, "
f"Image returned and cached at path='{cached_img.file_path}'. " f"with type='image' and 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 image info for LLM visibility # Yield image info for LLM visibility
@@ -698,12 +807,9 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
cached_img cached_img
) )
else: else:
tool_call_result_blocks.append( _append_tool_call_result(
ToolCallMessageSegment( func_tool_id,
role="tool", "The tool has returned a data type that is not supported.",
tool_call_id=func_tool_id,
content="The tool has returned a data type that is not supported.",
),
) )
elif resp is None: elif resp is None:
@@ -715,24 +821,18 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
) )
self._transition_state(AgentState.DONE) self._transition_state(AgentState.DONE)
self.stats.end_time = time.time() self.stats.end_time = time.time()
tool_call_result_blocks.append( _append_tool_call_result(
ToolCallMessageSegment( func_tool_id,
role="tool", "The tool has no return value, or has sent the result directly to the user.",
tool_call_id=func_tool_id,
content="The tool has no return value, or has sent the result directly to the user.",
),
) )
else: else:
# 不应该出现其他类型 # 不应该出现其他类型
logger.warning( logger.warning(
f"Tool 返回了不支持的类型: {type(resp)}", f"Tool 返回了不支持的类型: {type(resp)}",
) )
tool_call_result_blocks.append( _append_tool_call_result(
ToolCallMessageSegment( func_tool_id,
role="tool", "*The tool has returned an unsupported type. Please tell the user to check the definition and implementation of this 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.*",
),
) )
try: try:
@@ -746,12 +846,9 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
logger.error(f"Error in on_tool_end hook: {e}", exc_info=True) logger.error(f"Error in on_tool_end hook: {e}", exc_info=True)
except Exception as e: except Exception as e:
logger.warning(traceback.format_exc()) logger.warning(traceback.format_exc())
tool_call_result_blocks.append( _append_tool_call_result(
ToolCallMessageSegment( func_tool_id,
role="tool", f"error: {e!s}",
tool_call_id=func_tool_id,
content=f"error: {e!s}",
),
) )
# yield the last tool call result # yield the last tool call result
@@ -848,5 +945,11 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
"""检查 Agent 是否已完成工作""" """检查 Agent 是否已完成工作"""
return self._state in (AgentState.DONE, AgentState.ERROR) 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: def get_final_llm_resp(self) -> LLMResponse | None:
return self.final_llm_resp return self.final_llm_resp
+135 -17
View File
@@ -20,15 +20,81 @@ from astrbot.core.provider.provider import TTSProvider
AgentRunner = ToolLoopAgentRunner[AstrAgentContext] 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( async def run_agent(
agent_runner: AgentRunner, agent_runner: AgentRunner,
max_step: int = 30, max_step: int = 30,
show_tool_use: bool = True, show_tool_use: bool = True,
show_tool_call_result: bool = False,
stream_to_general: bool = False, stream_to_general: bool = False,
show_reasoning: bool = False, show_reasoning: bool = False,
) -> AsyncGenerator[MessageChain | None, None]: ) -> AsyncGenerator[MessageChain | None, None]:
step_idx = 0 step_idx = 0
astr_event = agent_runner.run_context.context.event astr_event = agent_runner.run_context.context.event
tool_name_by_call_id: dict[str, str] = {}
while step_idx < max_step + 1: while step_idx < max_step + 1:
step_idx += 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: try:
async for resp in agent_runner.step(): 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 return
if _should_stop_agent(astr_event):
continue
if resp.type == "tool_call_result": if resp.type == "tool_call_result":
msg_chain = resp.data["chain"] msg_chain = resp.data["chain"]
@@ -68,6 +152,13 @@ async def run_agent(
continue continue
if astr_event.get_platform_id() == "webchat": if astr_event.get_platform_id() == "webchat":
await astr_event.send(msg_chain) 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 continue
elif resp.type == "tool_call": elif resp.type == "tool_call":
@@ -75,25 +166,22 @@ async def run_agent(
# 用来标记流式响应需要分节 # 用来标记流式响应需要分节
yield MessageChain(chain=[], type="break") yield MessageChain(chain=[], type="break")
tool_info = None tool_info = _extract_chain_json_data(resp.data["chain"])
astr_event.trace.record(
if resp.data["chain"].chain: "agent_tool_call",
json_comp = resp.data["chain"].chain[0] tool_name=tool_info if tool_info else "unknown",
if isinstance(json_comp, Json): )
tool_info = json_comp.data _record_tool_call_name(tool_info, tool_name_by_call_id)
astr_event.trace.record(
"agent_tool_call",
tool_name=tool_info if tool_info else "unknown",
)
if astr_event.get_platform_name() == "webchat": if astr_event.get_platform_name() == "webchat":
await astr_event.send(resp.data["chain"]) await astr_event.send(resp.data["chain"])
elif show_tool_use: elif show_tool_use:
if tool_info: if show_tool_call_result and isinstance(tool_info, dict):
m = f"🔨 调用工具: {tool_info.get('name', 'unknown')}" # Delay tool status notification until tool_call_result.
else: continue
m = "🔨 调用工具..." chain = MessageChain(type="tool_call").message(
chain = MessageChain(type="tool_call").message(m) _build_tool_call_status_message(tool_info)
)
await astr_event.send(chain) await astr_event.send(chain)
continue continue
@@ -120,6 +208,12 @@ async def run_agent(
# display the reasoning content only when configured # display the reasoning content only when configured
continue continue
yield resp.data["chain"] # MessageChain 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(): if agent_runner.done():
# send agent stats to webchat # send agent stats to webchat
if astr_event.get_platform_name() == "webchat": if astr_event.get_platform_name() == "webchat":
@@ -133,6 +227,12 @@ async def run_agent(
break break
except Exception as e: 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()) logger.error(traceback.format_exc())
err_msg = f"\n\nAstrBot 请求失败。\n错误类型: {type(e).__name__}\n错误信息: {e!s}\n\n请在平台日志查看和分享错误详情。\n" err_msg = f"\n\nAstrBot 请求失败。\n错误类型: {type(e).__name__}\n错误信息: {e!s}\n\n请在平台日志查看和分享错误详情。\n"
@@ -155,11 +255,20 @@ async def run_agent(
return 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( async def run_live_agent(
agent_runner: AgentRunner, agent_runner: AgentRunner,
tts_provider: TTSProvider | None = None, tts_provider: TTSProvider | None = None,
max_step: int = 30, max_step: int = 30,
show_tool_use: bool = True, show_tool_use: bool = True,
show_tool_call_result: bool = False,
show_reasoning: bool = False, show_reasoning: bool = False,
) -> AsyncGenerator[MessageChain | None, None]: ) -> AsyncGenerator[MessageChain | None, None]:
"""Live Mode 的 Agent 运行器,支持流式 TTS """Live Mode 的 Agent 运行器,支持流式 TTS
@@ -169,6 +278,7 @@ async def run_live_agent(
tts_provider: TTS Provider 实例 tts_provider: TTS Provider 实例
max_step: 最大步数 max_step: 最大步数
show_tool_use: 是否显示工具使用 show_tool_use: 是否显示工具使用
show_tool_call_result: 是否显示工具返回结果
show_reasoning: 是否显示推理过程 show_reasoning: 是否显示推理过程
Yields: Yields:
@@ -180,6 +290,7 @@ async def run_live_agent(
agent_runner, agent_runner,
max_step=max_step, max_step=max_step,
show_tool_use=show_tool_use, show_tool_use=show_tool_use,
show_tool_call_result=show_tool_call_result,
stream_to_general=False, stream_to_general=False,
show_reasoning=show_reasoning, show_reasoning=show_reasoning,
): ):
@@ -208,7 +319,12 @@ async def run_live_agent(
# 1. 启动 Agent Feeder 任务:负责运行 Agent 并将文本分句喂给 text_queue # 1. 启动 Agent Feeder 任务:负责运行 Agent 并将文本分句喂给 text_queue
feeder_task = asyncio.create_task( feeder_task = asyncio.create_task(
_run_agent_feeder( _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,6 +410,7 @@ async def _run_agent_feeder(
text_queue: asyncio.Queue, text_queue: asyncio.Queue,
max_step: int, max_step: int,
show_tool_use: bool, show_tool_use: bool,
show_tool_call_result: bool,
show_reasoning: bool, show_reasoning: bool,
) -> None: ) -> None:
"""运行 Agent 并将文本输出分句放入队列""" """运行 Agent 并将文本输出分句放入队列"""
@@ -303,6 +420,7 @@ async def _run_agent_feeder(
agent_runner, agent_runner,
max_step=max_step, max_step=max_step,
show_tool_use=show_tool_use, show_tool_use=show_tool_use,
show_tool_call_result=show_tool_call_result,
stream_to_general=False, stream_to_general=False,
show_reasoning=show_reasoning, show_reasoning=show_reasoning,
): ):
+76 -14
View File
@@ -17,6 +17,12 @@ from astrbot.core.agent.tool_executor import BaseFunctionToolExecutor
from astrbot.core.astr_agent_context import AstrAgentContext from astrbot.core.astr_agent_context import AstrAgentContext
from astrbot.core.astr_main_agent_resources import ( from astrbot.core.astr_main_agent_resources import (
BACKGROUND_TASK_RESULT_WOKE_SYSTEM_PROMPT, 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, SEND_MESSAGE_TO_USER_TOOL,
) )
from astrbot.core.cron.events import CronMessageEvent from astrbot.core.cron.events import CronMessageEvent
@@ -91,6 +97,65 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
yield r yield r
return 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 @classmethod
async def _execute_handoff( async def _execute_handoff(
cls, cls,
@@ -99,20 +164,10 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
**tool_args, **tool_args,
): ):
input_ = tool_args.get("input") input_ = tool_args.get("input")
image_urls = tool_args.get("image_urls")
# make toolset for the agent # Build handoff toolset from registered tools plus runtime computer tools.
tools = tool.agent.tools toolset = cls._build_handoff_toolset(run_context, 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
ctx = run_context.context.context ctx = run_context.context.context
event = run_context.context.event event = run_context.context.event
@@ -143,11 +198,13 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
event=event, event=event,
chat_provider_id=prov_id, chat_provider_id=prov_id,
prompt=input_, prompt=input_,
image_urls=image_urls,
system_prompt=tool.agent.instructions, system_prompt=tool.agent.instructions,
tools=toolset, tools=toolset,
contexts=contexts, contexts=contexts,
max_steps=30, max_steps=30,
run_hooks=tool.agent.run_hooks, run_hooks=tool.agent.run_hooks,
stream=ctx.get_config().get("provider_settings", {}).get("stream", False),
) )
yield mcp.types.CallToolResult( yield mcp.types.CallToolResult(
content=[mcp.types.TextContent(type="text", text=llm_resp.completion_text)] content=[mcp.types.TextContent(type="text", text=llm_resp.completion_text)]
@@ -314,7 +371,12 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
message_type=session.message_type, message_type=session.message_type,
) )
cron_event.role = event.role 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() req = ProviderRequest()
conv = await _get_session_conv(event=cron_event, plugin_context=ctx) conv = await _get_session_conv(event=cron_event, plugin_context=ctx)
+13 -36
View File
@@ -1,7 +1,6 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import builtins
import copy import copy
import datetime import datetime
import json import json
@@ -10,7 +9,6 @@ import zoneinfo
from collections.abc import Coroutine from collections.abc import Coroutine
from dataclasses import dataclass, field from dataclasses import dataclass, field
from astrbot.api import sp
from astrbot.core import logger from astrbot.core import logger
from astrbot.core.agent.handoff import HandoffTool from astrbot.core.agent.handoff import HandoffTool
from astrbot.core.agent.mcp_client import MCPTool from astrbot.core.agent.mcp_client import MCPTool
@@ -275,47 +273,26 @@ async def _ensure_persona_and_skills(
if not req.conversation: if not req.conversation:
return return
# get persona ID (
persona_id,
# 1. from session service config - highest priority persona,
persona_id = ( _,
await sp.get_async( use_webchat_special_default,
scope="umo", ) = await plugin_context.persona_manager.resolve_selected_persona(
scope_id=event.unified_msg_origin, umo=event.unified_msg_origin,
key="session_service_config", conversation_persona_id=req.conversation.persona_id,
default={}, platform_name=event.get_platform_name(),
) provider_settings=cfg,
).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,
) )
if persona: if persona:
# Inject persona system prompt # Inject persona system prompt
if prompt := persona["prompt"]: if prompt := persona["prompt"]:
req.system_prompt += f"\n# Persona Instructions\n\n{prompt}\n" req.system_prompt += f"\n# Persona Instructions\n\n{prompt}\n"
if begin_dialogs := copy.deepcopy(persona.get("_begin_dialogs_processed")): if begin_dialogs := copy.deepcopy(persona.get("_begin_dialogs_processed")):
req.contexts[:0] = begin_dialogs req.contexts[:0] = begin_dialogs
else: elif use_webchat_special_default:
# special handling for webchat persona req.system_prompt += CHATUI_SPECIAL_DEFAULT_PERSONA_PROMPT
if event.get_platform_name() == "webchat" and persona_id != "[%None]":
persona_id = "_chatui_default_"
req.system_prompt += CHATUI_SPECIAL_DEFAULT_PERSONA_PROMPT
# Inject skills prompt # Inject skills prompt
runtime = cfg.get("computer_use_runtime", "local") runtime = cfg.get("computer_use_runtime", "local")
+5
View File
@@ -11,6 +11,7 @@ from astrbot.core.message.components import File
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
from ..computer_client import get_booter from ..computer_client import get_booter
from .permissions import check_admin_permission
# @dataclass # @dataclass
# class CreateFileTool(FunctionTool): # class CreateFileTool(FunctionTool):
@@ -102,6 +103,8 @@ class FileUploadTool(FunctionTool):
context: ContextWrapper[AstrAgentContext], context: ContextWrapper[AstrAgentContext],
local_path: str, local_path: str,
) -> str | None: ) -> str | None:
if permission_error := check_admin_permission(context, "File upload/download"):
return permission_error
sb = await get_booter( sb = await get_booter(
context.context.context, context.context.context,
context.context.event.unified_msg_origin, context.context.event.unified_msg_origin,
@@ -161,6 +164,8 @@ class FileDownloadTool(FunctionTool):
remote_path: str, remote_path: str,
also_send_to_user: bool = True, also_send_to_user: bool = True,
) -> ToolExecResult: ) -> ToolExecResult:
if permission_error := check_admin_permission(context, "File upload/download"):
return permission_error
sb = await get_booter( sb = await get_booter(
context.context.context, context.context.context,
context.context.event.unified_msg_origin, context.context.event.unified_msg_origin,
@@ -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
+3 -17
View File
@@ -7,6 +7,7 @@ from astrbot.core.agent.run_context import ContextWrapper
from astrbot.core.agent.tool import ToolExecResult from astrbot.core.agent.tool import ToolExecResult
from astrbot.core.astr_agent_context import AstrAgentContext, AstrMessageEvent 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.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 from astrbot.core.message.message_event_result import MessageChain
param_schema = { param_schema = {
@@ -26,21 +27,6 @@ param_schema = {
} }
def _check_admin_permission(context: ContextWrapper[AstrAgentContext]) -> 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 (
"error: Permission denied. Python execution 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
async def handle_result(result: dict, event: AstrMessageEvent) -> ToolExecResult: async def handle_result(result: dict, event: AstrMessageEvent) -> ToolExecResult:
data = result.get("data", {}) data = result.get("data", {})
output = data.get("output", {}) output = data.get("output", {})
@@ -81,7 +67,7 @@ class PythonTool(FunctionTool):
async def call( async def call(
self, context: ContextWrapper[AstrAgentContext], code: str, silent: bool = False self, context: ContextWrapper[AstrAgentContext], code: str, silent: bool = False
) -> ToolExecResult: ) -> ToolExecResult:
if permission_error := _check_admin_permission(context): if permission_error := check_admin_permission(context, "Python execution"):
return permission_error return permission_error
sb = await get_booter( sb = await get_booter(
context.context.context, context.context.context,
@@ -104,7 +90,7 @@ class LocalPythonTool(FunctionTool):
async def call( async def call(
self, context: ContextWrapper[AstrAgentContext], code: str, silent: bool = False self, context: ContextWrapper[AstrAgentContext], code: str, silent: bool = False
) -> ToolExecResult: ) -> ToolExecResult:
if permission_error := _check_admin_permission(context): if permission_error := check_admin_permission(context, "Python execution"):
return permission_error return permission_error
sb = get_local_booter() sb = get_local_booter()
try: try:
+2 -16
View File
@@ -7,21 +7,7 @@ from astrbot.core.agent.tool import ToolExecResult
from astrbot.core.astr_agent_context import AstrAgentContext from astrbot.core.astr_agent_context import AstrAgentContext
from ..computer_client import get_booter, get_local_booter from ..computer_client import get_booter, get_local_booter
from .permissions import check_admin_permission
def _check_admin_permission(context: ContextWrapper[AstrAgentContext]) -> 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 (
"error: Permission denied. Shell execution 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
@dataclass @dataclass
@@ -61,7 +47,7 @@ class ExecuteShellTool(FunctionTool):
background: bool = False, background: bool = False,
env: dict = {}, env: dict = {},
) -> ToolExecResult: ) -> ToolExecResult:
if permission_error := _check_admin_permission(context): if permission_error := check_admin_permission(context, "Shell execution"):
return permission_error return permission_error
if self.is_local: if self.is_local:
+3
View File
@@ -52,6 +52,9 @@ class AstrBotConfig(dict):
with open(config_path, encoding="utf-8-sig") as f: with open(config_path, encoding="utf-8-sig") as f:
conf_str = f.read() 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) conf = json.loads(conf_str)
# 检查配置完整性,并插入 # 检查配置完整性,并插入
+27 -4
View File
@@ -5,7 +5,7 @@ from typing import Any, TypedDict
from astrbot.core.utils.astrbot_path import get_astrbot_data_path from astrbot.core.utils.astrbot_path import get_astrbot_data_path
VERSION = "4.18.1" VERSION = "4.18.3"
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db") DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
WEBHOOK_SUPPORTED_PLATFORMS = [ WEBHOOK_SUPPORTED_PLATFORMS = [
@@ -100,6 +100,7 @@ DEFAULT_CONFIG = {
"dequeue_context_length": 1, "dequeue_context_length": 1,
"streaming_response": False, "streaming_response": False,
"show_tool_use_status": False, "show_tool_use_status": False,
"show_tool_call_result": False,
"sanitize_context_by_modalities": False, "sanitize_context_by_modalities": False,
"max_quoted_fallback_images": 20, "max_quoted_fallback_images": 20,
"quoted_message_parser": { "quoted_message_parser": {
@@ -424,7 +425,15 @@ CONFIG_METADATA_2 = {
"slack_webhook_port": 6197, "slack_webhook_port": 6197,
"slack_webhook_path": "/astrbot-slack-webhook/callback", "slack_webhook_path": "/astrbot-slack-webhook/callback",
}, },
# LINE's config is located in line_adapter.py "Line": {
"id": "line",
"type": "line",
"enable": False,
"channel_access_token": "",
"channel_secret": "",
"unified_webhook_mode": True,
"webhook_uuid": "",
},
"Satori": { "Satori": {
"id": "satori", "id": "satori",
"type": "satori", "type": "satori",
@@ -1462,6 +1471,7 @@ CONFIG_METADATA_2 = {
"type": "openai_embedding", "type": "openai_embedding",
"provider": "openai", "provider": "openai",
"provider_type": "embedding", "provider_type": "embedding",
"hint": "provider_group.provider.openai_embedding.hint",
"enable": True, "enable": True,
"embedding_api_key": "", "embedding_api_key": "",
"embedding_api_base": "", "embedding_api_base": "",
@@ -1475,6 +1485,7 @@ CONFIG_METADATA_2 = {
"type": "gemini_embedding", "type": "gemini_embedding",
"provider": "google", "provider": "google",
"provider_type": "embedding", "provider_type": "embedding",
"hint": "provider_group.provider.gemini_embedding.hint",
"enable": True, "enable": True,
"embedding_api_key": "", "embedding_api_key": "",
"embedding_api_base": "", "embedding_api_base": "",
@@ -2191,9 +2202,9 @@ CONFIG_METADATA_2 = {
"type": "string", "type": "string",
}, },
"proxy": { "proxy": {
"description": "代理地址", "description": "provider_group.provider.proxy.description",
"type": "string", "type": "string",
"hint": "HTTP/HTTPS 代理地址,格式如 http://127.0.0.1:7890。仅对该提供商的 API 请求生效,不影响 Docker 内网通信。", "hint": "provider_group.provider.proxy.hint",
}, },
"model": { "model": {
"description": "模型 ID", "description": "模型 ID",
@@ -2306,6 +2317,9 @@ CONFIG_METADATA_2 = {
"show_tool_use_status": { "show_tool_use_status": {
"type": "bool", "type": "bool",
}, },
"show_tool_call_result": {
"type": "bool",
},
"unsupported_streaming_strategy": { "unsupported_streaming_strategy": {
"type": "string", "type": "string",
}, },
@@ -2994,6 +3008,15 @@ CONFIG_METADATA_3 = {
"provider_settings.agent_runner_type": "local", "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": { "provider_settings.sanitize_context_by_modalities": {
"description": "按模型能力清理历史上下文", "description": "按模型能力清理历史上下文",
"type": "bool", "type": "bool",
+6 -6
View File
@@ -4,7 +4,7 @@ import typing as T
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from sqlalchemy import CursorResult from sqlalchemy import CursorResult, Row
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import col, delete, desc, func, or_, select, text, update from sqlmodel import col, delete, desc, func, or_, select, text, update
@@ -626,7 +626,7 @@ class SQLiteDatabase(BaseDatabase):
query = select(ApiKey).where( query = select(ApiKey).where(
ApiKey.key_hash == key_hash, ApiKey.key_hash == key_hash,
col(ApiKey.revoked_at).is_(None), col(ApiKey.revoked_at).is_(None),
or_(col(ApiKey.expires_at).is_(None), ApiKey.expires_at > now), or_(col(ApiKey.expires_at).is_(None), col(ApiKey.expires_at) > now),
) )
result = await session.execute(query) result = await session.execute(query)
return result.scalar_one_or_none() return result.scalar_one_or_none()
@@ -638,7 +638,7 @@ class SQLiteDatabase(BaseDatabase):
async with session.begin(): async with session.begin():
await session.execute( await session.execute(
update(ApiKey) update(ApiKey)
.where(ApiKey.key_id == key_id) .where(col(ApiKey.key_id) == key_id)
.values(last_used_at=datetime.now(timezone.utc)), .values(last_used_at=datetime.now(timezone.utc)),
) )
@@ -649,7 +649,7 @@ class SQLiteDatabase(BaseDatabase):
async with session.begin(): async with session.begin():
query = ( query = (
update(ApiKey) update(ApiKey)
.where(ApiKey.key_id == key_id) .where(col(ApiKey.key_id) == key_id)
.values(revoked_at=datetime.now(timezone.utc)) .values(revoked_at=datetime.now(timezone.utc))
) )
result = T.cast(CursorResult, await session.execute(query)) result = T.cast(CursorResult, await session.execute(query))
@@ -663,7 +663,7 @@ class SQLiteDatabase(BaseDatabase):
result = T.cast( result = T.cast(
CursorResult, CursorResult,
await session.execute( await session.execute(
delete(ApiKey).where(ApiKey.key_id == key_id) delete(ApiKey).where(col(ApiKey.key_id) == key_id)
), ),
) )
return result.rowcount > 0 return result.rowcount > 0
@@ -1457,7 +1457,7 @@ class SQLiteDatabase(BaseDatabase):
return query return query
@staticmethod @staticmethod
def _rows_to_session_dicts(rows: list[tuple]) -> list[dict]: def _rows_to_session_dicts(rows: T.Sequence[Row[tuple]]) -> list[dict]:
sessions_with_projects = [] sessions_with_projects = []
for row in rows: for row in rows:
platform_session = row[0] platform_session = row[0]
@@ -256,6 +256,46 @@ class KBSQLiteDatabase:
"knowledge_base": row[1], "knowledge_base": row[1],
} }
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 def delete_document_by_id(self, doc_id: str, vec_db: FaissVecDB) -> None:
"""删除单个文档及其相关数据""" """删除单个文档及其相关数据"""
# 在知识库表中删除 # 在知识库表中删除
@@ -142,10 +142,13 @@ class RetrievalManager:
f"Rank fusion took {time_end - time_start:.2f}s and returned {len(fused_results)} results.", 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 = [] retrieval_results = []
for fr in fused_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: if metadata_dict:
retrieval_results.append( retrieval_results.append(
RetrievalResult( RetrievalResult(
+28 -3
View File
@@ -720,13 +720,38 @@ class File(BaseMessageComponent):
if allow_return_url and self.url: if allow_return_url and self.url:
return self.url return self.url
if self.file_ and os.path.exists(self.file_): if self.file_:
return os.path.abspath(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: if self.url:
await self._download_file() await self._download_file()
if self.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 "" return ""
+55
View File
@@ -1,4 +1,5 @@
from astrbot import logger from astrbot import logger
from astrbot.api import sp
from astrbot.core.astrbot_config_mgr import AstrBotConfigManager from astrbot.core.astrbot_config_mgr import AstrBotConfigManager
from astrbot.core.db import BaseDatabase from astrbot.core.db import BaseDatabase
from astrbot.core.db.po import Persona, PersonaFolder, Personality from astrbot.core.db.po import Persona, PersonaFolder, Personality
@@ -58,6 +59,60 @@ class PersonaManager:
except Exception: except Exception:
return DEFAULT_PERSONALITY return DEFAULT_PERSONALITY
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: async def delete_persona(self, persona_id: str) -> None:
"""删除指定 persona""" """删除指定 persona"""
if not await self.db.get_persona_by_id(persona_id): if not await self.db.get_persona_by_id(persona_id):
+77 -21
View File
@@ -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 ( from astrbot.core.message.message_event_result import (
EventResultType, EventResultType,
MessageEventResult, MessageEventResult,
) )
from .content_safety_check.stage import ContentSafetyCheckStage from .stage_order import STAGES_ORDER
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
# 管道阶段顺序 if TYPE_CHECKING:
STAGES_ORDER = [ from .content_safety_check.stage import ContentSafetyCheckStage
"WakingCheckStage", # 检查是否需要唤醒 from .preprocess_stage.stage import PreProcessStage
"WhitelistCheckStage", # 检查是否在群聊/私聊白名单 from .process_stage.stage import ProcessStage
"SessionStatusCheckStage", # 检查会话是否整体启用 from .rate_limit_check.stage import RateLimitStage
"RateLimitStage", # 检查会话是否超过频率限制 from .respond.stage import RespondStage
"ContentSafetyCheckStage", # 检查内容安全 from .result_decorate.stage import ResultDecorateStage
"PreProcessStage", # 预处理 from .session_status_check.stage import SessionStatusCheckStage
"ProcessStage", # 交由 Stars 处理(a.k.a 插件),或者 LLM 调用 from .waking_check.stage import WakingCheckStage
"ResultDecorateStage", # 处理结果,比如添加回复前缀、t2i、转换为语音 等 from .whitelist_check.stage import WhitelistCheckStage
"RespondStage", # 发送消息
] _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__ = [ __all__ = [
"ContentSafetyCheckStage", "ContentSafetyCheckStage",
@@ -36,6 +77,21 @@ __all__ = [
"RespondStage", "RespondStage",
"ResultDecorateStage", "ResultDecorateStage",
"SessionStatusCheckStage", "SessionStatusCheckStage",
"STAGES_ORDER",
"WakingCheckStage", "WakingCheckStage",
"WhitelistCheckStage", "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__))
+52
View File
@@ -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"]
+4 -2
View File
@@ -1,7 +1,9 @@
from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any
from astrbot.core.config import AstrBotConfig from astrbot.core.config import AstrBotConfig
from astrbot.core.star import PluginManager
from .context_utils import call_event_hook, call_handler from .context_utils import call_event_hook, call_handler
@@ -11,7 +13,7 @@ class PipelineContext:
"""上下文对象,包含管道执行所需的上下文信息""" """上下文对象,包含管道执行所需的上下文信息"""
astrbot_config: AstrBotConfig # AstrBot 配置对象 astrbot_config: AstrBotConfig # AstrBot 配置对象
plugin_manager: PluginManager # 插件管理器对象 plugin_manager: Any # 插件管理器对象
astrbot_config_id: str astrbot_config_id: str
call_handler = call_handler call_handler = call_handler
call_event_hook = call_event_hook call_event_hook = call_event_hook
@@ -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)
@@ -19,6 +19,7 @@ from astrbot.core.message.message_event_result import (
MessageEventResult, MessageEventResult,
ResultContentType, ResultContentType,
) )
from astrbot.core.pipeline.stage import Stage
from astrbot.core.platform.astr_message_event import AstrMessageEvent from astrbot.core.platform.astr_message_event import AstrMessageEvent
from astrbot.core.provider.entities import ( from astrbot.core.provider.entities import (
LLMResponse, LLMResponse,
@@ -28,9 +29,16 @@ from astrbot.core.star.star_handler import EventType
from astrbot.core.utils.metrics import Metric from astrbot.core.utils.metrics import Metric
from astrbot.core.utils.session_lock import session_lock_manager 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 ....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): class InternalAgentSubStage(Stage):
@@ -54,6 +62,7 @@ class InternalAgentSubStage(Stage):
if isinstance(self.max_step, bool): # workaround: #2622 if isinstance(self.max_step, bool): # workaround: #2622
self.max_step = 30 self.max_step = 30
self.show_tool_use: bool = settings.get("show_tool_use_status", True) 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.show_reasoning = settings.get("display_reasoning_text", False)
self.sanitize_context_by_modalities: bool = settings.get( self.sanitize_context_by_modalities: bool = settings.get(
"sanitize_context_by_modalities", "sanitize_context_by_modalities",
@@ -129,6 +138,9 @@ class InternalAgentSubStage(Stage):
async def process( async def process(
self, event: AstrMessageEvent, provider_wake_prefix: str self, event: AstrMessageEvent, provider_wake_prefix: str
) -> AsyncGenerator[None, None]: ) -> AsyncGenerator[None, None]:
follow_up_capture: FollowUpCapture | None = None
follow_up_consumed_marked = False
follow_up_activated = False
try: try:
streaming_response = self.streaming_response streaming_response = self.streaming_response
if (enable_streaming := event.get_extra("enable_streaming")) is not None: if (enable_streaming := event.get_extra("enable_streaming")) is not None:
@@ -149,181 +161,208 @@ class InternalAgentSubStage(Stage):
return return
logger.debug("ready to request llm provider") 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 event.send_typing()
await call_event_hook(event, EventType.OnWaitingLLMRequestEvent) await call_event_hook(event, EventType.OnWaitingLLMRequestEvent)
async with session_lock_manager.acquire_lock(event.unified_msg_origin): async with session_lock_manager.acquire_lock(event.unified_msg_origin):
logger.debug("acquired session lock for llm request") 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( build_result: MainAgentBuildResult | None = await build_main_agent(
self.main_agent_cfg, event=event,
provider_wake_prefix=provider_wake_prefix, plugin_context=self.ctx.plugin_manager.context,
streaming_response=streaming_response, config=build_cfg,
) apply_reset=False,
)
build_result: MainAgentBuildResult | None = await build_main_agent( if build_result is None:
event=event,
plugin_context=self.ctx.plugin_manager.context,
config=build_cfg,
apply_reset=False,
)
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 return
stream_to_general = ( agent_runner = build_result.agent_runner
self.unsupported_streaming_strategy == "turn_off" req = build_result.provider_request
and not event.platform_meta.support_streaming_message provider = build_result.provider
) reset_coro = build_result.reset_coro
if await call_event_hook(event, EventType.OnLLMRequestEvent, req): 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
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
# apply reset
if reset_coro: if reset_coro:
reset_coro.close() await reset_coro
return
# apply reset register_active_runner(event.unified_msg_origin, agent_runner)
if reset_coro: runner_registered = True
await reset_coro action_type = event.get_extra("action_type")
action_type = event.get_extra("action_type") event.trace.record(
"astr_agent_prepare",
event.trace.record( system_prompt=req.system_prompt,
"astr_agent_prepare", tools=req.func_tool.names() if req.func_tool else [],
system_prompt=req.system_prompt, stream=streaming_response,
tools=req.func_tool.names() if req.func_tool else [], chat_provider={
stream=streaming_response, "id": provider.provider_config.get("id", ""),
chat_provider={ "model": provider.get_model(),
"id": provider.provider_config.get("id", ""), },
"model": provider.get_model(),
},
)
# 检测 Live Mode
if action_type == "live":
# Live Mode: 使用 run_live_agent
logger.info("[Internal Agent] 检测到 Live Mode,启用 TTS 处理")
# 获取 TTS Provider
tts_provider = (
self.ctx.plugin_manager.context.get_using_tts_provider(
event.unified_msg_origin
)
) )
if not tts_provider: # 检测 Live Mode
logger.warning( if action_type == "live":
"[Live Mode] TTS Provider 未配置,将使用普通流式模式" # Live Mode: 使用 run_live_agent
logger.info("[Internal Agent] 检测到 Live Mode,启用 TTS 处理")
# 获取 TTS Provider
tts_provider = (
self.ctx.plugin_manager.context.get_using_tts_provider(
event.unified_msg_origin
)
) )
# 使用 run_live_agent,总是使用流式响应 if not tts_provider:
event.set_result( logger.warning(
MessageEventResult() "[Live Mode] TTS Provider 未配置,将使用普通流式模式"
.set_result_content_type(ResultContentType.STREAMING_RESULT) )
.set_async_stream(
run_live_agent( # 使用 run_live_agent,总是使用流式响应
agent_runner, event.set_result(
tts_provider, MessageEventResult()
self.max_step, .set_result_content_type(ResultContentType.STREAMING_RESULT)
self.show_tool_use, .set_async_stream(
show_reasoning=self.show_reasoning, 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
yield
# 保存历史记录 # 保存历史记录
if not event.is_stopped() and agent_runner.done(): 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(),
)
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,
)
# 检查事件是否被停止,如果被停止则不保存历史记录
if not event.is_stopped() or agent_runner.was_aborted():
await self._save_to_history( await self._save_to_history(
event, event,
req, req,
agent_runner.get_final_llm_resp(), final_resp,
agent_runner.run_context.messages, agent_runner.run_context.messages,
agent_runner.stats, agent_runner.stats,
user_aborted=agent_runner.was_aborted(),
) )
elif streaming_response and not stream_to_general: asyncio.create_task(
# 流式响应 Metric.upload(
event.set_result( llm_tick=1,
MessageEventResult() model_name=agent_runner.provider.get_model(),
.set_result_content_type(ResultContentType.STREAMING_RESULT) provider_type=agent_runner.provider.meta().type,
.set_async_stream(
run_agent(
agent_runner,
self.max_step,
self.show_tool_use,
show_reasoning=self.show_reasoning,
),
), ),
) )
yield finally:
if agent_runner.done(): if runner_registered and agent_runner is not None:
if final_llm_resp := agent_runner.get_final_llm_resp(): unregister_active_runner(event.unified_msg_origin, agent_runner)
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,
),
)
except Exception as e: except Exception as e:
logger.error(f"Error occurred while processing agent: {e}") logger.error(f"Error occurred while processing agent: {e}")
@@ -332,6 +371,13 @@ class InternalAgentSubStage(Stage):
f"Error occurred while processing agent request: {e}" 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( async def _save_to_history(
self, self,
@@ -340,16 +386,29 @@ class InternalAgentSubStage(Stage):
llm_response: LLMResponse | None, llm_response: LLMResponse | None,
all_messages: list[Message], all_messages: list[Message],
runner_stats: AgentStats | None, runner_stats: AgentStats | None,
user_aborted: bool = False,
) -> None: ) -> None:
if ( if not req or not req.conversation:
not req
or not req.conversation
or not llm_response
or llm_response.role != "assistant"
):
return 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 响应为空,不保存记录。") logger.debug("LLM 响应为空,不保存记录。")
return return
@@ -363,6 +422,14 @@ class InternalAgentSubStage(Stage):
continue continue
message_to_save.append(message.model_dump()) 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 token_usage = None
if runner_stats: if runner_stats:
# token_usage = runner_stats.token_usage.total # token_usage = runner_stats.token_usage.total
@@ -8,6 +8,7 @@ from astrbot.core.agent.runners.dashscope.dashscope_agent_runner import (
DashscopeAgentRunner, DashscopeAgentRunner,
) )
from astrbot.core.agent.runners.dify.dify_agent_runner import DifyAgentRunner 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.components import Image
from astrbot.core.message.message_event_result import ( from astrbot.core.message.message_event_result import (
MessageChain, MessageChain,
@@ -17,6 +18,7 @@ from astrbot.core.message.message_event_result import (
if TYPE_CHECKING: if TYPE_CHECKING:
from astrbot.core.agent.runners.base import BaseAgentRunner 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.platform.astr_message_event import AstrMessageEvent
from astrbot.core.provider.entities import ( from astrbot.core.provider.entities import (
ProviderRequest, ProviderRequest,
@@ -25,9 +27,7 @@ from astrbot.core.star.star_handler import EventType
from astrbot.core.utils.metrics import Metric from astrbot.core.utils.metrics import Metric
from .....astr_agent_context import AgentContextWrapper, AstrAgentContext from .....astr_agent_context import AgentContextWrapper, AstrAgentContext
from .....astr_agent_hooks import MAIN_AGENT_HOOKS
from ....context import PipelineContext, call_event_hook from ....context import PipelineContext, call_event_hook
from ...stage import Stage
AGENT_RUNNER_TYPE_KEY = { AGENT_RUNNER_TYPE_KEY = {
"dify": "dify_agent_runner_provider_id", "dify": "dify_agent_runner_provider_id",
+3 -1
View File
@@ -8,15 +8,17 @@ from astrbot.core.platform.sources.wecom_ai_bot.wecomai_event import (
) )
from astrbot.core.utils.active_event_registry import active_event_registry 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 .context import PipelineContext
from .stage import registered_stages from .stage import registered_stages
from .stage_order import STAGES_ORDER
class PipelineScheduler: class PipelineScheduler:
"""管道调度器,负责调度各个阶段的执行""" """管道调度器,负责调度各个阶段的执行"""
def __init__(self, context: PipelineContext) -> None: def __init__(self, context: PipelineContext) -> None:
ensure_builtin_stages_registered()
registered_stages.sort( registered_stages.sort(
key=lambda x: STAGES_ORDER.index(x.__name__), key=lambda x: STAGES_ORDER.index(x.__name__),
) # 按照顺序排序 ) # 按照顺序排序
+15
View File
@@ -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"]
+33 -11
View File
@@ -52,9 +52,19 @@ class AstrMessageEvent(abc.ABC):
self.is_at_or_wake_command = False self.is_at_or_wake_command = False
"""是否是 At 机器人或者带有唤醒词或者是私聊(插件注册的事件监听器会让 is_wake 设为 True, 但是不会让这个属性置为 True)""" """是否是 At 机器人或者带有唤醒词或者是私聊(插件注册的事件监听器会让 is_wake 设为 True, 但是不会让这个属性置为 True)"""
self._extras: dict[str, Any] = {} 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( self.session = MessageSession(
platform_name=platform_meta.id, platform_name=platform_meta.id,
message_type=message_obj.type, message_type=message_type,
session_id=session_id, session_id=session_id,
) )
# self.unified_msg_origin = str(self.session) # self.unified_msg_origin = str(self.session)
@@ -159,15 +169,18 @@ class AstrMessageEvent(abc.ABC):
除了文本消息外其他消息类型会被转换为对应的占位符如图片消息会被转换为 [图片] 除了文本消息外其他消息类型会被转换为对应的占位符如图片消息会被转换为 [图片]
""" """
return self._outline_chain(self.message_obj.message) return self._outline_chain(getattr(self.message_obj, "message", None))
def get_messages(self) -> list[BaseMessageComponent]: def get_messages(self) -> list[BaseMessageComponent]:
"""获取消息链。""" """获取消息链。"""
return self.message_obj.message return getattr(self.message_obj, "message", [])
def get_message_type(self) -> MessageType: 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: def get_session_id(self) -> str:
"""获取会话id。""" """获取会话id。"""
@@ -175,21 +188,30 @@ class AstrMessageEvent(abc.ABC):
def get_group_id(self) -> str: def get_group_id(self) -> str:
"""获取群组id。如果不是群组消息,返回空字符串。""" """获取群组id。如果不是群组消息,返回空字符串。"""
return self.message_obj.group_id return getattr(self.message_obj, "group_id", "")
def get_self_id(self) -> str: def get_self_id(self) -> str:
"""获取机器人自身的id。""" """获取机器人自身的id。"""
return self.message_obj.self_id return getattr(self.message_obj, "self_id", "")
def get_sender_id(self) -> str: def get_sender_id(self) -> str:
"""获取消息发送者的id。""" """获取消息发送者的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: def get_sender_name(self) -> str:
"""获取消息发送者的名称。(可能会返回空字符串)""" """获取消息发送者的名称。(可能会返回空字符串)"""
if isinstance(self.message_obj.sender.nickname, str): sender = getattr(self.message_obj, "sender", None)
return self.message_obj.sender.nickname if not sender:
return "" 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) -> None: def set_extra(self, key, value) -> None:
"""设置额外的信息。""" """设置额外的信息。"""
@@ -208,7 +230,7 @@ class AstrMessageEvent(abc.ABC):
def is_private_chat(self) -> bool: 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: def is_wake_up(self) -> bool:
"""是否是唤醒机器人的事件。""" """是否是唤醒机器人的事件。"""
@@ -45,6 +45,19 @@ class AiocqhttpMessageEvent(AstrMessageEvent):
if isinstance(segment, File): if isinstance(segment, File):
# For File segments, we need to handle the file differently # For File segments, we need to handle the file differently
d = await segment.to_dict() 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 return d
if isinstance(segment, Video): if isinstance(segment, Video):
d = await segment.to_dict() d = await segment.to_dict()
@@ -1,4 +1,5 @@
import asyncio import asyncio
import inspect
import itertools import itertools
import logging import logging
import time import time
@@ -436,7 +437,42 @@ class AiocqhttpAdapter(Platform):
return coro return coro
async def terminate(self) -> None: async def terminate(self) -> None:
self.shutdown_event.set() 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) -> None: async def shutdown_trigger_placeholder(self) -> None:
await self.shutdown_event.wait() await self.shutdown_event.wait()
@@ -65,15 +65,6 @@ LINE_I18N_RESOURCES = {
"line", "line",
"LINE Messaging API 适配器", "LINE Messaging API 适配器",
support_streaming_message=False, support_streaming_message=False,
default_config_tmpl={
"id": "line",
"type": "line",
"enable": False,
"channel_access_token": "",
"channel_secret": "",
"unified_webhook_mode": True,
"webhook_uuid": "",
},
config_metadata=LINE_CONFIG_METADATA, config_metadata=LINE_CONFIG_METADATA,
i18n_resources=LINE_I18N_RESOURCES, i18n_resources=LINE_I18N_RESOURCES,
) )
@@ -162,6 +162,8 @@ class QQOfficialMessageEvent(AstrMessageEvent):
) )
payload["media"] = media payload["media"] = media
payload["msg_type"] = 7 payload["msg_type"] = 7
payload.pop("markdown", None)
payload["content"] = plain_text or None
if record_file_path: # group record msg if record_file_path: # group record msg
media = await self.upload_group_and_c2c_record( media = await self.upload_group_and_c2c_record(
record_file_path, record_file_path,
@@ -170,6 +172,8 @@ class QQOfficialMessageEvent(AstrMessageEvent):
) )
payload["media"] = media payload["media"] = media
payload["msg_type"] = 7 payload["msg_type"] = 7
payload.pop("markdown", None)
payload["content"] = plain_text or None
ret = await self._send_with_markdown_fallback( ret = await self._send_with_markdown_fallback(
send_func=lambda retry_payload: self.bot.api.post_group_message( send_func=lambda retry_payload: self.bot.api.post_group_message(
group_openid=source.group_openid, # type: ignore group_openid=source.group_openid, # type: ignore
@@ -188,6 +192,8 @@ class QQOfficialMessageEvent(AstrMessageEvent):
) )
payload["media"] = media payload["media"] = media
payload["msg_type"] = 7 payload["msg_type"] = 7
payload.pop("markdown", None)
payload["content"] = plain_text or None
if record_file_path: # c2c record if record_file_path: # c2c record
media = await self.upload_group_and_c2c_record( media = await self.upload_group_and_c2c_record(
record_file_path, record_file_path,
@@ -196,6 +202,8 @@ class QQOfficialMessageEvent(AstrMessageEvent):
) )
payload["media"] = media payload["media"] = media
payload["msg_type"] = 7 payload["msg_type"] = 7
payload.pop("markdown", None)
payload["content"] = plain_text or None
if stream: if stream:
ret = await self._send_with_markdown_fallback( ret = await self._send_with_markdown_fallback(
send_func=lambda retry_payload: self.post_c2c_message( send_func=lambda retry_payload: self.post_c2c_message(
@@ -1,7 +1,9 @@
import asyncio import asyncio
import os
import re import re
import sys import sys
import uuid import uuid
from typing import cast
from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.schedulers.asyncio import AsyncIOScheduler
from telegram import BotCommand, Update from telegram import BotCommand, Update
@@ -25,6 +27,9 @@ from astrbot.core.star.filter.command import CommandFilter
from astrbot.core.star.filter.command_group import CommandGroupFilter from astrbot.core.star.filter.command_group import CommandGroupFilter
from astrbot.core.star.star import star_map from astrbot.core.star.star import star_map
from astrbot.core.star.star_handler import star_handlers_registry 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 from .tg_event import TelegramPlatformEvent
@@ -375,8 +380,19 @@ class TelegramPlatformAdapter(Platform):
elif update.message.voice: elif update.message.voice:
file = await update.message.voice.get_file() 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 = [ message.message = [
Comp.Record(file=file.file_path, url=file.file_path), Comp.Record(file=path_wav, url=path_wav),
] ]
elif update.message.photo: elif update.message.photo:
@@ -18,6 +18,7 @@ from astrbot.api.message_components import (
Plain, Plain,
Record, Record,
Reply, Reply,
Video,
) )
from astrbot.api.platform import AstrBotMessage, MessageType, PlatformMetadata from astrbot.api.platform import AstrBotMessage, MessageType, PlatformMetadata
@@ -36,6 +37,7 @@ class TelegramPlatformEvent(AstrMessageEvent):
# 消息类型到 chat action 的映射,用于优先级判断 # 消息类型到 chat action 的映射,用于优先级判断
ACTION_BY_TYPE: dict[type, str] = { ACTION_BY_TYPE: dict[type, str] = {
Record: ChatAction.UPLOAD_VOICE, Record: ChatAction.UPLOAD_VOICE,
Video: ChatAction.UPLOAD_VIDEO,
File: ChatAction.UPLOAD_DOCUMENT, File: ChatAction.UPLOAD_DOCUMENT,
Image: ChatAction.UPLOAD_PHOTO, Image: ChatAction.UPLOAD_PHOTO,
Plain: ChatAction.TYPING, Plain: ChatAction.TYPING,
@@ -114,10 +116,18 @@ class TelegramPlatformEvent(AstrMessageEvent):
**payload: Any, **payload: Any,
) -> None: ) -> None:
"""发送媒体时显示 upload action,发送完成后恢复 typing""" """发送媒体时显示 upload action,发送完成后恢复 typing"""
await cls._send_chat_action(client, user_name, upload_action, message_thread_id) effective_thread_id = message_thread_id or cast(
await send_coro(**payload) str | None, payload.get("message_thread_id")
)
await cls._send_chat_action( await cls._send_chat_action(
client, user_name, ChatAction.TYPING, message_thread_id 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 @classmethod
@@ -141,14 +151,16 @@ class TelegramPlatformEvent(AstrMessageEvent):
""" """
try: try:
if use_media_action: 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( await cls._send_media_with_action(
client, client,
ChatAction.UPLOAD_VOICE, ChatAction.UPLOAD_VOICE,
client.send_voice, client.send_voice,
user_name=user_name, user_name=user_name,
message_thread_id=message_thread_id,
voice=path, voice=path,
**cast(Any, payload), **cast(Any, media_payload),
) )
else: else:
await client.send_voice(voice=path, **cast(Any, payload)) await client.send_voice(voice=path, **cast(Any, payload))
@@ -162,15 +174,17 @@ class TelegramPlatformEvent(AstrMessageEvent):
"To enable voice messages, go to Telegram Settings → Privacy and Security → Voice Messages → set to 'Everyone'." "To enable voice messages, go to Telegram Settings → Privacy and Security → Voice Messages → set to 'Everyone'."
) )
if use_media_action: 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( await cls._send_media_with_action(
client, client,
ChatAction.UPLOAD_DOCUMENT, ChatAction.UPLOAD_DOCUMENT,
client.send_document, client.send_document,
user_name=user_name, user_name=user_name,
message_thread_id=message_thread_id,
document=path, document=path,
caption=caption, caption=caption,
**cast(Any, payload), **cast(Any, media_payload),
) )
else: else:
await client.send_document( await client.send_document(
@@ -278,6 +292,13 @@ class TelegramPlatformEvent(AstrMessageEvent):
caption=i.text or None, caption=i.text or None,
use_media_action=False, 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) -> None: async def send(self, message: MessageChain) -> None:
if self.get_message_type() == MessageType.GROUP_MESSAGE: if self.get_message_type() == MessageType.GROUP_MESSAGE:
@@ -333,7 +354,7 @@ class TelegramPlatformEvent(AstrMessageEvent):
"chat_id": user_name, "chat_id": user_name,
} }
if message_thread_id: if message_thread_id:
payload["reply_to_message_id"] = message_thread_id payload["message_thread_id"] = message_thread_id
delta = "" delta = ""
current_content = "" current_content = ""
@@ -375,7 +396,6 @@ class TelegramPlatformEvent(AstrMessageEvent):
ChatAction.UPLOAD_PHOTO, ChatAction.UPLOAD_PHOTO,
self.client.send_photo, self.client.send_photo,
user_name=user_name, user_name=user_name,
message_thread_id=message_thread_id,
photo=image_path, photo=image_path,
**cast(Any, payload), **cast(Any, payload),
) )
@@ -388,7 +408,6 @@ class TelegramPlatformEvent(AstrMessageEvent):
ChatAction.UPLOAD_DOCUMENT, ChatAction.UPLOAD_DOCUMENT,
self.client.send_document, self.client.send_document,
user_name=user_name, user_name=user_name,
message_thread_id=message_thread_id,
document=path, document=path,
filename=name, filename=name,
**cast(Any, payload), **cast(Any, payload),
@@ -406,6 +425,17 @@ class TelegramPlatformEvent(AstrMessageEvent):
use_media_action=True, use_media_action=True,
) )
continue 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: else:
logger.warning(f"不支持的消息类型: {type(i)}") logger.warning(f"不支持的消息类型: {type(i)}")
continue continue
@@ -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,
}
@@ -3,12 +3,12 @@ import os
import time import time
import uuid import uuid
from collections.abc import Callable, Coroutine from collections.abc import Callable, Coroutine
from pathlib import Path
from typing import Any from typing import Any
from astrbot import logger from astrbot import logger
from astrbot.core import db_helper from astrbot.core import db_helper
from astrbot.core.db.po import PlatformMessageHistory 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.message.message_event_result import MessageChain
from astrbot.core.platform import ( from astrbot.core.platform import (
AstrBotMessage, AstrBotMessage,
@@ -21,10 +21,23 @@ 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_data_path
from ...register import register_platform_adapter 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_event import WebChatMessageEvent
from .webchat_queue_mgr import WebChatQueueMgr, webchat_queue_mgr 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: class QueueListener:
def __init__( def __init__(
self, self,
@@ -57,13 +70,15 @@ class WebChatAdapter(Platform):
self.settings = platform_settings self.settings = platform_settings
self.imgs_dir = os.path.join(get_astrbot_data_path(), "webchat", "imgs") 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) os.makedirs(self.imgs_dir, exist_ok=True)
self.attachments_dir.mkdir(parents=True, exist_ok=True)
self.metadata = PlatformMetadata( self.metadata = PlatformMetadata(
name="webchat", name="webchat",
description="webchat", description="webchat",
id="webchat", id="webchat",
support_proactive_message=False, support_proactive_message=True,
) )
self._shutdown_event = asyncio.Event() self._shutdown_event = asyncio.Event()
self._webchat_queue_mgr = webchat_queue_mgr self._webchat_queue_mgr = webchat_queue_mgr
@@ -73,10 +88,67 @@ class WebChatAdapter(Platform):
session: MessageSesion, session: MessageSesion,
message_chain: MessageChain, message_chain: MessageChain,
) -> None: ) -> None:
message_id = f"active_{str(uuid.uuid4())}" conversation_id = _extract_conversation_id(session.session_id)
await WebChatMessageEvent._send(message_id, message_chain, 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) 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( async def _get_message_history(
self, message_id: int self, message_id: int
) -> PlatformMessageHistory | None: ) -> PlatformMessageHistory | None:
@@ -98,72 +170,30 @@ class WebChatAdapter(Platform):
Returns: Returns:
tuple[list, list[str]]: (消息组件列表, 纯文本列表) tuple[list, list[str]]: (消息组件列表, 纯文本列表)
""" """
components = []
text_parts = []
for part in message_parts: async def get_reply_parts(
part_type = part.get("type") message_id: Any,
if part_type == "plain": ) -> tuple[list[dict], str | None, str | None] | None:
text = part.get("text", "") history = await self._get_message_history(message_id)
components.append(Plain(text=text)) if not history or not history.content:
text_parts.append(text) return None
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_parts = history.content.get("message", [])
reply_chain = [Plain(text=reply_message_str)] if not isinstance(reply_parts, list):
return None
# recursively get the content of the referenced message, if selected_text is empty return reply_parts, history.sender_id, history.sender_name
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))
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 return components, text_parts
async def convert_message(self, data: tuple) -> AstrBotMessage: async def convert_message(self, data: tuple) -> AstrBotMessage:
@@ -11,13 +11,22 @@ from astrbot.core.utils.astrbot_path import get_astrbot_data_path
from .webchat_queue_mgr import webchat_queue_mgr 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): class WebChatMessageEvent(AstrMessageEvent):
def __init__(self, message_str, message_obj, platform_meta, session_id) -> None: def __init__(self, message_str, message_obj, platform_meta, session_id) -> None:
super().__init__(message_str, message_obj, platform_meta, session_id) 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 @staticmethod
async def _send( async def _send(
@@ -27,7 +36,7 @@ class WebChatMessageEvent(AstrMessageEvent):
streaming: bool = False, streaming: bool = False,
) -> str | None: ) -> str | None:
request_id = str(message_id) request_id = str(message_id)
conversation_id = session_id.split("!")[-1] conversation_id = _extract_conversation_id(session_id)
web_chat_back_queue = webchat_queue_mgr.get_or_create_back_queue( web_chat_back_queue = webchat_queue_mgr.get_or_create_back_queue(
request_id, request_id,
conversation_id, conversation_id,
@@ -69,7 +78,7 @@ class WebChatMessageEvent(AstrMessageEvent):
elif isinstance(comp, Image): elif isinstance(comp, Image):
# save image to local # save image to local
filename = f"{str(uuid.uuid4())}.jpg" 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() image_base64 = await comp.convert_to_base64()
with open(path, "wb") as f: with open(path, "wb") as f:
f.write(base64.b64decode(image_base64)) f.write(base64.b64decode(image_base64))
@@ -85,7 +94,7 @@ class WebChatMessageEvent(AstrMessageEvent):
elif isinstance(comp, Record): elif isinstance(comp, Record):
# save record to local # save record to local
filename = f"{str(uuid.uuid4())}.wav" 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() record_base64 = await comp.convert_to_base64()
with open(path, "wb") as f: with open(path, "wb") as f:
f.write(base64.b64decode(record_base64)) f.write(base64.b64decode(record_base64))
@@ -104,7 +113,7 @@ class WebChatMessageEvent(AstrMessageEvent):
original_name = comp.name or os.path.basename(file_path) original_name = comp.name or os.path.basename(file_path)
ext = os.path.splitext(original_name)[1] or "" ext = os.path.splitext(original_name)[1] or ""
filename = f"{uuid.uuid4()!s}{ext}" 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) shutil.copy2(file_path, dest_path)
data = f"[FILE]{filename}" data = f"[FILE]{filename}"
await web_chat_back_queue.put( await web_chat_back_queue.put(
@@ -130,7 +139,7 @@ class WebChatMessageEvent(AstrMessageEvent):
reasoning_content = "" reasoning_content = ""
message_id = self.message_obj.message_id message_id = self.message_obj.message_id
request_id = str(message_id) request_id = str(message_id)
conversation_id = self.session_id.split("!")[-1] conversation_id = _extract_conversation_id(self.session_id)
web_chat_back_queue = webchat_queue_mgr.get_or_create_back_queue( web_chat_back_queue = webchat_queue_mgr.get_or_create_back_queue(
request_id, request_id,
conversation_id, conversation_id,
@@ -75,6 +75,10 @@ class WebChatQueueMgr:
if task is not None: if task is not None:
task.cancel() 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: def has_queue(self, conversation_id: str) -> bool:
"""Check if a queue exists for the given conversation ID""" """Check if a queue exists for the given conversation ID"""
return conversation_id in self.queues return conversation_id in self.queues
@@ -3,7 +3,7 @@ import os
import sys import sys
import time import time
import uuid import uuid
from collections.abc import Awaitable, Callable from collections.abc import Callable, Coroutine
from typing import Any, cast from typing import Any, cast
import quart import quart
@@ -65,7 +65,9 @@ class WeixinOfficialAccountServer:
self.event_queue = event_queue 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.shutdown_event = asyncio.Event()
self._wx_msg_time_out = 4.0 # 微信服务器要求 5 秒内回复 self._wx_msg_time_out = 4.0 # 微信服务器要求 5 秒内回复
@@ -48,6 +48,9 @@ class GeminiEmbeddingProvider(EmbeddingProvider):
result = await self.client.models.embed_content( result = await self.client.models.embed_content(
model=self.model, model=self.model,
contents=text, contents=text,
config=types.EmbedContentConfig(
output_dimensionality=self.get_dim(),
),
) )
assert result.embeddings is not None assert result.embeddings is not None
assert result.embeddings[0].values is not None assert result.embeddings[0].values is not None
@@ -61,6 +64,9 @@ class GeminiEmbeddingProvider(EmbeddingProvider):
result = await self.client.models.embed_content( result = await self.client.models.embed_content(
model=self.model, model=self.model,
contents=cast(types.ContentListUnion, text), contents=cast(types.ContentListUnion, text),
config=types.EmbedContentConfig(
output_dimensionality=self.get_dim(),
),
) )
assert result.embeddings is not None assert result.embeddings is not None
@@ -23,12 +23,16 @@ class OpenAIEmbeddingProvider(EmbeddingProvider):
if proxy: if proxy:
logger.info(f"[OpenAI Embedding] 使用代理: {proxy}") logger.info(f"[OpenAI Embedding] 使用代理: {proxy}")
http_client = httpx.AsyncClient(proxy=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( self.client = AsyncOpenAI(
api_key=provider_config.get("embedding_api_key"), api_key=provider_config.get("embedding_api_key"),
base_url=provider_config.get( base_url=api_base,
"embedding_api_base",
"https://api.openai.com/v1",
),
timeout=int(provider_config.get("timeout", 20)), timeout=int(provider_config.get("timeout", 20)),
http_client=http_client, http_client=http_client,
) )
@@ -36,12 +40,20 @@ class OpenAIEmbeddingProvider(EmbeddingProvider):
async def get_embedding(self, text: str) -> list[float]: 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 return embedding.data[0].embedding
async def get_embeddings(self, text: list[str]) -> list[list[float]]: 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] return [item.embedding for item in embeddings.data]
def get_dim(self) -> int: def get_dim(self) -> int:
+13 -62
View File
@@ -1,68 +1,19 @@
from astrbot.core import html_renderer # 兼容导出: Provider 从 provider 模块重新导出
from astrbot.core.provider import 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 .context import Context
from .star import StarMetadata, star_map, star_registry from .star import StarMetadata, star_map, star_registry
from .star_manager import PluginManager from .star_manager import PluginManager
from .star_tools import StarTools
__all__ = [
class Star(CommandParserMixin, PluginKVStoreMixin): "Context",
"""所有插件(Star)的父类,所有插件都应该继承于这个类""" "PluginManager",
"Provider",
author: str "Star",
name: str "StarMetadata",
"StarTools",
def __init__(self, context: Context, config: dict | None = None) -> None: "star_map",
StarTools.initialize(context) "star_registry",
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) -> None:
"""当插件被激活时会调用这个方法"""
async def terminate(self) -> None:
"""当插件被禁用、重载插件时会调用这个方法"""
def __del__(self) -> None:
"""[Deprecated] 当插件被禁用、重载插件时会调用这个方法"""
__all__ = ["Context", "PluginManager", "Provider", "Star", "StarMetadata", "StarTools"]
+87
View File
@@ -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] 当插件被禁用、重载插件时会调用这个方法"""
+16 -4
View File
@@ -1,7 +1,9 @@
from __future__ import annotations
import logging import logging
from asyncio import Queue from asyncio import Queue
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
from typing import Any from typing import TYPE_CHECKING, Any, Protocol
from deprecated import deprecated from deprecated import deprecated
@@ -12,14 +14,12 @@ from astrbot.core.agent.tool import ToolSet
from astrbot.core.astrbot_config_mgr import AstrBotConfigManager from astrbot.core.astrbot_config_mgr import AstrBotConfigManager
from astrbot.core.config.astrbot_config import AstrBotConfig from astrbot.core.config.astrbot_config import AstrBotConfig
from astrbot.core.conversation_mgr import ConversationManager from astrbot.core.conversation_mgr import ConversationManager
from astrbot.core.cron.manager import CronJobManager
from astrbot.core.db import BaseDatabase from astrbot.core.db import BaseDatabase
from astrbot.core.knowledge_base.kb_mgr import KnowledgeBaseManager from astrbot.core.knowledge_base.kb_mgr import KnowledgeBaseManager
from astrbot.core.message.message_event_result import MessageChain from astrbot.core.message.message_event_result import MessageChain
from astrbot.core.persona_mgr import PersonaManager from astrbot.core.persona_mgr import PersonaManager
from astrbot.core.platform import Platform from astrbot.core.platform import Platform
from astrbot.core.platform.astr_message_event import AstrMessageEvent, MessageSesion 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.platform_message_history_mgr import PlatformMessageHistoryManager
from astrbot.core.provider.entities import LLMResponse, ProviderRequest, ProviderType from astrbot.core.provider.entities import LLMResponse, ProviderRequest, ProviderType
from astrbot.core.provider.func_tool_manager import FunctionTool, FunctionToolManager from astrbot.core.provider.func_tool_manager import FunctionTool, FunctionToolManager
@@ -45,6 +45,15 @@ from .star_handler import EventType, StarHandlerMetadata, star_handlers_registry
logger = logging.getLogger("astrbot") 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: class Context:
"""暴露给插件的接口上下文。""" """暴露给插件的接口上下文。"""
@@ -61,7 +70,7 @@ class Context:
config: AstrBotConfig, config: AstrBotConfig,
db: BaseDatabase, db: BaseDatabase,
provider_manager: ProviderManager, provider_manager: ProviderManager,
platform_manager: PlatformManager, platform_manager: PlatformManagerProtocol,
conversation_manager: ConversationManager, conversation_manager: ConversationManager,
message_history_manager: PlatformMessageHistoryManager, message_history_manager: PlatformMessageHistoryManager,
persona_manager: PersonaManager, persona_manager: PersonaManager,
@@ -448,6 +457,9 @@ class Context:
if platform.meta().id == session.platform_name: if platform.meta().id == session.platform_name:
await platform.send_by_session(session, message_chain) await platform.send_by_session(session, message_chain)
return True return True
logger.warning(
f"cannot find platform for session {str(session)}, message not sent"
)
return False return False
def add_llm_tools(self, *tools: FunctionTool) -> None: def add_llm_tools(self, *tools: FunctionTool) -> None:
+4
View File
@@ -14,6 +14,8 @@ from .star_handler import (
register_on_llm_tool_respond, register_on_llm_tool_respond,
register_on_platform_loaded, register_on_platform_loaded,
register_on_plugin_error, register_on_plugin_error,
register_on_plugin_loaded,
register_on_plugin_unloaded,
register_on_using_llm_tool, register_on_using_llm_tool,
register_on_waiting_llm_request, register_on_waiting_llm_request,
register_permission_type, register_permission_type,
@@ -34,6 +36,8 @@ __all__ = [
"register_on_llm_request", "register_on_llm_request",
"register_on_llm_response", "register_on_llm_response",
"register_on_plugin_error", "register_on_plugin_error",
"register_on_plugin_loaded",
"register_on_plugin_unloaded",
"register_on_platform_loaded", "register_on_platform_loaded",
"register_on_waiting_llm_request", "register_on_waiting_llm_request",
"register_permission_type", "register_permission_type",
+1 -1
View File
@@ -1,6 +1,6 @@
import warnings import warnings
from astrbot.core.star import StarMetadata, star_map from astrbot.core.star.star import StarMetadata, star_map
_warned_register_star = False _warned_register_star = False
+38 -5
View File
@@ -11,7 +11,6 @@ from astrbot.core.agent.agent import Agent
from astrbot.core.agent.handoff import HandoffTool from astrbot.core.agent.handoff import HandoffTool
from astrbot.core.agent.hooks import BaseAgentRunHooks from astrbot.core.agent.hooks import BaseAgentRunHooks
from astrbot.core.agent.tool import FunctionTool 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.message.message_event_result import MessageEventResult
from astrbot.core.provider.func_tool_manager import PY_TO_JSON_TYPE, SUPPORTED_TYPES from astrbot.core.provider.func_tool_manager import PY_TO_JSON_TYPE, SUPPORTED_TYPES
from astrbot.core.provider.register import llm_tools from astrbot.core.provider.register import llm_tools
@@ -357,6 +356,40 @@ def register_on_plugin_error(**kwargs):
return decorator 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): def register_on_waiting_llm_request(**kwargs):
"""当等待调用 LLM 时的通知事件(在获取锁之前) """当等待调用 LLM 时的通知事件(在获取锁之前)
@@ -583,7 +616,7 @@ class RegisteringAgent:
kwargs["registering_agent"] = self kwargs["registering_agent"] = self
return register_llm_tool(*args, **kwargs) return register_llm_tool(*args, **kwargs)
def __init__(self, agent: Agent[AstrAgentContext]) -> None: def __init__(self, agent: Agent[Any]) -> None:
self._agent = agent self._agent = agent
@@ -591,7 +624,7 @@ def register_agent(
name: str, name: str,
instruction: str, instruction: str,
tools: list[str | FunctionTool] | None = None, tools: list[str | FunctionTool] | None = None,
run_hooks: BaseAgentRunHooks[AstrAgentContext] | None = None, run_hooks: BaseAgentRunHooks[Any] | None = None,
): ):
"""注册一个 Agent """注册一个 Agent
@@ -605,12 +638,12 @@ def register_agent(
tools_ = tools or [] tools_ = tools or []
def decorator(awaitable: Callable[..., Awaitable[Any]]): def decorator(awaitable: Callable[..., Awaitable[Any]]):
AstrAgent = Agent[AstrAgentContext] AstrAgent = Agent[Any]
agent = AstrAgent( agent = AstrAgent(
name=name, name=name,
instructions=instruction, instructions=instruction,
tools=tools_, tools=tools_,
run_hooks=run_hooks or BaseAgentRunHooks[AstrAgentContext](), run_hooks=run_hooks or BaseAgentRunHooks[Any](),
) )
handoff_tool = HandoffTool(agent=agent) handoff_tool = HandoffTool(agent=agent)
handoff_tool.handler = awaitable handoff_tool.handler = awaitable
+20
View File
@@ -105,6 +105,22 @@ class StarHandlerRegistry(Generic[T]):
plugins_name: list[str] | None = None, plugins_name: list[str] | None = None,
) -> list[StarHandlerMetadata[Callable[..., Awaitable[Any]]]]: ... ) -> 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 @overload
def get_handlers_by_event_type( def get_handlers_by_event_type(
self, self,
@@ -144,6 +160,8 @@ class StarHandlerRegistry(Generic[T]):
not in ( not in (
EventType.OnAstrBotLoadedEvent, EventType.OnAstrBotLoadedEvent,
EventType.OnPlatformLoadedEvent, EventType.OnPlatformLoadedEvent,
EventType.OnPluginLoadedEvent,
EventType.OnPluginUnloadedEvent,
) )
and not plugin.reserved and not plugin.reserved
): ):
@@ -201,6 +219,8 @@ class EventType(enum.Enum):
OnLLMToolRespondEvent = enum.auto() # 调用函数工具后 OnLLMToolRespondEvent = enum.auto() # 调用函数工具后
OnAfterMessageSentEvent = enum.auto() # 发送消息后 OnAfterMessageSentEvent = enum.auto() # 发送消息后
OnPluginErrorEvent = enum.auto() # 插件处理消息异常时 OnPluginErrorEvent = enum.auto() # 插件处理消息异常时
OnPluginLoadedEvent = enum.auto() # 插件加载完成
OnPluginUnloadedEvent = enum.auto() # 插件卸载完成
H = TypeVar("H", bound=Callable[..., Any]) H = TypeVar("H", bound=Callable[..., Any])
+88 -12
View File
@@ -33,7 +33,7 @@ from .command_management import sync_command_configs
from .context import Context from .context import Context
from .filter.permission import PermissionType, PermissionTypeFilter from .filter.permission import PermissionType, PermissionTypeFilter
from .star import star_map, star_registry 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 from .updator import PluginUpdator
try: try:
@@ -49,10 +49,13 @@ class PluginVersionIncompatibleError(Exception):
class PluginManager: class PluginManager:
def __init__(self, context: Context, config: AstrBotConfig) -> None: def __init__(self, context: Context, config: AstrBotConfig) -> None:
from .star_tools import StarTools
self.updator = PluginUpdator() self.updator = PluginUpdator()
self.context = context self.context = context
self.context._star_manager = self # type: ignore self.context._star_manager = self # type: ignore
StarTools.initialize(context)
self.config = config self.config = config
self.plugin_store_path = get_astrbot_plugin_path() self.plugin_store_path = get_astrbot_plugin_path()
@@ -385,6 +388,33 @@ class PluginManager:
except KeyError: except KeyError:
logger.warning(f"模块 {module_name} 未载入") 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): async def reload_failed_plugin(self, dir_name):
""" """
重新加载未注册加载失败的插件 重新加载未注册加载失败的插件
@@ -395,17 +425,21 @@ class PluginManager:
- success (bool): 重载是否成功 - success (bool): 重载是否成功
- error_message (str|None): 错误信息成功时为 None - error_message (str|None): 错误信息成功时为 None
""" """
async with self._pm_lock: async with self._pm_lock:
if dir_name in self.failed_plugin_dict: if dir_name not in self.failed_plugin_dict:
success, error = await self.load(specified_dir_name=dir_name) return False, "插件不存在于失败列表中"
if success:
self.failed_plugin_dict.pop(dir_name, None) self._cleanup_plugin_state(dir_name)
if not self.failed_plugin_dict:
self.failed_plugin_info = "" success, error = await self.load(specified_dir_name=dir_name)
return success, None if success:
else: self.failed_plugin_dict.pop(dir_name, None)
return False, error if not self.failed_plugin_dict:
return False, "插件不存在于失败列表中" self.failed_plugin_info = ""
return success, None
else:
return False, error
async def reload(self, specified_plugin_name=None): async def reload(self, specified_plugin_name=None):
"""重新加载插件 """重新加载插件
@@ -529,8 +563,19 @@ class PluginManager:
requirements_path=requirements_path, requirements_path=requirements_path,
) )
except Exception as e: 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}") 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 continue
# 检查 _conf_schema.json # 检查 _conf_schema.json
@@ -772,6 +817,19 @@ class PluginManager:
if hasattr(metadata.star_cls, "initialize") and metadata.star_cls: if hasattr(metadata.star_cls, "initialize") and metadata.star_cls:
await metadata.star_cls.initialize() 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: except BaseException as e:
logger.error(f"----- 插件 {root_dir_name} 载入失败 -----") logger.error(f"----- 插件 {root_dir_name} 载入失败 -----")
errors = traceback.format_exc() errors = traceback.format_exc()
@@ -784,6 +842,11 @@ class PluginManager:
"traceback": errors, "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 # 清除 pip.main 导致的多余的 logging handlers
for handler in logging.root.handlers[:]: for handler in logging.root.handlers[:]:
@@ -1159,6 +1222,19 @@ class PluginManager:
elif "terminate" in star_metadata.star_cls_type.__dict__: elif "terminate" in star_metadata.star_cls_type.__dict__:
await star_metadata.star_cls.terminate() await star_metadata.star_cls.terminate()
# 触发插件卸载事件
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: async def turn_on_plugin(self, plugin_name: str) -> None:
plugin = self.context.get_registered_star(plugin_name) plugin = self.context.get_registered_star(plugin_name)
if plugin is None: if plugin is None:
+21 -1
View File
@@ -1,4 +1,5 @@
from datetime import datetime from datetime import datetime
from typing import Any
from pydantic import Field from pydantic import Field
from pydantic.dataclasses import dataclass from pydantic.dataclasses import dataclass
@@ -8,6 +9,14 @@ from astrbot.core.agent.tool import FunctionTool, ToolExecResult
from astrbot.core.astr_agent_context import AstrAgentContext 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 @dataclass
class CreateActiveCronTool(FunctionTool[AstrAgentContext]): class CreateActiveCronTool(FunctionTool[AstrAgentContext]):
name: str = "create_future_task" name: str = "create_future_task"
@@ -119,9 +128,15 @@ class DeleteCronJobTool(FunctionTool[AstrAgentContext]):
cron_mgr = context.context.context.cron_manager cron_mgr = context.context.context.cron_manager
if cron_mgr is None: if cron_mgr is None:
return "error: cron manager is not available." return "error: cron manager is not available."
current_umo = context.context.event.unified_msg_origin
job_id = kwargs.get("job_id") job_id = kwargs.get("job_id")
if not job_id: if not job_id:
return "error: job_id is required." 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)) await cron_mgr.delete_job(str(job_id))
return f"Deleted cron job {job_id}." return f"Deleted cron job {job_id}."
@@ -148,8 +163,13 @@ class ListCronJobsTool(FunctionTool[AstrAgentContext]):
cron_mgr = context.context.context.cron_manager cron_mgr = context.context.context.cron_manager
if cron_mgr is None: if cron_mgr is None:
return "error: cron manager is not available." return "error: cron manager is not available."
current_umo = context.context.event.unified_msg_origin
job_type = kwargs.get("job_type") 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: if not jobs:
return "No cron jobs found." return "No cron jobs found."
lines = [] lines = []
@@ -46,5 +46,22 @@ class ActiveEventRegistry:
count += 1 count += 1
return count 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() active_event_registry = ActiveEventRegistry()
@@ -19,7 +19,7 @@ from astrbot.core.message.components import (
from astrbot.core.platform.astr_message_event import AstrMessageEvent from astrbot.core.platform.astr_message_event import AstrMessageEvent
from astrbot.core.utils.string_utils import normalize_and_dedupe_strings from astrbot.core.utils.string_utils import normalize_and_dedupe_strings
from .image_refs import looks_like_image_file_name, normalize_file_like_url from .image_refs import looks_like_image_file_name
from .settings import SETTINGS, QuotedMessageParserSettings from .settings import SETTINGS, QuotedMessageParserSettings
_FORWARD_PLACEHOLDER_PATTERN = re.compile( _FORWARD_PLACEHOLDER_PATTERN = re.compile(
@@ -296,11 +296,11 @@ def _parse_onebot_segments(
or "file" or "file"
) )
text_parts.append(f"[File:{file_name}]") text_parts.append(f"[File:{file_name}]")
candidate_url = seg_data.get("url") candidate_url = seg_data.get("url", "")
if ( if (
isinstance(candidate_url, str) isinstance(candidate_url, str)
and candidate_url.strip() and candidate_url.strip()
and looks_like_image_file_name(normalize_file_like_url(candidate_url)) and looks_like_image_file_name(candidate_url)
): ):
image_refs.append(candidate_url.strip()) image_refs.append(candidate_url.strip())
candidate_file = seg_data.get("file") candidate_file = seg_data.get("file")
@@ -308,11 +308,7 @@ def _parse_onebot_segments(
isinstance(candidate_file, str) isinstance(candidate_file, str)
and candidate_file.strip() and candidate_file.strip()
and looks_like_image_file_name( and looks_like_image_file_name(
normalize_file_like_url( seg_data.get("name") or seg_data.get("file_name") or candidate_file
seg_data.get("name")
or seg_data.get("file_name")
or candidate_file
)
) )
): ):
image_refs.append(candidate_file.strip()) image_refs.append(candidate_file.strip())
@@ -368,7 +364,9 @@ def _extract_text_forward_ids_and_images_from_forward_nodes(
if not isinstance(node, dict): if not isinstance(node, dict):
continue continue
sender = node.get("sender") if isinstance(node.get("sender"), dict) else {} sender = node.get("sender")
if not isinstance(sender, dict):
sender = {}
sender_name = ( sender_name = (
sender.get("nickname") sender.get("nickname")
or sender.get("card") or sender.get("card")
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
from typing import Any from collections.abc import Awaitable
from typing import Any, Protocol
from astrbot import logger from astrbot import logger
from astrbot.core.platform.astr_message_event import AstrMessageEvent from astrbot.core.platform.astr_message_event import AstrMessageEvent
@@ -17,6 +18,10 @@ def _unwrap_action_response(ret: dict[str, Any] | None) -> dict[str, Any]:
return ret return ret
class CallAction(Protocol):
def __call__(self, action: str, **params: Any) -> Awaitable[Any] | Any: ...
class OneBotClient: class OneBotClient:
def __init__( def __init__(
self, self,
@@ -27,7 +32,7 @@ class OneBotClient:
self._settings = settings self._settings = settings
@staticmethod @staticmethod
def _resolve_call_action(event: AstrMessageEvent): def _resolve_call_action(event: AstrMessageEvent) -> CallAction | None:
bot = getattr(event, "bot", None) bot = getattr(event, "bot", None)
api = getattr(bot, "api", None) api = getattr(bot, "api", None)
call_action = getattr(api, "call_action", None) call_action = getattr(api, "call_action", None)
+66 -94
View File
@@ -1,6 +1,5 @@
import asyncio import asyncio
import json import json
import mimetypes
import os import os
import re import re
import uuid import uuid
@@ -13,7 +12,15 @@ from quart import g, make_response, request, send_file
from astrbot.core import logger, sp from astrbot.core import logger, sp
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
from astrbot.core.db import BaseDatabase 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.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 astrbot.core.utils.astrbot_path import get_astrbot_data_path
from .route import Response, Route, RouteContext from .route import Response, Route, RouteContext
@@ -41,6 +48,7 @@ class ChatRoute(Route):
"/chat/new_session": ("GET", self.new_session), "/chat/new_session": ("GET", self.new_session),
"/chat/sessions": ("GET", self.get_sessions), "/chat/sessions": ("GET", self.get_sessions),
"/chat/get_session": ("GET", self.get_session), "/chat/get_session": ("GET", self.get_session),
"/chat/stop": ("POST", self.stop_session),
"/chat/delete_session": ("GET", self.delete_webchat_session), "/chat/delete_session": ("GET", self.delete_webchat_session),
"/chat/update_session_display_name": ( "/chat/update_session_display_name": (
"POST", "POST",
@@ -163,78 +171,24 @@ class ChatRoute(Route):
) )
async def _build_user_message_parts(self, message: str | list) -> list[dict]: async def _build_user_message_parts(self, message: str | list) -> list[dict]:
"""构建用户消息的部分列表 """构建用户消息的部分列表"""
return await build_webchat_message_parts(
Args: message,
message: 文本消息 (str) 或消息段列表 (list) get_attachment_by_id=self.db.get_attachment_by_id,
""" strict=False,
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
async def _create_attachment_from_file( async def _create_attachment_from_file(
self, filename: str, attach_type: str self, filename: str, attach_type: str
) -> dict | None: ) -> dict | None:
"""从本地文件创建 attachment 并返回消息部分 """从本地文件创建 attachment 并返回消息部分"""
return await create_attachment_part_from_existing_file(
用于处理 bot 回复中的媒体文件 filename,
attach_type=attach_type,
Args: insert_attachment=self.db.insert_attachment,
filename: 存储的文件名 attachments_dir=self.attachments_dir,
attach_type: 附件类型 (image, record, file, video) fallback_dirs=[self.legacy_img_dir],
"""
file_path = os.path.join(self.attachments_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,
) )
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( def _extract_web_search_refs(
self, accumulated_text: str, accumulated_parts: list self, accumulated_text: str, accumulated_parts: list
@@ -348,21 +302,6 @@ class ChatRoute(Route):
selected_model = post_data.get("selected_model") selected_model = post_data.get("selected_model")
enable_streaming = post_data.get("enable_streaming", True) 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: if not session_id:
return Response().error("session_id is empty").__dict__ return Response().error("session_id is empty").__dict__
@@ -370,6 +309,12 @@ class ChatRoute(Route):
# 构建用户消息段(包含 path 用于传递给 adapter # 构建用户消息段(包含 path 用于传递给 adapter
message_parts = await self._build_user_message_parts(message) 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()) message_id = str(uuid.uuid4())
back_queue = webchat_queue_mgr.get_or_create_back_queue( back_queue = webchat_queue_mgr.get_or_create_back_queue(
@@ -466,13 +411,13 @@ class ChatRoute(Route):
if tc_id in tool_calls: if tc_id in tool_calls:
tool_calls[tc_id]["result"] = tcr.get("result") tool_calls[tc_id]["result"] = tcr.get("result")
tool_calls[tc_id]["finished_ts"] = tcr.get("ts") tool_calls[tc_id]["finished_ts"] = tcr.get("ts")
accumulated_parts.append( accumulated_parts.append(
{ {
"type": "tool_call", "type": "tool_call",
"tool_calls": [tool_calls[tc_id]], "tool_calls": [tool_calls[tc_id]],
} }
) )
tool_calls.pop(tc_id, None) tool_calls.pop(tc_id, None)
elif chain_type == "reasoning": elif chain_type == "reasoning":
accumulated_reasoning += result_text accumulated_reasoning += result_text
elif streaming: elif streaming:
@@ -575,10 +520,7 @@ class ChatRoute(Route):
), ),
) )
message_parts_for_storage = [] message_parts_for_storage = strip_message_parts_path_fields(message_parts)
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)
await self.platform_history_mgr.insert( await self.platform_history_mgr.insert(
platform_id="webchat", platform_id="webchat",
@@ -603,6 +545,36 @@ class ChatRoute(Route):
response.timeout = None # fix SSE auto disconnect issue response.timeout = None # fix SSE auto disconnect issue
return response 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): async def delete_webchat_session(self):
"""Delete a Platform session and all its related data.""" """Delete a Platform session and all its related data."""
session_id = request.args.get("session_id") session_id = request.args.get("session_id")
+17 -1
View File
@@ -754,6 +754,22 @@ class ConfigRoute(Route):
if not provider_type: if not provider_type:
return Response().error("provider_config 缺少 type 字段").__dict__ 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 类 # 获取对应的 provider 类
if provider_type not in provider_cls_map: if provider_type not in provider_cls_map:
return ( return (
@@ -779,7 +795,7 @@ class ConfigRoute(Route):
if inspect.iscoroutinefunction(init_fn): if inspect.iscoroutinefunction(init_fn):
await init_fn() await init_fn()
# 获取嵌入向量维度 # 通过实际请求验证当前 embedding_dimensions 是否可用
vec = await inst.get_embedding("echo") vec = await inst.get_embedding("echo")
dim = len(vec) dim = len(vec)
+3 -1
View File
@@ -148,7 +148,6 @@ class ConversationRoute(Route):
user_id = data.get("user_id") user_id = data.get("user_id")
cid = data.get("cid") cid = data.get("cid")
title = data.get("title") title = data.get("title")
persona_id = data.get("persona_id", "")
if not user_id or not cid: if not user_id or not cid:
return Response().error("缺少必要参数: user_id 和 cid").__dict__ return Response().error("缺少必要参数: user_id 和 cid").__dict__
@@ -158,6 +157,9 @@ class ConversationRoute(Route):
) )
if not conversation: if not conversation:
return Response().error("对话不存在").__dict__ return Response().error("对话不存在").__dict__
persona_id = data.get("persona_id", conversation.persona_id)
if title is not None or persona_id is not None: if title is not None or persona_id is not None:
await self.conv_mgr.update_conversation( await self.conv_mgr.update_conversation(
unified_msg_origin=user_id, unified_msg_origin=user_id,
+509 -3
View File
@@ -1,6 +1,7 @@
import asyncio import asyncio
import json import json
import os import os
import re
import time import time
import uuid import uuid
import wave import wave
@@ -10,9 +11,16 @@ import jwt
from quart import websocket from quart import websocket
from astrbot import logger from astrbot import logger
from astrbot.core import sp
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle 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.platform.sources.webchat.webchat_queue_mgr import webchat_queue_mgr
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from astrbot.core.utils.astrbot_path import get_astrbot_data_path, get_astrbot_temp_path
from .route import Route, RouteContext from .route import Route, RouteContext
@@ -30,6 +38,9 @@ class LiveChatSession:
self.audio_frames: list[bytes] = [] self.audio_frames: list[bytes] = []
self.current_stamp: str | None = None self.current_stamp: str | None = None
self.temp_audio_path: 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) -> None: def start_speaking(self, stamp: str) -> None:
"""开始说话""" """开始说话"""
@@ -106,13 +117,26 @@ class LiveChatRoute(Route):
self.core_lifecycle = core_lifecycle self.core_lifecycle = core_lifecycle
self.db = db self.db = db
self.plugin_manager = core_lifecycle.plugin_manager self.plugin_manager = core_lifecycle.plugin_manager
self.platform_history_mgr = core_lifecycle.platform_message_history_manager
self.sessions: dict[str, LiveChatSession] = {} 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 路由 # 注册 WebSocket 路由
self.app.websocket("/api/live_chat/ws")(self.live_chat_ws) 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) -> None: async def live_chat_ws(self) -> None:
"""Live Chat WebSocket 处理器""" """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 不能通过 header 传递 token,需要从 query 参数获取
# 注意:WebSocket 上下文使用 websocket.args 而不是 request.args # 注意:WebSocket 上下文使用 websocket.args 而不是 request.args
token = websocket.args.get("token") token = websocket.args.get("token")
@@ -140,7 +164,11 @@ class LiveChatRoute(Route):
try: try:
while True: while True:
message = await websocket.receive_json() 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: except Exception as e:
logger.error(f"[Live Chat] WebSocket 错误: {e}", exc_info=True) logger.error(f"[Live Chat] WebSocket 错误: {e}", exc_info=True)
@@ -148,10 +176,488 @@ class LiveChatRoute(Route):
finally: finally:
# 清理会话 # 清理会话
if session_id in self.sessions: if session_id in self.sessions:
await self._cleanup_chat_subscriptions(live_session)
live_session.cleanup() live_session.cleanup()
del self.sessions[session_id] del self.sessions[session_id]
logger.info(f"[Live Chat] WebSocket 连接关闭: {username}") logger.info(f"[Live Chat] WebSocket 连接关闭: {username}")
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"<ref>(.*?)</ref>", 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: async def _handle_message(self, session: LiveChatSession, message: dict) -> None:
"""处理 WebSocket 消息""" """处理 WebSocket 消息"""
msg_type = message.get("t") # 使用 t 代替 type msg_type = message.get("t") # 使用 t 代替 type
+360 -81
View File
@@ -1,15 +1,22 @@
from pathlib import Path import asyncio
import hashlib
import json
from uuid import uuid4 from uuid import uuid4
from quart import g, request from quart import g, request, websocket
from astrbot.core import logger from astrbot.core import logger
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
from astrbot.core.db import BaseDatabase from astrbot.core.db import BaseDatabase
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.message_session import MessageSesion 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 .chat import ChatRoute
from .route import Response, Route, RouteContext from .route import Response, Route, RouteContext
@@ -37,6 +44,7 @@ class OpenApiRoute(Route):
"/v1/im/bots": ("GET", self.get_bots), "/v1/im/bots": ("GET", self.get_bots),
} }
self.register_routes() self.register_routes()
self.app.websocket("/api/v1/chat/ws")(self.chat_ws)
@staticmethod @staticmethod
def _resolve_open_username( def _resolve_open_username(
@@ -181,6 +189,348 @@ class OpenApiRoute(Route):
finally: finally:
g.username = original_username 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): async def upload_file(self):
return await self.chat_route.post_file() return await self.chat_route.post_file()
@@ -254,83 +604,12 @@ class OpenApiRoute(Route):
async def _build_message_chain_from_payload( async def _build_message_chain_from_payload(
self, self,
message_payload: str | list, message_payload: str | list,
) -> MessageChain: ):
if isinstance(message_payload, str): return await build_message_chain_from_payload(
text = message_payload.strip() message_payload,
if not text: get_attachment_by_id=self.db.get_attachment_by_id,
raise ValueError("Message is empty") strict=True,
return MessageChain(chain=[Plain(text=text)]) )
if not isinstance(message_payload, list):
raise ValueError("message must be a string or list")
components = []
has_content = False
for part in message_payload:
if not isinstance(part, dict):
raise ValueError("message part must be an object")
part_type = str(part.get("type", "")).strip()
if part_type == "plain":
text = str(part.get("text", ""))
if text:
has_content = True
components.append(Plain(text=text))
continue
if part_type == "reply":
message_id = part.get("message_id")
if message_id is None:
raise ValueError("reply part missing message_id")
components.append(
Reply(
id=str(message_id),
message_str=str(part.get("selected_text", "")),
chain=[],
)
)
continue
if part_type not in {"image", "record", "file", "video"}:
raise ValueError(f"unsupported message part type: {part_type}")
has_content = True
file_path: Path | None = None
resolved_type = part_type
filename = str(part.get("filename", "")).strip()
attachment_id = part.get("attachment_id")
if attachment_id:
attachment = await self.db.get_attachment_by_id(str(attachment_id))
if not attachment:
raise ValueError(f"attachment not found: {attachment_id}")
file_path = Path(attachment.path)
resolved_type = attachment.type
if not filename:
filename = file_path.name
else:
raise ValueError(f"{part_type} part missing attachment_id")
if not file_path.exists():
raise ValueError(f"file not found: {file_path!s}")
file_path_str = str(file_path.resolve())
if resolved_type == "image":
components.append(Image.fromFileSystem(file_path_str))
elif resolved_type == "record":
components.append(Record.fromFileSystem(file_path_str))
elif resolved_type == "video":
components.append(Video.fromFileSystem(file_path_str))
else:
components.append(
File(name=filename or file_path.name, file=file_path_str)
)
if not components or not has_content:
raise ValueError("Message content is empty (reply only is not allowed)")
return MessageChain(chain=components)
async def send_message(self): async def send_message(self):
post_data = await request.json or {} post_data = await request.json or {}
+28 -8
View File
@@ -698,10 +698,16 @@ class PluginRoute(Route):
logger.warning(f"插件 {plugin_name} 目录不存在") logger.warning(f"插件 {plugin_name} 目录不存在")
return Response().error(f"插件 {plugin_name} 目录不存在").__dict__ return Response().error(f"插件 {plugin_name} 目录不存在").__dict__
plugin_dir = os.path.join( if plugin_obj.reserved:
self.plugin_manager.plugin_store_path, plugin_dir = os.path.join(
plugin_obj.root_dir_name or "", 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): if not os.path.isdir(plugin_dir):
logger.warning(f"无法找到插件目录: {plugin_dir}") logger.warning(f"无法找到插件目录: {plugin_dir}")
@@ -735,6 +741,7 @@ class PluginRoute(Route):
logger.debug(f"正在获取插件 {plugin_name} 的更新日志") logger.debug(f"正在获取插件 {plugin_name} 的更新日志")
if not plugin_name: if not plugin_name:
logger.warning("插件名称为空")
return Response().error("插件名称不能为空").__dict__ return Response().error("插件名称不能为空").__dict__
# 查找插件 # 查找插件
@@ -745,15 +752,27 @@ class PluginRoute(Route):
break break
if not plugin_obj: if not plugin_obj:
logger.warning(f"插件 {plugin_name} 不存在")
return Response().error(f"插件 {plugin_name} 不存在").__dict__ return Response().error(f"插件 {plugin_name} 不存在").__dict__
if not plugin_obj.root_dir_name: if not plugin_obj.root_dir_name:
logger.warning(f"插件 {plugin_name} 目录不存在")
return Response().error(f"插件 {plugin_name} 目录不存在").__dict__ return Response().error(f"插件 {plugin_name} 目录不存在").__dict__
plugin_dir = os.path.join( if plugin_obj.reserved:
self.plugin_manager.plugin_store_path, plugin_dir = os.path.join(
plugin_obj.root_dir_name, 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"] changelog_names = ["CHANGELOG.md", "changelog.md", "CHANGELOG", "changelog"]
@@ -773,6 +792,7 @@ class PluginRoute(Route):
return Response().error(f"读取更新日志失败: {e!s}").__dict__ return Response().error(f"读取更新日志失败: {e!s}").__dict__
# 没有找到 changelog 文件,返回 ok 但 content 为 null # 没有找到 changelog 文件,返回 ok 但 content 为 null
logger.warning(f"插件 {plugin_name} 没有更新日志文件")
return Response().ok({"content": None}, "该插件没有更新日志文件").__dict__ return Response().ok({"content": None}, "该插件没有更新日志文件").__dict__
async def get_custom_source(self): async def get_custom_source(self):
+5
View File
@@ -204,6 +204,10 @@ class AstrBotDashboard:
@staticmethod @staticmethod
def _extract_raw_api_key() -> str | None: 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"): if key := request.headers.get("X-API-Key"):
return key.strip() return key.strip()
auth_header = request.headers.get("Authorization", "").strip() auth_header = request.headers.get("Authorization", "").strip()
@@ -217,6 +221,7 @@ class AstrBotDashboard:
def _get_required_open_api_scope(path: str) -> str | None: def _get_required_open_api_scope(path: str) -> str | None:
scope_map = { scope_map = {
"/api/v1/chat": "chat", "/api/v1/chat": "chat",
"/api/v1/chat/ws": "chat",
"/api/v1/chat/sessions": "chat", "/api/v1/chat/sessions": "chat",
"/api/v1/configs": "config", "/api/v1/configs": "config",
"/api/v1/file": "file", "/api/v1/file": "file",
+60
View File
@@ -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)).
+49
View File
@@ -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.
+8 -1
View File
@@ -1,3 +1,10 @@
# AstrBot 管理面板 # AstrBot 管理面板
基于 CodedThemes/Berry 模板开发。 基于 CodedThemes/Berry 模板开发。
## 环境变量
- `VITE_ASTRBOT_RELEASE_BASE_URL`(可选)
- 默认值:`https://github.com/AstrBotDevs/AstrBot/releases`
- 用途:管理面板内“更新到最新版本”外部跳转所使用的 release 基地址。集成方可按需覆盖(例如 Desktop 指向其自身发布页)。
- 建议传入仓库的 `.../releases` 基地址(不带 `/latest`)。
+8
View File
@@ -1 +1,9 @@
/// <reference types="vite/client" /> /// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_ASTRBOT_RELEASE_BASE_URL?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
+18 -1
View File
@@ -10,6 +10,7 @@
:selectedSessions="selectedSessions" :selectedSessions="selectedSessions"
:currSessionId="currSessionId" :currSessionId="currSessionId"
:selectedProjectId="selectedProjectId" :selectedProjectId="selectedProjectId"
:transportMode="transportMode"
:isDark="isDark" :isDark="isDark"
:chatboxMode="chatboxMode" :chatboxMode="chatboxMode"
:isMobile="isMobile" :isMobile="isMobile"
@@ -26,6 +27,7 @@
@createProject="showCreateProjectDialog" @createProject="showCreateProjectDialog"
@editProject="showEditProjectDialog" @editProject="showEditProjectDialog"
@deleteProject="handleDeleteProject" @deleteProject="handleDeleteProject"
@updateTransportMode="setTransportMode"
/> />
<!-- 右侧聊天内容区域 --> <!-- 右侧聊天内容区域 -->
@@ -77,12 +79,14 @@
:stagedAudioUrl="stagedAudioUrl" :stagedAudioUrl="stagedAudioUrl"
:stagedFiles="stagedNonImageFiles" :stagedFiles="stagedNonImageFiles"
:disabled="isStreaming" :disabled="isStreaming"
:is-running="isStreaming || isConvRunning"
:enableStreaming="enableStreaming" :enableStreaming="enableStreaming"
:isRecording="isRecording" :isRecording="isRecording"
:session-id="currSessionId || null" :session-id="currSessionId || null"
:current-session="getCurrentSession" :current-session="getCurrentSession"
:replyTo="replyTo" :replyTo="replyTo"
@send="handleSendMessage" @send="handleSendMessage"
@stop="handleStopMessage"
@toggleStreaming="toggleStreaming" @toggleStreaming="toggleStreaming"
@removeImage="removeImage" @removeImage="removeImage"
@removeAudio="removeAudio" @removeAudio="removeAudio"
@@ -106,12 +110,14 @@
:stagedAudioUrl="stagedAudioUrl" :stagedAudioUrl="stagedAudioUrl"
:stagedFiles="stagedNonImageFiles" :stagedFiles="stagedNonImageFiles"
:disabled="isStreaming" :disabled="isStreaming"
:is-running="isStreaming || isConvRunning"
:enableStreaming="enableStreaming" :enableStreaming="enableStreaming"
:isRecording="isRecording" :isRecording="isRecording"
:session-id="currSessionId || null" :session-id="currSessionId || null"
:current-session="getCurrentSession" :current-session="getCurrentSession"
:replyTo="replyTo" :replyTo="replyTo"
@send="handleSendMessage" @send="handleSendMessage"
@stop="handleStopMessage"
@toggleStreaming="toggleStreaming" @toggleStreaming="toggleStreaming"
@removeImage="removeImage" @removeImage="removeImage"
@removeAudio="removeAudio" @removeAudio="removeAudio"
@@ -134,12 +140,14 @@
:stagedAudioUrl="stagedAudioUrl" :stagedAudioUrl="stagedAudioUrl"
:stagedFiles="stagedNonImageFiles" :stagedFiles="stagedNonImageFiles"
:disabled="isStreaming" :disabled="isStreaming"
:is-running="isStreaming || isConvRunning"
:enableStreaming="enableStreaming" :enableStreaming="enableStreaming"
:isRecording="isRecording" :isRecording="isRecording"
:session-id="currSessionId || null" :session-id="currSessionId || null"
:current-session="getCurrentSession" :current-session="getCurrentSession"
:replyTo="replyTo" :replyTo="replyTo"
@send="handleSendMessage" @send="handleSendMessage"
@stop="handleStopMessage"
@toggleStreaming="toggleStreaming" @toggleStreaming="toggleStreaming"
@removeImage="removeImage" @removeImage="removeImage"
@removeAudio="removeAudio" @removeAudio="removeAudio"
@@ -295,10 +303,14 @@ const {
isStreaming, isStreaming,
isConvRunning, isConvRunning,
enableStreaming, enableStreaming,
transportMode,
currentSessionProject, currentSessionProject,
getSessionMessages: getSessionMsg, getSessionMessages: getSessionMsg,
sendMessage: sendMsg, sendMessage: sendMsg,
toggleStreaming stopMessage: stopMsg,
toggleStreaming,
setTransportMode,
cleanupTransport
} = useMessages(currSessionId, getMediaFile, updateSessionTitle, getSessions); } = useMessages(currSessionId, getMediaFile, updateSessionTitle, getSessions);
// //
@@ -631,6 +643,10 @@ async function handleSendMessage() {
} }
} }
async function handleStopMessage() {
await stopMsg();
}
// //
watch( watch(
() => route.path, () => route.path,
@@ -684,6 +700,7 @@ onMounted(() => {
onBeforeUnmount(() => { onBeforeUnmount(() => {
window.removeEventListener('resize', checkMobile); window.removeEventListener('resize', checkMobile);
cleanupMediaCache(); cleanupMediaCache();
cleanupTransport();
}); });
</script> </script>
+25 -2
View File
@@ -94,8 +94,29 @@
{{ isRecording ? tm('voice.speaking') : tm('voice.startRecording') }} {{ isRecording ? tm('voice.speaking') : tm('voice.startRecording') }}
</v-tooltip> </v-tooltip>
</v-btn> </v-btn>
<v-btn @click="$emit('send')" icon="mdi-send" variant="text" color="deep-purple" <v-btn
:disabled="!canSend" class="send-btn" size="small" /> icon
v-if="isRunning"
@click="$emit('stop')"
variant="text"
class="send-btn"
size="small"
>
<v-icon icon="mdi-stop" variant="text" plain></v-icon>
<v-tooltip activator="parent" location="top">
{{ tm('input.stopGenerating') }}
</v-tooltip>
</v-btn>
<v-btn
v-else
@click="$emit('send')"
icon="mdi-send"
variant="text"
color="deep-purple"
:disabled="!canSend"
class="send-btn"
size="small"
/>
</div> </div>
</div> </div>
</div> </div>
@@ -160,6 +181,7 @@ interface Props {
disabled: boolean; disabled: boolean;
enableStreaming: boolean; enableStreaming: boolean;
isRecording: boolean; isRecording: boolean;
isRunning: boolean;
sessionId?: string | null; sessionId?: string | null;
currentSession?: Session | null; currentSession?: Session | null;
configId?: string | null; configId?: string | null;
@@ -177,6 +199,7 @@ const props = withDefaults(defineProps<Props>(), {
const emit = defineEmits<{ const emit = defineEmits<{
'update:prompt': [value: string]; 'update:prompt': [value: string];
send: []; send: [];
stop: [];
toggleStreaming: []; toggleStreaming: [];
removeImage: [index: number]; removeImage: [index: number];
removeAudio: []; removeAudio: [];
@@ -117,6 +117,27 @@
<v-list-item-title>{{ isDark ? tm('modes.lightMode') : tm('modes.darkMode') }}</v-list-item-title> <v-list-item-title>{{ isDark ? tm('modes.lightMode') : tm('modes.darkMode') }}</v-list-item-title>
</v-list-item> </v-list-item>
<!-- 通信传输模式 -->
<v-list-item class="styled-menu-item">
<template v-slot:prepend>
<v-icon>mdi-lan-connect</v-icon>
</template>
<v-list-item-title>{{ tm('transport.title') }}</v-list-item-title>
<template v-slot:append>
<v-select
:model-value="transportMode"
:items="transportOptions"
item-title="label"
item-value="value"
density="compact"
variant="underlined"
hide-details
class="transport-mode-select"
@update:model-value="handleTransportModeChange"
/>
</template>
</v-list-item>
<!-- 全屏/退出全屏 --> <!-- 全屏/退出全屏 -->
<v-list-item class="styled-menu-item" @click="$emit('toggleFullscreen')"> <v-list-item class="styled-menu-item" @click="$emit('toggleFullscreen')">
<template v-slot:prepend> <template v-slot:prepend>
@@ -156,6 +177,7 @@ interface Props {
selectedSessions: string[]; selectedSessions: string[];
currSessionId: string; currSessionId: string;
selectedProjectId?: string | null; selectedProjectId?: string | null;
transportMode: 'sse' | 'websocket';
isDark: boolean; isDark: boolean;
chatboxMode: boolean; chatboxMode: boolean;
isMobile: boolean; isMobile: boolean;
@@ -179,6 +201,7 @@ const emit = defineEmits<{
createProject: []; createProject: [];
editProject: [project: Project]; editProject: [project: Project];
deleteProject: [projectId: string]; deleteProject: [projectId: string];
updateTransportMode: [mode: 'sse' | 'websocket'];
}>(); }>();
const { t } = useI18n(); const { t } = useI18n();
@@ -188,6 +211,10 @@ const confirmDialog = useConfirmDialog();
const sidebarCollapsed = ref(true); const sidebarCollapsed = ref(true);
const showProviderConfigDialog = ref(false); const showProviderConfigDialog = ref(false);
const transportOptions = [
{ label: tm('transport.sse'), value: 'sse' as const },
{ label: tm('transport.websocket'), value: 'websocket' as const }
];
// localStorage // localStorage
const savedCollapsedState = localStorage.getItem('sidebarCollapsed'); const savedCollapsedState = localStorage.getItem('sidebarCollapsed');
@@ -209,6 +236,12 @@ async function handleDeleteConversation(session: Session) {
emit('deleteConversation', session.session_id); emit('deleteConversation', session.session_id);
} }
} }
function handleTransportModeChange(mode: string | null) {
if (mode === 'sse' || mode === 'websocket') {
emit('updateTransportMode', mode);
}
}
</script> </script>
<style scoped> <style scoped>
@@ -361,4 +394,8 @@ async function handleDeleteConversation(session: Session) {
display: flex; display: flex;
justify-content: center; justify-content: center;
} }
.transport-mode-select {
min-width: 120px;
}
</style> </style>
+162 -76
View File
@@ -143,8 +143,8 @@
</v-card> </v-card>
</v-menu> </v-menu>
<v-btn :icon="getCopyIcon(index)" size="x-small" variant="text" class="copy-message-btn" <v-btn :icon="getCopyIcon(index)" size="x-small" variant="text" class="copy-message-btn"
:class="{ 'copy-success': isCopySuccess(index) }" :class="{ 'copy-success': isCopySuccess(index), 'copy-failed': isCopyFailure(index) }"
@click="copyBotMessage(msg.content.message, index)" :title="t('core.common.copy')" /> @click="copyBotMessage(msg.content.message, index)" :title="getCopyTitle(index)" />
<v-btn icon="mdi-reply-outline" size="x-small" variant="text" class="reply-message-btn" <v-btn icon="mdi-reply-outline" size="x-small" variant="text" class="reply-message-btn"
@click="$emit('replyMessage', msg, index)" :title="tm('actions.reply')" /> @click="$emit('replyMessage', msg, index)" :title="tm('actions.reply')" />
@@ -185,6 +185,7 @@ import 'markstream-vue/index.css'
import 'katex/dist/katex.min.css' import 'katex/dist/katex.min.css'
import 'highlight.js/styles/github.css'; import 'highlight.js/styles/github.css';
import axios from 'axios'; import axios from 'axios';
import { useToast } from '@/utils/toast'
import ReasoningBlock from './message_list_comps/ReasoningBlock.vue'; import ReasoningBlock from './message_list_comps/ReasoningBlock.vue';
import MessagePartsRenderer from './message_list_comps/MessagePartsRenderer.vue'; import MessagePartsRenderer from './message_list_comps/MessagePartsRenderer.vue';
import RefNode from './message_list_comps/RefNode.vue'; import RefNode from './message_list_comps/RefNode.vue';
@@ -226,10 +227,12 @@ export default {
setup() { setup() {
const { t } = useI18n(); const { t } = useI18n();
const { tm } = useModuleI18n('features/chat'); const { tm } = useModuleI18n('features/chat');
const toast = useToast()
return { return {
t, t,
tm tm,
toast
}; };
}, },
provide() { provide() {
@@ -241,6 +244,7 @@ export default {
data() { data() {
return { return {
copiedMessages: new Set(), copiedMessages: new Set(),
copyFailedMessages: new Set(),
isUserNearBottom: true, isUserNearBottom: true,
scrollThreshold: 1, scrollThreshold: 1,
scrollTimer: null, scrollTimer: null,
@@ -496,91 +500,142 @@ export default {
}, },
// //
copyCodeToClipboard(code) { tryExecCommandCopy(text) {
navigator.clipboard.writeText(code).then(() => { let textArea = null;
console.log('代码已复制到剪贴板'); try {
}).catch(err => { textArea = document.createElement('textarea');
console.error('复制失败:', err); textArea.value = text;
// API使
const textArea = document.createElement('textarea');
textArea.value = code;
document.body.appendChild(textArea); document.body.appendChild(textArea);
textArea.focus();
textArea.select(); textArea.select();
const ok = document.execCommand('copy');
return ok;
} catch (_) {
return false;
} finally {
try { try {
document.execCommand('copy'); textArea?.remove?.();
console.log('代码已复制到剪贴板 (fallback)'); } catch (_) {
} catch (fallbackErr) { // ignore cleanup errors
console.error('复制失败 (fallback):', fallbackErr);
} }
document.body.removeChild(textArea); }
}); },
async copyTextToClipboard(text) {
// 使
// IP + vite --host
if (this.tryExecCommandCopy(text)) {
return { ok: true, method: 'execCommand' };
}
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text);
return { ok: true, method: 'clipboard' };
} catch (error) {
return { ok: false, method: 'clipboard', error };
}
}
return { ok: false, method: 'unavailable' };
},
async copyWithFeedback(text, messageIndex = null) {
const result = await this.copyTextToClipboard(text);
const ok = !!result?.ok;
if (messageIndex !== null && messageIndex !== undefined) {
if (ok) this.showCopySuccess(messageIndex);
else this.showCopyFailure(messageIndex);
}
if (ok) {
this.toast?.success?.(this.t('core.common.copied'));
} else {
this.toast?.error?.(this.t('core.common.copyFailed'));
}
return result;
},
buildCopyTextFromParts(messageParts) {
if (typeof messageParts === 'string') {
return messageParts.trim();
}
if (!Array.isArray(messageParts)) {
return '';
}
const textContents = messageParts
.filter(part => part && typeof part === 'object' && part.type === 'plain' && part.text)
.map(part => part.text);
let textToCopy = textContents.join('\n');
const imageCount = messageParts.filter(part => part?.type === 'image' && part.embedded_url).length;
if (imageCount > 0) {
if (textToCopy) textToCopy += '\n\n';
textToCopy += `[包含 ${imageCount} 张图片]`;
}
const hasAudio = messageParts.some(part => part?.type === 'record' && part.embedded_url);
if (hasAudio) {
if (textToCopy) textToCopy += '\n\n';
textToCopy += '[包含音频内容]';
}
return String(textToCopy || '').trim();
},
async copyCodeToClipboard(code) {
const text = String(code ?? '');
if (!text) return { ok: false, method: 'empty' };
return await this.copyWithFeedback(text, null);
}, },
// bot // bot
copyBotMessage(messageParts, messageIndex) { async copyBotMessage(messageParts, messageIndex) {
let textToCopy = ''; let textToCopy = this.buildCopyTextFromParts(messageParts);
if (!textToCopy) textToCopy = '[媒体内容]';
if (Array.isArray(messageParts)) { await this.copyWithFeedback(textToCopy, messageIndex);
//
const textContents = messageParts
.filter(part => part.type === 'plain' && part.text)
.map(part => part.text);
textToCopy = textContents.join('\n');
//
const imageCount = messageParts.filter(part => part.type === 'image' && part.embedded_url).length;
if (imageCount > 0) {
if (textToCopy) textToCopy += '\n\n';
textToCopy += `[包含 ${imageCount} 张图片]`;
}
//
const hasAudio = messageParts.some(part => part.type === 'record' && part.embedded_url);
if (hasAudio) {
if (textToCopy) textToCopy += '\n\n';
textToCopy += '[包含音频内容]';
}
}
// 使
if (!textToCopy.trim()) {
textToCopy = '[媒体内容]';
}
navigator.clipboard.writeText(textToCopy).then(() => {
console.log('消息已复制到剪贴板');
this.showCopySuccess(messageIndex);
}).catch(err => {
console.error('复制失败:', err);
// API使
const textArea = document.createElement('textarea');
textArea.value = textToCopy;
document.body.appendChild(textArea);
textArea.select();
try {
document.execCommand('copy');
console.log('消息已复制到剪贴板 (fallback)');
this.showCopySuccess(messageIndex);
} catch (fallbackErr) {
console.error('复制失败 (fallback):', fallbackErr);
}
document.body.removeChild(textArea);
});
}, },
// //
showCopySuccess(messageIndex) { showCopySuccess(messageIndex) {
if (this.copyFailedMessages.has(messageIndex)) {
this.copyFailedMessages.delete(messageIndex);
this.copyFailedMessages = new Set(this.copyFailedMessages);
}
this.copiedMessages.add(messageIndex); this.copiedMessages.add(messageIndex);
this.copiedMessages = new Set(this.copiedMessages);
// 2 // 2
setTimeout(() => { setTimeout(() => {
this.copiedMessages.delete(messageIndex); this.copiedMessages.delete(messageIndex);
this.copiedMessages = new Set(this.copiedMessages);
}, 2000);
},
//
showCopyFailure(messageIndex) {
if (this.copiedMessages.has(messageIndex)) {
this.copiedMessages.delete(messageIndex);
this.copiedMessages = new Set(this.copiedMessages);
}
this.copyFailedMessages.add(messageIndex);
this.copyFailedMessages = new Set(this.copyFailedMessages);
setTimeout(() => {
this.copyFailedMessages.delete(messageIndex);
this.copyFailedMessages = new Set(this.copyFailedMessages);
}, 2000); }, 2000);
}, },
// //
getCopyIcon(messageIndex) { getCopyIcon(messageIndex) {
return this.copiedMessages.has(messageIndex) ? 'mdi-check' : 'mdi-content-copy'; if (this.copiedMessages.has(messageIndex)) return 'mdi-check';
if (this.copyFailedMessages.has(messageIndex)) return 'mdi-alert-circle-outline';
return 'mdi-content-copy';
}, },
// //
@@ -588,6 +643,18 @@ export default {
return this.copiedMessages.has(messageIndex); return this.copiedMessages.has(messageIndex);
}, },
//
isCopyFailure(messageIndex) {
return this.copyFailedMessages.has(messageIndex);
},
//
getCopyTitle(messageIndex) {
if (this.isCopySuccess(messageIndex)) return this.t('core.common.copied');
if (this.isCopyFailure(messageIndex)) return this.t('core.common.copyFailed');
return this.t('core.common.copy');
},
// SVG // SVG
getCopyIconSvg() { getCopyIconSvg() {
return '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>'; return '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>';
@@ -598,6 +665,11 @@ export default {
return '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20,6 9,17 4,12"></polyline></svg>'; return '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20,6 9,17 4,12"></polyline></svg>';
}, },
// SVG
getErrorIconSvg() {
return '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="13"></line><circle cx="12" cy="16.5" r="1"></circle></svg>';
},
// //
initCodeCopyButtons() { initCodeCopyButtons() {
this.$nextTick(() => { this.$nextTick(() => {
@@ -608,15 +680,19 @@ export default {
const button = document.createElement('button'); const button = document.createElement('button');
button.className = 'copy-code-btn'; button.className = 'copy-code-btn';
button.innerHTML = this.getCopyIconSvg(); button.innerHTML = this.getCopyIconSvg();
button.title = '复制代码'; button.title = this.t('core.common.copy');
button.addEventListener('click', () => { button.addEventListener('click', async () => {
this.copyCodeToClipboard(codeBlock.textContent); const res = await this.copyCodeToClipboard(codeBlock.textContent || '');
// const ok = !!res?.ok;
button.innerHTML = this.getSuccessIconSvg(); button.innerHTML = ok ? this.getSuccessIconSvg() : this.getErrorIconSvg();
button.style.color = '#4caf50'; button.style.color = ok
? 'rgb(var(--v-theme-success))'
: 'rgb(var(--v-theme-error))';
button.setAttribute("title", this.t(`core.common.${ok ? "copied" : "copyFailed"}`));
setTimeout(() => { setTimeout(() => {
button.innerHTML = this.getCopyIconSvg(); button.innerHTML = this.getCopyIconSvg();
button.style.color = ''; button.style.color = '';
button.setAttribute("title", this.t('core.common.copy'));
}, 2000); }, 2000);
}); });
pre.style.position = 'relative'; pre.style.position = 'relative';
@@ -1077,13 +1153,23 @@ export default {
} }
.copy-message-btn.copy-success { .copy-message-btn.copy-success {
color: #4caf50; color: rgb(var(--v-theme-success));
opacity: 1; opacity: 1;
} }
.copy-message-btn.copy-success:hover { .copy-message-btn.copy-success:hover {
color: #4caf50; color: rgb(var(--v-theme-success));
background-color: rgba(76, 175, 80, 0.1); background-color: rgba(var(--v-theme-success), 0.1);
}
.copy-message-btn.copy-failed {
color: rgb(var(--v-theme-error));
opacity: 1;
}
.copy-message-btn.copy-failed:hover {
color: rgb(var(--v-theme-error));
background-color: rgba(var(--v-theme-error), 0.1);
} }
.reply-message-btn { .reply-message-btn {
@@ -23,12 +23,14 @@
:stagedImagesUrl="stagedImagesUrl" :stagedImagesUrl="stagedImagesUrl"
:stagedAudioUrl="stagedAudioUrl" :stagedAudioUrl="stagedAudioUrl"
:disabled="isStreaming" :disabled="isStreaming"
:is-running="isStreaming || isConvRunning"
:enableStreaming="enableStreaming" :enableStreaming="enableStreaming"
:isRecording="isRecording" :isRecording="isRecording"
:session-id="currSessionId || null" :session-id="currSessionId || null"
:current-session="getCurrentSession" :current-session="getCurrentSession"
:config-id="configId" :config-id="configId"
@send="handleSendMessage" @send="handleSendMessage"
@stop="handleStopMessage"
@toggleStreaming="toggleStreaming" @toggleStreaming="toggleStreaming"
@removeImage="removeImage" @removeImage="removeImage"
@removeAudio="removeAudio" @removeAudio="removeAudio"
@@ -156,6 +158,7 @@ const {
enableStreaming, enableStreaming,
getSessionMessages: getSessionMsg, getSessionMessages: getSessionMsg,
sendMessage: sendMsg, sendMessage: sendMsg,
stopMessage: stopMsg,
toggleStreaming toggleStreaming
} = useMessages(currSessionId, getMediaFile, updateSessionTitle, getSessions); } = useMessages(currSessionId, getMediaFile, updateSessionTitle, getSessions);
@@ -236,6 +239,10 @@ async function handleSendMessage() {
} }
} }
async function handleStopMessage() {
await stopMsg();
}
onMounted(async () => { onMounted(async () => {
// //
try { try {
@@ -34,6 +34,7 @@ const platformDisplayList = computed(() =>
const handleInstall = (plugin) => { const handleInstall = (plugin) => {
emit("install", plugin); emit("install", plugin);
}; };
</script> </script>
<template> <template>
@@ -123,6 +124,7 @@ const handleInstall = (plugin) => {
v-if="plugin?.social_link" v-if="plugin?.social_link"
:href="plugin.social_link" :href="plugin.social_link"
target="_blank" target="_blank"
@click.stop
class="text-subtitle-2 font-weight-medium" class="text-subtitle-2 font-weight-medium"
style=" style="
text-decoration: none; text-decoration: none;
@@ -213,7 +215,10 @@ const handleInstall = (plugin) => {
</div> </div>
</v-card-text> </v-card-text>
<v-card-actions style="gap: 6px; padding: 8px 12px; padding-top: 0"> <v-card-actions
style="gap: 6px; padding: 8px 12px; padding-top: 0"
@click.stop
>
<v-chip <v-chip
v-for="tag in plugin.tags?.slice(0, 2)" v-for="tag in plugin.tags?.slice(0, 2)"
:key="tag" :key="tag"
@@ -248,22 +253,24 @@ const handleInstall = (plugin) => {
<v-btn <v-btn
v-if="plugin?.repo" v-if="plugin?.repo"
color="secondary" color="secondary"
size="x-small" size="small"
variant="tonal" variant="tonal"
class="market-action-btn"
:href="plugin.repo" :href="plugin.repo"
target="_blank" target="_blank"
style="height: 24px" style="height: 32px"
> >
<v-icon icon="mdi-github" start size="x-small"></v-icon> <v-icon icon="mdi-github" start size="small"></v-icon>
{{ tm("buttons.viewRepo") }} {{ tm("buttons.viewRepo") }}
</v-btn> </v-btn>
<v-btn <v-btn
v-if="!plugin?.installed" v-if="!plugin?.installed"
color="primary" color="primary"
size="x-small" size="small"
@click="handleInstall(plugin)" @click="handleInstall(plugin)"
variant="flat" variant="flat"
style="height: 24px" class="market-action-btn"
style="height: 32px"
> >
{{ tm("buttons.install") }} {{ tm("buttons.install") }}
</v-btn> </v-btn>
@@ -306,4 +313,9 @@ const handleInstall = (plugin) => {
.plugin-description::-webkit-scrollbar-thumb:hover { .plugin-description::-webkit-scrollbar-thumb:hover {
background-color: rgba(var(--v-theme-primary-rgb), 0.6); background-color: rgba(var(--v-theme-primary-rgb), 0.6);
} }
.market-action-btn {
font-size: 0.9rem;
font-weight: 600;
}
</style> </style>
@@ -48,6 +48,40 @@ const filteredIterable = computed(() => {
return rest return rest
}) })
const providerHint = computed(() => {
const hint = props.iterable?.hint
if (typeof hint !== 'string' || !hint) return ''
if (
hint === 'provider_group.provider.openai_embedding.hint'
|| hint === 'provider_group.provider.gemini_embedding.hint'
) {
return ''
}
return hint
})
const getItemHint = (itemKey, itemMeta) => {
if (itemMeta?.hint) return itemMeta.hint
if (itemKey !== 'embedding_api_base') return ''
const providerType = props.iterable?.type
if (providerType === 'openai_embedding') {
return getRaw('provider_group.provider.openai_embedding.hint')
? 'provider_group.provider.openai_embedding.hint'
: ''
}
if (providerType === 'gemini_embedding') {
return getRaw('provider_group.provider.gemini_embedding.hint')
? 'provider_group.provider.gemini_embedding.hint'
: ''
}
return ''
}
const dialog = ref(false) const dialog = ref(false)
const currentEditingKey = ref('') const currentEditingKey = ref('')
const currentEditingLanguage = ref('json') const currentEditingLanguage = ref('json')
@@ -153,14 +187,14 @@ function hasVisibleItemsAfter(items, currentIndex) {
<div v-if="metadata[metadataKey]?.type === 'object' || metadata[metadataKey]?.config_template" class="object-config"> <div v-if="metadata[metadataKey]?.type === 'object' || metadata[metadataKey]?.config_template" class="object-config">
<!-- Provider-level hint --> <!-- Provider-level hint -->
<v-alert <v-alert
v-if="iterable.hint && !isEditing" v-if="providerHint"
type="info" type="info"
variant="tonal" variant="tonal"
class="mb-4" class="mb-4"
border="start" border="start"
density="compact" density="compact"
> >
{{ iterable.hint }} {{ translateIfKey(providerHint) }}
</v-alert> </v-alert>
<div v-for="(val, key, index) in filteredIterable" :key="key" class="config-item"> <div v-for="(val, key, index) in filteredIterable" :key="key" class="config-item">
@@ -218,9 +252,9 @@ function hasVisibleItemsAfter(items, currentIndex) {
</v-list-item-title> </v-list-item-title>
<v-list-item-subtitle class="property-hint"> <v-list-item-subtitle class="property-hint">
<span v-if="metadata[metadataKey].items[key]?.obvious_hint && metadata[metadataKey].items[key]?.hint" <span v-if="metadata[metadataKey].items[key]?.obvious_hint && getItemHint(key, metadata[metadataKey].items[key])"
class="important-hint"></span> class="important-hint"></span>
{{ translateIfKey(metadata[metadataKey].items[key]?.hint) }} {{ translateIfKey(getItemHint(key, metadata[metadataKey].items[key])) }}
</v-list-item-subtitle> </v-list-item-subtitle>
</v-list-item> </v-list-item>
</v-col> </v-col>
+360 -235
View File
@@ -1,10 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, inject } from "vue"; import { ref, computed, inject, watch } from "vue";
import { useCustomizerStore } from "@/stores/customizer"; import { useCustomizerStore } from "@/stores/customizer";
import { useModuleI18n } from "@/i18n/composables"; import { useModuleI18n } from "@/i18n/composables";
import { getPlatformDisplayName, getPlatformIcon } from "@/utils/platformUtils"; import { getPlatformDisplayName, getPlatformIcon } from "@/utils/platformUtils";
import UninstallConfirmDialog from "./UninstallConfirmDialog.vue"; import UninstallConfirmDialog from "./UninstallConfirmDialog.vue";
import PluginPlatformChip from "./PluginPlatformChip.vue"; import PluginPlatformChip from "./PluginPlatformChip.vue";
import StyledMenu from "./StyledMenu.vue";
import defaultPluginIcon from "@/assets/images/plugin_icon.png";
const props = defineProps({ const props = defineProps({
extension: { extension: {
@@ -59,6 +61,25 @@ const astrbotVersionRequirement = computed(() => {
: ""; : "";
}); });
const logoLoadFailed = ref(false);
const logoSrc = computed(() => {
const logo = props.extension?.logo;
if (logoLoadFailed.value) {
return defaultPluginIcon;
}
return typeof logo === "string" && logo.trim().length
? logo
: defaultPluginIcon;
});
watch(
() => props.extension?.logo,
() => {
logoLoadFailed.value = false;
},
);
// //
const configure = () => { const configure = () => {
emit("configure", props.extension); emit("configure", props.extension);
@@ -104,6 +125,7 @@ const viewReadme = () => {
const viewChangelog = () => { const viewChangelog = () => {
emit("view-changelog", props.extension); emit("view-changelog", props.extension);
}; };
</script> </script>
<template> <template>
@@ -129,249 +151,292 @@ const viewChangelog = () => {
style=" style="
padding: 16px; padding: 16px;
padding-bottom: 0px; padding-bottom: 0px;
display: flex;
gap: 16px;
width: 100%; width: 100%;
" "
> >
<div v-if="extension?.logo"> <div style="overflow-x: auto; width: 100%">
<img :src="extension.logo" :alt="extension.name" cover width="100" />
</div>
<div style="overflow-x: auto">
<!-- Top-right three-dot menu -->
<div style="position: absolute; right: 8px; top: 8px; z-index: 5">
<v-menu offset-y>
<template v-slot:activator="{ props: menuProps }">
<v-btn
icon
variant="text"
aria-label="more"
v-if="extension?.repo"
:href="extension?.repo"
target="_blank"
>
<v-icon icon="mdi-github"></v-icon>
</v-btn>
<v-btn v-bind="menuProps" icon variant="text" aria-label="more">
<v-icon icon="mdi-dots-vertical"></v-icon>
</v-btn>
</template>
<v-list>
<v-list-item @click="viewReadme">
<v-list-item-title
>📄 {{ tm("buttons.viewDocs") }}</v-list-item-title
>
</v-list-item>
<v-list-item v-if="!marketMode" @click="viewChangelog">
<v-list-item-title
>📝 {{ tm("pluginChangelog.menuTitle") }}</v-list-item-title
>
</v-list-item>
<v-list-item
v-if="marketMode && !extension?.installed"
@click="installExtension"
>
<v-list-item-title>
{{ tm("buttons.install") }}</v-list-item-title
>
</v-list-item>
<v-list-item v-if="marketMode && extension?.installed">
<v-list-item-title class="text--disabled">{{
tm("status.installed")
}}</v-list-item-title>
</v-list-item>
<!-- Divider between market actions and plugin actions -->
<v-divider v-if="!marketMode" />
<template v-if="!marketMode">
<v-list-item @click="configure">
<v-list-item-title>
{{ tm("card.actions.pluginConfig") }}</v-list-item-title
>
</v-list-item>
<v-list-item @click="uninstallExtension">
<v-list-item-title class="text-error">{{
tm("card.actions.uninstallPlugin")
}}</v-list-item-title>
</v-list-item>
<v-list-item @click="reloadExtension">
<v-list-item-title>{{
tm("card.actions.reloadPlugin")
}}</v-list-item-title>
</v-list-item>
<v-list-item @click="toggleActivation">
<v-list-item-title>
{{
extension.activated
? tm("buttons.disable")
: tm("buttons.enable")
}}{{ tm("card.actions.togglePlugin") }}
</v-list-item-title>
</v-list-item>
<v-list-item @click="viewHandlers">
<v-list-item-title
>{{ tm("card.actions.viewHandlers") }} ({{
extension.handlers.length
}})</v-list-item-title
>
</v-list-item>
<v-list-item @click="updateExtension">
<v-list-item-title>
{{
extension.has_update
? tm("card.actions.updateTo") +
" " +
extension.online_version
: tm("card.actions.reinstall")
}}
</v-list-item-title>
</v-list-item>
</template>
</v-list>
</v-menu>
</div>
<div style="width: 100%; margin-bottom: 24px"> <div style="width: 100%; margin-bottom: 24px">
<!-- 最多一行 --> <div class="extension-title-row">
<div <p
class="text-caption" class="text-h3 font-weight-black extension-title"
style=" :class="{ 'text-h4': $vuetify.display.xs }"
color: gray;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-right: 84px;
"
>
{{ extension.author }} / {{ extension.name }}
</div>
<p
class="text-h3 font-weight-black extension-title"
:class="{ 'text-h4': $vuetify.display.xs }"
>
<span class="extension-title__text">{{
extension.display_name?.length
? extension.display_name
: extension.name
}}</span>
<v-tooltip
location="top"
v-if="extension?.has_update && !marketMode"
> >
<template v-slot:activator="{ props: tooltipProps }"> <v-tooltip
<v-icon location="top"
v-bind="tooltipProps" :text="
color="warning" extension.display_name?.length &&
class="ml-2" extension.display_name !== extension.name
icon="mdi-update" ? `${extension.display_name} (${extension.name})`
size="small" : extension.name
></v-icon> "
</template>
<span
>{{ tm("card.status.hasUpdate") }}:
{{ extension.online_version }}</span
> >
</v-tooltip> <template v-slot:activator="{ props: titleTooltipProps }">
<v-tooltip <span v-bind="titleTooltipProps" class="extension-title__text">{{
location="top" extension.display_name?.length
v-if="!extension.activated && !marketMode" ? extension.display_name
> : extension.name
<template v-slot:activator="{ props: tooltipProps }"> }}</span>
<v-icon </template>
v-bind="tooltipProps" </v-tooltip>
color="error" <v-tooltip
class="ml-2" location="top"
icon="mdi-cancel" v-if="extension?.has_update && !marketMode"
size="small" >
></v-icon> <template v-slot:activator="{ props: tooltipProps }">
</template> <v-icon
<span>{{ tm("card.status.disabled") }}</span> v-bind="tooltipProps"
</v-tooltip> color="warning"
</p> class="ml-2"
icon="mdi-update"
size="small"
></v-icon>
</template>
<span
>{{ tm("card.status.hasUpdate") }}:
{{ extension.online_version }}</span
>
</v-tooltip>
<v-tooltip
location="top"
v-if="!extension.activated && !marketMode"
>
<template v-slot:activator="{ props: tooltipProps }">
<v-icon
v-bind="tooltipProps"
color="error"
class="ml-2"
icon="mdi-cancel"
size="small"
></v-icon>
</template>
<span>{{ tm("card.status.disabled") }}</span>
</v-tooltip>
</p>
<div class="mt-1 d-flex flex-wrap"> <template v-if="!marketMode">
<v-chip color="primary" label size="small"> <v-tooltip location="left">
<v-icon icon="mdi-source-branch" start></v-icon> <template v-slot:activator="{ props: tooltipProps }">
{{ extension.version }} <div v-bind="tooltipProps" class="extension-switch-wrap" @click.stop>
</v-chip> <v-switch
<v-chip :model-value="extension.activated"
v-if="extension?.has_update" color="success"
color="warning" density="compact"
label hide-details
size="small" inset
class="ml-2" @update:model-value="toggleActivation"
> ></v-switch>
<v-icon icon="mdi-arrow-up-bold" start></v-icon> </div>
{{ extension.online_version }} </template>
</v-chip> <span>{{
<v-chip extension.activated ? tm("buttons.disable") : tm("buttons.enable")
color="primary" }}</span>
label </v-tooltip>
size="small" </template>
class="ml-2" <template v-else>
v-if="extension.handlers?.length" <div class="extension-market-menu-wrap">
@click="viewHandlers" <v-menu offset-y>
style="cursor: pointer" <template v-slot:activator="{ props: menuProps }">
> <v-btn
<v-icon icon="mdi-cogs" start></v-icon> icon
{{ extension.handlers?.length variant="text"
}}{{ tm("card.status.handlersCount") }} aria-label="more"
</v-chip> v-if="extension?.repo"
<v-chip :href="extension?.repo"
v-for="tag in extension.tags" target="_blank"
:key="tag" >
:color="tag === 'danger' ? 'error' : 'primary'" <v-icon icon="mdi-github"></v-icon>
label </v-btn>
size="small" <v-btn v-bind="menuProps" icon variant="text" aria-label="more">
class="ml-2" <v-icon icon="mdi-dots-vertical"></v-icon>
> </v-btn>
{{ tag === "danger" ? tm("tags.danger") : tag }} </template>
</v-chip>
<PluginPlatformChip <v-list>
:platforms="supportPlatforms" <v-list-item @click="viewReadme">
class="ml-2" <v-list-item-title
/> >📄 {{ tm("buttons.viewDocs") }}</v-list-item-title
<v-chip >
v-if="astrbotVersionRequirement" </v-list-item>
color="secondary"
variant="outlined" <v-list-item
label v-if="marketMode && !extension?.installed"
size="small" @click="installExtension"
class="ml-2" >
> <v-list-item-title>
AstrBot: {{ astrbotVersionRequirement }} {{ tm("buttons.install") }}</v-list-item-title
</v-chip> >
</v-list-item>
<v-list-item v-if="marketMode && extension?.installed">
<v-list-item-title class="text--disabled">{{
tm("status.installed")
}}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</div>
</template>
</div> </div>
<div <div class="extension-content-row mt-2">
class="mt-2" <div class="extension-image-container">
:class="{ 'text-caption': $vuetify.display.xs }" <img
style="overflow-y: auto; height: 70px; font-size: 90%" :src="logoSrc"
> :alt="extension.name"
{{ extension.desc }} class="extension-logo"
@error="logoLoadFailed = true"
/>
</div>
<div class="extension-meta-group">
<div class="extension-chip-group d-flex flex-wrap">
<v-chip color="primary" label size="small">
<v-icon icon="mdi-source-branch" start></v-icon>
{{ extension.version }}
</v-chip>
<v-chip
v-if="extension?.has_update"
color="warning"
label
size="small"
>
<v-icon icon="mdi-arrow-up-bold" start></v-icon>
{{ extension.online_version }}
</v-chip>
<v-chip
v-if="extension.handlers?.length"
color="primary"
label
size="small"
@click="viewHandlers"
style="cursor: pointer"
>
<v-icon icon="mdi-cogs" start></v-icon>
{{ extension.handlers?.length
}}{{ tm("card.status.handlersCount") }}
</v-chip>
<v-chip
v-for="tag in extension.tags"
:key="tag"
:color="tag === 'danger' ? 'error' : 'primary'"
label
size="small"
>
{{ tag === "danger" ? tm("tags.danger") : tag }}
</v-chip>
<PluginPlatformChip :platforms="supportPlatforms" />
<v-chip
v-if="astrbotVersionRequirement"
color="secondary"
variant="outlined"
label
size="small"
>
AstrBot: {{ astrbotVersionRequirement }}
</v-chip>
</div>
<div
class="extension-desc"
:class="{ 'text-caption': $vuetify.display.xs }"
>
{{ extension.desc }}
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
</v-card-text> </v-card-text>
<v-card-actions class="extension-actions"> <v-card-actions class="extension-actions" @click.stop>
<v-btn color="primary" size="small" @click="viewReadme"> <template v-if="!marketMode">
{{ tm("buttons.viewDocs") }} <v-spacer></v-spacer>
</v-btn> <v-tooltip location="top" :text="tm('buttons.viewDocs')">
<v-btn v-if="!marketMode" color="primary" size="small" @click="configure"> <template v-slot:activator="{ props: actionProps }">
{{ tm("card.actions.pluginConfig") }} <v-btn
</v-btn> v-bind="actionProps"
icon="mdi-book-open-page-variant"
size="small"
variant="tonal"
color="info"
@click="viewReadme"
></v-btn>
</template>
</v-tooltip>
<v-tooltip location="top" :text="tm('card.actions.pluginConfig')">
<template v-slot:activator="{ props: actionProps }">
<v-btn
v-bind="actionProps"
icon="mdi-cog"
size="small"
variant="tonal"
color="primary"
@click="configure"
></v-btn>
</template>
</v-tooltip>
<v-tooltip v-if="extension?.repo" location="top" :text="tm('buttons.viewRepo')">
<template v-slot:activator="{ props: actionProps }">
<v-btn
v-bind="actionProps"
icon="mdi-github"
size="small"
variant="tonal"
color="secondary"
:href="extension.repo"
target="_blank"
></v-btn>
</template>
</v-tooltip>
<v-tooltip location="top" :text="tm('card.actions.reloadPlugin')">
<template v-slot:activator="{ props: actionProps }">
<v-btn
v-bind="actionProps"
icon="mdi-refresh"
size="small"
variant="tonal"
color="primary"
@click="reloadExtension"
></v-btn>
</template>
</v-tooltip>
<StyledMenu location="top end" offset="8">
<template #activator="{ props: menuProps }">
<v-btn
v-bind="menuProps"
icon="mdi-dots-horizontal"
size="small"
variant="tonal"
color="secondary"
></v-btn>
</template>
<v-list-item class="styled-menu-item" prepend-icon="mdi-information" @click="viewHandlers">
<v-list-item-title>{{ tm("buttons.viewInfo") }}</v-list-item-title>
</v-list-item>
<v-list-item class="styled-menu-item" prepend-icon="mdi-update" @click="updateExtension">
<v-list-item-title>{{
extension.has_update
? tm("card.actions.updateTo") + " " + extension.online_version
: tm("card.actions.reinstall")
}}</v-list-item-title>
</v-list-item>
<v-list-item class="styled-menu-item" prepend-icon="mdi-delete" @click="uninstallExtension">
<v-list-item-title class="text-error">{{ tm("card.actions.uninstallPlugin") }}</v-list-item-title>
</v-list-item>
</StyledMenu>
</template>
<template v-else>
<v-btn color="primary" size="small" @click="viewReadme">
{{ tm("buttons.viewDocs") }}
</v-btn>
</template>
</v-card-actions> </v-card-actions>
</v-card> </v-card>
@@ -385,13 +450,52 @@ const viewChangelog = () => {
<style scoped> <style scoped>
.extension-image-container { .extension-image-container {
display: flex; display: flex;
align-items: center; align-items: flex-start;
margin-left: 12px; flex-shrink: 0;
}
.extension-logo {
width: 72px;
height: 72px;
border-radius: 12px;
object-fit: cover;
}
.extension-content-row {
display: flex;
gap: 12px;
align-items: flex-start;
}
.extension-meta-group {
flex: 1;
min-width: 0;
}
.extension-chip-group {
gap: 8px;
}
.extension-desc {
margin-top: 8px;
font-size: 90%;
overflow-y: auto;
height: 70px;
} }
.extension-title { .extension-title {
display: flex; display: flex;
align-items: center; align-items: center;
min-width: 0;
flex: 1;
margin: 0;
}
.extension-title-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
} }
.extension-title__text { .extension-title__text {
@@ -399,17 +503,38 @@ const viewChangelog = () => {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
padding-top: 6px; }
.extension-switch-wrap {
display: flex;
align-items: center;
flex-shrink: 0;
}
.extension-switch-wrap :deep(.v-switch) {
margin: 0;
}
.extension-market-menu-wrap {
display: flex;
align-items: center;
flex-shrink: 0;
} }
@media (max-width: 600px) { @media (max-width: 600px) {
.extension-image-container { .extension-content-row {
margin-left: 8px; flex-direction: column;
}
.extension-logo {
width: 64px;
height: 64px;
} }
} }
.extension-actions { .extension-actions {
margin-top: auto; margin-top: auto;
gap: 8px; gap: 8px;
justify-content: flex-end;
} }
</style> </style>
@@ -15,7 +15,7 @@
<v-expand-transition> <v-expand-transition>
<div v-if="radioValue === '1'" style="margin-left: 16px;"> <div v-if="radioValue === '1'" style="margin-left: 16px;">
<v-radio-group v-model="githubProxyRadioControl" class="mt-2" hide-details="true"> <v-radio-group v-model="githubProxyRadioControl" class="mt-2" hide-details="true">
<v-radio color="success" v-for="(proxy, idx) in githubProxies" :key="proxy" :value="idx"> <v-radio color="success" v-for="(proxy, idx) in githubProxies" :key="proxy" :value="String(idx)">
<template v-slot:label> <template v-slot:label>
<div class="d-flex align-center"> <div class="d-flex align-center">
<span class="mr-2">{{ proxy }}</span> <span class="mr-2">{{ proxy }}</span>
@@ -37,7 +37,7 @@
</template> </template>
</v-radio> </v-radio>
<v-radio color="primary" value="-1" :label="tm('network.proxySelector.custom')"> <v-radio color="primary" value="-1" :label="tm('network.proxySelector.custom')">
<template v-slot:label v-if="githubProxyRadioControl === '-1'"> <template v-slot:label v-if="String(githubProxyRadioControl) === '-1'">
<v-text-field density="compact" v-model="selectedGitHubProxy" variant="outlined" <v-text-field density="compact" v-model="selectedGitHubProxy" variant="outlined"
style="width: 100vw;" :placeholder="tm('network.proxySelector.custom')" hide-details="true"> style="width: 100vw;" :placeholder="tm('network.proxySelector.custom')" hide-details="true">
</v-text-field> </v-text-field>
@@ -72,9 +72,21 @@ export default {
loadingTestingConnection: false, loadingTestingConnection: false,
testingProxies: {}, testingProxies: {},
proxyStatus: {}, proxyStatus: {},
initializing: true,
} }
}, },
methods: { methods: {
getProxyByControl(control) {
const normalizedControl = String(control);
if (normalizedControl === "-1") {
return "";
}
const index = Number.parseInt(normalizedControl, 10);
if (Number.isNaN(index)) {
return "";
}
return this.githubProxies[index] || "";
},
async testSingleProxy(idx) { async testSingleProxy(idx) {
this.testingProxies[idx] = true; this.testingProxies[idx] = true;
@@ -118,42 +130,60 @@ export default {
}, },
}, },
mounted() { mounted() {
this.selectedGitHubProxy = localStorage.getItem('selectedGitHubProxy') || ""; this.initializing = true;
this.radioValue = localStorage.getItem('githubProxyRadioValue') || "0";
this.githubProxyRadioControl = localStorage.getItem('githubProxyRadioControl') || "0"; const savedProxy = localStorage.getItem('selectedGitHubProxy') || "";
if (this.radioValue === "1") { const savedRadio = localStorage.getItem('githubProxyRadioValue') || "0";
if (this.githubProxyRadioControl !== "-1") { const savedControl = String(localStorage.getItem('githubProxyRadioControl') || "0");
this.selectedGitHubProxy = this.githubProxies[this.githubProxyRadioControl] || "";
this.radioValue = savedRadio;
this.githubProxyRadioControl = savedControl;
if (savedRadio === "1") {
if (savedControl !== "-1") {
this.selectedGitHubProxy = this.getProxyByControl(savedControl);
} else {
this.selectedGitHubProxy = savedProxy;
} }
} else { } else {
this.selectedGitHubProxy = ""; this.selectedGitHubProxy = "";
} }
this.initializing = false;
}, },
watch: { watch: {
selectedGitHubProxy: function (newVal, oldVal) { selectedGitHubProxy: function (newVal, oldVal) {
if (this.initializing) {
return;
}
if (!newVal) { if (!newVal) {
newVal = "" newVal = ""
} }
localStorage.setItem('selectedGitHubProxy', newVal); localStorage.setItem('selectedGitHubProxy', newVal);
}, },
radioValue: function (newVal) { radioValue: function (newVal) {
if (this.initializing) {
return;
}
localStorage.setItem('githubProxyRadioValue', newVal); localStorage.setItem('githubProxyRadioValue', newVal);
if (newVal === "0") { if (String(newVal) === "0") {
this.selectedGitHubProxy = ""; this.selectedGitHubProxy = "";
} else if (this.githubProxyRadioControl !== "-1") { } else if (String(this.githubProxyRadioControl) !== "-1") {
this.selectedGitHubProxy = this.githubProxies[this.githubProxyRadioControl] || ""; this.selectedGitHubProxy = this.getProxyByControl(this.githubProxyRadioControl);
} }
}, },
githubProxyRadioControl: function (newVal) { githubProxyRadioControl: function (newVal) {
localStorage.setItem('githubProxyRadioControl', newVal); if (this.initializing) {
if (this.radioValue !== "1") { return;
}
const normalizedVal = String(newVal);
localStorage.setItem('githubProxyRadioControl', normalizedVal);
if (String(this.radioValue) !== "1") {
this.selectedGitHubProxy = ""; this.selectedGitHubProxy = "";
return; return;
} }
if (newVal !== "-1") { if (normalizedVal !== "-1") {
this.selectedGitHubProxy = this.githubProxies[newVal] || ""; this.selectedGitHubProxy = this.getProxyByControl(normalizedVal);
} else {
this.selectedGitHubProxy = "";
} }
} }
} }
File diff suppressed because it is too large Load Diff
@@ -4,6 +4,7 @@
"close": "Close", "close": "Close",
"copy": "Copy", "copy": "Copy",
"copied": "Copied", "copied": "Copied",
"copyFailed": "Copy failed",
"delete": "Delete", "delete": "Delete",
"edit": "Edit", "edit": "Edit",
"add": "Add", "add": "Add",
@@ -58,6 +58,18 @@
"guideStep2": "Install it and restart AstrBot.", "guideStep2": "Install it and restart AstrBot.",
"guideStep3": "If you use Docker, prefer the image update path." "guideStep3": "If you use Docker, prefer the image update path."
}, },
"desktopApp": {
"title": "Update Desktop App",
"message": "Check and upgrade the AstrBot desktop application.",
"currentVersion": "Current version: ",
"latestVersion": "Latest version: ",
"checking": "Checking desktop app updates...",
"hasNewVersion": "A new version is available. Click confirm to upgrade.",
"isLatest": "Already on the latest version",
"installing": "Downloading and installing update. The app will restart automatically...",
"checkFailed": "Failed to check updates. Please try again later.",
"installFailed": "Upgrade failed. Please try again later."
},
"dashboardUpdate": { "dashboardUpdate": {
"title": "Update Dashboard to Latest Version Only", "title": "Update Dashboard to Latest Version Only",
"currentVersion": "Current Version", "currentVersion": "Current Version",
@@ -9,7 +9,8 @@
"voice": "Voice Input", "voice": "Voice Input",
"recordingPrompt": "Recording, please speak...", "recordingPrompt": "Recording, please speak...",
"chatPrompt": "Let's chat!", "chatPrompt": "Let's chat!",
"dropToUpload": "Drop files to upload" "dropToUpload": "Drop files to upload",
"stopGenerating": "Stop generating"
}, },
"message": { "message": {
"user": "User", "user": "User",
@@ -80,9 +81,16 @@
"disabled": "Streaming disabled", "disabled": "Streaming disabled",
"on": "Stream", "on": "Stream",
"off": "Normal" "off": "Normal"
}, "config": { },
"transport": {
"title": "Transport Mode",
"sse": "SSE",
"websocket": "WebSocket"
},
"config": {
"title": "Config" "title": "Config"
}, "reasoning": { },
"reasoning": {
"thinking": "Thinking Process" "thinking": "Thinking Process"
}, },
"reply": { "reply": {
@@ -251,6 +251,10 @@
"show_tool_use_status": { "show_tool_use_status": {
"description": "Output Function Call Status" "description": "Output Function Call Status"
}, },
"show_tool_call_result": {
"description": "Output Tool Call Results",
"hint": "Only takes effect when \"Output Function Call Status\" is enabled, and shows at most 70 characters."
},
"sanitize_context_by_modalities": { "sanitize_context_by_modalities": {
"description": "Sanitize History by Modalities", "description": "Sanitize History by Modalities",
"hint": "When enabled, sanitizes contexts before each LLM request by removing image blocks and tool-call structures that the current provider's modalities do not support (this changes what the model sees)." "hint": "When enabled, sanitizes contexts before each LLM request by removing image blocks and tool-call structures that the current provider's modalities do not support (this changes what the model sees)."
@@ -1082,6 +1086,12 @@
"embedding_api_base": { "embedding_api_base": {
"description": "API Base URL" "description": "API Base URL"
}, },
"openai_embedding": {
"hint": "OpenAI Embedding automatically appends /v1 at request time."
},
"gemini_embedding": {
"hint": "Gemini Embedding does not require manually adding /v1beta."
},
"volcengine_cluster": { "volcengine_cluster": {
"description": "Volcengine cluster", "description": "Volcengine cluster",
"hint": "For voice cloning models, choose volcano_icl or volcano_icl_concurr; default is volcano_tts." "hint": "For voice cloning models, choose volcano_icl or volcano_icl_concurr; default is volcano_tts."
@@ -1309,6 +1319,10 @@
"api_base": { "api_base": {
"description": "API Base URL" "description": "API Base URL"
}, },
"proxy": {
"description": "Proxy address",
"hint": "HTTP/HTTPS proxy URL, e.g. http://127.0.0.1:7890. Applies only to this provider's API requests and does not affect Docker internal networking."
},
"model": { "model": {
"description": "Model ID", "description": "Model ID",
"hint": "Model name, e.g., gpt-4o-mini, deepseek-chat." "hint": "Model name, e.g., gpt-4o-mini, deepseek-chat."
@@ -8,6 +8,9 @@
"handlersOperation": "Manage Handlers", "handlersOperation": "Manage Handlers",
"market": "AstrBot Plugin Market" "market": "AstrBot Plugin Market"
}, },
"titles": {
"installedAstrBotPlugins": "Installed AstrBot Plugins"
},
"search": { "search": {
"placeholder": "Search extensions...", "placeholder": "Search extensions...",
"marketPlaceholder": "Search market extensions..." "marketPlaceholder": "Search market extensions..."
@@ -8,11 +8,14 @@
"refresh": "Refresh", "refresh": "Refresh",
"save": "Save", "save": "Save",
"add": "Add SubAgent", "add": "Add SubAgent",
"delete": "Delete" "delete": "Delete",
"close": "Close"
}, },
"switches": { "switches": {
"enable": "Enable SubAgent orchestration", "enable": "Enable SubAgent orchestration",
"dedupe": "Deduplicate main LLM tools (hide tools duplicated by SubAgents)" "enableHint": "Enable sub-agent functionality",
"dedupe": "Deduplicate main LLM tools (hide tools duplicated by SubAgents)",
"dedupeHint": "Remove duplicate tools from main agent"
}, },
"description": { "description": {
"disabled": "When off: SubAgent is disabled; the main LLM mounts tools via persona rules (all by default) and calls them directly.", "disabled": "When off: SubAgent is disabled; the main LLM mounts tools via persona rules (all by default) and calls them directly.",
@@ -29,7 +32,8 @@
"transferPrefix": "transfer_to_{name}", "transferPrefix": "transfer_to_{name}",
"switchLabel": "Enable", "switchLabel": "Enable",
"previewTitle": "Preview: handoff tool shown to the main LLM", "previewTitle": "Preview: handoff tool shown to the main LLM",
"personaChip": "Persona: {id}" "personaChip": "Persona: {id}",
"personaPreview": "PERSONA PREVIEW"
}, },
"form": { "form": {
"nameLabel": "Agent name (used for transfer_to_{name})", "nameLabel": "Agent name (used for transfer_to_{name})",
@@ -49,6 +53,13 @@
"nameDuplicate": "Duplicate SubAgent name: {name}", "nameDuplicate": "Duplicate SubAgent name: {name}",
"personaMissing": "SubAgent {name} has no persona selected", "personaMissing": "SubAgent {name} has no persona selected",
"saveSuccess": "Saved successfully", "saveSuccess": "Saved successfully",
"saveFailed": "Failed to save" "saveFailed": "Failed to save",
"nameRequired": "Name is required",
"namePattern": "Lowercase letters, numbers, underscore only"
},
"empty": {
"title": "No Agents Configured",
"subtitle": "Add a new sub-agent to get started",
"action": "Create First Agent"
} }
} }
@@ -4,6 +4,7 @@
"close": "关闭", "close": "关闭",
"copy": "复制", "copy": "复制",
"copied": "已复制", "copied": "已复制",
"copyFailed": "复制失败",
"delete": "删除", "delete": "删除",
"edit": "编辑", "edit": "编辑",
"add": "添加", "add": "添加",
@@ -58,6 +58,18 @@
"guideStep2": "完成安装后重启 AstrBot。", "guideStep2": "完成安装后重启 AstrBot。",
"guideStep3": "如果你使用 Docker,请优先使用镜像更新方式。" "guideStep3": "如果你使用 Docker,请优先使用镜像更新方式。"
}, },
"desktopApp": {
"title": "更新桌面应用",
"message": "将检查并升级 AstrBot 桌面端程序。",
"currentVersion": "当前版本:",
"latestVersion": "最新版本:",
"checking": "正在检查桌面应用更新...",
"hasNewVersion": "发现新版本,可点击确认升级。",
"isLatest": "已经是最新版本",
"installing": "正在下载并安装更新,完成后将自动重启应用...",
"checkFailed": "检查更新失败,请稍后重试。",
"installFailed": "升级失败,请稍后重试。"
},
"dashboardUpdate": { "dashboardUpdate": {
"title": "单独更新管理面板到最新版本", "title": "单独更新管理面板到最新版本",
"currentVersion": "当前版本", "currentVersion": "当前版本",
@@ -9,7 +9,8 @@
"voice": "语音输入", "voice": "语音输入",
"recordingPrompt": "录音中,请说话...", "recordingPrompt": "录音中,请说话...",
"chatPrompt": "聊天吧!", "chatPrompt": "聊天吧!",
"dropToUpload": "松开鼠标上传文件" "dropToUpload": "松开鼠标上传文件",
"stopGenerating": "停止生成"
}, },
"message": { "message": {
"user": "用户", "user": "用户",
@@ -81,6 +82,11 @@
"on": "流式", "on": "流式",
"off": "普通" "off": "普通"
}, },
"transport": {
"title": "通信传输模式",
"sse": "SSE",
"websocket": "WebSocket"
},
"config": { "config": {
"title": "配置文件" "title": "配置文件"
}, },
@@ -254,6 +254,10 @@
"show_tool_use_status": { "show_tool_use_status": {
"description": "输出函数调用状态" "description": "输出函数调用状态"
}, },
"show_tool_call_result": {
"description": "输出函数调用返回结果",
"hint": "仅在启用“输出函数调用状态”时生效,且最多展示 70 个字符。"
},
"sanitize_context_by_modalities": { "sanitize_context_by_modalities": {
"description": "按模型能力清理历史上下文", "description": "按模型能力清理历史上下文",
"hint": "开启后,在每次请求 LLM 前会按当前模型提供商中所选择的模型能力删除对话中不支持的图片/工具调用结构(会改变模型看到的历史)" "hint": "开启后,在每次请求 LLM 前会按当前模型提供商中所选择的模型能力删除对话中不支持的图片/工具调用结构(会改变模型看到的历史)"
@@ -1085,6 +1089,12 @@
"embedding_api_base": { "embedding_api_base": {
"description": "API Base URL" "description": "API Base URL"
}, },
"openai_embedding": {
"hint": "OpenAI Embedding 会在请求时自动补上 /v1。"
},
"gemini_embedding": {
"hint": "Gemini Embedding 无需手动添加 /v1beta。"
},
"volcengine_cluster": { "volcengine_cluster": {
"description": "火山引擎集群", "description": "火山引擎集群",
"hint": "若使用语音复刻大模型,可选volcano_icl或volcano_icl_concurr,默认使用volcano_tts" "hint": "若使用语音复刻大模型,可选volcano_icl或volcano_icl_concurr,默认使用volcano_tts"
@@ -1312,6 +1322,10 @@
"api_base": { "api_base": {
"description": "API Base URL" "description": "API Base URL"
}, },
"proxy": {
"description": "代理地址",
"hint": "HTTP/HTTPS 代理地址,格式如 http://127.0.0.1:7890。仅对该提供商的 API 请求生效,不影响 Docker 内网通信。"
},
"model": { "model": {
"description": "模型 ID", "description": "模型 ID",
"hint": "模型名称,如 gpt-4o-mini, deepseek-chat。" "hint": "模型名称,如 gpt-4o-mini, deepseek-chat。"
@@ -8,6 +8,9 @@
"skills": "Skills", "skills": "Skills",
"handlersOperation": "管理行为" "handlersOperation": "管理行为"
}, },
"titles": {
"installedAstrBotPlugins": "已安装的 AstrBot 插件"
},
"search": { "search": {
"placeholder": "搜索插件...", "placeholder": "搜索插件...",
"marketPlaceholder": "搜索市场插件..." "marketPlaceholder": "搜索市场插件..."
@@ -8,11 +8,14 @@
"refresh": "刷新", "refresh": "刷新",
"save": "保存", "save": "保存",
"add": "新增 SubAgent", "add": "新增 SubAgent",
"delete": "删除" "delete": "删除",
"close": "关闭"
}, },
"switches": { "switches": {
"enable": "启用 SubAgent 编排", "enable": "启用 SubAgent 编排",
"dedupe": "主 LLM 去重重复工具(与 SubAgent 重叠的工具将被隐藏)" "enableHint": "启用子代理功能",
"dedupe": "主 LLM 去重重复工具(与 SubAgent 重叠的工具将被隐藏)",
"dedupeHint": "从主代理中移除重复工具"
}, },
"description": { "description": {
"disabled": "不启动:SubAgent 关闭;主 LLM 按 persona 规则挂载工具(默认全部),并直接调用。", "disabled": "不启动:SubAgent 关闭;主 LLM 按 persona 规则挂载工具(默认全部),并直接调用。",
@@ -39,6 +42,7 @@
"providerHint": "留空表示跟随全局默认 provider。", "providerHint": "留空表示跟随全局默认 provider。",
"personaLabel": "选择人格设定", "personaLabel": "选择人格设定",
"personaHint": "SubAgent 将直接继承所选 Persona 的系统设定与工具。在人格设定页管理和新建人格。", "personaHint": "SubAgent 将直接继承所选 Persona 的系统设定与工具。在人格设定页管理和新建人格。",
"personaPreview": "人格预览",
"descriptionLabel": "对主 LLM 的描述(用于决定是否 handoff", "descriptionLabel": "对主 LLM 的描述(用于决定是否 handoff",
"descriptionHint": "这段会作为 transfer_to_* 工具的描述给主 LLM 看,建议简短明确。" "descriptionHint": "这段会作为 transfer_to_* 工具的描述给主 LLM 看,建议简短明确。"
}, },
@@ -50,6 +54,13 @@
"nameDuplicate": "SubAgent 名称重复:{name}", "nameDuplicate": "SubAgent 名称重复:{name}",
"personaMissing": "SubAgent {name} 未选择 Persona", "personaMissing": "SubAgent {name} 未选择 Persona",
"saveSuccess": "保存成功", "saveSuccess": "保存成功",
"saveFailed": "保存失败" "saveFailed": "保存失败",
"nameRequired": "名称必填",
"namePattern": "仅支持小写字母、数字和下划线"
},
"empty": {
"title": "未配置 SubAgent",
"subtitle": "添加一个新的子代理以开始",
"action": "创建第一个 Agent"
} }
} }
@@ -50,11 +50,28 @@ let installLoading = ref(false);
const isDesktopReleaseMode = ref( const isDesktopReleaseMode = ref(
typeof window !== 'undefined' && !!window.astrbotDesktop?.isDesktop typeof window !== 'undefined' && !!window.astrbotDesktop?.isDesktop
); );
const redirectConfirmDialog = ref(false); const desktopUpdateDialog = ref(false);
const pendingRedirectUrl = ref(''); const desktopUpdateChecking = ref(false);
const resolvingReleaseTarget = ref(false); const desktopUpdateInstalling = ref(false);
const desktopReleaseBaseUrl = 'https://github.com/AstrBotDevs/AstrBot-desktop/releases'; const desktopUpdateHasNewVersion = ref(false);
const fallbackReleaseUrl = desktopReleaseBaseUrl; const desktopUpdateCurrentVersion = ref('-');
const desktopUpdateLatestVersion = ref('-');
const desktopUpdateStatus = ref('');
const getAppUpdaterBridge = (): AstrBotAppUpdaterBridge | null => {
if (typeof window === 'undefined') {
return null;
}
const bridge = window.astrbotAppUpdater;
if (
bridge &&
typeof bridge.checkForAppUpdate === 'function' &&
typeof bridge.installAppUpdate === 'function'
) {
return bridge;
}
return null;
};
const getSelectedGitHubProxy = () => { const getSelectedGitHubProxy = () => {
if (typeof window === "undefined" || !window.localStorage) return ""; if (typeof window === "undefined" || !window.localStorage) return "";
@@ -75,16 +92,6 @@ const releasesHeader = computed(() => [
{ title: t('core.header.updateDialog.table.sourceUrl'), key: 'zipball_url' }, { title: t('core.header.updateDialog.table.sourceUrl'), key: 'zipball_url' },
{ title: t('core.header.updateDialog.table.actions'), key: 'switch' } { title: t('core.header.updateDialog.table.actions'), key: 'switch' }
]); ]);
const latestReleaseTag = computed(() => {
const firstRelease = (releases.value as any[])?.[0];
if (firstRelease?.tag_name) {
return firstRelease.tag_name as string;
}
return hasNewVersion.value
? t('core.header.updateDialog.redirectConfirm.latestLabel')
: (botCurrVersion.value || '-');
});
// Form validation // Form validation
const formValid = ref(true); const formValid = ref(true);
const passwordRules = computed(() => [ const passwordRules = computed(() => [
@@ -112,50 +119,88 @@ const accountEditStatus = ref({
message: '' message: ''
}); });
const open = (link: string) => { function cancelDesktopUpdate() {
window.open(link, '_blank'); if (desktopUpdateInstalling.value) {
}; return;
}
function requestExternalRedirect(link: string) { desktopUpdateDialog.value = false;
pendingRedirectUrl.value = link;
redirectConfirmDialog.value = true;
} }
function cancelExternalRedirect() { async function openDesktopUpdateDialog() {
redirectConfirmDialog.value = false; desktopUpdateDialog.value = true;
pendingRedirectUrl.value = ''; desktopUpdateChecking.value = true;
} desktopUpdateInstalling.value = false;
desktopUpdateHasNewVersion.value = false;
desktopUpdateCurrentVersion.value = '-';
desktopUpdateLatestVersion.value = '-';
desktopUpdateStatus.value = t('core.header.updateDialog.desktopApp.checking');
function confirmExternalRedirect() { const bridge = getAppUpdaterBridge();
const targetUrl = pendingRedirectUrl.value; if (!bridge) {
cancelExternalRedirect(); desktopUpdateChecking.value = false;
if (targetUrl) { desktopUpdateStatus.value = t('core.header.updateDialog.desktopApp.checkFailed');
open(targetUrl); return;
}
try {
const result = await bridge.checkForAppUpdate();
if (!result?.ok) {
desktopUpdateCurrentVersion.value = result?.currentVersion || '-';
desktopUpdateLatestVersion.value =
result?.latestVersion || result?.currentVersion || '-';
desktopUpdateStatus.value =
result?.reason || t('core.header.updateDialog.desktopApp.checkFailed');
return;
}
desktopUpdateCurrentVersion.value = result.currentVersion || '-';
desktopUpdateLatestVersion.value =
result.latestVersion || result.currentVersion || '-';
desktopUpdateHasNewVersion.value = !!result.hasUpdate;
desktopUpdateStatus.value = result.hasUpdate
? t('core.header.updateDialog.desktopApp.hasNewVersion')
: t('core.header.updateDialog.desktopApp.isLatest');
} catch (error) {
console.error(error);
desktopUpdateStatus.value = t('core.header.updateDialog.desktopApp.checkFailed');
} finally {
desktopUpdateChecking.value = false;
} }
} }
const getReleaseUrlForDesktop = () => { async function confirmDesktopUpdate() {
const firstRelease = (releases.value as any[])?.[0]; if (!desktopUpdateHasNewVersion.value || desktopUpdateInstalling.value) {
if (firstRelease?.tag_name) { return;
const tag = firstRelease.tag_name as string;
return `${desktopReleaseBaseUrl}/tag/${tag}`;
} }
if (hasNewVersion.value) return fallbackReleaseUrl;
const tag = botCurrVersion.value?.startsWith('v') ? botCurrVersion.value : 'latest'; const bridge = getAppUpdaterBridge();
return tag === 'latest' if (!bridge) {
? fallbackReleaseUrl desktopUpdateStatus.value = t('core.header.updateDialog.desktopApp.installFailed');
: `${desktopReleaseBaseUrl}/tag/${tag}`; return;
}; }
desktopUpdateInstalling.value = true;
desktopUpdateStatus.value = t('core.header.updateDialog.desktopApp.installing');
try {
const result = await bridge.installAppUpdate();
if (result?.ok) {
desktopUpdateDialog.value = false;
return;
}
desktopUpdateStatus.value =
result?.reason || t('core.header.updateDialog.desktopApp.installFailed');
} catch (error) {
console.error(error);
desktopUpdateStatus.value = t('core.header.updateDialog.desktopApp.installFailed');
} finally {
desktopUpdateInstalling.value = false;
}
}
function handleUpdateClick() { function handleUpdateClick() {
if (isDesktopReleaseMode.value) { if (isDesktopReleaseMode.value) {
requestExternalRedirect(''); void openDesktopUpdateDialog();
resolvingReleaseTarget.value = true;
checkUpdate();
void getReleases().finally(() => {
pendingRedirectUrl.value = getReleaseUrlForDesktop() || fallbackReleaseUrl;
resolvingReleaseTarget.value = false;
});
return; return;
} }
checkUpdate(); checkUpdate();
@@ -669,40 +714,38 @@ onMounted(async () => {
</v-card> </v-card>
</v-dialog> </v-dialog>
<v-dialog v-model="redirectConfirmDialog" max-width="460"> <v-dialog v-model="desktopUpdateDialog" max-width="460">
<v-card> <v-card>
<v-card-title class="text-h3 pa-4 pl-6 pb-0"> <v-card-title class="text-h3 pa-4 pl-6 pb-0">
{{ t('core.header.updateDialog.redirectConfirm.title') }} {{ t('core.header.updateDialog.desktopApp.title') }}
</v-card-title> </v-card-title>
<v-card-text> <v-card-text>
<div class="mb-3"> <div class="mb-3">
{{ t('core.header.updateDialog.redirectConfirm.message') }} {{ t('core.header.updateDialog.desktopApp.message') }}
</div> </div>
<v-alert type="info" variant="tonal" density="compact"> <v-alert type="info" variant="tonal" density="compact">
<div> <div>
{{ t('core.header.updateDialog.redirectConfirm.targetVersion') }} {{ t('core.header.updateDialog.desktopApp.currentVersion') }}
<strong v-if="!resolvingReleaseTarget">{{ latestReleaseTag }}</strong> <strong>{{ desktopUpdateCurrentVersion }}</strong>
<v-progress-circular v-else indeterminate size="16" width="2" class="ml-1" />
</div> </div>
<div class="text-caption"> <div>
{{ t('core.header.updateDialog.redirectConfirm.currentVersion') }} {{ t('core.header.updateDialog.desktopApp.latestVersion') }}
{{ botCurrVersion || '-' }} <strong v-if="!desktopUpdateChecking">{{ desktopUpdateLatestVersion }}</strong>
<v-progress-circular v-else indeterminate size="16" width="2" class="ml-1" />
</div> </div>
</v-alert> </v-alert>
<div class="text-caption mt-3"> <div class="text-caption mt-3">
<div>{{ t('core.header.updateDialog.redirectConfirm.guideTitle') }}</div> {{ desktopUpdateStatus }}
<div>1. {{ t('core.header.updateDialog.redirectConfirm.guideStep1') }}</div>
<div>2. {{ t('core.header.updateDialog.redirectConfirm.guideStep2') }}</div>
<div>3. {{ t('core.header.updateDialog.redirectConfirm.guideStep3') }}</div>
</div> </div>
</v-card-text> </v-card-text>
<v-card-actions> <v-card-actions>
<v-spacer></v-spacer> <v-spacer></v-spacer>
<v-btn color="grey" variant="text" @click="cancelExternalRedirect"> <v-btn color="grey" variant="text" @click="cancelDesktopUpdate" :disabled="desktopUpdateInstalling">
{{ t('core.common.dialog.cancelButton') }} {{ t('core.common.dialog.cancelButton') }}
</v-btn> </v-btn>
<v-btn color="primary" variant="flat" @click="confirmExternalRedirect" <v-btn color="primary" variant="flat" @click="confirmDesktopUpdate"
:loading="resolvingReleaseTarget" :disabled="resolvingReleaseTarget || !pendingRedirectUrl"> :loading="desktopUpdateInstalling"
:disabled="desktopUpdateChecking || desktopUpdateInstalling || !desktopUpdateHasNewVersion">
{{ t('core.common.dialog.confirmButton') }} {{ t('core.common.dialog.confirmButton') }}
</v-btn> </v-btn>
</v-card-actions> </v-card-actions>
@@ -1,7 +1,26 @@
export {}; export {};
declare global { declare global {
interface AstrBotDesktopAppUpdateCheckResult {
ok: boolean;
reason?: string | null;
currentVersion?: string;
latestVersion?: string | null;
hasUpdate: boolean;
}
interface AstrBotDesktopAppUpdateResult {
ok: boolean;
reason?: string | null;
}
interface AstrBotAppUpdaterBridge {
checkForAppUpdate: () => Promise<AstrBotDesktopAppUpdateCheckResult>;
installAppUpdate: () => Promise<AstrBotDesktopAppUpdateResult>;
}
interface Window { interface Window {
astrbotAppUpdater?: AstrBotAppUpdaterBridge;
astrbotDesktop?: { astrbotDesktop?: {
isDesktop: boolean; isDesktop: boolean;
isDesktopRuntime: () => Promise<boolean>; isDesktopRuntime: () => Promise<boolean>;
+1
View File
@@ -61,6 +61,7 @@ export function getTutorialLink(platformType) {
"vocechat": "https://docs.astrbot.app/deploy/platform/vocechat.html", "vocechat": "https://docs.astrbot.app/deploy/platform/vocechat.html",
"satori": "https://docs.astrbot.app/deploy/platform/satori/llonebot.html", "satori": "https://docs.astrbot.app/deploy/platform/satori/llonebot.html",
"misskey": "https://docs.astrbot.app/deploy/platform/misskey.html", "misskey": "https://docs.astrbot.app/deploy/platform/misskey.html",
"line": "https://docs.astrbot.app/deploy/platform/line.html",
} }
return tutorialMap[platformType] || "https://docs.astrbot.app"; return tutorialMap[platformType] || "https://docs.astrbot.app";
} }

Some files were not shown because too many files have changed in this diff Show More