Compare commits

...
21 Commits
Author SHA1 Message Date
梦圣智杰 cd55defc87 chore: 发布 v2.0.0 / Release v2.0.0 2026-10-10 01:33:23 +08:00
梦圣智杰 79dc337c0e feat: 合并重复检查节点到主线 / Merge duplicate-check node into main 2026-10-10 01:00:09 +08:00
梦圣智杰 642837842c feat: 隐藏按钮挪到方框右上角内 / Move hide button inside the card corner
- 标签卡上的隐藏(眼睛)按钮不再跟着英文行走,固定在整张卡片的右上角内侧
- 鼠标移到卡片任意位置(含中文行)都会显示该按钮
2026-10-10 00:30:18 +08:00
梦圣智杰 13c2fecce2 feat: 重复标签可设「唯一」并新增重复栏 / Unique toggle and duplicate bar for repeated tags
- 重复分组抽成 parser.js 的 duplicateGroups(),问题提示行与界面共用同一份口径
- 「重复标签:X」后面加每个标签独立的「唯一」开关:只保留第一次出现进入实际输出,
  其余在界面上划线保留,不删原文,支持 Ctrl+Z
- 英文标签区「已隐藏」下方新增重复栏,一个标签一个胶囊,单击依次定位到每一次
  出现并联动选中(标红),已定位时双击删掉当前那一处
- 新增 tests/test_duplicates.mjs,14 个 JS 测试全绿
2026-10-06 17:16:49 +08:00
梦圣智杰 7cb8d80b02 feat: 新增重复检查节点 / Add duplicate tag checker node
- 检测提示词里的重复标签,节点用淡橙色提示重复数量,无重复时保持默认配色
- 开关「只保留第一个」默认关闭,打开后只保留第一次出现的标签
- 输出提示词直通下游,重复项可接预览任意查看;判定口径沿用 parser.js
2026-10-05 22:42:56 +08:00
梦圣智杰 ed0ab5eee2 chore: 发布 v1.2.18 / Release v1.2.18 2026-10-05 09:19:41 +08:00
梦圣智杰 d45a9d9e63 feat: 标签编辑弹窗整合 / Consolidate tag editor 2026-10-05 08:27:34 +08:00
梦圣智杰 35ed25d69c chore: 界面细节调整 / UI polish 2026-10-05 08:22:37 +08:00
梦圣智杰 d3e6d7edce chore: 发布 v1.2.16 / Release v1.2.16 2026-10-01 02:50:30 +08:00
梦圣智杰 43a049b43f feat: 快捷添加支持按选中位置插入 / Quick add inserts after the selected tag
- 选中标签时用 token.end 偏移插到它后面,没选中或选中失效则落到末尾,与「插入换行」同一套判定
2026-10-01 02:00:12 +08:00
梦圣智杰 78924e8244 fix: 隐藏区长文本胶囊不再被撑成大圆角块 / Keep long hidden-tag chips on one line
- 胶囊里的中文译文缺少 nowrap,长自然语言会在里面换行把高度撑起来,
- 中英文两段都加单行截断,胶囊高度恒定一行,超出部分显示省略号
2026-10-01 01:35:38 +08:00
梦圣智杰 905d165348 chore: 发布 v1.2.15 / Release v1.2.15 2026-09-30 01:57:49 +08:00
梦圣智杰 3806ae7ae1 chore:添加了在ComfyUI注册表上面的图标/Added an icon on the ComfyUI registry 2026-09-30 01:53:44 +08:00
梦圣智杰 45eb64a5bf feat: 双击标签卡的编辑框加「词条信息」页签 / Add an entry-info tab to the tag editor
- 标签字段抽成 buildTagFields,弹窗与页签共用同一份
- 新页签展开完整词条
- 命中词库就整条带回来自改,没命中按当前标签卡起个头
- 「Anima 格式」说明挪进权重页,「清除权重」只在权重页显示
- 页签状态由 onColorTab 布尔改成 activeTab 三态,共用底部 Apply 分派
2026-09-29 21:07:39 +08:00
梦圣智杰 c19242700e chore: 发布 v1.2.14 / Release v1.2.14 2026-09-29 03:05:30 +08:00
梦圣智杰 6fe88a6bae feat: 标签弹窗的分类改成可筛选下拉 / Turn the category field into a filterable dropdown
- 候选取词库里真实出现过的分类,按词条数降序,每项标出条数
- 「自定义」恒为列表第一项且不被过滤掉,选中后输入框右侧挂青绿色标识
- 手打的值命中候选时自动摘掉标识;编辑历史标签带进来的陌生值当场算自定义
- 输入即筛选,↑↓ 切换、回车确认、Esc 收起,↓ 或再点输入框可重新唤出列表
- 新建时分类留空,由后端落成「自定义」,避免一开窗就挂着标识
2026-09-29 02:52:21 +08:00
梦圣智杰 238612920c feat: 标签卡支持按词库、按 Anima 上色,并标记疑似自然语言 / Color tag cards by dictionary or Anima, flag suspected natural language
- 过滤栏「颜色」改成就地展开面板:默认 / 按收录 / 随机 / 按词库 / 按 Anima
- 配色写入 data/token_colors.json(pack_colors / anima_colors),新增写接口
- 新增「疑似自然语言」种类(空格 > 3),节点卡片与明细表着色
- 自然语言词库拆出独立 pack_id
- 双击设的自定义色补上优先级说明
2026-09-29 02:18:01 +08:00
梦圣智杰 84d3430576 feat: 识别并标记「疑似自然语言」,新增自然语言词库 / Flag suspected natural language and add a natural language dictionary 2026-09-29 00:22:12 +08:00
梦圣智杰 125654864c fix: 优化为 Anima 不再被转义符、句点和权重小数打断 / Fix Anima optimization output rejected by escapes, stray periods and decimal weights
- assistant_store:清洗表补全角引号;清掉模型写成 JSON/代码字符串时残留的反斜杠(字面 \n \t 先还原成换行,其余孤立反斜杠删除)
- assistant_store:末尾孤立句点直接删除
- parser:判定句末标点前先中和数字句点与闭括号后的句点,权重小数不再让整串标签被当成自然语言段落
- 测试:新增转义残留、句点替换、权重保留等断言;既有「@artist!」用例同步改为清洗后不带句点
2026-09-28 22:49:04 +08:00
梦圣智杰 97c87043d7 ci: 后端测试前安装 aiohttp / Install aiohttp before backend tests 2026-09-27 21:23:17 +08:00
梦圣智杰 36daee2bed ci: 接入 Comfy Registry 自动发布 / Add registry publish workflow 2026-09-27 21:05:12 +08:00
28 changed files with 2304 additions and 300 deletions
+27
View File
@@ -0,0 +1,27 @@
name: Publish to Comfy Registry
# 只在并入 main 时发布:分支上的改动(哪怕改了 pyproject.toml)不会触发,
# 因此"功能做完再合进 main"的开发节奏是安全的——合进来时才对外发布一次。
# 触发条件是 pyproject.toml 变动,实际等价于"版本号升了才发";
# 版本号没升时 Registry 会报 Version already exists,本次 job 会失败,属预期。
on:
workflow_dispatch:
push:
branches:
- main
paths:
- "pyproject.toml"
permissions:
contents: read
jobs:
publish-node:
name: Publish Custom Node to registry
runs-on: ubuntu-latest
steps:
# publish-node-action 自带 checkout(默认 skip_checkout=false),不要再写一遍
- name: Publish Custom Node
uses: Comfy-Org/publish-node-action@main
with:
personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }}
+5 -1
View File
@@ -29,4 +29,8 @@ jobs:
run: npm test
- name: Run backend tests
run: python -m unittest discover -s tests -p "test_*.py"
# server.py 依赖 aiohttp(由 ComfyUI 运行时提供,不作为插件依赖声明),
# test_upstream_gate 导入它,因此 CI 上要单独装一次。
run: |
pip install aiohttp
python -m unittest discover -s tests -p "test_*.py"
+44
View File
@@ -1,5 +1,49 @@
# 更新记录
## v2.0.0(2026-10-10)
### 新增「重复检查」节点
- 独立节点,检测一段提示词里的重复标签,把重复数量报出来;无重复时节点保持默认配色,有重复时转成淡橙色提示。
- 开关「只保留第一个」默认关闭,打开后只保留每个标签第一次出现的写法。
- 提示词直通下游,重复项另外输出,接预览任意可看。判定口径与双语节点完全一致(大小写、空格、下划线、权重、强调括号都视为同一个标签,`<lora:…>` 与 `BREAK`/`AND` 不参与)。
### 重复标签可以设「唯一」,并新增重复栏
- 「重复标签:X」后面给每个标签加了独立的「唯一」开关:打开后只有第一次出现会进实际输出,其余在界面上划线保留、**原文一个字不动**,支持 `Ctrl+Z` 撤回。
- 英文标签区、「已隐藏」那一栏下面新增**重复栏**:一个标签一个胶囊,单击依次跳到每一次出现并联动选中(当前那处标红),已经定位时双击就删掉当前这一处。
- 重复分组抽成 `parser.js` 的 `duplicateGroups()`,问题提示行、重复栏、重复检查节点共用同一份口径,不会再各算一套。
### 其它
- 标签卡上的隐藏(眼睛)按钮不再跟着英文行走,固定在整张卡片的右上角内侧;鼠标移到卡片任意位置(含中文行)都会显示它。
## v1.2.18(2026-10-05)
- **双击标签卡打开的编辑框支持一次改多个标签**:多选后双击,编辑范围是「最前一个选中标签的开头」到「最后一个选中标签的结尾」,中间没选中的标签也一起带进来;只在选中多于一个时给标签卡和明细表行加描边。
- **「编辑」页签改成整段改写**:把这一段原文整块剪切出来改、改完粘回去(新增 `replacePromptSpan()`),不再是逐个标签拼装。清空即删除这一段,接缝处多余的逗号会被收掉,纯换行原样保留,多行排版不会被压平。
- 标签字段抽成 `buildTagFields()`,编辑框与侧边栏「新增/编辑个人标签」弹窗共用同一份字段,不会再两处走样;弹窗与面板的样式细节同步调整。
## v1.2.16(2026-10-01)
- **快捷添加会插到选中标签后面**:选中某个标签时,新增的标签按它的结束位置插进去,不再一律丢到末尾;没选中或选中失效时仍落到末尾,判定与「插入换行」共用同一套。
- **隐藏区的长文本胶囊不再被撑成大圆角块**:胶囊里的中文译文缺了单行限制,遇到长自然语言会在里面折行把高度顶起来。中英文两段都改成单行截断,高度恒定一行,超出部分显示省略号。
## v1.2.15(2026-09-30)
- **双击标签卡打开的编辑框新增「词条信息」页签**:现在三个页签——权重、颜色、词条信息。命中的词库词条会整条带过来改(分类、中文、来源、备注都能改),没命中就按当前标签卡起个头新建。词条字段抽成了 `buildTagFields()`,这个页签和侧边栏「新增/编辑个人标签」弹窗共用同一份字段,不会再两处走样。
- 编辑框里的「Anima 格式」说明挪进权重页,「清除权重」按钮也只在权重页显示(之前在别的页签上也会出现,容易误点)。页签状态由原来的 `onColorTab` 布尔改成 `activeTab` 三态,底部共用一个「应用」按钮按当前页签分派。
- Comfy Registry 的插件页面补上图标与横幅:`[tool.comfy]` 新增 `Icon`(400×400 方形)和 `Banner`(941×941 原图),在 Registry 节点页和 ComfyUI-Manager 里显示,方便按图找到本插件。
## v1.2.14(2026-09-29)
- **标签弹窗的「分类」改成可筛选下拉**(与「适合模型」同一套控件):候选取词库里**真实出现过**的分类,按词条数降序,每项标出条数;输入即筛选,`↑` `↓` 切换、回车确认、`Esc` 收起,按 `↓` 或再点输入框可重新唤出列表。「自定义」恒为列表第一项且不会被过滤掉,选中后输入框右侧挂青绿色标识,表示「这个值不是词库现成的分类」;手打的值命中候选时自动摘掉标识,编辑历史标签带进来的陌生值当场也算自定义。新建时分类留空、由后端落成「自定义」,避免一打开窗口就挂着标识。
- **标签卡新增两种上色方式**:过滤栏的「颜色」改成就地展开的面板,除原有的默认 / 按收录 / 随机外,新增**按词库**(每个词库一色)与**按 Anima**(按 Anima 官方分类上色)。配色写入 `data/token_colors.json` 的 `pack_colors` / `anima_colors` 并新增写接口;双击标签设的自定义色优先级最高。
- **识别并标记「疑似自然语言」**:英文片段里空格超过 3 个时判定为疑似自然语言——这是**形状上的怀疑**,跟「有没有被词库收录」是两回事,两者互不替代。节点卡片与明细表都会着色标出;只在默认/按收录这类诊断模式下显示,「按词库」「随机」这类配色方案不会被它盖住。
- 新增自然语言词库:从原词库里拆出独立的 `pack_id`,让自然语言片段有专门的来源标注。
- 修复「优化为 Anima」的结果被转义符、句点和权重小数打断:清洗表补全角引号;清掉模型把结果写成 JSON/代码字符串时残留的反斜杠(字面 `\n` `\t` 先还原成换行,其余孤立反斜杠删除);末尾孤立句点直接删除。
- 修复权重小数让整串标签被误判成自然语言段落:判定句末标点前先中和数字句点(如 `1.5`)与闭括号后的句点。
## v1.2.10(2026-09-27)
- **界面重构:英文输出与中文同步编辑合并成一张双语卡片**。节点里不再有上下两个编辑区,每个标签上面英文、下面中文,横排自动折行,节点一下子矮了一半。随之移除中文编辑框、编辑态、草稿、暂存、同步预览,以及「编辑文本/翻译/翻译并优化/展开编辑/同步到英文输出」这批只在中文区才需要的按钮;「优化为 Anima」改为直接处理英文提示词,出结果后先弹预览确认再写回。
+31 -13
View File
@@ -26,9 +26,23 @@ include credentials or private prompt content.
# 版本号与发布(改版本前必读)
## 一键升版本(平时用这个就够了)
python tools/bump_version.py 1.2.16
脚本会把下面 5 处全部改好,并在 `CHANGELOG.md` 顶部插入新的一节(正文留「- (待补充)」,自己填)。
只做字符串替换,`pyproject.toml` 里的 description / Icon / Banner 原样保留。
任何一处没匹配上就整体中止,不会留下改了一半的文件。
脚本跑完之后剩下的事(commit / push / 打 tag)自己来做:
git add -A && git commit -m 'chore: 发布 v1.2.16 / Release v1.2.16'
git push origin main
git tag -a v1.2.16 -m 'v1.2.16' && git push origin v1.2.16
## 需要同步修改的 5 个文件
升版本号时**这 5 处必须一起改**,漏一处就会出现"上架版本是 1.2.1、关于框还显示 1.2.0"这类不一致。
升版本号时**这 5 处必须一起改**(即上面脚本做的事,手动改时照这张表逐项核对),漏一处就会出现"上架版本是 1.2.1、关于框还显示 1.2.0"这类不一致。
| # | 文件 | 位置 | 当前写法 | 说明 |
|---|------|------|----------|------|
@@ -51,26 +65,30 @@ include credentials or private prompt content.
## 发布顺序
```bash
git push origin main
git tag -a v1.2.0 -m "v1.2.0" # 版本号换成当次版本
git push origin v1.2.0
```
然后在 GitHub 上基于该标签建 Release(内容取自 CHANGELOG 对应章节),最后:
Registry 的发布由 `.github/workflows/publish_action.yml` **自动完成**:并入 main 且 `pyproject.toml` 有变动
(实际就是版本号升了)时触发。所以在分支上怎么改都不会发布,只有合进 main 那一刻才对外发一次。
```bash
comfy node publish # 需要 registry.comfy.org 生成的 API key
git push origin main # 版本号已升 → 自动发布
git tag -a v1.2.10 -m "v1.2.10" # 标签只用于 GitHub Release 与回溯,Registry 不读它
git push origin v1.2.10
```
版本号没升又想重发时,到 GitHub 的 Actions → "Publish to Comfy Registry" → Run workflow(预留了 `workflow_dispatch`)。
本地 `comfy node publish` 只在 Actions 不可用时才用——它打的包是 **git 已跟踪的文件**,没 commit 的改动不会进包。
## 发布前必须确认(不可撤销)
- `pyproject.toml` 的 **`name`(`prompt-translator-manager`)发布后永久不可改**,且全局唯一
- **`version` 发布后不可覆盖**:1.2.0 发出去后,哪怕只改一个标点也只能发 1.2.1
- 确认 `.comfyignore` 没有误伤 `data/`(内置词库)、`assets/`(README 截图)、`locales/`(中文节点名)——排除了插件会直接跑不起来
## 自动发布(可选)
## 自动发布(已启用)
仓库里目前只有 `.github/workflows/tests.yml`,没有发布 workflow。想实现"升版本号 → 推 main → 自动发布",可加
`.github/workflows/publish_action.yml`(用 `Comfy-Org/publish-node-action@main`),并把 registry 的 API key
存为仓库 secret `REGISTRY_ACCESS_TOKEN`。该 workflow 只在 `pyproject.toml` 变动时触发。
`.github/workflows/publish_action.yml` 用 `Comfy-Org/publish-node-action@main`,凭据是仓库 secret
`REGISTRY_ACCESS_TOKEN`(在 registry.comfy.org 进自己的 publisher 页面生成 API Key 后,填到
Settings → Secrets and Variables → Actions → New repository secret)。
- 触发条件:`push` 到 `main` 且 `pyproject.toml` 有变动,或手动 `workflow_dispatch`。
- **所以每次要发布都必须 bump 版本号**:没升版本时 Registry 回 `Version already exists`,job 会报红,这是预期行为不是故障。
- 反过来,改了 `pyproject.toml` 里除 `version` 以外的字段也会触发发布,同样会因版本已存在而失败——非必要别动那些字段。
+3 -3
View File
@@ -1,4 +1,4 @@
# ComfyUI 双语提示词管理器 v1.2.10
# ComfyUI 双语提示词管理器 v2.0.0
> ComfyUI 双语提示词节点:把英文提示词拆成可点击的标签,边写边看中文解释、调顺序、试效果。
@@ -67,7 +67,7 @@
<td>提示词里的换行会渲染成行尾的 <code>↵</code> 块,标签区真的断行,中英两栏行数对齐。换行块本身也能拖动。</td>
</tr>
</table>
![Demo — 编辑](https://raw.githubusercontent.com/mengshengzhijie/tuchuan/master/assets/ComfyUI-Bilingual-Prompt-Inspector/demo-editing.gif)
![Demo — edit](https://raw.githubusercontent.com/mengshengzhijie/tuchuan/master/assets/ComfyUI-Bilingual-Prompt-Inspector/demo-editing.gif)
### 翻译与整理
@@ -168,7 +168,7 @@ git clone https://github.com/mengshengzhijie/ComfyUI-Bilingual-Prompt-Inspector.
3. 在标签视图里逐个检查:点标签看中文解释,双击调权重,拖动手柄排顺序。
4. 想确认某个标签到底有没有用?点眼睛图标隐藏它,重新出图对比,再一键恢复。
![Demo — 上手](https://raw.githubusercontent.com/mengshengzhijie/tuchuan/master/assets/ComfyUI-Bilingual-Prompt-Inspector/demo-run.gif)
![Demo — run](https://raw.githubusercontent.com/mengshengzhijie/tuchuan/master/assets/ComfyUI-Bilingual-Prompt-Inspector/demo-run.gif)
---
+3 -1
View File
@@ -1,13 +1,15 @@
from .nodes import BilingualPromptInspector
from .nodes import BilingualPromptInspector, DuplicateChecker
from . import server # noqa: F401 - importing registers local API routes
NODE_CLASS_MAPPINGS = {
"BilingualPromptInspector": BilingualPromptInspector,
"DuplicateChecker": DuplicateChecker,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"BilingualPromptInspector": "Prompt Translator & Manager",
"DuplicateChecker": "重复检查",
}
WEB_DIRECTORY = "./js"
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 726 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

+35 -4
View File
@@ -157,6 +157,30 @@ def baidu_split_query(text, max_chars=600, max_lines=30):
return chunks
# 权重的句点只出现在「冒号后的小数」里:(tag:1.2)、[tag:1.2]、<lora:name:0.8>。
# 断句用的句点则是「词 + 句点 + 空白」,形态完全不同,所以先把权重片段整段挑出来保护。
WEIGHT_FRAGMENT = re.compile(r"[(\[<][^\n]*?:\s*-?\d+(?:\.\d+)?\s*[)\]>]")
def replace_sentence_periods(text):
"""把权重之外的句点换成逗号。
模型按规则会用句点把末尾自然语言接在标签后面,但那会让整串标签被前端当成自然语言段。
权重片段整段跳过,片段外再要求句点前后都不是数字,双保险。
"""
def to_commas(segment):
return re.sub(r"(?<!\d)\s*\.\s*(?!\d)", ", ", segment)
parts = []
cursor = 0
for match in WEIGHT_FRAGMENT.finditer(text):
parts.append(to_commas(text[cursor:match.start()]))
parts.append(match.group(0))
cursor = match.end()
parts.append(to_commas(text[cursor:]))
return "".join(parts)
def sanitize_anima_prompt(value):
text = str(value or "").strip()
fenced = re.fullmatch(r"```(?:text|plaintext)?\s*([\s\S]*?)\s*```", text, re.IGNORECASE)
@@ -165,17 +189,23 @@ def sanitize_anima_prompt(value):
",": ",", "、": ",", ";": ",", ";": ",", "。": ".",
"!": ".", "!": ".", "?": ".", "?": ".", ":": ":",
"(": "(", ")": ")", "<": "<", ">": ">", "—": "-", "–": "-", "…": ".",
"“": "", "”": "", "‘": "'", "’": "'", '"': "", "`": "",
"“": "", "”": "", """: "", "‘": "'", "’": "'", "'": "'", '"': "", "`": "",
}
for source, target in replacements.items():
text = text.replace(source, target)
# 模型常把结果写成 JSON 或代码字符串,留下 \" \n 这类转义;先把字面换行/制表还原成换行,
# 再去掉其余孤立反斜杠,否则一个转义符就会让整条提示词判废。
text = re.sub(r"\\+[nrt]", "\n", text)
text = text.replace("\\", "")
text = re.sub(r"(?m)^\s*(?:[-*•]+|\d+[.)])\s*", "", text)
text = re.sub(r"[\r\n]+", ", ", text)
text = re.sub(r"\s*,\s*", ", ", text)
text = replace_sentence_periods(text)
text = re.sub(r",(?:\s*,)+", ",", text)
text = re.sub(r"(?<!\d)\s*\.\s*(?!\d)", ". ", text)
text = re.sub(r",\s*\.\s*", ". ", text)
text = re.sub(r"\s+", " ", text).strip(" ,")
# 模型常在结果末尾多留一个句点。规则里的句点是用来分隔末尾自然语言的,后面没有内容时
# 它没有意义,却会让前端把整串标签误判成自然语言段落,所以直接去掉。
text = re.sub(r"\s*[.]+\s*$", "", text)
if re.search(r"[\u3400-\u9fff]", text):
raise ValueError("助手返回的 Anima 提示词仍包含中文,请调整规则后重试")
allowed_punctuation = set(",.():@_-'<>%+/&")
@@ -488,7 +518,8 @@ def dictionary_translate(text, dictionary_store):
large = dictionary_store.search_large_tags(lookup_part, limit=30)
exact = [tag for tag in large if lookup_part == tag.get("chinese")]
if exact:
exact.sort(key=lambda tag: (tag.get("pack_id") == "personal", int(tag.get("post_count", 0))), reverse=True)
# 个人词库与自然语言词库都算本机自建,优先于内置词库(与 dictionary_store.USER_PACK_IDS 一致)
exact.sort(key=lambda tag: (tag.get("pack_id") in ("personal", "natural"), int(tag.get("post_count", 0))), reverse=True)
english = exact[0]["english"]
output.append(f"({english}:{weighted.group(2)})" if weighted else english)
else:
+14 -1
View File
@@ -22,5 +22,18 @@
"#5b7a8a",
"#8a8a5b",
"#5b8a5b"
]
],
"pack_colors": {
"base": "#8a6b5b",
"anima": "#7a8a5b",
"characters": "#5b8a72",
"poses": "#8a5b7a",
"camera": "#8a725b",
"clothing": "#5b8a8a",
"adult": "#5b8a5b",
"personal": "#8a8a5b",
"natural": "#5b8a72",
"unindexed": "#5b6e8a"
},
"anima_colors": {}
}
+14 -1
View File
@@ -12,6 +12,11 @@ from pathlib import Path
SCHEMA_VERSION = 1
PACK_SCHEMA_VERSION = 1
# 个人词库与自然语言词库共用 user_tags.json,靠 natural 标记分成两个 pack,
# 这样按词库筛选、配色都能把它们分开处理。
PERSONAL_PACK_ID = "personal"
NATURAL_PACK_ID = "natural"
USER_PACK_IDS = (PERSONAL_PACK_ID, NATURAL_PACK_ID)
MAX_TEXT_LENGTH = 500
MAX_ALIASES = 30
MAX_LARGE_LOOKUP_TERMS = 200
@@ -101,6 +106,8 @@ def validate_tag(value, *, default_source="user"):
"notes": notes,
"source": source,
"verified": bool(value.get("verified", default_source == "user")),
# 自然语言词库:与个人词库共用一个文件,靠这个标记区分来源
"natural": bool(value.get("natural", False)),
}
@@ -246,7 +253,13 @@ class DictionaryStore:
user = self.user_tags()
effective = {normalize_key(tag["english"]): tag for tag in builtin}
for tag in user:
clean = {**tag, "pack_id": "personal", "pack_name": "个人词库", "pack_source": "本机用户"}
natural = bool(tag.get("natural"))
clean = {
**tag,
"pack_id": NATURAL_PACK_ID if natural else PERSONAL_PACK_ID,
"pack_name": "自然语言词库" if natural else "个人词库",
"pack_source": "本机用户",
}
effective[normalize_key(tag["english"])] = clean
return {
"schema_version": SCHEMA_VERSION,
+173
View File
@@ -0,0 +1,173 @@
"""重复检查:切分、归一、去重都在这里,节点执行与前端实时提示共用同一份口径。
规则与前端 js/parser.js 保持一致(splitPrompt / analyzeSegment / normalizeKey),
改任一侧都要同步另一侧,否则节点上显示的重复数会和实际去掉的数量对不上。
"""
import re
# 段与段之间的间隙里只取第一个分隔符本身:段后的空白归下一段自己带(raw 里有前导空白),
# 两边都管就会在删掉中间段时丢掉空格(「standing, sitting」变成「standing,sitting」)。
_GAP_SEPARATOR = re.compile(r"^[ \t]*([,\r\n])")
# 间隙里只有分隔符和空白(比如两个标签之间隔了一个空行)时原样保留,
# 否则说明中间夹着被删掉的段,那点空白是被删段的前导,不能再计入。
_GAP_BLANK = re.compile(r"^[ \t]*[,\r\n][ \t\r\n]*$")
_SPECIAL_SEGMENT = re.compile(r"^<[^>]+>$")
_WEIGHTED = re.compile(r"^\((.+):\s*(-?(?:\d+(?:\.\d*)?|\.\d+))\)$", re.DOTALL)
_WHITESPACE = re.compile(r"\s+")
def strip_emphasis(value):
current = str(value or "").strip()
while len(current) >= 2 and _is_pair(current[0], current[-1]):
current = current[1:-1].strip()
return current
def _is_pair(open_char, close_char):
return (
(open_char == "(" and close_char == ")")
or (open_char == "[" and close_char == "]")
or (open_char == "{" and close_char == "}")
)
def normalize_key(value):
return _WHITESPACE.sub(" ", str(value or "").strip().lower().replace("_", " "))
def split_prompt(text):
"""按顶层逗号与换行切分;括号里、尖括号里、转义字符后面的分隔符都不算切分点。"""
value = str(text or "")
segments = []
start = 0
round_depth = 0
square_depth = 0
curly_depth = 0
angle_depth = 0
escaped = False
for index, char in enumerate(value):
if escaped:
escaped = False
continue
if char == "\\":
escaped = True
continue
if char == "<":
angle_depth += 1
elif char == ">" and angle_depth > 0:
angle_depth -= 1
elif angle_depth == 0:
if char == "(":
round_depth += 1
elif char == ")" and round_depth > 0:
round_depth -= 1
elif char == "[":
square_depth += 1
elif char == "]" and square_depth > 0:
square_depth -= 1
elif char == "{":
curly_depth += 1
elif char == "}" and curly_depth > 0:
curly_depth -= 1
if (
round_depth == 0
and square_depth == 0
and curly_depth == 0
and angle_depth == 0
and char in (",", "\n", "\r")
):
raw = value[start:index]
if raw.strip():
segments.append({"raw": raw, "start": start, "end": index})
start = index + 1
tail = value[start:]
if tail.strip():
segments.append({"raw": tail, "start": start, "end": len(value)})
return segments
def analyze_segment(raw):
"""剥掉权重与强调括号,得到用来判断重复的词条;纯特殊语法(<lora:...>)原样保留。"""
display = str(raw or "").strip()
if not display:
return None
if _SPECIAL_SEGMENT.match(display):
return {"term": display, "syntax": "special"}
term = display
weighted = _WEIGHTED.match(display)
if weighted:
term = weighted.group(1).strip()
term = strip_emphasis(term)
return {
"term": term,
"syntax": "operator" if term.upper() in ("BREAK", "AND") else "tag",
}
def find_duplicates(text):
"""返回重复报告:多余出现几处、重复词条、以及重复段的下标(保留第一次出现)。"""
value = str(text or "")
segments = split_prompt(value)
seen = {}
order = []
drop = []
for index, segment in enumerate(segments):
info = analyze_segment(segment["raw"])
if not info or info["syntax"] != "tag":
continue
key = normalize_key(info["term"])
if not key:
continue
if key in seen:
seen[key]["count"] += 1
drop.append(index)
else:
seen[key] = {"term": info["term"], "count": 1}
order.append(key)
return {
"extra": len(drop),
"terms": [seen[key] for key in order if seen[key]["count"] > 1],
"drop": drop,
"segments": segments,
}
def remove_segments(text, segments, drop):
"""删掉指定段并把剩下的段按原文的连接符接回去,不重写整段提示词的格式。"""
value = str(text or "")
doomed = set(drop or ())
keep = [index for index in range(len(segments)) if index not in doomed]
if not keep:
return ""
parts = []
previous_end = None
for index in keep:
segment = segments[index]
if previous_end is None:
parts.append(segment["raw"].strip())
else:
gap = value[previous_end:segment["start"]]
if _GAP_BLANK.match(gap):
# 只去掉首尾的空格,换行要留着(段落之间的空行是有意义的)
parts.append(gap.strip(" \t") or ",")
else:
matched = _GAP_SEPARATOR.match(gap)
parts.append(matched.group(1) if matched else ",")
parts.append(segment["raw"])
previous_end = segment["end"]
return "".join(parts)
def dedupe_prompt(text):
"""只保留第一次出现的标签,返回去重后的文本。"""
value = str(text or "")
report = find_duplicates(value)
if not report["drop"]:
return value
return remove_segments(value, report["segments"], report["drop"])
def format_duplicates(report):
"""重复项预览:一行一个「词条 ×次数」,没有重复时给空串。"""
return "\n".join(f"{item['term']} ×{item['count']}" for item in report["terms"])
+561 -172
View File
File diff suppressed because it is too large Load Diff
+570 -73
View File
@@ -1,6 +1,7 @@
import { app } from "../../scripts/app.js";
import { normalizePreferences } from "./dictionary_tools.js";
import { matchModelNames } from "./model_suggest.js";
import { ANIMA_SLOTS } from "./anima_sorter.js";
import {
LANGUAGE_CHANGED_EVENT,
applyLanguageToDom,
@@ -23,6 +24,14 @@ const MANAGER_TAB_ID = "bpi-manager";
const MANAGER_OPEN_EVENT = "bpi:open-manager";
// 后端把上游传来的提示词推给前端时使用的事件名
const UPSTREAM_ARRIVED_EVENT = "bpi/upstream-arrived";
// 按词库配色时,「没命中任何词库」也当成一个来源,与后端 token_color_store.UNINDEXED_PACK_ID 对齐
const UNINDEXED_PACK_ID = "unindexed";
// 骰子:点一下从 random_pool 里随机抽一个颜色
const DICE_ICON = '<svg viewBox="0 0 14 14" width="13" height="13" aria-hidden="true">'
+ '<rect x="1" y="1" width="12" height="12" rx="3" fill="none" stroke="currentColor" stroke-width="1.2"/>'
+ '<circle cx="4.5" cy="4.5" r="1.15" fill="currentColor"/><circle cx="9.5" cy="4.5" r="1.15" fill="currentColor"/>'
+ '<circle cx="7" cy="7" r="1.15" fill="currentColor"/>'
+ '<circle cx="4.5" cy="9.5" r="1.15" fill="currentColor"/><circle cx="9.5" cy="9.5" r="1.15" fill="currentColor"/></svg>';
let sessionTokenPromise = null;
let dictionaryPromise = null;
@@ -75,6 +84,17 @@ async function postUpstreamAction(action, nodeId, text = null) {
return result;
}
async function checkDuplicateTags(text) {
const response = await bpiFetch(`${API_ROOT}/duplicate-check`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: String(text ?? "") }),
});
const result = await response.json().catch(() => null);
if (!response.ok || !result?.success) throw new Error(result?.error || `Duplicate check failed (${response.status})`);
return result.data;
}
function loadPreferences() {
try {
return normalizePreferences(JSON.parse(localStorage.getItem(PREFERENCES_KEY) || "{}"));
@@ -198,6 +218,20 @@ function loadTokenColors(force = false) {
return tokenColorsPromise;
}
// 只传要更新的那一类;没传的那类后端保持原样
async function saveTokenColors({ pack_colors: packColors, anima_colors: animaColors } = {}) {
const response = await bpiFetch(`${API_ROOT}/token-colors/colors`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ pack_colors: packColors, anima_colors: animaColors }),
});
const result = await response.json();
if (!response.ok || !result.success) throw new Error(result.error || "Failed to save colors");
// 缓存失效:保存完节点那边要重新拉一次才能看到新配色
tokenColorsPromise = null;
return result.data;
}
async function saveAssistantConfig(payload) {
const response = await bpiFetch(`${API_ROOT}/assistant/config`, {
method: "POST",
@@ -689,24 +723,235 @@ function openSavePromptDialog({ name = "", text = "", note = null, models = [],
return { close };
}
// 分类候选:只收词库里真实出现过的 category,用得多的排前面。
// 不预置任何「应该有但还没用过」的分类;中英文混着也不翻译,原样列出。
function tagCategoryCounts(data) {
const counts = new Map();
for (const tag of data?.tags ?? []) {
const name = String(tag?.category ?? "").trim();
if (!name) continue;
counts.set(name, (counts.get(name) ?? 0) + 1);
}
return [...counts]
.map(([name, count]) => ({ name, count }))
.sort((left, right) => right.count - left.count || left.name.localeCompare(right.name, "zh-CN"));
}
// 「分类」输入框:下拉列出已有分类,边打字边收窄,第一项恒为「自定义」。
// 青绿色的「自定义」标识只表示「这个值不是词库里的现成分类」—— 选了自定义才会挂上,
// 手打的值一旦命中某个候选就自动摘掉;编辑历史标签时带进来的陌生值也当场算自定义。
function buildCategoryPicker({ initial = "", categories = [] } = {}) {
const root = element("div", "bpi-autocomplete");
const wrap = element("div", "bpi-input-wrap");
const input = element("input");
input.id = "bpi-field-category";
input.name = "category";
input.autocomplete = "off";
input.value = initial ?? "";
setPlaceholder(input, "Type to filter; ↑↓ to choose");
const badge = element("span", "bpi-custom-badge", "Custom");
badge.hidden = true;
const menu = element("div", "bpi-ac-menu");
menu.hidden = true;
wrap.append(input, badge);
root.append(wrap, menu);
let catalog = categories;
let rows = [];
let active = -1;
let custom = false;
const setCustom = (flag) => {
custom = flag;
badge.hidden = !flag;
};
const closeMenu = () => {
menu.hidden = true;
active = -1;
};
const renderMenu = () => {
menu.replaceChildren();
if (!rows.length) {
closeMenu();
return;
}
rows.forEach((row, index) => {
const item = element("div", `bpi-ac-item${index === active ? " bpi-ac-active" : ""}${row.custom ? " bpi-ac-custom" : ""}`);
// 分类名是词库数据,不进翻译层
const name = element("span", "bpi-ac-name");
name.textContent = row.custom ? t("Custom") : row.name;
const hint = element("span", "bpi-ac-kind");
hint.textContent = row.custom ? (input.value.trim() || t("Manual input")) : t(`${row.count} items`);
item.append(name, hint);
// 用 mousedown:等到 click 时输入框已经失焦、菜单早被关掉了
item.addEventListener("mousedown", (event) => {
event.preventDefault();
commit(row);
});
menu.appendChild(item);
});
menu.hidden = false;
};
const update = ({ reveal = true } = {}) => {
const needle = input.value.trim().toLowerCase();
const matches = catalog.filter((entry) => !needle || entry.name.toLowerCase().includes(needle));
rows = [{ custom: true }, ...matches];
if (!reveal) {
menu.hidden = true;
return;
}
// 打出来的字正好等于某个候选时,默认就落在它身上,省一次↓
const exact = rows.findIndex((row) => !row.custom && row.name === input.value.trim());
active = exact > 0 ? exact : 0;
renderMenu();
};
const commit = (row) => {
if (!row) return;
if (row.custom) {
setCustom(true);
} else {
input.value = row.name;
setCustom(false);
}
closeMenu();
input.focus();
};
input.addEventListener("input", () => {
if (custom && catalog.some((entry) => entry.name === input.value.trim())) setCustom(false);
update();
});
input.addEventListener("focus", () => update());
input.addEventListener("click", () => {
if (menu.hidden) update();
});
input.addEventListener("keydown", (event) => {
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
if (menu.hidden) {
update(); // 关着的时候先把列表开出来
return;
}
const step = event.key === "ArrowDown" ? 1 : -1;
active = (active + step + rows.length) % rows.length;
renderMenu();
return;
}
if (event.key === "Enter") {
// 菜单开着才拦回车;关着的时候回车照旧提交整个表单
if (menu.hidden) return;
event.preventDefault();
event.stopPropagation();
commit(rows[Math.max(0, active)]);
return;
}
if (event.key === "Escape" && !menu.hidden) {
event.stopPropagation();
closeMenu();
}
});
// Nodes 2.0 会在捕获阶段拦掉指针事件,所以点空白关闭也挂 document + 捕获
const onOutside = (event) => {
if (!root.contains(event.target)) closeMenu();
};
document.addEventListener("mousedown", onOutside, true);
return {
root,
destroy: () => document.removeEventListener("mousedown", onOutside, true),
value: () => input.value.trim(),
// 候选是异步取来的,取到之后不能把用户的菜单硬弹开
setCategories: (list) => {
catalog = list;
const current = input.value.trim();
if (current && !catalog.some((entry) => entry.name === current)) setCustom(true);
// 菜单本来开着就重画,没开就别突然弹出来打断用户
update({ reveal: !menu.hidden });
},
};
}
// 「标签字段」那一整套:独立的「新增/编辑个人标签」弹窗和节点里双击标签卡后的
// 「编辑」页签共用同一份,免得两边各抄一遍字段。
// withTarget = false 时不给「保存到哪个词库」的下拉:双击进来的一定是标签
// (自然语言片段进不了这个入口),恒存个人词库,让选项出现反而是噪音。
// readonlyEnglish:英文只显示、不给输入框。改它并不会改提示词,后端按英文做主键,
// 只会另存一条新词条、旧的还留着——要改提示词里的文字得走节点的「编辑」页。
function buildTagFields(parent, initial = {}, { withTarget = true, readonlyEnglish = false } = {}) {
const englishValue = String(initial?.english ?? "");
let english;
if (readonlyEnglish) {
const label = element("label", "", "English Tag");
const text = element("span", "bpi-field-static", englishValue);
parent.append(label, text);
// 兼容下面按 input 用法读写的地方:只提供 value / focus 两个口子
english = { value: englishValue, focus: () => {} };
} else {
english = field(parent, "English Tag", "english", initial?.english, "e.g.: looking at viewer");
}
const chinese = field(parent, "Chinese Name", "chinese", initial?.chinese, "e.g.: looking at viewer");
const aliases = field(parent, "Chinese Aliases", "aliases", initial?.aliases?.join(","), "Comma-separated");
const categoryLabel = element("label", "", "Category");
categoryLabel.htmlFor = "bpi-field-category";
// 分类留空也行:后端会把空值落成「自定义」,所以新建时不要预先塞值、免得一开窗就挂着标识
const categoryPicker = buildCategoryPicker({ initial: initial?.category ?? "" });
parent.append(categoryLabel, categoryPicker.root);
// 候选按词库实际用词实时汇总;取不到就退化成普通手输,这行本来也能打字
loadDictionary()
.then((data) => categoryPicker.setCategories(tagCategoryCounts(data)))
.catch(() => {});
const models = field(parent, "Models", "models", initial?.models?.join(",") ?? "general, anima", "Comma-separated");
const weight = field(parent, "Recommended Weight", "recommended_weight", initial?.recommended_weight ?? "", "Optional");
const notes = field(parent, "Notes", "notes", initial?.notes, "Optional");
let dictionary = null;
if (withTarget) {
const dictionaryLabel = element("label", "", "Save to Dictionary");
dictionaryLabel.htmlFor = "bpi-field-dictionary";
dictionary = element("select", "bpi-mode");
dictionary.id = dictionaryLabel.htmlFor;
dictionary.name = "dictionary";
for (const [value, label] of [["personal", "Personal dictionary"], ["natural", "Natural Language Dictionary"]]) {
const option = element("option", "", label);
option.value = value;
dictionary.appendChild(option);
}
dictionary.value = initial?.natural ? "natural" : "personal";
parent.append(dictionaryLabel, dictionary);
}
return {
destroy: () => categoryPicker.destroy(),
focus: () => (initial?.english ? chinese : english).focus(),
collect: () => ({
english: english.value,
chinese: chinese.value,
aliases: aliases.value,
category: categoryPicker.value(),
models: models.value,
recommended_weight: weight.value,
notes: notes.value,
natural: dictionary ? dictionary.value === "natural" : Boolean(initial?.natural),
source: initial?.source === "bpi-assistant" ? initial.source : "user",
verified: initial?.verified ?? true,
}),
};
}
function openTagDialog(initial, onSaved) {
const shade = element("div", "bpi-modal-shade");
const modal = element("div", "bpi-modal");
const title = element("h3", "", initial?.english ? "Add or Edit Personal Tag" : "Add Personal Tag");
const form = element("form", "bpi-form");
const english = field(form, "English Tag", "english", initial?.english, "e.g.: looking at viewer");
const chinese = field(form, "Chinese Name", "chinese", initial?.chinese, "e.g.: looking at viewer");
const aliases = field(form, "Chinese Aliases", "aliases", initial?.aliases?.join(","), "Comma-separated");
const category = field(form, "Category", "category", initial?.category ?? "Custom", "e.g.: pose, camera, style");
const models = field(form, "Models", "models", initial?.models?.join(",") ?? "general, anima", "Comma-separated");
const weight = field(form, "Recommended Weight", "recommended_weight", initial?.recommended_weight ?? "", "Optional");
const notes = field(form, "Notes", "notes", initial?.notes, "Optional");
const fields = buildTagFields(form, initial);
const error = element("div", "bpi-status");
error.dataset.kind = "error";
error.style.gridColumn = "1 / -1";
form.appendChild(error);
const actions = element("div", "bpi-modal-actions");
const close = () => shade.remove();
const close = () => {
fields.destroy();
shade.remove();
};
actions.append(
button("Cancel", close),
button("Save to Personal Dictionary", () => form.requestSubmit(), "bpi-primary"),
@@ -722,24 +967,235 @@ function openTagDialog(initial, onSaved) {
event.preventDefault();
error.textContent = "";
try {
await saveTag({
english: english.value,
chinese: chinese.value,
aliases: aliases.value,
category: category.value,
models: models.value,
recommended_weight: weight.value,
notes: notes.value,
source: initial?.source === "bpi-assistant" ? initial.source : "user",
verified: initial?.verified ?? true,
});
await saveTag(fields.collect());
close();
await onSaved();
} catch (saveError) {
error.textContent = saveError.message;
}
});
setTimeout(() => (initial?.english ? chinese : english).focus(), 0);
setTimeout(() => fields.focus(), 0);
}
// 颜色区能配色的「来源」:内置 / 社区词库包 + 大词库 + 两个本机词库 + 未收录。
// 后三者不在 packs 里(个人词库和自然语言词库共用 user_tags.json,未收录压根没有词条),得手工补。
function colorTargets(data) {
const userTags = data?.user ?? [];
const items = (count) => t(`${count ?? 0} items`);
const targets = (data?.packs ?? []).map((pack) => ({ id: pack.id, name: pack.name, meta: items(pack.count) }));
const large = data?.large_dictionary;
if (large?.available) {
targets.push({ id: "danbooru_large", name: large.name, meta: items(large.count) });
}
targets.push({ id: "personal", name: t("Personal dictionary"), meta: items(userTags.filter((tag) => !tag.natural).length) });
targets.push({ id: "natural", name: t("Natural Language Dictionary"), meta: items(userTags.filter((tag) => tag.natural).length) });
targets.push({ id: UNINDEXED_PACK_ID, name: t("Not indexed"), meta: t("Tags matching no dictionary") });
return targets;
}
// #RGB / #RRGGBB 统一成小写 6 位;认不出来返回空串(= 不着色)
function normalizeHexColor(value) {
const text = String(value ?? "").trim();
if (/^#[0-9a-fA-F]{6}$/.test(text)) return text.toLowerCase();
if (/^#[0-9a-fA-F]{3}$/.test(text)) return "#" + text.slice(1).split("").map((char) => char + char).join("").toLowerCase();
return "";
}
// 按 Anima 配色时的分类名:跟 anima_sorter.js 的 ANIMA_SLOTS 一一对应。
// 网格卡片窄,用短名,ANIMA_SLOTS 里的英文全称放 meta。
const ANIMA_SHORT_LABELS = {
quality: "Quality",
people: "People count",
character: "Character",
copyright: "Copyright",
artist: "Artist",
appearance: "Appearance",
action: "Expression",
camera: "Camera",
style: "Style",
environment: "Scene",
natural: "Natural language",
uncertain: "Uncertain",
};
function animaColorTargets() {
return ANIMA_SLOTS.map((slot) => ({
id: slot.id,
name: t(ANIMA_SHORT_LABELS[slot.id] ?? slot.label),
meta: slot.label,
}));
}
// 一块配色网格:按词库一块、按 Anima 一块,共用这套卡片。改动后由 onCommit 落盘。
function buildColorGrid({ targets, initial, presets, dicePool, error, onCommit }) {
const grid = element("div", "bpi-color-grid");
const picked = new Map();
const setters = [];
for (const target of targets) {
picked.set(target.id, initial?.[target.id] || "");
const card = element("div", "bpi-color-card");
const chip = element("span", "bpi-color-chip");
const head = element("div", "bpi-color-card-head");
head.append(chip, element("span", "", target.name));
const swatchRow = element("div", "bpi-color-swatches");
const swatches = [];
for (const hex of presets) {
const dot = element("i");
dot.style.background = hex;
dot.dataset.color = hex;
setTitle(dot, hex);
swatchRow.appendChild(dot);
swatches.push(dot);
}
const input = element("input");
setPlaceholder(input, "#6b9b78");
const dice = element("button", "bpi-color-dice");
dice.type = "button";
dice.innerHTML = DICE_ICON;
setTitle(dice, "Random color");
const row = element("div", "bpi-color-row");
row.append(input, dice, button("None", () => {
apply("");
onCommit();
}));
function apply(value) {
const color = normalizeHexColor(value);
picked.set(target.id, color);
chip.style.background = color || "transparent";
for (const dot of swatches) dot.classList.toggle("bpi-swatch-on", Boolean(color) && dot.dataset.color === color);
input.value = color;
error.textContent = "";
}
swatchRow.addEventListener("click", (event) => {
const hex = event.target?.dataset?.color;
if (!hex) return;
apply(hex);
onCommit();
});
dice.addEventListener("click", () => {
apply(dicePool[Math.floor(Math.random() * dicePool.length)]);
onCommit();
});
input.addEventListener("change", () => {
const value = input.value.trim();
if (!value) {
apply("");
onCommit();
return;
}
if (!normalizeHexColor(value)) {
error.textContent = t("Invalid color; use #RGB or #RRGGBB");
return;
}
apply(value);
onCommit();
});
apply(picked.get(target.id));
setters.push(apply);
card.append(head, element("div", "bpi-color-meta", target.meta), swatchRow, row);
grid.appendChild(card);
}
return {
root: grid,
values() {
const payload = {};
for (const [id, color] of picked) {
if (color) payload[id] = color;
}
return payload;
},
reset() {
for (const set of setters) set("");
},
};
}
// 颜色面板:节点过滤栏点「颜色」就在过滤栏下面就地展开,不做弹窗。
// 跟左边那排筛选按钮一个操作逻辑:点一下立刻生效;按词库 / 按 Anima 才展开对应的配色网格。
function buildColorPanel({ data, colors, mode, onChange }) {
const root = element("div", "bpi-color-panel");
const modes = [
["default", t("Default"), ["var(--bpi-border-2)"]],
["status", t("By inclusion"), ["#c08a4a", "#8a6bb0"]],
["random", t("Random"), ["#5b8a72", "#8a5b7a", "#5b6e8a"]],
["by_pack", t("By dictionary"), ["#6b9b78", "#6b7b9b", "#9b6b8b"]],
["by_anima", t("By Anima"), ["#9b8b6b", "#9b6b8b", "#6b9b9b"]],
];
// 旧存档里可能有不在选项里的值,回落到默认,别让按钮组一个都不亮
let currentMode = modes.some(([value]) => value === mode) ? mode : "default";
let live = colors;
const error = element("div", "bpi-color-error");
const presets = (colors?.presets ?? []).length ? colors.presets : ["#6b9b78"];
const dicePool = (colors?.random_pool ?? []).length ? colors.random_pool : presets;
const modeRow = element("div", "bpi-color-modes");
const modeButtons = new Map();
for (const [value, label, dots] of modes) {
const item = element("button", "bpi-color-mode");
item.type = "button";
for (const dot of dots) {
const mark = element("i", "bpi-color-mode-dot");
mark.style.background = dot;
item.appendChild(mark);
}
item.appendChild(element("span", "", label));
// 模式本身不落盘,只有配色才写文件
item.addEventListener("click", () => setMode(value));
modeButtons.set(value, item);
modeRow.appendChild(item);
}
async function commit() {
try {
live = await saveTokenColors({ pack_colors: packGrid.values(), anima_colors: animaGrid.values() });
error.textContent = "";
} catch (saveError) {
error.textContent = saveError.message;
return;
}
onChange(currentMode, live);
}
const gridOptions = { presets, dicePool, error, onCommit: commit };
const packGrid = buildColorGrid({ ...gridOptions, targets: colorTargets(data), initial: colors?.pack_colors });
const animaGrid = buildColorGrid({ ...gridOptions, targets: animaColorTargets(), initial: colors?.anima_colors });
const gridActions = (reset) => {
const actions = element("div", "bpi-color-panel-actions");
actions.appendChild(button("Restore defaults", () => {
reset();
commit();
}));
return actions;
};
const packWrap = element("div");
packWrap.append(
element("div", "bpi-color-note", t("Pick a color for each dictionary; uncolored ones keep the default.")),
packGrid.root,
gridActions(() => packGrid.reset()),
);
const animaWrap = element("div");
animaWrap.append(
element("div", "bpi-color-note", t("Pick a color for each Anima category; unset ones keep the default.")),
animaGrid.root,
gridActions(() => animaGrid.reset()),
);
const setMode = (value, notify = true) => {
currentMode = value;
for (const [id, item] of modeButtons) item.classList.toggle("bpi-color-mode-active", id === value);
packWrap.hidden = value !== "by_pack";
animaWrap.hidden = value !== "by_anima";
error.textContent = "";
if (notify) onChange(value, live);
};
root.append(modeRow, packWrap, animaWrap, error);
// 首次只是把当前模式点亮、决定网格显不显示,不该顺带触发一次重绘和同步
setMode(currentMode, false);
return root;
}
function openManagerPanel(section = "", nodeId = null) {
@@ -758,53 +1214,54 @@ function injectBpiStyles() {
const style = document.createElement("style");
style.id = "bpi-styles";
style.textContent = `
:root{--bpi-surface:rgba(255,255,255,.92);--bpi-surface-2:#f5f5f5;--bpi-surface-3:#eaeaea;--bpi-surface-table:#f0f0f0;--bpi-surface-modal:#fff;--bpi-text:#333;--bpi-text-muted:#666;--bpi-text-faint:#999;--bpi-border:#ccc;--bpi-border-2:#bbb;--bpi-border-3:#e0e0e0;--bpi-button-bg:#e8e8e8;--bpi-button-text:#333;--bpi-input-bg:#fff;--bpi-input-text:#333;--bpi-head-bg:#e8e8e8;--bpi-editor-bg:#fff}
.dark-theme{--bpi-surface:rgba(10,12,18,.72);--bpi-surface-2:#171b22;--bpi-surface-3:#14181f;--bpi-surface-table:#11151b;--bpi-surface-modal:#20252d;--bpi-text:#e6edf7;--bpi-text-muted:#aab5c5;--bpi-text-faint:#7f8a9a;--bpi-border:#3a4250;--bpi-border-2:#4c5668;--bpi-border-3:#29313d;--bpi-button-bg:#2c3340;--bpi-button-text:#e8edf5;--bpi-input-bg:#171b22;--bpi-input-text:#eef3fb;--bpi-head-bg:#252c36;--bpi-editor-bg:#111923}
[data-bpi-theme="dark"]{--bpi-surface:rgba(10,12,18,.72);--bpi-surface-2:#171b22;--bpi-surface-3:#14181f;--bpi-surface-table:#11151b;--bpi-surface-modal:#20252d;--bpi-text:#e6edf7;--bpi-text-muted:#aab5c5;--bpi-text-faint:#7f8a9a;--bpi-border:#3a4250;--bpi-border-2:#4c5668;--bpi-border-3:#29313d;--bpi-button-bg:#2c3340;--bpi-button-text:#e8edf5;--bpi-input-bg:#171b22;--bpi-input-text:#eef3fb;--bpi-head-bg:#252c36;--bpi-editor-bg:#111923}
[data-bpi-theme="light"]{--bpi-surface:rgba(255,255,255,.92);--bpi-surface-2:#f5f5f5;--bpi-surface-3:#eaeaea;--bpi-surface-table:#f0f0f0;--bpi-surface-modal:#fff;--bpi-text:#333;--bpi-text-muted:#666;--bpi-text-faint:#999;--bpi-border:#ccc;--bpi-border-2:#bbb;--bpi-border-3:#e0e0e0;--bpi-button-bg:#e8e8e8;--bpi-button-text:#333;--bpi-input-bg:#fff;--bpi-input-text:#333;--bpi-head-bg:#e8e8e8;--bpi-editor-bg:#fff}
:root{--bpm-accent:#45926f;--bpm-accent-hover:#3a7d5e;--bpm-accent-border:#35755a;--bpi-custom-bg:#e6f6f1;--bpi-custom-border:#7fc9b0;--bpi-custom-text:#0f6e56;--bpi-surface:rgba(255,255,255,.92);--bpi-surface-2:#f5f5f5;--bpi-surface-3:#eaeaea;--bpi-surface-table:#f0f0f0;--bpi-surface-modal:#fff;--bpi-text:#333;--bpi-text-muted:#666;--bpi-text-faint:#999;--bpi-border:#ccc;--bpi-border-2:#bbb;--bpi-border-3:#e0e0e0;--bpi-button-bg:#e8e8e8;--bpi-button-text:#333;--bpi-input-bg:#fff;--bpi-input-text:#333;--bpi-head-bg:#e8e8e8;--bpi-danger-bg:#c0392b;--bpi-danger-bg-hover:#a53023;--bpi-danger-border:#a53023;--bpi-danger-text:#fff;--bpi-break-bg:#e6f6f1;--bpi-break-bg-hover:#0f6e56;--bpi-break-border:#7fc9b0;--bpi-break-text:#0f6e56;--bpi-primary-bg:#2b6cb0;--bpi-primary-bg-hover:#245a94;--bpi-primary-border:#1e4f85;--bpi-primary-text:#fff;--bpi-en-head-text:#17557f;--bpi-en-hint-text:#5b7f9e;--bpi-zh-head-text:#1f5c22;--bpi-zh-hint-text:#5b7d5e;--bpi-cat-name-text:#1e5a28;--bpi-info-text:#1f5f96;--bpi-link-text:#1a5f96;--bpi-zh-text:#18508a;--bpi-status-error:#b3261e;--bpi-status-ok:#1a7f45;--bpi-status-busy:#8a5a00;--bpi-badge-warn:#8a5a00;--bpi-badge-source:#0f6e56;--bpi-badge-suspected:#1c5f80;--bpi-badge-machine:#5b3fa8;--bpi-star-on:#a8750a;--bpi-star-off:#6b7280;--bpi-quick-head-text:#4a3a7a;--bpi-quick-hint-text:#6b5a8f;--bpi-handle-text:#4a6a85;--bpi-handle-hover:#1f5f96;--bpi-mini-hover-bg:#d8dee7;--bpi-handle-hover-bg:#e2e8ef;--bpi-chip-hide-bg:#eef1f5;--bpi-waiting-bg:#fdf1dc;--bpi-waiting-border:#d9a95a;--bpi-hidden-chip-hover-bg:#e8eef5;--bpi-issues-bg:#fdf6e8;--bpi-issues-border:#d9b98a;--bpi-issue-text:#7a5410;--bpi-row-hover-overlay:rgba(47,108,176,.06);--bpi-row-pinned-overlay:#cfe3f7;--bpi-row-hover-ring:rgba(47,108,176,.55);--bpi-row-pinned-ring:#1a5f96;--bpi-row-hover-text:#1f1f1f;--bpi-row-hover-shadow:none;--bpi-row-selected-bg:#dcebf8;--bpi-en-section-bg:rgba(43,108,176,.06);--bpi-quick-section-bg:rgba(107,85,148,.06);--bpi-dup-overlay:rgba(179,38,30,.12);--bpi-editor-bg:#fff}
.dark-theme{--bpi-custom-bg:#17342c;--bpi-custom-border:#3f8f77;--bpi-custom-text:#7fdcbd;--bpi-surface:rgba(10,12,18,.72);--bpi-surface-2:#171b22;--bpi-surface-3:#14181f;--bpi-surface-table:#11151b;--bpi-surface-modal:#20252d;--bpi-text:#e6edf7;--bpi-text-muted:#aab5c5;--bpi-text-faint:#7f8a9a;--bpi-border:#3a4250;--bpi-border-2:#4c5668;--bpi-border-3:#29313d;--bpi-button-bg:#2c3340;--bpi-button-text:#e8edf5;--bpi-input-bg:#171b22;--bpi-input-text:#eef3fb;--bpi-head-bg:#252c36;--bpi-danger-bg:#65313b;--bpi-danger-bg-hover:#7d3d49;--bpi-danger-border:#8d4653;--bpi-danger-text:#ffd9de;--bpi-break-bg:#17342c;--bpi-break-bg-hover:#0f6e56;--bpi-break-border:#3f8f77;--bpi-break-text:#7fdcbd;--bpi-primary-bg:#285f8e;--bpi-primary-bg-hover:#346f9f;--bpi-primary-border:#3f83ba;--bpi-primary-text:#eaf3fb;--bpi-en-head-text:#cbe8ff;--bpi-en-hint-text:#91b4cf;--bpi-zh-head-text:#cbe9c8;--bpi-zh-hint-text:#91ad93;--bpi-cat-name-text:#9ee8a7;--bpi-info-text:#87bfea;--bpi-link-text:#76c9ff;--bpi-zh-text:#9ed0ff;--bpi-status-error:#ff8b93;--bpi-status-ok:#7fdda2;--bpi-status-busy:#ffd27a;--bpi-badge-warn:#e2ca78;--bpi-badge-source:#86d5c4;--bpi-badge-suspected:#7fc4e0;--bpi-badge-machine:#cbb1ff;--bpi-star-on:#ffd45f;--bpi-star-off:#8f9aaa;--bpi-quick-head-text:#d9ccff;--bpi-quick-hint-text:#a795c6;--bpi-handle-text:#7194ad;--bpi-handle-hover:#a9c8e4;--bpi-mini-hover-bg:#3c4b60;--bpi-handle-hover-bg:#2b3a4d;--bpi-chip-hide-bg:#1d2937;--bpi-waiting-bg:rgba(70,52,25,.5);--bpi-waiting-border:#8a5f16;--bpi-hidden-chip-hover-bg:#31404f;--bpi-issues-bg:rgba(55,39,25,.55);--bpi-issues-border:#5b4a36;--bpi-issue-text:#e6c792;--bpi-row-hover-overlay:rgba(76,167,232,.34);--bpi-row-pinned-overlay:#314b68;--bpi-row-hover-ring:rgba(76,167,232,.7);--bpi-row-pinned-ring:#76c9ff;--bpi-row-hover-text:#fff;--bpi-row-hover-shadow:0 0 7px rgba(109,190,255,.6);--bpi-row-selected-bg:#314b68;--bpi-en-section-bg:rgba(76,167,232,.08);--bpi-quick-section-bg:rgba(167,149,198,.08);--bpi-dup-overlay:rgba(255,139,147,.22);--bpi-editor-bg:#111923}
[data-bpi-theme="dark"]{--bpi-custom-bg:#17342c;--bpi-custom-border:#3f8f77;--bpi-custom-text:#7fdcbd;--bpi-surface:rgba(10,12,18,.72);--bpi-surface-2:#171b22;--bpi-surface-3:#14181f;--bpi-surface-table:#11151b;--bpi-surface-modal:#20252d;--bpi-text:#e6edf7;--bpi-text-muted:#aab5c5;--bpi-text-faint:#7f8a9a;--bpi-border:#3a4250;--bpi-border-2:#4c5668;--bpi-border-3:#29313d;--bpi-button-bg:#2c3340;--bpi-button-text:#e8edf5;--bpi-input-bg:#171b22;--bpi-input-text:#eef3fb;--bpi-head-bg:#252c36;--bpi-danger-bg:#65313b;--bpi-danger-bg-hover:#7d3d49;--bpi-danger-border:#8d4653;--bpi-danger-text:#ffd9de;--bpi-break-bg:#17342c;--bpi-break-bg-hover:#0f6e56;--bpi-break-border:#3f8f77;--bpi-break-text:#7fdcbd;--bpi-primary-bg:#285f8e;--bpi-primary-bg-hover:#346f9f;--bpi-primary-border:#3f83ba;--bpi-primary-text:#eaf3fb;--bpi-en-head-text:#cbe8ff;--bpi-en-hint-text:#91b4cf;--bpi-zh-head-text:#cbe9c8;--bpi-zh-hint-text:#91ad93;--bpi-cat-name-text:#9ee8a7;--bpi-info-text:#87bfea;--bpi-link-text:#76c9ff;--bpi-zh-text:#9ed0ff;--bpi-status-error:#ff8b93;--bpi-status-ok:#7fdda2;--bpi-status-busy:#ffd27a;--bpi-badge-warn:#e2ca78;--bpi-badge-source:#86d5c4;--bpi-badge-suspected:#7fc4e0;--bpi-badge-machine:#cbb1ff;--bpi-star-on:#ffd45f;--bpi-star-off:#8f9aaa;--bpi-quick-head-text:#d9ccff;--bpi-quick-hint-text:#a795c6;--bpi-handle-text:#7194ad;--bpi-handle-hover:#a9c8e4;--bpi-mini-hover-bg:#3c4b60;--bpi-handle-hover-bg:#2b3a4d;--bpi-chip-hide-bg:#1d2937;--bpi-waiting-bg:rgba(70,52,25,.5);--bpi-waiting-border:#8a5f16;--bpi-hidden-chip-hover-bg:#31404f;--bpi-issues-bg:rgba(55,39,25,.55);--bpi-issues-border:#5b4a36;--bpi-issue-text:#e6c792;--bpi-row-hover-overlay:rgba(76,167,232,.34);--bpi-row-pinned-overlay:#314b68;--bpi-row-hover-ring:rgba(76,167,232,.7);--bpi-row-pinned-ring:#76c9ff;--bpi-row-hover-text:#fff;--bpi-row-hover-shadow:0 0 7px rgba(109,190,255,.6);--bpi-row-selected-bg:#314b68;--bpi-en-section-bg:rgba(76,167,232,.08);--bpi-quick-section-bg:rgba(167,149,198,.08);--bpi-dup-overlay:rgba(255,139,147,.22);--bpi-editor-bg:#111923}
[data-bpi-theme="light"]{--bpi-custom-bg:#e6f6f1;--bpi-custom-border:#7fc9b0;--bpi-custom-text:#0f6e56;--bpi-surface:rgba(255,255,255,.92);--bpi-surface-2:#f5f5f5;--bpi-surface-3:#eaeaea;--bpi-surface-table:#f0f0f0;--bpi-surface-modal:#fff;--bpi-text:#333;--bpi-text-muted:#666;--bpi-text-faint:#999;--bpi-border:#ccc;--bpi-border-2:#bbb;--bpi-border-3:#e0e0e0;--bpi-button-bg:#e8e8e8;--bpi-button-text:#333;--bpi-input-bg:#fff;--bpi-input-text:#333;--bpi-head-bg:#e8e8e8;--bpi-danger-bg:#c0392b;--bpi-danger-bg-hover:#a53023;--bpi-danger-border:#a53023;--bpi-danger-text:#fff;--bpi-break-bg:#e6f6f1;--bpi-break-bg-hover:#0f6e56;--bpi-break-border:#7fc9b0;--bpi-break-text:#0f6e56;--bpi-primary-bg:#2b6cb0;--bpi-primary-bg-hover:#245a94;--bpi-primary-border:#1e4f85;--bpi-primary-text:#fff;--bpi-en-head-text:#17557f;--bpi-en-hint-text:#5b7f9e;--bpi-zh-head-text:#1f5c22;--bpi-zh-hint-text:#5b7d5e;--bpi-cat-name-text:#1e5a28;--bpi-info-text:#1f5f96;--bpi-link-text:#1a5f96;--bpi-zh-text:#18508a;--bpi-status-error:#b3261e;--bpi-status-ok:#1a7f45;--bpi-status-busy:#8a5a00;--bpi-badge-warn:#8a5a00;--bpi-badge-source:#0f6e56;--bpi-badge-suspected:#1c5f80;--bpi-badge-machine:#5b3fa8;--bpi-star-on:#a8750a;--bpi-star-off:#6b7280;--bpi-quick-head-text:#4a3a7a;--bpi-quick-hint-text:#6b5a8f;--bpi-handle-text:#4a6a85;--bpi-handle-hover:#1f5f96;--bpi-mini-hover-bg:#d8dee7;--bpi-handle-hover-bg:#e2e8ef;--bpi-chip-hide-bg:#eef1f5;--bpi-waiting-bg:#fdf1dc;--bpi-waiting-border:#d9a95a;--bpi-hidden-chip-hover-bg:#e8eef5;--bpi-issues-bg:#fdf6e8;--bpi-issues-border:#d9b98a;--bpi-issue-text:#7a5410;--bpi-row-hover-overlay:rgba(47,108,176,.06);--bpi-row-pinned-overlay:#cfe3f7;--bpi-row-hover-ring:rgba(47,108,176,.55);--bpi-row-pinned-ring:#1a5f96;--bpi-row-hover-text:#1f1f1f;--bpi-row-hover-shadow:none;--bpi-row-selected-bg:#dcebf8;--bpi-en-section-bg:rgba(43,108,176,.06);--bpi-quick-section-bg:rgba(107,85,148,.06);--bpi-dup-overlay:rgba(179,38,30,.12);--bpi-editor-bg:#fff}
.bpi-panel{box-sizing:border-box;width:100%;height:100%;min-height:var(--bpi-collapsed-height,390px);max-height:var(--bpi-collapsed-height,390px);padding:8px;display:flex;flex-direction:column;gap:7px;color:var(--bpi-text);font:12px/1.4 Arial,sans-serif;background:var(--bpi-surface);border:1px solid var(--bpi-border);border-radius:8px;overflow-y:auto;scrollbar-gutter:stable}
.bpi-english-section{flex:none;border:1px solid #3e7197;border-radius:7px;background:var(--bpi-surface);overflow:hidden}.bpi-english-head{display:flex;align-items:center;gap:7px;padding:5px 8px;background:var(--bpi-head-bg);color:#cbe8ff;font-weight:700;flex-wrap:wrap}.bpi-english-hint{margin-left:auto;color:#91b4cf;font-size:10px;font-weight:400}.bpi-english-token-view{box-sizing:border-box;width:100%;height:auto;min-height:92px;max-height:360px;overflow:auto;scrollbar-gutter:stable;padding:8px 9px;outline:none;color:var(--bpi-text);font:12px/1.85 Consolas,monospace;white-space:normal}.bpi-english-token-view:focus{box-shadow:inset 0 0 0 1px #5aa6d8}.bpi-english-editor{box-sizing:border-box;width:100%;height:120px;min-height:92px;max-height:420px;overflow-y:auto;resize:vertical;border:0;border-top:1px solid #3e7197;background:var(--bpi-editor-bg);color:var(--bpi-text);padding:9px;outline:none;font:12px/1.55 Consolas,monospace}.bpi-english-editor:focus{box-shadow:inset 0 0 0 1px #5aa6d8}.bpi-english-token{font-family:Consolas,monospace;color:var(--bpi-text)}.bpi-english-token.bpi-linked{background:#147fc3;border-color:#76c9ff;color:#fff;box-shadow:0 0 0 1px rgba(118,201,255,.32)}
.bpi-mirror-section{flex:none;border:1px solid #488739;border-radius:7px;background:var(--bpi-surface);overflow:visible}.bpi-section-head{display:flex;align-items:center;gap:7px;padding:5px 8px;background:var(--bpi-head-bg);color:#cbe9c8;font-weight:700;flex-wrap:wrap}.bpi-section-hint{margin-left:auto;color:#91ad93;font-size:10px;font-weight:400}.bpi-mirror-actions{display:flex;gap:4px;align-items:center;flex-wrap:wrap}.bpi-chinese-mirror{box-sizing:border-box;width:100%;height:auto;min-height:92px;max-height:360px;overflow:auto;resize:none;scrollbar-gutter:stable;padding:8px 9px;outline:none;color:var(--bpi-text);line-height:1.85;cursor:text;white-space:normal}.bpi-chinese-mirror:focus{box-shadow:inset 0 0 0 1px #70b35f}.bpi-category-table{display:grid;grid-template-columns:minmax(110px,150px) minmax(0,1fr);border:1px solid #315f3a;border-radius:6px;overflow:hidden;background:var(--bpi-surface-3)}.bpi-category-row{display:contents}.bpi-category-name,.bpi-category-content{padding:6px 8px;border-top:1px solid #31513a}.bpi-category-row:first-child .bpi-category-name,.bpi-category-row:first-child .bpi-category-content{border-top:0}.bpi-category-name{background:var(--bpi-head-bg);border-right:1px solid #31513a;color:#9ee8a7;font-weight:700}.bpi-category-content{min-width:0}.bpi-chinese-editor{box-sizing:border-box;width:100%;height:auto;min-height:110px;max-height:600px;overflow-y:auto;resize:vertical;border:0;border-top:1px solid #3b693c;background:var(--bpi-editor-bg);color:var(--bpi-text);padding:9px;outline:none;font:12px/1.65 Arial,sans-serif}.bpi-chinese-editor:focus{box-shadow:inset 0 0 0 1px #70b35f}.bpi-hidden{display:none!important}.bpi-mirror-empty{color:var(--bpi-text-muted)}.bpi-mirror-token{display:inline-flex;align-items:center;border:1px solid transparent;border-radius:5px;padding:0 3px;margin:1px 0;cursor:pointer;transition:background .12s,border-color .12s,color .12s}.bpi-mirror-token:hover{background:#345a3b;border-color:#5c8d63;color:#fff}.bpi-mirror-token.bpi-linked{background:#315f83;border-color:#76a9ff;color:#fff}
.bpi-english-section{flex:none;border:1px solid #3e7197;border-radius:7px;background-color:var(--bpi-en-section-bg);overflow:hidden}.bpi-english-head{display:flex;align-items:center;gap:7px;padding:5px 8px;background:var(--bpi-head-bg);color:var(--bpi-en-head-text);font-weight:700;flex-wrap:wrap}.bpi-english-hint{margin-left:auto;color:var(--bpi-en-hint-text);font-size:10px;font-weight:400}.bpi-english-token-view{box-sizing:border-box;width:100%;height:auto;min-height:92px;max-height:360px;overflow:auto;scrollbar-gutter:stable;padding:8px 9px;outline:none;color:var(--bpi-text);font:12px/1.85 Consolas,monospace;white-space:normal}.bpi-english-token-view:focus{box-shadow:inset 0 0 0 1px #5aa6d8}.bpi-english-editor{box-sizing:border-box;width:100%;height:120px;min-height:92px;max-height:420px;overflow-y:auto;resize:vertical;border:0;border-top:1px solid #3e7197;background-color:transparent;color:var(--bpi-text);padding:9px;outline:none;font:12px/1.55 Consolas,monospace}.bpi-english-editor:focus{box-shadow:inset 0 0 0 1px #5aa6d8}.bpi-english-token{font-family:Consolas,monospace;color:var(--bpi-text)}.bpi-english-token.bpi-linked{background:#147fc3;border-color:#76c9ff;color:#fff;box-shadow:0 0 0 1px rgba(118,201,255,.32)}
.bpi-mirror-section{flex:none;border:1px solid #488739;border-radius:7px;background:var(--bpi-surface);overflow:visible}.bpi-section-head{display:flex;align-items:center;gap:7px;padding:5px 8px;background:var(--bpi-head-bg);color:var(--bpi-zh-head-text);font-weight:700;flex-wrap:wrap}.bpi-section-hint{margin-left:auto;color:var(--bpi-zh-hint-text);font-size:10px;font-weight:400}.bpi-mirror-actions{display:flex;gap:4px;align-items:center;flex-wrap:wrap}.bpi-chinese-mirror{box-sizing:border-box;width:100%;height:auto;min-height:92px;max-height:360px;overflow:auto;resize:none;scrollbar-gutter:stable;padding:8px 9px;outline:none;color:var(--bpi-text);line-height:1.85;cursor:text;white-space:normal}.bpi-chinese-mirror:focus{box-shadow:inset 0 0 0 1px #70b35f}.bpi-category-table{display:grid;grid-template-columns:minmax(110px,150px) minmax(0,1fr);border:1px solid #315f3a;border-radius:6px;overflow:hidden;background:var(--bpi-surface-3)}.bpi-category-row{display:contents}.bpi-category-name,.bpi-category-content{padding:6px 8px;border-top:1px solid #31513a}.bpi-category-row:first-child .bpi-category-name,.bpi-category-row:first-child .bpi-category-content{border-top:0}.bpi-category-name{background:var(--bpi-head-bg);border-right:1px solid #31513a;color:var(--bpi-cat-name-text);font-weight:700}.bpi-category-content{min-width:0}.bpi-chinese-editor{box-sizing:border-box;width:100%;height:auto;min-height:110px;max-height:600px;overflow-y:auto;resize:vertical;border:0;border-top:1px solid #3b693c;background:var(--bpi-editor-bg);color:var(--bpi-text);padding:9px;outline:none;font:12px/1.65 Arial,sans-serif}.bpi-chinese-editor:focus{box-shadow:inset 0 0 0 1px #70b35f}.bpi-hidden{display:none!important}.bpi-mirror-empty{color:var(--bpi-text-muted)}.bpi-mirror-token{display:inline-flex;align-items:center;border:1px solid transparent;border-radius:5px;padding:0 3px;margin:1px 0;cursor:pointer;transition:background .12s,border-color .12s,color .12s}.bpi-mirror-token:hover{background:#345a3b;border-color:#5c8d63;color:#fff}.bpi-mirror-token.bpi-linked{background:#315f83;border-color:#76a9ff;color:#fff}
.bpi-mirror-token.bpi-linked,.bpi-english-token.bpi-linked{background:#147fc3;border-color:#76c9ff;color:#fff;box-shadow:0 0 0 1px rgba(118,201,255,.32)}.bpi-english-token-view .bpi-mirror-separator{color:#7194ad}
.bpi-toolbar,.bpi-search-line,.bpi-summary{display:flex;gap:6px;align-items:center;flex-wrap:wrap}
.bpi-toolbar{justify-content:space-between}.bpi-toolbar-group{display:flex;gap:5px;align-items:center;flex-wrap:wrap}
.bpi-button{border:1px solid var(--bpi-border-2);border-radius:5px;padding:4px 8px;background:var(--bpi-button-bg);color:var(--bpi-button-text);cursor:pointer;font-size:11px;line-height:1.25}
.bpi-button:hover{background:var(--bpi-border-3);border-color:#6c83a8}.bpi-button:disabled{opacity:.45;cursor:default}.bpi-button.bpi-primary{background:#285f8e;border-color:#3f83ba}.bpi-button.bpi-danger{background:#65313b;border-color:#8d4653}
.bpi-button:hover{background:var(--bpi-border-3);border-color:#6c83a8}.bpi-button:disabled{opacity:.45;cursor:default}.bpi-button.bpi-primary,.bpi-mini.bpi-primary{background:var(--bpi-primary-bg);border-color:var(--bpi-primary-border);color:var(--bpi-primary-text)}.bpi-button.bpi-primary:hover,.bpi-mini.bpi-primary:hover{background:var(--bpi-primary-bg-hover);border-color:var(--bpi-primary-border);color:var(--bpi-primary-text)}.bpi-button.bpi-danger{background:var(--bpi-danger-bg);border-color:var(--bpi-danger-border);color:var(--bpi-danger-text)}.bpi-button.bpi-danger:hover,.bpi-mini.bpi-danger:hover{background:var(--bpi-danger-bg-hover);border-color:var(--bpi-danger-border);color:var(--bpi-danger-text)}.bpi-mini.bpi-danger{background:var(--bpi-danger-bg);border-color:var(--bpi-danger-border);color:var(--bpi-danger-text)}
.bpi-search{flex:1;min-width:140px;border:1px solid var(--bpi-border-2);border-radius:5px;background:var(--bpi-input-bg);color:var(--bpi-input-text);padding:5px 7px;outline:none}.bpi-search:focus{border-color:#4ca7e8}.bpi-searching{padding:5px 8px;color:var(--bpi-text-muted);font-size:10px}
.bpi-mode{border:1px solid var(--bpi-border-2);border-radius:5px;background:var(--bpi-input-bg);color:var(--bpi-input-text);padding:5px 6px;font-size:11px;outline:none}.bpi-mode:focus{border-color:#4ca7e8}.bpi-mode-info{color:#87bfea}
.bpi-summary{color:var(--bpi-text-muted);font-size:11px;min-height:17px}.bpi-status{margin-left:auto}.bpi-status[data-kind="error"]{color:#ff8b93}.bpi-status[data-kind="ok"]{color:#7fdda2}.bpi-status[data-kind="busy"]{color:#ffd27a}
.bpi-issues{display:none;max-height:105px;overflow:auto;border:1px solid #5b4a36;border-radius:6px;background:rgba(55,39,25,.55);padding:4px 6px}.bpi-issues.bpi-visible{display:block}.bpi-issue{padding:2px 4px;color:#e6c792}.bpi-issue[data-severity="error"]{color:#ff9299}.bpi-issue[data-severity="info"]{color:#86c8eb}.bpi-issue::before{content:"⚠ ";}.bpi-issue[data-severity="error"]::before{content:"⛔ ";}.bpi-issue[data-severity="info"]::before{content:"ℹ ";}
.bpi-search-label{color:var(--bpi-text-muted);font-size:10px;white-space:nowrap}.bpi-results{display:none;max-height:220px;overflow:auto;border:1px solid var(--bpi-border-2);border-radius:6px;background:var(--bpi-surface-2)}.bpi-results.bpi-visible{display:block}.bpi-result{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr) minmax(150px,auto);gap:7px;padding:5px 7px;border-bottom:1px solid var(--bpi-border-3);cursor:pointer}.bpi-result:last-child{border-bottom:0}.bpi-result:hover{background:var(--bpi-surface-3)}.bpi-result-en{color:var(--bpi-text)}.bpi-result-zh{color:#9ed0ff}.bpi-category{color:var(--bpi-text-muted);font-size:10px;white-space:normal}.bpi-search-reason{display:block;color:#77b6df;margin-top:2px}.bpi-search-more{display:block;margin:7px auto}.bpi-results>.bpi-searching{text-align:center}
.bpi-mode{border:1px solid var(--bpi-border-2);border-radius:5px;background:var(--bpi-input-bg);color:var(--bpi-input-text);padding:5px 6px;font-size:11px;outline:none}.bpi-mode:focus{border-color:#4ca7e8}.bpi-mode-info{color:var(--bpi-info-text)}
.bpi-summary{color:var(--bpi-text-muted);font-size:11px;min-height:17px}.bpi-status{margin-left:auto}.bpi-status[data-kind="error"]{color:var(--bpi-status-error)}.bpi-status[data-kind="ok"]{color:var(--bpi-status-ok)}.bpi-status[data-kind="busy"]{color:var(--bpi-status-busy)}
.bpi-issues{display:none;max-height:105px;overflow:auto;border:1px solid var(--bpi-issues-border);border-radius:6px;background:var(--bpi-issues-bg);padding:4px 6px}.bpi-issues.bpi-visible{display:block}.bpi-issue{padding:2px 4px;color:var(--bpi-issue-text)}.bpi-issue[data-severity="error"]{color:var(--bpi-status-error)}.bpi-issue[data-severity="info"]{color:var(--bpi-info-text)}.bpi-issue::before{content:"⚠ ";}.bpi-issue[data-severity="error"]::before{content:"⛔ ";}.bpi-issue[data-severity="info"]::before{content:"ℹ ";}
.bpi-search-label{color:var(--bpi-text-muted);font-size:10px;white-space:nowrap}.bpi-results{display:none;max-height:220px;overflow:auto;border:1px solid var(--bpi-border-2);border-radius:6px;background:var(--bpi-surface-2)}.bpi-results.bpi-visible{display:block}.bpi-result{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr) minmax(150px,auto);gap:7px;padding:5px 7px;border-bottom:1px solid var(--bpi-border-3);cursor:pointer}.bpi-result:last-child{border-bottom:0}.bpi-result:hover{background:var(--bpi-surface-3)}.bpi-result-en{color:var(--bpi-text)}.bpi-result-zh{color:var(--bpi-zh-text)}.bpi-category{color:var(--bpi-text-muted);font-size:10px;white-space:normal}.bpi-search-reason{display:block;color:var(--bpi-info-text);margin-top:2px}.bpi-search-more{display:block;margin:7px auto}.bpi-results>.bpi-searching{text-align:center}
.bpi-table{position:relative;height:300px;min-height:180px;max-height:360px;flex:1 1 300px;overflow-x:hidden;overflow-y:scroll;scrollbar-gutter:stable;border:1px solid var(--bpi-border-2);border-radius:6px;background:var(--bpi-surface-table)}.bpi-split-handle{position:absolute;top:0;bottom:0;left:var(--bpi-split,40%);width:9px;margin-left:-4px;cursor:col-resize;z-index:4}.bpi-split-handle:hover{background:rgba(76,167,232,.35)}.bpi-head,.bpi-row{display:grid;grid-template-columns:var(--bpi-split,minmax(0,2fr)) minmax(0,1fr);gap:1px}.bpi-head{position:sticky;top:0;z-index:2;background:var(--bpi-head-bg);color:var(--bpi-text-muted);font-weight:700}.bpi-head>div,.bpi-cell{padding:6px 8px}.bpi-head>div+div,.bpi-cell+.bpi-cell{border-left:1px solid var(--bpi-border-2)}
.bpi-row{border-top:1px solid var(--bpi-border-3);cursor:pointer}.bpi-row:hover,.bpi-row.bpi-pinned{background:#314b68}.bpi-row:hover .bpi-cell,.bpi-row.bpi-pinned .bpi-cell{color:#fff}.bpi-row.bpi-unknown{background:rgba(112,70,28,.18)}.bpi-row.bpi-machine{background:rgba(85,62,131,.22)}.bpi-row.bpi-has-warning{box-shadow:inset 3px 0 #d39845}.bpi-row.bpi-has-error{box-shadow:inset 3px 0 #e35b66}
.bpi-cell{min-width:0;word-break:break-word;display:flex;align-items:flex-start;gap:5px}.bpi-en{color:var(--bpi-text);font-family:Consolas,monospace}.bpi-zh{color:#9ed0ff}.bpi-row:hover .bpi-en,.bpi-row:hover .bpi-zh,.bpi-row.bpi-pinned .bpi-en,.bpi-row.bpi-pinned .bpi-zh{color:#fff;text-shadow:0 0 7px rgba(109,190,255,.6)}
.bpi-badge{flex:none;border:1px solid var(--bpi-border-2);border-radius:8px;padding:0 5px;color:var(--bpi-text-muted);font-size:9px;line-height:15px}.bpi-source{border-color:#3e6f67;color:#86d5c4}.bpi-confidence-high{border-color:#3f785c;color:#85d5a6}.bpi-confidence-medium{border-color:#8a7538;color:#e2ca78}.bpi-confidence-low,.bpi-confidence-none{border-color:#7a4c55;color:#ee9ca8}.bpi-unknown .bpi-badge{color:#ffc277;border-color:#85602f}.bpi-machine .bpi-badge{color:#cbb1ff;border-color:#6b5594}.bpi-inline-actions{display:flex;gap:3px;margin-left:auto;flex-wrap:wrap}.bpi-mini{border:1px solid var(--bpi-border-2);border-radius:4px;background:var(--bpi-button-bg);color:var(--bpi-button-text);padding:1px 5px;cursor:pointer;font-size:9px}.bpi-mini:hover{background:#3c4b60}.bpi-inline-editor{min-width:90px;flex:1;border:1px solid #4ca7e8;border-radius:4px;background:var(--bpi-input-bg);color:var(--bpi-input-text);padding:3px 5px}.bpi-empty{padding:24px;text-align:center;color:var(--bpi-text-faint)}
.bpi-row{border-top:1px solid var(--bpi-border-3);cursor:pointer}.bpi-row:hover{background-image:linear-gradient(var(--bpi-row-hover-overlay),var(--bpi-row-hover-overlay));outline:1px dashed var(--bpi-row-hover-ring);outline-offset:-1px}.bpi-row.bpi-pinned{background-image:linear-gradient(var(--bpi-row-pinned-overlay),var(--bpi-row-pinned-overlay));outline:1px solid var(--bpi-row-pinned-ring);outline-offset:-1px;font-weight:500}.bpi-row:hover .bpi-cell,.bpi-row.bpi-pinned .bpi-cell{color:var(--bpi-row-hover-text)}.bpi-row.bpi-unknown{background-color:rgba(112,70,28,.18)}.bpi-row.bpi-machine{background-color:rgba(85,62,131,.22)}.bpi-row.bpi-suspected-natural{background-color:rgba(58,132,160,.20)}.bpi-row.bpi-has-warning{box-shadow:inset 3px 0 #d39845}.bpi-row.bpi-has-error{box-shadow:inset 3px 0 #e35b66}
.bpi-cell{min-width:0;word-break:break-word;display:flex;align-items:flex-start;gap:5px}.bpi-en{color:var(--bpi-text);font-family:Consolas,monospace}.bpi-zh{color:var(--bpi-zh-text)}.bpi-row:hover .bpi-en,.bpi-row:hover .bpi-zh,.bpi-row.bpi-pinned .bpi-en,.bpi-row.bpi-pinned .bpi-zh{color:var(--bpi-row-hover-text);text-shadow:var(--bpi-row-hover-shadow)}
.bpi-badge{flex:none;border:1px solid var(--bpi-border-2);border-radius:8px;padding:0 5px;color:var(--bpi-text-muted);font-size:9px;line-height:15px}.bpi-source{border-color:#3e6f67;color:var(--bpi-badge-source)}.bpi-suspected{border-color:#3a84a0;color:var(--bpi-badge-suspected)}.bpi-confidence-high{border-color:#3f785c;color:var(--bpi-status-ok)}.bpi-confidence-medium{border-color:#8a7538;color:var(--bpi-badge-warn)}.bpi-confidence-low,.bpi-confidence-none{border-color:#7a4c55;color:var(--bpi-status-error)}.bpi-unknown .bpi-badge{color:var(--bpi-badge-warn);border-color:#85602f}.bpi-machine .bpi-badge{color:var(--bpi-badge-machine);border-color:#6b5594}.bpi-inline-actions{display:flex;gap:3px;margin-left:auto;flex-wrap:wrap}.bpi-mini{border:1px solid var(--bpi-border-2);border-radius:4px;background:var(--bpi-button-bg);color:var(--bpi-button-text);padding:1px 5px;cursor:pointer;font-size:9px}.bpi-mini:hover{background:var(--bpi-mini-hover-bg)}.bpi-inline-editor{min-width:90px;flex:1;border:1px solid #4ca7e8;border-radius:4px;background:var(--bpi-input-bg);color:var(--bpi-input-text);padding:3px 5px}.bpi-empty{padding:24px;text-align:center;color:var(--bpi-text-faint)}
.bpi-filters{display:flex;gap:4px;align-items:center;flex-wrap:wrap}
.bpi-batch-bar{flex:none;gap:8px;padding:6px 8px;margin-bottom:6px;border:1px solid var(--bpi-border-2);border-radius:6px;background:var(--bpi-surface-3)}.bpi-filter-active{background:#365f84;border-color:#5792c4;color:#fff}.bpi-star{color:#8f9aaa}.bpi-star.bpi-starred{color:#ffd45f;border-color:#8f772f}.bpi-result.bpi-result-selected{background:#314b68;outline:1px solid #5688b5}.bpi-result-star{font-size:14px;color:#8995a8;align-self:center}.bpi-result-star.bpi-starred{color:#ffd45f}
.bpi-quick-section{flex:none;border:1px solid #6b5594;border-radius:7px;background:var(--bpi-surface);overflow:hidden}.bpi-quick-head{display:flex;align-items:center;gap:7px;padding:5px 8px;background:var(--bpi-head-bg);color:#d9ccff;font-weight:700;flex-wrap:wrap}.bpi-quick-hint{margin-left:auto;color:#a795c6;font-size:10px;font-weight:400}.bpi-quick-row{display:flex;gap:6px;align-items:flex-start;padding:6px 8px}.bpi-quick-input{flex:1;min-width:0;box-sizing:border-box;height:44px;min-height:44px;max-height:140px;overflow-y:auto;resize:vertical;border:1px solid var(--bpi-border-2);border-radius:5px;background:var(--bpi-input-bg);color:var(--bpi-input-text);padding:5px 7px;outline:none;font:12px/1.55 Arial,sans-serif}.bpi-quick-input:focus{box-shadow:inset 0 0 0 1px #a98be0}.bpi-quick-row .bpi-button{flex:none}
.bpi-drag-handle{flex:none;cursor:grab;color:#7194ad;font-size:11px;line-height:1;user-select:none;padding:1px 2px;border-radius:3px}.bpi-drag-handle:hover{color:#a9c8e4;background:#2b3a4d}.bpi-drag-handle:active{cursor:grabbing}
.bpi-batch-bar{flex:none;gap:8px;padding:6px 8px;margin-bottom:6px;border:1px solid var(--bpi-border-2);border-radius:6px;background:var(--bpi-surface-3)}.bpi-filter-active{background:#365f84;border-color:#5792c4;color:#fff}.bpi-star{color:var(--bpi-star-off)}.bpi-star.bpi-starred{color:var(--bpi-star-on);border-color:#8f772f}.bpi-result.bpi-result-selected{background:var(--bpi-row-selected-bg);outline:1px solid var(--bpi-link-text)}.bpi-result-star{font-size:14px;color:var(--bpi-star-off);align-self:center}.bpi-result-star.bpi-starred{color:var(--bpi-star-on)}
.bpi-quick-section{flex:none;border:1px solid #6b5594;border-radius:7px;background-color:var(--bpi-quick-section-bg);overflow:hidden}.bpi-quick-head{display:flex;align-items:center;gap:7px;padding:5px 8px;background:var(--bpi-head-bg);color:var(--bpi-quick-head-text);font-weight:700;flex-wrap:wrap}.bpi-quick-hint{margin-left:auto;color:var(--bpi-quick-hint-text);font-size:10px;font-weight:400}.bpi-quick-target{padding:0 8px 6px;font-size:11px;color:var(--bpi-text-muted)}.bpi-quick-target-linked{color:var(--bpi-link-text)}.bpi-quick-row{display:flex;gap:6px;align-items:flex-start;padding:6px 8px}.bpi-quick-input{flex:1;min-width:0;box-sizing:border-box;height:44px;min-height:44px;max-height:140px;overflow-y:auto;resize:vertical;border:1px solid var(--bpi-border-2);border-radius:5px;background:var(--bpi-input-bg);color:var(--bpi-input-text);padding:5px 7px;outline:none;font:12px/1.55 Arial,sans-serif}.bpi-quick-input:focus{box-shadow:inset 0 0 0 1px #a98be0}.bpi-quick-row .bpi-button{flex:none}
.bpi-drag-handle{flex:none;cursor:grab;color:var(--bpi-handle-text);font-size:11px;line-height:1;user-select:none;padding:1px 2px;border-radius:3px}.bpi-drag-handle:hover{color:var(--bpi-handle-hover);background:var(--bpi-handle-hover-bg)}.bpi-drag-handle:active{cursor:grabbing}
.bpi-row.bpi-dragging,.bpi-english-token.bpi-dragging{opacity:.45}.bpi-english-token.bpi-dragging{cursor:grabbing}
.bpi-drop-indicator{position:absolute;left:3px;right:3px;height:0;border-top:2px solid #76c9ff;box-shadow:0 0 5px rgba(118,201,255,.55);z-index:3;pointer-events:none}
.bpi-break-row{display:flex;align-items:center;gap:8px;border-top:1px solid var(--bpi-border-3);background:#0f6e56;color:#bff0dd;padding:6px 10px;cursor:default}
.bpi-break-row{display:flex;align-items:center;gap:8px;border-top:1px solid var(--bpi-border-3);background:var(--bpi-break-bg);color:var(--bpi-break-text);padding:6px 10px;cursor:default}
.bpi-row.bpi-break-row:hover{background-color:var(--bpi-break-bg-hover);background-image:none;outline:0;color:#fff}
.bpi-break-row.bpi-dragging{opacity:.45}
.bpi-break-row .bpi-drag-handle{color:#bff0dd}.bpi-break-row .bpi-drag-handle:hover{color:#fff;background:#0a5240}
.bpi-break-row .bpi-drag-handle{color:var(--bpi-break-text)}.bpi-break-row .bpi-drag-handle:hover{color:#fff;background:var(--bpi-break-bg-hover)}
.bpi-break-row-label{flex:1;font-size:12px;font-weight:500;letter-spacing:.5px}
.bpi-mirror-token{user-select:none}.bpi-english-token.bpi-drop-before,.bpi-mirror-token.bpi-drop-before{box-shadow:-2px 0 0 0 #76c9ff,0 0 5px rgba(118,201,255,.4)}.bpi-english-token.bpi-drop-after,.bpi-mirror-token.bpi-drop-after{box-shadow:2px 0 0 0 #76c9ff,0 0 5px rgba(118,201,255,.4)}
.bpi-english-token{position:relative}.bpi-hide-btn{flex:none;cursor:pointer;color:#7194ad;font-size:11px;line-height:1;user-select:none;padding:1px 2px;border-radius:3px;display:inline-flex;align-items:center}.bpi-hide-btn:hover{color:#9ed0ff;background:#2b3a4d}.bpi-eye-icon{display:inline-flex;align-items:center}
.bpi-english-token{position:relative}.bpi-hide-btn{flex:none;cursor:pointer;color:var(--bpi-handle-text);font-size:11px;line-height:1;user-select:none;padding:1px 2px;border-radius:3px;display:inline-flex;align-items:center}.bpi-hide-btn:hover{color:var(--bpi-link-text);background:var(--bpi-handle-hover-bg)}.bpi-eye-icon{display:inline-flex;align-items:center}
.bpi-card-hidden{opacity:.55;filter:grayscale(.7)}
.bpi-card-hidden:hover{opacity:.75}
.bpi-card-hidden .bpi-english-token{text-decoration:line-through}
.bpi-card-hidden .bpi-token-card-zh{color:var(--bpi-text-faint)}
.bpi-card-hidden .bpi-chip-hide{display:inline-flex}
.bpi-chip-hidden-mark{color:#e8b06a}
.bpi-chip-hidden-mark{color:var(--bpi-badge-warn)}
.bpi-row-hidden{opacity:.6}
.bpi-row-hidden:hover{opacity:.8}
.bpi-hidden-term{text-decoration:line-through;color:var(--bpi-text-faint)}
.bpi-hide-btn-off{color:#e8b06a}
.bpi-eye-off{color:#e8b06a}
.bpi-chip-hide{position:absolute;top:-5px;right:-5px;display:none;cursor:pointer;padding:1px 2px;border-radius:999px;background:#1d2937;border:1px solid #4c5668;color:#9ed0ff;line-height:1;z-index:2}.bpi-english-token:hover .bpi-chip-hide{display:inline-flex}.bpi-chip-hide:hover{background:#285f8e;border-color:#76c9ff;color:#fff}
.bpi-hide-btn-off{color:var(--bpi-badge-warn)}
.bpi-eye-off{color:var(--bpi-badge-warn)}
.bpi-chip-hide{position:absolute;top:2px;right:3px;display:none;cursor:pointer;padding:1px 2px;border-radius:999px;background:var(--bpi-chip-hide-bg);border:1px solid var(--bpi-border-2);color:var(--bpi-link-text);line-height:1;z-index:2}.bpi-token-card:hover .bpi-chip-hide{display:inline-flex}.bpi-chip-hide:hover{background:var(--bpi-primary-bg);border-color:var(--bpi-primary-border);color:#fff}
.bpi-token-line{display:flex;flex-wrap:wrap;align-items:flex-end;gap:2px 0}
.bpi-token-card{display:inline-flex;flex-direction:column;align-items:flex-start;max-width:100%;border:1px solid var(--bpi-border-3);border-radius:5px;background:var(--bpi-surface-3);padding:1px 5px;margin:2px 3px 2px 0;vertical-align:top;cursor:pointer}
.bpi-token-card{position:relative;display:inline-flex;flex-direction:column;align-items:flex-start;max-width:100%;border:1px solid var(--bpi-border-3);border-radius:5px;background:var(--bpi-surface-3);padding:1px 5px;margin:2px 3px 2px 0;vertical-align:top;cursor:pointer}
.bpi-token-card:hover{border-color:var(--bpi-border-2)}
.bpi-token-card.bpi-dragging{opacity:.45;cursor:grabbing}
.bpi-token-card .bpi-english-token{margin:0;padding:0}
@@ -813,37 +1270,41 @@ function injectBpiStyles() {
.bpi-token-card.bpi-card-linked .bpi-english-token{background:transparent;border-color:transparent;box-shadow:none;color:#fff}
.bpi-token-card-zh{min-height:15px;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--bpi-text-muted);font:11px/1.35 Arial,sans-serif}
.bpi-category-content{display:flex;flex-wrap:wrap;gap:2px 3px}
.bpi-break-chip{position:relative;display:inline-flex;align-items:center;gap:3px;border:1px dashed #6c83a8;border-radius:5px;background:#1d2937;color:#9ed0ff;padding:0 5px;margin:1px 0;cursor:grab;user-select:none;font-family:Consolas,monospace}
.bpi-break-chip:hover{background:#285f8e;border-color:#76c9ff;color:#fff}
.bpi-break-chip{position:relative;display:inline-flex;align-items:center;gap:3px;border:1px dashed var(--bpi-break-border);border-radius:5px;background:var(--bpi-break-bg);color:var(--bpi-break-text);padding:0 5px;margin:1px 0;cursor:grab;user-select:none;font-family:Consolas,monospace}
.bpi-break-chip:hover,.bpi-break-chip.bpi-drop-target{background:var(--bpi-break-bg-hover);border-color:var(--bpi-break-text);color:#fff}
.bpi-break-chip.bpi-dragging{opacity:.45;cursor:grabbing}
.bpi-break-chip.bpi-drop-target{background:#285f8e;border-color:#76c9ff;color:#fff}
.bpi-break-x{display:none;cursor:pointer;color:#9ed0ff;line-height:1}
.bpi-break-x{display:none;cursor:pointer;line-height:1}
.bpi-break-chip:hover .bpi-break-x{display:inline-flex}
.bpi-break-x:hover{color:#fff}
.bpi-source-bar{display:flex;gap:7px;align-items:center;flex-wrap:wrap;padding:5px 7px;margin-bottom:7px;border:1px solid var(--bpi-border-2);border-radius:6px;background:var(--bpi-surface-3)}
.bpi-source-bar.bpi-source-waiting{border-color:#8a5f16;background:rgba(70,52,25,.5)}
.bpi-source-label{color:#87bfea;font-size:11px}
.bpi-source-bar.bpi-source-waiting{border-color:var(--bpi-waiting-border);background:var(--bpi-waiting-bg)}
.bpi-source-label{color:var(--bpi-info-text);font-size:11px}
.bpi-source-state{color:var(--bpi-text-muted);font-size:11px}
.bpi-source-state.bpi-source-waiting-text{color:#ffd27a}
.bpi-source-state.bpi-source-waiting-text{color:var(--bpi-badge-warn)}
.bpi-source-toggle{display:inline-flex;gap:5px;align-items:center;font-size:11px;color:var(--bpi-text);cursor:pointer;user-select:none}
.bpi-source-toggle input{margin:0;accent-color:#3f83ba}
.bpi-source-select{border:1px solid var(--bpi-border-2);border-radius:5px;background:var(--bpi-input-bg);color:var(--bpi-input-text);padding:3px 5px;font-size:11px}
.bpi-source-select:disabled{opacity:.45}
.bpi-save-drop{position:relative;display:flex;flex-direction:column;gap:5px;align-items:center;justify-content:center;min-height:86px;border:1px dashed var(--bpi-border-2);border-radius:7px;background:var(--bpi-surface-3);color:var(--bpi-text-faint);font-size:11px;text-align:center;cursor:pointer}
.bpi-save-drop.bpi-dragover{border-color:#4ca7e8;color:#9ed0ff}
.bpi-save-drop.bpi-dragover{border-color:#4ca7e8;color:var(--bpi-link-text)}
.bpi-autocomplete{position:relative}
.bpi-chip-row{display:flex;flex-wrap:wrap;gap:5px;align-items:center;box-sizing:border-box;width:100%;min-height:30px;border:1px solid var(--bpi-border-2);border-radius:5px;background:var(--bpi-input-bg);padding:5px 6px}
.bpi-form .bpi-chip-row input{flex:1;min-width:90px;width:auto;border:0;outline:none;background:transparent;color:var(--bpi-input-text);padding:2px 0;font:12px Arial,sans-serif}
.bpi-chip{display:inline-flex;align-items:center;gap:4px;max-width:100%;padding:2px 5px 2px 8px;border:1px solid var(--bpi-border-2);border-radius:11px;background:var(--bpi-surface-3);color:var(--bpi-text);font:11px Arial,sans-serif}
.bpi-chip-label{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:160px}
.bpi-chip-x{border:0;background:transparent;color:var(--bpi-text-muted);cursor:pointer;font:12px Arial,sans-serif;line-height:1;padding:0 1px}
.bpi-chip-x:hover{color:#ff8b93}
.bpi-chip-x:hover{color:var(--bpi-status-error)}
.bpi-ac-menu{position:absolute;left:0;right:0;top:calc(100% + 4px);z-index:95;max-height:188px;overflow:auto;padding:3px;border:1px solid var(--bpi-border-2);border-radius:6px;background:var(--bpi-surface-modal);box-shadow:0 6px 16px rgba(0,0,0,.25)}
.bpi-ac-menu[hidden]{display:none}
.bpi-ac-item{display:flex;align-items:center;gap:8px;padding:5px 7px;border-radius:4px;color:var(--bpi-text);font:12px Arial,sans-serif;cursor:pointer}
.bpi-ac-item:hover,.bpi-ac-item.bpi-ac-active{background:var(--bpi-surface-3)}
.bpi-ac-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.bpi-ac-kind{margin-left:auto;flex:none;color:var(--bpi-text-faint);font-size:10px}
.bpi-ac-custom{color:var(--bpi-custom-text);border-bottom:1px solid var(--bpi-border-3);border-radius:4px 4px 0 0}
.bpi-input-wrap{display:flex;align-items:center;gap:5px;box-sizing:border-box;width:100%;border:1px solid var(--bpi-border-2);border-radius:5px;background:var(--bpi-input-bg);padding:5px 6px}
.bpi-input-wrap:focus-within{border-color:#4ca7e8}
.bpi-form .bpi-input-wrap input{flex:1;min-width:0;width:auto;border:0;outline:none;background:transparent;color:var(--bpi-input-text);padding:0;font:12px Arial,sans-serif}
.bpi-custom-badge{flex:none;border:1px solid var(--bpi-custom-border);border-radius:8px;background:var(--bpi-custom-bg);color:var(--bpi-custom-text);font-size:10px;line-height:15px;padding:0 5px}
.bpi-form-hint{color:var(--bpi-text-faint);font-size:10px}
.bpi-model-tags{display:flex;flex-wrap:wrap;gap:4px}
.bpi-model-tag{padding:1px 7px;border:1px solid var(--bpi-border-2);border-radius:10px;background:var(--bpi-surface-3);color:var(--bpi-text-muted);font-size:10px}
@@ -858,54 +1319,72 @@ function injectBpiStyles() {
.bpi-fav-meta{display:flex;flex-wrap:wrap;gap:5px;align-items:center;color:var(--bpi-text-faint);font-size:10px}
.bpi-fav-actions{display:flex;flex-wrap:wrap;gap:5px;margin-top:2px}
.bpi-hidden-bar{display:flex;align-items:center;gap:6px;flex-wrap:wrap;padding:5px 2px;border-top:1px solid var(--bpi-border-3)}.bpi-hidden-bar.bpi-hidden{display:none}.bpi-hidden-label{flex:none;color:var(--bpi-text-muted);font-size:11px}
.bpi-hidden-chip{display:inline-flex;align-items:center;gap:5px;max-width:280px;overflow:hidden;border:1px dashed var(--bpi-border-2);border-radius:999px;padding:2px 8px;background:var(--bpi-surface-3);color:var(--bpi-text-muted);cursor:pointer;font-size:11px}.bpi-hidden-chip:hover{background:#31404f;border-color:#76c9ff;color:#d7e6f7}.bpi-hidden-en{text-decoration:line-through;white-space:nowrap}.bpi-hidden-zh{color:var(--bpi-text-muted);font-size:10px}.bpi-hidden-restore{color:#76c9ff;font-size:12px}
.bpi-hidden-chip{display:inline-flex;align-items:center;gap:5px;max-width:280px;overflow:hidden;border:1px dashed var(--bpi-border-2);border-radius:999px;padding:2px 8px;background:var(--bpi-surface-3);color:var(--bpi-text-muted);cursor:pointer;font-size:11px}.bpi-hidden-chip:hover{background:var(--bpi-hidden-chip-hover-bg);border-color:var(--bpi-link-text);color:var(--bpi-row-hover-text)}.bpi-hidden-en{text-decoration:line-through;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}.bpi-hidden-zh{color:var(--bpi-text-muted);font-size:10px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}.bpi-hidden-restore{color:var(--bpi-link-text);font-size:12px}
.bpi-hidden-all{margin-left:auto}
.bpi-modal-shade{position:fixed;inset:0;z-index:10020;background:rgba(0,0,0,.58);display:flex;align-items:center;justify-content:center}.bpi-modal{width:min(520px,calc(100vw - 32px));max-height:calc(100vh - 40px);overflow:auto;background:var(--bpi-surface-modal);color:var(--bpi-text);border:1px solid var(--bpi-border-2);border-radius:10px;padding:16px;box-shadow:0 18px 55px rgba(0,0,0,.55)}.bpi-modal h3{margin:0 0 12px;font-size:16px}.bpi-form{display:grid;grid-template-columns:92px minmax(0,1fr);gap:9px;align-items:center}.bpi-form label{color:var(--bpi-text-muted)}.bpi-form input,.bpi-form select,.bpi-form textarea{box-sizing:border-box;width:100%;border:1px solid var(--bpi-border-2);border-radius:5px;background:var(--bpi-input-bg);color:var(--bpi-input-text);padding:6px 7px}.bpi-form textarea{min-height:112px;resize:vertical;font:11px/1.45 Arial,sans-serif}.bpi-form input:focus,.bpi-form select:focus,.bpi-form textarea:focus{outline:none;border-color:#4ca7e8}.bpi-modal-actions{display:flex;justify-content:flex-end;gap:7px;margin-top:14px;flex-wrap:wrap}.bpi-weight-presets{display:flex;gap:5px;flex-wrap:wrap;margin-top:10px}
.bpi-editor-tabs{display:flex;gap:0;border-bottom:1px solid var(--bpi-border);margin-bottom:12px}
.bpi-editor-tab{background:none;border:none;border-bottom:2px solid transparent;padding:5px 12px;color:var(--bpi-text-muted);font:12px Arial,sans-serif;cursor:pointer}
.bpi-editor-tab:hover{color:var(--bpi-text)}
.bpi-editor-tab.bpi-editor-tab-active{color:#76c9ff;border-bottom-color:#76c9ff}
.bpi-editor-tab.bpi-editor-tab-active{color:var(--bpi-link-text);border-bottom-color:var(--bpi-link-text)}
.bpi-editor-panel{display:block}
.bpi-editor-divider{height:1px;background:var(--bpi-border-3);margin:12px 0}
.bpi-form .bpi-field-static{box-sizing:border-box;padding:6px 0;color:var(--bpi-text);font-family:Consolas,monospace;word-break:break-all}
.bpi-raw-editor{box-sizing:border-box;width:100%;min-height:72px;max-height:220px;resize:vertical;border:1px solid var(--bpi-border-2);border-radius:5px;background:var(--bpi-input-bg);color:var(--bpi-input-text);padding:5px 7px;outline:none;font:12px/1.55 Consolas,monospace}
.bpi-raw-editor:focus{border-color:#4ca7e8}
.bpi-token-card.bpi-card-multi{outline:1px dashed var(--bpi-link-text);outline-offset:1px}
.bpi-row.bpi-row-multi{outline:1px dashed var(--bpi-link-text);outline-offset:-1px}
.bpi-color-presets{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:10px}
.bpi-color-swatch{width:26px;height:26px;border-radius:4px;border:1px solid var(--bpi-border);cursor:pointer;display:inline-block}
.bpi-color-swatch:hover{border-color:var(--bpi-border-2)}
.bpi-color-swatch.bpi-color-swatch-active{border-color:#76c9ff;box-shadow:0 0 0 1px #76c9ff}
.bpi-color-swatch.bpi-color-swatch-active{border-color:var(--bpi-link-text);box-shadow:0 0 0 1px var(--bpi-link-text)}
.bpi-color-swatch-none.bpi-color-swatch-active{border-style:solid;color:var(--bpi-text)}
.bpi-color-custom{display:flex;align-items:center;gap:6px}
.bpi-color-custom input{width:150px}
.bpi-color-swatch-none{display:inline-flex;align-items:center;justify-content:center;background:var(--bpi-surface-3);border-style:dashed;border-color:var(--bpi-border-2);color:var(--bpi-text-muted);font:11px Arial,sans-serif}
.bpi-color-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(196px,1fr));gap:6px}
.bpi-color-card{border:1px solid var(--bpi-border-2);border-radius:6px;background:var(--bpi-surface-2);padding:7px}
.bpi-color-card-head{display:flex;align-items:center;gap:6px;font-weight:700;color:var(--bpi-text);word-break:break-word}
.bpi-color-chip{width:13px;height:13px;border-radius:3px;border:1px solid var(--bpi-border-2);background:transparent;flex:none}
.bpi-color-meta{font-size:10px;color:var(--bpi-text-muted);margin-top:2px}
.bpi-color-swatches{display:grid;grid-template-columns:repeat(4,1fr);gap:5px;margin-top:6px}
.bpi-color-swatches i{height:20px;border-radius:4px;border:1px solid var(--bpi-border);cursor:pointer;display:block}
.bpi-color-swatches i.bpi-swatch-on{border-color:var(--bpi-link-text);box-shadow:0 0 0 1px var(--bpi-link-text)}
.bpi-color-row{display:flex;align-items:center;gap:5px;margin-top:6px}
.bpi-color-row input{width:92px;border:1px solid var(--bpi-border-2);border-radius:5px;background:var(--bpi-input-bg);color:var(--bpi-input-text);padding:3px 5px;font-size:11px}
.bpi-color-dice{width:24px;height:24px;display:inline-flex;align-items:center;justify-content:center;border:1px solid var(--bpi-border-2);border-radius:5px;background:transparent;color:var(--bpi-text-muted);cursor:pointer;padding:0;flex:none}
.bpi-color-dice:hover{color:var(--bpi-text)}
.bpi-color-error{color:var(--bpi-status-error);font-size:11px;margin-top:6px}
.bpi-color-panel{border:1px solid var(--bpi-border-2);border-radius:7px;background:var(--bpi-surface-3);padding:8px;margin:5px 0}
.bpi-color-panel-actions{display:flex;justify-content:flex-end;margin-top:8px}
.bpi-color-modes{display:flex;gap:6px;flex-wrap:wrap}
.bpi-color-mode{display:inline-flex;align-items:center;gap:5px;border:1px solid var(--bpi-border-2);border-radius:6px;background:var(--bpi-surface-2);color:var(--bpi-text);padding:5px 9px;cursor:pointer;font-size:12px;font-family:inherit}
.bpi-color-mode:hover{border-color:var(--bpi-text-muted)}
.bpi-color-mode-active{border-color:var(--bpi-link-text);box-shadow:0 0 0 1px var(--bpi-link-text)}
.bpi-color-mode-dot{width:9px;height:9px;border-radius:50%;display:inline-block;border:1px solid var(--bpi-border)}
.bpi-color-note{font-size:11px;color:var(--bpi-text-muted);margin:10px 0 6px}
.bpi-color-open{border-color:var(--bpi-link-text);box-shadow:0 0 0 1px var(--bpi-link-text)}
.bpi-filter-sep{width:1px;height:18px;background:var(--bpi-border);margin:0 4px;align-self:center}
.bpi-color-label{display:inline-flex;align-items:center;gap:5px;font-size:12px}
.bpi-dropdown{position:relative;display:inline-flex}
.bpi-dropdown-trigger{display:inline-flex;align-items:center;gap:6px;padding:3px 9px;border:1px solid var(--bpi-border-2);border-radius:6px;background:var(--bpi-button-bg);color:var(--bpi-button-text);font:12px Arial,sans-serif;line-height:1.2;cursor:pointer;user-select:none}
.bpi-dropdown-trigger:hover{border-color:var(--bpi-text-faint)}
.bpi-dropdown-caret{font-size:9px;color:var(--bpi-text-muted);transition:transform .12s}
.bpi-dropdown-open .bpi-dropdown-caret{transform:rotate(180deg)}
.bpi-dropdown-menu{position:absolute;top:calc(100% + 5px);left:0;z-index:90;min-width:150px;padding:4px;border:1px solid var(--bpi-border-2);border-radius:6px;background:var(--bpi-surface-modal);box-shadow:0 6px 16px rgba(0,0,0,.25)}
.bpi-dropdown-menu.bpi-hidden{display:none}
.bpi-dropdown-item{display:flex;align-items:center;gap:8px;padding:5px 8px;border-radius:4px;color:var(--bpi-text);font:12px Arial,sans-serif;white-space:nowrap;cursor:pointer}
.bpi-dropdown-item:hover{background:var(--bpi-surface-3)}
.bpi-dropdown-item-active{color:#76c9ff}
.bpi-dropdown-dots{display:inline-flex;gap:3px}
.bpi-dropdown-dot{width:9px;height:9px;border-radius:50%;display:inline-block}
.bpi-dropdown-check{margin-left:auto;font-size:11px;color:#76c9ff}.bpi-about-modal{width:min(460px,calc(100vw - 32px))}.bpi-about-name{margin-bottom:7px;color:var(--bpi-text);font-weight:700}.bpi-config-note{color:var(--bpi-text-muted);font-size:10px;word-break:break-all}
.bpi-about-modal{width:min(460px,calc(100vw - 32px))}.bpi-about-name{margin-bottom:7px;color:var(--bpi-text);font-weight:700}.bpi-config-note{color:var(--bpi-text-muted);font-size:10px;word-break:break-all}
.bpi-help{position:relative;display:inline-flex;align-items:center;justify-content:center;width:13px;height:13px;margin-left:5px;border:1px solid var(--bpi-border);border-radius:50%;color:var(--bpi-text-muted);font:10px/1 Arial,sans-serif;vertical-align:middle;cursor:help;user-select:none}
.bpi-help:hover,.bpi-help:focus{color:var(--bpi-text);border-color:var(--bpi-border-2)}
.bpi-help-pop{position:absolute;left:50%;bottom:calc(100% + 6px);transform:translateX(-50%);z-index:80;display:none;box-sizing:border-box;width:290px;max-width:70vw;padding:7px 9px;border:1px solid var(--bpi-border-2);border-radius:6px;background:var(--bpi-surface-modal);color:var(--bpi-text-muted);font:11px/1.65 Arial,sans-serif;white-space:pre-line;text-align:left;cursor:default}
.bpi-help:hover .bpi-help-pop,.bpi-help:focus .bpi-help-pop{display:block}.bpi-preview-text{box-sizing:border-box;width:100%;min-height:130px;max-height:300px;resize:vertical;border:1px solid var(--bpi-border-2);border-radius:6px;background:var(--bpi-surface-3);color:var(--bpi-text);padding:8px;font:12px/1.5 monospace}.bpi-sort-groups{max-height:230px;overflow:auto;border:1px solid var(--bpi-border-2);border-radius:6px;background:var(--bpi-surface-3);padding:6px;margin:8px 0}.bpi-sort-group{display:grid;grid-template-columns:145px minmax(0,1fr);gap:7px;padding:4px;border-top:1px solid var(--bpi-border-3)}.bpi-sort-group:first-child{border-top:0}.bpi-sort-group strong{color:#9fd3ff}
.bpi-about-footer{flex:none;min-height:20px;display:flex;align-items:center;justify-content:space-between;gap:8px;padding:1px 4px 0;color:var(--bpi-text-faint);font-size:9px}.bpi-about-button{border:0;background:transparent;color:var(--bpi-text-muted);padding:1px 3px;cursor:pointer;font-size:9px}.bpi-about-button:hover{color:#b8dcfa;text-decoration:underline}
.bpi-help:hover .bpi-help-pop,.bpi-help:focus .bpi-help-pop{display:block}.bpi-preview-text{box-sizing:border-box;width:100%;min-height:130px;max-height:300px;resize:vertical;border:1px solid var(--bpi-border-2);border-radius:6px;background:var(--bpi-surface-3);color:var(--bpi-text);padding:8px;font:12px/1.5 monospace}.bpi-sort-groups{max-height:230px;overflow:auto;border:1px solid var(--bpi-border-2);border-radius:6px;background:var(--bpi-surface-3);padding:6px;margin:8px 0}.bpi-sort-group{display:grid;grid-template-columns:145px minmax(0,1fr);gap:7px;padding:4px;border-top:1px solid var(--bpi-border-3)}.bpi-sort-group:first-child{border-top:0}.bpi-sort-group strong{color:var(--bpi-zh-text)}
.bpi-about-footer{flex:none;min-height:20px;display:flex;align-items:center;justify-content:space-between;gap:8px;padding:1px 4px 0;color:var(--bpi-text-faint);font-size:9px}.bpi-about-button{border:0;background:transparent;color:var(--bpi-text-muted);padding:1px 3px;cursor:pointer;font-size:9px}.bpi-about-button:hover{color:var(--bpi-link-text);text-decoration:underline}
.bpi-pack-section{border:1px solid var(--bpi-border-2);border-radius:7px;background:var(--bpi-surface-3);padding:7px;margin-bottom:8px}.bpi-pack-toolbar{display:flex;gap:6px;align-items:center;justify-content:space-between;flex-wrap:wrap;margin-bottom:6px}.bpi-pack-toolbar strong{color:var(--bpi-text)}.bpi-pack-list{display:grid;grid-template-columns:repeat(auto-fit,minmax(225px,1fr));gap:5px;max-height:166px;overflow:auto}.bpi-pack-card{border:1px solid var(--bpi-border-2);border-radius:6px;background:var(--bpi-surface-2);padding:6px;display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:6px;align-items:start}.bpi-pack-card.bpi-pack-disabled{opacity:.58}.bpi-pack-name{font-weight:700;color:var(--bpi-text)}.bpi-pack-meta{font-size:10px;color:var(--bpi-text-muted);margin-top:2px;word-break:break-word}.bpi-pack-controls{display:flex;gap:3px;flex-wrap:wrap;justify-content:flex-end}.bpi-switch{margin-top:3px}.bpi-pack-personal{border-color:#397062}.bpi-community-form{display:grid;grid-template-columns:105px minmax(0,1fr);gap:8px;align-items:center}.bpi-community-form input{border:1px solid var(--bpi-border-2);border-radius:5px;background:var(--bpi-input-bg);color:var(--bpi-input-text);padding:6px}.bpi-community-preview{max-height:150px;overflow:auto;border:1px solid var(--bpi-border-2);border-radius:5px;padding:5px;margin-top:8px;color:var(--bpi-text-muted)}
.bpi-import-conflicts{max-height:240px;overflow:auto;border:1px solid var(--bpi-border-2);border-radius:6px;margin-top:10px}.bpi-import-conflict{display:grid;grid-template-columns:1fr 1fr 1fr;gap:6px;padding:6px;border-top:1px solid var(--bpi-border-3)}.bpi-import-conflict:first-child{border-top:0}.bpi-import-stat{display:flex;gap:12px;flex-wrap:wrap;color:var(--bpi-text)}.bpi-danger-text{color:#ff9ba3}
.bpi-import-conflicts{max-height:240px;overflow:auto;border:1px solid var(--bpi-border-2);border-radius:6px;margin-top:10px}.bpi-import-conflict{display:grid;grid-template-columns:1fr 1fr 1fr;gap:6px;padding:6px;border-top:1px solid var(--bpi-border-3)}.bpi-import-conflict:first-child{border-top:0}.bpi-import-stat{display:flex;gap:12px;flex-wrap:wrap;color:var(--bpi-text)}.bpi-danger-text{color:var(--bpi-status-error)}
.bpm-panel{box-sizing:border-box;width:100%;height:100%;min-height:280px;display:flex;flex-direction:column;gap:7px;padding:8px;color:var(--bpi-text);font:12px/1.4 Arial,sans-serif;overflow:hidden}
.bpm-head{flex:none;display:flex;align-items:center;gap:7px;flex-wrap:wrap}.bpm-head strong{color:var(--bpi-text)}.bpm-head .bpi-status{margin-left:auto}
.bpm-tabs{flex:none;display:flex;gap:4px}.bpm-tab{flex:1;border:1px solid var(--bpi-border-2);border-radius:5px;padding:4px 6px;background:var(--bpi-button-bg);color:var(--bpi-button-text);cursor:pointer;font-size:11px;line-height:1.25}.bpm-tab:hover{background:var(--bpi-border-3)}.bpm-tab.bpm-tab-active{background:#285f8e;border-color:#3f83ba;color:#fff}
.bpm-tabs{flex:none;display:flex;gap:4px}.bpm-tab{flex:1;border:1px solid var(--bpi-border-2);border-radius:5px;padding:4px 6px;background:var(--bpi-button-bg);color:var(--bpi-button-text);cursor:pointer;font-size:11px;line-height:1.25}.bpm-tab:hover{background:var(--bpi-border-3)}.bpm-tab.bpm-tab-active{background:var(--bpm-accent);border-color:var(--bpm-accent-border);color:#fff}
.bpm-section{flex:1;min-height:0;display:flex;flex-direction:column;gap:6px}.bpm-section[hidden]{display:none}
.bpm-toolbar{flex:none;display:flex;gap:4px;align-items:center;flex-wrap:wrap}.bpm-toolbar .bpi-search{min-width:120px}.bpm-toolbar .bpi-mode{flex:1;min-width:90px}
.bpm-summary{flex:none;color:var(--bpi-text-muted);font-size:11px;min-height:15px;word-break:break-all}
.bpm-rows{flex:1;min-height:120px;overflow-y:auto;scrollbar-gutter:stable;border:1px solid var(--bpi-border-2);border-radius:6px;background:var(--bpi-surface-table)}.bpm-tagmanager-host{flex:1;min-height:0;overflow-y:auto;scrollbar-gutter:stable;display:flex;flex-direction:column;gap:7px;padding-right:2px}.bpm-tagmanager-host .bpi-details-body{display:flex;flex-direction:column;gap:7px}.bpm-tagmanager-host .bpi-table{height:auto;min-height:220px;max-height:none;flex:0 1 auto}
.bpm-row{border-top:1px solid var(--bpi-border-3);padding:6px 8px;display:flex;flex-direction:column;gap:3px}.bpm-row:first-child{border-top:0}.bpm-row:hover{background:var(--bpi-surface-3)}
.bpm-row-top{display:flex;align-items:flex-start;gap:5px}.bpm-row-top input[type="checkbox"]{margin-top:2px}.bpm-row-en{color:var(--bpi-text);font-family:Consolas,monospace;word-break:break-word;min-width:0;flex:1}
.bpm-row-zh{color:#9ed0ff;word-break:break-word}
.bpm-row-zh{color:var(--bpi-zh-text);word-break:break-word}
.bpm-row-meta{display:flex;align-items:center;gap:5px;flex-wrap:wrap;color:var(--bpi-text-muted);font-size:10px}
.bpm-actions{display:flex;gap:4px;flex-wrap:wrap}
.bpm-footer{flex:none;display:flex;gap:4px;align-items:center;justify-content:space-between;flex-wrap:wrap}.bpm-footer .bpi-toolbar-group{justify-content:flex-start}
@@ -913,6 +1392,19 @@ function injectBpiStyles() {
.bpm-section .bpi-form{grid-template-columns:78px minmax(0,1fr)}.bpm-section .bpi-form textarea{min-height:84px}
.bpm-assistant-actions{display:flex;gap:7px;flex-wrap:wrap;margin-top:10px}
.bpm-loading{padding:24px;text-align:center;color:var(--bpi-text-faint)}
.bpm-panel .bpi-button.bpi-primary,.bpm-panel .bpi-mini.bpi-primary{background:var(--bpm-accent);border-color:var(--bpm-accent-border);color:#fff}
.bpm-panel .bpi-button.bpi-primary:hover,.bpm-panel .bpi-mini.bpi-primary:hover{background:var(--bpm-accent-hover);border-color:var(--bpm-accent-border);color:#fff}
.bpm-panel .bpi-filter-active{background:var(--bpm-accent);border-color:var(--bpm-accent-border);color:#fff}
.bpi-modal .bpi-button.bpi-primary,.bpi-modal .bpi-mini.bpi-primary{background:var(--bpm-accent);border-color:var(--bpm-accent-border);color:#fff}
.bpi-modal .bpi-button.bpi-primary:hover,.bpi-modal .bpi-mini.bpi-primary:hover{background:var(--bpm-accent-hover);border-color:var(--bpm-accent-border);color:#fff}
.bpi-dup-bar{display:flex;align-items:center;gap:6px;flex-wrap:wrap;padding:5px 2px;border-top:1px solid var(--bpi-border-3)}.bpi-dup-bar.bpi-hidden{display:none}.bpi-dup-label{flex:none;color:var(--bpi-text-muted);font-size:11px}
.bpi-dup-chip{display:inline-flex;align-items:center;gap:5px;max-width:260px;overflow:hidden;border:1px dashed var(--bpi-border-2);border-radius:999px;padding:2px 8px;background-color:var(--bpi-surface-3);color:var(--bpi-text);cursor:pointer;font-size:11px;user-select:none}
.bpi-dup-chip:hover{background-color:var(--bpi-hidden-chip-hover-bg);border-color:var(--bpi-link-text)}.bpi-dup-en{font-family:Consolas,monospace;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}.bpi-dup-count{flex:none;color:var(--bpi-text-muted);font-size:10px}.bpi-dup-index{flex:none;color:var(--bpi-status-error);font-size:10px;font-weight:700}.bpi-dup-chip-armed{border-style:solid;border-color:var(--bpi-status-error);color:var(--bpi-status-error)}
.bpi-dup-off{opacity:.6}.bpi-dup-off .bpi-en,.bpi-dup-off .bpi-english-token{text-decoration:line-through;color:var(--bpi-text-faint)}
.bpi-row.bpi-dup-hit,.bpi-token-card.bpi-dup-hit{background-image:linear-gradient(var(--bpi-dup-overlay),var(--bpi-dup-overlay));outline:1px solid var(--bpi-status-error);outline-offset:-1px}
.bpi-token-card.bpi-dup-hit .bpi-english-token{background-color:transparent;border-color:transparent;box-shadow:none}.bpi-unique-toggle{margin-left:6px;font-size:10px}
.bpi-dup-chip-unique{border-style:solid;border-color:var(--bpi-custom-border);background-color:var(--bpi-custom-bg)}.bpi-dup-chip-unique:hover{border-color:var(--bpi-link-text)}.bpi-dup-flag{flex:none;color:var(--bpi-custom-text);font-size:10px}
.bpi-dup-space{flex:1 1 32px;min-width:32px;align-self:stretch;cursor:default}
`;
document.head.appendChild(style);
}
@@ -931,10 +1423,13 @@ export {
LANGUAGE_CHANGED_EVENT,
MANAGER_TAB_ID,
MANAGER_OPEN_EVENT,
UNINDEXED_PACK_ID,
UPSTREAM_ARRIVED_EVENT,
applyBpiAppearance,
applyLanguageToDom,
buildTagFields,
button,
checkDuplicateTags,
createSavedPrompt,
deleteCommunityPack,
deletePersonalTag,
@@ -959,6 +1454,7 @@ export {
loadDictionary,
loadPreferences,
lookupLargeDictionary,
buildColorPanel,
openManagerPanel,
openSavePromptDialog,
openTagDialog,
@@ -968,6 +1464,7 @@ export {
saveAssistantConfig,
savePreferences,
saveTag,
saveTokenColors,
searchLargeDictionary,
setLanguageMode,
setLargeDictionaryEnabled,
+185
View File
@@ -0,0 +1,185 @@
import { app } from "../../scripts/app.js";
import { checkDuplicateTags, t } from "./bpi_shared.js";
const NODE_NAME = "DuplicateChecker";
const STYLE_ID = "bpi-duplicate-check-style";
const PILL_HEIGHT = 26;
const REFRESH_DELAY = 250;
// 半透明橙:叠在画布上,亮色深色主题都能用,也不用去猜当前主题
const AMBER_TINT = "rgba(214,158,46,0.14)";
function injectStyles() {
if (document.getElementById(STYLE_ID)) return;
const style = document.createElement("style");
style.id = STYLE_ID;
style.textContent = [
".bpi-dup-pill{display:inline-flex;align-items:center;gap:6px;padding:3px 10px;border-radius:999px;font-size:12px;line-height:1.5;max-width:100%;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}",
".bpi-dup-pill .bpi-dup-dot{width:7px;height:7px;border-radius:50%;flex:none}",
".bpi-dup-pill .bpi-dup-text{overflow:hidden;text-overflow:ellipsis}",
".bpi-dup-pill.bpi-dup-warn{background:rgba(214,158,46,.18);color:#8a5a00}",
".bpi-dup-pill.bpi-dup-warn .bpi-dup-dot{background:#8a5a00}",
".bpi-dup-pill.bpi-dup-idle{background:rgba(128,128,128,.14);opacity:.75}",
".dark-theme .bpi-dup-pill.bpi-dup-warn{color:#e8b85c}",
".dark-theme .bpi-dup-pill.bpi-dup-warn .bpi-dup-dot{background:#e8b85c}",
].join("");
document.head.appendChild(style);
}
function upstreamWidget(node) {
const linkId = node?.inputs?.[0]?.link;
if (linkId == null) return null;
const graph = app.graph;
const links = graph?.links;
const info = Array.isArray(links) ? links[linkId] : links?.[linkId];
if (!info) return null;
const origin = (graph?._nodes ?? []).find((item) => String(item.id) === String(info.origin_id));
// 上游节点的文本 widget 是画布上唯一能读到的值;读不到就只靠执行结果更新。
// 缓存 widget 本身,这样每帧只要读一次 value,不用再遍历整张图。
return (
(origin?.widgets ?? []).find((item) => item.name === "text" && typeof item.value === "string") ??
null
);
}
function setPill(node, text, tone, terms = []) {
const state = node._duplicateCheck;
if (!state) return;
state.pill.className = `bpi-dup-pill ${tone === "warn" ? "bpi-dup-warn" : "bpi-dup-idle"}`;
state.textNode.textContent = text;
state.dot.style.display = tone === "warn" ? "" : "none";
state.pill.title = terms.length ? terms.join("、") : "";
}
function applyResult(node, count, terms = []) {
const state = node._duplicateCheck;
if (!state) return;
state.count = count;
if (!state.connected) {
setPill(node, t("Waiting for prompt"), "idle");
} else if (count > 0) {
setPill(node, t(`Duplicates ${count}`), "warn", terms);
} else {
setPill(node, t("No duplicates"), "idle");
}
node.bgcolor = state.connected && count > 0 ? AMBER_TINT : undefined;
node.setDirtyCanvas?.(true, true);
}
async function runCheck(node, text) {
const state = node._duplicateCheck;
if (!state) return;
const ticket = (state.ticket = (state.ticket ?? 0) + 1);
try {
const data = await checkDuplicateTags(text);
if (state.ticket !== ticket) return;
applyResult(node, data?.count ?? 0, (data?.terms ?? []).map((item) => item.term));
} catch {
// 查询失败就保持上一次的状态,别把节点闪成「无重复」误导人
}
}
function scheduleCheck(node, text) {
const state = node._duplicateCheck;
if (!state || text === state.checkedText) return;
state.checkedText = text;
clearTimeout(state.timer);
state.timer = setTimeout(() => runCheck(node, text), REFRESH_DELAY);
}
function syncConnection(node) {
const state = node._duplicateCheck;
if (!state) return;
state.connected = node.inputs?.[0]?.link != null;
state.source = state.connected ? upstreamWidget(node) : null;
if (!state.connected) {
state.checkedText = null;
applyResult(node, 0, []);
return;
}
const text = state.source?.value;
if (typeof text !== "string") {
applyResult(node, 0, []);
return;
}
scheduleCheck(node, text);
}
app.registerExtension({
name: "ComfyUI.BilingualPromptInspector.DuplicateCheck",
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name !== NODE_NAME) return;
const originalCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
originalCreated?.apply(this, arguments);
injectStyles();
// widget.label / input.label 都是直接赋值,不走翻译层,所以这里直接写中文
const toggle = this.widgets?.find((widget) => widget.name === "keep_first");
if (toggle) toggle.label = "只保留第一个";
if (this.inputs?.[0]) this.inputs[0].label = "提示词";
const pill = document.createElement("div");
pill.className = "bpi-dup-pill bpi-dup-idle";
const dot = document.createElement("span");
dot.className = "bpi-dup-dot";
const textNode = document.createElement("span");
textNode.className = "bpi-dup-text";
pill.append(dot, textNode);
this._duplicateCheck = { pill, dot, textNode, count: 0, connected: false };
try {
this.addDOMWidget("bpi_duplicate_status", "bpi-duplicate-status", pill, {
getMinHeight: () => PILL_HEIGHT,
getMaxHeight: () => PILL_HEIGHT,
hideOnZoom: false,
serialize: false,
});
} catch (error) {
console.error("[DuplicateChecker] Failed to create status pill", error);
this._duplicateCheck = null;
return;
}
setPill(this, t("Waiting for prompt"), "idle");
requestAnimationFrame(() => {
// 胶囊:一行状态加一个开关。等 DOM 布局算完再压尺寸,免得把开关挤没了
this.setSize?.([236, Math.min(this.size?.[1] ?? 88, 96)]);
syncConnection(this);
});
};
const originalConnectionsChange = nodeType.prototype.onConnectionsChange;
nodeType.prototype.onConnectionsChange = function () {
originalConnectionsChange?.apply(this, arguments);
syncConnection(this);
};
// canvas 模式靠每帧比对上游文本来保持实时;Nodes 2.0 不跑这里,
// 那种情况下由连线变化与执行结果兜底。
const originalDrawForeground = nodeType.prototype.onDrawForeground;
nodeType.prototype.onDrawForeground = function () {
originalDrawForeground?.apply(this, arguments);
const state = this._duplicateCheck;
if (!state?.connected) return;
const text = state.source?.value;
if (typeof text === "string") scheduleCheck(this, text);
};
const originalExecuted = nodeType.prototype.onExecuted;
nodeType.prototype.onExecuted = function (message) {
originalExecuted?.apply(this, arguments);
const state = this._duplicateCheck;
if (!state) return;
state.connected = true;
state.checkedText = null;
// 后端 ui 的值会被 execution.py 展开成列表,所以这里取第一个元素
const counts = message?.duplicate_count;
const count = Number(Array.isArray(counts) ? counts[0] : counts);
applyResult(this, Number.isFinite(count) ? count : 0, message?.duplicate_terms ?? []);
};
const originalRemoved = nodeType.prototype.onRemoved;
nodeType.prototype.onRemoved = function () {
clearTimeout(this._duplicateCheck?.timer);
this._duplicateCheck = null;
originalRemoved?.apply(this, arguments);
};
},
});
+66 -4
View File
@@ -44,12 +44,29 @@ const ZH = {
Discard: "丢弃",
Resume: "继续",
Weight: "权重",
Color: "颜色",
"Weight / color": "权重 / 颜色",
Apply: "应用",
Default: "默认",
Random: "随机",
None: "无",
"By inclusion": "按收录",
"By dictionary": "按词库",
"By Anima": "按 Anima",
Quality: "质量",
Copyright: "作品",
Expression: "表情",
Camera: "镜头",
Scene: "场景",
"Pick a color for each Anima category; unset ones keep the default.": "给每个 Anima 分类挑一个颜色;没配色的保持默认底色。",
"This color has the lowest priority: it only shows in Default mode; other color modes override it.": "这里改的颜色优先级最低:只有「默认」上色模式才显示,按收录、随机、按词库、按 Anima 都会盖住它。",
Colors: "颜色",
"Pick a color for each dictionary; uncolored ones keep the default.": "给每个词库挑一个颜色;没配色的保持默认底色。",
"Restore defaults": "恢复默认",
"Random color": "随机颜色",
"Colors saved": "颜色已保存",
"Tags matching no dictionary": "没命中任何词库的标签",
"Invalid color; use #RGB or #RRGGBB": "颜色格式不对;请用 #RGB 或 #RRGGBB",
"{} items": "{} 条",
"Invalid color; use #hex or rgb(r,g,b)": "颜色格式不对;请用 #hex 或 rgb(r,g,b)",
Category: "分类",
English: "英文",
@@ -71,7 +88,6 @@ const ZH = {
"Chinese explanation (read-only)": "中文解释(只读)",
"Category table view | click tags to link; category names are not written to the prompt":
"分类表视图 | 点击标签联动;分类名不会写入提示词",
"Click tags to link; select then press Delete": "点击标签联动;选中后按 Delete",
"Enter the English prompt; after editing, it will be shown as selectable, deletable tags.":
"输入英文提示词;编辑完成后会显示成可选中、可删除的标签。",
"Enter a prompt in the English text box above; per-tag bilingual mapping will appear here.":
@@ -177,6 +193,8 @@ const ZH = {
"deleted {}; press Ctrl+Z to undo": "已删除「{}」;按 Ctrl+Z 可撤销",
"{}; press Ctrl+Z to undo": "{};按 Ctrl+Z 可撤销",
"moved {}; press Ctrl+Z to undo": "已移动「{}」;按 Ctrl+Z 可撤销",
"rewrote {}; press Ctrl+Z to undo": "已改写「{}」;按 Ctrl+Z 可撤销",
"rewrote {} tags; press Ctrl+Z to undo": "已改写 {} 个标签;按 Ctrl+Z 可撤销",
"hidden {} (excluded from actual output); can be restored anytime in the hidden section; press Ctrl+Z to undo":
"已隐藏「{}」(不计入实际输出);随时可在隐藏区恢复;按 Ctrl+Z 可撤销",
"restored {} to its original position; press Ctrl+Z to undo": "已把「{}」恢复到原位置;按 Ctrl+Z 可撤销",
@@ -208,6 +226,8 @@ const ZH = {
"These tags stay visible above (strikethrough) but will not enter the actual output; click a tag to unhide":
"这些标签仍显示在上方(加删除线),但不会进入实际输出;点击标签取消隐藏",
"Click to unhide {}": "点击取消隐藏「{}」",
"Click to unhide {} — {}": "点击取消隐藏「{}」——{}",
"Click to restore {} — {} to its original position": "点击把「{}」恢复到原位置——{}",
"Unhide all {} hidden tags": "全部取消隐藏({} 个)",
"hidden ({})": "已隐藏({})",
"Delete personal override and restore built-in explanation": "删除个人覆盖并恢复内置解释",
@@ -222,8 +242,8 @@ const ZH = {
"Aliases: {}": "别名:{}",
"{} ↔ {} | natural language supports only whole-segment editing; can drag to reorder the whole segment":
"「{}」↔「{}」|自然语言只支持整段编辑;可拖动整段调整顺序",
"{} ↔ {} | click to link; double-click to edit weight; select then press Delete; drag to reorder or Alt+↑/↓ to nudge":
"「{}」↔「{}」|点击联动;双击编辑权重;选中后按 Delete;拖动排序或 Alt+↑/↓ 微调",
"{} ↔ {} | click to link; Ctrl+click to select multiple; double-click to open the editor; select then press Delete; drag to reorder or Alt+↑/↓ to nudge":
"「{}」↔「{}」|点击联动;Ctrl+点可多选;双击打开编辑;选中后按 Delete;拖动排序或 Alt+↑/↓ 微调",
"Insert line break": "插入换行",
"Delete line break": "删除换行",
"Move line break": "移动换行",
@@ -264,6 +284,10 @@ const ZH = {
"Type Chinese to translate it into English tags, or type English tags to add them directly":
"输入中文会自动翻译为英文标签;直接输入英文则原样添加",
Add: "添加",
"Insert after {}": "插到「{}」之后",
"Add to the end (no tag selected)": "添加到末尾(未选中标签)",
"The new tag goes right after {}": "新标签会插到「{}」之后",
"No tag is selected, so the new tag goes at the end of the prompt": "没有选中标签,新标签会添加到提示词末尾",
"Enter Chinese or an English tag first": "请先输入中文或英文标签",
"Added {} to the prompt": "已把「{}」添加到提示词",
"Translating {}…": "正在翻译「{}」…",
@@ -298,6 +322,11 @@ const ZH = {
"Model limit reached": "模型数量已达上限",
"Type keywords": "输入关键词",
"Press Enter to add; ↑↓ to choose": "回车添加,↑↓ 切换候选",
"Type to filter; ↑↓ to choose": "输入即筛选,↑↓ 切换候选",
"Manual input": "手动输入",
"Tag saved": "词条已保存",
"Entry info": "词条信息",
"Edits go to the personal dictionary and override same-name entries.": "改动存入个人词库,会覆盖同名词条的显示。",
"Edit Favorite": "编辑收藏",
"Keep the current image, or pick a new one to replace it": "不换图就保持原样,选一张新的会替换掉原来的",
@@ -431,6 +460,14 @@ const ZH = {
"Uncategorized": "未分类",
"Not indexed": "未收录",
"Natural-language segment (pending translation or confirmation)": "自然语言片段(待翻译或确认)",
"Suspected natural language": "疑似自然语言",
Suspected: "疑似",
"Save to Dictionary": "保存的词库",
"Natural Language Dictionary": "自然语言词库",
"Not indexed · suspected natural language": "未收录 · 疑似自然语言",
// 句尾的 3 也是变量:VAR_PATTERN 同时吃「“…”」和数字
"{} ↔ {} | suspected natural language: more than {} spaces; confirm or split it into tags":
"{} ↔ {}|疑似自然语言:空格超过 {} 个;请确认或拆成标签",
"Custom": "自定义",
"Pending": "待确认",
Syntax: "语法",
@@ -502,6 +539,8 @@ const ZH = {
"Save Personal": "保存为个人",
"Reset Built-in": "重置为内置",
"Select all ({} items)": "全选({} 条)",
"Edit {}": "编辑「{}」",
"Edit {} tags": "编辑 {} 个标签",
"pack:": "词包:",
"Copied {}": "已复制 {}",
"Processed {}": "已处理「{}」",
@@ -539,6 +578,8 @@ const ZH = {
"Added {}": "新增 {}",
"Conflicts {}": "冲突 {}",
"Duplicates {}": "重复 {}",
"No duplicates": "无重复",
"Waiting for prompt": "等待提示词",
"Invalid {}": "无效 {}",
"Import complete: added {}, overwrote {}, skipped {} | auto-backed up": "导入完成:新增 {}、覆盖 {}、跳过 {}|已自动备份",
"Import complete: {} added": "导入完成:新增 {} 条",
@@ -604,6 +645,27 @@ const ZH = {
"Bracket mismatch: extra or misplaced {}": "括号不匹配:多了或位置不对的 {}",
"Unclosed bracket: missing {}": "括号未闭合:缺少 {}",
"Duplicate tag: {}": "重复标签:{}",
// 重复标签的「唯一」开关 + 重复栏
Unique: "唯一",
"Keep only the first {} in the actual output; later occurrences stay here with a strikethrough":
"只让第一次出现的「{}」进入实际输出;其余照常显示在界面上(划线)",
"Tags repeated in this prompt; click a chip to step through its occurrences, double-click to delete the one you stepped to; click empty space here to clear the selection":
"提示词里重复的标签:单击胶囊依次跳到每一次出现,双击删掉跳到的那一处;点这里空白处取消选中",
"{} appears {} times; click to jump to the first one": "「{}」出现了 {} 次;单击跳到第一次",
"{} appears {} times; {} is on, only the first reaches the actual output": "「{}」出现了 {} 次;已开「{}」:只有第一次进入实际输出",
"{} appears {} times; now at {}, click for the next one, double-click to delete it":
"「{}」出现了 {} 次;当前第 {} 处,单击跳下一处,双击删除这一处",
"{} occurrence {} of {}; click again for the next one, double-click to delete it":
"「{}」第 {} / {} 处;再点一次跳下一处,双击删除这一处",
"Click a duplicate chip once to step to it before double-clicking to delete":
"先单击一次重复胶囊定位到某一处,再双击才会删除",
"{} now unique: only the first occurrence enters the actual output; press Ctrl+Z to undo":
"「{}」已设为唯一:只有第一次出现进入实际输出;按 Ctrl+Z 可撤销",
"{} no longer unique: all occurrences enter the actual output again; press Ctrl+Z to undo":
"「{}」已取消唯一:所有重复项重新进入实际输出;按 Ctrl+Z 可撤销",
"Query: {} | {} is on: only the first occurrence reaches the actual output": "查询「{}」|已开「{}」:只有第一次出现进入实际输出",
"keep only the first {}": "只保留第一次出现的「{}」",
"restore duplicates of {}": "恢复「{}」的重复项",
"Invalid weight format: {}": "权重格式无效:{}",
"Full-width separator detected; Anima tag flow usually uses ASCII commas": "检测到全角分隔符;Anima 标签流一般用半角逗号",
"Consecutive commas or empty tags detected": "检测到连续逗号或空标签",
+82 -3
View File
@@ -184,6 +184,43 @@ export function replacePromptTokenWeight(text, token, weight) {
};
}
/**
* Replace a raw slice of the prompt with whatever the user typed.
*
* Used by the "cut a piece out, edit it, paste it back" editor: the slice is a
* span of the ORIGINAL text (usually first-selected.start .. last-selected.end),
* so whatever sits between two selected tags comes along in the editor and is
* replaced with it. No parsing or validation happens here — commas the user
* typed simply get re-split by the next parse.
*
* Clearing the slice means deleting it, so the seam is tidied to avoid leaving
* `a, , b` behind: the comma on the left is dropped, or the one on the right
* when the slice started at the very beginning. A plain line break is left
* alone so multi-line layouts survive.
*
* The slice keeps its own leading / trailing whitespace in the text (that is
* where the space after a comma lives), so it is put back around what the user
* typed — otherwise the seam would come out as `a,b`.
*/
export function replacePromptSpan(text, start, end, replacement) {
const value = String(text ?? "");
const from = Math.max(0, Math.min(value.length, Number.isInteger(start) ? start : 0));
const to = Math.max(from, Math.min(value.length, Number.isInteger(end) ? end : from));
const span = value.slice(from, to);
const typed = String(replacement ?? "").trim();
const insert = typed ? `${span.match(/^\s*/)[0]}${typed}${span.match(/\s*$/)[0]}` : "";
if (span === insert) return { text: value, cursor: to, changed: false };
let left = value.slice(0, from);
let right = value.slice(to);
if (!insert) {
const trimmedLeft = left.replace(/[ \t]*,\r?\n?[ \t]*$/, "");
if (trimmedLeft !== left) left = trimmedLeft;
else right = right.replace(/^[\s,]+/, "");
}
const nextText = left + insert + right;
return { text: nextText, cursor: Math.min(from + insert.length, nextText.length), changed: true };
}
/**
* Move one parsed prompt token to a new position in the token order.
*
@@ -409,6 +446,15 @@ function looksLikeTagSuffix(raw) {
return true;
}
/**
* 标签里的空格超过 3 个(不含 3 个)时,它多半是被当成标签用的自然语言短语,
* 界面上标成「疑似自然语言」提醒确认。真正的 Danbooru 标签很少超过 3 个空格。
*/
export function looksSuspiciousNatural(raw) {
const value = String(raw ?? "").trim();
return (value.match(/ /g) ?? []).length > 3;
}
function trailingTagStart(segments, afterIndex) {
let start = segments.length;
while (start > afterIndex && looksLikeTagSuffix(segments[start - 1].raw)) start -= 1;
@@ -431,7 +477,10 @@ export function detectInputMode(text, requestedMode = "auto") {
const words = value.match(/[A-Za-z]+(?:[-'][A-Za-z]+)*/g) ?? [];
const commaSegments = splitPrompt(value);
const hasSentenceEnd = /[。!?.!?](?:\s|$)/.test(value);
// 权重小数和版本号里的句点(":1.2"、"1.")以及权重括号后的句点("(...:1.2).")都不是句子结束。
// 先把它们中和掉再判断,否则整串标签会只因为一个小数点被当成自然语言段落。
const sentenceEndCandidate = value.replace(/\d\s*[。!?.!?]|[)\]][。!?.!?]/g, (match) => match.replace(/[。!?.!?]/, ""));
const hasSentenceEnd = /[。!?.!?](?:\s|$)/.test(sentenceEndCandidate);
const hasNaturalGrammar = /\b(?:she|he|they|who|which|is|are|was|were|wearing|holding|standing|sitting|with|while|under|inside|outside|in the|on the)\b/i.test(value);
const longCommaSegment = commaSegments.some((item) => (item.raw.match(/[A-Za-z]+(?:[-'][A-Za-z]+)*/g) ?? []).length >= 10);
const strongNaturalStart = commaSegments.findIndex((item) => looksLikeNaturalClause(item.raw));
@@ -448,6 +497,15 @@ export function detectInputMode(text, requestedMode = "auto") {
if (commaSegments.length >= 3 && naturalStart === 0 && naturalEnd < commaSegments.length) {
return { mode: "mixed", reason: "Natural language followed by tag list", confidence: "high", naturalStart, naturalEnd };
}
// 上面没成立通常是尾部定界没认出标签流:trailingTagStart 还要求后缀里有 masterpiece /
// best quality 那类明星信号词,标签写得偏冷门时一个都命中不了。这时不该退化成整段自然语言——
// 开头那句是自然语言,后面明明是标签流,至少把开头的句子切出来。
if (naturalStart === 0 && commaSegments.length >= 4 && naturalEnd >= commaSegments.length && longCommaSegment) {
const tailTags = commaSegments.slice(1).filter((item) => looksLikeTagSuffix(item.raw)).length;
if (tailTags >= 4) {
return { mode: "mixed", reason: "Natural language followed by tag list", confidence: "medium", naturalStart, naturalEnd: naturalStart + 1 };
}
}
if (commaSegments.length >= 3 && naturalStart > 0) {
const shortBefore = commaSegments.slice(0, naturalStart).filter((item) =>
(item.raw.match(/[A-Za-z]+(?:[-'][A-Za-z]+)*/g) ?? []).length <= 6
@@ -608,6 +666,9 @@ export function parsePrompt(text, dictionaryIndex, machineTranslations = new Map
chinese = "自然语言片段(待翻译或确认)";
}
const confidence = confidenceFor(status, dictionaryEntry);
const suspectedNatural = analyzed.syntax === "tag" &&
segment.segmentKind !== "natural" &&
looksSuspiciousNatural(segment.raw);
return {
...segment,
...analyzed,
@@ -615,6 +676,7 @@ export function parsePrompt(text, dictionaryIndex, machineTranslations = new Map
key,
chinese,
status,
suspectedNatural,
...confidence,
inputMode: detected.mode,
entry: dictionaryEntry ?? null,
@@ -658,6 +720,23 @@ const CONFLICT_GROUPS = [
{ left: ["full body"], right: ["extreme close-up", "close-up"], label: "Full body vs close-up" },
];
// 重复分组:按 token.key 聚拢,只保留出现 2 次以上的组,组内按原文顺序。
// 问题行的「重复标签」提示和「唯一」开关都从这里取数,两边口径不会打架。
// 口径沿用 key:忽略大小写、连续空格、强调括号与权重((1girl:1.3) ≡ 1girl);
// 下划线按一个空格算,所以 sm_ile ≠ smile。BREAK 与 <lora:…> 不算重复标签。
export function duplicateGroups(tokens) {
const groups = new Map();
for (const token of tokens ?? []) {
if (!token.key || token.syntax !== "tag") continue;
const list = groups.get(token.key) ?? [];
list.push(token);
groups.set(token.key, list);
}
return [...groups]
.filter(([, list]) => list.length > 1)
.map(([key, list]) => ({ key, tokens: list }));
}
export function analyzePromptSyntax(text, tokens, modeInfo = detectInputMode(text)) {
const value = String(text ?? "");
const issues = bracketIssues(value);
@@ -677,8 +756,8 @@ export function analyzePromptSyntax(text, tokens, modeInfo = detectInputMode(tex
issues.push(issue("warning", "unusual-weight", `Weight ${token.weight} outside common range 0–3`, [token.key]));
}
}
for (const [key, matches] of keys) {
if (matches.length > 1) issues.push(issue("warning", "duplicate", `Duplicate tag: “${matches[0].term}”`, [key]));
for (const { key, tokens: matches } of duplicateGroups(tokens ?? [])) {
issues.push(issue("warning", "duplicate", `Duplicate tag: “${matches[0].term}”`, [key]));
}
const present = new Set(keys.keys());
+41
View File
@@ -1,3 +1,4 @@
from .duplicate_check import dedupe_prompt, find_duplicates, format_duplicates
from .server import announce_upstream, current_prompt_id, upstream_gate
@@ -52,3 +53,43 @@ class BilingualPromptInspector:
released = await upstream_gate.wait(node_id, future)
# 前端放行时带回的是节点上编辑后的文本;无人接管(无浏览器或 API 调用)时原样透传。
return (released if isinstance(released, str) else prompt,)
class DuplicateChecker:
"""
@title: 重复检查
@nickname: Duplicate Check
@description: 检查提示词里的重复标签:节点上用淡橙色提示有几处重复,「提示词」输出直通下游,「重复项」输出可接预览任意查看。开关打开时只保留第一次出现的标签。
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"text": ("STRING", {"forceInput": True}),
},
"optional": {
"keep_first": ("BOOLEAN", {"default": False}),
},
}
RETURN_TYPES = ("STRING", "STRING")
RETURN_NAMES = ("提示词", "重复项")
FUNCTION = "check"
CATEGORY = "text/prompt"
DESCRIPTION = "Experimental duplicate tag checker: flags repeated tags with a soft amber tint, passes the prompt through, and exposes the duplicated tags for preview."
def check(self, text, keep_first=False):
value = str(text or "")
report = find_duplicates(value)
# 开关没开就原样直通,连格式都不重写,免得改了用户没要求改的东西。
prompt = dedupe_prompt(value) if keep_first else value
# ui 里每个值都必须是列表:execution.py 会把它们展开拼接成一个列表,
# 传裸 int 会直接抛 "'int' object is not iterable"。
return {
"ui": {
"duplicate_count": [report["extra"]],
"duplicate_terms": [item["term"] for item in report["terms"]],
},
"result": (prompt, format_duplicates(report)),
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "comfyui-bilingual-prompt-inspector",
"version": "1.2.10",
"version": "2.0.0",
"description": "ComfyUI bilingual prompt inspection, translation, Anima sorting, and on-demand Danbooru dictionary extension.",
"private": true,
"type": "module",
+6 -5
View File
@@ -1,7 +1,8 @@
[project]
name = "prompt-translator-manager"
version = "1.2.10"
description = "ComfyUI prompt node with translation and management: bilingual tag view, drag-to-reorder, temporary hiding, multiple translation services and a sidebar dictionary manager."
version = "2.0.0"
# 与 Comfy Registry 页面上显示的描述保持一致: publish 会用这里的值覆盖网页上的手改
description = "ComfyUI prompt node with translation and management: bilingual tag view, drag-to-reorder, temporary hiding, multiple translation services and a sidebar dictionary manager.\nComfyUI 提示节点,支持翻译和管理:双语标签视图、拖拽排序、临时隐藏、多翻译服务和侧边栏词典管理器。"
license = { file = "LICENSE" }
requires-python = ">=3.10"
dependencies = []
@@ -23,6 +24,6 @@ requires-comfyui = ">=0.3.40"
# 和 __init__.py 的 WEB_DIRECTORY(key = 模块名),两者指向同一个 js/ 目录时
# /extensions 接口会把每个 js 列两遍,前端加载两次 → "Extension already registered"。
# 前端资源统一由 __init__.py 的 WEB_DIRECTORY = "./js" 提供(各版本 ComfyUI 都支持)。
# 图标与横幅(可选,填好图片的公开 URL 后取消注释即可)
# Icon = "https://..." # 方形,≤400×400,PNG/SVG/JPG/GIF
# Banner = "https://..." # 21:9 横幅,PNG/SVG/JPG/GIF
# 图标与横幅
Icon = "https://raw.githubusercontent.com/mengshengzhijie/ComfyUI-Bilingual-Prompt-Inspector/main/assets/icon.png" # 方形 400×400(缩过的小图)
Banner = "https://raw.githubusercontent.com/mengshengzhijie/ComfyUI-Bilingual-Prompt-Inspector/main/assets/banner.png" # 原图 941×941(未缩放),站点会自行裁切并加背景虚化
+38 -17
View File
@@ -26,13 +26,16 @@ from .assistant_store import (
translation_direction,
)
from .dictionary_store import DictionaryStore, normalize_key
from .duplicate_check import find_duplicates
from .saved_prompt_store import (
MAX_IMAGE_BYTES,
SavedPromptStore,
)
from .token_color_store import TokenColorStore
store = DictionaryStore()
token_color_store = TokenColorStore()
assistant_store = AssistantStore()
assistant_store.config() # 进程启动时立即清除与当前安装身份不匹配的旧配置和凭据
@@ -388,26 +391,26 @@ async def get_dictionary(_request):
return error_response(error, 500)
# 节点标签卡的颜色配置:用户可以直接编辑 data/token_colors.json 增删色块,
# 不用改源码。文件不在或格式不对时回退内置默认,不影响功能。
_DEFAULT_TOKEN_COLORS = {
"presets": ["#6b9b78", "#9b8b6b", "#6b7b9b", "#9b6b8b", "#8b9b6b", "#6b9b9b", "#9b7b6b", "#7b6b9b"],
"random_pool": ["#5b8a72", "#8a725b", "#5b6e8a", "#8a5b7a", "#7a8a5b", "#5b8a8a", "#8a6b5b", "#6b5b8a"],
}
# 节点标签卡的颜色配置:色块池只读(用户可以直接编辑 data/token_colors.json),
# 按词库配色由侧边栏「颜色」区写回同一个文件。
@protected_route("get", "/bpi/token-colors")
async def get_token_colors(_request):
from pathlib import Path
path = Path(__file__).resolve().parent / "data" / "token_colors.json"
try:
if path.exists():
data = json.loads(path.read_text(encoding="utf-8"))
if isinstance(data, dict) and isinstance(data.get("presets"), list) and isinstance(data.get("random_pool"), list):
return web.json_response({"success": True, "data": data})
except (OSError, ValueError):
pass
return web.json_response({"success": True, "data": _DEFAULT_TOKEN_COLORS})
return web.json_response({"success": True, "data": token_color_store.config()})
except OSError as error:
return error_response(error, 500)
@protected_route("post", "/bpi/token-colors/colors")
async def save_token_colors(request):
try:
payload = await _read_json(request, _MAX_JSON_BYTES)
if not isinstance(payload, dict):
raise ValueError("配色内容必须是对象")
data = token_color_store.save_colors(payload.get("pack_colors"), payload.get("anima_colors"))
return web.json_response({"success": True, "data": data})
except (OSError, ValueError) as error:
return error_response(error)
@protected_route("post", "/bpi/large/lookup")
@@ -852,3 +855,21 @@ def collect_model_names():
async def list_model_names(_request):
# 首次调用要遍历 models 目录,放到线程里免得卡住事件循环
return web.json_response({"success": True, "data": await asyncio.to_thread(collect_model_names)})
# ---------------------------------------------------------------------------
# 重复检查:节点上输入一变就要立刻提示,和节点执行走同一个 find_duplicates
# ---------------------------------------------------------------------------
@protected_route("post", "/bpi/duplicate-check")
async def check_duplicate_tags(request):
try:
payload = await _read_json(request)
except (ValueError, web.HTTPBadRequest) as error:
return error_response(error)
report = find_duplicates(payload.get("text") or "")
return web.json_response(
{"success": True, "data": {"count": report["extra"], "terms": report["terms"]}},
headers={"Cache-Control": "no-store"},
)
+27 -1
View File
@@ -124,7 +124,33 @@ class AssistantStoreTests(unittest.TestCase):
self.assertEqual(translation_direction("杰作, best quality"), "to_english")
self.assertEqual(
sanitize_anima_prompt("```text\nmasterpiece,(smile:1.2);@artist!\n```"),
"masterpiece, (smile:1.2), @artist.",
"masterpiece, (smile:1.2), @artist",
)
# 模型把结果包成 JSON/代码字符串时会残留转义符,清洗后不应判废
self.assertEqual(
sanitize_anima_prompt('masterpiece, \\"blue eyes\\", 1girl'),
"masterpiece, blue eyes, 1girl",
)
self.assertEqual(
sanitize_anima_prompt("masterpiece\\n\\t1girl, "blue dress""),
"masterpiece, 1girl, blue dress",
)
# 模型用来断句的句点统一换成逗号,权重里的小数点必须原样保留
self.assertEqual(
sanitize_anima_prompt("masterpiece, 1girl, dragon slayer ornstein."),
"masterpiece, 1girl, dragon slayer ornstein",
)
self.assertEqual(
sanitize_anima_prompt("masterpiece, 1girl. A boy stands in the rain."),
"masterpiece, 1girl, A boy stands in the rain",
)
self.assertEqual(
sanitize_anima_prompt("(smile:1.2), (@rei (sanbonzakura):1.3), 1girl. A knight stands."),
"(smile:1.2), (@rei (sanbonzakura):1.3), 1girl, A knight stands",
)
self.assertEqual(
sanitize_anima_prompt("<lora:detail:0.8>, 1girl. A knight stands amid ruins, red banners."),
"<lora:detail:0.8>, 1girl, A knight stands amid ruins, red banners",
)
with self.assertRaisesRegex(ValueError, "不符合 Anima"):
sanitize_anima_prompt("masterpiece [smile]")
+22
View File
@@ -41,6 +41,17 @@ class DictionaryStoreTests(unittest.TestCase):
self.assertTrue(store.user_path.exists())
self.assertEqual(store.user_tags()[0]["english"], "keep me")
def test_natural_language_pack_name_in_snapshot(self):
self.assertFalse(validate_tag({"english": "smile", "chinese": "微笑"})["natural"])
self.assertTrue(validate_tag({"english": "smile", "chinese": "微笑", "natural": True})["natural"])
temp, store = self.make_store()
self.addCleanup(temp.cleanup)
store.upsert({"english": "smile", "chinese": "微笑"})
store.upsert({"english": "a knight stands in the rain", "chinese": "一名骑士站在雨中", "natural": True})
names = {tag["english"]: tag["pack_name"] for tag in store.snapshot()["tags"]}
self.assertEqual(names["smile"], "个人词库")
self.assertEqual(names["a knight stands in the rain"], "自然语言词库")
def test_normalize_matches_spaces_and_underscores(self):
self.assertEqual(normalize_key(" Looking_At_Viewer "), "looking at viewer")
@@ -124,6 +135,17 @@ class DictionaryStoreTests(unittest.TestCase):
self.assertEqual(effective["smile"]["chinese"], "个人微笑")
self.assertEqual(effective["smile"]["pack_id"], "personal")
def test_natural_language_dictionary_is_a_separate_pack(self):
temp, store = self.make_pack_store()
self.addCleanup(temp.cleanup)
store.upsert({"english": "smile", "chinese": "个人微笑"})
store.upsert({"english": "a woman is standing in the rain", "chinese": "一个女人站在雨中", "natural": True})
effective = {tag["english"]: tag for tag in store.snapshot()["tags"]}
self.assertEqual(effective["smile"]["pack_id"], "personal")
self.assertEqual(effective["smile"]["pack_name"], "个人词库")
self.assertEqual(effective["a woman is standing in the rain"]["pack_id"], "natural")
self.assertEqual(effective["a woman is standing in the rain"]["pack_name"], "自然语言词库")
def test_community_pack_lifecycle(self):
temp, store = self.make_pack_store()
self.addCleanup(temp.cleanup)
+78
View File
@@ -0,0 +1,78 @@
import assert from "node:assert/strict";
import {
analyzePromptSyntax,
buildDictionaryIndex,
duplicateGroups,
normalizeKey,
parsePrompt,
removePromptToken,
} from "../js/parser.js";
import { setLanguageMode, t } from "../js/i18n.js";
const dictionary = buildDictionaryIndex([
{ english: "1girl", chinese: "一个女孩", verified: true },
{ english: "smile", chinese: "微笑", verified: true },
]);
// 同一个标签出现 3 次:权重写法不同的那次也算同一 key
const groups = duplicateGroups(parsePrompt("1girl, smile, (1girl:1.3), 1girl", dictionary));
assert.equal(groups.length, 1);
assert.equal(groups[0].key, normalizeKey("1girl"));
assert.equal(groups[0].tokens.length, 3);
// 顺序按原文,且三次都是普通标签
assert.equal(groups[0].tokens[0].raw.trim(), "1girl");
assert.equal(groups[0].tokens[1].raw.trim(), "(1girl:1.3)");
assert.ok(groups[0].tokens.every((token) => token.syntax === "tag"));
// key 归一:大小写差异算同一个标签(下划线是按空格算的,"sm_ile" 不等于 "smile")
assert.equal(duplicateGroups(parsePrompt("smile, Smile", dictionary)).length, 1);
// BREAK 与 <lora:…> 不算重复标签
assert.equal(duplicateGroups(parsePrompt("BREAK, BREAK", dictionary)).length, 0);
assert.equal(duplicateGroups(parsePrompt("<lora:a:1>, <lora:a:1>", dictionary)).length, 0);
// 问题行的「重复标签」提示与分组口径一致(同一个标签只提示一次)
const text = "1girl, 1girl, smile";
const issues = analyzePromptSyntax(text, parsePrompt(text, dictionary));
assert.equal(issues.filter((item) => item.code === "duplicate").length, 1);
assert.deepEqual(issues.find((item) => item.code === "duplicate").tokenKeys, [normalizeKey("1girl")]);
// 「唯一」的输出剔除:按出现次序删,第一次出现的那次要留着
let output = "1girl, smile, (1girl:1.3), 1girl";
for (;;) {
const parsed = parsePrompt(output, dictionary);
const target = duplicateGroups(parsed)
.filter((group) => group.key === normalizeKey("1girl"))
.flatMap((group) => group.tokens.slice(1))[0];
if (!target) break;
const step = removePromptToken(output, target);
assert.ok(step.changed);
output = step.text;
}
assert.equal(output.trim(), "1girl, smile");
// 界面文案:英文原文 + 中文译表双写,变量折成 {} 后要能命中
setLanguageMode("en");
assert.equal(t("Unique"), "Unique");
assert.equal(t("Duplicates {}").replace("{}", "3"), "Duplicates 3");
setLanguageMode("zh");
assert.equal(t("Unique"), "唯一");
assert.equal(t("Duplicates 3"), "重复 3");
assert.equal(t("Keep only the first “1girl” in the actual output; later occurrences stay here with a strikethrough"),
"只让第一次出现的「1girl」进入实际输出;其余照常显示在界面上(划线)");
assert.equal(t("Tags repeated in this prompt; click a chip to step through its occurrences, double-click to delete the one you stepped to; click empty space here to clear the selection"),
"提示词里重复的标签:单击胶囊依次跳到每一次出现,双击删掉跳到的那一处;点这里空白处取消选中");
assert.equal(t("“1girl” appears 3 times; click to jump to the first one"), "「1girl」出现了 3 次;单击跳到第一次");
// 开了「唯一」的胶囊是另一个提示语,里面嵌套的「Unique」要跟着翻成「唯一」
assert.equal(t("“1girl” appears 3 times; “Unique” is on, only the first reaches the actual output"),
"「1girl」出现了 3 次;已开「唯一」:只有第一次进入实际输出");
assert.equal(t("“1girl” occurrence 2 of 3; click again for the next one, double-click to delete it"),
"「1girl」第 2 / 3 处;再点一次跳下一处,双击删除这一处");
assert.equal(t("“1girl” now unique: only the first occurrence enters the actual output; press Ctrl+Z to undo"),
"「1girl」已设为唯一:只有第一次出现进入实际输出;按 Ctrl+Z 可撤销");
// 「Unique」在译文里会被二次翻译,tooltip 读起来是一致的中文
assert.equal(t("Query: “1girl” | “Unique” is on: only the first occurrence reaches the actual output"),
"查询「1girl」|已开「唯一」:只有第一次出现进入实际输出");
console.log("test_duplicates: ok");
+27
View File
@@ -34,6 +34,27 @@ assert.equal(t("restored 3 hidden tags; press Ctrl+Z to undo"), "已恢复 3 个
assert.equal(t("items 3 | recognized 1 | unknown 2"), "共 3 个标签|已识别 1|未收录 2");
assert.equal(t("items 3 | recognized 1 | unknown 2 | pending 1"), "共 3 个标签|已识别 1|未收录 2|待确认 1");
assert.equal(t("Personal"), "个人");
// 过滤栏颜色下拉的第四项:按标签所在词库上色
assert.equal(t("By dictionary"), "按词库");
// 侧边栏「颜色」区:分区名、按钮、词条数、未收录项
assert.equal(t("Colors"), "颜色");
assert.equal(t("Restore defaults"), "恢复默认");
assert.equal(t("Random color"), "随机颜色");
assert.equal(t("Not indexed"), "未收录");
assert.equal(t("3 items"), "3 条");
assert.equal(t("952 items"), "952 条");
// 按 Anima 配色的分类短名(跟 anima_sorter.js 的槽位一一对应)
assert.equal(t("By Anima"), "按 Anima");
assert.equal(t("Quality"), "质量");
assert.equal(t("Expression"), "表情");
assert.equal(t("Camera"), "镜头");
assert.equal(t("Scene"), "场景");
assert.equal(t("Copyright"), "作品");
// 双击改色面板底部那句优先级说明
assert.equal(
t("This color has the lowest priority: it only shows in Default mode; other color modes override it."),
"这里改的颜色优先级最低:只有「默认」上色模式才显示,按收录、随机、按词库、按 Anima 都会盖住它。",
);
assert.equal(t("Highest"), "最高");
assert.equal(t("12 items | Local user | Always enabled, overrides same-name entries"), "12 条|本地用户|始终启用,覆盖同名条目");
@@ -77,6 +98,12 @@ for (const [source, expected] of [
["Translation complete 5 items, rejected abnormal results 2 items", "翻译完成 5 条,已拒绝异常结果 2 条"],
["uses “12,345”", "使用 12,345 次"],
["tag: “1girl” | Anima format: (Tag:Weight)", "标签:1girl|Anima 格式:(标签:权重)"],
["Suspected natural language", "疑似自然语言"],
["Not indexed · suspected natural language", "未收录 · 疑似自然语言"],
[
"“overgrown with a variety of red wildflowers” ↔ “未收录” | suspected natural language: more than 3 spaces; confirm or split it into tags",
"overgrown with a variety of red wildflowers ↔ 未收录|疑似自然语言:空格超过 3 个;请确认或拆成标签",
],
["Node #3 · 12 tags", "节点 #3 · 12 个标签"],
["Translating: “1girl”", "正在翻译:1girl"],
["Re-translated “1girl”", "已重新翻译「1girl」"],
+45
View File
@@ -6,8 +6,10 @@ import {
buildDictionaryIndex,
detectInputMode,
extractInstructionBody,
looksSuspiciousNatural,
parsePrompt,
removePromptToken,
replacePromptSpan,
replacePromptTokenWeight,
splitMixedText,
splitNaturalText,
@@ -90,6 +92,26 @@ assert.equal(tagsAroundTokens[7].segmentKind, "natural");
assert.equal(tagsAroundTokens[8].term, "best quality");
assert.equal(tagsAroundTokens.at(-1).term, "newest");
// 开头的自然语言句很长、后面的标签又偏冷门(没有 masterpiece/best quality 那类信号词)时,
// 仍要把开头那句切出来,而不是把整段当成一个自然语言段
const coldTailSource = "A knight in dark gold armor holds a polearm spear amid overgrown ruins with red banners, cracked white stone, and red wildflowers under dramatic cinematic lighting\nultra-HD, masterwork, detailed, fs_ornstein, dark gold armor, breastplate, full armor, holding polearm, overgrown ruins, white and red theme";
const coldTailMode = detectInputMode(coldTailSource);
assert.equal(coldTailMode.mode, "mixed");
assert.equal(coldTailMode.naturalStart, 0);
const coldTailTokens = parsePrompt(coldTailSource, dictionary);
assert.equal(coldTailTokens.filter((token) => token.segmentKind === "natural").length, 1);
assert.equal(coldTailTokens.at(-1).term, "white and red theme");
// 空格超过 3 个(不含 3 个)就标成「疑似自然语言」
assert.equal(looksSuspiciousNatural("white and red theme"), false);
assert.equal(looksSuspiciousNatural("dragon slayer ornstein"), false);
assert.equal(looksSuspiciousNatural("overgrown with a variety of red wildflowers"), true);
assert.equal(looksSuspiciousNatural("and red wildflowers under dramatic cinematic lighting"), true);
const suspectedTokens = parsePrompt("1girl, white and red theme, overgrown with a variety of red wildflowers", dictionary);
assert.equal(suspectedTokens[0].suspectedNatural, false);
assert.equal(suspectedTokens[1].suspectedNatural, false);
assert.equal(suspectedTokens[2].suspectedNatural, true);
const commaRichNatural = "A woman walks home after the rain, feeling tired and cold, carrying a small umbrella, while the city grows quiet.";
assert.equal(detectInputMode(commaRichNatural).mode, "natural");
const shortClauseNatural = "She came home, tired, cold, wet, alone, at night.";
@@ -113,6 +135,12 @@ assert.equal(
"tags",
);
assert.equal(detectInputMode("A woman sits on a bed, asking for a hug.").mode, "natural");
// 权重里的句点不能把整串标签判定成自然语言
const weightedTagStream = "8K, ultra detailed, masterpiece, best quality, newest, absurdres, highres, very aesthetic, sharp focus, ultra-HD, amazing quality, masterwork, detailed, high detail, great atmosphere, amazing ambience, perfect composition, intricate, dynamic, rich color, 1boy, male focus, fs_ornstein, dragon slayer ornstein";
assert.equal(detectInputMode(`${weightedTagStream}, (@rei (sanbonzakura):1. )`).mode, "tags");
assert.equal(detectInputMode(`${weightedTagStream}, (@rei (sanbonzakura):1.\t`).mode, "tags");
assert.equal(detectInputMode(`${weightedTagStream}, (@rei (sanbonzakura):1.2). `).mode, "tags");
assert.equal(parsePrompt(`${weightedTagStream}, (@rei (sanbonzakura):1.2)`, dictionary).length, 25);
assert.equal(detectInputMode("whatever", "natural").reason, "Manual override");
const instructed = "翻译要求:转换为 Anima 标签\n正文:一位成年女性站在雨中";
assert.equal(detectInputMode(instructed).mode, "instruction");
@@ -155,6 +183,23 @@ assert.equal(replacePromptTokenWeight(weightSource, weightTokens[1], 4).changed,
const naturalWeightToken = parsePrompt("A woman is standing in the rain.", dictionary)[0];
assert.equal(replacePromptTokenWeight("A woman is standing in the rain.", naturalWeightToken, 1.2).changed, false);
// 「剪切出来改完再粘回去」:整段替换,段首段尾的空白是原文排版,要跟着补回去
const spanSource = "masterpiece, best quality, solo";
const spanTokens = parsePrompt(spanSource, dictionary);
assert.deepEqual(replacePromptSpan(spanSource, spanTokens[1].start, spanTokens[1].end, "high quality, ultra detailed"), {
text: "masterpiece, high quality, ultra detailed, solo",
cursor: 41,
changed: true,
});
assert.equal(replacePromptSpan(spanSource, spanTokens[1].start, spanTokens[2].end, "1girl").text, "masterpiece, 1girl");
// 清空 = 删除这一段,接缝处的逗号只留一个;开头那段没有左逗号就吃右边那个
assert.equal(replacePromptSpan(spanSource, spanTokens[1].start, spanTokens[2].end, "").text, "masterpiece");
assert.equal(replacePromptSpan(spanSource, spanTokens[0].start, spanTokens[0].end, "").text, "best quality, solo");
// 原样写回不算改动
assert.equal(replacePromptSpan(spanSource, spanTokens[1].start, spanTokens[1].end, "best quality").changed, false);
// 换行不是逗号,删掉标签后要留下,别把多行排版压平
assert.equal(replacePromptSpan("a\n1girl\n, b", 2, 7, "").text, "a\nb");
const diagnosticSource = "1girl, solo, 2girls, standing, sitting, red hair, red_hair, (smile:abc), [open";
const diagnosticTokens = parsePrompt(diagnosticSource, dictionary);
const diagnostics = analyzePromptSyntax(diagnosticSource, diagnosticTokens);
+76
View File
@@ -0,0 +1,76 @@
import json
import tempfile
import unittest
from pathlib import Path
from token_color_store import (
DEFAULT_PRESETS,
DEFAULT_RANDOM_POOL,
TokenColorStore,
normalize_color,
)
class TokenColorStoreTests(unittest.TestCase):
def make_store(self, payload=None):
temp = tempfile.TemporaryDirectory()
self.addCleanup(temp.cleanup)
store = TokenColorStore(Path(temp.name))
if payload is not None:
store.path.write_text(json.dumps(payload), encoding="utf-8")
return store
def test_normalize_color_accepts_short_and_long_hex(self):
self.assertEqual(normalize_color("#abc"), "#aabbcc")
self.assertEqual(normalize_color("#AABBCC"), "#aabbcc")
self.assertIsNone(normalize_color("#abcde"))
self.assertIsNone(normalize_color("red"))
self.assertIsNone(normalize_color(None))
def test_missing_file_falls_back_to_defaults(self):
config = self.make_store().config()
self.assertEqual(config["presets"], DEFAULT_PRESETS)
self.assertEqual(config["random_pool"], DEFAULT_RANDOM_POOL)
self.assertEqual(config["pack_colors"], {})
self.assertEqual(config["anima_colors"], {})
def test_broken_or_partial_file_still_reads_pools(self):
config = self.make_store({"presets": ["#112233"], "random_pool": [], "pack_colors": "bad"}).config()
self.assertEqual(config["presets"], ["#112233"])
self.assertEqual(config["random_pool"], [])
self.assertEqual(config["pack_colors"], {})
def test_save_colors_writes_back_and_keeps_pools(self):
store = self.make_store()
config = store.save_colors(pack_colors={"personal": "#ABC", "camera": None})
self.assertEqual(config["pack_colors"], {"personal": "#aabbcc"})
self.assertEqual(config["presets"], DEFAULT_PRESETS)
written = json.loads(store.path.read_text(encoding="utf-8"))
self.assertEqual(written["pack_colors"], {"personal": "#aabbcc"})
def test_save_colors_rejects_bad_input(self):
store = self.make_store()
with self.assertRaises(ValueError):
store.save_colors(pack_colors={"personal": "not-a-color"})
with self.assertRaises(ValueError):
store.save_colors(anima_colors={"bad id!": "#112233"})
with self.assertRaises(ValueError):
store.save_colors(pack_colors=[])
def test_empty_value_clears_existing_pack_color(self):
store = self.make_store({"pack_colors": {"personal": "#112233"}})
self.assertEqual(store.config()["pack_colors"], {"personal": "#112233"})
self.assertEqual(store.save_colors(pack_colors={"personal": ""})["pack_colors"], {})
def test_anima_colors_are_saved_alongside_pack_colors(self):
store = self.make_store({"pack_colors": {"personal": "#112233"}})
config = store.save_colors(anima_colors={"quality": "#ABC", "expression": ""})
self.assertEqual(config["anima_colors"], {"quality": "#aabbcc"})
# 只传了一类,另一类保持原样
self.assertEqual(config["pack_colors"], {"personal": "#112233"})
self.assertEqual(store.config()["anima_colors"], {"quality": "#aabbcc"})
def test_save_colors_without_payload_is_rejected(self):
store = self.make_store()
with self.assertRaises(ValueError):
store.save_colors()
+130
View File
@@ -0,0 +1,130 @@
"""节点标签卡的配色配置。
色块池(presets / random_pool)只读,用户可以直接编辑 data/token_colors.json 增删色块;
按词库配色(pack_colors)由界面写回同一个文件,格式不对时回退默认,不影响功能。
"""
import json
import os
import re
import tempfile
from pathlib import Path
DEFAULT_PRESETS = [
"#6b9b78", "#9b8b6b", "#6b7b9b", "#9b6b8b",
"#8b9b6b", "#6b9b9b", "#9b7b6b", "#7b6b9b",
]
DEFAULT_RANDOM_POOL = [
"#5b8a72", "#8a725b", "#5b6e8a", "#8a5b7a",
"#7a8a5b", "#5b8a8a", "#8a6b5b", "#6b5b8a",
]
# 未命中任何词库的标签也当成一个可配色的来源,key 固定为 unindexed
UNINDEXED_PACK_ID = "unindexed"
MAX_COLOR_ENTRIES = 200
PACK_ID_PATTERN = re.compile(r"[A-Za-z0-9_-]{1,64}")
HEX_COLOR_PATTERN = re.compile(r"#[0-9a-fA-F]{6}")
SHORT_HEX_COLOR_PATTERN = re.compile(r"#[0-9a-fA-F]{3}")
def normalize_color(value):
"""把 #RGB / #RRGGBB 统一成小写的 #rrggbb,认不出来返回 None。"""
text = str(value or "").strip()
if HEX_COLOR_PATTERN.fullmatch(text):
return text.lower()
if SHORT_HEX_COLOR_PATTERN.fullmatch(text):
return "#" + "".join(char * 2 for char in text[1:]).lower()
return None
class TokenColorStore:
def __init__(self, data_dir=None):
self.data_dir = Path(data_dir or Path(__file__).resolve().parent / "data")
self.path = self.data_dir / "token_colors.json"
@staticmethod
def defaults():
return {
"presets": list(DEFAULT_PRESETS),
"random_pool": list(DEFAULT_RANDOM_POOL),
"pack_colors": {},
"anima_colors": {},
}
def config(self):
"""读取配置:色块池原样透传,缺字段或文件坏了就回退默认。"""
payload = self._read()
if payload is None:
return self.defaults()
config = self.defaults()
for key in ("presets", "random_pool"):
values = payload.get(key)
if isinstance(values, list):
config[key] = values
for key in ("pack_colors", "anima_colors"):
values = payload.get(key)
if isinstance(values, dict):
config[key] = self._clean_map(values)
return config
@staticmethod
def _clean_map(raw):
cleaned = {}
for key, value in raw.items():
color = normalize_color(value)
if color and PACK_ID_PATTERN.fullmatch(str(key)):
cleaned[str(key)] = color
return cleaned
@staticmethod
def _validated_map(raw, label):
if not isinstance(raw, dict):
raise ValueError(f"{label}必须是对象")
if len(raw) > MAX_COLOR_ENTRIES:
raise ValueError(f"{label}最多 {MAX_COLOR_ENTRIES} 项")
cleaned = {}
for raw_key, value in raw.items():
key = str(raw_key).strip()
if not PACK_ID_PATTERN.fullmatch(key):
raise ValueError(f"分类ID格式不正确:{key}")
if value in (None, ""):
continue
color = normalize_color(value)
if color is None:
raise ValueError(f"颜色必须是 #RGB 或 #RRGGBB 十六进制值:{value}")
cleaned[key] = color
return cleaned
def save_colors(self, pack_colors=None, anima_colors=None):
"""整体覆盖某一类配色:传空值表示该项恢复默认。只更新传进来的那一类。"""
if pack_colors is None and anima_colors is None:
raise ValueError("没有要保存的配色")
config = self.config()
if pack_colors is not None:
config["pack_colors"] = self._validated_map(pack_colors, "按词库配色")
if anima_colors is not None:
config["anima_colors"] = self._validated_map(anima_colors, "按 Anima 配色")
self._write(config)
return config
def _read(self):
if not self.path.exists():
return None
try:
payload = json.loads(self.path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return None
return payload if isinstance(payload, dict) else None
def _write(self, payload):
self.path.parent.mkdir(parents=True, exist_ok=True)
fd, temp_name = tempfile.mkstemp(prefix="token_colors_", suffix=".json", dir=self.path.parent)
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
json.dump(payload, handle, ensure_ascii=False, indent=2)
handle.write("\n")
os.replace(temp_name, self.path)
finally:
if os.path.exists(temp_name):
os.unlink(temp_name)