feat: add custom T2I template editor (#2581)
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user