Compare commits

...

39 Commits

Author SHA1 Message Date
Soulter
7c3f5431ba chore: bump version to 4.0.0 2025-09-07 21:19:19 +08:00
anka
d98cf16a4c feat: 增加根据qq号/群号主动发送消息的封装, 增加事件构造 (#2629)
* feat: 增加根据qq号/群号主动发送消息的封装, 增加事件构造

* fix: 增加不支持平台提示, 修正文档字符串

* chore: lint

---------

Co-authored-by: Soulter <905617992@qq.com>
2025-09-07 17:04:29 +08:00
Soulter
2c3c3ae546 fix: 移除无用的调试日志以简化命令注册逻辑 2025-09-07 11:37:34 +08:00
Soulter
905eef48e3 feat: 增加 OneBot 服务 Token 为空时的安全提醒 (#2648) 2025-09-07 00:51:46 +08:00
RC-CHN
b31b520c7c feat: 支持管理 T2I 模版 (#2638)
* feat:添加t2i模板管理后端api,移除config.py中重复功能

* feat: 添加T2I模板管理功能前端,支持模板的创建、应用和重置

* refactor: 修复错误的保存逻辑,将t2i注册时打印路由信息部分移到基类实现

* remove:移除了路由注册时的打印

* chore: format code

* fix: update input variant from solo to outlined for better UI consistency

---------

Co-authored-by: Soulter <905617992@qq.com>
2025-09-07 00:14:28 +08:00
shangxue
17aee086a3 feat: 添加 Satori 协议适配器支持 (#2633)
* Create satori_adapter.py

* Add files via upload

* Update default.py

* Update manager.py

* Update platform_adapter_type.py

* Update PlatformPage.vue

* Add files via upload

* Update default.py

* Update manager.py

* Update platform_adapter_type.py

* Update PlatformPage.vue

* Add files via upload

* Update default.py

* chore: format code

* feat: 修复 Image, Audio 的解析,修复 message_str 的解析

* perf: 增强鲁棒性

* feat: 添加 Satori 配置项描述,移除适配器默认配置

---------

Co-authored-by: Soulter <905617992@qq.com>
2025-09-06 23:52:00 +08:00
Zhalslar
c1756e5767 fix: 修复组件 type 属性为枚举值 (#2628)
当前components.py中每个组件的type属性都是直接字符串赋值,IDE会爆红。
修正为使用本就定义好的ComponentType枚举类
用时修正多个组件中当url为空时convert_to_base64检查路径导致的报错
2025-09-06 19:22:49 +08:00
anka
2920279c64 Fix: 修正 QQ 群成员昵称获取 (#2626)
* feat: 修正群昵称获取

* fix: 增加兜底机制
2025-09-06 19:16:57 +08:00
Soulter
1f0f985b01 docs: update readme
Updated README to improve clarity and organization. Changed section titles and removed unnecessary content.
2025-09-06 16:21:33 +08:00
Soulter
0762c81633 Update Discord link in README.md 2025-09-06 11:49:05 +08:00
Soulter
28ef301ccc docs: update readme 2025-09-06 11:48:37 +08:00
Soulter
26c6a2950f 📦 release: bump version to v4.0.0-beta.5 2025-09-05 17:42:38 +08:00
Soulter
5082876de3 fix: 修复 v4.0.0 版本下可能无法得到 LLM 的响应的问题
closes: #2622
2025-09-05 17:20:29 +08:00
Soulter
e50e7ad3d5 fix: ensure deep copy of config_data before posting 2025-09-05 16:45:34 +08:00
卢小辉
45a4a6b6da feat: 给添加 edge_tts 新增 rate, volume, pitch 参数 (#2625)
* 修复python执行器文件上传qq提示参数错误问题,修改策略为本地url

* 给edge_tts 添加3个默认参数,方便通过ui配置
2025-09-05 15:23:21 +08:00
Soulter
02918b7267 perf: 增加 abconf_data 缓存,优化性能 2025-09-04 23:48:33 +08:00
Soulter
6c662a36c1 fix: 适配 qwen3 的 thinking 类模型
fixes: #2631
2025-09-04 20:26:52 +08:00
Soulter
b78fe3822a perf: 完善对 rerank model 的可用性检测 2025-09-04 15:46:23 +08:00
Soulter
35eda37e83 Merge remote-tracking branch 'origin/releases/v3.5.27' 2025-09-04 15:30:15 +08:00
Soulter
176a8e7067 chore: add no_proxy config item 2025-09-04 15:23:58 +08:00
Soulter
61d4f1fd4b 📦 release: v3.5.27 2025-09-04 15:01:44 +08:00
Soulter
121b68995e chore: update changelog 2025-09-04 14:34:23 +08:00
Soulter
d11f1d8dae perf: enhance update checks to consider pre-release versions 2025-09-04 14:33:19 +08:00
Soulter
c0ef2b5064 📦 release: v3.5.27 2025-09-04 13:56:47 +08:00
Soulter
2a7308363e fix: 下载 WebUI 时,明确版本号 2025-09-04 13:54:16 +08:00
Soulter
dc0c556f96 ci: build docker image 时同时 build webui,并放入 image 中 2025-09-04 13:42:26 +08:00
Soulter
ba2ee1c0aa fix: 初次下载 webui 构建文件时下载指定版本而非 latest 2025-09-04 13:27:55 +08:00
Zhalslar
0f8b550d68 fix: aiocqhttp优先使用session_id发送消息 (#2623)
* fix: aiocqhttp优先使用session_id发送消息

当前aiocqhttp依赖raw_message来发送消息,raw_message为空时也无法有效回退到用group_id或user_id来发送,更符合逻辑的应该:优先使用session_id(group_id or user_id),raw_message兜底

* Update aiocqhttp_message_event.py

* fix: validate session_id as integer and improve send_message docstring

---------

Co-authored-by: Soulter <905617992@qq.com>
2025-09-04 11:34:40 +08:00
Soulter
ed1fc98821 Merge pull request #2621 from AstrBotDevs/fix/gemini-api-error-handle
Fix: 修复 e.message 为 None 时报错的问题和部分 lint error
2025-09-04 11:20:05 +08:00
Soulter
fa53b468fd fix: ensure function call name and args are not None before processing 2025-09-04 11:18:58 +08:00
Soulter
4e2533d320 feat: add pre-release check for Docker image tagging 2025-09-04 09:30:21 +08:00
Soulter
388ae49e55 fix: 修复 e.message 为 None 时报错的问题和一些 lint error 2025-09-03 22:25:18 +08:00
Soulter
f3f347dcba 📦 release: bump verstion to v4.0.0-beta.4 2025-09-03 13:29:20 +08:00
Soulter
655be3519c perf: 数据迁移完毕之后引导重启程序
closes: #2613
2025-09-03 13:21:56 +08:00
Soulter
06df2940af chore: change identifier description 2025-09-03 12:46:10 +08:00
Soulter
4149549e42 fix: KeyError arprompt 2025-09-03 12:45:34 +08:00
Soulter
da351991f8 📦 release: bump verstion to v4.0.0-beta.3 2025-09-03 01:01:48 +08:00
Soulter
3305152e50 fix: 修复当人格 ID 为中文时,不可保存的问题 2025-09-03 00:59:07 +08:00
Soulter
bea7bae674 fix: dict read 2025-09-03 00:56:41 +08:00
58 changed files with 5951 additions and 404 deletions

View File

@@ -27,6 +27,33 @@ 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
@@ -53,9 +80,9 @@ jobs:
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
push: true push: true
tags: | tags: |
${{ secrets.DOCKER_HUB_USERNAME }}/astrbot:latest ${{ steps.check-prerelease.outputs.is_prerelease == 'false' && format('{0}/astrbot:latest', secrets.DOCKER_HUB_USERNAME) || '' }}
${{ 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 }}
ghcr.io/soulter/astrbot:latest ${{ steps.check-prerelease.outputs.is_prerelease == 'false' && '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

View File

@@ -6,8 +6,6 @@
<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)
@@ -27,7 +25,7 @@ _✨ 易上手的多平台 LLM 聊天机器人及开发框架 ✨_
AstrBot 是一个开源的一站式 Agentic 聊天机器人平台及开发框架。 AstrBot 是一个开源的一站式 Agentic 聊天机器人平台及开发框架。
## 主要功能 ## 主要功能
1. **大模型对话**。支持接入多种大模型服务。支持多模态、工具调用、MCP、原生知识库、人设等功能。 1. **大模型对话**。支持接入多种大模型服务。支持多模态、工具调用、MCP、原生知识库、人设等功能。
2. **多消息平台支持**。支持接入 QQ、企业微信、微信公众号、飞书、Telegram、钉钉、Discord、KOOK 等平台。支持速率限制、白名单、百度内容审核。 2. **多消息平台支持**。支持接入 QQ、企业微信、微信公众号、飞书、Telegram、钉钉、Discord、KOOK 等平台。支持速率限制、白名单、百度内容审核。
@@ -35,7 +33,7 @@ AstrBot 是一个开源的一站式 Agentic 聊天机器人平台及开发框架
4. **插件扩展**。深度优化的插件机制,支持[开发插件](https://astrbot.app/dev/plugin.html)扩展功能,社区插件生态丰富。 4. **插件扩展**。深度优化的插件机制,支持[开发插件](https://astrbot.app/dev/plugin.html)扩展功能,社区插件生态丰富。
5. **WebUI**。可视化配置和管理机器人,功能齐全。 5. **WebUI**。可视化配置和管理机器人,功能齐全。
## ✨ 使用方式 ## 部署方式
#### Docker 部署 #### Docker 部署
@@ -79,9 +77,7 @@ AstrBot 已由雨云官方上架至云应用平台,可一键部署。
#### 手动部署 #### 手动部署
> 推荐使用 `uv`。 首先安装 uv
首先,安装 uv
```bash ```bash
pip install uv pip install uv
@@ -96,6 +92,26 @@ 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
- 开发者群753075035
- 开发者群备份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>
## ⚡ 消息平台支持情况 ## ⚡ 消息平台支持情况
| 平台 | 支持性 | | 平台 | 支持性 |
@@ -112,22 +128,18 @@ 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 API | ✔ | 文本生成 | 支持 DeepSeek、Gemini、Kimi、xAI 等兼容 OpenAI API 的服务 | | OpenAI | ✔ | 文本生成 | 支持任何兼容 OpenAI API 的服务 |
| Claude API | ✔ | 文本生成 | | | Anthropic | ✔ | 文本生成 | |
| Google Gemini API | ✔ | 文本生成 | | | Google Gemini | ✔ | 文本生成 | |
| 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 服务平台 | |
@@ -143,7 +155,6 @@ uv run main.py
| 阿里云百炼 TTS | ✔ | 文本转语音 | | | 阿里云百炼 TTS | ✔ | 文本转语音 | |
| Azure TTS | ✔ | 文本转语音 | Microsoft Azure TTS | | Azure TTS | ✔ | 文本转语音 | Microsoft Azure TTS |
## ❤️ 贡献 ## ❤️ 贡献
欢迎任何 Issues/Pull Requests只需要将你的更改提交到此项目 ) 欢迎任何 Issues/Pull Requests只需要将你的更改提交到此项目 )
@@ -162,38 +173,6 @@ 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
@@ -219,7 +198,8 @@ _✨ WebUI ✨_
</div> </div>
![10k-star-banner-credit-by-kevin](https://github.com/user-attachments/assets/c97fc5fb-20b9-4bc8-9998-c20b930ab097)
</details>
_私は、高性能ですから!_ _私は、高性能ですから!_

View File

@@ -37,7 +37,10 @@ async def check_dashboard(astrbot_root: Path) -> None:
): ):
click.echo("正在安装管理面板...") click.echo("正在安装管理面板...")
await download_dashboard( await download_dashboard(
path="data/dashboard.zip", extract_path=str(astrbot_root) path="data/dashboard.zip",
extract_path=str(astrbot_root),
version=f"v{VERSION}",
latest=False,
) )
click.echo("管理面板安装完成") click.echo("管理面板安装完成")
@@ -50,7 +53,10 @@ 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", extract_path=str(astrbot_root) path="data/dashboard.zip",
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}")
@@ -59,7 +65,10 @@ 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"), extract_path=str(astrbot_root) path=str(astrbot_root / "dashboard.zip"),
extract_path=str(astrbot_root),
version=f"v{VERSION}",
latest=False,
) )
click.echo("管理面板初始化完成") click.echo("管理面板初始化完成")
except Exception as e: except Exception as e:

View File

@@ -36,13 +36,21 @@ 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.sp.get( abconf_data = self._get_abconf_data()
"abconf_mapping", {}, scope="global", scope_id="global" self.abconf_data = abconf_data
)
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)
@@ -72,9 +80,7 @@ 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.sp.get( abconf_data = self._get_abconf_data()
"abconf_mapping", {}, scope="global", scope_id="global"
)
if isinstance(umo, MessageSession): if isinstance(umo, MessageSession):
umo = str(umo) umo = str(umo)
else: else:
@@ -115,6 +121,7 @@ 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 到默认配置文件。"""
@@ -147,9 +154,7 @@ class AstrBotConfigManager:
"""获取所有配置文件的元数据列表""" """获取所有配置文件的元数据列表"""
conf_list = [] conf_list = []
conf_list.append(DEFAULT_CONFIG_CONF_INFO) conf_list.append(DEFAULT_CONFIG_CONF_INFO)
abconf_mapping = self.sp.get( abconf_mapping = self._get_abconf_data()
"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
@@ -218,6 +223,7 @@ 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
@@ -263,6 +269,7 @@ 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

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.0.0-beta.2" 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")
# 默认配置 # 默认配置
@@ -103,6 +103,7 @@ 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": {
@@ -246,8 +247,49 @@ 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",
@@ -866,6 +908,9 @@ 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(本地加载)": {
@@ -1913,7 +1958,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": {
@@ -1926,7 +1971,7 @@ CONFIG_METADATA_3 = {
}, },
"provider_settings.max_agent_step": { "provider_settings.max_agent_step": {
"description": "工具调用轮数上限", "description": "工具调用轮数上限",
"type": "bool", "type": "int",
}, },
"provider_settings.streaming_response": { "provider_settings.streaming_response": {
"description": "流式回复", "description": "流式回复",
@@ -2288,7 +2333,13 @@ 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": "控制台日志级别",
@@ -2321,6 +2372,11 @@ CONFIG_METADATA_3_SYSTEM = {
"type": "string", "type": "string",
"hint": "启用后,会以添加环境变量的方式设置代理。格式为 `http://ip:port`", "hint": "启用后,会以添加环境变量的方式设置代理。格式为 `http://ip:port`",
}, },
"no_proxy": {
"description": "直连地址列表",
"type": "list",
"items": {"type": "string"},
},
}, },
} }
}, },

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(Enum): class ComponentType(str, 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,8 +128,9 @@ 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, **_):
@@ -137,7 +138,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] = ""
@@ -164,19 +165,24 @@ 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 self.file and self.file.startswith("file:///"): if not self.file:
file_path = self.file[8:] raise Exception(f"not a valid file: {self.file}")
return file_path if self.file.startswith("file:///"):
elif self.file and self.file.startswith("http"): return self.file[8:]
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 and self.file.startswith("base64://"): elif 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")
@@ -185,8 +191,7 @@ 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):
file_path = self.file return os.path.abspath(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}")
@@ -197,12 +202,14 @@ class Record(BaseMessageComponent):
str: 语音的 base64 编码,不以 base64:// 或者 data:image/jpeg;base64, 开头。 str: 语音的 base64 编码,不以 base64:// 或者 data:image/jpeg;base64, 开头。
""" """
# convert to base64 # convert to base64
if self.file and self.file.startswith("file:///"): if not self.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 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)
bs64_data = file_to_base64(file_path) bs64_data = file_to_base64(file_path)
elif self.file and self.file.startswith("base64://"): elif 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)
@@ -236,7 +243,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
@@ -322,7 +329,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] = ""
@@ -344,28 +351,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, **_):
@@ -373,7 +380,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] = ""
@@ -384,7 +391,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
@@ -393,7 +400,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] = ""
@@ -404,7 +411,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] = ""
@@ -421,7 +428,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
@@ -464,14 +471,15 @@ class Image(BaseMessageComponent):
Returns: Returns:
str: 图片的本地路径,以绝对路径表示。 str: 图片的本地路径,以绝对路径表示。
""" """
url = self.url if self.url else self.file url = self.url or self.file
if url and url.startswith("file:///"): if not url:
image_file_path = url[8:] raise ValueError("No valid file or URL provided")
return image_file_path if url.startswith("file:///"):
elif url and url.startswith("http"): return url[8:]
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 and url.startswith("base64://"): elif 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")
@@ -480,8 +488,7 @@ 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):
image_file_path = url return os.path.abspath(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}")
@@ -492,13 +499,15 @@ 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 if self.url else self.file url = self.url or self.file
if url and url.startswith("file:///"): if not url:
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 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)
bs64_data = file_to_base64(image_file_path) bs64_data = file_to_base64(image_file_path)
elif url and url.startswith("base64://"): elif 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)
@@ -532,7 +541,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"]] = []
@@ -558,7 +567,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, **_):
@@ -566,7 +575,7 @@ class RedBag(BaseMessageComponent):
class Poke(BaseMessageComponent): class Poke(BaseMessageComponent):
type: str = "" type: str = ComponentType.Poke
id: T.Optional[int] = 0 id: T.Optional[int] = 0
qq: T.Optional[int] = 0 qq: T.Optional[int] = 0
@@ -576,7 +585,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, **_):
@@ -586,7 +595,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号
@@ -638,7 +647,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], **_):
@@ -664,7 +673,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
@@ -673,7 +682,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
@@ -684,7 +693,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
@@ -703,7 +712,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, **_):
@@ -711,7 +720,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):
@@ -723,7 +732,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
@@ -853,7 +862,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] = ""

View File

@@ -299,7 +299,9 @@ 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", 10) self.max_step: int = settings.get("max_agent_step", 30)
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:
@@ -434,7 +436,9 @@ 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(f"用户设置提供商 {provider} 不支持工具使用,清空工具列表。") logger.debug(
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:

View File

@@ -36,6 +36,7 @@ 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"
@@ -247,7 +248,10 @@ 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, return_url=True, use_network=self.t2i_use_network plain_str,
return_url=True,
use_network=self.t2i_use_network,
template_name=self.t2i_active_template,
) )
except BaseException: except BaseException:
logger.error("文本转图片失败,使用文本发送。") logger.error("文本转图片失败,使用文本发送。")

View File

@@ -85,6 +85,8 @@ 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库 中安装依赖库。"

View File

@@ -67,12 +67,19 @@ class AiocqhttpMessageEvent(AstrMessageEvent):
session_id: str, session_id: str,
messages: list[dict], messages: list[dict],
): ):
if event: # session_id 必须是纯数字字符串
await bot.send(event=event, message=messages) session_id = int(session_id) if session_id.isdigit() else None
elif is_group:
if is_group and isinstance(session_id, int):
await bot.send_group_msg(group_id=session_id, message=messages) await bot.send_group_msg(group_id=session_id, message=messages)
else: elif not is_group and isinstance(session_id, int):
await bot.send_private_msg(user_id=session_id, message=messages) await bot.send_private_msg(user_id=session_id, message=messages)
elif isinstance(event, Event): # 最后兜底
await bot.send(event=event, message=messages)
else:
raise ValueError(
f"无法发送消息:缺少有效的数字 session_id({session_id}) 或 event({event})"
)
@classmethod @classmethod
async def send_message( async def send_message(
@@ -83,7 +90,15 @@ 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(
@@ -122,18 +137,15 @@ class AiocqhttpMessageEvent(AstrMessageEvent):
async def send(self, message: MessageChain): async def send(self, message: MessageChain):
"""发送消息""" """发送消息"""
event = self.message_obj.raw_message event = getattr(self.message_obj, "raw_message", None)
assert isinstance(event, Event), "Event must be an instance of aiocqhttp.Event"
is_group = False is_group = bool(self.get_group_id())
if self.get_group_id(): session_id = self.get_group_id() if is_group else self.get_sender_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,
) )

View File

@@ -308,13 +308,20 @@ class AiocqhttpAdapter(Platform):
continue continue
at_info = await self.bot.call_action( at_info = await self.bot.call_action(
action="get_stranger_info", action="get_group_member_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("nick", "") or at_info.get( nickname = at_info.get("card", "")
"nickname", "" if 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(

View File

@@ -0,0 +1,482 @@
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()

View File

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

View File

@@ -183,7 +183,6 @@ 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.

View File

@@ -4,9 +4,11 @@ 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 from typing import List, Dict, Type, Any
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,
) )
@@ -30,11 +32,11 @@ class ProviderMetaData:
desc: str = "" desc: str = ""
"""提供商适配器描述.""" """提供商适配器描述."""
provider_type: ProviderType = ProviderType.CHAT_COMPLETION provider_type: ProviderType = ProviderType.CHAT_COMPLETION
cls_type: Type = None cls_type: Type | None = None
default_config_tmpl: dict = None default_config_tmpl: dict | None = None
"""平台的默认配置模板""" """平台的默认配置模板"""
provider_display_name: str = None provider_display_name: str | None = None
"""显示在 WebUI 配置页中的提供商名称,如空则是 type""" """显示在 WebUI 配置页中的提供商名称,如空则是 type"""
@@ -58,7 +60,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 content: str | None = 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"
@@ -205,17 +207,17 @@ class ProviderRequest:
class LLMResponse: class LLMResponse:
role: str role: str
"""角色, assistant, tool, err""" """角色, assistant, tool, err"""
result_chain: MessageChain = None result_chain: MessageChain | None = 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 = None raw_completion: ChatCompletion | GenerateContentResponse | Message | None = None
_new_record: Dict[str, any] = None _new_record: Dict[str, Any] | None = None
_completion_text: str = "" _completion_text: str = ""
@@ -226,12 +228,12 @@ class LLMResponse:
self, self,
role: str, role: str,
completion_text: str = "", completion_text: str = "",
result_chain: MessageChain = None, result_chain: MessageChain | None = None,
tools_call_args: List[Dict[str, any]] = None, tools_call_args: List[Dict[str, Any]] | None = None,
tools_call_name: List[str] = None, tools_call_name: List[str] | None = None,
tools_call_ids: List[str] = None, tools_call_ids: List[str] | None = None,
raw_completion: ChatCompletion = None, raw_completion: ChatCompletion | None = None,
_new_record: Dict[str, any] = None, _new_record: Dict[str, Any] | None = None,
is_chunk: bool = False, is_chunk: bool = False,
): ):
"""初始化 LLMResponse """初始化 LLMResponse

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 FuncCall from astrbot.core.provider.func_tool_manager import ToolSet
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 None self.chosen_api_key: str = self.api_keys[0] if len(self.api_keys) > 0 else ""
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,6 +96,9 @@ 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:
@@ -119,7 +122,7 @@ class ProviderGoogleGenAI(Provider):
async def _prepare_query_config( async def _prepare_query_config(
self, self,
payloads: dict, payloads: dict,
tools: Optional[FuncCall] = None, tools: Optional[ToolSet] = 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,
@@ -321,11 +324,15 @@ class ProviderGoogleGenAI(Provider):
@staticmethod @staticmethod
def _process_content_parts( def _process_content_parts(
result: types.GenerateContentResponse, llm_response: LLMResponse candidate: types.Candidate, llm_response: LLMResponse
) -> MessageChain: ) -> MessageChain:
"""处理内容部分并构建消息链""" """处理内容部分并构建消息链"""
finish_reason = result.candidates[0].finish_reason if not candidate.content:
result_parts: Optional[types.Part] = result.candidates[0].content.parts logger.warning(f"收到的 candidate.content 为空: {candidate}")
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 平台的安全检查")
@@ -343,22 +350,28 @@ class ProviderGoogleGenAI(Provider):
raise Exception("模型生成内容违反 Gemini 平台政策") raise Exception("模型生成内容违反 Gemini 平台政策")
if not result_parts: if not result_parts:
logger.debug(result.candidates) logger.warning(f"收到的 candidate.content.parts 为空: {candidate}")
raise Exception("API 返回的内容为空。") raise Exception("API 返回的 candidate.content.parts 为空。")
chain = [] chain = []
part: types.Part part: types.Part
# 暂时这样Fallback # 暂时这样Fallback
if all( if all(
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/")
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 part.function_call: elif (
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)
@@ -366,11 +379,16 @@ 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 part.inline_data and part.inline_data.mime_type.startswith("image/"): elif (
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: FuncCall) -> LLMResponse: async def _query(self, payloads: dict, tools: ToolSet | None) -> 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"),
@@ -396,6 +414,10 @@ 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")
@@ -408,6 +430,8 @@ 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已自动去除(影响人格设置)"
@@ -432,11 +456,13 @@ 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(result, llm_response) llm_response.result_chain = self._process_content_parts(
result.candidates[0], llm_response
)
return llm_response return llm_response
async def _query_stream( async def _query_stream(
self, payloads: dict, tools: FuncCall self, payloads: dict, tools: ToolSet | None
) -> AsyncGenerator[LLMResponse, None]: ) -> AsyncGenerator[LLMResponse, None]:
"""流式请求 Gemini API""" """流式请求 Gemini API"""
system_instruction = next( system_instruction = next(
@@ -459,6 +485,8 @@ 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已自动去除(影响人格设置)"
@@ -478,13 +506,20 @@ 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, llm_response chunk.candidates[0], llm_response
) )
yield llm_response yield llm_response
return return
@@ -500,7 +535,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, final_response chunk.candidates[0], final_response
) )
break break
@@ -566,6 +601,8 @@ class ProviderGoogleGenAI(Provider):
continue continue
break break
raise Exception("请求失败。")
async def text_chat_stream( async def text_chat_stream(
self, self,
prompt, prompt,
@@ -621,7 +658,9 @@ class ProviderGoogleGenAI(Provider):
return [ return [
m.name.replace("models/", "") m.name.replace("models/", "")
for m in models for m in models
if "generateContent" in m.supported_actions if 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}")
@@ -636,7 +675,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): async def assemble_context(self, text: str, image_urls: list[str] | None = None):
""" """
组装上下文。 组装上下文。
""" """

View File

@@ -100,9 +100,9 @@ class ProviderOpenAIOfficial(Provider):
del payloads[key] del payloads[key]
model = payloads.get("model", "") model = payloads.get("model", "")
# 针对 qwen3 模型的特殊处理:非流式调用必须设置 enable_thinking=false # 针对 qwen3 非 thinking 模型的特殊处理:非流式调用必须设置 enable_thinking=false
if "qwen3" in model.lower(): if "qwen3" in model.lower() and "thinking" not in model.lower():
extra_body["enable_thinking"] = False extra_body["enable_thinking"] = False
# 针对 deepseek 模型的特殊处理deepseek-reasoner调用必须移除 tools ,否则将被切换至 deepseek-chat # 针对 deepseek 模型的特殊处理deepseek-reasoner调用必须移除 tools ,否则将被切换至 deepseek-chat
elif model == "deepseek-reasoner" and "tools" in payloads: elif model == "deepseek-reasoner" and "tools" in payloads:
del payloads["tools"] del payloads["tools"]

View File

@@ -27,14 +27,16 @@ 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__
@staticmethod async def text_to_image(self, text: str, return_url=True) -> str:
async def text_to_image(text: str, return_url=True) -> str:
"""将文本转换为图片""" """将文本转换为图片"""
return await html_renderer.render_t2i(text, return_url=return_url) return await html_renderer.render_t2i(
text,
return_url=return_url,
template_name=self.context._config.get("t2i_active_template"),
)
@staticmethod
async def html_render( async def html_render(
tmpl: str, data: dict, return_url=True, options: dict | None = None self, 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(

View File

@@ -18,6 +18,7 @@ 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
@@ -31,6 +32,7 @@ class PlatformAdapterType(enum.Flag):
| KOOK | KOOK
| VOCECHAT | VOCECHAT
| WEIXIN_OFFICIAL_ACCOUNT | WEIXIN_OFFICIAL_ACCOUNT
| SATORI
) )
@@ -47,6 +49,7 @@ 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,
} }

View File

@@ -1,15 +1,37 @@
"""
插件开发工具集
封装了许多常用的操作,方便插件开发者使用
说明:
主动发送消息: 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 from astrbot.api.platform import MessageMember, AstrBotMessage, MessageType
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:
""" """
@@ -49,42 +71,76 @@ 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,
raw_message: object, message_id: str = "",
group_id: str = "", raw_message: object = None,
): group_id: str = ""
) -> AstrBotMessage:
""" """
创建一个AstrBot消息对象 创建一个AstrBot消息对象
Args: Args:
type (str): 消息类型 type (str): 消息类型, 例如 "GroupMessage" "FriendMessage" "OtherMessage"
self_id (str): 机器人自身ID self_id (str): 机器人自身ID
session_id (str): 会话ID(通常为用户ID)(QQ号, 群号等) session_id (str): 会话ID(通常为用户ID)(QQ号, 群号等)
message_id (str): 消息ID sender (MessageMember): 发送者信息, 例如 MessageMember(user_id="123456", nickname="昵称")
sender (MessageMember): 发送者信息 message (List[BaseMessageComponent]): 消息组件列表, 也就是消息链, 这个不会发给 llm, 但是会经过其他处理
message (List[BaseMessageComponent]): 消息组件列表 message_str (str): 消息字符串, 也就是纯文本消息, 也就是发送给 llm 的消息, 与消息链一致
message_str (str): 消息字符串
raw_message (object): 原始消息对象 message_id (str): 消息ID, 构造消息时可以随意填写也可不填
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 = type abm.type = MessageType(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
@@ -93,13 +149,38 @@ class StarTools:
abm.group_id = group_id abm.group_id = group_id
return abm return abm
# todo: 添加构造事件的方法 @classmethod
# async def create_event( async def create_event(
# self, platform: str, umo: str, sender_id: str, session_id: str cls, abm: AstrBotMessage, platform: str = "aiocqhttp", is_wake: bool = True
# ):
# platform = self._context.get_platform(platform)
# todo: 添加找到对应平台并提交对应事件的方法 ) -> None:
"""
创建并提交事件到指定平台
当有需要创建一个事件, 触发某些处理流程时, 使用该方法
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:
@@ -110,6 +191,8 @@ 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
@@ -120,6 +203,8 @@ 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
@@ -135,6 +220,8 @@ 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
@@ -146,6 +233,8 @@ 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
@@ -169,8 +258,11 @@ class StarTools:
- 创建目录失败权限不足或其他IO错误 - 创建目录失败权限不足或其他IO错误
""" """
if not plugin_name: if not plugin_name:
frame = inspect.currentframe().f_back frame = inspect.currentframe()
module = inspect.getmodule(frame) module = None
if frame:
frame = frame.f_back
module = inspect.getmodule(frame)
if not module: if not module:
raise RuntimeError("无法获取调用者模块信息") raise RuntimeError("无法获取调用者模块信息")
@@ -182,6 +274,9 @@ 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:

View File

@@ -56,9 +56,7 @@ 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 = [ args = [f'"{arg}"' if " " in arg else arg for arg in sys.argv[1:]]
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)
@@ -68,9 +66,13 @@ class AstrBotUpdator(RepoZipUpdator):
logger.error(f"重启失败({py}, {e}),请尝试手动重启。") logger.error(f"重启失败({py}, {e}),请尝试手动重启。")
raise e raise e
async def check_update(self, url: str, current_version: str) -> ReleaseInfo: async def check_update(
self, url: str, current_version: str, consider_prerelease: bool = True
) -> ReleaseInfo:
"""检查更新""" """检查更新"""
return await super().check_update(self.ASTRBOT_RELEASE_API, VERSION) return await super().check_update(
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)

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("准备下载最新发行版本的 AstrBot WebUI") logger.info(f"准备下载 {version} 发行版本的 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:

View File

@@ -1,6 +1,5 @@
import aiohttp import aiohttp
import asyncio import asyncio
import os
import ssl import ssl
import certifi import certifi
import logging import logging
@@ -8,10 +7,9 @@ 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.astrbot_path import get_astrbot_data_path from astrbot.core.utils.t2i.template_manager import TemplateManager
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")
@@ -23,26 +21,17 @@ 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) -> str: async def get_template(self, name: str = "base") -> 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 端点列表。"""
@@ -124,11 +113,15 @@ 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(self, text: str, return_url: bool = False) -> str: async def render(
self, text: str, return_url: bool = False, template_name: str | None = "base"
) -> str:
""" """
返回图像的文件路径 返回图像的文件路径
""" """
tmpl_str = await self.get_template() if not template_name:
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

View File

@@ -34,12 +34,18 @@ class HtmlRenderer:
) )
async def render_t2i( async def render_t2i(
self, text: str, use_network: bool = True, return_url: bool = False self,
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(text, return_url=return_url) return await self.network_strategy.render(
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."

View File

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

View File

@@ -0,0 +1,247 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8"/>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.10/dist/katex.min.css" integrity="sha384-wcIxkf4k558AjM3Yz3BBFQUbk/zgIYC2R0QpeeYb+TwlBVMrlgLqwRjRtGZiK7ww" crossorigin="anonymous">
<link rel="stylesheet" href="/path/to/styles/default.min.css">
<script src="/path/to/highlight.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>
</head>
<body>
<div style="background-color: #3276dc; color: #fff; font-size: 64px; ">
<span style="font-weight: bold; margin-left: 16px"># AstrBot</span>
<span>{{ version }}</span>
</div>
<article style="margin-top: 32px" id="content"></article>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script>
document.getElementById('content').innerHTML = marked.parse(`{{ text | safe}}`);
</script>
</body>
</html>
<style>
#content {
min-width: 200px;
max-width: 85%;
margin: 0 auto;
padding: 2rem 1em 1em;
}
body {
word-break: break-word;
line-height: 1.75;
font-weight: 400;
font-size: 32px;
margin: 0;
padding: 0;
overflow-x: hidden;
color: #333;
font-family: -apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif,Apple Color Emoji,Segoe UI Emoji;
}
h1, h2, h3, h4, h5, h6 {
line-height: 1.5;
margin-top: 35px;
margin-bottom: 10px;
padding-bottom: 5px;
}
h1:first-child, h2:first-child, h3:first-child, h4:first-child, h5:first-child, h6:first-child {
margin-top: -1.5rem;
margin-bottom: 1rem;
}
h1::before, h2::before, h3::before, h4::before, h5::before, h6::before {
content: "#";
display: inline-block;
color: #3eaf7c;
padding-right: 0.23em;
}
h1 {
position: relative;
font-size: 2.5rem;
margin-bottom: 5px;
}
h1::before {
font-size: 2.5rem;
}
h2 {
padding-bottom: 0.5rem;
font-size: 2.2rem;
border-bottom: 1px solid #ececec;
}
h3 {
font-size: 1.5rem;
padding-bottom: 0;
}
h4 {
font-size: 1.25rem;
}
h5 {
font-size: 1rem;
}
h6 {
margin-top: 5px;
}
p {
line-height: inherit;
margin-top: 22px;
margin-bottom: 22px;
}
strong {
color: #3eaf7c;
}
img {
max-width: 100%;
border-radius: 2px;
display: block;
margin: auto;
border: 3px solid rgba(62, 175, 124, 0.2);
}
hr {
border-top: 1px solid #3eaf7c;
border-bottom: none;
border-left: none;
border-right: none;
margin-top: 32px;
margin-bottom: 32px;
}
code {
font-family: Menlo, Monaco, Consolas, "Courier New", monospace;
word-break: break-word;
overflow-x: auto;
padding: 0.2rem 0.5rem;
margin: 0;
color: #3eaf7c;
font-size: 0.85em;
background-color: rgba(27, 31, 35, 0.05);
border-radius: 3px;
}
pre {
font-family: Menlo, Monaco, Consolas, "Courier New", monospace;
overflow: auto;
position: relative;
line-height: 1.75;
border-radius: 6px;
border: 2px solid #3eaf7c;
}
pre > code {
font-size: 12px;
padding: 15px 12px;
margin: 0;
word-break: normal;
display: block;
overflow-x: auto;
color: #333;
background: #f8f8f8;
}
a {
font-weight: 500;
text-decoration: none;
color: #3eaf7c;
}
a:hover, a:active {
border-bottom: 1.5px solid #3eaf7c;
}
a:before {
content: "⇲";
}
table {
display: inline-block !important;
font-size: 12px;
width: auto;
max-width: 100%;
overflow: auto;
border: solid 1px #3eaf7c;
}
thead {
background: #3eaf7c;
color: #fff;
text-align: left;
}
tr:nth-child(2n) {
background-color: rgba(62, 175, 124, 0.2);
}
th, td {
padding: 12px 7px;
line-height: 24px;
}
td {
min-width: 120px;
}
blockquote {
color: #666;
padding: 1px 23px;
margin: 22px 0;
border-left: 0.5rem solid rgba(62, 175, 124, 0.6);
border-color: #42b983;
background-color: #f8f8f8;
}
blockquote::after {
display: block;
content: "";
}
blockquote > p {
margin: 10px 0;
}
details {
border: none;
outline: none;
border-left: 4px solid #3eaf7c;
padding-left: 10px;
margin-left: 4px;
}
details summary {
cursor: pointer;
border: none;
outline: none;
background: white;
margin: 0px -17px;
}
details summary::-webkit-details-marker {
color: #3eaf7c;
}
ol, ul {
padding-left: 28px;
}
ol li, ul li {
margin-bottom: 0;
list-style: inherit;
}
ol li .task-list-item, ul li .task-list-item {
list-style: none;
}
ol li .task-list-item ul, ul li .task-list-item ul, ol li .task-list-item ol, ul li .task-list-item ol {
margin-top: 0;
}
ol ul, ul ul, ol ol, ul ol {
margin-top: 3px;
}
ol li {
padding-left: 6px;
}
ol li::marker {
color: #3eaf7c;
}
ul li {
list-style: none;
}
ul li:before {
content: "•";
margin-right: 4px;
color: #3eaf7c;
}
@media (max-width: 720px) {
h1 {
font-size: 24px;
}
h2 {
font-size: 20px;
}
h3 {
font-size: 18px;
}
}
</style>

View File

@@ -0,0 +1,95 @@
# astrbot/core/utils/t2i/template_manager.py
import os
import shutil
from astrbot.core.utils.astrbot_path import get_astrbot_path
class TemplateManager:
"""
负责管理 t2i HTML 模板的 CRUD 和重置操作。
"""
def __init__(self):
# 修正路径拼接,加入缺失的 'astrbot' 目录
self.template_dir = os.path.join(
get_astrbot_path(), "astrbot", "core", "utils", "t2i", "template"
)
self.backup_template_path = os.path.join(
self.template_dir, "default_template.html.bak"
)
# 确保模板目录存在
os.makedirs(self.template_dir, exist_ok=True)
# 检查模板目录中是否有 .html 文件
html_files = [f for f in os.listdir(self.template_dir) if f.endswith(".html")]
if not html_files and os.path.exists(self.backup_template_path):
self.reset_default_template()
def _get_template_path(self, name: str) -> str:
"""获取模板的完整路径,防止路径遍历漏洞。"""
if ".." in name or "/" in name or "\\" in name:
raise ValueError("模板名称包含非法字符。")
return os.path.join(self.template_dir, f"{name}.html")
def list_templates(self) -> list[dict]:
"""列出所有可用的模板。"""
templates = []
for filename in os.listdir(self.template_dir):
if filename.endswith(".html"):
templates.append(
{
"name": os.path.splitext(filename)[0],
"is_default": filename == "base.html",
}
)
return templates
def get_template(self, name: str) -> str:
"""获取指定模板的内容。"""
path = self._get_template_path(name)
if not os.path.exists(path):
raise FileNotFoundError("模板不存在。")
with open(path, "r", encoding="utf-8") as f:
return f.read()
def create_template(self, name: str, content: str):
"""创建一个新的模板文件。"""
path = self._get_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_template_path(name)
if not os.path.exists(path):
raise FileNotFoundError("模板不存在。")
with open(path, "w", encoding="utf-8") as f:
f.write(content)
def delete_template(self, name: str):
"""删除一个模板文件。"""
if name == "base":
raise ValueError("不能删除默认的 base 模板。")
path = self._get_template_path(name)
if not os.path.exists(path):
raise FileNotFoundError("模板不存在。")
os.remove(path)
def backup_default_template_if_not_exist(self):
"""如果备份不存在,则创建默认模板的备份。"""
default_path = os.path.join(self.template_dir, "base.html")
if not os.path.exists(self.backup_template_path) and os.path.exists(
default_path
):
shutil.copyfile(default_path, self.backup_template_path)
def reset_default_template(self):
"""重置默认模板。"""
if not os.path.exists(self.backup_template_path):
raise FileNotFoundError("默认模板的备份文件不存在,无法重置。")
default_path = os.path.join(self.template_dir, "base.html")
shutil.copyfile(self.backup_template_path, default_path)

View File

@@ -107,16 +107,38 @@ class RepoZipUpdator:
"""Semver 版本比较""" """Semver 版本比较"""
return VersionComparator.compare_version(v1, v2) return VersionComparator.compare_version(v1, v2)
async def check_update(self, url: str, current_version: str) -> ReleaseInfo | None: async def check_update(
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=update_data[0]["published_at"], published_at=sel_release_data["published_at"],
body=update_data[0]["body"], body=f"{tag_name}\n\n{sel_release_data['body']}",
) )
async def download_from_repo_url(self, target_path: str, repo_url: str, proxy=""): async def download_from_repo_url(self, target_path: str, repo_url: str, proxy=""):

View File

@@ -1,6 +1,7 @@
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
@@ -16,10 +17,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, html_renderer from astrbot.core import logger
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):
@@ -155,6 +156,7 @@ 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)
@@ -185,56 +187,9 @@ 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()
@@ -481,6 +436,19 @@ 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}"
@@ -752,6 +720,13 @@ 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

View File

@@ -1,3 +1,4 @@
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
@@ -15,8 +16,24 @@ class Route:
self.config = context.config self.config = context.config
def register_routes(self): def register_routes(self):
for route, (method, func) in self.routes.items(): def _add_rule(path, method, func):
self.app.add_url_rule(f"/api{route}", view_func=func, methods=[method]) # 统一添加 /api 前缀
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

View File

@@ -0,0 +1,232 @@
# 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.manager.backup_default_template_if_not_exist()
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
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:
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)
return jsonify(
asdict(
Response().ok(
data={"name": name}, message="Template updated 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 delete_template(self, name: str):
"""删除一个T2I模板"""
try:
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

View File

@@ -57,7 +57,7 @@ class UpdateRoute(Route):
.__dict__ .__dict__
) )
else: else:
ret = await self.astrbot_updator.check_update(None, None) ret = await self.astrbot_updator.check_update(None, None, False)
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,9 +100,7 @@ class UpdateRoute(Route):
) )
try: try:
await download_dashboard( await download_dashboard(latest=latest, version=version, proxy=proxy)
latest=latest, version=version, proxy=proxy
)
except Exception as e: except Exception as e:
logger.error(f"下载管理面板文件失败: {e}") logger.error(f"下载管理面板文件失败: {e}")
@@ -133,7 +131,7 @@ class UpdateRoute(Route):
async def update_dashboard(self): async def update_dashboard(self):
try: try:
try: try:
await download_dashboard() await download_dashboard(version=f"v{VERSION}", latest=False)
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__

View File

@@ -18,6 +18,7 @@ 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
@@ -60,9 +61,8 @@ 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.persona_route = PersonaRoute(self.context, db, core_lifecycle)
self.context, db, core_lifecycle self.t2i_route = T2iRoute(self.context, core_lifecycle)
)
self.app.add_url_rule( self.app.add_url_rule(
"/api/plug/<path:subpath>", "/api/plug/<path:subpath>",

5
changelogs/v3.5.27.md Normal file
View File

@@ -0,0 +1,5 @@
# What's Changed
1. 修复:构建 docker 镜像时同时构建 webui并放入镜像中。
2. 修复:下载 WebUI 文件时,明确版本号,以防止 latest 不一致导致下载的 WebUI 文件版本号与实际所需不符的问题。
3. 优化:优化版本检测,考虑预发布版本,移除 `更新到最新版本` 按钮

View File

@@ -1 +0,0 @@
# What's Changed

View File

@@ -1,3 +1,3 @@
# What's Changed # What's Changed
> 请仔细阅读:**这是 v4.0.0 的测试版本beta.1),功能尚未完全稳定和加入**。v4.0.0 被设计为向前兼容,如有任何插件兼容性问题或者其他异常请在 GitHub 提交 [Issue](https://github.com/AstrBotDevs/AstrBot/issues)。在测试版本期间,您可以无缝回退到旧版本的 AstrBot并且数据不受影响。新版本文档请[从此](https://docs-v4.astrbot.app/)访问,直到第一个 v4.0.0 稳定版本发布。 > 请仔细阅读:**这是 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 稳定版本发布。

View File

@@ -0,0 +1,8 @@
# 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. 数据迁移完毕之后引导重启程序

View File

@@ -0,0 +1,15 @@
# 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
changelogs/v4.0.0.md Normal file
View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

View File

@@ -12,7 +12,13 @@
<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>
{{ migrationResult?.message || t('features.migration.dialog.success') }} <p class="mb-4">{{ migrationResult?.message || t('features.migration.dialog.success') }}</p>
<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">
@@ -80,8 +86,11 @@
<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="primary" variant="elevated" @click="handleClose"> <v-btn color="grey" variant="text" @click="handleClose">
{{ t('core.common.confirm') }} {{ t('core.common.close') }}
</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>
@@ -96,6 +105,8 @@
</v-card-actions> </v-card-actions>
</v-card> </v-card>
</v-dialog> </v-dialog>
<WaitingForRestart ref="wfr"></WaitingForRestart>
</template> </template>
<script setup> <script setup>
@@ -103,6 +114,7 @@ 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()
@@ -114,6 +126,7 @@ 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
@@ -244,6 +257,15 @@ 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

View File

@@ -15,17 +15,59 @@
<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>
<div class="d-flex gap-2"> <v-spacer></v-spacer>
<v-btn <div class="d-flex align-center gap-2" style="width: 60%">
v-if="hasCustomTemplate" <v-text-field
v-if="isCreatingNew"
v-model="editingName"
label="输入新模板名称"
density="compact"
hide-details
variant="outlined" variant="outlined"
color="warning" class="flex-grow-1"
size="small" autofocus
@click="resetToDefault" :rules="[v => !!v || '名称不能为空']"
:loading="resetLoading" ></v-text-field>
<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-btn> <v-list-item v-bind="props" :title="item.raw.name">
<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
@@ -41,17 +83,49 @@
<!-- 左侧编辑器 --> <!-- 左侧编辑器 -->
<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">HTML 模板编辑器</v-toolbar-title> <v-toolbar-title class="text-subtitle-2">模板编辑器</v-toolbar-title>
<v-spacer></v-spacer> <v-spacer></v-spacer>
<v-btn <div class="d-flex align-center pa-1" style="border: 1px solid rgba(0,0,0,0.1); border-radius: 8px;">
variant="text" <v-btn
size="small" variant="text"
@click="saveTemplate" size="small"
:loading="saveLoading" @click="newTemplate"
color="primary" color="success"
> >
保存模板 <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
@@ -106,10 +180,11 @@
</v-btn> </v-btn>
<v-btn <v-btn
color="primary" color="primary"
@click="saveTemplate" @click="promptApplyAndClose"
:loading="saveLoading" :loading="saveLoading"
:disabled="isCreatingNew || !selectedTemplate"
> >
保存应用 保存应用当前编辑模板
</v-btn> </v-btn>
</v-col> </v-col>
</v-row> </v-row>
@@ -121,7 +196,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>
@@ -130,6 +205,37 @@
</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>
@@ -141,18 +247,30 @@ import axios from 'axios'
const { t } = useI18n() const { t } = useI18n()
// 响应式数据 // --- 响应式数据 ---
const dialog = ref(false) const dialog = ref(false)
const resetDialog = ref(false) const loading = 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 hasCustomTemplate = ref(false) const isCreatingNew = 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,
@@ -163,16 +281,13 @@ 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)
@@ -182,58 +297,128 @@ const previewContent = computed(() => {
} }
}) })
// 方法 // --- API 调用方法 ---
const loadTemplate = async () => {
const loadInitialData = async () => {
loading.value = true loading.value = true
try { try {
const response = await axios.get('/api/config/astrbot/t2i-template/get') const [listRes, activeRes] = await Promise.all([
if (response.data.status === 'ok') { axios.get('/api/t2i/templates'),
templateContent.value = response.data.data.template axios.get('/api/t2i/templates/active')
hasCustomTemplate.value = response.data.data.has_custom_template ])
if (listRes.data.status === 'ok') {
templates.value = listRes.data.data
} else { } else {
console.error('加载模板失败:', response.data.message) console.error('加载模板列表失败:', listRes.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 {
const response = await axios.post('/api/config/astrbot/t2i-template/save', { if (isCreatingNew.value) {
template: templateContent.value // --- 创建新模板 ---
}) if (!editingName.value) return
if (response.data.status === 'ok') { const response = await axios.post('/api/t2i/templates/create', {
hasCustomTemplate.value = true name: editingName.value,
closeDialog() content: templateContent.value
})
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 resetToDefault = () => { const setActiveTemplate = async (name) => {
resetDialog.value = true applyLoading.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 {
const response = await axios.delete('/api/config/astrbot/t2i-template/delete') await axios.post('/api/t2i/templates/reset_default')
if (response.data.status === 'ok') { resetDialog.value = false
hasCustomTemplate.value = false if (selectedTemplate.value === 'base') {
resetDialog.value = false await loadTemplateContent('base')
// 重新加载默认模板 }
await loadTemplate() if (activeTemplate.value !== 'base') {
} else { await setActiveTemplate('base')
console.error('重置模板失败:', response.data.message)
} }
} catch (error) { } catch (error) {
console.error('重置模板失败:', error) console.error('重置模板失败:', error)
@@ -242,15 +427,58 @@ 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(() => { setTimeout(() => previewLoading.value = false, 500)
previewLoading.value = false
}, 500)
}) })
} }
@@ -258,18 +486,29 @@ const closeDialog = () => {
dialog.value = false dialog.value = false
} }
// --- 监听器和生命周期 ---
watch(dialog, (newVal) => { watch(dialog, (newVal) => {
if (newVal && !templateContent.value) { if (newVal) {
loadTemplate() loadInitialData()
} 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>

View File

@@ -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": "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", "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",
"dockerTipLink": "watchtower", "dockerTipLink": "watchtower",
"dockerTipContinue": "to automatically monitor and pull.", "dockerTipContinue": "to automatically monitor and pull.",
"table": { "table": {

View File

@@ -11,6 +11,8 @@
"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"
} }
} }

View File

@@ -29,6 +29,11 @@
"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": {

View File

@@ -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": {

View File

@@ -11,6 +11,8 @@
"migratingSubtitle": "请耐心等待,迁移过程中请勿关闭此窗口", "migratingSubtitle": "请耐心等待,迁移过程中请勿关闭此窗口",
"migrationError": "迁移失败", "migrationError": "迁移失败",
"success": "迁移成功完成!", "success": "迁移成功完成!",
"completed": "迁移已完成" "completed": "迁移已完成",
"restartRecommended": "建议重启应用程序以使所有更改生效。",
"restartNow": "立即重启"
} }
} }

View File

@@ -29,6 +29,11 @@
"title": "ID 冲突警告", "title": "ID 冲突警告",
"message": "检测到 ID \"{id}\" 重复。请使用一个新的 ID。", "message": "检测到 ID \"{id}\" 重复。请使用一个新的 ID。",
"confirm": "好的" "confirm": "好的"
},
"securityWarning": {
"title": "安全提醒",
"aiocqhttpTokenMissing": "为了增强连接安全性,强烈建议您设置 ws_reverse_token。未设置 Token 可能导致安全风险。",
"learnMore": "了解更多"
} }
}, },
"messages": { "messages": {

View File

@@ -378,10 +378,6 @@ 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>

View File

@@ -18,6 +18,8 @@
</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"
@@ -481,7 +483,7 @@ export default {
if (!this.fetched) return; if (!this.fetched) return;
const postData = { const postData = {
config: this.config_data config: JSON.parse(JSON.stringify(this.config_data)),
}; };
if (this.isSystemConfig) { if (this.isSystemConfig) {

View File

@@ -383,8 +383,7 @@ export default {
messageType: 'success', messageType: 'success',
personaIdRules: [ personaIdRules: [
v => !!v || this.tm('validation.required'), v => !!v || this.tm('validation.required'),
v => (v && v.length >= 2) || this.tm('validation.minLength', { min: 2 }), v => (v && v.length >= 0) || 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'),

View File

@@ -168,6 +168,28 @@
</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>
@@ -234,17 +256,27 @@ 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;
}
} }
}, },
@@ -279,6 +311,8 @@ 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
} }
}, },
@@ -297,6 +331,7 @@ export default {
"slack": "https://astrbot.app/deploy/platform/slack.html", "slack": "https://astrbot.app/deploy/platform/slack.html",
"kook": "https://astrbot.app/deploy/platform/kook.html", "kook": "https://astrbot.app/deploy/platform/kook.html",
"vocechat": "https://astrbot.app/deploy/platform/vocechat.html", "vocechat": "https://astrbot.app/deploy/platform/vocechat.html",
"satori": "https://astrbot.app/deploy/platform/satori.html", // TODO
} }
return tutorial_map[platform_type] || "https://docs.astrbot.app"; return tutorial_map[platform_type] || "https://docs.astrbot.app";
}, },
@@ -350,24 +385,39 @@ export default {
newPlatform() { newPlatform() {
this.loading = true; this.loading = true;
if (this.updatingMode) { if (this.updatingMode) {
axios.post('/api/config/platform/update', { if (this.newSelectedPlatformConfig.type === 'aiocqhttp') {
id: this.newSelectedPlatformName, const token = this.newSelectedPlatformConfig.ws_reverse_token;
config: this.newSelectedPlatformConfig if (!token || token.trim() === '') {
}).then((res) => { this.showOneBotEmptyTokenWarning().then((continueWithWarning) => {
this.loading = false; if (continueWithWarning) {
this.showPlatformCfg = false; this.updatePlatform();
this.getConfig(); }
this.showSuccess(res.data.message || this.messages.updateSuccess); });
}).catch((err) => { return;
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);
@@ -379,6 +429,17 @@ 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;
@@ -406,6 +467,25 @@ 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) => {
@@ -532,4 +612,4 @@ export default {
font-weight: bold; font-weight: bold;
opacity: 0.3; opacity: 0.3;
} }
</style> </style>

View File

@@ -44,10 +44,10 @@ async def check_dashboard_files():
if v is not None: if v is not None:
# has file # has file
if v == f"v{VERSION}": if v == f"v{VERSION}":
logger.info("管理面板文件已是最新。") logger.info("WebUI 版本已是最新。")
else: else:
logger.warning( logger.warning(
"检测到管理面板有更新。可以使用 /dashboard_update 命令更新" f"检测到 WebUI 版本 ({v}) 与当前 AstrBot 版本 (v{VERSION}) 不符"
) )
return return
@@ -56,7 +56,7 @@ async def check_dashboard_files():
) )
try: try:
await download_dashboard() await download_dashboard(version=f"v{VERSION}", latest=False)
except Exception as e: except Exception as e:
logger.critical(f"下载管理面板文件失败: {e}") logger.critical(f"下载管理面板文件失败: {e}")
return return

View File

@@ -25,14 +25,18 @@ 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["group_message_max_cnt"]) max_cnt = int(cfg["provider_ltm_settings"]["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 = True if cfg["image_caption_provider_id"] else False image_caption = (
image_caption_prompt = cfg["image_caption_prompt"] True
image_caption_provider_id = cfg["image_caption_provider_id"] if cfg["provider_settings"]["default_image_caption_provider_id"]
active_reply = cfg["active_reply"] else False
)
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"]
@@ -88,7 +92,9 @@ 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 (event.get_group_id() and event.get_group_id() not in cfg["ar_whitelist"]) and (
event.get_group_id() and event.get_group_id() not in cfg["ar_whitelist"]
)
): ):
return False return False

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() await download_dashboard(version=f"v{VERSION}", latest=False)
yield event.plain_result("管理面板更新完成。") yield event.plain_result("管理面板更新完成。")
@filter.command("set") @filter.command("set")
@@ -1110,7 +1110,9 @@ 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(umo="uid", key="session_variables", default={}) session_var = await sp.session_get(
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 变量名。")
@@ -1176,9 +1178,7 @@ UID: {user_id} 此 ID 可用于设置管理员。
) )
return return
prompt = self.ltm.ar_prompt prompt = event.message_str
if not prompt:
prompt = event.message_str
yield event.request_llm( yield event.request_llm(
prompt=prompt, prompt=prompt,

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_s3_url)] chain = [File(name=file_name, file=file_path)]
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:

View File

@@ -1,6 +1,6 @@
[project] [project]
name = "AstrBot" name = "AstrBot"
version = "4.0.0-beta.2" 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",

3407
uv.lock generated Normal file

File diff suppressed because it is too large Load Diff