Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8781cbe921 | ||
|
|
bfcb2ebc85 | ||
|
|
a1741c8d8a | ||
|
|
8e784f3e47 | ||
|
|
05800efb44 | ||
|
|
eb35d1ee86 | ||
|
|
fbddc63fde | ||
|
|
2be6bd39ff | ||
|
|
b6faf77477 | ||
|
|
8d77815a02 | ||
|
|
2066b93b40 | ||
|
|
1e0cf30c7c |
@@ -5,6 +5,10 @@
|
||||
# assets/ —— README 里引用的截图与演示动图
|
||||
# locales/ —— 节点中文名/描述(ComfyUI 界面切中文时用到)
|
||||
|
||||
# 用户收藏库(个人提示词与参考图,不进发布包)
|
||||
data/saved/
|
||||
data/rule_presets/
|
||||
|
||||
# 测试
|
||||
tests/
|
||||
pnpm-lock.yaml
|
||||
@@ -31,3 +35,4 @@ data/packs/community_*.json
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.gitattributes
|
||||
bug.md
|
||||
|
||||
@@ -39,3 +39,12 @@ tag.sqlite
|
||||
/output/
|
||||
*.zip
|
||||
*.log
|
||||
|
||||
#userinfo
|
||||
bug.md
|
||||
|
||||
# 用户收藏库(个人提示词与参考图)
|
||||
data/saved/
|
||||
|
||||
# AI 规则预设(个人提示词,可能包含私有写法)
|
||||
data/rule_presets/
|
||||
|
||||
@@ -1,5 +1,73 @@
|
||||
# 更新记录
|
||||
|
||||
## 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);只有真的动过权重那个数字才写回。
|
||||
- 删掉行尾的标签时换行会保留,下一行不会被拽上来;标签独占一行被删掉后也不会空出一行。
|
||||
|
||||
### 删除动画改从右往左擦掉
|
||||
|
||||
- 删标签的动画最后一下改成从右往左擦(以前是从左往右)。
|
||||
- 点换行块上的 × 删掉换行,也走同一套动画和让位,不再是一下子消失。
|
||||
|
||||
### 拖标签跨行时落点终于对了
|
||||
|
||||
- 拖到一行的末尾就落在行尾,拖到下一行的开头就落在下一行开头,不再总是差一行。
|
||||
- 行首的标签拖到上一行的末尾会正确上移一行;行尾的标签拖到下一行开头会正确下移一行。
|
||||
- 明细表里的换行行去掉了那个没用的拖动手柄——它本来就拖不动。
|
||||
|
||||
### 系统开了「减少动态效果」就自动少动一点
|
||||
|
||||
- 系统里开了「减少动态效果」的,第一次进来会把拖动预览和标签增删动画都关掉,并在右上角说明一句、告诉你去哪重新打开。只做这一次:之后你手动打开就按你的意思来,不会再被覆盖。
|
||||
- 右上角弹窗和右下角提示条不受影响——它们本来就只是操作回执,关掉等于连结果都看不见了;入场动画在这种系统设置下已经自动压成瞬时。
|
||||
|
||||
### 翻译成功的提示改成中文
|
||||
|
||||
- 翻译成功后右下角那条从 `Translated "xxx"` 改成「已翻译「xxx」」,和其他提示统一。
|
||||
|
||||
## v2.2.1(2026-11-06)
|
||||
|
||||
### 修复:双击标签打不开编辑弹窗
|
||||
|
||||
- 2.2.0 里在节点英文标签区双击一张标签打不开编辑弹窗了,第二下只会被当成又一次单击,弹不出权重 / 颜色 / 编辑 / 词条信息那个窗口。
|
||||
- 原因:单击会立刻重渲染整个标签区,原来的卡片元素被换成新的,浏览器就不再把第二下算成「同一个东西被点两次」,双击事件根本不会触发。现在改由插件自己按「两次点击同一张卡、间隔够短」来判断双击,不再依赖元素身份。
|
||||
- 多选不受影响:已经在一组多选里的标签,双击仍保持整组选中,不会被打回单选。
|
||||
- 顺手修了 README 几处排版:demo 图前面误入的零宽空格和制表符(会让那行变成代码块、图片显示不出来)、一处粗体末尾多出的空格,以及「git安转」错字。
|
||||
|
||||
## v2.2.0(2026-11-05)
|
||||
|
||||
### 新增右下角提示条
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# ComfyUI 双语提示词管理器 v2.2.0
|
||||
# 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` 文件夹就是完整备份,粘到另一台机器的同一位置即可迁移。
|
||||
- **旧数据迁移**:从旧版本升级后第一次打开收藏,旧收藏会自动搬进新结构(只搬一次);旧数据原文不动。
|
||||
|
||||
> 「载入节点」会先弹窗确认再覆盖节点文本,这个动作**无法撤销**。
|
||||
|
||||
@@ -188,13 +190,13 @@ cd ComfyUI/custom_nodes/
|
||||
git clone --depth 1 https://github.com/mengshengzhijie/ComfyUI-Bilingual-Prompt-Inspector.git
|
||||
```
|
||||
|
||||
**如果网络受限也可以使用gitee或者使用神奇的 “-c” **
|
||||
**如果网络受限也可以使用 gitee,或者用上神奇的 `-c`**:
|
||||
|
||||
```bash
|
||||
git clone https://gitee.com/MengShengZhiJie/ComfyUI-Bilingual-Prompt-Inspector.git
|
||||
```
|
||||
|
||||
不过这里如果有动手能力的话推荐使用git安转,因为现在就我和我的朋友在使用,有时候修复bug和制作新功能我是总结下来然后一步一步的修复。直到把这次收录的bug和建议给完成之后才bump版本。
|
||||
不过这里如果有动手能力的话推荐使用 git 安装,因为现在就我和我的朋友在使用,有时候修复bug和制作新功能我是总结下来然后一步一步的修复。直到把这次收录的bug和建议给完成之后才bump版本。
|
||||
|
||||
重启 ComfyUI,在节点菜单的 **text/prompt** 分类里找到 **提示词翻译与管理(英文输出)**(英文界面下显示为 Prompt Translator & Manager),也可以直接搜索「提示词」。
|
||||
|
||||
@@ -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/`。
|
||||
|
||||
### 大型词库(可选)
|
||||
|
||||
@@ -255,6 +259,8 @@ git clone https://gitee.com/MengShengZhiJie/ComfyUI-Bilingual-Prompt-Inspector.g
|
||||
|
||||
折叠上方还有一项「点击节点时跟随切换」:点画布上的节点时,侧边栏「标签管理」是否跟着切过去。
|
||||
|
||||
系统开了「减少动态效果」的话,第一次进入会把上面这几个纯装饰性的动画(两个拖动预览、标签增删动画)自动关掉,并在右上角说明一句。只做这一次,之后你手动打开就按你的来;右上角弹窗和右下角提示条不受影响——它们只是操作回执,关掉等于连结果都看不见。
|
||||
|
||||
---
|
||||
|
||||
## 常见问题
|
||||
@@ -313,7 +319,7 @@ git clone https://gitee.com/MengShengZhiJie/ComfyUI-Bilingual-Prompt-Inspector.g
|
||||
- **默认不联网**:翻译服务的默认选项是纯词库,完全离线。
|
||||
- **只在你主动使用某个服务时才发请求**:调用百度或 AI 时,发送的是提示词文本与你的指令;密钥只存在后端本地,不回传浏览器、不写入工作流。
|
||||
- **大型词库按需加载**:不安装就不加载,装了也是本地文件查询。
|
||||
- **收藏只存在本机**:写在 ComfyUI 用户目录下,插件不做任何上传。
|
||||
- **收藏只存在本机**:写在插件目录的 `data/saved/` 下,插件不做任何上传。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -232,3 +232,23 @@ ComfyUI 原生 text 控件(工作流真值)
|
||||
- 前端依赖 ComfyUI DOM Widget/PrimeVue 文本控件结构,升级 ComfyUI 后应重点回归空节点、旧工作流、文本编辑和标签联动。
|
||||
- `panel_sync` 只解决同一页面多节点同步;跨标签页、跨浏览器和跨设备不是当前承诺。
|
||||
- 任何解析增强都要同时测试末尾逗号、权重、重复标签、自然语言多逗号和夹心结构。
|
||||
|
||||
## 9. 已知工程债
|
||||
|
||||
- **单文件过大(已拆六块 + 显式 store + render 函数化)**:`js/bilingual_prompt.js` 从 4546 行降到 3218 行。
|
||||
- `js/bpi_store.js`(129 行)—— 节点状态与偏好:`createStore({ node, textWidget })` 返回 `{ node, textWidget, state, hooks, storedMode, storedFillHeight }`。每个面板(每个节点)一份,多节点互不共享。主文件在 `renderNow` 之后调 `bindStoreHooks(store, {...})` 把 `render / updateText / setStatus / applyTokenWeight` 等填进 `hooks`;三个高度常量也在这里导出,供主文件与尺寸模块共用。
|
||||
- 功能模块统一收 `{ store }`(个别还需要一两个额外依赖),内部 `const { state, textWidget } = store` 取稳定引用,回调一律 `store.hooks.xxx()`(**不能提前解构**,bind 发生在后面)。
|
||||
- 已拆出的功能模块:
|
||||
| 文件 | 行数 | 职责 | 额外依赖 |
|
||||
|---|---|---|---|
|
||||
| `bpi_line_breaks.js` | 123 | 换行块数据层(gap 读写、插入/删除/移动)| — |
|
||||
| `bpi_editor_modal.js` | 365 | 标签编辑弹窗(权重与颜色 / 编辑原文 / 词条信息)| — |
|
||||
| `bpi_chip_drag.js` | 412 | 拖拽与实时预览(含明细表行)| `englishTokenView`、`clickGuard` |
|
||||
| `bpi_hidden.js` | 224 | 隐藏标签、「唯一」开关、输出管道(effective_text)| `buildTokenCard`(惰性)|
|
||||
| `bpi_shortcut.js` | 74 | 撤销/重做、顺序微调 | `setHiddenTags`、`setUniqueKeys` |
|
||||
| `bpi_node_size.js` | 208 | 节点尺寸与高度(自动高度、手动拉伸、适应高度,共 12 个函数)| `refs`(6 个 DOM)、`persistPreferences` |
|
||||
- 这样以后拆新块时参数表不再随功能膨胀,一个 `store` 就够;要新增能力就往 `hooks` 里加一项。
|
||||
- 剩下的候选:翻译批处理、卡片渲染、明细表、字典搜索(与 `render` 咬得紧,风险最高)。`render` 已把明细表与问题面板抽成同文件内的具名函数(306 → 57 行),不再作为候选;继续拆的前提是先把这些 DOM 收进 `store.refs`。实测主文件 0 个疑似死代码、连续 6 行重复只有 2 处,行数主要来自 172 次 `element()` 与 76 处 `addEventListener` —— 再搬模块的收益低于风险。
|
||||
- **UI 层没有自动化测试**:`tests/` 覆盖的是 `parser`(100+ 断言)和几个纯逻辑模块。拆模块后有 `tests/test_module_deps.mjs` 的静态体检(漏 import、漏传参、import→export 链接),能抓到浏览器模块链接失败这类 `node --check` 查不出的问题;DOM 初始化顺序、拖拽落点与节点高度仍只能手测。
|
||||
- **拖拽落点的历史坑**:`targetIndex` 表达不了「换行之前 / 之后」,必须配合 `beforeBreak` / `afterBreak` 两个标记;动这块之前先读 `js/parser.js` 里 `movePromptToken` 的注释,以及仓库根目录 `bug.md` 的复盘记录。
|
||||
- **动画与无障碍**:系统开了 `prefers-reduced-motion` 时,首次进入会把拖动预览与增删动画关掉(标记 `bpi.reducedMotion.v1`,只做一次);弹窗与提示条的入场动画由各自的 `@media` 规则压成瞬时,不靠开关。
|
||||
|
||||
+229
-1514
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,412 @@
|
||||
// 标签卡片 / 明细表行的拖拽:落点计算 + 实时预览(浮层跟手、其余项让位)。
|
||||
// 这里只算出「插到第几个 token」,文本改动一律交给 parser 的 movePromptToken。
|
||||
import { movePromptToken } from "./parser.js";
|
||||
import { dragPreviewNodeEnabled } from "./bpi_drag_preview.js";
|
||||
|
||||
export function createChipDrag({ store, englishTokenView, clickGuard }) {
|
||||
// 依赖统一从 store 取:state / textWidget 是稳定引用,回调走 store.hooks
|
||||
const { state } = store;
|
||||
const hooks = store.hooks;
|
||||
const bindDragTrackers = (onMove, finish) => {
|
||||
document.addEventListener("pointermove", onMove, true);
|
||||
document.addEventListener("pointerup", finish, true);
|
||||
document.addEventListener("pointercancel", finish, true);
|
||||
};
|
||||
const unbindDragTrackers = (onMove, finish) => {
|
||||
document.removeEventListener("pointermove", onMove, true);
|
||||
document.removeEventListener("pointerup", finish, true);
|
||||
document.removeEventListener("pointercancel", finish, true);
|
||||
};
|
||||
|
||||
// 拖动实时预览(设置里两个开关,默认关):把被拖的那一项复制成浮层跟着光标走,
|
||||
// 原位留一个等大的空位,其余标签用 FLIP 动画平移让位——手感接近手机桌面拖图标。
|
||||
// 全程只动 DOM,文本不动;松手才由 moveTokenTo 统一改文本,只留一条撤销记录。
|
||||
//
|
||||
// 落点 targetIndex 是 state.tokens 的下标(movePromptToken 的「先摘出来再插进去」语义),
|
||||
// 实际要插到哪个 DOM 元素前面由 data-token-id 反查;那一项被筛掉了就往后找最近的可见项。
|
||||
// 注意:一旦动了 DOM,它的顺序就不再等于 state.tokens,落点下标必须一直按拖动前
|
||||
// 那份快照算(这两个拖拽的实现都已经在 pointerdown 时把候选列表存好了)。
|
||||
// options.raise:默认抬起(浮层跟光标走,原位留空位);明细表传 false —— 不要抬起效果,
|
||||
// 行自己留在原位,只在换位时跟着大家一起滑过去(像列表重排,不像手机挪图标)。
|
||||
// FLIP_MS:让位动画时长,同时充当落点更新的节流窗口(动画播完才判下一次)。
|
||||
// 拖着划过一长串标签时,落点会在相邻两个槽位之间反复抖动;窗口太短就会
|
||||
// 不断打断重启动画,看上去就是疯狂左右闪。
|
||||
const FLIP_MS = 160;
|
||||
// 落点滞回:指针从上一次生效的位置挪出这么多像素才重新判定
|
||||
const MOVE_EPSILON = 10;
|
||||
const createDragPreview = (dragged, candidates, enabled, options = {}) => {
|
||||
const homeParent = dragged.parentNode;
|
||||
const homeNext = dragged.nextSibling;
|
||||
const tracked = candidates.slice();
|
||||
if (!tracked.includes(dragged)) tracked.push(dragged);
|
||||
const byTokenId = new Map();
|
||||
for (const item of candidates) {
|
||||
const id = Number(item.dataset?.tokenId);
|
||||
if (Number.isFinite(id)) byTokenId.set(id, item);
|
||||
}
|
||||
const candidateSet = new Set(candidates);
|
||||
// 下一个「同类」兄弟:中间的分隔符、换行块、落点提示线一律跳过
|
||||
const nextCandidate = (from) => {
|
||||
let node = from?.nextElementSibling ?? null;
|
||||
while (node && !candidateSet.has(node)) node = node.nextElementSibling;
|
||||
return node;
|
||||
};
|
||||
// FLIP:改 DOM 前先量一遍「看起来的位置」,改完把每个动过的元素用 transform
|
||||
// 拉回旧位,下一帧把 transform 清成空,浏览器自己去补这段位移 —— 平滑滑过去。
|
||||
// 量「改完之后的位置」之前必须先清掉上一次动画留下的 transform —— 否则量到的是
|
||||
// 动画中途的坐标,算出来的补偿位移是错的,下一次判定又会往回翻,来回振荡 = 疯狂闪。
|
||||
let animating = false;
|
||||
// 让位动画还没落地时攒着的落点,落地那一刻接着挪:最后一次移动不会被丢掉
|
||||
let wanted = null;
|
||||
let unlockTimer = null;
|
||||
// 指针位置:每次 track 更新,run 真正执行时才拿它算落点
|
||||
let pendingX = 0;
|
||||
let pendingY = 0;
|
||||
let appliedX = null;
|
||||
let appliedY = null;
|
||||
// 这次落点是不是「上一行末尾(换行块前面)」:拖拽层算好传进来,预览层不自己猜
|
||||
let pendingBreak = false;
|
||||
// 调试用(localStorage bpi.debug=1):落点每次变化打一行,方便看它到底算到哪张卡
|
||||
let debugLastKey = "";
|
||||
const flip = (mutate) => {
|
||||
// 被拖的那张(透明空位)不参与 FLIP:它要「啪」地挪到落点,
|
||||
// 只有旁边的元素需要平滑让开。要是连空位一起做过渡,眼睛就会看到
|
||||
// 原位还留着一块、落点又多出一块,像是「原位凭空挤出一个空间」。
|
||||
const before = tracked
|
||||
.filter((el) => el !== dragged)
|
||||
.map((el) => [el, el.getBoundingClientRect()]);
|
||||
mutate();
|
||||
const shifted = [];
|
||||
for (const [el, first] of before) {
|
||||
if (!el.isConnected) continue;
|
||||
el.style.transition = "none";
|
||||
el.style.transform = "";
|
||||
const last = el.getBoundingClientRect();
|
||||
const dx = first.left - last.left;
|
||||
const dy = first.top - last.top;
|
||||
if (!dx && !dy) {
|
||||
// 它压根没动:临时关掉的 transition 要还回去,否则后面就不再有动画了
|
||||
el.style.transition = "";
|
||||
continue;
|
||||
}
|
||||
el.style.transform = `translate(${dx}px,${dy}px)`;
|
||||
shifted.push(el);
|
||||
}
|
||||
if (!shifted.length) return;
|
||||
void dragged.offsetWidth;
|
||||
for (const el of shifted) el.style.transition = `transform ${FLIP_MS}ms cubic-bezier(.2,.8,.2,1)`;
|
||||
animating = true;
|
||||
requestAnimationFrame(() => {
|
||||
for (const el of shifted) el.style.transform = "";
|
||||
// 关键:等让位动画真正播完再解锁。早于一帧解除的话,动画还在半路,
|
||||
// 下一次 pointermove 量到的是中间坐标 → 落点算错 → 又挪回去 → 来回闪
|
||||
if (unlockTimer) clearTimeout(unlockTimer);
|
||||
unlockTimer = setTimeout(() => {
|
||||
unlockTimer = null;
|
||||
animating = false;
|
||||
// 动画期间攒下的新落点,这一刻再挪
|
||||
run();
|
||||
}, FLIP_MS);
|
||||
});
|
||||
};
|
||||
// 让位:把攒着的落点真正落到 DOM 上,其余标签用 FLIP 滑过去
|
||||
const run = (force = false) => {
|
||||
if (wanted === null || !dragged.isConnected) return;
|
||||
// 让位动画还在半路就只攒着:这时候 getBoundingClientRect / elementFromPoint
|
||||
// 拿到的都是「半路坐标」,拿它判定会立刻往回翻,来回振荡 = 疯狂闪。
|
||||
// force 是松手那一下的强制收口,此时必须把最后一次落点落实。
|
||||
if (animating && !force) return;
|
||||
// 指针停在两个槽位的分界线上时,一两像素的抖动也会让落点反复横跳,
|
||||
// 所以要求它从上一次生效的位置挪出 MOVE_EPSILON 才重新判定(滞回)。
|
||||
if (!force && appliedX !== null
|
||||
&& Math.hypot(pendingX - appliedX, pendingY - appliedY) < MOVE_EPSILON) return;
|
||||
appliedX = pendingX;
|
||||
appliedY = pendingY;
|
||||
// 落点在这里才算:此刻让位动画已播完,量到的是稳定的最终排版位置,
|
||||
// 不会像「边挪边量」那样读到半路坐标、判定完立刻翻回去
|
||||
const targetIndex = typeof wanted === "function" ? wanted() : wanted;
|
||||
wanted = null;
|
||||
let ref = null;
|
||||
for (let i = targetIndex; i < state.tokens.length && !ref; i += 1) {
|
||||
const found = byTokenId.get(state.tokens[i].id);
|
||||
if (found && found !== dragged) ref = found;
|
||||
}
|
||||
if (window.localStorage.getItem("bpi.debug") === "1") {
|
||||
const key = `${targetIndex}|${ref?.textContent ?? "null"}`;
|
||||
if (key !== debugLastKey) {
|
||||
debugLastKey = key;
|
||||
console.log("[bpi] run", {
|
||||
targetIndex,
|
||||
ref: ref?.textContent?.slice(0, 24) ?? null,
|
||||
dragged: dragged.textContent?.slice(0, 24),
|
||||
});
|
||||
}
|
||||
}
|
||||
// 落点其实是「上一行末尾」时,浮层要停到那一行的 ↵ 前面:
|
||||
// 每行是独立的 .bpi-token-line,↵ 挂在上一行末尾,不是下一张卡的兄弟
|
||||
if (ref && pendingBreak) {
|
||||
const prevLine = ref.parentElement?.previousElementSibling;
|
||||
const prevLast = prevLine?.lastElementChild;
|
||||
if (prevLast?.classList?.contains("bpi-break-chip")) ref = prevLast;
|
||||
}
|
||||
const tail = candidates[candidates.length - 1];
|
||||
const parent = ref ? ref.parentNode : tail?.parentNode;
|
||||
if (!parent?.isConnected) return;
|
||||
// 幂等检查:已经在目标位置就别再挪。这条同时也是防抖的关键,
|
||||
// 少了它相邻两个槽位会来回跳。
|
||||
// 注意别再补「落点等于原位就 return」那种判断——DOM 可能已经被前面几次
|
||||
// 移动挪走了,这时恰恰需要真的移回原位,预览才会挤开。
|
||||
if (dragged.parentNode === parent
|
||||
&& (dragged.nextElementSibling === ref || nextCandidate(dragged) === ref)) return;
|
||||
flip(() => parent.insertBefore(dragged, ref));
|
||||
};
|
||||
|
||||
let clone = null;
|
||||
let grabX = 0;
|
||||
let grabY = 0;
|
||||
let baseLeft = 0;
|
||||
let baseTop = 0;
|
||||
// 抬起:复制一份当浮层跟着光标走,原元素留在原位当空位(透明但仍参与命中测试)。
|
||||
// options.raise===false(明细表):根本不复制 —— 行自己在原地,换位置的时候跟着
|
||||
// 大家一起滑过去就行,不需要「拿起来」这回事。
|
||||
const lift = (pointerX, pointerY) => {
|
||||
if (!enabled || options.raise === false || clone) return;
|
||||
const rect = dragged.getBoundingClientRect();
|
||||
if (!rect.width || !rect.height) return;
|
||||
grabX = pointerX - rect.left;
|
||||
grabY = pointerY - rect.top;
|
||||
baseLeft = rect.left;
|
||||
baseTop = rect.top;
|
||||
clone = dragged.cloneNode(true);
|
||||
clone.classList.add("bpi-drag-float");
|
||||
clone.removeAttribute("id");
|
||||
// 拖起来的这份要看着就跟原物一模一样:原元素身上的「拖拽中」半透明状态(.bpi-dragging)
|
||||
// 不能带到浮层上,否则整块发灰,像隔着一层毛玻璃
|
||||
clone.classList.remove("bpi-dragging");
|
||||
clone.style.position = "fixed";
|
||||
clone.style.left = `${rect.left}px`;
|
||||
clone.style.top = `${rect.top}px`;
|
||||
// 必须按 border-box 算:行/卡片自带 padding 和边框,rect 是含这些的总尺寸,
|
||||
// 用默认的 content-box 会把浮层撑大一圈,看着像被放大了
|
||||
clone.style.boxSizing = "border-box";
|
||||
clone.style.width = `${rect.width}px`;
|
||||
clone.style.height = `${rect.height}px`;
|
||||
clone.style.margin = "0";
|
||||
clone.style.zIndex = "10050";
|
||||
clone.style.pointerEvents = "none";
|
||||
document.body.appendChild(clone);
|
||||
dragged.classList.add("bpi-drag-source");
|
||||
follow(pointerX, pointerY);
|
||||
};
|
||||
// 节点里:浮层跟着光标二维移动(抬起感)。不放大的,跟原物一样大,
|
||||
// 抬起感全靠 .bpi-drag-float 的投影;明细表传了 raise:false,这一步直接空转
|
||||
const follow = (pointerX, pointerY) => {
|
||||
if (!clone) return;
|
||||
clone.style.transform = `translate3d(${pointerX - grabX - baseLeft}px,${pointerY - grabY - baseTop}px,0)`;
|
||||
};
|
||||
// 只清 FLIP 留在被跟踪元素上的内联样式(transition / transform)
|
||||
const clearInline = () => {
|
||||
for (const el of tracked) {
|
||||
el.style.transition = "";
|
||||
el.style.transform = "";
|
||||
}
|
||||
};
|
||||
return {
|
||||
lift,
|
||||
// 跟手 + 让位:让位动画播到一半时只攒着落点,等落地那刻再判。
|
||||
// 这样最后一次移动不会被丢,也不会因为读到半路坐标而来回翻
|
||||
track(pointerX, pointerY, resolveIndex, beforeBreak = false) {
|
||||
if (!enabled) return;
|
||||
follow(pointerX, pointerY);
|
||||
pendingX = pointerX;
|
||||
pendingY = pointerY;
|
||||
pendingBreak = Boolean(beforeBreak);
|
||||
wanted = resolveIndex;
|
||||
run();
|
||||
},
|
||||
// 松手前收口:把攒着的那一步**强制**补上(不受让位动画节流限制),
|
||||
// 让「眼前的顺序」正好等于「马上要写进文本的顺序」,再清掉动画残留
|
||||
// (内联 transition / transform)。少了这一步,松手后排队中的重渲染
|
||||
// 会把「拖动前的旧顺序」画出来一帧 —— 那正是闪回起点
|
||||
settle() {
|
||||
run(true);
|
||||
clearInline();
|
||||
},
|
||||
// 浮层落地:对准传进来的落点(松手前量好的最终位置)飞过去,落地那一刻才消失。
|
||||
// 期间真实那一张一直是透明的空位,所以中间不会看到两张。
|
||||
// 落点量不到就直接删掉浮层 —— 无论如何不留残影
|
||||
land() {
|
||||
const dying = clone;
|
||||
clone = null;
|
||||
clearInline();
|
||||
// 排队中的重渲染先撤掉:它会马上重建 DOM,把落点上那一张提前显示出来(重影),
|
||||
// 等浮层落地再补刷一次
|
||||
const pending = Boolean(state.renderTimer);
|
||||
if (pending) {
|
||||
clearTimeout(state.renderTimer);
|
||||
state.renderTimer = null;
|
||||
}
|
||||
const reveal = () => {
|
||||
dragged.classList.remove("bpi-drag-source");
|
||||
if (pending) hooks.render();
|
||||
};
|
||||
if (!dying) {
|
||||
reveal();
|
||||
return;
|
||||
}
|
||||
// 上面 clearInline() 已经把让位动画的 transform 清掉了,
|
||||
// 所以此刻量到的就是真正的排版位置 —— 浮层该去的落点
|
||||
const rect = dragged.isConnected ? dragged.getBoundingClientRect() : null;
|
||||
if (rect && (rect.width || rect.height)) {
|
||||
dying.style.transition = "transform .18s cubic-bezier(.2,.8,.2,1)";
|
||||
dying.style.transform = `translate3d(${rect.left - baseLeft}px,${rect.top - baseTop}px,0) scale(1)`;
|
||||
window.setTimeout(() => {
|
||||
dying.remove();
|
||||
reveal();
|
||||
}, 200);
|
||||
return;
|
||||
}
|
||||
dying.remove();
|
||||
reveal();
|
||||
},
|
||||
// 放回原位:移动没生效时(原地放下 / 目标被筛掉了)把预览挪出来的错位收回去
|
||||
restore() {
|
||||
if (!dragged.isConnected || !homeParent?.isConnected) return;
|
||||
// 原来的下一个节点可能已经被重渲染换掉了,那就直接放到父节点末尾
|
||||
const before = homeNext && homeNext.parentNode === homeParent ? homeNext : null;
|
||||
if (dragged.parentNode === homeParent && dragged.nextSibling === before) return;
|
||||
homeParent.insertBefore(dragged, before);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
// Drag-to-reorder on the English token chips: pointerdown arms a possible
|
||||
// drag, moving past a small threshold activates it (so plain clicks and
|
||||
// double-clicks keep working), and releasing applies a structured move
|
||||
// through the shared undo stack.
|
||||
// view / selector 让英文标签区与中文对照区可以共用这套拖拽逻辑:
|
||||
// 两处 chip 一一对应同一批 token,拖动任何一个都是重排同一份文本。
|
||||
// selector 必须指到整张卡片(.bpi-token-card)而不是里面的英文行:卡片上
|
||||
// 半是英文、下半是中文小块,只认英文行的话,指针落在中文小块上就判定不到
|
||||
// 目标,插入提示不出现、落点还会被几何兜底算到列表末尾去。
|
||||
const attachChipDrag = (chip, token, view = englishTokenView, selector = ".bpi-token-card") => {
|
||||
chip.addEventListener("pointerdown", (event) => {
|
||||
if (event.button !== 0 || !hooks.canReorderTokens()) return;
|
||||
const startX = event.clientX;
|
||||
const startY = event.clientY;
|
||||
// 实时预览打开后卡片会被挪走,DOM 顺序就不等于 token 顺序了;
|
||||
// 落点下标一律按拖动前这份顺序算(关掉预览时两者本来就是一样的)
|
||||
const homeList = [...view.querySelectorAll(selector)];
|
||||
const previewEnabled = dragPreviewNodeEnabled();
|
||||
const preview = createDragPreview(chip, homeList, previewEnabled);
|
||||
let active = false;
|
||||
let targetIndex = null;
|
||||
// 落点是不是「上一行末尾」(换行块前面):被拖的标签本身就在行首时
|
||||
// token 顺序不会变,得靠这个标记把前面的换行挪到它后面
|
||||
let beforeBreak = false;
|
||||
let hoverChip = null;
|
||||
const clearHover = () => {
|
||||
if (hoverChip) hoverChip.classList.remove("bpi-drop-before", "bpi-drop-after");
|
||||
hoverChip = null;
|
||||
};
|
||||
const onMove = (moveEvent) => {
|
||||
if (!chip.isConnected) {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
if (!active) {
|
||||
// 阈值调高一点:手抖 / 点着不动不会误触发拖动(顺带也让「让位」
|
||||
// 不会在起手那一瞬间来回跳)
|
||||
if (Math.abs(moveEvent.clientX - startX) < 7 && Math.abs(moveEvent.clientY - startY) < 7) return;
|
||||
active = true;
|
||||
clickGuard.suppressed = true;
|
||||
chip.classList.add("bpi-dragging");
|
||||
preview.lift(moveEvent.clientX, moveEvent.clientY);
|
||||
}
|
||||
moveEvent.preventDefault();
|
||||
clearHover();
|
||||
targetIndex = null;
|
||||
const hit = document.elementFromPoint(moveEvent.clientX, moveEvent.clientY);
|
||||
const hitChip = hit?.closest?.(selector);
|
||||
// 拖拽元素现在可能是整张卡片:命中它内部的 chip 也算「拖到自己身上」,跳过
|
||||
const onSelf = Boolean(hitChip) && (hitChip === chip || chip.contains(hitChip));
|
||||
// 界面上真正排在后面的那张(跳过被拖的这张——它就是要插进来的那个)
|
||||
const live = [...view.querySelectorAll(selector)];
|
||||
const nextVisible = (from) => {
|
||||
const start = live.indexOf(from);
|
||||
for (let i = start + 1; i < live.length; i += 1) {
|
||||
if (live[i] !== chip) return live[i];
|
||||
}
|
||||
return null;
|
||||
};
|
||||
// 落点一律换算成「插到哪张卡前面」,再变回 state.tokens 的下标;
|
||||
// 这样即使预览已经把卡片挪过的位置串了,结果也跟 movePromptToken 一致
|
||||
const resolve = (anchor) => {
|
||||
const index = anchor ? homeList.indexOf(anchor) : -1;
|
||||
return index >= 0 ? index : state.tokens.length;
|
||||
};
|
||||
// 这张卡是不是「某行第一个、上一行以换行块结尾、而且指针确实在上一行的高度里」。
|
||||
// 最后那条不能少:光看结构的话,在同一行里往左拖一点、落点正好是行首那张卡,
|
||||
// 就会被当成要跨行,浮层一下子飞到上一行的回车前面去
|
||||
const landsBeforeBreak = (card, clientY) => {
|
||||
if (!card) return false;
|
||||
const prevLine = card.parentElement?.previousElementSibling;
|
||||
if (!prevLine?.lastElementChild?.classList?.contains("bpi-break-chip")) return false;
|
||||
// 用上一行容器的矩形来判:卡片自己带着 FLIP 的 transform,量出来不准
|
||||
const box = prevLine.getBoundingClientRect();
|
||||
return clientY >= box.top && clientY <= box.bottom;
|
||||
};
|
||||
if (hitChip && !onSelf) {
|
||||
const rect = hitChip.getBoundingClientRect();
|
||||
const before = moveEvent.clientX < rect.left + rect.width / 2;
|
||||
const anchorCard = before ? hitChip : nextVisible(hitChip);
|
||||
targetIndex = resolve(anchorCard);
|
||||
beforeBreak = !before && landsBeforeBreak(anchorCard, moveEvent.clientY);
|
||||
hoverChip = hitChip;
|
||||
hoverChip.classList.add(before ? "bpi-drop-before" : "bpi-drop-after");
|
||||
} else {
|
||||
// 空白处、或光标停在自己浮起来的那份身上:按当前画出来的顺序做几何兜底。
|
||||
// 少了后半句,卡片一旦挪到指针下面就再也算不出新落点,拖着就不让位了
|
||||
let lastCard = null;
|
||||
for (const other of live) {
|
||||
if (other === chip) continue;
|
||||
const rect = other.getBoundingClientRect();
|
||||
if (rect.bottom <= moveEvent.clientY) lastCard = other;
|
||||
else if (moveEvent.clientY >= rect.top && moveEvent.clientX >= rect.right) lastCard = other;
|
||||
}
|
||||
const anchor = lastCard ? nextVisible(lastCard) : live.find((item) => item !== chip) || null;
|
||||
targetIndex = resolve(anchor);
|
||||
beforeBreak = landsBeforeBreak(anchor, moveEvent.clientY);
|
||||
}
|
||||
preview.track(moveEvent.clientX, moveEvent.clientY, targetIndex, beforeBreak);
|
||||
};
|
||||
const finish = () => {
|
||||
unbindDragTrackers(onMove, finish);
|
||||
chip.classList.remove("bpi-dragging");
|
||||
clearHover();
|
||||
if (active) {
|
||||
setTimeout(() => { clickGuard.suppressed = false; }, 0);
|
||||
// 先收口:把攒着的那一步落到 DOM 上,让「眼前的顺序」正好等于
|
||||
// 「马上要写进文本的顺序」,再清掉让位动画残留
|
||||
preview.settle();
|
||||
// 落点正好是自己后面那一格、而自己后面就是换行:这是「下移一行」,
|
||||
// token 顺序没变,得让 parser 把换行挪到前面
|
||||
const afterBreak = !beforeBreak && targetIndex === token.id + 1
|
||||
&& token.id + 1 < state.tokens.length
|
||||
&& hooks.gapHasBreak(token.id);
|
||||
const committed = targetIndex !== null
|
||||
? hooks.moveTokenTo(token, targetIndex, { beforeBreak, afterBreak })
|
||||
: false;
|
||||
// 没搬动(原地放下 / 目标被筛掉了):把预览挪出来的错位收回去
|
||||
if (!committed) preview.restore();
|
||||
// 有浮层时 land() 会等它落地再刷 DOM(提前刷会把落点上那一张显示出来,看着是重影)
|
||||
preview.land();
|
||||
if (committed) requestAnimationFrame(() => view.focus({ preventScroll: true }));
|
||||
}
|
||||
};
|
||||
bindDragTrackers(onMove, finish);
|
||||
});
|
||||
};
|
||||
|
||||
return { attachChipDrag, createDragPreview, bindDragTrackers, unbindDragTrackers };
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// 删除标签时的消失动画:X 从四角向心长出 → 颜色从中心铺满整块 → 上下压两条灰边 → 整块从左往右擦掉。
|
||||
// 删除标签时的消失动画:X 从四角向心长出 → 颜色从中心铺满整块 → 上下压两条灰边 → 整块从右往左擦掉。
|
||||
// 关掉就是原来的样子:卡片直接消失。这个开关同时管添加动画(见 bpi_insert_anim.js),
|
||||
// 设置里显示为「标签增删动画(节点标签区)」,默认关。
|
||||
const KEY = "bpi.deleteAnim.node.v1";
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
// 标签编辑弹窗:权重与颜色 / 编辑原文 / 词条信息三页。
|
||||
// 只负责 DOM 与交互,改文本、存词条都通过调用方传进来的钩子做。
|
||||
import { buildTagFields, button, element, saveTag, setPlaceholder, setText, setTitle, t } from "./bpi_shared.js";
|
||||
import { replacePromptSpan, UNKNOWN_CHINESE } from "./parser.js";
|
||||
import { showToast } from "./bpi_toast.js";
|
||||
|
||||
export function createWeightEditor({ store }) {
|
||||
// 依赖统一从 store 取:state / textWidget 是稳定引用,回调走 store.hooks
|
||||
const { state, textWidget } = store;
|
||||
const hooks = store.hooks;
|
||||
const openWeightEditor = (token) => {
|
||||
if (!token) return;
|
||||
// 自然语言片段(以及 BREAK、<lora:…> 这类特殊语法)没有独立权重、也没有词条,
|
||||
// 所以这两个页签按 token 形态收起来,但弹窗照开:配色和整段编辑对它们一样有用
|
||||
const isPlainTag = token.syntax === "tag" && token.segmentKind !== "natural";
|
||||
const canWeight = isPlainTag && ["tags", "mixed"].includes(state.modeInfo.mode);
|
||||
const shade = element("div", "bpi-modal-shade");
|
||||
const modal = element("div", "bpi-modal");
|
||||
// 这句直接写中文:带变量 + 固定术语,走翻译层反而匹配不上,界面必须中文
|
||||
// 自然语言片段 / BREAK 这类不叫「标签」,标题换个说法免得看着别扭
|
||||
modal.appendChild(element("h3", "", isPlainTag ? `编辑标签:${token.term}` : `编辑片段:${token.term}`));
|
||||
|
||||
// 多选着进来、或者双击的不是普通标签(自然语言片段)→ 直接停在编辑页:
|
||||
// 权重只对单个标签有意义,而自然语言片段本来就是整段改的
|
||||
const startOnRaw = state.selected.size > 1 || !isPlainTag;
|
||||
// Tab 切换:权重与颜色(一页管两样)/ 编辑(改这行原文)/ 词条信息(改的是整条词条)
|
||||
// 自然语言片段没有权重也没有词条,顺序改成 编辑 → 颜色
|
||||
const tabs = element("div", "bpi-editor-tabs");
|
||||
// 没有权重可改时这页只剩配色,标签也就只写「颜色」
|
||||
const tabWeight = element("button", `bpi-editor-tab${startOnRaw ? "" : " bpi-editor-tab-active"}`, canWeight ? t("Weight / color") : t("Colors"));
|
||||
const tabRaw = element("button", `bpi-editor-tab${startOnRaw ? " bpi-editor-tab-active" : ""}`, t("Edit"));
|
||||
const tabEdit = element("button", "bpi-editor-tab", t("Entry info"));
|
||||
tabWeight.type = tabRaw.type = tabEdit.type = "button";
|
||||
if (isPlainTag) tabs.append(tabWeight, tabRaw, tabEdit);
|
||||
else tabs.append(tabRaw, tabWeight);
|
||||
modal.appendChild(tabs);
|
||||
|
||||
const weightPanel = element("div", `bpi-editor-panel${startOnRaw ? " bpi-hidden" : ""}`);
|
||||
// 权重那三块(说明 / 输入框 / 预设)单独装一个盒子,不能改权重时整盒收起,
|
||||
// 下半部分的配色照旧留着 —— 自然语言片段也要能上色
|
||||
const weightBlock = element("div");
|
||||
// 这句直接写中文:固定术语,走翻译层反而匹配不上
|
||||
weightBlock.appendChild(element("div", "bpi-config-note", "Anima 格式:(标签:权重)"));
|
||||
const form = element("div", "bpi-form");
|
||||
const input = element("input", "");
|
||||
input.type = "number";
|
||||
input.min = "0";
|
||||
input.max = "3";
|
||||
input.step = "0.05";
|
||||
input.value = token.weight === null ? "1" : String(token.weight);
|
||||
// 打开弹窗时这个数字是多少:只有它变了才写回权重,
|
||||
// 否则「只想换个颜色」会被顺手落成 (标签:1)
|
||||
const initialWeight = token.weight === null ? 1 : token.weight;
|
||||
form.append(element("label", "", "Weight"), input);
|
||||
weightBlock.appendChild(form);
|
||||
|
||||
const presets = element("div", "bpi-weight-presets");
|
||||
for (const value of [0.7, 0.8, 0.9, 1, 1.1, 1.2, 1.3, 1.5]) {
|
||||
presets.appendChild(button(String(value), () => {
|
||||
input.value = String(value);
|
||||
input.focus();
|
||||
}, "bpi-mini"));
|
||||
}
|
||||
weightBlock.appendChild(presets);
|
||||
weightPanel.appendChild(weightBlock);
|
||||
|
||||
// 同一页下半部分是配色:预设色块 + 自定义输入框;只在 default 模式下才看得见效果
|
||||
// 说明跟上方的「Anima 格式」一样放在控件上面
|
||||
const colorDivider = element("div", "bpi-editor-divider");
|
||||
weightPanel.appendChild(colorDivider);
|
||||
weightBlock.hidden = !canWeight;
|
||||
colorDivider.hidden = !canWeight;
|
||||
// 双击设的自定义色优先级最低:只有「默认」模式才轮到它显示,其它模式会盖住
|
||||
weightPanel.appendChild(element("div", "bpi-color-note", t("This color has the lowest priority: it only shows in Default mode; other color modes override it.")));
|
||||
const presetsRow = element("div", "bpi-color-presets");
|
||||
let pickedColor = state.customColors.get(token.key) || "";
|
||||
const customInput = element("input", "");
|
||||
customInput.value = pickedColor;
|
||||
// 统一选中态:值相同才高亮,「无」用空串,所以点它能直接清掉自定义色
|
||||
const markPicked = (value, keepInput) => {
|
||||
pickedColor = value;
|
||||
if (!keepInput) customInput.value = value;
|
||||
presetsRow.querySelectorAll(".bpi-color-swatch").forEach((s) => {
|
||||
s.classList.toggle("bpi-color-swatch-active", (s.dataset.color || "") === value);
|
||||
});
|
||||
};
|
||||
// 「无」= 不着色、跟随主题,放在预设色块最前面,一键回到默认
|
||||
const noneSwatch = element("span", "bpi-color-swatch bpi-color-swatch-none", t("None"));
|
||||
noneSwatch.dataset.color = "";
|
||||
setTitle(noneSwatch, t("None"));
|
||||
noneSwatch.addEventListener("click", () => markPicked(""));
|
||||
presetsRow.appendChild(noneSwatch);
|
||||
const presetsPool = (state.tokenColors.presets && state.tokenColors.presets.length) ? state.tokenColors.presets : ["#6b9b78"];
|
||||
for (const hex of presetsPool) {
|
||||
const swatch = element("span", "bpi-color-swatch");
|
||||
swatch.dataset.color = hex;
|
||||
if (hex === pickedColor) swatch.classList.add("bpi-color-swatch-active");
|
||||
swatch.style.background = hex;
|
||||
swatch.title = hex;
|
||||
swatch.addEventListener("click", () => markPicked(hex));
|
||||
presetsRow.appendChild(swatch);
|
||||
}
|
||||
if (!pickedColor) noneSwatch.classList.add("bpi-color-swatch-active");
|
||||
weightPanel.appendChild(presetsRow);
|
||||
const customRow = element("div", "bpi-color-custom");
|
||||
customRow.appendChild(element("span", "", t("Custom")));
|
||||
setPlaceholder(customInput, "#5b8a72 or rgb(91,138,114)");
|
||||
customInput.addEventListener("input", () => {
|
||||
// keepInput:正在打字就不要回写输入框,否则光标会被拉到末尾
|
||||
markPicked(customInput.value.trim(), true);
|
||||
});
|
||||
customInput.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter") { event.preventDefault(); apply(); }
|
||||
});
|
||||
customRow.appendChild(customInput);
|
||||
weightPanel.appendChild(customRow);
|
||||
|
||||
// 编辑 Tab:把选中那段原文原样搬进输入框,怎么改都行,应用时整段写回提示词
|
||||
const rawPanel = element("div", `bpi-editor-panel${startOnRaw ? "" : " bpi-hidden"}`);
|
||||
const rawSpan = hooks.spanForEdit(token);
|
||||
const originalSlice = String(textWidget.value ?? "").slice(rawSpan.start, rawSpan.end).trim();
|
||||
// 这三句直接写中文:本页专属说明,走翻译层反而匹配不上,界面必须中文
|
||||
rawPanel.appendChild(element("div", "bpi-config-note", rawSpan.tokens.length > 1
|
||||
? `已选中 ${rawSpan.tokens.length} 个标签,下面是它们在原文里的整段(含中间的标签)`
|
||||
: isPlainTag ? "下面是这个标签在原文里的那段文字" : "下面是这段文字在原文里的整段"));
|
||||
const rawEditor = element("textarea", "bpi-raw-editor");
|
||||
rawEditor.value = originalSlice;
|
||||
rawPanel.appendChild(rawEditor);
|
||||
rawPanel.appendChild(element("div", "bpi-config-note", "怎么改都行:逗号会被重新切成标签,清空等于删除这一段;Ctrl+Enter 应用"));
|
||||
|
||||
// 编辑 Tab:整套词条字段(含分类下拉),跟「新增/编辑个人标签」弹窗同一份构件。
|
||||
// 命中词库就整条带过来改;没命中就按这条标签卡起个头,存进去即新建。
|
||||
const editPanel = element("div", "bpi-editor-panel bpi-hidden");
|
||||
// 两句说明收进同一个盒子:应用时若发现还是「未收录」,整盒换成红色警告,
|
||||
// 并先把盒子按当前高度钉住,换文案就不会把下面的表单顶上去
|
||||
const editNotes = element("div", "bpi-editor-notes");
|
||||
// 这两句是原文(英文的走翻译层,中文那句本页专属、不进译表),来回切换时都按原文重新渲染
|
||||
const noteSavedSource = "Edits go to the personal dictionary and override same-name entries.";
|
||||
const noteReadonlySource = "英文标签在这里只读;要改提示词里的文字,请用「编辑」页。";
|
||||
const noteSaved = element("div", "bpi-config-note", noteSavedSource);
|
||||
const noteReadonly = element("div", "bpi-config-note", noteReadonlySource);
|
||||
editNotes.append(noteSaved, noteReadonly);
|
||||
editPanel.appendChild(editNotes);
|
||||
const editForm = element("div", "bpi-form");
|
||||
const editFields = buildTagFields(editForm, state.index.get(token.key) ?? {
|
||||
english: token.term,
|
||||
// 未收录的中文名只是占位,这里预填成空:想存就得自己写一个,
|
||||
// 下面的「还是未收录就拦掉」只是兜底(防手动敲回这三个字)
|
||||
chinese: token.chinese === UNKNOWN_CHINESE ? "" : token.chinese ?? "",
|
||||
models: ["general", "anima"],
|
||||
source: "user",
|
||||
verified: true,
|
||||
// 英文只读:改它只会另存一条词条,不会动提示词;改文字请去「编辑」页
|
||||
}, { withTarget: false, readonlyEnglish: true });
|
||||
editPanel.appendChild(editForm);
|
||||
|
||||
// 保护性占位:未收录标签的中文名只是提示文案,原样存进个人词库会得到
|
||||
// 一条中文名是「未收录」的残词条,标签从此显示成已收录却看不出是什么。
|
||||
// 所以「词条信息」页点应用时先比对中文名,还是占位就不保存、只把说明换成红色警告。
|
||||
let warnTimer = 0;
|
||||
const restoreEditNotes = () => {
|
||||
clearTimeout(warnTimer);
|
||||
editNotes.style.minHeight = "";
|
||||
setText(noteSaved, noteSavedSource);
|
||||
setText(noteReadonly, noteReadonlySource);
|
||||
noteSaved.classList.remove("bpi-config-note-warn");
|
||||
noteReadonly.classList.remove("bpi-config-note-warn");
|
||||
};
|
||||
const warnUnknownChinese = () => {
|
||||
// 先按现在的高度钉住:警告比原文短也不会缩,下面的表单一动不动
|
||||
const height = editNotes.getBoundingClientRect().height;
|
||||
if (height > 0) editNotes.style.minHeight = `${height}px`;
|
||||
setText(noteSaved, "Nothing was saved: the Chinese name is still the unknown placeholder.");
|
||||
setText(noteReadonly, "Enter a real Chinese name, then apply again.");
|
||||
noteSaved.classList.add("bpi-config-note-warn");
|
||||
noteReadonly.classList.add("bpi-config-note-warn");
|
||||
clearTimeout(warnTimer);
|
||||
warnTimer = setTimeout(restoreEditNotes, 2000);
|
||||
// 光标直接落到中文名那一格,省得再去找
|
||||
editFields.focus();
|
||||
};
|
||||
|
||||
modal.append(weightPanel, rawPanel, editPanel);
|
||||
|
||||
const close = () => {
|
||||
clearTimeout(warnTimer);
|
||||
editFields.destroy();
|
||||
shade.remove();
|
||||
};
|
||||
// 配色跟权重在同一页,所以 Apply 一次存两样;改动与否各自判断,
|
||||
// 只想改颜色的人不会被「权重没有变化」挡住
|
||||
const saveColor = () => {
|
||||
const value = String(customInput.value || "").trim();
|
||||
const previous = state.customColors.get(token.key) || "";
|
||||
// 任何模式下都允许设置(切回 default 就显示);显示与否由 tokenCardColor 按模式决定
|
||||
if (!value) state.customColors.delete(token.key);
|
||||
else {
|
||||
const okHex = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(value);
|
||||
const okRgb = /^rgb\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*\)$/.test(value);
|
||||
if (!okHex && !okRgb) {
|
||||
hooks.setStatus("Invalid color; use #hex or rgb(r,g,b)", "error");
|
||||
customInput.focus();
|
||||
return null;
|
||||
}
|
||||
state.customColors.set(token.key, value);
|
||||
}
|
||||
return value !== previous;
|
||||
};
|
||||
const apply = () => {
|
||||
// 不能改权重时这一页只剩配色,直接存色走人
|
||||
if (!canWeight) {
|
||||
const colorOnly = saveColor();
|
||||
if (colorOnly === null) return;
|
||||
if (colorOnly) hooks.render();
|
||||
close();
|
||||
return;
|
||||
}
|
||||
const raw = String(input.value ?? "").trim();
|
||||
const numeric = Number(raw);
|
||||
if (!raw || !Number.isFinite(numeric) || numeric < 0 || numeric > 3) {
|
||||
hooks.setStatus("Weight must be between 0 and 3", "error");
|
||||
input.focus();
|
||||
return;
|
||||
}
|
||||
const colorChanged = saveColor();
|
||||
if (colorChanged === null) return;
|
||||
// 打开时是 1(原标签没写权重)就不算改过:光改颜色的不应该被写成 (标签:1)
|
||||
const weightDirty = Math.abs(numeric - initialWeight) > 1e-9;
|
||||
if (weightDirty) {
|
||||
// 改文本会顺带重绘,配色不用再刷一次
|
||||
if (hooks.applyTokenWeight(token, numeric)) close();
|
||||
return;
|
||||
}
|
||||
if (colorChanged) hooks.render();
|
||||
close();
|
||||
};
|
||||
// 编辑页:整段写回当前节点的提示词原文,跟手改原文一样进撤销栈
|
||||
const applyRawEdit = () => {
|
||||
const before = String(textWidget.value ?? "");
|
||||
const result = replacePromptSpan(before, rawSpan.start, rawSpan.end, rawEditor.value);
|
||||
if (!result.changed) {
|
||||
hooks.setStatus("No content change", "ok");
|
||||
close();
|
||||
return;
|
||||
}
|
||||
const multi = rawSpan.tokens.length > 1;
|
||||
const label = multi ? `Edit ${rawSpan.tokens.length} tags` : `Edit “${rawSpan.tokens[0].term}”`;
|
||||
state.undoStack.push({
|
||||
before,
|
||||
after: result.text,
|
||||
beforeStart: rawSpan.start,
|
||||
beforeEnd: rawSpan.end,
|
||||
afterCursor: result.cursor,
|
||||
label,
|
||||
});
|
||||
if (state.undoStack.length > 50) state.undoStack.shift();
|
||||
state.redoStack = [];
|
||||
state.pinned = null;
|
||||
hooks.updateText(result.text, result.cursor);
|
||||
close();
|
||||
// 状态栏这两句分开写:套一层「“…”」再塞带引号的 label 会打乱变量折叠
|
||||
const rewroteMsg = multi
|
||||
? `rewrote ${rawSpan.tokens.length} tags`
|
||||
: `rewrote “${rawSpan.tokens[0].term}”`;
|
||||
if (!showToast(rewroteMsg, { type: "edit" })) hooks.setStatus(rewroteMsg, "ok");
|
||||
};
|
||||
// 词条页:只改词条,不动当前这行提示词文本,所以不进撤销栈
|
||||
const applyEdit = async () => {
|
||||
const payload = editFields.collect();
|
||||
// 中文名还是「未收录」占位:存下去只会得到一条残词条,直接拦掉不保存
|
||||
if (String(payload.chinese ?? "").trim() === UNKNOWN_CHINESE) {
|
||||
warnUnknownChinese();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await saveTag(payload);
|
||||
await hooks.refreshDictionary();
|
||||
close();
|
||||
hooks.setStatus(t("Tag saved"), "ok");
|
||||
} catch (saveError) {
|
||||
hooks.setStatus(saveError.message, "error");
|
||||
}
|
||||
};
|
||||
const actions = element("div", "bpi-modal-actions");
|
||||
// 「清除权重」只对权重页有意义,切到别的页就收起来;先存配色再清,别丢改动
|
||||
let clearWeightButton = null;
|
||||
if (canWeight && token.weight !== null) {
|
||||
clearWeightButton = button("Clear weight", () => {
|
||||
if (saveColor() === null) return;
|
||||
if (hooks.applyTokenWeight(token, null)) close();
|
||||
}, "bpi-danger");
|
||||
clearWeightButton.hidden = startOnRaw;
|
||||
actions.appendChild(clearWeightButton);
|
||||
}
|
||||
actions.append(button("Cancel", close));
|
||||
// 先声明再让 applyButton 引用它:let 有 TDZ,声明放在闭包之后会有隐患
|
||||
let activeTab = startOnRaw ? "raw" : "weight";
|
||||
const applyButton = button(t("Apply"), () => {
|
||||
if (activeTab === "edit") applyEdit();
|
||||
else if (activeTab === "raw") applyRawEdit();
|
||||
else apply();
|
||||
}, "bpi-primary");
|
||||
actions.append(applyButton);
|
||||
modal.appendChild(actions);
|
||||
shade.appendChild(modal);
|
||||
document.body.appendChild(shade);
|
||||
shade.addEventListener("mousedown", (event) => { if (event.target === shade) close(); });
|
||||
modal.addEventListener("mousedown", (event) => event.stopPropagation());
|
||||
input.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
apply();
|
||||
} else if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
close();
|
||||
}
|
||||
});
|
||||
// 编辑框要能打换行,所以 Enter 不提交,只有 Ctrl/Cmd+Enter 才应用
|
||||
rawEditor.addEventListener("keydown", (event) => {
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
applyRawEdit();
|
||||
} else if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
close();
|
||||
}
|
||||
});
|
||||
// Tab 切换:共用底部那个 Apply 按钮,按 activeTab 分派到三个动作
|
||||
const showPanel = (tab) => {
|
||||
tabWeight.classList.toggle("bpi-editor-tab-active", tab === "weight");
|
||||
tabRaw.classList.toggle("bpi-editor-tab-active", tab === "raw");
|
||||
tabEdit.classList.toggle("bpi-editor-tab-active", tab === "edit");
|
||||
weightPanel.classList.toggle("bpi-hidden", tab !== "weight");
|
||||
rawPanel.classList.toggle("bpi-hidden", tab !== "raw");
|
||||
editPanel.classList.toggle("bpi-hidden", tab !== "edit");
|
||||
// 离开词条页就把红色警告收回去,别等 2 秒后回来还挂着
|
||||
if (tab !== "edit") restoreEditNotes();
|
||||
if (clearWeightButton) clearWeightButton.hidden = tab !== "weight";
|
||||
};
|
||||
tabWeight.addEventListener("click", () => {
|
||||
activeTab = "weight";
|
||||
showPanel("weight");
|
||||
// 不能改权重时这一页只有配色,光标落到颜色输入框
|
||||
setTimeout(() => {
|
||||
if (canWeight) { input.focus(); input.select(); }
|
||||
else { customInput.focus(); customInput.select(); }
|
||||
}, 0);
|
||||
});
|
||||
tabRaw.addEventListener("click", () => {
|
||||
activeTab = "raw";
|
||||
showPanel("raw");
|
||||
setTimeout(() => { rawEditor.focus(); rawEditor.select(); }, 0);
|
||||
});
|
||||
tabEdit.addEventListener("click", () => {
|
||||
activeTab = "edit";
|
||||
showPanel("edit");
|
||||
setTimeout(() => editFields.focus(), 0);
|
||||
});
|
||||
if (startOnRaw) setTimeout(() => { rawEditor.focus(); rawEditor.select(); }, 0);
|
||||
else if (canWeight) setTimeout(() => { input.focus(); input.select(); }, 0);
|
||||
else setTimeout(() => { customInput.focus(); customInput.select(); }, 0);
|
||||
};
|
||||
|
||||
return openWeightEditor;
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
// 隐藏标签与「唯一」开关,以及它们怎么影响真正的输出。
|
||||
//
|
||||
// 隐藏的标签仍然留在 text 里(界面上划线显示,照常拖动/改色/改权重),
|
||||
// 真正送去出图的是两个隐藏 widget:effective_text(剔除隐藏后的文本)
|
||||
// 和 use_effective(上游是否采纳)。旧工作流没有这两个 widget 时直通 text。
|
||||
import { duplicateGroups, normalizeKey, parsePrompt, removePromptToken, restorePromptToken } from "./parser.js";
|
||||
import { showToast } from "./bpi_toast.js";
|
||||
|
||||
export function createHiddenTags({ store, buildTokenCard }) {
|
||||
const { state, textWidget, node } = store;
|
||||
const hooks = store.hooks;
|
||||
|
||||
// ---- Hidden tags ---------------------------------------------------------
|
||||
// A hidden tag STAYS in the text (the views draw it with a strikethrough and
|
||||
// it can still be dragged / recolored / re-weighted); it is only stripped
|
||||
// from the actual output, via the effective_text/use_effective widgets that
|
||||
// syncEffectiveOutput keeps up to date. The list lives in
|
||||
// properties.bpiHiddenTags so it survives workflow save/load, and every undo
|
||||
// entry snapshots the list so undo rolls mark and text back together.
|
||||
// Entries from older saves (whose tags were physically removed from the text
|
||||
// back then) are re-inserted at their old anchor on restore.
|
||||
const getHiddenTags = () => {
|
||||
if (!node.properties || typeof node.properties !== "object") node.properties = {};
|
||||
if (!Array.isArray(node.properties.bpiHiddenTags)) node.properties.bpiHiddenTags = [];
|
||||
return node.properties.bpiHiddenTags;
|
||||
};
|
||||
const setHiddenTags = (nextList) => {
|
||||
if (!node.properties || typeof node.properties !== "object") node.properties = {};
|
||||
node.properties.bpiHiddenTags = Array.isArray(nextList) ? nextList : [];
|
||||
};
|
||||
|
||||
const canHideTokens = () => ["tags", "mixed"].includes(state.modeInfo.mode);
|
||||
|
||||
// 隐藏条目 ↔ 当前 token 的匹配:优先按 key(normalizeKey(term),改权重不影响),
|
||||
// 旧存档的条目没有 key,退回按 raw 匹配。
|
||||
const hiddenEntryMatches = (item, token) =>
|
||||
(item.key && token.key === item.key) || (!item.key && token.raw === item.raw);
|
||||
const hiddenIndexOf = (token) => getHiddenTags().findIndex((item) => hiddenEntryMatches(item, token));
|
||||
const isTokenHidden = (token) => hiddenIndexOf(token) >= 0;
|
||||
|
||||
// ---- Duplicate tags ------------------------------------------------------
|
||||
// 重复标签走两条互不干扰的线路:
|
||||
// 1. 「唯一」开关(问题行里):按 key 记进 node.properties.bpiUniqueKeys,
|
||||
// 打开后第 2 次及以后出现的那几次不进实际输出,界面照常显示(划线)。
|
||||
// 和隐藏标记一样按节点存,跟着工作流一起保存/读取。
|
||||
// 2. 英文标签区下面的「重复」栏:单击依次跳到每一次出现(标红 + 联动选中),
|
||||
// 已经定位过再双击就删掉当前这一处。
|
||||
const getUniqueKeys = () => {
|
||||
if (!node.properties || typeof node.properties !== "object") node.properties = {};
|
||||
if (!Array.isArray(node.properties.bpiUniqueKeys)) node.properties.bpiUniqueKeys = [];
|
||||
return node.properties.bpiUniqueKeys;
|
||||
};
|
||||
const setUniqueKeys = (nextList) => {
|
||||
if (!node.properties || typeof node.properties !== "object") node.properties = {};
|
||||
node.properties.bpiUniqueKeys = Array.isArray(nextList) ? nextList : [];
|
||||
};
|
||||
const isUniqueKey = (key) => Boolean(key) && getUniqueKeys().includes(key);
|
||||
|
||||
// 同一个标签的第 2 次及以后出现,被「唯一」开关排除在输出之外
|
||||
const isTokenSuppressed = (token) => {
|
||||
const rank = state.duplicateRanks.get(token.id);
|
||||
return Boolean(rank && rank.rank > 0 && isUniqueKey(token.key));
|
||||
};
|
||||
|
||||
// 重复栏当前标红的那一处所在的 token(跨 render 只记 key + 第几次,够稳)
|
||||
const dupFocusToken = () => {
|
||||
if (!state.dupFocus) return null;
|
||||
const group = state.duplicateGroups.find((item) => item.key === state.dupFocus.key);
|
||||
return group?.tokens[state.dupFocus.index] ?? null;
|
||||
};
|
||||
|
||||
// ---- 输出管道 ------------------------------------------------------------
|
||||
// 隐藏的标签留在 text 里(界面上划线显示,照常拖动/改色/改权重),真正输出
|
||||
// 走两个隐藏 widget:effective_text = 剔除隐藏标签后的文本,use_effective = true。
|
||||
// 旧工作流没有它们,Python 默认直通 text,行为不变。
|
||||
const effectiveWidget = node.widgets?.find((widget) => widget.name === "effective_text") || null;
|
||||
const useEffectiveWidget = node.widgets?.find((widget) => widget.name === "use_effective") || null;
|
||||
const syncEffectiveOutput = () => {
|
||||
if (!effectiveWidget) return;
|
||||
const hidden = getHiddenTags();
|
||||
let out = String(textWidget.value ?? "");
|
||||
if (hidden.length) {
|
||||
// 每次删完重新解析,偏移才不会错;隐藏条目通常很少,代价可忽略
|
||||
for (;;) {
|
||||
const tokens = parsePrompt(out, state.index, state.machine, { mode: state.modePreference });
|
||||
const target = tokens.find((token) => hidden.some((item) => hiddenEntryMatches(item, token)));
|
||||
if (!target) break;
|
||||
const step = removePromptToken(out, target);
|
||||
if (!step.changed) break;
|
||||
out = step.text;
|
||||
}
|
||||
}
|
||||
// 「唯一」再去重:同一个 key 只留第一次出现的那一次
|
||||
const unique = getUniqueKeys();
|
||||
if (unique.length) {
|
||||
for (;;) {
|
||||
const tokens = parsePrompt(out, state.index, state.machine, { mode: state.modePreference });
|
||||
const target = duplicateGroups(tokens)
|
||||
.filter((group) => unique.includes(group.key))
|
||||
.flatMap((group) => group.tokens.slice(1))[0];
|
||||
if (!target) break;
|
||||
const step = removePromptToken(out, target);
|
||||
if (!step.changed) break;
|
||||
out = step.text;
|
||||
}
|
||||
}
|
||||
effectiveWidget.value = out;
|
||||
if (useEffectiveWidget) useEffectiveWidget.value = true;
|
||||
};
|
||||
|
||||
const pushHistoryWithHidden = (entry) => {
|
||||
state.undoStack.push(entry);
|
||||
if (state.undoStack.length > 50) state.undoStack.shift();
|
||||
state.redoStack = [];
|
||||
};
|
||||
|
||||
// 隐藏只是"打标记":标签留在文本里,界面上划线显示,照常拖动/改色/改权重,
|
||||
// 只是 syncEffectiveOutput 会把它从实际输出里剔除。所以隐藏/恢复的 undo 条目
|
||||
// before === after(文本没变),撤销只回滚隐藏列表。
|
||||
const hideToken = (token) => {
|
||||
if (!token || !canHideTokens()) return false;
|
||||
if (isTokenHidden(token)) return false;
|
||||
const hidden = getHiddenTags();
|
||||
const before = String(textWidget.value ?? "");
|
||||
const entry = {
|
||||
before,
|
||||
after: before,
|
||||
beforeStart: token.start,
|
||||
beforeEnd: token.end,
|
||||
afterCursor: token.start,
|
||||
label: `隐藏「${token.term}」`,
|
||||
hiddenBefore: hidden.slice(),
|
||||
hiddenAfter: hidden.concat([{
|
||||
raw: token.raw,
|
||||
chinese: token.chinese ?? "",
|
||||
after: null,
|
||||
key: token.key,
|
||||
}]),
|
||||
};
|
||||
pushHistoryWithHidden(entry);
|
||||
setHiddenTags(entry.hiddenAfter);
|
||||
state.pinned = null;
|
||||
hooks.updateText(before, token.start);
|
||||
// 有了右下角提示条就不用再写「点眼睛斜线图标取消隐藏」这种操作说明了
|
||||
const hiddenMsg = `已隐藏「${token.term}」`;
|
||||
if (!showToast(hiddenMsg, { type: "edit" })) hooks.setStatus(hiddenMsg, "ok");
|
||||
return true;
|
||||
};
|
||||
|
||||
// 取消隐藏 = 把条目从隐藏列表里摘掉。两种形态都兼容:
|
||||
// 标签还在文本里(新模型)→ 只摘标记;旧版工作流当时把标签从文本删了 → 按旧锚点插回去。
|
||||
const restoreHiddenTag = (index) => {
|
||||
const hidden = getHiddenTags();
|
||||
const item = hidden[index];
|
||||
if (!item) return false;
|
||||
const before = String(textWidget.value ?? "");
|
||||
const inText = state.tokens.some((token) => hiddenEntryMatches(item, token));
|
||||
let after = before;
|
||||
let cursor = before.length;
|
||||
if (!inText) {
|
||||
const result = restorePromptToken(before, state.tokens, item.raw, item.after);
|
||||
if (!result.changed) return false;
|
||||
after = result.text;
|
||||
cursor = result.cursor;
|
||||
}
|
||||
const entry = {
|
||||
before,
|
||||
after,
|
||||
beforeStart: 0,
|
||||
beforeEnd: before.length,
|
||||
afterCursor: cursor,
|
||||
label: `恢复「${item.raw}」`,
|
||||
hiddenBefore: hidden.slice(),
|
||||
hiddenAfter: hidden.filter((_, i) => i !== index),
|
||||
};
|
||||
pushHistoryWithHidden(entry);
|
||||
setHiddenTags(entry.hiddenAfter);
|
||||
hooks.updateText(after, cursor);
|
||||
const restoreMsg = inText ? `已取消隐藏「${item.raw}」` : `已把「${item.raw}」恢复到原位置`;
|
||||
if (!showToast(restoreMsg, { type: "edit" })) hooks.setStatus(restoreMsg, "ok");
|
||||
return true;
|
||||
};
|
||||
|
||||
const unhideToken = (token) => {
|
||||
const index = hiddenIndexOf(token);
|
||||
return index >= 0 ? restoreHiddenTag(index) : false;
|
||||
};
|
||||
|
||||
const restoreAllHiddenTags = () => {
|
||||
const hidden = getHiddenTags();
|
||||
if (!hidden.length) return false;
|
||||
const before = String(textWidget.value ?? "");
|
||||
let text = before;
|
||||
let tokens = state.tokens;
|
||||
for (const item of hidden) {
|
||||
if (tokens.some((token) => hiddenEntryMatches(item, token))) continue; // 还在文本里,只摘标记
|
||||
const step = restorePromptToken(text, tokens, item.raw, item.after);
|
||||
if (!step.changed) continue;
|
||||
text = step.text;
|
||||
tokens = parsePrompt(text, state.index, state.machine, { mode: state.modePreference });
|
||||
}
|
||||
const entry = {
|
||||
before,
|
||||
after: text,
|
||||
beforeStart: 0,
|
||||
beforeEnd: before.length,
|
||||
afterCursor: text.length,
|
||||
label: `恢复全部隐藏标签(${hidden.length})`,
|
||||
hiddenBefore: hidden.slice(),
|
||||
hiddenAfter: [],
|
||||
};
|
||||
pushHistoryWithHidden(entry);
|
||||
setHiddenTags([]);
|
||||
state.pinned = null;
|
||||
hooks.updateText(text, text.length);
|
||||
const restoreAllMsg = `已恢复 ${hidden.length} 个隐藏标签`;
|
||||
if (!showToast(restoreAllMsg, { type: "edit" })) hooks.setStatus(restoreAllMsg, "ok");
|
||||
return true;
|
||||
};
|
||||
|
||||
return {
|
||||
canHideTokens, dupFocusToken, effectiveWidget, getHiddenTags, getUniqueKeys, hiddenEntryMatches, hiddenIndexOf, hideToken, isTokenHidden, isTokenSuppressed, isUniqueKey, pushHistoryWithHidden, restoreAllHiddenTags, restoreHiddenTag, setHiddenTags, setUniqueKeys, syncEffectiveOutput, unhideToken, useEffectiveWidget,
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// 添加标签时的出现动画:新卡片从左往右展开(删除那套手势反过来用),
|
||||
// 添加标签时的出现动画:新卡片从左往右展开,
|
||||
// 其余卡片同时滑开让位。开关跟删除共用一个(设置里那个「标签增删动画」),
|
||||
// 所以这个文件只管播,不管开关。
|
||||
// 时长比删除短一些,但留足 0.42 秒:太快会看着像「啪」一下跳出来的。
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
// 换行块的数据层:原文里的换行既不是 token、也进不了撤销栈,所以用「第 n 个标签与
|
||||
// 第 n+1 个标签之间的原文片段」(gap)来定位换行。这里只读写文本与撤销栈,不碰 DOM;
|
||||
// 界面上的 ↵ 块由渲染层按 gapHasBreak 决定要不要画。
|
||||
export function createLineBreaks({ store }) {
|
||||
// 依赖统一从 store 取:state / textWidget 是稳定引用,回调走 store.hooks(运行时再取,
|
||||
// 因为主文件是在这些函数定义完之后才 bind 进来的)
|
||||
const { state, textWidget } = store;
|
||||
const hooks = store.hooks;
|
||||
// ---- Line breaks --------------------------------------------------------
|
||||
// 换行在原文里就是真正的 \n:parser 只把它当分隔符丢掉,所以它既不是 token
|
||||
// 也进不了撤销栈。这里给两个可视化区补一层「换行块」,让它和标签一样看得见、
|
||||
// 拖得动、删得掉。换行唯一能待的位置是第 n 个标签与第 n+1 个标签之间的原文
|
||||
// 片段(下面叫 gap),所以换行块一律用 gap 下标定位,parser 与数据格式不动。
|
||||
const breakGapCount = () => Math.max(0, state.tokens.length - 1);
|
||||
|
||||
const gapSource = (gapIndex) => {
|
||||
const source = String(textWidget.value ?? "");
|
||||
const left = state.tokens[gapIndex];
|
||||
const right = state.tokens[gapIndex + 1];
|
||||
if (!left || !right) return "";
|
||||
return source.slice(left.end, right.start);
|
||||
};
|
||||
|
||||
const gapHasBreak = (gapIndex) => /[\r\n]/.test(gapSource(gapIndex));
|
||||
|
||||
const currentBreakGaps = () => {
|
||||
const gaps = new Set();
|
||||
for (let index = 0; index < breakGapCount(); index += 1) if (gapHasBreak(index)) gaps.add(index);
|
||||
return gaps;
|
||||
};
|
||||
|
||||
// 只改写 gap:标签原文(权重、括号、自然语言整段)一个字符都不动。
|
||||
// 注意 parser 把分隔符后面的空格算进下一个 token(token.start 在空格前),
|
||||
// 所以 gap 里本来就只有 "," / "\n" 这类分隔符——去掉换行时补一个逗号就够,
|
||||
// 再补空格会出现 "a, b" 这种双空格。
|
||||
const rebuildTextWithBreaks = (breakGaps) => {
|
||||
const source = String(textWidget.value ?? "");
|
||||
if (state.tokens.length < 2) return source;
|
||||
const first = state.tokens[0];
|
||||
const last = state.tokens[state.tokens.length - 1];
|
||||
let out = source.slice(0, first.start);
|
||||
for (const [index, token] of state.tokens.entries()) {
|
||||
out += source.slice(token.start, token.end);
|
||||
const next = state.tokens[index + 1];
|
||||
if (!next) break;
|
||||
const gap = source.slice(token.end, next.start);
|
||||
if (!/[\r\n]/.test(gap)) {
|
||||
out += breakGaps.has(index) ? "\n" : gap;
|
||||
continue;
|
||||
}
|
||||
if (breakGaps.has(index)) {
|
||||
out += gap;
|
||||
continue;
|
||||
}
|
||||
const flat = gap.replace(/[\r\n]+/g, "").trim();
|
||||
out += flat || ",";
|
||||
}
|
||||
return out + source.slice(last.end);
|
||||
};
|
||||
|
||||
const replacePromptText = (nextText, label) => {
|
||||
const before = String(textWidget.value ?? "");
|
||||
if (nextText === before) return false;
|
||||
state.undoStack.push({
|
||||
before,
|
||||
after: nextText,
|
||||
beforeStart: before.length,
|
||||
beforeEnd: null,
|
||||
afterCursor: nextText.length,
|
||||
label,
|
||||
});
|
||||
if (state.undoStack.length > 50) state.undoStack.shift();
|
||||
state.redoStack = [];
|
||||
hooks.updateText(nextText);
|
||||
return true;
|
||||
};
|
||||
|
||||
const applyBreakGaps = (breakGaps, label) => replacePromptText(rebuildTextWithBreaks(breakGaps), label);
|
||||
|
||||
const removeBreakAt = (gapIndex) => {
|
||||
const gaps = currentBreakGaps();
|
||||
if (!gaps.delete(gapIndex)) return false;
|
||||
return applyBreakGaps(gaps, "删除换行");
|
||||
};
|
||||
|
||||
const moveBreakTo = (fromGap, toGap) => {
|
||||
const gaps = currentBreakGaps();
|
||||
if (!gaps.has(fromGap)) return false;
|
||||
const target = Math.max(0, Math.min(breakGapCount() - 1, toGap));
|
||||
if (target === fromGap) return false;
|
||||
gaps.delete(fromGap);
|
||||
gaps.add(target);
|
||||
return applyBreakGaps(gaps, "移动换行");
|
||||
};
|
||||
|
||||
const insertBreakFromToolbar = () => {
|
||||
if (state.englishEditing) {
|
||||
hooks.setEnglishEditing(false, true);
|
||||
hooks.render(); // setEnglishEditing 只排了个异步渲染,这里同步跑一遍拿最新的 token 偏移
|
||||
}
|
||||
if (!state.tokens.length) {
|
||||
hooks.setStatus("Enter tags first, then insert a line break", "");
|
||||
return;
|
||||
}
|
||||
const pinnedIndex = state.tokens.findIndex((item) => item.id === state.pinned);
|
||||
if (pinnedIndex >= 0 && pinnedIndex < breakGapCount()) {
|
||||
const gaps = currentBreakGaps();
|
||||
gaps.add(pinnedIndex);
|
||||
if (applyBreakGaps(gaps, "插入换行")) hooks.setStatus("Inserted a line break", "ok");
|
||||
return;
|
||||
}
|
||||
// 没选中标签,或选中的是最后一个标签(它后面没有 gap):换行加在整段末尾。
|
||||
const source = String(textWidget.value ?? "");
|
||||
if (replacePromptText(`${source.replace(/[ \t]+$/, "")}\n`, "插入换行")) {
|
||||
hooks.setStatus("Inserted a line break", "ok");
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
breakGapCount, gapSource, gapHasBreak, currentBreakGaps, rebuildTextWithBreaks,
|
||||
replacePromptText, applyBreakGaps, removeBreakAt, moveBreakTo, insertBreakFromToolbar,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
// 节点尺寸与高度管理:从主文件 createPanel 闭包里拆出来的 12 个函数。
|
||||
// 每个节点一份(createPanel 里调用),state / node 从 store 取,6 个 DOM 从 refs 取;
|
||||
// render 必须走 store.hooks.render(),不能提前解构 —— hooks 是在 createPanel 后半段才 bind 的。
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { inspectorNodeTargetHeight } from "./dictionary_tools.js";
|
||||
import { setTitle } from "./bpi_shared.js";
|
||||
import { COLLAPSED_NODE_MIN_HEIGHT, FILL_HEIGHT_MIN_GROWTH } from "./bpi_store.js";
|
||||
|
||||
export function createNodeSize({ store, refs, persistPreferences }) {
|
||||
const { state, node } = store;
|
||||
const { panel, englishSection, englishEditor, englishTokenView, fillSwitch, fillSwitchRow } = refs;
|
||||
|
||||
const autoFitGreenArea = (target, minimum) => {
|
||||
if (!target || target.classList.contains("bpi-hidden")) return;
|
||||
// 适应高度模式下高度交给 flex;这里再写死 height 会把刚放开的上限又按回去
|
||||
if (state.fillHeight) {
|
||||
target.style.height = "";
|
||||
target.style.overflowY = "auto";
|
||||
return;
|
||||
}
|
||||
const maximum = 360;
|
||||
target.style.height = "auto";
|
||||
const height = Math.max(minimum, Math.min(maximum, Math.ceil(target.scrollHeight + 2)));
|
||||
target.style.height = `${height}px`;
|
||||
target.style.overflowY = target.scrollHeight > height + 1 ? "auto" : "hidden";
|
||||
};
|
||||
const hasNodeSize = () => Number.isFinite(Number(node.size?.[0])) && Number.isFinite(Number(node.size?.[1]));
|
||||
const resizeCornerHit = (elementValue, event) => {
|
||||
const bounds = elementValue.getBoundingClientRect();
|
||||
return bounds.right - event.clientX <= 24 && bounds.bottom - event.clientY <= 24;
|
||||
};
|
||||
const resolveDeepestWidget = (widget) => {
|
||||
if (typeof widget?.resolveDeepest !== "function") return widget ?? null;
|
||||
try {
|
||||
return widget.resolveDeepest()?.widget ?? widget;
|
||||
} catch {
|
||||
return widget;
|
||||
}
|
||||
};
|
||||
const applyEnglishEditorHeight = (height) => {
|
||||
const value = Math.max(84, Math.min(420, Math.round(Number(height) || 0)));
|
||||
englishEditor.style.height = `${value}px`;
|
||||
return value;
|
||||
};
|
||||
const startManualResize = (kind, target, event) => {
|
||||
if (event.button !== 0 || !resizeCornerHit(target, event)) return;
|
||||
state.manualResize = {
|
||||
kind,
|
||||
target,
|
||||
startHeight: target.offsetHeight,
|
||||
startNodeHeight: Number(node.size?.[1]) || 0,
|
||||
};
|
||||
};
|
||||
const finishManualResize = () => {
|
||||
const resize = state.manualResize;
|
||||
if (!resize) return;
|
||||
state.manualResize = null;
|
||||
requestAnimationFrame(() => {
|
||||
if (!resize.target?.isConnected) return;
|
||||
if (resize.kind === "english") {
|
||||
const height = applyEnglishEditorHeight(resize.target.offsetHeight);
|
||||
const delta = height - resize.startHeight;
|
||||
state.preferences = { ...state.preferences, englishInputHeight: height };
|
||||
persistPreferences();
|
||||
if (hasNodeSize() && Math.abs(delta) > 0.5) {
|
||||
node.setSize?.([node.size[0], Math.max(COLLAPSED_NODE_MIN_HEIGHT, resize.startNodeHeight + delta)]);
|
||||
}
|
||||
}
|
||||
node.graph?.change?.();
|
||||
node.graph?.setDirtyCanvas?.(true, true);
|
||||
app.graph?.setDirtyCanvas?.(true, true);
|
||||
});
|
||||
};
|
||||
// 这两行是原块里的副作用,必须留在工厂内、且排在 finishManualResize 定义之后
|
||||
state.windowPointerUp = finishManualResize;
|
||||
window.addEventListener("pointerup", state.windowPointerUp, true);
|
||||
const measureCollapsedWidgetHeight = () => {
|
||||
const styles = getComputedStyle(panel);
|
||||
const pixels = (name) => Number.parseFloat(styles.getPropertyValue(name)) || 0;
|
||||
// 底部快捷输入区是后加的,这里用选择器取高度,避免引用尚未初始化的常量
|
||||
const quickHeight = panel.querySelector(".bpi-quick-section")?.offsetHeight ?? 0;
|
||||
const height = englishSection.offsetHeight
|
||||
+ quickHeight
|
||||
+ pixels("padding-top")
|
||||
+ pixels("padding-bottom")
|
||||
+ pixels("border-top-width")
|
||||
+ pixels("border-bottom-width")
|
||||
+ pixels("row-gap");
|
||||
return Math.max(280, Math.min(620, Math.ceil(height + 1)));
|
||||
};
|
||||
const requestNodeResize = () => {
|
||||
if (state.resizeFrame !== null) cancelAnimationFrame(state.resizeFrame);
|
||||
state.resizeFrame = requestAnimationFrame(() => {
|
||||
state.resizeFrame = null;
|
||||
const width = Math.max(node.size?.[0] ?? 0, 590);
|
||||
// 适应高度模式:高度归用户,只同步宽度。这里绝不能按内容重算——
|
||||
// 英文区一变高就会推高自动高度、再推高节点、英文区又更高,越滚越大。
|
||||
if (state.fillHeight) {
|
||||
if (Math.abs((node.size?.[0] ?? 0) - width) > 0.5) {
|
||||
node.setSize?.([width, Number(node.size?.[1]) || COLLAPSED_NODE_MIN_HEIGHT]);
|
||||
node.graph?.change?.();
|
||||
}
|
||||
node.graph?.setDirtyCanvas?.(true, true);
|
||||
app.graph?.setDirtyCanvas?.(true, true);
|
||||
return;
|
||||
}
|
||||
state.collapsedWidgetHeight = measureCollapsedWidgetHeight();
|
||||
panel.style.setProperty("--bpi-collapsed-height", `${state.collapsedWidgetHeight}px`);
|
||||
// 稳定基线 = 标题栏 + 原生 text 控件的真实内容高度。
|
||||
// 绝不能读 inspectorWidget().y(= bilingual_inspector.y)——那是 ComfyUI arrange
|
||||
// 从 text.computedHeight 派生的,而 text.computedHeight 在「setSize 变高 → 文本框
|
||||
// 可用空间变大 → scrollHeight 变大 → computedHeight 再涨」的反馈里只增不减
|
||||
// (实测每写一次值节点 +10px、永不回缩)。这里临时把 text 控件设成 auto 量
|
||||
// scrollHeight,得到与 setSize 无关的真实内容高,再恢复,从源头断开反馈环。
|
||||
const textWidget = node.widgets?.find((widget) => widget.name === "text");
|
||||
let baseHeight = state.nodeBaseHeight ?? 96;
|
||||
if (textWidget?.element) {
|
||||
const element = textWidget.element;
|
||||
const previousHeight = element.style.height;
|
||||
element.style.height = "auto";
|
||||
const natural = element.scrollHeight;
|
||||
element.style.height = previousHeight;
|
||||
const top = Number.isFinite(textWidget.y) && textWidget.y >= 0 ? textWidget.y : 26;
|
||||
const margin = Math.max(0, Number(textWidget.margin) || 0);
|
||||
if (Number.isFinite(natural) && natural > 0) baseHeight = top + natural + margin * 2;
|
||||
state.nodeBaseHeight = baseHeight;
|
||||
}
|
||||
const targetHeight = inspectorNodeTargetHeight({
|
||||
baseHeight,
|
||||
targetWidgetHeight: state.collapsedWidgetHeight,
|
||||
fallbackBaseHeight: state.nodeBaseHeight ?? 96,
|
||||
minimumHeight: COLLAPSED_NODE_MIN_HEIGHT,
|
||||
});
|
||||
// 高度只补不缩:用户把节点拉大之后,任何一次 render 都不该把它拽回自动高度,
|
||||
// 否则「拉大 → 改一个字 → 被打回原形」,拉大等于白拉。宽度仍按最小可用宽度收敛。
|
||||
const currentHeight = Number(node.size?.[1]) || 0;
|
||||
const nextHeight = Math.max(targetHeight, currentHeight);
|
||||
const sizeChanged = Math.abs((node.size?.[0] ?? 0) - width) > 0.5
|
||||
|| Math.abs(currentHeight - nextHeight) > 0.5;
|
||||
if (sizeChanged) {
|
||||
node.setSize?.([width, nextHeight]);
|
||||
node.graph?.change?.();
|
||||
}
|
||||
node.graph?.setDirtyCanvas?.(true, true);
|
||||
app.graph?.setDirtyCanvas?.(true, true);
|
||||
});
|
||||
};
|
||||
|
||||
// 量出英文标签区「没被 360 压住时」的真实高度:临时解除上限读 scrollHeight 再恢复
|
||||
const measureNaturalEnglishHeight = () => {
|
||||
const previousHeight = englishTokenView.style.height;
|
||||
const previousMax = englishTokenView.style.maxHeight;
|
||||
englishTokenView.style.height = "auto";
|
||||
englishTokenView.style.maxHeight = "none";
|
||||
const natural = englishTokenView.scrollHeight;
|
||||
englishTokenView.style.height = previousHeight;
|
||||
englishTokenView.style.maxHeight = previousMax;
|
||||
return natural;
|
||||
};
|
||||
|
||||
// 打开开关时把节点撑高一次,撑的量 = 被压掉的内容高度,但至少给一个看得出变化的量
|
||||
const growNodeForFill = () => {
|
||||
if (!hasNodeSize()) return;
|
||||
const natural = measureNaturalEnglishHeight();
|
||||
const extra = Math.max(FILL_HEIGHT_MIN_GROWTH, Math.ceil(natural - englishTokenView.offsetHeight));
|
||||
const height = Math.min((Number(node.size?.[1]) || COLLAPSED_NODE_MIN_HEIGHT) + extra, 2400);
|
||||
node.setSize?.([Math.max(node.size?.[0] ?? 0, 590), height]);
|
||||
node.graph?.change?.();
|
||||
};
|
||||
|
||||
const setFillHeight = (next) => {
|
||||
const fill = Boolean(next);
|
||||
if (fill === state.fillHeight) return;
|
||||
state.fillHeight = fill;
|
||||
if (!node.properties || typeof node.properties !== "object") node.properties = {};
|
||||
node.properties.bpiFillHeight = fill;
|
||||
panel.classList.toggle("bpi-fill", fill);
|
||||
fillSwitch.checked = fill;
|
||||
setTitle(fillSwitchRow, fill
|
||||
? "Back to the automatic height; the prompt area stops following the node"
|
||||
: "Let the prompt area grow with the node instead of being capped");
|
||||
if (fill) {
|
||||
// 先放开限制再撑高,撑完重排一次让英文区吃到新空间
|
||||
growNodeForFill();
|
||||
requestNodeResize();
|
||||
} else {
|
||||
state.collapsedWidgetHeight = measureCollapsedWidgetHeight();
|
||||
panel.style.setProperty("--bpi-collapsed-height", `${state.collapsedWidgetHeight}px`);
|
||||
if (hasNodeSize()) {
|
||||
const baseHeight = state.nodeBaseHeight ?? 96;
|
||||
node.setSize?.([Math.max(node.size?.[0] ?? 0, 590), Math.max(inspectorNodeTargetHeight({
|
||||
baseHeight,
|
||||
targetWidgetHeight: state.collapsedWidgetHeight,
|
||||
fallbackBaseHeight: state.nodeBaseHeight ?? 96,
|
||||
minimumHeight: COLLAPSED_NODE_MIN_HEIGHT,
|
||||
}), COLLAPSED_NODE_MIN_HEIGHT)]);
|
||||
node.graph?.change?.();
|
||||
}
|
||||
}
|
||||
store.hooks.render();
|
||||
};
|
||||
|
||||
return {
|
||||
applyEnglishEditorHeight, autoFitGreenArea, finishManualResize, growNodeForFill,
|
||||
hasNodeSize, measureCollapsedWidgetHeight, measureNaturalEnglishHeight, requestNodeResize,
|
||||
resizeCornerHit, resolveDeepestWidget, setFillHeight, startManualResize,
|
||||
};
|
||||
}
|
||||
+1
-1
@@ -71,7 +71,7 @@ const CSS = `
|
||||
.bpi-notif-content{display:flex;flex-direction:column;gap:10px;position:relative;z-index:0}
|
||||
.bpi-notif-head{padding-right:24px}
|
||||
.bpi-notif-title{font-size:15px;font-weight:600;color:var(--bpi-text,#e6edf7);letter-spacing:.3px;line-height:1.35;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.bpi-notif-extra{font-size:13px;color:var(--bpi-text-muted,#aab5c5);line-height:1.65;letter-spacing:.2px;opacity:0;transition:opacity .4s ease .1s;padding-bottom:6px;word-break:break-word}
|
||||
.bpi-notif-extra{font-size:13px;color:var(--bpi-text-muted,#aab5c5);line-height:1.65;letter-spacing:.2px;opacity:0;transition:opacity .4s ease .1s;padding-bottom:6px;word-break:break-word;white-space:pre-wrap}
|
||||
.bpi-notif.bpi-expand .bpi-notif-extra{opacity:1}
|
||||
.bpi-notif-logo{position:absolute;left:22px;top:50%;transform:translateY(-50%);line-height:0;color:#fff;pointer-events:none;user-select:none;filter:drop-shadow(var(--bpi-notif-logo-shadow,0 2px 14px rgba(0,0,0,.55)))}
|
||||
.bpi-notif-logo svg{display:block;height:var(--bpi-notif-logo-size,36px);width:auto}
|
||||
|
||||
+96
-144
@@ -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) {
|
||||
@@ -1295,25 +1167,25 @@ function injectBpiStyles() {
|
||||
.bpi-mirror-delete{position:absolute;top:2px;right:3px;cursor:pointer;padding:1px 3px;border-radius:999px;background:var(--bpi-chip-hide-bg);border:1px solid var(--bpi-border-2);color:var(--bpi-text-muted);font-size:11px;line-height:1;z-index:2}
|
||||
.bpi-mirror-delete:hover{color:var(--bpi-status-error);border-color:var(--bpi-status-error)}
|
||||
/* 删除标签的消失动画(设置里开关,默认关):X 从四角向心长出 → 颜色从中心铺满
|
||||
→ 上下压两条灰边 → 整块从左往右擦掉。只加在正在删的那张卡上,别的卡不受影响 */
|
||||
.bpi-token-card.bpi-deleting{--bpi-del-color:#a78bfa;--bpi-del-bar-color:#5a5a5a;overflow:hidden;pointer-events:none;clip-path:inset(0 0 0 0 round 5px);transition:clip-path .35s cubic-bezier(.4,0,.2,1)}
|
||||
.bpi-token-card.bpi-deleting.bpi-del-wipe{clip-path:inset(0 0 0 100% round 5px)}
|
||||
→ 上下压两条灰边 → 整块从右往左擦掉。只加在正在删的那张卡上,别的卡不受影响 */
|
||||
:is(.bpi-token-card,.bpi-break-chip).bpi-deleting{--bpi-del-color:#a78bfa;--bpi-del-bar-color:#5a5a5a;overflow:hidden;pointer-events:none;clip-path:inset(0 0 0 0 round 5px);transition:clip-path .35s cubic-bezier(.4,0,.2,1)}
|
||||
:is(.bpi-token-card,.bpi-break-chip).bpi-deleting.bpi-del-wipe{clip-path:inset(0 100% 0 0 round 5px)}
|
||||
.bpi-del-stage{position:absolute;inset:0;pointer-events:none}
|
||||
.bpi-del-half{position:absolute;width:var(--bpi-del-len,60px);height:2px;background-color:var(--bpi-del-color);transition:transform .16s cubic-bezier(.4,0,.2,1),height .4s cubic-bezier(.4,0,.2,1)}
|
||||
.bpi-del-half-a1{left:0;top:0;transform-origin:0 0;transform:rotate(var(--bpi-del-angle,30deg)) scaleX(0)}
|
||||
.bpi-del-half-a2{right:0;bottom:0;transform-origin:100% 100%;transform:rotate(var(--bpi-del-angle,30deg)) scaleX(0)}
|
||||
.bpi-del-half-b1{right:0;top:0;transform-origin:100% 0;transform:rotate(calc(-1 * var(--bpi-del-angle,30deg))) scaleX(0)}
|
||||
.bpi-del-half-b2{left:0;bottom:0;transform-origin:0 100%;transform:rotate(calc(-1 * var(--bpi-del-angle,30deg))) scaleX(0)}
|
||||
.bpi-token-card.bpi-del-grow .bpi-del-half-a1{transform:rotate(var(--bpi-del-angle,30deg)) scaleX(1)}
|
||||
.bpi-token-card.bpi-del-grow .bpi-del-half-a2{transform:rotate(var(--bpi-del-angle,30deg)) scaleX(1)}
|
||||
.bpi-token-card.bpi-del-grow .bpi-del-half-b1{transform:rotate(calc(-1 * var(--bpi-del-angle,30deg))) scaleX(1)}
|
||||
.bpi-token-card.bpi-del-grow .bpi-del-half-b2{transform:rotate(calc(-1 * var(--bpi-del-angle,30deg))) scaleX(1)}
|
||||
.bpi-token-card.bpi-del-spread .bpi-del-half{height:var(--bpi-del-fill,60px)}
|
||||
:is(.bpi-token-card,.bpi-break-chip).bpi-del-grow .bpi-del-half-a1{transform:rotate(var(--bpi-del-angle,30deg)) scaleX(1)}
|
||||
:is(.bpi-token-card,.bpi-break-chip).bpi-del-grow .bpi-del-half-a2{transform:rotate(var(--bpi-del-angle,30deg)) scaleX(1)}
|
||||
:is(.bpi-token-card,.bpi-break-chip).bpi-del-grow .bpi-del-half-b1{transform:rotate(calc(-1 * var(--bpi-del-angle,30deg))) scaleX(1)}
|
||||
:is(.bpi-token-card,.bpi-break-chip).bpi-del-grow .bpi-del-half-b2{transform:rotate(calc(-1 * var(--bpi-del-angle,30deg))) scaleX(1)}
|
||||
:is(.bpi-token-card,.bpi-break-chip).bpi-del-spread .bpi-del-half{height:var(--bpi-del-fill,60px)}
|
||||
.bpi-del-cover{position:absolute;inset:0;background-color:var(--bpi-del-color);clip-path:inset(50% 50% 50% 50%);transition:clip-path .4s cubic-bezier(.4,0,.2,1);z-index:3}
|
||||
.bpi-token-card.bpi-del-spread .bpi-del-cover{clip-path:inset(0 0 0 0)}
|
||||
:is(.bpi-token-card,.bpi-break-chip).bpi-del-spread .bpi-del-cover{clip-path:inset(0 0 0 0)}
|
||||
.bpi-del-bar{position:absolute;left:0;right:0;height:0;background-color:var(--bpi-del-bar-color);transition:height .1s ease;z-index:5}
|
||||
.bpi-del-bar-top{top:0}.bpi-del-bar-bottom{bottom:0}
|
||||
.bpi-token-card.bpi-del-wipe .bpi-del-bar{height:var(--bpi-del-bar-h,10px)}
|
||||
:is(.bpi-token-card,.bpi-break-chip).bpi-del-wipe .bpi-del-bar{height:var(--bpi-del-bar-h,10px)}
|
||||
/* 添加标签的出场动画:新卡片从左往右展开,旁边的卡片由 FLIP 滑开让位。
|
||||
带宽高一起淡入,纯靠 clip-path 硬揭开会显得突然 */
|
||||
@keyframes bpi-card-insert{from{clip-path:inset(0 100% 0 0 round 5px);opacity:.45}to{clip-path:inset(0 0 0 0 round 5px);opacity:1}}
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
// 撤销/重做与顺序微调:Shift+Alt+Z / Shift+Alt+Y、Alt+↑ / Alt+↓。
|
||||
//
|
||||
// 撤销栈的条目由各个操作自己 push(文本前后、光标、隐藏/唯一键的快照),
|
||||
// 这里只负责「把最后一条回滚/重放」,以及把快捷键分派过来。
|
||||
import { showToast } from "./bpi_toast.js";
|
||||
|
||||
export function createShortcuts({ store, setHiddenTags, setUniqueKeys }) {
|
||||
const { state, textWidget } = store;
|
||||
const hooks = store.hooks;
|
||||
|
||||
// Alt+ArrowUp / Alt+ArrowDown nudge the pinned token one slot. Returns
|
||||
// true when the key was consumed.
|
||||
const handleTokenReorderShortcut = (event) => {
|
||||
if (!event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return false;
|
||||
if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return false;
|
||||
const token = state.tokens.find((item) => item.id === state.pinned);
|
||||
if (!token) return false;
|
||||
const index = state.tokens.indexOf(token);
|
||||
if (index < 0) return false;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const upward = event.key === "ArrowUp";
|
||||
const moved = hooks.moveTokenTo(token, upward ? index - 1 : index + 2);
|
||||
if (!moved) hooks.setStatus(upward ? `“${token.term}” is already at the front` : `“${token.term}” is already at the back`, "");
|
||||
return true;
|
||||
};
|
||||
|
||||
const undoStructuredEdit = () => {
|
||||
const entry = state.undoStack.at(-1);
|
||||
if (!entry || String(textWidget.value ?? "") !== entry.after) return false;
|
||||
state.undoStack.pop();
|
||||
state.redoStack.push(entry);
|
||||
state.pinned = null;
|
||||
if (typeof entry.beforeCategoryView === "boolean") state.categoryView = entry.beforeCategoryView;
|
||||
if (Array.isArray(entry.hiddenBefore)) setHiddenTags(entry.hiddenBefore);
|
||||
if (Array.isArray(entry.uniqueBefore)) setUniqueKeys(entry.uniqueBefore);
|
||||
hooks.updateText(entry.before, entry.beforeStart);
|
||||
const undoneMsg = `已撤销:${entry.label}`;
|
||||
if (!showToast(undoneMsg, { type: "history" })) hooks.setStatus(undoneMsg, "ok");
|
||||
return true;
|
||||
};
|
||||
|
||||
const redoStructuredEdit = () => {
|
||||
const entry = state.redoStack.at(-1);
|
||||
if (!entry || String(textWidget.value ?? "") !== entry.before) return false;
|
||||
state.redoStack.pop();
|
||||
state.undoStack.push(entry);
|
||||
state.pinned = null;
|
||||
if (typeof entry.afterCategoryView === "boolean") state.categoryView = entry.afterCategoryView;
|
||||
if (Array.isArray(entry.hiddenAfter)) setHiddenTags(entry.hiddenAfter);
|
||||
if (Array.isArray(entry.uniqueAfter)) setUniqueKeys(entry.uniqueAfter);
|
||||
hooks.updateText(entry.after, entry.afterCursor);
|
||||
const redoneMsg = `已重做:${entry.label}`;
|
||||
if (!showToast(redoneMsg, { type: "history" })) hooks.setStatus(redoneMsg, "ok");
|
||||
return true;
|
||||
};
|
||||
|
||||
// 撤销/重做用 Shift+Alt+Z / Shift+Alt+Y:
|
||||
// Ctrl+Z 在 ComfyUI 里被画布自己的撤销吃掉(焦点不在文本框时甚至根本到不了这里),
|
||||
// 与其抢一个抢不过的键,不如整个让给它,换一个没人占的组合。
|
||||
const handleHistoryShortcut = (event) => {
|
||||
if (!event.altKey || !event.shiftKey || event.ctrlKey || event.metaKey) return;
|
||||
const key = event.key.toLowerCase();
|
||||
const handled = key === "y" ? redoStructuredEdit() : key === "z" ? undoStructuredEdit() : false;
|
||||
if (handled) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
handleTokenReorderShortcut, undoStructuredEdit, redoStructuredEdit, handleHistoryShortcut,
|
||||
};
|
||||
}
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
// 节点状态与偏好:每个面板(每个节点)一份,由 createStore 建。
|
||||
//
|
||||
// 为什么要这一个文件:主文件里所有逻辑都在 createPanel 的闭包里,拆功能模块时
|
||||
// 得把 state、render、updateText… 一长串闭包变量当参数传进去。把这些放进 store,
|
||||
// 新模块只要拿一个 store 就能干活,参数表不再随功能膨胀。
|
||||
//
|
||||
// hooks 里的函数由主文件在各处定义完以后通过 bindStoreHooks 填进来,
|
||||
// 所以模块调用时必须写 `store.hooks.render()`,不能提前解构(那时还是空的)。
|
||||
import { loadPreferences } from "./bpi_shared.js";
|
||||
import { panelSyncHub } from "./panel_sync.js";
|
||||
|
||||
// 「适应高度」关掉时面板的高度上限,state 初始化要用,主文件也读它
|
||||
export const COLLAPSED_WIDGET_FALLBACK_HEIGHT = 390;
|
||||
// 节点尺寸的下限与「适应高度」打开时至少撑高的量:节点尺寸模块与主文件都要用,
|
||||
// 放在这里导出,避免两个模块互相 import 造成循环依赖
|
||||
export const FILL_HEIGHT_MIN_GROWTH = 200;
|
||||
export const COLLAPSED_NODE_MIN_HEIGHT = 360;
|
||||
|
||||
export function createStore({ node, textWidget }) {
|
||||
|
||||
// 模式跟着节点存:工作流保存后重开仍是上次选的那一档,没存过就回到自动检测
|
||||
const storedMode = () => {
|
||||
const value = node.properties?.bpiMode;
|
||||
return ["tags", "natural"].includes(value) ? value : "auto";
|
||||
};
|
||||
// 「适应高度」也跟着节点存:工作流重开后仍是上次那个状态
|
||||
const storedFillHeight = () => node.properties?.bpiFillHeight === true;
|
||||
|
||||
const state = {
|
||||
data: { tags: [], builtin: [], user: [], packs: [] },
|
||||
index: new Map(),
|
||||
tokens: [],
|
||||
machine: panelSyncHub.machineTranslations,
|
||||
translating: new Set(),
|
||||
pinned: null,
|
||||
// Ctrl+点出来的多选,存 token.id;pinned 那一个恒在其中
|
||||
selected: new Set(),
|
||||
lastText: null,
|
||||
renderTimer: null,
|
||||
modePreference: storedMode(),
|
||||
fillHeight: storedFillHeight(),
|
||||
modeInfo: { mode: "tags", reason: "Empty input", confidence: "high" },
|
||||
issues: [],
|
||||
localOverrides: new Map(),
|
||||
editing: null,
|
||||
preferences: loadPreferences(),
|
||||
tableFilter: "all",
|
||||
// 明细表查找词:只筛视图,不进任何持久化数据
|
||||
tableQuery: "",
|
||||
filterButtons: new Map(),
|
||||
// 表格里勾选待入库的机器翻译词条,只存 token.key
|
||||
batchSelected: new Set(),
|
||||
// 重复分组(render 里重算)与每个 token 在组里的次序:token.id → { rank, total }
|
||||
duplicateGroups: [],
|
||||
duplicateRanks: new Map(),
|
||||
// 重复栏当前定位到的那一处:{ key, index };-1 表示还没定位过
|
||||
dupFocus: null,
|
||||
dupFocusId: null,
|
||||
searchMatches: [],
|
||||
searchIndex: -1,
|
||||
largeCache: new Map(),
|
||||
largeMisses: new Set(),
|
||||
largePending: new Set(),
|
||||
largeLookupTimer: null,
|
||||
largeLookupGeneration: 0,
|
||||
largeSearchMatches: [],
|
||||
largeSearchQuery: "",
|
||||
largeSearchLoading: false,
|
||||
largeSearchTimer: null,
|
||||
largeSearchGeneration: 0,
|
||||
largeSearchOffset: 0,
|
||||
largeSearchHasMore: false,
|
||||
largeSearchExpandedTerms: [],
|
||||
searchVisibleLimit: 40,
|
||||
lastRenderedSearchQuery: "",
|
||||
categoryView: false,
|
||||
// 「按 Anima 顺序排序」写入后留一份快照 { before, after },按钮据此变成「取消排序」;
|
||||
// 用户又手动改了文本就作废(见 render),避免回退把他后来的改动一起吞掉
|
||||
animaSortSnapshot: null,
|
||||
// 节点标签卡颜色模式:default(不着色)| status(按收录:未收录淡橙、机器翻译淡紫、已收录不变)
|
||||
// | random(从 token_colors.json 摇色)| by_pack(按标签所在词库取 pack_colors)
|
||||
colorMode: localStorage.getItem("bpi.colorMode") || "default",
|
||||
// 从后端读到的 { presets, random_pool, pack_colors }
|
||||
tokenColors: { presets: [], random_pool: [], pack_colors: {} },
|
||||
// 每个卡的双击自定义色,key = token.key;切到非默认模式时被模式覆盖
|
||||
customColors: new Map(),
|
||||
// 随机模式下每个 token.key 摇到的颜色
|
||||
randomColors: new Map(),
|
||||
englishEditing: !String(textWidget.value ?? "").trim(),
|
||||
englishEditingExplicit: false,
|
||||
englishEditorDirty: false,
|
||||
englishClearState: "idle",
|
||||
clearedEnglishEntry: null,
|
||||
assistantBusy: false,
|
||||
undoStack: [],
|
||||
redoStack: [],
|
||||
boundTextarea: null,
|
||||
textareaListeners: null,
|
||||
nodeBaseHeight: null,
|
||||
collapsedWidgetHeight: COLLAPSED_WIDGET_FALLBACK_HEIGHT,
|
||||
resizeFrame: null,
|
||||
manualResize: null,
|
||||
windowPointerUp: null,
|
||||
// 上游输入:是否正在等待用户确认,以及上次渲染时的连线状态
|
||||
upstreamWaiting: false,
|
||||
upstreamConnectedCache: null,
|
||||
syncSource: Symbol(`bpi-node-${node.id ?? "unknown"}`),
|
||||
};
|
||||
|
||||
// 常用回调的占位:值由主文件 bindStoreHooks 填,调用方一律走 store.hooks.xxx()
|
||||
const hooks = {
|
||||
render: () => {},
|
||||
updateText: () => {},
|
||||
setStatus: () => {},
|
||||
applyTokenWeight: () => false,
|
||||
spanForEdit: () => null,
|
||||
refreshDictionary: async () => {},
|
||||
setEnglishEditing: () => {},
|
||||
moveTokenTo: () => false,
|
||||
canReorderTokens: () => false,
|
||||
gapHasBreak: () => false,
|
||||
};
|
||||
|
||||
return { node, textWidget, state, hooks, storedMode, storedFillHeight };
|
||||
}
|
||||
|
||||
export function bindStoreHooks(store, hooks) {
|
||||
Object.assign(store.hooks, hooks);
|
||||
}
|
||||
@@ -50,6 +50,7 @@ export const TOAST_ACCENTS = {
|
||||
history: "#94a3b8",
|
||||
translate: "#34d399",
|
||||
duplicate: "#fbbf24",
|
||||
error: "#e06c75",
|
||||
};
|
||||
|
||||
// 图标是塞进 24×24 viewBox 的内联 SVG 片段,颜色靠 currentColor 吃 --bpi-toast-accent
|
||||
@@ -64,6 +65,7 @@ const ICONS = {
|
||||
remove: '<path d="M4 7h16M9 7V4h6v3"/><path d="M6 7l1 13h10l1-13"/>',
|
||||
move: '<path d="M4 12h16"/><path d="M8 8l-4 4 4 4M16 8l4 4-4 4"/>',
|
||||
history: '<path d="M3.5 12a8.5 8.5 0 1 1 2.6 6.1"/><path d="M3 5v5h5"/><path d="M12 8v4l3 2"/>',
|
||||
error: '<circle cx="12" cy="12" r="9"/><path d="M15 9l-6 6M9 9l6 6"/>',
|
||||
};
|
||||
|
||||
const CLOSE_SVG =
|
||||
|
||||
+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: {}": "连接正常:{}",
|
||||
|
||||
+272
-204
@@ -3,7 +3,7 @@ import { normalizeKey, validateTranslationResult } from "./parser.js";
|
||||
import { mergeImportAsAliases, previewImport, toggleFavorite } from "./dictionary_tools.js";
|
||||
import { panelSyncHub } from "./panel_sync.js";
|
||||
import { installBpiWheelGuard } from "./wheel_guard.js";
|
||||
import { notifyEnabled, setNotifyEnabled } from "./bpi_notify.js";
|
||||
import { notify, notifyEnabled, setNotifyEnabled } from "./bpi_notify.js";
|
||||
import { setToastEnabled, toastEnabled } from "./bpi_toast.js";
|
||||
import {
|
||||
dragPreviewNodeEnabled,
|
||||
@@ -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"],
|
||||
@@ -63,6 +59,8 @@ const SECTIONS = [
|
||||
|
||||
// 「跟随选中节点」的开关状态:默认关,关着就是原来只能在下拉里选节点的行为
|
||||
const FOLLOW_SELECTION_KEY = "bpi.followSelection.v1";
|
||||
// 系统「减少动态效果」已经处理过一次的标记:只做一次,之后用户手动打开就不再覆盖
|
||||
const REDUCED_MOTION_KEY = "bpi.reducedMotion.v1";
|
||||
|
||||
const manager = {
|
||||
data: { tags: [], builtin: [], user: [], packs: [] },
|
||||
@@ -71,8 +69,6 @@ const manager = {
|
||||
section: "tags",
|
||||
loaded: false,
|
||||
favoritesLoaded: false,
|
||||
favorites: [],
|
||||
favoritesChangeHandler: null,
|
||||
tagManagerNodeId: null,
|
||||
largeMatches: [],
|
||||
largeQuery: "",
|
||||
@@ -780,7 +776,7 @@ async function buildAppearanceForm(host, loading) {
|
||||
setText(status, deleteAnimSwitch.checked ? "节点标签区增删动画已开" : "节点标签区增删动画已关");
|
||||
status.dataset.kind = "ok";
|
||||
});
|
||||
const deleteAnimNote = element("div", "bpi-fold-note", "开:删标签时那张卡先被一个大 X 划掉,接着整块从左往右擦走,然后再消失(约 0.9 秒,这期间那张卡点不动);添加标签时新卡片从左往右展开,旁边的标签滑开让位。关掉就是原来那样:点一下直接没、加一个直接出现。");
|
||||
const deleteAnimNote = element("div", "bpi-fold-note", "开:删标签时那张卡先被一个大 X 划掉,接着整块从右往左擦走,然后再消失(约 0.9 秒,这期间那张卡点不动);添加标签时新卡片从左往右展开,旁边的标签滑开让位。关掉就是原来那样:点一下直接没、加一个直接出现。");
|
||||
const foldNote = element("div", "bpi-fold-note", "Operation results show as a popup in the top-right corner instead of the status line.");
|
||||
const toastNote = element("div", "bpi-fold-note", "Per-operation feedback (translation, tag edits, duplicate toggle) shows as a small bar stacked in the bottom-right corner. Turn it off to keep everything in the status line and save a bit of CPU.");
|
||||
// 总开关最后绑定:这时下面几个子开关都已经建好。
|
||||
@@ -918,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}`;
|
||||
@@ -928,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;
|
||||
@@ -957,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";
|
||||
@@ -1124,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 = () => {
|
||||
@@ -1160,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...");
|
||||
@@ -1178,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);
|
||||
@@ -1200,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),这里既不提交也不应用
|
||||
@@ -1243,7 +1428,7 @@ function setSection(section) {
|
||||
if (section === "favorites" && !manager.favoritesLoaded) {
|
||||
manager.favoritesLoaded = true;
|
||||
refs.sections.appendChild(buildFavoritesSection());
|
||||
refreshFavorites();
|
||||
refs.libraryView?.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1251,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)
|
||||
@@ -1322,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) {
|
||||
@@ -1415,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() {
|
||||
@@ -1477,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();
|
||||
@@ -1667,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() {
|
||||
@@ -1795,6 +1840,29 @@ function destroyTab() {
|
||||
refs.tagManagerHost?.replaceChildren?.();
|
||||
}
|
||||
|
||||
// 系统开了「减少动态效果」:第一次进 ComfyUI 时把这几套「纯装饰」动画关掉,只做一次。
|
||||
// 右上角弹窗和右下角提示条不动 —— 它们的入场在 CSS 的 reduced-motion 媒体查询里已经
|
||||
// 压成瞬时了,等于没有动画;关掉开关反而会连操作回执一起消失。
|
||||
function applyReducedMotionDefaults() {
|
||||
if (typeof window.matchMedia !== "function") return;
|
||||
if (!window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
|
||||
try {
|
||||
if (window.localStorage.getItem(REDUCED_MOTION_KEY) === "1") return;
|
||||
window.localStorage.setItem(REDUCED_MOTION_KEY, "1");
|
||||
} catch (error) {
|
||||
return;
|
||||
}
|
||||
const dragNode = dragPreviewNodeEnabled();
|
||||
const dragTable = dragPreviewTableEnabled();
|
||||
const deleteAnim = deleteAnimNodeEnabled();
|
||||
setDragPreviewNodeEnabled(false);
|
||||
setDragPreviewTableEnabled(false);
|
||||
setDeleteAnimNodeEnabled(false);
|
||||
if ((dragNode || dragTable || deleteAnim) && document.body) {
|
||||
notify("检测到系统开启了「减少动态效果」,拖动预览和增删动画已经关掉;需要的话可以在「设置 → 通用」里重新打开。", "notice", { hold: 9000 });
|
||||
}
|
||||
}
|
||||
applyReducedMotionDefaults();
|
||||
injectBpiStyles();
|
||||
installBpiWheelGuard();
|
||||
// 启动时把后端保存的外观 / 语言设置拉下来;语言还会顺带整树重刷一次已渲染的面板
|
||||
|
||||
+83
-13
@@ -114,17 +114,36 @@ export function removePromptToken(text, token) {
|
||||
return { text: value, cursor: start, changed: false };
|
||||
}
|
||||
|
||||
if (end < value.length && (value[end] === "," || isLineBreak(value[end]))) {
|
||||
const separator = value[end];
|
||||
if (end < value.length && value[end] === ",") {
|
||||
// 逗号是标签之间的分隔,跟着标签一起删
|
||||
end += 1;
|
||||
if (separator === "\r" && value[end] === "\n") end += 1;
|
||||
while (end < value.length && /[ \t]/.test(value[end])) end += 1;
|
||||
if (separator === "," && isLineBreak(value[end])) {
|
||||
const firstBreak = value[end];
|
||||
end += 1;
|
||||
if (firstBreak === "\r" && value[end] === "\n") end += 1;
|
||||
while (end < value.length && /[ \t]/.test(value[end])) end += 1;
|
||||
if (isLineBreak(value[end])) {
|
||||
// 行尾标签:这一行前面还有别的标签时,换行是分段标记,必须留着,
|
||||
// 否则下一行会被拽上来、两段并成一段(看起来就是「删标签把换行带走了」)。
|
||||
// 只有整行就剩这一个标签时,才连换行一起删,免得留下空行。
|
||||
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])) {
|
||||
// 标签正好在行尾、后面直接是换行:换行是分段标记,不能跟着删掉,
|
||||
// 否则下一行会被拽上来、两段并成一段。只把标签前面那个逗号清掉
|
||||
let probe = start - 1;
|
||||
while (probe >= 0 && /[ \t]/.test(value[probe])) probe -= 1;
|
||||
if (probe >= 0 && value[probe] === ",") start = probe;
|
||||
} else {
|
||||
let probe = start - 1;
|
||||
while (probe >= 0 && /[ \t]/.test(value[probe])) probe -= 1;
|
||||
@@ -141,6 +160,10 @@ export function removePromptToken(text, token) {
|
||||
|
||||
let left = value.slice(0, start);
|
||||
let right = value.slice(end);
|
||||
// 标签自己独占一行时前后各有一个换行,留两个就会空出一行;合成一个
|
||||
if (/[\r\n]$/.test(left) && /^[\r\n]/.test(right)) {
|
||||
right = right.replace(/^(\r\n|\n|\r)/, "");
|
||||
}
|
||||
if (!left.trim()) {
|
||||
right = right.replace(/^[\s,]+/, "");
|
||||
left = "";
|
||||
@@ -240,7 +263,7 @@ export function replacePromptSpan(text, start, end, replacement) {
|
||||
* present at the drop boundary. Duplicate tags are moved by occurrence via
|
||||
* their start/end offsets, matching removePromptToken.
|
||||
*/
|
||||
export function movePromptToken(text, tokens, token, targetIndex) {
|
||||
export function movePromptToken(text, tokens, token, targetIndex, options = {}) {
|
||||
const value = String(text ?? "");
|
||||
const list = Array.isArray(tokens) ? tokens : [];
|
||||
const fromIndex = list.findIndex((item) =>
|
||||
@@ -254,6 +277,45 @@ export function movePromptToken(text, tokens, token, targetIndex) {
|
||||
if (!Number.isFinite(target)) target = fromIndex;
|
||||
target = Math.max(0, Math.min(count, target));
|
||||
const newIndex = target > fromIndex ? target - 1 : target;
|
||||
// afterBreak:落点是「自己后面那一格」(把行尾标签拖到下一行开头)。
|
||||
// token 顺序没有变化,要把自己后面的换行挪到前面 —— 效果是下移一行。
|
||||
if (options.afterBreak && fromIndex < count - 1) {
|
||||
const tokenEnd = list[fromIndex].end;
|
||||
const nextStart = list[fromIndex + 1].start;
|
||||
const brk = value.slice(tokenEnd, nextStart).match(/[\r\n]+/);
|
||||
const head = value.slice(0, list[fromIndex].start).replace(/[\s,]+$/, "");
|
||||
if (brk && head) {
|
||||
const core = value.slice(list[fromIndex].start, tokenEnd).trim();
|
||||
const rest = value.slice(nextStart).replace(/^[\s,]+/, "");
|
||||
const nextText = `${head}${brk[0]}${core}, ${rest}`;
|
||||
return {
|
||||
text: nextText,
|
||||
cursor: head.length + brk[0].length + core.length,
|
||||
changed: nextText !== value,
|
||||
index: fromIndex,
|
||||
};
|
||||
}
|
||||
}
|
||||
// beforeBreak:落点其实是「上一行末尾」(换行块前面)。被拖的标签自己就站在
|
||||
// 某个行首时,token 顺序并没有变化,但要把挡在它前面的那个换行挪到它后面 ——
|
||||
// 效果就是它上移一行。没有这一条,这种拖动会被当成「没变化」直接吞掉。
|
||||
if (options.beforeBreak && fromIndex > 0) {
|
||||
const prevEnd = list[fromIndex - 1].end;
|
||||
const tokenStart = list[fromIndex].start;
|
||||
const brk = value.slice(prevEnd, tokenStart).match(/[\r\n]+/);
|
||||
if (brk) {
|
||||
const head = value.slice(0, prevEnd).replace(/[\s,]+$/, "");
|
||||
const core = value.slice(tokenStart, list[fromIndex].end).trim();
|
||||
const rest = value.slice(list[fromIndex].end).replace(/^[\s,]+/, "");
|
||||
const nextText = `${head}, ${core}${brk[0]}${rest}`;
|
||||
return {
|
||||
text: nextText,
|
||||
cursor: head.length + 2 + core.length,
|
||||
changed: nextText !== value,
|
||||
index: fromIndex,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (newIndex === fromIndex) {
|
||||
return { text: value, cursor: token.start, changed: false, index: fromIndex };
|
||||
}
|
||||
@@ -315,10 +377,18 @@ export function movePromptToken(text, tokens, token, targetIndex) {
|
||||
for (let p = 0; p < survivorCount; p += 1) {
|
||||
const idx = order[p];
|
||||
if (p === newIndex) {
|
||||
const edge = p === 0 ? prefix : normGap(inEdge(p));
|
||||
out += edge + movedCore;
|
||||
cursor = out.length;
|
||||
out += ", " + coreOf(idx) + trailOf(idx);
|
||||
const gap = p === 0 ? prefix : inEdge(p);
|
||||
if (p > 0 && options.beforeBreak && hasBreak(gap)) {
|
||||
// 这一格的分隔符里带换行:换行是「行尾」,要留在被移过来的标签后面。
|
||||
// 放在它前面的话,拖到行尾(换行块前面)的标签会直接掉到下一行去
|
||||
out += ", " + movedCore + (gap.match(/[\r\n]+/) ?? ["\n"])[0];
|
||||
cursor = out.length;
|
||||
out += coreOf(idx) + trailOf(idx);
|
||||
} else {
|
||||
out += (p === 0 ? prefix : normGap(gap)) + movedCore;
|
||||
cursor = out.length;
|
||||
out += ", " + coreOf(idx) + trailOf(idx);
|
||||
}
|
||||
} else {
|
||||
const lead = keepsLead(p) ? leadOf(idx) : "";
|
||||
out += inEdge(p) + lead + coreOf(idx) + trailOf(idx);
|
||||
|
||||
@@ -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.0",
|
||||
"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.0"
|
||||
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()
|
||||
@@ -0,0 +1,206 @@
|
||||
// 模块依赖体检:拆文件之后最容易犯的错是「引用了没 import / 没传进来 / 目标模块没导出的名字」。
|
||||
// 这类错误 node --check 查不出来(语法合法),浏览器要到模块链接或运行时才炸 —— 这里做四重静态比对:
|
||||
//
|
||||
// 1) 反向:模块里用了「别的模块 export 出来的名字」,却没 import
|
||||
// 2) 正向:模块里用了「主文件闭包里定义的名字」,却没通过参数传进来
|
||||
// 3) 主文件用了「模块内部定义、却没导出」的名字
|
||||
// 4) 链接:import 的名字在目标模块的 export 里找不到,或相对路径的文件不存在
|
||||
//
|
||||
// 字符串与注释先剥掉,避免把文案里的英文单词当成标识符。
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const JS_DIR = new URL("../js/", import.meta.url);
|
||||
const MAIN = new URL("bilingual_prompt.js", JS_DIR);
|
||||
const files = fs.readdirSync(JS_DIR).filter((name) => name.endsWith(".js"));
|
||||
|
||||
function stripLiterals(src) {
|
||||
return src
|
||||
.replace(/\/\*[\s\S]*?\*\//g, "")
|
||||
.replace(/\/\/[^\n]*/g, "")
|
||||
.replace(/`[^`]*`/g, "``")
|
||||
.replace(/"(?:[^"\\]|\\.)*"/g, '""')
|
||||
.replace(/'(?:[^'\\]|\\.)*'/g, "''");
|
||||
}
|
||||
|
||||
// 全仓导出名:模块用了却没 import 的,一定是漏了
|
||||
const exported = new Set();
|
||||
const sources = new Map();
|
||||
for (const file of files) {
|
||||
const raw = fs.readFileSync(new URL(file, JS_DIR), "utf8");
|
||||
sources.set(file, raw);
|
||||
for (const match of raw.matchAll(/export\s+(?:async\s+)?function\s+([A-Za-z_$][\w$]*)/g)) exported.add(match[1]);
|
||||
for (const match of raw.matchAll(/export\s+(?:const|let)\s+([A-Za-z_$][\w$]*)/g)) exported.add(match[1]);
|
||||
for (const match of raw.matchAll(/export\s*\{([^}]*)\}/g)) {
|
||||
for (const name of match[1].split(",")) {
|
||||
const clean = name.trim().split(/\s+as\s+/).pop();
|
||||
if (clean) exported.add(clean);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 主文件闭包里的名字:模块用到了就必须出现在参数表里
|
||||
const mainCode = stripLiterals(fs.readFileSync(MAIN, "utf8"));
|
||||
const closureNames = new Set();
|
||||
for (const match of mainCode.matchAll(/^ (?:const|let|function)\s+([A-Za-z_$][\w$]*)/gm)) closureNames.add(match[1]);
|
||||
for (const match of mainCode.matchAll(/^ const \{([^}]*)\}\s*=/gm)) {
|
||||
for (const name of match[1].split(",")) {
|
||||
const clean = name.trim();
|
||||
if (clean) closureNames.add(clean);
|
||||
}
|
||||
}
|
||||
for (const match of mainCode.matchAll(/function createPanel\(([^)]*)\)/g)) {
|
||||
for (const name of match[1].split(",")) {
|
||||
const clean = name.trim();
|
||||
if (clean) closureNames.add(clean);
|
||||
}
|
||||
}
|
||||
|
||||
const problems = [];
|
||||
let checked = 0;
|
||||
|
||||
for (const file of files) {
|
||||
const raw = sources.get(file);
|
||||
if (!/export function create[A-Za-z]+\(/.test(raw)) continue;
|
||||
checked += 1;
|
||||
|
||||
const imports = new Set();
|
||||
for (const match of raw.matchAll(/import\s*\{([^}]*)\}/g)) {
|
||||
for (const name of match[1].split(",")) {
|
||||
const clean = name.trim();
|
||||
if (clean) imports.add(clean);
|
||||
}
|
||||
}
|
||||
|
||||
const code = stripLiterals(raw);
|
||||
const declared = new Set();
|
||||
for (const match of code.matchAll(/export function create[A-Za-z]+\(\{([^}]*)\}\)/gs)) {
|
||||
for (const name of match[1].split(",")) {
|
||||
const clean = name.trim();
|
||||
if (clean) declared.add(clean);
|
||||
}
|
||||
}
|
||||
for (const match of code.matchAll(/\b(?:const|let|function|var)\s+([A-Za-z_$][\w$]*)/g)) declared.add(match[1]);
|
||||
for (const match of code.matchAll(/\b(?:const|let)\s*\{([^}]*)\}\s*=/g)) {
|
||||
for (const name of match[1].split(",")) {
|
||||
const clean = name.trim().replace(/^\.\.\./, "").split(":").pop().trim();
|
||||
if (clean) declared.add(clean);
|
||||
}
|
||||
}
|
||||
for (const match of code.matchAll(/\(([^()]*)\)\s*=>/g)) {
|
||||
for (const name of match[1].split(",")) {
|
||||
const clean = name.trim().replace(/^\.\.\./, "").split("=")[0].trim();
|
||||
if (clean) declared.add(clean);
|
||||
}
|
||||
}
|
||||
for (const match of code.matchAll(/^\s*([A-Za-z_$][\w$]*)\s*\([^)]*\)\s*\{/gm)) declared.add(match[1]);
|
||||
// 对象字面量的键(duplicateGroups: [] / showToast: () => ...)不是外部引用
|
||||
for (const match of code.matchAll(/(?:^|[{,])\s*([A-Za-z_$][\w$]*)\s*:/gm)) declared.add(match[1]);
|
||||
|
||||
|
||||
const used = new Set();
|
||||
for (const match of code.matchAll(/(?<![.\w$])([A-Za-z_$][\w$]*)/g)) used.add(match[1]);
|
||||
|
||||
const missingImport = [...used].filter(
|
||||
(name) => !imports.has(name) && !declared.has(name) && exported.has(name),
|
||||
);
|
||||
if (missingImport.length) {
|
||||
problems.push(`${file} 用了别处导出的名字却没 import:${missingImport.sort().join(", ")}`);
|
||||
}
|
||||
|
||||
const missingParam = [...used].filter(
|
||||
(name) => closureNames.has(name) && !declared.has(name) && !imports.has(name),
|
||||
);
|
||||
if (missingParam.length) {
|
||||
problems.push(`${file} 用了主文件里的名字却没传进来:${missingParam.sort().join(", ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
assert.deepEqual(problems, [], problems.join("\n"));
|
||||
assert.ok(checked >= 3, `只检查到 ${checked} 个工厂模块,匹配规则可能有问题`);
|
||||
|
||||
// 3) 反向:主文件用了「某个模块内部定义、但没导出」的名字 —— 拆模块时最容易漏的就是这种
|
||||
const moduleLocals = new Map();
|
||||
for (const [file, raw] of sources) {
|
||||
if (file === "bilingual_prompt.js") continue;
|
||||
const code = stripLiterals(raw);
|
||||
const locals = new Set();
|
||||
for (const match of code.matchAll(/\b(?:const|let|function|var)\s+([A-Za-z_$][\w$]*)/g)) locals.add(match[1]);
|
||||
moduleLocals.set(file, locals);
|
||||
}
|
||||
|
||||
const mainImports = new Set();
|
||||
for (const match of mainCode.matchAll(/import\s*\{([^}]*)\}/g)) {
|
||||
for (const name of match[1].split(",")) {
|
||||
const clean = name.trim();
|
||||
if (clean) mainImports.add(clean);
|
||||
}
|
||||
}
|
||||
const mainUsed = new Set();
|
||||
for (const match of mainCode.matchAll(/(?<![.\w$])([A-Za-z_$][\w$]*)/g)) mainUsed.add(match[1]);
|
||||
|
||||
const missingFromMain = new Set();
|
||||
for (const [file, locals] of moduleLocals) {
|
||||
for (const name of locals) {
|
||||
if (exported.has(name) || closureNames.has(name) || mainImports.has(name)) continue;
|
||||
if (mainUsed.has(name)) missingFromMain.add(`${name}(${file} 里定义但没导出)`);
|
||||
}
|
||||
}
|
||||
if (missingFromMain.size) {
|
||||
problems.push(`主文件用了模块内部的名字:${[...missingFromMain].sort().join(", ")}`);
|
||||
}
|
||||
|
||||
// 4) 链接体检:import 的名字必须真的由目标模块导出,相对路径的文件必须存在。
|
||||
// 浏览器在 ESM 链接阶段就会因此让整块扩展加载失败,node --check 与上面三项都查不出来。
|
||||
function stripComments(src) {
|
||||
return src
|
||||
.replace(/\/\*[\s\S]*?\*\//g, "")
|
||||
.replace(/\/\/[^\n]*/g, "");
|
||||
}
|
||||
|
||||
const exportsByFile = new Map();
|
||||
for (const [file, raw] of sources) {
|
||||
const code = stripComments(raw);
|
||||
const names = new Set();
|
||||
for (const match of code.matchAll(/export\s+(?:async\s+)?function\s+([A-Za-z_$][\w$]*)/g)) names.add(match[1]);
|
||||
for (const match of code.matchAll(/export\s+(?:const|let)\s+([A-Za-z_$][\w$]*)/g)) names.add(match[1]);
|
||||
for (const match of code.matchAll(/export\s*\{([^}]*)\}/gs)) {
|
||||
for (const item of match[1].split(",")) {
|
||||
const name = item.trim().split(/\s+as\s+/).pop().trim();
|
||||
if (name) names.add(name);
|
||||
}
|
||||
}
|
||||
exportsByFile.set(file, names);
|
||||
}
|
||||
|
||||
const jsDir = fileURLToPath(JS_DIR);
|
||||
const linkProblems = [];
|
||||
for (const [file, raw] of sources) {
|
||||
const code = stripComments(raw);
|
||||
const imports = [];
|
||||
for (const match of code.matchAll(/import\s*\{([^}]*)\}\s*from\s*["']([^"']+)["']/gs)) {
|
||||
for (const item of match[1].split(",")) {
|
||||
const name = item.trim().split(/\s+as\s+/)[0].trim();
|
||||
if (name) imports.push({ name, spec: match[2] });
|
||||
}
|
||||
}
|
||||
for (const match of code.matchAll(/import\s*["']([^"']+)["']/g)) {
|
||||
imports.push({ name: null, spec: match[1] });
|
||||
}
|
||||
for (const { name, spec } of imports) {
|
||||
const abs = path.resolve(path.dirname(path.join(jsDir, file)), spec);
|
||||
const rel = path.relative(jsDir, abs);
|
||||
if (rel.startsWith("..") || path.isAbsolute(rel)) continue; // ../../scripts/app.js 由宿主提供
|
||||
const target = path.basename(abs);
|
||||
if (!sources.has(target)) {
|
||||
linkProblems.push(`${file}: import 的文件不存在 ${spec}`);
|
||||
} else if (name && !exportsByFile.get(target).has(name)) {
|
||||
linkProblems.push(`${file}: ${spec} 没有导出 ${name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.deepEqual(linkProblems, [], linkProblems.join("\n"));
|
||||
|
||||
console.log(`module deps tests: OK (${checked} files, ${sources.size} modules linked)`);
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
detectInputMode,
|
||||
extractInstructionBody,
|
||||
looksSuspiciousNatural,
|
||||
movePromptToken,
|
||||
parsePrompt,
|
||||
removePromptToken,
|
||||
replacePromptSpan,
|
||||
@@ -220,4 +221,44 @@ assert.equal(parsePrompt("masterpiece", unverified)[0].confidence, "medium");
|
||||
const machineToken = parsePrompt("cinematic", dictionary, new Map([["cinematic", { text: "电影感", source: "prompt-assistant" }]]))[0];
|
||||
assert.equal(machineToken.confidence, "low");
|
||||
|
||||
|
||||
// ── 换行相关的删 / 移(2026-11-07 修的一批问题,这里把行为钉死)─────────────
|
||||
const wrapText = "a, b\nc, d";
|
||||
const wrapTokens = parsePrompt(wrapText, dictionary);
|
||||
const term = (name) => wrapTokens.find((item) => item.term === name);
|
||||
|
||||
// 删行尾标签:换行留着,下一行不被拽上来
|
||||
assert.equal(removePromptToken(wrapText, term("b")).text, "a\nc, d");
|
||||
// 删独占一行的标签:前后换行合成一个,不留空行
|
||||
assert.equal(removePromptToken("a\nb\nc", parsePrompt("a\nb\nc", dictionary)[1]).text, "a\nc");
|
||||
// 删末行标签:结尾不留空行
|
||||
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");
|
||||
// 拖到下一行开头:落在换行后面
|
||||
assert.equal(movePromptToken(wrapText, wrapTokens, term("d"), 2).text, "a, b\nd, c");
|
||||
// 行首标签上移到上一行末尾
|
||||
assert.equal(movePromptToken(wrapText, wrapTokens, term("c"), 2, { beforeBreak: true }).text, "a, b, c\nd");
|
||||
// 行尾标签下移到下一行开头
|
||||
assert.equal(movePromptToken(wrapText, wrapTokens, term("b"), 2, { afterBreak: true }).text, "a\nb, c, d");
|
||||
// 落点就是自己后面但不给标记:视为没变化
|
||||
assert.equal(movePromptToken(wrapText, wrapTokens, term("b"), 2).changed, false);
|
||||
// 移动不应改变标签数量
|
||||
assert.equal(parsePrompt(movePromptToken(wrapText, wrapTokens, term("d"), 2, { beforeBreak: true }).text, dictionary).length, wrapTokens.length);
|
||||
|
||||
console.log("parser tests: OK");
|
||||
|
||||
@@ -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