""" 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 logging import os import re import time import json as _json import subprocess import traceback from dataclasses import asdict from datetime import datetime, timedelta, timezone 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, telemetry from api_fastapi import app as api_app from core.engine import DanbooruTagger from core.models import RelatedTag, SearchRequest from core.prompt_import import ( WORKSPACE_GROUP_ORDER, PromptImportResult, WorkspaceCanonicalizationResult, canonicalize_workspace_tags, classify_workspace_tag, pending_to_workspace_entry, resolve_prompt_text, ) from core.workspace_insights import ( CANDIDATE_UNSELECTED, COVERED, UNCOVERED, artist_candidate_reason, compute_concept_coverage, related_candidate_reason, selected_tag_reason, semantic_candidate_reason, tag_group_candidate_reason, ) from core.workspace import ( ARTIST_SELECTION_ORIGINS, FAVORITES_STORAGE_KEY, HISTORY_STORAGE_KEY, LEGACY_STAGED_STORAGE_KEY, WORKSPACE_STORAGE_KEY, WorkspaceDataError, add_history_entry, append_workspace_query, build_backup, clone_workspace, dump_collection, dump_workspace, empty_favorites, empty_history, favorite_from_workspace, merge_favorite_into_workspace, merge_favorites, merge_history, merge_workspaces, migrate_legacy_workspace, new_workspace, normalize_backup, normalize_favorites, normalize_history, normalize_workspace, replace_with_favorite, sync_selected_entries, utc_now_iso, workspace_signature, ) from platform_utils import is_cloud, get_host_port, nsfw_allowed from mcp_server import mcp # 仅统计当前进程中已建立 Socket.IO 连接的 UI 页面,不等同于唯一用户数。 _ACTIVE_UI_CLIENT_IDS: set[str] = set() def _mark_ui_session_active(client_id: str) -> None: _ACTIVE_UI_CLIENT_IDS.add(client_id) def _mark_ui_session_inactive(client_id: str) -> None: _ACTIVE_UI_CLIENT_IDS.discard(client_id) def _get_active_ui_session_count() -> int: return len(_ACTIVE_UI_CLIENT_IDS) 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 endpoints) class _SuppressOAuthNoise(logging.Filter): _MARKERS = ( ".well-known/oauth-authorization-server", ".well-known/oauth-protected-resource", ) def filter(self, record: logging.LogRecord) -> bool: if any(marker in record.getMessage() for marker in self._MARKERS): 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}, {'name': 'reason', 'label': '推荐原因', 'field': 'reason', 'align': 'left'}, ] 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 = 7 _ANNOUNCEMENT_VERSION = 'p0-workspace-2026-07' SPONSOR_IMAGE_URL = "https://akizukipic.oss-cn-beijing.aliyuncs.com/img/202501120027592.png" SPONSOR_TOOLCHAIN_URL = "http://intro.sakizuki.site/index.html" SPONSOR_NOTICE_TEXT = "喜欢的话,可以请作者喝杯咖啡" SPONSOR_TITLE = "谢谢你愿意支持" SPONSOR_BODY = ( "如果你喜欢这个小工具,可以自愿赞赏一点维护成本。" "它会继续免费开放;赞赏不会解锁任何额外功能、不会提高额度、不会影响搜索结果," "量力而行就好,未成年人请勿赞赏。" ) def _resolve_group_render_limit(default: int = 80) -> int: raw = os.environ.get('DANBOORU_GROUP_RENDER_LIMIT') if raw is None: return default try: return int(raw) except ValueError: return default GROUP_RENDER_TAG_LIMIT = _resolve_group_render_limit() ARTIST_REC_LIMIT = 64 ARTIST_REC_PAGE_SIZE = 8 # 搜索模式预设 _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()) _ARTIST_ORIGINS = set(ARTIST_SELECTION_ORIGINS) # ── 辅助函数 ─────────────────────────────────────────────────────────────────── _HISTORY_DISPLAY_TIMEZONE = timezone(timedelta(hours=8)) def _format_history_time(value: object) -> str: """将历史记录的 ISO 时间转成简短的北京时间。""" raw = str(value or '').strip() if not raw: return '--' try: parsed = datetime.fromisoformat(raw.replace('Z', '+00:00')) if parsed.tzinfo is None: parsed = parsed.replace(tzinfo=_HISTORY_DISPLAY_TIMEZONE) return parsed.astimezone(_HISTORY_DISPLAY_TIMEZONE).strftime('%y-%m-%d %H:%M:%S') except ValueError: return raw def _format_history_settings(settings: object) -> str: if not isinstance(settings, dict): settings = {} mode = settings.get('search_mode') preset = _SEARCH_MODE_PRESETS.get(mode) if preset and all(settings.get(key) == value for key, value in preset.items()): return f'预设:{mode}' top_k = settings.get('top_k', '--') limit = settings.get('limit', '--') segmentation = settings.get('use_segmentation') segmentation_text = '开启' if segmentation is True else '关闭' if segmentation is False else '--' return f'Top K:{top_k} · 数量上限:{limit} · 分词:{segmentation_text}' def _sanitize_restored_config(cfg: dict) -> dict: """保留旧配置中的已知、安全字段;坏字段不得阻止页面启动。""" safe: dict = {} numeric_fields = { 'top_k': (int, 1, 200), 'limit': (int, 10, 500), 'popularity_weight': (float, 0.0, 1.0), 'max_per_group': (int, 1, 10), } for key, (caster, minimum, maximum) in numeric_fields.items(): if key not in cfg: continue try: value = caster(cfg[key]) except (TypeError, ValueError): continue if minimum <= value <= maximum: safe[key] = value for key in ( 'show_nsfw', 'use_segmentation', 'sw_semantic', 'sw_layer', 'sw_source', 'notice_expanded', 'mcp_notice_dismissed', ): if isinstance(cfg.get(key), bool): safe[key] = cfg[key] for key in ('selected_layers', 'selected_cats'): value = cfg.get(key) if isinstance(value, dict): safe[key] = {str(k): v for k, v in value.items() if isinstance(v, bool)} if cfg.get('prompt_format') in ('sdxl', 'nai', 'anima'): safe['prompt_format'] = cfg['prompt_format'] if cfg.get('search_mode') in _SEARCH_MODE_OPTIONS: safe['search_mode'] = cfg['search_mode'] if cfg.get('group_mode') in ('off', 'expand', 'diverse'): safe['group_mode'] = cfg['group_mode'] if isinstance(cfg.get('rows_per_page'), int) and cfg['rows_per_page'] in {0, 5, 7, 10, 15, 20, 25, 50}: safe['rows_per_page'] = cfg['rows_per_page'] if isinstance(cfg.get('search_query'), str): safe['search_query'] = cfg['search_query'][:4_000] if isinstance(cfg.get('dismissed_announcement_version'), str): safe['dismissed_announcement_version'] = cfg['dismissed_announcement_version'][:100] return safe def _next_group_render_limit(current: int, total: int, page_size: int) -> int: if page_size <= 0: return total return min(total, max(page_size, current + page_size)) def _limit_group_render_tags(tags: list[dict], visible_limit: int | None = None) -> tuple[list[dict], int]: limit = GROUP_RENDER_TAG_LIMIT if visible_limit is None else visible_limit if limit <= 0: return tags, 0 if len(tags) <= limit: return tags, 0 return tags[:limit], len(tags) - limit def _should_group_start_expanded(group_name: str, expanded_groups: set[str]) -> bool: return group_name in expanded_groups def _group_names_key(group_data: list[dict]) -> tuple[str, ...]: return tuple(sorted({str(group.get('group', '')) for group in group_data})) def _group_scroll_dom_id(group_name: str) -> str: safe_name = re.sub(r'[^0-9A-Za-z_-]+', '_', group_name) return f'group-scroll-{safe_name}' def _scroll_state_restore_script(positions: dict[str, int]) -> str: js_positions = _json.dumps(positions) return f""" (() => {{ const positions = {js_positions}; const restore = () => {{ const windowTop = positions.__window__; if (typeof windowTop === 'number') {{ window.scrollTo({{ top: windowTop, behavior: 'auto' }}); const root = document.scrollingElement || document.documentElement || document.body; if (root) root.scrollTop = windowTop; }} for (const [id, top] of Object.entries(positions)) {{ if (id === '__window__') continue; if (id.endsWith('__bottom__')) continue; const el = document.getElementById(id); if (!el) continue; const bottom = positions[`${{id}}__bottom__`]; if (typeof bottom === 'number') {{ el.scrollTop = Math.max(0, el.scrollHeight - bottom); }} else {{ el.scrollTop = top; }} }} }}; requestAnimationFrame(() => {{ restore(); requestAnimationFrame(restore); }}); setTimeout(restore, 80); }})(); """ 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 d['reason'] = semantic_candidate_reason(r.source, r.layer) 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 anima: (tag:1.5) 权重 1.0 时输出 tag,下划线替换为空格 所有模式均对标签名中的括号进行反斜杠转义。 """ tag = tag.replace('(', '\\(').replace(')', '\\)') if fmt == 'anima': tag = tag.replace('_', ' ') if weight == 1.0: return tag if fmt == 'nai': return f'{weight:.1f}::{tag}::' return f'({tag}:{weight:.1f})' def _format_selected_tag_label(tag: str, cn_name: str = '') -> str: cn_first = (cn_name or '').split(',', 1)[0].strip() return f'{tag} | {cn_first}' if cn_first else tag # ── UI 类 ───────────────────────────────────────────────────────────────────── class DanbooruSearchUI: def __init__(self): self.search_count_label = None self.service_status_container = None self.service_status_timer = None self._last_service_status_key = None self.current_search_interacted = True self._telemetry_search_started_at: float | None = None self._telemetry_selection_recorded = False self._telemetry_copy_timing_recorded = False self._telemetry_last_query = '' self._telemetry_last_search_at = 0.0 self.full_table_data: list[dict] = [] self.current_segments: list[str] = [] # 从句级原始片段,用于区分 chip 颜色 self.current_keywords: list[str] = [] self.current_cached_queries: set[str] = set() 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.artist_rec_pagination = None self.client = None self._group_render_limits: dict[str, int] = {} self._group_expanded_names: set[str] = set() self._group_scroll_positions: dict[str, int] = {} self._group_render_key: tuple[str, ...] = () self.results_section = None # 整个结果区域(搜索前隐藏) self.coverage_container = None self.selection_count_label = None self.selected_display = None # 已废弃 textarea,保留兼容 self.selected_chips_container = None # 已选标签 chip 容器 self.prompt_pending_container = None self.current_related: list = [] self.chip_extra_selected: set = set() self._selected_order: list[str] = [] self.workspace_state: dict = new_workspace() self.search_history: dict = empty_history() self.favorites: dict = empty_favorites() self._undo_stack: list[dict] = [] self._redo_stack: list[dict] = [] self._pending_selection_meta: dict[str, dict[str, str]] = {} self._workspace_artist_tags: set[str] = set() self.undo_btn = None self.redo_btn = None self.history_count_label = None self.favorites_count_label = None self._workspace_storage_listener_installed = False # 去抖任务句柄(取消旧任务避免 CPU 洪峰) self._debounce_related_task = None # type: asyncio.Task | None self._debounce_group_task = None # type: asyncio.Task | None self._debounce_artist_task = None # type: asyncio.Task | None # tag -> prompt 权重,范围 [0.1, 1.9],默认 1.0 self.tag_weights: dict[str, float] = {} # 复制格式:'sdxl'、'nai' 或 'anima' 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, 'artist': True} self.selected_cats = {'General': True, 'Copyright': True, 'Character': True} self.bad_case_btn = None self.mcp_notice = None self.notice_expansion = None self.announcement_banner = None self.dismissed_announcement_version = '' self.help_dialog = None self.sponsor_dialog = 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 引用 self._artist_rec_checkboxes: dict[str, ui.checkbox] = {} self._artist_rec_rows: list = [] self._artist_rec_sources: dict[str, str] = {} self._group_candidate_sources: dict[str, str] = {} self._artist_rec_page = 1 self._artist_rec_page_count = 0 self._artist_rec_page_label = None self._artist_rec_prev_button = None self._artist_rec_next_button = None # 当前推荐画师的标签名集合(用于 Anima 模式复制时加 @ 前缀) self._current_artist_rec_tags: set[str] = set() self._artist_result_tags: set[str] = set() self._last_recommendation_seed_tags: list[str] = [] # 高级选项中各层/类型的 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 and self._client_alive(): 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 _update_service_status(self): if self.service_status_container is None or not self._client_alive(): return ready = DanbooruTagger.is_ready() online_sessions = _get_active_ui_session_count() load = DanbooruTagger.get_load_snapshot() active = load['active'] waiting = load['waiting'] capacity = load['capacity'] busy = ready and (waiting > 0 or active >= capacity) status_key = (ready, busy, online_sessions, active, waiting, capacity) if status_key == self._last_service_status_key: return self._last_service_status_key = status_key self.service_status_container.clear() with self.service_status_container: if not ready: with ui.row().classes( 'w-full items-center gap-2 service-state-panel loading' ): ui.spinner(size='18px', color='primary') ui.label('引擎初始化中,请稍候…约需 5~10 分钟').classes('font-medium') else: with ui.row().classes( f'w-full items-center gap-2 service-state-panel {"busy" if busy else "ready"}' ): ui.icon( 'schedule' if busy else 'check_circle', size='18px', color='warning' if busy else 'positive', ) parts = [ '服务繁忙' if busy else '服务可用', f'{online_sessions} 个在线页面', ] if active > 0: parts.append(f'正在处理 {active} 个任务') if waiting > 0: parts.append(f'等待 {waiting} 个') ui.label(' · '.join(parts)).classes('font-medium') def _build_sponsor_dialog(self): with ui.dialog() as self.sponsor_dialog, ui.card().classes('w-full max-w-sm'): with ui.column().classes('w-full items-center gap-2 text-center'): ui.label(SPONSOR_TITLE).classes('text-base font-bold text-gray-800') ui.label(SPONSOR_BODY).classes('text-sm text-gray-600 leading-relaxed') ui.image(SPONSOR_IMAGE_URL).classes('w-60 max-w-full rounded border border-gray-200') ui.label('微信赞赏码').classes('text-xs text-gray-400') ui.link( '如果你想继续折腾,也可以看看基于这个小工具的更多工具链', SPONSOR_TOOLCHAIN_URL, new_tab=True, ).classes('text-xs text-blue-500 hover:text-blue-700 hover:underline') with ui.row().classes('w-full justify-end'): ui.button('关闭', on_click=self.sponsor_dialog.close).props('flat color=grey-7') def _build_help_dialog(self): from platform_utils import PLATFORM alternate_url = ( 'https://www.modelscope.cn/studios/SAkizuki/DanbooruSearchOnline' if PLATFORM == 'hf' else 'https://huggingface.co/spaces/SAkizuki/DanbooruSearch' ) with ui.dialog() as self.help_dialog, ui.card().classes('w-full max-w-3xl max-h-[90vh] p-0 gap-0'): with ui.row().classes('w-full items-center justify-between px-5 py-4 border-b border-slate-200'): with ui.row().classes('items-center gap-2'): ui.icon('help_outline', color='primary') ui.label('帮助 / 关于').classes('text-lg font-bold text-slate-800') ui.button(icon='close', on_click=self.help_dialog.close).props('flat dense round color=grey-7') with ui.scroll_area().classes('w-full h-[72vh]'): with ui.column().classes('w-full gap-4 px-5 py-4'): with ui.element('div').classes( 'w-full rounded-lg border border-blue-200 bg-blue-50 px-4 py-3' ): ui.label('最近更新:标签工作区与查询理解').classes( 'text-base font-bold text-blue-900' ) ui.label( '本次更新优化了标签已选区,现已支持历史记录、收藏等功能。这些信息始终保存在本地。' ).classes('text-sm text-blue-800 mt-1') ui.markdown(""" ### 1. 搜索与查询理解 搜索后的“查询理解”优化了原先的分词筛选功能,增加了是否覆盖的标记: - **绿色**:这个概念已有标签被加入工作区。 - **黄色**:搜索结果中存在候选,但你还没有选择。 - **红色**:当前结果没有覆盖;点击它会沿用当前参数发起补充搜索,不会清空工作区。 - 点击绿色或黄色概念,可以只查看来自该概念的搜索结果;点击“全部结果”恢复完整列表。 ### 2. 标签工作区 勾选搜索结果、关联标签、同类标签或推荐画师后,它们都会进入同一个工作区,并自动保存在当前浏览器中。 - 使用标签两侧的 `− / +` 调整 Prompt 权重。 - 使用“撤销 / 恢复”回退最近的工作区修改。 - “清空已选”只清空当前工作区标签,不会删除历史和收藏。 - SDXL、NAI、Anima 复制格式会随工作区保存;画师标签在 Anima 格式下会自动使用 `@画师名`。 ### 3. 历史与继续搜索 每次搜索都会记录查询、当时的搜索设置和工作区快照: - **重新搜索**:恢复历史查询和当时的搜索参数,然后立即搜索。 - **恢复工作区**:恢复当时的已选标签、权重和格式。 - **追加查询**:使用当前页面的搜索参数重新执行历史查询,并保留当前工作区。 ### 4. 收藏 “保存收藏”会保存当前标签组合、权重、来源和 Prompt 格式。收藏可以恢复、合并或直接复制;合并时,同名标签保留当前工作区已有权重。 ### 5. Prompt 导入与 Alias 纠错 点击“导入 Prompt”后粘贴标签字符串。全角逗号和半角逗号会等价解析;上传或粘贴完成后,需要再次点击确认才会导入。 - Alias 是 Danbooru 中标记弃用标签的数据接口,导入 Prompt 后,系统将自动替换弃用标签,并展示替换关系。 - 重复标签会合并,不会重复加入。 - 无法唯一识别或目标不在当前标签库中的内容进入“待确认”,不会被强行加入。 ### 6. 关联、同类与画师推荐 - **关联推荐**根据标签共现关系补充经常一起出现的标签。 - **同类标签**根据 Tag Group 展示同一语义组中的其他标签。 - **推荐擅长画师**根据标签与画师的共现数据推荐画师,每页显示 8 位;悬停可查看常见标签。 推荐原因只展示系统实际使用的数据来源,不会虚构标签关系。 ### 7. 备份与迁移 “备份 / 迁移”可以导出完整 JSON,内容包括当前配置、工作区、原始搜索查询、历史和收藏。文件仅在你主动下载或上传时离开浏览器。导入时可以选择合并或替换。 ### 8. 反馈与隐私 你可以提交整次搜索问题或标签翻译错误。反馈只记录当前查询、搜索设置、应用版本和你主动填写的说明;不会上传浏览器指纹、收藏、完整历史或未经提交的工作区等任何个人信息。 """).classes('w-full text-sm text-slate-700 leading-relaxed') ui.separator() with ui.column().classes('w-full gap-2'): ui.label('文档与集成').classes('text-sm font-bold text-slate-700') with ui.row().classes('w-full gap-3 flex-wrap'): ui.link('工具链使用指南', 'http://intro.sakizuki.site/index.html', new_tab=True) \ .classes('text-sm text-blue-600') ui.link('API 文档', '/api/docs', new_tab=True).classes('text-sm text-blue-600') ui.link( 'MCP 接入', 'https://github.com/SuzumiyaAkizuki/DanbooruSearchOnline#mcp-接口', new_tab=True, ).classes('text-sm text-blue-600') ui.link( 'ComfyUI 插件', 'https://github.com/SuzumiyaAkizuki/ComfyUI-DanbooruSearcher', new_tab=True, ).classes('text-sm text-blue-600') ui.link('备用服务', alternate_url, new_tab=True).classes('text-sm text-blue-600') ui.link( 'GitHub', 'https://github.com/SuzumiyaAkizuki/DanbooruSearchOnline', new_tab=True, ).classes('text-sm text-blue-600') ui.separator() with ui.element('div').classes( 'w-full rounded-lg border border-red-200 bg-red-50 px-4 py-3' ): ui.label('本地个人数据').classes('text-sm font-bold text-red-900') ui.label( '历史、收藏、当前工作区和搜索配置只保存在这个浏览器中。' ).classes('text-xs text-red-800 mt-1') ui.button( '删除所有本地个人数据', icon='delete_forever', on_click=self._confirm_delete_all_personal_data, ).props('outline color=negative no-caps').classes('mt-3') with ui.row().classes('w-full items-center justify-between gap-3 flex-wrap'): ui.label('DanbooruSearch 将持续免费开放。').classes('text-xs text-slate-500') ui.button( SPONSOR_NOTICE_TEXT, icon='volunteer_activism', on_click=self.sponsor_dialog.open, ).props('flat dense no-caps color=grey-7').classes('text-xs') def _confirm_delete_all_personal_data(self): with ui.dialog() as confirm, ui.card().classes('w-full max-w-lg'): with ui.row().classes('items-center gap-2'): ui.icon('warning', color='negative') ui.label('删除所有本地个人数据?').classes('text-lg font-bold text-red-900') ui.label( '将删除当前浏览器中保存的搜索配置、搜索输入、工作区、已选标签、权重、' '搜索历史、收藏以及旧版或损坏数据备份。' ).classes('text-sm text-slate-700 leading-relaxed') ui.label( '此操作无法撤销,但不会删除你已经下载到电脑上的 JSON 备份,也不会修改服务器端匿名聚合统计。' ).classes('text-xs text-red-700 bg-red-50 rounded p-3') async def delete_all_personal_data(): delete_btn.disable() storage_keys = [ _CONFIG_LS_KEY, WORKSPACE_STORAGE_KEY, HISTORY_STORAGE_KEY, FAVORITES_STORAGE_KEY, LEGACY_STAGED_STORAGE_KEY, f'{WORKSPACE_STORAGE_KEY}_corrupt_backup', f'{HISTORY_STORAGE_KEY}_corrupt_backup', f'{FAVORITES_STORAGE_KEY}_corrupt_backup', ] keys_json = _json.dumps(storage_keys, ensure_ascii=False) try: await ui.run_javascript( f"""(() => {{ const keys = {keys_json}; keys.forEach((key) => localStorage.removeItem(key)); return keys.length; }})()""", timeout=5.0, ) confirm.close() self.help_dialog.close() ui.notify('本地个人数据已删除,页面即将刷新', type='positive', timeout=2000) await ui.run_javascript( "(() => { setTimeout(() => window.location.reload(), 500); return true; })()", timeout=5.0, ) except Exception as exc: print(f'[UI] 删除本地个人数据失败: {exc}', flush=True) delete_btn.enable() ui.notify('删除失败,请稍后重试', type='negative') with ui.row().classes('w-full justify-end gap-2 mt-2'): ui.button('取消', on_click=confirm.close).props('flat color=grey-7') delete_btn = ui.button( '确认删除', icon='delete_forever', on_click=delete_all_personal_data, ).props('unelevated color=negative no-caps') confirm.open() def _build_release_announcement(self): self.announcement_banner = ui.element('div').classes('w-full release-notice px-3 py-2') with self.announcement_banner: with ui.row().classes('w-full items-center justify-between gap-2'): with ui.row().classes('items-center gap-2 min-w-0 flex-wrap'): ui.icon('new_releases', size='18px', color='primary') ui.label( '新版已加入标签工作区、Prompt 导入、Alias 纠错和分渠道统计。' ).classes('text-sm text-slate-700') ui.button( '查看详情', on_click=self.help_dialog.open, ).props('flat dense no-caps color=primary').classes('text-xs') ui.button( icon='close', on_click=self._dismiss_release_announcement, ).props('flat dense round color=grey-6') def _dismiss_release_announcement(self): self.dismissed_announcement_version = _ANNOUNCEMENT_VERSION if self.announcement_banner: self.announcement_banner.set_visibility(False) self._save_config() 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 _record_first_selection_if_needed( self, previous_tags: list[str], current_tags: list[str], ) -> None: if self._telemetry_search_started_at is None or self._telemetry_selection_recorded: return if not (set(current_tags) - set(previous_tags)): return self._telemetry_selection_recorded = True duration_ms = (time.perf_counter() - self._telemetry_search_started_at) * 1000 async def record_selection() -> None: try: await telemetry.increment("ui_search_with_selection_session") await telemetry.record_timing("search_to_first_selection", duration_ms) except Exception as exc: print(f"[UI] 首次选择统计失败: {exc}", flush=True) asyncio.create_task(record_selection()) def _record_ui_copy(self, event_name: str) -> None: duration_ms: float | None = None if ( self._telemetry_search_started_at is not None and not self._telemetry_copy_timing_recorded ): self._telemetry_copy_timing_recorded = True duration_ms = (time.perf_counter() - self._telemetry_search_started_at) * 1000 async def record_copy() -> None: try: await counter.increment_copy() await telemetry.increment(event_name) if duration_ms is not None: await telemetry.record_timing("search_to_first_copy", duration_ms) except Exception as exc: print(f"[UI] 复制统计失败: {exc}", flush=True) asyncio.create_task(record_copy()) # ── 分页辅助 ────────────────────────────────────────────────────────── 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 _apply_prompt_format(self, prompt_format: str): """统一设置复制格式及按钮外观,不触发额外持久化。""" if prompt_format not in ('sdxl', 'nai', 'anima'): prompt_format = 'sdxl' self.prompt_format = prompt_format if not self.format_toggle_btn: return if prompt_format == 'nai': self.format_toggle_btn.text = 'NAI' self.format_toggle_btn.props('color=purple-7') elif prompt_format == 'anima': self.format_toggle_btn.text = 'Anima' self.format_toggle_btn.props('color=teal-7') else: self.format_toggle_btn.text = 'SDXL' self.format_toggle_btn.props('color=grey-7') def _collect_config_state(self) -> dict: return { '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 '', 'dismissed_announcement_version': self.dismissed_announcement_version, '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, } def _save_config(self): """将当前控件状态序列化并写入 localStorage。""" if not self._client_alive(): return cfg = self._collect_config_state() js = _json.dumps(cfg, ensure_ascii=False) ui.run_javascript(f"localStorage.setItem('{_CONFIG_LS_KEY}', {_json.dumps(js)});") def _apply_config_state(self, cfg: dict): cfg = _sanitize_restored_config(cfg if isinstance(cfg, dict) else {}) dismissed_version = cfg.get('dismissed_announcement_version', '') self.dismissed_announcement_version = dismissed_version if self.announcement_banner: self.announcement_banner.set_visibility( dismissed_version != _ANNOUNCEMENT_VERSION ) # 模式可能触发预设填充,因此随后再覆盖各个具体参数。 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']) if nsfw_allowed() and self.input_nsfw and 'show_nsfw' in cfg: self.input_nsfw.set_value(cfg['show_nsfw']) for layer, val in cfg.get('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)) for cat, val in cfg.get('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: self._apply_prompt_format(cfg['prompt_format']) 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']) self._update_table_columns() async def _restore_config(self): """从 localStorage 读取配置并恢复控件状态。""" try: if not self._client_alive(): 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 not isinstance(cfg, dict): return if cfg.get('version') != _CONFIG_VERSION: # 旧版本仍按已知字段尽力恢复;保存时自然升级,禁止静默删除。 print( f"[UI] 检测到旧配置版本 {cfg.get('version')!r},执行兼容恢复。", flush=True, ) self._apply_config_state(cfg) # ══════════════════════════════════════════════════════════════════════ # 页面构建 # ══════════════════════════════════════════════════════════════════════ def build_page(self): self.client = ui.context.client ui.colors(primary='#4A90E2', secondary='#5E6C84', accent='#FF6B6B') ui.add_head_html(''' ''') self._build_sponsor_dialog() self._build_help_dialog() with ui.column().classes('w-full max-w-7xl mx-auto p-4 gap-3'): # ── 1. 搜索主路径 ── self._build_search_card() if not DanbooruTagger.is_ready(): asyncio.ensure_future(self._hide_banner_when_ready()) # ── 2. 紧凑版本公告 ── self._build_release_announcement() # ── 3. 工作区工具和已选标签(无需搜索即可恢复)── self.workspace_card = ui.card().classes( 'w-full p-0 gap-0 overflow-hidden bg-blue-50 border border-blue-200' ) with self.workspace_card: self._build_workspace_toolbar() self._build_selection_bar() # ── 4~5. 搜索结果区域(搜索前隐藏)── self.results_section = ui.column().classes('w-full gap-4') self.results_section.set_visibility(False) with self.results_section: # ── 4. 查询理解:来源筛选 + 概念覆盖 ── self.coverage_container = ui.column().classes('w-full gap-2') # ── 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() ui.button(SPONSOR_NOTICE_TEXT, on_click=self.sponsor_dialog.open) \ .props('flat dense no-caps color=grey-6') \ .classes('text-xs mt-1') # ── 公告栏(标签组 + 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'): with ui.row().classes('items-center gap-1'): 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( '基于标签共现数据,根据已选标签查找对应的擅长画师。' '鼠标悬停画师行可查看该画师最常画的标签' ).classes('text-xs text-green-900') ui.separator().classes('my-1') # ── 标签组扩展 ── ui.html( '【标签组扩展】 勾选标签后,搜索结果下方会出现同类标签区域,' '展示已选标签所属分组中的其他标签,勾选即可加入已选。' ).classes('text-xs text-green-900') ui.separator().classes('my-1') # ── MCP 服务 ── 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', '''
⚠️ 注意事项 / Note 如果觉得好用,请点击顶部给本 Space 点个 Like ❤️,或前往 GitHub 点个 Star ⭐
''') ui.markdown(""" - **AI 辅助**:基于语义匹配,结果未必绝对准确(Results may contain errors) - **内容警告**:查找结果可能包含 NSFW 内容 (May include NSFW content) - **检索限制**:仅支持中/英双语查找 ,更推荐中文(CN/EN only,CN is preferred) - **标签范围**:仅显示特征、角色与作品标签,且频数须 ≥ 100 (General, Character & Copyright only, Freq ≥ 100) - **集成与接口**:[ComfyUI 插件](https://github.com/SuzumiyaAkizuki/ComfyUI-DanbooruSearcher) · [API 文档](/api/docs) · [MCP 接入](https://github.com/SuzumiyaAkizuki/DanbooruSearchOnline#mcp-接口) - **支持作者**:如果觉得好用,欢迎点击顶部给本 Space 点个 **Like ❤️**,或前往 [GitHub](https://github.com/SuzumiyaAkizuki/DanbooruSearchOnline) 点个 **Star ⭐**!也可以自愿赞赏一点维护成本;不影响任何功能使用。 - **🚀 首次使用?[点击查看使用指南](http://intro.sakizuki.site/index.html)**,了解五种搜索模式与进阶技巧 """).classes('text-sm text-gray-800 px-4 pb-3') # ── 搜索卡片 ───────────────────────────────────────────────────────── def _build_search_card(self): with ui.card().classes('w-full product-search-card'): with ui.row().classes('w-full items-start justify-between gap-3 mb-1'): with ui.row().classes('items-center gap-2'): ui.icon('search', size='2em', color='primary') ui.label('Danbooru 标签模糊搜索').classes('text-2xl font-bold text-gray-800') ui.button( '帮助 / 关于', icon='help_outline', on_click=self.help_dialog.open, ).props('flat dense no-caps color=grey-7').classes('text-sm') ui.label( '基于语义匹配的标签搜索引擎,支持多维匹配与共现关联推荐。' ).classes('text-sm text-gray-500 -mt-1 mb-1') ui.html( '' '查看工具链介绍 / 使用指南 →' ).classes('text-sm mb-3') with ui.row().classes('w-full gap-3 items-stretch'): self.search_input = ui.textarea( placeholder='输入自然语言描述或模糊概念,例如:一个穿着白色水手服的少女在雨中奔跑...' ).classes('flex-grow text-base').props('outlined rows=2') self.search_input.on('keydown.ctrl.enter', self.perform_search) with ui.column().classes('justify-center'): self.search_btn = ui.button( '', on_click=self.perform_search, icon='search' ).classes('px-6 h-full min-h-16').props('unelevated color=dark') with self.search_btn: ui.label('搜索').classes('text-sm mt-1') self.spinner = ui.spinner(size='2em').classes('hidden') self.search_params_row = ui.row().classes('w-full gap-6 items-center mt-3 flex-wrap') with self.search_params_row: with ui.row().classes('items-center gap-2'): ui.label('搜索模式 (beta)').classes('text-sm text-gray-600') self.input_search_mode = ui.select( _SEARCH_MODE_OPTIONS, value='自定义', ).classes('w-28').props('outlined dense') self.input_search_mode.on('update:model-value', self._on_search_mode_change) with ui.tooltip().props('content-class="bg-black text-white shadow-4"'): ui.label('选择模式自动填充对应参数;手动修改参数后自动变为「自定义」').style('font-size:14px;') with ui.row().classes('items-center gap-2'): ui.label('Top K (语义相关)').classes('text-sm text-gray-600') self.input_top_k = ui.number(value=10, min=1, max=200).classes('w-20') \ .props('outlined dense') self.input_top_k.on('update:model-value', self._on_param_changed) with ui.row().classes('items-center gap-2'): ui.label('结果上限').classes('text-sm text-gray-600') self.input_limit = ui.number(value=80, min=10, max=500).classes('w-20') \ .props('outlined dense') self.input_limit.on('update:model-value', self._on_param_changed) with ui.switch('显示 NSFW(成人) 内容', value=False).props('color=red') as _nsfw_sw: if not nsfw_allowed(): with ui.tooltip().props('content-class="bg-black text-white shadow-4"'): ui.label('NSFW 内容在当前平台不可用').style('font-size:14px;') self.input_nsfw = _nsfw_sw if not nsfw_allowed(): self.input_nsfw.disable() else: self.input_nsfw.on('update:model-value', self.on_nsfw_toggle) with ui.switch('智能分词', value=True).props('color=primary') as _seg_sw: with ui.tooltip().props('content-class="bg-black text-white shadow-4"'): ui.label('关闭后系统将只匹配完整句子,适用于精准搜索整句。').style('font-size:14px;') self.input_segment = _seg_sw self.input_segment.on('update:model-value', self._on_param_changed) self.advanced_options = ui.expansion('高级选项', icon='tune').classes('w-full mt-2') with self.advanced_options: with ui.column().classes('w-full p-3 gap-4'): with ui.row().classes('items-center gap-2'): ui.label('热度权重').classes('text-sm font-bold text-gray-700') self.input_weight = ui.slider( min=0.0, max=1.0, value=0.15, step=0.05, ).classes('w-40') ui.label().bind_text_from( self.input_weight, 'value', lambda v: f"{v:.2f}", ).classes('text-sm font-mono text-gray-700 w-8') self.input_weight.on('update:model-value', self._on_param_changed) with ui.row().classes('w-full gap-8 flex-wrap'): with ui.column().classes('gap-2'): ui.label('匹配层筛选').classes('font-bold text-sm text-gray-700') display_map = { '英文': '英文标签', '中文扩展词': '中文扩展词', '释义': '维基释义', '中文核心词': '中文核心词', 'artist': 'artist', } for layer in ['英文', '中文扩展词', '释义', '中文核心词', 'artist']: cb = ui.checkbox( display_map.get(layer, layer), value=True, on_change=lambda e, l=layer: self.selected_layers.__setitem__(l, e.value) ).props('color=primary dense') self._layer_checkboxes[layer] = cb with ui.column().classes('gap-2'): ui.label('类型筛选').classes('font-bold text-sm text-gray-700') color_map = {'General': 'blue', 'Copyright': 'purple', 'Character': 'green'} label_map = { 'General': '通用 (General)', 'Copyright': '作品 (Copyright)', 'Character': '角色 (Character)', } for cat in ['General', 'Copyright', 'Character']: cb = ui.checkbox( label_map[cat], value=True, on_change=lambda e, c=cat: self.selected_cats.__setitem__(c, e.value) ).props(f'color={color_map[cat]} dense') self._cat_checkboxes[cat] = cb with ui.column().classes('gap-2'): ui.label('表格显示列').classes('font-bold text-sm text-gray-700') self.sw_semantic = ui.switch('显示语义分', value=False) self.sw_layer = ui.switch('显示匹配层', value=False) self.sw_source = ui.switch('显示匹配来源', value=False) self.sw_semantic.on('update:model-value', self._update_table_columns) self.sw_layer.on('update:model-value', self._update_table_columns) self.sw_source.on('update:model-value', self._update_table_columns) with ui.column().classes('gap-2'): ui.label('标签分组模式').classes('font-bold text-sm text-gray-700') self.input_group_mode = ui.select( ['off', 'expand', 'diverse'], value='off', ).classes('w-40').props('outlined dense') with ui.tooltip().props('content-class="bg-black text-white shadow-4"'): ui.label('off=关闭 | expand=同类召回增强 | diverse=多样性约束').style('font-size:14px;') self.input_group_mode.on('update:model-value', self._on_param_changed) self.input_max_per_group = ui.number( value=2, min=1, max=10, ).classes('w-20').props('outlined dense') ui.label('每组最大标签数(diverse 模式)').classes('text-xs text-gray-500') self.input_max_per_group.on('update:model-value', self._on_param_changed) with ui.element('div').classes('w-full border-t border-slate-100 pt-3 mt-1'): self.service_status_container = ui.column().classes('w-full gap-0') self._update_service_status() self.service_status_timer = ui.timer(1.0, self._update_service_status) # ── 工作区工具 ──────────────────────────────────────────────────────── def _build_workspace_toolbar(self): with ui.element('div').classes( 'w-full bg-slate-50 border-b border-blue-100 px-4 py-2' ): with ui.row().classes('w-full items-center gap-2 flex-wrap'): ui.icon('workspaces', color='primary') ui.label('标签工作区').classes('font-bold text-slate-700 mr-2') self.undo_btn = ui.button( '撤销', icon='undo', on_click=self._undo_workspace, ).props('dense flat color=grey-7') self.redo_btn = ui.button( '恢复', icon='redo', on_click=self._redo_workspace, ).props('dense flat color=grey-7') self.undo_btn.disable() self.redo_btn.disable() ui.separator().props('vertical').classes('h-7 mx-1') ui.button( '历史', icon='history', on_click=self._open_history_dialog, ).props('dense flat color=primary') self.history_count_label = ui.label('0').classes('text-xs text-gray-500 -ml-2') ui.button( '收藏', icon='star_outline', on_click=self._open_favorites_dialog, ).props('dense flat color=amber-8') self.favorites_count_label = ui.label('0').classes('text-xs text-gray-500 -ml-2') ui.button( '保存收藏', icon='bookmark_add', on_click=self._open_save_favorite_dialog, ).props('dense flat color=teal-7') ui.button( '导入 Prompt', icon='playlist_add', on_click=self._open_prompt_import_dialog, ).props('dense flat color=purple-7') ui.button( '备份 / 迁移', icon='swap_horiz', on_click=self._open_backup_dialog, ).props('dense flat color=grey-7') self._update_workspace_counts() def _update_workspace_counts(self): if self.history_count_label is not None: self.history_count_label.text = str(len(self.search_history.get('items', []))) if self.favorites_count_label is not None: self.favorites_count_label.text = str(len(self.favorites.get('items', []))) def _current_search_settings(self) -> dict: return { 'search_mode': self.input_search_mode.value if self.input_search_mode else '自定义', '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, 'target_layers': [k for k, v in self.selected_layers.items() if v], 'target_categories': [k for k, v in self.selected_cats.items() if v], '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, } def _apply_search_settings(self, settings: dict): if not isinstance(settings, dict): return self._applying_preset = True try: mode = settings.get('search_mode') if self.input_search_mode and mode in _SEARCH_MODE_OPTIONS: self.input_search_mode.set_value(mode) if self.input_top_k and isinstance(settings.get('top_k'), int): self.input_top_k.set_value(settings['top_k']) if self.input_limit and isinstance(settings.get('limit'), int): self.input_limit.set_value(settings['limit']) if self.input_weight and isinstance(settings.get('popularity_weight'), (int, float)): self.input_weight.set_value(settings['popularity_weight']) if self.input_segment and isinstance(settings.get('use_segmentation'), bool): self.input_segment.set_value(settings['use_segmentation']) if self.input_group_mode and settings.get('group_mode') in ('off', 'expand', 'diverse'): self.input_group_mode.set_value(settings['group_mode']) if self.input_max_per_group and isinstance(settings.get('max_per_group'), int): self.input_max_per_group.set_value(settings['max_per_group']) if nsfw_allowed() and self.input_nsfw and isinstance(settings.get('show_nsfw'), bool): self.input_nsfw.set_value(settings['show_nsfw']) layers = settings.get('target_layers') if isinstance(layers, list): selected = set(layers) for layer in self.selected_layers: value = layer in selected self.selected_layers[layer] = value if layer in self._layer_checkboxes: self._layer_checkboxes[layer].set_value(value) categories = settings.get('target_categories') if isinstance(categories, list): selected = set(categories) for category in self.selected_cats: value = category in selected self.selected_cats[category] = value if category in self._cat_checkboxes: self._cat_checkboxes[category].set_value(value) finally: self._applying_preset = False self._save_config() def _record_search_history(self, query: str): settings = self._current_search_settings() self.workspace_state = append_workspace_query( self.workspace_state, query, settings, ) self._save_staged_tags() self.search_history = add_history_entry( self.search_history, query, settings, self.workspace_state, ) self._save_history() self._update_workspace_counts() def _open_history_dialog(self): with ui.dialog() as dialog, ui.card().classes('w-full max-w-4xl max-h-[85vh]'): with ui.row().classes('w-full items-center justify-between'): ui.label('搜索历史').classes('text-lg font-bold') with ui.row().classes('gap-2'): if self.search_history.get('items'): ui.button( '清空全部', icon='delete_sweep', on_click=lambda: self._confirm_clear_history(dialog), ).props('flat dense color=red-7') ui.button(icon='close', on_click=dialog.close).props('flat round dense') with ui.scroll_area().classes('w-full h-[65vh]'): items = self.search_history.get('items', []) if not items: ui.label('暂无搜索历史').classes('text-sm text-gray-400 p-6') for item in items: with ui.card().classes('w-full mb-2 p-3 border border-gray-200 shadow-none'): with ui.row().classes('w-full items-start justify-between gap-3'): with ui.column().classes('gap-1 flex-grow min-w-0'): ui.label(item['query']).classes('font-medium text-gray-800 break-all') selected_count = len(item['workspace'].get('selected', [])) ui.label( f"{_format_history_time(item.get('searched_at'))} · " f"工作区内有 {selected_count} 个标签" ).classes('text-xs text-gray-400') ui.label( _format_history_settings(item.get('settings')) ).classes('text-xs text-gray-400') with ui.row().classes('gap-1 flex-wrap justify-end'): ui.button( '重新搜索', icon='search', on_click=lambda i=item, d=dialog: self._history_research(i, d), ).props('flat dense color=primary') ui.button( '恢复工作区', icon='restore', on_click=lambda i=item, d=dialog: self._history_restore(i, d), ).props('flat dense color=teal-7') ui.button( '追加查询', icon='playlist_add', on_click=lambda i=item, d=dialog: self._history_append(i, d), ).props('flat dense color=purple-7') ui.button( icon='delete_outline', on_click=lambda i=item, d=dialog: self._delete_history_entry(i, d), ).props('flat round dense color=red-6') dialog.open() async def _history_research(self, item: dict, dialog): dialog.close() self._apply_search_settings(item.get('settings', {})) self.search_input.set_value(item['query']) await self.perform_search() async def _history_restore(self, item: dict, dialog): normalized = await self._canonicalize_workspace_for_load( item['workspace'], source='历史工作区恢复', ) self._push_undo_snapshot() self._apply_workspace_state(normalized.workspace) self.search_input.set_value(item['query']) dialog.close() ui.notify('已恢复历史工作区;未重新发起搜索', type='positive') self._show_workspace_canonicalization(normalized, '历史工作区') async def _history_append(self, item: dict, dialog): dialog.close() self.search_input.set_value(item['query']) await self.perform_search() def _delete_history_entry(self, item: dict, dialog): history_id = item.get('history_id') self.search_history['items'] = [ entry for entry in self.search_history.get('items', []) if entry.get('history_id') != history_id ] self._save_history() self._update_workspace_counts() dialog.close() self._open_history_dialog() def _confirm_clear_history(self, parent_dialog): with ui.dialog() as confirm, ui.card(): ui.label('确定清空全部搜索历史吗?收藏和当前工作区不会受到影响。') with ui.row().classes('w-full justify-end gap-2'): ui.button('取消', on_click=confirm.close).props('flat') def clear(): self.search_history = empty_history() self._save_history() self._update_workspace_counts() confirm.close() parent_dialog.close() ui.notify('搜索历史已清空', type='positive') ui.button('清空', on_click=clear).props('unelevated color=red-7') confirm.open() def _open_prompt_import_dialog(self): with ui.dialog() as dialog, ui.card().classes('w-full max-w-3xl'): ui.label('导入 Prompt').classes('text-lg font-bold') ui.label( '支持半角/全角逗号或换行分隔,以及 (tag:1.2)、1.2::tag::、{tag}、[tag] 和 @artist。' '无法确认的内容会保留在待确认区,不会进入最终 Prompt。' ).classes('text-sm text-gray-600') prompt_input = ui.textarea( label='粘贴 Prompt', placeholder='1girl, (white_serafuku:1.2), {rain}, @artist_name', ).props('outlined autogrow maxlength=20000').classes('w-full min-h-48') import_btn = None async def submit_import(): text = str(prompt_input.value or '').strip() if not text: ui.notify('请先粘贴 Prompt', type='warning') return import_btn.disable() try: tagger = await DanbooruTagger.get_instance() allow_nsfw = bool( nsfw_allowed() and self.input_nsfw and self.input_nsfw.value ) result = await asyncio.to_thread( resolve_prompt_text, text, resolve_tag=tagger.resolve_tag_name, resolve_artist=tagger.resolve_artist_name, lookup_tag=tagger.get_tag_workspace_metadata, allow_nsfw=allow_nsfw, ) if result.parsed_count == 0: ui.notify('没有解析到可导入内容', type='warning') import_btn.enable() return added_count, duplicate_count = self._apply_prompt_import_result(result) dialog.close() self._show_prompt_import_summary(result, added_count, duplicate_count) except Exception as exc: print(f'[UI] Prompt 导入异常: {exc}', flush=True) ui.notify('Prompt 导入失败,请检查输入内容', type='negative') import_btn.enable() with ui.row().classes('w-full justify-end gap-2'): ui.button('取消', on_click=dialog.close).props('flat') import_btn = ui.button( '解析并导入', icon='playlist_add', on_click=submit_import, ).props('unelevated color=purple-7') dialog.open() def _apply_prompt_import_result(self, result: PromptImportResult) -> tuple[int, int]: current = self._get_selected_tags() existing = set(current) existing_pending = { ( item.get('normalized'), bool(item.get('is_artist')), item.get('reason'), item.get('alias_target'), ) for item in self.workspace_state.get('dismissed', []) if isinstance(item, dict) and item.get('kind') == 'prompt_import_pending' } pending_to_add = [] duplicate_count = result.duplicate_count for pending in result.pending: key = ( pending.normalized, pending.is_artist, pending.reason, pending.alias_target, ) if key in existing_pending: duplicate_count += 1 continue existing_pending.add(key) pending_to_add.append(pending_to_workspace_entry(pending)) new_items = [item for item in result.items if item.tag not in existing] duplicate_count += len(result.items) - len(new_items) if new_items or pending_to_add: self._push_undo_snapshot() for item in new_items: existing.add(item.tag) current.append(item.tag) self.tag_weights[item.tag] = item.weight self._set_selection_meta( item.tag, 'prompt_import_artist' if item.is_artist else 'prompt_import', item.original, ) if pending_to_add: self.workspace_state['dismissed'] = ( list(self.workspace_state.get('dismissed', [])) + pending_to_add )[-2000:] if new_items or pending_to_add: self._set_selected_tags(current, record_undo=False) self._render_prompt_pending() return len(new_items), duplicate_count @staticmethod def _prompt_pending_reason(item: dict) -> str: reason = item.get('reason') if reason == 'alias_target_missing': target = item.get('alias_target') or '未知目标' return f'Alias 指向 {target},但目标不在当前标签库范围内' if reason == 'nsfw_filtered': return '当前 NSFW 设置不允许加入该标签' if reason == 'ambiguous_compact': return '存在多个可能的规范标签,请人工选择' if reason == 'not_found': return '当前标签库中无法唯一识别' if item.get('is_artist'): return '画师共现库中无法唯一识别' return f'无法识别({reason or "unknown"})' def _show_prompt_import_summary( self, result: PromptImportResult, added_count: int, duplicate_count: int, ): with ui.dialog() as dialog, ui.card().classes('w-full max-w-3xl max-h-[85vh]'): ui.label('Prompt 导入结果').classes('text-lg font-bold') ui.label( f'解析 {result.parsed_count} 项 · 新增 {added_count} 项 · ' f'纠正 {len(result.corrections)} 项 · 重复合并 {duplicate_count} 项 · ' f'待确认 {len(result.pending)} 项' ).classes('text-sm text-gray-700 bg-slate-50 rounded p-3') with ui.scroll_area().classes('w-full max-h-[58vh]'): if result.corrections: ui.label('名称规范化').classes('font-bold text-teal-700 mt-2') for correction in result.corrections: ui.label( f'{correction.original} → {correction.canonical}' ).classes('text-sm font-mono text-teal-800') if result.pending: ui.label('未加入,已进入待确认区').classes( 'font-bold text-orange-700 mt-3' ) for item in result.pending: pending_record = pending_to_workspace_entry(item) ui.label( f'{item.original}:{self._prompt_pending_reason(pending_record)}' ).classes('text-sm text-orange-800 break-all') with ui.row().classes('w-full justify-end'): ui.button('知道了', on_click=dialog.close).props('unelevated color=primary') dialog.open() def _render_prompt_pending(self): if self.prompt_pending_container is None: return self.prompt_pending_container.clear() pending_items = [ item for item in self.workspace_state.get('dismissed', []) if isinstance(item, dict) and item.get('kind') == 'prompt_import_pending' ] if not pending_items: return with self.prompt_pending_container: with ui.expansion( f'待确认内容({len(pending_items)})', icon='help_outline', value=True, ).classes('w-full bg-orange-50 border border-orange-200 rounded'): ui.label( '以下内容不会出现在复制结果中。可选择可靠候选,或从待确认区移除。' ).classes('text-xs text-orange-700 mb-2') for item in pending_items: with ui.row().classes( 'w-full items-center justify-between gap-2 border-t border-orange-100 py-2' ): with ui.column().classes('gap-0 min-w-0 flex-grow'): ui.label(str(item.get('original') or '')).classes( 'text-sm font-mono text-gray-800 break-all' ) ui.label(self._prompt_pending_reason(item)).classes( 'text-xs text-orange-700' ) with ui.row().classes('gap-1 flex-wrap justify-end'): if item.get('reason') not in {'alias_target_missing', 'nsfw_filtered'}: for candidate in item.get('candidates', [])[:5]: ui.button( str(candidate), on_click=lambda i=item, c=str(candidate): self._accept_prompt_candidate(i, c), ).props('flat dense color=teal-7').classes('text-xs font-mono') ui.button( icon='close', on_click=lambda i=item: self._remove_prompt_pending(i), ).props('flat round dense color=grey-6') def _remove_prompt_pending(self, pending: dict): pending_id = pending.get('pending_id') self._push_undo_snapshot() self.workspace_state['dismissed'] = [ item for item in self.workspace_state.get('dismissed', []) if not ( isinstance(item, dict) and item.get('kind') == 'prompt_import_pending' and item.get('pending_id') == pending_id ) ] self._save_staged_tags() self._render_prompt_pending() def _accept_prompt_candidate(self, pending: dict, candidate: str): tagger = DanbooruTagger._instance is_artist = bool(pending.get('is_artist')) if tagger is None: ui.notify('标签引擎尚未就绪,请稍后再试', type='warning') return if is_artist: resolved = tagger.resolve_artist_name(candidate) canonical = resolved.get('artist') else: resolved = tagger.resolve_tag_name(candidate) canonical = resolved.get('tag') if not canonical: ui.notify('该候选目前无法加入工作区', type='warning') return if not is_artist: metadata = tagger.get_tag_workspace_metadata(canonical) or {} nsfw_blocked = ( str(metadata.get('nsfw', '0')) == '1' and not bool(nsfw_allowed() and self.input_nsfw and self.input_nsfw.value) ) if nsfw_blocked: ui.notify('当前 NSFW 设置不允许加入该候选', type='warning') return self._push_undo_snapshot() self.workspace_state['dismissed'] = [ item for item in self.workspace_state.get('dismissed', []) if not ( isinstance(item, dict) and item.get('kind') == 'prompt_import_pending' and item.get('pending_id') == pending.get('pending_id') ) ] current = self._get_selected_tags() if canonical not in current: current.append(canonical) self.tag_weights[canonical] = float(pending.get('weight', 1.0)) self._set_selection_meta( canonical, 'prompt_import_artist' if is_artist else 'prompt_import', str(pending.get('original') or ''), ) self._set_selected_tags(current, record_undo=False) self._render_prompt_pending() ui.notify( f"已确认:{pending.get('normalized') or pending.get('original')} → {canonical}", type='positive', ) async def _canonicalize_workspace_for_load( self, workspace: dict, *, source: str, ) -> WorkspaceCanonicalizationResult: tagger = await DanbooruTagger.get_instance() return await asyncio.to_thread( canonicalize_workspace_tags, workspace, resolve_tag=tagger.resolve_tag_name, resolve_artist=tagger.resolve_artist_name, lookup_tag=tagger.get_tag_workspace_metadata, artist_origins=_ARTIST_ORIGINS, source=source, ) def _show_workspace_canonicalization( self, result: WorkspaceCanonicalizationResult, label: str, ): if not result.corrections and not result.pending and not result.duplicate_count: return with ui.dialog() as dialog, ui.card().classes('w-full max-w-2xl'): ui.label(f'{label}标签规范化结果').classes('text-lg font-bold') ui.label( f'纠正 {len(result.corrections)} 项 · ' f'重复合并 {result.duplicate_count} 项 · ' f'待确认 {len(result.pending)} 项' ).classes('text-sm text-gray-600') if result.corrections: with ui.column().classes('w-full gap-1'): for correction in result.corrections: ui.label( f'{correction.original} → {correction.canonical}' ).classes('text-sm font-mono text-teal-800') if result.pending: ui.label('未识别内容已移入工作区待确认区,不会进入复制结果。').classes( 'text-sm text-orange-700' ) with ui.row().classes('w-full justify-end'): ui.button('知道了', on_click=dialog.close).props('unelevated color=primary') dialog.open() def _open_save_favorite_dialog(self): if not self._get_selected_tags(): ui.notify('当前工作区没有可收藏的标签', type='warning') return with ui.dialog() as dialog, ui.card().classes('w-full max-w-lg'): ui.label('保存当前工作区为收藏').classes('text-lg font-bold') name_input = ui.input('收藏名称').props('outlined maxlength=80').classes('w-full') notes_input = ui.textarea('备注(可选)').props( 'outlined autogrow maxlength=500' ).classes('w-full') def save(): name = (name_input.value or '').strip() if not name: ui.notify('请输入收藏名称', type='warning') return if any(item['name'] == name for item in self.favorites.get('items', [])): ui.notify('已存在同名收藏,请在收藏列表中使用“覆盖”', type='warning') return favorite = favorite_from_workspace( self.workspace_state, name, notes=(notes_input.value or '').strip(), ) candidate = { 'schema_version': 1, 'items': [favorite] + self.favorites.get('items', [])[:199], } if not self._replace_favorites_safely(candidate): return dialog.close() ui.notify(f'已保存收藏:{name}', type='positive') with ui.row().classes('w-full justify-end gap-2'): ui.button('取消', on_click=dialog.close).props('flat') ui.button('保存', icon='bookmark_add', on_click=save).props( 'unelevated color=teal-7' ) dialog.open() def _open_favorites_dialog(self): with ui.dialog() as dialog, ui.card().classes('w-full max-w-5xl max-h-[88vh]'): with ui.row().classes('w-full items-center justify-between'): ui.label('收藏').classes('text-lg font-bold') ui.button(icon='close', on_click=dialog.close).props('flat round dense') with ui.scroll_area().classes('w-full h-[70vh]'): items = self.favorites.get('items', []) if not items: ui.label('暂无收藏').classes('text-sm text-gray-400 p-6') for item in items: with ui.card().classes('w-full mb-2 p-3 border border-amber-100 shadow-none'): with ui.row().classes('w-full items-start justify-between gap-3'): with ui.column().classes('gap-1 flex-grow min-w-0'): ui.label(item['name']).classes('font-bold text-gray-800') ui.label( f"{len(item['selected'])} 个标签 · {item['prompt_format'].upper()} · " f"更新于 {item['updated_at']}" ).classes('text-xs text-gray-400') if item.get('source_query'): ui.label(f"来源:{item['source_query']}").classes( 'text-xs text-gray-500 break-all' ) if item.get('notes'): ui.label(item['notes']).classes('text-xs text-gray-600') with ui.row().classes('gap-1 flex-wrap justify-end max-w-xl'): ui.button( '替换载入', icon='file_open', on_click=lambda i=item, d=dialog: self._load_favorite(i, False, d), ).props('flat dense color=teal-7') ui.button( '合并', icon='merge', on_click=lambda i=item, d=dialog: self._load_favorite(i, True, d), ).props('flat dense color=primary') ui.button( '复制', icon='content_copy', on_click=lambda i=item: self._copy_favorite(i), ).props('flat dense color=grey-7') ui.button( '重命名', icon='edit', on_click=lambda i=item, d=dialog: self._rename_favorite(i, d), ).props('flat dense color=grey-7') ui.button( '覆盖', icon='save', on_click=lambda i=item, d=dialog: self._overwrite_favorite(i, d), ).props('flat dense color=amber-8') ui.button( '导出', icon='download', on_click=lambda i=item: self._export_favorite(i), ).props('flat dense color=purple-7') ui.button( icon='delete_outline', on_click=lambda i=item, d=dialog: self._confirm_delete_favorite(i, d), ).props('flat round dense color=red-6') dialog.open() async def _load_favorite(self, favorite: dict, merge: bool, dialog): if merge: workspace = merge_favorite_into_workspace(self.workspace_state, favorite) message = f"已合并收藏:{favorite['name']}" else: workspace = replace_with_favorite(favorite) message = f"已载入收藏:{favorite['name']}" normalized = await self._canonicalize_workspace_for_load( workspace, source=f"收藏恢复:{favorite['name']}", ) self._push_undo_snapshot() self._apply_workspace_state(normalized.workspace) dialog.close() ui.notify(message, type='positive') self._show_workspace_canonicalization(normalized, f"收藏“{favorite['name']}”") def _copy_favorite(self, favorite: dict): parts: list[str] = [] for item in favorite.get('selected', []): tag = item['tag'] if favorite.get('prompt_format') == 'anima' and item.get('origin') in _ARTIST_ORIGINS: tag = f'@{tag}' parts.append(_format_tag_with_weight( tag, item.get('weight', 1.0), favorite.get('prompt_format', 'sdxl'), )) ui.clipboard.write(', '.join(parts)) ui.notify(f"已复制收藏:{favorite['name']}", type='positive') def _rename_favorite(self, favorite: dict, parent_dialog): with ui.dialog() as dialog, ui.card().classes('w-full max-w-md'): ui.label('重命名收藏').classes('font-bold') name_input = ui.input('新名称', value=favorite['name']).props( 'outlined maxlength=80' ).classes('w-full') def rename(): name = (name_input.value or '').strip() if not name: ui.notify('请输入名称', type='warning') return if any( item['favorite_id'] != favorite['favorite_id'] and item['name'] == name for item in self.favorites.get('items', []) ): ui.notify('已存在同名收藏', type='warning') return candidate = normalize_favorites(self.favorites)[0] for item in candidate['items']: if item['favorite_id'] == favorite['favorite_id']: item['name'] = name item['updated_at'] = utc_now_iso() break if not self._replace_favorites_safely(candidate): return dialog.close() parent_dialog.close() self._open_favorites_dialog() with ui.row().classes('w-full justify-end gap-2'): ui.button('取消', on_click=dialog.close).props('flat') ui.button('保存', on_click=rename).props('unelevated color=primary') dialog.open() def _overwrite_favorite(self, favorite: dict, parent_dialog): if not self._get_selected_tags(): ui.notify('当前工作区没有标签,不能覆盖收藏', type='warning') return replacement = favorite_from_workspace( self.workspace_state, favorite['name'], notes=favorite.get('notes', ''), favorite_id=favorite['favorite_id'], created_at=favorite.get('created_at'), ) candidate = { 'schema_version': 1, 'items': [ replacement if item['favorite_id'] == favorite['favorite_id'] else item for item in self.favorites.get('items', []) ], } if not self._replace_favorites_safely(candidate): return parent_dialog.close() ui.notify(f"已覆盖收藏:{favorite['name']}", type='positive') def _export_favorite(self, favorite: dict): payload = { 'schema_version': 1, 'exported_at': favorite.get('updated_at', ''), 'favorite': favorite, } raw = dump_collection(payload, label='favorite export').encode('utf-8') safe_name = re.sub(r'[^0-9A-Za-z\u4e00-\u9fff_-]+', '_', favorite['name'])[:60] ui.download(raw, filename=f'danbooru_favorite_{safe_name or "export"}.json', media_type='application/json') def _confirm_delete_favorite(self, favorite: dict, parent_dialog): with ui.dialog() as dialog, ui.card(): ui.label(f"确定删除收藏“{favorite['name']}”吗?") with ui.row().classes('w-full justify-end gap-2'): ui.button('取消', on_click=dialog.close).props('flat') def delete(): candidate = { 'schema_version': 1, 'items': [ item for item in self.favorites.get('items', []) if item['favorite_id'] != favorite['favorite_id'] ], } if not self._replace_favorites_safely(candidate): return dialog.close() parent_dialog.close() ui.notify('收藏已删除', type='positive') ui.button('删除', on_click=delete).props('unelevated color=red-7') dialog.open() def _open_backup_dialog(self): with ui.dialog() as dialog, ui.card().classes('w-full max-w-2xl'): ui.label('本地数据备份与迁移').classes('text-lg font-bold') ui.label( '导出文件包含当前配置、工作区、原始搜索查询、历史和收藏。' '文件只会在你主动下载或上传时离开浏览器。' ).classes('text-sm text-orange-700 bg-orange-50 rounded p-3') ui.button( '导出完整 JSON', icon='download', on_click=self._export_backup, ).props('unelevated color=primary').classes('w-full') ui.separator() import_mode = ui.select( { 'merge': '合并:保留当前权重和配置', 'overwrite': '覆盖:使用备份中的全部数据', 'favorites_only': '只导入收藏', }, value='merge', label='导入方式', ).props('outlined').classes('w-full') pending_import = {'raw': None, 'name': ''} pending_label = ui.label( '选择文件后,请检查文件名和导入方式,再点击“确认导入”。' ).classes('text-sm text-gray-500') confirm_import_btn = None async def handle_upload(event): try: raw = await event.file.text('utf-8') _json.loads(raw) pending_import['raw'] = raw pending_import['name'] = event.file.name pending_label.text = f'已选择:{event.file.name};尚未执行导入' confirm_import_btn.enable() except (UnicodeDecodeError, _json.JSONDecodeError) as exc: pending_import['raw'] = None pending_import['name'] = '' pending_label.text = '文件读取失败,请重新选择有效的 JSON 文件。' confirm_import_btn.disable() ui.notify(f'文件读取失败:{exc}', type='negative', timeout=5000) except Exception as exc: pending_import['raw'] = None pending_import['name'] = '' pending_label.text = '文件读取失败,请重新选择。' confirm_import_btn.disable() print(f'[UI] JSON 文件读取异常: {exc}', flush=True) ui.notify('文件读取失败,请检查文件格式', type='negative') async def confirm_import(): raw = pending_import.get('raw') if not isinstance(raw, str): ui.notify('请先选择 JSON 文件', type='warning') return confirm_import_btn.disable() try: await self._import_backup_text(raw, import_mode.value) dialog.close() except (WorkspaceDataError, ValueError) as exc: ui.notify(f'导入失败:{exc}', type='negative', timeout=5000) confirm_import_btn.enable() except Exception as exc: print(f'[UI] JSON 导入异常: {exc}', flush=True) ui.notify('导入失败,请检查文件格式', type='negative') confirm_import_btn.enable() ui.upload( label='选择 JSON 文件', on_upload=handle_upload, auto_upload=True, max_file_size=12_000_000, on_rejected=lambda: ui.notify('文件过大,仅支持 12 MB 以内的 JSON', type='warning'), ).props('accept=.json').classes('w-full') with ui.row().classes('w-full justify-end gap-2'): ui.button('关闭', on_click=dialog.close).props('flat') confirm_import_btn = ui.button( '确认导入', icon='check', on_click=confirm_import, ).props('unelevated color=primary') confirm_import_btn.disable() dialog.open() def _export_backup(self): backup = build_backup( config=self._collect_config_state(), workspace=self.workspace_state, history=self.search_history, favorites=self.favorites, ) raw = _json.dumps(backup, ensure_ascii=False, indent=2).encode('utf-8') filename = f"danbooru_workspace_backup_{utc_now_iso()[:10]}.json" ui.download(raw, filename=filename, media_type='application/json') async def _import_backup_text(self, raw: str, mode: str): # 单个收藏导出文件也可直接从这里重新导入。 try: parsed = _json.loads(raw) except _json.JSONDecodeError as exc: raise WorkspaceDataError('文件不是有效 JSON') from exc if isinstance(parsed, dict) and 'favorite' in parsed: incoming = { 'schema_version': 1, 'items': [parsed['favorite']], } normalized, warnings = normalize_favorites(incoming) candidate = merge_favorites(self.favorites, normalized) if not self._replace_favorites_safely(candidate): return ui.notify('收藏已导入', type='positive') if warnings: print(f'[UI] 收藏导入提示: {warnings}', flush=True) return backup, warnings = normalize_backup(parsed) workspace_normalization = None if mode != 'favorites_only': workspace_normalization = await self._canonicalize_workspace_for_load( backup['workspace'], source='JSON 备份导入', ) backup['workspace'] = workspace_normalization.workspace if mode == 'favorites_only': candidate = merge_favorites(self.favorites, backup['favorites']) if not self._replace_favorites_safely(candidate): return message = '收藏已合并导入' elif mode == 'overwrite': if not self._replace_favorites_safely(backup['favorites']): return self._push_undo_snapshot() self.search_history = backup['history'] self._apply_config_state(backup['config']) self._apply_workspace_state(backup['workspace']) self._save_history() message = '本地数据已由备份覆盖' else: workspace = merge_workspaces(self.workspace_state, backup['workspace']) merged_history = merge_history(self.search_history, backup['history']) candidate = merge_favorites(self.favorites, backup['favorites']) if not self._replace_favorites_safely(candidate): return self._push_undo_snapshot() self.search_history = merged_history self._apply_workspace_state(workspace) self._save_history() message = '备份已合并;当前标签权重和配置保持不变' self._update_workspace_counts() ui.notify(message, type='positive', timeout=4000) if workspace_normalization is not None: self._show_workspace_canonicalization( workspace_normalization, 'JSON 备份工作区', ) if warnings: print(f'[UI] 备份导入提示: {sorted(set(warnings))}', flush=True) # ── 已选标签栏 ──────────────────────────────────────────────────────── def _build_selection_bar(self): self.selection_bar_card = ui.element('div').classes('w-full bg-blue-50 p-4') with self.selection_bar_card: with ui.row().classes('w-full items-center justify-between'): with ui.row().classes('items-center gap-2'): ui.icon('check_circle', color='primary') ui.label('已选标签').classes('font-bold text-primary') self.selection_count_label = ui.label('0').classes( 'bg-primary text-white px-2 rounded-full text-sm') with ui.icon('info_outline', size='sm', color='grey').classes('cursor-help'): with ui.tooltip().props('content-class="bg-black text-white shadow-4"'): ui.html( '点击 / + 可调整标签权重(步长 0.1,范围 0.1~1.9)。
' '权重 1.0 时输出原始标签;其余输出 (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('点击此处以反馈失败案例。
您的搜索词将被匿名收集用于优化引擎(不包含个人隐私)。').style('font-size:14px;line-height:1.5;') self.bad_case_btn = _bad_btn self.bad_case_btn.disable() self.bad_case_btn.on_click(self.report_bad_case) self.format_toggle_btn = ui.button( 'SDXL', icon='swap_horiz' ).props('dense flat color=grey-7').classes('text-xs font-mono') with self.format_toggle_btn: with ui.tooltip().props('content-class="bg-black text-white shadow-4"'): ui.html( '切换复制格式:
' 'SDXL(tag:1.2)
' 'NAI1.2::tag::
' 'Anima(tag:1.5) 下划线→空格' ).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' ) self.prompt_pending_container = ui.column().classes('w-full gap-2 mt-2') def _render_selected_chips(self): """按稳定 Tag Group 规则渲染;复制顺序仍使用原始选择顺序。""" 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 grouped: dict[str, list[str]] = {name: [] for name in WORKSPACE_GROUP_ORDER} for tag in tags: grouped[self._workspace_group_for_tag(tag)].append(tag) with self.selected_chips_container: step = 0.5 if self.prompt_format == 'anima' else 0.1 for group_name in WORKSPACE_GROUP_ORDER: group_tags = grouped[group_name] if not group_tags: continue with ui.element('div').classes('w-full px-1 py-1'): ui.label(f'{group_name} · {len(group_tags)}').classes( 'text-xs font-bold text-slate-500 mb-1' ) with ui.row().classes('w-full gap-1 flex-wrap'): for tag in group_tags: self._render_selected_tag_chip(tag, step) def _workspace_group_for_tag(self, tag: str) -> str: is_artist = tag in self._workspace_artist_tags category = 'Artist' if is_artist else 'Other' groups: set[str] = set() tagger = DanbooruTagger._instance if tagger is not None: metadata = tagger.get_tag_workspace_metadata(tag) if metadata: category = str(metadata.get('category') or category) groups = set(metadata.get('groups') or []) elif self.result_table is not None: for row in self.result_table.rows: if row.get('tag') == tag: category = str(row.get('category') or category) break return classify_workspace_tag( category=category, tag_groups=groups, is_artist=is_artist, ) def _render_selected_tag_chip(self, tag: str, step: float): 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}' display_label = _format_selected_tag_label(tag, self._get_cn_name_for_tag(tag)) with ui.element('div').classes(f'weight-chip {extra_cls}'): metadata = self._pending_selection_meta.get(tag, {}) reason = selected_tag_reason( metadata.get('origin'), metadata.get('source'), ) with ui.tooltip().props('content-class="bg-black text-white shadow-4"'): ui.label(reason).style('font-size:13px;') 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, s=step: self._adjust_weight(t, -s) ): ui.html('−') ui.label(display_label).style( 'font-family:Consolas,Monaco,monospace;font-size:12px;' 'color:#2c5282;max-width:240px;overflow:hidden;' 'text-overflow:ellipsis;white-space:nowrap;' ) if w != 1.0: ui.label(w_str).classes('weight-label').style( 'color:#e65100;font-weight:bold;' ) plus_btn = ui.element('button').classes('weight-btn').on( 'click', lambda t=tag, s=step: self._adjust_weight(t, +s) ) if self.prompt_format == 'anima': with plus_btn: with ui.tooltip().props('content-class="bg-black text-white shadow-4"'): ui.html('Anima模型所需要的权重数值较大').style('font-size:12px;') with plus_btn: ui.html('+') def _adjust_weight(self, tag: str, delta: float): """调整单个标签权重。Anima 模式范围 [0.5, 5.0],其他模式 [0.1, 1.9]。""" current = self.tag_weights.get(tag, 1.0) new_w = round(current + delta, 1) if self.prompt_format == 'anima': min_w, max_w = 0.5, 5.0 else: min_w, max_w = 0.1, 1.9 if new_w < min_w: ui.notify(f'权重范围为 {min_w} ~ {max_w},已到达最小值', type='warning', timeout=2000) return if new_w > max_w: ui.notify(f'权重范围为 {min_w} ~ {max_w},已到达最大值', type='warning', timeout=2000) return self._push_undo_snapshot() self.tag_weights[tag] = new_w self._save_staged_tags() self._render_selected_chips() def _get_cn_name_for_tag(self, tag: str) -> str: """尽量从当前 UI 数据中取标签中文名,用于已选区展示。""" if self.result_table is not None: for row in self.result_table.rows: if row.get('tag') == tag: return str(row.get('cn_name') or '') for item in self.current_related: if getattr(item, 'tag', None) == tag: return str(getattr(item, 'cn_name', '') or '') for item in self.workspace_state.get('selected', []): if item.get('tag') == tag: return str(item.get('cn_name') or '') try: tagger = DanbooruTagger._instance if tagger and tagger.df is not None and tag in tagger._name_to_idx: idx = tagger._name_to_idx[tag] return str(tagger.df.iloc[idx].get('cn_name', '') or '') except Exception: pass return '' 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 = LEGACY_STAGED_STORAGE_KEY def _set_selection_meta(self, tag: str, origin: str, source: str = ''): self._pending_selection_meta[tag] = { 'origin': origin, 'source': source, } def _push_undo_snapshot(self): snapshot = clone_workspace(self.workspace_state) signature = workspace_signature(snapshot) if self._undo_stack and workspace_signature(self._undo_stack[-1]) == signature: return self._undo_stack.append(snapshot) self._undo_stack = self._undo_stack[-30:] self._redo_stack.clear() self._update_undo_buttons() def _update_undo_buttons(self): if self.undo_btn is not None: self.undo_btn.enable() if self._undo_stack else self.undo_btn.disable() if self.redo_btn is not None: self.redo_btn.enable() if self._redo_stack else self.redo_btn.disable() def _apply_workspace_state( self, workspace: dict, *, persist: bool = True, refresh_recommendations: bool = True, ): self.workspace_state = clone_workspace(workspace) selected = self.workspace_state['selected'] tags = [item['tag'] for item in selected] tag_set = set(tags) self._selected_order = list(tags) self.tag_weights = {item['tag']: item.get('weight', 1.0) for item in selected} self._pending_selection_meta = { item['tag']: { 'origin': item.get('origin', 'unknown'), 'source': item.get('source', ''), } for item in selected } self._workspace_artist_tags = { item['tag'] for item in selected if item.get('origin') in _ARTIST_ORIGINS } table_tags = {row['tag'] for row in self.result_table.rows} if self.result_table else set() self.chip_extra_selected.clear() self.chip_extra_selected.update(tag for tag in tags if tag not in table_tags) if self.result_table is not None: self.result_table.selected = [ row for row in self.result_table.rows if row.get('tag') in tag_set ] self._apply_prompt_format(self.workspace_state.get('prompt_format', 'sdxl')) self._render_selected_chips() self._render_prompt_pending() self._render_concept_coverage() if self.selection_count_label is not None: self.selection_count_label.text = str(len(tags)) if self.results_section is not None: self.results_section.set_visibility(bool(tags) or bool(self.full_table_data)) if persist: self._save_staged_tags() self._save_config() if refresh_recommendations: show_nsfw = bool(self.input_nsfw.value) if self.input_nsfw else False self._last_recommendation_seed_tags = [] self._refresh_recommendations_if_seed_changed(tags, show_nsfw) def _undo_workspace(self): if not self._undo_stack: ui.notify('没有可撤销的操作', type='info', timeout=1500) return self._redo_stack.append(clone_workspace(self.workspace_state)) target = self._undo_stack.pop() self._apply_workspace_state(target) self._update_undo_buttons() ui.notify('已撤销', type='positive', timeout=1500) def _redo_workspace(self): if not self._redo_stack: ui.notify('没有可恢复的操作', type='info', timeout=1500) return self._undo_stack.append(clone_workspace(self.workspace_state)) self._undo_stack = self._undo_stack[-30:] target = self._redo_stack.pop() self._apply_workspace_state(target) self._update_undo_buttons() ui.notify('已恢复', type='positive', timeout=1500) def _save_staged_tags(self): """将实时选择同步到版本化 WorkspaceState 并写入 localStorage。""" tags = self._get_selected_tags() self._selected_order = list(tags) cn_names = {t: self._get_cn_name_for_tag(t) for t in tags} self.workspace_state['prompt_format'] = self.prompt_format self.workspace_state = sync_selected_entries( self.workspace_state, tags, self.tag_weights, cn_names, self._pending_selection_meta, ) self._workspace_artist_tags = { item['tag'] for item in self.workspace_state['selected'] if item.get('origin') in _ARTIST_ORIGINS } try: data = dump_workspace(self.workspace_state) except WorkspaceDataError as exc: print(f'[UI] 工作区保存前校验失败: {exc}', flush=True) return try: if getattr(ui.context.client, '_deleted', False): return ui.run_javascript( f"localStorage.setItem('{WORKSPACE_STORAGE_KEY}', {_json.dumps(data)});" ) except RuntimeError: pass # 事件上下文已销毁(UI 重建中),数据仍在内存里,下次保存时会同步 def _save_history(self): if not self._client_alive(): return while True: try: data = dump_collection(self.search_history, label='history') ui.run_javascript( f"localStorage.setItem('{HISTORY_STORAGE_KEY}', {_json.dumps(data)});" ) return except WorkspaceDataError as exc: if not self.search_history.get('items'): print(f'[UI] 搜索历史保存失败: {exc}', flush=True) return self.search_history['items'].pop() print('[UI] 搜索历史超过本地大小限制,已移除最旧记录。', flush=True) except RuntimeError as exc: print(f'[UI] 搜索历史保存失败: {exc}', flush=True) return def _save_favorites(self): if not self._client_alive(): return False try: data = dump_collection(self.favorites, label='favorites') ui.run_javascript( f"localStorage.setItem('{FAVORITES_STORAGE_KEY}', {_json.dumps(data)});" ) return True except (WorkspaceDataError, RuntimeError) as exc: print(f'[UI] 收藏保存失败: {exc}', flush=True) return False def _replace_favorites_safely(self, favorites: dict) -> bool: previous = self.favorites self.favorites = favorites if self._save_favorites(): self._update_workspace_counts() return True self.favorites = previous ui.notify('收藏数据过大或无法写入,操作已取消', type='negative') return False async def _restore_staged_tags(self): """恢复 WorkspaceState;首次升级时兼容迁移旧已选标签。""" try: if getattr(ui.context.client, '_deleted', False): return stored = await ui.run_javascript( "({" f"workspace: localStorage.getItem('{WORKSPACE_STORAGE_KEY}')," f"legacy: localStorage.getItem('{self._STAGED_LS_KEY}')," f"config: localStorage.getItem('{_CONFIG_LS_KEY}')," f"history: localStorage.getItem('{HISTORY_STORAGE_KEY}')," f"favorites: localStorage.getItem('{FAVORITES_STORAGE_KEY}')" "})", timeout=5.0, ) except Exception: return if not isinstance(stored, dict): stored = {} raw_workspace = stored.get('workspace') warnings: list[str] = [] should_persist = False try: if raw_workspace: workspace, warnings = normalize_workspace(raw_workspace) should_persist = bool(warnings) else: workspace, warnings = migrate_legacy_workspace( stored.get('legacy'), stored.get('config'), ) should_persist = True except WorkspaceDataError as exc: print(f'[UI] 工作区数据损坏,回退到旧状态迁移: {exc}', flush=True) # 保留原始损坏内容供用户恢复,不直接删除或覆盖唯一副本。 if raw_workspace: try: backup_key = f'{WORKSPACE_STORAGE_KEY}_corrupt_backup' ui.run_javascript( f"if (!localStorage.getItem('{backup_key}')) " f"localStorage.setItem('{backup_key}', {_json.dumps(raw_workspace)});" ) except RuntimeError: pass workspace, migration_warnings = migrate_legacy_workspace( stored.get('legacy'), stored.get('config'), ) warnings = ['workspace_corrupt'] + migration_warnings should_persist = True self._apply_workspace_state( workspace, persist=False, refresh_recommendations=False, ) if should_persist: self._save_staged_tags() try: self.search_history, history_warnings = normalize_history(stored.get('history')) except WorkspaceDataError as exc: print(f'[UI] 搜索历史损坏,已使用空历史: {exc}', flush=True) self._backup_corrupt_storage(HISTORY_STORAGE_KEY, stored.get('history')) self.search_history, history_warnings = empty_history(), ['history_corrupt'] if history_warnings: self._save_history() warnings.extend(history_warnings) try: self.favorites, favorite_warnings = normalize_favorites(stored.get('favorites')) except WorkspaceDataError as exc: print(f'[UI] 收藏数据损坏,已使用空收藏: {exc}', flush=True) self._backup_corrupt_storage(FAVORITES_STORAGE_KEY, stored.get('favorites')) self.favorites, favorite_warnings = empty_favorites(), ['favorites_corrupt'] if favorite_warnings: self._save_favorites() warnings.extend(favorite_warnings) self._install_workspace_storage_listener() self._update_undo_buttons() self._update_workspace_counts() if warnings: print(f'[UI] 工作区恢复提示: {sorted(set(warnings))}', flush=True) def _backup_corrupt_storage(self, key: str, raw_value): if not raw_value: return try: backup_key = f'{key}_corrupt_backup' ui.run_javascript( f"if (!localStorage.getItem('{backup_key}')) " f"localStorage.setItem('{backup_key}', {_json.dumps(raw_value)});" ) except RuntimeError: pass def _install_workspace_storage_listener(self): """其他标签页修改工作区时提示刷新,避免静默覆盖。""" if self._workspace_storage_listener_installed: return self._workspace_storage_listener_installed = True try: ui.run_javascript(f""" if (!window.__danbooruWorkspaceStorageListenerV1) {{ window.__danbooruWorkspaceStorageListenerV1 = true; window.addEventListener('storage', (event) => {{ const watchedKeys = new Set([ '{WORKSPACE_STORAGE_KEY}', '{HISTORY_STORAGE_KEY}', '{FAVORITES_STORAGE_KEY}', ]); if (watchedKeys.has(event.key) && event.newValue !== event.oldValue) {{ const reload = window.confirm( '工作区数据已在另一个标签页更新。是否重新加载当前页面以同步最新内容?' ); if (reload) window.location.reload(); }} }}); }} """) except RuntimeError: pass def _clear_all_staged(self): """清空所有已选标签。""" self._mark_interaction() pending_items = [ item for item in self.workspace_state.get('dismissed', []) if isinstance(item, dict) and item.get('kind') == 'prompt_import_pending' ] if self._get_selected_tags() or pending_items: self._push_undo_snapshot() if pending_items: self.workspace_state['dismissed'] = [ item for item in self.workspace_state.get('dismissed', []) if not ( isinstance(item, dict) and item.get('kind') == 'prompt_import_pending' ) ] self.chip_extra_selected.clear() self._selected_order.clear() self.tag_weights.clear() self._pending_selection_meta.clear() self._workspace_artist_tags.clear() if self.result_table is not None: self.result_table.selected = [] self._artist_rec_checkboxes.clear() self._current_artist_rec_tags.clear() self._artist_result_tags.clear() self._last_recommendation_seed_tags = [] self._render_selected_chips() self._render_prompt_pending() self._render_concept_coverage() 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) self._render_artist_rec([], {}) # 清空 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): self.two_col_container = ui.element('div').classes('w-full two-col-layout') with self.two_col_container: # ── 左栏:语义匹配结果(表格)── 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('translation_feedback', self.report_translation_error) self.result_table.on('pagination', lambda _: self._save_config()) # 自定义行模板:行背景色按分类,整行悬浮显示 wiki(NSFW模糊行除外) self.result_table.add_slot('body', r'''
{{ props.row.tag }}
这位画师经常画:
{{ props.row.category === 'General' ? '[通用]' : props.row.category === 'Character' ? '[角色]' : props.row.category === 'Copyright' ? '[作品]' : '' }}{{ props.row.wiki }}
{{ props.row.cn_name }}
''') # ── Group 同类扩展(左栏,表格下方)── ui.separator().classes('my-2') with ui.row().classes('items-center justify-between w-full mb-1'): with ui.row().classes('items-center gap-2'): ui.label('同类标签').classes('font-bold text-sm text-gray-600') with ui.icon('info_outline', size='xs', color='grey').classes('cursor-help'): with ui.tooltip().props('content-class="bg-black text-white shadow-4"'): ui.label('基于标签分组数据,展示已选标签所属分组中的其他标签。勾选可加入已选。').style('font-size:14px;') ui.button('根据已选刷新', icon='refresh', on_click=self._manual_refresh_group) \ .props('dense flat color=primary').classes('text-sm') self.group_expansion_container = ui.column().classes('w-full gap-0') with self.group_expansion_container: ui.label('请先搜索并勾选标签…').classes('text-sm text-gray-400 italic p-4') # ── 右栏:推荐画师 + 关联推荐 ── with ui.card().classes('col-right'): # 推荐画师 with ui.row().classes('items-center justify-between w-full mb-2'): with ui.row().classes('items-center gap-2'): ui.label('推荐擅长画师(Beta)').classes('font-bold text-lg text-gray-800') with ui.icon('info_outline', size='sm', color='grey').classes('cursor-help'): with ui.tooltip().props('content-class="bg-black text-white shadow-4"'): ui.html( '基于标签-画师 NPMI 共现数据,根据您当前已选的标签,推荐擅长这些元素的画师。
悬停画师行可查看与该画师共现关联最强的标签。').style( 'font-size:14px;line-height:1.5;') self.artist_rec_list = ui.column().classes('w-full gap-0') with self.artist_rec_list: ui.label('请先搜索并勾选标签…').classes('text-sm text-gray-400 italic p-4') self.artist_rec_pagination = ui.column().classes('w-full') ui.separator().classes('my-3') # 关联推荐 with ui.row().classes('items-center justify-between w-full mb-2'): with ui.row().classes('items-center gap-2'): ui.label('关联推荐').classes('font-bold text-lg text-gray-800') with ui.icon('info_outline', size='sm', color='grey').classes('cursor-help'): with ui.tooltip().props('content-class="bg-black text-white shadow-4"'): ui.html( '基于标签共现数据,发掘语义之外的相关性,为您推荐更多可能的标签。
勾选可加入或移出已选。如需根据最新选项更新推荐,请点击刷新按钮。').style( 'font-size:14px;line-height:1.5;') # 新增手动刷新按钮 ui.button('根据已选刷新', icon='refresh', on_click=self._manual_refresh_related) \ .props('dense flat color=primary').classes('text-sm') self.related_list_container = ui.column().classes('w-full gap-0') with self.related_list_container: ui.label('请先搜索并勾选标签…').classes('text-sm text-gray-400 italic p-4') # ══════════════════════════════════════════════════════════════════════ # 渲染关联推荐列表 # ══════════════════════════════════════════════════════════════════════ def _render_related_list(self, related: list, show_nsfw: bool): self.related_list_container.clear() self._related_checkboxes.clear() filtered = [r for r in related if not (r.nsfw == '1' and not show_nsfw)] if not filtered: with self.related_list_container: ui.label('暂无推荐').classes('text-sm text-gray-400 italic p-4') return selected_now = set(self._get_selected_tags()) with self.related_list_container: for r in filtered: tag = r.tag cn_first = r.cn_name.split(',')[0].strip() if r.cn_name else '' is_selected = tag in selected_now score_pct = f'+{r.cooc_score * 100:.0f}%' # 获取 wiki wiki_text = '' try: tagger = DanbooruTagger._instance if tagger and tagger.df is not None and tag in tagger._name_to_idx: idx = tagger._name_to_idx[tag] wiki_text = str(tagger.df.iloc[idx].get('wiki', '')) except Exception: pass sources_str = '、'.join( s.replace('tag_group:', '') for s in r.sources ) if r.sources else '—' CAT_LABEL = {'General': '通用', 'Character': '角色', 'Copyright': '作品'} cat_label = CAT_LABEL.get(r.category, '') tooltip_html = '' if wiki_text: prefix = f'[{cat_label}]' if cat_label else '' tooltip_html += f'
{prefix}{wiki_text}
' tooltip_html += ( f'
' f'{r.cn_name}
' f'共现: {r.cooc_count:,} 相关度: {r.cooc_score:.2f}
' f'来自选中: {sources_str}' f'
' ) # 行背景色按分类区分 CAT_BG = { 'General': 'background-color: rgba(59,130,246,0.06);', # 淡蓝 'Character': 'background-color: rgba(34,197,94,0.06);', # 淡绿 'Copyright': 'background-color: rgba(168,85,247,0.06);', # 淡紫 } row_bg = CAT_BG.get(r.category, '') # 整行容器,tooltip 挂在行上 with ui.row().classes( 'w-full flex-nowrap items-center gap-2 px-3 py-2 overflow-hidden ' 'related-item border-b border-gray-100' ).style(row_bg): # 整行 wiki tooltip if tooltip_html: with ui.tooltip().props('content-class="bg-black text-white shadow-4" max-width="500px"'): ui.html(tooltip_html).style('font-size:14px;line-height:1.5;max-width:480px;') # Checkbox cb = ui.checkbox( '', value=is_selected, on_change=lambda e, t=tag: self._on_related_checkbox_change(t, e.value) ).props('dense').classes('flex-none') self._related_checkboxes[tag] = cb # 标签名(可点击跳转)+ 中文名 with ui.column().classes('flex-1 gap-0 min-w-0 overflow-hidden'): with ui.row().classes('w-full flex-nowrap items-center gap-1 min-w-0 overflow-hidden'): link = ui.link( tag, f'https://danbooru.donmai.us/wiki_pages/{tag}', new_tab=True ).classes( 'tag-link text-primary font-bold text-xs flex-1 min-w-0 truncate' ) link.on('click', self._mark_interaction) if r.sources and r.sources[0].startswith('tag_group:'): group_display = r.sources[0].replace('tag_group:', '') ui.label(group_display).classes( 'text-xs text-orange-500 font-bold bg-orange-50 px-1 rounded' ) if cn_first: ui.label(cn_first).classes('w-full text-xs text-gray-500 truncate') ui.label( related_candidate_reason(r.sources) ).classes('w-full text-xs text-slate-500 truncate') # 关联分数 score_color = 'green' if r.cooc_score > 0.6 else ('teal' if r.cooc_score > 0.3 else 'grey') ui.label(score_pct).classes( f'flex-none ml-auto self-center text-sm font-bold ' f'text-{score_color}-600 whitespace-nowrap' ) # ══════════════════════════════════════════════════════════════════════ # 交互逻辑 # ══════════════════════════════════════════════════════════════════════ async def _hide_banner_when_ready(self): while not DanbooruTagger.is_ready(): if not self._client_alive(): return await asyncio.sleep(1) if not self._client_alive(): return if self.init_banner: self.init_banner.set_visibility(False) self._update_service_status() self._update_footer_text() # 工作区可能早于引擎恢复;引擎就绪后用真实 Category / Tag Group # 元数据重新分组,避免重启期间首次渲染的标签全部停留在“其他”。 self._render_selected_chips() def _client_alive(self) -> bool: try: client = self.client if client is None and self.search_btn is not None: client = self.search_btn.client return client is not None and not bool(getattr(client, '_deleted', False)) except (AttributeError, RuntimeError): return False # ── 分词筛选 ────────────────────────────────────────────────────────── def _filter_by_source(self, keyword: str): self.current_filter_keyword = keyword if keyword else 'ALL' show_nsfw_val = self.input_nsfw.value if not keyword or keyword == 'ALL': filtered = self.full_table_data else: filtered = [r for r in self.full_table_data if r['source'] == keyword] self.result_table.rows = apply_nsfw_filter(filtered, show_nsfw_val) self._render_concept_coverage() def _render_concept_coverage(self): if self.coverage_container is None: return self.coverage_container.clear() if not self.current_query_str: return segments = list(dict.fromkeys(self.current_segments + self.current_keywords)) if not segments: segments = [self.current_query_str] coverage = compute_concept_coverage( segments, self.full_table_data, self._get_selected_tags(), ) if not coverage: return status_counts = { COVERED: sum(item.status == COVERED for item in coverage), CANDIDATE_UNSELECTED: sum( item.status == CANDIDATE_UNSELECTED for item in coverage ), UNCOVERED: sum(item.status == UNCOVERED for item in coverage), } def decorate_chip(chip, source: str) -> None: if source in self.current_cached_queries: chip.style( 'outline: 1px dashed rgba(100,116,139,0.45); outline-offset: 1px;' ) if source == self.current_filter_keyword: chip.style('box-shadow: 0 0 0 2px #4a90e2;') with self.coverage_container: with ui.element('div').classes('w-full query-insight-panel'): with ui.row().classes('w-full items-center gap-2 flex-wrap'): ui.icon('manage_search', color='primary', size='sm') ui.label('查询理解').classes('text-sm font-bold text-slate-700') ui.label( f"已覆盖 {status_counts[COVERED]} · " f"有候选 {status_counts[CANDIDATE_UNSELECTED]} · " f"未覆盖 {status_counts[UNCOVERED]}" ).classes('text-xs text-slate-500') with ui.icon('info_outline', size='xs', color='grey').classes('cursor-help'): with ui.tooltip().props('content-class="bg-black text-white shadow-4"'): ui.label( '点击概念可筛选对应结果;红色未覆盖项点击后会发起补充搜索。' '覆盖状态根据当前结果来源和工作区已选标签近似判断。' ).style('font-size:13px;') with ui.row().classes('w-full gap-2 flex-wrap mt-2'): all_chip = ui.chip( '全部结果', on_click=lambda: self._filter_by_source('ALL') ) if self.current_filter_keyword == 'ALL': all_chip.props('color=primary text-color=white clickable') else: all_chip.props('color=grey-3 text-color=grey-9 clickable') use_segmentation = self.input_segment.value if self.input_segment else True if use_segmentation and self.current_query_str not in segments: whole_chip = ui.chip( '整句', on_click=lambda: self._filter_by_source(self.current_query_str), ).props('color=blue-grey-1 text-color=blue-grey-9 clickable') decorate_chip(whole_chip, self.current_query_str) for item in coverage: if item.status == COVERED: chip = ui.chip( item.segment, icon='check_circle', on_click=lambda s=item.segment: self._filter_by_source(s), ).props('color=green-1 text-color=green-9 clickable') detail = ( f"已覆盖;已选择:{'、'.join(item.selected_tags)}。" '点击筛选此概念的搜索结果。' ) elif item.status == CANDIDATE_UNSELECTED: chip = ui.chip( item.segment, icon='radio_button_unchecked', on_click=lambda s=item.segment: self._filter_by_source(s), ).props('color=amber-1 text-color=amber-9 clickable') detail = ( f"有候选:{'、'.join(item.candidate_tags[:5])}。" '点击筛选此概念的搜索结果。' ) else: chip = ui.chip( item.segment, icon='search', on_click=lambda s=item.segment: self._search_uncovered_segment(s), ).props('color=red-1 text-color=red-8 clickable') detail = '点击后沿用当前搜索设置进行补充搜索;工作区标签保持不变。' decorate_chip(chip, item.segment) with chip: with ui.tooltip().props('content-class="bg-black text-white shadow-4"'): ui.label(detail).style('font-size:13px;') async def _search_uncovered_segment(self, segment: str): segment = str(segment or '').strip() if not segment: return self.search_input.set_value(segment) await self.perform_search() # ── 搜索 ────────────────────────────────────────────────────────────── async def perform_search(self): query = self.search_input.value.strip() if not query: return # 搜索前校验数值参数 _err_fields = [] if self.input_top_k and (self.input_top_k.value is None or str(self.input_top_k.value).strip() == ''): _err_fields.append('Top K') if self.input_limit and (self.input_limit.value is None or str(self.input_limit.value).strip() == ''): _err_fields.append('返回数量') if self.input_weight and (self.input_weight.value is None or str(self.input_weight.value).strip() == ''): _err_fields.append('热度权重') if _err_fields: ui.notify(f'请填写:{"、".join(_err_fields)}', type='negative', timeout=3000) return # 搜索前保存配置 self._save_config() self.current_query_str = query self.search_btn.disable() self.spinner.classes(remove='hidden') ui.notify('正在搜索...', type='info') if self.bad_case_btn is not None: self.bad_case_btn.disable() target_layers_list = [k for k, v in self.selected_layers.items() if v] target_cats_list = [k for k, v in self.selected_cats.items() if v] if not target_layers_list: ui.notify('请至少选择一个匹配层!', type='warning') self.search_btn.enable() self.spinner.classes(add='hidden') return search_started_at = time.perf_counter() search_invoked_at = time.monotonic() normalized_telemetry_query = ' '.join(query.split()).casefold() await telemetry.increment("ui_search") if ( normalized_telemetry_query == self._telemetry_last_query and search_invoked_at - self._telemetry_last_search_at <= 60 ): await telemetry.increment("ui_repeat_search_60s") self._telemetry_last_query = normalized_telemetry_query self._telemetry_last_search_at = search_invoked_at try: tagger = await DanbooruTagger.get_instance() show_nsfw_val = self.input_nsfw.value request = SearchRequest( query=query, top_k=int(self.input_top_k.value), limit=int(self.input_limit.value), popularity_weight=float(self.input_weight.value), show_nsfw=show_nsfw_val, use_segmentation=self.input_segment.value if self.input_segment else True, target_layers=target_layers_list, target_categories=target_cats_list, 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, ) response = await tagger.search_async(request) await telemetry.record_timing( "ui_search_latency", (time.perf_counter() - search_started_at) * 1000, ) if not response.results: await telemetry.increment("ui_zero_result") self._telemetry_search_started_at = search_started_at self._telemetry_selection_recorded = False self._telemetry_copy_timing_recorded = False # 后台计数 async def silent_counter_update(): try: await counter.increment() if response.keywords: await counter.add_keywords(response.keywords) self._update_footer_text() except Exception as e: print(f"[UI] 后台静默更新计数失败: {e}", flush=True) asyncio.create_task(silent_counter_update()) if not self._client_alive(): return table_data = [result_to_row(r, show_nsfw_val) for r in response.results] self._artist_result_tags = {row['tag'] for row in table_data if row.get('layer') == 'artist'} self.full_table_data = table_data self.full_tags_str = response.tags_all self.full_tags_str_sfw = response.tags_sfw self.current_segments = list(response.segments) if response.segments else [] self.current_keywords = list(response.keywords) if response.keywords else [] self.current_cached_queries = set(response.cached_queries or []) self.results_section.set_visibility(True) _saved_rpp = self._get_rows_per_page() self.result_table.rows = apply_nsfw_filter(table_data, show_nsfw_val) self._set_rows_per_page(_saved_rpp) all_selected = self._get_selected_tags() self._selected_order = list(all_selected) self.chip_extra_selected.clear() self.chip_extra_selected.update(all_selected) self.result_table.selected = [] self._render_selected_chips() self._update_selection_display(None) self._save_staged_tags() self._refresh_related([], show_nsfw_val) self._last_recommendation_seed_tags = [] # 查询理解:分词来源筛选与概念覆盖共用同一组 chips self.current_filter_keyword = 'ALL' self._render_concept_coverage() self._record_search_history(query) ui.notify(f'找到 {len(table_data)} 个标签', type='positive') self.current_search_interacted = False if self.bad_case_btn is not None: self.bad_case_btn.enable() except RuntimeError as e: if 'deleted' in str(e).lower() or 'client' in str(e).lower(): return try: ui.notify(f'错误: {str(e)}', type='negative') except RuntimeError: pass except Exception as e: try: ui.notify(f'错误: {str(e)}', type='negative') except RuntimeError: pass finally: try: self.search_btn.enable() self.spinner.classes(add='hidden') except RuntimeError: pass # ── 选择管理 ────────────────────────────────────────────────────────── def _get_selected_tags(self) -> list[str]: table_tags = [row['tag'] for row in self.result_table.selected] if self.result_table else [] seen = set(table_tags) extra_pool = set(self.chip_extra_selected) extra = [t for t in self._selected_order if t in extra_pool and t not in seen] seen.update(extra) extra.extend(sorted(t for t in extra_pool if t not in seen)) return table_tags + extra def _get_recommendation_seed_tags(self, selected_tags: list[str]) -> list[str]: artist_tags = ( set(self._current_artist_rec_tags) | set(self._artist_result_tags) | set(self._workspace_artist_tags) ) if self.result_table is not None: for row in self.result_table.rows: if row.get('layer') != 'artist': continue tag = row.get('tag') if tag: artist_tags.add(tag) return [tag for tag in selected_tags if tag not in artist_tags] def _refresh_recommendations_if_seed_changed(self, selected_tags: list[str], show_nsfw: bool): seed_tags = self._get_recommendation_seed_tags(selected_tags) if seed_tags == self._last_recommendation_seed_tags: return self._last_recommendation_seed_tags = list(seed_tags) self._refresh_related_from_selection(seed_tags, show_nsfw) self._refresh_group_from_selection(seed_tags, show_nsfw) self._refresh_artist_from_selection(seed_tags, show_nsfw) def _set_selected_tags( self, tags: list[str], skip_refresh: bool = False, record_undo: bool = True, ): tags = list(dict.fromkeys(tags)) previous_tags = [item['tag'] for item in self.workspace_state.get('selected', [])] if record_undo and tags != previous_tags: self._push_undo_snapshot() self._selected_order = list(tags) tag_set = set(tags) table_tag_set = {row['tag'] for row in self.result_table.rows} if self.result_table else set() self.chip_extra_selected.clear() self.chip_extra_selected.update(t for t in tag_set if t not in table_tag_set) # clean up weights for deselected tags for t in list(self.tag_weights): if t not in tag_set: del self.tag_weights[t] self._pending_selection_meta.pop(t, None) if self.result_table is not None: self.result_table.selected = [row for row in self.result_table.rows if row.get('tag') in tag_set] # 同步推荐画师 checkbox for t, cb in self._artist_rec_checkboxes.items(): cb.set_value(t in tag_set) all_tags = self._get_selected_tags() self._selected_order = list(all_tags) self._record_first_selection_if_needed(previous_tags, all_tags) if self.selection_count_label is not None: self.selection_count_label.text = str(len(all_tags)) self._save_staged_tags() self._render_selected_chips() self._render_concept_coverage() # 显式刷新关联推荐和 Group 区域(不依赖 table.on('selection') 事件, # 因为在 chip 点击回调上下文中该事件可能不可靠)。 # 从关联推荐/同类标签勾选时跳过,由各自动态刷新或手动按钮触发。 if not skip_refresh: show_nsfw_val = self.input_nsfw.value self._refresh_recommendations_if_seed_changed(all_tags, show_nsfw_val) if not all_tags: self.chip_extra_selected.clear() def _update_selection_display(self, _e): if self.result_table is None: return self._mark_interaction() all_tags = self._get_selected_tags() previous_tags = [item['tag'] for item in self.workspace_state.get('selected', [])] self._record_first_selection_if_needed(previous_tags, all_tags) if all_tags != previous_tags: self._push_undo_snapshot() existing = set(previous_tags) for row in self.result_table.rows: tag = row.get('tag') if not tag or tag in existing or tag not in all_tags: continue origin = 'artist_search' if row.get('layer') == 'artist' else 'semantic_search' self._set_selection_meta(tag, origin, str(row.get('source') or self.current_query_str)) self._selected_order = list(all_tags) # clean up weights for deselected tags tag_set = set(all_tags) for t in list(self.tag_weights): if t not in tag_set: del self.tag_weights[t] self._pending_selection_meta.pop(t, None) # init weight for newly selected tags for t in all_tags: self.tag_weights.setdefault(t, 1.0) if self.selection_count_label is not None: self.selection_count_label.text = str(len(all_tags)) self._render_selected_chips() # 同步推荐画师 checkbox for t, cb in self._artist_rec_checkboxes.items(): cb.set_value(t in tag_set) show_nsfw_val = self.input_nsfw.value self._refresh_recommendations_if_seed_changed(all_tags, show_nsfw_val) if not all_tags: self.chip_extra_selected.clear() self._save_staged_tags() self._render_concept_coverage() def _on_related_checkbox_change(self, tag: str, checked: bool): self._mark_interaction() current = self._get_selected_tags() if checked: if tag not in current: source = '' for item in self.current_related: if getattr(item, 'tag', None) == tag: source = ', '.join(getattr(item, 'sources', []) or []) break self._set_selection_meta(tag, 'related_recommendation', source) current.append(tag) self.tag_weights.setdefault(tag, 1.0) self._set_selected_tags(current, skip_refresh=True) ui.notify(f'已添加 {tag}', type='positive', timeout=1500) else: if tag in current: current.remove(tag) self.tag_weights.pop(tag, None) self._set_selected_tags(current, skip_refresh=True) ui.notify(f'已移除 {tag}', type='warning', timeout=1500) # 刷新推荐画师 show_nsfw_val = self.input_nsfw.value self._refresh_artist_from_selection(current, show_nsfw_val) def _on_group_checkbox_change(self, tag: str, checked: bool): """同类标签复选框变化回调。""" self._mark_interaction() current = self._get_selected_tags() if checked: if tag not in current: self._set_selection_meta( tag, 'tag_group', self._group_candidate_sources.get(tag, ''), ) current.append(tag) self.tag_weights.setdefault(tag, 1.0) self._set_selected_tags(current, skip_refresh=True) ui.notify(f'已添加 {tag}', type='positive', timeout=1500) else: if tag in current: current.remove(tag) self.tag_weights.pop(tag, None) self._set_selected_tags(current, skip_refresh=True) ui.notify(f'已移除 {tag}', type='warning', timeout=1500) # 即刻刷新关联推荐 + 画师推荐 show_nsfw_val = self.input_nsfw.value self._refresh_related_from_selection(current, show_nsfw_val) self._refresh_artist_from_selection(current, show_nsfw_val) def _on_artist_rec_checkbox_change(self, tag: str, checked: bool): """推荐画师复选框变化回调。""" self._mark_interaction() current = self._get_selected_tags() if checked: if tag not in current: self._set_selection_meta( tag, 'artist_recommendation', self._artist_rec_sources.get(tag, ''), ) current.append(tag) self.tag_weights.setdefault(tag, 1.0) self._set_selected_tags(current, skip_refresh=True) ui.notify(f'已添加画师 {tag}', type='positive', timeout=1500) else: if tag in current: current.remove(tag) self.tag_weights.pop(tag, None) self._set_selected_tags(current, skip_refresh=True) ui.notify(f'已移除画师 {tag}', type='warning', timeout=1500) def _manual_refresh_related(self): """手动触发关联推荐列表的刷新""" self._mark_interaction() show_nsfw_val = self.input_nsfw.value all_tags = self._get_selected_tags() if all_tags: self._refresh_related_from_selection(all_tags, show_nsfw_val) self._refresh_artist_from_selection(all_tags, show_nsfw_val) ui.notify('已触发关联推荐更新', type='info', timeout=1500) else: self.chip_extra_selected.clear() self._refresh_related([], show_nsfw_val) ui.notify('已清空关联推荐', type='info', timeout=1500) def _manual_refresh_group(self): """手动触发同类扩展区域的刷新""" self._mark_interaction() show_nsfw_val = self.input_nsfw.value all_tags = self._get_selected_tags() if all_tags: self._refresh_group_from_selection(all_tags, show_nsfw_val) ui.notify('已触发同类标签更新', type='info', timeout=1500) else: 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') ui.notify('暂未选中标签', type='info', timeout=1500) # ── 关联推荐 ────────────────────────────────────────────────────────── def _refresh_related(self, related: list, show_nsfw: bool): if related is None: related = [] selected_now = set(self._get_selected_tags()) old_related = self.current_related new_tags = {r.tag for r in related} preserved = [r for r in old_related if r.tag in selected_now and r.tag not in new_tags] merged = list(related) + preserved self.current_related = merged if self.related_list_container is not None: self._render_related_list(merged, show_nsfw) def _refresh_related_from_selection(self, selected_tags: list[str], show_nsfw: bool): """仅刷新关联推荐列表(300ms 去抖,避免快速勾选产生 CPU 洪峰)。""" selected_tags = self._get_recommendation_seed_tags(selected_tags) # 取消上次未执行的刷新 if self._debounce_related_task and not self._debounce_related_task.done(): self._debounce_related_task.cancel() async def _do(): await asyncio.sleep(0.3) if not selected_tags: self._refresh_related([], show_nsfw) return tagger = await DanbooruTagger.get_instance() related = await tagger.get_related_async( selected_tags, set(selected_tags), 50, show_nsfw, ) self._refresh_related(related, show_nsfw) self._debounce_related_task = asyncio.ensure_future(_do()) def _refresh_group_from_selection(self, selected_tags: list[str], show_nsfw: bool): """仅刷新同类扩展区域(300ms 去抖,避免快速勾选产生 CPU 洪峰)。""" selected_tags = self._get_recommendation_seed_tags(selected_tags) if self._debounce_group_task and not self._debounce_group_task.done(): self._debounce_group_task.cancel() async def _do(): await asyncio.sleep(0.3) if not selected_tags: 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') return tagger = await DanbooruTagger.get_instance() group_data = await tagger.get_group_candidates_async( selected_tags, show_nsfw, ) await self._capture_group_scroll_positions() self._render_group_expansion(group_data, selected_tags, show_nsfw) self._debounce_group_task = asyncio.ensure_future(_do()) def _refresh_artist_from_selection(self, selected_tags: list[str], show_nsfw: bool = True): """根据已选标签刷新画师推荐(300ms 去抖)。""" selected_tags = self._get_recommendation_seed_tags(selected_tags) if self._debounce_artist_task and not self._debounce_artist_task.done(): self._debounce_artist_task.cancel() async def _do(): await asyncio.sleep(0.3) if len(selected_tags) < 1: self._render_artist_rec([], {}, show_nsfw) return tagger = await DanbooruTagger.get_instance() artist_results = await tagger.search_artists_by_tags_async( selected_tags, limit=ARTIST_REC_LIMIT, min_cooc=3, ) top_tags = {} if artist_results: names = [r.artist for r in artist_results] top_tags = tagger.get_artist_top_tags(names, show_nsfw=show_nsfw) self._render_artist_rec(artist_results, top_tags, show_nsfw) self._debounce_artist_task = asyncio.ensure_future(_do()) def _set_artist_rec_page(self, page: int): """切换推荐画师页,仅切换已构建行的可见性。""" if self._artist_rec_page_count < 1: return self._artist_rec_page = max(1, min(page, self._artist_rec_page_count)) start = (self._artist_rec_page - 1) * ARTIST_REC_PAGE_SIZE end = start + ARTIST_REC_PAGE_SIZE for index, row in enumerate(self._artist_rec_rows): row.set_visibility(start <= index < end) if self._artist_rec_page_label is not None: self._artist_rec_page_label.text = ( f'{self._artist_rec_page} / {self._artist_rec_page_count}' ) if self._artist_rec_prev_button is not None: if self._artist_rec_page == 1: self._artist_rec_prev_button.disable() else: self._artist_rec_prev_button.enable() if self._artist_rec_next_button is not None: if self._artist_rec_page == self._artist_rec_page_count: self._artist_rec_next_button.disable() else: self._artist_rec_next_button.enable() def _render_artist_rec(self, artist_results, top_tags=None, show_nsfw: bool = True): """渲染推荐画师列表(对标关联推荐样式)。""" if self.artist_rec_list is None or self.artist_rec_pagination is None: return self.artist_rec_list.clear() self.artist_rec_pagination.clear() self._artist_rec_checkboxes.clear() self._artist_rec_rows.clear() self._artist_rec_page = 1 self._artist_rec_page_count = 0 self._artist_rec_page_label = None self._artist_rec_prev_button = None self._artist_rec_next_button = None self._current_artist_rec_tags.clear() self._artist_rec_sources.clear() if not artist_results: with self.artist_rec_list: ui.label('暂无推荐画师').classes('text-sm text-gray-400 italic p-4') return top_tags = top_tags or {} selected_now = set(self._get_selected_tags()) with self.artist_rec_list: for r in artist_results[:ARTIST_REC_LIMIT]: artist = r.artist self._current_artist_rec_tags.add(artist) is_selected = artist in selected_now # 归一化:除以命中标签数,cap 到 100% normalized = min(r.score / max(r.hit_count, 1), 1.0) score_pct = f'+{normalized * 100:.0f}%' reason = artist_candidate_reason(r.sources) self._artist_rec_sources[artist] = '、'.join(r.sources[:3]) post_str = f'{r.post_count:,}' if r.post_count else '—' # tooltip:画师擅长标签 tag_list = top_tags.get(artist, []) tooltip_html = f'
{artist}
这位画师经常画:
' if tag_list: for t in tag_list[:10]: tooltip_html += f' · {t}
' else: tooltip_html += ' (无数据)' tooltip_html += '
' with ui.row().classes( 'w-full items-center gap-2 px-3 py-2 related-item border-b border-gray-100' ).style('background: rgba(244,114,182,0.04);') as row: self._artist_rec_rows.append(row) # tooltip with ui.tooltip().props('content-class="bg-black text-white shadow-4" max-width="400px"'): ui.html(tooltip_html).style('font-size:14px;line-height:1.5;max-width:380px;') # Checkbox cb = ui.checkbox( '', value=is_selected, on_change=lambda e, t=artist: self._on_artist_rec_checkbox_change(t, e.value) ).props('dense') self._artist_rec_checkboxes[artist] = cb # 画师名 + 信息 with ui.column().classes('flex-grow gap-0 min-w-0'): ui.link( artist, f'https://danbooru.donmai.us/posts?tags={artist}', new_tab=True, ).classes('text-primary font-bold text-xs') ui.label(reason).classes('text-xs text-slate-500') ui.label(f'作品 {post_str}').classes('text-xs text-gray-400') # 分值 score_color = 'green' if normalized > 0.6 else ('teal' if normalized > 0.3 else 'grey') ui.label(score_pct).classes(f'text-sm font-bold text-{score_color}-600 whitespace-nowrap') self._artist_rec_page_count = ( len(self._artist_rec_rows) + ARTIST_REC_PAGE_SIZE - 1 ) // ARTIST_REC_PAGE_SIZE if self._artist_rec_page_count > 1: with self.artist_rec_pagination: with ui.row().classes('w-full items-center justify-center gap-2 px-3 py-2'): self._artist_rec_prev_button = ui.button( '‹', on_click=lambda: self._set_artist_rec_page(self._artist_rec_page - 1), ).props('flat dense round color=grey-7') self._artist_rec_page_label = ui.label().classes('text-xs text-gray-600 min-w-12 text-center') self._artist_rec_next_button = ui.button( '›', on_click=lambda: self._set_artist_rec_page(self._artist_rec_page + 1), ).props('flat dense round color=grey-7') self._set_artist_rec_page(1) def _render_group_expansion(self, group_data: list, selected_tags: list[str], show_nsfw: bool): """渲染 Group 同类扩展区域。""" if self.group_expansion_container is None: return self.group_expansion_container.clear() self._group_checkboxes.clear() self._group_candidate_sources.clear() group_key = _group_names_key(group_data) if group_key != self._group_render_key: self._group_render_key = group_key self._group_render_limits.clear() self._group_expanded_names.clear() self._group_scroll_positions.clear() if not group_data: with self.group_expansion_container: ui.label('已选标签无分组信息').classes('text-sm text-gray-400 italic p-2') return # 行背景色按分类区分(与关联推荐一致) CAT_BG = { 'General': 'background-color: rgba(59,130,246,0.06);', 'Character': 'background-color: rgba(34,197,94,0.06);', 'Copyright': 'background-color: rgba(168,85,247,0.06);', } CAT_LABEL = {'General': '通用', 'Character': '角色', 'Copyright': '作品'} selected_now = set(self._get_selected_tags()) with self.group_expansion_container: for group_info in group_data: group_name = group_info['group'] group_cn = group_info.get('group_cn_name', group_name.replace('tag_group:', '')) group_sources = list(group_info.get('sources') or []) group_reason = tag_group_candidate_reason(group_cn, group_sources) group_source_detail = group_cn if group_sources: group_source_detail += f";触发标签:{'、'.join(group_sources[:3])}" tags = group_info['tags'] visible_limit = self._group_render_limits.get(group_name, GROUP_RENDER_TAG_LIMIT) visible_tags, hidden_count = _limit_group_render_tags(tags, visible_limit) scroll_id = _group_scroll_dom_id(group_name) expansion = ui.expansion( f'{group_cn} ({len(tags)} 个标签)', icon='label', value=_should_group_start_expanded(group_name, self._group_expanded_names), ).classes('w-full').props('dense') expansion.on( 'update:model-value', lambda e, g=group_name: self._on_group_expansion_change(g, e), ) with expansion: with ui.element('div').props( f'id="{scroll_id}" data-danbooru-group-scroll="1"' ).classes('w-full grid grid-cols-2 gap-1 p-1').style('max-height: 600px; overflow-y: auto;'): for t in visible_tags: tag = t['tag'] self._group_candidate_sources.setdefault(tag, group_source_detail) cn_first = t['cn_name'].split(',')[0].strip() if t['cn_name'] else '' cn_full = t.get('cn_name', '') cat = t['category'] wiki_text = str(t.get('wiki', '')) row_bg = CAT_BG.get(cat, '') is_selected = tag in selected_now cat_label = CAT_LABEL.get(cat, '') tooltip_html = '' if wiki_text: prefix = f'[{cat_label}]' if cat_label else '' tooltip_html += f'
{prefix}{wiki_text}
' if cn_full: tooltip_html += f'
{cn_full}
' with ui.row().classes( 'w-full min-w-0 flex-nowrap items-center gap-1.5 px-2 py-1.5 ' 'rounded overflow-hidden related-item' ).style(row_bg): if tooltip_html: with ui.tooltip().props('content-class="bg-black text-white shadow-4" max-width="500px"'): ui.html(tooltip_html).style('font-size:14px;line-height:1.5;max-width:480px;') # 复选框 cb = ui.checkbox( '', value=is_selected, on_change=lambda e, t=tag: self._on_group_checkbox_change(t, e.value), ).props('dense').classes('flex-none') self._group_checkboxes[tag] = cb # 标签名 + 中文名(与关联推荐对齐方式一致) with ui.column().classes('flex-1 gap-0 min-w-0 overflow-hidden'): link = ui.link( tag, f'https://danbooru.donmai.us/wiki_pages/{tag}', new_tab=True, ).classes( 'tag-link w-full min-w-0 text-primary font-bold text-xs truncate' ) if cn_first: ui.label(cn_first).classes( 'w-full text-xs text-gray-500 truncate' ) ui.label(group_reason).classes( 'w-full text-xs text-slate-500 truncate' ) # 热度 count = t['post_count'] if count > 0: if count >= 10000: count_str = f'{count/1000:.0f}k' elif count >= 1000: count_str = f'{count/1000:.1f}k' else: count_str = str(count) ui.label(count_str).classes( 'flex-none ml-auto self-center text-sm font-bold ' 'text-grey-600 whitespace-nowrap' ) if hidden_count > 0: async def _load_more( g=group_name, total=len(tags), gd=group_data, st=list(selected_tags), sn=show_nsfw, ): await self._load_more_group_tags(g, total, gd, st, sn) ui.button( f'加载更多(剩余 {hidden_count} 个)', icon='expand_more', on_click=_load_more, ).props('dense flat color=primary').classes('col-span-2 text-xs') self._restore_group_scroll_positions() def _on_group_expansion_change(self, group_name: str, event): value = getattr(event, 'args', None) if isinstance(value, dict): value = value.get('value', value.get('modelValue')) if bool(value): self._group_expanded_names.add(group_name) else: self._group_expanded_names.discard(group_name) async def _capture_group_scroll_positions(self, *, anchor_bottom: bool = False): client = self.client if client is None or getattr(client, '_deleted', False): return anchor_flag = 'true' if anchor_bottom else 'false' try: raw = await client.run_javascript( f""" const anchorBottom = {anchor_flag}; const groupEntries = Array.from(document.querySelectorAll('[data-danbooru-group-scroll="1"]')) .flatMap(el => {{ const top = Math.round(el.scrollTop || 0); const entries = [[el.id, top]]; if (anchorBottom) {{ entries.push([`${{el.id}}__bottom__`, Math.round(el.scrollHeight - top)]); }} return entries; }}); JSON.stringify({{ __window__: Math.round( window.scrollY || document.documentElement.scrollTop || document.body.scrollTop || 0 ), ...Object.fromEntries(groupEntries), }}); """, timeout=1.0, ) except Exception: return try: data = _json.loads(raw) if isinstance(raw, str) else raw if isinstance(data, dict): self._group_scroll_positions = { str(k): int(v) for k, v in data.items() if str(k) and int(v) >= 0 } except Exception: pass def _restore_group_scroll_positions(self): if not self._group_scroll_positions: return client = self.client if client is None or getattr(client, '_deleted', False): return client.run_javascript(_scroll_state_restore_script(self._group_scroll_positions), timeout=1.0) async def _load_more_group_tags( self, group_name: str, total: int, group_data: list, selected_tags: list[str], show_nsfw: bool, ): await self._capture_group_scroll_positions(anchor_bottom=True) current = self._group_render_limits.get(group_name, GROUP_RENDER_TAG_LIMIT) self._group_render_limits[group_name] = _next_group_render_limit( current, total, GROUP_RENDER_TAG_LIMIT, ) self._group_expanded_names.add(group_name) self._render_group_expansion(group_data, selected_tags, show_nsfw) # ── 表格列动态更新 ────────────────────────────────────────────────── def _update_table_columns(self, e=None): cols = list(TABLE_COLUMNS) if self.sw_semantic and self.sw_semantic.value: cols.append(OPTIONAL_COLS['semantic']) if self.sw_layer and self.sw_layer.value: cols.append(OPTIONAL_COLS['layer']) if self.sw_source and self.sw_source.value: cols.append(OPTIONAL_COLS['source']) self.result_table.columns = cols # ── 搜索模式 / 参数联动 ────────────────────────────────────────────── def _on_search_mode_change(self, _e=None): mode = self.input_search_mode.value if self.input_search_mode else None if not mode or mode == '自定义' or mode not in _SEARCH_MODE_PRESETS: return preset = _SEARCH_MODE_PRESETS[mode] self._applying_preset = True try: if self.input_top_k: self.input_top_k.set_value(preset['top_k']) if self.input_limit: self.input_limit.set_value(preset['limit']) if self.input_weight: self.input_weight.set_value(preset['popularity_weight']) if self.input_segment: self.input_segment.set_value(preset['use_segmentation']) if self.input_group_mode: self.input_group_mode.set_value(preset['group_mode']) if self.input_max_per_group: self.input_max_per_group.set_value(preset['max_per_group']) finally: self._applying_preset = False def _on_param_changed(self, _e=None): if not self._applying_preset and self.input_search_mode: if self.input_search_mode.value != '自定义': self.input_search_mode.set_value('自定义') # ── NSFW 切换 ───────────────────────────────────────────────────────── def on_nsfw_toggle(self, e): show_nsfw_val = self.input_nsfw.value # 复用当前分词筛选:同时套用新 NSFW 状态并保持 chip 选中态 self._filter_by_source(self.current_filter_keyword) if not show_nsfw_val: self.result_table.selected = [r for r in self.result_table.selected if r.get('nsfw') != '1'] self._update_selection_display(None) # ── 复制 / 反馈 ────────────────────────────────────────────────────── def _toggle_prompt_format(self): if self.prompt_format == 'sdxl': self._apply_prompt_format('nai') elif self.prompt_format == 'nai': self._apply_prompt_format('anima') else: self._apply_prompt_format('sdxl') self._save_config() self._save_staged_tags() self._render_selected_chips() def copy_selection(self): self._mark_interaction() tags = self._get_selected_tags() parts = [] artist_tags = ( set(self._current_artist_rec_tags) | set(self._artist_result_tags) | set(self._workspace_artist_tags) ) for t in tags: w = self.tag_weights.get(t, 1.0) if self.prompt_format == 'anima' and t in artist_tags: parts.append(_format_tag_with_weight(f'@{t}', w, self.prompt_format)) else: parts.append(_format_tag_with_weight(t, w, self.prompt_format)) prompt = ', '.join(parts) ui.clipboard.write(prompt) fmt_label = {'sdxl': 'SDXL', 'nai': 'NAI', 'anima': 'Anima'}.get(self.prompt_format, 'SDXL') ui.notify(f'已复制选中标签({fmt_label} 格式)!', type='positive') if prompt: self._record_ui_copy("ui_copy_selected") def _copy_all_tags(self): self._mark_interaction() show_nsfw_val = self.input_nsfw.value tags_str = self.full_tags_str if show_nsfw_val else self.full_tags_str_sfw if tags_str: tags_str = tags_str.replace('(', '\\(').replace(')', '\\)') ui.clipboard.write(tags_str) ui.notify('已复制全部标签!', type='positive') self._record_ui_copy("ui_copy_all") else: ui.notify('暂无标签可复制', type='warning') def _feedback_settings(self) -> dict: return { 'top_k': int(self.input_top_k.value) if self.input_top_k else None, 'limit': int(self.input_limit.value) if self.input_limit else None, 'popularity_weight': float(self.input_weight.value) if self.input_weight else None, 'show_nsfw': self.input_nsfw.value if self.input_nsfw else None, 'use_segmentation': self.input_segment.value if self.input_segment else None, 'search_mode': self.input_search_mode.value if self.input_search_mode else '自定义', 'target_layers': [key for key, enabled in self.selected_layers.items() if enabled], 'target_categories': [key for key, enabled in self.selected_cats.items() if enabled], '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, } def report_bad_case(self): from platform_utils import PLATFORM query = self.current_query_str.strip() if len(query) <= 1: ui.notify('搜索词太短,无法提交反馈。', type='warning', timeout=2000) return with ui.dialog() as dialog, ui.card().classes('w-full max-w-lg'): ui.label('反馈搜索问题').classes('text-base font-bold text-gray-800') ui.label(f'当前搜索词:{query}').classes('text-sm text-gray-600') ui.label( '提交后会记录当前查询、搜索设置、应用版本和提交时间;' '不会上传账号信息、浏览器指纹、搜索历史或收藏。' ).classes('text-xs text-slate-500 bg-slate-50 rounded p-2') detail_input = ui.textarea( label='具体问题(可选)', placeholder='例如:结果偏题、缺少某个关键标签、召回了不相关角色/作品...', ).props('outlined autogrow maxlength=500 counter').classes('w-full') async def submit_feedback(): detail = (detail_input.value or '').strip() submit_btn.disable() try: await telemetry.add_feedback( feedback_type='search_bad_case', query=query, search_settings=self._feedback_settings(), app_version=_get_git_commit(), platform=PLATFORM, details=detail, ) if self.bad_case_btn is not None: self.bad_case_btn.disable() dialog.close() ui.notify('感谢反馈!我们会持续优化。', type='positive', timeout=3000) except Exception as e: print(f'[UI] bad_case 记录异常: {e}') submit_btn.enable() ui.notify('记录失败,请稍后再试。', type='warning', timeout=3000) with ui.row().classes('w-full justify-end gap-2'): ui.button('取消', on_click=dialog.close).props('flat color=grey-7') submit_btn = ui.button('提交反馈', on_click=submit_feedback).props('unelevated color=primary') dialog.open() def report_translation_error(self, e): from platform_utils import PLATFORM raw_args = getattr(e, 'args', None) # print(f'[UI] translation_feedback event received: {raw_args!r}', flush=True) row = raw_args if isinstance(row, list) and row: row = row[0] if not isinstance(row, dict): print(f'[UI] translation_feedback invalid payload: {raw_args!r}', flush=True) ui.notify('无法读取当前词条信息。', type='warning', timeout=2000) return tag = str(row.get('tag') or '').strip() current_cn_name = str(row.get('cn_name') or '').strip() if not tag: ui.notify('无法读取当前词条。', type='warning', timeout=2000) return query = self.current_query_str.strip() current_cn_first = current_cn_name.split(',', 1)[0].strip() with ui.dialog() as dialog, ui.card().classes('w-full max-w-lg'): ui.label('反馈翻译错误').classes('text-base font-bold text-gray-800') ui.label(f'词条:{tag}').classes('text-sm font-mono text-gray-700') ui.label(f'当前中文名:{current_cn_first or current_cn_name or "(空)"}').classes('text-sm text-gray-600') ui.label( '提交后会记录当前查询、该词条、当前翻译、搜索设置、应用版本和提交时间;' '不会上传账号信息、浏览器指纹、搜索历史或收藏。' ).classes('text-xs text-slate-500 bg-slate-50 rounded p-2') suggested_input = ui.input( label='建议中文名(可选)', placeholder='如果有更合适的译名,可以填在这里', ).props('outlined maxlength=120 counter').classes('w-full') detail_input = ui.textarea( label='问题说明(可选)', placeholder='例如:含义不准确、作品/角色名误译、中文名缺失...', ).props('outlined autogrow maxlength=500 counter').classes('w-full') async def submit_feedback(): suggested = (suggested_input.value or '').strip() detail = (detail_input.value or '').strip() submit_btn.disable() try: await telemetry.add_feedback( feedback_type='translation_error', query=query, search_settings=self._feedback_settings(), app_version=_get_git_commit(), platform=PLATFORM, details=detail, tag=tag, current_cn_name=current_cn_name, suggested_cn_name=suggested, category=str(row.get('category') or ''), ) dialog.close() ui.notify('感谢反馈!这条翻译问题已记录。', type='positive', timeout=3000) except Exception as e: print(f'[UI] translation_error 记录异常: {e}') submit_btn.enable() ui.notify('记录失败,请稍后再试。', type='warning', timeout=3000) with ui.row().classes('w-full justify-end gap-2'): ui.button('取消', on_click=dialog.close).props('flat color=grey-7') submit_btn = ui.button('提交反馈', on_click=submit_feedback).props('unelevated color=primary') dialog.open() # ── 页面路由 ─────────────────────────────────────────────────────────────────── @ui.page('/') async def main_page(): client = ui.context.client client_id = client.id def mark_connected(*_): _mark_ui_session_active(client_id) def mark_disconnected(*_): _mark_ui_session_inactive(client_id) client.on_connect(mark_connected) client.on_disconnect(mark_disconnected) app_ui = DanbooruSearchUI() app_ui.build_page() async def silent_visit_update(): try: await counter.increment_visit() await telemetry.increment("ui_visit") app_ui._update_footer_text() except Exception: pass asyncio.create_task(silent_visit_update()) # 恢复用户配置(在页面渲染完成后执行) await app_ui._restore_config() # 恢复已选标签(备选区) await app_ui._restore_staged_tags() # ── 入口 ─────────────────────────────────────────────────────────────────────── if __name__ in {'__main__', '__mp_main__'}: host, port = get_host_port() @app.on_startup def _warmup(): async def background_init_tasks(): await asyncio.sleep(5) print("[UI] 开始预热计数器与引擎", flush=True) await counter.init() await telemetry.init() cold_start_started_at = time.perf_counter() await telemetry.increment("engine_cold_start_attempt") try: await DanbooruTagger.get_instance() except Exception: await telemetry.increment("engine_cold_start_failure") await telemetry.record_timing( "engine_cold_start", (time.perf_counter() - cold_start_started_at) * 1000, ) raise await telemetry.increment("engine_cold_start_success") await telemetry.record_timing( "engine_cold_start", (time.perf_counter() - cold_start_started_at) * 1000, ) print("[UI] 后台预热全部完成!", flush=True) asyncio.create_task(background_init_tasks()) @app.on_shutdown def _shutdown(): async def force_sync_all(): await asyncio.gather(counter.force_sync(), telemetry.force_sync()) try: loop = asyncio.get_event_loop() if loop.is_running(): loop.create_task(force_sync_all()) else: asyncio.run(force_sync_all()) except Exception as e: print(f"[UI] 关机同步失败: {e}") app.mount('/api', api_app) mcp_app = mcp.streamable_http_app() app.mount('/mcp', mcp_app) _mcp_lifespan_ctx = None @app.on_startup async def _start_mcp(): global _mcp_lifespan_ctx _mcp_lifespan_ctx = mcp_app.router.lifespan_context(mcp_app) await _mcp_lifespan_ctx.__aenter__() @app.on_shutdown async def _stop_mcp(): global _mcp_lifespan_ctx if _mcp_lifespan_ctx is not None: await _mcp_lifespan_ctx.__aexit__(None, None, None) @app.get('/googlebd34b54f8562aa06.html') def google_verification(): return PlainTextResponse('google-site-verification: googlebd34b54f8562aa06.html') @app.get('/robots.txt') def robots_txt(): content = ( 'User-agent: *\n' 'Allow: /$\n' 'Disallow: /api/\n' 'Disallow: /_nicegui/\n' 'Disallow: /socket.io/\n' ) return PlainTextResponse(content) @app.get('/robots.txt') def robots_txt(): content = ( 'User-agent: *\n' 'Allow: /$\n' 'Disallow: /api/\n' 'Disallow: /_nicegui/\n' 'Disallow: /socket.io/\n' ) return PlainTextResponse(content) @app.head('/') async def head_root(): return PlainTextResponse('') ui.run( host=host, port=port, title='Danbooru Tags Searcher', reload=not is_cloud(), show=not is_cloud(), reconnect_timeout=120, )