Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
Auto-sync from GitHub Actions
Browse files- core/engine.py +192 -5
- core/models.py +11 -0
- ui_nicegui.py +341 -102
core/engine.py
CHANGED
|
@@ -187,8 +187,9 @@ class DanbooruTagger:
|
|
| 187 |
model_path: Optional[str] = None,
|
| 188 |
csv_file: str = 'origin_database/tags_enhanced.csv',
|
| 189 |
cache_dir: str = 'tags_embedding',
|
| 190 |
-
cooc_file:
|
| 191 |
-
group_file:
|
|
|
|
| 192 |
):
|
| 193 |
# 模型路径:优先使用显式传入,否则交由 platform_utils 解析
|
| 194 |
self.model_path = model_path or resolve_model_path()
|
|
@@ -198,6 +199,7 @@ class DanbooruTagger:
|
|
| 198 |
self.paths = _CachePaths(cache_dir)
|
| 199 |
self.cooc_file = cooc_file
|
| 200 |
self.group_file = group_file
|
|
|
|
| 201 |
|
| 202 |
self.model: Optional[SentenceTransformer] = None
|
| 203 |
self.df: Optional[pd.DataFrame] = None
|
|
@@ -211,6 +213,7 @@ class DanbooruTagger:
|
|
| 211 |
self._tag_to_groups: dict[str, set[str]] = {}
|
| 212 |
self._group_to_tags_idx: dict[str, np.ndarray] = {}
|
| 213 |
self._group_cn_names: dict[str, str] = {}
|
|
|
|
| 214 |
self.is_loaded: bool = False
|
| 215 |
|
| 216 |
# 预提取的列数组,避免热点路径上反复执行 df.iloc[idx]
|
|
@@ -265,6 +268,7 @@ class DanbooruTagger:
|
|
| 265 |
|
| 266 |
self._setup_jieba_from_memory()
|
| 267 |
self._load_cooc()
|
|
|
|
| 268 |
self._name_to_idx = {n: i for i, n in enumerate(self.df['name'])}
|
| 269 |
self._tag_names_set: set[str] = set(self._name_to_idx.keys())
|
| 270 |
self._rebuild_arrays_from_df()
|
|
@@ -324,9 +328,10 @@ class DanbooruTagger:
|
|
| 324 |
print(f'[Engine] 拉取 {filename} 失败(非致命): {e}')
|
| 325 |
return filename # 回退到原始路径,让后续逻辑决定是否重建
|
| 326 |
|
| 327 |
-
self.csv_path
|
| 328 |
-
self.cooc_file
|
| 329 |
-
self.group_file
|
|
|
|
| 330 |
|
| 331 |
meta_path = pull('tags_embedding/tags_metadata.parquet')
|
| 332 |
emb_path = pull('tags_embedding/danbooru_multiview_embeddings.safetensors')
|
|
@@ -602,6 +607,36 @@ class DanbooruTagger:
|
|
| 602 |
self.get_group_candidates, selected_tags, show_nsfw,
|
| 603 |
)
|
| 604 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 605 |
def _apply_group_expand(self, final: dict[str, TagResult]) -> None:
|
| 606 |
"""expand 模式:提升同 group 标签的分数。"""
|
| 607 |
BETA = 0.2
|
|
@@ -1186,6 +1221,132 @@ class DanbooruTagger:
|
|
| 1186 |
|
| 1187 |
return results
|
| 1188 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1189 |
def _load_cooc(self) -> None:
|
| 1190 |
csv_path = Path(self.cooc_file)
|
| 1191 |
parquet_path = csv_path.with_suffix('.parquet')
|
|
@@ -1242,6 +1403,32 @@ class DanbooruTagger:
|
|
| 1242 |
except Exception as e:
|
| 1243 |
print(f'[Engine] 共现表加载失败: {e}')
|
| 1244 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1245 |
def _load_groups(self) -> None:
|
| 1246 |
"""加载 Tag Group 数据,构建 tag→group 和 group→idx 索引。"""
|
| 1247 |
if not Path(self.group_file).is_file():
|
|
|
|
| 187 |
model_path: Optional[str] = None,
|
| 188 |
csv_file: str = 'origin_database/tags_enhanced.csv',
|
| 189 |
cache_dir: str = 'tags_embedding',
|
| 190 |
+
cooc_file: str = 'origin_database/cooccurrence_clean.csv',
|
| 191 |
+
group_file: str = 'origin_database/tag_groups.json',
|
| 192 |
+
tag_artist_file: str = 'origin_database/tag_artist_cooc.parquet',
|
| 193 |
):
|
| 194 |
# 模型路径:优先使用显式传入,否则交由 platform_utils 解析
|
| 195 |
self.model_path = model_path or resolve_model_path()
|
|
|
|
| 199 |
self.paths = _CachePaths(cache_dir)
|
| 200 |
self.cooc_file = cooc_file
|
| 201 |
self.group_file = group_file
|
| 202 |
+
self.tag_artist_file = tag_artist_file
|
| 203 |
|
| 204 |
self.model: Optional[SentenceTransformer] = None
|
| 205 |
self.df: Optional[pd.DataFrame] = None
|
|
|
|
| 213 |
self._tag_to_groups: dict[str, set[str]] = {}
|
| 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]
|
|
|
|
| 268 |
|
| 269 |
self._setup_jieba_from_memory()
|
| 270 |
self._load_cooc()
|
| 271 |
+
self._load_tag_artist_cooc()
|
| 272 |
self._name_to_idx = {n: i for i, n in enumerate(self.df['name'])}
|
| 273 |
self._tag_names_set: set[str] = set(self._name_to_idx.keys())
|
| 274 |
self._rebuild_arrays_from_df()
|
|
|
|
| 328 |
print(f'[Engine] 拉取 {filename} 失败(非致命): {e}')
|
| 329 |
return filename # 回退到原始路径,让后续逻辑决定是否重建
|
| 330 |
|
| 331 |
+
self.csv_path = pull('origin_database/tags_enhanced.csv')
|
| 332 |
+
self.cooc_file = pull('origin_database/cooccurrence_clean.parquet')
|
| 333 |
+
self.group_file = pull('origin_database/tag_groups.json')
|
| 334 |
+
self.tag_artist_file = pull('origin_database/tag_artist_cooc.parquet')
|
| 335 |
|
| 336 |
meta_path = pull('tags_embedding/tags_metadata.parquet')
|
| 337 |
emb_path = pull('tags_embedding/danbooru_multiview_embeddings.safetensors')
|
|
|
|
| 607 |
self.get_group_candidates, selected_tags, show_nsfw,
|
| 608 |
)
|
| 609 |
|
| 610 |
+
async def search_artists_by_tags_async(
|
| 611 |
+
self,
|
| 612 |
+
tags: list[str],
|
| 613 |
+
limit: int = 30,
|
| 614 |
+
min_cooc: int = 5,
|
| 615 |
+
) -> list:
|
| 616 |
+
"""search_artists_by_tags() 的并发安全异步封装。"""
|
| 617 |
+
async with self._get_cpu_sem():
|
| 618 |
+
return await asyncio.to_thread(
|
| 619 |
+
self.search_artists_by_tags, tags, limit, min_cooc,
|
| 620 |
+
)
|
| 621 |
+
|
| 622 |
+
async def search_artists_pipeline_async(
|
| 623 |
+
self,
|
| 624 |
+
query: str,
|
| 625 |
+
limit: int = 30,
|
| 626 |
+
min_cooc: int = 5,
|
| 627 |
+
target_layers: list[str] | None = None,
|
| 628 |
+
target_categories: list[str] | None = None,
|
| 629 |
+
) -> tuple:
|
| 630 |
+
"""search_artists_pipeline() 的并发安全异步封装。"""
|
| 631 |
+
async with self._get_cpu_sem():
|
| 632 |
+
return await asyncio.wait_for(
|
| 633 |
+
asyncio.to_thread(
|
| 634 |
+
self.search_artists_pipeline,
|
| 635 |
+
query, limit, min_cooc, target_layers, target_categories,
|
| 636 |
+
),
|
| 637 |
+
timeout=120.0,
|
| 638 |
+
)
|
| 639 |
+
|
| 640 |
def _apply_group_expand(self, final: dict[str, TagResult]) -> None:
|
| 641 |
"""expand 模式:提升同 group 标签的分数。"""
|
| 642 |
BETA = 0.2
|
|
|
|
| 1221 |
|
| 1222 |
return results
|
| 1223 |
|
| 1224 |
+
# ── 画师查找 ──────────────────────────────────────────────────────────
|
| 1225 |
+
|
| 1226 |
+
def search_artists_by_tags(
|
| 1227 |
+
self,
|
| 1228 |
+
tags: list[str],
|
| 1229 |
+
limit: int = 30,
|
| 1230 |
+
min_cooc: int = 5,
|
| 1231 |
+
) -> list:
|
| 1232 |
+
"""按标签查找画师:聚合多个标签的 NPMI 得分,返回排名靠前的画师。"""
|
| 1233 |
+
from .models import ArtistResult
|
| 1234 |
+
|
| 1235 |
+
if not self._tag_artist_index or not tags:
|
| 1236 |
+
return []
|
| 1237 |
+
|
| 1238 |
+
artist_scores: dict[str, float] = {}
|
| 1239 |
+
artist_cooc: dict[str, int] = {}
|
| 1240 |
+
artist_post_count: dict[str, int] = {}
|
| 1241 |
+
artist_sources: dict[str, list[str]] = {}
|
| 1242 |
+
artist_hits: dict[str, int] = {}
|
| 1243 |
+
|
| 1244 |
+
for tag in tags:
|
| 1245 |
+
tag = tag.strip().lower()
|
| 1246 |
+
if not tag or tag not in self._tag_artist_index:
|
| 1247 |
+
continue
|
| 1248 |
+
for artist, npmi, cooc, post_count in self._tag_artist_index[tag]:
|
| 1249 |
+
if cooc < min_cooc:
|
| 1250 |
+
continue
|
| 1251 |
+
artist_scores[artist] = artist_scores.get(artist, 0.0) + npmi
|
| 1252 |
+
artist_cooc[artist] = artist_cooc.get(artist, 0) + cooc
|
| 1253 |
+
artist_post_count[artist] = max(artist_post_count.get(artist, 0), post_count)
|
| 1254 |
+
artist_sources.setdefault(artist, []).append(tag)
|
| 1255 |
+
artist_hits[artist] = artist_hits.get(artist, 0) + 1
|
| 1256 |
+
|
| 1257 |
+
if not artist_scores:
|
| 1258 |
+
return []
|
| 1259 |
+
|
| 1260 |
+
scored = []
|
| 1261 |
+
for artist, raw_score in artist_scores.items():
|
| 1262 |
+
hit_bonus = 1.0 + 0.3 * (artist_hits[artist] - 1)
|
| 1263 |
+
final_score = raw_score * hit_bonus
|
| 1264 |
+
scored.append((
|
| 1265 |
+
artist, final_score, artist_cooc[artist],
|
| 1266 |
+
artist_post_count[artist], artist_sources[artist],
|
| 1267 |
+
artist_hits[artist],
|
| 1268 |
+
))
|
| 1269 |
+
|
| 1270 |
+
scored.sort(key=lambda x: -x[1])
|
| 1271 |
+
results = []
|
| 1272 |
+
for artist, score, cooc, post_count, sources, hits in scored[:limit]:
|
| 1273 |
+
results.append(ArtistResult(
|
| 1274 |
+
artist=artist,
|
| 1275 |
+
score=round(score, 4),
|
| 1276 |
+
cooc_count=cooc,
|
| 1277 |
+
post_count=post_count,
|
| 1278 |
+
sources=sources,
|
| 1279 |
+
hit_count=hits,
|
| 1280 |
+
))
|
| 1281 |
+
return results
|
| 1282 |
+
|
| 1283 |
+
def search_artists_pipeline(
|
| 1284 |
+
self,
|
| 1285 |
+
query: str,
|
| 1286 |
+
limit: int = 30,
|
| 1287 |
+
min_cooc: int = 5,
|
| 1288 |
+
target_layers: list[str] | None = None,
|
| 1289 |
+
target_categories: list[str] | None = None,
|
| 1290 |
+
) -> tuple[list, list[str], list[str], list[str]]:
|
| 1291 |
+
"""画师查找完整管线:自然语言 → 标签搜索 → 提取最佳标签 → 画师查询。
|
| 1292 |
+
|
| 1293 |
+
Returns:
|
| 1294 |
+
(artist_results, seed_tags, found_tags, missing_tags)
|
| 1295 |
+
"""
|
| 1296 |
+
if target_layers is None:
|
| 1297 |
+
target_layers = ['英文', '中文扩展词', '释义', '中文核心词']
|
| 1298 |
+
if target_categories is None:
|
| 1299 |
+
target_categories = ['General', 'Character', 'Copyright']
|
| 1300 |
+
|
| 1301 |
+
# Step 1: 自然语言 → 标签搜索
|
| 1302 |
+
tag_request = SearchRequest(
|
| 1303 |
+
query=query,
|
| 1304 |
+
top_k=10,
|
| 1305 |
+
limit=80,
|
| 1306 |
+
popularity_weight=0.15,
|
| 1307 |
+
show_nsfw=True,
|
| 1308 |
+
use_segmentation=True,
|
| 1309 |
+
target_layers=target_layers,
|
| 1310 |
+
target_categories=target_categories,
|
| 1311 |
+
)
|
| 1312 |
+
tag_response = self.search(tag_request)
|
| 1313 |
+
|
| 1314 |
+
if not tag_response.results:
|
| 1315 |
+
return [], [], [], []
|
| 1316 |
+
|
| 1317 |
+
# Step 2: 按 source 分组,每组按分数降序取前 10 个候选,找到能命中画师的第一个
|
| 1318 |
+
tag_artist = self._tag_artist_index
|
| 1319 |
+
source_candidates: dict[str, list[str]] = {}
|
| 1320 |
+
for r in tag_response.results:
|
| 1321 |
+
if r.final_score < 0.45:
|
| 1322 |
+
continue
|
| 1323 |
+
source_candidates.setdefault(r.source, []).append(r.tag)
|
| 1324 |
+
|
| 1325 |
+
seed_tags: list[str] = []
|
| 1326 |
+
seen = set()
|
| 1327 |
+
for candidates in source_candidates.values():
|
| 1328 |
+
for tag in candidates[:10]:
|
| 1329 |
+
tag_lower = tag.strip().lower()
|
| 1330 |
+
if tag_lower in tag_artist and tag_lower not in seen:
|
| 1331 |
+
seed_tags.append(tag_lower)
|
| 1332 |
+
seen.add(tag_lower)
|
| 1333 |
+
break
|
| 1334 |
+
|
| 1335 |
+
if not seed_tags:
|
| 1336 |
+
return [], [], [], []
|
| 1337 |
+
|
| 1338 |
+
# Step 3: 标签 → 画师查找
|
| 1339 |
+
artist_results = self.search_artists_by_tags(seed_tags, limit, min_cooc)
|
| 1340 |
+
|
| 1341 |
+
# 统计 found / missing(seed_tags 中哪些匹配到了画师)
|
| 1342 |
+
matched = {s for r in artist_results for s in r.sources}
|
| 1343 |
+
found_tags = [t for t in seed_tags if t in matched]
|
| 1344 |
+
missing_tags = [t for t in seed_tags if t not in matched]
|
| 1345 |
+
|
| 1346 |
+
return artist_results, seed_tags, found_tags, missing_tags
|
| 1347 |
+
|
| 1348 |
+
# ── 共现数据加载 ──────────────────────────────────────────────────────
|
| 1349 |
+
|
| 1350 |
def _load_cooc(self) -> None:
|
| 1351 |
csv_path = Path(self.cooc_file)
|
| 1352 |
parquet_path = csv_path.with_suffix('.parquet')
|
|
|
|
| 1403 |
except Exception as e:
|
| 1404 |
print(f'[Engine] 共现表加载失败: {e}')
|
| 1405 |
|
| 1406 |
+
def _load_tag_artist_cooc(self) -> None:
|
| 1407 |
+
"""加载标签-画师共现数据(tag_artist_cooc.parquet)。"""
|
| 1408 |
+
path = Path(self.tag_artist_file)
|
| 1409 |
+
if not path.is_file():
|
| 1410 |
+
print(f'[Engine] 未找到标签-画师共现表 ({self.tag_artist_file}),画师查找功能不可用。')
|
| 1411 |
+
return
|
| 1412 |
+
|
| 1413 |
+
print(f'[Engine] 加载标签-画师共现表 ({path.name})...')
|
| 1414 |
+
t0 = time.time()
|
| 1415 |
+
try:
|
| 1416 |
+
df = pd.read_parquet(str(path))
|
| 1417 |
+
index: dict[str, list[tuple]] = {}
|
| 1418 |
+
for _, row in df.iterrows():
|
| 1419 |
+
tag = str(row["tag"])
|
| 1420 |
+
index.setdefault(tag, []).append(
|
| 1421 |
+
(str(row["artist"]), float(row["npmi"]),
|
| 1422 |
+
int(row["cooc_count"]), int(row["artist_post_count"]))
|
| 1423 |
+
)
|
| 1424 |
+
self._tag_artist_index = index
|
| 1425 |
+
print(
|
| 1426 |
+
f'[Engine] 标签-画师共现表加载完成,{len(index):,} 个标签,'
|
| 1427 |
+
f'耗时 {time.time() - t0:.2f}s'
|
| 1428 |
+
)
|
| 1429 |
+
except Exception as e:
|
| 1430 |
+
print(f'[Engine] 标签-画师共现表加载失败: {e}')
|
| 1431 |
+
|
| 1432 |
def _load_groups(self) -> None:
|
| 1433 |
"""加载 Tag Group 数据,构建 tag→group 和 group→idx 索引。"""
|
| 1434 |
if not Path(self.group_file).is_file():
|
core/models.py
CHANGED
|
@@ -34,6 +34,17 @@ class RelatedTag:
|
|
| 34 |
wiki: str = "" # 标签 wiki 描述
|
| 35 |
|
| 36 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
@dataclass
|
| 38 |
class SearchRequest:
|
| 39 |
"""搜索参数"""
|
|
|
|
| 34 |
wiki: str = "" # 标签 wiki 描述
|
| 35 |
|
| 36 |
|
| 37 |
+
@dataclass
|
| 38 |
+
class ArtistResult:
|
| 39 |
+
"""单条画师搜索结果。"""
|
| 40 |
+
artist: str # 画师名
|
| 41 |
+
score: float # 综合 NPMI 得分
|
| 42 |
+
cooc_count: int # 累计共现次数
|
| 43 |
+
post_count: int # 画师在 Danbooru 的作品数
|
| 44 |
+
sources: list[str] # 命中标签列表
|
| 45 |
+
hit_count: int # 命中标签数
|
| 46 |
+
|
| 47 |
+
|
| 48 |
@dataclass
|
| 49 |
class SearchRequest:
|
| 50 |
"""搜索参数"""
|
ui_nicegui.py
CHANGED
|
@@ -177,6 +177,12 @@ class DanbooruSearchUI:
|
|
| 177 |
self.prompt_format: str = 'sdxl'
|
| 178 |
self.format_toggle_btn = None
|
| 179 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 180 |
self.init_banner = None
|
| 181 |
self.input_top_k = None
|
| 182 |
self.input_limit = None
|
|
@@ -292,6 +298,7 @@ class DanbooruSearchUI:
|
|
| 292 |
'search_mode': self.input_search_mode.value if self.input_search_mode else '自定义',
|
| 293 |
'group_mode': self.input_group_mode.value if self.input_group_mode else 'off',
|
| 294 |
'max_per_group': int(self.input_max_per_group.value) if self.input_max_per_group else 2,
|
|
|
|
| 295 |
}
|
| 296 |
js = _json.dumps(cfg, ensure_ascii=False)
|
| 297 |
ui.run_javascript(f"localStorage.setItem('{_CONFIG_LS_KEY}', {_json.dumps(js)});")
|
|
@@ -388,6 +395,10 @@ class DanbooruSearchUI:
|
|
| 388 |
if self.mcp_notice and cfg.get('mcp_notice_dismissed'):
|
| 389 |
self.mcp_notice.set_visibility(False)
|
| 390 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 391 |
# 若高级选项列有变更,同步更新表格列
|
| 392 |
self._update_table_columns()
|
| 393 |
|
|
@@ -457,6 +468,60 @@ class DanbooruSearchUI:
|
|
| 457 |
max-width: 100% !important;
|
| 458 |
}
|
| 459 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 460 |
</style>
|
| 461 |
<script async src="https://www.googletagmanager.com/gtag/js?id=G-QPB7EEPR5G"></script>
|
| 462 |
<script>
|
|
@@ -530,7 +595,11 @@ class DanbooruSearchUI:
|
|
| 530 |
# ── 4. 分词筛选 chips ──
|
| 531 |
self.keywords_container = ui.row().classes('gap-2 items-center flex-wrap')
|
| 532 |
|
| 533 |
-
# ── 5.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 534 |
self._build_results_columns()
|
| 535 |
|
| 536 |
# ── 6. 底部 ──
|
|
@@ -538,7 +607,7 @@ class DanbooruSearchUI:
|
|
| 538 |
self.search_count_label = ui.html('正在加载数据...').classes('text-xs text-gray-400')
|
| 539 |
self._update_footer_text()
|
| 540 |
|
| 541 |
-
# ──
|
| 542 |
|
| 543 |
def _build_group_notice(self):
|
| 544 |
self.mcp_notice = ui.card().classes(
|
|
@@ -546,18 +615,26 @@ class DanbooruSearchUI:
|
|
| 546 |
)
|
| 547 |
with self.mcp_notice:
|
| 548 |
with ui.column().classes('px-4 py-3 w-full gap-2'):
|
|
|
|
| 549 |
with ui.row().classes('items-center justify-between w-full'):
|
| 550 |
-
ui.
|
|
|
|
| 551 |
ui.button(icon='close').props('flat dense round color=grey-6') \
|
| 552 |
.on_click(self._dismiss_mcp_notice)
|
| 553 |
ui.html(
|
| 554 |
-
'
|
| 555 |
-
'
|
| 556 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 557 |
).classes('text-xs text-green-900')
|
| 558 |
ui.separator().classes('my-1')
|
|
|
|
| 559 |
ui.html(
|
| 560 |
-
'【MCP 服务
|
| 561 |
'免配置托管版体验:'
|
| 562 |
'<a href="https://huggingface.co/spaces/SAkizuki/WenQiuYue" '
|
| 563 |
'target="_blank" rel="noopener noreferrer" '
|
|
@@ -604,12 +681,20 @@ class DanbooruSearchUI:
|
|
| 604 |
|
| 605 |
def _build_search_card(self):
|
| 606 |
with ui.card().classes('w-full'):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 607 |
with ui.row().classes('items-center gap-2 mb-2'):
|
| 608 |
ui.icon('search', size='2em', color='primary')
|
| 609 |
-
ui.label('Danbooru 标签模糊搜索').classes('text-2xl font-bold text-gray-800')
|
| 610 |
-
ui.label(
|
| 611 |
-
'
|
| 612 |
-
)
|
| 613 |
|
| 614 |
with ui.row().classes('w-full gap-3 items-stretch'):
|
| 615 |
self.search_input = ui.textarea(
|
|
@@ -625,7 +710,8 @@ class DanbooruSearchUI:
|
|
| 625 |
ui.label('搜索').classes('text-sm mt-1')
|
| 626 |
self.spinner = ui.spinner(size='2em').classes('hidden')
|
| 627 |
|
| 628 |
-
|
|
|
|
| 629 |
with ui.row().classes('items-center gap-2'):
|
| 630 |
ui.label('搜索模式 (beta)').classes('text-sm text-gray-600')
|
| 631 |
self.input_search_mode = ui.select(
|
|
@@ -670,7 +756,8 @@ class DanbooruSearchUI:
|
|
| 670 |
self.input_segment = _seg_sw
|
| 671 |
self.input_segment.on('update:model-value', self._on_param_changed)
|
| 672 |
|
| 673 |
-
|
|
|
|
| 674 |
with ui.column().classes('w-full p-3 gap-4'):
|
| 675 |
with ui.row().classes('w-full gap-8 flex-wrap'):
|
| 676 |
with ui.column().classes('gap-2'):
|
|
@@ -711,7 +798,7 @@ class DanbooruSearchUI:
|
|
| 711 |
self.sw_source.on('update:model-value', self._update_table_columns)
|
| 712 |
|
| 713 |
with ui.column().classes('gap-2'):
|
| 714 |
-
ui.label('标签分组模式
|
| 715 |
self.input_group_mode = ui.select(
|
| 716 |
['off', 'expand', 'diverse'], value='off',
|
| 717 |
).classes('w-40').props('outlined dense')
|
|
@@ -728,7 +815,8 @@ class DanbooruSearchUI:
|
|
| 728 |
# ── 已选标签栏 ────────────────────────────────────────────────────────
|
| 729 |
|
| 730 |
def _build_selection_bar(self):
|
| 731 |
-
|
|
|
|
| 732 |
with ui.row().classes('w-full items-center justify-between'):
|
| 733 |
with ui.row().classes('items-center gap-2'):
|
| 734 |
ui.icon('check_circle', color='primary')
|
|
@@ -912,7 +1000,8 @@ class DanbooruSearchUI:
|
|
| 912 |
# ── 两栏结果(CSS 强制并排)──────────────────────────────────────────
|
| 913 |
|
| 914 |
def _build_results_columns(self):
|
| 915 |
-
|
|
|
|
| 916 |
# ── 左栏:语义匹配结果(表格)──
|
| 917 |
with ui.card().classes('col-left'):
|
| 918 |
with ui.row().classes('items-center justify-between mb-2 w-full'):
|
|
@@ -997,7 +1086,7 @@ class DanbooruSearchUI:
|
|
| 997 |
ui.separator().classes('my-2')
|
| 998 |
with ui.row().classes('items-center justify-between w-full mb-1'):
|
| 999 |
with ui.row().classes('items-center gap-2'):
|
| 1000 |
-
ui.label('同类标签
|
| 1001 |
with ui.icon('info_outline', size='xs', color='grey').classes('cursor-help'):
|
| 1002 |
with ui.tooltip().props('content-class="bg-black text-white shadow-4"'):
|
| 1003 |
ui.label('基于标签分组数据,展示已选标签所属分组中的其他标签。勾选可加入已选。').style('font-size:14px;')
|
|
@@ -1026,6 +1115,73 @@ class DanbooruSearchUI:
|
|
| 1026 |
with self.related_list_container:
|
| 1027 |
ui.label('请先搜索并勾选标签…').classes('text-sm text-gray-400 italic p-4')
|
| 1028 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1029 |
# ══════════════════════════════════════════════════════════════════════
|
| 1030 |
# 渲染关联推荐列表
|
| 1031 |
# ══════════════════════════════════════════════════════════════════════
|
|
@@ -1167,6 +1323,52 @@ class DanbooruSearchUI:
|
|
| 1167 |
chip_color, text_color = 'grey-4', 'black'
|
| 1168 |
child.props(f'color={chip_color} text-color={text_color}')
|
| 1169 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1170 |
# ── 搜索 ──────────────────────────────────────────────────────────────
|
| 1171 |
|
| 1172 |
async def perform_search(self):
|
|
@@ -1209,97 +1411,134 @@ class DanbooruSearchUI:
|
|
| 1209 |
try:
|
| 1210 |
tagger = await DanbooruTagger.get_instance()
|
| 1211 |
|
| 1212 |
-
|
| 1213 |
-
|
| 1214 |
-
|
| 1215 |
-
|
| 1216 |
-
|
| 1217 |
-
|
| 1218 |
-
|
| 1219 |
-
|
| 1220 |
-
|
| 1221 |
-
use_segmentation=self.input_segment.value if self.input_segment else True,
|
| 1222 |
-
target_layers=target_layers_list,
|
| 1223 |
-
target_categories=target_cats_list,
|
| 1224 |
-
group_mode=self.input_group_mode.value if self.input_group_mode else 'off',
|
| 1225 |
-
max_per_group=int(self.input_max_per_group.value) if self.input_max_per_group else 2,
|
| 1226 |
-
)
|
| 1227 |
-
response = await tagger.search_async(request)
|
| 1228 |
-
|
| 1229 |
-
# 后台计数
|
| 1230 |
-
async def silent_counter_update():
|
| 1231 |
-
try:
|
| 1232 |
-
await counter.increment()
|
| 1233 |
-
if response.keywords:
|
| 1234 |
-
await counter.add_keywords(response.keywords)
|
| 1235 |
-
self._update_footer_text()
|
| 1236 |
-
except Exception as e:
|
| 1237 |
-
print(f"[UI] 后台静默更新计数失败: {e}", flush=True)
|
| 1238 |
-
asyncio.create_task(silent_counter_update())
|
| 1239 |
-
|
| 1240 |
-
if not self._client_alive():
|
| 1241 |
-
return
|
| 1242 |
|
| 1243 |
-
|
| 1244 |
-
|
| 1245 |
-
self.full_tags_str = response.tags_all
|
| 1246 |
-
self.full_tags_str_sfw = response.tags_sfw
|
| 1247 |
-
self.current_segments = list(response.segments) if response.segments else []
|
| 1248 |
|
| 1249 |
-
|
| 1250 |
-
|
| 1251 |
|
| 1252 |
-
|
| 1253 |
-
|
| 1254 |
-
|
| 1255 |
-
|
| 1256 |
-
|
| 1257 |
-
|
| 1258 |
-
|
| 1259 |
-
self.result_table.selected = []
|
| 1260 |
-
self._render_selected_chips()
|
| 1261 |
-
self._update_selection_display(None)
|
| 1262 |
-
self._save_staged_tags()
|
| 1263 |
|
| 1264 |
-
|
| 1265 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1266 |
|
| 1267 |
-
|
| 1268 |
-
|
| 1269 |
-
|
| 1270 |
-
cached_set = set(response.cached_queries) if response.cached_queries else set()
|
| 1271 |
-
with self.keywords_container:
|
| 1272 |
-
ui.label('分词筛选:').classes('text-sm text-gray-500 font-bold mr-2')
|
| 1273 |
-
ui.chip('全部', on_click=lambda: self._filter_by_source('ALL')) \
|
| 1274 |
-
.props('color=primary text-color=white clickable')
|
| 1275 |
-
use_seg = self.input_segment.value if self.input_segment else True
|
| 1276 |
-
if use_seg:
|
| 1277 |
-
whole = ui.chip('整句',
|
| 1278 |
-
on_click=lambda: self._filter_by_source(self.current_query_str))
|
| 1279 |
-
whole.props('color=grey-4 text-color=black clickable')
|
| 1280 |
-
if self.current_query_str in cached_set:
|
| 1281 |
-
whole.style('outline: 1px dashed rgba(128,128,128,0.3); outline-offset: 1px;')
|
| 1282 |
-
# 从句级原始片段(分隔符切分后未 jieba 的长片段,区别于关键词)
|
| 1283 |
-
for seg in response.segments:
|
| 1284 |
-
sc = ui.chip(seg,
|
| 1285 |
-
on_click=lambda s=seg: self._filter_by_source(s))
|
| 1286 |
-
sc.props('color=blue-1 text-color=blue-8 clickable')
|
| 1287 |
-
if seg in cached_set:
|
| 1288 |
-
sc.style('outline: 1px dashed rgba(128,128,128,0.3); outline-offset: 1px;')
|
| 1289 |
-
for kw in response.keywords:
|
| 1290 |
-
kc = ui.chip(kw,
|
| 1291 |
-
on_click=lambda k=kw: self._filter_by_source(k))
|
| 1292 |
-
kc.props('color=grey-4 text-color=black clickable')
|
| 1293 |
-
if kw in cached_set:
|
| 1294 |
-
kc.style('outline: 1px dashed rgba(128,128,128,0.3); outline-offset: 1px;')
|
| 1295 |
-
else:
|
| 1296 |
-
ui.label('(分词已关闭)').classes('text-xs text-gray-400')
|
| 1297 |
-
|
| 1298 |
-
ui.notify(f'找到 {len(table_data)} 个标签', type='positive')
|
| 1299 |
-
self.current_search_interacted = False
|
| 1300 |
|
| 1301 |
-
|
| 1302 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1303 |
|
| 1304 |
except RuntimeError as e:
|
| 1305 |
if 'deleted' in str(e).lower() or 'client' in str(e).lower():
|
|
|
|
| 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
|
|
|
|
| 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 |
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 |
|
|
|
|
| 468 |
max-width: 100% !important;
|
| 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 |
# ── 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 |
self.search_count_label = ui.html('正在加载数据...').classes('text-xs text-gray-400')
|
| 608 |
self._update_footer_text()
|
| 609 |
|
| 610 |
+
# ── 公告栏(画师查找 + 标签组 + MCP)───────────────────────────────────
|
| 611 |
|
| 612 |
def _build_group_notice(self):
|
| 613 |
self.mcp_notice = ui.card().classes(
|
|
|
|
| 615 |
)
|
| 616 |
with self.mcp_notice:
|
| 617 |
with ui.column().classes('px-4 py-3 w-full gap-2'):
|
| 618 |
+
# ── 画师查找公告 ──
|
| 619 |
with ui.row().classes('items-center justify-between w-full'):
|
| 620 |
+
with ui.row().classes('items-center gap-1'):
|
| 621 |
+
ui.label('🧪 新功能:画师查找(beta)').classes('text-sm font-bold text-green-800')
|
| 622 |
ui.button(icon='close').props('flat dense round color=grey-6') \
|
| 623 |
.on_click(self._dismiss_mcp_notice)
|
| 624 |
ui.html(
|
| 625 |
+
'基于标签共现数据,输入风格描述即可查找对应画师。'
|
| 626 |
+
'点击搜索栏上方的 <b>「画师查找(beta)」</b> 按钮即可切换模式。'
|
| 627 |
+
).classes('text-xs text-green-900')
|
| 628 |
+
ui.separator().classes('my-1')
|
| 629 |
+
# ── 标签组扩展 ──
|
| 630 |
+
ui.html(
|
| 631 |
+
'【标签组扩展】 勾选标签后,搜索结果下方会出现<b>同类标签</b>区域,'
|
| 632 |
+
'展示已选标签所属分组中的其他标签,勾选即可加入已选。'
|
| 633 |
).classes('text-xs text-green-900')
|
| 634 |
ui.separator().classes('my-1')
|
| 635 |
+
# ── MCP 服务 ──
|
| 636 |
ui.html(
|
| 637 |
+
'【MCP 服务】 支持通过 MCP 协议接入 AI Agent(如 Claude Desktop)。'
|
| 638 |
'免配置托管版体验:'
|
| 639 |
'<a href="https://huggingface.co/spaces/SAkizuki/WenQiuYue" '
|
| 640 |
'target="_blank" rel="noopener noreferrer" '
|
|
|
|
| 681 |
|
| 682 |
def _build_search_card(self):
|
| 683 |
with ui.card().classes('w-full'):
|
| 684 |
+
# ── 模式切换按钮 ──
|
| 685 |
+
with ui.row().classes('w-full items-center justify-between mb-3'):
|
| 686 |
+
with ui.row().classes('items-center gap-0'):
|
| 687 |
+
self.tag_mode_btn = ui.button('标签搜索', on_click=lambda: self._on_search_type_change(False)) \
|
| 688 |
+
.props('unelevated color=primary').classes('mode-toggle-btn rounded-r-none')
|
| 689 |
+
self.artist_mode_btn = ui.button('画师查找(beta)', on_click=lambda: self._on_search_type_change(True)) \
|
| 690 |
+
.props('flat color=grey-6').classes('mode-toggle-btn rounded-l-none')
|
| 691 |
+
|
| 692 |
with ui.row().classes('items-center gap-2 mb-2'):
|
| 693 |
ui.icon('search', size='2em', color='primary')
|
| 694 |
+
self.search_title_label = ui.label('Danbooru 标签模糊搜索').classes('text-2xl font-bold text-gray-800')
|
| 695 |
+
self.search_subtitle_label = ui.label(
|
| 696 |
+
'基于语义匹配的标签搜索引擎,支持多维匹配与共现关联推荐。'
|
| 697 |
+
).classes('text-sm text-gray-500 -mt-1 mb-3')
|
| 698 |
|
| 699 |
with ui.row().classes('w-full gap-3 items-stretch'):
|
| 700 |
self.search_input = ui.textarea(
|
|
|
|
| 710 |
ui.label('搜索').classes('text-sm mt-1')
|
| 711 |
self.spinner = ui.spinner(size='2em').classes('hidden')
|
| 712 |
|
| 713 |
+
self.search_params_row = ui.row().classes('w-full gap-6 items-center mt-3 flex-wrap')
|
| 714 |
+
with self.search_params_row:
|
| 715 |
with ui.row().classes('items-center gap-2'):
|
| 716 |
ui.label('搜索模式 (beta)').classes('text-sm text-gray-600')
|
| 717 |
self.input_search_mode = ui.select(
|
|
|
|
| 756 |
self.input_segment = _seg_sw
|
| 757 |
self.input_segment.on('update:model-value', self._on_param_changed)
|
| 758 |
|
| 759 |
+
self.advanced_options = ui.expansion('高级选项', icon='tune').classes('w-full mt-2')
|
| 760 |
+
with self.advanced_options:
|
| 761 |
with ui.column().classes('w-full p-3 gap-4'):
|
| 762 |
with ui.row().classes('w-full gap-8 flex-wrap'):
|
| 763 |
with ui.column().classes('gap-2'):
|
|
|
|
| 798 |
self.sw_source.on('update:model-value', self._update_table_columns)
|
| 799 |
|
| 800 |
with ui.column().classes('gap-2'):
|
| 801 |
+
ui.label('标签分组模式').classes('font-bold text-sm text-gray-700')
|
| 802 |
self.input_group_mode = ui.select(
|
| 803 |
['off', 'expand', 'diverse'], value='off',
|
| 804 |
).classes('w-40').props('outlined dense')
|
|
|
|
| 815 |
# ── 已选标签栏 ────────────────────────────────────────────────────────
|
| 816 |
|
| 817 |
def _build_selection_bar(self):
|
| 818 |
+
self.selection_bar_card = ui.card().classes('w-full bg-blue-50 border border-blue-200')
|
| 819 |
+
with self.selection_bar_card:
|
| 820 |
with ui.row().classes('w-full items-center justify-between'):
|
| 821 |
with ui.row().classes('items-center gap-2'):
|
| 822 |
ui.icon('check_circle', color='primary')
|
|
|
|
| 1000 |
# ── 两栏结果(CSS 强制并排)──────────────────────────────────────────
|
| 1001 |
|
| 1002 |
def _build_results_columns(self):
|
| 1003 |
+
self.two_col_container = ui.element('div').classes('w-full two-col-layout')
|
| 1004 |
+
with self.two_col_container:
|
| 1005 |
# ── 左栏:语义匹配结果(表格)──
|
| 1006 |
with ui.card().classes('col-left'):
|
| 1007 |
with ui.row().classes('items-center justify-between mb-2 w-full'):
|
|
|
|
| 1086 |
ui.separator().classes('my-2')
|
| 1087 |
with ui.row().classes('items-center justify-between w-full mb-1'):
|
| 1088 |
with ui.row().classes('items-center gap-2'):
|
| 1089 |
+
ui.label('同类标签').classes('font-bold text-sm text-gray-600')
|
| 1090 |
with ui.icon('info_outline', size='xs', color='grey').classes('cursor-help'):
|
| 1091 |
with ui.tooltip().props('content-class="bg-black text-white shadow-4"'):
|
| 1092 |
ui.label('基于标签分组数据,展示已选标签所属分组中的其他标签。勾选可加入已选。').style('font-size:14px;')
|
|
|
|
| 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 |
# ══════════════════════════════════════════════════════════════════════
|
|
|
|
| 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 |
try:
|
| 1412 |
tagger = await DanbooruTagger.get_instance()
|
| 1413 |
|
| 1414 |
+
if self.artist_search_mode:
|
| 1415 |
+
# ── 画师查找模式 ──
|
| 1416 |
+
artist_results, seed_tags, found_tags, missing_tags = \
|
| 1417 |
+
await tagger.search_artists_pipeline_async(
|
| 1418 |
+
query,
|
| 1419 |
+
limit=30,
|
| 1420 |
+
target_layers=target_layers_list,
|
| 1421 |
+
target_categories=target_cats_list,
|
| 1422 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1423 |
|
| 1424 |
+
# 后台计数
|
| 1425 |
+
asyncio.create_task(self._silent_increment_counter())
|
|
|
|
|
|
|
|
|
|
| 1426 |
|
| 1427 |
+
if not self._client_alive():
|
| 1428 |
+
return
|
| 1429 |
|
| 1430 |
+
self.current_artist_seed_tags = seed_tags
|
| 1431 |
+
self.results_section.set_visibility(True)
|
| 1432 |
+
# 隐藏标签搜索专属区域
|
| 1433 |
+
self.selection_bar_card.set_visibility(False)
|
| 1434 |
+
self.two_col_container.set_visibility(False)
|
| 1435 |
+
# 显示画师结果
|
| 1436 |
+
self.artist_results_container.set_visibility(True)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1437 |
|
| 1438 |
+
self.keywords_container.clear()
|
| 1439 |
+
with self.keywords_container:
|
| 1440 |
+
ui.label('匹配种子标签:').classes('text-sm text-gray-500 font-bold mr-2')
|
| 1441 |
+
for tag in seed_tags:
|
| 1442 |
+
ui.chip(tag).props('color=amber-2 text-color=amber-9 clickable')
|
| 1443 |
+
if not seed_tags:
|
| 1444 |
+
ui.label('无').classes('text-xs text-gray-400 italic')
|
| 1445 |
|
| 1446 |
+
self._render_artist_results(artist_results, found_tags, missing_tags)
|
| 1447 |
+
ui.notify(f'找到 {len(artist_results)} 位画师', type='positive')
|
| 1448 |
+
self.current_search_interacted = False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1449 |
|
| 1450 |
+
else:
|
| 1451 |
+
# ── 标签搜索模式 ──
|
| 1452 |
+
show_nsfw_val = self.input_nsfw.value
|
| 1453 |
+
|
| 1454 |
+
request = SearchRequest(
|
| 1455 |
+
query=query,
|
| 1456 |
+
top_k=int(self.input_top_k.value),
|
| 1457 |
+
limit=int(self.input_limit.value),
|
| 1458 |
+
popularity_weight=float(self.input_weight.value),
|
| 1459 |
+
show_nsfw=show_nsfw_val,
|
| 1460 |
+
use_segmentation=self.input_segment.value if self.input_segment else True,
|
| 1461 |
+
target_layers=target_layers_list,
|
| 1462 |
+
target_categories=target_cats_list,
|
| 1463 |
+
group_mode=self.input_group_mode.value if self.input_group_mode else 'off',
|
| 1464 |
+
max_per_group=int(self.input_max_per_group.value) if self.input_max_per_group else 2,
|
| 1465 |
+
)
|
| 1466 |
+
response = await tagger.search_async(request)
|
| 1467 |
+
|
| 1468 |
+
# 后台计数
|
| 1469 |
+
async def silent_counter_update():
|
| 1470 |
+
try:
|
| 1471 |
+
await counter.increment()
|
| 1472 |
+
if response.keywords:
|
| 1473 |
+
await counter.add_keywords(response.keywords)
|
| 1474 |
+
self._update_footer_text()
|
| 1475 |
+
except Exception as e:
|
| 1476 |
+
print(f"[UI] 后台静默更新计数失败: {e}", flush=True)
|
| 1477 |
+
asyncio.create_task(silent_counter_update())
|
| 1478 |
+
|
| 1479 |
+
if not self._client_alive():
|
| 1480 |
+
return
|
| 1481 |
+
|
| 1482 |
+
table_data = [result_to_row(r, show_nsfw_val) for r in response.results]
|
| 1483 |
+
self.full_table_data = table_data
|
| 1484 |
+
self.full_tags_str = response.tags_all
|
| 1485 |
+
self.full_tags_str_sfw = response.tags_sfw
|
| 1486 |
+
self.current_segments = list(response.segments) if response.segments else []
|
| 1487 |
+
|
| 1488 |
+
self.results_section.set_visibility(True)
|
| 1489 |
+
# 显示标签搜索专属区域
|
| 1490 |
+
self.selection_bar_card.set_visibility(True)
|
| 1491 |
+
self.two_col_container.set_visibility(True)
|
| 1492 |
+
self.artist_results_container.set_visibility(False)
|
| 1493 |
+
|
| 1494 |
+
_saved_rpp = self._get_rows_per_page()
|
| 1495 |
+
self.result_table.rows = apply_nsfw_filter(table_data, show_nsfw_val)
|
| 1496 |
+
self._set_rows_per_page(_saved_rpp)
|
| 1497 |
+
all_selected = self._get_selected_tags()
|
| 1498 |
+
self.chip_extra_selected.clear()
|
| 1499 |
+
self.chip_extra_selected.update(all_selected)
|
| 1500 |
+
self.result_table.selected = []
|
| 1501 |
+
self._render_selected_chips()
|
| 1502 |
+
self._update_selection_display(None)
|
| 1503 |
+
self._save_staged_tags()
|
| 1504 |
+
|
| 1505 |
+
self._refresh_related([], show_nsfw_val)
|
| 1506 |
+
|
| 1507 |
+
# 分词筛选 chips
|
| 1508 |
+
self.current_filter_keyword = 'ALL'
|
| 1509 |
+
self.keywords_container.clear()
|
| 1510 |
+
cached_set = set(response.cached_queries) if response.cached_queries else set()
|
| 1511 |
+
with self.keywords_container:
|
| 1512 |
+
ui.label('分词筛选:').classes('text-sm text-gray-500 font-bold mr-2')
|
| 1513 |
+
ui.chip('全部', on_click=lambda: self._filter_by_source('ALL')) \
|
| 1514 |
+
.props('color=primary text-color=white clickable')
|
| 1515 |
+
use_seg = self.input_segment.value if self.input_segment else True
|
| 1516 |
+
if use_seg:
|
| 1517 |
+
whole = ui.chip('整句',
|
| 1518 |
+
on_click=lambda: self._filter_by_source(self.current_query_str))
|
| 1519 |
+
whole.props('color=grey-4 text-color=black clickable')
|
| 1520 |
+
if self.current_query_str in cached_set:
|
| 1521 |
+
whole.style('outline: 1px dashed rgba(128,128,128,0.3); outline-offset: 1px;')
|
| 1522 |
+
for seg in response.segments:
|
| 1523 |
+
sc = ui.chip(seg,
|
| 1524 |
+
on_click=lambda s=seg: self._filter_by_source(s))
|
| 1525 |
+
sc.props('color=blue-1 text-color=blue-8 clickable')
|
| 1526 |
+
if seg in cached_set:
|
| 1527 |
+
sc.style('outline: 1px dashed rgba(128,128,128,0.3); outline-offset: 1px;')
|
| 1528 |
+
for kw in response.keywords:
|
| 1529 |
+
kc = ui.chip(kw,
|
| 1530 |
+
on_click=lambda k=kw: self._filter_by_source(k))
|
| 1531 |
+
kc.props('color=grey-4 text-color=black clickable')
|
| 1532 |
+
if kw in cached_set:
|
| 1533 |
+
kc.style('outline: 1px dashed rgba(128,128,128,0.3); outline-offset: 1px;')
|
| 1534 |
+
else:
|
| 1535 |
+
ui.label('(分词已关闭)').classes('text-xs text-gray-400')
|
| 1536 |
+
|
| 1537 |
+
ui.notify(f'找到 {len(table_data)} 个标签', type='positive')
|
| 1538 |
+
self.current_search_interacted = False
|
| 1539 |
+
|
| 1540 |
+
if self.bad_case_btn is not None:
|
| 1541 |
+
self.bad_case_btn.enable()
|
| 1542 |
|
| 1543 |
except RuntimeError as e:
|
| 1544 |
if 'deleted' in str(e).lower() or 'client' in str(e).lower():
|