Compare commits

...
20 Commits
Author SHA1 Message Date
梦圣智杰 194a6a8206 chore: 发布 v2.1.8 / Release v2.1.8 2026-10-26 05:42:02 +08:00
梦圣智杰 d38de335bd feat: 新增「适应高度」开关,提示词区不再被高度上限卡住 / Add a fit-height switch so the prompt area is no longer height-capped
- 提示词多的时候英文区卡在写死的高度上限里只能内部滚动,把节点拉大也没用;侧边栏「标签管理」的明细表上方新增「适应高度」滑动开关
- 打开后提示词区跟着节点一起变高,打开时节点自动撑高一次,之后可以随意拖动,不会被自动拽回原来的大小
- 开关状态跟着节点保存,切到别的节点显示那个节点自己的状态,切回来不变
2026-10-26 05:37:36 +08:00
梦圣智杰 f2d3de3659 feat: 「优化」按模式分派,新增自然语言规则 / Optimize follows input mode with a new natural language rule
- 「优化为 Anima」改名「优化」:标签模式整理标签流,自然语言模式输出英文句子,中文也能直接用
- 助手设置新增一条「自然语言规则」,两个 AI 后端各一套
- 自然语言模式下隐藏「按 Anima 排序」,模式选择跟着节点保存
2026-10-26 04:27:40 +08:00
梦圣智杰 464a6c79dc fix: 拖大重复检查节点时状态胶囊不再被撑变形 / Keep the duplicate pill at its own size when the node is resized
- 手动拖大「重复检查」节点时,节点上「重复 N」的胶囊不再跟着变高变成一个巨大的圆角块,始终保持原本大小并垂直居中
2026-10-13 07:46:11 +08:00
梦圣智杰 e2b2834298 chore: 发布 v2.1.5 / Release v2.1.5 2026-10-13 03:10:54 +08:00
梦圣智杰 ae96399bdd feat: 可信度按词条整理程度分档 / Rate confidence by how complete an entry is
- 明细表的可信度徽章只显示「未判定 / 低 / 中 / 高」四档,去掉「待确认」这类后缀
- 已收录、已校验、且填了分类(不是「未分类」)才算高;已收录但没填分类,或还没标成已校验的,算中
- 机器翻译还没存进词库的算低;词库里查不到的仍是未判定
- 鼠标悬停在徽章上会说明为什么是这一档,填了分类的会把分类名带出来
- 「高」「中」补上中文译文,之前这两档一直显示的是英文
2026-10-13 02:20:02 +08:00
梦圣智杰 84bbc97cba feat: 明细表双击已收录标签即可编辑词条 / Double-click an indexed tag to edit its entry
- 明细表里已收录的标签,双击英文那格会打开「新增或编辑个人标签」窗口,和在词库里点「编辑」是同一个,中文、别名、分类、模型、权重、备注都能改
- 未收录、待确认的机器翻译,以及 BREAK、<lora:…> 这类特殊语法,双击仍是原来的定位到提示词
- 未收录行去掉「搜索候选」按钮:词库里本来就搜不出这个词,点了只会打开空结果
2026-10-13 02:02:26 +08:00
梦圣智杰 b09ce43898 fix: 自然语言片段双击也能开编辑弹窗 / Natural-language segments now open the editor too
- 双击自然语言片段不再只提示「只有标签支持单独权重」,改为和标签一样打开编辑弹窗
- 自然语言片段只提供「颜色」和「编辑」两页,权重与词条信息不再出现
- 自然语言片段可以像标签一样设置单独配色
2026-10-13 01:13:36 +08:00
梦圣智杰 5bb839964a fix: 未收录标签不再被存成残词条 / Stop saving the not-indexed placeholder as a tag name
- 双击未收录标签打开「词条信息」时,「中文名称」改为留空,自己填上中文再点应用才会存入个人词库
- 中文名称还是「未收录」时点应用不会保存,下方说明变红提示,2 秒后自动恢复
- 明细表里直接改中文时,不再把「未收录」占位带进输入框
2026-10-12 11:35:36 +08:00
梦圣智杰 1233c9f0c9 feat:添加测试启动文件 / Add test startup file 2026-10-12 07:45:32 +08:00
梦圣智杰 ca916f5b82 chore: 发布 v2.1.0 / Release v2.1.0 2026-10-12 07:09:39 +08:00
梦圣智杰 6565157f2a fix: 暗色下输入框与下拉框改用 ComfyUI 的配色 / Dark mode fields now use ComfyUI's own colors 2026-10-12 07:00:13 +08:00
梦圣智杰 737458163b feat: AI 分成 OpenAI 与 Ollama 两套 / AI split into OpenAI and Ollama configs
- 助手设置移到 ComfyUI「设置 → 双语提示词检查器 → 助手设置」,表单直接铺在设置面板右侧;侧边栏只保留标签管理、词库、词包、收藏四个页签。
- 外观与语言从助手设置里分出来,单独成为「语言与外观」一项,下拉改完立即保存并生效。
- OpenAI 兼容与 Ollama 各有独立的地址、模型、密钥、超时和三条翻译规则,切换后端互不覆盖,已填的密钥不会被清掉。
- API 地址只接受 https 或本机地址,Ollama 只能用本机地址,地址中不能带用户名密码或查询参数,远端不接受内网与保留地址。
2026-10-12 06:53:08 +08:00
梦圣智杰 d6c51f9de2 chore: 发布 v2.0.6 / Release v2.0.6 2026-10-10 23:05:14 +08:00
梦圣智杰 b8218f7e10 feat: 明细表可按关键词查找当前提示词 / Filter the detail table by keyword
- 明细表上方新增查找框,输入即筛选出当前节点里命中的标签,英文原文和中文解释都参与匹配
- 命中片段会高亮,右侧显示命中数量,按 Esc 或点 × 一键清空
- 查找与「全部 / 未收录 / 机器翻译 / 个人词库 / 收藏」叠加生效。
2026-10-10 23:00:01 +08:00
梦圣智杰 01123cef5c chore: 去掉跳词库的「管理面板」按钮 / Remove the redundant manager panel button
- 侧边栏「标签管理」里的「管理面板」按钮和顶部「词库」页签是一回事,去掉后工具栏只留「刷新词库」
- 画布上节点面板里的同一个按钮也一并去掉,两边共用同一份面板
2026-10-10 19:09:04 +08:00
梦圣智杰 a8b7aca8f8 feat: 词库可按自然语言词库来源筛选 / Filter the dictionary by the natural language source
- 来源下拉新增「自然语言词库」,只显示保存时选了该词库的条目;「个人」不受影响,仍包含它们
- 分类下拉里「自然语言」固定排在「全部」下面,其余分类仍按拼音排序
2026-10-10 18:33:18 +08:00
梦圣智杰 c72422e648 feat: 侧边栏节点列表显示画布上改过的节点名 / Show custom node titles in the sidebar node list
- 标签管理的节点下拉、以及保存收藏时选节点的弹窗,会显示在画布上双击改过的节点名,后面保留节点号以便区分同名节点
- 没改过名的节点仍显示节点号加提示词摘要
2026-10-10 18:17:19 +08:00
梦圣智杰 ff8395302a feat: Anima 排序可一键取消 / Cancel the Anima sort with one click
- 排序写入后按钮变成「取消 Anima 顺序排序」,点击把提示词恢复到排序前的样子,同时退出分类表视图。
- 提示词本来就是 Anima 顺序时,取消只退出分类视图,不动提示词内容。
- 排序后又手动改过提示词,取消入口会自动失效,避免回退把后来的改动一起吞掉。
- 排序与优化的预览弹窗里,「确认写入英文」改为「确认写入新提示词」。
2026-10-10 18:03:11 +08:00
梦圣智杰 cab56b2f60 feat: 词库搜索添加的标签插到选中标签之后 / Insert searched tags after the selected tag
- 在节点和侧边栏里搜索词库添加标签时,新标签会插在当前选中标签的后面,不再总是追加到提示词末尾;没有选中标签时才加到末尾。
- 连续添加多个标签会依次往后排,添加完新标签自动处于选中状态。
2026-10-10 17:43:17 +08:00
19 changed files with 1754 additions and 501 deletions
+96 -5
View File
@@ -1,5 +1,99 @@
# 更新记录
## v2.1.8(2026-10-26)
### 新增「适应高度」开关
- 提示词一多,英文标签区就卡在写死的 360 像素上限里,只能内部滚动,把节点拉大也没用。侧边栏「标签管理」的明细表上方(「颜色」按钮右边)多了一个「适应高度」滑动开关,跟着所选节点走——切到别的节点就显示那个节点自己的状态。
- 打开后:面板不再被固定上限锁住,多出来的高度全部给英文标签区,提示词能多显示很多行;开关打开时节点会自动撑高一次(按被压住的内容量撑),之后想拉多大多大。
- 关掉就回到原来的自动高度。开关状态跟着节点保存,重开工作流还在。
- 默认关闭,提示词少的时候和以前完全一样。
### 「优化」按模式分派,新增自然语言规则
- 节点上的「优化为 Anima」改名为「优化」:标签模式下和以前一样,整理英文标签流、只接受英文;模式切到「自然语言」后改用新的自然语言规则,输出完整英文句子,中文也可以直接优化(会先译成英文,状态栏会提示一句)。
- 助手设置里 OpenAI 兼容与 Ollama 各多一条「自然语言规则」,可以分别自定义,也能单独恢复默认。
- 模式切到「自然语言」时「按 Anima 顺序排序」自动隐藏——整段句子没有标签流可排;已经排过序时按钮会保留,方便点「取消排序」。
- 手动选的「自然语言」也算,不用非得自动检测出来;模式选择现在跟着节点保存,重开工作流不用重选。
## v2.1.5(2026-10-13)
### 可信度徽章改成按词条整理程度分档
- 明细表的可信度只显示「未判定 / 低 / 中 / 高」四档,去掉「待确认」这类后缀,看起来干净了。
- 判据:已收录、**已校验**、且填了分类(不是「未分类」)才算**高**;已收录但没填分类、或还没标成已校验的算**中**;机器翻译还没存进词库的算**低**;词库里查不到的仍是**未判定**。
- 鼠标停在徽章上会说明为什么是这一档,填了分类的会把分类名带出来。
- 「高」「中」两档补上中文译文——之前这两档一直显示的是英文。
### 双击已收录标签就能改词条
- 明细表里已收录的标签,双击英文那格会打开「新增或编辑个人标签」窗口,和在词库里点「编辑」是同一个:中文、别名、分类、模型、权重、备注都能改。
- 未收录、待确认的机器翻译,以及 `BREAK`、`<lora:…>` 这类特殊语法,双击仍是原来的定位到提示词。
- 未收录行去掉「搜索候选」按钮——词库里本来就搜不出这个词,点了只会打开空结果。
### 自然语言片段双击也开编辑弹窗
- 双击自然语言片段不再只提示「只有标签支持单独权重」,改成和标签一样打开编辑弹窗,只是只有「颜色」和「编辑」两页(权重和词条信息对整段文本没有意义)。
- 自然语言片段可以像标签一样设置单独配色。
## v2.1.0(2026-10-12)
### 助手设置搬进 ComfyUI 设置面板
- 侧边栏只保留「标签管理 / 词库 / 词包 / 收藏」四个页签,助手设置改到 ComfyUI 顶部「设置 → 双语提示词检查器 → 助手设置」,打开设置面板就能直接改,不用再进侧边栏找。
- 「外观」和「语言」从助手设置里分出来,单独成为「语言与外观」一项,排在助手设置上面;下拉改完立即保存并生效,不用再点保存按钮。
- 「清除已保存的 API 密钥」「清除已保存的百度密钥」从复选框改成滑动开关,打开后旁边显示「保存后生效」,再关掉可以取消。
### OpenAI 兼容与 Ollama 在翻译服务里直接可选
- 翻译服务下拉改成四个平级选项:纯词库 / 百度翻译 / OpenAI 兼容接口 / Ollama,不再需要先选「AI」再在下面选后端。
- OpenAI 兼容和 Ollama 各有自己的地址、模型、密钥、温度、超时和三条翻译规则,来回切换不会互相覆盖,切换后原来填的密钥也还在。
- Ollama 一栏没有密钥输入框。
- Ollama 区块新增「使用 Ollama 本地预设」按钮,一键填入本机默认地址;OpenAI 兼容区块的「使用 LM Studio 本地预设」按钮不变。
- 选纯词库或百度翻译时,会多出一个「AI 后端」下拉,决定「翻译并优化」和「优化为 Anima」用哪一套。
- 升级后旧配置会按当前选择的后端搬进对应的一套,另一套保留默认值。
### API 地址限制
- OpenAI 兼容地址只接受 https,或本机地址(`127.0.0.1` / `localhost` / `::1`),方便 LM Studio 这类本地服务;http 只在填本机地址时允许。
- Ollama 地址只接受本机地址。
- 地址里不能带用户名和密码,也不能带 `?` 之后的参数。
- 远端地址不允许是内网、保留或链路本地地址(例如 `192.168.x.x`、`10.x.x.x`、`169.254.169.254`)。
### 百度翻译不再显示翻译规则
- 翻译规则只有 AI 会用上,百度翻译和纯词库下面不再出现规则输入框。
### 暗色模式下的表单配色
- 输入框和下拉框改用 ComfyUI 自己的配色变量,不再另外写死一套,暗色主题下和设置面板里其它控件保持一致。
## v2.0.6(2026-10-10)
### 明细表可以按关键词查找
- 明细表上方新增查找框,输入即筛选出当前节点里命中的标签,英文原文和中文解释都参与匹配。
- 命中的片段会高亮,右侧显示命中数量,按 `Esc` 或点 × 一键清空。
- 查找与「全部 / 未收录 / 机器翻译 / 个人词库 / 收藏」**叠加生效**,可以先筛来源再筛关键词。
### 添加标签会插到选中标签之后
- 在节点里、以及侧边栏搜索词库添加标签时,新标签插在当前选中标签的后面,不再一律追加到提示词末尾;没选中标签时才加到末尾。
- 连续添加多个标签会依次往后排,添加完新标签自动处于选中状态。
### Anima 排序可以一键取消
- 排序写入后按钮变成「取消 Anima 顺序排序」,点击把提示词恢复到排序前的样子,同时退出分类表视图。
- 提示词本来就是 Anima 顺序时,取消只退出分类视图,不动提示词内容。
- 排序后又手动改过提示词的,取消入口会自动失效,避免回退把后来的改动一起吞掉。
- 排序与优化的预览弹窗里,「确认写入英文」改成「确认写入新提示词」。
### 侧边栏与词库
- 词库来源下拉新增「自然语言词库」,只显示保存时选了该词库的条目;分类下拉里「自然语言」固定排在「全部」下面,其余仍按拼音排序。
- 标签管理的节点下拉、以及保存收藏时选节点的弹窗,会显示在画布上双击改过的节点名(后面保留节点号,方便区分同名节点)。
- 去掉侧边栏「标签管理」里的「管理面板」按钮——它和顶部「词库」页签是同一回事,工具栏只留「刷新词库」。
## v2.0.0(2026-10-10)
### 新增「重复检查」节点
@@ -67,6 +161,7 @@
- **修复插件前端扩展被加载两遍**:`pyproject.toml` 的 `[tool.comfy] web = "js"` 与 `__init__.py` 的 `WEB_DIRECTORY = "./js"` 会被 ComfyUI 分别登记(key 一个是 `project.name`、一个是模块名),`/extensions` 接口因此把同一批 js 列两次,前端各加载一次并报 `Extension named ... already registered.`。已移除 `web` 字段,前端资源统一由 `WEB_DIRECTORY` 提供。
- 上游连接判定改用 `node.isInputConnected(slot)`(老版本无此方法时退回读 `input.link`),消除新版前端里 `input.link is deprecated` 的控制台刷屏。
## v1.2.0(2026-09-23)
- 翻译服务与 AI 后端解耦(schema v3):翻译/解释可选纯词库 / 百度翻译 / AI;"翻译并优化"与"优化为 Anima"恒走 AI。AI 与百度密钥独立留存,切换翻译服务互不清空——只有 AI 后端/地址变更或换机器时才清 AI Key,百度密钥只在显式清除时才清。
@@ -75,7 +170,6 @@
- 修复 `clear_ai_api_key` / `clear_baidu_secret_key` 在 payload 未同时包含对应密钥字段时不生效的嵌套条件 bug。
- 修复节点高度棘轮上涨:ComfyUI `text` 控件的 `computedHeight` 是单调累加器,每次写值都会让节点变大一点。改用原生 `text` 控件的 `scrollHeight@auto` 作为稳定基线,不再读 `widget.y`,节点高度在多次写值后锁定不变。
- `inspectorNodeTargetHeight` 签名简化为 `{baseHeight, targetWidgetHeight, fallbackBaseHeight, minimumHeight}`,移除旧 `widgetY`/`nodeHeight`/`currentWidgetHeight` 等参数。
- 新增标签拖拽排序:英文标签区可按住标签直接拖动,明细表每行左侧提供 `⠿` 手柄(含插入指示线与边缘自动滚动),选中标签后支持 `Alt+↑` / `Alt+↓` 键盘微调;三处入口共用同一套 `Ctrl+Z` 撤销历史。
- 排序按“提取—插入”建模:保留原有逗号、空格与换行排版,多行结构不被合并;自然语言块整体移动,内部不拆分;重复标签按出现位置独立移动。
- 明细表筛选视图(未知/机器译/个人词库/收藏)与自然语言、指令模式下不启用拖拽,避免局部顺序被误解为完整顺序。
@@ -96,16 +190,13 @@
- 收藏数据存在 ComfyUI 用户目录 `user/bilingual-prompt-inspector/saved_prompts/`(`index.json` + 独立图片文件),不写入插件目录,重装插件不丢数据;单图上限 5 MB,最多 2000 条。
- 收藏不记录工作流信息;「载入节点」先弹窗确认再覆盖节点文本,不进 `Ctrl+Z` 撤销栈。侧边栏「新建」在没有选中节点时提供节点选择器兜底。
- 新增 `tests/test_saved_prompt_store.py`:覆盖图片魔数校验、超限拒绝、删除连带删图、导出导入往返(保留原顺序)、重复与非法条目跳过、非法时间戳回退等 14 个用例。
- 新增外观模式配置:助手设置里可切换「跟随 ComfyUI / 暗色 / 亮色」,默认跟随——ComfyUI 用什么主题插件就用什么主题。此前插件把配色硬编码成暗色值,在 ComfyUI 亮色模式下文字与背景几乎无法分辨。
- 实现上新增一组 `--bpi-*` CSS 变量(面板、文字、边框、按钮、输入框、表格、弹窗),按 `:root`(亮色默认)、`.dark-theme`(跟随 ComfyUI 暗色)、`[data-bpi-theme]`(强制暗/亮)四套定义;配置存在 `assistant_store` 的 `appearance` 字段,切换后即时生效无需刷新。
- 节点中文同步编辑区的「标签管理」「助手设置」入口已移除(与侧边栏管理面板重复),代码里保留注释说明如何恢复;助手设置里的翻译服务说明改为整行显示,不再挤在表单左列。
- 用户界面英文化:为符合 ComfyUI-Manager 注册要求,节点显示名改为 `Prompt Translator & Manager`,`@title` / `@description` / `RETURN_NAMES` / `CATEGORY` 与约 540 处 JS 界面文字(按钮、提示、状态消息、输入框占位符、语法诊断)全部改为英文。
- 中文通过 locale 保留:新增 `locales/zh/main.json`(`nodeDefs` 结构),在 ComfyUI 里把界面语言切到中文后,节点名、描述与输入输出名仍显示中文「提示词翻译与管理(英文输出)」——由 ComfyUI 的 `build_translations()` 与前端 `mergeCustomNodesI18n()` 自动合并,无需额外配置。
- 英文化时有四类内容刻意保留中文:代码注释;匹配用户中文输入的正则模式(如「翻译要求/正文」结构化指令识别);词库中文分类名匹配表(改掉会导致分类排序失效);以及「未收录」「自然语言片段(待翻译或确认)」等在中文列显示的翻译数据。
- 更新 5 个测试文件的断言以匹配新的英文字符串(`test_anima_sorter.mjs`、`test_clear_controls.mjs`、`test_dictionary_tools.mjs`、`test_parser.mjs`、`test_project_link.mjs`)。
- 新增界面语言设置:助手设置页新增「Language」下拉,可选「跟随 ComfyUI 语言设置」(默认)/「中文」/「英文」,随其它设置一起存在 `assistant_store` 的 `language` 字段(auto / zh / en),并在 `localStorage` 留一份供首屏同步取值。
- ComfyUI 自带的 locale 只翻译 `nodeDefs`(节点名、描述、输入输出名),面板里的按钮、状态栏、tooltip 管不到,因此新增插件自建的 i18n 层 `js/i18n.js`:源码继续保留英文原文(上架要求),运行时按原文查中文表,查不到就原样显示英文,不会出现半中半英或空文案。
- 文案统一走 `t()`:`element()` / `button()` / `field()` 三个 DOM 工厂与全部 `title` / `placeholder` / `textContent` 赋值点都接入翻译(共 200+ 条文案)。带变量的文案(如 `deleted “xxx”; press Ctrl+Z to undo`)把「“…”」片段和数字折叠成 `{}` 占位符再回填,模板字符串也能整句翻译。
@@ -115,7 +206,7 @@
- 补齐第二批翻译(约 200 条):词包卡片信息(个人词库/大词库/社区词包的条数、版本、来源、授权)、标签管理的统计行「共 N 个标签|已识别|未收录|待确认」、模式提示、各类操作回执(移动/隐藏/恢复/翻译/导入)、语法诊断全部文案(括号、权重、重复标签、冲突组、输入模式判定理由)、Anima 分类名、以及所有请求失败提示。
- 变量的递归翻译:嵌在文案里的变量本身也可能是界面词(如冲突组名 `Solo vs multiple subjects`、模式名 `Tag`),回填时会再翻译一层,避免出现「中文句子里夹一个英文词」。
- 为让变量能被稳定折叠,`parser.js` 的诊断模板与词包信息模板给动态片段加上「“...”」包裹(版本号 `1.0.0`、来源、授权等),否则带点号的版本会被拆成多个变量而匹配不到译表。
- 统计行改为分段翻译再拼接:词库页的 `Showing … | Selected … | Enabled packs … | Built-in … | Large … | Personal … | Pending …` 与节点里的 `Common dictionary … items | Large …` 原来是整句做键,大词库有「未安装 / 按需(N 条)/ 已停用」三种形态,整句键必然漏掉其中几种。现在每段各自翻译后再用 ` | ` 拼起来,新增形态不会再漏。
- 统计行改为分段翻译再拼接:词库页的 `Showing … | Selected … | Enabled packs … | Built-in … | Large … | Personal … | Pending …` 与节点里的 `Common dictionary … items | Large …` 原来是整句做键,大词库有「未安装 / 按需(N 条)/ 已停用」三种形态,整句键必然漏掉其中几种。现在每段各自翻译后再用 `|` 拼起来,新增形态不会再漏。
- 修掉一类「译表里有、界面上却还是英文」的隐蔽 bug:模板里的自由文本变量(标签名、词包名、错误信息、文件名、操作名)没有用「“...”」包裹时不会被折叠成 `{}`,整句变成带具体内容的字面量,永远匹配不到译表。已统一给 27 处这类变量加上包裹,并把 4 处误用 ASCII 直引号的地方(`"Re-translated \"...\""` 等)换成全角引号;译表里 6 条把直引号烤进键名的条目(`"Processed \"{}\""` 等)同步改成纯 `{}`。
- 补齐第三批翻译(22 条):置信度徽章(`Low · pending` / `Medium · pending` / `Medium · session-only`)、收藏星标按钮(★/☆)、`⚠ Not indexed:` 标签提示、Anima 格式说明、`Node #N · N tags` 收藏备注、`Running …` / `Translating unknown tags N/M` / `Translation complete …` 三条运行状态、`Querying dictionary…` / `Searching for more…` / `Paused, waiting for confirmation…`、权重越界诊断、以及词包导入预览与删除确认。
- 两处组合句改分段翻译:词包导入预览 `Tags N | Sample: …`(示例内容是数据,不该参与翻译)与标签管理节点标题 `Node #N: …`(提示词原文不翻译)。
+30 -13
View File
@@ -1,4 +1,4 @@
# ComfyUI 双语提示词管理器 v2.0.0
# ComfyUI 双语提示词管理器 v2.1.8
> ComfyUI 双语提示词节点:把英文提示词拆成可点击的标签,边写边看中文解释、调顺序、试效果。
@@ -73,8 +73,10 @@
| 能力 | 说明 |
|---|:-:|
| **四种处理方式** | 仅翻译 / 翻译并优化 / 优化为 Anima / 按 Anima 官方顺序整理(不新增标签,只重排分段) |
| **自动识别模式** | 自动区分纯标签、纯自然语言、混合文本和翻译指令,不用手选模式 |
| **四种处理方式** | 仅翻译 / 翻译并优化 / 优化 / 按 Anima 官方顺序整理(不新增标签,只重排分段) |
| **优化按模式分派** | 标签模式下「优化」整理现有英文标签流(只吃英文);自然语言模式下改写为英文自然语句,中文会先译成英文。两种模式各用一套规则,可在助手设置里分别自定义 |
| **自动识别模式** | 自动区分纯标签、纯自然语言、混合文本和翻译指令,也可以在明细表上方手动指定;指定的模式跟着节点保存 |
| **自然语言模式** | 模式切到「自然语言」后,「按 Anima 顺序排序」自动隐藏——整段句子没有标签流可排 |
| **提示词诊断** | 自动标出可疑权重、重复标签、语法问题 |
| **快捷添加** | 底部的快捷输入框:写中文自动翻译成英文标签后追加,写英文标签直接追加;`Enter` 添加、`Shift+Enter` 换行、`Ctrl+Enter` 翻译并优化 |
@@ -85,7 +87,7 @@
| **多来源词库** | 内置词库 + 个人词库 + 社区词库包,每条解释都会标注来源 |
| **概念搜索** | 搜「丝袜」能返回连裤袜、长筒袜、过膝袜等相关标签,支持滚动分页加载 |
| **大型 Danbooru 词库(可选)** | 按需安装,覆盖面更广,不装也不影响其它功能 |
| **翻译服务可切换** | 纯词库(离线、免费)/ 百度翻译 / AI(OpenAI 兼容 API 或 Ollama)。翻译与解释可选任意一种;「翻译并优化」「优化为 Anima」恒定走 AI |
| **翻译服务可切换** | 纯词库(离线、免费)/ 百度翻译 / OpenAI 兼容 API / Ollama。翻译与解释可选任意一种;「翻译并优化」「优化」恒定走 AI |
| **密钥互不干扰** | AI 与百度密钥独立留存,切换翻译服务不会清空;均不写入工作流、不返回浏览器 |
### 上游提示词接管
@@ -122,7 +124,9 @@
### 侧边栏管理面板
标签管理、词库维护、词库包、助手设置、收藏五个分页都收在侧边栏的「双语提示词管理」面板里,节点本身保持简洁。面板里的改动会实时同步到页面上所有同类节点,不需要刷新页面。
标签管理、词库维护、词库包、收藏四个分页收在侧边栏的「双语提示词管理」面板里,节点本身保持简洁。面板里的改动会实时同步到页面上所有同类节点,不需要刷新页面。
助手设置不在这个面板里:它在 ComfyUI 的「设置」面板中,「设置 → 双语提示词检查器」下有两项 ——「语言与外观」和「助手设置」,表单都直接铺在设置面板右侧。
---
@@ -132,7 +136,7 @@
节点上可以大致分成四块:
1. **英文原文(实际输出)**——节点真正输出给下游的文本。上面有「编辑文本 / 完成」「清空」「插入换行」「收藏」「优化为 Anima」「按 Anima 顺序排序」几个按钮。
1. **英文原文(实际输出)**——节点真正输出给下游的文本。上面有「编辑文本 / 完成」「清空」「插入换行」「收藏」「优化」「按 Anima 顺序排序」几个按钮。
2. **中文对照区**——英文标签逐条对应的中文,只用于阅读,不进入输出,也不会传给下游。
3. **明细表**——每行是一个标签:来源、翻译、权重、分类,右侧按行状态给出操作(翻译 / 确认并存 / 重新翻译 / 编辑中文 / 收藏 / 复制 / 删除个人词条)。上方有搜索框、模式选择、颜色模式、筛选(全部 / 未知 / 机器翻译 / 个人词库 / 收藏)和问题提示。
4. **快捷添加**——底部输入框,中文进英文出(自动翻译后追加),英文则直接追加。
@@ -141,15 +145,24 @@
## 安装
**用 ComfyUI-Manager(推荐)**:在 Manager 的节点列表里搜索 `Bilingual Prompt Inspector` 安装;也可以走 Manager 的「Install via Git URL」,填入下面的仓库地址。
**用 ComfyUI-Manager(推荐)**:在 Manager 的节点列表里搜索 `Bilingual Prompt Inspector` 安装;也可以走 Manager 的「Install via Git URL」,填入仓库的仓库地址。
**使用comfy-cli安装**:
```bash
cd ComfyUI/
comfy node install prompt-translator-manager
```
**手动安装**:
```bash
cd ComfyUI/custom_nodes/
git clone https://github.com/mengshengzhijie/ComfyUI-Bilingual-Prompt-Inspector.git
git clone --depth 1 https://github.com/mengshengzhijie/ComfyUI-Bilingual-Prompt-Inspector.git
```
不过这里如果有动手能力的话推荐使用git安转,因为现在就我和我的朋友在使用,有时候修复bug和制作新功能我是总结下来然后一步一步的修复。直到把这次收录的bug和建议给完成之后才bump版本。
重启 ComfyUI,在节点菜单的 **text/prompt** 分类里找到 **提示词翻译与管理(英文输出)**(英文界面下显示为 Prompt Translator & Manager),也可以直接搜索「提示词」。
两个常见坑:
@@ -176,13 +189,17 @@ git clone https://github.com/mengshengzhijie/ComfyUI-Bilingual-Prompt-Inspector.
### 翻译服务配置
默认走纯词库,不联网也能用。需要机器翻译或 AI 优化时,打开侧边栏「双语提示词管理」面板的「助手设置」分页。面板分三段:
默认走纯词库,不联网也能用。需要机器翻译或 AI 优化时,打开「设置 → 双语提示词检查器 → 助手设置」。这一项分三段:
| 分段 | 内容 |
|---|---|
| **翻译服务** | 翻译 / 解释按此选择:纯词库(离线、免费)/ 百度翻译(需自行申请开放平台的 APP ID 与密钥)/ AI(走下面的 AI 配置) |
| **翻译服务** | 翻译 / 解释按此选择:纯词库(离线、免费)/ 百度翻译(需自行申请开放平台的 APP ID 与密钥)/ OpenAI 兼容接口 / Ollama(本地)。选后面两个时,它们本身就是下面的 AI 服务 |
| **百度翻译** | 独立配置 APP ID 与密钥,切换翻译服务不会清空 |
| **AI 服务** | 后端(OpenAI 兼容 / Ollama)、API 地址与模型、可选 API Key、温度、超时。「翻译并优化」「优化为 Anima」恒定走这里 |
| **AI 服务** | 「翻译并优化」「优化」恒定走这里。翻译服务选了 OpenAI 兼容 / Ollama 时,下面直接就是那个后端的配置;选纯词库 / 百度翻译时,这里多出一个「AI 后端」下拉,决定优化用哪一套。两个后端各存一套配置,切换不清空: |
| ↳ OpenAI 兼容 | API 地址、模型、可选 API Key、温度、超时,另有「使用 LM Studio 本地预设」一键填入 `1234/v1`。远端地址必须 https,本机回环(127.0.0.1 / localhost)才允许 http;内网、保留、链路本地地址一律拒绝 |
| ↳ Ollama | API 地址、模型、温度、超时,没有密钥,另有「使用 Ollama 本地预设」一键填入 `11434`。地址只能是本机回环地址,其它一律拒绝 |
两个后端还各有一套规则(翻译 / 翻译并优化 / 优化 / 自然语言):本地小模型与云端大模型要写的提示词往往不一样,分开存可以随时切换后端而不用重改规则。其中「自然语言」那套只在自然语言模式下被「优化」使用,结果按完整英文句子保留标点,不套 Anima 的标点限制。地址不合规时会在发出请求前直接报错。
### 大型词库(可选)
@@ -190,7 +207,7 @@ git clone https://github.com/mengshengzhijie/ComfyUI-Bilingual-Prompt-Inspector.
### 界面语言与外观
界面文案跟随 ComfyUI 的语言设置,也可以在侧边栏面板的「助手设置」分页里固定为中文或英文。外观同样可在「跟随 ComfyUI / 暗色 / 亮色」之间切换。
界面文案跟随 ComfyUI 的语言设置,也可以在「设置 → 双语提示词检查器 → 语言与外观」里固定为中文或英文。外观同样可在「跟随 ComfyUI / 暗色 / 亮色」之间切换,两项改完立即生效并单独保存。
---
@@ -204,7 +221,7 @@ git clone https://github.com/mengshengzhijie/ComfyUI-Bilingual-Prompt-Inspector.
<details>
<summary><b>点翻译提示要先配置翻译服务</b></summary>
纯词库模式下只能查词库,不会翻译。想翻译请在侧边栏面板的「助手设置」分页里选一个翻译服务(百度或 AI)。
纯词库模式下只能查词库,不会翻译。想翻译请在「设置 → 双语提示词检查器 → 助手设置」里选一个翻译服务(百度翻译、OpenAI 兼容接口或 Ollama)。
</details>
<details>
+21
View File
@@ -31,3 +31,24 @@ unauthenticated ComfyUI instance directly to the public internet.
External translation and optimization requests are sent only to the provider configured
by the user. Review that provider's privacy policy, logging behavior, content policy,
and cost before use.
## Outbound request targets
AI requests are sent only to the address stored in the local configuration file, and that
address is validated before a request is sent:
- OpenAI-compatible endpoints must use HTTPS. Plain HTTP is accepted only for loopback
addresses on the same machine (127.0.0.0/8, ::1, localhost), so a local runtime such as
LM Studio keeps working without sending the API key in clear text across the network.
- Remote OpenAI-compatible endpoints must be public. An address written directly as a
private, reserved or link-local IP (192.168.x.x, 10.x.x.x, 172.16.x.x, 169.254.169.254,
...) is rejected even over HTTPS. Domain names are accepted; they are not resolved during
validation.
- Ollama endpoints must be a loopback address on the same machine.
Credentials embedded in the URL (`user:pass@`) and query strings are rejected as well, and
only the `http` and `https` schemes are allowed. The address is re-validated immediately
before each request, so editing the configuration file by hand cannot bypass these rules.
The large Danbooru dictionary is a local read-only SQLite file under the extension's data
directory. Despite the `connect(` call, it opens a file on disk and opens no socket.
+235 -94
View File
@@ -46,17 +46,30 @@ DEFAULT_OPTIMIZATION_RULE = """你是 Anima 模型英文提示词优化器。输
对于明确为成年人的合法成人内容,准确保留并规范相关成人标签,不弱化、不含糊替换,也不主动增加原文没有的成人内容;不得将未成年人或年龄不明确的人物成人化。
标签使用英文逗号分隔;自然语言只放在末尾并使用英文句点分隔。仅使用 Anima 支持的英文标点和语法:逗号、句点、圆括号与冒号权重、@ 画师前缀、下划线、连字符、撇号、百分号,以及尖括号 LoRA 语法。"""
DEFAULT_NATURAL_RULE = """你是面向现代生图模型(Krea、FLUX、Qwen-Image 等)的英文自然语言提示词助手。把用户输入整理成一段通顺、具体、可直接使用的英文描述;只返回最终结果,不输出 Markdown、标题、引号、解释、警告或免责声明。
输入是中文、英文或中英混合都可以:含中文时先忠实译为英文再整理表达,纯英文时只做表达优化,不改变原意。输入是逗号分隔的标签时,先理解这些标签的含义,再改写成一段连贯的英文自然语言描述。
保留主体、动作与状态、外貌与服装、环境与背景、光线、镜头与构图、风格与画材、氛围与情绪;原文没有给出的要素不要凭空添加,也不要删掉原文已经写明的限制。
严格保留人物数量、成年人身份、角色名、作品名、否定关系、左右位置和安全等级,不得擅自换人、增加人物或改变主题。
对于明确为成年人的合法成人内容,准确保留相关描述,不弱化、不含糊替换,也不主动增加原文没有的成人内容;不得将未成年人或年龄不明确的人物成人化。
用完整的英文句子输出,按语法正常使用逗号、句点和连接词;不要写成逗号分隔的标签列表,也不要使用括号权重、@ 画师前缀或尖括号语法。"""
DEFAULT_CONFIG = {
"schema_version": 3,
# 翻译/解释走哪个服务;优化永远走 AI,与此无关
"translate_service": "dictionary", # dictionary | baidu | ai
# AI 后端独立配置,不随翻译服务切换而清空
"schema_version": 4,
# 翻译/解释走哪个服务;OpenAI 兼容与 Ollama 本身就是两个平级选项
"translate_service": "dictionary", # dictionary | baidu | openai_compatible | ollama
# 选词库/百度时翻译不走 AI,但「翻译并优化」「优化」恒走 AI —— 这时用它决定读哪一套
"ai_provider": "openai_compatible", # openai_compatible | ollama
"ai_base_url": "",
"ai_model": "",
"ai_api_key": "",
"ai_temperature": 0.2,
"ai_timeout_seconds": 90,
# OpenAI 兼容:远端必须 https,本机回环 http 也放行(本地 LM Studio 之类)
"openai_base_url": "",
"openai_model": "",
"openai_api_key": "",
"openai_temperature": 0.2,
"openai_timeout_seconds": 90,
# Ollama:只允许本机回环地址,没有密钥
"ollama_base_url": "http://127.0.0.1:11434",
"ollama_model": "",
"ollama_temperature": 0.2,
"ollama_timeout_seconds": 90,
# 百度翻译独立配置,不随翻译服务切换而清空
"baidu_appid": "",
"baidu_secret_key": "",
@@ -66,11 +79,24 @@ DEFAULT_CONFIG = {
"appearance": "auto", # auto | dark | light
# 插件面板自己的界面语言:auto 跟随 ComfyUI 的 Comfy.Locale,zh / en 强制指定
"language": "auto", # auto | zh | en
"translation_rule": DEFAULT_TRANSLATION_RULE,
"translate_optimize_rule": DEFAULT_TRANSLATE_OPTIMIZE_RULE,
"optimization_rule": DEFAULT_OPTIMIZATION_RULE,
# 每个后端一套规则:本地小模型和云端大模型要写的提示词不一样
"openai_translation_rule": DEFAULT_TRANSLATION_RULE,
"openai_translate_optimize_rule": DEFAULT_TRANSLATE_OPTIMIZE_RULE,
"openai_optimization_rule": DEFAULT_OPTIMIZATION_RULE,
"openai_natural_rule": DEFAULT_NATURAL_RULE,
"ollama_translation_rule": DEFAULT_TRANSLATION_RULE,
"ollama_translate_optimize_rule": DEFAULT_TRANSLATE_OPTIMIZE_RULE,
"ollama_optimization_rule": DEFAULT_OPTIMIZATION_RULE,
"ollama_natural_rule": DEFAULT_NATURAL_RULE,
}
# 规则按后端分成两套,遍历时统一用这两个前缀
AI_PROVIDER_PREFIXES = {"openai_compatible": "openai", "ollama": "ollama"}
# translate_service 里属于 AI 的两个值,与 ai_provider 共用同一套前缀
AI_SERVICES = frozenset(AI_PROVIDER_PREFIXES)
TRANSLATE_SERVICES = frozenset({"dictionary", "baidu"}) | AI_SERVICES
RULE_KEYS = ("translation_rule", "translate_optimize_rule", "optimization_rule", "natural_rule")
BAIDU_TRANSLATE_ENDPOINT = "https://fanyi-api.baidu.com/api/trans/vip/translate"
# 百度「通用文本翻译」按账号档位给 QPS:标准版 1 QPS,个人认证 10 QPS,企业认证更高。
@@ -78,6 +104,8 @@ BAIDU_TRANSLATE_ENDPOINT = "https://fanyi-api.baidu.com/api/trans/vip/translate"
BAIDU_QPS_DEFAULT = 1.0
BAIDU_QPS_MIN = 0.1
BAIDU_QPS_MAX = 50.0
# 百度单块最多 600 字,90 秒与原默认值一致(AI 拆成两套后不再借用它的超时)
BAIDU_TIMEOUT_SECONDS = 90
def normalize_baidu_qps(value):
@@ -110,6 +138,59 @@ def translation_direction(text):
return "to_english" if re.search(r"[\u3400-\u9fff]", str(text or "")) else "to_chinese"
def is_loopback_host(hostname):
"""只允许真正的回环地址:127.0.0.0/8、::1、localhost。
比「私有网段」窄得多 —— 私有网段里还有路由器、NAS、同事的机器,
SSRF 正是靠它们横向探测,所以这里一律不放行。
"""
host = str(hostname or "").strip().lower().rstrip(".")
if host in {"localhost", "127.0.0.1", "::1"}:
return True
try:
return ip_address(host).is_loopback
except ValueError:
return False
def is_public_address(hostname):
"""主机名直接写成 IP 时,只放行公网地址;域名不做解析,一律放行。
挡的是 https://192.168.x.x、https://10.x.x.x、https://169.254.169.254(云元数据)这类
「虽然是 https、但仍然指向内网」的直连写法。
"""
try:
return ip_address(hostname).is_global
except ValueError:
return True
def normalize_ai_base_url(value, *, allow_remote=False):
"""校验 AI 地址,返回去掉尾斜杠的原文;不合规直接抛错,不发请求。
allow_remote=False(Ollama):只能是本机回环地址。
allow_remote=True(OpenAI 兼容):https 一律放行,http 只放行本机回环。
"""
base = str(value or "").strip().rstrip("/")
if not base:
return ""
parsed = urlparse(base)
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
raise ValueError("API 地址必须是完整的 http:// 或 https:// 地址")
if parsed.username or parsed.password:
raise ValueError("API 地址中不能包含用户名或密码")
if parsed.query or parsed.fragment:
raise ValueError("API 地址中不能包含查询参数或片段")
loopback = is_loopback_host(parsed.hostname)
if not allow_remote and not loopback:
raise ValueError("Ollama 地址只能是本机地址(127.0.0.1 / localhost / ::1)")
if parsed.scheme == "http" and not loopback:
raise ValueError("非本机 API 地址必须使用 HTTPS,避免密钥明文传输")
if allow_remote and not loopback and not is_public_address(parsed.hostname):
raise ValueError("远端 API 地址不能是内网、保留或链路本地地址")
return base
def openai_chat_endpoint(base_url):
"""Return a chat-completions endpoint for an OpenAI-compatible base URL."""
base = str(base_url or "").strip().rstrip("/")
@@ -217,6 +298,35 @@ def sanitize_anima_prompt(value):
return text
def sanitize_natural_prompt(value):
"""自然语言优化结果的清洗。
与 sanitize_anima_prompt 的区别:这里要保留完整句子,所以既不把句点改成逗号,
也不做 Anima 标点白名单收窄——只挡代码块、转义符、列表符号和残留中文。
"""
text = str(value or "").strip()
fenced = re.fullmatch(r"```(?:text|plaintext)?\s*([\s\S]*?)\s*```", text, re.IGNORECASE)
text = fenced.group(1).strip() if fenced else text
replacements = {
",": ", ", "、": ", ", ";": "; ", "。": ". ",
"!": "! ", "?": "? ", ":": ": ",
"(": "(", ")": ")", "<": "<", ">": ">", "—": " - ", "–": " - ",
"“": '"', "”": '"', """: '"', "‘": "'", "’": "'", "'": "'", "`": "",
}
for source, target in replacements.items():
text = text.replace(source, target)
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+", " ", text).strip()
if re.search(r"[\u3400-\u9fff]", text):
raise ValueError("助手返回的自然语言提示词仍包含中文,请调整规则后重试")
if not text:
raise ValueError("助手返回的自然语言提示词为空")
return text
def _default_config_dir():
try:
import folder_paths
@@ -283,17 +393,6 @@ class AssistantStore:
def _normalize_base_url(value):
return str(value or "").strip().rstrip("/")
@staticmethod
def _is_local_hostname(hostname):
host = str(hostname or "").strip().lower().rstrip(".")
if host in {"localhost", "host.docker.internal"} or host.endswith(".local") or "." not in host:
return True
try:
address = ip_address(host)
return address.is_private or address.is_loopback or address.is_link_local
except ValueError:
return False
@classmethod
def _credential_binding(cls, provider, base_url):
identity = f"{str(provider or '').strip().lower()}\0{cls._normalize_base_url(base_url)}"
@@ -303,7 +402,7 @@ class AssistantStore:
return {
**DEFAULT_CONFIG,
"installation_id": self.installation_id,
"ai_credential_binding": "",
"openai_credential_binding": "",
"baidu_credential_binding": "",
}
@@ -334,6 +433,40 @@ class AssistantStore:
value["schema_version"] = 3
return value
@classmethod
def _migrate_v3(cls, value):
"""v3(AI 单槽)→ v4(OpenAI / Ollama 两套独立配置)。
旧的单槽值整份搬到「当前所选后端」那套,另一套留默认;
规则原本不分后端,两套都继承,之后可以各自改。
"""
if value.get("schema_version", 0) >= 4:
return value
provider = str(value.get("ai_provider", "openai_compatible")).strip().lower()
prefix = AI_PROVIDER_PREFIXES.get(provider, "openai")
for source, target in (
("ai_base_url", "base_url"),
("ai_model", "model"),
("ai_temperature", "temperature"),
("ai_timeout_seconds", "timeout_seconds"),
):
if str(value.get(source) or "").strip() != "":
value[f"{prefix}_{target}"] = value[source]
if prefix == "openai" and str(value.get("ai_api_key") or "").strip():
value["openai_api_key"] = value["ai_api_key"]
# 绑定的 provider 标记从 openai_compatible 变成 openai,旧值必然对不上;
# 直接按新前缀重算,否则密钥会被当成「换了地址」清掉
value["openai_credential_binding"] = cls._credential_binding("openai", value.get("openai_base_url", ""))
for rule in RULE_KEYS:
existing = str(value.get(rule) or "").strip()
if existing:
for target in ("openai", "ollama"):
value.setdefault(f"{target}_{rule}", existing)
for key in ("ai_base_url", "ai_model", "ai_temperature", "ai_timeout_seconds", "ai_api_key", "ai_credential_binding"):
value.pop(key, None)
value["schema_version"] = 4
return value
def config(self):
value = {}
should_rewrite = False
@@ -350,20 +483,27 @@ class AssistantStore:
if value and value.get("schema_version", 0) < 3:
value = self._migrate_v2(value)
should_rewrite = True
if value.get("translation_rule") in {LEGACY_DEFAULT_TRANSLATION_RULE, V17_DEFAULT_TRANSLATION_RULE}:
value["translation_rule"] = DEFAULT_TRANSLATION_RULE
if value and value.get("schema_version", 0) < 4:
value = self._migrate_v3(value)
should_rewrite = True
if value.get("optimization_rule") == LEGACY_DEFAULT_OPTIMIZATION_RULE:
value["optimization_rule"] = DEFAULT_OPTIMIZATION_RULE
should_rewrite = True
if "translate_optimize_rule" not in value:
value["translate_optimize_rule"] = DEFAULT_TRANSLATE_OPTIMIZE_RULE
# 旧值 "ai" 已拆成两个平级服务:按当时的后端落到 openai_compatible / ollama
if value and value.get("translate_service") == "ai":
provider = str(value.get("ai_provider") or "openai_compatible").strip().lower()
value["translate_service"] = provider if provider in AI_SERVICES else "openai_compatible"
should_rewrite = True
# 旧版默认规则已废弃:两套各查一次,命中就换成现在的默认
for prefix in ("openai", "ollama"):
if value.get(f"{prefix}_translation_rule") in {LEGACY_DEFAULT_TRANSLATION_RULE, V17_DEFAULT_TRANSLATION_RULE}:
value[f"{prefix}_translation_rule"] = DEFAULT_TRANSLATION_RULE
should_rewrite = True
if value.get(f"{prefix}_optimization_rule") == LEGACY_DEFAULT_OPTIMIZATION_RULE:
value[f"{prefix}_optimization_rule"] = DEFAULT_OPTIMIZATION_RULE
should_rewrite = True
result = {**self._default_persisted_config(), **value, "installation_id": self.installation_id}
# 密钥只在「绑定的后端/地址变了」或「换机器」时清,切翻译服务绝不清
if result.get("ai_api_key") and result.get("ai_credential_binding") != self._credential_binding(result["ai_provider"], result["ai_base_url"]):
result["ai_api_key"] = ""
result["ai_credential_binding"] = ""
# 密钥只在「地址变了」或「换机器」时清,切翻译服务 / 切后端绝不清
if result.get("openai_api_key") and result.get("openai_credential_binding") != self._credential_binding("openai", result["openai_base_url"]):
result["openai_api_key"] = ""
result["openai_credential_binding"] = ""
should_rewrite = True
if result.get("baidu_secret_key") and result.get("baidu_credential_binding") != self._credential_binding("baidu", ""):
result["baidu_secret_key"] = ""
@@ -381,15 +521,18 @@ class AssistantStore:
def public_config(self):
config = self.config()
defaults = {}
for prefix in ("openai", "ollama"):
defaults[f"default_{prefix}_translation_rule"] = DEFAULT_TRANSLATION_RULE
defaults[f"default_{prefix}_translate_optimize_rule"] = DEFAULT_TRANSLATE_OPTIMIZE_RULE
defaults[f"default_{prefix}_optimization_rule"] = DEFAULT_OPTIMIZATION_RULE
defaults[f"default_{prefix}_natural_rule"] = DEFAULT_NATURAL_RULE
return {
key: config[key] for key in DEFAULT_CONFIG if key not in ("ai_api_key", "baidu_secret_key")
key: config[key] for key in DEFAULT_CONFIG if key not in ("openai_api_key", "baidu_secret_key")
} | {
"ai_api_key_configured": bool(config.get("ai_api_key")),
"openai_api_key_configured": bool(config.get("openai_api_key")),
"baidu_secret_key_configured": bool(config.get("baidu_secret_key")),
"default_translation_rule": DEFAULT_TRANSLATION_RULE,
"default_translate_optimize_rule": DEFAULT_TRANSLATE_OPTIMIZE_RULE,
"default_optimization_rule": DEFAULT_OPTIMIZATION_RULE,
}
} | defaults
@staticmethod
def _clean_rule(value, fallback):
@@ -405,45 +548,46 @@ class AssistantStore:
raise ValueError("设置必须是对象")
current = self.config()
translate_service = str(payload.get("translate_service", current["translate_service"])).strip().lower()
if translate_service not in {"dictionary", "baidu", "ai"}:
raise ValueError("翻译服务仅支持词库、百度翻译或 AI")
if translate_service not in TRANSLATE_SERVICES:
raise ValueError("翻译服务仅支持词库、百度翻译、OpenAI 兼容接口或 Ollama")
ai_provider = str(payload.get("ai_provider", current["ai_provider"])).strip().lower()
if ai_provider not in {"openai_compatible", "ollama"}:
raise ValueError("AI 后端仅支持 OpenAI 兼容接口或 Ollama")
ai_base_url = self._normalize_base_url(payload.get("ai_base_url", current["ai_base_url"]))
if ai_base_url:
parsed = urlparse(ai_base_url)
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
raise ValueError("API 地址必须是完整的 http:// 或 https:// 地址")
if parsed.username or parsed.password:
raise ValueError("API 地址中不能包含用户名或密码")
if parsed.query or parsed.fragment:
raise ValueError("API 地址中不能包含查询参数或片段")
if parsed.scheme == "http" and not self._is_local_hostname(parsed.hostname):
raise ValueError("非本机 API 地址必须使用 HTTPS,避免 API Key 明文传输")
ai_model = str(payload.get("ai_model", current["ai_model"])).strip()
if len(ai_model) > 300:
raise ValueError("模型名称过长")
try:
ai_temperature = float(payload.get("ai_temperature", current["ai_temperature"]))
ai_timeout_seconds = int(payload.get("ai_timeout_seconds", current["ai_timeout_seconds"]))
except (TypeError, ValueError) as error:
raise ValueError("温度和超时时间必须是数字") from error
if not 0 <= ai_temperature <= 2:
raise ValueError("温度必须在 0 到 2 之间")
if not 5 <= ai_timeout_seconds <= 600:
raise ValueError("超时时间必须在 5 到 600 秒之间")
# AI key:只在「AI 后端/地址变了」或显式清除时清,切翻译服务绝不清
ai_endpoint_changed = ai_provider != current["ai_provider"] or ai_base_url != self._normalize_base_url(current["ai_base_url"])
ai_api_key = "" if ai_endpoint_changed else current.get("ai_api_key", "")
if payload.get("clear_ai_api_key"):
ai_api_key = ""
elif "ai_api_key" in payload:
incoming_key = str(payload.get("ai_api_key") or "").strip()
# 选了 OpenAI / Ollama 当翻译服务时,它就是 AI 后端,两者不允许脱节
if translate_service in AI_SERVICES:
ai_provider = translate_service
# 两个后端各一套:地址规则不同(Ollama 只认本机,OpenAI 远端必须 https)
openai_base_url = normalize_ai_base_url(payload.get("openai_base_url", current["openai_base_url"]), allow_remote=True)
ollama_base_url = normalize_ai_base_url(payload.get("ollama_base_url", current["ollama_base_url"]))
if not ollama_base_url:
ollama_base_url = DEFAULT_CONFIG["ollama_base_url"]
models = {}
numbers = {}
for prefix in ("openai", "ollama"):
models[prefix] = str(payload.get(f"{prefix}_model", current[f"{prefix}_model"])).strip()
if len(models[prefix]) > 300:
raise ValueError("模型名称过长")
try:
numbers[prefix] = (
float(payload.get(f"{prefix}_temperature", current[f"{prefix}_temperature"])),
int(payload.get(f"{prefix}_timeout_seconds", current[f"{prefix}_timeout_seconds"])),
)
except (TypeError, ValueError) as error:
raise ValueError("温度和超时时间必须是数字") from error
if not 0 <= numbers[prefix][0] <= 2:
raise ValueError("温度必须在 0 到 2 之间")
if not 5 <= numbers[prefix][1] <= 600:
raise ValueError("超时时间必须在 5 到 600 秒之间")
# OpenAI key:只在「地址变了」或显式清除时清,切翻译服务 / 切后端绝不清
openai_api_key = "" if openai_base_url != self._normalize_base_url(current["openai_base_url"]) else current.get("openai_api_key", "")
if payload.get("clear_openai_api_key"):
openai_api_key = ""
elif "openai_api_key" in payload:
incoming_key = str(payload.get("openai_api_key") or "").strip()
if incoming_key and incoming_key != "••••••••":
if len(incoming_key) > 4096:
raise ValueError("API Key 过长")
ai_api_key = incoming_key
openai_api_key = incoming_key
baidu_appid = str(payload.get("baidu_appid", current.get("baidu_appid", ""))).strip()
if len(baidu_appid) > 128:
raise ValueError("百度 APP ID 过长")
@@ -458,7 +602,7 @@ class AssistantStore:
raise ValueError("百度密钥过长")
baidu_secret = incoming_secret
baidu_qps = normalize_baidu_qps(payload.get("baidu_qps", current.get("baidu_qps", BAIDU_QPS_DEFAULT)))
ai_credential_binding = self._credential_binding(ai_provider, ai_base_url) if ai_api_key else ""
openai_credential_binding = self._credential_binding("openai", openai_base_url) if openai_api_key else ""
baidu_credential_binding = self._credential_binding("baidu", "") if baidu_secret else ""
appearance = str(payload.get("appearance", current.get("appearance", "auto"))).strip().lower()
if appearance not in {"auto", "dark", "light"}:
@@ -467,37 +611,34 @@ class AssistantStore:
if language not in {"auto", "zh", "en"}:
raise ValueError("界面语言仅支持 auto / zh / en")
result = {
"schema_version": 3,
"schema_version": 4,
"translate_service": translate_service,
"ai_provider": ai_provider,
"ai_base_url": ai_base_url,
"ai_model": ai_model,
"ai_api_key": ai_api_key,
"ai_temperature": ai_temperature,
"ai_timeout_seconds": ai_timeout_seconds,
"openai_base_url": openai_base_url,
"openai_model": models["openai"],
"openai_api_key": openai_api_key,
"openai_temperature": numbers["openai"][0],
"openai_timeout_seconds": numbers["openai"][1],
"ollama_base_url": ollama_base_url,
"ollama_model": models["ollama"],
"ollama_temperature": numbers["ollama"][0],
"ollama_timeout_seconds": numbers["ollama"][1],
"baidu_appid": baidu_appid,
"baidu_secret_key": baidu_secret,
"baidu_qps": baidu_qps,
"appearance": appearance,
"language": language,
"translation_rule": self._clean_rule(payload.get("translation_rule"), current["translation_rule"]),
"translate_optimize_rule": self._clean_rule(payload.get("translate_optimize_rule"), current["translate_optimize_rule"]),
"optimization_rule": self._clean_rule(payload.get("optimization_rule"), current["optimization_rule"]),
"installation_id": self.installation_id,
"ai_credential_binding": ai_credential_binding,
"baidu_credential_binding": baidu_credential_binding,
"openai_credential_binding": openai_credential_binding,
"installation_id": self.installation_id,
}
# 规则两套,各取各自的当前值兜底
for prefix in ("openai", "ollama"):
for rule in RULE_KEYS:
result[f"{prefix}_{rule}"] = self._clean_rule(payload.get(f"{prefix}_{rule}"), current[f"{prefix}_{rule}"])
self._write_json_atomic(self.config_path, result)
return self.public_config()
def reset_rules(self):
config = self.config()
config["translation_rule"] = DEFAULT_TRANSLATION_RULE
config["translate_optimize_rule"] = DEFAULT_TRANSLATE_OPTIMIZE_RULE
config["optimization_rule"] = DEFAULT_OPTIMIZATION_RULE
self._write_json_atomic(self.config_path, config)
return self.public_config()
def dictionary_translate(text, dictionary_store):
parts = [part.strip() for part in re.split(r"[,,、\r\n]+", str(text or "")) if part.strip()]
+13 -7
View File
@@ -33,11 +33,13 @@
- `server.py`
- 注册助手、个人词库、词库包和大型词库的本地接口。
- 执行同源检查、临时会话令牌校验、请求体限制、助手频率限制和并发限制。
- 只在用户主动调用时访问配置的百度翻译或 AI(OpenAI 兼容 / Ollama)服务;"翻译并优化"与"优化为 Anima"恒走 AI,翻译/解释按 `translate_service` 选择。
- 只在用户主动调用时访问配置的百度翻译或 AI(OpenAI 兼容 / Ollama)服务;"翻译并优化"与"优化"恒走 AI,翻译/解释按 `translate_service` 选择。
- `UpstreamGate` 负责上游输入的挂起:按节点 id 保存一个 future,只能被 `/bpi/upstream/{ack,resume,cancel}` 唤醒。等待循环每 0.5 秒检查一次 ComfyUI 中断,无人接管时到点自动透传。唤醒一律走 `loop.call_soon_threadsafe`——future 属于执行线程的 loop,而路由跑在主线程。
- `assistant_store.py`
- 保存翻译服务类型(schema v3:`translate_service` = dictionary / baidu / ai)、AI 后端与地址模型、百度 APP ID 与密钥、三套规则和独立留存的 AI Key 与百度密钥。
- AI Key 与百度密钥互不干扰:切换翻译服务不清任一密钥;AI Key 只在 AI 后端/地址变更或换机器时清,百度密钥只在显式清除时清。
- 保存翻译服务类型(schema v4:`translate_service` = dictionary / baidu / openai_compatible / ollama;后两个同时是 AI 后端,选前两个时由 `ai_provider` 决定优化走哪一套)、两套 AI 配置(OpenAI 兼容 / Ollama 各一套地址、模型、温度、超时与四条规则)、百度 APP ID 与密钥,以及独立留存的 AI Key 与百度密钥。
- 四条规则是翻译 / 翻译并优化 / 优化 / 自然语言。前三条产出 Anima 标签流,走 `sanitize_anima_prompt`(句点收成逗号、标点只放行白名单);自然语言那条产出完整英文句子,走 `sanitize_natural_prompt`,两者不能混用。
- 读到旧值 `translate_service = "ai"` 时按当时的 `ai_provider` 拆成 `openai_compatible` / `ollama` 并回写。
- AI Key 与百度密钥互不干扰:切换翻译服务或 AI 后端不清任一密钥;AI Key 只在地址变更或换机器时清,百度密钥只在显式清除时清。
- 旧 v2 配置(单一 `provider`)自动迁移至 v3,密钥与绑定原样搬入 `ai_*` 槽。
- API Key 不通过读取接口返回前端;公开配置只返回 `ai_api_key_configured` 与 `baidu_secret_key_configured` 布尔值。
- 负责翻译方向判断、纯词库翻译和 Anima 输出标点清理。
@@ -58,8 +60,11 @@
- `openSavePromptDialog` 用 multipart 一次提交名称、备注与图片文件,正文只读展示,保存成功后回调刷新侧边栏。
- `openManagerPanel` 通过 ComfyUI 侧边栏 API 打开管理面板,可指定定位分页与目标节点。
- `js/manager_panel.js`
- 在 ComfyUI 侧边栏注册「双语提示词管理」面板,包含标签管理、词库、词库包、助手设置、收藏五个分页。
- 承担原节点内管理弹窗的全部职责:词条增删改、批量操作、个人词库导入导出、社区包与大型词库管理、助手服务与 API Key 配置。
- 在 ComfyUI 侧边栏注册「双语提示词管理」面板,包含标签管理、词库、词库包、收藏四个分页。
- 承担原节点内管理弹窗的全部职责:词条增删改、批量操作、个人词库导入导出、社区包与大型词库管理。
- 助手设置不在侧边栏:同一文件的 `registerExtension` 用 `settings` 在 ComfyUI「设置」面板的「双语提示词检查器」分类下注册两项 ——「语言与外观」(`appearanceControl()`)与「助手设置」(`assistantSettingsControl()`),表单都直接内嵌在设置面板右侧,配置仍写入 `assistant_store.py`。面板反复渲染会重建控件容器,所以每项的整块 DOM 只建一次后复用,避免输入到一半被清空。
- 语言与外观只提交 `appearance` / `language` 两个字段:`assistant_store.update()` 对 payload 里没给的字段一律取当前值,所以不会动翻译服务与密钥;助手设置的 payload 反过来已经不含这两项。
- AI 两个后端各一整套配置(地址 / 模型 / 温度 / 超时 / 三条规则),键名前缀 `openai_` 与 `ollama_`,`ai_provider` 只决定当前读哪一套;切换后端不清空任何一套。地址白名单在 `assistant_store.normalize_ai_base_url()`:OpenAI 远端必须 https、http 仅限本机回环,Ollama 只认本机回环,且 `server.py` 发请求前会再校验一次(防手改配置文件)。
- “标签管理”分页通过节点下拉列表挂载指定节点的标签翻译与词库搜索界面(DOM 直接移植,逻辑仍归属各节点闭包),节点删除时自动摘下。
- 修改后通过 `panelSyncHub` 通知当前页面所有节点刷新。
- “收藏”分页按需挂载并监听 `bpi:saved-prompts-changed`:卡片支持搜索、复制、删除、导出导入;「新建」没有选中节点时弹出节点选择器;「载入节点」先确认再调目标节点的 `_bilingualPromptInspector.setText`,覆盖不进 undo 栈。
@@ -68,7 +73,7 @@
- 标签排序是结构化编辑:英文标签区支持指针拖拽(4px 阈值区分点击与拖动,拖动后吞掉 click/dblclick),明细表行首提供 `⠿` 手柄(带插入指示线与边缘自动滚动),`Alt+↑/↓` 键盘微调;三个入口都汇入同一个 `moveTokenTo` → `movePromptToken` → undo 栈管线。
- 标签隐藏(临时排除出实际输出)只是“打标记”:标签留在文本里,卡片与明细行加删除线并灰化褪色,拖动/双击改权重/改色照常;剔除发生在输出时——`syncEffectiveOutput` 把去掉隐藏标签的文本同步进隐藏 widget `effective_text`(并置 `use_effective`),`nodes.py` 按它输出,旧工作流没有这两个 widget 时默认直通。明细表行内眼睛图标、英文标签 hover 浮出的眼睛角标在隐藏后变为眼睛斜线(取消隐藏);明细表下方与英文标签区下方的“已隐藏”条负责逐个/全部恢复;清单存在 `node.properties.bpiHiddenTags`(随工作流保存),每次编辑把清单快照写进 undo 条目,撤销时文本与清单一起回滚。旧版工作流里当时被物理删除的隐藏标签,恢复时仍按旧锚点插回文本。
- 标签翻译与词库搜索(标签管理)不再渲染进节点,其 DOM 由侧边栏“标签管理”分页挂载,节点高度恒为折叠尺寸。
- 管理功能收敛为「标签管理」「助手设置」等入口按钮,打开侧边栏面板对应分页并定位到本节点。
- 管理功能收敛为「标签管理」等入口按钮,打开侧边栏面板对应分页并定位到本节点。
- 上游输入条(`.bpi-source-bar`)只在 `prompt` 端口真的连上时才渲染,未连线时零高度、界面与旧版一致。条内含接管开关、导入策略下拉和状态文案,等待确认时额外出现「继续运行」「放弃」。开关与策略存在 `node.properties.bpiUpstream`(随工作流保存);放行时把节点当前文本 POST 回后端,因此输出始终等于放行那一刻节点上的内容。
- `js/parser.js`
- 非破坏性切分标签,保留原始字符位置。
@@ -113,7 +118,8 @@ ComfyUI 原生 text 控件(工作流真值)
```text
绿色草稿框
→ 用户点击仅翻译 / 翻译并优化 / 优化为 Anima
→ 用户点击仅翻译 / 翻译并优化 / 优化
→ 自然语言模式下「优化」改用 optimize_natural,走自然语言规则
→ /bpi/assistant/run
→ 纯词库、百度翻译或 AI(OpenAI 兼容 / Ollama)
→ 结果写回绿色暂存区
+12 -2
View File
@@ -16,9 +16,9 @@
## 3. 翻译、优化和同步必须分阶段
决定:设置“仅翻译”“翻译并优化”“优化为 Anima”“同步到英文输出”四个独立动作。
决定:设置“仅翻译”“翻译并优化”“优化”“同步到英文输出”四个独立动作。
原因:翻译与提示词创作不是同一任务。用户需要看见模型做了什么,并决定是否覆盖实际输出。每个动作使用独立规则,避免一个通用提示词同时承担忠实翻译、扩写和格式整理而行为混乱。
原因:翻译与提示词创作不是同一任务。用户需要看见模型做了什么,并决定是否覆盖实际输出。每个动作使用独立规则,避免一个通用提示词同时承担忠实翻译、扩写和格式整理而行为混乱。“优化”会按当前输入模式分派不同规则,见第 19 条。
## 4. Anima 排序只在按下按钮后执行
@@ -116,3 +116,13 @@
- GitHub 首个公开正式 Release 使用 `v1.0.0`。
- 后续发布应使用语义化版本号,不要把内部演进代次和公开版本号混为一谈。
- 兼容修复递增补丁号;向后兼容的新功能递增次版本;破坏工作流或数据格式时才递增主版本,并提供迁移说明。
## 19. “优化”按输入模式分派,而不是再加一个按钮
决定:节点顶部只保留一个“优化”按钮。标签模式下走 Anima 优化规则(只接受英文),自然语言模式下走独立的自然语言规则(中文先译后整理);模式切到“自然语言”时“按 Anima 顺序排序”隐藏。
原因:Krea、FLUX 一类新模型直接吃自然语言,再加一个按钮只会让顶部操作越排越长。模式下拉已经能判定当前是标签流还是整段句子,用它分派比让用户记两个按钮省事;排序只重排标签流,对整段句子没有意义,留着反而误导。
两套规则必须分开:`sanitize_anima_prompt` 会把句点收成逗号并只放行 Anima 标点白名单,完整句子喂进去会被判废,所以自然语言结果改走 `sanitize_natural_prompt`。
代价:已经排过序时要保留排序按钮,否则“取消排序”的入口会跟着一起消失。另外 mixed(标签 + 自然语言)不算自然语言模式——里面还有标签流可排。
+436 -94
View File
@@ -11,6 +11,7 @@ import {
replacePromptSpan,
replacePromptTokenWeight,
restorePromptToken,
UNKNOWN_CHINESE,
validateTranslationResult,
} from "./parser.js";
import {
@@ -40,7 +41,6 @@ import {
loadTokenColors,
buildColorPanel,
lookupLargeDictionary,
openManagerPanel,
openSavePromptDialog,
openTagDialog,
postUpstreamAction,
@@ -57,9 +57,14 @@ import {
} from "./bpi_shared.js";
const NODE_NAME = "BilingualPromptInspector";
const EXTENSION_VERSION = "v2.0.0";
const EXTENSION_VERSION = "v2.1.8";
const PROJECT_URL = "https://github.com/mengshengzhijie/ComfyUI-Bilingual-Prompt-Inspector";
const COLLAPSED_WIDGET_FALLBACK_HEIGHT = 390;
// 「适应高度」打开后面板能长到的上限。ComfyUI 按 min/max 分配空间,max 必须明显高于 min,
// 否则手动拉大节点多出来的高度全变成空白(distributeSpace 只会在 min..max 之间给)。
const EXPANDED_WIDGET_MAX_HEIGHT = 1600;
// 打开开关时至少给节点加这么高,保证「点了有反应」;内容被压掉更多时按实际差值撑
const FILL_HEIGHT_MIN_GROWTH = 200;
const COLLAPSED_NODE_MIN_HEIGHT = 360;
const IMPORT_POLICIES = [
{ value: "changed", label: "Import: on content change" },
@@ -132,17 +137,25 @@ function createPanel(node, textWidget) {
const insertBreakButton = element("button", "bpi-button bpi-mini", "Insert line break");
// 中文不再单独占一块:每个英文标签下面直接跟一行中文,构成一张双语小卡。
// 翻译类动作已移到侧边栏,节点里只保留「直接作用于英文提示词」的这两个。
const optimizeChineseButton = element("button", "bpi-button bpi-mini", "Optimize to Anima");
const optimizeButton = element("button", "bpi-button bpi-mini", "Optimize");
const sortPromptButton = element("button", "bpi-button bpi-mini", "Sort by Anima order");
// 「适应高度」:把面板高度从写死的上限里放出来,让提示词区跟着节点一起变高。
// 做成滑动开关(和设置面板里同款,只是用主题色而不是危险色),放在明细表上方的筛选栏里。
const fillSwitchRow = element("label", "bpi-switch-row bpi-switch-accent");
const fillSwitch = element("input", "bpi-switch");
fillSwitch.type = "checkbox";
const fillTrack = element("span", "bpi-switch-track");
fillTrack.appendChild(element("span", "bpi-switch-thumb"));
fillSwitchRow.append(fillSwitch, fillTrack, element("span", "bpi-switch-text", "Fit height"));
editEnglishButton.type = "button";
clearEnglishButton.type = "button";
favoriteButton.type = "button";
insertBreakButton.type = "button";
optimizeChineseButton.type = "button";
optimizeButton.type = "button";
sortPromptButton.type = "button";
setTitle(favoriteButton, "Save the current English prompt to favorites in the user directory (a reference image can be attached)");
setTitle(insertBreakButton, "Insert a line break after the selected tag, or at the end when no tag is selected");
englishHead.append(englishTitle, englishHint, editEnglishButton, clearEnglishButton, insertBreakButton, favoriteButton, optimizeChineseButton, sortPromptButton);
englishHead.append(englishTitle, englishHint, editEnglishButton, clearEnglishButton, insertBreakButton, favoriteButton, optimizeButton, sortPromptButton);
const englishTokenView = element("div", "bpi-english-token-view bpi-hidden");
englishTokenView.tabIndex = 0;
englishTokenView.setAttribute("role", "textbox");
@@ -201,6 +214,21 @@ function createPanel(node, textWidget) {
// 机器翻译批量入库操作栏:只在表格里出现机器翻译词条时才显示(详见 renderMachineBatchBar)
const machineBatchBar = element("div", "bpi-filters bpi-batch-bar bpi-hidden");
const table = element("div", "bpi-table");
// 明细表查找:只筛视图,不动提示词。视觉上跟表格扣成一块(上圆角、无下边框),
// 好跟上面那个「搜词库插标签」的输入框区分开——那个改提示词,这个不改。
const tableSearch = element("div", "bpi-table-search");
const tableSearchIcon = element("span", "bpi-table-search-icon");
tableSearchIcon.innerHTML = '<svg viewBox="0 0 12 12" width="11" height="11" fill="none" stroke="currentColor" stroke-width="1.4"><circle cx="5" cy="5" r="3.3"/><path d="M7.5 7.5 10.6 10.6"/></svg>';
const tableSearchInput = element("input", "bpi-search bpi-table-search-input");
// 不用 type=search:Chrome 会再画一个原生清除按钮,跟我们自己的 × 重复
tableSearchInput.type = "text";
setPlaceholder(tableSearchInput, "Find in the current prompt");
setTitle(tableSearchInput, "Filter the detail list by keyword; matches the English original or the Chinese explanation; the prompt itself never changes");
const tableSearchCount = element("span", "bpi-table-search-count");
const tableSearchClear = element("button", "bpi-table-search-clear", "×");
tableSearchClear.type = "button";
setTitle(tableSearchClear, "Clear search");
tableSearch.append(tableSearchIcon, tableSearchInput, tableSearchCount, tableSearchClear);
const head = element("div", "bpi-head");
head.append(element("div", "", "English original (actual output)"), element("div", "", "Chinese explanation (read-only)"));
// 表头分界处的抓手:拖动改左右两栏比例。比例写在表格的 --bpi-split 变量上,
@@ -259,6 +287,13 @@ function createPanel(node, textWidget) {
option.value = value;
modeSelect.appendChild(option);
}
// 模式跟着节点存:工作流保存后重开仍是上次选的那一档,没存过就回到自动检测
const storedMode = () => {
const value = node.properties?.bpiMode;
return ["tags", "natural"].includes(value) ? value : "auto";
};
// 「适应高度」也跟着节点存:工作流重开后仍是上次那个状态
const storedFillHeight = () => node.properties?.bpiFillHeight === true;
const state = {
data: { tags: [], builtin: [], user: [], packs: [] },
@@ -271,13 +306,16 @@ function createPanel(node, textWidget) {
selected: new Set(),
lastText: null,
renderTimer: null,
modePreference: "auto",
modePreference: storedMode(),
fillHeight: storedFillHeight(),
modeInfo: { mode: "tags", reason: "Empty input", confidence: "high" },
issues: [],
localOverrides: new Map(),
editing: null,
preferences: loadPreferences(),
tableFilter: "all",
// 明细表查找词:只筛视图,不进任何持久化数据
tableQuery: "",
filterButtons: new Map(),
// 表格里勾选待入库的机器翻译词条,只存 token.key
batchSelected: new Set(),
@@ -305,6 +343,9 @@ function createPanel(node, textWidget) {
searchVisibleLimit: 40,
lastRenderedSearchQuery: "",
categoryView: false,
// 「按 Anima 顺序排序」写入后留一份快照 { before, after },按钮据此变成「取消排序」;
// 用户又手动改了文本就作废(见 render),避免回退把他后来的改动一起吞掉
animaSortSnapshot: null,
// 节点标签卡颜色模式:default(不着色)| status(按收录:未收录淡橙、机器翻译淡紫、已收录不变)
// | random(从 token_colors.json 摇色)| by_pack(按标签所在词库取 pack_colors)
colorMode: localStorage.getItem("bpi.colorMode") || "default",
@@ -346,6 +387,12 @@ function createPanel(node, textWidget) {
};
const autoFitGreenArea = (target, minimum) => {
if (!target || target.classList.contains("bpi-hidden")) return;
// 适应高度模式下高度交给 flex;这里再写死 height 会把刚放开的上限又按回去
if (state.fillHeight) {
target.style.height = "";
target.style.overflowY = "auto";
return;
}
const maximum = 360;
target.style.height = "auto";
const height = Math.max(minimum, Math.min(maximum, Math.ceil(target.scrollHeight + 2)));
@@ -419,9 +466,20 @@ function createPanel(node, textWidget) {
if (state.resizeFrame !== null) cancelAnimationFrame(state.resizeFrame);
state.resizeFrame = requestAnimationFrame(() => {
state.resizeFrame = null;
const width = Math.max(node.size?.[0] ?? 0, 590);
// 适应高度模式:高度归用户,只同步宽度。这里绝不能按内容重算——
// 英文区一变高就会推高自动高度、再推高节点、英文区又更高,越滚越大。
if (state.fillHeight) {
if (Math.abs((node.size?.[0] ?? 0) - width) > 0.5) {
node.setSize?.([width, Number(node.size?.[1]) || COLLAPSED_NODE_MIN_HEIGHT]);
node.graph?.change?.();
}
node.graph?.setDirtyCanvas?.(true, true);
app.graph?.setDirtyCanvas?.(true, true);
return;
}
state.collapsedWidgetHeight = measureCollapsedWidgetHeight();
panel.style.setProperty("--bpi-collapsed-height", `${state.collapsedWidgetHeight}px`);
const width = Math.max(node.size?.[0] ?? 0, 590);
// 稳定基线 = 标题栏 + 原生 text 控件的真实内容高度。
// 绝不能读 inspectorWidget().y(= bilingual_inspector.y)——那是 ComfyUI arrange
// 从 text.computedHeight 派生的,而 text.computedHeight 在「setSize 变高 → 文本框
@@ -447,16 +505,74 @@ function createPanel(node, textWidget) {
fallbackBaseHeight: state.nodeBaseHeight ?? 96,
minimumHeight: COLLAPSED_NODE_MIN_HEIGHT,
});
// 高度只补不缩:用户把节点拉大之后,任何一次 render 都不该把它拽回自动高度,
// 否则「拉大 → 改一个字 → 被打回原形」,拉大等于白拉。宽度仍按最小可用宽度收敛。
const currentHeight = Number(node.size?.[1]) || 0;
const nextHeight = Math.max(targetHeight, currentHeight);
const sizeChanged = Math.abs((node.size?.[0] ?? 0) - width) > 0.5
|| Math.abs((node.size?.[1] ?? 0) - targetHeight) > 0.5;
|| Math.abs(currentHeight - nextHeight) > 0.5;
if (sizeChanged) {
node.setSize?.([width, targetHeight]);
node.setSize?.([width, nextHeight]);
node.graph?.change?.();
}
node.graph?.setDirtyCanvas?.(true, true);
app.graph?.setDirtyCanvas?.(true, true);
});
};
// 量出英文标签区「没被 360 压住时」的真实高度:临时解除上限读 scrollHeight 再恢复
const measureNaturalEnglishHeight = () => {
const previousHeight = englishTokenView.style.height;
const previousMax = englishTokenView.style.maxHeight;
englishTokenView.style.height = "auto";
englishTokenView.style.maxHeight = "none";
const natural = englishTokenView.scrollHeight;
englishTokenView.style.height = previousHeight;
englishTokenView.style.maxHeight = previousMax;
return natural;
};
// 打开开关时把节点撑高一次,撑的量 = 被压掉的内容高度,但至少给一个看得出变化的量
const growNodeForFill = () => {
if (!hasNodeSize()) return;
const natural = measureNaturalEnglishHeight();
const extra = Math.max(FILL_HEIGHT_MIN_GROWTH, Math.ceil(natural - englishTokenView.offsetHeight));
const height = Math.min((Number(node.size?.[1]) || COLLAPSED_NODE_MIN_HEIGHT) + extra, 2400);
node.setSize?.([Math.max(node.size?.[0] ?? 0, 590), height]);
node.graph?.change?.();
};
const setFillHeight = (next) => {
const fill = Boolean(next);
if (fill === state.fillHeight) return;
state.fillHeight = fill;
if (!node.properties || typeof node.properties !== "object") node.properties = {};
node.properties.bpiFillHeight = fill;
panel.classList.toggle("bpi-fill", fill);
fillSwitch.checked = fill;
setTitle(fillSwitchRow, fill
? "Back to the automatic height; the prompt area stops following the node"
: "Let the prompt area grow with the node instead of being capped");
if (fill) {
// 先放开限制再撑高,撑完重排一次让英文区吃到新空间
growNodeForFill();
requestNodeResize();
} else {
state.collapsedWidgetHeight = measureCollapsedWidgetHeight();
panel.style.setProperty("--bpi-collapsed-height", `${state.collapsedWidgetHeight}px`);
if (hasNodeSize()) {
const baseHeight = state.nodeBaseHeight ?? 96;
node.setSize?.([Math.max(node.size?.[0] ?? 0, 590), Math.max(inspectorNodeTargetHeight({
baseHeight,
targetWidgetHeight: state.collapsedWidgetHeight,
fallbackBaseHeight: state.nodeBaseHeight ?? 96,
minimumHeight: COLLAPSED_NODE_MIN_HEIGHT,
}), COLLAPSED_NODE_MIN_HEIGHT)]);
node.graph?.change?.();
}
}
render();
};
const isFavorite = (english) => state.preferences.favorites.includes(normalizeKey(english));
const changeFavorite = (english) => {
state.preferences = toggleFavorite(state.preferences, english);
@@ -606,6 +722,7 @@ function createPanel(node, textWidget) {
}
if (before === after) {
if (label === "Anima official sort") {
state.animaSortSnapshot = { before, after };
state.categoryView = true;
render();
setText(englishHint, "Category table view | click tags to link; category names are not written to the prompt");
@@ -639,6 +756,8 @@ function createPanel(node, textWidget) {
setText(editEnglishButton, "Edit text");
setText(englishHint, "");
updateText(after);
// 排序落盘后记住排序前那一版,按钮才好变成「取消 Anima 顺序排序」
if (label === "Anima official sort") state.animaSortSnapshot = { before, after };
setStatus(`applied “${label}”; press Ctrl+Z to undo`, "ok");
return true;
};
@@ -706,7 +825,7 @@ function createPanel(node, textWidget) {
if (detailNode) modal.appendChild(detailNode);
const actions = element("div", "bpi-modal-actions");
const close = () => shade.remove();
actions.append(button("Cancel", close), button("Confirm write English", () => {
actions.append(button("Cancel", close), button("Confirm write new prompt", () => {
if (applyFullText(after.value, label)) close();
}, "bpi-primary"));
modal.appendChild(actions);
@@ -747,6 +866,17 @@ function createPanel(node, textWidget) {
// 多选名单(按原文顺序);单选时就是 pinned 那一个
const selectedTokenList = () => state.tokens.filter((token) => state.selected.has(token.id));
// 多选看着选中的范围 = 最前与最后之间的整段,和双击编辑的范围一致。
// ctrl 点第 1、第 3 个时第 2 个虽然没被点到,但也在编辑范围里,要一起显示成选中
const inSelectionSpan = (token) => {
if (state.selected.size <= 1) return false;
const list = selectedTokenList();
if (!list.length) return false;
const start = Math.min(...list.map((item) => item.start));
const end = Math.max(...list.map((item) => item.end));
return token.start >= start && token.end <= end;
};
// 双击编辑的范围:选中那几个 token 在原文里的整段,中间的标签一并进来
const spanForEdit = (token) => {
const list = selectedTokenList();
@@ -1400,30 +1530,38 @@ function createPanel(node, textWidget) {
};
const openWeightEditor = (token) => {
if (!token || token.syntax !== "tag" || token.segmentKind === "natural" ||
!["tags", "mixed"].includes(state.modeInfo.mode)) {
setStatus("Only tags support individual weights; use text editing for natural language", "error");
return;
}
if (!token) return;
// 自然语言片段(以及 BREAK、<lora:…> 这类特殊语法)没有独立权重、也没有词条,
// 所以这两个页签按 token 形态收起来,但弹窗照开:配色和整段编辑对它们一样有用
const isPlainTag = token.syntax === "tag" && token.segmentKind !== "natural";
const canWeight = isPlainTag && ["tags", "mixed"].includes(state.modeInfo.mode);
const shade = element("div", "bpi-modal-shade");
const modal = element("div", "bpi-modal");
// 这两句直接写中文:带变量 + 固定术语,走翻译层反而匹配不上,界面必须中文
modal.appendChild(element("h3", "", `编辑标签:${token.term}`));
// 这句直接写中文:带变量 + 固定术语,走翻译层反而匹配不上,界面必须中文
// 自然语言片段 / BREAK 这类不叫「标签」,标题换个说法免得看着别扭
modal.appendChild(element("h3", "", isPlainTag ? `编辑标签:${token.term}` : `编辑片段:${token.term}`));
// 多选着进来就直接停在编辑页:权重只对单个标签有意义
const multiOpen = state.selected.size > 1;
// 多选着进来、或者双击的不是普通标签(自然语言片段)→ 直接停在编辑页:
// 权重只对单个标签有意义,而自然语言片段本来就是整段改的
const startOnRaw = state.selected.size > 1 || !isPlainTag;
// Tab 切换:权重与颜色(一页管两样)/ 编辑(改这行原文)/ 词条信息(改的是整条词条)
// 自然语言片段没有权重也没有词条,顺序改成 编辑 → 颜色
const tabs = element("div", "bpi-editor-tabs");
const tabWeight = element("button", `bpi-editor-tab${multiOpen ? "" : " bpi-editor-tab-active"}`, t("Weight / color"));
const tabRaw = element("button", `bpi-editor-tab${multiOpen ? " bpi-editor-tab-active" : ""}`, t("Edit"));
// 没有权重可改时这页只剩配色,标签也就只写「颜色」
const tabWeight = element("button", `bpi-editor-tab${startOnRaw ? "" : " bpi-editor-tab-active"}`, canWeight ? t("Weight / color") : t("Colors"));
const tabRaw = element("button", `bpi-editor-tab${startOnRaw ? " bpi-editor-tab-active" : ""}`, t("Edit"));
const tabEdit = element("button", "bpi-editor-tab", t("Entry info"));
tabWeight.type = tabRaw.type = tabEdit.type = "button";
tabs.append(tabWeight, tabRaw, tabEdit);
if (isPlainTag) tabs.append(tabWeight, tabRaw, tabEdit);
else tabs.append(tabRaw, tabWeight);
modal.appendChild(tabs);
const weightPanel = element("div", `bpi-editor-panel${multiOpen ? " bpi-hidden" : ""}`);
const weightPanel = element("div", `bpi-editor-panel${startOnRaw ? " bpi-hidden" : ""}`);
// 权重那三块(说明 / 输入框 / 预设)单独装一个盒子,不能改权重时整盒收起,
// 下半部分的配色照旧留着 —— 自然语言片段也要能上色
const weightBlock = element("div");
// 这句直接写中文:固定术语,走翻译层反而匹配不上
weightPanel.appendChild(element("div", "bpi-config-note", "Anima 格式:(标签:权重)"));
weightBlock.appendChild(element("div", "bpi-config-note", "Anima 格式:(标签:权重)"));
const form = element("div", "bpi-form");
const input = element("input", "");
input.type = "number";
@@ -1432,7 +1570,7 @@ function createPanel(node, textWidget) {
input.step = "0.05";
input.value = token.weight === null ? "1" : String(token.weight);
form.append(element("label", "", "Weight"), input);
weightPanel.appendChild(form);
weightBlock.appendChild(form);
const presets = element("div", "bpi-weight-presets");
for (const value of [0.7, 0.8, 0.9, 1, 1.1, 1.2, 1.3, 1.5]) {
@@ -1441,11 +1579,15 @@ function createPanel(node, textWidget) {
input.focus();
}, "bpi-mini"));
}
weightPanel.appendChild(presets);
weightBlock.appendChild(presets);
weightPanel.appendChild(weightBlock);
// 同一页下半部分是配色:预设色块 + 自定义输入框;只在 default 模式下才看得见效果
// 说明跟上方的「Anima 格式」一样放在控件上面
weightPanel.appendChild(element("div", "bpi-editor-divider"));
const colorDivider = element("div", "bpi-editor-divider");
weightPanel.appendChild(colorDivider);
weightBlock.hidden = !canWeight;
colorDivider.hidden = !canWeight;
// 双击设的自定义色优先级最低:只有「默认」模式才轮到它显示,其它模式会盖住
weightPanel.appendChild(element("div", "bpi-color-note", t("This color has the lowest priority: it only shows in Default mode; other color modes override it.")));
const presetsRow = element("div", "bpi-color-presets");
@@ -1492,13 +1634,13 @@ function createPanel(node, textWidget) {
weightPanel.appendChild(customRow);
// 编辑 Tab:把选中那段原文原样搬进输入框,怎么改都行,应用时整段写回提示词
const rawPanel = element("div", `bpi-editor-panel${multiOpen ? "" : " bpi-hidden"}`);
const rawPanel = element("div", `bpi-editor-panel${startOnRaw ? "" : " bpi-hidden"}`);
const rawSpan = spanForEdit(token);
const originalSlice = String(textWidget.value ?? "").slice(rawSpan.start, rawSpan.end).trim();
// 这三句直接写中文:本页专属说明,走翻译层反而匹配不上,界面必须中文
rawPanel.appendChild(element("div", "bpi-config-note", rawSpan.tokens.length > 1
? `已选中 ${rawSpan.tokens.length} 个标签,下面是它们在原文里的整段(含中间的标签)`
: "下面是这个标签在原文里的那段文字"));
: isPlainTag ? "下面是这个标签在原文里的那段文字" : "下面是这段文字在原文里的整段"));
const rawEditor = element("textarea", "bpi-raw-editor");
rawEditor.value = originalSlice;
rawPanel.appendChild(rawEditor);
@@ -1507,13 +1649,22 @@ function createPanel(node, textWidget) {
// 编辑 Tab:整套词条字段(含分类下拉),跟「新增/编辑个人标签」弹窗同一份构件。
// 命中词库就整条带过来改;没命中就按这条标签卡起个头,存进去即新建。
const editPanel = element("div", "bpi-editor-panel bpi-hidden");
editPanel.appendChild(element("div", "bpi-config-note", t("Edits go to the personal dictionary and override same-name entries.")));
// 这句直接写中文:本页专属说明,走翻译层反而匹配不上,界面必须中文
editPanel.appendChild(element("div", "bpi-config-note", "英文标签在这里只读;要改提示词里的文字,请用「编辑」页。"));
// 两句说明收进同一个盒子:应用时若发现还是「未收录」,整盒换成红色警告,
// 并先把盒子按当前高度钉住,换文案就不会把下面的表单顶上去
const editNotes = element("div", "bpi-editor-notes");
// 这两句是原文(英文的走翻译层,中文那句本页专属、不进译表),来回切换时都按原文重新渲染
const noteSavedSource = "Edits go to the personal dictionary and override same-name entries.";
const noteReadonlySource = "英文标签在这里只读;要改提示词里的文字,请用「编辑」页。";
const noteSaved = element("div", "bpi-config-note", noteSavedSource);
const noteReadonly = element("div", "bpi-config-note", noteReadonlySource);
editNotes.append(noteSaved, noteReadonly);
editPanel.appendChild(editNotes);
const editForm = element("div", "bpi-form");
const editFields = buildTagFields(editForm, state.index.get(token.key) ?? {
english: token.term,
chinese: token.chinese ?? "",
// 未收录的中文名只是占位,这里预填成空:想存就得自己写一个,
// 下面的「还是未收录就拦掉」只是兜底(防手动敲回这三个字)
chinese: token.chinese === UNKNOWN_CHINESE ? "" : token.chinese ?? "",
models: ["general", "anima"],
source: "user",
verified: true,
@@ -1521,9 +1672,36 @@ function createPanel(node, textWidget) {
}, { withTarget: false, readonlyEnglish: true });
editPanel.appendChild(editForm);
// 保护性占位:未收录标签的中文名只是提示文案,原样存进个人词库会得到
// 一条中文名是「未收录」的残词条,标签从此显示成已收录却看不出是什么。
// 所以「词条信息」页点应用时先比对中文名,还是占位就不保存、只把说明换成红色警告。
let warnTimer = 0;
const restoreEditNotes = () => {
clearTimeout(warnTimer);
editNotes.style.minHeight = "";
setText(noteSaved, noteSavedSource);
setText(noteReadonly, noteReadonlySource);
noteSaved.classList.remove("bpi-config-note-warn");
noteReadonly.classList.remove("bpi-config-note-warn");
};
const warnUnknownChinese = () => {
// 先按现在的高度钉住:警告比原文短也不会缩,下面的表单一动不动
const height = editNotes.getBoundingClientRect().height;
if (height > 0) editNotes.style.minHeight = `${height}px`;
setText(noteSaved, "Nothing was saved: the Chinese name is still the unknown placeholder.");
setText(noteReadonly, "Enter a real Chinese name, then apply again.");
noteSaved.classList.add("bpi-config-note-warn");
noteReadonly.classList.add("bpi-config-note-warn");
clearTimeout(warnTimer);
warnTimer = setTimeout(restoreEditNotes, 2000);
// 光标直接落到中文名那一格,省得再去找
editFields.focus();
};
modal.append(weightPanel, rawPanel, editPanel);
const close = () => {
clearTimeout(warnTimer);
editFields.destroy();
shade.remove();
};
@@ -1547,6 +1725,14 @@ function createPanel(node, textWidget) {
return value !== previous;
};
const apply = () => {
// 不能改权重时这一页只剩配色,直接存色走人
if (!canWeight) {
const colorOnly = saveColor();
if (colorOnly === null) return;
if (colorOnly) render();
close();
return;
}
const raw = String(input.value ?? "").trim();
const numeric = Number(raw);
if (!raw || !Number.isFinite(numeric) || numeric < 0 || numeric > 3) {
@@ -1597,8 +1783,14 @@ function createPanel(node, textWidget) {
};
// 词条页:只改词条,不动当前这行提示词文本,所以不进撤销栈
const applyEdit = async () => {
const payload = editFields.collect();
// 中文名还是「未收录」占位:存下去只会得到一条残词条,直接拦掉不保存
if (String(payload.chinese ?? "").trim() === UNKNOWN_CHINESE) {
warnUnknownChinese();
return;
}
try {
await saveTag(editFields.collect());
await saveTag(payload);
await refreshDictionary();
close();
setStatus(t("Tag saved"), "ok");
@@ -1609,17 +1801,17 @@ function createPanel(node, textWidget) {
const actions = element("div", "bpi-modal-actions");
// 「清除权重」只对权重页有意义,切到别的页就收起来;先存配色再清,别丢改动
let clearWeightButton = null;
if (token.weight !== null) {
if (canWeight && token.weight !== null) {
clearWeightButton = button("Clear weight", () => {
if (saveColor() === null) return;
if (applyTokenWeight(token, null)) close();
}, "bpi-danger");
clearWeightButton.hidden = multiOpen;
clearWeightButton.hidden = startOnRaw;
actions.appendChild(clearWeightButton);
}
actions.append(button("Cancel", close));
// 先声明再让 applyButton 引用它:let 有 TDZ,声明放在闭包之后会有隐患
let activeTab = multiOpen ? "raw" : "weight";
let activeTab = startOnRaw ? "raw" : "weight";
const applyButton = button(t("Apply"), () => {
if (activeTab === "edit") applyEdit();
else if (activeTab === "raw") applyRawEdit();
@@ -1658,12 +1850,18 @@ function createPanel(node, textWidget) {
weightPanel.classList.toggle("bpi-hidden", tab !== "weight");
rawPanel.classList.toggle("bpi-hidden", tab !== "raw");
editPanel.classList.toggle("bpi-hidden", tab !== "edit");
// 离开词条页就把红色警告收回去,别等 2 秒后回来还挂着
if (tab !== "edit") restoreEditNotes();
if (clearWeightButton) clearWeightButton.hidden = tab !== "weight";
};
tabWeight.addEventListener("click", () => {
activeTab = "weight";
showPanel("weight");
setTimeout(() => { input.focus(); input.select(); }, 0);
// 不能改权重时这一页只有配色,光标落到颜色输入框
setTimeout(() => {
if (canWeight) { input.focus(); input.select(); }
else { customInput.focus(); customInput.select(); }
}, 0);
});
tabRaw.addEventListener("click", () => {
activeTab = "raw";
@@ -1675,8 +1873,9 @@ function createPanel(node, textWidget) {
showPanel("edit");
setTimeout(() => editFields.focus(), 0);
});
if (multiOpen) setTimeout(() => { rawEditor.focus(); rawEditor.select(); }, 0);
else setTimeout(() => { input.focus(); input.select(); }, 0);
if (startOnRaw) setTimeout(() => { rawEditor.focus(); rawEditor.select(); }, 0);
else if (canWeight) setTimeout(() => { input.focus(); input.select(); }, 0);
else setTimeout(() => { customInput.focus(); customInput.select(); }, 0);
};
const undoStructuredEdit = () => {
@@ -1729,9 +1928,33 @@ function createPanel(node, textWidget) {
state.textareaListeners = [];
};
// 把当前文本解析成标签列表。render 用它,插入标签后也立刻刷一次:
// 连着插入(双击、回车连按)时下一次要拿新坐标,否则会拿着上一次的
// 位置去切已经变长的文本,落点就偏了。
const parseTokens = (text) => parsePrompt(text, state.index, state.machine, { mode: state.modePreference }).map((token) => {
const override = state.localOverrides.get(token.key);
if (!override) return token;
return {
...token,
chinese: override.text,
source: override.source,
status: "session",
confidence: "medium",
confidenceLabel: "Medium",
confidenceReason: "Changed for this session only; not saved to the dictionary",
};
});
const insertEnglish = (english, keepSearch = false) => {
const current = String(textWidget.value ?? "");
const start = state.englishEditing ? (englishEditor.selectionStart ?? current.length) : current.length;
// 标签视图下没有光标可依,落点跟底部「快捷添加」一致:插到当前选中标签后面,
// 没选中标签才落到末尾。侧边栏「标签管理」复用的就是这份明细表,所以那边
// 搜词库点结果也是走这里。
const anchor = state.englishEditing ? null : state.tokens.find((item) => item.id === state.pinned);
const at = anchor?.end;
const start = state.englishEditing
? (englishEditor.selectionStart ?? current.length)
: (Number.isInteger(at) && at > 0 && at <= current.length ? at : current.length);
const end = state.englishEditing ? (englishEditor.selectionEnd ?? start) : start;
const before = current.slice(0, start);
const after = current.slice(end);
@@ -1739,7 +1962,16 @@ function createPanel(node, textWidget) {
const needsSuffix = after.trim() && !/^[\s,\n]/.test(after);
const insertion = `${needsPrefix ? ", " : ""}${english}${needsSuffix ? ", " : ""}`;
const nextText = before + insertion + after;
// 新标签落在选中标签之后,也就是原来排在它前面的那些标签之后:加完把它设为选中,
// 连着加几个才会依次往后排,不用每次重新点一遍落点。
const nextId = state.englishEditing ? null : state.tokens.filter((token) => token.end <= start).length;
updateText(nextText, state.englishEditing ? start + insertion.length : null);
if (nextId !== null) {
state.tokens = parseTokens(nextText);
state.pinned = nextId;
state.selected.clear();
state.selected.add(nextId);
}
rememberRecent(english);
if (!keepSearch) search.value = "";
renderSearch();
@@ -1914,7 +2146,9 @@ function createPanel(node, textWidget) {
state.editing = {
id: token.id,
key: token.key,
value: ["Not indexed", "Natural-language segment (pending translation or confirmation)"].includes(token.chinese) ? "" : token.chinese,
// 「未收录」「自然语言片段」都只是占位说明,不是译文:改中文时不带出来,
// 免得一个字没动就原样存回个人词库,得到一条中文名叫「未收录」的残词条
value: [UNKNOWN_CHINESE, "自然语言片段(待翻译或确认)"].includes(token.chinese) ? "" : token.chinese,
};
render();
};
@@ -2016,30 +2250,36 @@ function createPanel(node, textWidget) {
const setAssistantBusy = (busy) => {
state.assistantBusy = Boolean(busy);
optimizeChineseButton.disabled = state.assistantBusy;
optimizeButton.disabled = state.assistantBusy;
sortPromptButton.disabled = state.assistantBusy;
};
// 「优化为 Anima」只处理英文提示词本身(中文翻译在侧边栏做),
// 自然语言模式下没有标签流可以重排,「按 Anima 排序」整个不出现;
// 「优化」也随之改走自然语言规则(可以吃中文),标签模式维持「只处理英文」。
const isNaturalPrompt = () => state.modeInfo.mode === "natural";
// 结果不直接写回,先弹预览让用户确认。
const runAnimaOptimize = async () => {
const runOptimize = async () => {
if (state.assistantBusy) return;
const source = String(textWidget.value ?? "").trim();
if (!source) {
setStatus("English prompt is empty; nothing to optimize", "error");
return;
}
if (containsChinese(source)) {
setStatus("“Optimize to Anima” only processes English; translate Chinese in the sidebar first", "error");
const label = "Optimize";
const natural = isNaturalPrompt();
const hasChinese = containsChinese(source);
if (hasChinese && !natural) {
setStatus("“Optimize” only processes English in tag mode; translate Chinese in the sidebar first", "error");
return;
}
const label = "Optimize to Anima";
setAssistantBusy(true);
setStatus(`Running “${label}”…`, "busy");
// 自然语言模式是允许中文的,这里明说一句会顺带翻译,免得用户以为漏处理了
setStatus(`Running “${label}”…${hasChinese ? " (Chinese detected; translating to English first)" : ""}`, "busy");
try {
const output = await runInspectorAssistant("optimize", source);
const punctuationError = animaPunctuationError(output);
openTextPreview("Optimize to Anima", output, null, label);
const output = await runInspectorAssistant(natural ? "optimize_natural" : "optimize", source);
const punctuationError = natural ? "" : animaPunctuationError(output);
openTextPreview(label, output, null, label);
if (punctuationError) {
setStatus(`“${punctuationError}”; please fix it in the preview before confirming`, "error");
} else {
@@ -2067,6 +2307,36 @@ function createPanel(node, textWidget) {
rebuildDictionaryIndex();
};
// 排序写入之后按钮就变成「取消」:点一下把提示词和分类视图一起还原到排序前。
const updateSortButton = () => {
const sorted = Boolean(state.animaSortSnapshot);
// 排序只重排标签流;整段自然语言没有可排的东西,按钮整个不出现。
// 但已经排过序时要留着,否则「取消排序」的入口会跟着一起消失。
sortPromptButton.hidden = isNaturalPrompt() && !state.animaSortSnapshot;
setText(sortPromptButton, sorted ? "Cancel Anima order sort" : "Sort by Anima order");
setTitle(sortPromptButton, sorted
? "Restore the prompt as it was before the Anima sort"
: "press Anima stable sort by recommended categories, one row per non-empty category; natural language and BREAK/AND kept intact");
};
const cancelAnimaSort = () => {
const snapshot = state.animaSortSnapshot;
if (!snapshot) return;
state.animaSortSnapshot = null;
if (snapshot.before !== snapshot.after) {
if (applyFullText(snapshot.before, "Cancel Anima sort")) {
setStatus("Restored the prompt from before the Anima sort", "ok");
}
} else {
// 排序没改动文本(本来就是 Anima 顺序),只切了分类视图:退出视图就回到原样
state.categoryView = false;
setText(englishHint, "");
render();
setStatus("Exited the Anima category view", "ok");
}
updateSortButton();
};
const sortByAnimaOrder = async () => {
if (state.assistantBusy) return;
const source = String(textWidget.value ?? "").trim();
@@ -2082,6 +2352,8 @@ function createPanel(node, textWidget) {
tokens = parsePrompt(source, state.index, state.machine, { mode: "auto" });
const result = sortAnimaPrompt(tokens, { groupLines: true });
if (result.text === source) {
// 本来就是 Anima 顺序,只是切成分类视图:前后文本相同,取消时退出视图即可
state.animaSortSnapshot = { before: source, after: source };
state.categoryView = true;
render();
setText(englishHint, "Category table view | click tags to link; category names are not written to the prompt");
@@ -2201,8 +2473,8 @@ function createPanel(node, textWidget) {
if (state.dupFocusId === token.id) card.classList.add("bpi-dup-hit");
// 选中反馈给整张卡片,而不是只有英文那一行
if (state.pinned === token.id) card.classList.add("bpi-card-linked");
// 多选的虚线框只在真的多选时出现,单选保持原样不打扰
if (state.selected.size > 1 && state.selected.has(token.id)) card.classList.add("bpi-card-multi");
// 多选的虚线框只在真的多选时出现,单选保持原样不打扰;中间没点到的也框上
if (inSelectionSpan(token)) card.classList.add("bpi-card-multi");
const cardColor = tokenCardColor(token);
if (cardColor && !card.classList.contains("bpi-card-linked") && !card.classList.contains("bpi-dup-hit")) card.style.background = cardColor;
card.append(chip, chinese);
@@ -2688,6 +2960,28 @@ function createPanel(node, textWidget) {
// 快捷添加区要等面板搭完才存在,这里先占位,render 里用可选链调用就不会撞 TDZ
let updateQuickTarget = null;
// 命中的片段包一层 mark。全程用文本节点拼,不碰 innerHTML —— 提示词是用户输入,
// 走字符串拼接等于给自己开个注入口子。
const highlightFragment = (text, needle) => {
const source = String(text ?? "");
const fragment = document.createDocumentFragment();
if (!needle) {
fragment.appendChild(document.createTextNode(source));
return fragment;
}
const haystack = source.toLowerCase();
let cursor = 0;
for (let at = haystack.indexOf(needle); at !== -1; at = haystack.indexOf(needle, cursor)) {
if (at > cursor) fragment.appendChild(document.createTextNode(source.slice(cursor, at)));
const hit = document.createElement("mark");
hit.className = "bpi-hit";
hit.textContent = source.slice(at, at + needle.length);
fragment.appendChild(hit);
cursor = at + needle.length;
}
fragment.appendChild(document.createTextNode(source.slice(cursor)));
return fragment;
};
const render = () => {
renderSourceBar();
// 输出管道跟着视图一起刷:任何文本/隐藏列表变化最后都会走到 render,
@@ -2695,19 +2989,12 @@ function createPanel(node, textWidget) {
syncEffectiveOutput();
const text = String(textWidget.value ?? "");
state.lastText = text;
// 排序完又手动改了文本,「回到排序前」会把这些改动一起吞掉,取消入口随之失效
if (state.animaSortSnapshot && text.trim() !== state.animaSortSnapshot.after) state.animaSortSnapshot = null;
state.modeInfo = detectInputMode(text, state.modePreference);
state.tokens = parsePrompt(text, state.index, state.machine, { mode: state.modePreference }).map((token) => {
const override = state.localOverrides.get(token.key);
if (!override) return token;
return {
...token,
chinese: override.text,
source: override.source,
status: "session",
confidence: "medium",
confidenceLabel: "Medium · session-only",
};
});
// 按钮显隐依赖 modeInfo,必须排在它更新之后
updateSortButton();
state.tokens = parseTokens(text);
queueLargeLookup(state.tokens);
// 重复分组要在渲染两个视图之前算好,标签区和明细表都吃这份数据
state.duplicateGroups = duplicateGroups(state.tokens);
@@ -2749,7 +3036,14 @@ function createPanel(node, textWidget) {
if (state.tableFilter === "favorites") return isFavorite(token.term);
return true;
});
renderMachineBatchBar(visibleTokens.filter((token) => token.status === "machine"));
// 查找和上面的筛选是叠加关系:先按来源筛,再按关键词筛,清空关键词回到筛选结果
const query = state.tableQuery.trim().toLowerCase();
const searchedTokens = query
? visibleTokens.filter((token) => token.raw.toLowerCase().includes(query) || token.chinese.toLowerCase().includes(query))
: visibleTokens;
tableSearchCount.textContent = query ? `${searchedTokens.length} / ${visibleTokens.length}` : "";
tableSearchClear.classList.toggle("bpi-visible", Boolean(query));
renderMachineBatchBar(searchedTokens.filter((token) => token.status === "machine"));
const errorsByKey = new Map();
for (const item of state.issues) {
for (const key of item.tokenKeys ?? []) {
@@ -2776,8 +3070,10 @@ function createPanel(node, textWidget) {
table.appendChild(element("div", "bpi-empty", "Enter a prompt in the English text box above; per-tag bilingual mapping will appear here."));
} else if (!visibleTokens.length) {
table.appendChild(element("div", "bpi-empty", "No matching items under the current filter."));
} else if (!searchedTokens.length) {
table.appendChild(element("div", "bpi-empty", `No tag matches “${state.tableQuery.trim()}”`));
} else {
visibleTokens.forEach((token, visibleIndex) => {
searchedTokens.forEach((token, visibleIndex) => {
const row = element("div", `bpi-row bpi-${token.status}`);
if (token.suspectedNatural) row.classList.add("bpi-suspected-natural");
const tokenHidden = isTokenHidden(token);
@@ -2790,22 +3086,32 @@ function createPanel(node, textWidget) {
else if (rowSeverity === "warning") row.classList.add("bpi-has-warning");
row.dataset.tokenId = String(token.id);
if (state.pinned === token.id) row.classList.add("bpi-pinned");
if (state.selected.size > 1 && state.selected.has(token.id)) row.classList.add("bpi-row-multi");
if (inSelectionSpan(token)) row.classList.add("bpi-row-multi");
const englishCell = element("div", "bpi-cell bpi-en");
if (token.status === "machine") englishCell.appendChild(buildRowCheckButton(token));
if (state.tableFilter === "all" && canReorderTokens()) {
// 查找状态下不给拖拽手柄:落点是按可见行算的,藏着行会把标签挪到错误的位置
if (state.tableFilter === "all" && !query && canReorderTokens()) {
englishCell.appendChild(buildRowDragHandle(row, token));
}
if (canHideTokens()) {
englishCell.appendChild(buildRowHideButton(token));
}
const chineseCell = element("div", "bpi-cell bpi-zh");
const englishText = element("span", tokenHidden || tokenSuppressed ? "bpi-hidden-term" : "", token.raw);
const englishText = element("span", tokenHidden || tokenSuppressed ? "bpi-hidden-term" : "");
englishText.appendChild(highlightFragment(token.raw, query));
setTitle(englishText, tokenSuppressed
? `Query: “${token.term}” | “Unique” is on: only the first occurrence reaches the actual output`
: `Query: “${token.term}”${token.weight === null ? "" : ` | weight: ${token.weight}`}`);
englishText.addEventListener("dblclick", (event) => {
event.stopPropagation();
// 词库里已经有这条(内置 / 词库包 / 个人,都算):双击直接开「新增或编辑个人标签」,
// 跟侧边栏词库里点「编辑」是同一个弹窗,别名、分类、模型这些都能一起改。
// 还没入库的(未收录、待确认的机器翻译、特殊语法)走原来的定位到提示词。
if (token.entry) {
// 中文以列表里现在显示的为准:可能只是本次会话临时改过、还没存进词库
openTagDialog({ ...token.entry, chinese: token.chinese }, refreshDictionary);
return;
}
setEnglishEditing(true);
requestAnimationFrame(() => {
englishEditor.focus({ preventScroll: true });
@@ -2843,7 +3149,8 @@ function createPanel(node, textWidget) {
setTimeout(() => { editor.focus(); editor.select(); }, 0);
return; // forEach 里不能用 continue;这里跳过本条剩余的渲染逻辑
}
const chineseText = element("span", "", token.chinese);
const chineseText = element("span", "");
chineseText.appendChild(highlightFragment(token.chinese, query));
setTitle(chineseText, "Double-click to edit Chinese explanation");
chineseText.addEventListener("dblclick", (event) => {
event.stopPropagation();
@@ -2870,22 +3177,11 @@ function createPanel(node, textWidget) {
chineseCell.appendChild(sourceBadge);
}
const confidenceBadge = element("span", `bpi-badge bpi-confidence-${token.confidence}`, token.confidenceLabel);
setTitle(confidenceBadge, token.status === "machine"
? "Machine translation not yet human-verified"
: token.status === "unknown" ? "Dictionary cannot determine" : "Estimated from dictionary source and verification status");
setTitle(confidenceBadge, token.confidenceReason);
chineseCell.appendChild(confidenceBadge);
const actions = element("span", "bpi-inline-actions");
if (token.status === "unknown" && !/[\u3400-\u9fff]/.test(token.term)) {
const searchButton = element("button", "bpi-mini", "Search candidates");
searchButton.addEventListener("click", (event) => {
event.stopPropagation();
openManagerPanel("tag-manager", node.id);
search.value = token.term;
renderSearch();
search.focus({ preventScroll: true });
results.scrollIntoView?.({ block: "nearest" });
});
const addButton = element("button", "bpi-mini", "Add manually");
addButton.addEventListener("click", (event) => {
event.stopPropagation();
@@ -2905,7 +3201,7 @@ function createPanel(node, textWidget) {
event.stopPropagation();
translateToken(token);
});
actions.append(searchButton, addButton, translateButton);
actions.append(addButton, translateButton);
} else if (token.status === "unknown" && /[\u3400-\u9fff]/.test(token.term)) {
const hint = element("span", "bpi-badge", "Use “Edit text” to process it");
setTitle(hint, "Click “Edit text”, then use the inspector's own “Translate” or “Translate & optimize”");
@@ -3218,6 +3514,9 @@ function createPanel(node, textWidget) {
}));
});
filtersBar.appendChild(colorButton);
// 「适应高度」跟着明细表走:明细表被侧边栏「标签管理」复用,开关也就在那边,
// 跟着所选节点换(每个节点一份实例 + 状态存在 node.properties 上)
filtersBar.appendChild(fillSwitchRow);
leftTools.append(
button("Add tag", () => openTagDialog({}, refreshDictionary), "bpi-primary"),
translateAllButton,
@@ -3228,14 +3527,16 @@ function createPanel(node, textWidget) {
render();
}),
);
// 侧边栏「标签管理」复用的就是这份 detailsBody,而它自己就在侧边栏里,
// 再放一个跳去词库的按钮和点顶部「词库」页签是同一件事,故不保留。
rightTools.append(
button("Manager panel", () => openManagerPanel("tags"), "bpi-primary"),
button("Refresh dictionary", refreshDictionary),
);
toolbar.append(leftTools, rightTools);
modeSelect.value = state.modePreference;
searchLine.append(modeSelect, searchLabel, search);
summary.append(counts, modeInfo, status);
detailsBody.append(toolbar, searchLine, results, summary, filtersBar, colorPanel, machineBatchBar, issuesPanel, table, detailsHiddenBar);
detailsBody.append(toolbar, searchLine, results, summary, filtersBar, colorPanel, machineBatchBar, issuesPanel, tableSearch, table, detailsHiddenBar);
// 底部快捷输入:中文先翻译再追加,英文直接追加。只挂在节点面板上,
// 明细表(detailsBody)才会被侧边栏「标签管理」复用,这个输入框不会跟着过去。
const quickSection = element("section", "bpi-quick-section");
@@ -3366,12 +3667,19 @@ function createPanel(node, textWidget) {
event.stopPropagation();
action();
});
setTitle(optimizeChineseButton, "Only optimizes existing English; does not translate");
setTitle(sortPromptButton, "press Anima stable sort by recommended categories, one row per non-empty category; natural language and BREAK/AND kept intact");
bindMirrorAction(optimizeChineseButton, runAnimaOptimize);
bindMirrorAction(sortPromptButton, sortByAnimaOrder);
// bindMirrorAction(tagManagerButton, () => openManagerPanel("tag-manager", node.id));
// setTitle(tagManagerButton, "在侧边栏管理面板中打开本节点的标签翻译与词库搜索");
setTitle(optimizeButton, "Optimize with the rule for the current mode: tag mode tidies the tag flow, natural language mode rewrites it as English sentences");
bindMirrorAction(optimizeButton, runOptimize);
fillSwitch.checked = state.fillHeight;
setTitle(fillSwitchRow, state.fillHeight
? "Back to the automatic height; the prompt area stops following the node"
: "Let the prompt area grow with the node instead of being capped");
panel.classList.toggle("bpi-fill", state.fillHeight);
fillSwitch.addEventListener("change", () => setFillHeight(fillSwitch.checked));
bindMirrorAction(sortPromptButton, () => {
if (state.animaSortSnapshot) cancelAnimaSort();
else sortByAnimaOrder();
});
updateSortButton();
setTitle(editEnglishButton, "Switch between the English text editor and the linked tag view");
setTitle(clearEnglishButton, "Requires a second click to clear the actual English output; after clearing, click the same button or press Ctrl+Z to undo");
editEnglishButton.addEventListener("mousedown", (event) => {
@@ -3424,8 +3732,31 @@ function createPanel(node, textWidget) {
});
search.addEventListener("input", renderSearch);
search.addEventListener("focus", renderSearch);
const clearTableSearch = () => {
tableSearchInput.value = "";
state.tableQuery = "";
render();
};
tableSearchInput.addEventListener("input", () => {
state.tableQuery = tableSearchInput.value;
render();
});
tableSearchInput.addEventListener("keydown", (event) => {
if (event.key !== "Escape" || !tableSearchInput.value) return;
// 节点里按 Esc 会被画布当成取消,这里自己吃掉
event.preventDefault();
event.stopPropagation();
clearTableSearch();
});
tableSearchClear.addEventListener("click", () => {
clearTableSearch();
tableSearchInput.focus({ preventScroll: true });
});
modeSelect.addEventListener("change", () => {
state.modePreference = modeSelect.value;
if (!node.properties || typeof node.properties !== "object") node.properties = {};
if (state.modePreference === "auto") delete node.properties.bpiMode;
else node.properties.bpiMode = state.modePreference;
state.editing = null;
render();
});
@@ -3518,12 +3849,20 @@ function createPanel(node, textWidget) {
return state.collapsedWidgetHeight;
},
getMaxHeight() {
return state.collapsedWidgetHeight;
// 适应高度模式把上限抬高:两者相等时 ComfyUI 只按下限给,拉大节点多出来的空间全变空白
return state.fillHeight ? EXPANDED_WIDGET_MAX_HEIGHT : state.collapsedWidgetHeight;
},
refreshAfterConfigure() {
// Workflow files keep the hidden-tag list in node.properties; make sure
// the bar is drawn even when the text itself did not change.
getHiddenTags();
// 工作流里的模式偏好要先回到下拉,否则按钮显隐会退回「自动」那一套
state.modePreference = storedMode();
modeSelect.value = state.modePreference;
// 工作流里的「适应高度」也要还原回按钮和面板 class
state.fillHeight = storedFillHeight();
panel.classList.toggle("bpi-fill", state.fillHeight);
fillSwitch.checked = state.fillHeight;
scheduleRender(true);
},
syncSourceBar() {
@@ -3617,6 +3956,9 @@ app.registerExtension({
bindUpstreamListener();
const inspector = createPanel(this, textWidget);
this._bilingualPromptInspector = inspector;
// 记下出厂标题。用户在画布上双击节点标题改名后 node.title 会跟着变,
// 侧边栏拿这个当基准,才能判断「这是用户自己起的名字」。
inspector.defaultNodeTitle = this.constructor?.title ?? this.title;
inspectorRegistry.set(String(this.id ?? ""), inspector);
try {
this.addDOMWidget("bilingual_inspector", "bilingual-inspector", inspector.panel, {
+58 -20
View File
@@ -737,6 +737,12 @@ function tagCategoryCounts(data) {
.sort((left, right) => right.count - left.count || left.name.localeCompare(right.name, "zh-CN"));
}
// 插件自己写死的默认分类:词库里存的其实是英文,显示时才翻成中文,存回去再还原,
// 免得同一个「未分类」在词库里分成两条。别的分类都是词库数据,不进翻译层。
const UNCATEGORIZED = "Uncategorized";
const categoryName = (value) => (value === UNCATEGORIZED ? t("Uncategorized") : value);
const isUncategorized = (value) => value === UNCATEGORIZED || value === t("Uncategorized");
// 「分类」输入框:下拉列出已有分类,边打字边收窄,第一项恒为「自定义」。
// 青绿色的「自定义」标识只表示「这个值不是词库里的现成分类」—— 选了自定义才会挂上,
// 手打的值一旦命中某个候选就自动摘掉;编辑历史标签时带进来的陌生值也当场算自定义。
@@ -747,7 +753,7 @@ function buildCategoryPicker({ initial = "", categories = [] } = {}) {
input.id = "bpi-field-category";
input.name = "category";
input.autocomplete = "off";
input.value = initial ?? "";
input.value = categoryName(initial ?? "");
setPlaceholder(input, "Type to filter; ↑↓ to choose");
const badge = element("span", "bpi-custom-badge", "Custom");
badge.hidden = true;
@@ -777,9 +783,9 @@ function buildCategoryPicker({ initial = "", categories = [] } = {}) {
}
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;
name.textContent = row.custom ? t("Custom") : categoryName(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);
@@ -794,14 +800,14 @@ function buildCategoryPicker({ initial = "", categories = [] } = {}) {
};
const update = ({ reveal = true } = {}) => {
const needle = input.value.trim().toLowerCase();
const matches = catalog.filter((entry) => !needle || entry.name.toLowerCase().includes(needle));
const matches = catalog.filter((entry) => !needle || categoryName(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());
const exact = rows.findIndex((row) => !row.custom && categoryName(row.name) === input.value.trim());
active = exact > 0 ? exact : 0;
renderMenu();
};
@@ -810,7 +816,7 @@ function buildCategoryPicker({ initial = "", categories = [] } = {}) {
if (row.custom) {
setCustom(true);
} else {
input.value = row.name;
input.value = categoryName(row.name);
setCustom(false);
}
closeMenu();
@@ -818,7 +824,7 @@ function buildCategoryPicker({ initial = "", categories = [] } = {}) {
};
input.addEventListener("input", () => {
if (custom && catalog.some((entry) => entry.name === input.value.trim())) setCustom(false);
if (custom && catalog.some((entry) => categoryName(entry.name) === input.value.trim())) setCustom(false);
update();
});
input.addEventListener("focus", () => update());
@@ -860,12 +866,13 @@ function buildCategoryPicker({ initial = "", categories = [] } = {}) {
return {
root,
destroy: () => document.removeEventListener("mousedown", onOutside, true),
value: () => input.value.trim(),
// 存回词库的是英文原值:显示用的「未分类」要还原,不然会多出一个中文分类
value: () => (isUncategorized(input.value.trim()) ? UNCATEGORIZED : input.value.trim()),
// 候选是异步取来的,取到之后不能把用户的菜单硬弹开
setCategories: (list) => {
catalog = list;
const current = input.value.trim();
if (current && !catalog.some((entry) => entry.name === current)) setCustom(true);
if (current && !catalog.some((entry) => categoryName(entry.name) === current)) setCustom(true);
// 菜单本来开着就重画,没开就别突然弹出来打断用户
update({ reveal: !menu.hidden });
},
@@ -890,7 +897,8 @@ function buildTagFields(parent, initial = {}, { withTarget = true, readonlyEngli
} 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 chinese = field(parent, "Chinese Name", "chinese", initial?.chinese, "e.g.: apple");
const aliases = field(parent, "Chinese Aliases", "aliases", initial?.aliases?.join(","), "Comma-separated");
const categoryLabel = element("label", "", "Category");
categoryLabel.htmlFor = "bpi-field-category";
@@ -1214,11 +1222,11 @@ function injectBpiStyles() {
const style = document.createElement("style");
style.id = "bpi-styles";
style.textContent = `
: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}
:root{--bpi-field-bg:#fff;--bpi-field-border:#bbb;--bpi-switch-off:#c9ced6;--bpi-switch-knob:#fff;--bpi-switch-off-border:#b0b6bf;--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;--bpi-hit-bg:#fbe49c;--bpi-hit-text:#7a5410}
.dark-theme{--bpi-field-bg:#0b0d10;--bpi-field-border:#333a45;--bpi-switch-off:#39414f;--bpi-switch-knob:#e8edf5;--bpi-switch-off-border:#4c5668;--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;--bpi-hit-bg:#5a4413;--bpi-hit-text:#ffe6a8}
[data-bpi-theme="dark"]{--bpi-field-bg:#0b0d10;--bpi-field-border:#333a45;--bpi-switch-off:#39414f;--bpi-switch-knob:#e8edf5;--bpi-switch-off-border:#4c5668;--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;--bpi-hit-bg:#5a4413;--bpi-hit-text:#ffe6a8}
[data-bpi-theme="light"]{--bpi-field-bg:#fff;--bpi-field-border:#bbb;--bpi-switch-off:#c9ced6;--bpi-switch-knob:#fff;--bpi-switch-off-border:#b0b6bf;--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-hit-bg:#fbe49c;--bpi-hit-text:#7a5410}
.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-panel.bpi-fill{max-height:none}.bpi-panel.bpi-fill > .bpi-english-section{display:flex;flex-direction:column;flex:1 1 auto;min-height:0}.bpi-panel.bpi-fill > .bpi-english-section > *{flex:none}.bpi-panel.bpi-fill > .bpi-english-section > .bpi-english-token-view{flex:1 1 auto;min-height:92px;max-height:none}.bpi-panel.bpi-fill > .bpi-english-section > .bpi-english-editor{flex:1 1 auto;min-height:120px}
.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}
@@ -1231,11 +1239,12 @@ function injectBpiStyles() {
.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-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:0 0 6px 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{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-table-search{flex:none;display:flex;align-items:center;gap:6px;box-sizing:border-box;padding:4px 6px;margin-bottom:-7px;border:1px solid var(--bpi-border-2);border-bottom:0;border-radius:6px 6px 0 0;background-color:var(--bpi-head-bg)}.bpi-table-search-icon{flex:none;display:inline-flex;color:var(--bpi-text-faint)}.bpi-search.bpi-table-search-input{flex:1;min-width:0;padding:3px 6px}.bpi-table-search-count{flex:none;color:var(--bpi-text-muted);font-size:10px;white-space:nowrap}.bpi-table-search-clear{flex:none;display:none;border:0;background-color:transparent;color:var(--bpi-text-muted);cursor:pointer;font-size:13px;line-height:1;padding:1px 3px;border-radius:3px}.bpi-table-search-clear.bpi-visible{display:block}.bpi-table-search-clear:hover{color:var(--bpi-text);background-color:var(--bpi-border-3)}.bpi-hit{background-color:var(--bpi-hit-bg);color:var(--bpi-hit-text);border-radius:2px}
.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}
@@ -1321,7 +1330,7 @@ function injectBpiStyles() {
.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: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-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 [hidden]{display:none!important}.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)}
@@ -1331,8 +1340,14 @@ function injectBpiStyles() {
.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-token-card.bpi-card-multi{position:relative}
.bpi-row.bpi-row-multi{position:relative}
/* 多选的虚线框做成会流动的「蚂蚁线」:四边各一条虚线渐变,靠 background-position 走一个周期,
看着就是虚线绕着选中的提示词转。用伪元素画,不占元素自己的 background-image(行选中态要用它) */
.bpi-card-multi::after,.bpi-row-multi::after{content:"";position:absolute;border-radius:inherit;pointer-events:none;background-image:repeating-linear-gradient(90deg,var(--bpi-link-text) 0 4px,transparent 4px 8px),repeating-linear-gradient(90deg,var(--bpi-link-text) 0 4px,transparent 4px 8px),repeating-linear-gradient(180deg,var(--bpi-link-text) 0 4px,transparent 4px 8px),repeating-linear-gradient(180deg,var(--bpi-link-text) 0 4px,transparent 4px 8px);background-size:100% 1px,100% 1px,1px 100%,1px 100%;background-position:0 0,0 100%,0 0,100% 0;background-repeat:repeat-x,repeat-x,repeat-y,repeat-y;animation:bpi-ants .6s linear infinite}
.bpi-card-multi::after{inset:-2px}
.bpi-row-multi::after{inset:0}
@keyframes bpi-ants{to{background-position:8px 0,-8px 100%,0 -8px,100% 8px}}
.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)}
@@ -1368,6 +1383,8 @@ function injectBpiStyles() {
.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-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-config-note-warn{color:var(--bpi-status-error)}
.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}
@@ -1390,8 +1407,29 @@ function injectBpiStyles() {
.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}
.bpm-section .bpi-pack-list{flex:1;min-height:0;max-height:none;grid-template-columns:1fr}
.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)}
.bpi-form-actions{display:flex;justify-content:flex-end;gap:7px;margin-top:12px;flex-wrap:wrap}
/* 助手设置在 ComfyUI「设置」面板里:原生那一行是「标签 + 控件」,把标签收掉、控件占满整行 */
.bpi-settings-form{box-sizing:border-box;width:100%;padding:2px 0 6px}.bpi-settings-form h3{margin:0 0 10px;font-size:15px}
div:has(> .form-input > div > .bpi-settings-form){align-items:flex-start}
div:has(> .form-input > div > .bpi-settings-form) > .form-label{display:none}
div:has(> .form-input > div > .bpi-settings-form) > .form-input{flex:1 1 0%;min-width:0}
.bpi-switch-row{display:flex;align-items:center;gap:8px;cursor:pointer;user-select:none;width:fit-content}
.bpi-switch-row input{position:absolute;width:0;height:0;opacity:0;margin:0}
.bpi-switch-track{position:relative;flex:0 0 auto;width:34px;height:18px;border-radius:9px;background-color:var(--bpi-switch-off);border:1px solid var(--bpi-switch-off-border);transition:background-color .16s ease,border-color .16s ease}
.bpi-switch-thumb{position:absolute;top:2px;left:2px;width:12px;height:12px;border-radius:50%;background-color:var(--bpi-switch-knob);transition:transform .16s ease}
.bpi-switch-row:hover .bpi-switch-track{border-color:var(--bpi-border-2)}
.bpi-switch-row input:checked + .bpi-switch-track{background-color:var(--bpi-danger-bg);border-color:var(--bpi-danger-border)}
.bpi-switch-row input:checked + .bpi-switch-track .bpi-switch-thumb{transform:translateX(16px)}
.bpi-switch-row input:focus-visible + .bpi-switch-track{outline:2px solid var(--bpi-row-pinned-ring);outline-offset:1px}
.bpi-switch-text{font-size:12px;color:var(--bpi-text-muted)}
.bpi-switch-row input:checked ~ .bpi-switch-text{color:var(--bpi-status-error)}
.bpi-switch-hint{display:none;font-size:11px;color:var(--bpi-status-error)}
.bpi-switch-row input:checked ~ .bpi-switch-hint{display:inline}.bpi-switch-row.bpi-switch-accent input:checked + .bpi-switch-track{background-color:var(--bpm-accent);border-color:var(--bpm-accent-border)}.bpi-switch-row.bpi-switch-accent input:checked ~ .bpi-switch-text{color:var(--bpi-text)}
/* 设置面板里的输入框 / 下拉:直接用 ComfyUI 的字段底色与描边,取不到才用兜底值,
免得暗色下我们的蓝灰底比面板亮一截、显得突兀 */
.bpi-settings-form .bpi-form input,.bpi-settings-form .bpi-form select,.bpi-settings-form .bpi-form textarea{background-color:var(--p-textfield-background,var(--p-inputtext-background,var(--bpi-field-bg)));border-color:var(--p-textfield-border-color,var(--p-inputtext-border-color,var(--bpi-field-border)))}
.bpi-settings-form .bpi-form input:focus,.bpi-settings-form .bpi-form select:focus,.bpi-settings-form .bpi-form textarea:focus{border-color:var(--p-primary-color,#4ca7e8)}
.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}
+7 -2
View File
@@ -13,7 +13,9 @@ function injectStyles() {
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}",
// holder 才是交给画布的 widget 元素:节点拖大时它被拉伸,胶囊自己锁死高度不跟着变
`.bpi-dup-holder{display:flex;align-items:center;justify-content:flex-start;width:100%;min-height:${PILL_HEIGHT}px;overflow:hidden}`,
`.bpi-dup-pill{display:inline-flex;align-items:center;gap:6px;padding:3px 10px;border-radius:999px;font-size:12px;line-height:1.5;height:${PILL_HEIGHT}px;box-sizing:border-box;flex:none;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}",
@@ -117,6 +119,8 @@ app.registerExtension({
const toggle = this.widgets?.find((widget) => widget.name === "keep_first");
if (toggle) toggle.label = "只保留第一个";
if (this.inputs?.[0]) this.inputs[0].label = "提示词";
const holder = document.createElement("div");
holder.className = "bpi-dup-holder";
const pill = document.createElement("div");
pill.className = "bpi-dup-pill bpi-dup-idle";
const dot = document.createElement("span");
@@ -124,9 +128,10 @@ app.registerExtension({
const textNode = document.createElement("span");
textNode.className = "bpi-dup-text";
pill.append(dot, textNode);
holder.append(pill);
this._duplicateCheck = { pill, dot, textNode, count: 0, connected: false };
try {
this.addDOMWidget("bpi_duplicate_status", "bpi-duplicate-status", pill, {
this.addDOMWidget("bpi_duplicate_status", "bpi-duplicate-status", holder, {
getMinHeight: () => PILL_HEIGHT,
getMaxHeight: () => PILL_HEIGHT,
hideOnZoom: false,
+48 -21
View File
@@ -103,17 +103,21 @@ const ZH = {
"Highest": "最高",
Translate: "翻译",
"Translate & optimize": "翻译并优化",
"Optimize to Anima": "优化为 Anima",
Optimize: "优化",
"Fit height": "适应高度",
"Let the prompt area grow with the node instead of being capped": "让提示词区跟着节点一起变高,不再卡在固定的高度上限",
"Back to the automatic height; the prompt area stops following the node": "回到自动高度,提示词区不再跟着节点变高",
"Re-translate": "重新翻译",
"Translate all unknown": "翻译全部未收录",
"Sort by Anima order": "按 Anima 顺序排序",
"Confirm write English": "确认写入英文",
"Cancel Anima order sort": "取消 Anima 顺序排序",
"Cancel Anima sort": "取消 Anima 排序",
"Confirm write new prompt": "确认写入新提示词",
"Confirm & save": "确认并保存",
"Save to dict": "存入词库",
"Save to Personal Dictionary": "保存到个人词库",
"Add tag": "添加标签",
"New Tag": "新建标签",
"Search candidates": "搜索候选",
"Dictionary search": "词库搜索",
"Enter Chinese, English, alias, or concept; click a result to insert the English tag":
"输入中文、英文、别名或概念;点击结果即可插入英文标签",
@@ -144,6 +148,10 @@ const ZH = {
"Personal dictionary": "个人词库",
"No inspector node on canvas": "画布上没有检查器节点",
"No matching items under the current filter.": "当前筛选条件下没有匹配项。",
"Find in the current prompt": "在当前提示词里查找",
"Clear search": "清除查找",
"No tag matches {}": "没有匹配“…”的标签",
"Filter the detail list by keyword; matches the English original or the Chinese explanation; the prompt itself never changes": "按关键词筛明细表;英文原文和中文解释都参与匹配,提示词本身不会变",
"No unknown tags currently": "当前没有未收录标签",
"Edit directly": "直接编辑",
@@ -171,8 +179,6 @@ const ZH = {
"No content change": "内容没有变化",
"Natural-language segments only support whole-segment editing; tags can still be deleted individually":
"自然语言片段只支持整段编辑;标签仍可单独删除",
"Only tags support individual weights; use text editing for natural language":
"只有标签支持单独权重;自然语言请用文本编辑",
"Weight must be between 0 and 3": "权重必须在 0 到 3 之间",
"Use “Edit text” to process it": "请用「编辑文本」处理",
"Click “Edit text”, then use the inspector's own “Translate” or “Translate & optimize”":
@@ -313,6 +319,7 @@ const ZH = {
Models: "适用模型",
"Recommended Weight": "推荐权重",
"e.g.: looking at viewer": "例如:looking at viewer",
"e.g.: apple": "例如:苹果",
"e.g.: pose, camera, style": "例如:pose、camera、style",
// ── 弹窗:保存 / 编辑收藏 ────────────────────────────────────────────
@@ -327,6 +334,8 @@ const ZH = {
"Tag saved": "词条已保存",
"Entry info": "词条信息",
"Edits go to the personal dictionary and override same-name entries.": "改动存入个人词库,会覆盖同名词条的显示。",
"Nothing was saved: the Chinese name is still the unknown placeholder.": "没有保存:中文名称还是「未收录」,这样存进去只会得到一条残词条。",
"Enter a real Chinese name, then apply again.": "先填一个真正的中文名称,再点应用。",
"Edit Favorite": "编辑收藏",
"Keep the current image, or pick a new one to replace it": "不换图就保持原样,选一张新的会替换掉原来的",
@@ -346,7 +355,6 @@ const ZH = {
Packs: "词包",
Favorites: "收藏",
"Assistant Settings": "助手设置",
"Manager panel": "管理面板",
"Add or select a BilingualPromptInspector node to see its tag manager here.":
"添加或选中画布上的 BilingualPromptInspector 节点,即可在此看到它的标签管理。",
"Tag translation, dictionary search and per-tag operations edit the selected node below.":
@@ -393,18 +401,17 @@ const ZH = {
"Recognizing tag categories…": "正在识别标签分类…",
"Loading dictionary…": "正在加载词库…",
"Loading settings...": "正在加载设置…",
"Settings load on first visit...": "首次进入时加载设置…",
// ── 助手设置 ────────────────────────────────────────────────────────
// ── 助手设置(ComfyUI「设置」面板里内嵌的那一项)─────────────────────
"Translation Service": "翻译服务",
"Dictionary only (no API)": "仅词库(不需要接口)",
"Baidu Translate": "百度翻译",
"AI (OpenAI-compatible / Ollama)": "AI(OpenAI 兼容 / Ollama)",
"Translation/explanation uses this choice. \"Translate & Optimize\" and \"Optimize to Anima\" always use AI, unrelated here. To test AI connection, temporarily set translation service to AI then click test (key preserved).":
"翻译/解释按这里的选择走。「翻译并优化」和「优化为 Anima」始终走 AI,与此无关。要测试 AI 连接,请临时把翻译服务切到 AI 再点测试(密钥会保留)。",
"Only this service's settings are shown below. \"Translate & Optimize\" and \"Optimize\" always use AI: choose OpenAI-compatible API or Ollama above to configure or test them. Switching service never clears saved keys.":
"下面只显示当前所选服务的设置。「翻译并优化」和「优化」始终走 AI:要配置或测试它们,请在上方选择「OpenAI 兼容接口」或「Ollama」。切换服务不会清掉已保存的密钥。",
"APP ID": "APP ID",
"Secret Key": "密钥",
"Clear saved Baidu secret": "清除已保存的百度密钥",
"Takes effect after saving": "保存后生效",
"Baidu Cloud \"General Text Translation\" APP ID": "百度智能云「通用文本翻译」的 APP ID",
"Baidu Cloud \"General Text Translation\" Secret Key": "百度智能云「通用文本翻译」的密钥",
"QPS (requests/second)": "QPS(每秒请求数)",
@@ -416,14 +423,21 @@ const ZH = {
"OpenAI-compatible API": "OpenAI 兼容接口",
"Ollama (local)": "Ollama(本地)",
"API URL": "API 地址",
"Optional for Ollama; e.g.: https://host/v1": "Ollama 可选;例如 https://host/v1",
"provider model ID": "服务商的模型 ID",
"e.g.: qwen3:8b": "例如:qwen3:8b",
"Optional for local LM Studio": "本地 LM Studio 可选",
"Remote addresses must use https; http is only accepted for this machine. Private, reserved and link-local addresses are rejected. Keys are never sent in clear text over the network.":
"远端地址必须使用 https;只有本机地址可以用 http。内网、保留和链路本地地址一律拒绝。密钥绝不会明文发到网络上。",
"Ollama only accepts a loopback address on this machine; anything else is rejected before a request is sent.":
"Ollama 只能填本机回环地址;其它地址在发出请求前就会被拒绝。",
"Model Name": "模型名称",
"e.g.: qwen3:8b or provider model ID": "例如:qwen3:8b 或服务商的模型 ID",
"Use LM Studio Local Preset": "使用 LM Studio 本地预设",
"Connects to localhost:1234 by default; no external API required.": "默认连接 localhost:1234,不需要外部接口。",
"LM Studio default URL filled; select a loaded model ID, then Save & Test": "已填入 LM Studio 默认地址;选择已加载的模型 ID 后保存并测试",
"Use Ollama Local Preset": "使用 Ollama 本地预设",
"Connects to localhost:11434 by default; no external API required.": "默认连接 localhost:11434,不需要外部接口。",
"Ollama default URL filled; select a pulled model ID, then Save & Test": "已填入 Ollama 默认地址;选择已拉取的模型 ID 后保存并测试",
"API Key": "API 密钥",
"Optional for local Ollama / LM Studio": "本地 Ollama / LM Studio 可选",
"Clear saved API Key": "清除已保存的 API 密钥",
"Saved; leave empty to keep": "已保存;留空则保持不变",
Temperature: "温度",
@@ -444,11 +458,13 @@ const ZH = {
// ── 语言设置(本功能新增) ────────────────────────────────────────────
Language: "语言",
Chinese: "中文",
English: "英文",
"Chinese (Simplified)": "简体中文",
"Follow ComfyUI language setting": "跟随 ComfyUI 语言设置",
"Choose the language for this plugin's own panels. \"Follow ComfyUI\" reads the ComfyUI language setting; \"Chinese\" and \"English\" force one. Applied to open panels right away; reload the page if anything still shows the old language.":
"选择本插件面板的语言。「跟随 ComfyUI 语言设置」会随 ComfyUI 的语言切换;「中文」「英文」为强制指定。已打开的面板会立即更新;若个别位置仍是旧语言,刷新页面即可。",
"Language saved": "语言设置已保存",
"Appearance & Language": "语言与外观",
"Appearance and language saved": "外观与语言已保存",
// ── 标签来源 / 分类 / 模式 ──────────────────────────────────────────
"Inspector assistant": "检查器助手",
@@ -481,6 +497,9 @@ const ZH = {
"English prompt tag view": "英文提示词标签视图",
"Anima official sort": "Anima 官方顺序",
"Anima official order preview": "Anima 官方顺序预览",
"Restore the prompt as it was before the Anima sort": "恢复到 Anima 排序前的提示词(分类视图一起退出)",
"Restored the prompt from before the Anima sort": "已恢复到 Anima 排序前的提示词",
"Exited the Anima category view": "已退出 Anima 分类视图",
"Empty input": "输入为空",
"Copied": "已复制",
@@ -605,6 +624,9 @@ const ZH = {
"Translation Rule": "翻译规则",
"Translate & Optimize Rule": "翻译并优化规则",
"Optimize Rule": "优化规则",
"Natural Language Rule": "自然语言规则",
"Optimize with the rule for the current mode: tag mode tidies the tag flow, natural language mode rewrites it as English sentences":
"按当前模式对应的规则优化:标签模式整理标签流,自然语言模式改写为英文自然语句",
"Connection OK: {}": "连接正常:{}",
// 确认对话框的默认确认按钮(confirmDialog 的 confirmLabel 缺省值),
// 不是「连接正常」的 OK——那个是整句键 "Connection OK: {}"。
@@ -618,8 +640,6 @@ const ZH = {
"Delete {}? Reference image will be deleted too.": "删除「{}」?参考图也会一并删除。",
"Node #{}": "节点 #{}",
"Node #{}: {}": "节点 #{}:{}",
"Bilingual Prompt Inspector: Tag Manager, Dictionary, Packs & Assistant Settings":
"提示词翻译与管理:标签管理、词库、词包与助手设置",
// ── 语法诊断(parser.js)────────────────────────────────────────────
"Manual override": "手动覆盖",
@@ -744,9 +764,15 @@ const ZH = {
// ── 第三批:自由文本变量包裹后的组合形态与遗漏项 ──────────────────────
"Low": "低",
"Low · pending": "低 · 待确认",
"Medium · pending": "中 · 待确认",
"Medium · session-only": "中 · 仅本次会话",
"Medium": "中",
"High": "高",
"Not in any dictionary, so it cannot be judged": "词库里查不到,没法判断",
"Machine translation, not saved to the dictionary or confirmed yet": "机器翻译,还没存进词库,也没人工确认",
"Syntax kept as-is; nothing to translate": "语法保留项,本来就不用翻译",
"In the dictionary, but not marked as verified": "词库里有,但还没标成已校验",
"In the dictionary, verified, category {}": "已在词库里,已校验,分类「{}」",
"In the dictionary, but no category set": "词库里有,但还没填分类",
"Changed for this session only; not saved to the dictionary": "只改了本次会话,没存进词库",
"★ Unfavorite": "★ 取消收藏",
"☆ Favorite": "☆ 收藏",
"Paused, waiting for confirmation…": "已暂停,等待确认…",
@@ -775,7 +801,8 @@ const ZH = {
"Click a tag to link it with the detail table below; select then press Delete": "点击标签可与下方明细表联动;选中后按 Delete 删除",
"English prompt is empty; nothing to optimize": "英文提示词是空的,没有可优化的内容",
"“Optimize to Anima” only processes English; translate Chinese in the sidebar first": "「优化为 Anima」只处理英文;中文请先在侧边栏翻译",
"“Optimize” only processes English in tag mode; translate Chinese in the sidebar first": "「优化」在标签模式下只处理英文;中文请先在侧边栏翻译,或把模式切到「自然语言」",
"(Chinese detected; translating to English first)": "(检测到中文,会先译成英文)",
"{} done; please confirm in the preview window": "「{}」完成;请在预览窗口确认",
"{}; please fix it in the preview before confirming": "「{}」;请在预览里改好再确认",
};
+346 -151
View File
@@ -48,7 +48,6 @@ const SECTIONS = [
["tag-manager", "Tag Manager"],
["tags", "Dictionary"],
["packs", "Packs"],
["assistant", "Assistant Settings"],
["favorites", "Favorites"],
];
@@ -58,7 +57,6 @@ const manager = {
selected: new Set(),
section: "tags",
loaded: false,
assistantLoaded: false,
favoritesLoaded: false,
favorites: [],
favoritesChangeHandler: null,
@@ -136,6 +134,9 @@ function rebuildSourceOptions() {
["all", "All Packs"],
["builtin", "Built-in pack enabled"],
["personal", "Personal"],
// 保存时选了「自然语言词库」的条目:和个人词库共用文件,只靠 natural 标记区分,
// 与分类叫「自然语言」的标签是两回事(后者是 Anima 分类名)。
["natural", "Natural Language Dictionary"],
...(manager.data.large_dictionary?.available ? [["large", "Danbooru Large Dict"]] : []),
["machine", "Pending Machine"],
["favorites", "Favorites"],
@@ -149,6 +150,13 @@ function rebuildSourceOptions() {
refs.source.value = options.some(([value]) => value === current) ? current : "all";
}
// 自然语言跟普通标签分类不是一个层级,按拼音排会掉到很后面;
// 词库里这个分类名中英两种写法都有(Anima 分类是英文,手填的多是中文),都认。
function isNaturalCategory(name) {
const value = String(name ?? "").trim().toLowerCase();
return value === "自然语言" || value === "natural language";
}
function rebuildCategoryOptions() {
const current = refs.category.value;
refs.category.replaceChildren();
@@ -156,7 +164,7 @@ function rebuildCategoryOptions() {
all.value = "all";
refs.category.appendChild(all);
const categories = [...new Set(managerRows().map((row) => row.tag.category).filter(Boolean))].sort((a, b) => a.localeCompare(b, "zh-CN"));
for (const category of categories) {
for (const category of [...categories.filter(isNaturalCategory), ...categories.filter((name) => !isNaturalCategory(name))]) {
const option = element("option", "", category);
option.value = category;
refs.category.appendChild(option);
@@ -218,9 +226,10 @@ function renderRows() {
const category = refs.category.value;
const source = refs.source.value;
const rows = managerRows().filter((row) => {
if (source === "natural" && !row.tag.natural) return false;
if (source === "favorites" && !isFavorite(row.tag.english)) return false;
if (source.startsWith("pack:") && row.tag.pack_id !== source.slice(5)) return false;
if (!["all", "favorites"].includes(source) && !source.startsWith("pack:") && row.kind !== source) return false;
if (!["all", "favorites", "natural"].includes(source) && !source.startsWith("pack:") && row.kind !== source) return false;
if (category !== "all" && row.tag.category !== category) return false;
if (query) {
const haystack = [row.tag.english, row.tag.chinese, row.tag.category, ...(row.tag.aliases ?? [])].join("\n").toLowerCase();
@@ -575,10 +584,21 @@ function openImportPreview(payload) {
modal.addEventListener("mousedown", (event) => event.stopPropagation());
}
async function buildAssistantSection(section) {
section.replaceChildren();
// 外观 / 语言从助手设置里拆出来,和它并列挂在「设置」面板的插件分类下。
// 改动立刻生效并单独保存,不需要去点助手设置的保存按钮。
let appearanceSettingsNode = null;
function appearanceControl() {
if (appearanceSettingsNode) return appearanceSettingsNode;
appearanceSettingsNode = element("div", "bpi-settings-form");
appearanceSettingsNode.appendChild(element("h3", "", "Appearance & Language"));
const loading = element("div", "bpm-loading", "Loading settings...");
section.appendChild(loading);
appearanceSettingsNode.appendChild(loading);
buildAppearanceForm(appearanceSettingsNode, loading);
return appearanceSettingsNode;
}
async function buildAppearanceForm(host, loading) {
let config;
try {
config = await getAssistantConfig();
@@ -587,110 +607,11 @@ async function buildAssistantSection(section) {
loading.classList.add("bpi-danger-text");
return;
}
manager.assistantLoaded = true;
applyBpiAppearance(config.appearance);
section.replaceChildren();
loading.remove();
const form = element("form", "bpi-form");
form.addEventListener("submit", (event) => event.preventDefault());
const error = element("div", "bpi-status");
error.style.gridColumn = "1 / -1";
const status = element("div", "bpi-status");
status.style.gridColumn = "1 / -1";
// 翻译服务:翻译/解释按此选择;“翻译并优化”“优化为 Anima”恒走 AI
const serviceLabel = element("label", "", "Translation Service");
const service = element("select");
for (const [value, label] of [
["dictionary", "Dictionary only (no API)"],
["baidu", "Baidu Translate"],
["ai", "AI (OpenAI-compatible / Ollama)"],
]) {
const option = element("option", "", label);
option.value = value;
service.appendChild(option);
}
service.value = config.translate_service;
form.append(serviceLabel, service);
const serviceNote = element("div", "bpi-config-note", 'Translation/explanation uses this choice. "Translate & Optimize" and "Optimize to Anima" always use AI, unrelated here. To test AI connection, temporarily set translation service to AI then click test (key preserved).');
serviceNote.style.gridColumn = "1 / -1";
form.append(serviceNote);
// 百度翻译(独立留存,切翻译服务不清)
const baiduHead = element("div", "bpm-toolbar");
baiduHead.style.gridColumn = "1 / -1";
baiduHead.append(element("strong", "", "Baidu Translate"));
form.append(baiduHead);
const baiduAppId = field(form, "APP ID", "assistant-baidu-appid", config.baidu_appid, 'Baidu Cloud "General Text Translation" APP ID');
const baiduSecretKey = field(form, "Secret Key", "assistant-baidu-secret", "", config.baidu_secret_key_configured ? "Saved; leave empty to keep" : 'Baidu Cloud "General Text Translation" Secret Key');
baiduSecretKey.type = "password";
const clearBaiduLine = element("label", "bpm-toolbar");
const clearBaiduSecretKey = element("input");
clearBaiduSecretKey.type = "checkbox";
clearBaiduLine.append(clearBaiduSecretKey, element("span", "", "Clear saved Baidu secret"));
const baiduSpacer = element("span");
form.append(baiduSpacer, clearBaiduLine);
const baiduQps = field(form, "QPS (requests/second)", "assistant-baidu-qps", config.baidu_qps ?? 1, "1");
baiduQps.type = "number";
baiduQps.min = "0.1";
baiduQps.max = "50";
baiduQps.step = "0.5";
const baiduQpsHelp = [
"Baidu rate-limits General Text Translation by account tier. Enter your tier's number and the plugin queues requests to match:",
"· Unverified / Standard: 1",
"· Personal verified: 10",
"· Enterprise verified: higher, see the Baidu AI Cloud console",
"Find your tier under Baidu AI Cloud → Machine Translation → General Text Translation.",
"Too high and Baidu still returns 54003 (too many requests); too low is only slower, never an error.",
"If unsure, use 1.",
].join("\n");
form.querySelector('label[for="bpi-field-assistant-baidu-qps"]')?.appendChild(helpMark(baiduQpsHelp));
const baiduQpsNote = element("div", "bpi-config-note", "How many requests per second Baidu may receive at most. Batch translation queues by this value and never exceeds it.");
baiduQpsNote.style.gridColumn = "1 / -1";
form.append(baiduQpsNote);
// AI 服务(优化恒走它;翻译在选 AI 时也走它)
const aiHead = element("div", "bpm-toolbar");
aiHead.style.gridColumn = "1 / -1";
aiHead.append(element("strong", "", "AI Service (Translate & Optimize / Optimize / AI Translate)"));
form.append(aiHead);
const aiProviderLabel = element("label", "", "AI Backend");
const aiProvider = element("select");
for (const [value, label] of [["openai_compatible", "OpenAI-compatible API"], ["ollama", "Ollama (local)"]]) {
const option = element("option", "", label);
option.value = value;
aiProvider.appendChild(option);
}
aiProvider.value = config.ai_provider;
form.append(aiProviderLabel, aiProvider);
const baseUrl = field(form, "API URL", "assistant-base-url", config.ai_base_url, "Optional for Ollama; e.g.: https://host/v1");
const model = field(form, "Model Name", "assistant-model", config.ai_model, "e.g.: qwen3:8b or provider model ID");
const presetLine = element("div", "bpm-toolbar");
presetLine.style.gridColumn = "1 / -1";
const lmStudioPreset = button("Use LM Studio Local Preset", () => {
aiProvider.value = "openai_compatible";
baseUrl.value = "http://127.0.0.1:1234/v1";
setText(error, "LM Studio default URL filled; select a loaded model ID, then Save & Test");
error.dataset.kind = "ok";
});
presetLine.append(lmStudioPreset, element("span", "bpi-config-note", "Connects to localhost:1234 by default; no external API required."));
form.append(presetLine);
const apiKey = field(form, "API Key", "assistant-api-key", "", config.ai_api_key_configured ? "Saved; leave empty to keep" : "Optional for local Ollama / LM Studio");
apiKey.type = "password";
const clearLine = element("label", "bpm-toolbar");
const clearApiKey = element("input");
clearApiKey.type = "checkbox";
clearLine.append(clearApiKey, element("span", "", "Clear saved API Key"));
const apiKeySpacer = element("span");
form.append(apiKeySpacer, clearLine);
const temperature = field(form, "Temperature", "assistant-temperature", config.ai_temperature, "0–2");
temperature.type = "number";
temperature.min = "0";
temperature.max = "2";
temperature.step = "0.05";
const timeout = field(form, "Timeout (s)", "assistant-timeout", config.ai_timeout_seconds, "5–600");
timeout.type = "number";
timeout.min = "5";
timeout.max = "600";
// 外观模式
const appearanceLabel = element("label", "", "Appearance");
const appearanceSelect = element("select");
for (const [value, label] of [
@@ -718,31 +639,279 @@ async function buildAssistantSection(section) {
languageSelect.appendChild(option);
}
languageSelect.value = config.language || "auto";
form.append(languageLabel, languageSelect);
const languageNote = element("div", "bpi-config-note", "Choose the language for this plugin's own panels. \"Follow ComfyUI\" reads the ComfyUI language setting; \"Chinese\" and \"English\" force one. Applied to open panels right away; reload the page if anything still shows the old language.");
languageNote.style.gridColumn = "1 / -1";
form.append(languageNote);
form.append(languageLabel, languageSelect, languageNote, status);
host.appendChild(form);
// 规则
const addRule = (labelText, value) => {
// 只提交这两项:后端 update 对没给的字段一律保留原值,不会动翻译服务和密钥
const save = async () => {
try {
const saved = await saveAssistantConfig({ appearance: appearanceSelect.value, language: languageSelect.value });
applyBpiAppearance(saved.appearance);
setLanguageMode(saved.language);
setText(status, "Appearance and language saved");
status.dataset.kind = "ok";
} catch (saveError) {
setText(status, saveError.message);
status.dataset.kind = "error";
}
};
appearanceSelect.addEventListener("change", save);
languageSelect.addEventListener("change", save);
}
// 助手设置直接长在 ComfyUI「设置」面板里(入口见下方 registerExtension 的 settings),
// 这一个函数返回的就是设置项右侧那块控件。
// 面板每次渲染都会重建控件容器,所以整块 DOM 只建一次后复用,输入到一半的内容不会丢。
let assistantSettingsNode = null;
function assistantSettingsControl() {
if (assistantSettingsNode) return assistantSettingsNode;
assistantSettingsNode = element("div", "bpi-settings-form");
assistantSettingsNode.appendChild(element("h3", "", "Assistant Settings"));
const loading = element("div", "bpm-loading", "Loading settings...");
assistantSettingsNode.appendChild(loading);
buildAssistantForm(assistantSettingsNode, loading);
return assistantSettingsNode;
}
async function buildAssistantForm(host, loading) {
let config;
try {
config = await getAssistantConfig();
} catch (error) {
setText(loading, error.message);
loading.classList.add("bpi-danger-text");
return;
}
applyBpiAppearance(config.appearance);
loading.remove();
const form = element("form", "bpi-form");
form.addEventListener("submit", (event) => event.preventDefault());
const error = element("div", "bpi-status");
error.style.gridColumn = "1 / -1";
// 翻译服务:翻译/解释按此选择;“翻译并优化”“优化”恒走 AI
const serviceLabel = element("label", "", "Translation Service");
const service = element("select");
for (const [value, label] of [
["dictionary", "Dictionary only (no API)"],
["baidu", "Baidu Translate"],
["openai_compatible", "OpenAI-compatible API"],
["ollama", "Ollama (local)"],
]) {
const option = element("option", "", label);
option.value = value;
service.appendChild(option);
}
// 后端已把旧的 "ai" 拆成两个具体服务;这里再兜一次,避免下拉落空
service.value = config.translate_service === "ai" ? config.ai_provider || "openai_compatible" : config.translate_service;
form.append(serviceLabel, service);
const serviceNote = element("div", "bpi-config-note", 'Only this service\'s settings are shown below. "Translate & Optimize" and "Optimize" always use AI: choose OpenAI-compatible API or Ollama above to configure or test them. Switching service never clears saved keys.');
serviceNote.style.gridColumn = "1 / -1";
form.append(serviceNote);
// 按翻译服务分组显示:隐藏的字段不提交也不清空,切换前后值都在
const baiduOnly = [];
// AI 两个后端各一组:地址规则不同(Ollama 只认本机,OpenAI 远端必须 https),
// 规则也各一套 —— 本地小模型和云端大模型要写的提示词不一样
const openaiOnly = [];
const ollamaOnly = [];
// AI 整段(标题 + 后端下拉 + 两套具体配置 + 规则)只在翻译服务就是 AI 时出现;
// 选词库 / 百度时一段都不显示,但优化仍会走上次选过的那个后端
const aiOnly = [];
// 翻译服务里属于 AI 的两个值:选了它们,翻译服务自己就是 AI 后端
const AI_SERVICES = new Set(["openai_compatible", "ollama"]);
const currentBackend = () => (AI_SERVICES.has(service.value) ? service.value : aiProvider.value);
const addField = (list, ...args) => {
const input = field(form, ...args);
list.push(form.querySelector(`label[for="${input.id}"]`), input);
return input;
};
// 清除已保存的密钥:开关样式,打开后要保存才生效。返回的 input 上挂了整行(`.row`)
const clearSwitch = (labelText) => {
const row = element("label", "bpi-switch-row");
const input = element("input");
input.type = "checkbox";
const track = element("span", "bpi-switch-track");
track.appendChild(element("span", "bpi-switch-thumb"));
row.append(input, track, element("span", "bpi-switch-text", labelText), element("span", "bpi-switch-hint", "Takes effect after saving"));
input.row = row;
return input;
};
// 百度翻译(独立留存,切翻译服务不清)
const baiduHead = element("div", "bpm-toolbar");
baiduHead.style.gridColumn = "1 / -1";
baiduHead.append(element("strong", "", "Baidu Translate"));
form.append(baiduHead);
baiduOnly.push(baiduHead);
const baiduAppId = addField(baiduOnly, "APP ID", "assistant-baidu-appid", config.baidu_appid, 'Baidu Cloud "General Text Translation" APP ID');
const baiduSecretKey = addField(baiduOnly, "Secret Key", "assistant-baidu-secret", "", config.baidu_secret_key_configured ? "Saved; leave empty to keep" : 'Baidu Cloud "General Text Translation" Secret Key');
baiduSecretKey.type = "password";
const clearBaiduSecretKey = clearSwitch("Clear saved Baidu secret");
const baiduSpacer = element("span");
form.append(baiduSpacer, clearBaiduSecretKey.row);
baiduOnly.push(baiduSpacer, clearBaiduSecretKey.row);
const baiduQps = addField(baiduOnly, "QPS (requests/second)", "assistant-baidu-qps", config.baidu_qps ?? 1, "1");
baiduQps.type = "number";
baiduQps.min = "0.1";
baiduQps.max = "50";
baiduQps.step = "0.5";
const baiduQpsHelp = [
"Baidu rate-limits General Text Translation by account tier. Enter your tier's number and the plugin queues requests to match:",
"· Unverified / Standard: 1",
"· Personal verified: 10",
"· Enterprise verified: higher, see the Baidu AI Cloud console",
"Find your tier under Baidu AI Cloud → Machine Translation → General Text Translation.",
"Too high and Baidu still returns 54003 (too many requests); too low is only slower, never an error.",
"If unsure, use 1.",
].join("\n");
form.querySelector('label[for="bpi-field-assistant-baidu-qps"]')?.appendChild(helpMark(baiduQpsHelp));
const baiduQpsNote = element("div", "bpi-config-note", "How many requests per second Baidu may receive at most. Batch translation queues by this value and never exceeds it.");
baiduQpsNote.style.gridColumn = "1 / -1";
form.append(baiduQpsNote);
baiduOnly.push(baiduQpsNote);
// AI 服务(优化恒走它;翻译在选 AI 时也走它)
const aiHead = element("div", "bpm-toolbar");
aiHead.style.gridColumn = "1 / -1";
aiHead.append(element("strong", "", "AI Service (Translate & Optimize / Optimize / AI Translate)"));
form.append(aiHead);
aiOnly.push(aiHead);
const aiProviderLabel = element("label", "", "AI Backend");
const aiProvider = element("select");
for (const [value, label] of [["openai_compatible", "OpenAI-compatible API"], ["ollama", "Ollama (local)"]]) {
const option = element("option", "", label);
option.value = value;
aiProvider.appendChild(option);
}
aiProvider.value = config.ai_provider;
form.append(aiProviderLabel, aiProvider);
aiOnly.push(aiProviderLabel, aiProvider);
// OpenAI 兼容:远端必须 https,本机回环才允许 http
const openaiHead = element("div", "bpm-toolbar");
openaiHead.style.gridColumn = "1 / -1";
openaiHead.append(element("strong", "", "OpenAI-compatible API"));
form.append(openaiHead);
openaiOnly.push(openaiHead);
const openaiBaseUrl = addField(openaiOnly, "API URL", "assistant-openai-base-url", config.openai_base_url, "https://host/v1");
const openaiModel = addField(openaiOnly, "Model Name", "assistant-openai-model", config.openai_model, "provider model ID");
const presetLine = element("div", "bpm-toolbar");
presetLine.style.gridColumn = "1 / -1";
const lmStudioPreset = button("Use LM Studio Local Preset", () => {
aiProvider.value = "openai_compatible";
openaiBaseUrl.value = "http://127.0.0.1:1234/v1";
setText(error, "LM Studio default URL filled; select a loaded model ID, then Save & Test");
error.dataset.kind = "ok";
});
presetLine.append(lmStudioPreset, element("span", "bpi-config-note", "Connects to localhost:1234 by default; no external API required."));
form.append(presetLine);
openaiOnly.push(presetLine);
const openaiApiKey = addField(openaiOnly, "API Key", "assistant-openai-api-key", "", config.openai_api_key_configured ? "Saved; leave empty to keep" : "Optional for local LM Studio");
openaiApiKey.type = "password";
const clearApiKey = clearSwitch("Clear saved API Key");
const apiKeySpacer = element("span");
form.append(apiKeySpacer, clearApiKey.row);
openaiOnly.push(apiKeySpacer, clearApiKey.row);
const openaiTemperature = addField(openaiOnly, "Temperature", "assistant-openai-temperature", config.openai_temperature, "0–2");
openaiTemperature.type = "number";
openaiTemperature.min = "0";
openaiTemperature.max = "2";
openaiTemperature.step = "0.05";
const openaiTimeout = addField(openaiOnly, "Timeout (s)", "assistant-openai-timeout", config.openai_timeout_seconds, "5–600");
openaiTimeout.type = "number";
openaiTimeout.min = "5";
openaiTimeout.max = "600";
const openaiUrlNote = element("div", "bpi-config-note", "Remote addresses must use https; http is only accepted for this machine. Private, reserved and link-local addresses are rejected. Keys are never sent in clear text over the network.");
openaiUrlNote.style.gridColumn = "1 / -1";
form.append(openaiUrlNote);
openaiOnly.push(openaiUrlNote);
// Ollama:只允许本机回环,没有密钥
const ollamaHead = element("div", "bpm-toolbar");
ollamaHead.style.gridColumn = "1 / -1";
ollamaHead.append(element("strong", "", "Ollama (local)"));
form.append(ollamaHead);
ollamaOnly.push(ollamaHead);
const ollamaBaseUrl = addField(ollamaOnly, "API URL", "assistant-ollama-base-url", config.ollama_base_url, "http://127.0.0.1:11434");
const ollamaModel = addField(ollamaOnly, "Model Name", "assistant-ollama-model", config.ollama_model, "e.g.: qwen3:8b");
const ollamaPresetLine = element("div", "bpm-toolbar");
ollamaPresetLine.style.gridColumn = "1 / -1";
const ollamaPreset = button("Use Ollama Local Preset", () => {
aiProvider.value = "ollama";
ollamaBaseUrl.value = "http://127.0.0.1:11434";
setText(error, "Ollama default URL filled; select a pulled model ID, then Save & Test");
error.dataset.kind = "ok";
});
ollamaPresetLine.append(ollamaPreset, element("span", "bpi-config-note", "Connects to localhost:11434 by default; no external API required."));
form.append(ollamaPresetLine);
ollamaOnly.push(ollamaPresetLine);
const ollamaTemperature = addField(ollamaOnly, "Temperature", "assistant-ollama-temperature", config.ollama_temperature, "0–2");
ollamaTemperature.type = "number";
ollamaTemperature.min = "0";
ollamaTemperature.max = "2";
ollamaTemperature.step = "0.05";
const ollamaTimeout = addField(ollamaOnly, "Timeout (s)", "assistant-ollama-timeout", config.ollama_timeout_seconds, "5–600");
ollamaTimeout.type = "number";
ollamaTimeout.min = "5";
ollamaTimeout.max = "600";
const ollamaUrlNote = element("div", "bpi-config-note", "Ollama only accepts a loopback address on this machine; anything else is rejected before a request is sent.");
ollamaUrlNote.style.gridColumn = "1 / -1";
form.append(ollamaUrlNote);
ollamaOnly.push(ollamaUrlNote);
// 规则:只有 AI 会读,两个后端各一套(server.py 按当前后端取对应的那套)
const addRule = (list, labelText, value) => {
const labelElement = element("label", "", labelText);
const textarea = element("textarea");
textarea.value = value;
form.append(labelElement, textarea);
return { labelElement, textarea };
list.push(labelElement, textarea);
return textarea;
};
const rules = {
openai_compatible: {
translation: addRule(openaiOnly, "Translation Rule", config.openai_translation_rule),
translateOptimize: addRule(openaiOnly, "Translate & Optimize Rule", config.openai_translate_optimize_rule),
optimization: addRule(openaiOnly, "Optimize Rule", config.openai_optimization_rule),
natural: addRule(openaiOnly, "Natural Language Rule", config.openai_natural_rule),
},
ollama: {
translation: addRule(ollamaOnly, "Translation Rule", config.ollama_translation_rule),
translateOptimize: addRule(ollamaOnly, "Translate & Optimize Rule", config.ollama_translate_optimize_rule),
optimization: addRule(ollamaOnly, "Optimize Rule", config.ollama_optimization_rule),
natural: addRule(ollamaOnly, "Natural Language Rule", config.ollama_natural_rule),
},
};
const translationRule = addRule("Translation Rule", config.translation_rule);
const translateOptimizeRule = addRule("Translate & Optimize Rule", config.translate_optimize_rule);
const optimizationRule = addRule("Optimize Rule", config.optimization_rule);
form.appendChild(error);
section.appendChild(form);
section.appendChild(element("div", "bpi-config-note", "Settings are stored in the ComfyUI user directory. AI key and Baidu secret are retained independently; switching service never clears either. AI key is only cleared when AI backend/URL changes or on a different machine. Neither is written to workflows or returned to the browser."));
const actions = element("div", "bpm-assistant-actions");
const applyServiceVisibility = () => {
const value = service.value;
// 只有翻译服务本身就是 AI 时才展开 AI 整段;选词库 / 百度时一段都不显示
for (const node of baiduOnly) node.hidden = value !== "baidu";
for (const node of aiOnly) node.hidden = !AI_SERVICES.has(value);
for (const node of openaiOnly) node.hidden = value !== "openai_compatible";
for (const node of ollamaOnly) node.hidden = value !== "ollama";
};
service.addEventListener("change", applyServiceVisibility);
aiProvider.addEventListener("change", applyServiceVisibility);
applyServiceVisibility();
host.appendChild(form);
host.appendChild(element("div", "bpi-config-note", "Settings are stored in the ComfyUI user directory. AI key and Baidu secret are retained independently; switching service never clears either. AI key is only cleared when AI backend/URL changes or on a different machine. Neither is written to workflows or returned to the browser."));
const actions = element("div", "bpi-form-actions");
// 只恢复当前所选后端那套规则,另一套不动
const resetButton = button("Reset to Default Rules", () => {
translationRule.textarea.value = config.default_translation_rule;
translateOptimizeRule.textarea.value = config.default_translate_optimize_rule;
optimizationRule.textarea.value = config.default_optimization_rule;
const backend = currentBackend();
const prefix = backend === "ollama" ? "ollama" : "openai";
const active = rules[backend] ?? rules.openai_compatible;
active.translation.value = config[`default_${prefix}_translation_rule`];
active.translateOptimize.value = config[`default_${prefix}_translate_optimize_rule`];
active.optimization.value = config[`default_${prefix}_optimization_rule`];
active.natural.value = config[`default_${prefix}_natural_rule`];
setText(error, "Defaults restored in editor; click Save to apply");
error.dataset.kind = "ok";
});
@@ -752,7 +921,6 @@ async function buildAssistantSection(section) {
error.dataset.kind = "busy";
try {
config = await saveAssistantConfig(payload());
setLanguageMode(config.language);
const result = await testAssistantConnection();
setText(error, `Connection OK: “${result.message}”`);
error.dataset.kind = "ok";
@@ -767,8 +935,6 @@ async function buildAssistantSection(section) {
saveButton.disabled = true;
try {
config = await saveAssistantConfig(payload());
applyBpiAppearance(config.appearance);
setLanguageMode(config.language);
setText(error, "Assistant settings saved");
error.dataset.kind = "ok";
} catch (saveError) {
@@ -779,25 +945,34 @@ async function buildAssistantSection(section) {
}
}, "bpi-primary");
actions.append(resetButton, testButton, saveButton);
section.appendChild(actions);
host.appendChild(actions);
// 两套 AI 配置和两套规则都提交(后端对没给的字段才保留原值,这里给全最省心);
// 外观 / 语言已在「设置」面板里独立成项(appearanceControl),这里既不提交也不应用
const payload = () => ({
translate_service: service.value,
ai_provider: aiProvider.value,
ai_base_url: baseUrl.value.trim(),
ai_model: model.value.trim(),
ai_api_key: apiKey.value.trim(),
clear_ai_api_key: clearApiKey.checked,
ai_provider: currentBackend(),
openai_base_url: openaiBaseUrl.value.trim(),
openai_model: openaiModel.value.trim(),
openai_api_key: openaiApiKey.value.trim(),
clear_openai_api_key: clearApiKey.checked,
openai_temperature: openaiTemperature.value,
openai_timeout_seconds: openaiTimeout.value,
ollama_base_url: ollamaBaseUrl.value.trim(),
ollama_model: ollamaModel.value.trim(),
ollama_temperature: ollamaTemperature.value,
ollama_timeout_seconds: ollamaTimeout.value,
baidu_appid: baiduAppId.value.trim(),
baidu_secret_key: baiduSecretKey.value.trim(),
clear_baidu_secret_key: clearBaiduSecretKey.checked,
baidu_qps: baiduQps.value.trim(),
ai_temperature: temperature.value,
ai_timeout_seconds: timeout.value,
appearance: appearanceSelect.value,
language: languageSelect.value,
translation_rule: translationRule.textarea.value,
translate_optimize_rule: translateOptimizeRule.textarea.value,
optimization_rule: optimizationRule.textarea.value,
openai_translation_rule: rules.openai_compatible.translation.value,
openai_translate_optimize_rule: rules.openai_compatible.translateOptimize.value,
openai_optimization_rule: rules.openai_compatible.optimization.value,
openai_natural_rule: rules.openai_compatible.natural.value,
ollama_translation_rule: rules.ollama.translation.value,
ollama_translate_optimize_rule: rules.ollama.translateOptimize.value,
ollama_optimization_rule: rules.ollama.optimization.value,
ollama_natural_rule: rules.ollama.natural.value,
});
}
@@ -809,7 +984,6 @@ function setSection(section) {
refs.sections?.querySelector(`.bpm-section[data-section="${id}"]`)?.toggleAttribute("hidden", id !== section);
}
if (section === "tag-manager") rebuildTagManagerSection();
if (section === "assistant" && !manager.assistantLoaded) buildAssistantSection(refs.assistantSection);
if (section === "favorites" && !manager.favoritesLoaded) {
manager.favoritesLoaded = true;
refs.sections.appendChild(buildFavoritesSection());
@@ -1109,8 +1283,21 @@ function inspectorNodes() {
return (app.graph?._nodes ?? []).filter((node) => node._bilingualPromptInspector?.detailsBody);
}
// 用户在画布上双击节点顶部改名后,列表里显示他自己起的名字;
// 名字后面保留节点号,免得两个节点改成同一个名字时认不出来。
function customNodeTitle(node) {
const current = String(node.title ?? "").trim();
// defaultNodeTitle 是节点创建时记下的出厂标题;拿不到就退回节点类型的默认标题,
// 两者都拿不到时不做判断,免得把出厂标题当成用户改过的名字。
const factory = String(node._bilingualPromptInspector?.defaultNodeTitle ?? node.constructor?.title ?? "").trim();
if (!current || !factory || current === factory) return null;
return current;
}
function tagNodeLabel(node) {
const text = String(node.widgets?.find((widget) => widget.name === "text")?.value ?? "").trim();
const custom = customNodeTitle(node);
if (custom) return `${custom} · ${t(`Node #${node.id}`)}`;
return `${t(`Node #${node.id}`)}: ${text ? text.slice(0, 30) : t("(empty prompt)")}`;
}
@@ -1186,8 +1373,6 @@ function build() {
refs.sections.appendChild(refs.tagManagerSection);
refs.sections.appendChild(buildTagsSection());
refs.sections.appendChild(buildPacksSection());
refs.assistantSection = buildAssistantPlaceholder();
refs.sections.appendChild(refs.assistantSection);
root.append(head, refs.tabs, refs.sections);
root.addEventListener("mousedown", (event) => event.stopPropagation());
root.addEventListener("wheel", (event) => event.stopPropagation(), { passive: true });
@@ -1303,14 +1488,6 @@ function buildPacksSection() {
return section;
}
function buildAssistantPlaceholder() {
const section = element("div", "bpm-section");
section.dataset.section = "assistant";
section.hidden = true;
section.appendChild(element("div", "bpm-loading", "Settings load on first visit..."));
return section;
}
function renderTab(container) {
if (!root) build();
container.replaceChildren(root);
@@ -1341,6 +1518,24 @@ getAssistantConfig()
app.registerExtension({
name: "ComfyUI.BilingualPromptInspector.ManagerPanel",
// 助手设置整体挂在 ComfyUI 的「设置」面板里:项名和分类由前端原生渲染,
// 不走插件的 t(),直接写中文;控件本体是 assistantSettingsControl() 返回的那块 DOM。
settings: [
{
id: "BilingualPromptInspector.Appearance",
name: "语言与外观",
type: appearanceControl,
defaultValue: "",
category: ["双语提示词检查器", "语言与外观"],
},
{
id: "BilingualPromptInspector.AssistantSettings",
name: "助手设置",
type: assistantSettingsControl,
defaultValue: "",
category: ["双语提示词检查器", "助手设置"],
},
],
async setup() {
if (!app.extensionManager?.registerSidebarTab) {
console.warn("[BilingualPromptInspector] Current frontend does not support sidebar; panel unavailable");
@@ -1352,7 +1547,7 @@ app.registerExtension({
// 侧边栏标签显示的就是插件名,直接写中文:注册发生在启动时,
// 语言设置要等后端配置返回才落地,走 t() 会先渲染成英文且不会再刷新。
title: "提示词翻译与管理",
tooltip: "提示词翻译与管理:标签管理、词库、词包与助手设置",
tooltip: "提示词翻译与管理:标签管理、词库、词包与收藏",
type: "custom",
render: renderTab,
destroy: destroyTab,
+28 -7
View File
@@ -20,6 +20,10 @@ function stripEmphasis(value) {
return current;
}
// 没命中任何词库时中文列填的占位文案。它只是「还没翻译」的提示,不是一条真正的译文,
// 所以任何往词库回写的地方都要先拿它比对,别把占位当成中文名存进去。
export const UNKNOWN_CHINESE = "未收录";
export function analyzeSegment(raw) {
const display = raw.trim();
if (!display) return null;
@@ -617,13 +621,30 @@ export function validateTranslationResult(source, translated, options = {}) {
return { ok: true, text: result };
}
// 插件写死的默认分类:分到它等于还没分类。parser 是纯逻辑模块,不引 UI 那层,
// 所以这个常量和 bpi_shared.js 里的那份各存一份,改要一起改。
const UNCATEGORIZED_CATEGORY = "Uncategorized";
// 可信度:徽章只显示「未判定 / 低 / 中 / 高」四档,为什么是这一档写在 tooltip 里。
// 判据从粗到细:查不到 → 还没入库 → 没校验 → 没分类 → 有分类。
function confidenceFor(status, entry) {
if (status === "machine") return { confidence: "low", confidenceLabel: "Low · pending" };
if (status === "unverified") return { confidence: "medium", confidenceLabel: "Medium · pending" };
if (status === "unknown") return { confidence: "none", confidenceLabel: "Not judged" };
if (status === "special") return { confidence: "high", confidenceLabel: "High" };
if (entry?.source === "user" || entry?.verified !== false) return { confidence: "high", confidenceLabel: "High" };
return { confidence: "medium", confidenceLabel: "Medium" };
if (status === "unknown") {
return { confidence: "none", confidenceLabel: "Not judged", confidenceReason: "Not in any dictionary, so it cannot be judged" };
}
if (status === "machine") {
return { confidence: "low", confidenceLabel: "Low", confidenceReason: "Machine translation, not saved to the dictionary or confirmed yet" };
}
if (status === "special") {
return { confidence: "high", confidenceLabel: "High", confidenceReason: "Syntax kept as-is; nothing to translate" };
}
if (entry?.verified === false) {
return { confidence: "medium", confidenceLabel: "Medium", confidenceReason: "In the dictionary, but not marked as verified" };
}
const category = String(entry?.category ?? "").trim();
if (category && category !== UNCATEGORIZED_CATEGORY) {
return { confidence: "high", confidenceLabel: "High", confidenceReason: `In the dictionary, verified, category “${category}”` };
}
return { confidence: "medium", confidenceLabel: "Medium", confidenceReason: "In the dictionary, but no category set" };
}
export function parsePrompt(text, dictionaryIndex, machineTranslations = new Map(), options = {}) {
@@ -648,7 +669,7 @@ export function parsePrompt(text, dictionaryIndex, machineTranslations = new Map
const dictionaryEntry = dictionaryIndex.get(key);
const machine = machineTranslations.get(key);
let status = "unknown";
let chinese = "未收录";
let chinese = UNKNOWN_CHINESE;
if (dictionaryEntry) {
status = dictionaryEntry.verified === false ? "unverified" : "verified";
chinese = dictionaryEntry.chinese;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "comfyui-bilingual-prompt-inspector",
"version": "2.0.0",
"version": "2.1.8",
"description": "ComfyUI bilingual prompt inspection, translation, Anima sorting, and on-demand Danbooru dictionary extension.",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "prompt-translator-manager"
version = "2.0.0"
version = "2.1.8"
# 与 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" }
+48 -34
View File
@@ -14,15 +14,20 @@ from comfy import model_management
from server import PromptServer
from .assistant_store import (
AI_PROVIDER_PREFIXES,
AI_SERVICES,
BAIDU_ERROR_MESSAGES,
BAIDU_QPS_DEFAULT,
BAIDU_TIMEOUT_SECONDS,
BAIDU_TRANSLATE_ENDPOINT,
AssistantStore,
baidu_split_query,
baidu_translate_params,
dictionary_translate,
normalize_ai_base_url,
openai_chat_endpoint,
sanitize_anima_prompt,
sanitize_natural_prompt,
translation_direction,
)
from .dictionary_store import DictionaryStore, normalize_key
@@ -170,7 +175,7 @@ async def _baidu_translate(text, to_lang):
if not chunks:
raise ValueError("待翻译文本为空")
qps = config.get("baidu_qps") or BAIDU_QPS_DEFAULT
timeout = ClientTimeout(total=config["ai_timeout_seconds"])
timeout = ClientTimeout(total=BAIDU_TIMEOUT_SECONDS)
results = []
async with ClientSession(timeout=timeout) as session:
for chunk in chunks:
@@ -194,15 +199,21 @@ async def _baidu_translate(text, to_lang):
async def _call_ai_assistant(action, text, instruction, config, connection_test=False):
"""翻译/优化都走这里。优化类恒走 AI;翻译类在 translate_service=ai 时也走 AI。"""
if not config.get("ai_model"):
raise ValueError("尚未填写 AI 模型名称(“翻译并优化”“优化为 Anima”以及 AI 翻译都需要)")
ai_provider = config["ai_provider"]
"""翻译/优化都走这里。优化类恒走 AI;翻译类在翻译服务就是某个 AI 后端时也走 AI。"""
# 两个后端各一套配置和规则。选 OpenAI / Ollama 当翻译服务时它就是后端;
# 选词库 / 百度时翻译不走 AI,优化仍走,这时用 ai_provider 决定读哪一套
prefix = AI_PROVIDER_PREFIXES.get(config.get("translate_service")) or AI_PROVIDER_PREFIXES.get(
config.get("ai_provider", "openai_compatible"), "openai"
)
if not config.get(f"{prefix}_model"):
raise ValueError("尚未填写 AI 模型名称(“翻译并优化”“优化”以及 AI 翻译都需要)")
rule = {
"translate": config["translation_rule"],
"explain": config["translation_rule"],
"translate_optimize": config["translate_optimize_rule"],
"optimize": config["optimization_rule"],
"translate": config[f"{prefix}_translation_rule"],
"explain": config[f"{prefix}_translation_rule"],
"translate_optimize": config[f"{prefix}_translate_optimize_rule"],
"optimize": config[f"{prefix}_optimization_rule"],
# 自然语言模式下的「优化」:输入可以是中文,输出是英文自然语句,不走 Anima 标点规则
"optimize_natural": config[f"{prefix}_natural_rule"],
}[action]
if connection_test:
rule = "你是连接测试助手。"
@@ -216,21 +227,26 @@ async def _call_ai_assistant(action, text, instruction, config, connection_test=
rule = f"{rule}\n\n本次任务:无论输入语言为何,最终必须输出经过优化的英文 Anima 提示词。"
elif not connection_test and action == "optimize":
if re.search(r"[\u3400-\u9fff]", text):
raise ValueError("“优化为 Anima”只接受英文;中文或混合文本请使用“翻译并优化”")
raise ValueError("“优化”在标签模式下只接受英文;中文或混合文本请使用“翻译并优化”,或把模式切到“自然语言”")
rule = f"{rule}\n\n本次任务:整理现有英文内容,不承担翻译。"
elif not connection_test and action == "optimize_natural":
# 自然语言模式允许中文输入:先译后整理,输出一段英文自然语句
direction = "先把中文忠实译为英文,再整理表达" if translation_direction(text) == "to_english" else "整理现有英文内容,不承担翻译"
rule = f"{rule}\n\n本次任务方向:{direction}。"
if instruction:
rule = f"{rule}\n\n用户本次附加要求优先遵循:{instruction.strip()}"
timeout = ClientTimeout(total=config["ai_timeout_seconds"])
if ai_provider == "ollama":
base = (config.get("ai_base_url") or "http://127.0.0.1:11434").rstrip("/")
timeout = ClientTimeout(total=config[f"{prefix}_timeout_seconds"])
if prefix == "ollama":
# Ollama 只允许本机回环地址:这里再校验一次,挡住手改配置文件的情况
base = normalize_ai_base_url(config["ollama_base_url"])
if base.endswith("/v1"):
base = base[:-3].rstrip("/")
endpoint = base if base.endswith("/api/chat") else f"{base}/api/chat"
payload = {
"model": config["ai_model"],
"model": config["ollama_model"],
"stream": False,
"messages": [{"role": "system", "content": rule}, {"role": "user", "content": text}],
"options": {"temperature": config["ai_temperature"]},
"options": {"temperature": config["ollama_temperature"]},
}
async with ClientSession(timeout=timeout) as session:
async with session.post(endpoint, json=payload) as response:
@@ -239,18 +255,20 @@ async def _call_ai_assistant(action, text, instruction, config, connection_test=
raise ValueError(data.get("error") or f"Ollama 请求失败:HTTP {response.status}")
result = data.get("message", {}).get("content")
else:
if not config.get("ai_base_url"):
# OpenAI 兼容:远端必须 https,本机回环才允许 http
base = normalize_ai_base_url(config["openai_base_url"], allow_remote=True)
if not base:
raise ValueError("尚未填写 OpenAI 兼容 API 地址")
headers = {"Content-Type": "application/json"}
if config.get("ai_api_key"):
headers["Authorization"] = f"Bearer {config['ai_api_key']}"
if config.get("openai_api_key"):
headers["Authorization"] = f"Bearer {config['openai_api_key']}"
payload = {
"model": config["ai_model"],
"temperature": config["ai_temperature"],
"model": config["openai_model"],
"temperature": config["openai_temperature"],
"messages": [{"role": "system", "content": rule}, {"role": "user", "content": text}],
}
async with ClientSession(timeout=timeout) as session:
async with session.post(openai_chat_endpoint(config["ai_base_url"]), headers=headers, json=payload) as response:
async with session.post(openai_chat_endpoint(base), headers=headers, json=payload) as response:
data = await response.json(content_type=None)
if response.status >= 400:
detail = data.get("error") if isinstance(data, dict) else None
@@ -264,7 +282,9 @@ async def _call_ai_assistant(action, text, instruction, config, connection_test=
raise ValueError("模型没有返回文本结果")
if len(result) > 50000:
raise ValueError("模型返回内容异常过长")
if action in {"translate_optimize", "optimize"}:
if action == "optimize_natural":
result = sanitize_natural_prompt(result)
elif action in {"translate_optimize", "optimize"}:
result = sanitize_anima_prompt(result)
return result
@@ -272,13 +292,13 @@ async def _call_ai_assistant(action, text, instruction, config, connection_test=
async def _call_configured_assistant(action, text, instruction="", connection_test=False):
config = assistant_store.config()
translate_service = config["translate_service"]
# “翻译并优化”“优化为 Anima”恒走 AI;翻译/解释在 translate_service=ai 时也走 AI
needs_ai = action in {"translate_optimize", "optimize"} or (
action in {"translate", "explain"} and translate_service == "ai"
# “翻译并优化”“优化”恒走 AI;翻译/解释在翻译服务就是某个 AI 后端时也走 AI
needs_ai = action in {"translate_optimize", "optimize", "optimize_natural"} or (
action in {"translate", "explain"} and translate_service in AI_SERVICES
)
if needs_ai:
return await _call_ai_assistant(action, text, instruction, config, connection_test)
# 以下只处理 translate/explain 且 translate_service != "ai"
# 以下只处理 translate/explain 且翻译服务不是 AI
if translate_service == "dictionary":
if connection_test:
return "纯词库模式可用"
@@ -291,7 +311,7 @@ async def _call_configured_assistant(action, text, instruction="", connection_te
return "百度翻译连接成功"
to_lang = "zh" if action == "explain" or translation_direction(text) == "to_chinese" else "en"
return await _baidu_translate(text, to_lang)
raise ValueError("翻译服务配置异常:请在助手设置里选择词库、百度或 AI")
raise ValueError("翻译服务配置异常:请在助手设置里选择词库、百度翻译、OpenAI 兼容接口或 Ollama")
@PromptServer.instance.routes.get("/bpi/session")
@@ -318,12 +338,6 @@ async def save_assistant_config(request):
return error_response(error)
@protected_route("post", "/bpi/assistant/reset-rules")
async def reset_assistant_rules(_request):
try:
return web.json_response({"success": True, "data": assistant_store.reset_rules()})
except OSError as error:
return error_response(error, 500)
@protected_route("post", "/bpi/assistant/test")
@@ -352,7 +366,7 @@ async def run_assistant(request):
action = str(payload.get("action", "translate")) if isinstance(payload, dict) else "translate"
text = str(payload.get("text", "")) if isinstance(payload, dict) else ""
instruction = str(payload.get("instruction", "")) if isinstance(payload, dict) else ""
if action not in {"translate", "explain", "translate_optimize", "optimize"}:
if action not in {"translate", "explain", "translate_optimize", "optimize", "optimize_natural"}:
raise ValueError("未知的助手操作")
if not text.strip():
raise ValueError("输入内容为空")
+242 -39
View File
@@ -6,6 +6,7 @@ from pathlib import Path
from assistant_store import (
AssistantStore,
DEFAULT_NATURAL_RULE,
DEFAULT_OPTIMIZATION_RULE,
DEFAULT_TRANSLATE_OPTIMIZE_RULE,
DEFAULT_TRANSLATION_RULE,
@@ -15,8 +16,10 @@ from assistant_store import (
baidu_split_query,
baidu_translate_params,
dictionary_translate,
normalize_ai_base_url,
openai_chat_endpoint,
sanitize_anima_prompt,
sanitize_natural_prompt,
translation_direction,
)
from dictionary_store import DictionaryStore
@@ -37,41 +40,108 @@ class AssistantStoreTests(unittest.TestCase):
with tempfile.TemporaryDirectory() as root:
store = AssistantStore(Path(root) / "config")
public = store.update({
"translate_service": "ai",
"translate_service": "openai_compatible",
"ai_provider": "openai_compatible",
"ai_base_url": "https://example.test/v1",
"ai_model": "example-model",
"ai_api_key": "secret-value",
"translation_rule": "自定义仅翻译",
"translate_optimize_rule": "自定义翻译并优化",
"optimization_rule": "自定义优化",
"openai_base_url": "https://example.test/v1",
"openai_model": "example-model",
"openai_api_key": "secret-value",
"openai_translation_rule": "自定义仅翻译",
"openai_translate_optimize_rule": "自定义翻译并优化",
"openai_optimization_rule": "自定义优化",
})
self.assertTrue(public["ai_api_key_configured"])
self.assertNotIn("ai_api_key", public)
self.assertEqual(store.config()["ai_api_key"], "secret-value")
self.assertEqual(store.config()["translation_rule"], "自定义仅翻译")
self.assertEqual(store.config()["translate_optimize_rule"], "自定义翻译并优化")
self.assertTrue(public["openai_api_key_configured"])
self.assertNotIn("openai_api_key", public)
self.assertEqual(store.config()["openai_api_key"], "secret-value")
self.assertEqual(store.config()["openai_translation_rule"], "自定义仅翻译")
self.assertEqual(store.config()["openai_translate_optimize_rule"], "自定义翻译并优化")
# 另一套规则不受影响
self.assertEqual(store.config()["ollama_translation_rule"], DEFAULT_TRANSLATION_RULE)
self.assertNotIn("explanation_rule", public)
def test_openai_and_ollama_slots_are_independent(self):
with tempfile.TemporaryDirectory() as root:
store = AssistantStore(Path(root) / "config")
store.update({
"ai_provider": "openai_compatible",
"openai_base_url": "https://example.test/v1",
"openai_model": "cloud-model",
"openai_api_key": "secret-value",
"openai_translation_rule": "云端规则",
})
store.update({
"ai_provider": "ollama",
"ollama_model": "qwen3:8b",
"ollama_translation_rule": "本地规则",
})
config = store.config()
self.assertEqual(config["openai_model"], "cloud-model")
self.assertEqual(config["openai_api_key"], "secret-value")
self.assertEqual(config["openai_translation_rule"], "云端规则")
self.assertEqual(config["ollama_model"], "qwen3:8b")
self.assertEqual(config["ollama_base_url"], "http://127.0.0.1:11434")
self.assertEqual(config["ollama_translation_rule"], "本地规则")
def test_dictionary_translation_preserves_weight(self):
with tempfile.TemporaryDirectory() as root:
dictionary = self.make_dictionary(root)
self.assertEqual(dictionary_translate("长发,(微笑:1.2)", dictionary), "long hair, (smile:1.2)")
def test_api_key_is_bound_to_provider_and_base_url(self):
def test_api_key_is_bound_to_base_url(self):
with tempfile.TemporaryDirectory() as root:
store = AssistantStore(Path(root) / "config")
store.update({
"translate_service": "ai",
"translate_service": "openai_compatible",
"ai_provider": "openai_compatible",
"ai_base_url": "https://first.example/v1",
"ai_model": "example-model",
"ai_api_key": "secret-value",
"openai_base_url": "https://first.example/v1",
"openai_model": "example-model",
"openai_api_key": "secret-value",
})
self.assertEqual(store.config()["ai_api_key"], "secret-value")
public = store.update({"ai_base_url": "https://second.example/v1"})
self.assertFalse(public["ai_api_key_configured"])
self.assertEqual(store.config()["ai_api_key"], "")
self.assertEqual(store.config()["openai_api_key"], "secret-value")
public = store.update({"openai_base_url": "https://second.example/v1"})
self.assertFalse(public["openai_api_key_configured"])
self.assertEqual(store.config()["openai_api_key"], "")
def test_api_key_survives_provider_switch(self):
with tempfile.TemporaryDirectory() as root:
store = AssistantStore(Path(root) / "config")
store.update({"openai_base_url": "https://example.test/v1", "openai_model": "m", "openai_api_key": "secret-value"})
# 切到 Ollama 再切回来,密钥还在
store.update({"ai_provider": "ollama"})
self.assertEqual(store.config()["openai_api_key"], "secret-value")
store.update({"ai_provider": "openai_compatible"})
self.assertTrue(store.public_config()["openai_api_key_configured"])
def test_legacy_ai_translate_service_is_split_by_provider(self):
with tempfile.TemporaryDirectory() as root:
store = AssistantStore(Path(root) / "config")
store.config_dir.mkdir(parents=True, exist_ok=True)
store.config_path.write_text(json.dumps({
"installation_id": store.installation_id,
"schema_version": 4,
"translate_service": "ai",
"ai_provider": "ollama",
"ollama_model": "qwen3:8b",
}, ensure_ascii=False), encoding="utf-8")
config = store.config()
self.assertEqual(config["translate_service"], "ollama")
self.assertEqual(config["ai_provider"], "ollama")
def test_ai_translate_service_pins_the_matching_provider(self):
with tempfile.TemporaryDirectory() as root:
store = AssistantStore(Path(root) / "config")
store.update({
"translate_service": "openai_compatible",
"openai_base_url": "https://example.test/v1",
"openai_model": "m",
"openai_api_key": "secret-value",
})
public = store.update({"translate_service": "ollama"})
self.assertEqual(public["translate_service"], "ollama")
self.assertEqual(public["ai_provider"], "ollama")
back = store.update({"translate_service": "openai_compatible"})
self.assertEqual(back["translate_service"], "openai_compatible")
self.assertEqual(back["ai_provider"], "openai_compatible")
self.assertTrue(back["openai_api_key_configured"])
def test_new_installation_id_invalidates_saved_configuration(self):
with tempfile.TemporaryDirectory() as root:
@@ -79,25 +149,115 @@ class AssistantStoreTests(unittest.TestCase):
first_marker = Path(root) / "first-installation-id"
first = AssistantStore(config_dir, install_id_path=first_marker)
first.update({
"translate_service": "ai",
"translate_service": "openai_compatible",
"ai_provider": "openai_compatible",
"ai_base_url": "https://example.test/v1",
"ai_model": "example-model",
"ai_api_key": "secret-value",
"openai_base_url": "https://example.test/v1",
"openai_model": "example-model",
"openai_api_key": "secret-value",
})
second = AssistantStore(config_dir, install_id_path=Path(root) / "second-installation-id")
config = second.config()
self.assertEqual(config["translate_service"], "dictionary")
self.assertEqual(config["ai_base_url"], "")
self.assertEqual(config["ai_api_key"], "")
self.assertEqual(config["openai_base_url"], "")
self.assertEqual(config["openai_api_key"], "")
def test_remote_http_endpoint_is_rejected_but_local_http_is_allowed(self):
def test_openai_base_url_requires_https_or_loopback(self):
with tempfile.TemporaryDirectory() as root:
store = AssistantStore(Path(root) / "config")
with self.assertRaisesRegex(ValueError, "HTTPS"):
store.update({"ai_provider": "openai_compatible", "ai_base_url": "http://public.example/v1"})
result = store.update({"ai_provider": "ollama", "ai_base_url": "http://127.0.0.1:11434"})
self.assertEqual(result["ai_base_url"], "http://127.0.0.1:11434")
store.update({"openai_base_url": "http://public.example/v1"})
with self.assertRaisesRegex(ValueError, "完整的 http"):
store.update({"openai_base_url": "public.example/v1"})
self.assertEqual(store.update({"openai_base_url": "https://public.example/v1"})["openai_base_url"], "https://public.example/v1")
# 本机回环的 http 放行:本地 LM Studio 之类
self.assertEqual(store.update({"openai_base_url": "http://127.0.0.1:1234/v1"})["openai_base_url"], "http://127.0.0.1:1234/v1")
def test_ollama_base_url_must_be_loopback(self):
with tempfile.TemporaryDirectory() as root:
store = AssistantStore(Path(root) / "config")
self.assertEqual(store.config()["ollama_base_url"], "http://127.0.0.1:11434")
self.assertEqual(store.update({"ollama_base_url": "http://localhost:11434"})["ollama_base_url"], "http://localhost:11434")
self.assertEqual(store.update({"ollama_base_url": "http://[::1]:11434"})["ollama_base_url"], "http://[::1]:11434")
for blocked in ("http://192.168.1.20:11434", "http://10.0.0.5:11434", "https://ollama.example.test"):
with self.assertRaisesRegex(ValueError, "Ollama 地址只能是本机地址"):
store.update({"ollama_base_url": blocked})
def test_remote_https_rejects_private_and_reserved_addresses(self):
# https 也不能拐进内网:云元数据、私有网段、链路本地一律拒绝
for blocked in (
"https://169.254.169.254/latest",
"https://192.168.1.20/v1",
"https://10.0.0.5/v1",
"https://172.16.0.9/v1",
"https://[fd00::1]/v1",
):
with self.assertRaisesRegex(ValueError, "内网、保留或链路本地"):
normalize_ai_base_url(blocked, allow_remote=True)
# 公网域名与公网 IP 正常放行
self.assertEqual(normalize_ai_base_url("https://api.example.test/v1", allow_remote=True), "https://api.example.test/v1")
self.assertEqual(normalize_ai_base_url("https://8.8.8.8/v1", allow_remote=True), "https://8.8.8.8/v1")
# 本机回环不受这条限制
self.assertEqual(normalize_ai_base_url("http://127.0.0.1:1234/v1", allow_remote=True), "http://127.0.0.1:1234/v1")
def test_private_https_openai_url_is_rejected_on_save(self):
with tempfile.TemporaryDirectory() as root:
store = AssistantStore(Path(root) / "config")
with self.assertRaisesRegex(ValueError, "内网、保留或链路本地"):
store.update({"openai_base_url": "https://192.168.1.20/v1"})
self.assertEqual(store.update({"openai_base_url": "https://api.example.test/v1"})["openai_base_url"], "https://api.example.test/v1")
def test_normalize_ai_base_url_rejects_credentials_and_query(self):
with self.assertRaisesRegex(ValueError, "用户名或密码"):
normalize_ai_base_url("https://user:pass@example.test/v1", allow_remote=True)
with self.assertRaisesRegex(ValueError, "查询参数"):
normalize_ai_base_url("https://example.test/v1?x=1", allow_remote=True)
self.assertEqual(normalize_ai_base_url("https://example.test/v1/", allow_remote=True), "https://example.test/v1")
self.assertEqual(normalize_ai_base_url(""), "")
def test_v3_config_migrates_into_selected_provider_slot(self):
with tempfile.TemporaryDirectory() as root:
store = AssistantStore(Path(root) / "config")
store.config_dir.mkdir(parents=True, exist_ok=True)
store.config_path.write_text(json.dumps({
"installation_id": store.installation_id,
"schema_version": 3,
"translate_service": "ollama",
"ai_provider": "ollama",
"ai_base_url": "http://127.0.0.1:11434",
"ai_model": "qwen3:8b",
"ai_temperature": 0.7,
"ai_timeout_seconds": 120,
"translation_rule": "我的规则",
"translate_optimize_rule": "我的优化规则",
"optimization_rule": "我的整理规则",
}, ensure_ascii=False), encoding="utf-8")
config = store.config()
self.assertEqual(config["ollama_base_url"], "http://127.0.0.1:11434")
self.assertEqual(config["ollama_model"], "qwen3:8b")
self.assertEqual(config["ollama_temperature"], 0.7)
self.assertEqual(config["ollama_timeout_seconds"], 120)
self.assertEqual(config["openai_model"], "") # 另一套留默认
# 规则原本不分后端,两套都继承
self.assertEqual(config["ollama_translation_rule"], "我的规则")
self.assertEqual(config["openai_translation_rule"], "我的规则")
self.assertEqual(config["schema_version"], 4)
def test_v3_migration_keeps_openai_api_key(self):
with tempfile.TemporaryDirectory() as root:
store = AssistantStore(Path(root) / "config")
store.config_dir.mkdir(parents=True, exist_ok=True)
store.config_path.write_text(json.dumps({
"installation_id": store.installation_id,
"schema_version": 3,
"ai_provider": "openai_compatible",
"ai_base_url": "https://example.test/v1",
"ai_model": "example-model",
"ai_api_key": "secret-value",
}, ensure_ascii=False), encoding="utf-8")
config = store.config()
# 绑定的 provider 标记变了,密钥不能因此被当成「换了地址」清掉
self.assertEqual(config["openai_api_key"], "secret-value")
self.assertTrue(store.public_config()["openai_api_key_configured"])
def test_dictionary_translation_reports_unknown(self):
with tempfile.TemporaryDirectory() as root:
@@ -157,6 +317,47 @@ class AssistantStoreTests(unittest.TestCase):
with self.assertRaisesRegex(ValueError, "仍包含中文"):
sanitize_anima_prompt("masterpiece, 杰作")
def test_natural_prompt_keeps_sentence_punctuation(self):
# 与 Anima 清洗的核心区别:句点是句子的一部分,不能被换成逗号
self.assertEqual(
sanitize_natural_prompt("A woman stands in the rain. She looks tired."),
"A woman stands in the rain. She looks tired.",
)
# 逗号是句子内部的停顿,原样保留
self.assertEqual(
sanitize_natural_prompt("A woman stands in the rain, tired and cold."),
"A woman stands in the rain, tired and cold.",
)
# 全角标点统一成半角,代码块围栏和转义符照旧清掉
self.assertEqual(
sanitize_natural_prompt('```text\nA woman stands in the rain,tired。\n```'),
"A woman stands in the rain, tired.",
)
self.assertEqual(
sanitize_natural_prompt('A woman \\"stands\\" in the rain'),
'A woman "stands" in the rain',
)
# 模型爱用列表;自然语言结果里列表符号要去掉,换行收成空格
self.assertEqual(
sanitize_natural_prompt("1. A woman stands in the rain\n2. She looks tired"),
"A woman stands in the rain She looks tired",
)
with self.assertRaisesRegex(ValueError, "仍包含中文"):
sanitize_natural_prompt("一名女子站在雨中")
def test_natural_rule_is_persisted_per_backend(self):
with tempfile.TemporaryDirectory() as root:
store = AssistantStore(config_dir=root, install_id_path=Path(root) / "install")
store.update({"openai_natural_rule": "我的自然语言规则", "ollama_natural_rule": "本地规则"})
config = store.config()
self.assertEqual(config["openai_natural_rule"], "我的自然语言规则")
self.assertEqual(config["ollama_natural_rule"], "本地规则")
# 另一套不受影响,且默认值能通过 public_config 取回
self.assertEqual(config["openai_optimization_rule"], DEFAULT_OPTIMIZATION_RULE)
public = store.public_config()
self.assertEqual(public["default_openai_natural_rule"], DEFAULT_NATURAL_RULE)
self.assertEqual(public["default_ollama_natural_rule"], DEFAULT_NATURAL_RULE)
def test_legacy_default_rules_are_upgraded(self):
with tempfile.TemporaryDirectory() as root:
store = AssistantStore(Path(root) / "config")
@@ -167,9 +368,10 @@ class AssistantStoreTests(unittest.TestCase):
"optimization_rule": LEGACY_DEFAULT_OPTIMIZATION_RULE,
}, ensure_ascii=False), encoding="utf-8")
config = store.config()
self.assertEqual(config["translation_rule"], DEFAULT_TRANSLATION_RULE)
self.assertEqual(config["translate_optimize_rule"], DEFAULT_TRANSLATE_OPTIMIZE_RULE)
self.assertEqual(config["optimization_rule"], DEFAULT_OPTIMIZATION_RULE)
for prefix in ("openai", "ollama"):
self.assertEqual(config[f"{prefix}_translation_rule"], DEFAULT_TRANSLATION_RULE)
self.assertEqual(config[f"{prefix}_translate_optimize_rule"], DEFAULT_TRANSLATE_OPTIMIZE_RULE)
self.assertEqual(config[f"{prefix}_optimization_rule"], DEFAULT_OPTIMIZATION_RULE)
def test_v17_default_translation_rule_is_upgraded_without_overwriting_custom_rules(self):
with tempfile.TemporaryDirectory() as root:
@@ -180,10 +382,11 @@ class AssistantStoreTests(unittest.TestCase):
"provider": "dictionary",
"translation_rule": V17_DEFAULT_TRANSLATION_RULE,
}, ensure_ascii=False), encoding="utf-8")
self.assertEqual(store.config()["translation_rule"], DEFAULT_TRANSLATION_RULE)
self.assertEqual(store.config()["openai_translation_rule"], DEFAULT_TRANSLATION_RULE)
store.update({"translation_rule": "我的自定义翻译规则"})
self.assertEqual(store.config()["translation_rule"], "我的自定义翻译规则")
store.update({"openai_translation_rule": "我的自定义翻译规则"})
self.assertEqual(store.config()["openai_translation_rule"], "我的自定义翻译规则")
self.assertEqual(store.config()["ollama_translation_rule"], DEFAULT_TRANSLATION_RULE)
def test_baidu_provider_saves_and_masks_secret(self):
with tempfile.TemporaryDirectory() as root:
@@ -198,13 +401,13 @@ class AssistantStoreTests(unittest.TestCase):
self.assertTrue(public["baidu_secret_key_configured"])
self.assertNotIn("baidu_secret_key", public)
self.assertEqual(store.config()["baidu_secret_key"], "baidu-secret")
self.assertFalse(public["ai_api_key_configured"])
self.assertFalse(public["openai_api_key_configured"])
def test_baidu_secret_is_retained_when_translate_service_changes(self):
with tempfile.TemporaryDirectory() as root:
store = AssistantStore(Path(root) / "config")
store.update({"translate_service": "baidu", "baidu_appid": "appid", "baidu_secret_key": "baidu-secret"})
public = store.update({"translate_service": "ai", "ai_provider": "openai_compatible", "ai_base_url": "https://example.test/v1", "ai_model": "m"})
public = store.update({"translate_service": "openai_compatible", "ai_provider": "openai_compatible", "openai_base_url": "https://example.test/v1", "openai_model": "m"})
self.assertTrue(public["baidu_secret_key_configured"])
self.assertEqual(store.config()["baidu_secret_key"], "baidu-secret")
+29 -9
View File
@@ -85,7 +85,15 @@ assert.equal(
// 自由文本变量必须用「"…"」包起来才能折叠成 {},否则整句永远匹配不上。
// 这里锁死「运行时真实形态」,防止以后有人把引号去掉或换成 ASCII 直引号。
for (const [source, expected] of [
["“Optimize to Anima” done; please confirm in the preview window", "「优化为 Anima」完成;请在预览窗口确认"],
["“Optimize” done; please confirm in the preview window", "「优化」完成;请在预览窗口确认"],
["Optimize", "优化"],
["Fit height", "适应高度"],
["Natural Language Rule", "自然语言规则"],
["(Chinese detected; translating to English first)", "(检测到中文,会先译成英文)"],
[
"Optimize with the rule for the current mode: tag mode tidies the tag flow, natural language mode rewrites it as English sentences",
"按当前模式对应的规则优化:标签模式整理标签流,自然语言模式改写为英文自然语句",
],
["applied “Translate & optimize”; press Ctrl+Z to undo", "已应用 翻译并优化;按 Ctrl+Z 可撤销"],
["Undone: “Sort to Anima order”", "已撤销:Sort to Anima order"],
["Redone: “Sort to Anima order”", "已重做:Sort to Anima order"],
@@ -124,11 +132,19 @@ for (const [source, expected] of [
assert.equal(t(source), expected, `未命中:${source}`);
}
// 置信度徽章:这几个是 parser.js 直接产出的界面文本
assert.equal(t("Low · pending"), "低 · 待确认");
assert.equal(t("Medium · pending"), "中 · 待确认");
assert.equal(t("Medium · session-only"), "中 · 仅本次会话");
// 置信度:徽章只显示档位,细分原因放在 tooltip,两边都是 parser.js 直接产出的
assert.equal(t("Not judged"), "未判定");
assert.equal(t("Low"), "低");
assert.equal(t("Medium"), "中");
assert.equal(t("High"), "高");
assert.equal(t("Not in any dictionary, so it cannot be judged"), "词库里查不到,没法判断");
assert.equal(t("Machine translation, not saved to the dictionary or confirmed yet"), "机器翻译,还没存进词库,也没人工确认");
assert.equal(t("Syntax kept as-is; nothing to translate"), "语法保留项,本来就不用翻译");
assert.equal(t("In the dictionary, but not marked as verified"), "词库里有,但还没标成已校验");
assert.equal(t("In the dictionary, but no category set"), "词库里有,但还没填分类");
assert.equal(t("Changed for this session only; not saved to the dictionary"), "只改了本次会话,没存进词库");
// 带分类名的那条:变量被 normalizeKey 折成 {},键里也是 {}
assert.equal(t("In the dictionary, verified, category “发型”"), "已在词库里,已校验,分类「发型」");
// 收藏星标按钮(带符号的整串)
assert.equal(t("★ Unfavorite"), "★ 取消收藏");
@@ -144,15 +160,19 @@ assert.equal(t("(weight 1.5)"), "(权重 1.5)");
// 插件名:侧边栏标签写死中文,面板/关于框走译表,三处必须同一个名字
assert.equal(t("Bilingual Prompt Manager"), "提示词翻译与管理");
assert.equal(t("Bilingual Prompt Inspector"), "提示词翻译与管理");
// 设置面板里「语言与外观」那一项
assert.equal(t("Appearance & Language"), "语言与外观");
assert.equal(t("Appearance and language saved"), "外观与语言已保存");
// 两个本地预设按钮各有一套文案,别混
assert.equal(t("Use LM Studio Local Preset"), "使用 LM Studio 本地预设");
assert.equal(t("Use Ollama Local Preset"), "使用 Ollama 本地预设");
assert.equal(t("Takes effect after saving"), "保存后生效");
assert.equal(t("Ollama default URL filled; select a pulled model ID, then Save & Test"), "已填入 Ollama 默认地址;选择已拉取的模型 ID 后保存并测试");
// 确认对话框:默认确认按钮是 "OK",必须译为「确认」而不是「正常」
assert.equal(t("OK"), "确认");
assert.equal(t("Cancel"), "取消");
assert.equal(t("This will overwrite node #3 prompt. Cannot be undone with Ctrl+Z."), "这会覆盖节点 #3 的提示词,Ctrl+Z 无法撤销。");
assert.equal(t("Delete “Fluffy”? Reference image will be deleted too."), "删除「Fluffy」?参考图也会一并删除。");
assert.equal(
t("Bilingual Prompt Inspector: Tag Manager, Dictionary, Packs & Assistant Settings"),
"提示词翻译与管理:标签管理、词库、词包与助手设置",
);
assert.equal(t("restored 3 hidden tags; press Ctrl+Z to undo"), "已恢复 3 个隐藏标签;按 Ctrl+Z 可撤销");
assert.equal(t("Node #3"), "节点 #3");
+8 -1
View File
@@ -21,7 +21,7 @@ import {
const source = "masterpiece, (looking at viewer:1.2), <lora:test:0.8>\nBREAK, hand_on_hip";
const dictionary = buildDictionaryIndex([
{ english: "masterpiece", chinese: "杰作", verified: true },
{ english: "masterpiece", chinese: "杰作", verified: true, category: "画质" },
{ english: "looking at viewer", chinese: "看向镜头", verified: true },
{ english: "hand on hip", chinese: "叉腰", verified: true },
]);
@@ -210,6 +210,13 @@ assert.ok(diagnostics.some((item) => item.code === "conflict"));
const knownToken = parsePrompt("masterpiece", dictionary)[0];
assert.equal(knownToken.confidence, "high");
// 可信度看词条整理得怎么样:填了分类才高,没填分类(含默认的「未分类」)只是中
const plain = buildDictionaryIndex([{ english: "masterpiece", chinese: "杰作", verified: true }]);
assert.equal(parsePrompt("masterpiece", plain)[0].confidence, "medium");
const uncategorized = buildDictionaryIndex([{ english: "masterpiece", chinese: "杰作", verified: true, category: "Uncategorized" }]);
assert.equal(parsePrompt("masterpiece", uncategorized)[0].confidence, "medium");
const unverified = buildDictionaryIndex([{ english: "masterpiece", chinese: "杰作", verified: false, category: "画质" }]);
assert.equal(parsePrompt("masterpiece", unverified)[0].confidence, "medium");
const machineToken = parsePrompt("cinematic", dictionary, new Map([["cinematic", { text: "电影感", source: "prompt-assistant" }]]))[0];
assert.equal(machineToken.confidence, "low");
+95
View File
@@ -0,0 +1,95 @@
"""一键跑全部测试(JS + Python)。
用法(在插件目录下,或者任何地方都行):
X:\\webui\\comfyui\\ComfyUI\\.venv\\Scripts\\python.exe tools\\main_test.py
会跑两轮:
1. tests\\*.mjs —— JS 测试
2. tests\\test_*.py —— Python 测试(PYTHONPATH 自己设好,不用手动加)
两轮都会实时输出,能看着它一条条走完。最后给一个汇总,
有失败的会单独列出来。全部通过退出码 0,有失败 1。
注意:必须用 ComfyUI 的 venv 跑(server.py 要 aiohttp),
用别的 Python 会出现 "No module named 'aiohttp'" 且用例数偏少。
"""
import os
import pathlib
import subprocess
import sys
# 这个脚本放在 tools/ 下(该目录被 .comfyignore 排除,不进发布包),
# 所以插件根目录在上一级。放根目录时也能用,这里两种位置都认。
ROOT = pathlib.Path(__file__).resolve().parent
if not (ROOT / "tests").is_dir():
ROOT = ROOT.parent
TESTS = ROOT / "tests"
# 不设成行缓冲的话,接管道(python main_test.py | tail)时我们自己的输出会被攒到最后,
# 跟子进程的实时输出插在一起,看起来顺序全乱。
sys.stdout.reconfigure(line_buffering=True)
def find_node():
"""找 node 可执行文件。
managed node 的目录号会随环境升级变(22.22.2-3 → 22.22.2-6),
写死路径会某天突然全部 FAIL,所以这里动态取最新的那个。
"""
versions = pathlib.Path.home() / ".workbuddy" / "binaries" / "node" / "versions"
if versions.is_dir():
candidates = [p for p in versions.iterdir() if p.is_dir() and p.name[:1].isdigit()]
for candidate in sorted(candidates, reverse=True):
exe = candidate / ("node.exe" if os.name == "nt" else "node")
if exe.exists():
return str(exe)
return "node"
def run_js():
files = sorted(TESTS.glob("*.mjs"))
if not files:
print("[js] 没有找到 tests/*.mjs,跳过")
return []
node = find_node()
print(f"\n=== JS 测试({len(files)} 个)用 {node} ===")
failed = []
for path in files:
result = subprocess.run([node, str(path)], cwd=ROOT)
if result.returncode == 0:
print(f" PASS {path.name}")
else:
print(f" FAIL {path.name}")
failed.append(path.name)
return failed
def run_python():
print(f"\n=== Python 测试 用 {sys.executable} ===")
env = dict(os.environ, PYTHONPATH=str(ROOT))
result = subprocess.run(
[sys.executable, "-m", "unittest", "discover", "-s", "tests", "-p", "test_*.py"],
cwd=ROOT,
env=env,
)
return [] if result.returncode == 0 else ["python 测试(见上面的输出)"]
def main():
if not TESTS.is_dir():
sys.exit(f"[test] 找不到 {TESTS}")
failed = run_js() + run_python()
print("\n" + "=" * 46)
if failed:
print(f"有 {len(failed)} 项失败:")
for name in failed:
print(f" - {name}")
sys.exit(1)
print("全部通过")
if __name__ == "__main__":
main()