Spaces:
Running
Running
| """ | |
| ui_nicegui.py | |
| ───────────── | |
| NiceGUI 前端层(重构版)。 | |
| ▸ 只负责渲染 / 交互。 | |
| ▸ 调用 core.engine.DanbooruTagger,通过 core.models 的数据结构通信。 | |
| ▸ 不包含任何算法逻辑。 | |
| ▸ 平台相关配置(host/port/云端判断)统一由 platform_utils 提供。 | |
| """ | |
| import sys | |
| sys.stdout.reconfigure(line_buffering=True) | |
| print("[UI] 脚本开始执行", flush=True) | |
| import asyncio | |
| import os | |
| import json as _json | |
| import subprocess | |
| import traceback | |
| from dataclasses import asdict | |
| from fastapi.responses import PlainTextResponse | |
| def _excepthook(exc_type, exc_value, exc_tb): | |
| print("[UI] FATAL ERROR ON STARTUP:", flush=True) | |
| traceback.print_exception(exc_type, exc_value, exc_tb) | |
| sys.__excepthook__(exc_type, exc_value, exc_tb) | |
| sys.excepthook = _excepthook | |
| from nicegui import ui, app, run | |
| from core import counter | |
| from api_fastapi import app as api_app | |
| from core.engine import DanbooruTagger | |
| from core.models import RelatedTag, SearchRequest | |
| from platform_utils import is_cloud, get_host_port, nsfw_allowed | |
| from mcp_server import mcp | |
| import logging | |
| logging.getLogger("httpx").setLevel(logging.WARNING) | |
| logging.getLogger("huggingface_hub").setLevel(logging.WARNING) | |
| logging.getLogger("mcp").setLevel(logging.WARNING) | |
| logging.getLogger("mcp.server").setLevel(logging.WARNING) | |
| logging.getLogger("fastmcp").setLevel(logging.WARNING) | |
| # suppress MCP streamable-HTTP transport noise ("No response returned" from Starlette middleware) | |
| class _SuppressMCPNoise(logging.Filter): | |
| _MARKER = "No response returned" | |
| def filter(self, record: logging.LogRecord) -> bool: | |
| if self._MARKER in record.getMessage(): | |
| return False | |
| if record.exc_info: | |
| import traceback | |
| tb_text = "".join(traceback.format_exception(*record.exc_info)) | |
| if self._MARKER in tb_text: | |
| return False | |
| return True | |
| logging.getLogger("uvicorn.error").addFilter(_SuppressMCPNoise()) | |
| # suppress MCP OAuth discovery 404 noise (clients probing .well-known/oauth-authorization-server) | |
| class _SuppressOAuthNoise(logging.Filter): | |
| _MARKER = ".well-known/oauth-authorization-server" | |
| def filter(self, record: logging.LogRecord) -> bool: | |
| if self._MARKER in record.getMessage(): | |
| return False | |
| return True | |
| logging.getLogger("uvicorn.access").addFilter(_SuppressOAuthNoise()) | |
| logging.getLogger("nicegui").addFilter(_SuppressOAuthNoise()) | |
| # ── 表格列定义 ───────────────────────────────────────────────────────────────── | |
| TABLE_COLUMNS = [ | |
| {'name': 'tag', 'label': '匹配标签', 'field': 'tag', 'align': 'left', 'sortable': True}, | |
| {'name': 'cn_name', 'label': '含义', 'field': 'cn_name', 'align': 'left'}, | |
| {'name': 'nsfw', 'label': '分级', 'field': 'nsfw', 'align': 'center', 'sortable': True}, | |
| {'name': 'final_score', 'label': '综合分', 'field': 'final_score', 'sortable': True}, | |
| {'name': 'count', 'label': '热度', 'field': 'count', 'sortable': True}, | |
| ] | |
| OPTIONAL_COLS = { | |
| 'semantic': {'name': 'semantic_score', 'label': '语义分', 'field': 'semantic_score', 'sortable': True}, | |
| 'layer': {'name': 'layer', 'label': '匹配层', 'field': 'layer'}, | |
| 'source': {'name': 'source', 'label': '匹配来源', 'field': 'source'}, | |
| } | |
| # localStorage key 与配置版本,版本变更时自动丢弃旧配置 | |
| _CONFIG_LS_KEY = 'danbooru_search_config' | |
| _CONFIG_VERSION = 5 | |
| # 搜索模式预设 | |
| _SEARCH_MODE_PRESETS: dict[str, dict] = { | |
| '精确查词': {'top_k': 20, 'limit': 10, 'popularity_weight': 0.15, 'use_segmentation': False, 'group_mode': 'off', 'max_per_group': 2}, | |
| '概念扩展': {'top_k': 80, 'limit': 80, 'popularity_weight': 0.15, 'use_segmentation': True, 'group_mode': 'expand', 'max_per_group': 2}, | |
| '描述查词': {'top_k': 20, 'limit': 20, 'popularity_weight': 0.15, 'use_segmentation': False, 'group_mode': 'off', 'max_per_group': 2}, | |
| '完整场景': {'top_k': 5, 'limit': 80, 'popularity_weight': 0.15, 'use_segmentation': True, 'group_mode': 'diverse', 'max_per_group': 2}, | |
| } | |
| _SEARCH_MODE_OPTIONS = ['自定义'] + list(_SEARCH_MODE_PRESETS.keys()) | |
| # ── 辅助函数 ─────────────────────────────────────────────────────────────────── | |
| def _get_git_commit() -> str: | |
| try: | |
| return subprocess.check_output( | |
| ['git', 'rev-parse', '--short', 'HEAD'], | |
| stderr=subprocess.DEVNULL, | |
| text=True, | |
| ).strip() | |
| except Exception: | |
| return os.environ.get('COMMIT_SHA', 'unknown')[:7] | |
| def result_to_row(r, nsfw_visible: bool) -> dict: | |
| d = asdict(r) | |
| d['_nsfw_blocked'] = (r.nsfw == '1') and not nsfw_visible | |
| return d | |
| def apply_nsfw_filter(rows: list[dict], show_nsfw: bool) -> list[dict]: | |
| result = [] | |
| for row in rows: | |
| r = dict(row) | |
| r['_nsfw_blocked'] = (r.get('nsfw') == '1') and not show_nsfw | |
| result.append(r) | |
| return result | |
| def _format_tag_with_weight(tag: str, weight: float, fmt: str = 'sdxl') -> str: | |
| """格式化单个标签。 | |
| sdxl: (tag:1.2) 权重 1.0 时输出 tag | |
| nai: 1.2::tag:: 权重 1.0 时输出 tag | |
| 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})' | |
| # ── UI 类 ───────────────────────────────────────────────────────────────────── | |
| class DanbooruSearchUI: | |
| def __init__(self): | |
| self.search_count_label = None | |
| self.current_search_interacted = True | |
| self.full_table_data: list[dict] = [] | |
| self.current_segments: list[str] = [] # 从句级原始片段,用于区分 chip 颜色 | |
| self.current_filter_keyword: str = 'ALL' # 当前选中的分词筛选 keyword(NSFW 切换时复用) | |
| self.current_query_str: str = "" | |
| self.full_tags_str: str = "" | |
| self.full_tags_str_sfw: str = "" | |
| self.result_table = None # 左栏表格 | |
| self.related_list_container = None # 右栏关联推荐列表 | |
| self.group_expansion_container = None # 左栏 Group 同类扩展(表格下方) | |
| self.results_section = None # 整个结果区域(搜索前隐藏) | |
| self.selection_count_label = None | |
| self.selected_display = None # 已废弃 textarea,保留兼容 | |
| self.selected_chips_container = None # 已选标签 chip 容器 | |
| self.current_related: list = [] | |
| self.chip_extra_selected: set = set() | |
| # 去抖任务句柄(取消旧任务避免 CPU 洪峰) | |
| self._debounce_related_task = None # type: asyncio.Task | None | |
| self._debounce_group_task = None # type: asyncio.Task | None | |
| # tag -> prompt 权重,范围 [0.1, 1.9],默认 1.0 | |
| self.tag_weights: dict[str, float] = {} | |
| # 复制格式:'sdxl'、'nai' 或 'anima' | |
| self.prompt_format: str = 'sdxl' | |
| self.format_toggle_btn = None | |
| # 画师查找模式 | |
| self.artist_search_mode: bool = False | |
| self.artist_search_btn = None | |
| self.artist_results_container = None | |
| self.current_artist_seed_tags: list[str] = [] # 本次画师搜索使用的种子标签 | |
| self.init_banner = None | |
| self.input_top_k = None | |
| self.input_limit = None | |
| self.input_weight = None | |
| self.input_nsfw = None | |
| self.input_segment = None | |
| self.input_search_mode = None | |
| self.input_group_mode = None | |
| self.input_max_per_group = None | |
| self._applying_preset = False | |
| self.search_input = None | |
| self.keywords_container = None | |
| self.spinner = None | |
| self.search_btn = None | |
| self.selected_layers = {'英文': True, '中文扩展词': True, '释义': True, '中文核心词': True} | |
| self.selected_cats = {'General': True, 'Copyright': True, 'Character': True} | |
| self.bad_case_btn = None | |
| self.mcp_notice = None | |
| self.notice_expansion = None | |
| # 表格显示选项开关 | |
| self.sw_semantic = None | |
| self.sw_layer = None | |
| self.sw_source = None | |
| # 关联推荐的 checkbox 引用 | |
| self._related_checkboxes: dict[str, ui.checkbox] = {} | |
| # 同类标签的 checkbox 引用 | |
| self._group_checkboxes: dict[str, ui.checkbox] = {} | |
| # 高级选项中各层/类型的 checkbox 引用,用于 restore 时同步控件状态 | |
| self._layer_checkboxes: dict[str, ui.checkbox] = {} | |
| self._cat_checkboxes: dict[str, ui.checkbox] = {} | |
| def _update_footer_text(self): | |
| if self.search_count_label is not None: | |
| try: | |
| total = counter.get() | |
| visits = counter.get_visits() | |
| commit = _get_git_commit() | |
| self.search_count_label.content = ( | |
| f'累计搜索 {total:,} 次 | 累计访问 {visits:,} 次 | ' | |
| f'<span class="font-mono text-gray-300">版本号: {commit}</span>' | |
| f'<br>' | |
| f'<a href="/api/docs" ' | |
| f'target="_blank" rel="noopener noreferrer" ' | |
| f'class="text-blue-400 hover:text-blue-600 hover:underline">使用 API 服务</a>' | |
| f' | <a href="https://github.com/SuzumiyaAkizuki/DanbooruSearchOnline#mcp-接口" ' | |
| f'target="_blank" rel="noopener noreferrer" ' | |
| f'class="text-blue-400 hover:text-blue-600 hover:underline">使用 MCP 服务</a>' | |
| ) | |
| except AttributeError: | |
| pass | |
| def _mark_interaction(self, e=None): | |
| if not self.current_search_interacted: | |
| self.current_search_interacted = True | |
| async def silent_success_update(): | |
| try: | |
| await counter.increment_success() | |
| except Exception: | |
| pass | |
| asyncio.create_task(silent_success_update()) | |
| # ── 分页辅助 ────────────────────────────────────────────────────────── | |
| def _get_rows_per_page(self) -> int: | |
| if self.result_table is None: | |
| return 0 | |
| p = self.result_table.pagination | |
| # pagination 可能是 int 或 dict | |
| if isinstance(p, dict): | |
| return int(p.get('rowsPerPage', 0)) | |
| return int(p) if p else 0 | |
| def _set_rows_per_page(self, value: int): | |
| if self.result_table is None: | |
| return | |
| allowed = {5, 7, 10, 15, 20, 25, 50, 0} # 0 = All | |
| value = value if value in allowed else 0 | |
| p = self.result_table.pagination | |
| if isinstance(p, dict): | |
| p['rowsPerPage'] = value | |
| self.result_table.pagination = p | |
| else: | |
| self.result_table.pagination = value | |
| # ── 配置持久化 ──────────────────────────────────────────────────────── | |
| def _save_config(self): | |
| """将当前控件状态序列化并写入 localStorage。""" | |
| cfg = { | |
| 'version': _CONFIG_VERSION, | |
| 'top_k': int(self.input_top_k.value) if self.input_top_k else 10, | |
| 'limit': int(self.input_limit.value) if self.input_limit else 80, | |
| 'popularity_weight': float(self.input_weight.value) if self.input_weight else 0.15, | |
| 'show_nsfw': bool(self.input_nsfw.value) if self.input_nsfw else False, | |
| 'use_segmentation': bool(self.input_segment.value) if self.input_segment else True, | |
| 'selected_layers': dict(self.selected_layers), | |
| 'selected_cats': dict(self.selected_cats), | |
| 'sw_semantic': bool(self.sw_semantic.value) if self.sw_semantic else False, | |
| 'sw_layer': bool(self.sw_layer.value) if self.sw_layer else False, | |
| 'sw_source': bool(self.sw_source.value) if self.sw_source else False, | |
| 'prompt_format': self.prompt_format, | |
| 'rows_per_page': self._get_rows_per_page(), | |
| 'search_query': self.search_input.value if self.search_input else '', | |
| 'notice_expanded': bool(self.notice_expansion.value) if self.notice_expansion else True, | |
| 'mcp_notice_dismissed': not bool(self.mcp_notice.visible) if self.mcp_notice else False, | |
| 'search_mode': self.input_search_mode.value if self.input_search_mode else '自定义', | |
| 'group_mode': self.input_group_mode.value if self.input_group_mode else 'off', | |
| 'max_per_group': int(self.input_max_per_group.value) if self.input_max_per_group else 2, | |
| 'artist_search_mode': self.artist_search_mode, | |
| } | |
| js = _json.dumps(cfg, ensure_ascii=False) | |
| ui.run_javascript(f"localStorage.setItem('{_CONFIG_LS_KEY}', {_json.dumps(js)});") | |
| async def _restore_config(self): | |
| """从 localStorage 读取配置并恢复控件状态。""" | |
| try: | |
| if getattr(ui.context.client, '_deleted', False): | |
| return | |
| raw = await ui.run_javascript( | |
| f"localStorage.getItem('{_CONFIG_LS_KEY}');", | |
| timeout=5.0, | |
| ) | |
| except Exception: | |
| return | |
| if not raw: | |
| return | |
| try: | |
| cfg = _json.loads(raw) | |
| except Exception: | |
| return | |
| if cfg.get('version') != _CONFIG_VERSION: | |
| # 版本不符,丢弃旧配置 | |
| ui.run_javascript(f"localStorage.removeItem('{_CONFIG_LS_KEY}');") | |
| return | |
| # 恢复搜索模式(会触发预设填充,但 _applying_preset 防止联动覆盖) | |
| if self.input_search_mode and 'search_mode' in cfg: | |
| self.input_search_mode.set_value(cfg['search_mode']) | |
| if self.input_top_k and 'top_k' in cfg: | |
| self.input_top_k.set_value(cfg['top_k']) | |
| if self.input_limit and 'limit' in cfg: | |
| self.input_limit.set_value(cfg['limit']) | |
| if self.input_weight and 'popularity_weight' in cfg: | |
| self.input_weight.set_value(cfg['popularity_weight']) | |
| if self.input_segment and 'use_segmentation' in cfg: | |
| self.input_segment.set_value(cfg['use_segmentation']) | |
| if self.input_group_mode and 'group_mode' in cfg: | |
| self.input_group_mode.set_value(cfg['group_mode']) | |
| if self.input_max_per_group and 'max_per_group' in cfg: | |
| self.input_max_per_group.set_value(cfg['max_per_group']) | |
| # NSFW:仅在平台允许时恢复 | |
| if nsfw_allowed() and self.input_nsfw and 'show_nsfw' in cfg: | |
| self.input_nsfw.set_value(cfg['show_nsfw']) | |
| if 'selected_layers' in cfg: | |
| for layer, val in cfg['selected_layers'].items(): | |
| if layer in self.selected_layers: | |
| self.selected_layers[layer] = bool(val) | |
| if layer in self._layer_checkboxes: | |
| self._layer_checkboxes[layer].set_value(bool(val)) | |
| if 'selected_cats' in cfg: | |
| for cat, val in cfg['selected_cats'].items(): | |
| if cat in self.selected_cats: | |
| self.selected_cats[cat] = bool(val) | |
| if cat in self._cat_checkboxes: | |
| self._cat_checkboxes[cat].set_value(bool(val)) | |
| if self.sw_semantic and 'sw_semantic' in cfg: | |
| self.sw_semantic.set_value(cfg['sw_semantic']) | |
| if self.sw_layer and 'sw_layer' in cfg: | |
| self.sw_layer.set_value(cfg['sw_layer']) | |
| if self.sw_source and 'sw_source' in cfg: | |
| self.sw_source.set_value(cfg['sw_source']) | |
| if 'prompt_format' in cfg and cfg['prompt_format'] in ('sdxl', 'nai', 'anima'): | |
| self.prompt_format = cfg['prompt_format'] | |
| if self.format_toggle_btn: | |
| if self.prompt_format == 'nai': | |
| self.format_toggle_btn.text = 'NAI' | |
| self.format_toggle_btn.props('color=purple-7') | |
| elif self.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') | |
| if 'rows_per_page' in cfg: | |
| self._set_rows_per_page(cfg['rows_per_page']) | |
| if self.search_input and cfg.get('search_query'): | |
| self.search_input.set_value(cfg['search_query']) | |
| if self.notice_expansion and 'notice_expanded' in cfg: | |
| self.notice_expansion.set_value(cfg['notice_expanded']) | |
| if self.mcp_notice and cfg.get('mcp_notice_dismissed'): | |
| self.mcp_notice.set_visibility(False) | |
| # 恢复画师查找模式 | |
| if cfg.get('artist_search_mode'): | |
| self._on_search_type_change(True) | |
| # 若高级选项列有变更,同步更新表格列 | |
| self._update_table_columns() | |
| # ══════════════════════════════════════════════════════════════════════ | |
| # 页面构建 | |
| # ══════════════════════════════════════════════════════════════════════ | |
| def build_page(self): | |
| ui.colors(primary='#4A90E2', secondary='#5E6C84', accent='#FF6B6B') | |
| ui.add_head_html(''' | |
| <meta name="description" content="基于语义匹配的 Danbooru 标签搜索引擎,支持中英双语描述、多维匹配、智能分词与共现关联推荐。"> | |
| <meta name="keywords" content="Danbooru, AI绘画, Stable Diffusion, 提示词, 标签搜索, RAG, Prompt, NovelAI"> | |
| <meta name="google-site-verification" content="cx4sl9Mb172GUFL556JFwKCP-pT3naQcmlMriy5B8ls" /> | |
| <style> | |
| .nsfw-blur-cell { filter: blur(8px); opacity: 0.5; transition: all 0.3s ease; | |
| pointer-events: none !important; user-select: none !important; } | |
| .nsfw-checkbox-disabled { pointer-events: none !important; opacity: 0.3 !important; } | |
| .nsfw-row-blocked { cursor: not-allowed !important; } | |
| .related-item { transition: background-color 0.15s ease; } | |
| .related-item:hover { background-color: rgba(74, 144, 226, 0.04); } | |
| .tag-link { text-decoration: none; font-family: 'Consolas', 'Monaco', 'Courier New', monospace; } | |
| .tag-link:hover { text-decoration: underline; } | |
| .weight-chip { display: inline-flex; align-items: center; gap: 2px; | |
| border-radius: 16px; padding: 2px 6px 2px 4px; | |
| background: #e3edf7; border: 1px solid #b3cde8; | |
| font-size: 12px; margin: 3px; white-space: nowrap; } | |
| .weight-chip.boosted { background: #fff3e0; border-color: #ffb74d; } | |
| .weight-chip.reduced { background: #f3e5f5; border-color: #ce93d8; } | |
| .weight-btn { cursor: pointer; width: 18px; height: 18px; border-radius: 50%; | |
| display: inline-flex; align-items: center; justify-content: center; | |
| font-size: 13px; font-weight: bold; line-height: 1; | |
| border: none; background: rgba(0,0,0,0.08); | |
| color: #555; transition: background 0.15s; padding: 0; } | |
| .weight-btn:hover { background: rgba(0,0,0,0.18); } | |
| .weight-label { font-family: Consolas, Monaco, monospace; font-size: 11px; | |
| color: #888; min-width: 28px; text-align: center; } | |
| /* 强制双栏并排 */ | |
| .two-col-layout { | |
| display: flex !important; | |
| flex-wrap: nowrap !important; | |
| align-items: flex-start !important; | |
| gap: 16px !important; | |
| } | |
| .two-col-layout > .col-left { | |
| flex: 0 0 62% !important; | |
| min-width: 0 !important; | |
| max-width: 62% !important; | |
| overflow: hidden; | |
| } | |
| .two-col-layout > .col-right { | |
| flex: 0 0 36% !important; | |
| min-width: 0 !important; | |
| max-width: 36% !important; | |
| overflow: hidden; | |
| } | |
| /* 窄屏回退为上下排列 */ | |
| @media (max-width: 900px) { | |
| .two-col-layout { | |
| flex-wrap: wrap !important; | |
| } | |
| .two-col-layout > .col-left, | |
| .two-col-layout > .col-right { | |
| flex: 1 1 100% !important; | |
| max-width: 100% !important; | |
| } | |
| } | |
| /* 画师查找模式 — 暗色主题 */ | |
| body[data-search-mode="artist"] .artist-card-row { | |
| background: rgba(245, 158, 11, 0.06); | |
| border-color: rgba(245, 158, 11, 0.15); | |
| } | |
| .artist-card-row { | |
| transition: background-color 0.2s, border-color 0.2s; | |
| } | |
| .artist-card-row:hover { | |
| background: rgba(245, 158, 11, 0.12) !important; | |
| } | |
| .mode-toggle-btn { | |
| transition: all 0.25s ease; | |
| } | |
| /* 暗色模式下文字颜色覆盖 */ | |
| body[data-search-mode="artist"] .text-gray-800, | |
| body[data-search-mode="artist"] .text-gray-700, | |
| body[data-search-mode="artist"] .text-gray-600 { | |
| color: #cbd5e1 !important; | |
| } | |
| body[data-search-mode="artist"] .text-gray-500 { | |
| color: #94a3b8 !important; | |
| } | |
| body[data-search-mode="artist"] .text-gray-400 { | |
| color: #64748b !important; | |
| } | |
| /* 暗色模式下公告栏 / 注意事项卡片适配 */ | |
| body[data-search-mode="artist"] .bg-green-50 { | |
| background: rgba(16, 185, 129, 0.1) !important; | |
| border-color: rgba(16, 185, 129, 0.3) !important; | |
| } | |
| body[data-search-mode="artist"] .text-green-800, | |
| body[data-search-mode="artist"] .text-green-900, | |
| body[data-search-mode="artist"] .text-green-700 { | |
| color: #6ee7b7 !important; | |
| } | |
| body[data-search-mode="artist"] .bg-orange-50 { | |
| background: rgba(249, 115, 22, 0.1) !important; | |
| border-color: rgba(249, 115, 22, 0.3) !important; | |
| } | |
| body[data-search-mode="artist"] .text-orange-800 { | |
| color: #fdba74 !important; | |
| } | |
| body[data-search-mode="artist"] .bg-blue-50 { | |
| background: rgba(59, 130, 246, 0.1) !important; | |
| border-color: rgba(59, 130, 246, 0.3) !important; | |
| } | |
| body[data-search-mode="artist"] .text-blue-700, | |
| body[data-search-mode="artist"] .text-blue-600 { | |
| color: #93c5fd !important; | |
| } | |
| </style> | |
| <script async src="https://www.googletagmanager.com/gtag/js?id=G-QPB7EEPR5G"></script> | |
| <script> | |
| window.dataLayer = window.dataLayer || []; | |
| function gtag(){dataLayer.push(arguments);} | |
| gtag('js', new Date()); | |
| gtag('config', 'G-QPB7EEPR5G'); | |
| </script> | |
| <script> | |
| document.addEventListener('DOMContentLoaded', function() { | |
| function openExternal(root) { | |
| root.querySelectorAll('a[href^="http"]').forEach(function(a) { | |
| a.setAttribute('target', '_blank'); | |
| a.setAttribute('rel', 'noopener noreferrer'); | |
| }); | |
| } | |
| openExternal(document); | |
| new MutationObserver(function(mutations) { | |
| mutations.forEach(function(m) { | |
| m.addedNodes.forEach(function(node) { | |
| if (node.querySelectorAll) openExternal(node); | |
| }); | |
| }); | |
| }).observe(document.body, { childList: true, subtree: true }); | |
| }); | |
| </script> | |
| ''') | |
| with ui.column().classes('w-full max-w-7xl mx-auto p-4 gap-4'): | |
| # ── 初始化提示 ── | |
| self.init_banner = ui.card().classes( | |
| 'w-full bg-blue-50 border-l-4 border-blue-400' | |
| ) | |
| with self.init_banner: | |
| with ui.row().classes('items-center gap-3 p-2'): | |
| ui.spinner(size='sm') | |
| ui.label('引擎初始化中,请稍候…约需 5~10 分钟').classes('text-sm text-blue-700') | |
| from platform_utils import PLATFORM | |
| _alt_url = ( | |
| 'https://www.modelscope.cn/studios/SAkizuki/DanbooruSearchOnline' | |
| if PLATFORM == 'hf' else | |
| 'https://huggingface.co/spaces/SAkizuki/DanbooruSearch' | |
| ) | |
| ui.html( | |
| f'初始化期间,您可以使用' | |
| f'<a href="{_alt_url}" target="_blank" rel="noopener noreferrer" ' | |
| f'class="text-blue-600 hover:text-blue-800 underline font-bold">备用服务</a>' | |
| ).classes('text-xs text-blue-600 px-6 pb-3') | |
| self.init_banner.set_visibility(not DanbooruTagger.is_ready()) | |
| if not DanbooruTagger.is_ready(): | |
| asyncio.ensure_future(self._hide_banner_when_ready()) | |
| # ── 0. 公告栏(同类标签 + MCP)── | |
| self._build_group_notice() | |
| # ── 1. 注意事项 ── | |
| self._build_notice() | |
| # ── 2. 搜索卡片 ── | |
| self._build_search_card() | |
| # ── 3~5. 结果区域(搜索前隐藏)── | |
| self.results_section = ui.column().classes('w-full gap-4') | |
| self.results_section.set_visibility(False) | |
| with self.results_section: | |
| # ── 3. 已选标签栏 ── | |
| self._build_selection_bar() | |
| # ── 4. 分词筛选 chips ── | |
| self.keywords_container = ui.row().classes('gap-2 items-center flex-wrap') | |
| # ── 5. 画师结果容器(画师模式专用)── | |
| self.artist_results_container = ui.column().classes('w-full gap-3') | |
| self.artist_results_container.set_visibility(False) | |
| # ── 6. 两栏结果(标签模式)── | |
| self._build_results_columns() | |
| # ── 6. 底部 ── | |
| with ui.element('div').classes('w-full text-center py-4 mt-2'): | |
| self.search_count_label = ui.html('正在加载数据...').classes('text-xs text-gray-400') | |
| self._update_footer_text() | |
| # ── 公告栏(画师查找 + 标签组 + MCP)─────────────────────────────────── | |
| def _build_group_notice(self): | |
| self.mcp_notice = ui.card().classes( | |
| 'w-full bg-green-50 border-l-4 border-green-500 p-0 overflow-hidden' | |
| ) | |
| with self.mcp_notice: | |
| with ui.column().classes('px-4 py-3 w-full gap-2'): | |
| # ── 画师查找公告 ── | |
| with ui.row().classes('items-center justify-between w-full'): | |
| 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( | |
| '基于标签共现数据,输入风格描述即可查找对应画师。' | |
| '点击搜索栏上方的 <b>「画师查找(beta)」</b> 按钮即可切换模式。' | |
| ).classes('text-xs text-green-900') | |
| ui.separator().classes('my-1') | |
| # ── 标签组扩展 ── | |
| ui.html( | |
| '【标签组扩展】 勾选标签后,搜索结果下方会出现<b>同类标签</b>区域,' | |
| '展示已选标签所属分组中的其他标签,勾选即可加入已选。' | |
| ).classes('text-xs text-green-900') | |
| ui.separator().classes('my-1') | |
| # ── MCP 服务 ── | |
| ui.html( | |
| '【MCP 服务】 支持通过 MCP 协议接入 AI Agent(如 Claude Desktop)。' | |
| '免配置托管版体验:' | |
| '<a href="https://huggingface.co/spaces/SAkizuki/WenQiuYue" ' | |
| 'target="_blank" rel="noopener noreferrer" ' | |
| 'class="text-green-700 font-bold underline">问秋月 Space</a>,' | |
| '<span class="text-gray-500 ml-1">API 额度有限,仅供体验。</span>' | |
| ' ' | |
| '<a href="https://github.com/SuzumiyaAkizuki/DanbooruSearchOnline#mcp-接口" ' | |
| 'target="_blank" rel="noopener noreferrer" ' | |
| 'class="text-green-700 underline">接入文档 →</a>' | |
| ).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', ''' | |
| <div class="flex items-center gap-2 px-4 py-2 w-full flex-wrap"> | |
| <span class="text-base font-bold text-orange-800">⚠️ 注意事项 / Note</span> | |
| <span v-if="!props.expanded" class="text-sm text-gray-600 ml-1"> | |
| 如果觉得好用,请点击顶部给本 Space 点个 | |
| <strong>Like ❤️</strong>,或前往 GitHub 点个 <strong>Star ⭐</strong>! | |
| </span> | |
| </div> | |
| ''') | |
| 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 ⭐**! | |
| - **🚀 首次使用?[点击查看使用指南](https://github.com/SuzumiyaAkizuki/DanbooruSearchOnline)**,了解五种搜索模式与进阶技巧 | |
| """).classes('text-sm text-gray-800 px-4 pb-3') | |
| # ── 搜索卡片 ───────────────────────────────────────────────────────── | |
| def _build_search_card(self): | |
| with ui.card().classes('w-full'): | |
| # ── 模式切换按钮 ── | |
| with ui.row().classes('w-full items-center justify-between mb-3'): | |
| with ui.row().classes('items-center gap-0'): | |
| self.tag_mode_btn = ui.button('标签搜索', on_click=lambda: self._on_search_type_change(False)) \ | |
| .props('unelevated color=primary').classes('mode-toggle-btn rounded-r-none') | |
| self.artist_mode_btn = ui.button('画师查找(beta)', on_click=lambda: self._on_search_type_change(True)) \ | |
| .props('flat color=grey-6').classes('mode-toggle-btn rounded-l-none') | |
| with ui.row().classes('items-center gap-2 mb-2'): | |
| ui.icon('search', size='2em', color='primary') | |
| self.search_title_label = ui.label('Danbooru 标签模糊搜索').classes('text-2xl font-bold text-gray-800') | |
| self.search_subtitle_label = ui.label( | |
| '基于语义匹配的标签搜索引擎,支持多维匹配与共现关联推荐。' | |
| ).classes('text-sm text-gray-500 -mt-1 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.row().classes('items-center gap-2'): | |
| ui.label('热度权重').classes('text-sm text-gray-600') | |
| self.input_weight = ui.slider(min=0.0, max=1.0, value=0.15, step=0.05).classes('w-32') | |
| 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.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('w-full gap-8 flex-wrap'): | |
| with ui.column().classes('gap-2'): | |
| ui.label('匹配层筛选').classes('font-bold text-sm text-gray-700') | |
| display_map = { | |
| '英文': '英文标签', '中文扩展词': '中文扩展词', | |
| '释义': '维基释义', '中文核心词': '中文核心词', | |
| } | |
| for layer in ['英文', '中文扩展词', '释义', '中文核心词']: | |
| 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': 'pink', '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) | |
| # ── 已选标签栏 ──────────────────────────────────────────────────────── | |
| def _build_selection_bar(self): | |
| self.selection_bar_card = ui.card().classes('w-full bg-blue-50 border border-blue-200') | |
| 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( | |
| '点击 <b>−</b> / <b>+</b> 可调整标签权重(步长 0.1,范围 0.1~1.9)。<br>' | |
| '权重 1.0 时输出原始标签;其余输出 <code>(tag:1.2)</code> 格式。' | |
| ).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('点击此处以反馈失败案例。<br>您的搜索词将被匿名收集用于优化引擎(不包含个人隐私)。').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( | |
| '切换复制格式:<br>' | |
| '<b>SDXL</b>:<code>(tag:1.2)</code><br>' | |
| '<b>NAI</b>:<code>1.2::tag::</code><br>' | |
| '<b>Anima</b>:<code>(tag:1.5)</code> 下划线→空格' | |
| ).style('font-size:13px;line-height:1.7;') | |
| self.format_toggle_btn.on_click(self._toggle_prompt_format) | |
| clear_btn = ui.button('清空已选', icon='delete_sweep').props('dense flat color=red-7').classes('text-xs') | |
| clear_btn.on_click(self._clear_all_staged) | |
| copy_btn = ui.button('复制选中', icon='content_copy').props('dense unelevated color=primary') | |
| copy_btn.on_click(self.copy_selection) | |
| # chip 容器:每个已选标签渲染为一个带加减按钮的 chip | |
| self.selected_chips_container = ui.element('div').classes( | |
| 'w-full mt-2 min-h-10 p-1 rounded bg-white border border-blue-100 flex flex-wrap' | |
| ) | |
| def _render_selected_chips(self): | |
| """重新渲染已选标签的 chip 列表。""" | |
| if self.selected_chips_container is None: | |
| return | |
| self.selected_chips_container.clear() | |
| tags = self._get_selected_tags() | |
| if not tags: | |
| with self.selected_chips_container: | |
| ui.label('暂无已选标签').classes('text-xs text-gray-400 italic p-2 self-center') | |
| return | |
| with self.selected_chips_container: | |
| step = 0.5 if self.prompt_format == 'anima' else 0.1 | |
| for tag in tags: | |
| w = self.tag_weights.get(tag, 1.0) | |
| extra_cls = 'boosted' if w > 1.0 else ('reduced' if w < 1.0 else '') | |
| w_str = f'{w:.1f}' | |
| with ui.element('div').classes(f'weight-chip {extra_cls}'): | |
| # 删除按钮(×) | |
| with ui.element('button').classes('weight-btn').props(f'title="移除 {tag}"').on( | |
| 'click', lambda t=tag: self._remove_selected_tag(t) | |
| ): | |
| ui.html('×') | |
| # 减号 | |
| with ui.element('button').classes('weight-btn').on( | |
| 'click', lambda t=tag, s=step: self._adjust_weight(t, -s) | |
| ): | |
| ui.html('−') | |
| # 标签名 | |
| ui.label(tag).style( | |
| 'font-family:Consolas,Monaco,monospace;font-size:12px;' | |
| 'color:#2c5282;max-width:150px;overflow:hidden;' | |
| 'text-overflow:ellipsis;white-space:nowrap;' | |
| ) | |
| # 权重值(仅非 1.0 时显示) | |
| if w != 1.0: | |
| ui.label(w_str).classes('weight-label').style('color:#e65100;font-weight:bold;') | |
| # 加号 | |
| 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.tag_weights[tag] = new_w | |
| self._save_staged_tags() | |
| self._render_selected_chips() | |
| def _remove_selected_tag(self, tag: str): | |
| """从已选中移除标签(同步表格选中状态)。""" | |
| self._mark_interaction() | |
| current = self._get_selected_tags() | |
| if tag in current: | |
| current.remove(tag) | |
| self.tag_weights.pop(tag, None) | |
| self._set_selected_tags(current) | |
| # ── 备选区持久化 ───────────────────────────────────────────────────── | |
| _STAGED_LS_KEY = 'danbooru_staged_tags' | |
| def _save_staged_tags(self): | |
| """将已选标签及其权重保存到 localStorage。""" | |
| tags = self._get_selected_tags() | |
| weights = {t: self.tag_weights.get(t, 1.0) for t in tags} | |
| data = _json.dumps({'tags': tags, 'weights': weights}, ensure_ascii=False) | |
| try: | |
| if getattr(ui.context.client, '_deleted', False): | |
| return | |
| ui.run_javascript(f"localStorage.setItem('{self._STAGED_LS_KEY}', {_json.dumps(data)});") | |
| except RuntimeError: | |
| pass # 事件上下文已销毁(UI 重建中),数据仍在内存里,下次保存时会同步 | |
| async def _restore_staged_tags(self): | |
| """从 localStorage 恢复已选标签。""" | |
| try: | |
| if getattr(ui.context.client, '_deleted', False): | |
| return | |
| raw = await ui.run_javascript( | |
| f"localStorage.getItem('{self._STAGED_LS_KEY}');", | |
| timeout=5.0, | |
| ) | |
| except Exception: | |
| return | |
| if not raw: | |
| return | |
| try: | |
| data = _json.loads(raw) | |
| except Exception: | |
| return | |
| tags = data.get('tags', []) | |
| weights = data.get('weights', {}) | |
| if not tags: | |
| return | |
| self.chip_extra_selected.update(tags) | |
| for t in tags: | |
| self.tag_weights[t] = weights.get(t, 1.0) | |
| self._render_selected_chips() | |
| if self.selection_count_label is not None: | |
| self.selection_count_label.text = str(len(tags)) | |
| def _clear_all_staged(self): | |
| """清空所有已选标签。""" | |
| self._mark_interaction() | |
| self.chip_extra_selected.clear() | |
| self.tag_weights.clear() | |
| if self.result_table is not None: | |
| self.result_table.selected = [] | |
| self._render_selected_chips() | |
| if self.selection_count_label is not None: | |
| self.selection_count_label.text = '0' | |
| show_nsfw_val = self.input_nsfw.value | |
| self._refresh_related([], show_nsfw_val) | |
| # 清空 Group 同类扩展 | |
| if self.group_expansion_container is not None: | |
| self.group_expansion_container.clear() | |
| with self.group_expansion_container: | |
| ui.label('请先搜索并勾选标签…').classes('text-sm text-gray-400 italic p-4') | |
| self._save_staged_tags() | |
| ui.notify('已清空所有已选标签', type='warning') | |
| # ── 两栏结果(CSS 强制并排)────────────────────────────────────────── | |
| def _build_results_columns(self): | |
| 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('pagination', lambda _: self._save_config()) | |
| # 自定义行模板:行背景色按分类,整行悬浮显示 wiki(NSFW模糊行除外) | |
| self.result_table.add_slot('body', r''' | |
| <q-tr :props="props" | |
| :class="props.row._nsfw_blocked ? 'nsfw-row-blocked' : ''" | |
| :style="{ | |
| 'background-color': | |
| props.row.category === 'General' ? 'rgba(59,130,246,0.06)' : | |
| props.row.category === 'Character' ? 'rgba(34,197,94,0.06)' : | |
| props.row.category === 'Copyright' ? 'rgba(236,72,153,0.06)' : '' | |
| }"> | |
| <q-td auto-width> | |
| <q-checkbox v-model="props.selected" | |
| :class="props.row._nsfw_blocked ? 'nsfw-checkbox-disabled' : ''"/> | |
| </q-td> | |
| <q-td v-for="col in props.cols" :key="col.name" :props="props"> | |
| <template v-if="col.name === 'tag' || col.name === 'cn_name'"> | |
| <div :class="props.row._nsfw_blocked ? 'nsfw-blur-cell' : ''"> | |
| <template v-if="col.name === 'cn_name' && col.value"> | |
| <span style="font-size:14px"> | |
| {{ col.value.split(',')[0] }} | |
| </span> | |
| </template> | |
| <template v-else-if="col.name === 'tag'"> | |
| <a :href="'https://danbooru.donmai.us/wiki_pages/'+col.value" | |
| target="_blank" | |
| class="text-primary hover:underline font-bold inline-flex items-center" | |
| style="text-decoration:none; font-family: Consolas, Monaco, Courier New, monospace;" | |
| @click.stop="$emit('link_click', col.value)"> | |
| {{ col.value }} | |
| <q-icon name="open_in_new" size="xs" class="q-ml-xs opacity-50"/> | |
| </a> | |
| </template> | |
| <template v-else>{{ col.value }}</template> | |
| </div> | |
| </template> | |
| <template v-else-if="col.name === 'nsfw'"> | |
| <div v-if="col.value === '1'" class="text-red-500">🔴</div> | |
| <div v-else class="text-green-500">🟢</div> | |
| </template> | |
| <template v-else-if="col.name === 'final_score'"> | |
| <q-badge :color="col.value > 0.6 ? 'green' : (col.value > 0.5 ? 'teal' : 'orange')"> | |
| {{ col.value }} | |
| </q-badge> | |
| </template> | |
| <template v-else>{{ col.value }}</template> | |
| </q-td> | |
| <q-tooltip v-if="(props.row.wiki || props.row.cn_name) && !props.row._nsfw_blocked" | |
| content-class="bg-black text-white shadow-4" | |
| max-width="500px" :offset="[10,10]"> | |
| <div style="font-size:14px;line-height:1.5;"> | |
| <span style="opacity:0.7;margin-right:4px;">{{ | |
| props.row.category === 'General' ? '[通用]' : | |
| props.row.category === 'Character' ? '[角色]' : | |
| props.row.category === 'Copyright' ? '[作品]' : '' | |
| }}</span>{{ props.row.wiki }} | |
| <div v-if="props.row.cn_name" | |
| style="margin-top:6px;opacity:0.85;">{{ props.row.cn_name }}</div> | |
| </div> | |
| </q-tooltip> | |
| </q-tr> | |
| ''') | |
| # ── 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('关联推荐').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( | |
| '基于标签共现数据,发掘语义之外的相关性,为您推荐更多可能的标签。<br>勾选可加入或移出已选。如需根据最新选项更新推荐,请点击刷新按钮。').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') | |
| # ══════════════════════════════════════════════════════════════════════ | |
| # 渲染画师搜索结果 | |
| # ══════════════════════════════════════════════════════════════════════ | |
| async def _silent_increment_counter(self): | |
| try: | |
| await counter.increment() | |
| self._update_footer_text() | |
| except Exception: | |
| pass | |
| def _render_artist_results(self, artist_results, found_tags, missing_tags): | |
| if self.artist_results_container is None: | |
| return | |
| self.artist_results_container.clear() | |
| if not artist_results: | |
| with self.artist_results_container: | |
| with ui.card().classes('w-full bg-amber-50 border border-amber-200'): | |
| with ui.row().classes('items-center gap-2 p-4'): | |
| ui.icon('info', color='amber') | |
| ui.label('未找到匹配的画师,请尝试更具体的风格描述。').classes('text-sm text-amber-700') | |
| return | |
| max_score = max(r.score for r in artist_results) if artist_results else 1.0 | |
| with self.artist_results_container: | |
| # 种子标签信息 | |
| if found_tags: | |
| tags_display = ', '.join(found_tags) | |
| ui.label(f'种子标签: {tags_display}').classes('text-sm text-gray-500 mb-1') | |
| if missing_tags: | |
| ui.label(f'未匹配画师的标签: {", ".join(missing_tags)}').classes('text-xs text-orange-400') | |
| with ui.card().classes('w-full p-0'): | |
| for i, r in enumerate(artist_results): | |
| pct = min(r.score / max_score * 100, 100) if max_score > 0 else 0 | |
| rank = i + 1 | |
| sources_str = ', '.join(r.sources[:5]) | |
| post_str = f'{r.post_count:,}' if r.post_count else '—' | |
| with ui.row().classes( | |
| 'w-full items-center gap-3 px-4 py-3 border-b border-gray-200 artist-card-row' | |
| ).style('border-bottom: 1px solid rgba(128,128,128,0.1);'): | |
| # 排名 | |
| ui.label(f'#{rank}').classes( | |
| 'text-lg font-bold min-w-[42px] text-center' | |
| ).style(f'color: {"#F59E0B" if rank <= 3 else "#9CA3AF"};') | |
| # 画师信息 | |
| with ui.column().classes('flex-grow gap-0 min-w-0'): | |
| ui.link(r.artist, f'https://danbooru.donmai.us/posts?tags={r.artist}', new_tab=True) \ | |
| .classes('text-base font-bold') | |
| with ui.row().classes('items-center gap-4 mt-1'): | |
| ui.label(f'匹配标签: {sources_str}').classes('text-xs text-gray-500') | |
| ui.label(f'Danbooru 作品: {post_str}').classes('text-xs text-gray-400') | |
| # 分数条 | |
| with ui.column().classes('items-end gap-0 min-w-[80px]'): | |
| ui.label(f'{r.score:.4f}').classes('text-sm font-mono font-bold') | |
| with ui.element('div').classes('w-full h-2 rounded-full overflow-hidden') \ | |
| .style('background: rgba(128,128,128,0.15);'): | |
| ui.element('div').classes('h-full rounded-full').style( | |
| f'width: {pct:.0f}%;' | |
| f'background: #F59E0B;' | |
| ) | |
| # ══════════════════════════════════════════════════════════════════════ | |
| # 渲染关联推荐列表 | |
| # ══════════════════════════════════════════════════════════════════════ | |
| 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'<span style="opacity:0.7;margin-right:4px;">[{cat_label}]</span>' if cat_label else '' | |
| tooltip_html += f'<div style="margin-bottom:6px;">{prefix}{wiki_text}</div>' | |
| tooltip_html += ( | |
| f'<div style="opacity:0.85;">' | |
| f'{r.cn_name}<br>' | |
| f'共现: {r.cooc_count:,} 相关度: {r.cooc_score:.2f}<br>' | |
| f'来自选中: {sources_str}' | |
| f'</div>' | |
| ) | |
| # 行背景色按分类区分 | |
| CAT_BG = { | |
| 'General': 'background-color: rgba(59,130,246,0.06);', # 淡蓝 | |
| 'Character': 'background-color: rgba(34,197,94,0.06);', # 淡绿 | |
| 'Copyright': 'background-color: rgba(236,72,153,0.06);', # 淡红/粉 | |
| } | |
| row_bg = CAT_BG.get(r.category, '') | |
| # 整行容器,tooltip 挂在行上 | |
| with ui.row().classes( | |
| 'w-full items-center gap-2 px-3 py-2 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') | |
| self._related_checkboxes[tag] = cb | |
| # 标签名(可点击跳转)+ 中文名 | |
| with ui.column().classes('flex-grow gap-0 min-w-0'): | |
| with ui.row().classes('items-center gap-1'): | |
| link = ui.link( | |
| tag, | |
| f'https://danbooru.donmai.us/wiki_pages/{tag}', | |
| new_tab=True | |
| ).classes('tag-link text-primary font-bold text-xs') | |
| 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('text-xs text-gray-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'text-sm font-bold text-{score_color}-600 whitespace-nowrap') | |
| # ══════════════════════════════════════════════════════════════════════ | |
| # 交互逻辑 | |
| # ══════════════════════════════════════════════════════════════════════ | |
| async def _hide_banner_when_ready(self): | |
| while not DanbooruTagger.is_ready(): | |
| await asyncio.sleep(1) | |
| if self.init_banner: | |
| self.init_banner.set_visibility(False) | |
| def _client_alive(self) -> bool: | |
| try: | |
| _ = self.search_btn.client | |
| return True | |
| except 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) | |
| for child in self.keywords_container.default_slot.children: | |
| if isinstance(child, ui.chip): | |
| selected = ( | |
| (keyword == 'ALL' and child.text == '全部') | |
| or (keyword == self.current_query_str and child.text == '整句') | |
| or (child.text == keyword) | |
| ) | |
| is_segment = child.text in self.current_segments | |
| if selected: | |
| chip_color, text_color = 'primary', 'white' | |
| elif is_segment: | |
| chip_color, text_color = 'blue-1', 'blue-8' | |
| else: | |
| chip_color, text_color = 'grey-4', 'black' | |
| child.props(f'color={chip_color} text-color={text_color}') | |
| # ── 搜索模式切换 ────────────────────────────────────────────────────── | |
| def _on_search_type_change(self, artist_mode: bool): | |
| if artist_mode == self.artist_search_mode: | |
| return | |
| self.artist_search_mode = artist_mode | |
| if artist_mode: | |
| # 画师查找 → 暗色主题 | |
| ui.colors(primary='#F59E0B', secondary='#78716C', accent='#10B981') | |
| ui.dark_mode().enable() | |
| self.tag_mode_btn.props('flat color=grey-6') | |
| self.artist_mode_btn.props('unelevated color=primary') | |
| self.search_title_label.set_text('画师查找') | |
| self.search_subtitle_label.set_text('输入任何元素(如"平涂" "枪" "蔚蓝档案"),自动匹配擅长的画师。') | |
| self.search_input.props('placeholder="输入任何元素,如:平涂 枪 蔚蓝档案..."') | |
| # 隐藏搜索参数控件 | |
| self.search_params_row.set_visibility(False) | |
| self.advanced_options.set_visibility(False) | |
| # 清空旧结果并隐藏标签搜索专属区域 | |
| if self.results_section: | |
| self.results_section.set_visibility(False) | |
| if self.artist_results_container: | |
| self.artist_results_container.clear() | |
| else: | |
| # 标签搜索 → 亮色主题 | |
| ui.colors(primary='#4A90E2', secondary='#5E6C84', accent='#FF6B6B') | |
| ui.dark_mode().disable() | |
| self.tag_mode_btn.props('unelevated color=primary') | |
| self.artist_mode_btn.props('flat color=grey-6') | |
| self.search_title_label.set_text('Danbooru 标签模糊搜索') | |
| self.search_subtitle_label.set_text('基于语义匹配的标签搜索引擎,支持多维匹配与共现关联推荐。') | |
| self.search_input.props('placeholder="输入自然语言描述或模糊概念,例如:一个穿着白色水手服的少女在雨中奔跑..."') | |
| # 恢复搜索参数控件 | |
| self.search_params_row.set_visibility(True) | |
| self.advanced_options.set_visibility(True) | |
| # 清空旧结果 | |
| if self.results_section: | |
| self.results_section.set_visibility(False) | |
| if self.artist_results_container: | |
| self.artist_results_container.clear() | |
| self.keywords_container.clear() | |
| ui.run_javascript(f"document.body.setAttribute('data-search-mode', '{'artist' if artist_mode else 'tag'}');") | |
| self._save_config() | |
| # ── 搜索 ────────────────────────────────────────────────────────────── | |
| 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 | |
| try: | |
| tagger = await DanbooruTagger.get_instance() | |
| if self.artist_search_mode: | |
| # ── 画师查找模式 ── | |
| artist_results, seed_tags, found_tags, missing_tags = \ | |
| await tagger.search_artists_pipeline_async( | |
| query, | |
| limit=30, | |
| target_layers=target_layers_list, | |
| target_categories=target_cats_list, | |
| ) | |
| # 后台计数 | |
| asyncio.create_task(self._silent_increment_counter()) | |
| if not self._client_alive(): | |
| return | |
| self.current_artist_seed_tags = seed_tags | |
| self.results_section.set_visibility(True) | |
| # 隐藏标签搜索专属区域 | |
| self.selection_bar_card.set_visibility(False) | |
| self.two_col_container.set_visibility(False) | |
| # 显示画师结果 | |
| self.artist_results_container.set_visibility(True) | |
| self.keywords_container.clear() | |
| with self.keywords_container: | |
| ui.label('匹配种子标签:').classes('text-sm text-gray-500 font-bold mr-2') | |
| for tag in seed_tags: | |
| ui.chip(tag).props('color=amber-2 text-color=amber-9 clickable') | |
| if not seed_tags: | |
| ui.label('无').classes('text-xs text-gray-400 italic') | |
| self._render_artist_results(artist_results, found_tags, missing_tags) | |
| ui.notify(f'找到 {len(artist_results)} 位画师', type='positive') | |
| self.current_search_interacted = False | |
| else: | |
| # ── 标签搜索模式 ── | |
| 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) | |
| # 后台计数 | |
| 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.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.results_section.set_visibility(True) | |
| # 显示标签搜索专属区域 | |
| self.selection_bar_card.set_visibility(True) | |
| self.two_col_container.set_visibility(True) | |
| self.artist_results_container.set_visibility(False) | |
| _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.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) | |
| # 分词筛选 chips | |
| self.current_filter_keyword = 'ALL' | |
| self.keywords_container.clear() | |
| cached_set = set(response.cached_queries) if response.cached_queries else set() | |
| with self.keywords_container: | |
| ui.label('分词筛选:').classes('text-sm text-gray-500 font-bold mr-2') | |
| ui.chip('全部', on_click=lambda: self._filter_by_source('ALL')) \ | |
| .props('color=primary text-color=white clickable') | |
| use_seg = self.input_segment.value if self.input_segment else True | |
| if use_seg: | |
| whole = ui.chip('整句', | |
| on_click=lambda: self._filter_by_source(self.current_query_str)) | |
| whole.props('color=grey-4 text-color=black clickable') | |
| if self.current_query_str in cached_set: | |
| whole.style('outline: 1px dashed rgba(128,128,128,0.3); outline-offset: 1px;') | |
| for seg in response.segments: | |
| sc = ui.chip(seg, | |
| on_click=lambda s=seg: self._filter_by_source(s)) | |
| sc.props('color=blue-1 text-color=blue-8 clickable') | |
| if seg in cached_set: | |
| sc.style('outline: 1px dashed rgba(128,128,128,0.3); outline-offset: 1px;') | |
| for kw in response.keywords: | |
| kc = ui.chip(kw, | |
| on_click=lambda k=kw: self._filter_by_source(k)) | |
| kc.props('color=grey-4 text-color=black clickable') | |
| if kw in cached_set: | |
| kc.style('outline: 1px dashed rgba(128,128,128,0.3); outline-offset: 1px;') | |
| else: | |
| ui.label('(分词已关闭)').classes('text-xs text-gray-400') | |
| 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 = [t for t in self.chip_extra_selected if t not in seen] | |
| return table_tags + extra | |
| def _set_selected_tags(self, tags: list[str], skip_refresh: bool = False): | |
| 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] | |
| 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] | |
| all_tags = self._get_selected_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() | |
| # 显式刷新关联推荐和 Group 区域(不依赖 table.on('selection') 事件, | |
| # 因为在 chip 点击回调上下文中该事件可能不可靠)。 | |
| # 从关联推荐/同类标签勾选时跳过,由各自动态刷新或手动按钮触发。 | |
| if not skip_refresh: | |
| show_nsfw_val = self.input_nsfw.value | |
| self._refresh_related_from_selection(all_tags, show_nsfw_val) | |
| self._refresh_group_from_selection(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() | |
| # 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] | |
| # 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() | |
| show_nsfw_val = self.input_nsfw.value | |
| self._refresh_related_from_selection(all_tags, show_nsfw_val) | |
| self._refresh_group_from_selection(all_tags, show_nsfw_val) | |
| if not all_tags: | |
| self.chip_extra_selected.clear() | |
| self._save_staged_tags() | |
| 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: | |
| 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 _on_group_checkbox_change(self, tag: str, checked: bool): | |
| """同类标签复选框变化回调。""" | |
| self._mark_interaction() | |
| current = self._get_selected_tags() | |
| if checked: | |
| if tag not in current: | |
| 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) | |
| 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) | |
| 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 洪峰)。""" | |
| # 取消上次未执行的刷新 | |
| 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 洪峰)。""" | |
| 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, | |
| ) | |
| self._render_group_expansion(group_data, selected_tags, show_nsfw) | |
| self._debounce_group_task = asyncio.ensure_future(_do()) | |
| 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() | |
| 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(236,72,153,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:', '')) | |
| tags = group_info['tags'] | |
| with ui.expansion( | |
| f'{group_cn} ({len(tags)} 个标签)', | |
| icon='label', | |
| ).classes('w-full').props('dense'): | |
| with ui.element('div').classes('w-full grid grid-cols-2 gap-1 p-1').style('max-height: 600px; overflow-y: auto;'): | |
| for t in tags: | |
| tag = t['tag'] | |
| 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'<span style="opacity:0.7;margin-right:4px;">[{cat_label}]</span>' if cat_label else '' | |
| tooltip_html += f'<div style="margin-bottom:6px;">{prefix}{wiki_text}</div>' | |
| if cn_full: | |
| tooltip_html += f'<div style="opacity:0.85;">{cn_full}</div>' | |
| with ui.row().classes( | |
| 'w-full items-center gap-1.5 px-2 py-1.5 rounded 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') | |
| self._group_checkboxes[tag] = cb | |
| # 标签名 + 中文名(与关联推荐对齐方式一致) | |
| with ui.column().classes('flex-grow 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 text-primary font-bold text-xs truncate') | |
| if cn_first: | |
| ui.label(cn_first).classes('text-xs text-gray-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('text-sm font-bold text-grey-600 whitespace-nowrap') | |
| # ── 表格列动态更新 ────────────────────────────────────────────────── | |
| 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.prompt_format = 'nai' | |
| if self.format_toggle_btn: | |
| self.format_toggle_btn.text = 'NAI' | |
| self.format_toggle_btn.props('color=purple-7') | |
| elif self.prompt_format == 'nai': | |
| self.prompt_format = 'anima' | |
| if self.format_toggle_btn: | |
| self.format_toggle_btn.text = 'Anima' | |
| self.format_toggle_btn.props('color=teal-7') | |
| else: | |
| self.prompt_format = 'sdxl' | |
| if self.format_toggle_btn: | |
| self.format_toggle_btn.text = 'SDXL' | |
| self.format_toggle_btn.props('color=grey-7') | |
| self._render_selected_chips() | |
| def copy_selection(self): | |
| self._mark_interaction() | |
| tags = self._get_selected_tags() | |
| prompt = ', '.join( | |
| _format_tag_with_weight(t, self.tag_weights.get(t, 1.0), self.prompt_format) | |
| for t in tags | |
| ) | |
| ui.clipboard.write(prompt) | |
| fmt_label = {'sdxl': 'SDXL', 'nai': 'NAI', 'anima': 'Anima'}.get(self.prompt_format, 'SDXL') | |
| ui.notify(f'已复制选中标签({fmt_label} 格式)!', type='positive') | |
| async def silent_copy_update(): | |
| try: | |
| await counter.increment_copy() | |
| except Exception: | |
| pass | |
| asyncio.create_task(silent_copy_update()) | |
| 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') | |
| else: | |
| ui.notify('暂无标签可复制', type='warning') | |
| async 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 | |
| if self.bad_case_btn is not None: | |
| self.bad_case_btn.disable() | |
| try: | |
| settings = { | |
| 'top_k': int(self.input_top_k.value) if self.input_top_k else None, | |
| 'segmentation': self.input_segment.value if self.input_segment else None, | |
| 'nsfw': self.input_nsfw.value if self.input_nsfw else None, | |
| } | |
| await counter.add_bad_case(query, platform=PLATFORM, settings=settings) | |
| ui.notify('感谢反馈!我们会持续优化。', type='positive', timeout=3000) | |
| except Exception as e: | |
| print(f'[UI] bad_case 记录异常: {e}') | |
| ui.notify('记录失败,请稍后再试。', type='warning', timeout=3000) | |
| if self.bad_case_btn is not None: | |
| self.bad_case_btn.enable() | |
| # ── 页面路由 ─────────────────────────────────────────────────────────────────── | |
| async def main_page(): | |
| app_ui = DanbooruSearchUI() | |
| app_ui.build_page() | |
| async def silent_visit_update(): | |
| try: | |
| await counter.increment_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() | |
| def _warmup(): | |
| async def background_init_tasks(): | |
| await asyncio.sleep(5) | |
| print("[UI] 开始预热计数器与引擎", flush=True) | |
| await counter.init() | |
| await DanbooruTagger.get_instance() | |
| print("[UI] 后台预热全部完成!", flush=True) | |
| asyncio.create_task(background_init_tasks()) | |
| def _shutdown(): | |
| try: | |
| loop = asyncio.get_event_loop() | |
| if loop.is_running(): | |
| loop.create_task(counter.force_sync()) | |
| else: | |
| asyncio.run(counter.force_sync()) | |
| 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 | |
| async def _start_mcp(): | |
| global _mcp_lifespan_ctx | |
| _mcp_lifespan_ctx = mcp_app.router.lifespan_context(mcp_app) | |
| await _mcp_lifespan_ctx.__aenter__() | |
| async def _stop_mcp(): | |
| global _mcp_lifespan_ctx | |
| if _mcp_lifespan_ctx is not None: | |
| await _mcp_lifespan_ctx.__aexit__(None, None, None) | |
| def google_verification(): | |
| return PlainTextResponse('google-site-verification: googlebd34b54f8562aa06.html') | |
| def robots_txt(): | |
| content = ( | |
| 'User-agent: *\n' | |
| 'Allow: /$\n' | |
| 'Disallow: /api/\n' | |
| 'Disallow: /_nicegui/\n' | |
| 'Disallow: /socket.io/\n' | |
| ) | |
| return PlainTextResponse(content) | |
| def robots_txt(): | |
| content = ( | |
| 'User-agent: *\n' | |
| 'Allow: /$\n' | |
| 'Disallow: /api/\n' | |
| 'Disallow: /_nicegui/\n' | |
| 'Disallow: /socket.io/\n' | |
| ) | |
| return PlainTextResponse(content) | |
| 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, | |
| ) | |