From 9fbfff73162974611d8d85caa7e8e42e7666eea6 Mon Sep 17 00:00:00 2001 From: camera-2018 <40380042+camera-2018@users.noreply.github.com> Date: Sat, 25 Apr 2026 00:50:42 +0800 Subject: [PATCH 1/5] 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 --- astrbot/core/utils/t2i/network_strategy.py | 34 ++- .../t2i/template/astrbot_powershell.html | 18 +- .../utils/t2i/template/astrbot_vitepress.html | 19 +- astrbot/core/utils/t2i/template/base.html | 18 +- astrbot/core/utils/t2i/template_manager.py | 106 +++++++++ .../components/shared/T2ITemplateEditor.vue | 112 +++++++++- .../src/i18n/locales/en-US/core/shared.json | 2 +- .../src/i18n/locales/ru-RU/core/shared.json | 4 +- .../src/i18n/locales/zh-CN/core/shared.json | 2 +- dashboard/vite.config.ts | 33 ++- tests/unit/test_network_render_strategy.py | 208 ++++++++++++++++++ 11 files changed, 491 insertions(+), 65 deletions(-) create mode 100644 tests/unit/test_network_render_strategy.py diff --git a/astrbot/core/utils/t2i/network_strategy.py b/astrbot/core/utils/t2i/network_strategy.py index 5f9385614f..9b3ebe4cf0 100644 --- a/astrbot/core/utils/t2i/network_strategy.py +++ b/astrbot/core/utils/t2i/network_strategy.py @@ -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,8 @@ 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*\}\}") logger = logging.getLogger("astrbot") @@ -41,7 +43,25 @@ def get_shiki_runtime() -> str: ) return "" - return runtime.replace(" 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'' + head_close = re.search(r"", tmpl_str, flags=re.IGNORECASE) + if head_close: + return f"{tmpl_str[: head_close.start()]} {script}\n{tmpl_str[head_close.start() :]}" + + return f"{script}\n{tmpl_str}" class NetworkRenderStrategy(RenderStrategy): @@ -105,7 +125,9 @@ async def render_custom_template( 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 +180,11 @@ async def render( 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, ) diff --git a/astrbot/core/utils/t2i/template/astrbot_powershell.html b/astrbot/core/utils/t2i/template/astrbot_powershell.html index 746d06fe80..3bfa014c0c 100644 --- a/astrbot/core/utils/t2i/template/astrbot_powershell.html +++ b/astrbot/core/utils/t2i/template/astrbot_powershell.html @@ -174,14 +174,14 @@
- + diff --git a/astrbot/core/utils/t2i/template/astrbot_vitepress.html b/astrbot/core/utils/t2i/template/astrbot_vitepress.html index 381da3f98c..4e02636968 100644 --- a/astrbot/core/utils/t2i/template/astrbot_vitepress.html +++ b/astrbot/core/utils/t2i/template/astrbot_vitepress.html @@ -415,14 +415,14 @@

AstrBot Docs

- + + diff --git a/astrbot/core/utils/t2i/template_manager.py b/astrbot/core/utils/t2i/template_manager.py index c4d72f3e42..64a098bcfc 100644 --- a/astrbot/core/utils/t2i/template_manager.py +++ b/astrbot/core/utils/t2i/template_manager.py @@ -1,6 +1,7 @@ # astrbot/core/utils/t2i/template_manager.py import os +import re import shutil from astrbot.core.utils.astrbot_path import get_astrbot_data_path, get_astrbot_path @@ -43,6 +44,111 @@ def _copy_core_templates(self, overwrite: bool = False) -> None: def _initialize_user_templates(self) -> None: """如果用户目录下缺少核心模板,则进行复制。""" self._copy_core_templates(overwrite=False) + self._migrate_legacy_template_variables() + + def _migrate_legacy_template_variables(self) -> None: + """Migrate legacy template variables hidden in user templates.""" + for filename in os.listdir(self.user_template_dir): + if not filename.endswith(".html"): + continue + + path = os.path.join(self.user_template_dir, filename) + content = self._read_file(path) + migrated_content = self._migrate_legacy_template_content(content) + if migrated_content != content: + with open(path, "w", encoding="utf-8") as f: + f.write(migrated_content) + + @staticmethod + def _migrate_legacy_template_content(content: str) -> str: + had_legacy_text = bool( + re.search( + r'decodeBase64Utf8\("\{\{\s*text_base64\s*\}\}"\)', + content, + ), + ) + content = re.sub( + r"^[ \t]*[ \t]*\r?\n?", + "", + content, + flags=re.MULTILINE, + ) + content = re.sub( + r'decodeBase64Utf8\("\{\{\s*text_base64\s*\}\}"\)', + 'document.getElementById("markdown-source").value', + content, + ) + content = re.sub( + r"\s*\{\{\s*text\s*\|\s*safe\s*\}\}\s*", + '', + content, + flags=re.IGNORECASE, + ) + content = content.replace( + 'document.getElementById("markdown-source").textContent', + 'document.getElementById("markdown-source").value', + ) + content = TemplateManager._remove_decode_base64_utf8_helper(content) + if had_legacy_text and not TemplateManager._has_markdown_source(content): + content = TemplateManager._insert_markdown_source_element(content) + return content + + @staticmethod + def _has_markdown_source(content: str) -> bool: + return bool( + re.search(r"<[a-z][^>]*\bid=[\"']markdown-source[\"']", content), + ) + + @staticmethod + def _insert_markdown_source_element(content: str) -> str: + source_element = ( + ' \n' + ) + marked_script = re.search( + r"^[ \t]*[ \t]*\r?\n?", + content, + flags=re.MULTILINE, + ) + if marked_script: + return ( + f"{content[: marked_script.start()]}" + f"{source_element}" + f"{content[marked_script.start() :]}" + ) + + body_close = re.search(r"", content, flags=re.IGNORECASE) + if body_close: + return ( + f"{content[: body_close.start()]}" + f"{source_element}" + f"{content[body_close.start() :]}" + ) + + return f"{source_element}{content}" + + @staticmethod + def _remove_decode_base64_utf8_helper(content: str) -> str: + lines = content.splitlines(keepends=True) + migrated_lines: list[str] = [] + index = 0 + while index < len(lines): + line = lines[index] + if "function decodeBase64Utf8(base64Text)" not in line: + migrated_lines.append(line) + index += 1 + continue + + depth = 0 + while index < len(lines): + depth += lines[index].count("{") - lines[index].count("}") + index += 1 + if depth <= 0: + break + + if migrated_lines and not migrated_lines[-1].strip(): + migrated_lines.pop() + + return "".join(migrated_lines) def _get_user_template_path(self, name: str) -> str: """获取用户模板的完整路径,防止路径遍历漏洞。""" diff --git a/dashboard/src/components/shared/T2ITemplateEditor.vue b/dashboard/src/components/shared/T2ITemplateEditor.vue index eac49c7601..05e66cee1b 100644 --- a/dashboard/src/components/shared/T2ITemplateEditor.vue +++ b/dashboard/src/components/shared/T2ITemplateEditor.vue @@ -284,6 +284,42 @@ const editorOptions = { // --- 预览逻辑 --- const previewVersion = ref('v4.0.0') +const defaultPreviewText = [ + '# AstrBot T2I 预览', + '', + '> 用来检查 Markdown、代码高亮、表格、数学公式和多行文本。', + '', + '## 渲染清单', + '', + '- [x] 标题和列表', + '- [x] Shiki 代码高亮', + '- [x] 表格和引用', + '- [x] 行内公式 $E = mc^2$', + '', + '| 模块 | 状态 | 说明 |', + '| --- | --- | --- |', + '| Markdown | 正常 | 支持粗体、列表、表格 |', + '| Shiki | 待验证 | 下面的 Python 代码应被高亮 |', + '', + '```python', + 'from astrbot.api.event import filter', + '', + "@filter.command('hello')", + 'async def hello(event):', + ' name = event.get_sender_name()', + " yield event.plain_result(f'Hello, {name}!')", + '```', + '', + '```yaml', + 'provider: openai', + 'features:', + ' - markdown', + ' - shiki', + ' - katex', + '```', + '', + '渲染结束:如果代码块有颜色、表格对齐、公式正常,模板预览就基本可用。' +].join('\n') const syncPreviewVersion = async () => { try { const res = await axios.get('/api/stat/version') @@ -297,16 +333,80 @@ const syncPreviewVersion = async () => { } const previewData = computed(() => ({ - text: tm('t2iTemplateEditor.previewText') || '这是一个示例文本,用于预览模板效果。\n\n这里可以包含多行文本,支持换行和各种格式。', - version: previewVersion.value + text: tm('t2iTemplateEditor.previewText') || defaultPreviewText, + 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 = () => '" + result = await strategy.render(text, return_url=True) + + assert result == "rendered.png" + strategy.render_custom_template.assert_awaited_once() + _, tmpl_data, return_url = strategy.render_custom_template.await_args.args + assert tmpl_data["text"] == text + assert "text_base64" not in tmpl_data + assert return_url is True + + +def test_builtin_templates_read_legacy_text_from_hidden_textarea() -> None: + for template_name in ( + "base.html", + "astrbot_powershell.html", + "astrbot_vitepress.html", + ): + content = (TEMPLATE_DIR / template_name).read_text(encoding="utf-8") + assert '', - content, - flags=re.IGNORECASE, - ) - content = content.replace( - 'document.getElementById("markdown-source").textContent', - 'document.getElementById("markdown-source").value', - ) - content = TemplateManager._remove_decode_base64_utf8_helper(content) - if had_legacy_text and not TemplateManager._has_markdown_source(content): - content = TemplateManager._insert_markdown_source_element(content) - return content - - @staticmethod - def _has_markdown_source(content: str) -> bool: - return bool( - re.search(r"<[a-z][^>]*\bid=[\"']markdown-source[\"']", content), - ) - - @staticmethod - def _insert_markdown_source_element(content: str) -> str: - source_element = ( - ' \n' - ) - marked_script = re.search( - r"^[ \t]*[ \t]*\r?\n?", - content, - flags=re.MULTILINE, - ) - if marked_script: - return ( - f"{content[: marked_script.start()]}" - f"{source_element}" - f"{content[marked_script.start() :]}" - ) - - body_close = re.search(r"", content, flags=re.IGNORECASE) - if body_close: - return ( - f"{content[: body_close.start()]}" - f"{source_element}" - f"{content[body_close.start() :]}" - ) - - return f"{source_element}{content}" - - @staticmethod - def _remove_decode_base64_utf8_helper(content: str) -> str: - lines = content.splitlines(keepends=True) - migrated_lines: list[str] = [] - index = 0 - while index < len(lines): - line = lines[index] - if "function decodeBase64Utf8(base64Text)" not in line: - migrated_lines.append(line) - index += 1 - continue - - depth = 0 - while index < len(lines): - depth += lines[index].count("{") - lines[index].count("}") - index += 1 - if depth <= 0: - break - - if migrated_lines and not migrated_lines[-1].strip(): - migrated_lines.pop() - - return "".join(migrated_lines) def _get_user_template_path(self, name: str) -> str: """获取用户模板的完整路径,防止路径遍历漏洞。""" diff --git a/dashboard/src/components/shared/T2ITemplateEditor.vue b/dashboard/src/components/shared/T2ITemplateEditor.vue index 05e66cee1b..fc9ae4b0ae 100644 --- a/dashboard/src/components/shared/T2ITemplateEditor.vue +++ b/dashboard/src/components/shared/T2ITemplateEditor.vue @@ -408,7 +408,8 @@ const previewContent = computed(() => { }) return usedLegacyShikiPlaceholder ? content : injectShikiRuntime(content) } catch (error) { - return `
模板渲染错误: ${error.message}
` + const errorMessage = error instanceof Error ? error.message : String(error) + return `
模板渲染错误: ${errorMessage}
` } }) diff --git a/dashboard/src/i18n/locales/en-US/core/shared.json b/dashboard/src/i18n/locales/en-US/core/shared.json index 0a93849d5c..414732ce0a 100644 --- a/dashboard/src/i18n/locales/en-US/core/shared.json +++ b/dashboard/src/i18n/locales/en-US/core/shared.json @@ -97,7 +97,7 @@ "livePreview": "Live Preview (may differ)", "refreshPreview": "Refresh Preview", "previewText": "# AstrBot T2I Preview\n\n> Use this to check Markdown, syntax highlighting, tables, math, and multiline text.\n\n## Render Checklist\n\n- [x] Headings and lists\n- [x] Shiki syntax highlighting\n- [x] Tables and blockquotes\n- [x] Inline math $E = mc^2$\n\n| Module | Status | Notes |\n| --- | --- | --- |\n| Markdown | OK | Supports bold text, lists, and tables |\n| Shiki | Verify | The Python code below should be highlighted |\n\n```python\nfrom astrbot.api.event import filter\n\n@filter.command('hello')\nasync def hello(event):\n name = event.get_sender_name()\n yield event.plain_result(f'Hello, {name}!')\n```\n\n```yaml\nprovider: openai\nfeatures:\n - markdown\n - shiki\n - katex\n```\n\nRender complete: if code blocks are colored, tables align, and math renders, the template preview is basically working.", - "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.", diff --git a/dashboard/src/i18n/locales/ru-RU/core/shared.json b/dashboard/src/i18n/locales/ru-RU/core/shared.json index acf28f8e11..5c133268de 100644 --- a/dashboard/src/i18n/locales/ru-RU/core/shared.json +++ b/dashboard/src/i18n/locales/ru-RU/core/shared.json @@ -98,7 +98,7 @@ "livePreview": "Предпросмотр (может отличаться)", "refreshPreview": "Обновить", "previewText": "# Предпросмотр AstrBot T2I\n\n> Используйте этот текст, чтобы проверить Markdown, подсветку синтаксиса, таблицы, формулы и многострочный текст.\n\n## Список проверки рендера\n\n- [x] Заголовки и списки\n- [x] Подсветка синтаксиса Shiki\n- [x] Таблицы и цитаты\n- [x] Строчная формула $E = mc^2$\n\n| Модуль | Статус | Примечание |\n| --- | --- | --- |\n| Markdown | OK | Поддерживает жирный текст, списки и таблицы |\n| Shiki | Проверить | Код Python ниже должен быть подсвечен |\n\n```python\nfrom astrbot.api.event import filter\n\n@filter.command('hello')\nasync def hello(event):\n name = event.get_sender_name()\n yield event.plain_result(f'Hello, {name}!')\n```\n\n```yaml\nprovider: openai\nfeatures:\n - markdown\n - shiki\n - katex\n```\n\nРендер завершен: если код подсвечен, таблицы выровнены, а формулы отображаются, предпросмотр шаблона в целом работает.", - "syntaxHint": "Поддерживается синтаксис jinja2. Переменные: text | safe (текст для рендеринга), version (версия AstrBot)", + "syntaxHint": "Поддерживается синтаксис jinja2. Переменные: text | safe (текст для рендеринга), version (версия AstrBot). Если тестовый текст предпросмотра не загружается или выглядит неверно, нажмите «Сбросить 'base'», чтобы восстановить шаблон по умолчанию.", "saveAndApply": "Сохранить и применить текущий шаблон", "confirmReset": "Подтверждение сброса", "confirmResetMessage": "Вы уверены, что хотите сбросить шаблон 'base' до значений по умолчанию? Все несохраненные изменения будут потеряны. Это действие необратимо.", diff --git a/dashboard/src/i18n/locales/zh-CN/core/shared.json b/dashboard/src/i18n/locales/zh-CN/core/shared.json index 5d5f85aee1..5b8b6be575 100644 --- a/dashboard/src/i18n/locales/zh-CN/core/shared.json +++ b/dashboard/src/i18n/locales/zh-CN/core/shared.json @@ -97,7 +97,7 @@ "livePreview": "实时预览(可能有差异)", "refreshPreview": "刷新预览", "previewText": "# AstrBot T2I 预览\n\n> 用来检查 Markdown、代码高亮、表格、数学公式和多行文本。\n\n## 渲染清单\n\n- [x] 标题和列表\n- [x] Shiki 代码高亮\n- [x] 表格和引用\n- [x] 行内公式 $E = mc^2$\n\n| 模块 | 状态 | 说明 |\n| --- | --- | --- |\n| Markdown | 正常 | 支持粗体、列表、表格 |\n| Shiki | 待验证 | 下面的 Python 代码应被高亮 |\n\n```python\nfrom astrbot.api.event import filter\n\n@filter.command('hello')\nasync def hello(event):\n name = event.get_sender_name()\n yield event.plain_result(f'Hello, {name}!')\n```\n\n```yaml\nprovider: openai\nfeatures:\n - markdown\n - shiki\n - katex\n```\n\n渲染结束:如果代码块有颜色、表格对齐、公式正常,模板预览就基本可用。", - "syntaxHint": "支持 jinja2 语法。可用变量:text | safe(要渲染的文本), version(AstrBot 版本)", + "syntaxHint": "支持 jinja2 语法。可用变量:text | safe(要渲染的文本), version(AstrBot 版本)。如果预览测试内容未加载或显示异常,请点击“重置Base”恢复默认模板。", "saveAndApply": "保存应用当前编辑模板", "confirmReset": "确认重置", "confirmResetMessage": "确定要将 'base' 模板恢复为默认内容吗?当前编辑器中的任何未保存更改将丢失。此操作无法撤销。", diff --git a/dashboard/vite.config.ts b/dashboard/vite.config.ts index 4585f966ff..05eaff1d3d 100644 --- a/dashboard/vite.config.ts +++ b/dashboard/vite.config.ts @@ -38,11 +38,19 @@ function t2iShikiRuntimeAsset(): Plugin { }); }, generateBundle() { - this.emitFile({ - type: 'asset', - fileName: 't2i/shiki_runtime.iife.js', - source: readFileSync(t2iShikiRuntimePath) - }); + 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) + }` + ); + } } }; } diff --git a/tests/unit/test_network_render_strategy.py b/tests/unit/test_network_render_strategy.py index 1cf97c371a..8acfb22b1f 100644 --- a/tests/unit/test_network_render_strategy.py +++ b/tests/unit/test_network_render_strategy.py @@ -10,7 +10,6 @@ NetworkRenderStrategy, inject_shiki_runtime, ) -from astrbot.core.utils.t2i.template_manager import TemplateManager TEMPLATE_DIR = ( Path(__file__).resolve().parents[2] @@ -62,74 +61,6 @@ def test_builtin_templates_read_legacy_text_from_hidden_textarea() -> None: assert "{{ shiki_runtime | safe }}" not in content -def test_template_manager_migrates_legacy_user_template( - temp_astrbot_root: Path, -) -> None: - user_template_dir = temp_astrbot_root / "data" / "t2i_templates" - user_template_dir.mkdir(parents=True) - stale_template = user_template_dir / "astrbot_vitepress.html" - stale_template.write_text( - """ - - - - - -""", - encoding="utf-8", - ) - - manager = TemplateManager() - - content = manager.get_template("astrbot_vitepress") - assert "{{ text | safe }}" in content - assert '