Compare commits

...
32 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
梦圣智杰 bf5cf58c23 chore: 发布 v2.2.0 / Release v2.2.0
- 新增右下角操作提示条,按操作类型分色,可在设置里关闭。
- 标签删除与添加加入过渡动画,删掉后其余标签平滑让位。
- 新增「点击节点时跟随切换」:点画布节点,侧边栏自动切过去。
- 拖动时可以实时预览顺序(节点标签区与明细表各一个开关,默认关)。
- 撤销快捷键改为 Shift+Alt+Z / 重做 Shift+Alt+Y,不再占用 Ctrl+Z。
- 右上角弹窗同类操作就地更新,不再一条接一条冒;出错提示停留延长到 8 秒。
- 英文标签小卡的眼睛与删除按钮分居两个角,不再叠在一起。
- 设置面板重新归置:「通用」项收进外观、语言与跟随切换,折叠新增「全部开启」总开关。
2026-11-05 17:08:25 +08:00
梦圣智杰 082f82cacf feat: 试验结束,设置面板定稿 / Finish the settings experiment
- 设置面板里的「语言与外观」改名「通用」,外观、语言、跟随选中节点都收进这一项里。
- 「跟随选中节点」不再单独占一行设置项,挪到「通用」里面、语言设置下面。
- 原来那个 bulingbuling 折叠改名「通知弹窗及实时动画预览」,最上面多了「全部开启」总开关,一次控制弹窗、提示条、两个拖动预览和增删动画。
- README 和架构文档同步了这些改名与位置调整。
2026-11-05 17:00:25 +08:00
梦圣智杰 f4821317ad docs: 更新 README / Update README
- README 按最近的功能改动重新整理了一遍:结构说明、新增的开关和提示条配色都同步了。
2026-11-05 16:44:12 +08:00
梦圣智杰 70b6c3f8be feat: 提示条按操作分色 / Color-code toasts by operation 2026-11-05 15:22:20 +08:00
梦圣智杰 770f5df5e3 merge: 合并 main 的节点 切换跟随/ Merge main into exp/bulingbuling 2026-11-05 15:05:15 +08:00
梦圣智杰 80bd7d6af7 feat: 新增「跟随选中节点」开关,点节点时侧边栏跟着切换 / Add a follow-selection switch so the sidebar follows the clicked node 2026-11-05 15:04:41 +08:00
梦圣智杰 eeddddf27a fix: 修插入到中间时标签跳动 / Fix tags jumping when inserting mid-list 2026-11-05 14:47:00 +08:00
梦圣智杰 14dab702ce feat: 标签增删动画 / Animate adding and removing tag
- 节点里删标签不再是一下子消失:那张卡先被一个大 X 从四个角划掉,整块铺上紫色,上下压出灰边,最后从左往右擦走;擦完剩下的标签滑过来补位。
- 添加标签时,新标签从左往右展开,旁边的标签滑开让位;底部快捷输入和搜词库点结果两条路都算。
- 两个动画共用一个开关
- 目前只做节点标签区(× 角标、选中后按 Delete、重复栏双击删除、底部快捷添加、搜词库点结果),明细表以后另做。
2026-11-04 16:32:04 +08:00
梦圣智杰 83c01742a0 merge: 合并 main 的角标改动 / Merge main into exp/bulingbuling 2026-10-31 08:07:02 +08:00
梦圣智杰 fea43bf66b fix: 隐藏按钮与删除按钮分居卡片两角 / Move the hide and delete corners apart
- 英文标签小卡右上角原来同时放着「眼睛(隐藏)」和「×(删除)」,两个按钮叠在一起容易点错;现在「×」变成卡片右上角的角标,「眼睛」挪到卡片右下角。
- 两个按钮都浮在卡片上、不占位置:卡片大小和以前完全一样,不会因为多了个按钮把标签撑大,也不会把刚好放得下一整行的标签挤到下一行;按钮压到文字时就盖在文字上面。
2026-10-31 07:59:44 +08:00
梦圣智杰 53994df55b feat: 优化错误提示 / Improve error notification 2026-10-31 07:43:04 +08:00
梦圣智杰 8e3f9cea0b feat: 拖动实时预览:拖动时其余标签实时让位 / Live drag preview with real-time reordering
- 新增「拖动实时预览」:拖动标签时其余标签实时让位,松手前就能看到最终顺序;
  节点标签区与明细表各一个开关,互不影响,默认关。关掉时行为与升级前一致
- 预览期间只调整界面上的顺序、不改动提示词文本,松手统一写入,撤销仍是一步
- 节点标签区:被拖的标签抬起跟随光标(不再放大),原位留空,其余标签平滑滑开
- 明细表:不做抬起、放大与半透明淡化,改用左侧实心色条 + 高亮底色 + 亮色文字来标记正在拖动的那一行
- 修复:拖回原位时其余标签不让位
- 修复:拖动途中其余标签疯狂左右闪
2026-10-31 05:49:58 +08:00
梦圣智杰 5065b4bd8f - 侧边栏「标签管理」写中文:「插件撤销:Shift+Alt+Z(重做:Shift+Alt+Y)」。 2026-10-31 03:37:37 +08:00
梦圣智杰 e6083bcd6a Merge branch 'main' into exp/bulingbuling 2026-10-31 03:21:20 +08:00
梦圣智杰 62c90526db fix: 节点内拖拽落点提示只对英文行生效 / Fix drop hint only matching the English row 2026-10-31 02:55:38 +08:00
梦圣智杰 12b0af695e - 弹窗退场时卡片后面多垫了一层:卡片(蓝)先向右滑走,接着是紫红那层,最后才是绿底纸,三层依次离场;平时看不到这层。 2026-10-30 02:16:14 +08:00
梦圣智杰 a15c27c879 feat: 新增右下角提示条,重构操作反馈与撤销提示 / Add bottom-right toast and rework operation feedback
- 新增右下角堆叠提示条:节点增删改、移动、隐藏与恢复、撤销重做、应用与改写、词库操作、翻译逐条结果、重复开关都走它;设置里可单独开关,关掉后退回右上角弹窗或状态栏
- 右上角弹窗改用插件主题配色,入场与退场整体提速
- 弹窗不再一条一冒:连点重复胶囊时右上角提示原地刷新内容、从最后一次点击重新计时;「翻译全部未知」期间只保留一张总进度卡,结束后弹一张新卡显示翻译与拒绝的条数
- 撤销快捷键改为 Shift+Alt+Z、重做 Shift+Alt+Y(Ctrl+Z 留给画布),界面不再提示 Ctrl+Z,侧边栏标签管理上方增加快捷键说明
- 撤销与编辑类提示改为中文,去掉「点眼睛斜线图标取消隐藏」这类冗长解释
2026-10-30 00:08:24 +08:00
梦圣智杰 575ab1814f chore: 发布 v2.1.9 / Release v2.1.9 2026-10-29 00:54:45 +08:00
梦圣智杰 7759128dac fix: 设置面板两项统一靠左对齐 / Align the two settings entries to the left edge
- 「语言与外观」与「助手设置」两块的内容改为顶到最左边,左边缘齐平,看起来是一整块
- 标题仍在:由各自的 h3 显示,收起的只是 ComfyUI 重复渲染出来的那个名称列
2026-10-29 00:51:33 +08:00
梦圣智杰 2cf72b1a10 feat: 助手设置可自动获取模型列表 / Fetch model list automatically in assistant settings
- OpenAI 兼容与 Ollama 两段的「模型名称」旁新增「获取模型」按钮,拉到的名字填进输入框自带的下拉,点箭头即可选择,名称也照旧可以手写
- 下拉挂在输入框上、不弹窗,ComfyUI 设置面板不用关也能用;按键即重新拉取,按钮显示拿到了几个
- 密钥以输入框里填的为准,留空就用已保存的那一份,只随这一次请求发出
- 列表只在本次会话内有效,地址一改自动清空,不写进配置文件
- 服务端返回非 JSON 的内容(纯文本报错、登录页之类)时,界面直接显示原文与状态码
2026-10-29 00:02:32 +08:00
38 changed files with 5901 additions and 1704 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/
+157
View File
@@ -1,5 +1,162 @@
# 更新记录
## 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)
### 新增右下角提示条
- 节点的添加、删除、移动、隐藏与取消隐藏,撤销与重做,应用与改写,词库相关操作,「唯一」开关,以及翻译的逐条结果,现在都在屏幕右下角弹出一条提示条:新的出现在最下面,旧的往上顶,最多同时显示 5 条,过一会儿自己收走,也可以点右上角的 ✕ 立刻关掉。
- 按操作类型分色,一眼能看出刚发生了什么:改内容(应用改写、权重、词条保存等)蓝色,添加标签紫色,删除标签红色,移动标签青色,撤销重做灰色,翻译成功绿色,「唯一」开关橙色。翻译被拒绝的不走这里,仍用右上角的红色弹窗报;每条提示左侧的竖线、图标、出场时抹开的那层颜色都跟着变。
- 底部快捷输入加标签现在也弹提示条了(以前只写节点状态栏),和搜词库加标签走同一种紫色。
- 「设置 → 双语提示词检查器 → 通用」里多了一个「右下角提示条」开关,和原来的「右上角通知弹窗」并排;默认开启。关掉后这些提示退回原来的位置——右上角弹窗开着就走弹窗,都关了就走节点状态栏。
- 担心这点性能开销的可以直接关掉,关掉后行为和你没升级前一样。
### 删标签时的消失动画
- 节点里删标签多了一段过渡:那张卡先被一个大 X 从四个角向中心划掉,接着整块颜色铺满,上下各压出一条灰边,最后从左往右擦掉才真正消失。以前是点一下直接没了。
- 颜色用的是紫色,卡片多长都按实际宽高算,长短标签的动画都贴合卡片自己的边。
- 卡片擦掉之后,剩下的标签会平滑地滑过来补上空位,不再是一下子跳过去。
- 动画大约 0.9 秒,这期间那张卡点不动;真正删除在动画结束时才发生,撤销仍然只有一步。
- 「通用」里那个「通知弹窗及实时动画预览」折叠(以前叫 bulingbuling)多了一个「标签增删动画(节点标签区)」开关,和拖动预览那两个开关并排,默认关。
- 目前只做节点标签区(卡片右上角的 ×、选中后按 Delete、重复栏双击删除都算)
### 添加标签时的出现动画
- 添加标签也有过渡了:新标签从左往右展开出来,旁边的标签平滑滑开让位,不再是一下子出现在那里。底部快捷输入(中文会先翻译再加)和搜词库点结果这两条添加路都算。
- 加和删共用一个开关,就是上面那个「标签增删动画(节点标签区)」:打开两个动画都播,关掉两个都回到原来的样子。
- 加到中间时后面的标签也会平滑让位(之前只有加到末尾才正常,插在前面会跳一下);动画时长 0.42 秒,带一点淡入,看着更连贯。
### 右上角弹窗不再一条接一条地冒
- 连点重复栏里的胶囊时,弹窗不再每次新开一张,而是就地把「第 1 / 3 处」换成「第 2 / 3 处」,并从最后一次点击重新计时 5 秒,方便看清楚;只有手点 ✕ 才会立刻收起。
- 「翻译全部未知」期间右上角只保留一张总进度卡,显示「正在翻译未知标签 3 / 12」;全部译完后进度卡退场,弹出一张新卡显示翻译了多少条、被拒绝了多少条。以前是每译完一条就新冒一张。
- 弹窗整体换成插件的主题配色,入场和退场都比以前快一点;退场时卡片后面还垫着两层纸,卡片先滑走、接着中间那层、最后才是底纸,三层依次向右离场。
- 出错的提示停留时间从 3 秒延长到 8 秒——带原因的长句得读两遍才看清,3 秒一闪就过去了。出错时的配色也整体对调:平时离场那层和底边亮线是蓝色,出错时转成紫红,垫在后面的纸转成蓝。
### 撤销快捷键改为 Shift+Alt+Z / Shift+Alt+Y
- ComfyUI 里 `Ctrl+Z` 会触发画布自己的撤销,插件不再占用它:撤销改成 `Shift+Alt+Z`,重做改成 `Shift+Alt+Y`,只在节点文本框或英文标签区有焦点时生效。
- 界面上所有「按 Ctrl+Z 可撤销」的提示都删掉了,侧边栏「标签管理」上方加了一行小字说明插件自己的快捷键。
- 撤销、重做、删除、移动、隐藏、恢复这类提示全部改成中文,并去掉了「点眼睛斜线图标取消隐藏」这种冗长的解释,弹窗不再被撑得很大。
### 拖动时可以实时预览顺序
- 「设置 → 双语提示词检查器 → 通用」里那个「通知弹窗及实时动画预览」折叠里多了两个开关:「拖动实时预览(节点标签区)」和「拖动实时预览(明细表)」,各管一处、互不影响,默认都关着。关掉时和升级前一模一样:只有落点提示,松手才真正生效。
- 打开后,拖动途中其余标签会实时让位,顺序跟着你走,松手前就能看到最终排布,不用靠猜。
- 预览期间只挪动界面上的顺序,提示词文本一律不动;松手那一刻才统一写入,所以撤销仍然是一步就能回到拖动前。
### 拖动时的外观调整
- 节点标签区:被拖的标签抬起跟随光标,不再放大(原来的放大看着发虚),原位留一个空位,其余标签平滑滑开。
- 明细表:不做抬起、放大和半透明淡化——淡化会把字看糊。改成给正在拖的那一行加左侧实心色条 + 高亮底色 + 亮色文字,一眼看得出是哪一行在被拖;行自己留在原位,只跟着大家一起滑过去。
- 落点提示画在整张卡片上(英文行和下面的中文小块一起亮),拖到中文小块上也认。以前只认卡片上半的英文行,指针压在中文上就判定不到目标,插入提示不出现、落点还会被算到列表末尾去。
### 拖动更稳:修好不让位与疯狂闪烁
- 拖回原位时其余标签不让位:之前误加了「落点等于原位就直接返回」的判断,导致只有拖到相邻两个提示词中间才回得去。现在拖回哪儿都能正常让位。
- 拖动途中其余标签疯狂左右闪:把最后一个拖到最前面、或把长提示词往右拖时最明显。根因是让位动画播到一半就被下一次落点判定打断重来(原来的节流只挡一帧,动画却要跑 220ms),来回振荡。改成等动画播完才解锁下一次判定,另加 10px 滞回带——指针停在两个槽位分界线上抖一两像素不会再触发;动画缩短到 160ms 更跟手。
- 拖动激活阈值调高:节点 4px → 7px、明细表 3px → 6px,轻点着手柄不会误进入拖动。
### 点哪个节点,侧边栏就切到哪个节点
- 以前要改哪个节点的提示词,只能在侧边栏「标签管理」顶部的节点下拉里手动挑。现在多了个「点击节点时跟随切换」开关(默认关闭):设置 → 双语提示词检查器 → 通用 → 点击节点时跟随切换。
- 打开后:在画布上点一个「双语提示词检查器」节点,侧边栏「标签管理」就切到那个节点,下拉和下面的明细表一起换过去;点编码器、采样器这类别的节点不受影响,仍然停在原来那个检查器节点上。
- 侧边栏没打开、或者正停在别的分页时也记着:切回「标签管理」直接就是刚才点的那个节点。
- 关掉和以前一模一样,没有任何变化。开关长在「通用」这一项里、语言设置下面,外观和同处的那几个开关一致;状态存在本机浏览器里,跟着这台机器走,不进工作流。
### 隐藏的眼睛、删除的 × 各占一个角
- 英文标签区的每张小卡,鼠标移上去时右上角会同时出现「眼睛(隐藏)」和「×(删除)」,两个按钮叠在一起容易点错。现在「×」是卡片右上角的角标,不再跟英文标签挤在同一行;「眼睛」挪到卡片右下角,两个按钮不再抢同一个位置。
- 两个角标都浮在卡片上、不占位置:卡片大小和以前完全一样,不会因为多了个按钮把标签撑大、把刚好放得下一整行的标签挤到下一行去;角标压到文字时就是盖在文字上面。
### 设置面板重新归置
- 「语言与外观」改名叫「通用」:外观、语言、点击节点时跟随切换三项都收在这一项里。其中跟随节点原来是单独一项设置,单独放的话 ComfyUI 会按分类名排序、怎么调都排不到想要的位置,所以并进「通用」、排在语言下面。
- 那个叫「bulingbuling」的折叠改名「通知弹窗及实时动画预览」,名字写明白里面到底是什么。
- 折叠最上面多了一个「全部开启」总开关:一次控制右上角弹窗、右下角提示条、两个拖动预览和标签增删动画这五项;单独关掉其中任何一个,总开关会跟着变成未勾选。显存或电脑资源紧张时可以整组关掉,动画会吃一小部分性能。
## v2.1.9(2026-10-29)
### 助手设置里可以自动获取模型列表
- OpenAI 兼容和 Ollama 两段的「模型名称」旁边多了一个「获取模型」按钮:拿输入框里当前填的地址问一次服务端有哪些模型(OpenAI 兼容问 `/models`,Ollama 问 `/api/tags`),拉到的名字填进输入框自带的下拉——点输入框里的箭头就能选,名称也照旧可以手写。
- 下拉用浏览器原生的方式挂在输入框上,不弹任何窗口:助手设置长在 ComfyUI 的「设置」面板里,浮层会被它挡住点不动。
- 再点一次按钮就是重新拉取,按钮上显示拿到了几个;地址改了会自动清空,列表只在本次会话内有效、不写进配置文件。
- 这次请求用你在 API Key 输入框里填的密钥;输入框留空就用已经保存的那一份(密钥仍不会回传给浏览器),只会随这一次请求发出。
- 服务端返回看不懂的内容时(比如缺密钥时 DeepSeek 只回一行纯文本 `Authentication Fails`、或网关甩回来一整页登录页),界面直接显示那句原话和状态码,不再崩一段 `Expecting value: line 1 column 1 (char 0)`。「保存并测试连接」和翻译 / 优化走的是同一套解析,一起修好了。
- 地址照旧走既有的白名单:远端必须 https、内网与链路本地地址驳回、Ollama 只认本机。
### 设置面板两项统一靠左对齐
- 「语言与外观」与「助手设置」两块的内容顶到最左边,左边缘齐平,看起来是一整块。标题仍在,由各自的标题行显示,收起的只是 ComfyUI 重复渲染出来的那个名称列。
## v2.1.8(2026-10-26)
### 新增「适应高度」开关
+79 -20
View File
@@ -1,10 +1,11 @@
# ComfyUI 双语提示词管理器 v2.1.8
# ComfyUI 双语提示词管理器 v2.2.3
> ComfyUI 双语提示词节点:把英文提示词拆成可点击的标签,边写边看中文解释、调顺序、试效果。
<p align="center">
<img src="assets/screenshot-main.png" alt="主界面" width="880">
<img src="https://raw.githubusercontent.com/mengshengzhijie/tuchuan/master/assets/ComfyUI-Bilingual-Prompt-Inspector/screenshot-main.png" alt="主界面" width="880">
</p>
**它解决什么问题**:Anima / Danbooru 风格的提示词是一长串英文标签,写完很难确认模型到底读到了什么。这个节点把提示词变成可选中、可删除、可排序的芯片,每个芯片旁边就是它的中文解释;输出的永远只有英文提示词本身,中文只用于阅读。
---
@@ -18,6 +19,7 @@
- [上游提示词接管](#上游提示词接管)
- [收藏](#收藏)
- [侧边栏管理面板](#侧边栏管理面板)
- [操作反馈](#操作反馈)
- [界面导览](#界面导览)
- [安装](#安装)
- [快速上手](#快速上手)
@@ -36,7 +38,7 @@
<table>
<tr>
<td width="26%"><b>标签化视图</b></td>
<td>英文提示词按标签切成芯片,点一下就和下方的中文解释、明细行联动高亮。看不清楚的标签不用再猜。</td>
<td>英文提示词按标签切成芯片,每张卡片上半是英文、下半就是它的中文解释,点一下还会和侧边栏的明细行联动高亮。看不清楚的标签不用再猜。</td>
</tr>
<tr>
<td><b>编辑 / 标签两种形态</b></td>
@@ -44,11 +46,15 @@
</tr>
<tr>
<td><b>拖拽排序</b></td>
<td>按住标签直接拖,或在明细表里拖 <code>⠿</code> 手柄;也可以选中后用 <code>Alt+↑</code> / <code>Alt+↓</code> 微调。排版不会乱——逗号空格样式保留,换行分段不被合并。</td>
<td>按住标签直接拖,或在明细表里拖 <code>⠿</code> 手柄;也可以选中后用 <code>Alt+↑</code> / <code>Alt+↓</code> 微调。排版不会乱——逗号空格样式保留,换行分段不被合并。开启「拖动实时预览」后,被拖的标签会浮起来跟着光标走,其余标签像手机桌面挪图标那样平滑让开。</td>
</tr>
<tr>
<td><b>临时隐藏</b></td>
<td>点标签上的眼睛图标把它移出实际输出。标签不会从节点里消失,只是加删除线并灰化,照常可以拖动、改色、改权重,但模型完全看不到它。适合做「有 / 没有这个标签」的对比出图,之后逐个或一次性全部恢复。</td>
<td>鼠标移到标签卡片上,右下角会出现眼睛图标,点它把标签移出实际输出。标签不会从节点里消失,只是加删除线并灰化,照常可以拖动、改色、改权重,但模型完全看不到它。适合做「有 / 没有这个标签」的对比出图,之后逐个或一次性全部恢复。</td>
</tr>
<tr>
<td><b>删除标签</b></td>
<td>选中标签后卡片右上角出现 <code>×</code>,点它删除;也可以选中后直接按 <code>Delete</code>。删掉的标签会先被划掉、擦走,再从列表里消失;添加标签时新标签则展开出现——两个过渡共用一个开关,可以关掉。</td>
</tr>
<tr>
<td><b>权重编辑</b></td>
@@ -60,7 +66,7 @@
</tr>
<tr>
<td><b>撤销重做</b></td>
<td>删除、排序、隐藏、权重、清空全部支持 <code>Ctrl+Z</code> / <code>Ctrl+Y</code>。</td>
<td>删除、排序、隐藏、权重、清空全部支持撤销重做,快捷键 <code>Shift+Alt+Z</code> / <code>Shift+Alt+Y</code>(<code>Ctrl+Z</code> 留给 ComfyUI 画布自己的撤销,插件不抢)。快捷键只在节点文本框 / 英文标签区有焦点时生效。</td>
</tr>
<tr>
<td><b>换行分段</b></td>
@@ -117,16 +123,38 @@
- **保存**:点节点上「英文原文(实际输出)」右侧的「收藏」按钮 → 填名字、写备注(可留空)、标上适合的模型、拖入或粘贴一张出图截图 → 保存。保存的是节点当前这份英文文本(包含其中被隐藏的标签)。不在节点上时,也可以从侧边栏「收藏」分页点「新建」,再选一个节点读取文本。
- **适合模型**:输入关键词,会从本机 `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` 文件夹就是完整备份,粘到另一台机器的同一位置即可迁移。
- **旧数据迁移**:从旧版本升级后第一次打开收藏,旧收藏会自动搬进新结构(只搬一次);旧数据原文不动。
> 「载入节点」会先弹窗确认再覆盖节点文本,这个动作**不支持 `Ctrl+Z` 撤销**。
> 「载入节点」会先弹窗确认再覆盖节点文本,这个动作**无法撤销**。
### 侧边栏管理面板
标签管理、词库维护、词库包、收藏四个分页收在侧边栏的「双语提示词管理」面板里,节点本身保持简洁。面板里的改动会实时同步到页面上所有同类节点,不需要刷新页面。
助手设置不在这个面板里:它在 ComfyUI 的「设置」面板中,「设置 → 双语提示词检查器」下有两项 ——「语言与外观」和「助手设置」,表单都直接铺在设置面板右侧。
「标签管理」默认跟着你点开的节点走:在画布上点一个「双语提示词检查器」节点,侧边栏就切到它。不想要这个行为,关掉「设置 → 通用」里的「点击节点时跟随切换」,之后只能在面板顶部的节点下拉里手动选。
助手设置不在这个面板里:它在 ComfyUI 的「设置」面板中,「设置 → 双语提示词检查器」下有两项 ——「通用」和「助手设置」,表单都直接铺在设置面板右侧。
### 操作反馈
每次操作都会给一条回执,按操作类型分色,不用再去状态栏找那一行字:
| 颜色 | 什么时候出现 |
|---|---|
| 蓝色 | 改内容:应用改写、改权重、隐藏 / 取消隐藏、保存词条、导入词库 |
| 紫色 | 添加标签(底部快捷输入、搜词库点结果) |
| 红色 | 删除标签 |
| 青色 | 移动标签 |
| 灰色 | 撤销 / 重做 |
| 绿色 | 翻译成功 |
| 橙色 | 「唯一」开关 |
- **右下角提示条**:新的出现在最下面,旧的往上顶,最多同时显示 5 条,过一会儿自己收走,也可以点 ✕ 立刻关掉。
- **右上角弹窗**:错误、翻译被拒、批量翻译的进度与汇总、重复标签的定位走这里,红色系、停留更久。同类操作会就地更新同一张卡(比如连点重复胶囊是在同一张卡里翻「第 2 / 3 处」),不会一条接一条地冒;批量翻译期间只留一张总进度卡。
- 两个都嫌打扰,可以在「设置 → 通用」里分别关掉「右上角通知弹窗」和「右下角提示条」,关掉后提示退回节点状态栏。
---
@@ -134,12 +162,13 @@
![ComfyUI 双语提示检查器截图](https://raw.githubusercontent.com/mengshengzhijie/tuchuan/master/assets/ComfyUI-Bilingual-Prompt-Inspector/screenshot-main.png)
节点上可以大致分成四块:
节点上可以大致分成三块:
1. **英文原文(实际输出)**——节点真正输出给下游的文本。上面有「编辑文本 / 完成」「清空」「插入换行」「收藏」「优化」「按 Anima 顺序排序」几个按钮。
2. **中文对照区**——英文标签逐条对应的中文,只用于阅读,不进入输出,也不会传给下游。
3. **明细表**——每行是一个标签:来源、翻译、权重、分类,右侧按行状态给出操作(翻译 / 确认并存 / 重新翻译 / 编辑中文 / 收藏 / 复制 / 删除个人词条)。上方有搜索框、模式选择、颜色模式、筛选(全部 / 未知 / 机器翻译 / 个人词库 / 收藏)和问题提示。
4. **快捷添加**——底部输入框,中文进英文出(自动翻译后追加),英文则直接追加。
2. **标签区**——每张卡片上半是英文标签、下半是它的中文解释,只用于阅读,不进入输出,也不会传给下游。标签多了这里会自己滚动,也可以在明细表上方的筛选栏打开「适应高度」,让它跟着节点一起变高。
3. **快捷添加**——底部输入框,中文进英文出(自动翻译后追加),英文则直接追加。
**明细表不在节点上**:它被侧边栏的「标签管理」借走了(节点旁边的画布空间要留给画面)。那里每行一个标签,列出每行的来源、翻译、权重、分类,右侧按行状态给出操作(翻译 / 确认并存 / 重新翻译 / 编辑中文 / 收藏 / 复制 / 删除个人词条);上方有搜索框、模式选择、颜色模式、筛选(全部 / 未知 / 机器翻译 / 个人词库 / 收藏)和问题提示。
---
@@ -161,7 +190,13 @@ cd ComfyUI/custom_nodes/
git clone --depth 1 https://github.com/mengshengzhijie/ComfyUI-Bilingual-Prompt-Inspector.git
```
不过这里如果有动手能力的话推荐使用git安转,因为现在就我和我的朋友在使用,有时候修复bug和制作新功能我是总结下来然后一步一步的修复。直到把这次收录的bug和建议给完成之后才bump版本。
**如果网络受限也可以使用 gitee,或者用上神奇的 `-c`**:
```bash
git clone https://gitee.com/MengShengZhiJie/ComfyUI-Bilingual-Prompt-Inspector.git
```
不过这里如果有动手能力的话推荐使用 git 安装,因为现在就我和我的朋友在使用,有时候修复bug和制作新功能我是总结下来然后一步一步的修复。直到把这次收录的bug和建议给完成之后才bump版本。
重启 ComfyUI,在节点菜单的 **text/prompt** 分类里找到 **提示词翻译与管理(英文输出)**(英文界面下显示为 Prompt Translator & Manager),也可以直接搜索「提示词」。
@@ -179,7 +214,7 @@ git clone --depth 1 https://github.com/mengshengzhijie/ComfyUI-Bilingual-Prompt-
1. 把节点接到 CLIP 文本编码器上,和普通提示词节点一样。
2. 在「英文原文」里写提示词,或点「编辑文本」写完后点「完成」。
3. 在标签视图里逐个检查:点标签看中文解释,双击调权重,拖动手柄排顺序。
4. 想确认某个标签到底有没有用?点眼睛图标隐藏它,重新出图对比,再一键恢复。
4. 想确认某个标签到底有没有用?鼠标移到标签卡片上,点右下角那颗眼睛隐藏它,重新出图对比,再一键恢复。
![Demo — run](https://raw.githubusercontent.com/mengshengzhijie/tuchuan/master/assets/ComfyUI-Bilingual-Prompt-Inspector/demo-run.gif)
@@ -199,7 +234,11 @@ git clone --depth 1 https://github.com/mengshengzhijie/ComfyUI-Bilingual-Prompt-
| ↳ OpenAI 兼容 | API 地址、模型、可选 API Key、温度、超时,另有「使用 LM Studio 本地预设」一键填入 `1234/v1`。远端地址必须 https,本机回环(127.0.0.1 / localhost)才允许 http;内网、保留、链路本地地址一律拒绝 |
| ↳ Ollama | API 地址、模型、温度、超时,没有密钥,另有「使用 Ollama 本地预设」一键填入 `11434`。地址只能是本机回环地址,其它一律拒绝 |
两个后端还各有一套规则(翻译 / 翻译并优化 / 优化 / 自然语言):本地小模型与云端大模型要写的提示词往往不一样,分开存可以随时切换后端而不用重改规则。其中「自然语言」那套只在自然语言模式下被「优化」使用,结果按完整英文句子保留标点,不套 Anima 的标点限制。地址不合规时会在发出请求前直接报错。
模型名称旁边的「获取模型」按钮会拿输入框里当前填的地址问一次服务端有哪些模型(OpenAI 兼容问 `/models`,Ollama 问 `/api/tags`),拉到的名字显示成输入框下方的行内候选列表——跨整行显示、点一下就填进去,也可以完全不管它自己手写。再点一次按钮就是重新拉取。列表只在本次会话内有效、地址一改就作废,不写进配置文件。密钥以输入框里填的为准,输入框留空就用你已经保存的那一份;无论哪种都只随这一次请求发出,不会随着设置一起回传给浏览器。服务端返回看不懂的内容(纯文本报错、登录页之类)时会原话告诉你,不会甩一段 JSON 报错。
两个后端各有自己的规则编辑器(翻译 / 翻译并优化 / 优化 / 自然语言):本地小模型与云端大模型要写的提示词往往不一样,分开存可以随时切换后端而不用重改规则。其中「自然语言」那套只在自然语言模式下被「优化」使用,结果按完整英文句子保留标点,不套 Anima 的标点限制。地址不合规时会在发出请求前直接报错。
规则还能按后端存多套预设:规则区标题右侧的下拉选一套已存的预设,旁边「另存为 / 删除 / 恢复默认」就地完成——比如同一个后端留一套「直译」一套「精修」,随时切。编辑器里手改过的内容会被标出来(和内置默认、所有预设都对不上的那条会带记号),提醒你还没存进任何一套;改完记得点保存,未保存的规则不参与翻译。「恢复默认」要点三次才真正执行。预设文件存放在插件目录的 `data/rule_presets/`。
### 大型词库(可选)
@@ -207,7 +246,20 @@ git clone --depth 1 https://github.com/mengshengzhijie/ComfyUI-Bilingual-Prompt-
### 界面语言与外观
界面文案跟随 ComfyUI 的语言设置,也可以在「设置 → 双语提示词检查器 → 语言与外观」里固定为中文或英文。外观同样可在「跟随 ComfyUI / 暗色 / 亮色」之间切换,两项改完立即生效并单独保存。
界面文案跟随 ComfyUI 的语言设置,也可以在「设置 → 双语提示词检查器 → 通用」里固定为中文或英文。外观同样可在「跟随 ComfyUI / 暗色 / 亮色」之间切换,两项改完立即生效并单独保存。
这个折叠(「通知弹窗及实时动画预览」)里还收着几个手感和提示相关的开关,最上面另有一个「全部开启」总开关,一次控制下面这几项;各项都默认关闭,关掉就是原来那样:
| 开关 | 管什么 |
|---|---|
| 右上角通知弹窗 | 错误、被拒、批量翻译进度是否用右上角弹窗报 |
| 右下角提示条 | 操作回执是否用右下角提示条显示 |
| 拖动实时预览(节点标签区 / 明细表) | 拖动时其余标签是否实时让位,两个区域各管各的 |
| 标签增删动画(节点标签区) | 删除标签是否划掉擦走、添加标签是否展开出现 |
折叠上方还有一项「点击节点时跟随切换」:点画布上的节点时,侧边栏「标签管理」是否跟着切过去。
系统开了「减少动态效果」的话,第一次进入会把上面这几个纯装饰性的动画(两个拖动预览、标签增删动画)自动关掉,并在右上角说明一句。只做这一次,之后你手动打开就按你的来;右上角弹窗和右下角提示条不受影响——它们只是操作回执,关掉等于连结果都看不见。
---
@@ -235,6 +287,12 @@ git clone --depth 1 https://github.com/mengshengzhijie/ComfyUI-Bilingual-Prompt-
这两种操作只在标签模式与混合模式下可用,纯自然语言模式下没有意义。另外如果开了 ComfyUI 的「现代节点设计(Nodes 2.0)」,请关掉再试,原因见上文安装一节。
</details>
<details>
<summary><b>拖标签时整片标签在动,看不清落点</b></summary>
那是「拖动实时预览」在起作用(默认关闭)。它会让其余标签实时让开,长提示词下偶尔显得晃;到「设置 → 通用」把它关掉,就退回只有落点提示线的老样子,松手才真正挪动。
</details>
<details>
<summary><b>运行到一半卡住了,好像在等什么</b></summary>
这是上游接管的暂停确认。节点在等你整理导入的上游文本,点「继续」放行,或点「丢弃」终止本次等待;也可以直接点 ComfyUI 的取消中断。无人确认时约 20 秒会自动透传。
@@ -248,6 +306,7 @@ git clone --depth 1 https://github.com/mengshengzhijie/ComfyUI-Bilingual-Prompt-
- 自动翻译与优化结果都需要人工复查。
- 自然语言块不支持按内部单词逐项删除,要回文本模式改。
- 拖拽排序与隐藏只在标签模式和混合模式下可用。
- 提示条、拖动实时预览、增删动画这几个都属于观感加成,默认关闭;关掉后行为和旧版本完全一致,不影响任何数据。
- 隐藏清单、接管开关与导入策略都保存在节点属性里,导出「API 格式」JSON 时不会带出——届时隐藏标记会丢失,被隐藏的标签会直接出现在输出里。
- 接管上游提示词需要浏览器界面来确认;用 API 提交工作流时节点自动透传,不会等待。
- 收藏不记录工作流名称与节点编号;载入节点会覆盖节点当前文本,只弹窗确认,不支持撤销。
@@ -260,7 +319,7 @@ git clone --depth 1 https://github.com/mengshengzhijie/ComfyUI-Bilingual-Prompt-
- **默认不联网**:翻译服务的默认选项是纯词库,完全离线。
- **只在你主动使用某个服务时才发请求**:调用百度或 AI 时,发送的是提示词文本与你的指令;密钥只存在后端本地,不回传浏览器、不写入工作流。
- **大型词库按需加载**:不安装就不加载,装了也是本地文件查询。
- **收藏只存在本机**:写在 ComfyUI 用户目录下,插件不做任何上传。
- **收藏只存在本机**:写在插件目录的 `data/saved/` 下,插件不做任何上传。
---
@@ -279,6 +338,6 @@ git clone --depth 1 https://github.com/mengshengzhijie/ComfyUI-Bilingual-Prompt-
## 致谢与许可
基于 [Qiongyi44/ComfyUI-Bilingual-Prompt-Inspector](https://github.com/Qiongyi44/ComfyUI-Bilingual-Prompt-Inspector) 二次开发,现已独立维护。在原版基础上新增了百度翻译、侧边栏管理面板、标签拖拽排序、临时隐藏、上游接管与收藏。
基于 [Qiongyi44/ComfyUI-Bilingual-Prompt-Inspector](https://github.com/Qiongyi44/ComfyUI-Bilingual-Prompt-Inspector) 二次开发,现已独立维护。在原版基础上新增了百度翻译、侧边栏管理面板、标签拖拽排序、临时隐藏、上游接管与收藏,以及右下角提示条、拖动实时预览、标签增删动画这些交互细节。
词库数据来源与授权说明见 `THIRD_PARTY_NOTICE.md`,许可条款见 `LICENSE`,完整使用手册见 `release_docs/使用手册(原README).md`。
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5 8L10 13M4 14L10 8L12 5M2 5H14M7 2H8M12.913 17H20.087M12.913 17L11 21M12.913 17L15.7783 11.009C16.0092 10.5263 16.1246 10.2849 16.2826 10.2086C16.4199 10.1423 16.5801 10.1423 16.7174 10.2086C16.8754 10.2849 16.9908 10.5263 17.2217 11.009L20.087 17M20.087 17L22 21" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 586 B

+62
View File
@@ -205,6 +205,68 @@ def openai_chat_endpoint(base_url):
return f"{base}/chat/completions"
def openai_models_endpoint(base_url):
"""Return a model-list endpoint for an OpenAI-compatible base URL."""
base = str(base_url or "").strip().rstrip("/")
if not base:
return ""
if base.endswith("/models"):
return base
if base.endswith("/chat/completions"):
base = base[: -len("/chat/completions")].rstrip("/")
return f"{base}/models"
def response_snippet(body):
"""错误应答常常是一整页 HTML 或带换行的纯文本;压成一行并截断,免得提示框被撑爆。"""
return re.sub(r"\s+", " ", str(body or "")).strip()[:160]
def upstream_error_message(body, status, who):
"""把上游错误应答压成一句能看懂的中文。
各家返回的不是齐步走的:OpenAI 系给 JSON,DeepSeek 缺密钥时回的是纯文本一行
“Authentication Fails”,还有网关直接甩一页 HTML。所以先看原文能不能解析成 JSON,
解析不了就退化成原文片段,绝不在取错误信息的路上把请求整条打崩。
`who` 是“Ollama” / “API” 这一类的来源名,只在连原文都没有时用来兜底。
"""
message = None
try:
data = json.loads(body)
except (TypeError, UnicodeDecodeError, json.JSONDecodeError):
data = None
if isinstance(data, dict):
detail = data.get("error")
if isinstance(detail, dict):
detail = detail.get("message")
message = str(detail).strip() if detail else None
hint = ";请检查 API 密钥是否已填写、是否有效" if status in {401, 403} else ""
return f"{message or response_snippet(body) or f'{who} 请求失败'}(HTTP {status}{hint})"
def parse_json_body(body, who):
"""解析成功应答;不是 JSON 或不是字典时给一句能看懂的提示,而不是抛堆栈。"""
try:
data = json.loads(body)
except (TypeError, UnicodeDecodeError, json.JSONDecodeError) as error:
raise ValueError(f"{who} 返回的不是 JSON(可能是登录页或错误页):{response_snippet(body)}") from error
if not isinstance(data, dict):
raise ValueError(f"{who} 返回的内容无法识别:{response_snippet(body)}")
return data
def ollama_tags_endpoint(base_url):
"""Return the local-model list endpoint for an Ollama base URL."""
base = str(base_url or "").strip().rstrip("/")
if not base:
return ""
if base.endswith("/v1"):
base = base[:-3].rstrip("/")
if base.endswith("/api/chat"):
base = base[: -len("/api/chat")].rstrip("/")
return f"{base}/api/tags"
def baidu_translate_params(appid, secret_key, query, to_lang, from_lang="auto", salt=None):
"""组装百度通用文本翻译接口参数;sign = MD5(appid + q + salt + 密钥)。"""
appid = str(appid or "")
+22 -2
View File
@@ -62,8 +62,8 @@
- `js/manager_panel.js`
- 在 ComfyUI 侧边栏注册「双语提示词管理」面板,包含标签管理、词库、词库包、收藏四个分页。
- 承担原节点内管理弹窗的全部职责:词条增删改、批量操作、个人词库导入导出、社区包与大型词库管理。
- 助手设置不在侧边栏:同一文件的 `registerExtension` 用 `settings` 在 ComfyUI「设置」面板的「双语提示词检查器」分类下注册两项 ——「语言与外观」(`appearanceControl()`)与「助手设置」(`assistantSettingsControl()`),表单都直接内嵌在设置面板右侧,配置仍写入 `assistant_store.py`。面板反复渲染会重建控件容器,所以每项的整块 DOM 只建一次后复用,避免输入到一半被清空。
- 语言与外观只提交 `appearance` / `language` 两个字段:`assistant_store.update()` 对 payload 里没给的字段一律取当前值,所以不会动翻译服务与密钥;助手设置的 payload 反过来已经不含这两项。
- 助手设置不在侧边栏:同一文件的 `registerExtension` 用 `settings` 在 ComfyUI「设置」面板的「双语提示词检查器」分类下注册两项 ——「通用」(`appearanceControl()`)与「助手设置」(`assistantSettingsControl()`),表单都直接内嵌在设置面板右侧,配置仍写入 `assistant_store.py`。面板反复渲染会重建控件容器,所以每项的整块 DOM 只建一次后复用,避免输入到一半被清空。
- 通用只提交 `appearance` / `language` 两个字段:`assistant_store.update()` 对 payload 里没给的字段一律取当前值,所以不会动翻译服务与密钥;助手设置的 payload 反过来已经不含这两项。
- AI 两个后端各一整套配置(地址 / 模型 / 温度 / 超时 / 三条规则),键名前缀 `openai_` 与 `ollama_`,`ai_provider` 只决定当前读哪一套;切换后端不清空任何一套。地址白名单在 `assistant_store.normalize_ai_base_url()`:OpenAI 远端必须 https、http 仅限本机回环,Ollama 只认本机回环,且 `server.py` 发请求前会再校验一次(防手改配置文件)。
- “标签管理”分页通过节点下拉列表挂载指定节点的标签翻译与词库搜索界面(DOM 直接移植,逻辑仍归属各节点闭包),节点删除时自动摘下。
- 修改后通过 `panelSyncHub` 通知当前页面所有节点刷新。
@@ -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` 规则压成瞬时,不靠开关。
+12
View File
@@ -126,3 +126,15 @@
两套规则必须分开:`sanitize_anima_prompt` 会把句点收成逗号并只放行 Anima 标点白名单,完整句子喂进去会被判废,所以自然语言结果改走 `sanitize_natural_prompt`。
代价:已经排过序时要保留排序按钮,否则“取消排序”的入口会跟着一起消失。另外 mixed(标签 + 自然语言)不算自然语言模式——里面还有标签流可排。
## 20. 模型列表由服务端代问,且只在本次会话内缓存
决定:助手设置的「模型名称」输入框保持自由输入,旁边另加一个「获取模型」按钮。按钮把输入框里当前填的地址和密钥发给插件自己的 `/bpi/assistant/models`,由服务端去问那个 AI 后端(OpenAI 兼容问 `/models`,Ollama 问 `/api/tags`),把名字填进输入框自带的原生下拉(`<datalist>`);按钮显示「获取模型(N)」,再点一次就是重新拉取。
不弹窗是有原因的:助手设置长在 ComfyUI 的「设置」面板里,任何浮层都会被这块面板压住、点不动,逼着用户先关掉设置面板。下拉是浏览器原生的输入框附件,既没有层级问题,也不用自己处理定位与溢出。
原因:地址白名单(远端必须 https、内网与链路本地地址驳回、Ollama 只认本机回环)是整个插件的安全边界。让浏览器直接去拉,这份校验就得在前端重写一遍,而且可以绕过;交给服务端代发,还能顺带解决输入框里那份还没保存的密钥往哪发:填了就用填的,留空就用已存的那份,都只用于这一次请求,返回给浏览器的始终只有模型名字。
列表不持久化有两点考虑:各家网关的模型随时增减,写进配置文件第二天就是过期数据;地址一改缓存必须作废,把「这次问的地址」和列表绑在同一个闭包里最省事,也不用为它设计一份需要清理的存储格式。
代价:第一次点按钮要多等一次 `/models` 往返;各家返回结构不一样(`data[].id` 与 `models[].name`),得分别解析。面板一律不去猜哪个模型能聊天——embedding、rerank 之类照常列出,免得误伤名字奇怪但确实可用的模型。
+481 -1277
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 };
}
+68
View File
@@ -0,0 +1,68 @@
// 删除标签时的消失动画:X 从四角向心长出 → 颜色从中心铺满整块 → 上下压两条灰边 → 整块从右往左擦掉。
// 关掉就是原来的样子:卡片直接消失。这个开关同时管添加动画(见 bpi_insert_anim.js),
// 设置里显示为「标签增删动画(节点标签区)」,默认关。
const KEY = "bpi.deleteAnim.node.v1";
// 四拍各自的时长,加起来就是删掉一张卡要等的时间(≈0.9 秒)
const TIMING = { grow: 160, spread: 400, wipe: 350 };
export function deleteAnimNodeEnabled() {
try {
return window.localStorage.getItem(KEY) === "1";
} catch (error) {
return false;
}
}
export function setDeleteAnimNodeEnabled(next) {
try {
window.localStorage.setItem(KEY, next ? "1" : "0");
} catch (error) {
/* 隐私模式下写不进去,本次会话内仍按传入值生效 */
}
}
// 卡片尺寸跟着中英文长度变,所以每次删之前都量一遍:对角线角度、半对角线长度、
// 铺满整块需要的宽度、上下灰边的高度,全按当前宽高算(和 demo 里 measure() 一个算法)。
// onDone 在擦除结束时调用,真正的删除动作在那时才执行。
export function playDeleteAnim(card, onDone) {
if (!card || card.classList.contains("bpi-deleting")) {
onDone?.();
return;
}
const width = card.clientWidth;
const height = card.clientHeight;
const diagonal = Math.hypot(width, height);
card.style.setProperty("--bpi-del-angle", `${(Math.atan2(height, width) * 180) / Math.PI}deg`);
card.style.setProperty("--bpi-del-len", `${diagonal / 2}px`);
card.style.setProperty("--bpi-del-fill", `${((width * height) / diagonal) * 1.35}px`);
card.style.setProperty("--bpi-del-bar-h", `${Math.max(6, Math.min(14, Math.round(height * 0.16)))}px`);
const stage = document.createElement("div");
stage.className = "bpi-del-stage";
for (const corner of ["a1", "a2", "b1", "b2"]) {
const half = document.createElement("i");
half.className = `bpi-del-half bpi-del-half-${corner}`;
stage.appendChild(half);
}
const cover = document.createElement("i");
cover.className = "bpi-del-cover";
const topBar = document.createElement("i");
topBar.className = "bpi-del-bar bpi-del-bar-top";
const bottomBar = document.createElement("i");
bottomBar.className = "bpi-del-bar bpi-del-bar-bottom";
stage.append(cover, topBar, bottomBar);
card.appendChild(stage);
card.classList.add("bpi-deleting");
// 先让初始状态落一帧,否则浏览器会把 transition 一起跳过(X 会直接是满的)
requestAnimationFrame(() => card.classList.add("bpi-del-grow"));
window.setTimeout(() => card.classList.add("bpi-del-spread"), TIMING.grow);
window.setTimeout(() => card.classList.add("bpi-del-wipe"), TIMING.grow + TIMING.spread);
window.setTimeout(() => {
// 删除失败时卡片要能回到原样,所以先把动画痕迹全撤掉再交回去
card.classList.remove("bpi-deleting", "bpi-del-grow", "bpi-del-spread", "bpi-del-wipe");
stage.remove();
onDone?.();
}, TIMING.grow + TIMING.spread + TIMING.wipe);
}
+39
View File
@@ -0,0 +1,39 @@
// 拖动实时预览的开关:打开后,拖动标签时被拖的那一项会浮起来跟着光标走,
// 原位留一个等大的空位,其余标签用动画平滑让位;松手才真正改写文本,只留一条撤销记录。
// 关掉就是原来的样子:拖动过程中只有落点提示线,松手才见效。
// 平滑动效每次拖动都要量两遍布局,提示词很长时会多花一点 CPU,所以默认关。
// 节点标签区和明细表各一个开关,分开存,互不影响。
const NODE_KEY = "bpi.dragPreview.node.v1";
const TABLE_KEY = "bpi.dragPreview.table.v1";
const readFlag = (key) => {
try {
return window.localStorage.getItem(key) === "1";
} catch (error) {
return false;
}
};
const writeFlag = (key, next) => {
try {
window.localStorage.setItem(key, next ? "1" : "0");
} catch (error) {
/* 隐私模式下写不进去就算了,本次会话内仍按传入值生效 */
}
};
export function dragPreviewNodeEnabled() {
return readFlag(NODE_KEY);
}
export function setDragPreviewNodeEnabled(next) {
writeFlag(NODE_KEY, next);
}
export function dragPreviewTableEnabled() {
return readFlag(TABLE_KEY);
}
export function setDragPreviewTableEnabled(next) {
writeFlag(TABLE_KEY, next);
}
+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,
};
}
+12
View File
@@ -0,0 +1,12 @@
// 添加标签时的出现动画:新卡片从左往右展开,
// 其余卡片同时滑开让位。开关跟删除共用一个(设置里那个「标签增删动画」),
// 所以这个文件只管播,不管开关。
// 时长比删除短一些,但留足 0.42 秒:太快会看着像「啪」一下跳出来的。
export const INSERT_MS = 420;
export function playInsertAnim(cards) {
for (const card of cards) {
card.classList.add("bpi-inserting");
window.setTimeout(() => card.classList.remove("bpi-inserting"), INSERT_MS + 60);
}
}
+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,
};
}
+445
View File
@@ -0,0 +1,445 @@
// 右上角通知弹窗:开关打开时,节点面板里的操作提示改成弹窗显示;关掉就还是原来那条状态文字。
// 动画时间线照抄 deepseek_html_20261028_2e0b65.html(副本存在 .workbuddy/refs/notif-template.html),
// 大号标识内联 assets/translate.svg(见下方 LOGO_SVG),尺寸按卡片高度算。
// 多条通知同时存在时从上往下堆叠(新的插在最上面,把旧的往下挤),最多同时 3 条。
import { element, t } from "./bpi_shared.js";
const STORAGE_KEY = "bpi.notify.v1";
const HOLD_MS = 3000;
// 报错停留更久:出错时要读两遍才看清(尤其是带原因的长句),3 秒一闪就过去了
const ERROR_HOLD_MS = 8000;
// 同一 group 的提示(节点里反复点「唯一」开关这类)停留更久,方便看清内容;
// 期间再来一条同组的就原地换字并把这段时间重新计一遍,不另开卡片
const REFRESH_HOLD_MS = 5000;
const STICKY_MAX_MS = 10000; // 「处理中」最多挂这么久,等不到结果也自己收掉
// 「处理中」至少完整露脸这么久(入场动画本身约 1150ms):加载太快时结果会瞬间顶掉它,
// 看着就是闪一下,所以结果先扣着,等这一阶段播够了再换
const MIN_BUSY_MS = 1200;
const PADDING_EXTRA = 38; // 卡片上下 padding 36 + 上下 border 2
// 标识尺寸 = 卡片初始高度 × 这个比例(下限/上限见下方 logoSize,防止太小或撑出卡片)
const LOGO_HEIGHT_RATIO = 0.58;
const MAX_CARDS = 3;
let injected = false;
let stack = null;
// 从上到下排列,cards[0] 是最新的那条
const cards = [];
// 开关状态只存本地:这是实验功能,先不惊动后端那套 assistant_settings
export function notifyEnabled() {
try {
return window.localStorage.getItem(STORAGE_KEY) === "1";
} catch (error) {
return false;
}
}
export function setNotifyEnabled(next) {
try {
window.localStorage.setItem(STORAGE_KEY, next ? "1" : "0");
} catch (error) {
/* 隐私模式下写不进去就算了,本次会话内仍按传入值生效 */
}
}
const STYLE_ID = "bpi-notify-style";
const CSS = `
/* 弹窗自己的主题色:只在这一层覆盖,插件别处的 --bpm-accent(绿)不受影响。
它用在入场最后落下的那层、和垫在最底下的那张纸(左侧竖条);换青色是为了和退场那层紫红搭。
另加两个分组变量,出错时这两个颜色对调(见下面 .bpi-notif-error):
--bpi-notif-front 管「离场上抹层 + 底边亮线」(默认蓝 #0b8ce9),
--bpi-notif-paper 管垫在卡片正后方那张纸(默认紫红 #d946ef)。 */
.bpi-notif-stack{position:fixed;top:30px;right:0;width:min(320px,calc(100vw - 40px));z-index:9999;display:flex;flex-direction:column;gap:10px;--bpm-accent:#14b8a6;--bpi-notif-front:#0b8ce9;--bpi-notif-front-rgb:11,140,233;--bpi-notif-paper:#d946ef}
/* 投影只有暗色下才需要那么重,亮色下换成淡的;其余颜色都跟着插件的 --bpi-* 主题变量走 */
[data-bpi-theme="light"] .bpi-notif-stack{--bpi-notif-shadow:0 14px 34px rgba(0,0,0,.16);--bpi-notif-logo-shadow:0 2px 10px rgba(0,0,0,.22)}
.bpi-notif-slot{position:relative;width:100%;flex:none}
/* 出错时「上抹层的蓝」和「垫纸的紫红」对调:上抹层与底边亮线转成紫红 #d946ef,
垫纸转成蓝 #0b8ce9,撤场那三层从前往后变成「紫红 → 蓝 → 红」。
左侧竖条(--bpm-accent)和入场两层保持红色,不参与这次对调 */
.bpi-notif-slot.bpi-notif-error{--bpm-accent:#e06c75;--bpi-notif-front:#d946ef;--bpi-notif-front-rgb:217,70,239;--bpi-notif-paper:#0b8ce9}
/* 双层纸的下面那张:只在卡片左侧露出 6px,用插件主题色,充当卡片左侧的色条 */
.bpi-notif-under{position:absolute;top:0;right:0;bottom:0;left:-6px;background:var(--bpm-accent,#45926f);border:0;z-index:0;pointer-events:none;opacity:0;transform:translateY(-120%);transition:transform .5s cubic-bezier(.16,1,.3,1),opacity .4s ease}
.bpi-notif-under.bpi-show{opacity:1;transform:translateY(0)}
.bpi-notif-under.bpi-wipe{transform:translateX(125%);transition:transform .32s cubic-bezier(.55,0,.8,.4)}
.bpi-notif{position:relative;z-index:1;width:100%;padding:18px 20px;background:var(--bpi-surface-modal,#20252d);border:1px solid var(--bpi-border,#3a4250);border-radius:0;box-shadow:var(--bpi-notif-shadow,0 20px 50px rgba(0,0,0,.65));overflow:hidden;max-height:90px;opacity:0;transform:translateY(-120%);transition:transform .5s cubic-bezier(.16,1,.3,1),opacity .4s ease,max-height .32s cubic-bezier(.3,.8,.3,1);will-change:transform,opacity,max-height}
.bpi-notif.bpi-play{opacity:1;transform:translateY(0)}
/* 底边亮线跟离场那层同色(--bpi-notif-front):默认蓝,出错时一起转紫红,
和垫在卡片后面那张纸(此时转成蓝)正好对调。它不是左侧竖条,竖条是 --bpm-accent */
.bpi-notif::after{content:"";position:absolute;left:0;right:0;bottom:0;height:3px;background:var(--bpi-notif-front,#0b8ce9);box-shadow:0 0 12px rgba(var(--bpi-notif-front-rgb,11,140,233),.55),0 0 4px rgba(var(--bpi-notif-front-rgb,11,140,233),.8);z-index:1;pointer-events:none}
.bpi-notif.bpi-out{pointer-events:none;animation:bpiExitSlide .36s cubic-bezier(.55,0,.8,.4) forwards}
@keyframes bpiExitSlide{from{transform:translateX(0)}to{transform:translateX(125%)}}
.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;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}
.bpi-notif-logo-standalone{z-index:9;opacity:0;transition:opacity .22s ease}
.bpi-notif.bpi-logo-visible .bpi-notif-logo-standalone{opacity:1}
/* 入场:先整块是蓝(mask 底色),再从上方落下绿色那层盖住 → 620ms 向右扫出去的瞬间是绿的 */
.bpi-notif-mask{position:absolute;inset:0;background:#0b8ce9;z-index:7;pointer-events:none;transform:translateX(0)}
.bpi-notif.bpi-play .bpi-notif-mask{animation:bpiMaskSweep .34s cubic-bezier(.55,0,.2,1) .48s forwards}
@keyframes bpiMaskSweep{from{transform:translateX(0)}to{transform:translateX(112%)}}
.bpi-notif-seq{position:absolute;inset:0;transform:translateY(-100%);will-change:transform}
.bpi-notif-seq:first-child{background:#0b8ce9;z-index:1}
/* 入场最后落下的那层:主题绿,扫出去露出内容前看到的颜色 */
.bpi-notif-seq:nth-child(2){background:var(--bpm-accent,#45926f);z-index:2}
.bpi-notif.bpi-play .bpi-notif-seq:first-child{animation:bpiSeqDrop .22s cubic-bezier(.3,.7,.3,1) 0s forwards}
.bpi-notif.bpi-play .bpi-notif-seq:nth-child(2){animation:bpiSeqDrop .22s cubic-bezier(.3,.7,.3,1) .08s forwards}
@keyframes bpiSeqDrop{from{transform:translateY(-100%)}to{transform:translateY(0)}}
.bpi-notif-logo-in-mask{z-index:3}
/* 离场时从卡片下方往上抹满整张卡片的那层:跟底边亮线同色(--bpi-notif-front),
默认蓝、出错时紫红 */
.bpi-notif-cover{position:absolute;inset:0;background:var(--bpi-notif-front,#0b8ce9);z-index:8;pointer-events:none;transform:translateY(calc(100% + 3px));transition:transform .38s cubic-bezier(.45,0,.2,1);will-change:transform}
.bpi-notif.bpi-covering .bpi-notif-cover{transform:translateY(0)}
/* 垫在卡片正后方的那张紫红纸:和卡片一样大、不往外露,所以静止时完全被卡片盖住看不见;
DOM 排在绿底纸之后 → 压在绿纸上面,卡片滑走后先看到它,它再滑走才轮到绿纸。
顺序:卡片(蓝)→ 紫红 → 绿,三层依次向右走;出错时前两层对调,变成紫红 → 蓝 → 红。 */
.bpi-notif-sheet{position:absolute;top:0;right:0;bottom:0;left:0;background:var(--bpi-notif-paper,#d946ef);border:0;z-index:0;pointer-events:none;opacity:0;transform:translateY(-120%);transition:transform .5s cubic-bezier(.16,1,.3,1),opacity .4s ease}
.bpi-notif-sheet.bpi-show{opacity:1;transform:translateY(0)}
.bpi-notif-sheet.bpi-wipe{transform:translateX(125%);transition:transform .32s cubic-bezier(.55,0,.8,.4)}
.bpi-notif-close{position:absolute;top:6px;right:6px;z-index:10;width:28px;height:28px;display:grid;place-items:center;padding:0;border:0;border-radius:0;background:transparent;color:var(--bpi-text-muted,#aab5c5);cursor:pointer;transition:background .15s ease,color .15s ease}
.bpi-notif-close:hover{background:var(--bpi-handle-hover-bg,#2b3a4d);color:var(--bpi-text,#e6edf7)}
.bpi-notif-close:active{background:var(--bpi-mini-hover-bg,#3c4b60)}
@media (prefers-reduced-motion: reduce){
.bpi-notif,.bpi-notif-extra,.bpi-notif.bpi-out,.bpi-notif-cover,.bpi-notif-logo-standalone,.bpi-notif-seq,.bpi-notif-sheet,.bpi-notif-under,.bpi-notif.bpi-play .bpi-notif-mask{animation-duration:.01ms !important;animation-delay:0s !important;transition-duration:.01ms !important}
}
`;
function injectStyles() {
if (injected || document.getElementById(STYLE_ID)) {
injected = true;
return;
}
const style = document.createElement("style");
style.id = STYLE_ID;
style.textContent = CSS;
document.head.appendChild(style);
injected = true;
}
// 标识内联 assets/translate.svg 的那条 path:插件的 /extensions 静态根只到 js/,
// assets/ 在插件根取不到(请求会 404),内联省得再搬一份文件,也不受静态服务影响。
// 原图 stroke 是黑的,这里改 currentColor 交给 CSS 上色。
const LOGO_SVG = '<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">'
+ '<path d="M5 8L10 13M4 14L10 8L12 5M2 5H14M7 2H8M12.913 17H20.087M12.913 17L11 21M12.913 17L15.7783 11.009C16.0092 10.5263 16.1246 10.2849 16.2826 10.2086C16.4199 10.1423 16.5801 10.1423 16.7174 10.2086C16.8754 10.2849 16.9908 10.5263 17.2217 11.009L20.087 17M20.087 17L22 21" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>'
+ "</svg>";
function buildLogo(extraClass) {
const logo = element("div", `bpi-notif-logo ${extraClass}`);
logo.innerHTML = LOGO_SVG;
return logo;
}
function ensureStack() {
if (stack) return;
stack = element("div", "bpi-notif-stack");
document.body.appendChild(stack);
}
function createCard() {
const slot = element("div", "bpi-notif-slot");
// 由下而上:绿底纸 → 紫红垫纸 → 卡片(卡片盖住前两张,紫红只在卡片滑走后才露出来)
const under = element("div", "bpi-notif-under");
const sheet = element("div", "bpi-notif-sheet");
const notif = element("div", "bpi-notif");
const logoStandalone = buildLogo("bpi-notif-logo-standalone");
const content = element("div", "bpi-notif-content");
const head = element("div", "bpi-notif-head");
// 折叠时只显示标题那一行,消息正文放在展开后才淡入的 extra 里,不再重复一遍
const titleEl = element("div", "bpi-notif-title");
const extraEl = element("div", "bpi-notif-extra");
head.appendChild(titleEl);
content.append(head, extraEl);
const mask = element("div", "bpi-notif-mask");
mask.append(element("div", "bpi-notif-seq"), element("div", "bpi-notif-seq"));
mask.appendChild(buildLogo("bpi-notif-logo-in-mask"));
const cover = element("div", "bpi-notif-cover");
const close = element("button", "bpi-notif-close", "✕");
close.type = "button";
notif.append(logoStandalone, content, mask, cover, close);
slot.append(under, sheet, notif);
const card = {
slot, notif, under, sheet, content, head, titleEl, extraEl,
item: null,
sticky: false,
shownAt: 0,
pendingItem: null,
pendingTimer: null,
timers: [],
closed: false,
leaving: false,
};
close.addEventListener("click", () => dismissCard(card));
return card;
}
function clearCardTimers(card) {
card.timers.forEach((id) => clearTimeout(id));
card.timers = [];
}
// 状态词当标题,消息本身放正文:标题一行放不下的长句,展开后能看全
function titleFor(kind) {
if (kind === "ok") return "Done";
if (kind === "error") return "Something went wrong";
if (kind === "busy") return "Working...";
return "Notice";
}
function sameItem(a, b) {
return !!a && !!b && a.message === b.message && a.kind === b.kind;
}
// 「处理中」一直挂着,等下一条结果把它顶掉;真等不到结果,兜底这么多毫秒后自己收掉
function armDismiss(card) {
card.sticky = card.item.kind === "busy";
if (card.sticky) {
card.shownAt = Date.now();
card.timers.push(setTimeout(() => dismissCard(card), STICKY_MAX_MS));
return;
}
const baseHold = card.item.kind === "error" ? ERROR_HOLD_MS : HOLD_MS;
const hold = Number(card.item.hold) > 0 ? Number(card.item.hold) : baseHold;
card.timers.push(setTimeout(() => dismissCard(card), hold));
}
function measure(card) {
const notif = card.notif;
notif.style.transition = "none";
notif.style.maxHeight = "none";
void notif.offsetWidth;
const fullH = card.content.scrollHeight + PADDING_EXTRA;
const initialH = card.head.offsetHeight + PADDING_EXTRA;
// 标识跟着卡片大小走,再多也不许超过卡片宽度的四分之一,免得顶到外面被裁掉
const logoSize = Math.max(28, Math.min(Math.round(initialH * LOGO_HEIGHT_RATIO), Math.round(notif.offsetWidth * 0.24)));
notif.style.setProperty("--bpi-notif-logo-size", `${logoSize}px`);
return { fullH, initialH };
}
function playCard(card) {
clearCardTimers(card);
card.item = card.item || { message: "", kind: "" };
card.slot.classList.toggle("bpi-notif-error", card.item.kind === "error");
// 摘类之前先关掉过渡:卡片此刻停在屏幕右侧(上一次退场的位置),
// 带着过渡摘类会让它斜着飞回入场起点,看着就是「结束后往上一跳」
card.notif.style.transition = "none";
card.under.style.transition = "none";
card.sheet.style.transition = "none";
card.notif.classList.remove("bpi-play", "bpi-out", "bpi-expand", "bpi-covering", "bpi-logo-visible");
card.under.classList.remove("bpi-show", "bpi-wipe");
card.sheet.classList.remove("bpi-show", "bpi-wipe");
void card.notif.offsetWidth;
void card.under.offsetWidth;
void card.sheet.offsetWidth;
card.extraEl.textContent = t(card.item.message);
card.titleEl.textContent = t(titleFor(card.item.kind));
// 先放开量完整高度,再压回只够显示标题的高度,最后才恢复过渡
const { fullH, initialH } = measure(card);
card.initialH = initialH;
card.notif.style.maxHeight = `${initialH}px`;
void card.notif.offsetWidth;
card.notif.style.transition = "";
void card.notif.offsetWidth;
card.notif.classList.add("bpi-play");
card.under.style.transition = ""; // 归位已无过渡完成,这里恢复后才能滑入
card.sheet.style.transition = "";
void card.under.offsetWidth;
void card.sheet.offsetWidth;
card.under.classList.add("bpi-show");
card.sheet.classList.add("bpi-show");
card.timers.push(setTimeout(() => {
card.notif.classList.add("bpi-expand");
card.notif.style.maxHeight = `${fullH}px`;
}, 880));
armDismiss(card);
}
// 结果到了:不重播入场动画,直接换掉卡片里的文字,展开高度跟着新内容重算
function updateCardContent(card, item) {
clearCardTimers(card);
card.item = item;
card.slot.classList.toggle("bpi-notif-error", item.kind === "error");
card.extraEl.textContent = t(item.message);
card.titleEl.textContent = t(titleFor(item.kind));
card.notif.style.transition = "none";
card.notif.style.maxHeight = "none";
void card.notif.offsetWidth;
card.notif.style.maxHeight = `${card.content.scrollHeight + PADDING_EXTRA}px`;
void card.notif.offsetWidth;
card.notif.style.transition = "";
card.notif.classList.add("bpi-expand");
armDismiss(card);
}
// 结果来得太快时先扣着,等「处理中」播够时间再顶上去
function flushPending(card) {
card.pendingTimer = null;
const item = card.pendingItem;
card.pendingItem = null;
if (item && !card.closed) updateCardContent(card, item);
}
function dismissCard(card) {
// leaving 挡住重复调用:退场要跑 0.9 秒才真正从 cards 里摘掉,
// 期间 trimCards 可能又点到同一张卡,没有这个标记就会反复触发、甚至死循环
if (card.closed || card.leaving) return;
card.leaving = true;
clearCardTimers(card);
if (card.pendingTimer) {
clearTimeout(card.pendingTimer);
card.pendingTimer = null;
}
card.pendingItem = null;
card.notif.classList.remove("bpi-expand");
card.notif.classList.add("bpi-covering", "bpi-logo-visible");
card.notif.style.maxHeight = `${card.initialH}px`;
// 退场:先收高度 + 上抹层刷上来(默认蓝、出错时紫红),420ms 后卡片右移;垫纸晚 85ms 跟上,
// 最下面那张纸再晚 85ms,三层依次向右滑走 —— 紫红那张「和上抹层一样向右走,只不过在它后面」
card.timers.push(setTimeout(() => {
card.notif.classList.add("bpi-out");
card.timers.push(setTimeout(() => {
card.sheet.classList.add("bpi-wipe");
card.timers.push(setTimeout(() => {
card.under.classList.add("bpi-wipe");
card.timers.push(setTimeout(() => {
// 复位必须先关过渡:垫纸和底纸此刻停在屏幕右侧,直接摘类会让它一路斜着飞回入场起点
card.sheet.style.transition = "none";
card.sheet.classList.remove("bpi-show", "bpi-wipe");
void card.sheet.offsetWidth;
card.sheet.style.transition = "";
card.under.style.transition = "none";
card.under.classList.remove("bpi-show", "bpi-wipe");
void card.under.offsetWidth;
card.under.style.transition = "";
card.notif.classList.remove("bpi-logo-visible");
removeCard(card);
}, 330));
}, 85));
}, 85));
}, 420));
}
// 让位动画(FLIP):插入/移除卡片会让下面的卡片瞬间跳位,这里先把它们按原位置偏回去,
// 再过渡到新位置,看着就是平滑地被挤下去 / 收上来
function snapshotTops() {
const tops = new Map();
cards.forEach((card) => tops.set(card, card.slot.getBoundingClientRect().top));
return tops;
}
function nudgeStack(before) {
before.forEach((top, card) => {
if (card.leaving || card.closed) return;
const delta = top - card.slot.getBoundingClientRect().top;
if (!delta) return;
card.slot.style.transition = "none";
card.slot.style.transform = `translateY(${delta}px)`;
void card.slot.offsetWidth;
card.slot.style.transition = "transform .35s cubic-bezier(.16,1,.3,1)";
card.slot.style.transform = "";
});
}
function removeCard(card) {
if (card.closed) return;
card.closed = true;
clearCardTimers(card);
const index = cards.indexOf(card);
const before = snapshotTops();
if (index >= 0) cards.splice(index, 1);
card.slot.remove();
nudgeStack(before);
}
// 超过上限就把最旧的那条(在最下面)挤掉。正在退场的卡片不算数,也别重复点它
function trimCards() {
for (let index = cards.length - 1; index >= 0; index -= 1) {
const alive = cards.filter((card) => !card.leaving).length;
if (alive <= MAX_CARDS) return;
dismissCard(cards[index]);
}
}
export function notify(message, kind = "", options = null) {
if (!notifyEnabled() || !message) return false;
injectStyles();
ensureStack();
const opts = options || {};
const group = typeof opts.group === "string" ? opts.group : "";
const hold = Number(opts.hold) > 0 ? Number(opts.hold) : 0;
const fresh = opts.fresh === true;
const item = { message, kind, group, hold: hold || (group ? REFRESH_HOLD_MS : 0) };
// 正在退场的不算;fresh 会先让旧卡退场,所以每次都重新取一遍
const alive = () => cards.filter((card) => !card.leaving);
// fresh:让同组的旧卡(批量翻译那张「处理中」进度卡)先退场,再开一张全新的报结果,
// 避免「进度卡 + 汇总卡」并排挂着。没给 group 时退掉所有「处理中」卡。
if (fresh) {
alive()
.filter((card) => (group ? card.item && card.item.group === group : card.sticky))
.forEach((card) => dismissCard(card));
}
// 有 group 的提示(连点重复胶囊等):复用那张卡,换掉文字并把停留时间重新计一遍。
// 没有 group 时才走「一模一样就忽略」的旧逻辑;fresh 一律另开新卡。
if (!fresh) {
if (group) {
const groupCard = alive().find((card) => card.item && card.item.group === group && !card.sticky);
if (groupCard) {
updateCardContent(groupCard, item);
return true;
}
} else if (alive().some((card) => sameItem(card.item, item))) {
return true;
}
// 有「处理中」挂着:结果去顶掉它,不另开一张卡
const busyCard = alive().find((card) => card.sticky);
if (busyCard) {
const waited = Date.now() - busyCard.shownAt;
if (waited < MIN_BUSY_MS) {
busyCard.pendingItem = item;
if (busyCard.pendingTimer) clearTimeout(busyCard.pendingTimer);
busyCard.pendingTimer = setTimeout(() => flushPending(busyCard), MIN_BUSY_MS - waited);
return true;
}
updateCardContent(busyCard, item);
return true;
}
}
const card = createCard();
card.item = item;
const before = snapshotTops();
cards.unshift(card);
// 插在最上面,已有的往下挤
stack.insertBefore(card.slot, stack.firstChild);
trimCards();
// 先播(内部会量出真实高度并压回折叠高度),再让下面的卡片平滑让位:
// 顺序反过来会因为「插入时的默认高度 → 量完的真实高度」抖两次
playCard(card);
nudgeStack(before);
return true;
}
+171 -144
View File
@@ -199,6 +199,18 @@ async function getAssistantConfig() {
return result.data;
}
// 拿表单当前填的地址和密钥问一次「你有哪些模型」;后端不读已保存的密钥,也不写配置
async function listAssistantModels(payload) {
const response = await bpiFetch(`${API_ROOT}/assistant/models`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const result = await response.json().catch(() => null);
if (!response.ok || !result?.success) throw new Error(result?.error || "Failed to fetch models");
return Array.isArray(result.data?.models) ? result.data.models : [];
}
// 读 data/token_colors.json;用户可以不改源码只改这个文件管理节点标签卡的颜色池。
// 文件不在或格式不对时后端回退内置默认,这里只管透传。
let tokenColorsPromise = null;
@@ -595,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) {
@@ -1222,10 +1106,10 @@ function injectBpiStyles() {
const style = document.createElement("style");
style.id = "bpi-styles";
style.textContent = `
:root{--bpi-field-bg:#fff;--bpi-field-border:#bbb;--bpi-switch-off:#c9ced6;--bpi-switch-knob:#fff;--bpi-switch-off-border:#b0b6bf;--bpm-accent:#45926f;--bpm-accent-hover:#3a7d5e;--bpm-accent-border:#35755a;--bpi-custom-bg:#e6f6f1;--bpi-custom-border:#7fc9b0;--bpi-custom-text:#0f6e56;--bpi-surface:rgba(255,255,255,.92);--bpi-surface-2:#f5f5f5;--bpi-surface-3:#eaeaea;--bpi-surface-table:#f0f0f0;--bpi-surface-modal:#fff;--bpi-text:#333;--bpi-text-muted:#666;--bpi-text-faint:#999;--bpi-border:#ccc;--bpi-border-2:#bbb;--bpi-border-3:#e0e0e0;--bpi-button-bg:#e8e8e8;--bpi-button-text:#333;--bpi-input-bg:#fff;--bpi-input-text:#333;--bpi-head-bg:#e8e8e8;--bpi-danger-bg:#c0392b;--bpi-danger-bg-hover:#a53023;--bpi-danger-border:#a53023;--bpi-danger-text:#fff;--bpi-break-bg:#e6f6f1;--bpi-break-bg-hover:#0f6e56;--bpi-break-border:#7fc9b0;--bpi-break-text:#0f6e56;--bpi-primary-bg:#2b6cb0;--bpi-primary-bg-hover:#245a94;--bpi-primary-border:#1e4f85;--bpi-primary-text:#fff;--bpi-en-head-text:#17557f;--bpi-en-hint-text:#5b7f9e;--bpi-zh-head-text:#1f5c22;--bpi-zh-hint-text:#5b7d5e;--bpi-cat-name-text:#1e5a28;--bpi-info-text:#1f5f96;--bpi-link-text:#1a5f96;--bpi-zh-text:#18508a;--bpi-status-error:#b3261e;--bpi-status-ok:#1a7f45;--bpi-status-busy:#8a5a00;--bpi-badge-warn:#8a5a00;--bpi-badge-source:#0f6e56;--bpi-badge-suspected:#1c5f80;--bpi-badge-machine:#5b3fa8;--bpi-star-on:#a8750a;--bpi-star-off:#6b7280;--bpi-quick-head-text:#4a3a7a;--bpi-quick-hint-text:#6b5a8f;--bpi-handle-text:#4a6a85;--bpi-handle-hover:#1f5f96;--bpi-mini-hover-bg:#d8dee7;--bpi-handle-hover-bg:#e2e8ef;--bpi-chip-hide-bg:#eef1f5;--bpi-waiting-bg:#fdf1dc;--bpi-waiting-border:#d9a95a;--bpi-hidden-chip-hover-bg:#e8eef5;--bpi-issues-bg:#fdf6e8;--bpi-issues-border:#d9b98a;--bpi-issue-text:#7a5410;--bpi-row-hover-overlay:rgba(47,108,176,.06);--bpi-row-pinned-overlay:#cfe3f7;--bpi-row-hover-ring:rgba(47,108,176,.55);--bpi-row-pinned-ring:#1a5f96;--bpi-row-hover-text:#1f1f1f;--bpi-row-hover-shadow:none;--bpi-row-selected-bg:#dcebf8;--bpi-en-section-bg:rgba(43,108,176,.06);--bpi-quick-section-bg:rgba(107,85,148,.06);--bpi-dup-overlay:rgba(179,38,30,.12);--bpi-editor-bg:#fff;--bpi-hit-bg:#fbe49c;--bpi-hit-text:#7a5410}
.dark-theme{--bpi-field-bg:#0b0d10;--bpi-field-border:#333a45;--bpi-switch-off:#39414f;--bpi-switch-knob:#e8edf5;--bpi-switch-off-border:#4c5668;--bpi-custom-bg:#17342c;--bpi-custom-border:#3f8f77;--bpi-custom-text:#7fdcbd;--bpi-surface:rgba(10,12,18,.72);--bpi-surface-2:#171b22;--bpi-surface-3:#14181f;--bpi-surface-table:#11151b;--bpi-surface-modal:#20252d;--bpi-text:#e6edf7;--bpi-text-muted:#aab5c5;--bpi-text-faint:#7f8a9a;--bpi-border:#3a4250;--bpi-border-2:#4c5668;--bpi-border-3:#29313d;--bpi-button-bg:#2c3340;--bpi-button-text:#e8edf5;--bpi-input-bg:#171b22;--bpi-input-text:#eef3fb;--bpi-head-bg:#252c36;--bpi-danger-bg:#65313b;--bpi-danger-bg-hover:#7d3d49;--bpi-danger-border:#8d4653;--bpi-danger-text:#ffd9de;--bpi-break-bg:#17342c;--bpi-break-bg-hover:#0f6e56;--bpi-break-border:#3f8f77;--bpi-break-text:#7fdcbd;--bpi-primary-bg:#285f8e;--bpi-primary-bg-hover:#346f9f;--bpi-primary-border:#3f83ba;--bpi-primary-text:#eaf3fb;--bpi-en-head-text:#cbe8ff;--bpi-en-hint-text:#91b4cf;--bpi-zh-head-text:#cbe9c8;--bpi-zh-hint-text:#91ad93;--bpi-cat-name-text:#9ee8a7;--bpi-info-text:#87bfea;--bpi-link-text:#76c9ff;--bpi-zh-text:#9ed0ff;--bpi-status-error:#ff8b93;--bpi-status-ok:#7fdda2;--bpi-status-busy:#ffd27a;--bpi-badge-warn:#e2ca78;--bpi-badge-source:#86d5c4;--bpi-badge-suspected:#7fc4e0;--bpi-badge-machine:#cbb1ff;--bpi-star-on:#ffd45f;--bpi-star-off:#8f9aaa;--bpi-quick-head-text:#d9ccff;--bpi-quick-hint-text:#a795c6;--bpi-handle-text:#7194ad;--bpi-handle-hover:#a9c8e4;--bpi-mini-hover-bg:#3c4b60;--bpi-handle-hover-bg:#2b3a4d;--bpi-chip-hide-bg:#1d2937;--bpi-waiting-bg:rgba(70,52,25,.5);--bpi-waiting-border:#8a5f16;--bpi-hidden-chip-hover-bg:#31404f;--bpi-issues-bg:rgba(55,39,25,.55);--bpi-issues-border:#5b4a36;--bpi-issue-text:#e6c792;--bpi-row-hover-overlay:rgba(76,167,232,.34);--bpi-row-pinned-overlay:#314b68;--bpi-row-hover-ring:rgba(76,167,232,.7);--bpi-row-pinned-ring:#76c9ff;--bpi-row-hover-text:#fff;--bpi-row-hover-shadow:0 0 7px rgba(109,190,255,.6);--bpi-row-selected-bg:#314b68;--bpi-en-section-bg:rgba(76,167,232,.08);--bpi-quick-section-bg:rgba(167,149,198,.08);--bpi-dup-overlay:rgba(255,139,147,.22);--bpi-editor-bg:#111923;--bpi-hit-bg:#5a4413;--bpi-hit-text:#ffe6a8}
[data-bpi-theme="dark"]{--bpi-field-bg:#0b0d10;--bpi-field-border:#333a45;--bpi-switch-off:#39414f;--bpi-switch-knob:#e8edf5;--bpi-switch-off-border:#4c5668;--bpi-custom-bg:#17342c;--bpi-custom-border:#3f8f77;--bpi-custom-text:#7fdcbd;--bpi-surface:rgba(10,12,18,.72);--bpi-surface-2:#171b22;--bpi-surface-3:#14181f;--bpi-surface-table:#11151b;--bpi-surface-modal:#20252d;--bpi-text:#e6edf7;--bpi-text-muted:#aab5c5;--bpi-text-faint:#7f8a9a;--bpi-border:#3a4250;--bpi-border-2:#4c5668;--bpi-border-3:#29313d;--bpi-button-bg:#2c3340;--bpi-button-text:#e8edf5;--bpi-input-bg:#171b22;--bpi-input-text:#eef3fb;--bpi-head-bg:#252c36;--bpi-danger-bg:#65313b;--bpi-danger-bg-hover:#7d3d49;--bpi-danger-border:#8d4653;--bpi-danger-text:#ffd9de;--bpi-break-bg:#17342c;--bpi-break-bg-hover:#0f6e56;--bpi-break-border:#3f8f77;--bpi-break-text:#7fdcbd;--bpi-primary-bg:#285f8e;--bpi-primary-bg-hover:#346f9f;--bpi-primary-border:#3f83ba;--bpi-primary-text:#eaf3fb;--bpi-en-head-text:#cbe8ff;--bpi-en-hint-text:#91b4cf;--bpi-zh-head-text:#cbe9c8;--bpi-zh-hint-text:#91ad93;--bpi-cat-name-text:#9ee8a7;--bpi-info-text:#87bfea;--bpi-link-text:#76c9ff;--bpi-zh-text:#9ed0ff;--bpi-status-error:#ff8b93;--bpi-status-ok:#7fdda2;--bpi-status-busy:#ffd27a;--bpi-badge-warn:#e2ca78;--bpi-badge-source:#86d5c4;--bpi-badge-suspected:#7fc4e0;--bpi-badge-machine:#cbb1ff;--bpi-star-on:#ffd45f;--bpi-star-off:#8f9aaa;--bpi-quick-head-text:#d9ccff;--bpi-quick-hint-text:#a795c6;--bpi-handle-text:#7194ad;--bpi-handle-hover:#a9c8e4;--bpi-mini-hover-bg:#3c4b60;--bpi-handle-hover-bg:#2b3a4d;--bpi-chip-hide-bg:#1d2937;--bpi-waiting-bg:rgba(70,52,25,.5);--bpi-waiting-border:#8a5f16;--bpi-hidden-chip-hover-bg:#31404f;--bpi-issues-bg:rgba(55,39,25,.55);--bpi-issues-border:#5b4a36;--bpi-issue-text:#e6c792;--bpi-row-hover-overlay:rgba(76,167,232,.34);--bpi-row-pinned-overlay:#314b68;--bpi-row-hover-ring:rgba(76,167,232,.7);--bpi-row-pinned-ring:#76c9ff;--bpi-row-hover-text:#fff;--bpi-row-hover-shadow:0 0 7px rgba(109,190,255,.6);--bpi-row-selected-bg:#314b68;--bpi-en-section-bg:rgba(76,167,232,.08);--bpi-quick-section-bg:rgba(167,149,198,.08);--bpi-dup-overlay:rgba(255,139,147,.22);--bpi-editor-bg:#111923;--bpi-hit-bg:#5a4413;--bpi-hit-text:#ffe6a8}
[data-bpi-theme="light"]{--bpi-field-bg:#fff;--bpi-field-border:#bbb;--bpi-switch-off:#c9ced6;--bpi-switch-knob:#fff;--bpi-switch-off-border:#b0b6bf;--bpi-custom-bg:#e6f6f1;--bpi-custom-border:#7fc9b0;--bpi-custom-text:#0f6e56;--bpi-surface:rgba(255,255,255,.92);--bpi-surface-2:#f5f5f5;--bpi-surface-3:#eaeaea;--bpi-surface-table:#f0f0f0;--bpi-surface-modal:#fff;--bpi-text:#333;--bpi-text-muted:#666;--bpi-text-faint:#999;--bpi-border:#ccc;--bpi-border-2:#bbb;--bpi-border-3:#e0e0e0;--bpi-button-bg:#e8e8e8;--bpi-button-text:#333;--bpi-input-bg:#fff;--bpi-input-text:#333;--bpi-head-bg:#e8e8e8;--bpi-danger-bg:#c0392b;--bpi-danger-bg-hover:#a53023;--bpi-danger-border:#a53023;--bpi-danger-text:#fff;--bpi-break-bg:#e6f6f1;--bpi-break-bg-hover:#0f6e56;--bpi-break-border:#7fc9b0;--bpi-break-text:#0f6e56;--bpi-primary-bg:#2b6cb0;--bpi-primary-bg-hover:#245a94;--bpi-primary-border:#1e4f85;--bpi-primary-text:#fff;--bpi-en-head-text:#17557f;--bpi-en-hint-text:#5b7f9e;--bpi-zh-head-text:#1f5c22;--bpi-zh-hint-text:#5b7d5e;--bpi-cat-name-text:#1e5a28;--bpi-info-text:#1f5f96;--bpi-link-text:#1a5f96;--bpi-zh-text:#18508a;--bpi-status-error:#b3261e;--bpi-status-ok:#1a7f45;--bpi-status-busy:#8a5a00;--bpi-badge-warn:#8a5a00;--bpi-badge-source:#0f6e56;--bpi-badge-suspected:#1c5f80;--bpi-badge-machine:#5b3fa8;--bpi-star-on:#a8750a;--bpi-star-off:#6b7280;--bpi-quick-head-text:#4a3a7a;--bpi-quick-hint-text:#6b5a8f;--bpi-handle-text:#4a6a85;--bpi-handle-hover:#1f5f96;--bpi-mini-hover-bg:#d8dee7;--bpi-handle-hover-bg:#e2e8ef;--bpi-chip-hide-bg:#eef1f5;--bpi-waiting-bg:#fdf1dc;--bpi-waiting-border:#d9a95a;--bpi-hidden-chip-hover-bg:#e8eef5;--bpi-issues-bg:#fdf6e8;--bpi-issues-border:#d9b98a;--bpi-issue-text:#7a5410;--bpi-row-hover-overlay:rgba(47,108,176,.06);--bpi-row-pinned-overlay:#cfe3f7;--bpi-row-hover-ring:rgba(47,108,176,.55);--bpi-row-pinned-ring:#1a5f96;--bpi-row-hover-text:#1f1f1f;--bpi-row-hover-shadow:none;--bpi-row-selected-bg:#dcebf8;--bpi-en-section-bg:rgba(43,108,176,.06);--bpi-quick-section-bg:rgba(107,85,148,.06);--bpi-dup-overlay:rgba(179,38,30,.12);--bpi-editor-bg:#fff;--bpi-hit-bg:#fbe49c;--bpi-hit-text:#7a5410}
:root{--bpi-field-bg:#fff;--bpi-field-border:#bbb;--bpi-switch-off:#c9ced6;--bpi-switch-knob:#fff;--bpi-switch-off-border:#b0b6bf;--bpm-accent:#45926f;--bpm-accent-hover:#3a7d5e;--bpm-accent-border:#35755a;--bpi-custom-bg:#e6f6f1;--bpi-custom-border:#7fc9b0;--bpi-custom-text:#0f6e56;--bpi-surface:rgba(255,255,255,.92);--bpi-surface-2:#f5f5f5;--bpi-surface-3:#eaeaea;--bpi-surface-table:#f0f0f0;--bpi-surface-modal:#fff;--bpi-text:#333;--bpi-text-muted:#666;--bpi-text-faint:#999;--bpi-border:#ccc;--bpi-border-2:#bbb;--bpi-border-3:#e0e0e0;--bpi-button-bg:#e8e8e8;--bpi-button-text:#333;--bpi-input-bg:#fff;--bpi-input-text:#333;--bpi-head-bg:#e8e8e8;--bpi-danger-bg:#c0392b;--bpi-danger-bg-hover:#a53023;--bpi-danger-border:#a53023;--bpi-danger-text:#fff;--bpi-break-bg:#e6f6f1;--bpi-break-bg-hover:#0f6e56;--bpi-break-border:#7fc9b0;--bpi-break-text:#0f6e56;--bpi-primary-bg:#2b6cb0;--bpi-primary-bg-hover:#245a94;--bpi-primary-border:#1e4f85;--bpi-primary-text:#fff;--bpi-en-head-text:#17557f;--bpi-en-hint-text:#5b7f9e;--bpi-zh-head-text:#1f5c22;--bpi-zh-hint-text:#5b7d5e;--bpi-cat-name-text:#1e5a28;--bpi-info-text:#1f5f96;--bpi-link-text:#1a5f96;--bpi-zh-text:#18508a;--bpi-status-error:#b3261e;--bpi-status-ok:#1a7f45;--bpi-status-busy:#8a5a00;--bpi-badge-warn:#8a5a00;--bpi-badge-source:#0f6e56;--bpi-badge-suspected:#1c5f80;--bpi-badge-machine:#5b3fa8;--bpi-star-on:#a8750a;--bpi-star-off:#6b7280;--bpi-quick-head-text:#4a3a7a;--bpi-quick-hint-text:#6b5a8f;--bpi-handle-text:#4a6a85;--bpi-handle-hover:#1f5f96;--bpi-mini-hover-bg:#d8dee7;--bpi-handle-hover-bg:#e2e8ef;--bpi-chip-hide-bg:#eef1f5;--bpi-waiting-bg:#fdf1dc;--bpi-waiting-border:#d9a95a;--bpi-hidden-chip-hover-bg:#e8eef5;--bpi-issues-bg:#fdf6e8;--bpi-issues-border:#d9b98a;--bpi-issue-text:#7a5410;--bpi-row-hover-overlay:rgba(47,108,176,.06);--bpi-row-pinned-overlay:#cfe3f7;--bpi-row-hover-ring:rgba(47,108,176,.55);--bpi-row-pinned-ring:#1a5f96;--bpi-row-hover-text:#1f1f1f;--bpi-row-hover-shadow:none;--bpi-row-selected-bg:#dcebf8;--bpi-drag-row-bar:#45926f;--bpi-drag-row-bg:#d6efe4;--bpi-drag-row-text:#0f4d3a;--bpi-en-section-bg:rgba(43,108,176,.06);--bpi-quick-section-bg:rgba(107,85,148,.06);--bpi-dup-overlay:rgba(179,38,30,.12);--bpi-editor-bg:#fff;--bpi-hit-bg:#fbe49c;--bpi-hit-text:#7a5410}
.dark-theme{--bpi-field-bg:#0b0d10;--bpi-field-border:#333a45;--bpi-switch-off:#39414f;--bpi-switch-knob:#e8edf5;--bpi-switch-off-border:#4c5668;--bpi-custom-bg:#17342c;--bpi-custom-border:#3f8f77;--bpi-custom-text:#7fdcbd;--bpi-surface:rgba(10,12,18,.72);--bpi-surface-2:#171b22;--bpi-surface-3:#14181f;--bpi-surface-table:#11151b;--bpi-surface-modal:#20252d;--bpi-text:#e6edf7;--bpi-text-muted:#aab5c5;--bpi-text-faint:#7f8a9a;--bpi-border:#3a4250;--bpi-border-2:#4c5668;--bpi-border-3:#29313d;--bpi-button-bg:#2c3340;--bpi-button-text:#e8edf5;--bpi-input-bg:#171b22;--bpi-input-text:#eef3fb;--bpi-head-bg:#252c36;--bpi-danger-bg:#65313b;--bpi-danger-bg-hover:#7d3d49;--bpi-danger-border:#8d4653;--bpi-danger-text:#ffd9de;--bpi-break-bg:#17342c;--bpi-break-bg-hover:#0f6e56;--bpi-break-border:#3f8f77;--bpi-break-text:#7fdcbd;--bpi-primary-bg:#285f8e;--bpi-primary-bg-hover:#346f9f;--bpi-primary-border:#3f83ba;--bpi-primary-text:#eaf3fb;--bpi-en-head-text:#cbe8ff;--bpi-en-hint-text:#91b4cf;--bpi-zh-head-text:#cbe9c8;--bpi-zh-hint-text:#91ad93;--bpi-cat-name-text:#9ee8a7;--bpi-info-text:#87bfea;--bpi-link-text:#76c9ff;--bpi-zh-text:#9ed0ff;--bpi-status-error:#ff8b93;--bpi-status-ok:#7fdda2;--bpi-status-busy:#ffd27a;--bpi-badge-warn:#e2ca78;--bpi-badge-source:#86d5c4;--bpi-badge-suspected:#7fc4e0;--bpi-badge-machine:#cbb1ff;--bpi-star-on:#ffd45f;--bpi-star-off:#8f9aaa;--bpi-quick-head-text:#d9ccff;--bpi-quick-hint-text:#a795c6;--bpi-handle-text:#7194ad;--bpi-handle-hover:#a9c8e4;--bpi-mini-hover-bg:#3c4b60;--bpi-handle-hover-bg:#2b3a4d;--bpi-chip-hide-bg:#1d2937;--bpi-waiting-bg:rgba(70,52,25,.5);--bpi-waiting-border:#8a5f16;--bpi-hidden-chip-hover-bg:#31404f;--bpi-issues-bg:rgba(55,39,25,.55);--bpi-issues-border:#5b4a36;--bpi-issue-text:#e6c792;--bpi-row-hover-overlay:rgba(76,167,232,.34);--bpi-row-pinned-overlay:#314b68;--bpi-row-hover-ring:rgba(76,167,232,.7);--bpi-row-pinned-ring:#76c9ff;--bpi-row-hover-text:#fff;--bpi-row-hover-shadow:0 0 7px rgba(109,190,255,.6);--bpi-row-selected-bg:#314b68;--bpi-drag-row-bar:#5ec9a3;--bpi-drag-row-bg:rgba(63,143,119,.30);--bpi-drag-row-text:#eafff7;--bpi-en-section-bg:rgba(76,167,232,.08);--bpi-quick-section-bg:rgba(167,149,198,.08);--bpi-dup-overlay:rgba(255,139,147,.22);--bpi-editor-bg:#111923;--bpi-hit-bg:#5a4413;--bpi-hit-text:#ffe6a8}
[data-bpi-theme="dark"]{--bpi-field-bg:#0b0d10;--bpi-field-border:#333a45;--bpi-switch-off:#39414f;--bpi-switch-knob:#e8edf5;--bpi-switch-off-border:#4c5668;--bpi-custom-bg:#17342c;--bpi-custom-border:#3f8f77;--bpi-custom-text:#7fdcbd;--bpi-surface:rgba(10,12,18,.72);--bpi-surface-2:#171b22;--bpi-surface-3:#14181f;--bpi-surface-table:#11151b;--bpi-surface-modal:#20252d;--bpi-text:#e6edf7;--bpi-text-muted:#aab5c5;--bpi-text-faint:#7f8a9a;--bpi-border:#3a4250;--bpi-border-2:#4c5668;--bpi-border-3:#29313d;--bpi-button-bg:#2c3340;--bpi-button-text:#e8edf5;--bpi-input-bg:#171b22;--bpi-input-text:#eef3fb;--bpi-head-bg:#252c36;--bpi-danger-bg:#65313b;--bpi-danger-bg-hover:#7d3d49;--bpi-danger-border:#8d4653;--bpi-danger-text:#ffd9de;--bpi-break-bg:#17342c;--bpi-break-bg-hover:#0f6e56;--bpi-break-border:#3f8f77;--bpi-break-text:#7fdcbd;--bpi-primary-bg:#285f8e;--bpi-primary-bg-hover:#346f9f;--bpi-primary-border:#3f83ba;--bpi-primary-text:#eaf3fb;--bpi-en-head-text:#cbe8ff;--bpi-en-hint-text:#91b4cf;--bpi-zh-head-text:#cbe9c8;--bpi-zh-hint-text:#91ad93;--bpi-cat-name-text:#9ee8a7;--bpi-info-text:#87bfea;--bpi-link-text:#76c9ff;--bpi-zh-text:#9ed0ff;--bpi-status-error:#ff8b93;--bpi-status-ok:#7fdda2;--bpi-status-busy:#ffd27a;--bpi-badge-warn:#e2ca78;--bpi-badge-source:#86d5c4;--bpi-badge-suspected:#7fc4e0;--bpi-badge-machine:#cbb1ff;--bpi-star-on:#ffd45f;--bpi-star-off:#8f9aaa;--bpi-quick-head-text:#d9ccff;--bpi-quick-hint-text:#a795c6;--bpi-handle-text:#7194ad;--bpi-handle-hover:#a9c8e4;--bpi-mini-hover-bg:#3c4b60;--bpi-handle-hover-bg:#2b3a4d;--bpi-chip-hide-bg:#1d2937;--bpi-waiting-bg:rgba(70,52,25,.5);--bpi-waiting-border:#8a5f16;--bpi-hidden-chip-hover-bg:#31404f;--bpi-issues-bg:rgba(55,39,25,.55);--bpi-issues-border:#5b4a36;--bpi-issue-text:#e6c792;--bpi-row-hover-overlay:rgba(76,167,232,.34);--bpi-row-pinned-overlay:#314b68;--bpi-row-hover-ring:rgba(76,167,232,.7);--bpi-row-pinned-ring:#76c9ff;--bpi-row-hover-text:#fff;--bpi-row-hover-shadow:0 0 7px rgba(109,190,255,.6);--bpi-row-selected-bg:#314b68;--bpi-drag-row-bar:#5ec9a3;--bpi-drag-row-bg:rgba(63,143,119,.30);--bpi-drag-row-text:#eafff7;--bpi-en-section-bg:rgba(76,167,232,.08);--bpi-quick-section-bg:rgba(167,149,198,.08);--bpi-dup-overlay:rgba(255,139,147,.22);--bpi-editor-bg:#111923;--bpi-hit-bg:#5a4413;--bpi-hit-text:#ffe6a8}
[data-bpi-theme="light"]{--bpi-field-bg:#fff;--bpi-field-border:#bbb;--bpi-switch-off:#c9ced6;--bpi-switch-knob:#fff;--bpi-switch-off-border:#b0b6bf;--bpi-custom-bg:#e6f6f1;--bpi-custom-border:#7fc9b0;--bpi-custom-text:#0f6e56;--bpi-surface:rgba(255,255,255,.92);--bpi-surface-2:#f5f5f5;--bpi-surface-3:#eaeaea;--bpi-surface-table:#f0f0f0;--bpi-surface-modal:#fff;--bpi-text:#333;--bpi-text-muted:#666;--bpi-text-faint:#999;--bpi-border:#ccc;--bpi-border-2:#bbb;--bpi-border-3:#e0e0e0;--bpi-button-bg:#e8e8e8;--bpi-button-text:#333;--bpi-input-bg:#fff;--bpi-input-text:#333;--bpi-head-bg:#e8e8e8;--bpi-danger-bg:#c0392b;--bpi-danger-bg-hover:#a53023;--bpi-danger-border:#a53023;--bpi-danger-text:#fff;--bpi-break-bg:#e6f6f1;--bpi-break-bg-hover:#0f6e56;--bpi-break-border:#7fc9b0;--bpi-break-text:#0f6e56;--bpi-primary-bg:#2b6cb0;--bpi-primary-bg-hover:#245a94;--bpi-primary-border:#1e4f85;--bpi-primary-text:#fff;--bpi-en-head-text:#17557f;--bpi-en-hint-text:#5b7f9e;--bpi-zh-head-text:#1f5c22;--bpi-zh-hint-text:#5b7d5e;--bpi-cat-name-text:#1e5a28;--bpi-info-text:#1f5f96;--bpi-link-text:#1a5f96;--bpi-zh-text:#18508a;--bpi-status-error:#b3261e;--bpi-status-ok:#1a7f45;--bpi-status-busy:#8a5a00;--bpi-badge-warn:#8a5a00;--bpi-badge-source:#0f6e56;--bpi-badge-suspected:#1c5f80;--bpi-badge-machine:#5b3fa8;--bpi-star-on:#a8750a;--bpi-star-off:#6b7280;--bpi-quick-head-text:#4a3a7a;--bpi-quick-hint-text:#6b5a8f;--bpi-handle-text:#4a6a85;--bpi-handle-hover:#1f5f96;--bpi-mini-hover-bg:#d8dee7;--bpi-handle-hover-bg:#e2e8ef;--bpi-chip-hide-bg:#eef1f5;--bpi-waiting-bg:#fdf1dc;--bpi-waiting-border:#d9a95a;--bpi-hidden-chip-hover-bg:#e8eef5;--bpi-issues-bg:#fdf6e8;--bpi-issues-border:#d9b98a;--bpi-issue-text:#7a5410;--bpi-row-hover-overlay:rgba(47,108,176,.06);--bpi-row-pinned-overlay:#cfe3f7;--bpi-row-hover-ring:rgba(47,108,176,.55);--bpi-row-pinned-ring:#1a5f96;--bpi-row-hover-text:#1f1f1f;--bpi-row-hover-shadow:none;--bpi-row-selected-bg:#dcebf8;--bpi-drag-row-bar:#45926f;--bpi-drag-row-bg:#d6efe4;--bpi-drag-row-text:#0f4d3a;--bpi-en-section-bg:rgba(43,108,176,.06);--bpi-quick-section-bg:rgba(107,85,148,.06);--bpi-dup-overlay:rgba(179,38,30,.12);--bpi-editor-bg:#fff;--bpi-hit-bg:#fbe49c;--bpi-hit-text:#7a5410}
.bpi-panel{box-sizing:border-box;width:100%;height:100%;min-height:var(--bpi-collapsed-height,390px);max-height:var(--bpi-collapsed-height,390px);padding:8px;display:flex;flex-direction:column;gap:7px;color:var(--bpi-text);font:12px/1.4 Arial,sans-serif;background:var(--bpi-surface);border:1px solid var(--bpi-border);border-radius:8px;overflow-y:auto;scrollbar-gutter:stable}.bpi-panel.bpi-fill{max-height:none}.bpi-panel.bpi-fill > .bpi-english-section{display:flex;flex-direction:column;flex:1 1 auto;min-height:0}.bpi-panel.bpi-fill > .bpi-english-section > *{flex:none}.bpi-panel.bpi-fill > .bpi-english-section > .bpi-english-token-view{flex:1 1 auto;min-height:92px;max-height:none}.bpi-panel.bpi-fill > .bpi-english-section > .bpi-english-editor{flex:1 1 auto;min-height:120px}
.bpi-english-section{flex:none;border:1px solid #3e7197;border-radius:7px;background-color:var(--bpi-en-section-bg);overflow:hidden}.bpi-english-head{display:flex;align-items:center;gap:7px;padding:5px 8px;background:var(--bpi-head-bg);color:var(--bpi-en-head-text);font-weight:700;flex-wrap:wrap}.bpi-english-hint{margin-left:auto;color:var(--bpi-en-hint-text);font-size:10px;font-weight:400}.bpi-english-token-view{box-sizing:border-box;width:100%;height:auto;min-height:92px;max-height:360px;overflow:auto;scrollbar-gutter:stable;padding:8px 9px;outline:none;color:var(--bpi-text);font:12px/1.85 Consolas,monospace;white-space:normal}.bpi-english-token-view:focus{box-shadow:inset 0 0 0 1px #5aa6d8}.bpi-english-editor{box-sizing:border-box;width:100%;height:120px;min-height:92px;max-height:420px;overflow-y:auto;resize:vertical;border:0;border-top:1px solid #3e7197;background-color:transparent;color:var(--bpi-text);padding:9px;outline:none;font:12px/1.55 Consolas,monospace}.bpi-english-editor:focus{box-shadow:inset 0 0 0 1px #5aa6d8}.bpi-english-token{font-family:Consolas,monospace;color:var(--bpi-text)}.bpi-english-token.bpi-linked{background:#147fc3;border-color:#76c9ff;color:#fff;box-shadow:0 0 0 1px rgba(118,201,255,.32)}
.bpi-mirror-section{flex:none;border:1px solid #488739;border-radius:7px;background:var(--bpi-surface);overflow:visible}.bpi-section-head{display:flex;align-items:center;gap:7px;padding:5px 8px;background:var(--bpi-head-bg);color:var(--bpi-zh-head-text);font-weight:700;flex-wrap:wrap}.bpi-section-hint{margin-left:auto;color:var(--bpi-zh-hint-text);font-size:10px;font-weight:400}.bpi-mirror-actions{display:flex;gap:4px;align-items:center;flex-wrap:wrap}.bpi-chinese-mirror{box-sizing:border-box;width:100%;height:auto;min-height:92px;max-height:360px;overflow:auto;resize:none;scrollbar-gutter:stable;padding:8px 9px;outline:none;color:var(--bpi-text);line-height:1.85;cursor:text;white-space:normal}.bpi-chinese-mirror:focus{box-shadow:inset 0 0 0 1px #70b35f}.bpi-category-table{display:grid;grid-template-columns:minmax(110px,150px) minmax(0,1fr);border:1px solid #315f3a;border-radius:6px;overflow:hidden;background:var(--bpi-surface-3)}.bpi-category-row{display:contents}.bpi-category-name,.bpi-category-content{padding:6px 8px;border-top:1px solid #31513a}.bpi-category-row:first-child .bpi-category-name,.bpi-category-row:first-child .bpi-category-content{border-top:0}.bpi-category-name{background:var(--bpi-head-bg);border-right:1px solid #31513a;color:var(--bpi-cat-name-text);font-weight:700}.bpi-category-content{min-width:0}.bpi-chinese-editor{box-sizing:border-box;width:100%;height:auto;min-height:110px;max-height:600px;overflow-y:auto;resize:vertical;border:0;border-top:1px solid #3b693c;background:var(--bpi-editor-bg);color:var(--bpi-text);padding:9px;outline:none;font:12px/1.65 Arial,sans-serif}.bpi-chinese-editor:focus{box-shadow:inset 0 0 0 1px #70b35f}.bpi-hidden{display:none!important}.bpi-mirror-empty{color:var(--bpi-text-muted)}.bpi-mirror-token{display:inline-flex;align-items:center;border:1px solid transparent;border-radius:5px;padding:0 3px;margin:1px 0;cursor:pointer;transition:background .12s,border-color .12s,color .12s}.bpi-mirror-token:hover{background:#345a3b;border-color:#5c8d63;color:#fff}.bpi-mirror-token.bpi-linked{background:#315f83;border-color:#76a9ff;color:#fff}
@@ -1248,7 +1132,17 @@ function injectBpiStyles() {
.bpi-batch-bar{flex:none;gap:8px;padding:6px 8px;margin-bottom:6px;border:1px solid var(--bpi-border-2);border-radius:6px;background:var(--bpi-surface-3)}.bpi-filter-active{background:#365f84;border-color:#5792c4;color:#fff}.bpi-star{color:var(--bpi-star-off)}.bpi-star.bpi-starred{color:var(--bpi-star-on);border-color:#8f772f}.bpi-result.bpi-result-selected{background:var(--bpi-row-selected-bg);outline:1px solid var(--bpi-link-text)}.bpi-result-star{font-size:14px;color:var(--bpi-star-off);align-self:center}.bpi-result-star.bpi-starred{color:var(--bpi-star-on)}
.bpi-quick-section{flex:none;border:1px solid #6b5594;border-radius:7px;background-color:var(--bpi-quick-section-bg);overflow:hidden}.bpi-quick-head{display:flex;align-items:center;gap:7px;padding:5px 8px;background:var(--bpi-head-bg);color:var(--bpi-quick-head-text);font-weight:700;flex-wrap:wrap}.bpi-quick-hint{margin-left:auto;color:var(--bpi-quick-hint-text);font-size:10px;font-weight:400}.bpi-quick-target{padding:0 8px 6px;font-size:11px;color:var(--bpi-text-muted)}.bpi-quick-target-linked{color:var(--bpi-link-text)}.bpi-quick-row{display:flex;gap:6px;align-items:flex-start;padding:6px 8px}.bpi-quick-input{flex:1;min-width:0;box-sizing:border-box;height:44px;min-height:44px;max-height:140px;overflow-y:auto;resize:vertical;border:1px solid var(--bpi-border-2);border-radius:5px;background:var(--bpi-input-bg);color:var(--bpi-input-text);padding:5px 7px;outline:none;font:12px/1.55 Arial,sans-serif}.bpi-quick-input:focus{box-shadow:inset 0 0 0 1px #a98be0}.bpi-quick-row .bpi-button{flex:none}
.bpi-drag-handle{flex:none;cursor:grab;color:var(--bpi-handle-text);font-size:11px;line-height:1;user-select:none;padding:1px 2px;border-radius:3px}.bpi-drag-handle:hover{color:var(--bpi-handle-hover);background:var(--bpi-handle-hover-bg)}.bpi-drag-handle:active{cursor:grabbing}
.bpi-row.bpi-dragging,.bpi-english-token.bpi-dragging{opacity:.45}.bpi-english-token.bpi-dragging{cursor:grabbing}
/* 拖动中:明细表不用淡化(降透明度会把字看糊),改成左侧实心色条 + 高亮底 +
亮色文字,一眼看得出「就是这一行在被拖」。节点那边只留抬起阴影,不加底色 */
.bpi-row.bpi-dragging{background-color:var(--bpi-drag-row-bg);background-image:none;box-shadow:inset 3px 0 0 0 var(--bpi-drag-row-bar);outline:1px solid var(--bpi-drag-row-bar);outline-offset:-1px}
.bpi-row.bpi-dragging .bpi-cell{color:var(--bpi-drag-row-text)}
.bpi-row.bpi-dragging .bpi-drag-handle,.bpi-row.bpi-dragging .bpi-hide-btn{color:var(--bpi-drag-row-text)}
.bpi-english-token.bpi-dragging{opacity:.45;cursor:grabbing}
/* 拖动实时预览:被拖的那一项复制成 .bpi-drag-float 挂到 body 上跟着光标走,
原来的位置由 bpi-drag-source 占位(透明但仍参与命中测试,所以鼠标停在空位上
算「停在自己身上」,不会把落点算到别处去) */
.bpi-drag-source{opacity:0!important}
.bpi-drag-float{box-shadow:0 10px 26px rgba(0,0,0,.4);transition:transform .09s linear;cursor:grabbing;will-change:transform}
.bpi-drop-indicator{position:absolute;left:3px;right:3px;height:0;border-top:2px solid #76c9ff;box-shadow:0 0 5px rgba(118,201,255,.55);z-index:3;pointer-events:none}
.bpi-break-row{display:flex;align-items:center;gap:8px;border-top:1px solid var(--bpi-border-3);background:var(--bpi-break-bg);color:var(--bpi-break-text);padding:6px 10px;cursor:default}
.bpi-row.bpi-break-row:hover{background-color:var(--bpi-break-bg-hover);background-image:none;outline:0;color:#fff}
@@ -1268,7 +1162,34 @@ function injectBpiStyles() {
.bpi-hidden-term{text-decoration:line-through;color:var(--bpi-text-faint)}
.bpi-hide-btn-off{color:var(--bpi-badge-warn)}
.bpi-eye-off{color:var(--bpi-badge-warn)}
.bpi-chip-hide{position:absolute;top:2px;right:3px;display:none;cursor:pointer;padding:1px 2px;border-radius:999px;background:var(--bpi-chip-hide-bg);border:1px solid var(--bpi-border-2);color:var(--bpi-link-text);line-height:1;z-index:2}.bpi-token-card:hover .bpi-chip-hide{display:inline-flex}.bpi-chip-hide:hover{background:var(--bpi-primary-bg);border-color:var(--bpi-primary-border);color:#fff}
.bpi-chip-hide{position:absolute;bottom:2px;right:3px;display:none;cursor:pointer;padding:1px 2px;border-radius:999px;background:var(--bpi-chip-hide-bg);border:1px solid var(--bpi-border-2);color:var(--bpi-link-text);line-height:1;z-index:2}.bpi-token-card:hover .bpi-chip-hide{display:inline-flex}.bpi-chip-hide:hover{background:var(--bpi-primary-bg);border-color:var(--bpi-primary-border);color:#fff}
/* 删除的 × 是选中那张卡的右上角角标,和左上角的眼睛对称;不再跟英文标签挤在一行 */
.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 从四角向心长出 → 颜色从中心铺满
→ 上下压两条灰边 → 整块从右往左擦掉。只加在正在删的那张卡上,别的卡不受影响 */
: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)}
: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}
: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}
: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}}
.bpi-token-card.bpi-inserting{animation:bpi-card-insert .42s cubic-bezier(.32,.72,.28,1)}
.bpi-token-line{display:flex;flex-wrap:wrap;align-items:flex-end;gap:2px 0}
.bpi-token-card{position:relative;display:inline-flex;flex-direction:column;align-items:flex-start;max-width:100%;border:1px solid var(--bpi-border-3);border-radius:5px;background:var(--bpi-surface-3);padding:1px 5px;margin:2px 3px 2px 0;vertical-align:top;cursor:pointer}
.bpi-token-card:hover{border-color:var(--bpi-border-2)}
@@ -1278,6 +1199,11 @@ function injectBpiStyles() {
.bpi-token-card.bpi-card-linked .bpi-token-card-zh{color:#fff}
.bpi-token-card.bpi-card-linked .bpi-english-token{background:transparent;border-color:transparent;box-shadow:none;color:#fff}
.bpi-token-card-zh{min-height:15px;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--bpi-text-muted);font:11px/1.35 Arial,sans-serif}
/* 落点提示画在整张卡片上(英文行 + 中文小块一起亮)。必须排在
.bpi-card-linked 之后:两条规则特异度相同,靠后定义的才盖得住选中态
自己的 box-shadow,否则拖到已选中的卡片上看不出插入位置 */
.bpi-token-card.bpi-drop-before{box-shadow:-3px 0 0 0 #76c9ff,0 0 6px rgba(118,201,255,.45)}
.bpi-token-card.bpi-drop-after{box-shadow:3px 0 0 0 #76c9ff,0 0 6px rgba(118,201,255,.45)}
.bpi-category-content{display:flex;flex-wrap:wrap;gap:2px 3px}
.bpi-break-chip{position:relative;display:inline-flex;align-items:center;gap:3px;border:1px dashed var(--bpi-break-border);border-radius:5px;background:var(--bpi-break-bg);color:var(--bpi-break-text);padding:0 5px;margin:1px 0;cursor:grab;user-select:none;font-family:Consolas,monospace}
.bpi-break-chip:hover,.bpi-break-chip.bpi-drop-target{background:var(--bpi-break-bg-hover);border-color:var(--bpi-break-text);color:#fff}
@@ -1385,9 +1311,54 @@ function injectBpiStyles() {
.bpi-about-modal{width:min(460px,calc(100vw - 32px))}.bpi-about-name{margin-bottom:7px;color:var(--bpi-text);font-weight:700}.bpi-config-note{color:var(--bpi-text-muted);font-size:10px;word-break:break-all}
/* 同一句说明变成警告时用:只换颜色,不动字号字重,免得换行数变了把下面的表单顶走 */
.bpi-config-note-warn{color:var(--bpi-status-error)}
/* 「获取模型」:输入框照旧独占一行宽度,按钮挤在右边不换行 */
.bpi-model-line{display:flex;gap:6px;align-items:stretch;min-width:0}
.bpi-model-line input{flex:1 1 auto;min-width:0}
.bpi-model-line .bpi-button{flex:none;white-space:nowrap;background-color:var(--bpm-accent);border-color:var(--bpm-accent-border);color:#fff}
.bpi-model-line .bpi-button:hover{background-color:var(--bpm-accent-hover);border-color:var(--bpm-accent-border);color:#fff}
.bpi-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)}
@@ -1410,10 +1381,25 @@ function injectBpiStyles() {
.bpm-loading{padding:24px;text-align:center;color:var(--bpi-text-faint)}
.bpi-form-actions{display:flex;justify-content:flex-end;gap:7px;margin-top:12px;flex-wrap:wrap}
/* 助手设置在 ComfyUI「设置」面板里:原生那一行是「标签 + 控件」,把标签收掉、控件占满整行 */
.bpi-settings-form{box-sizing:border-box;width:100%;padding:2px 0 6px}.bpi-settings-form h3{margin:0 0 10px;font-size:15px}
div:has(> .form-input > div > .bpi-settings-form){align-items:flex-start}
div:has(> .form-input > div > .bpi-settings-form) > .form-label{display:none}
div:has(> .form-input > div > .bpi-settings-form) > .form-input{flex:1 1 0%;min-width:0}
.bpi-settings-form{box-sizing:border-box;width:100%;padding:2px 0 6px}
/* 设置面板每一项 = 名称列 + 控件列,名称列宽度跟着名字长短走:插件这两项一个叫
「通用」一个叫「助手设置」,字数不同,右边的内容就被推得差了一格。
让名称列不占位、控件列占满,两块都从最左边开始,标题由各自的 h3 显示。
只在「我们的块不在第一列」时才生效,结构对不上时不会把内容自己藏掉。 */
*:has(> :not(:first-child) > .bpi-settings-form){align-items:flex-start}
*:has(> :not(:first-child) > .bpi-settings-form) > :first-child{display:none}
*:has(> :not(:first-child) > .bpi-settings-form) > :not(:first-child){flex:1 1 0%;min-width:0;justify-content:flex-start}
/* ComfyUI 给控件列自带 justify-end:宽度一占满就会把内容推到最右,这里改回靠左 */
*:has(> .bpi-settings-form){flex:1 1 0%;min-width:0}
/* 设置面板里的可展开分组:直接用原生 details,三角自己画,展开状态由 open 属性管 */
.bpi-fold{grid-column:1 / -1;margin-top:8px;border:1px solid var(--bpi-border-2);border-radius:6px}
.bpi-fold>summary{display:flex;align-items:center;gap:6px;padding:7px 10px;font-size:12px;font-weight:600;color:var(--bpi-text);cursor:pointer;list-style:none;user-select:none}
.bpi-fold>summary::-webkit-details-marker{display:none}
.bpi-fold>summary::before{flex:none;content:"";width:0;height:0;border-left:5px solid var(--bpi-text-muted);border-top:4px solid transparent;border-bottom:4px solid transparent;transition:transform .15s ease}
.bpi-fold[open]>summary::before{transform:rotate(90deg)}
.bpi-fold>summary:hover{background-image:linear-gradient(var(--bpi-row-hover-overlay),var(--bpi-row-hover-overlay))}
.bpi-fold-body{padding:8px 10px 10px;border-top:1px solid var(--bpi-border-3);display:flex;flex-direction:column;gap:8px}
.bpi-fold-note{font-size:10px;color:var(--bpi-text-faint)}
.bpi-switch-row{display:flex;align-items:center;gap:8px;cursor:pointer;user-select:none;width:fit-content}
.bpi-switch-row input{position:absolute;width:0;height:0;opacity:0;margin:0}
.bpi-switch-track{position:relative;flex:0 0 auto;width:34px;height:18px;border-radius:9px;background-color:var(--bpi-switch-off);border:1px solid var(--bpi-switch-off-border);transition:background-color .16s ease,border-color .16s ease}
@@ -1447,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);
}
}
@@ -1464,6 +1490,7 @@ export {
UNINDEXED_PACK_ID,
UPSTREAM_ARRIVED_EVENT,
applyBpiAppearance,
bpiFetch,
applyLanguageToDom,
buildTagFields,
button,
@@ -1487,6 +1514,7 @@ export {
importTags,
bulkUpdateTags,
injectBpiStyles,
listAssistantModels,
listModelNames,
listSavedPrompts,
loadDictionary,
@@ -1494,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);
}
+262
View File
@@ -0,0 +1,262 @@
// 右下角贴边堆叠 Toast(临时提示)
// 模板原件:.workbuddy/refs/toast-template.html —— 动画、贴边造型、堆叠与防卡死写法照搬。
// 和右上角那套 js/bpi_notify.js 是两套,互不干扰:那边是双层纸 + 从上往下 + 展开正文,
// 这边是贴着右下角长出来、强调色遮罩从左往右刷过、整条向右滑走。
import { t } from "./i18n.js";
const STYLE_ID = "bpi-toast-style";
// 和右上角那套一样,开关只存本地:这是表现层的事,不进后端 assistant_settings
const STORAGE_KEY = "bpi.toast.v1";
// 默认开:逐条提示本来就在用,关掉才会退回状态栏
export function toastEnabled() {
try {
return window.localStorage.getItem(STORAGE_KEY) !== "0";
} catch (error) {
return true;
}
}
export function setToastEnabled(next) {
try {
window.localStorage.setItem(STORAGE_KEY, next ? "1" : "0");
} catch (error) {
/* 隐私模式下写不进去就算了,本次会话内仍按传入值生效 */
}
}
// JS 与 CSS 必须同步:COVER_DURATION 是 .bpi-toast-cover 的时长,SLIDE_DURATION 是 .is-out 的时长
const MAX_VISIBLE = 5;
const AUTO_DISMISS = 3000;
const COVER_DURATION = 180;
const SLIDE_DURATION = 320;
const REMOVE_DELAY = SLIDE_DURATION + 40;
// 同一车道(lane)最多同时挂几条:满了就顶掉最旧的一条。
// 车道是给「一批同类消息排队」准备的(试过给批量翻译用,已撤),目前没人传 lane
const LANE_MAX = 3;
// 被顶掉之前至少要显示这么久,免得结果来得太快、字还没看清就被刷走
const LANE_MIN_HOLD = 1200;
// 一个操作一类色,别都挤在蓝里:
// 蓝 = 改内容(应用改写 / 权重 / 隐藏恢复 / 词条保存)、紫 = 插入、红 = 删除、
// 青 = 移动、灰 = 撤销重做、绿 = 翻译成功、橙 = 重复开关
export const TOAST_ACCENTS = {
edit: "#60a5fa",
insert: "#a78bfa",
remove: "#e06c75",
move: "#22d3ee",
history: "#94a3b8",
translate: "#34d399",
duplicate: "#fbbf24",
error: "#e06c75",
};
// 图标是塞进 24×24 viewBox 的内联 SVG 片段,颜色靠 currentColor 吃 --bpi-toast-accent
const ICONS = {
duplicate:
'<rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>',
translate:
'<path d="M5 8L10 13M4 14L10 8L12 5M2 5H14M7 2H8M12.913 17H20.087M12.913 17L11 21M12.913 17L15.7783 11.009C16.0092 10.5263 16.1246 10.2849 16.2826 10.2086C16.4199 10.1423 16.5801 10.1423 16.7174 10.2086C16.8754 10.2849 16.9908 10.5263 17.2217 11.009L20.087 17M20.087 17L22 21"/>',
notice: '<circle cx="12" cy="12" r="9"/><path d="M12 8v5M12 16h.01"/>',
edit: '<path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 1 1 3 3L7 19l-4 1 1-4 12.5-12.5z"/>',
insert: '<path d="M12 5v14M5 12h14"/>',
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 =
'<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18"/></svg>';
// 强调线(::before)、图标、抹开遮罩三处共用这一个变量
const CSS = `
.bpi-toast-stack{position:fixed;right:0;bottom:0;width:340px;max-width:calc(100vw - 28px);display:flex;flex-direction:column-reverse;gap:10px;z-index:9999;pointer-events:none}
/* 亮色下不用那么重的黑影 */
[data-bpi-theme="light"] .bpi-toast-stack{--bpi-toast-shadow:0 10px 26px rgba(0,0,0,.14),0 2px 6px rgba(0,0,0,.10)}
.bpi-toast{position:relative;display:flex;align-items:center;gap:10px;padding:13px 16px;background:var(--bpi-surface-modal,#20252d);border:1px solid var(--bpi-border,#3a4250);border-right:0;border-radius:8px 0 0 8px;box-shadow:var(--bpi-toast-shadow,0 10px 30px rgba(0,0,0,.5),0 2px 6px rgba(0,0,0,.3));font-size:13px;line-height:1.45;color:var(--bpi-text,#e6edf7);pointer-events:auto;overflow:hidden;opacity:0;transform:translateX(100%);transition:transform .45s cubic-bezier(.16,1,.3,1),opacity .35s ease;will-change:transform,opacity}
.bpi-toast.is-in{opacity:1;transform:translateX(0)}
/* 位置被上下顶动时的平滑位移;必须写在 .is-out 之前,退场那条要能盖过它 */
.bpi-toast.bpi-flip{transition:transform .35s cubic-bezier(.16,1,.3,1)}
/* 退场全程不透明:淡出会让人觉得是「直接消失」 */
.bpi-toast.is-out{opacity:1;transform:translateX(100%);transition:transform .32s cubic-bezier(.55,0,.8,.4)}
.bpi-toast::before{content:"";position:absolute;left:0;top:0;bottom:0;width:3px;border-radius:8px 0 0 8px;background:var(--bpi-toast-accent,#fbbf24);z-index:1}
.bpi-toast-cover{position:absolute;inset:0;border-radius:8px 0 0 8px;background:var(--bpi-toast-accent,#fbbf24);transform:translateX(-101%);transition:transform .18s cubic-bezier(.45,0,.2,1);z-index:5;pointer-events:none;will-change:transform}
.bpi-toast.is-covering .bpi-toast-cover{transform:translateX(0)}
.bpi-toast-icon{flex:0 0 20px;width:20px;height:20px;display:grid;place-items:center;color:var(--bpi-toast-accent,#fbbf24);position:relative;z-index:2}
.bpi-toast-text{flex:1;min-width:0;word-break:break-word;position:relative;z-index:2}
.bpi-toast-close{flex:0 0 20px;width:20px;height:20px;display:grid;place-items:center;padding:0;border:0;border-radius:4px;background:transparent;color:var(--bpi-text-muted,#aab5c5);cursor:pointer;position:relative;z-index:6;transition:background .15s ease,color .15s ease}
.bpi-toast-close:hover{background:var(--bpi-handle-hover-bg,#2b3a4d);color:var(--bpi-text,#e6edf7)}
@media (prefers-reduced-motion: reduce){
.bpi-toast,.bpi-toast-cover{transition-duration:.01ms !important}
}
`;
let stack = null;
function injectStyle() {
if (document.getElementById(STYLE_ID)) return;
const style = document.createElement("style");
style.id = STYLE_ID;
style.textContent = CSS;
document.head.appendChild(style);
}
function ensureStack() {
if (stack && stack.isConnected) return stack;
injectStyle();
stack = document.createElement("div");
stack.className = "bpi-toast-stack";
document.body.appendChild(stack);
return stack;
}
// FLIP:记下每条当前的位置,等布局变了再让它们从旧位置滑到新位置,避免「闪现」
function snapshotTops(host, skip) {
const tops = new Map();
Array.from(host.children).forEach((el) => {
if (el !== skip) tops.set(el, el.getBoundingClientRect().top);
});
return tops;
}
function reflow(host, tops) {
tops.forEach((top, el) => {
// 只挪已经入场、又没在退场的:正在入场/退场的动自己的动画,别插手
if (!el.isConnected || el._dismissed || !el.classList.contains("is-in")) return;
const delta = top - el.getBoundingClientRect().top;
if (!delta) return;
el.style.transition = "none";
el.style.transform = `translateY(${delta}px)`;
void el.offsetWidth;
// 关键:必须把内联的 transition:none 交回 CSS,否则它一直压着 .is-out 的过渡,
// 这条之后退场就会「动画没播完直接消失」
el.style.transition = "";
el.classList.add("bpi-flip");
el.style.transform = "";
setTimeout(() => el.classList.remove("bpi-flip"), 380);
});
}
// 移除一条时让剩下的平滑补位
function dropEl(host, el) {
const tops = snapshotTops(host, el);
el.remove();
reflow(host, tops);
}
function dismiss(el, immediate = false) {
if (!el) return;
// 被顶走的分支必须排在最前面并无条件移除:
// 先判 _dismissed 的话,正在等动画的那条会一直留在 DOM 里,while 循环反复拿到它 → 卡死
if (immediate) {
el._dismissed = true;
clearTimeout(el._timer);
dropEl(el.parentNode, el);
return;
}
if (el._dismissed) return;
el._dismissed = true;
clearTimeout(el._timer);
// 补位动画可能还挂着,退场前先摘掉,别让它抢 .is-out 的过渡
el.classList.remove("bpi-flip");
el.style.transform = "";
el.style.transition = "";
el.classList.add("is-covering");
setTimeout(() => {
if (!el.isConnected) return;
el.classList.add("is-out");
setTimeout(() => {
if (el.isConnected) dropEl(el.parentNode, el);
}, REMOVE_DELAY);
}, COVER_DURATION);
}
// 车道内还活着(没被标记退场)的条目,顺序是 新 → 旧
function laneAlive(host, lane) {
return Array.from(host.children).filter((el) => el._lane === lane && !el._dismissed);
}
// 只多一条时给足停留时间;积压多了就立刻收,不然会越堆越高
function trimLane(host, lane) {
const alive = laneAlive(host, lane);
const excess = alive.length - LANE_MAX;
for (let i = 0; i < excess; i += 1) {
const victim = alive[alive.length - 1 - i];
if (!victim) break;
const wait = Math.max(0, LANE_MIN_HOLD - (Date.now() - victim._shownAt));
if (!wait || i > 0) {
dismiss(victim);
continue;
}
setTimeout(() => {
if (victim.isConnected && !victim._dismissed) dismiss(victim);
}, wait);
}
}
// message 走 t();type 决定强调色与图标,也可以直接传 accent / icon 覆盖。
// lane 相同的一批会共用 3 个位置:新的从最下面长出来,最旧的从上面被顶走。
export function showToast(message, options = {}) {
// 关掉时直接不建 DOM:调用方自己退回状态栏(返回 false 就是告诉它「我没显示」)
if (!toastEnabled()) return false;
const host = ensureStack();
const type = options.type || "";
const accent = options.accent || TOAST_ACCENTS[type] || TOAST_ACCENTS.duplicate;
const markup = options.icon || ICONS[type] || ICONS.notice;
const el = document.createElement("div");
el.className = "bpi-toast";
el.style.setProperty("--bpi-toast-accent", accent);
const cover = document.createElement("div");
cover.className = "bpi-toast-cover";
const icon = document.createElement("span");
icon.className = "bpi-toast-icon";
icon.innerHTML =
`<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"` +
` stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${markup}</svg>`;
const text = document.createElement("span");
text.className = "bpi-toast-text";
text.textContent = t(message);
const close = document.createElement("button");
close.className = "bpi-toast-close";
close.type = "button";
close.title = t("Close");
close.setAttribute("aria-label", t("Close"));
close.innerHTML = CLOSE_SVG;
el.append(cover, icon, text, close);
el._lane = options.lane || "";
el._shownAt = Date.now();
// column-reverse 的主轴起点在下方,插到最前面 = 视觉最下面;旧的自然往上顶
const before = snapshotTops(host, null);
host.insertBefore(el, host.firstChild);
// 全局上限:超了取 DOM 最后一条(视觉最上面、最旧的那条)直接移除,不播动画
while (host.children.length > MAX_VISIBLE) {
dismiss(host.lastElementChild, true);
}
if (el._lane) trimLane(host, el._lane);
reflow(host, before);
// 双 rAF:确保初始样式(translateX(100%))已经渲染过一帧,否则过渡不会触发
requestAnimationFrame(() => requestAnimationFrame(() => {
if (el.isConnected) el.classList.add("is-in");
}));
close.addEventListener("click", () => dismiss(el));
el._timer = setTimeout(() => dismiss(el), AUTO_DISMISS);
return el;
}
+46 -30
View File
@@ -7,7 +7,7 @@
// · 运行时按英文原文查中文表,查不到就原样显示英文 —— 缺翻译只会退回英文,不会出现空文案
// · 「跟随 ComfyUI」读 ComfyUI 的 Comfy.Locale 设置,中文系语言走中文,其余走英文
//
// 带变量的文案(例如 `deleted “${term}”; press Ctrl+Z to undo`)没法直接整句做键,
// 带变量的文案(例如 `deleted “${term}”`)没法直接整句做键,
// 这里把「“...”」和数字统一折叠成 {} 再查表,命中后按出现顺序回填。
const STORAGE_KEY = "bpi.language.v1";
@@ -130,8 +130,8 @@ const ZH = {
"输入时保持文本编辑;失焦或点「完成」后切回标签视图",
"Switch between the English text editor and the linked tag view":
"在英文文本编辑框与联动标签视图之间切换",
"Requires a second click to clear the actual English output; after clearing, click the same button or press Ctrl+Z to undo":
"需要再点一次才会清空英文实际输出;清空后点同一个按钮或按 Ctrl+Z 可撤销",
"Requires a second click to clear the actual English output; after clearing, click the same button to undo":
"需要再点一次才会清空英文实际输出;清空后点同一个按钮撤销",
"Save the current English prompt to favorites in the user directory (a reference image can be attached)":
"把当前英文提示词保存到用户目录的收藏里(可附带参考图)",
"Hidden tags will not enter the actual output": "隐藏的标签不会进入实际输出",
@@ -193,18 +193,17 @@ const ZH = {
"Only optimizes existing English; does not translate": "只优化已有英文,不做翻译",
"press Anima stable sort by recommended categories, one row per non-empty category; natural language and BREAK/AND kept intact":
"按 Anima 推荐分类做稳定排序,每个非空分类一行;自然语言与 BREAK/AND 原样保留",
"Drag to reorder tags; or select then press Alt+↑/↓ to nudge; Ctrl+Z to undo":
"拖动可调整标签顺序;或选中后按 Alt+↑/↓ 微调;Ctrl+Z 撤销",
"applied {}; press Ctrl+Z to undo": "已应用 {};按 Ctrl+Z 可撤销",
"deleted {}; press Ctrl+Z to undo": "已删除「{}」;按 Ctrl+Z 可撤销",
"{}; press Ctrl+Z to undo": "{};按 Ctrl+Z 可撤销",
"moved {}; press Ctrl+Z to undo": "已移动「{}」;按 Ctrl+Z 可撤销",
"rewrote {}; press Ctrl+Z to undo": "已改写「{}」;按 Ctrl+Z 可撤销",
"rewrote {} tags; press Ctrl+Z to undo": "已改写 {} 个标签;按 Ctrl+Z 可撤销",
"hidden {} (excluded from actual output); can be restored anytime in the hidden section; press Ctrl+Z to undo":
"已隐藏「{}」(不计入实际输出);随时可在隐藏区恢复;按 Ctrl+Z 可撤销",
"restored {} to its original position; press Ctrl+Z to undo": "已把「{}」恢复到原位置;按 Ctrl+Z 可撤销",
"restored {} hidden tags; press Ctrl+Z to undo": "已恢复 {} 个隐藏标签;按 Ctrl+Z 可撤销",
"Drag to reorder tags; or select then press Alt+↑/↓ to nudge":
"拖动可调整标签顺序;或选中后按 Alt+↑/↓ 微调",
"applied {}": "已应用 {}",
"deleted {}": "已删除「{}」",
"moved {}": "已移动「{}」",
"rewrote {}": "已改写「{}」",
"rewrote {} tags": "已改写 {} 个标签",
"hidden {} (excluded from actual output); can be restored anytime in the hidden section":
"已隐藏「{}」(不计入实际输出);随时可在隐藏区恢复",
"restored {} to its original position": "已把「{}」恢复到原位置",
"restored {} hidden tags": "已恢复 {} 个隐藏标签",
"{} is already at the front": "「{}」已经在最前面",
"{} is already at the back": "「{}」已经在最后面",
"temporarily modified {}; not saved to dictionary": "已临时修改「{}」,未存入词库",
@@ -225,10 +224,10 @@ const ZH = {
"Hide {}: keeps it visible here (strikethrough) but excludes it from the actual output":
"隐藏「{}」:仍显示在节点里(加删除线),但不参与实际输出",
"Unhide {}: back into the actual output": "取消隐藏「{}」:重新参与实际输出",
"Hidden {}: stays visible and editable here, but excluded from the actual output; click the eye-slash to unhide; press Ctrl+Z to undo":
"已隐藏「{}」:仍显示、可照常编辑,但不参与实际输出;点眼睛斜线图标取消隐藏;按 Ctrl+Z 可撤销",
"Unhidden {}: back in the actual output; press Ctrl+Z to undo":
"已取消隐藏「{}」:重新参与实际输出;按 Ctrl+Z 可撤销",
"Hidden {}: stays visible and editable here, but excluded from the actual output; click the eye-slash to unhide":
"已隐藏「{}」:仍显示、可照常编辑,但不参与实际输出;点眼睛斜线图标取消隐藏",
"Unhidden {}: back in the actual output":
"已取消隐藏「{}」:重新参与实际输出",
"These tags stay visible above (strikethrough) but will not enter the actual output; click a tag to unhide":
"这些标签仍显示在上方(加删除线),但不会进入实际输出;点击标签取消隐藏",
"Click to unhide {}": "点击取消隐藏「{}」",
@@ -259,9 +258,9 @@ const ZH = {
"换行|拖到某个标签上可移动位置;点 × 删除(上下两行合并)",
"Delete this line break": "删除这个换行",
"Enter tags first, then insert a line break": "先输入标签,再插入换行",
"Inserted a line break; press Ctrl+Z to undo": "已插入换行;按 Ctrl+Z 撤销",
"Deleted a line break; press Ctrl+Z to undo": "已删除换行;按 Ctrl+Z 撤销",
"Moved the line break; press Ctrl+Z to undo": "已移动换行;按 Ctrl+Z 撤销",
"Inserted a line break": "已插入换行",
"Deleted a line break": "已删除换行",
"Moved the line break": "已移动换行",
"Check this machine translation; use the toolbar above the table to add several entries to the personal dictionary at once":
"勾选这条机器翻译;用表格上方的工具条可以一次把多条加入个人词库",
"Drag to resize the two columns; double-click to reset to the default ratio": "拖动调整左右两栏宽度;双击恢复默认比例",
@@ -431,6 +430,11 @@ const ZH = {
"Ollama only accepts a loopback address on this machine; anything else is rejected before a request is sent.":
"Ollama 只能填本机回环地址;其它地址在发出请求前就会被拒绝。",
"Model Name": "模型名称",
"Fetch Models": "获取模型",
"Fetch Models ({})": "获取模型({})",
"Fetching...": "获取中…",
"Fetched {} models": "已获取 {} 个模型",
Close: "关闭",
"Use LM Studio Local Preset": "使用 LM Studio 本地预设",
"Connects to localhost:1234 by default; no external API required.": "默认连接 localhost:1234,不需要外部接口。",
"LM Studio default URL filled; select a loaded model ID, then Save & Test": "已填入 LM Studio 默认地址;选择已加载的模型 ID 后保存并测试",
@@ -463,8 +467,16 @@ const ZH = {
"Follow ComfyUI language setting": "跟随 ComfyUI 语言设置",
"Choose the language for this plugin's own panels. \"Follow ComfyUI\" reads the ComfyUI language setting; \"Chinese\" and \"English\" force one. Applied to open panels right away; reload the page if anything still shows the old language.":
"选择本插件面板的语言。「跟随 ComfyUI 语言设置」会随 ComfyUI 的语言切换;「中文」「英文」为强制指定。已打开的面板会立即更新;若个别位置仍是旧语言,刷新页面即可。",
"Appearance & Language": "语言与外观",
"Appearance and language saved": "外观与语言已保存",
"Notification popup (top right)": "右上角通知弹窗",
"Operation results show as a popup in the top-right corner instead of the status line.": "操作结果改用右上角弹窗显示,不再写在状态栏上。",
"Notifications moved to the top-right popup": "已改为右上角弹窗提示",
"Notifications back in the status bar": "已改回状态栏提示",
// 弹窗标题:按提示类型给一个短词,消息本身放展开后的正文里
"Done": "已完成",
"Something went wrong": "出错了",
"Working...": "处理中",
"Notice": "提示",
// ── 标签来源 / 分类 / 模式 ──────────────────────────────────────────
"Inspector assistant": "检查器助手",
@@ -622,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: {}": "连接正常:{}",
@@ -635,7 +647,7 @@ const ZH = {
"No matching favorites.": "没有匹配的收藏。",
"No favorites yet. Click the star button on a node to save the current prompt.":
"还没有收藏。点节点上的星标按钮即可保存当前提示词。",
"This will overwrite node #{} prompt. Cannot be undone with Ctrl+Z.": "这会覆盖节点 #{} 的提示词,Ctrl+Z 无法撤销。",
"This will overwrite node #{} prompt. This cannot be undone.": "这会覆盖节点 #{} 的提示词,此操作无法撤销。",
"Loaded to node #{}": "已载入到节点 #{}",
"Delete {}? Reference image will be deleted too.": "删除「{}」?参考图也会一并删除。",
"Node #{}": "节点 #{}",
@@ -679,10 +691,14 @@ const ZH = {
"「{}」第 {} / {} 处;再点一次跳下一处,双击删除这一处",
"Click a duplicate chip once to step to it before double-clicking to delete":
"先单击一次重复胶囊定位到某一处,再双击才会删除",
"{} now unique: only the first occurrence enters the actual output; press Ctrl+Z to undo":
"「{}」已设为唯一:只有第一次出现进入实际输出;按 Ctrl+Z 可撤销",
"{} no longer unique: all occurrences enter the actual output again; press Ctrl+Z to undo":
"「{}」已取消唯一:所有重复项重新进入实际输出;按 Ctrl+Z 可撤销",
"{} now unique": "「{}」已设为唯一",
"{} no longer unique": "「{}」已取消唯一",
"Bottom-right toast": "右下角提示条",
"Bottom-right toast on": "已开启右下角提示条",
"Bottom-right toast off": "已关闭右下角提示条,提示回到状态栏",
"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.":
"逐条操作结果(翻译、标签增删改移、重复开关)用右下角堆叠的小条显示;关掉就全部回到状态栏,能省一点性能开销。",
"added {}": "已添加「{}」",
"Query: {} | {} is on: only the first occurrence reaches the actual output": "查询「{}」|已开「{}」:只有第一次出现进入实际输出",
"keep only the first {}": "只保留第一次出现的「{}」",
"restore duplicates of {}": "恢复「{}」的重复项",
+554 -196
View File
@@ -3,33 +3,38 @@ 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 { notify, notifyEnabled, setNotifyEnabled } from "./bpi_notify.js";
import { setToastEnabled, toastEnabled } from "./bpi_toast.js";
import {
dragPreviewNodeEnabled,
dragPreviewTableEnabled,
setDragPreviewNodeEnabled,
setDragPreviewTableEnabled,
} from "./bpi_drag_preview.js";
import { deleteAnimNodeEnabled, setDeleteAnimNodeEnabled } from "./bpi_delete_anim.js";
import {
MANAGER_OPEN_EVENT,
MANAGER_TAB_ID,
button,
deleteCommunityPack,
deletePersonalTag,
deleteSavedPrompt,
downloadJson,
element,
exportDictionaryPack,
exportPersonalDictionary,
exportSavedPrompts,
field,
getAssistantConfig,
helpMark,
importCommunityPack,
importSavedPrompts,
importTags,
bulkUpdateTags,
applyBpiAppearance,
bpiFetch,
injectBpiStyles,
listSavedPrompts,
listAssistantModels,
loadDictionary,
loadPreferences,
openSavePromptDialog,
openTagDialog,
savedPromptImageUrl,
runInspectorAssistant,
saveAssistantConfig,
savePreferences,
@@ -43,6 +48,7 @@ import {
t,
testAssistantConnection,
} from "./bpi_shared.js";
import { createLibraryView, openLibraryItemDialog } from "./bpi_library.js";
const SECTIONS = [
["tag-manager", "Tag Manager"],
@@ -51,6 +57,11 @@ const SECTIONS = [
["favorites", "Favorites"],
];
// 「跟随选中节点」的开关状态:默认关,关着就是原来只能在下拉里选节点的行为
const FOLLOW_SELECTION_KEY = "bpi.followSelection.v1";
// 系统「减少动态效果」已经处理过一次的标记:只做一次,之后用户手动打开就不再覆盖
const REDUCED_MOTION_KEY = "bpi.reducedMotion.v1";
const manager = {
data: { tags: [], builtin: [], user: [], packs: [] },
preferences: loadPreferences(),
@@ -58,8 +69,6 @@ const manager = {
section: "tags",
loaded: false,
favoritesLoaded: false,
favorites: [],
favoritesChangeHandler: null,
tagManagerNodeId: null,
largeMatches: [],
largeQuery: "",
@@ -584,17 +593,53 @@ function openImportPreview(payload) {
modal.addEventListener("mousedown", (event) => event.stopPropagation());
}
// 设置面板每一项 = 名称列 + 控件列,名称列宽度跟着设置项名字长短走:
// 插件这两项一个「通用」一个「助手设置」,字数不同,右边的内容就被推得差了一格。
// CSS 里已经用 :has() 处理过一次,这里再兜一次——面板重建后内联样式会丢,
// 而设置面板每次打开都会重新调用下面的 control,正好顺势对齐一次。
// 只认「恰好两列、且第二列装着我们内容」的那一行,结构对不上就不动,免得误伤别的元素。
function alignSettingRow(node) {
let row = node.parentElement;
while (row && row !== document.body) {
if (row.children.length === 2 && row.children[1].contains(node)) break;
row = row.parentElement;
}
if (!row || row === document.body) return;
const [name, control] = row.children;
name.style.display = "none";
control.style.flex = "1 1 0%";
control.style.minWidth = "0";
// ComfyUI 给控件列自带 justify-end:宽度一占满,它反而把内容整个推到右边。
// 改回靠左,并让包着我们的那一层也占满,否则内容仍会缩在右侧。
control.style.justifyContent = "flex-start";
if (node.parentElement && node.parentElement !== control) {
node.parentElement.style.flex = "1 1 0%";
node.parentElement.style.minWidth = "0";
}
}
function alignWhenMounted(node, frames = 30) {
if (node.isConnected) {
alignSettingRow(node);
return;
}
if (frames > 0) requestAnimationFrame(() => alignWhenMounted(node, frames - 1));
}
// 外观 / 语言从助手设置里拆出来,和它并列挂在「设置」面板的插件分类下。
// 改动立刻生效并单独保存,不需要去点助手设置的保存按钮。
let appearanceSettingsNode = null;
function appearanceControl() {
if (appearanceSettingsNode) return appearanceSettingsNode;
if (appearanceSettingsNode) {
alignWhenMounted(appearanceSettingsNode);
return appearanceSettingsNode;
}
appearanceSettingsNode = element("div", "bpi-settings-form");
appearanceSettingsNode.appendChild(element("h3", "", "Appearance & Language"));
const loading = element("div", "bpm-loading", "Loading settings...");
appearanceSettingsNode.appendChild(loading);
buildAppearanceForm(appearanceSettingsNode, loading);
alignWhenMounted(appearanceSettingsNode);
return appearanceSettingsNode;
}
@@ -641,7 +686,125 @@ async function buildAppearanceForm(host, loading) {
languageSelect.value = config.language || "auto";
const languageNote = element("div", "bpi-config-note", "Choose the language for this plugin's own panels. \"Follow ComfyUI\" reads the ComfyUI language setting; \"Chinese\" and \"English\" force one. Applied to open panels right away; reload the page if anything still shows the old language.");
languageNote.style.gridColumn = "1 / -1";
form.append(languageLabel, languageSelect, languageNote, status);
// 「跟随选中节点」直接长在「通用」这一项里,排在语言设置下面。
// 单独注册成一个设置项的话,ComfyUI 会按分类名给各项排序,怎么调都排不到想要的位置。
const followRow = element("label", "bpi-switch-row bpi-switch-accent");
followRow.style.gridColumn = "1 / -1";
const followToggle = element("input", "bpi-switch");
followToggle.type = "checkbox";
followToggle.checked = followSelectionEnabled();
const followTrack = element("span", "bpi-switch-track");
followTrack.appendChild(element("span", "bpi-switch-thumb"));
followRow.append(followToggle, followTrack, element("span", "bpi-switch-text", "点击节点时跟随切换"));
followToggle.addEventListener("change", () => setFollowSelectionEnabled(followToggle.checked));
const followNote = element("div", "bpi-config-note", "开:在画布上点一个「双语提示词检查器」节点,侧边栏的「标签管理」就切到它。关:只能在「标签管理」的节点下拉里手动选。");
followNote.style.gridColumn = "1 / -1";
form.append(languageLabel, languageSelect, languageNote, followRow, followNote, status);
// 开:操作提示改成右上角弹窗;关:还是写回明细表上方那条状态文字
const fold = element("details", "bpi-fold");
const foldBody = element("div", "bpi-fold-body");
// 最上面一个总开关:一次把下面这几个弹窗 / 提示条 / 预览 / 动画全打开或全关掉
const masterRow = element("label", "bpi-switch-row bpi-switch-accent");
const masterSwitch = element("input", "bpi-switch");
masterSwitch.type = "checkbox";
const masterTrack = element("span", "bpi-switch-track");
masterTrack.appendChild(element("span", "bpi-switch-thumb"));
masterRow.append(masterSwitch, masterTrack, element("span", "bpi-switch-text", "全部开启"));
const masterNote = element("div", "bpi-fold-note", "小孩子才做选择,大人当然选择全都要!😁");
const masterNoteTail = element("div", "bpi-fold-note", "如果显存不够,或者电脑资源比较紧张,可以适当开启或关闭。动画会吃一小部分电脑性能。");
const popupRow = element("label", "bpi-switch-row bpi-switch-accent");
const popupSwitch = element("input", "bpi-switch");
popupSwitch.type = "checkbox";
popupSwitch.checked = notifyEnabled();
const popupTrack = element("span", "bpi-switch-track");
popupTrack.appendChild(element("span", "bpi-switch-thumb"));
popupRow.append(popupSwitch, popupTrack, element("span", "bpi-switch-text", "Notification popup (top right)"));
popupSwitch.addEventListener("change", () => {
setNotifyEnabled(popupSwitch.checked);
setText(status, popupSwitch.checked ? "Notifications moved to the top-right popup" : "Notifications back in the status bar");
status.dataset.kind = "ok";
});
// 右下角那套也一样给个总开关:关掉就退回状态栏,电脑省一点开销
const toastRow = element("label", "bpi-switch-row bpi-switch-accent");
const toastSwitch = element("input", "bpi-switch");
toastSwitch.type = "checkbox";
toastSwitch.checked = toastEnabled();
const toastTrack = element("span", "bpi-switch-track");
toastTrack.appendChild(element("span", "bpi-switch-thumb"));
toastRow.append(toastSwitch, toastTrack, element("span", "bpi-switch-text", "Bottom-right toast"));
toastSwitch.addEventListener("change", () => {
setToastEnabled(toastSwitch.checked);
setText(status, toastSwitch.checked ? "Bottom-right toast on" : "Bottom-right toast off");
status.dataset.kind = "ok";
});
// 拖动实时预览:默认关,节点标签区和明细表各一个开关,互不影响
const nodeDragRow = element("label", "bpi-switch-row bpi-switch-accent");
const nodeDragSwitch = element("input", "bpi-switch");
nodeDragSwitch.type = "checkbox";
nodeDragSwitch.checked = dragPreviewNodeEnabled();
const nodeDragTrack = element("span", "bpi-switch-track");
nodeDragTrack.appendChild(element("span", "bpi-switch-thumb"));
nodeDragRow.append(nodeDragSwitch, nodeDragTrack, element("span", "bpi-switch-text", "拖动实时预览(节点标签区)"));
nodeDragSwitch.addEventListener("change", () => {
setDragPreviewNodeEnabled(nodeDragSwitch.checked);
setText(status, nodeDragSwitch.checked ? "节点标签区拖动实时预览已开" : "节点标签区拖动实时预览已关");
status.dataset.kind = "ok";
});
const tableDragRow = element("label", "bpi-switch-row bpi-switch-accent");
const tableDragSwitch = element("input", "bpi-switch");
tableDragSwitch.type = "checkbox";
tableDragSwitch.checked = dragPreviewTableEnabled();
const tableDragTrack = element("span", "bpi-switch-track");
tableDragTrack.appendChild(element("span", "bpi-switch-thumb"));
tableDragRow.append(tableDragSwitch, tableDragTrack, element("span", "bpi-switch-text", "拖动实时预览(明细表)"));
tableDragSwitch.addEventListener("change", () => {
setDragPreviewTableEnabled(tableDragSwitch.checked);
setText(status, tableDragSwitch.checked ? "明细表拖动实时预览已开" : "明细表拖动实时预览已关");
status.dataset.kind = "ok";
});
const nodeDragNote = element("div", "bpi-fold-note", "开:按住标签会浮起来跟着鼠标走,原位留一个空位,其余标签像手机桌面挪图标那样平滑让开;松手才真正改写提示词,只留一条撤销记录。每次拖动都要量一遍布局,提示词很长时会多花一点 CPU,受不了就关掉。");
const tableDragNote = element("div", "bpi-fold-note", "同上,但只管明细表里的拖动(拖行首的 ⠿ 手柄)。两个开关各管一处,互不影响。");
const deleteAnimRow = element("label", "bpi-switch-row bpi-switch-accent");
const deleteAnimSwitch = element("input", "bpi-switch");
deleteAnimSwitch.type = "checkbox";
deleteAnimSwitch.checked = deleteAnimNodeEnabled();
const deleteAnimTrack = element("span", "bpi-switch-track");
deleteAnimTrack.appendChild(element("span", "bpi-switch-thumb"));
deleteAnimRow.append(deleteAnimSwitch, deleteAnimTrack, element("span", "bpi-switch-text", "标签增删动画(节点标签区)"));
deleteAnimSwitch.addEventListener("change", () => {
setDeleteAnimNodeEnabled(deleteAnimSwitch.checked);
setText(status, deleteAnimSwitch.checked ? "节点标签区增删动画已开" : "节点标签区增删动画已关");
status.dataset.kind = "ok";
});
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.");
// 总开关最后绑定:这时下面几个子开关都已经建好。
// 打开 = 五个全开;关掉 = 五个全关。单独动某个子开关时,总开关跟着反映"是不是全开着"
const effectSwitches = [
[popupSwitch, setNotifyEnabled],
[toastSwitch, setToastEnabled],
[nodeDragSwitch, setDragPreviewNodeEnabled],
[tableDragSwitch, setDragPreviewTableEnabled],
[deleteAnimSwitch, setDeleteAnimNodeEnabled],
];
const syncMasterSwitch = () => {
masterSwitch.checked = effectSwitches.every(([input]) => input.checked);
};
syncMasterSwitch();
masterSwitch.addEventListener("change", () => {
const next = masterSwitch.checked;
for (const [input, setter] of effectSwitches) {
input.checked = next;
setter(next);
}
setText(status, next ? "通知弹窗、提示条、拖动预览和增删动画已全部开启" : "已全部关闭,提示退回状态栏,动画也不再播放");
status.dataset.kind = "ok";
});
for (const [input] of effectSwitches) input.addEventListener("change", syncMasterSwitch);
foldBody.append(masterRow, masterNote, masterNoteTail, popupRow, foldNote, toastRow, toastNote, nodeDragRow, nodeDragNote, tableDragRow, tableDragNote, deleteAnimRow, deleteAnimNote);
fold.append(element("summary", "", "通知弹窗及实时动画预览"), foldBody);
form.appendChild(fold);
host.appendChild(form);
// 只提交这两项:后端 update 对没给的字段一律保留原值,不会动翻译服务和密钥
@@ -661,18 +824,40 @@ async function buildAppearanceForm(host, loading) {
languageSelect.addEventListener("change", save);
}
// 「跟随选中节点」:试验开关,单独一项设置,不和外观那块混在一起。
// 存在本机 localStorage 里,不走后端配置——它只管这一台机器上的操作习惯。
// 开关本体长在「通用」那一项里(见 buildAppearanceForm),这里只管读写状态
function followSelectionEnabled() {
try {
return localStorage.getItem(FOLLOW_SELECTION_KEY) === "1";
} catch {
return false;
}
}
function setFollowSelectionEnabled(enabled) {
try {
localStorage.setItem(FOLLOW_SELECTION_KEY, enabled ? "1" : "0");
} catch (error) {
console.warn("[BilingualPromptInspector] Failed to save the follow-selection switch", error);
}
}
// 助手设置直接长在 ComfyUI「设置」面板里(入口见下方 registerExtension 的 settings),
// 这一个函数返回的就是设置项右侧那块控件。
// 面板每次渲染都会重建控件容器,所以整块 DOM 只建一次后复用,输入到一半的内容不会丢。
let assistantSettingsNode = null;
function assistantSettingsControl() {
if (assistantSettingsNode) return assistantSettingsNode;
if (assistantSettingsNode) {
alignWhenMounted(assistantSettingsNode);
return assistantSettingsNode;
}
assistantSettingsNode = element("div", "bpi-settings-form");
assistantSettingsNode.appendChild(element("h3", "", "Assistant Settings"));
const loading = element("div", "bpm-loading", "Loading settings...");
assistantSettingsNode.appendChild(loading);
buildAssistantForm(assistantSettingsNode, loading);
alignWhenMounted(assistantSettingsNode);
return assistantSettingsNode;
}
@@ -729,6 +914,81 @@ async function buildAssistantForm(host, loading) {
list.push(form.querySelector(`label[for="${input.id}"]`), input);
return input;
};
// 模型名称这一行:输入框照旧可以手写,旁边的按钮去问一次服务端有哪些模型。
// 候选不放在原生 datalist 里 —— Chrome 只有在输入框里打字时才弹,空着聚焦点不开,
// 而 ComfyUI 设置面板里又容易被裁剪。改成行下方内联展开的候选列表,点得到也看得清。
const addModelRow = (list, name, value, placeholder, options) => {
const label = element("label", "", "Model Name");
label.htmlFor = `bpi-field-${name}`;
const input = element("input");
input.id = label.htmlFor;
input.name = name;
input.value = value ?? "";
setPlaceholder(input, placeholder);
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, 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;
closePicks();
setText(trigger, countLabel());
};
// 地址改了就地作废:候选里不能留着上一个地址的模型
options.baseUrl().addEventListener("change", forget);
const pick = async () => {
trigger.disabled = true;
setText(trigger, models ? countLabel() : "Fetching...");
try {
const url = options.baseUrl().value.trim();
const keyInput = options.apiKey();
models = await listAssistantModels({
backend: options.backend,
base_url: url,
api_key: keyInput ? keyInput.value.trim() : "",
});
setText(trigger, countLabel());
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";
} finally {
trigger.disabled = false;
}
};
return input;
};
// 清除已保存的密钥:开关样式,打开后要保存才生效。返回的 input 上挂了整行(`.row`)
const clearSwitch = (labelText) => {
const row = element("label", "bpi-switch-row");
@@ -798,7 +1058,11 @@ async function buildAssistantForm(host, loading) {
form.append(openaiHead);
openaiOnly.push(openaiHead);
const openaiBaseUrl = addField(openaiOnly, "API URL", "assistant-openai-base-url", config.openai_base_url, "https://host/v1");
const openaiModel = addField(openaiOnly, "Model Name", "assistant-openai-model", config.openai_model, "provider model ID");
const openaiModel = addModelRow(openaiOnly, "assistant-openai-model", config.openai_model, "provider model ID", {
backend: "openai_compatible",
baseUrl: () => openaiBaseUrl,
apiKey: () => openaiApiKey,
});
const presetLine = element("div", "bpm-toolbar");
presetLine.style.gridColumn = "1 / -1";
const lmStudioPreset = button("Use LM Studio Local Preset", () => {
@@ -837,7 +1101,11 @@ async function buildAssistantForm(host, loading) {
form.append(ollamaHead);
ollamaOnly.push(ollamaHead);
const ollamaBaseUrl = addField(ollamaOnly, "API URL", "assistant-ollama-base-url", config.ollama_base_url, "http://127.0.0.1:11434");
const ollamaModel = addField(ollamaOnly, "Model Name", "assistant-ollama-model", config.ollama_model, "e.g.: qwen3:8b");
const ollamaModel = addModelRow(ollamaOnly, "assistant-ollama-model", config.ollama_model, "e.g.: qwen3:8b", {
backend: "ollama",
baseUrl: () => ollamaBaseUrl,
apiKey: () => null,
});
const ollamaPresetLine = element("div", "bpm-toolbar");
ollamaPresetLine.style.gridColumn = "1 / -1";
const ollamaPreset = button("Use Ollama Local Preset", () => {
@@ -868,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 = () => {
@@ -904,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...");
@@ -922,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);
@@ -944,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),这里既不提交也不应用
@@ -987,7 +1428,7 @@ function setSection(section) {
if (section === "favorites" && !manager.favoritesLoaded) {
manager.favoritesLoaded = true;
refs.sections.appendChild(buildFavoritesSection());
refreshFavorites();
refs.libraryView?.refresh();
}
}
@@ -995,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)
@@ -1066,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) {
@@ -1152,66 +1507,23 @@ async function loadFavoriteIntoNode(item) {
}
const confirmed = await confirmDialog(
"Load",
`This will overwrite node #${target.id} prompt. Cannot be undone with Ctrl+Z.`,
`This will overwrite node #${target.id} prompt. This cannot be undone.`,
);
if (!confirmed) return;
target._bilingualPromptInspector?.setText?.(item.text ?? "");
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() {
@@ -1221,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();
@@ -1356,6 +1664,34 @@ function rebuildTagManagerSection() {
target._bilingualPromptInspector.refreshAfterConfigure?.();
}
// 画布上点节点 → 「标签管理」跟着切过去。
// 只认真正的检查器节点:点编码器、采样器之类的不动,免得切走了还得手动选回来。
function followSelectedNode(node) {
if (!followSelectionEnabled()) return;
if (!node?._bilingualPromptInspector?.detailsBody) return;
const id = String(node.id ?? "");
if (!id || manager.tagManagerNodeId === id) return;
manager.tagManagerNodeId = id;
// 面板没打开、或停在别的分页时只记下节点号,等切到「标签管理」时按它渲染
if (manager.section === "tag-manager" && refs.tagManagerHost?.isConnected) rebuildTagManagerSection();
}
// canvas.onNodeSelected 只有一个位置,接管前先把原来的接回去(别的扩展也可能挂在上面)。
// 前端自己只读不改这个回调,所以在 setup 里装一次就够。
function installFollowSelection() {
const canvas = app.canvas;
if (!canvas || canvas.bpiFollowSelection) return;
const previous = canvas.onNodeSelected;
canvas.onNodeSelected = function (node, ...rest) {
try {
previous?.apply(this, [node, ...rest]);
} finally {
followSelectedNode(node);
}
};
canvas.bpiFollowSelection = true;
}
function build() {
root = element("div", "bpm-panel");
const head = element("div", "bpm-head");
@@ -1373,18 +1709,16 @@ function build() {
refs.sections.appendChild(refs.tagManagerSection);
refs.sections.appendChild(buildTagsSection());
refs.sections.appendChild(buildPacksSection());
root.append(head, refs.tabs, refs.sections);
// 撤销键没写在任何提示里,这里在分区上方给一行小字:
// Ctrl+Z 归画布,插件自己的撤销是 Shift+Alt+Z。这行直接写中文:
// 侧边栏面板的标题/提示不进 i18n 译表,写英文就会一路显示英文
const undoHint = element("div", "bpi-config-note", "插件撤销:Shift+Alt+Z(重做:Shift+Alt+Y)");
undoHint.style.cssText = "padding:0 10px 6px";
root.append(head, refs.tabs, undoHint, refs.sections);
root.addEventListener("mousedown", (event) => event.stopPropagation());
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() {
@@ -1506,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();
// 启动时把后端保存的外观 / 语言设置拉下来;语言还会顺带整树重刷一次已渲染的面板
@@ -1523,10 +1880,10 @@ app.registerExtension({
settings: [
{
id: "BilingualPromptInspector.Appearance",
name: "语言与外观",
name: "通用",
type: appearanceControl,
defaultValue: "",
category: ["双语提示词检查器", "语言与外观"],
category: ["双语提示词检查器", "通用"],
},
{
id: "BilingualPromptInspector.AssistantSettings",
@@ -1537,6 +1894,7 @@ app.registerExtension({
},
],
async setup() {
installFollowSelection();
if (!app.extensionManager?.registerSidebarTab) {
console.warn("[BilingualPromptInspector] Current frontend does not support sidebar; panel unavailable");
return;
+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.1.8",
"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.1.8"
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}
+230 -9
View File
@@ -25,10 +25,14 @@ from .assistant_store import (
baidu_translate_params,
dictionary_translate,
normalize_ai_base_url,
ollama_tags_endpoint,
openai_chat_endpoint,
openai_models_endpoint,
parse_json_body,
sanitize_anima_prompt,
sanitize_natural_prompt,
translation_direction,
upstream_error_message,
)
from .dictionary_store import DictionaryStore, normalize_key
from .duplicate_check import find_duplicates
@@ -36,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
@@ -47,6 +53,7 @@ assistant_store.config() # 进程启动时立即清除与当前安装身份不
_SESSION_TOKEN = secrets.token_urlsafe(32)
_MAX_JSON_BYTES = 8 * 1024 * 1024
_MAX_ASSISTANT_JSON_BYTES = 256 * 1024
_MODEL_LIST_TIMEOUT_SECONDS = 20
_ASSISTANT_RATE_WINDOW = 60.0
_ASSISTANT_RATE_LIMIT = 30
_assistant_requests = deque()
@@ -250,10 +257,10 @@ async def _call_ai_assistant(action, text, instruction, config, connection_test=
}
async with ClientSession(timeout=timeout) as session:
async with session.post(endpoint, json=payload) as response:
data = await response.json(content_type=None)
body = await response.text()
if response.status >= 400:
raise ValueError(data.get("error") or f"Ollama 请求失败:HTTP {response.status}")
result = data.get("message", {}).get("content")
raise ValueError(upstream_error_message(body, response.status, "Ollama"))
result = parse_json_body(body, "Ollama").get("message", {}).get("content")
else:
# OpenAI 兼容:远端必须 https,本机回环才允许 http
base = normalize_ai_base_url(config["openai_base_url"], allow_remote=True)
@@ -269,13 +276,10 @@ async def _call_ai_assistant(action, text, instruction, config, connection_test=
}
async with ClientSession(timeout=timeout) as session:
async with session.post(openai_chat_endpoint(base), headers=headers, json=payload) as response:
data = await response.json(content_type=None)
body = await response.text()
if response.status >= 400:
detail = data.get("error") if isinstance(data, dict) else None
if isinstance(detail, dict):
detail = detail.get("message")
raise ValueError(detail or f"API 请求失败:HTTP {response.status}")
choices = data.get("choices") if isinstance(data, dict) else None
raise ValueError(upstream_error_message(body, response.status, "API"))
choices = parse_json_body(body, "API").get("choices")
result = choices[0].get("message", {}).get("content") if choices else None
result = _clean_assistant_result(result)
if not result:
@@ -338,6 +342,54 @@ async def save_assistant_config(request):
return error_response(error)
async def _list_backend_models(prefix, base_url, api_key):
"""向 AI 后端问一次「你有哪些模型」;地址沿用现有白名单,问完不留任何东西。"""
timeout = ClientTimeout(total=_MODEL_LIST_TIMEOUT_SECONDS)
async with ClientSession(timeout=timeout) as session:
if prefix == "ollama":
# Ollama 只认本机回环地址,这里再校验一次,挡住手改配置文件的情况
base = normalize_ai_base_url(base_url)
if not base:
raise ValueError("尚未填写 Ollama 地址")
async with session.get(ollama_tags_endpoint(base)) as response:
# 先读原文再看状态码:错误应答常常不是 JSON,按 JSON 硬解析会把整个请求打崩
body = await response.text()
if response.status >= 400:
raise ValueError(upstream_error_message(body, response.status, "Ollama"))
names = [item.get("name") for item in parse_json_body(body, "Ollama").get("models") or []]
else:
base = normalize_ai_base_url(base_url, allow_remote=True)
if not base:
raise ValueError("尚未填写 OpenAI 兼容 API 地址")
headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
async with session.get(openai_models_endpoint(base), headers=headers) as response:
body = await response.text()
if response.status >= 400:
raise ValueError(upstream_error_message(body, response.status, "API"))
names = [item.get("id") for item in parse_json_body(body, "API").get("data") or []]
models = sorted({str(name).strip() for name in names if str(name).strip()})
if not models:
raise ValueError("服务端没有返回任何模型;也可以直接在输入框里手写模型名称")
return models
@protected_route("post", "/bpi/assistant/models")
async def list_assistant_models(request):
"""用表单里当前填的地址问一次模型列表,只为给下拉候选用:不写配置,也不把密钥返回浏览器。"""
try:
payload = await _read_json(request, _MAX_ASSISTANT_JSON_BYTES)
if not isinstance(payload, dict):
raise ValueError("请求内容格式不正确")
_consume_assistant_rate_limit()
prefix = "ollama" if payload.get("backend") == "ollama" else "openai"
# 密钥输入框留空 = 用户已经存过、这次不想再敲一遍,就拿已存的那份去问;仍只用于这一次请求
api_key = str(payload.get("api_key") or "").strip() or str(assistant_store.config().get(f"{prefix}_api_key") or "").strip()
models = await _list_backend_models(prefix, str(payload.get("base_url") or "").strip(), api_key)
return web.json_response({"success": True, "data": {"models": models}})
except AssistantLimitError as error:
return error_response(error, 429)
except Exception as error:
return error_response(error)
@protected_route("post", "/bpi/assistant/test")
@@ -729,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
@@ -814,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()})
+36
View File
@@ -17,10 +17,14 @@ from assistant_store import (
baidu_translate_params,
dictionary_translate,
normalize_ai_base_url,
ollama_tags_endpoint,
openai_chat_endpoint,
openai_models_endpoint,
parse_json_body,
sanitize_anima_prompt,
sanitize_natural_prompt,
translation_direction,
upstream_error_message,
)
from dictionary_store import DictionaryStore
@@ -444,6 +448,38 @@ class AssistantStoreTests(unittest.TestCase):
path.write_text(json.dumps(raw, ensure_ascii=False), encoding="utf-8")
self.assertEqual(store.config()["baidu_qps"], 1.0)
def test_model_list_endpoints_reuse_the_saved_base_url_shape(self):
self.assertEqual(openai_models_endpoint("https://api.openai.com/v1"), "https://api.openai.com/v1/models")
self.assertEqual(openai_models_endpoint("http://127.0.0.1:1234/v1/"), "http://127.0.0.1:1234/v1/models")
self.assertEqual(openai_models_endpoint("https://x.cn/v1/chat/completions"), "https://x.cn/v1/models")
self.assertEqual(openai_models_endpoint("https://x.cn/models"), "https://x.cn/models")
self.assertEqual(ollama_tags_endpoint("http://127.0.0.1:11434"), "http://127.0.0.1:11434/api/tags")
self.assertEqual(ollama_tags_endpoint("http://127.0.0.1:11434/v1"), "http://127.0.0.1:11434/api/tags")
self.assertEqual(ollama_tags_endpoint("http://127.0.0.1:11434/api/chat"), "http://127.0.0.1:11434/api/tags")
self.assertEqual(openai_models_endpoint(""), "")
self.assertEqual(ollama_tags_endpoint(""), "")
def test_error_bodies_that_are_not_json_still_read_as_a_sentence(self):
# DeepSeek 缺密钥时回的是纯文本一行,不是 JSON;解析不了也不能把整条请求打崩
# 第三个参数和 server.py 里的调用保持一致:整条路径改签名时这里会先炸出来
self.assertIn("Authentication Fails", upstream_error_message("Authentication Fails (governor)", 401, "API"))
self.assertIn("请检查 API 密钥", upstream_error_message("Authentication Fails (governor)", 401, "API"))
self.assertEqual(
upstream_error_message('{"error":{"message":"Invalid API key"}}', 401, "API"),
"Invalid API key(HTTP 401;请检查 API 密钥是否已填写、是否有效)",
)
self.assertEqual(upstream_error_message("404 page not found", 404, "API"), "404 page not found(HTTP 404)")
self.assertEqual(upstream_error_message("", 502, "Ollama"), "Ollama 请求失败(HTTP 502)")
self.assertTrue(len(upstream_error_message("<html>" + "x" * 500 + "</html>", 403, "API")) < 220)
def test_non_json_success_body_reports_instead_of_crashing(self):
self.assertEqual(parse_json_body('{"data": []}', "API"), {"data": []})
with self.assertRaises(ValueError) as raised:
parse_json_body("<html>login</html>", "API")
self.assertIn("返回的不是 JSON", str(raised.exception))
with self.assertRaises(ValueError):
parse_json_body("[1, 2, 3]", "API")
def test_baidu_params_and_query_splitting(self):
params = baidu_translate_params("appid", "secret", "你好", "zh", salt="12345")
self.assertEqual(params["sign"], hashlib.md5("appid你好12345secret".encode("utf-8")).hexdigest())
+1 -2
View File
@@ -69,8 +69,7 @@ assert.equal(t("“1girl” appears 3 times; “Unique” is on, only the first
"「1girl」出现了 3 次;已开「唯一」:只有第一次进入实际输出");
assert.equal(t("“1girl” occurrence 2 of 3; click again for the next one, double-click to delete it"),
"「1girl」第 2 / 3 处;再点一次跳下一处,双击删除这一处");
assert.equal(t("“1girl” now unique: only the first occurrence enters the actual output; press Ctrl+Z to undo"),
"「1girl」已设为唯一:只有第一次出现进入实际输出;按 Ctrl+Z 可撤销");
assert.equal(t("“1girl” now unique"), "「1girl」已设为唯一");
// 「Unique」在译文里会被二次翻译,tooltip 读起来是一致的中文
assert.equal(t("Query: “1girl” | “Unique” is on: only the first occurrence reaches the actual output"),
"查询「1girl」|已开「唯一」:只有第一次出现进入实际输出");
+8 -9
View File
@@ -27,8 +27,8 @@ assert.equal(t(""), "");
assert.equal(t(undefined), undefined);
// 带变量的文案:「“...”」和数字折叠成占位符后回填
assert.equal(t("deleted “masterpiece”; press Ctrl+Z to undo"), "已删除「masterpiece」;按 Ctrl+Z 可撤销");
assert.equal(t("restored 3 hidden tags; press Ctrl+Z to undo"), "已恢复 3 个隐藏标签;按 Ctrl+Z 可撤销");
assert.equal(t("deleted “masterpiece”"), "已删除「masterpiece」");
assert.equal(t("restored 3 hidden tags"), "已恢复 3 个隐藏标签");
// 用户实际会看到的组合文案:统计行、词包信息、操作回执、模式提示
assert.equal(t("items 3 | recognized 1 | unknown 2"), "共 3 个标签|已识别 1|未收录 2");
@@ -74,7 +74,7 @@ assert.equal(
assert.equal(t("Large 12000 (on-demand)"), "大词库 12000(按需)");
assert.equal(t("Large 12000 (disabled)"), "大词库 12000(已停用)");
assert.equal(t("Common dictionary 952 items"), "通用词库 952 条");
assert.equal(t("moved “1girl”; press Ctrl+Z to undo"), "已移动「1girl」;按 Ctrl+Z 可撤销");
assert.equal(t("moved “1girl”"), "已移动「1girl」");
assert.equal(t("Mode: “Tag” | “标签式输入”"), "模式:标签|标签式输入");
assert.equal(t("Mode: “Tag”"), "模式:标签");
assert.equal(
@@ -88,13 +88,13 @@ 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",
"按当前模式对应的规则优化:标签模式整理标签流,自然语言模式改写为英文自然语句",
],
["applied “Translate & optimize”; press Ctrl+Z to undo", "已应用 翻译并优化;按 Ctrl+Z 可撤销"],
["applied “Translate & optimize”", "已应用 翻译并优化"],
["Undone: “Sort to Anima order”", "已撤销:Sort to Anima order"],
["Redone: “Sort to Anima order”", "已重做:Sort to Anima order"],
["Running “Translate”…", "正在运行 翻译…"],
@@ -160,8 +160,7 @@ assert.equal(t("(weight 1.5)"), "(权重 1.5)");
// 插件名:侧边栏标签写死中文,面板/关于框走译表,三处必须同一个名字
assert.equal(t("Bilingual Prompt Manager"), "提示词翻译与管理");
assert.equal(t("Bilingual Prompt Inspector"), "提示词翻译与管理");
// 设置面板里「语言与外观」那一项
assert.equal(t("Appearance & Language"), "语言与外观");
// 「语言与外观 / 助手设置」两项的标题由 ComfyUI 渲染设置项名称,插件不再自带 h3
assert.equal(t("Appearance and language saved"), "外观与语言已保存");
// 两个本地预设按钮各有一套文案,别混
assert.equal(t("Use LM Studio Local Preset"), "使用 LM Studio 本地预设");
@@ -171,9 +170,9 @@ assert.equal(t("Ollama default URL filled; select a pulled model ID, then Save &
// 确认对话框:默认确认按钮是 "OK",必须译为「确认」而不是「正常」
assert.equal(t("OK"), "确认");
assert.equal(t("Cancel"), "取消");
assert.equal(t("This will overwrite node #3 prompt. Cannot be undone with Ctrl+Z."), "这会覆盖节点 #3 的提示词,Ctrl+Z 无法撤销。");
assert.equal(t("This will overwrite node #3 prompt. This cannot be undone."), "这会覆盖节点 #3 的提示词,此操作无法撤销。");
assert.equal(t("Delete “Fluffy”? Reference image will be deleted too."), "删除「Fluffy」?参考图也会一并删除。");
assert.equal(t("restored 3 hidden tags; press Ctrl+Z to undo"), "已恢复 3 个隐藏标签;按 Ctrl+Z 可撤销");
assert.equal(t("restored 3 hidden tags"), "已恢复 3 个隐藏标签");
assert.equal(t("Node #3"), "节点 #3");
// 状态提示(整句形态)
+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()