mirror of
https://github.com/AstrBotDevs/AstrBot.git
synced 2026-09-01 15:32:49 +08:00
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:
@@ -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
|
||||
@@ -8,6 +8,7 @@ uv.lock
|
||||
# IDE and editors
|
||||
.vscode
|
||||
.idea
|
||||
.zed/
|
||||
|
||||
# Logs and temporary files
|
||||
botpy.log
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||

|
||||

|
||||
|
||||
|
||||
<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
@@ -0,0 +1,289 @@
|
||||

|
||||
|
||||
|
||||
<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="Featured|HelloGitHub" 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.
|
||||
|
||||

|
||||
|
||||
## 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 ☁️:
|
||||
|
||||
[](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.
|
||||
|
||||
[](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">
|
||||
|
||||
[](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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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."""
|
||||
...
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)}",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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}"
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 ["."])
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}"
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}"
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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,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="获取文档列表失败",
|
||||
)
|
||||
|
||||
@@ -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))
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
|
||||
Generated
+1811
-3314
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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";
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user