Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8781cbe921 | ||
|
|
bfcb2ebc85 | ||
|
|
a1741c8d8a | ||
|
|
8e784f3e47 | ||
|
|
05800efb44 | ||
|
|
eb35d1ee86 |
@@ -5,6 +5,10 @@
|
||||
# assets/ —— README 里引用的截图与演示动图
|
||||
# locales/ —— 节点中文名/描述(ComfyUI 界面切中文时用到)
|
||||
|
||||
# 用户收藏库(个人提示词与参考图,不进发布包)
|
||||
data/saved/
|
||||
data/rule_presets/
|
||||
|
||||
# 测试
|
||||
tests/
|
||||
pnpm-lock.yaml
|
||||
|
||||
@@ -42,3 +42,9 @@ tag.sqlite
|
||||
|
||||
#userinfo
|
||||
bug.md
|
||||
|
||||
# 用户收藏库(个人提示词与参考图)
|
||||
data/saved/
|
||||
|
||||
# AI 规则预设(个人提示词,可能包含私有写法)
|
||||
data/rule_presets/
|
||||
|
||||
@@ -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)
|
||||
|
||||
### 快捷添加的反馈收干净了
|
||||
|
||||
@@ -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/` 下,插件不做任何上传。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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])) {
|
||||
// 标签正好在行尾、后面直接是换行:换行是分段标记,不能跟着删掉,
|
||||
|
||||
@@ -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
@@ -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
@@ -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" }
|
||||
|
||||
@@ -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}
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
@@ -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");
|
||||
// 拖到下一行开头:落在换行后面
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user