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

Auto-sync from GitHub Actions

Browse files
Files changed (6) hide show
  1. README.md +53 -4
  2. api_fastapi.py +46 -0
  3. core/engine.py +84 -4
  4. mcp_server.py +113 -0
  5. platform_utils.py +1 -1
  6. ui_nicegui.py +243 -329
README.md CHANGED
@@ -43,6 +43,7 @@ thumbnail: >-
43
  * **多维度语义匹配:** 底层支持 4 个维度的向量空间检索(英文、中文扩展词、维基释义、中文核心词),大幅提升模糊描述的命中率。
44
  * **智能分词与显式分隔:** 支持一键开启/关闭自动分词。开启后自动提取长句中概念分别检索;同时支持用空格、换行、中文逗号(,)和顿号(、)手动分隔概念,系统会尊重你的分隔意图,将每个片段作为整体匹配(超过 7 字的片段仍然自动分词)。
45
  * **标签互相关推荐:** 引擎内置标签共现数据。当你选中某些标签后,系统会自动推荐在 Danbooru 图库中经常与它们一同出现的相关标签,辅助补全画面细节。
 
46
  * **精细化查阅与过滤:** 支持按标签类别(General通用 / Character角色 / Copyright作品)进行硬过滤。
47
  * 提供 **NSFW 保护模式**(开启后自动模糊敏感词汇)。
48
  * 表格内置 Danbooru 维基释义悬浮提示,并提供前往原站的快捷链接。
@@ -50,15 +51,16 @@ thumbnail: >-
50
 
51
  ## 功能概览
52
 
53
- 本工具提供以下五项核心功能:
54
 
55
  - [精确查词](#1-精确查词)(容错拼写错误)
56
  - [概念模糊发散](#2-概念模糊发散)
57
  - [输入描述查词](#3-输入描述查词)
58
  - [完整画面查找](#4-完整画面查找)
59
  - [关联推荐](#5-关联推荐共现匹配)
 
60
 
61
- 前四项基于**语义向量匹配**,后项基于**标签共现数据**。
62
 
63
  ---
64
 
@@ -277,6 +279,30 @@ thumbnail: >-
277
 
278
  ---
279
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
280
  ## 使用技巧
281
 
282
  **技巧 1:先宽后窄**
@@ -331,6 +357,16 @@ POST /api/related
331
  }
332
  ```
333
 
 
 
 
 
 
 
 
 
 
 
334
  详见:[API 文档](https://sakizuki-danboorusearch.hf.space/api/docs)
335
 
336
  ---
@@ -381,7 +417,7 @@ npm install -g mcp-remote
381
  }
382
  ```
383
 
384
- 保存后重启 Claude Desktop,工具列表中出现 `search_tags` 和 `get_related_tags` 即为成功。
385
 
386
  > 注意:不要使用 `"url"` 字段直接填写地址,Claude Desktop 不支持该格式,会提示配置无效。也不推荐通过 `npx mcp-remote` 调用,首次执行时 `npx` 需要临时下载包,npm 缓存损坏时会导致启动失败。全局安装可规避此问题。
387
 
@@ -399,7 +435,7 @@ npm install -g mcp-remote
399
 
400
  ### 可用工具
401
 
402
- 接入后 AI 可调用以下两个工具:
403
 
404
  **`search_tags`** — 自然语言搜索标签
405
 
@@ -419,6 +455,17 @@ npm install -g mcp-remote
419
 
420
  给定一组已选标签,返回在 Danbooru 图库中与之最常共现的相关标签,基于 NPMI 评分,可有效过滤掉仅因自身热度高而频繁出现的标签。
421
 
 
 
 
 
 
 
 
 
 
 
 
422
  ### 调用示例
423
 
424
  接入后,你可以直接用自然语言告诉 AI 你的需求,AI 会自动选择合适的参数调用工具:
@@ -431,6 +478,8 @@ npm install -g mcp-remote
431
 
432
  > 我已经选了 `white_serafuku` 和 `rain` 这两个标签,帮我推荐一些常见的搭配标签
433
 
 
 
434
  > 查一下「运动社团 校队 比赛 运动会 体育祭 田径部 网球部 篮球部」这些概念对应的标签
435
 
436
  > 找「反乌托邦、赛博朋克、蒸汽朋克」的 General 类标签,只查通用特征不查作品名
 
43
  * **多维度语义匹配:** 底层支持 4 个维度的向量空间检索(英文、中文扩展词、维基释义、中文核心词),大幅提升模糊描述的命中率。
44
  * **智能分词与显式分隔:** 支持一键开启/关闭自动分词。开启后自动提取长句中概念分别检索;同时支持用空格、换行、中文逗号(,)和顿号(、)手动分隔概念,系统会尊重你的分隔意图,将每个片段作为整体匹配(超过 7 字的片段仍然自动分词)。
45
  * **标签互相关推荐:** 引擎内置标签共现数据。当你选中某些标签后,系统会自动推荐在 Danbooru 图库中经常与它们一同出现的相关标签,辅助补全画面细节。
46
+ * **推荐擅长画师:** 基于标签-画师 NPMI 共现数据,根据已选标签实时推荐擅长绘制这些元素的画师,支持勾选加入已选标签。
47
  * **精细化查阅与过滤:** 支持按标签类别(General通用 / Character角色 / Copyright作品)进行硬过滤。
48
  * 提供 **NSFW 保护模式**(开启后自动模糊敏感词汇)。
49
  * 表格内置 Danbooru 维基释义悬浮提示,并提供前往原站的快捷链接。
 
51
 
52
  ## 功能概览
53
 
54
+ 本工具提供以下核心功能:
55
 
56
  - [精确查词](#1-精确查词)(容错拼写错误)
57
  - [概念模糊发散](#2-概念模糊发散)
58
  - [输入描述查词](#3-输入描述查词)
59
  - [完整画面查找](#4-完整画面查找)
60
  - [关联推荐](#5-关联推荐共现匹配)
61
+ - [推荐擅长画师](#6-推荐擅长画师)
62
 
63
+ 前四项基于**语义向量匹配**,后项基于**标签-画师共现数据**。
64
 
65
  ---
66
 
 
279
 
280
  ---
281
 
282
+ ## 6. 推荐擅长画师
283
+
284
+ 推荐擅长画师与关联推荐类似,基于 **标签-画师 NPMI 共现数据**,但目标是推荐擅长绘制已选标签所描述元素的画师。
285
+
286
+ **使用方式:** 在左侧结果表格或右侧关联推荐中勾选标签后,右栏「推荐擅长画师」区域会**实时自动更新**,无需手动刷新。画师可勾选加入已选标签(Anima 模式下复制时自动加 `@` 前缀)。
287
+
288
+ **评分逻辑:** 对每个已选标签,计算该标签与画师的 NPMI 共现分,累加后乘以命中标签数加成,最终归一化显示为百分比。
289
+
290
+ ### 适用场景
291
+
292
+ **场景 1:根据风格标签找画师**
293
+
294
+ 勾选 `flat_color`(平涂)+ `1girl`,推荐擅长平涂风格女性角色的画师。
295
+
296
+ **场景 2:根据角色找画师**
297
+
298
+ 勾选某角色标签,推荐经常绘制该角色的画师。
299
+
300
+ **场景 3:根据画面元素找画师**
301
+
302
+ 勾选 `mecha`(机甲)+ `detailed_background`,推荐擅长机甲和精细背景的画师。
303
+
304
+ ---
305
+
306
  ## 使用技巧
307
 
308
  **技巧 1:先宽后窄**
 
357
  }
358
  ```
359
 
360
+ **推荐擅长画师接口**
361
+ ```
362
+ POST /api/artists
363
+ {
364
+ "tags": ["1girl", "blue_hair", "school_uniform"],
365
+ "limit": 30,
366
+ "min_cooc": 3
367
+ }
368
+ ```
369
+
370
  详见:[API 文档](https://sakizuki-danboorusearch.hf.space/api/docs)
371
 
372
  ---
 
417
  }
418
  ```
419
 
420
+ 保存后重启 Claude Desktop,工具列表中出现 `search_tags`、`get_related_tags` 和 `get_artist_recommendations` 即为成功。
421
 
422
  > 注意:不要使用 `"url"` 字段直接填写地址,Claude Desktop 不支持该格式,会提示配置无效。也不推荐通过 `npx mcp-remote` 调用,首次执行时 `npx` 需要临时下载包,npm 缓存损坏时会导致启动失败。全局安装可规避此问题。
423
 
 
435
 
436
  ### 可用工具
437
 
438
+ 接入后 AI 可调用以下工具:
439
 
440
  **`search_tags`** — 自然语言搜索标签
441
 
 
455
 
456
  给定一组已选标签,返回在 Danbooru 图库中与之最常共现的相关标签,基于 NPMI 评分,可有效过滤掉仅因自身热度高而频繁出现的标签。
457
 
458
+ **`get_artist_recommendations`** — 推荐擅长画师
459
+
460
+ 给定一组标签,返回擅长绘制这些元素的画师列表,基于标签-画师 NPMI 共现数据排序。
461
+
462
+ | 参数 | 默认值 | 说明 |
463
+ |---|---|---|
464
+ | `tags` | — | 标签列表(必填,Danbooru 英文标签名) |
465
+ | `limit` | `30` | 最多返回画师数 |
466
+ | `min_cooc` | `3` | 单个 (tag, artist) 对的最小共现次数 |
467
+ | `show_nsfw` | `true` | 是否包含 NSFW 数据 |
468
+
469
  ### 调用示例
470
 
471
  接入后,你可以直接用自然语言告诉 AI 你的需求,AI 会自动选择合适的参数调用工具:
 
478
 
479
  > 我已经选了 `white_serafuku` 和 `rain` 这两个标签,帮我推荐一些常见的搭配标签
480
 
481
+ > 帮我找擅长画「平涂风格 + 蓝发少女」的画��
482
+
483
  > 查一下「运动社团 校队 比赛 运动会 体育祭 田径部 网球部 篮球部」这些概念对应的标签
484
 
485
  > 找「反乌托邦、赛博朋克、蒸汽朋克」的 General 类标签,只查通用特征不查作品名
api_fastapi.py CHANGED
@@ -88,6 +88,21 @@ class SearchOut(BaseModel):
88
  keywords: list[str]
89
 
90
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91
  # ── FastAPI 子应用(挂载到 NiceGUI 的 /api 路径下)──
92
  # lifespan / 预热由 ui_nicegui.py 的 @app.on_startup 统一管理,此处不重复。
93
  app = FastAPI(
@@ -162,6 +177,37 @@ async def related(body: RelatedIn) -> list[RelatedTagOut]:
162
  ]
163
 
164
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
165
  @app.get("/health")
166
  async def health():
167
  tagger = await DanbooruTagger.get_instance()
 
88
  keywords: list[str]
89
 
90
 
91
+ class ArtistIn(BaseModel):
92
+ tags: list[str]
93
+ limit: int = Field(30, ge=1, le=100)
94
+ min_cooc: int = Field(3, ge=1, le=100)
95
+
96
+
97
+ class ArtistOut(BaseModel):
98
+ artist: str
99
+ score: float
100
+ cooc_count: int
101
+ post_count: int
102
+ sources: list[str]
103
+ hit_count: int
104
+
105
+
106
  # ── FastAPI 子应用(挂载到 NiceGUI 的 /api 路径下)──
107
  # lifespan / 预热由 ui_nicegui.py 的 @app.on_startup 统一管理,此处不重复。
108
  app = FastAPI(
 
177
  ]
178
 
179
 
180
+ @app.post("/artists", response_model=list[ArtistOut])
181
+ async def artists(body: ArtistIn) -> list[ArtistOut]:
182
+ """
183
+ 给定标签列表,推荐擅长绘制这些标签的画师(基于 NPMI 共现数据)。
184
+
185
+ - tags:种子标签列表(Danbooru 英文标签名)
186
+ - limit:最多返回条数,默认 30
187
+ - min_cooc:单个 (tag, artist) 对的最小共现次数,默认 3
188
+ """
189
+ tagger = await DanbooruTagger.get_instance()
190
+ results = await tagger.search_artists_by_tags_async(
191
+ body.tags, limit=body.limit, min_cooc=body.min_cooc,
192
+ )
193
+ # 计数
194
+ await counter.increment()
195
+ await counter.increment_success()
196
+ await counter.increment_copy()
197
+
198
+ return [
199
+ ArtistOut(
200
+ artist=r.artist,
201
+ score=round(r.score, 4),
202
+ cooc_count=r.cooc_count,
203
+ post_count=r.post_count,
204
+ sources=r.sources,
205
+ hit_count=r.hit_count,
206
+ )
207
+ for r in results
208
+ ]
209
+
210
+
211
  @app.get("/health")
212
  async def health():
213
  tagger = await DanbooruTagger.get_instance()
core/engine.py CHANGED
@@ -214,6 +214,7 @@ class DanbooruTagger:
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]
@@ -1280,6 +1281,37 @@ class DanbooruTagger:
1280
  ))
1281
  return results
1282
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1283
  def search_artists_pipeline(
1284
  self,
1285
  query: str,
@@ -1413,18 +1445,66 @@ class DanbooruTagger:
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}')
 
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._artist_top_tags: dict[str, list[tuple]] = {} # artist → [(tag, npmi, cn_short), ...]
218
  self.is_loaded: bool = False
219
 
220
  # 预提取的列数组,避免热点路径上反复执行 df.iloc[idx]
 
1281
  ))
1282
  return results
1283
 
1284
+ def get_artist_top_tags(self, artist_names: list[str], top_n: int = 10,
1285
+ show_nsfw: bool = True) -> dict[str, list[str]]:
1286
+ """获取画师的 top-N 共现标签(含中文简称)。
1287
+
1288
+ Returns:
1289
+ {artist: [f"{tag} ({cn_short})", ...]}
1290
+ """
1291
+ result: dict[str, list[str]] = {}
1292
+ for artist in artist_names:
1293
+ entries = self._artist_top_tags.get(artist, [])
1294
+ items: list[str] = []
1295
+ for tag, npmi, cooc in entries:
1296
+ if len(items) >= top_n:
1297
+ break
1298
+ # NSFW 过滤
1299
+ if not show_nsfw and self._name_to_idx is not None and tag in self._name_to_idx:
1300
+ idx = self._name_to_idx[tag]
1301
+ if self._arr_nsfw is not None and self._arr_nsfw[idx] == '1':
1302
+ continue
1303
+ cn_short = ""
1304
+ if self._name_to_idx is not None and tag in self._name_to_idx:
1305
+ idx = self._name_to_idx[tag]
1306
+ cn_full = str(self._arr_cn_name[idx]) if self._arr_cn_name is not None else ""
1307
+ cn_short = cn_full.split(',')[0].strip() if cn_full else ""
1308
+ display = f"{tag}"
1309
+ if cn_short:
1310
+ display += f" ({cn_short})"
1311
+ items.append(display)
1312
+ result[artist] = items
1313
+ return result
1314
+
1315
  def search_artists_pipeline(
1316
  self,
1317
  query: str,
 
1445
  print(f'[Engine] 加载标签-画师共现表 ({path.name})...')
1446
  t0 = time.time()
1447
  try:
1448
+ import math
1449
  df = pd.read_parquet(str(path))
1450
+
1451
+ # 预计算语料库总大小和 tag → post_count 映射
1452
+ total_posts = float(max(self.df['post_count'].max(), 7_000_000))
1453
+ tag_post_count = dict(zip(self.df['name'], self.df['post_count']))
1454
+
1455
  index: dict[str, list[tuple]] = {}
1456
+ skipped = 0
1457
  for _, row in df.iterrows():
1458
  tag = str(row["tag"])
1459
+ artist = str(row["artist"])
1460
+ cooc = int(row["cooc_count"])
1461
+ artist_pc = int(row["artist_post_count"])
1462
+
1463
+ # 获取标签的 post_count
1464
+ tag_pc = tag_post_count.get(tag)
1465
+ if tag_pc is None or tag_pc <= 0:
1466
+ skipped += 1
1467
+ continue
1468
+
1469
+ # 即时计算 NPMI(与 get_related 相同算法)
1470
+ cooc_capped = min(float(cooc), float(tag_pc), float(artist_pc))
1471
+ if cooc_capped <= 0:
1472
+ skipped += 1
1473
+ continue
1474
+
1475
+ numerator = (cooc_capped * total_posts) / (float(tag_pc) * float(artist_pc))
1476
+ if numerator <= 1.0:
1477
+ skipped += 1
1478
+ continue
1479
+
1480
+ pmi = math.log(numerator)
1481
+ p_ab = cooc_capped / total_posts
1482
+ if p_ab >= 1.0:
1483
+ npmi_val = 1.0
1484
+ else:
1485
+ npmi_val = pmi / -math.log(p_ab)
1486
+
1487
+ if math.isnan(npmi_val):
1488
+ skipped += 1
1489
+ continue
1490
+
1491
  index.setdefault(tag, []).append(
1492
+ (artist, npmi_val, cooc, artist_pc))
1493
+
 
1494
  self._tag_artist_index = index
1495
+
1496
+ # 构建反向索引:artist → top tags(按 NPMI 降序)
1497
+ artist_tags: dict[str, list[tuple]] = {}
1498
+ for tag, entries in index.items():
1499
+ for artist, npmi, cooc, _ in entries:
1500
+ artist_tags.setdefault(artist, []).append((tag, npmi, cooc))
1501
+ for artist, entries in artist_tags.items():
1502
+ entries.sort(key=lambda x: -x[1])
1503
+ self._artist_top_tags = artist_tags
1504
+
1505
  print(
1506
  f'[Engine] 标签-画师共现表加载完成,{len(index):,} 个标签,'
1507
+ f'跳过 {skipped:,} 行,耗时 {time.time() - t0:.2f}s'
1508
  )
1509
  except Exception as e:
1510
  print(f'[Engine] 标签-画师共现表加载失败: {e}')
mcp_server.py CHANGED
@@ -329,6 +329,119 @@ JSON array sorted by aggregated NPMI score (descending). Each result:
329
  return json.dumps(payload, ensure_ascii=False, indent=2)
330
 
331
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
332
  # ── Anima 提示词格式说明 ─────────────────────────────────────────────────
333
  _ANIMA_FORMAT_INSTRUCTION = """请严格按以下 Anima 混合提示词(Hybrid Prompt)规范,基于提供的标签和用户描述,输出最终结果。
334
 
 
329
  return json.dumps(payload, ensure_ascii=False, indent=2)
330
 
331
 
332
+ @mcp.tool()
333
+ async def get_artist_recommendations(
334
+ tags: list[str],
335
+ limit: int = 30,
336
+ min_cooc: int = 3,
337
+ show_nsfw: bool = True,
338
+ ) -> str:
339
+ """
340
+ Recommend artists who are skilled at drawing the given tags, based on NPMI co-occurrence data.
341
+
342
+ Given a list of Danbooru tags (e.g. character names, clothing, styles), this tool returns
343
+ artists whose works frequently co-occur with those tags on Danbooru, ranked by aggregated
344
+ NPMI score.
345
+
346
+ ## Args
347
+ - tags: List of canonical Danbooru tag names (underscores, no spaces).
348
+ e.g. ["1girl", "blue_hair", "school_uniform"]
349
+ - limit: Max artists returned. Default 30.
350
+ - min_cooc: Minimum co-occurrence count per (tag, artist) pair to consider. Default 3.
351
+ - show_nsfw: Include NSFW artist data. Default True.
352
+
353
+ ## Returns
354
+
355
+ JSON array sorted by NPMI score (descending). Each result:
356
+ - artist: Danbooru artist tag name
357
+ - score: Aggregated NPMI score (higher = stronger association)
358
+ - cooc_count: Total co-occurrence count across all input tags
359
+ - post_count: Artist's total post count on Danbooru
360
+ - sources: Input tags that matched this artist
361
+ - hit_count: Number of input tags that matched
362
+ """
363
+ tagger = await DanbooruTagger.get_instance()
364
+
365
+ if not tags:
366
+ return json.dumps({"error": "tags 列表不能为空"}, ensure_ascii=False, indent=2)
367
+
368
+ # ── 检查标签是否存在,不存在则尝试 search_tags 纠错 ──────────────────
369
+ valid_tags = []
370
+ invalid_tags = []
371
+ for t in tags:
372
+ if t in tagger._name_to_idx:
373
+ valid_tags.append(t)
374
+ else:
375
+ invalid_tags.append(t)
376
+
377
+ corrections = {}
378
+ if invalid_tags:
379
+ for bad_tag in invalid_tags:
380
+ try:
381
+ req = SearchRequest(
382
+ query=bad_tag,
383
+ top_k=5,
384
+ limit=5,
385
+ popularity_weight=0.15,
386
+ use_segmentation=False,
387
+ target_layers=['英文']
388
+ )
389
+ resp = await tagger.search_async(req)
390
+ if resp.results:
391
+ corrections[bad_tag] = resp.results[0].tag
392
+ except Exception:
393
+ pass
394
+
395
+ if not valid_tags and not corrections:
396
+ return json.dumps({
397
+ "error": "所有传入的标签均不存在于标签表中",
398
+ "invalid_tags": invalid_tags,
399
+ }, ensure_ascii=False, indent=2)
400
+
401
+ # 用纠错后的标签替换无效标签
402
+ corrected_tags = []
403
+ for t in tags:
404
+ if t in valid_tags:
405
+ corrected_tags.append(t)
406
+ elif t in corrections:
407
+ corrected_tags.append(corrections[t])
408
+
409
+ results = await tagger.search_artists_by_tags_async(
410
+ corrected_tags, limit=limit, min_cooc=min_cooc,
411
+ )
412
+
413
+ output = []
414
+ for r in results:
415
+ item = {
416
+ "artist": r.artist,
417
+ "score": round(r.score, 4),
418
+ "cooc_count": r.cooc_count,
419
+ "post_count": r.post_count,
420
+ "sources": r.sources,
421
+ "hit_count": r.hit_count,
422
+ }
423
+ output.append(item)
424
+
425
+ # 计数
426
+ await counter.increment()
427
+ await counter.increment_success()
428
+ await counter.increment_copy()
429
+ await counter.increment_mcp()
430
+
431
+ payload = {"results": output}
432
+ if corrections:
433
+ correction_notes = [
434
+ f"{bad} → {good}" for bad, good in corrections.items()
435
+ ]
436
+ payload = {
437
+ "correction_note": "标签拼写错误,已经纠错: " + ", ".join(correction_notes),
438
+ "corrections": corrections,
439
+ "results": output,
440
+ }
441
+
442
+ return json.dumps(payload, ensure_ascii=False, indent=2)
443
+
444
+
445
  # ── Anima 提示词格式说明 ─────────────────────────────────────────────────
446
  _ANIMA_FORMAT_INSTRUCTION = """请严格按以下 Anima 混合提示词(Hybrid Prompt)规范,基于提供的标签和用户描述,输出最终结果。
447
 
platform_utils.py CHANGED
@@ -294,7 +294,7 @@ def download_file(
294
 
295
  # 模型路径解析
296
 
297
- LOCAL_MODEL_PATH = 'my_model_bge_m3'
298
  HF_MODEL_ID = 'BAAI/bge-m3'
299
  MS_MODEL_ID = 'BAAI/bge-m3' # 魔搭上同名,走国内节点
300
 
 
294
 
295
  # 模型路径解析
296
 
297
+ LOCAL_MODEL_PATH = 'D:/LLMs/BAAI/bge-m3'
298
  HF_MODEL_ID = 'BAAI/bge-m3'
299
  MS_MODEL_ID = 'BAAI/bge-m3' # 魔搭上同名,走国内节点
300
 
ui_nicegui.py CHANGED
@@ -86,7 +86,7 @@ OPTIONAL_COLS = {
86
 
87
  # localStorage key 与配置版本,版本变更时自动丢弃旧配置
88
  _CONFIG_LS_KEY = 'danbooru_search_config'
89
- _CONFIG_VERSION = 5
90
 
91
  # 搜索模式预设
92
  _SEARCH_MODE_PRESETS: dict[str, dict] = {
@@ -170,6 +170,7 @@ class DanbooruSearchUI:
170
  # 去抖任务句柄(取消旧任务避免 CPU 洪峰)
171
  self._debounce_related_task = None # type: asyncio.Task | None
172
  self._debounce_group_task = None # type: asyncio.Task | None
 
173
 
174
  # tag -> prompt 权重,范围 [0.1, 1.9],默认 1.0
175
  self.tag_weights: dict[str, float] = {}
@@ -177,12 +178,6 @@ class DanbooruSearchUI:
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
@@ -215,6 +210,10 @@ class DanbooruSearchUI:
215
  self._related_checkboxes: dict[str, ui.checkbox] = {}
216
  # 同类标签的 checkbox 引用
217
  self._group_checkboxes: dict[str, ui.checkbox] = {}
 
 
 
 
218
 
219
  # 高级选项中各层/类型的 checkbox 引用,用于 restore 时同步控件状态
220
  self._layer_checkboxes: dict[str, ui.checkbox] = {}
@@ -298,7 +297,6 @@ class DanbooruSearchUI:
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,10 +393,6 @@ class DanbooruSearchUI:
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
 
@@ -469,59 +463,6 @@ class DanbooruSearchUI:
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,11 +536,7 @@ class DanbooruSearchUI:
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,7 +544,7 @@ class DanbooruSearchUI:
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(
@@ -618,12 +555,12 @@ class DanbooruSearchUI:
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
  # ── 标签组扩展 ──
@@ -681,18 +618,10 @@ class DanbooruSearchUI:
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
 
@@ -775,7 +704,7 @@ class DanbooruSearchUI:
775
 
776
  with ui.column().classes('gap-2'):
777
  ui.label('类型筛选').classes('font-bold text-sm text-gray-700')
778
- color_map = {'General': 'blue', 'Copyright': 'pink', 'Character': 'green'}
779
  label_map = {
780
  'General': '通用 (General)',
781
  'Copyright': '作品 (Copyright)',
@@ -984,11 +913,14 @@ class DanbooruSearchUI:
984
  self.tag_weights.clear()
985
  if self.result_table is not None:
986
  self.result_table.selected = []
 
 
987
  self._render_selected_chips()
988
  if self.selection_count_label is not None:
989
  self.selection_count_label.text = '0'
990
  show_nsfw_val = self.input_nsfw.value
991
  self._refresh_related([], show_nsfw_val)
 
992
  # 清空 Group 同类扩展
993
  if self.group_expansion_container is not None:
994
  self.group_expansion_container.clear()
@@ -1028,7 +960,7 @@ class DanbooruSearchUI:
1028
  'background-color':
1029
  props.row.category === 'General' ? 'rgba(59,130,246,0.06)' :
1030
  props.row.category === 'Character' ? 'rgba(34,197,94,0.06)' :
1031
- props.row.category === 'Copyright' ? 'rgba(236,72,153,0.06)' : ''
1032
  }">
1033
  <q-td auto-width>
1034
  <q-checkbox v-model="props.selected"
@@ -1096,8 +1028,25 @@ class DanbooruSearchUI:
1096
  with self.group_expansion_container:
1097
  ui.label('请先搜索并勾选标签…').classes('text-sm text-gray-400 italic p-4')
1098
 
1099
- # ── 右栏:关联推荐 ──
1100
  with ui.card().classes('col-right'):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1101
  with ui.row().classes('items-center justify-between w-full mb-2'):
1102
  with ui.row().classes('items-center gap-2'):
1103
  ui.label('关联推荐').classes('font-bold text-lg text-gray-800')
@@ -1115,73 +1064,6 @@ class DanbooruSearchUI:
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
  # ══════════════════════════════════════════════════════════════════════
@@ -1236,7 +1118,7 @@ class DanbooruSearchUI:
1236
  CAT_BG = {
1237
  'General': 'background-color: rgba(59,130,246,0.06);', # 淡蓝
1238
  'Character': 'background-color: rgba(34,197,94,0.06);', # 淡绿
1239
- 'Copyright': 'background-color: rgba(236,72,153,0.06);', # 淡红/粉
1240
  }
1241
  row_bg = CAT_BG.get(r.category, '')
1242
 
@@ -1323,52 +1205,6 @@ class DanbooruSearchUI:
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,134 +1247,92 @@ class DanbooruSearchUI:
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():
@@ -1580,6 +1374,10 @@ class DanbooruSearchUI:
1580
  if self.result_table is not None:
1581
  self.result_table.selected = [row for row in self.result_table.rows if row.get('tag') in tag_set]
1582
 
 
 
 
 
1583
  all_tags = self._get_selected_tags()
1584
  if self.selection_count_label is not None:
1585
  self.selection_count_label.text = str(len(all_tags))
@@ -1592,6 +1390,7 @@ class DanbooruSearchUI:
1592
  show_nsfw_val = self.input_nsfw.value
1593
  self._refresh_related_from_selection(all_tags, show_nsfw_val)
1594
  self._refresh_group_from_selection(all_tags, show_nsfw_val)
 
1595
  if not all_tags:
1596
  self.chip_extra_selected.clear()
1597
 
@@ -1614,9 +1413,14 @@ class DanbooruSearchUI:
1614
  self.selection_count_label.text = str(len(all_tags))
1615
  self._render_selected_chips()
1616
 
 
 
 
 
1617
  show_nsfw_val = self.input_nsfw.value
1618
  self._refresh_related_from_selection(all_tags, show_nsfw_val)
1619
  self._refresh_group_from_selection(all_tags, show_nsfw_val)
 
1620
  if not all_tags:
1621
  self.chip_extra_selected.clear()
1622
  self._save_staged_tags()
@@ -1636,6 +1440,9 @@ class DanbooruSearchUI:
1636
  self.tag_weights.pop(tag, None)
1637
  self._set_selected_tags(current, skip_refresh=True)
1638
  ui.notify(f'已移除 {tag}', type='warning', timeout=1500)
 
 
 
1639
 
1640
  def _on_group_checkbox_change(self, tag: str, checked: bool):
1641
  """同类标签复选框变化回调。"""
@@ -1653,9 +1460,27 @@ class DanbooruSearchUI:
1653
  self.tag_weights.pop(tag, None)
1654
  self._set_selected_tags(current, skip_refresh=True)
1655
  ui.notify(f'已移除 {tag}', type='warning', timeout=1500)
1656
- # 即刻刷新关联推荐
1657
  show_nsfw_val = self.input_nsfw.value
1658
  self._refresh_related_from_selection(current, show_nsfw_val)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1659
 
1660
  def _manual_refresh_related(self):
1661
  """手动触发关联推荐列表的刷新"""
@@ -1665,6 +1490,7 @@ class DanbooruSearchUI:
1665
 
1666
  if all_tags:
1667
  self._refresh_related_from_selection(all_tags, show_nsfw_val)
 
1668
  ui.notify('已触发关联推荐更新', type='info', timeout=1500)
1669
  else:
1670
  self.chip_extra_selected.clear()
@@ -1742,6 +1568,90 @@ class DanbooruSearchUI:
1742
  self._render_group_expansion(group_data, selected_tags, show_nsfw)
1743
  self._debounce_group_task = asyncio.ensure_future(_do())
1744
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1745
  def _render_group_expansion(self, group_data: list, selected_tags: list[str], show_nsfw: bool):
1746
  """渲染 Group 同类扩展区域。"""
1747
  if self.group_expansion_container is None:
@@ -1758,7 +1668,7 @@ class DanbooruSearchUI:
1758
  CAT_BG = {
1759
  'General': 'background-color: rgba(59,130,246,0.06);',
1760
  'Character': 'background-color: rgba(34,197,94,0.06);',
1761
- 'Copyright': 'background-color: rgba(236,72,153,0.06);',
1762
  }
1763
  CAT_LABEL = {'General': '通用', 'Character': '角色', 'Copyright': '作品'}
1764
 
@@ -1902,10 +1812,14 @@ class DanbooruSearchUI:
1902
  def copy_selection(self):
1903
  self._mark_interaction()
1904
  tags = self._get_selected_tags()
1905
- prompt = ', '.join(
1906
- _format_tag_with_weight(t, self.tag_weights.get(t, 1.0), self.prompt_format)
1907
- for t in tags
1908
- )
 
 
 
 
1909
  ui.clipboard.write(prompt)
1910
  fmt_label = {'sdxl': 'SDXL', 'nai': 'NAI', 'anima': 'Anima'}.get(self.prompt_format, 'SDXL')
1911
  ui.notify(f'已复制选中标签({fmt_label} 格式)!', type='positive')
 
86
 
87
  # localStorage key 与配置版本,版本变更时自动丢弃旧配置
88
  _CONFIG_LS_KEY = 'danbooru_search_config'
89
+ _CONFIG_VERSION = 6
90
 
91
  # 搜索模式预设
92
  _SEARCH_MODE_PRESETS: dict[str, dict] = {
 
170
  # 去抖任务句柄(取消旧任务避免 CPU 洪峰)
171
  self._debounce_related_task = None # type: asyncio.Task | None
172
  self._debounce_group_task = None # type: asyncio.Task | None
173
+ self._debounce_artist_task = None # type: asyncio.Task | None
174
 
175
  # tag -> prompt 权重,范围 [0.1, 1.9],默认 1.0
176
  self.tag_weights: dict[str, float] = {}
 
178
  self.prompt_format: str = 'sdxl'
179
  self.format_toggle_btn = None
180
 
 
 
 
 
 
 
181
  self.init_banner = None
182
  self.input_top_k = None
183
  self.input_limit = None
 
210
  self._related_checkboxes: dict[str, ui.checkbox] = {}
211
  # 同类标签的 checkbox 引用
212
  self._group_checkboxes: dict[str, ui.checkbox] = {}
213
+ # 推荐画师的 checkbox 引用
214
+ self._artist_rec_checkboxes: dict[str, ui.checkbox] = {}
215
+ # 当前推荐画师的标签名集合(用于 Anima 模式复制时加 @ 前缀)
216
+ self._current_artist_rec_tags: set[str] = set()
217
 
218
  # 高级选项中各层/类型的 checkbox 引用,用于 restore 时同步控件状态
219
  self._layer_checkboxes: dict[str, ui.checkbox] = {}
 
297
  'search_mode': self.input_search_mode.value if self.input_search_mode else '自定义',
298
  'group_mode': self.input_group_mode.value if self.input_group_mode else 'off',
299
  'max_per_group': int(self.input_max_per_group.value) if self.input_max_per_group else 2,
 
300
  }
301
  js = _json.dumps(cfg, ensure_ascii=False)
302
  ui.run_javascript(f"localStorage.setItem('{_CONFIG_LS_KEY}', {_json.dumps(js)});")
 
393
  if self.mcp_notice and cfg.get('mcp_notice_dismissed'):
394
  self.mcp_notice.set_visibility(False)
395
 
 
 
 
 
396
  # 若高级选项列有变更,同步更新表格列
397
  self._update_table_columns()
398
 
 
463
  }
464
  }
465
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
466
  </style>
467
  <script async src="https://www.googletagmanager.com/gtag/js?id=G-QPB7EEPR5G"></script>
468
  <script>
 
536
  # ── 4. 分词筛选 chips ──
537
  self.keywords_container = ui.row().classes('gap-2 items-center flex-wrap')
538
 
539
+ # ── 5. 两栏结果 ──
 
 
 
 
540
  self._build_results_columns()
541
 
542
  # ── 6. 底部 ──
 
544
  self.search_count_label = ui.html('正在加载数据...').classes('text-xs text-gray-400')
545
  self._update_footer_text()
546
 
547
+ # ── 公告栏(标签组 + MCP)─────────────────────────────────────────────
548
 
549
  def _build_group_notice(self):
550
  self.mcp_notice = ui.card().classes(
 
555
  # ── 画师查找公告 ──
556
  with ui.row().classes('items-center justify-between w-full'):
557
  with ui.row().classes('items-center gap-1'):
558
+ ui.label('🧪 新功能:推荐擅长画师(beta)').classes('text-sm font-bold text-green-800')
559
  ui.button(icon='close').props('flat dense round color=grey-6') \
560
  .on_click(self._dismiss_mcp_notice)
561
  ui.html(
562
+ '基于标签共现数据,根据已选标签查找对应的擅长画师。'
563
+ '鼠标悬停画师查看该画师最常画的标签'
564
  ).classes('text-xs text-green-900')
565
  ui.separator().classes('my-1')
566
  # ── 标签组扩展 ──
 
618
 
619
  def _build_search_card(self):
620
  with ui.card().classes('w-full'):
 
 
 
 
 
 
 
 
621
  with ui.row().classes('items-center gap-2 mb-2'):
622
  ui.icon('search', size='2em', color='primary')
623
+ ui.label('Danbooru 标签模糊搜索').classes('text-2xl font-bold text-gray-800')
624
+ ui.label(
625
  '基于语义匹配的标签搜索引擎,支持多维匹配与共现关联推荐。'
626
  ).classes('text-sm text-gray-500 -mt-1 mb-3')
627
 
 
704
 
705
  with ui.column().classes('gap-2'):
706
  ui.label('类型筛选').classes('font-bold text-sm text-gray-700')
707
+ color_map = {'General': 'blue', 'Copyright': 'purple', 'Character': 'green'}
708
  label_map = {
709
  'General': '通用 (General)',
710
  'Copyright': '作品 (Copyright)',
 
913
  self.tag_weights.clear()
914
  if self.result_table is not None:
915
  self.result_table.selected = []
916
+ self._artist_rec_checkboxes.clear()
917
+ self._current_artist_rec_tags.clear()
918
  self._render_selected_chips()
919
  if self.selection_count_label is not None:
920
  self.selection_count_label.text = '0'
921
  show_nsfw_val = self.input_nsfw.value
922
  self._refresh_related([], show_nsfw_val)
923
+ self._render_artist_rec([], {})
924
  # 清空 Group 同类扩展
925
  if self.group_expansion_container is not None:
926
  self.group_expansion_container.clear()
 
960
  'background-color':
961
  props.row.category === 'General' ? 'rgba(59,130,246,0.06)' :
962
  props.row.category === 'Character' ? 'rgba(34,197,94,0.06)' :
963
+ props.row.category === 'Copyright' ? 'rgba(168,85,247,0.06)' : ''
964
  }">
965
  <q-td auto-width>
966
  <q-checkbox v-model="props.selected"
 
1028
  with self.group_expansion_container:
1029
  ui.label('请先搜索并勾选标签…').classes('text-sm text-gray-400 italic p-4')
1030
 
1031
+ # ── 右栏:推荐画师 + 关联推荐 ──
1032
  with ui.card().classes('col-right'):
1033
+ # 推荐画师
1034
+ with ui.row().classes('items-center justify-between w-full mb-2'):
1035
+ with ui.row().classes('items-center gap-2'):
1036
+ ui.label('推荐擅长画师(Beta)').classes('font-bold text-lg text-gray-800')
1037
+ with ui.icon('info_outline', size='sm', color='grey').classes('cursor-help'):
1038
+ with ui.tooltip().props('content-class="bg-black text-white shadow-4"'):
1039
+ ui.html(
1040
+ '基于标签-画师 NPMI 共现数据,根据您当前已选的标签,推荐擅长这些元素的画师。<br>悬停画师行可查看与该画师共现关联最强的标签。').style(
1041
+ 'font-size:14px;line-height:1.5;')
1042
+
1043
+ self.artist_rec_list = ui.column().classes('w-full gap-0').style('max-height: 420px; overflow-y: auto;')
1044
+ with self.artist_rec_list:
1045
+ ui.label('请先搜索并勾选标签…').classes('text-sm text-gray-400 italic p-4')
1046
+
1047
+ ui.separator().classes('my-3')
1048
+
1049
+ # 关联推荐
1050
  with ui.row().classes('items-center justify-between w-full mb-2'):
1051
  with ui.row().classes('items-center gap-2'):
1052
  ui.label('关联推荐').classes('font-bold text-lg text-gray-800')
 
1064
  with self.related_list_container:
1065
  ui.label('请先搜索并勾选标签…').classes('text-sm text-gray-400 italic p-4')
1066
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1067
  # ══════════════════════════════════════════════════════════════════════
1068
  # 渲染关联推荐列表
1069
  # ══════════════════════════════════════════════════════════════════════
 
1118
  CAT_BG = {
1119
  'General': 'background-color: rgba(59,130,246,0.06);', # 淡蓝
1120
  'Character': 'background-color: rgba(34,197,94,0.06);', # 淡绿
1121
+ 'Copyright': 'background-color: rgba(168,85,247,0.06);', # 淡
1122
  }
1123
  row_bg = CAT_BG.get(r.category, '')
1124
 
 
1205
  chip_color, text_color = 'grey-4', 'black'
1206
  child.props(f'color={chip_color} text-color={text_color}')
1207
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1208
  # ── 搜索 ──────────────────────────────────────────────────────────────
1209
 
1210
  async def perform_search(self):
 
1247
  try:
1248
  tagger = await DanbooruTagger.get_instance()
1249
 
1250
+ show_nsfw_val = self.input_nsfw.value
1251
+
1252
+ request = SearchRequest(
1253
+ query=query,
1254
+ top_k=int(self.input_top_k.value),
1255
+ limit=int(self.input_limit.value),
1256
+ popularity_weight=float(self.input_weight.value),
1257
+ show_nsfw=show_nsfw_val,
1258
+ use_segmentation=self.input_segment.value if self.input_segment else True,
1259
+ target_layers=target_layers_list,
1260
+ target_categories=target_cats_list,
1261
+ group_mode=self.input_group_mode.value if self.input_group_mode else 'off',
1262
+ max_per_group=int(self.input_max_per_group.value) if self.input_max_per_group else 2,
1263
+ )
1264
+ response = await tagger.search_async(request)
1265
+
1266
+ # 后台计数
1267
+ async def silent_counter_update():
1268
+ try:
1269
+ await counter.increment()
1270
+ if response.keywords:
1271
+ await counter.add_keywords(response.keywords)
1272
+ self._update_footer_text()
1273
+ except Exception as e:
1274
+ print(f"[UI] 后台静默更新计数失败: {e}", flush=True)
1275
+ asyncio.create_task(silent_counter_update())
1276
+
1277
+ if not self._client_alive():
1278
+ return
1279
 
1280
+ table_data = [result_to_row(r, show_nsfw_val) for r in response.results]
1281
+ self.full_table_data = table_data
1282
+ self.full_tags_str = response.tags_all
1283
+ self.full_tags_str_sfw = response.tags_sfw
1284
+ self.current_segments = list(response.segments) if response.segments else []
1285
 
1286
+ self.results_section.set_visibility(True)
 
1287
 
1288
+ _saved_rpp = self._get_rows_per_page()
1289
+ self.result_table.rows = apply_nsfw_filter(table_data, show_nsfw_val)
1290
+ self._set_rows_per_page(_saved_rpp)
1291
+ all_selected = self._get_selected_tags()
1292
+ self.chip_extra_selected.clear()
1293
+ self.chip_extra_selected.update(all_selected)
1294
+ self.result_table.selected = []
1295
+ self._render_selected_chips()
1296
+ self._update_selection_display(None)
1297
+ self._save_staged_tags()
1298
 
1299
+ self._refresh_related([], show_nsfw_val)
 
 
 
 
 
 
1300
 
1301
+ # 分词筛选 chips
1302
+ self.current_filter_keyword = 'ALL'
1303
+ self.keywords_container.clear()
1304
+ cached_set = set(response.cached_queries) if response.cached_queries else set()
1305
+ with self.keywords_container:
1306
+ ui.label('分词筛选:').classes('text-sm text-gray-500 font-bold mr-2')
1307
+ ui.chip('全部', on_click=lambda: self._filter_by_source('ALL')) \
1308
+ .props('color=primary text-color=white clickable')
1309
+ use_seg = self.input_segment.value if self.input_segment else True
1310
+ if use_seg:
1311
+ whole = ui.chip('整句',
1312
+ on_click=lambda: self._filter_by_source(self.current_query_str))
1313
+ whole.props('color=grey-4 text-color=black clickable')
1314
+ if self.current_query_str in cached_set:
1315
+ whole.style('outline: 1px dashed rgba(128,128,128,0.3); outline-offset: 1px;')
1316
+ for seg in response.segments:
1317
+ sc = ui.chip(seg,
1318
+ on_click=lambda s=seg: self._filter_by_source(s))
1319
+ sc.props('color=blue-1 text-color=blue-8 clickable')
1320
+ if seg in cached_set:
1321
+ sc.style('outline: 1px dashed rgba(128,128,128,0.3); outline-offset: 1px;')
1322
+ for kw in response.keywords:
1323
+ kc = ui.chip(kw,
1324
+ on_click=lambda k=kw: self._filter_by_source(k))
1325
+ kc.props('color=grey-4 text-color=black clickable')
1326
+ if kw in cached_set:
1327
+ kc.style('outline: 1px dashed rgba(128,128,128,0.3); outline-offset: 1px;')
1328
+ else:
1329
+ ui.label('(分词已关闭)').classes('text-xs text-gray-400')
1330
 
1331
+ ui.notify(f'找到 {len(table_data)} 个标签', type='positive')
1332
+ self.current_search_interacted = False
1333
+
1334
+ if self.bad_case_btn is not None:
1335
+ self.bad_case_btn.enable()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1336
 
1337
  except RuntimeError as e:
1338
  if 'deleted' in str(e).lower() or 'client' in str(e).lower():
 
1374
  if self.result_table is not None:
1375
  self.result_table.selected = [row for row in self.result_table.rows if row.get('tag') in tag_set]
1376
 
1377
+ # 同步推荐画师 checkbox
1378
+ for t, cb in self._artist_rec_checkboxes.items():
1379
+ cb.set_value(t in tag_set)
1380
+
1381
  all_tags = self._get_selected_tags()
1382
  if self.selection_count_label is not None:
1383
  self.selection_count_label.text = str(len(all_tags))
 
1390
  show_nsfw_val = self.input_nsfw.value
1391
  self._refresh_related_from_selection(all_tags, show_nsfw_val)
1392
  self._refresh_group_from_selection(all_tags, show_nsfw_val)
1393
+ self._refresh_artist_from_selection(all_tags, show_nsfw_val)
1394
  if not all_tags:
1395
  self.chip_extra_selected.clear()
1396
 
 
1413
  self.selection_count_label.text = str(len(all_tags))
1414
  self._render_selected_chips()
1415
 
1416
+ # 同步推荐画师 checkbox
1417
+ for t, cb in self._artist_rec_checkboxes.items():
1418
+ cb.set_value(t in tag_set)
1419
+
1420
  show_nsfw_val = self.input_nsfw.value
1421
  self._refresh_related_from_selection(all_tags, show_nsfw_val)
1422
  self._refresh_group_from_selection(all_tags, show_nsfw_val)
1423
+ self._refresh_artist_from_selection(all_tags, show_nsfw_val)
1424
  if not all_tags:
1425
  self.chip_extra_selected.clear()
1426
  self._save_staged_tags()
 
1440
  self.tag_weights.pop(tag, None)
1441
  self._set_selected_tags(current, skip_refresh=True)
1442
  ui.notify(f'已移除 {tag}', type='warning', timeout=1500)
1443
+ # 刷新推荐画师
1444
+ show_nsfw_val = self.input_nsfw.value
1445
+ self._refresh_artist_from_selection(current, show_nsfw_val)
1446
 
1447
  def _on_group_checkbox_change(self, tag: str, checked: bool):
1448
  """同类标签复选框变化回调。"""
 
1460
  self.tag_weights.pop(tag, None)
1461
  self._set_selected_tags(current, skip_refresh=True)
1462
  ui.notify(f'已移除 {tag}', type='warning', timeout=1500)
1463
+ # 即刻刷新关联推荐 + 画师推荐
1464
  show_nsfw_val = self.input_nsfw.value
1465
  self._refresh_related_from_selection(current, show_nsfw_val)
1466
+ self._refresh_artist_from_selection(current, show_nsfw_val)
1467
+
1468
+ def _on_artist_rec_checkbox_change(self, tag: str, checked: bool):
1469
+ """推荐画师复选框变化回调。"""
1470
+ self._mark_interaction()
1471
+ current = self._get_selected_tags()
1472
+ if checked:
1473
+ if tag not in current:
1474
+ current.append(tag)
1475
+ self.tag_weights.setdefault(tag, 1.0)
1476
+ self._set_selected_tags(current, skip_refresh=True)
1477
+ ui.notify(f'已添加画师 {tag}', type='positive', timeout=1500)
1478
+ else:
1479
+ if tag in current:
1480
+ current.remove(tag)
1481
+ self.tag_weights.pop(tag, None)
1482
+ self._set_selected_tags(current, skip_refresh=True)
1483
+ ui.notify(f'已移除画师 {tag}', type='warning', timeout=1500)
1484
 
1485
  def _manual_refresh_related(self):
1486
  """手动触发关联推荐列表的刷新"""
 
1490
 
1491
  if all_tags:
1492
  self._refresh_related_from_selection(all_tags, show_nsfw_val)
1493
+ self._refresh_artist_from_selection(all_tags, show_nsfw_val)
1494
  ui.notify('已触发关联推荐更新', type='info', timeout=1500)
1495
  else:
1496
  self.chip_extra_selected.clear()
 
1568
  self._render_group_expansion(group_data, selected_tags, show_nsfw)
1569
  self._debounce_group_task = asyncio.ensure_future(_do())
1570
 
1571
+ def _refresh_artist_from_selection(self, selected_tags: list[str], show_nsfw: bool = True):
1572
+ """根据已选标签刷新画师推荐(300ms 去抖)。"""
1573
+ if self._debounce_artist_task and not self._debounce_artist_task.done():
1574
+ self._debounce_artist_task.cancel()
1575
+ async def _do():
1576
+ await asyncio.sleep(0.3)
1577
+ if len(selected_tags) < 1:
1578
+ self._render_artist_rec([], {}, show_nsfw)
1579
+ return
1580
+ tagger = await DanbooruTagger.get_instance()
1581
+ artist_results = await tagger.search_artists_by_tags_async(
1582
+ selected_tags, limit=30, min_cooc=3,
1583
+ )
1584
+ top_tags = {}
1585
+ if artist_results:
1586
+ names = [r.artist for r in artist_results[:10]]
1587
+ top_tags = tagger.get_artist_top_tags(names, show_nsfw=show_nsfw)
1588
+ self._render_artist_rec(artist_results, top_tags, show_nsfw)
1589
+ self._debounce_artist_task = asyncio.ensure_future(_do())
1590
+
1591
+ def _render_artist_rec(self, artist_results, top_tags=None, show_nsfw: bool = True):
1592
+ """渲染推荐画师列表(对标关联推荐样式)。"""
1593
+ if self.artist_rec_list is None:
1594
+ return
1595
+ self.artist_rec_list.clear()
1596
+ self._artist_rec_checkboxes.clear()
1597
+ self._current_artist_rec_tags.clear()
1598
+
1599
+ if not artist_results:
1600
+ with self.artist_rec_list:
1601
+ ui.label('暂无推荐画师').classes('text-sm text-gray-400 italic p-4')
1602
+ return
1603
+
1604
+ top_tags = top_tags or {}
1605
+ selected_now = set(self._get_selected_tags())
1606
+
1607
+ with self.artist_rec_list:
1608
+ for r in artist_results[:10]:
1609
+ artist = r.artist
1610
+ self._current_artist_rec_tags.add(artist)
1611
+ is_selected = artist in selected_now
1612
+ # 归一化:除以命中标签数,cap 到 100%
1613
+ normalized = min(r.score / max(r.hit_count, 1), 1.0)
1614
+ score_pct = f'+{normalized * 100:.0f}%'
1615
+ sources_str = '、'.join(r.sources[:3]) if r.sources else '—'
1616
+ post_str = f'{r.post_count:,}' if r.post_count else '—'
1617
+
1618
+ # tooltip:画师擅长标签
1619
+ tag_list = top_tags.get(artist, [])
1620
+ tooltip_html = f'<div><b>{artist}</b><br>这位画师经常画:<br>'
1621
+ if tag_list:
1622
+ for t in tag_list[:10]:
1623
+ tooltip_html += f' · {t}<br>'
1624
+ else:
1625
+ tooltip_html += ' (无数据)'
1626
+ tooltip_html += '</div>'
1627
+
1628
+ with ui.row().classes(
1629
+ 'w-full items-center gap-2 px-3 py-2 related-item border-b border-gray-100'
1630
+ ).style('background: rgba(244,114,182,0.04);'):
1631
+ # tooltip
1632
+ with ui.tooltip().props('content-class="bg-black text-white shadow-4" max-width="400px"'):
1633
+ ui.html(tooltip_html).style('font-size:14px;line-height:1.5;max-width:380px;')
1634
+
1635
+ # Checkbox
1636
+ cb = ui.checkbox(
1637
+ '', value=is_selected,
1638
+ on_change=lambda e, t=artist: self._on_artist_rec_checkbox_change(t, e.value)
1639
+ ).props('dense')
1640
+ self._artist_rec_checkboxes[artist] = cb
1641
+
1642
+ # 画师名 + 信息
1643
+ with ui.column().classes('flex-grow gap-0 min-w-0'):
1644
+ ui.link(
1645
+ artist,
1646
+ f'https://danbooru.donmai.us/posts?tags={artist}',
1647
+ new_tab=True,
1648
+ ).classes('text-primary font-bold text-xs')
1649
+ ui.label(f'{sources_str} · 作品 {post_str}').classes('text-xs text-gray-500')
1650
+
1651
+ # 分值
1652
+ score_color = 'green' if normalized > 0.6 else ('teal' if normalized > 0.3 else 'grey')
1653
+ ui.label(score_pct).classes(f'text-sm font-bold text-{score_color}-600 whitespace-nowrap')
1654
+
1655
  def _render_group_expansion(self, group_data: list, selected_tags: list[str], show_nsfw: bool):
1656
  """渲染 Group 同类扩展区域。"""
1657
  if self.group_expansion_container is None:
 
1668
  CAT_BG = {
1669
  'General': 'background-color: rgba(59,130,246,0.06);',
1670
  'Character': 'background-color: rgba(34,197,94,0.06);',
1671
+ 'Copyright': 'background-color: rgba(168,85,247,0.06);',
1672
  }
1673
  CAT_LABEL = {'General': '通用', 'Character': '角色', 'Copyright': '作品'}
1674
 
 
1812
  def copy_selection(self):
1813
  self._mark_interaction()
1814
  tags = self._get_selected_tags()
1815
+ parts = []
1816
+ for t in tags:
1817
+ w = self.tag_weights.get(t, 1.0)
1818
+ if self.prompt_format == 'anima' and t in self._current_artist_rec_tags:
1819
+ parts.append(_format_tag_with_weight(f'@{t}', w, self.prompt_format))
1820
+ else:
1821
+ parts.append(_format_tag_with_weight(t, w, self.prompt_format))
1822
+ prompt = ', '.join(parts)
1823
  ui.clipboard.write(prompt)
1824
  fmt_label = {'sdxl': 'SDXL', 'nai': 'NAI', 'anima': 'Anima'}.get(self.prompt_format, 'SDXL')
1825
  ui.notify(f'已复制选中标签({fmt_label} 格式)!', type='positive')