Compare commits

...
3 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
12 changed files with 335 additions and 20 deletions
+15
View File
@@ -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)
### 新增「适应高度」开关
+3 -1
View File
@@ -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 的标点限制。地址不合规时会在发出请求前直接报错。
### 大型词库(可选)
+62
View File
@@ -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 "")
+12
View File
@@ -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 之类照常列出,免得误伤名字奇怪但确实可用的模型。
+1 -1
View File
@@ -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
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;
@@ -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,
+5
View File
@@ -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
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;
}
@@ -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
View File
@@ -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
View File
@@ -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" }
+59 -9
View File
@@ -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")
+36
View File
@@ -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())