Compare commits
11 Commits
stress_tes
...
v4.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c3f5431ba | ||
|
|
d98cf16a4c | ||
|
|
2c3c3ae546 | ||
|
|
905eef48e3 | ||
|
|
b31b520c7c | ||
|
|
17aee086a3 | ||
|
|
c1756e5767 | ||
|
|
2920279c64 | ||
|
|
1f0f985b01 | ||
|
|
0762c81633 | ||
|
|
28ef301ccc |
76
README.md
76
README.md
@@ -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>
|
||||
|
||||
[](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>
|
||||
|
||||

|
||||
|
||||
</details>
|
||||
|
||||
|
||||
_私は、高性能ですから!_
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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] = ""
|
||||
|
||||
@@ -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("文本转图片失败,使用文本发送。")
|
||||
|
||||
@@ -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库 中安装依赖库。"
|
||||
|
||||
@@ -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(
|
||||
|
||||
482
astrbot/core/platform/sources/satori/satori_adapter.py
Normal file
482
astrbot/core/platform/sources/satori/satori_adapter.py
Normal 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()
|
||||
221
astrbot/core/platform/sources/satori/satori_event.py
Normal file
221
astrbot/core/platform/sources/satori/satori_event.py
Normal 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("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
)
|
||||
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("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
)
|
||||
content_parts.append(text)
|
||||
|
||||
elif isinstance(component, At):
|
||||
if component.qq:
|
||||
content_parts.append(f'<at id="{component.qq}"/>')
|
||||
elif component.name:
|
||||
content_parts.append(f'<at name="{component.name}"/>')
|
||||
|
||||
elif isinstance(component, Image):
|
||||
try:
|
||||
image_base64 = await component.convert_to_base64()
|
||||
if image_base64:
|
||||
content_parts.append(
|
||||
f'<img src="data:image/jpeg;base64,{image_base64}"/>'
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"图片转换为base64失败: {e}")
|
||||
|
||||
elif isinstance(component, File):
|
||||
content_parts.append(
|
||||
f'<file src="{component.file}" name="{component.name or "文件"}"/>'
|
||||
)
|
||||
|
||||
elif isinstance(component, Record):
|
||||
try:
|
||||
record_base64 = await component.convert_to_base64()
|
||||
if record_base64:
|
||||
content_parts.append(
|
||||
f'<audio src="data:audio/wav;base64,{record_base64}"/>'
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"语音转换为base64失败: {e}")
|
||||
|
||||
content = "".join(content_parts)
|
||||
channel_id = self.session_id
|
||||
data = {"channel_id": channel_id, "content": content}
|
||||
|
||||
result = await self.adapter.send_http_request(
|
||||
"POST", "/message.create", data, platform, user_id
|
||||
)
|
||||
if not result:
|
||||
logger.error("Satori 消息发送失败")
|
||||
except Exception as e:
|
||||
logger.error(f"Satori 消息发送异常: {e}")
|
||||
|
||||
await super().send(message)
|
||||
|
||||
async def send_streaming(self, generator, use_fallback: bool = False):
|
||||
try:
|
||||
content_parts = []
|
||||
|
||||
async for chain in generator:
|
||||
if isinstance(chain, MessageChain):
|
||||
if chain.type == "break":
|
||||
if content_parts:
|
||||
content = "".join(content_parts)
|
||||
temp_chain = MessageChain([Plain(text=content)])
|
||||
await self.send(temp_chain)
|
||||
content_parts = []
|
||||
continue
|
||||
|
||||
for component in chain.chain:
|
||||
if isinstance(component, Plain):
|
||||
content_parts.append(component.text)
|
||||
elif isinstance(component, Image):
|
||||
if content_parts:
|
||||
content = "".join(content_parts)
|
||||
temp_chain = MessageChain([Plain(text=content)])
|
||||
await self.send(temp_chain)
|
||||
content_parts = []
|
||||
try:
|
||||
image_base64 = await component.convert_to_base64()
|
||||
if image_base64:
|
||||
img_chain = MessageChain(
|
||||
[
|
||||
Plain(
|
||||
text=f'<img src="data:image/jpeg;base64,{image_base64}"/>'
|
||||
)
|
||||
]
|
||||
)
|
||||
await self.send(img_chain)
|
||||
except Exception as e:
|
||||
logger.error(f"图片转换为base64失败: {e}")
|
||||
else:
|
||||
content_parts.append(str(component))
|
||||
|
||||
if content_parts:
|
||||
content = "".join(content_parts)
|
||||
temp_chain = MessageChain([Plain(text=content)])
|
||||
await self.send(temp_chain)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Satori 流式消息发送异常: {e}")
|
||||
|
||||
return await super().send_streaming(generator, use_fallback)
|
||||
@@ -183,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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."
|
||||
|
||||
184
astrbot/core/utils/t2i/template/astrbot_powershell.html
Normal file
184
astrbot/core/utils/t2i/template/astrbot_powershell.html
Normal 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>
|
||||
247
astrbot/core/utils/t2i/template/default_template.html.bak
Normal file
247
astrbot/core/utils/t2i/template/default_template.html.bak
Normal 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>
|
||||
95
astrbot/core/utils/t2i/template_manager.py
Normal file
95
astrbot/core/utils/t2i/template_manager.py
Normal 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)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
232
astrbot/dashboard/routes/t2i.py
Normal file
232
astrbot/dashboard/routes/t2i.py
Normal 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
|
||||
@@ -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
24
changelogs/v4.0.0.md
Normal 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
|
||||
BIN
dashboard/src/assets/images/platform_logos/satori.png
Normal file
BIN
dashboard/src/assets/images/platform_logos/satori.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
@@ -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>
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -29,6 +29,11 @@
|
||||
"title": "ID 冲突警告",
|
||||
"message": "检测到 ID \"{id}\" 重复。请使用一个新的 ID。",
|
||||
"confirm": "好的"
|
||||
},
|
||||
"securityWarning": {
|
||||
"title": "安全提醒",
|
||||
"aiocqhttpTokenMissing": "为了增强连接安全性,强烈建议您设置 ws_reverse_token。未设置 Token 可能导致安全风险。",
|
||||
"learnMore": "了解更多"
|
||||
}
|
||||
},
|
||||
"messages": {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user