SAkizuki commited on
Commit
6828c58
·
verified ·
1 Parent(s): 970bcba

Auto-sync from GitHub Actions

Browse files
Files changed (3) hide show
  1. core/engine.py +192 -5
  2. core/models.py +11 -0
  3. ui_nicegui.py +341 -102
core/engine.py CHANGED
@@ -187,8 +187,9 @@ class DanbooruTagger:
187
  model_path: Optional[str] = None,
188
  csv_file: str = 'origin_database/tags_enhanced.csv',
189
  cache_dir: str = 'tags_embedding',
190
- cooc_file: str = 'origin_database/cooccurrence_clean.csv',
191
- group_file: str = 'origin_database/tag_groups.json',
 
192
  ):
193
  # 模型路径:优先使用显式传入,否则交由 platform_utils 解析
194
  self.model_path = model_path or resolve_model_path()
@@ -198,6 +199,7 @@ class DanbooruTagger:
198
  self.paths = _CachePaths(cache_dir)
199
  self.cooc_file = cooc_file
200
  self.group_file = group_file
 
201
 
202
  self.model: Optional[SentenceTransformer] = None
203
  self.df: Optional[pd.DataFrame] = None
@@ -211,6 +213,7 @@ class DanbooruTagger:
211
  self._tag_to_groups: dict[str, set[str]] = {}
212
  self._group_to_tags_idx: dict[str, np.ndarray] = {}
213
  self._group_cn_names: dict[str, str] = {}
 
214
  self.is_loaded: bool = False
215
 
216
  # 预提取的列数组,避免热点路径上反复执行 df.iloc[idx]
@@ -265,6 +268,7 @@ class DanbooruTagger:
265
 
266
  self._setup_jieba_from_memory()
267
  self._load_cooc()
 
268
  self._name_to_idx = {n: i for i, n in enumerate(self.df['name'])}
269
  self._tag_names_set: set[str] = set(self._name_to_idx.keys())
270
  self._rebuild_arrays_from_df()
@@ -324,9 +328,10 @@ class DanbooruTagger:
324
  print(f'[Engine] 拉取 {filename} 失败(非致命): {e}')
325
  return filename # 回退到原始路径,让后续逻辑决定是否重建
326
 
327
- self.csv_path = pull('origin_database/tags_enhanced.csv')
328
- self.cooc_file = pull('origin_database/cooccurrence_clean.parquet')
329
- self.group_file = pull('origin_database/tag_groups.json')
 
330
 
331
  meta_path = pull('tags_embedding/tags_metadata.parquet')
332
  emb_path = pull('tags_embedding/danbooru_multiview_embeddings.safetensors')
@@ -602,6 +607,36 @@ class DanbooruTagger:
602
  self.get_group_candidates, selected_tags, show_nsfw,
603
  )
604
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
605
  def _apply_group_expand(self, final: dict[str, TagResult]) -> None:
606
  """expand 模式:提升同 group 标签的分数。"""
607
  BETA = 0.2
@@ -1186,6 +1221,132 @@ class DanbooruTagger:
1186
 
1187
  return results
1188
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1189
  def _load_cooc(self) -> None:
1190
  csv_path = Path(self.cooc_file)
1191
  parquet_path = csv_path.with_suffix('.parquet')
@@ -1242,6 +1403,32 @@ class DanbooruTagger:
1242
  except Exception as e:
1243
  print(f'[Engine] 共现表加载失败: {e}')
1244
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1245
  def _load_groups(self) -> None:
1246
  """加载 Tag Group 数据,构建 tag→group 和 group→idx 索引。"""
1247
  if not Path(self.group_file).is_file():
 
187
  model_path: Optional[str] = None,
188
  csv_file: str = 'origin_database/tags_enhanced.csv',
189
  cache_dir: str = 'tags_embedding',
190
+ cooc_file: str = 'origin_database/cooccurrence_clean.csv',
191
+ group_file: str = 'origin_database/tag_groups.json',
192
+ tag_artist_file: str = 'origin_database/tag_artist_cooc.parquet',
193
  ):
194
  # 模型路径:优先使用显式传入,否则交由 platform_utils 解析
195
  self.model_path = model_path or resolve_model_path()
 
199
  self.paths = _CachePaths(cache_dir)
200
  self.cooc_file = cooc_file
201
  self.group_file = group_file
202
+ self.tag_artist_file = tag_artist_file
203
 
204
  self.model: Optional[SentenceTransformer] = None
205
  self.df: Optional[pd.DataFrame] = None
 
213
  self._tag_to_groups: dict[str, set[str]] = {}
214
  self._group_to_tags_idx: dict[str, np.ndarray] = {}
215
  self._group_cn_names: dict[str, str] = {}
216
+ self._tag_artist_index: dict[str, list[tuple]] = {}
217
  self.is_loaded: bool = False
218
 
219
  # 预提取的列数组,避免热点路径上反复执行 df.iloc[idx]
 
268
 
269
  self._setup_jieba_from_memory()
270
  self._load_cooc()
271
+ self._load_tag_artist_cooc()
272
  self._name_to_idx = {n: i for i, n in enumerate(self.df['name'])}
273
  self._tag_names_set: set[str] = set(self._name_to_idx.keys())
274
  self._rebuild_arrays_from_df()
 
328
  print(f'[Engine] 拉取 {filename} 失败(非致命): {e}')
329
  return filename # 回退到原始路径,让后续逻辑决定是否重建
330
 
331
+ self.csv_path = pull('origin_database/tags_enhanced.csv')
332
+ self.cooc_file = pull('origin_database/cooccurrence_clean.parquet')
333
+ self.group_file = pull('origin_database/tag_groups.json')
334
+ self.tag_artist_file = pull('origin_database/tag_artist_cooc.parquet')
335
 
336
  meta_path = pull('tags_embedding/tags_metadata.parquet')
337
  emb_path = pull('tags_embedding/danbooru_multiview_embeddings.safetensors')
 
607
  self.get_group_candidates, selected_tags, show_nsfw,
608
  )
609
 
610
+ async def search_artists_by_tags_async(
611
+ self,
612
+ tags: list[str],
613
+ limit: int = 30,
614
+ min_cooc: int = 5,
615
+ ) -> list:
616
+ """search_artists_by_tags() 的并发安全异步封装。"""
617
+ async with self._get_cpu_sem():
618
+ return await asyncio.to_thread(
619
+ self.search_artists_by_tags, tags, limit, min_cooc,
620
+ )
621
+
622
+ async def search_artists_pipeline_async(
623
+ self,
624
+ query: str,
625
+ limit: int = 30,
626
+ min_cooc: int = 5,
627
+ target_layers: list[str] | None = None,
628
+ target_categories: list[str] | None = None,
629
+ ) -> tuple:
630
+ """search_artists_pipeline() 的并发安全异步封装。"""
631
+ async with self._get_cpu_sem():
632
+ return await asyncio.wait_for(
633
+ asyncio.to_thread(
634
+ self.search_artists_pipeline,
635
+ query, limit, min_cooc, target_layers, target_categories,
636
+ ),
637
+ timeout=120.0,
638
+ )
639
+
640
  def _apply_group_expand(self, final: dict[str, TagResult]) -> None:
641
  """expand 模式:提升同 group 标签的分数。"""
642
  BETA = 0.2
 
1221
 
1222
  return results
1223
 
1224
+ # ── 画师查找 ──────────────────────────────────────────────────────────
1225
+
1226
+ def search_artists_by_tags(
1227
+ self,
1228
+ tags: list[str],
1229
+ limit: int = 30,
1230
+ min_cooc: int = 5,
1231
+ ) -> list:
1232
+ """按标签查找画师:聚合多个标签的 NPMI 得分,返回排名靠前的画师。"""
1233
+ from .models import ArtistResult
1234
+
1235
+ if not self._tag_artist_index or not tags:
1236
+ return []
1237
+
1238
+ artist_scores: dict[str, float] = {}
1239
+ artist_cooc: dict[str, int] = {}
1240
+ artist_post_count: dict[str, int] = {}
1241
+ artist_sources: dict[str, list[str]] = {}
1242
+ artist_hits: dict[str, int] = {}
1243
+
1244
+ for tag in tags:
1245
+ tag = tag.strip().lower()
1246
+ if not tag or tag not in self._tag_artist_index:
1247
+ continue
1248
+ for artist, npmi, cooc, post_count in self._tag_artist_index[tag]:
1249
+ if cooc < min_cooc:
1250
+ continue
1251
+ artist_scores[artist] = artist_scores.get(artist, 0.0) + npmi
1252
+ artist_cooc[artist] = artist_cooc.get(artist, 0) + cooc
1253
+ artist_post_count[artist] = max(artist_post_count.get(artist, 0), post_count)
1254
+ artist_sources.setdefault(artist, []).append(tag)
1255
+ artist_hits[artist] = artist_hits.get(artist, 0) + 1
1256
+
1257
+ if not artist_scores:
1258
+ return []
1259
+
1260
+ scored = []
1261
+ for artist, raw_score in artist_scores.items():
1262
+ hit_bonus = 1.0 + 0.3 * (artist_hits[artist] - 1)
1263
+ final_score = raw_score * hit_bonus
1264
+ scored.append((
1265
+ artist, final_score, artist_cooc[artist],
1266
+ artist_post_count[artist], artist_sources[artist],
1267
+ artist_hits[artist],
1268
+ ))
1269
+
1270
+ scored.sort(key=lambda x: -x[1])
1271
+ results = []
1272
+ for artist, score, cooc, post_count, sources, hits in scored[:limit]:
1273
+ results.append(ArtistResult(
1274
+ artist=artist,
1275
+ score=round(score, 4),
1276
+ cooc_count=cooc,
1277
+ post_count=post_count,
1278
+ sources=sources,
1279
+ hit_count=hits,
1280
+ ))
1281
+ return results
1282
+
1283
+ def search_artists_pipeline(
1284
+ self,
1285
+ query: str,
1286
+ limit: int = 30,
1287
+ min_cooc: int = 5,
1288
+ target_layers: list[str] | None = None,
1289
+ target_categories: list[str] | None = None,
1290
+ ) -> tuple[list, list[str], list[str], list[str]]:
1291
+ """画师查找完整管线:自然语言 → 标签搜索 → 提取最佳标签 → 画师查询。
1292
+
1293
+ Returns:
1294
+ (artist_results, seed_tags, found_tags, missing_tags)
1295
+ """
1296
+ if target_layers is None:
1297
+ target_layers = ['英文', '中文扩展词', '释义', '中文核心词']
1298
+ if target_categories is None:
1299
+ target_categories = ['General', 'Character', 'Copyright']
1300
+
1301
+ # Step 1: 自然语言 → 标签搜索
1302
+ tag_request = SearchRequest(
1303
+ query=query,
1304
+ top_k=10,
1305
+ limit=80,
1306
+ popularity_weight=0.15,
1307
+ show_nsfw=True,
1308
+ use_segmentation=True,
1309
+ target_layers=target_layers,
1310
+ target_categories=target_categories,
1311
+ )
1312
+ tag_response = self.search(tag_request)
1313
+
1314
+ if not tag_response.results:
1315
+ return [], [], [], []
1316
+
1317
+ # Step 2: 按 source 分组,每组按分数降序取前 10 个候选,找到能命中画师的第一个
1318
+ tag_artist = self._tag_artist_index
1319
+ source_candidates: dict[str, list[str]] = {}
1320
+ for r in tag_response.results:
1321
+ if r.final_score < 0.45:
1322
+ continue
1323
+ source_candidates.setdefault(r.source, []).append(r.tag)
1324
+
1325
+ seed_tags: list[str] = []
1326
+ seen = set()
1327
+ for candidates in source_candidates.values():
1328
+ for tag in candidates[:10]:
1329
+ tag_lower = tag.strip().lower()
1330
+ if tag_lower in tag_artist and tag_lower not in seen:
1331
+ seed_tags.append(tag_lower)
1332
+ seen.add(tag_lower)
1333
+ break
1334
+
1335
+ if not seed_tags:
1336
+ return [], [], [], []
1337
+
1338
+ # Step 3: 标签 → 画师查找
1339
+ artist_results = self.search_artists_by_tags(seed_tags, limit, min_cooc)
1340
+
1341
+ # 统计 found / missing(seed_tags 中哪些匹配到了画师)
1342
+ matched = {s for r in artist_results for s in r.sources}
1343
+ found_tags = [t for t in seed_tags if t in matched]
1344
+ missing_tags = [t for t in seed_tags if t not in matched]
1345
+
1346
+ return artist_results, seed_tags, found_tags, missing_tags
1347
+
1348
+ # ── 共现数据加载 ──────────────────────────────────────────────────────
1349
+
1350
  def _load_cooc(self) -> None:
1351
  csv_path = Path(self.cooc_file)
1352
  parquet_path = csv_path.with_suffix('.parquet')
 
1403
  except Exception as e:
1404
  print(f'[Engine] 共现表加载失败: {e}')
1405
 
1406
+ def _load_tag_artist_cooc(self) -> None:
1407
+ """加载标签-画师共现数据(tag_artist_cooc.parquet)。"""
1408
+ path = Path(self.tag_artist_file)
1409
+ if not path.is_file():
1410
+ print(f'[Engine] 未找到标签-画师共现表 ({self.tag_artist_file}),画师查找功能不可用。')
1411
+ return
1412
+
1413
+ print(f'[Engine] 加载标签-画师共现表 ({path.name})...')
1414
+ t0 = time.time()
1415
+ try:
1416
+ df = pd.read_parquet(str(path))
1417
+ index: dict[str, list[tuple]] = {}
1418
+ for _, row in df.iterrows():
1419
+ tag = str(row["tag"])
1420
+ index.setdefault(tag, []).append(
1421
+ (str(row["artist"]), float(row["npmi"]),
1422
+ int(row["cooc_count"]), int(row["artist_post_count"]))
1423
+ )
1424
+ self._tag_artist_index = index
1425
+ print(
1426
+ f'[Engine] 标签-画师共现表加载完成,{len(index):,} 个标签,'
1427
+ f'耗时 {time.time() - t0:.2f}s'
1428
+ )
1429
+ except Exception as e:
1430
+ print(f'[Engine] 标签-画师共现表加载失败: {e}')
1431
+
1432
  def _load_groups(self) -> None:
1433
  """加载 Tag Group 数据,构建 tag→group 和 group→idx 索引。"""
1434
  if not Path(self.group_file).is_file():
core/models.py CHANGED
@@ -34,6 +34,17 @@ class RelatedTag:
34
  wiki: str = "" # 标签 wiki 描述
35
 
36
 
 
 
 
 
 
 
 
 
 
 
 
37
  @dataclass
38
  class SearchRequest:
39
  """搜索参数"""
 
34
  wiki: str = "" # 标签 wiki 描述
35
 
36
 
37
+ @dataclass
38
+ class ArtistResult:
39
+ """单条画师搜索结果。"""
40
+ artist: str # 画师名
41
+ score: float # 综合 NPMI 得分
42
+ cooc_count: int # 累计共现次数
43
+ post_count: int # 画师在 Danbooru 的作品数
44
+ sources: list[str] # 命中标签列表
45
+ hit_count: int # 命中标签数
46
+
47
+
48
  @dataclass
49
  class SearchRequest:
50
  """搜索参数"""
ui_nicegui.py CHANGED
@@ -177,6 +177,12 @@ class DanbooruSearchUI:
177
  self.prompt_format: str = 'sdxl'
178
  self.format_toggle_btn = None
179
 
 
 
 
 
 
 
180
  self.init_banner = None
181
  self.input_top_k = None
182
  self.input_limit = None
@@ -292,6 +298,7 @@ class DanbooruSearchUI:
292
  'search_mode': self.input_search_mode.value if self.input_search_mode else '自定义',
293
  'group_mode': self.input_group_mode.value if self.input_group_mode else 'off',
294
  'max_per_group': int(self.input_max_per_group.value) if self.input_max_per_group else 2,
 
295
  }
296
  js = _json.dumps(cfg, ensure_ascii=False)
297
  ui.run_javascript(f"localStorage.setItem('{_CONFIG_LS_KEY}', {_json.dumps(js)});")
@@ -388,6 +395,10 @@ class DanbooruSearchUI:
388
  if self.mcp_notice and cfg.get('mcp_notice_dismissed'):
389
  self.mcp_notice.set_visibility(False)
390
 
 
 
 
 
391
  # 若高级选项列有变更,同步更新表格列
392
  self._update_table_columns()
393
 
@@ -457,6 +468,60 @@ class DanbooruSearchUI:
457
  max-width: 100% !important;
458
  }
459
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
460
  </style>
461
  <script async src="https://www.googletagmanager.com/gtag/js?id=G-QPB7EEPR5G"></script>
462
  <script>
@@ -530,7 +595,11 @@ class DanbooruSearchUI:
530
  # ── 4. 分词筛选 chips ──
531
  self.keywords_container = ui.row().classes('gap-2 items-center flex-wrap')
532
 
533
- # ── 5. 两栏结果 ──
 
 
 
 
534
  self._build_results_columns()
535
 
536
  # ── 6. 底部 ──
@@ -538,7 +607,7 @@ class DanbooruSearchUI:
538
  self.search_count_label = ui.html('正在加载数据...').classes('text-xs text-gray-400')
539
  self._update_footer_text()
540
 
541
- # ── MCP 上线通知 ──────────────────────────────────────────────────────
542
 
543
  def _build_group_notice(self):
544
  self.mcp_notice = ui.card().classes(
@@ -546,18 +615,26 @@ class DanbooruSearchUI:
546
  )
547
  with self.mcp_notice:
548
  with ui.column().classes('px-4 py-3 w-full gap-2'):
 
549
  with ui.row().classes('items-center justify-between w-full'):
550
- ui.label('🧪 新功能:标签组扩展').classes('text-sm font-bold text-green-800')
 
551
  ui.button(icon='close').props('flat dense round color=grey-6') \
552
  .on_click(self._dismiss_mcp_notice)
553
  ui.html(
554
- '勾选标签后,搜索结果下方会出<b>同类标签</b>区域,'
555
- '展示已选标签所属分组中其他标签。<b>勾选复选框</b>即可加入已选,'
556
- '选中的标签会加入已选列表,可直接复制为 Prompt。'
 
 
 
 
 
557
  ).classes('text-xs text-green-900')
558
  ui.separator().classes('my-1')
 
559
  ui.html(
560
- '【MCP 服务已上线】 支持通过 MCP 协议接入 AI Agent(如 Claude Desktop)。'
561
  '免配置托管版体验:'
562
  '<a href="https://huggingface.co/spaces/SAkizuki/WenQiuYue" '
563
  'target="_blank" rel="noopener noreferrer" '
@@ -604,12 +681,20 @@ class DanbooruSearchUI:
604
 
605
  def _build_search_card(self):
606
  with ui.card().classes('w-full'):
 
 
 
 
 
 
 
 
607
  with ui.row().classes('items-center gap-2 mb-2'):
608
  ui.icon('search', size='2em', color='primary')
609
- ui.label('Danbooru 标签模糊搜索').classes('text-2xl font-bold text-gray-800')
610
- ui.label('基于语义匹配的标签搜索引擎,支持多维匹配与共现关联推荐。').classes(
611
- 'text-sm text-gray-500 -mt-1 mb-3'
612
- )
613
 
614
  with ui.row().classes('w-full gap-3 items-stretch'):
615
  self.search_input = ui.textarea(
@@ -625,7 +710,8 @@ class DanbooruSearchUI:
625
  ui.label('搜索').classes('text-sm mt-1')
626
  self.spinner = ui.spinner(size='2em').classes('hidden')
627
 
628
- with ui.row().classes('w-full gap-6 items-center mt-3 flex-wrap'):
 
629
  with ui.row().classes('items-center gap-2'):
630
  ui.label('搜索模式 (beta)').classes('text-sm text-gray-600')
631
  self.input_search_mode = ui.select(
@@ -670,7 +756,8 @@ class DanbooruSearchUI:
670
  self.input_segment = _seg_sw
671
  self.input_segment.on('update:model-value', self._on_param_changed)
672
 
673
- with ui.expansion('高级选项', icon='tune').classes('w-full mt-2'):
 
674
  with ui.column().classes('w-full p-3 gap-4'):
675
  with ui.row().classes('w-full gap-8 flex-wrap'):
676
  with ui.column().classes('gap-2'):
@@ -711,7 +798,7 @@ class DanbooruSearchUI:
711
  self.sw_source.on('update:model-value', self._update_table_columns)
712
 
713
  with ui.column().classes('gap-2'):
714
- ui.label('标签分组模式 (beta)').classes('font-bold text-sm text-gray-700')
715
  self.input_group_mode = ui.select(
716
  ['off', 'expand', 'diverse'], value='off',
717
  ).classes('w-40').props('outlined dense')
@@ -728,7 +815,8 @@ class DanbooruSearchUI:
728
  # ── 已选标签栏 ────────────────────────────────────────────────────────
729
 
730
  def _build_selection_bar(self):
731
- with ui.card().classes('w-full bg-blue-50 border border-blue-200'):
 
732
  with ui.row().classes('w-full items-center justify-between'):
733
  with ui.row().classes('items-center gap-2'):
734
  ui.icon('check_circle', color='primary')
@@ -912,7 +1000,8 @@ class DanbooruSearchUI:
912
  # ── 两栏结果(CSS 强制并排)──────────────────────────────────────────
913
 
914
  def _build_results_columns(self):
915
- with ui.element('div').classes('w-full two-col-layout'):
 
916
  # ── 左栏:语义匹配结果(表格)──
917
  with ui.card().classes('col-left'):
918
  with ui.row().classes('items-center justify-between mb-2 w-full'):
@@ -997,7 +1086,7 @@ class DanbooruSearchUI:
997
  ui.separator().classes('my-2')
998
  with ui.row().classes('items-center justify-between w-full mb-1'):
999
  with ui.row().classes('items-center gap-2'):
1000
- ui.label('同类标签 (beta)').classes('font-bold text-sm text-gray-600')
1001
  with ui.icon('info_outline', size='xs', color='grey').classes('cursor-help'):
1002
  with ui.tooltip().props('content-class="bg-black text-white shadow-4"'):
1003
  ui.label('基于标签分组数据,展示已选标签所属分组中的其他标签。勾选可加入已选。').style('font-size:14px;')
@@ -1026,6 +1115,73 @@ class DanbooruSearchUI:
1026
  with self.related_list_container:
1027
  ui.label('请先搜索并勾选标签…').classes('text-sm text-gray-400 italic p-4')
1028
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1029
  # ══════════════════════════════════════════════════════════════════════
1030
  # 渲染关联推荐列表
1031
  # ══════════════════════════════════════════════════════════════════════
@@ -1167,6 +1323,52 @@ class DanbooruSearchUI:
1167
  chip_color, text_color = 'grey-4', 'black'
1168
  child.props(f'color={chip_color} text-color={text_color}')
1169
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1170
  # ── 搜索 ──────────────────────────────────────────────────────────────
1171
 
1172
  async def perform_search(self):
@@ -1209,97 +1411,134 @@ class DanbooruSearchUI:
1209
  try:
1210
  tagger = await DanbooruTagger.get_instance()
1211
 
1212
- # NSFW 保护模式:开 = 不显示 NSFW
1213
- show_nsfw_val = self.input_nsfw.value
1214
-
1215
- request = SearchRequest(
1216
- query=query,
1217
- top_k=int(self.input_top_k.value),
1218
- limit=int(self.input_limit.value),
1219
- popularity_weight=float(self.input_weight.value),
1220
- show_nsfw=show_nsfw_val,
1221
- use_segmentation=self.input_segment.value if self.input_segment else True,
1222
- target_layers=target_layers_list,
1223
- target_categories=target_cats_list,
1224
- group_mode=self.input_group_mode.value if self.input_group_mode else 'off',
1225
- max_per_group=int(self.input_max_per_group.value) if self.input_max_per_group else 2,
1226
- )
1227
- response = await tagger.search_async(request)
1228
-
1229
- # 后台计数
1230
- async def silent_counter_update():
1231
- try:
1232
- await counter.increment()
1233
- if response.keywords:
1234
- await counter.add_keywords(response.keywords)
1235
- self._update_footer_text()
1236
- except Exception as e:
1237
- print(f"[UI] 后台静默更新计数失败: {e}", flush=True)
1238
- asyncio.create_task(silent_counter_update())
1239
-
1240
- if not self._client_alive():
1241
- return
1242
 
1243
- table_data = [result_to_row(r, show_nsfw_val) for r in response.results]
1244
- self.full_table_data = table_data
1245
- self.full_tags_str = response.tags_all
1246
- self.full_tags_str_sfw = response.tags_sfw
1247
- self.current_segments = list(response.segments) if response.segments else []
1248
 
1249
- # 显示结果区域
1250
- self.results_section.set_visibility(True)
1251
 
1252
- _saved_rpp = self._get_rows_per_page()
1253
- self.result_table.rows = apply_nsfw_filter(table_data, show_nsfw_val)
1254
- self._set_rows_per_page(_saved_rpp)
1255
- # 搜索时保留已选标签(跨搜索积累)
1256
- all_selected = self._get_selected_tags()
1257
- self.chip_extra_selected.clear()
1258
- self.chip_extra_selected.update(all_selected)
1259
- self.result_table.selected = []
1260
- self._render_selected_chips()
1261
- self._update_selection_display(None)
1262
- self._save_staged_tags()
1263
 
1264
- # 清空关联推荐
1265
- self._refresh_related([], show_nsfw_val)
 
 
 
 
 
1266
 
1267
- # 分词筛选 chips
1268
- self.current_filter_keyword = 'ALL' # 新搜索默认选中"全部"
1269
- self.keywords_container.clear()
1270
- cached_set = set(response.cached_queries) if response.cached_queries else set()
1271
- with self.keywords_container:
1272
- ui.label('分词筛选:').classes('text-sm text-gray-500 font-bold mr-2')
1273
- ui.chip('全部', on_click=lambda: self._filter_by_source('ALL')) \
1274
- .props('color=primary text-color=white clickable')
1275
- use_seg = self.input_segment.value if self.input_segment else True
1276
- if use_seg:
1277
- whole = ui.chip('整句',
1278
- on_click=lambda: self._filter_by_source(self.current_query_str))
1279
- whole.props('color=grey-4 text-color=black clickable')
1280
- if self.current_query_str in cached_set:
1281
- whole.style('outline: 1px dashed rgba(128,128,128,0.3); outline-offset: 1px;')
1282
- # 从句级原始片段(分隔符切分后未 jieba 的长片段,区别于关键词)
1283
- for seg in response.segments:
1284
- sc = ui.chip(seg,
1285
- on_click=lambda s=seg: self._filter_by_source(s))
1286
- sc.props('color=blue-1 text-color=blue-8 clickable')
1287
- if seg in cached_set:
1288
- sc.style('outline: 1px dashed rgba(128,128,128,0.3); outline-offset: 1px;')
1289
- for kw in response.keywords:
1290
- kc = ui.chip(kw,
1291
- on_click=lambda k=kw: self._filter_by_source(k))
1292
- kc.props('color=grey-4 text-color=black clickable')
1293
- if kw in cached_set:
1294
- kc.style('outline: 1px dashed rgba(128,128,128,0.3); outline-offset: 1px;')
1295
- else:
1296
- ui.label('(分词已关闭)').classes('text-xs text-gray-400')
1297
-
1298
- ui.notify(f'找到 {len(table_data)} 个标签', type='positive')
1299
- self.current_search_interacted = False
1300
 
1301
- if self.bad_case_btn is not None:
1302
- self.bad_case_btn.enable()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1303
 
1304
  except RuntimeError as e:
1305
  if 'deleted' in str(e).lower() or 'client' in str(e).lower():
 
177
  self.prompt_format: str = 'sdxl'
178
  self.format_toggle_btn = None
179
 
180
+ # 画师查找模式
181
+ self.artist_search_mode: bool = False
182
+ self.artist_search_btn = None
183
+ self.artist_results_container = None
184
+ self.current_artist_seed_tags: list[str] = [] # 本次画师搜索使用的种子标签
185
+
186
  self.init_banner = None
187
  self.input_top_k = None
188
  self.input_limit = None
 
298
  'search_mode': self.input_search_mode.value if self.input_search_mode else '自定义',
299
  'group_mode': self.input_group_mode.value if self.input_group_mode else 'off',
300
  'max_per_group': int(self.input_max_per_group.value) if self.input_max_per_group else 2,
301
+ 'artist_search_mode': self.artist_search_mode,
302
  }
303
  js = _json.dumps(cfg, ensure_ascii=False)
304
  ui.run_javascript(f"localStorage.setItem('{_CONFIG_LS_KEY}', {_json.dumps(js)});")
 
395
  if self.mcp_notice and cfg.get('mcp_notice_dismissed'):
396
  self.mcp_notice.set_visibility(False)
397
 
398
+ # 恢复画师查找模式
399
+ if cfg.get('artist_search_mode'):
400
+ self._on_search_type_change(True)
401
+
402
  # 若高级选项列有变更,同步更新表格列
403
  self._update_table_columns()
404
 
 
468
  max-width: 100% !important;
469
  }
470
  }
471
+
472
+ /* 画师查找模式 — 暗色主题 */
473
+ body[data-search-mode="artist"] .artist-card-row {
474
+ background: rgba(245, 158, 11, 0.06);
475
+ border-color: rgba(245, 158, 11, 0.15);
476
+ }
477
+ .artist-card-row {
478
+ transition: background-color 0.2s, border-color 0.2s;
479
+ }
480
+ .artist-card-row:hover {
481
+ background: rgba(245, 158, 11, 0.12) !important;
482
+ }
483
+ .mode-toggle-btn {
484
+ transition: all 0.25s ease;
485
+ }
486
+
487
+ /* 暗色模式下文字颜色覆盖 */
488
+ body[data-search-mode="artist"] .text-gray-800,
489
+ body[data-search-mode="artist"] .text-gray-700,
490
+ body[data-search-mode="artist"] .text-gray-600 {
491
+ color: #cbd5e1 !important;
492
+ }
493
+ body[data-search-mode="artist"] .text-gray-500 {
494
+ color: #94a3b8 !important;
495
+ }
496
+ body[data-search-mode="artist"] .text-gray-400 {
497
+ color: #64748b !important;
498
+ }
499
+
500
+ /* 暗色模式下公告栏 / 注意事项卡片适配 */
501
+ body[data-search-mode="artist"] .bg-green-50 {
502
+ background: rgba(16, 185, 129, 0.1) !important;
503
+ border-color: rgba(16, 185, 129, 0.3) !important;
504
+ }
505
+ body[data-search-mode="artist"] .text-green-800,
506
+ body[data-search-mode="artist"] .text-green-900,
507
+ body[data-search-mode="artist"] .text-green-700 {
508
+ color: #6ee7b7 !important;
509
+ }
510
+ body[data-search-mode="artist"] .bg-orange-50 {
511
+ background: rgba(249, 115, 22, 0.1) !important;
512
+ border-color: rgba(249, 115, 22, 0.3) !important;
513
+ }
514
+ body[data-search-mode="artist"] .text-orange-800 {
515
+ color: #fdba74 !important;
516
+ }
517
+ body[data-search-mode="artist"] .bg-blue-50 {
518
+ background: rgba(59, 130, 246, 0.1) !important;
519
+ border-color: rgba(59, 130, 246, 0.3) !important;
520
+ }
521
+ body[data-search-mode="artist"] .text-blue-700,
522
+ body[data-search-mode="artist"] .text-blue-600 {
523
+ color: #93c5fd !important;
524
+ }
525
  </style>
526
  <script async src="https://www.googletagmanager.com/gtag/js?id=G-QPB7EEPR5G"></script>
527
  <script>
 
595
  # ── 4. 分词筛选 chips ──
596
  self.keywords_container = ui.row().classes('gap-2 items-center flex-wrap')
597
 
598
+ # ── 5. 画师结果容器(画师模式专用)──
599
+ self.artist_results_container = ui.column().classes('w-full gap-3')
600
+ self.artist_results_container.set_visibility(False)
601
+
602
+ # ── 6. 两栏结果(标签模式)──
603
  self._build_results_columns()
604
 
605
  # ── 6. 底部 ──
 
607
  self.search_count_label = ui.html('正在加载数据...').classes('text-xs text-gray-400')
608
  self._update_footer_text()
609
 
610
+ # ── 公告栏(画师查找 + 标签组 + MCP)───────────────────────────────────
611
 
612
  def _build_group_notice(self):
613
  self.mcp_notice = ui.card().classes(
 
615
  )
616
  with self.mcp_notice:
617
  with ui.column().classes('px-4 py-3 w-full gap-2'):
618
+ # ── 画师查找公告 ──
619
  with ui.row().classes('items-center justify-between w-full'):
620
+ with ui.row().classes('items-center gap-1'):
621
+ ui.label('🧪 新功能:画师查找(beta)').classes('text-sm font-bold text-green-800')
622
  ui.button(icon='close').props('flat dense round color=grey-6') \
623
  .on_click(self._dismiss_mcp_notice)
624
  ui.html(
625
+ '基于标签数据输入风格描述即可查找对应画师。'
626
+ '点击搜索栏上方 <b>「画师查找(beta)」</b> 按钮即可切换模式。'
627
+ ).classes('text-xs text-green-900')
628
+ ui.separator().classes('my-1')
629
+ # ── 标签组扩展 ──
630
+ ui.html(
631
+ '【标签组扩展】 勾选标签后,搜索结果下方会出现<b>同类标签</b>区域,'
632
+ '展示已选标签所属分组中的其他标签,勾选即可加入已选。'
633
  ).classes('text-xs text-green-900')
634
  ui.separator().classes('my-1')
635
+ # ── MCP 服务 ──
636
  ui.html(
637
+ '【MCP 服务】 支持通过 MCP 协议接入 AI Agent(如 Claude Desktop)。'
638
  '免配置托管版体验:'
639
  '<a href="https://huggingface.co/spaces/SAkizuki/WenQiuYue" '
640
  'target="_blank" rel="noopener noreferrer" '
 
681
 
682
  def _build_search_card(self):
683
  with ui.card().classes('w-full'):
684
+ # ── 模式切换按钮 ──
685
+ with ui.row().classes('w-full items-center justify-between mb-3'):
686
+ with ui.row().classes('items-center gap-0'):
687
+ self.tag_mode_btn = ui.button('标签搜索', on_click=lambda: self._on_search_type_change(False)) \
688
+ .props('unelevated color=primary').classes('mode-toggle-btn rounded-r-none')
689
+ self.artist_mode_btn = ui.button('画师查找(beta)', on_click=lambda: self._on_search_type_change(True)) \
690
+ .props('flat color=grey-6').classes('mode-toggle-btn rounded-l-none')
691
+
692
  with ui.row().classes('items-center gap-2 mb-2'):
693
  ui.icon('search', size='2em', color='primary')
694
+ self.search_title_label = ui.label('Danbooru 标签模糊搜索').classes('text-2xl font-bold text-gray-800')
695
+ self.search_subtitle_label = ui.label(
696
+ '基于语义匹配的标签搜索引擎,支持多维匹配与共现关联推荐。'
697
+ ).classes('text-sm text-gray-500 -mt-1 mb-3')
698
 
699
  with ui.row().classes('w-full gap-3 items-stretch'):
700
  self.search_input = ui.textarea(
 
710
  ui.label('搜索').classes('text-sm mt-1')
711
  self.spinner = ui.spinner(size='2em').classes('hidden')
712
 
713
+ self.search_params_row = ui.row().classes('w-full gap-6 items-center mt-3 flex-wrap')
714
+ with self.search_params_row:
715
  with ui.row().classes('items-center gap-2'):
716
  ui.label('搜索模式 (beta)').classes('text-sm text-gray-600')
717
  self.input_search_mode = ui.select(
 
756
  self.input_segment = _seg_sw
757
  self.input_segment.on('update:model-value', self._on_param_changed)
758
 
759
+ self.advanced_options = ui.expansion('高级选项', icon='tune').classes('w-full mt-2')
760
+ with self.advanced_options:
761
  with ui.column().classes('w-full p-3 gap-4'):
762
  with ui.row().classes('w-full gap-8 flex-wrap'):
763
  with ui.column().classes('gap-2'):
 
798
  self.sw_source.on('update:model-value', self._update_table_columns)
799
 
800
  with ui.column().classes('gap-2'):
801
+ ui.label('标签分组模式').classes('font-bold text-sm text-gray-700')
802
  self.input_group_mode = ui.select(
803
  ['off', 'expand', 'diverse'], value='off',
804
  ).classes('w-40').props('outlined dense')
 
815
  # ── 已选标签栏 ────────────────────────────────────────────────────────
816
 
817
  def _build_selection_bar(self):
818
+ self.selection_bar_card = ui.card().classes('w-full bg-blue-50 border border-blue-200')
819
+ with self.selection_bar_card:
820
  with ui.row().classes('w-full items-center justify-between'):
821
  with ui.row().classes('items-center gap-2'):
822
  ui.icon('check_circle', color='primary')
 
1000
  # ── 两栏结果(CSS 强制并排)──────────────────────────────────────────
1001
 
1002
  def _build_results_columns(self):
1003
+ self.two_col_container = ui.element('div').classes('w-full two-col-layout')
1004
+ with self.two_col_container:
1005
  # ── 左栏:语义匹配结果(表格)──
1006
  with ui.card().classes('col-left'):
1007
  with ui.row().classes('items-center justify-between mb-2 w-full'):
 
1086
  ui.separator().classes('my-2')
1087
  with ui.row().classes('items-center justify-between w-full mb-1'):
1088
  with ui.row().classes('items-center gap-2'):
1089
+ ui.label('同类标签').classes('font-bold text-sm text-gray-600')
1090
  with ui.icon('info_outline', size='xs', color='grey').classes('cursor-help'):
1091
  with ui.tooltip().props('content-class="bg-black text-white shadow-4"'):
1092
  ui.label('基于标签分组数据,展示已选标签所属分组中的其他标签。勾选可加入已选。').style('font-size:14px;')
 
1115
  with self.related_list_container:
1116
  ui.label('请先搜索并勾选标签…').classes('text-sm text-gray-400 italic p-4')
1117
 
1118
+ # ══════════════════════════════════════════════════════════════════════
1119
+ # 渲染画师搜索结果
1120
+ # ══════════════════════════════════════════════════════════════════════
1121
+
1122
+ async def _silent_increment_counter(self):
1123
+ try:
1124
+ await counter.increment()
1125
+ self._update_footer_text()
1126
+ except Exception:
1127
+ pass
1128
+
1129
+ def _render_artist_results(self, artist_results, found_tags, missing_tags):
1130
+ if self.artist_results_container is None:
1131
+ return
1132
+ self.artist_results_container.clear()
1133
+
1134
+ if not artist_results:
1135
+ with self.artist_results_container:
1136
+ with ui.card().classes('w-full bg-amber-50 border border-amber-200'):
1137
+ with ui.row().classes('items-center gap-2 p-4'):
1138
+ ui.icon('info', color='amber')
1139
+ ui.label('未找到匹配的画师,请尝试更具体的风格描述。').classes('text-sm text-amber-700')
1140
+ return
1141
+
1142
+ max_score = max(r.score for r in artist_results) if artist_results else 1.0
1143
+
1144
+ with self.artist_results_container:
1145
+ # 种子标签信息
1146
+ if found_tags:
1147
+ tags_display = ', '.join(found_tags)
1148
+ ui.label(f'种子标签: {tags_display}').classes('text-sm text-gray-500 mb-1')
1149
+ if missing_tags:
1150
+ ui.label(f'未匹配画师的标签: {", ".join(missing_tags)}').classes('text-xs text-orange-400')
1151
+
1152
+ with ui.card().classes('w-full p-0'):
1153
+ for i, r in enumerate(artist_results):
1154
+ pct = min(r.score / max_score * 100, 100) if max_score > 0 else 0
1155
+ rank = i + 1
1156
+ sources_str = ', '.join(r.sources[:5])
1157
+ post_str = f'{r.post_count:,}' if r.post_count else '—'
1158
+
1159
+ with ui.row().classes(
1160
+ 'w-full items-center gap-3 px-4 py-3 border-b border-gray-200 artist-card-row'
1161
+ ).style('border-bottom: 1px solid rgba(128,128,128,0.1);'):
1162
+ # 排名
1163
+ ui.label(f'#{rank}').classes(
1164
+ 'text-lg font-bold min-w-[42px] text-center'
1165
+ ).style(f'color: {"#F59E0B" if rank <= 3 else "#9CA3AF"};')
1166
+
1167
+ # 画师信息
1168
+ with ui.column().classes('flex-grow gap-0 min-w-0'):
1169
+ ui.link(r.artist, f'https://danbooru.donmai.us/posts?tags={r.artist}', new_tab=True) \
1170
+ .classes('text-base font-bold')
1171
+ with ui.row().classes('items-center gap-4 mt-1'):
1172
+ ui.label(f'匹配标签: {sources_str}').classes('text-xs text-gray-500')
1173
+ ui.label(f'Danbooru 作品: {post_str}').classes('text-xs text-gray-400')
1174
+
1175
+ # 分数条
1176
+ with ui.column().classes('items-end gap-0 min-w-[80px]'):
1177
+ ui.label(f'{r.score:.4f}').classes('text-sm font-mono font-bold')
1178
+ with ui.element('div').classes('w-full h-2 rounded-full overflow-hidden') \
1179
+ .style('background: rgba(128,128,128,0.15);'):
1180
+ ui.element('div').classes('h-full rounded-full').style(
1181
+ f'width: {pct:.0f}%;'
1182
+ f'background: #F59E0B;'
1183
+ )
1184
+
1185
  # ══════════════════════════════════════════════════════════════════════
1186
  # 渲染关联推荐列表
1187
  # ══════════════════════════════════════════════════════════════════════
 
1323
  chip_color, text_color = 'grey-4', 'black'
1324
  child.props(f'color={chip_color} text-color={text_color}')
1325
 
1326
+ # ── 搜索模式切换 ──────────────────────────────────────────────────────
1327
+
1328
+ def _on_search_type_change(self, artist_mode: bool):
1329
+ if artist_mode == self.artist_search_mode:
1330
+ return
1331
+ self.artist_search_mode = artist_mode
1332
+
1333
+ if artist_mode:
1334
+ # 画师查找 → 暗色主题
1335
+ ui.colors(primary='#F59E0B', secondary='#78716C', accent='#10B981')
1336
+ ui.dark_mode().enable()
1337
+ self.tag_mode_btn.props('flat color=grey-6')
1338
+ self.artist_mode_btn.props('unelevated color=primary')
1339
+ self.search_title_label.set_text('画师查找')
1340
+ self.search_subtitle_label.set_text('输入任何元素(如"平涂" "枪" "蔚蓝档案"),自动匹配擅长的画师。')
1341
+ self.search_input.props('placeholder="输入任何元素,如:平涂 枪 蔚蓝档案..."')
1342
+ # 隐藏搜索参数控件
1343
+ self.search_params_row.set_visibility(False)
1344
+ self.advanced_options.set_visibility(False)
1345
+ # 清空旧结果并隐藏标签搜索专属区域
1346
+ if self.results_section:
1347
+ self.results_section.set_visibility(False)
1348
+ if self.artist_results_container:
1349
+ self.artist_results_container.clear()
1350
+ else:
1351
+ # 标签搜索 → 亮色主题
1352
+ ui.colors(primary='#4A90E2', secondary='#5E6C84', accent='#FF6B6B')
1353
+ ui.dark_mode().disable()
1354
+ self.tag_mode_btn.props('unelevated color=primary')
1355
+ self.artist_mode_btn.props('flat color=grey-6')
1356
+ self.search_title_label.set_text('Danbooru 标签模糊搜索')
1357
+ self.search_subtitle_label.set_text('基于语义匹配的标签搜索引擎,支持多维匹配与共现关联推荐。')
1358
+ self.search_input.props('placeholder="输入自然语言描述或模糊概念,例如:一个穿着白色水手服的少女在雨中奔跑..."')
1359
+ # 恢复搜索参数控件
1360
+ self.search_params_row.set_visibility(True)
1361
+ self.advanced_options.set_visibility(True)
1362
+ # 清空旧结果
1363
+ if self.results_section:
1364
+ self.results_section.set_visibility(False)
1365
+ if self.artist_results_container:
1366
+ self.artist_results_container.clear()
1367
+ self.keywords_container.clear()
1368
+
1369
+ ui.run_javascript(f"document.body.setAttribute('data-search-mode', '{'artist' if artist_mode else 'tag'}');")
1370
+ self._save_config()
1371
+
1372
  # ── 搜索 ──────────────────────────────────────────────────────────────
1373
 
1374
  async def perform_search(self):
 
1411
  try:
1412
  tagger = await DanbooruTagger.get_instance()
1413
 
1414
+ if self.artist_search_mode:
1415
+ # ── 画师查找模式 ──
1416
+ artist_results, seed_tags, found_tags, missing_tags = \
1417
+ await tagger.search_artists_pipeline_async(
1418
+ query,
1419
+ limit=30,
1420
+ target_layers=target_layers_list,
1421
+ target_categories=target_cats_list,
1422
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1423
 
1424
+ # 后台计数
1425
+ asyncio.create_task(self._silent_increment_counter())
 
 
 
1426
 
1427
+ if not self._client_alive():
1428
+ return
1429
 
1430
+ self.current_artist_seed_tags = seed_tags
1431
+ self.results_section.set_visibility(True)
1432
+ # 隐藏标签搜索专属区域
1433
+ self.selection_bar_card.set_visibility(False)
1434
+ self.two_col_container.set_visibility(False)
1435
+ # 显示画师结果
1436
+ self.artist_results_container.set_visibility(True)
 
 
 
 
1437
 
1438
+ self.keywords_container.clear()
1439
+ with self.keywords_container:
1440
+ ui.label('匹配种子标签:').classes('text-sm text-gray-500 font-bold mr-2')
1441
+ for tag in seed_tags:
1442
+ ui.chip(tag).props('color=amber-2 text-color=amber-9 clickable')
1443
+ if not seed_tags:
1444
+ ui.label('无').classes('text-xs text-gray-400 italic')
1445
 
1446
+ self._render_artist_results(artist_results, found_tags, missing_tags)
1447
+ ui.notify(f'找到 {len(artist_results)} 位画师', type='positive')
1448
+ self.current_search_interacted = False
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1449
 
1450
+ else:
1451
+ # ── 标签搜索模式 ──
1452
+ show_nsfw_val = self.input_nsfw.value
1453
+
1454
+ request = SearchRequest(
1455
+ query=query,
1456
+ top_k=int(self.input_top_k.value),
1457
+ limit=int(self.input_limit.value),
1458
+ popularity_weight=float(self.input_weight.value),
1459
+ show_nsfw=show_nsfw_val,
1460
+ use_segmentation=self.input_segment.value if self.input_segment else True,
1461
+ target_layers=target_layers_list,
1462
+ target_categories=target_cats_list,
1463
+ group_mode=self.input_group_mode.value if self.input_group_mode else 'off',
1464
+ max_per_group=int(self.input_max_per_group.value) if self.input_max_per_group else 2,
1465
+ )
1466
+ response = await tagger.search_async(request)
1467
+
1468
+ # 后台计数
1469
+ async def silent_counter_update():
1470
+ try:
1471
+ await counter.increment()
1472
+ if response.keywords:
1473
+ await counter.add_keywords(response.keywords)
1474
+ self._update_footer_text()
1475
+ except Exception as e:
1476
+ print(f"[UI] 后台静默更新计数失败: {e}", flush=True)
1477
+ asyncio.create_task(silent_counter_update())
1478
+
1479
+ if not self._client_alive():
1480
+ return
1481
+
1482
+ table_data = [result_to_row(r, show_nsfw_val) for r in response.results]
1483
+ self.full_table_data = table_data
1484
+ self.full_tags_str = response.tags_all
1485
+ self.full_tags_str_sfw = response.tags_sfw
1486
+ self.current_segments = list(response.segments) if response.segments else []
1487
+
1488
+ self.results_section.set_visibility(True)
1489
+ # 显示标签搜索专属区域
1490
+ self.selection_bar_card.set_visibility(True)
1491
+ self.two_col_container.set_visibility(True)
1492
+ self.artist_results_container.set_visibility(False)
1493
+
1494
+ _saved_rpp = self._get_rows_per_page()
1495
+ self.result_table.rows = apply_nsfw_filter(table_data, show_nsfw_val)
1496
+ self._set_rows_per_page(_saved_rpp)
1497
+ all_selected = self._get_selected_tags()
1498
+ self.chip_extra_selected.clear()
1499
+ self.chip_extra_selected.update(all_selected)
1500
+ self.result_table.selected = []
1501
+ self._render_selected_chips()
1502
+ self._update_selection_display(None)
1503
+ self._save_staged_tags()
1504
+
1505
+ self._refresh_related([], show_nsfw_val)
1506
+
1507
+ # 分词筛选 chips
1508
+ self.current_filter_keyword = 'ALL'
1509
+ self.keywords_container.clear()
1510
+ cached_set = set(response.cached_queries) if response.cached_queries else set()
1511
+ with self.keywords_container:
1512
+ ui.label('分词筛选:').classes('text-sm text-gray-500 font-bold mr-2')
1513
+ ui.chip('全部', on_click=lambda: self._filter_by_source('ALL')) \
1514
+ .props('color=primary text-color=white clickable')
1515
+ use_seg = self.input_segment.value if self.input_segment else True
1516
+ if use_seg:
1517
+ whole = ui.chip('整句',
1518
+ on_click=lambda: self._filter_by_source(self.current_query_str))
1519
+ whole.props('color=grey-4 text-color=black clickable')
1520
+ if self.current_query_str in cached_set:
1521
+ whole.style('outline: 1px dashed rgba(128,128,128,0.3); outline-offset: 1px;')
1522
+ for seg in response.segments:
1523
+ sc = ui.chip(seg,
1524
+ on_click=lambda s=seg: self._filter_by_source(s))
1525
+ sc.props('color=blue-1 text-color=blue-8 clickable')
1526
+ if seg in cached_set:
1527
+ sc.style('outline: 1px dashed rgba(128,128,128,0.3); outline-offset: 1px;')
1528
+ for kw in response.keywords:
1529
+ kc = ui.chip(kw,
1530
+ on_click=lambda k=kw: self._filter_by_source(k))
1531
+ kc.props('color=grey-4 text-color=black clickable')
1532
+ if kw in cached_set:
1533
+ kc.style('outline: 1px dashed rgba(128,128,128,0.3); outline-offset: 1px;')
1534
+ else:
1535
+ ui.label('(分词已关闭)').classes('text-xs text-gray-400')
1536
+
1537
+ ui.notify(f'找到 {len(table_data)} 个标签', type='positive')
1538
+ self.current_search_interacted = False
1539
+
1540
+ if self.bad_case_btn is not None:
1541
+ self.bad_case_btn.enable()
1542
 
1543
  except RuntimeError as e:
1544
  if 'deleted' in str(e).lower() or 'client' in str(e).lower():