Compare commits

..
6 Commits
Author SHA1 Message Date
梦圣智杰 8781cbe921 chore: 发布 v2.2.3 / Release v2.2.3
- 修复新版 ComfyUI(前端 1.58+)下不跟随明暗主题。
- 收藏改成文件夹式的库:一个条目一个文件夹、可建文件夹设颜色、拖动排序,旧收藏自动迁移;存放位置挪到插件目录 data/saved,拷贝文件夹即迁移。
- AI 规则可按后端存多套预设,切换、另存为、删除、恢复默认就地完成;改过的规则带记号,未保存的不参与翻译。
- 修复删行尾带逗号标签误删换行、获取模型候选看不到选不了、问号提示框向左溢出三处界面问题。
- 恢复默认改为三步确认;删除参考图开关旁标注保存后生效。
2026-11-11 02:43:09 +08:00
梦圣智杰 bfcb2ebc85 fix: 修复三处界面问题 / Fix three UI issues
- 问号提示框改为从问号右侧展开,不再向左溢出被左侧控件盖住
- 编辑收藏时打开「删除已有参考图」,右侧显示「保存后生效」,关掉开关提示自动消失
- 「恢复默认」改为三步确认
2026-11-11 02:15:03 +08:00
梦圣智杰 a1741c8d8a feat: AI 规则支持多套预设与独立标记 / AI rules support multiple presets and orphan marks
- AI 规则可以按后端分别保存多套预设,切换、另存为、删除、恢复默认都在规则标题行右侧完成
- 每套预设是一个独立文件
- 每条规则都会和内置默认、所有预设比对
- 这个判断放在点「保存并测试连接」时执行
- 标题旁注明「修改需保存后生效;未保存的规则不参与翻译」
2026-11-11 01:41:26 +08:00
梦圣智杰 8e784f3e47 feat: 收藏改成文件式库:树形文件夹、颜色标记、拖动排序 / File-based favorites library with folders and drag sorting
- 收藏变成「一个条目一个文件夹」:文件夹名就是收藏名称,参考图与该文件夹同名,在文件管理器里也能直接看懂
- 可以建文件夹:新建时选「文件夹」,只填名称、备注、使用模型、参考图
- 文件夹能设颜色(8 个预设或随机),里面的条目沿用同一个颜色、但更淡一档
- 拖动可以排序,也可以拖进文件夹;全程没有过渡动画
- 第一次打开会把旧收藏自动搬过来(只搬一次,删空也不会再灌)
2026-11-10 00:41:56 +08:00
梦圣智杰 05800efb44 - 修复设置里获取模型后看不到、也选不了候选(原生 datalist 换成行内候选列表,跨整行显示) 2026-11-09 23:07:23 +08:00
梦圣智杰 eb35d1ee86 - 修复两个问题:新版 ComfyUI 下插件不跟随明暗主题;删除行尾标签(带逗号)时把换行一起删掉
- 修复新版 ComfyUI(前端 1.58+)下插件不跟随明暗主题(改成三路探测,不再依赖 dark-theme 类)
- 明暗判断改为 color-scheme
2026-11-09 13:45:55 +08:00
19 changed files with 1921 additions and 356 deletions
+4
View File
@@ -5,6 +5,10 @@
# assets/ —— README 里引用的截图与演示动图
# locales/ —— 节点中文名/描述(ComfyUI 界面切中文时用到)
# 用户收藏库(个人提示词与参考图,不进发布包)
data/saved/
data/rule_presets/
# 测试
tests/
pnpm-lock.yaml
+6
View File
@@ -42,3 +42,9 @@ tag.sqlite
#userinfo
bug.md
# 用户收藏库(个人提示词与参考图)
data/saved/
# AI 规则预设(个人提示词,可能包含私有写法)
data/rule_presets/
+27
View File
@@ -1,5 +1,32 @@
# 更新记录
## v2.2.3(2026-11-11)
### 收藏改成了文件夹式的库
- 收藏从「一个总账文件」变成「一个条目一个文件夹」:文件夹名就是收藏的名字,参考图与文件夹同名。在文件管理器里直接就能看懂、改名、备份;顺序和层级放在一个小缓存文件里,条目多了打开面板也不会变卡。
- 可以建文件夹收纳:新建时类型选「文件夹」,只填名称、备注、适合的模型、参考图。文件夹能设颜色(8 个预设色或随机取一个),里面的条目沿用同一个颜色、淡一档,一眼能看出谁属于谁。
- 拖动可以排序,也可以把条目拖进文件夹(这一版没有过渡动画)。
- 第一次打开新面板时会把旧收藏自动搬过来(只搬一次,之后删空了也不会再灌)。旧收藏原文不动,只是复制进新结构。
- 存放位置随这次改动挪到了插件目录的 `data/saved`:和用户词库、配置放在一起,备份时一起带走;重装或更新插件仍然不会丢。
- 以前「整包导出 JSON」没有了——现在整个收藏库就是一个文件夹,拷贝这个文件夹就是完整的备份,粘到另一台机器的同一位置就能用。
- 编辑收藏时的「删除已有参考图」开关旁边会显示「保存后生效」,关掉开关提示自动消失——它不是即时生效的,之前看不出来。
### AI 规则可以存多套预设
- 助手设置里每个后端(OpenAI 兼容 / Ollama)的规则编辑器,标题行右侧多了一排操作:下拉选一套已存的预设、另存为、删除、恢复默认。本地小模型和云端大模型各存各的,想要「直译版」「精修版」几套随时切。
- 每套预设是一个独立文件,存放在插件目录的 `data/rule_presets`。
- 编辑器里改过的内容会被标出来:哪条规则和内置默认、所有预设都对不上,它左下角就会有一个圈出来的标记——说明这条是你手改的、还没存进任何一套。这个比对在点「保存并测试连接」时执行。
- 规则区标题旁注明了「修改需保存后生效;未保存的规则不参与翻译」,避免改完不保存、翻译却还是旧规则的误会。
- 「恢复默认」是破坏性操作,改成三步确认:先点一下变成「确认?」,再点一下变成红色,第三次才真正恢复;4 秒不点就自动回到原样。
### 修了四处小问题
- 新版 ComfyUI(前端 1.58+)下插件不跟随明暗主题:明暗判断改成读 `color-scheme`,不再依赖旧版的 `dark-theme` 类。
- 删掉行尾带逗号的标签时,不会再把后面的换行一起删掉。
- 助手设置里「获取模型」拉到候选后看不到、也选不了:原生下拉换成行内候选列表,跨整行显示,点一下就填进去。
- 问号提示框改成从问号右侧展开,不再向左溢出被左侧的控件盖住。
## v2.2.2(2026-11-08)
### 快捷添加的反馈收干净了
+10 -6
View File
@@ -1,4 +1,4 @@
# ComfyUI 双语提示词管理器 v2.2.2
# ComfyUI 双语提示词管理器 v2.2.3
> ComfyUI 双语提示词节点:把英文提示词拆成可点击的标签,边写边看中文解释、调顺序、试效果。
@@ -123,8 +123,10 @@
- **保存**:点节点上「英文原文(实际输出)」右侧的「收藏」按钮 → 填名字、写备注(可留空)、标上适合的模型、拖入或粘贴一张出图截图 → 保存。保存的是节点当前这份英文文本(包含其中被隐藏的标签)。不在节点上时,也可以从侧边栏「收藏」分页点「新建」,再选一个节点读取文本。
- **适合模型**:输入关键词,会从本机 `models/` 目录(checkpoints、diffusion_models、loras、embeddings、text_encoders)里匹配已安装的模型;`Enter` 把高亮那条加成标签,`↑↓` 切换候选,列表里没有的名字也能直接回车当自由文本填进去。最多 20 个。
- **编辑**:卡片上的「编辑」按钮打开和保存时同一个弹窗,名字、备注、适合模型、配图、提示词正文都能改。不选新图就保持原图,选了新图会替换并删掉旧的。
- **使用**:侧边栏「收藏」分页按卡片浏览,卡片直接显示适合模型和备注;支持搜索(模型名也算)、编辑、复制、载入节点、删除,也可以整包导出 JSON 给别的机器导入。
- **存放位置**:`ComfyUI/user/bilingual-prompt-inspector/saved_prompts/`(`index.json` + 按 id 命名的图片文件),跟随 ComfyUI 用户目录,不在插件目录内,重装或更新插件不会丢。单图上限 5 MB,最多 2000 条。
- **结构**:一个条目一个文件夹,文件夹名就是收藏的名字,参考图与文件夹同名——在文件管理器里直接就能看懂、改名、备份。新建时类型也可以选「文件夹」收纳条目:只填名称、备注、适合的模型、参考图;文件夹能设颜色(8 个预设色或随机取一个),里面的条目沿用同一个颜色、淡一档。拖动可以排序,也可以把条目拖进文件夹。
- **使用**:侧边栏「收藏」分页按树形浏览,卡片直接显示适合模型和备注;支持搜索(模型名也算)、编辑、复制、载入节点、删除。
- **存放位置**:插件目录的 `data/saved/`(和用户词库、配置放在一起,备份时一起带走),重装或更新插件不会丢。单图上限 5 MB,最多 2000 条。拷贝整个 `saved` 文件夹就是完整备份,粘到另一台机器的同一位置即可迁移。
- **旧数据迁移**:从旧版本升级后第一次打开收藏,旧收藏会自动搬进新结构(只搬一次);旧数据原文不动。
> 「载入节点」会先弹窗确认再覆盖节点文本,这个动作**无法撤销**。
@@ -232,9 +234,11 @@ git clone https://gitee.com/MengShengZhiJie/ComfyUI-Bilingual-Prompt-Inspector.g
| ↳ 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 报错。
模型名称旁边的「获取模型」按钮会拿输入框里当前填的地址问一次服务端有哪些模型(OpenAI 兼容问 `/models`,Ollama 问 `/api/tags`),拉到的名字显示成输入框下方的行内候选列表——跨整行显示、点一下就填进去,也可以完全不管它自己手写。再点一次按钮就是重新拉取。列表只在本次会话内有效、地址一改就作废,不写进配置文件。密钥以输入框里填的为准,输入框留空就用你已经保存的那一份;无论哪种都只随这一次请求发出,不会随着设置一起回传给浏览器。服务端返回看不懂的内容(纯文本报错、登录页之类)时会原话告诉你,不会甩一段 JSON 报错。
两个后端还各有一套规则(翻译 / 翻译并优化 / 优化 / 自然语言):本地小模型与云端大模型要写的提示词往往不一样,分开存可以随时切换后端而不用重改规则。其中「自然语言」那套只在自然语言模式下被「优化」使用,结果按完整英文句子保留标点,不套 Anima 的标点限制。地址不合规时会在发出请求前直接报错。
两个后端各有自己的规则编辑器(翻译 / 翻译并优化 / 优化 / 自然语言):本地小模型与云端大模型要写的提示词往往不一样,分开存可以随时切换后端而不用重改规则。其中「自然语言」那套只在自然语言模式下被「优化」使用,结果按完整英文句子保留标点,不套 Anima 的标点限制。地址不合规时会在发出请求前直接报错。
规则还能按后端存多套预设:规则区标题右侧的下拉选一套已存的预设,旁边「另存为 / 删除 / 恢复默认」就地完成——比如同一个后端留一套「直译」一套「精修」,随时切。编辑器里手改过的内容会被标出来(和内置默认、所有预设都对不上的那条会带记号),提醒你还没存进任何一套;改完记得点保存,未保存的规则不参与翻译。「恢复默认」要点三次才真正执行。预设文件存放在插件目录的 `data/rule_presets/`。
### 大型词库(可选)
@@ -315,7 +319,7 @@ git clone https://gitee.com/MengShengZhiJie/ComfyUI-Bilingual-Prompt-Inspector.g
- **默认不联网**:翻译服务的默认选项是纯词库,完全离线。
- **只在你主动使用某个服务时才发请求**:调用百度或 AI 时,发送的是提示词文本与你的指令;密钥只存在后端本地,不回传浏览器、不写入工作流。
- **大型词库按需加载**:不安装就不加载,装了也是本地文件查询。
- **收藏只存在本机**:写在 ComfyUI 用户目录下,插件不做任何上传。
- **收藏只存在本机**:写在插件目录的 `data/saved/` 下,插件不做任何上传。
---
+3 -3
View File
@@ -30,6 +30,7 @@ import { createWeightEditor } from "./bpi_editor_modal.js";
import { createHiddenTags } from "./bpi_hidden.js";
import { createShortcuts } from "./bpi_shortcut.js";
import { createChipDrag } from "./bpi_chip_drag.js";
import { openLibraryItemDialog } from "./bpi_library.js";
import {
bindStoreHooks, COLLAPSED_NODE_MIN_HEIGHT, createStore,
} from "./bpi_store.js";
@@ -52,7 +53,6 @@ import {
loadTokenColors,
buildColorPanel,
lookupLargeDictionary,
openSavePromptDialog,
openTagDialog,
postUpstreamAction,
runInspectorAssistant,
@@ -68,7 +68,7 @@ import {
} from "./bpi_shared.js";
const NODE_NAME = "BilingualPromptInspector";
const EXTENSION_VERSION = "v2.2.2";
const EXTENSION_VERSION = "v2.2.3";
const PROJECT_URL = "https://github.com/mengshengzhijie/ComfyUI-Bilingual-Prompt-Inspector";
// 「适应高度」打开后面板能长到的上限。ComfyUI 按 min/max 分配空间,max 必须明显高于 min,
// 否则手动拉大节点多出来的高度全变成空白(distributeSpace 只会在 min..max 之间给)。
@@ -2078,7 +2078,7 @@ function createPanel(node, textWidget) {
setStatus("Prompt is empty; cannot favorite", "error");
return;
}
openSavePromptDialog({ text });
openLibraryItemDialog({ presetKind: "prompt", presetText: text });
};
sourceToggle.addEventListener("change", () => {
+582
View File
@@ -0,0 +1,582 @@
// 收藏库的树视图:文件夹 + 条目,拖动排序 / 拖进文件夹,默认全部收起。
//
// 几个约定(和存储层对齐):
// - 展开状态只存在内存里:刷新页面才回到「全部收起」,切窗口、关侧边栏都不影响;
// - 拖动不加任何过渡动画,松手直接重排;
// - 缩进只缩一层(12px),不做层层加深,避免长条目被挤成窄条;
// - 文件夹的箭头放在最右边。
import { bpiFetch, button, element, setPlaceholder, setText } from "./bpi_shared.js";
const API_ROOT = "/bpi/library";
// 文件夹的淡色:只影响行的底色,渲染时统一加 12% 透明度(#rrggbb + "1f")
const FOLDER_COLORS = ["#4ca7e8", "#5ec9a3", "#e8b85c", "#cbb1ff", "#ff8b93", "#86d5c4", "#e2ca78", "#7fc4e0"];
function randomFolderColor() {
const hue = Math.floor(Math.random() * 360);
const sat = 0.62;
const light = 0.58;
const channel = (offset) => {
const k = (offset + hue / 30) % 12;
const a = sat * Math.min(light, 1 - light);
const value = light - a * Math.max(-1, Math.min(k - 3, Math.min(9 - k, 1)));
return Math.round(255 * value).toString(16).padStart(2, "0");
};
return `#${channel(0)}${channel(8)}${channel(4)}`;
}
async function readJson(response) {
const payload = await response.json().catch(() => null);
if (!response.ok || !payload || payload.success === false) {
throw new Error(payload?.error || `请求失败(HTTP ${response.status})`);
}
return payload.data;
}
async function fetchTree() {
return readJson(await bpiFetch(API_ROOT, { headers: { Accept: "application/json" } }));
}
async function createItem({ kind, name, text, note, models, imageFile, parent, color }) {
const body = new FormData();
body.append("kind", kind);
body.append("name", name ?? "");
if (text != null) body.append("text", text);
if (note) body.append("note", note);
for (const model of models ?? []) body.append("models", model);
if (parent) body.append("parent", parent);
if (color) body.append("color", color);
if (imageFile) body.append("image", imageFile, imageFile.name || "image");
return readJson(await bpiFetch(API_ROOT, { method: "POST", body }));
}
async function updateItem(id, { name, text, note, models, imageFile, removeImage, color }) {
const body = new FormData();
if (name != null) body.append("name", name);
if (text != null) body.append("text", text);
if (note != null) body.append("note", note ?? "");
for (const model of models ?? []) body.append("models", model);
if (removeImage) body.append("remove_image", "1");
if (color !== undefined) body.append("color", color ?? "");
if (imageFile) body.append("image", imageFile, imageFile.name || "image");
return readJson(await bpiFetch(`${API_ROOT}/${encodeURIComponent(id)}`, { method: "POST", body }));
}
async function moveItem(id, parent, index) {
return readJson(await bpiFetch(`${API_ROOT}/${encodeURIComponent(id)}/move`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ parent: parent ?? null, index }),
}));
}
async function deleteItem(id) {
return readJson(await bpiFetch(`${API_ROOT}/${encodeURIComponent(id)}`, { method: "DELETE" }));
}
function imageUrl(item) {
return `${API_ROOT}/${encodeURIComponent(item.id)}/image?v=${item.updated_at ?? item.created_at ?? 0}`;
}
function parseModels(value) {
return String(value ?? "")
.split(/[,,]/)
.map((part) => part.trim())
.filter(Boolean);
}
// 新建 / 编辑弹窗。类型选「文件夹」时只需要名称、备注、模型、参考图,没有提示词那一块。
export function openLibraryItemDialog({ item = null, parent = null, presetKind = null, presetText = "" }, onSaved) {
const editing = Boolean(item);
const shade = element("div", "bpi-modal-shade");
const modal = element("div", "bpi-modal");
const title = element("h3", "", editing ? "编辑收藏" : "新建收藏");
const form = element("form", "bpi-form");
const error = element("div", "bpi-status");
error.dataset.kind = "error";
error.style.gridColumn = "1 / -1";
let kind = item?.type === "folder" || presetKind === "folder" ? "folder" : "prompt";
const kindLabel = element("label", "", "类型");
const kindSelect = element("select", "bpi-select");
for (const [value, label] of [["folder", "文件夹"], ["prompt", "提示词"]]) {
const option = element("option", "", label);
option.value = value;
kindSelect.appendChild(option);
}
kindSelect.value = kind;
kindSelect.disabled = editing; // 已有的条目不允许改类型
form.append(kindLabel, kindSelect);
const nameLabel = element("label", "", "名称");
const nameInput = element("input");
nameInput.value = item?.name ?? "";
setPlaceholder(nameInput, "必填");
form.append(nameLabel, nameInput);
const noteLabel = element("label", "", "备注");
const noteInput = element("input");
noteInput.value = item?.note ?? "";
setPlaceholder(noteInput, "可选");
form.append(noteLabel, noteInput);
const modelsLabel = element("label", "", "使用模型");
const modelsInput = element("input");
modelsInput.value = Array.isArray(item?.models) ? item.models.join(", ") : "";
setPlaceholder(modelsInput, "可选,逗号分隔");
form.append(modelsLabel, modelsInput);
let pickedColor = item?.color ?? "";
const colorLabel = element("label", "", "颜色");
const colorRow = element("div", "bpi-lib-colors");
const swatches = [];
const syncColor = () => {
for (const swatch of swatches) swatch.classList.toggle("bpi-lib-color-on", swatch.dataset.color === pickedColor);
};
const addSwatch = (value, label) => {
const swatch = element("button", label ? "bpi-lib-color bpi-lib-color-text" : "bpi-lib-color", label ?? "");
swatch.type = "button";
swatch.dataset.color = value ?? "";
if (value) swatch.style.backgroundColor = value;
swatch.addEventListener("click", (event) => {
event.preventDefault();
pickedColor = value ?? "";
syncColor();
});
swatches.push(swatch);
colorRow.appendChild(swatch);
return swatch;
};
for (const color of FOLDER_COLORS) addSwatch(color);
addSwatch("", "无");
const randomSwatch = addSwatch("", "随机");
randomSwatch.addEventListener("click", () => {
// 先清掉旧高亮,再把自己涂成新颜色并选中
const color = randomFolderColor();
randomSwatch.style.backgroundColor = color;
randomSwatch.dataset.color = color;
pickedColor = color;
syncColor();
});
form.append(colorLabel, colorRow);
const textLabel = element("label", "", "提示词");
const textArea = element("textarea", "bpi-save-template");
textArea.value = item?.text ?? presetText ?? "";
textArea.rows = 4;
form.append(textLabel, textArea);
const imageLabel = element("label", "", "参考图");
const drop = element("div", "bpi-save-drop bpi-lib-drop-image");
const preview = element("img", "bpi-save-preview");
const dropText = element("div", "", "拖一张图进来,或点一下选择");
const fileInfo = element("div", "bpi-save-file", "可选;PNG / JPG / WebP,最大 5 MB");
if (item?.image) {
preview.src = imageUrl(item);
preview.style.display = "block";
}
drop.append(preview, dropText, fileInfo);
const picker = element("input");
picker.type = "file";
picker.accept = "image/png,image/jpeg,image/webp";
picker.hidden = true;
form.append(imageLabel, drop);
const removeRow = element("label", "bpi-switch-row");
const removeImageInput = element("input");
removeImageInput.type = "checkbox";
const removeTrack = element("span", "bpi-switch-track");
removeTrack.appendChild(element("span", "bpi-switch-thumb"));
// 打开后变红,旁边提醒一句「保存后生效」——和清除密钥那个开关的写法一致
removeRow.append(
removeImageInput,
removeTrack,
element("span", "bpi-switch-text", "删除已有参考图"),
element("span", "bpi-switch-hint", "保存后生效"),
);
removeRow.style.gridColumn = "1 / -1";
if (item?.image) form.append(removeRow);
form.appendChild(error);
let imageFile = null;
const setImage = (file) => {
imageFile = file;
preview.src = URL.createObjectURL(file);
preview.style.display = "block";
setText(fileInfo, `“${file.name || "图片"}” · ${Math.max(1, Math.round(file.size / 1024))} KB`);
};
drop.addEventListener("click", () => picker.click());
picker.addEventListener("change", () => {
if (picker.files?.[0]) setImage(picker.files[0]);
});
drop.addEventListener("dragover", (event) => {
event.preventDefault();
drop.classList.add("bpi-dragover");
});
drop.addEventListener("dragleave", () => drop.classList.remove("bpi-dragover"));
drop.addEventListener("drop", (event) => {
event.preventDefault();
drop.classList.remove("bpi-dragover");
const file = event.dataTransfer?.files?.[0];
if (file) setImage(file);
});
const syncKind = () => {
const isFolder = kind === "folder";
textLabel.hidden = isFolder;
textArea.hidden = isFolder;
colorLabel.hidden = !isFolder;
colorRow.hidden = !isFolder;
setText(title, editing ? "编辑收藏" : isFolder ? "新建文件夹" : "新建提示词");
};
kindSelect.addEventListener("change", () => {
kind = kindSelect.value;
syncKind();
});
syncKind();
const close = () => shade.remove();
const actions = element("div", "bpi-modal-actions");
const saveButton = button("保存", async () => {
const name = nameInput.value.trim();
if (!name) {
setText(error, "名称不能为空");
return;
}
const text = kind === "prompt" ? textArea.value : null;
if (kind === "prompt" && !String(text ?? "").trim()) {
setText(error, "提示词内容不能为空");
return;
}
saveButton.disabled = true;
try {
const payload = {
name,
text,
note: noteInput.value.trim() || null,
models: parseModels(modelsInput.value),
imageFile,
removeImage: removeImageInput.checked,
color: kind === "folder" ? pickedColor : "",
};
const saved = editing
? await updateItem(item.id, payload)
: await createItem({ ...payload, kind, parent });
close();
window.dispatchEvent(new CustomEvent("bpi:library-changed", { detail: saved }));
onSaved?.(saved);
} catch (failure) {
setText(error, failure.message);
saveButton.disabled = false;
}
}, "bpi-primary");
actions.append(button("取消", close), saveButton);
form.addEventListener("submit", (event) => {
event.preventDefault();
saveButton.click();
});
modal.append(title, form, picker, actions);
shade.appendChild(modal);
document.body.appendChild(shade);
shade.addEventListener("mousedown", (event) => {
if (event.target === shade) close();
});
modal.addEventListener("mousedown", (event) => event.stopPropagation());
requestAnimationFrame(() => nameInput.focus());
return { close };
}
export function createLibraryView({ mount, onLoad, onToast, confirmDelete }) {
let items = [];
let loaded = false;
let dragId = null;
const expanded = new Set(); // 只在内存:刷新页面才回到全部收起
let query = "";
const byId = (id) => items.find((item) => item.id === id) ?? null;
const childrenOf = (parent) => items
.filter((item) => (item.parent ?? null) === (parent ?? null))
.sort((left, right) => (left.order ?? 0) - (right.order ?? 0));
const matches = (item) => {
if (!query) return true;
const haystack = [item.name, item.note, item.text, ...(item.models ?? [])].join("\n").toLowerCase();
return haystack.includes(query);
};
// 搜索时把所有祖先也带出来,否则匹配到的条目会因为没有父级而整条不显示
const visibleIds = () => {
if (!query) return null;
const keep = new Set();
for (const item of items) {
if (!matches(item)) continue;
keep.add(item.id);
let parent = item.parent;
while (parent) {
keep.add(parent);
parent = byId(parent)?.parent ?? null;
}
}
return keep;
};
function rowsFor(parent, depth) {
const keep = visibleIds();
const list = childrenOf(parent).filter((item) => !keep || keep.has(item.id));
const rows = [];
for (const item of list) {
rows.push(item, ...(item.type === "folder" && (expanded.has(item.id) || Boolean(query))
? rowsFor(item.id, depth + 1)
: []));
}
return rows;
}
function rowElement(item) {
const isFolder = item.type === "folder";
const row = element("div", `bpi-lib-row ${isFolder ? "bpi-lib-folder" : "bpi-lib-item"}`);
row.dataset.id = item.id;
row.draggable = true;
// 文件夹的淡色:存的是 #rrggbb,这里补成 12% 透明度铺一层,跟节点里提示词区那种底色一样。
// 文件夹里的条目继承同一个颜色,但更淡一档(5%),一眼能看出它属于哪个组。
if (isFolder && item.color) {
row.style.backgroundColor = `${item.color}1f`;
} else if (!isFolder && item.parent) {
const parentColor = byId(item.parent)?.color;
if (parentColor) row.style.backgroundColor = `${parentColor}0d`;
}
const main = element("div", "bpi-lib-main");
// 缩略图对所有行都放(文件夹也能配参考图),没有图就给虚线占位,
// 这样文件夹与提示词的行高一致,树看起来才是齐的
const thumb = element("span", "bpi-lib-thumb");
if (item.image) thumb.style.backgroundImage = `url("${imageUrl(item)}")`;
else thumb.classList.add("bpi-lib-noimg");
main.appendChild(thumb);
const name = element("span", "bpi-lib-name", item.name ?? "未命名");
if (isFolder) {
main.appendChild(name);
main.appendChild(element("span", "bpi-lib-count", String(childrenOf(item.id).length)));
} else {
// 名称一行、提示词摘要一行:挤在同一行时两边都被截断,反而看不清是什么
const texts = element("div", "bpi-lib-texts");
texts.appendChild(name);
const summary = String(item.text ?? "").replace(/\s+/g, " ").slice(0, 120);
if (summary) texts.appendChild(element("span", "bpi-lib-summary", summary));
main.appendChild(texts);
}
const arrow = element("span", "bpi-lib-arrow", isFolder ? (expanded.has(item.id) ? "▾" : "▸") : "");
if (isFolder) {
arrow.addEventListener("click", (event) => {
event.stopPropagation();
if (expanded.has(item.id)) expanded.delete(item.id);
else expanded.add(item.id);
render();
});
}
const tools = element("span", "bpi-lib-tools");
if (!isFolder) {
tools.append(
button("载入", (event) => {
event.stopPropagation();
onLoad?.(item);
}, "bpi-mini"),
button("复制", async (event) => {
event.stopPropagation();
try {
await navigator.clipboard.writeText(String(item.text ?? ""));
onToast?.("已复制提示词", "ok");
} catch {
onToast?.("浏览器不允许写剪贴板", "error");
}
}, "bpi-mini"),
);
}
tools.append(
button("编辑", (event) => {
event.stopPropagation();
openLibraryItemDialog({ item }, () => refresh());
}, "bpi-mini"),
button("删除", async (event) => {
event.stopPropagation();
const ok = confirmDelete ? await confirmDelete(item) : window.confirm(`删除“${item.name}”?`);
if (!ok) return;
try {
await deleteItem(item.id);
expanded.delete(item.id);
onToast?.("已删除", "ok");
refresh();
} catch (failure) {
onToast?.(failure.message, "error");
}
}, "bpi-mini bpi-danger"),
);
row.append(main, tools, arrow);
if (isFolder) {
row.addEventListener("click", () => {
if (expanded.has(item.id)) expanded.delete(item.id);
else expanded.add(item.id);
render();
});
}
attachDrag(row, item);
return row;
}
function clearDropMarks() {
for (const node of mount.querySelectorAll(".bpi-lib-drop-before, .bpi-lib-drop-after, .bpi-lib-drop-into")) {
node.classList.remove("bpi-lib-drop-before", "bpi-lib-drop-after", "bpi-lib-drop-into");
}
}
function attachDrag(row, item) {
row.addEventListener("dragstart", (event) => {
dragId = item.id;
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", item.id);
row.classList.add("bpi-lib-dragging");
});
row.addEventListener("dragend", () => {
dragId = null;
row.classList.remove("bpi-lib-dragging");
clearDropMarks();
});
row.addEventListener("dragover", (event) => {
if (!dragId || dragId === item.id) return;
event.preventDefault();
event.stopPropagation();
clearDropMarks();
const rect = row.getBoundingClientRect();
const ratio = (event.clientY - rect.top) / Math.max(1, rect.height);
if (item.type === "folder" && ratio > 0.3 && ratio < 0.7) {
row.classList.add("bpi-lib-drop-into");
} else {
row.classList.add(ratio < 0.5 ? "bpi-lib-drop-before" : "bpi-lib-drop-after");
}
});
row.addEventListener("dragleave", () => {
row.classList.remove("bpi-lib-drop-before", "bpi-lib-drop-after", "bpi-lib-drop-into");
});
row.addEventListener("drop", async (event) => {
if (!dragId || dragId === item.id) return;
event.preventDefault();
event.stopPropagation();
const rect = row.getBoundingClientRect();
const ratio = (event.clientY - rect.top) / Math.max(1, rect.height);
const intoFolder = item.type === "folder" && ratio > 0.3 && ratio < 0.7;
clearDropMarks();
const moving = dragId;
dragId = null;
try {
if (intoFolder) {
expanded.add(item.id);
await moveItem(moving, item.id, null);
} else {
const siblings = childrenOf(item.parent).filter((entry) => entry.id !== moving);
const at = siblings.findIndex((entry) => entry.id === item.id);
const index = ratio < 0.5 ? Math.max(0, at) : Math.max(0, at + 1);
await moveItem(moving, item.parent, index);
}
await refresh();
} catch (failure) {
onToast?.(failure.message, "error");
}
});
}
function render() {
mount.replaceChildren();
const rows = rowsFor(null, 0);
if (!items.length) {
mount.appendChild(element("div", "bpi-empty", "还没有收藏。点上面的「新建」建一个文件夹或提示词。"));
return;
}
if (!rows.length) {
mount.appendChild(element("div", "bpi-empty", "没有匹配的收藏。"));
return;
}
for (const item of rows) {
const row = rowElement(item);
const depth = (() => {
let level = 0;
let parent = item.parent;
while (parent) {
level += 1;
parent = byId(parent)?.parent ?? null;
}
return level;
})();
row.style.paddingLeft = `${8 + depth * 12}px`;
mount.appendChild(row);
}
}
let legacyTried = false;
async function refresh() {
try {
let data = await fetchTree();
// 新库第一次用而且空着:把旧版 saved_prompts 里的一次性搬过来(后端有标记,只搬一次)
if (!data?.items?.length && !legacyTried) {
legacyTried = true;
const imported = await readJson(await bpiFetch(`${API_ROOT}/import-legacy`, { method: "POST" })).catch(() => null);
if (imported?.imported > 0) {
data = await fetchTree();
onToast?.(`已从旧收藏导入 ${imported.imported} 条`, "ok");
}
}
items = Array.isArray(data?.items) ? data.items : [];
loaded = true;
render();
} catch (failure) {
mount.replaceChildren(element("div", "bpi-empty", `读取收藏失败:${failure.message}`));
}
}
const onLibraryChanged = () => {
if (!mount.isConnected) {
window.removeEventListener("bpi:library-changed", onLibraryChanged);
return;
}
refresh();
};
window.addEventListener("bpi:library-changed", onLibraryChanged);
// 拖到列表空白处 = 移出到根目录末尾
mount.addEventListener("dragover", (event) => {
if (!dragId) return;
event.preventDefault();
});
mount.addEventListener("drop", async (event) => {
if (!dragId || event.target !== mount) return;
event.preventDefault();
const moving = dragId;
dragId = null;
try {
await moveItem(moving, null, null);
await refresh();
} catch (failure) {
onToast?.(failure.message, "error");
}
});
return {
refresh,
render,
setQuery(value) {
query = String(value ?? "").trim().toLowerCase();
render();
},
openCreate(parent = null) {
openLibraryItemDialog({ parent }, () => refresh());
},
isLoaded: () => loaded,
items: () => items,
};
}
+86 -134
View File
@@ -607,134 +607,6 @@ function buildModelPicker({ initial = [], onError } = {}) {
return { root, destroy, models: () => values.slice() };
}
// 保存 / 编辑收藏的弹窗:文本自动带好,图片可拖、可选、可粘贴,不想要就空着。
// 传了 editing(整条收藏)就是编辑:提示词文本放开手改,不选新图则保留原图。
function openSavePromptDialog({ name = "", text = "", note = null, models = [], editing = null } = {}, onSaved) {
const shade = element("div", "bpi-modal-shade");
const modal = element("div", "bpi-modal");
const title = element("h3", "", editing ? "Edit Favorite" : "Save Current Prompt");
const form = element("form", "bpi-form");
const error = element("div", "bpi-status");
error.dataset.kind = "error";
error.style.gridColumn = "1 / -1";
const nameInput = field(form, "Name", "name", (name || text.trim().slice(0, 24)).slice(0, 80), "Defaults to the start of the prompt");
const noteInput = field(form, "Note", "note", note ?? "", "Optional");
const modelPicker = buildModelPicker({ initial: models, onError: (message) => setText(error, message) });
const modelsLabel = element("label", "", "Models");
modelsLabel.htmlFor = "bpi-field-models";
const modelsHint = element("div", "bpi-form-hint", "Press Enter to add; ↑↓ to choose");
modelsHint.style.gridColumn = "1 / -1";
form.append(modelsLabel, modelPicker.root, modelsHint);
const textLabel = element("label", "", "Prompt");
textLabel.htmlFor = "bpi-save-prompt-text";
const textPreview = element("textarea", "");
textPreview.id = "bpi-save-prompt-text";
textPreview.readOnly = !editing; // 新建时文本来自节点,不该手改;编辑时放开
textPreview.value = text;
form.append(textLabel, textPreview);
const imageLabel = element("label", "", "Reference Image");
const drop = element("div", "bpi-save-drop");
const previewImage = element("img", "bpi-save-preview");
const dropText = element("div", "", "Drop an image here, or click to select");
const fileInfo = element("div", "bpi-save-file", editing
? "Keep the current image, or pick a new one to replace it"
: "Optional; PNG / JPG / WebP, max 5 MB");
if (editing?.image) {
previewImage.src = savedPromptImageUrl(editing.id);
previewImage.style.display = "block";
}
drop.append(previewImage, dropText, fileInfo);
const picker = element("input");
picker.type = "file";
picker.accept = "image/png,image/jpeg,image/webp";
picker.hidden = true;
form.append(imageLabel, drop);
form.appendChild(error);
let imageFile = null;
const setImage = (file) => {
imageFile = file;
previewImage.src = URL.createObjectURL(file);
previewImage.style.display = "block";
setText(fileInfo, `“${file.name || "image"}” · ${Math.max(1, Math.round(file.size / 1024))} KB`);
};
drop.addEventListener("click", () => picker.click());
picker.addEventListener("change", () => {
if (picker.files?.[0]) setImage(picker.files[0]);
});
drop.addEventListener("dragover", (event) => {
event.preventDefault();
drop.classList.add("bpi-dragover");
});
drop.addEventListener("dragleave", () => drop.classList.remove("bpi-dragover"));
drop.addEventListener("drop", (event) => {
event.preventDefault();
drop.classList.remove("bpi-dragover");
const file = event.dataTransfer?.files?.[0];
if (file) setImage(file);
});
const onPaste = (event) => {
const file = event.clipboardData?.files?.[0];
if (!file) return;
event.preventDefault();
setImage(file);
};
window.addEventListener("paste", onPaste);
const close = () => {
window.removeEventListener("paste", onPaste);
modelPicker.destroy();
shade.remove();
};
const actions = element("div", "bpi-modal-actions");
const saveButton = button("Save", async () => {
const promptText = editing ? textPreview.value : text;
if (!promptText.trim()) {
setText(error, "Prompt is empty, cannot save");
return;
}
saveButton.disabled = true;
try {
const entry = editing
? await updateSavedPrompt({
id: editing.id,
name: nameInput.value,
text: promptText,
note: noteInput.value.trim() || null,
models: modelPicker.models(),
imageFile,
})
: await createSavedPrompt({
name: nameInput.value,
text: promptText,
note: noteInput.value.trim() || null,
models: modelPicker.models(),
imageFile,
});
window.dispatchEvent(new CustomEvent("bpi:saved-prompts-changed", { detail: entry }));
onSaved?.(entry);
close();
} catch (failure) {
error.textContent = failure.message;
saveButton.disabled = false;
}
}, "bpi-primary");
actions.append(button("Cancel", close), saveButton);
form.addEventListener("submit", (event) => {
event.preventDefault();
saveButton.click();
});
modal.append(title, form, picker, actions);
shade.appendChild(modal);
document.body.appendChild(shade);
shade.addEventListener("mousedown", (event) => {
if (event.target === shade) close();
});
modal.addEventListener("mousedown", (event) => event.stopPropagation());
requestAnimationFrame(() => nameInput.focus());
return { close };
}
// 分类候选:只收词库里真实出现过的 category,用得多的排前面。
// 不预置任何「应该有但还没用过」的分类;中英文混着也不翻译,原样列出。
function tagCategoryCounts(data) {
@@ -1444,9 +1316,49 @@ function injectBpiStyles() {
.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-rule-label{position:relative;display:flex;align-self:start;min-width:0;padding-bottom:16px}
.bpi-rule-orphan{position:absolute;left:0;bottom:0;display:none;width:15px;height:15px;border:1px solid var(--bpi-status-busy);border-radius:50%;background-color:var(--bpi-surface-2);color:var(--bpi-status-busy);font-size:10px;line-height:13px;text-align:center;cursor:help;user-select:none}
.bpi-rule-orphan.bpi-rule-orphan-on{display:block}
.bpi-rule-presets{display:flex;gap:6px;align-items:center;margin-left:auto;flex-wrap:nowrap}
.bpi-rule-presets .bpi-select{max-width:150px;padding:2px 6px;font-size:12px}
.bpi-rule-presets .bpi-mini{flex:none;white-space:nowrap;padding:2px 9px;font-size:11px;line-height:1.5}
.bpi-model-picks{display:flex;flex-wrap:wrap;gap:6px;max-height:180px;overflow-y:auto;padding:8px;border:1px solid var(--bpi-border);border-radius:6px;background-color:var(--bpi-surface-2)}
.bpi-model-picks.bpi-hidden{display:none}
.bpi-model-pick{font:12px/1.5 ui-monospace,Consolas,monospace;padding:4px 10px;border:1px solid var(--bpi-border-2);border-radius:999px;background-color:var(--bpi-button-bg);color:var(--bpi-button-text);cursor:pointer;white-space:nowrap}
.bpi-model-pick:hover{border-color:var(--bpm-accent);color:var(--bpm-accent)}
/* 收藏库的树:一行一条,文件夹带展开箭头(放最右),拖动不做任何过渡动画 */
.bpi-lib-list{display:flex;flex-direction:column;gap:1px;padding:4px 0}
.bpi-lib-row{position:relative;display:flex;align-items:center;gap:10px;padding:8px 10px;border-radius:5px;user-select:none}
.bpi-lib-row:hover{background-color:var(--bpi-surface-2)}
.bpi-lib-main{display:flex;align-items:center;gap:6px;min-width:0;flex:1 1 auto}
.bpi-lib-thumb{flex:none;width:120px;height:80px;border:1px solid var(--bpi-border-3);border-radius:4px;background-size:cover;background-position:center}
.bpi-lib-thumb.bpi-lib-noimg{border-style:dashed;background-color:var(--bpi-surface-3)}
.bpi-lib-name{min-width:0;overflow:hidden;color:var(--bpi-text);font-size:20px;white-space:nowrap;text-overflow:ellipsis}
.bpi-lib-folder .bpi-lib-name{flex:none;max-width:55%;font-weight:600;font-size:20px}
.bpi-lib-texts{display:flex;flex-direction:column;gap:3px;min-width:0;flex:1 1 auto}
.bpi-lib-summary{min-width:0;overflow:hidden;color:var(--bpi-text-faint);font-size:16px;white-space:nowrap;text-overflow:ellipsis}
.bpi-lib-count{flex:none;color:var(--bpi-text-faint);font-size:14px}
.bpi-lib-tools{position:absolute;right:42px;top:15px;display:none;gap:4px}
/* 树里的行内按钮比全局 .bpi-mini 大一档:不然在放大后的名称旁边小得几乎点不中 */
.bpi-lib-tools .bpi-mini{font-size:12px;padding:2px 7px;border-radius:4px;line-height:1.4}
.bpi-lib-row:hover .bpi-lib-tools{display:flex}
.bpi-lib-arrow{flex:none;width:38px;padding:2px 0;color:var(--bpi-text-muted);font-size:30px;line-height:1;text-align:center;cursor:pointer;border-radius:5px}
.bpi-lib-arrow:hover{background-color:var(--bpi-surface-3);color:var(--bpi-text)}
.bpi-lib-folder{cursor:pointer}
.bpi-lib-row.bpi-lib-dragging{opacity:.5}
.bpi-lib-row.bpi-lib-drop-before{box-shadow:inset 0 2px 0 var(--bpm-accent)}
.bpi-lib-row.bpi-lib-drop-after{box-shadow:inset 0 -2px 0 var(--bpm-accent)}
.bpi-lib-row.bpi-lib-drop-into{background-color:var(--bpi-surface-3);outline:1px solid var(--bpm-accent);outline-offset:-1px}
.bpi-lib-drop-image{min-height:64px}
.bpi-lib-colors{display:flex;flex-wrap:wrap;gap:6px;align-items:center}
.bpi-lib-color{width:28px;height:28px;padding:0;border:1px solid var(--bpi-border-2);border-radius:6px;background-color:var(--bpi-surface-2);color:var(--bpi-text);cursor:pointer;font-size:12px}
.bpi-lib-color:hover{border-color:var(--bpm-accent)}
.bpi-lib-color.bpi-lib-color-on{outline:2px solid var(--bpm-accent);outline-offset:1px}
.bpi-lib-color-text{width:auto;min-width:46px;padding:0 8px}
.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}
.bpi-help-pop{position:absolute;left:0;bottom:calc(100% + 6px);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}
.bpi-help:hover .bpi-help-pop,.bpi-help:focus .bpi-help-pop{display:block}.bpi-preview-text{box-sizing:border-box;width:100%;min-height:130px;max-height:300px;resize:vertical;border:1px solid var(--bpi-border-2);border-radius:6px;background:var(--bpi-surface-3);color:var(--bpi-text);padding:8px;font:12px/1.5 monospace}.bpi-sort-groups{max-height:230px;overflow:auto;border:1px solid var(--bpi-border-2);border-radius:6px;background:var(--bpi-surface-3);padding:6px;margin:8px 0}.bpi-sort-group{display:grid;grid-template-columns:145px minmax(0,1fr);gap:7px;padding:4px;border-top:1px solid var(--bpi-border-3)}.bpi-sort-group:first-child{border-top:0}.bpi-sort-group strong{color:var(--bpi-zh-text)}
.bpi-about-footer{flex:none;min-height:20px;display:flex;align-items:center;justify-content:space-between;gap:8px;padding:1px 4px 0;color:var(--bpi-text-faint);font-size:9px}.bpi-about-button{border:0;background:transparent;color:var(--bpi-text-muted);padding:1px 3px;cursor:pointer;font-size:9px}.bpi-about-button:hover{color:var(--bpi-link-text);text-decoration:underline}
.bpi-pack-section{border:1px solid var(--bpi-border-2);border-radius:7px;background:var(--bpi-surface-3);padding:7px;margin-bottom:8px}.bpi-pack-toolbar{display:flex;gap:6px;align-items:center;justify-content:space-between;flex-wrap:wrap;margin-bottom:6px}.bpi-pack-toolbar strong{color:var(--bpi-text)}.bpi-pack-list{display:grid;grid-template-columns:repeat(auto-fit,minmax(225px,1fr));gap:5px;max-height:166px;overflow:auto}.bpi-pack-card{border:1px solid var(--bpi-border-2);border-radius:6px;background:var(--bpi-surface-2);padding:6px;display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:6px;align-items:start}.bpi-pack-card.bpi-pack-disabled{opacity:.58}.bpi-pack-name{font-weight:700;color:var(--bpi-text)}.bpi-pack-meta{font-size:10px;color:var(--bpi-text-muted);margin-top:2px;word-break:break-word}.bpi-pack-controls{display:flex;gap:3px;flex-wrap:wrap;justify-content:flex-end}.bpi-switch{margin-top:3px}.bpi-pack-personal{border-color:#397062}.bpi-community-form{display:grid;grid-template-columns:105px minmax(0,1fr);gap:8px;align-items:center}.bpi-community-form input{border:1px solid var(--bpi-border-2);border-radius:5px;background:var(--bpi-input-bg);color:var(--bpi-input-text);padding:6px}.bpi-community-preview{max-height:150px;overflow:auto;border:1px solid var(--bpi-border-2);border-radius:5px;padding:5px;margin-top:8px;color:var(--bpi-text-muted)}
@@ -1521,12 +1433,52 @@ function injectBpiStyles() {
document.head.appendChild(style);
}
// 明暗判断不能只看某一个类名:ComfyUI 前端换过好几轮标记
// (旧版是 body.dark-theme,1.58 之后改成 CSS 变量 + color-scheme),
// 所以这里三路探测,最后用背景亮度兜底。
let bpiAppearance = "auto";
let bpiThemeWatchTimer = null;
function readComfyTheme() {
const root = document.documentElement;
const body = document.body;
// 1) color-scheme:新版前端靠 CSS 变量表达主题,这一路最可靠
const scheme = `${getComputedStyle(root).colorScheme || ""} ${body ? getComputedStyle(body).colorScheme || "" : ""}`;
if (/\bdark\b/.test(scheme)) return "dark";
if (/\blight\b/.test(scheme)) return "light";
// 2) 类名与数据属性:旧版 dark-theme,新版可能是 data-theme / scheme-dark
const themeAttr = root.getAttribute("data-theme") || (body ? body.getAttribute("data-theme") || "" : "");
const marks = [root.className, body ? body.className : "", themeAttr].join(" ");
if (/(^|\s)(dark-theme|dark|scheme-dark)(\s|$)/.test(marks) || /dark/i.test(themeAttr)) return "dark";
if (/(^|\s)(light-theme|light)(\s|$)/.test(marks)) return "light";
// 3) 兜底:看实际背景亮度;取不到就按暗色(ComfyUI 默认是暗的)
const bg = getComputedStyle(body || root).backgroundColor || "";
const nums = bg.match(/[\d.]+/g);
if (!nums || nums.length < 3) return "dark";
const [r, g, b] = nums.slice(0, 3).map(Number);
return (0.299 * r + 0.587 * g + 0.114 * b) / 255 > 0.5 ? "light" : "dark";
}
function syncBpiTheme() {
const next = bpiAppearance === "auto" ? readComfyTheme() : bpiAppearance;
if (document.documentElement.getAttribute("data-bpi-theme") !== next) {
document.documentElement.setAttribute("data-bpi-theme", next);
}
}
function applyBpiAppearance(appearance) {
const value = String(appearance || "auto").toLowerCase();
if (value === "dark" || value === "light") {
document.documentElement.setAttribute("data-bpi-theme", value);
} else {
document.documentElement.removeAttribute("data-bpi-theme");
bpiAppearance = value === "dark" || value === "light" ? value : "auto";
syncBpiTheme();
// 在 ComfyUI 设置里换主题时,前端未必改 class(可能只换 CSS 变量),
// 所以再挂一个低频轮询兜底;值没变时不碰 DOM,开销可以忽略。
if (!bpiThemeWatchTimer) {
bpiThemeWatchTimer = setInterval(() => {
if (bpiAppearance === "auto" && !document.hidden) syncBpiTheme();
}, 1200);
}
}
@@ -1538,6 +1490,7 @@ export {
UNINDEXED_PACK_ID,
UPSTREAM_ARRIVED_EVENT,
applyBpiAppearance,
bpiFetch,
applyLanguageToDom,
buildTagFields,
button,
@@ -1569,7 +1522,6 @@ export {
lookupLargeDictionary,
buildColorPanel,
openManagerPanel,
openSavePromptDialog,
openTagDialog,
postUpstreamAction,
savedPromptImageUrl,
+2 -2
View File
@@ -634,9 +634,9 @@ const ZH = {
"Homepage (optional)": "主页(选填)",
"Records source only; no automatic access": "仅记录来源,不会自动访问",
"Translation Rule": "翻译规则",
"Translate & Optimize Rule": "翻译并优化规则",
"Translate & Optimize": "翻译并优化",
"Optimize Rule": "优化规则",
"Natural Language Rule": "自然语言规则",
"Natural Language": "自然语言",
"Optimize with the rule for the current mode: tag mode tidies the tag flow, natural language mode rewrites it as English sentences":
"按当前模式对应的规则优化:标签模式整理标签流,自然语言模式改写为英文自然语句",
"Connection OK: {}": "连接正常:{}",
+245 -202
View File
@@ -18,28 +18,23 @@ import {
button,
deleteCommunityPack,
deletePersonalTag,
deleteSavedPrompt,
downloadJson,
element,
exportDictionaryPack,
exportPersonalDictionary,
exportSavedPrompts,
field,
getAssistantConfig,
helpMark,
importCommunityPack,
importSavedPrompts,
importTags,
bulkUpdateTags,
applyBpiAppearance,
bpiFetch,
injectBpiStyles,
listAssistantModels,
listSavedPrompts,
loadDictionary,
loadPreferences,
openSavePromptDialog,
openTagDialog,
savedPromptImageUrl,
runInspectorAssistant,
saveAssistantConfig,
savePreferences,
@@ -53,6 +48,7 @@ import {
t,
testAssistantConnection,
} from "./bpi_shared.js";
import { createLibraryView, openLibraryItemDialog } from "./bpi_library.js";
const SECTIONS = [
["tag-manager", "Tag Manager"],
@@ -73,8 +69,6 @@ const manager = {
section: "tags",
loaded: false,
favoritesLoaded: false,
favorites: [],
favoritesChangeHandler: null,
tagManagerNodeId: null,
largeMatches: [],
largeQuery: "",
@@ -920,8 +914,9 @@ async function buildAssistantForm(host, loading) {
list.push(form.querySelector(`label[for="${input.id}"]`), input);
return input;
};
// 模型名称这一行:输入框照旧可以手写,旁边的按钮去问一次服务端有哪些模型,填进输入框自带的下拉里。
// 用原生 datalist 而不是弹窗:助手设置长在 ComfyUI 的「设置」面板里,浮层会被它盖住且点不动。
// 模型名称这一行:输入框照旧可以手写,旁边的按钮去问一次服务端有哪些模型。
// 候选不放在原生 datalist 里 —— Chrome 只有在输入框里打字时才弹,空着聚焦点不开,
// 而 ComfyUI 设置面板里又容易被裁剪。改成行下方内联展开的候选列表,点得到也看得清。
const addModelRow = (list, name, value, placeholder, options) => {
const label = element("label", "", "Model Name");
label.htmlFor = `bpi-field-${name}`;
@@ -930,23 +925,43 @@ async function buildAssistantForm(host, loading) {
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);
const picks = element("div", "bpi-model-picks bpi-hidden");
// 设置表单是两列 grid(标签 78px + 控件),候选列表要横跨整行,否则会缩到左边那一列里
picks.style.gridColumn = "1 / -1";
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);
form.append(label, line, picks);
list.push(label, line, picks);
const countLabel = () => (models ? `Fetch Models (${models.length})` : "Fetch Models");
const closePicks = () => {
picks.replaceChildren();
picks.classList.add("bpi-hidden");
};
const openPicks = () => {
picks.replaceChildren();
for (const model of models ?? []) {
const item = element("button", "bpi-model-pick", model);
item.type = "button";
setTitle(item, "Use this model");
item.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
input.value = model;
input.dispatchEvent(new Event("change", { bubbles: true }));
picks.classList.add("bpi-hidden");
});
picks.appendChild(item);
}
picks.classList.toggle("bpi-hidden", !models?.length);
};
const forget = () => {
models = null;
dataList.textContent = "";
closePicks();
setText(trigger, countLabel());
};
// 地址改了就地作废:下拉里不能留着上一个地址的模型
// 地址改了就地作废:候选里不能留着上一个地址的模型
options.baseUrl().addEventListener("change", forget);
const pick = async () => {
trigger.disabled = true;
@@ -959,17 +974,12 @@ async function buildAssistantForm(host, loading) {
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();
openPicks();
setText(error, `Fetched ${models.length} models`);
error.dataset.kind = "ok";
} catch (fetchError) {
closePicks();
setText(trigger, countLabel());
setText(error, fetchError.message);
error.dataset.kind = "error";
@@ -1126,24 +1136,204 @@ async function buildAssistantForm(host, loading) {
const labelElement = element("label", "", labelText);
const textarea = element("textarea");
textarea.value = value;
form.append(labelElement, textarea);
list.push(labelElement, textarea);
// 小圈挂在标签那一列的左下角:这条规则和所有预设(含内置默认)都对不上时显示
const labelCell = element("div", "bpi-rule-label");
const orphan = element("span", "bpi-rule-orphan", "*");
setTitle(orphan, "这条规则不属于任何一个预设");
labelCell.append(labelElement, orphan);
form.append(labelCell, textarea);
list.push(labelCell, textarea);
textarea.orphanBadge = orphan;
return textarea;
};
// 四条规则归到一个分组里:以后切规则预设时整体一起换,也方便在标题行右侧放管理入口
const addRuleHead = (list, title, hint) => {
const head = element("div", "bpm-toolbar");
head.style.gridColumn = "1 / -1";
head.append(element("strong", "", title));
if (hint) {
const note = element("span", "bpi-config-note", hint);
note.style.marginLeft = "8px";
head.append(note);
}
form.append(head);
list.push(head);
return head;
};
// 规则编辑和保存是两件事:改完不点保存,刷新或执行翻译时用的还是旧规则
const RULE_SAVE_HINT = "修改需保存后生效;未保存的规则不参与翻译";
const openaiRulesHead = addRuleHead(openaiOnly, "AI 规则", RULE_SAVE_HINT);
const ollamaRulesHead = addRuleHead(ollamaOnly, "AI 规则", RULE_SAVE_HINT);
const rules = {
openai_compatible: {
translation: addRule(openaiOnly, "Translation Rule", config.openai_translation_rule),
translateOptimize: addRule(openaiOnly, "Translate & Optimize Rule", config.openai_translate_optimize_rule),
translateOptimize: addRule(openaiOnly, "Translate & Optimize", config.openai_translate_optimize_rule),
optimization: addRule(openaiOnly, "Optimize Rule", config.openai_optimization_rule),
natural: addRule(openaiOnly, "Natural Language Rule", config.openai_natural_rule),
natural: addRule(openaiOnly, "Natural Language", 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),
translateOptimize: addRule(ollamaOnly, "Translate & Optimize", config.ollama_translate_optimize_rule),
optimization: addRule(ollamaOnly, "Optimize Rule", config.ollama_optimization_rule),
natural: addRule(ollamaOnly, "Natural Language Rule", config.ollama_natural_rule),
natural: addRule(ollamaOnly, "Natural Language", config.ollama_natural_rule),
},
};
// —— AI 规则预设:标题行右侧一排(下拉 + 另存为 + 删除),一套规则一个文件 ——
// 存在 data/rule_presets/<backend>/<名字>.json,可以直接拷给别人或手工编辑
const RULE_FIELDS = [
["translation", "translation"],
["translate_optimize", "translateOptimize"],
["optimization", "optimization"],
["natural", "natural"],
];
const readRuleSet = (ruleSet) => Object.fromEntries(
RULE_FIELDS.map(([wire, key]) => [wire, String(ruleSet[key].value ?? "")]),
);
const writeRuleSet = (ruleSet, payload) => {
for (const [wire, key] of RULE_FIELDS) {
if (typeof payload?.[wire] === "string") ruleSet[key].value = payload[wire];
}
};
const rulePresetApi = async (url, options) => {
const response = await bpiFetch(url, options);
const payload = await response.json().catch(() => null);
if (!response.ok || !payload?.success) throw new Error(payload?.error || `请求失败(HTTP ${response.status})`);
return payload.data;
};
const buildRulePresetBar = (head, backend, ruleSet) => {
const bar = element("span", "bpi-rule-presets");
const select = element("select", "bpi-select bpi-rule-preset-select");
let presets = [];
const fill = () => {
select.replaceChildren();
const placeholder = element("option", "", presets.length ? "选择一套规则" : "还没有预设");
placeholder.value = "";
select.appendChild(placeholder);
for (const preset of presets) {
const option = element("option", "", preset.name);
option.value = preset.name;
select.appendChild(option);
}
select.value = "";
};
const refresh = async () => {
try {
presets = await rulePresetApi(`/bpi/rule-presets?backend=${encodeURIComponent(backend)}`);
fill();
markOrphans();
} catch (failure) {
setText(error, failure.message);
error.dataset.kind = "error";
}
};
select.addEventListener("change", () => {
const preset = presets.find((item) => item.name === select.value);
if (!preset) return;
writeRuleSet(ruleSet, preset.rules);
markOrphans();
setText(error, `已载入规则「${preset.name}」,记得点下面的保存`);
error.dataset.kind = "ok";
});
const saveButton = button("另存为", async () => {
const name = window.prompt("这套规则叫什么名字?(同名会覆盖)", "");
if (!name?.trim()) return;
try {
const saved = await rulePresetApi("/bpi/rule-presets", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ backend, name: name.trim(), rules: readRuleSet(ruleSet) }),
});
setText(error, `已保存规则「${saved.name}」`);
error.dataset.kind = "ok";
await refresh();
select.value = saved.name;
} catch (failure) {
setText(error, failure.message);
error.dataset.kind = "error";
}
}, "bpi-mini");
const deleteButton = button("删除", async () => {
const name = select.value;
if (!name) return;
if (!window.confirm(`删除规则「${name}」?`)) return;
try {
await rulePresetApi(`/bpi/rule-presets/${encodeURIComponent(name)}?backend=${encodeURIComponent(backend)}`, { method: "DELETE" });
setText(error, `已删除「${name}」`);
error.dataset.kind = "ok";
await refresh();
} catch (failure) {
setText(error, failure.message);
error.dataset.kind = "error";
}
}, "bpi-mini bpi-danger");
// 恢复默认是破坏性操作,要点三次:确认?→ 红色「恢复默认」→ 真正执行
let resetStage = 0;
let resetTimer = null;
const resetIdle = () => {
resetStage = 0;
clearTimeout(resetTimer);
resetTimer = null;
setText(resetButton, "恢复默认");
resetButton.classList.remove("bpi-danger");
};
const armResetIdle = () => {
clearTimeout(resetTimer);
resetTimer = setTimeout(resetIdle, 4000);
};
const resetButton = button("恢复默认", () => {
if (resetStage === 0) {
resetStage = 1;
setText(resetButton, "确认?");
armResetIdle();
return;
}
if (resetStage === 1) {
resetStage = 2;
setText(resetButton, "恢复默认");
resetButton.classList.add("bpi-danger");
armResetIdle();
return;
}
const prefix = backend === "ollama" ? "ollama" : "openai";
ruleSet.translation.value = config[`default_${prefix}_translation_rule`];
ruleSet.translateOptimize.value = config[`default_${prefix}_translate_optimize_rule`];
ruleSet.optimization.value = config[`default_${prefix}_optimization_rule`];
ruleSet.natural.value = config[`default_${prefix}_natural_rule`];
markOrphans();
setText(error, "编辑器已恢复内置默认,记得点下面的保存");
error.dataset.kind = "ok";
resetIdle();
}, "bpi-mini");
// 和「内置默认 + 所有预设」比对:哪条规则对不上任何一个,就在它左下角标一个圈 *
const normalizeRule = (value) => String(value ?? "").replace(/\r\n/g, "\n").replace(/[ \t]+/g, " ").trim();
const markOrphans = () => {
const prefix = backend === "ollama" ? "ollama" : "openai";
const defaults = {
translation: config[`default_${prefix}_translation_rule`],
translate_optimize: config[`default_${prefix}_translate_optimize_rule`],
optimization: config[`default_${prefix}_optimization_rule`],
natural: config[`default_${prefix}_natural_rule`],
};
const known = [defaults, ...presets.map((preset) => preset.rules)];
let orphanCount = 0;
for (const [wire, key] of RULE_FIELDS) {
const current = normalizeRule(ruleSet[key].value);
const found = known.some((set) => normalizeRule(set?.[wire]) === current);
const badge = ruleSet[key].orphanBadge;
if (badge) badge.classList.toggle("bpi-rule-orphan-on", !found);
if (!found) orphanCount += 1;
}
return orphanCount;
};
bar.append(select, saveButton, resetButton, deleteButton);
head.appendChild(bar);
refresh();
return { refresh, markOrphans };
};
const openaiRulesBar = buildRulePresetBar(openaiRulesHead, "openai", rules.openai_compatible);
const ollamaRulesBar = buildRulePresetBar(ollamaRulesHead, "ollama", rules.ollama);
form.appendChild(error);
const applyServiceVisibility = () => {
@@ -1162,17 +1352,6 @@ async function buildAssistantForm(host, loading) {
host.appendChild(element("div", "bpi-config-note", "Settings are stored in the ComfyUI user directory. AI key and Baidu secret are retained independently; switching service never clears either. AI key is only cleared when AI backend/URL changes or on a different machine. Neither is written to workflows or returned to the browser."));
const actions = element("div", "bpi-form-actions");
// 只恢复当前所选后端那套规则,另一套不动
const resetButton = button("Reset to Default Rules", () => {
const backend = currentBackend();
const prefix = backend === "ollama" ? "ollama" : "openai";
const active = rules[backend] ?? rules.openai_compatible;
active.translation.value = config[`default_${prefix}_translation_rule`];
active.translateOptimize.value = config[`default_${prefix}_translate_optimize_rule`];
active.optimization.value = config[`default_${prefix}_optimization_rule`];
active.natural.value = config[`default_${prefix}_natural_rule`];
setText(error, "Defaults restored in editor; click Save to apply");
error.dataset.kind = "ok";
});
const testButton = button("Save & Test Connection", async () => {
testButton.disabled = true;
setText(error, "Testing...");
@@ -1180,7 +1359,11 @@ async function buildAssistantForm(host, loading) {
try {
config = await saveAssistantConfig(payload());
const result = await testAssistantConnection();
setText(error, `Connection OK: “${result.message}”`);
// 保存这一下才重新判断哪些规则不在任何预设里(打字时不闪)
const orphans = openaiRulesBar.markOrphans() + ollamaRulesBar.markOrphans();
setText(error, orphans
? `Connection OK: “${result.message}”;有 ${orphans} 条规则不属于任何预设,记得另存为预设`
: `Connection OK: “${result.message}”`);
error.dataset.kind = "ok";
} catch (testError) {
setText(error, testError.message);
@@ -1202,7 +1385,7 @@ async function buildAssistantForm(host, loading) {
saveButton.disabled = false;
}
}, "bpi-primary");
actions.append(resetButton, testButton, saveButton);
actions.append(testButton, saveButton);
host.appendChild(actions);
// 两套 AI 配置和两套规则都提交(后端对没给的字段才保留原值,这里给全最省心);
// 外观 / 语言已在「设置」面板里独立成项(appearanceControl),这里既不提交也不应用
@@ -1245,7 +1428,7 @@ function setSection(section) {
if (section === "favorites" && !manager.favoritesLoaded) {
manager.favoritesLoaded = true;
refs.sections.appendChild(buildFavoritesSection());
refreshFavorites();
refs.libraryView?.refresh();
}
}
@@ -1253,14 +1436,6 @@ function setSection(section) {
// 收藏:提示词 + 可选配图,落在 ComfyUI 用户目录
// ---------------------------------------------------------------------------
function favoriteDate(value) {
const stamp = Number(value ?? 0);
if (!stamp) return "";
const date = new Date(stamp * 1000);
const pad = (item) => String(item).padStart(2, "0");
return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
function targetInspectorNode() {
const nodes = inspectorNodes();
return nodes.find((node) => node.id === manager.tagManagerNodeId)
@@ -1324,84 +1499,6 @@ async function pickInspectorNode() {
});
}
function favoriteCard(item) {
const card = element("div", "bpi-fav-card");
const thumb = element("div", "bpi-fav-thumb");
if (item.image) thumb.style.backgroundImage = `url("${savedPromptImageUrl(item.id)}")`;
else setText(thumb, "No image");
const info = element("div", "bpi-fav-info");
const name = element("span", "bpi-fav-name", item.name ?? "Untitled");
name.appendChild(element("span", "bpi-fav-date", favoriteDate(item.created_at)));
const actions = element("div", "bpi-fav-actions");
actions.append(
button("Load to Node", () => loadFavoriteIntoNode(item), "bpi-primary"),
button("Edit", () => editFavorite(item)),
button("Copy", () => copyFavorite(item)),
button("Delete", () => removeFavorite(item), "bpi-danger"),
);
const meta = element("div", "bpi-fav-meta");
// 模型名是用户数据,直接写进 textContent,不进翻译层
const models = Array.isArray(item.models) ? item.models.filter((value) => typeof value === "string" && value.trim()) : [];
if (models.length) {
const tags = element("div", "bpi-model-tags");
for (const model of models) {
const tag = element("span", "bpi-model-tag");
tag.textContent = model;
tags.appendChild(tag);
}
meta.appendChild(tags);
}
const note = element("span", "bpi-fav-note", item.note ? "" : "No note");
if (item.note) note.textContent = item.note;
meta.appendChild(note);
info.append(
name,
element("div", "bpi-fav-text", item.text ?? ""),
meta,
actions,
);
card.append(thumb, info);
return card;
}
function renderFavorites() {
const list = refs.favoritesList;
if (!list) return;
const query = String(refs.favoritesSearch?.value ?? "").trim().toLowerCase();
const items = manager.favorites.filter((item) => !query
|| String(item.name ?? "").toLowerCase().includes(query)
|| String(item.text ?? "").toLowerCase().includes(query)
|| (Array.isArray(item.models) && item.models.some((model) => String(model ?? "").toLowerCase().includes(query))));
list.replaceChildren();
if (!items.length) {
list.appendChild(element("div", "bpi-empty", manager.favorites.length
? "No matching favorites."
: "No favorites yet. Click the star button on a node to save the current prompt."));
return;
}
for (const item of items) list.appendChild(favoriteCard(item));
}
async function refreshFavorites() {
try {
manager.favorites = await listSavedPrompts();
} catch (error) {
setStatus(error.message, "error");
manager.favorites = [];
}
renderFavorites();
}
async function copyFavorite(item) {
try {
await navigator.clipboard.writeText(item.text ?? "");
setStatus("Prompt copied to clipboard", "ok");
} catch {
setStatus("Browser denied clipboard access", "error");
}
}
async function loadFavoriteIntoNode(item) {
const target = targetInspectorNode();
if (!target) {
@@ -1417,59 +1514,16 @@ async function loadFavoriteIntoNode(item) {
setStatus(`Loaded to node #${target.id}`, "ok");
}
async function removeFavorite(item) {
const confirmed = await confirmDialog("Delete", `Delete “${item.name ?? t("Untitled")}”? Reference image will be deleted too.`);
if (!confirmed) return;
try {
await deleteSavedPrompt(item.id);
await refreshFavorites();
setStatus("Deleted", "ok");
} catch (error) {
setStatus(error.message, "error");
}
}
async function createFavoriteFromNode() {
// 保存当前节点的提示词到收藏库:和节点上的「收藏」按钮等价,只是入口在侧边栏
async function saveCurrentPromptToLibrary() {
const node = await pickInspectorNode();
if (!node) return;
const text = String(node.widgets?.find((widget) => widget.name === "text")?.value ?? "");
openSavePromptDialog({ text }, async () => {
await refreshFavorites();
setStatus("Saved", "ok");
});
}
function editFavorite(item) {
openSavePromptDialog({
name: item.name ?? "",
text: item.text ?? "",
note: item.note ?? null,
models: Array.isArray(item.models) ? item.models : [],
editing: item,
}, async () => {
await refreshFavorites();
setStatus("Saved", "ok");
});
}
async function exportFavorites() {
try {
const bundle = await exportSavedPrompts();
downloadJson(bundle, `saved-prompts-${new Date().toISOString().slice(0, 10)}.json`);
setStatus("Exported", "ok");
} catch (error) {
setStatus(error.message, "error");
}
}
async function importFavoritesFile(file) {
try {
const result = await importSavedPrompts(JSON.parse(await file.text()));
await refreshFavorites();
setStatus(`Import complete: ${result?.imported ?? 0} added`, "ok");
} catch (error) {
setStatus(error.message, "error");
if (!text.trim()) {
setStatus("这个节点的提示词是空的", "error");
return;
}
openLibraryItemDialog({ presetKind: "prompt", presetText: text }, () => setStatus("已保存", "ok"));
}
function buildFavoritesSection() {
@@ -1479,28 +1533,24 @@ function buildFavoritesSection() {
refs.favoritesSearch = element("input", "bpi-search");
refs.favoritesSearch.type = "search";
setPlaceholder(refs.favoritesSearch, "Search by name or content");
refs.favoritesSearch.addEventListener("input", renderFavorites);
const importInput = element("input");
importInput.type = "file";
importInput.accept = ".json,application/json";
importInput.style.display = "none";
importInput.addEventListener("change", async () => {
const file = importInput.files?.[0];
importInput.value = "";
if (file) await importFavoritesFile(file);
refs.favoritesSearch.addEventListener("input", () => {
refs.libraryView?.setQuery(refs.favoritesSearch.value);
});
refs.favoritesList = element("div", "bpm-rows bpi-lib-list");
refs.libraryView = createLibraryView({
mount: refs.favoritesList,
onLoad: (item) => loadFavoriteIntoNode(item),
onToast: (message, kind) => setStatus(message, kind ?? ""),
confirmDelete: (item) => confirmDialog("删除", `删除“${item.name ?? "未命名"}”?参考图会一起删掉。`),
});
toolbar.append(
refs.favoritesSearch,
button("New", () => createFavoriteFromNode(), "bpi-primary"),
button("Export", () => exportFavorites()),
button("Import", () => importInput.click()),
importInput,
button("新建", () => refs.libraryView.openCreate(), "bpi-primary"),
button("保存", () => saveCurrentPromptToLibrary()),
);
refs.favoritesList = element("div", "bpm-rows");
section.append(toolbar, refs.favoritesList);
return section;
}
function renderAll() {
renderRows();
renderPacks();
@@ -1669,13 +1719,6 @@ function build() {
root.addEventListener("wheel", (event) => event.stopPropagation(), { passive: true });
// 面板每次都重建,收藏分页也要跟着重新懒加载
manager.favoritesLoaded = false;
if (manager.favoritesChangeHandler) {
window.removeEventListener("bpi:saved-prompts-changed", manager.favoritesChangeHandler);
}
manager.favoritesChangeHandler = () => {
if (manager.favoritesLoaded) refreshFavorites();
};
window.addEventListener("bpi:saved-prompts-changed", manager.favoritesChangeHandler);
}
function buildTagsSection() {
+19 -6
View File
@@ -115,15 +115,28 @@ export function removePromptToken(text, token) {
}
if (end < value.length && value[end] === ",") {
// 逗号是标签之间的分隔,跟着标签一起删;逗号后面紧跟的换行也算同一处分隔
// (前面已经有一个换行在分段了,再留一个就多出空行)
// 逗号是标签之间的分隔,跟着标签一起删
end += 1;
while (end < value.length && /[ \t]/.test(value[end])) end += 1;
if (isLineBreak(value[end])) {
const separator = value[end];
end += 1;
if (separator === "\r" && value[end] === "\n") end += 1;
while (end < value.length && /[ \t]/.test(value[end])) end += 1;
// 行尾标签:这一行前面还有别的标签时,换行是分段标记,必须留着,
// 否则下一行会被拽上来、两段并成一段(看起来就是「删标签把换行带走了」)。
// 只有整行就剩这一个标签时,才连换行一起删,免得留下空行。
const lineStart = Math.max(value.lastIndexOf("\n", start - 1), value.lastIndexOf("\r", start - 1)) + 1;
const leftOnLine = value.slice(lineStart, start).replace(/[ \t]/g, "").replace(/,+$/, "");
if (leftOnLine) {
let probe = start - 1;
while (probe >= 0 && /[ \t]/.test(value[probe])) probe -= 1;
if (probe >= 0 && value[probe] === ",") {
start = probe;
while (start > 0 && /[ \t]/.test(value[start - 1])) start -= 1;
}
} else {
const separator = value[end];
end += 1;
if (separator === "\r" && value[end] === "\n") end += 1;
while (end < value.length && /[ \t]/.test(value[end])) end += 1;
}
}
} else if (end < value.length && isLineBreak(value[end])) {
// 标签正好在行尾、后面直接是换行:换行是分段标记,不能跟着删掉,
+456
View File
@@ -0,0 +1,456 @@
"""收藏库:一个条目一个目录,目录名就是名称,缩略图与目录同名。
为什么这么存(而不是像 saved_prompts 那样一个大 index.json 装文本):
- 用户能在文件管理器里直接看到、改名、备份,目录名一眼就知道是什么;
- 图片与目录同名,缺图/多图一眼能看出来;
- 顺序和父子关系放在 library/index.json 里当缓存,打开面板只读这一个文件,
不扫目录 —— 目录多了也不会卡。
磁盘是真相,index.json 是缓存:被外部改乱时用 rebuild() 重建。
"""
import json
import os
import re
import secrets
import shutil
import tempfile
import time
from pathlib import Path
try: # 包内导入优先;单文件调试时退化成同目录导入
from .saved_prompt_store import sniff_image
except ImportError: # pragma: no cover
from saved_prompt_store import sniff_image
MAX_ITEMS = 2000
MAX_NAME_CHARS = 80
MAX_TEXT_CHARS = 65536
MAX_NOTE_CHARS = 500
MAX_MODELS = 20
MAX_IMAGE_BYTES = 5 * 1024 * 1024
INDEX_VERSION = 1
_ILLEGAL_NAME_CHARS = set('\\/:*?"<>|')
_WINDOWS_RESERVED = {"CON", "PRN", "AUX", "NUL", *(f"COM{i}" for i in range(1, 10)), *(f"LPT{i}" for i in range(1, 10))}
def _default_library_dir():
"""收藏库放在插件目录下的 data/saved:和用户词库、配置在一起,备份时一起带走。"""
return Path(__file__).resolve().parent / "data" / "saved"
def safe_dir_name(name):
"""目录名:去掉 Windows 不允许的字符、首尾的点和空格,避开保留名。"""
cleaned = "".join("_" if ch in _ILLEGAL_NAME_CHARS or ord(ch) < 32 else ch for ch in str(name or ""))
cleaned = cleaned.strip(" .")[:MAX_NAME_CHARS] or "未命名"
if cleaned.upper() in _WINDOWS_RESERVED:
cleaned = f"_{cleaned}"
return cleaned
_COLOR_PATTERN = re.compile(r"^#[0-9a-f]{6}$")
def clean_color(value):
"""文件夹的淡色:只认 #rrggbb(前端渲染时自己加透明度),别的都当没设。"""
if not isinstance(value, str):
return None
text = value.strip().lower()
return text if _COLOR_PATTERN.match(text) else None
def clean_models(value):
"""适用模型:去空白、去重(忽略大小写),最多 MAX_MODELS 个。"""
if not isinstance(value, (list, tuple)):
return []
cleaned, seen = [], set()
for item in value:
if not isinstance(item, str):
continue
label = " ".join(item.split())[:MAX_NAME_CHARS]
if not label:
continue
marker = label.casefold()
if marker in seen:
continue
seen.add(marker)
cleaned.append(label)
if len(cleaned) >= MAX_MODELS:
break
return cleaned
class LibraryStore:
def __init__(self, store_dir=None):
self.store_dir = Path(store_dir or _default_library_dir())
self.index_path = self.store_dir / "index.json"
self._cache = None
self._cache_signature = None
# 建不出来(只读安装、权限不足)也不要在导入阶段就炸:
# 收藏相关接口各自报错,其它功能照常
try:
self.store_dir.mkdir(parents=True, exist_ok=True)
except OSError:
pass
# ---- 索引 -------------------------------------------------------------
def _signature(self):
try:
stat = self.index_path.stat()
except OSError:
return None
return (stat.st_mtime_ns, stat.st_size)
def _read_index_file(self):
try:
payload = json.loads(self.index_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return None
if not isinstance(payload, dict) or not isinstance(payload.get("items"), list):
return None
return payload
def _write_index_file(self, items):
items.sort(key=lambda item: (item.get("order", 0), item.get("created_at", 0)))
payload = {"version": INDEX_VERSION, "updated_at": int(time.time()), "items": items}
self.store_dir.mkdir(parents=True, exist_ok=True)
fd, temp_name = tempfile.mkstemp(prefix="library_", suffix=".json", dir=self.store_dir)
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
json.dump(payload, handle, ensure_ascii=False, indent=2)
handle.write("\n")
os.replace(temp_name, self.index_path)
finally:
if os.path.exists(temp_name):
os.unlink(temp_name)
self._cache = items
self._cache_signature = self._signature()
def _entries(self):
"""取条目列表:优先内存缓存,其次 index.json,最后重建。"""
if self._cache is not None and self._cache_signature == self._signature():
return self._cache
payload = self._read_index_file()
if payload is None:
return self.rebuild()
items = [item for item in payload["items"] if isinstance(item, dict) and isinstance(item.get("id"), str)]
items.sort(key=lambda item: (item.get("order", 0), item.get("created_at", 0)))
self._cache = items
self._cache_signature = self._signature()
return items
def rebuild(self):
"""扫一遍目录重建索引;只在首次、索引损坏或手动触发时调用。"""
items = []
try:
self.store_dir.mkdir(parents=True, exist_ok=True)
children = sorted(self.store_dir.iterdir(), key=lambda path: path.name.casefold())
except OSError:
return items
for entry in children:
if not entry.is_dir() or entry.name.startswith("."):
continue
info = self._read_info(entry)
if not info:
continue
image = self._resolve_image(entry, info)
items.append({
"id": info["id"],
"type": "folder" if info.get("type") == "folder" else "prompt",
"name": info["name"],
"dir": entry.name,
"parent": info.get("parent"),
"order": int(info.get("order") or 0),
"note": info.get("note"),
"models": clean_models(info.get("models")),
"color": clean_color(info.get("color")),
"text": str(info.get("text") or "")[:MAX_TEXT_CHARS],
"image": image,
"created_at": int(info.get("created_at") or 0),
})
items.sort(key=lambda item: (item["order"], item["created_at"]))
self._write_index_file(items)
return items
@staticmethod
def _read_info(directory):
try:
info = json.loads((directory / "info.json").read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return None
if not isinstance(info, dict) or not isinstance(info.get("id"), str):
return None
name = str(info.get("name") or "").strip()[:MAX_NAME_CHARS] or directory.name
info["name"] = name
return info
@staticmethod
def _resolve_image(directory, info):
"""图片名必须与目录名一致才算数:有人手工塞进来一张别的图就不认。"""
recorded = info.get("image")
if not isinstance(recorded, str) or not recorded:
return None
candidate = directory / recorded
if not candidate.is_file():
return None
if Path(recorded).stem.casefold() != directory.name.casefold():
return None
return recorded
# ---- 目录与文件 -------------------------------------------------------
def _unique_dir_name(self, name, entries, current_id=None):
base = safe_dir_name(name)
taken = {item["dir"].casefold() for item in entries if item.get("id") != current_id}
candidate = base
index = 2
while candidate.casefold() in taken or (self.store_dir / candidate).exists():
candidate = f"{base} ({index})"
index += 1
return candidate
def _write_info(self, directory, info):
target = directory / "info.json"
fd, temp_name = tempfile.mkstemp(prefix="info_", suffix=".json", dir=directory)
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
json.dump(info, handle, ensure_ascii=False, indent=2)
handle.write("\n")
os.replace(temp_name, target)
finally:
if os.path.exists(temp_name):
os.unlink(temp_name)
def _write_image(self, directory, dir_name, image_bytes, previous=None):
"""图片与目录同名;替换时先删旧文件,避免留下两张。"""
if previous:
try:
(directory / previous).unlink()
except OSError:
pass
if not image_bytes:
return None
if not isinstance(image_bytes, (bytes, bytearray)) or len(image_bytes) > MAX_IMAGE_BYTES:
raise ValueError(f"图片过大或无效(最大 {MAX_IMAGE_BYTES // (1024 * 1024)} MB)")
suffix = sniff_image(image_bytes)
if suffix is None:
raise ValueError("图片格式仅支持 PNG / JPG / WebP")
image_name = f"{dir_name}{suffix}"
(directory / image_name).write_bytes(image_bytes)
return image_name
def _find(self, item_id, entries=None):
for item in entries or self._entries():
if item["id"] == item_id:
return item
return None
def _require(self, item_id):
item = self._find(item_id)
if not item:
raise KeyError(item_id)
return item
# ---- 对外接口 ---------------------------------------------------------
def tree(self):
"""扁平列表(带 parent/order),前端自己拼树;顺便给出目录根,方便显示路径。"""
return {"root": str(self.store_dir), "items": self._entries()}
def create(self, kind, name, text=None, note=None, models=None, image_bytes=None, parent=None, color=None):
entries = self._entries()
if len(entries) >= MAX_ITEMS:
raise ValueError(f"收藏数量已达上限({MAX_ITEMS})")
name = " ".join(str(name or "").split())[:MAX_NAME_CHARS]
if not name:
raise ValueError("名称不能为空")
kind = "folder" if kind == "folder" else "prompt"
if kind == "prompt" and not str(text or "").strip():
raise ValueError("提示词内容不能为空")
if parent and not any(item["id"] == parent and item["type"] == "folder" for item in entries):
raise ValueError("目标文件夹不存在")
dir_name = self._unique_dir_name(name, entries)
directory = self.store_dir / dir_name
directory.mkdir(parents=True, exist_ok=False)
now = int(time.time())
info = {
"id": secrets.token_hex(8),
"type": kind,
"name": name,
"note": (str(note)[:MAX_NOTE_CHARS] if note else None),
"models": clean_models(models),
"color": clean_color(color),
"created_at": now,
"parent": parent,
"order": max([item["order"] for item in entries if item.get("parent") == parent], default=-1) + 1,
}
if kind == "prompt":
info["text"] = str(text)[:MAX_TEXT_CHARS]
try:
image = self._write_image(directory, dir_name, image_bytes)
except ValueError:
shutil.rmtree(directory, ignore_errors=True)
raise
if image:
info["image"] = image
self._write_info(directory, info)
entries.append({
"id": info["id"], "type": kind, "name": name, "dir": dir_name,
"parent": info["parent"], "order": info["order"],
"note": info["note"], "models": info["models"], "color": info.get("color"),
"text": info.get("text", ""), "image": image, "created_at": now,
})
self._write_index_file(entries)
return next(item for item in entries if item["id"] == info["id"])
def update(self, item_id, name=None, text=None, note=None, models=None, image_bytes=None, remove_image=False, color=None):
entries = self._entries()
item = self._require(item_id)
directory = self.store_dir / item["dir"]
if not directory.is_dir():
raise ValueError("目录已不存在,请刷新收藏列表")
info = self._read_info(directory) or {"id": item_id, "name": item["name"]}
new_name = " ".join(str(name).split())[:MAX_NAME_CHARS] if name is not None else item["name"]
if not new_name:
raise ValueError("名称不能为空")
renamed = new_name != item["name"]
dir_name = item["dir"]
if renamed:
target = self._unique_dir_name(new_name, entries, current_id=item_id)
# 目录名跟着名称走,图片也要跟着改名,保持「目录名 = 图片名」
previous_image = info.get("image")
directory.rename(self.store_dir / target)
directory = self.store_dir / target
dir_name = target
if previous_image:
new_image = f"{target}{Path(previous_image).suffix}"
try:
(directory / previous_image).rename(directory / new_image)
info["image"] = new_image
except OSError:
info["image"] = None
if text is not None and item["type"] == "prompt":
info["text"] = str(text)[:MAX_TEXT_CHARS]
if note is not None:
info["note"] = (str(note)[:MAX_NOTE_CHARS] if note else None)
if models is not None:
info["models"] = clean_models(models)
if color is not None:
info["color"] = clean_color(color)
if remove_image:
previous = info.get("image")
if previous:
try:
(directory / previous).unlink()
except OSError:
pass
info["image"] = None
elif image_bytes is not None:
image = self._write_image(directory, dir_name, image_bytes, previous=info.get("image"))
info["image"] = image
info["name"] = new_name
self._write_info(directory, info)
item.update({
"name": new_name, "dir": dir_name,
"note": info.get("note"), "models": clean_models(info.get("models")),
"color": clean_color(info.get("color")),
"text": info.get("text", item.get("text", "")),
"image": info.get("image"),
})
self._write_index_file(entries)
return item
def move(self, item_id, parent=None, index=None):
entries = self._entries()
item = self._require(item_id)
if item["type"] == "folder" and parent == item_id:
raise ValueError("不能把文件夹拖进自己里面")
if parent:
target = self._find(parent, entries)
if not target or target["type"] != "folder":
raise ValueError("目标文件夹不存在")
if item["type"] == "folder" and target.get("parent") == item_id:
raise ValueError("不能把文件夹拖进自己里面")
siblings = [entry for entry in entries if entry.get("parent") == parent and entry["id"] != item_id]
position = len(siblings) if index is None else max(0, min(int(index), len(siblings)))
siblings.insert(position, item)
for order, sibling in enumerate(siblings):
sibling["order"] = order
item["parent"] = parent
self._write_index_file(entries)
directory = self.store_dir / item["dir"]
info = self._read_info(directory)
if info:
info["parent"] = parent
info["order"] = item["order"]
self._write_info(directory, info)
return item
def delete(self, item_id):
entries = self._entries()
item = self._require(item_id)
directory = self.store_dir / item["dir"]
# 只删库目录下的这一层,避免任何路径穿越
if directory.is_dir() and directory.parent == self.store_dir:
shutil.rmtree(directory, ignore_errors=True)
remaining = [entry for entry in entries if entry["id"] != item_id]
if item["type"] == "folder":
# 文件夹里的东西一起消失,索引里也一并清掉
orphaned = [entry for entry in remaining if entry.get("parent") == item_id]
for entry in orphaned:
child = self.store_dir / entry["dir"]
if child.is_dir() and child.parent == self.store_dir:
shutil.rmtree(child, ignore_errors=True)
remaining = [entry for entry in remaining if entry.get("parent") != item_id]
self._write_index_file(remaining)
return len(remaining)
def import_legacy(self, entries, source_dir):
"""把旧的 saved_prompts 一次性搬进来:名字/文本/备注/模型照搬,图片读成字节重新落盘。
只搬一次:搬过就写 .legacy-imported 标记,之后即使用户把收藏删空也不会再灌一遍。
"""
marker = self.store_dir / ".legacy-imported"
if marker.exists() or self._entries():
return {"imported": 0, "skipped": True}
source = Path(source_dir)
imported = 0
for entry in entries or []:
if not isinstance(entry, dict):
continue
try:
image_bytes = None
image = entry.get("image")
if isinstance(image, str) and image:
candidate = source / image
if candidate.is_file():
image_bytes = candidate.read_bytes()
self.create(
"prompt", entry.get("name"), text=entry.get("text"),
note=entry.get("note"), models=entry.get("models"), image_bytes=image_bytes,
)
imported += 1
except (ValueError, OSError):
continue
marker.write_text("1", encoding="utf-8")
return {"imported": imported, "skipped": False}
def image_path(self, item_id):
item = self._require(item_id)
image = item.get("image")
if not image:
raise ValueError("这条收藏没有配图")
path = self.store_dir / item["dir"] / image
if not path.is_file():
raise ValueError("配图文件已丢失")
return path
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "comfyui-bilingual-prompt-inspector",
"version": "2.2.2",
"version": "2.2.3",
"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.2.2"
version = "2.2.3"
# 与 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" }
+100
View File
@@ -0,0 +1,100 @@
"""AI 规则预设:一套规则一个文件,按后端分目录,方便单独备份、拷贝、给别人。
data/rule_presets/openai/方案A.json
data/rule_presets/ollama/本地 Qwen.json
每个 json 里是四条规则:
{ "translation": "...", "translate_optimize": "...", "optimization": "...", "natural": "..." }
刻意不塞进配置文件的单个 json:一套规则出问题时只影响它自己,也能直接拿去比对/分享。
"""
import json
import os
import tempfile
from pathlib import Path
MAX_NAME_CHARS = 60
MAX_RULE_CHARS = 20000
RULE_KINDS = ("translation", "translate_optimize", "optimization", "natural")
BACKENDS = ("openai", "ollama")
_ILLEGAL_NAME_CHARS = set('\\/:*?"<>|')
def _default_preset_dir():
return Path(__file__).resolve().parent / "data" / "rule_presets"
def safe_preset_name(name):
cleaned = "".join("_" if ch in _ILLEGAL_NAME_CHARS or ord(ch) < 32 else ch for ch in str(name or ""))
cleaned = cleaned.strip(" .")[:MAX_NAME_CHARS]
return cleaned or ""
class RulePresetStore:
def __init__(self, store_dir=None):
self.store_dir = Path(store_dir or _default_preset_dir())
def _backend_dir(self, backend):
name = backend if backend in BACKENDS else BACKENDS[0]
return self.store_dir / name
def list_presets(self, backend):
"""列出某个后端下的全部预设;坏文件跳过,不让一条坏数据挡住整张列表。"""
directory = self._backend_dir(backend)
try:
files = sorted(directory.glob("*.json"), key=lambda path: path.stem.casefold())
except OSError:
return []
presets = []
for path in files:
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
continue
if not isinstance(payload, dict):
continue
rules = {}
for kind in RULE_KINDS:
value = payload.get(kind)
rules[kind] = value[:MAX_RULE_CHARS] if isinstance(value, str) else ""
presets.append({"name": path.stem, "rules": rules})
return presets
def save_preset(self, backend, name, rules):
cleaned = safe_preset_name(name)
if not cleaned:
raise ValueError("预设名称不能为空")
if not isinstance(rules, dict):
raise ValueError("规则格式不对")
payload = {}
for kind in RULE_KINDS:
value = rules.get(kind)
payload[kind] = str(value)[:MAX_RULE_CHARS] if value is not None else ""
directory = self._backend_dir(backend)
try:
directory.mkdir(parents=True, exist_ok=True)
except OSError as error:
raise ValueError(f"无法创建预设目录:{error}") from error
target = directory / f"{cleaned}.json"
fd, temp_name = tempfile.mkstemp(prefix="preset_", suffix=".json", dir=directory)
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
json.dump(payload, handle, ensure_ascii=False, indent=2)
handle.write("\n")
os.replace(temp_name, target)
finally:
if os.path.exists(temp_name):
os.unlink(temp_name)
return {"name": cleaned, "rules": payload}
def delete_preset(self, backend, name):
cleaned = safe_preset_name(name)
if not cleaned:
raise ValueError("预设名称不能为空")
target = self._backend_dir(backend) / f"{cleaned}.json"
if not target.is_file():
raise FileNotFoundError(cleaned)
target.unlink()
return {"deleted": cleaned}
+171
View File
@@ -40,6 +40,8 @@ from .saved_prompt_store import (
MAX_IMAGE_BYTES,
SavedPromptStore,
)
from .library_store import LibraryStore
from .rule_preset_store import RulePresetStore
from .token_color_store import TokenColorStore
@@ -779,6 +781,8 @@ async def cancel_upstream(request):
# ---------------------------------------------------------------------------
saved_prompt_store = SavedPromptStore()
library_store = LibraryStore()
rule_preset_store = RulePresetStore()
# multipart 正文 = 文本字段 + 一张可选图片(图片本身最大 5 MB)
_MAX_SAVED_PROMPT_BYTES = MAX_IMAGE_BYTES + 512 * 1024
@@ -864,6 +868,173 @@ async def saved_prompt_image(request):
return web.FileResponse(path, headers={"Cache-Control": "private, max-age=3600"})
# ---------------------------------------------------------------------------
# 收藏库(树结构):一个条目一个目录,目录名 = 名称,缩略图与目录同名。
# 顺序与父子关系由 library/index.json 当缓存,打开面板不扫目录。
# ---------------------------------------------------------------------------
_LIBRARY_MAX_BYTES = MAX_IMAGE_BYTES + 512 * 1024
async def _read_library_form(request):
form = {"kind": None, "name": None, "text": None, "note": None, "parent": None,
"image_bytes": None, "remove_image": False, "models": [], "color": None}
reader = await request.multipart()
async for part in reader:
if part.name == "image":
form["image_bytes"] = await part.read(decode=False)
elif part.name == "kind":
form["kind"] = await part.text()
elif part.name == "name":
form["name"] = await part.text()
elif part.name == "text":
form["text"] = await part.text()
elif part.name == "note":
form["note"] = await part.text()
elif part.name == "parent":
value = (await part.text()).strip()
form["parent"] = value or None
elif part.name == "remove_image":
form["remove_image"] = (await part.text()).strip() == "1"
elif part.name == "color":
form["color"] = await part.text()
elif part.name == "models":
form["models"].append(await part.text())
return form
@protected_route("get", "/bpi/library")
async def list_library(_request):
return web.json_response({"success": True, "data": library_store.tree()})
@protected_route("post", "/bpi/library")
async def create_library_item(request):
if request.content_length is not None and request.content_length > _LIBRARY_MAX_BYTES:
return error_response("收藏内容过大(图片最大 5 MB)", 413)
try:
form = await _read_library_form(request)
except (ValueError, web.HTTPException) as error:
return error_response(f"读取表单失败:{error}")
try:
item = library_store.create(
form["kind"], form["name"], text=form["text"], note=form["note"],
models=form["models"], image_bytes=form["image_bytes"], parent=form["parent"],
color=form["color"],
)
return web.json_response({"success": True, "data": item})
except ValueError as error:
return error_response(error)
# 放在 {item_id} 之前注册,否则 "rebuild" 会被当成条目 id
@protected_route("post", "/bpi/library/rebuild")
async def rebuild_library(_request):
return web.json_response({"success": True, "data": {"items": library_store.rebuild()}})
@protected_route("post", "/bpi/library/import-legacy")
async def import_legacy_library(_request):
result = library_store.import_legacy(saved_prompt_store.list_prompts(), saved_prompt_store.store_dir)
return web.json_response({"success": True, "data": result})
@protected_route("post", "/bpi/library/{item_id}")
async def update_library_item(request):
if request.content_length is not None and request.content_length > _LIBRARY_MAX_BYTES:
return error_response("收藏内容过大(图片最大 5 MB)", 413)
try:
form = await _read_library_form(request)
except (ValueError, web.HTTPException) as error:
return error_response(f"读取表单失败:{error}")
try:
item = library_store.update(
request.match_info["item_id"], name=form["name"], text=form["text"],
note=form["note"], models=form["models"], image_bytes=form["image_bytes"],
remove_image=form["remove_image"], color=form["color"],
)
return web.json_response({"success": True, "data": item})
except KeyError:
return error_response("条目不存在", 404)
except ValueError as error:
return error_response(error)
@protected_route("delete", "/bpi/library/{item_id}")
async def delete_library_item(request):
try:
remaining = library_store.delete(request.match_info["item_id"])
return web.json_response({"success": True, "data": {"remaining": remaining}})
except KeyError:
return error_response("条目不存在", 404)
@protected_route("post", "/bpi/library/{item_id}/move")
async def move_library_item(request):
try:
payload = await request.json()
except (ValueError, web.HTTPException):
return error_response("请求体必须是 JSON")
try:
item = library_store.move(
request.match_info["item_id"],
parent=(payload.get("parent") or None),
index=payload.get("index"),
)
return web.json_response({"success": True, "data": item})
except KeyError:
return error_response("条目不存在", 404)
except ValueError as error:
return error_response(error)
# 图片用 <img> 加载,自定义请求头带不进去,所以只做同源检查、不走会话令牌。
@PromptServer.instance.routes.get("/bpi/library/{item_id}/image")
async def library_item_image(request):
if not _same_origin(request):
return error_response("拒绝跨源请求", 403)
try:
path = library_store.image_path(request.match_info["item_id"])
except (KeyError, ValueError):
return error_response("配图不存在", 404)
return web.FileResponse(path, headers={"Cache-Control": "private, max-age=3600"})
# ---------------------------------------------------------------------------
# AI 规则预设:一套规则一个 json 文件,按后端分目录(data/rule_presets/<backend>/<name>.json)
# ---------------------------------------------------------------------------
@protected_route("get", "/bpi/rule-presets")
async def list_rule_presets(request):
backend = request.query.get("backend", "openai")
return web.json_response({"success": True, "data": rule_preset_store.list_presets(backend)})
@protected_route("post", "/bpi/rule-presets")
async def save_rule_preset(request):
try:
payload = await request.json()
except (ValueError, web.HTTPException):
return error_response("请求体必须是 JSON")
try:
saved = rule_preset_store.save_preset(payload.get("backend"), payload.get("name"), payload.get("rules"))
return web.json_response({"success": True, "data": saved})
except ValueError as error:
return error_response(error)
@protected_route("delete", "/bpi/rule-presets/{name}")
async def delete_rule_preset(request):
backend = request.query.get("backend", "openai")
try:
result = rule_preset_store.delete_preset(backend, request.match_info["name"])
return web.json_response({"success": True, "data": result})
except FileNotFoundError:
return error_response("预设不存在", 404)
except ValueError as error:
return error_response(error)
@protected_route("get", "/bpi/saved-prompts/export")
async def export_saved_prompts(_request):
return web.json_response({"success": True, "data": saved_prompt_store.export_bundle()})
+1 -1
View File
@@ -88,7 +88,7 @@ for (const [source, expected] of [
["“Optimize” done; please confirm in the preview window", "「优化」完成;请在预览窗口确认"],
["Optimize", "优化"],
["Fit height", "适应高度"],
["Natural Language Rule", "自然语言规则"],
["Natural Language", "自然语言"],
["(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",
+119
View File
@@ -0,0 +1,119 @@
"""收藏库(library_store)的存储约定:目录名 = 名称、图片与目录同名、索引当缓存。"""
import base64
import shutil
import sys
import tempfile
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
from library_store import LibraryStore, safe_dir_name # noqa: E402
# 1x1 透明 PNG
PNG = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
)
class LibraryStoreTest(unittest.TestCase):
def setUp(self):
self.root = Path(tempfile.mkdtemp(prefix="bpi-lib-"))
self.store = LibraryStore(self.root)
def tearDown(self):
shutil.rmtree(self.root, ignore_errors=True)
def test_create_folder_and_prompt_on_disk(self):
folder = self.store.create("folder", "人物", note="角色类", models=["anima"])
prompt = self.store.create(
"prompt", "森林夜景", text="forest, night", parent=folder["id"], image_bytes=PNG
)
self.assertEqual(folder["dir"], "人物")
self.assertIsNone(folder["image"])
self.assertEqual(prompt["parent"], folder["id"])
self.assertEqual(prompt["image"], "森林夜景.png")
# 目录名与图片名一致,各自带一份 info.json
self.assertTrue((self.root / "森林夜景" / "森林夜景.png").is_file())
self.assertTrue((self.root / "森林夜景" / "info.json").is_file())
self.assertTrue((self.root / "人物" / "info.json").is_file())
self.assertTrue((self.root / "index.json").is_file())
def test_rename_moves_directory_and_image(self):
prompt = self.store.create("prompt", "森林夜景", text="x", image_bytes=PNG)
renamed = self.store.update(prompt["id"], name="雨夜街道")
self.assertEqual(renamed["dir"], "雨夜街道")
self.assertEqual(renamed["image"], "雨夜街道.png")
self.assertFalse((self.root / "森林夜景").exists())
self.assertTrue((self.root / "雨夜街道" / "雨夜街道.png").is_file())
def test_duplicate_names_get_suffix(self):
first = self.store.create("prompt", "同名", text="a")
second = self.store.create("prompt", "同名", text="b")
self.assertNotEqual(first["dir"], second["dir"])
self.assertTrue(second["dir"].startswith("同名 ("))
def test_move_reorders_within_folder(self):
folder = self.store.create("folder", "组")
a = self.store.create("prompt", "A", text="a", parent=folder["id"])
b = self.store.create("prompt", "B", text="b", parent=folder["id"])
self.store.move(b["id"], parent=folder["id"], index=0)
names = [item["name"] for item in self.store.tree()["items"] if item.get("parent") == folder["id"]]
self.assertEqual(names, ["B", "A"])
self.assertEqual(a["order"], 1)
def test_delete_folder_removes_children(self):
folder = self.store.create("folder", "组")
self.store.create("prompt", "A", text="a", parent=folder["id"])
remaining = self.store.delete(folder["id"])
self.assertEqual(remaining, 0)
self.assertFalse((self.root / "组").exists())
self.assertEqual(self.store.tree()["items"], [])
def test_rebuild_drops_image_when_name_mismatch(self):
prompt = self.store.create("prompt", "森林夜景", text="x", image_bytes=PNG)
image = self.root / "森林夜景" / "森林夜景.png"
image.rename(self.root / "森林夜景" / "别的名字.png")
items = self.store.rebuild()
target = next(item for item in items if item["id"] == prompt["id"])
self.assertIsNone(target["image"])
def test_tree_reads_cache_not_directory(self):
self.store.create("prompt", "A", text="a")
# 手写一个假条目进索引:如果不是读索引而是扫目录,它就会消失
payload = self.store._read_index_file() or {"version": 1, "items": []}
payload["items"].append({
"id": "fake", "type": "prompt", "name": "假条目", "dir": "假条目",
"parent": None, "order": 99, "note": None, "models": [], "text": "", "image": None, "created_at": 0,
})
self.store._write_index_file(payload["items"])
names = [item["name"] for item in self.store.tree()["items"]]
self.assertIn("假条目", names)
def test_folder_color_roundtrip(self):
folder = self.store.create("folder", "带色", color="#4ca7e8")
self.assertEqual(folder["color"], "#4ca7e8")
# 非法值当没设
self.store.update(folder["id"], color="red")
again = self.store.tree()["items"][0]
self.assertIsNone(again["color"])
# 重建索引后仍然读得回来
self.store.update(folder["id"], color="#5EC9A3")
items = self.store.rebuild()
self.assertEqual(items[0]["color"], "#5ec9a3")
def test_unsafe_names_are_sanitized(self):
self.assertEqual(safe_dir_name("a/b:c*d"), "a_b_c_d")
self.assertEqual(safe_dir_name(" "), "未命名")
self.assertEqual(safe_dir_name("CON"), "_CON")
if __name__ == "__main__":
unittest.main()
+14
View File
@@ -234,6 +234,20 @@ assert.equal(removePromptToken("a\nb\nc", parsePrompt("a\nb\nc", dictionary)[1])
// 删末行标签:结尾不留空行
assert.equal(removePromptToken("a\nb", parsePrompt("a\nb", dictionary)[1]).text, "a");
// 行尾带逗号的情况(真实提示词几乎都这样):换行是分段标记,不能跟着删
const commaLine = "a, b,\nc, d";
assert.equal(removePromptToken(commaLine, parsePrompt(commaLine, dictionary)[1]).text, "a\nc, d");
const commaLineSpaced = "a, b, \nc, d";
assert.equal(removePromptToken(commaLineSpaced, parsePrompt(commaLineSpaced, dictionary)[1]).text, "a\nc, d");
const realLine = "masterpiece, best quality,\n1girl, solo";
assert.equal(removePromptToken(realLine, parsePrompt(realLine, dictionary)[1]).text, "masterpiece\n1girl, solo");
// 整行只有这一个标签时,连换行一起删,不留空行
const soloLine = "b,\nc";
assert.equal(removePromptToken(soloLine, parsePrompt(soloLine, dictionary)[0]).text, "c");
// 拖到行尾(换行块前面):落在第一行末尾,换行跟着到标签后面
assert.equal(movePromptToken(wrapText, wrapTokens, term("d"), 2, { beforeBreak: true }).text, "a, b, d\nc");
// 拖到下一行开头:落在换行后面
+74
View File
@@ -0,0 +1,74 @@
"""AI 规则预设:一套规则一个 json,按后端分目录。"""
import json
import shutil
import sys
import tempfile
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
from rule_preset_store import RulePresetStore # noqa: E402
SAMPLE = {
"translation": "translate to chinese",
"translate_optimize": "translate and tidy",
"optimization": "optimize tags",
"natural": "write natural language",
}
class RulePresetStoreTest(unittest.TestCase):
def setUp(self):
self.root = Path(tempfile.mkdtemp(prefix="bpi-rules-"))
self.store = RulePresetStore(self.root)
def tearDown(self):
shutil.rmtree(self.root, ignore_errors=True)
def test_save_and_list_per_backend(self):
self.store.save_preset("openai", "方案A", SAMPLE)
self.store.save_preset("ollama", "本地Qwen", {**SAMPLE, "natural": "local"})
openai = self.store.list_presets("openai")
ollama = self.store.list_presets("ollama")
self.assertEqual([item["name"] for item in openai], ["方案A"])
self.assertEqual([item["name"] for item in ollama], ["本地Qwen"])
self.assertEqual(openai[0]["rules"]["translation"], "translate to chinese")
self.assertEqual(ollama[0]["rules"]["natural"], "local")
def test_each_preset_is_its_own_file(self):
self.store.save_preset("openai", "A", SAMPLE)
self.store.save_preset("openai", "B", SAMPLE)
files = sorted(path.name for path in (self.root / "openai").glob("*.json"))
self.assertEqual(files, ["A.json", "B.json"])
def test_same_name_overwrites(self):
self.store.save_preset("openai", "A", SAMPLE)
self.store.save_preset("openai", "A", {**SAMPLE, "translation": "new"})
presets = self.store.list_presets("openai")
self.assertEqual(len(presets), 1)
self.assertEqual(presets[0]["rules"]["translation"], "new")
def test_delete_removes_file(self):
self.store.save_preset("openai", "A", SAMPLE)
self.store.delete_preset("openai", "A")
self.assertEqual(self.store.list_presets("openai"), [])
def test_broken_file_is_skipped(self):
self.store.save_preset("openai", "good", SAMPLE)
(self.root / "openai" / "broken.json").write_text("{not json", encoding="utf-8")
names = [item["name"] for item in self.store.list_presets("openai")]
self.assertEqual(names, ["good"])
def test_name_sanitized_and_empty_rejected(self):
saved = self.store.save_preset("openai", "a/b:c", SAMPLE)
self.assertEqual(saved["name"], "a_b_c")
with self.assertRaises(ValueError):
self.store.save_preset("openai", " ", SAMPLE)
if __name__ == "__main__":
unittest.main()