DanbooruSearch / webui /controller.py
SAkizuki's picture
Auto-sync from GitHub Actions
65b0b93 verified
Raw
History Blame Contribute Delete
97.3 kB
"""
webui/controller.py
โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
NiceGUI ้กต้ขๆŽงๅˆถๅ™จใ€‚
โ–ธ ็ผ–ๆŽ’็”จๆˆทๅŠจไฝœใ€ๆฃ€็ดข่ฐƒ็”จใ€้กต้ข็Šถๆ€ไธŽ่ง†ๅ›พๅˆทๆ–ฐใ€‚
โ–ธ NiceGUI ๆŽงไปถๅˆ›ๅปบ้›†ไธญๅœจ webui.renderใ€‚
โ–ธ ๅฏๅŠจใ€่ทฏ็”ฑๅ’Œๅบ”็”จๆŒ‚่ฝฝ้›†ไธญๅœจ ui_nicegui.pyใ€‚
"""
import asyncio
import logging
import re
import time
import json as _json
from datetime import timedelta, timezone
from nicegui import ui
from core import counter, telemetry
from core.engine import DanbooruTagger
from core.models import SearchRequest
from core.prompt_import import (
PromptImportResult,
WorkspaceCanonicalizationResult,
canonicalize_workspace_tags,
classify_workspace_tag,
pending_to_workspace_entry,
resolve_prompt_text,
)
from core.workspace import (
FAVORITES_STORAGE_KEY,
HISTORY_STORAGE_KEY,
LEGACY_STAGED_STORAGE_KEY,
WORKSPACE_STORAGE_KEY,
WorkspaceDataError,
build_backup,
dump_collection,
empty_favorites,
empty_history,
favorite_from_workspace,
merge_favorite_into_workspace,
new_workspace,
normalize_backup,
normalize_favorites,
replace_with_favorite,
utc_now_iso,
)
from platform_utils import nsfw_allowed
from webui.constants import (
ANNOUNCEMENT_VERSION as _ANNOUNCEMENT_VERSION,
ARTIST_ORIGINS as _ARTIST_ORIGINS,
ARTIST_REC_LIMIT,
CONFIG_LS_KEY as _CONFIG_LS_KEY,
CONFIG_VERSION as _CONFIG_VERSION,
GROUP_RENDER_TAG_LIMIT,
HISTORY_PRE_COMPACTION_BACKUP_KEY as _HISTORY_PRE_COMPACTION_BACKUP_KEY,
LOCAL_STORAGE_NAMES as _LOCAL_STORAGE_NAMES,
LOCAL_STORAGE_RESTORE_RETRY_DELAYS as _LOCAL_STORAGE_RESTORE_RETRY_DELAYS,
RECOMMENDATION_DEBOUNCE_SECONDS,
SEARCH_MODE_OPTIONS as _SEARCH_MODE_OPTIONS,
SEARCH_MODE_PRESETS as _SEARCH_MODE_PRESETS,
SPONSOR_IMAGE_URL,
SPONSOR_NOTICE_TEXT,
SPONSOR_TITLE,
SPONSOR_TOOLCHAIN_URL,
UI_TEXT,
WORKSPACE_SAVE_DEBOUNCE_SECONDS,
)
from webui.helpers import (
apply_nsfw_filter as _apply_nsfw_filter,
format_history_settings,
format_history_time,
format_selected_tag_label,
format_tag_with_weight,
get_git_commit,
group_names_key,
group_scroll_dom_id,
limit_group_render_tags,
next_group_render_limit,
result_to_row as _result_to_row,
scroll_state_restore_script,
should_group_start_expanded,
)
from webui.styles import MOTION_STYLE
from webui.state import PageState, StateField
from webui.local_storage import backup_key, clear_restore_cache, finish_storage_restore_task, flush_storage_session_changes, pause_storage_restore, prepare_restore_snapshot, read_prepared_value, restore_staged_storage, restore_with_retries, schedule_workspace_persist, start_storage_restore_task, storage_keys, storage_listener_script, write_is_ready
from webui.workspace_state import apply_config_state, apply_search_settings, apply_workspace_state, build_backup_import_plan, clear_history, collect_config_state, current_search_settings, merge_imported_favorite, pop_redo_workspace, pop_undo_workspace, push_undo_snapshot, record_search_history_state, remove_history_entry, replace_favorites_safely, selected_tags, set_selection_meta, sync_workspace_selection
from webui.recommendations import clamp_page, consume_latest_recommendation_requests, merge_related_results, queue_latest_recommendation_request, recommendation_seed_tags, set_paginated_recommendation_page
from webui.render.results_panel import (
filter_by_source,
build_results_columns,
render_concept_coverage,
update_table_columns,
)
from webui.render.recommendations import (
render_artist_page,
render_artist_recommendations,
render_group_expansion,
render_related_list,
render_related_page,
)
from webui.render.dialogs import (
build_help_dialog,
build_sponsor_dialog,
confirm_clear_history,
confirm_delete_all_personal_data,
confirm_delete_favorite,
open_backup_dialog,
open_favorites_dialog,
open_history_dialog,
open_prompt_import_dialog,
open_rename_favorite_dialog,
open_save_favorite_dialog,
open_search_feedback_dialog,
open_translation_feedback_dialog,
)
from webui.render.workspace_panel import (
build_selection_bar,
build_workspace_toolbar,
render_prompt_pending,
render_selected_chips,
render_selected_tag_chip,
show_prompt_import_summary,
show_workspace_canonicalization,
)
from webui.render.search_panel import build_search_panel
from webui.render.page import (
build_page as render_page,
build_release_announcement,
render_service_status,
)
# ไป…็ปŸ่ฎกๅฝ“ๅ‰่ฟ›็จ‹ไธญๅทฒๅปบ็ซ‹ Socket.IO ่ฟžๆŽฅ็š„ UI ้กต้ข๏ผŒไธ็ญ‰ๅŒไบŽๅ”ฏไธ€็”จๆˆทๆ•ฐใ€‚
_ACTIVE_UI_CLIENT_IDS: set[str] = set()
def _mark_ui_session_active(client_id: str) -> None:
_ACTIVE_UI_CLIENT_IDS.add(client_id)
def _mark_ui_session_inactive(client_id: str) -> None:
_ACTIVE_UI_CLIENT_IDS.discard(client_id)
def _get_active_ui_session_count() -> int:
return len(_ACTIVE_UI_CLIENT_IDS)
logging.getLogger("httpx").setLevel(logging.WARNING)
logging.getLogger("huggingface_hub").setLevel(logging.WARNING)
logging.getLogger("mcp").setLevel(logging.WARNING)
logging.getLogger("mcp.server").setLevel(logging.WARNING)
logging.getLogger("fastmcp").setLevel(logging.WARNING)
# suppress MCP streamable-HTTP transport noise ("No response returned" from Starlette middleware)
class _SuppressMCPNoise(logging.Filter):
_MARKER = "No response returned"
def filter(self, record: logging.LogRecord) -> bool:
if self._MARKER in record.getMessage():
return False
if record.exc_info:
import traceback
tb_text = "".join(traceback.format_exception(*record.exc_info))
if self._MARKER in tb_text:
return False
return True
logging.getLogger("uvicorn.error").addFilter(_SuppressMCPNoise())
# suppress MCP OAuth discovery 404 noise (clients probing .well-known OAuth endpoints)
class _SuppressOAuthNoise(logging.Filter):
_MARKERS = (
".well-known/oauth-authorization-server",
".well-known/oauth-protected-resource",
)
def filter(self, record: logging.LogRecord) -> bool:
if any(marker in record.getMessage() for marker in self._MARKERS):
return False
return True
logging.getLogger("uvicorn.access").addFilter(_SuppressOAuthNoise())
logging.getLogger("nicegui").addFilter(_SuppressOAuthNoise())
# โ”€โ”€ ่พ…ๅŠฉๅ‡ฝๆ•ฐ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
_HISTORY_DISPLAY_TIMEZONE = timezone(timedelta(hours=8))
def _format_history_time(value: object) -> str:
return format_history_time(value)
def _format_history_settings(settings: object) -> str:
return format_history_settings(settings)
def _next_group_render_limit(current: int, total: int, page_size: int) -> int:
return next_group_render_limit(current, total, page_size)
def _limit_group_render_tags(tags: list[dict], visible_limit: int | None = None) -> tuple[list[dict], int]:
return limit_group_render_tags(tags, visible_limit)
def _should_group_start_expanded(group_name: str, expanded_groups: set[str]) -> bool:
return should_group_start_expanded(group_name, expanded_groups)
def _group_names_key(group_data: list[dict]) -> tuple[str, ...]:
return group_names_key(group_data)
def _group_scroll_dom_id(group_name: str) -> str:
return group_scroll_dom_id(group_name)
def _scroll_state_restore_script(positions: dict[str, int]) -> str:
return scroll_state_restore_script(positions)
def _get_git_commit() -> str:
return get_git_commit()
def result_to_row(r, nsfw_visible: bool) -> dict:
return _result_to_row(r, nsfw_visible)
def apply_nsfw_filter(rows: list[dict], show_nsfw: bool) -> list[dict]:
return _apply_nsfw_filter(rows, show_nsfw)
def _format_tag_with_weight(tag: str, weight: float, fmt: str = 'sdxl') -> str:
return format_tag_with_weight(tag, weight, fmt)
def _format_selected_tag_label(tag: str, cn_name: str = '') -> str:
return format_selected_tag_label(tag, cn_name)
# โ”€โ”€ UI ็ฑป โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
class DanbooruSearchUI:
full_table_data = StateField()
current_segments = StateField()
current_keywords = StateField()
current_cached_queries = StateField()
current_filter_keyword = StateField()
current_query_str = StateField()
full_tags_str = StateField()
full_tags_str_sfw = StateField()
current_related = StateField()
chip_extra_selected = StateField()
_selected_order = StateField('selected_order')
_rendered_selected_chip_tags = StateField('rendered_selected_chip_tags')
workspace_state = StateField('workspace')
search_history = StateField('history')
favorites = StateField()
_undo_stack = StateField('undo_stack')
_redo_stack = StateField('redo_stack')
_pending_selection_meta = StateField('pending_selection_meta')
_workspace_artist_tags = StateField('workspace_artist_tags')
tag_weights = StateField()
prompt_format = StateField()
selected_layers = StateField()
selected_cats = StateField('selected_categories')
_related_results = StateField('related_results')
_related_show_nsfw = StateField('related_show_nsfw')
_related_page = StateField('related_page')
_related_page_count = StateField('related_page_count')
_group_render_limits = StateField('group_render_limits')
_group_expanded_names = StateField('group_expanded_names')
_group_scroll_positions = StateField('group_scroll_positions')
_group_render_key = StateField('group_render_key')
_group_candidate_sources = StateField('group_candidate_sources')
_artist_rec_rows = StateField('artist_rec_rows')
_artist_rec_results = StateField('artist_rec_results')
_artist_rec_top_tags = StateField('artist_rec_top_tags')
_artist_rec_show_nsfw = StateField('artist_rec_show_nsfw')
_artist_rec_sources = StateField('artist_rec_sources')
_artist_rec_page = StateField('artist_rec_page')
_artist_rec_page_count = StateField('artist_rec_page_count')
_current_artist_rec_tags = StateField('current_artist_rec_tags')
_artist_result_tags = StateField('artist_result_tags')
_last_recommendation_seed_tags = StateField('last_recommendation_seed_tags')
_pending_recommendation_request = StateField('pending_recommendation_request')
_recommendation_generation = StateField('recommendation_generation')
_storage_states = StateField('storage_states')
_storage_session_dirty = StateField('storage_session_dirty')
_storage_applying = StateField('storage_applying')
_storage_raw_values = StateField('storage_raw_values')
def __init__(self):
self.state = PageState()
self.search_count_label = None
self.service_status_container = None
self._service_status_task = None
self._last_service_status_key = None
self.current_search_interacted = True
self._telemetry_search_started_at: float | None = None
self._telemetry_selection_recorded = False
self._telemetry_copy_timing_recorded = False
self._telemetry_last_query = ''
self._telemetry_last_search_at = 0.0
self.full_table_data: list[dict] = []
self.current_segments: list[str] = [] # ไปŽๅฅ็บงๅŽŸๅง‹็‰‡ๆฎต๏ผŒ็”จไบŽๅŒบๅˆ† chip ้ขœ่‰ฒ
self.current_keywords: list[str] = []
self.current_cached_queries: set[str] = set()
self.current_filter_keyword: str = 'ALL' # ๅฝ“ๅ‰้€‰ไธญ็š„ๅˆ†่ฏ็ญ›้€‰ keyword๏ผˆNSFW ๅˆ‡ๆขๆ—ถๅค็”จ๏ผ‰
self.current_query_str: str = ""
self.full_tags_str: str = ""
self.full_tags_str_sfw: str = ""
self.result_table = None # ๅทฆๆ ่กจๆ ผ
self.related_list_container = None # ๅณๆ ๅ…ณ่”ๆŽจ่ๅˆ—่กจ
self.group_expansion_container = None # ๅทฆๆ  Group ๅŒ็ฑปๆ‰ฉๅฑ•๏ผˆ่กจๆ ผไธ‹ๆ–น๏ผ‰
self.artist_rec_pagination = None
self.related_pagination = None
self.client = None
self._group_render_limits: dict[str, int] = {}
self._group_expanded_names: set[str] = set()
self._group_scroll_positions: dict[str, int] = {}
self._group_render_key: tuple[str, ...] = ()
self.results_section = None # ๆ•ดไธช็ป“ๆžœๅŒบๅŸŸ๏ผˆๆœ็ดขๅ‰้š่—๏ผ‰
self.coverage_container = None
self.selection_count_label = None
self.selected_display = None # ๅทฒๅบŸๅผƒ textarea๏ผŒไฟ็•™ๅ…ผๅฎน
self.selected_chips_container = None # ๅทฒ้€‰ๆ ‡็ญพ chip ๅฎนๅ™จ
self.prompt_pending_container = None
self.current_related: list = []
self.chip_extra_selected: set = set()
self._selected_order: list[str] = []
self._rendered_selected_chip_tags: set[str] = set()
self.workspace_state: dict = new_workspace()
self.search_history: dict = empty_history()
self.favorites: dict = empty_favorites()
self._undo_stack: list[dict] = []
self._redo_stack: list[dict] = []
self._pending_selection_meta: dict[str, dict[str, str]] = {}
self._workspace_artist_tags: set[str] = set()
self.undo_btn = None
self.redo_btn = None
self.history_count_label = None
self.favorites_count_label = None
self._workspace_storage_listener_installed = False
# ้€‰ๆ‹ฉๅˆทๆ–ฐๅชไฟ็•™ๆœ€ๆ–ฐๅฟซ็…ง๏ผ›่ฟ่กŒไธญ็š„็บฟ็จ‹ไธๅ–ๆถˆ๏ผŒๅฎŒๆˆๅŽไธขๅผƒ่ฟ‡ๆœŸ็ป“ๆžœใ€‚
self._recommendation_task = None # type: asyncio.Task | None
self._pending_recommendation_request = None
self._recommendation_generation = 0
self._workspace_save_task = None # type: asyncio.Task | None
self._coverage_render_task = None # type: asyncio.Task | None
self._storage_restore_task = None # type: asyncio.Task | None
self._storage_restore_started = False
self._storage_restoring = False
self._storage_failure_notified = False
self._storage_states = {name: 'pending' for name in _LOCAL_STORAGE_NAMES}
self._storage_session_dirty: set[str] = set()
self._storage_applying: set[str] = set()
self._storage_raw_values: dict[str, str | None] = {}
# tag -> prompt ๆƒ้‡๏ผŒ่Œƒๅ›ด [0.1, 1.9]๏ผŒ้ป˜่ฎค 1.0
self.tag_weights: dict[str, float] = {}
# ๅคๅˆถๆ ผๅผ๏ผš'sdxl'ใ€'nai' ๆˆ– 'anima'
self.prompt_format: str = 'sdxl'
self.format_toggle_btn = None
self.init_banner = None
self.input_top_k = None
self.input_limit = None
self.input_weight = None
self.input_nsfw = None
self.input_segment = None
self.input_search_mode = None
self.input_group_mode = None
self.input_max_per_group = None
self._applying_preset = False
self.search_input = None
self.keywords_container = None
self.spinner = None
self.search_btn = None
self.selected_layers = {'่‹ฑๆ–‡': True, 'ไธญๆ–‡ๆ‰ฉๅฑ•่ฏ': True, '้‡Šไน‰': True, 'ไธญๆ–‡ๆ ธๅฟƒ่ฏ': True, 'artist': True}
self.selected_cats = {'General': True, 'Copyright': True, 'Character': True}
self.bad_case_btn = None
self.announcement_banner = None
self.dismissed_announcement_version = ''
self.help_dialog = None
self.sponsor_dialog = None
# ่กจๆ ผๆ˜พ็คบ้€‰้กนๅผ€ๅ…ณ
self.sw_semantic = None
self.sw_layer = None
self.sw_source = None
# ๅ…ณ่”ๆŽจ่็š„ checkbox ๅผ•็”จ
self._related_checkboxes: dict[str, ui.checkbox] = {}
self._related_results: list = []
self._related_show_nsfw = True
self._related_page = 1
self._related_page_count = 0
self._related_page_label = None
self._related_prev_button = None
self._related_next_button = None
# ๅŒ็ฑปๆ ‡็ญพ็š„ checkbox ๅผ•็”จ
self._group_checkboxes: dict[str, ui.checkbox] = {}
# ๆŽจ่็”ปๅธˆ็š„ checkbox ๅผ•็”จ
self._artist_rec_checkboxes: dict[str, ui.checkbox] = {}
self._artist_rec_rows: list = []
self._artist_rec_results: list = []
self._artist_rec_top_tags: dict[str, list[str]] = {}
self._artist_rec_show_nsfw = True
self._artist_rec_sources: dict[str, str] = {}
self._group_candidate_sources: dict[str, str] = {}
self._artist_rec_page = 1
self._artist_rec_page_count = 0
self._artist_rec_page_label = None
self._artist_rec_prev_button = None
self._artist_rec_next_button = None
# ๅฝ“ๅ‰ๆŽจ่็”ปๅธˆ็š„ๆ ‡็ญพๅ้›†ๅˆ๏ผˆ็”จไบŽ Anima ๆจกๅผๅคๅˆถๆ—ถๅŠ  @ ๅ‰็ผ€๏ผ‰
self._current_artist_rec_tags: set[str] = set()
self._artist_result_tags: set[str] = set()
self._last_recommendation_seed_tags: list[str] = []
# ้ซ˜็บง้€‰้กนไธญๅ„ๅฑ‚/็ฑปๅž‹็š„ checkbox ๅผ•็”จ๏ผŒ็”จไบŽ restore ๆ—ถๅŒๆญฅๆŽงไปถ็Šถๆ€
self._layer_checkboxes: dict[str, ui.checkbox] = {}
self._cat_checkboxes: dict[str, ui.checkbox] = {}
def _update_footer_text(self):
if self.search_count_label is not None and self._client_alive():
try:
total = counter.get()
visits = counter.get_visits()
commit = _get_git_commit()
self.search_count_label.content = (
f'็ดฏ่ฎกๆœ็ดข {total:,} ๆฌก | ็ดฏ่ฎก่ฎฟ้—ฎ {visits:,} ๆฌก | '
f'<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 _update_service_status(self):
if self.service_status_container is None or not self._client_alive():
return
ready = DanbooruTagger.is_ready()
online_sessions = _get_active_ui_session_count()
load = DanbooruTagger.get_load_snapshot()
active = load['active']
waiting = load['waiting']
capacity = load['capacity']
busy = ready and (waiting > 0 or active >= capacity)
status_key = (ready, busy, online_sessions, active, waiting, capacity)
if status_key == self._last_service_status_key:
return
self._last_service_status_key = status_key
render_service_status(self, {
'ready': ready,
'busy': busy,
'online_sessions': online_sessions,
'active': active,
'waiting': waiting,
})
def _client_connected(self) -> bool:
if not self._client_alive():
return False
client = self.client
connection_state = getattr(client, 'has_socket_connection', None)
if callable(connection_state):
connection_state = connection_state()
if connection_state is not None:
return bool(connection_state)
if hasattr(client, 'tab_id'):
return client.tab_id is not None
return True
async def _service_status_loop(self):
"""Refresh service status without creating a NiceGUI timer element."""
try:
while self._client_alive():
await asyncio.sleep(5.0)
if not self._client_alive():
return
if not self._client_connected():
continue
try:
self._update_service_status()
except RuntimeError:
# The page may be deleted between the connection check and render.
return
except asyncio.CancelledError:
return
finally:
if self._service_status_task is asyncio.current_task():
self._service_status_task = None
def _start_service_status_task(self):
task = self._service_status_task
if task is not None and not task.done():
return
self._service_status_task = asyncio.create_task(self._service_status_loop())
def _build_sponsor_dialog(self):
build_sponsor_dialog(
self,
title=SPONSOR_TITLE,
image_url=SPONSOR_IMAGE_URL,
toolchain_url=SPONSOR_TOOLCHAIN_URL,
ui_text=UI_TEXT,
)
def _build_help_dialog(self):
build_help_dialog(
self,
ui_text=UI_TEXT,
sponsor_notice_text=SPONSOR_NOTICE_TEXT,
)
def _confirm_delete_all_personal_data(self):
storage_keys = [
_CONFIG_LS_KEY,
WORKSPACE_STORAGE_KEY,
HISTORY_STORAGE_KEY,
FAVORITES_STORAGE_KEY,
LEGACY_STAGED_STORAGE_KEY,
f'{WORKSPACE_STORAGE_KEY}_corrupt_backup',
f'{HISTORY_STORAGE_KEY}_corrupt_backup',
f'{FAVORITES_STORAGE_KEY}_corrupt_backup',
_HISTORY_PRE_COMPACTION_BACKUP_KEY,
]
confirm_delete_all_personal_data(self, storage_keys)
def _build_release_announcement(self):
build_release_announcement(self)
def _dismiss_release_announcement(self):
self.dismissed_announcement_version = _ANNOUNCEMENT_VERSION
if self.announcement_banner:
self.announcement_banner.set_visibility(False)
self._save_config()
def _mark_interaction(self, e=None):
if not self.current_search_interacted:
self.current_search_interacted = True
async def silent_success_update():
try:
await counter.increment_success()
except Exception:
pass
asyncio.create_task(silent_success_update())
def _record_first_selection_if_needed(
self,
previous_tags: list[str],
current_tags: list[str],
) -> None:
if self._telemetry_search_started_at is None or self._telemetry_selection_recorded:
return
if not (set(current_tags) - set(previous_tags)):
return
self._telemetry_selection_recorded = True
duration_ms = (time.perf_counter() - self._telemetry_search_started_at) * 1000
async def record_selection() -> None:
try:
await telemetry.increment("ui_search_with_selection_session")
await telemetry.record_timing("search_to_first_selection", duration_ms)
except Exception as exc:
print(f"[UI] ้ฆ–ๆฌก้€‰ๆ‹ฉ็ปŸ่ฎกๅคฑ่ดฅ: {exc}", flush=True)
asyncio.create_task(record_selection())
def _record_ui_copy(self, event_name: str) -> None:
duration_ms: float | None = None
if (
self._telemetry_search_started_at is not None
and not self._telemetry_copy_timing_recorded
):
self._telemetry_copy_timing_recorded = True
duration_ms = (time.perf_counter() - self._telemetry_search_started_at) * 1000
async def record_copy() -> None:
try:
await counter.increment_copy()
await telemetry.increment(event_name)
if duration_ms is not None:
await telemetry.record_timing("search_to_first_copy", duration_ms)
except Exception as exc:
print(f"[UI] ๅคๅˆถ็ปŸ่ฎกๅคฑ่ดฅ: {exc}", flush=True)
asyncio.create_task(record_copy())
# โ”€โ”€ ๅˆ†้กต่พ…ๅŠฉ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
def _get_rows_per_page(self) -> int:
if self.result_table is None:
return 0
p = self.result_table.pagination
# pagination ๅฏ่ƒฝๆ˜ฏ int ๆˆ– dict
if isinstance(p, dict):
return int(p.get('rowsPerPage', 0))
return int(p) if p else 0
def _set_rows_per_page(self, value: int):
if self.result_table is None:
return
allowed = {5, 7, 10, 15, 20, 25, 50, 0} # 0 = All
value = value if value in allowed else 0
p = self.result_table.pagination
if isinstance(p, dict):
p['rowsPerPage'] = value
self.result_table.pagination = p
else:
self.result_table.pagination = value
# โ”€โ”€ ้…็ฝฎๆŒไน…ๅŒ– โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
def _apply_prompt_format(self, prompt_format: str):
"""็ปŸไธ€่ฎพ็ฝฎๅคๅˆถๆ ผๅผๅŠๆŒ‰้’ฎๅค–่ง‚๏ผŒไธ่งฆๅ‘้ขๅค–ๆŒไน…ๅŒ–ใ€‚"""
if prompt_format not in ('sdxl', 'nai', 'anima'):
prompt_format = 'sdxl'
self.prompt_format = prompt_format
if not self.format_toggle_btn:
return
if prompt_format == 'nai':
self.format_toggle_btn.text = 'NAI'
self.format_toggle_btn.props('color=purple-7')
elif prompt_format == 'anima':
self.format_toggle_btn.text = 'Anima'
self.format_toggle_btn.props('color=teal-7')
else:
self.format_toggle_btn.text = 'SDXL'
self.format_toggle_btn.props('color=grey-7')
def _collect_config_state(self) -> dict:
return collect_config_state(self, _CONFIG_VERSION)
def _storage_write_allowed(self, name: str) -> bool:
"""Block writes until that localStorage domain has been safely restored."""
if not write_is_ready(name, self._storage_states, self._storage_applying) or not self._client_connected():
if self._storage_restore_started:
self._storage_session_dirty.add(name)
return False
return True
def _save_config(self):
"""ๅฐ†ๅฝ“ๅ‰ๆŽงไปถ็Šถๆ€ๅบๅˆ—ๅŒ–ๅนถๅ†™ๅ…ฅ localStorageใ€‚"""
if not self._storage_write_allowed('config'):
return False
cfg = self._collect_config_state()
js = _json.dumps(cfg, ensure_ascii=False)
try:
self.client.run_javascript(
f"localStorage.setItem('{_CONFIG_LS_KEY}', {_json.dumps(js)});"
)
except RuntimeError:
self._storage_session_dirty.add('config')
return False
self._storage_session_dirty.discard('config')
return True
def _apply_config_state(self, cfg: dict):
apply_config_state(self, cfg, _ANNOUNCEMENT_VERSION)
# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
# ้กต้ขๆž„ๅปบ
# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
def build_page(self):
engine_ready = DanbooruTagger.is_ready()
render_page(
self,
motion_style=MOTION_STYLE,
sponsor_notice_text=SPONSOR_NOTICE_TEXT,
)
if not engine_ready:
asyncio.ensure_future(self._hide_banner_when_ready())
def _replay_motion(self, element_id: str, motion_class: str) -> None:
"""ๅœจๅฎขๆˆท็ซฏ้‡ๆ”พไธ€ๆฌกๅŒบๅŸŸ็บงๅŠจๆ•ˆ๏ผ›ๅ‡ๅฐ‘ๅŠจๆ€ๆ•ˆๆžœๆ—ถไฟๆŒ้™ๆญขใ€‚"""
if not self._client_alive():
return
try:
ui.run_javascript(f'''
const element = document.getElementById('{element_id}');
if (element && !window.matchMedia('(prefers-reduced-motion: reduce)').matches) {{
element.classList.remove(
'motion-results-enter',
'motion-secondary-enter',
'motion-refresh-enter',
'motion-recommendation-enter-right',
'motion-recommendation-enter-left',
);
void element.offsetWidth;
element.classList.add('{motion_class}');
}}
''')
except RuntimeError:
pass
# โ”€โ”€ ๆœ็ดขๅก็‰‡ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
def _build_search_card(self):
build_search_panel(self)
# โ”€โ”€ ๅทฅไฝœๅŒบๅทฅๅ…ท โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
def _build_workspace_toolbar(self):
build_workspace_toolbar(self)
def _update_workspace_counts(self):
if self.history_count_label is not None:
self.history_count_label.text = str(len(self.search_history.get('items', [])))
if self.favorites_count_label is not None:
self.favorites_count_label.text = str(len(self.favorites.get('items', [])))
def _current_search_settings(self) -> dict:
return current_search_settings(self)
def _apply_search_settings(self, settings: dict):
apply_search_settings(self, settings, _SEARCH_MODE_OPTIONS)
def _record_search_history(self, query: str):
record_search_history_state(self, query)
self._save_history()
self._update_workspace_counts()
def _open_history_dialog(self):
open_history_dialog(self)
async def _history_research(self, item: dict, dialog):
dialog.close()
self._apply_search_settings(item.get('settings', {}))
self.search_input.set_value(item['query'])
await self.perform_search()
async def _history_restore(self, item: dict, dialog):
normalized = await self._canonicalize_workspace_for_load(
item['workspace'],
source='ๅކๅฒๅทฅไฝœๅŒบๆขๅค',
)
self._push_undo_snapshot()
self._apply_workspace_state(normalized.workspace)
self.search_input.set_value(item['query'])
dialog.close()
ui.notify('ๅทฒๆขๅคๅކๅฒๅทฅไฝœๅŒบ๏ผ›ๆœช้‡ๆ–ฐๅ‘่ตทๆœ็ดข', type='positive')
self._show_workspace_canonicalization(normalized, 'ๅކๅฒๅทฅไฝœๅŒบ')
async def _history_append(self, item: dict, dialog):
dialog.close()
self.search_input.set_value(item['query'])
await self.perform_search()
def _delete_history_entry(self, item: dict, dialog):
remove_history_entry(self, item.get('history_id'))
self._save_history()
self._update_workspace_counts()
dialog.close()
self._open_history_dialog()
def _confirm_clear_history(self, parent_dialog):
confirm_clear_history(self, parent_dialog)
def _clear_all_history(self) -> None:
clear_history(self)
self._save_history()
self._update_workspace_counts()
def _open_prompt_import_dialog(self):
open_prompt_import_dialog(
self,
description=UI_TEXT['dialogs']['prompt_import_description'],
)
async def _resolve_prompt_import_text(self, text: str) -> PromptImportResult:
tagger = await DanbooruTagger.get_instance()
allow_nsfw = bool(nsfw_allowed() and self.input_nsfw and self.input_nsfw.value)
return await asyncio.to_thread(
resolve_prompt_text,
text,
resolve_tag=tagger.resolve_tag_name,
resolve_artist=tagger.resolve_artist_name,
lookup_tag=tagger.get_tag_workspace_metadata,
allow_nsfw=allow_nsfw,
)
def _apply_prompt_import_result(self, result: PromptImportResult) -> tuple[int, int]:
current = self._get_selected_tags()
existing = set(current)
existing_pending = {
(
item.get('normalized'),
bool(item.get('is_artist')),
item.get('reason'),
item.get('alias_target'),
)
for item in self.workspace_state.get('dismissed', [])
if isinstance(item, dict) and item.get('kind') == 'prompt_import_pending'
}
pending_to_add = []
duplicate_count = result.duplicate_count
for pending in result.pending:
key = (
pending.normalized,
pending.is_artist,
pending.reason,
pending.alias_target,
)
if key in existing_pending:
duplicate_count += 1
continue
existing_pending.add(key)
pending_to_add.append(pending_to_workspace_entry(pending))
new_items = [item for item in result.items if item.tag not in existing]
duplicate_count += len(result.items) - len(new_items)
if new_items or pending_to_add:
self._push_undo_snapshot()
for item in new_items:
existing.add(item.tag)
current.append(item.tag)
self.tag_weights[item.tag] = item.weight
self._set_selection_meta(
item.tag,
'prompt_import_artist' if item.is_artist else 'prompt_import',
item.original,
)
if pending_to_add:
self.workspace_state['dismissed'] = (
list(self.workspace_state.get('dismissed', [])) + pending_to_add
)[-2000:]
if new_items or pending_to_add:
self._set_selected_tags(current, record_undo=False)
self._render_prompt_pending()
return len(new_items), duplicate_count
@staticmethod
def _prompt_pending_reason(item: dict) -> str:
reason = item.get('reason')
if reason == 'alias_target_missing':
target = item.get('alias_target') or 'ๆœช็Ÿฅ็›ฎๆ ‡'
return f'Alias ๆŒ‡ๅ‘ {target}๏ผŒไฝ†็›ฎๆ ‡ไธๅœจๅฝ“ๅ‰ๆ ‡็ญพๅบ“่Œƒๅ›ดๅ†…'
if reason == 'nsfw_filtered':
return 'ๅฝ“ๅ‰ NSFW ่ฎพ็ฝฎไธๅ…่ฎธๅŠ ๅ…ฅ่ฏฅๆ ‡็ญพ'
if reason == 'ambiguous_compact':
return 'ๅญ˜ๅœจๅคšไธชๅฏ่ƒฝ็š„่ง„่Œƒๆ ‡็ญพ๏ผŒ่ฏทไบบๅทฅ้€‰ๆ‹ฉ'
if reason == 'not_found':
return 'ๅฝ“ๅ‰ๆ ‡็ญพๅบ“ไธญๆ— ๆณ•ๅ”ฏไธ€่ฏ†ๅˆซ'
if item.get('is_artist'):
return '็”ปๅธˆๅ…ฑ็Žฐๅบ“ไธญๆ— ๆณ•ๅ”ฏไธ€่ฏ†ๅˆซ'
return f'ๆ— ๆณ•่ฏ†ๅˆซ๏ผˆ{reason or "unknown"}๏ผ‰'
def _show_prompt_import_summary(self, result: PromptImportResult, added_count: int, duplicate_count: int):
show_prompt_import_summary(self, result, added_count, duplicate_count)
def _render_prompt_pending(self):
render_prompt_pending(self)
def _remove_prompt_pending(self, pending: dict):
pending_id = pending.get('pending_id')
self._push_undo_snapshot()
self.workspace_state['dismissed'] = [
item for item in self.workspace_state.get('dismissed', [])
if not (
isinstance(item, dict)
and item.get('kind') == 'prompt_import_pending'
and item.get('pending_id') == pending_id
)
]
self._save_staged_tags()
self._render_prompt_pending()
def _accept_prompt_candidate(self, pending: dict, candidate: str):
tagger = DanbooruTagger._instance
is_artist = bool(pending.get('is_artist'))
if tagger is None:
ui.notify('ๆ ‡็ญพๅผ•ๆ“Žๅฐšๆœชๅฐฑ็ปช๏ผŒ่ฏท็จๅŽๅ†่ฏ•', type='warning')
return
if is_artist:
resolved = tagger.resolve_artist_name(candidate)
canonical = resolved.get('artist')
else:
resolved = tagger.resolve_tag_name(candidate)
canonical = resolved.get('tag')
if not canonical:
ui.notify('่ฏฅๅ€™้€‰็›ฎๅ‰ๆ— ๆณ•ๅŠ ๅ…ฅๅทฅไฝœๅŒบ', type='warning')
return
if not is_artist:
metadata = tagger.get_tag_workspace_metadata(canonical) or {}
nsfw_blocked = (
str(metadata.get('nsfw', '0')) == '1'
and not bool(nsfw_allowed() and self.input_nsfw and self.input_nsfw.value)
)
if nsfw_blocked:
ui.notify('ๅฝ“ๅ‰ NSFW ่ฎพ็ฝฎไธๅ…่ฎธๅŠ ๅ…ฅ่ฏฅๅ€™้€‰', type='warning')
return
self._push_undo_snapshot()
self.workspace_state['dismissed'] = [
item for item in self.workspace_state.get('dismissed', [])
if not (
isinstance(item, dict)
and item.get('kind') == 'prompt_import_pending'
and item.get('pending_id') == pending.get('pending_id')
)
]
current = self._get_selected_tags()
if canonical not in current:
current.append(canonical)
self.tag_weights[canonical] = float(pending.get('weight', 1.0))
self._set_selection_meta(
canonical,
'prompt_import_artist' if is_artist else 'prompt_import',
str(pending.get('original') or ''),
)
self._set_selected_tags(current, record_undo=False)
self._render_prompt_pending()
ui.notify(
f"ๅทฒ็กฎ่ฎค๏ผš{pending.get('normalized') or pending.get('original')} โ†’ {canonical}",
type='positive',
)
async def _canonicalize_workspace_for_load(
self,
workspace: dict,
*,
source: str,
) -> WorkspaceCanonicalizationResult:
tagger = await DanbooruTagger.get_instance()
return await asyncio.to_thread(
canonicalize_workspace_tags,
workspace,
resolve_tag=tagger.resolve_tag_name,
resolve_artist=tagger.resolve_artist_name,
lookup_tag=tagger.get_tag_workspace_metadata,
artist_origins=_ARTIST_ORIGINS,
source=source,
)
def _show_workspace_canonicalization(self, result: WorkspaceCanonicalizationResult, label: str):
show_workspace_canonicalization(self, result, label)
def _open_save_favorite_dialog(self):
open_save_favorite_dialog(self)
def _save_current_workspace_as_favorite(self, name: str, notes: str) -> bool:
favorite = favorite_from_workspace(self.workspace_state, name, notes=notes)
candidate = {
'schema_version': 1,
'items': [favorite] + self.favorites.get('items', [])[:199],
}
if self._replace_favorites_safely(candidate):
ui.notify(f'ๅทฒไฟๅญ˜ๆ”ถ่—๏ผš{name}', type='positive')
return True
return False
def _open_favorites_dialog(self):
open_favorites_dialog(self)
async def _load_favorite(self, favorite: dict, merge: bool, dialog):
if merge:
workspace = merge_favorite_into_workspace(self.workspace_state, favorite)
message = f"ๅทฒๅˆๅนถๆ”ถ่—๏ผš{favorite['name']}"
else:
workspace = replace_with_favorite(favorite)
message = f"ๅทฒ่ฝฝๅ…ฅๆ”ถ่—๏ผš{favorite['name']}"
normalized = await self._canonicalize_workspace_for_load(
workspace,
source=f"ๆ”ถ่—ๆขๅค๏ผš{favorite['name']}",
)
self._push_undo_snapshot()
self._apply_workspace_state(normalized.workspace)
dialog.close()
ui.notify(message, type='positive')
self._show_workspace_canonicalization(normalized, f"ๆ”ถ่—โ€œ{favorite['name']}โ€")
def _copy_favorite(self, favorite: dict):
parts: list[str] = []
for item in favorite.get('selected', []):
tag = item['tag']
if favorite.get('prompt_format') == 'anima' and item.get('origin') in _ARTIST_ORIGINS:
tag = f'@{tag}'
parts.append(_format_tag_with_weight(
tag,
item.get('weight', 1.0),
favorite.get('prompt_format', 'sdxl'),
))
ui.clipboard.write(', '.join(parts))
ui.notify(f"ๅทฒๅคๅˆถๆ”ถ่—๏ผš{favorite['name']}", type='positive')
def _rename_favorite(self, favorite: dict, parent_dialog):
open_rename_favorite_dialog(self, favorite, parent_dialog)
def _rename_favorite_to(self, favorite: dict, name: str) -> str:
if not name:
return '่ฏท่พ“ๅ…ฅๅ็งฐ'
if any(
item['favorite_id'] != favorite['favorite_id'] and item['name'] == name
for item in self.favorites.get('items', [])
):
return 'ๅทฒๅญ˜ๅœจๅŒๅๆ”ถ่—'
candidate = normalize_favorites(self.favorites)[0]
for item in candidate['items']:
if item['favorite_id'] == favorite['favorite_id']:
item['name'] = name
item['updated_at'] = utc_now_iso()
break
if not self._replace_favorites_safely(candidate):
return 'ไฟๅญ˜ๅคฑ่ดฅ๏ผŒ่ฏท็จๅŽ้‡่ฏ•'
return ''
def _overwrite_favorite(self, favorite: dict, parent_dialog):
if not self._get_selected_tags():
ui.notify('ๅฝ“ๅ‰ๅทฅไฝœๅŒบๆฒกๆœ‰ๆ ‡็ญพ๏ผŒไธ่ƒฝ่ฆ†็›–ๆ”ถ่—', type='warning')
return
replacement = favorite_from_workspace(
self.workspace_state,
favorite['name'],
notes=favorite.get('notes', ''),
favorite_id=favorite['favorite_id'],
created_at=favorite.get('created_at'),
)
candidate = {
'schema_version': 1,
'items': [
replacement if item['favorite_id'] == favorite['favorite_id'] else item
for item in self.favorites.get('items', [])
],
}
if not self._replace_favorites_safely(candidate):
return
parent_dialog.close()
ui.notify(f"ๅทฒ่ฆ†็›–ๆ”ถ่—๏ผš{favorite['name']}", type='positive')
def _export_favorite(self, favorite: dict):
payload = {
'schema_version': 1,
'exported_at': favorite.get('updated_at', ''),
'favorite': favorite,
}
raw = dump_collection(payload, label='favorite export').encode('utf-8')
safe_name = re.sub(r'[^0-9A-Za-z\u4e00-\u9fff_-]+', '_', favorite['name'])[:60]
ui.download(raw, filename=f'danbooru_favorite_{safe_name or "export"}.json', media_type='application/json')
def _confirm_delete_favorite(self, favorite: dict, parent_dialog):
confirm_delete_favorite(self, favorite, parent_dialog)
def _delete_favorite(self, favorite: dict) -> bool:
candidate = {
'schema_version': 1,
'items': [
item for item in self.favorites.get('items', [])
if item['favorite_id'] != favorite['favorite_id']
],
}
return self._replace_favorites_safely(candidate)
def _open_backup_dialog(self):
open_backup_dialog(
self,
description=UI_TEXT['dialogs']['backup_description'],
)
def _export_backup(self):
backup = build_backup(
config=self._collect_config_state(),
workspace=self.workspace_state,
history=self.search_history,
favorites=self.favorites,
)
raw = _json.dumps(backup, ensure_ascii=False, indent=2).encode('utf-8')
filename = f"danbooru_workspace_backup_{utc_now_iso()[:10]}.json"
ui.download(raw, filename=filename, media_type='application/json')
async def _import_backup_text(self, raw: str, mode: str):
# ๅ•ไธชๆ”ถ่—ๅฏผๅ‡บๆ–‡ไปถไนŸๅฏ็›ดๆŽฅไปŽ่ฟ™้‡Œ้‡ๆ–ฐๅฏผๅ…ฅใ€‚
try:
parsed = _json.loads(raw)
except _json.JSONDecodeError as exc:
raise WorkspaceDataError('ๆ–‡ไปถไธๆ˜ฏๆœ‰ๆ•ˆ JSON') from exc
if isinstance(parsed, dict) and 'favorite' in parsed:
candidate, warnings = merge_imported_favorite(
self.favorites,
parsed['favorite'],
)
if not self._replace_favorites_safely(candidate):
return
ui.notify('ๆ”ถ่—ๅทฒๅฏผๅ…ฅ', type='positive')
if warnings:
print(f'[UI] ๆ”ถ่—ๅฏผๅ…ฅๆ็คบ: {warnings}', flush=True)
return
backup, warnings = normalize_backup(parsed)
workspace_normalization = None
if mode != 'favorites_only':
workspace_normalization = await self._canonicalize_workspace_for_load(
backup['workspace'],
source='JSON ๅค‡ไปฝๅฏผๅ…ฅ',
)
backup['workspace'] = workspace_normalization.workspace
plan = build_backup_import_plan(
self.workspace_state,
self.search_history,
self.favorites,
backup,
mode,
)
if not self._replace_favorites_safely(plan.favorites):
return
if plan.workspace is not None:
self._push_undo_snapshot()
self.search_history = plan.history
if plan.config is not None:
self._apply_config_state(plan.config)
self._apply_workspace_state(plan.workspace)
self._save_history()
message = plan.message
self._update_workspace_counts()
ui.notify(message, type='positive', timeout=4000)
if workspace_normalization is not None:
self._show_workspace_canonicalization(
workspace_normalization,
'JSON ๅค‡ไปฝๅทฅไฝœๅŒบ',
)
if warnings:
print(f'[UI] ๅค‡ไปฝๅฏผๅ…ฅๆ็คบ: {sorted(set(warnings))}', flush=True)
# โ”€โ”€ ๅทฒ้€‰ๆ ‡็ญพๆ  โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
def _build_selection_bar(self):
build_selection_bar(self)
def _render_selected_chips(self):
render_selected_chips(self)
def _workspace_group_for_tag(self, tag: str) -> str:
is_artist = tag in self._workspace_artist_tags
category = 'Artist' if is_artist else 'Other'
groups: set[str] = set()
tagger = DanbooruTagger._instance
if tagger is not None:
metadata = tagger.get_tag_workspace_metadata(tag)
if metadata:
category = str(metadata.get('category') or category)
groups = set(metadata.get('groups') or [])
elif self.result_table is not None:
for row in self.result_table.rows:
if row.get('tag') == tag:
category = str(row.get('category') or category)
break
return classify_workspace_tag(
category=category,
tag_groups=groups,
is_artist=is_artist,
)
def _render_selected_tag_chip(self, tag: str, step: float, *, animate: bool = False):
render_selected_tag_chip(self, tag, step, animate=animate)
def _adjust_weight(self, tag: str, delta: float):
"""่ฐƒๆ•ดๅ•ไธชๆ ‡็ญพๆƒ้‡ใ€‚Anima ๆจกๅผ่Œƒๅ›ด [0.5, 5.0]๏ผŒๅ…ถไป–ๆจกๅผ [0.1, 1.9]ใ€‚"""
current = self.tag_weights.get(tag, 1.0)
new_w = round(current + delta, 1)
if self.prompt_format == 'anima':
min_w, max_w = 0.5, 5.0
else:
min_w, max_w = 0.1, 1.9
if new_w < min_w:
ui.notify(f'ๆƒ้‡่Œƒๅ›ดไธบ {min_w} ~ {max_w}๏ผŒๅทฒๅˆฐ่พพๆœ€ๅฐๅ€ผ', type='warning', timeout=2000)
return
if new_w > max_w:
ui.notify(f'ๆƒ้‡่Œƒๅ›ดไธบ {min_w} ~ {max_w}๏ผŒๅทฒๅˆฐ่พพๆœ€ๅคงๅ€ผ', type='warning', timeout=2000)
return
self._push_undo_snapshot()
self.tag_weights[tag] = new_w
self._save_staged_tags()
self._render_selected_chips()
def _get_cn_name_for_tag(self, tag: str) -> str:
"""ๅฐฝ้‡ไปŽๅฝ“ๅ‰ UI ๆ•ฐๆฎไธญๅ–ๆ ‡็ญพไธญๆ–‡ๅ๏ผŒ็”จไบŽๅทฒ้€‰ๅŒบๅฑ•็คบใ€‚"""
if self.result_table is not None:
for row in self.result_table.rows:
if row.get('tag') == tag:
return str(row.get('cn_name') or '')
for item in self.current_related:
if getattr(item, 'tag', None) == tag:
return str(getattr(item, 'cn_name', '') or '')
for item in self.workspace_state.get('selected', []):
if item.get('tag') == tag:
return str(item.get('cn_name') or '')
try:
tagger = DanbooruTagger._instance
if tagger and tagger.df is not None and tag in tagger._name_to_idx:
idx = tagger._name_to_idx[tag]
return str(tagger.df.iloc[idx].get('cn_name', '') or '')
except Exception:
pass
return ''
def _remove_selected_tag(self, tag: str):
"""ไปŽๅทฒ้€‰ไธญ็งป้™คๆ ‡็ญพ๏ผˆๅŒๆญฅ่กจๆ ผ้€‰ไธญ็Šถๆ€๏ผ‰ใ€‚"""
self._mark_interaction()
current = self._get_selected_tags()
if tag in current:
current.remove(tag)
self.tag_weights.pop(tag, None)
self._set_selected_tags(current)
# โ”€โ”€ ๅค‡้€‰ๅŒบๆŒไน…ๅŒ– โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
_STAGED_LS_KEY = LEGACY_STAGED_STORAGE_KEY
def _set_selection_meta(self, tag: str, origin: str, source: str = ''):
set_selection_meta(self, tag, origin, source)
def _push_undo_snapshot(self):
push_undo_snapshot(self)
def _update_undo_buttons(self):
if self.undo_btn is not None:
self.undo_btn.enable() if self._undo_stack else self.undo_btn.disable()
if self.redo_btn is not None:
self.redo_btn.enable() if self._redo_stack else self.redo_btn.disable()
def _apply_workspace_state(
self,
workspace: dict,
*,
persist: bool = True,
refresh_recommendations: bool = True,
):
apply_workspace_state(
self,
workspace,
_ARTIST_ORIGINS,
persist=persist,
refresh_recommendations=refresh_recommendations,
)
def _undo_workspace(self):
target = pop_undo_workspace(self)
if target is None:
ui.notify('ๆฒกๆœ‰ๅฏๆ’ค้”€็š„ๆ“ไฝœ', type='info', timeout=1500)
return
self._apply_workspace_state(target)
self._update_undo_buttons()
ui.notify('ๅทฒๆ’ค้”€', type='positive', timeout=1500)
def _redo_workspace(self):
target = pop_redo_workspace(self)
if target is None:
ui.notify('ๆฒกๆœ‰ๅฏๆขๅค็š„ๆ“ไฝœ', type='info', timeout=1500)
return
self._apply_workspace_state(target)
self._update_undo_buttons()
ui.notify('ๅทฒๆขๅค', type='positive', timeout=1500)
def _schedule_workspace_persist(self):
schedule_workspace_persist(self, WORKSPACE_SAVE_DEBOUNCE_SECONDS)
def _save_staged_tags(self):
"""ๅฐ†ๅฎžๆ—ถ้€‰ๆ‹ฉๅŒๆญฅๅˆฐ็‰ˆๆœฌๅŒ– WorkspaceState๏ผŒๅนถๅŽปๆŠ–ๅ†™ๅ…ฅ localStorageใ€‚"""
sync_workspace_selection(self, _ARTIST_ORIGINS)
self._schedule_workspace_persist()
def _local_storage_keys(self) -> dict[str, str]:
return storage_keys()
async def _prepare_local_storage_restore(self, names: list[str]) -> dict:
"""Snapshot requested keys in-browser and compact legacy history in memory."""
keys = {name: self._local_storage_keys()[name] for name in names}
return await prepare_restore_snapshot(self.client, self._client_connected, keys)
async def _read_local_storage_value(
self,
name: str,
key: str,
length,
) -> str | None:
return await read_prepared_value(self.client, self._client_connected, name, key, length)
def _clear_local_storage_restore_cache(self):
if not self._client_connected():
return
clear_restore_cache(self.client)
async def _backup_local_storage_key(self, source_key: str, backup_key: str) -> bool:
"""Copy an existing value inside the browser before replacing it."""
if not self._client_connected():
return False
return await backup_key(self.client, source_key, backup_key)
async def _backup_history_before_compaction(self) -> bool:
"""Preserve the original history in-browser before replacing it with v2."""
return await self._backup_local_storage_key(
HISTORY_STORAGE_KEY,
_HISTORY_PRE_COMPACTION_BACKUP_KEY,
)
def _save_history(self):
if not self._storage_write_allowed('history'):
return False
client = self.client
while True:
try:
data = dump_collection(self.search_history, label='history')
client.run_javascript(
f"localStorage.setItem('{HISTORY_STORAGE_KEY}', {_json.dumps(data)});"
)
self._storage_session_dirty.discard('history')
return True
except WorkspaceDataError as exc:
if not self.search_history.get('items'):
print(f'[UI] ๆœ็ดขๅކๅฒไฟๅญ˜ๅคฑ่ดฅ: {exc}', flush=True)
return False
self.search_history['items'].pop()
print('[UI] ๆœ็ดขๅކๅฒ่ถ…่ฟ‡ๆœฌๅœฐๅคงๅฐ้™ๅˆถ๏ผŒๅทฒ็งป้™คๆœ€ๆ—ง่ฎฐๅฝ•ใ€‚', flush=True)
except RuntimeError as exc:
print(f'[UI] ๆœ็ดขๅކๅฒไฟๅญ˜ๅคฑ่ดฅ: {exc}', flush=True)
self._storage_session_dirty.add('history')
return False
def _save_favorites(self):
if not self._storage_write_allowed('favorites'):
return False
client = self.client
try:
data = dump_collection(self.favorites, label='favorites')
client.run_javascript(
f"localStorage.setItem('{FAVORITES_STORAGE_KEY}', {_json.dumps(data)});"
)
self._storage_session_dirty.discard('favorites')
return True
except (WorkspaceDataError, RuntimeError) as exc:
print(f'[UI] ๆ”ถ่—ไฟๅญ˜ๅคฑ่ดฅ: {exc}', flush=True)
self._storage_session_dirty.add('favorites')
return False
def _replace_favorites_safely(self, favorites: dict) -> bool:
if replace_favorites_safely(self, favorites):
return True
ui.notify('ๆ”ถ่—ๆ•ฐๆฎ่ฟ‡ๅคงๆˆ–ๆ— ๆณ•ๅ†™ๅ…ฅ๏ผŒๆ“ไฝœๅทฒๅ–ๆถˆ', type='negative')
return False
async def _restore_staged_tags(self) -> tuple[dict[str, str], set[str], list[str]]:
"""Run one restore attempt and return failures, required writes and warnings."""
return await restore_staged_storage(
self,
_LOCAL_STORAGE_NAMES,
_CONFIG_VERSION,
)
async def _restore_local_storage_with_retries(self):
self._storage_restore_started = True
was_cancelled = False
try:
result = await restore_with_retries(
self,
_LOCAL_STORAGE_RESTORE_RETRY_DELAYS,
)
if result.client_stopped:
return
if result.completed:
flush_storage_session_changes(self)
self._install_workspace_storage_listener()
self._storage_failure_notified = False
if result.warnings:
print(
f'[UI] ๆœฌๅœฐๆ•ฐๆฎๆขๅคๆ็คบ: {sorted(set(result.warnings))}',
flush=True,
)
return
unresolved = sorted(
name for name in _LOCAL_STORAGE_NAMES
if self._storage_states.get(name) != 'ready'
)
if unresolved:
client_id = str(getattr(self.client, 'id', 'unknown'))[:8]
detail = '; '.join(
f'{name}={message}' for name, message in sorted(result.failures.items())
)
print(
f'[UI] localStorage ๆขๅคๆœชๅฎŒๆˆ (client={client_id}, '
f'keys={unresolved}): {detail}๏ผ›ๆœช่ฆ†็›–่ฟ™ไบ›ๆต่งˆๅ™จๆ•ฐๆฎใ€‚',
flush=True,
)
if self._client_connected() and not self._storage_failure_notified:
self._storage_failure_notified = True
try:
with self.client:
ui.notify(
'ๆœฌๅœฐๅทฅไฝœๅŒบๆ•ฐๆฎๆš‚ๆœชๆขๅค๏ผŒๅทฒๅœๆญข่ฆ†็›–ๆ—งๆ•ฐๆฎ๏ผ›่ฟžๆŽฅๆขๅคๅŽไผš่‡ชๅŠจ้‡่ฏ•ใ€‚',
type='warning',
timeout=5000,
)
except RuntimeError:
# The client may disconnect after the connection check.
pass
except asyncio.CancelledError:
was_cancelled = True
finally:
if finish_storage_restore_task(
self,
asyncio.current_task(),
was_cancelled,
):
self._start_storage_restore_task()
def _start_storage_restore_task(self):
return start_storage_restore_task(
self,
self._restore_local_storage_with_retries,
)
def _pause_storage_restore(self):
pause_storage_restore(self)
def _install_workspace_storage_listener(self):
"""ๅ…ถไป–ๆ ‡็ญพ้กตไฟฎๆ”นๅทฅไฝœๅŒบๆ—ถๆ็คบๅˆทๆ–ฐ๏ผŒ้ฟๅ…้™้ป˜่ฆ†็›–ใ€‚"""
if self._workspace_storage_listener_installed:
return
self._workspace_storage_listener_installed = True
try:
ui.run_javascript(storage_listener_script())
except RuntimeError:
pass
def _clear_all_staged(self):
"""ๆธ…็ฉบๆ‰€ๆœ‰ๅทฒ้€‰ๆ ‡็ญพใ€‚"""
self._mark_interaction()
pending_items = [
item for item in self.workspace_state.get('dismissed', [])
if isinstance(item, dict) and item.get('kind') == 'prompt_import_pending'
]
if self._get_selected_tags() or pending_items:
self._push_undo_snapshot()
if pending_items:
self.workspace_state['dismissed'] = [
item for item in self.workspace_state.get('dismissed', [])
if not (
isinstance(item, dict)
and item.get('kind') == 'prompt_import_pending'
)
]
self.chip_extra_selected.clear()
self._selected_order.clear()
self.tag_weights.clear()
self._pending_selection_meta.clear()
self._workspace_artist_tags.clear()
if self.result_table is not None:
self.result_table.selected = []
self._artist_rec_checkboxes.clear()
self._current_artist_rec_tags.clear()
self._artist_result_tags.clear()
self._last_recommendation_seed_tags = []
self._render_selected_chips()
self._render_prompt_pending()
self._render_concept_coverage()
if self.selection_count_label is not None:
self.selection_count_label.text = '0'
show_nsfw_val = self.input_nsfw.value
self._refresh_related([], show_nsfw_val)
self._render_artist_rec([], {})
# ๆธ…็ฉบ Group ๅŒ็ฑปๆ‰ฉๅฑ•
if self.group_expansion_container is not None:
self.group_expansion_container.clear()
with self.group_expansion_container:
ui.label('่ฏทๅ…ˆๆœ็ดขๅนถๅ‹พ้€‰ๆ ‡็ญพโ€ฆ').classes('text-sm text-gray-400 italic p-4')
self._save_staged_tags()
ui.notify('ๅทฒๆธ…็ฉบๆ‰€ๆœ‰ๅทฒ้€‰ๆ ‡็ญพ', type='warning')
# โ”€โ”€ ไธคๆ ็ป“ๆžœ๏ผˆCSS ๅผบๅˆถๅนถๆŽ’๏ผ‰โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
def _build_results_columns(self):
build_results_columns(self)
def _set_related_page(self, page: int):
"""ๅˆ‡ๆขๅ…ณ่”ๆŽจ่้กต๏ผŒๅชๆž„ๅปบๅฝ“ๅ‰้กต็š„ๅฏ่ง่กŒใ€‚"""
set_paginated_recommendation_page(
self,
page,
state_prefix='_related',
render_page=self._render_related_page,
motion_element_id='danbooru-related-recommendations',
)
def _lookup_tag_wiki(self, tag: str) -> str:
"""ๅœจๆŽงๅˆถๅ™จๅฑ‚่ฏปๅ–ๆ ‡็ญพๅ…ƒๆ•ฐๆฎ๏ผŒ่ง†ๅ›พๅฑ‚ไธ็›ดๆŽฅ่ฎฟ้—ฎๆฃ€็ดขๅผ•ๆ“Žใ€‚"""
try:
tagger = DanbooruTagger._instance
if tagger and tagger.df is not None and tag in tagger._name_to_idx:
index = tagger._name_to_idx[tag]
return str(tagger.df.iloc[index].get('wiki', ''))
except Exception:
pass
return ''
def _render_related_page(self):
render_related_page(self)
def _render_related_list(self, related: list, show_nsfw: bool):
"""ไฟๅญ˜ๅ…ณ่”ๆŽจ่ๅฟซ็…ง๏ผŒๅนถไป…ๆธฒๆŸ“ๅฝ“ๅ‰้กตใ€‚"""
render_related_list(self, related, show_nsfw)
# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
# ไบคไบ’้€ป่พ‘
# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
async def _hide_banner_when_ready(self):
while not DanbooruTagger.is_ready():
if not self._client_alive():
return
await asyncio.sleep(1)
if not self._client_alive():
return
if self.init_banner:
self.init_banner.set_visibility(False)
self._update_service_status()
self._update_footer_text()
# ๅทฅไฝœๅŒบๅฏ่ƒฝๆ—ฉไบŽๅผ•ๆ“Žๆขๅค๏ผ›ๅผ•ๆ“Žๅฐฑ็ปชๅŽ็”จ็œŸๅฎž Category / Tag Group
# ๅ…ƒๆ•ฐๆฎ้‡ๆ–ฐๅˆ†็ป„๏ผŒ้ฟๅ…้‡ๅฏๆœŸ้—ด้ฆ–ๆฌกๆธฒๆŸ“็š„ๆ ‡็ญพๅ…จ้ƒจๅœ็•™ๅœจโ€œๅ…ถไป–โ€ใ€‚
self._render_selected_chips()
def _client_alive(self) -> bool:
try:
client = self.client
if client is None and self.search_btn is not None:
client = self.search_btn.client
return client is not None and not bool(getattr(client, '_deleted', False))
except (AttributeError, RuntimeError):
return False
def _dispose(self):
"""Stop page-owned background resources."""
tasks = (
self._service_status_task,
self._storage_restore_task,
self._workspace_save_task,
)
self._service_status_task = None
self._storage_restore_task = None
self._workspace_save_task = None
for task in tasks:
if task is not None and not task.done():
task.cancel()
# โ”€โ”€ ๅˆ†่ฏ็ญ›้€‰ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
def _filter_by_source(self, keyword: str):
filter_by_source(self, keyword)
def _render_concept_coverage(self):
render_concept_coverage(self)
def _schedule_concept_coverage_render(self):
"""่ฎฉ้€‰ๆ‹ฉๅŒบๅ…ˆๅˆทๆ–ฐ๏ผŒๅ†ๅœจไธ‹ไธ€ไธชไบ‹ไปถๅพช็Žฏๅ‘จๆœŸ้‡ๅปบๆŸฅ่ฏข็†่งฃใ€‚"""
if self._coverage_render_task and not self._coverage_render_task.done():
self._coverage_render_task.cancel()
async def _render():
await asyncio.sleep(0)
if self._client_alive():
self._render_concept_coverage()
self._coverage_render_task = asyncio.ensure_future(_render())
async def _search_uncovered_segment(self, segment: str):
segment = str(segment or '').strip()
if not segment:
return
self.search_input.set_value(segment)
await self.perform_search()
# โ”€โ”€ ๆœ็ดข โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
async def perform_search(self):
query = self.search_input.value.strip()
if not query:
return
# ๆœ็ดขๅ‰ๆ ก้ชŒๆ•ฐๅ€ผๅ‚ๆ•ฐ
_err_fields = []
if self.input_top_k and (self.input_top_k.value is None or str(self.input_top_k.value).strip() == ''):
_err_fields.append('Top K')
if self.input_limit and (self.input_limit.value is None or str(self.input_limit.value).strip() == ''):
_err_fields.append('่ฟ”ๅ›žๆ•ฐ้‡')
if self.input_weight and (self.input_weight.value is None or str(self.input_weight.value).strip() == ''):
_err_fields.append('็ƒญๅบฆๆƒ้‡')
if _err_fields:
ui.notify(f'่ฏทๅกซๅ†™๏ผš{"ใ€".join(_err_fields)}', type='negative', timeout=3000)
return
# ๆœ็ดขๅ‰ไฟๅญ˜้…็ฝฎ
self._save_config()
self.current_query_str = query
self.search_btn.disable()
self.spinner.classes(remove='hidden')
ui.notify('ๆญฃๅœจๆœ็ดข...', type='info')
if self.bad_case_btn is not None:
self.bad_case_btn.disable()
target_layers_list = [k for k, v in self.selected_layers.items() if v]
target_cats_list = [k for k, v in self.selected_cats.items() if v]
if not target_layers_list:
ui.notify('่ฏท่‡ณๅฐ‘้€‰ๆ‹ฉไธ€ไธชๅŒน้…ๅฑ‚๏ผ', type='warning')
self.search_btn.enable()
self.spinner.classes(add='hidden')
return
search_started_at = time.perf_counter()
search_invoked_at = time.monotonic()
normalized_telemetry_query = ' '.join(query.split()).casefold()
await telemetry.increment("ui_search")
if (
normalized_telemetry_query == self._telemetry_last_query
and search_invoked_at - self._telemetry_last_search_at <= 60
):
await telemetry.increment("ui_repeat_search_60s")
self._telemetry_last_query = normalized_telemetry_query
self._telemetry_last_search_at = search_invoked_at
try:
tagger = await DanbooruTagger.get_instance()
show_nsfw_val = self.input_nsfw.value
request = SearchRequest(
query=query,
top_k=int(self.input_top_k.value),
limit=int(self.input_limit.value),
popularity_weight=float(self.input_weight.value),
show_nsfw=show_nsfw_val,
use_segmentation=self.input_segment.value if self.input_segment else True,
target_layers=target_layers_list,
target_categories=target_cats_list,
group_mode=self.input_group_mode.value if self.input_group_mode else 'off',
max_per_group=int(self.input_max_per_group.value) if self.input_max_per_group else 2,
)
response = await tagger.search_async(request)
await telemetry.record_timing(
"ui_search_latency",
(time.perf_counter() - search_started_at) * 1000,
)
if not response.results:
await telemetry.increment("ui_zero_result")
self._telemetry_search_started_at = search_started_at
self._telemetry_selection_recorded = False
self._telemetry_copy_timing_recorded = False
# ๅŽๅฐ่ฎกๆ•ฐ
async def silent_counter_update():
try:
await counter.increment()
if response.keywords:
await counter.add_keywords(response.keywords)
self._update_footer_text()
except Exception as e:
print(f"[UI] ๅŽๅฐ้™้ป˜ๆ›ดๆ–ฐ่ฎกๆ•ฐๅคฑ่ดฅ: {e}", flush=True)
asyncio.create_task(silent_counter_update())
if not self._client_alive():
return
table_data = [result_to_row(r, show_nsfw_val) for r in response.results]
self._artist_result_tags = {row['tag'] for row in table_data if row.get('layer') == 'artist'}
self.full_table_data = table_data
self.full_tags_str = response.tags_all
self.full_tags_str_sfw = response.tags_sfw
self.current_segments = list(response.segments) if response.segments else []
self.current_keywords = list(response.keywords) if response.keywords else []
self.current_cached_queries = set(response.cached_queries or [])
self.results_section.set_visibility(True)
_saved_rpp = self._get_rows_per_page()
self.result_table.rows = apply_nsfw_filter(table_data, show_nsfw_val)
self._set_rows_per_page(_saved_rpp)
all_selected = self._get_selected_tags()
self._selected_order = list(all_selected)
self.chip_extra_selected.clear()
self.chip_extra_selected.update(all_selected)
self.result_table.selected = []
self._render_selected_chips()
await self._update_selection_display(None)
self._refresh_related([], show_nsfw_val)
self._last_recommendation_seed_tags = []
# ๆŸฅ่ฏข็†่งฃ๏ผšๅˆ†่ฏๆฅๆบ็ญ›้€‰ไธŽๆฆ‚ๅฟต่ฆ†็›–ๅ…ฑ็”จๅŒไธ€็ป„ chips
self.current_filter_keyword = 'ALL'
self._render_concept_coverage()
self._record_search_history(query)
self._replay_motion('danbooru-results-section', 'motion-results-enter')
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]:
return selected_tags(self)
def _get_recommendation_seed_tags(self, selected_tags: list[str]) -> list[str]:
return recommendation_seed_tags(self, selected_tags)
def _refresh_recommendations_if_seed_changed(self, selected_tags: list[str], show_nsfw: bool):
seed_tags = self._get_recommendation_seed_tags(selected_tags)
if seed_tags == self._last_recommendation_seed_tags:
return
self._last_recommendation_seed_tags = list(seed_tags)
self._refresh_related_from_selection(seed_tags, show_nsfw)
self._refresh_group_from_selection(seed_tags, show_nsfw)
self._refresh_artist_from_selection(seed_tags, show_nsfw)
def _set_selected_tags(
self,
tags: list[str],
skip_refresh: bool = False,
record_undo: bool = True,
):
tags = list(dict.fromkeys(tags))
previous_tags = [item['tag'] for item in self.workspace_state.get('selected', [])]
if record_undo and tags != previous_tags:
self._push_undo_snapshot()
self._selected_order = list(tags)
tag_set = set(tags)
table_tag_set = {row['tag'] for row in self.result_table.rows} if self.result_table else set()
self.chip_extra_selected.clear()
self.chip_extra_selected.update(t for t in tag_set if t not in table_tag_set)
# clean up weights for deselected tags
for t in list(self.tag_weights):
if t not in tag_set:
del self.tag_weights[t]
self._pending_selection_meta.pop(t, None)
if self.result_table is not None:
self.result_table.selected = [row for row in self.result_table.rows if row.get('tag') in tag_set]
# ๅŒๆญฅๆŽจ่็”ปๅธˆ checkbox
for t, cb in self._artist_rec_checkboxes.items():
cb.set_value(t in tag_set)
all_tags = self._get_selected_tags()
self._selected_order = list(all_tags)
self._record_first_selection_if_needed(previous_tags, all_tags)
if self.selection_count_label is not None:
self.selection_count_label.text = str(len(all_tags))
self._save_staged_tags()
self._render_selected_chips()
self._schedule_concept_coverage_render()
# ๆ˜พๅผๅˆทๆ–ฐๅ…ณ่”ๆŽจ่ๅ’Œ Group ๅŒบๅŸŸ๏ผˆไธไพ่ต– table.on('selection') ไบ‹ไปถ๏ผŒ
# ๅ› ไธบๅœจ chip ็‚นๅ‡ปๅ›ž่ฐƒไธŠไธ‹ๆ–‡ไธญ่ฏฅไบ‹ไปถๅฏ่ƒฝไธๅฏ้ ๏ผ‰ใ€‚
# ไปŽๅ…ณ่”ๆŽจ่/ๅŒ็ฑปๆ ‡็ญพๅ‹พ้€‰ๆ—ถ่ทณ่ฟ‡๏ผŒ็”ฑๅ„่‡ชๅŠจๆ€ๅˆทๆ–ฐๆˆ–ๆ‰‹ๅŠจๆŒ‰้’ฎ่งฆๅ‘ใ€‚
if not skip_refresh:
show_nsfw_val = self.input_nsfw.value
self._refresh_recommendations_if_seed_changed(all_tags, show_nsfw_val)
if not all_tags:
self.chip_extra_selected.clear()
async def _update_selection_display(self, _e):
if self.result_table is None:
return
self._mark_interaction()
all_tags = self._get_selected_tags()
previous_tags = [item['tag'] for item in self.workspace_state.get('selected', [])]
self._record_first_selection_if_needed(previous_tags, all_tags)
if all_tags != previous_tags:
self._push_undo_snapshot()
existing = set(previous_tags)
for row in self.result_table.rows:
tag = row.get('tag')
if not tag or tag in existing or tag not in all_tags:
continue
origin = 'artist_search' if row.get('layer') == 'artist' else 'semantic_search'
self._set_selection_meta(tag, origin, str(row.get('source') or self.current_query_str))
self._selected_order = list(all_tags)
# clean up weights for deselected tags
tag_set = set(all_tags)
for t in list(self.tag_weights):
if t not in tag_set:
del self.tag_weights[t]
self._pending_selection_meta.pop(t, None)
# init weight for newly selected tags
for t in all_tags:
self.tag_weights.setdefault(t, 1.0)
if self.selection_count_label is not None:
self.selection_count_label.text = str(len(all_tags))
self._render_selected_chips()
# ๅ…ˆๆŠŠ้€‰ๆ‹ฉๅŒบ็š„่ฝป้‡ๅ˜ๅŒ–ไบค็ป™ WebSocket ๅˆทๅ‡บ๏ผŒๅ†ๅค„็†ๆŽจ่ๅ’ŒๆŸฅ่ฏข็†่งฃใ€‚
self._save_staged_tags()
await asyncio.sleep(0)
# ๅŒๆญฅๅฝ“ๅ‰็”ปๅธˆ้กต็š„ checkbox๏ผˆๅฝ“ๅ‰้กตๆœ€ๅคš 8 ไธช๏ผ‰ใ€‚
for t, cb in self._artist_rec_checkboxes.items():
cb.set_value(t in tag_set)
show_nsfw_val = self.input_nsfw.value
self._refresh_recommendations_if_seed_changed(all_tags, show_nsfw_val)
if not all_tags:
self.chip_extra_selected.clear()
self._render_concept_coverage()
def _on_related_checkbox_change(self, tag: str, checked: bool):
self._mark_interaction()
current = self._get_selected_tags()
if checked:
if tag not in current:
source = ''
for item in self.current_related:
if getattr(item, 'tag', None) == tag:
source = ', '.join(getattr(item, 'sources', []) or [])
break
self._set_selection_meta(tag, 'related_recommendation', source)
current.append(tag)
self.tag_weights.setdefault(tag, 1.0)
self._set_selected_tags(current, skip_refresh=True)
ui.notify(f'ๅทฒๆทปๅŠ  {tag}', type='positive', timeout=1500)
else:
if tag in current:
current.remove(tag)
self.tag_weights.pop(tag, None)
self._set_selected_tags(current, skip_refresh=True)
ui.notify(f'ๅทฒ็งป้™ค {tag}', type='warning', timeout=1500)
# ๅˆทๆ–ฐๆŽจ่็”ปๅธˆ
show_nsfw_val = self.input_nsfw.value
self._refresh_artist_from_selection(current, show_nsfw_val)
def _on_group_checkbox_change(self, tag: str, checked: bool):
"""ๅŒ็ฑปๆ ‡็ญพๅค้€‰ๆก†ๅ˜ๅŒ–ๅ›ž่ฐƒใ€‚"""
self._mark_interaction()
current = self._get_selected_tags()
if checked:
if tag not in current:
self._set_selection_meta(
tag,
'tag_group',
self._group_candidate_sources.get(tag, ''),
)
current.append(tag)
self.tag_weights.setdefault(tag, 1.0)
self._set_selected_tags(current, skip_refresh=True)
ui.notify(f'ๅทฒๆทปๅŠ  {tag}', type='positive', timeout=1500)
else:
if tag in current:
current.remove(tag)
self.tag_weights.pop(tag, None)
self._set_selected_tags(current, skip_refresh=True)
ui.notify(f'ๅทฒ็งป้™ค {tag}', type='warning', timeout=1500)
# ๅณๅˆปๅˆทๆ–ฐๅ…ณ่”ๆŽจ่ + ็”ปๅธˆๆŽจ่
show_nsfw_val = self.input_nsfw.value
self._refresh_related_from_selection(current, show_nsfw_val)
self._refresh_artist_from_selection(current, show_nsfw_val)
def _on_artist_rec_checkbox_change(self, tag: str, checked: bool):
"""ๆŽจ่็”ปๅธˆๅค้€‰ๆก†ๅ˜ๅŒ–ๅ›ž่ฐƒใ€‚"""
self._mark_interaction()
current = self._get_selected_tags()
if checked:
if tag not in current:
self._set_selection_meta(
tag,
'artist_recommendation',
self._artist_rec_sources.get(tag, ''),
)
current.append(tag)
self.tag_weights.setdefault(tag, 1.0)
self._set_selected_tags(current, skip_refresh=True)
ui.notify(f'ๅทฒๆทปๅŠ ็”ปๅธˆ {tag}', type='positive', timeout=1500)
else:
if tag in current:
current.remove(tag)
self.tag_weights.pop(tag, None)
self._set_selected_tags(current, skip_refresh=True)
ui.notify(f'ๅทฒ็งป้™ค็”ปๅธˆ {tag}', type='warning', timeout=1500)
def _manual_refresh_related(self):
"""ๆ‰‹ๅŠจ่งฆๅ‘ๅ…ณ่”ๆŽจ่ๅˆ—่กจ็š„ๅˆทๆ–ฐ"""
self._mark_interaction()
show_nsfw_val = self.input_nsfw.value
all_tags = self._get_selected_tags()
if all_tags:
self._refresh_related_from_selection(all_tags, show_nsfw_val)
self._refresh_artist_from_selection(all_tags, show_nsfw_val)
ui.notify('ๅทฒ่งฆๅ‘ๅ…ณ่”ๆŽจ่ๆ›ดๆ–ฐ', type='info', timeout=1500)
else:
self.chip_extra_selected.clear()
self._refresh_related([], show_nsfw_val)
ui.notify('ๅทฒๆธ…็ฉบๅ…ณ่”ๆŽจ่', type='info', timeout=1500)
def _manual_refresh_group(self):
"""ๆ‰‹ๅŠจ่งฆๅ‘ๅŒ็ฑปๆ‰ฉๅฑ•ๅŒบๅŸŸ็š„ๅˆทๆ–ฐ"""
self._mark_interaction()
show_nsfw_val = self.input_nsfw.value
all_tags = self._get_selected_tags()
if all_tags:
self._refresh_group_from_selection(all_tags, show_nsfw_val)
ui.notify('ๅทฒ่งฆๅ‘ๅŒ็ฑปๆ ‡็ญพๆ›ดๆ–ฐ', type='info', timeout=1500)
else:
if self.group_expansion_container is not None:
self.group_expansion_container.clear()
with self.group_expansion_container:
ui.label('่ฏทๅ…ˆๆœ็ดขๅนถๅ‹พ้€‰ๆ ‡็ญพโ€ฆ').classes('text-sm text-gray-400 italic p-4')
ui.notify('ๆš‚ๆœช้€‰ไธญๆ ‡็ญพ', type='info', timeout=1500)
# โ”€โ”€ ๅ…ณ่”ๆŽจ่ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
def _refresh_related(self, related: list, show_nsfw: bool):
selected_now = set(self._get_selected_tags())
merged = merge_related_results(self.current_related, related, selected_now)
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):
"""่ฏทๆฑ‚ๅˆทๆ–ฐๅ…ณ่”ๆŽจ่๏ผ›ๅฎž้™…่ฎก็ฎ—็”ฑ็ปŸไธ€็š„ latest-wins ่ฐƒๅบฆๅ™จๆ‰ง่กŒใ€‚"""
self._queue_recommendation_refresh(
selected_tags, show_nsfw, {'related'},
)
def _refresh_group_from_selection(self, selected_tags: list[str], show_nsfw: bool):
"""่ฏทๆฑ‚ๅˆทๆ–ฐๅŒ็ฑปๆ‰ฉๅฑ•๏ผ›ๅฎž้™…่ฎก็ฎ—็”ฑ็ปŸไธ€็š„ latest-wins ่ฐƒๅบฆๅ™จๆ‰ง่กŒใ€‚"""
self._queue_recommendation_refresh(
selected_tags, show_nsfw, {'group'},
)
def _refresh_artist_from_selection(self, selected_tags: list[str], show_nsfw: bool = True):
"""่ฏทๆฑ‚ๅˆทๆ–ฐ็”ปๅธˆๆŽจ่๏ผ›ๅฎž้™…่ฎก็ฎ—็”ฑ็ปŸไธ€็š„ latest-wins ่ฐƒๅบฆๅ™จๆ‰ง่กŒใ€‚"""
self._queue_recommendation_refresh(
selected_tags, show_nsfw, {'artist'},
)
def _queue_recommendation_refresh(
self,
selected_tags: list[str],
show_nsfw: bool,
scopes: set[str],
):
"""ๅˆๅนถๅŒไธ€้€‰ๆ‹ฉๅฟซ็…ง็š„ๅˆทๆ–ฐ่Œƒๅ›ด๏ผŒๅนถ็กฎไฟ่ฟ่กŒไธญ็š„็บฟ็จ‹ไธไผš่ขซๅ–ๆถˆใ€‚"""
if queue_latest_recommendation_request(
self,
selected_tags,
show_nsfw,
scopes,
):
self._recommendation_task = asyncio.ensure_future(
self._recommendation_worker()
)
async def _recommendation_worker(self):
"""่ฟž็ปญๆถˆ่ดนๆœ€ๆ–ฐ้€‰ๆ‹ฉๅฟซ็…ง๏ผ›่ฟ‡ๆœŸ็ป“ๆžœๅช่ฎก็ฎ—ไธๆธฒๆŸ“ใ€‚"""
try:
async def fetch(request: dict) -> dict:
selected_tags = request['selected_tags']
if not selected_tags:
return {
'related': [],
'groups': [],
'artists': [],
'artist_top_tags': {},
}
tagger = await DanbooruTagger.get_instance()
return await tagger.get_selection_recommendations_async(
selected_tags,
request['show_nsfw'],
request['scopes'],
related_limit=50,
artist_limit=ARTIST_REC_LIMIT,
artist_min_cooc=3,
)
async def apply(request: dict, result: dict) -> None:
selected_tags = request['selected_tags']
show_nsfw = request['show_nsfw']
scopes = request['scopes']
if 'related' in scopes:
self._refresh_related(result['related'], show_nsfw)
if 'group' in scopes:
if selected_tags:
await self._capture_group_scroll_positions()
self._render_group_expansion(
result['groups'], selected_tags, show_nsfw,
)
elif 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'
)
if 'artist' in scopes:
self._render_artist_rec(
result['artists'],
result['artist_top_tags'],
show_nsfw,
)
consume = consume_latest_recommendation_requests(
self,
debounce_seconds=RECOMMENDATION_DEBOUNCE_SECONDS,
fetch=fetch,
apply=apply,
client_alive=self._client_alive,
report_error=lambda exc: print(f'[UI] ๆŽจ่ๅˆทๆ–ฐๅคฑ่ดฅ: {exc}', flush=True),
)
await consume
finally:
self._recommendation_task = None
def _set_artist_rec_page(self, page: int):
"""ๅˆ‡ๆขๆŽจ่็”ปๅธˆ้กต๏ผŒๅชๆž„ๅปบๅฝ“ๅ‰้กต็š„ๅฏ่ง่กŒใ€‚"""
set_paginated_recommendation_page(
self,
page,
state_prefix='_artist_rec',
render_page=self._render_artist_rec_page,
motion_element_id='danbooru-artist-recommendations',
)
def _render_artist_rec_page(self):
render_artist_page(self)
def _render_artist_rec(self, artist_results, top_tags=None, show_nsfw: bool = True):
"""ไฟๅญ˜ๆŽจ่ๅฟซ็…ง๏ผŒๅนถไป…ๆธฒๆŸ“ๅฝ“ๅ‰็”ปๅธˆ้กตใ€‚"""
render_artist_recommendations(self, artist_results, top_tags, show_nsfw)
def _render_group_expansion(self, group_data: list, selected_tags: list[str], show_nsfw: bool):
render_group_expansion(self, group_data, selected_tags, show_nsfw)
def _on_group_expansion_change(self, group_name: str, event):
value = getattr(event, 'args', None)
if isinstance(value, dict):
value = value.get('value', value.get('modelValue'))
if bool(value):
self._group_expanded_names.add(group_name)
else:
self._group_expanded_names.discard(group_name)
async def _capture_group_scroll_positions(self, *, anchor_bottom: bool = False):
client = self.client
if client is None or getattr(client, '_deleted', False):
return
anchor_flag = 'true' if anchor_bottom else 'false'
try:
raw = await client.run_javascript(
f"""
const anchorBottom = {anchor_flag};
const groupEntries = Array.from(document.querySelectorAll('[data-danbooru-group-scroll="1"]'))
.flatMap(el => {{
const top = Math.round(el.scrollTop || 0);
const entries = [[el.id, top]];
if (anchorBottom) {{
entries.push([`${{el.id}}__bottom__`, Math.round(el.scrollHeight - top)]);
}}
return entries;
}});
JSON.stringify({{
__window__: Math.round(
window.scrollY ||
document.documentElement.scrollTop ||
document.body.scrollTop ||
0
),
...Object.fromEntries(groupEntries),
}});
""",
timeout=1.0,
)
except Exception:
return
try:
data = _json.loads(raw) if isinstance(raw, str) else raw
if isinstance(data, dict):
self._group_scroll_positions = {
str(k): int(v) for k, v in data.items()
if str(k) and int(v) >= 0
}
except Exception:
pass
def _restore_group_scroll_positions(self):
if not self._group_scroll_positions:
return
client = self.client
if client is None or getattr(client, '_deleted', False):
return
client.run_javascript(_scroll_state_restore_script(self._group_scroll_positions), timeout=1.0)
async def _load_more_group_tags(
self,
group_name: str,
total: int,
group_data: list,
selected_tags: list[str],
show_nsfw: bool,
):
await self._capture_group_scroll_positions(anchor_bottom=True)
current = self._group_render_limits.get(group_name, GROUP_RENDER_TAG_LIMIT)
self._group_render_limits[group_name] = _next_group_render_limit(
current,
total,
GROUP_RENDER_TAG_LIMIT,
)
self._group_expanded_names.add(group_name)
self._render_group_expansion(group_data, selected_tags, show_nsfw)
# โ”€โ”€ ่กจๆ ผๅˆ—ๅŠจๆ€ๆ›ดๆ–ฐ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
def _update_table_columns(self, e=None):
update_table_columns(self, e)
# โ”€โ”€ ๆœ็ดขๆจกๅผ / ๅ‚ๆ•ฐ่”ๅŠจ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
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 ๅˆ‡ๆข โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
async 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']
await self._update_selection_display(None)
# โ”€โ”€ ๅคๅˆถ / ๅ้ฆˆ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
def _toggle_prompt_format(self):
if self.prompt_format == 'sdxl':
self._apply_prompt_format('nai')
elif self.prompt_format == 'nai':
self._apply_prompt_format('anima')
else:
self._apply_prompt_format('sdxl')
self._save_config()
self._save_staged_tags()
self._render_selected_chips()
def copy_selection(self):
self._mark_interaction()
tags = self._get_selected_tags()
parts = []
artist_tags = (
set(self._current_artist_rec_tags)
| set(self._artist_result_tags)
| set(self._workspace_artist_tags)
)
for t in tags:
w = self.tag_weights.get(t, 1.0)
if self.prompt_format == 'anima' and t in artist_tags:
parts.append(_format_tag_with_weight(f'@{t}', w, self.prompt_format))
else:
parts.append(_format_tag_with_weight(t, w, self.prompt_format))
prompt = ', '.join(parts)
ui.clipboard.write(prompt)
fmt_label = {'sdxl': 'SDXL', 'nai': 'NAI', 'anima': 'Anima'}.get(self.prompt_format, 'SDXL')
ui.notify(f'ๅทฒๅคๅˆถ้€‰ไธญๆ ‡็ญพ๏ผˆ{fmt_label} ๆ ผๅผ๏ผ‰!', type='positive')
if prompt:
self._record_ui_copy("ui_copy_selected")
def _copy_all_tags(self):
self._mark_interaction()
show_nsfw_val = self.input_nsfw.value
tags_str = self.full_tags_str if show_nsfw_val else self.full_tags_str_sfw
if tags_str:
tags_str = tags_str.replace('(', '\\(').replace(')', '\\)')
ui.clipboard.write(tags_str)
ui.notify('ๅทฒๅคๅˆถๅ…จ้ƒจๆ ‡็ญพ!', type='positive')
self._record_ui_copy("ui_copy_all")
else:
ui.notify('ๆš‚ๆ— ๆ ‡็ญพๅฏๅคๅˆถ', type='warning')
def _feedback_settings(self) -> dict:
return {
'top_k': int(self.input_top_k.value) if self.input_top_k else None,
'limit': int(self.input_limit.value) if self.input_limit else None,
'popularity_weight': float(self.input_weight.value) if self.input_weight else None,
'show_nsfw': self.input_nsfw.value if self.input_nsfw else None,
'use_segmentation': self.input_segment.value if self.input_segment else None,
'search_mode': self.input_search_mode.value if self.input_search_mode else '่‡ชๅฎšไน‰',
'target_layers': [key for key, enabled in self.selected_layers.items() if enabled],
'target_categories': [key for key, enabled in self.selected_cats.items() if enabled],
'group_mode': self.input_group_mode.value if self.input_group_mode else 'off',
'max_per_group': int(self.input_max_per_group.value) if self.input_max_per_group else 2,
}
def report_bad_case(self):
from platform_utils import PLATFORM
query = self.current_query_str.strip()
if len(query) <= 1:
ui.notify('ๆœ็ดข่ฏๅคช็Ÿญ๏ผŒๆ— ๆณ•ๆไบคๅ้ฆˆใ€‚', type='warning', timeout=2000)
return
open_search_feedback_dialog(
self,
query=query,
privacy_text=UI_TEXT['dialogs']['search_feedback_privacy'],
)
async def _submit_search_feedback(self, query: str, detail: str) -> None:
from platform_utils import PLATFORM
await telemetry.add_feedback(
feedback_type='search_bad_case',
query=query,
search_settings=self._feedback_settings(),
app_version=_get_git_commit(),
platform=PLATFORM,
details=detail,
)
def report_translation_error(self, e):
raw_args = getattr(e, 'args', None)
# print(f'[UI] translation_feedback event received: {raw_args!r}', flush=True)
row = raw_args
if isinstance(row, list) and row:
row = row[0]
if not isinstance(row, dict):
print(f'[UI] translation_feedback invalid payload: {raw_args!r}', flush=True)
ui.notify('ๆ— ๆณ•่ฏปๅ–ๅฝ“ๅ‰่ฏๆกไฟกๆฏใ€‚', type='warning', timeout=2000)
return
tag = str(row.get('tag') or '').strip()
current_cn_name = str(row.get('cn_name') or '').strip()
if not tag:
ui.notify('ๆ— ๆณ•่ฏปๅ–ๅฝ“ๅ‰่ฏๆกใ€‚', type='warning', timeout=2000)
return
open_translation_feedback_dialog(
self,
row=row,
privacy_text=UI_TEXT['dialogs']['translation_feedback_privacy'],
)
async def _submit_translation_feedback(
self,
row: dict,
suggested: str,
detail: str,
) -> None:
from platform_utils import PLATFORM
await telemetry.add_feedback(
feedback_type='translation_error',
query=self.current_query_str.strip(),
search_settings=self._feedback_settings(),
app_version=_get_git_commit(),
platform=PLATFORM,
details=detail,
tag=str(row.get('tag') or '').strip(),
current_cn_name=str(row.get('cn_name') or '').strip(),
suggested_cn_name=suggested,
category=str(row.get('category') or ''),
)