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