Compare commits

...
10 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
18 changed files with 660 additions and 134 deletions
+36
View File
@@ -1,5 +1,41 @@
# 更新记录
## 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 设置面板
+9 -7
View File
@@ -1,4 +1,4 @@
# ComfyUI 双语提示词管理器 v2.1.0
# ComfyUI 双语提示词管理器 v2.1.8
> ComfyUI 双语提示词节点:把英文提示词拆成可点击的标签,边写边看中文解释、调顺序、试效果。
@@ -73,8 +73,10 @@
| 能力 | 说明 |
|---|:-:|
| **四种处理方式** | 仅翻译 / 翻译并优化 / 优化为 Anima / 按 Anima 官方顺序整理(不新增标签,只重排分段) |
| **自动识别模式** | 自动区分纯标签、纯自然语言、混合文本和翻译指令,不用手选模式 |
| **四种处理方式** | 仅翻译 / 翻译并优化 / 优化 / 按 Anima 官方顺序整理(不新增标签,只重排分段) |
| **优化按模式分派** | 标签模式下「优化」整理现有英文标签流(只吃英文);自然语言模式下改写为英文自然语句,中文会先译成英文。两种模式各用一套规则,可在助手设置里分别自定义 |
| **自动识别模式** | 自动区分纯标签、纯自然语言、混合文本和翻译指令,也可以在明细表上方手动指定;指定的模式跟着节点保存 |
| **自然语言模式** | 模式切到「自然语言」后,「按 Anima 顺序排序」自动隐藏——整段句子没有标签流可排 |
| **提示词诊断** | 自动标出可疑权重、重复标签、语法问题 |
| **快捷添加** | 底部的快捷输入框:写中文自动翻译成英文标签后追加,写英文标签直接追加;`Enter` 添加、`Shift+Enter` 换行、`Ctrl+Enter` 翻译并优化 |
@@ -85,7 +87,7 @@
| **多来源词库** | 内置词库 + 个人词库 + 社区词库包,每条解释都会标注来源 |
| **概念搜索** | 搜「丝袜」能返回连裤袜、长筒袜、过膝袜等相关标签,支持滚动分页加载 |
| **大型 Danbooru 词库(可选)** | 按需安装,覆盖面更广,不装也不影响其它功能 |
| **翻译服务可切换** | 纯词库(离线、免费)/ 百度翻译 / OpenAI 兼容 API / Ollama。翻译与解释可选任意一种;「翻译并优化」「优化为 Anima」恒定走 AI |
| **翻译服务可切换** | 纯词库(离线、免费)/ 百度翻译 / OpenAI 兼容 API / Ollama。翻译与解释可选任意一种;「翻译并优化」「优化」恒定走 AI |
| **密钥互不干扰** | AI 与百度密钥独立留存,切换翻译服务不会清空;均不写入工作流、不返回浏览器 |
### 上游提示词接管
@@ -134,7 +136,7 @@
节点上可以大致分成四块:
1. **英文原文(实际输出)**——节点真正输出给下游的文本。上面有「编辑文本 / 完成」「清空」「插入换行」「收藏」「优化为 Anima」「按 Anima 顺序排序」几个按钮。
1. **英文原文(实际输出)**——节点真正输出给下游的文本。上面有「编辑文本 / 完成」「清空」「插入换行」「收藏」「优化」「按 Anima 顺序排序」几个按钮。
2. **中文对照区**——英文标签逐条对应的中文,只用于阅读,不进入输出,也不会传给下游。
3. **明细表**——每行是一个标签:来源、翻译、权重、分类,右侧按行状态给出操作(翻译 / 确认并存 / 重新翻译 / 编辑中文 / 收藏 / 复制 / 删除个人词条)。上方有搜索框、模式选择、颜色模式、筛选(全部 / 未知 / 机器翻译 / 个人词库 / 收藏)和问题提示。
4. **快捷添加**——底部输入框,中文进英文出(自动翻译后追加),英文则直接追加。
@@ -193,11 +195,11 @@ git clone --depth 1 https://github.com/mengshengzhijie/ComfyUI-Bilingual-Prompt-
|---|---|
| **翻译服务** | 翻译 / 解释按此选择:纯词库(离线、免费)/ 百度翻译(需自行申请开放平台的 APP ID 与密钥)/ OpenAI 兼容接口 / Ollama(本地)。选后面两个时,它们本身就是下面的 AI 服务 |
| **百度翻译** | 独立配置 APP ID 与密钥,切换翻译服务不会清空 |
| **AI 服务** | 「翻译并优化」「优化为 Anima」恒定走这里。翻译服务选了 OpenAI 兼容 / Ollama 时,下面直接就是那个后端的配置;选纯词库 / 百度翻译时,这里多出一个「AI 后端」下拉,决定优化用哪一套。两个后端各存一套配置,切换不清空: |
| **AI 服务** | 「翻译并优化」「优化」恒定走这里。翻译服务选了 OpenAI 兼容 / Ollama 时,下面直接就是那个后端的配置;选纯词库 / 百度翻译时,这里多出一个「AI 后端」下拉,决定优化用哪一套。两个后端各存一套配置,切换不清空: |
| ↳ OpenAI 兼容 | API 地址、模型、可选 API Key、温度、超时,另有「使用 LM Studio 本地预设」一键填入 `1234/v1`。远端地址必须 https,本机回环(127.0.0.1 / localhost)才允许 http;内网、保留、链路本地地址一律拒绝 |
| ↳ Ollama | API 地址、模型、温度、超时,没有密钥,另有「使用 Ollama 本地预设」一键填入 `11434`。地址只能是本机回环地址,其它一律拒绝 |
两个后端还各有一套规则(翻译 / 翻译并优化 / 优化):本地小模型与云端大模型要写的提示词往往不一样,分开存可以随时切换后端而不用重改规则。地址不合规时会在发出请求前直接报错。
两个后端还各有一套规则(翻译 / 翻译并优化 / 优化 / 自然语言):本地小模型与云端大模型要写的提示词往往不一样,分开存可以随时切换后端而不用重改规则。其中「自然语言」那套只在自然语言模式下被「优化」使用,结果按完整英文句子保留标点,不套 Anima 的标点限制。地址不合规时会在发出请求前直接报错。
### 大型词库(可选)
+41 -2
View File
@@ -46,11 +46,18 @@ DEFAULT_OPTIMIZATION_RULE = """你是 Anima 模型英文提示词优化器。输
对于明确为成年人的合法成人内容,准确保留并规范相关成人标签,不弱化、不含糊替换,也不主动增加原文没有的成人内容;不得将未成年人或年龄不明确的人物成人化。
标签使用英文逗号分隔;自然语言只放在末尾并使用英文句点分隔。仅使用 Anima 支持的英文标点和语法:逗号、句点、圆括号与冒号权重、@ 画师前缀、下划线、连字符、撇号、百分号,以及尖括号 LoRA 语法。"""
DEFAULT_NATURAL_RULE = """你是面向现代生图模型(Krea、FLUX、Qwen-Image 等)的英文自然语言提示词助手。把用户输入整理成一段通顺、具体、可直接使用的英文描述;只返回最终结果,不输出 Markdown、标题、引号、解释、警告或免责声明。
输入是中文、英文或中英混合都可以:含中文时先忠实译为英文再整理表达,纯英文时只做表达优化,不改变原意。输入是逗号分隔的标签时,先理解这些标签的含义,再改写成一段连贯的英文自然语言描述。
保留主体、动作与状态、外貌与服装、环境与背景、光线、镜头与构图、风格与画材、氛围与情绪;原文没有给出的要素不要凭空添加,也不要删掉原文已经写明的限制。
严格保留人物数量、成年人身份、角色名、作品名、否定关系、左右位置和安全等级,不得擅自换人、增加人物或改变主题。
对于明确为成年人的合法成人内容,准确保留相关描述,不弱化、不含糊替换,也不主动增加原文没有的成人内容;不得将未成年人或年龄不明确的人物成人化。
用完整的英文句子输出,按语法正常使用逗号、句点和连接词;不要写成逗号分隔的标签列表,也不要使用括号权重、@ 画师前缀或尖括号语法。"""
DEFAULT_CONFIG = {
"schema_version": 4,
# 翻译/解释走哪个服务;OpenAI 兼容与 Ollama 本身就是两个平级选项
"translate_service": "dictionary", # dictionary | baidu | openai_compatible | ollama
# 选词库/百度时翻译不走 AI,但「翻译并优化」「优化为 Anima」恒走 AI —— 这时用它决定读哪一套
# 选词库/百度时翻译不走 AI,但「翻译并优化」「优化」恒走 AI —— 这时用它决定读哪一套
"ai_provider": "openai_compatible", # openai_compatible | ollama
# OpenAI 兼容:远端必须 https,本机回环 http 也放行(本地 LM Studio 之类)
"openai_base_url": "",
@@ -76,9 +83,11 @@ DEFAULT_CONFIG = {
"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,
}
# 规则按后端分成两套,遍历时统一用这两个前缀
@@ -86,7 +95,7 @@ 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")
RULE_KEYS = ("translation_rule", "translate_optimize_rule", "optimization_rule", "natural_rule")
BAIDU_TRANSLATE_ENDPOINT = "https://fanyi-api.baidu.com/api/trans/vip/translate"
@@ -289,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
@@ -488,6 +526,7 @@ class AssistantStore:
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 ("openai_api_key", "baidu_secret_key")
} | {
+5 -3
View File
@@ -33,10 +33,11 @@
- `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 v4:`translate_service` = dictionary / baidu / openai_compatible / ollama;后两个同时是 AI 后端,选前两个时由 `ai_provider` 决定优化走哪一套)、两套 AI 配置(OpenAI 兼容 / Ollama 各一套地址、模型、温度、超时与三条规则)、百度 APP ID 与密钥,以及独立留存的 AI Key 与百度密钥。
- 保存翻译服务类型(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_*` 槽。
@@ -117,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(标签 + 自然语言)不算自然语言模式——里面还有标签流可排。
+278 -71
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.1.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");
@@ -274,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: [] },
@@ -286,7 +306,8 @@ 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(),
@@ -366,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)));
@@ -439,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 变高 → 文本框
@@ -467,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);
@@ -770,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();
@@ -1423,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";
@@ -1455,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]) {
@@ -1464,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");
@@ -1515,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);
@@ -1530,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,
@@ -1544,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();
};
@@ -1570,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) {
@@ -1620,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");
@@ -1632,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();
@@ -1681,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";
@@ -1698,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 = () => {
@@ -1764,7 +1940,8 @@ function createPanel(node, textWidget) {
source: override.source,
status: "session",
confidence: "medium",
confidenceLabel: "Medium · session-only",
confidenceLabel: "Medium",
confidenceReason: "Changed for this session only; not saved to the dictionary",
};
});
@@ -1969,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();
};
@@ -2071,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 {
@@ -2125,6 +2310,9 @@ function createPanel(node, textWidget) {
// 排序写入之后按钮就变成「取消」:点一下把提示词和分类视图一起还原到排序前。
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"
@@ -2285,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);
@@ -2803,8 +2991,9 @@ function createPanel(node, textWidget) {
state.lastText = text;
// 排序完又手动改了文本,「回到排序前」会把这些改动一起吞掉,取消入口随之失效
if (state.animaSortSnapshot && text.trim() !== state.animaSortSnapshot.after) state.animaSortSnapshot = null;
updateSortButton();
state.modeInfo = detectInputMode(text, state.modePreference);
// 按钮显隐依赖 modeInfo,必须排在它更新之后
updateSortButton();
state.tokens = parseTokens(text);
queueLargeLookup(state.tokens);
// 重复分组要在渲染两个视图之前算好,标签区和明细表都吃这份数据
@@ -2897,7 +3086,7 @@ 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));
// 查找状态下不给拖拽手柄:落点是按可见行算的,藏着行会把标签挪到错误的位置
@@ -2915,6 +3104,14 @@ function createPanel(node, textWidget) {
: `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 });
@@ -2980,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();
@@ -3015,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”");
@@ -3328,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,
@@ -3344,6 +3533,7 @@ function createPanel(node, textWidget) {
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, tableSearch, table, detailsHiddenBar);
@@ -3477,8 +3667,14 @@ function createPanel(node, textWidget) {
event.stopPropagation();
action();
});
setTitle(optimizeChineseButton, "Only optimizes existing English; does not translate");
bindMirrorAction(optimizeChineseButton, runAnimaOptimize);
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();
@@ -3558,6 +3754,9 @@ function createPanel(node, textWidget) {
});
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();
});
@@ -3650,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() {
+30 -14
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";
@@ -1218,7 +1226,7 @@ function injectBpiStyles() {
.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{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}
@@ -1332,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)}
@@ -1369,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}
@@ -1409,7 +1425,7 @@ function injectBpiStyles() {
.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 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)))}
+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,
+23 -10
View File
@@ -103,7 +103,10 @@ 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 顺序排序",
@@ -115,7 +118,6 @@ const ZH = {
"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":
"输入中文、英文、别名或概念;点击结果即可插入英文标签",
@@ -177,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”":
@@ -319,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",
// ── 弹窗:保存 / 编辑收藏 ────────────────────────────────────────────
@@ -333,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": "不换图就保持原样,选一张新的会替换掉原来的",
@@ -403,8 +406,8 @@ const ZH = {
"Translation Service": "翻译服务",
"Dictionary only (no API)": "仅词库(不需要接口)",
"Baidu Translate": "百度翻译",
"Only this service's settings are shown below. \"Translate & Optimize\" and \"Optimize to Anima\" always use AI: choose OpenAI-compatible API or Ollama above to configure or test them. Switching service never clears saved keys.":
"下面只显示当前所选服务的设置。「翻译并优化」和「优化为 Anima」始终走 AI:要配置或测试它们,请在上方选择「OpenAI 兼容接口」或「Ollama」。切换服务不会清掉已保存的密钥。",
"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": "清除已保存的百度密钥",
@@ -621,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: {}"。
@@ -758,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…": "已暂停,等待确认…",
@@ -789,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": "「{}」;请在预览里改好再确认",
};
+7 -2
View File
@@ -692,7 +692,7 @@ async function buildAssistantForm(host, loading) {
const error = element("div", "bpi-status");
error.style.gridColumn = "1 / -1";
// 翻译服务:翻译/解释按此选择;“翻译并优化”“优化为 Anima”恒走 AI
// 翻译服务:翻译/解释按此选择;“翻译并优化”“优化”恒走 AI
const serviceLabel = element("label", "", "Translation Service");
const service = element("select");
for (const [value, label] of [
@@ -708,7 +708,7 @@ async function buildAssistantForm(host, loading) {
// 后端已把旧的 "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 to Anima" always use AI: choose OpenAI-compatible API or Ollama above to configure or test them. Switching service never clears saved keys.');
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);
@@ -877,11 +877,13 @@ async function buildAssistantForm(host, loading) {
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),
},
};
form.appendChild(error);
@@ -909,6 +911,7 @@ async function buildAssistantForm(host, loading) {
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";
});
@@ -965,9 +968,11 @@ async function buildAssistantForm(host, loading) {
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,
});
}
+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.1.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.1.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" }
+15 -6
View File
@@ -27,6 +27,7 @@ from .assistant_store import (
normalize_ai_base_url,
openai_chat_endpoint,
sanitize_anima_prompt,
sanitize_natural_prompt,
translation_direction,
)
from .dictionary_store import DictionaryStore, normalize_key
@@ -205,12 +206,14 @@ async def _call_ai_assistant(action, text, instruction, config, connection_test=
config.get("ai_provider", "openai_compatible"), "openai"
)
if not config.get(f"{prefix}_model"):
raise ValueError("尚未填写 AI 模型名称(“翻译并优化”“优化为 Anima”以及 AI 翻译都需要)")
raise ValueError("尚未填写 AI 模型名称(“翻译并优化”“优化”以及 AI 翻译都需要)")
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 = "你是连接测试助手。"
@@ -224,8 +227,12 @@ 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[f"{prefix}_timeout_seconds"])
@@ -275,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
@@ -283,8 +292,8 @@ 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;翻译/解释在翻译服务就是某个 AI 后端时也走 AI
needs_ai = action in {"translate_optimize", "optimize"} or (
# “翻译并优化”“优化”恒走 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:
@@ -357,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("输入内容为空")
+43
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,
@@ -18,6 +19,7 @@ from assistant_store import (
normalize_ai_base_url,
openai_chat_endpoint,
sanitize_anima_prompt,
sanitize_natural_prompt,
translation_direction,
)
from dictionary_store import DictionaryStore
@@ -315,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")
+21 -5
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"), "★ 取消收藏");
+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()