Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
Auto-sync from GitHub Actions
Browse files- api_fastapi.py +1 -3
- core/engine.py +65 -22
- core/models.py +2 -1
- mcp_server.py +3 -3
- ui_nicegui.py +46 -17
api_fastapi.py
CHANGED
|
@@ -26,7 +26,6 @@ FastAPI 适配层(可选)。
|
|
| 26 |
|
| 27 |
from __future__ import annotations
|
| 28 |
|
| 29 |
-
import asyncio
|
| 30 |
from fastapi import FastAPI
|
| 31 |
from pydantic import BaseModel, Field
|
| 32 |
|
|
@@ -130,8 +129,7 @@ async def related(body: RelatedIn) -> list[RelatedTagOut]:
|
|
| 130 |
- show_nsfw:是否包含 NSFW 标签,默认 True
|
| 131 |
"""
|
| 132 |
tagger = await DanbooruTagger.get_instance()
|
| 133 |
-
results = await
|
| 134 |
-
tagger.get_related,
|
| 135 |
body.tags,
|
| 136 |
set(body.tags), # exclude 已选标签自身
|
| 137 |
body.limit,
|
|
|
|
| 26 |
|
| 27 |
from __future__ import annotations
|
| 28 |
|
|
|
|
| 29 |
from fastapi import FastAPI
|
| 30 |
from pydantic import BaseModel, Field
|
| 31 |
|
|
|
|
| 129 |
- show_nsfw:是否包含 NSFW 标签,默认 True
|
| 130 |
"""
|
| 131 |
tagger = await DanbooruTagger.get_instance()
|
| 132 |
+
results = await tagger.get_related_async(
|
|
|
|
| 133 |
body.tags,
|
| 134 |
set(body.tags), # exclude 已选标签自身
|
| 135 |
body.limit,
|
core/engine.py
CHANGED
|
@@ -163,9 +163,9 @@ class DanbooruTagger:
|
|
| 163 |
|
| 164 |
_instance: Optional['DanbooruTagger'] = None
|
| 165 |
_lock: Optional[asyncio.Lock] = None
|
| 166 |
-
# 进程级
|
| 167 |
-
# 并发抢占 CPU 而拖垮事件循环。
|
| 168 |
-
|
| 169 |
|
| 170 |
@classmethod
|
| 171 |
def is_ready(cls) -> bool:
|
|
@@ -346,10 +346,15 @@ class DanbooruTagger:
|
|
| 346 |
|
| 347 |
# ── 搜索 ──────────────────────────────────────────────────────────────
|
| 348 |
|
| 349 |
-
def _encode_queries(self, queries: list[str]) -> torch.Tensor:
|
| 350 |
-
"""批量编码查询词,命中 embedding 缓存的跳过 model.encode。
|
|
|
|
|
|
|
|
|
|
|
|
|
| 351 |
cached_vecs: list[Optional[torch.Tensor]] = [self._emb_cache.get(q) for q in queries]
|
| 352 |
uncached_idx = [i for i, v in enumerate(cached_vecs) if v is None]
|
|
|
|
| 353 |
|
| 354 |
if uncached_idx:
|
| 355 |
uncached_texts = [queries[i] for i in uncached_idx]
|
|
@@ -362,7 +367,7 @@ class DanbooruTagger:
|
|
| 362 |
self._emb_cache.put(queries[i], emb)
|
| 363 |
cached_vecs[i] = emb
|
| 364 |
|
| 365 |
-
return torch.stack(cached_vecs) # type: ignore[arg-type]
|
| 366 |
|
| 367 |
def search(self, request: SearchRequest) -> SearchResponse:
|
| 368 |
if not self.is_loaded:
|
|
@@ -396,7 +401,7 @@ class DanbooruTagger:
|
|
| 396 |
extra_segments = []
|
| 397 |
queries = [request.query]
|
| 398 |
|
| 399 |
-
q_emb = self._encode_queries(queries)
|
| 400 |
|
| 401 |
tl = request.target_layers
|
| 402 |
k = request.top_k
|
|
@@ -473,17 +478,23 @@ class DanbooruTagger:
|
|
| 473 |
# 对每个候选标签,计算其与完整原始查询(而非分词片段)的语义相似度,
|
| 474 |
# 将相似度作为软因子乘入 final_score,使仅由分词碎片匹配到的噪声
|
| 475 |
# 标签自然下沉,同时不硬过滤任何结果。
|
|
|
|
| 476 |
full_q = q_emb[0] # queries[0] 始终为完整原始查询
|
| 477 |
-
alpha = 0.3 if
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
|
|
|
|
|
|
|
| 481 |
for ln, attr, _ in _LAYER_SPEC:
|
| 482 |
if ln not in tl:
|
| 483 |
continue
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
|
|
|
|
|
|
|
|
|
|
| 487 |
|
| 488 |
# Group expand 处理(在 guaranteed_tags 之前,因为会改分数)
|
| 489 |
if request.group_mode == "expand" and self._tag_to_groups:
|
|
@@ -537,27 +548,59 @@ class DanbooruTagger:
|
|
| 537 |
|
| 538 |
tags_all = ', '.join(r.tag for r in valid)
|
| 539 |
tags_sfw = ', '.join(r.tag for r in valid if r.nsfw != '1')
|
|
|
|
| 540 |
response = SearchResponse(
|
| 541 |
tags_all=tags_all, tags_sfw=tags_sfw,
|
| 542 |
results=valid, keywords=keywords, segments=extra_segments,
|
|
|
|
| 543 |
)
|
| 544 |
self._search_cache.put(cache_key, response)
|
| 545 |
return response
|
| 546 |
|
|
|
|
|
|
|
| 547 |
@classmethod
|
| 548 |
-
def
|
| 549 |
-
if cls.
|
| 550 |
-
cls.
|
| 551 |
-
return cls.
|
| 552 |
|
| 553 |
async def search_async(self, request: SearchRequest) -> SearchResponse:
|
| 554 |
-
"""search() 的并发安全异步封装:
|
| 555 |
|
| 556 |
所有异步入口(MCP / API / UI)都应改用本方法,而非各自
|
| 557 |
-
asyncio.to_thread(self.search),以共享同一个并发闸门。
|
|
|
|
| 558 |
"""
|
| 559 |
-
async with self.
|
| 560 |
-
return await asyncio.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 561 |
|
| 562 |
def _apply_group_expand(self, final: dict[str, TagResult]) -> None:
|
| 563 |
"""expand 模式:提升同 group 标签的分数。"""
|
|
|
|
| 163 |
|
| 164 |
_instance: Optional['DanbooruTagger'] = None
|
| 165 |
_lock: Optional[asyncio.Lock] = None
|
| 166 |
+
# 进程级 CPU 并发闸门:串行化所有 CPU 密集型操作(search / get_related /
|
| 167 |
+
# get_group_candidates),避免并发抢占 CPU 而拖垮 asyncio 事件循环。
|
| 168 |
+
_cpu_sem: Optional[asyncio.Semaphore] = None
|
| 169 |
|
| 170 |
@classmethod
|
| 171 |
def is_ready(cls) -> bool:
|
|
|
|
| 346 |
|
| 347 |
# ── 搜索 ──────────────────────────────────────────────────────────────
|
| 348 |
|
| 349 |
+
def _encode_queries(self, queries: list[str]) -> tuple[torch.Tensor, list[bool]]:
|
| 350 |
+
"""批量编码查询词,命中 embedding 缓存的跳过 model.encode。
|
| 351 |
+
|
| 352 |
+
Returns:
|
| 353 |
+
(q_emb, hit_mask): 编码后的张量 (Q, D) 以及每个 query 是否命中缓存。
|
| 354 |
+
"""
|
| 355 |
cached_vecs: list[Optional[torch.Tensor]] = [self._emb_cache.get(q) for q in queries]
|
| 356 |
uncached_idx = [i for i, v in enumerate(cached_vecs) if v is None]
|
| 357 |
+
hit_mask = [v is not None for v in cached_vecs]
|
| 358 |
|
| 359 |
if uncached_idx:
|
| 360 |
uncached_texts = [queries[i] for i in uncached_idx]
|
|
|
|
| 367 |
self._emb_cache.put(queries[i], emb)
|
| 368 |
cached_vecs[i] = emb
|
| 369 |
|
| 370 |
+
return torch.stack(cached_vecs), hit_mask # type: ignore[arg-type]
|
| 371 |
|
| 372 |
def search(self, request: SearchRequest) -> SearchResponse:
|
| 373 |
if not self.is_loaded:
|
|
|
|
| 401 |
extra_segments = []
|
| 402 |
queries = [request.query]
|
| 403 |
|
| 404 |
+
q_emb, hit_mask = self._encode_queries(queries)
|
| 405 |
|
| 406 |
tl = request.target_layers
|
| 407 |
k = request.top_k
|
|
|
|
| 478 |
# 对每个候选标签,计算其与完整原始查询(而非分词片段)的语义相似度,
|
| 479 |
# 将相似度作为软因子乘入 final_score,使仅由分词碎片匹配到的噪声
|
| 480 |
# 标签自然下沉,同时不硬过滤任何结果。
|
| 481 |
+
# 批量矩阵乘法替代逐条 torch.dot,O(R*L) 降为 O(L) + O(R)。
|
| 482 |
full_q = q_emb[0] # queries[0] 始终为完整原始查询
|
| 483 |
+
alpha = 0.3 if request.use_segmentation else 0 # 一致性调节强度(0=不调节, 1=完全按一致性重排),仅在启用分词时有意义
|
| 484 |
+
if alpha > 0 and final:
|
| 485 |
+
tag_list = list(final.keys())
|
| 486 |
+
tag_indices = [self._name_to_idx[t] for t in tag_list]
|
| 487 |
+
idx_tensor = torch.tensor(tag_indices, dtype=torch.long, device=full_q.device)
|
| 488 |
+
max_co = torch.zeros(len(tag_indices), device=full_q.device)
|
| 489 |
for ln, attr, _ in _LAYER_SPEC:
|
| 490 |
if ln not in tl:
|
| 491 |
continue
|
| 492 |
+
emb_selected = getattr(self, attr)[idx_tensor] # (R, D)
|
| 493 |
+
co = (full_q.unsqueeze(0) @ emb_selected.T).squeeze(0) # (R,)
|
| 494 |
+
max_co = torch.maximum(max_co, co)
|
| 495 |
+
for i, tag in enumerate(tag_list):
|
| 496 |
+
r = final[tag]
|
| 497 |
+
r.final_score = round(r.final_score * (1.0 - alpha + alpha * float(max_co[i])), 4)
|
| 498 |
|
| 499 |
# Group expand 处理(在 guaranteed_tags 之前,因为会改分数)
|
| 500 |
if request.group_mode == "expand" and self._tag_to_groups:
|
|
|
|
| 548 |
|
| 549 |
tags_all = ', '.join(r.tag for r in valid)
|
| 550 |
tags_sfw = ', '.join(r.tag for r in valid if r.nsfw != '1')
|
| 551 |
+
cached_queries = [q for q, hit in zip(queries, hit_mask) if hit]
|
| 552 |
response = SearchResponse(
|
| 553 |
tags_all=tags_all, tags_sfw=tags_sfw,
|
| 554 |
results=valid, keywords=keywords, segments=extra_segments,
|
| 555 |
+
cached_queries=cached_queries,
|
| 556 |
)
|
| 557 |
self._search_cache.put(cache_key, response)
|
| 558 |
return response
|
| 559 |
|
| 560 |
+
# ── CPU 并发闸门(类级信号量,所有 CPU 密集型操作共享)───────────────
|
| 561 |
+
|
| 562 |
@classmethod
|
| 563 |
+
def _get_cpu_sem(cls) -> asyncio.Semaphore:
|
| 564 |
+
if cls._cpu_sem is None:
|
| 565 |
+
cls._cpu_sem = asyncio.Semaphore(1)
|
| 566 |
+
return cls._cpu_sem
|
| 567 |
|
| 568 |
async def search_async(self, request: SearchRequest) -> SearchResponse:
|
| 569 |
+
"""search() 的并发安全异步封装:共享闸门串行化 + 线程池执行。
|
| 570 |
|
| 571 |
所有异步入口(MCP / API / UI)都应改用本方法,而非各自
|
| 572 |
+
asyncio.to_thread(self.search),以共享同一个 CPU 并发闸门。
|
| 573 |
+
包含 60 秒超时,防止异常卡死导致信号量永久泄漏。
|
| 574 |
"""
|
| 575 |
+
async with self._get_cpu_sem():
|
| 576 |
+
return await asyncio.wait_for(
|
| 577 |
+
asyncio.to_thread(self.search, request),
|
| 578 |
+
timeout=60.0,
|
| 579 |
+
)
|
| 580 |
+
|
| 581 |
+
async def get_related_async(
|
| 582 |
+
self,
|
| 583 |
+
seed_tags: list[str],
|
| 584 |
+
exclude: set[str] | None = None,
|
| 585 |
+
limit: int = 20,
|
| 586 |
+
show_nsfw: bool = True,
|
| 587 |
+
) -> list:
|
| 588 |
+
"""get_related() 的并发安全异步封装,共享同一个 CPU 闸门。"""
|
| 589 |
+
async with self._get_cpu_sem():
|
| 590 |
+
return await asyncio.to_thread(
|
| 591 |
+
self.get_related, seed_tags, exclude, limit, show_nsfw,
|
| 592 |
+
)
|
| 593 |
+
|
| 594 |
+
async def get_group_candidates_async(
|
| 595 |
+
self,
|
| 596 |
+
selected_tags: list[str],
|
| 597 |
+
show_nsfw: bool = True,
|
| 598 |
+
) -> list[dict]:
|
| 599 |
+
"""get_group_candidates() 的并发安全异步封装,共享同一个 CPU 闸门。"""
|
| 600 |
+
async with self._get_cpu_sem():
|
| 601 |
+
return await asyncio.to_thread(
|
| 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 标签的分数。"""
|
core/models.py
CHANGED
|
@@ -60,4 +60,5 @@ class SearchResponse:
|
|
| 60 |
tags_sfw: str
|
| 61 |
results: list[TagResult]
|
| 62 |
keywords: list[str]
|
| 63 |
-
segments: list[str] = field(default_factory=list) # 分隔符切分后的原始从句级片段
|
|
|
|
|
|
| 60 |
tags_sfw: str
|
| 61 |
results: list[TagResult]
|
| 62 |
keywords: list[str]
|
| 63 |
+
segments: list[str] = field(default_factory=list) # 分隔符切分后的原始从句级片段
|
| 64 |
+
cached_queries: list[str] = field(default_factory=list) # 命中 emb 缓存的查询文本
|
mcp_server.py
CHANGED
|
@@ -64,8 +64,9 @@ Only supported for general, copyright, and character tag searches; **artists and
|
|
| 64 |
- search_mode: Preset strategy. Pick the one that matches your intent.
|
| 65 |
"full_scene" — Full scene → prompt (e.g. "一个穿着白色水手服的少女在雨中奔跑")
|
| 66 |
"concept_explore" — Vague concept exploration, broad recall (e.g. "赛博朋克服装", "兔耳朵", "中国风汉服")
|
| 67 |
-
"subject_describe" — Describe
|
| 68 |
"precise_lookup" — Precise lookup / spell fix (e.g. "selafuku", "thighhigh")
|
|
|
|
| 69 |
- category: Filter to a specific tag category. Default "all".
|
| 70 |
"all" — All (通用 + 版权 + 人物)
|
| 71 |
"general" — Visual attributes, clothing, pose, background, etc.
|
|
@@ -258,8 +259,7 @@ JSON array sorted by aggregated NPMI score (descending). Each result:
|
|
| 258 |
elif t in corrections:
|
| 259 |
corrected_tags.append(corrections[t])
|
| 260 |
|
| 261 |
-
results = await
|
| 262 |
-
tagger.get_related,
|
| 263 |
corrected_tags,
|
| 264 |
set(corrected_tags),
|
| 265 |
limit,
|
|
|
|
| 64 |
- search_mode: Preset strategy. Pick the one that matches your intent.
|
| 65 |
"full_scene" — Full scene → prompt (e.g. "一个穿着白色水手服的少女在雨中奔跑")
|
| 66 |
"concept_explore" — Vague concept exploration, broad recall (e.g. "赛博朋克服装", "兔耳朵", "中国风汉服")
|
| 67 |
+
"subject_describe" — Describe **one** subject to find matching tags (e.g. "EVA中蓝发的驾驶员", "两侧有开口,前方有拉绳的运动短裤")
|
| 68 |
"precise_lookup" — Precise lookup / spell fix (e.g. "selafuku", "thighhigh")
|
| 69 |
+
- HINT: In subject_describe mode, the tokenizer is disabled, and you can only describe one thing at a time. To search for multiple things at once, use concept_explore or full_scene.
|
| 70 |
- category: Filter to a specific tag category. Default "all".
|
| 71 |
"all" — All (通用 + 版权 + 人物)
|
| 72 |
"general" — Visual attributes, clothing, pose, background, etc.
|
|
|
|
| 259 |
elif t in corrections:
|
| 260 |
corrected_tags.append(corrections[t])
|
| 261 |
|
| 262 |
+
results = await tagger.get_related_async(
|
|
|
|
| 263 |
corrected_tags,
|
| 264 |
set(corrected_tags),
|
| 265 |
limit,
|
ui_nicegui.py
CHANGED
|
@@ -56,6 +56,18 @@ class _SuppressMCPNoise(logging.Filter):
|
|
| 56 |
|
| 57 |
logging.getLogger("uvicorn.error").addFilter(_SuppressMCPNoise())
|
| 58 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
# ── 表格列定义 ─────────────────────────────────────────────────────────────────
|
| 60 |
|
| 61 |
TABLE_COLUMNS = [
|
|
@@ -150,6 +162,9 @@ class DanbooruSearchUI:
|
|
| 150 |
self.selected_chips_container = None # 已选标签 chip 容器
|
| 151 |
self.current_related: list = []
|
| 152 |
self.chip_extra_selected: set = set()
|
|
|
|
|
|
|
|
|
|
| 153 |
|
| 154 |
# tag -> prompt 权重,范围 [0.1, 1.9],默认 1.0
|
| 155 |
self.tag_weights: dict[str, float] = {}
|
|
@@ -1233,24 +1248,31 @@ class DanbooruSearchUI:
|
|
| 1233 |
# 分词筛选 chips
|
| 1234 |
self.current_filter_keyword = 'ALL' # 新搜索默认选中"全部"
|
| 1235 |
self.keywords_container.clear()
|
|
|
|
| 1236 |
with self.keywords_container:
|
| 1237 |
ui.label('分词筛选:').classes('text-sm text-gray-500 font-bold mr-2')
|
| 1238 |
ui.chip('全部', on_click=lambda: self._filter_by_source('ALL')) \
|
| 1239 |
.props('color=primary text-color=white clickable')
|
| 1240 |
use_seg = self.input_segment.value if self.input_segment else True
|
| 1241 |
if use_seg:
|
| 1242 |
-
ui.chip('整句',
|
| 1243 |
-
on_click=lambda: self._filter_by_source(self.current_query_str))
|
| 1244 |
-
|
|
|
|
|
|
|
| 1245 |
# 从句级原始片段(分隔符切分后未 jieba 的长片段,区别于关键词)
|
| 1246 |
for seg in response.segments:
|
| 1247 |
-
ui.chip(seg,
|
| 1248 |
-
on_click=lambda s=seg: self._filter_by_source(s))
|
| 1249 |
-
|
|
|
|
|
|
|
| 1250 |
for kw in response.keywords:
|
| 1251 |
-
ui.chip(kw,
|
| 1252 |
-
on_click=lambda k=kw: self._filter_by_source(k))
|
| 1253 |
-
|
|
|
|
|
|
|
| 1254 |
else:
|
| 1255 |
ui.label('(分词已关闭)').classes('text-xs text-gray-400')
|
| 1256 |
|
|
@@ -1423,25 +1445,31 @@ class DanbooruSearchUI:
|
|
| 1423 |
self._render_related_list(merged, show_nsfw)
|
| 1424 |
|
| 1425 |
def _refresh_related_from_selection(self, selected_tags: list[str], show_nsfw: bool):
|
| 1426 |
-
"""仅刷新关联推荐列表。"""
|
|
|
|
|
|
|
|
|
|
| 1427 |
async def _do():
|
|
|
|
| 1428 |
if not selected_tags:
|
| 1429 |
self._refresh_related([], show_nsfw)
|
| 1430 |
return
|
| 1431 |
tagger = await DanbooruTagger.get_instance()
|
| 1432 |
-
related = await
|
| 1433 |
-
tagger.get_related,
|
| 1434 |
selected_tags,
|
| 1435 |
set(selected_tags),
|
| 1436 |
50,
|
| 1437 |
show_nsfw,
|
| 1438 |
)
|
| 1439 |
self._refresh_related(related, show_nsfw)
|
| 1440 |
-
asyncio.ensure_future(_do())
|
| 1441 |
|
| 1442 |
def _refresh_group_from_selection(self, selected_tags: list[str], show_nsfw: bool):
|
| 1443 |
-
"""仅刷新同类扩展区域。"""
|
|
|
|
|
|
|
| 1444 |
async def _do():
|
|
|
|
| 1445 |
if not selected_tags:
|
| 1446 |
if self.group_expansion_container is not None:
|
| 1447 |
self.group_expansion_container.clear()
|
|
@@ -1449,13 +1477,12 @@ class DanbooruSearchUI:
|
|
| 1449 |
ui.label('请先搜索并勾选标签…').classes('text-sm text-gray-400 italic p-4')
|
| 1450 |
return
|
| 1451 |
tagger = await DanbooruTagger.get_instance()
|
| 1452 |
-
group_data = await
|
| 1453 |
-
tagger.get_group_candidates,
|
| 1454 |
selected_tags,
|
| 1455 |
show_nsfw,
|
| 1456 |
)
|
| 1457 |
self._render_group_expansion(group_data, selected_tags, show_nsfw)
|
| 1458 |
-
asyncio.ensure_future(_do())
|
| 1459 |
|
| 1460 |
def _render_group_expansion(self, group_data: list, selected_tags: list[str], show_nsfw: bool):
|
| 1461 |
"""渲染 Group 同类扩展区域。"""
|
|
@@ -1753,6 +1780,7 @@ if __name__ in {'__main__', '__mp_main__'}:
|
|
| 1753 |
async def head_root():
|
| 1754 |
return PlainTextResponse('')
|
| 1755 |
|
|
|
|
| 1756 |
ui.run(
|
| 1757 |
host=host,
|
| 1758 |
port=port,
|
|
@@ -1761,3 +1789,4 @@ if __name__ in {'__main__', '__mp_main__'}:
|
|
| 1761 |
show=not is_cloud(),
|
| 1762 |
reconnect_timeout=120,
|
| 1763 |
)
|
|
|
|
|
|
| 56 |
|
| 57 |
logging.getLogger("uvicorn.error").addFilter(_SuppressMCPNoise())
|
| 58 |
|
| 59 |
+
# suppress MCP OAuth discovery 404 noise (clients probing .well-known/oauth-authorization-server)
|
| 60 |
+
class _SuppressOAuthNoise(logging.Filter):
|
| 61 |
+
_MARKER = ".well-known/oauth-authorization-server"
|
| 62 |
+
|
| 63 |
+
def filter(self, record: logging.LogRecord) -> bool:
|
| 64 |
+
if self._MARKER in record.getMessage():
|
| 65 |
+
return False
|
| 66 |
+
return True
|
| 67 |
+
|
| 68 |
+
logging.getLogger("uvicorn.access").addFilter(_SuppressOAuthNoise())
|
| 69 |
+
logging.getLogger("nicegui").addFilter(_SuppressOAuthNoise())
|
| 70 |
+
|
| 71 |
# ── 表格列定义 ─────────────────────────────────────────────────────────────────
|
| 72 |
|
| 73 |
TABLE_COLUMNS = [
|
|
|
|
| 162 |
self.selected_chips_container = None # 已选标签 chip 容器
|
| 163 |
self.current_related: list = []
|
| 164 |
self.chip_extra_selected: set = set()
|
| 165 |
+
# 去抖任务句柄(取消旧任务避免 CPU 洪峰)
|
| 166 |
+
self._debounce_related_task = None # type: asyncio.Task | None
|
| 167 |
+
self._debounce_group_task = None # type: asyncio.Task | None
|
| 168 |
|
| 169 |
# tag -> prompt 权重,范围 [0.1, 1.9],默认 1.0
|
| 170 |
self.tag_weights: dict[str, float] = {}
|
|
|
|
| 1248 |
# 分词筛选 chips
|
| 1249 |
self.current_filter_keyword = 'ALL' # 新搜索默认选中"全部"
|
| 1250 |
self.keywords_container.clear()
|
| 1251 |
+
cached_set = set(response.cached_queries) if response.cached_queries else set()
|
| 1252 |
with self.keywords_container:
|
| 1253 |
ui.label('分词筛选:').classes('text-sm text-gray-500 font-bold mr-2')
|
| 1254 |
ui.chip('全部', on_click=lambda: self._filter_by_source('ALL')) \
|
| 1255 |
.props('color=primary text-color=white clickable')
|
| 1256 |
use_seg = self.input_segment.value if self.input_segment else True
|
| 1257 |
if use_seg:
|
| 1258 |
+
whole = ui.chip('整句',
|
| 1259 |
+
on_click=lambda: self._filter_by_source(self.current_query_str))
|
| 1260 |
+
whole.props('color=grey-4 text-color=black clickable')
|
| 1261 |
+
if self.current_query_str in cached_set:
|
| 1262 |
+
whole.style('outline: 1px dashed rgba(128,128,128,0.3); outline-offset: 1px;')
|
| 1263 |
# 从句级原始片段(分隔符切分后未 jieba 的长片段,区别于关键词)
|
| 1264 |
for seg in response.segments:
|
| 1265 |
+
sc = ui.chip(seg,
|
| 1266 |
+
on_click=lambda s=seg: self._filter_by_source(s))
|
| 1267 |
+
sc.props('color=blue-1 text-color=blue-8 clickable')
|
| 1268 |
+
if seg in cached_set:
|
| 1269 |
+
sc.style('outline: 1px dashed rgba(128,128,128,0.3); outline-offset: 1px;')
|
| 1270 |
for kw in response.keywords:
|
| 1271 |
+
kc = ui.chip(kw,
|
| 1272 |
+
on_click=lambda k=kw: self._filter_by_source(k))
|
| 1273 |
+
kc.props('color=grey-4 text-color=black clickable')
|
| 1274 |
+
if kw in cached_set:
|
| 1275 |
+
kc.style('outline: 1px dashed rgba(128,128,128,0.3); outline-offset: 1px;')
|
| 1276 |
else:
|
| 1277 |
ui.label('(分词已关闭)').classes('text-xs text-gray-400')
|
| 1278 |
|
|
|
|
| 1445 |
self._render_related_list(merged, show_nsfw)
|
| 1446 |
|
| 1447 |
def _refresh_related_from_selection(self, selected_tags: list[str], show_nsfw: bool):
|
| 1448 |
+
"""仅刷新关联推荐列表(300ms 去抖,避免快速勾选产生 CPU 洪峰)。"""
|
| 1449 |
+
# 取消上次未执行的刷新
|
| 1450 |
+
if self._debounce_related_task and not self._debounce_related_task.done():
|
| 1451 |
+
self._debounce_related_task.cancel()
|
| 1452 |
async def _do():
|
| 1453 |
+
await asyncio.sleep(0.3)
|
| 1454 |
if not selected_tags:
|
| 1455 |
self._refresh_related([], show_nsfw)
|
| 1456 |
return
|
| 1457 |
tagger = await DanbooruTagger.get_instance()
|
| 1458 |
+
related = await tagger.get_related_async(
|
|
|
|
| 1459 |
selected_tags,
|
| 1460 |
set(selected_tags),
|
| 1461 |
50,
|
| 1462 |
show_nsfw,
|
| 1463 |
)
|
| 1464 |
self._refresh_related(related, show_nsfw)
|
| 1465 |
+
self._debounce_related_task = asyncio.ensure_future(_do())
|
| 1466 |
|
| 1467 |
def _refresh_group_from_selection(self, selected_tags: list[str], show_nsfw: bool):
|
| 1468 |
+
"""仅刷新同类扩展区域(300ms 去抖,避免快速勾选产生 CPU 洪峰)。"""
|
| 1469 |
+
if self._debounce_group_task and not self._debounce_group_task.done():
|
| 1470 |
+
self._debounce_group_task.cancel()
|
| 1471 |
async def _do():
|
| 1472 |
+
await asyncio.sleep(0.3)
|
| 1473 |
if not selected_tags:
|
| 1474 |
if self.group_expansion_container is not None:
|
| 1475 |
self.group_expansion_container.clear()
|
|
|
|
| 1477 |
ui.label('请先搜索并勾选标签…').classes('text-sm text-gray-400 italic p-4')
|
| 1478 |
return
|
| 1479 |
tagger = await DanbooruTagger.get_instance()
|
| 1480 |
+
group_data = await tagger.get_group_candidates_async(
|
|
|
|
| 1481 |
selected_tags,
|
| 1482 |
show_nsfw,
|
| 1483 |
)
|
| 1484 |
self._render_group_expansion(group_data, selected_tags, show_nsfw)
|
| 1485 |
+
self._debounce_group_task = asyncio.ensure_future(_do())
|
| 1486 |
|
| 1487 |
def _render_group_expansion(self, group_data: list, selected_tags: list[str], show_nsfw: bool):
|
| 1488 |
"""渲染 Group 同类扩展区域。"""
|
|
|
|
| 1780 |
async def head_root():
|
| 1781 |
return PlainTextResponse('')
|
| 1782 |
|
| 1783 |
+
|
| 1784 |
ui.run(
|
| 1785 |
host=host,
|
| 1786 |
port=port,
|
|
|
|
| 1789 |
show=not is_cloud(),
|
| 1790 |
reconnect_timeout=120,
|
| 1791 |
)
|
| 1792 |
+
|