"""
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'版本号: {commit}'
f'
'
f'使用 API 服务'
f' | 使用 MCP 服务'
)
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 ''),
)