mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-16 09:40:30 +08:00
fix: restore T2I text template rendering (#7789)
* fix: restore T2I text template rendering
- keep using {{ text | safe }} instead of text_base64
- inject Shiki runtime by default for T2I templates
- update built-in templates to read markdown from a hidden textarea
- improve WebUI preview sample text and Shiki runtime serving
- add regression tests for template rendering and runtime injection
* fix: prevent injected Shiki runtime from breaking T2I templates
* fix(t2i): restore raw text template rendering
* test(t2i): remove test
* fix(t2i): restore previewText
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import logging
|
||||
import random
|
||||
import re
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
@@ -15,6 +15,11 @@ from astrbot.core.utils.t2i.template_manager import TemplateManager
|
||||
from . import RenderStrategy
|
||||
|
||||
ASTRBOT_T2I_DEFAULT_ENDPOINT = "https://t2i.soulter.top/text2img"
|
||||
SHIKI_RUNTIME_SCRIPT_ID = "astrbot-t2i-shiki-runtime"
|
||||
SHIKI_RUNTIME_TEMPLATE_PATTERN = re.compile(r"\{\{\s*shiki_runtime\s*\|\s*safe\s*\}\}")
|
||||
JINJA_SYNTAX_PATTERN = re.compile(r"\{[{%#]")
|
||||
JINJA_RAW_OPEN_PATTERN = re.compile(r"{%-?\s*raw\s*-?%}")
|
||||
JINJA_RAW_CLOSE_PATTERN = re.compile(r"{%-?\s*endraw\s*-?%}")
|
||||
|
||||
logger = logging.getLogger("astrbot")
|
||||
|
||||
@@ -41,7 +46,49 @@ def get_shiki_runtime() -> str:
|
||||
)
|
||||
return ""
|
||||
|
||||
return runtime.replace("</script", "<\\/script")
|
||||
return re.sub(r"</(script)", r"<\/\1", runtime, flags=re.IGNORECASE)
|
||||
|
||||
|
||||
def _is_inside_jinja_raw_block(tmpl_str: str, index: int) -> bool:
|
||||
raw_open_index = -1
|
||||
for match in JINJA_RAW_OPEN_PATTERN.finditer(tmpl_str, 0, index):
|
||||
raw_open_index = match.start()
|
||||
|
||||
raw_close_index = -1
|
||||
for match in JINJA_RAW_CLOSE_PATTERN.finditer(tmpl_str, 0, index):
|
||||
raw_close_index = match.start()
|
||||
|
||||
return raw_open_index > raw_close_index
|
||||
|
||||
|
||||
def _wrap_runtime_for_jinja(tmpl_str: str, script: str, index: int) -> str:
|
||||
if not JINJA_SYNTAX_PATTERN.search(script) or _is_inside_jinja_raw_block(
|
||||
tmpl_str,
|
||||
index,
|
||||
):
|
||||
return script
|
||||
|
||||
return f"{{% raw %}}{script}{{% endraw %}}"
|
||||
|
||||
|
||||
def inject_shiki_runtime(tmpl_str: str) -> str:
|
||||
if SHIKI_RUNTIME_SCRIPT_ID in tmpl_str or SHIKI_RUNTIME_TEMPLATE_PATTERN.search(
|
||||
tmpl_str,
|
||||
):
|
||||
return tmpl_str
|
||||
|
||||
runtime = get_shiki_runtime()
|
||||
if not runtime:
|
||||
return tmpl_str
|
||||
|
||||
script = f'<script id="{SHIKI_RUNTIME_SCRIPT_ID}">{runtime}</script>'
|
||||
head_close = re.search(r"</head\s*>", tmpl_str, flags=re.IGNORECASE)
|
||||
if head_close:
|
||||
script = _wrap_runtime_for_jinja(tmpl_str, script, head_close.start())
|
||||
return f"{tmpl_str[: head_close.start()]} {script}\n{tmpl_str[head_close.start() :]}"
|
||||
|
||||
script = _wrap_runtime_for_jinja(tmpl_str, script, 0)
|
||||
return f"{script}\n{tmpl_str}"
|
||||
|
||||
|
||||
class NetworkRenderStrategy(RenderStrategy):
|
||||
@@ -101,11 +148,17 @@ class NetworkRenderStrategy(RenderStrategy):
|
||||
options: dict | None = None,
|
||||
) -> str:
|
||||
"""使用自定义文转图模板"""
|
||||
default_options = {"full_page": True, "type": "jpeg", "quality": 40}
|
||||
default_options = {
|
||||
"full_page": True,
|
||||
"type": "jpeg",
|
||||
"quality": 40,
|
||||
}
|
||||
if options:
|
||||
default_options |= options
|
||||
|
||||
tmpl_data = {"shiki_runtime": get_shiki_runtime()} | tmpl_data
|
||||
if SHIKI_RUNTIME_TEMPLATE_PATTERN.search(tmpl_str):
|
||||
tmpl_data = {"shiki_runtime": get_shiki_runtime()} | tmpl_data
|
||||
tmpl_str = inject_shiki_runtime(tmpl_str)
|
||||
post_data = {
|
||||
"tmpl": tmpl_str,
|
||||
"json": return_url,
|
||||
@@ -158,9 +211,11 @@ class NetworkRenderStrategy(RenderStrategy):
|
||||
if not template_name:
|
||||
template_name = "base"
|
||||
tmpl_str = await self.get_template(name=template_name)
|
||||
text_base64 = base64.b64encode(text.encode("utf-8")).decode("ascii")
|
||||
return await self.render_custom_template(
|
||||
tmpl_str,
|
||||
{"text_base64": text_base64, "version": f"v{VERSION}"},
|
||||
{
|
||||
"text": text,
|
||||
"version": f"v{VERSION}",
|
||||
},
|
||||
return_url,
|
||||
)
|
||||
|
||||
@@ -174,14 +174,14 @@
|
||||
<div id="content"></div>
|
||||
</main>
|
||||
|
||||
<script>{{ shiki_runtime | safe }}</script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.10/dist/katex.min.js" integrity="sha384-hIoBPJpTUs74ddyc4bFZSM1TVlQDA60VBbJS0oA934VSz82sBx1X7kSx2ATBDIyd" crossorigin="anonymous"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.10/dist/contrib/auto-render.min.js" integrity="sha384-43gviWU0YVjaDtb/GhzOouOXtZMP/7XUzwPTstBeZFe/+rCMvRwr4yROQP43s0Xk" crossorigin="anonymous"></script>
|
||||
<textarea id="markdown-source" hidden>{{ text | safe }}</textarea>
|
||||
<script>
|
||||
(function () {
|
||||
const contentElement = document.getElementById("content");
|
||||
const source = decodeBase64Utf8("{{ text_base64 }}");
|
||||
const source = document.getElementById("markdown-source").value;
|
||||
|
||||
contentElement.innerHTML = marked.parse(source);
|
||||
|
||||
@@ -198,20 +198,6 @@
|
||||
});
|
||||
}
|
||||
|
||||
function decodeBase64Utf8(base64Text) {
|
||||
const binary = window.atob(base64Text || "");
|
||||
const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
|
||||
|
||||
if (window.TextDecoder) {
|
||||
return new TextDecoder().decode(bytes);
|
||||
}
|
||||
|
||||
let fallback = "";
|
||||
bytes.forEach((byte) => {
|
||||
fallback += String.fromCharCode(byte);
|
||||
});
|
||||
return decodeURIComponent(escape(fallback));
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@@ -415,14 +415,14 @@
|
||||
<footer class="vp-footer">Rendered by AstrBot {{ version }}</footer>
|
||||
</div>
|
||||
|
||||
<script>{{ shiki_runtime | safe }}</script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.10/dist/katex.min.js" integrity="sha384-hIoBPJpTUs74ddyc4bFZSM1TVlQDA60VBbJS0oA934VSz82sBx1X7kSx2ATBDIyd" crossorigin="anonymous"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.10/dist/contrib/auto-render.min.js" integrity="sha384-43gviWU0YVjaDtb/GhzOouOXtZMP/7XUzwPTstBeZFe/+rCMvRwr4yROQP43s0Xk" crossorigin="anonymous"></script>
|
||||
<textarea id="markdown-source" hidden>{{ text | safe }}</textarea>
|
||||
<script>
|
||||
(function () {
|
||||
const contentElement = document.getElementById("content");
|
||||
const source = decodeBase64Utf8("{{ text_base64 }}");
|
||||
const source = document.getElementById("markdown-source").value;
|
||||
|
||||
marked.setOptions({
|
||||
gfm: true,
|
||||
@@ -446,21 +446,6 @@
|
||||
const headings = collectHeadings(contentElement);
|
||||
populateHero(contentElement, headings);
|
||||
|
||||
function decodeBase64Utf8(base64Text) {
|
||||
const binary = window.atob(base64Text || "");
|
||||
const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
|
||||
|
||||
if (window.TextDecoder) {
|
||||
return new TextDecoder().decode(bytes);
|
||||
}
|
||||
|
||||
let fallback = "";
|
||||
bytes.forEach((byte) => {
|
||||
fallback += String.fromCharCode(byte);
|
||||
});
|
||||
return decodeURIComponent(escape(fallback));
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value || "")
|
||||
.replaceAll("&", "&")
|
||||
|
||||
@@ -242,14 +242,14 @@
|
||||
</div>
|
||||
<article style="margin-top: 32px" id="content"></article>
|
||||
|
||||
<script>{{ shiki_runtime | safe }}</script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.10/dist/katex.min.js" integrity="sha384-hIoBPJpTUs74ddyc4bFZSM1TVlQDA60VBbJS0oA934VSz82sBx1X7kSx2ATBDIyd" crossorigin="anonymous"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.10/dist/contrib/auto-render.min.js" integrity="sha384-43gviWU0YVjaDtb/GhzOouOXtZMP/7XUzwPTstBeZFe/+rCMvRwr4yROQP43s0Xk" crossorigin="anonymous"></script>
|
||||
<textarea id="markdown-source" hidden>{{ text | safe }}</textarea>
|
||||
<script>
|
||||
(function () {
|
||||
const contentElement = document.getElementById("content");
|
||||
const source = decodeBase64Utf8("{{ text_base64 }}");
|
||||
const source = document.getElementById("markdown-source").value;
|
||||
|
||||
contentElement.innerHTML = marked.parse(source);
|
||||
|
||||
@@ -266,20 +266,6 @@
|
||||
});
|
||||
}
|
||||
|
||||
function decodeBase64Utf8(base64Text) {
|
||||
const binary = window.atob(base64Text || "");
|
||||
const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
|
||||
|
||||
if (window.TextDecoder) {
|
||||
return new TextDecoder().decode(bytes);
|
||||
}
|
||||
|
||||
let fallback = "";
|
||||
bytes.forEach((byte) => {
|
||||
fallback += String.fromCharCode(byte);
|
||||
});
|
||||
return decodeURIComponent(escape(fallback));
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@@ -298,17 +298,82 @@ const syncPreviewVersion = async () => {
|
||||
|
||||
const previewData = computed(() => ({
|
||||
text: tm('t2iTemplateEditor.previewText') || '这是一个示例文本,用于预览模板效果。\n\n这里可以包含多行文本,支持换行和各种格式。',
|
||||
version: previewVersion.value
|
||||
version: previewVersion.value
|
||||
}))
|
||||
|
||||
const injectShikiRuntime = (content) => {
|
||||
if (content.includes('astrbot-t2i-shiki-runtime')) {
|
||||
return content
|
||||
}
|
||||
|
||||
const runtimeScript = getShikiRuntimeScript()
|
||||
const headClose = content.search(/<\/head\s*>/i)
|
||||
if (headClose >= 0) {
|
||||
return `${content.slice(0, headClose)} ${runtimeScript}\n${content.slice(headClose)}`
|
||||
}
|
||||
|
||||
return `${runtimeScript}\n${content}`
|
||||
}
|
||||
|
||||
const getShikiRuntimeScript = () => '<script id="astrbot-t2i-shiki-runtime" src="/t2i/shiki_runtime.iife.js"></scr' + 'ipt>'
|
||||
|
||||
const hasMarkdownSource = (content) => /<[^>]+\bid=["']markdown-source["']/i.test(content)
|
||||
|
||||
const insertMarkdownSource = (content) => {
|
||||
const sourceElement = ' <textarea id="markdown-source" hidden>{{ text | safe }}</textarea>\n'
|
||||
const markedScript = content.search(/^[ \t]*<script\s+src=["']https:\/\/cdn\.jsdelivr\.net\/npm\/marked\/marked\.min\.js["']><\/script>[ \t]*\r?\n?/im)
|
||||
if (markedScript >= 0) {
|
||||
return `${content.slice(0, markedScript)}${sourceElement}${content.slice(markedScript)}`
|
||||
}
|
||||
|
||||
const bodyClose = content.search(/<\/body\s*>/i)
|
||||
if (bodyClose >= 0) {
|
||||
return `${content.slice(0, bodyClose)}${sourceElement}${content.slice(bodyClose)}`
|
||||
}
|
||||
|
||||
return `${sourceElement}${content}`
|
||||
}
|
||||
|
||||
const normalizeMarkdownSource = (content) => {
|
||||
let normalized = content.replace(
|
||||
/<script\s+id=["']markdown-source["']\s+type=["']text\/plain["']>\s*\{\{\s*text\s*\|\s*safe\s*\}\}\s*<\/script>/gi,
|
||||
'<textarea id="markdown-source" hidden>{{ text | safe }}</textarea>'
|
||||
)
|
||||
|
||||
normalized = normalized.replace(
|
||||
/decodeBase64Utf8\("\{\{\s*text_base64\s*\}\}"\)/g,
|
||||
'document.getElementById("markdown-source").value'
|
||||
)
|
||||
normalized = normalized.replace(
|
||||
/document\.getElementById\(["']markdown-source["']\)\.textContent/g,
|
||||
'document.getElementById("markdown-source").value'
|
||||
)
|
||||
|
||||
if (/\{\{\s*text_base64\s*\}\}/.test(normalized) && !hasMarkdownSource(normalized)) {
|
||||
normalized = insertMarkdownSource(normalized)
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
const previewContent = computed(() => {
|
||||
try {
|
||||
let content = templateContent.value
|
||||
content = content.replace(/\{\{\s*text\s*\|\s*safe\s*\}\}/g, previewData.value.text)
|
||||
content = content.replace(/\{\{\s*version\s*\}\}/g, previewData.value.version)
|
||||
return content
|
||||
let content = normalizeMarkdownSource(templateContent.value)
|
||||
content = content.replace(/\{\{\s*text\s*\|\s*safe\s*\}\}/g, () => previewData.value.text)
|
||||
content = content.replace(/\{\{\s*version\s*\}\}/g, () => previewData.value.version)
|
||||
let usedLegacyShikiPlaceholder = false
|
||||
content = content.replace(/<script\b[^>]*>\s*\{\{\s*shiki_runtime\s*\|\s*safe\s*\}\}\s*<\/script>/gi, () => {
|
||||
usedLegacyShikiPlaceholder = true
|
||||
return getShikiRuntimeScript()
|
||||
})
|
||||
content = content.replace(/\{\{\s*shiki_runtime\s*\|\s*safe\s*\}\}/g, () => {
|
||||
usedLegacyShikiPlaceholder = true
|
||||
return getShikiRuntimeScript()
|
||||
})
|
||||
return usedLegacyShikiPlaceholder ? content : injectShikiRuntime(content)
|
||||
} catch (error) {
|
||||
return `<div style="color: red; padding: 20px;">模板渲染错误: ${error.message}</div>`
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
return `<div style="color: red; padding: 20px;">模板渲染错误: ${errorMessage}</div>`
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -97,7 +97,7 @@
|
||||
"livePreview": "Live Preview (may differ)",
|
||||
"refreshPreview": "Refresh Preview",
|
||||
"previewText": "This is a sample text used to preview the template output.\n\nIt can contain multiple lines and various formatting.",
|
||||
"syntaxHint": "Supports jinja2 syntax. Available variables: text | safe (text to render), version (AstrBot version)",
|
||||
"syntaxHint": "Supports jinja2 syntax. Available variables: text | safe (text to render), version (AstrBot version). If the preview test content does not load or looks wrong, click Reset Base to restore the default template.",
|
||||
"saveAndApply": "Save and Apply Current Template",
|
||||
"confirmReset": "Confirm Reset",
|
||||
"confirmResetMessage": "Are you sure you want to reset the 'base' template to default content? Any unsaved changes in the editor will be lost. This action cannot be undone.",
|
||||
|
||||
@@ -98,7 +98,7 @@
|
||||
"livePreview": "Предпросмотр (может отличаться)",
|
||||
"refreshPreview": "Обновить",
|
||||
"previewText": "Это пример текста для предпросмотра результата шаблона.\n\nОн может содержать несколько строк и различные форматы.",
|
||||
"syntaxHint": "Поддерживается синтаксис jinja2. Переменные: text | safe (текст для рендеринга), version (версия AstrBot)",
|
||||
"syntaxHint": "Поддерживается синтаксис jinja2. Переменные: text | safe (текст для рендеринга), version (версия AstrBot). Если тестовый текст предпросмотра не загружается или выглядит неверно, нажмите «Сбросить 'base'», чтобы восстановить шаблон по умолчанию.",
|
||||
"saveAndApply": "Сохранить и применить текущий шаблон",
|
||||
"confirmReset": "Подтверждение сброса",
|
||||
"confirmResetMessage": "Вы уверены, что хотите сбросить шаблон 'base' до значений по умолчанию? Все несохраненные изменения будут потеряны. Это действие необратимо.",
|
||||
@@ -109,4 +109,4 @@
|
||||
"confirmAction": "Подтверждение действия",
|
||||
"confirmApplyMessage": "Вы уверены, что хотите сохранить изменения в '{name}' и сделать его активным шаблоном?"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,7 +97,7 @@
|
||||
"livePreview": "实时预览(可能有差异)",
|
||||
"refreshPreview": "刷新预览",
|
||||
"previewText": "这是一个示例文本,用于预览模板效果。\n\n这里可以包含多行文本,支持换行和各种格式。",
|
||||
"syntaxHint": "支持 jinja2 语法。可用变量:text | safe(要渲染的文本), version(AstrBot 版本)",
|
||||
"syntaxHint": "支持 jinja2 语法。可用变量:text | safe(要渲染的文本), version(AstrBot 版本)。如果预览测试内容未加载或显示异常,请点击“重置Base”恢复默认模板。",
|
||||
"saveAndApply": "保存应用当前编辑模板",
|
||||
"confirmReset": "确认重置",
|
||||
"confirmResetMessage": "确定要将 'base' 模板恢复为默认内容吗?当前编辑器中的任何未保存更改将丢失。此操作无法撤销。",
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import { readFileSync } from 'fs';
|
||||
import { fileURLToPath, URL } from 'url';
|
||||
import { defineConfig } from 'vite';
|
||||
import { defineConfig, type Plugin } from 'vite';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
import vuetify from 'vite-plugin-vuetify';
|
||||
import webfontDl from 'vite-plugin-webfont-dl';
|
||||
// @ts-ignore — .mjs not in TS project scope; Vite resolves this at runtime
|
||||
import { runMdiSubset } from './scripts/subset-mdi-font.mjs';
|
||||
|
||||
const t2iShikiRuntimePath = fileURLToPath(
|
||||
new URL('../astrbot/core/utils/t2i/template/shiki_runtime.iife.js', import.meta.url)
|
||||
);
|
||||
|
||||
// Vite plugin: run MDI icon font subsetting (build only)
|
||||
function mdiSubset() {
|
||||
return {
|
||||
@@ -17,11 +22,45 @@ function mdiSubset() {
|
||||
};
|
||||
}
|
||||
|
||||
function t2iShikiRuntimeAsset(): Plugin {
|
||||
return {
|
||||
name: 'vite-plugin-t2i-shiki-runtime',
|
||||
configureServer(server) {
|
||||
server.middlewares.use('/t2i/shiki_runtime.iife.js', (_req, res) => {
|
||||
try {
|
||||
res.statusCode = 200;
|
||||
res.setHeader('Content-Type', 'text/javascript; charset=utf-8');
|
||||
res.end(readFileSync(t2iShikiRuntimePath));
|
||||
} catch (error) {
|
||||
res.statusCode = 404;
|
||||
res.end(`T2I Shiki runtime not found: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
});
|
||||
},
|
||||
generateBundle() {
|
||||
try {
|
||||
this.emitFile({
|
||||
type: 'asset',
|
||||
fileName: 't2i/shiki_runtime.iife.js',
|
||||
source: readFileSync(t2iShikiRuntimePath)
|
||||
});
|
||||
} catch (error) {
|
||||
this.warn(
|
||||
`Skipping T2I Shiki runtime asset because it could not be read: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig(({ command }) => ({
|
||||
plugins: [
|
||||
// Only run MDI subsetting during production builds, skip in dev server
|
||||
...(command === 'build' ? [mdiSubset()] : []),
|
||||
t2iShikiRuntimeAsset(),
|
||||
vue({
|
||||
template: {
|
||||
compilerOptions: {
|
||||
|
||||
Reference in New Issue
Block a user