Compare commits

...
7 Commits
Author SHA1 Message Date
梦圣智杰 575ab1814f chore: 发布 v2.1.9 / Release v2.1.9 2026-10-29 00:54:45 +08:00
梦圣智杰 7759128dac fix: 设置面板两项统一靠左对齐 / Align the two settings entries to the left edge
- 「语言与外观」与「助手设置」两块的内容改为顶到最左边,左边缘齐平,看起来是一整块
- 标题仍在:由各自的 h3 显示,收起的只是 ComfyUI 重复渲染出来的那个名称列
2026-10-29 00:51:33 +08:00
梦圣智杰 2cf72b1a10 feat: 助手设置可自动获取模型列表 / Fetch model list automatically in assistant settings
- OpenAI 兼容与 Ollama 两段的「模型名称」旁新增「获取模型」按钮,拉到的名字填进输入框自带的下拉,点箭头即可选择,名称也照旧可以手写
- 下拉挂在输入框上、不弹窗,ComfyUI 设置面板不用关也能用;按键即重新拉取,按钮显示拿到了几个
- 密钥以输入框里填的为准,留空就用已保存的那一份,只随这一次请求发出
- 列表只在本次会话内有效,地址一改自动清空,不写进配置文件
- 服务端返回非 JSON 的内容(纯文本报错、登录页之类)时,界面直接显示原文与状态码
2026-10-29 00:02:32 +08:00
梦圣智杰 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
15 changed files with 659 additions and 71 deletions
+31
View File
@@ -1,5 +1,36 @@
# 更新记录
## v2.1.9(2026-10-29)
### 助手设置里可以自动获取模型列表
- OpenAI 兼容和 Ollama 两段的「模型名称」旁边多了一个「获取模型」按钮:拿输入框里当前填的地址问一次服务端有哪些模型(OpenAI 兼容问 `/models`,Ollama 问 `/api/tags`),拉到的名字填进输入框自带的下拉——点输入框里的箭头就能选,名称也照旧可以手写。
- 下拉用浏览器原生的方式挂在输入框上,不弹任何窗口:助手设置长在 ComfyUI 的「设置」面板里,浮层会被它挡住点不动。
- 再点一次按钮就是重新拉取,按钮上显示拿到了几个;地址改了会自动清空,列表只在本次会话内有效、不写进配置文件。
- 这次请求用你在 API Key 输入框里填的密钥;输入框留空就用已经保存的那一份(密钥仍不会回传给浏览器),只会随这一次请求发出。
- 服务端返回看不懂的内容时(比如缺密钥时 DeepSeek 只回一行纯文本 `Authentication Fails`、或网关甩回来一整页登录页),界面直接显示那句原话和状态码,不再崩一段 `Expecting value: line 1 column 1 (char 0)`。「保存并测试连接」和翻译 / 优化走的是同一套解析,一起修好了。
- 地址照旧走既有的白名单:远端必须 https、内网与链路本地地址驳回、Ollama 只认本机。
### 设置面板两项统一靠左对齐
- 「语言与外观」与「助手设置」两块的内容顶到最左边,左边缘齐平,看起来是一整块。标题仍在,由各自的标题行显示,收起的只是 ComfyUI 重复渲染出来的那个名称列。
## v2.1.8(2026-10-26)
### 新增「适应高度」开关
- 提示词一多,英文标签区就卡在写死的 360 像素上限里,只能内部滚动,把节点拉大也没用。侧边栏「标签管理」的明细表上方(「颜色」按钮右边)多了一个「适应高度」滑动开关,跟着所选节点走——切到别的节点就显示那个节点自己的状态。
- 打开后:面板不再被固定上限锁住,多出来的高度全部给英文标签区,提示词能多显示很多行;开关打开时节点会自动撑高一次(按被压住的内容量撑),之后想拉多大多大。
- 关掉就回到原来的自动高度。开关状态跟着节点保存,重开工作流还在。
- 默认关闭,提示词少的时候和以前完全一样。
### 「优化」按模式分派,新增自然语言规则
- 节点上的「优化为 Anima」改名为「优化」:标签模式下和以前一样,整理英文标签流、只接受英文;模式切到「自然语言」后改用新的自然语言规则,输出完整英文句子,中文也可以直接优化(会先译成英文,状态栏会提示一句)。
- 助手设置里 OpenAI 兼容与 Ollama 各多一条「自然语言规则」,可以分别自定义,也能单独恢复默认。
- 模式切到「自然语言」时「按 Anima 顺序排序」自动隐藏——整段句子没有标签流可排;已经排过序时按钮会保留,方便点「取消排序」。
- 手动选的「自然语言」也算,不用非得自动检测出来;模式选择现在跟着节点保存,重开工作流不用重选。
## v2.1.5(2026-10-13)
### 可信度徽章改成按词条整理程度分档
+11 -7
View File
@@ -1,4 +1,4 @@
# ComfyUI 双语提示词管理器 v2.1.5
# ComfyUI 双语提示词管理器 v2.1.9
> 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,13 @@ 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`。地址只能是本机回环地址,其它一律拒绝 |
两个后端还各有一套规则(翻译 / 翻译并优化 / 优化):本地小模型与云端大模型要写的提示词往往不一样,分开存可以随时切换后端而不用重改规则。地址不合规时会在发出请求前直接报错。
模型名称旁边的「获取模型」按钮会拿输入框里当前填的地址问一次服务端有哪些模型(OpenAI 兼容问 `/models`,Ollama 问 `/api/tags`),拉到的名字直接填进输入框自带的下拉里——点输入框里的箭头就能选,也可以完全不管它自己手写。再点一次按钮就是重新拉取。列表只在本次会话内有效、地址一改就作废,不写进配置文件。密钥以输入框里填的为准,输入框留空就用你已经保存的那一份;无论哪种都只随这一次请求发出,不会随着设置一起回传给浏览器。服务端返回看不懂的内容(纯文本报错、登录页之类)时会原话告诉你,不会甩一段 JSON 报错。
两个后端还各有一套规则(翻译 / 翻译并优化 / 优化 / 自然语言):本地小模型与云端大模型要写的提示词往往不一样,分开存可以随时切换后端而不用重改规则。其中「自然语言」那套只在自然语言模式下被「优化」使用,结果按完整英文句子保留标点,不套 Anima 的标点限制。地址不合规时会在发出请求前直接报错。
### 大型词库(可选)
+103 -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"
@@ -196,6 +205,68 @@ def openai_chat_endpoint(base_url):
return f"{base}/chat/completions"
def openai_models_endpoint(base_url):
"""Return a model-list endpoint for an OpenAI-compatible base URL."""
base = str(base_url or "").strip().rstrip("/")
if not base:
return ""
if base.endswith("/models"):
return base
if base.endswith("/chat/completions"):
base = base[: -len("/chat/completions")].rstrip("/")
return f"{base}/models"
def response_snippet(body):
"""错误应答常常是一整页 HTML 或带换行的纯文本;压成一行并截断,免得提示框被撑爆。"""
return re.sub(r"\s+", " ", str(body or "")).strip()[:160]
def upstream_error_message(body, status, who):
"""把上游错误应答压成一句能看懂的中文。
各家返回的不是齐步走的:OpenAI 系给 JSON,DeepSeek 缺密钥时回的是纯文本一行
“Authentication Fails”,还有网关直接甩一页 HTML。所以先看原文能不能解析成 JSON,
解析不了就退化成原文片段,绝不在取错误信息的路上把请求整条打崩。
`who` 是“Ollama” / “API” 这一类的来源名,只在连原文都没有时用来兜底。
"""
message = None
try:
data = json.loads(body)
except (TypeError, UnicodeDecodeError, json.JSONDecodeError):
data = None
if isinstance(data, dict):
detail = data.get("error")
if isinstance(detail, dict):
detail = detail.get("message")
message = str(detail).strip() if detail else None
hint = ";请检查 API 密钥是否已填写、是否有效" if status in {401, 403} else ""
return f"{message or response_snippet(body) or f'{who} 请求失败'}(HTTP {status}{hint})"
def parse_json_body(body, who):
"""解析成功应答;不是 JSON 或不是字典时给一句能看懂的提示,而不是抛堆栈。"""
try:
data = json.loads(body)
except (TypeError, UnicodeDecodeError, json.JSONDecodeError) as error:
raise ValueError(f"{who} 返回的不是 JSON(可能是登录页或错误页):{response_snippet(body)}") from error
if not isinstance(data, dict):
raise ValueError(f"{who} 返回的内容无法识别:{response_snippet(body)}")
return data
def ollama_tags_endpoint(base_url):
"""Return the local-model list endpoint for an Ollama base URL."""
base = str(base_url or "").strip().rstrip("/")
if not base:
return ""
if base.endswith("/v1"):
base = base[:-3].rstrip("/")
if base.endswith("/api/chat"):
base = base[: -len("/api/chat")].rstrip("/")
return f"{base}/api/tags"
def baidu_translate_params(appid, secret_key, query, to_lang, from_lang="auto", salt=None):
"""组装百度通用文本翻译接口参数;sign = MD5(appid + q + salt + 密钥)。"""
appid = str(appid or "")
@@ -289,6 +360,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 +588,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)
→ 结果写回绿色暂存区
+24 -2
View File
@@ -16,9 +16,9 @@
## 3. 翻译、优化和同步必须分阶段
决定:设置“仅翻译”“翻译并优化”“优化为 Anima”“同步到英文输出”四个独立动作。
决定:设置“仅翻译”“翻译并优化”“优化”“同步到英文输出”四个独立动作。
原因:翻译与提示词创作不是同一任务。用户需要看见模型做了什么,并决定是否覆盖实际输出。每个动作使用独立规则,避免一个通用提示词同时承担忠实翻译、扩写和格式整理而行为混乱。
原因:翻译与提示词创作不是同一任务。用户需要看见模型做了什么,并决定是否覆盖实际输出。每个动作使用独立规则,避免一个通用提示词同时承担忠实翻译、扩写和格式整理而行为混乱。“优化”会按当前输入模式分派不同规则,见第 19 条。
## 4. Anima 排序只在按下按钮后执行
@@ -116,3 +116,25 @@
- GitHub 首个公开正式 Release 使用 `v1.0.0`。
- 后续发布应使用语义化版本号,不要把内部演进代次和公开版本号混为一谈。
- 兼容修复递增补丁号;向后兼容的新功能递增次版本;破坏工作流或数据格式时才递增主版本,并提供迁移说明。
## 19. “优化”按输入模式分派,而不是再加一个按钮
决定:节点顶部只保留一个“优化”按钮。标签模式下走 Anima 优化规则(只接受英文),自然语言模式下走独立的自然语言规则(中文先译后整理);模式切到“自然语言”时“按 Anima 顺序排序”隐藏。
原因:Krea、FLUX 一类新模型直接吃自然语言,再加一个按钮只会让顶部操作越排越长。模式下拉已经能判定当前是标签流还是整段句子,用它分派比让用户记两个按钮省事;排序只重排标签流,对整段句子没有意义,留着反而误导。
两套规则必须分开:`sanitize_anima_prompt` 会把句点收成逗号并只放行 Anima 标点白名单,完整句子喂进去会被判废,所以自然语言结果改走 `sanitize_natural_prompt`。
代价:已经排过序时要保留排序按钮,否则“取消排序”的入口会跟着一起消失。另外 mixed(标签 + 自然语言)不算自然语言模式——里面还有标签流可排。
## 20. 模型列表由服务端代问,且只在本次会话内缓存
决定:助手设置的「模型名称」输入框保持自由输入,旁边另加一个「获取模型」按钮。按钮把输入框里当前填的地址和密钥发给插件自己的 `/bpi/assistant/models`,由服务端去问那个 AI 后端(OpenAI 兼容问 `/models`,Ollama 问 `/api/tags`),把名字填进输入框自带的原生下拉(`<datalist>`);按钮显示「获取模型(N)」,再点一次就是重新拉取。
不弹窗是有原因的:助手设置长在 ComfyUI 的「设置」面板里,任何浮层都会被这块面板压住、点不动,逼着用户先关掉设置面板。下拉是浏览器原生的输入框附件,既没有层级问题,也不用自己处理定位与溢出。
原因:地址白名单(远端必须 https、内网与链路本地地址驳回、Ollama 只认本机回环)是整个插件的安全边界。让浏览器直接去拉,这份校验就得在前端重写一遍,而且可以绕过;交给服务端代发,还能顺带解决输入框里那份还没保存的密钥往哪发:填了就用填的,留空就用已存的那份,都只用于这一次请求,返回给浏览器的始终只有模型名字。
列表不持久化有两点考虑:各家网关的模型随时增减,写进配置文件第二天就是过期数据;地址一改缓存必须作废,把「这次问的地址」和列表绑在同一个闭包里最省事,也不用为它设计一份需要清理的存储格式。
代价:第一次点按钮要多等一次 `/models` 往返;各家返回结构不一样(`data[].id` 与 `models[].name`),得分别解析。面板一律不去猜哪个模型能聊天——embedding、rerank 之类照常列出,免得误伤名字奇怪但确实可用的模型。
+149 -22
View File
@@ -57,9 +57,14 @@ import {
} from "./bpi_shared.js";
const NODE_NAME = "BilingualPromptInspector";
const EXTENSION_VERSION = "v2.1.5";
const EXTENSION_VERSION = "v2.1.9";
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);
@@ -2154,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 {
@@ -2208,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"
@@ -2886,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);
// 重复分组要在渲染两个视图之前算好,标签区和明细表都吃这份数据
@@ -3408,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,
@@ -3424,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);
@@ -3557,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();
@@ -3638,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();
});
@@ -3730,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() {
+29 -5
View File
@@ -199,6 +199,18 @@ async function getAssistantConfig() {
return result.data;
}
// 拿表单当前填的地址和密钥问一次「你有哪些模型」;后端不读已保存的密钥,也不写配置
async function listAssistantModels(payload) {
const response = await bpiFetch(`${API_ROOT}/assistant/models`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const result = await response.json().catch(() => null);
if (!response.ok || !result?.success) throw new Error(result?.error || "Failed to fetch models");
return Array.isArray(result.data?.models) ? result.data.models : [];
}
// 读 data/token_colors.json;用户可以不改源码只改这个文件管理节点标签卡的颜色池。
// 文件不在或格式不对时后端回退内置默认,这里只管透传。
let tokenColorsPromise = null;
@@ -1226,7 +1238,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}
@@ -1385,6 +1397,11 @@ function injectBpiStyles() {
.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-model-line{display:flex;gap:6px;align-items:stretch;min-width:0}
.bpi-model-line input{flex:1 1 auto;min-width:0}
.bpi-model-line .bpi-button{flex:none;white-space:nowrap;background-color:var(--bpm-accent);border-color:var(--bpm-accent-border);color:#fff}
.bpi-model-line .bpi-button:hover{background-color:var(--bpm-accent-hover);border-color:var(--bpm-accent-border);color:#fff}
.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}
@@ -1411,9 +1428,15 @@ function injectBpiStyles() {
.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}
/* 设置面板每一项 = 名称列 + 控件列,名称列宽度跟着名字长短走:插件这两项一个叫
「语言与外观」一个叫「助手设置」,字数不同,右边的内容就被推得差了一格。
让名称列不占位、控件列占满,两块都从最左边开始,标题由各自的 h3 显示。
只在「我们的块不在第一列」时才生效,结构对不上时不会把内容自己藏掉。 */
*:has(> :not(:first-child) > .bpi-settings-form){align-items:flex-start}
*:has(> :not(:first-child) > .bpi-settings-form) > :first-child{display:none}
*:has(> :not(:first-child) > .bpi-settings-form) > :not(:first-child){flex:1 1 0%;min-width:0;justify-content:flex-start}
/* ComfyUI 给控件列自带 justify-end:宽度一占满就会把内容推到最右,这里改回靠左 */
*:has(> .bpi-settings-form){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}
@@ -1425,7 +1448,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)))}
@@ -1487,6 +1510,7 @@ export {
importTags,
bulkUpdateTags,
injectBpiStyles,
listAssistantModels,
listModelNames,
listSavedPrompts,
loadDictionary,
+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,
+16 -4
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 顺序排序",
@@ -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": "清除已保存的百度密钥",
@@ -428,6 +431,11 @@ const ZH = {
"Ollama only accepts a loopback address on this machine; anything else is rejected before a request is sent.":
"Ollama 只能填本机回环地址;其它地址在发出请求前就会被拒绝。",
"Model Name": "模型名称",
"Fetch Models": "获取模型",
"Fetch Models ({})": "获取模型({})",
"Fetching...": "获取中…",
"Fetched {} models": "已获取 {} 个模型",
Close: "关闭",
"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 后保存并测试",
@@ -621,6 +629,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: {}"。
@@ -795,7 +806,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": "「{}」;请在预览里改好再确认",
};
+120 -6
View File
@@ -24,6 +24,7 @@ import {
bulkUpdateTags,
applyBpiAppearance,
injectBpiStyles,
listAssistantModels,
listSavedPrompts,
loadDictionary,
loadPreferences,
@@ -584,17 +585,54 @@ function openImportPreview(payload) {
modal.addEventListener("mousedown", (event) => event.stopPropagation());
}
// 设置面板每一项 = 名称列 + 控件列,名称列宽度跟着设置项名字长短走:
// 插件这两项一个「语言与外观」一个「助手设置」,字数不同,右边的内容就被推得差了一格。
// CSS 里已经用 :has() 处理过一次,这里再兜一次——面板重建后内联样式会丢,
// 而设置面板每次打开都会重新调用下面的 control,正好顺势对齐一次。
// 只认「恰好两列、且第二列装着我们内容」的那一行,结构对不上就不动,免得误伤别的元素。
function alignSettingRow(node) {
let row = node.parentElement;
while (row && row !== document.body) {
if (row.children.length === 2 && row.children[1].contains(node)) break;
row = row.parentElement;
}
if (!row || row === document.body) return;
const [name, control] = row.children;
name.style.display = "none";
control.style.flex = "1 1 0%";
control.style.minWidth = "0";
// ComfyUI 给控件列自带 justify-end:宽度一占满,它反而把内容整个推到右边。
// 改回靠左,并让包着我们的那一层也占满,否则内容仍会缩在右侧。
control.style.justifyContent = "flex-start";
if (node.parentElement && node.parentElement !== control) {
node.parentElement.style.flex = "1 1 0%";
node.parentElement.style.minWidth = "0";
}
}
function alignWhenMounted(node, frames = 30) {
if (node.isConnected) {
alignSettingRow(node);
return;
}
if (frames > 0) requestAnimationFrame(() => alignWhenMounted(node, frames - 1));
}
// 外观 / 语言从助手设置里拆出来,和它并列挂在「设置」面板的插件分类下。
// 改动立刻生效并单独保存,不需要去点助手设置的保存按钮。
let appearanceSettingsNode = null;
function appearanceControl() {
if (appearanceSettingsNode) return appearanceSettingsNode;
if (appearanceSettingsNode) {
alignWhenMounted(appearanceSettingsNode);
return appearanceSettingsNode;
}
appearanceSettingsNode = element("div", "bpi-settings-form");
appearanceSettingsNode.appendChild(element("h3", "", "Appearance & Language"));
const loading = element("div", "bpm-loading", "Loading settings...");
appearanceSettingsNode.appendChild(loading);
buildAppearanceForm(appearanceSettingsNode, loading);
alignWhenMounted(appearanceSettingsNode);
return appearanceSettingsNode;
}
@@ -667,12 +705,16 @@ async function buildAppearanceForm(host, loading) {
let assistantSettingsNode = null;
function assistantSettingsControl() {
if (assistantSettingsNode) return assistantSettingsNode;
if (assistantSettingsNode) {
alignWhenMounted(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);
alignWhenMounted(assistantSettingsNode);
return assistantSettingsNode;
}
@@ -692,7 +734,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 +750,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);
@@ -729,6 +771,65 @@ async function buildAssistantForm(host, loading) {
list.push(form.querySelector(`label[for="${input.id}"]`), input);
return input;
};
// 模型名称这一行:输入框照旧可以手写,旁边的按钮去问一次服务端有哪些模型,填进输入框自带的下拉里。
// 用原生 datalist 而不是弹窗:助手设置长在 ComfyUI 的「设置」面板里,浮层会被它盖住且点不动。
const addModelRow = (list, name, value, placeholder, options) => {
const label = element("label", "", "Model Name");
label.htmlFor = `bpi-field-${name}`;
const input = element("input");
input.id = label.htmlFor;
input.name = name;
input.value = value ?? "";
setPlaceholder(input, placeholder);
// datalist 本身不渲染,挂在 form 里跟着这块设置 DOM 一起活;选项只在本次会话内有效
const dataList = element("datalist");
dataList.id = `bpi-models-${name}`;
input.setAttribute("list", dataList.id);
let models = null;
const trigger = button("Fetch Models", () => pick());
const line = element("div", "bpi-model-line");
line.append(input, trigger);
form.append(label, line, dataList);
list.push(label, line, dataList);
const countLabel = () => (models ? `Fetch Models (${models.length})` : "Fetch Models");
const forget = () => {
models = null;
dataList.textContent = "";
setText(trigger, countLabel());
};
// 地址改了就地作废:下拉里不能留着上一个地址的模型
options.baseUrl().addEventListener("change", forget);
const pick = async () => {
trigger.disabled = true;
setText(trigger, models ? countLabel() : "Fetching...");
try {
const url = options.baseUrl().value.trim();
const keyInput = options.apiKey();
models = await listAssistantModels({
backend: options.backend,
base_url: url,
api_key: keyInput ? keyInput.value.trim() : "",
});
dataList.textContent = "";
for (const model of models) {
const option = element("option");
option.value = model; // 模型名是服务端原样返回值,不参与翻译
dataList.appendChild(option);
}
setText(trigger, countLabel());
input.focus();
setText(error, `Fetched ${models.length} models`);
error.dataset.kind = "ok";
} catch (fetchError) {
setText(trigger, countLabel());
setText(error, fetchError.message);
error.dataset.kind = "error";
} finally {
trigger.disabled = false;
}
};
return input;
};
// 清除已保存的密钥:开关样式,打开后要保存才生效。返回的 input 上挂了整行(`.row`)
const clearSwitch = (labelText) => {
const row = element("label", "bpi-switch-row");
@@ -798,7 +899,11 @@ async function buildAssistantForm(host, loading) {
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 openaiModel = addModelRow(openaiOnly, "assistant-openai-model", config.openai_model, "provider model ID", {
backend: "openai_compatible",
baseUrl: () => openaiBaseUrl,
apiKey: () => openaiApiKey,
});
const presetLine = element("div", "bpm-toolbar");
presetLine.style.gridColumn = "1 / -1";
const lmStudioPreset = button("Use LM Studio Local Preset", () => {
@@ -837,7 +942,11 @@ async function buildAssistantForm(host, loading) {
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 ollamaModel = addModelRow(ollamaOnly, "assistant-ollama-model", config.ollama_model, "e.g.: qwen3:8b", {
backend: "ollama",
baseUrl: () => ollamaBaseUrl,
apiKey: () => null,
});
const ollamaPresetLine = element("div", "bpm-toolbar");
ollamaPresetLine.style.gridColumn = "1 / -1";
const ollamaPreset = button("Use Ollama Local Preset", () => {
@@ -877,11 +986,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 +1020,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 +1077,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,
});
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "comfyui-bilingual-prompt-inspector",
"version": "2.1.5",
"version": "2.1.9",
"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.5"
version = "2.1.9"
# 与 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" }
+74 -15
View File
@@ -25,9 +25,14 @@ from .assistant_store import (
baidu_translate_params,
dictionary_translate,
normalize_ai_base_url,
ollama_tags_endpoint,
openai_chat_endpoint,
openai_models_endpoint,
parse_json_body,
sanitize_anima_prompt,
sanitize_natural_prompt,
translation_direction,
upstream_error_message,
)
from .dictionary_store import DictionaryStore, normalize_key
from .duplicate_check import find_duplicates
@@ -46,6 +51,7 @@ assistant_store.config() # 进程启动时立即清除与当前安装身份不
_SESSION_TOKEN = secrets.token_urlsafe(32)
_MAX_JSON_BYTES = 8 * 1024 * 1024
_MAX_ASSISTANT_JSON_BYTES = 256 * 1024
_MODEL_LIST_TIMEOUT_SECONDS = 20
_ASSISTANT_RATE_WINDOW = 60.0
_ASSISTANT_RATE_LIMIT = 30
_assistant_requests = deque()
@@ -205,12 +211,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 +232,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"])
@@ -243,10 +255,10 @@ async def _call_ai_assistant(action, text, instruction, config, connection_test=
}
async with ClientSession(timeout=timeout) as session:
async with session.post(endpoint, json=payload) as response:
data = await response.json(content_type=None)
body = await response.text()
if response.status >= 400:
raise ValueError(data.get("error") or f"Ollama 请求失败:HTTP {response.status}")
result = data.get("message", {}).get("content")
raise ValueError(upstream_error_message(body, response.status, "Ollama"))
result = parse_json_body(body, "Ollama").get("message", {}).get("content")
else:
# OpenAI 兼容:远端必须 https,本机回环才允许 http
base = normalize_ai_base_url(config["openai_base_url"], allow_remote=True)
@@ -262,20 +274,19 @@ async def _call_ai_assistant(action, text, instruction, config, connection_test=
}
async with ClientSession(timeout=timeout) as session:
async with session.post(openai_chat_endpoint(base), headers=headers, json=payload) as response:
data = await response.json(content_type=None)
body = await response.text()
if response.status >= 400:
detail = data.get("error") if isinstance(data, dict) else None
if isinstance(detail, dict):
detail = detail.get("message")
raise ValueError(detail or f"API 请求失败:HTTP {response.status}")
choices = data.get("choices") if isinstance(data, dict) else None
raise ValueError(upstream_error_message(body, response.status, "API"))
choices = parse_json_body(body, "API").get("choices")
result = choices[0].get("message", {}).get("content") if choices else None
result = _clean_assistant_result(result)
if not result:
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 +294,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:
@@ -329,6 +340,54 @@ async def save_assistant_config(request):
return error_response(error)
async def _list_backend_models(prefix, base_url, api_key):
"""向 AI 后端问一次「你有哪些模型」;地址沿用现有白名单,问完不留任何东西。"""
timeout = ClientTimeout(total=_MODEL_LIST_TIMEOUT_SECONDS)
async with ClientSession(timeout=timeout) as session:
if prefix == "ollama":
# Ollama 只认本机回环地址,这里再校验一次,挡住手改配置文件的情况
base = normalize_ai_base_url(base_url)
if not base:
raise ValueError("尚未填写 Ollama 地址")
async with session.get(ollama_tags_endpoint(base)) as response:
# 先读原文再看状态码:错误应答常常不是 JSON,按 JSON 硬解析会把整个请求打崩
body = await response.text()
if response.status >= 400:
raise ValueError(upstream_error_message(body, response.status, "Ollama"))
names = [item.get("name") for item in parse_json_body(body, "Ollama").get("models") or []]
else:
base = normalize_ai_base_url(base_url, allow_remote=True)
if not base:
raise ValueError("尚未填写 OpenAI 兼容 API 地址")
headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
async with session.get(openai_models_endpoint(base), headers=headers) as response:
body = await response.text()
if response.status >= 400:
raise ValueError(upstream_error_message(body, response.status, "API"))
names = [item.get("id") for item in parse_json_body(body, "API").get("data") or []]
models = sorted({str(name).strip() for name in names if str(name).strip()})
if not models:
raise ValueError("服务端没有返回任何模型;也可以直接在输入框里手写模型名称")
return models
@protected_route("post", "/bpi/assistant/models")
async def list_assistant_models(request):
"""用表单里当前填的地址问一次模型列表,只为给下拉候选用:不写配置,也不把密钥返回浏览器。"""
try:
payload = await _read_json(request, _MAX_ASSISTANT_JSON_BYTES)
if not isinstance(payload, dict):
raise ValueError("请求内容格式不正确")
_consume_assistant_rate_limit()
prefix = "ollama" if payload.get("backend") == "ollama" else "openai"
# 密钥输入框留空 = 用户已经存过、这次不想再敲一遍,就拿已存的那份去问;仍只用于这一次请求
api_key = str(payload.get("api_key") or "").strip() or str(assistant_store.config().get(f"{prefix}_api_key") or "").strip()
models = await _list_backend_models(prefix, str(payload.get("base_url") or "").strip(), api_key)
return web.json_response({"success": True, "data": {"models": models}})
except AssistantLimitError as error:
return error_response(error, 429)
except Exception as error:
return error_response(error)
@protected_route("post", "/bpi/assistant/test")
@@ -357,7 +416,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("输入内容为空")
+79
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,
@@ -16,9 +17,14 @@ from assistant_store import (
baidu_translate_params,
dictionary_translate,
normalize_ai_base_url,
ollama_tags_endpoint,
openai_chat_endpoint,
openai_models_endpoint,
parse_json_body,
sanitize_anima_prompt,
sanitize_natural_prompt,
translation_direction,
upstream_error_message,
)
from dictionary_store import DictionaryStore
@@ -315,6 +321,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")
@@ -401,6 +448,38 @@ class AssistantStoreTests(unittest.TestCase):
path.write_text(json.dumps(raw, ensure_ascii=False), encoding="utf-8")
self.assertEqual(store.config()["baidu_qps"], 1.0)
def test_model_list_endpoints_reuse_the_saved_base_url_shape(self):
self.assertEqual(openai_models_endpoint("https://api.openai.com/v1"), "https://api.openai.com/v1/models")
self.assertEqual(openai_models_endpoint("http://127.0.0.1:1234/v1/"), "http://127.0.0.1:1234/v1/models")
self.assertEqual(openai_models_endpoint("https://x.cn/v1/chat/completions"), "https://x.cn/v1/models")
self.assertEqual(openai_models_endpoint("https://x.cn/models"), "https://x.cn/models")
self.assertEqual(ollama_tags_endpoint("http://127.0.0.1:11434"), "http://127.0.0.1:11434/api/tags")
self.assertEqual(ollama_tags_endpoint("http://127.0.0.1:11434/v1"), "http://127.0.0.1:11434/api/tags")
self.assertEqual(ollama_tags_endpoint("http://127.0.0.1:11434/api/chat"), "http://127.0.0.1:11434/api/tags")
self.assertEqual(openai_models_endpoint(""), "")
self.assertEqual(ollama_tags_endpoint(""), "")
def test_error_bodies_that_are_not_json_still_read_as_a_sentence(self):
# DeepSeek 缺密钥时回的是纯文本一行,不是 JSON;解析不了也不能把整条请求打崩
# 第三个参数和 server.py 里的调用保持一致:整条路径改签名时这里会先炸出来
self.assertIn("Authentication Fails", upstream_error_message("Authentication Fails (governor)", 401, "API"))
self.assertIn("请检查 API 密钥", upstream_error_message("Authentication Fails (governor)", 401, "API"))
self.assertEqual(
upstream_error_message('{"error":{"message":"Invalid API key"}}', 401, "API"),
"Invalid API key(HTTP 401;请检查 API 密钥是否已填写、是否有效)",
)
self.assertEqual(upstream_error_message("404 page not found", 404, "API"), "404 page not found(HTTP 404)")
self.assertEqual(upstream_error_message("", 502, "Ollama"), "Ollama 请求失败(HTTP 502)")
self.assertTrue(len(upstream_error_message("<html>" + "x" * 500 + "</html>", 403, "API")) < 220)
def test_non_json_success_body_reports_instead_of_crashing(self):
self.assertEqual(parse_json_body('{"data": []}', "API"), {"data": []})
with self.assertRaises(ValueError) as raised:
parse_json_body("<html>login</html>", "API")
self.assertIn("返回的不是 JSON", str(raised.exception))
with self.assertRaises(ValueError):
parse_json_body("[1, 2, 3]", "API")
def test_baidu_params_and_query_splitting(self):
params = baidu_translate_params("appid", "secret", "你好", "zh", salt="12345")
self.assertEqual(params["sign"], hashlib.md5("appid你好12345secret".encode("utf-8")).hexdigest())
+9 -1
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"],