Compare commits

..

1 Commits

76 changed files with 2131 additions and 5195 deletions
-36
View File
@@ -1,36 +0,0 @@
# Set to true to add reviewers to pull requests
addReviewers: true
# Set to true to add assignees to pull requests
addAssignees: false
# A list of reviewers to be added to pull requests (GitHub user name)
reviewers:
- Soulter
- Raven95676
- Larch-C
- anka-afk
- advent259141
# - zouyonghe
# A number of reviewers added to the pull request
# Set 0 to add all the reviewers (default: 0)
numberOfReviewers: 2
# A list of assignees, overrides reviewers if set
# assignees:
# - assigneeA
# A number of assignees to add to the pull request
# Set to 0 to add all of the assignees.
# Uses numberOfReviewers if unset.
# numberOfAssignees: 2
# A list of keywords to be skipped the process that add reviewers if pull requests include it
skipKeywords:
- wip
- draft
# A list of users to be skipped by both the add reviewers and add assignees processes
# skipUsers:
# - dependabot[bot]
+1 -1
View File
@@ -73,7 +73,7 @@ jobs:
uses: actions/checkout@v5 uses: actions/checkout@v5
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v6 uses: actions/setup-python@v5
with: with:
python-version: '3.10' python-version: '3.10'
+1 -1
View File
@@ -22,7 +22,7 @@ jobs:
fetch-depth: 0 fetch-depth: 0
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v6 uses: actions/setup-python@v5
- name: Install dependencies - name: Install dependencies
run: | run: |
+2 -29
View File
@@ -27,33 +27,6 @@ jobs:
if: github.event_name == 'workflow_dispatch' if: github.event_name == 'workflow_dispatch'
run: git checkout ${{ steps.get-latest-tag.outputs.latest_tag }} run: git checkout ${{ steps.get-latest-tag.outputs.latest_tag }}
- name: Check if version is pre-release
id: check-prerelease
run: |
if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then
version="${{ steps.get-latest-tag.outputs.latest_tag }}"
else
version="${{ github.ref_name }}"
fi
if [[ "$version" == *"beta"* ]] || [[ "$version" == *"alpha"* ]]; then
echo "is_prerelease=true" >> $GITHUB_OUTPUT
echo "Version $version is a pre-release, will not push latest tag"
else
echo "is_prerelease=false" >> $GITHUB_OUTPUT
echo "Version $version is a stable release, will push latest tag"
fi
- name: Build Dashboard
run: |
cd dashboard
npm install
npm run build
mkdir -p dist/assets
echo $(git rev-parse HEAD) > dist/assets/version
cd ..
mkdir -p data
cp -r dashboard/dist data/
- name: Set QEMU - name: Set QEMU
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v3
@@ -80,9 +53,9 @@ jobs:
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
push: true push: true
tags: | tags: |
${{ steps.check-prerelease.outputs.is_prerelease == 'false' && format('{0}/astrbot:latest', secrets.DOCKER_HUB_USERNAME) || '' }} ${{ secrets.DOCKER_HUB_USERNAME }}/astrbot:latest
${{ secrets.DOCKER_HUB_USERNAME }}/astrbot:${{ github.event_name == 'workflow_dispatch' && steps.get-latest-tag.outputs.latest_tag || github.ref_name }} ${{ secrets.DOCKER_HUB_USERNAME }}/astrbot:${{ github.event_name == 'workflow_dispatch' && steps.get-latest-tag.outputs.latest_tag || github.ref_name }}
${{ steps.check-prerelease.outputs.is_prerelease == 'false' && 'ghcr.io/soulter/astrbot:latest' || '' }} ghcr.io/soulter/astrbot:latest
ghcr.io/soulter/astrbot:${{ github.event_name == 'workflow_dispatch' && steps.get-latest-tag.outputs.latest_tag || github.ref_name }} ghcr.io/soulter/astrbot:${{ github.event_name == 'workflow_dispatch' && steps.get-latest-tag.outputs.latest_tag || github.ref_name }}
- name: Post build notifications - name: Post build notifications
+1 -1
View File
@@ -18,7 +18,7 @@ jobs:
pull-requests: write pull-requests: write
steps: steps:
- uses: actions/stale@v10 - uses: actions/stale@v9
with: with:
repo-token: ${{ secrets.GITHUB_TOKEN }} repo-token: ${{ secrets.GITHUB_TOKEN }}
stale-issue-message: 'Stale issue message' stale-issue-message: 'Stale issue message'
+51 -38
View File
@@ -6,6 +6,8 @@
<div align="center"> <div align="center">
_✨ 易上手的多平台 LLM 聊天机器人及开发框架 ✨_
<a href="https://trendshift.io/repositories/12875" target="_blank"><img src="https://trendshift.io/api/badge/repositories/12875" alt="Soulter%2FAstrBot | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a> <a href="https://trendshift.io/repositories/12875" target="_blank"><img src="https://trendshift.io/api/badge/repositories/12875" alt="Soulter%2FAstrBot | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
[![GitHub release (latest by date)](https://img.shields.io/github/v/release/Soulter/AstrBot?style=for-the-badge&color=76bad9)](https://github.com/Soulter/AstrBot/releases/latest) [![GitHub release (latest by date)](https://img.shields.io/github/v/release/Soulter/AstrBot?style=for-the-badge&color=76bad9)](https://github.com/Soulter/AstrBot/releases/latest)
@@ -14,6 +16,7 @@
<a href="https://qm.qq.com/cgi-bin/qm/qr?k=wtbaNx7EioxeaqS9z7RQWVXPIxg2zYr7&jump_from=webapi&authKey=vlqnv/AV2DbJEvGIcxdlNSpfxVy+8vVqijgreRdnVKOaydpc+YSw4MctmEbr0k5"><img alt="QQ_community" src="https://img.shields.io/badge/QQ群-775869627-purple?style=for-the-badge&color=76bad9"></a> <a href="https://qm.qq.com/cgi-bin/qm/qr?k=wtbaNx7EioxeaqS9z7RQWVXPIxg2zYr7&jump_from=webapi&authKey=vlqnv/AV2DbJEvGIcxdlNSpfxVy+8vVqijgreRdnVKOaydpc+YSw4MctmEbr0k5"><img alt="QQ_community" src="https://img.shields.io/badge/QQ群-775869627-purple?style=for-the-badge&color=76bad9"></a>
<a href="https://t.me/+hAsD2Ebl5as3NmY1"><img alt="Telegram_community" src="https://img.shields.io/badge/Telegram-AstrBot-purple?style=for-the-badge&color=76bad9"></a> <a href="https://t.me/+hAsD2Ebl5as3NmY1"><img alt="Telegram_community" src="https://img.shields.io/badge/Telegram-AstrBot-purple?style=for-the-badge&color=76bad9"></a>
[![wakatime](https://wakatime.com/badge/user/915e5316-99c6-4563-a483-ef186cf000c9/project/018e705a-a1a7-409a-a849-3013485e6c8e.svg?style=for-the-badge&color=76bad9)](https://wakatime.com/badge/user/915e5316-99c6-4563-a483-ef186cf000c9/project/018e705a-a1a7-409a-a849-3013485e6c8e) [![wakatime](https://wakatime.com/badge/user/915e5316-99c6-4563-a483-ef186cf000c9/project/018e705a-a1a7-409a-a849-3013485e6c8e.svg?style=for-the-badge&color=76bad9)](https://wakatime.com/badge/user/915e5316-99c6-4563-a483-ef186cf000c9/project/018e705a-a1a7-409a-a849-3013485e6c8e)
![Dynamic JSON Badge](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.soulter.top%2Fastrbot%2Fstats&query=v&label=7日消息量&cacheSeconds=3600&style=for-the-badge&color=3b618e)
![Dynamic JSON Badge](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.soulter.top%2Fastrbot%2Fplugin-num&query=%24.result&suffix=%E4%B8%AA&style=for-the-badge&label=%E6%8F%92%E4%BB%B6%E5%B8%82%E5%9C%BA&cacheSeconds=3600) ![Dynamic JSON Badge](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.soulter.top%2Fastrbot%2Fplugin-num&query=%24.result&suffix=%E4%B8%AA&style=for-the-badge&label=%E6%8F%92%E4%BB%B6%E5%B8%82%E5%9C%BA&cacheSeconds=3600)
<a href="https://github.com/Soulter/AstrBot/blob/master/README_en.md">English</a> <a href="https://github.com/Soulter/AstrBot/blob/master/README_en.md">English</a>
@@ -24,7 +27,7 @@
AstrBot 是一个开源的一站式 Agentic 聊天机器人平台及开发框架。 AstrBot 是一个开源的一站式 Agentic 聊天机器人平台及开发框架。
## 主要功能 ## 主要功能
1. **大模型对话**。支持接入多种大模型服务。支持多模态、工具调用、MCP、原生知识库、人设等功能。 1. **大模型对话**。支持接入多种大模型服务。支持多模态、工具调用、MCP、原生知识库、人设等功能。
2. **多消息平台支持**。支持接入 QQ、企业微信、微信公众号、飞书、Telegram、钉钉、Discord、KOOK 等平台。支持速率限制、白名单、百度内容审核。 2. **多消息平台支持**。支持接入 QQ、企业微信、微信公众号、飞书、Telegram、钉钉、Discord、KOOK 等平台。支持速率限制、白名单、百度内容审核。
@@ -32,7 +35,7 @@ AstrBot 是一个开源的一站式 Agentic 聊天机器人平台及开发框架
4. **插件扩展**。深度优化的插件机制,支持[开发插件](https://astrbot.app/dev/plugin.html)扩展功能,社区插件生态丰富。 4. **插件扩展**。深度优化的插件机制,支持[开发插件](https://astrbot.app/dev/plugin.html)扩展功能,社区插件生态丰富。
5. **WebUI**。可视化配置和管理机器人,功能齐全。 5. **WebUI**。可视化配置和管理机器人,功能齐全。
## 部署方式 ## ✨ 使用方式
#### Docker 部署 #### Docker 部署
@@ -76,7 +79,9 @@ AstrBot 已由雨云官方上架至云应用平台,可一键部署。
#### 手动部署 #### 手动部署
首先安装 uv > 推荐使用 `uv`。
首先,安装 uv
```bash ```bash
pip install uv pip install uv
@@ -91,26 +96,6 @@ uv run main.py
或者请参阅官方文档 [通过源码部署 AstrBot](https://astrbot.app/deploy/astrbot/cli.html) 。 或者请参阅官方文档 [通过源码部署 AstrBot](https://astrbot.app/deploy/astrbot/cli.html) 。
## 🌍 社区
### QQ 群组
- 1 群:322154837
- 3 群:630166526
- 5 群:822130018
- 6 群:753075035
- 开发者群:975206796
- 开发者群(备份):295657329
### Telegram 群组
<a href="https://t.me/+hAsD2Ebl5as3NmY1"><img alt="Telegram_community" src="https://img.shields.io/badge/Telegram-AstrBot-purple?style=for-the-badge&color=76bad9"></a>
### Discord 群组
<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>
## ⚡ 消息平台支持情况 ## ⚡ 消息平台支持情况
| 平台 | 支持性 | | 平台 | 支持性 |
@@ -127,18 +112,22 @@ uv run main.py
| Discord | ✔ | | Discord | ✔ |
| [KOOK](https://github.com/wuyan1003/astrbot_plugin_kook_adapter) | ✔ | | [KOOK](https://github.com/wuyan1003/astrbot_plugin_kook_adapter) | ✔ |
| [VoceChat](https://github.com/HikariFroya/astrbot_plugin_vocechat) | ✔ | | [VoceChat](https://github.com/HikariFroya/astrbot_plugin_vocechat) | ✔ |
| 微信对话开放平台 | 🚧 |
| WhatsApp | 🚧 |
| 小爱音响 | 🚧 |
## ⚡ 提供商支持情况 ## ⚡ 提供商支持情况
| 名称 | 支持性 | 类型 | 备注 | | 名称 | 支持性 | 类型 | 备注 |
| -------- | ------- | ------- | ------- | | -------- | ------- | ------- | ------- |
| OpenAI | ✔ | 文本生成 | 支持任何兼容 OpenAI API 的服务 | | OpenAI API | ✔ | 文本生成 | 支持 DeepSeek、Gemini、Kimi、xAI 等兼容 OpenAI API 的服务 |
| Anthropic | ✔ | 文本生成 | | | Claude API | ✔ | 文本生成 | |
| Google Gemini | ✔ | 文本生成 | | | Google Gemini API | ✔ | 文本生成 | |
| Dify | ✔ | LLMOps | | | Dify | ✔ | LLMOps | |
| 阿里云百炼应用 | ✔ | LLMOps | | | 阿里云百炼应用 | ✔ | LLMOps | |
| Ollama | ✔ | 模型加载器 | 本地部署 DeepSeek、Llama 等开源语言模型 | | Ollama | ✔ | 模型加载器 | 本地部署 DeepSeek、Llama 等开源语言模型 |
| LM Studio | ✔ | 模型加载器 | 本地部署 DeepSeek、Llama 等开源语言模型 | | LM Studio | ✔ | 模型加载器 | 本地部署 DeepSeek、Llama 等开源语言模型 |
| LLMTuner | ✔ | 模型加载器 | 本地加载 lora 等微调模型 |
| [优云智算](https://www.compshare.cn/?ytag=GPU_YY-gh_astrbot&referral_code=FV7DcGowN4hB5UuXKgpE74) | ✔ | 模型 API 及算力服务平台 | | | [优云智算](https://www.compshare.cn/?ytag=GPU_YY-gh_astrbot&referral_code=FV7DcGowN4hB5UuXKgpE74) | ✔ | 模型 API 及算力服务平台 | |
| [302.AI](https://share.302.ai/rr1M3l) | ✔ | 模型 API 服务平台 | | | [302.AI](https://share.302.ai/rr1M3l) | ✔ | 模型 API 服务平台 | |
| 硅基流动 | ✔ | 模型 API 服务平台 | | | 硅基流动 | ✔ | 模型 API 服务平台 | |
@@ -154,6 +143,7 @@ uv run main.py
| 阿里云百炼 TTS | ✔ | 文本转语音 | | | 阿里云百炼 TTS | ✔ | 文本转语音 | |
| Azure TTS | ✔ | 文本转语音 | Microsoft Azure TTS | | Azure TTS | ✔ | 文本转语音 | Microsoft Azure TTS |
## ❤️ 贡献 ## ❤️ 贡献
欢迎任何 Issues/Pull Requests!只需要将你的更改提交到此项目 :) 欢迎任何 Issues/Pull Requests!只需要将你的更改提交到此项目 :)
@@ -172,6 +162,38 @@ pip install pre-commit
pre-commit install pre-commit install
``` ```
## 🌟 支持
- Star 这个项目!
- 在[爱发电](https://afdian.com/a/soulter)支持我!
## ✨ Demo
<details><summary>👉 点击展开多张 Demo 截图 👈</summary>
<div align='center'>
<img src="https://github.com/user-attachments/assets/4ee688d9-467d-45c8-99d6-368f9a8a92d8" width="600">
_✨基于 Docker 的沙箱化代码执行器(Beta 测试)✨_
<img src="https://github.com/user-attachments/assets/0378f407-6079-4f64-ae4c-e97ab20611d2" height=500>
_✨ 多模态、网页搜索、长文本转图片(可配置) ✨_
<img src="https://github.com/user-attachments/assets/e137a9e1-340a-4bf2-bb2b-771132780735" height=150>
<img src="https://github.com/user-attachments/assets/480f5e82-cf6a-4955-a869-0d73137aa6e1" height=150>
_✨ 插件系统——部分插件展示 ✨_
<img src="https://github.com/user-attachments/assets/0cdbf564-2f59-4da5-b524-ce0e7ef3d978" width=600>
_✨ WebUI ✨_
</div>
</details>
## ❤️ Special Thanks ## ❤️ Special Thanks
@@ -181,18 +203,10 @@ pre-commit install
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot" /> <img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot" />
</a> </a>
此外,本项目的诞生离不开以下开源项目的帮助 此外,本项目的诞生离不开以下开源项目:
- [NapNeko/NapCatQQ](https://github.com/NapNeko/NapCatQQ) - 伟大的猫猫框架 - [NapNeko/NapCatQQ](https://github.com/NapNeko/NapCatQQ) - 伟大的猫猫框架
- [wechatpy/wechatpy](https://github.com/wechatpy/wechatpy)
另外,一些同类型其他的活跃开源 Bot 项目:
- [nonebot/nonebot2](https://github.com/nonebot/nonebot2) - 扩展性极强的 Bot 框架
- [koishijs/koishi](https://github.com/koishijs/koishi) - 扩展性极强的 Bot 框架
- [MaiM-with-u/MaiBot](https://github.com/MaiM-with-u/MaiBot) - 注重拟人功能的 ChatBot
- [langbot-app/LangBot](https://github.com/langbot-app/LangBot) - 功能丰富的 Bot 平台
- [LroMiose/nekro-agent](https://github.com/KroMiose/nekro-agent) - 注重 Agent 的 ChatBot
- [zhenxun-org/zhenxun_bot](https://github.com/zhenxun-org/zhenxun_bot) - 功能完善的 ChatBot
## ⭐ Star History ## ⭐ Star History
@@ -205,8 +219,7 @@ pre-commit install
</div> </div>
![10k-star-banner-credit-by-kevin](https://github.com/user-attachments/assets/c97fc5fb-20b9-4bc8-9998-c20b930ab097)
</details>
_私は、高性能ですから!_ _私は、高性能ですから!_
-2
View File
@@ -7,7 +7,6 @@ from astrbot.core.star.register import (
register_permission_type as permission_type, register_permission_type as permission_type,
register_custom_filter as custom_filter, register_custom_filter as custom_filter,
register_on_astrbot_loaded as on_astrbot_loaded, register_on_astrbot_loaded as on_astrbot_loaded,
register_on_platform_loaded as on_platform_loaded,
register_on_llm_request as on_llm_request, register_on_llm_request as on_llm_request,
register_on_llm_response as on_llm_response, register_on_llm_response as on_llm_response,
register_llm_tool as llm_tool, register_llm_tool as llm_tool,
@@ -42,7 +41,6 @@ __all__ = [
"custom_filter", "custom_filter",
"PermissionType", "PermissionType",
"on_astrbot_loaded", "on_astrbot_loaded",
"on_platform_loaded",
"on_llm_request", "on_llm_request",
"llm_tool", "llm_tool",
"on_decorating_result", "on_decorating_result",
+3 -12
View File
@@ -37,10 +37,7 @@ async def check_dashboard(astrbot_root: Path) -> None:
): ):
click.echo("正在安装管理面板...") click.echo("正在安装管理面板...")
await download_dashboard( await download_dashboard(
path="data/dashboard.zip", path="data/dashboard.zip", extract_path=str(astrbot_root)
extract_path=str(astrbot_root),
version=f"v{VERSION}",
latest=False,
) )
click.echo("管理面板安装完成") click.echo("管理面板安装完成")
@@ -53,10 +50,7 @@ async def check_dashboard(astrbot_root: Path) -> None:
version = dashboard_version.split("v")[1] version = dashboard_version.split("v")[1]
click.echo(f"管理面板版本: {version}") click.echo(f"管理面板版本: {version}")
await download_dashboard( await download_dashboard(
path="data/dashboard.zip", path="data/dashboard.zip", extract_path=str(astrbot_root)
extract_path=str(astrbot_root),
version=f"v{VERSION}",
latest=False,
) )
except Exception as e: except Exception as e:
click.echo(f"下载管理面板失败: {e}") click.echo(f"下载管理面板失败: {e}")
@@ -65,10 +59,7 @@ async def check_dashboard(astrbot_root: Path) -> None:
click.echo("初始化管理面板目录...") click.echo("初始化管理面板目录...")
try: try:
await download_dashboard( await download_dashboard(
path=str(astrbot_root / "dashboard.zip"), path=str(astrbot_root / "dashboard.zip"), extract_path=str(astrbot_root)
extract_path=str(astrbot_root),
version=f"v{VERSION}",
latest=False,
) )
click.echo("管理面板初始化完成") click.echo("管理面板初始化完成")
except Exception as e: except Exception as e:
+9 -16
View File
@@ -36,21 +36,13 @@ class AstrBotConfigManager:
self.confs: dict[str, AstrBotConfig] = {} self.confs: dict[str, AstrBotConfig] = {}
"""uuid / "default" -> AstrBotConfig""" """uuid / "default" -> AstrBotConfig"""
self.confs["default"] = default_config self.confs["default"] = default_config
self.abconf_data = None
self._load_all_configs() self._load_all_configs()
def _get_abconf_data(self) -> dict:
"""获取所有的 abconf 数据"""
if self.abconf_data is None:
self.abconf_data = self.sp.get(
"abconf_mapping", {}, scope="global", scope_id="global"
)
return self.abconf_data
def _load_all_configs(self): def _load_all_configs(self):
"""Load all configurations from the shared preferences.""" """Load all configurations from the shared preferences."""
abconf_data = self._get_abconf_data() abconf_data = self.sp.get(
self.abconf_data = abconf_data "abconf_mapping", {}, scope="global", scope_id="global"
)
for uuid_, meta in abconf_data.items(): for uuid_, meta in abconf_data.items():
filename = meta["path"] filename = meta["path"]
conf_path = os.path.join(get_astrbot_config_path(), filename) conf_path = os.path.join(get_astrbot_config_path(), filename)
@@ -80,7 +72,9 @@ class AstrBotConfigManager:
ConfInfo: 包含配置文件的 uuid, 路径和名称等信息, 是一个 dict 类型 ConfInfo: 包含配置文件的 uuid, 路径和名称等信息, 是一个 dict 类型
""" """
# uuid -> { "umop": list, "path": str, "name": str } # uuid -> { "umop": list, "path": str, "name": str }
abconf_data = self._get_abconf_data() abconf_data = self.sp.get(
"abconf_mapping", {}, scope="global", scope_id="global"
)
if isinstance(umo, MessageSession): if isinstance(umo, MessageSession):
umo = str(umo) umo = str(umo)
else: else:
@@ -121,7 +115,6 @@ class AstrBotConfigManager:
"name": random_word, "name": random_word,
} }
self.sp.put("abconf_mapping", abconf_data, scope="global", scope_id="global") self.sp.put("abconf_mapping", abconf_data, scope="global", scope_id="global")
self.abconf_data = abconf_data
def get_conf(self, umo: str | MessageSession | None) -> AstrBotConfig: def get_conf(self, umo: str | MessageSession | None) -> AstrBotConfig:
"""获取指定 umo 的配置文件。如果不存在,则 fallback 到默认配置文件。""" """获取指定 umo 的配置文件。如果不存在,则 fallback 到默认配置文件。"""
@@ -154,7 +147,9 @@ class AstrBotConfigManager:
"""获取所有配置文件的元数据列表""" """获取所有配置文件的元数据列表"""
conf_list = [] conf_list = []
conf_list.append(DEFAULT_CONFIG_CONF_INFO) conf_list.append(DEFAULT_CONFIG_CONF_INFO)
abconf_mapping = self._get_abconf_data() abconf_mapping = self.sp.get(
"abconf_mapping", {}, scope="global", scope_id="global"
)
for uuid_, meta in abconf_mapping.items(): for uuid_, meta in abconf_mapping.items():
conf_list.append(ConfInfo(**meta, id=uuid_)) conf_list.append(ConfInfo(**meta, id=uuid_))
return conf_list return conf_list
@@ -223,7 +218,6 @@ class AstrBotConfigManager:
# 从映射中移除 # 从映射中移除
del abconf_data[conf_id] del abconf_data[conf_id]
self.sp.put("abconf_mapping", abconf_data, scope="global", scope_id="global") self.sp.put("abconf_mapping", abconf_data, scope="global", scope_id="global")
self.abconf_data = abconf_data
logger.info(f"成功删除配置文件 {conf_id}") logger.info(f"成功删除配置文件 {conf_id}")
return True return True
@@ -269,7 +263,6 @@ class AstrBotConfigManager:
# 保存更新 # 保存更新
self.sp.put("abconf_mapping", abconf_data, scope="global", scope_id="global") self.sp.put("abconf_mapping", abconf_data, scope="global", scope_id="global")
self.abconf_data = abconf_data
logger.info(f"成功更新配置文件 {conf_id} 的信息") logger.info(f"成功更新配置文件 {conf_id} 的信息")
return True return True
+37 -89
View File
@@ -6,7 +6,7 @@ import os
from astrbot.core.utils.astrbot_path import get_astrbot_data_path from astrbot.core.utils.astrbot_path import get_astrbot_data_path
VERSION = "4.1.0" VERSION = "4.0.0"
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db") DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
# 默认配置 # 默认配置
@@ -51,12 +51,16 @@ DEFAULT_CONFIG = {
"enable": True, "enable": True,
"default_provider_id": "", "default_provider_id": "",
"default_image_caption_provider_id": "", "default_image_caption_provider_id": "",
"default_summarize_provider_id": "",
"context_exceed_calc_method": "token_size",
"max_token_size": 128000,
"max_context_length": 100,
"image_caption_prompt": "Please describe the image using Chinese.", "image_caption_prompt": "Please describe the image using Chinese.",
"provider_pool": ["*"], # "*" 表示使用所有可用的提供者 "provider_pool": ["*"], # "*" 表示使用所有可用的提供者
"wake_prefix": "", "wake_prefix": "",
"web_search": False, "web_search": False,
"websearch_provider": "default", "websearch_provider": "default",
"websearch_tavily_key": [], "websearch_tavily_key": "",
"web_search_link": False, "web_search_link": False,
"display_reasoning_text": False, "display_reasoning_text": False,
"identifier": False, "identifier": False,
@@ -64,7 +68,6 @@ DEFAULT_CONFIG = {
"default_personality": "default", "default_personality": "default",
"persona_pool": ["*"], "persona_pool": ["*"],
"prompt_prefix": "", "prompt_prefix": "",
"max_context_length": -1,
"dequeue_context_length": 1, "dequeue_context_length": 1,
"streaming_response": False, "streaming_response": False,
"show_tool_use_status": False, "show_tool_use_status": False,
@@ -103,7 +106,6 @@ DEFAULT_CONFIG = {
"t2i_strategy": "remote", "t2i_strategy": "remote",
"t2i_endpoint": "", "t2i_endpoint": "",
"t2i_use_file_service": False, "t2i_use_file_service": False,
"t2i_active_template": "base",
"http_proxy": "", "http_proxy": "",
"no_proxy": ["localhost", "127.0.0.1", "::1"], "no_proxy": ["localhost", "127.0.0.1", "::1"],
"dashboard": { "dashboard": {
@@ -247,49 +249,8 @@ CONFIG_METADATA_2 = {
"slack_webhook_port": 6197, "slack_webhook_port": 6197,
"slack_webhook_path": "/astrbot-slack-webhook/callback", "slack_webhook_path": "/astrbot-slack-webhook/callback",
}, },
"Satori": {
"id": "satori",
"type": "satori",
"enable": False,
"satori_api_base_url": "http://localhost:5140/satori/v1",
"satori_endpoint": "ws://127.0.0.1:5140/satori/v1/events",
"satori_token": "",
"satori_auto_reconnect": True,
"satori_heartbeat_interval": 10,
"satori_reconnect_delay": 5,
},
}, },
"items": { "items": {
"satori_api_base_url": {
"description": "Satori API Base URL",
"type": "string",
"hint": "The base URL for the Satori API.",
},
"satori_endpoint": {
"description": "Satori WebSocket Endpoint",
"type": "string",
"hint": "The WebSocket endpoint for Satori events.",
},
"satori_token": {
"description": "Satori Token",
"type": "string",
"hint": "The token used for authenticating with the Satori API.",
},
"satori_auto_reconnect": {
"description": "Enable Auto Reconnect",
"type": "bool",
"hint": "Whether to automatically reconnect the WebSocket on disconnection.",
},
"satori_heartbeat_interval": {
"description": "Satori Heartbeat Interval",
"type": "int",
"hint": "The interval (in seconds) for sending heartbeat messages.",
},
"satori_reconnect_delay": {
"description": "Satori Reconnect Delay",
"type": "int",
"hint": "The delay (in seconds) before attempting to reconnect.",
},
"slack_connection_mode": { "slack_connection_mode": {
"description": "Slack Connection Mode", "description": "Slack Connection Mode",
"type": "string", "type": "string",
@@ -599,7 +560,6 @@ CONFIG_METADATA_2 = {
"api_base": "https://api.openai.com/v1", "api_base": "https://api.openai.com/v1",
"timeout": 120, "timeout": 120,
"model_config": {"model": "gpt-4o-mini", "temperature": 0.4}, "model_config": {"model": "gpt-4o-mini", "temperature": 0.4},
"custom_extra_body": {},
"modalities": ["text", "image", "tool_use"], "modalities": ["text", "image", "tool_use"],
"hint": "也兼容所有与 OpenAI API 兼容的服务。", "hint": "也兼容所有与 OpenAI API 兼容的服务。",
}, },
@@ -614,7 +574,6 @@ CONFIG_METADATA_2 = {
"api_base": "", "api_base": "",
"timeout": 120, "timeout": 120,
"model_config": {"model": "gpt-4o-mini", "temperature": 0.4}, "model_config": {"model": "gpt-4o-mini", "temperature": 0.4},
"custom_extra_body": {},
"modalities": ["text", "image", "tool_use"], "modalities": ["text", "image", "tool_use"],
}, },
"xAI": { "xAI": {
@@ -627,7 +586,6 @@ CONFIG_METADATA_2 = {
"api_base": "https://api.x.ai/v1", "api_base": "https://api.x.ai/v1",
"timeout": 120, "timeout": 120,
"model_config": {"model": "grok-2-latest", "temperature": 0.4}, "model_config": {"model": "grok-2-latest", "temperature": 0.4},
"custom_extra_body": {},
"modalities": ["text", "image", "tool_use"], "modalities": ["text", "image", "tool_use"],
}, },
"Anthropic": { "Anthropic": {
@@ -657,7 +615,6 @@ CONFIG_METADATA_2 = {
"key": ["ollama"], # ollama 的 key 默认是 ollama "key": ["ollama"], # ollama 的 key 默认是 ollama
"api_base": "http://localhost:11434/v1", "api_base": "http://localhost:11434/v1",
"model_config": {"model": "llama3.1-8b", "temperature": 0.4}, "model_config": {"model": "llama3.1-8b", "temperature": 0.4},
"custom_extra_body": {},
"modalities": ["text", "image", "tool_use"], "modalities": ["text", "image", "tool_use"],
}, },
"LM Studio": { "LM Studio": {
@@ -671,7 +628,6 @@ CONFIG_METADATA_2 = {
"model_config": { "model_config": {
"model": "llama-3.1-8b", "model": "llama-3.1-8b",
}, },
"custom_extra_body": {},
"modalities": ["text", "image", "tool_use"], "modalities": ["text", "image", "tool_use"],
}, },
"Gemini(OpenAI兼容)": { "Gemini(OpenAI兼容)": {
@@ -687,7 +643,6 @@ CONFIG_METADATA_2 = {
"model": "gemini-1.5-flash", "model": "gemini-1.5-flash",
"temperature": 0.4, "temperature": 0.4,
}, },
"custom_extra_body": {},
"modalities": ["text", "image", "tool_use"], "modalities": ["text", "image", "tool_use"],
}, },
"Gemini": { "Gemini": {
@@ -728,7 +683,6 @@ CONFIG_METADATA_2 = {
"api_base": "https://api.deepseek.com/v1", "api_base": "https://api.deepseek.com/v1",
"timeout": 120, "timeout": 120,
"model_config": {"model": "deepseek-chat", "temperature": 0.4}, "model_config": {"model": "deepseek-chat", "temperature": 0.4},
"custom_extra_body": {},
"modalities": ["text", "image", "tool_use"], "modalities": ["text", "image", "tool_use"],
}, },
"302.AI": { "302.AI": {
@@ -741,7 +695,6 @@ CONFIG_METADATA_2 = {
"api_base": "https://api.302.ai/v1", "api_base": "https://api.302.ai/v1",
"timeout": 120, "timeout": 120,
"model_config": {"model": "gpt-4.1-mini", "temperature": 0.4}, "model_config": {"model": "gpt-4.1-mini", "temperature": 0.4},
"custom_extra_body": {},
"modalities": ["text", "image", "tool_use"], "modalities": ["text", "image", "tool_use"],
}, },
"硅基流动": { "硅基流动": {
@@ -757,7 +710,6 @@ CONFIG_METADATA_2 = {
"model": "deepseek-ai/DeepSeek-V3", "model": "deepseek-ai/DeepSeek-V3",
"temperature": 0.4, "temperature": 0.4,
}, },
"custom_extra_body": {},
"modalities": ["text", "image", "tool_use"], "modalities": ["text", "image", "tool_use"],
}, },
"PPIO派欧云": { "PPIO派欧云": {
@@ -773,7 +725,6 @@ CONFIG_METADATA_2 = {
"model": "deepseek/deepseek-r1", "model": "deepseek/deepseek-r1",
"temperature": 0.4, "temperature": 0.4,
}, },
"custom_extra_body": {},
}, },
"优云智算": { "优云智算": {
"id": "compshare", "id": "compshare",
@@ -787,7 +738,6 @@ CONFIG_METADATA_2 = {
"model_config": { "model_config": {
"model": "moonshotai/Kimi-K2-Instruct", "model": "moonshotai/Kimi-K2-Instruct",
}, },
"custom_extra_body": {},
"modalities": ["text", "image", "tool_use"], "modalities": ["text", "image", "tool_use"],
}, },
"Kimi": { "Kimi": {
@@ -800,7 +750,6 @@ CONFIG_METADATA_2 = {
"timeout": 120, "timeout": 120,
"api_base": "https://api.moonshot.cn/v1", "api_base": "https://api.moonshot.cn/v1",
"model_config": {"model": "moonshot-v1-8k", "temperature": 0.4}, "model_config": {"model": "moonshot-v1-8k", "temperature": 0.4},
"custom_extra_body": {},
"modalities": ["text", "image", "tool_use"], "modalities": ["text", "image", "tool_use"],
}, },
"智谱 AI": { "智谱 AI": {
@@ -859,7 +808,6 @@ CONFIG_METADATA_2 = {
"timeout": 120, "timeout": 120,
"api_base": "https://api-inference.modelscope.cn/v1", "api_base": "https://api-inference.modelscope.cn/v1",
"model_config": {"model": "Qwen/Qwen3-32B", "temperature": 0.4}, "model_config": {"model": "Qwen/Qwen3-32B", "temperature": 0.4},
"custom_extra_body": {},
"modalities": ["text", "image", "tool_use"], "modalities": ["text", "image", "tool_use"],
}, },
"FastGPT": { "FastGPT": {
@@ -871,7 +819,6 @@ CONFIG_METADATA_2 = {
"key": [], "key": [],
"api_base": "https://api.fastgpt.in/api/v1", "api_base": "https://api.fastgpt.in/api/v1",
"timeout": 60, "timeout": 60,
"custom_extra_body": {},
}, },
"Whisper(API)": { "Whisper(API)": {
"id": "whisper", "id": "whisper",
@@ -922,9 +869,6 @@ CONFIG_METADATA_2 = {
"provider_type": "text_to_speech", "provider_type": "text_to_speech",
"enable": False, "enable": False,
"edge-tts-voice": "zh-CN-XiaoxiaoNeural", "edge-tts-voice": "zh-CN-XiaoxiaoNeural",
"rate": "+0%",
"volume": "+0%",
"pitch": "+0Hz",
"timeout": 20, "timeout": 20,
}, },
"GSV TTS(本地加载)": { "GSV TTS(本地加载)": {
@@ -1116,12 +1060,6 @@ CONFIG_METADATA_2 = {
"render_type": "checkbox", "render_type": "checkbox",
"hint": "模型支持的模态。如所填写的模型不支持图像,请取消勾选图像。", "hint": "模型支持的模态。如所填写的模型不支持图像,请取消勾选图像。",
}, },
"custom_extra_body": {
"description": "自定义请求体参数",
"type": "dict",
"items": {},
"hint": "此处添加的键值对将被合并到发送给 API 的 extra_body 中。值可以是字符串、数字或布尔值。",
},
"provider": { "provider": {
"type": "string", "type": "string",
"invisible": True, "invisible": True,
@@ -1897,6 +1835,12 @@ CONFIG_METADATA_3 = {
"_special": "select_provider", "_special": "select_provider",
"hint": "留空时使用第一个模型。", "hint": "留空时使用第一个模型。",
}, },
"provider_settings.default_summarize_provider_id": {
"description": "默认对话总结模型",
"type": "string",
"_special": "select_provider",
"hint": "留空代表不进行对话总结。可用于压缩上下文以减少 token 用量,并一定程度上保持历史聊天记忆。",
},
"provider_settings.default_image_caption_provider_id": { "provider_settings.default_image_caption_provider_id": {
"description": "默认图片转述模型", "description": "默认图片转述模型",
"type": "string", "type": "string",
@@ -1915,6 +1859,28 @@ CONFIG_METADATA_3 = {
"hint": "留空代表不使用。", "hint": "留空代表不使用。",
"_special": "select_provider_tts", "_special": "select_provider_tts",
}, },
"provider_settings.context_exceed_calc_method": {
"description": "上下文超限的触发策略",
"type": "string",
"options": ["token_size", "context_length"],
"labels": ["基于 Token 长度(估算)", "基于对话轮数"],
"hint": "如配置了对话总结模型,则触发时总结对话内容,否则丢弃最旧部分。"
},
"provider_settings.max_context_length": {
"description": "对话轮数上限",
"type": "int",
"condition": {
"provider_settings.context_exceed_calc_method": "context_length"
}
},
"provider_settings.max_token_size": {
"description": "Token 长度上限(估算)",
"type": "int",
"hint": "超出这个数量时丢弃最旧的部分。",
"condition": {
"provider_settings.context_exceed_calc_method": "token_size"
}
},
"provider_settings.image_caption_prompt": { "provider_settings.image_caption_prompt": {
"description": "图片转述提示词", "description": "图片转述提示词",
"type": "text", "type": "text",
@@ -1958,9 +1924,7 @@ CONFIG_METADATA_3 = {
}, },
"provider_settings.websearch_tavily_key": { "provider_settings.websearch_tavily_key": {
"description": "Tavily API Key", "description": "Tavily API Key",
"type": "list", "type": "string",
"items": {"type": "string"},
"hint": "可添加多个 Key 进行轮询。",
"condition": { "condition": {
"provider_settings.websearch_provider": "tavily", "provider_settings.websearch_provider": "tavily",
}, },
@@ -1980,7 +1944,7 @@ CONFIG_METADATA_3 = {
"type": "bool", "type": "bool",
}, },
"provider_settings.identifier": { "provider_settings.identifier": {
"description": "用户识别", "description": "用户感知",
"type": "bool", "type": "bool",
}, },
"provider_settings.datetime_system_prompt": { "provider_settings.datetime_system_prompt": {
@@ -2003,11 +1967,6 @@ CONFIG_METADATA_3 = {
"description": "不支持流式回复的平台采取分段输出", "description": "不支持流式回复的平台采取分段输出",
"type": "bool", "type": "bool",
}, },
"provider_settings.max_context_length": {
"description": "最多携带对话轮数",
"type": "int",
"hint": "超出这个数量时丢弃最旧的部分,一轮聊天记为 1 条。-1 为不限制。",
},
"provider_settings.dequeue_context_length": { "provider_settings.dequeue_context_length": {
"description": "丢弃对话轮数", "description": "丢弃对话轮数",
"type": "int", "type": "int",
@@ -2355,13 +2314,7 @@ CONFIG_METADATA_3_SYSTEM = {
"condition": { "condition": {
"t2i_strategy": "remote", "t2i_strategy": "remote",
}, },
"_special": "t2i_template", "_special": "t2i_template"
},
"t2i_active_template": {
"description": "当前应用的文转图渲染模板",
"type": "string",
"hint": "此处的值由文转图模板管理页面进行维护。",
"invisible": True,
}, },
"log_level": { "log_level": {
"description": "控制台日志级别", "description": "控制台日志级别",
@@ -2394,11 +2347,6 @@ CONFIG_METADATA_3_SYSTEM = {
"type": "string", "type": "string",
"hint": "启用后,会以添加环境变量的方式设置代理。格式为 `http://ip:port`", "hint": "启用后,会以添加环境变量的方式设置代理。格式为 `http://ip:port`",
}, },
"no_proxy": {
"description": "直连地址列表",
"type": "list",
"items": {"type": "string"},
},
}, },
} }
}, },
+1 -4
View File
@@ -22,7 +22,6 @@ class InitialLoader:
self.db = db self.db = db
self.logger = logger self.logger = logger
self.log_broker = log_broker self.log_broker = log_broker
self.webui_dir: str | None = None
async def start(self): async def start(self):
core_lifecycle = AstrBotCoreLifecycle(self.log_broker, self.db) core_lifecycle = AstrBotCoreLifecycle(self.log_broker, self.db)
@@ -36,10 +35,8 @@ class InitialLoader:
core_task = core_lifecycle.start() core_task = core_lifecycle.start()
webui_dir = self.webui_dir
self.dashboard_server = AstrBotDashboard( self.dashboard_server = AstrBotDashboard(
core_lifecycle, self.db, core_lifecycle.dashboard_shutdown_event, webui_dir core_lifecycle, self.db, core_lifecycle.dashboard_shutdown_event
) )
task = asyncio.gather( task = asyncio.gather(
core_task, self.dashboard_server.run() core_task, self.dashboard_server.run()
+50 -59
View File
@@ -37,7 +37,7 @@ from astrbot.core.utils.astrbot_path import get_astrbot_data_path
from astrbot.core.utils.io import download_file, download_image_by_url, file_to_base64 from astrbot.core.utils.io import download_file, download_image_by_url, file_to_base64
class ComponentType(str, Enum): class ComponentType(Enum):
Plain = "Plain" # 纯文本消息 Plain = "Plain" # 纯文本消息
Face = "Face" # QQ表情 Face = "Face" # QQ表情
Record = "Record" # 语音 Record = "Record" # 语音
@@ -108,7 +108,7 @@ class BaseMessageComponent(BaseModel):
class Plain(BaseMessageComponent): class Plain(BaseMessageComponent):
type = ComponentType.Plain type: ComponentType = "Plain"
text: str text: str
convert: T.Optional[bool] = True # 若为 False 则直接发送未转换 CQ 码的消息 convert: T.Optional[bool] = True # 若为 False 则直接发送未转换 CQ 码的消息
@@ -128,9 +128,8 @@ class Plain(BaseMessageComponent):
async def to_dict(self): async def to_dict(self):
return {"type": "text", "data": {"text": self.text}} return {"type": "text", "data": {"text": self.text}}
class Face(BaseMessageComponent): class Face(BaseMessageComponent):
type = ComponentType.Face type: ComponentType = "Face"
id: int id: int
def __init__(self, **_): def __init__(self, **_):
@@ -138,7 +137,7 @@ class Face(BaseMessageComponent):
class Record(BaseMessageComponent): class Record(BaseMessageComponent):
type = ComponentType.Record type: ComponentType = "Record"
file: T.Optional[str] = "" file: T.Optional[str] = ""
magic: T.Optional[bool] = False magic: T.Optional[bool] = False
url: T.Optional[str] = "" url: T.Optional[str] = ""
@@ -165,24 +164,19 @@ class Record(BaseMessageComponent):
return Record(file=url, **_) return Record(file=url, **_)
raise Exception("not a valid url") raise Exception("not a valid url")
@staticmethod
def fromBase64(bs64_data: str, **_):
return Record(file=f"base64://{bs64_data}", **_)
async def convert_to_file_path(self) -> str: async def convert_to_file_path(self) -> str:
"""将这个语音统一转换为本地文件路径。这个方法避免了手动判断语音数据类型,直接返回语音数据的本地路径(如果是网络 URL, 则会自动进行下载)。 """将这个语音统一转换为本地文件路径。这个方法避免了手动判断语音数据类型,直接返回语音数据的本地路径(如果是网络 URL, 则会自动进行下载)。
Returns: Returns:
str: 语音的本地路径,以绝对路径表示。 str: 语音的本地路径,以绝对路径表示。
""" """
if not self.file: if self.file and self.file.startswith("file:///"):
raise Exception(f"not a valid file: {self.file}") file_path = self.file[8:]
if self.file.startswith("file:///"): return file_path
return self.file[8:] elif self.file and self.file.startswith("http"):
elif self.file.startswith("http"):
file_path = await download_image_by_url(self.file) file_path = await download_image_by_url(self.file)
return os.path.abspath(file_path) return os.path.abspath(file_path)
elif self.file.startswith("base64://"): elif self.file and self.file.startswith("base64://"):
bs64_data = self.file.removeprefix("base64://") bs64_data = self.file.removeprefix("base64://")
image_bytes = base64.b64decode(bs64_data) image_bytes = base64.b64decode(bs64_data)
temp_dir = os.path.join(get_astrbot_data_path(), "temp") temp_dir = os.path.join(get_astrbot_data_path(), "temp")
@@ -191,7 +185,8 @@ class Record(BaseMessageComponent):
f.write(image_bytes) f.write(image_bytes)
return os.path.abspath(file_path) return os.path.abspath(file_path)
elif os.path.exists(self.file): elif os.path.exists(self.file):
return os.path.abspath(self.file) file_path = self.file
return os.path.abspath(file_path)
else: else:
raise Exception(f"not a valid file: {self.file}") raise Exception(f"not a valid file: {self.file}")
@@ -202,14 +197,12 @@ class Record(BaseMessageComponent):
str: 语音的 base64 编码,不以 base64:// 或者 data:image/jpeg;base64, 开头。 str: 语音的 base64 编码,不以 base64:// 或者 data:image/jpeg;base64, 开头。
""" """
# convert to base64 # convert to base64
if not self.file: if self.file and self.file.startswith("file:///"):
raise Exception(f"not a valid file: {self.file}")
if self.file.startswith("file:///"):
bs64_data = file_to_base64(self.file[8:]) bs64_data = file_to_base64(self.file[8:])
elif self.file.startswith("http"): elif self.file and self.file.startswith("http"):
file_path = await download_image_by_url(self.file) file_path = await download_image_by_url(self.file)
bs64_data = file_to_base64(file_path) bs64_data = file_to_base64(file_path)
elif self.file.startswith("base64://"): elif self.file and self.file.startswith("base64://"):
bs64_data = self.file bs64_data = self.file
elif os.path.exists(self.file): elif os.path.exists(self.file):
bs64_data = file_to_base64(self.file) bs64_data = file_to_base64(self.file)
@@ -243,7 +236,7 @@ class Record(BaseMessageComponent):
class Video(BaseMessageComponent): class Video(BaseMessageComponent):
type = ComponentType.Video type: ComponentType = "Video"
file: str file: str
cover: T.Optional[str] = "" cover: T.Optional[str] = ""
c: T.Optional[int] = 2 c: T.Optional[int] = 2
@@ -329,7 +322,7 @@ class Video(BaseMessageComponent):
class At(BaseMessageComponent): class At(BaseMessageComponent):
type = ComponentType.At type: ComponentType = "At"
qq: T.Union[int, str] # 此处str为all时代表所有人 qq: T.Union[int, str] # 此处str为all时代表所有人
name: T.Optional[str] = "" name: T.Optional[str] = ""
@@ -351,28 +344,28 @@ class AtAll(At):
class RPS(BaseMessageComponent): # TODO class RPS(BaseMessageComponent): # TODO
type = ComponentType.RPS type: ComponentType = "RPS"
def __init__(self, **_): def __init__(self, **_):
super().__init__(**_) super().__init__(**_)
class Dice(BaseMessageComponent): # TODO class Dice(BaseMessageComponent): # TODO
type = ComponentType.Dice type: ComponentType = "Dice"
def __init__(self, **_): def __init__(self, **_):
super().__init__(**_) super().__init__(**_)
class Shake(BaseMessageComponent): # TODO class Shake(BaseMessageComponent): # TODO
type = ComponentType.Shake type: ComponentType = "Shake"
def __init__(self, **_): def __init__(self, **_):
super().__init__(**_) super().__init__(**_)
class Anonymous(BaseMessageComponent): # TODO class Anonymous(BaseMessageComponent): # TODO
type = ComponentType.Anonymous type: ComponentType = "Anonymous"
ignore: T.Optional[bool] = False ignore: T.Optional[bool] = False
def __init__(self, **_): def __init__(self, **_):
@@ -380,7 +373,7 @@ class Anonymous(BaseMessageComponent): # TODO
class Share(BaseMessageComponent): class Share(BaseMessageComponent):
type = ComponentType.Share type: ComponentType = "Share"
url: str url: str
title: str title: str
content: T.Optional[str] = "" content: T.Optional[str] = ""
@@ -391,7 +384,7 @@ class Share(BaseMessageComponent):
class Contact(BaseMessageComponent): # TODO class Contact(BaseMessageComponent): # TODO
type = ComponentType.Contact type: ComponentType = "Contact"
_type: str # type 字段冲突 _type: str # type 字段冲突
id: T.Optional[int] = 0 id: T.Optional[int] = 0
@@ -400,7 +393,7 @@ class Contact(BaseMessageComponent): # TODO
class Location(BaseMessageComponent): # TODO class Location(BaseMessageComponent): # TODO
type = ComponentType.Location type: ComponentType = "Location"
lat: float lat: float
lon: float lon: float
title: T.Optional[str] = "" title: T.Optional[str] = ""
@@ -411,7 +404,7 @@ class Location(BaseMessageComponent): # TODO
class Music(BaseMessageComponent): class Music(BaseMessageComponent):
type = ComponentType.Music type: ComponentType = "Music"
_type: str _type: str
id: T.Optional[int] = 0 id: T.Optional[int] = 0
url: T.Optional[str] = "" url: T.Optional[str] = ""
@@ -428,7 +421,7 @@ class Music(BaseMessageComponent):
class Image(BaseMessageComponent): class Image(BaseMessageComponent):
type = ComponentType.Image type: ComponentType = "Image"
file: T.Optional[str] = "" file: T.Optional[str] = ""
_type: T.Optional[str] = "" _type: T.Optional[str] = ""
subType: T.Optional[int] = 0 subType: T.Optional[int] = 0
@@ -471,15 +464,14 @@ class Image(BaseMessageComponent):
Returns: Returns:
str: 图片的本地路径,以绝对路径表示。 str: 图片的本地路径,以绝对路径表示。
""" """
url = self.url or self.file url = self.url if self.url else self.file
if not url: if url and url.startswith("file:///"):
raise ValueError("No valid file or URL provided") image_file_path = url[8:]
if url.startswith("file:///"): return image_file_path
return url[8:] elif url and url.startswith("http"):
elif url.startswith("http"):
image_file_path = await download_image_by_url(url) image_file_path = await download_image_by_url(url)
return os.path.abspath(image_file_path) return os.path.abspath(image_file_path)
elif url.startswith("base64://"): elif url and url.startswith("base64://"):
bs64_data = url.removeprefix("base64://") bs64_data = url.removeprefix("base64://")
image_bytes = base64.b64decode(bs64_data) image_bytes = base64.b64decode(bs64_data)
temp_dir = os.path.join(get_astrbot_data_path(), "temp") temp_dir = os.path.join(get_astrbot_data_path(), "temp")
@@ -488,7 +480,8 @@ class Image(BaseMessageComponent):
f.write(image_bytes) f.write(image_bytes)
return os.path.abspath(image_file_path) return os.path.abspath(image_file_path)
elif os.path.exists(url): elif os.path.exists(url):
return os.path.abspath(url) image_file_path = url
return os.path.abspath(image_file_path)
else: else:
raise Exception(f"not a valid file: {url}") raise Exception(f"not a valid file: {url}")
@@ -499,15 +492,13 @@ class Image(BaseMessageComponent):
str: 图片的 base64 编码,不以 base64:// 或者 data:image/jpeg;base64, 开头。 str: 图片的 base64 编码,不以 base64:// 或者 data:image/jpeg;base64, 开头。
""" """
# convert to base64 # convert to base64
url = self.url or self.file url = self.url if self.url else self.file
if not url: if url and url.startswith("file:///"):
raise ValueError("No valid file or URL provided")
if url.startswith("file:///"):
bs64_data = file_to_base64(url[8:]) bs64_data = file_to_base64(url[8:])
elif url.startswith("http"): elif url and url.startswith("http"):
image_file_path = await download_image_by_url(url) image_file_path = await download_image_by_url(url)
bs64_data = file_to_base64(image_file_path) bs64_data = file_to_base64(image_file_path)
elif url.startswith("base64://"): elif url and url.startswith("base64://"):
bs64_data = url bs64_data = url
elif os.path.exists(url): elif os.path.exists(url):
bs64_data = file_to_base64(url) bs64_data = file_to_base64(url)
@@ -541,7 +532,7 @@ class Image(BaseMessageComponent):
class Reply(BaseMessageComponent): class Reply(BaseMessageComponent):
type = ComponentType.Reply type: ComponentType = "Reply"
id: T.Union[str, int] id: T.Union[str, int]
"""所引用的消息 ID""" """所引用的消息 ID"""
chain: T.Optional[T.List["BaseMessageComponent"]] = [] chain: T.Optional[T.List["BaseMessageComponent"]] = []
@@ -567,7 +558,7 @@ class Reply(BaseMessageComponent):
class RedBag(BaseMessageComponent): class RedBag(BaseMessageComponent):
type = ComponentType.RedBag type: ComponentType = "RedBag"
title: str title: str
def __init__(self, **_): def __init__(self, **_):
@@ -575,7 +566,7 @@ class RedBag(BaseMessageComponent):
class Poke(BaseMessageComponent): class Poke(BaseMessageComponent):
type: str = ComponentType.Poke type: str = ""
id: T.Optional[int] = 0 id: T.Optional[int] = 0
qq: T.Optional[int] = 0 qq: T.Optional[int] = 0
@@ -585,7 +576,7 @@ class Poke(BaseMessageComponent):
class Forward(BaseMessageComponent): class Forward(BaseMessageComponent):
type = ComponentType.Forward type: ComponentType = "Forward"
id: str id: str
def __init__(self, **_): def __init__(self, **_):
@@ -595,7 +586,7 @@ class Forward(BaseMessageComponent):
class Node(BaseMessageComponent): class Node(BaseMessageComponent):
"""群合并转发消息""" """群合并转发消息"""
type = ComponentType.Node type: ComponentType = "Node"
id: T.Optional[int] = 0 # 忽略 id: T.Optional[int] = 0 # 忽略
name: T.Optional[str] = "" # qq昵称 name: T.Optional[str] = "" # qq昵称
uin: T.Optional[str] = "0" # qq号 uin: T.Optional[str] = "0" # qq号
@@ -647,7 +638,7 @@ class Node(BaseMessageComponent):
class Nodes(BaseMessageComponent): class Nodes(BaseMessageComponent):
type = ComponentType.Nodes type: ComponentType = "Nodes"
nodes: T.List[Node] nodes: T.List[Node]
def __init__(self, nodes: T.List[Node], **_): def __init__(self, nodes: T.List[Node], **_):
@@ -673,7 +664,7 @@ class Nodes(BaseMessageComponent):
class Xml(BaseMessageComponent): class Xml(BaseMessageComponent):
type = ComponentType.Xml type: ComponentType = "Xml"
data: str data: str
resid: T.Optional[int] = 0 resid: T.Optional[int] = 0
@@ -682,7 +673,7 @@ class Xml(BaseMessageComponent):
class Json(BaseMessageComponent): class Json(BaseMessageComponent):
type = ComponentType.Json type: ComponentType = "Json"
data: T.Union[str, dict] data: T.Union[str, dict]
resid: T.Optional[int] = 0 resid: T.Optional[int] = 0
@@ -693,7 +684,7 @@ class Json(BaseMessageComponent):
class CardImage(BaseMessageComponent): class CardImage(BaseMessageComponent):
type = ComponentType.CardImage type: ComponentType = "CardImage"
file: str file: str
cache: T.Optional[bool] = True cache: T.Optional[bool] = True
minwidth: T.Optional[int] = 400 minwidth: T.Optional[int] = 400
@@ -712,7 +703,7 @@ class CardImage(BaseMessageComponent):
class TTS(BaseMessageComponent): class TTS(BaseMessageComponent):
type = ComponentType.TTS type: ComponentType = "TTS"
text: str text: str
def __init__(self, **_): def __init__(self, **_):
@@ -720,7 +711,7 @@ class TTS(BaseMessageComponent):
class Unknown(BaseMessageComponent): class Unknown(BaseMessageComponent):
type = ComponentType.Unknown type: ComponentType = "Unknown"
text: str text: str
def toString(self): def toString(self):
@@ -732,7 +723,7 @@ class File(BaseMessageComponent):
文件消息段 文件消息段
""" """
type = ComponentType.File type: ComponentType = "File"
name: T.Optional[str] = "" # 名字 name: T.Optional[str] = "" # 名字
file_: T.Optional[str] = "" # 本地路径 file_: T.Optional[str] = "" # 本地路径
url: T.Optional[str] = "" # url url: T.Optional[str] = "" # url
@@ -862,7 +853,7 @@ class File(BaseMessageComponent):
class WechatEmoji(BaseMessageComponent): class WechatEmoji(BaseMessageComponent):
type = ComponentType.WechatEmoji type: ComponentType = "WechatEmoji"
md5: T.Optional[str] = "" md5: T.Optional[str] = ""
md5_len: T.Optional[int] = 0 md5_len: T.Optional[int] = 0
cdnurl: T.Optional[str] = "" cdnurl: T.Optional[str] = ""
@@ -299,9 +299,7 @@ class LLMRequestSubStage(Stage):
self.max_context_length - 1, self.max_context_length - 1,
) )
self.streaming_response: bool = settings["streaming_response"] self.streaming_response: bool = settings["streaming_response"]
self.max_step: int = settings.get("max_agent_step", 30) self.max_step: int = settings.get("max_agent_step", 10)
if isinstance(self.max_step, bool): # workaround: #2622
self.max_step = 30
self.show_tool_use: bool = settings.get("show_tool_use_status", True) self.show_tool_use: bool = settings.get("show_tool_use_status", True)
for bwp in self.bot_wake_prefixs: for bwp in self.bot_wake_prefixs:
@@ -436,9 +434,7 @@ class LLMRequestSubStage(Stage):
provider_cfg = provider.provider_config.get("modalities", ["tool_use"]) provider_cfg = provider.provider_config.get("modalities", ["tool_use"])
# 如果模型不支持工具使用,但请求中包含工具列表,则清空。 # 如果模型不支持工具使用,但请求中包含工具列表,则清空。
if "tool_use" not in provider_cfg: if "tool_use" not in provider_cfg:
logger.debug( logger.debug(f"用户设置提供商 {provider} 不支持工具使用,清空工具列表。")
f"用户设置提供商 {provider} 不支持工具使用,清空工具列表。"
)
req.func_tool = None req.func_tool = None
# 插件可用性设置 # 插件可用性设置
if event.plugins_name is not None and req.func_tool: if event.plugins_name is not None and req.func_tool:
@@ -36,7 +36,6 @@ class ResultDecorateStage(Stage):
self.t2i_word_threshold = 150 self.t2i_word_threshold = 150
self.t2i_strategy = ctx.astrbot_config["t2i_strategy"] self.t2i_strategy = ctx.astrbot_config["t2i_strategy"]
self.t2i_use_network = self.t2i_strategy == "remote" self.t2i_use_network = self.t2i_strategy == "remote"
self.t2i_active_template = ctx.astrbot_config["t2i_active_template"]
self.forward_threshold = ctx.astrbot_config["platform_settings"][ self.forward_threshold = ctx.astrbot_config["platform_settings"][
"forward_threshold" "forward_threshold"
@@ -248,10 +247,7 @@ class ResultDecorateStage(Stage):
render_start = time.time() render_start = time.time()
try: try:
url = await html_renderer.render_t2i( url = await html_renderer.render_t2i(
plain_str, plain_str, return_url=True, use_network=self.t2i_use_network
return_url=True,
use_network=self.t2i_use_network,
template_name=self.t2i_active_template,
) )
except BaseException: except BaseException:
logger.error("文本转图片失败,使用文本发送。") logger.error("文本转图片失败,使用文本发送。")
+3 -25
View File
@@ -6,7 +6,6 @@ from typing import List
from asyncio import Queue from asyncio import Queue
from .register import platform_cls_map from .register import platform_cls_map
from astrbot.core import logger from astrbot.core import logger
from astrbot.core.star.star_handler import star_handlers_registry, star_map, EventType
from .sources.webchat.webchat_adapter import WebChatAdapter from .sources.webchat.webchat_adapter import WebChatAdapter
@@ -67,21 +66,15 @@ class PlatformManager:
WeChatPadProAdapter, # noqa: F401 WeChatPadProAdapter, # noqa: F401
) )
case "lark": case "lark":
from .sources.lark.lark_adapter import ( from .sources.lark.lark_adapter import LarkPlatformAdapter # noqa: F401
LarkPlatformAdapter,
) # noqa: F401
case "dingtalk": case "dingtalk":
from .sources.dingtalk.dingtalk_adapter import ( from .sources.dingtalk.dingtalk_adapter import (
DingtalkPlatformAdapter, # noqa: F401 DingtalkPlatformAdapter, # noqa: F401
) )
case "telegram": case "telegram":
from .sources.telegram.tg_adapter import ( from .sources.telegram.tg_adapter import TelegramPlatformAdapter # noqa: F401
TelegramPlatformAdapter,
) # noqa: F401
case "wecom": case "wecom":
from .sources.wecom.wecom_adapter import ( from .sources.wecom.wecom_adapter import WecomPlatformAdapter # noqa: F401
WecomPlatformAdapter,
) # noqa: F401
case "weixin_official_account": case "weixin_official_account":
from .sources.weixin_official_account.weixin_offacc_adapter import ( from .sources.weixin_official_account.weixin_offacc_adapter import (
WeixinOfficialAccountPlatformAdapter, # noqa WeixinOfficialAccountPlatformAdapter, # noqa
@@ -92,10 +85,6 @@ class PlatformManager:
) )
case "slack": case "slack":
from .sources.slack.slack_adapter import SlackAdapter # noqa: F401 from .sources.slack.slack_adapter import SlackAdapter # noqa: F401
case "satori":
from .sources.satori.satori_adapter import (
SatoriPlatformAdapter,
) # noqa: F401
except (ImportError, ModuleNotFoundError) as e: except (ImportError, ModuleNotFoundError) as e:
logger.error( logger.error(
f"加载平台适配器 {platform_config['type']} 失败,原因:{e}。请检查依赖库是否安装。提示:可以在 管理面板->控制台->安装Pip库 中安装依赖库。" f"加载平台适配器 {platform_config['type']} 失败,原因:{e}。请检查依赖库是否安装。提示:可以在 管理面板->控制台->安装Pip库 中安装依赖库。"
@@ -124,17 +113,6 @@ class PlatformManager:
) )
) )
) )
handlers = star_handlers_registry.get_handlers_by_event_type(
EventType.OnPlatformLoadedEvent
)
for handler in handlers:
try:
logger.info(
f"hook(on_platform_loaded) -> {star_map[handler.handler_module_path].name} - {handler.handler_name}"
)
await handler.handler()
except Exception:
logger.error(traceback.format_exc())
async def _task_wrapper(self, task: asyncio.Task): async def _task_wrapper(self, task: asyncio.Task):
try: try:
@@ -67,19 +67,12 @@ class AiocqhttpMessageEvent(AstrMessageEvent):
session_id: str, session_id: str,
messages: list[dict], messages: list[dict],
): ):
# session_id 必须是纯数字字符串 if event:
session_id = int(session_id) if session_id.isdigit() else None
if is_group and isinstance(session_id, int):
await bot.send_group_msg(group_id=session_id, message=messages)
elif not is_group and isinstance(session_id, int):
await bot.send_private_msg(user_id=session_id, message=messages)
elif isinstance(event, Event): # 最后兜底
await bot.send(event=event, message=messages) await bot.send(event=event, message=messages)
elif is_group:
await bot.send_group_msg(group_id=session_id, message=messages)
else: else:
raise ValueError( await bot.send_private_msg(user_id=session_id, message=messages)
f"无法发送消息:缺少有效的数字 session_id({session_id}) 或 event({event})"
)
@classmethod @classmethod
async def send_message( async def send_message(
@@ -90,15 +83,7 @@ class AiocqhttpMessageEvent(AstrMessageEvent):
is_group: bool = False, is_group: bool = False,
session_id: str = None, session_id: str = None,
): ):
"""发送消息至 QQ 协议端(aiocqhttp)。 """发送消息"""
Args:
bot (CQHttp): aiocqhttp 机器人实例
message_chain (MessageChain): 要发送的消息链
event (Event | None, optional): aiocqhttp 事件对象.
is_group (bool, optional): 是否为群消息.
session_id (str | None, optional): 会话 ID(群号或 QQ 号
"""
# 转发消息、文件消息不能和普通消息混在一起发送 # 转发消息、文件消息不能和普通消息混在一起发送
send_one_by_one = any( send_one_by_one = any(
@@ -137,15 +122,18 @@ class AiocqhttpMessageEvent(AstrMessageEvent):
async def send(self, message: MessageChain): async def send(self, message: MessageChain):
"""发送消息""" """发送消息"""
event = getattr(self.message_obj, "raw_message", None) event = self.message_obj.raw_message
assert isinstance(event, Event), "Event must be an instance of aiocqhttp.Event"
is_group = bool(self.get_group_id()) is_group = False
session_id = self.get_group_id() if is_group else self.get_sender_id() if self.get_group_id():
is_group = True
session_id = self.get_group_id()
else:
session_id = self.get_sender_id()
await self.send_message( await self.send_message(
bot=self.bot, bot=self.bot,
message_chain=message, message_chain=message,
event=event, # 不强制要求一定是 Event event=event,
is_group=is_group, is_group=is_group,
session_id=session_id, session_id=session_id,
) )
@@ -308,20 +308,13 @@ class AiocqhttpAdapter(Platform):
continue continue
at_info = await self.bot.call_action( at_info = await self.bot.call_action(
action="get_group_member_info", action="get_stranger_info",
group_id=event.group_id,
user_id=int(m["data"]["qq"]), user_id=int(m["data"]["qq"]),
no_cache=False,
) )
if at_info: if at_info:
nickname = at_info.get("card", "") nickname = at_info.get("nick", "") or at_info.get(
if nickname == "": "nickname", ""
at_info = await self.bot.call_action( )
action="get_stranger_info",
user_id=int(m["data"]["qq"]),
no_cache=False,
)
nickname = at_info.get("nick", "") or at_info.get("nickname", "")
is_at_self = str(m["data"]["qq"]) in {abm.self_id, "all"} is_at_self = str(m["data"]["qq"]) in {abm.self_id, "all"}
abm.message.append( abm.message.append(
@@ -1,482 +0,0 @@
import asyncio
import json
import time
import websockets
from websockets.asyncio.client import connect
from typing import Optional
from aiohttp import ClientSession, ClientTimeout
from websockets.asyncio.client import ClientConnection
from astrbot.api import logger
from astrbot.api.event import MessageChain
from astrbot.api.platform import (
AstrBotMessage,
MessageMember,
MessageType,
Platform,
PlatformMetadata,
register_platform_adapter,
)
from astrbot.core.platform.astr_message_event import MessageSession
from astrbot.api.message_components import Plain, Image, At, File, Record
from xml.etree import ElementTree as ET
@register_platform_adapter(
"satori",
"Satori 协议适配器",
)
class SatoriPlatformAdapter(Platform):
def __init__(
self, platform_config: dict, platform_settings: dict, event_queue: asyncio.Queue
) -> None:
super().__init__(event_queue)
self.config = platform_config
self.settings = platform_settings
self.api_base_url = self.config.get(
"satori_api_base_url", "http://localhost:5140/satori/v1"
)
self.token = self.config.get("satori_token", "")
self.endpoint = self.config.get(
"satori_endpoint", "ws://127.0.0.1:5140/satori/v1/events"
)
self.auto_reconnect = self.config.get("satori_auto_reconnect", True)
self.heartbeat_interval = self.config.get("satori_heartbeat_interval", 10)
self.reconnect_delay = self.config.get("satori_reconnect_delay", 5)
self.ws: Optional[ClientConnection] = None
self.session: Optional[ClientSession] = None
self.sequence = 0
self.logins = []
self.running = False
self.heartbeat_task: Optional[asyncio.Task] = None
self.ready_received = False
async def send_by_session(
self, session: MessageSession, message_chain: MessageChain
):
from .satori_event import SatoriPlatformEvent
await SatoriPlatformEvent.send_with_adapter(
self, message_chain, session.session_id
)
await super().send_by_session(session, message_chain)
def meta(self) -> PlatformMetadata:
return PlatformMetadata(name="satori", description="Satori 通用协议适配器")
def _is_websocket_closed(self, ws) -> bool:
"""检查WebSocket连接是否已关闭"""
if not ws:
return True
try:
if hasattr(ws, "closed"):
return ws.closed
elif hasattr(ws, "close_code"):
return ws.close_code is not None
else:
return False
except AttributeError:
return False
async def run(self):
self.running = True
self.session = ClientSession(timeout=ClientTimeout(total=30))
retry_count = 0
max_retries = 10
while self.running:
try:
await self.connect_websocket()
retry_count = 0
except websockets.exceptions.ConnectionClosed as e:
logger.warning(f"Satori WebSocket 连接关闭: {e}")
retry_count += 1
except Exception as e:
logger.error(f"Satori WebSocket 连接失败: {e}")
retry_count += 1
if not self.running:
break
if retry_count >= max_retries:
logger.error(f"达到最大重试次数 ({max_retries}),停止重试")
break
if not self.auto_reconnect:
break
delay = min(self.reconnect_delay * (2 ** (retry_count - 1)), 60)
await asyncio.sleep(delay)
if self.session:
await self.session.close()
async def connect_websocket(self):
logger.info(f"Satori 适配器正在连接到 WebSocket: {self.endpoint}")
logger.info(f"Satori 适配器 HTTP API 地址: {self.api_base_url}")
if not self.endpoint.startswith(("ws://", "wss://")):
logger.error(f"无效的WebSocket URL: {self.endpoint}")
raise ValueError(f"WebSocket URL必须以ws://或wss://开头: {self.endpoint}")
try:
websocket = await connect(self.endpoint, additional_headers={})
self.ws = websocket
await asyncio.sleep(0.1)
await self.send_identify()
self.heartbeat_task = asyncio.create_task(self.heartbeat_loop())
async for message in websocket:
try:
await self.handle_message(message) # type: ignore
except Exception as e:
logger.error(f"Satori 处理消息异常: {e}")
except websockets.exceptions.ConnectionClosed as e:
logger.warning(f"Satori WebSocket 连接关闭: {e}")
raise
except Exception as e:
logger.error(f"Satori WebSocket 连接异常: {e}")
raise
finally:
if self.heartbeat_task:
self.heartbeat_task.cancel()
try:
await self.heartbeat_task
except asyncio.CancelledError:
pass
if self.ws:
try:
await self.ws.close()
except Exception as e:
logger.error(f"Satori WebSocket 关闭异常: {e}")
async def send_identify(self):
if not self.ws:
raise Exception("WebSocket连接未建立")
if self._is_websocket_closed(self.ws):
raise Exception("WebSocket连接已关闭")
identify_payload = {
"op": 3, # IDENTIFY
"body": {
"token": str(self.token) if self.token else "", # 字符串
},
}
# 只有在有序列号时才添加sn字段
if self.sequence > 0:
identify_payload["body"]["sn"] = self.sequence
try:
message_str = json.dumps(identify_payload, ensure_ascii=False)
await self.ws.send(message_str)
except websockets.exceptions.ConnectionClosed as e:
logger.error(f"发送 IDENTIFY 信令时连接关闭: {e}")
raise
except Exception as e:
logger.error(f"发送 IDENTIFY 信令失败: {e}")
raise
async def heartbeat_loop(self):
try:
while self.running and self.ws:
await asyncio.sleep(self.heartbeat_interval)
if self.ws and not self._is_websocket_closed(self.ws):
try:
ping_payload = {
"op": 1, # PING
"body": {},
}
await self.ws.send(json.dumps(ping_payload, ensure_ascii=False))
except websockets.exceptions.ConnectionClosed as e:
logger.error(f"Satori WebSocket 连接关闭: {e}")
break
except Exception as e:
logger.error(f"Satori WebSocket 发送心跳失败: {e}")
break
else:
break
except asyncio.CancelledError:
pass
except Exception as e:
logger.error(f"心跳任务异常: {e}")
async def handle_message(self, message: str):
try:
data = json.loads(message)
op = data.get("op")
body = data.get("body", {})
if op == 4: # READY
self.logins = body.get("logins", [])
self.ready_received = True
# 输出连接成功的bot信息
if self.logins:
for i, login in enumerate(self.logins):
platform = login.get("platform", "")
user = login.get("user", {})
user_id = user.get("id", "")
user_name = user.get("name", "")
logger.info(
f"Satori 连接成功 - Bot {i + 1}: platform={platform}, user_id={user_id}, user_name={user_name}"
)
if "sn" in body:
self.sequence = body["sn"]
elif op == 2: # PONG
pass
elif op == 0: # EVENT
await self.handle_event(body)
if "sn" in body:
self.sequence = body["sn"]
elif op == 5: # META
if "sn" in body:
self.sequence = body["sn"]
except json.JSONDecodeError as e:
logger.error(f"解析 WebSocket 消息失败: {e}, 消息内容: {message}")
except Exception as e:
logger.error(f"处理 WebSocket 消息异常: {e}")
async def handle_event(self, event_data: dict):
try:
event_type = event_data.get("type")
sn = event_data.get("sn")
if sn:
self.sequence = sn
if event_type == "message-created":
message = event_data.get("message", {})
user = event_data.get("user", {})
channel = event_data.get("channel", {})
guild = event_data.get("guild")
login = event_data.get("login", {})
timestamp = event_data.get("timestamp")
if user.get("id") == login.get("user", {}).get("id"):
return
abm = await self.convert_satori_message(
message, user, channel, guild, login, timestamp
)
if abm:
await self.handle_msg(abm)
except Exception as e:
logger.error(f"处理事件失败: {e}")
async def convert_satori_message(
self,
message: dict,
user: dict,
channel: dict,
guild: Optional[dict],
login: dict,
timestamp: Optional[int] = None,
) -> Optional[AstrBotMessage]:
try:
abm = AstrBotMessage()
abm.message_id = message.get("id", "")
abm.raw_message = {
"message": message,
"user": user,
"channel": channel,
"guild": guild,
"login": login,
}
if guild and guild.get("id"):
abm.type = MessageType.GROUP_MESSAGE
abm.group_id = guild.get("id", "")
abm.session_id = channel.get("id", "")
else:
abm.type = MessageType.FRIEND_MESSAGE
abm.session_id = channel.get("id", "")
abm.sender = MessageMember(
user_id=user.get("id", ""),
nickname=user.get("nick", user.get("name", "")),
)
abm.self_id = login.get("user", {}).get("id", "")
content = message.get("content", "")
abm.message = await self.parse_satori_elements(content)
# parse message_str
abm.message_str = ""
for comp in abm.message:
if isinstance(comp, Plain):
abm.message_str += comp.text
# 优先使用Satori事件中的时间戳
if timestamp is not None:
abm.timestamp = timestamp
else:
abm.timestamp = int(time.time())
return abm
except Exception as e:
logger.error(f"转换 Satori 消息失败: {e}")
return None
async def parse_satori_elements(self, content: str) -> list:
"""解析 Satori 消息元素"""
elements = []
if not content:
return elements
try:
wrapped_content = f"<root>{content}</root>"
root = ET.fromstring(wrapped_content)
await self._parse_xml_node(root, elements)
except ET.ParseError as e:
raise ValueError(f"解析 Satori 元素时发生解析错误: {e}")
except Exception as e:
raise e
# 如果没有解析到任何元素,将整个内容当作纯文本
if not elements and content.strip():
elements.append(Plain(text=content))
return elements
async def _parse_xml_node(self, node: ET.Element, elements: list) -> None:
"""递归解析 XML 节点"""
if node.text and node.text.strip():
elements.append(Plain(text=node.text))
for child in node:
tag_name = child.tag.lower()
attrs = child.attrib
if tag_name == "at":
user_id = attrs.get("id") or attrs.get("name", "")
elements.append(At(qq=user_id, name=user_id))
elif tag_name in ("img", "image"):
src = attrs.get("src", "")
if not src:
continue
if src.startswith("data:image/"):
src = src.split(",")[1]
elements.append(Image.fromBase64(src))
elif src.startswith("http"):
elements.append(Image.fromURL(src))
else:
logger.error(f"未知的图片 src 格式: {str(src)[:16]}")
elif tag_name == "file":
src = attrs.get("src", "")
name = attrs.get("name", "文件")
if src:
elements.append(File(file=src, name=name))
elif tag_name in ("audio", "record"):
src = attrs.get("src", "")
if not src:
continue
if src.startswith("data:audio/"):
src = src.split(",")[1]
elements.append(Record.fromBase64(src))
elif src.startswith("http"):
elements.append(Record.fromURL(src))
else:
logger.error(f"未知的音频 src 格式: {str(src)[:16]}")
else:
# 未知标签,递归处理其内容
if child.text and child.text.strip():
elements.append(Plain(text=child.text))
await self._parse_xml_node(child, elements)
# 处理标签后的文本
if child.tail and child.tail.strip():
elements.append(Plain(text=child.tail))
async def handle_msg(self, message: AstrBotMessage):
from .satori_event import SatoriPlatformEvent
message_event = SatoriPlatformEvent(
message_str=message.message_str,
message_obj=message,
platform_meta=self.meta(),
session_id=message.session_id,
adapter=self,
)
self.commit_event(message_event)
async def send_http_request(
self,
method: str,
path: str,
data: dict | None = None,
platform: str | None = None,
user_id: str | None = None,
) -> dict:
if not self.session:
raise Exception("HTTP session 未初始化")
headers = {
"Content-Type": "application/json",
}
if self.token:
headers["Authorization"] = f"Bearer {self.token}"
if platform and user_id:
headers["satori-platform"] = platform
headers["satori-user-id"] = user_id
elif self.logins:
current_login = self.logins[0]
headers["satori-platform"] = current_login.get("platform", "")
user = current_login.get("user", {})
headers["satori-user-id"] = user.get("id", "") if user else ""
if not path.startswith("/"):
path = "/" + path
# 使用新的API地址配置
url = f"{self.api_base_url.rstrip('/')}{path}"
try:
async with self.session.request(
method, url, json=data, headers=headers
) as response:
if response.status == 200:
result = await response.json()
return result
else:
return {}
except Exception as e:
logger.error(f"Satori HTTP 请求异常: {e}")
return {}
async def terminate(self):
self.running = False
if self.heartbeat_task:
self.heartbeat_task.cancel()
if self.ws:
try:
await self.ws.close()
except Exception as e:
logger.error(f"Satori WebSocket 关闭异常: {e}")
if self.session:
await self.session.close()
@@ -1,221 +0,0 @@
from typing import TYPE_CHECKING
from astrbot.api import logger
from astrbot.api.event import AstrMessageEvent, MessageChain
from astrbot.api.platform import AstrBotMessage, PlatformMetadata
from astrbot.api.message_components import Plain, Image, At, File, Record
if TYPE_CHECKING:
from .satori_adapter import SatoriPlatformAdapter
class SatoriPlatformEvent(AstrMessageEvent):
def __init__(
self,
message_str: str,
message_obj: AstrBotMessage,
platform_meta: PlatformMetadata,
session_id: str,
adapter: "SatoriPlatformAdapter",
):
super().__init__(message_str, message_obj, platform_meta, session_id)
self.adapter = adapter
self.platform = None
self.user_id = None
if (
hasattr(message_obj, "raw_message")
and message_obj.raw_message
and isinstance(message_obj.raw_message, dict)
):
login = message_obj.raw_message.get("login", {})
self.platform = login.get("platform")
user = login.get("user", {})
self.user_id = user.get("id") if user else None
@classmethod
async def send_with_adapter(
cls, adapter: "SatoriPlatformAdapter", message: MessageChain, session_id: str
):
try:
content_parts = []
for component in message.chain:
if isinstance(component, Plain):
text = (
component.text.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
)
content_parts.append(text)
elif isinstance(component, At):
if component.qq:
content_parts.append(f'<at id="{component.qq}"/>')
elif component.name:
content_parts.append(f'<at name="{component.name}"/>')
elif isinstance(component, Image):
try:
image_base64 = await component.convert_to_base64()
if image_base64:
content_parts.append(
f'<img src="data:image/jpeg;base64,{image_base64}"/>'
)
except Exception as e:
logger.error(f"图片转换为base64失败: {e}")
elif isinstance(component, File):
content_parts.append(
f'<file src="{component.file}" name="{component.name or "文件"}"/>'
)
elif isinstance(component, Record):
try:
record_base64 = await component.convert_to_base64()
if record_base64:
content_parts.append(
f'<audio src="data:audio/wav;base64,{record_base64}"/>'
)
except Exception as e:
logger.error(f"语音转换为base64失败: {e}")
content = "".join(content_parts)
channel_id = session_id
data = {"channel_id": channel_id, "content": content}
platform = None
user_id = None
if hasattr(adapter, "logins") and adapter.logins:
current_login = adapter.logins[0]
platform = current_login.get("platform", "")
user = current_login.get("user", {})
user_id = user.get("id", "") if user else ""
result = await adapter.send_http_request(
"POST", "/message.create", data, platform, user_id
)
if result:
return result
else:
return None
except Exception as e:
logger.error(f"Satori 消息发送异常: {e}")
return None
async def send(self, message: MessageChain):
platform = getattr(self, "platform", None)
user_id = getattr(self, "user_id", None)
if not platform or not user_id:
if hasattr(self.adapter, "logins") and self.adapter.logins:
current_login = self.adapter.logins[0]
platform = current_login.get("platform", "")
user = current_login.get("user", {})
user_id = user.get("id", "") if user else ""
try:
content_parts = []
for component in message.chain:
if isinstance(component, Plain):
text = (
component.text.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
)
content_parts.append(text)
elif isinstance(component, At):
if component.qq:
content_parts.append(f'<at id="{component.qq}"/>')
elif component.name:
content_parts.append(f'<at name="{component.name}"/>')
elif isinstance(component, Image):
try:
image_base64 = await component.convert_to_base64()
if image_base64:
content_parts.append(
f'<img src="data:image/jpeg;base64,{image_base64}"/>'
)
except Exception as e:
logger.error(f"图片转换为base64失败: {e}")
elif isinstance(component, File):
content_parts.append(
f'<file src="{component.file}" name="{component.name or "文件"}"/>'
)
elif isinstance(component, Record):
try:
record_base64 = await component.convert_to_base64()
if record_base64:
content_parts.append(
f'<audio src="data:audio/wav;base64,{record_base64}"/>'
)
except Exception as e:
logger.error(f"语音转换为base64失败: {e}")
content = "".join(content_parts)
channel_id = self.session_id
data = {"channel_id": channel_id, "content": content}
result = await self.adapter.send_http_request(
"POST", "/message.create", data, platform, user_id
)
if not result:
logger.error("Satori 消息发送失败")
except Exception as e:
logger.error(f"Satori 消息发送异常: {e}")
await super().send(message)
async def send_streaming(self, generator, use_fallback: bool = False):
try:
content_parts = []
async for chain in generator:
if isinstance(chain, MessageChain):
if chain.type == "break":
if content_parts:
content = "".join(content_parts)
temp_chain = MessageChain([Plain(text=content)])
await self.send(temp_chain)
content_parts = []
continue
for component in chain.chain:
if isinstance(component, Plain):
content_parts.append(component.text)
elif isinstance(component, Image):
if content_parts:
content = "".join(content_parts)
temp_chain = MessageChain([Plain(text=content)])
await self.send(temp_chain)
content_parts = []
try:
image_base64 = await component.convert_to_base64()
if image_base64:
img_chain = MessageChain(
[
Plain(
text=f'<img src="data:image/jpeg;base64,{image_base64}"/>'
)
]
)
await self.send(img_chain)
except Exception as e:
logger.error(f"图片转换为base64失败: {e}")
else:
content_parts.append(str(component))
if content_parts:
content = "".join(content_parts)
temp_chain = MessageChain([Plain(text=content)])
await self.send(temp_chain)
except Exception as e:
logger.error(f"Satori 流式消息发送异常: {e}")
return await super().send_streaming(generator, use_fallback)
@@ -183,6 +183,7 @@ class TelegramPlatformAdapter(Platform):
return None return None
if not re.match(r"^[a-z0-9_]+$", cmd_name) or len(cmd_name) > 32: if not re.match(r"^[a-z0-9_]+$", cmd_name) or len(cmd_name) > 32:
logger.debug(f"跳过无法注册的命令: {cmd_name}")
return None return None
# Build description. # Build description.
+15 -17
View File
@@ -4,11 +4,9 @@ import json
from astrbot.core.utils.io import download_image_by_url from astrbot.core.utils.io import download_image_by_url
from astrbot import logger from astrbot import logger
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import List, Dict, Type, Any from typing import List, Dict, Type
from astrbot.core.agent.tool import ToolSet from astrbot.core.agent.tool import ToolSet
from openai.types.chat.chat_completion import ChatCompletion from openai.types.chat.chat_completion import ChatCompletion
from google.genai.types import GenerateContentResponse
from anthropic.types import Message
from openai.types.chat.chat_completion_message_tool_call import ( from openai.types.chat.chat_completion_message_tool_call import (
ChatCompletionMessageToolCall, ChatCompletionMessageToolCall,
) )
@@ -32,11 +30,11 @@ class ProviderMetaData:
desc: str = "" desc: str = ""
"""提供商适配器描述.""" """提供商适配器描述."""
provider_type: ProviderType = ProviderType.CHAT_COMPLETION provider_type: ProviderType = ProviderType.CHAT_COMPLETION
cls_type: Type | None = None cls_type: Type = None
default_config_tmpl: dict | None = None default_config_tmpl: dict = None
"""平台的默认配置模板""" """平台的默认配置模板"""
provider_display_name: str | None = None provider_display_name: str = None
"""显示在 WebUI 配置页中的提供商名称,如空则是 type""" """显示在 WebUI 配置页中的提供商名称,如空则是 type"""
@@ -60,7 +58,7 @@ class ToolCallMessageSegment:
class AssistantMessageSegment: class AssistantMessageSegment:
"""OpenAI 格式的上下文中 role 为 assistant 的消息段。参考: https://platform.openai.com/docs/guides/function-calling""" """OpenAI 格式的上下文中 role 为 assistant 的消息段。参考: https://platform.openai.com/docs/guides/function-calling"""
content: str | None = None content: str = None
tool_calls: List[ChatCompletionMessageToolCall | Dict] = field(default_factory=list) tool_calls: List[ChatCompletionMessageToolCall | Dict] = field(default_factory=list)
role: str = "assistant" role: str = "assistant"
@@ -207,17 +205,17 @@ class ProviderRequest:
class LLMResponse: class LLMResponse:
role: str role: str
"""角色, assistant, tool, err""" """角色, assistant, tool, err"""
result_chain: MessageChain | None = None result_chain: MessageChain = None
"""返回的消息链""" """返回的消息链"""
tools_call_args: List[Dict[str, Any]] = field(default_factory=list) tools_call_args: List[Dict[str, any]] = field(default_factory=list)
"""工具调用参数""" """工具调用参数"""
tools_call_name: List[str] = field(default_factory=list) tools_call_name: List[str] = field(default_factory=list)
"""工具调用名称""" """工具调用名称"""
tools_call_ids: List[str] = field(default_factory=list) tools_call_ids: List[str] = field(default_factory=list)
"""工具调用 ID""" """工具调用 ID"""
raw_completion: ChatCompletion | GenerateContentResponse | Message | None = None raw_completion: ChatCompletion = None
_new_record: Dict[str, Any] | None = None _new_record: Dict[str, any] = None
_completion_text: str = "" _completion_text: str = ""
@@ -228,12 +226,12 @@ class LLMResponse:
self, self,
role: str, role: str,
completion_text: str = "", completion_text: str = "",
result_chain: MessageChain | None = None, result_chain: MessageChain = None,
tools_call_args: List[Dict[str, Any]] | None = None, tools_call_args: List[Dict[str, any]] = None,
tools_call_name: List[str] | None = None, tools_call_name: List[str] = None,
tools_call_ids: List[str] | None = None, tools_call_ids: List[str] = None,
raw_completion: ChatCompletion | None = None, raw_completion: ChatCompletion = None,
_new_record: Dict[str, Any] | None = None, _new_record: Dict[str, any] = None,
is_chunk: bool = False, is_chunk: bool = False,
): ):
"""初始化 LLMResponse """初始化 LLMResponse
+18 -57
View File
@@ -15,7 +15,7 @@ from astrbot import logger
from astrbot.api.provider import Provider from astrbot.api.provider import Provider
from astrbot.core.message.message_event_result import MessageChain from astrbot.core.message.message_event_result import MessageChain
from astrbot.core.provider.entities import LLMResponse from astrbot.core.provider.entities import LLMResponse
from astrbot.core.provider.func_tool_manager import ToolSet from astrbot.core.provider.func_tool_manager import FuncCall
from astrbot.core.utils.io import download_image_by_url from astrbot.core.utils.io import download_image_by_url
from ..register import register_provider_adapter from ..register import register_provider_adapter
@@ -61,7 +61,7 @@ class ProviderGoogleGenAI(Provider):
default_persona, default_persona,
) )
self.api_keys: list = provider_config.get("key", []) self.api_keys: list = provider_config.get("key", [])
self.chosen_api_key: str = self.api_keys[0] if len(self.api_keys) > 0 else "" self.chosen_api_key: str = self.api_keys[0] if len(self.api_keys) > 0 else None
self.timeout: int = int(provider_config.get("timeout", 180)) self.timeout: int = int(provider_config.get("timeout", 180))
self.api_base: Optional[str] = provider_config.get("api_base", None) self.api_base: Optional[str] = provider_config.get("api_base", None)
@@ -96,9 +96,6 @@ class ProviderGoogleGenAI(Provider):
async def _handle_api_error(self, e: APIError, keys: list[str]) -> bool: async def _handle_api_error(self, e: APIError, keys: list[str]) -> bool:
"""处理API错误,返回是否需要重试""" """处理API错误,返回是否需要重试"""
if e.message is None:
e.message = ""
if e.code == 429 or "API key not valid" in e.message: if e.code == 429 or "API key not valid" in e.message:
keys.remove(self.chosen_api_key) keys.remove(self.chosen_api_key)
if len(keys) > 0: if len(keys) > 0:
@@ -122,7 +119,7 @@ class ProviderGoogleGenAI(Provider):
async def _prepare_query_config( async def _prepare_query_config(
self, self,
payloads: dict, payloads: dict,
tools: Optional[ToolSet] = None, tools: Optional[FuncCall] = None,
system_instruction: Optional[str] = None, system_instruction: Optional[str] = None,
modalities: Optional[list[str]] = None, modalities: Optional[list[str]] = None,
temperature: float = 0.7, temperature: float = 0.7,
@@ -324,15 +321,11 @@ class ProviderGoogleGenAI(Provider):
@staticmethod @staticmethod
def _process_content_parts( def _process_content_parts(
candidate: types.Candidate, llm_response: LLMResponse result: types.GenerateContentResponse, llm_response: LLMResponse
) -> MessageChain: ) -> MessageChain:
"""处理内容部分并构建消息链""" """处理内容部分并构建消息链"""
if not candidate.content: finish_reason = result.candidates[0].finish_reason
logger.warning(f"收到的 candidate.content 为空: {candidate}") result_parts: Optional[types.Part] = result.candidates[0].content.parts
raise Exception("API 返回的 candidate.content 为空。")
finish_reason = candidate.finish_reason
result_parts: list[types.Part] | None = candidate.content.parts
if finish_reason == types.FinishReason.SAFETY: if finish_reason == types.FinishReason.SAFETY:
raise Exception("模型生成内容未通过 Gemini 平台的安全检查") raise Exception("模型生成内容未通过 Gemini 平台的安全检查")
@@ -350,28 +343,22 @@ class ProviderGoogleGenAI(Provider):
raise Exception("模型生成内容违反 Gemini 平台政策") raise Exception("模型生成内容违反 Gemini 平台政策")
if not result_parts: if not result_parts:
logger.warning(f"收到的 candidate.content.parts 为空: {candidate}") logger.debug(result.candidates)
raise Exception("API 返回的 candidate.content.parts 为空。") raise Exception("API 返回的内容为空。")
chain = [] chain = []
part: types.Part part: types.Part
# 暂时这样Fallback # 暂时这样Fallback
if all( if all(
part.inline_data part.inline_data and part.inline_data.mime_type.startswith("image/")
and part.inline_data.mime_type
and part.inline_data.mime_type.startswith("image/")
for part in result_parts for part in result_parts
): ):
chain.append(Comp.Plain("这是图片")) chain.append(Comp.Plain("这是图片"))
for part in result_parts: for part in result_parts:
if part.text: if part.text:
chain.append(Comp.Plain(part.text)) chain.append(Comp.Plain(part.text))
elif ( elif part.function_call:
part.function_call
and part.function_call.name is not None
and part.function_call.args is not None
):
llm_response.role = "tool" llm_response.role = "tool"
llm_response.tools_call_name.append(part.function_call.name) llm_response.tools_call_name.append(part.function_call.name)
llm_response.tools_call_args.append(part.function_call.args) llm_response.tools_call_args.append(part.function_call.args)
@@ -379,16 +366,11 @@ class ProviderGoogleGenAI(Provider):
llm_response.tools_call_ids.append( llm_response.tools_call_ids.append(
part.function_call.id or part.function_call.name part.function_call.id or part.function_call.name
) )
elif ( elif part.inline_data and part.inline_data.mime_type.startswith("image/"):
part.inline_data
and part.inline_data.mime_type
and part.inline_data.mime_type.startswith("image/")
and part.inline_data.data
):
chain.append(Comp.Image.fromBytes(part.inline_data.data)) chain.append(Comp.Image.fromBytes(part.inline_data.data))
return MessageChain(chain=chain) return MessageChain(chain=chain)
async def _query(self, payloads: dict, tools: ToolSet | None) -> LLMResponse: async def _query(self, payloads: dict, tools: FuncCall) -> LLMResponse:
"""非流式请求 Gemini API""" """非流式请求 Gemini API"""
system_instruction = next( system_instruction = next(
(msg["content"] for msg in payloads["messages"] if msg["role"] == "system"), (msg["content"] for msg in payloads["messages"] if msg["role"] == "system"),
@@ -414,10 +396,6 @@ class ProviderGoogleGenAI(Provider):
config=config, config=config,
) )
if not result.candidates:
logger.error(f"请求失败, 返回的 candidates 为空: {result}")
raise Exception("请求失败, 返回的 candidates 为空。")
if result.candidates[0].finish_reason == types.FinishReason.RECITATION: if result.candidates[0].finish_reason == types.FinishReason.RECITATION:
if temperature > 2: if temperature > 2:
raise Exception("温度参数已超过最大值2,仍然发生recitation") raise Exception("温度参数已超过最大值2,仍然发生recitation")
@@ -430,8 +408,6 @@ class ProviderGoogleGenAI(Provider):
break break
except APIError as e: except APIError as e:
if e.message is None:
e.message = ""
if "Developer instruction is not enabled" in e.message: if "Developer instruction is not enabled" in e.message:
logger.warning( logger.warning(
f"{self.get_model()} 不支持 system prompt,已自动去除(影响人格设置)" f"{self.get_model()} 不支持 system prompt,已自动去除(影响人格设置)"
@@ -456,13 +432,11 @@ class ProviderGoogleGenAI(Provider):
llm_response = LLMResponse("assistant") llm_response = LLMResponse("assistant")
llm_response.raw_completion = result llm_response.raw_completion = result
llm_response.result_chain = self._process_content_parts( llm_response.result_chain = self._process_content_parts(result, llm_response)
result.candidates[0], llm_response
)
return llm_response return llm_response
async def _query_stream( async def _query_stream(
self, payloads: dict, tools: ToolSet | None self, payloads: dict, tools: FuncCall
) -> AsyncGenerator[LLMResponse, None]: ) -> AsyncGenerator[LLMResponse, None]:
"""流式请求 Gemini API""" """流式请求 Gemini API"""
system_instruction = next( system_instruction = next(
@@ -485,8 +459,6 @@ class ProviderGoogleGenAI(Provider):
) )
break break
except APIError as e: except APIError as e:
if e.message is None:
e.message = ""
if "Developer instruction is not enabled" in e.message: if "Developer instruction is not enabled" in e.message:
logger.warning( logger.warning(
f"{self.get_model()} 不支持 system prompt,已自动去除(影响人格设置)" f"{self.get_model()} 不支持 system prompt,已自动去除(影响人格设置)"
@@ -506,20 +478,13 @@ class ProviderGoogleGenAI(Provider):
async for chunk in result: async for chunk in result:
llm_response = LLMResponse("assistant", is_chunk=True) llm_response = LLMResponse("assistant", is_chunk=True)
if not chunk.candidates:
logger.warning(f"收到的 chunk 中 candidates 为空: {chunk}")
continue
if not chunk.candidates[0].content:
logger.warning(f"收到的 chunk 中 content 为空: {chunk}")
continue
if chunk.candidates[0].content.parts and any( if chunk.candidates[0].content.parts and any(
part.function_call for part in chunk.candidates[0].content.parts part.function_call for part in chunk.candidates[0].content.parts
): ):
llm_response = LLMResponse("assistant", is_chunk=False) llm_response = LLMResponse("assistant", is_chunk=False)
llm_response.raw_completion = chunk llm_response.raw_completion = chunk
llm_response.result_chain = self._process_content_parts( llm_response.result_chain = self._process_content_parts(
chunk.candidates[0], llm_response chunk, llm_response
) )
yield llm_response yield llm_response
return return
@@ -535,7 +500,7 @@ class ProviderGoogleGenAI(Provider):
final_response = LLMResponse("assistant", is_chunk=False) final_response = LLMResponse("assistant", is_chunk=False)
final_response.raw_completion = chunk final_response.raw_completion = chunk
final_response.result_chain = self._process_content_parts( final_response.result_chain = self._process_content_parts(
chunk.candidates[0], final_response chunk, final_response
) )
break break
@@ -601,8 +566,6 @@ class ProviderGoogleGenAI(Provider):
continue continue
break break
raise Exception("请求失败。")
async def text_chat_stream( async def text_chat_stream(
self, self,
prompt, prompt,
@@ -658,9 +621,7 @@ class ProviderGoogleGenAI(Provider):
return [ return [
m.name.replace("models/", "") m.name.replace("models/", "")
for m in models for m in models
if m.supported_actions if "generateContent" in m.supported_actions
and "generateContent" in m.supported_actions
and m.name
] ]
except APIError as e: except APIError as e:
raise Exception(f"获取模型列表失败: {e.message}") raise Exception(f"获取模型列表失败: {e.message}")
@@ -675,7 +636,7 @@ class ProviderGoogleGenAI(Provider):
self.chosen_api_key = key self.chosen_api_key = key
self._init_client() self._init_client()
async def assemble_context(self, text: str, image_urls: list[str] | None = None): async def assemble_context(self, text: str, image_urls: list[str] = None):
""" """
组装上下文。 组装上下文。
""" """
+5 -12
View File
@@ -99,13 +99,12 @@ class ProviderOpenAIOfficial(Provider):
for key in to_del: for key in to_del:
del payloads[key] del payloads[key]
# 读取并合并 custom_extra_body 配置 model = payloads.get("model", "")
custom_extra_body = self.provider_config.get("custom_extra_body", {}) # 针对 qwen3 模型的特殊处理:非流式调用必须设置 enable_thinking=false
if isinstance(custom_extra_body, dict): if "qwen3" in model.lower():
extra_body.update(custom_extra_body) extra_body["enable_thinking"] = False
# 针对 deepseek 模型的特殊处理:deepseek-reasoner调用必须移除 tools ,否则将被切换至 deepseek-chat # 针对 deepseek 模型的特殊处理:deepseek-reasoner调用必须移除 tools ,否则将被切换至 deepseek-chat
if model == "deepseek-reasoner" and "tools" in payloads: elif model == "deepseek-reasoner" and "tools" in payloads:
del payloads["tools"] del payloads["tools"]
completion = await self.client.chat.completions.create( completion = await self.client.chat.completions.create(
@@ -138,12 +137,6 @@ class ProviderOpenAIOfficial(Provider):
# 不在默认参数中的参数放在 extra_body 中 # 不在默认参数中的参数放在 extra_body 中
extra_body = {} extra_body = {}
# 读取并合并 custom_extra_body 配置
custom_extra_body = self.provider_config.get("custom_extra_body", {})
if isinstance(custom_extra_body, dict):
extra_body.update(custom_extra_body)
to_del = [] to_del = []
for key in payloads.keys(): for key in payloads.keys():
if key not in self.default_params: if key not in self.default_params:
@@ -1,5 +1,4 @@
import aiohttp import aiohttp
from astrbot import logger
from ..provider import RerankProvider from ..provider import RerankProvider
from ..register import register_provider_adapter from ..register import register_provider_adapter
from ..entities import ProviderType, RerankResult from ..entities import ProviderType, RerankResult
@@ -45,11 +44,6 @@ class VLLMRerankProvider(RerankProvider):
response_data = await response.json() response_data = await response.json()
results = response_data.get("results", []) results = response_data.get("results", [])
if not results:
logger.warning(
f"Rerank API 返回了空的列表数据。原始响应: {response_data}"
)
return [ return [
RerankResult( RerankResult(
index=result["index"], index=result["index"],
+5 -7
View File
@@ -27,16 +27,14 @@ class Star(CommandParserMixin):
star_map[cls.__module__].star_cls_type = cls star_map[cls.__module__].star_cls_type = cls
star_map[cls.__module__].module_path = cls.__module__ star_map[cls.__module__].module_path = cls.__module__
async def text_to_image(self, text: str, return_url=True) -> str: @staticmethod
async def text_to_image(text: str, return_url=True) -> str:
"""将文本转换为图片""" """将文本转换为图片"""
return await html_renderer.render_t2i( return await html_renderer.render_t2i(text, return_url=return_url)
text,
return_url=return_url,
template_name=self.context._config.get("t2i_active_template"),
)
@staticmethod
async def html_render( async def html_render(
self, tmpl: str, data: dict, return_url=True, options: dict | None = None tmpl: str, data: dict, return_url=True, options: dict | None = None
) -> str: ) -> str:
"""渲染 HTML""" """渲染 HTML"""
return await html_renderer.render_custom_template( return await html_renderer.render_custom_template(
+3 -9
View File
@@ -7,6 +7,7 @@ from astrbot.core.config import AstrBotConfig
from .custom_filter import CustomFilter from .custom_filter import CustomFilter
from ..star_handler import StarHandlerMetadata from ..star_handler import StarHandlerMetadata
class GreedyStr(str): class GreedyStr(str):
"""标记指令完成其他参数接收后的所有剩余文本。""" """标记指令完成其他参数接收后的所有剩余文本。"""
@@ -152,17 +153,10 @@ class CommandFilter(HandlerFilter):
_full = f"{parent_command_name} {candidate}" _full = f"{parent_command_name} {candidate}"
else: else:
_full = candidate _full = candidate
if message_str == _full: if message_str.startswith(f"{_full} ") or message_str == _full:
# 完全等于命令名 → 没参数 message_str = message_str[len(_full) :].strip()
message_str = ""
ok = True ok = True
break break
elif message_str.startswith(_full):
# 命令名后面无论是空格还是直接连参数都可以
message_str = message_str[len(_full):].lstrip()
ok = True
break
if not ok: if not ok:
return False return False
@@ -18,7 +18,6 @@ class PlatformAdapterType(enum.Flag):
KOOK = enum.auto() KOOK = enum.auto()
VOCECHAT = enum.auto() VOCECHAT = enum.auto()
WEIXIN_OFFICIAL_ACCOUNT = enum.auto() WEIXIN_OFFICIAL_ACCOUNT = enum.auto()
SATORI = enum.auto()
ALL = ( ALL = (
AIOCQHTTP AIOCQHTTP
| QQOFFICIAL | QQOFFICIAL
@@ -32,7 +31,6 @@ class PlatformAdapterType(enum.Flag):
| KOOK | KOOK
| VOCECHAT | VOCECHAT
| WEIXIN_OFFICIAL_ACCOUNT | WEIXIN_OFFICIAL_ACCOUNT
| SATORI
) )
@@ -49,7 +47,6 @@ ADAPTER_NAME_2_TYPE = {
"wechatpadpro": PlatformAdapterType.WECHATPADPRO, "wechatpadpro": PlatformAdapterType.WECHATPADPRO,
"vocechat": PlatformAdapterType.VOCECHAT, "vocechat": PlatformAdapterType.VOCECHAT,
"weixin_official_account": PlatformAdapterType.WEIXIN_OFFICIAL_ACCOUNT, "weixin_official_account": PlatformAdapterType.WEIXIN_OFFICIAL_ACCOUNT,
"satori": PlatformAdapterType.SATORI,
} }
-2
View File
@@ -8,7 +8,6 @@ from .star_handler import (
register_permission_type, register_permission_type,
register_custom_filter, register_custom_filter,
register_on_astrbot_loaded, register_on_astrbot_loaded,
register_on_platform_loaded,
register_on_llm_request, register_on_llm_request,
register_on_llm_response, register_on_llm_response,
register_llm_tool, register_llm_tool,
@@ -27,7 +26,6 @@ __all__ = [
"register_permission_type", "register_permission_type",
"register_custom_filter", "register_custom_filter",
"register_on_astrbot_loaded", "register_on_astrbot_loaded",
"register_on_platform_loaded",
"register_on_llm_request", "register_on_llm_request",
"register_on_llm_response", "register_on_llm_response",
"register_llm_tool", "register_llm_tool",
+4 -18
View File
@@ -267,18 +267,6 @@ def register_on_astrbot_loaded(**kwargs):
return decorator return decorator
def register_on_platform_loaded(**kwargs):
"""
当平台加载完成时
"""
def decorator(awaitable):
_ = get_handler_or_create(awaitable, EventType.OnPlatformLoadedEvent, **kwargs)
return awaitable
return decorator
def register_on_llm_request(**kwargs): def register_on_llm_request(**kwargs):
"""当有 LLM 请求时的事件 """当有 LLM 请求时的事件
@@ -388,11 +376,9 @@ def register_llm_tool(name: str = None, **kwargs):
# print(f"Registering tool {llm_tool_name} for agent", registering_agent._agent.name) # print(f"Registering tool {llm_tool_name} for agent", registering_agent._agent.name)
if registering_agent._agent.tools is None: if registering_agent._agent.tools is None:
registering_agent._agent.tools = [] registering_agent._agent.tools = []
registering_agent._agent.tools.append( registering_agent._agent.tools.append(llm_tools.spec_to_func(
llm_tools.spec_to_func( llm_tool_name, args, docstring.description.strip(), awaitable
llm_tool_name, args, docstring.description.strip(), awaitable ))
)
)
return awaitable return awaitable
@@ -435,7 +421,7 @@ def register_agent(
run_hooks=run_hooks or BaseAgentRunHooks[AstrAgentContext](), run_hooks=run_hooks or BaseAgentRunHooks[AstrAgentContext](),
) )
handoff_tool = HandoffTool(agent=agent) handoff_tool = HandoffTool(agent=agent)
handoff_tool.handler = awaitable handoff_tool.handler=awaitable
llm_tools.func_list.append(handoff_tool) llm_tools.func_list.append(handoff_tool)
return RegisteringAgent(agent) return RegisteringAgent(agent)
+1 -9
View File
@@ -34,26 +34,19 @@ class StarHandlerRegistry(Generic[T]):
) -> List[StarHandlerMetadata]: ) -> List[StarHandlerMetadata]:
handlers = [] handlers = []
for handler in self._handlers: for handler in self._handlers:
# 过滤事件类型
if handler.event_type != event_type: if handler.event_type != event_type:
continue continue
# 过滤启用状态
if only_activated: if only_activated:
plugin = star_map.get(handler.handler_module_path) plugin = star_map.get(handler.handler_module_path)
if not (plugin and plugin.activated): if not (plugin and plugin.activated):
continue continue
# 过滤插件白名单
if plugins_name is not None and plugins_name != ["*"]: if plugins_name is not None and plugins_name != ["*"]:
plugin = star_map.get(handler.handler_module_path) plugin = star_map.get(handler.handler_module_path)
if not plugin: if not plugin:
continue continue
if ( if (
plugin.name not in plugins_name plugin.name not in plugins_name
and event_type and event_type != EventType.OnAstrBotLoadedEvent
not in (
EventType.OnAstrBotLoadedEvent,
EventType.OnPlatformLoadedEvent,
)
and not plugin.reserved and not plugin.reserved
): ):
continue continue
@@ -97,7 +90,6 @@ class EventType(enum.Enum):
""" """
OnAstrBotLoadedEvent = enum.auto() # AstrBot 加载完成 OnAstrBotLoadedEvent = enum.auto() # AstrBot 加载完成
OnPlatformLoadedEvent = enum.auto() # 平台加载完成
AdapterMessageEvent = enum.auto() # 收到适配器发来的消息 AdapterMessageEvent = enum.auto() # 收到适配器发来的消息
OnLLMRequestEvent = enum.auto() # 收到 LLM 请求(可以是用户也可以是插件) OnLLMRequestEvent = enum.auto() # 收到 LLM 请求(可以是用户也可以是插件)
+21 -116
View File
@@ -1,37 +1,15 @@
"""
插件开发工具集
封装了许多常用的操作,方便插件开发者使用
说明:
主动发送消息: send_message(session, message_chain)
根据 session (unified_msg_origin) 主动发送消息, 前提是需要提前获得或构造 session
根据id直接主动发送消息: send_message_by_id(type, id, message_chain, platform="aiocqhttp")
根据 id (例如 qq 号, 群号等) 直接, 主动地发送消息
以上两种方式需要构造消息链, 也就是消息组件的列表
构造事件:
首先需要构造一个 AstrBotMessage 对象, 使用 create_message 方法
然后使用 create_event 方法提交事件到指定平台
"""
import inspect import inspect
import os import os
import uuid
from pathlib import Path from pathlib import Path
from typing import Union, Awaitable, List, Optional, ClassVar from typing import Union, Awaitable, List, Optional, ClassVar
from astrbot.core.message.components import BaseMessageComponent from astrbot.core.message.components import BaseMessageComponent
from astrbot.core.message.message_event_result import MessageChain from astrbot.core.message.message_event_result import MessageChain
from astrbot.api.platform import MessageMember, AstrBotMessage, MessageType from astrbot.api.platform import MessageMember, AstrBotMessage
from astrbot.core.platform.astr_message_event import MessageSesion from astrbot.core.platform.astr_message_event import MessageSesion
from astrbot.core.star.context import Context from astrbot.core.star.context import Context
from astrbot.core.star.star import star_map from astrbot.core.star.star import star_map
from astrbot.core.utils.astrbot_path import get_astrbot_data_path from astrbot.core.utils.astrbot_path import get_astrbot_data_path
from astrbot.core.platform.sources.aiocqhttp.aiocqhttp_message_event import AiocqhttpMessageEvent
from astrbot.core.platform.sources.aiocqhttp.aiocqhttp_platform_adapter import AiocqhttpAdapter
class StarTools: class StarTools:
""" """
@@ -71,76 +49,42 @@ class StarTools:
Note: Note:
qq_official(QQ官方API平台)不支持此方法 qq_official(QQ官方API平台)不支持此方法
""" """
if cls._context is None:
raise ValueError("StarTools not initialized")
return await cls._context.send_message(session, message_chain) return await cls._context.send_message(session, message_chain)
@classmethod
async def send_message_by_id(
cls, type: str, id: str, message_chain: MessageChain, platform: str = "aiocqhttp"
):
"""
根据 id(例如qq号, 群号等) 直接, 主动地发送消息
Args:
type (str): 消息类型, 可选: PrivateMessage, GroupMessage
id (str): 目标ID, 例如QQ号, 群号等
message_chain (MessageChain): 消息链
platform (str): 可选的平台名称,默认平台(aiocqhttp), 目前只支持 aiocqhttp
"""
if cls._context is None:
raise ValueError("StarTools not initialized")
platforms = cls._context.platform_manager.get_insts()
if platform == "aiocqhttp":
adapter = next((p for p in platforms if isinstance(p, AiocqhttpAdapter)), None)
if adapter is None:
raise ValueError("未找到适配器: AiocqhttpAdapter")
await AiocqhttpMessageEvent.send_message(
bot=adapter.bot,
message_chain=message_chain,
is_group=(type == "GroupMessage"),
session_id=id,
)
else:
raise ValueError(f"不支持的平台: {platform}")
@classmethod @classmethod
async def create_message( async def create_message(
cls, cls,
type: str, type: str,
self_id: str, self_id: str,
session_id: str, session_id: str,
message_id: str,
sender: MessageMember, sender: MessageMember,
message: List[BaseMessageComponent], message: List[BaseMessageComponent],
message_str: str, message_str: str,
message_id: str = "", raw_message: object,
raw_message: object = None, group_id: str = "",
group_id: str = "" ):
) -> AstrBotMessage:
""" """
创建一个AstrBot消息对象 创建一个AstrBot消息对象
Args: Args:
type (str): 消息类型, 例如 "GroupMessage" "FriendMessage" "OtherMessage" type (str): 消息类型
self_id (str): 机器人自身ID self_id (str): 机器人自身ID
session_id (str): 会话ID(通常为用户ID)(QQ号, 群号等) session_id (str): 会话ID(通常为用户ID)(QQ号, 群号等)
sender (MessageMember): 发送者信息, 例如 MessageMember(user_id="123456", nickname="昵称") message_id (str): 消息ID
message (List[BaseMessageComponent]): 消息组件列表, 也就是消息链, 这个不会发给 llm, 但是会经过其他处理 sender (MessageMember): 发送者信息
message_str (str): 消息字符串, 也就是纯文本消息, 也就是发送给 llm 的消息, 与消息链一致 message (List[BaseMessageComponent]): 消息组件列表
message_str (str): 消息字符串
message_id (str): 消息ID, 构造消息时可以随意填写也可不填 raw_message (object): 原始消息对象
raw_message (object): 原始消息对象, 可以随意填写也可不填
group_id (str, optional): 群组ID, 如果为私聊则为空. Defaults to "". group_id (str, optional): 群组ID, 如果为私聊则为空. Defaults to "".
Returns: Returns:
AstrBotMessage: 创建的消息对象 AstrBotMessage: 创建的消息对象
""" """
abm = AstrBotMessage() abm = AstrBotMessage()
abm.type = MessageType(type) abm.type = type
abm.self_id = self_id abm.self_id = self_id
abm.session_id = session_id abm.session_id = session_id
if message_id == "":
message_id = uuid.uuid4().hex
abm.message_id = message_id abm.message_id = message_id
abm.sender = sender abm.sender = sender
abm.message = message abm.message = message
@@ -149,38 +93,13 @@ class StarTools:
abm.group_id = group_id abm.group_id = group_id
return abm return abm
@classmethod # todo: 添加构造事件的方法
async def create_event( # async def create_event(
cls, abm: AstrBotMessage, platform: str = "aiocqhttp", is_wake: bool = True # self, platform: str, umo: str, sender_id: str, session_id: str
# ):
# platform = self._context.get_platform(platform)
) -> None: # todo: 添加找到对应平台并提交对应事件的方法
"""
创建并提交事件到指定平台
当有需要创建一个事件, 触发某些处理流程时, 使用该方法
Args:
abm (AstrBotMessage): 要提交的消息对象, 请先使用 create_message 创建
platform (str): 可选的平台名称,默认平台(aiocqhttp), 目前只支持 aiocqhttp
is_wake (bool): 是否标记为唤醒事件, 默认为 True, 只有唤醒事件才会被 llm 响应
"""
if cls._context is None:
raise ValueError("StarTools not initialized")
platforms = cls._context.platform_manager.get_insts()
if platform == "aiocqhttp":
adapter = next((p for p in platforms if isinstance(p, AiocqhttpAdapter)), None)
if adapter is None:
raise ValueError("未找到适配器: AiocqhttpAdapter")
event = AiocqhttpMessageEvent(
message_str=abm.message_str,
message_obj=abm,
platform_meta=adapter.metadata,
session_id=abm.session_id,
bot=adapter.bot,
)
event.is_wake = is_wake
adapter.commit_event(event)
else:
raise ValueError(f"不支持的平台: {platform}")
@classmethod @classmethod
def activate_llm_tool(cls, name: str) -> bool: def activate_llm_tool(cls, name: str) -> bool:
@@ -191,8 +110,6 @@ class StarTools:
Args: Args:
name (str): 工具名称 name (str): 工具名称
""" """
if cls._context is None:
raise ValueError("StarTools not initialized")
return cls._context.activate_llm_tool(name) return cls._context.activate_llm_tool(name)
@classmethod @classmethod
@@ -203,8 +120,6 @@ class StarTools:
Args: Args:
name (str): 工具名称 name (str): 工具名称
""" """
if cls._context is None:
raise ValueError("StarTools not initialized")
return cls._context.deactivate_llm_tool(name) return cls._context.deactivate_llm_tool(name)
@classmethod @classmethod
@@ -220,8 +135,6 @@ class StarTools:
desc (str): 工具描述 desc (str): 工具描述
func_obj (Awaitable): 函数对象,必须是异步函数 func_obj (Awaitable): 函数对象,必须是异步函数
""" """
if cls._context is None:
raise ValueError("StarTools not initialized")
cls._context.register_llm_tool(name, func_args, desc, func_obj) cls._context.register_llm_tool(name, func_args, desc, func_obj)
@classmethod @classmethod
@@ -233,8 +146,6 @@ class StarTools:
Args: Args:
name (str): 工具名称 name (str): 工具名称
""" """
if cls._context is None:
raise ValueError("StarTools not initialized")
cls._context.unregister_llm_tool(name) cls._context.unregister_llm_tool(name)
@classmethod @classmethod
@@ -258,11 +169,8 @@ class StarTools:
- 创建目录失败(权限不足或其他IO错误) - 创建目录失败(权限不足或其他IO错误)
""" """
if not plugin_name: if not plugin_name:
frame = inspect.currentframe() frame = inspect.currentframe().f_back
module = None module = inspect.getmodule(frame)
if frame:
frame = frame.f_back
module = inspect.getmodule(frame)
if not module: if not module:
raise RuntimeError("无法获取调用者模块信息") raise RuntimeError("无法获取调用者模块信息")
@@ -274,9 +182,6 @@ class StarTools:
plugin_name = metadata.name plugin_name = metadata.name
if not plugin_name:
raise ValueError("无法获取插件名称")
data_dir = Path(os.path.join(get_astrbot_data_path(), "plugin_data", plugin_name)) data_dir = Path(os.path.join(get_astrbot_data_path(), "plugin_data", plugin_name))
try: try:
+5 -7
View File
@@ -56,7 +56,9 @@ class AstrBotUpdator(RepoZipUpdator):
try: try:
if "astrbot" in os.path.basename(sys.argv[0]): # 兼容cli if "astrbot" in os.path.basename(sys.argv[0]): # 兼容cli
if os.name == "nt": if os.name == "nt":
args = [f'"{arg}"' if " " in arg else arg for arg in sys.argv[1:]] args = [
f'"{arg}"' if " " in arg else arg for arg in sys.argv[1:]
]
else: else:
args = sys.argv[1:] args = sys.argv[1:]
os.execl(sys.executable, py, "-m", "astrbot.cli.__main__", *args) os.execl(sys.executable, py, "-m", "astrbot.cli.__main__", *args)
@@ -66,13 +68,9 @@ class AstrBotUpdator(RepoZipUpdator):
logger.error(f"重启失败({py}, {e}),请尝试手动重启。") logger.error(f"重启失败({py}, {e}),请尝试手动重启。")
raise e raise e
async def check_update( async def check_update(self, url: str, current_version: str) -> ReleaseInfo:
self, url: str, current_version: str, consider_prerelease: bool = True
) -> ReleaseInfo:
"""检查更新""" """检查更新"""
return await super().check_update( return await super().check_update(self.ASTRBOT_RELEASE_API, VERSION)
self.ASTRBOT_RELEASE_API, VERSION, consider_prerelease
)
async def get_releases(self) -> list: async def get_releases(self) -> list:
return await self.fetch_release_info(self.ASTRBOT_RELEASE_API) return await self.fetch_release_info(self.ASTRBOT_RELEASE_API)
+1 -1
View File
@@ -227,7 +227,7 @@ async def download_dashboard(
path = os.path.join(get_astrbot_data_path(), "dashboard.zip") path = os.path.join(get_astrbot_data_path(), "dashboard.zip")
if latest or len(str(version)) != 40: if latest or len(str(version)) != 40:
logger.info(f"准备下载 {version} 发行版本的 AstrBot WebUI 文件") logger.info("准备下载最新发行版本的 AstrBot WebUI")
ver_name = "latest" if latest else version ver_name = "latest" if latest else version
dashboard_release_url = f"https://astrbot-registry.soulter.top/download/astrbot-dashboard/{ver_name}/dist.zip" dashboard_release_url = f"https://astrbot-registry.soulter.top/download/astrbot-dashboard/{ver_name}/dist.zip"
try: try:
+18 -11
View File
@@ -1,5 +1,6 @@
import aiohttp import aiohttp
import asyncio import asyncio
import os
import ssl import ssl
import certifi import certifi
import logging import logging
@@ -7,9 +8,10 @@ import random
from . import RenderStrategy from . import RenderStrategy
from astrbot.core.config import VERSION from astrbot.core.config import VERSION
from astrbot.core.utils.io import download_image_by_url from astrbot.core.utils.io import download_image_by_url
from astrbot.core.utils.t2i.template_manager import TemplateManager from astrbot.core.utils.astrbot_path import get_astrbot_data_path
ASTRBOT_T2I_DEFAULT_ENDPOINT = "https://t2i.soulter.top/text2img" ASTRBOT_T2I_DEFAULT_ENDPOINT = "https://t2i.soulter.top/text2img"
CUSTOM_T2I_TEMPLATE_PATH = os.path.join(get_astrbot_data_path(), "t2i_template.html")
logger = logging.getLogger("astrbot") logger = logging.getLogger("astrbot")
@@ -21,17 +23,26 @@ class NetworkRenderStrategy(RenderStrategy):
self.BASE_RENDER_URL = ASTRBOT_T2I_DEFAULT_ENDPOINT self.BASE_RENDER_URL = ASTRBOT_T2I_DEFAULT_ENDPOINT
else: else:
self.BASE_RENDER_URL = self._clean_url(base_url) self.BASE_RENDER_URL = self._clean_url(base_url)
self.TEMPLATE_PATH = os.path.join(os.path.dirname(__file__), "template", "base.html")
with open(self.TEMPLATE_PATH, "r", encoding="utf-8") as f:
self.DEFAULT_TEMPLATE = f.read()
self.endpoints = [self.BASE_RENDER_URL] self.endpoints = [self.BASE_RENDER_URL]
self.template_manager = TemplateManager()
async def initialize(self): async def initialize(self):
if self.BASE_RENDER_URL == ASTRBOT_T2I_DEFAULT_ENDPOINT: if self.BASE_RENDER_URL == ASTRBOT_T2I_DEFAULT_ENDPOINT:
asyncio.create_task(self.get_official_endpoints()) asyncio.create_task(self.get_official_endpoints())
async def get_template(self, name: str = "base") -> str: async def get_template(self) -> str:
"""通过名称获取文转图 HTML 模板""" """获取文转图 HTML 模板
return self.template_manager.get_template(name)
Returns:
str: 文转图 HTML 模板字符串
"""
if os.path.exists(CUSTOM_T2I_TEMPLATE_PATH):
with open(CUSTOM_T2I_TEMPLATE_PATH, "r", encoding="utf-8") as f:
return f.read()
return self.DEFAULT_TEMPLATE
async def get_official_endpoints(self): async def get_official_endpoints(self):
"""获取官方的 t2i 端点列表。""" """获取官方的 t2i 端点列表。"""
@@ -113,15 +124,11 @@ class NetworkRenderStrategy(RenderStrategy):
logger.error(f"All endpoints failed: {last_exception}") logger.error(f"All endpoints failed: {last_exception}")
raise RuntimeError(f"All endpoints failed: {last_exception}") raise RuntimeError(f"All endpoints failed: {last_exception}")
async def render( async def render(self, text: str, return_url: bool = False) -> str:
self, text: str, return_url: bool = False, template_name: str | None = "base"
) -> str:
""" """
返回图像的文件路径 返回图像的文件路径
""" """
if not template_name: tmpl_str = await self.get_template()
template_name = "base"
tmpl_str = await self.get_template(name=template_name)
text = text.replace("`", "\\`") text = text.replace("`", "\\`")
return await self.render_custom_template( return await self.render_custom_template(
tmpl_str, {"text": text, "version": f"v{VERSION}"}, return_url tmpl_str, {"text": text, "version": f"v{VERSION}"}, return_url
+2 -8
View File
@@ -34,18 +34,12 @@ class HtmlRenderer:
) )
async def render_t2i( async def render_t2i(
self, self, text: str, use_network: bool = True, return_url: bool = False
text: str,
use_network: bool = True,
return_url: bool = False,
template_name: str | None = None,
): ):
"""使用默认文转图模板。""" """使用默认文转图模板。"""
if use_network: if use_network:
try: try:
return await self.network_strategy.render( return await self.network_strategy.render(text, return_url=return_url)
text, return_url=return_url, template_name=template_name
)
except BaseException as e: except BaseException as e:
logger.error( logger.error(
f"Failed to render image via AstrBot API: {e}. Falling back to local rendering." f"Failed to render image via AstrBot API: {e}. Falling back to local rendering."
@@ -1,184 +0,0 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8"/>
<title>Astrbot PowerShell {{ version }} </title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.10/dist/katex.min.css" integrity="sha384-wcIxkf4k558AjM3Yz3BBFQUbk/zgIYC2R0QpeeYb+TwlBVMrlgLqwRjRtGZiK7ww" crossorigin="anonymous">
<script src="https://cdn.jsdelivr.net/npm/highlight.js@11.9.0/lib/common.min.js"></script>
<script>hljs.highlightAll();</script>
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.10/dist/katex.min.js" integrity="sha384-hIoBPJpTUs74ddyc4bFZSM1TVlQDA60VBbJS0oA934VSz82sBx1X7kSx2ATBDIyd" crossorigin="anonymous"></script>
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.10/dist/contrib/auto-render.min.js" integrity="sha384-43gviWU0YVjaDtb/GhzOouOXtZMP/7XUzwPTstBeZFe/+rCMvRwr4yROQP43s0Xk" crossorigin="anonymous"
onload="renderMathInElement(document.getElementById('content'),{delimiters: [{left: '$$', right: '$$', display: true},{left: '$', right: '$', display: false}]});"></script>
<style>
:root {
--bg-color: #010409;
--text-color: #e6edf3;
--title-bar-color: #161b22;
--title-text-color: #e6edf3;
--font-family: 'Consolas', 'Microsoft YaHei Mono', 'Dengxian Mono', 'Courier New', monospace;
--glow-color: rgba(200, 220, 255, 0.7);
}
@keyframes scanline {
0% {
background-position: 0 0;
}
100% {
background-position: 0 100%;
}
}
body {
background-color: var(--bg-color);
color: var(--text-color);
font-family: var(--font-family);
margin: 0;
padding: 0;
line-height: 1.6;
font-size: 18px;
/* The CRT glow effect from the image */
text-shadow: 0 0 15px var(--glow-color), 0 0 7px rgba(255, 255, 255, 1);
position: relative;
overflow: hidden;
}
body::after {
content: " ";
display: block;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(to bottom, transparent 50%, rgba(0, 0, 0, 0.3) 50%);
background-size: 100% 4px;
z-index: 2;
pointer-events: none;
animation: scanline 8s linear infinite;
}
.header {
background-color: var(--title-bar-color);
padding: 12px 18px;
color: var(--title-text-color);
font-size: 16px;
border-bottom: 1px solid #30363d;
text-shadow: none; /* No glow for title bar */
}
.header .title {
font-weight: bold;
font-size: 28px;
}
.header .version {
opacity: 0.8;
margin-left: 1rem;
}
main {
padding: 1rem 1.5rem;
}
#content {
/* min-width and max-width removed as per request */
}
/* --- Markdown Styles adjusted for terminal look --- */
h1, h2, h3, h4, h5, h6 {
line-height: 1.4;
margin-top: 20px;
margin-bottom: 10px;
padding-bottom: 5px;
border-bottom: 1px solid #30363d;
color: var(--text-color);
}
h1 { font-size: 2rem; }
h2 { font-size: 1.7rem; }
h3 { font-size: 1.4rem; }
p {
margin-top: 1rem;
margin-bottom: 1rem;
}
strong {
color: var(--text-color);
font-weight: bold;
}
img {
max-width: 100%;
border: 1px solid #30363d;
display: block;
margin: 1rem auto;
}
hr {
border: 0;
border-top: 1px dashed #30363d;
margin: 2rem 0;
}
code {
font-family: var(--font-family);
padding: 0.2em 0.4em;
margin: 0;
font-size: 90%;
background-color: #161b22;
border-radius: 4px;
}
pre {
font-family: var(--font-family);
border-radius: 4px;
background: #0d1117;
padding: 1rem;
overflow-x: auto;
border: 1px solid #30363d;
}
pre > code {
padding: 0;
margin: 0;
font-size: 100%;
background-color: transparent;
border-radius: 0;
text-shadow: none; /* Disable glow inside code blocks for clarity */
}
a {
color: #58a6ff;
text-decoration: underline;
}
a:hover {
text-decoration: underline;
}
blockquote {
border-left: 4px solid #30363d;
padding: 0.5rem 1rem;
margin: 1.5rem 0;
color: #8b949e;
background-color: #161b22;
}
</style>
</head>
<body>
<div class="header">
<span class="title">> Astrbot PowerShell</span>
<span class="version">{{ version }}</span>
</div>
<main>
<div id="content"></div>
</main>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script>
document.getElementById('content').innerHTML = marked.parse(`{{ text | safe }}`);
</script>
</body>
</html>
-112
View File
@@ -1,112 +0,0 @@
# astrbot/core/utils/t2i/template_manager.py
import os
import shutil
from astrbot.core.utils.astrbot_path import get_astrbot_data_path, get_astrbot_path
class TemplateManager:
"""
负责管理 t2i HTML 模板的 CRUD 和重置操作。
采用“用户覆盖内置”策略:用户模板存储在 data 目录中,并优先于内置模板加载。
所有创建、更新、删除操作仅影响用户目录,以确保更新框架时用户数据安全。
"""
CORE_TEMPLATES = ["base.html", "astrbot_powershell.html"]
def __init__(self):
self.builtin_template_dir = os.path.join(
get_astrbot_path(), "astrbot", "core", "utils", "t2i", "template"
)
self.user_template_dir = os.path.join(get_astrbot_data_path(), "t2i_templates")
os.makedirs(self.user_template_dir, exist_ok=True)
self._initialize_user_templates()
def _copy_core_templates(self, overwrite: bool = False):
"""从内置目录复制核心模板到用户目录。"""
for filename in self.CORE_TEMPLATES:
src = os.path.join(self.builtin_template_dir, filename)
dst = os.path.join(self.user_template_dir, filename)
if os.path.exists(src) and (overwrite or not os.path.exists(dst)):
shutil.copyfile(src, dst)
def _initialize_user_templates(self):
"""如果用户目录下缺少核心模板,则进行复制。"""
self._copy_core_templates(overwrite=False)
def _get_user_template_path(self, name: str) -> str:
"""获取用户模板的完整路径,防止路径遍历漏洞。"""
if ".." in name or "/" in name or "\\" in name:
raise ValueError("模板名称包含非法字符。")
return os.path.join(self.user_template_dir, f"{name}.html")
def _read_file(self, path: str) -> str:
"""读取文件内容。"""
with open(path, "r", encoding="utf-8") as f:
return f.read()
def list_templates(self) -> list[dict]:
"""
列出所有可用模板。
该列表是内置模板和用户模板的合并视图,用户模板将覆盖同名的内置模板。
"""
dirs_to_scan = [self.builtin_template_dir, self.user_template_dir]
all_names = {
os.path.splitext(f)[0]
for d in dirs_to_scan
for f in os.listdir(d)
if f.endswith(".html")
}
return [
{"name": name, "is_default": name == "base"} for name in sorted(all_names)
]
def get_template(self, name: str) -> str:
"""
获取指定模板的内容。
优先从用户目录加载,如果不存在则回退到内置目录。
"""
user_path = self._get_user_template_path(name)
if os.path.exists(user_path):
return self._read_file(user_path)
builtin_path = os.path.join(self.builtin_template_dir, f"{name}.html")
if os.path.exists(builtin_path):
return self._read_file(builtin_path)
raise FileNotFoundError("模板不存在。")
def create_template(self, name: str, content: str):
"""在用户目录中创建一个新的模板文件。"""
path = self._get_user_template_path(name)
if os.path.exists(path):
raise FileExistsError("同名模板已存在。")
with open(path, "w", encoding="utf-8") as f:
f.write(content)
def update_template(self, name: str, content: str):
"""
更新一个模板。此操作始终写入用户目录。
如果更新的是一个内置模板,此操作实际上会在用户目录中创建一个修改后的副本,
从而实现对内置模板的“覆盖”。
"""
path = self._get_user_template_path(name)
with open(path, "w", encoding="utf-8") as f:
f.write(content)
def delete_template(self, name: str):
"""
仅删除用户目录中的模板文件。
如果删除的是一个覆盖了内置模板的用户模板,这将有效地“恢复”到内置版本。
"""
path = self._get_user_template_path(name)
if not os.path.exists(path):
raise FileNotFoundError("用户模板不存在,无法删除。")
os.remove(path)
def reset_default_template(self):
"""
将核心模板从内置目录强制重置到用户目录。
"""
self._copy_core_templates(overwrite=True)
+4 -26
View File
@@ -107,38 +107,16 @@ class RepoZipUpdator:
"""Semver 版本比较""" """Semver 版本比较"""
return VersionComparator.compare_version(v1, v2) return VersionComparator.compare_version(v1, v2)
async def check_update( async def check_update(self, url: str, current_version: str) -> ReleaseInfo | None:
self, url: str, current_version: str, consider_prerelease: bool = True
) -> ReleaseInfo | None:
update_data = await self.fetch_release_info(url) update_data = await self.fetch_release_info(url)
tag_name = update_data[0]["tag_name"]
sel_release_data = None
if consider_prerelease:
tag_name = update_data[0]["tag_name"]
sel_release_data = update_data[0]
else:
for data in update_data:
# 跳过带有 alpha、beta 等预发布标签的版本
if re.search(
r"[\-_.]?(alpha|beta|rc|dev)[\-_.]?\d*$",
data["tag_name"],
re.IGNORECASE,
):
continue
tag_name = data["tag_name"]
sel_release_data = data
break
if not sel_release_data or not tag_name:
logger.error("未找到合适的发布版本")
return None
if self.compare_version(current_version, tag_name) >= 0: if self.compare_version(current_version, tag_name) >= 0:
return None return None
return ReleaseInfo( return ReleaseInfo(
version=tag_name, version=tag_name,
published_at=sel_release_data["published_at"], published_at=update_data[0]["published_at"],
body=f"{tag_name}\n\n{sel_release_data['body']}", body=update_data[0]["body"],
) )
async def download_from_repo_url(self, target_path: str, repo_url: str, proxy=""): async def download_from_repo_url(self, target_path: str, repo_url: str, proxy=""):
+1 -6
View File
@@ -157,11 +157,7 @@ class ChatRoute(Route):
if type == "end": if type == "end":
break break
elif ( elif (streaming and type == "complete") or not streaming:
(streaming and type == "complete")
or not streaming
or type == "break"
):
# append bot message # append bot message
new_his = {"type": "bot", "message": result_text} new_his = {"type": "bot", "message": result_text}
await self.platform_history_mgr.insert( await self.platform_history_mgr.insert(
@@ -201,7 +197,6 @@ class ChatRoute(Route):
"Connection": "keep-alive", "Connection": "keep-alive",
}, },
) )
response.timeout = None # fix SSE auto disconnect issue
return response return response
async def _get_webchat_conv_id_from_conv_id(self, conversation_id: str) -> str: async def _get_webchat_conv_id_from_conv_id(self, conversation_id: str) -> str:
+49 -24
View File
@@ -1,7 +1,6 @@
import typing import typing
import traceback import traceback
import os import os
import copy
from .route import Route, Response, RouteContext from .route import Route, Response, RouteContext
from astrbot.core.provider.entities import ProviderType from astrbot.core.provider.entities import ProviderType
from quart import request from quart import request
@@ -17,10 +16,10 @@ from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
from astrbot.core.platform.register import platform_registry from astrbot.core.platform.register import platform_registry
from astrbot.core.provider.register import provider_registry from astrbot.core.provider.register import provider_registry
from astrbot.core.star.star import star_registry from astrbot.core.star.star import star_registry
from astrbot.core import logger from astrbot.core import logger, html_renderer
from astrbot.core.provider import Provider from astrbot.core.provider import Provider
from astrbot.core.provider.provider import RerankProvider
import asyncio import asyncio
from astrbot.core.utils.t2i.network_strategy import CUSTOM_T2I_TEMPLATE_PATH
def try_cast(value: str, type_: str): def try_cast(value: str, type_: str):
@@ -156,7 +155,6 @@ def save_config(post_config: dict, config: AstrBotConfig, is_core: bool = False)
raise ValueError(f"验证配置时出现异常: {e}") raise ValueError(f"验证配置时出现异常: {e}")
if errors: if errors:
raise ValueError(f"格式校验未通过: {errors}") raise ValueError(f"格式校验未通过: {errors}")
config.save_config(post_config) config.save_config(post_config)
@@ -187,9 +185,56 @@ class ConfigRoute(Route):
"/config/provider/check_one": ("GET", self.check_one_provider_status), "/config/provider/check_one": ("GET", self.check_one_provider_status),
"/config/provider/list": ("GET", self.get_provider_config_list), "/config/provider/list": ("GET", self.get_provider_config_list),
"/config/provider/model_list": ("GET", self.get_provider_model_list), "/config/provider/model_list": ("GET", self.get_provider_model_list),
"/config/astrbot/t2i-template/get": ("GET", self.get_t2i_template),
"/config/astrbot/t2i-template/save": ("POST", self.post_t2i_template),
"/config/astrbot/t2i-template/delete": ("DELETE", self.delete_t2i_template),
} }
self.register_routes() self.register_routes()
async def get_t2i_template(self):
"""获取 T2I 模板"""
try:
template = await html_renderer.network_strategy.get_template()
has_custom_template = os.path.exists(CUSTOM_T2I_TEMPLATE_PATH)
return (
Response()
.ok({"template": template, "has_custom_template": has_custom_template})
.__dict__
)
except Exception as e:
logger.error(traceback.format_exc())
return Response().error(f"获取模板失败: {str(e)}").__dict__
async def post_t2i_template(self):
"""保存 T2I 模板"""
try:
post_data = await request.json
if not post_data or "template" not in post_data:
return Response().error("缺少模板内容").__dict__
template_content = post_data["template"]
# 保存自定义模板到文件
with open(CUSTOM_T2I_TEMPLATE_PATH, "w", encoding="utf-8") as f:
f.write(template_content)
return Response().ok(message="模板保存成功").__dict__
except Exception as e:
logger.error(traceback.format_exc())
return Response().error(f"保存模板失败: {str(e)}").__dict__
async def delete_t2i_template(self):
"""删除自定义 T2I 模板,恢复默认模板"""
try:
if os.path.exists(CUSTOM_T2I_TEMPLATE_PATH):
os.remove(CUSTOM_T2I_TEMPLATE_PATH)
return Response().ok(message="已恢复默认模板").__dict__
else:
return Response().ok(message="未找到自定义模板文件").__dict__
except Exception as e:
logger.error(traceback.format_exc())
return Response().error(f"删除模板失败: {str(e)}").__dict__
async def get_abconf_list(self): async def get_abconf_list(self):
"""获取所有 AstrBot 配置文件的列表""" """获取所有 AstrBot 配置文件的列表"""
abconf_list = self.acm.get_conf_list() abconf_list = self.acm.get_conf_list()
@@ -436,19 +481,6 @@ class ConfigRoute(Route):
) )
status_info["status"] = "unavailable" status_info["status"] = "unavailable"
status_info["error"] = f"STT test failed: {str(e)}" status_info["error"] = f"STT test failed: {str(e)}"
elif provider_capability_type == ProviderType.RERANK:
try:
assert isinstance(provider, RerankProvider)
await provider.rerank("Apple", documents=["apple", "banana"])
status_info["status"] = "available"
except Exception as e:
logger.error(
f"Error testing rerank provider {provider_name}: {e}",
exc_info=True,
)
status_info["status"] = "unavailable"
status_info["error"] = f"Rerank test failed: {str(e)}"
else: else:
logger.debug( logger.debug(
f"Provider {provider_name} is not a Chat Completion or Embedding provider. Marking as available without test. Meta: {meta}" f"Provider {provider_name} is not a Chat Completion or Embedding provider. Marking as available without test. Meta: {meta}"
@@ -720,13 +752,6 @@ class ConfigRoute(Route):
if conf_id not in self.acm.confs: if conf_id not in self.acm.confs:
raise ValueError(f"配置文件 {conf_id} 不存在") raise ValueError(f"配置文件 {conf_id} 不存在")
astrbot_config = self.acm.confs[conf_id] astrbot_config = self.acm.confs[conf_id]
# 保留服务端的 t2i_active_template 值
if "t2i_active_template" in astrbot_config:
post_configs["t2i_active_template"] = astrbot_config[
"t2i_active_template"
]
save_config(post_configs, astrbot_config, is_core=True) save_config(post_configs, astrbot_config, is_core=True)
except Exception as e: except Exception as e:
raise e raise e
+2 -19
View File
@@ -1,4 +1,3 @@
from astrbot.core import logger
from astrbot.core.config.astrbot_config import AstrBotConfig from astrbot.core.config.astrbot_config import AstrBotConfig
from dataclasses import dataclass from dataclasses import dataclass
from quart import Quart from quart import Quart
@@ -16,24 +15,8 @@ class Route:
self.config = context.config self.config = context.config
def register_routes(self): def register_routes(self):
def _add_rule(path, method, func): for route, (method, func) in self.routes.items():
# 统一添加 /api 前缀 self.app.add_url_rule(f"/api{route}", view_func=func, methods=[method])
full_path = f"/api{path}"
self.app.add_url_rule(full_path, view_func=func, methods=[method])
# 兼容字典和列表两种格式
routes_to_register = (
self.routes.items() if isinstance(self.routes, dict) else self.routes
)
for route, definition in routes_to_register:
# 兼容一个路由多个方法
if isinstance(definition, list):
for method, func in definition:
_add_rule(route, method, func)
else:
method, func = definition
_add_rule(route, method, func)
@dataclass @dataclass
-230
View File
@@ -1,230 +0,0 @@
# astrbot/dashboard/routes/t2i.py
from dataclasses import asdict
from quart import jsonify, request
from astrbot.core import logger
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
from astrbot.core.utils.t2i.template_manager import TemplateManager
from .route import Response, Route, RouteContext
class T2iRoute(Route):
def __init__(self, context: RouteContext, core_lifecycle: AstrBotCoreLifecycle):
super().__init__(context)
self.core_lifecycle = core_lifecycle
self.config = core_lifecycle.astrbot_config
self.manager = TemplateManager()
# 使用列表保证路由注册顺序,避免 /<name> 路由优先匹配 /reset_default
self.routes = [
("/t2i/templates", ("GET", self.list_templates)),
("/t2i/templates/active", ("GET", self.get_active_template)),
("/t2i/templates/create", ("POST", self.create_template)),
("/t2i/templates/reset_default", ("POST", self.reset_default_template)),
("/t2i/templates/set_active", ("POST", self.set_active_template)),
# 动态路由应该在静态路由之后注册
(
"/t2i/templates/<name>",
[
("GET", self.get_template),
("PUT", self.update_template),
("DELETE", self.delete_template),
],
),
]
self.register_routes()
async def list_templates(self):
"""获取所有T2I模板列表"""
try:
templates = self.manager.list_templates()
return jsonify(asdict(Response().ok(data=templates)))
except Exception as e:
response = jsonify(asdict(Response().error(str(e))))
response.status_code = 500
return response
async def get_active_template(self):
"""获取当前激活的T2I模板"""
try:
active_template = self.config.get("t2i_active_template", "base")
return jsonify(
asdict(Response().ok(data={"active_template": active_template}))
)
except Exception as e:
logger.error("Error in get_active_template", exc_info=True)
response = jsonify(asdict(Response().error(str(e))))
response.status_code = 500
return response
async def get_template(self, name: str):
"""获取指定名称的T2I模板内容"""
try:
content = self.manager.get_template(name)
return jsonify(
asdict(Response().ok(data={"name": name, "content": content}))
)
except FileNotFoundError:
response = jsonify(asdict(Response().error("Template not found")))
response.status_code = 404
return response
except Exception as e:
response = jsonify(asdict(Response().error(str(e))))
response.status_code = 500
return response
async def create_template(self):
"""创建一个新的T2I模板"""
try:
data = await request.json
name = data.get("name")
content = data.get("content")
if not name or not content:
response = jsonify(
asdict(Response().error("Name and content are required."))
)
response.status_code = 400
return response
name = name.strip()
self.manager.create_template(name, content)
response = jsonify(
asdict(
Response().ok(
data={"name": name}, message="Template created successfully."
)
)
)
response.status_code = 201
return response
except FileExistsError:
response = jsonify(
asdict(Response().error("Template with this name already exists."))
)
response.status_code = 409
return response
except ValueError as e:
response = jsonify(asdict(Response().error(str(e))))
response.status_code = 400
return response
except Exception as e:
response = jsonify(asdict(Response().error(str(e))))
response.status_code = 500
return response
async def update_template(self, name: str):
"""更新一个已存在的T2I模板"""
try:
name = name.strip()
data = await request.json
content = data.get("content")
if content is None:
response = jsonify(asdict(Response().error("Content is required.")))
response.status_code = 400
return response
self.manager.update_template(name, content)
# 检查更新的是否为当前激活的模板,如果是,则热重载
active_template = self.config.get("t2i_active_template", "base")
if name == active_template:
await self.core_lifecycle.reload_pipeline_scheduler("default")
message = f"模板 '{name}' 已更新并重新加载。"
else:
message = f"模板 '{name}' 已更新。"
return jsonify(asdict(Response().ok(data={"name": name}, message=message)))
except ValueError as e:
response = jsonify(asdict(Response().error(str(e))))
response.status_code = 400
return response
except Exception as e:
response = jsonify(asdict(Response().error(str(e))))
response.status_code = 500
return response
async def delete_template(self, name: str):
"""删除一个T2I模板"""
try:
name = name.strip()
self.manager.delete_template(name)
return jsonify(
asdict(Response().ok(message="Template deleted successfully."))
)
except FileNotFoundError:
response = jsonify(asdict(Response().error("Template not found.")))
response.status_code = 404
return response
except ValueError as e:
response = jsonify(asdict(Response().error(str(e))))
response.status_code = 400
return response
except Exception as e:
response = jsonify(asdict(Response().error(str(e))))
response.status_code = 500
return response
async def set_active_template(self):
"""设置当前活动的T2I模板"""
try:
data = await request.json
name = data.get("name")
if not name:
response = jsonify(asdict(Response().error("模板名称(name)不能为空。")))
response.status_code = 400
return response
# 验证模板文件是否存在
self.manager.get_template(name)
# 更新配置
config = self.config
config["t2i_active_template"] = name
config.save_config(config)
# 热重载以应用更改
await self.core_lifecycle.reload_pipeline_scheduler("default")
return jsonify(asdict(Response().ok(message=f"模板 '{name}' 已成功应用。")))
except FileNotFoundError:
response = jsonify(
asdict(Response().error(f"模板 '{name}' 不存在,无法应用。"))
)
response.status_code = 404
return response
except Exception as e:
logger.error("Error in set_active_template", exc_info=True)
response = jsonify(asdict(Response().error(str(e))))
response.status_code = 500
return response
async def reset_default_template(self):
"""重置默认的'base'模板"""
try:
self.manager.reset_default_template()
# 更新配置,将激活模板也重置为'base'
config = self.config
config["t2i_active_template"] = "base"
config.save_config(config)
# 热重载以应用更改
await self.core_lifecycle.reload_pipeline_scheduler("default")
return jsonify(
asdict(
Response().ok(
message="Default template has been reset and activated."
)
)
)
except FileNotFoundError as e:
response = jsonify(asdict(Response().error(str(e))))
response.status_code = 404
return response
except Exception as e:
logger.error("Error in reset_default_template", exc_info=True)
response = jsonify(asdict(Response().error(str(e))))
response.status_code = 500
return response
+5 -3
View File
@@ -57,7 +57,7 @@ class UpdateRoute(Route):
.__dict__ .__dict__
) )
else: else:
ret = await self.astrbot_updator.check_update(None, None, False) ret = await self.astrbot_updator.check_update(None, None)
return Response( return Response(
status="success", status="success",
message=str(ret) if ret is not None else "已经是最新版本了。", message=str(ret) if ret is not None else "已经是最新版本了。",
@@ -100,7 +100,9 @@ class UpdateRoute(Route):
) )
try: try:
await download_dashboard(latest=latest, version=version, proxy=proxy) await download_dashboard(
latest=latest, version=version, proxy=proxy
)
except Exception as e: except Exception as e:
logger.error(f"下载管理面板文件失败: {e}") logger.error(f"下载管理面板文件失败: {e}")
@@ -131,7 +133,7 @@ class UpdateRoute(Route):
async def update_dashboard(self): async def update_dashboard(self):
try: try:
try: try:
await download_dashboard(version=f"v{VERSION}", latest=False) await download_dashboard()
except Exception as e: except Exception as e:
logger.error(f"下载管理面板文件失败: {e}") logger.error(f"下载管理面板文件失败: {e}")
return Response().error(f"下载管理面板文件失败: {e}").__dict__ return Response().error(f"下载管理面板文件失败: {e}").__dict__
+4 -13
View File
@@ -18,7 +18,6 @@ from astrbot.core.utils.io import get_local_ip_addresses
from .routes import * from .routes import *
from .routes.route import Response, RouteContext from .routes.route import Response, RouteContext
from .routes.session_management import SessionManagementRoute from .routes.session_management import SessionManagementRoute
from .routes.t2i import T2iRoute
APP: Quart = None APP: Quart = None
@@ -29,19 +28,10 @@ class AstrBotDashboard:
core_lifecycle: AstrBotCoreLifecycle, core_lifecycle: AstrBotCoreLifecycle,
db: BaseDatabase, db: BaseDatabase,
shutdown_event: asyncio.Event, shutdown_event: asyncio.Event,
webui_dir: str | None = None,
) -> None: ) -> None:
self.core_lifecycle = core_lifecycle self.core_lifecycle = core_lifecycle
self.config = core_lifecycle.astrbot_config self.config = core_lifecycle.astrbot_config
self.data_path = os.path.abspath(os.path.join(get_astrbot_data_path(), "dist"))
# 参数指定webui目录
if webui_dir and os.path.exists(webui_dir):
self.data_path = os.path.abspath(webui_dir)
else:
self.data_path = os.path.abspath(
os.path.join(get_astrbot_data_path(), "dist")
)
self.app = Quart("dashboard", static_folder=self.data_path, static_url_path="/") self.app = Quart("dashboard", static_folder=self.data_path, static_url_path="/")
APP = self.app # noqa APP = self.app # noqa
self.app.config["MAX_CONTENT_LENGTH"] = ( self.app.config["MAX_CONTENT_LENGTH"] = (
@@ -70,8 +60,9 @@ class AstrBotDashboard:
self.session_management_route = SessionManagementRoute( self.session_management_route = SessionManagementRoute(
self.context, db, core_lifecycle self.context, db, core_lifecycle
) )
self.persona_route = PersonaRoute(self.context, db, core_lifecycle) self.persona_route = PersonaRoute(
self.t2i_route = T2iRoute(self.context, core_lifecycle) self.context, db, core_lifecycle
)
self.app.add_url_rule( self.app.add_url_rule(
"/api/plug/<path:subpath>", "/api/plug/<path:subpath>",
-5
View File
@@ -1,5 +0,0 @@
# What's Changed
1. 修复:构建 docker 镜像时同时构建 webui,并放入镜像中。
2. 修复:下载 WebUI 文件时,明确版本号,以防止 latest 不一致导致下载的 WebUI 文件版本号与实际所需不符的问题。
3. 优化:优化版本检测,考虑预发布版本,移除 `更新到最新版本` 按钮
-3
View File
@@ -1,3 +0,0 @@
# What's Changed
> 请仔细阅读:**这是 v4.0.0 的测试版本(beta.3),功能尚未完全稳定和加入**。v4.0.0 被设计为向前兼容,如有任何插件兼容性问题或者其他异常请在 GitHub 提交 [Issue](https://github.com/AstrBotDevs/AstrBot/issues)。在测试版本期间,您可以无缝回退到旧版本的 AstrBot,并且数据不受影响。新版本文档请[从此](https://docs-v4.astrbot.app/)访问,直到第一个 v4.0.0 稳定版本发布。
-8
View File
@@ -1,8 +0,0 @@
# What's Changed
> 请仔细阅读:**这是 v4.0.0 的测试版本(beta.4),功能尚未完全稳定和加入**。v4.0.0 被设计为向前兼容,如有任何插件兼容性问题或者其他异常请在 GitHub 提交 [Issue](https://github.com/AstrBotDevs/AstrBot/issues)。在测试版本期间,您可以无缝回退到旧版本的 AstrBot,并且数据不受影响。新版本文档请[从此](https://docs-v4.astrbot.app/)访问,直到第一个 v4.0.0 稳定版本发布。
相较于 beta.3
1. 修复了主动回复时报错的问题
2. 数据迁移完毕之后引导重启程序
-15
View File
@@ -1,15 +0,0 @@
# What's Changed
> 请仔细阅读:**这是 v4.0.0 的测试版本(beta.4),功能尚未完全稳定和加入**。v4.0.0 被设计为向前兼容,如有任何插件兼容性问题或者其他异常请在 GitHub 提交 [Issue](https://github.com/AstrBotDevs/AstrBot/issues)。在测试版本期间,您可以无缝回退到旧版本的 AstrBot,并且数据不受影响。新版本文档请[从此](https://docs-v4.astrbot.app/)访问,直到第一个 v4.0.0 稳定版本发布。
相较于 beta.4
1. ‼️修复:新版本在初次保存配置之后,调用 LLM 无法获得响应,但插件指令仍可以使用的问题
2. 修复:部分情况下,Dashboard 内修改配置保存后报错 UnicodeDecodeError
3. 修复:构建 docker 镜像时同时构建 webui,并放入镜像中。
4. 修复:下载 WebUI 文件时,明确版本号,以防止 latest 不一致导致下载的 WebUI 文件版本号与实际所需不符的问题。
5. 优化:优化版本检测,考虑预发布版本,移除 `更新到最新版本` 按钮
6. 优化:增加 abconf_data 缓存,优化性能
7. 优化: 适配 qwen3 的 thinking 类模型
8. 优化: 完善对 rerank model 的可用性检测
9. 新增: 给添加 edge_tts 新增 rate, volume, pitch 参数 ([#2625](https://github.com/Soulter/AstrBot/issues/2625))
-24
View File
@@ -1,24 +0,0 @@
# What's Changed
> 新版本介绍和用法请看 AstrBot 官方 Blog [v4.0.0 的新变化](https://blog.astrbot.app/posts/what-is-changed-in-4.0.0/)。
* Refactor: using sqlmodel(sqlchemy+pydantic) as ORM framework and switch to async-based sqlite operation by @Soulter in https://github.com/AstrBotDevs/AstrBot/pull/2294
* Fix: 当多个相同消息平台实例部署时上下文可能混乱(共享) by @Soulter in https://github.com/AstrBotDevs/AstrBot/pull/2298
* Improve: 引入全新的人格管理模式 by @Soulter in https://github.com/AstrBotDevs/AstrBot/pull/2305
* Feature: Add support to sync MCP servers from ModelScope by @Soulter in https://github.com/AstrBotDevs/AstrBot/pull/2313
* Feature: 移除 MCP 市场相关逻辑 by @Soulter in https://github.com/AstrBotDevs/AstrBot/pull/2314
* Refactor: 重构配置文件管理,以支持更灵活的、会话粒度的(基于 umo part)配置文件隔离 by @Soulter in https://github.com/AstrBotDevs/AstrBot/pull/2328
* Feature: 增加图片转述提供商配置、支持用户自定义模型模态能力 by @Soulter in https://github.com/AstrBotDevs/AstrBot/pull/2422
* Feature: 优化 WebSearch 的爬取网页速度并且支持使用 Tavily 作为搜索引擎 by @Soulter in https://github.com/AstrBotDevs/AstrBot/pull/2427
* Feature: 添加url转知识库功能 by @RC-CHN in https://github.com/AstrBotDevs/AstrBot/pull/2280
* Feature: 添加条件显示逻辑以优化插件配置项的可见性管理 by @Soulter in https://github.com/AstrBotDevs/AstrBot/pull/2433
* Feature: 支持在 WebUI 配置文件页中配置默认知识库 by @Soulter in https://github.com/AstrBotDevs/AstrBot/pull/2437
* Feature: 重构 Function Tool 管理并初步引入 Multi Agent 及 Agent Handsoff 机制 by @Soulter in https://github.com/AstrBotDevs/AstrBot/pull/2454
* feat: 添加数据迁移助手以及相关迁移方法 by @Soulter in https://github.com/AstrBotDevs/AstrBot/pull/2477
* Refactor: 重构 SharedPreference 类并采用数据库存储替换 json 存储 by @Soulter in https://github.com/AstrBotDevs/AstrBot/pull/2482
* Feature: 支持配置重排序模型(vLLM API 格式)用于 score 任务 by @Soulter in https://github.com/AstrBotDevs/AstrBot/pull/2496
* Feature: 支持在配置文件配置可用的插件组 by @Soulter in https://github.com/AstrBotDevs/AstrBot/pull/2505
* Feature: llm_tool 装饰器返回值支持 mcp 库的 tool 返回值类型 (mcp.type.CallToolResult) by @Soulter in https://github.com/AstrBotDevs/AstrBot/pull/2507
* Feature: 多 t2i 服务的随机负载均衡 by @Soulter in https://github.com/AstrBotDevs/AstrBot/pull/2529
* Improve: 扩大配置文件生效范围的自定义程度到会话粒度 by @Soulter in https://github.com/AstrBotDevs/AstrBot/pull/2532
* Feature: 支持可视化自定义 T2I 模版 by @Soulter in https://github.com/AstrBotDevs/AstrBot/pull/2581
-15
View File
@@ -1,15 +0,0 @@
# What's Changed
> 如果已经使用自定义文转图模板,此次升级之后将会被覆盖,请提前备份。路径在 `astrbot/core/utils/t2i/template` 目录下。
0. ‼️‼️‼️ 修复 LLM 仍会调用已禁用的工具的问题 ([#2729](https://github.com/Soulter/AstrBot/issues/2729))
1. ‼️ 修复 WebChat 下,Agent 长时任务时,SSE 连接自动断开的问题
2. ‼️ 修复自定义文转图模板更新版本后会被覆盖的问题 ([#2677](https://github.com/Soulter/AstrBot/issues/2677))
3. 修复 Satori 适配器教程链接 ([#2668](https://github.com/Soulter/AstrBot/issues/2668))
4. 修复插件页表格视图中,点击状态字段表头排序不起作用的问题 ([#2714](https://github.com/Soulter/AstrBot/issues/2714))
5. 修复工具调用时的 content 内容在重新加载后没有显示在 webchat 的问题 ([#2727](https://github.com/Soulter/AstrBot/issues/2727))
6. 允许添加多个 tavily API Key 进行轮询 ([#2725](https://github.com/Soulter/AstrBot/issues/2725))
7. 添加 --webui-dir 启动参数以支持指定 WebUI 构建文件目录 ([#2680](https://github.com/Soulter/AstrBot/issues/2680))
8. 兼容指令名和第一个参数之间没有空格的情况 ([#2650](https://github.com/Soulter/AstrBot/issues/2650))
9. 支持在 WebUI 自定义 OpenAI API extra_body 参数 ([#2719](https://github.com/Soulter/AstrBot/issues/2719))
10. 增加 on_platform_loaded 钩子以在消息平台适配器实例化完成后触发 ([#2651](https://github.com/Soulter/AstrBot/issues/2651))
Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

@@ -2,7 +2,6 @@
import { VueMonacoEditor } from '@guolao/vue-monaco-editor' import { VueMonacoEditor } from '@guolao/vue-monaco-editor'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import ListConfigItem from './ListConfigItem.vue' import ListConfigItem from './ListConfigItem.vue'
import ObjectEditor from './ObjectEditor.vue'
import ProviderSelector from './ProviderSelector.vue' import ProviderSelector from './ProviderSelector.vue'
import PersonaSelector from './PersonaSelector.vue' import PersonaSelector from './PersonaSelector.vue'
import KnowledgeBaseSelector from './KnowledgeBaseSelector.vue' import KnowledgeBaseSelector from './KnowledgeBaseSelector.vue'
@@ -81,7 +80,7 @@ function shouldShowItem(itemMeta, itemKey) {
function hasVisibleItemsAfter(items, currentIndex) { function hasVisibleItemsAfter(items, currentIndex) {
const itemEntries = Object.entries(items) const itemEntries = Object.entries(items)
// //
for (let i = currentIndex + 1; i < itemEntries.length; i++) { for (let i = currentIndex + 1; i < itemEntries.length; i++) {
const [itemKey, itemValue] = itemEntries[i] const [itemKey, itemValue] = itemEntries[i]
@@ -90,7 +89,7 @@ function hasVisibleItemsAfter(items, currentIndex) {
return true return true
} }
} }
return false return false
} }
</script> </script>
@@ -131,7 +130,7 @@ function hasVisibleItemsAfter(items, currentIndex) {
</v-expand-transition> </v-expand-transition>
</div> </div>
</div> </div>
<!-- Regular Property --> <!-- Regular Property -->
<template v-else> <template v-else>
<v-row v-if="!metadata[metadataKey].items[key]?.invisible && shouldShowItem(metadata[metadataKey].items[key], key)" class="config-row"> <v-row v-if="!metadata[metadataKey].items[key]?.invisible && shouldShowItem(metadata[metadataKey].items[key], key)" class="config-row">
@@ -146,7 +145,7 @@ function hasVisibleItemsAfter(items, currentIndex) {
</v-list-item-title> </v-list-item-title>
<v-list-item-subtitle class="property-hint"> <v-list-item-subtitle class="property-hint">
<span v-if="metadata[metadataKey].items[key]?.obvious_hint && metadata[metadataKey].items[key]?.hint" <span v-if="metadata[metadataKey].items[key]?.obvious_hint && metadata[metadataKey].items[key]?.hint"
class="important-hint"></span> class="important-hint"></span>
{{ metadata[metadataKey].items[key]?.hint }} {{ metadata[metadataKey].items[key]?.hint }}
</v-list-item-subtitle> </v-list-item-subtitle>
@@ -154,10 +153,10 @@ function hasVisibleItemsAfter(items, currentIndex) {
</v-col> </v-col>
<v-col cols="12" sm="1" class="d-flex align-center type-indicator"> <v-col cols="12" sm="1" class="d-flex align-center type-indicator">
<v-chip v-if="!metadata[metadataKey].items[key]?.invisible" <v-chip v-if="!metadata[metadataKey].items[key]?.invisible"
color="primary" color="primary"
label label
size="x-small" size="x-small"
variant="flat"> variant="flat">
{{ metadata[metadataKey].items[key]?.type || 'string' }} {{ metadata[metadataKey].items[key]?.type || 'string' }}
</v-chip> </v-chip>
@@ -167,35 +166,35 @@ function hasVisibleItemsAfter(items, currentIndex) {
<div v-if="metadata[metadataKey].items[key]" class="w-100"> <div v-if="metadata[metadataKey].items[key]" class="w-100">
<!-- Special handling for specific metadata types --> <!-- Special handling for specific metadata types -->
<div v-if="metadata[metadataKey].items[key]?._special === 'select_provider'"> <div v-if="metadata[metadataKey].items[key]?._special === 'select_provider'">
<ProviderSelector <ProviderSelector
v-model="iterable[key]" v-model="iterable[key]"
:provider-type="'chat_completion'" :provider-type="'chat_completion'"
/> />
</div> </div>
<div v-else-if="metadata[metadataKey].items[key]?._special === 'select_provider_stt'"> <div v-else-if="metadata[metadataKey].items[key]?._special === 'select_provider_stt'">
<ProviderSelector <ProviderSelector
v-model="iterable[key]" v-model="iterable[key]"
:provider-type="'speech_to_text'" :provider-type="'speech_to_text'"
/> />
</div> </div>
<div v-else-if="metadata[metadataKey].items[key]?._special === 'select_provider_tts'"> <div v-else-if="metadata[metadataKey].items[key]?._special === 'select_provider_tts'">
<ProviderSelector <ProviderSelector
v-model="iterable[key]" v-model="iterable[key]"
:provider-type="'text_to_speech'" :provider-type="'text_to_speech'"
/> />
</div> </div>
<div v-else-if="metadata[metadataKey].items[key]?._special === 'select_persona'"> <div v-else-if="metadata[metadataKey].items[key]?._special === 'select_persona'">
<PersonaSelector <PersonaSelector
v-model="iterable[key]" v-model="iterable[key]"
/> />
</div> </div>
<div v-else-if="metadata[metadataKey].items[key]?._special === 'select_knowledgebase'"> <div v-else-if="metadata[metadataKey].items[key]?._special === 'select_knowledgebase'">
<KnowledgeBaseSelector <KnowledgeBaseSelector
v-model="iterable[key]" v-model="iterable[key]"
/> />
</div> </div>
<!-- List item with options--> <!-- List item with options-->
<div v-else-if="metadata[metadataKey].items[key]?.type === 'list' && metadata[metadataKey].items[key]?.options && !metadata[metadataKey].items[key]?.invisible && metadata[metadataKey].items[key]?.render_type === 'checkbox'" <div v-else-if="metadata[metadataKey].items[key]?.type === 'list' && metadata[metadataKey].items[key]?.options && !metadata[metadataKey].items[key]?.invisible && metadata[metadataKey].items[key]?.render_type === 'checkbox'"
class="d-flex flex-wrap gap-20"> class="d-flex flex-wrap gap-20">
<v-checkbox <v-checkbox
v-for="(option, index) in metadata[metadataKey].items[key]?.options" v-for="(option, index) in metadata[metadataKey].items[key]?.options"
@@ -234,10 +233,10 @@ function hasVisibleItemsAfter(items, currentIndex) {
<!-- Code Editor with Full Screen Option --> <!-- Code Editor with Full Screen Option -->
<div v-else-if="metadata[metadataKey].items[key]?.editor_mode && !metadata[metadataKey].items[key]?.invisible" class="editor-container"> <div v-else-if="metadata[metadataKey].items[key]?.editor_mode && !metadata[metadataKey].items[key]?.invisible" class="editor-container">
<VueMonacoEditor <VueMonacoEditor
:theme="metadata[metadataKey].items[key]?.editor_theme || 'vs-light'" :theme="metadata[metadataKey].items[key]?.editor_theme || 'vs-light'"
:language="metadata[metadataKey].items[key]?.editor_language || 'json'" :language="metadata[metadataKey].items[key]?.editor_language || 'json'"
style="min-height: 100px; flex-grow: 1; border: 1px solid rgba(0, 0, 0, 0.1);" style="min-height: 100px; flex-grow: 1; border: 1px solid rgba(0, 0, 0, 0.1);"
v-model:value="iterable[key]" v-model:value="iterable[key]"
> >
</VueMonacoEditor> </VueMonacoEditor>
@@ -253,7 +252,7 @@ function hasVisibleItemsAfter(items, currentIndex) {
<v-icon>mdi-fullscreen</v-icon> <v-icon>mdi-fullscreen</v-icon>
</v-btn> </v-btn>
</div> </div>
<!-- String input --> <!-- String input -->
<v-text-field <v-text-field
v-else-if="metadata[metadataKey].items[key]?.type === 'string' && !metadata[metadataKey].items[key]?.invisible" v-else-if="metadata[metadataKey].items[key]?.type === 'string' && !metadata[metadataKey].items[key]?.invisible"
@@ -263,7 +262,7 @@ function hasVisibleItemsAfter(items, currentIndex) {
class="config-field" class="config-field"
hide-details hide-details
></v-text-field> ></v-text-field>
<!-- Numeric input --> <!-- Numeric input -->
<v-text-field <v-text-field
v-else-if="(metadata[metadataKey].items[key]?.type === 'int' || metadata[metadataKey].items[key]?.type === 'float') && !metadata[metadataKey]?.invisible" v-else-if="(metadata[metadataKey].items[key]?.type === 'int' || metadata[metadataKey].items[key]?.type === 'float') && !metadata[metadataKey]?.invisible"
@@ -274,7 +273,7 @@ function hasVisibleItemsAfter(items, currentIndex) {
type="number" type="number"
hide-details hide-details
></v-text-field> ></v-text-field>
<!-- Text area --> <!-- Text area -->
<v-textarea <v-textarea
v-else-if="metadata[metadataKey].items[key]?.type === 'text' && !metadata[metadataKey].items[key]?.invisible" v-else-if="metadata[metadataKey].items[key]?.type === 'text' && !metadata[metadataKey].items[key]?.invisible"
@@ -284,7 +283,7 @@ function hasVisibleItemsAfter(items, currentIndex) {
class="config-field" class="config-field"
hide-details hide-details
></v-textarea> ></v-textarea>
<!-- Boolean switch --> <!-- Boolean switch -->
<v-switch <v-switch
v-else-if="metadata[metadataKey].items[key]?.type === 'bool' && !metadata[metadataKey].items[key]?.invisible" v-else-if="metadata[metadataKey].items[key]?.type === 'bool' && !metadata[metadataKey].items[key]?.invisible"
@@ -294,27 +293,20 @@ function hasVisibleItemsAfter(items, currentIndex) {
density="compact" density="compact"
hide-details hide-details
></v-switch> ></v-switch>
<!-- List item --> <!-- List item -->
<ListConfigItem <ListConfigItem
v-else-if="metadata[metadataKey].items[key]?.type === 'list' && !metadata[metadataKey].items[key]?.invisible" v-else-if="metadata[metadataKey].items[key]?.type === 'list' && !metadata[metadataKey].items[key]?.invisible"
v-model="iterable[key]" v-model="iterable[key]"
class="config-field" class="config-field"
/> />
<!-- Dict item (key-value editor) -->
<ObjectEditor
v-else-if="metadata[metadataKey].items[key]?.type === 'dict' && !metadata[metadataKey].items[key]?.invisible"
v-model="iterable[key]"
class="config-field"
/>
</div> </div>
<!-- Fallback for unknown metadata --> <!-- Fallback for unknown metadata -->
<div v-else class="w-100"> <div v-else class="w-100">
<v-text-field <v-text-field
v-model="iterable[key]" v-model="iterable[key]"
:label="key" :label="key"
density="compact" density="compact"
variant="outlined" variant="outlined"
class="config-field" class="config-field"
@@ -324,14 +316,14 @@ function hasVisibleItemsAfter(items, currentIndex) {
</v-col> </v-col>
</v-row> </v-row>
<v-divider <v-divider
v-if="hasVisibleItemsAfter(filteredIterable, index) && !metadata[metadataKey].items[key]?.invisible && shouldShowItem(metadata[metadataKey].items[key], key)" v-if="hasVisibleItemsAfter(filteredIterable, index) && !metadata[metadataKey].items[key]?.invisible && shouldShowItem(metadata[metadataKey].items[key], key)"
class="config-divider" class="config-divider"
></v-divider> ></v-divider>
</template> </template>
</div> </div>
</div> </div>
<!-- Simple Value Configuration --> <!-- Simple Value Configuration -->
<div v-else class="simple-config"> <div v-else class="simple-config">
<v-row class="config-row"> <v-row class="config-row">
@@ -350,9 +342,9 @@ function hasVisibleItemsAfter(items, currentIndex) {
</v-col> </v-col>
<v-col cols="12" sm="1" class="d-flex align-center type-indicator"> <v-col cols="12" sm="1" class="d-flex align-center type-indicator">
<v-chip v-if="!metadata[metadataKey]?.invisible" <v-chip v-if="!metadata[metadataKey]?.invisible"
color="primary" color="primary"
label label
size="x-small" size="x-small"
variant="flat"> variant="flat">
{{ metadata[metadataKey]?.type }} {{ metadata[metadataKey]?.type }}
@@ -372,7 +364,7 @@ function hasVisibleItemsAfter(items, currentIndex) {
class="config-field" class="config-field"
hide-details hide-details
></v-select> ></v-select>
<!-- String input --> <!-- String input -->
<v-text-field <v-text-field
v-else-if="metadata[metadataKey]?.type === 'string' && !metadata[metadataKey]?.invisible" v-else-if="metadata[metadataKey]?.type === 'string' && !metadata[metadataKey]?.invisible"
@@ -382,7 +374,7 @@ function hasVisibleItemsAfter(items, currentIndex) {
class="config-field" class="config-field"
hide-details hide-details
></v-text-field> ></v-text-field>
<!-- Numeric input --> <!-- Numeric input -->
<v-text-field <v-text-field
v-else-if="(metadata[metadataKey]?.type === 'int' || metadata[metadataKey]?.type === 'float') && !metadata[metadataKey]?.invisible" v-else-if="(metadata[metadataKey]?.type === 'int' || metadata[metadataKey]?.type === 'float') && !metadata[metadataKey]?.invisible"
@@ -393,7 +385,7 @@ function hasVisibleItemsAfter(items, currentIndex) {
type="number" type="number"
hide-details hide-details
></v-text-field> ></v-text-field>
<!-- Text area --> <!-- Text area -->
<v-textarea <v-textarea
v-else-if="metadata[metadataKey]?.type === 'text' && !metadata[metadataKey]?.invisible" v-else-if="metadata[metadataKey]?.type === 'text' && !metadata[metadataKey]?.invisible"
@@ -404,7 +396,7 @@ function hasVisibleItemsAfter(items, currentIndex) {
class="config-field" class="config-field"
hide-details hide-details
></v-textarea> ></v-textarea>
<!-- Boolean switch --> <!-- Boolean switch -->
<v-switch <v-switch
v-else-if="metadata[metadataKey]?.type === 'bool' && !metadata[metadataKey]?.invisible" v-else-if="metadata[metadataKey]?.type === 'bool' && !metadata[metadataKey]?.invisible"
@@ -414,9 +406,9 @@ function hasVisibleItemsAfter(items, currentIndex) {
density="compact" density="compact"
hide-details hide-details
></v-switch> ></v-switch>
<!-- List item --> <!-- List item -->
<ListConfigItem <ListConfigItem
v-else-if="metadata[metadataKey]?.type === 'list' && !metadata[metadataKey]?.invisible" v-else-if="metadata[metadataKey]?.type === 'list' && !metadata[metadataKey]?.invisible"
v-model="iterable[metadataKey]" v-model="iterable[metadataKey]"
class="config-field" class="config-field"
@@ -443,9 +435,9 @@ function hasVisibleItemsAfter(items, currentIndex) {
</v-toolbar-items> </v-toolbar-items>
</v-toolbar> </v-toolbar>
<v-card-text class="pa-0"> <v-card-text class="pa-0">
<VueMonacoEditor <VueMonacoEditor
:theme="currentEditingTheme" :theme="currentEditingTheme"
:language="currentEditingLanguage" :language="currentEditingLanguage"
style="height: calc(100vh - 64px);" style="height: calc(100vh - 64px);"
v-model:value="currentEditingKeyIterable[currentEditingKey]" v-model:value="currentEditingKeyIterable[currentEditingKey]"
> >
@@ -575,11 +567,11 @@ function hasVisibleItemsAfter(items, currentIndex) {
.nested-object { .nested-object {
padding-left: 8px; padding-left: 8px;
} }
.config-row { .config-row {
padding: 8px 0; padding: 8px 0;
} }
.property-info, .type-indicator, .config-input { .property-info, .type-indicator, .config-input {
padding: 4px; padding: 4px;
} }
@@ -2,7 +2,6 @@
import { VueMonacoEditor } from '@guolao/vue-monaco-editor' import { VueMonacoEditor } from '@guolao/vue-monaco-editor'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import ListConfigItem from './ListConfigItem.vue' import ListConfigItem from './ListConfigItem.vue'
import ObjectEditor from './ObjectEditor.vue'
import ProviderSelector from './ProviderSelector.vue' import ProviderSelector from './ProviderSelector.vue'
import PersonaSelector from './PersonaSelector.vue' import PersonaSelector from './PersonaSelector.vue'
import KnowledgeBaseSelector from './KnowledgeBaseSelector.vue' import KnowledgeBaseSelector from './KnowledgeBaseSelector.vue'
@@ -103,7 +102,7 @@ function shouldShowItem(itemMeta, itemKey) {
function hasVisibleItemsAfter(items, currentIndex) { function hasVisibleItemsAfter(items, currentIndex) {
const itemEntries = Object.entries(items) const itemEntries = Object.entries(items)
// //
for (let i = currentIndex + 1; i < itemEntries.length; i++) { for (let i = currentIndex + 1; i < itemEntries.length; i++) {
const [itemKey, itemMeta] = itemEntries[i] const [itemKey, itemMeta] = itemEntries[i]
@@ -111,7 +110,7 @@ function hasVisibleItemsAfter(items, currentIndex) {
return true return true
} }
} }
return false return false
} }
</script> </script>
@@ -189,20 +188,13 @@ function hasVisibleItemsAfter(items, currentIndex) {
color="primary" inset density="compact" hide-details style="display: flex; justify-content: end;"></v-switch> color="primary" inset density="compact" hide-details style="display: flex; justify-content: end;"></v-switch>
<!-- List item for JSON selector --> <!-- List item for JSON selector -->
<ListConfigItem <ListConfigItem
v-else-if="itemMeta?.type === 'list'" v-else-if="itemMeta?.type === 'list'"
v-model="createSelectorModel(itemKey).value" v-model="createSelectorModel(itemKey).value"
button-text="修改" button-text="修改"
class="config-field" class="config-field"
/> />
<!-- Object editor for JSON selector -->
<ObjectEditor
v-else-if="itemMeta?.type === 'dict'"
v-model="createSelectorModel(itemKey).value"
class="config-field"
/>
<!-- Fallback for JSON selector --> <!-- Fallback for JSON selector -->
<v-text-field v-else v-model="createSelectorModel(itemKey).value" density="compact" variant="outlined" <v-text-field v-else v-model="createSelectorModel(itemKey).value" density="compact" variant="outlined"
class="config-field" hide-details></v-text-field> class="config-field" hide-details></v-text-field>
@@ -210,48 +202,48 @@ function hasVisibleItemsAfter(items, currentIndex) {
<!-- Special handling for specific metadata types --> <!-- Special handling for specific metadata types -->
<div v-else-if="itemMeta?._special === 'select_provider'"> <div v-else-if="itemMeta?._special === 'select_provider'">
<ProviderSelector <ProviderSelector
v-model="createSelectorModel(itemKey).value" v-model="createSelectorModel(itemKey).value"
:provider-type="'chat_completion'" :provider-type="'chat_completion'"
/> />
</div> </div>
<div v-else-if="itemMeta?._special === 'select_provider_stt'"> <div v-else-if="itemMeta?._special === 'select_provider_stt'">
<ProviderSelector <ProviderSelector
v-model="createSelectorModel(itemKey).value" v-model="createSelectorModel(itemKey).value"
:provider-type="'speech_to_text'" :provider-type="'speech_to_text'"
/> />
</div> </div>
<div v-else-if="itemMeta?._special === 'select_provider_tts'"> <div v-else-if="itemMeta?._special === 'select_provider_tts'">
<ProviderSelector <ProviderSelector
v-model="createSelectorModel(itemKey).value" v-model="createSelectorModel(itemKey).value"
:provider-type="'text_to_speech'" :provider-type="'text_to_speech'"
/> />
</div> </div>
<div v-else-if="itemMeta?._special === 'provider_pool'"> <div v-else-if="itemMeta?._special === 'provider_pool'">
<ProviderSelector <ProviderSelector
v-model="createSelectorModel(itemKey).value" v-model="createSelectorModel(itemKey).value"
:provider-type="'chat_completion'" :provider-type="'chat_completion'"
button-text="选择提供商池..." button-text="选择提供商池..."
/> />
</div> </div>
<div v-else-if="itemMeta?._special === 'select_persona'"> <div v-else-if="itemMeta?._special === 'select_persona'">
<PersonaSelector <PersonaSelector
v-model="createSelectorModel(itemKey).value" v-model="createSelectorModel(itemKey).value"
/> />
</div> </div>
<div v-else-if="itemMeta?._special === 'persona_pool'"> <div v-else-if="itemMeta?._special === 'persona_pool'">
<PersonaSelector <PersonaSelector
v-model="createSelectorModel(itemKey).value" v-model="createSelectorModel(itemKey).value"
button-text="选择人格池..." button-text="选择人格池..."
/> />
</div> </div>
<div v-else-if="itemMeta?._special === 'select_knowledgebase'"> <div v-else-if="itemMeta?._special === 'select_knowledgebase'">
<KnowledgeBaseSelector <KnowledgeBaseSelector
v-model="createSelectorModel(itemKey).value" v-model="createSelectorModel(itemKey).value"
/> />
</div> </div>
<div v-else-if="itemMeta?._special === 'select_plugin_set'"> <div v-else-if="itemMeta?._special === 'select_plugin_set'">
<PluginSetSelector <PluginSetSelector
v-model="createSelectorModel(itemKey).value" v-model="createSelectorModel(itemKey).value"
/> />
</div> </div>
@@ -269,12 +261,12 @@ function hasVisibleItemsAfter(items, currentIndex) {
<small class="text-grey">已选择的插件</small> <small class="text-grey">已选择的插件</small>
</div> </div>
<div class="d-flex flex-wrap ga-2 mt-2"> <div class="d-flex flex-wrap ga-2 mt-2">
<v-chip <v-chip
v-for="plugin in (createSelectorModel(itemKey).value || [])" v-for="plugin in (createSelectorModel(itemKey).value || [])"
:key="plugin" :key="plugin"
size="small" size="small"
label label
color="primary" color="primary"
variant="outlined" variant="outlined"
> >
{{ plugin === '*' ? '所有插件' : plugin }} {{ plugin === '*' ? '所有插件' : plugin }}
@@ -12,13 +12,7 @@
<div v-if="migrationCompleted" class="text-center py-8"> <div v-if="migrationCompleted" class="text-center py-8">
<v-icon size="64" color="success" class="mb-4">mdi-check-circle</v-icon> <v-icon size="64" color="success" class="mb-4">mdi-check-circle</v-icon>
<h3 class="mb-4">{{ t('features.migration.dialog.completed') }}</h3> <h3 class="mb-4">{{ t('features.migration.dialog.completed') }}</h3>
<p class="mb-4">{{ migrationResult?.message || t('features.migration.dialog.success') }}</p> {{ migrationResult?.message || t('features.migration.dialog.success') }}
<v-alert type="info" variant="tonal" class="mb-4">
<template v-slot:prepend>
<v-icon>mdi-information</v-icon>
</template>
{{ t('features.migration.dialog.restartRecommended') }}
</v-alert>
</div> </div>
<div v-else-if="migrating" class="migration-in-progress"> <div v-else-if="migrating" class="migration-in-progress">
@@ -86,11 +80,8 @@
<v-card-actions class="px-6 py-4"> <v-card-actions class="px-6 py-4">
<v-spacer></v-spacer> <v-spacer></v-spacer>
<template v-if="migrationCompleted"> <template v-if="migrationCompleted">
<v-btn color="grey" variant="text" @click="handleClose"> <v-btn color="primary" variant="elevated" @click="handleClose">
{{ t('core.common.close') }} {{ t('core.common.confirm') }}
</v-btn>
<v-btn color="primary" variant="elevated" @click="restartAstrBot">
{{ t('features.migration.dialog.restartNow') }}
</v-btn> </v-btn>
</template> </template>
<template v-else> <template v-else>
@@ -105,8 +96,6 @@
</v-card-actions> </v-card-actions>
</v-card> </v-card>
</v-dialog> </v-dialog>
<WaitingForRestart ref="wfr"></WaitingForRestart>
</template> </template>
<script setup> <script setup>
@@ -114,7 +103,6 @@ import { ref, computed, watch } from 'vue'
import axios from 'axios' import axios from 'axios'
import { useI18n } from '@/i18n/composables' import { useI18n } from '@/i18n/composables'
import ConsoleDisplayer from './ConsoleDisplayer.vue' import ConsoleDisplayer from './ConsoleDisplayer.vue'
import WaitingForRestart from './WaitingForRestart.vue'
const { t } = useI18n() const { t } = useI18n()
@@ -126,7 +114,6 @@ const migrationCompleted = ref(false)
const migrationResult = ref(null) const migrationResult = ref(null)
const platforms = ref([]) const platforms = ref([])
const selectedPlatforms = ref({}) const selectedPlatforms = ref({})
const wfr = ref(null)
let resolvePromise = null let resolvePromise = null
@@ -257,15 +244,6 @@ const getPlatformLabel = (platform) => {
return `${name}` return `${name}`
} }
// AstrBot
const restartAstrBot = () => {
axios.post('/api/stat/restart-core').then(() => {
if (wfr.value) {
wfr.value.check();
}
})
}
// //
const open = () => { const open = () => {
isOpen.value = true isOpen.value = true
@@ -1,282 +0,0 @@
<template>
<div class="d-flex align-center justify-space-between">
<div>
<span v-if="!modelValue || Object.keys(modelValue).length === 0" style="color: rgb(var(--v-theme-primaryText));">
暂无项目
</span>
<div v-else class="d-flex flex-wrap ga-2">
<v-chip v-for="key in displayKeys" :key="key" size="x-small" label color="primary">
{{ key.length > 20 ? key.slice(0, 20) + '...' : key }}
</v-chip>
<v-chip v-if="Object.keys(modelValue).length > maxDisplayItems" size="x-small" label color="grey-lighten-1">
+{{ Object.keys(modelValue).length - maxDisplayItems }}
</v-chip>
</div>
</div>
<v-btn size="small" color="primary" variant="tonal" @click="openDialog">
{{ buttonText }}
</v-btn>
</div>
<!-- Key-Value Management Dialog -->
<v-dialog v-model="dialog" max-width="600px">
<v-card>
<v-card-title class="text-h3 py-4" style="font-weight: normal;">
{{ dialogTitle }}
</v-card-title>
<v-card-text class="pa-4" style="max-height: 400px; overflow-y: auto;">
<div v-if="localKeyValuePairs.length > 0">
<div v-for="(pair, index) in localKeyValuePairs" :key="index" class="key-value-pair">
<v-row no-gutters align="center" class="mb-2">
<v-col cols="4">
<v-text-field
v-model="pair.key"
density="compact"
variant="outlined"
hide-details
placeholder="键名"
@blur="updateKey(index, pair.key)"
></v-text-field>
</v-col>
<v-col cols="7" class="pl-2 d-flex align-center justify-end">
<v-text-field
v-if="pair.type === 'string'"
v-model="pair.value"
density="compact"
variant="outlined"
hide-details
placeholder="字符串值"
></v-text-field>
<v-text-field
v-else-if="pair.type === 'number'"
v-model.number="pair.value"
type="number"
density="compact"
variant="outlined"
hide-details
placeholder="数值"
></v-text-field>
<v-switch
v-else-if="pair.type === 'boolean'"
v-model="pair.value"
density="compact"
hide-details
color="primary"
></v-switch>
</v-col>
<v-col cols="1" class="pl-2">
<v-btn
icon
variant="text"
size="small"
color="error"
@click="removeKeyValuePair(index)"
>
<v-icon>mdi-delete</v-icon>
</v-btn>
</v-col>
</v-row>
</div>
</div>
<div v-else class="text-center py-8">
<v-icon size="64" color="grey-lighten-1">mdi-code-json</v-icon>
<p class="text-grey mt-4">暂无参数</p>
</div>
</v-card-text>
<!-- Add new key-value pair section -->
<v-card-text class="pa-4">
<div class="d-flex align-center ga-2">
<v-text-field
v-model="newKey"
label="新键名"
density="compact"
variant="outlined"
hide-details
class="flex-grow-1"
></v-text-field>
<v-select
v-model="newValueType"
:items="['string', 'number', 'boolean']"
label="值类型"
density="compact"
variant="outlined"
hide-details
style="max-width: 120px;"
></v-select>
<v-btn @click="addKeyValuePair" variant="tonal" color="primary">
<v-icon>mdi-plus</v-icon>
添加
</v-btn>
</div>
</v-card-text>
<v-card-actions class="pa-4">
<v-spacer></v-spacer>
<v-btn variant="text" @click="cancelDialog">取消</v-btn>
<v-btn color="primary" @click="confirmDialog">确认</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { useI18n } from '@/i18n/composables'
const { t } = useI18n()
const props = defineProps({
modelValue: {
type: Object,
required: true
},
buttonText: {
type: String,
default: '修改'
},
dialogTitle: {
type: String,
default: '修改键值对'
},
maxDisplayItems: {
type: Number,
default: 1
}
})
const emit = defineEmits(['update:modelValue'])
const dialog = ref(false)
const localKeyValuePairs = ref([])
const originalKeyValuePairs = ref([])
const newKey = ref('')
const newValueType = ref('string')
//
const displayKeys = computed(() => {
return Object.keys(props.modelValue).slice(0, props.maxDisplayItems)
})
// modelValue
watch(() => props.modelValue, (newValue) => {
// This watch is primarily for initialization or external changes
// The dialog-based editing handles internal updates
}, { immediate: true })
function initializeLocalKeyValuePairs() {
localKeyValuePairs.value = []
for (const [key, value] of Object.entries(props.modelValue)) {
localKeyValuePairs.value.push({
key: key,
value: value,
type: typeof value // Store the original type
})
}
}
function openDialog() {
initializeLocalKeyValuePairs()
originalKeyValuePairs.value = JSON.parse(JSON.stringify(localKeyValuePairs.value)) // Deep copy
newKey.value = ''
newValueType.value = 'string'
dialog.value = true
}
function addKeyValuePair() {
const key = newKey.value.trim()
if (key !== '') {
const isKeyExists = localKeyValuePairs.value.some(pair => pair.key === key)
if (isKeyExists) {
alert('键名已存在')
return
}
let defaultValue
switch (newValueType.value) {
case 'number':
defaultValue = 0
break
case 'boolean':
defaultValue = false
break
default: // string
defaultValue = ""
break
}
localKeyValuePairs.value.push({
key: key,
value: defaultValue,
type: newValueType.value
})
newKey.value = ''
}
}
function removeKeyValuePair(index) {
localKeyValuePairs.value.splice(index, 1)
}
function updateKey(index, newKey) {
const originalKey = localKeyValuePairs.value[index].key
//
if (originalKey === newKey) return
//
const isKeyExists = localKeyValuePairs.value.some((pair, i) => i !== index && pair.key === newKey)
if (isKeyExists) {
//
alert('键名已存在')
//
localKeyValuePairs.value[index].key = originalKey
return
}
//
localKeyValuePairs.value[index].key = newKey
}
function confirmDialog() {
const updatedValue = {}
for (const pair of localKeyValuePairs.value) {
let convertedValue = pair.value
//
switch (pair.type) {
case 'number':
// 0
convertedValue = Number(pair.value)
// 0
// if (isNaN(convertedValue)) convertedValue = 0;
break
case 'boolean':
// v-switch
// JavaScript false, 0, "", null, undefined, NaN false
// pair.value v-model
// convertedValue = Boolean(pair.value)
break
case 'string':
default:
//
convertedValue = String(pair.value)
break
}
updatedValue[pair.key] = convertedValue
}
emit('update:modelValue', updatedValue)
dialog.value = false
}
function cancelDialog() {
// Reset to original state
localKeyValuePairs.value = JSON.parse(JSON.stringify(originalKeyValuePairs.value))
dialog.value = false
}
</script>
<style scoped>
.key-value-pair {
width: 100%;
}
</style>
@@ -15,59 +15,17 @@
<v-card> <v-card>
<v-card-title class="d-flex align-center justify-space-between"> <v-card-title class="d-flex align-center justify-space-between">
<span>自定义文转图 HTML 模板</span> <span>自定义文转图 HTML 模板</span>
<v-spacer></v-spacer> <div class="d-flex gap-2">
<div class="d-flex align-center gap-2" style="width: 60%"> <v-btn
<v-text-field v-if="hasCustomTemplate"
v-if="isCreatingNew"
v-model="editingName"
label="输入新模板名称"
density="compact"
hide-details
variant="outlined" variant="outlined"
class="flex-grow-1" color="warning"
autofocus size="small"
:rules="[v => !!v || '名称不能为空']" @click="resetToDefault"
></v-text-field> :loading="resetLoading"
<v-select
v-else
v-model="selectedTemplate"
:items="templates"
item-title="name"
item-value="name"
label="选择模板"
density="compact"
hide-details
variant="outlined"
class="flex-grow-1"
:loading="loading"
> >
<template v-slot:item="{ props, item }"> 恢复默认
<v-list-item v-bind="props" :title="item.raw.name"> </v-btn>
<template v-slot:append>
<v-chip
v-if="item.raw.name === activeTemplate"
color="success"
variant="tonal"
size="small"
class="ml-2"
>
已应用
</v-chip>
<v-btn
v-else
variant="text"
color="primary"
size="small"
class="ml-2"
@click.stop="setActiveTemplate(item.raw.name)"
:loading="applyLoading"
>
应用
</v-btn>
</template>
</v-list-item>
</template>
</v-select>
<v-btn <v-btn
variant="text" variant="text"
icon icon
@@ -83,49 +41,17 @@
<!-- 左侧编辑器 --> <!-- 左侧编辑器 -->
<v-col cols="6" class="d-flex flex-column"> <v-col cols="6" class="d-flex flex-column">
<v-toolbar density="compact" color="surface-variant"> <v-toolbar density="compact" color="surface-variant">
<v-toolbar-title class="text-subtitle-2">模板编辑器</v-toolbar-title> <v-toolbar-title class="text-subtitle-2">HTML 模板编辑器</v-toolbar-title>
<v-spacer></v-spacer> <v-spacer></v-spacer>
<div class="d-flex align-center pa-1" style="border: 1px solid rgba(0,0,0,0.1); border-radius: 8px;"> <v-btn
<v-btn variant="text"
variant="text" size="small"
size="small" @click="saveTemplate"
@click="newTemplate" :loading="saveLoading"
color="success" color="primary"
> >
<v-icon left>mdi-plus</v-icon> 保存模板
新建 </v-btn>
</v-btn>
<v-divider vertical class="mx-1"></v-divider>
<v-btn
variant="text"
size="small"
@click="resetToDefault"
:loading="resetLoading"
color="warning"
>
重置Base
</v-btn>
<v-btn
variant="text"
size="small"
@click="promptDelete"
color="error"
:disabled="isCreatingNew || selectedTemplate === 'base' || !selectedTemplate"
>
删除
</v-btn>
<v-divider vertical class="mx-1"></v-divider>
<v-btn
variant="text"
size="small"
@click="saveTemplate"
:loading="saveLoading"
color="primary"
:disabled="(isCreatingNew && !editingName) || (!isCreatingNew && !selectedTemplate)"
>
保存
</v-btn>
</div>
</v-toolbar> </v-toolbar>
<div class="flex-grow-1" style="border-right: 1px solid rgba(0,0,0,0.1);"> <div class="flex-grow-1" style="border-right: 1px solid rgba(0,0,0,0.1);">
<VueMonacoEditor <VueMonacoEditor
@@ -180,11 +106,10 @@
</v-btn> </v-btn>
<v-btn <v-btn
color="primary" color="primary"
@click="promptApplyAndClose" @click="saveTemplate"
:loading="saveLoading" :loading="saveLoading"
:disabled="isCreatingNew || !selectedTemplate"
> >
保存应用当前编辑模板 保存应用
</v-btn> </v-btn>
</v-col> </v-col>
</v-row> </v-row>
@@ -196,7 +121,7 @@
<v-card> <v-card>
<v-card-title>确认重置</v-card-title> <v-card-title>确认重置</v-card-title>
<v-card-text> <v-card-text>
确定要 'base' 模板恢复默认内容当前编辑器中的任何未保存更改将丢失此操作无法撤销 确定要恢复默认模板这将删除您的自定义模板此操作无法撤销
</v-card-text> </v-card-text>
<v-card-actions> <v-card-actions>
<v-spacer></v-spacer> <v-spacer></v-spacer>
@@ -205,37 +130,6 @@
</v-card-actions> </v-card-actions>
</v-card> </v-card>
</v-dialog> </v-dialog>
<!-- 删除确认对话框 -->
<v-dialog v-model="deleteDialog" max-width="400px">
<v-card>
<v-card-title>确认删除</v-card-title>
<v-card-text>
确定要删除模板 '{{ selectedTemplate }}' 此操作无法撤销
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn text @click="deleteDialog = false">取消</v-btn>
<v-btn color="error" @click="confirmDelete" :loading="saveLoading">确认删除</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- 保存并应用确认对话框 -->
<v-dialog v-model="applyAndCloseDialog" max-width="500px">
<v-card>
<v-card-title>确认操作</v-card-title>
<v-card-text>
确定要保存对 '{{ selectedTemplate }}' 的修改并将其设为新的活动模板吗
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn text @click="applyAndCloseDialog = false">取消</v-btn>
<v-btn color="primary" @click="confirmApplyAndClose" :loading="saveLoading">确认</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-dialog> </v-dialog>
</template> </template>
@@ -247,30 +141,18 @@ import axios from 'axios'
const { t } = useI18n() const { t } = useI18n()
// --- --- //
const dialog = ref(false) const dialog = ref(false)
const loading = ref(false) // const resetDialog = ref(false)
const loading = ref(false)
const saveLoading = ref(false) const saveLoading = ref(false)
const resetLoading = ref(false) const resetLoading = ref(false)
const previewLoading = ref(false) const previewLoading = ref(false)
const applyLoading = ref(false)
//
const templates = ref([])
const activeTemplate = ref('base')
const selectedTemplate = ref(null)
const editingName = ref('') //
const templateContent = ref('') const templateContent = ref('')
const isCreatingNew = ref(false) const hasCustomTemplate = ref(false)
//
const resetDialog = ref(false)
const deleteDialog = ref(false)
const applyAndCloseDialog = ref(false)
const previewFrame = ref(null) const previewFrame = ref(null)
// --- --- //
const editorTheme = computed(() => 'vs-light') const editorTheme = computed(() => 'vs-light')
const editorOptions = { const editorOptions = {
automaticLayout: true, automaticLayout: true,
@@ -281,13 +163,16 @@ const editorOptions = {
scrollBeyondLastLine: false, scrollBeyondLastLine: false,
} }
// --- --- //
const previewData = { const previewData = {
text: '这是一个示例文本,用于预览模板效果。\n\n这里可以包含多行文本,支持换行和各种格式。', text: '这是一个示例文本,用于预览模板效果。\n\n这里可以包含多行文本,支持换行和各种格式。',
version: 'v4.0.0' version: 'v4.0.0'
} }
//
const previewContent = computed(() => { const previewContent = computed(() => {
try { try {
// Jinja2
let content = templateContent.value let content = templateContent.value
content = content.replace(/\{\{\s*text\s*\|\s*safe\s*\}\}/g, previewData.text) content = content.replace(/\{\{\s*text\s*\|\s*safe\s*\}\}/g, previewData.text)
content = content.replace(/\{\{\s*version\s*\}\}/g, previewData.version) content = content.replace(/\{\{\s*version\s*\}\}/g, previewData.version)
@@ -297,128 +182,58 @@ const previewContent = computed(() => {
} }
}) })
// --- API --- //
const loadTemplate = async () => {
const loadInitialData = async () => {
loading.value = true loading.value = true
try { try {
const [listRes, activeRes] = await Promise.all([ const response = await axios.get('/api/config/astrbot/t2i-template/get')
axios.get('/api/t2i/templates'), if (response.data.status === 'ok') {
axios.get('/api/t2i/templates/active') templateContent.value = response.data.data.template
]) hasCustomTemplate.value = response.data.data.has_custom_template
if (listRes.data.status === 'ok') {
templates.value = listRes.data.data
} else { } else {
console.error('加载模板列表失败:', listRes.data.message) console.error('加载模板失败:', response.data.message)
} }
if (activeRes.data.status === 'ok') {
activeTemplate.value = activeRes.data.data.active_template
} else {
console.error('加载活动模板失败:', activeRes.data.message)
}
//
if (templates.value.length > 0) {
selectedTemplate.value = activeTemplate.value
}
} catch (error) { } catch (error) {
console.error('加载初始数据失败:', error) console.error('加载模板失败:', error)
} finally { } finally {
loading.value = false loading.value = false
} }
} }
const loadTemplateContent = async (name) => {
if (!name) return
previewLoading.value = true
try {
const response = await axios.get(`/api/t2i/templates/${name}`)
if (response.data.status === 'ok') {
templateContent.value = response.data.data.content
} else {
console.error(`加载模板 '${name}' 失败:`, response.data.message)
}
} catch (error) {
console.error(`加载模板 '${name}' 失败:`, error)
} finally {
previewLoading.value = false
}
}
const saveTemplate = async () => { const saveTemplate = async () => {
saveLoading.value = true saveLoading.value = true
try { try {
if (isCreatingNew.value) { const response = await axios.post('/api/config/astrbot/t2i-template/save', {
// --- --- template: templateContent.value
if (!editingName.value) return })
const response = await axios.post('/api/t2i/templates/create', { if (response.data.status === 'ok') {
name: editingName.value, hasCustomTemplate.value = true
content: templateContent.value closeDialog()
})
await loadInitialData() //
selectedTemplate.value = response.data.data.name
isCreatingNew.value = false
} else { } else {
// --- --- console.error('保存模板失败:', response.data.message)
if (!selectedTemplate.value) return
await axios.put(`/api/t2i/templates/${selectedTemplate.value}`, {
content: templateContent.value
})
} }
} catch (error) { } catch (error) {
console.error('保存模板失败:', error) console.error('保存模板失败:', error)
//
} finally { } finally {
saveLoading.value = false saveLoading.value = false
} }
} }
const setActiveTemplate = async (name) => { const resetToDefault = () => {
applyLoading.value = true resetDialog.value = true
try {
await axios.post('/api/t2i/templates/set_active', { name })
activeTemplate.value = name
} catch (error) {
console.error(`应用模板 '${name}' 失败:`, error)
} finally {
applyLoading.value = false
}
}
const confirmDelete = async () => {
if (!selectedTemplate.value || selectedTemplate.value === 'base') return
saveLoading.value = true
try {
const nameToDelete = selectedTemplate.value
await axios.delete(`/api/t2i/templates/${nameToDelete}`)
deleteDialog.value = false
// base
if (activeTemplate.value === nameToDelete) {
await setActiveTemplate('base')
}
await loadInitialData()
selectedTemplate.value = 'base'
} catch (error) {
console.error(`删除模板 '${selectedTemplate.value}' 失败:`, error)
} finally {
saveLoading.value = false
}
} }
const confirmReset = async () => { const confirmReset = async () => {
resetLoading.value = true resetLoading.value = true
try { try {
await axios.post('/api/t2i/templates/reset_default') const response = await axios.delete('/api/config/astrbot/t2i-template/delete')
resetDialog.value = false if (response.data.status === 'ok') {
if (selectedTemplate.value === 'base') { hasCustomTemplate.value = false
await loadTemplateContent('base') resetDialog.value = false
} //
if (activeTemplate.value !== 'base') { await loadTemplate()
await setActiveTemplate('base') } else {
console.error('重置模板失败:', response.data.message)
} }
} catch (error) { } catch (error) {
console.error('重置模板失败:', error) console.error('重置模板失败:', error)
@@ -427,58 +242,15 @@ const confirmReset = async () => {
} }
} }
// --- UI ---
const resetToDefault = () => {
resetDialog.value = true
}
const newTemplate = () => {
isCreatingNew.value = true
selectedTemplate.value = null
editingName.value = ''
templateContent.value = `<!doctype html>
<html>
<head>
<meta charset="utf-8"/>
<title>New Template</title>
</head>
<body>
<!-- 从这里开始编辑 -->
<article>{{ text | safe }}</article>
</body>
</html>
`
}
const promptDelete = () => {
if (selectedTemplate.value && selectedTemplate.value !== 'base') {
deleteDialog.value = true
}
}
const promptApplyAndClose = () => {
if (!isCreatingNew.value && selectedTemplate.value) {
applyAndCloseDialog.value = true
}
}
const confirmApplyAndClose = async () => {
if (isCreatingNew.value) return
await saveTemplate()
await setActiveTemplate(selectedTemplate.value)
applyAndCloseDialog.value = false
closeDialog()
}
const refreshPreview = () => { const refreshPreview = () => {
previewLoading.value = true previewLoading.value = true
nextTick(() => { nextTick(() => {
if (previewFrame.value) { if (previewFrame.value) {
previewFrame.value.contentWindow.location.reload() previewFrame.value.contentWindow.location.reload()
} }
setTimeout(() => previewLoading.value = false, 500) setTimeout(() => {
previewLoading.value = false
}, 500)
}) })
} }
@@ -486,29 +258,18 @@ const closeDialog = () => {
dialog.value = false dialog.value = false
} }
// --- ---
watch(dialog, (newVal) => { watch(dialog, (newVal) => {
if (newVal) { if (newVal && !templateContent.value) {
loadInitialData() loadTemplate()
} else {
//
selectedTemplate.value = null
templateContent.value = ''
isCreatingNew.value = false
}
})
watch(selectedTemplate, (newName) => {
if (newName) {
isCreatingNew.value = false
loadTemplateContent(newName)
} }
}) })
defineExpose({ defineExpose({
openDialog: () => { openDialog: () => {
dialog.value = true dialog.value = true
if (!templateContent.value) {
loadTemplate()
}
} }
}) })
</script> </script>
@@ -34,7 +34,7 @@
"tip": "💡 TIP:", "tip": "💡 TIP:",
"tipLink": "", "tipLink": "",
"tipContinue": "By default, the corresponding version of the WebUI files will be downloaded when switching versions. The WebUI code is located in the dashboard directory of the project, and you can use npm to build it yourself.", "tipContinue": "By default, the corresponding version of the WebUI files will be downloaded when switching versions. The WebUI code is located in the dashboard directory of the project, and you can use npm to build it yourself.",
"dockerTip": "When switching versions, it will try to update both the bot main program and the dashboard. If you are using Docker deployment, you can also re-pull the image or use", "dockerTip": "The `Update to Latest Version` button will try to update both the bot main program and the dashboard. If you are using Docker deployment, you can also re-pull the image or use",
"dockerTipLink": "watchtower", "dockerTipLink": "watchtower",
"dockerTipContinue": "to automatically monitor and pull.", "dockerTipContinue": "to automatically monitor and pull.",
"table": { "table": {
@@ -11,8 +11,6 @@
"migratingSubtitle": "Please wait patiently, do not close this window during migration", "migratingSubtitle": "Please wait patiently, do not close this window during migration",
"migrationError": "Migration failed", "migrationError": "Migration failed",
"success": "Migration completed successfully!", "success": "Migration completed successfully!",
"completed": "Migration Completed", "completed": "Migration Completed"
"restartRecommended": "It is recommended to restart the application for all changes to take effect.",
"restartNow": "Restart Now"
} }
} }
@@ -29,11 +29,6 @@
"title": "ID Conflict Warning", "title": "ID Conflict Warning",
"message": "Detected duplicate ID \"{id}\". Please use a new ID.", "message": "Detected duplicate ID \"{id}\". Please use a new ID.",
"confirm": "OK" "confirm": "OK"
},
"securityWarning": {
"title": "Security Warning",
"aiocqhttpTokenMissing": "To enhance connection security, it is strongly recommended to set ws_reverse_token. Not setting a token may lead to security risks.",
"learnMore": "Learn More"
} }
}, },
"messages": { "messages": {
@@ -33,7 +33,7 @@
}, },
"tip": "💡 TIP: ", "tip": "💡 TIP: ",
"tipContinue": "默认在切换版本时会下载对应版本的 WebUI 文件。WebUI 代码位于项目的 dashboard 目录,您可使用 npm 自行构建。", "tipContinue": "默认在切换版本时会下载对应版本的 WebUI 文件。WebUI 代码位于项目的 dashboard 目录,您可使用 npm 自行构建。",
"dockerTip": "切换版本时,会同时尝试更新机器人主程序和管理面板。如果您正在使用 Docker 部署,也可以重新拉取镜像或者使用", "dockerTip": "`更新到最新版本` 按钮会同时尝试更新机器人主程序和管理面板。如果您正在使用 Docker 部署,也可以重新拉取镜像或者使用",
"dockerTipLink": "watchtower", "dockerTipLink": "watchtower",
"dockerTipContinue": "来自动监控拉取。", "dockerTipContinue": "来自动监控拉取。",
"table": { "table": {
@@ -11,8 +11,6 @@
"migratingSubtitle": "请耐心等待,迁移过程中请勿关闭此窗口", "migratingSubtitle": "请耐心等待,迁移过程中请勿关闭此窗口",
"migrationError": "迁移失败", "migrationError": "迁移失败",
"success": "迁移成功完成!", "success": "迁移成功完成!",
"completed": "迁移已完成", "completed": "迁移已完成"
"restartRecommended": "建议重启应用程序以使所有更改生效。",
"restartNow": "立即重启"
} }
} }
@@ -29,11 +29,6 @@
"title": "ID 冲突警告", "title": "ID 冲突警告",
"message": "检测到 ID \"{id}\" 重复。请使用一个新的 ID。", "message": "检测到 ID \"{id}\" 重复。请使用一个新的 ID。",
"confirm": "好的" "confirm": "好的"
},
"securityWarning": {
"title": "安全提醒",
"aiocqhttpTokenMissing": "为了增强连接安全性,强烈建议您设置 ws_reverse_token。未设置 Token 可能导致安全风险。",
"learnMore": "了解更多"
} }
}, },
"messages": { "messages": {
@@ -378,6 +378,10 @@ commonStore.getStartTime();
<!-- 发行版 --> <!-- 发行版 -->
<v-tabs-window-item key="0" v-show="tab == 0"> <v-tabs-window-item key="0" v-show="tab == 0">
<v-btn class="mt-4 mb-4" @click="switchVersion('latest')" color="primary" style="border-radius: 10px;"
:disabled="!hasNewVersion">
{{ t('core.header.updateDialog.updateToLatest') }}
</v-btn>
<div class="mb-4"> <div class="mb-4">
<small>{{ t('core.header.updateDialog.dockerTip') }} <a <small>{{ t('core.header.updateDialog.dockerTip') }} <a
href="https://containrrr.dev/watchtower/usage-overview/">{{ t('core.header.updateDialog.dockerTipLink') }}</a> {{ t('core.header.updateDialog.dockerTipContinue') }}</small> href="https://containrrr.dev/watchtower/usage-overview/">{{ t('core.header.updateDialog.dockerTipLink') }}</a> {{ t('core.header.updateDialog.dockerTipContinue') }}</small>
+1 -3
View File
@@ -18,8 +18,6 @@
</v-list-item> </v-list-item>
</template> </template>
</v-select> </v-select>
<a style="color: inherit;" href="https://blog.astrbot.app/posts/what-is-changed-in-4.0.0/#%E5%A4%9A%E9%85%8D%E7%BD%AE%E6%96%87%E4%BB%B6" target="_blank"><v-btn icon="mdi-help-circle" size="small" variant="plain"></v-btn></a>
</div> </div>
<v-btn-toggle v-model="configType" mandatory color="primary" variant="outlined" density="comfortable" <v-btn-toggle v-model="configType" mandatory color="primary" variant="outlined" density="comfortable"
@@ -483,7 +481,7 @@ export default {
if (!this.fetched) return; if (!this.fetched) return;
const postData = { const postData = {
config: JSON.parse(JSON.stringify(this.config_data)), config: this.config_data
}; };
if (this.isSystemConfig) { if (this.isSystemConfig) {
+2 -2
View File
@@ -78,7 +78,7 @@ const pluginHeaders = computed(() => [
{ title: tm('table.headers.description'), key: 'desc', maxWidth: '250px' }, { title: tm('table.headers.description'), key: 'desc', maxWidth: '250px' },
{ title: tm('table.headers.version'), key: 'version', width: '100px' }, { title: tm('table.headers.version'), key: 'version', width: '100px' },
{ title: tm('table.headers.author'), key: 'author', width: '100px' }, { title: tm('table.headers.author'), key: 'author', width: '100px' },
{ title: tm('table.headers.status'), key: 'activated', width: '100px' }, { title: tm('table.headers.status'), key: 'status', width: '80px' },
{ title: tm('table.headers.actions'), key: 'actions', sortable: false, width: '220px' } { title: tm('table.headers.actions'), key: 'actions', sortable: false, width: '220px' }
]); ]);
@@ -660,7 +660,7 @@ onMounted(async () => {
<div class="text-body-2">{{ item.author }}</div> <div class="text-body-2">{{ item.author }}</div>
</template> </template>
<template v-slot:item.activated="{ item }"> <template v-slot:item.status="{ item }">
<v-chip :color="item.activated ? 'success' : 'error'" size="small" class="font-weight-medium" <v-chip :color="item.activated ? 'success' : 'error'" size="small" class="font-weight-medium"
:variant="item.activated ? 'flat' : 'outlined'"> :variant="item.activated ? 'flat' : 'outlined'">
{{ item.activated ? tm('status.enabled') : tm('status.disabled') }} {{ item.activated ? tm('status.enabled') : tm('status.disabled') }}
+2 -1
View File
@@ -383,7 +383,8 @@ export default {
messageType: 'success', messageType: 'success',
personaIdRules: [ personaIdRules: [
v => !!v || this.tm('validation.required'), v => !!v || this.tm('validation.required'),
v => (v && v.length >= 0) || this.tm('validation.minLength', { min: 2 }), v => (v && v.length >= 2) || this.tm('validation.minLength', { min: 2 }),
v => /^[a-zA-Z0-9_-]+$/.test(v) || this.tm('validation.alphanumeric')
], ],
systemPromptRules: [ systemPromptRules: [
v => !!v || this.tm('validation.required'), v => !!v || this.tm('validation.required'),
+28 -108
View File
@@ -168,28 +168,6 @@
</v-card-actions> </v-card-actions>
</v-card> </v-card>
</v-dialog> </v-dialog>
<!-- 安全警告对话框 -->
<v-dialog v-model="showOneBotEmptyTokenWarnDialog" max-width="600" persistent>
<v-card>
<v-card-title>
{{ tm('dialog.securityWarning.title') }}
</v-card-title>
<v-card-text class="py-4">
<p>{{ tm('dialog.securityWarning.aiocqhttpTokenMissing') }}</p>
<span><a href="https://docs.astrbot.app/deploy/platform/aiocqhttp/napcat.html#%E9%99%84%E5%BD%95-%E5%A2%9E%E5%BC%BA%E8%BF%9E%E6%8E%A5%E5%AE%89%E5%85%A8%E6%80%A7" target="_blank">{{ tm('dialog.securityWarning.learnMore') }}</a></span>
</v-card-text>
<v-card-actions class="px-4 pb-4">
<v-spacer></v-spacer>
<v-btn color="error" @click="handleOneBotEmptyTokenWarningDismiss(true)">
无视警告并继续创建
</v-btn>
<v-btn color="primary" @click="handleOneBotEmptyTokenWarningDismiss(false)">
重新修改
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</div> </div>
</template> </template>
@@ -256,27 +234,17 @@ export default {
conflictId: '', conflictId: '',
idConflictResolve: null, idConflictResolve: null,
// OneBot Empty Token Warning #2639
showOneBotEmptyTokenWarnDialog: false,
oneBotEmptyTokenWarningResolve: null,
store: useCommonStore() store: useCommonStore()
} }
}, },
watch: { watch: {
showIdConflictDialog(newValue) { showIdConflictDialog(newValue) {
// Promise
if (!newValue && this.idConflictResolve) { if (!newValue && this.idConflictResolve) {
this.idConflictResolve(false); this.idConflictResolve(false);
this.idConflictResolve = null; this.idConflictResolve = null;
} }
},
showOneBotEmptyTokenWarnDialog(newValue) {
if (!newValue && this.oneBotEmptyTokenWarningResolve) {
this.oneBotEmptyTokenWarningResolve(true);
this.oneBotEmptyTokenWarningResolve = null;
}
} }
}, },
@@ -311,27 +279,24 @@ export default {
return new URL('@/assets/images/platform_logos/kook.png', import.meta.url).href return new URL('@/assets/images/platform_logos/kook.png', import.meta.url).href
} else if (name === 'vocechat') { } else if (name === 'vocechat') {
return new URL('@/assets/images/platform_logos/vocechat.png', import.meta.url).href return new URL('@/assets/images/platform_logos/vocechat.png', import.meta.url).href
} else if (name === 'satori' || name === 'Satori') {
return new URL('@/assets/images/platform_logos/satori.png', import.meta.url).href
} }
}, },
getTutorialLink(platform_type) { getTutorialLink(platform_type) {
let tutorial_map = { let tutorial_map = {
"qq_official_webhook": "https://docs.astrbot.app/deploy/platform/qqofficial/webhook.html", "qq_official_webhook": "https://astrbot.app/deploy/platform/qqofficial/webhook.html",
"qq_official": "https://docs.astrbot.app/deploy/platform/qqofficial/websockets.html", "qq_official": "https://astrbot.app/deploy/platform/qqofficial/websockets.html",
"aiocqhttp": "https://docs.astrbot.app/deploy/platform/aiocqhttp/napcat.html", "aiocqhttp": "https://astrbot.app/deploy/platform/aiocqhttp/napcat.html",
"wecom": "https://docs.astrbot.app/deploy/platform/wecom.html", "wecom": "https://astrbot.app/deploy/platform/wecom.html",
"lark": "https://docs.astrbot.app/deploy/platform/lark.html", "lark": "https://astrbot.app/deploy/platform/lark.html",
"telegram": "https://docs.astrbot.app/deploy/platform/telegram.html", "telegram": "https://astrbot.app/deploy/platform/telegram.html",
"dingtalk": "https://docs.astrbot.app/deploy/platform/dingtalk.html", "dingtalk": "https://astrbot.app/deploy/platform/dingtalk.html",
"wechatpadpro": "https://docs.astrbot.app/deploy/platform/wechat/wechatpadpro.html", "wechatpadpro": "https://astrbot.app/deploy/platform/wechat/wechatpadpro.html",
"weixin_official_account": "https://docs.astrbot.app/deploy/platform/weixin-official-account.html", "weixin_official_account": "https://astrbot.app/deploy/platform/weixin-official-account.html",
"discord": "https://docs.astrbot.app/deploy/platform/discord.html", "discord": "https://astrbot.app/deploy/platform/discord.html",
"slack": "https://docs.astrbot.app/deploy/platform/slack.html", "slack": "https://astrbot.app/deploy/platform/slack.html",
"kook": "https://docs.astrbot.app/deploy/platform/kook.html", "kook": "https://astrbot.app/deploy/platform/kook.html",
"vocechat": "https://docs.astrbot.app/deploy/platform/vocechat.html", "vocechat": "https://astrbot.app/deploy/platform/vocechat.html",
"satori": "https://docs.astrbot.app/deploy/platform/satori/llonebot.html",
} }
return tutorial_map[platform_type] || "https://docs.astrbot.app"; return tutorial_map[platform_type] || "https://docs.astrbot.app";
}, },
@@ -385,39 +350,24 @@ export default {
newPlatform() { newPlatform() {
this.loading = true; this.loading = true;
if (this.updatingMode) { if (this.updatingMode) {
if (this.newSelectedPlatformConfig.type === 'aiocqhttp') { axios.post('/api/config/platform/update', {
const token = this.newSelectedPlatformConfig.ws_reverse_token; id: this.newSelectedPlatformName,
if (!token || token.trim() === '') { config: this.newSelectedPlatformConfig
this.showOneBotEmptyTokenWarning().then((continueWithWarning) => { }).then((res) => {
if (continueWithWarning) { this.loading = false;
this.updatePlatform(); this.showPlatformCfg = false;
} this.getConfig();
}); this.showSuccess(res.data.message || this.messages.updateSuccess);
return; }).catch((err) => {
} this.loading = false;
} this.showError(err.response?.data?.message || err.message);
this.updatePlatform(); });
this.updatingMode = false;
} else { } else {
this.savePlatform(); this.savePlatform();
} }
}, },
updatePlatform() {
axios.post('/api/config/platform/update', {
id: this.newSelectedPlatformName,
config: this.newSelectedPlatformConfig
}).then((res) => {
this.loading = false;
this.showPlatformCfg = false;
this.getConfig();
this.showSuccess(res.data.message || this.messages.updateSuccess);
}).catch((err) => {
this.loading = false;
this.showError(err.response?.data?.message || err.message);
});
this.updatingMode = false;
},
async savePlatform() { async savePlatform() {
// ID // ID
const existingPlatform = this.config_data.platform?.find(p => p.id === this.newSelectedPlatformConfig.id); const existingPlatform = this.config_data.platform?.find(p => p.id === this.newSelectedPlatformConfig.id);
@@ -429,17 +379,6 @@ export default {
} }
} }
// aiocqhttp
if (this.newSelectedPlatformConfig.type === 'aiocqhttp') {
const token = this.newSelectedPlatformConfig.ws_reverse_token;
if (!token || token.trim() === '') {
const continueWithWarning = await this.showOneBotEmptyTokenWarning();
if (!continueWithWarning) {
return;
}
}
}
try { try {
const res = await axios.post('/api/config/platform/new', this.newSelectedPlatformConfig); const res = await axios.post('/api/config/platform/new', this.newSelectedPlatformConfig);
this.loading = false; this.loading = false;
@@ -467,25 +406,6 @@ export default {
this.showIdConflictDialog = false; this.showIdConflictDialog = false;
}, },
showOneBotEmptyTokenWarning() {
this.showOneBotEmptyTokenWarnDialog = true;
return new Promise((resolve) => {
this.oneBotEmptyTokenWarningResolve = resolve;
});
},
handleOneBotEmptyTokenWarningDismiss(continueWithWarning) {
this.showOneBotEmptyTokenWarnDialog = false;
if (this.oneBotEmptyTokenWarningResolve) {
this.oneBotEmptyTokenWarningResolve(continueWithWarning);
this.oneBotEmptyTokenWarningResolve = null;
}
if (!continueWithWarning) {
this.loading = false;
}
},
deletePlatform(platform) { deletePlatform(platform) {
if (confirm(`${this.messages.deleteConfirm} ${platform.id}?`)) { if (confirm(`${this.messages.deleteConfirm} ${platform.id}?`)) {
axios.post('/api/config/platform/delete', { id: platform.id }).then((res) => { axios.post('/api/config/platform/delete', { id: platform.id }).then((res) => {
@@ -612,4 +532,4 @@ export default {
font-weight: bold; font-weight: bold;
opacity: 0.3; opacity: 0.3;
} }
</style> </style>
+2 -2
View File
@@ -718,10 +718,10 @@ export default {
createCollection(name, emoji, description) { createCollection(name, emoji, description) {
// this.newKB.embedding_provider_id Object // this.newKB.embedding_provider_id Object
if (this.newKB.embedding_provider_id && typeof this.newKB.embedding_provider_id === 'object') { if (typeof this.newKB.embedding_provider_id === 'object') {
this.newKB.embedding_provider_id = this.newKB.embedding_provider_id.id || ''; this.newKB.embedding_provider_id = this.newKB.embedding_provider_id.id || '';
} }
if (this.newKB.rerank_provider_id && typeof this.newKB.rerank_provider_id === 'object') { if (typeof this.newKB.rerank_provider_id === 'object') {
this.newKB.rerank_provider_id = this.newKB.rerank_provider_id.id || ''; this.newKB.rerank_provider_id = this.newKB.rerank_provider_id.id || '';
} }
axios.post('/api/plug/alkaid/kb/create_collection', { axios.post('/api/plug/alkaid/kb/create_collection', {
+14 -33
View File
@@ -2,13 +2,11 @@ import os
import asyncio import asyncio
import sys import sys
import mimetypes import mimetypes
import argparse
from astrbot.core.initial_loader import InitialLoader from astrbot.core.initial_loader import InitialLoader
from astrbot.core import db_helper from astrbot.core import db_helper
from astrbot.core import logger, LogManager, LogBroker from astrbot.core import logger, LogManager, LogBroker
from astrbot.core.config.default import VERSION from astrbot.core.config.default import VERSION
from astrbot.core.utils.io import download_dashboard, get_dashboard_version from astrbot.core.utils.io import download_dashboard, get_dashboard_version
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
# add parent path to sys.path # add parent path to sys.path
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
@@ -39,50 +37,34 @@ def check_env():
mimetypes.add_type("application/json", ".json") mimetypes.add_type("application/json", ".json")
async def check_dashboard_files(webui_dir: str | None = None): async def check_dashboard_files():
"""下载管理面板文件""" """下载管理面板文件"""
# 指定webui目录
if webui_dir:
if os.path.exists(webui_dir):
logger.info(f"使用指定的 WebUI 目录: {webui_dir}")
return webui_dir
else:
logger.warning(f"指定的 WebUI 目录 {webui_dir} 不存在,将使用默认逻辑。")
data_dist_path = os.path.join(get_astrbot_data_path(), "dist") v = await get_dashboard_version()
if os.path.exists(data_dist_path): if v is not None:
v = await get_dashboard_version() # has file
if v is not None: if v == f"v{VERSION}":
# has file logger.info("管理面板文件已是最新。")
if v == f"v{VERSION}": else:
logger.info("WebUI 版本已是最新。") logger.warning(
else: "检测到管理面板有更新。可以使用 /dashboard_update 命令更新。"
logger.warning( )
f"检测到 WebUI 版本 ({v}) 与当前 AstrBot 版本 (v{VERSION}) 不符。" return
)
return data_dist_path
logger.info( logger.info(
"开始下载管理面板文件...高峰期(晚上)可能导致较慢的速度。如多次下载失败,请前往 https://github.com/Soulter/AstrBot/releases/latest 下载 dist.zip,并将其中的 dist 文件夹解压至 data 目录下。" "开始下载管理面板文件...高峰期(晚上)可能导致较慢的速度。如多次下载失败,请前往 https://github.com/Soulter/AstrBot/releases/latest 下载 dist.zip,并将其中的 dist 文件夹解压至 data 目录下。"
) )
try: try:
await download_dashboard(version=f"v{VERSION}", latest=False) await download_dashboard()
except Exception as e: except Exception as e:
logger.critical(f"下载管理面板文件失败: {e}") logger.critical(f"下载管理面板文件失败: {e}")
return None return
logger.info("管理面板下载完成。") logger.info("管理面板下载完成。")
return data_dist_path
if __name__ == "__main__": if __name__ == "__main__":
parser = argparse.ArgumentParser(description="AstrBot")
parser.add_argument(
"--webui-dir", type=str, help="指定 WebUI 静态文件目录路径", default=None
)
args = parser.parse_args()
check_env() check_env()
# start log broker # start log broker
@@ -90,7 +72,7 @@ if __name__ == "__main__":
LogManager.set_queue_handler(logger, log_broker) LogManager.set_queue_handler(logger, log_broker)
# check dashboard files # check dashboard files
webui_dir = asyncio.run(check_dashboard_files(args.webui_dir)) asyncio.run(check_dashboard_files())
db = db_helper db = db_helper
@@ -98,5 +80,4 @@ if __name__ == "__main__":
logger.info(logo_tmpl) logger.info(logo_tmpl)
core_lifecycle = InitialLoader(db, log_broker) core_lifecycle = InitialLoader(db, log_broker)
core_lifecycle.webui_dir = webui_dir
asyncio.run(core_lifecycle.start()) asyncio.run(core_lifecycle.start())
+7 -12
View File
@@ -25,18 +25,14 @@ class LongTermMemory:
def cfg(self, event: AstrMessageEvent): def cfg(self, event: AstrMessageEvent):
cfg = self.context.get_config(umo=event.unified_msg_origin) cfg = self.context.get_config(umo=event.unified_msg_origin)
try: try:
max_cnt = int(cfg["provider_ltm_settings"]["group_message_max_cnt"]) max_cnt = int(cfg["group_message_max_cnt"])
except BaseException as e: except BaseException as e:
logger.error(e) logger.error(e)
max_cnt = 300 max_cnt = 300
image_caption = ( image_caption = cfg["image_caption"]
True image_caption_prompt = cfg["image_caption_prompt"]
if cfg["provider_settings"]["default_image_caption_provider_id"] image_caption_provider_id = cfg["image_caption_provider_id"]
else False active_reply = cfg["active_reply"]
)
image_caption_prompt = cfg["provider_settings"]["image_caption_prompt"]
image_caption_provider_id = cfg["provider_settings"]["default_image_caption_provider_id"]
active_reply = cfg["provider_ltm_settings"]["active_reply"]
enable_active_reply = active_reply.get("enable", False) enable_active_reply = active_reply.get("enable", False)
ar_method = active_reply["method"] ar_method = active_reply["method"]
ar_possibility = active_reply["possibility_reply"] ar_possibility = active_reply["possibility_reply"]
@@ -92,9 +88,7 @@ class LongTermMemory:
if cfg["ar_whitelist"] and ( if cfg["ar_whitelist"] and (
event.unified_msg_origin not in cfg["ar_whitelist"] event.unified_msg_origin not in cfg["ar_whitelist"]
and ( and (event.get_group_id() and event.get_group_id() not in cfg["ar_whitelist"])
event.get_group_id() and event.get_group_id() not in cfg["ar_whitelist"]
)
): ):
return False return False
@@ -118,6 +112,7 @@ class LongTermMemory:
if isinstance(comp, Plain): if isinstance(comp, Plain):
final_message += f" {comp.text}" final_message += f" {comp.text}"
elif isinstance(comp, Image): elif isinstance(comp, Image):
cfg = self.cfg(event)
if cfg["image_caption"]: if cfg["image_caption"]:
try: try:
caption = await self.get_image_caption( caption = await self.get_image_caption(
+6 -9
View File
@@ -1094,7 +1094,7 @@ UID: {user_id} 此 ID 可用于设置管理员。
@filter.command("dashboard_update") @filter.command("dashboard_update")
async def update_dashboard(self, event: AstrMessageEvent): async def update_dashboard(self, event: AstrMessageEvent):
yield event.plain_result("正在尝试更新管理面板...") yield event.plain_result("正在尝试更新管理面板...")
await download_dashboard(version=f"v{VERSION}", latest=False) await download_dashboard()
yield event.plain_result("管理面板更新完成。") yield event.plain_result("管理面板更新完成。")
@filter.command("set") @filter.command("set")
@@ -1110,9 +1110,7 @@ UID: {user_id} 此 ID 可用于设置管理员。
@filter.command("unset") @filter.command("unset")
async def unset_variable(self, event: AstrMessageEvent, key: str): async def unset_variable(self, event: AstrMessageEvent, key: str):
uid = event.unified_msg_origin uid = event.unified_msg_origin
session_var = await sp.session_get( session_var = await sp.session_get(umo="uid", key="session_variables", default={})
umo="uid", key="session_variables", default={}
)
if key not in session_var: if key not in session_var:
yield event.plain_result("没有那个变量名。格式 /unset 变量名。") yield event.plain_result("没有那个变量名。格式 /unset 变量名。")
@@ -1178,7 +1176,9 @@ UID: {user_id} 此 ID 可用于设置管理员。
) )
return return
prompt = event.message_str prompt = self.ltm.ar_prompt
if not prompt:
prompt = event.message_str
yield event.request_llm( yield event.request_llm(
prompt=prompt, prompt=prompt,
@@ -1255,14 +1255,11 @@ UID: {user_id} 此 ID 可用于设置管理员。
if (persona and persona.get("tools") is None) or not persona: if (persona and persona.get("tools") is None) or not persona:
# select all # select all
toolset = tmgr.get_full_tool_set() toolset = tmgr.get_full_tool_set()
for tool in toolset:
if not tool.active:
toolset.remove_tool(tool.name)
else: else:
toolset = ToolSet() toolset = ToolSet()
for tool_name in persona["tools"]: for tool_name in persona["tools"]:
tool = tmgr.get_func(tool_name) tool = tmgr.get_func(tool_name)
if tool and tool.active: if tool:
toolset.add_tool(tool) toolset.add_tool(tool)
req.func_tool = toolset req.func_tool = toolset
logger.debug(f"Tool set for persona {persona_id}: {toolset.names()}") logger.debug(f"Tool set for persona {persona_id}: {toolset.names()}")
+4 -4
View File
@@ -463,11 +463,11 @@ class Main(star.Star):
yield event.image_result(image_path) yield event.image_result(image_path)
elif match.group(1) == "FILE": elif match.group(1) == "FILE":
file_path = os.path.join(workplace_path, match.group(2)) file_path = os.path.join(workplace_path, match.group(2))
# logger.debug(f"Sending file: {file_path}") logger.debug(f"Sending file: {file_path}")
# file_s3_url = await self.file_upload(file_path) file_s3_url = await self.file_upload(file_path)
# logger.info(f"文件上传到 AstrBot 云节点: {file_s3_url}") logger.info(f"文件上传到 AstrBot 云节点: {file_s3_url}")
file_name = os.path.basename(file_path) file_name = os.path.basename(file_path)
chain = [File(name=file_name, file=file_path)] chain = [File(name=file_name, file=file_s3_url)]
yield event.set_result(MessageEventResult(chain=chain)) yield event.set_result(MessageEventResult(chain=chain))
elif "Traceback (most recent call last)" in log or "[Error]: " in log: elif "Traceback (most recent call last)" in log or "[Error]: " in log:
+10 -30
View File
@@ -26,21 +26,6 @@ class Main(star.Star):
def __init__(self, context: star.Context) -> None: def __init__(self, context: star.Context) -> None:
self.context = context self.context = context
self.tavily_key_index = 0
self.tavily_key_lock = asyncio.Lock()
# 将 str 类型的 key 迁移至 list[str],并保存
cfg = self.context.get_config()
provider_settings = cfg.get("provider_settings")
if provider_settings:
tavily_key = provider_settings.get("websearch_tavily_key")
if isinstance(tavily_key, str):
logger.info("检测到旧版 websearch_tavily_key (字符串格式),自动迁移为列表格式并保存。")
if tavily_key:
provider_settings["websearch_tavily_key"] = [tavily_key]
else:
provider_settings["websearch_tavily_key"] = []
cfg.save_config()
self.bing_search = Bing() self.bing_search = Bing()
self.sogo_search = Sogo() self.sogo_search = Sogo()
@@ -109,22 +94,13 @@ class Main(star.Star):
return results return results
async def _get_tavily_key(self, cfg: AstrBotConfig) -> str:
"""并发安全的从列表中获取并轮换Tavily API密钥。"""
tavily_keys = cfg.get("provider_settings", {}).get("websearch_tavily_key", [])
if not tavily_keys:
raise ValueError("错误:Tavily API密钥未在AstrBot中配置。")
async with self.tavily_key_lock:
key = tavily_keys[self.tavily_key_index]
self.tavily_key_index = (self.tavily_key_index + 1) % len(tavily_keys)
return key
async def _web_search_tavily( async def _web_search_tavily(
self, cfg: AstrBotConfig, payload: dict self, cfg: AstrBotConfig, payload: dict
) -> list[SearchResult]: ) -> list[SearchResult]:
"""使用 Tavily 搜索引擎进行搜索""" """使用 Tavily 搜索引擎进行搜索"""
tavily_key = await self._get_tavily_key(cfg) tavily_key = cfg.get("provider_settings", {}).get("websearch_tavily_key", None)
if not tavily_key:
raise ValueError("Error: Tavily API key is not configured in AstrBot.")
url = "https://api.tavily.com/search" url = "https://api.tavily.com/search"
header = { header = {
"Authorization": f"Bearer {tavily_key}", "Authorization": f"Bearer {tavily_key}",
@@ -152,7 +128,9 @@ class Main(star.Star):
async def _extract_tavily(self, cfg: AstrBotConfig, payload: dict) -> list[dict]: async def _extract_tavily(self, cfg: AstrBotConfig, payload: dict) -> list[dict]:
"""使用 Tavily 提取网页内容""" """使用 Tavily 提取网页内容"""
tavily_key = await self._get_tavily_key(cfg) tavily_key = cfg.get("provider_settings", {}).get("websearch_tavily_key", None)
if not tavily_key:
raise ValueError("Error: Tavily API key is not configured in AstrBot.")
url = "https://api.tavily.com/extract" url = "https://api.tavily.com/extract"
header = { header = {
"Authorization": f"Bearer {tavily_key}", "Authorization": f"Bearer {tavily_key}",
@@ -257,7 +235,8 @@ class Main(star.Star):
logger.info(f"web_searcher - search_from_tavily: {query}") logger.info(f"web_searcher - search_from_tavily: {query}")
cfg = self.context.get_config(umo=event.unified_msg_origin) cfg = self.context.get_config(umo=event.unified_msg_origin)
websearch_link = cfg["provider_settings"].get("web_search_link", False) websearch_link = cfg["provider_settings"].get("web_search_link", False)
if not cfg.get("provider_settings", {}).get("websearch_tavily_key", []): tavily_key = cfg.get("provider_settings", {}).get("websearch_tavily_key", None)
if not tavily_key:
raise ValueError("Error: Tavily API key is not configured in AstrBot.") raise ValueError("Error: Tavily API key is not configured in AstrBot.")
# build payload # build payload
@@ -309,7 +288,8 @@ class Main(star.Star):
extract_depth(string): Optional. The depth of the extraction, must be one of 'basic', 'advanced'. Default is "basic". extract_depth(string): Optional. The depth of the extraction, must be one of 'basic', 'advanced'. Default is "basic".
""" """
cfg = self.context.get_config(umo=event.unified_msg_origin) cfg = self.context.get_config(umo=event.unified_msg_origin)
if not cfg.get("provider_settings", {}).get("websearch_tavily_key", []): tavily_key = cfg.get("provider_settings", {}).get("websearch_tavily_key", None)
if not tavily_key:
raise ValueError("Error: Tavily API key is not configured in AstrBot.") raise ValueError("Error: Tavily API key is not configured in AstrBot.")
if not url: if not url:
+2 -2
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "AstrBot" name = "AstrBot"
version = "4.1.0" version = "4.0.0"
description = "易上手的多平台 LLM 聊天机器人及开发框架" description = "易上手的多平台 LLM 聊天机器人及开发框架"
readme = "README.md" readme = "README.md"
requires-python = ">=3.10" requires-python = ">=3.10"
@@ -21,7 +21,7 @@ dependencies = [
"deprecated>=1.2.18", "deprecated>=1.2.18",
"dingtalk-stream>=0.22.1", "dingtalk-stream>=0.22.1",
"docstring-parser>=0.16", "docstring-parser>=0.16",
"faiss-cpu==1.10.0", "faiss-cpu>=1.10.0",
"filelock>=3.18.0", "filelock>=3.18.0",
"google-genai>=1.14.0", "google-genai>=1.14.0",
"googlesearch-python>=1.3.0", "googlesearch-python>=1.3.0",
+1 -4
View File
@@ -39,7 +39,4 @@ faiss-cpu
aiosqlite aiosqlite
py-cord>=2.6.1 py-cord>=2.6.1
slack-sdk slack-sdk
pydub pydub
sqlmodel
deprecated
sqlalchemy[asyncio]
Generated
+1575 -2249
View File
File diff suppressed because it is too large Load Diff