"""
ui_nicegui.py
─────────────
NiceGUI 前端层(重构版)。
▸ 只负责渲染 / 交互。
▸ 调用 core.engine.DanbooruTagger,通过 core.models 的数据结构通信。
▸ 不包含任何算法逻辑。
▸ 平台相关配置(host/port/云端判断)统一由 platform_utils 提供。
"""
import sys
sys.stdout.reconfigure(line_buffering=True)
print("[UI] 脚本开始执行", flush=True)
import asyncio
import os
import json as _json
import subprocess
import traceback
from dataclasses import asdict
from fastapi.responses import PlainTextResponse
def _excepthook(exc_type, exc_value, exc_tb):
print("[UI] FATAL ERROR ON STARTUP:", flush=True)
traceback.print_exception(exc_type, exc_value, exc_tb)
sys.__excepthook__(exc_type, exc_value, exc_tb)
sys.excepthook = _excepthook
from nicegui import ui, app, run
from core import counter
from api_fastapi import app as api_app
from core.engine import DanbooruTagger
from core.models import RelatedTag, SearchRequest
from platform_utils import is_cloud, get_host_port, nsfw_allowed
from mcp_server import mcp
import logging
logging.getLogger("httpx").setLevel(logging.WARNING)
logging.getLogger("huggingface_hub").setLevel(logging.WARNING)
logging.getLogger("mcp").setLevel(logging.WARNING)
logging.getLogger("mcp.server").setLevel(logging.WARNING)
logging.getLogger("fastmcp").setLevel(logging.WARNING)
# suppress MCP streamable-HTTP transport noise ("No response returned" from Starlette middleware)
class _SuppressMCPNoise(logging.Filter):
_MARKER = "No response returned"
def filter(self, record: logging.LogRecord) -> bool:
if self._MARKER in record.getMessage():
return False
if record.exc_info:
import traceback
tb_text = "".join(traceback.format_exception(*record.exc_info))
if self._MARKER in tb_text:
return False
return True
logging.getLogger("uvicorn.error").addFilter(_SuppressMCPNoise())
# suppress MCP OAuth discovery 404 noise (clients probing .well-known/oauth-authorization-server)
class _SuppressOAuthNoise(logging.Filter):
_MARKER = ".well-known/oauth-authorization-server"
def filter(self, record: logging.LogRecord) -> bool:
if self._MARKER in record.getMessage():
return False
return True
logging.getLogger("uvicorn.access").addFilter(_SuppressOAuthNoise())
logging.getLogger("nicegui").addFilter(_SuppressOAuthNoise())
# ── 表格列定义 ─────────────────────────────────────────────────────────────────
TABLE_COLUMNS = [
{'name': 'tag', 'label': '匹配标签', 'field': 'tag', 'align': 'left', 'sortable': True},
{'name': 'cn_name', 'label': '含义', 'field': 'cn_name', 'align': 'left'},
{'name': 'nsfw', 'label': '分级', 'field': 'nsfw', 'align': 'center', 'sortable': True},
{'name': 'final_score', 'label': '综合分', 'field': 'final_score', 'sortable': True},
{'name': 'count', 'label': '热度', 'field': 'count', 'sortable': True},
]
OPTIONAL_COLS = {
'semantic': {'name': 'semantic_score', 'label': '语义分', 'field': 'semantic_score', 'sortable': True},
'layer': {'name': 'layer', 'label': '匹配层', 'field': 'layer'},
'source': {'name': 'source', 'label': '匹配来源', 'field': 'source'},
}
# localStorage key 与配置版本,版本变更时自动丢弃旧配置
_CONFIG_LS_KEY = 'danbooru_search_config'
_CONFIG_VERSION = 5
# 搜索模式预设
_SEARCH_MODE_PRESETS: dict[str, dict] = {
'精确查词': {'top_k': 20, 'limit': 10, 'popularity_weight': 0.15, 'use_segmentation': False, 'group_mode': 'off', 'max_per_group': 2},
'概念扩展': {'top_k': 80, 'limit': 80, 'popularity_weight': 0.15, 'use_segmentation': True, 'group_mode': 'expand', 'max_per_group': 2},
'描述查词': {'top_k': 20, 'limit': 20, 'popularity_weight': 0.15, 'use_segmentation': False, 'group_mode': 'off', 'max_per_group': 2},
'完整场景': {'top_k': 5, 'limit': 80, 'popularity_weight': 0.15, 'use_segmentation': True, 'group_mode': 'diverse', 'max_per_group': 2},
}
_SEARCH_MODE_OPTIONS = ['自定义'] + list(_SEARCH_MODE_PRESETS.keys())
# ── 辅助函数 ───────────────────────────────────────────────────────────────────
def _get_git_commit() -> str:
try:
return subprocess.check_output(
['git', 'rev-parse', '--short', 'HEAD'],
stderr=subprocess.DEVNULL,
text=True,
).strip()
except Exception:
return os.environ.get('COMMIT_SHA', 'unknown')[:7]
def result_to_row(r, nsfw_visible: bool) -> dict:
d = asdict(r)
d['_nsfw_blocked'] = (r.nsfw == '1') and not nsfw_visible
return d
def apply_nsfw_filter(rows: list[dict], show_nsfw: bool) -> list[dict]:
result = []
for row in rows:
r = dict(row)
r['_nsfw_blocked'] = (r.get('nsfw') == '1') and not show_nsfw
result.append(r)
return result
def _format_tag_with_weight(tag: str, weight: float, fmt: str = 'sdxl') -> str:
"""格式化单个标签。
sdxl: (tag:1.2) 权重 1.0 时输出 tag
nai: 1.2::tag:: 权重 1.0 时输出 tag
"""
if weight == 1.0:
return tag
if fmt == 'nai':
return f'{weight:.1f}::{tag}::'
return f'({tag}:{weight:.1f})'
# ── UI 类 ─────────────────────────────────────────────────────────────────────
class DanbooruSearchUI:
def __init__(self):
self.search_count_label = None
self.current_search_interacted = True
self.full_table_data: list[dict] = []
self.current_segments: list[str] = [] # 从句级原始片段,用于区分 chip 颜色
self.current_filter_keyword: str = 'ALL' # 当前选中的分词筛选 keyword(NSFW 切换时复用)
self.current_query_str: str = ""
self.full_tags_str: str = ""
self.full_tags_str_sfw: str = ""
self.result_table = None # 左栏表格
self.related_list_container = None # 右栏关联推荐列表
self.group_expansion_container = None # 左栏 Group 同类扩展(表格下方)
self.results_section = None # 整个结果区域(搜索前隐藏)
self.selection_count_label = None
self.selected_display = None # 已废弃 textarea,保留兼容
self.selected_chips_container = None # 已选标签 chip 容器
self.current_related: list = []
self.chip_extra_selected: set = set()
# 去抖任务句柄(取消旧任务避免 CPU 洪峰)
self._debounce_related_task = None # type: asyncio.Task | None
self._debounce_group_task = None # type: asyncio.Task | None
# tag -> prompt 权重,范围 [0.1, 1.9],默认 1.0
self.tag_weights: dict[str, float] = {}
# 复制格式:'sdxl' 或 'nai'
self.prompt_format: str = 'sdxl'
self.format_toggle_btn = None
self.init_banner = None
self.input_top_k = None
self.input_limit = None
self.input_weight = None
self.input_nsfw = None
self.input_segment = None
self.input_search_mode = None
self.input_group_mode = None
self.input_max_per_group = None
self._applying_preset = False
self.search_input = None
self.keywords_container = None
self.spinner = None
self.search_btn = None
self.selected_layers = {'英文': True, '中文扩展词': True, '释义': True, '中文核心词': True}
self.selected_cats = {'General': True, 'Copyright': True, 'Character': True}
self.bad_case_btn = None
self.mcp_notice = None
self.notice_expansion = None
# 表格显示选项开关
self.sw_semantic = None
self.sw_layer = None
self.sw_source = None
# 关联推荐的 checkbox 引用
self._related_checkboxes: dict[str, ui.checkbox] = {}
# 同类标签的 checkbox 引用
self._group_checkboxes: dict[str, ui.checkbox] = {}
# 高级选项中各层/类型的 checkbox 引用,用于 restore 时同步控件状态
self._layer_checkboxes: dict[str, ui.checkbox] = {}
self._cat_checkboxes: dict[str, ui.checkbox] = {}
def _update_footer_text(self):
if self.search_count_label is not None:
try:
total = counter.get()
visits = counter.get_visits()
commit = _get_git_commit()
self.search_count_label.content = (
f'累计搜索 {total:,} 次 | 累计访问 {visits:,} 次 | '
f'版本号: {commit}'
f'
'
f'使用 API 服务'
f' | 使用 MCP 服务'
)
except AttributeError:
pass
def _mark_interaction(self, e=None):
if not self.current_search_interacted:
self.current_search_interacted = True
async def silent_success_update():
try:
await counter.increment_success()
except Exception:
pass
asyncio.create_task(silent_success_update())
# ── 分页辅助 ──────────────────────────────────────────────────────────
def _get_rows_per_page(self) -> int:
if self.result_table is None:
return 0
p = self.result_table.pagination
# pagination 可能是 int 或 dict
if isinstance(p, dict):
return int(p.get('rowsPerPage', 0))
return int(p) if p else 0
def _set_rows_per_page(self, value: int):
if self.result_table is None:
return
allowed = {5, 7, 10, 15, 20, 25, 50, 0} # 0 = All
value = value if value in allowed else 0
p = self.result_table.pagination
if isinstance(p, dict):
p['rowsPerPage'] = value
self.result_table.pagination = p
else:
self.result_table.pagination = value
# ── 配置持久化 ────────────────────────────────────────────────────────
def _save_config(self):
"""将当前控件状态序列化并写入 localStorage。"""
cfg = {
'version': _CONFIG_VERSION,
'top_k': int(self.input_top_k.value) if self.input_top_k else 10,
'limit': int(self.input_limit.value) if self.input_limit else 80,
'popularity_weight': float(self.input_weight.value) if self.input_weight else 0.15,
'show_nsfw': bool(self.input_nsfw.value) if self.input_nsfw else False,
'use_segmentation': bool(self.input_segment.value) if self.input_segment else True,
'selected_layers': dict(self.selected_layers),
'selected_cats': dict(self.selected_cats),
'sw_semantic': bool(self.sw_semantic.value) if self.sw_semantic else False,
'sw_layer': bool(self.sw_layer.value) if self.sw_layer else False,
'sw_source': bool(self.sw_source.value) if self.sw_source else False,
'prompt_format': self.prompt_format,
'rows_per_page': self._get_rows_per_page(),
'search_query': self.search_input.value if self.search_input else '',
'notice_expanded': bool(self.notice_expansion.value) if self.notice_expansion else True,
'mcp_notice_dismissed': not bool(self.mcp_notice.visible) if self.mcp_notice else False,
'search_mode': self.input_search_mode.value if self.input_search_mode else '自定义',
'group_mode': self.input_group_mode.value if self.input_group_mode else 'off',
'max_per_group': int(self.input_max_per_group.value) if self.input_max_per_group else 2,
}
js = _json.dumps(cfg, ensure_ascii=False)
ui.run_javascript(f"localStorage.setItem('{_CONFIG_LS_KEY}', {_json.dumps(js)});")
async def _restore_config(self):
"""从 localStorage 读取配置并恢复控件状态。"""
try:
if getattr(ui.context.client, '_deleted', False):
return
raw = await ui.run_javascript(
f"localStorage.getItem('{_CONFIG_LS_KEY}');",
timeout=5.0,
)
except Exception:
return
if not raw:
return
try:
cfg = _json.loads(raw)
except Exception:
return
if cfg.get('version') != _CONFIG_VERSION:
# 版本不符,丢弃旧配置
ui.run_javascript(f"localStorage.removeItem('{_CONFIG_LS_KEY}');")
return
# 恢复搜索模式(会触发预设填充,但 _applying_preset 防止联动覆盖)
if self.input_search_mode and 'search_mode' in cfg:
self.input_search_mode.set_value(cfg['search_mode'])
if self.input_top_k and 'top_k' in cfg:
self.input_top_k.set_value(cfg['top_k'])
if self.input_limit and 'limit' in cfg:
self.input_limit.set_value(cfg['limit'])
if self.input_weight and 'popularity_weight' in cfg:
self.input_weight.set_value(cfg['popularity_weight'])
if self.input_segment and 'use_segmentation' in cfg:
self.input_segment.set_value(cfg['use_segmentation'])
if self.input_group_mode and 'group_mode' in cfg:
self.input_group_mode.set_value(cfg['group_mode'])
if self.input_max_per_group and 'max_per_group' in cfg:
self.input_max_per_group.set_value(cfg['max_per_group'])
# NSFW:仅在平台允许时恢复
if nsfw_allowed() and self.input_nsfw and 'show_nsfw' in cfg:
self.input_nsfw.set_value(cfg['show_nsfw'])
if 'selected_layers' in cfg:
for layer, val in cfg['selected_layers'].items():
if layer in self.selected_layers:
self.selected_layers[layer] = bool(val)
if layer in self._layer_checkboxes:
self._layer_checkboxes[layer].set_value(bool(val))
if 'selected_cats' in cfg:
for cat, val in cfg['selected_cats'].items():
if cat in self.selected_cats:
self.selected_cats[cat] = bool(val)
if cat in self._cat_checkboxes:
self._cat_checkboxes[cat].set_value(bool(val))
if self.sw_semantic and 'sw_semantic' in cfg:
self.sw_semantic.set_value(cfg['sw_semantic'])
if self.sw_layer and 'sw_layer' in cfg:
self.sw_layer.set_value(cfg['sw_layer'])
if self.sw_source and 'sw_source' in cfg:
self.sw_source.set_value(cfg['sw_source'])
if 'prompt_format' in cfg and cfg['prompt_format'] in ('sdxl', 'nai'):
self.prompt_format = cfg['prompt_format']
if self.format_toggle_btn:
if self.prompt_format == 'nai':
self.format_toggle_btn.text = 'NAI'
self.format_toggle_btn.props('color=purple-7')
else:
self.format_toggle_btn.text = 'SDXL'
self.format_toggle_btn.props('color=grey-7')
if 'rows_per_page' in cfg:
self._set_rows_per_page(cfg['rows_per_page'])
if self.search_input and cfg.get('search_query'):
self.search_input.set_value(cfg['search_query'])
if self.notice_expansion and 'notice_expanded' in cfg:
self.notice_expansion.set_value(cfg['notice_expanded'])
if self.mcp_notice and cfg.get('mcp_notice_dismissed'):
self.mcp_notice.set_visibility(False)
# 若高级选项列有变更,同步更新表格列
self._update_table_columns()
# ══════════════════════════════════════════════════════════════════════
# 页面构建
# ══════════════════════════════════════════════════════════════════════
def build_page(self):
ui.colors(primary='#4A90E2', secondary='#5E6C84', accent='#FF6B6B')
ui.add_head_html('''
''')
with ui.column().classes('w-full max-w-7xl mx-auto p-4 gap-4'):
# ── 初始化提示 ──
self.init_banner = ui.card().classes(
'w-full bg-blue-50 border-l-4 border-blue-400'
)
with self.init_banner:
with ui.row().classes('items-center gap-3 p-2'):
ui.spinner(size='sm')
ui.label('引擎初始化中,请稍候…约需 5~10 分钟').classes('text-sm text-blue-700')
from platform_utils import PLATFORM
_alt_url = (
'https://www.modelscope.cn/studios/SAkizuki/DanbooruSearchOnline'
if PLATFORM == 'hf' else
'https://huggingface.co/spaces/SAkizuki/DanbooruSearch'
)
ui.html(
f'初始化期间,您可以使用'
f'备用服务'
).classes('text-xs text-blue-600 px-6 pb-3')
self.init_banner.set_visibility(not DanbooruTagger.is_ready())
if not DanbooruTagger.is_ready():
asyncio.ensure_future(self._hide_banner_when_ready())
# ── 0. 公告栏(同类标签 + MCP)──
self._build_group_notice()
# ── 1. 注意事项 ──
self._build_notice()
# ── 2. 搜索卡片 ──
self._build_search_card()
# ── 3~5. 结果区域(搜索前隐藏)──
self.results_section = ui.column().classes('w-full gap-4')
self.results_section.set_visibility(False)
with self.results_section:
# ── 3. 已选标签栏 ──
self._build_selection_bar()
# ── 4. 分词筛选 chips ──
self.keywords_container = ui.row().classes('gap-2 items-center flex-wrap')
# ── 5. 两栏结果 ──
self._build_results_columns()
# ── 6. 底部 ──
with ui.element('div').classes('w-full text-center py-4 mt-2'):
self.search_count_label = ui.html('正在加载数据...').classes('text-xs text-gray-400')
self._update_footer_text()
# ── MCP 上线通知 ──────────────────────────────────────────────────────
def _build_group_notice(self):
self.mcp_notice = ui.card().classes(
'w-full bg-green-50 border-l-4 border-green-500 p-0 overflow-hidden'
)
with self.mcp_notice:
with ui.column().classes('px-4 py-3 w-full gap-2'):
with ui.row().classes('items-center justify-between w-full'):
ui.label('🧪 新功能:标签组扩展(beta)').classes('text-sm font-bold text-green-800')
ui.button(icon='close').props('flat dense round color=grey-6') \
.on_click(self._dismiss_mcp_notice)
ui.html(
'勾选标签后,搜索结果下方会出现同类标签区域,'
'展示已选标签所属分组中的其他标签。勾选复选框即可加入已选,'
'选中的标签会加入已选列表,可直接复制为 Prompt。'
).classes('text-xs text-green-900')
ui.separator().classes('my-1')
ui.html(
'【MCP 服务已上线】 支持通过 MCP 协议接入 AI Agent(如 Claude Desktop)。'
'免配置托管版体验:'
'问秋月 Space,'
'API 额度有限,仅供体验。'
' '
'接入文档 →'
).classes('text-xs text-green-900')
def _dismiss_mcp_notice(self):
if self.mcp_notice:
self.mcp_notice.set_visibility(False)
self._save_config()
# ── 注意事项 ──────────────────────────────────────────────────────────
def _build_notice(self):
with ui.card().classes('w-full bg-orange-50 border-l-4 border-orange-500 p-0 overflow-hidden'):
with ui.expansion(value=True).classes('w-full') as notice_expansion:
self.notice_expansion = notice_expansion
notice_expansion.on('update:model-value', lambda _: self._save_config())
notice_expansion.add_slot('header', '''
(tag:1.2) 格式。'
).style('font-size:14px;line-height:1.6;')
with ui.row().classes('items-center gap-2'):
with ui.button('没搜到?', icon='help_outline').props('dense flat color=grey-6').classes('text-sm') as _bad_btn:
with ui.tooltip().props('content-class="bg-black text-white shadow-4"'):
ui.html('点击此处以反馈失败案例。(tag:1.2)1.2::tag::'
).style('font-size:13px;line-height:1.7;')
self.format_toggle_btn.on_click(self._toggle_prompt_format)
clear_btn = ui.button('清空已选', icon='delete_sweep').props('dense flat color=red-7').classes('text-xs')
clear_btn.on_click(self._clear_all_staged)
copy_btn = ui.button('复制选中', icon='content_copy').props('dense unelevated color=primary')
copy_btn.on_click(self.copy_selection)
# chip 容器:每个已选标签渲染为一个带加减按钮的 chip
self.selected_chips_container = ui.element('div').classes(
'w-full mt-2 min-h-10 p-1 rounded bg-white border border-blue-100 flex flex-wrap'
)
def _render_selected_chips(self):
"""重新渲染已选标签的 chip 列表。"""
if self.selected_chips_container is None:
return
self.selected_chips_container.clear()
tags = self._get_selected_tags()
if not tags:
with self.selected_chips_container:
ui.label('暂无已选标签').classes('text-xs text-gray-400 italic p-2 self-center')
return
with self.selected_chips_container:
for tag in tags:
w = self.tag_weights.get(tag, 1.0)
extra_cls = 'boosted' if w > 1.0 else ('reduced' if w < 1.0 else '')
w_str = f'{w:.1f}'
with ui.element('div').classes(f'weight-chip {extra_cls}'):
# 删除按钮(×)
with ui.element('button').classes('weight-btn').props(f'title="移除 {tag}"').on(
'click', lambda t=tag: self._remove_selected_tag(t)
):
ui.html('×')
# 减号
with ui.element('button').classes('weight-btn').on(
'click', lambda t=tag: self._adjust_weight(t, -0.1)
):
ui.html('−')
# 标签名
ui.label(tag).style(
'font-family:Consolas,Monaco,monospace;font-size:12px;'
'color:#2c5282;max-width:150px;overflow:hidden;'
'text-overflow:ellipsis;white-space:nowrap;'
)
# 权重值(仅非 1.0 时显示)
if w != 1.0:
ui.label(w_str).classes('weight-label').style('color:#e65100;font-weight:bold;')
# 加号
with ui.element('button').classes('weight-btn').on(
'click', lambda t=tag: self._adjust_weight(t, +0.1)
):
ui.html('+')
def _adjust_weight(self, tag: str, delta: float):
"""调整单个标签权重,钳位到 [0.1, 1.9]。"""
current = self.tag_weights.get(tag, 1.0)
new_w = round(current + delta, 1)
if new_w < 0.1:
ui.notify('权重范围为 0.1 ~ 1.9,已到达最小值', type='warning', timeout=2000)
return
if new_w > 1.9:
ui.notify('权重范围为 0.1 ~ 1.9,已到达最大值', type='warning', timeout=2000)
return
self.tag_weights[tag] = new_w
self._save_staged_tags()
self._render_selected_chips()
def _remove_selected_tag(self, tag: str):
"""从已选中移除标签(同步表格选中状态)。"""
self._mark_interaction()
current = self._get_selected_tags()
if tag in current:
current.remove(tag)
self.tag_weights.pop(tag, None)
self._set_selected_tags(current)
# ── 备选区持久化 ─────────────────────────────────────────────────────
_STAGED_LS_KEY = 'danbooru_staged_tags'
def _save_staged_tags(self):
"""将已选标签及其权重保存到 localStorage。"""
tags = self._get_selected_tags()
weights = {t: self.tag_weights.get(t, 1.0) for t in tags}
data = _json.dumps({'tags': tags, 'weights': weights}, ensure_ascii=False)
try:
if getattr(ui.context.client, '_deleted', False):
return
ui.run_javascript(f"localStorage.setItem('{self._STAGED_LS_KEY}', {_json.dumps(data)});")
except RuntimeError:
pass # 事件上下文已销毁(UI 重建中),数据仍在内存里,下次保存时会同步
async def _restore_staged_tags(self):
"""从 localStorage 恢复已选标签。"""
try:
if getattr(ui.context.client, '_deleted', False):
return
raw = await ui.run_javascript(
f"localStorage.getItem('{self._STAGED_LS_KEY}');",
timeout=5.0,
)
except Exception:
return
if not raw:
return
try:
data = _json.loads(raw)
except Exception:
return
tags = data.get('tags', [])
weights = data.get('weights', {})
if not tags:
return
self.chip_extra_selected.update(tags)
for t in tags:
self.tag_weights[t] = weights.get(t, 1.0)
self._render_selected_chips()
if self.selection_count_label is not None:
self.selection_count_label.text = str(len(tags))
def _clear_all_staged(self):
"""清空所有已选标签。"""
self._mark_interaction()
self.chip_extra_selected.clear()
self.tag_weights.clear()
if self.result_table is not None:
self.result_table.selected = []
self._render_selected_chips()
if self.selection_count_label is not None:
self.selection_count_label.text = '0'
show_nsfw_val = self.input_nsfw.value
self._refresh_related([], show_nsfw_val)
# 清空 Group 同类扩展
if self.group_expansion_container is not None:
self.group_expansion_container.clear()
with self.group_expansion_container:
ui.label('请先搜索并勾选标签…').classes('text-sm text-gray-400 italic p-4')
self._save_staged_tags()
ui.notify('已清空所有已选标签', type='warning')
# ── 两栏结果(CSS 强制并排)──────────────────────────────────────────
def _build_results_columns(self):
with ui.element('div').classes('w-full two-col-layout'):
# ── 左栏:语义匹配结果(表格)──
with ui.card().classes('col-left'):
with ui.row().classes('items-center justify-between mb-2 w-full'):
ui.label('匹配标签结果').classes('font-bold text-lg text-gray-800')
ui.button('复制全部标签', icon='content_copy', on_click=self._copy_all_tags) \
.props('dense flat color=primary').classes('text-sm')
self.result_table = ui.table(
columns=TABLE_COLUMNS,
rows=[],
pagination=0,
selection='multiple',
row_key='tag',
).classes('w-full')
self.result_table.on('selection', self._update_selection_display)
self.result_table.on('link_click', self._mark_interaction)
self.result_table.on('pagination', lambda _: self._save_config())
# 自定义行模板:行背景色按分类,整行悬浮显示 wiki(NSFW模糊行除外)
self.result_table.add_slot('body', r'''