Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
575ab1814f | ||
|
|
7759128dac | ||
|
|
2cf72b1a10 |
@@ -1,5 +1,20 @@
|
||||
# 更新记录
|
||||
|
||||
## 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)
|
||||
|
||||
### 新增「适应高度」开关
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# ComfyUI 双语提示词管理器 v2.1.8
|
||||
# ComfyUI 双语提示词管理器 v2.1.9
|
||||
|
||||
> ComfyUI 双语提示词节点:把英文提示词拆成可点击的标签,边写边看中文解释、调顺序、试效果。
|
||||
|
||||
@@ -199,6 +199,8 @@ git clone --depth 1 https://github.com/mengshengzhijie/ComfyUI-Bilingual-Prompt-
|
||||
| ↳ 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 的标点限制。地址不合规时会在发出请求前直接报错。
|
||||
|
||||
### 大型词库(可选)
|
||||
|
||||
@@ -205,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 "")
|
||||
|
||||
@@ -126,3 +126,15 @@
|
||||
两套规则必须分开:`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 之类照常列出,免得误伤名字奇怪但确实可用的模型。
|
||||
|
||||
@@ -57,7 +57,7 @@ import {
|
||||
} from "./bpi_shared.js";
|
||||
|
||||
const NODE_NAME = "BilingualPromptInspector";
|
||||
const EXTENSION_VERSION = "v2.1.8";
|
||||
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,
|
||||
|
||||
+27
-3
@@ -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;
|
||||
@@ -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}
|
||||
@@ -1487,6 +1510,7 @@ export {
|
||||
importTags,
|
||||
bulkUpdateTags,
|
||||
injectBpiStyles,
|
||||
listAssistantModels,
|
||||
listModelNames,
|
||||
listSavedPrompts,
|
||||
loadDictionary,
|
||||
|
||||
@@ -431,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 后保存并测试",
|
||||
|
||||
+113
-4
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "comfyui-bilingual-prompt-inspector",
|
||||
"version": "2.1.8",
|
||||
"version": "2.1.9",
|
||||
"description": "ComfyUI bilingual prompt inspection, translation, Anima sorting, and on-demand Danbooru dictionary extension.",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "prompt-translator-manager"
|
||||
version = "2.1.8"
|
||||
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" }
|
||||
|
||||
@@ -25,10 +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
|
||||
@@ -47,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()
|
||||
@@ -250,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)
|
||||
@@ -269,13 +274,10 @@ 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:
|
||||
@@ -338,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")
|
||||
|
||||
@@ -17,10 +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
|
||||
|
||||
@@ -444,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())
|
||||
|
||||
Reference in New Issue
Block a user