Compare commits

..

11 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
29 changed files with 2227 additions and 285 deletions

View File

@@ -6,8 +6,6 @@
<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>
[![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 聊天机器人平台及开发框架。
## 主要功能
## 主要功能
1. **大模型对话**。支持接入多种大模型服务。支持多模态、工具调用、MCP、原生知识库、人设等功能。
2. **多消息平台支持**。支持接入 QQ、企业微信、微信公众号、飞书、Telegram、钉钉、Discord、KOOK 等平台。支持速率限制、白名单、百度内容审核。
@@ -35,7 +33,7 @@ AstrBot 是一个开源的一站式 Agentic 聊天机器人平台及开发框架
4. **插件扩展**。深度优化的插件机制,支持[开发插件](https://astrbot.app/dev/plugin.html)扩展功能,社区插件生态丰富。
5. **WebUI**。可视化配置和管理机器人,功能齐全。
## ✨ 使用方式
## 部署方式
#### Docker 部署
@@ -79,9 +77,7 @@ AstrBot 已由雨云官方上架至云应用平台,可一键部署。
#### 手动部署
> 推荐使用 `uv`。
首先,安装 uv
首先安装 uv
```bash
pip install uv
@@ -96,6 +92,26 @@ uv run main.py
或者请参阅官方文档 [通过源码部署 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 | ✔ |
| [KOOK](https://github.com/wuyan1003/astrbot_plugin_kook_adapter) | ✔ |
| [VoceChat](https://github.com/HikariFroya/astrbot_plugin_vocechat) | ✔ |
| 微信对话开放平台 | 🚧 |
| WhatsApp | 🚧 |
| 小爱音响 | 🚧 |
## ⚡ 提供商支持情况
| 名称 | 支持性 | 类型 | 备注 |
| -------- | ------- | ------- | ------- |
| OpenAI API | ✔ | 文本生成 | 支持 DeepSeek、Gemini、Kimi、xAI 等兼容 OpenAI API 的服务 |
| Claude API | ✔ | 文本生成 | |
| Google Gemini API | ✔ | 文本生成 | |
| OpenAI | ✔ | 文本生成 | 支持任何兼容 OpenAI API 的服务 |
| Anthropic | ✔ | 文本生成 | |
| Google Gemini | ✔ | 文本生成 | |
| Dify | ✔ | LLMOps | |
| 阿里云百炼应用 | ✔ | LLMOps | |
| Ollama | ✔ | 模型加载器 | 本地部署 DeepSeek、Llama 等开源语言模型 |
| LM Studio | ✔ | 模型加载器 | 本地部署 DeepSeek、Llama 等开源语言模型 |
| LLMTuner | ✔ | 模型加载器 | 本地加载 lora 等微调模型 |
| [优云智算](https://www.compshare.cn/?ytag=GPU_YY-gh_astrbot&referral_code=FV7DcGowN4hB5UuXKgpE74) | ✔ | 模型 API 及算力服务平台 | |
| [302.AI](https://share.302.ai/rr1M3l) | ✔ | 模型 API 服务平台 | |
| 硅基流动 | ✔ | 模型 API 服务平台 | |
@@ -143,7 +155,6 @@ uv run main.py
| 阿里云百炼 TTS | ✔ | 文本转语音 | |
| Azure TTS | ✔ | 文本转语音 | Microsoft Azure TTS |
## ❤️ 贡献
欢迎任何 Issues/Pull Requests只需要将你的更改提交到此项目 )
@@ -162,38 +173,6 @@ pip install pre-commit
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
@@ -219,7 +198,8 @@ _✨ WebUI ✨_
</div>
![10k-star-banner-credit-by-kevin](https://github.com/user-attachments/assets/c97fc5fb-20b9-4bc8-9998-c20b930ab097)
</details>
_私は、高性能ですから!_

View File

@@ -6,7 +6,7 @@ import os
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
VERSION = "4.0.0-beta.5"
VERSION = "4.0.0"
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
# 默认配置
@@ -103,6 +103,7 @@ DEFAULT_CONFIG = {
"t2i_strategy": "remote",
"t2i_endpoint": "",
"t2i_use_file_service": False,
"t2i_active_template": "base",
"http_proxy": "",
"no_proxy": ["localhost", "127.0.0.1", "::1"],
"dashboard": {
@@ -246,8 +247,49 @@ CONFIG_METADATA_2 = {
"slack_webhook_port": 6197,
"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": {
"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": {
"description": "Slack Connection Mode",
"type": "string",
@@ -2293,6 +2335,12 @@ CONFIG_METADATA_3_SYSTEM = {
},
"_special": "t2i_template",
},
"t2i_active_template": {
"description": "当前应用的文转图渲染模板",
"type": "string",
"hint": "此处的值由文转图模板管理页面进行维护。",
"invisible": True,
},
"log_level": {
"description": "控制台日志级别",
"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
class ComponentType(Enum):
class ComponentType(str, Enum):
Plain = "Plain" # 纯文本消息
Face = "Face" # QQ表情
Record = "Record" # 语音
@@ -108,7 +108,7 @@ class BaseMessageComponent(BaseModel):
class Plain(BaseMessageComponent):
type: ComponentType = "Plain"
type = ComponentType.Plain
text: str
convert: T.Optional[bool] = True # 若为 False 则直接发送未转换 CQ 码的消息
@@ -128,8 +128,9 @@ class Plain(BaseMessageComponent):
async def to_dict(self):
return {"type": "text", "data": {"text": self.text}}
class Face(BaseMessageComponent):
type: ComponentType = "Face"
type = ComponentType.Face
id: int
def __init__(self, **_):
@@ -137,7 +138,7 @@ class Face(BaseMessageComponent):
class Record(BaseMessageComponent):
type: ComponentType = "Record"
type = ComponentType.Record
file: T.Optional[str] = ""
magic: T.Optional[bool] = False
url: T.Optional[str] = ""
@@ -164,19 +165,24 @@ class Record(BaseMessageComponent):
return Record(file=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:
"""将这个语音统一转换为本地文件路径。这个方法避免了手动判断语音数据类型,直接返回语音数据的本地路径(如果是网络 URL, 则会自动进行下载)。
Returns:
str: 语音的本地路径,以绝对路径表示。
"""
if self.file and self.file.startswith("file:///"):
file_path = self.file[8:]
return file_path
elif self.file and self.file.startswith("http"):
if not self.file:
raise Exception(f"not a valid file: {self.file}")
if self.file.startswith("file:///"):
return self.file[8:]
elif self.file.startswith("http"):
file_path = await download_image_by_url(self.file)
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://")
image_bytes = base64.b64decode(bs64_data)
temp_dir = os.path.join(get_astrbot_data_path(), "temp")
@@ -185,8 +191,7 @@ class Record(BaseMessageComponent):
f.write(image_bytes)
return os.path.abspath(file_path)
elif os.path.exists(self.file):
file_path = self.file
return os.path.abspath(file_path)
return os.path.abspath(self.file)
else:
raise Exception(f"not a valid file: {self.file}")
@@ -197,12 +202,14 @@ class Record(BaseMessageComponent):
str: 语音的 base64 编码,不以 base64:// 或者 data:image/jpeg;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:])
elif self.file and self.file.startswith("http"):
elif self.file.startswith("http"):
file_path = await download_image_by_url(self.file)
bs64_data = file_to_base64(file_path)
elif self.file and self.file.startswith("base64://"):
elif self.file.startswith("base64://"):
bs64_data = self.file
elif os.path.exists(self.file):
bs64_data = file_to_base64(self.file)
@@ -236,7 +243,7 @@ class Record(BaseMessageComponent):
class Video(BaseMessageComponent):
type: ComponentType = "Video"
type = ComponentType.Video
file: str
cover: T.Optional[str] = ""
c: T.Optional[int] = 2
@@ -322,7 +329,7 @@ class Video(BaseMessageComponent):
class At(BaseMessageComponent):
type: ComponentType = "At"
type = ComponentType.At
qq: T.Union[int, str] # 此处str为all时代表所有人
name: T.Optional[str] = ""
@@ -344,28 +351,28 @@ class AtAll(At):
class RPS(BaseMessageComponent): # TODO
type: ComponentType = "RPS"
type = ComponentType.RPS
def __init__(self, **_):
super().__init__(**_)
class Dice(BaseMessageComponent): # TODO
type: ComponentType = "Dice"
type = ComponentType.Dice
def __init__(self, **_):
super().__init__(**_)
class Shake(BaseMessageComponent): # TODO
type: ComponentType = "Shake"
type = ComponentType.Shake
def __init__(self, **_):
super().__init__(**_)
class Anonymous(BaseMessageComponent): # TODO
type: ComponentType = "Anonymous"
type = ComponentType.Anonymous
ignore: T.Optional[bool] = False
def __init__(self, **_):
@@ -373,7 +380,7 @@ class Anonymous(BaseMessageComponent): # TODO
class Share(BaseMessageComponent):
type: ComponentType = "Share"
type = ComponentType.Share
url: str
title: str
content: T.Optional[str] = ""
@@ -384,7 +391,7 @@ class Share(BaseMessageComponent):
class Contact(BaseMessageComponent): # TODO
type: ComponentType = "Contact"
type = ComponentType.Contact
_type: str # type 字段冲突
id: T.Optional[int] = 0
@@ -393,7 +400,7 @@ class Contact(BaseMessageComponent): # TODO
class Location(BaseMessageComponent): # TODO
type: ComponentType = "Location"
type = ComponentType.Location
lat: float
lon: float
title: T.Optional[str] = ""
@@ -404,7 +411,7 @@ class Location(BaseMessageComponent): # TODO
class Music(BaseMessageComponent):
type: ComponentType = "Music"
type = ComponentType.Music
_type: str
id: T.Optional[int] = 0
url: T.Optional[str] = ""
@@ -421,7 +428,7 @@ class Music(BaseMessageComponent):
class Image(BaseMessageComponent):
type: ComponentType = "Image"
type = ComponentType.Image
file: T.Optional[str] = ""
_type: T.Optional[str] = ""
subType: T.Optional[int] = 0
@@ -464,14 +471,15 @@ class Image(BaseMessageComponent):
Returns:
str: 图片的本地路径,以绝对路径表示。
"""
url = self.url if self.url else self.file
if url and url.startswith("file:///"):
image_file_path = url[8:]
return image_file_path
elif url and url.startswith("http"):
url = self.url or self.file
if not url:
raise ValueError("No valid file or URL provided")
if url.startswith("file:///"):
return url[8:]
elif url.startswith("http"):
image_file_path = await download_image_by_url(url)
return os.path.abspath(image_file_path)
elif url and url.startswith("base64://"):
elif url.startswith("base64://"):
bs64_data = url.removeprefix("base64://")
image_bytes = base64.b64decode(bs64_data)
temp_dir = os.path.join(get_astrbot_data_path(), "temp")
@@ -480,8 +488,7 @@ class Image(BaseMessageComponent):
f.write(image_bytes)
return os.path.abspath(image_file_path)
elif os.path.exists(url):
image_file_path = url
return os.path.abspath(image_file_path)
return os.path.abspath(url)
else:
raise Exception(f"not a valid file: {url}")
@@ -492,13 +499,15 @@ class Image(BaseMessageComponent):
str: 图片的 base64 编码,不以 base64:// 或者 data:image/jpeg;base64, 开头。
"""
# convert to base64
url = self.url if self.url else self.file
if url and url.startswith("file:///"):
url = self.url or self.file
if not url:
raise ValueError("No valid file or URL provided")
if url.startswith("file:///"):
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)
bs64_data = file_to_base64(image_file_path)
elif url and url.startswith("base64://"):
elif url.startswith("base64://"):
bs64_data = url
elif os.path.exists(url):
bs64_data = file_to_base64(url)
@@ -532,7 +541,7 @@ class Image(BaseMessageComponent):
class Reply(BaseMessageComponent):
type: ComponentType = "Reply"
type = ComponentType.Reply
id: T.Union[str, int]
"""所引用的消息 ID"""
chain: T.Optional[T.List["BaseMessageComponent"]] = []
@@ -558,7 +567,7 @@ class Reply(BaseMessageComponent):
class RedBag(BaseMessageComponent):
type: ComponentType = "RedBag"
type = ComponentType.RedBag
title: str
def __init__(self, **_):
@@ -566,7 +575,7 @@ class RedBag(BaseMessageComponent):
class Poke(BaseMessageComponent):
type: str = ""
type: str = ComponentType.Poke
id: T.Optional[int] = 0
qq: T.Optional[int] = 0
@@ -576,7 +585,7 @@ class Poke(BaseMessageComponent):
class Forward(BaseMessageComponent):
type: ComponentType = "Forward"
type = ComponentType.Forward
id: str
def __init__(self, **_):
@@ -586,7 +595,7 @@ class Forward(BaseMessageComponent):
class Node(BaseMessageComponent):
"""群合并转发消息"""
type: ComponentType = "Node"
type = ComponentType.Node
id: T.Optional[int] = 0 # 忽略
name: T.Optional[str] = "" # qq昵称
uin: T.Optional[str] = "0" # qq号
@@ -638,7 +647,7 @@ class Node(BaseMessageComponent):
class Nodes(BaseMessageComponent):
type: ComponentType = "Nodes"
type = ComponentType.Nodes
nodes: T.List[Node]
def __init__(self, nodes: T.List[Node], **_):
@@ -664,7 +673,7 @@ class Nodes(BaseMessageComponent):
class Xml(BaseMessageComponent):
type: ComponentType = "Xml"
type = ComponentType.Xml
data: str
resid: T.Optional[int] = 0
@@ -673,7 +682,7 @@ class Xml(BaseMessageComponent):
class Json(BaseMessageComponent):
type: ComponentType = "Json"
type = ComponentType.Json
data: T.Union[str, dict]
resid: T.Optional[int] = 0
@@ -684,7 +693,7 @@ class Json(BaseMessageComponent):
class CardImage(BaseMessageComponent):
type: ComponentType = "CardImage"
type = ComponentType.CardImage
file: str
cache: T.Optional[bool] = True
minwidth: T.Optional[int] = 400
@@ -703,7 +712,7 @@ class CardImage(BaseMessageComponent):
class TTS(BaseMessageComponent):
type: ComponentType = "TTS"
type = ComponentType.TTS
text: str
def __init__(self, **_):
@@ -711,7 +720,7 @@ class TTS(BaseMessageComponent):
class Unknown(BaseMessageComponent):
type: ComponentType = "Unknown"
type = ComponentType.Unknown
text: str
def toString(self):
@@ -723,7 +732,7 @@ class File(BaseMessageComponent):
文件消息段
"""
type: ComponentType = "File"
type = ComponentType.File
name: T.Optional[str] = "" # 名字
file_: T.Optional[str] = "" # 本地路径
url: T.Optional[str] = "" # url
@@ -853,7 +862,7 @@ class File(BaseMessageComponent):
class WechatEmoji(BaseMessageComponent):
type: ComponentType = "WechatEmoji"
type = ComponentType.WechatEmoji
md5: T.Optional[str] = ""
md5_len: T.Optional[int] = 0
cdnurl: T.Optional[str] = ""

View File

@@ -36,6 +36,7 @@ class ResultDecorateStage(Stage):
self.t2i_word_threshold = 150
self.t2i_strategy = ctx.astrbot_config["t2i_strategy"]
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"][
"forward_threshold"
@@ -247,7 +248,10 @@ class ResultDecorateStage(Stage):
render_start = time.time()
try:
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:
logger.error("文本转图片失败,使用文本发送。")

View File

@@ -85,6 +85,8 @@ class PlatformManager:
)
case "slack":
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:
logger.error(
f"加载平台适配器 {platform_config['type']} 失败,原因:{e}。请检查依赖库是否安装。提示:可以在 管理面板->控制台->安装Pip库 中安装依赖库。"

View File

@@ -308,13 +308,20 @@ class AiocqhttpAdapter(Platform):
continue
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"]),
no_cache=False,
)
if at_info:
nickname = at_info.get("nick", "") or at_info.get(
"nickname", ""
)
nickname = at_info.get("card", "")
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"}
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
if not re.match(r"^[a-z0-9_]+$", cmd_name) or len(cmd_name) > 32:
logger.debug(f"跳过无法注册的命令: {cmd_name}")
return None
# Build description.

View File

@@ -27,14 +27,16 @@ class Star(CommandParserMixin):
star_map[cls.__module__].star_cls_type = cls
star_map[cls.__module__].module_path = cls.__module__
@staticmethod
async def text_to_image(text: str, return_url=True) -> str:
async def text_to_image(self, 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(
tmpl: str, data: dict, return_url=True, options: dict | None = None
self, tmpl: str, data: dict, return_url=True, options: dict | None = None
) -> str:
"""渲染 HTML"""
return await html_renderer.render_custom_template(

View File

@@ -18,6 +18,7 @@ class PlatformAdapterType(enum.Flag):
KOOK = enum.auto()
VOCECHAT = enum.auto()
WEIXIN_OFFICIAL_ACCOUNT = enum.auto()
SATORI = enum.auto()
ALL = (
AIOCQHTTP
| QQOFFICIAL
@@ -31,6 +32,7 @@ class PlatformAdapterType(enum.Flag):
| KOOK
| VOCECHAT
| WEIXIN_OFFICIAL_ACCOUNT
| SATORI
)
@@ -47,6 +49,7 @@ ADAPTER_NAME_2_TYPE = {
"wechatpadpro": PlatformAdapterType.WECHATPADPRO,
"vocechat": PlatformAdapterType.VOCECHAT,
"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 os
import uuid
from pathlib import Path
from typing import Union, Awaitable, List, Optional, ClassVar
from astrbot.core.message.components import BaseMessageComponent
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.star.context import Context
from astrbot.core.star.star import star_map
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:
"""
@@ -49,42 +71,76 @@ class StarTools:
Note:
qq_official(QQ官方API平台)不支持此方法
"""
if cls._context is None:
raise ValueError("StarTools not initialized")
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
async def create_message(
cls,
type: str,
self_id: str,
session_id: str,
message_id: str,
sender: MessageMember,
message: List[BaseMessageComponent],
message_str: str,
raw_message: object,
group_id: str = "",
):
message_id: str = "",
raw_message: object = None,
group_id: str = ""
) -> AstrBotMessage:
"""
创建一个AstrBot消息对象
Args:
type (str): 消息类型
type (str): 消息类型, 例如 "GroupMessage" "FriendMessage" "OtherMessage"
self_id (str): 机器人自身ID
session_id (str): 会话ID(通常为用户ID)(QQ号, 群号等)
message_id (str): 消息ID
sender (MessageMember): 发送者信息
message (List[BaseMessageComponent]): 消息组件列表
message_str (str): 消息字符串
raw_message (object): 原始消息对象
sender (MessageMember): 发送者信息, 例如 MessageMember(user_id="123456", nickname="昵称")
message (List[BaseMessageComponent]): 消息组件列表, 也就是消息链, 这个不会发给 llm, 但是会经过其他处理
message_str (str): 消息字符串, 也就是纯文本消息, 也就是发送给 llm 的消息, 与消息链一致
message_id (str): 消息ID, 构造消息时可以随意填写也可不填
raw_message (object): 原始消息对象, 可以随意填写也可不填
group_id (str, optional): 群组ID, 如果为私聊则为空. Defaults to "".
Returns:
AstrBotMessage: 创建的消息对象
"""
abm = AstrBotMessage()
abm.type = type
abm.type = MessageType(type)
abm.self_id = self_id
abm.session_id = session_id
if message_id == "":
message_id = uuid.uuid4().hex
abm.message_id = message_id
abm.sender = sender
abm.message = message
@@ -93,13 +149,38 @@ class StarTools:
abm.group_id = group_id
return abm
# todo: 添加构造事件的方法
# async def create_event(
# self, platform: str, umo: str, sender_id: str, session_id: str
# ):
# platform = self._context.get_platform(platform)
@classmethod
async def create_event(
cls, abm: AstrBotMessage, platform: str = "aiocqhttp", is_wake: bool = True
# 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
def activate_llm_tool(cls, name: str) -> bool:
@@ -110,6 +191,8 @@ class StarTools:
Args:
name (str): 工具名称
"""
if cls._context is None:
raise ValueError("StarTools not initialized")
return cls._context.activate_llm_tool(name)
@classmethod
@@ -120,6 +203,8 @@ class StarTools:
Args:
name (str): 工具名称
"""
if cls._context is None:
raise ValueError("StarTools not initialized")
return cls._context.deactivate_llm_tool(name)
@classmethod
@@ -135,6 +220,8 @@ class StarTools:
desc (str): 工具描述
func_obj (Awaitable): 函数对象,必须是异步函数
"""
if cls._context is None:
raise ValueError("StarTools not initialized")
cls._context.register_llm_tool(name, func_args, desc, func_obj)
@classmethod
@@ -146,6 +233,8 @@ class StarTools:
Args:
name (str): 工具名称
"""
if cls._context is None:
raise ValueError("StarTools not initialized")
cls._context.unregister_llm_tool(name)
@classmethod
@@ -169,8 +258,11 @@ class StarTools:
- 创建目录失败权限不足或其他IO错误
"""
if not plugin_name:
frame = inspect.currentframe().f_back
module = inspect.getmodule(frame)
frame = inspect.currentframe()
module = None
if frame:
frame = frame.f_back
module = inspect.getmodule(frame)
if not module:
raise RuntimeError("无法获取调用者模块信息")
@@ -182,6 +274,9 @@ class StarTools:
plugin_name = metadata.name
if not plugin_name:
raise ValueError("无法获取插件名称")
data_dir = Path(os.path.join(get_astrbot_data_path(), "plugin_data", plugin_name))
try:

View File

@@ -1,6 +1,5 @@
import aiohttp
import asyncio
import os
import ssl
import certifi
import logging
@@ -8,10 +7,9 @@ import random
from . import RenderStrategy
from astrbot.core.config import VERSION
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"
CUSTOM_T2I_TEMPLATE_PATH = os.path.join(get_astrbot_data_path(), "t2i_template.html")
logger = logging.getLogger("astrbot")
@@ -23,26 +21,17 @@ class NetworkRenderStrategy(RenderStrategy):
self.BASE_RENDER_URL = ASTRBOT_T2I_DEFAULT_ENDPOINT
else:
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.template_manager = TemplateManager()
async def initialize(self):
if self.BASE_RENDER_URL == ASTRBOT_T2I_DEFAULT_ENDPOINT:
asyncio.create_task(self.get_official_endpoints())
async def get_template(self) -> str:
"""获取文转图 HTML 模板
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_template(self, name: str = "base") -> str:
"""通过名称获取文转图 HTML 模板"""
return self.template_manager.get_template(name)
async def get_official_endpoints(self):
"""获取官方的 t2i 端点列表。"""
@@ -124,11 +113,15 @@ class NetworkRenderStrategy(RenderStrategy):
logger.error(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("`", "\\`")
return await self.render_custom_template(
tmpl_str, {"text": text, "version": f"v{VERSION}"}, return_url

View File

@@ -34,12 +34,18 @@ class HtmlRenderer:
)
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:
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:
logger.error(
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

@@ -1,6 +1,7 @@
import typing
import traceback
import os
import copy
from .route import Route, Response, RouteContext
from astrbot.core.provider.entities import ProviderType
from quart import request
@@ -16,11 +17,10 @@ from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
from astrbot.core.platform.register import platform_registry
from astrbot.core.provider.register import provider_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.provider import RerankProvider
import asyncio
from astrbot.core.utils.t2i.network_strategy import CUSTOM_T2I_TEMPLATE_PATH
def try_cast(value: str, type_: str):
@@ -156,6 +156,7 @@ def save_config(post_config: dict, config: AstrBotConfig, is_core: bool = False)
raise ValueError(f"验证配置时出现异常: {e}")
if errors:
raise ValueError(f"格式校验未通过: {errors}")
config.save_config(post_config)
@@ -186,56 +187,9 @@ class ConfigRoute(Route):
"/config/provider/check_one": ("GET", self.check_one_provider_status),
"/config/provider/list": ("GET", self.get_provider_config_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()
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):
"""获取所有 AstrBot 配置文件的列表"""
abconf_list = self.acm.get_conf_list()
@@ -766,6 +720,13 @@ class ConfigRoute(Route):
if conf_id not in self.acm.confs:
raise ValueError(f"配置文件 {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)
except Exception as e:
raise e

View File

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

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

@@ -18,6 +18,7 @@ from astrbot.core.utils.io import get_local_ip_addresses
from .routes import *
from .routes.route import Response, RouteContext
from .routes.session_management import SessionManagementRoute
from .routes.t2i import T2iRoute
APP: Quart = None
@@ -60,9 +61,8 @@ class AstrBotDashboard:
self.session_management_route = SessionManagementRoute(
self.context, db, core_lifecycle
)
self.persona_route = PersonaRoute(
self.context, db, core_lifecycle
)
self.persona_route = PersonaRoute(self.context, db, core_lifecycle)
self.t2i_route = T2iRoute(self.context, core_lifecycle)
self.app.add_url_rule(
"/api/plug/<path:subpath>",

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

@@ -15,17 +15,59 @@
<v-card>
<v-card-title class="d-flex align-center justify-space-between">
<span>自定义文转图 HTML 模板</span>
<div class="d-flex gap-2">
<v-btn
v-if="hasCustomTemplate"
<v-spacer></v-spacer>
<div class="d-flex align-center gap-2" style="width: 60%">
<v-text-field
v-if="isCreatingNew"
v-model="editingName"
label="输入新模板名称"
density="compact"
hide-details
variant="outlined"
color="warning"
size="small"
@click="resetToDefault"
:loading="resetLoading"
class="flex-grow-1"
autofocus
:rules="[v => !!v || '名称不能为空']"
></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"
>
恢复默认
</v-btn>
<template v-slot:item="{ props, item }">
<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
variant="text"
icon
@@ -41,17 +83,49 @@
<!-- 左侧编辑器 -->
<v-col cols="6" class="d-flex flex-column">
<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-btn
variant="text"
size="small"
@click="saveTemplate"
:loading="saveLoading"
color="primary"
>
保存模板
</v-btn>
<div class="d-flex align-center pa-1" style="border: 1px solid rgba(0,0,0,0.1); border-radius: 8px;">
<v-btn
variant="text"
size="small"
@click="newTemplate"
color="success"
>
<v-icon left>mdi-plus</v-icon>
新建
</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>
<div class="flex-grow-1" style="border-right: 1px solid rgba(0,0,0,0.1);">
<VueMonacoEditor
@@ -106,10 +180,11 @@
</v-btn>
<v-btn
color="primary"
@click="saveTemplate"
@click="promptApplyAndClose"
:loading="saveLoading"
:disabled="isCreatingNew || !selectedTemplate"
>
保存应用
保存应用当前编辑模板
</v-btn>
</v-col>
</v-row>
@@ -121,7 +196,7 @@
<v-card>
<v-card-title>确认重置</v-card-title>
<v-card-text>
确定要恢复默认模板这将删除您的自定义模板此操作无法撤销
确定要 'base' 模板恢复默认内容当前编辑器中的任何未保存更改将丢失此操作无法撤销
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
@@ -130,6 +205,37 @@
</v-card-actions>
</v-card>
</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>
</template>
@@ -141,18 +247,30 @@ import axios from 'axios'
const { t } = useI18n()
// 响应式数据
// --- 响应式数据 ---
const dialog = ref(false)
const resetDialog = ref(false)
const loading = ref(false)
const loading = ref(false) // 用于加载模板列表
const saveLoading = ref(false)
const resetLoading = 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 hasCustomTemplate = ref(false)
const isCreatingNew = ref(false)
// 对话框状态
const resetDialog = ref(false)
const deleteDialog = ref(false)
const applyAndCloseDialog = ref(false)
const previewFrame = ref(null)
// 编辑器配置
// --- 编辑器配置 ---
const editorTheme = computed(() => 'vs-light')
const editorOptions = {
automaticLayout: true,
@@ -163,16 +281,13 @@ const editorOptions = {
scrollBeyondLastLine: false,
}
// 示例数据用于预览
// --- 预览逻辑 ---
const previewData = {
text: '这是一个示例文本,用于预览模板效果。\n\n这里可以包含多行文本支持换行和各种格式。',
version: 'v4.0.0'
}
// 生成预览内容
const previewContent = computed(() => {
try {
// 简单的模板替换,模拟 Jinja2 渲染
let content = templateContent.value
content = content.replace(/\{\{\s*text\s*\|\s*safe\s*\}\}/g, previewData.text)
content = content.replace(/\{\{\s*version\s*\}\}/g, previewData.version)
@@ -182,58 +297,128 @@ const previewContent = computed(() => {
}
})
// 方法
const loadTemplate = async () => {
// --- API 调用方法 ---
const loadInitialData = async () => {
loading.value = true
try {
const response = await axios.get('/api/config/astrbot/t2i-template/get')
if (response.data.status === 'ok') {
templateContent.value = response.data.data.template
hasCustomTemplate.value = response.data.data.has_custom_template
const [listRes, activeRes] = await Promise.all([
axios.get('/api/t2i/templates'),
axios.get('/api/t2i/templates/active')
])
if (listRes.data.status === 'ok') {
templates.value = listRes.data.data
} 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) {
console.error('加载模板失败:', error)
console.error('加载初始数据失败:', error)
} finally {
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 () => {
saveLoading.value = true
try {
const response = await axios.post('/api/config/astrbot/t2i-template/save', {
template: templateContent.value
})
if (response.data.status === 'ok') {
hasCustomTemplate.value = true
closeDialog()
if (isCreatingNew.value) {
// --- 创建新模板 ---
if (!editingName.value) return
const response = await axios.post('/api/t2i/templates/create', {
name: editingName.value,
content: templateContent.value
})
await loadInitialData() // 重新加载所有数据
selectedTemplate.value = response.data.data.name
isCreatingNew.value = false
} else {
console.error('保存模板失败:', response.data.message)
// --- 更新现有模板 ---
if (!selectedTemplate.value) return
await axios.put(`/api/t2i/templates/${selectedTemplate.value}`, {
content: templateContent.value
})
}
} catch (error) {
console.error('保存模板失败:', error)
// 可以在此添加错误提示
} finally {
saveLoading.value = false
}
}
const resetToDefault = () => {
resetDialog.value = true
const setActiveTemplate = async (name) => {
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 () => {
resetLoading.value = true
try {
const response = await axios.delete('/api/config/astrbot/t2i-template/delete')
if (response.data.status === 'ok') {
hasCustomTemplate.value = false
resetDialog.value = false
// 重新加载默认模板
await loadTemplate()
} else {
console.error('重置模板失败:', response.data.message)
await axios.post('/api/t2i/templates/reset_default')
resetDialog.value = false
if (selectedTemplate.value === 'base') {
await loadTemplateContent('base')
}
if (activeTemplate.value !== 'base') {
await setActiveTemplate('base')
}
} catch (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 = () => {
previewLoading.value = true
nextTick(() => {
if (previewFrame.value) {
previewFrame.value.contentWindow.location.reload()
}
setTimeout(() => {
previewLoading.value = false
}, 500)
setTimeout(() => previewLoading.value = false, 500)
})
}
@@ -258,18 +486,29 @@ const closeDialog = () => {
dialog.value = false
}
// --- 监听器和生命周期 ---
watch(dialog, (newVal) => {
if (newVal && !templateContent.value) {
loadTemplate()
if (newVal) {
loadInitialData()
} else {
// 关闭时重置状态
selectedTemplate.value = null
templateContent.value = ''
isCreatingNew.value = false
}
})
watch(selectedTemplate, (newName) => {
if (newName) {
isCreatingNew.value = false
loadTemplateContent(newName)
}
})
defineExpose({
openDialog: () => {
dialog.value = true
if (!templateContent.value) {
loadTemplate()
}
}
})
</script>

View File

@@ -29,6 +29,11 @@
"title": "ID Conflict Warning",
"message": "Detected duplicate ID \"{id}\". Please use a new ID.",
"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": {

View File

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

View File

@@ -18,6 +18,8 @@
</v-list-item>
</template>
</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>
<v-btn-toggle v-model="configType" mandatory color="primary" variant="outlined" density="comfortable"

View File

@@ -168,6 +168,28 @@
</v-card-actions>
</v-card>
</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>
</template>
@@ -234,17 +256,27 @@ export default {
conflictId: '',
idConflictResolve: null,
// OneBot Empty Token Warning #2639
showOneBotEmptyTokenWarnDialog: false,
oneBotEmptyTokenWarningResolve: null,
store: useCommonStore()
}
},
watch: {
showIdConflictDialog(newValue) {
// 当对话框关闭时,如果 Promise 还在等待,则拒绝它以防止内存泄漏
if (!newValue && this.idConflictResolve) {
this.idConflictResolve(false);
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
} else if (name === 'vocechat') {
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",
"kook": "https://astrbot.app/deploy/platform/kook.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";
},
@@ -350,24 +385,39 @@ export default {
newPlatform() {
this.loading = true;
if (this.updatingMode) {
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;
if (this.newSelectedPlatformConfig.type === 'aiocqhttp') {
const token = this.newSelectedPlatformConfig.ws_reverse_token;
if (!token || token.trim() === '') {
this.showOneBotEmptyTokenWarning().then((continueWithWarning) => {
if (continueWithWarning) {
this.updatePlatform();
}
});
return;
}
}
this.updatePlatform();
} else {
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() {
// 检查 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 {
const res = await axios.post('/api/config/platform/new', this.newSelectedPlatformConfig);
this.loading = false;
@@ -406,6 +467,25 @@ export default {
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) {
if (confirm(`${this.messages.deleteConfirm} ${platform.id}?`)) {
axios.post('/api/config/platform/delete', { id: platform.id }).then((res) => {
@@ -532,4 +612,4 @@ export default {
font-weight: bold;
opacity: 0.3;
}
</style>
</style>

View File

@@ -1,6 +1,6 @@
[project]
name = "AstrBot"
version = "4.0.0-beta.5"
version = "4.0.0"
description = "易上手的多平台 LLM 聊天机器人及开发框架"
readme = "README.md"
requires-python = ">=3.10"