SAkizuki commited on
Commit
65b0b93
·
verified ·
1 Parent(s): 22f3e17

Auto-sync from GitHub Actions

Browse files
README.md CHANGED
@@ -6,7 +6,7 @@ colorTo: indigo
6
  sdk: docker
7
  app_port: 7860
8
  pinned: false
9
- license: mit
10
  short_description: 基于语义匹配的 Danbooru 标签搜索引擎,支持多维匹配、智能分词与共现关联推荐。
11
  tags:
12
  - text-to-image
@@ -44,6 +44,7 @@ thumbnail: >-
44
  * **智能分词与显式分隔:** 支持一键开启/关闭自动分词。开启后自动提取长句中概念分别检索;同时支持用空格、换行、中文逗号(,)和顿号(、)手动分隔概念,系统会尊重你的分隔意图,将每个片段作为整体匹配(超过 7 字的片段仍然自动分词)。
45
  * **标签互相关推荐:** 引擎内置标签共现数据。当你选中某些标签后,系统会自动推荐在 Danbooru 图库中经常与它们一同出现的相关标签,辅助补全画面细节。
46
  * **推荐擅长画师:** 基于标签-画师 NPMI 共现数据,根据已选标签实时推荐擅长绘制这些元素的画师,支持勾选加入已选标签。
 
47
  * **精细化查阅与过滤:** 支持按标签类别(General通用 / Character角色 / Copyright作品)进行硬过滤。
48
  * 提供 **NSFW 保护模式**(开启后自动模糊敏感词汇)。
49
  * 表格内置 Danbooru 维基释义悬浮提示,并提供前往原站的快捷链接。
@@ -61,8 +62,9 @@ thumbnail: >-
61
  - [完整画面查找](#4-完整画面查找)
62
  - [关联推荐](#5-关联推荐共现匹配)
63
  - [推荐擅长画师](#6-推荐擅长画师)
 
64
 
65
- 前四项基于**语义向量匹配**,后两项基于**共现数据**(标签-标签共现与标签-画师共现)。
66
 
67
  ---
68
 
@@ -98,9 +100,10 @@ thumbnail: >-
98
 
99
  ### 1. 精确查词
100
 
101
- **适用场景:** 你知道某个标签的大概写法但不确定拼写是否准确,或者只记得部分写法
102
 
103
  本工具通过语义相似度匹配而非字符串精确匹配,因此具备一定的拼写容错能力。
 
104
 
105
  **建议参数:** Top K: 20 | 结果上限: 10 | 热度权重: 0.15 | **关闭智能分词**
106
 
@@ -110,6 +113,7 @@ thumbnail: >-
110
 
111
  | 你的输入 | 实际标准标签 | 说明 |
112
  |---|---|---|
 
113
  | `selafuku` | `serafuku` | 经典拼写错误,仍可命中 |
114
  | `school unifor` | `school_uniform` | 漏字母,仍可命中 |
115
  | `twintail` | `twintails` | 单复数混淆,仍可命中 |
@@ -307,6 +311,29 @@ thumbnail: >-
307
 
308
  ---
309
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
310
  ### 使用技巧
311
 
312
  **技巧 1:先宽后窄**
@@ -360,7 +387,7 @@ POST /api/search
360
  ```
361
 
362
  搜索接口保留底层可调参数,适合需要精细控制召回数量、分层检索、分词策略、类别筛选和分组去重的工作流。
363
- 响应包含 `tags_all`、`tags_sfw`、`keywords`、`results`;每条结果默认包含完整字段和 `wiki`。
364
 
365
  **关联推荐接口**
366
  ```
@@ -480,9 +507,9 @@ npm install -g mcp-remote
480
  - `full_scene`:默认值。用户描述的是一张具体画面时使用,即使描述里有很多元素,也仍然属于完整画面。
481
  - `concept_explore`:用户想浏览某个概念下有哪些候选标签时使用,结果更多,token 消耗更高。
482
  - `subject_describe`:只描述一个单一视觉概念、角色或物品时使用;多元素画面不要用这个模式。
483
- - `precise_lookup`:用于拼写纠错接近精确的标签查询。
484
 
485
- 返回 JSON 包含:`prompt`(逗号分隔标签)、`keywords`(检索关键词)、`results`(标签结果)。每条结果包含 `tag`、`cn_name`,当 `include_wiki=true` 时包含 `wiki`。
486
 
487
  **`get_related_tags`** — 共现关联推荐
488
 
@@ -589,3 +616,7 @@ DanbooruSearch 会继续免费开放。如果这个小工具刚好帮你省了
589
 
590
  - 本项目中借鉴了[Good Anima](https://github.com/ShiroEirin/comfyui-good-anima)项目中关于Anima模型提示词书写的相关内容
591
  - [Linux Do社区](https://linux.do/)
 
 
 
 
 
6
  sdk: docker
7
  app_port: 7860
8
  pinned: false
9
+ license: gpl-3.0
10
  short_description: 基于语义匹配的 Danbooru 标签搜索引擎,支持多维匹配、智能分词与共现关联推荐。
11
  tags:
12
  - text-to-image
 
44
  * **智能分词与显式分隔:** 支持一键开启/关闭自动分词。开启后自动提取长句中概念分别检索;同时支持用空格、换行、中文逗号(,)和顿号(、)手动分隔概念,系统会尊重你的分隔意图,将每个片段作为整体匹配(超过 7 字的片段仍然自动分词)。
45
  * **标签互相关推荐:** 引擎内置标签共现数据。当你选中某些标签后,系统会自动推荐在 Danbooru 图库中经常与它们一同出现的相关标签,辅助补全画面细节。
46
  * **推荐擅长画师:** 基于标签-画师 NPMI 共现数据,根据已选标签实时推荐擅长绘制这些元素的画师,支持勾选加入已选标签。
47
+ * **已选区工作区编辑:** 搜索结果、关联标签、同类标签和推荐画师可以汇入同一个标签工作区;支持调整 Prompt 权重、撤销/恢复、历史快照、收藏、Prompt 导入与完整备份,数据默认保存在当前浏览器中。
48
  * **精细化查阅与过滤:** 支持按标签类别(General通用 / Character角色 / Copyright作品)进行硬过滤。
49
  * 提供 **NSFW 保护模式**(开启后自动模糊敏感词汇)。
50
  * 表格内置 Danbooru 维基释义悬浮提示,并提供前往原站的快捷链接。
 
62
  - [完整画面查找](#4-完整画面查找)
63
  - [关联推荐](#5-关联推荐共现匹配)
64
  - [推荐擅长画师](#6-推荐擅长画师)
65
+ - [已选区工作区编辑](#7-已选区工作区编辑)
66
 
67
+ 前四项基于**语义向量匹配**,第 5、6 项基于**共现数据**(标签-标签共现与标签-画师共现);第 7 项用于整理、编辑和复用最终 Prompt
68
 
69
  ---
70
 
 
100
 
101
  ### 1. 精确查词
102
 
103
+ **适用场景:** 你想用中文或英文查询一个单一概念,或者知道某个标签的大概写法但不确定拼写是否准确。
104
 
105
  本工具通过语义相似度匹配而非字符串精确匹配,因此具备一定的拼写容错能力。
106
+ 中文概念同样会通过中文核心词、中文扩展词和释义等向量层检索,例如输入 `水手服` 可以召回 `serafuku`。语义搜索召回废弃标签时,系统会使用官方 Danbooru Tag Alias 将其规范化为当前标签;Alias 只纠正结果,不替代语义搜索。
107
 
108
  **建议参数:** Top K: 20 | 结果上限: 10 | 热度权重: 0.15 | **关闭智能分词**
109
 
 
113
 
114
  | 你的输入 | 实际标准标签 | 说明 |
115
  |---|---|---|
116
+ | `水手服` | `serafuku` | 中文概念通过语义搜索命中 |
117
  | `selafuku` | `serafuku` | 经典拼写错误,仍可命中 |
118
  | `school unifor` | `school_uniform` | 漏字母,仍可命中 |
119
  | `twintail` | `twintails` | 单复数混淆,仍可命中 |
 
311
 
312
  ---
313
 
314
+ ### 7. 已选区工作区编辑
315
+
316
+ 在搜索结果、关联标签、同类标签或推荐画师中勾选内容后,它们都会进入同一个已选区(标签工作区)。工作区会自动保存在当前浏览器中,刷新页面后仍可继续编辑。
317
+
318
+ **标签与 Prompt 编辑:**
319
+
320
+ - 使用标签两侧的 `− / +` 调整 Prompt 权重,也可以随时移除不需要的标签。
321
+ - 使用「撤销 / 恢复」回退或重做最近的工作区修改。
322
+ - 可切换 SDXL、NAI、Anima 复制格式,所选格式会随工作区保存;画师标签在 Anima 格式下会自动转换为 `@画师名`。
323
+ - 「清空已选」只会清空当前工作区,不会删除历史记录或收藏。
324
+
325
+ **历史与收藏:**
326
+
327
+ - 每次搜索都会记录查询内容、当时的搜索设置和工作区快照。你可以使用「重新搜索」恢复原查询与参数,使用「恢复工作区」找回当时的标签、权重和格式,或使用「追加查询」保留当前工作区并继续搜索。
328
+ - 「保存收藏」会保存当前标签组合、权重、来源和 Prompt 格式。收藏可恢复、合并或直接复制;合并同名标签时,会保留当前工作区已有的权重。
329
+
330
+ **Prompt 导入与备份:**
331
+
332
+ - 「导入 Prompt」支持半角/全角逗号和换行分隔,并会使用 Danbooru Tag Alias 替换已弃用标签。重复标签会自动合并,无法唯一识别的内容会进入待确认区,不会被强行加入工作区。
333
+ - 「备份 / 迁移」可以导出包含当前配置、工作区、原始搜索查询、历史和收藏的完整 JSON,并支持合并或替换导入。上述数据默认仅保存在当前浏览器中,备份文件只有在你主动下载或上传时才会离开浏览器。
334
+
335
+ ---
336
+
337
  ### 使用技巧
338
 
339
  **技巧 1:先宽后窄**
 
387
  ```
388
 
389
  搜索接口保留底层可调参数,适合需要精细控制召回数量、分层检索、分词策略、类别筛选和分组去重的工作流。
390
+ 响应包含 `tags_all`、`tags_sfw`、`keywords`、`results`;每条结果默认包含完整字段和 `wiki`。如果语义召回结果经官方 Tag Alias 规范化,还会包含原废弃标签 `alias_from`;`tag`、`tags_all` 和 `tags_sfw` 始终使用规范标签。
391
 
392
  **关联推荐接口**
393
  ```
 
507
  - `full_scene`:默认值。用户描述的是一张具体画面时使用,即使描述里有很多元素,也仍然属于完整画面。
508
  - `concept_explore`:用户想浏览某个概念下有哪些候选标签时使用,结果更多,token 消耗更高。
509
  - `subject_describe`:只描述一个单一视觉概念、角色或物品时使用;多元素画面不要用这个模式。
510
+ - `precise_lookup`:用于中文或英文的单一概念、拼写纠错接近精确的标签查询;仍使用语义搜索,并用官方 Tag Alias 规范化返回结果
511
 
512
+ 返回 JSON 包含:`prompt`(逗号分隔标签)、`keywords`(检索关键词)、`results`(标签结果)。每条结果包含 `tag`、`cn_name`;发生官方 Alias 纠正时包含 `alias_from`,当 `include_wiki=true` 时包含 `wiki`。
513
 
514
  **`get_related_tags`** — 共现关联推荐
515
 
 
616
 
617
  - 本项目中借鉴了[Good Anima](https://github.com/ShiroEirin/comfyui-good-anima)项目中关于Anima模型提示词书写的相关内容
618
  - [Linux Do社区](https://linux.do/)
619
+
620
+ ## 开源协议
621
+
622
+ 本项目采用 [GNU General Public License v3.0](./LICENSE) 开源协议。
api_fastapi.py CHANGED
@@ -92,6 +92,7 @@ class TagOut(BaseModel):
92
  layer: str
93
  wiki: str = ""
94
  artist_top_tags: list[str] = Field(default_factory=list)
 
95
 
96
 
97
  class RelatedIn(BaseModel):
 
92
  layer: str
93
  wiki: str = ""
94
  artist_top_tags: list[str] = Field(default_factory=list)
95
+ alias_from: str | None = Field(default=None, exclude_if=lambda value: value is None)
96
 
97
 
98
  class RelatedIn(BaseModel):
config/ui_text.yaml CHANGED
@@ -82,15 +82,27 @@ documentation:
82
  copyright_markdown: |-
83
  **接口使用与版权声明**
84
 
85
- 任何用户都可以免费将本服务的 API 或 MCP 接口接入自己的私有或公开服务
86
 
87
- 如果接入公服务务必在主页提供链接到服务主地址友情链接:
88
- ```
89
- URL: <https://huggingface.co/spaces/SAkizuki/DanbooruSearch>
 
 
 
 
 
 
 
 
90
  简介: 基于语义匹配的 Danbooru 标签搜索引擎,支持多维匹配与共现关联推荐。
91
- 作者:[凉宫秋月](https://github.com/SuzumiyaAkizuki)
 
92
  ```
93
 
 
 
 
94
  notice:
95
  title: 注意事项
96
  subtitle: 使用搜索结果和对外接入能力前,请留意以下说明。
 
82
  copyright_markdown: |-
83
  **接口使用与版权声明**
84
 
85
+ 可以免费将本服务提供的 API 或 MCP 接口接入个人、私有或公开服务。
86
 
87
+ 本项目的数据库、前端及后端代码均可用于二次发。项目采用 **GPL-3.0** 开源协议基于项目代码开发衍生作品也须遵守 GPL-3.0 协议。
88
+
89
+ 如果您的公开在线服务使用了以下任一资源,请在服务主页添加指向本项目主站的友情链接:
90
+
91
+ - [公开 Bucket](https://huggingface.co/buckets/SAkizuki/DanbooruSearchEmbedding) 中的任意数据库文件;
92
+ - 本项目提供的 API 或 MCP 接口。
93
+
94
+ 友情链接信息如下:
95
+
96
+ ```text
97
+ URL: https://huggingface.co/spaces/SAkizuki/DanbooruSearch
98
  简介: 基于语义匹配的 Danbooru 标签搜索引擎,支持多维匹配与共现关联推荐。
99
+ 作者: 凉宫秋月https://github.com/SuzumiyaAkizuki
100
+ LOGO: https://akizukipic.oss-cn-beijing.aliyuncs.com/img/file%20%281%29.svg
101
  ```
102
 
103
+ 作为回馈,我也可以在本项目主页展示您的公开服务。若有需要,请参照上述格式整理您服务的 **URL、简介、作者和 LOGO**,并发送至:`misakaacer@gmail.com`。
104
+
105
+
106
  notice:
107
  title: 注意事项
108
  subtitle: 使用搜索结果和对外接入能力前,请留意以下说明。
core/engine.py CHANGED
@@ -593,6 +593,10 @@ class DanbooruTagger:
593
  r = final[tag]
594
  r.final_score = round(r.final_score * (1.0 - alpha + alpha * float(max_co[i])), 4)
595
 
 
 
 
 
596
  # Group expand 处理(在 guaranteed_tags 之前,因为会改分数)
597
  if request.group_mode == "expand" and self._tag_to_groups:
598
  self._apply_group_expand(final)
@@ -646,6 +650,69 @@ class DanbooruTagger:
646
  cached_queries = [q for q, hit in zip(queries, hit_mask) if hit]
647
  return valid, keywords, extra_segments, cached_queries
648
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
649
  # ── 并发闸门(语义搜索与轻量推荐分通道)─────────────────────────────
650
 
651
  @classmethod
 
593
  r = final[tag]
594
  r.final_score = round(r.final_score * (1.0 - alpha + alpha * float(max_co[i])), 4)
595
 
596
+ # 官方 Tag Alias 只负责规范化语义召回结果,不改变查询文本或召回方式。
597
+ # 必须在最终截断前合并,避免废弃标签和规范标签重复占用结果名额。
598
+ final = self._canonicalize_search_aliases(final)
599
+
600
  # Group expand 处理(在 guaranteed_tags 之前,因为会改分数)
601
  if request.group_mode == "expand" and self._tag_to_groups:
602
  self._apply_group_expand(final)
 
650
  cached_queries = [q for q, hit in zip(queries, hit_mask) if hit]
651
  return valid, keywords, extra_segments, cached_queries
652
 
653
+ def _canonicalize_search_aliases(
654
+ self,
655
+ results: dict[str, TagResult],
656
+ ) -> dict[str, TagResult]:
657
+ """用官方 Alias 规范化语义搜索候选,并按规范标签去重。"""
658
+ if not results or not self._tag_aliases:
659
+ return results
660
+
661
+ canonical: dict[str, TagResult] = {}
662
+ for result in results.values():
663
+ alias = self._tag_aliases.get(result.tag)
664
+ if alias is None:
665
+ normalized = result
666
+ else:
667
+ original_tag = result.tag
668
+ target_tag = alias.consequent_name
669
+ target_idx = self._name_to_idx.get(target_tag)
670
+
671
+ if target_idx is None:
672
+ # Alias 目标尚未进入本地标签库时,仍输出官方规范名;分数和
673
+ # 安全/分类信息沿用已被模型实际召回的原标签,其余元数据
674
+ # 使用明确的降级值,避免把废弃标签资料冒充为规范标签资料。
675
+ normalized = TagResult(
676
+ tag=target_tag,
677
+ cn_name="",
678
+ category=result.category,
679
+ nsfw=result.nsfw,
680
+ final_score=result.final_score,
681
+ semantic_score=result.semantic_score,
682
+ count=0,
683
+ source=result.source,
684
+ layer=result.layer,
685
+ wiki="",
686
+ alias_from=original_tag,
687
+ )
688
+ else:
689
+ normalized = TagResult(
690
+ tag=target_tag,
691
+ cn_name=str(self._arr_cn_name[target_idx]),
692
+ category=CAT_MAP.get(self._arr_category[target_idx], 'Other'),
693
+ nsfw=str(self._arr_nsfw[target_idx]),
694
+ final_score=result.final_score,
695
+ semantic_score=result.semantic_score,
696
+ count=int(self._arr_post_count[target_idx]),
697
+ source=result.source,
698
+ layer=result.layer,
699
+ wiki=str(self._arr_wiki[target_idx]),
700
+ artist_top_tags=list(result.artist_top_tags),
701
+ alias_from=original_tag,
702
+ )
703
+
704
+ existing = canonical.get(normalized.tag)
705
+ if existing is None or (
706
+ normalized.final_score,
707
+ normalized.semantic_score,
708
+ ) > (
709
+ existing.final_score,
710
+ existing.semantic_score,
711
+ ):
712
+ canonical[normalized.tag] = normalized
713
+
714
+ return canonical
715
+
716
  # ── 并发闸门(语义搜索与轻量推荐分通道)─────────────────────────────
717
 
718
  @classmethod
core/models.py CHANGED
@@ -19,6 +19,7 @@ class TagResult:
19
  layer: str
20
  wiki: str = ""
21
  artist_top_tags: list[str] = field(default_factory=list)
 
22
 
23
 
24
  @dataclass
 
19
  layer: str
20
  wiki: str = ""
21
  artist_top_tags: list[str] = field(default_factory=list)
22
+ alias_from: str | None = None # 搜索结果由官方 Tag Alias 规范化时记录原标签
23
 
24
 
25
  @dataclass
core/workspace_insights.py CHANGED
@@ -33,11 +33,19 @@ def _clean_sources(sources: Iterable[Any], *, limit: int = 3) -> list[str]:
33
  return result
34
 
35
 
36
- def semantic_candidate_reason(source: Any, layer: Any = "") -> str:
 
 
 
 
37
  source_text = str(source or "").strip()
 
38
  if str(layer or "") == "artist":
39
  return f"匹配画师名称:{source_text}" if source_text else "来自画师名称匹配"
40
- return f"匹配输入:{source_text}" if source_text else "来自语义搜索"
 
 
 
41
 
42
 
43
  def related_candidate_reason(sources: Iterable[Any]) -> str:
 
33
  return result
34
 
35
 
36
+ def semantic_candidate_reason(
37
+ source: Any,
38
+ layer: Any = "",
39
+ alias_from: Any = "",
40
+ ) -> str:
41
  source_text = str(source or "").strip()
42
+ alias_text = str(alias_from or "").strip()
43
  if str(layer or "") == "artist":
44
  return f"匹配画师名称:{source_text}" if source_text else "来自画师名称匹配"
45
+ reason = f"匹配输入:{source_text}" if source_text else "来自语义搜索"
46
+ if alias_text:
47
+ return f"{reason};已由 {alias_text} 规范化"
48
+ return reason
49
 
50
 
51
  def related_candidate_reason(sources: Iterable[Any]) -> str:
mcp_server.py CHANGED
@@ -147,7 +147,9 @@ search_tags,不要把画师名放进 query。
147
  "full_scene"。
148
  适合:"EVA中蓝发的驾驶员"(单一角色概念)、"灯笼裤"(单一物件)、
149
  "两侧有开口,前方有拉绳的运动短裤"(带细节的单一物件)。
150
- "precise_lookup" — 精确查词 / 拼写纠错,例如 "selafuku""thighhigh"。
 
 
151
  - 判断规则:用户是想得到一张具体图的提示词(→ full_scene),还是想浏览某个概念的多种候选
152
  (→ concept_explore)?元素数量不是判断依据,探索意图才是。
153
  - 重要:只要 query 是具体场景、多元素组合、角色 + 属性,就用 "full_scene"。拿不准时也用
@@ -181,7 +183,8 @@ search_tags,不要把画师名放进 query。
181
  ## 返回
182
 
183
  JSON 对象,包含 prompt(逗号分隔 tag)、keywords、results。
184
- 每个 result 包含 tag、cn_name; include_wiki=True额外包含 wiki。
 
185
  """
186
  await telemetry.increment("mcp_search_tags")
187
  _SEARCH_MODE_PRESETS: dict[str, dict] = {
@@ -234,6 +237,8 @@ JSON 对象,包含 prompt(逗号分隔 tag)、keywords、results。
234
  "tag": r.tag,
235
  "cn_name": r.cn_name,
236
  }
 
 
237
  if include_wiki:
238
  item["wiki"] = r.wiki
239
  results.append(item)
@@ -528,7 +533,7 @@ Anima 同时理解 Danbooru 标签和自然语言:
528
 
529
  ### 第一层:Hard Tags
530
 
531
- 使用经检索确认的逗号分隔标签。
532
 
533
  **包含:**
534
 
@@ -555,11 +560,11 @@ Anima 同时理解 Danbooru 标签和自然语言:
555
  推荐职责:
556
 
557
  1. 第一句:景别、主体占比、整体布局、背景层级、前景限制。
558
- 2. 单人物时,第二句说明动作、视线、道具接触和空间关系;多人物时,先按角色分别说明画面位置、身份和少量关键特征。
559
  3. 多人物的角色锚定完成后,再用独立句子说明互动,明确写出动作发起者、承受者、接触对象和视线方向。
560
  4. 最后一句:主光源、主体曝光、色彩主次和景深。
561
 
562
- 不要机械复述 Hard Tags 中已经明确的外观和服装。只有在说空间、动作归属或光照对象时才简短引用人物、道具或场景
563
  背景描述不要只写 `behind the character`、`recedes into the background` 或整体 `softly blurred`;应说明主要环境位于画面的左侧、中央、人物后方或地面,并仅让最远处细节轻微虚化。
564
  多人物场景必须使用“画面方位 + 角色名/身份 + 关键辨识特征”的独立短句建立角色锚点;详细规则见“多人物特征分离规则”。
565
 
@@ -573,7 +578,11 @@ Anima 同时理解 Danbooru 标签和自然语言:
573
 
574
  - `close-up`、`upper body`、`cowboy_shot`、`full body` 等决定可见范围。
575
  - `dominates the frame`、`occupies most of the frame` 等描述决定人物大小。
576
- - 如果用户要求的不是全你可以在提示词中删除不出现在画面中的标签。比如用户要求upper body,你可以删除鞋袜的标签。
 
 
 
 
577
 
578
  参考范围:
579
 
@@ -667,12 +676,25 @@ Anima 同时理解 Danbooru 标签和自然语言:
667
 
668
  ### 标签数量
669
 
 
 
670
  | 场景复杂度 | 总标签数 |
671
  |---|---:|
672
  | 简单 | 16~30 |
673
  | 标准 | 22~38 |
674
  | 复杂多人或剧情主视觉 | 30~48 |
675
 
 
 
 
 
 
 
 
 
 
 
 
676
  只保留关键且有区分度的标签:
677
 
678
  - 宽泛标签与更准确的具体标签重复时,保留更准确的一项。
@@ -761,21 +783,21 @@ Anima 的多人生成可以通过清晰的角色边界减少特征混淆,但
761
  1. **先声明准确人数与性别构成**:使用 `2girls`、`1girl, 1boy`、`3girls` 等与画面一致的标签;不得同时保留 `solo`,也不得用 `multiple girls` 代替已知的精确人数。
762
  2. **先建立角色身份,再描述互动**:人数之后先写角色名及各自作品名。不要把互动标签插在角色身份之间,也不要只列角色名后立刻进入复杂动作。
763
  3. **Hard Tags 按角色分组**:同一角色的专属发型、瞳色、服装、体型和道具连续出现后再切换到下一角色。严禁把不同角色的同类属性交叉排列,例如 `blue hair, red hair, short hair, long hair`。
764
- 4. **每个角色使用独立的 Natural Language 锚定句**:推荐结构为 `On the left side of the image is Character A from Series A, with [4~6个关键辨识特征].`;下一角色另起一句。不得把多名角色的外观塞进同一个嵌套长句。
765
- 5. **关键辨识特征规则**: 规则4提到关键辨识特征,必须包含发型、上衣、下装、鞋袜、道具、姿势各至少一个(没有则写)
766
  6. **空间位置以画面/观众视角为基准并保持稳定**:使用 `on the left side of the image`、`on the right side of the image`、`in the center`、`in the foreground`、`in the background`。不要混用画面左右与角色自身左右;后文不得交换已分配的位置。
767
  7. **互动句必须明确主语和宾语**:完成所有角色锚定后,再写 `Character A holds Character B's right hand`、`Character B looks at Character A` 等。避免连续使用含义不明的 `she`、`he`、`they`,避免笼统的 `interacting`、`together` 代替可见动作。
768
  8. **区分专属属性与共享属性**:专属外观、服装、表情和道具必须放进对应角色的分组或锚定句;两人共有的服装、姿势或环境状态使用 `Both characters...` 单独说明,不得复制成含混的全局属性。
769
- 9. **只保留少量高区分度特征**:每个角色优先选择2~4个最能区分彼此外观或服装锚点。角色相似、人数达到3人以上或互动复杂时,应减少次要装饰、道具和同时发生的动作,优先保证身份、位置和主要互动
770
  10. **权重不能代替属性归属**:只有在角色分组和自然语言锚定已经清楚时,才可谨慎强化关键特征。不得仅靠 `(blue hair:2)`、`(red hair:2)` 分离角色,也不得同时堆叠大量高权重属性。
771
- 11. **Natural Language 负责明确归属而非机械复读**:复述用于身份区分关键特征,并补充空间位置、互动动作、��影对象和构图取景;把全部 Hard Tags 再抄一遍
772
 
773
  推荐示意:
774
 
775
  ```text
776
- 2girls, character a, series a, black hair, blue eyes, white jacket, character b, series b, blonde hair, red eyes, black dress, railway station
777
 
778
- The image is divided into a left side and a right side, with both characters shown at the same readable scale. On the left side of the image is Character A from Series A, with short black hair, blue eyes, and a white jacket. On the right side of the image is Character B from Series B, with long blonde hair, red eyes, and a black dress. Character A holds Character B's right hand while Character B looks at Character A. A soft side light keeps both faces clearly visible while the railway platform remains secondary in the background.
779
  ```
780
 
781
  ---
@@ -809,10 +831,10 @@ The image is divided into a left side and a right side, with both characters sho
809
  4. 是否避免无意义前景、大片暗部和隧道式构图?
810
  5. 是否明确主光源照亮人物的哪些部位?
811
  6. 夜景或背光场景是否避免意外剪影?
812
- 7. 标签是否去重并符合数上限
813
  8. 动作、视线、姿势和道具关系是否一致?
814
  9. Natural Language 是否保持职责清晰:单人物2~3句,多人物按角色拆句且没有嵌套长句?
815
- 10. 多人物时,人数是否准确,每个角色是否都有稳定的画面位置、独立锚定明确的动作归属?
816
  11. 是否只加入用户要求或画面逻辑真正需要的内容?
817
 
818
  ## 中文解释规则
 
147
  "full_scene"。
148
  适合:"EVA中蓝发的驾驶员"(单一角色概念)、"灯笼裤"(单一物件)、
149
  "两侧有开口,前方有拉绳的运动短裤"(带细节的单一物件)。
150
+ "precise_lookup" — 中文或英文的单一概念精确查词 / 拼写纠错,例如“水手服”
151
+ "selafuku"、"thighhigh"。仍使用语义搜索;官方 Tag Alias 仅用于
152
+ 将召回的废弃标签规范化为当前标签。
153
  - 判断规则:用户是想得到一张具体图的提示词(→ full_scene),还是想浏览某个概念的多种候选
154
  (→ concept_explore)?元素数量不是判断依据,探索意图才是。
155
  - 重要:只要 query 是具体场景、多元素组合、角色 + 属性,就用 "full_scene"。拿不准时也用
 
183
  ## 返回
184
 
185
  JSON 对象,包含 prompt(逗号分隔 tag)、keywords、results。
186
+ 每个 result 包含 tag、cn_name;搜索结果经官方 Alias 规范化时包含 alias_from;
187
+ 当 include_wiki=True 时额外包含 wiki。
188
  """
189
  await telemetry.increment("mcp_search_tags")
190
  _SEARCH_MODE_PRESETS: dict[str, dict] = {
 
237
  "tag": r.tag,
238
  "cn_name": r.cn_name,
239
  }
240
+ if r.alias_from:
241
+ item["alias_from"] = r.alias_from
242
  if include_wiki:
243
  item["wiki"] = r.wiki
244
  results.append(item)
 
533
 
534
  ### 第一层:Hard Tags
535
 
536
+ 使用用户已提供或经检索确认的逗号分隔标签。
537
 
538
  **包含:**
539
 
 
560
  推荐职责:
561
 
562
  1. 第一句:景别、主体占比、整体布局、背景层级、前景限制。
563
+ 2. 单人物时,第二句说明动作、视线、道具接触和空间关系;多人物时,先按角色分别说明画面位置、身份,以及按可见类别选出的代表性特征。
564
  3. 多人物的角色锚定完成后,再用独立句子说明互动,明确写出动作发起者、承受者、接触对象和视线方向。
565
  4. 最后一句:主光源、主体曝光、色彩主次和景深。
566
 
567
+ 单人物不要机械复述 Hard Tags 中已经明确的外观和服装。多人物为了确属性归属,每个角色的锚定句必须复述“多人物特征分离规则”要求的各类代表性特征,但不要继续抄写同类别的其余次要 Hard Tags
568
  背景描述不要只写 `behind the character`、`recedes into the background` 或整体 `softly blurred`;应说明主要环境位于画面的左侧、中央、人物后方或地面,并仅让最远处细节轻微虚化。
569
  多人物场景必须使用“画面方位 + 角色名/身份 + 关键辨识特征”的独立短句建立角色锚点;详细规则见“多人物特征分离规则”。
570
 
 
578
 
579
  - `close-up`、`upper body`、`cowboy_shot`、`full body` 等决定可见范围。
580
  - `dominates the frame`、`occupies most of the frame` 等描述决定人物大小。
581
+ - Hard Tags 必须与最终景别一致。用户已有标签即使本正确只要对内容不会出现在当前取景范围内也应删除,不能为了“保留已有标签”继续放入提示词
582
+ - `close-up`:优先保留脸部、发型、头部饰品和表情,删除画面外的身体、下装、腿部、鞋袜及全身姿势标签。
583
+ - `upper body`:保留上半身服装、手臂和画面内可见动作;删除裙子、裤子等下装细节,以及腿部姿势、袜子、长筒袜和鞋子标签。
584
+ - `cowboy shot` 或大腿以上取景:可以保留可见的下装,通常删除袜子、鞋子、脚部动作及依赖完整身体的姿势标签。
585
+ - `full body`:身体、下装、腿部和鞋袜均实际可见时,才保留对应标签。
586
 
587
  参考范围:
588
 
 
676
 
677
  ### 标签数量
678
 
679
+ 以下数量是推荐的**目标范围**,不是必须��足的硬性上下限:
680
+
681
  | 场景复杂度 | 总标签数 |
682
  |---|---:|
683
  | 简单 | 16~30 |
684
  | 标准 | 22~38 |
685
  | 复杂多人或剧情主视觉 | 30~48 |
686
 
687
+ 用户已有标签视为可信标签,通常不需要重新检索,但不代表必须全部保留。应尽量保留与最终画面一致、对人物识别或用户意图有帮助的标签;当已有标签过多时,可以按需删除一部分。
688
+
689
+ 裁剪已有标签时按以下顺序处理:
690
+
691
+ 1. 先删除与最终景别不一致、实际不会出现在画面中的标签。
692
+ 2. 再删除与人数、动作、视线、姿势、服装状态或安全分级冲突的标签。
693
+ 3. 再删除宽泛标签、近义重复标签,以及被更准确标签覆盖的标签。
694
+ 4. 最后删除对人物区分和主要画面贡献较低的次要装饰、身体细节或场景细节。
695
+
696
+ 应优先保留用户强调的内容、人物身份与作品、关键发型和瞳色、主要服装、核心道具、主要表情和动作。不要为了达到目标范围的下限而补充无关标签;如果完成必要裁剪后仍略高于目标范围,可以保留真正重要的标签,不要为了机械计数继续删除。
697
+
698
  只保留关键且有区分度的标签:
699
 
700
  - 宽泛标签与更准确的具体标签重复时,保留更准确的一项。
 
783
  1. **先声明准确人数与性别构成**:使用 `2girls`、`1girl, 1boy`、`3girls` 等与画面一致的标签;不得同时保留 `solo`,也不得用 `multiple girls` 代替已知的精确人数。
784
  2. **先建立角色身份,再描述互动**:人数之后先写角色名及各自作品名。不要把互动标签插在角色身份之间,也不要只列角色名后立刻进入复杂动作。
785
  3. **Hard Tags 按角色分组**:同一角色的专属发型、瞳色、服装、体型和道具连续出现后再切换到下一角色。严禁把不同角色的同类属性交叉排列,例如 `blue hair, red hair, short hair, long hair`。
786
+ 4. **每个角色使用独立的 Natural Language 锚定句**:推荐结构为 `On the left side of the image is Character A from Series A, with [按规则5覆盖各可见类别的代表性特征].`;下一角色另起一句。不得把多名角色的外观塞进同一个嵌套长句。
787
+ 5. **各类别至少一个代表性特征**:每个角色在设定存在且在当前景别中可见的发型、上衣、下装、鞋袜、道具、姿势类别,各至少一个代表性特征。某类别本来存在,或因 `close-up`、`upper body` 等景别不会出现在画面中时,不得为了凑齐类别而编造或保留画面外特征
788
  6. **空间位置以画面/观众视角为基准并保持稳定**:使用 `on the left side of the image`、`on the right side of the image`、`in the center`、`in the foreground`、`in the background`。不要混用画面左右与角色自身左右;后文不得交换已分配的位置。
789
  7. **互动句必须明确主语和宾语**:完成所有角色锚定后,再写 `Character A holds Character B's right hand`、`Character B looks at Character A` 等。避免连续使用含义不明的 `she`、`he`、`they`,避免笼统的 `interacting`、`together` 代替可见动作。
790
  8. **区分专属属性与共享属性**:专属外观、服装、表情和道具必须放进对应角色的分组或锚定句;两人共有的服装、姿势或环境状态使用 `Both characters...` 单独说明,不得复制成含混的全局属性。
791
+ 9. **每类只保留少量高区分度特征**:在满足规则5的前提下,每个可见类别通常只选择个最有辨识度代表性特征;只有人物识别确实需要时,同一类别才增加第二个。角色相似、人数达到3人以上或互动复杂时,应删除同类别的次要细节不必要的同时动作,但不得删掉某个实际存在且可见类别的唯一代表性特征
792
  10. **权重不能代替属性归属**:只有在角色分组和自然语言锚定已经清楚时,才可谨慎强化关键特征。不得仅靠 `(blue hair:2)`、`(red hair:2)` 分离角色,也不得同时堆叠大量高权重属性。
793
+ 11. **Natural Language 负责明确归属而非完整抄写**:角色锚定句必须复述规则5要求各类别代表性特征,并补充空间位置、互动动作、影对象和构图取景;同类别的其余次要 Hard Tags 重复
794
 
795
  推荐示意:
796
 
797
  ```text
798
+ 2girls, character a, series a, short black hair, blue eyes, white jacket, blue skirt, black boots, shoulder bag, standing, character b, series b, long blonde hair, red eyes, black blouse, red skirt, white boots, suitcase, standing, holding hands, railway station
799
 
800
+ The image is divided into a left side and a right side, with both characters shown at the same readable scale. On the left side of the image is Character A from Series A, standing with short black hair, a white jacket, a blue skirt, black boots, and a shoulder bag. On the right side of the image is Character B from Series B, standing with long blonde hair, a black blouse, a red skirt, white boots, and a suitcase held in her left hand. Character A holds Character B's right hand while Character B looks at Character A. A soft side light keeps both faces clearly visible while the railway platform remains secondary in the background.
801
  ```
802
 
803
  ---
 
831
  4. 是否避免无意义前景、大片暗部和隧道式构图?
832
  5. 是否明确主光源照亮人物的哪些部位?
833
  6. 夜景或背光场景是否避免意外剪影?
834
+ 7. 标签是否去重并落入目标范围;超出目标范围时,是否只保留了与取景、人物识别和用户重点真正相关的标签
835
  8. 动作、视线、姿势和道具关系是否一致?
836
  9. Natural Language 是否保持职责清晰:单人物2~3句,多人物按角色拆句且没有嵌套长句?
837
+ 10. 多人物时,人数是否准确,每个角色是否都有稳定的画面位置、独立锚定明确的动作归属,并为设定中存在且当前景别可见的发型、上衣、下装、鞋袜、道具、姿势类别各保留至少一个代表性特征
838
  11. 是否只加入用户要求或画面逻辑真正需要的内容?
839
 
840
  ## 中文解释规则
ui_nicegui.py CHANGED
The diff for this file is too large to render. See raw diff
 
webui/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """DanbooruSearch NiceGUI 前端的可复用模块。"""
webui/constants.py ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """NiceGUI 前端共享常量,避免入口文件承载配置定义。"""
2
+
3
+ import os
4
+
5
+ from core.ui_text import load_ui_text
6
+ from core.workspace import (
7
+ ARTIST_SELECTION_ORIGINS,
8
+ FAVORITES_STORAGE_KEY,
9
+ HISTORY_STORAGE_KEY,
10
+ LEGACY_STAGED_STORAGE_KEY,
11
+ WORKSPACE_STORAGE_KEY,
12
+ )
13
+
14
+
15
+ TABLE_COLUMNS = [
16
+ {'name': 'tag', 'label': '匹配标签', 'field': 'tag', 'align': 'left', 'sortable': True},
17
+ {'name': 'cn_name', 'label': '含义', 'field': 'cn_name', 'align': 'left'},
18
+ {'name': 'nsfw', 'label': '分级', 'field': 'nsfw', 'align': 'center', 'sortable': True},
19
+ {'name': 'final_score', 'label': '综合分', 'field': 'final_score', 'sortable': True},
20
+ {'name': 'count', 'label': '热度', 'field': 'count', 'sortable': True},
21
+ {'name': 'reason', 'label': '推荐原因', 'field': 'reason', 'align': 'left'},
22
+ ]
23
+
24
+ OPTIONAL_COLS = {
25
+ 'semantic': {'name': 'semantic_score', 'label': '语义分', 'field': 'semantic_score', 'sortable': True},
26
+ 'layer': {'name': 'layer', 'label': '匹配层', 'field': 'layer'},
27
+ 'source': {'name': 'source', 'label': '匹配来源', 'field': 'source'},
28
+ }
29
+
30
+ CONFIG_LS_KEY = 'danbooru_search_config'
31
+ CONFIG_VERSION = 7
32
+ ANNOUNCEMENT_VERSION = 'p0-workspace-2026-07'
33
+ LOCAL_STORAGE_READ_CHUNK_CHARS = 200_000
34
+ LOCAL_STORAGE_MAX_READ_CHARS = 4_000_000
35
+ HISTORY_PRE_COMPACTION_BACKUP_KEY = f'{HISTORY_STORAGE_KEY}_pre_compaction_backup'
36
+ LOCAL_STORAGE_RESTORE_CACHE = '__danbooruLocalStorageRestoreV2'
37
+ LOCAL_STORAGE_RESTORE_RETRY_DELAYS = (0.0, 1.0, 3.0)
38
+ LOCAL_STORAGE_NAMES = ('config', 'workspace', 'history', 'favorites', 'legacy')
39
+
40
+ SPONSOR_IMAGE_URL = 'https://akizukipic.oss-cn-beijing.aliyuncs.com/img/202501120027592.png'
41
+ SPONSOR_TOOLCHAIN_URL = 'http://intro.sakizuki.site/index.html'
42
+ SPONSOR_NOTICE_TEXT = '喜欢的话,可以请作者喝杯咖啡'
43
+ SPONSOR_TITLE = '谢谢你愿意支持'
44
+ UI_TEXT = load_ui_text()
45
+
46
+
47
+ def resolve_group_render_limit(default: int = 80) -> int:
48
+ raw = os.environ.get('DANBOORU_GROUP_RENDER_LIMIT')
49
+ if raw is None:
50
+ return default
51
+ try:
52
+ return int(raw)
53
+ except ValueError:
54
+ return default
55
+
56
+
57
+ GROUP_RENDER_TAG_LIMIT = resolve_group_render_limit()
58
+ ARTIST_REC_LIMIT = 64
59
+ ARTIST_REC_PAGE_SIZE = 8
60
+ RELATED_REC_PAGE_SIZE = 10
61
+ RECOMMENDATION_DEBOUNCE_SECONDS = 0.1
62
+ WORKSPACE_SAVE_DEBOUNCE_SECONDS = 0.3
63
+
64
+ SEARCH_MODE_PRESETS: dict[str, dict] = {
65
+ '精确查词': {'top_k': 20, 'limit': 10, 'popularity_weight': 0.15, 'use_segmentation': False, 'group_mode': 'off', 'max_per_group': 2},
66
+ '概念扩展': {'top_k': 80, 'limit': 80, 'popularity_weight': 0.15, 'use_segmentation': True, 'group_mode': 'expand', 'max_per_group': 2},
67
+ '描述查词': {'top_k': 20, 'limit': 20, 'popularity_weight': 0.15, 'use_segmentation': False, 'group_mode': 'off', 'max_per_group': 2},
68
+ '完整场景': {'top_k': 5, 'limit': 80, 'popularity_weight': 0.15, 'use_segmentation': True, 'group_mode': 'diverse', 'max_per_group': 2},
69
+ }
70
+ SEARCH_MODE_OPTIONS = ['自定义'] + list(SEARCH_MODE_PRESETS)
71
+ ARTIST_ORIGINS = set(ARTIST_SELECTION_ORIGINS)
webui/controller.py ADDED
@@ -0,0 +1,2319 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ webui/controller.py
3
+ ─────────────
4
+ NiceGUI 页面控制器。
5
+
6
+ ▸ 编排用户动作、检索调用、页面状态与视图刷新。
7
+ ▸ NiceGUI 控件创建集中在 webui.render。
8
+ ▸ 启动、路由和应用挂载集中在 ui_nicegui.py。
9
+ """
10
+ import asyncio
11
+ import logging
12
+ import re
13
+ import time
14
+ import json as _json
15
+ from datetime import timedelta, timezone
16
+
17
+ from nicegui import ui
18
+ from core import counter, telemetry
19
+ from core.engine import DanbooruTagger
20
+ from core.models import SearchRequest
21
+ from core.prompt_import import (
22
+ PromptImportResult,
23
+ WorkspaceCanonicalizationResult,
24
+ canonicalize_workspace_tags,
25
+ classify_workspace_tag,
26
+ pending_to_workspace_entry,
27
+ resolve_prompt_text,
28
+ )
29
+ from core.workspace import (
30
+ FAVORITES_STORAGE_KEY,
31
+ HISTORY_STORAGE_KEY,
32
+ LEGACY_STAGED_STORAGE_KEY,
33
+ WORKSPACE_STORAGE_KEY,
34
+ WorkspaceDataError,
35
+ build_backup,
36
+ dump_collection,
37
+ empty_favorites,
38
+ empty_history,
39
+ favorite_from_workspace,
40
+ merge_favorite_into_workspace,
41
+ new_workspace,
42
+ normalize_backup,
43
+ normalize_favorites,
44
+ replace_with_favorite,
45
+ utc_now_iso,
46
+ )
47
+ from platform_utils import nsfw_allowed
48
+ from webui.constants import (
49
+ ANNOUNCEMENT_VERSION as _ANNOUNCEMENT_VERSION,
50
+ ARTIST_ORIGINS as _ARTIST_ORIGINS,
51
+ ARTIST_REC_LIMIT,
52
+ CONFIG_LS_KEY as _CONFIG_LS_KEY,
53
+ CONFIG_VERSION as _CONFIG_VERSION,
54
+ GROUP_RENDER_TAG_LIMIT,
55
+ HISTORY_PRE_COMPACTION_BACKUP_KEY as _HISTORY_PRE_COMPACTION_BACKUP_KEY,
56
+ LOCAL_STORAGE_NAMES as _LOCAL_STORAGE_NAMES,
57
+ LOCAL_STORAGE_RESTORE_RETRY_DELAYS as _LOCAL_STORAGE_RESTORE_RETRY_DELAYS,
58
+ RECOMMENDATION_DEBOUNCE_SECONDS,
59
+ SEARCH_MODE_OPTIONS as _SEARCH_MODE_OPTIONS,
60
+ SEARCH_MODE_PRESETS as _SEARCH_MODE_PRESETS,
61
+ SPONSOR_IMAGE_URL,
62
+ SPONSOR_NOTICE_TEXT,
63
+ SPONSOR_TITLE,
64
+ SPONSOR_TOOLCHAIN_URL,
65
+ UI_TEXT,
66
+ WORKSPACE_SAVE_DEBOUNCE_SECONDS,
67
+ )
68
+ from webui.helpers import (
69
+ apply_nsfw_filter as _apply_nsfw_filter,
70
+ format_history_settings,
71
+ format_history_time,
72
+ format_selected_tag_label,
73
+ format_tag_with_weight,
74
+ get_git_commit,
75
+ group_names_key,
76
+ group_scroll_dom_id,
77
+ limit_group_render_tags,
78
+ next_group_render_limit,
79
+ result_to_row as _result_to_row,
80
+ scroll_state_restore_script,
81
+ should_group_start_expanded,
82
+ )
83
+ from webui.styles import MOTION_STYLE
84
+ from webui.state import PageState, StateField
85
+ from webui.local_storage import backup_key, clear_restore_cache, finish_storage_restore_task, flush_storage_session_changes, pause_storage_restore, prepare_restore_snapshot, read_prepared_value, restore_staged_storage, restore_with_retries, schedule_workspace_persist, start_storage_restore_task, storage_keys, storage_listener_script, write_is_ready
86
+ from webui.workspace_state import apply_config_state, apply_search_settings, apply_workspace_state, build_backup_import_plan, clear_history, collect_config_state, current_search_settings, merge_imported_favorite, pop_redo_workspace, pop_undo_workspace, push_undo_snapshot, record_search_history_state, remove_history_entry, replace_favorites_safely, selected_tags, set_selection_meta, sync_workspace_selection
87
+ from webui.recommendations import clamp_page, consume_latest_recommendation_requests, merge_related_results, queue_latest_recommendation_request, recommendation_seed_tags, set_paginated_recommendation_page
88
+ from webui.render.results_panel import (
89
+ filter_by_source,
90
+ build_results_columns,
91
+ render_concept_coverage,
92
+ update_table_columns,
93
+ )
94
+ from webui.render.recommendations import (
95
+ render_artist_page,
96
+ render_artist_recommendations,
97
+ render_group_expansion,
98
+ render_related_list,
99
+ render_related_page,
100
+ )
101
+ from webui.render.dialogs import (
102
+ build_help_dialog,
103
+ build_sponsor_dialog,
104
+ confirm_clear_history,
105
+ confirm_delete_all_personal_data,
106
+ confirm_delete_favorite,
107
+ open_backup_dialog,
108
+ open_favorites_dialog,
109
+ open_history_dialog,
110
+ open_prompt_import_dialog,
111
+ open_rename_favorite_dialog,
112
+ open_save_favorite_dialog,
113
+ open_search_feedback_dialog,
114
+ open_translation_feedback_dialog,
115
+ )
116
+ from webui.render.workspace_panel import (
117
+ build_selection_bar,
118
+ build_workspace_toolbar,
119
+ render_prompt_pending,
120
+ render_selected_chips,
121
+ render_selected_tag_chip,
122
+ show_prompt_import_summary,
123
+ show_workspace_canonicalization,
124
+ )
125
+ from webui.render.search_panel import build_search_panel
126
+ from webui.render.page import (
127
+ build_page as render_page,
128
+ build_release_announcement,
129
+ render_service_status,
130
+ )
131
+
132
+
133
+ # 仅统计当前进程中已建立 Socket.IO 连接的 UI 页面,不等同于唯一用户数。
134
+ _ACTIVE_UI_CLIENT_IDS: set[str] = set()
135
+
136
+
137
+ def _mark_ui_session_active(client_id: str) -> None:
138
+ _ACTIVE_UI_CLIENT_IDS.add(client_id)
139
+
140
+
141
+ def _mark_ui_session_inactive(client_id: str) -> None:
142
+ _ACTIVE_UI_CLIENT_IDS.discard(client_id)
143
+
144
+
145
+ def _get_active_ui_session_count() -> int:
146
+ return len(_ACTIVE_UI_CLIENT_IDS)
147
+
148
+
149
+ logging.getLogger("httpx").setLevel(logging.WARNING)
150
+ logging.getLogger("huggingface_hub").setLevel(logging.WARNING)
151
+ logging.getLogger("mcp").setLevel(logging.WARNING)
152
+ logging.getLogger("mcp.server").setLevel(logging.WARNING)
153
+ logging.getLogger("fastmcp").setLevel(logging.WARNING)
154
+ # suppress MCP streamable-HTTP transport noise ("No response returned" from Starlette middleware)
155
+ class _SuppressMCPNoise(logging.Filter):
156
+ _MARKER = "No response returned"
157
+
158
+ def filter(self, record: logging.LogRecord) -> bool:
159
+ if self._MARKER in record.getMessage():
160
+ return False
161
+ if record.exc_info:
162
+ import traceback
163
+ tb_text = "".join(traceback.format_exception(*record.exc_info))
164
+ if self._MARKER in tb_text:
165
+ return False
166
+ return True
167
+
168
+ logging.getLogger("uvicorn.error").addFilter(_SuppressMCPNoise())
169
+
170
+ # suppress MCP OAuth discovery 404 noise (clients probing .well-known OAuth endpoints)
171
+ class _SuppressOAuthNoise(logging.Filter):
172
+ _MARKERS = (
173
+ ".well-known/oauth-authorization-server",
174
+ ".well-known/oauth-protected-resource",
175
+ )
176
+
177
+ def filter(self, record: logging.LogRecord) -> bool:
178
+ if any(marker in record.getMessage() for marker in self._MARKERS):
179
+ return False
180
+ return True
181
+
182
+ logging.getLogger("uvicorn.access").addFilter(_SuppressOAuthNoise())
183
+ logging.getLogger("nicegui").addFilter(_SuppressOAuthNoise())
184
+
185
+ # ── 辅助函数 ───────────────────────────────────────────────────────────────────
186
+
187
+ _HISTORY_DISPLAY_TIMEZONE = timezone(timedelta(hours=8))
188
+
189
+
190
+ def _format_history_time(value: object) -> str:
191
+ return format_history_time(value)
192
+
193
+
194
+ def _format_history_settings(settings: object) -> str:
195
+ return format_history_settings(settings)
196
+
197
+ def _next_group_render_limit(current: int, total: int, page_size: int) -> int:
198
+ return next_group_render_limit(current, total, page_size)
199
+
200
+
201
+ def _limit_group_render_tags(tags: list[dict], visible_limit: int | None = None) -> tuple[list[dict], int]:
202
+ return limit_group_render_tags(tags, visible_limit)
203
+
204
+
205
+ def _should_group_start_expanded(group_name: str, expanded_groups: set[str]) -> bool:
206
+ return should_group_start_expanded(group_name, expanded_groups)
207
+
208
+
209
+ def _group_names_key(group_data: list[dict]) -> tuple[str, ...]:
210
+ return group_names_key(group_data)
211
+
212
+
213
+ def _group_scroll_dom_id(group_name: str) -> str:
214
+ return group_scroll_dom_id(group_name)
215
+
216
+
217
+ def _scroll_state_restore_script(positions: dict[str, int]) -> str:
218
+ return scroll_state_restore_script(positions)
219
+
220
+
221
+ def _get_git_commit() -> str:
222
+ return get_git_commit()
223
+
224
+
225
+ def result_to_row(r, nsfw_visible: bool) -> dict:
226
+ return _result_to_row(r, nsfw_visible)
227
+
228
+
229
+ def apply_nsfw_filter(rows: list[dict], show_nsfw: bool) -> list[dict]:
230
+ return _apply_nsfw_filter(rows, show_nsfw)
231
+
232
+
233
+ def _format_tag_with_weight(tag: str, weight: float, fmt: str = 'sdxl') -> str:
234
+ return format_tag_with_weight(tag, weight, fmt)
235
+
236
+
237
+ def _format_selected_tag_label(tag: str, cn_name: str = '') -> str:
238
+ return format_selected_tag_label(tag, cn_name)
239
+
240
+
241
+ # ── UI 类 ─────────────────────────────────────────────────────────────────────
242
+
243
+ class DanbooruSearchUI:
244
+ full_table_data = StateField()
245
+ current_segments = StateField()
246
+ current_keywords = StateField()
247
+ current_cached_queries = StateField()
248
+ current_filter_keyword = StateField()
249
+ current_query_str = StateField()
250
+ full_tags_str = StateField()
251
+ full_tags_str_sfw = StateField()
252
+ current_related = StateField()
253
+ chip_extra_selected = StateField()
254
+ _selected_order = StateField('selected_order')
255
+ _rendered_selected_chip_tags = StateField('rendered_selected_chip_tags')
256
+ workspace_state = StateField('workspace')
257
+ search_history = StateField('history')
258
+ favorites = StateField()
259
+ _undo_stack = StateField('undo_stack')
260
+ _redo_stack = StateField('redo_stack')
261
+ _pending_selection_meta = StateField('pending_selection_meta')
262
+ _workspace_artist_tags = StateField('workspace_artist_tags')
263
+ tag_weights = StateField()
264
+ prompt_format = StateField()
265
+ selected_layers = StateField()
266
+ selected_cats = StateField('selected_categories')
267
+ _related_results = StateField('related_results')
268
+ _related_show_nsfw = StateField('related_show_nsfw')
269
+ _related_page = StateField('related_page')
270
+ _related_page_count = StateField('related_page_count')
271
+ _group_render_limits = StateField('group_render_limits')
272
+ _group_expanded_names = StateField('group_expanded_names')
273
+ _group_scroll_positions = StateField('group_scroll_positions')
274
+ _group_render_key = StateField('group_render_key')
275
+ _group_candidate_sources = StateField('group_candidate_sources')
276
+ _artist_rec_rows = StateField('artist_rec_rows')
277
+ _artist_rec_results = StateField('artist_rec_results')
278
+ _artist_rec_top_tags = StateField('artist_rec_top_tags')
279
+ _artist_rec_show_nsfw = StateField('artist_rec_show_nsfw')
280
+ _artist_rec_sources = StateField('artist_rec_sources')
281
+ _artist_rec_page = StateField('artist_rec_page')
282
+ _artist_rec_page_count = StateField('artist_rec_page_count')
283
+ _current_artist_rec_tags = StateField('current_artist_rec_tags')
284
+ _artist_result_tags = StateField('artist_result_tags')
285
+ _last_recommendation_seed_tags = StateField('last_recommendation_seed_tags')
286
+ _pending_recommendation_request = StateField('pending_recommendation_request')
287
+ _recommendation_generation = StateField('recommendation_generation')
288
+ _storage_states = StateField('storage_states')
289
+ _storage_session_dirty = StateField('storage_session_dirty')
290
+ _storage_applying = StateField('storage_applying')
291
+ _storage_raw_values = StateField('storage_raw_values')
292
+
293
+ def __init__(self):
294
+ self.state = PageState()
295
+ self.search_count_label = None
296
+ self.service_status_container = None
297
+ self._service_status_task = None
298
+ self._last_service_status_key = None
299
+ self.current_search_interacted = True
300
+ self._telemetry_search_started_at: float | None = None
301
+ self._telemetry_selection_recorded = False
302
+ self._telemetry_copy_timing_recorded = False
303
+ self._telemetry_last_query = ''
304
+ self._telemetry_last_search_at = 0.0
305
+
306
+ self.full_table_data: list[dict] = []
307
+ self.current_segments: list[str] = [] # 从句级原始片段,用于区分 chip 颜色
308
+ self.current_keywords: list[str] = []
309
+ self.current_cached_queries: set[str] = set()
310
+ self.current_filter_keyword: str = 'ALL' # 当前选中的分词筛选 keyword(NSFW 切换时复用)
311
+ self.current_query_str: str = ""
312
+ self.full_tags_str: str = ""
313
+ self.full_tags_str_sfw: str = ""
314
+
315
+ self.result_table = None # 左栏表格
316
+ self.related_list_container = None # 右栏关联推荐列表
317
+ self.group_expansion_container = None # 左栏 Group 同类扩展(表格下方)
318
+ self.artist_rec_pagination = None
319
+ self.related_pagination = None
320
+ self.client = None
321
+ self._group_render_limits: dict[str, int] = {}
322
+ self._group_expanded_names: set[str] = set()
323
+ self._group_scroll_positions: dict[str, int] = {}
324
+ self._group_render_key: tuple[str, ...] = ()
325
+ self.results_section = None # 整个结果区域(搜索前隐藏)
326
+ self.coverage_container = None
327
+ self.selection_count_label = None
328
+ self.selected_display = None # 已废弃 textarea,保留兼容
329
+ self.selected_chips_container = None # 已选标签 chip 容器
330
+ self.prompt_pending_container = None
331
+ self.current_related: list = []
332
+ self.chip_extra_selected: set = set()
333
+ self._selected_order: list[str] = []
334
+ self._rendered_selected_chip_tags: set[str] = set()
335
+ self.workspace_state: dict = new_workspace()
336
+ self.search_history: dict = empty_history()
337
+ self.favorites: dict = empty_favorites()
338
+ self._undo_stack: list[dict] = []
339
+ self._redo_stack: list[dict] = []
340
+ self._pending_selection_meta: dict[str, dict[str, str]] = {}
341
+ self._workspace_artist_tags: set[str] = set()
342
+ self.undo_btn = None
343
+ self.redo_btn = None
344
+ self.history_count_label = None
345
+ self.favorites_count_label = None
346
+ self._workspace_storage_listener_installed = False
347
+ # 选择刷新只保留最新快照;运行中的线程不取消,完成后丢弃过期结果。
348
+ self._recommendation_task = None # type: asyncio.Task | None
349
+ self._pending_recommendation_request = None
350
+ self._recommendation_generation = 0
351
+ self._workspace_save_task = None # type: asyncio.Task | None
352
+ self._coverage_render_task = None # type: asyncio.Task | None
353
+ self._storage_restore_task = None # type: asyncio.Task | None
354
+ self._storage_restore_started = False
355
+ self._storage_restoring = False
356
+ self._storage_failure_notified = False
357
+ self._storage_states = {name: 'pending' for name in _LOCAL_STORAGE_NAMES}
358
+ self._storage_session_dirty: set[str] = set()
359
+ self._storage_applying: set[str] = set()
360
+ self._storage_raw_values: dict[str, str | None] = {}
361
+
362
+ # tag -> prompt 权重,范围 [0.1, 1.9],默认 1.0
363
+ self.tag_weights: dict[str, float] = {}
364
+ # 复制格式:'sdxl'、'nai' 或 'anima'
365
+ self.prompt_format: str = 'sdxl'
366
+ self.format_toggle_btn = None
367
+
368
+ self.init_banner = None
369
+ self.input_top_k = None
370
+ self.input_limit = None
371
+ self.input_weight = None
372
+ self.input_nsfw = None
373
+ self.input_segment = None
374
+ self.input_search_mode = None
375
+ self.input_group_mode = None
376
+ self.input_max_per_group = None
377
+ self._applying_preset = False
378
+ self.search_input = None
379
+ self.keywords_container = None
380
+ self.spinner = None
381
+ self.search_btn = None
382
+
383
+ self.selected_layers = {'英文': True, '中文扩展词': True, '释义': True, '中文核心词': True, 'artist': True}
384
+ self.selected_cats = {'General': True, 'Copyright': True, 'Character': True}
385
+
386
+ self.bad_case_btn = None
387
+
388
+ self.announcement_banner = None
389
+ self.dismissed_announcement_version = ''
390
+ self.help_dialog = None
391
+ self.sponsor_dialog = None
392
+
393
+ # 表格显示选项开关
394
+ self.sw_semantic = None
395
+ self.sw_layer = None
396
+ self.sw_source = None
397
+
398
+ # 关联推荐的 checkbox 引用
399
+ self._related_checkboxes: dict[str, ui.checkbox] = {}
400
+ self._related_results: list = []
401
+ self._related_show_nsfw = True
402
+ self._related_page = 1
403
+ self._related_page_count = 0
404
+ self._related_page_label = None
405
+ self._related_prev_button = None
406
+ self._related_next_button = None
407
+ # 同类标签的 checkbox 引用
408
+ self._group_checkboxes: dict[str, ui.checkbox] = {}
409
+ # 推荐画师的 checkbox 引用
410
+ self._artist_rec_checkboxes: dict[str, ui.checkbox] = {}
411
+ self._artist_rec_rows: list = []
412
+ self._artist_rec_results: list = []
413
+ self._artist_rec_top_tags: dict[str, list[str]] = {}
414
+ self._artist_rec_show_nsfw = True
415
+ self._artist_rec_sources: dict[str, str] = {}
416
+ self._group_candidate_sources: dict[str, str] = {}
417
+ self._artist_rec_page = 1
418
+ self._artist_rec_page_count = 0
419
+ self._artist_rec_page_label = None
420
+ self._artist_rec_prev_button = None
421
+ self._artist_rec_next_button = None
422
+ # 当前推荐画师的标签名集合(用于 Anima 模式复制时加 @ 前缀)
423
+ self._current_artist_rec_tags: set[str] = set()
424
+ self._artist_result_tags: set[str] = set()
425
+ self._last_recommendation_seed_tags: list[str] = []
426
+
427
+ # 高级选项中各层/类型的 checkbox 引用,用于 restore 时同步控件状态
428
+ self._layer_checkboxes: dict[str, ui.checkbox] = {}
429
+ self._cat_checkboxes: dict[str, ui.checkbox] = {}
430
+
431
+ def _update_footer_text(self):
432
+ if self.search_count_label is not None and self._client_alive():
433
+ try:
434
+ total = counter.get()
435
+ visits = counter.get_visits()
436
+ commit = _get_git_commit()
437
+ self.search_count_label.content = (
438
+ f'累计搜索 {total:,} 次 | 累计访问 {visits:,} 次 | '
439
+ f'<span class="font-mono text-gray-300">版本号: {commit}</span>'
440
+ f'<br>'
441
+ f'<a href="/api/docs" '
442
+ f'target="_blank" rel="noopener noreferrer" '
443
+ f'class="text-blue-400 hover:text-blue-600 hover:underline">使用 API 服务</a>'
444
+ f' | <a href="https://github.com/SuzumiyaAkizuki/DanbooruSearchOnline#mcp-接口" '
445
+ f'target="_blank" rel="noopener noreferrer" '
446
+ f'class="text-blue-400 hover:text-blue-600 hover:underline">使用 MCP 服务</a>'
447
+ )
448
+ except AttributeError:
449
+ pass
450
+
451
+ def _update_service_status(self):
452
+ if self.service_status_container is None or not self._client_alive():
453
+ return
454
+
455
+ ready = DanbooruTagger.is_ready()
456
+ online_sessions = _get_active_ui_session_count()
457
+ load = DanbooruTagger.get_load_snapshot()
458
+ active = load['active']
459
+ waiting = load['waiting']
460
+ capacity = load['capacity']
461
+ busy = ready and (waiting > 0 or active >= capacity)
462
+ status_key = (ready, busy, online_sessions, active, waiting, capacity)
463
+ if status_key == self._last_service_status_key:
464
+ return
465
+ self._last_service_status_key = status_key
466
+
467
+ render_service_status(self, {
468
+ 'ready': ready,
469
+ 'busy': busy,
470
+ 'online_sessions': online_sessions,
471
+ 'active': active,
472
+ 'waiting': waiting,
473
+ })
474
+
475
+ def _client_connected(self) -> bool:
476
+ if not self._client_alive():
477
+ return False
478
+ client = self.client
479
+ connection_state = getattr(client, 'has_socket_connection', None)
480
+ if callable(connection_state):
481
+ connection_state = connection_state()
482
+ if connection_state is not None:
483
+ return bool(connection_state)
484
+ if hasattr(client, 'tab_id'):
485
+ return client.tab_id is not None
486
+ return True
487
+
488
+ async def _service_status_loop(self):
489
+ """Refresh service status without creating a NiceGUI timer element."""
490
+ try:
491
+ while self._client_alive():
492
+ await asyncio.sleep(5.0)
493
+ if not self._client_alive():
494
+ return
495
+ if not self._client_connected():
496
+ continue
497
+ try:
498
+ self._update_service_status()
499
+ except RuntimeError:
500
+ # The page may be deleted between the connection check and render.
501
+ return
502
+ except asyncio.CancelledError:
503
+ return
504
+ finally:
505
+ if self._service_status_task is asyncio.current_task():
506
+ self._service_status_task = None
507
+
508
+ def _start_service_status_task(self):
509
+ task = self._service_status_task
510
+ if task is not None and not task.done():
511
+ return
512
+ self._service_status_task = asyncio.create_task(self._service_status_loop())
513
+
514
+ def _build_sponsor_dialog(self):
515
+ build_sponsor_dialog(
516
+ self,
517
+ title=SPONSOR_TITLE,
518
+ image_url=SPONSOR_IMAGE_URL,
519
+ toolchain_url=SPONSOR_TOOLCHAIN_URL,
520
+ ui_text=UI_TEXT,
521
+ )
522
+
523
+ def _build_help_dialog(self):
524
+ build_help_dialog(
525
+ self,
526
+ ui_text=UI_TEXT,
527
+ sponsor_notice_text=SPONSOR_NOTICE_TEXT,
528
+ )
529
+
530
+ def _confirm_delete_all_personal_data(self):
531
+ storage_keys = [
532
+ _CONFIG_LS_KEY,
533
+ WORKSPACE_STORAGE_KEY,
534
+ HISTORY_STORAGE_KEY,
535
+ FAVORITES_STORAGE_KEY,
536
+ LEGACY_STAGED_STORAGE_KEY,
537
+ f'{WORKSPACE_STORAGE_KEY}_corrupt_backup',
538
+ f'{HISTORY_STORAGE_KEY}_corrupt_backup',
539
+ f'{FAVORITES_STORAGE_KEY}_corrupt_backup',
540
+ _HISTORY_PRE_COMPACTION_BACKUP_KEY,
541
+ ]
542
+ confirm_delete_all_personal_data(self, storage_keys)
543
+
544
+ def _build_release_announcement(self):
545
+ build_release_announcement(self)
546
+
547
+ def _dismiss_release_announcement(self):
548
+ self.dismissed_announcement_version = _ANNOUNCEMENT_VERSION
549
+ if self.announcement_banner:
550
+ self.announcement_banner.set_visibility(False)
551
+ self._save_config()
552
+
553
+ def _mark_interaction(self, e=None):
554
+ if not self.current_search_interacted:
555
+ self.current_search_interacted = True
556
+
557
+ async def silent_success_update():
558
+ try:
559
+ await counter.increment_success()
560
+ except Exception:
561
+ pass
562
+ asyncio.create_task(silent_success_update())
563
+
564
+ def _record_first_selection_if_needed(
565
+ self,
566
+ previous_tags: list[str],
567
+ current_tags: list[str],
568
+ ) -> None:
569
+ if self._telemetry_search_started_at is None or self._telemetry_selection_recorded:
570
+ return
571
+ if not (set(current_tags) - set(previous_tags)):
572
+ return
573
+ self._telemetry_selection_recorded = True
574
+ duration_ms = (time.perf_counter() - self._telemetry_search_started_at) * 1000
575
+
576
+ async def record_selection() -> None:
577
+ try:
578
+ await telemetry.increment("ui_search_with_selection_session")
579
+ await telemetry.record_timing("search_to_first_selection", duration_ms)
580
+ except Exception as exc:
581
+ print(f"[UI] 首次选择统计失败: {exc}", flush=True)
582
+
583
+ asyncio.create_task(record_selection())
584
+
585
+ def _record_ui_copy(self, event_name: str) -> None:
586
+ duration_ms: float | None = None
587
+ if (
588
+ self._telemetry_search_started_at is not None
589
+ and not self._telemetry_copy_timing_recorded
590
+ ):
591
+ self._telemetry_copy_timing_recorded = True
592
+ duration_ms = (time.perf_counter() - self._telemetry_search_started_at) * 1000
593
+
594
+ async def record_copy() -> None:
595
+ try:
596
+ await counter.increment_copy()
597
+ await telemetry.increment(event_name)
598
+ if duration_ms is not None:
599
+ await telemetry.record_timing("search_to_first_copy", duration_ms)
600
+ except Exception as exc:
601
+ print(f"[UI] 复制统计失败: {exc}", flush=True)
602
+
603
+ asyncio.create_task(record_copy())
604
+
605
+ # ── 分页辅助 ──────────────────────────────────────────────────────────
606
+
607
+ def _get_rows_per_page(self) -> int:
608
+ if self.result_table is None:
609
+ return 0
610
+ p = self.result_table.pagination
611
+ # pagination 可能是 int 或 dict
612
+ if isinstance(p, dict):
613
+ return int(p.get('rowsPerPage', 0))
614
+ return int(p) if p else 0
615
+
616
+ def _set_rows_per_page(self, value: int):
617
+ if self.result_table is None:
618
+ return
619
+ allowed = {5, 7, 10, 15, 20, 25, 50, 0} # 0 = All
620
+ value = value if value in allowed else 0
621
+ p = self.result_table.pagination
622
+ if isinstance(p, dict):
623
+ p['rowsPerPage'] = value
624
+ self.result_table.pagination = p
625
+ else:
626
+ self.result_table.pagination = value
627
+
628
+ # ── 配置持久化 ────────────────────────────────────────────────────────
629
+
630
+ def _apply_prompt_format(self, prompt_format: str):
631
+ """统一设置复制格式及按钮外观,不触发额外持久化。"""
632
+ if prompt_format not in ('sdxl', 'nai', 'anima'):
633
+ prompt_format = 'sdxl'
634
+ self.prompt_format = prompt_format
635
+ if not self.format_toggle_btn:
636
+ return
637
+ if prompt_format == 'nai':
638
+ self.format_toggle_btn.text = 'NAI'
639
+ self.format_toggle_btn.props('color=purple-7')
640
+ elif prompt_format == 'anima':
641
+ self.format_toggle_btn.text = 'Anima'
642
+ self.format_toggle_btn.props('color=teal-7')
643
+ else:
644
+ self.format_toggle_btn.text = 'SDXL'
645
+ self.format_toggle_btn.props('color=grey-7')
646
+
647
+ def _collect_config_state(self) -> dict:
648
+ return collect_config_state(self, _CONFIG_VERSION)
649
+
650
+ def _storage_write_allowed(self, name: str) -> bool:
651
+ """Block writes until that localStorage domain has been safely restored."""
652
+ if not write_is_ready(name, self._storage_states, self._storage_applying) or not self._client_connected():
653
+ if self._storage_restore_started:
654
+ self._storage_session_dirty.add(name)
655
+ return False
656
+ return True
657
+
658
+ def _save_config(self):
659
+ """将当前控件状态序列化并写入 localStorage。"""
660
+ if not self._storage_write_allowed('config'):
661
+ return False
662
+ cfg = self._collect_config_state()
663
+ js = _json.dumps(cfg, ensure_ascii=False)
664
+ try:
665
+ self.client.run_javascript(
666
+ f"localStorage.setItem('{_CONFIG_LS_KEY}', {_json.dumps(js)});"
667
+ )
668
+ except RuntimeError:
669
+ self._storage_session_dirty.add('config')
670
+ return False
671
+ self._storage_session_dirty.discard('config')
672
+ return True
673
+
674
+ def _apply_config_state(self, cfg: dict):
675
+ apply_config_state(self, cfg, _ANNOUNCEMENT_VERSION)
676
+
677
+ # ══════════════════════════════════════════════════════════════════════
678
+ # 页面构建
679
+ # ══════════════════════════════════════════════════════════════════════
680
+
681
+ def build_page(self):
682
+ engine_ready = DanbooruTagger.is_ready()
683
+ render_page(
684
+ self,
685
+ motion_style=MOTION_STYLE,
686
+ sponsor_notice_text=SPONSOR_NOTICE_TEXT,
687
+ )
688
+ if not engine_ready:
689
+ asyncio.ensure_future(self._hide_banner_when_ready())
690
+
691
+ def _replay_motion(self, element_id: str, motion_class: str) -> None:
692
+ """在客户端重放一次区域级动效;减少动态效果时保持静止。"""
693
+ if not self._client_alive():
694
+ return
695
+ try:
696
+ ui.run_javascript(f'''
697
+ const element = document.getElementById('{element_id}');
698
+ if (element && !window.matchMedia('(prefers-reduced-motion: reduce)').matches) {{
699
+ element.classList.remove(
700
+ 'motion-results-enter',
701
+ 'motion-secondary-enter',
702
+ 'motion-refresh-enter',
703
+ 'motion-recommendation-enter-right',
704
+ 'motion-recommendation-enter-left',
705
+ );
706
+ void element.offsetWidth;
707
+ element.classList.add('{motion_class}');
708
+ }}
709
+ ''')
710
+ except RuntimeError:
711
+ pass
712
+
713
+ # ── 搜索卡片 ─────────────────────────────────────────────────────────
714
+
715
+ def _build_search_card(self):
716
+ build_search_panel(self)
717
+
718
+
719
+ # ── 工作区工具 ────────────────────────────────────────────────────────
720
+
721
+ def _build_workspace_toolbar(self):
722
+ build_workspace_toolbar(self)
723
+
724
+ def _update_workspace_counts(self):
725
+ if self.history_count_label is not None:
726
+ self.history_count_label.text = str(len(self.search_history.get('items', [])))
727
+ if self.favorites_count_label is not None:
728
+ self.favorites_count_label.text = str(len(self.favorites.get('items', [])))
729
+
730
+ def _current_search_settings(self) -> dict:
731
+ return current_search_settings(self)
732
+
733
+ def _apply_search_settings(self, settings: dict):
734
+ apply_search_settings(self, settings, _SEARCH_MODE_OPTIONS)
735
+
736
+ def _record_search_history(self, query: str):
737
+ record_search_history_state(self, query)
738
+ self._save_history()
739
+ self._update_workspace_counts()
740
+
741
+ def _open_history_dialog(self):
742
+ open_history_dialog(self)
743
+
744
+ async def _history_research(self, item: dict, dialog):
745
+ dialog.close()
746
+ self._apply_search_settings(item.get('settings', {}))
747
+ self.search_input.set_value(item['query'])
748
+ await self.perform_search()
749
+
750
+ async def _history_restore(self, item: dict, dialog):
751
+ normalized = await self._canonicalize_workspace_for_load(
752
+ item['workspace'],
753
+ source='历史工作区恢复',
754
+ )
755
+ self._push_undo_snapshot()
756
+ self._apply_workspace_state(normalized.workspace)
757
+ self.search_input.set_value(item['query'])
758
+ dialog.close()
759
+ ui.notify('已恢复历史工作区;未重新发起搜索', type='positive')
760
+ self._show_workspace_canonicalization(normalized, '历史工作区')
761
+
762
+ async def _history_append(self, item: dict, dialog):
763
+ dialog.close()
764
+ self.search_input.set_value(item['query'])
765
+ await self.perform_search()
766
+
767
+ def _delete_history_entry(self, item: dict, dialog):
768
+ remove_history_entry(self, item.get('history_id'))
769
+ self._save_history()
770
+ self._update_workspace_counts()
771
+ dialog.close()
772
+ self._open_history_dialog()
773
+
774
+ def _confirm_clear_history(self, parent_dialog):
775
+ confirm_clear_history(self, parent_dialog)
776
+
777
+ def _clear_all_history(self) -> None:
778
+ clear_history(self)
779
+ self._save_history()
780
+ self._update_workspace_counts()
781
+
782
+ def _open_prompt_import_dialog(self):
783
+ open_prompt_import_dialog(
784
+ self,
785
+ description=UI_TEXT['dialogs']['prompt_import_description'],
786
+ )
787
+
788
+ async def _resolve_prompt_import_text(self, text: str) -> PromptImportResult:
789
+ tagger = await DanbooruTagger.get_instance()
790
+ allow_nsfw = bool(nsfw_allowed() and self.input_nsfw and self.input_nsfw.value)
791
+ return await asyncio.to_thread(
792
+ resolve_prompt_text,
793
+ text,
794
+ resolve_tag=tagger.resolve_tag_name,
795
+ resolve_artist=tagger.resolve_artist_name,
796
+ lookup_tag=tagger.get_tag_workspace_metadata,
797
+ allow_nsfw=allow_nsfw,
798
+ )
799
+
800
+ def _apply_prompt_import_result(self, result: PromptImportResult) -> tuple[int, int]:
801
+ current = self._get_selected_tags()
802
+ existing = set(current)
803
+ existing_pending = {
804
+ (
805
+ item.get('normalized'),
806
+ bool(item.get('is_artist')),
807
+ item.get('reason'),
808
+ item.get('alias_target'),
809
+ )
810
+ for item in self.workspace_state.get('dismissed', [])
811
+ if isinstance(item, dict) and item.get('kind') == 'prompt_import_pending'
812
+ }
813
+ pending_to_add = []
814
+ duplicate_count = result.duplicate_count
815
+
816
+ for pending in result.pending:
817
+ key = (
818
+ pending.normalized,
819
+ pending.is_artist,
820
+ pending.reason,
821
+ pending.alias_target,
822
+ )
823
+ if key in existing_pending:
824
+ duplicate_count += 1
825
+ continue
826
+ existing_pending.add(key)
827
+ pending_to_add.append(pending_to_workspace_entry(pending))
828
+
829
+ new_items = [item for item in result.items if item.tag not in existing]
830
+ duplicate_count += len(result.items) - len(new_items)
831
+ if new_items or pending_to_add:
832
+ self._push_undo_snapshot()
833
+
834
+ for item in new_items:
835
+ existing.add(item.tag)
836
+ current.append(item.tag)
837
+ self.tag_weights[item.tag] = item.weight
838
+ self._set_selection_meta(
839
+ item.tag,
840
+ 'prompt_import_artist' if item.is_artist else 'prompt_import',
841
+ item.original,
842
+ )
843
+
844
+ if pending_to_add:
845
+ self.workspace_state['dismissed'] = (
846
+ list(self.workspace_state.get('dismissed', [])) + pending_to_add
847
+ )[-2000:]
848
+
849
+ if new_items or pending_to_add:
850
+ self._set_selected_tags(current, record_undo=False)
851
+ self._render_prompt_pending()
852
+ return len(new_items), duplicate_count
853
+
854
+ @staticmethod
855
+ def _prompt_pending_reason(item: dict) -> str:
856
+ reason = item.get('reason')
857
+ if reason == 'alias_target_missing':
858
+ target = item.get('alias_target') or '未知目标'
859
+ return f'Alias 指向 {target},但目标不在当前标签库范围内'
860
+ if reason == 'nsfw_filtered':
861
+ return '当前 NSFW 设置不允许加入该标签'
862
+ if reason == 'ambiguous_compact':
863
+ return '存在多个可能的规范标签,请人工选择'
864
+ if reason == 'not_found':
865
+ return '当前标签库中无法唯一识别'
866
+ if item.get('is_artist'):
867
+ return '画师共现库中无法唯一识别'
868
+ return f'无法识别({reason or "unknown"})'
869
+ def _show_prompt_import_summary(self, result: PromptImportResult, added_count: int, duplicate_count: int):
870
+ show_prompt_import_summary(self, result, added_count, duplicate_count)
871
+ def _render_prompt_pending(self):
872
+ render_prompt_pending(self)
873
+
874
+ def _remove_prompt_pending(self, pending: dict):
875
+ pending_id = pending.get('pending_id')
876
+ self._push_undo_snapshot()
877
+ self.workspace_state['dismissed'] = [
878
+ item for item in self.workspace_state.get('dismissed', [])
879
+ if not (
880
+ isinstance(item, dict)
881
+ and item.get('kind') == 'prompt_import_pending'
882
+ and item.get('pending_id') == pending_id
883
+ )
884
+ ]
885
+ self._save_staged_tags()
886
+ self._render_prompt_pending()
887
+
888
+ def _accept_prompt_candidate(self, pending: dict, candidate: str):
889
+ tagger = DanbooruTagger._instance
890
+ is_artist = bool(pending.get('is_artist'))
891
+ if tagger is None:
892
+ ui.notify('标签引擎尚未就绪,请稍后再试', type='warning')
893
+ return
894
+ if is_artist:
895
+ resolved = tagger.resolve_artist_name(candidate)
896
+ canonical = resolved.get('artist')
897
+ else:
898
+ resolved = tagger.resolve_tag_name(candidate)
899
+ canonical = resolved.get('tag')
900
+ if not canonical:
901
+ ui.notify('该候选目前无法加入工作区', type='warning')
902
+ return
903
+ if not is_artist:
904
+ metadata = tagger.get_tag_workspace_metadata(canonical) or {}
905
+ nsfw_blocked = (
906
+ str(metadata.get('nsfw', '0')) == '1'
907
+ and not bool(nsfw_allowed() and self.input_nsfw and self.input_nsfw.value)
908
+ )
909
+ if nsfw_blocked:
910
+ ui.notify('当前 NSFW 设置不允许加入该候选', type='warning')
911
+ return
912
+
913
+ self._push_undo_snapshot()
914
+ self.workspace_state['dismissed'] = [
915
+ item for item in self.workspace_state.get('dismissed', [])
916
+ if not (
917
+ isinstance(item, dict)
918
+ and item.get('kind') == 'prompt_import_pending'
919
+ and item.get('pending_id') == pending.get('pending_id')
920
+ )
921
+ ]
922
+ current = self._get_selected_tags()
923
+ if canonical not in current:
924
+ current.append(canonical)
925
+ self.tag_weights[canonical] = float(pending.get('weight', 1.0))
926
+ self._set_selection_meta(
927
+ canonical,
928
+ 'prompt_import_artist' if is_artist else 'prompt_import',
929
+ str(pending.get('original') or ''),
930
+ )
931
+ self._set_selected_tags(current, record_undo=False)
932
+ self._render_prompt_pending()
933
+ ui.notify(
934
+ f"已确认:{pending.get('normalized') or pending.get('original')} → {canonical}",
935
+ type='positive',
936
+ )
937
+
938
+ async def _canonicalize_workspace_for_load(
939
+ self,
940
+ workspace: dict,
941
+ *,
942
+ source: str,
943
+ ) -> WorkspaceCanonicalizationResult:
944
+ tagger = await DanbooruTagger.get_instance()
945
+ return await asyncio.to_thread(
946
+ canonicalize_workspace_tags,
947
+ workspace,
948
+ resolve_tag=tagger.resolve_tag_name,
949
+ resolve_artist=tagger.resolve_artist_name,
950
+ lookup_tag=tagger.get_tag_workspace_metadata,
951
+ artist_origins=_ARTIST_ORIGINS,
952
+ source=source,
953
+ )
954
+ def _show_workspace_canonicalization(self, result: WorkspaceCanonicalizationResult, label: str):
955
+ show_workspace_canonicalization(self, result, label)
956
+
957
+ def _open_save_favorite_dialog(self):
958
+ open_save_favorite_dialog(self)
959
+
960
+ def _save_current_workspace_as_favorite(self, name: str, notes: str) -> bool:
961
+ favorite = favorite_from_workspace(self.workspace_state, name, notes=notes)
962
+ candidate = {
963
+ 'schema_version': 1,
964
+ 'items': [favorite] + self.favorites.get('items', [])[:199],
965
+ }
966
+ if self._replace_favorites_safely(candidate):
967
+ ui.notify(f'已保存收藏:{name}', type='positive')
968
+ return True
969
+ return False
970
+
971
+ def _open_favorites_dialog(self):
972
+ open_favorites_dialog(self)
973
+
974
+ async def _load_favorite(self, favorite: dict, merge: bool, dialog):
975
+ if merge:
976
+ workspace = merge_favorite_into_workspace(self.workspace_state, favorite)
977
+ message = f"已合并收藏:{favorite['name']}"
978
+ else:
979
+ workspace = replace_with_favorite(favorite)
980
+ message = f"已载入收藏:{favorite['name']}"
981
+ normalized = await self._canonicalize_workspace_for_load(
982
+ workspace,
983
+ source=f"收藏恢复:{favorite['name']}",
984
+ )
985
+ self._push_undo_snapshot()
986
+ self._apply_workspace_state(normalized.workspace)
987
+ dialog.close()
988
+ ui.notify(message, type='positive')
989
+ self._show_workspace_canonicalization(normalized, f"收藏“{favorite['name']}”")
990
+
991
+ def _copy_favorite(self, favorite: dict):
992
+ parts: list[str] = []
993
+ for item in favorite.get('selected', []):
994
+ tag = item['tag']
995
+ if favorite.get('prompt_format') == 'anima' and item.get('origin') in _ARTIST_ORIGINS:
996
+ tag = f'@{tag}'
997
+ parts.append(_format_tag_with_weight(
998
+ tag,
999
+ item.get('weight', 1.0),
1000
+ favorite.get('prompt_format', 'sdxl'),
1001
+ ))
1002
+ ui.clipboard.write(', '.join(parts))
1003
+ ui.notify(f"已复制收藏:{favorite['name']}", type='positive')
1004
+
1005
+ def _rename_favorite(self, favorite: dict, parent_dialog):
1006
+ open_rename_favorite_dialog(self, favorite, parent_dialog)
1007
+
1008
+ def _rename_favorite_to(self, favorite: dict, name: str) -> str:
1009
+ if not name:
1010
+ return '请输入名称'
1011
+ if any(
1012
+ item['favorite_id'] != favorite['favorite_id'] and item['name'] == name
1013
+ for item in self.favorites.get('items', [])
1014
+ ):
1015
+ return '已存在同名收藏'
1016
+ candidate = normalize_favorites(self.favorites)[0]
1017
+ for item in candidate['items']:
1018
+ if item['favorite_id'] == favorite['favorite_id']:
1019
+ item['name'] = name
1020
+ item['updated_at'] = utc_now_iso()
1021
+ break
1022
+ if not self._replace_favorites_safely(candidate):
1023
+ return '保存失败,请稍后重试'
1024
+ return ''
1025
+
1026
+ def _overwrite_favorite(self, favorite: dict, parent_dialog):
1027
+ if not self._get_selected_tags():
1028
+ ui.notify('当前工作区没有标签,不能覆盖收藏', type='warning')
1029
+ return
1030
+ replacement = favorite_from_workspace(
1031
+ self.workspace_state,
1032
+ favorite['name'],
1033
+ notes=favorite.get('notes', ''),
1034
+ favorite_id=favorite['favorite_id'],
1035
+ created_at=favorite.get('created_at'),
1036
+ )
1037
+ candidate = {
1038
+ 'schema_version': 1,
1039
+ 'items': [
1040
+ replacement if item['favorite_id'] == favorite['favorite_id'] else item
1041
+ for item in self.favorites.get('items', [])
1042
+ ],
1043
+ }
1044
+ if not self._replace_favorites_safely(candidate):
1045
+ return
1046
+ parent_dialog.close()
1047
+ ui.notify(f"已覆盖收藏:{favorite['name']}", type='positive')
1048
+
1049
+ def _export_favorite(self, favorite: dict):
1050
+ payload = {
1051
+ 'schema_version': 1,
1052
+ 'exported_at': favorite.get('updated_at', ''),
1053
+ 'favorite': favorite,
1054
+ }
1055
+ raw = dump_collection(payload, label='favorite export').encode('utf-8')
1056
+ safe_name = re.sub(r'[^0-9A-Za-z\u4e00-\u9fff_-]+', '_', favorite['name'])[:60]
1057
+ ui.download(raw, filename=f'danbooru_favorite_{safe_name or "export"}.json', media_type='application/json')
1058
+
1059
+ def _confirm_delete_favorite(self, favorite: dict, parent_dialog):
1060
+ confirm_delete_favorite(self, favorite, parent_dialog)
1061
+
1062
+ def _delete_favorite(self, favorite: dict) -> bool:
1063
+ candidate = {
1064
+ 'schema_version': 1,
1065
+ 'items': [
1066
+ item for item in self.favorites.get('items', [])
1067
+ if item['favorite_id'] != favorite['favorite_id']
1068
+ ],
1069
+ }
1070
+ return self._replace_favorites_safely(candidate)
1071
+
1072
+ def _open_backup_dialog(self):
1073
+ open_backup_dialog(
1074
+ self,
1075
+ description=UI_TEXT['dialogs']['backup_description'],
1076
+ )
1077
+
1078
+ def _export_backup(self):
1079
+ backup = build_backup(
1080
+ config=self._collect_config_state(),
1081
+ workspace=self.workspace_state,
1082
+ history=self.search_history,
1083
+ favorites=self.favorites,
1084
+ )
1085
+ raw = _json.dumps(backup, ensure_ascii=False, indent=2).encode('utf-8')
1086
+ filename = f"danbooru_workspace_backup_{utc_now_iso()[:10]}.json"
1087
+ ui.download(raw, filename=filename, media_type='application/json')
1088
+
1089
+ async def _import_backup_text(self, raw: str, mode: str):
1090
+ # 单个收藏导出文件也可直接从这里重新导入。
1091
+ try:
1092
+ parsed = _json.loads(raw)
1093
+ except _json.JSONDecodeError as exc:
1094
+ raise WorkspaceDataError('文件不是有效 JSON') from exc
1095
+ if isinstance(parsed, dict) and 'favorite' in parsed:
1096
+ candidate, warnings = merge_imported_favorite(
1097
+ self.favorites,
1098
+ parsed['favorite'],
1099
+ )
1100
+ if not self._replace_favorites_safely(candidate):
1101
+ return
1102
+ ui.notify('收藏已导入', type='positive')
1103
+ if warnings:
1104
+ print(f'[UI] 收藏导入提示: {warnings}', flush=True)
1105
+ return
1106
+
1107
+ backup, warnings = normalize_backup(parsed)
1108
+ workspace_normalization = None
1109
+ if mode != 'favorites_only':
1110
+ workspace_normalization = await self._canonicalize_workspace_for_load(
1111
+ backup['workspace'],
1112
+ source='JSON 备份导入',
1113
+ )
1114
+ backup['workspace'] = workspace_normalization.workspace
1115
+ plan = build_backup_import_plan(
1116
+ self.workspace_state,
1117
+ self.search_history,
1118
+ self.favorites,
1119
+ backup,
1120
+ mode,
1121
+ )
1122
+ if not self._replace_favorites_safely(plan.favorites):
1123
+ return
1124
+ if plan.workspace is not None:
1125
+ self._push_undo_snapshot()
1126
+ self.search_history = plan.history
1127
+ if plan.config is not None:
1128
+ self._apply_config_state(plan.config)
1129
+ self._apply_workspace_state(plan.workspace)
1130
+ self._save_history()
1131
+ message = plan.message
1132
+ self._update_workspace_counts()
1133
+ ui.notify(message, type='positive', timeout=4000)
1134
+ if workspace_normalization is not None:
1135
+ self._show_workspace_canonicalization(
1136
+ workspace_normalization,
1137
+ 'JSON 备份工作区',
1138
+ )
1139
+ if warnings:
1140
+ print(f'[UI] 备份导入提示: {sorted(set(warnings))}', flush=True)
1141
+
1142
+ # ── 已选标签栏 ────────────────────────────────────────────────────────
1143
+
1144
+ def _build_selection_bar(self):
1145
+ build_selection_bar(self)
1146
+ def _render_selected_chips(self):
1147
+ render_selected_chips(self)
1148
+
1149
+ def _workspace_group_for_tag(self, tag: str) -> str:
1150
+ is_artist = tag in self._workspace_artist_tags
1151
+ category = 'Artist' if is_artist else 'Other'
1152
+ groups: set[str] = set()
1153
+
1154
+ tagger = DanbooruTagger._instance
1155
+ if tagger is not None:
1156
+ metadata = tagger.get_tag_workspace_metadata(tag)
1157
+ if metadata:
1158
+ category = str(metadata.get('category') or category)
1159
+ groups = set(metadata.get('groups') or [])
1160
+ elif self.result_table is not None:
1161
+ for row in self.result_table.rows:
1162
+ if row.get('tag') == tag:
1163
+ category = str(row.get('category') or category)
1164
+ break
1165
+
1166
+ return classify_workspace_tag(
1167
+ category=category,
1168
+ tag_groups=groups,
1169
+ is_artist=is_artist,
1170
+ )
1171
+ def _render_selected_tag_chip(self, tag: str, step: float, *, animate: bool = False):
1172
+ render_selected_tag_chip(self, tag, step, animate=animate)
1173
+
1174
+ def _adjust_weight(self, tag: str, delta: float):
1175
+ """调整单个标签权重。Anima 模式范围 [0.5, 5.0],其他模式 [0.1, 1.9]。"""
1176
+ current = self.tag_weights.get(tag, 1.0)
1177
+ new_w = round(current + delta, 1)
1178
+ if self.prompt_format == 'anima':
1179
+ min_w, max_w = 0.5, 5.0
1180
+ else:
1181
+ min_w, max_w = 0.1, 1.9
1182
+ if new_w < min_w:
1183
+ ui.notify(f'权重范围为 {min_w} ~ {max_w},已到达最小值', type='warning', timeout=2000)
1184
+ return
1185
+ if new_w > max_w:
1186
+ ui.notify(f'权重范围为 {min_w} ~ {max_w},已到达最大值', type='warning', timeout=2000)
1187
+ return
1188
+ self._push_undo_snapshot()
1189
+ self.tag_weights[tag] = new_w
1190
+ self._save_staged_tags()
1191
+ self._render_selected_chips()
1192
+
1193
+ def _get_cn_name_for_tag(self, tag: str) -> str:
1194
+ """尽量从当前 UI 数据中取标签中文名,用于已选区展示。"""
1195
+ if self.result_table is not None:
1196
+ for row in self.result_table.rows:
1197
+ if row.get('tag') == tag:
1198
+ return str(row.get('cn_name') or '')
1199
+
1200
+ for item in self.current_related:
1201
+ if getattr(item, 'tag', None) == tag:
1202
+ return str(getattr(item, 'cn_name', '') or '')
1203
+
1204
+ for item in self.workspace_state.get('selected', []):
1205
+ if item.get('tag') == tag:
1206
+ return str(item.get('cn_name') or '')
1207
+
1208
+ try:
1209
+ tagger = DanbooruTagger._instance
1210
+ if tagger and tagger.df is not None and tag in tagger._name_to_idx:
1211
+ idx = tagger._name_to_idx[tag]
1212
+ return str(tagger.df.iloc[idx].get('cn_name', '') or '')
1213
+ except Exception:
1214
+ pass
1215
+ return ''
1216
+
1217
+ def _remove_selected_tag(self, tag: str):
1218
+ """从已选中移除标签(同步表格选中状态)。"""
1219
+ self._mark_interaction()
1220
+ current = self._get_selected_tags()
1221
+ if tag in current:
1222
+ current.remove(tag)
1223
+ self.tag_weights.pop(tag, None)
1224
+ self._set_selected_tags(current)
1225
+
1226
+ # ── 备选区持久化 ─────────────────────────────────���───────────────────
1227
+
1228
+ _STAGED_LS_KEY = LEGACY_STAGED_STORAGE_KEY
1229
+
1230
+ def _set_selection_meta(self, tag: str, origin: str, source: str = ''):
1231
+ set_selection_meta(self, tag, origin, source)
1232
+
1233
+ def _push_undo_snapshot(self):
1234
+ push_undo_snapshot(self)
1235
+
1236
+ def _update_undo_buttons(self):
1237
+ if self.undo_btn is not None:
1238
+ self.undo_btn.enable() if self._undo_stack else self.undo_btn.disable()
1239
+ if self.redo_btn is not None:
1240
+ self.redo_btn.enable() if self._redo_stack else self.redo_btn.disable()
1241
+
1242
+ def _apply_workspace_state(
1243
+ self,
1244
+ workspace: dict,
1245
+ *,
1246
+ persist: bool = True,
1247
+ refresh_recommendations: bool = True,
1248
+ ):
1249
+ apply_workspace_state(
1250
+ self,
1251
+ workspace,
1252
+ _ARTIST_ORIGINS,
1253
+ persist=persist,
1254
+ refresh_recommendations=refresh_recommendations,
1255
+ )
1256
+
1257
+ def _undo_workspace(self):
1258
+ target = pop_undo_workspace(self)
1259
+ if target is None:
1260
+ ui.notify('没有可撤销的操作', type='info', timeout=1500)
1261
+ return
1262
+ self._apply_workspace_state(target)
1263
+ self._update_undo_buttons()
1264
+ ui.notify('已撤销', type='positive', timeout=1500)
1265
+
1266
+ def _redo_workspace(self):
1267
+ target = pop_redo_workspace(self)
1268
+ if target is None:
1269
+ ui.notify('没有可恢复的操作', type='info', timeout=1500)
1270
+ return
1271
+ self._apply_workspace_state(target)
1272
+ self._update_undo_buttons()
1273
+ ui.notify('已恢复', type='positive', timeout=1500)
1274
+
1275
+ def _schedule_workspace_persist(self):
1276
+ schedule_workspace_persist(self, WORKSPACE_SAVE_DEBOUNCE_SECONDS)
1277
+
1278
+ def _save_staged_tags(self):
1279
+ """将实时选择同步到版本化 WorkspaceState,并去抖写入 localStorage。"""
1280
+ sync_workspace_selection(self, _ARTIST_ORIGINS)
1281
+ self._schedule_workspace_persist()
1282
+
1283
+ def _local_storage_keys(self) -> dict[str, str]:
1284
+ return storage_keys()
1285
+
1286
+ async def _prepare_local_storage_restore(self, names: list[str]) -> dict:
1287
+ """Snapshot requested keys in-browser and compact legacy history in memory."""
1288
+ keys = {name: self._local_storage_keys()[name] for name in names}
1289
+ return await prepare_restore_snapshot(self.client, self._client_connected, keys)
1290
+
1291
+ async def _read_local_storage_value(
1292
+ self,
1293
+ name: str,
1294
+ key: str,
1295
+ length,
1296
+ ) -> str | None:
1297
+ return await read_prepared_value(self.client, self._client_connected, name, key, length)
1298
+
1299
+ def _clear_local_storage_restore_cache(self):
1300
+ if not self._client_connected():
1301
+ return
1302
+ clear_restore_cache(self.client)
1303
+
1304
+ async def _backup_local_storage_key(self, source_key: str, backup_key: str) -> bool:
1305
+ """Copy an existing value inside the browser before replacing it."""
1306
+ if not self._client_connected():
1307
+ return False
1308
+ return await backup_key(self.client, source_key, backup_key)
1309
+
1310
+ async def _backup_history_before_compaction(self) -> bool:
1311
+ """Preserve the original history in-browser before replacing it with v2."""
1312
+ return await self._backup_local_storage_key(
1313
+ HISTORY_STORAGE_KEY,
1314
+ _HISTORY_PRE_COMPACTION_BACKUP_KEY,
1315
+ )
1316
+
1317
+ def _save_history(self):
1318
+ if not self._storage_write_allowed('history'):
1319
+ return False
1320
+ client = self.client
1321
+ while True:
1322
+ try:
1323
+ data = dump_collection(self.search_history, label='history')
1324
+ client.run_javascript(
1325
+ f"localStorage.setItem('{HISTORY_STORAGE_KEY}', {_json.dumps(data)});"
1326
+ )
1327
+ self._storage_session_dirty.discard('history')
1328
+ return True
1329
+ except WorkspaceDataError as exc:
1330
+ if not self.search_history.get('items'):
1331
+ print(f'[UI] 搜索历史保存失败: {exc}', flush=True)
1332
+ return False
1333
+ self.search_history['items'].pop()
1334
+ print('[UI] 搜索历史超过本地大小限制,已移除最旧记录。', flush=True)
1335
+ except RuntimeError as exc:
1336
+ print(f'[UI] 搜索历史保存失败: {exc}', flush=True)
1337
+ self._storage_session_dirty.add('history')
1338
+ return False
1339
+
1340
+ def _save_favorites(self):
1341
+ if not self._storage_write_allowed('favorites'):
1342
+ return False
1343
+ client = self.client
1344
+ try:
1345
+ data = dump_collection(self.favorites, label='favorites')
1346
+ client.run_javascript(
1347
+ f"localStorage.setItem('{FAVORITES_STORAGE_KEY}', {_json.dumps(data)});"
1348
+ )
1349
+ self._storage_session_dirty.discard('favorites')
1350
+ return True
1351
+ except (WorkspaceDataError, RuntimeError) as exc:
1352
+ print(f'[UI] 收藏保存失败: {exc}', flush=True)
1353
+ self._storage_session_dirty.add('favorites')
1354
+ return False
1355
+
1356
+ def _replace_favorites_safely(self, favorites: dict) -> bool:
1357
+ if replace_favorites_safely(self, favorites):
1358
+ return True
1359
+ ui.notify('收藏数据过大或无法写入,操作已取消', type='negative')
1360
+ return False
1361
+
1362
+ async def _restore_staged_tags(self) -> tuple[dict[str, str], set[str], list[str]]:
1363
+ """Run one restore attempt and return failures, required writes and warnings."""
1364
+ return await restore_staged_storage(
1365
+ self,
1366
+ _LOCAL_STORAGE_NAMES,
1367
+ _CONFIG_VERSION,
1368
+ )
1369
+
1370
+ async def _restore_local_storage_with_retries(self):
1371
+ self._storage_restore_started = True
1372
+ was_cancelled = False
1373
+ try:
1374
+ result = await restore_with_retries(
1375
+ self,
1376
+ _LOCAL_STORAGE_RESTORE_RETRY_DELAYS,
1377
+ )
1378
+ if result.client_stopped:
1379
+ return
1380
+ if result.completed:
1381
+ flush_storage_session_changes(self)
1382
+ self._install_workspace_storage_listener()
1383
+ self._storage_failure_notified = False
1384
+ if result.warnings:
1385
+ print(
1386
+ f'[UI] 本地数据恢复提示: {sorted(set(result.warnings))}',
1387
+ flush=True,
1388
+ )
1389
+ return
1390
+ unresolved = sorted(
1391
+ name for name in _LOCAL_STORAGE_NAMES
1392
+ if self._storage_states.get(name) != 'ready'
1393
+ )
1394
+ if unresolved:
1395
+ client_id = str(getattr(self.client, 'id', 'unknown'))[:8]
1396
+ detail = '; '.join(
1397
+ f'{name}={message}' for name, message in sorted(result.failures.items())
1398
+ )
1399
+ print(
1400
+ f'[UI] localStorage 恢复未完成 (client={client_id}, '
1401
+ f'keys={unresolved}): {detail};未覆盖这些浏览器数据。',
1402
+ flush=True,
1403
+ )
1404
+ if self._client_connected() and not self._storage_failure_notified:
1405
+ self._storage_failure_notified = True
1406
+ try:
1407
+ with self.client:
1408
+ ui.notify(
1409
+ '本地工作区数据暂未恢复,已停止覆盖旧数据;连接恢复后会自动重试。',
1410
+ type='warning',
1411
+ timeout=5000,
1412
+ )
1413
+ except RuntimeError:
1414
+ # The client may disconnect after the connection check.
1415
+ pass
1416
+ except asyncio.CancelledError:
1417
+ was_cancelled = True
1418
+ finally:
1419
+ if finish_storage_restore_task(
1420
+ self,
1421
+ asyncio.current_task(),
1422
+ was_cancelled,
1423
+ ):
1424
+ self._start_storage_restore_task()
1425
+
1426
+ def _start_storage_restore_task(self):
1427
+ return start_storage_restore_task(
1428
+ self,
1429
+ self._restore_local_storage_with_retries,
1430
+ )
1431
+
1432
+ def _pause_storage_restore(self):
1433
+ pause_storage_restore(self)
1434
+
1435
+ def _install_workspace_storage_listener(self):
1436
+ """其他标签页修改工作区时提示刷新,避免静默覆盖。"""
1437
+ if self._workspace_storage_listener_installed:
1438
+ return
1439
+ self._workspace_storage_listener_installed = True
1440
+ try:
1441
+ ui.run_javascript(storage_listener_script())
1442
+ except RuntimeError:
1443
+ pass
1444
+
1445
+ def _clear_all_staged(self):
1446
+ """清空所有已选标签。"""
1447
+ self._mark_interaction()
1448
+ pending_items = [
1449
+ item for item in self.workspace_state.get('dismissed', [])
1450
+ if isinstance(item, dict) and item.get('kind') == 'prompt_import_pending'
1451
+ ]
1452
+ if self._get_selected_tags() or pending_items:
1453
+ self._push_undo_snapshot()
1454
+ if pending_items:
1455
+ self.workspace_state['dismissed'] = [
1456
+ item for item in self.workspace_state.get('dismissed', [])
1457
+ if not (
1458
+ isinstance(item, dict)
1459
+ and item.get('kind') == 'prompt_import_pending'
1460
+ )
1461
+ ]
1462
+ self.chip_extra_selected.clear()
1463
+ self._selected_order.clear()
1464
+ self.tag_weights.clear()
1465
+ self._pending_selection_meta.clear()
1466
+ self._workspace_artist_tags.clear()
1467
+ if self.result_table is not None:
1468
+ self.result_table.selected = []
1469
+ self._artist_rec_checkboxes.clear()
1470
+ self._current_artist_rec_tags.clear()
1471
+ self._artist_result_tags.clear()
1472
+ self._last_recommendation_seed_tags = []
1473
+ self._render_selected_chips()
1474
+ self._render_prompt_pending()
1475
+ self._render_concept_coverage()
1476
+ if self.selection_count_label is not None:
1477
+ self.selection_count_label.text = '0'
1478
+ show_nsfw_val = self.input_nsfw.value
1479
+ self._refresh_related([], show_nsfw_val)
1480
+ self._render_artist_rec([], {})
1481
+ # 清空 Group 同类扩展
1482
+ if self.group_expansion_container is not None:
1483
+ self.group_expansion_container.clear()
1484
+ with self.group_expansion_container:
1485
+ ui.label('请先搜索并勾选标签…').classes('text-sm text-gray-400 italic p-4')
1486
+ self._save_staged_tags()
1487
+ ui.notify('已清空所有已选标签', type='warning')
1488
+
1489
+ # ── 两栏结果(CSS 强制并排)──────────────────────────────────────────
1490
+ def _build_results_columns(self):
1491
+ build_results_columns(self)
1492
+
1493
+ def _set_related_page(self, page: int):
1494
+ """切换关联推荐页,只构建当前页的可见行。"""
1495
+ set_paginated_recommendation_page(
1496
+ self,
1497
+ page,
1498
+ state_prefix='_related',
1499
+ render_page=self._render_related_page,
1500
+ motion_element_id='danbooru-related-recommendations',
1501
+ )
1502
+
1503
+ def _lookup_tag_wiki(self, tag: str) -> str:
1504
+ """在控制器层读取标签元数据,视图层不直接访问检索引擎。"""
1505
+ try:
1506
+ tagger = DanbooruTagger._instance
1507
+ if tagger and tagger.df is not None and tag in tagger._name_to_idx:
1508
+ index = tagger._name_to_idx[tag]
1509
+ return str(tagger.df.iloc[index].get('wiki', ''))
1510
+ except Exception:
1511
+ pass
1512
+ return ''
1513
+
1514
+ def _render_related_page(self):
1515
+ render_related_page(self)
1516
+
1517
+ def _render_related_list(self, related: list, show_nsfw: bool):
1518
+ """保存关联推荐快照,并仅渲染当前页。"""
1519
+ render_related_list(self, related, show_nsfw)
1520
+
1521
+ # ══════════════════════════════════════════════════════════════════════
1522
+ # 交互逻辑
1523
+ # ══════════════════════════════════════════════════════════════════════
1524
+
1525
+ async def _hide_banner_when_ready(self):
1526
+ while not DanbooruTagger.is_ready():
1527
+ if not self._client_alive():
1528
+ return
1529
+ await asyncio.sleep(1)
1530
+ if not self._client_alive():
1531
+ return
1532
+ if self.init_banner:
1533
+ self.init_banner.set_visibility(False)
1534
+ self._update_service_status()
1535
+ self._update_footer_text()
1536
+ # 工作区可能早于引擎恢复;引擎就绪后用真实 Category / Tag Group
1537
+ # 元数据重新分组,避免重启期间首次渲染的标签全部停留在“其他”。
1538
+ self._render_selected_chips()
1539
+
1540
+ def _client_alive(self) -> bool:
1541
+ try:
1542
+ client = self.client
1543
+ if client is None and self.search_btn is not None:
1544
+ client = self.search_btn.client
1545
+ return client is not None and not bool(getattr(client, '_deleted', False))
1546
+ except (AttributeError, RuntimeError):
1547
+ return False
1548
+
1549
+ def _dispose(self):
1550
+ """Stop page-owned background resources."""
1551
+ tasks = (
1552
+ self._service_status_task,
1553
+ self._storage_restore_task,
1554
+ self._workspace_save_task,
1555
+ )
1556
+ self._service_status_task = None
1557
+ self._storage_restore_task = None
1558
+ self._workspace_save_task = None
1559
+ for task in tasks:
1560
+ if task is not None and not task.done():
1561
+ task.cancel()
1562
+
1563
+ # ── 分词筛选 ──────────────────────────────────────────────────────────
1564
+
1565
+ def _filter_by_source(self, keyword: str):
1566
+ filter_by_source(self, keyword)
1567
+ def _render_concept_coverage(self):
1568
+ render_concept_coverage(self)
1569
+
1570
+ def _schedule_concept_coverage_render(self):
1571
+ """让选择区先刷新,再在下一个事件循环周期重建查询理解。"""
1572
+ if self._coverage_render_task and not self._coverage_render_task.done():
1573
+ self._coverage_render_task.cancel()
1574
+
1575
+ async def _render():
1576
+ await asyncio.sleep(0)
1577
+ if self._client_alive():
1578
+ self._render_concept_coverage()
1579
+
1580
+ self._coverage_render_task = asyncio.ensure_future(_render())
1581
+
1582
+ async def _search_uncovered_segment(self, segment: str):
1583
+ segment = str(segment or '').strip()
1584
+ if not segment:
1585
+ return
1586
+ self.search_input.set_value(segment)
1587
+ await self.perform_search()
1588
+
1589
+ # ── 搜索 ──────────────────────���───────────────────────────────────────
1590
+
1591
+ async def perform_search(self):
1592
+ query = self.search_input.value.strip()
1593
+ if not query:
1594
+ return
1595
+
1596
+ # 搜索前校验数值参数
1597
+ _err_fields = []
1598
+ if self.input_top_k and (self.input_top_k.value is None or str(self.input_top_k.value).strip() == ''):
1599
+ _err_fields.append('Top K')
1600
+ if self.input_limit and (self.input_limit.value is None or str(self.input_limit.value).strip() == ''):
1601
+ _err_fields.append('返回数量')
1602
+ if self.input_weight and (self.input_weight.value is None or str(self.input_weight.value).strip() == ''):
1603
+ _err_fields.append('热度权重')
1604
+ if _err_fields:
1605
+ ui.notify(f'请填写:{"、".join(_err_fields)}', type='negative', timeout=3000)
1606
+ return
1607
+
1608
+ # 搜索前保存配置
1609
+ self._save_config()
1610
+
1611
+ self.current_query_str = query
1612
+ self.search_btn.disable()
1613
+ self.spinner.classes(remove='hidden')
1614
+ ui.notify('正在搜索...', type='info')
1615
+
1616
+ if self.bad_case_btn is not None:
1617
+ self.bad_case_btn.disable()
1618
+
1619
+ target_layers_list = [k for k, v in self.selected_layers.items() if v]
1620
+ target_cats_list = [k for k, v in self.selected_cats.items() if v]
1621
+
1622
+ if not target_layers_list:
1623
+ ui.notify('请至少选择一个匹配层!', type='warning')
1624
+ self.search_btn.enable()
1625
+ self.spinner.classes(add='hidden')
1626
+ return
1627
+
1628
+ search_started_at = time.perf_counter()
1629
+ search_invoked_at = time.monotonic()
1630
+ normalized_telemetry_query = ' '.join(query.split()).casefold()
1631
+ await telemetry.increment("ui_search")
1632
+ if (
1633
+ normalized_telemetry_query == self._telemetry_last_query
1634
+ and search_invoked_at - self._telemetry_last_search_at <= 60
1635
+ ):
1636
+ await telemetry.increment("ui_repeat_search_60s")
1637
+ self._telemetry_last_query = normalized_telemetry_query
1638
+ self._telemetry_last_search_at = search_invoked_at
1639
+
1640
+ try:
1641
+ tagger = await DanbooruTagger.get_instance()
1642
+
1643
+ show_nsfw_val = self.input_nsfw.value
1644
+
1645
+ request = SearchRequest(
1646
+ query=query,
1647
+ top_k=int(self.input_top_k.value),
1648
+ limit=int(self.input_limit.value),
1649
+ popularity_weight=float(self.input_weight.value),
1650
+ show_nsfw=show_nsfw_val,
1651
+ use_segmentation=self.input_segment.value if self.input_segment else True,
1652
+ target_layers=target_layers_list,
1653
+ target_categories=target_cats_list,
1654
+ group_mode=self.input_group_mode.value if self.input_group_mode else 'off',
1655
+ max_per_group=int(self.input_max_per_group.value) if self.input_max_per_group else 2,
1656
+ )
1657
+ response = await tagger.search_async(request)
1658
+ await telemetry.record_timing(
1659
+ "ui_search_latency",
1660
+ (time.perf_counter() - search_started_at) * 1000,
1661
+ )
1662
+ if not response.results:
1663
+ await telemetry.increment("ui_zero_result")
1664
+ self._telemetry_search_started_at = search_started_at
1665
+ self._telemetry_selection_recorded = False
1666
+ self._telemetry_copy_timing_recorded = False
1667
+
1668
+ # 后台计数
1669
+ async def silent_counter_update():
1670
+ try:
1671
+ await counter.increment()
1672
+ if response.keywords:
1673
+ await counter.add_keywords(response.keywords)
1674
+ self._update_footer_text()
1675
+ except Exception as e:
1676
+ print(f"[UI] 后台静默更新计数失败: {e}", flush=True)
1677
+ asyncio.create_task(silent_counter_update())
1678
+
1679
+ if not self._client_alive():
1680
+ return
1681
+
1682
+ table_data = [result_to_row(r, show_nsfw_val) for r in response.results]
1683
+ self._artist_result_tags = {row['tag'] for row in table_data if row.get('layer') == 'artist'}
1684
+ self.full_table_data = table_data
1685
+ self.full_tags_str = response.tags_all
1686
+ self.full_tags_str_sfw = response.tags_sfw
1687
+ self.current_segments = list(response.segments) if response.segments else []
1688
+ self.current_keywords = list(response.keywords) if response.keywords else []
1689
+ self.current_cached_queries = set(response.cached_queries or [])
1690
+
1691
+ self.results_section.set_visibility(True)
1692
+
1693
+ _saved_rpp = self._get_rows_per_page()
1694
+ self.result_table.rows = apply_nsfw_filter(table_data, show_nsfw_val)
1695
+ self._set_rows_per_page(_saved_rpp)
1696
+ all_selected = self._get_selected_tags()
1697
+ self._selected_order = list(all_selected)
1698
+ self.chip_extra_selected.clear()
1699
+ self.chip_extra_selected.update(all_selected)
1700
+ self.result_table.selected = []
1701
+ self._render_selected_chips()
1702
+ await self._update_selection_display(None)
1703
+
1704
+ self._refresh_related([], show_nsfw_val)
1705
+ self._last_recommendation_seed_tags = []
1706
+
1707
+ # 查询理解:分词来源筛选与概念覆盖共用同一组 chips
1708
+ self.current_filter_keyword = 'ALL'
1709
+ self._render_concept_coverage()
1710
+ self._record_search_history(query)
1711
+ self._replay_motion('danbooru-results-section', 'motion-results-enter')
1712
+ ui.notify(f'找到 {len(table_data)} 个标签', type='positive')
1713
+ self.current_search_interacted = False
1714
+
1715
+ if self.bad_case_btn is not None:
1716
+ self.bad_case_btn.enable()
1717
+
1718
+ except RuntimeError as e:
1719
+ if 'deleted' in str(e).lower() or 'client' in str(e).lower():
1720
+ return
1721
+ try:
1722
+ ui.notify(f'错误: {str(e)}', type='negative')
1723
+ except RuntimeError:
1724
+ pass
1725
+ except Exception as e:
1726
+ try:
1727
+ ui.notify(f'错误: {str(e)}', type='negative')
1728
+ except RuntimeError:
1729
+ pass
1730
+ finally:
1731
+ try:
1732
+ self.search_btn.enable()
1733
+ self.spinner.classes(add='hidden')
1734
+ except RuntimeError:
1735
+ pass
1736
+
1737
+ # ── 选择管理 ──────────────────────────────────────────────────────────
1738
+
1739
+ def _get_selected_tags(self) -> list[str]:
1740
+ return selected_tags(self)
1741
+
1742
+ def _get_recommendation_seed_tags(self, selected_tags: list[str]) -> list[str]:
1743
+ return recommendation_seed_tags(self, selected_tags)
1744
+
1745
+ def _refresh_recommendations_if_seed_changed(self, selected_tags: list[str], show_nsfw: bool):
1746
+ seed_tags = self._get_recommendation_seed_tags(selected_tags)
1747
+ if seed_tags == self._last_recommendation_seed_tags:
1748
+ return
1749
+ self._last_recommendation_seed_tags = list(seed_tags)
1750
+ self._refresh_related_from_selection(seed_tags, show_nsfw)
1751
+ self._refresh_group_from_selection(seed_tags, show_nsfw)
1752
+ self._refresh_artist_from_selection(seed_tags, show_nsfw)
1753
+
1754
+ def _set_selected_tags(
1755
+ self,
1756
+ tags: list[str],
1757
+ skip_refresh: bool = False,
1758
+ record_undo: bool = True,
1759
+ ):
1760
+ tags = list(dict.fromkeys(tags))
1761
+ previous_tags = [item['tag'] for item in self.workspace_state.get('selected', [])]
1762
+ if record_undo and tags != previous_tags:
1763
+ self._push_undo_snapshot()
1764
+ self._selected_order = list(tags)
1765
+ tag_set = set(tags)
1766
+ table_tag_set = {row['tag'] for row in self.result_table.rows} if self.result_table else set()
1767
+ self.chip_extra_selected.clear()
1768
+ self.chip_extra_selected.update(t for t in tag_set if t not in table_tag_set)
1769
+ # clean up weights for deselected tags
1770
+ for t in list(self.tag_weights):
1771
+ if t not in tag_set:
1772
+ del self.tag_weights[t]
1773
+ self._pending_selection_meta.pop(t, None)
1774
+
1775
+ if self.result_table is not None:
1776
+ self.result_table.selected = [row for row in self.result_table.rows if row.get('tag') in tag_set]
1777
+
1778
+ # 同步推荐画师 checkbox
1779
+ for t, cb in self._artist_rec_checkboxes.items():
1780
+ cb.set_value(t in tag_set)
1781
+
1782
+ all_tags = self._get_selected_tags()
1783
+ self._selected_order = list(all_tags)
1784
+ self._record_first_selection_if_needed(previous_tags, all_tags)
1785
+ if self.selection_count_label is not None:
1786
+ self.selection_count_label.text = str(len(all_tags))
1787
+ self._save_staged_tags()
1788
+ self._render_selected_chips()
1789
+ self._schedule_concept_coverage_render()
1790
+ # 显式刷新关联推荐和 Group 区域(不依赖 table.on('selection') 事件,
1791
+ # 因为在 chip 点击回调上下文中该事件可能不可靠)。
1792
+ # 从关联推荐/同类标签勾选时跳过,由各自动态刷新或手动按钮触发。
1793
+ if not skip_refresh:
1794
+ show_nsfw_val = self.input_nsfw.value
1795
+ self._refresh_recommendations_if_seed_changed(all_tags, show_nsfw_val)
1796
+ if not all_tags:
1797
+ self.chip_extra_selected.clear()
1798
+
1799
+ async def _update_selection_display(self, _e):
1800
+ if self.result_table is None:
1801
+ return
1802
+ self._mark_interaction()
1803
+
1804
+ all_tags = self._get_selected_tags()
1805
+ previous_tags = [item['tag'] for item in self.workspace_state.get('selected', [])]
1806
+ self._record_first_selection_if_needed(previous_tags, all_tags)
1807
+ if all_tags != previous_tags:
1808
+ self._push_undo_snapshot()
1809
+ existing = set(previous_tags)
1810
+ for row in self.result_table.rows:
1811
+ tag = row.get('tag')
1812
+ if not tag or tag in existing or tag not in all_tags:
1813
+ continue
1814
+ origin = 'artist_search' if row.get('layer') == 'artist' else 'semantic_search'
1815
+ self._set_selection_meta(tag, origin, str(row.get('source') or self.current_query_str))
1816
+ self._selected_order = list(all_tags)
1817
+ # clean up weights for deselected tags
1818
+ tag_set = set(all_tags)
1819
+ for t in list(self.tag_weights):
1820
+ if t not in tag_set:
1821
+ del self.tag_weights[t]
1822
+ self._pending_selection_meta.pop(t, None)
1823
+ # init weight for newly selected tags
1824
+ for t in all_tags:
1825
+ self.tag_weights.setdefault(t, 1.0)
1826
+
1827
+ if self.selection_count_label is not None:
1828
+ self.selection_count_label.text = str(len(all_tags))
1829
+ self._render_selected_chips()
1830
+
1831
+ # 先把选择区的轻量变化交给 WebSocket 刷出,再处理推荐和查询理解。
1832
+ self._save_staged_tags()
1833
+ await asyncio.sleep(0)
1834
+
1835
+ # 同步当前画师页的 checkbox(当前页最多 8 个)。
1836
+ for t, cb in self._artist_rec_checkboxes.items():
1837
+ cb.set_value(t in tag_set)
1838
+
1839
+ show_nsfw_val = self.input_nsfw.value
1840
+ self._refresh_recommendations_if_seed_changed(all_tags, show_nsfw_val)
1841
+ if not all_tags:
1842
+ self.chip_extra_selected.clear()
1843
+ self._render_concept_coverage()
1844
+
1845
+ def _on_related_checkbox_change(self, tag: str, checked: bool):
1846
+ self._mark_interaction()
1847
+ current = self._get_selected_tags()
1848
+ if checked:
1849
+ if tag not in current:
1850
+ source = ''
1851
+ for item in self.current_related:
1852
+ if getattr(item, 'tag', None) == tag:
1853
+ source = ', '.join(getattr(item, 'sources', []) or [])
1854
+ break
1855
+ self._set_selection_meta(tag, 'related_recommendation', source)
1856
+ current.append(tag)
1857
+ self.tag_weights.setdefault(tag, 1.0)
1858
+ self._set_selected_tags(current, skip_refresh=True)
1859
+ ui.notify(f'已添加 {tag}', type='positive', timeout=1500)
1860
+ else:
1861
+ if tag in current:
1862
+ current.remove(tag)
1863
+ self.tag_weights.pop(tag, None)
1864
+ self._set_selected_tags(current, skip_refresh=True)
1865
+ ui.notify(f'已移除 {tag}', type='warning', timeout=1500)
1866
+ # 刷新推荐画师
1867
+ show_nsfw_val = self.input_nsfw.value
1868
+ self._refresh_artist_from_selection(current, show_nsfw_val)
1869
+
1870
+ def _on_group_checkbox_change(self, tag: str, checked: bool):
1871
+ """同类标签复选框变化回调。"""
1872
+ self._mark_interaction()
1873
+ current = self._get_selected_tags()
1874
+ if checked:
1875
+ if tag not in current:
1876
+ self._set_selection_meta(
1877
+ tag,
1878
+ 'tag_group',
1879
+ self._group_candidate_sources.get(tag, ''),
1880
+ )
1881
+ current.append(tag)
1882
+ self.tag_weights.setdefault(tag, 1.0)
1883
+ self._set_selected_tags(current, skip_refresh=True)
1884
+ ui.notify(f'已添加 {tag}', type='positive', timeout=1500)
1885
+ else:
1886
+ if tag in current:
1887
+ current.remove(tag)
1888
+ self.tag_weights.pop(tag, None)
1889
+ self._set_selected_tags(current, skip_refresh=True)
1890
+ ui.notify(f'已移除 {tag}', type='warning', timeout=1500)
1891
+ # 即刻刷新关联推荐 + 画师推荐
1892
+ show_nsfw_val = self.input_nsfw.value
1893
+ self._refresh_related_from_selection(current, show_nsfw_val)
1894
+ self._refresh_artist_from_selection(current, show_nsfw_val)
1895
+
1896
+ def _on_artist_rec_checkbox_change(self, tag: str, checked: bool):
1897
+ """推荐画师复选框变化回调。"""
1898
+ self._mark_interaction()
1899
+ current = self._get_selected_tags()
1900
+ if checked:
1901
+ if tag not in current:
1902
+ self._set_selection_meta(
1903
+ tag,
1904
+ 'artist_recommendation',
1905
+ self._artist_rec_sources.get(tag, ''),
1906
+ )
1907
+ current.append(tag)
1908
+ self.tag_weights.setdefault(tag, 1.0)
1909
+ self._set_selected_tags(current, skip_refresh=True)
1910
+ ui.notify(f'已添加画师 {tag}', type='positive', timeout=1500)
1911
+ else:
1912
+ if tag in current:
1913
+ current.remove(tag)
1914
+ self.tag_weights.pop(tag, None)
1915
+ self._set_selected_tags(current, skip_refresh=True)
1916
+ ui.notify(f'已移除画师 {tag}', type='warning', timeout=1500)
1917
+
1918
+ def _manual_refresh_related(self):
1919
+ """手动触发关联推荐列表的刷新"""
1920
+ self._mark_interaction()
1921
+ show_nsfw_val = self.input_nsfw.value
1922
+ all_tags = self._get_selected_tags()
1923
+
1924
+ if all_tags:
1925
+ self._refresh_related_from_selection(all_tags, show_nsfw_val)
1926
+ self._refresh_artist_from_selection(all_tags, show_nsfw_val)
1927
+ ui.notify('已触发关联推荐更新', type='info', timeout=1500)
1928
+ else:
1929
+ self.chip_extra_selected.clear()
1930
+ self._refresh_related([], show_nsfw_val)
1931
+ ui.notify('已清空关联推荐', type='info', timeout=1500)
1932
+
1933
+ def _manual_refresh_group(self):
1934
+ """手动触发同类扩展区域的刷新"""
1935
+ self._mark_interaction()
1936
+ show_nsfw_val = self.input_nsfw.value
1937
+ all_tags = self._get_selected_tags()
1938
+
1939
+ if all_tags:
1940
+ self._refresh_group_from_selection(all_tags, show_nsfw_val)
1941
+ ui.notify('已触发同类标签更新', type='info', timeout=1500)
1942
+ else:
1943
+ if self.group_expansion_container is not None:
1944
+ self.group_expansion_container.clear()
1945
+ with self.group_expansion_container:
1946
+ ui.label('请先搜索并勾选标签…').classes('text-sm text-gray-400 italic p-4')
1947
+ ui.notify('暂未选中标签', type='info', timeout=1500)
1948
+
1949
+ # ── 关联推荐 ──────────────────────────────────────────────────────────
1950
+
1951
+ def _refresh_related(self, related: list, show_nsfw: bool):
1952
+ selected_now = set(self._get_selected_tags())
1953
+ merged = merge_related_results(self.current_related, related, selected_now)
1954
+
1955
+ self.current_related = merged
1956
+ if self.related_list_container is not None:
1957
+ self._render_related_list(merged, show_nsfw)
1958
+
1959
+ def _refresh_related_from_selection(self, selected_tags: list[str], show_nsfw: bool):
1960
+ """请求刷新关联推荐;实际计算由统一的 latest-wins 调度器执行。"""
1961
+ self._queue_recommendation_refresh(
1962
+ selected_tags, show_nsfw, {'related'},
1963
+ )
1964
+
1965
+ def _refresh_group_from_selection(self, selected_tags: list[str], show_nsfw: bool):
1966
+ """请求刷新同类扩展;实际计算由统一的 latest-wins 调度器执行。"""
1967
+ self._queue_recommendation_refresh(
1968
+ selected_tags, show_nsfw, {'group'},
1969
+ )
1970
+
1971
+ def _refresh_artist_from_selection(self, selected_tags: list[str], show_nsfw: bool = True):
1972
+ """请求刷新画师推荐;实际计算由统一的 latest-wins 调度器执行。"""
1973
+ self._queue_recommendation_refresh(
1974
+ selected_tags, show_nsfw, {'artist'},
1975
+ )
1976
+
1977
+ def _queue_recommendation_refresh(
1978
+ self,
1979
+ selected_tags: list[str],
1980
+ show_nsfw: bool,
1981
+ scopes: set[str],
1982
+ ):
1983
+ """合并同一选择快照的刷新范围,并确保运行中的线程不会被取消。"""
1984
+ if queue_latest_recommendation_request(
1985
+ self,
1986
+ selected_tags,
1987
+ show_nsfw,
1988
+ scopes,
1989
+ ):
1990
+ self._recommendation_task = asyncio.ensure_future(
1991
+ self._recommendation_worker()
1992
+ )
1993
+
1994
+ async def _recommendation_worker(self):
1995
+ """连续消费最新选择快照;过期结果只计算不渲染。"""
1996
+ try:
1997
+ async def fetch(request: dict) -> dict:
1998
+ selected_tags = request['selected_tags']
1999
+ if not selected_tags:
2000
+ return {
2001
+ 'related': [],
2002
+ 'groups': [],
2003
+ 'artists': [],
2004
+ 'artist_top_tags': {},
2005
+ }
2006
+ tagger = await DanbooruTagger.get_instance()
2007
+ return await tagger.get_selection_recommendations_async(
2008
+ selected_tags,
2009
+ request['show_nsfw'],
2010
+ request['scopes'],
2011
+ related_limit=50,
2012
+ artist_limit=ARTIST_REC_LIMIT,
2013
+ artist_min_cooc=3,
2014
+ )
2015
+
2016
+ async def apply(request: dict, result: dict) -> None:
2017
+ selected_tags = request['selected_tags']
2018
+ show_nsfw = request['show_nsfw']
2019
+ scopes = request['scopes']
2020
+ if 'related' in scopes:
2021
+ self._refresh_related(result['related'], show_nsfw)
2022
+ if 'group' in scopes:
2023
+ if selected_tags:
2024
+ await self._capture_group_scroll_positions()
2025
+ self._render_group_expansion(
2026
+ result['groups'], selected_tags, show_nsfw,
2027
+ )
2028
+ elif self.group_expansion_container is not None:
2029
+ self.group_expansion_container.clear()
2030
+ with self.group_expansion_container:
2031
+ ui.label('请先搜索并勾选标签…').classes(
2032
+ 'text-sm text-gray-400 italic p-4'
2033
+ )
2034
+ if 'artist' in scopes:
2035
+ self._render_artist_rec(
2036
+ result['artists'],
2037
+ result['artist_top_tags'],
2038
+ show_nsfw,
2039
+ )
2040
+
2041
+ consume = consume_latest_recommendation_requests(
2042
+ self,
2043
+ debounce_seconds=RECOMMENDATION_DEBOUNCE_SECONDS,
2044
+ fetch=fetch,
2045
+ apply=apply,
2046
+ client_alive=self._client_alive,
2047
+ report_error=lambda exc: print(f'[UI] 推荐刷新失败: {exc}', flush=True),
2048
+ )
2049
+ await consume
2050
+ finally:
2051
+ self._recommendation_task = None
2052
+
2053
+ def _set_artist_rec_page(self, page: int):
2054
+ """切换推荐画师页,只构建当前页的可见行。"""
2055
+ set_paginated_recommendation_page(
2056
+ self,
2057
+ page,
2058
+ state_prefix='_artist_rec',
2059
+ render_page=self._render_artist_rec_page,
2060
+ motion_element_id='danbooru-artist-recommendations',
2061
+ )
2062
+ def _render_artist_rec_page(self):
2063
+ render_artist_page(self)
2064
+
2065
+ def _render_artist_rec(self, artist_results, top_tags=None, show_nsfw: bool = True):
2066
+ """保存推荐快照,并仅渲染当前画师页。"""
2067
+ render_artist_recommendations(self, artist_results, top_tags, show_nsfw)
2068
+ def _render_group_expansion(self, group_data: list, selected_tags: list[str], show_nsfw: bool):
2069
+ render_group_expansion(self, group_data, selected_tags, show_nsfw)
2070
+
2071
+ def _on_group_expansion_change(self, group_name: str, event):
2072
+ value = getattr(event, 'args', None)
2073
+ if isinstance(value, dict):
2074
+ value = value.get('value', value.get('modelValue'))
2075
+ if bool(value):
2076
+ self._group_expanded_names.add(group_name)
2077
+ else:
2078
+ self._group_expanded_names.discard(group_name)
2079
+
2080
+ async def _capture_group_scroll_positions(self, *, anchor_bottom: bool = False):
2081
+ client = self.client
2082
+ if client is None or getattr(client, '_deleted', False):
2083
+ return
2084
+ anchor_flag = 'true' if anchor_bottom else 'false'
2085
+ try:
2086
+ raw = await client.run_javascript(
2087
+ f"""
2088
+ const anchorBottom = {anchor_flag};
2089
+ const groupEntries = Array.from(document.querySelectorAll('[data-danbooru-group-scroll="1"]'))
2090
+ .flatMap(el => {{
2091
+ const top = Math.round(el.scrollTop || 0);
2092
+ const entries = [[el.id, top]];
2093
+ if (anchorBottom) {{
2094
+ entries.push([`${{el.id}}__bottom__`, Math.round(el.scrollHeight - top)]);
2095
+ }}
2096
+ return entries;
2097
+ }});
2098
+ JSON.stringify({{
2099
+ __window__: Math.round(
2100
+ window.scrollY ||
2101
+ document.documentElement.scrollTop ||
2102
+ document.body.scrollTop ||
2103
+ 0
2104
+ ),
2105
+ ...Object.fromEntries(groupEntries),
2106
+ }});
2107
+ """,
2108
+ timeout=1.0,
2109
+ )
2110
+ except Exception:
2111
+ return
2112
+ try:
2113
+ data = _json.loads(raw) if isinstance(raw, str) else raw
2114
+ if isinstance(data, dict):
2115
+ self._group_scroll_positions = {
2116
+ str(k): int(v) for k, v in data.items()
2117
+ if str(k) and int(v) >= 0
2118
+ }
2119
+ except Exception:
2120
+ pass
2121
+
2122
+ def _restore_group_scroll_positions(self):
2123
+ if not self._group_scroll_positions:
2124
+ return
2125
+ client = self.client
2126
+ if client is None or getattr(client, '_deleted', False):
2127
+ return
2128
+ client.run_javascript(_scroll_state_restore_script(self._group_scroll_positions), timeout=1.0)
2129
+
2130
+ async def _load_more_group_tags(
2131
+ self,
2132
+ group_name: str,
2133
+ total: int,
2134
+ group_data: list,
2135
+ selected_tags: list[str],
2136
+ show_nsfw: bool,
2137
+ ):
2138
+ await self._capture_group_scroll_positions(anchor_bottom=True)
2139
+ current = self._group_render_limits.get(group_name, GROUP_RENDER_TAG_LIMIT)
2140
+ self._group_render_limits[group_name] = _next_group_render_limit(
2141
+ current,
2142
+ total,
2143
+ GROUP_RENDER_TAG_LIMIT,
2144
+ )
2145
+ self._group_expanded_names.add(group_name)
2146
+ self._render_group_expansion(group_data, selected_tags, show_nsfw)
2147
+
2148
+ # ── 表格列动态更新 ──────────────────────────────────────────────────
2149
+
2150
+ def _update_table_columns(self, e=None):
2151
+ update_table_columns(self, e)
2152
+
2153
+ # ── 搜索模式 / 参数联动 ──────────────────────────────────────────────
2154
+
2155
+ def _on_search_mode_change(self, _e=None):
2156
+ mode = self.input_search_mode.value if self.input_search_mode else None
2157
+ if not mode or mode == '自定义' or mode not in _SEARCH_MODE_PRESETS:
2158
+ return
2159
+ preset = _SEARCH_MODE_PRESETS[mode]
2160
+ self._applying_preset = True
2161
+ try:
2162
+ if self.input_top_k:
2163
+ self.input_top_k.set_value(preset['top_k'])
2164
+ if self.input_limit:
2165
+ self.input_limit.set_value(preset['limit'])
2166
+ if self.input_weight:
2167
+ self.input_weight.set_value(preset['popularity_weight'])
2168
+ if self.input_segment:
2169
+ self.input_segment.set_value(preset['use_segmentation'])
2170
+ if self.input_group_mode:
2171
+ self.input_group_mode.set_value(preset['group_mode'])
2172
+ if self.input_max_per_group:
2173
+ self.input_max_per_group.set_value(preset['max_per_group'])
2174
+ finally:
2175
+ self._applying_preset = False
2176
+
2177
+ def _on_param_changed(self, _e=None):
2178
+ if not self._applying_preset and self.input_search_mode:
2179
+ if self.input_search_mode.value != '自定义':
2180
+ self.input_search_mode.set_value('自定义')
2181
+
2182
+ # ── NSFW 切换 ─────────────────────────────────────────────────────────
2183
+
2184
+ async def on_nsfw_toggle(self, e):
2185
+ show_nsfw_val = self.input_nsfw.value
2186
+
2187
+ # 复用当前分词筛选:同时套用新 NSFW 状态并保持 chip 选中态
2188
+ self._filter_by_source(self.current_filter_keyword)
2189
+ if not show_nsfw_val:
2190
+ self.result_table.selected = [r for r in self.result_table.selected if r.get('nsfw') != '1']
2191
+ await self._update_selection_display(None)
2192
+
2193
+ # ── 复制 / 反馈 ──────────────────────────────────────────────────────
2194
+
2195
+ def _toggle_prompt_format(self):
2196
+ if self.prompt_format == 'sdxl':
2197
+ self._apply_prompt_format('nai')
2198
+ elif self.prompt_format == 'nai':
2199
+ self._apply_prompt_format('anima')
2200
+ else:
2201
+ self._apply_prompt_format('sdxl')
2202
+ self._save_config()
2203
+ self._save_staged_tags()
2204
+ self._render_selected_chips()
2205
+
2206
+ def copy_selection(self):
2207
+ self._mark_interaction()
2208
+ tags = self._get_selected_tags()
2209
+ parts = []
2210
+ artist_tags = (
2211
+ set(self._current_artist_rec_tags)
2212
+ | set(self._artist_result_tags)
2213
+ | set(self._workspace_artist_tags)
2214
+ )
2215
+ for t in tags:
2216
+ w = self.tag_weights.get(t, 1.0)
2217
+ if self.prompt_format == 'anima' and t in artist_tags:
2218
+ parts.append(_format_tag_with_weight(f'@{t}', w, self.prompt_format))
2219
+ else:
2220
+ parts.append(_format_tag_with_weight(t, w, self.prompt_format))
2221
+ prompt = ', '.join(parts)
2222
+ ui.clipboard.write(prompt)
2223
+ fmt_label = {'sdxl': 'SDXL', 'nai': 'NAI', 'anima': 'Anima'}.get(self.prompt_format, 'SDXL')
2224
+ ui.notify(f'已复制选中标签({fmt_label} 格式)!', type='positive')
2225
+ if prompt:
2226
+ self._record_ui_copy("ui_copy_selected")
2227
+
2228
+ def _copy_all_tags(self):
2229
+ self._mark_interaction()
2230
+ show_nsfw_val = self.input_nsfw.value
2231
+ tags_str = self.full_tags_str if show_nsfw_val else self.full_tags_str_sfw
2232
+ if tags_str:
2233
+ tags_str = tags_str.replace('(', '\\(').replace(')', '\\)')
2234
+ ui.clipboard.write(tags_str)
2235
+ ui.notify('已复制全部标签!', type='positive')
2236
+ self._record_ui_copy("ui_copy_all")
2237
+ else:
2238
+ ui.notify('暂无标签可复制', type='warning')
2239
+
2240
+ def _feedback_settings(self) -> dict:
2241
+ return {
2242
+ 'top_k': int(self.input_top_k.value) if self.input_top_k else None,
2243
+ 'limit': int(self.input_limit.value) if self.input_limit else None,
2244
+ 'popularity_weight': float(self.input_weight.value) if self.input_weight else None,
2245
+ 'show_nsfw': self.input_nsfw.value if self.input_nsfw else None,
2246
+ 'use_segmentation': self.input_segment.value if self.input_segment else None,
2247
+ 'search_mode': self.input_search_mode.value if self.input_search_mode else '自定义',
2248
+ 'target_layers': [key for key, enabled in self.selected_layers.items() if enabled],
2249
+ 'target_categories': [key for key, enabled in self.selected_cats.items() if enabled],
2250
+ 'group_mode': self.input_group_mode.value if self.input_group_mode else 'off',
2251
+ 'max_per_group': int(self.input_max_per_group.value) if self.input_max_per_group else 2,
2252
+ }
2253
+
2254
+ def report_bad_case(self):
2255
+ from platform_utils import PLATFORM
2256
+ query = self.current_query_str.strip()
2257
+ if len(query) <= 1:
2258
+ ui.notify('搜索词太短,无法提交反馈。', type='warning', timeout=2000)
2259
+ return
2260
+ open_search_feedback_dialog(
2261
+ self,
2262
+ query=query,
2263
+ privacy_text=UI_TEXT['dialogs']['search_feedback_privacy'],
2264
+ )
2265
+
2266
+ async def _submit_search_feedback(self, query: str, detail: str) -> None:
2267
+ from platform_utils import PLATFORM
2268
+
2269
+ await telemetry.add_feedback(
2270
+ feedback_type='search_bad_case',
2271
+ query=query,
2272
+ search_settings=self._feedback_settings(),
2273
+ app_version=_get_git_commit(),
2274
+ platform=PLATFORM,
2275
+ details=detail,
2276
+ )
2277
+
2278
+ def report_translation_error(self, e):
2279
+ raw_args = getattr(e, 'args', None)
2280
+ # print(f'[UI] translation_feedback event received: {raw_args!r}', flush=True)
2281
+ row = raw_args
2282
+ if isinstance(row, list) and row:
2283
+ row = row[0]
2284
+ if not isinstance(row, dict):
2285
+ print(f'[UI] translation_feedback invalid payload: {raw_args!r}', flush=True)
2286
+ ui.notify('无法读取当前词条信息。', type='warning', timeout=2000)
2287
+ return
2288
+
2289
+ tag = str(row.get('tag') or '').strip()
2290
+ current_cn_name = str(row.get('cn_name') or '').strip()
2291
+ if not tag:
2292
+ ui.notify('无法读取当前词条。', type='warning', timeout=2000)
2293
+ return
2294
+ open_translation_feedback_dialog(
2295
+ self,
2296
+ row=row,
2297
+ privacy_text=UI_TEXT['dialogs']['translation_feedback_privacy'],
2298
+ )
2299
+
2300
+ async def _submit_translation_feedback(
2301
+ self,
2302
+ row: dict,
2303
+ suggested: str,
2304
+ detail: str,
2305
+ ) -> None:
2306
+ from platform_utils import PLATFORM
2307
+
2308
+ await telemetry.add_feedback(
2309
+ feedback_type='translation_error',
2310
+ query=self.current_query_str.strip(),
2311
+ search_settings=self._feedback_settings(),
2312
+ app_version=_get_git_commit(),
2313
+ platform=PLATFORM,
2314
+ details=detail,
2315
+ tag=str(row.get('tag') or '').strip(),
2316
+ current_cn_name=str(row.get('cn_name') or '').strip(),
2317
+ suggested_cn_name=suggested,
2318
+ category=str(row.get('category') or ''),
2319
+ )
webui/helpers.py ADDED
@@ -0,0 +1,178 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """无 NiceGUI 控件依赖的前端格式化与数据适配函数。"""
2
+
3
+ import json
4
+ import os
5
+ import re
6
+ import subprocess
7
+ from dataclasses import asdict
8
+ from datetime import datetime, timedelta, timezone
9
+
10
+ from core.workspace_insights import semantic_candidate_reason
11
+ from webui.constants import (
12
+ GROUP_RENDER_TAG_LIMIT,
13
+ SEARCH_MODE_OPTIONS,
14
+ SEARCH_MODE_PRESETS,
15
+ )
16
+
17
+
18
+ _HISTORY_DISPLAY_TIMEZONE = timezone(timedelta(hours=8))
19
+
20
+
21
+ def format_history_time(value: object) -> str:
22
+ raw = str(value or '').strip()
23
+ if not raw:
24
+ return '--'
25
+ try:
26
+ parsed = datetime.fromisoformat(raw.replace('Z', '+00:00'))
27
+ if parsed.tzinfo is None:
28
+ parsed = parsed.replace(tzinfo=_HISTORY_DISPLAY_TIMEZONE)
29
+ return parsed.astimezone(_HISTORY_DISPLAY_TIMEZONE).strftime('%y-%m-%d %H:%M:%S')
30
+ except ValueError:
31
+ return raw
32
+
33
+
34
+ def format_history_settings(settings: object) -> str:
35
+ if not isinstance(settings, dict):
36
+ settings = {}
37
+ mode = settings.get('search_mode')
38
+ preset = SEARCH_MODE_PRESETS.get(mode)
39
+ if preset and all(settings.get(key) == value for key, value in preset.items()):
40
+ return f'预设:{mode}'
41
+ top_k = settings.get('top_k', '--')
42
+ limit = settings.get('limit', '--')
43
+ segmentation = settings.get('use_segmentation')
44
+ segmentation_text = '开启' if segmentation is True else '关闭' if segmentation is False else '--'
45
+ return f'Top K:{top_k} · 数量上限:{limit} · 分词:{segmentation_text}'
46
+
47
+
48
+ def sanitize_restored_config(cfg: dict) -> dict:
49
+ safe: dict = {}
50
+ numeric_fields = {
51
+ 'top_k': (int, 1, 200),
52
+ 'limit': (int, 10, 500),
53
+ 'popularity_weight': (float, 0.0, 1.0),
54
+ 'max_per_group': (int, 1, 10),
55
+ }
56
+ for key, (caster, minimum, maximum) in numeric_fields.items():
57
+ if key not in cfg:
58
+ continue
59
+ try:
60
+ value = caster(cfg[key])
61
+ except (TypeError, ValueError):
62
+ continue
63
+ if minimum <= value <= maximum:
64
+ safe[key] = value
65
+ for key in ('show_nsfw', 'use_segmentation', 'sw_semantic', 'sw_layer', 'sw_source'):
66
+ if isinstance(cfg.get(key), bool):
67
+ safe[key] = cfg[key]
68
+ for key in ('selected_layers', 'selected_cats'):
69
+ value = cfg.get(key)
70
+ if isinstance(value, dict):
71
+ safe[key] = {str(k): v for k, v in value.items() if isinstance(v, bool)}
72
+ if cfg.get('prompt_format') in ('sdxl', 'nai', 'anima'):
73
+ safe['prompt_format'] = cfg['prompt_format']
74
+ if cfg.get('search_mode') in SEARCH_MODE_OPTIONS:
75
+ safe['search_mode'] = cfg['search_mode']
76
+ if cfg.get('group_mode') in ('off', 'expand', 'diverse'):
77
+ safe['group_mode'] = cfg['group_mode']
78
+ if isinstance(cfg.get('rows_per_page'), int) and cfg['rows_per_page'] in {0, 5, 7, 10, 15, 20, 25, 50}:
79
+ safe['rows_per_page'] = cfg['rows_per_page']
80
+ if isinstance(cfg.get('search_query'), str):
81
+ safe['search_query'] = cfg['search_query'][:4_000]
82
+ if isinstance(cfg.get('dismissed_announcement_version'), str):
83
+ safe['dismissed_announcement_version'] = cfg['dismissed_announcement_version'][:100]
84
+ return safe
85
+
86
+
87
+ def next_group_render_limit(current: int, total: int, page_size: int) -> int:
88
+ if page_size <= 0:
89
+ return total
90
+ return min(total, max(page_size, current + page_size))
91
+
92
+
93
+ def limit_group_render_tags(tags: list[dict], visible_limit: int | None = None) -> tuple[list[dict], int]:
94
+ limit = GROUP_RENDER_TAG_LIMIT if visible_limit is None else visible_limit
95
+ if limit <= 0 or len(tags) <= limit:
96
+ return tags, 0
97
+ return tags[:limit], len(tags) - limit
98
+
99
+
100
+ def should_group_start_expanded(group_name: str, expanded_groups: set[str]) -> bool:
101
+ return group_name in expanded_groups
102
+
103
+
104
+ def group_names_key(group_data: list[dict]) -> tuple[str, ...]:
105
+ return tuple(sorted({str(group.get('group', '')) for group in group_data}))
106
+
107
+
108
+ def group_scroll_dom_id(group_name: str) -> str:
109
+ safe_name = re.sub(r'[^0-9A-Za-z_-]+', '_', group_name)
110
+ return f'group-scroll-{safe_name}'
111
+
112
+
113
+ def scroll_state_restore_script(positions: dict[str, int]) -> str:
114
+ js_positions = json.dumps(positions)
115
+ return f"""
116
+ (() => {{
117
+ const positions = {js_positions};
118
+ const restore = () => {{
119
+ const windowTop = positions.__window__;
120
+ if (typeof windowTop === 'number') {{
121
+ window.scrollTo({{ top: windowTop, behavior: 'auto' }});
122
+ const root = document.scrollingElement || document.documentElement || document.body;
123
+ if (root) root.scrollTop = windowTop;
124
+ }}
125
+ for (const [id, top] of Object.entries(positions)) {{
126
+ if (id === '__window__' || id.endsWith('__bottom__')) continue;
127
+ const el = document.getElementById(id);
128
+ if (!el) continue;
129
+ const bottom = positions[`${{id}}__bottom__`];
130
+ el.scrollTop = typeof bottom === 'number'
131
+ ? Math.max(0, el.scrollHeight - bottom) : top;
132
+ }}
133
+ }};
134
+ requestAnimationFrame(() => {{ restore(); requestAnimationFrame(restore); }});
135
+ setTimeout(restore, 80);
136
+ }})();
137
+ """
138
+
139
+
140
+ def get_git_commit() -> str:
141
+ try:
142
+ return subprocess.check_output(
143
+ ['git', 'rev-parse', '--short', 'HEAD'], stderr=subprocess.DEVNULL, text=True,
144
+ ).strip()
145
+ except Exception:
146
+ return os.environ.get('COMMIT_SHA', 'unknown')[:7]
147
+
148
+
149
+ def result_to_row(result, nsfw_visible: bool) -> dict:
150
+ row = asdict(result)
151
+ row['_nsfw_blocked'] = (result.nsfw == '1') and not nsfw_visible
152
+ row['reason'] = semantic_candidate_reason(result.source, result.layer, result.alias_from)
153
+ return row
154
+
155
+
156
+ def apply_nsfw_filter(rows: list[dict], show_nsfw: bool) -> list[dict]:
157
+ result = []
158
+ for row in rows:
159
+ copied = dict(row)
160
+ copied['_nsfw_blocked'] = (copied.get('nsfw') == '1') and not show_nsfw
161
+ result.append(copied)
162
+ return result
163
+
164
+
165
+ def format_tag_with_weight(tag: str, weight: float, fmt: str = 'sdxl') -> str:
166
+ tag = tag.replace('(', '\\(').replace(')', '\\)')
167
+ if fmt == 'anima':
168
+ tag = tag.replace('_', ' ')
169
+ if weight == 1.0:
170
+ return tag
171
+ if fmt == 'nai':
172
+ return f'{weight:.1f}::{tag}::'
173
+ return f'({tag}:{weight:.1f})'
174
+
175
+
176
+ def format_selected_tag_label(tag: str, cn_name: str = '') -> str:
177
+ cn_first = (cn_name or '').split(',', 1)[0].strip()
178
+ return f'{tag} | {cn_first}' if cn_first else tag
webui/local_storage.py ADDED
@@ -0,0 +1,488 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """浏览器 localStorage 协议的共享边界。"""
2
+
3
+ import asyncio
4
+ import json
5
+ from dataclasses import dataclass
6
+ from typing import Any
7
+
8
+ from core.workspace import (
9
+ FAVORITES_STORAGE_KEY,
10
+ HISTORY_STORAGE_KEY,
11
+ LEGACY_STAGED_STORAGE_KEY,
12
+ WORKSPACE_STORAGE_KEY,
13
+ WorkspaceDataError,
14
+ dump_workspace,
15
+ empty_favorites,
16
+ empty_history,
17
+ merge_favorites,
18
+ merge_history,
19
+ merge_workspaces,
20
+ migrate_legacy_workspace,
21
+ normalize_favorites,
22
+ normalize_history,
23
+ normalize_workspace,
24
+ )
25
+ from webui.constants import CONFIG_LS_KEY
26
+ from webui.constants import (
27
+ LOCAL_STORAGE_MAX_READ_CHARS,
28
+ LOCAL_STORAGE_READ_CHUNK_CHARS,
29
+ LOCAL_STORAGE_RESTORE_CACHE,
30
+ )
31
+
32
+
33
+ def storage_keys() -> dict[str, str]:
34
+ return {
35
+ 'workspace': WORKSPACE_STORAGE_KEY,
36
+ 'legacy': LEGACY_STAGED_STORAGE_KEY,
37
+ 'config': CONFIG_LS_KEY,
38
+ 'history': HISTORY_STORAGE_KEY,
39
+ 'favorites': FAVORITES_STORAGE_KEY,
40
+ }
41
+
42
+
43
+ def write_is_ready(name: str, states: dict[str, str], applying: set[str]) -> bool:
44
+ """只有已安全恢复且未处于应用阶段的数据域允许写回浏览器。"""
45
+ return states.get(name) == 'ready' and name not in applying
46
+
47
+
48
+ async def backup_key(client, source_key: str, backup_key: str) -> bool:
49
+ """在覆盖前仅创建一次浏览器内备份;已有备份绝不覆盖。"""
50
+ source_key_js = json.dumps(source_key, ensure_ascii=False)
51
+ backup_key_js = json.dumps(backup_key, ensure_ascii=False)
52
+ try:
53
+ status = await client.run_javascript(
54
+ f'''(() => {{
55
+ const source = localStorage.getItem({source_key_js});
56
+ if (source === null) return 'missing';
57
+ if (localStorage.getItem({backup_key_js}) !== null) return 'exists';
58
+ try {{ localStorage.setItem({backup_key_js}, source); return 'created'; }}
59
+ catch (error) {{ return `error:${{error && error.name ? error.name : 'unknown'}}`; }}
60
+ }})()''',
61
+ timeout=5.0,
62
+ )
63
+ except Exception:
64
+ return False
65
+ return status in {'created', 'exists'}
66
+
67
+
68
+ def storage_listener_script() -> str:
69
+ keys = storage_keys()
70
+ return f'''
71
+ if (!window.__danbooruWorkspaceStorageListenerV1) {{
72
+ window.__danbooruWorkspaceStorageListenerV1 = true;
73
+ window.addEventListener('storage', (event) => {{
74
+ const watchedKeys = new Set([{keys['workspace']!r}, {keys['history']!r}, {keys['favorites']!r}]);
75
+ if (watchedKeys.has(event.key) && event.newValue !== event.oldValue) {{
76
+ if (window.confirm('工作区数据已在另一个标签页更新。是否重新加载当前页面以同步最新内容?')) window.location.reload();
77
+ }}
78
+ }});
79
+ }}
80
+ '''
81
+
82
+
83
+ async def read_prepared_value(client, connected, name: str, key: str, length) -> str | None:
84
+ """按 UTF-16 安全边界分块读取准备好的浏览器缓存值。"""
85
+ if length is None:
86
+ return None
87
+ if isinstance(length, bool) or not isinstance(length, (int, float)):
88
+ raise RuntimeError(f'localStorage key {key!r} returned an invalid length')
89
+ length = int(length)
90
+ if length < 0 or length > LOCAL_STORAGE_MAX_READ_CHARS:
91
+ raise WorkspaceDataError(f'localStorage key {key!r} exceeds the read limit')
92
+ if length == 0:
93
+ return ''
94
+ name_js, key_js = json.dumps(name, ensure_ascii=False), json.dumps(key, ensure_ascii=False)
95
+ cache_key_js = json.dumps(LOCAL_STORAGE_RESTORE_CACHE)
96
+ chunks: list[str] = []
97
+ offset = 0
98
+ while offset < length:
99
+ if not connected():
100
+ raise RuntimeError('client disconnected during localStorage restore')
101
+ result = await client.run_javascript(
102
+ f'''(() => {{
103
+ const cache = window[{cache_key_js}];
104
+ const value = cache && Object.prototype.hasOwnProperty.call(cache, {name_js}) ? cache[{name_js}] : localStorage.getItem({key_js});
105
+ if (value === null) return null;
106
+ let end = Math.min(value.length, {offset + LOCAL_STORAGE_READ_CHUNK_CHARS});
107
+ if (end < value.length) {{ const last = value.charCodeAt(end - 1); if (last >= 0xD800 && last <= 0xDBFF) end += 1; }}
108
+ return {{chunk: value.slice({offset}, end), next_offset: end}};
109
+ }})()''', timeout=5.0,
110
+ )
111
+ if not isinstance(result, dict):
112
+ raise RuntimeError(f'localStorage key {key!r} disappeared during restore')
113
+ chunk, next_offset = result.get('chunk'), result.get('next_offset')
114
+ if not isinstance(chunk, str) or not isinstance(next_offset, (int, float)):
115
+ raise RuntimeError(f'localStorage key {key!r} returned an invalid chunk')
116
+ next_offset = int(next_offset)
117
+ if next_offset <= offset or next_offset > length:
118
+ raise RuntimeError(f'localStorage key {key!r} returned an invalid offset')
119
+ chunks.append(chunk)
120
+ offset = next_offset
121
+ return ''.join(chunks)
122
+
123
+
124
+ def clear_restore_cache(client) -> None:
125
+ try:
126
+ client.run_javascript(f'delete window[{json.dumps(LOCAL_STORAGE_RESTORE_CACHE)}];')
127
+ except RuntimeError:
128
+ pass
129
+
130
+
131
+ async def prepare_restore_snapshot(client, connected, keys: dict[str, str]) -> dict:
132
+ """快照请求域;仅在浏览器内存中预压缩旧 history,绝不直接覆盖存储。"""
133
+ if not connected():
134
+ raise RuntimeError('client is disconnected')
135
+ keys_js = json.dumps(keys, ensure_ascii=False)
136
+ cache_key_js = json.dumps(LOCAL_STORAGE_RESTORE_CACHE)
137
+ result = await client.run_javascript(
138
+ f'''(() => {{
139
+ const keys = {keys_js}, values = {{}}, manifest = {{}};
140
+ for (const [name, key] of Object.entries(keys)) {{
141
+ let value = localStorage.getItem(key), prepared = false;
142
+ const originalLength = value === null ? null : value.length;
143
+ if (name === 'history' && value) try {{
144
+ const data = JSON.parse(value);
145
+ if (data && typeof data === 'object' && (data.schema_version === 1 || data.schema_version === 2) && Array.isArray(data.items)) {{
146
+ let changed = data.schema_version !== 2;
147
+ const items = data.items.map((item) => {{
148
+ if (!item || typeof item !== 'object' || !item.workspace || typeof item.workspace !== 'object' || typeof item.query !== 'string' || !item.query.trim() || !item.settings || typeof item.settings !== 'object' || Array.isArray(item.settings)) return item;
149
+ const query = item.query.trim().slice(0, 4000);
150
+ const searchedAt = typeof item.searched_at === 'string' && item.searched_at ? item.searched_at : new Date().toISOString();
151
+ const oldQueries = item.workspace.queries;
152
+ if (!Array.isArray(oldQueries) || oldQueries.length !== 1 || !oldQueries[0] || oldQueries[0].query !== query) changed = true;
153
+ const workspace = {{...item.workspace, queries: [{{query, searched_at: searchedAt, settings: item.settings}}], updated_at: searchedAt}};
154
+ return {{...item, workspace_id: workspace.workspace_id, workspace}};
155
+ }});
156
+ if (changed) {{ value = JSON.stringify({{...data, schema_version: 2, items}}); prepared = true; }}
157
+ }}
158
+ }} catch (_) {{ /* Python validates and backs up corruption. */ }}
159
+ values[name] = value;
160
+ manifest[name] = {{length: value === null ? null : value.length, original_length: originalLength, prepared}};
161
+ }}
162
+ window[{cache_key_js}] = values;
163
+ return manifest;
164
+ }})()''', timeout=5.0,
165
+ )
166
+ if not isinstance(result, dict):
167
+ raise RuntimeError('localStorage manifest is invalid')
168
+ return result
169
+
170
+
171
+ async def restore_staged_storage(
172
+ controller: Any,
173
+ names: tuple[str, ...],
174
+ config_version: int,
175
+ ) -> tuple[dict[str, str], set[str], list[str]]:
176
+ """执行一次恢复尝试;控制器只提供页面状态和应用回调。"""
177
+ unresolved = [
178
+ name for name in names
179
+ if controller._storage_states.get(name) != 'ready'
180
+ ]
181
+ if not unresolved:
182
+ return {}, set(), []
183
+
184
+ failures: dict[str, str] = {}
185
+ persist: set[str] = set()
186
+ warnings: list[str] = []
187
+ keys = controller._local_storage_keys()
188
+ try:
189
+ manifest = await controller._prepare_local_storage_restore(unresolved)
190
+ except Exception as exc:
191
+ message = str(exc) or type(exc).__name__
192
+ return {name: message for name in unresolved}, persist, warnings
193
+
194
+ try:
195
+ for name in unresolved:
196
+ meta = manifest.get(name)
197
+ if not isinstance(meta, dict):
198
+ failures[name] = 'missing manifest entry'
199
+ continue
200
+ try:
201
+ raw = await controller._read_local_storage_value(
202
+ name, keys[name], meta.get('length')
203
+ )
204
+ except Exception as exc:
205
+ failures[name] = str(exc) or type(exc).__name__
206
+ continue
207
+ controller._storage_raw_values[name] = raw
208
+ finally:
209
+ controller._clear_local_storage_restore_cache()
210
+
211
+ if 'legacy' in unresolved and 'legacy' not in failures:
212
+ controller._storage_states['legacy'] = 'ready'
213
+
214
+ if 'config' in unresolved and 'config' not in failures:
215
+ raw_config = controller._storage_raw_values.get('config')
216
+ config_dirty = 'config' in controller._storage_session_dirty
217
+ try:
218
+ cfg = json.loads(raw_config) if raw_config else {}
219
+ if not isinstance(cfg, dict):
220
+ raise WorkspaceDataError('config must be a JSON object')
221
+ except Exception as exc:
222
+ config_key = keys['config']
223
+ if raw_config and not await controller._backup_local_storage_key(
224
+ config_key, f'{config_key}_corrupt_backup'
225
+ ):
226
+ failures['config'] = f'corrupt config backup failed: {exc}'
227
+ else:
228
+ warnings.append('config_corrupt')
229
+ persist.add('config')
230
+ else:
231
+ if cfg and cfg.get('version') != config_version:
232
+ warnings.append('config_schema_migrated')
233
+ persist.add('config')
234
+ if not config_dirty:
235
+ controller._storage_applying.add('config')
236
+ try:
237
+ controller._apply_config_state(cfg)
238
+ finally:
239
+ controller._storage_applying.discard('config')
240
+ else:
241
+ persist.add('config')
242
+ if 'config' not in failures:
243
+ controller._storage_states['config'] = 'ready'
244
+
245
+ if 'workspace' in unresolved and 'workspace' not in failures:
246
+ raw_workspace = controller._storage_raw_values.get('workspace')
247
+ if not raw_workspace and any(
248
+ controller._storage_states.get(name) != 'ready'
249
+ for name in ('legacy', 'config')
250
+ ):
251
+ failures['workspace'] = 'legacy workspace inputs are not available'
252
+ else:
253
+ workspace_warnings: list[str] = []
254
+ try:
255
+ if raw_workspace:
256
+ workspace, workspace_warnings = normalize_workspace(raw_workspace)
257
+ else:
258
+ workspace, workspace_warnings = migrate_legacy_workspace(
259
+ controller._storage_raw_values.get('legacy'),
260
+ controller._storage_raw_values.get('config'),
261
+ )
262
+ persist.add('workspace')
263
+ except WorkspaceDataError as exc:
264
+ backed_up = await controller._backup_local_storage_key(
265
+ keys['workspace'], f"{keys['workspace']}_corrupt_backup"
266
+ )
267
+ if raw_workspace and not backed_up:
268
+ failures['workspace'] = f'corrupt workspace backup failed: {exc}'
269
+ else:
270
+ workspace, migration_warnings = migrate_legacy_workspace(
271
+ controller._storage_raw_values.get('legacy'),
272
+ controller._storage_raw_values.get('config'),
273
+ )
274
+ workspace_warnings = ['workspace_corrupt'] + migration_warnings
275
+ persist.add('workspace')
276
+ if 'workspace' not in failures:
277
+ if 'workspace' in controller._storage_session_dirty:
278
+ workspace = merge_workspaces(
279
+ controller.workspace_state,
280
+ workspace,
281
+ origin='local_restore',
282
+ source='浏览器本地恢复',
283
+ )
284
+ persist.add('workspace')
285
+ controller._storage_applying.add('workspace')
286
+ try:
287
+ controller._apply_workspace_state(
288
+ workspace, persist=False, refresh_recommendations=False
289
+ )
290
+ finally:
291
+ controller._storage_applying.discard('workspace')
292
+ warnings.extend(workspace_warnings)
293
+ if workspace_warnings:
294
+ persist.add('workspace')
295
+ controller._storage_states['workspace'] = 'ready'
296
+ if raw_workspace:
297
+ controller._storage_states['legacy'] = 'ready'
298
+ failures.pop('legacy', None)
299
+
300
+ if 'history' in unresolved and 'history' not in failures:
301
+ raw_history = controller._storage_raw_values.get('history')
302
+ history_prepared = bool(
303
+ isinstance(manifest.get('history'), dict)
304
+ and manifest['history'].get('prepared')
305
+ )
306
+ try:
307
+ history, history_warnings = normalize_history(raw_history)
308
+ except WorkspaceDataError as exc:
309
+ backed_up = await controller._backup_local_storage_key(
310
+ keys['history'], f"{keys['history']}_corrupt_backup"
311
+ )
312
+ if raw_history and not backed_up:
313
+ failures['history'] = f'corrupt history backup failed: {exc}'
314
+ else:
315
+ history, history_warnings = empty_history(), ['history_corrupt']
316
+ persist.add('history')
317
+ if 'history' not in failures:
318
+ if history_prepared:
319
+ if not await controller._backup_history_before_compaction():
320
+ failures['history'] = 'legacy history backup failed'
321
+ else:
322
+ history_warnings.extend([
323
+ 'history_schema_migrated',
324
+ 'history_workspace_queries_compacted',
325
+ ])
326
+ persist.add('history')
327
+ if 'history' not in failures:
328
+ if 'history' in controller._storage_session_dirty:
329
+ history = merge_history(controller.search_history, history)
330
+ persist.add('history')
331
+ controller.search_history = history
332
+ warnings.extend(history_warnings)
333
+ if history_warnings:
334
+ persist.add('history')
335
+ controller._storage_states['history'] = 'ready'
336
+
337
+ if 'favorites' in unresolved and 'favorites' not in failures:
338
+ raw_favorites = controller._storage_raw_values.get('favorites')
339
+ try:
340
+ favorites, favorite_warnings = normalize_favorites(raw_favorites)
341
+ except WorkspaceDataError as exc:
342
+ backed_up = await controller._backup_local_storage_key(
343
+ keys['favorites'], f"{keys['favorites']}_corrupt_backup"
344
+ )
345
+ if raw_favorites and not backed_up:
346
+ failures['favorites'] = f'corrupt favorites backup failed: {exc}'
347
+ else:
348
+ favorites, favorite_warnings = empty_favorites(), ['favorites_corrupt']
349
+ persist.add('favorites')
350
+ if 'favorites' not in failures:
351
+ if 'favorites' in controller._storage_session_dirty:
352
+ favorites = merge_favorites(controller.favorites, favorites)
353
+ persist.add('favorites')
354
+ controller.favorites = favorites
355
+ warnings.extend(favorite_warnings)
356
+ if favorite_warnings:
357
+ persist.add('favorites')
358
+ controller._storage_states['favorites'] = 'ready'
359
+
360
+ for name in failures:
361
+ controller._storage_states[name] = 'failed'
362
+ controller._update_undo_buttons()
363
+ controller._update_workspace_counts()
364
+ return failures, persist, warnings
365
+
366
+
367
+ @dataclass(frozen=True)
368
+ class RestoreRetryResult:
369
+ """一次恢复任务的无界面结果,交由页面层决定日志和提示。"""
370
+
371
+ completed: bool
372
+ client_stopped: bool
373
+ failures: dict[str, str]
374
+ warnings: list[str]
375
+
376
+
377
+ async def restore_with_retries(
378
+ controller: Any,
379
+ retry_delays: tuple[float, ...],
380
+ ) -> RestoreRetryResult:
381
+ """在连接可用时执行有限次恢复,不触碰 NiceGUI 的通知上下文。"""
382
+ last_failures: dict[str, str] = {}
383
+ all_warnings: list[str] = []
384
+ for delay in retry_delays:
385
+ if delay:
386
+ await asyncio.sleep(delay)
387
+ if not controller._client_alive():
388
+ return RestoreRetryResult(False, True, last_failures, all_warnings)
389
+ if not controller._client_connected():
390
+ last_failures = {'connection': 'client is disconnected'}
391
+ continue
392
+ controller._storage_restoring = True
393
+ try:
394
+ failures, persist, warnings = await controller._restore_staged_tags()
395
+ finally:
396
+ controller._storage_restoring = False
397
+ persist_restored_storage(controller, persist)
398
+ all_warnings.extend(warnings)
399
+ last_failures = failures
400
+ if not failures:
401
+ return RestoreRetryResult(True, False, {}, all_warnings)
402
+ return RestoreRetryResult(False, False, last_failures, all_warnings)
403
+
404
+
405
+ def persist_restored_storage(controller: Any, names: set[str]) -> None:
406
+ """按既定顺序写回已恢复的数据域,具体写入仍由页面控制器完成。"""
407
+ for name, saver_name in (
408
+ ('config', '_save_config'),
409
+ ('workspace', '_save_staged_tags'),
410
+ ('history', '_save_history'),
411
+ ('favorites', '_save_favorites'),
412
+ ):
413
+ if name in names:
414
+ getattr(controller, saver_name)()
415
+
416
+
417
+ def flush_storage_session_changes(controller: Any) -> None:
418
+ """只刷新已安全恢复的数据域,失败或未恢复域绝不覆盖浏览器旧值。"""
419
+ ready_dirty = {
420
+ name for name in controller._storage_session_dirty
421
+ if controller._storage_states.get(name) == 'ready'
422
+ }
423
+ persist_restored_storage(controller, ready_dirty)
424
+
425
+
426
+ def start_storage_restore_task(controller: Any, worker) -> Any:
427
+ """仅在仍有待恢复域或待写回数据时创建一个恢复任务。"""
428
+ task = controller._storage_restore_task
429
+ if task is not None and not task.done():
430
+ return task
431
+ needs_work = any(
432
+ state != 'ready' for state in controller._storage_states.values()
433
+ ) or bool(controller._storage_session_dirty)
434
+ if not needs_work:
435
+ return None
436
+ controller._storage_restore_task = asyncio.create_task(worker())
437
+ return controller._storage_restore_task
438
+
439
+
440
+ def pause_storage_restore(controller: Any) -> None:
441
+ """取消当前恢复任务;重连后的重启决定仍由页面生命周期处理。"""
442
+ task = controller._storage_restore_task
443
+ if task is not None and not task.done():
444
+ task.cancel()
445
+
446
+
447
+ def finish_storage_restore_task(
448
+ controller: Any,
449
+ current_task: Any,
450
+ was_cancelled: bool,
451
+ ) -> bool:
452
+ """清理当前任务,并告知页面层是否应在已连接时重新启动恢复。"""
453
+ controller._storage_restoring = False
454
+ if controller._storage_restore_task is not current_task:
455
+ return False
456
+ controller._storage_restore_task = None
457
+ return was_cancelled and controller._client_connected()
458
+
459
+
460
+ def schedule_workspace_persist(controller: Any, debounce_seconds: float) -> None:
461
+ """去抖写入工作区;恢复未就绪或写入失败时保留原有浏览器数据。"""
462
+ if not controller._storage_write_allowed('workspace'):
463
+ return
464
+ if controller._workspace_save_task and not controller._workspace_save_task.done():
465
+ controller._workspace_save_task.cancel()
466
+
467
+ async def persist() -> None:
468
+ try:
469
+ await asyncio.sleep(debounce_seconds)
470
+ except asyncio.CancelledError:
471
+ return
472
+ try:
473
+ data = dump_workspace(controller.workspace_state)
474
+ except WorkspaceDataError as exc:
475
+ print(f'[UI] 工作区保存前校验失败: {exc}', flush=True)
476
+ return
477
+ if not controller._storage_write_allowed('workspace'):
478
+ return
479
+ try:
480
+ controller.client.run_javascript(
481
+ f"localStorage.setItem({json.dumps(WORKSPACE_STORAGE_KEY)}, {json.dumps(data)});"
482
+ )
483
+ except RuntimeError:
484
+ controller._storage_session_dirty.add('workspace')
485
+ return
486
+ controller._storage_session_dirty.discard('workspace')
487
+
488
+ controller._workspace_save_task = asyncio.ensure_future(persist())
webui/recommendations.py ADDED
@@ -0,0 +1,143 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """推荐区域共享的状态与调度规则,不包含 NiceGUI 渲染。"""
2
+
3
+ import asyncio
4
+ from collections.abc import Awaitable, Callable
5
+ from typing import Any
6
+
7
+
8
+ def recommendation_seed_tags(controller: Any, selected_tags: list[str]) -> list[str]:
9
+ """排除画师标签,避免把画师作为关联/同类推荐的种子。"""
10
+ artist_tags = (
11
+ set(controller._current_artist_rec_tags)
12
+ | set(controller._artist_result_tags)
13
+ | set(controller._workspace_artist_tags)
14
+ )
15
+ if controller.result_table is not None:
16
+ for row in controller.result_table.rows:
17
+ if row.get('layer') == 'artist' and row.get('tag'):
18
+ artist_tags.add(row['tag'])
19
+ return [tag for tag in selected_tags if tag not in artist_tags]
20
+
21
+
22
+ def merge_related_results(
23
+ current_related: list,
24
+ related: list | None,
25
+ selected_tags: set[str],
26
+ ) -> list:
27
+ """刷新时保留仍被选中的旧关联标签,防止已选项从面板消失。"""
28
+ related = list(related or [])
29
+ new_tags = {item.tag for item in related}
30
+ preserved = [
31
+ item for item in current_related
32
+ if item.tag in selected_tags and item.tag not in new_tags
33
+ ]
34
+ return related + preserved
35
+
36
+
37
+ def queue_latest_recommendation_request(
38
+ controller: Any,
39
+ selected_tags: list[str],
40
+ show_nsfw: bool,
41
+ scopes: set[str],
42
+ ) -> bool:
43
+ """合并同一选择快照的范围;返回是否需要启动消费任务。"""
44
+ selected_tags = recommendation_seed_tags(controller, selected_tags)
45
+ requested_scopes = frozenset(scopes)
46
+ pending = controller._pending_recommendation_request
47
+ if (
48
+ pending is not None
49
+ and pending['selected_tags'] == selected_tags
50
+ and pending['show_nsfw'] == show_nsfw
51
+ ):
52
+ requested_scopes = pending['scopes'] | requested_scopes
53
+
54
+ controller._recommendation_generation += 1
55
+ controller._pending_recommendation_request = {
56
+ 'generation': controller._recommendation_generation,
57
+ 'selected_tags': list(selected_tags),
58
+ 'show_nsfw': show_nsfw,
59
+ 'scopes': requested_scopes,
60
+ }
61
+ return controller._recommendation_task is None or controller._recommendation_task.done()
62
+
63
+
64
+ def page_count(item_count: int, page_size: int) -> int:
65
+ """返回分页数量;空列表没有有效页。"""
66
+ if item_count <= 0 or page_size <= 0:
67
+ return 0
68
+ return (item_count + page_size - 1) // page_size
69
+
70
+
71
+ def clamp_page(page: int, total_pages: int) -> int:
72
+ """把外部页码约束在有效范围内。"""
73
+ return max(1, min(page, total_pages)) if total_pages > 0 else 0
74
+
75
+
76
+ def page_items(items: list, page: int, page_size: int) -> list:
77
+ """返回有效页中的项目,空结果或无效页都返回空列表。"""
78
+ total_pages = page_count(len(items), page_size)
79
+ current_page = clamp_page(page, total_pages)
80
+ if current_page == 0:
81
+ return []
82
+ start = (current_page - 1) * page_size
83
+ return items[start:start + page_size]
84
+
85
+
86
+ def set_paginated_recommendation_page(
87
+ controller: Any,
88
+ page: int,
89
+ *,
90
+ state_prefix: str,
91
+ render_page: Callable[[], None],
92
+ motion_element_id: str,
93
+ ) -> None:
94
+ """切换关联或画师推荐页,并同步翻页按钮的状态。"""
95
+ page_count_value = getattr(controller, f'{state_prefix}_page_count')
96
+ if page_count_value < 1:
97
+ return
98
+ previous_page = getattr(controller, f'{state_prefix}_page')
99
+ current_page = clamp_page(page, page_count_value)
100
+ setattr(controller, f'{state_prefix}_page', current_page)
101
+ render_page()
102
+ motion_class = (
103
+ 'motion-recommendation-enter-right'
104
+ if current_page >= previous_page
105
+ else 'motion-recommendation-enter-left'
106
+ )
107
+ controller._replay_motion(motion_element_id, motion_class)
108
+
109
+ label = getattr(controller, f'{state_prefix}_page_label')
110
+ if label is not None:
111
+ label.text = f'{current_page} / {page_count_value}'
112
+ previous_button = getattr(controller, f'{state_prefix}_prev_button')
113
+ if previous_button is not None:
114
+ (previous_button.disable if current_page == 1 else previous_button.enable)()
115
+ next_button = getattr(controller, f'{state_prefix}_next_button')
116
+ if next_button is not None:
117
+ (next_button.disable if current_page == page_count_value else next_button.enable)()
118
+
119
+
120
+ async def consume_latest_recommendation_requests(
121
+ controller: Any,
122
+ *,
123
+ debounce_seconds: float,
124
+ fetch: Callable[[dict], Awaitable[dict]],
125
+ apply: Callable[[dict, dict], Awaitable[None]],
126
+ client_alive: Callable[[], bool],
127
+ report_error: Callable[[Exception], None],
128
+ ) -> None:
129
+ """顺序消费最新快照,计算完成后丢弃已经过期的结果。"""
130
+ while controller._pending_recommendation_request is not None:
131
+ await asyncio.sleep(debounce_seconds)
132
+ request = controller._pending_recommendation_request
133
+ controller._pending_recommendation_request = None
134
+ try:
135
+ result = await fetch(request)
136
+ except Exception as exc:
137
+ report_error(exc)
138
+ continue
139
+ if request['generation'] != controller._recommendation_generation:
140
+ continue
141
+ if not client_alive():
142
+ return
143
+ await apply(request, result)
webui/render/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """仅负责创建或更新 NiceGUI 控件的视图层。"""
webui/render/dialogs.py ADDED
@@ -0,0 +1,589 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """通用帮助、历史与备份弹窗视图。"""
2
+
3
+ import json
4
+ from typing import Any
5
+
6
+ from nicegui import ui
7
+
8
+ from webui.helpers import format_history_settings, format_history_time
9
+
10
+
11
+ def build_sponsor_dialog(
12
+ controller: Any,
13
+ *,
14
+ title: str,
15
+ image_url: str,
16
+ toolchain_url: str,
17
+ ui_text: dict,
18
+ ) -> None:
19
+ """构建低干扰的赞赏弹窗。"""
20
+ with ui.dialog() as controller.sponsor_dialog, ui.card().classes('w-full max-w-sm'):
21
+ with ui.column().classes('w-full items-center gap-2 text-center'):
22
+ ui.label(title).classes('text-base font-bold text-gray-800')
23
+ ui.label(ui_text['sponsor']['body']).classes('text-sm text-gray-600 leading-relaxed')
24
+ ui.image(image_url).classes('w-60 max-w-full rounded border border-gray-200')
25
+ ui.label('微信赞赏码').classes('text-xs text-gray-400')
26
+ ui.link(
27
+ ui_text['sponsor']['toolchain_prompt'],
28
+ toolchain_url,
29
+ new_tab=True,
30
+ ).classes('text-xs text-blue-500 hover:text-blue-700 hover:underline')
31
+ with ui.row().classes('w-full justify-end'):
32
+ ui.button('关闭', on_click=controller.sponsor_dialog.close).props('flat color=grey-7')
33
+
34
+
35
+ def build_help_dialog(controller: Any, *, ui_text: dict, sponsor_notice_text: str) -> None:
36
+ """构建帮助、文档与本地数据说明弹窗。"""
37
+ from platform_utils import PLATFORM
38
+
39
+ alternate_url = (
40
+ 'https://www.modelscope.cn/studios/SAkizuki/DanbooruSearchOnline'
41
+ if PLATFORM == 'hf'
42
+ else 'https://huggingface.co/spaces/SAkizuki/DanbooruSearch'
43
+ )
44
+ with ui.dialog() as controller.help_dialog, ui.card().classes(
45
+ 'w-full max-w-3xl max-h-[90vh] p-0 gap-0'
46
+ ):
47
+ with ui.row().classes('w-full items-center justify-between px-5 py-4 border-b border-slate-200'):
48
+ with ui.row().classes('items-center gap-2'):
49
+ ui.icon('help_outline', color='primary')
50
+ ui.label('帮助 / 关于').classes('text-lg font-bold text-slate-800')
51
+ ui.button(icon='close', on_click=controller.help_dialog.close).props(
52
+ 'flat dense round color=grey-7'
53
+ )
54
+
55
+ with ui.scroll_area().classes('w-full h-[72vh]'):
56
+ with ui.column().classes('w-full gap-5 px-5 py-4'):
57
+ with ui.column().classes('help-section'):
58
+ with ui.element('div').classes('help-section-heading'):
59
+ ui.label(ui_text['help']['update_title']).classes('help-section-heading-title')
60
+ ui.label(ui_text['help']['update_summary']).classes('help-section-heading-subtitle')
61
+ ui.markdown(ui_text['help']['guide_markdown']).classes('help-content')
62
+
63
+ with ui.column().classes('help-section'):
64
+ with ui.element('div').classes(
65
+ 'help-section-heading help-section-heading--documentation'
66
+ ):
67
+ ui.label(ui_text['documentation']['title']).classes('help-section-heading-title')
68
+ ui.label(ui_text['documentation']['subtitle']).classes('help-section-heading-subtitle')
69
+ with ui.column().classes('help-content gap-3'):
70
+ with ui.row().classes('w-full gap-3 flex-wrap'):
71
+ for link in ui_text['documentation']['links']:
72
+ link_url = alternate_url if link['url'] == '{alternate_url}' else link['url']
73
+ ui.link(link['label'], link_url, new_tab=True).classes('help-link')
74
+ ui.markdown(ui_text['documentation']['copyright_markdown']).classes(
75
+ 'help-content'
76
+ )
77
+
78
+ with ui.column().classes('help-section'):
79
+ with ui.element('div').classes(
80
+ 'help-section-heading help-section-heading--notice'
81
+ ):
82
+ ui.label(ui_text['notice']['title']).classes('help-section-heading-title')
83
+ ui.label(ui_text['notice']['subtitle']).classes('help-section-heading-subtitle')
84
+ ui.markdown(ui_text['notice']['body_markdown']).classes('help-content')
85
+
86
+ with ui.element('div').classes(
87
+ 'w-full rounded-lg border border-red-200 bg-red-50 px-4 py-3'
88
+ ):
89
+ ui.label('本地个人数据').classes('text-sm font-bold text-red-900')
90
+ ui.label(
91
+ '历史、收藏、当前工作区和搜索配置只保存在这个浏览器中。'
92
+ ).classes('text-xs text-red-800 mt-1')
93
+ ui.button(
94
+ '删除所有本地个人数据',
95
+ icon='delete_forever',
96
+ on_click=controller._confirm_delete_all_personal_data,
97
+ ).props('outline color=negative no-caps').classes('mt-3')
98
+
99
+ with ui.row().classes('w-full items-center justify-between gap-3 flex-wrap'):
100
+ ui.label('DanbooruSearch 将持续免费开放。').classes('text-xs text-slate-500')
101
+ ui.button(
102
+ sponsor_notice_text,
103
+ icon='volunteer_activism',
104
+ on_click=controller.sponsor_dialog.open,
105
+ ).props('flat dense no-caps color=grey-7').classes('text-xs')
106
+
107
+
108
+ def open_history_dialog(controller: Any) -> None:
109
+ """显示本地搜索历史及其恢复操作。"""
110
+ with ui.dialog() as dialog, ui.card().classes('w-full max-w-4xl max-h-[85vh]'):
111
+ with ui.row().classes('w-full items-center justify-between'):
112
+ ui.label('搜索历史').classes('text-lg font-bold')
113
+ with ui.row().classes('gap-2'):
114
+ if controller.search_history.get('items'):
115
+ ui.button(
116
+ '清空全部',
117
+ icon='delete_sweep',
118
+ on_click=lambda: controller._confirm_clear_history(dialog),
119
+ ).props('flat dense color=red-7')
120
+ ui.button(icon='close', on_click=dialog.close).props('flat round dense')
121
+
122
+ with ui.scroll_area().classes('w-full h-[65vh]'):
123
+ items = controller.search_history.get('items', [])
124
+ if not items:
125
+ ui.label('暂无搜索历史').classes('text-sm text-gray-400 p-6')
126
+ for item in items:
127
+ with ui.card().classes('w-full mb-2 p-3 border border-gray-200 shadow-none'):
128
+ with ui.row().classes('w-full items-start justify-between gap-3'):
129
+ with ui.column().classes('gap-1 flex-grow min-w-0'):
130
+ ui.label(item['query']).classes('font-medium text-gray-800 break-all')
131
+ selected_count = len(item['workspace'].get('selected', []))
132
+ ui.label(
133
+ f"{format_history_time(item.get('searched_at'))} · "
134
+ f"工作区内有 {selected_count} 个标签"
135
+ ).classes('text-xs text-gray-400')
136
+ ui.label(
137
+ format_history_settings(item.get('settings'))
138
+ ).classes('text-xs text-gray-400')
139
+ with ui.row().classes('gap-1 flex-wrap justify-end'):
140
+ ui.button(
141
+ '重新搜索',
142
+ icon='search',
143
+ on_click=lambda value=item, current=dialog: controller._history_research(
144
+ value, current
145
+ ),
146
+ ).props('flat dense color=primary')
147
+ ui.button(
148
+ '恢复工作区',
149
+ icon='restore',
150
+ on_click=lambda value=item, current=dialog: controller._history_restore(
151
+ value, current
152
+ ),
153
+ ).props('flat dense color=teal-7')
154
+ ui.button(
155
+ '追加查询',
156
+ icon='playlist_add',
157
+ on_click=lambda value=item, current=dialog: controller._history_append(
158
+ value, current
159
+ ),
160
+ ).props('flat dense color=purple-7')
161
+ ui.button(
162
+ icon='delete_outline',
163
+ on_click=lambda value=item, current=dialog: controller._delete_history_entry(
164
+ value, current
165
+ ),
166
+ ).props('flat round dense color=red-6')
167
+ dialog.open()
168
+
169
+
170
+ def open_backup_dialog(controller: Any, *, description: str) -> None:
171
+ """显示备份导出与二次确认导入入口。"""
172
+ with ui.dialog() as dialog, ui.card().classes('w-full max-w-2xl'):
173
+ ui.label('本地数据备份与迁移').classes('text-lg font-bold')
174
+ ui.label(description).classes('text-sm text-orange-700 bg-orange-50 rounded p-3')
175
+ ui.button('导出完整 JSON', icon='download', on_click=controller._export_backup).props(
176
+ 'unelevated color=primary'
177
+ ).classes('w-full')
178
+ ui.separator()
179
+ import_mode = ui.select(
180
+ {
181
+ 'merge': '合并:保留当前权重和配置',
182
+ 'overwrite': '覆盖:使用备份中的全部数据',
183
+ 'favorites_only': '只导入收藏',
184
+ },
185
+ value='merge',
186
+ label='导入方式',
187
+ ).props('outlined').classes('w-full')
188
+ pending_import = {'raw': None, 'name': ''}
189
+ pending_label = ui.label(
190
+ '选择文件后,请检查文件名和导入方式,再点击“确认导入”。'
191
+ ).classes('text-sm text-gray-500')
192
+ confirm_import_button = None
193
+
194
+ async def handle_upload(event: Any) -> None:
195
+ try:
196
+ raw = await event.file.text('utf-8')
197
+ json.loads(raw)
198
+ pending_import['raw'] = raw
199
+ pending_import['name'] = event.file.name
200
+ pending_label.text = f'已选择:{event.file.name};尚未执行导入'
201
+ confirm_import_button.enable()
202
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
203
+ pending_import['raw'] = None
204
+ pending_import['name'] = ''
205
+ pending_label.text = '文件读取失败,请重新选择有效的 JSON 文件。'
206
+ confirm_import_button.disable()
207
+ ui.notify(f'文件读取失败:{exc}', type='negative', timeout=5000)
208
+ except Exception as exc:
209
+ pending_import['raw'] = None
210
+ pending_import['name'] = ''
211
+ pending_label.text = '文件读取失败,请重新选择。'
212
+ confirm_import_button.disable()
213
+ print(f'[UI] JSON 文件读取异常: {exc}', flush=True)
214
+ ui.notify('文件读取失败,请检查文件格式', type='negative')
215
+
216
+ async def confirm_import() -> None:
217
+ raw = pending_import.get('raw')
218
+ if not isinstance(raw, str):
219
+ ui.notify('请先选择 JSON 文件', type='warning')
220
+ return
221
+ confirm_import_button.disable()
222
+ try:
223
+ await controller._import_backup_text(raw, import_mode.value)
224
+ dialog.close()
225
+ except (ValueError,) as exc:
226
+ ui.notify(f'导入失败:{exc}', type='negative', timeout=5000)
227
+ confirm_import_button.enable()
228
+ except Exception as exc:
229
+ print(f'[UI] JSON 导入异常: {exc}', flush=True)
230
+ ui.notify('导入失败,请检查文件格式', type='negative')
231
+ confirm_import_button.enable()
232
+
233
+ ui.upload(
234
+ label='选择 JSON 文件',
235
+ on_upload=handle_upload,
236
+ auto_upload=True,
237
+ max_file_size=12_000_000,
238
+ on_rejected=lambda: ui.notify('文件过大,仅支持 12 MB 以内的 JSON', type='warning'),
239
+ ).props('accept=.json').classes('w-full')
240
+ with ui.row().classes('w-full justify-end gap-2'):
241
+ ui.button('关闭', on_click=dialog.close).props('flat')
242
+ confirm_import_button = ui.button(
243
+ '确认导入', icon='check', on_click=confirm_import,
244
+ ).props('unelevated color=primary')
245
+ confirm_import_button.disable()
246
+ dialog.open()
247
+
248
+
249
+ def open_favorites_dialog(controller: Any) -> None:
250
+ """显示收藏列表与加载、合并、导出等操作入口。"""
251
+ with ui.dialog() as dialog, ui.card().classes('w-full max-w-5xl max-h-[88vh]'):
252
+ with ui.row().classes('w-full items-center justify-between'):
253
+ ui.label('收藏').classes('text-lg font-bold')
254
+ ui.button(icon='close', on_click=dialog.close).props('flat round dense')
255
+ with ui.scroll_area().classes('w-full h-[70vh]'):
256
+ items = controller.favorites.get('items', [])
257
+ if not items:
258
+ ui.label('暂无收藏').classes('text-sm text-gray-400 p-6')
259
+ for item in items:
260
+ with ui.card().classes('w-full mb-2 p-3 border border-amber-100 shadow-none'):
261
+ with ui.row().classes('w-full items-start justify-between gap-3'):
262
+ with ui.column().classes('gap-1 flex-grow min-w-0'):
263
+ ui.label(item['name']).classes('font-bold text-gray-800')
264
+ ui.label(
265
+ f"{len(item['selected'])} 个标签 · {item['prompt_format'].upper()} · "
266
+ f"更新于 {item['updated_at']}"
267
+ ).classes('text-xs text-gray-400')
268
+ if item.get('source_query'):
269
+ ui.label(f"来源:{item['source_query']}").classes(
270
+ 'text-xs text-gray-500 break-all'
271
+ )
272
+ if item.get('notes'):
273
+ ui.label(item['notes']).classes('text-xs text-gray-600')
274
+ with ui.row().classes('gap-1 flex-wrap justify-end max-w-xl'):
275
+ ui.button(
276
+ '替换载入', icon='file_open',
277
+ on_click=lambda value=item, current=dialog: controller._load_favorite(
278
+ value, False, current
279
+ ),
280
+ ).props('flat dense color=teal-7')
281
+ ui.button(
282
+ '合并', icon='merge',
283
+ on_click=lambda value=item, current=dialog: controller._load_favorite(
284
+ value, True, current
285
+ ),
286
+ ).props('flat dense color=primary')
287
+ ui.button(
288
+ '复制', icon='content_copy',
289
+ on_click=lambda value=item: controller._copy_favorite(value),
290
+ ).props('flat dense color=grey-7')
291
+ ui.button(
292
+ '重命名', icon='edit',
293
+ on_click=lambda value=item, current=dialog: controller._rename_favorite(
294
+ value, current
295
+ ),
296
+ ).props('flat dense color=grey-7')
297
+ ui.button(
298
+ '覆盖', icon='save',
299
+ on_click=lambda value=item, current=dialog: controller._overwrite_favorite(
300
+ value, current
301
+ ),
302
+ ).props('flat dense color=amber-8')
303
+ ui.button(
304
+ '导出', icon='download',
305
+ on_click=lambda value=item: controller._export_favorite(value),
306
+ ).props('flat dense color=purple-7')
307
+ ui.button(
308
+ icon='delete_outline',
309
+ on_click=lambda value=item, current=dialog: controller._confirm_delete_favorite(
310
+ value, current
311
+ ),
312
+ ).props('flat round dense color=red-6')
313
+ dialog.open()
314
+
315
+
316
+ def open_save_favorite_dialog(controller: Any) -> None:
317
+ """显示保存当前工作区为收藏的表单。"""
318
+ if not controller._get_selected_tags():
319
+ ui.notify('当前工作区没有可收藏的标签', type='warning')
320
+ return
321
+ with ui.dialog() as dialog, ui.card().classes('w-full max-w-lg'):
322
+ ui.label('保存当前工作区为收藏').classes('text-lg font-bold')
323
+ name_input = ui.input('收藏名称').props('outlined maxlength=80').classes('w-full')
324
+ notes_input = ui.textarea('备注(可选)').props(
325
+ 'outlined autogrow maxlength=500'
326
+ ).classes('w-full')
327
+
328
+ def save() -> None:
329
+ name = (name_input.value or '').strip()
330
+ if not name:
331
+ ui.notify('请输入收藏名称', type='warning')
332
+ return
333
+ if any(item['name'] == name for item in controller.favorites.get('items', [])):
334
+ ui.notify('已存在同名收藏,请在收藏列表中使用“覆盖”', type='warning')
335
+ return
336
+ if controller._save_current_workspace_as_favorite(
337
+ name, (notes_input.value or '').strip()
338
+ ):
339
+ dialog.close()
340
+
341
+ with ui.row().classes('w-full justify-end gap-2'):
342
+ ui.button('取消', on_click=dialog.close).props('flat')
343
+ ui.button('保存', icon='bookmark_add', on_click=save).props(
344
+ 'unelevated color=teal-7'
345
+ )
346
+ dialog.open()
347
+
348
+
349
+ def open_prompt_import_dialog(controller: Any, *, description: str) -> None:
350
+ """显示 Prompt 粘贴、解析和导入入口。"""
351
+ with ui.dialog() as dialog, ui.card().classes('w-full max-w-3xl'):
352
+ ui.label('导入 Prompt').classes('text-lg font-bold')
353
+ ui.label(description).classes('text-sm text-gray-600')
354
+ prompt_input = ui.textarea(
355
+ label='粘贴 Prompt',
356
+ placeholder='1girl, (white_serafuku:1.2), {rain}, @artist_name',
357
+ ).props('outlined autogrow maxlength=20000').classes('w-full min-h-48')
358
+ import_button = None
359
+
360
+ async def submit_import() -> None:
361
+ text = str(prompt_input.value or '').strip()
362
+ if not text:
363
+ ui.notify('请先粘贴 Prompt', type='warning')
364
+ return
365
+ import_button.disable()
366
+ try:
367
+ result = await controller._resolve_prompt_import_text(text)
368
+ if result.parsed_count == 0:
369
+ ui.notify('没有解析到可导入内容', type='warning')
370
+ import_button.enable()
371
+ return
372
+ added_count, duplicate_count = controller._apply_prompt_import_result(result)
373
+ dialog.close()
374
+ controller._show_prompt_import_summary(result, added_count, duplicate_count)
375
+ except Exception as exc:
376
+ print(f'[UI] Prompt 导入异常: {exc}', flush=True)
377
+ ui.notify('Prompt 导入失败,请检查输入内容', type='negative')
378
+ import_button.enable()
379
+
380
+ with ui.row().classes('w-full justify-end gap-2'):
381
+ ui.button('取消', on_click=dialog.close).props('flat')
382
+ import_button = ui.button(
383
+ '解析并导入', icon='playlist_add', on_click=submit_import,
384
+ ).props('unelevated color=purple-7')
385
+ dialog.open()
386
+
387
+
388
+ def confirm_clear_history(controller: Any, parent_dialog: Any) -> None:
389
+ """确认清空历史,明确不影响收藏与当前工作区。"""
390
+ with ui.dialog() as confirm, ui.card():
391
+ ui.label('确定清空全部搜索历史吗?收藏和当前工作区不会受到影响。')
392
+ with ui.row().classes('w-full justify-end gap-2'):
393
+ ui.button('取消', on_click=confirm.close).props('flat')
394
+
395
+ def clear() -> None:
396
+ controller._clear_all_history()
397
+ confirm.close()
398
+ parent_dialog.close()
399
+ ui.notify('搜索历史已清空', type='positive')
400
+
401
+ ui.button('清空', on_click=clear).props('unelevated color=red-7')
402
+ confirm.open()
403
+
404
+
405
+ def confirm_delete_favorite(controller: Any, favorite: dict, parent_dialog: Any) -> None:
406
+ """确认删除指定收藏。"""
407
+ with ui.dialog() as dialog, ui.card():
408
+ ui.label(f"确定删除收藏“{favorite['name']}”吗?")
409
+ with ui.row().classes('w-full justify-end gap-2'):
410
+ ui.button('取消', on_click=dialog.close).props('flat')
411
+
412
+ def delete() -> None:
413
+ if not controller._delete_favorite(favorite):
414
+ return
415
+ dialog.close()
416
+ parent_dialog.close()
417
+ ui.notify('收藏已删除', type='positive')
418
+
419
+ ui.button('删除', on_click=delete).props('unelevated color=red-7')
420
+ dialog.open()
421
+
422
+
423
+ def confirm_delete_all_personal_data(controller: Any, storage_keys: list[str]) -> None:
424
+ """确认删除当前浏览器保存的个人数据,不涉及服务器或导出的备份文件。"""
425
+ with ui.dialog() as confirm, ui.card().classes('w-full max-w-lg'):
426
+ with ui.row().classes('items-center gap-2'):
427
+ ui.icon('warning', color='negative')
428
+ ui.label('删除所有本地个人数据?').classes('text-lg font-bold text-red-900')
429
+ ui.label(
430
+ '将删除当前浏览器中保存的搜索配置、搜索输入、工作区、已选标签、权重、'
431
+ '搜索历史、收藏以及旧版或损坏数据备份。'
432
+ ).classes('text-sm text-slate-700 leading-relaxed')
433
+ ui.label(
434
+ '此操作无法撤销,但不会删除你已经下载到电脑上的 JSON 备份,也不会修改服务器端匿名聚合统计。'
435
+ ).classes('text-xs text-red-700 bg-red-50 rounded p-3')
436
+
437
+ async def delete_all_personal_data() -> None:
438
+ delete_button.disable()
439
+ keys_json = json.dumps(storage_keys, ensure_ascii=False)
440
+ try:
441
+ await ui.run_javascript(
442
+ f"""(() => {{
443
+ const keys = {keys_json};
444
+ keys.forEach((key) => localStorage.removeItem(key));
445
+ return keys.length;
446
+ }})()""",
447
+ timeout=5.0,
448
+ )
449
+ confirm.close()
450
+ controller.help_dialog.close()
451
+ ui.notify('本地个人数据已删除,页面即将刷新', type='positive', timeout=2000)
452
+ await ui.run_javascript(
453
+ "(() => { setTimeout(() => window.location.reload(), 500); return true; })()",
454
+ timeout=5.0,
455
+ )
456
+ except Exception as exc:
457
+ print(f'[UI] 删除本地个人数据失败: {exc}', flush=True)
458
+ delete_button.enable()
459
+ ui.notify('删除失败,请稍后重试', type='negative')
460
+
461
+ with ui.row().classes('w-full justify-end gap-2 mt-2'):
462
+ ui.button('取消', on_click=confirm.close).props('flat color=grey-7')
463
+ delete_button = ui.button(
464
+ '确认删除',
465
+ icon='delete_forever',
466
+ on_click=delete_all_personal_data,
467
+ ).props('unelevated color=negative no-caps')
468
+ confirm.open()
469
+
470
+
471
+ def open_rename_favorite_dialog(
472
+ controller: Any,
473
+ favorite: dict,
474
+ parent_dialog: Any,
475
+ ) -> None:
476
+ with ui.dialog() as dialog, ui.card().classes('w-full max-w-md'):
477
+ ui.label('重命名收藏').classes('font-bold')
478
+ name_input = ui.input('新名称', value=favorite['name']).props(
479
+ 'outlined maxlength=80'
480
+ ).classes('w-full')
481
+
482
+ def rename() -> None:
483
+ error = controller._rename_favorite_to(
484
+ favorite,
485
+ str(name_input.value or '').strip(),
486
+ )
487
+ if error:
488
+ ui.notify(error, type='warning')
489
+ return
490
+ dialog.close()
491
+ parent_dialog.close()
492
+ controller._open_favorites_dialog()
493
+
494
+ with ui.row().classes('w-full justify-end gap-2'):
495
+ ui.button('取消', on_click=dialog.close).props('flat')
496
+ ui.button('保存', on_click=rename).props('unelevated color=primary')
497
+ dialog.open()
498
+
499
+
500
+ def open_search_feedback_dialog(
501
+ controller: Any,
502
+ *,
503
+ query: str,
504
+ privacy_text: str,
505
+ ) -> None:
506
+ with ui.dialog() as dialog, ui.card().classes('w-full max-w-lg'):
507
+ ui.label('反馈搜索问题').classes('text-base font-bold text-gray-800')
508
+ ui.label(f'当前搜索词:{query}').classes('text-sm text-gray-600')
509
+ ui.label(privacy_text).classes(
510
+ 'text-xs text-slate-500 bg-slate-50 rounded p-2'
511
+ )
512
+ detail_input = ui.textarea(
513
+ label='具体问题(可选)',
514
+ placeholder='例如:结果偏题、缺少某个关键标签、召回了不相关角色/作品...',
515
+ ).props('outlined autogrow maxlength=500 counter').classes('w-full')
516
+
517
+ async def submit_feedback() -> None:
518
+ submit_button.disable()
519
+ try:
520
+ await controller._submit_search_feedback(
521
+ query,
522
+ str(detail_input.value or '').strip(),
523
+ )
524
+ if controller.bad_case_btn is not None:
525
+ controller.bad_case_btn.disable()
526
+ dialog.close()
527
+ ui.notify('感谢反馈!我们会持续优化。', type='positive', timeout=3000)
528
+ except Exception as exc:
529
+ print(f'[UI] bad_case 记录异常: {exc}')
530
+ submit_button.enable()
531
+ ui.notify('记录失败,请稍后再试。', type='warning', timeout=3000)
532
+
533
+ with ui.row().classes('w-full justify-end gap-2'):
534
+ ui.button('取消', on_click=dialog.close).props('flat color=grey-7')
535
+ submit_button = ui.button(
536
+ '提交反馈', on_click=submit_feedback,
537
+ ).props('unelevated color=primary')
538
+ dialog.open()
539
+
540
+
541
+ def open_translation_feedback_dialog(
542
+ controller: Any,
543
+ *,
544
+ row: dict,
545
+ privacy_text: str,
546
+ ) -> None:
547
+ tag = str(row.get('tag') or '').strip()
548
+ current_cn_name = str(row.get('cn_name') or '').strip()
549
+ current_cn_first = current_cn_name.split(',', 1)[0].strip()
550
+
551
+ with ui.dialog() as dialog, ui.card().classes('w-full max-w-lg'):
552
+ ui.label('反馈翻译错误').classes('text-base font-bold text-gray-800')
553
+ ui.label(f'词条:{tag}').classes('text-sm font-mono text-gray-700')
554
+ ui.label(
555
+ f'当前中文名:{current_cn_first or current_cn_name or "(空)"}'
556
+ ).classes('text-sm text-gray-600')
557
+ ui.label(privacy_text).classes(
558
+ 'text-xs text-slate-500 bg-slate-50 rounded p-2'
559
+ )
560
+ suggested_input = ui.input(
561
+ label='建议中文名(可选)',
562
+ placeholder='如果有更合适的译名,可以填在这里',
563
+ ).props('outlined maxlength=120 counter').classes('w-full')
564
+ detail_input = ui.textarea(
565
+ label='问题说明(可选)',
566
+ placeholder='例如:含义不准确、作品/角色名误译、中文名缺失...',
567
+ ).props('outlined autogrow maxlength=500 counter').classes('w-full')
568
+
569
+ async def submit_feedback() -> None:
570
+ submit_button.disable()
571
+ try:
572
+ await controller._submit_translation_feedback(
573
+ row,
574
+ str(suggested_input.value or '').strip(),
575
+ str(detail_input.value or '').strip(),
576
+ )
577
+ dialog.close()
578
+ ui.notify('感谢反馈!这条翻译问题已记录。', type='positive', timeout=3000)
579
+ except Exception as exc:
580
+ print(f'[UI] translation_error 记录异常: {exc}')
581
+ submit_button.enable()
582
+ ui.notify('记录失败,请稍后再试。', type='warning', timeout=3000)
583
+
584
+ with ui.row().classes('w-full justify-end gap-2'):
585
+ ui.button('取消', on_click=dialog.close).props('flat color=grey-7')
586
+ submit_button = ui.button(
587
+ '提交反馈', on_click=submit_feedback,
588
+ ).props('unelevated color=primary')
589
+ dialog.open()
webui/render/page.py ADDED
@@ -0,0 +1,356 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """主页装配视图;只创建控件并连接控制器回调。"""
2
+
3
+ from typing import Any
4
+
5
+ from nicegui import ui
6
+
7
+
8
+ def build_page(controller: Any, *, motion_style: str, sponsor_notice_text: str) -> None:
9
+ controller.client = ui.context.client
10
+ ui.colors(primary='#4A90E2', secondary='#5E6C84', accent='#FF6B6B')
11
+ ui.add_head_html(motion_style)
12
+ ui.add_head_html('''
13
+ <meta name="description" content="基于语义匹配的 Danbooru 标签搜索引擎,支持中英双语描述、多维匹配、智能分词与共现关联推荐。">
14
+ <meta name="keywords" content="Danbooru, AI绘画, Stable Diffusion, 提示词, 标签搜索, RAG, Prompt, NovelAI">
15
+ <meta name="google-site-verification" content="cx4sl9Mb172GUFL556JFwKCP-pT3naQcmlMriy5B8ls" />
16
+
17
+ <style>
18
+ .nsfw-blur-cell { filter: blur(8px); opacity: 0.5; transition: all 0.3s ease;
19
+ pointer-events: none !important; user-select: none !important; }
20
+ .nsfw-checkbox-disabled { pointer-events: none !important; opacity: 0.3 !important; }
21
+ .nsfw-row-blocked { cursor: not-allowed !important; }
22
+ .result-table-flat {
23
+ border-radius: 4px;
24
+ box-shadow: none !important;
25
+ }
26
+ .result-table-flat .q-table th,
27
+ .result-table-flat .q-table td {
28
+ border-color: var(--cell-border);
29
+ }
30
+ .recommendation-grid {
31
+ overflow: hidden;
32
+ background: #ffffff;
33
+ border-top: 1px solid var(--cell-border);
34
+ border-bottom: 1px solid var(--cell-border);
35
+ }
36
+ .recommendation-row + .recommendation-row {
37
+ border-top: 1px solid var(--cell-border);
38
+ }
39
+ .recommendation-cell {
40
+ align-self: stretch;
41
+ display: flex;
42
+ align-items: center;
43
+ }
44
+ .related-item {
45
+ transition: box-shadow var(--motion-fast) var(--motion-ease-out);
46
+ }
47
+ @media (hover: hover) {
48
+ .related-item:hover {
49
+ box-shadow: inset 0 0 0 9999px rgba(15, 23, 42, 0.045);
50
+ }
51
+ }
52
+ .tag-link { text-decoration: none; font-family: 'Consolas', 'Monaco', 'Courier New', monospace; }
53
+ .tag-link:hover { text-decoration: underline; }
54
+ .weight-chip { display: inline-flex; align-items: center; gap: 2px;
55
+ border-radius: 16px; padding: 2px 6px 2px 4px;
56
+ background: #e3edf7; border: 1px solid #b3cde8;
57
+ font-size: 12px; margin: 3px; white-space: nowrap; }
58
+ .weight-chip.boosted { background: #fff3e0; border-color: #ffb74d; }
59
+ .weight-chip.reduced { background: #f3e5f5; border-color: #ce93d8; }
60
+ .weight-btn { cursor: pointer; width: 18px; height: 18px; border-radius: 50%;
61
+ display: inline-flex; align-items: center; justify-content: center;
62
+ font-size: 13px; font-weight: bold; line-height: 1;
63
+ border: none; background: rgba(0,0,0,0.08);
64
+ color: #555; transition: background 0.15s; padding: 0; }
65
+ .weight-btn:hover { background: rgba(0,0,0,0.18); }
66
+ .weight-label { font-family: Consolas, Monaco, monospace; font-size: 11px;
67
+ color: #888; min-width: 28px; text-align: center; }
68
+
69
+ :root {
70
+ --section-surface: #f8fafc;
71
+ --section-border: #dbe4ee;
72
+ --cell-border: #e2e8f0;
73
+ --section-heading: #334155;
74
+ --section-radius: 8px;
75
+ }
76
+ .product-search-card {
77
+ background: #ffffff;
78
+ border: 1px solid var(--section-border);
79
+ border-top: 3px solid #4a90e2;
80
+ border-radius: var(--section-radius);
81
+ box-shadow: none;
82
+ }
83
+ .section-surface {
84
+ box-sizing: border-box;
85
+ background: var(--section-surface);
86
+ border: 1px solid var(--section-border);
87
+ border-radius: var(--section-radius);
88
+ box-shadow: none !important;
89
+ }
90
+ .section-heading {
91
+ color: var(--section-heading);
92
+ font-size: 14px;
93
+ font-weight: 700;
94
+ line-height: 1.4;
95
+ }
96
+ .service-state-panel {
97
+ border-radius: 8px;
98
+ padding: 7px 10px;
99
+ font-size: 12px;
100
+ }
101
+ .service-state-panel.loading {
102
+ background: #eff6ff;
103
+ border: 1px solid #bfdbfe;
104
+ color: #1d4ed8;
105
+ }
106
+ .service-state-panel.ready {
107
+ background: #ecfdf5;
108
+ border: 1px solid #a7f3d0;
109
+ color: #047857;
110
+ }
111
+ .service-state-panel.busy {
112
+ background: #fff7ed;
113
+ border: 1px solid #fed7aa;
114
+ color: #c2410c;
115
+ }
116
+ .query-insight-panel {
117
+ padding: 10px 12px;
118
+ }
119
+ .homepage-support-note {
120
+ color: #64748b;
121
+ font-size: 12px;
122
+ line-height: 1.6;
123
+ }
124
+ .homepage-support-note a {
125
+ color: #4a90e2;
126
+ font-weight: 500;
127
+ text-decoration: none;
128
+ }
129
+ .homepage-support-note a:hover {
130
+ color: #2563a8;
131
+ text-decoration: underline;
132
+ }
133
+ .help-section {
134
+ width: 100%;
135
+ gap: 12px;
136
+ background: #ffffff;
137
+ }
138
+ .help-section-heading {
139
+ display: flex;
140
+ flex-direction: column;
141
+ width: 100%;
142
+ gap: 5px;
143
+ padding: 12px 16px;
144
+ border: 1px solid #b9d7f7;
145
+ border-radius: 8px;
146
+ background: #eef6ff;
147
+ }
148
+ .help-section-heading-title {
149
+ color: #174f91;
150
+ font-size: 15px;
151
+ font-weight: 700;
152
+ line-height: 1.4;
153
+ }
154
+ .help-section-heading-subtitle {
155
+ color: #2563a8;
156
+ font-size: 13px;
157
+ font-weight: 400;
158
+ line-height: 1.55;
159
+ }
160
+ .help-section-heading--documentation {
161
+ border-color: #a7e3c4;
162
+ background: #ecfdf5;
163
+ }
164
+ .help-section-heading--documentation .help-section-heading-title {
165
+ color: #047857;
166
+ }
167
+ .help-section-heading--documentation .help-section-heading-subtitle {
168
+ color: #16855f;
169
+ }
170
+ .help-section-heading--notice {
171
+ border-color: #fed7aa;
172
+ background: #fff7ed;
173
+ }
174
+ .help-section-heading--notice .help-section-heading-title {
175
+ color: #c2410c;
176
+ }
177
+ .help-section-heading--notice .help-section-heading-subtitle {
178
+ color: #b45309;
179
+ }
180
+ .help-content {
181
+ width: 100%;
182
+ color: #334155;
183
+ font-size: 14px;
184
+ line-height: 1.75;
185
+ }
186
+ .help-section > .help-content {
187
+ box-sizing: border-box;
188
+ padding-right: 16px;
189
+ padding-left: 16px;
190
+ }
191
+ .help-content h3,
192
+ .help-content h4 {
193
+ color: #334155;
194
+ }
195
+ .help-link {
196
+ color: #2563a8;
197
+ font-size: 14px;
198
+ line-height: 1.6;
199
+ }
200
+ .help-link:hover {
201
+ color: #174f86;
202
+ text-decoration: underline;
203
+ }
204
+
205
+ /* 强制双栏并排 */
206
+ .two-col-layout {
207
+ display: flex !important;
208
+ flex-wrap: nowrap !important;
209
+ align-items: flex-start !important;
210
+ gap: 16px !important;
211
+ }
212
+ .two-col-layout > .col-left {
213
+ flex: 0 0 62% !important;
214
+ min-width: 0 !important;
215
+ max-width: 62% !important;
216
+ overflow: hidden;
217
+ }
218
+ .two-col-layout > .col-right {
219
+ flex: 0 0 36% !important;
220
+ min-width: 0 !important;
221
+ max-width: 36% !important;
222
+ overflow: hidden;
223
+ }
224
+
225
+ /* 窄屏回退为上下排列 */
226
+ @media (max-width: 900px) {
227
+ .two-col-layout {
228
+ flex-wrap: wrap !important;
229
+ }
230
+ .two-col-layout > .col-left,
231
+ .two-col-layout > .col-right {
232
+ flex: 1 1 100% !important;
233
+ max-width: 100% !important;
234
+ }
235
+ }
236
+
237
+ </style>
238
+ <script async src="https://www.googletagmanager.com/gtag/js?id=G-QPB7EEPR5G"></script>
239
+ <script>
240
+ window.dataLayer = window.dataLayer || [];
241
+ function gtag(){dataLayer.push(arguments);}
242
+ gtag('js', new Date());
243
+ gtag('config', 'G-QPB7EEPR5G');
244
+ </script>
245
+ <script>
246
+ document.addEventListener('DOMContentLoaded', function() {
247
+ function openExternal(root) {
248
+ root.querySelectorAll('a[href^="http"]').forEach(function(a) {
249
+ a.setAttribute('target', '_blank');
250
+ a.setAttribute('rel', 'noopener noreferrer');
251
+ });
252
+ }
253
+ openExternal(document);
254
+ new MutationObserver(function(mutations) {
255
+ mutations.forEach(function(m) {
256
+ m.addedNodes.forEach(function(node) {
257
+ if (node.querySelectorAll) openExternal(node);
258
+ });
259
+ });
260
+ }).observe(document.body, { childList: true, subtree: true });
261
+ });
262
+ </script>
263
+ ''')
264
+
265
+ controller._build_sponsor_dialog()
266
+ controller._build_help_dialog()
267
+
268
+ with ui.column().classes('w-full max-w-7xl mx-auto p-4 gap-3'):
269
+
270
+ # ── 1. 搜索主路径 ──
271
+ controller._build_search_card()
272
+
273
+ # ── 2. 紧凑版本公告 ──
274
+ controller._build_release_announcement()
275
+
276
+ # ── 3. 工作区工具和已选标签(无需搜索即可恢复)──
277
+ controller.workspace_card = ui.card().classes(
278
+ 'w-full p-0 gap-0 overflow-hidden section-surface'
279
+ )
280
+ with controller.workspace_card:
281
+ controller._build_workspace_toolbar()
282
+ controller._build_selection_bar()
283
+
284
+ # ── 4~5. 搜索结果区域(搜索前隐藏)──
285
+ controller.results_section = ui.column().classes('w-full gap-4').props(
286
+ 'id="danbooru-results-section"'
287
+ )
288
+ controller.results_section.set_visibility(False)
289
+
290
+ with controller.results_section:
291
+ # ── 4. 查询理解:来源筛选 + 概念覆盖 ──
292
+ controller.coverage_container = ui.column().classes('w-full gap-2').props(
293
+ 'id="danbooru-coverage-section"'
294
+ )
295
+
296
+ # ── 5. 两栏结果 ──
297
+ controller._build_results_columns()
298
+
299
+ # ── 6. 页脚 ──
300
+ with ui.element('div').classes('w-full text-center py-4 mt-2'):
301
+ controller.search_count_label = ui.html('正在加载数据...').classes('text-xs text-gray-400')
302
+ controller._update_footer_text()
303
+ ui.button(sponsor_notice_text, on_click=controller.sponsor_dialog.open) \
304
+ .props('flat dense no-caps color=grey-6') \
305
+ .classes('text-xs mt-1')
306
+
307
+
308
+ def render_service_status(controller: Any, status: dict) -> None:
309
+ """根据控制器整理好的服务快照更新状态区域。"""
310
+ controller.service_status_container.clear()
311
+ with controller.service_status_container:
312
+ if not status['ready']:
313
+ with ui.row().classes(
314
+ 'w-full items-center gap-2 service-state-panel loading'
315
+ ):
316
+ ui.spinner(size='18px', color='primary')
317
+ ui.label('引擎初始化中,请稍候…约需 5~10 分钟').classes('font-medium')
318
+ return
319
+
320
+ with ui.row().classes(
321
+ f'w-full items-center gap-2 service-state-panel '
322
+ f'{"busy" if status["busy"] else "ready"}'
323
+ ):
324
+ ui.icon(
325
+ 'schedule' if status['busy'] else 'check_circle',
326
+ size='18px',
327
+ color='warning' if status['busy'] else 'positive',
328
+ )
329
+ parts = [
330
+ '服务繁忙' if status['busy'] else '服务可用',
331
+ f'{status["online_sessions"]} 个在线页面',
332
+ ]
333
+ if status['active'] > 0:
334
+ parts.append(f'正在处理 {status["active"]} 个任务')
335
+ if status['waiting'] > 0:
336
+ parts.append(f'等待 {status["waiting"]} 个')
337
+ ui.label(' · '.join(parts)).classes('font-medium')
338
+
339
+
340
+ def build_release_announcement(controller: Any) -> None:
341
+ controller.announcement_banner = ui.element('div').classes(
342
+ 'w-full release-notice section-surface px-3 py-2'
343
+ )
344
+ with controller.announcement_banner:
345
+ with ui.row().classes('w-full items-center justify-between gap-2'):
346
+ with ui.row().classes('items-center gap-2 min-w-0 flex-wrap'):
347
+ ui.icon('new_releases', size='18px', color='primary')
348
+ ui.label(
349
+ '新版已加入标签工作区、Prompt 导入、Alias 纠错和分渠道统计。'
350
+ ).classes('text-sm text-slate-700')
351
+ ui.button(
352
+ '查看详情', on_click=controller.help_dialog.open,
353
+ ).props('flat dense no-caps color=primary').classes('text-xs')
354
+ ui.button(
355
+ icon='close', on_click=controller._dismiss_release_announcement,
356
+ ).props('flat dense round color=grey-6')
webui/render/recommendations.py ADDED
@@ -0,0 +1,463 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """推荐区域的 NiceGUI 容器与分页控件渲染。"""
2
+
3
+ from typing import Any
4
+
5
+ from nicegui import ui
6
+
7
+ from webui.constants import (
8
+ ARTIST_REC_LIMIT,
9
+ ARTIST_REC_PAGE_SIZE,
10
+ GROUP_RENDER_TAG_LIMIT,
11
+ RELATED_REC_PAGE_SIZE,
12
+ )
13
+ from core.workspace_insights import (
14
+ artist_candidate_reason,
15
+ related_candidate_reason,
16
+ tag_group_candidate_reason,
17
+ )
18
+ from webui.helpers import (
19
+ group_names_key,
20
+ group_scroll_dom_id,
21
+ limit_group_render_tags,
22
+ should_group_start_expanded,
23
+ )
24
+ from webui.recommendations import page_count, page_items
25
+
26
+
27
+ def render_related_list(controller: Any, related: list, show_nsfw: bool) -> None:
28
+ """保存关联推荐快照,构建分页控件并渲染第一页。"""
29
+ if controller.related_list_container is None or controller.related_pagination is None:
30
+ return
31
+ controller.related_list_container.clear()
32
+ controller.related_pagination.clear()
33
+ controller._related_checkboxes.clear()
34
+ controller._related_page = 1
35
+ controller._related_page_label = None
36
+ controller._related_prev_button = None
37
+ controller._related_next_button = None
38
+ controller._related_results = [
39
+ item for item in related
40
+ if not (item.nsfw == '1' and not show_nsfw)
41
+ ]
42
+ controller._related_show_nsfw = show_nsfw
43
+ controller._related_page_count = page_count(
44
+ len(controller._related_results), RELATED_REC_PAGE_SIZE
45
+ )
46
+
47
+ if not controller._related_results:
48
+ controller._render_related_page()
49
+ controller._replay_motion(
50
+ 'danbooru-related-recommendations', 'motion-recommendation-enter-right'
51
+ )
52
+ return
53
+
54
+ if controller._related_page_count > 1:
55
+ with controller.related_pagination:
56
+ with ui.row().classes('w-full items-center justify-center gap-2 px-3 py-2'):
57
+ controller._related_prev_button = ui.button(
58
+ '‹',
59
+ on_click=lambda: controller._set_related_page(
60
+ controller._related_page - 1
61
+ ),
62
+ ).props('flat dense round color=grey-7')
63
+ controller._related_page_label = ui.label().classes(
64
+ 'text-xs text-gray-600 min-w-12 text-center'
65
+ )
66
+ controller._related_next_button = ui.button(
67
+ '›',
68
+ on_click=lambda: controller._set_related_page(
69
+ controller._related_page + 1
70
+ ),
71
+ ).props('flat dense round color=grey-7')
72
+
73
+ controller._set_related_page(1)
74
+
75
+
76
+ def render_artist_recommendations(
77
+ controller: Any,
78
+ artist_results: list,
79
+ top_tags: dict | None = None,
80
+ show_nsfw: bool = True,
81
+ ) -> None:
82
+ """保存画师推荐快照,构建分页控件并渲染第一页。"""
83
+ if controller.artist_rec_list is None or controller.artist_rec_pagination is None:
84
+ return
85
+ controller.artist_rec_list.clear()
86
+ controller.artist_rec_pagination.clear()
87
+ controller._artist_rec_checkboxes.clear()
88
+ controller._artist_rec_rows.clear()
89
+ controller._artist_rec_page = 1
90
+ controller._artist_rec_page_label = None
91
+ controller._artist_rec_prev_button = None
92
+ controller._artist_rec_next_button = None
93
+ controller._artist_rec_results = list(artist_results[:ARTIST_REC_LIMIT])
94
+ controller._artist_rec_top_tags = dict(top_tags or {})
95
+ controller._artist_rec_show_nsfw = show_nsfw
96
+ controller._artist_rec_page_count = page_count(
97
+ len(controller._artist_rec_results), ARTIST_REC_PAGE_SIZE
98
+ )
99
+ controller._current_artist_rec_tags = {
100
+ result.artist for result in controller._artist_rec_results
101
+ }
102
+ controller._artist_rec_sources = {
103
+ result.artist: '、'.join(result.sources[:3])
104
+ for result in controller._artist_rec_results
105
+ }
106
+
107
+ if not controller._artist_rec_results:
108
+ controller._render_artist_rec_page()
109
+ controller._replay_motion(
110
+ 'danbooru-artist-recommendations', 'motion-recommendation-enter-right'
111
+ )
112
+ return
113
+
114
+ if controller._artist_rec_page_count > 1:
115
+ with controller.artist_rec_pagination:
116
+ with ui.row().classes('w-full items-center justify-center gap-2 px-3 py-2'):
117
+ controller._artist_rec_prev_button = ui.button(
118
+ '‹',
119
+ on_click=lambda: controller._set_artist_rec_page(
120
+ controller._artist_rec_page - 1
121
+ ),
122
+ ).props('flat dense round color=grey-7')
123
+ controller._artist_rec_page_label = ui.label().classes(
124
+ 'text-xs text-gray-600 min-w-12 text-center'
125
+ )
126
+ controller._artist_rec_next_button = ui.button(
127
+ '›',
128
+ on_click=lambda: controller._set_artist_rec_page(
129
+ controller._artist_rec_page + 1
130
+ ),
131
+ ).props('flat dense round color=grey-7')
132
+
133
+ controller._set_artist_rec_page(1)
134
+
135
+
136
+ def render_related_page(controller: Any) -> None:
137
+ """重建当前关联推荐页,节点数量固定不超过 10 条。"""
138
+ controller.related_list_container.clear()
139
+ controller._related_checkboxes.clear()
140
+
141
+ if not controller._related_results:
142
+ with controller.related_list_container:
143
+ ui.label('暂无推荐').classes('text-sm text-gray-400 italic p-4')
144
+ return
145
+
146
+ selected_now = set(controller._get_selected_tags())
147
+ page_results = page_items(
148
+ controller._related_results,
149
+ controller._related_page,
150
+ RELATED_REC_PAGE_SIZE,
151
+ )
152
+
153
+ with controller.related_list_container:
154
+ for r in page_results:
155
+ tag = r.tag
156
+ cn_first = r.cn_name.split(',')[0].strip() if r.cn_name else ''
157
+ is_selected = tag in selected_now
158
+ score_pct = f'+{r.cooc_score * 100:.0f}%'
159
+
160
+ wiki_text = controller._lookup_tag_wiki(tag)
161
+
162
+ sources_str = '、'.join(
163
+ s.replace('tag_group:', '') for s in r.sources
164
+ ) if r.sources else '—'
165
+ CAT_LABEL = {'General': '通用', 'Character': '角色', 'Copyright': '作品'}
166
+ cat_label = CAT_LABEL.get(r.category, '')
167
+ tooltip_html = ''
168
+ if wiki_text:
169
+ prefix = f'<span style="opacity:0.7;margin-right:4px;">[{cat_label}]</span>' if cat_label else ''
170
+ tooltip_html += f'<div style="margin-bottom:6px;">{prefix}{wiki_text}</div>'
171
+ tooltip_html += (
172
+ f'<div style="opacity:0.85;">'
173
+ f'{r.cn_name}<br>'
174
+ f'共现: {r.cooc_count:,} 相关度: {r.cooc_score:.2f}<br>'
175
+ f'来自选中: {sources_str}'
176
+ f'</div>'
177
+ )
178
+
179
+ # 行背景色按分类区分
180
+ CAT_BG = {
181
+ 'General': 'background-color: rgba(59,130,246,0.06);', # 淡蓝
182
+ 'Character': 'background-color: rgba(34,197,94,0.06);', # 淡绿
183
+ 'Copyright': 'background-color: rgba(168,85,247,0.06);', # 淡紫
184
+ }
185
+ row_bg = CAT_BG.get(r.category, '')
186
+
187
+ # 整行容器,tooltip 挂在行上
188
+ with ui.row().classes(
189
+ 'w-full flex-nowrap items-stretch gap-0 overflow-hidden '
190
+ 'related-item recommendation-row'
191
+ ).style(row_bg):
192
+ # 整行 wiki tooltip
193
+ if tooltip_html:
194
+ with ui.tooltip().props('content-class="bg-black text-white shadow-4" max-width="500px"'):
195
+ ui.html(tooltip_html).style('font-size:14px;line-height:1.5;max-width:480px;')
196
+
197
+ # Checkbox 单元格
198
+ with ui.element('div').classes(
199
+ 'recommendation-cell flex-none justify-center px-2 py-2'
200
+ ):
201
+ cb = ui.checkbox(
202
+ '', value=is_selected,
203
+ on_change=lambda e, t=tag: controller._on_related_checkbox_change(t, e.value)
204
+ ).props('dense').classes('flex-none')
205
+ controller._related_checkboxes[tag] = cb
206
+
207
+ # 标签名(可点击跳转)+ 中文名
208
+ with ui.element('div').classes(
209
+ 'recommendation-cell '
210
+ 'flex-1 min-w-0 overflow-hidden px-3 py-2'
211
+ ):
212
+ with ui.column().classes('w-full gap-0 min-w-0 overflow-hidden'):
213
+ with ui.row().classes('w-full flex-nowrap items-center gap-1 min-w-0 overflow-hidden'):
214
+ link = ui.link(
215
+ tag,
216
+ f'https://danbooru.donmai.us/wiki_pages/{tag}',
217
+ new_tab=True
218
+ ).classes(
219
+ 'tag-link text-primary font-bold text-xs flex-1 min-w-0 truncate'
220
+ )
221
+ link.on('click', controller._mark_interaction)
222
+ if r.sources and r.sources[0].startswith('tag_group:'):
223
+ group_display = r.sources[0].replace('tag_group:', '')
224
+ ui.label(group_display).classes(
225
+ 'text-xs text-orange-500 font-bold bg-orange-50 px-1 rounded'
226
+ )
227
+
228
+ if cn_first:
229
+ ui.label(cn_first).classes('w-full text-xs text-gray-500 truncate')
230
+ ui.label(
231
+ related_candidate_reason(r.sources)
232
+ ).classes('w-full text-xs text-slate-500 truncate')
233
+
234
+ # 关联分数单元格
235
+ with ui.element('div').classes(
236
+ 'recommendation-cell '
237
+ 'flex-none justify-end min-w-16 px-3 py-2'
238
+ ):
239
+ score_color = 'green' if r.cooc_score > 0.6 else ('teal' if r.cooc_score > 0.3 else 'grey')
240
+ ui.label(score_pct).classes(
241
+ f'text-sm font-bold text-{score_color}-600 whitespace-nowrap'
242
+ )
243
+
244
+ def render_artist_page(controller: Any) -> None:
245
+ """重建当前画师页,节点数量固定不超过 ARTIST_REC_PAGE_SIZE。"""
246
+ if controller.artist_rec_list is None:
247
+ return
248
+ controller.artist_rec_list.clear()
249
+ controller._artist_rec_checkboxes.clear()
250
+ controller._artist_rec_rows.clear()
251
+
252
+ if not controller._artist_rec_results:
253
+ with controller.artist_rec_list:
254
+ ui.label('暂无推荐画师').classes('text-sm text-gray-400 italic p-4')
255
+ return
256
+
257
+ selected_now = set(controller._get_selected_tags())
258
+ page_results = page_items(
259
+ controller._artist_rec_results,
260
+ controller._artist_rec_page,
261
+ ARTIST_REC_PAGE_SIZE,
262
+ )
263
+
264
+ with controller.artist_rec_list:
265
+ for r in page_results:
266
+ artist = r.artist
267
+ is_selected = artist in selected_now
268
+ # 归一化:除以命中标签数,cap 到 100%
269
+ normalized = min(r.score / max(r.hit_count, 1), 1.0)
270
+ score_pct = f'+{normalized * 100:.0f}%'
271
+ reason = artist_candidate_reason(r.sources)
272
+ post_str = f'{r.post_count:,}' if r.post_count else '—'
273
+
274
+ # tooltip:画师擅长标签
275
+ tag_list = controller._artist_rec_top_tags.get(artist, [])
276
+ tooltip_html = f'<div><b>{artist}</b><br>这位画师经常画:<br>'
277
+ if tag_list:
278
+ for t in tag_list[:10]:
279
+ tooltip_html += f' · {t}<br>'
280
+ else:
281
+ tooltip_html += ' (无数据)'
282
+ tooltip_html += '</div>'
283
+
284
+ with ui.row().classes(
285
+ 'w-full flex-nowrap items-stretch gap-0 overflow-hidden '
286
+ 'related-item recommendation-row'
287
+ ).style('background: rgba(244,114,182,0.04);') as row:
288
+ controller._artist_rec_rows.append(row)
289
+ # tooltip
290
+ with ui.tooltip().props('content-class="bg-black text-white shadow-4" max-width="400px"'):
291
+ ui.html(tooltip_html).style('font-size:14px;line-height:1.5;max-width:380px;')
292
+
293
+ # Checkbox 单元格
294
+ with ui.element('div').classes(
295
+ 'recommendation-cell flex-none justify-center px-2 py-2'
296
+ ):
297
+ cb = ui.checkbox(
298
+ '', value=is_selected,
299
+ on_change=lambda e, t=artist: controller._on_artist_rec_checkbox_change(t, e.value)
300
+ ).props('dense')
301
+ controller._artist_rec_checkboxes[artist] = cb
302
+
303
+ # 画师名 + 信息
304
+ with ui.element('div').classes(
305
+ 'recommendation-cell '
306
+ 'flex-grow min-w-0 overflow-hidden px-3 py-2'
307
+ ):
308
+ with ui.column().classes('w-full gap-0 min-w-0'):
309
+ ui.link(
310
+ artist,
311
+ f'https://danbooru.donmai.us/posts?tags={artist}',
312
+ new_tab=True,
313
+ ).classes('text-primary font-bold text-xs')
314
+ ui.label(reason).classes('text-xs text-slate-500')
315
+ ui.label(f'作品 {post_str}').classes('text-xs text-gray-400')
316
+
317
+ # 分值单元格
318
+ with ui.element('div').classes(
319
+ 'recommendation-cell '
320
+ 'flex-none justify-end min-w-16 px-3 py-2'
321
+ ):
322
+ score_color = 'green' if normalized > 0.6 else ('teal' if normalized > 0.3 else 'grey')
323
+ ui.label(score_pct).classes(
324
+ f'text-sm font-bold text-{score_color}-600 whitespace-nowrap'
325
+ )
326
+
327
+ def render_group_expansion(controller: Any, group_data: list, selected_tags: list[str], show_nsfw: bool) -> None:
328
+ """渲染 Group 同类扩展区域。"""
329
+ if controller.group_expansion_container is None:
330
+ return
331
+ controller.group_expansion_container.clear()
332
+ controller._group_checkboxes.clear()
333
+ controller._group_candidate_sources.clear()
334
+ group_key = group_names_key(group_data)
335
+ if group_key != controller._group_render_key:
336
+ controller._group_render_key = group_key
337
+ controller._group_render_limits.clear()
338
+ controller._group_expanded_names.clear()
339
+ controller._group_scroll_positions.clear()
340
+
341
+ if not group_data:
342
+ with controller.group_expansion_container:
343
+ ui.label('已选标签无分组信息').classes('text-sm text-gray-400 italic p-2')
344
+ controller._replay_motion('danbooru-group-expansion', 'motion-refresh-enter')
345
+ return
346
+
347
+ # 行背景色按分类区分(与关联推荐一致)
348
+ CAT_BG = {
349
+ 'General': 'background-color: rgba(59,130,246,0.06);',
350
+ 'Character': 'background-color: rgba(34,197,94,0.06);',
351
+ 'Copyright': 'background-color: rgba(168,85,247,0.06);',
352
+ }
353
+ CAT_LABEL = {'General': '通用', 'Character': '角色', 'Copyright': '作品'}
354
+
355
+ selected_now = set(controller._get_selected_tags())
356
+
357
+ with controller.group_expansion_container:
358
+ for group_info in group_data:
359
+ group_name = group_info['group']
360
+ group_cn = group_info.get('group_cn_name', group_name.replace('tag_group:', ''))
361
+ group_sources = list(group_info.get('sources') or [])
362
+ group_reason = tag_group_candidate_reason(group_cn, group_sources)
363
+ group_source_detail = group_cn
364
+ if group_sources:
365
+ group_source_detail += f";触发标签:{'、'.join(group_sources[:3])}"
366
+ tags = group_info['tags']
367
+ visible_limit = controller._group_render_limits.get(group_name, GROUP_RENDER_TAG_LIMIT)
368
+ visible_tags, hidden_count = limit_group_render_tags(tags, visible_limit)
369
+ scroll_id = group_scroll_dom_id(group_name)
370
+
371
+ expansion = ui.expansion(
372
+ f'{group_cn} ({len(tags)} 个标签)',
373
+ icon='label',
374
+ value=should_group_start_expanded(group_name, controller._group_expanded_names),
375
+ ).classes('w-full').props('dense')
376
+ expansion.on(
377
+ 'update:model-value',
378
+ lambda e, g=group_name: controller._on_group_expansion_change(g, e),
379
+ )
380
+ with expansion:
381
+ with ui.element('div').props(
382
+ f'id="{scroll_id}" data-danbooru-group-scroll="1"'
383
+ ).classes('w-full grid grid-cols-2 gap-1 p-1').style('max-height: 600px; overflow-y: auto;'):
384
+ for t in visible_tags:
385
+ tag = t['tag']
386
+ controller._group_candidate_sources.setdefault(tag, group_source_detail)
387
+ cn_first = t['cn_name'].split(',')[0].strip() if t['cn_name'] else ''
388
+ cn_full = t.get('cn_name', '')
389
+ cat = t['category']
390
+ wiki_text = str(t.get('wiki', ''))
391
+ row_bg = CAT_BG.get(cat, '')
392
+ is_selected = tag in selected_now
393
+
394
+ cat_label = CAT_LABEL.get(cat, '')
395
+ tooltip_html = ''
396
+ if wiki_text:
397
+ prefix = f'<span style="opacity:0.7;margin-right:4px;">[{cat_label}]</span>' if cat_label else ''
398
+ tooltip_html += f'<div style="margin-bottom:6px;">{prefix}{wiki_text}</div>'
399
+ if cn_full:
400
+ tooltip_html += f'<div style="opacity:0.85;">{cn_full}</div>'
401
+
402
+ with ui.row().classes(
403
+ 'w-full min-w-0 flex-nowrap items-center gap-1.5 px-2 py-1.5 '
404
+ 'rounded overflow-hidden related-item'
405
+ ).style(row_bg):
406
+ if tooltip_html:
407
+ with ui.tooltip().props('content-class="bg-black text-white shadow-4" max-width="500px"'):
408
+ ui.html(tooltip_html).style('font-size:14px;line-height:1.5;max-width:480px;')
409
+
410
+ # 复选框
411
+ cb = ui.checkbox(
412
+ '', value=is_selected,
413
+ on_change=lambda e, t=tag: controller._on_group_checkbox_change(t, e.value),
414
+ ).props('dense').classes('flex-none')
415
+ controller._group_checkboxes[tag] = cb
416
+
417
+ # 标签名 + 中文名(与关联推荐对齐方式一致)
418
+ with ui.column().classes('flex-1 gap-0 min-w-0 overflow-hidden'):
419
+ link = ui.link(
420
+ tag,
421
+ f'https://danbooru.donmai.us/wiki_pages/{tag}',
422
+ new_tab=True,
423
+ ).classes(
424
+ 'tag-link w-full min-w-0 text-primary font-bold text-xs truncate'
425
+ )
426
+ if cn_first:
427
+ ui.label(cn_first).classes(
428
+ 'w-full text-xs text-gray-500 truncate'
429
+ )
430
+ ui.label(group_reason).classes(
431
+ 'w-full text-xs text-slate-500 truncate'
432
+ )
433
+
434
+ # 热度
435
+ count = t['post_count']
436
+ if count > 0:
437
+ if count >= 10000:
438
+ count_str = f'{count/1000:.0f}k'
439
+ elif count >= 1000:
440
+ count_str = f'{count/1000:.1f}k'
441
+ else:
442
+ count_str = str(count)
443
+ ui.label(count_str).classes(
444
+ 'flex-none ml-auto self-center text-sm font-bold '
445
+ 'text-grey-600 whitespace-nowrap'
446
+ )
447
+ if hidden_count > 0:
448
+ async def _load_more(
449
+ g=group_name,
450
+ total=len(tags),
451
+ gd=group_data,
452
+ st=list(selected_tags),
453
+ sn=show_nsfw,
454
+ ):
455
+ await controller._load_more_group_tags(g, total, gd, st, sn)
456
+
457
+ ui.button(
458
+ f'加载更多(剩余 {hidden_count} 个)',
459
+ icon='expand_more',
460
+ on_click=_load_more,
461
+ ).props('dense flat color=primary').classes('col-span-2 text-xs')
462
+ controller._restore_group_scroll_positions()
463
+ controller._replay_motion('danbooru-group-expansion', 'motion-refresh-enter')
webui/render/results_panel.py ADDED
@@ -0,0 +1,280 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """结果区域的 NiceGUI 布局、查询理解和表格显示逻辑。"""
2
+
3
+ from typing import Any
4
+
5
+ from nicegui import ui
6
+
7
+ from core.workspace_insights import (
8
+ CANDIDATE_UNSELECTED,
9
+ COVERED,
10
+ UNCOVERED,
11
+ compute_concept_coverage,
12
+ )
13
+ from webui.constants import OPTIONAL_COLS, TABLE_COLUMNS
14
+ from webui.helpers import apply_nsfw_filter
15
+
16
+
17
+ RESULT_TABLE_BODY = r'''
18
+ <q-tr :props="props"
19
+ :class="props.row._nsfw_blocked ? 'nsfw-row-blocked' : ''"
20
+ :style="{
21
+ 'background-color':
22
+ props.row.layer === 'artist' ? 'rgba(244,114,182,0.08)' :
23
+ props.row.category === 'General' ? 'rgba(59,130,246,0.06)' :
24
+ props.row.category === 'Character' ? 'rgba(34,197,94,0.06)' :
25
+ props.row.category === 'Copyright' ? 'rgba(168,85,247,0.06)' : ''
26
+ }">
27
+ <q-td auto-width>
28
+ <q-checkbox v-model="props.selected"
29
+ :class="props.row._nsfw_blocked ? 'nsfw-checkbox-disabled' : ''"/>
30
+ </q-td>
31
+ <q-td v-for="col in props.cols" :key="col.name" :props="props">
32
+ <template v-if="col.name === 'tag' || col.name === 'cn_name'">
33
+ <div :class="props.row._nsfw_blocked ? 'nsfw-blur-cell' : ''">
34
+ <template v-if="col.name === 'cn_name' && col.value && props.row.layer !== 'artist'">
35
+ <span style="font-size:14px;display:inline-flex;align-items:center;gap:4px;">
36
+ <span>{{ col.value.split(',')[0] }}</span>
37
+ <q-btn icon="report_problem"
38
+ size="sm"
39
+ dense flat round
40
+ color="grey-5"
41
+ padding="xs"
42
+ @click.stop.prevent="console.debug('[DanbooruSearch] translation_feedback click', props.row); $parent.$emit('translation_feedback', props.row)">
43
+ <q-tooltip>反馈翻译错误</q-tooltip>
44
+ </q-btn>
45
+ </span>
46
+ </template>
47
+ <template v-else-if="col.name === 'tag'">
48
+ <a :href="'https://danbooru.donmai.us/wiki_pages/'+col.value"
49
+ target="_blank"
50
+ class="text-primary hover:underline font-bold inline-flex items-center"
51
+ style="text-decoration:none; font-family: Consolas, Monaco, Courier New, monospace;"
52
+ @click.stop="$emit('link_click', col.value)">
53
+ {{ col.value }}
54
+ <q-icon name="open_in_new" size="xs" class="q-ml-xs opacity-50"/>
55
+ </a>
56
+ </template>
57
+ <template v-else>{{ col.value }}</template>
58
+ </div>
59
+ </template>
60
+ <template v-else-if="col.name === 'nsfw'">
61
+ <div v-if="col.value === '1'" class="text-red-500">🔴</div>
62
+ <div v-else class="text-green-500">🟢</div>
63
+ </template>
64
+ <template v-else-if="col.name === 'final_score'">
65
+ <q-badge :color="col.value > 0.6 ? 'green' : (col.value > 0.5 ? 'teal' : 'orange')">
66
+ {{ col.value }}
67
+ </q-badge>
68
+ </template>
69
+ <template v-else>{{ col.value }}</template>
70
+ </q-td>
71
+ <q-tooltip v-if="props.row.layer === 'artist' && props.row.artist_top_tags && props.row.artist_top_tags.length && !props.row._nsfw_blocked"
72
+ content-class="bg-black text-white shadow-4"
73
+ max-width="400px" :offset="[10,10]">
74
+ <div style="font-size:14px;line-height:1.5;max-width:380px;">
75
+ <b>{{ props.row.tag }}</b><br>这位画师经常画:<br>
76
+ <template v-for="tag in props.row.artist_top_tags.slice(0, 10)" :key="tag">
77
+ &nbsp;&nbsp;· {{ tag }}<br>
78
+ </template>
79
+ </div>
80
+ </q-tooltip>
81
+ <q-tooltip v-else-if="(props.row.wiki || props.row.cn_name) && !props.row._nsfw_blocked"
82
+ content-class="bg-black text-white shadow-4"
83
+ max-width="500px" :offset="[10,10]">
84
+ <div style="font-size:14px;line-height:1.5;">
85
+ <span style="opacity:0.7;margin-right:4px;">{{
86
+ props.row.category === 'General' ? '[通用]' :
87
+ props.row.category === 'Character' ? '[角色]' :
88
+ props.row.category === 'Copyright' ? '[作品]' : ''
89
+ }}</span>{{ props.row.wiki }}
90
+ <div v-if="props.row.cn_name"
91
+ style="margin-top:6px;opacity:0.85;">{{ props.row.cn_name }}</div>
92
+ </div>
93
+ </q-tooltip>
94
+ </q-tr>
95
+ '''
96
+
97
+
98
+ def build_results_columns(controller: Any) -> None:
99
+ """构建搜索结果与三个推荐区的静态容器。"""
100
+ controller.two_col_container = ui.element('div').classes('w-full two-col-layout')
101
+ with controller.two_col_container:
102
+ with ui.card().classes('col-left section-surface'):
103
+ with ui.row().classes('items-center justify-between mb-2 w-full'):
104
+ ui.label('匹配标签结果').classes('section-heading')
105
+ ui.button('复制全部标签', icon='content_copy', on_click=controller._copy_all_tags) \
106
+ .props('dense flat color=primary').classes('text-sm')
107
+
108
+ controller.result_table = ui.table(
109
+ columns=TABLE_COLUMNS,
110
+ rows=[],
111
+ pagination=0,
112
+ selection='multiple',
113
+ row_key='tag',
114
+ ).props('flat separator=horizontal').classes('w-full result-table-flat')
115
+ controller.result_table.on('selection', controller._update_selection_display)
116
+ controller.result_table.on('link_click', controller._mark_interaction)
117
+ controller.result_table.on('translation_feedback', controller.report_translation_error)
118
+ controller.result_table.on('pagination', lambda _: controller._save_config())
119
+ controller.result_table.add_slot('body', RESULT_TABLE_BODY)
120
+
121
+ ui.separator().classes('my-2')
122
+ with ui.row().classes('items-center justify-between w-full mb-1'):
123
+ with ui.row().classes('items-center gap-2'):
124
+ ui.label('同类标签').classes('font-bold text-sm text-gray-600')
125
+ with ui.icon('info_outline', size='xs', color='grey').classes('cursor-help'):
126
+ with ui.tooltip().props('content-class="bg-black text-white shadow-4"'):
127
+ ui.label('基于标签分组数据,展示已选标签所属分组中的其他标签。勾选可加入已选。').style('font-size:14px;')
128
+ ui.button('根据已选刷新', icon='refresh', on_click=controller._manual_refresh_group) \
129
+ .props('dense flat color=primary').classes('text-sm')
130
+ controller.group_expansion_container = ui.column().classes('w-full gap-0')
131
+ controller.group_expansion_container.props('id="danbooru-group-expansion"')
132
+ with controller.group_expansion_container:
133
+ ui.label('请先搜索并勾选标签…').classes('text-sm text-gray-400 italic p-4')
134
+
135
+ with ui.card().classes('col-right section-surface'):
136
+ with ui.row().classes('items-center justify-between w-full mb-2'):
137
+ with ui.row().classes('items-center gap-2'):
138
+ ui.label('推荐擅长画师(Beta)').classes('section-heading')
139
+ with ui.icon('info_outline', size='sm', color='grey').classes('cursor-help'):
140
+ with ui.tooltip().props('content-class="bg-black text-white shadow-4"'):
141
+ ui.html(
142
+ '基于标签-画师 NPMI 共现数据,根据您当前已选的标签,推荐擅长这些元素的画师。<br>悬停画师行可查看与该画师共现关联最强的标签。'
143
+ ).style('font-size:14px;line-height:1.5;')
144
+ controller.artist_rec_list = ui.column().classes(
145
+ 'w-full gap-0 recommendation-grid'
146
+ ).props('id="danbooru-artist-recommendations"')
147
+ with controller.artist_rec_list:
148
+ ui.label('请先搜索并勾选标签…').classes('text-sm text-gray-400 italic p-4')
149
+ controller.artist_rec_pagination = ui.column().classes('w-full')
150
+
151
+ ui.separator().classes('my-3')
152
+ with ui.row().classes('items-center justify-between w-full mb-2'):
153
+ with ui.row().classes('items-center gap-2'):
154
+ ui.label('关联推荐').classes('section-heading')
155
+ with ui.icon('info_outline', size='sm', color='grey').classes('cursor-help'):
156
+ with ui.tooltip().props('content-class="bg-black text-white shadow-4"'):
157
+ ui.html(
158
+ '基于标签共现数据,发掘语义之外的相关性,为您推荐更多可能的标签。<br>勾选可加入或移出已选。如需根据最新选项更新推荐,请点击刷新按钮。'
159
+ ).style('font-size:14px;line-height:1.5;')
160
+ ui.button('根据已选刷新', icon='refresh', on_click=controller._manual_refresh_related) \
161
+ .props('dense flat color=primary').classes('text-sm')
162
+ controller.related_list_container = ui.column().classes(
163
+ 'w-full gap-0 recommendation-grid'
164
+ ).props('id="danbooru-related-recommendations"')
165
+ with controller.related_list_container:
166
+ ui.label('请先搜索并勾选标签…').classes('text-sm text-gray-400 italic p-4')
167
+ controller.related_pagination = ui.column().classes('w-full')
168
+
169
+
170
+ def filter_by_source(controller: Any, keyword: str) -> None:
171
+ """按查询概念过滤表格,并重建查询理解卡片。"""
172
+ controller.current_filter_keyword = keyword if keyword else 'ALL'
173
+ show_nsfw = controller.input_nsfw.value
174
+ if not keyword or keyword == 'ALL':
175
+ filtered = controller.full_table_data
176
+ else:
177
+ filtered = [row for row in controller.full_table_data if row['source'] == keyword]
178
+ controller.result_table.rows = apply_nsfw_filter(filtered, show_nsfw)
179
+ controller._render_concept_coverage()
180
+
181
+
182
+ def render_concept_coverage(controller: Any) -> None:
183
+ """渲染查询概念覆盖状态与概念筛选入口。"""
184
+ if controller.coverage_container is None:
185
+ return
186
+ controller.coverage_container.clear()
187
+ if not controller.current_query_str:
188
+ return
189
+
190
+ segments = list(dict.fromkeys(controller.current_segments + controller.current_keywords))
191
+ if not segments:
192
+ segments = [controller.current_query_str]
193
+ coverage = compute_concept_coverage(
194
+ segments, controller.full_table_data, controller._get_selected_tags()
195
+ )
196
+ if not coverage:
197
+ return
198
+
199
+ status_counts = {
200
+ COVERED: sum(item.status == COVERED for item in coverage),
201
+ CANDIDATE_UNSELECTED: sum(item.status == CANDIDATE_UNSELECTED for item in coverage),
202
+ UNCOVERED: sum(item.status == UNCOVERED for item in coverage),
203
+ }
204
+
205
+ def decorate_chip(chip: Any, source: str) -> None:
206
+ if source in controller.current_cached_queries:
207
+ chip.style('outline: 1px dashed rgba(100,116,139,0.45); outline-offset: 1px;')
208
+ if source == controller.current_filter_keyword:
209
+ chip.style('box-shadow: 0 0 0 2px #4a90e2;')
210
+
211
+ with controller.coverage_container:
212
+ with ui.element('div').classes('w-full query-insight-panel section-surface'):
213
+ with ui.row().classes('w-full items-center gap-2 flex-wrap'):
214
+ ui.icon('manage_search', color='primary', size='sm')
215
+ ui.label('查询理解').classes('section-heading')
216
+ ui.label(
217
+ f"已覆盖 {status_counts[COVERED]} · "
218
+ f"有候选 {status_counts[CANDIDATE_UNSELECTED]} · "
219
+ f"未覆盖 {status_counts[UNCOVERED]}"
220
+ ).classes('text-xs text-slate-500')
221
+ with ui.icon('info_outline', size='xs', color='grey').classes('cursor-help'):
222
+ with ui.tooltip().props('content-class="bg-black text-white shadow-4"'):
223
+ ui.label(
224
+ '点击概念可筛选对应结果;红色未覆盖项点击后会发起补充搜索。'
225
+ '覆盖状态根据当前结果来源和工作区已选标签近似判断。'
226
+ ).style('font-size:13px;')
227
+
228
+ with ui.row().classes('w-full gap-2 flex-wrap mt-2'):
229
+ all_chip = ui.chip('全部结果', on_click=lambda: controller._filter_by_source('ALL'))
230
+ if controller.current_filter_keyword == 'ALL':
231
+ all_chip.props('color=primary text-color=white clickable')
232
+ else:
233
+ all_chip.props('color=grey-3 text-color=grey-9 clickable')
234
+
235
+ use_segmentation = controller.input_segment.value if controller.input_segment else True
236
+ if use_segmentation and controller.current_query_str not in segments:
237
+ whole_chip = ui.chip(
238
+ '整句',
239
+ on_click=lambda: controller._filter_by_source(controller.current_query_str),
240
+ ).props('color=blue-grey-1 text-color=blue-grey-9 clickable')
241
+ decorate_chip(whole_chip, controller.current_query_str)
242
+
243
+ for item in coverage:
244
+ if item.status == COVERED:
245
+ chip = ui.chip(
246
+ item.segment,
247
+ icon='check_circle',
248
+ on_click=lambda s=item.segment: controller._filter_by_source(s),
249
+ ).props('color=green-1 text-color=green-9 clickable')
250
+ detail = f"已覆盖;已选择:{'、'.join(item.selected_tags)}。点击筛选此概念的搜索结果。"
251
+ elif item.status == CANDIDATE_UNSELECTED:
252
+ chip = ui.chip(
253
+ item.segment,
254
+ icon='radio_button_unchecked',
255
+ on_click=lambda s=item.segment: controller._filter_by_source(s),
256
+ ).props('color=amber-1 text-color=amber-9 clickable')
257
+ detail = f"有候选:{'、'.join(item.candidate_tags[:5])}。点击筛选此概念的搜索结果。"
258
+ else:
259
+ chip = ui.chip(
260
+ item.segment,
261
+ icon='search',
262
+ on_click=lambda s=item.segment: controller._search_uncovered_segment(s),
263
+ ).props('color=red-1 text-color=red-8 clickable')
264
+ detail = '点击后沿用当前搜索设置进行补充搜索;工作区标签保持不变。'
265
+ decorate_chip(chip, item.segment)
266
+ with chip:
267
+ with ui.tooltip().props('content-class="bg-black text-white shadow-4"'):
268
+ ui.label(detail).style('font-size:13px;')
269
+
270
+
271
+ def update_table_columns(controller: Any, _event: Any = None) -> None:
272
+ """按显示开关更新结果表的可选列。"""
273
+ columns = list(TABLE_COLUMNS)
274
+ if controller.sw_semantic and controller.sw_semantic.value:
275
+ columns.append(OPTIONAL_COLS['semantic'])
276
+ if controller.sw_layer and controller.sw_layer.value:
277
+ columns.append(OPTIONAL_COLS['layer'])
278
+ if controller.sw_source and controller.sw_source.value:
279
+ columns.append(OPTIONAL_COLS['source'])
280
+ controller.result_table.columns = columns
webui/render/search_panel.py ADDED
@@ -0,0 +1,174 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """搜索卡与搜索参数区域的 NiceGUI 视图。"""
2
+
3
+ from typing import Any
4
+
5
+ from nicegui import ui
6
+
7
+ from platform_utils import nsfw_allowed
8
+ from webui.constants import SEARCH_MODE_OPTIONS
9
+
10
+
11
+ def build_search_panel(controller: Any) -> None:
12
+ with ui.card().classes('w-full product-search-card'):
13
+ with ui.row().classes('w-full items-start justify-between gap-3 mb-1'):
14
+ with ui.row().classes('items-center gap-2'):
15
+ ui.icon('search', size='2em', color='primary')
16
+ ui.label('Danbooru 标签模糊搜索').classes('text-2xl font-bold text-gray-800')
17
+ ui.button(
18
+ '帮助 / 关于', icon='help_outline', on_click=controller.help_dialog.open,
19
+ ).props('flat dense no-caps color=grey-7').classes('text-sm')
20
+ ui.label(
21
+ '基于语义匹配的标签搜索引擎,支持多维匹配与共现关联推荐。'
22
+ ).classes('text-sm text-gray-500 -mt-1 mb-1')
23
+ with ui.row().classes(
24
+ 'w-full items-center justify-between gap-x-4 gap-y-1 flex-wrap mb-3'
25
+ ):
26
+ ui.link(
27
+ '查看工具链介绍 / 使用指南 →',
28
+ 'http://intro.sakizuki.site/index.html',
29
+ new_tab=True,
30
+ ).classes('text-sm text-blue-600 hover:text-blue-800 font-medium')
31
+ ui.html(
32
+ '觉得好用?给 '
33
+ '<a href="https://huggingface.co/spaces/SAkizuki/DanbooruSearch">'
34
+ 'Space 点个 Like ❤️</a>,或到 '
35
+ '<a href="https://github.com/SuzumiyaAkizuki/DanbooruSearchOnline">'
36
+ 'GitHub 点 Star ⭐</a>'
37
+ ).classes('homepage-support-note')
38
+
39
+ with ui.row().classes('w-full gap-3 items-stretch'):
40
+ controller.search_input = ui.textarea(
41
+ placeholder='输入自然语言描述或模糊概念,例如:一个穿着白色水手服的少女在雨中奔跑...'
42
+ ).classes('flex-grow text-base motion-search-input').props('outlined rows=2')
43
+ controller.search_input.on('keydown.ctrl.enter', controller.perform_search)
44
+
45
+ with ui.column().classes('justify-center'):
46
+ controller.search_btn = ui.button(
47
+ '', on_click=controller.perform_search, icon='search'
48
+ ).classes('px-6 h-full min-h-16 motion-search-button').props('unelevated color=dark')
49
+ with controller.search_btn:
50
+ ui.label('搜索').classes('text-sm mt-1')
51
+ controller.spinner = ui.spinner(size='2em').classes(
52
+ 'hidden motion-search-spinner'
53
+ )
54
+
55
+ controller.search_params_row = ui.row().classes(
56
+ 'w-full gap-6 items-center mt-3 flex-wrap'
57
+ )
58
+ with controller.search_params_row:
59
+ with ui.row().classes('items-center gap-2'):
60
+ ui.label('搜索模式 (beta)').classes('text-sm text-gray-600')
61
+ controller.input_search_mode = ui.select(
62
+ SEARCH_MODE_OPTIONS, value='自定义',
63
+ ).classes('w-28').props('outlined dense')
64
+ controller.input_search_mode.on(
65
+ 'update:model-value', controller._on_search_mode_change
66
+ )
67
+ with ui.tooltip().props('content-class="bg-black text-white shadow-4"'):
68
+ ui.label('选择模式自动填充对应参数;手动修改参数后自动变为「自定义」').style('font-size:14px;')
69
+
70
+ with ui.row().classes('items-center gap-2'):
71
+ ui.label('Top K (语义相关)').classes('text-sm text-gray-600')
72
+ controller.input_top_k = ui.number(value=10, min=1, max=200).classes('w-20') \
73
+ .props('outlined dense')
74
+ controller.input_top_k.on('update:model-value', controller._on_param_changed)
75
+
76
+ with ui.row().classes('items-center gap-2'):
77
+ ui.label('结果上限').classes('text-sm text-gray-600')
78
+ controller.input_limit = ui.number(value=80, min=10, max=500).classes('w-20') \
79
+ .props('outlined dense')
80
+ controller.input_limit.on('update:model-value', controller._on_param_changed)
81
+
82
+ with ui.switch('显示 NSFW(成人) 内容', value=False).props('color=red') as nsfw_switch:
83
+ if not nsfw_allowed():
84
+ with ui.tooltip().props('content-class="bg-black text-white shadow-4"'):
85
+ ui.label('NSFW 内容在当前平台不可用').style('font-size:14px;')
86
+ controller.input_nsfw = nsfw_switch
87
+ if not nsfw_allowed():
88
+ controller.input_nsfw.disable()
89
+ else:
90
+ controller.input_nsfw.on('update:model-value', controller.on_nsfw_toggle)
91
+
92
+ with ui.switch('智能分词', value=True).props('color=primary') as segment_switch:
93
+ with ui.tooltip().props('content-class="bg-black text-white shadow-4"'):
94
+ ui.label('关闭后系统将只匹配完整句子,适用于精准搜索整句。').style('font-size:14px;')
95
+ controller.input_segment = segment_switch
96
+ controller.input_segment.on('update:model-value', controller._on_param_changed)
97
+
98
+ controller.advanced_options = ui.expansion('高级选项', icon='tune').classes('w-full mt-2')
99
+ with controller.advanced_options:
100
+ with ui.column().classes('w-full p-3 gap-4'):
101
+ with ui.row().classes('items-center gap-2'):
102
+ ui.label('热度权重').classes('text-sm font-bold text-gray-700')
103
+ controller.input_weight = ui.slider(
104
+ min=0.0, max=1.0, value=0.15, step=0.05,
105
+ ).classes('w-40')
106
+ ui.label().bind_text_from(
107
+ controller.input_weight, 'value', lambda value: f'{value:.2f}',
108
+ ).classes('text-sm font-mono text-gray-700 w-8')
109
+ controller.input_weight.on('update:model-value', controller._on_param_changed)
110
+
111
+ with ui.row().classes('w-full gap-8 flex-wrap'):
112
+ with ui.column().classes('gap-2'):
113
+ ui.label('匹配层筛选').classes('font-bold text-sm text-gray-700')
114
+ display_map = {
115
+ '英文': '英文标签', '中文扩展词': '中文扩展词',
116
+ '释义': '维基释义', '中文核心词': '中文核心词',
117
+ 'artist': 'artist',
118
+ }
119
+ for layer in ['英文', '中文扩展词', '释义', '中文核心词', 'artist']:
120
+ checkbox = ui.checkbox(
121
+ display_map.get(layer, layer), value=True,
122
+ on_change=lambda event, current=layer:
123
+ controller.selected_layers.__setitem__(current, event.value),
124
+ ).props('color=primary dense')
125
+ controller._layer_checkboxes[layer] = checkbox
126
+
127
+ with ui.column().classes('gap-2'):
128
+ ui.label('类型筛选').classes('font-bold text-sm text-gray-700')
129
+ color_map = {'General': 'blue', 'Copyright': 'purple', 'Character': 'green'}
130
+ label_map = {
131
+ 'General': '通用 (General)',
132
+ 'Copyright': '作品 (Copyright)',
133
+ 'Character': '角色 (Character)',
134
+ }
135
+ for category in ['General', 'Copyright', 'Character']:
136
+ checkbox = ui.checkbox(
137
+ label_map[category], value=True,
138
+ on_change=lambda event, current=category:
139
+ controller.selected_cats.__setitem__(current, event.value),
140
+ ).props(f'color={color_map[category]} dense')
141
+ controller._cat_checkboxes[category] = checkbox
142
+
143
+ with ui.column().classes('gap-2'):
144
+ ui.label('表格显示列').classes('font-bold text-sm text-gray-700')
145
+ controller.sw_semantic = ui.switch('显示语义分', value=False)
146
+ controller.sw_layer = ui.switch('显示匹配层', value=False)
147
+ controller.sw_source = ui.switch('显示匹配来源', value=False)
148
+ controller.sw_semantic.on('update:model-value', controller._update_table_columns)
149
+ controller.sw_layer.on('update:model-value', controller._update_table_columns)
150
+ controller.sw_source.on('update:model-value', controller._update_table_columns)
151
+
152
+ with ui.column().classes('gap-2'):
153
+ ui.label('标签分组模式').classes('font-bold text-sm text-gray-700')
154
+ controller.input_group_mode = ui.select(
155
+ ['off', 'expand', 'diverse'], value='off',
156
+ ).classes('w-40').props('outlined dense')
157
+ with ui.tooltip().props('content-class="bg-black text-white shadow-4"'):
158
+ ui.label('off=关闭 | expand=同类召回增强 | diverse=多样性约束').style('font-size:14px;')
159
+ controller.input_group_mode.on(
160
+ 'update:model-value', controller._on_param_changed
161
+ )
162
+
163
+ controller.input_max_per_group = ui.number(
164
+ value=2, min=1, max=10,
165
+ ).classes('w-20').props('outlined dense')
166
+ ui.label('每组最大标签数(diverse 模式)').classes('text-xs text-gray-500')
167
+ controller.input_max_per_group.on(
168
+ 'update:model-value', controller._on_param_changed
169
+ )
170
+
171
+ with ui.element('div').classes('w-full border-t border-slate-100 pt-3 mt-1'):
172
+ controller.service_status_container = ui.column().classes('w-full gap-0')
173
+ controller._update_service_status()
174
+ controller._start_service_status_task()
webui/render/workspace_panel.py ADDED
@@ -0,0 +1,277 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """工作区工具栏和已选标签区域的基础视图。"""
2
+
3
+ from typing import Any
4
+
5
+ from nicegui import ui
6
+
7
+ from core.prompt_import import (
8
+ PromptImportResult,
9
+ WORKSPACE_GROUP_ORDER,
10
+ WorkspaceCanonicalizationResult,
11
+ pending_to_workspace_entry,
12
+ )
13
+ from core.workspace_insights import selected_tag_reason
14
+ from webui.helpers import format_selected_tag_label
15
+
16
+
17
+ def build_workspace_toolbar(controller: Any) -> None:
18
+ """构建工作区历史、收藏、导入与备份入口。"""
19
+ with ui.element('div').classes(
20
+ 'w-full bg-slate-50 border-b border-slate-200 px-4 py-2'
21
+ ):
22
+ with ui.row().classes('w-full items-center gap-2 flex-wrap'):
23
+ ui.icon('workspaces', color='primary')
24
+ ui.label('标签工作区').classes('section-heading mr-2')
25
+ controller.undo_btn = ui.button(
26
+ '撤销', icon='undo', on_click=controller._undo_workspace,
27
+ ).props('dense flat color=grey-7')
28
+ controller.redo_btn = ui.button(
29
+ '恢复', icon='redo', on_click=controller._redo_workspace,
30
+ ).props('dense flat color=grey-7')
31
+ controller.undo_btn.disable()
32
+ controller.redo_btn.disable()
33
+ ui.separator().props('vertical').classes('h-7 mx-1')
34
+ ui.button('历史', icon='history', on_click=controller._open_history_dialog).props(
35
+ 'dense flat color=primary'
36
+ )
37
+ controller.history_count_label = ui.label('0').classes('text-xs text-gray-500 -ml-2')
38
+ ui.button('收藏', icon='star_outline', on_click=controller._open_favorites_dialog).props(
39
+ 'dense flat color=amber-8'
40
+ )
41
+ controller.favorites_count_label = ui.label('0').classes('text-xs text-gray-500 -ml-2')
42
+ ui.button('保存收藏', icon='bookmark_add', on_click=controller._open_save_favorite_dialog).props(
43
+ 'dense flat color=teal-7'
44
+ )
45
+ ui.button('导入 Prompt', icon='playlist_add', on_click=controller._open_prompt_import_dialog).props(
46
+ 'dense flat color=purple-7'
47
+ )
48
+ ui.button('备份 / 迁移', icon='swap_horiz', on_click=controller._open_backup_dialog).props(
49
+ 'dense flat color=grey-7'
50
+ )
51
+ controller._update_workspace_counts()
52
+
53
+
54
+ def build_selection_bar(controller: Any) -> None:
55
+ """构建已选标签栏及其操作入口。"""
56
+ controller.selection_bar_card = ui.element('div').classes('w-full bg-blue-50 p-4')
57
+ with controller.selection_bar_card:
58
+ with ui.row().classes('w-full items-center justify-between'):
59
+ with ui.row().classes('items-center gap-2'):
60
+ ui.icon('check_circle', color='primary')
61
+ ui.label('已选标签').classes('font-bold text-primary')
62
+ controller.selection_count_label = ui.label('0').classes(
63
+ 'bg-primary text-white px-2 rounded-full text-sm'
64
+ )
65
+ with ui.icon('info_outline', size='sm', color='grey').classes('cursor-help'):
66
+ with ui.tooltip().props('content-class="bg-black text-white shadow-4"'):
67
+ ui.html(
68
+ '点击 <b>−</b> / <b>+</b> 可调整标签权重(步长 0.1,范围 0.1~1.9)。<br>'
69
+ '权重 1.0 时输出原始标签;其余输出 <code>(tag:1.2)</code> 格式。'
70
+ ).style('font-size:14px;line-height:1.6;')
71
+
72
+ with ui.row().classes('items-center gap-2'):
73
+ with ui.button('没搜到?', icon='help_outline').props(
74
+ 'dense flat color=grey-6'
75
+ ).classes('text-sm') as bad_button:
76
+ with ui.tooltip().props('content-class="bg-black text-white shadow-4"'):
77
+ ui.html(
78
+ '点击此处以反馈失败案例。<br>您的搜索词将被匿名收集用于优化引擎(不包含个人隐私)。'
79
+ ).style('font-size:14px;line-height:1.5;')
80
+ controller.bad_case_btn = bad_button
81
+ controller.bad_case_btn.disable()
82
+ controller.bad_case_btn.on_click(controller.report_bad_case)
83
+ controller.format_toggle_btn = ui.button('SDXL', icon='swap_horiz').props(
84
+ 'dense flat color=grey-7'
85
+ ).classes('text-xs font-mono')
86
+ with controller.format_toggle_btn:
87
+ with ui.tooltip().props('content-class="bg-black text-white shadow-4"'):
88
+ ui.html(
89
+ '切换复制格式:<br>'
90
+ '<b>SDXL</b>:<code>(tag:1.2)</code><br>'
91
+ '<b>NAI</b>:<code>1.2::tag::</code><br>'
92
+ '<b>Anima</b>:<code>(tag:1.5)</code> 下划线→空格'
93
+ ).style('font-size:13px;line-height:1.7;')
94
+ controller.format_toggle_btn.on_click(controller._toggle_prompt_format)
95
+ clear_button = ui.button('清空已选', icon='delete_sweep').props(
96
+ 'dense flat color=red-7'
97
+ ).classes('text-xs')
98
+ clear_button.on_click(controller._clear_all_staged)
99
+ copy_button = ui.button('复制选中', icon='content_copy').props(
100
+ 'dense unelevated color=primary'
101
+ )
102
+ copy_button.on_click(controller.copy_selection)
103
+
104
+ controller.selected_chips_container = ui.element('div').classes(
105
+ 'w-full mt-2 min-h-10 p-1 rounded bg-white border border-blue-100'
106
+ ).props('id="danbooru-selected-chips"')
107
+ controller.prompt_pending_container = ui.column().classes('w-full gap-2 mt-2')
108
+
109
+
110
+ def show_prompt_import_summary(controller: Any, result: PromptImportResult, added_count: int, duplicate_count: int) -> None:
111
+ with ui.dialog() as dialog, ui.card().classes('w-full max-w-3xl max-h-[85vh]'):
112
+ ui.label('Prompt 导入结果').classes('text-lg font-bold')
113
+ ui.label(
114
+ f'解析 {result.parsed_count} 项 · 新增 {added_count} 项 · '
115
+ f'纠正 {len(result.corrections)} 项 · 重复合并 {duplicate_count} 项 · '
116
+ f'待确认 {len(result.pending)} 项'
117
+ ).classes('text-sm text-gray-700 bg-slate-50 rounded p-3')
118
+ with ui.scroll_area().classes('w-full max-h-[58vh]'):
119
+ if result.corrections:
120
+ ui.label('名称规范化').classes('font-bold text-teal-700 mt-2')
121
+ for correction in result.corrections:
122
+ ui.label(
123
+ f'{correction.original} → {correction.canonical}'
124
+ ).classes('text-sm font-mono text-teal-800')
125
+ if result.pending:
126
+ ui.label('未加入,已进入待确认区').classes(
127
+ 'font-bold text-orange-700 mt-3'
128
+ )
129
+ for item in result.pending:
130
+ pending_record = pending_to_workspace_entry(item)
131
+ ui.label(
132
+ f'{item.original}:{controller._prompt_pending_reason(pending_record)}'
133
+ ).classes('text-sm text-orange-800 break-all')
134
+ with ui.row().classes('w-full justify-end'):
135
+ ui.button('知道了', on_click=dialog.close).props('unelevated color=primary')
136
+ dialog.open()
137
+
138
+ def render_prompt_pending(controller: Any) -> None:
139
+ if controller.prompt_pending_container is None:
140
+ return
141
+ controller.prompt_pending_container.clear()
142
+ pending_items = [
143
+ item for item in controller.workspace_state.get('dismissed', [])
144
+ if isinstance(item, dict) and item.get('kind') == 'prompt_import_pending'
145
+ ]
146
+ if not pending_items:
147
+ return
148
+
149
+ with controller.prompt_pending_container:
150
+ with ui.expansion(
151
+ f'待确认内容({len(pending_items)})', icon='help_outline', value=True,
152
+ ).classes('w-full bg-orange-50 border border-orange-200 rounded'):
153
+ ui.label(
154
+ '以下内容不会出现在复制结果中。可选择可靠候选,或从待确认区移除。'
155
+ ).classes('text-xs text-orange-700 mb-2')
156
+ for item in pending_items:
157
+ with ui.row().classes(
158
+ 'w-full items-center justify-between gap-2 border-t border-orange-100 py-2'
159
+ ):
160
+ with ui.column().classes('gap-0 min-w-0 flex-grow'):
161
+ ui.label(str(item.get('original') or '')).classes(
162
+ 'text-sm font-mono text-gray-800 break-all'
163
+ )
164
+ ui.label(controller._prompt_pending_reason(item)).classes(
165
+ 'text-xs text-orange-700'
166
+ )
167
+ with ui.row().classes('gap-1 flex-wrap justify-end'):
168
+ if item.get('reason') not in {'alias_target_missing', 'nsfw_filtered'}:
169
+ for candidate in item.get('candidates', [])[:5]:
170
+ ui.button(
171
+ str(candidate),
172
+ on_click=lambda i=item, c=str(candidate):
173
+ controller._accept_prompt_candidate(i, c),
174
+ ).props('flat dense color=teal-7').classes('text-xs font-mono')
175
+ ui.button(
176
+ icon='close',
177
+ on_click=lambda i=item: controller._remove_prompt_pending(i),
178
+ ).props('flat round dense color=grey-6')
179
+
180
+ def show_workspace_canonicalization(controller: Any, result: WorkspaceCanonicalizationResult, label: str) -> None:
181
+ if not result.corrections and not result.pending and not result.duplicate_count:
182
+ return
183
+ with ui.dialog() as dialog, ui.card().classes('w-full max-w-2xl'):
184
+ ui.label(f'{label}标签规���化结果').classes('text-lg font-bold')
185
+ ui.label(
186
+ f'纠正 {len(result.corrections)} 项 · '
187
+ f'重复合并 {result.duplicate_count} 项 · '
188
+ f'待确认 {len(result.pending)} 项'
189
+ ).classes('text-sm text-gray-600')
190
+ if result.corrections:
191
+ with ui.column().classes('w-full gap-1'):
192
+ for correction in result.corrections:
193
+ ui.label(
194
+ f'{correction.original} → {correction.canonical}'
195
+ ).classes('text-sm font-mono text-teal-800')
196
+ if result.pending:
197
+ ui.label('未识别内容已移入工作区待确认区,不会进入复制结果。').classes(
198
+ 'text-sm text-orange-700'
199
+ )
200
+ with ui.row().classes('w-full justify-end'):
201
+ ui.button('知道了', on_click=dialog.close).props('unelevated color=primary')
202
+ dialog.open()
203
+
204
+ def render_selected_chips(controller: Any) -> None:
205
+ """按稳定 Tag Group 规则渲染;复制顺序仍使用原始选择顺序。"""
206
+ if controller.selected_chips_container is None:
207
+ return
208
+ tags = controller._get_selected_tags()
209
+ previous_tags = set(controller._rendered_selected_chip_tags)
210
+ controller.selected_chips_container.clear()
211
+ if not tags:
212
+ controller._rendered_selected_chip_tags.clear()
213
+ with controller.selected_chips_container:
214
+ ui.label('暂无已选标签').classes('text-xs text-gray-400 italic p-2 self-center')
215
+ return
216
+
217
+ grouped: dict[str, list[str]] = {name: [] for name in WORKSPACE_GROUP_ORDER}
218
+ for tag in tags:
219
+ grouped[controller._workspace_group_for_tag(tag)].append(tag)
220
+
221
+ with controller.selected_chips_container:
222
+ step = 0.5 if controller.prompt_format == 'anima' else 0.1
223
+ for group_name in WORKSPACE_GROUP_ORDER:
224
+ group_tags = grouped[group_name]
225
+ if not group_tags:
226
+ continue
227
+ with ui.element('div').classes('w-full px-1 py-1'):
228
+ ui.label(f'{group_name} · {len(group_tags)}').classes(
229
+ 'text-xs font-bold text-slate-500 mb-1'
230
+ )
231
+ with ui.row().classes('w-full gap-1 flex-wrap'):
232
+ for tag in group_tags:
233
+ controller._render_selected_tag_chip(
234
+ tag, step, animate=tag not in previous_tags,
235
+ )
236
+ controller._rendered_selected_chip_tags = set(tags)
237
+
238
+ def render_selected_tag_chip(controller: Any, tag: str, step: float, *, animate: bool = False) -> None:
239
+ w = controller.tag_weights.get(tag, 1.0)
240
+ extra_cls = 'boosted' if w > 1.0 else ('reduced' if w < 1.0 else '')
241
+ w_str = f'{w:.1f}'
242
+ display_label = format_selected_tag_label(tag, controller._get_cn_name_for_tag(tag))
243
+ motion_cls = ' motion-chip-enter' if animate else ''
244
+ with ui.element('div').classes(f'weight-chip{motion_cls} {extra_cls}'):
245
+ metadata = controller._pending_selection_meta.get(tag, {})
246
+ reason = selected_tag_reason(
247
+ metadata.get('origin'),
248
+ metadata.get('source'),
249
+ )
250
+ with ui.tooltip().props('content-class="bg-black text-white shadow-4"'):
251
+ ui.label(reason).style('font-size:13px;')
252
+ with ui.element('button').classes('weight-btn').props(f'title="移除 {tag}"').on(
253
+ 'click', lambda t=tag: controller._remove_selected_tag(t)
254
+ ):
255
+ ui.html('&times;')
256
+ with ui.element('button').classes('weight-btn').on(
257
+ 'click', lambda t=tag, s=step: controller._adjust_weight(t, -s)
258
+ ):
259
+ ui.html('&minus;')
260
+ ui.label(display_label).style(
261
+ 'font-family:Consolas,Monaco,monospace;font-size:12px;'
262
+ 'color:#2c5282;max-width:240px;overflow:hidden;'
263
+ 'text-overflow:ellipsis;white-space:nowrap;'
264
+ )
265
+ if w != 1.0:
266
+ ui.label(w_str).classes('weight-label').style(
267
+ 'color:#e65100;font-weight:bold;'
268
+ )
269
+ plus_btn = ui.element('button').classes('weight-btn').on(
270
+ 'click', lambda t=tag, s=step: controller._adjust_weight(t, +s)
271
+ )
272
+ if controller.prompt_format == 'anima':
273
+ with plus_btn:
274
+ with ui.tooltip().props('content-class="bg-black text-white shadow-4"'):
275
+ ui.html('Anima模型所需要的权重数值较大').style('font-size:12px;')
276
+ with plus_btn:
277
+ ui.html('&plus;')
webui/routes.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """NiceGUI 页面路由与客户端生命周期绑定。"""
2
+
3
+ import asyncio
4
+ from collections.abc import Callable
5
+
6
+ from nicegui import ui
7
+
8
+
9
+ def register_main_page(
10
+ create_page_controller: Callable[[], object],
11
+ mark_active: Callable[[str], None],
12
+ mark_inactive: Callable[[str], None],
13
+ increment_visit: Callable[[], object],
14
+ increment_telemetry: Callable[[str], object],
15
+ ) -> None:
16
+ """注册主页;控制器仍由入口注入,避免路由层依赖具体实现。"""
17
+
18
+ @ui.page('/')
19
+ async def main_page():
20
+ client = ui.context.client
21
+ client_id = client.id
22
+ app_ui = create_page_controller()
23
+
24
+ def mark_connected(*_):
25
+ mark_active(client_id)
26
+ app_ui._start_service_status_task()
27
+ app_ui._start_storage_restore_task()
28
+
29
+ def mark_disconnected(*_):
30
+ mark_inactive(client_id)
31
+ app_ui._pause_storage_restore()
32
+
33
+ def mark_deleted(*_):
34
+ mark_inactive(client_id)
35
+ app_ui._dispose()
36
+
37
+ client.on_connect(mark_connected)
38
+ client.on_disconnect(mark_disconnected)
39
+ on_delete = getattr(client, 'on_delete', None)
40
+ if callable(on_delete):
41
+ on_delete(mark_deleted)
42
+
43
+ app_ui.build_page()
44
+
45
+ async def silent_visit_update():
46
+ try:
47
+ await increment_visit()
48
+ await increment_telemetry('ui_visit')
49
+ app_ui._update_footer_text()
50
+ except Exception:
51
+ pass
52
+
53
+ asyncio.create_task(silent_visit_update())
webui/state.py ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """不依赖 NiceGUI 控件的页面会话状态。"""
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any
5
+
6
+ from core.workspace import empty_favorites, empty_history, new_workspace
7
+ from webui.constants import LOCAL_STORAGE_NAMES
8
+
9
+
10
+ @dataclass
11
+ class PageState:
12
+ """单个浏览器页面的纯数据状态;控件引用由控制器单独持有。"""
13
+
14
+ full_table_data: list[dict] = field(default_factory=list)
15
+ current_segments: list[str] = field(default_factory=list)
16
+ current_keywords: list[str] = field(default_factory=list)
17
+ current_cached_queries: set[str] = field(default_factory=set)
18
+ current_filter_keyword: str = 'ALL'
19
+ current_query_str: str = ''
20
+ full_tags_str: str = ''
21
+ full_tags_str_sfw: str = ''
22
+
23
+ current_related: list[Any] = field(default_factory=list)
24
+ chip_extra_selected: set[str] = field(default_factory=set)
25
+ selected_order: list[str] = field(default_factory=list)
26
+ rendered_selected_chip_tags: set[str] = field(default_factory=set)
27
+ workspace: dict = field(default_factory=new_workspace)
28
+ history: dict = field(default_factory=empty_history)
29
+ favorites: dict = field(default_factory=empty_favorites)
30
+ undo_stack: list[dict] = field(default_factory=list)
31
+ redo_stack: list[dict] = field(default_factory=list)
32
+ pending_selection_meta: dict[str, dict[str, str]] = field(default_factory=dict)
33
+ workspace_artist_tags: set[str] = field(default_factory=set)
34
+
35
+ tag_weights: dict[str, float] = field(default_factory=dict)
36
+ prompt_format: str = 'sdxl'
37
+ selected_layers: dict[str, bool] = field(default_factory=lambda: {
38
+ '英文': True,
39
+ '中文扩展词': True,
40
+ '释义': True,
41
+ '中文核心词': True,
42
+ 'artist': True,
43
+ })
44
+ selected_categories: dict[str, bool] = field(default_factory=lambda: {
45
+ 'General': True,
46
+ 'Copyright': True,
47
+ 'Character': True,
48
+ })
49
+
50
+ related_results: list[Any] = field(default_factory=list)
51
+ related_show_nsfw: bool = True
52
+ related_page: int = 1
53
+ related_page_count: int = 0
54
+ group_render_limits: dict[str, int] = field(default_factory=dict)
55
+ group_expanded_names: set[str] = field(default_factory=set)
56
+ group_scroll_positions: dict[str, int] = field(default_factory=dict)
57
+ group_render_key: tuple[str, ...] = ()
58
+ group_candidate_sources: dict[str, str] = field(default_factory=dict)
59
+
60
+ artist_rec_rows: list[dict] = field(default_factory=list)
61
+ artist_rec_results: list[Any] = field(default_factory=list)
62
+ artist_rec_top_tags: dict[str, list[str]] = field(default_factory=dict)
63
+ artist_rec_show_nsfw: bool = True
64
+ artist_rec_sources: dict[str, str] = field(default_factory=dict)
65
+ artist_rec_page: int = 1
66
+ artist_rec_page_count: int = 0
67
+ current_artist_rec_tags: set[str] = field(default_factory=set)
68
+ artist_result_tags: set[str] = field(default_factory=set)
69
+ last_recommendation_seed_tags: list[str] = field(default_factory=list)
70
+ pending_recommendation_request: dict | None = None
71
+ recommendation_generation: int = 0
72
+
73
+ storage_states: dict[str, str] = field(
74
+ default_factory=lambda: {name: 'pending' for name in LOCAL_STORAGE_NAMES}
75
+ )
76
+ storage_session_dirty: set[str] = field(default_factory=set)
77
+ storage_applying: set[str] = field(default_factory=set)
78
+ storage_raw_values: dict[str, str | None] = field(default_factory=dict)
79
+
80
+
81
+ class StateField:
82
+ """把控制器的旧属性名兼容地代理到 ``controller.state``。"""
83
+
84
+ def __init__(self, state_name: str | None = None):
85
+ self.state_name = state_name
86
+
87
+ def __set_name__(self, owner, name: str) -> None:
88
+ if self.state_name is None:
89
+ self.state_name = name
90
+
91
+ def __get__(self, instance, owner=None):
92
+ if instance is None:
93
+ return self
94
+ return getattr(instance.state, self.state_name)
95
+
96
+ def __set__(self, instance, value) -> None:
97
+ setattr(instance.state, self.state_name, value)
webui/styles.py ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """与业务逻辑无关的页面样式片段。"""
2
+
3
+
4
+ MOTION_STYLE = '''
5
+ <style>
6
+ :root {
7
+ --motion-ease-out: cubic-bezier(0.22, 1, 0.36, 1);
8
+ --motion-fast: 180ms;
9
+ --motion-content: 250ms;
10
+ --motion-chip: 210ms;
11
+ }
12
+ .motion-search-input .q-field__control {
13
+ transition: box-shadow var(--motion-fast) var(--motion-ease-out),
14
+ background-color var(--motion-fast) ease;
15
+ }
16
+ .motion-search-input.q-field--focused .q-field__control {
17
+ box-shadow: 0 0 0 3px rgba(74, 144, 226, 0.12);
18
+ }
19
+ .motion-search-button {
20
+ transition: transform var(--motion-fast) var(--motion-ease-out),
21
+ box-shadow var(--motion-fast) ease, opacity var(--motion-fast) ease;
22
+ }
23
+ @media (hover: hover) {
24
+ .motion-search-button:not(.disabled):hover {
25
+ transform: translateY(-1px);
26
+ box-shadow: 0 3px 8px rgba(51, 65, 85, 0.18);
27
+ }
28
+ }
29
+ .motion-search-button:active { transform: translateY(0); }
30
+ .motion-search-button.disabled { opacity: 0.82; }
31
+ .motion-search-spinner { animation: motion-chip-enter var(--motion-fast) var(--motion-ease-out) both; }
32
+ @keyframes motion-content-enter {
33
+ from { opacity: 0; transform: translateY(6px); }
34
+ to { opacity: 1; transform: translateY(0); }
35
+ }
36
+ @keyframes motion-chip-enter {
37
+ from { opacity: 0; transform: translateY(3px); }
38
+ to { opacity: 1; transform: translateY(0); }
39
+ }
40
+ @keyframes motion-recommendation-enter-from-right {
41
+ from { opacity: 0; transform: translateX(10px); }
42
+ to { opacity: 1; transform: translateX(0); }
43
+ }
44
+ @keyframes motion-recommendation-enter-from-left {
45
+ from { opacity: 0; transform: translateX(-10px); }
46
+ to { opacity: 1; transform: translateX(0); }
47
+ }
48
+ .motion-results-enter, .motion-secondary-enter, .motion-refresh-enter {
49
+ animation: motion-content-enter var(--motion-content) var(--motion-ease-out) both;
50
+ }
51
+ .motion-recommendation-enter-right {
52
+ animation: motion-recommendation-enter-from-right var(--motion-content) var(--motion-ease-out) both;
53
+ }
54
+ .motion-recommendation-enter-left {
55
+ animation: motion-recommendation-enter-from-left var(--motion-content) var(--motion-ease-out) both;
56
+ }
57
+ .motion-chip-enter { animation: motion-chip-enter var(--motion-chip) var(--motion-ease-out) both; }
58
+ @media (prefers-reduced-motion: reduce) {
59
+ .motion-search-input .q-field__control, .motion-search-button, .motion-search-spinner,
60
+ .related-item, .weight-btn { transition-duration: 1ms !important; }
61
+ .motion-results-enter, .motion-secondary-enter, .motion-refresh-enter,
62
+ .motion-recommendation-enter-right, .motion-recommendation-enter-left,
63
+ .motion-chip-enter, .motion-search-spinner { animation: none !important; }
64
+ }
65
+ </style>
66
+ '''
webui/workspace_state.py ADDED
@@ -0,0 +1,382 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """工作区状态的可复用规则,不包含 NiceGUI 渲染。"""
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any
5
+
6
+ from core.workspace import (
7
+ add_history_entry,
8
+ append_workspace_query,
9
+ clone_workspace,
10
+ empty_history,
11
+ merge_favorites,
12
+ merge_history,
13
+ merge_workspaces,
14
+ normalize_favorites,
15
+ sync_selected_entries,
16
+ workspace_signature,
17
+ )
18
+ from platform_utils import nsfw_allowed
19
+ from webui.helpers import sanitize_restored_config
20
+
21
+
22
+ def set_selection_meta(controller: Any, tag: str, origin: str, source: str = '') -> None:
23
+ """记录标签的来源,供写入工作区时补齐可追溯信息。"""
24
+ controller._pending_selection_meta[tag] = {
25
+ 'origin': origin,
26
+ 'source': source,
27
+ }
28
+
29
+
30
+ def push_undo_snapshot(controller: Any, *, limit: int = 30) -> bool:
31
+ """在工作区内容变化前保存快照;相同快照不重复入栈。"""
32
+ snapshot = clone_workspace(controller.workspace_state)
33
+ signature = workspace_signature(snapshot)
34
+ if controller._undo_stack and workspace_signature(controller._undo_stack[-1]) == signature:
35
+ return False
36
+ controller._undo_stack.append(snapshot)
37
+ controller._undo_stack = controller._undo_stack[-limit:]
38
+ controller._redo_stack.clear()
39
+ controller._update_undo_buttons()
40
+ return True
41
+
42
+
43
+ def selected_tags(controller: Any) -> list[str]:
44
+ """合并结果表与工作区额外标签,稳定保留用户选择顺序。"""
45
+ table_tags = [row['tag'] for row in controller.result_table.selected] if controller.result_table else []
46
+ seen = set(table_tags)
47
+ extra_pool = set(controller.chip_extra_selected)
48
+ extra = [tag for tag in controller._selected_order if tag in extra_pool and tag not in seen]
49
+ seen.update(extra)
50
+ extra.extend(sorted(tag for tag in extra_pool if tag not in seen))
51
+ return table_tags + extra
52
+
53
+
54
+ def collect_config_state(controller: Any, config_version: int) -> dict:
55
+ """从页面控件采集可持久化的工作区配置。"""
56
+ return {
57
+ 'version': config_version,
58
+ 'top_k': int(controller.input_top_k.value) if controller.input_top_k else 10,
59
+ 'limit': int(controller.input_limit.value) if controller.input_limit else 80,
60
+ 'popularity_weight': float(controller.input_weight.value) if controller.input_weight else 0.15,
61
+ 'show_nsfw': bool(controller.input_nsfw.value) if controller.input_nsfw else False,
62
+ 'use_segmentation': bool(controller.input_segment.value) if controller.input_segment else True,
63
+ 'selected_layers': dict(controller.selected_layers),
64
+ 'selected_cats': dict(controller.selected_cats),
65
+ 'sw_semantic': bool(controller.sw_semantic.value) if controller.sw_semantic else False,
66
+ 'sw_layer': bool(controller.sw_layer.value) if controller.sw_layer else False,
67
+ 'sw_source': bool(controller.sw_source.value) if controller.sw_source else False,
68
+ 'prompt_format': controller.prompt_format,
69
+ 'rows_per_page': controller._get_rows_per_page(),
70
+ 'search_query': controller.search_input.value if controller.search_input else '',
71
+ 'dismissed_announcement_version': controller.dismissed_announcement_version,
72
+ 'search_mode': controller.input_search_mode.value if controller.input_search_mode else '自定义',
73
+ 'group_mode': controller.input_group_mode.value if controller.input_group_mode else 'off',
74
+ 'max_per_group': int(controller.input_max_per_group.value) if controller.input_max_per_group else 2,
75
+ }
76
+
77
+
78
+ def apply_config_state(
79
+ controller: Any,
80
+ config: object,
81
+ announcement_version: str,
82
+ ) -> None:
83
+ """将已校验的配置按原有顺序同步回控件和工作区状态。"""
84
+ config = sanitize_restored_config(config if isinstance(config, dict) else {})
85
+
86
+ dismissed_version = config.get('dismissed_announcement_version', '')
87
+ controller.dismissed_announcement_version = dismissed_version
88
+ if controller.announcement_banner:
89
+ controller.announcement_banner.set_visibility(
90
+ dismissed_version != announcement_version
91
+ )
92
+
93
+ if controller.input_search_mode and 'search_mode' in config:
94
+ controller.input_search_mode.set_value(config['search_mode'])
95
+ if controller.input_top_k and 'top_k' in config:
96
+ controller.input_top_k.set_value(config['top_k'])
97
+ if controller.input_limit and 'limit' in config:
98
+ controller.input_limit.set_value(config['limit'])
99
+ if controller.input_weight and 'popularity_weight' in config:
100
+ controller.input_weight.set_value(config['popularity_weight'])
101
+ if controller.input_segment and 'use_segmentation' in config:
102
+ controller.input_segment.set_value(config['use_segmentation'])
103
+ if controller.input_group_mode and 'group_mode' in config:
104
+ controller.input_group_mode.set_value(config['group_mode'])
105
+ if controller.input_max_per_group and 'max_per_group' in config:
106
+ controller.input_max_per_group.set_value(config['max_per_group'])
107
+ if nsfw_allowed() and controller.input_nsfw and 'show_nsfw' in config:
108
+ controller.input_nsfw.set_value(config['show_nsfw'])
109
+
110
+ for layer, value in config.get('selected_layers', {}).items():
111
+ if layer in controller.selected_layers:
112
+ controller.selected_layers[layer] = bool(value)
113
+ if layer in controller._layer_checkboxes:
114
+ controller._layer_checkboxes[layer].set_value(bool(value))
115
+ for category, value in config.get('selected_cats', {}).items():
116
+ if category in controller.selected_cats:
117
+ controller.selected_cats[category] = bool(value)
118
+ if category in controller._cat_checkboxes:
119
+ controller._cat_checkboxes[category].set_value(bool(value))
120
+
121
+ if controller.sw_semantic and 'sw_semantic' in config:
122
+ controller.sw_semantic.set_value(config['sw_semantic'])
123
+ if controller.sw_layer and 'sw_layer' in config:
124
+ controller.sw_layer.set_value(config['sw_layer'])
125
+ if controller.sw_source and 'sw_source' in config:
126
+ controller.sw_source.set_value(config['sw_source'])
127
+ if 'prompt_format' in config:
128
+ controller._apply_prompt_format(config['prompt_format'])
129
+ if 'rows_per_page' in config:
130
+ controller._set_rows_per_page(config['rows_per_page'])
131
+ if controller.search_input and config.get('search_query'):
132
+ controller.search_input.set_value(config['search_query'])
133
+ controller._update_table_columns()
134
+
135
+
136
+ def current_search_settings(controller: Any) -> dict:
137
+ """采集一条历史记录所需的搜索设置。"""
138
+ return {
139
+ 'search_mode': controller.input_search_mode.value if controller.input_search_mode else '自定义',
140
+ 'top_k': int(controller.input_top_k.value) if controller.input_top_k else 10,
141
+ 'limit': int(controller.input_limit.value) if controller.input_limit else 80,
142
+ 'popularity_weight': float(controller.input_weight.value) if controller.input_weight else 0.15,
143
+ 'show_nsfw': bool(controller.input_nsfw.value) if controller.input_nsfw else False,
144
+ 'use_segmentation': bool(controller.input_segment.value) if controller.input_segment else True,
145
+ 'target_layers': [key for key, value in controller.selected_layers.items() if value],
146
+ 'target_categories': [key for key, value in controller.selected_cats.items() if value],
147
+ 'group_mode': controller.input_group_mode.value if controller.input_group_mode else 'off',
148
+ 'max_per_group': int(controller.input_max_per_group.value) if controller.input_max_per_group else 2,
149
+ }
150
+
151
+
152
+ def apply_search_settings(
153
+ controller: Any,
154
+ settings: object,
155
+ search_mode_options: tuple[str, ...],
156
+ ) -> None:
157
+ """恢复历史/收藏的搜索设置,并保留原有的最后保存动作。"""
158
+ if not isinstance(settings, dict):
159
+ return
160
+ controller._applying_preset = True
161
+ try:
162
+ mode = settings.get('search_mode')
163
+ if controller.input_search_mode and mode in search_mode_options:
164
+ controller.input_search_mode.set_value(mode)
165
+ if controller.input_top_k and isinstance(settings.get('top_k'), int):
166
+ controller.input_top_k.set_value(settings['top_k'])
167
+ if controller.input_limit and isinstance(settings.get('limit'), int):
168
+ controller.input_limit.set_value(settings['limit'])
169
+ if controller.input_weight and isinstance(settings.get('popularity_weight'), (int, float)):
170
+ controller.input_weight.set_value(settings['popularity_weight'])
171
+ if controller.input_segment and isinstance(settings.get('use_segmentation'), bool):
172
+ controller.input_segment.set_value(settings['use_segmentation'])
173
+ if controller.input_group_mode and settings.get('group_mode') in ('off', 'expand', 'diverse'):
174
+ controller.input_group_mode.set_value(settings['group_mode'])
175
+ if controller.input_max_per_group and isinstance(settings.get('max_per_group'), int):
176
+ controller.input_max_per_group.set_value(settings['max_per_group'])
177
+ if nsfw_allowed() and controller.input_nsfw and isinstance(settings.get('show_nsfw'), bool):
178
+ controller.input_nsfw.set_value(settings['show_nsfw'])
179
+
180
+ layers = settings.get('target_layers')
181
+ if isinstance(layers, list):
182
+ selected = set(layers)
183
+ for layer in controller.selected_layers:
184
+ value = layer in selected
185
+ controller.selected_layers[layer] = value
186
+ if layer in controller._layer_checkboxes:
187
+ controller._layer_checkboxes[layer].set_value(value)
188
+ categories = settings.get('target_categories')
189
+ if isinstance(categories, list):
190
+ selected = set(categories)
191
+ for category in controller.selected_cats:
192
+ value = category in selected
193
+ controller.selected_cats[category] = value
194
+ if category in controller._cat_checkboxes:
195
+ controller._cat_checkboxes[category].set_value(value)
196
+ finally:
197
+ controller._applying_preset = False
198
+ controller._save_config()
199
+
200
+
201
+ def apply_workspace_state(
202
+ controller: Any,
203
+ workspace: dict,
204
+ artist_origins: set[str],
205
+ *,
206
+ persist: bool = True,
207
+ refresh_recommendations: bool = True,
208
+ ) -> None:
209
+ """将版本化工作区同步至页面状态;渲染仍通过控制器回调完成。"""
210
+ controller.workspace_state = clone_workspace(workspace)
211
+ selected = controller.workspace_state['selected']
212
+ tags = [item['tag'] for item in selected]
213
+ tag_set = set(tags)
214
+ controller._selected_order = list(tags)
215
+ controller.tag_weights = {item['tag']: item.get('weight', 1.0) for item in selected}
216
+ controller._pending_selection_meta = {
217
+ item['tag']: {
218
+ 'origin': item.get('origin', 'unknown'),
219
+ 'source': item.get('source', ''),
220
+ }
221
+ for item in selected
222
+ }
223
+ controller._workspace_artist_tags = {
224
+ item['tag'] for item in selected
225
+ if item.get('origin') in artist_origins
226
+ }
227
+
228
+ table_tags = {row['tag'] for row in controller.result_table.rows} if controller.result_table else set()
229
+ controller.chip_extra_selected.clear()
230
+ controller.chip_extra_selected.update(tag for tag in tags if tag not in table_tags)
231
+ if controller.result_table is not None:
232
+ controller.result_table.selected = [
233
+ row for row in controller.result_table.rows if row.get('tag') in tag_set
234
+ ]
235
+ controller._apply_prompt_format(controller.workspace_state.get('prompt_format', 'sdxl'))
236
+ controller._render_selected_chips()
237
+ controller._render_prompt_pending()
238
+ controller._render_concept_coverage()
239
+ if controller.selection_count_label is not None:
240
+ controller.selection_count_label.text = str(len(tags))
241
+ if controller.results_section is not None:
242
+ controller.results_section.set_visibility(bool(tags) or bool(controller.full_table_data))
243
+
244
+ if persist:
245
+ controller._save_staged_tags()
246
+ controller._save_config()
247
+ if refresh_recommendations:
248
+ show_nsfw = bool(controller.input_nsfw.value) if controller.input_nsfw else False
249
+ controller._last_recommendation_seed_tags = []
250
+ controller._refresh_recommendations_if_seed_changed(tags, show_nsfw)
251
+
252
+
253
+ def pop_undo_workspace(controller: Any) -> dict | None:
254
+ """执行撤销栈的纯状态转移,返回需要应用的目标工作区。"""
255
+ if not controller._undo_stack:
256
+ return None
257
+ controller._redo_stack.append(clone_workspace(controller.workspace_state))
258
+ return controller._undo_stack.pop()
259
+
260
+
261
+ def pop_redo_workspace(controller: Any, *, limit: int = 30) -> dict | None:
262
+ """执行重做栈的纯状态转移,返回需要应用的目标工作区。"""
263
+ if not controller._redo_stack:
264
+ return None
265
+ controller._undo_stack.append(clone_workspace(controller.workspace_state))
266
+ controller._undo_stack = controller._undo_stack[-limit:]
267
+ return controller._redo_stack.pop()
268
+
269
+
270
+ def sync_workspace_selection(controller: Any, artist_origins: set[str]) -> list[str]:
271
+ """把当前选择、权重和来源元数据写入版本化工作区状态。"""
272
+ tags = controller._get_selected_tags()
273
+ controller._selected_order = list(tags)
274
+ cn_names = {tag: controller._get_cn_name_for_tag(tag) for tag in tags}
275
+ controller.workspace_state['prompt_format'] = controller.prompt_format
276
+ controller.workspace_state = sync_selected_entries(
277
+ controller.workspace_state,
278
+ tags,
279
+ controller.tag_weights,
280
+ cn_names,
281
+ controller._pending_selection_meta,
282
+ )
283
+ controller._workspace_artist_tags = {
284
+ item['tag'] for item in controller.workspace_state['selected']
285
+ if item.get('origin') in artist_origins
286
+ }
287
+ return tags
288
+
289
+
290
+ def record_search_history_state(controller: Any, query: str) -> None:
291
+ """将当前查询追加到工作区和历史;持久化仍由页面调用方执行。"""
292
+ settings = controller._current_search_settings()
293
+ controller.workspace_state = append_workspace_query(
294
+ controller.workspace_state,
295
+ query,
296
+ settings,
297
+ )
298
+ controller._save_staged_tags()
299
+ controller.search_history = add_history_entry(
300
+ controller.search_history,
301
+ query,
302
+ settings,
303
+ controller.workspace_state,
304
+ )
305
+
306
+
307
+ def remove_history_entry(controller: Any, history_id: object) -> None:
308
+ """按历史 ID 删除一条记录。"""
309
+ controller.search_history['items'] = [
310
+ entry for entry in controller.search_history.get('items', [])
311
+ if entry.get('history_id') != history_id
312
+ ]
313
+
314
+
315
+ def clear_history(controller: Any) -> None:
316
+ """重置搜索历史,不影响当前工作区或收藏。"""
317
+ controller.search_history = empty_history()
318
+
319
+
320
+ def replace_favorites_safely(controller: Any, favorites: dict) -> bool:
321
+ """先尝试保存新收藏;失败时恢复内存中的旧收藏。"""
322
+ previous = controller.favorites
323
+ controller.favorites = favorites
324
+ if controller._save_favorites():
325
+ controller._update_workspace_counts()
326
+ return True
327
+ controller.favorites = previous
328
+ controller._storage_session_dirty.discard('favorites')
329
+ return False
330
+
331
+
332
+ @dataclass(frozen=True)
333
+ class BackupImportPlan:
334
+ """规范化备份在页面应用前的纯状态变更计划。"""
335
+
336
+ favorites: dict
337
+ workspace: dict | None
338
+ history: dict | None
339
+ config: dict | None
340
+ message: str
341
+
342
+
343
+ def merge_imported_favorite(current_favorites: dict, favorite: object) -> tuple[dict, list[str]]:
344
+ """将单收藏导出内容规范化后合并到当前收藏。"""
345
+ normalized, warnings = normalize_favorites({
346
+ 'schema_version': 1,
347
+ 'items': [favorite],
348
+ })
349
+ return merge_favorites(current_favorites, normalized), warnings
350
+
351
+
352
+ def build_backup_import_plan(
353
+ current_workspace: dict,
354
+ current_history: dict,
355
+ current_favorites: dict,
356
+ backup: dict,
357
+ mode: str,
358
+ ) -> BackupImportPlan:
359
+ """生成备份导入方案;合并模式不改变当前配置和同标签权重。"""
360
+ if mode == 'favorites_only':
361
+ return BackupImportPlan(
362
+ favorites=merge_favorites(current_favorites, backup['favorites']),
363
+ workspace=None,
364
+ history=None,
365
+ config=None,
366
+ message='收藏已合并导入',
367
+ )
368
+ if mode == 'overwrite':
369
+ return BackupImportPlan(
370
+ favorites=backup['favorites'],
371
+ workspace=backup['workspace'],
372
+ history=backup['history'],
373
+ config=backup['config'],
374
+ message='本地数据已由备份覆盖',
375
+ )
376
+ return BackupImportPlan(
377
+ favorites=merge_favorites(current_favorites, backup['favorites']),
378
+ workspace=merge_workspaces(current_workspace, backup['workspace']),
379
+ history=merge_history(current_history, backup['history']),
380
+ config=None,
381
+ message='备份已合并;当前标签权重和配置保持不变',
382
+ )