SAkizuki commited on
Commit
bb334c7
ยท
verified ยท
1 Parent(s): 5bb3b2d

Auto-sync from GitHub Actions

Browse files
Files changed (6) hide show
  1. api_fastapi.py +2 -0
  2. cli.py +9 -0
  3. core/engine.py +175 -11
  4. core/models.py +2 -0
  5. mcp_server.py +9 -0
  6. ui_nicegui.py +229 -30
api_fastapi.py CHANGED
@@ -46,6 +46,8 @@ class SearchIn(BaseModel):
46
  use_segmentation: bool = True
47
  target_layers: list[str] = ['่‹ฑๆ–‡', 'ไธญๆ–‡ๆ‰ฉๅฑ•่ฏ', '้‡Šไน‰', 'ไธญๆ–‡ๆ ธๅฟƒ่ฏ']
48
  target_categories: list[str] = ['General', 'Character', 'Copyright']
 
 
49
 
50
 
51
  class TagOut(BaseModel):
 
46
  use_segmentation: bool = True
47
  target_layers: list[str] = ['่‹ฑๆ–‡', 'ไธญๆ–‡ๆ‰ฉๅฑ•่ฏ', '้‡Šไน‰', 'ไธญๆ–‡ๆ ธๅฟƒ่ฏ']
48
  target_categories: list[str] = ['General', 'Character', 'Copyright']
49
+ group_mode: str = "off"
50
+ max_per_group: int = 2
51
 
52
 
53
  class TagOut(BaseModel):
cli.py CHANGED
@@ -33,6 +33,8 @@ async def cmd_search(args):
33
  use_segmentation=not args.no_seg,
34
  target_layers=args.layers,
35
  target_categories=args.categories,
 
 
36
  )
37
  resp = await asyncio.to_thread(tagger.search, request)
38
 
@@ -61,6 +63,7 @@ async def cmd_related(args):
61
  set(seed_tags), # exclude ็งๅญๆ ‡็ญพ่‡ช่บซ
62
  args.limit,
63
  not args.no_nsfw,
 
64
  )
65
 
66
  if not results:
@@ -114,6 +117,10 @@ async def main():
114
  p_search.add_argument('--weight', type=float, default=0.15, help='็ƒญๅบฆๆƒ้‡๏ผˆ้ป˜่ฎค 0.15๏ผ‰')
115
  p_search.add_argument('--no-nsfw', action='store_true', help='่ฟ‡ๆปค NSFW ๅ†…ๅฎน')
116
  p_search.add_argument('--no-seg', action='store_true', help='็ฆ็”จๆ™บ่ƒฝๅˆ†่ฏ')
 
 
 
 
117
  p_search.add_argument(
118
  '--layers', nargs='+', default=_all_layers,
119
  metavar='LAYER',
@@ -131,6 +138,8 @@ async def main():
131
  p_related.add_argument('--limit', type=int, default=50, help='ๆŽจ่็ป“ๆžœไธŠ้™๏ผˆ้ป˜่ฎค 50๏ผ‰')
132
  p_related.add_argument('--no-nsfw', action='store_true', help='่ฟ‡ๆปค NSFW ๅ†…ๅฎน')
133
  p_related.add_argument('--show-sources', action='store_true', help='ๆ˜พ็คบๆฏๆกๆŽจ่็”ฑๅ“ชไธช็งๅญ่งฆๅ‘')
 
 
134
 
135
  args = parser.parse_args()
136
  if args.cmd == 'search':
 
33
  use_segmentation=not args.no_seg,
34
  target_layers=args.layers,
35
  target_categories=args.categories,
36
+ group_mode=args.group_mode,
37
+ max_per_group=args.max_per_group,
38
  )
39
  resp = await asyncio.to_thread(tagger.search, request)
40
 
 
63
  set(seed_tags), # exclude ็งๅญๆ ‡็ญพ่‡ช่บซ
64
  args.limit,
65
  not args.no_nsfw,
66
+ not args.no_group_expansion,
67
  )
68
 
69
  if not results:
 
117
  p_search.add_argument('--weight', type=float, default=0.15, help='็ƒญๅบฆๆƒ้‡๏ผˆ้ป˜่ฎค 0.15๏ผ‰')
118
  p_search.add_argument('--no-nsfw', action='store_true', help='่ฟ‡ๆปค NSFW ๅ†…ๅฎน')
119
  p_search.add_argument('--no-seg', action='store_true', help='็ฆ็”จๆ™บ่ƒฝๅˆ†่ฏ')
120
+ p_search.add_argument('--group-mode', choices=['off', 'expand', 'diverse'],
121
+ default='off', help='Group ๅค„็†ๆจกๅผ๏ผˆ้ป˜่ฎค off๏ผ‰')
122
+ p_search.add_argument('--max-per-group', type=int, default=2,
123
+ help='diverse ๆจกๅผไธ‹ๆฏไธช group ๆœ€ๅคšไฟ็•™็š„ๆ ‡็ญพๆ•ฐ๏ผˆ้ป˜่ฎค 2๏ผ‰')
124
  p_search.add_argument(
125
  '--layers', nargs='+', default=_all_layers,
126
  metavar='LAYER',
 
138
  p_related.add_argument('--limit', type=int, default=50, help='ๆŽจ่็ป“ๆžœไธŠ้™๏ผˆ้ป˜่ฎค 50๏ผ‰')
139
  p_related.add_argument('--no-nsfw', action='store_true', help='่ฟ‡ๆปค NSFW ๅ†…ๅฎน')
140
  p_related.add_argument('--show-sources', action='store_true', help='ๆ˜พ็คบๆฏๆกๆŽจ่็”ฑๅ“ชไธช็งๅญ่งฆๅ‘')
141
+ p_related.add_argument('--no-group-expansion', action='store_true',
142
+ help='ๅ…ณ้—ญ group ๅŒ็ฑปๆ‰ฉๅฑ•๏ผˆ้ป˜่ฎคๅผ€ๅฏ๏ผ‰')
143
 
144
  args = parser.parse_args()
145
  if args.cmd == 'search':
core/engine.py CHANGED
@@ -163,6 +163,7 @@ class DanbooruTagger:
163
  csv_file: str = 'origin_database/tags_enhanced.csv',
164
  cache_dir: str = 'tags_embedding',
165
  cooc_file: str = 'origin_database/cooccurrence_clean.csv',
 
166
  ):
167
  # ๆจกๅž‹่ทฏๅพ„๏ผšไผ˜ๅ…ˆไฝฟ็”จๆ˜พๅผไผ ๅ…ฅ๏ผŒๅฆๅˆ™ไบค็”ฑ platform_utils ่งฃๆž
168
  self.model_path = model_path or resolve_model_path()
@@ -171,6 +172,7 @@ class DanbooruTagger:
171
  self.device = 'cuda' if torch.cuda.is_available() else 'cpu'
172
  self.paths = _CachePaths(cache_dir)
173
  self.cooc_file = cooc_file
 
174
 
175
  self.model: Optional[SentenceTransformer] = None
176
  self.df: Optional[pd.DataFrame] = None
@@ -181,6 +183,8 @@ class DanbooruTagger:
181
  self.max_log_count: float = 15.0
182
  self.cooc: dict[str, list[tuple[str, int]]] = {}
183
  self._name_to_idx: dict[str, int] = {}
 
 
184
  self.is_loaded: bool = False
185
 
186
  # ้ข„ๆๅ–็š„ๅˆ—ๆ•ฐ็ป„๏ผŒ้ฟๅ…็ƒญ็‚น่ทฏๅพ„ไธŠๅๅคๆ‰ง่กŒ df.iloc[idx]
@@ -238,6 +242,7 @@ class DanbooruTagger:
238
  self._name_to_idx = {n: i for i, n in enumerate(self.df['name'])}
239
  self._rebuild_arrays_from_df()
240
  self._normalize_embeddings()
 
241
  self.is_loaded = True
242
  print(f'[Engine] ๅˆๅง‹ๅŒ–ๅฎŒๆˆ๏ผŒ่€—ๆ—ถ {time.time() - t0:.2f}s')
243
 
@@ -294,6 +299,7 @@ class DanbooruTagger:
294
 
295
  self.csv_path = pull('origin_database/tags_enhanced.csv')
296
  self.cooc_file = pull('origin_database/cooccurrence_clean.parquet')
 
297
 
298
  meta_path = pull('tags_embedding/tags_metadata.parquet')
299
  emb_path = pull('tags_embedding/danbooru_multiview_embeddings.safetensors')
@@ -343,6 +349,8 @@ class DanbooruTagger:
343
  request.use_segmentation,
344
  tuple(sorted(request.target_layers)),
345
  tuple(sorted(request.target_categories)),
 
 
346
  )
347
  cached = self._search_cache.get(cache_key)
348
  if cached is not None:
@@ -450,6 +458,10 @@ class DanbooruTagger:
450
  # coherence=0 ๆ—ถๆœ€ๅคšๆ‰ฃ alpha=15%๏ผ›coherence=1 ๆ—ถไธๆ‰ฃๅˆ†
451
  r.final_score = round(r.final_score * (1.0 - alpha + alpha * max_co), 4)
452
 
 
 
 
 
453
  # ๆ”ถ้›†ๆฏไธชๆŸฅ่ฏขๆบ็š„ top-1 ็ป“ๆžœ๏ผˆ้ซ˜ไบŽ้˜ˆๅ€ผ๏ผ‰
454
  guaranteed_tags: set[str] = set()
455
  for source_word in queries:
@@ -464,11 +476,37 @@ class DanbooruTagger:
464
  # ๅฏนๆ‰€ๆœ‰ๅ€™้€‰่ฟ›่กŒๆŽ’ๅบ๏ผŒ็„ถๅŽๅœจไฟ็•™ไฟ่ฏ็ป“ๆžœ็š„ๅŒๆ—ถๆˆชๆ–ญ่‡ณ้™ๅˆถๆ•ฐ้‡
465
  sorted_results = sorted(final.values(), key=lambda r: r.final_score, reverse=True)
466
  valid: list[TagResult] = []
467
- for r in sorted_results:
468
- if r.final_score <= 0.45:
469
- continue
470
- if len(valid) < request.limit or r.tag in guaranteed_tags:
471
- valid.append(r)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
472
 
473
  tags_all = ', '.join(r.tag for r in valid)
474
  tags_sfw = ', '.join(r.tag for r in valid if r.nsfw != '1')
@@ -479,6 +517,46 @@ class DanbooruTagger:
479
  self._search_cache.put(cache_key, response)
480
  return response
481
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
482
  # โ”€โ”€ ๅ…จ้‡ๆž„ๅปบ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
483
 
484
  def _build_full(self) -> None:
@@ -553,6 +631,7 @@ class DanbooruTagger:
553
  self.max_log_count = float(np.log1p(self.df['post_count'].max()))
554
  self._name_to_idx = {n: i for i, n in enumerate(self.df['name'])}
555
  self._rebuild_arrays_from_df()
 
556
  self._normalize_embeddings()
557
  self._save_cache()
558
  print(f'[Engine] ๅขž้‡ๆ›ดๆ–ฐๅฎŒๆˆ๏ผŒ่€—ๆ—ถ {time.time() - t0:.2f}s๏ผˆๅ…ฑ {len(self.df)} ๆก๏ผ‰')
@@ -812,25 +891,24 @@ class DanbooruTagger:
812
  max_score = max(npmi_scores.values())
813
 
814
  sorted_candidates = sorted(npmi_scores.items(), key=lambda x: x[1], reverse=True)
815
- results = []
 
 
816
 
817
  for tag_name, raw_score in sorted_candidates:
818
  if len(results) >= limit:
819
  break
820
-
821
  idx = name_to_idx[tag_name]
822
  nsfw = self._arr_nsfw[idx]
823
  if nsfw == '1' and not show_nsfw:
824
  continue
825
-
826
  cat = CAT_MAP.get(self._arr_category[idx], 'Other')
827
-
828
  results.append(RelatedTag(
829
  tag=tag_name,
830
  cn_name=str(self._arr_cn_name[idx]),
831
  category=cat,
832
  nsfw=nsfw,
833
- cooc_count=total_cooc[tag_name],
834
  cooc_score=round(raw_score / max_score, 4),
835
  sources=tag_sources.get(tag_name, []),
836
  post_count=int(self._arr_post_count[idx]),
@@ -840,6 +918,61 @@ class DanbooruTagger:
840
  self._related_cache.put(related_key, results)
841
  return results
842
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
843
  def _load_cooc(self) -> None:
844
  csv_path = Path(self.cooc_file)
845
  parquet_path = csv_path.with_suffix('.parquet')
@@ -894,4 +1027,35 @@ class DanbooruTagger:
894
  f'่€—ๆ—ถ {time.time() - t0:.2f}s'
895
  )
896
  except Exception as e:
897
- print(f'[Engine] ๅ…ฑ็Žฐ่กจๅŠ ่ฝฝๅคฑ่ดฅ: {e}')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
163
  csv_file: str = 'origin_database/tags_enhanced.csv',
164
  cache_dir: str = 'tags_embedding',
165
  cooc_file: str = 'origin_database/cooccurrence_clean.csv',
166
+ group_file: str = 'origin_database/tag_groups.json',
167
  ):
168
  # ๆจกๅž‹่ทฏๅพ„๏ผšไผ˜ๅ…ˆไฝฟ็”จๆ˜พๅผไผ ๅ…ฅ๏ผŒๅฆๅˆ™ไบค็”ฑ platform_utils ่งฃๆž
169
  self.model_path = model_path or resolve_model_path()
 
172
  self.device = 'cuda' if torch.cuda.is_available() else 'cpu'
173
  self.paths = _CachePaths(cache_dir)
174
  self.cooc_file = cooc_file
175
+ self.group_file = group_file
176
 
177
  self.model: Optional[SentenceTransformer] = None
178
  self.df: Optional[pd.DataFrame] = None
 
183
  self.max_log_count: float = 15.0
184
  self.cooc: dict[str, list[tuple[str, int]]] = {}
185
  self._name_to_idx: dict[str, int] = {}
186
+ self._tag_to_groups: dict[str, set[str]] = {}
187
+ self._group_to_tags_idx: dict[str, np.ndarray] = {}
188
  self.is_loaded: bool = False
189
 
190
  # ้ข„ๆๅ–็š„ๅˆ—ๆ•ฐ็ป„๏ผŒ้ฟๅ…็ƒญ็‚น่ทฏๅพ„ไธŠๅๅคๆ‰ง่กŒ df.iloc[idx]
 
242
  self._name_to_idx = {n: i for i, n in enumerate(self.df['name'])}
243
  self._rebuild_arrays_from_df()
244
  self._normalize_embeddings()
245
+ self._load_groups()
246
  self.is_loaded = True
247
  print(f'[Engine] ๅˆๅง‹ๅŒ–ๅฎŒๆˆ๏ผŒ่€—ๆ—ถ {time.time() - t0:.2f}s')
248
 
 
299
 
300
  self.csv_path = pull('origin_database/tags_enhanced.csv')
301
  self.cooc_file = pull('origin_database/cooccurrence_clean.parquet')
302
+ self.group_file = pull('origin_database/tag_groups.json')
303
 
304
  meta_path = pull('tags_embedding/tags_metadata.parquet')
305
  emb_path = pull('tags_embedding/danbooru_multiview_embeddings.safetensors')
 
349
  request.use_segmentation,
350
  tuple(sorted(request.target_layers)),
351
  tuple(sorted(request.target_categories)),
352
+ request.group_mode,
353
+ request.max_per_group,
354
  )
355
  cached = self._search_cache.get(cache_key)
356
  if cached is not None:
 
458
  # coherence=0 ๆ—ถๆœ€ๅคšๆ‰ฃ alpha=15%๏ผ›coherence=1 ๆ—ถไธๆ‰ฃๅˆ†
459
  r.final_score = round(r.final_score * (1.0 - alpha + alpha * max_co), 4)
460
 
461
+ # Group expand ๅค„็†๏ผˆๅœจ guaranteed_tags ไน‹ๅ‰๏ผŒๅ› ไธบไผšๆ”นๅˆ†ๆ•ฐ๏ผ‰
462
+ if request.group_mode == "expand" and self._tag_to_groups:
463
+ self._apply_group_expand(final)
464
+
465
  # ๆ”ถ้›†ๆฏไธชๆŸฅ่ฏขๆบ็š„ top-1 ็ป“ๆžœ๏ผˆ้ซ˜ไบŽ้˜ˆๅ€ผ๏ผ‰
466
  guaranteed_tags: set[str] = set()
467
  for source_word in queries:
 
476
  # ๅฏนๆ‰€ๆœ‰ๅ€™้€‰่ฟ›่กŒๆŽ’ๅบ๏ผŒ็„ถๅŽๅœจไฟ็•™ไฟ่ฏ็ป“ๆžœ็š„ๅŒๆ—ถๆˆชๆ–ญ่‡ณ้™ๅˆถๆ•ฐ้‡
477
  sorted_results = sorted(final.values(), key=lambda r: r.final_score, reverse=True)
478
  valid: list[TagResult] = []
479
+
480
+ if request.group_mode == "diverse" and self._tag_to_groups:
481
+ # diverse ๆจกๅผ๏ผšๆฏไธช group ๆœ€ๅคšไฟ็•™ max_per_group ไธชๆ ‡็ญพ
482
+ group_counter: dict[str, int] = {}
483
+ max_per = request.max_per_group
484
+ for r in sorted_results:
485
+ if r.final_score <= 0.45:
486
+ continue
487
+ if r.tag in guaranteed_tags:
488
+ # guaranteed_tags ่ฑๅ… group ไธŠ้™
489
+ valid.append(r)
490
+ continue
491
+ groups = self._tag_to_groups.get(r.tag)
492
+ if not groups:
493
+ # ๆ—  group ไฟกๆฏ๏ผŒไธๅ—้™ๅˆถ
494
+ if len(valid) < request.limit:
495
+ valid.append(r)
496
+ continue
497
+ # ๆฃ€ๆŸฅๆ˜ฏๅฆๆœ‰ไปปไธ€ group ่พพไธŠ้™
498
+ if any(group_counter.get(g, 0) >= max_per for g in groups):
499
+ continue
500
+ if len(valid) < request.limit:
501
+ valid.append(r)
502
+ for g in groups:
503
+ group_counter[g] = group_counter.get(g, 0) + 1
504
+ else:
505
+ for r in sorted_results:
506
+ if r.final_score <= 0.45:
507
+ continue
508
+ if len(valid) < request.limit or r.tag in guaranteed_tags:
509
+ valid.append(r)
510
 
511
  tags_all = ', '.join(r.tag for r in valid)
512
  tags_sfw = ', '.join(r.tag for r in valid if r.nsfw != '1')
 
517
  self._search_cache.put(cache_key, response)
518
  return response
519
 
520
+ def _apply_group_expand(self, final: dict[str, TagResult]) -> None:
521
+ """expand ๆจกๅผ๏ผšๆๅ‡ๅŒ group ๆ ‡็ญพ็š„ๅˆ†ๆ•ฐใ€‚"""
522
+ BETA = 0.2
523
+ TOP_N = 20
524
+
525
+ sorted_items = sorted(final.values(), key=lambda r: r.final_score, reverse=True)
526
+ top_n = min(TOP_N, len(sorted_items))
527
+ anchor_results = sorted_items[:top_n]
528
+
529
+ # ๆ”ถ้›†้”š็‚น็ป“ๆžœๆ‰€ๅฑž็š„ๆ‰€ๆœ‰ group
530
+ active_groups: set[str] = set()
531
+ for r in anchor_results:
532
+ groups = self._tag_to_groups.get(r.tag)
533
+ if groups:
534
+ active_groups.update(groups)
535
+
536
+ if not active_groups:
537
+ return
538
+
539
+ # ้ข„่ฎก็ฎ—ๆฏไธช group ็š„้”š็‚นๆœ€ๅคงๅˆ†
540
+ group_max_score: dict[str, float] = {}
541
+ for g in active_groups:
542
+ group_max_score[g] = max(
543
+ (r.final_score for r in anchor_results
544
+ if g in self._tag_to_groups.get(r.tag, set())),
545
+ default=0.0,
546
+ )
547
+
548
+ # ๅฏนๆ‰€ๆœ‰ๅ€™้€‰ๅบ”็”จ boost
549
+ for r in final.values():
550
+ groups = self._tag_to_groups.get(r.tag)
551
+ if not groups:
552
+ continue
553
+ overlap = groups & active_groups
554
+ if not overlap:
555
+ continue
556
+ best_group_score = max(group_max_score[g] for g in overlap)
557
+ boost = 1.0 + BETA * best_group_score
558
+ r.final_score = round(r.final_score * boost, 4)
559
+
560
  # โ”€โ”€ ๅ…จ้‡ๆž„ๅปบ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
561
 
562
  def _build_full(self) -> None:
 
631
  self.max_log_count = float(np.log1p(self.df['post_count'].max()))
632
  self._name_to_idx = {n: i for i, n in enumerate(self.df['name'])}
633
  self._rebuild_arrays_from_df()
634
+ self._load_groups()
635
  self._normalize_embeddings()
636
  self._save_cache()
637
  print(f'[Engine] ๅขž้‡ๆ›ดๆ–ฐๅฎŒๆˆ๏ผŒ่€—ๆ—ถ {time.time() - t0:.2f}s๏ผˆๅ…ฑ {len(self.df)} ๆก๏ผ‰')
 
891
  max_score = max(npmi_scores.values())
892
 
893
  sorted_candidates = sorted(npmi_scores.items(), key=lambda x: x[1], reverse=True)
894
+
895
+ # โ”€โ”€ ๆž„ๅปบ NPMI ็ป“ๆžœ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
896
+ results: list = []
897
 
898
  for tag_name, raw_score in sorted_candidates:
899
  if len(results) >= limit:
900
  break
 
901
  idx = name_to_idx[tag_name]
902
  nsfw = self._arr_nsfw[idx]
903
  if nsfw == '1' and not show_nsfw:
904
  continue
 
905
  cat = CAT_MAP.get(self._arr_category[idx], 'Other')
 
906
  results.append(RelatedTag(
907
  tag=tag_name,
908
  cn_name=str(self._arr_cn_name[idx]),
909
  category=cat,
910
  nsfw=nsfw,
911
+ cooc_count=total_cooc.get(tag_name, 0),
912
  cooc_score=round(raw_score / max_score, 4),
913
  sources=tag_sources.get(tag_name, []),
914
  post_count=int(self._arr_post_count[idx]),
 
918
  self._related_cache.put(related_key, results)
919
  return results
920
 
921
+ def get_group_candidates(
922
+ self,
923
+ selected_tags: list[str],
924
+ show_nsfw: bool = True,
925
+ ) -> list[dict]:
926
+ """ๆ นๆฎๅทฒ้€‰ๆ ‡็ญพ๏ผŒ่ฟ”ๅ›žๅ€™้€‰ Group ๅŠๅ…ถๆˆๅ‘˜ๆ ‡็ญพใ€‚"""
927
+ if not self._tag_to_groups or not selected_tags:
928
+ return []
929
+
930
+ group_hit_count: dict[str, int] = {}
931
+ for tag_name in selected_tags:
932
+ groups = self._tag_to_groups.get(tag_name)
933
+ if groups:
934
+ for g in groups:
935
+ group_hit_count[g] = group_hit_count.get(g, 0) + 1
936
+
937
+ if not group_hit_count:
938
+ return []
939
+
940
+ selected_set = set(selected_tags)
941
+ sorted_groups = sorted(group_hit_count.items(), key=lambda x: -x[1])
942
+
943
+ results = []
944
+ for group_name, hit_count in sorted_groups:
945
+ member_idxs = self._group_to_tags_idx.get(group_name)
946
+ if member_idxs is None:
947
+ continue
948
+
949
+ tags = []
950
+ for idx in member_idxs:
951
+ tag_name = str(self._arr_name[idx])
952
+ if tag_name in selected_set:
953
+ continue
954
+ nsfw = self._arr_nsfw[idx]
955
+ if nsfw == '1' and not show_nsfw:
956
+ continue
957
+ cat = CAT_MAP.get(self._arr_category[idx], 'Other')
958
+ tags.append({
959
+ 'tag': tag_name,
960
+ 'cn_name': str(self._arr_cn_name[idx]),
961
+ 'category': cat,
962
+ 'nsfw': nsfw,
963
+ 'post_count': int(self._arr_post_count[idx]),
964
+ 'wiki': str(self._arr_wiki[idx]) if self._arr_wiki is not None else '',
965
+ })
966
+
967
+ tags.sort(key=lambda x: -x['post_count'])
968
+ results.append({
969
+ 'group': group_name,
970
+ 'hit_count': hit_count,
971
+ 'tags': tags,
972
+ })
973
+
974
+ return results
975
+
976
  def _load_cooc(self) -> None:
977
  csv_path = Path(self.cooc_file)
978
  parquet_path = csv_path.with_suffix('.parquet')
 
1027
  f'่€—ๆ—ถ {time.time() - t0:.2f}s'
1028
  )
1029
  except Exception as e:
1030
+ print(f'[Engine] ๅ…ฑ็Žฐ่กจๅŠ ่ฝฝๅคฑ่ดฅ: {e}')
1031
+
1032
+ def _load_groups(self) -> None:
1033
+ """ๅŠ ่ฝฝ Tag Group ๆ•ฐๆฎ๏ผŒๆž„ๅปบ tagโ†’group ๅ’Œ groupโ†’idx ็ดขๅผ•ใ€‚"""
1034
+ if not Path(self.group_file).is_file():
1035
+ print('[Engine] ๆœชๆ‰พๅˆฐ Tag Group ๆ•ฐๆฎ๏ผŒgroup ๅŠŸ่ƒฝไธๅฏ็”จใ€‚')
1036
+ return
1037
+
1038
+ with open(self.group_file, 'r', encoding='utf-8') as f:
1039
+ data = json.load(f)
1040
+
1041
+ raw_t2g = data.get('tag_to_groups', {})
1042
+ name_to_idx = self._name_to_idx
1043
+
1044
+ self._tag_to_groups = {}
1045
+ group_members: dict[str, list[int]] = {}
1046
+
1047
+ for tag_name, groups in raw_t2g.items():
1048
+ if tag_name not in name_to_idx:
1049
+ continue
1050
+ group_set = set(groups)
1051
+ self._tag_to_groups[tag_name] = group_set
1052
+ idx = name_to_idx[tag_name]
1053
+ for g in group_set:
1054
+ group_members.setdefault(g, []).append(idx)
1055
+
1056
+ self._group_to_tags_idx = {
1057
+ g: np.array(idxs, dtype=np.int64) for g, idxs in group_members.items()
1058
+ }
1059
+
1060
+ print(f'[Engine] Tag Group loaded, {len(self._tag_to_groups)} tags, '
1061
+ f'{len(self._group_to_tags_idx)} groups')
core/models.py CHANGED
@@ -49,6 +49,8 @@ class SearchRequest:
49
  target_categories: list[str] = field(
50
  default_factory=lambda: ['General', 'Character', 'Copyright']
51
  )
 
 
52
 
53
 
54
  @dataclass
 
49
  target_categories: list[str] = field(
50
  default_factory=lambda: ['General', 'Character', 'Copyright']
51
  )
52
+ group_mode: str = "off" # "off" / "expand" / "diverse"
53
+ max_per_group: int = 2 # ไป… diverse ๆจกๅผ็”Ÿๆ•ˆ
54
 
55
 
56
  @dataclass
mcp_server.py CHANGED
@@ -41,6 +41,8 @@ async def search_tags(
41
  show_nsfw: bool = True,
42
  include_wiki: bool = False,
43
  category: str = "all",
 
 
44
  ) -> str:
45
  """
46
  Search Danbooru tags using natural language and return a ready-to-use prompt.
@@ -60,6 +62,11 @@ Search Danbooru tags using natural language and return a ready-to-use prompt.
60
  "character" โ€” Character: named characters from any series
61
  Use this when you know what kind of tag you need โ€” e.g. looking for a
62
  character name vs. describing a scene visually.
 
 
 
 
 
63
 
64
  ## Query writing guide
65
 
@@ -188,6 +195,8 @@ Each result: tag, cn_name, category, final_score, count[, wiki if include_wiki=T
188
  show_nsfw=show_nsfw,
189
  use_segmentation=use_segmentation,
190
  target_categories=target_categories,
 
 
191
  )
192
  response = await asyncio.to_thread(tagger.search, request)
193
  # ่ฎกๆ•ฐ๏ผšๆฏๆฌก MCP ๆœ็ดข่ฐƒ็”จๅ‡่ฎกๅ…ฅๆœ็ดขใ€ๆˆๅŠŸใ€ๅคๅˆถ๏ผ›่ฎฟ้—ฎไธๅ˜
 
41
  show_nsfw: bool = True,
42
  include_wiki: bool = False,
43
  category: str = "all",
44
+ group_mode: str = "off",
45
+ max_per_group: int = 2,
46
  ) -> str:
47
  """
48
  Search Danbooru tags using natural language and return a ready-to-use prompt.
 
62
  "character" โ€” Character: named characters from any series
63
  Use this when you know what kind of tag you need โ€” e.g. looking for a
64
  character name vs. describing a scene visually.
65
+ - group_mode: Tag group processing mode. Default "off".
66
+ "off" โ€” No group processing (backward compatible)
67
+ "expand" โ€” Boost same-group tags for concept exploration
68
+ "diverse" โ€” Limit tags per group for scene diversity
69
+ - max_per_group: Max tags per group in diverse mode. Default 2.
70
 
71
  ## Query writing guide
72
 
 
195
  show_nsfw=show_nsfw,
196
  use_segmentation=use_segmentation,
197
  target_categories=target_categories,
198
+ group_mode=group_mode,
199
+ max_per_group=max_per_group,
200
  )
201
  response = await asyncio.to_thread(tagger.search, request)
202
  # ่ฎกๆ•ฐ๏ผšๆฏๆฌก MCP ๆœ็ดข่ฐƒ็”จๅ‡่ฎกๅ…ฅๆœ็ดขใ€ๆˆๅŠŸใ€ๅคๅˆถ๏ผ›่ฎฟ้—ฎไธๅ˜
ui_nicegui.py CHANGED
@@ -74,7 +74,16 @@ OPTIONAL_COLS = {
74
 
75
  # localStorage key ไธŽ้…็ฝฎ็‰ˆๆœฌ๏ผŒ็‰ˆๆœฌๅ˜ๆ›ดๆ—ถ่‡ชๅŠจไธขๅผƒๆ—ง้…็ฝฎ
76
  _CONFIG_LS_KEY = 'danbooru_search_config'
77
- _CONFIG_VERSION = 4
 
 
 
 
 
 
 
 
 
78
 
79
 
80
 
@@ -133,6 +142,7 @@ class DanbooruSearchUI:
133
 
134
  self.result_table = None # ๅทฆๆ ่กจๆ ผ
135
  self.related_list_container = None # ๅณๆ ๅ…ณ่”ๆŽจ่ๅˆ—่กจ
 
136
  self.results_section = None # ๆ•ดไธช็ป“ๆžœๅŒบๅŸŸ๏ผˆๆœ็ดขๅ‰้š่—๏ผ‰
137
  self.selection_count_label = None
138
  self.selected_display = None # ๅทฒๅบŸๅผƒ textarea๏ผŒไฟ็•™ๅ…ผๅฎน
@@ -152,6 +162,10 @@ class DanbooruSearchUI:
152
  self.input_weight = None
153
  self.input_nsfw = None
154
  self.input_segment = None
 
 
 
 
155
  self.search_input = None
156
  self.keywords_container = None
157
  self.spinner = None
@@ -252,6 +266,9 @@ class DanbooruSearchUI:
252
  'search_query': self.search_input.value if self.search_input else '',
253
  'notice_expanded': bool(self.notice_expansion.value) if self.notice_expansion else True,
254
  'mcp_notice_dismissed': not bool(self.mcp_notice.visible) if self.mcp_notice else False,
 
 
 
255
  }
256
  js = _json.dumps(cfg, ensure_ascii=False)
257
  ui.run_javascript(f"localStorage.setItem('{_CONFIG_LS_KEY}', {_json.dumps(js)});")
@@ -281,6 +298,10 @@ class DanbooruSearchUI:
281
  ui.run_javascript(f"localStorage.removeItem('{_CONFIG_LS_KEY}');")
282
  return
283
 
 
 
 
 
284
  if self.input_top_k and 'top_k' in cfg:
285
  self.input_top_k.set_value(cfg['top_k'])
286
  if self.input_limit and 'limit' in cfg:
@@ -289,6 +310,10 @@ class DanbooruSearchUI:
289
  self.input_weight.set_value(cfg['popularity_weight'])
290
  if self.input_segment and 'use_segmentation' in cfg:
291
  self.input_segment.set_value(cfg['use_segmentation'])
 
 
 
 
292
 
293
  # NSFW๏ผšไป…ๅœจๅนณๅฐๅ…่ฎธๆ—ถๆขๅค
294
  if nsfw_allowed() and self.input_nsfw and 'show_nsfw' in cfg:
@@ -448,10 +473,10 @@ class DanbooruSearchUI:
448
  if not DanbooruTagger.is_ready():
449
  asyncio.ensure_future(self._hide_banner_when_ready())
450
 
451
- # โ”€โ”€ 1. MCP ไธŠ็บฟ้€š็Ÿฅ โ”€โ”€
452
- self._build_mcp_notice()
453
 
454
- # โ”€โ”€ 2. ๆณจๆ„ไบ‹้กน โ”€โ”€
455
  self._build_notice()
456
 
457
  # โ”€โ”€ 2. ๆœ็ดขๅก็‰‡ โ”€โ”€
@@ -478,31 +503,34 @@ class DanbooruSearchUI:
478
 
479
  # โ”€โ”€ MCP ไธŠ็บฟ้€š็Ÿฅ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
480
 
481
- def _build_mcp_notice(self):
482
  self.mcp_notice = ui.card().classes(
483
  'w-full bg-green-50 border-l-4 border-green-500 p-0 overflow-hidden'
484
  )
485
  with self.mcp_notice:
486
- with ui.column().classes('px-4 py-3 w-full gap-1'):
487
  with ui.row().classes('items-center justify-between w-full'):
488
- ui.label('๐ŸŽ‰ MCP ๆœๅŠกๆญฃๅผไธŠ็บฟ๏ผ').classes('text-base font-bold text-green-800')
489
  ui.button(icon='close').props('flat dense round color=grey-6') \
490
  .on_click(self._dismiss_mcp_notice)
491
  ui.html(
492
- 'ๆœฌ็ซ™็Žฐๅทฒๆ”ฏๆŒ้€š่ฟ‡ MCP ๅ่ฎฎๆŽฅๅ…ฅ AI Agent๏ผˆๅฆ‚ Claude Desktop๏ผ‰ใ€‚'
493
- 'ๅฆ‚้œ€ไฝ“้ชŒ<b>ๆ‰˜็ฎก็‰ˆ</b>๏ผˆๅ…้…็ฝฎใ€ๅผ€็ฎฑๅณ็”จ๏ผ‰๏ผŒ่ฏท่ฎฟ้—ฎ '
 
 
 
 
 
 
494
  '<a href="https://huggingface.co/spaces/SAkizuki/WenQiuYue" '
495
  'target="_blank" rel="noopener noreferrer" '
496
  'class="text-green-700 font-bold underline">้—ฎ็ง‹ๆœˆ Space</a>๏ผŒ'
497
- '่ฟ›ๅ…ฅๅŽ็‚นๅ‡ปใ€Œๆœๆ ‡็ญพใ€ๅณๅฏ่ฐƒ็”จใ€‚'
498
- '<span class="text-gray-500 ml-1">'
499
- 'API ้ขๅบฆๆœ‰้™๏ผˆ็บฆ 30 ๅ…ƒ๏ผ‰๏ผŒ็”จๅฎŒๅณๆญข๏ผŒไป…ไพ›ไฝ“้ชŒใ€‚'
500
- '</span>'
501
- '&nbsp;&nbsp;'
502
  '<a href="https://github.com/SuzumiyaAkizuki/DanbooruSearchOnline#mcp-ๆŽฅๅฃ" '
503
  'target="_blank" rel="noopener noreferrer" '
504
- 'class="text-green-700 underline">ๆŸฅ็œ‹ๆŽฅๅ…ฅๆ–‡ๆกฃ โ†’</a>'
505
- ).classes('text-sm text-green-900')
506
 
507
  def _dismiss_mcp_notice(self):
508
  if self.mcp_notice:
@@ -561,21 +589,33 @@ class DanbooruSearchUI:
561
  self.spinner = ui.spinner(size='2em').classes('hidden')
562
 
563
  with ui.row().classes('w-full gap-6 items-center mt-3 flex-wrap'):
 
 
 
 
 
 
 
 
 
564
  with ui.row().classes('items-center gap-2'):
565
  ui.label('Top K (่ฏญไน‰็›ธๅ…ณ)').classes('text-sm text-gray-600')
566
  self.input_top_k = ui.number(value=10, min=1, max=200).classes('w-20') \
567
  .props('outlined dense')
 
568
 
569
  with ui.row().classes('items-center gap-2'):
570
  ui.label('็ป“ๆžœไธŠ้™').classes('text-sm text-gray-600')
571
  self.input_limit = ui.number(value=80, min=10, max=500).classes('w-20') \
572
  .props('outlined dense')
 
573
 
574
  with ui.row().classes('items-center gap-2'):
575
  ui.label('็ƒญๅบฆๆƒ้‡').classes('text-sm text-gray-600')
576
  self.input_weight = ui.slider(min=0.0, max=1.0, value=0.15, step=0.05).classes('w-32')
577
  ui.label().bind_text_from(self.input_weight, 'value', lambda v: f"{v:.2f}") \
578
  .classes('text-sm font-mono text-gray-700 w-8')
 
579
 
580
  with ui.switch('ๆ˜พ็คบ NSFW(ๆˆไบบ) ๅ†…ๅฎน', value=False).props('color=red') as _nsfw_sw:
581
  if not nsfw_allowed():
@@ -591,6 +631,7 @@ class DanbooruSearchUI:
591
  with ui.tooltip().props('content-class="bg-black text-white shadow-4"'):
592
  ui.label('ๅ…ณ้—ญๅŽ็ณป็ปŸๅฐ†ๅชๅŒน้…ๅฎŒๆ•ดๅฅๅญ๏ผŒ้€‚็”จไบŽ็ฒพๅ‡†ๆœ็ดขๆ•ดๅฅใ€‚').style('font-size:14px;')
593
  self.input_segment = _seg_sw
 
594
 
595
  with ui.expansion('้ซ˜็บง้€‰้กน', icon='tune').classes('w-full mt-2'):
596
  with ui.column().classes('w-full p-3 gap-4'):
@@ -632,6 +673,21 @@ class DanbooruSearchUI:
632
  self.sw_layer.on('update:model-value', self._update_table_columns)
633
  self.sw_source.on('update:model-value', self._update_table_columns)
634
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
635
  # โ”€โ”€ ๅทฒ้€‰ๆ ‡็ญพๆ  โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
636
 
637
  def _build_selection_bar(self):
@@ -797,6 +853,11 @@ class DanbooruSearchUI:
797
  self.selection_count_label.text = '0'
798
  show_nsfw_val = self.input_nsfw.value
799
  self._refresh_related([], show_nsfw_val)
 
 
 
 
 
800
  self._save_staged_tags()
801
  ui.notify('ๅทฒๆธ…็ฉบๆ‰€ๆœ‰ๅทฒ้€‰ๆ ‡็ญพ', type='warning')
802
 
@@ -901,6 +962,17 @@ class DanbooruSearchUI:
901
  with self.related_list_container:
902
  ui.label('่ฏทๅ…ˆๆœ็ดขๅนถๅ‹พ้€‰ๆ ‡็ญพโ€ฆ').classes('text-sm text-gray-400 italic p-4')
903
 
 
 
 
 
 
 
 
 
 
 
 
904
  # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
905
  # ๆธฒๆŸ“ๅ…ณ่”ๆŽจ่ๅˆ—่กจ
906
  # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
@@ -934,7 +1006,9 @@ class DanbooruSearchUI:
934
  except Exception:
935
  pass
936
 
937
- sources_str = 'ใ€'.join(r.sources) if r.sources else 'โ€”'
 
 
938
  CAT_LABEL = {'General': '้€š็”จ', 'Character': '่ง’่‰ฒ', 'Copyright': 'ไฝœๅ“'}
939
  cat_label = CAT_LABEL.get(r.category, '')
940
  tooltip_html = ''
@@ -975,12 +1049,18 @@ class DanbooruSearchUI:
975
 
976
  # ๆ ‡็ญพๅ๏ผˆๅฏ็‚นๅ‡ป่ทณ่ฝฌ๏ผ‰+ ไธญๆ–‡ๅ
977
  with ui.column().classes('flex-grow gap-0 min-w-0'):
978
- link = ui.link(
979
- tag,
980
- f'https://danbooru.donmai.us/wiki_pages/{tag}',
981
- new_tab=True
982
- ).classes('tag-link text-primary font-bold text-xs')
983
- link.on('click', self._mark_interaction)
 
 
 
 
 
 
984
 
985
  if cn_first:
986
  ui.label(cn_first).classes('text-xs text-gray-500 truncate')
@@ -1087,6 +1167,8 @@ class DanbooruSearchUI:
1087
  use_segmentation=self.input_segment.value if self.input_segment else True,
1088
  target_layers=target_layers_list,
1089
  target_categories=target_cats_list,
 
 
1090
  )
1091
  response = await run.io_bound(tagger.search, request)
1092
 
@@ -1264,6 +1346,8 @@ class DanbooruSearchUI:
1264
  # โ”€โ”€ ๅ…ณ่”ๆŽจ่ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1265
 
1266
  def _refresh_related(self, related: list, show_nsfw: bool):
 
 
1267
  selected_now = set(self._get_selected_tags())
1268
  old_related = self.current_related
1269
  new_tags = {r.tag for r in related}
@@ -1285,8 +1369,94 @@ class DanbooruSearchUI:
1285
  show_nsfw,
1286
  )
1287
  self._refresh_related(related, show_nsfw)
 
 
 
 
 
 
 
 
1288
  asyncio.ensure_future(_do())
1289
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1290
  # โ”€โ”€ ่กจๆ ผๅˆ—ๅŠจๆ€ๆ›ดๆ–ฐ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1291
 
1292
  def _update_table_columns(self, e=None):
@@ -1299,6 +1469,35 @@ class DanbooruSearchUI:
1299
  cols.append(OPTIONAL_COLS['source'])
1300
  self.result_table.columns = cols
1301
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1302
  # โ”€โ”€ NSFW ๅˆ‡ๆข โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1303
 
1304
  def on_nsfw_toggle(self, e):
@@ -1443,17 +1642,17 @@ if __name__ in {'__main__', '__mp_main__'}:
1443
  @app.get('/robots.txt')
1444
  def robots_txt():
1445
  content = (
1446
- "User-agent: *\n"
1447
- "Allow: /$\n"
1448
- "Disallow: /api/\n"
1449
- "Disallow: /_nicegui/\n"
1450
- "Disallow: /socket.io/\n"
1451
  )
1452
  return PlainTextResponse(content)
1453
 
1454
  @app.head('/')
1455
  async def head_root():
1456
- return PlainTextResponse("")
1457
 
1458
  ui.run(
1459
  host=host,
@@ -1462,4 +1661,4 @@ if __name__ in {'__main__', '__mp_main__'}:
1462
  reload=not is_cloud(),
1463
  show=not is_cloud(),
1464
  reconnect_timeout=120,
1465
- )
 
74
 
75
  # localStorage key ไธŽ้…็ฝฎ็‰ˆๆœฌ๏ผŒ็‰ˆๆœฌๅ˜ๆ›ดๆ—ถ่‡ชๅŠจไธขๅผƒๆ—ง้…็ฝฎ
76
  _CONFIG_LS_KEY = 'danbooru_search_config'
77
+ _CONFIG_VERSION = 5
78
+
79
+ # ๆœ็ดขๆจกๅผ้ข„่ฎพ
80
+ _SEARCH_MODE_PRESETS: dict[str, dict] = {
81
+ '็ฒพ็กฎๆŸฅ่ฏ': {'top_k': 20, 'limit': 10, 'popularity_weight': 0.15, 'use_segmentation': False, 'group_mode': 'off', 'max_per_group': 2},
82
+ 'ๆฆ‚ๅฟตๆ‰ฉๅฑ•': {'top_k': 80, 'limit': 80, 'popularity_weight': 0.15, 'use_segmentation': True, 'group_mode': 'expand', 'max_per_group': 2},
83
+ 'ๆ่ฟฐๆŸฅ่ฏ': {'top_k': 20, 'limit': 20, 'popularity_weight': 0.15, 'use_segmentation': False, 'group_mode': 'off', 'max_per_group': 2},
84
+ 'ๅฎŒๆ•ดๅœบๆ™ฏ': {'top_k': 5, 'limit': 80, 'popularity_weight': 0.15, 'use_segmentation': True, 'group_mode': 'diverse', 'max_per_group': 2},
85
+ }
86
+ _SEARCH_MODE_OPTIONS = ['่‡ชๅฎšไน‰'] + list(_SEARCH_MODE_PRESETS.keys())
87
 
88
 
89
 
 
142
 
143
  self.result_table = None # ๅทฆๆ ่กจๆ ผ
144
  self.related_list_container = None # ๅณๆ ๅ…ณ่”ๆŽจ่ๅˆ—่กจ
145
+ self.group_expansion_container = None # ๅณๆ  Group ๅŒ็ฑปๆ‰ฉๅฑ•
146
  self.results_section = None # ๆ•ดไธช็ป“ๆžœๅŒบๅŸŸ๏ผˆๆœ็ดขๅ‰้š่—๏ผ‰
147
  self.selection_count_label = None
148
  self.selected_display = None # ๅทฒๅบŸๅผƒ textarea๏ผŒไฟ็•™ๅ…ผๅฎน
 
162
  self.input_weight = None
163
  self.input_nsfw = None
164
  self.input_segment = None
165
+ self.input_search_mode = None
166
+ self.input_group_mode = None
167
+ self.input_max_per_group = None
168
+ self._applying_preset = False
169
  self.search_input = None
170
  self.keywords_container = None
171
  self.spinner = None
 
266
  'search_query': self.search_input.value if self.search_input else '',
267
  'notice_expanded': bool(self.notice_expansion.value) if self.notice_expansion else True,
268
  'mcp_notice_dismissed': not bool(self.mcp_notice.visible) if self.mcp_notice else False,
269
+ 'search_mode': self.input_search_mode.value if self.input_search_mode else '่‡ชๅฎšไน‰',
270
+ 'group_mode': self.input_group_mode.value if self.input_group_mode else 'off',
271
+ 'max_per_group': int(self.input_max_per_group.value) if self.input_max_per_group else 2,
272
  }
273
  js = _json.dumps(cfg, ensure_ascii=False)
274
  ui.run_javascript(f"localStorage.setItem('{_CONFIG_LS_KEY}', {_json.dumps(js)});")
 
298
  ui.run_javascript(f"localStorage.removeItem('{_CONFIG_LS_KEY}');")
299
  return
300
 
301
+ # ๆขๅคๆœ็ดขๆจกๅผ๏ผˆไผš่งฆๅ‘้ข„่ฎพๅกซๅ……๏ผŒไฝ† _applying_preset ้˜ฒๆญข่”ๅŠจ่ฆ†็›–๏ผ‰
302
+ if self.input_search_mode and 'search_mode' in cfg:
303
+ self.input_search_mode.set_value(cfg['search_mode'])
304
+
305
  if self.input_top_k and 'top_k' in cfg:
306
  self.input_top_k.set_value(cfg['top_k'])
307
  if self.input_limit and 'limit' in cfg:
 
310
  self.input_weight.set_value(cfg['popularity_weight'])
311
  if self.input_segment and 'use_segmentation' in cfg:
312
  self.input_segment.set_value(cfg['use_segmentation'])
313
+ if self.input_group_mode and 'group_mode' in cfg:
314
+ self.input_group_mode.set_value(cfg['group_mode'])
315
+ if self.input_max_per_group and 'max_per_group' in cfg:
316
+ self.input_max_per_group.set_value(cfg['max_per_group'])
317
 
318
  # NSFW๏ผšไป…ๅœจๅนณๅฐๅ…่ฎธๆ—ถๆขๅค
319
  if nsfw_allowed() and self.input_nsfw and 'show_nsfw' in cfg:
 
473
  if not DanbooruTagger.is_ready():
474
  asyncio.ensure_future(self._hide_banner_when_ready())
475
 
476
+ # โ”€โ”€ 0. ๅ…ฌๅ‘Šๆ ๏ผˆๅŒ็ฑปๆ ‡็ญพ + MCP๏ผ‰โ”€โ”€
477
+ self._build_group_notice()
478
 
479
+ # โ”€โ”€ 1. ๆณจๆ„ไบ‹้กน โ”€โ”€
480
  self._build_notice()
481
 
482
  # โ”€โ”€ 2. ๆœ็ดขๅก็‰‡ โ”€โ”€
 
503
 
504
  # โ”€โ”€ MCP ไธŠ็บฟ้€š็Ÿฅ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
505
 
506
+ def _build_group_notice(self):
507
  self.mcp_notice = ui.card().classes(
508
  'w-full bg-green-50 border-l-4 border-green-500 p-0 overflow-hidden'
509
  )
510
  with self.mcp_notice:
511
+ with ui.column().classes('px-4 py-3 w-full gap-2'):
512
  with ui.row().classes('items-center justify-between w-full'):
513
+ ui.label('๐Ÿงช ๆ–ฐๅŠŸ่ƒฝ๏ผšๅŒ็ฑปๆ ‡็ญพ๏ฟฝ๏ฟฝ๏ฟฝๅฑ•๏ผˆbeta๏ผ‰').classes('text-sm font-bold text-green-800')
514
  ui.button(icon='close').props('flat dense round color=grey-6') \
515
  .on_click(self._dismiss_mcp_notice)
516
  ui.html(
517
+ 'ๅ‹พ้€‰ๆ ‡็ญพๅŽ๏ผŒๅณไพง้ขๆฟๅบ•้ƒจไผšๅ‡บ็Žฐ<b>ๅŒ็ฑปๆ ‡็ญพ</b>ๅŒบๅŸŸ๏ผŒ'
518
+ 'ๅฑ•็คบๅทฒ้€‰ๆ ‡็ญพๆ‰€ๅฑžๅˆ†็ป„ไธญ็š„ๅ…ถไป–ๆ ‡็ญพใ€‚<b>็‚นๅ‡ป่‰ฒๅ—</b>ๅณๅฏ้€‰ไธญ/ๅ–ๆถˆ๏ผŒ'
519
+ '้€‰ไธญ็š„ๆ ‡็ญพไผšๅŠ ๅ…ฅๅทฒ้€‰ๅˆ—่กจ๏ผŒๅฏ็›ดๆŽฅๅคๅˆถไธบ Promptใ€‚'
520
+ ).classes('text-xs text-green-900')
521
+ ui.separator().classes('my-1')
522
+ ui.html(
523
+ 'MCP ๆœๅŠกๅทฒไธŠ็บฟ โ€” ๆ”ฏๆŒ้€š่ฟ‡ MCP ๅ่ฎฎๆŽฅๅ…ฅ AI Agent๏ผˆๅฆ‚ Claude Desktop๏ผ‰ใ€‚'
524
+ 'ๆ‰˜็ฎก็‰ˆไฝ“้ชŒ โ†’ '
525
  '<a href="https://huggingface.co/spaces/SAkizuki/WenQiuYue" '
526
  'target="_blank" rel="noopener noreferrer" '
527
  'class="text-green-700 font-bold underline">้—ฎ็ง‹ๆœˆ Space</a>๏ผŒ'
528
+ '<span class="text-gray-500 ml-1">API ้ขๅบฆๆœ‰้™๏ผŒไป…ไพ›ไฝ“้ชŒใ€‚</span>'
529
+ '&nbsp;'
 
 
 
530
  '<a href="https://github.com/SuzumiyaAkizuki/DanbooruSearchOnline#mcp-ๆŽฅๅฃ" '
531
  'target="_blank" rel="noopener noreferrer" '
532
+ 'class="text-green-700 underline">ๆŽฅๅ…ฅๆ–‡ๆกฃ โ†’</a>'
533
+ ).classes('text-xs text-green-900')
534
 
535
  def _dismiss_mcp_notice(self):
536
  if self.mcp_notice:
 
589
  self.spinner = ui.spinner(size='2em').classes('hidden')
590
 
591
  with ui.row().classes('w-full gap-6 items-center mt-3 flex-wrap'):
592
+ with ui.row().classes('items-center gap-2'):
593
+ ui.label('ๆœ็ดขๆจกๅผ (beta)').classes('text-sm text-gray-600')
594
+ self.input_search_mode = ui.select(
595
+ _SEARCH_MODE_OPTIONS, value='่‡ชๅฎšไน‰',
596
+ ).classes('w-28').props('outlined dense')
597
+ self.input_search_mode.on('update:model-value', self._on_search_mode_change)
598
+ with ui.tooltip().props('content-class="bg-black text-white shadow-4"'):
599
+ ui.label('้€‰ๆ‹ฉๆจกๅผ่‡ชๅŠจๅกซๅ……ๅฏนๅบ”ๅ‚ๆ•ฐ๏ผ›ๆ‰‹ๅŠจไฟฎๆ”นๅ‚ๆ•ฐๅŽ่‡ชๅŠจๅ˜ไธบใ€Œ่‡ชๅฎšไน‰ใ€').style('font-size:14px;')
600
+
601
  with ui.row().classes('items-center gap-2'):
602
  ui.label('Top K (่ฏญไน‰็›ธๅ…ณ)').classes('text-sm text-gray-600')
603
  self.input_top_k = ui.number(value=10, min=1, max=200).classes('w-20') \
604
  .props('outlined dense')
605
+ self.input_top_k.on('update:model-value', self._on_param_changed)
606
 
607
  with ui.row().classes('items-center gap-2'):
608
  ui.label('็ป“ๆžœไธŠ้™').classes('text-sm text-gray-600')
609
  self.input_limit = ui.number(value=80, min=10, max=500).classes('w-20') \
610
  .props('outlined dense')
611
+ self.input_limit.on('update:model-value', self._on_param_changed)
612
 
613
  with ui.row().classes('items-center gap-2'):
614
  ui.label('็ƒญๅบฆๆƒ้‡').classes('text-sm text-gray-600')
615
  self.input_weight = ui.slider(min=0.0, max=1.0, value=0.15, step=0.05).classes('w-32')
616
  ui.label().bind_text_from(self.input_weight, 'value', lambda v: f"{v:.2f}") \
617
  .classes('text-sm font-mono text-gray-700 w-8')
618
+ self.input_weight.on('update:model-value', self._on_param_changed)
619
 
620
  with ui.switch('ๆ˜พ็คบ NSFW(ๆˆไบบ) ๅ†…ๅฎน', value=False).props('color=red') as _nsfw_sw:
621
  if not nsfw_allowed():
 
631
  with ui.tooltip().props('content-class="bg-black text-white shadow-4"'):
632
  ui.label('ๅ…ณ้—ญๅŽ็ณป็ปŸๅฐ†ๅชๅŒน้…ๅฎŒๆ•ดๅฅๅญ๏ผŒ้€‚็”จไบŽ็ฒพๅ‡†ๆœ็ดขๆ•ดๅฅใ€‚').style('font-size:14px;')
633
  self.input_segment = _seg_sw
634
+ self.input_segment.on('update:model-value', self._on_param_changed)
635
 
636
  with ui.expansion('้ซ˜็บง้€‰้กน', icon='tune').classes('w-full mt-2'):
637
  with ui.column().classes('w-full p-3 gap-4'):
 
673
  self.sw_layer.on('update:model-value', self._update_table_columns)
674
  self.sw_source.on('update:model-value', self._update_table_columns)
675
 
676
+ with ui.column().classes('gap-2'):
677
+ ui.label('ๆ ‡็ญพๅˆ†็ป„ๆจกๅผ (beta)').classes('font-bold text-sm text-gray-700')
678
+ self.input_group_mode = ui.select(
679
+ ['off', 'expand', 'diverse'], value='off',
680
+ ).classes('w-40').props('outlined dense')
681
+ with ui.tooltip().props('content-class="bg-black text-white shadow-4"'):
682
+ ui.label('off=ๅ…ณ้—ญ | expand=ๅŒ็ฑปๅฌๅ›žๅขžๅผบ | diverse=ๅคšๆ ทๆ€ง็บฆๆŸ').style('font-size:14px;')
683
+ self.input_group_mode.on('update:model-value', self._on_param_changed)
684
+
685
+ self.input_max_per_group = ui.number(
686
+ value=2, min=1, max=10,
687
+ ).classes('w-20').props('outlined dense')
688
+ ui.label('ๆฏ็ป„ๆœ€ๅคงๆ ‡็ญพๆ•ฐ๏ผˆdiverse ๆจกๅผ๏ผ‰').classes('text-xs text-gray-500')
689
+ self.input_max_per_group.on('update:model-value', self._on_param_changed)
690
+
691
  # โ”€โ”€ ๅทฒ้€‰ๆ ‡็ญพๆ  โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
692
 
693
  def _build_selection_bar(self):
 
853
  self.selection_count_label.text = '0'
854
  show_nsfw_val = self.input_nsfw.value
855
  self._refresh_related([], show_nsfw_val)
856
+ # ๆธ…็ฉบ Group ๅŒ็ฑปๆ‰ฉๅฑ•
857
+ if self.group_expansion_container is not None:
858
+ self.group_expansion_container.clear()
859
+ with self.group_expansion_container:
860
+ ui.label('่ฏทๅ…ˆๆœ็ดขๅนถๅ‹พ้€‰ๆ ‡็ญพโ€ฆ').classes('text-sm text-gray-400 italic p-4')
861
  self._save_staged_tags()
862
  ui.notify('ๅทฒๆธ…็ฉบๆ‰€ๆœ‰ๅทฒ้€‰ๆ ‡็ญพ', type='warning')
863
 
 
962
  with self.related_list_container:
963
  ui.label('่ฏทๅ…ˆๆœ็ดขๅนถๅ‹พ้€‰ๆ ‡็ญพโ€ฆ').classes('text-sm text-gray-400 italic p-4')
964
 
965
+ # โ”€โ”€ Group ๅŒ็ฑปๆ‰ฉๅฑ•๏ผˆๆŠ˜ๅ ๅŒบๅŸŸ๏ผ‰โ”€โ”€
966
+ ui.separator().classes('my-2')
967
+ with ui.row().classes('items-center gap-2 mb-1 w-full'):
968
+ ui.label('ๅŒ็ฑปๆ ‡็ญพ (beta)').classes('font-bold text-sm text-gray-600')
969
+ with ui.icon('info_outline', size='xs', color='grey').classes('cursor-help'):
970
+ with ui.tooltip().props('content-class="bg-black text-white shadow-4"'):
971
+ ui.label('ๅŸบไบŽๆ ‡็ญพๅˆ†็ป„ๆ•ฐๆฎ๏ผŒๅฑ•็คบๅทฒ้€‰ๆ ‡็ญพๆ‰€ๅฑžๅˆ†็ป„ไธญ็š„ๅ…ถไป–ๆ ‡็ญพใ€‚็‚นๅ‡ปๅฑ•ๅผ€ๆŸฅ็œ‹ใ€‚').style('font-size:14px;')
972
+ self.group_expansion_container = ui.column().classes('w-full gap-0')
973
+ with self.group_expansion_container:
974
+ ui.label('่ฏทๅ…ˆๆœ็ดขๅนถๅ‹พ้€‰ๆ ‡็ญพโ€ฆ').classes('text-sm text-gray-400 italic p-4')
975
+
976
  # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
977
  # ๆธฒๆŸ“ๅ…ณ่”ๆŽจ่ๅˆ—่กจ
978
  # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
 
1006
  except Exception:
1007
  pass
1008
 
1009
+ sources_str = 'ใ€'.join(
1010
+ s.replace('tag_group:', '') for s in r.sources
1011
+ ) if r.sources else 'โ€”'
1012
  CAT_LABEL = {'General': '้€š็”จ', 'Character': '่ง’่‰ฒ', 'Copyright': 'ไฝœๅ“'}
1013
  cat_label = CAT_LABEL.get(r.category, '')
1014
  tooltip_html = ''
 
1049
 
1050
  # ๆ ‡็ญพๅ๏ผˆๅฏ็‚นๅ‡ป่ทณ่ฝฌ๏ผ‰+ ไธญๆ–‡ๅ
1051
  with ui.column().classes('flex-grow gap-0 min-w-0'):
1052
+ with ui.row().classes('items-center gap-1'):
1053
+ link = ui.link(
1054
+ tag,
1055
+ f'https://danbooru.donmai.us/wiki_pages/{tag}',
1056
+ new_tab=True
1057
+ ).classes('tag-link text-primary font-bold text-xs')
1058
+ link.on('click', self._mark_interaction)
1059
+ if r.sources and r.sources[0].startswith('tag_group:'):
1060
+ group_display = r.sources[0].replace('tag_group:', '')
1061
+ ui.label(group_display).classes(
1062
+ 'text-xs text-orange-500 font-bold bg-orange-50 px-1 rounded'
1063
+ )
1064
 
1065
  if cn_first:
1066
  ui.label(cn_first).classes('text-xs text-gray-500 truncate')
 
1167
  use_segmentation=self.input_segment.value if self.input_segment else True,
1168
  target_layers=target_layers_list,
1169
  target_categories=target_cats_list,
1170
+ group_mode=self.input_group_mode.value if self.input_group_mode else 'off',
1171
+ max_per_group=int(self.input_max_per_group.value) if self.input_max_per_group else 2,
1172
  )
1173
  response = await run.io_bound(tagger.search, request)
1174
 
 
1346
  # โ”€โ”€ ๅ…ณ่”ๆŽจ่ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1347
 
1348
  def _refresh_related(self, related: list, show_nsfw: bool):
1349
+ if related is None:
1350
+ related = []
1351
  selected_now = set(self._get_selected_tags())
1352
  old_related = self.current_related
1353
  new_tags = {r.tag for r in related}
 
1369
  show_nsfw,
1370
  )
1371
  self._refresh_related(related, show_nsfw)
1372
+ # ๅผ‚ๆญฅๅŠ ่ฝฝ Group ๅŒ็ฑปๆ‰ฉๅฑ•
1373
+ if selected_tags:
1374
+ group_data = await run.io_bound(
1375
+ tagger.get_group_candidates,
1376
+ selected_tags,
1377
+ show_nsfw,
1378
+ )
1379
+ self._render_group_expansion(group_data, selected_tags, show_nsfw)
1380
  asyncio.ensure_future(_do())
1381
 
1382
+ def _render_group_expansion(self, group_data: list, selected_tags: list[str], show_nsfw: bool):
1383
+ """ๆธฒๆŸ“ Group ๅŒ็ฑปๆ‰ฉๅฑ•ๅŒบๅŸŸใ€‚"""
1384
+ if self.group_expansion_container is None:
1385
+ return
1386
+ self.group_expansion_container.clear()
1387
+
1388
+ if not group_data:
1389
+ with self.group_expansion_container:
1390
+ ui.label('ๅทฒ้€‰ๆ ‡็ญพๆ— ๅˆ†็ป„ไฟกๆฏ').classes('text-sm text-gray-400 italic p-2')
1391
+ return
1392
+
1393
+ # ๅˆ†็ฑป่‰ฒ่กจ๏ผš(่ƒŒๆ™ฏ่‰ฒ, ้€‰ไธญๅŽๆทฑ่‰ฒ, ๆ–‡ๅญ—่‰ฒ)
1394
+ CAT_STYLE = {
1395
+ 'General': ('rgba(59,130,246,0.12)', 'rgba(59,130,246,0.30)', 'rgb(37,99,235)'),
1396
+ 'Character': ('rgba(34,197,94,0.12)', 'rgba(34,197,94,0.30)', 'rgb(22,163,74)'),
1397
+ 'Copyright': ('rgba(236,72,153,0.12)', 'rgba(236,72,153,0.30)', 'rgb(219,39,119)'),
1398
+ }
1399
+ default_style = ('rgba(107,114,128,0.12)', 'rgba(107,114,128,0.30)', 'rgb(55,65,81)')
1400
+
1401
+ with self.group_expansion_container:
1402
+ for group_info in group_data:
1403
+ group_name = group_info['group']
1404
+ tags = group_info['tags']
1405
+ display_name = group_name.replace('tag_group:', '')
1406
+
1407
+ with ui.expansion(
1408
+ f'{display_name} ({len(tags)} ไธชๆ ‡็ญพ)',
1409
+ icon='label',
1410
+ ).classes('w-full').props('dense'):
1411
+ with ui.element('div').classes('w-full grid grid-cols-2 gap-1 p-1').style('max-height: 600px; overflow-y: auto;'):
1412
+ for t in tags:
1413
+ tag = t['tag']
1414
+ cn_first = t['cn_name'].split(',')[0].strip() if t['cn_name'] else ''
1415
+ cat = t['category']
1416
+ wiki_text = str(t.get('wiki', ''))
1417
+ bg_normal, bg_selected, text_color = CAT_STYLE.get(cat, default_style)
1418
+
1419
+ # ่‰ฒๅ—ๅฎนๅ™จ
1420
+ chip = ui.element('div').classes(
1421
+ 'rounded cursor-pointer transition-all duration-150 px-2 py-1.5'
1422
+ ).style(f'background-color: {bg_normal}; color: {text_color};')
1423
+
1424
+ with chip:
1425
+ with ui.row().classes('items-center gap-1 w-full'):
1426
+ ui.label(tag).classes('text-xs font-bold truncate flex-grow')
1427
+ count = t['post_count']
1428
+ if count > 0:
1429
+ if count >= 10000:
1430
+ count_str = f'{count/1000:.0f}k'
1431
+ elif count >= 1000:
1432
+ count_str = f'{count/1000:.1f}k'
1433
+ else:
1434
+ count_str = str(count)
1435
+ ui.label(count_str).classes('text-xs opacity-60')
1436
+ if cn_first:
1437
+ ui.label(cn_first).classes('text-xs opacity-70 truncate block')
1438
+
1439
+ # Wiki tooltip
1440
+ if wiki_text:
1441
+ with chip:
1442
+ with ui.tooltip().props('content-class="bg-black text-white shadow-4" max-width="400px"'):
1443
+ ui.html(wiki_text).style('font-size:13px;line-height:1.4;max-width:380px;')
1444
+
1445
+ # ็‚นๅ‡ป้€‰ไธญ/ๅ–ๆถˆ
1446
+ is_selected = [False]
1447
+
1448
+ def _toggle(e, _chip=chip, _tag=tag, _bg_n=bg_normal, _bg_s=bg_selected):
1449
+ if is_selected[0]:
1450
+ _chip.style(f'background-color: {_bg_n};')
1451
+ self.chip_extra_selected.discard(_tag)
1452
+ else:
1453
+ _chip.style(f'background-color: {_bg_s};')
1454
+ self.chip_extra_selected.add(_tag)
1455
+ is_selected[0] = not is_selected[0]
1456
+ self._render_selected_chips()
1457
+
1458
+ chip.on('click', _toggle)
1459
+
1460
  # โ”€โ”€ ่กจๆ ผๅˆ—ๅŠจๆ€ๆ›ดๆ–ฐ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1461
 
1462
  def _update_table_columns(self, e=None):
 
1469
  cols.append(OPTIONAL_COLS['source'])
1470
  self.result_table.columns = cols
1471
 
1472
+ # โ”€โ”€ ๆœ็ดขๆจกๅผ / ๅ‚ๆ•ฐ่”ๅŠจ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1473
+
1474
+ def _on_search_mode_change(self, _e=None):
1475
+ mode = self.input_search_mode.value if self.input_search_mode else None
1476
+ if not mode or mode == '่‡ชๅฎšไน‰' or mode not in _SEARCH_MODE_PRESETS:
1477
+ return
1478
+ preset = _SEARCH_MODE_PRESETS[mode]
1479
+ self._applying_preset = True
1480
+ try:
1481
+ if self.input_top_k:
1482
+ self.input_top_k.set_value(preset['top_k'])
1483
+ if self.input_limit:
1484
+ self.input_limit.set_value(preset['limit'])
1485
+ if self.input_weight:
1486
+ self.input_weight.set_value(preset['popularity_weight'])
1487
+ if self.input_segment:
1488
+ self.input_segment.set_value(preset['use_segmentation'])
1489
+ if self.input_group_mode:
1490
+ self.input_group_mode.set_value(preset['group_mode'])
1491
+ if self.input_max_per_group:
1492
+ self.input_max_per_group.set_value(preset['max_per_group'])
1493
+ finally:
1494
+ self._applying_preset = False
1495
+
1496
+ def _on_param_changed(self, _e=None):
1497
+ if not self._applying_preset and self.input_search_mode:
1498
+ if self.input_search_mode.value != '่‡ชๅฎšไน‰':
1499
+ self.input_search_mode.set_value('่‡ชๅฎšไน‰')
1500
+
1501
  # โ”€โ”€ NSFW ๅˆ‡ๆข โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1502
 
1503
  def on_nsfw_toggle(self, e):
 
1642
  @app.get('/robots.txt')
1643
  def robots_txt():
1644
  content = (
1645
+ 'User-agent: *\n'
1646
+ 'Allow: /$\n'
1647
+ 'Disallow: /api/\n'
1648
+ 'Disallow: /_nicegui/\n'
1649
+ 'Disallow: /socket.io/\n'
1650
  )
1651
  return PlainTextResponse(content)
1652
 
1653
  @app.head('/')
1654
  async def head_root():
1655
+ return PlainTextResponse('')
1656
 
1657
  ui.run(
1658
  host=host,
 
1661
  reload=not is_cloud(),
1662
  show=not is_cloud(),
1663
  reconnect_timeout=120,
1664
+ )