SAkizuki commited on
Commit
57c49db
·
verified ·
1 Parent(s): 725431e

Auto-sync from GitHub Actions

Browse files
Files changed (4) hide show
  1. .gitignore +1 -1
  2. api_fastapi.py +157 -65
  3. mcp_server.py +18 -26
  4. ui_nicegui.py +28 -3
.gitignore CHANGED
@@ -20,4 +20,4 @@ CLAUDE.md
20
  docs/
21
  .*/
22
  /AGENTS.md
23
- test/
 
20
  docs/
21
  .*/
22
  /AGENTS.md
23
+ tests/
api_fastapi.py CHANGED
@@ -27,39 +27,30 @@ FastAPI 适配层(可选)。
27
  from __future__ import annotations
28
 
29
  import asyncio
 
 
30
  from fastapi import FastAPI, HTTPException
31
  from pydantic import BaseModel, Field
32
 
33
  from core.engine import DanbooruTagger
34
- from core.models import SearchRequest, SearchResponse, TagResult, RelatedTag
35
  import core.counter as counter
36
 
37
 
38
  # ── Pydantic I/O 模型(API 层专用,与 core.models 解耦)──
39
 
 
40
  class SearchIn(BaseModel):
41
  query: str
42
- top_k: int = Field(5, ge=1, le=50)
43
- limit: int = Field(80, ge=1, le=500)
44
- popularity_weight: float = Field(0.15, ge=0.0, le=1.0)
45
  show_nsfw: bool = True
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):
54
  tag: str
55
  cn_name: str
56
- category: str
57
- nsfw: str
58
- final_score: float
59
- semantic_score: float
60
- count: int
61
- source: str
62
- layer: str
63
  wiki: str = ""
64
 
65
 
@@ -67,40 +58,98 @@ class RelatedIn(BaseModel):
67
  tags: list[str]
68
  limit: int = Field(50, ge=1, le=200)
69
  show_nsfw: bool = True
 
70
 
71
 
72
  class RelatedTagOut(BaseModel):
73
  tag: str
74
  cn_name: str
75
- category: str
76
- nsfw: str
77
- cooc_count: int
78
- cooc_score: float
79
  sources: list[str]
80
- post_count: int = 0
81
  wiki: str = ""
82
 
83
 
84
  class SearchOut(BaseModel):
85
- tags_all: str
86
- tags_sfw: str
87
  results: list[TagOut]
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 路径下)──
@@ -114,12 +163,24 @@ app = FastAPI(
114
 
115
  # ── 端点 ──
116
 
117
- @app.post("/search", response_model=SearchOut)
118
- async def search(body: SearchIn) -> SearchOut:
119
  tagger = await DanbooruTagger.get_instance()
120
 
121
  # SearchIn → core.models.SearchRequest(两者字段一一对应,直接解包)
122
- request = SearchRequest(**body.model_dump())
 
 
 
 
 
 
 
 
 
 
 
 
123
 
124
  # 并发安全的异步 search(信号量串行化 + 线程池执行)
125
  try:
@@ -132,16 +193,31 @@ async def search(body: SearchIn) -> SearchOut:
132
  await counter.increment_success()
133
  await counter.increment_copy()
134
 
135
- return SearchOut(
136
- tags_all=response.tags_all,
137
- tags_sfw=response.tags_sfw,
138
- results=[TagOut(**vars(r)) for r in response.results],
139
- keywords=response.keywords,
140
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
141
 
142
 
143
- @app.post("/related", response_model=list[RelatedTagOut])
144
- async def related(body: RelatedIn) -> list[RelatedTagOut]:
145
  """
146
  给定已选标签列表,返回基于共现表的关联推荐。
147
 
@@ -150,9 +226,15 @@ async def related(body: RelatedIn) -> list[RelatedTagOut]:
150
  - show_nsfw:是否包含 NSFW 标签,默认 True
151
  """
152
  tagger = await DanbooruTagger.get_instance()
 
 
 
 
 
 
153
  results = await tagger.get_related_async(
154
- body.tags,
155
- set(body.tags), # exclude 已选标签自身
156
  body.limit,
157
  body.show_nsfw,
158
  )
@@ -161,24 +243,22 @@ async def related(body: RelatedIn) -> list[RelatedTagOut]:
161
  await counter.increment_success()
162
  await counter.increment_copy()
163
 
164
- return [
165
- RelatedTagOut(
166
- tag=r.tag,
167
- cn_name=r.cn_name,
168
- category=r.category,
169
- nsfw=r.nsfw,
170
- cooc_count=r.cooc_count,
171
- cooc_score=r.cooc_score,
172
- sources=r.sources,
173
- post_count=r.post_count,
174
- wiki=r.wiki,
175
- )
176
- for r in results
177
- ]
178
 
179
 
180
- @app.post("/artists", response_model=list[ArtistOut])
181
- async def artists(body: ArtistIn) -> list[ArtistOut]:
182
  """
183
  给定标签列表,推荐擅长绘制这些标签的画师(基于 NPMI 共现数据)。
184
 
@@ -187,25 +267,37 @@ async def artists(body: ArtistIn) -> list[ArtistOut]:
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")
 
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
37
  import core.counter as counter
38
 
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
 
 
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):
65
  tag: str
66
  cn_name: str
 
 
 
 
67
  sources: list[str]
 
68
  wiki: str = ""
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):
79
  tags: list[str]
80
  limit: int = Field(30, ge=1, le=100)
81
  min_cooc: int = Field(3, ge=1, le=100)
82
+ show_nsfw: bool = True
83
 
84
 
85
  class ArtistOut(BaseModel):
86
  artist: str
 
87
  cooc_count: int
88
  post_count: int
89
  sources: list[str]
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] = []
111
+ for tag in tags:
112
+ if tag in tagger._name_to_idx:
113
+ valid_tags.append(tag)
114
+ else:
115
+ invalid_tags.append(tag)
116
+
117
+ corrections: dict[str, str] = {}
118
+ for bad_tag in invalid_tags:
119
+ try:
120
+ request = SearchRequest(
121
+ query=bad_tag,
122
+ top_k=5,
123
+ limit=5,
124
+ popularity_weight=0.15,
125
+ use_segmentation=False,
126
+ target_layers=['英文'],
127
+ )
128
+ response = await tagger.search_async(request)
129
+ if response.results:
130
+ corrections[bad_tag] = response.results[0].tag
131
+ except Exception:
132
+ pass
133
+
134
+ corrected_tags: list[str] = []
135
+ for tag in tags:
136
+ if tag in valid_tags:
137
+ corrected_tags.append(tag)
138
+ elif tag in corrections:
139
+ corrected_tags.append(corrections[tag])
140
+
141
+ return corrected_tags, invalid_tags, corrections
142
+
143
+
144
+ def _with_corrections(results: list[dict[str, Any]], corrections: dict[str, str]) -> dict[str, Any]:
145
+ if not corrections:
146
+ return {"results": results}
147
+ correction_notes = [f"{bad} → {good}" for bad, good in corrections.items()]
148
+ return {
149
+ "correction_note": "标签拼写错误,已经纠错: " + ", ".join(correction_notes),
150
+ "corrections": corrections,
151
+ "results": results,
152
+ }
153
 
154
 
155
  # ── FastAPI 子应用(挂载到 NiceGUI 的 /api 路径下)──
 
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
  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")
220
+ async def related(body: RelatedIn) -> dict[str, Any]:
221
  """
222
  给定已选标签列表,返回基于共现表的关联推荐。
223
 
 
226
  - show_nsfw:是否包含 NSFW 标签,默认 True
227
  """
228
  tagger = await DanbooruTagger.get_instance()
229
+ corrected_tags, invalid_tags, corrections = await _correct_tags(tagger, body.tags)
230
+ if not corrected_tags:
231
+ return {
232
+ "error": "所有传入的标签均不存在于标签表中",
233
+ "invalid_tags": invalid_tags,
234
+ }
235
  results = await tagger.get_related_async(
236
+ corrected_tags,
237
+ set(corrected_tags),
238
  body.limit,
239
  body.show_nsfw,
240
  )
 
243
  await counter.increment_success()
244
  await counter.increment_copy()
245
 
246
+ output: list[dict[str, Any]] = []
247
+ for result in results:
248
+ item = {
249
+ "tag": result.tag,
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)
 
 
258
 
259
 
260
+ @app.post("/artists")
261
+ async def artists(body: ArtistIn) -> dict[str, Any]:
262
  """
263
  给定标签列表,推荐擅长绘制这些标签的画师(基于 NPMI 共现数据)。
264
 
 
267
  - min_cooc:单个 (tag, artist) 对的最小共现次数,默认 3
268
  """
269
  tagger = await DanbooruTagger.get_instance()
270
+ if not body.tags:
271
+ return {"error": "tags 列表不能为空"}
272
+
273
+ corrected_tags, invalid_tags, corrections = await _correct_tags(tagger, body.tags)
274
+ if not corrected_tags:
275
+ return {
276
+ "error": "所有传入的标签均不存在于标签表中",
277
+ "invalid_tags": invalid_tags,
278
+ }
279
+
280
  results = await tagger.search_artists_by_tags_async(
281
+ corrected_tags, limit=body.limit, min_cooc=body.min_cooc,
282
  )
283
+ artist_names = [result.artist for result in results]
284
+ top_tags_map = tagger.get_artist_top_tags(artist_names, show_nsfw=body.show_nsfw)
285
  # 计数
286
  await counter.increment()
287
  await counter.increment_success()
288
  await counter.increment_copy()
289
 
290
+ output = [
291
+ {
292
+ "artist": result.artist,
293
+ "cooc_count": result.cooc_count,
294
+ "post_count": result.post_count,
295
+ "sources": result.sources,
296
+ "top_tags": top_tags_map.get(result.artist, []),
297
+ }
298
+ for result in results
 
299
  ]
300
+ return _with_corrections(output, corrections)
301
 
302
 
303
  @app.get("/health")
mcp_server.py CHANGED
@@ -90,18 +90,26 @@ Only supported for general, copyright, and character tag searches; **artists and
90
  "full_scene" — **DEFAULT.** Use whenever the user gives a concrete picture description: a specific
91
  scene, subject(s), clothing, pose, action, or background — no matter how detailed or how
92
  many elements. The user wants ONE coherent prompt for ONE intended image.
93
- (e.g. "一个穿着白色水手服的少女在雨中奔跑", "金发双马尾女孩坐在教室窗边看书,夕阳")
 
94
  "concept_explore" — **ONLY for open-ended browsing**, when the user wants to SEE A VARIETY of options for a
95
  vague/single concept and pick from them — i.e. "show me what kinds of X exist".
96
  Returns up to 80 candidates → high token cost. Do NOT use just because a description has
97
  many elements; a detailed scene is still "full_scene".
98
  (e.g. "各种各样的汉服", "兔耳朵都有哪些", "赛博朋克服装有什么风格")
99
- "subject_describe" — Describe **one** subject to find matching tags (e.g. "EVA中蓝发的驾驶员", "两侧有开口,前方有拉绳的运动短裤")
 
 
 
 
 
100
  "precise_lookup" — Precise lookup / spell fix (e.g. "selafuku", "thighhigh")
101
  - DECISION RULE: Does the user want one specific picture (→ full_scene) or to browse many options for a concept
102
  (→ concept_explore)? A long, multi-element description still maps to full_scene — element count is NOT the signal,
103
  exploratory intent is.
104
- - HINT: In subject_describe mode, the tokenizer is disabled, and you can only describe one thing at a time. To search for multiple things at once, use full_scene (for a specific image) or concept_explore (for browsing).
 
 
105
  - category: Filter to a specific tag category. Default "all".
106
  "all" — All (通用 + 版权 + 人物)
107
  "general" — Visual attributes, clothing, pose, background, etc.
@@ -471,7 +479,7 @@ Anima 是一个 2B 参数的文生图模型(CircleStone Labs × Comfy Org)
471
 
472
  ## 情境因果锁(组装前必做)
473
 
474
- 组装 prompt 前,先建立情境因果链,再拆解为层内容:
475
 
476
  ```
477
  发生了什么 → 角色的情感/欲望/冲突 → 具体反应(表情+肢体) → 环境如何参与 → 最抓人眼球的画面瞬间
@@ -493,9 +501,9 @@ Anima 是一个 2B 参数的文生图模型(CircleStone Labs × Comfy Org)
493
 
494
  ---
495
 
496
- ## 层 Prompt 结构
497
 
498
- prompt 内部分层组装,同一语义不跨层重复:
499
 
500
  ### 第一层:硬锚点(Hard Tags)
501
 
@@ -513,23 +521,9 @@ prompt 内部分三层组装,同一语义不跨层重复:
513
  **不包含:**
514
  - 未经确认的模糊描述
515
  - 完整英文句子
516
- - 构图、光影、氛围(这些交给下层)
517
 
518
- ### 第二层:视觉短语Soft Phrases
519
-
520
- 模型根据情境因果生成的短视觉短语,不走 Danbooru 检索,不作为硬锚点。
521
-
522
- **包含:**
523
- - 动作/情感短语示例:`horsing around, having fun, surprised giggling, grinning broadly`
524
- - 环境效果短语示例:`strong wind, cherry blossom blizzard, petals filling the air`
525
- - 画师倾向短语:大构图、柔光、戏剧性背光、清透色彩等可见风格结果
526
-
527
- **规则:**
528
- - soft phrase 必须服务于情境因果链,不能变成 loose list。
529
- - 不查 Danbooru,不进入 confirmed tags。
530
- - 与 hard tags 不重复、不矛盾。
531
-
532
- ### 第三层:空间叙事(NL Tags Block)
533
 
534
  有语法结构的连续描述,负责 hard tags 和 soft phrases 难以精确表达的内容。
535
  特别提示:画面的逻辑需要由空间叙事描述。例如:如果场景有大风,那么画面各处的风向应当一致。如果场景是室内,那么室内桌椅板凳的布局和位置必须合理。
@@ -562,8 +556,6 @@ prompt 内部分三层组装,同一语义不跨层重复:
562
  ```
563
  [硬锚点层:逗号分隔,单行]
564
 
565
- [视觉短语层:逗号分隔短语]
566
-
567
  [空间叙事层:2 到 3 句英文]
568
  ```
569
 
@@ -578,7 +570,7 @@ prompt 内部分三层组装,同一语义不跨层重复:
578
 
579
  ## 八维补全检查(输出前必做)
580
 
581
- 层组装完成后,自查以下 8 个维度,**至少触发 3 维以上**。缺失的维度用空间叙事层补全,不硬塞更多 Danbooru 标签。
582
 
583
  | 维度 | 检查问题 | 缺失表现 | 补全方向 |
584
  |------|----------|----------|----------|
@@ -696,7 +688,7 @@ masterpiece, best quality, very aesthetic, score_7, safe,
696
 
697
  **取景默认**:若用户未指定,默认近景人物、人物面向观众。若用户有描述则以用户描述为准。
698
 
699
- **模式默认**:采用 Hybrid 混合结构(硬锚点 + 视觉短语 + 空间叙事)。仅当用户明确要求纯标签或纯自然语言时才切换。
700
 
701
  ---
702
 
 
90
  "full_scene" — **DEFAULT.** Use whenever the user gives a concrete picture description: a specific
91
  scene, subject(s), clothing, pose, action, or background — no matter how detailed or how
92
  many elements. The user wants ONE coherent prompt for ONE intended image.
93
+ (e.g. "一个穿着白色水手服的少女在雨中奔跑", "金发双马尾女孩坐在教室窗边看书,夕阳",
94
+ "芙兰朵露 金发 辫子 发带 连衣裙 围裙 灯笼裤")
95
  "concept_explore" — **ONLY for open-ended browsing**, when the user wants to SEE A VARIETY of options for a
96
  vague/single concept and pick from them — i.e. "show me what kinds of X exist".
97
  Returns up to 80 candidates → high token cost. Do NOT use just because a description has
98
  many elements; a detailed scene is still "full_scene".
99
  (e.g. "各种各样的汉服", "兔耳朵都有哪些", "赛博朋克服装有什么风格")
100
+ "subject_describe" — **WARNING: Only for describing ONE single visual concept.** Tokenizer is DISABLED in this
101
+ mode, so it cannot parse multi-element queries. If the query contains a character name +
102
+ attributes (e.g. "芙兰朵露 金发 连衣裙"), multiple clothing items, or any combination of
103
+ visual elements, you MUST use "full_scene" instead.
104
+ Valid use cases: "EVA中蓝发的驾驶员" (single character concept), "灯笼裤" (single item),
105
+ "两侧有开口,前方有拉绳的运动短裤" (single item with details).
106
  "precise_lookup" — Precise lookup / spell fix (e.g. "selafuku", "thighhigh")
107
  - DECISION RULE: Does the user want one specific picture (→ full_scene) or to browse many options for a concept
108
  (→ concept_explore)? A long, multi-element description still maps to full_scene — element count is NOT the signal,
109
  exploratory intent is.
110
+ - CRITICAL: "subject_describe" is ONLY for queries about a SINGLE visual concept (one item, one attribute, one character
111
+ type). Any query with a character name + attributes, multiple items, or a scene description MUST use "full_scene".
112
+ When in doubt, use "full_scene" — it handles all concrete image descriptions correctly.
113
  - category: Filter to a specific tag category. Default "all".
114
  "all" — All (通用 + 版权 + 人物)
115
  "general" — Visual attributes, clothing, pose, background, etc.
 
479
 
480
  ## 情境因果锁(组装前必做)
481
 
482
+ 组装 prompt 前,先建立情境因果链,再拆解为层内容:
483
 
484
  ```
485
  发生了什么 → 角色的情感/欲望/冲突 → 具体反应(表情+肢体) → 环境如何参与 → 最抓人眼球的画面瞬间
 
501
 
502
  ---
503
 
504
+ ## 层 Prompt 结构
505
 
506
+ prompt 内部分层组装,同一语义不跨层重复:
507
 
508
  ### 第一层:硬锚点(Hard Tags)
509
 
 
521
  **不包含:**
522
  - 未经确认的模糊描述
523
  - 完整英文句子
524
+ - 构图、光影、氛围(这些交给下层)
525
 
526
+ ### 第二层:空间叙事NL Tags Block
 
 
 
 
 
 
 
 
 
 
 
 
 
 
527
 
528
  有语法结构的连续描述,负责 hard tags 和 soft phrases 难以精确表达的内容。
529
  特别提示:画面的逻辑需要由空间叙事描述。例如:如果场景有大风,那么画面各处的风向应当一致。如果场景是室内,那么室内桌椅板凳的布局和位置必须合理。
 
556
  ```
557
  [硬锚点层:逗号分隔,单行]
558
 
 
 
559
  [空间叙事层:2 到 3 句英文]
560
  ```
561
 
 
570
 
571
  ## 八维补全检查(输出前必做)
572
 
573
+ 层组装完成后,自查以下 8 个维度,**至少触发 3 维以上**。缺失的维度用空间叙事层补全,不硬塞更多 Danbooru 标签。
574
 
575
  | 维度 | 检查问题 | 缺失表现 | 补全方向 |
576
  |------|----------|----------|----------|
 
688
 
689
  **取景默认**:若用户未指定,默认近景人物、人物面向观众。若用户有描述则以用户描述为准。
690
 
691
+ **模式默认**:采用 Hybrid 混合结构(硬锚点 + 空间叙事)。仅当用户明确要求纯标签或纯自然语言时才切换。
692
 
693
  ---
694
 
ui_nicegui.py CHANGED
@@ -108,7 +108,6 @@ _SEARCH_MODE_PRESETS: dict[str, dict] = {
108
  _SEARCH_MODE_OPTIONS = ['自定义'] + list(_SEARCH_MODE_PRESETS.keys())
109
 
110
 
111
-
112
  # ── 辅助函数 ───────────────────────────────────────────────────────────────────
113
 
114
  def _get_git_commit() -> str:
@@ -154,6 +153,11 @@ def _format_tag_with_weight(tag: str, weight: float, fmt: str = 'sdxl') -> str:
154
  return f'({tag}:{weight:.1f})'
155
 
156
 
 
 
 
 
 
157
  # ── UI 类 ─────────────────────────────────────────────────────────────────────
158
 
159
  class DanbooruSearchUI:
@@ -845,6 +849,7 @@ class DanbooruSearchUI:
845
  w = self.tag_weights.get(tag, 1.0)
846
  extra_cls = 'boosted' if w > 1.0 else ('reduced' if w < 1.0 else '')
847
  w_str = f'{w:.1f}'
 
848
  with ui.element('div').classes(f'weight-chip {extra_cls}'):
849
  # 删除按钮(×)
850
  with ui.element('button').classes('weight-btn').props(f'title="移除 {tag}"').on(
@@ -857,9 +862,9 @@ class DanbooruSearchUI:
857
  ):
858
  ui.html('&minus;')
859
  # 标签名
860
- ui.label(tag).style(
861
  'font-family:Consolas,Monaco,monospace;font-size:12px;'
862
- 'color:#2c5282;max-width:150px;overflow:hidden;'
863
  'text-overflow:ellipsis;white-space:nowrap;'
864
  )
865
  # 权重值(仅非 1.0 时显示)
@@ -894,6 +899,26 @@ class DanbooruSearchUI:
894
  self._save_staged_tags()
895
  self._render_selected_chips()
896
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
897
  def _remove_selected_tag(self, tag: str):
898
  """从已选中移除标签(同步表格选中状态)。"""
899
  self._mark_interaction()
 
108
  _SEARCH_MODE_OPTIONS = ['自定义'] + list(_SEARCH_MODE_PRESETS.keys())
109
 
110
 
 
111
  # ── 辅助函数 ───────────────────────────────────────────────────────────────────
112
 
113
  def _get_git_commit() -> str:
 
153
  return f'({tag}:{weight:.1f})'
154
 
155
 
156
+ def _format_selected_tag_label(tag: str, cn_name: str = '') -> str:
157
+ cn_first = (cn_name or '').split(',', 1)[0].strip()
158
+ return f'{tag} | {cn_first}' if cn_first else tag
159
+
160
+
161
  # ── UI 类 ─────────────────────────────────────────────────────────────────────
162
 
163
  class DanbooruSearchUI:
 
849
  w = self.tag_weights.get(tag, 1.0)
850
  extra_cls = 'boosted' if w > 1.0 else ('reduced' if w < 1.0 else '')
851
  w_str = f'{w:.1f}'
852
+ display_label = _format_selected_tag_label(tag, self._get_cn_name_for_tag(tag))
853
  with ui.element('div').classes(f'weight-chip {extra_cls}'):
854
  # 删除按钮(×)
855
  with ui.element('button').classes('weight-btn').props(f'title="移除 {tag}"').on(
 
862
  ):
863
  ui.html('&minus;')
864
  # 标签名
865
+ ui.label(display_label).style(
866
  'font-family:Consolas,Monaco,monospace;font-size:12px;'
867
+ 'color:#2c5282;max-width:240px;overflow:hidden;'
868
  'text-overflow:ellipsis;white-space:nowrap;'
869
  )
870
  # 权重值(仅非 1.0 时显示)
 
899
  self._save_staged_tags()
900
  self._render_selected_chips()
901
 
902
+ def _get_cn_name_for_tag(self, tag: str) -> str:
903
+ """尽量从当前 UI 数据中取标签中文名,用于已选区展示。"""
904
+ if self.result_table is not None:
905
+ for row in self.result_table.rows:
906
+ if row.get('tag') == tag:
907
+ return str(row.get('cn_name') or '')
908
+
909
+ for item in self.current_related:
910
+ if getattr(item, 'tag', None) == tag:
911
+ return str(getattr(item, 'cn_name', '') or '')
912
+
913
+ try:
914
+ tagger = DanbooruTagger._instance
915
+ if tagger and tagger.df is not None and tag in tagger._name_to_idx:
916
+ idx = tagger._name_to_idx[tag]
917
+ return str(tagger.df.iloc[idx].get('cn_name', '') or '')
918
+ except Exception:
919
+ pass
920
+ return ''
921
+
922
  def _remove_selected_tag(self, tag: str):
923
  """从已选中移除标签(同步表格选中状态)。"""
924
  self._mark_interaction()