Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
| """ | |
| 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, | |
| ) | |