Compare commits

..
12 Commits
Author SHA1 Message Date
梦圣智杰 8781cbe921 chore: 发布 v2.2.3 / Release v2.2.3
- 修复新版 ComfyUI(前端 1.58+)下不跟随明暗主题。
- 收藏改成文件夹式的库:一个条目一个文件夹、可建文件夹设颜色、拖动排序,旧收藏自动迁移;存放位置挪到插件目录 data/saved,拷贝文件夹即迁移。
- AI 规则可按后端存多套预设,切换、另存为、删除、恢复默认就地完成;改过的规则带记号,未保存的不参与翻译。
- 修复删行尾带逗号标签误删换行、获取模型候选看不到选不了、问号提示框向左溢出三处界面问题。
- 恢复默认改为三步确认;删除参考图开关旁标注保存后生效。
2026-11-11 02:43:09 +08:00
梦圣智杰 bfcb2ebc85 fix: 修复三处界面问题 / Fix three UI issues
- 问号提示框改为从问号右侧展开,不再向左溢出被左侧控件盖住
- 编辑收藏时打开「删除已有参考图」,右侧显示「保存后生效」,关掉开关提示自动消失
- 「恢复默认」改为三步确认
2026-11-11 02:15:03 +08:00
梦圣智杰 a1741c8d8a feat: AI 规则支持多套预设与独立标记 / AI rules support multiple presets and orphan marks
- AI 规则可以按后端分别保存多套预设,切换、另存为、删除、恢复默认都在规则标题行右侧完成
- 每套预设是一个独立文件
- 每条规则都会和内置默认、所有预设比对
- 这个判断放在点「保存并测试连接」时执行
- 标题旁注明「修改需保存后生效;未保存的规则不参与翻译」
2026-11-11 01:41:26 +08:00
梦圣智杰 8e784f3e47 feat: 收藏改成文件式库:树形文件夹、颜色标记、拖动排序 / File-based favorites library with folders and drag sorting
- 收藏变成「一个条目一个文件夹」:文件夹名就是收藏名称,参考图与该文件夹同名,在文件管理器里也能直接看懂
- 可以建文件夹:新建时选「文件夹」,只填名称、备注、使用模型、参考图
- 文件夹能设颜色(8 个预设或随机),里面的条目沿用同一个颜色、但更淡一档
- 拖动可以排序,也可以拖进文件夹;全程没有过渡动画
- 第一次打开会把旧收藏自动搬过来(只搬一次,删空也不会再灌)
2026-11-10 00:41:56 +08:00
梦圣智杰 05800efb44 - 修复设置里获取模型后看不到、也选不了候选(原生 datalist 换成行内候选列表,跨整行显示) 2026-11-09 23:07:23 +08:00
梦圣智杰 eb35d1ee86 - 修复两个问题:新版 ComfyUI 下插件不跟随明暗主题;删除行尾标签(带逗号)时把换行一起删掉
- 修复新版 ComfyUI(前端 1.58+)下插件不跟随明暗主题(改成三路探测,不再依赖 dark-theme 类)
- 明暗判断改为 color-scheme
2026-11-09 13:45:55 +08:00
梦圣智杰 fbddc63fde chore: 发布 v2.2.2 / Release v2.2.2
- 快捷添加的反馈收干净:翻译过程只写状态栏,结果只走右下角;被拒绝时右上角额外贴出模型原样返回的内容。
- 修好删行尾标签会拽掉换行、只改颜色会写入权重 (标签:1) 两个会丢东西的问题。
- 删除动画改成从右往左擦,换行块的 × 也走同一套动画和让位。
- 拖动跨行的落点修正:拖到行尾/下一行开头各归各位,行首上移、行尾下移不再差一行。
- 系统开了「减少动态效果」时,首次进入自动关掉拖动预览与增删动画,只做一次。
- 翻译成功的右下角提示改成中文「已翻译「xxx」」。
2026-11-08 00:31:21 +08:00
梦圣智杰 2be6bd39ff - 技术债_结构拆分
- 新增模块依赖体检测试,防止拆模块时漏 import / 漏传参
2026-11-08 00:24:46 +08:00
梦圣智杰 b6faf77477 - 翻译成功的右下角提示改成中文「已翻译「xxx」」
- 系统开了「减少动态效果」时,首次进入自动关掉拖动预览和增删动画(只做一次,之后手动打开就按你的来)
2026-11-07 09:11:40 +08:00
梦圣智杰 8d77815a02 fix: 换行处理与拖动落点的修复 / Fix line-break handling and drag drop targets
- 快捷添加的反馈只走右下角:翻译过程写状态栏,成功紫色、失败红色;结果被拒绝时右上角额外弹一张卡,带上模型原样返回的内容。
- 编辑标签只改颜色时,不会再把权重写成 (标签:1);只有真的动过权重数字才写回。
- 删除行尾标签时保留换行,下一行不再被拽上来;删除独占一行的标签也不会空出一行。
- 删除动画最后一拍改为从右往左擦;点换行块上的 × 删除换行也走同一套动画和让位。
- 移动标签时的换行方向修正:拖到行尾就落在行尾,拖到下一行开头就落在下一行开头;行首标签拖到上一行末尾会正确上移一行,行尾标签拖到下一行开头会正确下移一行。
- 明细表里的换行行去掉没用的拖动手柄。
2026-11-07 08:42:09 +08:00
梦圣智杰 2066b93b40 chore: 发布 v2.2.1 / Release v2.2.1 2026-11-06 01:35:17 +08:00
梦圣智杰 1e0cf30c7c fix: 双击标签打不开编辑弹窗 / Fix double-click not opening the editor
- README 修了几处排版:demo 图前面误入的零宽度空格和 Tab(会让那行变成代码块、图片不显示)、一处粗体末尾多出的空格(星号会直接显示出来),以及「git安转」的错字。
2026-11-06 01:32:42 +08:00
32 changed files with 4090 additions and 1891 deletions
+5
View File
@@ -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
+9
View File
@@ -39,3 +39,12 @@ tag.sqlite
/output/
*.zip
*.log
#userinfo
bug.md
# 用户收藏库(个人提示词与参考图)
data/saved/
# AI 规则预设(个人提示词,可能包含私有写法)
data/rule_presets/
+68
View File
@@ -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)
### 新增右下角提示条
+14 -8
View File
@@ -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/` 下,插件不做任何上传。
---
+20
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+412
View File
@@ -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 -1
View File
@@ -1,4 +1,4 @@
// 删除标签时的消失动画:X 从四角向心长出 → 颜色从中心铺满整块 → 上下压两条灰边 → 整块从左往右擦掉。
// 删除标签时的消失动画:X 从四角向心长出 → 颜色从中心铺满整块 → 上下压两条灰边 → 整块从右往左擦掉。
// 关掉就是原来的样子:卡片直接消失。这个开关同时管添加动画(见 bpi_insert_anim.js),
// 设置里显示为「标签增删动画(节点标签区)」,默认关。
const KEY = "bpi.deleteAnim.node.v1";
+365
View File
@@ -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;
}
+224
View File
@@ -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 -1
View File
@@ -1,4 +1,4 @@
// 添加标签时的出现动画:新卡片从左往右展开(删除那套手势反过来用),
// 添加标签时的出现动画:新卡片从左往右展开,
// 其余卡片同时滑开让位。开关跟删除共用一个(设置里那个「标签增删动画」),
// 所以这个文件只管播,不管开关。
// 时长比删除短一些,但留足 0.42 秒:太快会看着像「啪」一下跳出来的。
+582
View File
@@ -0,0 +1,582 @@
// 收藏库的树视图:文件夹 + 条目,拖动排序 / 拖进文件夹,默认全部收起。
//
// 几个约定(和存储层对齐):
// - 展开状态只存在内存里:刷新页面才回到「全部收起」,切窗口、关侧边栏都不影响;
// - 拖动不加任何过渡动画,松手直接重排;
// - 缩进只缩一层(12px),不做层层加深,避免长条目被挤成窄条;
// - 文件夹的箭头放在最右边。
import { bpiFetch, button, element, setPlaceholder, setText } from "./bpi_shared.js";
const API_ROOT = "/bpi/library";
// 文件夹的淡色:只影响行的底色,渲染时统一加 12% 透明度(#rrggbb + "1f")
const FOLDER_COLORS = ["#4ca7e8", "#5ec9a3", "#e8b85c", "#cbb1ff", "#ff8b93", "#86d5c4", "#e2ca78", "#7fc4e0"];
function randomFolderColor() {
const hue = Math.floor(Math.random() * 360);
const sat = 0.62;
const light = 0.58;
const channel = (offset) => {
const k = (offset + hue / 30) % 12;
const a = sat * Math.min(light, 1 - light);
const value = light - a * Math.max(-1, Math.min(k - 3, Math.min(9 - k, 1)));
return Math.round(255 * value).toString(16).padStart(2, "0");
};
return `#${channel(0)}${channel(8)}${channel(4)}`;
}
async function readJson(response) {
const payload = await response.json().catch(() => null);
if (!response.ok || !payload || payload.success === false) {
throw new Error(payload?.error || `请求失败(HTTP ${response.status})`);
}
return payload.data;
}
async function fetchTree() {
return readJson(await bpiFetch(API_ROOT, { headers: { Accept: "application/json" } }));
}
async function createItem({ kind, name, text, note, models, imageFile, parent, color }) {
const body = new FormData();
body.append("kind", kind);
body.append("name", name ?? "");
if (text != null) body.append("text", text);
if (note) body.append("note", note);
for (const model of models ?? []) body.append("models", model);
if (parent) body.append("parent", parent);
if (color) body.append("color", color);
if (imageFile) body.append("image", imageFile, imageFile.name || "image");
return readJson(await bpiFetch(API_ROOT, { method: "POST", body }));
}
async function updateItem(id, { name, text, note, models, imageFile, removeImage, color }) {
const body = new FormData();
if (name != null) body.append("name", name);
if (text != null) body.append("text", text);
if (note != null) body.append("note", note ?? "");
for (const model of models ?? []) body.append("models", model);
if (removeImage) body.append("remove_image", "1");
if (color !== undefined) body.append("color", color ?? "");
if (imageFile) body.append("image", imageFile, imageFile.name || "image");
return readJson(await bpiFetch(`${API_ROOT}/${encodeURIComponent(id)}`, { method: "POST", body }));
}
async function moveItem(id, parent, index) {
return readJson(await bpiFetch(`${API_ROOT}/${encodeURIComponent(id)}/move`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ parent: parent ?? null, index }),
}));
}
async function deleteItem(id) {
return readJson(await bpiFetch(`${API_ROOT}/${encodeURIComponent(id)}`, { method: "DELETE" }));
}
function imageUrl(item) {
return `${API_ROOT}/${encodeURIComponent(item.id)}/image?v=${item.updated_at ?? item.created_at ?? 0}`;
}
function parseModels(value) {
return String(value ?? "")
.split(/[,,]/)
.map((part) => part.trim())
.filter(Boolean);
}
// 新建 / 编辑弹窗。类型选「文件夹」时只需要名称、备注、模型、参考图,没有提示词那一块。
export function openLibraryItemDialog({ item = null, parent = null, presetKind = null, presetText = "" }, onSaved) {
const editing = Boolean(item);
const shade = element("div", "bpi-modal-shade");
const modal = element("div", "bpi-modal");
const title = element("h3", "", editing ? "编辑收藏" : "新建收藏");
const form = element("form", "bpi-form");
const error = element("div", "bpi-status");
error.dataset.kind = "error";
error.style.gridColumn = "1 / -1";
let kind = item?.type === "folder" || presetKind === "folder" ? "folder" : "prompt";
const kindLabel = element("label", "", "类型");
const kindSelect = element("select", "bpi-select");
for (const [value, label] of [["folder", "文件夹"], ["prompt", "提示词"]]) {
const option = element("option", "", label);
option.value = value;
kindSelect.appendChild(option);
}
kindSelect.value = kind;
kindSelect.disabled = editing; // 已有的条目不允许改类型
form.append(kindLabel, kindSelect);
const nameLabel = element("label", "", "名称");
const nameInput = element("input");
nameInput.value = item?.name ?? "";
setPlaceholder(nameInput, "必填");
form.append(nameLabel, nameInput);
const noteLabel = element("label", "", "备注");
const noteInput = element("input");
noteInput.value = item?.note ?? "";
setPlaceholder(noteInput, "可选");
form.append(noteLabel, noteInput);
const modelsLabel = element("label", "", "使用模型");
const modelsInput = element("input");
modelsInput.value = Array.isArray(item?.models) ? item.models.join(", ") : "";
setPlaceholder(modelsInput, "可选,逗号分隔");
form.append(modelsLabel, modelsInput);
let pickedColor = item?.color ?? "";
const colorLabel = element("label", "", "颜色");
const colorRow = element("div", "bpi-lib-colors");
const swatches = [];
const syncColor = () => {
for (const swatch of swatches) swatch.classList.toggle("bpi-lib-color-on", swatch.dataset.color === pickedColor);
};
const addSwatch = (value, label) => {
const swatch = element("button", label ? "bpi-lib-color bpi-lib-color-text" : "bpi-lib-color", label ?? "");
swatch.type = "button";
swatch.dataset.color = value ?? "";
if (value) swatch.style.backgroundColor = value;
swatch.addEventListener("click", (event) => {
event.preventDefault();
pickedColor = value ?? "";
syncColor();
});
swatches.push(swatch);
colorRow.appendChild(swatch);
return swatch;
};
for (const color of FOLDER_COLORS) addSwatch(color);
addSwatch("", "无");
const randomSwatch = addSwatch("", "随机");
randomSwatch.addEventListener("click", () => {
// 先清掉旧高亮,再把自己涂成新颜色并选中
const color = randomFolderColor();
randomSwatch.style.backgroundColor = color;
randomSwatch.dataset.color = color;
pickedColor = color;
syncColor();
});
form.append(colorLabel, colorRow);
const textLabel = element("label", "", "提示词");
const textArea = element("textarea", "bpi-save-template");
textArea.value = item?.text ?? presetText ?? "";
textArea.rows = 4;
form.append(textLabel, textArea);
const imageLabel = element("label", "", "参考图");
const drop = element("div", "bpi-save-drop bpi-lib-drop-image");
const preview = element("img", "bpi-save-preview");
const dropText = element("div", "", "拖一张图进来,或点一下选择");
const fileInfo = element("div", "bpi-save-file", "可选;PNG / JPG / WebP,最大 5 MB");
if (item?.image) {
preview.src = imageUrl(item);
preview.style.display = "block";
}
drop.append(preview, dropText, fileInfo);
const picker = element("input");
picker.type = "file";
picker.accept = "image/png,image/jpeg,image/webp";
picker.hidden = true;
form.append(imageLabel, drop);
const removeRow = element("label", "bpi-switch-row");
const removeImageInput = element("input");
removeImageInput.type = "checkbox";
const removeTrack = element("span", "bpi-switch-track");
removeTrack.appendChild(element("span", "bpi-switch-thumb"));
// 打开后变红,旁边提醒一句「保存后生效」——和清除密钥那个开关的写法一致
removeRow.append(
removeImageInput,
removeTrack,
element("span", "bpi-switch-text", "删除已有参考图"),
element("span", "bpi-switch-hint", "保存后生效"),
);
removeRow.style.gridColumn = "1 / -1";
if (item?.image) form.append(removeRow);
form.appendChild(error);
let imageFile = null;
const setImage = (file) => {
imageFile = file;
preview.src = URL.createObjectURL(file);
preview.style.display = "block";
setText(fileInfo, `“${file.name || "图片"}” · ${Math.max(1, Math.round(file.size / 1024))} KB`);
};
drop.addEventListener("click", () => picker.click());
picker.addEventListener("change", () => {
if (picker.files?.[0]) setImage(picker.files[0]);
});
drop.addEventListener("dragover", (event) => {
event.preventDefault();
drop.classList.add("bpi-dragover");
});
drop.addEventListener("dragleave", () => drop.classList.remove("bpi-dragover"));
drop.addEventListener("drop", (event) => {
event.preventDefault();
drop.classList.remove("bpi-dragover");
const file = event.dataTransfer?.files?.[0];
if (file) setImage(file);
});
const syncKind = () => {
const isFolder = kind === "folder";
textLabel.hidden = isFolder;
textArea.hidden = isFolder;
colorLabel.hidden = !isFolder;
colorRow.hidden = !isFolder;
setText(title, editing ? "编辑收藏" : isFolder ? "新建文件夹" : "新建提示词");
};
kindSelect.addEventListener("change", () => {
kind = kindSelect.value;
syncKind();
});
syncKind();
const close = () => shade.remove();
const actions = element("div", "bpi-modal-actions");
const saveButton = button("保存", async () => {
const name = nameInput.value.trim();
if (!name) {
setText(error, "名称不能为空");
return;
}
const text = kind === "prompt" ? textArea.value : null;
if (kind === "prompt" && !String(text ?? "").trim()) {
setText(error, "提示词内容不能为空");
return;
}
saveButton.disabled = true;
try {
const payload = {
name,
text,
note: noteInput.value.trim() || null,
models: parseModels(modelsInput.value),
imageFile,
removeImage: removeImageInput.checked,
color: kind === "folder" ? pickedColor : "",
};
const saved = editing
? await updateItem(item.id, payload)
: await createItem({ ...payload, kind, parent });
close();
window.dispatchEvent(new CustomEvent("bpi:library-changed", { detail: saved }));
onSaved?.(saved);
} catch (failure) {
setText(error, failure.message);
saveButton.disabled = false;
}
}, "bpi-primary");
actions.append(button("取消", close), saveButton);
form.addEventListener("submit", (event) => {
event.preventDefault();
saveButton.click();
});
modal.append(title, form, picker, actions);
shade.appendChild(modal);
document.body.appendChild(shade);
shade.addEventListener("mousedown", (event) => {
if (event.target === shade) close();
});
modal.addEventListener("mousedown", (event) => event.stopPropagation());
requestAnimationFrame(() => nameInput.focus());
return { close };
}
export function createLibraryView({ mount, onLoad, onToast, confirmDelete }) {
let items = [];
let loaded = false;
let dragId = null;
const expanded = new Set(); // 只在内存:刷新页面才回到全部收起
let query = "";
const byId = (id) => items.find((item) => item.id === id) ?? null;
const childrenOf = (parent) => items
.filter((item) => (item.parent ?? null) === (parent ?? null))
.sort((left, right) => (left.order ?? 0) - (right.order ?? 0));
const matches = (item) => {
if (!query) return true;
const haystack = [item.name, item.note, item.text, ...(item.models ?? [])].join("\n").toLowerCase();
return haystack.includes(query);
};
// 搜索时把所有祖先也带出来,否则匹配到的条目会因为没有父级而整条不显示
const visibleIds = () => {
if (!query) return null;
const keep = new Set();
for (const item of items) {
if (!matches(item)) continue;
keep.add(item.id);
let parent = item.parent;
while (parent) {
keep.add(parent);
parent = byId(parent)?.parent ?? null;
}
}
return keep;
};
function rowsFor(parent, depth) {
const keep = visibleIds();
const list = childrenOf(parent).filter((item) => !keep || keep.has(item.id));
const rows = [];
for (const item of list) {
rows.push(item, ...(item.type === "folder" && (expanded.has(item.id) || Boolean(query))
? rowsFor(item.id, depth + 1)
: []));
}
return rows;
}
function rowElement(item) {
const isFolder = item.type === "folder";
const row = element("div", `bpi-lib-row ${isFolder ? "bpi-lib-folder" : "bpi-lib-item"}`);
row.dataset.id = item.id;
row.draggable = true;
// 文件夹的淡色:存的是 #rrggbb,这里补成 12% 透明度铺一层,跟节点里提示词区那种底色一样。
// 文件夹里的条目继承同一个颜色,但更淡一档(5%),一眼能看出它属于哪个组。
if (isFolder && item.color) {
row.style.backgroundColor = `${item.color}1f`;
} else if (!isFolder && item.parent) {
const parentColor = byId(item.parent)?.color;
if (parentColor) row.style.backgroundColor = `${parentColor}0d`;
}
const main = element("div", "bpi-lib-main");
// 缩略图对所有行都放(文件夹也能配参考图),没有图就给虚线占位,
// 这样文件夹与提示词的行高一致,树看起来才是齐的
const thumb = element("span", "bpi-lib-thumb");
if (item.image) thumb.style.backgroundImage = `url("${imageUrl(item)}")`;
else thumb.classList.add("bpi-lib-noimg");
main.appendChild(thumb);
const name = element("span", "bpi-lib-name", item.name ?? "未命名");
if (isFolder) {
main.appendChild(name);
main.appendChild(element("span", "bpi-lib-count", String(childrenOf(item.id).length)));
} else {
// 名称一行、提示词摘要一行:挤在同一行时两边都被截断,反而看不清是什么
const texts = element("div", "bpi-lib-texts");
texts.appendChild(name);
const summary = String(item.text ?? "").replace(/\s+/g, " ").slice(0, 120);
if (summary) texts.appendChild(element("span", "bpi-lib-summary", summary));
main.appendChild(texts);
}
const arrow = element("span", "bpi-lib-arrow", isFolder ? (expanded.has(item.id) ? "▾" : "▸") : "");
if (isFolder) {
arrow.addEventListener("click", (event) => {
event.stopPropagation();
if (expanded.has(item.id)) expanded.delete(item.id);
else expanded.add(item.id);
render();
});
}
const tools = element("span", "bpi-lib-tools");
if (!isFolder) {
tools.append(
button("载入", (event) => {
event.stopPropagation();
onLoad?.(item);
}, "bpi-mini"),
button("复制", async (event) => {
event.stopPropagation();
try {
await navigator.clipboard.writeText(String(item.text ?? ""));
onToast?.("已复制提示词", "ok");
} catch {
onToast?.("浏览器不允许写剪贴板", "error");
}
}, "bpi-mini"),
);
}
tools.append(
button("编辑", (event) => {
event.stopPropagation();
openLibraryItemDialog({ item }, () => refresh());
}, "bpi-mini"),
button("删除", async (event) => {
event.stopPropagation();
const ok = confirmDelete ? await confirmDelete(item) : window.confirm(`删除“${item.name}”?`);
if (!ok) return;
try {
await deleteItem(item.id);
expanded.delete(item.id);
onToast?.("已删除", "ok");
refresh();
} catch (failure) {
onToast?.(failure.message, "error");
}
}, "bpi-mini bpi-danger"),
);
row.append(main, tools, arrow);
if (isFolder) {
row.addEventListener("click", () => {
if (expanded.has(item.id)) expanded.delete(item.id);
else expanded.add(item.id);
render();
});
}
attachDrag(row, item);
return row;
}
function clearDropMarks() {
for (const node of mount.querySelectorAll(".bpi-lib-drop-before, .bpi-lib-drop-after, .bpi-lib-drop-into")) {
node.classList.remove("bpi-lib-drop-before", "bpi-lib-drop-after", "bpi-lib-drop-into");
}
}
function attachDrag(row, item) {
row.addEventListener("dragstart", (event) => {
dragId = item.id;
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", item.id);
row.classList.add("bpi-lib-dragging");
});
row.addEventListener("dragend", () => {
dragId = null;
row.classList.remove("bpi-lib-dragging");
clearDropMarks();
});
row.addEventListener("dragover", (event) => {
if (!dragId || dragId === item.id) return;
event.preventDefault();
event.stopPropagation();
clearDropMarks();
const rect = row.getBoundingClientRect();
const ratio = (event.clientY - rect.top) / Math.max(1, rect.height);
if (item.type === "folder" && ratio > 0.3 && ratio < 0.7) {
row.classList.add("bpi-lib-drop-into");
} else {
row.classList.add(ratio < 0.5 ? "bpi-lib-drop-before" : "bpi-lib-drop-after");
}
});
row.addEventListener("dragleave", () => {
row.classList.remove("bpi-lib-drop-before", "bpi-lib-drop-after", "bpi-lib-drop-into");
});
row.addEventListener("drop", async (event) => {
if (!dragId || dragId === item.id) return;
event.preventDefault();
event.stopPropagation();
const rect = row.getBoundingClientRect();
const ratio = (event.clientY - rect.top) / Math.max(1, rect.height);
const intoFolder = item.type === "folder" && ratio > 0.3 && ratio < 0.7;
clearDropMarks();
const moving = dragId;
dragId = null;
try {
if (intoFolder) {
expanded.add(item.id);
await moveItem(moving, item.id, null);
} else {
const siblings = childrenOf(item.parent).filter((entry) => entry.id !== moving);
const at = siblings.findIndex((entry) => entry.id === item.id);
const index = ratio < 0.5 ? Math.max(0, at) : Math.max(0, at + 1);
await moveItem(moving, item.parent, index);
}
await refresh();
} catch (failure) {
onToast?.(failure.message, "error");
}
});
}
function render() {
mount.replaceChildren();
const rows = rowsFor(null, 0);
if (!items.length) {
mount.appendChild(element("div", "bpi-empty", "还没有收藏。点上面的「新建」建一个文件夹或提示词。"));
return;
}
if (!rows.length) {
mount.appendChild(element("div", "bpi-empty", "没有匹配的收藏。"));
return;
}
for (const item of rows) {
const row = rowElement(item);
const depth = (() => {
let level = 0;
let parent = item.parent;
while (parent) {
level += 1;
parent = byId(parent)?.parent ?? null;
}
return level;
})();
row.style.paddingLeft = `${8 + depth * 12}px`;
mount.appendChild(row);
}
}
let legacyTried = false;
async function refresh() {
try {
let data = await fetchTree();
// 新库第一次用而且空着:把旧版 saved_prompts 里的一次性搬过来(后端有标记,只搬一次)
if (!data?.items?.length && !legacyTried) {
legacyTried = true;
const imported = await readJson(await bpiFetch(`${API_ROOT}/import-legacy`, { method: "POST" })).catch(() => null);
if (imported?.imported > 0) {
data = await fetchTree();
onToast?.(`已从旧收藏导入 ${imported.imported} 条`, "ok");
}
}
items = Array.isArray(data?.items) ? data.items : [];
loaded = true;
render();
} catch (failure) {
mount.replaceChildren(element("div", "bpi-empty", `读取收藏失败:${failure.message}`));
}
}
const onLibraryChanged = () => {
if (!mount.isConnected) {
window.removeEventListener("bpi:library-changed", onLibraryChanged);
return;
}
refresh();
};
window.addEventListener("bpi:library-changed", onLibraryChanged);
// 拖到列表空白处 = 移出到根目录末尾
mount.addEventListener("dragover", (event) => {
if (!dragId) return;
event.preventDefault();
});
mount.addEventListener("drop", async (event) => {
if (!dragId || event.target !== mount) return;
event.preventDefault();
const moving = dragId;
dragId = null;
try {
await moveItem(moving, null, null);
await refresh();
} catch (failure) {
onToast?.(failure.message, "error");
}
});
return {
refresh,
render,
setQuery(value) {
query = String(value ?? "").trim().toLowerCase();
render();
},
openCreate(parent = null) {
openLibraryItemDialog({ parent }, () => refresh());
},
isLoaded: () => loaded,
items: () => items,
};
}
+123
View File
@@ -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,
};
}
+208
View File
@@ -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
View File
@@ -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
View File
@@ -607,134 +607,6 @@ function buildModelPicker({ initial = [], onError } = {}) {
return { root, destroy, models: () => values.slice() };
}
// 保存 / 编辑收藏的弹窗:文本自动带好,图片可拖、可选、可粘贴,不想要就空着。
// 传了 editing(整条收藏)就是编辑:提示词文本放开手改,不选新图则保留原图。
function openSavePromptDialog({ name = "", text = "", note = null, models = [], editing = null } = {}, onSaved) {
const shade = element("div", "bpi-modal-shade");
const modal = element("div", "bpi-modal");
const title = element("h3", "", editing ? "Edit Favorite" : "Save Current Prompt");
const form = element("form", "bpi-form");
const error = element("div", "bpi-status");
error.dataset.kind = "error";
error.style.gridColumn = "1 / -1";
const nameInput = field(form, "Name", "name", (name || text.trim().slice(0, 24)).slice(0, 80), "Defaults to the start of the prompt");
const noteInput = field(form, "Note", "note", note ?? "", "Optional");
const modelPicker = buildModelPicker({ initial: models, onError: (message) => setText(error, message) });
const modelsLabel = element("label", "", "Models");
modelsLabel.htmlFor = "bpi-field-models";
const modelsHint = element("div", "bpi-form-hint", "Press Enter to add; ↑↓ to choose");
modelsHint.style.gridColumn = "1 / -1";
form.append(modelsLabel, modelPicker.root, modelsHint);
const textLabel = element("label", "", "Prompt");
textLabel.htmlFor = "bpi-save-prompt-text";
const textPreview = element("textarea", "");
textPreview.id = "bpi-save-prompt-text";
textPreview.readOnly = !editing; // 新建时文本来自节点,不该手改;编辑时放开
textPreview.value = text;
form.append(textLabel, textPreview);
const imageLabel = element("label", "", "Reference Image");
const drop = element("div", "bpi-save-drop");
const previewImage = element("img", "bpi-save-preview");
const dropText = element("div", "", "Drop an image here, or click to select");
const fileInfo = element("div", "bpi-save-file", editing
? "Keep the current image, or pick a new one to replace it"
: "Optional; PNG / JPG / WebP, max 5 MB");
if (editing?.image) {
previewImage.src = savedPromptImageUrl(editing.id);
previewImage.style.display = "block";
}
drop.append(previewImage, dropText, fileInfo);
const picker = element("input");
picker.type = "file";
picker.accept = "image/png,image/jpeg,image/webp";
picker.hidden = true;
form.append(imageLabel, drop);
form.appendChild(error);
let imageFile = null;
const setImage = (file) => {
imageFile = file;
previewImage.src = URL.createObjectURL(file);
previewImage.style.display = "block";
setText(fileInfo, `“${file.name || "image"}” · ${Math.max(1, Math.round(file.size / 1024))} KB`);
};
drop.addEventListener("click", () => picker.click());
picker.addEventListener("change", () => {
if (picker.files?.[0]) setImage(picker.files[0]);
});
drop.addEventListener("dragover", (event) => {
event.preventDefault();
drop.classList.add("bpi-dragover");
});
drop.addEventListener("dragleave", () => drop.classList.remove("bpi-dragover"));
drop.addEventListener("drop", (event) => {
event.preventDefault();
drop.classList.remove("bpi-dragover");
const file = event.dataTransfer?.files?.[0];
if (file) setImage(file);
});
const onPaste = (event) => {
const file = event.clipboardData?.files?.[0];
if (!file) return;
event.preventDefault();
setImage(file);
};
window.addEventListener("paste", onPaste);
const close = () => {
window.removeEventListener("paste", onPaste);
modelPicker.destroy();
shade.remove();
};
const actions = element("div", "bpi-modal-actions");
const saveButton = button("Save", async () => {
const promptText = editing ? textPreview.value : text;
if (!promptText.trim()) {
setText(error, "Prompt is empty, cannot save");
return;
}
saveButton.disabled = true;
try {
const entry = editing
? await updateSavedPrompt({
id: editing.id,
name: nameInput.value,
text: promptText,
note: noteInput.value.trim() || null,
models: modelPicker.models(),
imageFile,
})
: await createSavedPrompt({
name: nameInput.value,
text: promptText,
note: noteInput.value.trim() || null,
models: modelPicker.models(),
imageFile,
});
window.dispatchEvent(new CustomEvent("bpi:saved-prompts-changed", { detail: entry }));
onSaved?.(entry);
close();
} catch (failure) {
error.textContent = failure.message;
saveButton.disabled = false;
}
}, "bpi-primary");
actions.append(button("Cancel", close), saveButton);
form.addEventListener("submit", (event) => {
event.preventDefault();
saveButton.click();
});
modal.append(title, form, picker, actions);
shade.appendChild(modal);
document.body.appendChild(shade);
shade.addEventListener("mousedown", (event) => {
if (event.target === shade) close();
});
modal.addEventListener("mousedown", (event) => event.stopPropagation());
requestAnimationFrame(() => nameInput.focus());
return { close };
}
// 分类候选:只收词库里真实出现过的 category,用得多的排前面。
// 不预置任何「应该有但还没用过」的分类;中英文混着也不翻译,原样列出。
function tagCategoryCounts(data) {
@@ -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,
+74
View File
@@ -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
View File
@@ -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);
}
+2
View File
@@ -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
View File
@@ -634,9 +634,9 @@ const ZH = {
"Homepage (optional)": "主页(选填)",
"Records source only; no automatic access": "仅记录来源,不会自动访问",
"Translation Rule": "翻译规则",
"Translate & Optimize Rule": "翻译并优化规则",
"Translate & Optimize": "翻译并优化",
"Optimize Rule": "优化规则",
"Natural Language Rule": "自然语言规则",
"Natural Language": "自然语言",
"Optimize with the rule for the current mode: tag mode tidies the tag flow, natural language mode rewrites it as English sentences":
"按当前模式对应的规则优化:标签模式整理标签流,自然语言模式改写为英文自然语句",
"Connection OK: {}": "连接正常:{}",
+272 -204
View File
@@ -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
View File
@@ -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);
+456
View File
@@ -0,0 +1,456 @@
"""收藏库:一个条目一个目录,目录名就是名称,缩略图与目录同名。
为什么这么存(而不是像 saved_prompts 那样一个大 index.json 装文本):
- 用户能在文件管理器里直接看到、改名、备份,目录名一眼就知道是什么;
- 图片与目录同名,缺图/多图一眼能看出来;
- 顺序和父子关系放在 library/index.json 里当缓存,打开面板只读这一个文件,
不扫目录 —— 目录多了也不会卡。
磁盘是真相,index.json 是缓存:被外部改乱时用 rebuild() 重建。
"""
import json
import os
import re
import secrets
import shutil
import tempfile
import time
from pathlib import Path
try: # 包内导入优先;单文件调试时退化成同目录导入
from .saved_prompt_store import sniff_image
except ImportError: # pragma: no cover
from saved_prompt_store import sniff_image
MAX_ITEMS = 2000
MAX_NAME_CHARS = 80
MAX_TEXT_CHARS = 65536
MAX_NOTE_CHARS = 500
MAX_MODELS = 20
MAX_IMAGE_BYTES = 5 * 1024 * 1024
INDEX_VERSION = 1
_ILLEGAL_NAME_CHARS = set('\\/:*?"<>|')
_WINDOWS_RESERVED = {"CON", "PRN", "AUX", "NUL", *(f"COM{i}" for i in range(1, 10)), *(f"LPT{i}" for i in range(1, 10))}
def _default_library_dir():
"""收藏库放在插件目录下的 data/saved:和用户词库、配置在一起,备份时一起带走。"""
return Path(__file__).resolve().parent / "data" / "saved"
def safe_dir_name(name):
"""目录名:去掉 Windows 不允许的字符、首尾的点和空格,避开保留名。"""
cleaned = "".join("_" if ch in _ILLEGAL_NAME_CHARS or ord(ch) < 32 else ch for ch in str(name or ""))
cleaned = cleaned.strip(" .")[:MAX_NAME_CHARS] or "未命名"
if cleaned.upper() in _WINDOWS_RESERVED:
cleaned = f"_{cleaned}"
return cleaned
_COLOR_PATTERN = re.compile(r"^#[0-9a-f]{6}$")
def clean_color(value):
"""文件夹的淡色:只认 #rrggbb(前端渲染时自己加透明度),别的都当没设。"""
if not isinstance(value, str):
return None
text = value.strip().lower()
return text if _COLOR_PATTERN.match(text) else None
def clean_models(value):
"""适用模型:去空白、去重(忽略大小写),最多 MAX_MODELS 个。"""
if not isinstance(value, (list, tuple)):
return []
cleaned, seen = [], set()
for item in value:
if not isinstance(item, str):
continue
label = " ".join(item.split())[:MAX_NAME_CHARS]
if not label:
continue
marker = label.casefold()
if marker in seen:
continue
seen.add(marker)
cleaned.append(label)
if len(cleaned) >= MAX_MODELS:
break
return cleaned
class LibraryStore:
def __init__(self, store_dir=None):
self.store_dir = Path(store_dir or _default_library_dir())
self.index_path = self.store_dir / "index.json"
self._cache = None
self._cache_signature = None
# 建不出来(只读安装、权限不足)也不要在导入阶段就炸:
# 收藏相关接口各自报错,其它功能照常
try:
self.store_dir.mkdir(parents=True, exist_ok=True)
except OSError:
pass
# ---- 索引 -------------------------------------------------------------
def _signature(self):
try:
stat = self.index_path.stat()
except OSError:
return None
return (stat.st_mtime_ns, stat.st_size)
def _read_index_file(self):
try:
payload = json.loads(self.index_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return None
if not isinstance(payload, dict) or not isinstance(payload.get("items"), list):
return None
return payload
def _write_index_file(self, items):
items.sort(key=lambda item: (item.get("order", 0), item.get("created_at", 0)))
payload = {"version": INDEX_VERSION, "updated_at": int(time.time()), "items": items}
self.store_dir.mkdir(parents=True, exist_ok=True)
fd, temp_name = tempfile.mkstemp(prefix="library_", suffix=".json", dir=self.store_dir)
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
json.dump(payload, handle, ensure_ascii=False, indent=2)
handle.write("\n")
os.replace(temp_name, self.index_path)
finally:
if os.path.exists(temp_name):
os.unlink(temp_name)
self._cache = items
self._cache_signature = self._signature()
def _entries(self):
"""取条目列表:优先内存缓存,其次 index.json,最后重建。"""
if self._cache is not None and self._cache_signature == self._signature():
return self._cache
payload = self._read_index_file()
if payload is None:
return self.rebuild()
items = [item for item in payload["items"] if isinstance(item, dict) and isinstance(item.get("id"), str)]
items.sort(key=lambda item: (item.get("order", 0), item.get("created_at", 0)))
self._cache = items
self._cache_signature = self._signature()
return items
def rebuild(self):
"""扫一遍目录重建索引;只在首次、索引损坏或手动触发时调用。"""
items = []
try:
self.store_dir.mkdir(parents=True, exist_ok=True)
children = sorted(self.store_dir.iterdir(), key=lambda path: path.name.casefold())
except OSError:
return items
for entry in children:
if not entry.is_dir() or entry.name.startswith("."):
continue
info = self._read_info(entry)
if not info:
continue
image = self._resolve_image(entry, info)
items.append({
"id": info["id"],
"type": "folder" if info.get("type") == "folder" else "prompt",
"name": info["name"],
"dir": entry.name,
"parent": info.get("parent"),
"order": int(info.get("order") or 0),
"note": info.get("note"),
"models": clean_models(info.get("models")),
"color": clean_color(info.get("color")),
"text": str(info.get("text") or "")[:MAX_TEXT_CHARS],
"image": image,
"created_at": int(info.get("created_at") or 0),
})
items.sort(key=lambda item: (item["order"], item["created_at"]))
self._write_index_file(items)
return items
@staticmethod
def _read_info(directory):
try:
info = json.loads((directory / "info.json").read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return None
if not isinstance(info, dict) or not isinstance(info.get("id"), str):
return None
name = str(info.get("name") or "").strip()[:MAX_NAME_CHARS] or directory.name
info["name"] = name
return info
@staticmethod
def _resolve_image(directory, info):
"""图片名必须与目录名一致才算数:有人手工塞进来一张别的图就不认。"""
recorded = info.get("image")
if not isinstance(recorded, str) or not recorded:
return None
candidate = directory / recorded
if not candidate.is_file():
return None
if Path(recorded).stem.casefold() != directory.name.casefold():
return None
return recorded
# ---- 目录与文件 -------------------------------------------------------
def _unique_dir_name(self, name, entries, current_id=None):
base = safe_dir_name(name)
taken = {item["dir"].casefold() for item in entries if item.get("id") != current_id}
candidate = base
index = 2
while candidate.casefold() in taken or (self.store_dir / candidate).exists():
candidate = f"{base} ({index})"
index += 1
return candidate
def _write_info(self, directory, info):
target = directory / "info.json"
fd, temp_name = tempfile.mkstemp(prefix="info_", suffix=".json", dir=directory)
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
json.dump(info, handle, ensure_ascii=False, indent=2)
handle.write("\n")
os.replace(temp_name, target)
finally:
if os.path.exists(temp_name):
os.unlink(temp_name)
def _write_image(self, directory, dir_name, image_bytes, previous=None):
"""图片与目录同名;替换时先删旧文件,避免留下两张。"""
if previous:
try:
(directory / previous).unlink()
except OSError:
pass
if not image_bytes:
return None
if not isinstance(image_bytes, (bytes, bytearray)) or len(image_bytes) > MAX_IMAGE_BYTES:
raise ValueError(f"图片过大或无效(最大 {MAX_IMAGE_BYTES // (1024 * 1024)} MB)")
suffix = sniff_image(image_bytes)
if suffix is None:
raise ValueError("图片格式仅支持 PNG / JPG / WebP")
image_name = f"{dir_name}{suffix}"
(directory / image_name).write_bytes(image_bytes)
return image_name
def _find(self, item_id, entries=None):
for item in entries or self._entries():
if item["id"] == item_id:
return item
return None
def _require(self, item_id):
item = self._find(item_id)
if not item:
raise KeyError(item_id)
return item
# ---- 对外接口 ---------------------------------------------------------
def tree(self):
"""扁平列表(带 parent/order),前端自己拼树;顺便给出目录根,方便显示路径。"""
return {"root": str(self.store_dir), "items": self._entries()}
def create(self, kind, name, text=None, note=None, models=None, image_bytes=None, parent=None, color=None):
entries = self._entries()
if len(entries) >= MAX_ITEMS:
raise ValueError(f"收藏数量已达上限({MAX_ITEMS})")
name = " ".join(str(name or "").split())[:MAX_NAME_CHARS]
if not name:
raise ValueError("名称不能为空")
kind = "folder" if kind == "folder" else "prompt"
if kind == "prompt" and not str(text or "").strip():
raise ValueError("提示词内容不能为空")
if parent and not any(item["id"] == parent and item["type"] == "folder" for item in entries):
raise ValueError("目标文件夹不存在")
dir_name = self._unique_dir_name(name, entries)
directory = self.store_dir / dir_name
directory.mkdir(parents=True, exist_ok=False)
now = int(time.time())
info = {
"id": secrets.token_hex(8),
"type": kind,
"name": name,
"note": (str(note)[:MAX_NOTE_CHARS] if note else None),
"models": clean_models(models),
"color": clean_color(color),
"created_at": now,
"parent": parent,
"order": max([item["order"] for item in entries if item.get("parent") == parent], default=-1) + 1,
}
if kind == "prompt":
info["text"] = str(text)[:MAX_TEXT_CHARS]
try:
image = self._write_image(directory, dir_name, image_bytes)
except ValueError:
shutil.rmtree(directory, ignore_errors=True)
raise
if image:
info["image"] = image
self._write_info(directory, info)
entries.append({
"id": info["id"], "type": kind, "name": name, "dir": dir_name,
"parent": info["parent"], "order": info["order"],
"note": info["note"], "models": info["models"], "color": info.get("color"),
"text": info.get("text", ""), "image": image, "created_at": now,
})
self._write_index_file(entries)
return next(item for item in entries if item["id"] == info["id"])
def update(self, item_id, name=None, text=None, note=None, models=None, image_bytes=None, remove_image=False, color=None):
entries = self._entries()
item = self._require(item_id)
directory = self.store_dir / item["dir"]
if not directory.is_dir():
raise ValueError("目录已不存在,请刷新收藏列表")
info = self._read_info(directory) or {"id": item_id, "name": item["name"]}
new_name = " ".join(str(name).split())[:MAX_NAME_CHARS] if name is not None else item["name"]
if not new_name:
raise ValueError("名称不能为空")
renamed = new_name != item["name"]
dir_name = item["dir"]
if renamed:
target = self._unique_dir_name(new_name, entries, current_id=item_id)
# 目录名跟着名称走,图片也要跟着改名,保持「目录名 = 图片名」
previous_image = info.get("image")
directory.rename(self.store_dir / target)
directory = self.store_dir / target
dir_name = target
if previous_image:
new_image = f"{target}{Path(previous_image).suffix}"
try:
(directory / previous_image).rename(directory / new_image)
info["image"] = new_image
except OSError:
info["image"] = None
if text is not None and item["type"] == "prompt":
info["text"] = str(text)[:MAX_TEXT_CHARS]
if note is not None:
info["note"] = (str(note)[:MAX_NOTE_CHARS] if note else None)
if models is not None:
info["models"] = clean_models(models)
if color is not None:
info["color"] = clean_color(color)
if remove_image:
previous = info.get("image")
if previous:
try:
(directory / previous).unlink()
except OSError:
pass
info["image"] = None
elif image_bytes is not None:
image = self._write_image(directory, dir_name, image_bytes, previous=info.get("image"))
info["image"] = image
info["name"] = new_name
self._write_info(directory, info)
item.update({
"name": new_name, "dir": dir_name,
"note": info.get("note"), "models": clean_models(info.get("models")),
"color": clean_color(info.get("color")),
"text": info.get("text", item.get("text", "")),
"image": info.get("image"),
})
self._write_index_file(entries)
return item
def move(self, item_id, parent=None, index=None):
entries = self._entries()
item = self._require(item_id)
if item["type"] == "folder" and parent == item_id:
raise ValueError("不能把文件夹拖进自己里面")
if parent:
target = self._find(parent, entries)
if not target or target["type"] != "folder":
raise ValueError("目标文件夹不存在")
if item["type"] == "folder" and target.get("parent") == item_id:
raise ValueError("不能把文件夹拖进自己里面")
siblings = [entry for entry in entries if entry.get("parent") == parent and entry["id"] != item_id]
position = len(siblings) if index is None else max(0, min(int(index), len(siblings)))
siblings.insert(position, item)
for order, sibling in enumerate(siblings):
sibling["order"] = order
item["parent"] = parent
self._write_index_file(entries)
directory = self.store_dir / item["dir"]
info = self._read_info(directory)
if info:
info["parent"] = parent
info["order"] = item["order"]
self._write_info(directory, info)
return item
def delete(self, item_id):
entries = self._entries()
item = self._require(item_id)
directory = self.store_dir / item["dir"]
# 只删库目录下的这一层,避免任何路径穿越
if directory.is_dir() and directory.parent == self.store_dir:
shutil.rmtree(directory, ignore_errors=True)
remaining = [entry for entry in entries if entry["id"] != item_id]
if item["type"] == "folder":
# 文件夹里的东西一起消失,索引里也一并清掉
orphaned = [entry for entry in remaining if entry.get("parent") == item_id]
for entry in orphaned:
child = self.store_dir / entry["dir"]
if child.is_dir() and child.parent == self.store_dir:
shutil.rmtree(child, ignore_errors=True)
remaining = [entry for entry in remaining if entry.get("parent") != item_id]
self._write_index_file(remaining)
return len(remaining)
def import_legacy(self, entries, source_dir):
"""把旧的 saved_prompts 一次性搬进来:名字/文本/备注/模型照搬,图片读成字节重新落盘。
只搬一次:搬过就写 .legacy-imported 标记,之后即使用户把收藏删空也不会再灌一遍。
"""
marker = self.store_dir / ".legacy-imported"
if marker.exists() or self._entries():
return {"imported": 0, "skipped": True}
source = Path(source_dir)
imported = 0
for entry in entries or []:
if not isinstance(entry, dict):
continue
try:
image_bytes = None
image = entry.get("image")
if isinstance(image, str) and image:
candidate = source / image
if candidate.is_file():
image_bytes = candidate.read_bytes()
self.create(
"prompt", entry.get("name"), text=entry.get("text"),
note=entry.get("note"), models=entry.get("models"), image_bytes=image_bytes,
)
imported += 1
except (ValueError, OSError):
continue
marker.write_text("1", encoding="utf-8")
return {"imported": imported, "skipped": False}
def image_path(self, item_id):
item = self._require(item_id)
image = item.get("image")
if not image:
raise ValueError("这条收藏没有配图")
path = self.store_dir / item["dir"] / image
if not path.is_file():
raise ValueError("配图文件已丢失")
return path
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "comfyui-bilingual-prompt-inspector",
"version": "2.2.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
View File
@@ -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" }
+100
View File
@@ -0,0 +1,100 @@
"""AI 规则预设:一套规则一个文件,按后端分目录,方便单独备份、拷贝、给别人。
data/rule_presets/openai/方案A.json
data/rule_presets/ollama/本地 Qwen.json
每个 json 里是四条规则:
{ "translation": "...", "translate_optimize": "...", "optimization": "...", "natural": "..." }
刻意不塞进配置文件的单个 json:一套规则出问题时只影响它自己,也能直接拿去比对/分享。
"""
import json
import os
import tempfile
from pathlib import Path
MAX_NAME_CHARS = 60
MAX_RULE_CHARS = 20000
RULE_KINDS = ("translation", "translate_optimize", "optimization", "natural")
BACKENDS = ("openai", "ollama")
_ILLEGAL_NAME_CHARS = set('\\/:*?"<>|')
def _default_preset_dir():
return Path(__file__).resolve().parent / "data" / "rule_presets"
def safe_preset_name(name):
cleaned = "".join("_" if ch in _ILLEGAL_NAME_CHARS or ord(ch) < 32 else ch for ch in str(name or ""))
cleaned = cleaned.strip(" .")[:MAX_NAME_CHARS]
return cleaned or ""
class RulePresetStore:
def __init__(self, store_dir=None):
self.store_dir = Path(store_dir or _default_preset_dir())
def _backend_dir(self, backend):
name = backend if backend in BACKENDS else BACKENDS[0]
return self.store_dir / name
def list_presets(self, backend):
"""列出某个后端下的全部预设;坏文件跳过,不让一条坏数据挡住整张列表。"""
directory = self._backend_dir(backend)
try:
files = sorted(directory.glob("*.json"), key=lambda path: path.stem.casefold())
except OSError:
return []
presets = []
for path in files:
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
continue
if not isinstance(payload, dict):
continue
rules = {}
for kind in RULE_KINDS:
value = payload.get(kind)
rules[kind] = value[:MAX_RULE_CHARS] if isinstance(value, str) else ""
presets.append({"name": path.stem, "rules": rules})
return presets
def save_preset(self, backend, name, rules):
cleaned = safe_preset_name(name)
if not cleaned:
raise ValueError("预设名称不能为空")
if not isinstance(rules, dict):
raise ValueError("规则格式不对")
payload = {}
for kind in RULE_KINDS:
value = rules.get(kind)
payload[kind] = str(value)[:MAX_RULE_CHARS] if value is not None else ""
directory = self._backend_dir(backend)
try:
directory.mkdir(parents=True, exist_ok=True)
except OSError as error:
raise ValueError(f"无法创建预设目录:{error}") from error
target = directory / f"{cleaned}.json"
fd, temp_name = tempfile.mkstemp(prefix="preset_", suffix=".json", dir=directory)
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
json.dump(payload, handle, ensure_ascii=False, indent=2)
handle.write("\n")
os.replace(temp_name, target)
finally:
if os.path.exists(temp_name):
os.unlink(temp_name)
return {"name": cleaned, "rules": payload}
def delete_preset(self, backend, name):
cleaned = safe_preset_name(name)
if not cleaned:
raise ValueError("预设名称不能为空")
target = self._backend_dir(backend) / f"{cleaned}.json"
if not target.is_file():
raise FileNotFoundError(cleaned)
target.unlink()
return {"deleted": cleaned}
+171
View File
@@ -40,6 +40,8 @@ from .saved_prompt_store import (
MAX_IMAGE_BYTES,
SavedPromptStore,
)
from .library_store import LibraryStore
from .rule_preset_store import RulePresetStore
from .token_color_store import TokenColorStore
@@ -779,6 +781,8 @@ async def cancel_upstream(request):
# ---------------------------------------------------------------------------
saved_prompt_store = SavedPromptStore()
library_store = LibraryStore()
rule_preset_store = RulePresetStore()
# multipart 正文 = 文本字段 + 一张可选图片(图片本身最大 5 MB)
_MAX_SAVED_PROMPT_BYTES = MAX_IMAGE_BYTES + 512 * 1024
@@ -864,6 +868,173 @@ async def saved_prompt_image(request):
return web.FileResponse(path, headers={"Cache-Control": "private, max-age=3600"})
# ---------------------------------------------------------------------------
# 收藏库(树结构):一个条目一个目录,目录名 = 名称,缩略图与目录同名。
# 顺序与父子关系由 library/index.json 当缓存,打开面板不扫目录。
# ---------------------------------------------------------------------------
_LIBRARY_MAX_BYTES = MAX_IMAGE_BYTES + 512 * 1024
async def _read_library_form(request):
form = {"kind": None, "name": None, "text": None, "note": None, "parent": None,
"image_bytes": None, "remove_image": False, "models": [], "color": None}
reader = await request.multipart()
async for part in reader:
if part.name == "image":
form["image_bytes"] = await part.read(decode=False)
elif part.name == "kind":
form["kind"] = await part.text()
elif part.name == "name":
form["name"] = await part.text()
elif part.name == "text":
form["text"] = await part.text()
elif part.name == "note":
form["note"] = await part.text()
elif part.name == "parent":
value = (await part.text()).strip()
form["parent"] = value or None
elif part.name == "remove_image":
form["remove_image"] = (await part.text()).strip() == "1"
elif part.name == "color":
form["color"] = await part.text()
elif part.name == "models":
form["models"].append(await part.text())
return form
@protected_route("get", "/bpi/library")
async def list_library(_request):
return web.json_response({"success": True, "data": library_store.tree()})
@protected_route("post", "/bpi/library")
async def create_library_item(request):
if request.content_length is not None and request.content_length > _LIBRARY_MAX_BYTES:
return error_response("收藏内容过大(图片最大 5 MB)", 413)
try:
form = await _read_library_form(request)
except (ValueError, web.HTTPException) as error:
return error_response(f"读取表单失败:{error}")
try:
item = library_store.create(
form["kind"], form["name"], text=form["text"], note=form["note"],
models=form["models"], image_bytes=form["image_bytes"], parent=form["parent"],
color=form["color"],
)
return web.json_response({"success": True, "data": item})
except ValueError as error:
return error_response(error)
# 放在 {item_id} 之前注册,否则 "rebuild" 会被当成条目 id
@protected_route("post", "/bpi/library/rebuild")
async def rebuild_library(_request):
return web.json_response({"success": True, "data": {"items": library_store.rebuild()}})
@protected_route("post", "/bpi/library/import-legacy")
async def import_legacy_library(_request):
result = library_store.import_legacy(saved_prompt_store.list_prompts(), saved_prompt_store.store_dir)
return web.json_response({"success": True, "data": result})
@protected_route("post", "/bpi/library/{item_id}")
async def update_library_item(request):
if request.content_length is not None and request.content_length > _LIBRARY_MAX_BYTES:
return error_response("收藏内容过大(图片最大 5 MB)", 413)
try:
form = await _read_library_form(request)
except (ValueError, web.HTTPException) as error:
return error_response(f"读取表单失败:{error}")
try:
item = library_store.update(
request.match_info["item_id"], name=form["name"], text=form["text"],
note=form["note"], models=form["models"], image_bytes=form["image_bytes"],
remove_image=form["remove_image"], color=form["color"],
)
return web.json_response({"success": True, "data": item})
except KeyError:
return error_response("条目不存在", 404)
except ValueError as error:
return error_response(error)
@protected_route("delete", "/bpi/library/{item_id}")
async def delete_library_item(request):
try:
remaining = library_store.delete(request.match_info["item_id"])
return web.json_response({"success": True, "data": {"remaining": remaining}})
except KeyError:
return error_response("条目不存在", 404)
@protected_route("post", "/bpi/library/{item_id}/move")
async def move_library_item(request):
try:
payload = await request.json()
except (ValueError, web.HTTPException):
return error_response("请求体必须是 JSON")
try:
item = library_store.move(
request.match_info["item_id"],
parent=(payload.get("parent") or None),
index=payload.get("index"),
)
return web.json_response({"success": True, "data": item})
except KeyError:
return error_response("条目不存在", 404)
except ValueError as error:
return error_response(error)
# 图片用 <img> 加载,自定义请求头带不进去,所以只做同源检查、不走会话令牌。
@PromptServer.instance.routes.get("/bpi/library/{item_id}/image")
async def library_item_image(request):
if not _same_origin(request):
return error_response("拒绝跨源请求", 403)
try:
path = library_store.image_path(request.match_info["item_id"])
except (KeyError, ValueError):
return error_response("配图不存在", 404)
return web.FileResponse(path, headers={"Cache-Control": "private, max-age=3600"})
# ---------------------------------------------------------------------------
# AI 规则预设:一套规则一个 json 文件,按后端分目录(data/rule_presets/<backend>/<name>.json)
# ---------------------------------------------------------------------------
@protected_route("get", "/bpi/rule-presets")
async def list_rule_presets(request):
backend = request.query.get("backend", "openai")
return web.json_response({"success": True, "data": rule_preset_store.list_presets(backend)})
@protected_route("post", "/bpi/rule-presets")
async def save_rule_preset(request):
try:
payload = await request.json()
except (ValueError, web.HTTPException):
return error_response("请求体必须是 JSON")
try:
saved = rule_preset_store.save_preset(payload.get("backend"), payload.get("name"), payload.get("rules"))
return web.json_response({"success": True, "data": saved})
except ValueError as error:
return error_response(error)
@protected_route("delete", "/bpi/rule-presets/{name}")
async def delete_rule_preset(request):
backend = request.query.get("backend", "openai")
try:
result = rule_preset_store.delete_preset(backend, request.match_info["name"])
return web.json_response({"success": True, "data": result})
except FileNotFoundError:
return error_response("预设不存在", 404)
except ValueError as error:
return error_response(error)
@protected_route("get", "/bpi/saved-prompts/export")
async def export_saved_prompts(_request):
return web.json_response({"success": True, "data": saved_prompt_store.export_bundle()})
+1 -1
View File
@@ -88,7 +88,7 @@ for (const [source, expected] of [
["“Optimize” done; please confirm in the preview window", "「优化」完成;请在预览窗口确认"],
["Optimize", "优化"],
["Fit height", "适应高度"],
["Natural Language Rule", "自然语言规则"],
["Natural Language", "自然语言"],
["(Chinese detected; translating to English first)", "(检测到中文,会先译成英文)"],
[
"Optimize with the rule for the current mode: tag mode tidies the tag flow, natural language mode rewrites it as English sentences",
+119
View File
@@ -0,0 +1,119 @@
"""收藏库(library_store)的存储约定:目录名 = 名称、图片与目录同名、索引当缓存。"""
import base64
import shutil
import sys
import tempfile
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
from library_store import LibraryStore, safe_dir_name # noqa: E402
# 1x1 透明 PNG
PNG = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
)
class LibraryStoreTest(unittest.TestCase):
def setUp(self):
self.root = Path(tempfile.mkdtemp(prefix="bpi-lib-"))
self.store = LibraryStore(self.root)
def tearDown(self):
shutil.rmtree(self.root, ignore_errors=True)
def test_create_folder_and_prompt_on_disk(self):
folder = self.store.create("folder", "人物", note="角色类", models=["anima"])
prompt = self.store.create(
"prompt", "森林夜景", text="forest, night", parent=folder["id"], image_bytes=PNG
)
self.assertEqual(folder["dir"], "人物")
self.assertIsNone(folder["image"])
self.assertEqual(prompt["parent"], folder["id"])
self.assertEqual(prompt["image"], "森林夜景.png")
# 目录名与图片名一致,各自带一份 info.json
self.assertTrue((self.root / "森林夜景" / "森林夜景.png").is_file())
self.assertTrue((self.root / "森林夜景" / "info.json").is_file())
self.assertTrue((self.root / "人物" / "info.json").is_file())
self.assertTrue((self.root / "index.json").is_file())
def test_rename_moves_directory_and_image(self):
prompt = self.store.create("prompt", "森林夜景", text="x", image_bytes=PNG)
renamed = self.store.update(prompt["id"], name="雨夜街道")
self.assertEqual(renamed["dir"], "雨夜街道")
self.assertEqual(renamed["image"], "雨夜街道.png")
self.assertFalse((self.root / "森林夜景").exists())
self.assertTrue((self.root / "雨夜街道" / "雨夜街道.png").is_file())
def test_duplicate_names_get_suffix(self):
first = self.store.create("prompt", "同名", text="a")
second = self.store.create("prompt", "同名", text="b")
self.assertNotEqual(first["dir"], second["dir"])
self.assertTrue(second["dir"].startswith("同名 ("))
def test_move_reorders_within_folder(self):
folder = self.store.create("folder", "组")
a = self.store.create("prompt", "A", text="a", parent=folder["id"])
b = self.store.create("prompt", "B", text="b", parent=folder["id"])
self.store.move(b["id"], parent=folder["id"], index=0)
names = [item["name"] for item in self.store.tree()["items"] if item.get("parent") == folder["id"]]
self.assertEqual(names, ["B", "A"])
self.assertEqual(a["order"], 1)
def test_delete_folder_removes_children(self):
folder = self.store.create("folder", "组")
self.store.create("prompt", "A", text="a", parent=folder["id"])
remaining = self.store.delete(folder["id"])
self.assertEqual(remaining, 0)
self.assertFalse((self.root / "组").exists())
self.assertEqual(self.store.tree()["items"], [])
def test_rebuild_drops_image_when_name_mismatch(self):
prompt = self.store.create("prompt", "森林夜景", text="x", image_bytes=PNG)
image = self.root / "森林夜景" / "森林夜景.png"
image.rename(self.root / "森林夜景" / "别的名字.png")
items = self.store.rebuild()
target = next(item for item in items if item["id"] == prompt["id"])
self.assertIsNone(target["image"])
def test_tree_reads_cache_not_directory(self):
self.store.create("prompt", "A", text="a")
# 手写一个假条目进索引:如果不是读索引而是扫目录,它就会消失
payload = self.store._read_index_file() or {"version": 1, "items": []}
payload["items"].append({
"id": "fake", "type": "prompt", "name": "假条目", "dir": "假条目",
"parent": None, "order": 99, "note": None, "models": [], "text": "", "image": None, "created_at": 0,
})
self.store._write_index_file(payload["items"])
names = [item["name"] for item in self.store.tree()["items"]]
self.assertIn("假条目", names)
def test_folder_color_roundtrip(self):
folder = self.store.create("folder", "带色", color="#4ca7e8")
self.assertEqual(folder["color"], "#4ca7e8")
# 非法值当没设
self.store.update(folder["id"], color="red")
again = self.store.tree()["items"][0]
self.assertIsNone(again["color"])
# 重建索引后仍然读得回来
self.store.update(folder["id"], color="#5EC9A3")
items = self.store.rebuild()
self.assertEqual(items[0]["color"], "#5ec9a3")
def test_unsafe_names_are_sanitized(self):
self.assertEqual(safe_dir_name("a/b:c*d"), "a_b_c_d")
self.assertEqual(safe_dir_name(" "), "未命名")
self.assertEqual(safe_dir_name("CON"), "_CON")
if __name__ == "__main__":
unittest.main()
+206
View File
@@ -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)`);
+41
View File
@@ -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");
+74
View File
@@ -0,0 +1,74 @@
"""AI 规则预设:一套规则一个 json,按后端分目录。"""
import json
import shutil
import sys
import tempfile
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
from rule_preset_store import RulePresetStore # noqa: E402
SAMPLE = {
"translation": "translate to chinese",
"translate_optimize": "translate and tidy",
"optimization": "optimize tags",
"natural": "write natural language",
}
class RulePresetStoreTest(unittest.TestCase):
def setUp(self):
self.root = Path(tempfile.mkdtemp(prefix="bpi-rules-"))
self.store = RulePresetStore(self.root)
def tearDown(self):
shutil.rmtree(self.root, ignore_errors=True)
def test_save_and_list_per_backend(self):
self.store.save_preset("openai", "方案A", SAMPLE)
self.store.save_preset("ollama", "本地Qwen", {**SAMPLE, "natural": "local"})
openai = self.store.list_presets("openai")
ollama = self.store.list_presets("ollama")
self.assertEqual([item["name"] for item in openai], ["方案A"])
self.assertEqual([item["name"] for item in ollama], ["本地Qwen"])
self.assertEqual(openai[0]["rules"]["translation"], "translate to chinese")
self.assertEqual(ollama[0]["rules"]["natural"], "local")
def test_each_preset_is_its_own_file(self):
self.store.save_preset("openai", "A", SAMPLE)
self.store.save_preset("openai", "B", SAMPLE)
files = sorted(path.name for path in (self.root / "openai").glob("*.json"))
self.assertEqual(files, ["A.json", "B.json"])
def test_same_name_overwrites(self):
self.store.save_preset("openai", "A", SAMPLE)
self.store.save_preset("openai", "A", {**SAMPLE, "translation": "new"})
presets = self.store.list_presets("openai")
self.assertEqual(len(presets), 1)
self.assertEqual(presets[0]["rules"]["translation"], "new")
def test_delete_removes_file(self):
self.store.save_preset("openai", "A", SAMPLE)
self.store.delete_preset("openai", "A")
self.assertEqual(self.store.list_presets("openai"), [])
def test_broken_file_is_skipped(self):
self.store.save_preset("openai", "good", SAMPLE)
(self.root / "openai" / "broken.json").write_text("{not json", encoding="utf-8")
names = [item["name"] for item in self.store.list_presets("openai")]
self.assertEqual(names, ["good"])
def test_name_sanitized_and_empty_rejected(self):
saved = self.store.save_preset("openai", "a/b:c", SAMPLE)
self.assertEqual(saved["name"], "a_b_c")
with self.assertRaises(ValueError):
self.store.save_preset("openai", " ", SAMPLE)
if __name__ == "__main__":
unittest.main()