SAkizuki commited on
Commit
866d034
·
verified ·
1 Parent(s): e3630d8

Auto-sync from GitHub Actions

Browse files
Files changed (5) hide show
  1. README.md +12 -16
  2. api_fastapi.py +56 -61
  3. core/engine.py +86 -9
  4. core/models.py +2 -1
  5. ui_nicegui.py +49 -11
README.md CHANGED
@@ -347,23 +347,20 @@ thumbnail: >-
347
  POST /api/search
348
  {
349
  "query": "白色水手服的女孩",
350
- "search_mode": "full_scene",
351
- "category": "all",
 
352
  "show_nsfw": true,
353
- "include_wiki": false
 
 
 
 
354
  }
355
  ```
356
 
357
- `search_mode` 使用预设检策略,不再直暴露 `top_k`、`limit`、`popularity_weight`、`use_segmentation` 等底层参数
358
-
359
- | 模式 | 适用场景 |
360
- |---|---|
361
- | `full_scene` | 默认模式。用于完整画面、多人/多元素描述,返回可直接使用的 prompt |
362
- | `concept_explore` | 开放式概念发散,例如“兔耳朵都有哪些” |
363
- | `subject_describe` | 单一角色、物品或视觉概念描述,例如“EVA中蓝发的驾驶员” |
364
- | `precise_lookup` | 精确查词或拼写纠错,例如 `selafuku` |
365
-
366
- `category` 可选:`all`、`general`、`character`、`copyright`。搜索响应包含 `prompt`、`keywords`、`results`,当 `include_wiki=true` 时,每条结果会附带 `wiki` 字段。
367
 
368
  **关联推荐接口**
369
  ```
@@ -371,12 +368,11 @@ POST /api/related
371
  {
372
  "tags": ["white_serafuku", "sailor_collar"],
373
  "limit": 50,
374
- "show_nsfw": true,
375
- "include_wiki": false
376
  }
377
  ```
378
 
379
- 响应为 `results` 列表;如果输入标签拼写错误但可被纠正,会额外返回 `correction_note` 和 `corrections`。
380
 
381
  **推荐擅长画师接口**
382
  ```
 
347
  POST /api/search
348
  {
349
  "query": "白色水手服的女孩",
350
+ "top_k": 5,
351
+ "limit": 80,
352
+ "popularity_weight": 0.15,
353
  "show_nsfw": true,
354
+ "use_segmentation": true,
355
+ "target_layers": ["英文", "中文扩展词", "释义", "中文核心词"],
356
+ "target_categories": ["General", "Character", "Copyright"],
357
+ "group_mode": "off",
358
+ "max_per_group": 2
359
  }
360
  ```
361
 
362
+ 索接口保留底层可调参数,适合需要精细控制召回数量、分层检索、分词策略、类别筛选和分组去重的工作流。
363
+ 响应包含 `tags_all`、`tags_sfw`、`keywords`、`results`;每条结果默认包含完整字段和 `wiki`。
 
 
 
 
 
 
 
 
364
 
365
  **关联推荐接口**
366
  ```
 
368
  {
369
  "tags": ["white_serafuku", "sailor_collar"],
370
  "limit": 50,
371
+ "show_nsfw": true
 
372
  }
373
  ```
374
 
375
+ 响应为 `results` 列表,每条结果默认包含 `wiki`;如果输入标签拼写错误但可被纠正,会额外返回 `correction_note` 和 `corrections`。
376
 
377
  **推荐擅长画师接口**
378
  ```
api_fastapi.py CHANGED
@@ -27,10 +27,10 @@ FastAPI 适配层(可选)。
27
  from __future__ import annotations
28
 
29
  import asyncio
30
- import re
31
  from typing import Any
 
32
  from fastapi import FastAPI, HTTPException
33
- from pydantic import BaseModel, Field
34
 
35
  from core.engine import DanbooruTagger
36
  from core.models import SearchRequest, SearchResponse
@@ -39,26 +39,64 @@ import core.counter as counter
39
 
40
  # ── Pydantic I/O 模型(API 层专用,与 core.models 解耦)──
41
 
 
 
 
 
42
 
43
  class SearchIn(BaseModel):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
  query: str
45
- search_mode: str = "full_scene"
46
- category: str = "all"
 
47
  show_nsfw: bool = True
48
- include_wiki: bool = False
 
 
 
 
 
 
 
 
 
49
 
50
 
51
  class TagOut(BaseModel):
52
  tag: str
53
  cn_name: str
 
 
 
 
 
 
 
54
  wiki: str = ""
 
55
 
56
 
57
  class RelatedIn(BaseModel):
58
  tags: list[str]
59
  limit: int = Field(50, ge=1, le=200)
60
  show_nsfw: bool = True
61
- include_wiki: bool = False
62
 
63
 
64
  class RelatedTagOut(BaseModel):
@@ -69,10 +107,10 @@ class RelatedTagOut(BaseModel):
69
 
70
 
71
  class SearchOut(BaseModel):
72
- prompt: str
 
73
  results: list[TagOut]
74
  keywords: list[str]
75
- hint: str | None = None
76
 
77
 
78
  class ArtistIn(BaseModel):
@@ -90,21 +128,6 @@ class ArtistOut(BaseModel):
90
  top_tags: list[str]
91
 
92
 
93
- _SEARCH_MODE_PRESETS: dict[str, dict[str, Any]] = {
94
- "precise_lookup": {"top_k": 10, "limit": 10, "popularity_weight": 0.15, "use_segmentation": False, "group_mode": "off", "max_per_group": 2},
95
- "concept_explore": {"top_k": 80, "limit": 80, "popularity_weight": 0.15, "use_segmentation": True, "group_mode": "expand", "max_per_group": 2},
96
- "subject_describe": {"top_k": 20, "limit": 20, "popularity_weight": 0.15, "use_segmentation": False, "group_mode": "off", "max_per_group": 2},
97
- "full_scene": {"top_k": 5, "limit": 80, "popularity_weight": 0.15, "use_segmentation": True, "group_mode": "diverse", "max_per_group": 2},
98
- }
99
-
100
- _CATEGORY_MAP: dict[str, list[str]] = {
101
- "all": ["General", "Character", "Copyright", "Artist", "Meta"],
102
- "general": ["General"],
103
- "character": ["Character"],
104
- "copyright": ["Copyright"],
105
- }
106
-
107
-
108
  async def _correct_tags(tagger: DanbooruTagger, tags: list[str]) -> tuple[list[str], list[str], dict[str, str]]:
109
  valid_tags: list[str] = []
110
  invalid_tags: list[str] = []
@@ -163,24 +186,12 @@ app = FastAPI(
163
 
164
  # ── 端点 ──
165
 
166
- @app.post("/search")
167
- async def search(body: SearchIn) -> dict[str, Any]:
168
  tagger = await DanbooruTagger.get_instance()
169
 
170
  # SearchIn → core.models.SearchRequest(两者字段一一对应,直接解包)
171
- preset = _SEARCH_MODE_PRESETS.get(body.search_mode, _SEARCH_MODE_PRESETS["full_scene"])
172
- target_categories = _CATEGORY_MAP.get(body.category, _CATEGORY_MAP["all"])
173
- request = SearchRequest(
174
- query=body.query,
175
- top_k=preset["top_k"],
176
- limit=preset["limit"],
177
- popularity_weight=preset["popularity_weight"],
178
- show_nsfw=body.show_nsfw,
179
- use_segmentation=preset["use_segmentation"],
180
- target_categories=target_categories,
181
- group_mode=preset["group_mode"],
182
- max_per_group=preset["max_per_group"],
183
- )
184
 
185
  # 并发安全的异步 search(信号量串行化 + 线程池执行)
186
  try:
@@ -193,27 +204,12 @@ async def search(body: SearchIn) -> dict[str, Any]:
193
  await counter.increment_success()
194
  await counter.increment_copy()
195
 
196
- results: list[dict[str, Any]] = []
197
- for result in response.results:
198
- if result.nsfw == '1' and not body.show_nsfw:
199
- continue
200
- item = {
201
- "tag": result.tag,
202
- "cn_name": result.cn_name,
203
- }
204
- if body.include_wiki:
205
- item["wiki"] = result.wiki
206
- results.append(item)
207
-
208
- payload: dict[str, Any] = {
209
- "prompt": response.tags_sfw if not body.show_nsfw else response.tags_all,
210
- "keywords": response.keywords,
211
- "results": results,
212
- }
213
- han_chars = re.findall(r'[\u4e00-\u9fff]', body.query)
214
- if body.query and len(han_chars) / len(body.query) < 0.5:
215
- payload["hint"] = "检测到英文查询,该搜索引擎对中文查询优化更好,如果搜索结果不合预期,推荐用中文重试"
216
- return payload
217
 
218
 
219
  @app.post("/related")
@@ -250,8 +246,7 @@ async def related(body: RelatedIn) -> dict[str, Any]:
250
  "cn_name": result.cn_name,
251
  "sources": result.sources,
252
  }
253
- if body.include_wiki:
254
- item["wiki"] = result.wiki
255
  output.append(item)
256
 
257
  return _with_corrections(output, corrections)
 
27
  from __future__ import annotations
28
 
29
  import asyncio
 
30
  from typing import Any
31
+ from typing import Literal
32
  from fastapi import FastAPI, HTTPException
33
+ from pydantic import BaseModel, ConfigDict, Field
34
 
35
  from core.engine import DanbooruTagger
36
  from core.models import SearchRequest, SearchResponse
 
39
 
40
  # ── Pydantic I/O 模型(API 层专用,与 core.models 解耦)──
41
 
42
+ LayerName = Literal['英文', '中文扩展词', '释义', '中文核心词', 'artist']
43
+ CategoryName = Literal['General', 'Artist', 'Copyright', 'Character', 'Meta']
44
+ GroupMode = Literal['off', 'expand', 'diverse']
45
+
46
 
47
  class SearchIn(BaseModel):
48
+ model_config = ConfigDict(
49
+ json_schema_extra={
50
+ "example": {
51
+ "query": "白色水手服的女孩",
52
+ "top_k": 5,
53
+ "limit": 80,
54
+ "popularity_weight": 0.15,
55
+ "show_nsfw": True,
56
+ "use_segmentation": True,
57
+ "target_layers": ['英文', '中文扩展词', '释义', '中文核心词', 'artist'],
58
+ "target_categories": ['General', 'Character', 'Copyright'],
59
+ "group_mode": "off",
60
+ "max_per_group": 2,
61
+ }
62
+ }
63
+ )
64
+
65
  query: str
66
+ top_k: int = Field(5, ge=1, le=50)
67
+ limit: int = Field(80, ge=1, le=500)
68
+ popularity_weight: float = Field(0.15, ge=0.0, le=1.0)
69
  show_nsfw: bool = True
70
+ use_segmentation: bool = True
71
+ target_layers: list[LayerName] = Field(
72
+ default_factory=lambda: ['英文', '中文扩展词', '释义', '中文核心词'],
73
+ description="匹配层;可显式加入 'artist' 以返回编辑距离<=1的画师标签行。",
74
+ )
75
+ target_categories: list[CategoryName] = Field(
76
+ default_factory=lambda: ['General', 'Character', 'Copyright'],
77
+ )
78
+ group_mode: GroupMode = "off"
79
+ max_per_group: int = 2
80
 
81
 
82
  class TagOut(BaseModel):
83
  tag: str
84
  cn_name: str
85
+ category: str
86
+ nsfw: str
87
+ final_score: float
88
+ semantic_score: float
89
+ count: int
90
+ source: str
91
+ layer: str
92
  wiki: str = ""
93
+ artist_top_tags: list[str] = Field(default_factory=list)
94
 
95
 
96
  class RelatedIn(BaseModel):
97
  tags: list[str]
98
  limit: int = Field(50, ge=1, le=200)
99
  show_nsfw: bool = True
 
100
 
101
 
102
  class RelatedTagOut(BaseModel):
 
107
 
108
 
109
  class SearchOut(BaseModel):
110
+ tags_all: str
111
+ tags_sfw: str
112
  results: list[TagOut]
113
  keywords: list[str]
 
114
 
115
 
116
  class ArtistIn(BaseModel):
 
128
  top_tags: list[str]
129
 
130
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
131
  async def _correct_tags(tagger: DanbooruTagger, tags: list[str]) -> tuple[list[str], list[str], dict[str, str]]:
132
  valid_tags: list[str] = []
133
  invalid_tags: list[str] = []
 
186
 
187
  # ── 端点 ──
188
 
189
+ @app.post("/search", response_model=SearchOut)
190
+ async def search(body: SearchIn) -> SearchOut:
191
  tagger = await DanbooruTagger.get_instance()
192
 
193
  # SearchIn → core.models.SearchRequest(两者字段一一对应,直接解包)
194
+ request = SearchRequest(**body.model_dump())
 
 
 
 
 
 
 
 
 
 
 
 
195
 
196
  # 并发安全的异步 search(信号量串行化 + 线程池执行)
197
  try:
 
204
  await counter.increment_success()
205
  await counter.increment_copy()
206
 
207
+ return SearchOut(
208
+ tags_all=response.tags_all,
209
+ tags_sfw=response.tags_sfw,
210
+ results=[TagOut(**vars(result)) for result in response.results],
211
+ keywords=response.keywords,
212
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
213
 
214
 
215
  @app.post("/related")
 
246
  "cn_name": result.cn_name,
247
  "sources": result.sources,
248
  }
249
+ item["wiki"] = result.wiki
 
250
  output.append(item)
251
 
252
  return _with_corrections(output, corrections)
core/engine.py CHANGED
@@ -396,6 +396,29 @@ class DanbooruTagger:
396
  if cached is not None:
397
  return cached
398
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
399
  if request.use_segmentation:
400
  raw_kw, raw_segments = self._smart_split(request.query)
401
  keywords = [w.strip() for w in raw_kw if w.strip() and w.strip() not in STOP_WORDS]
@@ -554,16 +577,8 @@ class DanbooruTagger:
554
  if len(valid) < request.limit or r.tag in guaranteed_tags:
555
  valid.append(r)
556
 
557
- tags_all = ', '.join(r.tag for r in valid)
558
- tags_sfw = ', '.join(r.tag for r in valid if r.nsfw != '1')
559
  cached_queries = [q for q, hit in zip(queries, hit_mask) if hit]
560
- response = SearchResponse(
561
- tags_all=tags_all, tags_sfw=tags_sfw,
562
- results=valid, keywords=keywords, segments=extra_segments,
563
- cached_queries=cached_queries,
564
- )
565
- self._search_cache.put(cache_key, response)
566
- return response
567
 
568
  # ── CPU 并发闸门(类级信号量,所有 CPU 密集型操作共享)───────────────
569
 
@@ -1339,6 +1354,68 @@ class DanbooruTagger:
1339
  def _compact_artist_key(name: str) -> str:
1340
  return re.sub(r"[\W_]+", "", str(name or "").lower())
1341
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1342
  def resolve_artist_name(self, artist_name: str) -> dict[str, Any]:
1343
  """Resolve a user-entered artist name to the artist co-occurrence index."""
1344
  artists = set(self._artist_top_tags.keys())
 
396
  if cached is not None:
397
  return cached
398
 
399
+ tag_results, keywords, extra_segments, cached_queries = self._search_tag_results(request)
400
+ artist_results = (
401
+ self.search_artist_rows(request.query, request.limit, show_nsfw=request.show_nsfw)
402
+ if "artist" in request.target_layers else []
403
+ )
404
+ if artist_results:
405
+ artist_tags = {r.tag for r in artist_results}
406
+ tag_results = [r for r in tag_results if r.tag not in artist_tags]
407
+ valid = artist_results + tag_results
408
+
409
+ tags_all = ', '.join(r.tag for r in valid)
410
+ tags_sfw = ', '.join(r.tag for r in valid if r.nsfw != '1')
411
+ response = SearchResponse(
412
+ tags_all=tags_all, tags_sfw=tags_sfw,
413
+ results=valid, keywords=keywords, segments=extra_segments,
414
+ cached_queries=cached_queries,
415
+ )
416
+ self._search_cache.put(cache_key, response)
417
+ return response
418
+
419
+ def _search_tag_results(
420
+ self, request: SearchRequest,
421
+ ) -> tuple[list[TagResult], list[str], list[str], list[str]]:
422
  if request.use_segmentation:
423
  raw_kw, raw_segments = self._smart_split(request.query)
424
  keywords = [w.strip() for w in raw_kw if w.strip() and w.strip() not in STOP_WORDS]
 
577
  if len(valid) < request.limit or r.tag in guaranteed_tags:
578
  valid.append(r)
579
 
 
 
580
  cached_queries = [q for q, hit in zip(queries, hit_mask) if hit]
581
+ return valid, keywords, extra_segments, cached_queries
 
 
 
 
 
 
582
 
583
  # ── CPU 并发闸门(类级信号量,所有 CPU 密集型操作共享)───────────────
584
 
 
1354
  def _compact_artist_key(name: str) -> str:
1355
  return re.sub(r"[\W_]+", "", str(name or "").lower())
1356
 
1357
+ @staticmethod
1358
+ def _edit_distance_at_most_one(left: str, right: str) -> bool:
1359
+ if left == right:
1360
+ return True
1361
+ if abs(len(left) - len(right)) > 1:
1362
+ return False
1363
+
1364
+ if len(left) == len(right):
1365
+ mismatches = 0
1366
+ for a, b in zip(left, right):
1367
+ if a != b:
1368
+ mismatches += 1
1369
+ if mismatches > 1:
1370
+ return False
1371
+ return True
1372
+
1373
+ short, long = (left, right) if len(left) < len(right) else (right, left)
1374
+ i = j = edits = 0
1375
+ while i < len(short) and j < len(long):
1376
+ if short[i] == long[j]:
1377
+ i += 1
1378
+ j += 1
1379
+ continue
1380
+ edits += 1
1381
+ if edits > 1:
1382
+ return False
1383
+ j += 1
1384
+ return True
1385
+
1386
+ def search_artist_rows(
1387
+ self, query: str, limit: int = 20, show_nsfw: bool = True,
1388
+ ) -> list[TagResult]:
1389
+ """Return artist rows whose normalized name is within edit distance 1."""
1390
+ normalized = self._normalize_artist_name(query)
1391
+ compact_query = self._compact_artist_key(normalized)
1392
+ if not compact_query:
1393
+ return []
1394
+
1395
+ matches: list[TagResult] = []
1396
+ for artist in sorted(self._artist_top_tags.keys()):
1397
+ if not self._edit_distance_at_most_one(compact_query, self._compact_artist_key(artist)):
1398
+ continue
1399
+ top_tags = self.get_artist_top_tags(
1400
+ [artist], top_n=10, show_nsfw=show_nsfw,
1401
+ ).get(artist, [])
1402
+ matches.append(TagResult(
1403
+ tag=artist,
1404
+ cn_name="画师标签",
1405
+ category="Artist",
1406
+ nsfw="0",
1407
+ final_score=1.0,
1408
+ semantic_score=1.0,
1409
+ count=int(self._artist_post_count.get(artist, 0)),
1410
+ source=query,
1411
+ layer="artist",
1412
+ wiki="",
1413
+ artist_top_tags=top_tags,
1414
+ ))
1415
+
1416
+ matches.sort(key=lambda r: (r.count, r.tag), reverse=True)
1417
+ return matches[:limit]
1418
+
1419
  def resolve_artist_name(self, artist_name: str) -> dict[str, Any]:
1420
  """Resolve a user-entered artist name to the artist co-occurrence index."""
1421
  artists = set(self._artist_top_tags.keys())
core/models.py CHANGED
@@ -18,6 +18,7 @@ class TagResult:
18
  source: str
19
  layer: str
20
  wiki: str = ""
 
21
 
22
 
23
  @dataclass
@@ -72,4 +73,4 @@ class SearchResponse:
72
  results: list[TagResult]
73
  keywords: list[str]
74
  segments: list[str] = field(default_factory=list) # 分隔符切分后的原始从句级片段
75
- cached_queries: list[str] = field(default_factory=list) # 命中 emb 缓存的查询文本
 
18
  source: str
19
  layer: str
20
  wiki: str = ""
21
+ artist_top_tags: list[str] = field(default_factory=list)
22
 
23
 
24
  @dataclass
 
73
  results: list[TagResult]
74
  keywords: list[str]
75
  segments: list[str] = field(default_factory=list) # 分隔符切分后的原始从句级片段
76
+ cached_queries: list[str] = field(default_factory=list) # 命中 emb 缓存的查询文本
ui_nicegui.py CHANGED
@@ -207,7 +207,7 @@ class DanbooruSearchUI:
207
  self.spinner = None
208
  self.search_btn = None
209
 
210
- self.selected_layers = {'英文': True, '中文扩展词': True, '释义': True, '中文核心词': True}
211
  self.selected_cats = {'General': True, 'Copyright': True, 'Character': True}
212
 
213
  self.bad_case_btn = None
@@ -229,6 +229,8 @@ class DanbooruSearchUI:
229
  self._artist_rec_checkboxes: dict[str, ui.checkbox] = {}
230
  # 当前推荐画师的标签名集合(用于 Anima 模式复制时加 @ 前缀)
231
  self._current_artist_rec_tags: set[str] = set()
 
 
232
 
233
  # 高级选项中各层/类型的 checkbox 引用,用于 restore 时同步控件状态
234
  self._layer_checkboxes: dict[str, ui.checkbox] = {}
@@ -739,8 +741,9 @@ class DanbooruSearchUI:
739
  display_map = {
740
  '英文': '英文标签', '中文扩展词': '中文扩展词',
741
  '释义': '维基释义', '中文核心词': '中文核心词',
 
742
  }
743
- for layer in ['英文', '中文扩展词', '释义', '中文核心词']:
744
  cb = ui.checkbox(
745
  display_map.get(layer, layer), value=True,
746
  on_change=lambda e, l=layer: self.selected_layers.__setitem__(l, e.value)
@@ -981,6 +984,8 @@ class DanbooruSearchUI:
981
  self.result_table.selected = []
982
  self._artist_rec_checkboxes.clear()
983
  self._current_artist_rec_tags.clear()
 
 
984
  self._render_selected_chips()
985
  if self.selection_count_label is not None:
986
  self.selection_count_label.text = '0'
@@ -1025,6 +1030,7 @@ class DanbooruSearchUI:
1025
  :class="props.row._nsfw_blocked ? 'nsfw-row-blocked' : ''"
1026
  :style="{
1027
  'background-color':
 
1028
  props.row.category === 'General' ? 'rgba(59,130,246,0.06)' :
1029
  props.row.category === 'Character' ? 'rgba(34,197,94,0.06)' :
1030
  props.row.category === 'Copyright' ? 'rgba(168,85,247,0.06)' : ''
@@ -1036,7 +1042,7 @@ class DanbooruSearchUI:
1036
  <q-td v-for="col in props.cols" :key="col.name" :props="props">
1037
  <template v-if="col.name === 'tag' || col.name === 'cn_name'">
1038
  <div :class="props.row._nsfw_blocked ? 'nsfw-blur-cell' : ''">
1039
- <template v-if="col.name === 'cn_name' && col.value">
1040
  <span style="font-size:14px;display:inline-flex;align-items:center;gap:4px;">
1041
  <span>{{ col.value.split(',')[0] }}</span>
1042
  <q-btn icon="report_problem"
@@ -1073,7 +1079,17 @@ class DanbooruSearchUI:
1073
  </template>
1074
  <template v-else>{{ col.value }}</template>
1075
  </q-td>
1076
- <q-tooltip v-if="(props.row.wiki || props.row.cn_name) && !props.row._nsfw_blocked"
 
 
 
 
 
 
 
 
 
 
1077
  content-class="bg-black text-white shadow-4"
1078
  max-width="500px" :offset="[10,10]">
1079
  <div style="font-size:14px;line-height:1.5;">
@@ -1353,6 +1369,7 @@ class DanbooruSearchUI:
1353
  return
1354
 
1355
  table_data = [result_to_row(r, show_nsfw_val) for r in response.results]
 
1356
  self.full_table_data = table_data
1357
  self.full_tags_str = response.tags_all
1358
  self.full_tags_str_sfw = response.tags_sfw
@@ -1372,6 +1389,7 @@ class DanbooruSearchUI:
1372
  self._save_staged_tags()
1373
 
1374
  self._refresh_related([], show_nsfw_val)
 
1375
 
1376
  # 分词筛选 chips
1377
  self.current_filter_keyword = 'ALL'
@@ -1436,6 +1454,26 @@ class DanbooruSearchUI:
1436
  extra = [t for t in self.chip_extra_selected if t not in seen]
1437
  return table_tags + extra
1438
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1439
  def _set_selected_tags(self, tags: list[str], skip_refresh: bool = False):
1440
  tag_set = set(tags)
1441
  table_tag_set = {row['tag'] for row in self.result_table.rows} if self.result_table else set()
@@ -1463,9 +1501,7 @@ class DanbooruSearchUI:
1463
  # 从关联推荐/同类标签勾选时跳过,由各自动态刷新或手动按钮触发。
1464
  if not skip_refresh:
1465
  show_nsfw_val = self.input_nsfw.value
1466
- self._refresh_related_from_selection(all_tags, show_nsfw_val)
1467
- self._refresh_group_from_selection(all_tags, show_nsfw_val)
1468
- self._refresh_artist_from_selection(all_tags, show_nsfw_val)
1469
  if not all_tags:
1470
  self.chip_extra_selected.clear()
1471
 
@@ -1493,9 +1529,7 @@ class DanbooruSearchUI:
1493
  cb.set_value(t in tag_set)
1494
 
1495
  show_nsfw_val = self.input_nsfw.value
1496
- self._refresh_related_from_selection(all_tags, show_nsfw_val)
1497
- self._refresh_group_from_selection(all_tags, show_nsfw_val)
1498
- self._refresh_artist_from_selection(all_tags, show_nsfw_val)
1499
  if not all_tags:
1500
  self.chip_extra_selected.clear()
1501
  self._save_staged_tags()
@@ -1605,6 +1639,7 @@ class DanbooruSearchUI:
1605
 
1606
  def _refresh_related_from_selection(self, selected_tags: list[str], show_nsfw: bool):
1607
  """仅刷新关联推荐列表(300ms 去抖,避免快速勾选产生 CPU 洪峰)。"""
 
1608
  # 取消上次未执行的刷新
1609
  if self._debounce_related_task and not self._debounce_related_task.done():
1610
  self._debounce_related_task.cancel()
@@ -1625,6 +1660,7 @@ class DanbooruSearchUI:
1625
 
1626
  def _refresh_group_from_selection(self, selected_tags: list[str], show_nsfw: bool):
1627
  """仅刷新同类扩展区域(300ms 去抖,避免快速勾选产生 CPU 洪峰)。"""
 
1628
  if self._debounce_group_task and not self._debounce_group_task.done():
1629
  self._debounce_group_task.cancel()
1630
  async def _do():
@@ -1645,6 +1681,7 @@ class DanbooruSearchUI:
1645
 
1646
  def _refresh_artist_from_selection(self, selected_tags: list[str], show_nsfw: bool = True):
1647
  """根据已选标签刷新画师推荐(300ms 去抖)。"""
 
1648
  if self._debounce_artist_task and not self._debounce_artist_task.done():
1649
  self._debounce_artist_task.cancel()
1650
  async def _do():
@@ -1888,9 +1925,10 @@ class DanbooruSearchUI:
1888
  self._mark_interaction()
1889
  tags = self._get_selected_tags()
1890
  parts = []
 
1891
  for t in tags:
1892
  w = self.tag_weights.get(t, 1.0)
1893
- if self.prompt_format == 'anima' and t in self._current_artist_rec_tags:
1894
  parts.append(_format_tag_with_weight(f'@{t}', w, self.prompt_format))
1895
  else:
1896
  parts.append(_format_tag_with_weight(t, w, self.prompt_format))
 
207
  self.spinner = None
208
  self.search_btn = None
209
 
210
+ self.selected_layers = {'英文': True, '中文扩展词': True, '释义': True, '中文核心词': True, 'artist': True}
211
  self.selected_cats = {'General': True, 'Copyright': True, 'Character': True}
212
 
213
  self.bad_case_btn = None
 
229
  self._artist_rec_checkboxes: dict[str, ui.checkbox] = {}
230
  # 当前推荐画师的标签名集合(用于 Anima 模式复制时加 @ 前缀)
231
  self._current_artist_rec_tags: set[str] = set()
232
+ self._artist_result_tags: set[str] = set()
233
+ self._last_recommendation_seed_tags: list[str] = []
234
 
235
  # 高级选项中各层/类型的 checkbox 引用,用于 restore 时同步控件状态
236
  self._layer_checkboxes: dict[str, ui.checkbox] = {}
 
741
  display_map = {
742
  '英文': '英文标签', '中文扩展词': '中文扩展词',
743
  '释义': '维基释义', '中文核心词': '中文核心词',
744
+ 'artist': 'artist',
745
  }
746
+ for layer in ['英文', '中文扩展词', '释义', '中文核心词', 'artist']:
747
  cb = ui.checkbox(
748
  display_map.get(layer, layer), value=True,
749
  on_change=lambda e, l=layer: self.selected_layers.__setitem__(l, e.value)
 
984
  self.result_table.selected = []
985
  self._artist_rec_checkboxes.clear()
986
  self._current_artist_rec_tags.clear()
987
+ self._artist_result_tags.clear()
988
+ self._last_recommendation_seed_tags = []
989
  self._render_selected_chips()
990
  if self.selection_count_label is not None:
991
  self.selection_count_label.text = '0'
 
1030
  :class="props.row._nsfw_blocked ? 'nsfw-row-blocked' : ''"
1031
  :style="{
1032
  'background-color':
1033
+ props.row.layer === 'artist' ? 'rgba(244,114,182,0.08)' :
1034
  props.row.category === 'General' ? 'rgba(59,130,246,0.06)' :
1035
  props.row.category === 'Character' ? 'rgba(34,197,94,0.06)' :
1036
  props.row.category === 'Copyright' ? 'rgba(168,85,247,0.06)' : ''
 
1042
  <q-td v-for="col in props.cols" :key="col.name" :props="props">
1043
  <template v-if="col.name === 'tag' || col.name === 'cn_name'">
1044
  <div :class="props.row._nsfw_blocked ? 'nsfw-blur-cell' : ''">
1045
+ <template v-if="col.name === 'cn_name' && col.value && props.row.layer !== 'artist'">
1046
  <span style="font-size:14px;display:inline-flex;align-items:center;gap:4px;">
1047
  <span>{{ col.value.split(',')[0] }}</span>
1048
  <q-btn icon="report_problem"
 
1079
  </template>
1080
  <template v-else>{{ col.value }}</template>
1081
  </q-td>
1082
+ <q-tooltip v-if="props.row.layer === 'artist' && props.row.artist_top_tags && props.row.artist_top_tags.length && !props.row._nsfw_blocked"
1083
+ content-class="bg-black text-white shadow-4"
1084
+ max-width="400px" :offset="[10,10]">
1085
+ <div style="font-size:14px;line-height:1.5;max-width:380px;">
1086
+ <b>{{ props.row.tag }}</b><br>这位画师经常画:<br>
1087
+ <template v-for="tag in props.row.artist_top_tags.slice(0, 10)" :key="tag">
1088
+ &nbsp;&nbsp;· {{ tag }}<br>
1089
+ </template>
1090
+ </div>
1091
+ </q-tooltip>
1092
+ <q-tooltip v-else-if="(props.row.wiki || props.row.cn_name) && !props.row._nsfw_blocked"
1093
  content-class="bg-black text-white shadow-4"
1094
  max-width="500px" :offset="[10,10]">
1095
  <div style="font-size:14px;line-height:1.5;">
 
1369
  return
1370
 
1371
  table_data = [result_to_row(r, show_nsfw_val) for r in response.results]
1372
+ self._artist_result_tags = {row['tag'] for row in table_data if row.get('layer') == 'artist'}
1373
  self.full_table_data = table_data
1374
  self.full_tags_str = response.tags_all
1375
  self.full_tags_str_sfw = response.tags_sfw
 
1389
  self._save_staged_tags()
1390
 
1391
  self._refresh_related([], show_nsfw_val)
1392
+ self._last_recommendation_seed_tags = []
1393
 
1394
  # 分词筛选 chips
1395
  self.current_filter_keyword = 'ALL'
 
1454
  extra = [t for t in self.chip_extra_selected if t not in seen]
1455
  return table_tags + extra
1456
 
1457
+ def _get_recommendation_seed_tags(self, selected_tags: list[str]) -> list[str]:
1458
+ artist_tags = set(self._current_artist_rec_tags) | set(self._artist_result_tags)
1459
+ if self.result_table is not None:
1460
+ for row in self.result_table.rows:
1461
+ if row.get('layer') != 'artist':
1462
+ continue
1463
+ tag = row.get('tag')
1464
+ if tag:
1465
+ artist_tags.add(tag)
1466
+ return [tag for tag in selected_tags if tag not in artist_tags]
1467
+
1468
+ def _refresh_recommendations_if_seed_changed(self, selected_tags: list[str], show_nsfw: bool):
1469
+ seed_tags = self._get_recommendation_seed_tags(selected_tags)
1470
+ if seed_tags == self._last_recommendation_seed_tags:
1471
+ return
1472
+ self._last_recommendation_seed_tags = list(seed_tags)
1473
+ self._refresh_related_from_selection(seed_tags, show_nsfw)
1474
+ self._refresh_group_from_selection(seed_tags, show_nsfw)
1475
+ self._refresh_artist_from_selection(seed_tags, show_nsfw)
1476
+
1477
  def _set_selected_tags(self, tags: list[str], skip_refresh: bool = False):
1478
  tag_set = set(tags)
1479
  table_tag_set = {row['tag'] for row in self.result_table.rows} if self.result_table else set()
 
1501
  # 从关联推荐/同类标签勾选时跳过,由各自动态刷新或手动按钮触发。
1502
  if not skip_refresh:
1503
  show_nsfw_val = self.input_nsfw.value
1504
+ self._refresh_recommendations_if_seed_changed(all_tags, show_nsfw_val)
 
 
1505
  if not all_tags:
1506
  self.chip_extra_selected.clear()
1507
 
 
1529
  cb.set_value(t in tag_set)
1530
 
1531
  show_nsfw_val = self.input_nsfw.value
1532
+ self._refresh_recommendations_if_seed_changed(all_tags, show_nsfw_val)
 
 
1533
  if not all_tags:
1534
  self.chip_extra_selected.clear()
1535
  self._save_staged_tags()
 
1639
 
1640
  def _refresh_related_from_selection(self, selected_tags: list[str], show_nsfw: bool):
1641
  """仅刷新关联推荐列表(300ms 去抖,避免快速勾选产生 CPU 洪峰)。"""
1642
+ selected_tags = self._get_recommendation_seed_tags(selected_tags)
1643
  # 取消上次未执行的刷新
1644
  if self._debounce_related_task and not self._debounce_related_task.done():
1645
  self._debounce_related_task.cancel()
 
1660
 
1661
  def _refresh_group_from_selection(self, selected_tags: list[str], show_nsfw: bool):
1662
  """仅刷新同类扩展区域(300ms 去抖,避免快速勾选产生 CPU 洪峰)。"""
1663
+ selected_tags = self._get_recommendation_seed_tags(selected_tags)
1664
  if self._debounce_group_task and not self._debounce_group_task.done():
1665
  self._debounce_group_task.cancel()
1666
  async def _do():
 
1681
 
1682
  def _refresh_artist_from_selection(self, selected_tags: list[str], show_nsfw: bool = True):
1683
  """根据已选标签刷新画师推荐(300ms 去抖)。"""
1684
+ selected_tags = self._get_recommendation_seed_tags(selected_tags)
1685
  if self._debounce_artist_task and not self._debounce_artist_task.done():
1686
  self._debounce_artist_task.cancel()
1687
  async def _do():
 
1925
  self._mark_interaction()
1926
  tags = self._get_selected_tags()
1927
  parts = []
1928
+ artist_tags = set(self._current_artist_rec_tags) | set(self._artist_result_tags)
1929
  for t in tags:
1930
  w = self.tag_weights.get(t, 1.0)
1931
+ if self.prompt_format == 'anima' and t in artist_tags:
1932
  parts.append(_format_tag_with_weight(f'@{t}', w, self.prompt_format))
1933
  else:
1934
  parts.append(_format_tag_with_weight(t, w, self.prompt_format))