feat: 支持管理 T2I 模版 (#2638)
* feat:添加t2i模板管理后端api,移除config.py中重复功能 * feat: 添加T2I模板管理功能前端,支持模板的创建、应用和重置 * refactor: 修复错误的保存逻辑,将t2i注册时打印路由信息部分移到基类实现 * remove:移除了路由注册时的打印 * chore: format code * fix: update input variant from solo to outlined for better UI consistency --------- Co-authored-by: Soulter <905617992@qq.com>
This commit is contained in:
@@ -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": {
|
||||
@@ -2334,6 +2335,12 @@ CONFIG_METADATA_3_SYSTEM = {
|
||||
},
|
||||
"_special": "t2i_template",
|
||||
},
|
||||
"t2i_active_template": {
|
||||
"description": "当前应用的文转图渲染模板",
|
||||
"type": "string",
|
||||
"hint": "此处的值由文转图模板管理页面进行维护。",
|
||||
"invisible": True,
|
||||
},
|
||||
"log_level": {
|
||||
"description": "控制台日志级别",
|
||||
"type": "string",
|
||||
|
||||
@@ -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("文本转图片失败,使用文本发送。")
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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>",
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user