fix: 修复自定义文转图模板更新版本后会被覆盖的问题 (#2677)

* perf: 更新模板管理逻辑,在data目录中管理用户自定义模板,优化热重载逻辑

* refactor: 优化模板管理逻辑,重构模板复制和初始化流程,增强用户模板管理功能

* chore:移除无用注释

* remove:移除了t2i部分中不会走到的异常

* style: format code

* fix: trim whitespace from template names in create, update, and delete operations

---------

Co-authored-by: Soulter <905617992@qq.com>
This commit is contained in:
RC-CHN
2025-09-12 13:34:07 +08:00
committed by GitHub
parent 1770556d56
commit dc9612d564
3 changed files with 88 additions and 320 deletions
@@ -1,247 +0,0 @@
<!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>
+75 -58
View File
@@ -2,94 +2,111 @@
import os
import shutil
from astrbot.core.utils.astrbot_path import get_astrbot_path
from astrbot.core.utils.astrbot_path import get_astrbot_data_path, get_astrbot_path
class TemplateManager:
"""
负责管理 t2i HTML 模板的 CRUD 和重置操作。
采用“用户覆盖内置”策略:用户模板存储在 data 目录中,并优先于内置模板加载。
所有创建、更新、删除操作仅影响用户目录,以确保更新框架时用户数据安全。
"""
CORE_TEMPLATES = ["base.html", "astrbot_powershell.html"]
def __init__(self):
# 修正路径拼接,加入缺失的 'astrbot' 目录
self.template_dir = os.path.join(
self.builtin_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)
self.user_template_dir = os.path.join(get_astrbot_data_path(), "t2i_templates")
# 检查模板目录中是否有 .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()
os.makedirs(self.user_template_dir, exist_ok=True)
self._initialize_user_templates()
def _get_template_path(self, name: str) -> str:
"""获取模板的完整路径,防止路径遍历漏洞"""
def _copy_core_templates(self, overwrite: bool = False):
"""从内置目录复制核心模板到用户目录"""
for filename in self.CORE_TEMPLATES:
src = os.path.join(self.builtin_template_dir, filename)
dst = os.path.join(self.user_template_dir, filename)
if os.path.exists(src) and (overwrite or not os.path.exists(dst)):
shutil.copyfile(src, dst)
def _initialize_user_templates(self):
"""如果用户目录下缺少核心模板,则进行复制。"""
self._copy_core_templates(overwrite=False)
def _get_user_template_path(self, name: str) -> str:
"""获取用户模板的完整路径,防止路径遍历漏洞。"""
if ".." in name or "/" in name or "\\" in name:
raise ValueError("模板名称包含非法字符。")
return os.path.join(self.template_dir, f"{name}.html")
return os.path.join(self.user_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("模板不存在。")
def _read_file(self, path: str) -> str:
"""读取文件内容"""
with open(path, "r", encoding="utf-8") as f:
return f.read()
def list_templates(self) -> list[dict]:
"""
列出所有可用模板。
该列表是内置模板和用户模板的合并视图,用户模板将覆盖同名的内置模板。
"""
dirs_to_scan = [self.builtin_template_dir, self.user_template_dir]
all_names = {
os.path.splitext(f)[0]
for d in dirs_to_scan
for f in os.listdir(d)
if f.endswith(".html")
}
return [
{"name": name, "is_default": name == "base"} for name in sorted(all_names)
]
def get_template(self, name: str) -> str:
"""
获取指定模板的内容。
优先从用户目录加载,如果不存在则回退到内置目录。
"""
user_path = self._get_user_template_path(name)
if os.path.exists(user_path):
return self._read_file(user_path)
builtin_path = os.path.join(self.builtin_template_dir, f"{name}.html")
if os.path.exists(builtin_path):
return self._read_file(builtin_path)
raise FileNotFoundError("模板不存在。")
def create_template(self, name: str, content: str):
"""创建一个新的模板文件。"""
path = self._get_template_path(name)
"""在用户目录中创建一个新的模板文件。"""
path = self._get_user_template_path(name)
if os.path.exists(path):
raise FileExistsError("同名模板已存在。")
with open(path, "w", encoding="utf-8") as f:
f.write(content)
def update_template(self, name: str, content: str):
"""更新一个已存在的模板文件。"""
path = self._get_template_path(name)
if not os.path.exists(path):
raise FileNotFoundError("模板不存在。")
"""
更新一个模板。此操作始终写入用户目录。
如果更新的是一个内置模板,此操作实际上会在用户目录中创建一个修改后的副本,
从而实现对内置模板的“覆盖”。
"""
path = self._get_user_template_path(name)
with open(path, "w", encoding="utf-8") as f:
f.write(content)
def delete_template(self, name: str):
"""删除一个模板文件。"""
if name == "base":
raise ValueError("不能删除默认的 base 模板。")
path = self._get_template_path(name)
"""
仅删除用户目录中的模板文件。
如果删除的是一个覆盖了内置模板的用户模板,这将有效地“恢复”到内置版本。
"""
path = self._get_user_template_path(name)
if not os.path.exists(path):
raise FileNotFoundError("模板不存在。")
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)
"""
将核心模板从内置目录强制重置到用户目录。
"""
self._copy_core_templates(overwrite=True)
+13 -15
View File
@@ -32,10 +32,6 @@ class T2iRoute(Route):
],
),
]
# 应用启动时,确保备份存在
self.manager.backup_default_template_if_not_exist()
self.register_routes()
async def list_templates(self):
@@ -89,6 +85,7 @@ class T2iRoute(Route):
)
response.status_code = 400
return response
name = name.strip()
self.manager.create_template(name, content)
response = jsonify(
@@ -118,6 +115,7 @@ class T2iRoute(Route):
async def update_template(self, name: str):
"""更新一个已存在的T2I模板"""
try:
name = name.strip()
data = await request.json
content = data.get("content")
if content is None:
@@ -126,17 +124,16 @@ class T2iRoute(Route):
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
# 检查更新的是否为当前激活的模板,如果是,则热重载
active_template = self.config.get("t2i_active_template", "base")
if name == active_template:
await self.core_lifecycle.reload_pipeline_scheduler("default")
message = f"模板 '{name}' 已更新并重新加载。"
else:
message = f"模板 '{name}' 已更新。"
return jsonify(asdict(Response().ok(data={"name": name}, message=message)))
except ValueError as e:
response = jsonify(asdict(Response().error(str(e))))
response.status_code = 400
@@ -149,6 +146,7 @@ class T2iRoute(Route):
async def delete_template(self, name: str):
"""删除一个T2I模板"""
try:
name = name.strip()
self.manager.delete_template(name)
return jsonify(
asdict(Response().ok(message="Template deleted successfully."))