Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
Auto-sync from GitHub Actions
Browse files- README.md +53 -4
- api_fastapi.py +46 -0
- core/engine.py +84 -4
- mcp_server.py +113 -0
- platform_utils.py +1 -1
- 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` 和 `
|
| 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 |
-
(
|
| 1422 |
-
|
| 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 = '
|
| 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 =
|
| 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 |
-
# ── 公告栏(
|
| 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('🧪 新功能:画师
|
| 622 |
ui.button(icon='close').props('flat dense round color=grey-6') \
|
| 623 |
.on_click(self._dismiss_mcp_notice)
|
| 624 |
ui.html(
|
| 625 |
-
'基于标签共现数据,
|
| 626 |
-
'
|
| 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 |
-
|
| 695 |
-
|
| 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': '
|
| 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(
|
| 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(
|
| 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 |
-
|
| 1415 |
-
|
| 1416 |
-
|
| 1417 |
-
|
| 1418 |
-
|
| 1419 |
-
|
| 1420 |
-
|
| 1421 |
-
|
| 1422 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1423 |
|
| 1424 |
-
|
| 1425 |
-
|
|
|
|
|
|
|
|
|
|
| 1426 |
|
| 1427 |
-
|
| 1428 |
-
return
|
| 1429 |
|
| 1430 |
-
|
| 1431 |
-
|
| 1432 |
-
|
| 1433 |
-
|
| 1434 |
-
|
| 1435 |
-
|
| 1436 |
-
|
|
|
|
|
|
|
|
|
|
| 1437 |
|
| 1438 |
-
|
| 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 |
-
|
| 1447 |
-
|
| 1448 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1449 |
|
| 1450 |
-
|
| 1451 |
-
|
| 1452 |
-
|
| 1453 |
-
|
| 1454 |
-
|
| 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(
|
| 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 |
-
|
| 1906 |
-
|
| 1907 |
-
|
| 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')
|