DanbooruSearch / ui_nicegui.py
SAkizuki's picture
Auto-sync from GitHub Actions
e1b4c00 verified
Raw
History Blame
101 kB
"""
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 = 6
# ๆœ็ดขๆจกๅผ้ข„่ฎพ
_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
self._debounce_artist_task = None # type: asyncio.Task | None
# tag -> prompt ๆƒ้‡๏ผŒ่Œƒๅ›ด [0.1, 1.9]๏ผŒ้ป˜่ฎค 1.0
self.tag_weights: dict[str, float] = {}
# ๅคๅˆถๆ ผๅผ๏ผš'sdxl'ใ€'nai' ๆˆ– 'anima'
self.prompt_format: str = 'sdxl'
self.format_toggle_btn = None
self.init_banner = None
self.input_top_k = None
self.input_limit = None
self.input_weight = None
self.input_nsfw = None
self.input_segment = None
self.input_search_mode = None
self.input_group_mode = None
self.input_max_per_group = None
self._applying_preset = False
self.search_input = None
self.keywords_container = None
self.spinner = None
self.search_btn = None
self.selected_layers = {'่‹ฑๆ–‡': True, 'ไธญๆ–‡ๆ‰ฉๅฑ•่ฏ': True, '้‡Šไน‰': True, 'ไธญๆ–‡ๆ ธๅฟƒ่ฏ': True}
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 ๅผ•็”จ
self._artist_rec_checkboxes: dict[str, ui.checkbox] = {}
# ๅฝ“ๅ‰ๆŽจ่็”ปๅธˆ็š„ๆ ‡็ญพๅ้›†ๅˆ๏ผˆ็”จไบŽ Anima ๆจกๅผๅคๅˆถๆ—ถๅŠ  @ ๅ‰็ผ€๏ผ‰
self._current_artist_rec_tags: set[str] = set()
# ้ซ˜็บง้€‰้กนไธญๅ„ๅฑ‚/็ฑปๅž‹็š„ 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,
}
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)
# ่‹ฅ้ซ˜็บง้€‰้กนๅˆ—ๆœ‰ๅ˜ๆ›ด๏ผŒๅŒๆญฅๆ›ดๆ–ฐ่กจๆ ผๅˆ—
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;
}
}
</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._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(
'ๅŸบไบŽๆ ‡็ญพๅ…ฑ็Žฐๆ•ฐๆฎ๏ผŒๆ นๆฎๅทฒ้€‰ๆ ‡็ญพๆŸฅๆ‰พๅฏนๅบ”็š„ๆ“…้•ฟ็”ปๅธˆใ€‚'
'้ผ ๆ ‡ๆ‚ฌๅœ็”ปๅธˆ่กŒๅฏๆŸฅ็œ‹่ฏฅ็”ปๅธˆๆœ€ๅธธ็”ป็š„ๆ ‡็ญพ'
).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>'
'&nbsp;'
'<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('items-center gap-2 mb-2'):
ui.icon('search', size='2em', color='primary')
ui.label('Danbooru ๆ ‡็ญพๆจก็ณŠๆœ็ดข').classes('text-2xl font-bold text-gray-800')
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': 'purple', 'Character': 'green'}
label_map = {
'General': '้€š็”จ (General)',
'Copyright': 'ไฝœๅ“ (Copyright)',
'Character': '่ง’่‰ฒ (Character)',
}
for cat in ['General', 'Copyright', 'Character']:
cb = ui.checkbox(
label_map[cat], value=True,
on_change=lambda e, c=cat: self.selected_cats.__setitem__(c, e.value)
).props(f'color={color_map[cat]} dense')
self._cat_checkboxes[cat] = cb
with ui.column().classes('gap-2'):
ui.label('่กจๆ ผๆ˜พ็คบๅˆ—').classes('font-bold text-sm text-gray-700')
self.sw_semantic = ui.switch('ๆ˜พ็คบ่ฏญไน‰ๅˆ†', value=False)
self.sw_layer = ui.switch('ๆ˜พ็คบๅŒน้…ๅฑ‚', value=False)
self.sw_source = ui.switch('ๆ˜พ็คบๅŒน้…ๆฅๆบ', value=False)
self.sw_semantic.on('update:model-value', self._update_table_columns)
self.sw_layer.on('update:model-value', self._update_table_columns)
self.sw_source.on('update:model-value', self._update_table_columns)
with ui.column().classes('gap-2'):
ui.label('ๆ ‡็ญพๅˆ†็ป„ๆจกๅผ').classes('font-bold text-sm text-gray-700')
self.input_group_mode = ui.select(
['off', 'expand', 'diverse'], value='off',
).classes('w-40').props('outlined dense')
with ui.tooltip().props('content-class="bg-black text-white shadow-4"'):
ui.label('off=ๅ…ณ้—ญ | expand=ๅŒ็ฑปๅฌๅ›žๅขžๅผบ | diverse=ๅคšๆ ทๆ€ง็บฆๆŸ').style('font-size:14px;')
self.input_group_mode.on('update:model-value', self._on_param_changed)
self.input_max_per_group = ui.number(
value=2, min=1, max=10,
).classes('w-20').props('outlined dense')
ui.label('ๆฏ็ป„ๆœ€ๅคงๆ ‡็ญพๆ•ฐ๏ผˆdiverse ๆจกๅผ๏ผ‰').classes('text-xs text-gray-500')
self.input_max_per_group.on('update:model-value', self._on_param_changed)
# โ”€โ”€ ๅทฒ้€‰ๆ ‡็ญพๆ  โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
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('&times;')
# ๅ‡ๅท
with ui.element('button').classes('weight-btn').on(
'click', lambda t=tag, s=step: self._adjust_weight(t, -s)
):
ui.html('&minus;')
# ๆ ‡็ญพๅ
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('&plus;')
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._artist_rec_checkboxes.clear()
self._current_artist_rec_tags.clear()
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)
self._render_artist_rec([], {})
# ๆธ…็ฉบ Group ๅŒ็ฑปๆ‰ฉๅฑ•
if self.group_expansion_container is not None:
self.group_expansion_container.clear()
with self.group_expansion_container:
ui.label('่ฏทๅ…ˆๆœ็ดขๅนถๅ‹พ้€‰ๆ ‡็ญพโ€ฆ').classes('text-sm text-gray-400 italic p-4')
self._save_staged_tags()
ui.notify('ๅทฒๆธ…็ฉบๆ‰€ๆœ‰ๅทฒ้€‰ๆ ‡็ญพ', type='warning')
# โ”€โ”€ ไธคๆ ็ป“ๆžœ๏ผˆCSS ๅผบๅˆถๅนถๆŽ’๏ผ‰โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
def _build_results_columns(self):
self.two_col_container = ui.element('div').classes('w-full two-col-layout')
with self.two_col_container:
# โ”€โ”€ ๅทฆๆ ๏ผš่ฏญไน‰ๅŒน้…็ป“ๆžœ๏ผˆ่กจๆ ผ๏ผ‰โ”€โ”€
with ui.card().classes('col-left'):
with ui.row().classes('items-center justify-between mb-2 w-full'):
ui.label('ๅŒน้…ๆ ‡็ญพ็ป“ๆžœ').classes('font-bold text-lg text-gray-800')
ui.button('ๅคๅˆถๅ…จ้ƒจๆ ‡็ญพ', icon='content_copy', on_click=self._copy_all_tags) \
.props('dense flat color=primary').classes('text-sm')
self.result_table = ui.table(
columns=TABLE_COLUMNS,
rows=[],
pagination=0,
selection='multiple',
row_key='tag',
).classes('w-full')
self.result_table.on('selection', self._update_selection_display)
self.result_table.on('link_click', self._mark_interaction)
self.result_table.on('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(168,85,247,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('ๆŽจ่ๆ“…้•ฟ็”ปๅธˆ(Beta)').classes('font-bold text-lg text-gray-800')
with ui.icon('info_outline', size='sm', color='grey').classes('cursor-help'):
with ui.tooltip().props('content-class="bg-black text-white shadow-4"'):
ui.html(
'ๅŸบไบŽๆ ‡็ญพ-็”ปๅธˆ NPMI ๅ…ฑ็Žฐๆ•ฐๆฎ๏ผŒๆ นๆฎๆ‚จๅฝ“ๅ‰ๅทฒ้€‰็š„ๆ ‡็ญพ๏ผŒๆŽจ่ๆ“…้•ฟ่ฟ™ไบ›ๅ…ƒ็ด ็š„็”ปๅธˆใ€‚<br>ๆ‚ฌๅœ็”ปๅธˆ่กŒๅฏๆŸฅ็œ‹ไธŽ่ฏฅ็”ปๅธˆๅ…ฑ็Žฐๅ…ณ่”ๆœ€ๅผบ็š„ๆ ‡็ญพใ€‚').style(
'font-size:14px;line-height:1.5;')
self.artist_rec_list = ui.column().classes('w-full gap-0').style('max-height: 420px; overflow-y: auto;')
with self.artist_rec_list:
ui.label('่ฏทๅ…ˆๆœ็ดขๅนถๅ‹พ้€‰ๆ ‡็ญพโ€ฆ').classes('text-sm text-gray-400 italic p-4')
ui.separator().classes('my-3')
# ๅ…ณ่”ๆŽจ่
with ui.row().classes('items-center justify-between w-full mb-2'):
with ui.row().classes('items-center gap-2'):
ui.label('ๅ…ณ่”ๆŽจ่').classes('font-bold text-lg text-gray-800')
with ui.icon('info_outline', size='sm', color='grey').classes('cursor-help'):
with ui.tooltip().props('content-class="bg-black text-white shadow-4"'):
ui.html(
'ๅŸบไบŽๆ ‡็ญพๅ…ฑ็Žฐๆ•ฐๆฎ๏ผŒๅ‘ๆŽ˜่ฏญไน‰ไน‹ๅค–็š„็›ธๅ…ณๆ€ง๏ผŒไธบๆ‚จๆŽจ่ๆ›ดๅคšๅฏ่ƒฝ็š„ๆ ‡็ญพใ€‚<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')
# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
# ๆธฒๆŸ“ๅ…ณ่”ๆŽจ่ๅˆ—่กจ
# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
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(168,85,247,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}')
# โ”€โ”€ ๆœ็ดข โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
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()
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)
_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]
# ๅŒๆญฅๆŽจ่็”ปๅธˆ checkbox
for t, cb in self._artist_rec_checkboxes.items():
cb.set_value(t 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)
self._refresh_artist_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()
# ๅŒๆญฅๆŽจ่็”ปๅธˆ checkbox
for t, cb in self._artist_rec_checkboxes.items():
cb.set_value(t in tag_set)
show_nsfw_val = self.input_nsfw.value
self._refresh_related_from_selection(all_tags, show_nsfw_val)
self._refresh_group_from_selection(all_tags, show_nsfw_val)
self._refresh_artist_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)
# ๅˆทๆ–ฐๆŽจ่็”ปๅธˆ
show_nsfw_val = self.input_nsfw.value
self._refresh_artist_from_selection(current, show_nsfw_val)
def _on_group_checkbox_change(self, tag: str, checked: bool):
"""ๅŒ็ฑปๆ ‡็ญพๅค้€‰ๆก†ๅ˜ๅŒ–ๅ›ž่ฐƒใ€‚"""
self._mark_interaction()
current = self._get_selected_tags()
if checked:
if tag not in current:
current.append(tag)
self.tag_weights.setdefault(tag, 1.0)
self._set_selected_tags(current, skip_refresh=True)
ui.notify(f'ๅทฒๆทปๅŠ  {tag}', type='positive', timeout=1500)
else:
if tag in current:
current.remove(tag)
self.tag_weights.pop(tag, None)
self._set_selected_tags(current, skip_refresh=True)
ui.notify(f'ๅทฒ็งป้™ค {tag}', type='warning', timeout=1500)
# ๅณๅˆปๅˆทๆ–ฐๅ…ณ่”ๆŽจ่ + ็”ปๅธˆๆŽจ่
show_nsfw_val = self.input_nsfw.value
self._refresh_related_from_selection(current, show_nsfw_val)
self._refresh_artist_from_selection(current, show_nsfw_val)
def _on_artist_rec_checkbox_change(self, tag: str, checked: bool):
"""ๆŽจ่็”ปๅธˆๅค้€‰ๆก†ๅ˜ๅŒ–ๅ›ž่ฐƒใ€‚"""
self._mark_interaction()
current = self._get_selected_tags()
if checked:
if tag not in current:
current.append(tag)
self.tag_weights.setdefault(tag, 1.0)
self._set_selected_tags(current, skip_refresh=True)
ui.notify(f'ๅทฒๆทปๅŠ ็”ปๅธˆ {tag}', type='positive', timeout=1500)
else:
if tag in current:
current.remove(tag)
self.tag_weights.pop(tag, None)
self._set_selected_tags(current, skip_refresh=True)
ui.notify(f'ๅทฒ็งป้™ค็”ปๅธˆ {tag}', type='warning', timeout=1500)
def _manual_refresh_related(self):
"""ๆ‰‹ๅŠจ่งฆๅ‘ๅ…ณ่”ๆŽจ่ๅˆ—่กจ็š„ๅˆทๆ–ฐ"""
self._mark_interaction()
show_nsfw_val = self.input_nsfw.value
all_tags = self._get_selected_tags()
if all_tags:
self._refresh_related_from_selection(all_tags, show_nsfw_val)
self._refresh_artist_from_selection(all_tags, show_nsfw_val)
ui.notify('ๅทฒ่งฆๅ‘ๅ…ณ่”ๆŽจ่ๆ›ดๆ–ฐ', type='info', timeout=1500)
else:
self.chip_extra_selected.clear()
self._refresh_related([], show_nsfw_val)
ui.notify('ๅทฒๆธ…็ฉบๅ…ณ่”ๆŽจ่', type='info', timeout=1500)
def _manual_refresh_group(self):
"""ๆ‰‹ๅŠจ่งฆๅ‘ๅŒ็ฑปๆ‰ฉๅฑ•ๅŒบๅŸŸ็š„ๅˆทๆ–ฐ"""
self._mark_interaction()
show_nsfw_val = self.input_nsfw.value
all_tags = self._get_selected_tags()
if all_tags:
self._refresh_group_from_selection(all_tags, show_nsfw_val)
ui.notify('ๅทฒ่งฆๅ‘ๅŒ็ฑปๆ ‡็ญพๆ›ดๆ–ฐ', type='info', timeout=1500)
else:
if self.group_expansion_container is not None:
self.group_expansion_container.clear()
with self.group_expansion_container:
ui.label('่ฏทๅ…ˆๆœ็ดขๅนถๅ‹พ้€‰ๆ ‡็ญพโ€ฆ').classes('text-sm text-gray-400 italic p-4')
ui.notify('ๆš‚ๆœช้€‰ไธญๆ ‡็ญพ', type='info', timeout=1500)
# โ”€โ”€ ๅ…ณ่”ๆŽจ่ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
def _refresh_related(self, related: list, show_nsfw: bool):
if related is None:
related = []
selected_now = set(self._get_selected_tags())
old_related = self.current_related
new_tags = {r.tag for r in related}
preserved = [r for r in old_related if r.tag in selected_now and r.tag not in new_tags]
merged = list(related) + preserved
self.current_related = merged
if self.related_list_container is not None:
self._render_related_list(merged, show_nsfw)
def _refresh_related_from_selection(self, selected_tags: list[str], show_nsfw: bool):
"""ไป…ๅˆทๆ–ฐๅ…ณ่”ๆŽจ่ๅˆ—่กจ๏ผˆ300ms ๅŽปๆŠ–๏ผŒ้ฟๅ…ๅฟซ้€Ÿๅ‹พ้€‰ไบง็”Ÿ CPU ๆดชๅณฐ๏ผ‰ใ€‚"""
# ๅ–ๆถˆไธŠๆฌกๆœชๆ‰ง่กŒ็š„ๅˆทๆ–ฐ
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 _refresh_artist_from_selection(self, selected_tags: list[str], show_nsfw: bool = True):
"""ๆ นๆฎๅทฒ้€‰ๆ ‡็ญพๅˆทๆ–ฐ็”ปๅธˆๆŽจ่๏ผˆ300ms ๅŽปๆŠ–๏ผ‰ใ€‚"""
if self._debounce_artist_task and not self._debounce_artist_task.done():
self._debounce_artist_task.cancel()
async def _do():
await asyncio.sleep(0.3)
if len(selected_tags) < 1:
self._render_artist_rec([], {}, show_nsfw)
return
tagger = await DanbooruTagger.get_instance()
artist_results = await tagger.search_artists_by_tags_async(
selected_tags, limit=30, min_cooc=3,
)
top_tags = {}
if artist_results:
names = [r.artist for r in artist_results[:10]]
top_tags = tagger.get_artist_top_tags(names, show_nsfw=show_nsfw)
self._render_artist_rec(artist_results, top_tags, show_nsfw)
self._debounce_artist_task = asyncio.ensure_future(_do())
def _render_artist_rec(self, artist_results, top_tags=None, show_nsfw: bool = True):
"""ๆธฒๆŸ“ๆŽจ่็”ปๅธˆๅˆ—่กจ๏ผˆๅฏนๆ ‡ๅ…ณ่”ๆŽจ่ๆ ทๅผ๏ผ‰ใ€‚"""
if self.artist_rec_list is None:
return
self.artist_rec_list.clear()
self._artist_rec_checkboxes.clear()
self._current_artist_rec_tags.clear()
if not artist_results:
with self.artist_rec_list:
ui.label('ๆš‚ๆ— ๆŽจ่็”ปๅธˆ').classes('text-sm text-gray-400 italic p-4')
return
top_tags = top_tags or {}
selected_now = set(self._get_selected_tags())
with self.artist_rec_list:
for r in artist_results[:10]:
artist = r.artist
self._current_artist_rec_tags.add(artist)
is_selected = artist in selected_now
# ๅฝ’ไธ€ๅŒ–๏ผš้™คไปฅๅ‘ฝไธญๆ ‡็ญพๆ•ฐ๏ผŒcap ๅˆฐ 100%
normalized = min(r.score / max(r.hit_count, 1), 1.0)
score_pct = f'+{normalized * 100:.0f}%'
sources_str = 'ใ€'.join(r.sources[:3]) if r.sources else 'โ€”'
post_str = f'{r.post_count:,}' if r.post_count else 'โ€”'
# tooltip๏ผš็”ปๅธˆๆ“…้•ฟๆ ‡็ญพ
tag_list = top_tags.get(artist, [])
tooltip_html = f'<div><b>{artist}</b><br>่ฟ™ไฝ็”ปๅธˆ็ปๅธธ็”ป:<br>'
if tag_list:
for t in tag_list[:10]:
tooltip_html += f' ยท {t}<br>'
else:
tooltip_html += ' (ๆ— ๆ•ฐๆฎ)'
tooltip_html += '</div>'
with ui.row().classes(
'w-full items-center gap-2 px-3 py-2 related-item border-b border-gray-100'
).style('background: rgba(244,114,182,0.04);'):
# tooltip
with ui.tooltip().props('content-class="bg-black text-white shadow-4" max-width="400px"'):
ui.html(tooltip_html).style('font-size:14px;line-height:1.5;max-width:380px;')
# Checkbox
cb = ui.checkbox(
'', value=is_selected,
on_change=lambda e, t=artist: self._on_artist_rec_checkbox_change(t, e.value)
).props('dense')
self._artist_rec_checkboxes[artist] = cb
# ็”ปๅธˆๅ + ไฟกๆฏ
with ui.column().classes('flex-grow gap-0 min-w-0'):
ui.link(
artist,
f'https://danbooru.donmai.us/posts?tags={artist}',
new_tab=True,
).classes('text-primary font-bold text-xs')
ui.label(f'{sources_str} ยท ไฝœๅ“ {post_str}').classes('text-xs text-gray-500')
# ๅˆ†ๅ€ผ
score_color = 'green' if normalized > 0.6 else ('teal' if normalized > 0.3 else 'grey')
ui.label(score_pct).classes(f'text-sm font-bold text-{score_color}-600 whitespace-nowrap')
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(168,85,247,0.06);',
}
CAT_LABEL = {'General': '้€š็”จ', 'Character': '่ง’่‰ฒ', 'Copyright': 'ไฝœๅ“'}
selected_now = set(self._get_selected_tags())
with self.group_expansion_container:
for group_info in group_data:
group_name = group_info['group']
group_cn = group_info.get('group_cn_name', group_name.replace('tag_group:', ''))
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()
parts = []
for t in tags:
w = self.tag_weights.get(t, 1.0)
if self.prompt_format == 'anima' and t in self._current_artist_rec_tags:
parts.append(_format_tag_with_weight(f'@{t}', w, self.prompt_format))
else:
parts.append(_format_tag_with_weight(t, w, self.prompt_format))
prompt = ', '.join(parts)
ui.clipboard.write(prompt)
fmt_label = {'sdxl': 'SDXL', 'nai': 'NAI', 'anima': 'Anima'}.get(self.prompt_format, 'SDXL')
ui.notify(f'ๅทฒๅคๅˆถ้€‰ไธญๆ ‡็ญพ๏ผˆ{fmt_label} ๆ ผๅผ๏ผ‰!', type='positive')
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()
# โ”€โ”€ ้กต้ข่ทฏ็”ฑ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
@ui.page('/')
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()
@app.on_startup
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())
@app.on_shutdown
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
@app.on_startup
async def _start_mcp():
global _mcp_lifespan_ctx
_mcp_lifespan_ctx = mcp_app.router.lifespan_context(mcp_app)
await _mcp_lifespan_ctx.__aenter__()
@app.on_shutdown
async def _stop_mcp():
global _mcp_lifespan_ctx
if _mcp_lifespan_ctx is not None:
await _mcp_lifespan_ctx.__aexit__(None, None, None)
@app.get('/googlebd34b54f8562aa06.html')
def google_verification():
return PlainTextResponse('google-site-verification: googlebd34b54f8562aa06.html')
@app.get('/robots.txt')
def robots_txt():
content = (
'User-agent: *\n'
'Allow: /$\n'
'Disallow: /api/\n'
'Disallow: /_nicegui/\n'
'Disallow: /socket.io/\n'
)
return PlainTextResponse(content)
@app.get('/robots.txt')
def robots_txt():
content = (
'User-agent: *\n'
'Allow: /$\n'
'Disallow: /api/\n'
'Disallow: /_nicegui/\n'
'Disallow: /socket.io/\n'
)
return PlainTextResponse(content)
@app.head('/')
async def head_root():
return PlainTextResponse('')
ui.run(
host=host,
port=port,
title='Danbooru Tags Searcher',
reload=not is_cloud(),
show=not is_cloud(),
reconnect_timeout=120,
)