feat: add custom T2I template editor (#2581)

This commit is contained in:
Soulter
2025-08-31 12:11:55 +09:00
committed by GitHub
parent 5073f21002
commit d2d5ef1c5c
5 changed files with 387 additions and 8 deletions
+10 -1
View File
@@ -2261,13 +2261,22 @@ CONFIG_METADATA_3_SYSTEM = {
"options": ["remote", "local"],
},
"t2i_endpoint": {
"description": "文本转图像服务接口",
"description": "文本转图像服务 API 地址",
"type": "string",
"hint": "为空时使用 AstrBot API 服务",
"condition": {
"t2i_strategy": "remote",
},
},
"t2i_template": {
"description": "文本转图像自定义模版",
"type": "bool",
"hint": "启用后可自定义 HTML 模板用于文转图渲染。",
"condition": {
"t2i_strategy": "remote",
},
"_special": "t2i_template"
},
"log_level": {
"description": "控制台日志级别",
"type": "string",
+17 -6
View File
@@ -8,8 +8,10 @@ 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
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")
@@ -21,7 +23,9 @@ 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")
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]
@@ -29,6 +33,17 @@ class NetworkRenderStrategy(RenderStrategy):
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_official_endpoints(self):
"""获取官方的 t2i 端点列表。"""
try:
@@ -113,11 +128,7 @@ class NetworkRenderStrategy(RenderStrategy):
"""
返回图像的文件路径
"""
with open(
os.path.join(self.TEMPLATE_PATH, "base.html"), "r", encoding="utf-8"
) as f:
tmpl_str = f.read()
assert tmpl_str
tmpl_str = await self.get_template()
text = text.replace("`", "\\`")
return await self.render_custom_template(
tmpl_str, {"text": text, "version": f"v{VERSION}"}, return_url
+49 -1
View File
@@ -16,9 +16,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
from astrbot.core import logger, html_renderer
from astrbot.core.provider import Provider
import asyncio
from astrbot.core.utils.t2i.network_strategy import CUSTOM_T2I_TEMPLATE_PATH
def try_cast(value: str, type_: str):
@@ -184,9 +185,56 @@ 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()
@@ -6,6 +6,7 @@ import ProviderSelector from './ProviderSelector.vue'
import PersonaSelector from './PersonaSelector.vue'
import KnowledgeBaseSelector from './KnowledgeBaseSelector.vue'
import PluginSetSelector from './PluginSetSelector.vue'
import T2ITemplateEditor from './T2ITemplateEditor.vue'
import { useI18n } from '@/i18n/composables'
@@ -246,6 +247,9 @@ function hasVisibleItemsAfter(items, currentIndex) {
v-model="createSelectorModel(itemKey).value"
/>
</div>
<div v-else-if="itemMeta?._special === 't2i_template'">
<T2ITemplateEditor />
</div>
</v-col>
</v-row>
@@ -0,0 +1,307 @@
<template>
<v-dialog v-model="dialog" max-width="1400px" persistent scrollable>
<template v-slot:activator="{ props }">
<v-btn
v-bind="props"
variant="outlined"
color="primary"
size="small"
:loading="loading"
>
自定义 T2I 模板
</v-btn>
</template>
<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"
variant="outlined"
color="warning"
size="small"
@click="resetToDefault"
:loading="resetLoading"
>
恢复默认
</v-btn>
<v-btn
variant="text"
icon
@click="closeDialog"
>
<v-icon>mdi-close</v-icon>
</v-btn>
</div>
</v-card-title>
<v-card-text class="pa-0">
<v-row no-gutters style="height: 70vh;">
<!-- 左侧编辑器 -->
<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-spacer></v-spacer>
<v-btn
variant="text"
size="small"
@click="saveTemplate"
:loading="saveLoading"
color="primary"
>
保存模板
</v-btn>
</v-toolbar>
<div class="flex-grow-1" style="border-right: 1px solid rgba(0,0,0,0.1);">
<VueMonacoEditor
v-model:value="templateContent"
:theme="editorTheme"
language="html"
:options="editorOptions"
style="height: 100%;"
/>
</div>
</v-col>
<!-- 右侧预览 -->
<v-col cols="6" class="d-flex flex-column">
<v-toolbar density="compact" color="surface-variant">
<v-toolbar-title class="text-subtitle-2">实时预览(可能有差异)</v-toolbar-title>
<v-spacer></v-spacer>
<v-btn
variant="text"
size="small"
@click="refreshPreview"
:loading="previewLoading"
>
刷新预览
</v-btn>
</v-toolbar>
<div class="flex-grow-1 preview-container">
<iframe
ref="previewFrame"
:srcdoc="previewContent"
style="width: 100%; height: 100%; border: none; zoom: 0.6;"
/>
</div>
</v-col>
</v-row>
</v-card-text>
<v-card-actions class="px-6 py-4">
<v-row no-gutters class="align-center">
<v-col>
<div class="text-caption text-grey">
<v-icon size="16" class="mr-1">mdi-information</v-icon>
支持 jinja2 语法可用变量<code> text | safe </code>要渲染的文本, <code> version </code>AstrBot 版本
</div>
</v-col>
<v-col cols="auto">
<v-btn
variant="text"
@click="closeDialog"
>
取消
</v-btn>
<v-btn
color="primary"
@click="saveTemplate"
:loading="saveLoading"
>
保存并应用
</v-btn>
</v-col>
</v-row>
</v-card-actions>
</v-card>
<!-- 确认重置对话框 -->
<v-dialog v-model="resetDialog" max-width="400px">
<v-card>
<v-card-title>确认重置</v-card-title>
<v-card-text>
确定要恢复默认模板吗这将删除您的自定义模板此操作无法撤销
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn text @click="resetDialog = false">取消</v-btn>
<v-btn color="warning" @click="confirmReset" :loading="resetLoading">确认重置</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-dialog>
</template>
<script setup>
import { ref, computed, nextTick, watch } from 'vue'
import { VueMonacoEditor } from '@guolao/vue-monaco-editor'
import { useI18n } from '@/i18n/composables'
import axios from 'axios'
const { t } = useI18n()
//
const dialog = ref(false)
const resetDialog = ref(false)
const loading = ref(false)
const saveLoading = ref(false)
const resetLoading = ref(false)
const previewLoading = ref(false)
const templateContent = ref('')
const hasCustomTemplate = ref(false)
const previewFrame = ref(null)
//
const editorTheme = computed(() => 'vs-light')
const editorOptions = {
automaticLayout: true,
fontSize: 12,
lineNumbers: 'on',
wordWrap: 'on',
minimap: { enabled: false },
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)
return content
} catch (error) {
return `<div style="color: red; padding: 20px;">模板渲染错误: ${error.message}</div>`
}
})
//
const loadTemplate = 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
} else {
console.error('加载模板失败:', response.data.message)
}
} catch (error) {
console.error('加载模板失败:', error)
} finally {
loading.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()
} else {
console.error('保存模板失败:', response.data.message)
}
} catch (error) {
console.error('保存模板失败:', error)
} finally {
saveLoading.value = false
}
}
const resetToDefault = () => {
resetDialog.value = true
}
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)
}
} catch (error) {
console.error('重置模板失败:', error)
} finally {
resetLoading.value = false
}
}
const refreshPreview = () => {
previewLoading.value = true
nextTick(() => {
if (previewFrame.value) {
previewFrame.value.contentWindow.location.reload()
}
setTimeout(() => {
previewLoading.value = false
}, 500)
})
}
const closeDialog = () => {
dialog.value = false
}
watch(dialog, (newVal) => {
if (newVal && !templateContent.value) {
loadTemplate()
}
})
defineExpose({
openDialog: () => {
dialog.value = true
if (!templateContent.value) {
loadTemplate()
}
}
})
</script>
<style scoped>
.preview-container {
background-color: #f5f5f5;
position: relative;
}
.preview-container::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-image:
linear-gradient(45deg, #ccc 25%, transparent 25%),
linear-gradient(-45deg, #ccc 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #ccc 75%),
linear-gradient(-45deg, transparent 75%, #ccc 75%);
background-size: 20px 20px;
background-position: 0 0, 0 10px, 10px -10px, -10px 0px;
opacity: 0.1;
pointer-events: none;
}
code {
background-color: rgba(0,0,0,0.05);
padding: 2px 4px;
border-radius: 3px;
font-size: 0.875em;
}
</style>