Merge branch 'master' into dev

# Conflicts:
#	AGENTS.md
#	astrbot/__init__.py
#	astrbot/cli/utils/__init__.py
#	astrbot/core/astr_agent_tool_exec.py
#	astrbot/core/computer/booters/local.py
#	astrbot/core/cron/manager.py
#	astrbot/core/knowledge_base/kb_mgr.py
#	astrbot/core/knowledge_base/retrieval/manager.py
#	astrbot/core/platform/sources/discord/discord_platform_adapter.py
#	astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py
#	astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py
#	astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_server.py
#	astrbot/core/platform/sources/wecom/wecom_adapter.py
#	astrbot/core/platform/sources/wecom_ai_bot/wecomai_adapter.py
#	astrbot/core/provider/func_tool_manager.py
#	astrbot/core/provider/sources/mimo_api_common.py
#	astrbot/core/star/star_manager.py
#	astrbot/core/tools/computer_tools/fs.py
#	astrbot/core/tools/computer_tools/python.py
#	astrbot/core/tools/computer_tools/shell.py
#	astrbot/core/tools/message_tools.py
#	astrbot/core/tools/web_search_tools.py
#	astrbot/core/utils/io.py
#	astrbot/core/utils/tencent_record_helper.py
#	dashboard/package.json
#	dashboard/src/assets/mdi-subset/materialdesignicons-subset.css
#	dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff
#	dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff2
#	dashboard/src/components/ConfirmDialog.vue
#	dashboard/src/components/chat/Chat.vue
#	dashboard/src/components/chat/ChatInput.vue
#	dashboard/src/components/chat/ChatMessageList.vue
#	dashboard/src/components/chat/ConfigSelector.vue
#	dashboard/src/components/chat/MessageList.vue
#	dashboard/src/components/chat/ProjectDialog.vue
#	dashboard/src/components/chat/ProjectList.vue
#	dashboard/src/components/chat/ProjectView.vue
#	dashboard/src/components/chat/ProviderModelMenu.vue
#	dashboard/src/components/chat/RegenerateMenu.vue
#	dashboard/src/components/chat/StandaloneChat.vue
#	dashboard/src/components/chat/ThreadPanel.vue
#	dashboard/src/components/chat/ThreadedMarkdownMessagePart.vue
#	dashboard/src/components/chat/message_list_comps/MarkdownMessagePart.vue
#	dashboard/src/components/chat/message_list_comps/ReasoningTimeline.vue
#	dashboard/src/components/chat/message_list_comps/RefsSidebar.vue
#	dashboard/src/components/config/UnsavedChangesConfirmDialog.vue
#	dashboard/src/components/extension/MarketPluginCard.vue
#	dashboard/src/components/extension/McpServersSection.vue
#	dashboard/src/components/extension/SkillsSection.vue
#	dashboard/src/components/extension/componentPanel/components/DetailsDialog.vue
#	dashboard/src/components/extension/componentPanel/components/RenameDialog.vue
#	dashboard/src/components/folder/BaseCreateFolderDialog.vue
#	dashboard/src/components/folder/BaseFolderItemSelector.vue
#	dashboard/src/components/folder/BaseMoveToFolderDialog.vue
#	dashboard/src/components/platform/AddNewPlatform.vue
#	dashboard/src/components/provider/AddNewProvider.vue
#	dashboard/src/components/provider/ProviderModelsPanel.vue
#	dashboard/src/components/shared/BackupDialog.vue
#	dashboard/src/components/shared/ChangelogDialog.vue
#	dashboard/src/components/shared/ExtensionCard.vue
#	dashboard/src/components/shared/FileConfigItem.vue
#	dashboard/src/components/shared/KnowledgeBaseSelector.vue
#	dashboard/src/components/shared/ListConfigItem.vue
#	dashboard/src/components/shared/ObjectEditor.vue
#	dashboard/src/components/shared/PersonaForm.vue
#	dashboard/src/components/shared/PluginSetSelector.vue
#	dashboard/src/components/shared/ProviderSelector.vue
#	dashboard/src/components/shared/ReadmeDialog.vue
#	dashboard/src/components/shared/SidebarCustomizer.vue
#	dashboard/src/components/shared/T2ITemplateEditor.vue
#	dashboard/src/components/shared/UninstallConfirmDialog.vue
#	dashboard/src/components/shared/WaitingForRestart.vue
#	dashboard/src/composables/useMessages.ts
#	dashboard/src/composables/useProjects.ts
#	dashboard/src/composables/useProviderModelConfigDialog.ts
#	dashboard/src/composables/useProviderSources.ts
#	dashboard/src/i18n/locales/ru-RU/features/config-metadata.json
#	dashboard/src/i18n/locales/ru-RU/features/extension.json
#	dashboard/src/i18n/locales/ru-RU/features/provider.json
#	dashboard/src/layouts/full/vertical-header/VerticalHeader.vue
#	dashboard/src/scss/_override.scss
#	dashboard/src/stores/common.js
#	dashboard/src/stores/customizer.ts
#	dashboard/src/views/ConfigPage.vue
#	dashboard/src/views/ConversationPage.vue
#	dashboard/src/views/PlatformPage.vue
#	dashboard/src/views/SessionManagementPage.vue
#	dashboard/src/views/WelcomePage.vue
#	dashboard/src/views/alkaid/KnowledgeBase.vue
#	dashboard/src/views/alkaid/LongTermMemory.vue
#	dashboard/src/views/authentication/auth/LoginPage.vue
#	dashboard/src/views/extension/InstalledPluginsTab.vue
#	dashboard/src/views/extension/useExtensionPage.js
#	dashboard/src/views/knowledge-base/DocumentDetail.vue
#	dashboard/src/views/knowledge-base/KBList.vue
#	dashboard/src/views/knowledge-base/components/DocumentsTab.vue
#	dashboard/src/views/knowledge-base/components/SettingsTab.vue
#	dashboard/src/views/knowledge-base/components/TavilyKeyDialog.vue
#	dashboard/src/views/persona/FolderTree.vue
#	dashboard/src/views/persona/MoveToFolderDialog.vue
#	dashboard/src/views/persona/PersonaManager.vue
#	requirements.txt
#	tests/test_local_shell_component.py
#	tests/unit/test_func_tool_manager.py
#	tests/unit/test_web_search_tools.py
This commit is contained in:
LIghtJUNction
2026-07-16 17:39:40 +08:00
231 changed files with 17462 additions and 5837 deletions
-57
View File
@@ -1,57 +0,0 @@
name: 🥳 发布插件
description: 提交插件到插件市场
title: "[Plugin] 插件名"
labels: ["plugin-publish"]
assignees: []
body:
- type: markdown
attributes:
value: |
欢迎发布插件到插件市场!
- type: markdown
attributes:
value: |
## 插件基本信息
请将插件信息填写到下方的 JSON 代码块中。其中 `tags`(插件标签)和 `social_link`(社交链接)选填。
不熟悉 JSON ?可以从 [此站](https://plugins.astrbot.app) 右下角提交。
- type: textarea
id: plugin-info
attributes:
label: 插件信息
description: 请在下方代码块中填写您的插件信息,确保反引号包裹了JSON
value: |
```json
{
"name": "插件名,请以 astrbot_plugin_ 开头",
"display_name": "用于展示的插件名,方便人类阅读",
"desc": "插件的简短介绍",
"author": "作者名",
"repo": "插件仓库链接",
"tags": [],
"social_link": "",
}
```
validations:
required: true
- type: markdown
attributes:
value: |
## 检查
- type: checkboxes
id: checks
attributes:
label: 插件检查清单
description: 请确认以下所有项目
options:
- label: 我的插件经过完整的测试
required: true
- label: 我的插件不包含恶意代码
required: true
- label: 我已阅读并同意遵守该项目的 [行为准则](https://docs.github.com/zh/site-policy/github-terms/github-community-code-of-conduct)。
required: true
+1
View File
@@ -8,6 +8,7 @@ uv.lock
# IDE and editors
.vscode
.idea
.zed/
# Logs and temporary files
botpy.log
+6 -2
View File
@@ -142,7 +142,7 @@ Stars are plugins in `astrbot/builtin_stars/`:
ruff check .
```
4. **Comments** - Use English for all comments and docstrings
4. **Comments and logs** - Use English for all comments, docstrings, and log messages
5. **Imports** - Use absolute imports via `astrbot.` prefix
@@ -230,6 +230,10 @@ chore: maintenance tasks
5. When modifying frontend/dashboard code, use the project's custom request module `@/utils/request` for HTTP calls
6. For fetch or SSE URLs, use `resolveApiUrl('/api/your-path')` so the configured `VITE_API_BASE` and dev proxy rules are respected
7. Do not import the plain `axios` package directly in dashboard source files
8. When backend API routes, request/response schemas, or OpenAPI definitions change, regenerate the frontend API client with `cd dashboard && pnpm generate:api`
9. Keep `[project].version` in `pyproject.toml` and `__version__` in `astrbot/__init__.py` in sync; `VERSION` in `astrbot/core/config/default.py` must derive from `astrbot.__version__` rather than hardcoding a separate value
10. For WebUI dialogs, use `text-h3 pa-4 pb-0 pl-6` as the base title class and use `variant="text"` or `variant="tonal"` for dialog buttons
11. Consider Windows, macOS, and Linux behavior on both Arm64 and x86 architectures, and preserve compatibility with Python 3.12+
## Common Tasks
@@ -254,4 +258,4 @@ chore: maintenance tasks
1. Replace current version name to specific version name.
2. Write changelog in `changelogs/`, you can refer to the full commit messages between the latest tag to the latest commit.
3. Make and push a commit into master branch with message format like: `chore: bump version to 4.25.0`
4. Create a tag and push the tag. For example: `git tag v4.25.0 && git push origin v4.25.0`
4. Create a tag and push the tag. For example: `git tag v4.25.0 && git push origin v4.25.0`
+3 -2
View File
@@ -1,4 +1,4 @@
![astrbot-github-banner-v2-light-0405_副本](https://github.com/user-attachments/assets/36fb04e4-cc75-4454-bd8b-049d11aa86f9)
![AstrBot-Logo-Simplified](https://github.com/user-attachments/assets/ffd99b6b-3272-4682-beaa-6fe74250f7d9)
<div align="center">
@@ -7,6 +7,7 @@
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_zh-TW.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_es.md">Español</a>
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ru.md">Русский</a>
<br>
@@ -20,7 +21,7 @@
<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://img.shields.io/badge/python-3.12+-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>
+289
View File
@@ -0,0 +1,289 @@
![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_zh.md">简体中文</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_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_ru.md">Русский</a>
<br>
<div>
<a href="https://trendshift.io/repositories/21369" target="_blank"><img src="https://trendshift.io/api/badge/repositories/21369" alt="AstrBotDevs%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=%20plugins&label=Marketplace&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/">Documentación</a>
<a href="https://blog.astrbot.app/">Blog</a>
<a href="https://astrbot.featurebase.app/roadmap">Hoja de ruta</a>
<a href="https://github.com/AstrBotDevs/AstrBot/issues">Registro de incidencias</a>
<a href="mailto:community@astrbot.app">Soporte por correo</a>
</div>
AstrBot es una plataforma de chatbot Agent todo en uno de código abierto que se integra con las principales aplicaciones de mensajería instantánea. Proporciona una infraestructura de IA conversacional confiable y escalable para individuos, desarrolladores y equipos. Ya sea que estés construyendo un compañero de IA personal, un servicio de atención al cliente inteligente, un asistente de automatización o una base de conocimiento empresarial, AstrBot te permite crear rápidamente aplicaciones de IA listas para producción dentro de los flujos de trabajo de tu plataforma de mensajería instantánea.
![screenshot_1 5x_postspark_2026-02-27_22-37-45](https://github.com/user-attachments/assets/f17cdb90-52d7-4773-be2e-ff64b566af6b)
## Características principales
1. 💯 Gratis y de código abierto.
2. ✨ Conversaciones con LLM de IA, multimodal, Agent, MCP, habilidades, base de conocimiento, configuración de personalidad, compresión automática de contexto.
3. 🤖 Soporta integración con Dify, Alibaba Cloud Bailian, Coze y otras plataformas de Agent.
4. 🌐 Multiplataforma: QQ, WeChat Work, Feishu, DingTalk, cuentas oficiales de WeChat, Telegram, Slack y [más](#plataformas-de-mensajería-soportadas).
5. 📦 Extensiones mediante plugins con más de 1000 plugins disponibles para instalación en un clic.
6. 🛡️ [Agent Sandbox](https://docs.astrbot.app/use/astrbot-agent-sandbox.html) para ejecución aislada y segura de código, llamadas a shell y reutilización de recursos a nivel de sesión.
7. 💻 Soporte de WebUI.
8. 🌈 Soporte de Web ChatUI con Agent Sandbox integrado y búsqueda web.
9. 🌐 Soporte de internacionalización (i18n).
<br>
<table align="center">
<tr align="center">
<th>💙 Juego de roles y compañía emocional</th>
<th>✨ Agent proactivo</th>
<th>🚀 Capacidades Agentic generales</th>
<th>🧩 Más de 1000 plugins de la comunidad</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>
## Inicio rápido
### Despliegue en un clic
Para los usuarios que quieran experimentar AstrBot rápidamente, estén familiarizados con el uso de la línea de comandos y puedan instalar un entorno `uv` por su cuenta, recomendamos el método de despliegue en un clic con `uv` ⚡️:
```bash
uv tool install astrbot --python 3.12
astrbot init # Ejecuta este comando solo la primera vez para inicializar el entorno
astrbot run
```
> Requiere tener [uv](https://docs.astral.sh/uv/) instalado.
> AstrBot requiere Python 3.12 o superior. La opción `--python 3.12` asegura que `uv` cree el entorno de la herramienta con Python 3.12.
> [!NOTE]
> Para usuarios de macOS: debido a las comprobaciones de seguridad de macOS, la primera ejecución del comando `astrbot` puede tardar más (aproximadamente 10-20s).
Actualizar `astrbot`:
```bash
uv tool upgrade astrbot --python 3.12
```
> [!WARNING]
> AstrBot desplegado mediante `uv` **no soporta la actualización a través de la WebUI**. Para actualizar, ejecuta el comando anterior desde la línea de comandos.
### Despliegue con Docker
Para usuarios familiarizados con contenedores y que buscan un método de despliegue más estable y listo para producción, recomendamos desplegar AstrBot con Docker / Docker Compose.
Consulta la documentación oficial: [Desplegar AstrBot con Docker](https://docs.astrbot.app/deploy/astrbot/docker.html#%E4%BD%BF%E7%94%A8-docker-%E9%83%A8%E7%BD%B2-astrbot).
### Desplegar en RainYun
Para usuarios que desean un despliegue en un clic y no quieren administrar servidores por sí mismos, recomendamos el servicio de despliegue en la nube en un clic de RainYun ☁️:
[![Desplegar en RainYun](https://rainyun-apps.cn-nb1.rains3.com/materials/deploy-on-rainyun-en.svg)](https://app.rainyun.com/apps/rca/store/5994?ref=NjU1ODg0)
### Despliegue como aplicación de escritorio
Para usuarios que quieran usar AstrBot en el escritorio y principalmente usen ChatUI, recomendamos AstrBot App.
Visita [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop) para descargar e instalar; este método está diseñado para uso en escritorio y no se recomienda para escenarios de servidor.
### Despliegue con Launcher
Para usuarios de escritorio que también desean un despliegue rápido y uso aislado de múltiples instancias, recomendamos AstrBot Launcher.
Visita [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) para descargar e instalar.
### Desplegar en Replit
El despliegue en Replit es mantenido por la comunidad y es adecuado para demostraciones en línea y pruebas ligeras.
[![Ejecutar en Repl.it](https://repl.it/badge/github/AstrBotDevs/AstrBot)](https://repl.it/github/AstrBotDevs/AstrBot)
### AUR
El despliegue mediante AUR está dirigido a usuarios de Arch Linux que prefieren instalar AstrBot a través del flujo de trabajo de paquetes del sistema.
Ejecuta el siguiente comando para instalar `astrbot-git`, luego inicia AstrBot en tu entorno local.
```bash
yay -S astrbot-git
```
**Más métodos de despliegue**
Si necesitas gestión basada en panel o una personalización más profunda, consulta [Despliegue con BT-Panel](https://docs.astrbot.app/deploy/astrbot/btpanel.html) para la configuración desde la tienda de aplicaciones de BT Panel, [Despliegue con 1Panel](https://docs.astrbot.app/deploy/astrbot/1panel.html) para el despliegue desde el mercado de aplicaciones de 1Panel, [Despliegue con CasaOS](https://docs.astrbot.app/deploy/astrbot/casaos.html) para despliegue visual en NAS/servidor doméstico, y [Despliegue manual](https://docs.astrbot.app/deploy/astrbot/cli.html) para una instalación completamente personalizada desde el código fuente con `uv`.
## Plataformas de mensajería soportadas
Conecta AstrBot a tu plataforma de chat favorita.
| Plataforma | Mantenedor |
|---------|---------------|
| QQ | Oficial |
| Implementación del protocolo OneBot v11 | Oficial |
| Telegram | Oficial |
| Wecom y Wecom AI Bot | Oficial |
| Cuentas oficiales de WeChat | Oficial |
| Feishu (Lark) | Oficial |
| DingTalk | Oficial |
| Slack | Oficial |
| Discord | Oficial |
| LINE | Oficial |
| Satori | Oficial |
| KOOK | Oficial |
| Misskey | Oficial |
| Mattermost | Oficial |
| WhatsApp (Próximamente) | Oficial |
| [Matrix](https://github.com/stevessr/astrbot_plugin_matrix_adapter) | Comunidad |
| [Rocket.Chat](https://github.com/NET-Homeless/astrbot_plugin_rocket_chat_adapter) | Comunidad |
| [VoceChat](https://github.com/HikariFroya/astrbot_plugin_vocechat) | Comunidad |
## Servicios de modelo soportados
| Servicio | Tipo |
|---------|---------------|
| OpenAI y servicios compatibles | Servicios LLM |
| Anthropic | Servicios LLM |
| Google Gemini | Servicios LLM |
| Moonshot AI | Servicios LLM |
| Zhipu AI | Servicios LLM |
| DeepSeek | Servicios LLM |
| Ollama (Autoalojado) | Servicios LLM |
| LM Studio (Autoalojado) | Servicios LLM |
| [AIHubMix](https://aihubmix.com/?aff=4bfH) | Servicios LLM (API Gateway, soporta todos los modelos) |
| [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_astrbot&referral_code=FV7DcGowN4hB5UuXKgpE74) | Servicios LLM |
| [302.AI](https://share.302.ai/rr1M3l) | Servicios LLM |
| [TokenPony](https://www.tokenpony.cn/3YPyf) | Servicios LLM |
| [SiliconFlow](https://docs.siliconflow.cn/cn/usercases/use-siliconcloud-in-astrbot) | Servicios LLM |
| [PPIO Cloud](https://ppio.com/user/register?invited_by=AIOONE) | Servicios LLM |
| ModelScope | Servicios LLM |
| OneAPI | Servicios LLM |
| Dify | Plataformas LLMOps |
| Aplicaciones de Alibaba Cloud Bailian | Plataformas LLMOps |
| Coze | Plataformas LLMOps |
| OpenAI Whisper | Servicios de voz a texto |
| SenseVoice | Servicios de voz a texto |
| Xiaomi MiMo Omni | Servicios de voz a texto |
| OpenAI TTS | Servicios de texto a voz |
| Gemini TTS | Servicios de texto a voz |
| GPT-Sovits-Inference | Servicios de texto a voz |
| GPT-Sovits | Servicios de texto a voz |
| FishAudio | Servicios de texto a voz |
| Edge TTS | Servicios de texto a voz |
| Alibaba Cloud Bailian TTS | Servicios de texto a voz |
| Azure TTS | Servicios de texto a voz |
| Minimax TTS | Servicios de texto a voz |
| Xiaomi MiMo TTS | Servicios de texto a voz |
| Volcano Engine TTS | Servicios de texto a voz |
## ❤️ Patrocinadores
<p align="center">
<img alt="sponsors" src="https://sponsors.astrbot.app/?v=1">
</p>
## ❤️ Contribuir
¡Issues y Pull Requests son siempre bienvenidos! No dudes en enviar tus cambios a este proyecto :)
### Cómo contribuir
Puedes contribuir revisando issues o ayudando con la revisión de pull requests. Cualquier issue o PR es bienvenido para fomentar la participación de la comunidad. Por supuesto, estas son solo sugerencias: puedes contribuir de la manera que prefieras. Para agregar nuevas funcionalidades, por favor discútelo primero a través de un Issue.
### Entorno de desarrollo
AstrBot usa `ruff` para el formateo y linting de código.
```bash
git clone https://github.com/AstrBotDevs/AstrBot
pip install pre-commit
pre-commit install
```
## 🌍 Comunidad
### Grupos de QQ
- Grupo 1: 322154837 (Lleno)
- Grupo 3: 630166526 (Lleno)
- Grupo 4: 1077826412 (Lleno)
- Grupo 5: 822130018 (Lleno)
- Grupo 6: 753075035 (Lleno)
- Grupo 7: 743746109 (Lleno)
- Grupo 8: 1030353265 (Lleno)
- Grupo 9: 1076659624 (Lleno)
- Grupo 10: 1078079676 (Lleno)
- Grupo 11: 704659519 (Lleno)
- Grupo 12: 916228568 (Lleno)
- Grupo 13: 1092185289
- Grupo 14: 1103419483
- Grupo de desarrolladores (Charla): 975206796
- Grupo de desarrolladores (Formal): 1039761811
### Servidor de Discord
<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>
## ❤️ Agradecimientos especiales
Un agradecimiento especial a todos los contribuidores y desarrolladores de plugins por sus contribuciones a AstrBot ❤️
<a href="https://github.com/AstrBotDevs/AstrBot/graphs/contributors">
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot&max=300&columns=15" />
</a>
Además, el nacimiento de este proyecto no habría sido posible sin la ayuda de los siguientes proyectos de código abierto:
- [NapNeko/NapCatQQ](https://github.com/NapNeko/NapCatQQ) - El increíble framework felino
## ⭐ Historial de estrellas
> [!TIP]
> Si este proyecto te ha ayudado en tu vida o trabajo, o si estás interesado en su desarrollo futuro, por favor dale una estrella al proyecto. Es la fuerza impulsora detrás del mantenimiento de este proyecto de código abierto <3
<div align="center">
[![Gráfico de historial de estrellas](https://api.star-history.com/svg?repos=astrbotdevs/astrbot&type=Date)](https://star-history.com/#astrbotdevs/astrbot&Date)
</div>
<div align="center">
_La compañía y la capacidad nunca deberían estar en conflicto. Lo que aspiramos a crear es un robot que pueda entender emociones, proporcionar compañía genuina y realizar tareas de manera confiable._
_私は、高性能ですから!_
<img src="https://files.astrbot.app/watashiwa-koseino-desukara.gif" width="100"/>
</div>
+2 -1
View File
@@ -6,6 +6,7 @@
<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_ja.md">日本語</a>
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_es.md">Español</a>
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ru.md">Русский</a>
<br>
@@ -19,7 +20,7 @@
<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://img.shields.io/badge/python-3.12+-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%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>
+2 -1
View File
@@ -6,6 +6,7 @@
<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_fr.md">Français</a>
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_es.md">Español</a>
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ru.md">Русский</a>
<br>
@@ -19,7 +20,7 @@
<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://img.shields.io/badge/python-3.12+-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%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>
+3 -2
View File
@@ -6,7 +6,8 @@
<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_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_es.md">Español</a>
<br>
@@ -19,7 +20,7 @@
<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://img.shields.io/badge/python-3.12+-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%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>
+2 -1
View File
@@ -6,6 +6,7 @@
<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_fr.md">Français</a>
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_es.md">Español</a>
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ru.md">Русский</a>
<br>
@@ -19,7 +20,7 @@
<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://img.shields.io/badge/python-3.12+-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>
+2 -1
View File
@@ -6,6 +6,7 @@
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_zh-TW.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_es.md">Español</a>
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ru.md">Русский</a>
<div>
@@ -17,7 +18,7 @@
<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://img.shields.io/badge/python-3.12+-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>
+1 -1
View File
@@ -7,7 +7,7 @@ from typing import TYPE_CHECKING, Any
try:
__version__ = _pkg_version("astrbot")
except PackageNotFoundError:
__version__ = "4.26.1"
__version__ = "4.26.4"
if TYPE_CHECKING:
+24 -2
View File
@@ -1,5 +1,6 @@
import re
import shutil
from pathlib import Path
import click
@@ -8,6 +9,7 @@ from astrbot.cli.utils import (
PluginStatus,
build_plug_list,
get_git_repo,
install_local_plugin,
manage_plugin,
)
@@ -136,14 +138,34 @@ def list(all: bool) -> None:
@plug.command()
@click.argument("name")
@click.argument("name", required=False)
@click.option(
"--editable",
"-e",
"local_path",
type=click.Path(exists=True, file_okay=False, path_type=Path),
help="Install a plugin from a local directory as a symlink",
)
@click.option("--proxy", help="Proxy server address")
def install(name: str, proxy: str | None) -> None:
def install(name: str | None, local_path: Path | None, proxy: str | None) -> None:
"""Install a plugin"""
from astrbot.core.utils.astrbot_path import astrbot_paths
base_path = astrbot_paths.data
plug_path = base_path / "plugins"
if local_path is not None:
install_local_plugin(local_path, plug_path, editable=True)
return
if name is None:
raise click.ClickException("Missing plugin name or local plugin path")
local_name_path = Path(name).expanduser()
if local_name_path.exists() and local_name_path.is_dir():
install_local_plugin(local_name_path, plug_path, editable=False)
return
plugins = build_plug_list(base_path / "plugins")
plugin = next(
+8 -1
View File
@@ -1,5 +1,11 @@
from .dashboard import DashboardManager
from .plugin import PluginStatus, build_plug_list, get_git_repo, manage_plugin
from .plugin import (
PluginStatus,
build_plug_list,
get_git_repo,
install_local_plugin,
manage_plugin,
)
from .version_comparator import VersionComparator
__all__ = [
@@ -8,5 +14,6 @@ __all__ = [
"VersionComparator",
"build_plug_list",
"get_git_repo",
"install_local_plugin",
"manage_plugin",
]
+102
View File
@@ -1,5 +1,6 @@
import shutil
import tempfile
import uuid
from enum import Enum
from io import BytesIO
from pathlib import Path
@@ -20,6 +21,35 @@ class PluginStatus(str, Enum):
NOT_PUBLISHED = "unpublished"
LOCAL_PLUGIN_COPY_IGNORE = shutil.ignore_patterns(
".git",
"__pycache__",
"*.pyc",
".venv",
"venv",
".idea",
".vscode",
".zed",
)
def _validate_plugin_dir_name(plugin_name: str, source_path: Path) -> str:
plugin_name = plugin_name.strip()
plugin_path = Path(plugin_name)
has_separator = "/" in plugin_name or "\\" in plugin_name
if (
not plugin_name
or plugin_name in {".", ".."}
or plugin_path.is_absolute()
or has_separator
or plugin_path.name != plugin_name
):
raise click.ClickException(
f"Local plugin {source_path} metadata.yaml has invalid name: {plugin_name}"
)
return plugin_name
def get_git_repo(url: str, target_path: Path, proxy: str | None = None) -> None:
"""Download code from a Git repository and extract to the specified path"""
temp_dir = Path(tempfile.mkdtemp())
@@ -197,6 +227,78 @@ def build_plug_list(plugins_dir: Path) -> list:
return result
def _cleanup_local_plugin_target(target_path: Path) -> None:
if target_path.is_symlink() or target_path.is_file():
target_path.unlink(missing_ok=True)
elif target_path.exists():
shutil.rmtree(target_path, ignore_errors=True)
def _copy_local_plugin(source_path: Path, plugins_dir: Path, target_path: Path) -> None:
temp_target = plugins_dir / f".{target_path.name}.tmp-{uuid.uuid4().hex}"
try:
shutil.copytree(source_path, temp_target, ignore=LOCAL_PLUGIN_COPY_IGNORE)
temp_target.rename(target_path)
except FileExistsError:
raise click.ClickException(
f"Plugin {target_path.name} already exists"
) from None
except Exception:
raise
finally:
if temp_target.exists() or temp_target.is_symlink():
_cleanup_local_plugin_target(temp_target)
def install_local_plugin(
source_path: Path,
plugins_dir: Path,
editable: bool = False,
) -> None:
"""Install a plugin from a local directory."""
source_path = source_path.expanduser().resolve()
plugins_dir = plugins_dir.resolve()
if not source_path.exists() or not source_path.is_dir():
raise click.ClickException(f"Local plugin path does not exist: {source_path}")
metadata = load_yaml_metadata(source_path)
plugin_name = metadata.get("name")
if not isinstance(plugin_name, str) or not plugin_name.strip():
raise click.ClickException(
f"Local plugin {source_path} must contain metadata.yaml with a valid name"
)
plugin_name = _validate_plugin_dir_name(plugin_name, source_path)
target_path = plugins_dir / plugin_name
if target_path.exists():
raise click.ClickException(f"Plugin {plugin_name} already exists")
try:
plugins_dir.mkdir(parents=True, exist_ok=True)
if editable:
try:
target_path.symlink_to(source_path, target_is_directory=True)
except OSError as e:
raise click.ClickException(
f"Failed to create symlink for editable install: {e}. "
"On Windows, you may need to run as Administrator or enable Developer Mode."
) from e
else:
_copy_local_plugin(source_path, plugins_dir, target_path)
click.echo(f"Plugin {plugin_name} installed successfully from {source_path}")
except FileExistsError:
raise click.ClickException(f"Plugin {plugin_name} already exists") from None
except click.ClickException:
raise
except Exception as e:
if editable and target_path.is_symlink():
_cleanup_local_plugin_target(target_path)
raise click.ClickException(
f"Error installing local plugin {plugin_name}: {e}"
) from e
def manage_plugin(
plugin: dict,
plugins_dir: Path,
+140 -28
View File
@@ -410,10 +410,16 @@ def _normalize_mcp_input_schema(schema: dict[str, Any]) -> dict[str, Any]:
class MCPClient:
def __init__(self) -> None:
# Initialize session and client objects
self.session: mcp.ClientSession | None = None
self.exit_stack = AsyncExitStack()
self._old_exit_stacks: list[AsyncExitStack] = [] # Track old stacks for cleanup
# Each connection runs in its own task so that anyio cancel scopes
# are always exited from the task that entered them, preventing
# RuntimeError: Attempted to exit cancel scope in a different task
self._connection_task: asyncio.Task | None = None
self._old_connection_tasks: list[asyncio.Task] = []
# Internal; managed exclusively by _run_connection.
self.exit_stack: AsyncExitStack | None = None
self.name: str | None = None
self.active: bool = True
@@ -422,11 +428,59 @@ class MCPClient:
self.running_event = asyncio.Event()
self.process_pid: int | None = None
# Store connection config for reconnection
self._mcp_server_config: dict | None = None
self._server_name: str | None = None
self._reconnect_lock = asyncio.Lock() # Lock for thread-safe reconnection
self._reconnecting: bool = False # For logging and debugging
self._reconnecting: bool = False
async def _run_connection(
self,
mcp_server_config: dict,
name: str,
ready: asyncio.Future,
) -> None:
"""Own the full lifetime of one MCP connection.
This coroutine is always run inside a dedicated asyncio.Task
(_connection_task). Because *this task* is the one that enters every
anyio cancel scope (via sse_client / streamablehttp_client), anyio's
_host_task check is always satisfied when the stack is later closed —
either in the task's own finally block (normal path) or when the task
is cancelled from outside (cleanup / reconnect path).
This avoids the
RuntimeError: Attempted to exit cancel scope in a different task
that previously occurred when aclose() was called from a different task
or from the asyncio async-generator GC finalizer.
"""
# Capture the stack in a local variable so that if self.exit_stack is
# overwritten by a concurrent _run_connection (during reconnect), this
# task's finally block still closes only the resources it opened.
stack = self.exit_stack = AsyncExitStack()
try:
try:
await self._do_connect(mcp_server_config, name)
except Exception as exc:
if not ready.done():
ready.set_exception(exc)
raise
else:
if not ready.done():
ready.set_result(None)
# Hold the connection open until cancelled.
await asyncio.Event().wait()
finally:
try:
await stack.aclose()
except Exception as e:
logger.debug(f"Error closing exit stack for {name}: {e}")
# Clear the instance reference only if it still points to this task's
# stack; a concurrent reconnect may have already replaced it.
if self.exit_stack is stack:
self.exit_stack = None
# Guard against the task exiting before ready was resolved.
if not ready.done():
ready.set_exception(RuntimeError("Connection task exited early"))
@staticmethod
def _extract_stdio_process_pid(streams_context: object) -> int | None:
@@ -447,7 +501,12 @@ class MCPClient:
return None
async def connect_to_server(self, mcp_server_config: dict, name: str) -> None:
"""Connect to MCP server
"""Connect to MCP server by spawning a dedicated owner task.
The owner task (_connection_task) holds the AsyncExitStack and all
anyio cancel scopes for the lifetime of this connection. To disconnect,
cancel _connection_task — the finally block in _run_connection will call
aclose() from within the correct task context.
If `url` parameter exists:
1. When transport is specified as `streamable_http`, use Streamable HTTP connection.
@@ -458,11 +517,48 @@ class MCPClient:
mcp_server_config (dict): Configuration for the MCP server. See https://modelcontextprotocol.io/quickstart/server
"""
# Store config for reconnection
self._mcp_server_config = mcp_server_config
self._server_name = name
self.process_pid = None
ready: asyncio.Future = asyncio.get_running_loop().create_future()
# Defensively cancel any existing connection task that was not cleaned
# up before this call (e.g. if connect_to_server is called twice).
if self._connection_task and not self._connection_task.done():
self._cancel_connection_task(self._connection_task)
self._connection_task = None
self._connection_task = asyncio.create_task(
self._run_connection(mcp_server_config, name, ready),
name=f"mcp-conn:{name}",
)
try:
await ready
except asyncio.CancelledError:
# Caller was cancelled while waiting — tear down the connection task.
# cancel() is asynchronous; the task will not finish until the next
# event-loop iteration, so we track it in _old_connection_tasks so
# that cleanup() can await it later.
if self._connection_task and not self._connection_task.done():
self._cancel_connection_task(self._connection_task)
self._connection_task = None
raise
except Exception:
# _do_connect raised; the connection task's finally block may still
# be running (e.g. awaiting stack.aclose()). Track it so that
# cleanup() can await it, but do NOT cancel it — we want the
# finally block to finish cleaning up resources naturally.
if self._connection_task and not self._connection_task.done():
self._old_connection_tasks.append(self._connection_task)
self._connection_task = None
raise
async def _do_connect(self, mcp_server_config: dict, name: str) -> None:
"""Internal: perform the actual connection inside _run_connection's task."""
# exit_stack is always set by _run_connection before _do_connect is called.
assert self.exit_stack is not None
cfg = _prepare_config(mcp_server_config.copy())
async def logging_callback(
@@ -612,9 +708,26 @@ class MCPClient:
self.tools = response.tools
return response
def _cancel_connection_task(self, task: asyncio.Task) -> None:
"""Cancel a connection owner task and track it until it finishes."""
# Prune already-finished tasks to avoid accumulating references over
# many reconnections in a long-running process.
self._old_connection_tasks = [
t for t in self._old_connection_tasks if not t.done()
]
if task.done():
return
task.cancel()
self._old_connection_tasks.append(task)
async def _reconnect(self) -> None:
"""Reconnect to the MCP server using the stored configuration.
Cancels the current _connection_task (which owns the exit_stack and all
anyio cancel scopes) and starts a fresh one. Because each connection
task enters and exits its own anyio cancel scope, there is no
cross-task cancel-scope violation and no GC finalizer surprise.
Uses asyncio.Lock to ensure thread-safe reconnection in concurrent environments.
Raises:
@@ -622,7 +735,6 @@ class MCPClient:
"""
async with self._reconnect_lock:
# Check if already reconnecting (useful for logging)
if self._reconnecting:
logger.debug(
f"MCP Client {self._server_name} is already reconnecting, skipping",
@@ -638,17 +750,16 @@ class MCPClient:
f"Attempting to reconnect to MCP server {self._server_name}...",
)
# Save old exit_stack for later cleanup (don't close it now to avoid cancel scope issues)
if self.exit_stack:
self._old_exit_stacks.append(self.exit_stack)
# Mark old session as invalid
# Cancel the old connection task. Its finally block will call
# exit_stack.aclose() from within the correct task context, so
# anyio cancel scopes are exited cleanly without triggering the
# GC-finalizer busy-spin bug.
if self._connection_task and not self._connection_task.done():
self._cancel_connection_task(self._connection_task)
self._connection_task = None
self.session = None
# Create new exit stack for new connection
self.exit_stack = AsyncExitStack()
# Reconnect using stored config
# Reconnect — this creates a new _connection_task.
await self.connect_to_server(self._mcp_server_config, self._server_name)
await self.list_tools_and_save()
@@ -714,19 +825,20 @@ class MCPClient:
return await _call_with_retry()
async def cleanup(self) -> None:
"""Clean up resources including old exit stacks from reconnections"""
# Close current exit stack
try:
await self.exit_stack.aclose()
except Exception as e:
logger.debug(f"Error closing current exit stack: {e}")
"""Clean up resources by cancelling the connection owner task."""
# Cancel current and any old connection tasks via the shared helper so
# all cancellation + tracking behaviour goes through one code path.
if self._connection_task:
self._cancel_connection_task(self._connection_task)
self._connection_task = None
# Don't close old exit stacks as they may be in different task contexts
# They will be garbage collected naturally
# Just clear the list to release references
self._old_exit_stacks.clear()
if self._old_connection_tasks:
pending = [t for t in self._old_connection_tasks if not t.done()]
if pending:
await asyncio.gather(*pending, return_exceptions=True)
self._old_connection_tasks.clear()
# Set running_event first to unblock any waiting tasks
# Set running_event to unblock any waiting tasks
self.running_event.set()
self.process_pid = None
@@ -140,6 +140,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
REPEATED_TOOL_NOTICE_L1_THRESHOLD = 3
REPEATED_TOOL_NOTICE_L2_THRESHOLD = 4
REPEATED_TOOL_NOTICE_L3_THRESHOLD = 5
MALFORMED_TOOL_NAME_PLACEHOLDER = "__malformed_tool_name__"
REPEATED_TOOL_NOTICE_L1_TEMPLATE = (
"\n\n[SYSTEM NOTICE] By the way, you have executed the same tool "
"`{tool_name}` {streak} times consecutively. Double-check whether another "
@@ -527,6 +528,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
)
break
self._sanitize_malformed_tool_calls(resp)
yield resp
return
@@ -684,6 +686,22 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
streak=streak,
)
def _sanitize_malformed_tool_calls(
self,
llm_resp: LLMResponse,
) -> None:
"""Normalize malformed tool call names.
Args:
llm_resp: The LLM response whose tool call lists should be sanitized.
"""
llm_resp.tools_call_name = [
self.MALFORMED_TOOL_NAME_PLACEHOLDER
if tool_name is None or tool_name.strip() == ""
else tool_name
for tool_name in llm_resp.tools_call_name
]
@override
async def step(self):
"""Process a single step of the agent.
@@ -1307,6 +1325,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
)
if requery_resp:
llm_resp = requery_resp
self._sanitize_malformed_tool_calls(llm_resp)
# If the re-query still returns no tool calls, and also does not have a meaningful assistant reply,
# we consider it as a failure of the LLM to follow the tool-use instruction,
@@ -1334,6 +1353,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
)
if repair_resp:
llm_resp = repair_resp
self._sanitize_malformed_tool_calls(llm_resp)
return llm_resp, subset
+4 -4
View File
@@ -531,12 +531,12 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
message_type=session.message_type,
)
cron_event.role = event.role
cfg = ctx.get_config(umo=event.unified_msg_origin) or {}
provider_settings = cfg.get("provider_settings") or {}
config = MainAgentBuildConfig(
tool_call_timeout=run_context.tool_call_timeout,
streaming_response=ctx.get_config()
.get("provider_settings", {})
.get("stream", False),
streaming_response=provider_settings.get("stream", False),
provider_settings=provider_settings,
)
req = ProviderRequest()
req.system_prompt = ""
+63 -33
View File
@@ -29,6 +29,7 @@ from astrbot.core.astr_main_agent_resources import (
TOOL_CALL_PROMPT_SKILLS_LIKE_MODE,
)
from astrbot.core.conversation_mgr import Conversation
from astrbot.core.db import BaseDatabase
from astrbot.core.message.components import File, Image, Record, Reply, Video
from astrbot.core.persona_error_reply import (
extract_persona_custom_error_message_from_persona,
@@ -73,7 +74,6 @@ from astrbot.core.tools.computer_tools import (
RollbackSkillReleaseTool,
RunBrowserSkillTool,
SyncSkillReleaseTool,
normalize_umo_for_workspace,
)
from astrbot.core.tools.cron_tools import FutureTaskTool
from astrbot.core.tools.knowledge_base_tools import (
@@ -115,6 +115,10 @@ from astrbot.core.utils.quoted_message_parser import (
extract_quoted_message_text,
)
from astrbot.core.utils.string_utils import normalize_and_dedupe_strings
from astrbot.core.workspace import (
normalize_umo_for_workspace,
resolve_workspace_root_for_umo,
)
LLM_ERROR_MESSAGE_EXTRA_KEY = "_llm_error_message"
WEEKDAY_NAMES = (
@@ -357,41 +361,63 @@ def _apply_prompt_prefix(req: ProviderRequest, cfg: dict) -> None:
req.prompt = f"{prefix}{req.prompt}"
def _get_workspace_path_for_umo(umo: str) -> Path:
normalized_umo = normalize_umo_for_workspace(umo)
return Path(get_astrbot_workspaces_path()) / normalized_umo
async def _get_workspace_path_for_umo(umo: str, plugin_context: Context) -> Path:
"""Resolve the workspace path for the current request.
Args:
umo: Unified message origin.
plugin_context: Star context containing the database instance.
Returns:
Workspace path used as cwd.
"""
fallback_root = (
Path(get_astrbot_workspaces_path()) / normalize_umo_for_workspace(umo)
).resolve(strict=False)
db = getattr(plugin_context, "_db", None)
if not isinstance(db, BaseDatabase):
return fallback_root
try:
return await resolve_workspace_root_for_umo(umo, db)
except Exception:
return fallback_root
def _apply_workspace_extra_prompt(
async def _apply_workspace_extra_prompt(
event: AstrMessageEvent,
req: ProviderRequest,
plugin_context: Context,
) -> None:
extra_prompt_path = _get_workspace_path_for_umo(event.unified_msg_origin) / (
"EXTRA_PROMPT.md"
workspace_root = await _get_workspace_path_for_umo(
event.unified_msg_origin,
plugin_context,
)
if not extra_prompt_path.is_file():
return
try:
extra_prompt = extra_prompt_path.read_text(encoding="utf-8").strip()
except Exception as exc: # noqa: BLE001
logger.warning(
"Failed to read workspace extra prompt for umo=%s from %s: %s",
event.unified_msg_origin,
extra_prompt_path,
exc,
)
return
if not extra_prompt:
extra_prompts: list[str] = []
extra_prompt_path = workspace_root / "EXTRA_PROMPT.md"
if extra_prompt_path.is_file():
try:
extra_prompt = extra_prompt_path.read_text(encoding="utf-8").strip()
except Exception as exc: # noqa: BLE001
logger.warning(
"Failed to read workspace extra prompt for umo=%s from %s: %s",
event.unified_msg_origin,
extra_prompt_path,
exc,
)
else:
if extra_prompt:
extra_prompts.append(f"From `{extra_prompt_path}`:\n{extra_prompt}")
if not extra_prompts:
return
extra_prompt_text = "\n\n".join(extra_prompts)
req.system_prompt = (
f"{req.system_prompt or ''}\n"
"[Workspace Extra Prompt]\n"
"The following instructions are loaded from the current workspace "
"`EXTRA_PROMPT.md` file.\n"
f"{extra_prompt}\n"
f"{extra_prompt_text}\n"
)
@@ -498,13 +524,13 @@ async def _ensure_persona_and_skills(
skill_manager = SkillManager()
skills = skill_manager.list_skills(active_only=True, runtime=runtime)
skills = _filter_skills_for_current_config(skills, cfg)
workspace_skills = (
skill_manager.list_workspace_skills(
_get_workspace_path_for_umo(event.unified_msg_origin)
workspace_skills: list[SkillInfo] = []
if runtime == "local":
workspace_root = await _get_workspace_path_for_umo(
event.unified_msg_origin,
plugin_context,
)
if runtime == "local"
else []
)
workspace_skills.extend(skill_manager.list_workspace_skills(workspace_root))
if skills or workspace_skills:
if persona and persona.get("skills") is not None:
@@ -989,7 +1015,7 @@ async def _decorate_llm_request(
if tz is None:
tz = plugin_context.get_config().get("timezone")
_append_system_reminders(event, req, cfg, tz)
_apply_workspace_extra_prompt(event, req)
await _apply_workspace_extra_prompt(event, req, plugin_context)
def _plugin_tool_fix(event: AstrMessageEvent, req: ProviderRequest) -> None:
@@ -1590,10 +1616,14 @@ async def build_main_agent(
)
if config.computer_use_runtime == "local":
workspace_root = await _get_workspace_path_for_umo(
event.unified_msg_origin,
plugin_context,
)
workspace_prompt = f"\nCurrent workspace you can use: `{workspace_root}`\n"
tool_prompt += (
f"\nCurrent workspace you can use: "
f"`{_get_workspace_path_for_umo(event.unified_msg_origin)}`\n"
"Unless the user explicitly specifies a different directory, "
workspace_prompt
+ "Unless the user explicitly specifies a different directory, "
"perform all file-related operations in this workspace.\n"
)
+44 -2
View File
@@ -96,6 +96,39 @@ def _decode_bytes_with_fallback(
return output.decode("utf-8", errors="replace")
async def _terminate_shell_process_tree(session: PersistentShellSession) -> None:
"""Terminate the persistent shell and its children after a timeout."""
proc = session._proc
if proc is None or proc.returncode is not None:
return
taskkill_succeeded = False
if sys.platform == "win32":
try:
taskkill_result = await asyncio.to_thread(
subprocess.run,
["taskkill", "/F", "/T", "/PID", str(proc.pid)],
check=False,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
timeout=5,
)
taskkill_succeeded = taskkill_result.returncode == 0
except (OSError, subprocess.SubprocessError):
taskkill_succeeded = False
if not taskkill_succeeded:
try:
proc.kill()
except ProcessLookupError:
pass
try:
await asyncio.wait_for(proc.wait(), timeout=5)
except (TimeoutError, ProcessLookupError):
pass
@dataclass
class LocalShellComponent(ShellComponent):
async def exec(
@@ -113,13 +146,22 @@ class LocalShellComponent(ShellComponent):
key = session_id or "default"
session = PersistentShellSession.get_or_create(key)
return await session.exec(
execution = session.exec(
command,
cwd=cwd,
env=env,
timeout=timeout,
timeout=None if timeout is not None and not background else timeout,
background=background,
)
if background or timeout is None:
return await execution
try:
return await asyncio.wait_for(execution, timeout=timeout)
except TimeoutError:
await _terminate_shell_process_tree(session)
await PersistentShellSession.cleanup(key)
raise
@staticmethod
async def shutdown_all() -> None:
+1 -13
View File
@@ -1593,9 +1593,7 @@ CONFIG_METADATA_2 = {
"enable": False,
"api_key": "",
"api_base": "https://api.xiaomimimo.com/v1",
"model": "mimo-v2-omni",
"mimo-stt-system-prompt": "You are a speech transcription assistant. Transcribe the spoken content from the audio exactly and return only the transcription text.",
"mimo-stt-user-prompt": "Please transcribe the content of the audio and return only the transcription text.",
"model": "mimo-v2.5-asr",
"timeout": "20",
"proxy": "",
},
@@ -2598,16 +2596,6 @@ CONFIG_METADATA_2 = {
"type": "int",
"hint": "超时时间,单位为秒。",
},
"mimo-stt-system-prompt": {
"description": "系统提示词",
"type": "string",
"hint": "用于指导 MiMo STT 转录行为的 system prompt。",
},
"mimo-stt-user-prompt": {
"description": "用户提示词",
"type": "string",
"hint": "附加给 MiMo STT 的用户提示词,用于约束返回结果格式。",
},
"openai-tts-voice": {
"description": "voice",
"type": "string",
+81 -5
View File
@@ -1,5 +1,6 @@
import asyncio
import json
import re
from collections.abc import Awaitable, Callable
from datetime import datetime, timezone
from typing import TYPE_CHECKING, Any
@@ -24,6 +25,70 @@ if TYPE_CHECKING:
from astrbot.core.star.context import Context
_CRONTAB_WEEKDAY_NAMES = ("sun", "mon", "tue", "wed", "thu", "fri", "sat")
_CRONTAB_WEEKDAY_PATTERN = re.compile(r"^(?:(\*)|(\d+)(?:-(\d+))?)(?:/(\d+))?$")
def _normalize_crontab_day_of_week(day_of_week: str) -> str:
"""Normalize standard crontab weekdays for APScheduler.
APScheduler treats numeric weekdays as Monday=0, while standard crontab and
AstrBot's WebUI use Sunday=0/7. Numeric weekday fields are expanded to
weekday names so the scheduled day remains unambiguous.
Args:
day_of_week: The day-of-week field from a five-part crontab expression.
Returns:
A day-of-week field compatible with APScheduler.
Raises:
ValueError: If a numeric weekday value or step is outside the supported
crontab range.
"""
normalized_parts: list[str] = []
for raw_part in day_of_week.split(","):
part = raw_part.strip().lower()
match = _CRONTAB_WEEKDAY_PATTERN.fullmatch(part)
if not match:
normalized_parts.append(part)
continue
wildcard, start_text, end_text, step_text = match.groups()
step = int(step_text or "1")
if step < 1:
raise ValueError("day_of_week step must be greater than 0")
if wildcard:
if step == 1:
normalized_parts.append("*")
continue
values = range(0, 7, step)
else:
start = int(start_text)
end = int(end_text) if end_text is not None else None
if start < 0 or start > 7 or (end is not None and (end < 0 or end > 7)):
raise ValueError("day_of_week values must be between 0 and 7")
if end is not None and start > end:
raise ValueError("day_of_week range start must not exceed end")
if end is None:
end = 7 if step_text else start
values = range(start, end + 1, step)
weekdays: list[int] = []
for value in values:
weekday = 0 if value == 7 else value
if weekday not in weekdays:
weekdays.append(weekday)
if len(weekdays) == 7:
normalized_parts.append("*")
else:
normalized_parts.extend(_CRONTAB_WEEKDAY_NAMES[value] for value in weekdays)
return ",".join(normalized_parts)
class CronJobSchedulingError(Exception):
"""Raised when a cron job fails to be scheduled."""
@@ -191,8 +256,20 @@ class CronJobManager:
if interval_seconds is not None:
trigger = IntervalTrigger(seconds=interval_seconds, timezone=tzinfo)
else:
if not job.cron_expression:
raise ValueError("recurring job missing cron_expression")
minute, hour, day, month, day_of_week = job.cron_expression.split()
normalized_cron_expression = " ".join(
[
minute,
hour,
day,
month,
_normalize_crontab_day_of_week(day_of_week),
]
)
trigger = CronTrigger.from_crontab(
job.cron_expression,
normalized_cron_expression,
timezone=tzinfo,
)
self.scheduler.add_job(
@@ -354,14 +431,13 @@ class CronJobManager:
cron_event.role = "admin" if sender_id in admin_ids else "member"
if cron_payload.get("origin", "tool") == "api":
cron_event.role = "admin"
tool_call_timeout = cfg.get("provider_settings", {}).get(
"tool_call_timeout",
120,
)
provider_settings = cfg.get("provider_settings", {}) or {}
tool_call_timeout = provider_settings.get("tool_call_timeout", 120)
config = MainAgentBuildConfig(
tool_call_timeout=tool_call_timeout,
llm_safety_mode=False,
streaming_response=False,
provider_settings=provider_settings,
)
req = ProviderRequest()
conv = await _get_session_conv(event=cron_event, plugin_context=self.ctx)
+4
View File
@@ -916,6 +916,8 @@ class BaseDatabase(abc.ABC):
title: str,
emoji: str | None = "📁",
description: str | None = None,
workspace_type: str = "session",
workspace_path: str | None = None,
) -> ChatUIProject:
"""Create a new ChatUI project."""
...
@@ -942,6 +944,8 @@ class BaseDatabase(abc.ABC):
title: str | None = None,
emoji: str | None = None,
description: str | None = None,
workspace_type: str | None = None,
workspace_path: str | None = None,
) -> None:
"""Update a ChatUI project."""
...
+4
View File
@@ -447,6 +447,10 @@ class ChatUIProject(TimestampMixin, SQLModel, table=True):
"""Title of the project"""
description: str | None = Field(default=None, max_length=1000)
"""Description of the project"""
workspace_type: str = Field(default="session", nullable=False, max_length=32)
"""Workspace mode: session, project, or custom"""
workspace_path: str | None = Field(default=None, max_length=1024)
"""Custom workspace path"""
__table_args__ = (
UniqueConstraint(
+27
View File
@@ -64,6 +64,7 @@ class SQLiteDatabase(BaseDatabase):
await self._ensure_persona_skills_column(conn)
await self._ensure_persona_custom_error_message_column(conn)
await self._ensure_platform_message_history_checkpoint_column(conn)
await self._ensure_chatui_project_workspace_columns(conn)
await conn.commit()
async def _ensure_persona_folder_columns(self, conn) -> None:
@@ -128,6 +129,23 @@ class SQLiteDatabase(BaseDatabase):
),
)
async def _ensure_chatui_project_workspace_columns(self, conn) -> None:
"""Ensure chatui_projects has workspace configuration columns."""
result = await conn.execute(text("PRAGMA table_info(chatui_projects)"))
columns = {row[1] for row in result.fetchall()}
if "workspace_type" not in columns:
await conn.execute(
text(
"ALTER TABLE chatui_projects "
"ADD COLUMN workspace_type VARCHAR(32) NOT NULL DEFAULT 'session'"
)
)
if "workspace_path" not in columns:
await conn.execute(
text("ALTER TABLE chatui_projects ADD COLUMN workspace_path VARCHAR")
)
# ====
# Platform Statistics
# ====
@@ -1894,6 +1912,8 @@ class SQLiteDatabase(BaseDatabase):
title: str,
emoji: str | None = "📁",
description: str | None = None,
workspace_type: str = "session",
workspace_path: str | None = None,
) -> ChatUIProject:
"""Create a new ChatUI project."""
async with self.get_db() as session:
@@ -1904,6 +1924,8 @@ class SQLiteDatabase(BaseDatabase):
title=title,
emoji=emoji,
description=description,
workspace_type=workspace_type,
workspace_path=workspace_path,
)
session.add(project)
await session.flush()
@@ -1946,6 +1968,8 @@ class SQLiteDatabase(BaseDatabase):
title: str | None = None,
emoji: str | None = None,
description: str | None = None,
workspace_type: str | None = None,
workspace_path: str | None = None,
) -> None:
"""Update a ChatUI project."""
async with self.get_db() as session:
@@ -1958,6 +1982,9 @@ class SQLiteDatabase(BaseDatabase):
values["emoji"] = emoji
if description is not None:
values["description"] = description
if workspace_type is not None:
values["workspace_type"] = workspace_type
values["workspace_path"] = workspace_path
await session.execute(
update(ChatUIProject)
+10
View File
@@ -0,0 +1,10 @@
import os
DESKTOP_MANAGED_RESTART_MESSAGE = (
"AstrBot Desktop manages this backend process. Please restart or update from "
"the desktop app instead of the core WebUI."
)
def is_desktop_managed_backend() -> bool:
return os.environ.get("ASTRBOT_DESKTOP_MANAGED") == "1"
+34 -12
View File
@@ -211,25 +211,45 @@ class KBSQLiteDatabase:
kb_id: str,
offset: int = 0,
limit: int = 100,
search: str | None = None,
) -> list[KBDocument]:
"""列出知识库的所有文档"""
"""List documents in a knowledge base.
Args:
kb_id: Knowledge base ID.
offset: Number of documents to skip.
limit: Maximum number of documents to return.
search: Optional partial match on document name; disabled when None or empty.
Returns:
List of matching KBDocument rows.
"""
async with self.get_db() as session:
stmt = select(KBDocument).where(col(KBDocument.kb_id) == kb_id)
if search:
stmt = stmt.where(col(KBDocument.doc_name).contains(search))
stmt = (
select(KBDocument)
.where(col(KBDocument.kb_id) == kb_id)
.offset(offset)
.limit(limit)
.order_by(desc(KBDocument.created_at))
stmt.offset(offset).limit(limit).order_by(desc(KBDocument.created_at))
)
result = await session.execute(stmt)
return list(result.scalars().all())
async def count_documents_by_kb(self, kb_id: str) -> int:
"""统计知识库的文档数量"""
async def count_documents_by_kb(self, kb_id: str, search: str | None = None) -> int:
"""Count documents in a knowledge base.
Args:
kb_id: Knowledge base ID.
search: Optional partial match on document name; disabled when None or empty.
Returns:
Total number of matching documents.
"""
async with self.get_db() as session:
stmt = select(func.count(col(KBDocument.id))).where(
col(KBDocument.kb_id) == kb_id,
)
if search:
stmt = stmt.where(col(KBDocument.doc_name).contains(search))
result = await session.execute(stmt)
return result.scalar() or 0
@@ -293,13 +313,15 @@ class KBSQLiteDatabase:
return metadata_map
async def delete_document_by_id(self, doc_id: str, vec_db: "FaissVecDB") -> None:
"""删除单个文档及其相关数据"""
# 在知识库表中删除
"""删除单个文档及其相关数据(包括多媒体记录)"""
async with self.get_db() as session, session.begin():
# 删除多媒体记录
delete_media_stmt = delete(KBMedia).where(col(KBMedia.doc_id) == doc_id)
await session.execute(delete_media_stmt)
# 删除文档记录
delete_stmt = delete(KBDocument).where(col(KBDocument.doc_id) == doc_id)
await session.execute(delete_stmt)
await session.commit()
# 在 vec db 中删除相关向量
await vec_db.delete_documents(metadata_filters={"kb_doc_id": doc_id})
@@ -322,7 +344,7 @@ class KBSQLiteDatabase:
async def update_kb_stats(self, kb_id: str, vec_db: "FaissVecDB") -> None:
"""更新知识库统计信息"""
chunk_cnt = await vec_db.count_documents()
chunk_cnt = await vec_db.count_documents(metadata_filter={"kb_id": kb_id})
async with self.get_db() as session, session.begin():
update_stmt = (
+28 -2
View File
@@ -421,11 +421,37 @@ class KBHelper:
self,
offset: int = 0,
limit: int = 100,
search: str | None = None,
) -> list[KBDocument]:
"""列出知识库的所有文档"""
docs = await self.kb_db.list_documents_by_kb(self.kb.kb_id, offset, limit)
"""List documents in the knowledge base.
Args:
offset: Number of documents to skip.
limit: Maximum number of documents to return.
search: Optional partial match on document name; disabled when None or empty.
Returns:
List of matching KBDocument rows.
"""
docs = await self.kb_db.list_documents_by_kb(
self.kb.kb_id,
offset,
limit,
search=search,
)
return docs
async def count_documents(self, search: str | None = None) -> int:
"""Count documents in the knowledge base.
Args:
search: Optional partial match on document name; disabled when None or empty.
Returns:
Total number of matching documents.
"""
return await self.kb_db.count_documents_by_kb(self.kb.kb_id, search=search)
async def get_document(self, doc_id: str) -> KBDocument | None:
"""获取单个文档"""
doc = await self.kb_db.get_document_by_id(doc_id)
+12 -3
View File
@@ -3,6 +3,8 @@ from __future__ import annotations
from pathlib import Path
from typing import TYPE_CHECKING
from sqlalchemy.exc import IntegrityError # type: ignore
from astrbot.core import logger
from astrbot.core.provider.manager import ProviderManager
from astrbot.core.utils.astrbot_path import get_astrbot_knowledge_base_path
@@ -106,6 +108,11 @@ class KnowledgeBaseManager:
"""创建新的知识库实例"""
if embedding_provider_id is None:
raise ValueError("创建知识库时必须提供embedding_provider_id")
# 预先检查名称是否已存在,避免依赖异常字符串匹配
existing = await self.kb_db.get_kb_by_name(kb_name)
if existing:
raise ValueError(f"知识库名称 '{kb_name}' 已存在")
kb = KnowledgeBase(
kb_name=kb_name,
description=description,
@@ -134,9 +141,11 @@ class KnowledgeBaseManager:
await session.commit()
self.kb_insts[kb.kb_id] = kb_helper
return kb_helper
except Exception as e:
if "kb_name" in str(e):
raise ValueError(f"知识库名称 '{kb_name}' 已存在") from e
except IntegrityError as e:
logger.exception("创建知识库失败:唯一约束冲突")
raise ValueError(f"知识库名称 '{kb_name}' 已存在") from e
except Exception:
logger.exception("创建知识库失败")
raise
async def get_kb(self, kb_id: str) -> KBHelper | None:
@@ -167,20 +167,13 @@ class RetrievalManager:
# 5. Rerank
first_rerank = None
for kb_id in kb_ids:
vec_db = kb_options[kb_id]["vec_db"]
if not isinstance(vec_db, FaissVecDB):
logger.warning(f"vec_db for kb_id {kb_id} is not FaissVecDB")
continue
rerank_pi = kb_options[kb_id]["rerank_provider_id"]
if (
vec_db
and vec_db.rerank_provider
and rerank_pi
and rerank_pi == vec_db.rerank_provider.meta().id
):
first_rerank = vec_db.rerank_provider
for kb_opt in kb_options.values():
vec_db = kb_opt.get("vec_db")
rerank_provider = (
getattr(vec_db, "rerank_provider", None) if vec_db else None
)
if rerank_provider is not None:
first_rerank = rerank_provider
break
if first_rerank and retrieval_results:
try:
@@ -231,10 +224,11 @@ class RetrievalManager:
all_results.extend(vec_results)
except Exception as e:
logger.error(f"知识库 {kb_id} 稠密检索失败: {e}", exc_info=True)
if len(kb_ids) == 1:
raise RuntimeError(f"知识库 {kb_id} 稠密检索失败: {e}") from e
# multi-KB: skip the faulty KB and continue
logger.error(
f"知识库 {kb_id} 稠密检索失败: {type(e).__name__}: {e}",
exc_info=True,
)
# skip the faulty KB and continue
# 按相似度排序并返回 top_k
all_results.sort(key=lambda x: x.similarity, reverse=True)
+23
View File
@@ -211,6 +211,29 @@ class RespondStage(Stage):
if result.result_content_type == ResultContentType.STREAMING_FINISH:
event.set_extra("_streaming_finished", True)
return
sent_plain_texts = event.get_extra(
"_send_message_to_user_current_session_plain_texts",
[],
)
result_plain_text = result.get_plain_text().strip()
if (
result_plain_text
and isinstance(sent_plain_texts, list)
and result_plain_text in sent_plain_texts
and all(
comp.type
in {
ComponentType.Plain,
ComponentType.Reply,
ComponentType.At,
}
for comp in result.chain
)
):
logger.info(
"send_message_to_user already delivered the same text in this session, skip respond stage to avoid duplicate reply.",
)
return
logger.info(
f"Prepare to send - {event.get_sender_name()}/{event.get_sender_id()}: {event._outline_chain(result.chain)}",
+4 -3
View File
@@ -271,10 +271,11 @@ class AstrMessageEvent(abc.ABC):
match = re.search(pattern, buffer)
if not match:
break
matched_text = match.group()
await self.send(MessageChain([Plain(matched_text)]))
matched_text = match.group().strip()
if matched_text:
await self.send(MessageChain([Plain(matched_text)]))
await asyncio.sleep(1.5) # 限速
buffer = buffer[match.end() :]
await asyncio.sleep(1.5) # 限速
return buffer
async def send_streaming(
@@ -443,7 +443,8 @@ class AiocqhttpMessageEvent(AstrMessageEvent):
await self.send(MessageChain(chain=[comp]))
await asyncio.sleep(1.5) # 限速
if buffer.strip():
buffer = buffer.strip()
if buffer:
await self.send(MessageChain([Plain(buffer)]))
return await super().send_streaming(generator, use_fallback)
@@ -608,7 +608,8 @@ class DiscordPlatformAdapter(Platform):
if not cmd_name:
return None
if not re.match(r"^[a-z0-9_-]{1,32}$", cmd_name):
# Discord 斜杠指令名称规范
if cmd_name != cmd_name.lower() or not re.match(r"^[-_'\\w]{1,32}$", cmd_name):
logger.debug(f"[Discord] Skipping invalid slash command format: {cmd_name}")
return None
@@ -28,12 +28,16 @@ from tenacity import (
from astrbot.api import logger
from astrbot.api.event import AstrMessageEvent, MessageChain
from astrbot.api.message_components import File, Image, Plain, Record, Video
from astrbot.api.platform import AstrBotMessage, PlatformMetadata
from astrbot.api.platform import AstrBotMessage, Group, PlatformMetadata
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
from astrbot.core.utils.io import download_image_by_url, file_to_base64
from astrbot.core.utils.tencent_record_helper import wav_to_tencent_silk
class APIReturnNoneError(Exception):
pass
def _patch_qq_botpy_formdata() -> None:
"""Patch qq-botpy for aiohttp>=3.12 compatibility.
@@ -52,21 +56,24 @@ def _patch_qq_botpy_formdata() -> None:
_patch_qq_botpy_formdata()
# Retry decorator for QQ Official API transient errors (HTTP 500/504)
_qqofficial_retry = retry(
retry=retry_if_exception_type(
(
botpy.errors.ServerError,
botpy.errors.SequenceNumberError,
OSError,
asyncio.TimeoutError,
def _qqofficial_retry(max_attempts: int = 5):
"""Retry decorator for QQ Official API transient errors (HTTP 500/504)"""
return retry(
retry=retry_if_exception_type(
(
botpy.errors.ServerError,
botpy.errors.SequenceNumberError,
OSError,
asyncio.TimeoutError,
APIReturnNoneError,
)
),
),
stop=stop_after_attempt(5),
wait=wait_exponential(multiplier=2, min=2, max=30),
before_sleep=before_sleep_log(logger, logging.WARNING),
reraise=True,
)
stop=stop_after_attempt(max_attempts),
wait=wait_exponential(multiplier=2, min=2, max=30),
before_sleep=before_sleep_log(logger, logging.WARNING),
reraise=True,
)
class QQOfficialMessageEvent(AstrMessageEvent):
@@ -89,6 +96,24 @@ class QQOfficialMessageEvent(AstrMessageEvent):
self.bot = bot
self.send_buffer = None
async def send_typing(self) -> None:
"""QQ Official does not expose a typing-state API."""
async def stop_typing(self) -> None:
"""QQ Official does not expose a typing-state API."""
async def _pre_send(self) -> None:
"""Compatibility hook retained for the platform event contract."""
async def get_group(
self,
group_id: str | None = None,
**kwargs: Any,
) -> Group | None:
"""QQ Official does not currently expose group metadata lookup."""
_ = group_id, kwargs
return None
async def send(self, message: MessageChain) -> None:
self.send_buffer = message
await self._post_send()
@@ -433,21 +458,35 @@ class QQOfficialMessageEvent(AstrMessageEvent):
"file_type": file_type,
"srv_send_msg": False,
}
result = None
if "openid" in kwargs:
payload["openid"] = kwargs["openid"]
route = Route("POST", "/v2/users/{openid}/files", openid=kwargs["openid"])
@_qqofficial_retry()
async def _do_upload():
if "openid" in kwargs:
payload["openid"] = kwargs["openid"]
route = Route(
"POST", "/v2/users/{openid}/files", openid=kwargs["openid"]
)
elif "group_openid" in kwargs:
payload["group_openid"] = kwargs["group_openid"]
route = Route(
"POST",
"/v2/groups/{group_openid}/files",
group_openid=kwargs["group_openid"],
)
else:
raise ValueError("Invalid upload parameters")
result = await self.bot.api._http.request(route, json=payload)
elif "group_openid" in kwargs:
payload["group_openid"] = kwargs["group_openid"]
route = Route(
"POST",
"/v2/groups/{group_openid}/files",
group_openid=kwargs["group_openid"],
)
result = await self.bot.api._http.request(route, json=payload)
else:
raise ValueError("Invalid upload parameters")
if result is None:
err_msg = "上传图片API返回None,触发重试"
raise APIReturnNoneError(err_msg)
return result
try:
result = await _do_upload()
except APIReturnNoneError:
logger.warning(f"上传图片API返回None,共尝试5次后放弃: {payload}")
raise
if not isinstance(result, dict):
raise RuntimeError(
f"Failed to upload image, response is not dict: {result}",
@@ -489,8 +528,17 @@ class QQOfficialMessageEvent(AstrMessageEvent):
)
else:
return None
try:
@_qqofficial_retry()
async def _do_upload():
result = await self.bot.api._http.request(route, json=payload)
if result is None:
err_msg = "上传文件API返回None,触发重试"
raise APIReturnNoneError(err_msg)
return result
try:
result = await _do_upload()
if result:
if not isinstance(result, dict):
logger.error(f"上传文件响应格式错误: {result}")
@@ -500,6 +548,8 @@ class QQOfficialMessageEvent(AstrMessageEvent):
file_info=result["file_info"],
ttl=result.get("ttl", 0),
)
except APIReturnNoneError:
logger.warning(f"上传文件API返回None,共尝试5次后放弃: {file_source}")
except (botpy.errors.ServerError, botpy.errors.SequenceNumberError):
logger.error(f"上传媒体文件失败,共尝试5次后放弃: {file_source}")
except Exception as e:
@@ -530,10 +580,26 @@ class QQOfficialMessageEvent(AstrMessageEvent):
stream_data.pop("id", None)
payload["stream"] = stream_data
route = Route("POST", "/v2/users/{openid}/messages", openid=openid)
result = await self.bot.api._http.request(route, json=payload)
if result is None:
logger.warning("[QQOfficial] post_c2c_message: API 返回 None,跳过本次发送")
retry_times = 3
@_qqofficial_retry(retry_times)
async def _do_request():
result = await self.bot.api._http.request(route, json=payload)
if result is None:
err_msg = "发送消息API返回None,触发重试"
raise APIReturnNoneError(err_msg)
return result
result = None
try:
result = await _do_request()
except APIReturnNoneError:
logger.warning(
f"[QQOfficial] post_c2c_message: 发送消息失败,API 返回 None,共尝试{retry_times}次后放弃"
)
return None
if not isinstance(result, dict):
logger.error(f"[QQOfficial] post_c2c_message: 响应不是 dict: {result}")
return None
@@ -22,10 +22,11 @@ from typing import Any
import botpy
import botpy.message
from botpy import Client
from botpy.connection import ConnectionState
from astrbot import logger
from astrbot.api.event import MessageChain
from astrbot.api.message_components import At, File, Image, Plain, Record, Video
from astrbot.api.message_components import At, File, Image, Plain, Record, Reply, Video
from astrbot.api.platform import (
AstrBotMessage,
MessageMember,
@@ -46,6 +47,129 @@ for handler in logging.root.handlers[:]:
logging.root.removeHandler(handler)
def _set_raw_message_fields(message: Any, data: dict[str, Any]) -> None:
"""Preserve QQ message fields that qq-botpy does not expose.
Args:
message: Patched qq-botpy message object.
data: Raw message payload from QQ.
Returns:
None.
"""
if not isinstance(data, dict):
data = {}
message.raw_data = data
message.message_type = data.get("message_type")
msg_elements = data.get("msg_elements")
message.msg_elements = msg_elements if isinstance(msg_elements, list) else []
class PatchedMessage(botpy.message.Message):
__slots__ = ("raw_data", "message_type", "msg_elements")
def __init__(
self,
api: Any,
event_id: str | None,
data: dict[str, Any],
) -> None:
super().__init__(api, event_id, data)
_set_raw_message_fields(self, data)
class PatchedDirectMessage(botpy.message.DirectMessage):
__slots__ = ("raw_data", "message_type", "msg_elements")
def __init__(
self,
api: Any,
event_id: str | None,
data: dict[str, Any],
) -> None:
super().__init__(api, event_id, data)
_set_raw_message_fields(self, data)
class PatchedC2CMessage(botpy.message.C2CMessage):
__slots__ = ("raw_data", "message_type", "msg_elements")
def __init__(
self,
api: Any,
event_id: str | None,
data: dict[str, Any],
) -> None:
super().__init__(api, event_id, data)
_set_raw_message_fields(self, data)
class PatchedGroupMessage(botpy.message.GroupMessage):
__slots__ = ("raw_data", "message_type", "msg_elements")
def __init__(
self,
api: Any,
event_id: str | None,
data: dict[str, Any],
) -> None:
super().__init__(api, event_id, data)
_set_raw_message_fields(self, data)
class _User:
def __init__(self, data: dict[str, Any]) -> None:
self.id = data.get("id", None)
self.username = data.get("username", None)
self.bot = data.get("bot", None)
self.avatar = data.get("avatar", None)
self.member_openid = data.get("member_openid", None)
self.user_openid = data.get("user_openid", None)
self.is_you = data.get("is_you", None)
def __repr__(self) -> str:
return str(self.__dict__)
def _ensure_group_message_create_parser() -> None:
"""Register qq-botpy message parsers with QQ quote payload preservation."""
def build_parser(event_name: str, message_cls: type) -> Any:
"""Build a ConnectionState parser for one QQ message event.
Args:
event_name: botpy dispatch event name.
message_cls: Patched message class used to retain raw fields.
Returns:
Parser function bound by qq-botpy's ConnectionState.
"""
def parse_message(self, payload: dict[str, Any]) -> None:
qq_message = message_cls(
self.api,
payload.get("id", None),
payload.get("d", {}),
)
self._dispatch(event_name, qq_message)
return parse_message
parser_specs = {
"message_create": ("message_create", PatchedMessage),
"at_message_create": ("at_message_create", PatchedMessage),
"direct_message_create": ("direct_message_create", PatchedDirectMessage),
"group_at_message_create": ("group_at_message_create", PatchedGroupMessage),
"c2c_message_create": ("c2c_message_create", PatchedC2CMessage),
"group_message_create": ("group_message_create", PatchedGroupMessage),
}
for parser_name, (event_name, message_cls) in parser_specs.items():
setattr(
ConnectionState,
f"parse_{parser_name}",
build_parser(event_name, message_cls),
)
class botClient(Client):
def set_platform(self, platform: QQOfficialPlatformAdapter) -> None:
# keep a typed reference back to adapter for callbacks to use
@@ -58,6 +182,7 @@ class botClient(Client):
abm = await QQOfficialPlatformAdapter._parse_from_qqofficial(
message,
MessageType.GROUP_MESSAGE,
force_group_mention=True,
)
# normalize group/session id to str
abm.group_id = str(getattr(message, "group_openid", "") or "")
@@ -65,6 +190,19 @@ class botClient(Client):
self.platform.remember_session_scene(abm.session_id, "group")
self._commit(abm)
async def on_group_message_create(
self,
message: botpy.message.GroupMessage,
) -> None:
abm = await QQOfficialPlatformAdapter._parse_from_qqofficial(
message,
MessageType.GROUP_MESSAGE,
)
abm.group_id = str(getattr(message, "group_openid", "") or "")
abm.session_id = abm.group_id
self.platform.remember_session_scene(abm.session_id, "group")
self._commit(abm)
async def on_at_message_create(self, message: botpy.message.Message) -> None:
abm = await QQOfficialPlatformAdapter._parse_from_qqofficial(
message,
@@ -103,22 +241,8 @@ class botClient(Client):
self._commit(abm)
def _commit(self, abm: AstrBotMessage) -> None:
# cache the last message id for a session and commit the platform event
self.platform.remember_session_message_id(abm.session_id, abm.message_id)
self.platform.commit_event(
# QQOfficialMessageEvent expects (message_str, message_obj, platform_meta, session_id, bot)
# adapter passes its own client to event instances
# The commit wraps abm into an event for processing by AstrBot core
# The QQOfficialMessageEvent used here is only to build the platform event,
# the consumer of commit_event will use QQOfficialMessageEvent to send later.
QQOfficialMessageEvent(
abm.message_str,
abm,
self.platform.meta(),
abm.session_id,
self.platform.client,
),
)
self.platform.commit_event(self.platform.create_event(abm))
@register_platform_adapter("qq_official", "QQ 机器人官方 API 适配器")
@@ -154,6 +278,7 @@ class QQOfficialPlatformAdapter(Platform):
timeout=20,
)
self.client.set_platform(self)
_ensure_group_message_create_parser()
self._session_last_message_id: dict[str, str] = {}
self._session_scene: dict[str, str] = {}
self.test_mode = os.environ.get("TEST_MODE", "off") == "on"
@@ -191,24 +316,33 @@ class QQOfficialPlatformAdapter(Platform):
):
return
# 私聊主动推送不需要 msg_id,见 https://github.com/AstrBotDevs/AstrBot/issues/7904
msg_id = self._session_last_message_id.get(session.session_id)
if not msg_id and session.message_type != MessageType.FRIEND_MESSAGE:
scene = self._session_scene.get(session.session_id)
group_proactive_send = (
session.message_type == MessageType.GROUP_MESSAGE and scene == "group"
)
if (
not msg_id
and session.message_type != MessageType.FRIEND_MESSAGE
and not group_proactive_send
):
logger.warning(
"[QQOfficial] No cached msg_id for session: %s, skip send_by_session",
session.session_id,
)
return
# Build initial payload
payload: dict[str, Any] = {"content": plain_text, "msg_id": msg_id}
payload: dict[str, Any] = {"content": plain_text}
if msg_id and not group_proactive_send:
payload["msg_id"] = msg_id
ret: Any | None = None
# Create a real QQOfficialMessageEvent helper so instance methods are typed correctly.
# Provide a minimal AstrBotMessage and platform meta; these values are placeholders and
# only used by helper methods that need access to bot/client or metadata.
helper_message_obj = AstrBotMessage()
helper_message_obj.message_id = msg_id
helper_message_obj.type = session.message_type
helper_message_obj.message_id = msg_id or ""
helper_event = QQOfficialMessageEvent(
message_str=plain_text or "",
message_obj=helper_message_obj,
@@ -219,7 +353,6 @@ class QQOfficialPlatformAdapter(Platform):
# Decide how to send based on session type
if session.message_type == MessageType.GROUP_MESSAGE:
scene = self._session_scene.get(session.session_id)
if scene == "group":
payload["msg_seq"] = random.randint(1, 10000)
if image_base64:
@@ -326,7 +459,7 @@ class QQOfficialPlatformAdapter(Platform):
sent_message_id = self._extract_message_id(ret)
if sent_message_id:
self.remember_session_message_id(session.session_id, sent_message_id)
await super().send_by_session(session, message_chain)
await Platform.send_by_session(self, session, message_chain)
def remember_session_message_id(self, session_id: str, message_id: str) -> None:
if not session_id or not message_id:
@@ -354,6 +487,15 @@ class QQOfficialPlatformAdapter(Platform):
support_proactive_message=True,
)
def create_event(self, message: AstrBotMessage) -> QQOfficialMessageEvent:
return QQOfficialMessageEvent(
message.message_str,
message,
self.meta(),
message.session_id,
self.client,
)
@staticmethod
def _normalize_attachment_url(url: str | None) -> str:
if not url:
@@ -383,20 +525,40 @@ class QQOfficialPlatformAdapter(Platform):
if not attachments:
return
for attachment in attachments:
content_type = (getattr(attachment, "content_type", "") or "").lower()
url = QQOfficialPlatformAdapter._normalize_attachment_url(
getattr(attachment, "url", None),
)
if isinstance(attachment, dict):
content_type = str(
attachment.get("content_type")
or attachment.get("contentType")
or "",
).lower()
attachment_url = attachment.get("url")
url = QQOfficialPlatformAdapter._normalize_attachment_url(
str(attachment_url) if attachment_url else None,
)
filename = str(
attachment.get("filename")
or attachment.get("name")
or "attachment",
)
else:
content_type = str(
getattr(attachment, "content_type", "") or "",
).lower()
attachment_url = getattr(attachment, "url", None)
url = QQOfficialPlatformAdapter._normalize_attachment_url(
str(attachment_url) if attachment_url else None,
)
filename = str(
getattr(attachment, "filename", None)
or getattr(attachment, "name", None)
or "attachment",
)
if not url:
continue
if content_type.startswith("image"):
msg.append(Image.fromURL(url))
else:
filename = (
getattr(attachment, "filename", None)
or getattr(attachment, "name", None)
or "attachment"
)
ext = Path(filename).suffix.lower()
image_exts = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"}
audio_exts = {".mp3", ".wav", ".ogg", ".m4a", ".amr", ".silk"}
@@ -465,6 +627,7 @@ class QQOfficialPlatformAdapter(Platform):
| botpy.message.DirectMessage
| botpy.message.C2CMessage,
message_type: MessageType,
force_group_mention: bool = False,
) -> AstrBotMessage:
"""Normalize incoming botpy message into AstrBotMessage with safe string fields."""
abm = AstrBotMessage()
@@ -474,6 +637,53 @@ class QQOfficialPlatformAdapter(Platform):
# normalize message_id to string
abm.message_id = str(getattr(message, "id", "") or uuid.uuid4().hex)
msg: list[BaseMessageComponent] = []
message_reference = getattr(message, "message_reference", None)
quoted_message_id = getattr(message_reference, "message_id", None)
raw_message_type = getattr(message, "message_type", None)
try:
is_quoted_message = int(raw_message_type or 0) == 103
except (TypeError, ValueError):
is_quoted_message = False
msg_elements = getattr(message, "msg_elements", None)
quoted_message_str = ""
quoted_element_message_id = ""
quoted_chain: list[BaseMessageComponent] = []
if is_quoted_message and isinstance(msg_elements, list) and msg_elements:
quoted_element = msg_elements[0]
if isinstance(quoted_element, dict):
quoted_content = quoted_element.get("content")
quoted_attachments = quoted_element.get("attachments")
quoted_element_message_id = str(
quoted_element.get("id") or quoted_element.get("message_id") or "",
)
else:
quoted_content = getattr(quoted_element, "content", None)
quoted_attachments = getattr(quoted_element, "attachments", None)
quoted_element_message_id = str(
getattr(quoted_element, "id", None)
or getattr(quoted_element, "message_id", None)
or "",
)
quoted_message_str = QQOfficialPlatformAdapter._parse_face_message(
str(quoted_content or "").strip()
)
if quoted_message_str:
quoted_chain.append(Plain(quoted_message_str))
if isinstance(quoted_attachments, list):
await QQOfficialPlatformAdapter._append_attachments(
quoted_chain,
quoted_attachments,
)
if quoted_message_id or quoted_element_message_id or quoted_chain:
msg.append(
Reply(
id=str(quoted_message_id or quoted_element_message_id or ""),
chain=quoted_chain,
message_str=quoted_message_str,
text=quoted_message_str,
)
)
# Group-like messages (GroupMessage or C2C in some contexts)
if isinstance(message, botpy.message.GroupMessage) or isinstance(
@@ -483,20 +693,49 @@ class QQOfficialPlatformAdapter(Platform):
if isinstance(message, botpy.message.GroupMessage):
abm.sender = MessageMember(
str(getattr(message.author, "member_openid", "") or ""),
"",
str(getattr(message.author, "username", "") or ""),
)
abm.group_id = str(getattr(message, "group_openid", "") or "")
bot_mentions = [
mention
for mention in (getattr(message, "mentions", None) or [])
if getattr(mention, "is_you", False) is True
and getattr(mention, "id", None) is not None
]
bot_mention_ids = [
str(getattr(mention, "id")) for mention in bot_mentions
]
group_mentioned = bool(bot_mention_ids) or force_group_mention
plain_content = str(getattr(message, "content", "") or "")
for mention_id in bot_mention_ids:
plain_content = plain_content.replace(
f"<@{mention_id}>",
"",
).replace(
f"<@!{mention_id}>",
"",
)
abm.message_str = QQOfficialPlatformAdapter._parse_face_message(
plain_content.strip(),
)
abm.self_id = bot_mention_ids[0] if bot_mention_ids else "qq_official"
if group_mentioned:
mention_name = (
str(getattr(bot_mentions[0], "username", "") or "")
if bot_mentions
else ""
)
msg.append(At(qq=abm.self_id, name=mention_name))
else:
abm.sender = MessageMember(
str(getattr(message.author, "user_openid", "") or ""),
"",
)
abm.message_str = QQOfficialPlatformAdapter._parse_face_message(
(getattr(message, "content", "") or "").strip(),
)
abm.self_id = "unknown_selfid"
# keep the @ component to indicate mention within group message
msg.append(At(qq="qq_official"))
abm.message_str = QQOfficialPlatformAdapter._parse_face_message(
str(getattr(message, "content", "") or "").strip(),
)
abm.self_id = "unknown_selfid"
msg.append(At(qq="qq_official"))
msg.append(Plain(abm.message_str))
await QQOfficialPlatformAdapter._append_attachments(
msg,
@@ -167,15 +167,6 @@ class QQOfficialWebhook:
"""
body = await request.get_data()
if not _verify_qq_webhook_signature(
self.secret,
request.headers.get(_SIGNATURE_TIMESTAMP_HEADER),
request.headers.get(_SIGNATURE_HEADER),
body,
):
logger.warning("qq_official_webhook signature verification failed.")
return {"error": "Invalid signature"}, 401
try:
msg = json.loads(body.decode("utf-8"))
except json.JSONDecodeError:
@@ -191,6 +182,16 @@ class QQOfficialWebhook:
if opcode == 13:
signed = await self.webhook_validation(data)
return signed
if not _verify_qq_webhook_signature(
self.secret,
request.headers.get(_SIGNATURE_TIMESTAMP_HEADER),
request.headers.get(_SIGNATURE_HEADER),
body,
):
logger.warning("qq_official_webhook signature verification failed.")
return {"error": "Invalid signature"}, 401
event_id = msg.get("id")
if event_id:
now = time.monotonic()
@@ -3,7 +3,7 @@ import mimetypes
import shutil
import uuid
from collections.abc import Awaitable, Callable, Sequence
from pathlib import Path
from pathlib import Path, PurePosixPath
from typing import Any
import anyio
@@ -32,6 +32,23 @@ ReplyHistoryGetter = Callable[
MEDIA_PART_TYPES = {"image", "record", "file", "video"}
def _safe_display_filename(filename: str | None) -> str:
"""Return a safe basename for display-only filenames.
Args:
filename: Candidate filename from a message payload or component.
Returns:
Sanitized basename, or an empty string when the value is unusable.
"""
if not filename:
return ""
basename = (
PurePosixPath(str(filename).replace("\\", "/")).name.replace("\x00", "").strip()
)
return "" if basename in {"", ".", ".."} else basename
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]
@@ -234,14 +251,19 @@ async def build_webchat_message_parts(
continue
attachment_path = Path(attachment.path)
display_name = (
_safe_display_filename(part.get("filename")) or attachment_path.name
)
message_parts.append(
{
"type": attachment.type,
"attachment_id": attachment.attachment_id,
"filename": attachment_path.name,
"filename": display_name,
"path": str(attachment_path),
},
)
if display_name != attachment_path.name:
message_parts[-1]["stored_filename"] = attachment_path.name
return message_parts
@@ -345,6 +367,7 @@ async def create_attachment_part_from_existing_file(
insert_attachment: AttachmentInserter,
attachments_dir: str | Path,
fallback_dirs: Sequence[str | Path] = (),
display_name: str | None = None,
) -> dict | None:
basename = Path(filename).name
candidate_paths = [Path(attachments_dir) / basename]
@@ -367,11 +390,15 @@ async def create_attachment_part_from_existing_file(
if not attachment:
return None
return {
safe_display_name = _safe_display_filename(display_name)
part = {
"type": attach_type,
"attachment_id": attachment.attachment_id,
"filename": file_path.name,
"filename": safe_display_name or file_path.name,
}
if part["filename"] != file_path.name:
part["stored_filename"] = file_path.name
return part
async def message_chain_to_storage_message_parts(
@@ -473,8 +500,11 @@ async def _copy_file_to_attachment_part(
if not attachment:
return None
return {
part = {
"type": attach_type,
"attachment_id": attachment.attachment_id,
"filename": display_name or src_path.name,
"filename": _safe_display_filename(display_name) or src_path.name,
}
if part["filename"] != target_path.name:
part["stored_filename"] = target_path.name
return part
@@ -4,7 +4,7 @@ import json
import os
import shutil
import uuid
from pathlib import Path
from pathlib import Path, PurePosixPath
from astrbot.api import logger
from astrbot.api.event import AstrMessageEvent, MessageChain
@@ -122,12 +122,19 @@ class WebChatMessageEvent(AstrMessageEvent):
elif isinstance(comp, File):
# save file to local
file_path = await comp.get_file()
original_name = comp.name or os.path.basename(file_path)
raw_original_name = comp.name or os.path.basename(file_path)
original_name = (
PurePosixPath(str(raw_original_name).replace("\\", "/"))
.name.replace("\x00", "")
.strip()
)
if original_name in {"", ".", ".."}:
original_name = os.path.basename(file_path) or "file"
ext = os.path.splitext(original_name)[1] or ""
filename = f"{uuid.uuid4()!s}{ext}"
dest_path = os.path.join(attachments_dir, filename)
shutil.copy2(file_path, dest_path)
data = f"[FILE]{filename}"
data = f"[FILE]{filename}|{original_name}"
await web_chat_back_queue.put(
{
"type": "file",
@@ -91,7 +91,7 @@ class WecomServer:
"""内部服务器的 GET 验证入口"""
return await self.handle_verify(quart.request)
async def handle_verify(self, request) -> str:
async def handle_verify(self, request) -> quart.Response:
"""处理验证请求,可被统一 webhook 入口复用
Args:
@@ -111,7 +111,7 @@ class WecomServer:
args.get("echostr"),
)
logger.info("验证请求有效性成功。")
return echo_str
return quart.Response(echo_str, content_type="text/plain")
except InvalidSignatureException:
logger.error("验证请求有效性失败,签名异常,请检查配置。")
raise
@@ -267,15 +267,13 @@ class WecomPlatformAdapter(Platform):
) -> None:
# 企业微信客服不支持主动发送
if hasattr(self.client, "kf_message"):
logger.warning("企业微信客服模式不支持 send_by_session 主动发送。")
await super().send_by_session(session, message_chain)
return
raise Exception("企业微信客服模式不支持 send_by_session 主动发送。")
if not self.agent_id:
logger.warning(
await super().send_by_session(session, message_chain)
raise Exception(
f"send_by_session 失败:无法为会话 {session.session_id} 推断 agent_id。",
)
await super().send_by_session(session, message_chain)
return
message_obj = AstrBotMessage()
message_obj.self_id = self.agent_id
@@ -304,7 +302,7 @@ class WecomPlatformAdapter(Platform):
"wecom 适配器",
id=self.config.get("id", "wecom"),
support_streaming_message=False,
support_proactive_message=False,
support_proactive_message=True,
)
@override
@@ -118,7 +118,7 @@ class WecomAIBotAdapter(Platform):
name="wecom_ai_bot",
description="企业微信智能机器人适配器,支持 HTTP 回调和长连接模式",
id=self.config.get("id", "wecom_ai_bot"),
support_proactive_message=bool(self.msg_push_webhook_url),
support_proactive_message=True,
)
self.api_client: WecomAIBotAPIClient | None = None
self.server: WecomAIBotServer | None = None
@@ -513,16 +513,18 @@ class WecomAIBotAdapter(Platform):
) -> None:
"""通过消息推送 webhook 发送消息。"""
if not self.webhook_client:
logger.warning(
"主动消息发送失败: 未配置企业微信消息推送 Webhook URL,请前往配置添加。session_id=%s",
session.session_id,
raise RuntimeError(
"主动消息发送失败: 未配置企业微信消息推送 Webhook URL请前往配置添加"
"详见文档: https://docs.astrbot.app/platform/wecom_ai_bot.html#%E9%85%8D%E7%BD%AE-astrbot。"
f"session_id={session.session_id}"
)
await super().send_by_session(session, message_chain)
return
try:
await self.webhook_client.send_message_chain(message_chain)
except Exception as e:
logger.error("企业微信消息推送失败(session=%s): %s", session.session_id, e)
raise RuntimeError(
f"企业微信消息推送失败: session_id={session.session_id}, error={e}"
) from e
await super().send_by_session(session, message_chain)
def run(self) -> Coroutine[Any, Any, None]:
@@ -351,6 +351,7 @@ class WeixinOfficialAccountPlatformAdapter(Platform):
message_chain: MessageChain,
) -> None:
await super().send_by_session(session, message_chain)
raise Exception("微信公众号不支持发送主动消息")
@override
def meta(self) -> PlatformMetadata:
@@ -359,7 +360,7 @@ class WeixinOfficialAccountPlatformAdapter(Platform):
"微信公众平台 适配器",
id=self.config.get("id", "weixin_official_account"),
support_streaming_message=False,
support_proactive_message=False,
support_proactive_message=True,
)
@override
+13 -2
View File
@@ -33,7 +33,15 @@ class WebhookRequest:
raise
def _response_from_result(result: Any):
def webhook_response_from_result(result: Any):
"""Convert adapter callback results into raw webhook HTTP responses.
Args:
result: Adapter callback return value.
Returns:
A FastAPI-compatible raw response value.
"""
if isinstance(result, Response):
return result
@@ -55,6 +63,9 @@ def _response_from_result(result: Any):
if isinstance(result, dict | list):
return JSONResponse(result)
if isinstance(result, str | bytes):
return Response(content=result)
return result
@@ -77,7 +88,7 @@ class FastAPIWebhookServer:
result = view_func()
if inspect.isawaitable(result):
result = await result
return _response_from_result(result)
return webhook_response_from_result(result)
self.app.add_api_route(
path,
+100 -65
View File
@@ -666,25 +666,56 @@ class FunctionToolManager:
if shutdown_event is None:
shutdown_event = asyncio.Event()
mcp_client: MCPClient | None = None
try:
mcp_client = await asyncio.wait_for(
self._init_mcp_client(name, cfg),
timeout=timeout,
)
except TimeoutError as exc:
raise MCPInitTimeoutError(
f"Connected to MCP server {name} timeout ({timeout:g} seconds)"
) from exc
except Exception:
logger.error(f"Failed to initialize MCP client {name}", exc_info=True)
raise
finally:
if mcp_client is None:
async with self._runtime_lock:
self._mcp_starting.discard(name)
mcp_client = MCPClient()
mcp_client.name = name
connect_done = asyncio.Event()
connect_error: BaseException | None = None
async def connect_and_lifecycle() -> None:
# Single task that handles connect, lifecycle, and cleanup.
nonlocal connect_error
try:
await mcp_client.connect_to_server(cfg, name)
await mcp_client.list_tools_and_save()
except asyncio.CancelledError:
# cleanup on cancellation
try:
await mcp_client.cleanup()
except BaseException:
pass
raise
except Exception as e:
connect_error = e
try:
await mcp_client.cleanup()
except Exception:
pass
connect_done.set()
return
# Register tools
self.func_list = [
f
for f in self.func_list
if not (isinstance(f, MCPTool) and f.mcp_server_name == name)
]
for tool in mcp_client.tools:
func_tool = MCPTool(
mcp_tool=tool,
mcp_client=mcp_client,
mcp_server_name=name,
)
self.func_list.append(func_tool)
logger.info(
f"Connected to MCP server {name}, "
f"Tools: {[t.name for t in mcp_client.tools]}"
)
connect_done.set()
async def lifecycle() -> None:
try:
await shutdown_event.wait()
logger.info(f"Received shutdown signal for MCP client {name}")
@@ -692,9 +723,25 @@ class FunctionToolManager:
logger.debug(f"MCP client {name} task was cancelled")
raise
finally:
await self._terminate_mcp_client(name)
# Cleanup in the same task that entered the anyio contexts:
# asyncio.shield() would schedule the coroutine as a separate
# Task, and anyio cancel scopes cannot exit across tasks (#9068).
# Absorb late cancellations so a forced shutdown cannot abort
# the cleanup halfway.
task = asyncio.current_task()
while True:
try:
await self._terminate_mcp_client(name)
break
except asyncio.CancelledError:
# Task.uncancel() is 3.11+; on 3.10 absorbing the
# cancellation is sufficient.
if task is not None and hasattr(task, "uncancel"):
task.uncancel()
lifecycle_task = asyncio.create_task(lifecycle(), name=f"mcp-client:{name}")
lifecycle_task = asyncio.create_task(
connect_and_lifecycle(), name=f"mcp-client:{name}"
)
async with self._runtime_lock:
self._mcp_server_runtime[name] = _MCPServerRuntime(
name=name,
@@ -704,6 +751,26 @@ class FunctionToolManager:
)
self._mcp_starting.discard(name)
try:
await asyncio.wait_for(connect_done.wait(), timeout=timeout)
except (TimeoutError, asyncio.CancelledError) as e:
lifecycle_task.cancel()
await asyncio.gather(lifecycle_task, return_exceptions=True)
async with self._runtime_lock:
self._mcp_starting.discard(name)
self._mcp_server_runtime.pop(name, None)
if isinstance(e, asyncio.TimeoutError):
raise MCPInitTimeoutError(
f"Connected to MCP server {name} timeout ({timeout:g} seconds)"
) from e
raise
if connect_error is not None:
async with self._runtime_lock:
self._mcp_starting.discard(name)
self._mcp_server_runtime.pop(name, None)
raise connect_error
async def _shutdown_runtimes(
self,
runtimes: list[_MCPServerRuntime],
@@ -768,41 +835,6 @@ class FunctionToolManager:
f"Failed to cleanup MCP client resources {name}: {cleanup_exc}"
)
async def _init_mcp_client(self, name: str, config: dict) -> MCPClient:
"""初始化单个MCP客户端"""
mcp_client = MCPClient()
mcp_client.name = name
try:
await mcp_client.connect_to_server(config, name)
tools_res = await mcp_client.list_tools_and_save()
except asyncio.CancelledError:
await self._cleanup_mcp_client_safely(mcp_client, name)
raise
except Exception:
await self._cleanup_mcp_client_safely(mcp_client, name)
raise
logger.debug(f"MCP server {name} list tools response: {tools_res}")
tool_names = [tool.name for tool in tools_res.tools]
# 移除该MCP服务之前的工具(如有)
self.func_list = [
f
for f in self.func_list
if not (isinstance(f, MCPTool) and f.mcp_server_name == name)
]
# 将 MCP 工具转换为 FuncTool 并添加到 func_list
for tool in mcp_client.tools:
func_tool = MCPTool(
mcp_tool=tool,
mcp_client=mcp_client,
mcp_server_name=name,
)
self.func_list.append(func_tool)
logger.info(f"Connected to MCP server {name}, Tools: {tool_names}")
return mcp_client
async def _terminate_mcp_client(self, name: str) -> None:
"""关闭并清理MCP客户端"""
async with self._runtime_lock:
@@ -1057,11 +1089,14 @@ class FunctionToolManager:
"mcp_server_list",
[],
)
local_mcp_config = self.load_mcp_config()
local_mcp_config = copy.deepcopy(self.load_mcp_config())
synced_count = 0
mcp_servers = local_mcp_config.setdefault("mcpServers", {})
synced_servers: list[tuple[str, dict]] = []
for server in mcp_server_list:
server_name = server["name"]
server_name = server.get("name")
if not server_name:
continue
operational_urls = server.get("operational_urls", [])
if not operational_urls:
continue
@@ -1070,28 +1105,28 @@ class FunctionToolManager:
if not server_url:
continue
# 添加到配置中(同名会覆盖)
local_mcp_config["mcpServers"][server_name] = {
server_config = {
"url": server_url,
"transport": "sse",
"active": True,
"provider": "modelscope",
}
synced_count += 1
mcp_servers[server_name] = server_config
synced_servers.append((server_name, server_config))
if synced_count > 0:
if synced_servers:
self.save_mcp_config(local_mcp_config)
tasks = []
for server in mcp_server_list:
name = server["name"]
for name, config in synced_servers:
tasks.append(
self.enable_mcp_server(
name=name,
config=local_mcp_config["mcpServers"][name],
config=config,
),
)
await asyncio.gather(*tasks)
logger.info(
f"从 ModelScope 同步了 {synced_count} 个 MCP 服务器",
f"从 ModelScope 同步了 {len(synced_servers)} 个 MCP 服务器",
)
else:
logger.warning("没有找到可用的 ModelScope MCP 服务器")
@@ -313,6 +313,131 @@ class ProviderAnthropic(Provider):
return system_prompt, new_messages
@staticmethod
def _merge_consecutive_anthropic_messages(messages: list[Any]) -> list[Any]:
"""Merge adjacent Anthropic messages with the same role.
Args:
messages: Anthropic messages to merge.
Returns:
Merged Anthropic messages. When merging user messages, tool result
blocks are moved before other blocks to satisfy Anthropic ordering.
"""
merged: list[Any] = []
for msg in messages:
if not isinstance(msg, dict):
merged.append(msg)
continue
if (
msg.get("role")
and merged
and isinstance(merged[-1], dict)
and merged[-1].get("role") == msg.get("role")
):
prev = merged[-1]
prev_content = prev.get("content") or []
if isinstance(prev_content, str):
prev_content = [{"type": "text", "text": prev_content}]
elif isinstance(prev_content, list):
prev_content = list(prev_content)
else:
prev_content = [prev_content]
cur_content = msg.get("content") or []
if isinstance(cur_content, str):
cur_content = [{"type": "text", "text": cur_content}]
elif isinstance(cur_content, list):
cur_content = list(cur_content)
else:
cur_content = [cur_content]
combined_content = prev_content + cur_content
if msg.get("role") == "user":
tool_results = [
block
for block in combined_content
if isinstance(block, dict)
and block.get("type") == "tool_result"
]
if tool_results:
combined_content = tool_results + [
block
for block in combined_content
if not (
isinstance(block, dict)
and block.get("type") == "tool_result"
)
]
merged[-1] = {**prev, "content": combined_content}
else:
merged.append(msg)
return merged
@staticmethod
def _sanitize_assistant_messages(payloads: dict) -> None:
"""Remove orphaned tool results from Anthropic messages.
Args:
payloads: Anthropic request payload containing a messages list.
Returns:
None. The messages list is updated in place on ``payloads``.
"""
messages = payloads.get("messages")
if not isinstance(messages, list):
return
merged = ProviderAnthropic._merge_consecutive_anthropic_messages(messages)
sanitized: list[Any] = []
pending_tool_use_ids: set[str] = set()
for msg in merged:
if not isinstance(msg, dict):
sanitized.append(msg)
pending_tool_use_ids = set()
continue
role = msg.get("role")
content = msg.get("content")
if role == "assistant":
pending_tool_use_ids = set()
if isinstance(content, list):
for block in content:
if isinstance(block, dict) and block.get("type") == "tool_use":
tool_use_id = block.get("id")
if tool_use_id:
pending_tool_use_ids.add(tool_use_id)
sanitized.append(msg)
continue
if role == "user" and isinstance(content, list):
tool_results: list[Any] = []
other_blocks: list[Any] = []
for block in content:
if isinstance(block, dict) and block.get("type") == "tool_result":
tool_use_id = block.get("tool_use_id")
if tool_use_id in pending_tool_use_ids:
tool_results.append(block)
pending_tool_use_ids.remove(tool_use_id)
continue
other_blocks.append(block)
cleaned_content = tool_results + other_blocks
if cleaned_content:
sanitized.append({**msg, "content": cleaned_content})
pending_tool_use_ids = set()
continue
sanitized.append(msg)
pending_tool_use_ids = set()
payloads["messages"] = ProviderAnthropic._merge_consecutive_anthropic_messages(
sanitized
)
def _extract_usage(self, usage: Usage | None) -> TokenUsage:
if usage is None:
return TokenUsage()
@@ -376,6 +501,7 @@ class ProviderAnthropic(Provider):
if "max_tokens" not in payloads:
payloads["max_tokens"] = 65536
self._apply_thinking_config(payloads)
self._sanitize_assistant_messages(payloads)
try:
completion = await self.client.messages.create(
@@ -472,6 +598,7 @@ class ProviderAnthropic(Provider):
if "max_tokens" not in payloads:
payloads["max_tokens"] = 65536
self._apply_thinking_config(payloads)
self._sanitize_assistant_messages(payloads)
async with self.client.messages.stream(
**payloads,
@@ -1,24 +1,26 @@
import base64
import uuid
from pathlib import Path
from urllib.parse import urlparse
import httpx
from astrbot import logger
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
from astrbot.core.utils.io import download_file
from astrbot.core.utils.tencent_record_helper import (
convert_to_pcm_wav,
tencent_silk_to_wav,
from astrbot.core.utils.media_utils import (
MediaResolver,
describe_media_ref,
)
DEFAULT_MIMO_API_BASE = "https://api.xiaomimimo.com/v1"
DEFAULT_MIMO_TTS_MODEL = "mimo-v2-tts"
DEFAULT_MIMO_TTS_VOICE = "mimo_default"
DEFAULT_MIMO_TTS_SEED_TEXT = "Hello, MiMo, have you had lunch?"
DEFAULT_MIMO_STT_MODEL = "mimo-v2-omni"
DEFAULT_MIMO_STT_SYSTEM_PROMPT = "You are a speech transcription assistant. Transcribe the spoken content from the audio exactly and return only the transcription text."
# The MiMo-V2 series went offline on 2026-06-30; mimo-v2.5-asr is the
# dedicated speech recognition model per the official model lineup.
DEFAULT_MIMO_STT_MODEL = "mimo-v2.5-asr"
DEFAULT_MIMO_STT_SYSTEM_PROMPT = (
"You are a speech transcription assistant. "
"Transcribe the spoken content from the audio exactly and return only the transcription text."
)
DEFAULT_MIMO_STT_USER_PROMPT = (
"Please transcribe the content of the audio and return only the transcription text."
)
@@ -67,49 +69,59 @@ def build_api_url(api_base: str) -> str:
return normalized_api_base + "/chat/completions"
async def _detect_audio_format(file_path: Path) -> str | None:
silk_header = b"SILK"
amr_header = b"#!AMR"
try:
with file_path.open("rb") as file:
file_header = file.read(8)
except FileNotFoundError:
return None
if silk_header in file_header:
return "silk"
if amr_header in file_header:
return "amr"
return None
async def prepare_audio_input(audio_source: str) -> tuple[str, list[Path]]:
cleanup_paths: list[Path] = []
source_path = Path(audio_source)
is_remote = audio_source.startswith(("http://", "https://"))
is_tencent = "multimedia.nt.qq.com.cn" in audio_source if is_remote else False
if is_remote:
parsed_url = urlparse(audio_source)
suffix = Path(parsed_url.path).suffix or ".input"
download_path = get_temp_dir() / f"mimo_audio_{uuid.uuid4().hex[:8]}{suffix}"
await download_file(audio_source, str(download_path))
source_path = download_path
cleanup_paths.append(download_path)
if not source_path.exists():
raise FileNotFoundError(f"File does not exist: {source_path}")
if source_path.suffix.lower() in {".amr", ".silk"} or is_tencent:
file_format = await _detect_audio_format(source_path)
if file_format in {"silk", "amr"}:
converted_path = get_temp_dir() / f"mimo_audio_{uuid.uuid4().hex[:8]}.wav"
cleanup_paths.append(converted_path)
if file_format == "silk":
logger.info("Converting silk file to wav for MiMo STT...")
await tencent_silk_to_wav(str(source_path), str(converted_path))
else:
logger.info("Converting amr file to wav for MiMo STT...")
await convert_to_pcm_wav(str(source_path), str(converted_path))
source_path = converted_path
encoded_audio = base64.b64encode(source_path.read_bytes()).decode("utf-8")
return (encoded_audio, cleanup_paths)
audio_data = await MediaResolver(
audio_source,
media_type="audio",
default_suffix=".wav",
).to_base64_data(
strict=True,
target_format="wav",
)
if audio_data is None:
raise ValueError(f"Invalid audio data: {describe_media_ref(audio_source)}")
_validate_wav_payload(audio_data.base64_data, audio_source)
return audio_data.to_data_url(), []
def _decode_base64_header(base64_data: str) -> bytes:
chunk = "".join(base64_data[:64].split())
padding = len(chunk) % 4
if padding:
chunk += "=" * (4 - padding)
return base64.b64decode(chunk)
def _validate_wav_payload(base64_data: str, audio_source: str) -> None:
"""Reject audio payloads whose bytes are not RIFF/WAVE.
MiMo only accepts wav/mp3 audio. When a platform voice file (e.g. Tencent
SILK from QQ) slips through the WAV conversion chain unchanged, the API
replies with an opaque HTTP 400, so fail locally with the real reason.
Args:
base64_data: Base64-encoded audio payload about to be sent.
audio_source: Original media reference, used in error messages.
Raises:
MiMoAPIError: Raised when the payload is not valid WAV data.
"""
try:
header = _decode_base64_header(base64_data)
except Exception:
header = b""
if len(header) >= 12 and header[:4] == b"RIFF" and header[8:12] == b"WAVE":
return
if header.startswith((b"#!SILK_V3", b"\x02#!SILK_V3")):
raise MiMoAPIError(
"Audio for MiMo STT is still Tencent SILK data after WAV conversion; "
"check that the silk-python package is installed and working: "
f"{describe_media_ref(audio_source)}"
)
raise MiMoAPIError(
"Audio for MiMo STT could not be converted to WAV "
f"(unrecognized audio bytes): {describe_media_ref(audio_source)}"
)
def cleanup_files(paths: list[Path]) -> None:
@@ -33,42 +33,52 @@ class ProviderMiMoSTTAPI(STTProvider):
self.api_base = provider_config.get("api_base", DEFAULT_MIMO_API_BASE)
self.proxy = provider_config.get("proxy", "")
self.timeout = normalize_timeout(provider_config.get("timeout", 20))
self.system_prompt = provider_config.get(
"mimo-stt-system-prompt",
DEFAULT_MIMO_STT_SYSTEM_PROMPT,
)
self.user_prompt = provider_config.get(
"mimo-stt-user-prompt",
DEFAULT_MIMO_STT_USER_PROMPT,
)
self.set_model(provider_config.get("model", DEFAULT_MIMO_STT_MODEL))
self.client = create_http_client(self.timeout, self.proxy)
def _is_asr_model(self) -> bool:
return "asr" in (self.model_name or "").lower()
def _build_messages(self, audio_data_url: str) -> list[dict]:
audio_content = {
"type": "input_audio",
"input_audio": {
"data": audio_data_url,
},
}
if self._is_asr_model():
# Dedicated ASR models (speech-recognition docs) take bare audio.
return [
{
"role": "user",
"content": [audio_content],
},
]
# Multimodal models such as mimo-v2.5 (audio-understanding docs)
# require a text instruction alongside the audio, otherwise the API
# rejects the request.
return [
{
"role": "system",
"content": DEFAULT_MIMO_STT_SYSTEM_PROMPT,
},
{
"role": "user",
"content": [
audio_content,
{
"type": "text",
"text": DEFAULT_MIMO_STT_USER_PROMPT,
},
],
},
]
async def get_text(self, audio_url: str) -> str:
audio_data_url, cleanup_paths = await prepare_audio_input(audio_url)
payload = {
"model": self.model_name,
"messages": [
{
"role": "system",
"content": self.system_prompt,
},
{
"role": "user",
"content": [
{
"type": "input_audio",
"input_audio": {
"data": audio_data_url,
},
},
{
"type": "text",
"text": self.user_prompt,
},
],
},
],
"messages": self._build_messages(audio_data_url),
"max_completion_tokens": 1024,
}
@@ -89,9 +99,10 @@ class ProviderMiMoSTTAPI(STTProvider):
data = response.json()
choices = data.get("choices") or []
first_choice = choices[0] if choices else {}
content = first_choice.get("message", {}).get("content", "")
message = (first_choice or {}).get("message") or {}
content = message.get("content") or message.get("reasoning_content") or ""
if not isinstance(content, str) or not content.strip():
raise MiMoAPIError(f"MiMo STT API returned empty transcription: {data}")
raise MiMoAPIError("MiMo STT API returned empty transcription")
return content.strip()
finally:
cleanup_files(cleanup_paths)
+56 -10
View File
@@ -602,16 +602,59 @@ class ProviderOpenAIOfficial(Provider):
tool_calls: Any = msg.get("tool_calls")
reasoning_content = msg.get("reasoning_content")
if _is_empty(content) and not tool_calls and not reasoning_content:
logger.warning(f"过滤第 {idx} 条空 assistant 消息 (无工具调用)")
continue
if _is_empty(content) and not tool_calls:
if not reasoning_content:
# 三者全空,真正的垃圾消息,丢弃
logger.debug(
f"过滤第 {idx} 条空 assistant 消息 (无 content | tool_calls | reasoning_content)"
)
continue
else:
# ⭐ 有 reasoning_content 但没有 content 和 tool_calls
# 不能丢(推理模型需要 reasoning 历史)
# 但 API 要求 content 或 tool_calls 至少有一个
# → 设空字符串占位,满足校验
msg["content"] = ""
if _is_empty(content) and tool_calls:
msg["content"] = None
elif _is_empty(content) and tool_calls:
msg["content"] = None # 有 tool_calls,按 OpenAI 规范
cleaned.append(msg)
payloads["messages"] = cleaned
# Drop orphaned or duplicate tool messages whose assistant(tool_calls)
# was removed by context truncation / compression.
pending_tool_call_ids: set[str] = set()
final: list = []
removed_tool_messages = 0
for msg in cleaned:
if not isinstance(msg, dict):
final.append(msg)
pending_tool_call_ids = set()
continue
role = msg.get("role")
if role == "assistant" and msg.get("tool_calls"):
pending_tool_call_ids = {
tc["id"]
for tc in msg["tool_calls"]
if isinstance(tc, dict) and "id" in tc
}
final.append(msg)
elif role == "tool":
tool_call_id = msg.get("tool_call_id")
if tool_call_id in pending_tool_call_ids:
final.append(msg)
pending_tool_call_ids.remove(tool_call_id)
else:
removed_tool_messages += 1
else:
pending_tool_call_ids = set()
final.append(msg)
if removed_tool_messages:
logger.debug(
"Filtered %d orphaned or duplicate tool message(s)",
removed_tool_messages,
)
payloads["messages"] = final
async def _query(self, payloads: dict, tools: ToolSet | None) -> LLMResponse:
if tools:
@@ -1004,8 +1047,9 @@ class ProviderOpenAIOfficial(Provider):
llm_response.raw_completion = completion
llm_response.id = completion.id
if completion.usage:
llm_response.usage = self._extract_usage(completion.usage)
llm_response.usage = (
self._extract_usage(completion.usage) if completion.usage else TokenUsage()
)
return llm_response
@@ -1065,11 +1109,13 @@ class ProviderOpenAIOfficial(Provider):
"""Finally convert the payload. Such as think part conversion, tool inject."""
model = payloads.get("model", "").lower()
is_gemini = "gemini" in model
deepseek_reasoning_models = {"deepseek-v4-pro", "deepseek-v4-flash"}
_deepseek_v4_markers = ("deepseek-v4-pro", "deepseek-v4-flash", "deepseek-v4")
is_deepseek_v4_reasoning = (
model in deepseek_reasoning_models
any(marker in model for marker in _deepseek_v4_markers)
or "api.deepseek.com" in self.client.base_url.host
)
# deepseek-chat and deepseek-reasoner now point to V4 models (per official website)
# MiMo 推理模型(MiMo-V2.5-Pro / MiMo-V2.5 / MiMo-V2-Pro / MiMo-V2-Omni / MiMo-V2-Flash
# 要求 assistant 历史消息必须回传 reasoning_content,否则返回 400
mimo_reasoning_models = {
+6
View File
@@ -75,6 +75,12 @@ class StarMetadata:
pages: list[dict] = field(default_factory=list)
"""插件注册的 Pages 元数据。"""
@property
def plugin_id(self) -> str:
p_name = (self.name or "unknown").lower().replace("/", "_")
p_author = (self.author or "unknown").lower().replace("/", "_")
return f"{p_author}/{p_name}"
def __str__(self) -> str:
return f"Plugin {self.name} ({self.version}) by {self.author}: {self.desc}"
+214 -90
View File
@@ -11,6 +11,7 @@ import os
import sys
import tempfile
import traceback
from collections.abc import Iterable
from dataclasses import dataclass
from enum import Enum, auto
from pathlib import Path
@@ -54,7 +55,7 @@ from .error_messages import format_plugin_error
from .filter.permission import PermissionType, PermissionTypeFilter
from .star import star_map, star_registry
from .star_handler import EventType, star_handlers_registry
from .updator import PluginUpdator
from .updator import PLUGIN_METADATA_FILENAMES, PluginUpdator
try:
from watchfiles import PythonFilter, awatch
@@ -101,6 +102,9 @@ class ImportDependencyRecoveryState:
install_plan: MissingRequirementsPlan | None = None
PLUGIN_TOOL_STATE_MIGRATION_KEY = "inactivated_llm_tools_plugin_state_migrated_v1"
@contextlib.contextmanager
def _temporary_filtered_requirements_file(
*,
@@ -483,39 +487,45 @@ class PluginManager:
@staticmethod
def _load_plugin_metadata(plugin_path: str, plugin_obj=None) -> StarMetadata | None:
"""先寻找 metadata.yaml 文件,如果不存在,则使用插件对象的 info() 函数获取元数据。
"""Load plugin metadata from metadata.yaml or metadata.yml.
Notes: 旧版本 AstrBot 插件可能使用的是 info() 函数来获取元数据
Args:
plugin_path: Plugin directory path.
plugin_obj: Deprecated compatibility argument; ignored.
Returns:
Loaded plugin metadata, or None if no metadata file exists.
"""
metadata = None
del plugin_obj
metadata: StarMetadata | None = None
raw_metadata: object | None = None
metadata_label = "metadata.yaml"
plugin_root = Path(plugin_path)
if not os.path.exists(plugin_path):
if not plugin_root.exists():
raise Exception("插件不存在。")
if os.path.exists(os.path.join(plugin_path, "metadata.yaml")):
with open(
os.path.join(plugin_path, "metadata.yaml"),
encoding="utf-8",
) as f:
metadata_path = next(
(
plugin_root / filename
for filename in PLUGIN_METADATA_FILENAMES
if (plugin_root / filename).exists()
),
None,
)
if metadata_path:
metadata_label = metadata_path.name
with metadata_path.open(encoding="utf-8") as f:
raw_metadata = yaml.safe_load(f)
elif plugin_obj and hasattr(plugin_obj, "info"):
# 使用 info() 函数
raw_metadata = plugin_obj.info()
if isinstance(raw_metadata, dict):
if "desc" not in raw_metadata and "description" in raw_metadata:
raw_metadata["desc"] = raw_metadata["description"]
if (
"name" not in raw_metadata
or "desc" not in raw_metadata
or "version" not in raw_metadata
or "author" not in raw_metadata
):
raise Exception(
"插件元数据信息不完整。name, desc, version, author 是必须的字段。",
)
try:
PluginUpdator.validate_plugin_metadata(raw_metadata, metadata_label)
except ValueError as exc:
raise Exception(f"插件元数据校验失败:{exc!s}") from exc
metadata = StarMetadata(
name=raw_metadata["name"],
author=raw_metadata["author"],
@@ -596,32 +606,42 @@ class PluginManager:
def _validate_importable_name(plugin_name: str) -> None:
if "/" in plugin_name or "\\" in plugin_name:
raise ValueError(
"metadata.yaml 中 name 含有路径分隔符,不可用于 importlib 加载。",
"metadata 文件中 name 含有路径分隔符,不可用于 importlib 加载。"
)
if not plugin_name.isidentifier() or keyword.iskeyword(plugin_name):
raise Exception(
"metadata.yaml 中 name 不是合法的模块名称(应为合法 Python 标识符且非关键字)。",
"metadata 文件中 name 不是合法的模块名称(应为合法 Python 标识符且非关键字)。"
)
@staticmethod
def _get_plugin_dir_name_from_metadata(plugin_path: str) -> str:
metadata_path = os.path.join(plugin_path, "metadata.yaml")
if not os.path.exists(metadata_path):
raise Exception("未找到 metadata.yaml,无法获取插件目录名。")
plugin_root = Path(plugin_path)
metadata_path = next(
(
plugin_root / filename
for filename in PLUGIN_METADATA_FILENAMES
if (plugin_root / filename).exists()
),
None,
)
if metadata_path is None:
raise Exception(
"未找到 metadata.yaml 或 metadata.yml,无法获取插件目录名。"
)
with open(metadata_path, encoding="utf-8") as f:
with metadata_path.open(encoding="utf-8") as f:
metadata = yaml.safe_load(f)
if not isinstance(metadata, dict):
raise Exception("metadata.yaml 格式错误。")
raise Exception(f"{metadata_path.name} 格式错误。")
plugin_name = metadata.get("name")
if not isinstance(plugin_name, str) or not plugin_name.strip():
raise Exception("metadata.yaml 中缺少 name 字段。")
raise Exception(f"{metadata_path.name} 中缺少 name 字段。")
plugin_dir_name = PluginManager._normalize_plugin_dir_name(plugin_name)
if not plugin_dir_name:
raise Exception("metadata.yaml 中 name 字段内容非法。")
raise Exception(f"{metadata_path.name} 中 name 字段内容非法。")
PluginManager._validate_importable_name(plugin_dir_name)
return plugin_dir_name
@@ -800,7 +820,8 @@ class PluginManager:
"display_name": metadata.display_name,
"support_platforms": metadata.support_platforms,
"astrbot_version": metadata.astrbot_version,
},
"plugin_id": metadata.plugin_id,
}
)
except Exception as metadata_error:
logger.debug(
@@ -834,6 +855,110 @@ class PluginManager:
self.failed_plugin_info = "\n".join(lines) + "\n"
@staticmethod
def _iter_concrete_llm_tools(func_tool: FunctionTool) -> Iterable[FunctionTool]:
"""Return concrete function tools that may belong to a plugin.
Args:
func_tool: A registered function tool, possibly a handoff tool.
Returns:
The concrete function tools to inspect for plugin ownership.
"""
if isinstance(func_tool, HandoffTool):
agent = getattr(func_tool, "agent", None)
tools = getattr(agent, "tools", None) if agent else None
for tool in tools or []:
if isinstance(tool, FunctionTool):
yield tool
return
yield func_tool
@staticmethod
def _is_plugin_llm_tool(
func_tool: FunctionTool,
plugin_module_path: str | None,
) -> bool:
"""Check whether a function tool belongs to a plugin module.
Args:
func_tool: Function tool to inspect.
plugin_module_path: Plugin module path.
Returns:
Whether the tool belongs to the plugin module.
"""
module_path = getattr(func_tool, "handler_module_path", None)
return bool(
plugin_module_path
and module_path
and (
module_path == plugin_module_path
or module_path.startswith(f"{plugin_module_path}.")
)
and not module_path.endswith(("astrbot.builtin_stars", "data.plugins"))
)
@classmethod
def _iter_plugin_llm_tools(
cls,
plugin_module_path: str | None,
) -> Iterable[FunctionTool]:
"""Return registered LLM tools owned by a plugin module.
Args:
plugin_module_path: Plugin module path.
Returns:
Matching function tools, including sub-tools inside handoff tools.
"""
if not plugin_module_path:
return
for func_tool in llm_tools.func_list:
for concrete_tool in cls._iter_concrete_llm_tools(func_tool):
if cls._is_plugin_llm_tool(concrete_tool, plugin_module_path):
yield concrete_tool
async def _migrate_legacy_plugin_tool_inactivation_state(
self,
inactivated_llm_tools: list,
inactivated_plugins: list,
) -> list:
"""Remove plugin-owned tools from the legacy manual tool blacklist.
Args:
inactivated_llm_tools: Persisted inactive tool names.
inactivated_plugins: Persisted inactive plugin module paths.
Returns:
Updated inactive tool names.
"""
migrated = await sp.global_get(PLUGIN_TOOL_STATE_MIGRATION_KEY, False)
if migrated:
return inactivated_llm_tools
plugin_tool_names: set[str] = set()
inactive_plugin_paths = set(inactivated_plugins)
for star_metadata in star_registry:
if not star_metadata.module_path:
continue
plugin_disabled = star_metadata.module_path in inactive_plugin_paths
for func_tool in self._iter_plugin_llm_tools(star_metadata.module_path):
plugin_tool_names.add(func_tool.name)
if func_tool.name in inactivated_llm_tools:
func_tool.active = not plugin_disabled
if not plugin_tool_names and inactivated_llm_tools:
return inactivated_llm_tools
updated_tools = [
name for name in inactivated_llm_tools if name not in plugin_tool_names
]
if updated_tools != inactivated_llm_tools:
await sp.global_put("inactivated_llm_tools", updated_tools)
await sp.global_put(PLUGIN_TOOL_STATE_MIGRATION_KEY, True)
return updated_tools
async def reload_failed_plugin(self, dir_name):
"""重新加载未注册(加载失败)的插件
Args:
@@ -908,7 +1033,7 @@ class PluginManager:
logger.warning(
f"插件 {smd.name} 未被正常终止: {e!s}, 可能会导致该插件运行不正常。",
)
if smd.name:
if smd.name and smd.activated:
await self._unbind_plugin(smd.name, specified_module_path)
result = await self.load(specified_module_path)
@@ -1058,15 +1183,14 @@ class PluginManager:
logger.info(metadata)
metadata.config = plugin_config
p_name = (metadata.name or "unknown").lower().replace("/", "_")
p_author = (metadata.author or "unknown").lower().replace("/", "_")
plugin_id = f"{p_author}/{p_name}"
plugin_id = metadata.plugin_id
# 在实例化前注入类属性,保证插件 __init__ 可读取这些值
# inject class attributes before instantiation so __init__ can read them
if metadata.star_cls_type:
metadata.star_cls_type.name = p_name
metadata.star_cls_type.author = p_author
metadata.star_cls_type.plugin_id = plugin_id
p_author, p_name = plugin_id.split("/")
setattr(metadata.star_cls_type, "name", p_name)
setattr(metadata.star_cls_type, "author", p_author)
setattr(metadata.star_cls_type, "plugin_id", plugin_id)
if path not in inactivated_plugins:
# 只有没有禁用插件时才实例化插件类
@@ -1111,19 +1235,11 @@ class PluginManager:
handler.handler,
metadata.star_cls, # type: ignore
)
plugin_disabled = metadata.module_path in inactivated_plugins
# 绑定 llm_tool handler
for func_tool in llm_tools.func_list:
if isinstance(func_tool, HandoffTool):
need_apply = []
sub_tools = func_tool.agent.tools
if sub_tools:
for sub_tool in sub_tools:
if isinstance(sub_tool, FunctionTool):
need_apply.append(sub_tool)
else:
need_apply = [func_tool]
for ft in need_apply:
for ft in self._iter_concrete_llm_tools(func_tool):
if (
ft.handler
and ft.handler.__module__ == metadata.module_path
@@ -1133,8 +1249,11 @@ class PluginManager:
ft.handler,
metadata.star_cls, # type: ignore
)
if ft.name in inactivated_llm_tools:
ft.active = False
if self._is_plugin_llm_tool(ft, metadata.module_path):
ft.active = (
not plugin_disabled
and ft.name not in inactivated_llm_tools
)
else:
# v3.4.0 以前的方式注册插件
@@ -1200,8 +1319,7 @@ class PluginManager:
assert metadata.module_path, f"插件 {metadata.name} 模块路径为空"
# 禁用/启用插件
if metadata.module_path in inactivated_plugins:
metadata.activated = False
metadata.activated = metadata.module_path not in inactivated_plugins
# Plugin logo path
if await asyncio.to_thread(os.path.exists, logo_path):
@@ -1282,6 +1400,19 @@ class PluginManager:
)
self._cleanup_plugin_state(root_dir_name, reserved)
if not specified_module_path and not specified_dir_name:
inactivated_llm_tools = (
await self._migrate_legacy_plugin_tool_inactivation_state(
inactivated_llm_tools,
inactivated_plugins,
)
)
inactive_tool_names = set(inactivated_llm_tools)
for func_tool in llm_tools.func_list:
for concrete_tool in self._iter_concrete_llm_tools(func_tool):
if concrete_tool.name in inactive_tool_names:
concrete_tool.active = False
# 清除 pip.main 导致的多余的 logging handlers
for handler in logging.root.handlers[:]:
logging.root.removeHandler(handler)
@@ -1339,11 +1470,12 @@ class PluginManager:
f"清理安装失败插件配置失败: {plugin_config_path},原因: {e!s}",
)
def _cleanup_plugin_optional_artifacts(
async def _cleanup_plugin_optional_artifacts(
self,
*,
root_dir_name: str,
plugin_label: str,
plugin_id: str | None = None,
delete_config: bool,
delete_data: bool,
) -> None:
@@ -1378,6 +1510,13 @@ class PluginManager:
f"删除插件持久化数据失败 ({data_dir_name}, {plugin_label}): {e!s}",
)
if plugin_id:
try:
await self.context.get_db().clear_preferences("plugin", plugin_id)
logger.info(f"已清除插件 {plugin_label}({plugin_id}) 的 KV 数据")
except Exception as e:
logger.warning(f"清除插件 KV 数据失败 ({plugin_label}): {e!s}")
def _track_failed_install_dir(
self,
*,
@@ -1581,9 +1720,12 @@ class PluginManager:
f"移除插件成功,但是删除插件文件夹失败: {e!s}。您可以手动删除该文件夹,位于 addons/plugins/ 下。",
) from e
self._cleanup_plugin_optional_artifacts(
plugin_id = plugin.plugin_id
await self._cleanup_plugin_optional_artifacts(
root_dir_name=root_dir_name,
plugin_label=plugin_name,
plugin_id=plugin_id,
delete_config=delete_config,
delete_data=delete_data,
)
@@ -1627,16 +1769,19 @@ class PluginManager:
)
plugin_label = dir_name
plugin_id = None
if isinstance(failed_info, dict):
plugin_label = (
failed_info.get("display_name")
or failed_info.get("name")
or dir_name
)
plugin_id = failed_info.get("plugin_id")
self._cleanup_plugin_optional_artifacts(
await self._cleanup_plugin_optional_artifacts(
root_dir_name=dir_name,
plugin_label=plugin_label,
plugin_id=plugin_id,
delete_config=delete_config,
delete_data=delete_data,
)
@@ -1733,7 +1878,7 @@ class PluginManager:
"""禁用一个插件。
调用插件的 terminate() 方法
将插件的 module_path 加入到 data/shared_preferences.json inactivated_plugins 列表中
并且同时将插件启用的 llm_tool 禁用
Disables the plugin's LLM tools only for the current runtime state.
"""
async with self._pm_lock:
plugin = self.context.get_registered_star(plugin_name)
@@ -1750,25 +1895,10 @@ class PluginManager:
if plugin.module_path not in inactivated_plugins:
inactivated_plugins.append(plugin.module_path)
inactivated_llm_tools = list(
set(await _get_global_list_preference("inactivated_llm_tools")),
) # 后向兼容
# 禁用插件启用的 llm_tool
for func_tool in llm_tools.func_list:
mp = func_tool.handler_module_path
if (
plugin.module_path
and mp
and plugin.module_path.startswith(mp)
and not mp.endswith(("astrbot.builtin_stars", "data.plugins"))
):
func_tool.active = False
if func_tool.name not in inactivated_llm_tools:
inactivated_llm_tools.append(func_tool.name)
for func_tool in self._iter_plugin_llm_tools(plugin.module_path):
func_tool.active = False
await sp.global_put("inactivated_plugins", inactivated_plugins)
await sp.global_put("inactivated_llm_tools", inactivated_llm_tools)
plugin.activated = False
@@ -1836,21 +1966,15 @@ class PluginManager:
inactivated_plugins.remove(plugin.module_path)
await sp.global_put("inactivated_plugins", inactivated_plugins)
# 启用插件启用的 llm_tool
for func_tool in llm_tools.func_list:
mp = func_tool.handler_module_path
if (
plugin.module_path
and mp
and plugin.module_path.startswith(mp)
and not mp.endswith(("astrbot.builtin_stars", "data.plugins"))
and func_tool.name in inactivated_llm_tools
):
inactivated_llm_tools.remove(func_tool.name)
func_tool.active = True
await sp.global_put("inactivated_llm_tools", inactivated_llm_tools)
for func_tool in self._iter_plugin_llm_tools(plugin.module_path):
func_tool.active = func_tool.name not in inactivated_llm_tools
await self.reload(plugin_name)
success, error = await self.reload(plugin_name)
if not success:
raise Exception(error or f"插件 {plugin_name} 启用失败。")
current_plugin = self.context.get_registered_star(plugin_name)
if current_plugin:
current_plugin.activated = True
async def install_plugin_from_file(
self,
+134
View File
@@ -1,12 +1,17 @@
import os
import zipfile
import yaml
from astrbot.core import logger
from astrbot.core.star.star import StarMetadata
from astrbot.core.utils.astrbot_path import get_astrbot_plugin_path
from astrbot.core.utils.io import ensure_dir, remove_dir
from astrbot.core.zip_updator import RepoZipUpdator
PLUGIN_METADATA_FILENAMES = ("metadata.yaml", "metadata.yml")
PLUGIN_METADATA_REQUIRED_FIELDS = ("name", "desc", "version", "author")
class PluginUpdator(RepoZipUpdator):
def __init__(self, repo_mirror: str = "", verify: str | bool | None = None) -> None:
@@ -60,6 +65,7 @@ class PluginUpdator(RepoZipUpdator):
elif repo_url:
await self.download_from_repo_url(plugin_path, repo_url, proxy=proxy)
self.validate_plugin_archive(plugin_path + ".zip")
try:
remove_dir(plugin_path)
except BaseException as e:
@@ -71,7 +77,135 @@ class PluginUpdator(RepoZipUpdator):
return plugin_path
@classmethod
def find_plugin_metadata_entry(cls, entries: list[str]) -> str | None:
"""Find AstrBot plugin metadata in archive entries.
Args:
entries: Zip archive member names.
Returns:
The original archive entry name for plugin metadata, or None.
"""
update_dir = cls._resolve_archive_root_dir(entries)
portable_update_dir = os.path.normpath(update_dir).replace("\\", "/")
if portable_update_dir == ".":
portable_update_dir = ""
entries_by_portable_path = {}
for entry in entries:
portable_entry = os.path.normpath(entry).replace("\\", "/")
if portable_entry in ("", "."):
continue
entries_by_portable_path[portable_entry] = entry
metadata_candidates = (
[
f"{portable_update_dir}/{filename}"
for filename in PLUGIN_METADATA_FILENAMES
]
if portable_update_dir
else list(PLUGIN_METADATA_FILENAMES)
)
for candidate in metadata_candidates:
if candidate in entries_by_portable_path:
return entries_by_portable_path[candidate]
return None
@staticmethod
def validate_plugin_metadata(metadata: object, metadata_label: str) -> None:
"""Validate AstrBot plugin metadata content.
Args:
metadata: Parsed metadata YAML content.
metadata_label: Metadata filename or archive entry for error messages.
Raises:
ValueError: If metadata is malformed or misses required fields.
"""
if not isinstance(metadata, dict):
raise ValueError(f"{metadata_label} 格式错误。")
normalized_metadata = dict(metadata)
if "desc" not in normalized_metadata and "description" in normalized_metadata:
normalized_metadata["desc"] = normalized_metadata["description"]
missing_fields = [
field
for field in PLUGIN_METADATA_REQUIRED_FIELDS
if field not in normalized_metadata
]
if missing_fields:
raise ValueError(
f"{metadata_label} 中缺少必需字段: {', '.join(missing_fields)}"
)
invalid_fields = [
field
for field in PLUGIN_METADATA_REQUIRED_FIELDS
if not isinstance(normalized_metadata[field], str)
or not normalized_metadata[field].strip()
]
if invalid_fields:
raise ValueError(
f"{metadata_label} 中字段 {', '.join(invalid_fields)} 必须是非空字符串。"
)
@classmethod
def inspect_plugin_archive(cls, zip_path: str) -> dict[str, object]:
"""Inspect plugin metadata in an AstrBot plugin archive.
Args:
zip_path: Path to the plugin archive.
Returns:
A dict containing the metadata entry and parsed metadata.
Raises:
ValueError: If the archive is not a valid AstrBot plugin.
"""
try:
with zipfile.ZipFile(zip_path, "r") as z:
metadata_entry = cls.find_plugin_metadata_entry(z.namelist())
if metadata_entry is None:
raise ValueError(
"压缩包不是合法的 AstrBot 插件:未找到 metadata.yaml 或 metadata.yml。"
)
try:
metadata_text = z.read(metadata_entry).decode("utf-8")
metadata = yaml.safe_load(metadata_text)
except UnicodeDecodeError as exc:
raise ValueError(f"{metadata_entry} 必须使用 UTF-8 编码。") from exc
except yaml.YAMLError as exc:
raise ValueError(f"{metadata_entry} 格式错误。") from exc
cls.validate_plugin_metadata(metadata, metadata_entry)
return {
"metadata_entry": metadata_entry,
"metadata": metadata,
}
except zipfile.BadZipFile as exc:
raise ValueError("插件压缩包格式错误。") from exc
@classmethod
def validate_plugin_archive(cls, zip_path: str) -> str:
"""Validate that an archive contains a valid AstrBot plugin.
Args:
zip_path: Path to the plugin archive.
Returns:
The archive entry name of the plugin metadata file.
Raises:
ValueError: If the archive is not a valid AstrBot plugin.
"""
inspection = cls.inspect_plugin_archive(zip_path)
return str(inspection["metadata_entry"])
def unzip_file(self, zip_path: str, target_dir: str) -> None:
self.validate_plugin_archive(zip_path)
ensure_dir(target_dir)
logger.info(f"Extracting archive: {zip_path}")
with zipfile.ZipFile(zip_path, "r") as z:
+115 -24
View File
@@ -14,7 +14,7 @@ Behavior when `provider_settings.computer_use_require_admin=True`:
implement them and the main agent does not expose them in local mode.
- Member + local: read/grep are restricted to `data/skills`,
plugin-provided `data/plugins/*/skills`,
`data/workspaces/{normalized_umo}`, and `/tmp/.astrbot`; write/edit are
the current session or project workspace, and `/tmp/.astrbot`; write/edit are
restricted to the same local roots except plugin-provided Skills, which are
read-only. Upload/download are denied by `check_admin_permission` if invoked.
- Admin + sandbox: read/write/edit/grep are not path-restricted by this
@@ -28,8 +28,7 @@ When `computer_use_require_admin=False`, member behavior in this module matches
admin behavior.
Local path resolution rule:
- In local runtime, relative paths are resolved under
`data/workspaces/{normalized_umo}`.
- In local runtime, relative paths are resolved under the primary workspace.
- In sandbox runtime, relative paths are passed through unchanged.
"""
@@ -53,6 +52,7 @@ from astrbot.core.tools.computer_tools.util import (
check_admin_permission,
is_local_runtime,
normalize_umo_for_workspace,
workspace_root_for_context,
)
from astrbot.core.tools.registry import builtin_tool
from astrbot.core.utils.astrbot_path import (
@@ -77,9 +77,13 @@ def _remote_basename(path: str) -> str:
return path.replace("\\", "/").rstrip("/").split("/")[-1]
def _restricted_env_path_labels(umo: str, *, include_plugin_skills: bool) -> list[str]:
def _restricted_env_path_labels(
umo: str,
*,
include_plugin_skills: bool,
current_workspace_root: Path | None = None,
) -> list[str]:
"""Labels for the allowed directories in a local(not sandbox) and restricted(not admin) environment"""
normalized_umo = normalize_umo_for_workspace(umo)
labels = [
"data/skills",
]
@@ -87,7 +91,7 @@ def _restricted_env_path_labels(umo: str, *, include_plugin_skills: bool) -> lis
labels.append("data/plugins/*/skills")
labels.extend(
[
f"data/workspaces/{normalized_umo}",
str(current_workspace_root or _workspace_root(umo)),
get_astrbot_system_tmp_path(),
get_astrbot_temp_path(),
],
@@ -117,22 +121,28 @@ def _plugin_skill_roots() -> tuple[Path, ...]:
)
def _read_allowed_roots(umo: str) -> tuple[Path, ...]:
def _read_allowed_roots(
umo: str,
current_workspace_root: Path | None = None,
) -> tuple[Path, ...]:
"""Non-admin users can only read files within these directories (and their subdirectories)"""
return (
Path(get_astrbot_skills_path()).resolve(strict=False),
*_plugin_skill_roots(),
_workspace_root(umo),
current_workspace_root or _workspace_root(umo),
Path(get_astrbot_system_tmp_path()).resolve(strict=False),
Path(get_astrbot_temp_path()).resolve(strict=False),
)
def _write_allowed_roots(umo: str) -> tuple[Path, ...]:
def _write_allowed_roots(
umo: str,
current_workspace_root: Path | None = None,
) -> tuple[Path, ...]:
"""Non-admin users cannot modify plugin-provided Skills."""
return (
Path(get_astrbot_skills_path()).resolve(strict=False),
_workspace_root(umo),
current_workspace_root or _workspace_root(umo),
Path(get_astrbot_system_tmp_path()).resolve(strict=False),
Path(get_astrbot_temp_path()).resolve(strict=False),
)
@@ -149,7 +159,13 @@ def _is_restricted_env(context: ContextWrapper[AstrAgentContext]) -> bool:
return require_admin and context.context.event.role != "admin"
def _resolve_tool_path(path: str, *, local_env: bool, umo: str) -> str:
def _resolve_tool_path(
path: str,
*,
local_env: bool,
umo: str,
current_workspace_root: Path | None = None,
) -> str:
normalized_path = path.strip()
if not normalized_path:
return normalized_path
@@ -157,16 +173,28 @@ def _resolve_tool_path(path: str, *, local_env: bool, umo: str) -> str:
if candidate.is_absolute():
return str(candidate.resolve(strict=False))
if local_env:
return str((_workspace_root(umo) / candidate).resolve(strict=False))
return str(
((current_workspace_root or _workspace_root(umo)) / candidate).resolve(
strict=False
)
)
return normalized_path
def _resolve_user_path(path: str, *, local_env: bool, umo: str) -> Path:
def _resolve_user_path(
path: str,
*,
local_env: bool,
umo: str,
current_workspace_root: Path | None = None,
) -> Path:
candidate = Path(path).expanduser()
if candidate.is_absolute():
return candidate.resolve(strict=False)
if local_env:
return (_workspace_root(umo) / candidate).resolve(strict=False)
return ((current_workspace_root or _workspace_root(umo)) / candidate).resolve(
strict=False
)
return (Path.cwd() / candidate).resolve(strict=False)
@@ -175,8 +203,14 @@ def _is_path_within_allowed_roots(
*,
umo: str,
allowed_roots: tuple[Path, ...],
current_workspace_root: Path | None = None,
) -> bool:
resolved = _resolve_user_path(path, local_env=True, umo=umo)
resolved = _resolve_user_path(
path,
local_env=True,
umo=umo,
current_workspace_root=current_workspace_root,
)
return any(
resolved == allowed_root or resolved.is_relative_to(allowed_root)
for allowed_root in allowed_roots
@@ -209,19 +243,34 @@ def _normalize_rw_path(
local_env: bool,
umo: str,
write: bool = False,
current_workspace_root: Path | None = None,
) -> str:
normalized_path = _resolve_tool_path(path, local_env=local_env, umo=umo)
normalized_path = _resolve_tool_path(
path,
local_env=local_env,
umo=umo,
current_workspace_root=current_workspace_root,
)
if not normalized_path:
raise ValueError("`path` must be a non-empty string.")
if restricted:
allowed_roots = _write_allowed_roots(umo) if write else _read_allowed_roots(umo)
allowed_roots = (
_write_allowed_roots(umo, current_workspace_root)
if write
else _read_allowed_roots(umo, current_workspace_root)
)
if restricted and not _is_path_within_allowed_roots(
normalized_path,
umo=umo,
allowed_roots=allowed_roots,
current_workspace_root=current_workspace_root,
):
allowed = ", ".join(
_restricted_env_path_labels(umo, include_plugin_skills=not write),
_restricted_env_path_labels(
umo,
include_plugin_skills=not write,
current_workspace_root=current_workspace_root,
)
)
access = "Write" if write else "Read"
raise PermissionError(
@@ -291,6 +340,9 @@ class FileReadTool(FunctionTool):
) -> ToolExecResult:
local_env = is_local_runtime(context)
restricted = _is_restricted_env(context)
current_workspace_root = (
await workspace_root_for_context(context) if local_env else None
)
try:
normalized_path = (
_normalize_rw_path(
@@ -298,12 +350,18 @@ class FileReadTool(FunctionTool):
restricted=restricted,
local_env=local_env,
umo=context.context.event.unified_msg_origin,
current_workspace_root=current_workspace_root,
)
if local_env
else path.strip()
)
if not normalized_path:
raise ValueError("`path` must be a non-empty string.")
if local_env and os.path.isdir(normalized_path):
return (
f"Error: '{normalized_path}' is a directory, not a file. "
"Use a file path instead, or use 'astrbot_execute_shell' to list directory contents."
)
offset, limit = self._validate_read_window(offset, limit)
sb = await get_booter(
context.context.context,
@@ -316,7 +374,10 @@ class FileReadTool(FunctionTool):
offset=offset,
limit=limit,
workspace_dir=(
str(_workspace_root(context.context.event.unified_msg_origin))
str(
current_workspace_root
or _workspace_root(context.context.event.unified_msg_origin)
)
if local_env
else None
),
@@ -358,6 +419,9 @@ class FileWriteTool(FunctionTool):
) -> ToolExecResult:
local_env = is_local_runtime(context)
restricted = _is_restricted_env(context)
current_workspace_root = (
await workspace_root_for_context(context) if local_env else None
)
try:
normalized_path = (
_normalize_rw_path(
@@ -366,6 +430,7 @@ class FileWriteTool(FunctionTool):
local_env=local_env,
umo=context.context.event.unified_msg_origin,
write=True,
current_workspace_root=current_workspace_root,
)
if local_env
else path.strip()
@@ -437,6 +502,9 @@ class FileEditTool(FunctionTool):
umo = str(context.context.event.unified_msg_origin)
local_env = is_local_runtime(context)
restricted = _is_restricted_env(context)
current_workspace_root = (
await workspace_root_for_context(context) if local_env else None
)
try:
normalized_path = (
_normalize_rw_path(
@@ -445,6 +513,7 @@ class FileEditTool(FunctionTool):
local_env=local_env,
umo=umo,
write=True,
current_workspace_root=current_workspace_root,
)
if local_env
else path.strip()
@@ -594,15 +663,28 @@ class GrepTool(FunctionTool):
restricted: bool,
local_env: bool,
umo: str,
current_workspace_root: Path | None = None,
) -> list[str]:
normalized = (
[_resolve_tool_path(path, local_env=local_env, umo=umo)] if path else []
[
_resolve_tool_path(
path,
local_env=local_env,
umo=umo,
current_workspace_root=current_workspace_root,
)
]
if path
else []
)
if not normalized:
if restricted:
return [str(root) for root in _read_allowed_roots(umo)]
return [
str(root)
for root in _read_allowed_roots(umo, current_workspace_root)
]
if local_env:
return [str(_workspace_root(umo))]
return [str(current_workspace_root or _workspace_root(umo))]
return ["."]
if restricted:
@@ -612,12 +694,17 @@ class GrepTool(FunctionTool):
if not _is_path_within_allowed_roots(
path,
umo=umo,
allowed_roots=_read_allowed_roots(umo),
allowed_roots=_read_allowed_roots(umo, current_workspace_root),
current_workspace_root=current_workspace_root,
)
]
if disallowed:
allowed = ", ".join(
_restricted_env_path_labels(umo, include_plugin_skills=True),
_restricted_env_path_labels(
umo,
include_plugin_skills=True,
current_workspace_root=current_workspace_root,
)
)
blocked = ", ".join(disallowed)
raise PermissionError(
@@ -644,6 +731,9 @@ class GrepTool(FunctionTool):
local_env = is_local_runtime(context)
restricted = _is_restricted_env(context)
current_workspace_root = (
await workspace_root_for_context(context) if local_env else None
)
try:
search_paths = (
self._normalize_search_paths(
@@ -651,6 +741,7 @@ class GrepTool(FunctionTool):
restricted=restricted,
local_env=local_env,
umo=context.context.event.unified_msg_origin,
current_workspace_root=current_workspace_root,
)
if local_env
else ([path.strip()] if path and path.strip() else ["."])
+7 -1
View File
@@ -9,7 +9,10 @@ from astrbot.core.agent.tool import ToolExecResult
from astrbot.core.astr_agent_context import AstrAgentContext, AstrMessageEvent
from astrbot.core.computer.computer_client import get_booter, get_local_booter
from astrbot.core.message.message_event_result import MessageChain
from astrbot.core.tools.computer_tools.util import check_admin_permission
from astrbot.core.tools.computer_tools.util import (
check_admin_permission,
workspace_root_for_context,
)
from astrbot.core.tools.registry import builtin_tool
_OS_NAME = platform.system()
@@ -138,10 +141,13 @@ class LocalPythonTool(FunctionTool):
else context.tool_call_timeout
)
try:
current_workspace_root = await workspace_root_for_context(context)
current_workspace_root.mkdir(parents=True, exist_ok=True)
result = await sb.python.exec(
code,
timeout=effective_timeout,
silent=silent,
cwd=str(current_workspace_root),
)
return await handle_result(result, context.context.event)
except Exception as e:
+23 -11
View File
@@ -14,7 +14,7 @@ from astrbot.core.computer.computer_client import get_booter
from astrbot.core.tools.computer_tools.util import (
check_admin_permission,
is_local_runtime,
workspace_root,
workspace_root_for_context,
)
from astrbot.core.tools.registry import builtin_tool
from astrbot.core.utils.astrbot_path import get_astrbot_system_tmp_path
@@ -23,6 +23,8 @@ _COMPUTER_RUNTIME_TOOL_CONFIG = {
"provider_settings.computer_use_runtime": ("local", "sandbox"),
}
_LOCAL_SHELL_WORKSPACES: dict[str, str] = {}
def _quote_redirect_path(path: str, *, local_runtime: bool) -> str:
if local_runtime and os.name == "nt":
@@ -104,18 +106,18 @@ class ExecuteShellTool(FunctionTool):
context.context.event.unified_msg_origin,
)
try:
# Ensure the workspace directory exists (useful for file operations)
if is_local_runtime(context):
workspace_root(
context.context.event.unified_msg_origin,
).mkdir(parents=True, exist_ok=True)
local_runtime = is_local_runtime(context)
current_workspace: str | None = None
if local_runtime:
current_workspace_root = await workspace_root_for_context(context)
current_workspace_root.mkdir(parents=True, exist_ok=True)
current_workspace = str(current_workspace_root)
env = dict(env or {})
effective_background = background and not _is_self_detached_command(command)
stdout_file: str | None = None
if effective_background:
local_runtime = is_local_runtime(context)
stdout_file = _build_background_output_path(
local_runtime=local_runtime,
)
@@ -131,16 +133,21 @@ class ExecuteShellTool(FunctionTool):
"env": env,
"timeout": timeout or 300,
}
# Don't pass cwd for local runtime — the persistent shell
# session maintains its own working directory across calls.
if is_local_runtime(context):
exec_kwargs["session_id"] = context.context.event.unified_msg_origin
if local_runtime:
session_id = context.context.event.unified_msg_origin
exec_kwargs["session_id"] = session_id
if _LOCAL_SHELL_WORKSPACES.get(session_id) != current_workspace:
exec_kwargs["cwd"] = current_workspace
else:
exec_kwargs["cwd"] = (
None # remote runtime; cwd is managed by the sandbox
)
result = await sb.shell.exec(**exec_kwargs)
if local_runtime and current_workspace is not None:
_LOCAL_SHELL_WORKSPACES[context.context.event.unified_msg_origin] = (
current_workspace
)
if stdout_file:
result["stdout"] = (
f"Command is running in the background. stdout/stderr is being "
@@ -148,6 +155,11 @@ class ExecuteShellTool(FunctionTool):
)
return json.dumps(result, ensure_ascii=False)
except Exception as e:
if is_local_runtime(context):
_LOCAL_SHELL_WORKSPACES.pop(
context.context.event.unified_msg_origin,
None,
)
detail = str(e) or type(e).__name__
return f"Error executing command: {detail}"
+37 -9
View File
@@ -1,20 +1,48 @@
import re
from pathlib import Path
from astrbot.core.agent.run_context import ContextWrapper
from astrbot.core.astr_agent_context import AstrAgentContext
from astrbot.core.db import BaseDatabase
from astrbot.core.utils.astrbot_path import get_astrbot_workspaces_path
def normalize_umo_for_workspace(umo: str) -> str:
normalized = re.sub(r"[^A-Za-z0-9._-]+", "_", umo.strip())
return normalized or "unknown"
from astrbot.core.workspace import (
normalize_umo_for_workspace,
resolve_workspace_root_for_umo,
)
def workspace_root(umo: str) -> Path:
"""Root directory for relative paths in local runtime"""
normalized_umo = normalize_umo_for_workspace(umo)
return (Path(get_astrbot_workspaces_path()) / normalized_umo).resolve(strict=False)
"""Return the legacy workspace root for compatibility.
Args:
umo: Unified message origin.
Returns:
Legacy per-session workspace root.
"""
return (
Path(get_astrbot_workspaces_path()) / normalize_umo_for_workspace(umo)
).resolve(strict=False)
async def workspace_root_for_context(
context: ContextWrapper[AstrAgentContext],
) -> Path:
"""Resolve the workspace root for a tool call context.
Args:
context: Tool call context.
Returns:
Workspace root used as cwd.
"""
umo = context.context.event.unified_msg_origin
db = getattr(context.context.context, "_db", None)
if not isinstance(db, BaseDatabase):
return workspace_root(umo)
try:
return await resolve_workspace_root_for_umo(umo, db)
except Exception:
return workspace_root(umo)
def is_local_runtime(context: ContextWrapper[AstrAgentContext]) -> bool:
+109 -18
View File
@@ -2,9 +2,9 @@ import json
import os
import shlex
import uuid
from pathlib import Path
from typing import TypedDict
import anyio
from pydantic import Field
from pydantic.dataclasses import dataclass
@@ -16,9 +16,18 @@ from astrbot.core.astr_agent_context import AstrAgentContext
from astrbot.core.computer.computer_client import get_booter
from astrbot.core.message.message_event_result import MessageChain
from astrbot.core.platform.message_session import MessageSession
from astrbot.core.tools.computer_tools.util import check_admin_permission
from astrbot.core.tools.computer_tools.fs import _remote_basename
from astrbot.core.tools.computer_tools.util import (
check_admin_permission,
is_local_runtime,
workspace_root,
workspace_root_for_context,
)
from astrbot.core.tools.registry import builtin_tool
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
from astrbot.core.utils.astrbot_path import (
get_astrbot_system_tmp_path,
get_astrbot_temp_path,
)
class MessageComponentPayload(TypedDict, total=False):
@@ -29,6 +38,26 @@ class MessageComponentPayload(TypedDict, total=False):
mention_user_id: str
def _file_send_allowed_roots(
umo: str | None,
current_workspace_root: Path | None = None,
) -> tuple[Path, ...]:
roots = []
if umo:
roots.append(current_workspace_root or workspace_root(umo))
roots.extend(
[
Path(get_astrbot_temp_path()).resolve(strict=False),
Path(get_astrbot_system_tmp_path()).resolve(strict=False),
]
)
return tuple(roots)
def _is_path_within(path: Path, roots: tuple[Path, ...]) -> bool:
return any(path == root or path.is_relative_to(root) for root in roots)
def _normalize_message_component(raw_msg: object) -> MessageComponentPayload | None:
if not isinstance(raw_msg, dict):
return None
@@ -50,6 +79,29 @@ def _normalize_message_component(raw_msg: object) -> MessageComponentPayload | N
return normalized
def _is_restricted_local_env(context: ContextWrapper[AstrAgentContext]) -> bool:
if not is_local_runtime(context):
return False
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)
return require_admin and context.context.event.role != "admin"
def _can_send_local_file(
context: ContextWrapper[AstrAgentContext],
local_path: Path,
current_workspace_root: Path | None = None,
) -> bool:
umo = context.context.event.unified_msg_origin
allowed_roots = _file_send_allowed_roots(umo, current_workspace_root)
if _is_path_within(local_path, allowed_roots):
return True
return is_local_runtime(context) and not _is_restricted_local_env(context)
@builtin_tool
@dataclass
class SendMessageToUserTool(FunctionTool[AstrAgentContext]):
@@ -116,23 +168,47 @@ class SendMessageToUserTool(FunctionTool[AstrAgentContext]):
*,
component_type: str = "file",
) -> tuple[str, bool]:
path = str(path)
# if the path is relative, check if the file exists in user's local workspace
path = str(path).strip()
if not path:
raise FileNotFoundError(f"{component_type} path is empty")
current_workspace_root = (
await workspace_root_for_context(context)
if is_local_runtime(context)
else None
)
# Relative host paths are resolved only inside the user's workspace.
if not os.path.isabs(path):
unified_msg_origin = context.context.event.unified_msg_origin
if unified_msg_origin:
from astrbot.core.tools.computer_tools.util import workspace_root
ws_path = current_workspace_root or workspace_root(unified_msg_origin)
try:
ws_path = workspace_root(unified_msg_origin)
ws_candidate = (ws_path / path).resolve()
ws_candidate = (ws_path / path).resolve(strict=False)
if ws_candidate.is_file() and ws_candidate.is_relative_to(ws_path):
return str(ws_candidate), False
except Exception:
pass
# check if the file exists in local environment (only allow absolute paths to prevent traversal)
elif await anyio.Path(path).is_file():
return path, False
else:
local_candidate = Path(path).expanduser().resolve(strict=False)
if local_candidate.is_file():
if _can_send_local_file(
context, local_candidate, current_workspace_root
):
return str(local_candidate), False
if is_local_runtime(context):
allowed = ", ".join(
str(root)
for root in _file_send_allowed_roots(
context.context.event.unified_msg_origin,
current_workspace_root,
)
)
raise PermissionError(
"Local file send is restricted for this user. "
f"Allowed directories: {allowed}. "
f"Blocked path: {local_candidate}."
)
try:
sb = await get_booter(
@@ -142,7 +218,7 @@ class SendMessageToUserTool(FunctionTool[AstrAgentContext]):
quoted_path = shlex.quote(path)
result = await sb.shell.exec(f"test -f {quoted_path} && echo '_&exists_'")
if "_&exists_" in json.dumps(result):
name = os.path.basename(path)
name = _remote_basename(path) or os.path.basename(path)
local_path = os.path.join(
get_astrbot_temp_path(),
f"sandbox_{uuid.uuid4().hex[:4]}_{name}",
@@ -239,7 +315,7 @@ class SendMessageToUserTool(FunctionTool[AstrAgentContext]):
url = normalized_msg.get("url")
name = (
normalized_msg.get("text")
or (os.path.basename(path) if path else "")
or (_remote_basename(path) if path else "")
or (os.path.basename(url) if url else "")
or "file"
)
@@ -265,6 +341,8 @@ class SendMessageToUserTool(FunctionTool[AstrAgentContext]):
)
except FileNotFoundError as exc:
return f"error: {exc}"
except PermissionError as exc:
return f"error: {exc}"
except Exception as exc:
return f"error: failed to build messages[{idx}] component: {exc}"
@@ -296,10 +374,23 @@ class SendMessageToUserTool(FunctionTool[AstrAgentContext]):
else:
return f"error: invalid session: {session}"
await context.context.context.send_message(
target_session,
MessageChain(chain=components),
)
message_chain = MessageChain(chain=components)
await context.context.context.send_message(target_session, message_chain)
if str(target_session) == current_session:
context.context.event._has_send_oper = True
sent_plain_text = message_chain.get_plain_text().strip()
if sent_plain_text:
sent_plain_texts = context.context.event.get_extra(
"_send_message_to_user_current_session_plain_texts",
[],
)
if not isinstance(sent_plain_texts, list):
sent_plain_texts = []
sent_plain_texts.append(sent_plain_text)
context.context.event.set_extra(
"_send_message_to_user_current_session_plain_texts",
sent_plain_texts,
)
return f"Message sent to session {target_session}"
+369 -175
View File
@@ -60,12 +60,29 @@ class SearchResult:
@std_dataclass
class _KeyRotator:
"""Concurrency-safe round-robin API key rotator.
Each call returns the current key and advances the index. Search functions
combine this with failover loops to retry with the next configured key.
"""
setting_name: str
provider_name: str
index: int = 0
lock: asyncio.Lock = field(default_factory=asyncio.Lock)
async def get(self, provider_settings: dict) -> str:
"""Return the current key and advance the round-robin index.
Args:
provider_settings: Provider settings containing API key lists.
Returns:
The API key selected for this call.
Raises:
ValueError: If the configured key list is empty or missing.
"""
keys = provider_settings.get(self.setting_name, [])
if not keys:
raise ValueError(
@@ -73,11 +90,21 @@ class _KeyRotator:
)
async with self.lock:
# Keep the index valid if runtime config reloads shrink the key list.
self.index = self.index % len(keys)
key = keys[self.index]
self.index = (self.index + 1) % len(keys)
return key
# Retry with the next API key when these HTTP statuses indicate key-specific
# auth, quota, or rate-limit failures.
# 401 - Unauthorized, usually invalid or expired key.
# 403 - Forbidden, usually disabled key.
# 429 - Rate limited.
# 432 - Tavily quota exceeded.
_RETRYABLE_HTTP_STATUSES: frozenset[int] = frozenset({401, 403, 429, 432})
_TAVILY_KEY_ROTATOR = _KeyRotator("websearch_tavily_key", "Tavily")
_BOCHA_KEY_ROTATOR = _KeyRotator("websearch_bocha_key", "BoCha")
_BRAVE_KEY_ROTATOR = _KeyRotator("websearch_brave_key", "Brave")
@@ -151,205 +178,372 @@ async def _tavily_search(
provider_settings: dict,
payload: dict,
) -> list[SearchResult]:
tavily_key = await _TAVILY_KEY_ROTATOR.get(provider_settings)
header = {
"Authorization": f"Bearer {tavily_key}",
"Content-Type": "application/json",
}
async with (
aiohttp.ClientSession(trust_env=True) as session,
session.post(
"https://api.tavily.com/search",
json=payload,
headers=header,
) as response,
):
if response.status != 200:
reason = await response.text()
raise Exception(
f"Tavily web search failed: {reason}, status: {response.status}",
)
data = await response.json()
return [
SearchResult(
title=item.get("title"),
url=item.get("url"),
snippet=item.get("content"),
favicon=item.get("favicon"),
)
for item in data.get("results", [])
]
"""Call the Tavily Search API with API key failover.
Args:
provider_settings: Provider settings containing Tavily API keys.
payload: Request payload for the Tavily search endpoint.
Returns:
Normalized search results.
Raises:
ValueError: If Tavily API keys are not configured.
Exception: If the request fails after all retryable keys are exhausted,
or if a non-retryable HTTP error is returned.
"""
keys = provider_settings.get("websearch_tavily_key", [])
if not keys:
raise ValueError("Error: Tavily API key is not configured in AstrBot.")
# Retry key-specific failures with the next key, but fail fast for
# non-retryable errors such as server-side 5xx responses.
last_error = None
for _ in range(len(keys)):
tavily_key = await _TAVILY_KEY_ROTATOR.get(provider_settings)
header = {
"Authorization": f"Bearer {tavily_key}",
"Content-Type": "application/json",
}
async with aiohttp.ClientSession(trust_env=True) as session:
async with session.post(
"https://api.tavily.com/search",
json=payload,
headers=header,
) as response:
if response.status == 200:
data = await response.json()
return [
SearchResult(
title=item.get("title"),
url=item.get("url"),
snippet=item.get("content"),
favicon=item.get("favicon"),
)
for item in data.get("results", [])
]
reason = await response.text()
# Retryable errors are saved so the final failure is meaningful.
if response.status in _RETRYABLE_HTTP_STATUSES:
last_error = Exception(
f"Tavily web search failed: {reason}, status: {response.status}",
)
continue
raise Exception(
f"Tavily web search failed: {reason}, status: {response.status}",
)
if last_error is not None:
raise last_error
raise Exception("Tavily web search failed with all configured keys.")
async def _tavily_extract(provider_settings: dict, payload: dict) -> list[dict]:
tavily_key = await _TAVILY_KEY_ROTATOR.get(provider_settings)
header = {
"Authorization": f"Bearer {tavily_key}",
"Content-Type": "application/json",
}
async with (
aiohttp.ClientSession(trust_env=True) as session,
session.post(
"https://api.tavily.com/extract",
json=payload,
headers=header,
) as response,
):
if response.status != 200:
reason = await response.text()
raise Exception(
f"Tavily web search failed: {reason}, status: {response.status}",
)
data = await response.json()
results: list[dict] = data.get("results", [])
if not results:
raise ValueError(
"Error: Tavily web searcher does not return any results.",
)
return results
"""Call the Tavily Extract API with API key failover.
Args:
provider_settings: Provider settings containing Tavily API keys.
payload: Request payload for the Tavily extract endpoint.
Returns:
Raw Tavily extraction results.
Raises:
ValueError: If Tavily API keys are not configured or no results are
returned.
Exception: If the request fails after all retryable keys are exhausted,
or if a non-retryable HTTP error is returned.
"""
keys = provider_settings.get("websearch_tavily_key", [])
if not keys:
raise ValueError("Error: Tavily API key is not configured in AstrBot.")
last_error = None
for _ in range(len(keys)):
tavily_key = await _TAVILY_KEY_ROTATOR.get(provider_settings)
header = {
"Authorization": f"Bearer {tavily_key}",
"Content-Type": "application/json",
}
async with aiohttp.ClientSession(trust_env=True) as session:
async with session.post(
"https://api.tavily.com/extract",
json=payload,
headers=header,
) as response:
if response.status == 200:
data = await response.json()
results: list[dict] = data.get("results", [])
if not results:
raise ValueError(
"Error: Tavily web searcher does not return any results."
)
return results
reason = await response.text()
if response.status in _RETRYABLE_HTTP_STATUSES:
last_error = Exception(
f"Tavily web search failed: {reason}, status: {response.status}",
)
continue
raise Exception(
f"Tavily web search failed: {reason}, status: {response.status}",
)
if last_error is not None:
raise last_error
raise Exception("Tavily web extract failed with all configured keys.")
async def _bocha_search(
provider_settings: dict,
payload: dict,
) -> list[SearchResult]:
bocha_key = await _BOCHA_KEY_ROTATOR.get(provider_settings)
header = {
"Authorization": f"Bearer {bocha_key}",
"Content-Type": "application/json",
# Explicitly disable brotli encoding to avoid aiohttp >= 3.13.3 brotli
# decompression incompatibility (TypeError: process() takes exactly 1 argument).
# See: https://github.com/aio-libs/aiohttp/issues/11898
"Accept-Encoding": "gzip, deflate",
}
async with (
aiohttp.ClientSession(trust_env=True) as session,
session.post(
"https://api.bochaai.com/v1/web-search",
json=payload,
headers=header,
) as response,
):
if response.status != 200:
reason = await response.text()
raise Exception(
f"BoCha web search failed: {reason}, status: {response.status}",
)
data = await response.json()
rows = data["data"]["webPages"]["value"]
return [
SearchResult(
title=item.get("name"),
url=item.get("url"),
snippet=item.get("snippet"),
favicon=item.get("siteIcon"),
)
for item in rows
]
"""Call the BoCha Search API with API key failover.
Args:
provider_settings: Provider settings containing BoCha API keys.
payload: Request payload for the BoCha search endpoint.
Returns:
Normalized search results.
Raises:
ValueError: If BoCha API keys are not configured.
Exception: If the request fails after all retryable keys are exhausted,
or if a non-retryable HTTP error is returned.
"""
keys = provider_settings.get("websearch_bocha_key", [])
if not keys:
raise ValueError("Error: BoCha API key is not configured in AstrBot.")
last_error = None
for _ in range(len(keys)):
bocha_key = await _BOCHA_KEY_ROTATOR.get(provider_settings)
header = {
"Authorization": f"Bearer {bocha_key}",
"Content-Type": "application/json",
# Explicitly disable brotli encoding to avoid aiohttp >= 3.13.3
# decompression incompatibility.
# See: https://github.com/aio-libs/aiohttp/issues/11898
"Accept-Encoding": "gzip, deflate",
}
async with aiohttp.ClientSession(trust_env=True) as session:
async with session.post(
"https://api.bochaai.com/v1/web-search",
json=payload,
headers=header,
) as response:
if response.status == 200:
data = await response.json()
rows = data["data"]["webPages"]["value"]
return [
SearchResult(
title=item.get("name"),
url=item.get("url"),
snippet=item.get("snippet"),
favicon=item.get("siteIcon"),
)
for item in rows
]
reason = await response.text()
if response.status in _RETRYABLE_HTTP_STATUSES:
last_error = Exception(
f"BoCha web search failed: {reason}, status: {response.status}",
)
continue
raise Exception(
f"BoCha web search failed: {reason}, status: {response.status}",
)
if last_error is not None:
raise last_error
raise Exception("BoCha web search failed with all configured keys.")
async def _brave_search(
provider_settings: dict,
payload: dict,
) -> list[SearchResult]:
brave_key = await _BRAVE_KEY_ROTATOR.get(provider_settings)
header = {
"Accept": "application/json",
"X-Subscription-Token": brave_key,
}
async with (
aiohttp.ClientSession(trust_env=True) as session,
session.get(
"https://api.search.brave.com/res/v1/web/search",
params=payload,
headers=header,
) as response,
):
if response.status != 200:
reason = await response.text()
raise Exception(
f"Brave web search failed: {reason}, status: {response.status}",
)
data = await response.json()
rows = data.get("web", {}).get("results", [])
return [
SearchResult(
title=item.get("title", ""),
url=item.get("url", ""),
snippet=item.get("description", ""),
)
for item in rows
]
"""Call the Brave Search API with API key failover.
Args:
provider_settings: Provider settings containing Brave API keys.
payload: Request payload for the Brave search endpoint.
Returns:
Normalized search results.
Raises:
ValueError: If Brave API keys are not configured.
Exception: If the request fails after all retryable keys are exhausted,
or if a non-retryable HTTP error is returned.
"""
keys = provider_settings.get("websearch_brave_key", [])
if not keys:
raise ValueError("Error: Brave API key is not configured in AstrBot.")
last_error = None
for _ in range(len(keys)):
brave_key = await _BRAVE_KEY_ROTATOR.get(provider_settings)
header = {
"Accept": "application/json",
"X-Subscription-Token": brave_key,
}
async with aiohttp.ClientSession(trust_env=True) as session:
async with session.get(
"https://api.search.brave.com/res/v1/web/search",
params=payload,
headers=header,
) as response:
if response.status == 200:
data = await response.json()
rows = data.get("web", {}).get("results", [])
return [
SearchResult(
title=item.get("title", ""),
url=item.get("url", ""),
snippet=item.get("description", ""),
)
for item in rows
]
reason = await response.text()
if response.status in _RETRYABLE_HTTP_STATUSES:
last_error = Exception(
f"Brave web search failed: {reason}, status: {response.status}",
)
continue
raise Exception(
f"Brave web search failed: {reason}, status: {response.status}",
)
if last_error is not None:
raise last_error
raise Exception("Brave web search failed with all configured keys.")
async def _firecrawl_search(
provider_settings: dict,
payload: dict,
) -> list[SearchResult]:
firecrawl_key = await _FIRECRAWL_KEY_ROTATOR.get(provider_settings)
header = {
"Authorization": f"Bearer {firecrawl_key}",
"Content-Type": "application/json",
}
async with (
aiohttp.ClientSession(trust_env=True) as session,
session.post(
"https://api.firecrawl.dev/v2/search",
json=payload,
headers=header,
) as response,
):
if response.status != 200:
reason = await response.text()
raise Exception(
f"Firecrawl web search failed: {reason}, status: {response.status}",
)
data = await response.json()
rows = data.get("data", [])
if isinstance(rows, dict):
rows = rows.get("web", [])
return [
SearchResult(
title=item.get("title", ""),
url=item.get("url", ""),
snippet=(
item.get("description")
or item.get("snippet")
or item.get("markdown")
or ""
),
)
for item in rows
if item.get("url")
]
"""Call the Firecrawl Search API with API key failover.
Args:
provider_settings: Provider settings containing Firecrawl API keys.
payload: Request payload for the Firecrawl search endpoint.
Returns:
Normalized search results.
Raises:
ValueError: If Firecrawl API keys are not configured.
Exception: If the request fails after all retryable keys are exhausted,
or if a non-retryable HTTP error is returned.
"""
keys = provider_settings.get("websearch_firecrawl_key", [])
if not keys:
raise ValueError("Error: Firecrawl API key is not configured in AstrBot.")
last_error = None
for _ in range(len(keys)):
firecrawl_key = await _FIRECRAWL_KEY_ROTATOR.get(provider_settings)
header = {
"Authorization": f"Bearer {firecrawl_key}",
"Content-Type": "application/json",
}
async with aiohttp.ClientSession(trust_env=True) as session:
async with session.post(
"https://api.firecrawl.dev/v2/search",
json=payload,
headers=header,
) as response:
if response.status == 200:
data = await response.json()
rows = data.get("data", [])
if isinstance(rows, dict):
rows = rows.get("web", [])
return [
SearchResult(
title=item.get("title", ""),
url=item.get("url", ""),
snippet=(
item.get("description")
or item.get("snippet")
or item.get("markdown")
or ""
),
)
for item in rows
if item.get("url")
]
reason = await response.text()
if response.status in _RETRYABLE_HTTP_STATUSES:
last_error = Exception(
f"Firecrawl web search failed: {reason}, status: {response.status}",
)
continue
raise Exception(
f"Firecrawl web search failed: {reason}, status: {response.status}",
)
if last_error is not None:
raise last_error
raise Exception("Firecrawl web search failed with all configured keys.")
async def _firecrawl_scrape(provider_settings: dict, payload: dict) -> dict:
firecrawl_key = await _FIRECRAWL_KEY_ROTATOR.get(provider_settings)
header = {
"Authorization": f"Bearer {firecrawl_key}",
"Content-Type": "application/json",
}
async with (
aiohttp.ClientSession(trust_env=True) as session,
session.post(
"https://api.firecrawl.dev/v2/scrape",
json=payload,
headers=header,
) as response,
):
if response.status != 200:
reason = await response.text()
raise Exception(
f"Firecrawl web scraper failed: {reason}, status: {response.status}",
)
data = await response.json()
result = data.get("data", {})
if not result:
raise ValueError(
"Error: Firecrawl web scraper does not return any results.",
)
return result
"""Call the Firecrawl Scrape API with API key failover.
Args:
provider_settings: Provider settings containing Firecrawl API keys.
payload: Request payload for the Firecrawl scrape endpoint.
Returns:
Raw Firecrawl scrape result data.
Raises:
ValueError: If Firecrawl API keys are not configured or no result data
is returned.
Exception: If the request fails after all retryable keys are exhausted,
or if a non-retryable HTTP error is returned.
"""
keys = provider_settings.get("websearch_firecrawl_key", [])
if not keys:
raise ValueError("Error: Firecrawl API key is not configured in AstrBot.")
last_error = None
for _ in range(len(keys)):
firecrawl_key = await _FIRECRAWL_KEY_ROTATOR.get(provider_settings)
header = {
"Authorization": f"Bearer {firecrawl_key}",
"Content-Type": "application/json",
}
async with aiohttp.ClientSession(trust_env=True) as session:
async with session.post(
"https://api.firecrawl.dev/v2/scrape",
json=payload,
headers=header,
) as response:
if response.status == 200:
data = await response.json()
result = data.get("data", {})
if not result:
raise ValueError(
"Error: Firecrawl web scraper does not return any results."
)
return result
reason = await response.text()
if response.status in _RETRYABLE_HTTP_STATUSES:
last_error = Exception(
f"Firecrawl web scraper failed: {reason}, status: {response.status}",
)
continue
raise Exception(
f"Firecrawl web scraper failed: {reason}, status: {response.status}",
)
if last_error is not None:
raise last_error
raise Exception("Firecrawl web scraper failed with all configured keys.")
async def _baidu_search(
+86 -9
View File
@@ -1,12 +1,20 @@
import os
import subprocess
import sys
import time
import zipfile
from pathlib import Path
import psutil
from astrbot.core import logger
from astrbot.core.config.default import VERSION
from astrbot.core.desktop_runtime import (
DESKTOP_MANAGED_RESTART_MESSAGE,
is_desktop_managed_backend,
)
from astrbot.core.utils.astrbot_path import get_astrbot_path
from astrbot.core.utils.io import ensure_dir
from .zip_updator import ReleaseInfo, RepoZipUpdator
@@ -21,6 +29,20 @@ class AstrBotUpdator(RepoZipUpdator):
super().__init__(repo_mirror, verify=verify)
self.MAIN_PATH = get_astrbot_path()
self.ASTRBOT_RELEASE_API = "https://api.soulter.top/releases"
self.CORE_PACKAGE_BASE_URL = (
"https://astrbot-registry.soulter.top/download/astrbot-core"
)
def _build_core_package_url(self, version: str | None) -> str | None:
if not version or not str(version).startswith("v"):
return None
base_url = os.environ.get(
"ASTRBOT_CORE_PACKAGE_BASE_URL",
self.CORE_PACKAGE_BASE_URL,
).strip()
if not base_url:
return None
return f"{base_url.rstrip('/')}/{version}/source.zip"
def terminate_child_processes(self) -> None:
"""终止当前进程的所有子进程
@@ -108,6 +130,11 @@ class AstrBotUpdator(RepoZipUpdator):
quoted_args = [f'"{arg}"' if " " in arg else arg for arg in argv[1:]]
os.execl(executable, quoted_executable, *quoted_args)
return
elif os.name == "nt":
subprocess.Popen(
[executable] + argv[1:], creationflags=subprocess.CREATE_NEW_CONSOLE
)
os._exit(0)
os.execv(executable, argv)
def _reboot(self, delay: int = 3) -> None:
@@ -115,6 +142,10 @@ class AstrBotUpdator(RepoZipUpdator):
在指定的延迟后,终止所有子进程并重新启动程序
这里只能使用 os.exec* 来重启程序
"""
if is_desktop_managed_backend():
logger.error(DESKTOP_MANAGED_RESTART_MESSAGE)
raise RuntimeError(DESKTOP_MANAGED_RESTART_MESSAGE)
time.sleep(delay)
self.terminate_child_processes()
executable = sys.executable
@@ -151,6 +182,25 @@ class AstrBotUpdator(RepoZipUpdator):
proxy="",
progress_callback=None,
) -> None:
zip_path = await self.download_update_package(
latest=latest,
version=version,
proxy=proxy,
progress_callback=progress_callback,
)
self.apply_update_package(zip_path)
if reboot:
self._reboot()
async def download_update_package(
self,
latest=True,
version=None,
proxy="",
path: str | Path = "temp.zip",
progress_callback=None,
) -> Path:
update_data = await self.fetch_release_info(self.ASTRBOT_RELEASE_API, latest)
file_url = None
@@ -159,15 +209,18 @@ class AstrBotUpdator(RepoZipUpdator):
"Error: You are running AstrBot via CLI, please use `pip` or `uv tool upgrade` to update AstrBot.",
) # 避免版本管理混乱
target_version = None
if latest:
latest_version = update_data[0]["tag_name"]
if self.compare_version(VERSION, latest_version) >= 0:
raise Exception("当前已经是最新版本。")
target_version = latest_version
file_url = update_data[0]["zipball_url"]
elif str(version).startswith("v"):
# 更新到指定版本
for data in update_data:
if data["tag_name"] == version:
target_version = data["tag_name"]
file_url = data["zipball_url"]
if not file_url:
raise Exception(f"未找到版本号为 {version} 的更新文件。")
@@ -181,16 +234,40 @@ class AstrBotUpdator(RepoZipUpdator):
proxy = proxy.removesuffix("/")
file_url = f"{proxy}/{file_url}"
zip_path = Path(path)
ensure_dir(zip_path.parent)
hosted_package_url = self._build_core_package_url(target_version)
if hosted_package_url:
try:
logger.info(
f"优先从托管存储下载 AstrBot Core 更新包: {hosted_package_url}"
)
await self._download_file(
hosted_package_url,
str(zip_path),
progress_callback=progress_callback,
)
if not zipfile.is_zipfile(zip_path):
raise RuntimeError(
"Downloaded hosted package is not a valid ZIP file"
)
return zip_path
except Exception as exc:
logger.warning(
f"从托管存储下载 AstrBot Core 更新包失败: {exc}"
"将回退到当前更新源。"
)
await self._download_file(
file_url,
str(zip_path),
progress_callback=progress_callback,
)
return zip_path
def apply_update_package(self, zip_path: str | Path) -> None:
try:
await self._download_file(
file_url,
"temp.zip",
progress_callback=progress_callback,
)
logger.info("下载 AstrBot Core 更新文件完成,正在执行解压...")
self.unzip_file("temp.zip", self.MAIN_PATH)
self.unzip_file(str(zip_path), self.MAIN_PATH)
except BaseException as e:
raise e
if reboot:
self._reboot()
+38 -6
View File
@@ -168,6 +168,23 @@ async def _emit_download_progress(progress_callback, payload: dict) -> None:
await result
class DownloadFileHTTPError(RuntimeError):
"""Raised when a file download returns an unsuccessful HTTP status."""
def _raise_for_download_status(resp, url: str) -> None:
if resp.status == 200:
return
logger.error(
"Failed to download file from %s. HTTP status code: %s",
url,
resp.status,
)
raise DownloadFileHTTPError(
f"Failed to download file from {url}. HTTP status code: {resp.status}"
)
async def download_file(
url: str,
path: str,
@@ -189,10 +206,7 @@ async def download_file(
url,
timeout=aiohttp.ClientTimeout(total=1800),
) as resp:
if resp.status != 200:
logger.error(
f"Failed to download file from {url}. HTTP status code: {resp.status}",
)
_raise_for_download_status(resp, url)
total_size = int(resp.headers.get("content-length", 0))
downloaded_size = 0
start_time = time.time()
@@ -271,6 +285,7 @@ async def download_file(
timeout=aiohttp.ClientTimeout(total=120),
) as resp,
):
_raise_for_download_status(resp, url)
total_size = int(resp.headers.get("content-length", 0))
downloaded_size = 0
start_time = time.time()
@@ -457,12 +472,14 @@ async def download_dashboard(
version: str | None = None,
proxy: str | None = None,
progress_callback=None,
extract: bool = True,
) -> None:
"""下载管理面板文件"""
if path is None:
zip_path = anyio.Path(get_astrbot_data_path()) / "dashboard.zip"
else:
zip_path = anyio.Path(path)
await zip_path.parent.mkdir(parents=True, exist_ok=True)
if latest or len(str(version)) != 40:
ver_name = "latest" if latest else version
@@ -477,6 +494,10 @@ async def download_dashboard(
show_progress=True,
progress_callback=progress_callback,
)
if not zipfile.is_zipfile(zip_path):
raise RuntimeError(
"Downloaded dashboard package is not a valid ZIP file"
)
except BaseException as _:
if latest:
# Resolve latest release tag from GitHub API to construct correct asset URL
@@ -517,10 +538,21 @@ async def download_dashboard(
show_progress=True,
progress_callback=progress_callback,
)
extract_dashboard(zip_path, extract_path)
if not zipfile.is_zipfile(zip_path):
raise RuntimeError("Downloaded dashboard package is not a valid ZIP file")
if extract:
extract_dashboard(zip_path, extract_path)
def extract_dashboard(zip_path: str | Path, extract_path: str | Path) -> None:
"""Extract a downloaded dashboard archive."""
extract_root = Path(extract_path).resolve()
ensure_dir(extract_root)
with zipfile.ZipFile(zip_path, "r") as z:
z.extractall(extract_path)
for member in z.infolist():
target_path = (extract_root / member.filename).resolve()
if not target_path.is_relative_to(extract_root):
raise ValueError(
f"Unsafe dashboard archive path: {member.filename}",
)
z.extract(member, extract_root)
+25 -3
View File
@@ -10,6 +10,7 @@ import binascii
import io
import mimetypes
import os
import shutil
import subprocess
import uuid
from collections.abc import AsyncIterator
@@ -1271,8 +1272,9 @@ async def ensure_jpeg(image_path: str, output_path: str | None = None) -> str:
Returns:
The original path when the source is already a JPEG file with a jpg/jpeg
suffix, cannot be found, has alpha transparency, or is animated; otherwise
the converted JPEG path.
suffix, cannot be found, has alpha transparency, or is animated. JPEG
files with another suffix are copied without re-encoding; other still
images are converted to JPEG.
Raises:
Exception: Raised by Pillow when the source file cannot be opened or saved as
@@ -1313,6 +1315,21 @@ async def ensure_jpeg(image_path: str, output_path: str | None = None) -> str:
output_path = str(temp_dir / f"media_image_{uuid.uuid4().hex}.jpg")
jpeg_output_path = output_path
try:
if image_format == "JPEG":
await asyncio.to_thread(shutil.copyfile, source_path, jpeg_output_path)
return jpeg_output_path
except Exception:
if output_path and os.path.exists(output_path):
try:
os.remove(output_path)
except OSError as e:
logger.warning(
"Failed to remove failed image output file: %s",
e,
)
raise
def convert_image_to_jpeg() -> str:
converted_img: PILImage.Image | None = None
@@ -1323,7 +1340,12 @@ async def ensure_jpeg(image_path: str, output_path: str | None = None) -> str:
converted_img = opened_img.convert("RGB")
working_img = converted_img
working_img.save(jpeg_output_path, "JPEG")
working_img.save(
jpeg_output_path,
"JPEG",
quality=IMAGE_COMPRESS_DEFAULT_QUALITY,
subsampling=0,
)
return jpeg_output_path
finally:
if converted_img is not None:
+25 -27
View File
@@ -1,4 +1,5 @@
import asyncio
import audioop
import base64
import os
import subprocess
@@ -12,6 +13,9 @@ import pysilk # requires silk-python (core dependency)
from astrbot.core import logger
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
# The SILK SDK only supports these rates
_PYSILK_SUPPORTED_RATES = frozenset({8000, 12000, 16000, 24000, 32000, 48000})
async def tencent_silk_to_wav(silk_path: str, output_path: str) -> str:
async with await anyio.open_file(silk_path, "rb") as f:
@@ -32,36 +36,30 @@ async def tencent_silk_to_wav(silk_path: str, output_path: str) -> str:
async def wav_to_tencent_silk(wav_path: str, output_path: str) -> float:
"""返回 duration"""
try:
import pilk
except (ImportError, ModuleNotFoundError) as _:
raise Exception(
"pilk 模块未安装,请前往管理面板->平台日志->安装pip库 安装 pilk 这个库",
) from None
# with wave.open(wav_path, 'rb') as wav:
# wav_data = wav.readframes(wav.getnframes())
# wav_data = BytesIO(wav_data)
# output_io = BytesIO()
# pysilk.encode(wav_data, output_io, 24000, 24000)
# output_io.seek(0)
# # 在首字节添加 \x02,去除结尾的\xff\xff
# silk_data = output_io.read()
# silk_data_with_prefix = b'\x02' + silk_data[:-2]
# # return BytesIO(silk_data_with_prefix)
# with open(output_path, "wb") as f:
# f.write(silk_data_with_prefix)
# return 0
"""Encode a WAV file as Tencent SILK and return its duration."""
with wave.open(wav_path, "rb") as wav:
rate = wav.getframerate()
frame_count = wav.getnframes()
fallback_duration = frame_count / rate if rate else 0
channels = wav.getnchannels()
sampwidth = wav.getsampwidth()
pcm_data = wav.readframes(wav.getnframes())
duration = pilk.encode(wav_path, output_path, pcm_rate=rate, tencent=True)
return duration or fallback_duration
# Downmix to mono, resample to 24 kHz if needed, and convert to 16-bit PCM
# (pysilk only accepts 16-bit linear PCM)
if channels == 2:
pcm_data = audioop.tomono(pcm_data, sampwidth, 0.5, 0.5)
if rate not in _PYSILK_SUPPORTED_RATES:
pcm_data, _ = audioop.ratecv(pcm_data, sampwidth, 1, rate, 24000, None)
rate = 24000
if sampwidth != 2:
pcm_data = audioop.lin2lin(pcm_data, sampwidth, 2)
input_io = BytesIO(pcm_data)
output_io = BytesIO()
# tencent=True makes pysilk emit the QQ-compatible 0x02-prefixed SILK stream.
pysilk.encode(input_io, output_io, rate, rate, tencent=True)
with open(output_path, "wb") as f:
f.write(output_io.getvalue())
return len(pcm_data) / (2 * rate) if rate else 0
async def convert_to_pcm_wav(input_path: str, output_path: str) -> str:
+196
View File
@@ -0,0 +1,196 @@
from __future__ import annotations
import re
from pathlib import Path
from typing import Any
from astrbot.core.db import BaseDatabase
from astrbot.core.platform.message_session import MessageSession
from astrbot.core.utils.astrbot_path import get_astrbot_workspaces_path
WORKSPACE_TYPE_SESSION = "session"
WORKSPACE_TYPE_PROJECT = "project"
WORKSPACE_TYPE_CUSTOM = "custom"
WORKSPACE_TYPES = {
WORKSPACE_TYPE_SESSION,
WORKSPACE_TYPE_PROJECT,
WORKSPACE_TYPE_CUSTOM,
}
def normalize_umo_for_workspace(umo: str) -> str:
"""Normalize a unified message origin into a filesystem-safe name.
Args:
umo: Unified message origin.
Returns:
Filesystem-safe workspace directory name.
"""
normalized = re.sub(r"[^A-Za-z0-9._-]+", "_", umo.strip())
return normalized or "unknown"
def normalize_project_workspace_type(value: Any) -> str:
"""Normalize stored or incoming project workspace type.
Args:
value: Raw workspace type value.
Returns:
A known workspace type.
"""
workspace_type = str(value or WORKSPACE_TYPE_SESSION).strip().lower()
return (
workspace_type if workspace_type in WORKSPACE_TYPES else WORKSPACE_TYPE_SESSION
)
def normalize_workspace_path(path: Any) -> str | None:
"""Normalize a custom workspace path value for storage.
Args:
path: Raw path value from API or database.
Returns:
Normalized path string, or None when empty.
"""
if not isinstance(path, str):
return None
value = path.strip()
return value or None
def default_workspace_root(umo: str) -> Path:
"""Return the legacy per-session workspace root.
Args:
umo: Unified message origin.
Returns:
The legacy workspace directory path.
"""
return (
Path(get_astrbot_workspaces_path()) / normalize_umo_for_workspace(umo)
).resolve(strict=False)
def project_workspace_root(project_id: str) -> Path:
"""Return the default shared workspace root for a ChatUI project.
Args:
project_id: ChatUI project ID.
Returns:
The project workspace directory path.
"""
safe_project_id = re.sub(r"[^A-Za-z0-9._-]+", "_", project_id.strip())
return (Path(get_astrbot_workspaces_path()) / f"project_{safe_project_id}").resolve(
strict=False
)
def workspace_path_to_root(path: str) -> Path:
"""Resolve a custom workspace path.
Args:
path: Stored workspace path. Relative values are rooted under AstrBot
workspaces. Absolute values are allowed and resolved as provided.
Returns:
Absolute resolved path.
Raises:
ValueError: If a relative path escapes or targets the AstrBot workspaces
root.
"""
workspaces_root = Path(get_astrbot_workspaces_path()).resolve(strict=False)
candidate = Path(path).expanduser()
if candidate.is_absolute():
return candidate.resolve(strict=False)
resolved = (workspaces_root / candidate).resolve(strict=False)
if resolved == workspaces_root or not resolved.is_relative_to(workspaces_root):
raise ValueError(
"Relative workspace path must stay within a subdirectory of AstrBot workspaces"
)
return resolved
def resolve_project_workspace_root(project: Any, *, fallback_umo: str) -> Path:
"""Resolve the workspace root from a project record.
Args:
project: Project-like object with workspace fields.
fallback_umo: UMO used when the project keeps legacy session workspaces.
Returns:
Workspace root used as cwd.
"""
fallback = default_workspace_root(fallback_umo)
workspace_type = normalize_project_workspace_type(
getattr(project, "workspace_type", WORKSPACE_TYPE_SESSION)
)
if workspace_type == WORKSPACE_TYPE_SESSION:
return fallback
if workspace_type == WORKSPACE_TYPE_PROJECT:
return project_workspace_root(str(project.project_id))
if workspace_type == WORKSPACE_TYPE_CUSTOM:
workspace_path = normalize_workspace_path(
getattr(project, "workspace_path", None)
)
if workspace_path:
return workspace_path_to_root(workspace_path)
return fallback
def parse_webchat_umo(umo: str) -> tuple[str, str] | None:
"""Extract creator and session ID from a webchat UMO.
Args:
umo: Unified message origin.
Returns:
Tuple of creator and ChatUI session ID, or None for non-webchat UMO.
"""
try:
message_session = MessageSession.from_str(umo)
except Exception:
return None
if message_session.platform_name != "webchat":
return None
parts = message_session.session_id.split("!", 2)
if len(parts) != 3 or parts[0] != "webchat":
return None
return parts[1], parts[2]
async def resolve_workspace_root_for_umo(
umo: str,
db: BaseDatabase | None = None,
) -> Path:
"""Resolve the workspace root for a UMO.
Args:
umo: Unified message origin.
db: Optional database instance. Falls back to the global database helper.
Returns:
Workspace root used as cwd.
"""
parsed = parse_webchat_umo(umo)
if not parsed:
return default_workspace_root(umo)
creator, session_id = parsed
if db is None:
from astrbot.core import db_helper
db = db_helper
project = await db.get_project_by_session(session_id=session_id, creator=creator)
if not project:
return default_workspace_root(umo)
return resolve_project_workspace_root(project, fallback_umo=umo)
+54 -24
View File
@@ -55,6 +55,55 @@ class RepoZipUpdator:
return body
return body[:max_len] + "...[truncated]"
async def fetch_github_default_branch(self, author: str, repo: str) -> str | None:
"""Fetch the default branch for a GitHub repository.
Args:
author: GitHub repository owner.
repo: GitHub repository name.
Returns:
The default branch name, or None if it cannot be resolved.
"""
url = f"https://api.github.com/repos/{author}/{repo}"
try:
async with self._create_httpx_client(timeout=10.0) as client:
response = await client.get(url)
response.raise_for_status()
repo_info = response.json()
except Exception as exc:
logger.debug("获取 GitHub 默认分支失败 %s/%s: %s", author, repo, exc)
return None
default_branch = str(repo_info.get("default_branch") or "").strip()
return default_branch or None
async def resolve_github_source_branch(
self,
repo_url: str,
) -> tuple[str, str, str]:
"""Resolve the GitHub branch used for repository source downloads.
Args:
repo_url: GitHub repository URL, optionally with a tree branch.
Returns:
Repository owner, name, and resolved source branch.
Raises:
ValueError: If the repository URL is invalid.
"""
author, repo, branch = self.parse_github_url(repo_url)
if branch:
return author, repo, branch
default_branch = await self.fetch_github_default_branch(author, repo)
if default_branch:
return author, repo, default_branch
logger.info("未能获取 %s/%s 的默认分支,将尝试 main 分支", author, repo)
return author, repo, "main"
async def _download_file(
self,
url: str,
@@ -229,32 +278,13 @@ class RepoZipUpdator:
repo_url: str,
proxy="",
) -> None:
author, repo, branch = self.parse_github_url(repo_url)
author, repo, branch = await self.resolve_github_source_branch(repo_url)
logger.info(f"正在下载更新 {repo} ...")
if branch:
logger.info(f"正在从指定分支 {branch} 下载 {author}/{repo}")
release_url = (
f"https://github.com/{author}/{repo}/archive/refs/heads/{branch}.zip"
)
else:
try:
release_url = f"https://api.github.com/repos/{author}/{repo}/releases"
releases = await self.fetch_release_info(url=release_url)
except Exception as e:
logger.warning(
f"获取 {author}/{repo} 的 GitHub Releases 失败: {e},将尝试下载默认分支",
)
releases = []
if not releases:
# 如果没有最新版本,下载默认分支
logger.info(f"正在从默认分支下载 {author}/{repo}")
release_url = (
f"https://github.com/{author}/{repo}/archive/refs/heads/master.zip"
)
else:
release_url = releases[0]["zipball_url"]
logger.info(f"正在从分支 {branch} 下载 {author}/{repo}")
release_url = (
f"https://github.com/{author}/{repo}/archive/refs/heads/{branch}.zip"
)
if proxy:
proxy = proxy.rstrip("/")
+9 -15
View File
@@ -9,6 +9,7 @@ from astrbot.core import logger
from astrbot.dashboard.async_utils import run_maybe_async
from astrbot.dashboard.responses import error, ok
from astrbot.dashboard.schemas import (
KnowledgeBaseCreateRequest,
KnowledgeBaseImportRequest,
KnowledgeBaseRequest,
KnowledgeBaseRetrieveRequest,
@@ -53,14 +54,6 @@ def _to_int(value: Any, default: int) -> int:
return default
def _model_dict(payload) -> dict[str, Any]:
if payload is None:
return {}
if hasattr(payload, "model_dump"):
return payload.model_dump(exclude_none=True)
return payload if isinstance(payload, dict) else {}
async def _run(operation, *, prefix: str):
try:
result = await run_maybe_async(operation)
@@ -102,12 +95,12 @@ async def list_knowledge_bases(
@router.post("/knowledge-bases")
async def create_knowledge_base(
payload: KnowledgeBaseRequest,
payload: KnowledgeBaseCreateRequest,
_auth: AuthContext = Depends(require_kb_scope),
service: KnowledgeBaseService = Depends(get_service),
):
return await _run(
lambda: service.create_kb(_model_dict(payload)),
lambda: service.create_kb(payload.canonical_payload()),
prefix="创建知识库失败",
)
@@ -140,9 +133,8 @@ async def update_knowledge_base(
_auth: AuthContext = Depends(require_kb_scope),
service: KnowledgeBaseService = Depends(get_service),
):
body = _model_dict(payload)
return await _run(
lambda: service.update_kb({"kb_id": kb_id, **body}),
lambda: service.update_kb({**payload.canonical_payload(), "kb_id": kb_id}),
prefix="更新知识库失败",
)
@@ -182,6 +174,7 @@ async def list_knowledge_base_documents(
kb_id=kb_id,
page=_to_int(request.query_params.get("page"), 1),
page_size=_to_int(request.query_params.get("page_size"), 100),
search=request.query_params.get("search"),
),
prefix="获取文档列表失败",
)
@@ -212,7 +205,7 @@ async def import_knowledge_base_documents(
_auth: AuthContext = Depends(require_kb_scope),
service: KnowledgeBaseService = Depends(get_service),
):
body = _model_dict(payload)
body = payload.model_dump(exclude_none=True)
return await _run(
lambda: service.import_documents({"kb_id": kb_id, **body}),
prefix="导入文档失败",
@@ -226,7 +219,7 @@ async def import_knowledge_base_document_url(
_auth: AuthContext = Depends(require_kb_scope),
service: KnowledgeBaseService = Depends(get_service),
):
body = _model_dict(payload)
body = payload.model_dump(exclude_none=True)
return await _run(
lambda: service.upload_document_from_url({"kb_id": kb_id, **body}),
prefix="从URL上传文档失败",
@@ -306,7 +299,7 @@ async def retrieve_knowledge_base(
_auth: AuthContext = Depends(require_kb_scope),
service: KnowledgeBaseService = Depends(get_service),
):
body = _model_dict(payload)
body = payload.model_dump(exclude_none=True)
return await _run(
lambda: service.retrieve({"kb_id": kb_id, **body}),
prefix="检索失败",
@@ -390,6 +383,7 @@ async def dashboard_list_documents(
kb_id=request.query_params.get("kb_id"),
page=_to_int(request.query_params.get("page"), 1),
page_size=_to_int(request.query_params.get("page_size"), 100),
search=request.query_params.get("search"),
),
prefix="获取文档列表失败",
)
+24 -3
View File
@@ -3,7 +3,9 @@ from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, Request
from fastapi.responses import Response
from astrbot.core.platform.webhook_server import webhook_response_from_result
from astrbot.dashboard.asgi_runtime import DashboardRequest
from astrbot.dashboard.async_utils import run_maybe_async
from astrbot.dashboard.responses import ApiError, ok
@@ -50,11 +52,30 @@ def _model_dict(payload) -> dict[str, Any]:
async def _run(operation):
try:
result = await run_maybe_async(operation)
if isinstance(result, Response):
return result
return ok(result)
except PlatformServiceError as exc:
_raise_platform_error(exc)
async def _run_webhook(operation):
"""Run a platform webhook callback and preserve the platform response.
Args:
operation: Callback operation returning a platform-specific response.
Returns:
Raw FastAPI response compatible with third-party webhook protocols.
"""
try:
result = await run_maybe_async(operation)
except PlatformServiceError as exc:
return webhook_response_from_result(({"error": str(exc)}, exc.status_code))
return webhook_response_from_result(result)
@router.post("/bot-types/{bot_type}/registration")
async def register_bot_type(
bot_type: str,
@@ -73,7 +94,7 @@ async def verify_platform_webhook(
request: Request,
service: PlatformService = Depends(get_service),
):
return await _run(
return await _run_webhook(
lambda: service.handle_webhook_callback(webhook_uuid, DashboardRequest(request))
)
@@ -84,7 +105,7 @@ async def receive_platform_webhook(
request: Request,
service: PlatformService = Depends(get_service),
):
return await _run(
return await _run_webhook(
lambda: service.handle_webhook_callback(webhook_uuid, DashboardRequest(request))
)
@@ -95,7 +116,7 @@ async def dashboard_platform_webhook(
request: Request,
service: PlatformService = Depends(get_service),
):
return await _run(
return await _run_webhook(
lambda: service.handle_webhook_callback(webhook_uuid, DashboardRequest(request))
)
+46
View File
@@ -24,9 +24,11 @@ from astrbot.dashboard.schemas import (
PluginConfigUpdateRequest,
PluginEnabledRequest,
PluginInstallRequest,
PluginSourceBindRequest,
PluginSourceRequest,
PluginUninstallRequest,
PluginUpdateRequest,
PluginValidateRepoRequest,
PluginVersionSupportRequest,
)
from astrbot.dashboard.services.config_service import (
@@ -484,6 +486,18 @@ async def check_plugin_version_support(
return await _check_plugin_version_support_payload(_model_dict(payload), service)
@router.post("/plugins/validate/repo")
async def validate_plugin_repo(
payload: PluginValidateRepoRequest,
_auth: AuthContext = Depends(require_plugin_scope),
service: PluginService = Depends(get_service),
):
return await _run_service(
service.validate_plugin_repo(_model_dict(payload)),
log_label="/api/plugin/validate-repo",
)
@router.post("/plugins/install/github")
async def install_plugin_from_github(
payload: PluginInstallRequest,
@@ -498,6 +512,15 @@ async def install_plugin_from_github(
"url": repository,
"proxy": body.get("proxy"),
"ignore_version_check": body.get("ignore_version_check", False),
**{
key: body[key]
for key in (
"install_method",
"registry_url",
"market_plugin_id",
)
if key in body
},
}
if body.get("download_url"):
install_payload["download_url"] = body["download_url"]
@@ -523,6 +546,15 @@ async def install_plugin_from_url(
"download_url": download_url,
"proxy": body.get("proxy"),
"ignore_version_check": body.get("ignore_version_check", False),
**{
key: body[key]
for key in (
"install_method",
"registry_url",
"market_plugin_id",
)
if key in body
},
}
),
log_label="/api/plugin/install",
@@ -1041,6 +1073,20 @@ async def reload_plugin(
)
@router.post("/plugins/{plugin_id}/source")
async def bind_plugin_source(
plugin_id: str,
payload: PluginSourceBindRequest,
_auth: AuthContext = Depends(require_plugin_scope),
service: PluginService = Depends(get_service),
):
body = _model_dict(payload)
return await _run_service(
service.bind_plugin_market_source({"name": plugin_id, **body}),
log_label="/api/plugin/source",
)
@router.patch("/plugins/{plugin_id}/enabled")
async def set_plugin_enabled(
plugin_id: str,
+10
View File
@@ -4,6 +4,7 @@ from fastapi import APIRouter, Depends, Query, Request
from fastapi.responses import JSONResponse
from astrbot.core import logger
from astrbot.core.desktop_runtime import DESKTOP_MANAGED_RESTART_MESSAGE
from astrbot.dashboard.async_utils import run_maybe_async
from astrbot.dashboard.schemas import PipInstallRequest, UpdateRequest
from astrbot.dashboard.services.update_service import (
@@ -58,6 +59,15 @@ def _service_response(result: UpdateServiceResult) -> JSONResponse:
def _service_error(exc: UpdateServiceError) -> JSONResponse:
logger.error(f"Dashboard update operation failed: {exc}", exc_info=True)
if exc.code == "desktop_managed":
return JSONResponse(
{
"status": "error",
"message": DESKTOP_MANAGED_RESTART_MESSAGE,
"data": None,
},
status_code=200,
)
return JSONResponse(
{"status": "error", "message": "An internal error has occurred.", "data": None},
status_code=200,
+8
View File
@@ -193,6 +193,14 @@ class DashboardRequest:
assert self._files_cache is not None
return self._files_cache
async def get_data(self) -> bytes:
"""Return the raw request body as bytes.
Returns:
The raw body bytes of the request.
"""
return await self._request.body()
class DashboardWebSocket:
def __init__(self, websocket: WebSocket) -> None:
+52 -2
View File
@@ -95,6 +95,8 @@ class ChatProjectRequest(OpenModel):
title: str | None = None
emoji: str | None = None
description: str | None = None
workspace_type: str | None = None
workspace_path: str | None = None
class ChatProjectSessionRequest(OpenModel):
@@ -205,13 +207,49 @@ class ImMessageRequest(OpenModel):
class KnowledgeBaseRequest(OpenModel):
kb_id: str | None = None
name: str | None = None
kb_name: str | None = Field(None, alias="name")
description: str | None = None
emoji: str | None = None
embedding_provider_id: str | None = None
rerank_provider_id: str | None = None
chunk_size: int | None = None
chunk_overlap: int | None = None
top_k_dense: int | None = None
top_k_sparse: int | None = None
top_m_final: int | None = None
model_config = ConfigDict(populate_by_name=True, extra="allow")
def canonical_payload(self) -> dict[str, Any]:
"""Return the service-facing knowledge base payload.
Returns:
Dictionary accepted by KnowledgeBaseService.
"""
return self.model_dump(
exclude_unset=True,
include={
"kb_name",
"description",
"emoji",
"embedding_provider_id",
"rerank_provider_id",
"chunk_size",
"chunk_overlap",
"top_k_dense",
"top_k_sparse",
"top_m_final",
},
by_alias=False,
)
class KnowledgeBaseCreateRequest(KnowledgeBaseRequest):
model_config = ConfigDict(
populate_by_name=True,
extra="allow",
json_schema_extra={"required": ["name", "embedding_provider_id"]},
)
class KnowledgeBaseImportRequest(OpenModel):
@@ -504,6 +542,18 @@ class PluginInstallRequest(OpenModel):
ignore_version_check: bool | None = None
class PluginValidateRepoRequest(OpenModel):
repository: str | None = None
url: str | None = None
proxy: str | None = None
class PluginSourceBindRequest(OpenModel):
install_method: str | None = None
registry_url: str | None = None
market_plugin_id: str | None = None
class PluginUpdateRequest(OpenModel):
plugin_id: str | None = None
plugin_ids: list[str] | None = None
+79 -27
View File
@@ -24,6 +24,7 @@ from astrbot.core.db import BaseDatabase
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
from astrbot.core.utils.io import (
get_bundled_dashboard_dist_path,
get_dashboard_dist_version,
get_local_ip_addresses,
should_use_bundled_dashboard_dist,
)
@@ -133,7 +134,7 @@ def _expand_env_placeholders(value: str, field_name: str) -> str:
import re
pattern = re.compile(
r"\$(?:\{(?P<braced>[A-Za-z_][A-Za-z0-9_]*)(?::-(?P<default>[^}]*)?\}|(?P<plain>[A-Za-z_][A-Za-z0-9_]*))"
r"\$(?:\{(?P<braced>[A-Za-z_][A-Za-z0-9_]*)(?::-(?P<default>[^}]*))?\}|(?P<plain>[A-Za-z_][A-Za-z0-9_]*))"
)
def _replace(match: re.Match[str]) -> str:
@@ -262,22 +263,42 @@ class AstrBotDashboard:
else:
user_dist = os.path.join(get_astrbot_data_path(), "dist")
bundled_dist = get_bundled_dashboard_dist_path()
if os.path.exists(user_dist) and not should_use_bundled_dashboard_dist(
user_dist,
VERSION,
user_index = Path(user_dist) / "index.html"
user_version = get_dashboard_dist_version(user_dist)
if (
os.path.exists(user_dist)
and user_index.is_file()
and not should_use_bundled_dashboard_dist(user_dist, VERSION)
):
self.data_path = os.path.abspath(user_dist)
elif bundled_dist.exists():
self.data_path = str(bundled_dist)
logger.info("Using bundled dashboard dist: %s", self.data_path)
elif os.path.exists(user_dist) and user_index.is_file():
logger.warning(
"Using existing data/dist as a fallback even though WebUI "
"version mismatches core: %s, expected v%s. Some dashboard "
"features may not work until the matching WebUI is available.",
user_version,
VERSION,
)
self.data_path = os.path.abspath(user_dist)
elif os.path.exists(user_dist):
logger.warning(
"Ignoring data/dist because WebUI files are incomplete for core v%s.",
VERSION,
)
self.data_path = None
else:
self.data_path = os.path.abspath(user_dist)
if self.enable_webui and not (Path(self.data_path) / "index.html").exists():
if self.enable_webui and (
self.data_path is None or not (Path(self.data_path) / "index.html").exists()
):
logger.warning(
"前端未内置或未初始化 (index.html missing in %s), "
"回退到仅启动后端. 请访问在线面板: dash.astrbot.men",
self.data_path,
self.data_path or "disabled incomplete data/dist",
)
self.enable_webui = False
self._webui_fallback = True
@@ -299,6 +320,7 @@ class AstrBotDashboard:
"/api/auth/logout",
"/api/auth/setup-status",
"/api/auth/setup",
"/api/stat/versions",
}
allowed_endpoint_prefixes = [
"/api/file",
@@ -313,34 +335,64 @@ class AstrBotDashboard:
return None
is_plugin_page_path = PluginPageAuth.is_protected_path(path)
token = self._extract_dashboard_jwt(current_request)
if not token and is_plugin_page_path:
token = PluginPageAuth.extract_asset_token(current_request.query_params)
if not token:
dashboard_token = self._extract_dashboard_jwt(current_request)
asset_token = (
PluginPageAuth.extract_asset_token(current_request.query_params)
if is_plugin_page_path
else None
)
token_candidates = []
if dashboard_token:
token_candidates.append(dashboard_token)
if asset_token and asset_token != dashboard_token:
token_candidates.append(asset_token)
if not token_candidates:
r = JSONResponse(error("未授权"))
r.status_code = 401
return r
token_errors: list[str] = []
for token in token_candidates:
payload, token_error = self._validate_dashboard_token(token, path)
if payload is not None:
current_request.state.dashboard_g.username = cast(
str, payload["username"]
)
return None
token_errors.append(token_error)
error_message = (
"Token 过期"
if token_errors and all(item == "Token 过期" for item in token_errors)
else "Token 无效"
)
r = JSONResponse(error(error_message))
r.status_code = 401
return r
def _validate_dashboard_token(
self,
token: str,
path: str,
) -> tuple[dict[str, Any] | None, str]:
try:
payload = jwt.decode(token, self._jwt_secret, algorithms=["HS256"])
if PluginPageAuth.is_asset_token(
payload
) and not PluginPageAuth.is_scope_valid(payload, path):
r = JSONResponse(error("Token 无效"))
r.status_code = 401
return r
username = payload.get("username")
if not isinstance(username, str) or not username.strip():
raise jwt.InvalidTokenError("missing username in token payload")
current_request.state.dashboard_g.username = username
except jwt.ExpiredSignatureError:
r = JSONResponse(error("Token 过期"))
r.status_code = 401
return r
return None, "Token 过期"
except jwt.InvalidTokenError:
r = JSONResponse(error("Token 无效"))
r.status_code = 401
return r
return None, "Token 无效"
if PluginPageAuth.is_asset_token(payload) and not PluginPageAuth.is_scope_valid(
payload,
path,
):
return None, "Token 无效"
username = payload.get("username")
if not isinstance(username, str) or not username.strip():
return None, "Token 无效"
return payload, ""
async def _apply_auth_rate_limit(
self,
+34 -7
View File
@@ -345,6 +345,23 @@ def serialize_thread(thread) -> dict:
}
def serialize_history_entry(history) -> dict:
"""Serialize a PlatformMessageHistory record with UTC-aware timestamps.
Args:
history: A PlatformMessageHistory instance. Must not be None.
Returns:
Dict with all model fields plus created_at/updated_at serialized as
UTC-aware ISO strings (e.g. ``2026-07-06T04:00:00+00:00``).
"""
return {
**history.model_dump(),
"created_at": to_utc_isoformat(history.created_at),
"updated_at": to_utc_isoformat(history.updated_at),
}
def find_checkpoint_index(history: list[dict], checkpoint_id: str) -> int | None:
for index, message in enumerate(history):
if get_checkpoint_id(message) == checkpoint_id:
@@ -501,7 +518,7 @@ class ChatService:
)
async def create_attachment_from_file(
self, filename: str, attach_type: str
self, filename: str, attach_type: str, display_name: str | None = None
) -> dict | None:
return await create_attachment_part_from_existing_file(
filename,
@@ -509,6 +526,7 @@ class ChatService:
insert_attachment=self.db.insert_attachment,
attachments_dir=self.attachments_dir,
fallback_dirs=[self.webchat_img_dir],
display_name=display_name,
)
async def resolve_webchat_file(
@@ -897,9 +915,14 @@ class ChatService:
):
yield attachment_saved_event
elif msg_type == "file":
filename = result_text.replace("[FILE]", "")
filename = result_text.replace("[FILE]", "", 1)
display_name = None
if "|" in filename:
filename, display_name = filename.split("|", 1)
part = await self.create_attachment_from_file(
filename, "file"
filename,
"file",
display_name=display_name,
)
message_accumulator.add_attachment(part)
if attachment_saved_event := build_attachment_saved_event(
@@ -1190,7 +1213,11 @@ class ChatService:
async def get_session(self, username: str, session_id: str) -> dict:
session = await self.db.get_platform_session_by_id(session_id)
platform_id = session.platform_id if session else "webchat"
if not session:
raise ChatServiceError(f"Session {session_id} not found")
if session.creator != username:
raise ChatServiceError("Permission denied")
platform_id = session.platform_id
project_info = await self.db.get_project_by_session(
session_id=session_id, creator=username
@@ -1207,7 +1234,7 @@ class ChatService:
)
response_data = {
"history": [history.model_dump() for history in history_ls],
"history": [serialize_history_entry(history) for history in history_ls],
"threads": [serialize_thread(thread) for thread in threads],
"is_running": self.running_convs.get(session_id, False),
}
@@ -1323,7 +1350,7 @@ class ChatService:
)
return {
"thread": serialize_thread(thread),
"history": [history.model_dump() for history in history_ls],
"history": [serialize_history_entry(history) for history in history_ls],
"is_running": self.running_convs.get(thread_id, False),
}
@@ -1477,7 +1504,7 @@ class ChatService:
await self.db.update_platform_session(session_id=session_id)
updated = await self.db.get_platform_message_history_by_id(message_id)
return {
"message": updated.model_dump() if updated else None,
"message": serialize_history_entry(updated) if updated else None,
"needs_regenerate": True,
"truncated_after_message": True,
}
@@ -1,7 +1,17 @@
from __future__ import annotations
import os
from astrbot.core.db import BaseDatabase
from astrbot.core.utils.datetime_utils import to_utc_isoformat
from astrbot.core.workspace import (
WORKSPACE_TYPE_CUSTOM,
WORKSPACE_TYPE_SESSION,
normalize_project_workspace_type,
normalize_workspace_path,
resolve_project_workspace_root,
workspace_path_to_root,
)
class ChatUIProjectServiceError(Exception):
@@ -17,6 +27,7 @@ class ChatUIProjectService:
title = payload.get("title")
emoji = payload.get("emoji", "📁")
description = payload.get("description")
workspace_type, workspace_path = self._normalize_workspace_config(payload)
if not title:
raise ChatUIProjectServiceError("Missing key: title")
@@ -26,6 +37,8 @@ class ChatUIProjectService:
title=title,
emoji=emoji,
description=description,
workspace_type=workspace_type,
workspace_path=workspace_path,
)
return self._serialize_project(project)
@@ -53,12 +66,22 @@ class ChatUIProjectService:
if not project_id:
raise ChatUIProjectServiceError("Missing key: project_id")
await self._get_owned_project(username, project_id)
project = await self._get_owned_project(username, project_id)
workspace_type = None
workspace_path = None
if "workspace_type" in payload or "workspace_path" in payload:
workspace_type, workspace_path = self._normalize_workspace_config(
payload,
fallback_type=project.workspace_type,
fallback_path=project.workspace_path,
)
await self.db.update_chatui_project(
project_id=project_id,
title=payload.get("title"),
emoji=payload.get("emoji"),
description=payload.get("description"),
workspace_type=workspace_type,
workspace_path=workspace_path,
)
async def delete_project(self, username: str, project_id: str | None) -> None:
@@ -136,11 +159,32 @@ class ChatUIProjectService:
@staticmethod
def _serialize_project(project) -> dict:
workspace_type = normalize_project_workspace_type(
getattr(project, "workspace_type", WORKSPACE_TYPE_SESSION)
)
workspace_path = normalize_workspace_path(
getattr(project, "workspace_path", None)
)
resolved_workspace_path = None
if workspace_type != WORKSPACE_TYPE_SESSION:
fallback_umo = f"webchat:FriendMessage:webchat!{project.creator}!default"
try:
resolved_workspace_path = str(
resolve_project_workspace_root(
project,
fallback_umo=fallback_umo,
)
)
except ValueError:
resolved_workspace_path = None
return {
"project_id": project.project_id,
"title": project.title,
"emoji": project.emoji,
"description": project.description,
"workspace_type": workspace_type,
"workspace_path": workspace_path,
"resolved_workspace_path": resolved_workspace_path,
"created_at": to_utc_isoformat(project.created_at),
"updated_at": to_utc_isoformat(project.updated_at),
}
@@ -160,3 +204,49 @@ class ChatUIProjectService:
@staticmethod
def _as_payload(data: object) -> dict:
return data if isinstance(data, dict) else {}
@staticmethod
def _normalize_workspace_config(
payload: dict,
*,
fallback_type: str | None = None,
fallback_path: str | None = None,
) -> tuple[str, str | None]:
"""Normalize project workspace config from request payload.
Args:
payload: Request payload.
fallback_type: Existing workspace type used when omitted.
fallback_path: Existing workspace path used when omitted.
Returns:
Normalized workspace type and path.
Raises:
ChatUIProjectServiceError: If a custom workspace has no usable path.
"""
workspace_type = normalize_project_workspace_type(
payload.get("workspace_type", fallback_type or WORKSPACE_TYPE_SESSION)
)
raw_path = payload.get("workspace_path", fallback_path)
workspace_path = normalize_workspace_path(raw_path)
if workspace_type != WORKSPACE_TYPE_CUSTOM:
workspace_path = None
return workspace_type, workspace_path
if not workspace_path:
raise ChatUIProjectServiceError("Custom workspace requires a path")
try:
workspace_root = workspace_path_to_root(workspace_path)
except ValueError as exc:
raise ChatUIProjectServiceError(str(exc)) from exc
if not workspace_root.exists():
raise ChatUIProjectServiceError("Custom workspace path does not exist")
if not workspace_root.is_dir():
raise ChatUIProjectServiceError("Custom workspace path must be a directory")
if not os.access(workspace_root, os.R_OK | os.W_OK | os.X_OK):
raise ChatUIProjectServiceError(
"Custom workspace path requires read, write, and enter permissions"
)
return workspace_type, workspace_path
+29 -6
View File
@@ -1297,10 +1297,19 @@ class ProviderConfigService:
for provider in provider_registry:
if provider.default_config_tmpl:
provider_default_tmpl[provider.type] = provider.default_config_tmpl
providers = copy.deepcopy(self.config.get("provider", []))
from astrbot.core.utils.llm_metadata import LLM_METADATAS
model_metadata = {}
for provider in providers:
model_id = provider.get("model")
if isinstance(model_id, str) and model_id in LLM_METADATAS:
model_metadata[model_id] = LLM_METADATAS[model_id]
return {
"config_schema": config_schema,
"providers": self.config.get("provider", []),
"providers": providers,
"provider_sources": self.config.get("provider_sources", []),
"model_metadata": model_metadata,
}
def list_provider_sources(self) -> dict:
@@ -1543,8 +1552,11 @@ class ProviderConfigService:
source_id: str | None = None,
enabled: bool | None = None,
) -> dict:
from astrbot.core.utils.llm_metadata import LLM_METADATAS
provider_type = self._resolve_provider_type(capability)
providers = []
model_metadata = {}
source_provider_type = {
source["id"]: source.get("provider_type", "chat_completion")
for source in self.provider_manager.provider_sources_config
@@ -1562,12 +1574,16 @@ class ProviderConfigService:
if provider_type and effective_type != provider_type:
continue
if provider.get("provider_source_id"):
providers.append(
self.provider_manager.get_merged_provider_config(provider)
provider_response = self.provider_manager.get_merged_provider_config(
provider
)
else:
providers.append(copy.deepcopy(provider))
return {"providers": providers}
provider_response = copy.deepcopy(provider)
model_id = provider_response.get("model")
if isinstance(model_id, str) and model_id in LLM_METADATAS:
model_metadata[model_id] = LLM_METADATAS[model_id]
providers.append(provider_response)
return {"providers": providers, "model_metadata": model_metadata}
def list_providers_for_dashboard_types(
self, provider_type: str | None
@@ -1597,7 +1613,14 @@ class ProviderConfigService:
)
if provider is None:
raise ValueError(f"Provider {provider_id} not found")
return {"provider": provider}
provider_response = copy.deepcopy(provider)
from astrbot.core.utils.llm_metadata import LLM_METADATAS
model_id = provider_response.get("model")
model_metadata = {}
if isinstance(model_id, str) and model_id in LLM_METADATAS:
model_metadata[model_id] = LLM_METADATAS[model_id]
return {"provider": provider_response, "model_metadata": model_metadata}
async def create_provider(self, config: dict, source_id: str | None = None) -> None:
config = copy.deepcopy(config)
@@ -12,6 +12,7 @@ from astrbot.core import logger
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
from astrbot.core.provider.provider import EmbeddingProvider, RerankProvider
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
from astrbot.dashboard.schemas import KnowledgeBaseRequest
from astrbot.dashboard.utils import generate_tsne_visualization
@@ -29,6 +30,19 @@ class KnowledgeBaseService:
def _payload(data: object) -> dict[str, Any]:
return data if isinstance(data, dict) else {}
@staticmethod
def _canonical_kb_payload(data: object) -> dict[str, Any]:
"""Normalize knowledge base create/update payloads.
Uses KnowledgeBaseRequest to handle the legacy ``name``
``kb_name`` migration while preserving operational fields
like ``kb_id``.
"""
raw = KnowledgeBaseService._payload(data)
canonical = KnowledgeBaseRequest(**raw).canonical_payload()
raw.update(canonical)
return raw
def get_kb_manager(self):
return self.core_lifecycle.kb_manager
@@ -266,16 +280,24 @@ class KnowledgeBaseService:
async def list_kbs(self, *, page: int, page_size: int) -> dict[str, Any]:
kb_manager = self.get_kb_manager()
kbs = await kb_manager.list_kbs()
total = len(kbs)
# Clamp page and page_size to at least 1 before calculating offsets/slices.
page = max(page, 1)
page_size = max(page_size, 1)
start = (page - 1) * page_size
end = start + page_size
paged_kbs = kbs[start:end]
kb_list = []
for kb in kbs:
for kb in paged_kbs:
kb_dict = kb.model_dump()
kb_helper = await kb_manager.get_kb(kb.kb_id)
if kb_helper and kb_helper.init_error:
kb_dict["init_error"] = kb_helper.init_error
kb_list.append(kb_dict)
return {"items": kb_list, "page": page, "page_size": page_size}
return {"items": kb_list, "page": page, "page_size": page_size, "total": total}
async def list_kbs_from_dashboard_query(self, *, page, page_size) -> dict[str, Any]:
return await self.list_kbs(
@@ -285,7 +307,7 @@ class KnowledgeBaseService:
async def create_kb(self, data: object) -> tuple[dict[str, Any], str]:
kb_manager = self.get_kb_manager()
payload = self._payload(data)
payload = self._canonical_kb_payload(data)
kb_name = payload.get("kb_name")
if not kb_name:
raise KnowledgeBaseServiceError("知识库名称不能为空")
@@ -355,7 +377,7 @@ class KnowledgeBaseService:
return await self.get_kb(kb_id)
async def update_kb(self, data: object) -> tuple[dict[str, Any], str]:
payload = self._payload(data)
payload = self._canonical_kb_payload(data)
kb_id = payload.get("kb_id")
if not kb_id:
raise KnowledgeBaseServiceError("缺少参数 kb_id")
@@ -372,28 +394,20 @@ class KnowledgeBaseService:
"top_k_sparse",
"top_m_final",
]
if all(payload.get(key) is None for key in update_keys):
provided_updates = {key: payload[key] for key in update_keys if key in payload}
if not provided_updates:
raise KnowledgeBaseServiceError("至少需要提供一个更新字段")
current_kb = await self.get_kb_manager().get_kb(kb_id)
kb_name = payload.get("kb_name")
if kb_name is None:
if not current_kb:
raise KnowledgeBaseServiceError("知识库不存在")
kb_name = current_kb.kb.kb_name
if not current_kb:
raise KnowledgeBaseServiceError("知识库不存在")
current = current_kb.kb
update_data = {key: getattr(current, key, None) for key in update_keys}
update_data.update(provided_updates)
kb_helper = await self.get_kb_manager().update_kb(
kb_id=kb_id,
kb_name=kb_name,
description=payload.get("description"),
emoji=payload.get("emoji"),
embedding_provider_id=payload.get("embedding_provider_id"),
rerank_provider_id=payload.get("rerank_provider_id"),
chunk_size=payload.get("chunk_size"),
chunk_overlap=payload.get("chunk_overlap"),
top_k_dense=payload.get("top_k_dense"),
top_k_sparse=payload.get("top_k_sparse"),
top_m_final=payload.get("top_m_final"),
**update_data,
)
if not kb_helper:
raise KnowledgeBaseServiceError("知识库不存在")
@@ -437,6 +451,7 @@ class KnowledgeBaseService:
kb_id: str | None,
page: int,
page_size: int,
search: str | None = None,
) -> dict[str, Any]:
if not kb_id:
raise KnowledgeBaseServiceError("缺少参数 kb_id")
@@ -444,12 +459,25 @@ class KnowledgeBaseService:
if not kb_helper:
raise KnowledgeBaseServiceError("知识库不存在")
if search is not None:
search = search.strip()
if not search:
search = None
page = max(page, 1)
page_size = max(page_size, 1)
offset = (page - 1) * page_size
doc_list = await kb_helper.list_documents(offset=offset, limit=page_size)
doc_list = await kb_helper.list_documents(
offset=offset,
limit=page_size,
search=search,
)
total = await kb_helper.count_documents(search=search)
return {
"items": [doc.model_dump() for doc in doc_list],
"page": page,
"page_size": page_size,
"total": total,
}
async def list_documents_from_dashboard_query(
@@ -458,11 +486,13 @@ class KnowledgeBaseService:
kb_id: str | None,
page,
page_size,
search: str | None = None,
) -> dict[str, Any]:
return await self.list_documents(
kb_id=kb_id,
page=self._to_int(page, 1),
page_size=self._to_int(page_size, 100),
search=search,
)
async def upload_document(
@@ -738,11 +768,11 @@ class KnowledgeBaseService:
if not query:
raise KnowledgeBaseServiceError("缺少参数 query")
kb_manager = self.get_kb_manager()
if not kb_names or not isinstance(kb_names, list):
raise KnowledgeBaseServiceError("缺少参数 kb_names 或格式错误")
top_k = payload.get("top_k", 5)
kb_manager = self.get_kb_manager()
results = await kb_manager.retrieve(
query=query,
kb_names=kb_names,
@@ -205,7 +205,7 @@ class LiveChatService:
logger.info(f"[Live Chat] WebSocket 连接关闭: {username}")
async def create_attachment_from_file(
self, filename: str, attach_type: str
self, filename: str, attach_type: str, display_name: str | None = None
) -> dict | None:
return await create_attachment_part_from_existing_file(
filename,
@@ -213,6 +213,7 @@ class LiveChatService:
insert_attachment=self.db.insert_attachment,
attachments_dir=self.attachments_dir,
fallback_dirs=[self.webchat_img_dir],
display_name=display_name,
)
@staticmethod
@@ -650,13 +651,27 @@ class LiveChatService:
message_accumulator.add_attachment(part)
await send_attachment_saved_event(part)
elif result_type == "file":
filename = str(result_text).replace("[FILE]", "").split("|", 1)[0]
part = await self.create_attachment_from_file(filename, "file")
filename = str(result_text).replace("[FILE]", "", 1)
display_name = None
if "|" in filename:
filename, display_name = filename.split("|", 1)
part = await self.create_attachment_from_file(
filename,
"file",
display_name=display_name,
)
message_accumulator.add_attachment(part)
await send_attachment_saved_event(part)
elif result_type == "video":
filename = str(result_text).replace("[VIDEO]", "").split("|", 1)[0]
part = await self.create_attachment_from_file(filename, "video")
filename = str(result_text).replace("[VIDEO]", "", 1)
display_name = None
if "|" in filename:
filename, display_name = filename.split("|", 1)
part = await self.create_attachment_from_file(
filename,
"video",
display_name=display_name,
)
message_accumulator.add_attachment(part)
await send_attachment_saved_event(part)
File diff suppressed because it is too large Load Diff
@@ -21,6 +21,10 @@ from astrbot.core.config.astrbot_config import AstrBotConfig
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
from astrbot.core.db import BaseDatabase
from astrbot.core.db.po import ProviderStat
from astrbot.core.desktop_runtime import (
DESKTOP_MANAGED_RESTART_MESSAGE,
is_desktop_managed_backend,
)
from astrbot.core.utils.astrbot_path import get_astrbot_path
from astrbot.core.utils.auth_password import (
is_default_dashboard_password,
@@ -57,6 +61,9 @@ class StatService:
raise StatServiceError(
"You are not permitted to do this operation in demo mode"
)
if is_desktop_managed_backend():
raise StatServiceError(DESKTOP_MANAGED_RESTART_MESSAGE)
await self.core_lifecycle.restart()
@staticmethod
+161 -145
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import asyncio
import inspect
import tempfile
import traceback
import uuid
import zipfile
@@ -15,10 +16,14 @@ from astrbot.core import logger
from astrbot.core import pip_installer as _pip_installer
from astrbot.core.config.default import VERSION
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
from astrbot.core.desktop_runtime import (
DESKTOP_MANAGED_RESTART_MESSAGE,
is_desktop_managed_backend,
)
from astrbot.core.updator import AstrBotUpdator
from astrbot.core.utils.astrbot_path import (
get_astrbot_data_path,
get_astrbot_system_tmp_path,
get_astrbot_temp_path,
)
from astrbot.core.utils.io import (
download_dashboard as _download_dashboard,
@@ -67,7 +72,9 @@ class UpdateServiceResult:
class UpdateServiceError(Exception):
pass
def __init__(self, message: str, *, code: str | None = None) -> None:
super().__init__(message)
self.code = code
class UpdateService:
@@ -143,6 +150,12 @@ class UpdateService:
raise UpdateServiceError(exc.__str__()) from exc
async def update_project(self, data: object) -> UpdateServiceResult:
if is_desktop_managed_backend():
raise UpdateServiceError(
DESKTOP_MANAGED_RESTART_MESSAGE,
code="desktop_managed",
)
payload = data if isinstance(data, dict) else {}
version = payload.get("version", "")
reboot = payload.get("reboot", True)
@@ -194,158 +207,168 @@ class UpdateService:
reboot: Whether to restart AstrBot after applying files.
proxy: Optional GitHub proxy URL.
"""
update_temp_dir = Path(get_astrbot_system_tmp_path()) / "updates"
update_temp_dir.mkdir(parents=True, exist_ok=True)
update_token = uuid.uuid4().hex
dashboard_zip_path = update_temp_dir / f"{update_token}-dashboard.zip"
core_zip_path = update_temp_dir / f"{update_token}-core.zip"
update_temp_parent = Path(get_astrbot_temp_path()) / "updates"
try:
self._set_update_stage(
progress_id,
"dashboard",
"running",
"正在下载 WebUI...",
0,
)
await self.download_dashboard(
path=str(dashboard_zip_path),
latest=latest,
version=version,
proxy=proxy or "",
progress_callback=self._make_progress_callback(
if update_temp_parent.is_symlink():
update_temp_parent.unlink()
update_temp_parent.mkdir(mode=0o700, parents=True, exist_ok=True)
update_temp_parent.chmod(0o700)
with tempfile.TemporaryDirectory(
prefix="project-update-",
dir=update_temp_parent,
) as update_temp_dir_name:
update_temp_dir = Path(update_temp_dir_name)
update_token = uuid.uuid4().hex
dashboard_zip_path = update_temp_dir / f"{update_token}-dashboard.zip"
core_zip_path = update_temp_dir / f"{update_token}-core.zip"
self._set_update_stage(
progress_id,
"dashboard",
"running",
"正在下载 WebUI...",
0,
45,
),
extract=False,
)
self._set_update_stage(
progress_id,
"dashboard",
"done",
"WebUI 下载完成。",
45,
)
self._set_update_stage(
progress_id,
"core",
"running",
"正在下载 AstrBot 项目代码...",
45,
)
core_zip_path = Path(
await self.astrbot_updator.download_update_package(
)
await self.download_dashboard(
path=str(dashboard_zip_path),
latest=latest,
version=version,
proxy=proxy or "",
path=core_zip_path,
progress_callback=self._make_progress_callback(
progress_id,
"core",
45,
"dashboard",
0,
45,
),
extract=False,
)
)
self._set_update_stage(
progress_id,
"core",
"done",
"项目代码下载完成。",
90,
)
self._set_update_stage(
progress_id,
"verify",
"running",
"下载完成,正在校验更新包...",
90,
)
def _verify_update_packages() -> None:
for zip_path in (dashboard_zip_path, core_zip_path):
with zipfile.ZipFile(zip_path, "r") as archive:
corrupt_member = archive.testzip()
if corrupt_member:
raise UpdateServiceError(f"更新包校验失败: {corrupt_member}")
await asyncio.to_thread(_verify_update_packages)
self._set_update_stage(
progress_id,
"verify",
"done",
"更新包校验完成。",
91,
)
self._set_update_stage(
progress_id,
"apply",
"running",
"下载完成,正在应用更新...",
91,
)
await asyncio.to_thread(
self.astrbot_updator.apply_update_package,
core_zip_path,
)
await self.extract_dashboard(
dashboard_zip_path,
Path(get_astrbot_data_path()),
)
self._set_update_stage(
progress_id,
"apply",
"done",
"更新文件应用完成。",
92,
)
self._set_update_stage(
progress_id,
"dependencies",
"running",
"正在更新依赖...",
92,
)
logger.info("更新依赖中...")
try:
await self.pip_install(requirements_path="requirements.txt")
except Exception as exc:
logger.error(f"更新依赖失败: {exc}")
self._set_update_stage(
progress_id,
"dependencies",
"done",
"依赖更新完成。",
96,
)
if reboot:
self._set_update_stage(
progress_id,
"restart",
"running",
"更新成功,正在准备重启...",
98,
"dashboard",
"done",
"WebUI 下载完成。",
45,
)
await self.core_lifecycle.restart()
message = "更新成功,AstrBot 将在 2 秒内全量重启以应用新的代码。"
else:
message = "更新成功,AstrBot 将在下次启动时应用新的代码。"
self.update_progress[progress_id].update(
{
"status": "success",
"stage": "done",
"message": message,
"overall_percent": 100,
},
)
logger.info(message)
self._set_update_stage(
progress_id,
"core",
"running",
"正在下载 AstrBot 项目代码...",
45,
)
core_zip_path = Path(
await self.astrbot_updator.download_update_package(
latest=latest,
version=version,
proxy=proxy or "",
path=core_zip_path,
progress_callback=self._make_progress_callback(
progress_id,
"core",
45,
45,
),
)
)
self._set_update_stage(
progress_id,
"core",
"done",
"项目代码下载完成。",
90,
)
self._set_update_stage(
progress_id,
"verify",
"running",
"下载完成,正在校验更新包...",
90,
)
def _verify_update_packages() -> None:
for zip_path in (dashboard_zip_path, core_zip_path):
with zipfile.ZipFile(zip_path, "r") as archive:
corrupt_member = archive.testzip()
if corrupt_member:
raise UpdateServiceError(
f"更新包校验失败: {corrupt_member}"
)
await asyncio.to_thread(_verify_update_packages)
self._set_update_stage(
progress_id,
"verify",
"done",
"更新包校验完成。",
91,
)
self._set_update_stage(
progress_id,
"apply",
"running",
"下载完成,正在应用更新...",
91,
)
await asyncio.to_thread(
self.astrbot_updator.apply_update_package,
core_zip_path,
)
await self.extract_dashboard(
dashboard_zip_path,
Path(get_astrbot_data_path()),
)
self._set_update_stage(
progress_id,
"apply",
"done",
"更新文件应用完成。",
92,
)
self._set_update_stage(
progress_id,
"dependencies",
"running",
"正在更新依赖...",
92,
)
logger.info("更新依赖中...")
try:
await self.pip_install(requirements_path="requirements.txt")
except Exception as exc:
logger.error(f"更新依赖失败: {exc}")
self._set_update_stage(
progress_id,
"dependencies",
"done",
"依赖更新完成。",
96,
)
if reboot:
self._set_update_stage(
progress_id,
"restart",
"running",
"更新成功,正在准备重启...",
98,
)
await self.core_lifecycle.restart()
message = "更新成功,AstrBot 将在 2 秒内全量重启以应用新的代码。"
else:
message = "更新成功,AstrBot 将在下次启动时应用新的代码。"
self.update_progress[progress_id].update(
{
"status": "success",
"stage": "done",
"message": message,
"overall_percent": 100,
},
)
logger.info(message)
except asyncio.CancelledError:
self.update_progress[progress_id].update(
{
@@ -364,13 +387,6 @@ class UpdateService:
)
logger.error(f"/api/update_project: {traceback.format_exc()}")
logger.debug(f"Update task failed: {exc!s}")
finally:
for zip_path in (dashboard_zip_path, core_zip_path):
try:
if zip_path.exists():
zip_path.unlink()
except Exception as cleanup_exc:
logger.warning(f"清理更新临时文件失败: {zip_path}, {cleanup_exc}")
async def update_dashboard(self) -> UpdateServiceResult:
try:
+61
View File
@@ -0,0 +1,61 @@
## What's Changed
### Fixes
- Preserve image formats and JPEG quality during media conversion (#9019, #9031)
- Fix `DashboardRequest` compatibility for dashboard requests, resolving some IM webhook errors (#9021, #9023)
- Normalize streamed message whitespace and strip trailing buffers before sending (#9029)
- Prevent plugin detail marketplace mismatches (#9028)
- Reliably kill shell process trees on Windows timeout (#8822)
- Guard `_KeyRotator` index bounds (#9040)
- Track plugin install source for update checks (#9037)
- Handle MiMo STT audio and reasoning output (#8938)
- Only show plugin updates for newer market versions
- Sanitize orphaned `tool_result` blocks in the Anthropic provider (#8952)
- Preserve assistant messages that contain `reasoning_content` without content or tool calls (#8483)
- Recognize DeepSeek V4 proxy model names with substring matching (#9015)
- Clear KV storage when uninstalling plugins and update related i18n text (#8291)
- Separate plugin and tool activation state (#9048)
- Keep Tab navigation within reset-password inputs in the account dialog (#9049)
- Align OpenAI tool message sanitization (#8350)
- Avoid duplicate `send_message_to_user` replies (#9051)
### Documentation
- Update the Python requirement to 3.12 (#9022)
- Add the Spanish README (#9020)
### Chores
- Remove the plugin publish issue template (#9050)
## 中文翻译
### 修复
- 在媒体转换过程中保留图片格式和 JPEG 质量 (#9019, #9031)
- 修复 `DashboardRequest` 对仪表盘请求的兼容性,解决部分 IM webhook 报错问题 (#9021, #9023)
- 统一流式消息片段中的空白字符处理,并在发送前移除尾部缓存 (#9029)
- 防止插件详情与插件市场信息不匹配 (#9028)
- 在 Windows 超时场景下可靠终止 shell 进程树 (#8822)
- 修复 `_KeyRotator` 索引边界检查 (#9040)
- 跟踪插件安装来源,用于更新检查 (#9037)
- 处理 MiMo STT 音频和推理输出 (#8938)
- 仅在插件市场版本更新时显示插件更新提示
- 清理 Anthropic 提供商中的孤立 `tool_result` 块 (#8952)
- 保留只包含 `reasoning_content`、但没有内容或工具调用的 assistant 消息 (#8483)
- 通过子字符串匹配识别 DeepSeek V4 代理模型名称 (#9015)
- 卸载插件时清理 KV 存储,并更新相关 i18n 文案 (#8291)
- 分离插件和工具的启用状态 (#9048)
- 在账号对话框中,将 Tab 导航限制在重置密码输入框内 (#9049)
- 对齐 OpenAI 工具消息清理逻辑 (#8350)
- 避免重复发送 `send_message_to_user` 回复 (#9051)
### 文档
- 将 Python 版本要求更新为 3.12 (#9022)
- 添加西班牙语 README (#9020)
### 杂项
- 移除插件发布 issue 模板 (#9050)
+31
View File
@@ -0,0 +1,31 @@
## What's Changed
### Features
- Support installing local plugins (#8448)
### Fixes
- Preserve fallback models for future tasks (#9054)
- Validate plugin install sources (#9061)
- Paginate knowledge base dashboard lists (#9055)
### Styles
- Standardize dashboard dialog styling (#9062)
## 中文翻译
### 功能
- 支持安装本地插件 (#8448)
### 修复
- 保留未来任务可用的 fallback models (#9054)
- 校验插件安装来源 (#9061)
- 为知识库仪表盘列表增加分页 (#9055)
### 样式
- 统一仪表盘对话框样式 (#9062)
+13
View File
@@ -0,0 +1,13 @@
## What's Changed
- fix: astrbot_file_read_tool returns clear error for directory path instead of misleading Permission denied (#9088) (41f896030)
- fix: reduce markdown streaming lag (#9097) (372b9f5bf)
- fix: resample and downmix WAV files for Tencent Silk encoding (#9100) (4cf210e50)
- fix: guard desktop-managed core restart (#9098) (b673cb375)
- fix: updated reboot logic (#9073) (3b41a870f)
- 修复了DISCORD适配器注册命令正则过于严格的问题 (#9102) (029e9c84a)
- fix: reject non-200 download responses (#9085) (70a52ea6d)
- fix: wecom adapter returning json instead of plain text (#9107) (ea19be1d0)
- fix: preserve webhook callback responses (1e3b12acc)
- fix: skip _unbind_plugin for inactivated plugins in reload() (#9096) (152fb3be8)
- fix: apply fallback chat models to background wakeups (#9094) (413340fca)
+3 -2
View File
@@ -20,6 +20,7 @@
"dependencies": {
"@guolao/vue-monaco-editor": "^1.6.0",
"@hey-api/client-axios": "^0.9.1",
"@lucide/vue": "^1.23.0",
"@tiptap/starter-kit": "3.20.5",
"@tiptap/vue-3": "3.20.5",
"apexcharts": "5.10.4",
@@ -35,14 +36,14 @@
"katex": "^0.16.44",
"lodash": "4.18.1",
"markdown-it": "^14.1.1",
"markstream-vue": "^0.0.9",
"markstream-vue": "1.0.5-beta.0",
"mermaid": "^11.14.0",
"monaco-editor": "^0.55.1",
"pinia": "^3.0.4",
"pinyin-pro": "^3.28.0",
"qrcode": "^1.5.4",
"shiki": "^3.23.0",
"stream-markdown": "^0.0.14",
"stream-markdown": "^0.0.16",
"vee-validate": "4.15.1",
"vite-plugin-vuetify": "2.1.3",
"vue": "3.5.31",
+1811 -3314
View File
File diff suppressed because it is too large Load Diff
+14 -1
View File
@@ -1 +1,14 @@
<svg t="1702013028016" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1541" width="200" height="200"><path d="M0 0m204.8 0l614.4 0q204.8 0 204.8 204.8l0 614.4q0 204.8-204.8 204.8l-614.4 0q-204.8 0-204.8-204.8l0-614.4q0-204.8 204.8-204.8Z" fill="#FFEC9C" p-id="1542"></path><path d="M819.2 0H534.272A756.48 756.48 0 0 0 0 483.584V819.2a204.8 204.8 0 0 0 204.8 204.8h614.4a204.8 204.8 0 0 0 204.8-204.8V204.8a204.8 204.8 0 0 0-204.8-204.8z" fill="#FFE98A" p-id="1543"></path><path d="M819.2 0h-3.84a755.2 755.2 0 0 0-539.392 1024H819.2a204.8 204.8 0 0 0 204.8-204.8V204.8a204.8 204.8 0 0 0-204.8-204.8z" fill="#FFE471" p-id="1544"></path><path d="M497.152 721.152A752.384 752.384 0 0 0 560.384 1024H819.2a204.8 204.8 0 0 0 204.8-204.8V204.8a204.8 204.8 0 0 0-89.088-168.96 755.2 755.2 0 0 0-437.76 685.312z" fill="#FFE161" p-id="1545"></path><path d="M526.08 140.032l98.304 199.168L844.8 371.2a15.616 15.616 0 0 1 8.704 25.6l-159.744 156.16 37.632 219.136a15.616 15.616 0 0 1-22.528 16.384l-196.608-102.4-196.608 102.4a15.616 15.616 0 0 1-22.528-16.384l37.12-219.136-159.232-155.136a15.616 15.616 0 0 1 8.704-25.6l219.904-32 98.304-199.168a15.616 15.616 0 0 1 28.16-1.024z" fill="#FFF5CC" p-id="1546"></path><path d="M665.6 409.6a444.16 444.16 0 0 0 25.6-61.44l-65.536-9.472-99.584-198.656a15.616 15.616 0 0 0-27.904 0l-98.304 199.168L179.2 371.2a15.616 15.616 0 0 0-8.704 25.6l159.744 156.16-15.104 87.04A407.808 407.808 0 0 0 665.6 409.6z" fill="#FFFFFF" p-id="1547"></path></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512" preserveAspectRatio="xMidYMid meet">
<g transform="translate(0.8 0.9)">
<g transform="translate(0 32)">
<path
d="m246.3 328.1-17.8 41.2c-6.4 14.8-26.9 14.8-33.3 0l-17.8-41.2c-14.9-34.2-41.8-61.4-75.3-76.3l-48.8-21.6c-14.7-6.5-14.7-27.9 0-34.4l47.2-21c34.4-15.3 61.7-43.5 76.4-78.8l18-43.7c6.3-15.2 27.3-15.2 33.6 0l18 43.7c14.7 35.3 42 63.6 76.4 78.8l47.2 21c14.7 6.5 14.7 27.9 0 34.4l-48.8 21.6c-33.5 14.8-60.4 42.1-75.3 76.2z"
fill="#2f86bd"
transform="translate(0 35)" />
<path
d="m402.2 449.3-5.3 12.2c-3.5 7.9-14.4 7.9-17.9 0l-5.3-12.2c-8.4-19.3-23.6-34.6-42.4-43l-15.4-6.9c-7.9-3.5-7.9-14.9 0-18.4l14.5-6.5c19.4-8.6 34.8-24.5 43.1-44.5l5.4-13.1c3.4-8.1 14.6-8.1 18 0l5.4 13.1c8.3 19.9 23.7 35.8 43.1 44.5l14.5 6.5c7.9 3.5 7.9 14.9 0 18.4l-15.4 6.9c-19 8.3-34.1 23.7-42.5 43z"
fill="#2f86bd"
transform="matrix(0.95 0 0 0.95 22 -278)" />
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1018 B

File diff suppressed because one or more lines are too long
@@ -83,8 +83,12 @@ export type ChatProjectRequest = {
title?: string;
emoji?: string;
description?: string;
workspace_type?: 'session' | 'project' | 'custom';
workspace_path?: string;
};
export type workspace_type = 'session' | 'project' | 'custom';
export type ChatRequest = {
/**
* Caller-declared WebChat sender/session owner. This value is used as the message sender identity and may participate in sender-ID-based command permission checks. Treat chat-scoped API keys as trusted backend credentials and map or validate usernames before accepting end-user input.
@@ -255,13 +259,22 @@ export type JsonSchema = {
[key: string]: unknown;
};
export type KnowledgeBaseCreateRequest = KnowledgeBaseRequest & {
kb_name: string;
embedding_provider_id: string;
};
export type KnowledgeBaseRequest = {
name: string;
kb_name?: string;
description?: string;
embedding_provider_id?: string;
rerank_provider_id?: string;
chunking?: DynamicConfig;
metadata?: DynamicConfig;
emoji?: string;
embedding_provider_id?: (string) | null;
rerank_provider_id?: (string) | null;
chunk_size?: number;
chunk_overlap?: number;
top_k_dense?: number;
top_k_sparse?: number;
top_m_final?: number;
};
export type KnowledgeDocumentImportRequest = {
@@ -271,7 +284,6 @@ export type KnowledgeDocumentImportRequest = {
export type KnowledgeDocumentUploadRequest = {
file: (Blob | File);
parser?: string;
};
export type KnowledgeDocumentUrlImportRequest = {
@@ -317,6 +329,7 @@ export type MessagePart = {
attachment_id?: string;
url?: string;
filename?: string;
stored_filename?: string;
mime_type?: string;
[key: string]: unknown | string;
};
@@ -462,6 +475,15 @@ export type PluginGithubInstallRequest = {
download_url?: string;
proxy?: string;
ignore_version_check?: boolean;
install_method?: string;
registry_url?: (string) | null;
market_plugin_id?: string;
};
export type PluginSourceBindRequest = {
install_method?: string;
registry_url?: (string) | null;
market_plugin_id?: string;
};
export type PluginSourceRequest = {
@@ -486,6 +508,15 @@ export type PluginUrlInstallRequest = {
download_url?: string;
proxy?: string;
ignore_version_check?: boolean;
install_method?: string;
registry_url?: (string) | null;
market_plugin_id?: string;
};
export type PluginValidateRepoRequest = {
repository?: string;
url?: string;
proxy?: string;
};
export type PluginVersionSupportRequest = {
@@ -1879,6 +1910,17 @@ export type ReloadPluginResponse = (SuccessEnvelope);
export type ReloadPluginError = unknown;
export type BindPluginSourceData = {
body: PluginSourceBindRequest;
path: {
plugin_id: string;
};
};
export type BindPluginSourceResponse = (SuccessEnvelope);
export type BindPluginSourceError = unknown;
export type SetPluginEnabledData = {
body: EnabledPatch;
path: {
@@ -1917,6 +1959,14 @@ export type CheckPluginVersionSupportResponse = (SuccessEnvelope);
export type CheckPluginVersionSupportError = unknown;
export type ValidatePluginRepoData = {
body: PluginValidateRepoRequest;
};
export type ValidatePluginRepoResponse = (SuccessEnvelope);
export type ValidatePluginRepoError = unknown;
export type ListFailedPluginsResponse = (SuccessEnvelope);
export type ListFailedPluginsError = unknown;
@@ -2569,7 +2619,7 @@ export type ListKnowledgeBasesResponse = (SuccessEnvelope);
export type ListKnowledgeBasesError = unknown;
export type CreateKnowledgeBaseData = {
body: KnowledgeBaseRequest;
body: KnowledgeBaseCreateRequest;
};
export type CreateKnowledgeBaseResponse = (SuccessEnvelope);
@@ -2624,6 +2674,10 @@ export type ListKnowledgeDocumentsData = {
query?: {
page?: number;
page_size?: number;
/**
* Filter documents by name (case-insensitive partial match).
*/
search?: string;
};
};
+53 -9
View File
@@ -32,12 +32,15 @@ import {
type DynamicConfig,
type EnabledPatch,
type GhproxyTestRequest,
type KnowledgeBaseCreateRequest,
type KnowledgeBaseRequest,
type LoginRequest,
type ListConversationsData,
type McpServerConfig,
type ModelScopeSyncRequest,
type PipInstallRequest,
type PluginVersionSupportRequest,
type PluginValidateRepoRequest,
type PluginConfigFileDeleteRequest,
type ProviderConfigRequest,
type BatchSessionProviderRequest,
@@ -75,6 +78,21 @@ export interface ProviderSchemaData {
config_schema?: OpenConfig;
providers?: OpenConfig[];
provider_sources?: OpenConfig[];
model_metadata?: Record<string, unknown>;
}
export interface ProviderListData {
providers?: OpenConfig[];
model_metadata?: Record<string, unknown>;
}
export interface ProviderByTypeEnvelope extends ApiEnvelope<OpenConfig[]> {
model_metadata?: Record<string, unknown>;
}
export interface ProviderByIdData {
provider?: OpenConfig;
model_metadata?: Record<string, unknown>;
}
export interface ProviderSourceModelsData {
@@ -493,11 +511,13 @@ export const providerApi = {
);
},
list(params?: ProviderListParams) {
return typed<{ providers: OpenConfig[] }>(
return typed<ProviderListData>(
openApiV1.listProviders({ query: generatedQuery(params) }),
);
},
async listByProviderType(providerType: string): Promise<AxiosResponse<ApiEnvelope<OpenConfig[]>>> {
async listByProviderType(
providerType: string,
): Promise<AxiosResponse<ProviderByTypeEnvelope>> {
const capabilities = providerTypeToCapabilities(providerType);
if (capabilities.length === 0) {
const response = await providerApi.list();
@@ -506,6 +526,7 @@ export const providerApi = {
data: {
...response.data,
data: response.data.data.providers || [],
model_metadata: response.data.data.model_metadata || {},
},
};
}
@@ -514,11 +535,21 @@ export const providerApi = {
capabilities.map((capability) => providerApi.list({ capability })),
);
const first = responses[0];
const modelMetadata = responses.reduce<Record<string, unknown>>(
(acc, response) => ({
...acc,
...(response.data.data.model_metadata || {}),
}),
{},
);
return {
...first,
data: {
...first.data,
data: responses.flatMap((response) => response.data.data.providers || []),
data: responses.flatMap(
(response) => response.data.data.providers || [],
),
model_metadata: modelMetadata,
},
};
},
@@ -542,7 +573,7 @@ export const providerApi = {
);
},
get(providerId: string, merged = false) {
return typed<{ provider: OpenConfig }>(
return typed<ProviderByIdData>(
openApiV1.getProviderById({
query: { provider_id: providerId, merged },
}),
@@ -1294,6 +1325,19 @@ export const pluginApi = {
openApiV1.installPluginFromUrl({ body: body as any }),
);
},
validateRepo(body: PluginValidateRepoRequest) {
return typed<OpenConfig>(
openApiV1.validatePluginRepo({ body }),
);
},
bindSource(pluginId: string, body: OpenConfig) {
return typed<OpenConfig>(
openApiV1.bindPluginSource({
path: { plugin_id: pluginId },
body: body as any,
}),
);
},
page(pluginId: string, pageName: string) {
return typed<any>(
openApiV1.getPluginPageById({
@@ -1352,16 +1396,16 @@ export const knowledgeApi = {
openApiV1.getKnowledgeBase({ path: { kb_id: kbId } }),
);
},
create(config: OpenConfig) {
create(config: KnowledgeBaseCreateRequest) {
return typed<OpenConfig>(
openApiV1.createKnowledgeBase({ body: config as any }),
openApiV1.createKnowledgeBase({ body: config }),
);
},
update(kbId: string, config: OpenConfig) {
update(kbId: string, config: KnowledgeBaseRequest) {
return typed<OpenConfig>(
openApiV1.updateKnowledgeBase({
path: { kb_id: kbId },
body: config as any,
body: config,
}),
);
},
@@ -1370,7 +1414,7 @@ export const knowledgeApi = {
openApiV1.deleteKnowledgeBase({ path: { kb_id: kbId } }),
);
},
documents(kbId: string, params?: { page?: number; page_size?: number }) {
documents(kbId: string, params?: { page?: number; page_size?: number; search?: string }) {
return typed<any>(
openApiV1.listKnowledgeDocuments({
path: { kb_id: kbId },
@@ -408,10 +408,6 @@
content: "\F0209";
}
.mdi-eye-outline::before {
content: "\F06D0";
}
.mdi-eyedropper::before {
content: "\F020A";
}
@@ -452,6 +448,14 @@
content: "\F021C";
}
.mdi-file-image::before {
content: "\F021F";
}
.mdi-file-music-outline::before {
content: "\F0E2A";
}
.mdi-file-outline::before {
content: "\F0224";
}
@@ -476,6 +480,10 @@
content: "\F0A4D";
}
.mdi-file-video-outline::before {
content: "\F0E2C";
}
.mdi-file-word-box::before {
content: "\F022D";
}
@@ -492,6 +500,10 @@
content: "\F024B";
}
.mdi-folder-cog-outline::before {
content: "\F1080";
}
.mdi-folder-move::before {
content: "\F0252";
}
@@ -580,6 +592,10 @@
content: "\F0EFE";
}
.mdi-image-outline::before {
content: "\F0976";
}
.mdi-import::before {
content: "\F02FA";
}
@@ -620,18 +636,6 @@
content: "\F0318";
}
.mdi-language-css3::before {
content: "\F031C";
}
.mdi-language-html5::before {
content: "\F031D";
}
.mdi-language-javascript::before {
content: "\F031E";
}
.mdi-language-markdown::before {
content: "\F0354";
}
@@ -640,14 +644,6 @@
content: "\F0F5B";
}
.mdi-language-python::before {
content: "\F0320";
}
.mdi-language-typescript::before {
content: "\F06E6";
}
.mdi-layers-outline::before {
content: "\F09FE";
}
@@ -1024,6 +1020,10 @@
content: "\F060D";
}
.mdi-svg::before {
content: "\F0721";
}
.mdi-sync::before {
content: "\F04E6";
}
+3 -3
View File
@@ -1,16 +1,16 @@
<template>
<v-dialog v-model="isOpen" max-width="400">
<v-card>
<v-card-title class="text-h6">
<v-card-title class="text-h3 pa-4 pb-0 pl-6">
{{ title }}
</v-card-title>
<v-card-text>{{ message }}</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn color="gray" @click="handleCancel">
<v-btn color="gray" variant="text" @click="handleCancel">
{{ t("core.common.dialog.cancelButton") }}
</v-btn>
<v-btn color="red" @click="handleConfirm">
<v-btn color="red" variant="tonal" @click="handleConfirm">
{{ t("core.common.dialog.confirmButton") }}
</v-btn>
</v-card-actions>
File diff suppressed because it is too large Load Diff
+132 -55
View File
@@ -9,8 +9,8 @@
<div
class="input-container"
:style="{
width: '85%',
maxWidth: '900px',
width: 'var(--chat-content-width, 76%)',
maxWidth: 'var(--chat-content-max-width, 760px)',
margin: '0 auto',
border: isDark ? 'none' : '1px solid #e0e0e0',
borderRadius: '24px',
@@ -55,7 +55,7 @@
@compositioncancel="handleCompositionEnd"
@blur="clearCompositionState()"
:disabled="disabled"
placeholder="Ask AstrBot..."
:placeholder="tm('input.placeholder')"
class="chat-textarea"
autocomplete="off"
autocorrect="off"
@@ -155,7 +155,7 @@
<!-- Provider/Model Selector Menu -->
<ProviderModelMenu
v-if="showProviderSelector"
v-if="providerSelectorVisible"
ref="providerModelMenuRef"
/>
</div>
@@ -182,6 +182,27 @@
class="mr-1"
width="1.5"
/>
<v-tooltip
v-if="tokenUsageVisible"
location="top"
max-width="320"
>
<template #activator="{ props: tokenTooltipProps }">
<span
v-bind="tokenTooltipProps"
class="token-usage-indicator"
:style="{ '--token-usage-color': tokenUsageColor }"
>
<v-progress-circular
:model-value="tokenUsagePercent"
size="24"
width="2.5"
class="token-usage-progress"
/>
</span>
</template>
<span>{{ props.tokenUsage?.tooltip }}</span>
</v-tooltip>
<!-- <v-btn @click="$emit('openLiveMode')"
icon
variant="text"
@@ -259,11 +280,9 @@
/>
</div>
<div v-if="stagedAudioUrl" class="audio-preview">
<v-chip color="primary" variant="tonal" class="audio-chip">
<v-icon start icon="mdi-microphone" size="small"></v-icon>
{{ tm("voice.recording") }}
</v-chip>
<div v-if="stagedAudioUrl" class="attachment-card audio-preview">
<v-icon icon="mdi-microphone" size="24" />
<span class="attachment-name">{{ tm("voice.recording") }}</span>
<v-btn
@click="$emit('removeAudio')"
class="remove-attachment-btn"
@@ -277,12 +296,14 @@
<div
v-for="(file, index) in stagedFiles"
:key="'file-' + index"
class="file-preview"
class="attachment-card file-preview"
:style="{ '--attachment-color': filePresentation(file).color }"
>
<v-chip color="primary" variant="tonal" class="file-chip">
<v-icon start icon="mdi-file-document-outline" size="small"></v-icon>
<span class="file-name-preview">{{ file.original_name }}</span>
</v-chip>
<span class="attachment-icon">
<v-icon :icon="filePresentation(file).icon" size="24" />
<span class="attachment-ext">{{ filePresentation(file).label }}</span>
</span>
<span class="attachment-name">{{ file.original_name }}</span>
<v-btn
@click="$emit('removeFile', index)"
class="remove-attachment-btn"
@@ -304,6 +325,7 @@ import type { Session } from "@/composables/useSessions";
import { useModuleI18n } from "@/i18n/composables";
import { useCustomizerStore } from "@/stores/customizer";
import { isComposingEnter } from "@/utils/imeInput.mjs";
import { attachmentPresentation } from "./attachmentPresentation";
import ConfigSelector from "./ConfigSelector.vue";
// biome-ignore lint/style/useImportType: Vue template components require runtime imports.
import ProviderModelMenu from "./ProviderModelMenu.vue";
@@ -321,6 +343,13 @@ interface ReplyInfo {
selectedText?: string;
}
interface TokenUsageInfo {
used: number;
limit: number;
percent: number;
tooltip: string;
}
interface Props {
prompt: string;
stagedImagesUrl: string[];
@@ -335,6 +364,8 @@ interface Props {
configId?: string | null;
replyTo?: ReplyInfo | null;
sendShortcut?: "enter" | "shift_enter";
showProviderSelector?: boolean;
tokenUsage?: TokenUsageInfo | null;
}
const props = withDefaults(defineProps<Props>(), {
@@ -344,6 +375,8 @@ const props = withDefaults(defineProps<Props>(), {
stagedFiles: () => [],
replyTo: null,
sendShortcut: "shift_enter",
showProviderSelector: true,
tokenUsage: null,
});
const emit = defineEmits<{
@@ -368,7 +401,7 @@ const isDark = computed(() => useCustomizerStore().uiTheme === "PurpleThemeDark"
const inputField = ref<HTMLTextAreaElement | null>(null);
const imageInputRef = ref<HTMLInputElement | null>(null);
const providerModelMenuRef = ref<InstanceType<typeof ProviderModelMenu> | null>(null);
const showProviderSelector = ref(true);
const providerSelectorAvailable = ref(true);
const isReplyClosing = ref(false);
const isComposing = ref(false);
const lastCompositionEndAt = ref<number | null>(null);
@@ -392,41 +425,37 @@ const canSend = computed(() => {
);
});
const fileTypeStyles: Record<string, { color: string; icon: string; label: string }> = {
pdf: { color: "#d32f2f", icon: "mdi-file-pdf-box", label: "PDF" },
txt: { color: "#1976d2", icon: "mdi-file-document-outline", label: "TXT" },
md: { color: "#1976d2", icon: "mdi-language-markdown-outline", label: "MD" },
doc: { color: "#2b579a", icon: "mdi-file-word-box", label: "DOC" },
docx: { color: "#2b579a", icon: "mdi-file-word-box", label: "DOCX" },
xls: { color: "#217346", icon: "mdi-file-excel-box", label: "XLS" },
xlsx: { color: "#217346", icon: "mdi-file-excel-box", label: "XLSX" },
csv: { color: "#217346", icon: "mdi-file-delimited-outline", label: "CSV" },
zip: { color: "#7b5e00", icon: "mdi-folder-zip-outline", label: "ZIP" },
py: { color: "#3776ab", icon: "mdi-language-python", label: "PY" },
js: { color: "#b8860b", icon: "mdi-language-javascript", label: "JS" },
ts: { color: "#3178c6", icon: "mdi-language-typescript", label: "TS" },
html: { color: "#e34c26", icon: "mdi-language-html5", label: "HTML" },
css: { color: "#264de4", icon: "mdi-language-css3", label: "CSS" },
json: { color: "#6a1b9a", icon: "mdi-code-json", label: "JSON" },
};
function fileExtension(file: StagedFileInfo) {
const name = file.original_name || file.filename || "";
const extension = name.split(".").pop()?.toLowerCase() || "";
return extension === name.toLowerCase() ? "" : extension;
}
function filePresentation(file: StagedFileInfo) {
const extension = fileExtension(file);
return (
fileTypeStyles[extension] || {
color: "#607d8b",
icon: "mdi-file-document-outline",
label: extension ? extension.slice(0, 4).toUpperCase() : "FILE",
}
);
return attachmentPresentation(file);
}
const providerSelectorVisible = computed(
() => props.showProviderSelector && providerSelectorAvailable.value,
);
const tokenUsageVisible = computed(() => {
const usage = props.tokenUsage;
return Boolean(
usage &&
Number.isFinite(usage.used) &&
Number.isFinite(usage.limit) &&
usage.used > 0 &&
usage.limit > 0,
);
});
const tokenUsagePercent = computed(() => {
const percent = props.tokenUsage?.percent || 0;
if (!Number.isFinite(percent)) return 0;
return Math.min(100, Math.max(0, percent));
});
const tokenUsageColor = computed(() =>
isDark.value
? "rgba(var(--v-theme-on-surface), 0.82)"
: "rgba(var(--v-theme-on-surface), 0.72)",
);
// Ctrl+B
const ctrlKeyDown = ref(false);
const ctrlKeyTimer = ref<number | null>(null);
@@ -584,11 +613,11 @@ function handleRecordClick() {
function handleConfigChange(payload: { configId: string; agentRunnerType: string }) {
const runnerType = (payload.agentRunnerType || "").toLowerCase();
const isInternal = runnerType === "internal" || runnerType === "local";
showProviderSelector.value = isInternal;
providerSelectorAvailable.value = isInternal;
}
function getCurrentSelection() {
if (!showProviderSelector.value) {
if (!providerSelectorVisible.value) {
return null;
}
return providerModelMenuRef.value?.getCurrentSelection();
@@ -860,6 +889,46 @@ defineExpose({
display: inline-flex;
}
.attachment-card {
--attachment-color: #607d8b;
min-width: 150px;
max-width: 240px;
height: 60px;
align-items: center;
gap: 9px;
padding: 8px 12px;
border-radius: 10px;
background: rgba(var(--v-theme-on-surface), 0.055);
color: var(--attachment-color);
}
.attachment-icon {
display: inline-flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 0 0 auto;
}
.attachment-ext {
max-width: 42px;
overflow: hidden;
font-size: 10px;
font-weight: 700;
line-height: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.attachment-name {
min-width: 0;
overflow: hidden;
color: rgb(var(--v-theme-on-surface));
font-size: 13px;
text-overflow: ellipsis;
white-space: nowrap;
}
.preview-image {
width: 60px;
height: 60px;
@@ -868,12 +937,6 @@ defineExpose({
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.audio-chip,
.file-chip {
height: 36px;
border-radius: 18px;
}
.file-name-preview {
max-width: 120px;
overflow: hidden;
@@ -893,6 +956,20 @@ defineExpose({
opacity: 1;
}
.token-usage-indicator {
width: 28px;
height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
color: var(--token-usage-color);
}
.token-usage-progress {
color: inherit;
}
.fade-in {
animation: fadeIn 0.3s ease-in-out;
}
+246 -12
View File
@@ -24,6 +24,63 @@
<div class="message-stack">
<div
v-if="isUserMessage(msg) && userAttachmentParts(msg).length"
class="sent-attachments"
:class="{ 'images-only': hasImageOnlyAttachments(msg) }"
>
<template
v-for="(part, attachmentIndex) in userAttachmentParts(msg)"
:key="`${msgIndex}-attachment-${attachmentIndex}-${part.type}`"
>
<button
v-if="part.type === 'image'"
class="sent-attachment-card sent-image-card"
type="button"
@click="openImage(partUrl(part))"
>
<img :src="partUrl(part)" :alt="part.filename || 'image'" />
</button>
<div v-else class="sent-attachment-card sent-file-card">
<div
class="sent-attachment-icon"
:style="{
'--attachment-color': attachmentPresentation(part).color,
}"
>
<v-icon
class="sent-attachment-icon-symbol"
:icon="attachmentPresentation(part).icon"
size="24"
/>
<span class="sent-attachment-ext">
{{ attachmentPresentation(part).label }}
</span>
</div>
<span class="sent-attachment-name">
{{ attachmentName(part) }}
</span>
<v-btn
v-if="part.type === 'file'"
icon="mdi-download"
size="x-small"
variant="text"
:loading="
downloadingFiles.has(
part.attachment_id ||
part.stored_filename ||
part.filename ||
'',
)
"
@click="downloadPart(part)"
/>
</div>
</template>
</div>
<div
v-if="shouldShowMessageBubble(msg)"
class="message-bubble"
:class="{ user: isUserMessage(msg), bot: !isUserMessage(msg) }"
@mouseup="handleMouseUp($event, msg)"
@@ -81,7 +138,7 @@
/>
<template
v-for="(part, partIndex) in messageParts(msg)"
v-for="(part, partIndex) in displayedMessageParts(msg)"
:key="`${msgIndex}-${partIndex}-${part.type}`"
>
<button
@@ -314,7 +371,6 @@
</template>
<script setup lang="ts">
import axios from "axios";
import { setCustomComponents } from "markstream-vue";
import { computed, nextTick, reactive, ref } from "vue";
import "markstream-vue/index.css";
@@ -331,8 +387,18 @@ import RegenerateMenu, { type RegenerateModelSelection } from "@/components/chat
import ThreadedMarkdownMessagePart from "@/components/chat/ThreadedMarkdownMessagePart.vue";
import StyledMenu from "@/components/shared/StyledMenu.vue";
import ThemeAwareMarkdownCodeBlock from "@/components/shared/ThemeAwareMarkdownCodeBlock.vue";
import type { ChatContent, ChatRecord, ChatThread, MessagePart } from "@/composables/useMessages";
import {
attachmentName,
attachmentPresentation,
} from "@/components/chat/attachmentPresentation";
import type {
ChatContent,
ChatRecord,
ChatThread,
MessagePart,
} from "@/composables/useMessages";
import { useI18n, useModuleI18n } from "@/i18n/composables";
import axios from "@/utils/request";
const props = withDefaults(
defineProps<{
@@ -407,6 +473,32 @@ function messageParts(message: ChatRecord): MessagePart[] {
return [];
}
function userAttachmentParts(message: ChatRecord) {
return messageParts(message).filter((part) =>
["image", "record", "video", "file"].includes(part.type),
);
}
function bubbleParts(message: ChatRecord) {
if (!isUserMessage(message)) return messageParts(message);
return messageParts(message).filter(
(part) => !["image", "record", "video", "file"].includes(part.type),
);
}
function displayedMessageParts(message: ChatRecord) {
return isUserMessage(message) ? bubbleParts(message) : messageParts(message);
}
function hasImageOnlyAttachments(message: ChatRecord) {
const parts = userAttachmentParts(message);
return parts.length > 0 && parts.every((part) => part.type === "image");
}
function shouldShowMessageBubble(message: ChatRecord) {
return !isUserMessage(message) || bubbleParts(message).length > 0;
}
function isMessageStreaming(message: ChatRecord, messageIndex: number) {
return props.isStreaming && !isUserMessage(message) && messageIndex === props.messages.length - 1;
}
@@ -481,8 +573,9 @@ function partUrl(part: MessagePart) {
if (part.attachment_id) {
return `/api/chat/get_attachment?attachment_id=${encodeURIComponent(part.attachment_id)}`;
}
if (part.filename) {
return `/api/chat/get_file?filename=${encodeURIComponent(part.filename)}`;
const lookupFilename = part.stored_filename || part.filename;
if (lookupFilename) {
return `/api/chat/get_file?filename=${encodeURIComponent(lookupFilename)}`;
}
return "";
}
@@ -598,7 +691,7 @@ async function copyMessage(message: ChatRecord) {
}
async function downloadPart(part: MessagePart) {
const key = part.attachment_id || part.filename || "";
const key = part.attachment_id || part.stored_filename || part.filename || "";
if (!key) return;
downloadingFiles.value = new Set(downloadingFiles.value).add(key);
try {
@@ -711,6 +804,107 @@ function formatDuration(seconds: number) {
max-width: 60%;
}
.sent-attachments {
display: flex;
max-width: 100%;
gap: 10px;
margin-bottom: 8px;
padding: 2px 2px 4px;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: thin;
}
.sent-attachment-card {
--attachment-color: #607d8b;
position: relative;
display: inline-flex;
flex: 0 0 auto;
align-items: center;
justify-content: flex-start;
gap: 8px;
height: 60px;
overflow: hidden;
border: 0;
border-radius: 8px;
background: rgba(var(--v-theme-on-surface), 0.055);
color: rgb(var(--v-theme-on-surface));
}
.sent-image-card {
width: 64px;
padding: 0;
border: 0;
cursor: zoom-in;
}
.sent-image-card img {
width: 100%;
height: 100%;
border-radius: 8px;
object-fit: cover;
}
.sent-attachments.images-only {
max-width: min(420px, 100%);
}
.sent-attachments.images-only .sent-image-card {
width: 180px;
height: 180px;
}
.sent-attachments.images-only .sent-image-card img {
object-fit: cover;
background: rgba(var(--v-theme-on-surface), 0.04);
}
.sent-file-card {
width: 236px;
padding: 8px 10px;
background: rgba(var(--v-theme-on-surface), 0.055);
background: linear-gradient(
90deg,
color-mix(in srgb, var(--attachment-color) 14%, transparent),
rgba(var(--v-theme-on-surface), 0.055) 62%
);
}
.sent-attachment-icon {
display: inline-flex;
flex-shrink: 0;
min-width: 36px;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1px;
color: var(--attachment-color);
}
.sent-attachment-icon-symbol {
color: var(--attachment-color);
}
.sent-attachment-ext {
max-width: 58px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 10px;
font-weight: 700;
line-height: 12px;
color: var(--attachment-color);
}
.sent-attachment-name {
min-width: 0;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
line-height: 18px;
}
.bot-avatar {
margin-top: 2px;
color: rgb(var(--v-theme-primary));
@@ -859,21 +1053,61 @@ function formatDuration(seconds: number) {
}
.file-part {
display: flex;
--attachment-color: #607d8b;
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
gap: 10px;
width: min(420px, 100%);
margin-top: 8px;
padding: 8px 10px;
border: 1px solid var(--chat-border);
padding: 9px 8px 9px 10px;
border: 0;
border-radius: 8px;
background: rgba(var(--v-theme-on-surface), 0.055);
background: linear-gradient(
90deg,
color-mix(in srgb, var(--attachment-color) 13%, transparent),
rgba(var(--v-theme-on-surface), 0.055) 58%
);
}
.file-part span {
.file-part-icon {
color: var(--attachment-color);
}
.file-part-meta {
min-width: 0;
flex: 1;
display: flex;
flex-direction: column;
gap: 1px;
}
.file-part-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
font-weight: 500;
line-height: 20px;
}
.file-part-kind {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--attachment-color);
font-size: 11px;
font-weight: 700;
line-height: 14px;
}
.file-part-action {
color: rgb(var(--v-theme-on-surface));
opacity: 0.72;
}
.file-part:hover .file-part-action {
opacity: 1;
}
.tool-call-block {
@@ -0,0 +1,23 @@
<template>
<svg
viewBox="0 0 24 24"
aria-hidden="true"
focusable="false"
xmlns="http://www.w3.org/2000/svg"
>
<g transform="matrix(0.048 0 0 0.048 1.8 0.16)">
<g transform="translate(0.8 0.9)">
<g transform="translate(0 32)">
<path
d="m246.3 328.1-17.8 41.2c-6.4 14.8-26.9 14.8-33.3 0l-17.8-41.2c-14.9-34.2-41.8-61.4-75.3-76.3l-48.8-21.6c-14.7-6.5-14.7-27.9 0-34.4l47.2-21c34.4-15.3 61.7-43.5 76.4-78.8l18-43.7c6.3-15.2 27.3-15.2 33.6 0l18 43.7c14.7 35.3 42 63.6 76.4 78.8l47.2 21c14.7 6.5 14.7 27.9 0 34.4l-48.8 21.6c-33.5 14.8-60.4 42.1-75.3 76.2z"
fill="#2f86bd"
transform="translate(0 35)" />
<path
d="m402.2 449.3-5.3 12.2c-3.5 7.9-14.4 7.9-17.9 0l-5.3-12.2c-8.4-19.3-23.6-34.6-42.4-43l-15.4-6.9c-7.9-3.5-7.9-14.9 0-18.4l14.5-6.5c19.4-8.6 34.8-24.5 43.1-44.5l5.4-13.1c3.4-8.1 14.6-8.1 18 0l5.4 13.1c8.3 19.9 23.7 35.8 43.1 44.5l14.5 6.5c7.9 3.5 7.9 14.9 0 18.4l-15.4 6.9c-19 8.3-34.1 23.7-42.5 43z"
fill="#2f86bd"
transform="matrix(0.95 0 0 0.95 22 -278)" />
</g>
</g>
</g>
</svg>
</template>
@@ -26,7 +26,9 @@
<v-dialog v-model="dialog" max-width="480">
<v-card>
<v-card-title class="d-flex align-center justify-space-between">
<v-card-title
class="text-h3 pa-4 pb-0 pl-6 d-flex align-center justify-space-between"
>
<span>选择配置文件</span>
<v-btn icon variant="text" @click="closeDialog">
<v-icon>mdi-close</v-icon>
@@ -69,6 +71,7 @@
<v-btn variant="text" @click="closeDialog"> 取消 </v-btn>
<v-btn
color="primary"
variant="tonal"
:disabled="!tempSelectedConfig"
:loading="saving"
@click="confirmSelection"
+153 -84
View File
@@ -60,105 +60,67 @@
<!-- 图片附件 -->
<div
v-else-if="part.type === 'image' && part.embedded_url"
v-else-if="part.type === 'image' && partUrl(part)"
class="image-attachments"
>
<div class="image-attachment">
<img
:src="part.embedded_url"
:src="partUrl(part)"
class="attached-image"
@click="openImagePreview(part.embedded_url)"
@click="openImagePreview(partUrl(part))"
/>
</div>
</div>
<!-- 音频附件 -->
<div
v-else-if="part.type === 'record' && part.embedded_url"
class="audio-attachment"
>
<audio controls class="audio-player">
<source :src="part.embedded_url" type="audio/wav" />
{{ t("messages.errors.browser.audioNotSupported") }}
</audio>
</div>
<audio
v-else-if="part.type === 'record' && partUrl(part)"
controls
class="audio-player"
:src="partUrl(part)"
/>
<video
v-else-if="part.type === 'video' && partUrl(part)"
controls
class="video-part"
:src="partUrl(part)"
/>
<!-- 文件附件 -->
<div
v-else-if="part.type === 'file' && part.embedded_file"
class="file-attachments"
v-else-if="part.type === 'file'"
class="file-part"
:style="{
'--attachment-color': attachmentPresentation(part).color,
}"
>
<div class="file-attachment">
<a
v-if="part.embedded_file.url"
:href="part.embedded_file.url"
:download="part.embedded_file.filename"
class="file-link"
:class="{ 'is-dark': isDark }"
:style="
isDark
? {
backgroundColor: 'rgba(255, 255, 255, 0.05)',
borderColor: 'rgba(255, 255, 255, 0.1)',
color: 'var(--v-theme-secondary)',
}
: {}
"
>
<v-icon
size="small"
class="file-icon"
:style="
isDark ? { color: 'var(--v-theme-secondary)' } : {}
"
>mdi-file-document-outline</v-icon
>
<span class="file-name">{{
part.embedded_file.filename
}}</span>
</a>
<a
v-else
class="file-link file-link-download"
:class="{ 'is-dark': isDark }"
:style="
isDark
? {
backgroundColor: 'rgba(255, 255, 255, 0.05)',
borderColor: 'rgba(255, 255, 255, 0.1)',
color: 'var(--v-theme-secondary)',
}
: {}
"
@click="downloadFile(part.embedded_file)"
>
<v-icon
size="small"
class="file-icon"
:style="
isDark ? { color: 'var(--v-theme-secondary)' } : {}
"
>mdi-file-document-outline</v-icon
>
<span class="file-name">{{
part.embedded_file.filename
}}</span>
<v-icon
v-if="
downloadingFiles.has(
part.embedded_file.attachment_id || '',
)
"
size="small"
class="download-icon"
>mdi-loading mdi-spin</v-icon
>
<v-icon v-else size="small" class="download-icon"
>mdi-download</v-icon
>
</a>
<v-icon
class="file-part-icon"
:icon="attachmentPresentation(part).icon"
size="24"
/>
<div class="file-part-meta">
<span class="file-part-name">{{ attachmentName(part) }}</span>
<span class="file-part-kind">
{{ attachmentPresentation(part).label }}
</span>
</div>
<v-btn
class="file-part-action"
icon="mdi-download"
size="x-small"
variant="text"
:loading="
downloadingFiles.has(
part.attachment_id ||
part.stored_filename ||
part.filename ||
'',
)
"
@click="downloadPart(part)"
/>
</div>
</template>
</div>
@@ -363,6 +325,10 @@
import { enableKatex, enableMermaid, MarkdownCodeBlockNode, setCustomComponents } from "markstream-vue";
import type { PropType } from "vue";
import { defineComponent } from "vue";
import {
attachmentName,
attachmentPresentation,
} from "@/components/chat/attachmentPresentation";
import { useI18n, useModuleI18n } from "@/i18n/composables";
import "markstream-vue/index.css";
import "katex/dist/katex.min.css";
@@ -423,6 +389,8 @@ export default defineComponent({
t,
tm,
toast,
attachmentName,
attachmentPresentation,
};
},
data() {
@@ -657,6 +625,42 @@ export default defineComponent({
return this.expandedReasoning.has(messageIndex);
},
partUrl(part: MessagePart): string {
if (part.embedded_url) return part.embedded_url;
if (part.embedded_file?.url) return part.embedded_file.url;
if (part.attachment_id) {
return `/api/chat/get_attachment?attachment_id=${encodeURIComponent(part.attachment_id)}`;
}
const lookupFilename = part.stored_filename || part.filename;
if (lookupFilename) {
return `/api/chat/get_file?filename=${encodeURIComponent(lookupFilename)}`;
}
return "";
},
async downloadPart(part: MessagePart): Promise<void> {
const key = part.attachment_id || part.stored_filename || part.filename || "";
const url = this.partUrl(part);
if (!key || !url) return;
this.downloadingFiles.add(key);
this.downloadingFiles = new Set(this.downloadingFiles);
try {
const response = await axios.get(url, { responseType: "blob" });
const objectUrl = URL.createObjectURL(response.data);
const anchor = document.createElement("a");
anchor.href = objectUrl;
anchor.download = attachmentName(part);
anchor.click();
setTimeout(() => URL.revokeObjectURL(objectUrl), 100);
} catch (error) {
console.error("Download attachment failed:", error);
} finally {
this.downloadingFiles.delete(key);
this.downloadingFiles = new Set(this.downloadingFiles);
}
},
//
async downloadFile(file: { attachment_id?: string; filename?: string }): Promise<void> {
if (!file.attachment_id) return;
@@ -1530,6 +1534,71 @@ export default defineComponent({
border-color: rgba(255, 255, 255, 0.2) !important;
}
.video-part {
display: block;
max-width: 100%;
max-height: 360px;
margin-top: 8px;
border-radius: 8px;
}
.file-part {
--attachment-color: #607d8b;
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
width: min(420px, 100%);
margin-top: 8px;
padding: 9px 8px 9px 10px;
border-radius: 8px;
background: linear-gradient(
90deg,
color-mix(in srgb, var(--attachment-color) 13%, transparent),
rgba(var(--v-theme-on-surface), 0.055) 58%
);
}
.file-part-icon,
.file-part-kind {
color: var(--attachment-color);
}
.file-part-meta {
min-width: 0;
display: flex;
flex-direction: column;
gap: 1px;
}
.file-part-name,
.file-part-kind {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-part-name {
font-size: 14px;
font-weight: 500;
line-height: 20px;
}
.file-part-kind {
font-size: 11px;
font-weight: 700;
line-height: 14px;
}
.file-part-action {
color: rgb(var(--v-theme-on-surface));
opacity: 0.72;
}
.file-part:hover .file-part-action {
opacity: 1;
}
/* 动画类 */
.fade-in {
animation: fadeIn 0.3s ease-in-out;
+99 -39
View File
@@ -1,11 +1,11 @@
<template>
<v-dialog
v-model="isOpen"
max-width="500"
max-width="640"
@update:model-value="handleDialogChange"
>
<v-card>
<v-card-title class="dialog-title">
<v-card-title class="text-h3 pa-4 pb-0 pl-6">
{{ isEditing ? tm("project.edit") : tm("project.create") }}
</v-card-title>
<v-card-text>
@@ -36,16 +36,51 @@
rows="3"
rounded="lg"
/>
<v-divider class="my-4" />
<v-select
v-model="form.workspace_type"
:items="workspaceTypeItems"
item-title="label"
item-value="value"
:label="tm('project.workspace.type')"
flat
variant="solo-filled"
hide-details
class="mb-3"
/>
<v-text-field
v-if="form.workspace_type === 'custom'"
v-model="form.workspace_path"
:label="tm('project.workspace.path')"
flat
variant="solo-filled"
hide-details
/>
<v-alert
v-if="props.errorMessage"
class="mt-3"
type="error"
variant="tonal"
density="compact"
>
{{ props.errorMessage }}
</v-alert>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" color="grey-darken-1" @click="handleCancel">
<v-btn
variant="text"
color="grey-darken-1"
:disabled="props.saving"
@click="handleCancel"
>
{{ t("core.common.cancel") }}
</v-btn>
<v-btn
variant="text"
color="primary"
:disabled="!form.title.trim()"
:disabled="!canSave || props.saving"
:loading="props.saving"
@click="handleSave"
>
{{ t("core.common.save") }}
@@ -56,14 +91,19 @@
</template>
<script setup lang="ts">
import { ref, watch } from "vue";
import { computed, ref, watch } from "vue";
import { useI18n, useModuleI18n } from "@/i18n/composables";
export type WorkspaceType = "session" | "project" | "custom";
export interface Project {
project_id: string;
title: string;
emoji?: string;
description?: string;
workspace_type?: WorkspaceType;
workspace_path?: string | null;
resolved_workspace_path?: string | null;
created_at: string;
updated_at: string;
}
@@ -72,16 +112,22 @@ export interface ProjectFormData {
emoji: string;
title: string;
description: string;
workspace_type: WorkspaceType;
workspace_path: string;
}
interface Props {
modelValue: boolean;
project?: Project | null;
errorMessage?: string;
saving?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
modelValue: false,
project: null,
errorMessage: "",
saving: false,
});
const emit = defineEmits<{
@@ -94,37 +140,56 @@ const { tm } = useModuleI18n("features/chat");
const isOpen = ref(props.modelValue);
const isEditing = ref(false);
const form = ref<ProjectFormData>({
emoji: "📁",
title: "",
description: "",
const form = ref<ProjectFormData>(emptyProjectForm());
const workspaceTypeItems = computed(() => [
{ label: tm("project.workspace.project"), value: "project" },
{ label: tm("project.workspace.session"), value: "session" },
{ label: tm("project.workspace.custom"), value: "custom" },
]);
const canSave = computed(() => {
if (!form.value.title.trim()) return false;
if (form.value.workspace_type !== "custom") return true;
return form.value.workspace_path.trim().length > 0;
});
watch(
() => props.modelValue,
(newVal) => {
isOpen.value = newVal;
if (newVal) {
//
if (props.project) {
isEditing.value = true;
form.value = {
(newValue) => {
isOpen.value = newValue;
if (!newValue) return;
isEditing.value = Boolean(props.project);
form.value = props.project
? {
emoji: props.project.emoji || "📁",
title: props.project.title,
description: props.project.description || "",
};
} else {
isEditing.value = false;
form.value = {
emoji: "📁",
title: "",
description: "",
};
}
workspace_type: props.project.workspace_type || "session",
workspace_path: props.project.workspace_path || "",
}
: emptyProjectForm();
},
);
watch(
() => form.value.workspace_type,
(workspaceType) => {
if (workspaceType !== "custom") {
form.value.workspace_path = "";
}
},
);
function emptyProjectForm(): ProjectFormData {
return {
emoji: "📁",
title: "",
description: "",
workspace_type: "project",
workspace_path: "",
};
}
function handleDialogChange(value: boolean) {
emit("update:modelValue", value);
}
@@ -135,19 +200,14 @@ function handleCancel() {
}
function handleSave() {
if (!form.value.title.trim()) {
return;
}
emit("save", { ...form.value }, props.project?.project_id);
isOpen.value = false;
emit("update:modelValue", false);
if (!canSave.value) return;
emit(
"save",
{
...form.value,
workspace_path: form.value.workspace_path.trim(),
},
props.project?.project_id,
);
}
</script>
<style scoped>
.dialog-title {
font-size: 22px;
font-weight: 500;
}
</style>
+310 -82
View File
@@ -1,9 +1,7 @@
<template>
<div>
<!-- 项目按钮 -->
<div style="padding: 0 8px 0px 8px; opacity: 0.6">
<div class="project-section-header">
<v-btn
block
variant="text"
class="project-btn"
prepend-icon="mdi-folder-outline"
@@ -16,64 +14,140 @@
</v-icon>
</template>
</v-btn>
<v-btn
icon="mdi-plus"
size="x-small"
variant="text"
:title="tm('project.create')"
@click="$emit('createProject')"
/>
</div>
<!-- 项目列表 -->
<v-expand-transition>
<div v-show="expanded" style="padding: 0 8px">
<div v-show="expanded" class="project-list-wrap">
<v-list
density="compact"
nav
class="project-list"
style="background-color: transparent"
>
<v-list-item
class="create-project-item"
rounded="lg"
@click="$emit('createProject')"
>
<template #prepend>
<span class="project-emoji"
><v-icon size="small">mdi-plus</v-icon></span
<template v-for="project in projects" :key="project.project_id">
<v-list-item
rounded="lg"
class="project-item"
:class="{ active: selectedProjectId === project.project_id }"
@click="handleProjectClick(project)"
>
<template #prepend>
<span class="project-emoji">{{ project.emoji || "📁" }}</span>
</template>
<v-list-item-title class="project-title">
{{ project.title }}
</v-list-item-title>
<template #append>
<div class="project-actions" @click.stop>
<v-btn
icon="mdi-pencil"
size="x-small"
variant="text"
class="project-action-btn"
:title="tm('project.edit')"
@click="$emit('editProject', project)"
/>
<v-btn
icon="mdi-delete"
size="x-small"
variant="text"
class="project-action-btn"
color="error"
:title="tm('actions.deleteChat')"
@click="handleDeleteProject(project)"
/>
<v-btn
:icon="
isProjectExpanded(project.project_id)
? 'mdi-chevron-up'
: 'mdi-chevron-down'
"
size="x-small"
variant="text"
class="project-action-btn"
@click="toggleProject(project.project_id)"
/>
</div>
</template>
</v-list-item>
<v-expand-transition>
<div
v-show="isProjectExpanded(project.project_id)"
class="project-session-list"
>
</template>
<v-list-item-title style="font-size: 13px">
{{ tm("project.create") }}
</v-list-item-title>
</v-list-item>
<v-list-item
v-for="project in projects"
:key="project.project_id"
rounded="lg"
class="project-item"
@click="$emit('selectProject', project.project_id)"
>
<template #prepend>
<span class="project-emoji">{{ project.emoji || "📁" }}</span>
</template>
<v-list-item-title class="project-title">
{{ project.title }}
</v-list-item-title>
<template #append>
<div class="project-actions">
<v-btn
icon="mdi-pencil"
size="x-small"
variant="text"
class="edit-project-btn"
@click.stop="$emit('editProject', project)"
/>
<v-btn
icon="mdi-delete"
size="x-small"
variant="text"
class="delete-project-btn"
color="error"
@click.stop="handleDeleteProject(project)"
/>
<div
v-if="loadingProjectIds.includes(project.project_id)"
class="project-session-empty"
>
{{ tm("project.loadingSessions") }}
</div>
<template
v-else-if="projectSessionList(project.project_id).length"
>
<div
v-for="session in projectSessionList(project.project_id)"
:key="session.session_id"
class="project-session-item"
:class="{ active: activeSessionId === session.session_id }"
role="button"
tabindex="0"
@click="$emit('selectSession', session.session_id)"
@keydown.enter="$emit('selectSession', session.session_id)"
@keydown.space.prevent="
$emit('selectSession', session.session_id)
"
>
<span class="project-session-title">
{{ sessionTitle(session) }}
</span>
<v-progress-circular
v-if="sessionRunning(session.session_id)"
class="project-session-progress"
indeterminate
size="14"
width="2"
/>
<span class="project-session-actions" @click.stop>
<v-btn
icon="mdi-pencil"
size="x-small"
variant="text"
class="project-action-btn"
:title="tm('conversation.editDisplayName')"
@click="
$emit(
'editSessionTitle',
session.session_id,
session.display_name || '',
)
"
/>
<v-btn
icon="mdi-delete"
size="x-small"
variant="text"
class="project-action-btn"
color="error"
:title="tm('actions.deleteChat')"
@click="handleDeleteSession(project.project_id, session)"
/>
</span>
</div>
</template>
<div v-else class="project-session-empty">
{{ tm("project.noSessions") }}
</div>
</div>
</template>
</v-list-item>
</v-expand-transition>
</template>
</v-list>
</div>
</v-expand-transition>
@@ -81,7 +155,7 @@
</template>
<script setup lang="ts">
import { ref } from "vue";
import { ref, watch } from "vue";
import { useModuleI18n } from "@/i18n/composables";
import { askForConfirmation, useConfirmDialog } from "@/utils/confirmDialog";
@@ -90,16 +164,34 @@ export interface Project {
title: string;
emoji?: string;
description?: string;
workspace_type?: "session" | "project" | "custom";
workspace_path?: string | null;
resolved_workspace_path?: string | null;
created_at: string;
updated_at: string;
}
export interface ProjectSession {
session_id: string;
display_name?: string | null;
updated_at: string;
}
interface Props {
projects: Project[];
projectSessions?: Record<string, ProjectSession[]>;
loadingProjectIds?: string[];
selectedProjectId?: string | null;
activeSessionId?: string | null;
isSessionRunning?: (sessionId: string) => boolean;
initialExpanded?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
projectSessions: () => ({}),
loadingProjectIds: () => [],
selectedProjectId: null,
activeSessionId: null,
initialExpanded: false,
});
@@ -108,18 +200,62 @@ const emit = defineEmits<{
createProject: [];
editProject: [project: Project];
deleteProject: [projectId: string];
toggleProject: [projectId: string, expanded: boolean];
selectSession: [sessionId: string];
editSessionTitle: [sessionId: string, title: string];
deleteSession: [sessionId: string, projectId: string];
}>();
const { tm } = useModuleI18n("features/chat");
const confirmDialog = useConfirmDialog();
const expanded = ref(props.initialExpanded);
const expanded = ref(readProjectsExpanded());
const expandedProjectIds = ref<Set<string>>(readExpandedProjectIds());
// localStorage
const savedProjectsExpandedState = localStorage.getItem("projectsExpanded");
if (savedProjectsExpandedState !== null) {
expanded.value = JSON.parse(savedProjectsExpandedState);
watch(
() => props.selectedProjectId,
(projectId) => {
if (projectId) setProjectExpanded(projectId, true);
},
);
watch(
() => props.projects.map((project) => project.project_id).join(","),
() => {
const validProjectIds = new Set(
props.projects.map((project) => project.project_id),
);
expandedProjectIds.value.forEach((projectId) => {
if (validProjectIds.has(projectId)) {
emit("toggleProject", projectId, true);
}
});
},
{ immediate: true },
);
function readProjectsExpanded() {
const savedState = localStorage.getItem("projectsExpanded");
if (savedState === null) return props.initialExpanded;
try {
return Boolean(JSON.parse(savedState));
} catch {
return props.initialExpanded;
}
}
function readExpandedProjectIds() {
try {
const raw = localStorage.getItem("chat.projectExpandedIds");
const parsed: unknown = raw ? JSON.parse(raw) : [];
return new Set(
Array.isArray(parsed)
? parsed.filter((item): item is string => typeof item === "string")
: [],
);
} catch {
return new Set<string>();
}
}
function toggleExpanded() {
@@ -127,36 +263,101 @@ function toggleExpanded() {
localStorage.setItem("projectsExpanded", JSON.stringify(expanded.value));
}
function persistExpandedProjectIds() {
localStorage.setItem(
"chat.projectExpandedIds",
JSON.stringify([...expandedProjectIds.value]),
);
}
function isProjectExpanded(projectId: string) {
return expandedProjectIds.value.has(projectId);
}
function setProjectExpanded(projectId: string, nextExpanded: boolean) {
if (isProjectExpanded(projectId) === nextExpanded) return;
const next = new Set(expandedProjectIds.value);
if (nextExpanded) next.add(projectId);
else next.delete(projectId);
expandedProjectIds.value = next;
persistExpandedProjectIds();
emit("toggleProject", projectId, nextExpanded);
}
function toggleProject(projectId: string) {
setProjectExpanded(projectId, !isProjectExpanded(projectId));
}
function handleProjectClick(project: Project) {
setProjectExpanded(project.project_id, true);
emit("selectProject", project.project_id);
}
function projectSessionList(projectId: string) {
return props.projectSessions[projectId] || [];
}
function sessionRunning(sessionId: string) {
return props.isSessionRunning?.(sessionId) || false;
}
function sessionTitle(session: ProjectSession) {
return session.display_name?.trim() || tm("conversation.newConversation");
}
async function handleDeleteProject(project: Project) {
const message = tm("project.confirmDelete", { title: project.title });
if (await askForConfirmation(message, confirmDialog)) {
emit("deleteProject", project.project_id);
}
}
async function handleDeleteSession(projectId: string, session: ProjectSession) {
const message = tm("conversation.confirmDelete", {
name: sessionTitle(session),
});
if (await askForConfirmation(message, confirmDialog)) {
emit("deleteSession", session.session_id, projectId);
}
}
</script>
<style scoped>
.project-section-header {
display: flex;
align-items: center;
padding: 0 8px;
opacity: 0.7;
}
.project-btn {
flex: 1;
justify-content: flex-start;
background-color: transparent !important;
border-radius: 20px;
padding: 8px 16px !important;
padding: 8px !important;
text-transform: none;
}
.project-item {
.project-list-wrap {
padding: 0 8px;
}
.project-item,
.project-session-item {
border-radius: 16px !important;
padding: 4px 12px !important;
margin-bottom: 2px;
}
.project-item:hover {
background-color: rgba(103, 58, 183, 0.05);
.project-item {
padding: 4px 8px !important;
}
.project-item:hover .project-actions {
opacity: 1;
visibility: visible;
.project-item:hover,
.project-item.active,
.project-session-item:hover,
.project-session-item.active {
background-color: rgba(103, 58, 183, 0.08);
}
.project-emoji {
@@ -164,38 +365,65 @@ async function handleDeleteProject(project: Project) {
margin-right: 6px;
}
.project-title {
.project-title,
.project-session-title {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
font-weight: 500;
}
.project-actions {
.project-actions,
.project-session-actions {
display: flex;
align-items: center;
gap: 2px;
opacity: 0;
visibility: hidden;
transition: all 0.2s ease;
}
.edit-project-btn,
.delete-project-btn {
opacity: 0.7;
transition: opacity 0.2s ease;
}
.edit-project-btn:hover,
.delete-project-btn:hover {
.project-item:hover .project-actions,
.project-session-item:hover .project-session-actions,
.project-session-item:focus-within .project-session-actions {
opacity: 1;
visibility: visible;
}
.project-action-btn {
opacity: 0.75;
}
.project-action-btn:hover {
opacity: 1;
}
.create-project-item {
border-radius: 16px !important;
padding: 4px 12px !important;
opacity: 0.7;
.project-session-list {
padding: 0 0 4px 24px;
}
.create-project-item:hover {
background-color: rgba(103, 58, 183, 0.08);
opacity: 1;
.project-session-item {
min-height: 30px;
display: flex;
align-items: center;
gap: 6px;
padding: 4px 4px 4px 12px;
cursor: pointer;
}
.project-session-title {
flex: 1;
}
.project-session-progress {
flex: 0 0 auto;
}
.project-session-empty {
padding: 5px 12px;
color: rgba(var(--v-theme-on-surface), 0.56);
font-size: 12px;
}
</style>
+46 -3
View File
@@ -10,6 +10,14 @@
<p v-if="project?.description" class="project-header-description">
{{ project.description }}
</p>
<div
v-if="workspaceSummary"
class="project-workspace-summary"
:title="workspaceSummary"
>
<v-icon icon="mdi-folder-cog-outline" size="15" />
<span>{{ workspaceSummary }}</span>
</div>
</div>
<div class="project-input-slot">
@@ -38,6 +46,7 @@
size="x-small"
variant="text"
class="edit-session-btn"
:title="tm('conversation.editDisplayName')"
@click.stop="
$emit(
'editSessionTitle',
@@ -52,6 +61,7 @@
variant="text"
class="delete-session-btn"
color="error"
:title="tm('actions.deleteChat')"
@click.stop="handleDeleteSession(session)"
/>
</div>
@@ -71,6 +81,7 @@
</template>
<script setup lang="ts">
import { computed } from "vue";
import type { Project } from "@/components/chat/ProjectList.vue";
import { useModuleI18n } from "@/i18n/composables";
import { askForConfirmation, useConfirmDialog } from "@/utils/confirmDialog";
@@ -86,7 +97,7 @@ interface Props {
sessions: Session[];
}
defineProps<Props>();
const props = defineProps<Props>();
const emit = defineEmits<{
selectSession: [sessionId: string];
@@ -95,15 +106,29 @@ const emit = defineEmits<{
}>();
const { tm } = useModuleI18n("features/chat");
const confirmDialog = useConfirmDialog();
const workspaceSummary = computed(() => {
const project = props.project;
if (!project) return "";
const workspaceType = project.workspace_type || "session";
if (workspaceType === "session") {
return tm("project.workspace.session");
}
const path = project.resolved_workspace_path || project.workspace_path || "";
const label = tm(`project.workspace.${workspaceType}`);
return path ? `${label} · ${path}` : label;
});
function formatDate(dateString: string): string {
return new Date(dateString).toLocaleString();
}
async function handleDeleteSession(session: Session) {
const sessionTitle = session.display_name || tm("conversation.newConversation");
const sessionTitle =
session.display_name || tm("conversation.newConversation");
const message = tm("conversation.confirmDelete", { name: sessionTitle });
if (await askForConfirmation(message, confirmDialog)) {
emit("deleteSession", session.session_id);
@@ -150,6 +175,24 @@ async function handleDeleteSession(session: Session) {
margin: 0;
}
.project-workspace-summary {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
max-width: 100%;
margin-top: 12px;
color: rgba(var(--v-theme-on-surface), 0.52);
font-size: 12px;
}
.project-workspace-summary span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.project-input-slot {
width: 100%;
max-width: 800px;
@@ -1,89 +1,205 @@
<template>
<v-menu v-model="menuOpen" :close-on-content-click="false" location="top" @update:model-value="handleMenuToggle">
<template v-slot:activator="{ props: menuProps }">
<v-chip v-bind="menuProps" class="text-none provider-chip" variant="outlined" size="small">
<v-icon start size="14">mdi-creation</v-icon>
<span v-if="selectedProviderId">
{{ selectedProviderId }}
</span>
<span v-else>Model</span>
</v-chip>
</template>
<v-card class="provider-menu-card" min-width="280" max-width="400">
<v-card-text class="pa-2">
<v-text-field
v-model="searchQuery"
placeholder="Search..."
hide-details
variant="plain"
flat
density="compact"
prepend-inner-icon="mdi-magnify"
class="ml-2 mb-2 mr-2"
clearable
/>
<v-list density="compact" nav class="provider-menu-list">
<v-list-item v-for="provider in filteredProviders" :key="provider.id"
:active="selectedProviderId === provider.id" @click="selectProvider(provider)" rounded="lg"
class="provider-menu-item">
<v-list-item-title class="text-body-2">{{ provider.id }}</v-list-item-title>
<v-list-item-subtitle class="provider-subtitle">
<span class="model-name">{{ provider.model }}</span>
<span class="meta-icons">
<v-tooltip text="支持图像输入" location="top" v-if="supportsImageInput(provider)">
<template v-slot:activator="{ props: tipProps }">
<v-icon v-bind="tipProps" size="12" color="grey">mdi-eye-outline</v-icon>
</template>
</v-tooltip>
<v-tooltip text="支持音频输入" location="top" v-if="supportsAudioInput(provider)">
<template v-slot:activator="{ props: tipProps }">
<v-icon v-bind="tipProps" size="12" color="grey">mdi-music-note-outline</v-icon>
</template>
</v-tooltip>
<v-tooltip text="支持工具调用" location="top" v-if="supportsToolCall(provider)">
<template v-slot:activator="{ props: tipProps }">
<v-icon v-bind="tipProps" size="12" color="grey">mdi-wrench</v-icon>
</template>
</v-tooltip>
<v-tooltip text="支持推理" location="top" v-if="supportsReasoning(provider)">
<template v-slot:activator="{ props: tipProps }">
<v-icon v-bind="tipProps" size="12" color="grey">mdi-brain</v-icon>
</template>
</v-tooltip>
</span>
</v-list-item-subtitle>
</v-list-item>
</v-list>
<div v-if="providerConfigs.length === 0" class="empty-hint">
No available models
</div>
</v-card-text>
</v-card>
</v-menu>
<v-menu
v-model="menuOpen"
:close-on-content-click="false"
:location="menuLocation"
offset="10"
transition="none"
@update:model-value="handleMenuToggle"
>
<template #activator="{ props: menuProps }">
<button
v-bind="menuProps"
class="provider-trigger"
:class="`provider-trigger--${variant}`"
type="button"
>
<span class="provider-trigger-copy">
<span class="provider-trigger-title">{{ triggerTitle }}</span>
<span v-if="triggerMeta" class="provider-trigger-meta">
{{ triggerMeta }}
</span>
</span>
<v-icon class="provider-trigger-chevron" size="18">
mdi-chevron-down
</v-icon>
</button>
</template>
<v-card class="provider-menu-card" elevation="0">
<div class="provider-menu-body">
<v-text-field
v-model="searchQuery"
placeholder="Search models"
hide-details
variant="outlined"
density="compact"
prepend-inner-icon="mdi-magnify"
class="provider-search"
clearable
/>
<v-list density="compact" nav class="provider-menu-list">
<v-list-item
v-for="provider in filteredProviders"
:key="provider.id"
:active="selectedProviderId === provider.id"
rounded="lg"
class="provider-menu-item"
@click="selectProvider(provider)"
>
<v-list-item-title class="provider-item-title">
{{ provider.id }}
</v-list-item-title>
<v-list-item-subtitle class="provider-subtitle">
<span class="model-name">{{ provider.model }}</span>
<span class="meta-icons">
<v-tooltip
v-for="item in capabilityBadges(provider)"
:key="item.key"
location="top"
max-width="320"
>
<template #activator="{ props: badgeTooltipProps }">
<span
v-bind="badgeTooltipProps"
class="meta-icon-badge"
:class="{ 'meta-icon-badge--disabled': !item.enabled }"
@click.stop
>
<v-icon size="13">{{ item.icon }}</v-icon>
</span>
</template>
<span>{{ item.tooltip }}</span>
</v-tooltip>
<v-tooltip
v-if="formatContextLimit(provider, metadataForProvider(provider))"
location="top"
max-width="320"
>
<template #activator="{ props: contextTooltipProps }">
<span
v-bind="contextTooltipProps"
class="meta-context-badge"
@click.stop
>
{{ formatContextLimit(provider, metadataForProvider(provider)) }}
</span>
</template>
<span>{{
tm("models.metadata.context", {
tokens: formatContextLimit(
provider,
metadataForProvider(provider),
),
})
}}</span>
</v-tooltip>
</span>
</v-list-item-subtitle>
<template #append>
<div class="provider-menu-actions" @click.stop>
<v-tooltip location="top">
<template #activator="{ props: testTooltipProps }">
<v-btn
v-bind="testTooltipProps"
icon="mdi-connection"
size="x-small"
variant="text"
:loading="testingProviderIds.includes(provider.id)"
:disabled="testingProviderIds.includes(provider.id)"
@click.stop="testProvider(provider)"
/>
</template>
<span>{{ tm("models.testButton") }}</span>
</v-tooltip>
<v-icon
v-if="selectedProviderId === provider.id"
class="provider-selected-icon"
size="18"
>
mdi-check
</v-icon>
</div>
</template>
</v-list-item>
</v-list>
<div v-if="loadingProviders" class="empty-hint">
Loading models...
</div>
<div v-else-if="filteredProviders.length === 0" class="empty-hint">
No available models
</div>
</div>
</v-card>
</v-menu>
</template>
<script setup lang="ts">
import axios from "axios";
import { computed, onMounted, ref } from "vue";
import { ref, computed, onMounted } from "vue";
import { providerApi } from "@/api/v1";
import { useModuleI18n } from "@/i18n/composables";
import { useToast } from "@/utils/toast";
import {
formatContextLimit,
providerCapabilityBadges,
type ProviderModelMetadata,
type ProviderMetadataSource,
} from "@/utils/providerMetadata";
interface ModelMetadata {
modalities?: { input?: string[] };
tool_call?: boolean;
reasoning?: boolean;
}
interface ProviderConfig {
interface ProviderConfig extends ProviderMetadataSource {
id: string;
model: string;
api_base?: string;
model_metadata?: ModelMetadata;
enable?: boolean;
}
const props = withDefaults(
defineProps<{
variant?: "input" | "header";
}>(),
{
variant: "input",
},
);
const SELECTED_PROVIDER_KEY = "selectedProvider";
const SELECTED_PROVIDER_MODEL_KEY = "selectedProviderModel";
const providerConfigs = ref<ProviderConfig[]>([]);
const selectedProviderId = ref("");
const selectedModelName = ref("");
const searchQuery = ref("");
const menuOpen = ref(false);
const loadingProviders = ref(false);
const providersLoaded = ref(false);
const testingProviderIds = ref<string[]>([]);
const modelMetadata = ref<Record<string, ProviderModelMetadata>>({});
const { tm } = useModuleI18n("features/provider");
const { success: toastSuccess, error: toastError } = useToast();
const variant = computed(() => props.variant);
const menuLocation = computed(() =>
props.variant === "header" ? "bottom start" : "top",
);
const selectedProvider = computed(() =>
providerConfigs.value.find(
(provider) => provider.id === selectedProviderId.value,
),
);
const triggerTitle = computed(() => {
if (selectedProvider.value?.id) return selectedProvider.value.id;
if (selectedProviderId.value) return selectedProviderId.value;
return props.variant === "header" ? "Default model" : "Model";
});
const triggerMeta = computed(() => {
const model = selectedProvider.value?.model || selectedModelName.value;
if (!model || model === triggerTitle.value) return "";
return model;
});
const filteredProviders = computed(() => {
if (!searchQuery.value) {
@@ -91,74 +207,107 @@ const filteredProviders = computed(() => {
}
const query = searchQuery.value.toLowerCase();
return providerConfigs.value.filter(
(p) => p.id.toLowerCase().includes(query) || p.model.toLowerCase().includes(query),
(provider) =>
provider.id.toLowerCase().includes(query) ||
provider.model.toLowerCase().includes(query),
);
});
function loadFromStorage() {
const savedProvider = localStorage.getItem("selectedProvider");
const savedProvider = localStorage.getItem(SELECTED_PROVIDER_KEY);
const savedModel = localStorage.getItem(SELECTED_PROVIDER_MODEL_KEY);
if (savedProvider) {
selectedProviderId.value = savedProvider;
}
}
function saveToStorage() {
if (selectedProviderId.value) {
localStorage.setItem("selectedProvider", selectedProviderId.value);
if (savedModel) {
selectedModelName.value = savedModel;
}
}
function loadProviderConfigs() {
axios
.get("/api/config/provider/list", {
params: { provider_type: "chat_completion" },
})
.then((response) => {
if (response.data.status === "ok") {
// enable false
providerConfigs.value = (response.data.data || []).filter((p: ProviderConfig) => p.enable !== false);
function saveToStorage(provider: ProviderConfig) {
localStorage.setItem(SELECTED_PROVIDER_KEY, provider.id);
localStorage.setItem(SELECTED_PROVIDER_MODEL_KEY, provider.model || "");
}
async function loadProviderConfigs(force = false) {
if (loadingProviders.value || (providersLoaded.value && !force)) return;
loadingProviders.value = true;
try {
const response = await providerApi.listByProviderType("chat_completion");
if (response.data.status === "ok") {
modelMetadata.value = (
response.data.model_metadata || {}
) as Record<string, ProviderModelMetadata>;
providerConfigs.value = (
(response.data.data || []) as unknown as ProviderConfig[]
).filter((provider: ProviderConfig) => provider.enable !== false);
providersLoaded.value = true;
const selected = selectedProvider.value;
if (selected) {
selectedModelName.value = selected.model || "";
saveToStorage(selected);
}
})
.catch((error) => {
console.error("获取提供商列表失败:", error);
});
}
} catch (error) {
console.error("Failed to load provider list:", error);
} finally {
loadingProviders.value = false;
}
}
function selectProvider(provider: ProviderConfig) {
selectedProviderId.value = provider.id;
saveToStorage();
selectedModelName.value = provider.model || "";
saveToStorage(provider);
menuOpen.value = false;
}
function supportsImageInput(provider: ProviderConfig): boolean {
const inputs = provider.model_metadata?.modalities?.input || [];
return inputs.includes("image");
function capabilityBadges(provider: ProviderConfig) {
return providerCapabilityBadges(provider, metadataForProvider(provider), tm);
}
function supportsAudioInput(provider: ProviderConfig): boolean {
const inputs = provider.model_metadata?.modalities?.input || [];
return inputs.includes("audio");
function metadataForProvider(provider: ProviderConfig) {
return provider.model ? modelMetadata.value[provider.model] || null : null;
}
function supportsToolCall(provider: ProviderConfig): boolean {
return Boolean(provider.model_metadata?.tool_call);
}
function supportsReasoning(provider: ProviderConfig): boolean {
return Boolean(provider.model_metadata?.reasoning);
async function testProvider(provider: ProviderConfig) {
if (testingProviderIds.value.includes(provider.id)) return;
testingProviderIds.value.push(provider.id);
try {
const startTime = performance.now();
const response = await providerApi.test(provider.id);
if (response.data.status === "ok" && response.data.data.error === null) {
const latency = Math.max(0, Math.round(performance.now() - startTime));
toastSuccess(
tm("models.testSuccessWithLatency", {
id: provider.id,
latency,
}),
);
} else {
throw new Error(response.data.data.error || tm("models.testError"));
}
} catch (error: any) {
toastError(
error.response?.data?.message || error.message || tm("models.testError"),
);
} finally {
testingProviderIds.value = testingProviderIds.value.filter(
(id) => id !== provider.id,
);
}
}
function getCurrentSelection() {
const provider = providerConfigs.value.find((p) => p.id === selectedProviderId.value);
return {
providerId: selectedProviderId.value,
modelName: provider?.model || "",
modelName: selectedProvider.value?.model || selectedModelName.value || "",
};
}
function handleMenuToggle(isOpen: boolean) {
if (isOpen) {
//
loadProviderConfigs();
loadProviderConfigs(true);
}
}
@@ -173,72 +322,217 @@ defineExpose({
</script>
<style scoped>
.provider-chip {
cursor: pointer;
height: 36px !important;
min-height: 36px !important;
border-color: rgba(var(--v-theme-on-surface), 0.18) !important;
background: transparent !important;
color: rgba(var(--v-theme-on-surface), 0.78) !important;
.provider-trigger {
display: inline-flex;
max-width: 100%;
min-width: 0;
align-items: center;
justify-content: flex-start;
gap: 6px;
border: 0;
background: transparent;
color: rgb(var(--v-theme-on-surface));
cursor: pointer;
font: inherit;
letter-spacing: 0;
text-align: left;
}
.provider-chip:hover {
border-color: rgba(var(--v-theme-on-surface), 0.34) !important;
background: rgba(var(--v-theme-on-surface), 0.04) !important;
.provider-trigger-copy {
display: inline-flex;
min-width: 0;
align-items: baseline;
gap: 10px;
}
.provider-trigger-title,
.provider-trigger-meta {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.provider-trigger--header {
height: 24px;
margin-top: 2px;
padding: 0;
}
.provider-trigger--header .provider-trigger-title {
font-size: 17px;
font-weight: 620;
line-height: 24px;
}
.provider-trigger--header .provider-trigger-meta {
color: rgba(var(--v-theme-on-surface), 0.55);
font-size: 13px;
font-weight: 500;
line-height: 18px;
}
.provider-trigger--input {
height: 40px;
max-width: min(280px, 42vw);
padding: 0 12px;
border: 1px solid rgba(var(--v-theme-on-surface), 0.18);
border-radius: 999px;
}
.provider-trigger--input:hover {
border-color: rgba(var(--v-theme-on-surface), 0.34);
background: rgba(var(--v-theme-on-surface), 0.04);
}
.provider-trigger--input .provider-trigger-title {
font-size: 14px;
font-weight: 500;
}
.provider-trigger--input .provider-trigger-meta {
display: none;
}
.provider-trigger-chevron {
flex: 0 0 auto;
opacity: 0.64;
}
.provider-menu-card {
border-radius: 12px !important;
width: min(420px, calc(100vw - 24px));
overflow: hidden;
border: 0;
border-radius: 14px !important;
background: rgb(var(--v-theme-surface));
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.08) !important;
}
.provider-menu-body {
padding: 10px;
}
.provider-search {
margin-bottom: 8px;
}
.provider-search :deep(.v-field) {
border-radius: 10px;
box-shadow: none;
}
.provider-search :deep(.v-field__outline) {
color: rgba(var(--v-theme-on-surface), 0.16);
}
.provider-menu-list {
max-height: 280px;
overflow-y: auto;
max-height: min(360px, 58vh);
overflow-y: auto;
padding: 0;
}
.provider-menu-item {
margin-bottom: 2px;
border-radius: 8px !important;
min-height: 44px !important;
min-height: 54px !important;
margin-bottom: 2px;
border-radius: 10px !important;
}
.provider-menu-item:hover {
background-color: rgba(103, 58, 183, 0.05);
background: rgba(var(--v-theme-on-surface), 0.05);
}
.provider-menu-item.v-list-item--active {
background-color: rgba(103, 58, 183, 0.1);
background: #f2f2f2;
color: rgb(var(--v-theme-on-surface));
}
.provider-item-title {
font-size: 14px;
font-weight: 600;
line-height: 18px;
}
.provider-subtitle {
display: flex;
align-items: center;
gap: 8px;
display: flex;
min-width: 0;
align-items: center;
gap: 8px;
}
.model-name {
font-size: 12px;
color: var(--v-theme-secondaryText);
min-width: 0;
overflow: hidden;
color: rgba(var(--v-theme-on-surface), 0.58);
font-size: 12px;
line-height: 16px;
text-overflow: ellipsis;
white-space: nowrap;
}
.meta-icons {
display: flex;
align-items: center;
gap: 4px;
display: flex;
flex: 0 0 auto;
align-items: center;
gap: 4px;
color: rgba(var(--v-theme-on-surface), 0.5);
}
.meta-icon-badge {
display: inline-flex;
align-items: center;
color: rgba(var(--v-theme-on-surface), 0.72);
}
.meta-icon-badge--disabled {
color: rgba(var(--v-theme-on-surface), 0.34);
}
.meta-context-badge {
display: inline-flex;
align-items: center;
height: 16px;
padding: 0 5px;
border-radius: 999px;
background: rgba(var(--v-theme-on-surface), 0.06);
color: rgba(var(--v-theme-on-surface), 0.72);
font-size: 10px;
font-weight: 650;
line-height: 16px;
}
.provider-menu-actions {
display: inline-flex;
align-items: center;
gap: 2px;
}
.provider-selected-icon {
color: rgba(var(--v-theme-on-surface), 0.72);
}
.empty-hint {
font-size: 12px;
color: var(--v-theme-secondaryText);
text-align: center;
padding: 16px;
opacity: 0.6;
padding: 16px;
color: rgba(var(--v-theme-on-surface), 0.5);
font-size: 12px;
text-align: center;
}
:global(.v-theme--dark) .provider-menu-item.v-list-item--active {
background: rgba(255, 255, 255, 0.1);
}
@media (max-width: 768px) {
.provider-chip {
height: 32px !important;
min-height: 32px !important;
}
.provider-trigger--header .provider-trigger-title {
font-size: 16px;
}
.provider-trigger--header .provider-trigger-meta {
display: none;
}
.provider-trigger--input {
height: 38px;
max-width: 48vw;
}
}
</style>

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