Compare commits

...
75 Commits
Author SHA1 Message Date
梦圣智杰 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
梦圣智杰 194a6a8206 chore: 发布 v2.1.8 / Release v2.1.8 2026-10-26 05:42:02 +08:00
梦圣智杰 d38de335bd feat: 新增「适应高度」开关,提示词区不再被高度上限卡住 / Add a fit-height switch so the prompt area is no longer height-capped
- 提示词多的时候英文区卡在写死的高度上限里只能内部滚动,把节点拉大也没用;侧边栏「标签管理」的明细表上方新增「适应高度」滑动开关
- 打开后提示词区跟着节点一起变高,打开时节点自动撑高一次,之后可以随意拖动,不会被自动拽回原来的大小
- 开关状态跟着节点保存,切到别的节点显示那个节点自己的状态,切回来不变
2026-10-26 05:37:36 +08:00
梦圣智杰 f2d3de3659 feat: 「优化」按模式分派,新增自然语言规则 / Optimize follows input mode with a new natural language rule
- 「优化为 Anima」改名「优化」:标签模式整理标签流,自然语言模式输出英文句子,中文也能直接用
- 助手设置新增一条「自然语言规则」,两个 AI 后端各一套
- 自然语言模式下隐藏「按 Anima 排序」,模式选择跟着节点保存
2026-10-26 04:27:40 +08:00
梦圣智杰 464a6c79dc fix: 拖大重复检查节点时状态胶囊不再被撑变形 / Keep the duplicate pill at its own size when the node is resized
- 手动拖大「重复检查」节点时,节点上「重复 N」的胶囊不再跟着变高变成一个巨大的圆角块,始终保持原本大小并垂直居中
2026-10-13 07:46:11 +08:00
梦圣智杰 e2b2834298 chore: 发布 v2.1.5 / Release v2.1.5 2026-10-13 03:10:54 +08:00
梦圣智杰 ae96399bdd feat: 可信度按词条整理程度分档 / Rate confidence by how complete an entry is
- 明细表的可信度徽章只显示「未判定 / 低 / 中 / 高」四档,去掉「待确认」这类后缀
- 已收录、已校验、且填了分类(不是「未分类」)才算高;已收录但没填分类,或还没标成已校验的,算中
- 机器翻译还没存进词库的算低;词库里查不到的仍是未判定
- 鼠标悬停在徽章上会说明为什么是这一档,填了分类的会把分类名带出来
- 「高」「中」补上中文译文,之前这两档一直显示的是英文
2026-10-13 02:20:02 +08:00
梦圣智杰 84bbc97cba feat: 明细表双击已收录标签即可编辑词条 / Double-click an indexed tag to edit its entry
- 明细表里已收录的标签,双击英文那格会打开「新增或编辑个人标签」窗口,和在词库里点「编辑」是同一个,中文、别名、分类、模型、权重、备注都能改
- 未收录、待确认的机器翻译,以及 BREAK、<lora:…> 这类特殊语法,双击仍是原来的定位到提示词
- 未收录行去掉「搜索候选」按钮:词库里本来就搜不出这个词,点了只会打开空结果
2026-10-13 02:02:26 +08:00
梦圣智杰 b09ce43898 fix: 自然语言片段双击也能开编辑弹窗 / Natural-language segments now open the editor too
- 双击自然语言片段不再只提示「只有标签支持单独权重」,改为和标签一样打开编辑弹窗
- 自然语言片段只提供「颜色」和「编辑」两页,权重与词条信息不再出现
- 自然语言片段可以像标签一样设置单独配色
2026-10-13 01:13:36 +08:00
梦圣智杰 5bb839964a fix: 未收录标签不再被存成残词条 / Stop saving the not-indexed placeholder as a tag name
- 双击未收录标签打开「词条信息」时,「中文名称」改为留空,自己填上中文再点应用才会存入个人词库
- 中文名称还是「未收录」时点应用不会保存,下方说明变红提示,2 秒后自动恢复
- 明细表里直接改中文时,不再把「未收录」占位带进输入框
2026-10-12 11:35:36 +08:00
梦圣智杰 1233c9f0c9 feat:添加测试启动文件 / Add test startup file 2026-10-12 07:45:32 +08:00
梦圣智杰 ca916f5b82 chore: 发布 v2.1.0 / Release v2.1.0 2026-10-12 07:09:39 +08:00
梦圣智杰 6565157f2a fix: 暗色下输入框与下拉框改用 ComfyUI 的配色 / Dark mode fields now use ComfyUI's own colors 2026-10-12 07:00:13 +08:00
梦圣智杰 737458163b feat: AI 分成 OpenAI 与 Ollama 两套 / AI split into OpenAI and Ollama configs
- 助手设置移到 ComfyUI「设置 → 双语提示词检查器 → 助手设置」,表单直接铺在设置面板右侧;侧边栏只保留标签管理、词库、词包、收藏四个页签。
- 外观与语言从助手设置里分出来,单独成为「语言与外观」一项,下拉改完立即保存并生效。
- OpenAI 兼容与 Ollama 各有独立的地址、模型、密钥、超时和三条翻译规则,切换后端互不覆盖,已填的密钥不会被清掉。
- API 地址只接受 https 或本机地址,Ollama 只能用本机地址,地址中不能带用户名密码或查询参数,远端不接受内网与保留地址。
2026-10-12 06:53:08 +08:00
梦圣智杰 d6c51f9de2 chore: 发布 v2.0.6 / Release v2.0.6 2026-10-10 23:05:14 +08:00
梦圣智杰 b8218f7e10 feat: 明细表可按关键词查找当前提示词 / Filter the detail table by keyword
- 明细表上方新增查找框,输入即筛选出当前节点里命中的标签,英文原文和中文解释都参与匹配
- 命中片段会高亮,右侧显示命中数量,按 Esc 或点 × 一键清空
- 查找与「全部 / 未收录 / 机器翻译 / 个人词库 / 收藏」叠加生效。
2026-10-10 23:00:01 +08:00
梦圣智杰 01123cef5c chore: 去掉跳词库的「管理面板」按钮 / Remove the redundant manager panel button
- 侧边栏「标签管理」里的「管理面板」按钮和顶部「词库」页签是一回事,去掉后工具栏只留「刷新词库」
- 画布上节点面板里的同一个按钮也一并去掉,两边共用同一份面板
2026-10-10 19:09:04 +08:00
梦圣智杰 a8b7aca8f8 feat: 词库可按自然语言词库来源筛选 / Filter the dictionary by the natural language source
- 来源下拉新增「自然语言词库」,只显示保存时选了该词库的条目;「个人」不受影响,仍包含它们
- 分类下拉里「自然语言」固定排在「全部」下面,其余分类仍按拼音排序
2026-10-10 18:33:18 +08:00
梦圣智杰 c72422e648 feat: 侧边栏节点列表显示画布上改过的节点名 / Show custom node titles in the sidebar node list
- 标签管理的节点下拉、以及保存收藏时选节点的弹窗,会显示在画布上双击改过的节点名,后面保留节点号以便区分同名节点
- 没改过名的节点仍显示节点号加提示词摘要
2026-10-10 18:17:19 +08:00
梦圣智杰 ff8395302a feat: Anima 排序可一键取消 / Cancel the Anima sort with one click
- 排序写入后按钮变成「取消 Anima 顺序排序」,点击把提示词恢复到排序前的样子,同时退出分类表视图。
- 提示词本来就是 Anima 顺序时,取消只退出分类视图,不动提示词内容。
- 排序后又手动改过提示词,取消入口会自动失效,避免回退把后来的改动一起吞掉。
- 排序与优化的预览弹窗里,「确认写入英文」改为「确认写入新提示词」。
2026-10-10 18:03:11 +08:00
梦圣智杰 cab56b2f60 feat: 词库搜索添加的标签插到选中标签之后 / Insert searched tags after the selected tag
- 在节点和侧边栏里搜索词库添加标签时,新标签会插在当前选中标签的后面,不再总是追加到提示词末尾;没有选中标签时才加到末尾。
- 连续添加多个标签会依次往后排,添加完新标签自动处于选中状态。
2026-10-10 17:43:17 +08:00
梦圣智杰 cd55defc87 chore: 发布 v2.0.0 / Release v2.0.0 2026-10-10 01:33:23 +08:00
梦圣智杰 79dc337c0e feat: 合并重复检查节点到主线 / Merge duplicate-check node into main 2026-10-10 01:00:09 +08:00
梦圣智杰 642837842c feat: 隐藏按钮挪到方框右上角内 / Move hide button inside the card corner
- 标签卡上的隐藏(眼睛)按钮不再跟着英文行走,固定在整张卡片的右上角内侧
- 鼠标移到卡片任意位置(含中文行)都会显示该按钮
2026-10-10 00:30:18 +08:00
梦圣智杰 13c2fecce2 feat: 重复标签可设「唯一」并新增重复栏 / Unique toggle and duplicate bar for repeated tags
- 重复分组抽成 parser.js 的 duplicateGroups(),问题提示行与界面共用同一份口径
- 「重复标签:X」后面加每个标签独立的「唯一」开关:只保留第一次出现进入实际输出,
  其余在界面上划线保留,不删原文,支持 Ctrl+Z
- 英文标签区「已隐藏」下方新增重复栏,一个标签一个胶囊,单击依次定位到每一次
  出现并联动选中(标红),已定位时双击删掉当前那一处
- 新增 tests/test_duplicates.mjs,14 个 JS 测试全绿
2026-10-06 17:16:49 +08:00
梦圣智杰 7cb8d80b02 feat: 新增重复检查节点 / Add duplicate tag checker node
- 检测提示词里的重复标签,节点用淡橙色提示重复数量,无重复时保持默认配色
- 开关「只保留第一个」默认关闭,打开后只保留第一次出现的标签
- 输出提示词直通下游,重复项可接预览任意查看;判定口径沿用 parser.js
2026-10-05 22:42:56 +08:00
梦圣智杰 ed0ab5eee2 chore: 发布 v1.2.18 / Release v1.2.18 2026-10-05 09:19:41 +08:00
梦圣智杰 d45a9d9e63 feat: 标签编辑弹窗整合 / Consolidate tag editor 2026-10-05 08:27:34 +08:00
梦圣智杰 35ed25d69c chore: 界面细节调整 / UI polish 2026-10-05 08:22:37 +08:00
梦圣智杰 d3e6d7edce chore: 发布 v1.2.16 / Release v1.2.16 2026-10-01 02:50:30 +08:00
梦圣智杰 43a049b43f feat: 快捷添加支持按选中位置插入 / Quick add inserts after the selected tag
- 选中标签时用 token.end 偏移插到它后面,没选中或选中失效则落到末尾,与「插入换行」同一套判定
2026-10-01 02:00:12 +08:00
梦圣智杰 78924e8244 fix: 隐藏区长文本胶囊不再被撑成大圆角块 / Keep long hidden-tag chips on one line
- 胶囊里的中文译文缺少 nowrap,长自然语言会在里面换行把高度撑起来,
- 中英文两段都加单行截断,胶囊高度恒定一行,超出部分显示省略号
2026-10-01 01:35:38 +08:00
梦圣智杰 905d165348 chore: 发布 v1.2.15 / Release v1.2.15 2026-09-30 01:57:49 +08:00
梦圣智杰 3806ae7ae1 chore:添加了在ComfyUI注册表上面的图标/Added an icon on the ComfyUI registry 2026-09-30 01:53:44 +08:00
梦圣智杰 45eb64a5bf feat: 双击标签卡的编辑框加「词条信息」页签 / Add an entry-info tab to the tag editor
- 标签字段抽成 buildTagFields,弹窗与页签共用同一份
- 新页签展开完整词条
- 命中词库就整条带回来自改,没命中按当前标签卡起个头
- 「Anima 格式」说明挪进权重页,「清除权重」只在权重页显示
- 页签状态由 onColorTab 布尔改成 activeTab 三态,共用底部 Apply 分派
2026-09-29 21:07:39 +08:00
梦圣智杰 c19242700e chore: 发布 v1.2.14 / Release v1.2.14 2026-09-29 03:05:30 +08:00
梦圣智杰 6fe88a6bae feat: 标签弹窗的分类改成可筛选下拉 / Turn the category field into a filterable dropdown
- 候选取词库里真实出现过的分类,按词条数降序,每项标出条数
- 「自定义」恒为列表第一项且不被过滤掉,选中后输入框右侧挂青绿色标识
- 手打的值命中候选时自动摘掉标识;编辑历史标签带进来的陌生值当场算自定义
- 输入即筛选,↑↓ 切换、回车确认、Esc 收起,↓ 或再点输入框可重新唤出列表
- 新建时分类留空,由后端落成「自定义」,避免一开窗就挂着标识
2026-09-29 02:52:21 +08:00
梦圣智杰 238612920c feat: 标签卡支持按词库、按 Anima 上色,并标记疑似自然语言 / Color tag cards by dictionary or Anima, flag suspected natural language
- 过滤栏「颜色」改成就地展开面板:默认 / 按收录 / 随机 / 按词库 / 按 Anima
- 配色写入 data/token_colors.json(pack_colors / anima_colors),新增写接口
- 新增「疑似自然语言」种类(空格 > 3),节点卡片与明细表着色
- 自然语言词库拆出独立 pack_id
- 双击设的自定义色补上优先级说明
2026-09-29 02:18:01 +08:00
梦圣智杰 84d3430576 feat: 识别并标记「疑似自然语言」,新增自然语言词库 / Flag suspected natural language and add a natural language dictionary 2026-09-29 00:22:12 +08:00
梦圣智杰 125654864c fix: 优化为 Anima 不再被转义符、句点和权重小数打断 / Fix Anima optimization output rejected by escapes, stray periods and decimal weights
- assistant_store:清洗表补全角引号;清掉模型写成 JSON/代码字符串时残留的反斜杠(字面 \n \t 先还原成换行,其余孤立反斜杠删除)
- assistant_store:末尾孤立句点直接删除
- parser:判定句末标点前先中和数字句点与闭括号后的句点,权重小数不再让整串标签被当成自然语言段落
- 测试:新增转义残留、句点替换、权重保留等断言;既有「@artist!」用例同步改为清洗后不带句点
2026-09-28 22:49:04 +08:00
梦圣智杰 97c87043d7 ci: 后端测试前安装 aiohttp / Install aiohttp before backend tests 2026-09-27 21:23:17 +08:00
梦圣智杰 36daee2bed ci: 接入 Comfy Registry 自动发布 / Add registry publish workflow 2026-09-27 21:05:12 +08:00
梦圣智杰 9995906469 fix:v1.2.10的文件,但是忘了提交了 2026-09-27 20:32:56 +08:00
梦圣智杰 1e826fe064 feat: 收藏支持「适合模型」与备注,并可直接编辑 / Add model tags and notes to favorites, with in-place editing
- 新增 POST /bpi/saved-prompts/{id} 与 SavedPromptStore.update_prompt();
  图片读写抽出 _write_image / _remove_image 给新建、编辑、删除共用。
- 测试:新增 tests/test_model_suggest.mjs,saved_prompt_store
2026-09-27 16:26:28 +08:00
梦圣智杰 ee6aa8c100 feat: 隐藏标签留在节点内,只在输出时剔除 / Keep hidden tags in the node and strip them only from the output
- 隐藏改为打标记:标签不离开文本,卡片与明细行加删除线并灰化,照常可拖动排序、改颜色、改权重
- 新增输出通道 effective_text / use_effective:节点文本保留隐藏标签,实际输出为剔除后的文本(全隐藏时输出为空)
- 眼睛图标隐藏后变为带斜线的「取消隐藏」图标;「已隐藏」条与「全部恢复」保留
- 节点英文文本框标签改名为「英文提示词(实际输入)」
2026-09-27 01:43:22 +08:00
梦圣智杰 1bdc037b4e feat: 节点标签卡支持颜色模式与自定义配色 / Add color modes and custom colors to node tag cards
- 过滤栏加「颜色」下拉:默认 / 按收录 / 随机
- 双击编辑器加「权重 / 颜色」Tab,颜色支持预设色块与 #hex、rgb() 自定义
- 颜色池来自 data/token_colors.json
2026-09-27 00:46:35 +08:00
梦圣智杰 7b0590bc8b feat: 明细表显示换行行 / Show line breaks as teal rows in the details table
- 两个标签之间的 gap 有换行时,插入一行青绿色"换行"行
- 拖手柄到某个标签行上 = 把换行移到那个标签后面(复用 moveBreakTo)
- 只在"全部"过滤下显示
2026-09-26 23:45:50 +08:00
梦圣智杰 0f209db10b fix: 百度翻译按账号 QPS 排队,逐条翻译不再触发 54003 / Throttle Baidu requests by account QPS; translate tags one by one instead of bursting
- 后端:把只在单次请求内生效的 sleep(1.1) 换成跨请求的全局节流器 _baidu_throttle,间隔 1/QPS×1.1(留 10% 余量)
- 新增 baidu_qps 配置项(默认 1,标准版;个人认证填 10),助手设置里加数字输入框
2026-09-26 21:19:44 +08:00
梦圣智杰 d9357799c0 fix: 选中与拖拽改为作用于整张标签卡片 / Select and drag the whole tag card, not just the English line 2026-09-26 18:46:52 +08:00
梦圣智杰 8bc3b05f0d fix:卡片标题简化为「提示词」;逗号与换行块下沉到中文行高度;选中时整张卡片高亮(中英文一起) 2026-09-26 17:36:27 +08:00
梦圣智杰 c49c494b0c feat: 提示词输出与中文同步编辑合并为一张双语卡片 / Merge the two editors into one bilingual tag card
- 节点只剩一张「提示词」卡片:每个标签上面英文、下面中文,横排自动折行
- 删掉整个中文同步编辑块(中文编辑框 / 编辑态 / 草稿 / 暂存 / 同步预览)
  与冗余按钮:编辑文本、翻译、翻译并优化、展开编辑、同步到英文输出
- 保留:拖拽排序、权重、隐藏、删除、换行块、分类表视图、上游拦截条
- 优化为 Anima 改为直接处理英文提示词,结果弹预览确认后再写回
- 修 retranslateMachineRow 用了不存在的 row.english(一直是 undefined)
2026-09-26 17:28:12 +08:00
梦圣智杰 7077e8f1ca faat:添加了快捷添加功能 2026-09-26 16:59:21 +08:00
梦圣智杰 edb404cd52 feat: 换行块、机器翻译批量入库、明细表分栏可拖动 / Line break blocks, batch-add machine translations, resizable table columns
- 换行块:英文标签区与中文对照区把原文里的换行渲染成 ↵ 块,可拖动、可删除,
  英文区头部新增「插入换行」。只改写标签之间的间隔,token 原文与 parser 均不动。
- 机器翻译批量入库:明细表里机器翻译行新增勾选框,表格上方出现「全选 / 已选计数 /
  把已选加入个人词库」,一次写入个人词库并清掉待确认;没有机器翻译词条时整条隐藏。
- 明细表分栏:默认左 2 右 3,表头分界处可拖动改比例(夹在 20%~80%),双击复位,
  比例存进 preferences(localStorage),刷新与重启后保留。
- 批量入库去掉浏览器确认弹窗(后端写入前本身会自动备份个人词库)。
2026-09-26 16:14:30 +08:00
梦圣智杰 f730746ffd feat: 中文对照区支持拖拽排序;输出名与提示词控件标题改为中文 / Allow drag-to-reorder in the Chinese mirror; use Chinese for the output name and prompt widget label
中文对照区的标签此前只能点击联动、不能拖动排序,只有上方英文标签区能拖。
现在两区共用同一套拖拽逻辑;节点输出名与提示词控件标题改为写死的中文。

The Chinese mirror chips could only be clicked, never dragged — only the English
token row above supported drag-to-reorder. Both rows now share one drag
implementation, and the node output name plus prompt widget label are hardcoded
to Chinese.

- attachChipDrag 增加 view / selector 参数,英文区与中文区共用
- 中文区 chip 挂上拖拽,落点高亮样式补 .bpi-mirror-token
- 中文 chip 提示补「拖动排序或 Alt+↑/↓ 微调」,并加 i18n 条目
- RETURN_NAMES: english_prompt → 英文提示词
- textWidget.label 写死中文(widget.label 是直接赋值,不走翻译层)
- 删除 locales/zh/main.json 中已失效的 english_prompt 映射
2026-09-26 14:06:31 +08:00
45 changed files with 8018 additions and 1286 deletions
+27
View File
@@ -0,0 +1,27 @@
name: Publish to Comfy Registry
# 只在并入 main 时发布:分支上的改动(哪怕改了 pyproject.toml)不会触发,
# 因此"功能做完再合进 main"的开发节奏是安全的——合进来时才对外发布一次。
# 触发条件是 pyproject.toml 变动,实际等价于"版本号升了才发";
# 版本号没升时 Registry 会报 Version already exists,本次 job 会失败,属预期。
on:
workflow_dispatch:
push:
branches:
- main
paths:
- "pyproject.toml"
permissions:
contents: read
jobs:
publish-node:
name: Publish Custom Node to registry
runs-on: ubuntu-latest
steps:
# publish-node-action 自带 checkout(默认 skip_checkout=false),不要再写一遍
- name: Publish Custom Node
uses: Comfy-Org/publish-node-action@main
with:
personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }}
+5 -1
View File
@@ -29,4 +29,8 @@ jobs:
run: npm test
- name: Run backend tests
run: python -m unittest discover -s tests -p "test_*.py"
# server.py 依赖 aiohttp(由 ComfyUI 运行时提供,不作为插件依赖声明),
# test_upstream_gate 导入它,因此 CI 上要单独装一次。
run: |
pip install aiohttp
python -m unittest discover -s tests -p "test_*.py"
+254 -5
View File
@@ -1,5 +1,257 @@
# 更新记录
## 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)
### 新增「适应高度」开关
- 提示词一多,英文标签区就卡在写死的 360 像素上限里,只能内部滚动,把节点拉大也没用。侧边栏「标签管理」的明细表上方(「颜色」按钮右边)多了一个「适应高度」滑动开关,跟着所选节点走——切到别的节点就显示那个节点自己的状态。
- 打开后:面板不再被固定上限锁住,多出来的高度全部给英文标签区,提示词能多显示很多行;开关打开时节点会自动撑高一次(按被压住的内容量撑),之后想拉多大多大。
- 关掉就回到原来的自动高度。开关状态跟着节点保存,重开工作流还在。
- 默认关闭,提示词少的时候和以前完全一样。
### 「优化」按模式分派,新增自然语言规则
- 节点上的「优化为 Anima」改名为「优化」:标签模式下和以前一样,整理英文标签流、只接受英文;模式切到「自然语言」后改用新的自然语言规则,输出完整英文句子,中文也可以直接优化(会先译成英文,状态栏会提示一句)。
- 助手设置里 OpenAI 兼容与 Ollama 各多一条「自然语言规则」,可以分别自定义,也能单独恢复默认。
- 模式切到「自然语言」时「按 Anima 顺序排序」自动隐藏——整段句子没有标签流可排;已经排过序时按钮会保留,方便点「取消排序」。
- 手动选的「自然语言」也算,不用非得自动检测出来;模式选择现在跟着节点保存,重开工作流不用重选。
## v2.1.5(2026-10-13)
### 可信度徽章改成按词条整理程度分档
- 明细表的可信度只显示「未判定 / 低 / 中 / 高」四档,去掉「待确认」这类后缀,看起来干净了。
- 判据:已收录、**已校验**、且填了分类(不是「未分类」)才算**高**;已收录但没填分类、或还没标成已校验的算**中**;机器翻译还没存进词库的算**低**;词库里查不到的仍是**未判定**。
- 鼠标停在徽章上会说明为什么是这一档,填了分类的会把分类名带出来。
- 「高」「中」两档补上中文译文——之前这两档一直显示的是英文。
### 双击已收录标签就能改词条
- 明细表里已收录的标签,双击英文那格会打开「新增或编辑个人标签」窗口,和在词库里点「编辑」是同一个:中文、别名、分类、模型、权重、备注都能改。
- 未收录、待确认的机器翻译,以及 `BREAK`、`<lora:…>` 这类特殊语法,双击仍是原来的定位到提示词。
- 未收录行去掉「搜索候选」按钮——词库里本来就搜不出这个词,点了只会打开空结果。
### 自然语言片段双击也开编辑弹窗
- 双击自然语言片段不再只提示「只有标签支持单独权重」,改成和标签一样打开编辑弹窗,只是只有「颜色」和「编辑」两页(权重和词条信息对整段文本没有意义)。
- 自然语言片段可以像标签一样设置单独配色。
## v2.1.0(2026-10-12)
### 助手设置搬进 ComfyUI 设置面板
- 侧边栏只保留「标签管理 / 词库 / 词包 / 收藏」四个页签,助手设置改到 ComfyUI 顶部「设置 → 双语提示词检查器 → 助手设置」,打开设置面板就能直接改,不用再进侧边栏找。
- 「外观」和「语言」从助手设置里分出来,单独成为「语言与外观」一项,排在助手设置上面;下拉改完立即保存并生效,不用再点保存按钮。
- 「清除已保存的 API 密钥」「清除已保存的百度密钥」从复选框改成滑动开关,打开后旁边显示「保存后生效」,再关掉可以取消。
### OpenAI 兼容与 Ollama 在翻译服务里直接可选
- 翻译服务下拉改成四个平级选项:纯词库 / 百度翻译 / OpenAI 兼容接口 / Ollama,不再需要先选「AI」再在下面选后端。
- OpenAI 兼容和 Ollama 各有自己的地址、模型、密钥、温度、超时和三条翻译规则,来回切换不会互相覆盖,切换后原来填的密钥也还在。
- Ollama 一栏没有密钥输入框。
- Ollama 区块新增「使用 Ollama 本地预设」按钮,一键填入本机默认地址;OpenAI 兼容区块的「使用 LM Studio 本地预设」按钮不变。
- 选纯词库或百度翻译时,会多出一个「AI 后端」下拉,决定「翻译并优化」和「优化为 Anima」用哪一套。
- 升级后旧配置会按当前选择的后端搬进对应的一套,另一套保留默认值。
### API 地址限制
- OpenAI 兼容地址只接受 https,或本机地址(`127.0.0.1` / `localhost` / `::1`),方便 LM Studio 这类本地服务;http 只在填本机地址时允许。
- Ollama 地址只接受本机地址。
- 地址里不能带用户名和密码,也不能带 `?` 之后的参数。
- 远端地址不允许是内网、保留或链路本地地址(例如 `192.168.x.x`、`10.x.x.x`、`169.254.169.254`)。
### 百度翻译不再显示翻译规则
- 翻译规则只有 AI 会用上,百度翻译和纯词库下面不再出现规则输入框。
### 暗色模式下的表单配色
- 输入框和下拉框改用 ComfyUI 自己的配色变量,不再另外写死一套,暗色主题下和设置面板里其它控件保持一致。
## v2.0.6(2026-10-10)
### 明细表可以按关键词查找
- 明细表上方新增查找框,输入即筛选出当前节点里命中的标签,英文原文和中文解释都参与匹配。
- 命中的片段会高亮,右侧显示命中数量,按 `Esc` 或点 × 一键清空。
- 查找与「全部 / 未收录 / 机器翻译 / 个人词库 / 收藏」**叠加生效**,可以先筛来源再筛关键词。
### 添加标签会插到选中标签之后
- 在节点里、以及侧边栏搜索词库添加标签时,新标签插在当前选中标签的后面,不再一律追加到提示词末尾;没选中标签时才加到末尾。
- 连续添加多个标签会依次往后排,添加完新标签自动处于选中状态。
### Anima 排序可以一键取消
- 排序写入后按钮变成「取消 Anima 顺序排序」,点击把提示词恢复到排序前的样子,同时退出分类表视图。
- 提示词本来就是 Anima 顺序时,取消只退出分类视图,不动提示词内容。
- 排序后又手动改过提示词的,取消入口会自动失效,避免回退把后来的改动一起吞掉。
- 排序与优化的预览弹窗里,「确认写入英文」改成「确认写入新提示词」。
### 侧边栏与词库
- 词库来源下拉新增「自然语言词库」,只显示保存时选了该词库的条目;分类下拉里「自然语言」固定排在「全部」下面,其余仍按拼音排序。
- 标签管理的节点下拉、以及保存收藏时选节点的弹窗,会显示在画布上双击改过的节点名(后面保留节点号,方便区分同名节点)。
- 去掉侧边栏「标签管理」里的「管理面板」按钮——它和顶部「词库」页签是同一回事,工具栏只留「刷新词库」。
## v2.0.0(2026-10-10)
### 新增「重复检查」节点
- 独立节点,检测一段提示词里的重复标签,把重复数量报出来;无重复时节点保持默认配色,有重复时转成淡橙色提示。
- 开关「只保留第一个」默认关闭,打开后只保留每个标签第一次出现的写法。
- 提示词直通下游,重复项另外输出,接预览任意可看。判定口径与双语节点完全一致(大小写、空格、下划线、权重、强调括号都视为同一个标签,`<lora:…>` 与 `BREAK`/`AND` 不参与)。
### 重复标签可以设「唯一」,并新增重复栏
- 「重复标签:X」后面给每个标签加了独立的「唯一」开关:打开后只有第一次出现会进实际输出,其余在界面上划线保留、**原文一个字不动**,支持 `Ctrl+Z` 撤回。
- 英文标签区、「已隐藏」那一栏下面新增**重复栏**:一个标签一个胶囊,单击依次跳到每一次出现并联动选中(当前那处标红),已经定位时双击就删掉当前这一处。
- 重复分组抽成 `parser.js` 的 `duplicateGroups()`,问题提示行、重复栏、重复检查节点共用同一份口径,不会再各算一套。
### 其它
- 标签卡上的隐藏(眼睛)按钮不再跟着英文行走,固定在整张卡片的右上角内侧;鼠标移到卡片任意位置(含中文行)都会显示它。
## v1.2.18(2026-10-05)
- **双击标签卡打开的编辑框支持一次改多个标签**:多选后双击,编辑范围是「最前一个选中标签的开头」到「最后一个选中标签的结尾」,中间没选中的标签也一起带进来;只在选中多于一个时给标签卡和明细表行加描边。
- **「编辑」页签改成整段改写**:把这一段原文整块剪切出来改、改完粘回去(新增 `replacePromptSpan()`),不再是逐个标签拼装。清空即删除这一段,接缝处多余的逗号会被收掉,纯换行原样保留,多行排版不会被压平。
- 标签字段抽成 `buildTagFields()`,编辑框与侧边栏「新增/编辑个人标签」弹窗共用同一份字段,不会再两处走样;弹窗与面板的样式细节同步调整。
## v1.2.16(2026-10-01)
- **快捷添加会插到选中标签后面**:选中某个标签时,新增的标签按它的结束位置插进去,不再一律丢到末尾;没选中或选中失效时仍落到末尾,判定与「插入换行」共用同一套。
- **隐藏区的长文本胶囊不再被撑成大圆角块**:胶囊里的中文译文缺了单行限制,遇到长自然语言会在里面折行把高度顶起来。中英文两段都改成单行截断,高度恒定一行,超出部分显示省略号。
## v1.2.15(2026-09-30)
- **双击标签卡打开的编辑框新增「词条信息」页签**:现在三个页签——权重、颜色、词条信息。命中的词库词条会整条带过来改(分类、中文、来源、备注都能改),没命中就按当前标签卡起个头新建。词条字段抽成了 `buildTagFields()`,这个页签和侧边栏「新增/编辑个人标签」弹窗共用同一份字段,不会再两处走样。
- 编辑框里的「Anima 格式」说明挪进权重页,「清除权重」按钮也只在权重页显示(之前在别的页签上也会出现,容易误点)。页签状态由原来的 `onColorTab` 布尔改成 `activeTab` 三态,底部共用一个「应用」按钮按当前页签分派。
- Comfy Registry 的插件页面补上图标与横幅:`[tool.comfy]` 新增 `Icon`(400×400 方形)和 `Banner`(941×941 原图),在 Registry 节点页和 ComfyUI-Manager 里显示,方便按图找到本插件。
## v1.2.14(2026-09-29)
- **标签弹窗的「分类」改成可筛选下拉**(与「适合模型」同一套控件):候选取词库里**真实出现过**的分类,按词条数降序,每项标出条数;输入即筛选,`↑` `↓` 切换、回车确认、`Esc` 收起,按 `↓` 或再点输入框可重新唤出列表。「自定义」恒为列表第一项且不会被过滤掉,选中后输入框右侧挂青绿色标识,表示「这个值不是词库现成的分类」;手打的值命中候选时自动摘掉标识,编辑历史标签带进来的陌生值当场也算自定义。新建时分类留空、由后端落成「自定义」,避免一打开窗口就挂着标识。
- **标签卡新增两种上色方式**:过滤栏的「颜色」改成就地展开的面板,除原有的默认 / 按收录 / 随机外,新增**按词库**(每个词库一色)与**按 Anima**(按 Anima 官方分类上色)。配色写入 `data/token_colors.json` 的 `pack_colors` / `anima_colors` 并新增写接口;双击标签设的自定义色优先级最高。
- **识别并标记「疑似自然语言」**:英文片段里空格超过 3 个时判定为疑似自然语言——这是**形状上的怀疑**,跟「有没有被词库收录」是两回事,两者互不替代。节点卡片与明细表都会着色标出;只在默认/按收录这类诊断模式下显示,「按词库」「随机」这类配色方案不会被它盖住。
- 新增自然语言词库:从原词库里拆出独立的 `pack_id`,让自然语言片段有专门的来源标注。
- 修复「优化为 Anima」的结果被转义符、句点和权重小数打断:清洗表补全角引号;清掉模型把结果写成 JSON/代码字符串时残留的反斜杠(字面 `\n` `\t` 先还原成换行,其余孤立反斜杠删除);末尾孤立句点直接删除。
- 修复权重小数让整串标签被误判成自然语言段落:判定句末标点前先中和数字句点(如 `1.5`)与闭括号后的句点。
## v1.2.10(2026-09-27)
- **界面重构:英文输出与中文同步编辑合并成一张双语卡片**。节点里不再有上下两个编辑区,每个标签上面英文、下面中文,横排自动折行,节点一下子矮了一半。随之移除中文编辑框、编辑态、草稿、暂存、同步预览,以及「编辑文本/翻译/翻译并优化/展开编辑/同步到英文输出」这批只在中文区才需要的按钮;「优化为 Anima」改为直接处理英文提示词,出结果后先弹预览确认再写回。
- 卡片标题简化为「提示词」,逗号与换行块下沉到中文行的高度,选中时整张卡片(中英文一起)高亮;点击与拖拽都作用于整张卡片,不再只认英文那一行。
- **隐藏改成为标签打标记**:隐藏的标签不再离开文本,卡片与明细行加删除线并灰化,照样可以拖动排序、改颜色、改权重,只是模型看不到它。节点文本保留隐藏标签,实际输出走新的 `effective_text` 输出口(原输出口保留),全隐藏时输出为空。眼睛图标隐藏后变成带斜线的「取消隐藏」,「已隐藏」条与「全部恢复」照旧。
- **换行块**:英文标签区与中文对照区把原文里的换行渲染成 `↵` 块,可拖动、可删除,英文区头部新增「插入换行」;两个视图按换行真的断行,中英两区行数对齐。只改写标签之间的间隔,token 原文与 parser 都不动。
- 明细表在两个标签之间出现换行时插入一行青绿色「换行」行(只在「全部」过滤下显示),拖手柄到某个标签行上即把换行移到那个标签后面。
- **标签颜色**:过滤栏新增「颜色」下拉(默认/按收录/随机),双击标签打开的编辑器加「颜色」页,支持预设色块与 `#hex` / `rgb()` 自定义,颜色池来自 `data/token_colors.json`。
- 机器翻译批量入库:明细表里机器翻译行新增勾选框,表格上方出现「全选/已选计数/把已选加入个人词库」,一次写入个人词库并清掉「待确认」标记;没有机器翻译词条时整条隐藏。
- 明细表分栏可拖动:默认左 2 右 3,拖表头分界改比例(夹在 20%~80%),双击复位,比例存进 preferences,刷新与重启后保留。
- 底部新增快捷添加输入框:写中文自动翻译成英文标签后追加,写英文标签直接追加;`Enter` 添加、`Shift+Enter` 换行、`Ctrl+Enter` 翻译并优化。
- 中文对照区的标签支持拖拽排序(此前只能点击联动),与英文区共用同一套拖拽逻辑;节点输出名改为「英文提示词」,提示词控件标题写死中文(`widget.label` 是直接赋值,不走翻译层)。
- 修复百度翻译批量请求触发 54003:把只在单次请求内生效的 `sleep(1.1)` 换成跨请求的全局节流器,间隔按 `1/QPS×1.1` 留 10% 余量;新增 `baidu_qps` 配置项(默认 1,个人认证可填 10),助手设置里加了数字输入框。
- 收藏支持「适合模型」与备注,并可直接编辑:新增 `POST /bpi/saved-prompts/{id}` 与 `SavedPromptStore.update_prompt()`,图片读写抽出 `_write_image` / `_remove_image` 给新建、编辑、删除共用;新增 `tests/test_model_suggest.mjs` 与收藏存储的用例。
- 顺带修掉 `retranslateMachineRow` 用了不存在的 `row.english`(一直是 `undefined`)。
## v1.2.1(2026-09-23)
- **修复 Nodes 2.0(设置里的「现代节点设计」)下标签拖拽完全失效**:该模式把节点改成 DOM 渲染,Vue 的祖先元素会在**捕获阶段** `stopPropagation`,事件到不了目标、也不会冒泡回 `document`,而拖拽的 `pointermove` / `pointerup` / `pointercancel` 原本挂在 `document` 的冒泡阶段,一次都收不到。改为统一走 `document` 的**捕获阶段**(新增 `bindDragTrackers` / `unbindDragTrackers` 两个工具函数),英文标签与明细表 `⠿` 手柄两处同时修复。经典画布模式行为不变。
@@ -7,6 +259,7 @@
- **修复插件前端扩展被加载两遍**:`pyproject.toml` 的 `[tool.comfy] web = "js"` 与 `__init__.py` 的 `WEB_DIRECTORY = "./js"` 会被 ComfyUI 分别登记(key 一个是 `project.name`、一个是模块名),`/extensions` 接口因此把同一批 js 列两次,前端各加载一次并报 `Extension named ... already registered.`。已移除 `web` 字段,前端资源统一由 `WEB_DIRECTORY` 提供。
- 上游连接判定改用 `node.isInputConnected(slot)`(老版本无此方法时退回读 `input.link`),消除新版前端里 `input.link is deprecated` 的控制台刷屏。
## v1.2.0(2026-09-23)
- 翻译服务与 AI 后端解耦(schema v3):翻译/解释可选纯词库 / 百度翻译 / AI;"翻译并优化"与"优化为 Anima"恒走 AI。AI 与百度密钥独立留存,切换翻译服务互不清空——只有 AI 后端/地址变更或换机器时才清 AI Key,百度密钥只在显式清除时才清。
@@ -15,7 +268,6 @@
- 修复 `clear_ai_api_key` / `clear_baidu_secret_key` 在 payload 未同时包含对应密钥字段时不生效的嵌套条件 bug。
- 修复节点高度棘轮上涨:ComfyUI `text` 控件的 `computedHeight` 是单调累加器,每次写值都会让节点变大一点。改用原生 `text` 控件的 `scrollHeight@auto` 作为稳定基线,不再读 `widget.y`,节点高度在多次写值后锁定不变。
- `inspectorNodeTargetHeight` 签名简化为 `{baseHeight, targetWidgetHeight, fallbackBaseHeight, minimumHeight}`,移除旧 `widgetY`/`nodeHeight`/`currentWidgetHeight` 等参数。
- 新增标签拖拽排序:英文标签区可按住标签直接拖动,明细表每行左侧提供 `⠿` 手柄(含插入指示线与边缘自动滚动),选中标签后支持 `Alt+↑` / `Alt+↓` 键盘微调;三处入口共用同一套 `Ctrl+Z` 撤销历史。
- 排序按“提取—插入”建模:保留原有逗号、空格与换行排版,多行结构不被合并;自然语言块整体移动,内部不拆分;重复标签按出现位置独立移动。
- 明细表筛选视图(未知/机器译/个人词库/收藏)与自然语言、指令模式下不启用拖拽,避免局部顺序被误解为完整顺序。
@@ -36,16 +288,13 @@
- 收藏数据存在 ComfyUI 用户目录 `user/bilingual-prompt-inspector/saved_prompts/`(`index.json` + 独立图片文件),不写入插件目录,重装插件不丢数据;单图上限 5 MB,最多 2000 条。
- 收藏不记录工作流信息;「载入节点」先弹窗确认再覆盖节点文本,不进 `Ctrl+Z` 撤销栈。侧边栏「新建」在没有选中节点时提供节点选择器兜底。
- 新增 `tests/test_saved_prompt_store.py`:覆盖图片魔数校验、超限拒绝、删除连带删图、导出导入往返(保留原顺序)、重复与非法条目跳过、非法时间戳回退等 14 个用例。
- 新增外观模式配置:助手设置里可切换「跟随 ComfyUI / 暗色 / 亮色」,默认跟随——ComfyUI 用什么主题插件就用什么主题。此前插件把配色硬编码成暗色值,在 ComfyUI 亮色模式下文字与背景几乎无法分辨。
- 实现上新增一组 `--bpi-*` CSS 变量(面板、文字、边框、按钮、输入框、表格、弹窗),按 `:root`(亮色默认)、`.dark-theme`(跟随 ComfyUI 暗色)、`[data-bpi-theme]`(强制暗/亮)四套定义;配置存在 `assistant_store` 的 `appearance` 字段,切换后即时生效无需刷新。
- 节点中文同步编辑区的「标签管理」「助手设置」入口已移除(与侧边栏管理面板重复),代码里保留注释说明如何恢复;助手设置里的翻译服务说明改为整行显示,不再挤在表单左列。
- 用户界面英文化:为符合 ComfyUI-Manager 注册要求,节点显示名改为 `Prompt Translator & Manager`,`@title` / `@description` / `RETURN_NAMES` / `CATEGORY` 与约 540 处 JS 界面文字(按钮、提示、状态消息、输入框占位符、语法诊断)全部改为英文。
- 中文通过 locale 保留:新增 `locales/zh/main.json`(`nodeDefs` 结构),在 ComfyUI 里把界面语言切到中文后,节点名、描述与输入输出名仍显示中文「提示词翻译与管理(英文输出)」——由 ComfyUI 的 `build_translations()` 与前端 `mergeCustomNodesI18n()` 自动合并,无需额外配置。
- 英文化时有四类内容刻意保留中文:代码注释;匹配用户中文输入的正则模式(如「翻译要求/正文」结构化指令识别);词库中文分类名匹配表(改掉会导致分类排序失效);以及「未收录」「自然语言片段(待翻译或确认)」等在中文列显示的翻译数据。
- 更新 5 个测试文件的断言以匹配新的英文字符串(`test_anima_sorter.mjs`、`test_clear_controls.mjs`、`test_dictionary_tools.mjs`、`test_parser.mjs`、`test_project_link.mjs`)。
- 新增界面语言设置:助手设置页新增「Language」下拉,可选「跟随 ComfyUI 语言设置」(默认)/「中文」/「英文」,随其它设置一起存在 `assistant_store` 的 `language` 字段(auto / zh / en),并在 `localStorage` 留一份供首屏同步取值。
- ComfyUI 自带的 locale 只翻译 `nodeDefs`(节点名、描述、输入输出名),面板里的按钮、状态栏、tooltip 管不到,因此新增插件自建的 i18n 层 `js/i18n.js`:源码继续保留英文原文(上架要求),运行时按原文查中文表,查不到就原样显示英文,不会出现半中半英或空文案。
- 文案统一走 `t()`:`element()` / `button()` / `field()` 三个 DOM 工厂与全部 `title` / `placeholder` / `textContent` 赋值点都接入翻译(共 200+ 条文案)。带变量的文案(如 `deleted “xxx”; press Ctrl+Z to undo`)把「“…”」片段和数字折叠成 `{}` 占位符再回填,模板字符串也能整句翻译。
@@ -55,7 +304,7 @@
- 补齐第二批翻译(约 200 条):词包卡片信息(个人词库/大词库/社区词包的条数、版本、来源、授权)、标签管理的统计行「共 N 个标签|已识别|未收录|待确认」、模式提示、各类操作回执(移动/隐藏/恢复/翻译/导入)、语法诊断全部文案(括号、权重、重复标签、冲突组、输入模式判定理由)、Anima 分类名、以及所有请求失败提示。
- 变量的递归翻译:嵌在文案里的变量本身也可能是界面词(如冲突组名 `Solo vs multiple subjects`、模式名 `Tag`),回填时会再翻译一层,避免出现「中文句子里夹一个英文词」。
- 为让变量能被稳定折叠,`parser.js` 的诊断模板与词包信息模板给动态片段加上「“...”」包裹(版本号 `1.0.0`、来源、授权等),否则带点号的版本会被拆成多个变量而匹配不到译表。
- 统计行改为分段翻译再拼接:词库页的 `Showing … | Selected … | Enabled packs … | Built-in … | Large … | Personal … | Pending …` 与节点里的 `Common dictionary … items | Large …` 原来是整句做键,大词库有「未安装 / 按需(N 条)/ 已停用」三种形态,整句键必然漏掉其中几种。现在每段各自翻译后再用 ` | ` 拼起来,新增形态不会再漏。
- 统计行改为分段翻译再拼接:词库页的 `Showing … | Selected … | Enabled packs … | Built-in … | Large … | Personal … | Pending …` 与节点里的 `Common dictionary … items | Large …` 原来是整句做键,大词库有「未安装 / 按需(N 条)/ 已停用」三种形态,整句键必然漏掉其中几种。现在每段各自翻译后再用 `|` 拼起来,新增形态不会再漏。
- 修掉一类「译表里有、界面上却还是英文」的隐蔽 bug:模板里的自由文本变量(标签名、词包名、错误信息、文件名、操作名)没有用「“...”」包裹时不会被折叠成 `{}`,整句变成带具体内容的字面量,永远匹配不到译表。已统一给 27 处这类变量加上包裹,并把 4 处误用 ASCII 直引号的地方(`"Re-translated \"...\""` 等)换成全角引号;译表里 6 条把直引号烤进键名的条目(`"Processed \"{}\""` 等)同步改成纯 `{}`。
- 补齐第三批翻译(22 条):置信度徽章(`Low · pending` / `Medium · pending` / `Medium · session-only`)、收藏星标按钮(★/☆)、`⚠ Not indexed:` 标签提示、Anima 格式说明、`Node #N · N tags` 收藏备注、`Running …` / `Translating unknown tags N/M` / `Translation complete …` 三条运行状态、`Querying dictionary…` / `Searching for more…` / `Paused, waiting for confirmation…`、权重越界诊断、以及词包导入预览与删除确认。
- 两处组合句改分段翻译:词包导入预览 `Tags N | Sample: …`(示例内容是数据,不该参与翻译)与标签管理节点标题 `Node #N: …`(提示词原文不翻译)。
+31 -13
View File
@@ -26,9 +26,23 @@ include credentials or private prompt content.
# 版本号与发布(改版本前必读)
## 一键升版本(平时用这个就够了)
python tools/bump_version.py 1.2.16
脚本会把下面 5 处全部改好,并在 `CHANGELOG.md` 顶部插入新的一节(正文留「- (待补充)」,自己填)。
只做字符串替换,`pyproject.toml` 里的 description / Icon / Banner 原样保留。
任何一处没匹配上就整体中止,不会留下改了一半的文件。
脚本跑完之后剩下的事(commit / push / 打 tag)自己来做:
git add -A && git commit -m 'chore: 发布 v1.2.16 / Release v1.2.16'
git push origin main
git tag -a v1.2.16 -m 'v1.2.16' && git push origin v1.2.16
## 需要同步修改的 5 个文件
升版本号时**这 5 处必须一起改**,漏一处就会出现"上架版本是 1.2.1、关于框还显示 1.2.0"这类不一致。
升版本号时**这 5 处必须一起改**(即上面脚本做的事,手动改时照这张表逐项核对),漏一处就会出现"上架版本是 1.2.1、关于框还显示 1.2.0"这类不一致。
| # | 文件 | 位置 | 当前写法 | 说明 |
|---|------|------|----------|------|
@@ -51,26 +65,30 @@ include credentials or private prompt content.
## 发布顺序
```bash
git push origin main
git tag -a v1.2.0 -m "v1.2.0" # 版本号换成当次版本
git push origin v1.2.0
```
然后在 GitHub 上基于该标签建 Release(内容取自 CHANGELOG 对应章节),最后:
Registry 的发布由 `.github/workflows/publish_action.yml` **自动完成**:并入 main 且 `pyproject.toml` 有变动
(实际就是版本号升了)时触发。所以在分支上怎么改都不会发布,只有合进 main 那一刻才对外发一次。
```bash
comfy node publish # 需要 registry.comfy.org 生成的 API key
git push origin main # 版本号已升 → 自动发布
git tag -a v1.2.10 -m "v1.2.10" # 标签只用于 GitHub Release 与回溯,Registry 不读它
git push origin v1.2.10
```
版本号没升又想重发时,到 GitHub 的 Actions → "Publish to Comfy Registry" → Run workflow(预留了 `workflow_dispatch`)。
本地 `comfy node publish` 只在 Actions 不可用时才用——它打的包是 **git 已跟踪的文件**,没 commit 的改动不会进包。
## 发布前必须确认(不可撤销)
- `pyproject.toml` 的 **`name`(`prompt-translator-manager`)发布后永久不可改**,且全局唯一
- **`version` 发布后不可覆盖**:1.2.0 发出去后,哪怕只改一个标点也只能发 1.2.1
- 确认 `.comfyignore` 没有误伤 `data/`(内置词库)、`assets/`(README 截图)、`locales/`(中文节点名)——排除了插件会直接跑不起来
## 自动发布(可选)
## 自动发布(已启用)
仓库里目前只有 `.github/workflows/tests.yml`,没有发布 workflow。想实现"升版本号 → 推 main → 自动发布",可加
`.github/workflows/publish_action.yml`(用 `Comfy-Org/publish-node-action@main`),并把 registry 的 API key
存为仓库 secret `REGISTRY_ACCESS_TOKEN`。该 workflow 只在 `pyproject.toml` 变动时触发。
`.github/workflows/publish_action.yml` 用 `Comfy-Org/publish-node-action@main`,凭据是仓库 secret
`REGISTRY_ACCESS_TOKEN`(在 registry.comfy.org 进自己的 publisher 页面生成 API Key 后,填到
Settings → Secrets and Variables → Actions → New repository secret)。
- 触发条件:`push` 到 `main` 且 `pyproject.toml` 有变动,或手动 `workflow_dispatch`。
- **所以每次要发布都必须 bump 版本号**:没升版本时 Registry 回 `Version already exists`,job 会报红,这是预期行为不是故障。
- 反过来,改了 `pyproject.toml` 里除 `version` 以外的字段也会触发发布,同样会因版本已存在而失败——非必要别动那些字段。
+275 -93
View File
@@ -1,145 +1,325 @@
# ComfyUI 双语提示词管理器 v1.2.1
# ComfyUI 双语提示词管理器 v2.2.1
一个用于整理、检查和翻译 Anima / Danbooru 风格提示词的 ComfyUI 节点。既可以当普通英文提示词输入框用,也可以把提示词拆成可点击的英文标签 + 中文解释 + 词库记录,边写边确认每个标签到底在起什么作用。
> ComfyUI 双语提示词节点:把英文提示词拆成可点击的标签,边写边看中文解释、调顺序、试效果。
> 本项目基于 [Qiongyi44/ComfyUI-Bilingual-Prompt-Inspector](https://github.com/Qiongyi44/ComfyUI-Bilingual-Prompt-Inspector) 二次开发,现已独立维护。在原版基础上新增了百度翻译、侧边栏管理面板,以及标签拖拽排序与临时隐藏功能。
<p align="center">
<img src="https://raw.githubusercontent.com/mengshengzhijie/tuchuan/master/assets/ComfyUI-Bilingual-Prompt-Inspector/screenshot-main.png" alt="主界面" width="880">
</p>
![主界面](assets/screenshot-main.png)
**它解决什么问题**:Anima / Danbooru 风格的提示词是一长串英文标签,写完很难确认模型到底读到了什么。这个节点把提示词变成可选中、可删除、可排序的芯片,每个芯片旁边就是它的中文解释;输出的永远只有英文提示词本身,中文只用于阅读。
## 功能一览
---
**提示词编辑**
- **标签化视图**:点一下英文标签,对应的中文解释和明细行会同步高亮,看不清的标签不用再猜。
## 目录
- **拖拽排序**:按住标签直接拖动,或在明细表里拖 `⠿` 手柄,也可以选中后按 `Alt+↑` / `Alt+↓` 微调。排版不会乱——逗号空格样式保留,换行分段不被合并。
- [主要功能](#主要功能)
- [提示词编辑](#提示词编辑)
- [翻译与整理](#翻译与整理)
- [词库与翻译服务](#词库与翻译服务)
- [上游提示词接管](#上游提示词接管)
- [收藏](#收藏)
- [侧边栏管理面板](#侧边栏管理面板)
- [操作反馈](#操作反馈)
- [界面导览](#界面导览)
- [安装](#安装)
- [快速上手](#快速上手)
- [详细说明](#详细说明)
- [常见问题](#常见问题)
- [已知边界](#已知边界)
- [性能与隐私](#性能与隐私)
- [反馈与联系](#反馈与联系)
- **临时隐藏**:点标签上的眼睛图标,标签会从实际输出中移除(模型完全看不到),同时留在「已隐藏」区,随时一键恢复原位。做「有无这个标签」的对比出图很方便。
---
- **权重编辑**:双击标签设置 `0–3` 权重,支持常用预设与一键清除。
## 主要功能
- **撤销重做**:删除、排序、隐藏、权重全部支持 `Ctrl+Z` / `Ctrl+Y`。
### 提示词编辑
- **接管上游提示词**:左侧 `prompt` 输入口可接任意输出文本的上游节点。接管开启时运行到本节点会暂停,把上游文本导入后让你整理,点「继续运行」才输出给下游。
<table>
<tr>
<td width="26%"><b>标签化视图</b></td>
<td>英文提示词按标签切成芯片,每张卡片上半是英文、下半就是它的中文解释,点一下还会和侧边栏的明细行联动高亮。看不清楚的标签不用再猜。</td>
</tr>
<tr>
<td><b>编辑 / 标签两种形态</b></td>
<td>「编辑文本」在纯文本编辑和标签视图之间切换。写完点「完成」进入标签视图,随时可以切回去改。</td>
</tr>
<tr>
<td><b>拖拽排序</b></td>
<td>按住标签直接拖,或在明细表里拖 <code>⠿</code> 手柄;也可以选中后用 <code>Alt+↑</code> / <code>Alt+↓</code> 微调。排版不会乱——逗号空格样式保留,换行分段不被合并。开启「拖动实时预览」后,被拖的标签会浮起来跟着光标走,其余标签像手机桌面挪图标那样平滑让开。</td>
</tr>
<tr>
<td><b>临时隐藏</b></td>
<td>鼠标移到标签卡片上,右下角会出现眼睛图标,点它把标签移出实际输出。标签不会从节点里消失,只是加删除线并灰化,照常可以拖动、改色、改权重,但模型完全看不到它。适合做「有 / 没有这个标签」的对比出图,之后逐个或一次性全部恢复。</td>
</tr>
<tr>
<td><b>删除标签</b></td>
<td>选中标签后卡片右上角出现 <code>×</code>,点它删除;也可以选中后直接按 <code>Delete</code>。删掉的标签会先被划掉、擦走,再从列表里消失;添加标签时新标签则展开出现——两个过渡共用一个开关,可以关掉。</td>
</tr>
<tr>
<td><b>权重编辑</b></td>
<td>双击标签设置 <code>0–3</code> 权重,有常用预设和一键清除。</td>
</tr>
<tr>
<td><b>颜色分区</b></td>
<td>标签可按「收录状态」着色(未收录 / 机器翻译各自一色),也可以随机着色帮助分辨;需要时一个个自定义。</td>
</tr>
<tr>
<td><b>撤销重做</b></td>
<td>删除、排序、隐藏、权重、清空全部支持撤销重做,快捷键 <code>Shift+Alt+Z</code> / <code>Shift+Alt+Y</code>(<code>Ctrl+Z</code> 留给 ComfyUI 画布自己的撤销,插件不抢)。快捷键只在节点文本框 / 英文标签区有焦点时生效。</td>
</tr>
<tr>
<td><b>换行分段</b></td>
<td>提示词里的换行会渲染成行尾的 <code>↵</code> 块,标签区真的断行,中英两栏行数对齐。换行块本身也能拖动。</td>
</tr>
</table>
![Demo — edit](https://raw.githubusercontent.com/mengshengzhijie/tuchuan/master/assets/ComfyUI-Bilingual-Prompt-Inspector/demo-editing.gif)
- **收藏**:遇到好用的提示词,点节点上「英文提示词(实际输出)」旁边的「收藏」按钮,命名后可选配一张出图截图,存进侧边栏「收藏」分页。
### 翻译与整理
![标签联动与拖拽排序](assets/demo-editing.gif)
| 能力 | 说明 |
|---|:-:|
| **四种处理方式** | 仅翻译 / 翻译并优化 / 优化 / 按 Anima 官方顺序整理(不新增标签,只重排分段) |
| **优化按模式分派** | 标签模式下「优化」整理现有英文标签流(只吃英文);自然语言模式下改写为英文自然语句,中文会先译成英文。两种模式各用一套规则,可在助手设置里分别自定义 |
| **自动识别模式** | 自动区分纯标签、纯自然语言、混合文本和翻译指令,也可以在明细表上方手动指定;指定的模式跟着节点保存 |
| **自然语言模式** | 模式切到「自然语言」后,「按 Anima 顺序排序」自动隐藏——整段句子没有标签流可排 |
| **提示词诊断** | 自动标出可疑权重、重复标签、语法问题 |
| **快捷添加** | 底部的快捷输入框:写中文自动翻译成英文标签后追加,写英文标签直接追加;`Enter` 添加、`Shift+Enter` 换行、`Ctrl+Enter` 翻译并优化 |
**翻译与整理**
### 词库与翻译服务
- **三种处理方式**:仅翻译 / 翻译并优化 / 优化为 Anima。
- **Anima 顺序整理**:按官方推荐顺序把现有标签重新分段排列,不新增标签。
- **提示词诊断**:自动标出可疑的权重、重复标签、语法问题。
- **自动识别模式**:自动区分纯标签、纯自然语言、混合文本和翻译指令。
| 能力 | 说明 |
|---|---|
| **多来源词库** | 内置词库 + 个人词库 + 社区词库包,每条解释都会标注来源 |
| **概念搜索** | 搜「丝袜」能返回连裤袜、长筒袜、过膝袜等相关标签,支持滚动分页加载 |
| **大型 Danbooru 词库(可选)** | 按需安装,覆盖面更广,不装也不影响其它功能 |
| **翻译服务可切换** | 纯词库(离线、免费)/ 百度翻译 / OpenAI 兼容 API / Ollama。翻译与解释可选任意一种;「翻译并优化」「优化」恒定走 AI |
| **密钥互不干扰** | AI 与百度密钥独立留存,切换翻译服务不会清空;均不写入工作流、不返回浏览器 |
**词库与翻译服务**
- **多来源词库**:内置词库 + 个人词库 + 社区词库包,逐条说明解释来源。
- **概念搜索**:搜「丝袜」能返回连裤袜、长筒袜、过膝袜等相关标签;支持 40 项分页滚动加载。
- **可选大型 Danbooru 词库**:按需安装,覆盖更广。
- **翻译服务可选**:翻译/解释可选纯词库(离线)/ 百度翻译 / AI;"翻译并优化"与"优化为 Anima"恒走 AI。AI 与百度密钥独立留存,切换翻译服务互不清空。
### 上游提示词接管
**管理面板(侧边栏)**
- 标签管理、词库维护、助手设置、收藏集中在侧边栏「双语提示词管理」面板,节点本身保持简洁。
- 面板改动实时同步到页面上所有节点,无需刷新。
节点左侧有一个 `prompt` 输入口,接法与 CLIP 文本编码器完全一样,把上游节点的文本输出拖过来即可。不接的时候节点行为和普通提示词节点一样。
**收藏(保存到用户目录)**
- 收藏内容存在 ComfyUI 用户目录 `user/bilingual-prompt-inspector/saved_prompts/`,不写在插件目录里,重装或更新插件不会丢。
- 支持搜索、备注、配图、复制、导出/导入 JSON 备份。
- 「载入节点」会先弹窗确认再覆盖节点文本(不支持 `Ctrl+Z` 撤销)。
接上之后,节点顶部会出现「上游输入」控制条:
- **拦截上游文本**(默认开启):运行时先把上游文本导入节点,然后**暂停工作流**让你整理——排序、隐藏、翻译、直接改都行——点「继续」后才把结果交给下游。暂停期间下游不执行,也不占用显卡资源。
- **关闭拦截**:节点等价于一根导线,上游文本原样流向下游,不导入、不暂停。
- **导入策略**(仅拦截时可用):`导入:内容变化时`(默认,不会冲掉你手工改过的内容)/ `导入:每次运行` / `导入:仅首次运行`。
三点注意事项:
1. 暂停用的是 ComfyUI 原生的异步节点机制,不是死等轮询;等待期间可以点 ComfyUI 的取消中断,也可以点「丢弃」终止本次等待。
2. 暂停会占用 ComfyUI 的执行线程,队列里的其它任务要等这次确认完成。
3. 没有浏览器界面时(例如用 API 提交工作流)没人能确认,节点约 20 秒后自动按透传放行,不会永久卡住。
![Demo — stop](https://raw.githubusercontent.com/mengshengzhijie/tuchuan/master/assets/ComfyUI-Bilingual-Prompt-Inspector/demo-stop.gif)
### 收藏
出了一张满意的图,把当时的提示词存下来,下次直接复用。
- **保存**:点节点上「英文原文(实际输出)」右侧的「收藏」按钮 → 填名字、写备注(可留空)、标上适合的模型、拖入或粘贴一张出图截图 → 保存。保存的是节点当前这份英文文本(包含其中被隐藏的标签)。不在节点上时,也可以从侧边栏「收藏」分页点「新建」,再选一个节点读取文本。
- **适合模型**:输入关键词,会从本机 `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 条。
> 「载入节点」会先弹窗确认再覆盖节点文本,这个动作**无法撤销**。
### 侧边栏管理面板
标签管理、词库维护、词库包、收藏四个分页收在侧边栏的「双语提示词管理」面板里,节点本身保持简洁。面板里的改动会实时同步到页面上所有同类节点,不需要刷新页面。
「标签管理」默认跟着你点开的节点走:在画布上点一个「双语提示词检查器」节点,侧边栏就切到它。不想要这个行为,关掉「设置 → 通用」里的「点击节点时跟随切换」,之后只能在面板顶部的节点下拉里手动选。
助手设置不在这个面板里:它在 ComfyUI 的「设置」面板中,「设置 → 双语提示词检查器」下有两项 ——「通用」和「助手设置」,表单都直接铺在设置面板右侧。
### 操作反馈
每次操作都会给一条回执,按操作类型分色,不用再去状态栏找那一行字:
| 颜色 | 什么时候出现 |
|---|---|
| 蓝色 | 改内容:应用改写、改权重、隐藏 / 取消隐藏、保存词条、导入词库 |
| 紫色 | 添加标签(底部快捷输入、搜词库点结果) |
| 红色 | 删除标签 |
| 青色 | 移动标签 |
| 灰色 | 撤销 / 重做 |
| 绿色 | 翻译成功 |
| 橙色 | 「唯一」开关 |
- **右下角提示条**:新的出现在最下面,旧的往上顶,最多同时显示 5 条,过一会儿自己收走,也可以点 ✕ 立刻关掉。
- **右上角弹窗**:错误、翻译被拒、批量翻译的进度与汇总、重复标签的定位走这里,红色系、停留更久。同类操作会就地更新同一张卡(比如连点重复胶囊是在同一张卡里翻「第 2 / 3 处」),不会一条接一条地冒;批量翻译期间只留一张总进度卡。
- 两个都嫌打扰,可以在「设置 → 通用」里分别关掉「右上角通知弹窗」和「右下角提示条」,关掉后提示退回节点状态栏。
---
## 界面导览
![ComfyUI 双语提示检查器截图](https://raw.githubusercontent.com/mengshengzhijie/tuchuan/master/assets/ComfyUI-Bilingual-Prompt-Inspector/screenshot-main.png)
节点上可以大致分成三块:
1. **英文原文(实际输出)**——节点真正输出给下游的文本。上面有「编辑文本 / 完成」「清空」「插入换行」「收藏」「优化」「按 Anima 顺序排序」几个按钮。
2. **标签区**——每张卡片上半是英文标签、下半是它的中文解释,只用于阅读,不进入输出,也不会传给下游。标签多了这里会自己滚动,也可以在明细表上方的筛选栏打开「适应高度」,让它跟着节点一起变高。
3. **快捷添加**——底部输入框,中文进英文出(自动翻译后追加),英文则直接追加。
**明细表不在节点上**:它被侧边栏的「标签管理」借走了(节点旁边的画布空间要留给画面)。那里每行一个标签,列出每行的来源、翻译、权重、分类,右侧按行状态给出操作(翻译 / 确认并存 / 重新翻译 / 编辑中文 / 收藏 / 复制 / 删除个人词条);上方有搜索框、模式选择、颜色模式、筛选(全部 / 未知 / 机器翻译 / 个人词库 / 收藏)和问题提示。
---
## 安装
**用 ComfyUI-Manager(推荐)**:在 Manager 的节点列表里搜索 `Bilingual Prompt Inspector` 安装;也可以走 Manager 的「Install via Git URL」,填入仓库的仓库地址。
**使用comfy-cli安装**:
```bash
cd ComfyUI/custom_nodes/
git clone https://github.com/mengshengzhijie/ComfyUI-Bilingual-Prompt-Inspector.git
cd ComfyUI/
comfy node install prompt-translator-manager
```
重启 ComfyUI 后,在节点菜单的 **文本/提示词工具** 分类里找到 **提示词翻译与管理(英文输出)**。
**手动安装**:
> 从原版升级:直接覆盖 `custom_nodes` 下的目录即可,个人词库与词库设置会保留。
```bash
cd ComfyUI/custom_nodes/
git clone --depth 1 https://github.com/mengshengzhijie/ComfyUI-Bilingual-Prompt-Inspector.git
```
**如果网络受限也可以使用 gitee,或者用上神奇的 `-c`**:
```bash
git clone https://gitee.com/MengShengZhiJie/ComfyUI-Bilingual-Prompt-Inspector.git
```
不过这里如果有动手能力的话推荐使用 git 安装,因为现在就我和我的朋友在使用,有时候修复bug和制作新功能我是总结下来然后一步一步的修复。直到把这次收录的bug和建议给完成之后才bump版本。
重启 ComfyUI,在节点菜单的 **text/prompt** 分类里找到 **提示词翻译与管理(英文输出)**(英文界面下显示为 Prompt Translator & Manager),也可以直接搜索「提示词」。
两个常见坑:
- 装完看不到节点 → 先确认 `custom_nodes` 下的目录名正确、ComfyUI 确实重启了,然后 **`Ctrl+F5` 完全刷新浏览器**,清掉旧的 JS 缓存。
- 从原版([Qiongyi44/ComfyUI-Bilingual-Prompt-Inspector](https://github.com/Qiongyi44/ComfyUI-Bilingual-Prompt-Inspector))升级 → 直接覆盖 `custom_nodes` 下的目录,个人词库与词库设置会保留。
> Nodes 2.0(ComfyUI 设置里的「现代节点设计」)下,标签拖拽排序与上游即时拦截**已被官方前端旁路,无法工作**,这是前端渲染方式的限制。要用完整功能请关闭该选项;1.2.1 起其余功能在两种模式下都正常。
---
## 快速上手
1. 把节点接到 CLIP 文本编码器上(和普通提示词节点一样)。
2. 在节点顶部输入英文提示词,或点击「编辑文本」写好后完成编辑。
3. 在标签联动模式里逐个检查:点标签看中文解释,双击调权重,拖动手柄排顺序。
4. 想试某个标签的影响?点眼睛图标隐藏它,重新出图对比,再一键恢复。
1. 把节点接到 CLIP 文本编码器上,和普通提示词节点一样。
2. 在「英文原文」里写提示词,或点「编辑文本」写完后点「完成」。
3. 在标签视图里逐个检查:点标签看中文解释,双击调权重,拖动手柄排顺序。
4. 想确认某个标签到底有没有用?鼠标移到标签卡片上,点右下角那颗眼睛隐藏它,重新出图对比,再一键恢复。
![快速上手演示](assets/demo-quickstart.gif)
![Demo — run](https://raw.githubusercontent.com/mengshengzhijie/tuchuan/master/assets/ComfyUI-Bilingual-Prompt-Inspector/demo-run.gif)
## 接管上游提示词(暂停确认)
---
节点左侧有一个 `prompt` 输入口,接法和 CLIP 文本编码器一样:把上游节点的文本输出拖过来即可。不接时节点行为和以前完全一样。
## 详细说明
接上之后,节点内「英文提示词(实际输出)」上方会出现一条「上游输入」控制条:
### 翻译服务配置
- **接管上游文本**(默认开启):运行时先把上游文本导入节点,然后**暂停工作流**让你整理——排序、隐藏、翻译、直接改都行——点「继续运行」后才把结果交给下游。暂停期间下游不会执行,也不占用显卡资源。
- 关闭时节点等价于一根导线:上游文本原样流向下游,不导入、不暂停。
- **导入策略**(仅接管时可用)决定上游文本何时覆盖节点内容:`内容变化时`(默认,不会冲掉你改过的内容)/`每次运行`/`仅首次`。
默认走纯词库,不联网也能用。需要机器翻译或 AI 优化时,打开「设置 → 双语提示词检查器 → 助手设置」。这一项分三段:
几点说明:
- 暂停用的是 ComfyUI 的异步节点机制,不是死等轮询。等待期间可以点 ComfyUI 的取消中断,也可以点「放弃」终止本次等待。
- 暂停会占用 ComfyUI 的执行线程,队列里的其它任务要等这次确认完成。
- 没有浏览器界面时(例如用 API 提交工作流)没人能确认,节点会在约 20 秒后自动按透传放行,不会永久卡住。
## 收藏提示词
**保存**:出了一张满意的图,点节点上「英文提示词(实际输出)」右侧的「收藏」按钮 → 填名字、可写备注、拖入或粘贴一张配图 → 保存。提示词正文只读展示,保存的就是节点当前实际输出。如果当前不在节点上,也可以从侧边栏「收藏」分页点「新建」,再选一个节点读取它的文本。
**使用**:侧边栏「双语提示词管理 → 收藏」分页里按卡片浏览,可搜索、复制文本、载入节点、删除,也可以整包导出 JSON 给别的机器导入。
**存放位置**:`ComfyUI/user/bilingual-prompt-inspector/saved_prompts/`(`index.json` + 按 id 命名的图片文件),跟随 ComfyUI 的用户目录,不在插件目录内。单图上限 5 MB,最多 2000 条。
## 翻译服务配置
默认走纯词库,不联网也能用。需要机器翻译或 AI 优化时,点击节点上的「助手设置」按钮(或侧边栏面板的助手设置分页)选择服务。
助手设置面板分三段:
**翻译服务**(翻译 / 解释按此选择)
| 服务 | 说明 |
| 分段 | 内容 |
|---|---|
| 纯词库 | 只查本地词库,完全离线,不产生费用 |
| 百度翻译 | 需自行申请百度翻译开放平台的 APP ID 和密钥 |
| AI | 翻译/解释走 AI(OpenAI 兼容 API 或 Ollama),需填 AI 配置 |
| **翻译服务** | 翻译 / 解释按此选择:纯词库(离线、免费)/ 百度翻译(需自行申请开放平台的 APP ID 与密钥)/ OpenAI 兼容接口 / Ollama(本地)。选后面两个时,它们本身就是下面的 AI 服务 |
| **百度翻译** | 独立配置 APP ID 与密钥,切换翻译服务不会清空 |
| **AI 服务** | 「翻译并优化」「优化」恒定走这里。翻译服务选了 OpenAI 兼容 / Ollama 时,下面直接就是那个后端的配置;选纯词库 / 百度翻译时,这里多出一个「AI 后端」下拉,决定优化用哪一套。两个后端各存一套配置,切换不清空: |
| ↳ OpenAI 兼容 | API 地址、模型、可选 API Key、温度、超时,另有「使用 LM Studio 本地预设」一键填入 `1234/v1`。远端地址必须 https,本机回环(127.0.0.1 / localhost)才允许 http;内网、保留、链路本地地址一律拒绝 |
| ↳ Ollama | API 地址、模型、温度、超时,没有密钥,另有「使用 Ollama 本地预设」一键填入 `11434`。地址只能是本机回环地址,其它一律拒绝 |
**百度翻译**(独立配置,切翻译服务不清密钥):填百度 APP ID 与密钥。
模型名称旁边的「获取模型」按钮会拿输入框里当前填的地址问一次服务端有哪些模型(OpenAI 兼容问 `/models`,Ollama 问 `/api/tags`),拉到的名字直接填进输入框自带的下拉里——点输入框里的箭头就能选,也可以完全不管它自己手写。再点一次按钮就是重新拉取。列表只在本次会话内有效、地址一改就作废,不写进配置文件。密钥以输入框里填的为准,输入框留空就用你已经保存的那一份;无论哪种都只随这一次请求发出,不会随着设置一起回传给浏览器。服务端返回看不懂的内容(纯文本报错、登录页之类)时会原话告诉你,不会甩一段 JSON 报错。
**AI 服务**("翻译并优化""优化为 Anima"恒走 AI;翻译在选 AI 时也走它):选 AI 后端(OpenAI 兼容 / Ollama)、填 API 地址与模型、可选 API Key、温度与超时。
两个后端还各有一套规则(翻译 / 翻译并优化 / 优化 / 自然语言):本地小模型与云端大模型要写的提示词往往不一样,分开存可以随时切换后端而不用重改规则。其中「自然语言」那套只在自然语言模式下被「优化」使用,结果按完整英文句子保留标点,不套 Anima 的标点限制。地址不合规时会在发出请求前直接报错。
> 两个密钥独立留存——切换翻译服务互不清空,只有「AI 后端/地址变了」或「换机器」时才清 AI Key;百度密钥只在显式勾选"清除"时才清。两者均不写入工作流、不返回浏览器。
### 大型词库(可选)
## 大型词库(可选)
由于社区数据的授权边界,本仓库不附带 `danbooru_tags.sqlite3`。需要更大覆盖面时,按 `release_docs/大型词库手动安装说明.txt` 自行构建安装;不装也不影响其它功能,界面会明确显示「未安装」。
由于社区数据的授权边界,本仓库不附带 `danbooru_tags.sqlite3`。需要更大覆盖面时按 `release_docs/大型词库手动安装说明.txt` 自行构建安装;不装也不影响其他功能,界面会明确提示「未安装」。
### 界面语言与外观
界面文案跟随 ComfyUI 的语言设置,也可以在「设置 → 双语提示词检查器 → 通用」里固定为中文或英文。外观同样可在「跟随 ComfyUI / 暗色 / 亮色」之间切换,两项改完立即生效并单独保存。
这个折叠(「通知弹窗及实时动画预览」)里还收着几个手感和提示相关的开关,最上面另有一个「全部开启」总开关,一次控制下面这几项;各项都默认关闭,关掉就是原来那样:
| 开关 | 管什么 |
|---|---|
| 右上角通知弹窗 | 错误、被拒、批量翻译进度是否用右上角弹窗报 |
| 右下角提示条 | 操作回执是否用右下角提示条显示 |
| 拖动实时预览(节点标签区 / 明细表) | 拖动时其余标签是否实时让位,两个区域各管各的 |
| 标签增删动画(节点标签区) | 删除标签是否划掉擦走、添加标签是否展开出现 |
折叠上方还有一项「点击节点时跟随切换」:点画布上的节点时,侧边栏「标签管理」是否跟着切过去。
---
## 常见问题
**找不到节点?** 确认 `custom_nodes` 下目录名正确、已重启 ComfyUI;完全刷新浏览器(`Ctrl+F5`)清掉旧 JS 缓存。
<details>
<summary><b>找不到节点 / 界面还是旧的</b></summary>
先确认 `custom_nodes` 下的目录名正确、ComfyUI 确实重启过;然后强制刷新浏览器(`Ctrl+F5`)清掉旧 JS 缓存。ComfyUI 前端缓存比较顽固,这一步不能省。
</details>
**界面还是旧的?** ComfyUI 前端缓存较顽固,强制刷新或清一次浏览器缓存。
<details>
<summary><b>点翻译提示要先配置翻译服务</b></summary>
**翻译按钮提示需要模型?** 纯词库模式下只能查词库,想翻译请在助手设置里选一个翻译服务。
纯词库模式下只能查词库,不会翻译。想翻译请在「设置 → 双语提示词检查器 → 助手设置」里选一个翻译服务(百度翻译、OpenAI 兼容接口或 Ollama)。
</details>
**切换翻译服务后 Key 消失?** 不会。v3 起两套密钥独立留存,切换翻译服务互不清空;只有 AI 后端/地址变更或换机器时才清 AI Key。
<details>
<summary><b>切换翻译服务后密钥消失了?</b></summary>
不会。两套密钥独立留存,切换翻译服务互不清空。只有 AI 后端 / 地址变更或换机器时才清 AI Key,百度密钥只在显式勾选「清除」时才清。
</details>
<details>
<summary><b>拖拽排序 / 隐藏没反应?</b></summary>
这两种操作只在标签模式与混合模式下可用,纯自然语言模式下没有意义。另外如果开了 ComfyUI 的「现代节点设计(Nodes 2.0)」,请关掉再试,原因见上文安装一节。
</details>
<details>
<summary><b>拖标签时整片标签在动,看不清落点</b></summary>
那是「拖动实时预览」在起作用(默认关闭)。它会让其余标签实时让开,长提示词下偶尔显得晃;到「设置 → 通用」把它关掉,就退回只有落点提示线的老样子,松手才真正挪动。
</details>
<details>
<summary><b>运行到一半卡住了,好像在等什么</b></summary>
这是上游接管的暂停确认。节点在等你整理导入的上游文本,点「继续」放行,或点「丢弃」终止本次等待;也可以直接点 ComfyUI 的取消中断。无人确认时约 20 秒会自动透传。
</details>
---
## 已知边界
- 本扩展是提示词工具,不保证某个标签一定被模型准确执行。
- 自动翻译和优化结果都需要人工复查。
- 自然语言块不支持按内部单词逐项删除,应回到文本模式修改。
- 本扩展是提示词工具,**不保证**某个标签一定被模型准确执行。
- 自动翻译与优化结果都需要人工复查。
- 自然语言块不支持按内部单词逐项删除,要回文本模式改。
- 拖拽排序与隐藏只在标签模式和混合模式下可用。
- 隐藏清单保存在节点属性中,导出「API 格式」JSON 时不会带出(实际输出文本不受影响)。
- 接管上游提示词需要浏览器界面来确认;用 API 提交工作流时节点会自动透传,不会等待确认。
- 接管开关与导入策略同样保存在节点属性中,导出「API 格式」JSON 时不会带出。
- 收藏不记录工作流名称与节点编号;载入节点会覆盖节点当前文本,只弹窗确认、不支持撤销。
- 若 ComfyUI 大幅改动前端文本控件结构,界面适配可能需要更新。
- 提示条、拖动实时预览、增删动画这几个都属于观感加成,默认关闭;关掉后行为和旧版本完全一致,不影响任何数据。
- 隐藏清单、接管开关与导入策略都保存在节点属性里,导出「API 格式」JSON 时不会带出——届时隐藏标记会丢失,被隐藏的标签会直接出现在输出里。
- 接管上游提示词需要浏览器界面来确认;用 API 提交工作流时节点自动透传,不会等待。
- 收藏不记录工作流名称与节点编号;载入节点会覆盖节点当前文本,只弹窗确认,不支持撤销。
- 若 ComfyUI 大幅改动前端文本控件结构,界面适配可能需要跟着更新。
## 报告问题与联系
---
本插件由 mengshengzhijie 独立维护。遇到问题、有功能建议,请按下面的方式反馈(**不要附带 API Key、密钥、私人词典或完整配置文件**):
## 性能与隐私
- **默认不联网**:翻译服务的默认选项是纯词库,完全离线。
- **只在你主动使用某个服务时才发请求**:调用百度或 AI 时,发送的是提示词文本与你的指令;密钥只存在后端本地,不回传浏览器、不写入工作流。
- **大型词库按需加载**:不安装就不加载,装了也是本地文件查询。
- **收藏只存在本机**:写在 ComfyUI 用户目录下,插件不做任何上传。
---
## 反馈与联系
本插件由 mengshengzhijie 独立维护。遇到问题或有功能建议,欢迎反馈(**不要附带 API Key、密钥、私人词典或完整配置文件**):
- GitHub Issues:https://github.com/mengshengzhijie/ComfyUI-Bilingual-Prompt-Inspector/issues
- 邮箱:mengshengzhijie@163.com
@@ -148,8 +328,10 @@ git clone https://github.com/mengshengzhijie/ComfyUI-Bilingual-Prompt-Inspector.
安全问题(如密钥泄露风险)请不要开公开 issue,走 [GitHub 私有漏洞报告](https://github.com/mengshengzhijie/ComfyUI-Bilingual-Prompt-Inspector/security/advisories/new) 或发邮件,详见 `SECURITY.md`。
---
## 致谢与许可
基于 [Qiongyi44/ComfyUI-Bilingual-Prompt-Inspector](https://github.com/Qiongyi44/ComfyUI-Bilingual-Prompt-Inspector) 二次开发。词库数据来源与授权说明见 `THIRD_PARTY_NOTICE.md`,许可条款见 `LICENSE`。
基于 [Qiongyi44/ComfyUI-Bilingual-Prompt-Inspector](https://github.com/Qiongyi44/ComfyUI-Bilingual-Prompt-Inspector) 二次开发,现已独立维护。在原版基础上新增了百度翻译、侧边栏管理面板、标签拖拽排序、临时隐藏、上游接管与收藏,以及右下角提示条、拖动实时预览、标签增删动画这些交互细节。
详细使用手册见 `release_docs/使用手册(原README).md`。
词库数据来源与授权说明见 `THIRD_PARTY_NOTICE.md`,许可条款见 `LICENSE`,完整使用手册见 `release_docs/使用手册(原README).md`。
+21
View File
@@ -31,3 +31,24 @@ unauthenticated ComfyUI instance directly to the public internet.
External translation and optimization requests are sent only to the provider configured
by the user. Review that provider's privacy policy, logging behavior, content policy,
and cost before use.
## Outbound request targets
AI requests are sent only to the address stored in the local configuration file, and that
address is validated before a request is sent:
- OpenAI-compatible endpoints must use HTTPS. Plain HTTP is accepted only for loopback
addresses on the same machine (127.0.0.0/8, ::1, localhost), so a local runtime such as
LM Studio keeps working without sending the API key in clear text across the network.
- Remote OpenAI-compatible endpoints must be public. An address written directly as a
private, reserved or link-local IP (192.168.x.x, 10.x.x.x, 172.16.x.x, 169.254.169.254,
...) is rejected even over HTTPS. Domain names are accepted; they are not resolved during
validation.
- Ollama endpoints must be a loopback address on the same machine.
Credentials embedded in the URL (`user:pass@`) and query strings are rejected as well, and
only the `http` and `https` schemes are allowed. The address is re-validated immediately
before each request, so editing the configuration file by hand cannot bypass these rules.
The large Danbooru dictionary is a local read-only SQLite file under the extension's data
directory. Despite the `connect(` call, it opens a file on disk and opens no socket.
+3 -1
View File
@@ -1,13 +1,15 @@
from .nodes import BilingualPromptInspector
from .nodes import BilingualPromptInspector, DuplicateChecker
from . import server # noqa: F401 - importing registers local API routes
NODE_CLASS_MAPPINGS = {
"BilingualPromptInspector": BilingualPromptInspector,
"DuplicateChecker": DuplicateChecker,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"BilingualPromptInspector": "Prompt Translator & Manager",
"DuplicateChecker": "重复检查",
}
WEB_DIRECTORY = "./js"
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 726 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

+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

+362 -98
View File
@@ -46,30 +46,80 @@ DEFAULT_OPTIMIZATION_RULE = """你是 Anima 模型英文提示词优化器。输
对于明确为成年人的合法成人内容,准确保留并规范相关成人标签,不弱化、不含糊替换,也不主动增加原文没有的成人内容;不得将未成年人或年龄不明确的人物成人化。
标签使用英文逗号分隔;自然语言只放在末尾并使用英文句点分隔。仅使用 Anima 支持的英文标点和语法:逗号、句点、圆括号与冒号权重、@ 画师前缀、下划线、连字符、撇号、百分号,以及尖括号 LoRA 语法。"""
DEFAULT_NATURAL_RULE = """你是面向现代生图模型(Krea、FLUX、Qwen-Image 等)的英文自然语言提示词助手。把用户输入整理成一段通顺、具体、可直接使用的英文描述;只返回最终结果,不输出 Markdown、标题、引号、解释、警告或免责声明。
输入是中文、英文或中英混合都可以:含中文时先忠实译为英文再整理表达,纯英文时只做表达优化,不改变原意。输入是逗号分隔的标签时,先理解这些标签的含义,再改写成一段连贯的英文自然语言描述。
保留主体、动作与状态、外貌与服装、环境与背景、光线、镜头与构图、风格与画材、氛围与情绪;原文没有给出的要素不要凭空添加,也不要删掉原文已经写明的限制。
严格保留人物数量、成年人身份、角色名、作品名、否定关系、左右位置和安全等级,不得擅自换人、增加人物或改变主题。
对于明确为成年人的合法成人内容,准确保留相关描述,不弱化、不含糊替换,也不主动增加原文没有的成人内容;不得将未成年人或年龄不明确的人物成人化。
用完整的英文句子输出,按语法正常使用逗号、句点和连接词;不要写成逗号分隔的标签列表,也不要使用括号权重、@ 画师前缀或尖括号语法。"""
DEFAULT_CONFIG = {
"schema_version": 3,
# 翻译/解释走哪个服务;优化永远走 AI,与此无关
"translate_service": "dictionary", # dictionary | baidu | ai
# AI 后端独立配置,不随翻译服务切换而清空
"schema_version": 4,
# 翻译/解释走哪个服务;OpenAI 兼容与 Ollama 本身就是两个平级选项
"translate_service": "dictionary", # dictionary | baidu | openai_compatible | ollama
# 选词库/百度时翻译不走 AI,但「翻译并优化」「优化」恒走 AI —— 这时用它决定读哪一套
"ai_provider": "openai_compatible", # openai_compatible | ollama
"ai_base_url": "",
"ai_model": "",
"ai_api_key": "",
"ai_temperature": 0.2,
"ai_timeout_seconds": 90,
# OpenAI 兼容:远端必须 https,本机回环 http 也放行(本地 LM Studio 之类)
"openai_base_url": "",
"openai_model": "",
"openai_api_key": "",
"openai_temperature": 0.2,
"openai_timeout_seconds": 90,
# Ollama:只允许本机回环地址,没有密钥
"ollama_base_url": "http://127.0.0.1:11434",
"ollama_model": "",
"ollama_temperature": 0.2,
"ollama_timeout_seconds": 90,
# 百度翻译独立配置,不随翻译服务切换而清空
"baidu_appid": "",
"baidu_secret_key": "",
# 百度账号的 QPS 上限:未认证/标准版 1,个人认证 10,企业认证更高。
# 后端按它给百度请求排队,避免触发 54003「请求过于频繁」
"baidu_qps": 1.0,
"appearance": "auto", # auto | dark | light
# 插件面板自己的界面语言:auto 跟随 ComfyUI 的 Comfy.Locale,zh / en 强制指定
"language": "auto", # auto | zh | en
"translation_rule": DEFAULT_TRANSLATION_RULE,
"translate_optimize_rule": DEFAULT_TRANSLATE_OPTIMIZE_RULE,
"optimization_rule": DEFAULT_OPTIMIZATION_RULE,
# 每个后端一套规则:本地小模型和云端大模型要写的提示词不一样
"openai_translation_rule": DEFAULT_TRANSLATION_RULE,
"openai_translate_optimize_rule": DEFAULT_TRANSLATE_OPTIMIZE_RULE,
"openai_optimization_rule": DEFAULT_OPTIMIZATION_RULE,
"openai_natural_rule": DEFAULT_NATURAL_RULE,
"ollama_translation_rule": DEFAULT_TRANSLATION_RULE,
"ollama_translate_optimize_rule": DEFAULT_TRANSLATE_OPTIMIZE_RULE,
"ollama_optimization_rule": DEFAULT_OPTIMIZATION_RULE,
"ollama_natural_rule": DEFAULT_NATURAL_RULE,
}
# 规则按后端分成两套,遍历时统一用这两个前缀
AI_PROVIDER_PREFIXES = {"openai_compatible": "openai", "ollama": "ollama"}
# translate_service 里属于 AI 的两个值,与 ai_provider 共用同一套前缀
AI_SERVICES = frozenset(AI_PROVIDER_PREFIXES)
TRANSLATE_SERVICES = frozenset({"dictionary", "baidu"}) | AI_SERVICES
RULE_KEYS = ("translation_rule", "translate_optimize_rule", "optimization_rule", "natural_rule")
BAIDU_TRANSLATE_ENDPOINT = "https://fanyi-api.baidu.com/api/trans/vip/translate"
# 百度「通用文本翻译」按账号档位给 QPS:标准版 1 QPS,个人认证 10 QPS,企业认证更高。
# 后端按这个值给百度请求排队;填高了会被百度报 54003,填低了只是慢一点。
BAIDU_QPS_DEFAULT = 1.0
BAIDU_QPS_MIN = 0.1
BAIDU_QPS_MAX = 50.0
# 百度单块最多 600 字,90 秒与原默认值一致(AI 拆成两套后不再借用它的超时)
BAIDU_TIMEOUT_SECONDS = 90
def normalize_baidu_qps(value):
"""把用户填的 QPS 收敛成合法数字;非数字一律拒绝,不静默回退。"""
if value is None or isinstance(value, bool) or str(value).strip() == "":
return BAIDU_QPS_DEFAULT
try:
qps = float(value)
except (TypeError, ValueError) as error:
raise ValueError("百度 QPS 必须是数字") from error
if qps != qps or qps in (float("inf"), float("-inf")):
raise ValueError("百度 QPS 必须是数字")
return min(max(qps, BAIDU_QPS_MIN), BAIDU_QPS_MAX)
BAIDU_ERROR_MESSAGES = {
"52001": "请求超时,请重试",
"52002": "百度翻译系统错误,请稍后重试",
@@ -88,6 +138,59 @@ def translation_direction(text):
return "to_english" if re.search(r"[\u3400-\u9fff]", str(text or "")) else "to_chinese"
def is_loopback_host(hostname):
"""只允许真正的回环地址:127.0.0.0/8、::1、localhost。
比「私有网段」窄得多 —— 私有网段里还有路由器、NAS、同事的机器,
SSRF 正是靠它们横向探测,所以这里一律不放行。
"""
host = str(hostname or "").strip().lower().rstrip(".")
if host in {"localhost", "127.0.0.1", "::1"}:
return True
try:
return ip_address(host).is_loopback
except ValueError:
return False
def is_public_address(hostname):
"""主机名直接写成 IP 时,只放行公网地址;域名不做解析,一律放行。
挡的是 https://192.168.x.x、https://10.x.x.x、https://169.254.169.254(云元数据)这类
「虽然是 https、但仍然指向内网」的直连写法。
"""
try:
return ip_address(hostname).is_global
except ValueError:
return True
def normalize_ai_base_url(value, *, allow_remote=False):
"""校验 AI 地址,返回去掉尾斜杠的原文;不合规直接抛错,不发请求。
allow_remote=False(Ollama):只能是本机回环地址。
allow_remote=True(OpenAI 兼容):https 一律放行,http 只放行本机回环。
"""
base = str(value or "").strip().rstrip("/")
if not base:
return ""
parsed = urlparse(base)
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
raise ValueError("API 地址必须是完整的 http:// 或 https:// 地址")
if parsed.username or parsed.password:
raise ValueError("API 地址中不能包含用户名或密码")
if parsed.query or parsed.fragment:
raise ValueError("API 地址中不能包含查询参数或片段")
loopback = is_loopback_host(parsed.hostname)
if not allow_remote and not loopback:
raise ValueError("Ollama 地址只能是本机地址(127.0.0.1 / localhost / ::1)")
if parsed.scheme == "http" and not loopback:
raise ValueError("非本机 API 地址必须使用 HTTPS,避免密钥明文传输")
if allow_remote and not loopback and not is_public_address(parsed.hostname):
raise ValueError("远端 API 地址不能是内网、保留或链路本地地址")
return base
def openai_chat_endpoint(base_url):
"""Return a chat-completions endpoint for an OpenAI-compatible base URL."""
base = str(base_url or "").strip().rstrip("/")
@@ -102,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 "")
@@ -135,6 +300,30 @@ def baidu_split_query(text, max_chars=600, max_lines=30):
return chunks
# 权重的句点只出现在「冒号后的小数」里:(tag:1.2)、[tag:1.2]、<lora:name:0.8>。
# 断句用的句点则是「词 + 句点 + 空白」,形态完全不同,所以先把权重片段整段挑出来保护。
WEIGHT_FRAGMENT = re.compile(r"[(\[<][^\n]*?:\s*-?\d+(?:\.\d+)?\s*[)\]>]")
def replace_sentence_periods(text):
"""把权重之外的句点换成逗号。
模型按规则会用句点把末尾自然语言接在标签后面,但那会让整串标签被前端当成自然语言段。
权重片段整段跳过,片段外再要求句点前后都不是数字,双保险。
"""
def to_commas(segment):
return re.sub(r"(?<!\d)\s*\.\s*(?!\d)", ", ", segment)
parts = []
cursor = 0
for match in WEIGHT_FRAGMENT.finditer(text):
parts.append(to_commas(text[cursor:match.start()]))
parts.append(match.group(0))
cursor = match.end()
parts.append(to_commas(text[cursor:]))
return "".join(parts)
def sanitize_anima_prompt(value):
text = str(value or "").strip()
fenced = re.fullmatch(r"```(?:text|plaintext)?\s*([\s\S]*?)\s*```", text, re.IGNORECASE)
@@ -143,17 +332,23 @@ def sanitize_anima_prompt(value):
",": ",", "、": ",", ";": ",", ";": ",", "。": ".",
"!": ".", "!": ".", "?": ".", "?": ".", ":": ":",
"(": "(", ")": ")", "<": "<", ">": ">", "—": "-", "–": "-", "…": ".",
"“": "", "”": "", "‘": "'", "’": "'", '"': "", "`": "",
"“": "", "”": "", """: "", "‘": "'", "’": "'", "'": "'", '"': "", "`": "",
}
for source, target in replacements.items():
text = text.replace(source, target)
# 模型常把结果写成 JSON 或代码字符串,留下 \" \n 这类转义;先把字面换行/制表还原成换行,
# 再去掉其余孤立反斜杠,否则一个转义符就会让整条提示词判废。
text = re.sub(r"\\+[nrt]", "\n", text)
text = text.replace("\\", "")
text = re.sub(r"(?m)^\s*(?:[-*•]+|\d+[.)])\s*", "", text)
text = re.sub(r"[\r\n]+", ", ", text)
text = re.sub(r"\s*,\s*", ", ", text)
text = replace_sentence_periods(text)
text = re.sub(r",(?:\s*,)+", ",", text)
text = re.sub(r"(?<!\d)\s*\.\s*(?!\d)", ". ", text)
text = re.sub(r",\s*\.\s*", ". ", text)
text = re.sub(r"\s+", " ", text).strip(" ,")
# 模型常在结果末尾多留一个句点。规则里的句点是用来分隔末尾自然语言的,后面没有内容时
# 它没有意义,却会让前端把整串标签误判成自然语言段落,所以直接去掉。
text = re.sub(r"\s*[.]+\s*$", "", text)
if re.search(r"[\u3400-\u9fff]", text):
raise ValueError("助手返回的 Anima 提示词仍包含中文,请调整规则后重试")
allowed_punctuation = set(",.():@_-'<>%+/&")
@@ -165,6 +360,35 @@ def sanitize_anima_prompt(value):
return text
def sanitize_natural_prompt(value):
"""自然语言优化结果的清洗。
与 sanitize_anima_prompt 的区别:这里要保留完整句子,所以既不把句点改成逗号,
也不做 Anima 标点白名单收窄——只挡代码块、转义符、列表符号和残留中文。
"""
text = str(value or "").strip()
fenced = re.fullmatch(r"```(?:text|plaintext)?\s*([\s\S]*?)\s*```", text, re.IGNORECASE)
text = fenced.group(1).strip() if fenced else text
replacements = {
",": ", ", "、": ", ", ";": "; ", "。": ". ",
"!": "! ", "?": "? ", ":": ": ",
"(": "(", ")": ")", "<": "<", ">": ">", "—": " - ", "–": " - ",
"“": '"', "”": '"', """: '"', "‘": "'", "’": "'", "'": "'", "`": "",
}
for source, target in replacements.items():
text = text.replace(source, target)
text = re.sub(r"\\+[nrt]", "\n", text)
text = text.replace("\\", "")
text = re.sub(r"(?m)^\s*(?:[-*•]+|\d+[.)])\s*", "", text)
text = re.sub(r"[\r\n]+", " ", text)
text = re.sub(r"\s+", " ", text).strip()
if re.search(r"[\u3400-\u9fff]", text):
raise ValueError("助手返回的自然语言提示词仍包含中文,请调整规则后重试")
if not text:
raise ValueError("助手返回的自然语言提示词为空")
return text
def _default_config_dir():
try:
import folder_paths
@@ -231,17 +455,6 @@ class AssistantStore:
def _normalize_base_url(value):
return str(value or "").strip().rstrip("/")
@staticmethod
def _is_local_hostname(hostname):
host = str(hostname or "").strip().lower().rstrip(".")
if host in {"localhost", "host.docker.internal"} or host.endswith(".local") or "." not in host:
return True
try:
address = ip_address(host)
return address.is_private or address.is_loopback or address.is_link_local
except ValueError:
return False
@classmethod
def _credential_binding(cls, provider, base_url):
identity = f"{str(provider or '').strip().lower()}\0{cls._normalize_base_url(base_url)}"
@@ -251,7 +464,7 @@ class AssistantStore:
return {
**DEFAULT_CONFIG,
"installation_id": self.installation_id,
"ai_credential_binding": "",
"openai_credential_binding": "",
"baidu_credential_binding": "",
}
@@ -282,6 +495,40 @@ class AssistantStore:
value["schema_version"] = 3
return value
@classmethod
def _migrate_v3(cls, value):
"""v3(AI 单槽)→ v4(OpenAI / Ollama 两套独立配置)。
旧的单槽值整份搬到「当前所选后端」那套,另一套留默认;
规则原本不分后端,两套都继承,之后可以各自改。
"""
if value.get("schema_version", 0) >= 4:
return value
provider = str(value.get("ai_provider", "openai_compatible")).strip().lower()
prefix = AI_PROVIDER_PREFIXES.get(provider, "openai")
for source, target in (
("ai_base_url", "base_url"),
("ai_model", "model"),
("ai_temperature", "temperature"),
("ai_timeout_seconds", "timeout_seconds"),
):
if str(value.get(source) or "").strip() != "":
value[f"{prefix}_{target}"] = value[source]
if prefix == "openai" and str(value.get("ai_api_key") or "").strip():
value["openai_api_key"] = value["ai_api_key"]
# 绑定的 provider 标记从 openai_compatible 变成 openai,旧值必然对不上;
# 直接按新前缀重算,否则密钥会被当成「换了地址」清掉
value["openai_credential_binding"] = cls._credential_binding("openai", value.get("openai_base_url", ""))
for rule in RULE_KEYS:
existing = str(value.get(rule) or "").strip()
if existing:
for target in ("openai", "ollama"):
value.setdefault(f"{target}_{rule}", existing)
for key in ("ai_base_url", "ai_model", "ai_temperature", "ai_timeout_seconds", "ai_api_key", "ai_credential_binding"):
value.pop(key, None)
value["schema_version"] = 4
return value
def config(self):
value = {}
should_rewrite = False
@@ -298,40 +545,56 @@ class AssistantStore:
if value and value.get("schema_version", 0) < 3:
value = self._migrate_v2(value)
should_rewrite = True
if value.get("translation_rule") in {LEGACY_DEFAULT_TRANSLATION_RULE, V17_DEFAULT_TRANSLATION_RULE}:
value["translation_rule"] = DEFAULT_TRANSLATION_RULE
if value and value.get("schema_version", 0) < 4:
value = self._migrate_v3(value)
should_rewrite = True
if value.get("optimization_rule") == LEGACY_DEFAULT_OPTIMIZATION_RULE:
value["optimization_rule"] = DEFAULT_OPTIMIZATION_RULE
should_rewrite = True
if "translate_optimize_rule" not in value:
value["translate_optimize_rule"] = DEFAULT_TRANSLATE_OPTIMIZE_RULE
# 旧值 "ai" 已拆成两个平级服务:按当时的后端落到 openai_compatible / ollama
if value and value.get("translate_service") == "ai":
provider = str(value.get("ai_provider") or "openai_compatible").strip().lower()
value["translate_service"] = provider if provider in AI_SERVICES else "openai_compatible"
should_rewrite = True
# 旧版默认规则已废弃:两套各查一次,命中就换成现在的默认
for prefix in ("openai", "ollama"):
if value.get(f"{prefix}_translation_rule") in {LEGACY_DEFAULT_TRANSLATION_RULE, V17_DEFAULT_TRANSLATION_RULE}:
value[f"{prefix}_translation_rule"] = DEFAULT_TRANSLATION_RULE
should_rewrite = True
if value.get(f"{prefix}_optimization_rule") == LEGACY_DEFAULT_OPTIMIZATION_RULE:
value[f"{prefix}_optimization_rule"] = DEFAULT_OPTIMIZATION_RULE
should_rewrite = True
result = {**self._default_persisted_config(), **value, "installation_id": self.installation_id}
# 密钥只在「绑定的后端/地址变了」或「换机器」时清,切翻译服务绝不清
if result.get("ai_api_key") and result.get("ai_credential_binding") != self._credential_binding(result["ai_provider"], result["ai_base_url"]):
result["ai_api_key"] = ""
result["ai_credential_binding"] = ""
# 密钥只在「地址变了」或「换机器」时清,切翻译服务 / 切后端绝不清
if result.get("openai_api_key") and result.get("openai_credential_binding") != self._credential_binding("openai", result["openai_base_url"]):
result["openai_api_key"] = ""
result["openai_credential_binding"] = ""
should_rewrite = True
if result.get("baidu_secret_key") and result.get("baidu_credential_binding") != self._credential_binding("baidu", ""):
result["baidu_secret_key"] = ""
result["baidu_credential_binding"] = ""
should_rewrite = True
# 手改配置文件可能写坏 QPS;这里兜底回默认值,不让整个助手挂掉
try:
result["baidu_qps"] = normalize_baidu_qps(result.get("baidu_qps"))
except ValueError:
result["baidu_qps"] = BAIDU_QPS_DEFAULT
should_rewrite = True
if should_rewrite:
self._write_json_atomic(self.config_path, result)
return result
def public_config(self):
config = self.config()
defaults = {}
for prefix in ("openai", "ollama"):
defaults[f"default_{prefix}_translation_rule"] = DEFAULT_TRANSLATION_RULE
defaults[f"default_{prefix}_translate_optimize_rule"] = DEFAULT_TRANSLATE_OPTIMIZE_RULE
defaults[f"default_{prefix}_optimization_rule"] = DEFAULT_OPTIMIZATION_RULE
defaults[f"default_{prefix}_natural_rule"] = DEFAULT_NATURAL_RULE
return {
key: config[key] for key in DEFAULT_CONFIG if key not in ("ai_api_key", "baidu_secret_key")
key: config[key] for key in DEFAULT_CONFIG if key not in ("openai_api_key", "baidu_secret_key")
} | {
"ai_api_key_configured": bool(config.get("ai_api_key")),
"openai_api_key_configured": bool(config.get("openai_api_key")),
"baidu_secret_key_configured": bool(config.get("baidu_secret_key")),
"default_translation_rule": DEFAULT_TRANSLATION_RULE,
"default_translate_optimize_rule": DEFAULT_TRANSLATE_OPTIMIZE_RULE,
"default_optimization_rule": DEFAULT_OPTIMIZATION_RULE,
}
} | defaults
@staticmethod
def _clean_rule(value, fallback):
@@ -347,45 +610,46 @@ class AssistantStore:
raise ValueError("设置必须是对象")
current = self.config()
translate_service = str(payload.get("translate_service", current["translate_service"])).strip().lower()
if translate_service not in {"dictionary", "baidu", "ai"}:
raise ValueError("翻译服务仅支持词库、百度翻译或 AI")
if translate_service not in TRANSLATE_SERVICES:
raise ValueError("翻译服务仅支持词库、百度翻译、OpenAI 兼容接口或 Ollama")
ai_provider = str(payload.get("ai_provider", current["ai_provider"])).strip().lower()
if ai_provider not in {"openai_compatible", "ollama"}:
raise ValueError("AI 后端仅支持 OpenAI 兼容接口或 Ollama")
ai_base_url = self._normalize_base_url(payload.get("ai_base_url", current["ai_base_url"]))
if ai_base_url:
parsed = urlparse(ai_base_url)
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
raise ValueError("API 地址必须是完整的 http:// 或 https:// 地址")
if parsed.username or parsed.password:
raise ValueError("API 地址中不能包含用户名或密码")
if parsed.query or parsed.fragment:
raise ValueError("API 地址中不能包含查询参数或片段")
if parsed.scheme == "http" and not self._is_local_hostname(parsed.hostname):
raise ValueError("非本机 API 地址必须使用 HTTPS,避免 API Key 明文传输")
ai_model = str(payload.get("ai_model", current["ai_model"])).strip()
if len(ai_model) > 300:
raise ValueError("模型名称过长")
try:
ai_temperature = float(payload.get("ai_temperature", current["ai_temperature"]))
ai_timeout_seconds = int(payload.get("ai_timeout_seconds", current["ai_timeout_seconds"]))
except (TypeError, ValueError) as error:
raise ValueError("温度和超时时间必须是数字") from error
if not 0 <= ai_temperature <= 2:
raise ValueError("温度必须在 0 到 2 之间")
if not 5 <= ai_timeout_seconds <= 600:
raise ValueError("超时时间必须在 5 到 600 秒之间")
# AI key:只在「AI 后端/地址变了」或显式清除时清,切翻译服务绝不清
ai_endpoint_changed = ai_provider != current["ai_provider"] or ai_base_url != self._normalize_base_url(current["ai_base_url"])
ai_api_key = "" if ai_endpoint_changed else current.get("ai_api_key", "")
if payload.get("clear_ai_api_key"):
ai_api_key = ""
elif "ai_api_key" in payload:
incoming_key = str(payload.get("ai_api_key") or "").strip()
# 选了 OpenAI / Ollama 当翻译服务时,它就是 AI 后端,两者不允许脱节
if translate_service in AI_SERVICES:
ai_provider = translate_service
# 两个后端各一套:地址规则不同(Ollama 只认本机,OpenAI 远端必须 https)
openai_base_url = normalize_ai_base_url(payload.get("openai_base_url", current["openai_base_url"]), allow_remote=True)
ollama_base_url = normalize_ai_base_url(payload.get("ollama_base_url", current["ollama_base_url"]))
if not ollama_base_url:
ollama_base_url = DEFAULT_CONFIG["ollama_base_url"]
models = {}
numbers = {}
for prefix in ("openai", "ollama"):
models[prefix] = str(payload.get(f"{prefix}_model", current[f"{prefix}_model"])).strip()
if len(models[prefix]) > 300:
raise ValueError("模型名称过长")
try:
numbers[prefix] = (
float(payload.get(f"{prefix}_temperature", current[f"{prefix}_temperature"])),
int(payload.get(f"{prefix}_timeout_seconds", current[f"{prefix}_timeout_seconds"])),
)
except (TypeError, ValueError) as error:
raise ValueError("温度和超时时间必须是数字") from error
if not 0 <= numbers[prefix][0] <= 2:
raise ValueError("温度必须在 0 到 2 之间")
if not 5 <= numbers[prefix][1] <= 600:
raise ValueError("超时时间必须在 5 到 600 秒之间")
# OpenAI key:只在「地址变了」或显式清除时清,切翻译服务 / 切后端绝不清
openai_api_key = "" if openai_base_url != self._normalize_base_url(current["openai_base_url"]) else current.get("openai_api_key", "")
if payload.get("clear_openai_api_key"):
openai_api_key = ""
elif "openai_api_key" in payload:
incoming_key = str(payload.get("openai_api_key") or "").strip()
if incoming_key and incoming_key != "••••••••":
if len(incoming_key) > 4096:
raise ValueError("API Key 过长")
ai_api_key = incoming_key
openai_api_key = incoming_key
baidu_appid = str(payload.get("baidu_appid", current.get("baidu_appid", ""))).strip()
if len(baidu_appid) > 128:
raise ValueError("百度 APP ID 过长")
@@ -399,7 +663,8 @@ class AssistantStore:
if len(incoming_secret) > 4096:
raise ValueError("百度密钥过长")
baidu_secret = incoming_secret
ai_credential_binding = self._credential_binding(ai_provider, ai_base_url) if ai_api_key else ""
baidu_qps = normalize_baidu_qps(payload.get("baidu_qps", current.get("baidu_qps", BAIDU_QPS_DEFAULT)))
openai_credential_binding = self._credential_binding("openai", openai_base_url) if openai_api_key else ""
baidu_credential_binding = self._credential_binding("baidu", "") if baidu_secret else ""
appearance = str(payload.get("appearance", current.get("appearance", "auto"))).strip().lower()
if appearance not in {"auto", "dark", "light"}:
@@ -408,36 +673,34 @@ class AssistantStore:
if language not in {"auto", "zh", "en"}:
raise ValueError("界面语言仅支持 auto / zh / en")
result = {
"schema_version": 3,
"schema_version": 4,
"translate_service": translate_service,
"ai_provider": ai_provider,
"ai_base_url": ai_base_url,
"ai_model": ai_model,
"ai_api_key": ai_api_key,
"ai_temperature": ai_temperature,
"ai_timeout_seconds": ai_timeout_seconds,
"openai_base_url": openai_base_url,
"openai_model": models["openai"],
"openai_api_key": openai_api_key,
"openai_temperature": numbers["openai"][0],
"openai_timeout_seconds": numbers["openai"][1],
"ollama_base_url": ollama_base_url,
"ollama_model": models["ollama"],
"ollama_temperature": numbers["ollama"][0],
"ollama_timeout_seconds": numbers["ollama"][1],
"baidu_appid": baidu_appid,
"baidu_secret_key": baidu_secret,
"baidu_qps": baidu_qps,
"appearance": appearance,
"language": language,
"translation_rule": self._clean_rule(payload.get("translation_rule"), current["translation_rule"]),
"translate_optimize_rule": self._clean_rule(payload.get("translate_optimize_rule"), current["translate_optimize_rule"]),
"optimization_rule": self._clean_rule(payload.get("optimization_rule"), current["optimization_rule"]),
"installation_id": self.installation_id,
"ai_credential_binding": ai_credential_binding,
"baidu_credential_binding": baidu_credential_binding,
"openai_credential_binding": openai_credential_binding,
"installation_id": self.installation_id,
}
# 规则两套,各取各自的当前值兜底
for prefix in ("openai", "ollama"):
for rule in RULE_KEYS:
result[f"{prefix}_{rule}"] = self._clean_rule(payload.get(f"{prefix}_{rule}"), current[f"{prefix}_{rule}"])
self._write_json_atomic(self.config_path, result)
return self.public_config()
def reset_rules(self):
config = self.config()
config["translation_rule"] = DEFAULT_TRANSLATION_RULE
config["translate_optimize_rule"] = DEFAULT_TRANSLATE_OPTIMIZE_RULE
config["optimization_rule"] = DEFAULT_OPTIMIZATION_RULE
self._write_json_atomic(self.config_path, config)
return self.public_config()
def dictionary_translate(text, dictionary_store):
parts = [part.strip() for part in re.split(r"[,,、\r\n]+", str(text or "")) if part.strip()]
@@ -458,7 +721,8 @@ def dictionary_translate(text, dictionary_store):
large = dictionary_store.search_large_tags(lookup_part, limit=30)
exact = [tag for tag in large if lookup_part == tag.get("chinese")]
if exact:
exact.sort(key=lambda tag: (tag.get("pack_id") == "personal", int(tag.get("post_count", 0))), reverse=True)
# 个人词库与自然语言词库都算本机自建,优先于内置词库(与 dictionary_store.USER_PACK_IDS 一致)
exact.sort(key=lambda tag: (tag.get("pack_id") in ("personal", "natural"), int(tag.get("post_count", 0))), reverse=True)
english = exact[0]["english"]
output.append(f"({english}:{weighted.group(2)})" if weighted else english)
else:
+39
View File
@@ -0,0 +1,39 @@
{
"presets": [
"#6b9b78",
"#9b8b6b",
"#6b7b9b",
"#9b6b8b",
"#8b9b6b",
"#6b9b9b",
"#9b7b6b",
"#7b6b9b"
],
"random_pool": [
"#5b8a72",
"#8a725b",
"#5b6e8a",
"#8a5b7a",
"#7a8a5b",
"#5b8a8a",
"#8a6b5b",
"#6b5b8a",
"#7a5b6b",
"#5b7a8a",
"#8a8a5b",
"#5b8a5b"
],
"pack_colors": {
"base": "#8a6b5b",
"anima": "#7a8a5b",
"characters": "#5b8a72",
"poses": "#8a5b7a",
"camera": "#8a725b",
"clothing": "#5b8a8a",
"adult": "#5b8a5b",
"personal": "#8a8a5b",
"natural": "#5b8a72",
"unindexed": "#5b6e8a"
},
"anima_colors": {}
}
+14 -1
View File
@@ -12,6 +12,11 @@ from pathlib import Path
SCHEMA_VERSION = 1
PACK_SCHEMA_VERSION = 1
# 个人词库与自然语言词库共用 user_tags.json,靠 natural 标记分成两个 pack,
# 这样按词库筛选、配色都能把它们分开处理。
PERSONAL_PACK_ID = "personal"
NATURAL_PACK_ID = "natural"
USER_PACK_IDS = (PERSONAL_PACK_ID, NATURAL_PACK_ID)
MAX_TEXT_LENGTH = 500
MAX_ALIASES = 30
MAX_LARGE_LOOKUP_TERMS = 200
@@ -101,6 +106,8 @@ def validate_tag(value, *, default_source="user"):
"notes": notes,
"source": source,
"verified": bool(value.get("verified", default_source == "user")),
# 自然语言词库:与个人词库共用一个文件,靠这个标记区分来源
"natural": bool(value.get("natural", False)),
}
@@ -246,7 +253,13 @@ class DictionaryStore:
user = self.user_tags()
effective = {normalize_key(tag["english"]): tag for tag in builtin}
for tag in user:
clean = {**tag, "pack_id": "personal", "pack_name": "个人词库", "pack_source": "本机用户"}
natural = bool(tag.get("natural"))
clean = {
**tag,
"pack_id": NATURAL_PACK_ID if natural else PERSONAL_PACK_ID,
"pack_name": "自然语言词库" if natural else "个人词库",
"pack_source": "本机用户",
}
effective[normalize_key(tag["english"])] = clean
return {
"schema_version": SCHEMA_VERSION,
+14 -8
View File
@@ -33,11 +33,13 @@
- `server.py`
- 注册助手、个人词库、词库包和大型词库的本地接口。
- 执行同源检查、临时会话令牌校验、请求体限制、助手频率限制和并发限制。
- 只在用户主动调用时访问配置的百度翻译或 AI(OpenAI 兼容 / Ollama)服务;"翻译并优化"与"优化为 Anima"恒走 AI,翻译/解释按 `translate_service` 选择。
- 只在用户主动调用时访问配置的百度翻译或 AI(OpenAI 兼容 / Ollama)服务;"翻译并优化"与"优化"恒走 AI,翻译/解释按 `translate_service` 选择。
- `UpstreamGate` 负责上游输入的挂起:按节点 id 保存一个 future,只能被 `/bpi/upstream/{ack,resume,cancel}` 唤醒。等待循环每 0.5 秒检查一次 ComfyUI 中断,无人接管时到点自动透传。唤醒一律走 `loop.call_soon_threadsafe`——future 属于执行线程的 loop,而路由跑在主线程。
- `assistant_store.py`
- 保存翻译服务类型(schema v3:`translate_service` = dictionary / baidu / ai)、AI 后端与地址模型、百度 APP ID 与密钥、三套规则和独立留存的 AI Key 与百度密钥。
- AI Key 与百度密钥互不干扰:切换翻译服务不清任一密钥;AI Key 只在 AI 后端/地址变更或换机器时清,百度密钥只在显式清除时清。
- 保存翻译服务类型(schema v4:`translate_service` = dictionary / baidu / openai_compatible / ollama;后两个同时是 AI 后端,选前两个时由 `ai_provider` 决定优化走哪一套)、两套 AI 配置(OpenAI 兼容 / Ollama 各一套地址、模型、温度、超时与四条规则)、百度 APP ID 与密钥,以及独立留存的 AI Key 与百度密钥。
- 四条规则是翻译 / 翻译并优化 / 优化 / 自然语言。前三条产出 Anima 标签流,走 `sanitize_anima_prompt`(句点收成逗号、标点只放行白名单);自然语言那条产出完整英文句子,走 `sanitize_natural_prompt`,两者不能混用。
- 读到旧值 `translate_service = "ai"` 时按当时的 `ai_provider` 拆成 `openai_compatible` / `ollama` 并回写。
- AI Key 与百度密钥互不干扰:切换翻译服务或 AI 后端不清任一密钥;AI Key 只在地址变更或换机器时清,百度密钥只在显式清除时清。
- 旧 v2 配置(单一 `provider`)自动迁移至 v3,密钥与绑定原样搬入 `ai_*` 槽。
- API Key 不通过读取接口返回前端;公开配置只返回 `ai_api_key_configured` 与 `baidu_secret_key_configured` 布尔值。
- 负责翻译方向判断、纯词库翻译和 Anima 输出标点清理。
@@ -58,17 +60,20 @@
- `openSavePromptDialog` 用 multipart 一次提交名称、备注与图片文件,正文只读展示,保存成功后回调刷新侧边栏。
- `openManagerPanel` 通过 ComfyUI 侧边栏 API 打开管理面板,可指定定位分页与目标节点。
- `js/manager_panel.js`
- 在 ComfyUI 侧边栏注册「双语提示词管理」面板,包含标签管理、词库、词库包、助手设置、收藏五个分页。
- 承担原节点内管理弹窗的全部职责:词条增删改、批量操作、个人词库导入导出、社区包与大型词库管理、助手服务与 API Key 配置。
- 在 ComfyUI 侧边栏注册「双语提示词管理」面板,包含标签管理、词库、词库包、收藏四个分页。
- 承担原节点内管理弹窗的全部职责:词条增删改、批量操作、个人词库导入导出、社区包与大型词库管理。
- 助手设置不在侧边栏:同一文件的 `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` 通知当前页面所有节点刷新。
- “收藏”分页按需挂载并监听 `bpi:saved-prompts-changed`:卡片支持搜索、复制、删除、导出导入;「新建」没有选中节点时弹出节点选择器;「载入节点」先确认再调目标节点的 `_bilingualPromptInspector.setText`,覆盖不进 undo 栈。
- `js/bilingual_prompt.js`
- 创建节点内可视化界面:英文文本/标签双模式、绿色中文镜像、联动选择、权重、删除、撤销重做与跨节点刷新。
- 标签排序是结构化编辑:英文标签区支持指针拖拽(4px 阈值区分点击与拖动,拖动后吞掉 click/dblclick),明细表行首提供 `⠿` 手柄(带插入指示线与边缘自动滚动),`Alt+↑/↓` 键盘微调;三个入口都汇入同一个 `moveTokenTo` → `movePromptToken` → undo 栈管线。
- 标签隐藏(临时排除出实际输出)同样是结构化编辑:明细表行内眼睛图标、英文标签 hover 浮出的眼睛角标负责隐藏,明细表下方与英文标签区下方的“已隐藏”条负责逐个/全部恢复;清单存在 `node.properties.bpiHiddenTags`(随工作流保存),每次编辑把清单快照写进 undo 条目,撤销时文本与清单一起回滚,避免出现“幽灵恢复项”。
- 标签隐藏(临时排除出实际输出)只是“打标记”:标签留在文本里,卡片与明细行加删除线并灰化褪色,拖动/双击改权重/改色照常;剔除发生在输出时——`syncEffectiveOutput` 把去掉隐藏标签的文本同步进隐藏 widget `effective_text`(并置 `use_effective`),`nodes.py` 按它输出,旧工作流没有这两个 widget 时默认直通。明细表行内眼睛图标、英文标签 hover 浮出的眼睛角标在隐藏后变为眼睛斜线(取消隐藏);明细表下方与英文标签区下方的“已隐藏”条负责逐个/全部恢复;清单存在 `node.properties.bpiHiddenTags`(随工作流保存),每次编辑把清单快照写进 undo 条目,撤销时文本与清单一起回滚。旧版工作流里当时被物理删除的隐藏标签,恢复时仍按旧锚点插回文本。
- 标签翻译与词库搜索(标签管理)不再渲染进节点,其 DOM 由侧边栏“标签管理”分页挂载,节点高度恒为折叠尺寸。
- 管理功能收敛为「标签管理」「助手设置」等入口按钮,打开侧边栏面板对应分页并定位到本节点。
- 管理功能收敛为「标签管理」等入口按钮,打开侧边栏面板对应分页并定位到本节点。
- 上游输入条(`.bpi-source-bar`)只在 `prompt` 端口真的连上时才渲染,未连线时零高度、界面与旧版一致。条内含接管开关、导入策略下拉和状态文案,等待确认时额外出现「继续运行」「放弃」。开关与策略存在 `node.properties.bpiUpstream`(随工作流保存);放行时把节点当前文本 POST 回后端,因此输出始终等于放行那一刻节点上的内容。
- `js/parser.js`
- 非破坏性切分标签,保留原始字符位置。
@@ -113,7 +118,8 @@ ComfyUI 原生 text 控件(工作流真值)
```text
绿色草稿框
→ 用户点击仅翻译 / 翻译并优化 / 优化为 Anima
→ 用户点击仅翻译 / 翻译并优化 / 优化
→ 自然语言模式下「优化」改用 optimize_natural,走自然语言规则
→ /bpi/assistant/run
→ 纯词库、百度翻译或 AI(OpenAI 兼容 / Ollama)
→ 结果写回绿色暂存区
+24 -2
View File
@@ -16,9 +16,9 @@
## 3. 翻译、优化和同步必须分阶段
决定:设置“仅翻译”“翻译并优化”“优化为 Anima”“同步到英文输出”四个独立动作。
决定:设置“仅翻译”“翻译并优化”“优化”“同步到英文输出”四个独立动作。
原因:翻译与提示词创作不是同一任务。用户需要看见模型做了什么,并决定是否覆盖实际输出。每个动作使用独立规则,避免一个通用提示词同时承担忠实翻译、扩写和格式整理而行为混乱。
原因:翻译与提示词创作不是同一任务。用户需要看见模型做了什么,并决定是否覆盖实际输出。每个动作使用独立规则,避免一个通用提示词同时承担忠实翻译、扩写和格式整理而行为混乱。“优化”会按当前输入模式分派不同规则,见第 19 条。
## 4. Anima 排序只在按下按钮后执行
@@ -116,3 +116,25 @@
- GitHub 首个公开正式 Release 使用 `v1.0.0`。
- 后续发布应使用语义化版本号,不要把内部演进代次和公开版本号混为一谈。
- 兼容修复递增补丁号;向后兼容的新功能递增次版本;破坏工作流或数据格式时才递增主版本,并提供迁移说明。
## 19. “优化”按输入模式分派,而不是再加一个按钮
决定:节点顶部只保留一个“优化”按钮。标签模式下走 Anima 优化规则(只接受英文),自然语言模式下走独立的自然语言规则(中文先译后整理);模式切到“自然语言”时“按 Anima 顺序排序”隐藏。
原因:Krea、FLUX 一类新模型直接吃自然语言,再加一个按钮只会让顶部操作越排越长。模式下拉已经能判定当前是标签流还是整段句子,用它分派比让用户记两个按钮省事;排序只重排标签流,对整段句子没有意义,留着反而误导。
两套规则必须分开:`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 之类照常列出,免得误伤名字奇怪但确实可用的模型。
+173
View File
@@ -0,0 +1,173 @@
"""重复检查:切分、归一、去重都在这里,节点执行与前端实时提示共用同一份口径。
规则与前端 js/parser.js 保持一致(splitPrompt / analyzeSegment / normalizeKey),
改任一侧都要同步另一侧,否则节点上显示的重复数会和实际去掉的数量对不上。
"""
import re
# 段与段之间的间隙里只取第一个分隔符本身:段后的空白归下一段自己带(raw 里有前导空白),
# 两边都管就会在删掉中间段时丢掉空格(「standing, sitting」变成「standing,sitting」)。
_GAP_SEPARATOR = re.compile(r"^[ \t]*([,\r\n])")
# 间隙里只有分隔符和空白(比如两个标签之间隔了一个空行)时原样保留,
# 否则说明中间夹着被删掉的段,那点空白是被删段的前导,不能再计入。
_GAP_BLANK = re.compile(r"^[ \t]*[,\r\n][ \t\r\n]*$")
_SPECIAL_SEGMENT = re.compile(r"^<[^>]+>$")
_WEIGHTED = re.compile(r"^\((.+):\s*(-?(?:\d+(?:\.\d*)?|\.\d+))\)$", re.DOTALL)
_WHITESPACE = re.compile(r"\s+")
def strip_emphasis(value):
current = str(value or "").strip()
while len(current) >= 2 and _is_pair(current[0], current[-1]):
current = current[1:-1].strip()
return current
def _is_pair(open_char, close_char):
return (
(open_char == "(" and close_char == ")")
or (open_char == "[" and close_char == "]")
or (open_char == "{" and close_char == "}")
)
def normalize_key(value):
return _WHITESPACE.sub(" ", str(value or "").strip().lower().replace("_", " "))
def split_prompt(text):
"""按顶层逗号与换行切分;括号里、尖括号里、转义字符后面的分隔符都不算切分点。"""
value = str(text or "")
segments = []
start = 0
round_depth = 0
square_depth = 0
curly_depth = 0
angle_depth = 0
escaped = False
for index, char in enumerate(value):
if escaped:
escaped = False
continue
if char == "\\":
escaped = True
continue
if char == "<":
angle_depth += 1
elif char == ">" and angle_depth > 0:
angle_depth -= 1
elif angle_depth == 0:
if char == "(":
round_depth += 1
elif char == ")" and round_depth > 0:
round_depth -= 1
elif char == "[":
square_depth += 1
elif char == "]" and square_depth > 0:
square_depth -= 1
elif char == "{":
curly_depth += 1
elif char == "}" and curly_depth > 0:
curly_depth -= 1
if (
round_depth == 0
and square_depth == 0
and curly_depth == 0
and angle_depth == 0
and char in (",", "\n", "\r")
):
raw = value[start:index]
if raw.strip():
segments.append({"raw": raw, "start": start, "end": index})
start = index + 1
tail = value[start:]
if tail.strip():
segments.append({"raw": tail, "start": start, "end": len(value)})
return segments
def analyze_segment(raw):
"""剥掉权重与强调括号,得到用来判断重复的词条;纯特殊语法(<lora:...>)原样保留。"""
display = str(raw or "").strip()
if not display:
return None
if _SPECIAL_SEGMENT.match(display):
return {"term": display, "syntax": "special"}
term = display
weighted = _WEIGHTED.match(display)
if weighted:
term = weighted.group(1).strip()
term = strip_emphasis(term)
return {
"term": term,
"syntax": "operator" if term.upper() in ("BREAK", "AND") else "tag",
}
def find_duplicates(text):
"""返回重复报告:多余出现几处、重复词条、以及重复段的下标(保留第一次出现)。"""
value = str(text or "")
segments = split_prompt(value)
seen = {}
order = []
drop = []
for index, segment in enumerate(segments):
info = analyze_segment(segment["raw"])
if not info or info["syntax"] != "tag":
continue
key = normalize_key(info["term"])
if not key:
continue
if key in seen:
seen[key]["count"] += 1
drop.append(index)
else:
seen[key] = {"term": info["term"], "count": 1}
order.append(key)
return {
"extra": len(drop),
"terms": [seen[key] for key in order if seen[key]["count"] > 1],
"drop": drop,
"segments": segments,
}
def remove_segments(text, segments, drop):
"""删掉指定段并把剩下的段按原文的连接符接回去,不重写整段提示词的格式。"""
value = str(text or "")
doomed = set(drop or ())
keep = [index for index in range(len(segments)) if index not in doomed]
if not keep:
return ""
parts = []
previous_end = None
for index in keep:
segment = segments[index]
if previous_end is None:
parts.append(segment["raw"].strip())
else:
gap = value[previous_end:segment["start"]]
if _GAP_BLANK.match(gap):
# 只去掉首尾的空格,换行要留着(段落之间的空行是有意义的)
parts.append(gap.strip(" \t") or ",")
else:
matched = _GAP_SEPARATOR.match(gap)
parts.append(matched.group(1) if matched else ",")
parts.append(segment["raw"])
previous_end = segment["end"]
return "".join(parts)
def dedupe_prompt(text):
"""只保留第一次出现的标签,返回去重后的文本。"""
value = str(text or "")
report = find_duplicates(value)
if not report["drop"]:
return value
return remove_segments(value, report["segments"], report["drop"])
def format_duplicates(report):
"""重复项预览:一行一个「词条 ×次数」,没有重复时给空串。"""
return "\n".join(f"{item['term']} ×{item['count']}" for item in report["terms"])
+2382 -598
View File
File diff suppressed because it is too large Load Diff
+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);
}
+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);
}
}
+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}
.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;
}
+999 -67
View File
File diff suppressed because it is too large Load Diff
+260
View File
@@ -0,0 +1,260 @@
// 右下角贴边堆叠 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",
};
// 图标是塞进 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"/>',
};
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;
}
+190
View File
@@ -0,0 +1,190 @@
import { app } from "../../scripts/app.js";
import { checkDuplicateTags, t } from "./bpi_shared.js";
const NODE_NAME = "DuplicateChecker";
const STYLE_ID = "bpi-duplicate-check-style";
const PILL_HEIGHT = 26;
const REFRESH_DELAY = 250;
// 半透明橙:叠在画布上,亮色深色主题都能用,也不用去猜当前主题
const AMBER_TINT = "rgba(214,158,46,0.14)";
function injectStyles() {
if (document.getElementById(STYLE_ID)) return;
const style = document.createElement("style");
style.id = STYLE_ID;
style.textContent = [
// holder 才是交给画布的 widget 元素:节点拖大时它被拉伸,胶囊自己锁死高度不跟着变
`.bpi-dup-holder{display:flex;align-items:center;justify-content:flex-start;width:100%;min-height:${PILL_HEIGHT}px;overflow:hidden}`,
`.bpi-dup-pill{display:inline-flex;align-items:center;gap:6px;padding:3px 10px;border-radius:999px;font-size:12px;line-height:1.5;height:${PILL_HEIGHT}px;box-sizing:border-box;flex:none;max-width:100%;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}`,
".bpi-dup-pill .bpi-dup-dot{width:7px;height:7px;border-radius:50%;flex:none}",
".bpi-dup-pill .bpi-dup-text{overflow:hidden;text-overflow:ellipsis}",
".bpi-dup-pill.bpi-dup-warn{background:rgba(214,158,46,.18);color:#8a5a00}",
".bpi-dup-pill.bpi-dup-warn .bpi-dup-dot{background:#8a5a00}",
".bpi-dup-pill.bpi-dup-idle{background:rgba(128,128,128,.14);opacity:.75}",
".dark-theme .bpi-dup-pill.bpi-dup-warn{color:#e8b85c}",
".dark-theme .bpi-dup-pill.bpi-dup-warn .bpi-dup-dot{background:#e8b85c}",
].join("");
document.head.appendChild(style);
}
function upstreamWidget(node) {
const linkId = node?.inputs?.[0]?.link;
if (linkId == null) return null;
const graph = app.graph;
const links = graph?.links;
const info = Array.isArray(links) ? links[linkId] : links?.[linkId];
if (!info) return null;
const origin = (graph?._nodes ?? []).find((item) => String(item.id) === String(info.origin_id));
// 上游节点的文本 widget 是画布上唯一能读到的值;读不到就只靠执行结果更新。
// 缓存 widget 本身,这样每帧只要读一次 value,不用再遍历整张图。
return (
(origin?.widgets ?? []).find((item) => item.name === "text" && typeof item.value === "string") ??
null
);
}
function setPill(node, text, tone, terms = []) {
const state = node._duplicateCheck;
if (!state) return;
state.pill.className = `bpi-dup-pill ${tone === "warn" ? "bpi-dup-warn" : "bpi-dup-idle"}`;
state.textNode.textContent = text;
state.dot.style.display = tone === "warn" ? "" : "none";
state.pill.title = terms.length ? terms.join("、") : "";
}
function applyResult(node, count, terms = []) {
const state = node._duplicateCheck;
if (!state) return;
state.count = count;
if (!state.connected) {
setPill(node, t("Waiting for prompt"), "idle");
} else if (count > 0) {
setPill(node, t(`Duplicates ${count}`), "warn", terms);
} else {
setPill(node, t("No duplicates"), "idle");
}
node.bgcolor = state.connected && count > 0 ? AMBER_TINT : undefined;
node.setDirtyCanvas?.(true, true);
}
async function runCheck(node, text) {
const state = node._duplicateCheck;
if (!state) return;
const ticket = (state.ticket = (state.ticket ?? 0) + 1);
try {
const data = await checkDuplicateTags(text);
if (state.ticket !== ticket) return;
applyResult(node, data?.count ?? 0, (data?.terms ?? []).map((item) => item.term));
} catch {
// 查询失败就保持上一次的状态,别把节点闪成「无重复」误导人
}
}
function scheduleCheck(node, text) {
const state = node._duplicateCheck;
if (!state || text === state.checkedText) return;
state.checkedText = text;
clearTimeout(state.timer);
state.timer = setTimeout(() => runCheck(node, text), REFRESH_DELAY);
}
function syncConnection(node) {
const state = node._duplicateCheck;
if (!state) return;
state.connected = node.inputs?.[0]?.link != null;
state.source = state.connected ? upstreamWidget(node) : null;
if (!state.connected) {
state.checkedText = null;
applyResult(node, 0, []);
return;
}
const text = state.source?.value;
if (typeof text !== "string") {
applyResult(node, 0, []);
return;
}
scheduleCheck(node, text);
}
app.registerExtension({
name: "ComfyUI.BilingualPromptInspector.DuplicateCheck",
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name !== NODE_NAME) return;
const originalCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
originalCreated?.apply(this, arguments);
injectStyles();
// widget.label / input.label 都是直接赋值,不走翻译层,所以这里直接写中文
const toggle = this.widgets?.find((widget) => widget.name === "keep_first");
if (toggle) toggle.label = "只保留第一个";
if (this.inputs?.[0]) this.inputs[0].label = "提示词";
const holder = document.createElement("div");
holder.className = "bpi-dup-holder";
const pill = document.createElement("div");
pill.className = "bpi-dup-pill bpi-dup-idle";
const dot = document.createElement("span");
dot.className = "bpi-dup-dot";
const textNode = document.createElement("span");
textNode.className = "bpi-dup-text";
pill.append(dot, textNode);
holder.append(pill);
this._duplicateCheck = { pill, dot, textNode, count: 0, connected: false };
try {
this.addDOMWidget("bpi_duplicate_status", "bpi-duplicate-status", holder, {
getMinHeight: () => PILL_HEIGHT,
getMaxHeight: () => PILL_HEIGHT,
hideOnZoom: false,
serialize: false,
});
} catch (error) {
console.error("[DuplicateChecker] Failed to create status pill", error);
this._duplicateCheck = null;
return;
}
setPill(this, t("Waiting for prompt"), "idle");
requestAnimationFrame(() => {
// 胶囊:一行状态加一个开关。等 DOM 布局算完再压尺寸,免得把开关挤没了
this.setSize?.([236, Math.min(this.size?.[1] ?? 88, 96)]);
syncConnection(this);
});
};
const originalConnectionsChange = nodeType.prototype.onConnectionsChange;
nodeType.prototype.onConnectionsChange = function () {
originalConnectionsChange?.apply(this, arguments);
syncConnection(this);
};
// canvas 模式靠每帧比对上游文本来保持实时;Nodes 2.0 不跑这里,
// 那种情况下由连线变化与执行结果兜底。
const originalDrawForeground = nodeType.prototype.onDrawForeground;
nodeType.prototype.onDrawForeground = function () {
originalDrawForeground?.apply(this, arguments);
const state = this._duplicateCheck;
if (!state?.connected) return;
const text = state.source?.value;
if (typeof text === "string") scheduleCheck(this, text);
};
const originalExecuted = nodeType.prototype.onExecuted;
nodeType.prototype.onExecuted = function (message) {
originalExecuted?.apply(this, arguments);
const state = this._duplicateCheck;
if (!state) return;
state.connected = true;
state.checkedText = null;
// 后端 ui 的值会被 execution.py 展开成列表,所以这里取第一个元素
const counts = message?.duplicate_count;
const count = Number(Array.isArray(counts) ? counts[0] : counts);
applyResult(this, Number.isFinite(count) ? count : 0, message?.duplicate_terms ?? []);
};
const originalRemoved = nodeType.prototype.onRemoved;
nodeType.prototype.onRemoved = function () {
clearTimeout(this._duplicateCheck?.timer);
this._duplicateCheck = null;
originalRemoved?.apply(this, arguments);
};
},
});
+213 -97
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";
@@ -44,6 +44,30 @@ const ZH = {
Discard: "丢弃",
Resume: "继续",
Weight: "权重",
"Weight / color": "权重 / 颜色",
Apply: "应用",
Default: "默认",
Random: "随机",
None: "无",
"By inclusion": "按收录",
"By dictionary": "按词库",
"By Anima": "按 Anima",
Quality: "质量",
Copyright: "作品",
Expression: "表情",
Camera: "镜头",
Scene: "场景",
"Pick a color for each Anima category; unset ones keep the default.": "给每个 Anima 分类挑一个颜色;没配色的保持默认底色。",
"This color has the lowest priority: it only shows in Default mode; other color modes override it.": "这里改的颜色优先级最低:只有「默认」上色模式才显示,按收录、随机、按词库、按 Anima 都会盖住它。",
Colors: "颜色",
"Pick a color for each dictionary; uncolored ones keep the default.": "给每个词库挑一个颜色;没配色的保持默认底色。",
"Restore defaults": "恢复默认",
"Random color": "随机颜色",
"Colors saved": "颜色已保存",
"Tags matching no dictionary": "没命中任何词库的标签",
"Invalid color; use #RGB or #RRGGBB": "颜色格式不对;请用 #RGB 或 #RRGGBB",
"{} items": "{} 条",
"Invalid color; use #hex or rgb(r,g,b)": "颜色格式不对;请用 #hex 或 rgb(r,g,b)",
Category: "分类",
English: "英文",
Name: "名称",
@@ -61,35 +85,17 @@ const ZH = {
// ── 节点主面板 ──────────────────────────────────────────────────────
"English original (actual output)": "英文原文(实际输出)",
"English prompt (actual output)": "英文提示词(实际输出)",
"Chinese explanation (read-only)": "中文解释(只读)",
"Chinese sync editor (per-tag composition)": "中文同步编辑(按标签组合)",
"Category table view | click tags to link; category names are not written to the prompt":
"分类表视图 | 点击标签联动;分类名不会写入提示词",
"Click tags to link; select then press Delete": "点击标签联动;选中后按 Delete",
"Click to link | select then press Delete": "点击联动 | 选中后按 Delete",
"Click English tags to link with the Chinese view and the detail table below":
"点击英文标签,与下方中文视图和明细表联动",
"Click tags to link with the English view above and the detail table below":
"点击标签,与上方英文视图和明细表联动",
"Enter the English prompt; after editing, it will be shown as selectable, deletable tags.":
"输入英文提示词;编辑完成后会显示成可选中、可删除的标签。",
"Enter Chinese, English, or mixed text; use “Translate” or “Translate & optimize”, then sync to the English output separately after confirming.":
"输入中文、英文或混排文本;可用「翻译」或「翻译并优化」,确认后再单独同步到英文输出。",
"Enter Chinese, English, or mixed text; drag the bottom-right corner to resize (auto-saved)":
"输入中文、英文或混排文本;拖右下角可调整大小(自动保存)",
"Enter a prompt in the English text box above; per-tag bilingual mapping will appear here.":
"在上面的英文输入框里写提示词,这里会出现逐标签的中英对照。",
"Per-tag Chinese combinations for the English prompt will appear here.":
"英文提示词的逐标签中文组合会出现在这里。",
"Linked tags will appear here after you finish editing English.":
"英文编辑完成后,联动标签会出现在这里。",
"Edit text": "编辑文本",
"Back to link": "返回联动视图",
"Edit Chinese": "编辑中文",
"Expand edit": "展开编辑",
"Expand edit Chinese / mixed text": "展开编辑中文/混排文本",
"Edit Chinese or mixed content in a larger popup": "在更大的弹窗里编辑中文或中英混排内容",
"Edit directly when empty": "为空时可直接编辑",
"Edit tag weight": "编辑标签权重",
"Apply weight": "应用权重",
@@ -97,18 +103,21 @@ const ZH = {
"Highest": "最高",
Translate: "翻译",
"Translate & optimize": "翻译并优化",
"Optimize to Anima": "优化为 Anima",
Optimize: "优化",
"Fit height": "适应高度",
"Let the prompt area grow with the node instead of being capped": "让提示词区跟着节点一起变高,不再卡在固定的高度上限",
"Back to the automatic height; the prompt area stops following the node": "回到自动高度,提示词区不再跟着节点变高",
"Re-translate": "重新翻译",
"Translate all unknown": "翻译全部未收录",
"Sort by Anima order": "按 Anima 顺序排序",
"Sync to English output": "同步到英文输出",
"Confirm write English": "确认写入英文",
"Cancel Anima order sort": "取消 Anima 顺序排序",
"Cancel Anima sort": "取消 Anima 排序",
"Confirm write new prompt": "确认写入新提示词",
"Confirm & save": "确认并保存",
"Save to dict": "存入词库",
"Save to Personal Dictionary": "保存到个人词库",
"Add tag": "添加标签",
"New Tag": "新建标签",
"Search candidates": "搜索候选",
"Dictionary search": "词库搜索",
"Enter Chinese, English, alias, or concept; click a result to insert the English tag":
"输入中文、英文、别名或概念;点击结果即可插入英文标签",
@@ -116,16 +125,13 @@ const ZH = {
"Double-click to edit Chinese explanation": "双击编辑中文解释",
"Enter Chinese explanation": "输入中文解释",
"Copy English + Chinese mapping": "复制中英对照",
"Text editing & processing (mixed CN/EN supported)": "文本编辑与处理(支持中英混排)",
"Text mode while typing; parsed into tags when done": "输入时按文本处理,结束后解析为标签",
"Stay in text editing while typing; switch to tag view on blur or after clicking Done":
"输入时保持文本编辑;失焦或点「完成」后切回标签视图",
"Switch between the English text editor and the linked tag view":
"在英文文本编辑框与联动标签视图之间切换",
"Switch between the tag-linked view and the bilingual text editor":
"在标签联动视图与双语文本编辑框之间切换",
"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": "隐藏的标签不会进入实际输出",
@@ -142,6 +148,10 @@ const ZH = {
"Personal dictionary": "个人词库",
"No inspector node on canvas": "画布上没有检查器节点",
"No matching items under the current filter.": "当前筛选条件下没有匹配项。",
"Find in the current prompt": "在当前提示词里查找",
"Clear search": "清除查找",
"No tag matches {}": "没有匹配“…”的标签",
"Filter the detail list by keyword; matches the English original or the Chinese explanation; the prompt itself never changes": "按关键词筛明细表;英文原文和中文解释都参与匹配,提示词本身不会变",
"No unknown tags currently": "当前没有未收录标签",
"Edit directly": "直接编辑",
@@ -156,7 +166,6 @@ const ZH = {
"Import: first run only": "导入:仅首次运行",
"New English prompt to apply": "待应用的新英文提示词",
"Current English prompt": "当前英文提示词",
"Apply to editor": "应用到编辑框",
// ── 状态栏 / 提示文案(含变量) ──────────────────────────────────────
"Click “Confirm clear” again to clear the English actual output": "再点一次「确认清空」才会清空英文实际输出",
@@ -165,24 +174,12 @@ const ZH = {
"English clear undone": "已撤销英文清空",
"English content has changed; cannot undo this clear": "英文内容已变化,无法撤销这次清空",
"English prompt is empty; cannot sort": "英文提示词是空的,无法排序",
"Green text editor is empty": "绿色文本框是空的",
"Please enter content in the green text editor first": "请先在绿色文本框里输入内容",
"Content is empty; enter the English prompt directly": "内容为空,请直接输入英文提示词",
"Result is empty; English prompt not overwritten": "结果为空,未覆盖英文提示词",
"No content change": "内容没有变化",
"The current result contains Chinese and cannot be synced to the English model input":
"当前结果含中文,不能同步到英文模型输入",
"result not yet written to the model; after confirming, click “Sync to English output”":
"结果还没写入模型;确认后点「同步到英文输出」",
"Natural-language segments only support whole-segment editing; tags can still be deleted individually":
"自然语言片段只支持整段编辑;标签仍可单独删除",
"Only tags support individual weights; use text editing for natural language":
"只有标签支持单独权重;自然语言请用文本编辑",
"Weight must be between 0 and 3": "权重必须在 0 到 3 之间",
"“Optimize to Anima” only processes English; for Chinese or mixed content, use “Translate & optimize”":
"「优化为 Anima」只处理英文;中文或混排内容请用「翻译并优化」",
"Chinese or mixed content detected; use “Translate” or “Translate & optimize”":
"检测到中文或混排内容,请用「翻译」或「翻译并优化」",
"Use “Edit text” to process it": "请用「编辑文本」处理",
"Click “Edit text”, then use the inspector's own “Translate” or “Translate & optimize”":
"点「编辑文本」,再用检查器自带的「翻译」或「翻译并优化」",
@@ -193,23 +190,20 @@ const ZH = {
"Prompt is empty; cannot favorite": "提示词是空的,无法收藏",
"Browser denied clipboard write": "浏览器拒绝了剪贴板写入",
"Browser denied clipboard access": "浏览器拒绝了剪贴板访问",
"Auto-detect language and translate faithfully; no optimization, no auto-sync":
"自动识别语言并忠实翻译;不优化、不自动同步",
"auto-translate then optimize to Anima -compliant English prompt":
"自动翻译后优化为符合 Anima 规范的英文提示词",
"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 可撤销",
"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": "已临时修改「{}」,未存入词库",
@@ -227,6 +221,19 @@ const ZH = {
"Hide {}: removed from actual output but kept in this list; can be restored to original position at any time":
"隐藏「{}」:从实际输出中移除但保留在本列表,随时可恢复到原位置",
"Click to restore {} to its original position": "点击把「{}」恢复到原位置",
"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":
"已隐藏「{}」:仍显示、可照常编辑,但不参与实际输出;点眼睛斜线图标取消隐藏",
"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 {}": "点击取消隐藏「{}」",
"Click to unhide {} — {}": "点击取消隐藏「{}」——{}",
"Click to restore {} — {} to its original position": "点击把「{}」恢复到原位置——{}",
"Unhide all {} hidden tags": "全部取消隐藏({} 个)",
"hidden ({})": "已隐藏({})",
"Delete personal override and restore built-in explanation": "删除个人覆盖并恢复内置解释",
"Delete personal tag": "删除个人标签",
@@ -238,16 +245,31 @@ const ZH = {
"Query: {} | weight: {}": "查询:{}|权重:{}",
"Explanation source: {}": "解释来源:{}",
"Aliases: {}": "别名:{}",
"Unknown English tag: {} | click to locate; double-click to edit weight":
"未收录的英文标签:「{}」|点击定位;双击编辑权重",
"{} ↔ {} | natural language supports only whole-segment editing; can drag to reorder the whole segment":
"「{}」↔「{}」|自然语言只支持整段编辑;可拖动整段调整顺序",
"{} ↔ {} | click to link; double-click to edit weight; select then press Delete; drag to reorder or Alt+↑/↓ to nudge":
"「{}」↔「{}」|点击联动;双击编辑权重;选中后按 Delete;拖动排序或 Alt+↑/↓ 微调",
"{} ↔ {} | natural language supports only whole-segment editing or translation":
"「{}」↔「{}」|自然语言只支持整段编辑或整段翻译",
"{} ↔ {} | click to locate; double-click to edit weight; select then press Delete":
"「{}」↔「{}」|点击定位;双击编辑权重;选中后按 Delete",
"{} ↔ {} | click to link; Ctrl+click to select multiple; double-click to open the editor; select then press Delete; drag to reorder or Alt+↑/↓ to nudge":
"「{}」↔「{}」|点击联动;Ctrl+点可多选;双击打开编辑;选中后按 Delete;拖动排序或 Alt+↑/↓ 微调",
"Insert line break": "插入换行",
"Delete line break": "删除换行",
"Move line break": "移动换行",
"Insert a line break after the selected tag, or at the end when no tag is selected":
"在选中的标签后面插入换行;没有选中标签时插到末尾",
"Line break | drag onto a tag to move it; click × to delete (the two lines merge)":
"换行|拖到某个标签上可移动位置;点 × 删除(上下两行合并)",
"Delete this line break": "删除这个换行",
"Enter tags first, then insert a line break": "先输入标签,再插入换行",
"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": "拖动调整左右两栏宽度;双击恢复默认比例",
"Add selected to personal dictionary": "把已选加入个人词库",
"Selected {} / {}": "已选 {} / 共 {}",
"Check the entries you want to add first": "请先勾选要加入的词条",
"Adding {} entries…": "正在加入「{}」条词条…",
"Added {} entries | new {}, replaced {}": "已加入「{}」条|新增「{}」、覆盖「{}」",
"Added {} entries | new {}, replaced {}, skipped {}": "已加入「{}」条|新增「{}」、覆盖「{}」、跳过「{}」",
"Restore all {} hidden tags to their original positions": "把全部 {} 个隐藏标签恢复到原位置",
"Generated by the assistant currently configured in the bilingual inspector; not yet saved to the personal dictionary":
"由双语检查器当前配置的助手生成,尚未存入个人词库",
@@ -261,6 +283,22 @@ const ZH = {
"Click to disable this pack": "点击停用这个词包",
"Click to enable this pack": "点击启用这个词包",
// ── 底部快捷输入 ────────────────────────────────────────────────────
"Quick add": "快捷添加",
"Enter to add | Shift+Enter for a new line | Ctrl+Enter to translate & optimize": "回车添加|Shift+回车换行|Ctrl+回车翻译并优化",
"Type Chinese to translate it into English tags, or type English tags to add them directly":
"输入中文会自动翻译为英文标签;直接输入英文则原样添加",
Add: "添加",
"Insert after {}": "插到「{}」之后",
"Add to the end (no tag selected)": "添加到末尾(未选中标签)",
"The new tag goes right after {}": "新标签会插到「{}」之后",
"No tag is selected, so the new tag goes at the end of the prompt": "没有选中标签,新标签会添加到提示词末尾",
"Enter Chinese or an English tag first": "请先输入中文或英文标签",
"Added {} to the prompt": "已把「{}」添加到提示词",
"Translating {}…": "正在翻译「{}」…",
"Translation result is empty": "翻译结果是空的",
"Translated {} to {} and added it": "已把「{}」翻译为「{}」并添加",
// ── 弹窗:保存提示词 ────────────────────────────────────────────────
"Save Current Prompt": "保存当前提示词",
"Defaults to the start of the prompt": "默认取提示词开头",
@@ -280,8 +318,26 @@ const ZH = {
Models: "适用模型",
"Recommended Weight": "推荐权重",
"e.g.: looking at viewer": "例如:looking at viewer",
"e.g.: apple": "例如:苹果",
"e.g.: pose, camera, style": "例如:pose、camera、style",
// ── 弹窗:保存 / 编辑收藏 ────────────────────────────────────────────
Note: "备注",
Remove: "移除",
"No note": "无",
"Model limit reached": "模型数量已达上限",
"Type keywords": "输入关键词",
"Press Enter to add; ↑↓ to choose": "回车添加,↑↓ 切换候选",
"Type to filter; ↑↓ to choose": "输入即筛选,↑↓ 切换候选",
"Manual input": "手动输入",
"Tag saved": "词条已保存",
"Entry info": "词条信息",
"Edits go to the personal dictionary and override same-name entries.": "改动存入个人词库,会覆盖同名词条的显示。",
"Nothing was saved: the Chinese name is still the unknown placeholder.": "没有保存:中文名称还是「未收录」,这样存进去只会得到一条残词条。",
"Enter a real Chinese name, then apply again.": "先填一个真正的中文名称,再点应用。",
"Edit Favorite": "编辑收藏",
"Keep the current image, or pick a new one to replace it": "不换图就保持原样,选一张新的会替换掉原来的",
// ── 关于 ────────────────────────────────────────────────────────────
"About plugin": "关于插件",
"View plugin version and project repository": "查看插件版本与项目仓库",
@@ -298,7 +354,6 @@ const ZH = {
Packs: "词包",
Favorites: "收藏",
"Assistant Settings": "助手设置",
"Manager panel": "管理面板",
"Add or select a BilingualPromptInspector node to see its tag manager here.":
"添加或选中画布上的 BilingualPromptInspector 节点,即可在此看到它的标签管理。",
"Tag translation, dictionary search and per-tag operations edit the selected node below.":
@@ -345,33 +400,48 @@ const ZH = {
"Recognizing tag categories…": "正在识别标签分类…",
"Loading dictionary…": "正在加载词库…",
"Loading settings...": "正在加载设置…",
"Settings load on first visit...": "首次进入时加载设置…",
// ── 助手设置 ────────────────────────────────────────────────────────
// ── 助手设置(ComfyUI「设置」面板里内嵌的那一项)─────────────────────
"Translation Service": "翻译服务",
"Dictionary only (no API)": "仅词库(不需要接口)",
"Baidu Translate": "百度翻译",
"AI (OpenAI-compatible / Ollama)": "AI(OpenAI 兼容 / Ollama)",
"Translation/explanation uses this choice. \"Translate & Optimize\" and \"Optimize to Anima\" always use AI, unrelated here. To test AI connection, temporarily set translation service to AI then click test (key preserved).":
"翻译/解释按这里的选择走。「翻译并优化」和「优化为 Anima」始终走 AI,与此无关。要测试 AI 连接,请临时把翻译服务切到 AI 再点测试(密钥会保留)。",
"Only this service's settings are shown below. \"Translate & Optimize\" and \"Optimize\" always use AI: choose OpenAI-compatible API or Ollama above to configure or test them. Switching service never clears saved keys.":
"下面只显示当前所选服务的设置。「翻译并优化」和「优化」始终走 AI:要配置或测试它们,请在上方选择「OpenAI 兼容接口」或「Ollama」。切换服务不会清掉已保存的密钥。",
"APP ID": "APP ID",
"Secret Key": "密钥",
"Clear saved Baidu secret": "清除已保存的百度密钥",
"Takes effect after saving": "保存后生效",
"Baidu Cloud \"General Text Translation\" APP ID": "百度智能云「通用文本翻译」的 APP ID",
"Baidu Cloud \"General Text Translation\" Secret Key": "百度智能云「通用文本翻译」的密钥",
"QPS (requests/second)": "QPS(每秒请求数)",
"Baidu rate-limits General Text Translation by account tier. Enter your tier's number and the plugin queues requests to match:\n· Unverified / Standard: {}\n· Personal verified: {}\n· Enterprise verified: higher, see the Baidu AI Cloud console\nFind your tier under Baidu AI Cloud → Machine Translation → General Text Translation.\nToo high and Baidu still returns {} (too many requests); too low is only slower, never an error.\nIf unsure, use {}.":
"百度「通用文本翻译」按账号档位限速,填你账号对应的数值,插件会照它排队发请求:\n· 未认证 / 标准版:{}\n· 个人认证:{}\n· 企业认证:更高,以百度智能云控制台为准\n档位可在「百度智能云 → 机器翻译 → 通用文本翻译」的接入文档里查到。\n填高了仍会被百度返回 {}(请求过于频繁);填低了只是慢一点,不会报错。\n不确定就填 {}。",
"How many requests per second Baidu may receive at most. Batch translation queues by this value and never exceeds it.": "每秒最多发给百度多少次请求。批量翻译会按这个值排队,不会超额。",
"AI Service (Translate & Optimize / Optimize / AI Translate)": "AI 服务(翻译并优化 / 优化 / AI 翻译)",
"AI Backend": "AI 后端",
"OpenAI-compatible API": "OpenAI 兼容接口",
"Ollama (local)": "Ollama(本地)",
"API URL": "API 地址",
"Optional for Ollama; e.g.: https://host/v1": "Ollama 可选;例如 https://host/v1",
"provider model ID": "服务商的模型 ID",
"e.g.: qwen3:8b": "例如:qwen3:8b",
"Optional for local LM Studio": "本地 LM Studio 可选",
"Remote addresses must use https; http is only accepted for this machine. Private, reserved and link-local addresses are rejected. Keys are never sent in clear text over the network.":
"远端地址必须使用 https;只有本机地址可以用 http。内网、保留和链路本地地址一律拒绝。密钥绝不会明文发到网络上。",
"Ollama only accepts a loopback address on this machine; anything else is rejected before a request is sent.":
"Ollama 只能填本机回环地址;其它地址在发出请求前就会被拒绝。",
"Model Name": "模型名称",
"e.g.: qwen3:8b or provider model ID": "例如:qwen3:8b 或服务商的模型 ID",
"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 后保存并测试",
"Use Ollama Local Preset": "使用 Ollama 本地预设",
"Connects to localhost:11434 by default; no external API required.": "默认连接 localhost:11434,不需要外部接口。",
"Ollama default URL filled; select a pulled model ID, then Save & Test": "已填入 Ollama 默认地址;选择已拉取的模型 ID 后保存并测试",
"API Key": "API 密钥",
"Optional for local Ollama / LM Studio": "本地 Ollama / LM Studio 可选",
"Clear saved API Key": "清除已保存的 API 密钥",
"Saved; leave empty to keep": "已保存;留空则保持不变",
Temperature: "温度",
@@ -392,11 +462,21 @@ const ZH = {
// ── 语言设置(本功能新增) ────────────────────────────────────────────
Language: "语言",
Chinese: "中文",
English: "英文",
"Chinese (Simplified)": "简体中文",
"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 的语言切换;「中文」「英文」为强制指定。已打开的面板会立即更新;若个别位置仍是旧语言,刷新页面即可。",
"Language saved": "语言设置已保存",
"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": "检查器助手",
@@ -408,6 +488,14 @@ const ZH = {
"Uncategorized": "未分类",
"Not indexed": "未收录",
"Natural-language segment (pending translation or confirmation)": "自然语言片段(待翻译或确认)",
"Suspected natural language": "疑似自然语言",
Suspected: "疑似",
"Save to Dictionary": "保存的词库",
"Natural Language Dictionary": "自然语言词库",
"Not indexed · suspected natural language": "未收录 · 疑似自然语言",
// 句尾的 3 也是变量:VAR_PATTERN 同时吃「“…”」和数字
"{} ↔ {} | suspected natural language: more than {} spaces; confirm or split it into tags":
"{} ↔ {}|疑似自然语言:空格超过 {} 个;请确认或拆成标签",
"Custom": "自定义",
"Pending": "待确认",
Syntax: "语法",
@@ -416,14 +504,14 @@ const ZH = {
Tag: "标签",
"Tag + Natural language": "标签 + 自然语言",
Instruction: "指令",
"Tag mode": "标签模式",
"Mode: {}": "模式:{}",
"Mode: {} | {}": "模式:{}|{}",
"English prompt tag view": "英文提示词标签视图",
"Per-tag Chinese sync view": "逐标签中文同步视图",
"Anima official sort": "Anima 官方顺序",
"Anima official order preview": "Anima 官方顺序预览",
"Manual edit": "手动编辑",
"Restore the prompt as it was before the Anima sort": "恢复到 Anima 排序前的提示词(分类视图一起退出)",
"Restored the prompt from before the Anima sort": "已恢复到 Anima 排序前的提示词",
"Exited the Anima category view": "已退出 Anima 分类视图",
"Empty input": "输入为空",
"Copied": "已复制",
@@ -432,19 +520,7 @@ const ZH = {
"items {} | recognized {} | unknown {} | pending {}": "共 {} 个标签|已识别 {}|未收录 {}|待确认 {}",
"items {} | recognized {} | unknown {} | issues {}": "共 {} 个标签|已识别 {}|未收录 {}|问题 {}",
"items {} | recognized {} | unknown {} | pending {} | issues {}": "共 {} 个标签|已识别 {}|未收录 {}|待确认 {}|问题 {}",
"Text processing result: {}": "文本处理结果:{}",
"Chinese results are for reading only; to generate images, continue with translation or optimization":
"中文结果仅供阅读;要出图请继续翻译或优化",
"Processing results stay here; click Sync to write to the English output above":
"处理结果留在这里;点「同步」写入上方英文输出",
"{}completed; result not yet synced to the English output above": "{}已完成;结果尚未同步到上方英文输出",
"{}; please fix before syncing": "{};请先修正再同步",
"{}Sync": "{}同步",
"Text sync": "文本同步",
"Write the current English result to the actual output above after preview": "预览后把当前英文结果写入上方实际输出",
"Only the English result in the green editing area can be synced to the actual output":
"只有绿色编辑区里的英文结果能同步到实际输出",
"English content can be synced directly, or optimized to Anima": "英文内容可直接同步,或优化为 Anima",
"Clear English prompt": "清空英文提示词",
"This tag has no explicit weight": "这个标签没有显式权重",
"Weight unchanged or value invalid": "权重没有变化,或数值无效",
@@ -494,6 +570,8 @@ const ZH = {
"Save Personal": "保存为个人",
"Reset Built-in": "重置为内置",
"Select all ({} items)": "全选({} 条)",
"Edit {}": "编辑「{}」",
"Edit {} tags": "编辑 {} 个标签",
"pack:": "词包:",
"Copied {}": "已复制 {}",
"Processed {}": "已处理「{}」",
@@ -531,6 +609,8 @@ const ZH = {
"Added {}": "新增 {}",
"Conflicts {}": "冲突 {}",
"Duplicates {}": "重复 {}",
"No duplicates": "无重复",
"Waiting for prompt": "等待提示词",
"Invalid {}": "无效 {}",
"Import complete: added {}, overwrote {}, skipped {} | auto-backed up": "导入完成:新增 {}、覆盖 {}、跳过 {}|已自动备份",
"Import complete: {} added": "导入完成:新增 {} 条",
@@ -556,22 +636,22 @@ const ZH = {
"Translation Rule": "翻译规则",
"Translate & Optimize Rule": "翻译并优化规则",
"Optimize Rule": "优化规则",
"Natural Language Rule": "自然语言规则",
"Optimize with the rule for the current mode: tag mode tidies the tag flow, natural language mode rewrites it as English sentences":
"按当前模式对应的规则优化:标签模式整理标签流,自然语言模式改写为英文自然语句",
"Connection OK: {}": "连接正常:{}",
// 确认对话框的默认确认按钮(confirmDialog 的 confirmLabel 缺省值),
// 不是「连接正常」的 OK——那个是整句键 "Connection OK: {}"。
OK: "确认",
Untitled: "未命名",
"Source node not recorded": "未记录来源节点",
"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 #{}": "节点 #{}",
"Node #{}: {}": "节点 #{}:{}",
"Bilingual Prompt Inspector: Tag Manager, Dictionary, Packs & Assistant Settings":
"提示词翻译与管理:标签管理、词库、词包与助手设置",
// ── 语法诊断(parser.js)────────────────────────────────────────────
"Manual override": "手动覆盖",
@@ -597,6 +677,31 @@ const ZH = {
"Bracket mismatch: extra or misplaced {}": "括号不匹配:多了或位置不对的 {}",
"Unclosed bracket: missing {}": "括号未闭合:缺少 {}",
"Duplicate tag: {}": "重复标签:{}",
// 重复标签的「唯一」开关 + 重复栏
Unique: "唯一",
"Keep only the first {} in the actual output; later occurrences stay here with a strikethrough":
"只让第一次出现的「{}」进入实际输出;其余照常显示在界面上(划线)",
"Tags repeated in this prompt; click a chip to step through its occurrences, double-click to delete the one you stepped to; click empty space here to clear the selection":
"提示词里重复的标签:单击胶囊依次跳到每一次出现,双击删掉跳到的那一处;点这里空白处取消选中",
"{} appears {} times; click to jump to the first one": "「{}」出现了 {} 次;单击跳到第一次",
"{} appears {} times; {} is on, only the first reaches the actual output": "「{}」出现了 {} 次;已开「{}」:只有第一次进入实际输出",
"{} appears {} times; now at {}, click for the next one, double-click to delete it":
"「{}」出现了 {} 次;当前第 {} 处,单击跳下一处,双击删除这一处",
"{} occurrence {} of {}; click again for the next one, double-click to delete it":
"「{}」第 {} / {} 处;再点一次跳下一处,双击删除这一处",
"Click a duplicate chip once to step to it before double-clicking to delete":
"先单击一次重复胶囊定位到某一处,再双击才会删除",
"{} 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 {}": "恢复「{}」的重复项",
"Invalid weight format: {}": "权重格式无效:{}",
"Full-width separator detected; Anima tag flow usually uses ASCII commas": "检测到全角分隔符;Anima 标签流一般用半角逗号",
"Consecutive commas or empty tags detected": "检测到连续逗号或空标签",
@@ -675,19 +780,25 @@ const ZH = {
// ── 第三批:自由文本变量包裹后的组合形态与遗漏项 ──────────────────────
"Low": "低",
"Low · pending": "低 · 待确认",
"Medium · pending": "中 · 待确认",
"Medium · session-only": "中 · 仅本次会话",
"Medium": "中",
"High": "高",
"Not in any dictionary, so it cannot be judged": "词库里查不到,没法判断",
"Machine translation, not saved to the dictionary or confirmed yet": "机器翻译,还没存进词库,也没人工确认",
"Syntax kept as-is; nothing to translate": "语法保留项,本来就不用翻译",
"In the dictionary, but not marked as verified": "词库里有,但还没标成已校验",
"In the dictionary, verified, category {}": "已在词库里,已校验,分类「{}」",
"In the dictionary, but no category set": "词库里有,但还没填分类",
"Changed for this session only; not saved to the dictionary": "只改了本次会话,没存进词库",
"★ Unfavorite": "★ 取消收藏",
"☆ Favorite": "☆ 收藏",
"Paused, waiting for confirmation…": "已暂停,等待确认…",
"Querying dictionary…": "正在查询词库…",
"Searching for more…": "正在搜索更多…",
"⚠ Not indexed: {}": "⚠ 未收录:{}",
"tag: {} | Anima format: (Tag:Weight)": "标签:{}|Anima 格式:(标签:权重)",
"Node #{} · {} tags": "节点 #{} · {} 个标签",
"Running {}…": "正在运行 {}…",
"Translating unknown tags {}/{}:{}": "正在翻译未收录标签 {}/{}:{}",
"Rate limited; waiting to retry {}…": "触发限流;稍后重试「{}」…",
"Translation complete {} items, rejected abnormal results {} items": "翻译完成 {} 条,已拒绝异常结果 {} 条",
"uses {}": "使用 {} 次",
"{} · {} KB": "{} · {} KB",
@@ -696,7 +807,6 @@ const ZH = {
"Sample": "示例",
"(empty prompt)": "(空提示词)",
"(weight {})": "(权重 {})",
"restored {} hidden tags; press Ctrl+Z to undo": "已恢复 {} 个隐藏标签;按 Ctrl+Z 可撤销",
"Delete community pack {}? File is backed up first; recoverable from data/backups.": "删除社区词包「{}」?会先备份文件,可从 data/backups 恢复。",
@@ -705,6 +815,12 @@ const ZH = {
Updated: "已更新",
Imported: "已导入",
"Click a tag to link it with the detail table below; select then press Delete": "点击标签可与下方明细表联动;选中后按 Delete 删除",
"English prompt is empty; nothing to optimize": "英文提示词是空的,没有可优化的内容",
"“Optimize” only processes English in tag mode; translate Chinese in the sidebar first": "「优化」在标签模式下只处理英文;中文请先在侧边栏翻译,或把模式切到「自然语言」",
"(Chinese detected; translating to English first)": "(检测到中文,会先译成英文)",
"{} done; please confirm in the preview window": "「{}」完成;请在预览窗口确认",
"{}; please fix it in the preview before confirming": "「{}」;请在预览里改好再确认",
};
let mode = "auto";
+676 -140
View File
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
// 收藏弹窗里「适合模型」的补全匹配:纯函数、不碰 DOM,方便单测。
// 排序规则:出现位置越靠前越优先(所以前缀匹配天然排在前面),位置相同短名优先,
// 再一样就按字典序,保证同样的输入每次给出同样的顺序。
export function matchModelNames(items, query, limit = 8) {
const needle = String(query ?? "").trim().toLowerCase();
if (!needle) return [];
const scored = [];
for (const item of Array.isArray(items) ? items : []) {
const name = String(item?.name ?? "");
const at = name.toLowerCase().indexOf(needle);
if (at < 0) continue;
scored.push({ item, at, name });
}
scored.sort((left, right) => (
left.at - right.at
|| left.name.length - right.name.length
|| left.name.localeCompare(right.name)
));
return scored.slice(0, limit).map((entry) => entry.item);
}
+110 -10
View File
@@ -20,6 +20,10 @@ function stripEmphasis(value) {
return current;
}
// 没命中任何词库时中文列填的占位文案。它只是「还没翻译」的提示,不是一条真正的译文,
// 所以任何往词库回写的地方都要先拿它比对,别把占位当成中文名存进去。
export const UNKNOWN_CHINESE = "未收录";
export function analyzeSegment(raw) {
const display = raw.trim();
if (!display) return null;
@@ -184,6 +188,43 @@ export function replacePromptTokenWeight(text, token, weight) {
};
}
/**
* Replace a raw slice of the prompt with whatever the user typed.
*
* Used by the "cut a piece out, edit it, paste it back" editor: the slice is a
* span of the ORIGINAL text (usually first-selected.start .. last-selected.end),
* so whatever sits between two selected tags comes along in the editor and is
* replaced with it. No parsing or validation happens here — commas the user
* typed simply get re-split by the next parse.
*
* Clearing the slice means deleting it, so the seam is tidied to avoid leaving
* `a, , b` behind: the comma on the left is dropped, or the one on the right
* when the slice started at the very beginning. A plain line break is left
* alone so multi-line layouts survive.
*
* The slice keeps its own leading / trailing whitespace in the text (that is
* where the space after a comma lives), so it is put back around what the user
* typed — otherwise the seam would come out as `a,b`.
*/
export function replacePromptSpan(text, start, end, replacement) {
const value = String(text ?? "");
const from = Math.max(0, Math.min(value.length, Number.isInteger(start) ? start : 0));
const to = Math.max(from, Math.min(value.length, Number.isInteger(end) ? end : from));
const span = value.slice(from, to);
const typed = String(replacement ?? "").trim();
const insert = typed ? `${span.match(/^\s*/)[0]}${typed}${span.match(/\s*$/)[0]}` : "";
if (span === insert) return { text: value, cursor: to, changed: false };
let left = value.slice(0, from);
let right = value.slice(to);
if (!insert) {
const trimmedLeft = left.replace(/[ \t]*,\r?\n?[ \t]*$/, "");
if (trimmedLeft !== left) left = trimmedLeft;
else right = right.replace(/^[\s,]+/, "");
}
const nextText = left + insert + right;
return { text: nextText, cursor: Math.min(from + insert.length, nextText.length), changed: true };
}
/**
* Move one parsed prompt token to a new position in the token order.
*
@@ -409,6 +450,15 @@ function looksLikeTagSuffix(raw) {
return true;
}
/**
* 标签里的空格超过 3 个(不含 3 个)时,它多半是被当成标签用的自然语言短语,
* 界面上标成「疑似自然语言」提醒确认。真正的 Danbooru 标签很少超过 3 个空格。
*/
export function looksSuspiciousNatural(raw) {
const value = String(raw ?? "").trim();
return (value.match(/ /g) ?? []).length > 3;
}
function trailingTagStart(segments, afterIndex) {
let start = segments.length;
while (start > afterIndex && looksLikeTagSuffix(segments[start - 1].raw)) start -= 1;
@@ -431,7 +481,10 @@ export function detectInputMode(text, requestedMode = "auto") {
const words = value.match(/[A-Za-z]+(?:[-'][A-Za-z]+)*/g) ?? [];
const commaSegments = splitPrompt(value);
const hasSentenceEnd = /[。!?.!?](?:\s|$)/.test(value);
// 权重小数和版本号里的句点(":1.2"、"1.")以及权重括号后的句点("(...:1.2).")都不是句子结束。
// 先把它们中和掉再判断,否则整串标签会只因为一个小数点被当成自然语言段落。
const sentenceEndCandidate = value.replace(/\d\s*[。!?.!?]|[)\]][。!?.!?]/g, (match) => match.replace(/[。!?.!?]/, ""));
const hasSentenceEnd = /[。!?.!?](?:\s|$)/.test(sentenceEndCandidate);
const hasNaturalGrammar = /\b(?:she|he|they|who|which|is|are|was|were|wearing|holding|standing|sitting|with|while|under|inside|outside|in the|on the)\b/i.test(value);
const longCommaSegment = commaSegments.some((item) => (item.raw.match(/[A-Za-z]+(?:[-'][A-Za-z]+)*/g) ?? []).length >= 10);
const strongNaturalStart = commaSegments.findIndex((item) => looksLikeNaturalClause(item.raw));
@@ -448,6 +501,15 @@ export function detectInputMode(text, requestedMode = "auto") {
if (commaSegments.length >= 3 && naturalStart === 0 && naturalEnd < commaSegments.length) {
return { mode: "mixed", reason: "Natural language followed by tag list", confidence: "high", naturalStart, naturalEnd };
}
// 上面没成立通常是尾部定界没认出标签流:trailingTagStart 还要求后缀里有 masterpiece /
// best quality 那类明星信号词,标签写得偏冷门时一个都命中不了。这时不该退化成整段自然语言——
// 开头那句是自然语言,后面明明是标签流,至少把开头的句子切出来。
if (naturalStart === 0 && commaSegments.length >= 4 && naturalEnd >= commaSegments.length && longCommaSegment) {
const tailTags = commaSegments.slice(1).filter((item) => looksLikeTagSuffix(item.raw)).length;
if (tailTags >= 4) {
return { mode: "mixed", reason: "Natural language followed by tag list", confidence: "medium", naturalStart, naturalEnd: naturalStart + 1 };
}
}
if (commaSegments.length >= 3 && naturalStart > 0) {
const shortBefore = commaSegments.slice(0, naturalStart).filter((item) =>
(item.raw.match(/[A-Za-z]+(?:[-'][A-Za-z]+)*/g) ?? []).length <= 6
@@ -559,13 +621,30 @@ export function validateTranslationResult(source, translated, options = {}) {
return { ok: true, text: result };
}
// 插件写死的默认分类:分到它等于还没分类。parser 是纯逻辑模块,不引 UI 那层,
// 所以这个常量和 bpi_shared.js 里的那份各存一份,改要一起改。
const UNCATEGORIZED_CATEGORY = "Uncategorized";
// 可信度:徽章只显示「未判定 / 低 / 中 / 高」四档,为什么是这一档写在 tooltip 里。
// 判据从粗到细:查不到 → 还没入库 → 没校验 → 没分类 → 有分类。
function confidenceFor(status, entry) {
if (status === "machine") return { confidence: "low", confidenceLabel: "Low · pending" };
if (status === "unverified") return { confidence: "medium", confidenceLabel: "Medium · pending" };
if (status === "unknown") return { confidence: "none", confidenceLabel: "Not judged" };
if (status === "special") return { confidence: "high", confidenceLabel: "High" };
if (entry?.source === "user" || entry?.verified !== false) return { confidence: "high", confidenceLabel: "High" };
return { confidence: "medium", confidenceLabel: "Medium" };
if (status === "unknown") {
return { confidence: "none", confidenceLabel: "Not judged", confidenceReason: "Not in any dictionary, so it cannot be judged" };
}
if (status === "machine") {
return { confidence: "low", confidenceLabel: "Low", confidenceReason: "Machine translation, not saved to the dictionary or confirmed yet" };
}
if (status === "special") {
return { confidence: "high", confidenceLabel: "High", confidenceReason: "Syntax kept as-is; nothing to translate" };
}
if (entry?.verified === false) {
return { confidence: "medium", confidenceLabel: "Medium", confidenceReason: "In the dictionary, but not marked as verified" };
}
const category = String(entry?.category ?? "").trim();
if (category && category !== UNCATEGORIZED_CATEGORY) {
return { confidence: "high", confidenceLabel: "High", confidenceReason: `In the dictionary, verified, category “${category}”` };
}
return { confidence: "medium", confidenceLabel: "Medium", confidenceReason: "In the dictionary, but no category set" };
}
export function parsePrompt(text, dictionaryIndex, machineTranslations = new Map(), options = {}) {
@@ -590,7 +669,7 @@ export function parsePrompt(text, dictionaryIndex, machineTranslations = new Map
const dictionaryEntry = dictionaryIndex.get(key);
const machine = machineTranslations.get(key);
let status = "unknown";
let chinese = "未收录";
let chinese = UNKNOWN_CHINESE;
if (dictionaryEntry) {
status = dictionaryEntry.verified === false ? "unverified" : "verified";
chinese = dictionaryEntry.chinese;
@@ -608,6 +687,9 @@ export function parsePrompt(text, dictionaryIndex, machineTranslations = new Map
chinese = "自然语言片段(待翻译或确认)";
}
const confidence = confidenceFor(status, dictionaryEntry);
const suspectedNatural = analyzed.syntax === "tag" &&
segment.segmentKind !== "natural" &&
looksSuspiciousNatural(segment.raw);
return {
...segment,
...analyzed,
@@ -615,6 +697,7 @@ export function parsePrompt(text, dictionaryIndex, machineTranslations = new Map
key,
chinese,
status,
suspectedNatural,
...confidence,
inputMode: detected.mode,
entry: dictionaryEntry ?? null,
@@ -658,6 +741,23 @@ const CONFLICT_GROUPS = [
{ left: ["full body"], right: ["extreme close-up", "close-up"], label: "Full body vs close-up" },
];
// 重复分组:按 token.key 聚拢,只保留出现 2 次以上的组,组内按原文顺序。
// 问题行的「重复标签」提示和「唯一」开关都从这里取数,两边口径不会打架。
// 口径沿用 key:忽略大小写、连续空格、强调括号与权重((1girl:1.3) ≡ 1girl);
// 下划线按一个空格算,所以 sm_ile ≠ smile。BREAK 与 <lora:…> 不算重复标签。
export function duplicateGroups(tokens) {
const groups = new Map();
for (const token of tokens ?? []) {
if (!token.key || token.syntax !== "tag") continue;
const list = groups.get(token.key) ?? [];
list.push(token);
groups.set(token.key, list);
}
return [...groups]
.filter(([, list]) => list.length > 1)
.map(([key, list]) => ({ key, tokens: list }));
}
export function analyzePromptSyntax(text, tokens, modeInfo = detectInputMode(text)) {
const value = String(text ?? "");
const issues = bracketIssues(value);
@@ -677,8 +777,8 @@ export function analyzePromptSyntax(text, tokens, modeInfo = detectInputMode(tex
issues.push(issue("warning", "unusual-weight", `Weight ${token.weight} outside common range 0–3`, [token.key]));
}
}
for (const [key, matches] of keys) {
if (matches.length > 1) issues.push(issue("warning", "duplicate", `Duplicate tag: “${matches[0].term}”`, [key]));
for (const { key, tokens: matches } of duplicateGroups(tokens ?? [])) {
issues.push(issue("warning", "duplicate", `Duplicate tag: “${matches[0].term}”`, [key]));
}
const present = new Set(keys.keys());
+1 -2
View File
@@ -2,10 +2,9 @@ const SCROLLABLE_SELECTOR = [
".bpi-table",
".bpi-results",
".bpi-issues",
".bpi-chinese-mirror",
".bpi-chinese-editor",
".bpi-english-token-view",
".bpi-english-editor",
".bpi-quick-input",
".bpi-manager-table",
".bpi-pack-list",
".bpi-sort-groups",
-5
View File
@@ -10,11 +10,6 @@
"prompt": {
"name": "上游提示词"
}
},
"outputs": {
"english_prompt": {
"name": "英文提示词"
}
}
}
}
+52 -3
View File
@@ -1,3 +1,4 @@
from .duplicate_check import dedupe_prompt, find_duplicates, format_duplicates
from .server import announce_upstream, current_prompt_id, upstream_gate
@@ -23,20 +24,28 @@ class BilingualPromptInspector:
},
"optional": {
"prompt": ("STRING", {"forceInput": True}),
# 输出管道(前端隐藏标签用):节点文本保留隐藏标签便于继续编辑,
# 前端把"剔除隐藏标签后的文本"同步进 effective_text 并置 use_effective,
# 实际输出取它;旧工作流没有这两个 widget,默认直通 text。
"effective_text": ("STRING", {"multiline": True, "default": ""}),
"use_effective": ("BOOLEAN", {"default": False}),
},
"hidden": {"unique_id": "UNIQUE_ID"},
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("english_prompt",)
RETURN_NAMES = ("英文提示词",)
FUNCTION = "pass_through"
CATEGORY = "text/prompt"
DESCRIPTION = "Bilingual prompt inspector and manager: tag-linked views, drag-to-reorder, temporary hide, upstream intercept with pause-to-confirm. Output always matches the English prompt text."
async def pass_through(self, text, prompt=None, unique_id=None):
async def pass_through(self, text, prompt=None, effective_text="", use_effective=False, unique_id=None):
# 前端置了 use_effective 时,effective_text 是"剔除隐藏标签后的文本",全隐藏时为空串,
# 也必须照用(所以不能拿空串当"未设置");旧工作流没有该 widget,默认 False 直通。
base = effective_text if use_effective else text
# 没有上游时保持原来的直通行为:协程立即返回,执行器不会挂起。
if prompt is None:
return (text,)
return (base,)
node_id = str(unique_id or "")
announce_upstream(node_id, prompt)
@@ -44,3 +53,43 @@ class BilingualPromptInspector:
released = await upstream_gate.wait(node_id, future)
# 前端放行时带回的是节点上编辑后的文本;无人接管(无浏览器或 API 调用)时原样透传。
return (released if isinstance(released, str) else prompt,)
class DuplicateChecker:
"""
@title: 重复检查
@nickname: Duplicate Check
@description: 检查提示词里的重复标签:节点上用淡橙色提示有几处重复,「提示词」输出直通下游,「重复项」输出可接预览任意查看。开关打开时只保留第一次出现的标签。
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"text": ("STRING", {"forceInput": True}),
},
"optional": {
"keep_first": ("BOOLEAN", {"default": False}),
},
}
RETURN_TYPES = ("STRING", "STRING")
RETURN_NAMES = ("提示词", "重复项")
FUNCTION = "check"
CATEGORY = "text/prompt"
DESCRIPTION = "Experimental duplicate tag checker: flags repeated tags with a soft amber tint, passes the prompt through, and exposes the duplicated tags for preview."
def check(self, text, keep_first=False):
value = str(text or "")
report = find_duplicates(value)
# 开关没开就原样直通,连格式都不重写,免得改了用户没要求改的东西。
prompt = dedupe_prompt(value) if keep_first else value
# ui 里每个值都必须是列表:execution.py 会把它们展开拼接成一个列表,
# 传裸 int 会直接抛 "'int' object is not iterable"。
return {
"ui": {
"duplicate_count": [report["extra"]],
"duplicate_terms": [item["term"] for item in report["terms"]],
},
"result": (prompt, format_duplicates(report)),
}
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "comfyui-bilingual-prompt-inspector",
"version": "1.2.1",
"version": "2.2.1",
"description": "ComfyUI bilingual prompt inspection, translation, Anima sorting, and on-demand Danbooru dictionary extension.",
"private": true,
"type": "module",
@@ -19,6 +19,6 @@
"node": ">=20"
},
"scripts": {
"test": "node tests/test_parser.mjs && node tests/test_mixed_boundaries.mjs && node tests/test_dictionary_tools.mjs && node tests/test_anima_sorter.mjs && node tests/test_panel_sync.mjs && node tests/test_wheel_guard.mjs && node tests/test_project_link.mjs && node tests/test_clear_controls.mjs && node tests/test_reorder.mjs && node tests/test_hide.mjs && node tests/test_performance.mjs && node tests/test_i18n.mjs"
"test": "node tests/test_parser.mjs && node tests/test_mixed_boundaries.mjs && node tests/test_dictionary_tools.mjs && node tests/test_anima_sorter.mjs && node tests/test_panel_sync.mjs && node tests/test_wheel_guard.mjs && node tests/test_project_link.mjs && node tests/test_clear_controls.mjs && node tests/test_reorder.mjs && node tests/test_hide.mjs && node tests/test_performance.mjs && node tests/test_i18n.mjs && node tests/test_model_suggest.mjs"
}
}
+6 -5
View File
@@ -1,7 +1,8 @@
[project]
name = "prompt-translator-manager"
version = "1.2.1"
description = "ComfyUI prompt node with translation and management: bilingual tag view, drag-to-reorder, temporary hiding, multiple translation services and a sidebar dictionary manager."
version = "2.2.1"
# 与 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" }
requires-python = ">=3.10"
dependencies = []
@@ -23,6 +24,6 @@ requires-comfyui = ">=0.3.40"
# 和 __init__.py 的 WEB_DIRECTORY(key = 模块名),两者指向同一个 js/ 目录时
# /extensions 接口会把每个 js 列两遍,前端加载两次 → "Extension already registered"。
# 前端资源统一由 __init__.py 的 WEB_DIRECTORY = "./js" 提供(各版本 ComfyUI 都支持)。
# 图标与横幅(可选,填好图片的公开 URL 后取消注释即可)
# Icon = "https://..." # 方形,≤400×400,PNG/SVG/JPG/GIF
# Banner = "https://..." # 21:9 横幅,PNG/SVG/JPG/GIF
# 图标与横幅
Icon = "https://raw.githubusercontent.com/mengshengzhijie/ComfyUI-Bilingual-Prompt-Inspector/main/assets/icon.png" # 方形 400×400(缩过的小图)
Banner = "https://raw.githubusercontent.com/mengshengzhijie/ComfyUI-Bilingual-Prompt-Inspector/main/assets/banner.png" # 原图 941×941(未缩放),站点会自行裁切并加背景虚化
+78 -18
View File
@@ -20,6 +20,8 @@ MAX_NOTE_CHARS = 500
MAX_IMAGE_BYTES = 5 * 1024 * 1024
MAX_IMPORT_BYTES = 128 * 1024 * 1024
BUNDLE_VERSION = 1
# 一条收藏最多记多少个适用模型,单个名字的长度上限复用 MAX_NAME_CHARS
MAX_MODELS = 20
IMAGE_MIME_TYPES = {".png": "image/png", ".jpg": "image/jpeg", ".webp": "image/webp"}
@@ -80,6 +82,28 @@ class SavedPromptStore:
note = None
return name, text, (note[:MAX_NOTE_CHARS] if note else None)
@staticmethod
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
def list_prompts(self):
entries = self._load_index()
entries.sort(key=lambda entry: entry.get("created_at") or 0, reverse=True)
@@ -91,48 +115,82 @@ class SavedPromptStore:
latest = max([entry.get("created_at") or 0 for entry in entries], default=0)
return max(int(time.time()), latest + 1)
def create_prompt(self, name, text, note=None, image_bytes=None, created_at=None):
def _write_image(self, prompt_id, image_bytes):
"""写入配图并返回文件名;没传图片返回 None。"""
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)")
sniffed = sniff_image(image_bytes)
if sniffed is None:
raise ValueError("图片格式仅支持 PNG / JPG / WebP")
image_name = f"{prompt_id}{sniffed}"
(self.store_dir / image_name).write_bytes(image_bytes)
return image_name
def _remove_image(self, image_name):
if not isinstance(image_name, str) or not image_name:
return
try:
(self.store_dir / image_name).unlink()
except OSError:
pass
def create_prompt(self, name, text, note=None, image_bytes=None, created_at=None, models=None):
if not str(text or "").strip():
raise ValueError("提示词内容为空,无法收藏")
entries = self._load_index()
if len(entries) >= MAX_PROMPTS:
raise ValueError(f"收藏数量已达上限({MAX_PROMPTS} 条),请先清理")
name, text, note = self._clean_entry({"name": name, "text": text, "note": note})
models = self._clean_models(models)
prompt_id = secrets.token_urlsafe(6)
image_name = None
if image_bytes:
if not isinstance(image_bytes, (bytes, bytearray)) or len(image_bytes) > MAX_IMAGE_BYTES:
raise ValueError(f"图片过大或无效(最大 {MAX_IMAGE_BYTES // (1024 * 1024)} MB)")
sniffed = sniff_image(image_bytes)
if sniffed is None:
raise ValueError("图片格式仅支持 PNG / JPG / WebP")
image_name = f"{prompt_id}{sniffed}"
(self.store_dir / image_name).write_bytes(image_bytes)
entry = {
"id": prompt_id,
"name": name,
"text": text,
"note": note,
"image": image_name,
"models": models,
"image": self._write_image(prompt_id, image_bytes),
"created_at": int(created_at) if created_at else self._next_created_at(entries),
}
entries.append(entry)
self._save_index(entries)
return entry
def update_prompt(self, prompt_id, name, text, note=None, models=None, image_bytes=None):
"""改一条收藏;不传新图就保留原图(换图时先写新的、成功再删旧的)。"""
if not str(text or "").strip():
raise ValueError("提示词内容为空,无法保存")
entries = self._load_index()
for entry in entries:
if entry.get("id") != prompt_id:
continue
name, text, note = self._clean_entry({"name": name, "text": text, "note": note})
entry["name"] = name
entry["text"] = text
entry["note"] = note
entry["models"] = self._clean_models(models)
if image_bytes:
old_image = entry.get("image")
new_image = self._write_image(prompt_id, image_bytes)
# 换成同名文件(同 id 同格式)时别删,否则会把刚写进去的新图删掉
if new_image != old_image:
self._remove_image(old_image)
entry["image"] = new_image
self._save_index(entries)
return entry
raise ValueError("收藏不存在或已删除")
def delete_prompt(self, prompt_id):
entries = self._load_index()
remaining = [entry for entry in entries if entry.get("id") != prompt_id]
if len(remaining) == len(entries):
raise ValueError("收藏不存在或已删除")
for entry in entries:
if entry.get("id") == prompt_id and isinstance(entry.get("image"), str) and entry["image"]:
try:
(self.store_dir / entry["image"]).unlink()
except OSError:
pass
if entry.get("id") == prompt_id:
self._remove_image(entry.get("image"))
self._save_index(remaining)
return len(remaining)
@@ -155,6 +213,7 @@ class SavedPromptStore:
"name": entry.get("name"),
"text": entry.get("text"),
"note": entry.get("note"),
"models": self._clean_models(entry.get("models")),
"created_at": entry.get("created_at"),
"image": None,
}
@@ -206,7 +265,8 @@ class SavedPromptStore:
if not isinstance(created_at, int) or isinstance(created_at, bool):
created_at = None
entry = self.create_prompt(
name, text, note=note, image_bytes=image_bytes, created_at=created_at
name, text, note=note, image_bytes=image_bytes, created_at=created_at,
models=self._clean_models(item.get("models")),
)
existing.append(entry)
existing_ids.add(entry["id"])
+261 -59
View File
@@ -1,5 +1,6 @@
import asyncio
import json
import os
import sqlite3
import re
import secrets
@@ -13,34 +14,52 @@ from comfy import model_management
from server import PromptServer
from .assistant_store import (
AI_PROVIDER_PREFIXES,
AI_SERVICES,
BAIDU_ERROR_MESSAGES,
BAIDU_QPS_DEFAULT,
BAIDU_TIMEOUT_SECONDS,
BAIDU_TRANSLATE_ENDPOINT,
AssistantStore,
baidu_split_query,
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
from .saved_prompt_store import (
MAX_IMAGE_BYTES,
SavedPromptStore,
)
from .token_color_store import TokenColorStore
store = DictionaryStore()
token_color_store = TokenColorStore()
assistant_store = AssistantStore()
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()
_assistant_slots = asyncio.Semaphore(2)
# 百度翻译的全局 QPS 节流:所有百度请求共用一条时间轴,跨请求生效。
# 之前是每个请求内部 sleep(1.1),只有单次文本被拆成多块时才生效,逐条翻译等于没节流。
_baidu_gate = asyncio.Lock()
_baidu_next_slot = 0.0
class AssistantLimitError(ValueError):
@@ -138,6 +157,19 @@ def _dictionary_explain(text):
return ",".join(output)
async def _baidu_throttle(qps):
"""按账号 QPS 给百度请求排号;多留 10% 余量,避免边界抖动踩到 54003。"""
global _baidu_next_slot
interval = (1.0 / max(float(qps), 0.1)) * 1.1
async with _baidu_gate:
now = time.monotonic()
slot = max(now, _baidu_next_slot)
_baidu_next_slot = slot + interval
delay = slot - time.monotonic()
if delay > 0:
await asyncio.sleep(delay)
async def _baidu_translate(text, to_lang):
config = assistant_store.config()
appid = str(config.get("baidu_appid") or "").strip()
@@ -147,12 +179,12 @@ async def _baidu_translate(text, to_lang):
chunks = baidu_split_query(text)
if not chunks:
raise ValueError("待翻译文本为空")
timeout = ClientTimeout(total=config["ai_timeout_seconds"])
qps = config.get("baidu_qps") or BAIDU_QPS_DEFAULT
timeout = ClientTimeout(total=BAIDU_TIMEOUT_SECONDS)
results = []
async with ClientSession(timeout=timeout) as session:
for index, chunk in enumerate(chunks):
if index:
await asyncio.sleep(1.1) # 通用文本翻译标准版限 1 QPS
for chunk in chunks:
await _baidu_throttle(qps)
params = baidu_translate_params(appid, secret_key, chunk, to_lang)
async with session.post(BAIDU_TRANSLATE_ENDPOINT, data=params) as response:
if response.status >= 400:
@@ -172,15 +204,21 @@ async def _baidu_translate(text, to_lang):
async def _call_ai_assistant(action, text, instruction, config, connection_test=False):
"""翻译/优化都走这里。优化类恒走 AI;翻译类在 translate_service=ai 时也走 AI。"""
if not config.get("ai_model"):
raise ValueError("尚未填写 AI 模型名称(“翻译并优化”“优化为 Anima”以及 AI 翻译都需要)")
ai_provider = config["ai_provider"]
"""翻译/优化都走这里。优化类恒走 AI;翻译类在翻译服务就是某个 AI 后端时也走 AI。"""
# 两个后端各一套配置和规则。选 OpenAI / Ollama 当翻译服务时它就是后端;
# 选词库 / 百度时翻译不走 AI,优化仍走,这时用 ai_provider 决定读哪一套
prefix = AI_PROVIDER_PREFIXES.get(config.get("translate_service")) or AI_PROVIDER_PREFIXES.get(
config.get("ai_provider", "openai_compatible"), "openai"
)
if not config.get(f"{prefix}_model"):
raise ValueError("尚未填写 AI 模型名称(“翻译并优化”“优化”以及 AI 翻译都需要)")
rule = {
"translate": config["translation_rule"],
"explain": config["translation_rule"],
"translate_optimize": config["translate_optimize_rule"],
"optimize": config["optimization_rule"],
"translate": config[f"{prefix}_translation_rule"],
"explain": config[f"{prefix}_translation_rule"],
"translate_optimize": config[f"{prefix}_translate_optimize_rule"],
"optimize": config[f"{prefix}_optimization_rule"],
# 自然语言模式下的「优化」:输入可以是中文,输出是英文自然语句,不走 Anima 标点规则
"optimize_natural": config[f"{prefix}_natural_rule"],
}[action]
if connection_test:
rule = "你是连接测试助手。"
@@ -194,55 +232,61 @@ async def _call_ai_assistant(action, text, instruction, config, connection_test=
rule = f"{rule}\n\n本次任务:无论输入语言为何,最终必须输出经过优化的英文 Anima 提示词。"
elif not connection_test and action == "optimize":
if re.search(r"[\u3400-\u9fff]", text):
raise ValueError("“优化为 Anima”只接受英文;中文或混合文本请使用“翻译并优化”")
raise ValueError("“优化”在标签模式下只接受英文;中文或混合文本请使用“翻译并优化”,或把模式切到“自然语言”")
rule = f"{rule}\n\n本次任务:整理现有英文内容,不承担翻译。"
elif not connection_test and action == "optimize_natural":
# 自然语言模式允许中文输入:先译后整理,输出一段英文自然语句
direction = "先把中文忠实译为英文,再整理表达" if translation_direction(text) == "to_english" else "整理现有英文内容,不承担翻译"
rule = f"{rule}\n\n本次任务方向:{direction}。"
if instruction:
rule = f"{rule}\n\n用户本次附加要求优先遵循:{instruction.strip()}"
timeout = ClientTimeout(total=config["ai_timeout_seconds"])
if ai_provider == "ollama":
base = (config.get("ai_base_url") or "http://127.0.0.1:11434").rstrip("/")
timeout = ClientTimeout(total=config[f"{prefix}_timeout_seconds"])
if prefix == "ollama":
# Ollama 只允许本机回环地址:这里再校验一次,挡住手改配置文件的情况
base = normalize_ai_base_url(config["ollama_base_url"])
if base.endswith("/v1"):
base = base[:-3].rstrip("/")
endpoint = base if base.endswith("/api/chat") else f"{base}/api/chat"
payload = {
"model": config["ai_model"],
"model": config["ollama_model"],
"stream": False,
"messages": [{"role": "system", "content": rule}, {"role": "user", "content": text}],
"options": {"temperature": config["ai_temperature"]},
"options": {"temperature": config["ollama_temperature"]},
}
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:
if not config.get("ai_base_url"):
# OpenAI 兼容:远端必须 https,本机回环才允许 http
base = normalize_ai_base_url(config["openai_base_url"], allow_remote=True)
if not base:
raise ValueError("尚未填写 OpenAI 兼容 API 地址")
headers = {"Content-Type": "application/json"}
if config.get("ai_api_key"):
headers["Authorization"] = f"Bearer {config['ai_api_key']}"
if config.get("openai_api_key"):
headers["Authorization"] = f"Bearer {config['openai_api_key']}"
payload = {
"model": config["ai_model"],
"temperature": config["ai_temperature"],
"model": config["openai_model"],
"temperature": config["openai_temperature"],
"messages": [{"role": "system", "content": rule}, {"role": "user", "content": text}],
}
async with ClientSession(timeout=timeout) as session:
async with session.post(openai_chat_endpoint(config["ai_base_url"]), headers=headers, json=payload) as response:
data = await response.json(content_type=None)
async with session.post(openai_chat_endpoint(base), headers=headers, json=payload) as response:
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:
raise ValueError("模型没有返回文本结果")
if len(result) > 50000:
raise ValueError("模型返回内容异常过长")
if action in {"translate_optimize", "optimize"}:
if action == "optimize_natural":
result = sanitize_natural_prompt(result)
elif action in {"translate_optimize", "optimize"}:
result = sanitize_anima_prompt(result)
return result
@@ -250,13 +294,13 @@ async def _call_ai_assistant(action, text, instruction, config, connection_test=
async def _call_configured_assistant(action, text, instruction="", connection_test=False):
config = assistant_store.config()
translate_service = config["translate_service"]
# “翻译并优化”“优化为 Anima”恒走 AI;翻译/解释在 translate_service=ai 时也走 AI
needs_ai = action in {"translate_optimize", "optimize"} or (
action in {"translate", "explain"} and translate_service == "ai"
# “翻译并优化”“优化”恒走 AI;翻译/解释在翻译服务就是某个 AI 后端时也走 AI
needs_ai = action in {"translate_optimize", "optimize", "optimize_natural"} or (
action in {"translate", "explain"} and translate_service in AI_SERVICES
)
if needs_ai:
return await _call_ai_assistant(action, text, instruction, config, connection_test)
# 以下只处理 translate/explain 且 translate_service != "ai"
# 以下只处理 translate/explain 且翻译服务不是 AI
if translate_service == "dictionary":
if connection_test:
return "纯词库模式可用"
@@ -269,7 +313,7 @@ async def _call_configured_assistant(action, text, instruction="", connection_te
return "百度翻译连接成功"
to_lang = "zh" if action == "explain" or translation_direction(text) == "to_chinese" else "en"
return await _baidu_translate(text, to_lang)
raise ValueError("翻译服务配置异常:请在助手设置里选择词库、百度或 AI")
raise ValueError("翻译服务配置异常:请在助手设置里选择词库、百度翻译、OpenAI 兼容接口或 Ollama")
@PromptServer.instance.routes.get("/bpi/session")
@@ -296,12 +340,54 @@ async def save_assistant_config(request):
return error_response(error)
@protected_route("post", "/bpi/assistant/reset-rules")
async def reset_assistant_rules(_request):
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:
return web.json_response({"success": True, "data": assistant_store.reset_rules()})
except OSError as error:
return error_response(error, 500)
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")
@@ -330,7 +416,7 @@ async def run_assistant(request):
action = str(payload.get("action", "translate")) if isinstance(payload, dict) else "translate"
text = str(payload.get("text", "")) if isinstance(payload, dict) else ""
instruction = str(payload.get("instruction", "")) if isinstance(payload, dict) else ""
if action not in {"translate", "explain", "translate_optimize", "optimize"}:
if action not in {"translate", "explain", "translate_optimize", "optimize", "optimize_natural"}:
raise ValueError("未知的助手操作")
if not text.strip():
raise ValueError("输入内容为空")
@@ -338,7 +424,12 @@ async def run_assistant(request):
raise ValueError("输入内容过长")
if len(instruction) > 20000:
raise ValueError("附加要求过长")
_consume_assistant_rate_limit()
# 百度翻译有自己的 QPS 节流器(_baidu_throttle),通用的「30 次 / 60 秒」
# 计数器不再重复计数——否则逐条翻译 30 个标签之后,一分钟内再点一次会被误拦。
# 只有真正走百度的 translate/explain 才豁免;optimize 恒走 AI,照旧计数。
goes_to_baidu = assistant_store.config()["translate_service"] == "baidu" and action in {"translate", "explain"}
if not goes_to_baidu:
_consume_assistant_rate_limit()
try:
await asyncio.wait_for(_assistant_slots.acquire(), timeout=0.25)
except asyncio.TimeoutError as error:
@@ -364,6 +455,28 @@ async def get_dictionary(_request):
return error_response(error, 500)
# 节点标签卡的颜色配置:色块池只读(用户可以直接编辑 data/token_colors.json),
# 按词库配色由侧边栏「颜色」区写回同一个文件。
@protected_route("get", "/bpi/token-colors")
async def get_token_colors(_request):
try:
return web.json_response({"success": True, "data": token_color_store.config()})
except OSError as error:
return error_response(error, 500)
@protected_route("post", "/bpi/token-colors/colors")
async def save_token_colors(request):
try:
payload = await _read_json(request, _MAX_JSON_BYTES)
if not isinstance(payload, dict):
raise ValueError("配色内容必须是对象")
data = token_color_store.save_colors(payload.get("pack_colors"), payload.get("anima_colors"))
return web.json_response({"success": True, "data": data})
except (OSError, ValueError) as error:
return error_response(error)
@protected_route("post", "/bpi/large/lookup")
async def lookup_large_dictionary(request):
try:
@@ -675,32 +788,61 @@ async def list_saved_prompts(_request):
return web.json_response({"success": True, "data": saved_prompt_store.list_prompts()})
async def _read_saved_prompt_form(request):
"""收藏表单:文本字段 + 一张可选图片 + 若干个 models(每个模型一个同名字段)。"""
form = {"name": None, "text": None, "note": None, "image_bytes": None, "models": []}
reader = await request.multipart()
async for part in reader:
if part.name == "image":
form["image_bytes"] = await part.read(decode=False)
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 == "models":
# 脏数据(非字符串、重复…)交给 store 的清洗逻辑兜底
form["models"].append(await part.text())
return form
@protected_route("post", "/bpi/saved-prompts")
async def create_saved_prompt(request):
if request.content_length is not None and request.content_length > _MAX_SAVED_PROMPT_BYTES:
return error_response("收藏内容过大(图片最大 5 MB)", 413)
name = text = note = None
image_bytes = None
try:
reader = await request.multipart()
async for part in reader:
if part.name == "image":
image_bytes = await part.read(decode=False)
elif part.name == "name":
name = await part.text()
elif part.name == "text":
text = await part.text()
elif part.name == "note":
note = await part.text()
form = await _read_saved_prompt_form(request)
except (ValueError, web.HTTPException) as error:
return error_response(f"读取表单失败:{error}")
try:
entry = saved_prompt_store.create_prompt(name, text, note=note, image_bytes=image_bytes)
entry = saved_prompt_store.create_prompt(
form["name"], form["text"], note=form["note"],
image_bytes=form["image_bytes"], models=form["models"],
)
return web.json_response({"success": True, "data": entry})
except ValueError as error:
return error_response(error)
@protected_route("post", "/bpi/saved-prompts/{prompt_id}")
async def update_saved_prompt(request):
if request.content_length is not None and request.content_length > _MAX_SAVED_PROMPT_BYTES:
return error_response("收藏内容过大(图片最大 5 MB)", 413)
try:
form = await _read_saved_prompt_form(request)
except (ValueError, web.HTTPException) as error:
return error_response(f"读取表单失败:{error}")
try:
entry = saved_prompt_store.update_prompt(
request.match_info["prompt_id"], form["name"], form["text"],
note=form["note"], image_bytes=form["image_bytes"], models=form["models"],
)
return web.json_response({"success": True, "data": entry})
except ValueError as error:
return error_response(error, 404)
@protected_route("delete", "/bpi/saved-prompts/{prompt_id}")
async def delete_saved_prompt(request):
try:
@@ -735,3 +877,63 @@ async def import_saved_prompts(request):
return web.json_response({"success": True, "data": result})
except ValueError as error:
return error_response(error)
# ---------------------------------------------------------------------------
# 模型名:给收藏弹窗的「适合模型」做自动补全
# ---------------------------------------------------------------------------
# 只取和提示词适配性有关的几类,别的(vae / upscale / controlnet…)混进来只会干扰补全
_MODEL_FOLDERS = ("checkpoints", "diffusion_models", "loras", "embeddings", "text_encoders")
_MAX_MODEL_NAMES = 3000
def collect_model_names():
try:
import folder_paths
except ImportError:
return []
items = []
seen = set()
for folder in _MODEL_FOLDERS:
try:
filenames = folder_paths.get_filename_list(folder)
except Exception:
continue
for filename in filenames:
# 子目录里的模型保留目录前缀(同一个系列往往靠目录名区分),只去掉扩展名
label = os.path.splitext(filename.replace("\\", "/"))[0]
if not label:
continue
marker = (label.casefold(), folder)
if marker in seen:
continue
seen.add(marker)
items.append({"name": label, "kind": folder})
if len(items) >= _MAX_MODEL_NAMES:
return sorted(items, key=lambda item: item["name"].casefold())
return sorted(items, key=lambda item: item["name"].casefold())
@protected_route("get", "/bpi/model-names")
async def list_model_names(_request):
# 首次调用要遍历 models 目录,放到线程里免得卡住事件循环
return web.json_response({"success": True, "data": await asyncio.to_thread(collect_model_names)})
# ---------------------------------------------------------------------------
# 重复检查:节点上输入一变就要立刻提示,和节点执行走同一个 find_duplicates
# ---------------------------------------------------------------------------
@protected_route("post", "/bpi/duplicate-check")
async def check_duplicate_tags(request):
try:
payload = await _read_json(request)
except (ValueError, web.HTTPBadRequest) as error:
return error_response(error)
report = find_duplicates(payload.get("text") or "")
return web.json_response(
{"success": True, "data": {"count": report["extra"], "terms": report["terms"]}},
headers={"Cache-Control": "no-store"},
)
+330 -40
View File
@@ -6,6 +6,7 @@ from pathlib import Path
from assistant_store import (
AssistantStore,
DEFAULT_NATURAL_RULE,
DEFAULT_OPTIMIZATION_RULE,
DEFAULT_TRANSLATE_OPTIMIZE_RULE,
DEFAULT_TRANSLATION_RULE,
@@ -15,9 +16,15 @@ from assistant_store import (
baidu_split_query,
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
@@ -37,41 +44,108 @@ class AssistantStoreTests(unittest.TestCase):
with tempfile.TemporaryDirectory() as root:
store = AssistantStore(Path(root) / "config")
public = store.update({
"translate_service": "ai",
"translate_service": "openai_compatible",
"ai_provider": "openai_compatible",
"ai_base_url": "https://example.test/v1",
"ai_model": "example-model",
"ai_api_key": "secret-value",
"translation_rule": "自定义仅翻译",
"translate_optimize_rule": "自定义翻译并优化",
"optimization_rule": "自定义优化",
"openai_base_url": "https://example.test/v1",
"openai_model": "example-model",
"openai_api_key": "secret-value",
"openai_translation_rule": "自定义仅翻译",
"openai_translate_optimize_rule": "自定义翻译并优化",
"openai_optimization_rule": "自定义优化",
})
self.assertTrue(public["ai_api_key_configured"])
self.assertNotIn("ai_api_key", public)
self.assertEqual(store.config()["ai_api_key"], "secret-value")
self.assertEqual(store.config()["translation_rule"], "自定义仅翻译")
self.assertEqual(store.config()["translate_optimize_rule"], "自定义翻译并优化")
self.assertTrue(public["openai_api_key_configured"])
self.assertNotIn("openai_api_key", public)
self.assertEqual(store.config()["openai_api_key"], "secret-value")
self.assertEqual(store.config()["openai_translation_rule"], "自定义仅翻译")
self.assertEqual(store.config()["openai_translate_optimize_rule"], "自定义翻译并优化")
# 另一套规则不受影响
self.assertEqual(store.config()["ollama_translation_rule"], DEFAULT_TRANSLATION_RULE)
self.assertNotIn("explanation_rule", public)
def test_openai_and_ollama_slots_are_independent(self):
with tempfile.TemporaryDirectory() as root:
store = AssistantStore(Path(root) / "config")
store.update({
"ai_provider": "openai_compatible",
"openai_base_url": "https://example.test/v1",
"openai_model": "cloud-model",
"openai_api_key": "secret-value",
"openai_translation_rule": "云端规则",
})
store.update({
"ai_provider": "ollama",
"ollama_model": "qwen3:8b",
"ollama_translation_rule": "本地规则",
})
config = store.config()
self.assertEqual(config["openai_model"], "cloud-model")
self.assertEqual(config["openai_api_key"], "secret-value")
self.assertEqual(config["openai_translation_rule"], "云端规则")
self.assertEqual(config["ollama_model"], "qwen3:8b")
self.assertEqual(config["ollama_base_url"], "http://127.0.0.1:11434")
self.assertEqual(config["ollama_translation_rule"], "本地规则")
def test_dictionary_translation_preserves_weight(self):
with tempfile.TemporaryDirectory() as root:
dictionary = self.make_dictionary(root)
self.assertEqual(dictionary_translate("长发,(微笑:1.2)", dictionary), "long hair, (smile:1.2)")
def test_api_key_is_bound_to_provider_and_base_url(self):
def test_api_key_is_bound_to_base_url(self):
with tempfile.TemporaryDirectory() as root:
store = AssistantStore(Path(root) / "config")
store.update({
"translate_service": "ai",
"translate_service": "openai_compatible",
"ai_provider": "openai_compatible",
"ai_base_url": "https://first.example/v1",
"ai_model": "example-model",
"ai_api_key": "secret-value",
"openai_base_url": "https://first.example/v1",
"openai_model": "example-model",
"openai_api_key": "secret-value",
})
self.assertEqual(store.config()["ai_api_key"], "secret-value")
public = store.update({"ai_base_url": "https://second.example/v1"})
self.assertFalse(public["ai_api_key_configured"])
self.assertEqual(store.config()["ai_api_key"], "")
self.assertEqual(store.config()["openai_api_key"], "secret-value")
public = store.update({"openai_base_url": "https://second.example/v1"})
self.assertFalse(public["openai_api_key_configured"])
self.assertEqual(store.config()["openai_api_key"], "")
def test_api_key_survives_provider_switch(self):
with tempfile.TemporaryDirectory() as root:
store = AssistantStore(Path(root) / "config")
store.update({"openai_base_url": "https://example.test/v1", "openai_model": "m", "openai_api_key": "secret-value"})
# 切到 Ollama 再切回来,密钥还在
store.update({"ai_provider": "ollama"})
self.assertEqual(store.config()["openai_api_key"], "secret-value")
store.update({"ai_provider": "openai_compatible"})
self.assertTrue(store.public_config()["openai_api_key_configured"])
def test_legacy_ai_translate_service_is_split_by_provider(self):
with tempfile.TemporaryDirectory() as root:
store = AssistantStore(Path(root) / "config")
store.config_dir.mkdir(parents=True, exist_ok=True)
store.config_path.write_text(json.dumps({
"installation_id": store.installation_id,
"schema_version": 4,
"translate_service": "ai",
"ai_provider": "ollama",
"ollama_model": "qwen3:8b",
}, ensure_ascii=False), encoding="utf-8")
config = store.config()
self.assertEqual(config["translate_service"], "ollama")
self.assertEqual(config["ai_provider"], "ollama")
def test_ai_translate_service_pins_the_matching_provider(self):
with tempfile.TemporaryDirectory() as root:
store = AssistantStore(Path(root) / "config")
store.update({
"translate_service": "openai_compatible",
"openai_base_url": "https://example.test/v1",
"openai_model": "m",
"openai_api_key": "secret-value",
})
public = store.update({"translate_service": "ollama"})
self.assertEqual(public["translate_service"], "ollama")
self.assertEqual(public["ai_provider"], "ollama")
back = store.update({"translate_service": "openai_compatible"})
self.assertEqual(back["translate_service"], "openai_compatible")
self.assertEqual(back["ai_provider"], "openai_compatible")
self.assertTrue(back["openai_api_key_configured"])
def test_new_installation_id_invalidates_saved_configuration(self):
with tempfile.TemporaryDirectory() as root:
@@ -79,25 +153,115 @@ class AssistantStoreTests(unittest.TestCase):
first_marker = Path(root) / "first-installation-id"
first = AssistantStore(config_dir, install_id_path=first_marker)
first.update({
"translate_service": "ai",
"translate_service": "openai_compatible",
"ai_provider": "openai_compatible",
"ai_base_url": "https://example.test/v1",
"ai_model": "example-model",
"ai_api_key": "secret-value",
"openai_base_url": "https://example.test/v1",
"openai_model": "example-model",
"openai_api_key": "secret-value",
})
second = AssistantStore(config_dir, install_id_path=Path(root) / "second-installation-id")
config = second.config()
self.assertEqual(config["translate_service"], "dictionary")
self.assertEqual(config["ai_base_url"], "")
self.assertEqual(config["ai_api_key"], "")
self.assertEqual(config["openai_base_url"], "")
self.assertEqual(config["openai_api_key"], "")
def test_remote_http_endpoint_is_rejected_but_local_http_is_allowed(self):
def test_openai_base_url_requires_https_or_loopback(self):
with tempfile.TemporaryDirectory() as root:
store = AssistantStore(Path(root) / "config")
with self.assertRaisesRegex(ValueError, "HTTPS"):
store.update({"ai_provider": "openai_compatible", "ai_base_url": "http://public.example/v1"})
result = store.update({"ai_provider": "ollama", "ai_base_url": "http://127.0.0.1:11434"})
self.assertEqual(result["ai_base_url"], "http://127.0.0.1:11434")
store.update({"openai_base_url": "http://public.example/v1"})
with self.assertRaisesRegex(ValueError, "完整的 http"):
store.update({"openai_base_url": "public.example/v1"})
self.assertEqual(store.update({"openai_base_url": "https://public.example/v1"})["openai_base_url"], "https://public.example/v1")
# 本机回环的 http 放行:本地 LM Studio 之类
self.assertEqual(store.update({"openai_base_url": "http://127.0.0.1:1234/v1"})["openai_base_url"], "http://127.0.0.1:1234/v1")
def test_ollama_base_url_must_be_loopback(self):
with tempfile.TemporaryDirectory() as root:
store = AssistantStore(Path(root) / "config")
self.assertEqual(store.config()["ollama_base_url"], "http://127.0.0.1:11434")
self.assertEqual(store.update({"ollama_base_url": "http://localhost:11434"})["ollama_base_url"], "http://localhost:11434")
self.assertEqual(store.update({"ollama_base_url": "http://[::1]:11434"})["ollama_base_url"], "http://[::1]:11434")
for blocked in ("http://192.168.1.20:11434", "http://10.0.0.5:11434", "https://ollama.example.test"):
with self.assertRaisesRegex(ValueError, "Ollama 地址只能是本机地址"):
store.update({"ollama_base_url": blocked})
def test_remote_https_rejects_private_and_reserved_addresses(self):
# https 也不能拐进内网:云元数据、私有网段、链路本地一律拒绝
for blocked in (
"https://169.254.169.254/latest",
"https://192.168.1.20/v1",
"https://10.0.0.5/v1",
"https://172.16.0.9/v1",
"https://[fd00::1]/v1",
):
with self.assertRaisesRegex(ValueError, "内网、保留或链路本地"):
normalize_ai_base_url(blocked, allow_remote=True)
# 公网域名与公网 IP 正常放行
self.assertEqual(normalize_ai_base_url("https://api.example.test/v1", allow_remote=True), "https://api.example.test/v1")
self.assertEqual(normalize_ai_base_url("https://8.8.8.8/v1", allow_remote=True), "https://8.8.8.8/v1")
# 本机回环不受这条限制
self.assertEqual(normalize_ai_base_url("http://127.0.0.1:1234/v1", allow_remote=True), "http://127.0.0.1:1234/v1")
def test_private_https_openai_url_is_rejected_on_save(self):
with tempfile.TemporaryDirectory() as root:
store = AssistantStore(Path(root) / "config")
with self.assertRaisesRegex(ValueError, "内网、保留或链路本地"):
store.update({"openai_base_url": "https://192.168.1.20/v1"})
self.assertEqual(store.update({"openai_base_url": "https://api.example.test/v1"})["openai_base_url"], "https://api.example.test/v1")
def test_normalize_ai_base_url_rejects_credentials_and_query(self):
with self.assertRaisesRegex(ValueError, "用户名或密码"):
normalize_ai_base_url("https://user:pass@example.test/v1", allow_remote=True)
with self.assertRaisesRegex(ValueError, "查询参数"):
normalize_ai_base_url("https://example.test/v1?x=1", allow_remote=True)
self.assertEqual(normalize_ai_base_url("https://example.test/v1/", allow_remote=True), "https://example.test/v1")
self.assertEqual(normalize_ai_base_url(""), "")
def test_v3_config_migrates_into_selected_provider_slot(self):
with tempfile.TemporaryDirectory() as root:
store = AssistantStore(Path(root) / "config")
store.config_dir.mkdir(parents=True, exist_ok=True)
store.config_path.write_text(json.dumps({
"installation_id": store.installation_id,
"schema_version": 3,
"translate_service": "ollama",
"ai_provider": "ollama",
"ai_base_url": "http://127.0.0.1:11434",
"ai_model": "qwen3:8b",
"ai_temperature": 0.7,
"ai_timeout_seconds": 120,
"translation_rule": "我的规则",
"translate_optimize_rule": "我的优化规则",
"optimization_rule": "我的整理规则",
}, ensure_ascii=False), encoding="utf-8")
config = store.config()
self.assertEqual(config["ollama_base_url"], "http://127.0.0.1:11434")
self.assertEqual(config["ollama_model"], "qwen3:8b")
self.assertEqual(config["ollama_temperature"], 0.7)
self.assertEqual(config["ollama_timeout_seconds"], 120)
self.assertEqual(config["openai_model"], "") # 另一套留默认
# 规则原本不分后端,两套都继承
self.assertEqual(config["ollama_translation_rule"], "我的规则")
self.assertEqual(config["openai_translation_rule"], "我的规则")
self.assertEqual(config["schema_version"], 4)
def test_v3_migration_keeps_openai_api_key(self):
with tempfile.TemporaryDirectory() as root:
store = AssistantStore(Path(root) / "config")
store.config_dir.mkdir(parents=True, exist_ok=True)
store.config_path.write_text(json.dumps({
"installation_id": store.installation_id,
"schema_version": 3,
"ai_provider": "openai_compatible",
"ai_base_url": "https://example.test/v1",
"ai_model": "example-model",
"ai_api_key": "secret-value",
}, ensure_ascii=False), encoding="utf-8")
config = store.config()
# 绑定的 provider 标记变了,密钥不能因此被当成「换了地址」清掉
self.assertEqual(config["openai_api_key"], "secret-value")
self.assertTrue(store.public_config()["openai_api_key_configured"])
def test_dictionary_translation_reports_unknown(self):
with tempfile.TemporaryDirectory() as root:
@@ -124,13 +288,80 @@ class AssistantStoreTests(unittest.TestCase):
self.assertEqual(translation_direction("杰作, best quality"), "to_english")
self.assertEqual(
sanitize_anima_prompt("```text\nmasterpiece,(smile:1.2);@artist!\n```"),
"masterpiece, (smile:1.2), @artist.",
"masterpiece, (smile:1.2), @artist",
)
# 模型把结果包成 JSON/代码字符串时会残留转义符,清洗后不应判废
self.assertEqual(
sanitize_anima_prompt('masterpiece, \\"blue eyes\\", 1girl'),
"masterpiece, blue eyes, 1girl",
)
self.assertEqual(
sanitize_anima_prompt("masterpiece\\n\\t1girl, "blue dress""),
"masterpiece, 1girl, blue dress",
)
# 模型用来断句的句点统一换成逗号,权重里的小数点必须原样保留
self.assertEqual(
sanitize_anima_prompt("masterpiece, 1girl, dragon slayer ornstein."),
"masterpiece, 1girl, dragon slayer ornstein",
)
self.assertEqual(
sanitize_anima_prompt("masterpiece, 1girl. A boy stands in the rain."),
"masterpiece, 1girl, A boy stands in the rain",
)
self.assertEqual(
sanitize_anima_prompt("(smile:1.2), (@rei (sanbonzakura):1.3), 1girl. A knight stands."),
"(smile:1.2), (@rei (sanbonzakura):1.3), 1girl, A knight stands",
)
self.assertEqual(
sanitize_anima_prompt("<lora:detail:0.8>, 1girl. A knight stands amid ruins, red banners."),
"<lora:detail:0.8>, 1girl, A knight stands amid ruins, red banners",
)
with self.assertRaisesRegex(ValueError, "不符合 Anima"):
sanitize_anima_prompt("masterpiece [smile]")
with self.assertRaisesRegex(ValueError, "仍包含中文"):
sanitize_anima_prompt("masterpiece, 杰作")
def test_natural_prompt_keeps_sentence_punctuation(self):
# 与 Anima 清洗的核心区别:句点是句子的一部分,不能被换成逗号
self.assertEqual(
sanitize_natural_prompt("A woman stands in the rain. She looks tired."),
"A woman stands in the rain. She looks tired.",
)
# 逗号是句子内部的停顿,原样保留
self.assertEqual(
sanitize_natural_prompt("A woman stands in the rain, tired and cold."),
"A woman stands in the rain, tired and cold.",
)
# 全角标点统一成半角,代码块围栏和转义符照旧清掉
self.assertEqual(
sanitize_natural_prompt('```text\nA woman stands in the rain,tired。\n```'),
"A woman stands in the rain, tired.",
)
self.assertEqual(
sanitize_natural_prompt('A woman \\"stands\\" in the rain'),
'A woman "stands" in the rain',
)
# 模型爱用列表;自然语言结果里列表符号要去掉,换行收成空格
self.assertEqual(
sanitize_natural_prompt("1. A woman stands in the rain\n2. She looks tired"),
"A woman stands in the rain She looks tired",
)
with self.assertRaisesRegex(ValueError, "仍包含中文"):
sanitize_natural_prompt("一名女子站在雨中")
def test_natural_rule_is_persisted_per_backend(self):
with tempfile.TemporaryDirectory() as root:
store = AssistantStore(config_dir=root, install_id_path=Path(root) / "install")
store.update({"openai_natural_rule": "我的自然语言规则", "ollama_natural_rule": "本地规则"})
config = store.config()
self.assertEqual(config["openai_natural_rule"], "我的自然语言规则")
self.assertEqual(config["ollama_natural_rule"], "本地规则")
# 另一套不受影响,且默认值能通过 public_config 取回
self.assertEqual(config["openai_optimization_rule"], DEFAULT_OPTIMIZATION_RULE)
public = store.public_config()
self.assertEqual(public["default_openai_natural_rule"], DEFAULT_NATURAL_RULE)
self.assertEqual(public["default_ollama_natural_rule"], DEFAULT_NATURAL_RULE)
def test_legacy_default_rules_are_upgraded(self):
with tempfile.TemporaryDirectory() as root:
store = AssistantStore(Path(root) / "config")
@@ -141,9 +372,10 @@ class AssistantStoreTests(unittest.TestCase):
"optimization_rule": LEGACY_DEFAULT_OPTIMIZATION_RULE,
}, ensure_ascii=False), encoding="utf-8")
config = store.config()
self.assertEqual(config["translation_rule"], DEFAULT_TRANSLATION_RULE)
self.assertEqual(config["translate_optimize_rule"], DEFAULT_TRANSLATE_OPTIMIZE_RULE)
self.assertEqual(config["optimization_rule"], DEFAULT_OPTIMIZATION_RULE)
for prefix in ("openai", "ollama"):
self.assertEqual(config[f"{prefix}_translation_rule"], DEFAULT_TRANSLATION_RULE)
self.assertEqual(config[f"{prefix}_translate_optimize_rule"], DEFAULT_TRANSLATE_OPTIMIZE_RULE)
self.assertEqual(config[f"{prefix}_optimization_rule"], DEFAULT_OPTIMIZATION_RULE)
def test_v17_default_translation_rule_is_upgraded_without_overwriting_custom_rules(self):
with tempfile.TemporaryDirectory() as root:
@@ -154,10 +386,11 @@ class AssistantStoreTests(unittest.TestCase):
"provider": "dictionary",
"translation_rule": V17_DEFAULT_TRANSLATION_RULE,
}, ensure_ascii=False), encoding="utf-8")
self.assertEqual(store.config()["translation_rule"], DEFAULT_TRANSLATION_RULE)
self.assertEqual(store.config()["openai_translation_rule"], DEFAULT_TRANSLATION_RULE)
store.update({"translation_rule": "我的自定义翻译规则"})
self.assertEqual(store.config()["translation_rule"], "我的自定义翻译规则")
store.update({"openai_translation_rule": "我的自定义翻译规则"})
self.assertEqual(store.config()["openai_translation_rule"], "我的自定义翻译规则")
self.assertEqual(store.config()["ollama_translation_rule"], DEFAULT_TRANSLATION_RULE)
def test_baidu_provider_saves_and_masks_secret(self):
with tempfile.TemporaryDirectory() as root:
@@ -172,13 +405,13 @@ class AssistantStoreTests(unittest.TestCase):
self.assertTrue(public["baidu_secret_key_configured"])
self.assertNotIn("baidu_secret_key", public)
self.assertEqual(store.config()["baidu_secret_key"], "baidu-secret")
self.assertFalse(public["ai_api_key_configured"])
self.assertFalse(public["openai_api_key_configured"])
def test_baidu_secret_is_retained_when_translate_service_changes(self):
with tempfile.TemporaryDirectory() as root:
store = AssistantStore(Path(root) / "config")
store.update({"translate_service": "baidu", "baidu_appid": "appid", "baidu_secret_key": "baidu-secret"})
public = store.update({"translate_service": "ai", "ai_provider": "openai_compatible", "ai_base_url": "https://example.test/v1", "ai_model": "m"})
public = store.update({"translate_service": "openai_compatible", "ai_provider": "openai_compatible", "openai_base_url": "https://example.test/v1", "openai_model": "m"})
self.assertTrue(public["baidu_secret_key_configured"])
self.assertEqual(store.config()["baidu_secret_key"], "baidu-secret")
@@ -190,6 +423,63 @@ class AssistantStoreTests(unittest.TestCase):
self.assertFalse(public["baidu_secret_key_configured"])
self.assertEqual(store.config()["baidu_secret_key"], "")
def test_baidu_qps_is_persisted_and_validated(self):
with tempfile.TemporaryDirectory() as root:
store = AssistantStore(Path(root) / "config")
# 新装默认按标准版 1 QPS,宁慢不错
self.assertEqual(store.config()["baidu_qps"], 1.0)
public = store.update({"translate_service": "baidu", "baidu_appid": "appid", "baidu_qps": 10})
self.assertEqual(public["baidu_qps"], 10.0)
self.assertEqual(store.config()["baidu_qps"], 10.0)
# 非数字直接拒绝,不静默回退,免得用户以为填上了
with self.assertRaises(ValueError):
store.update({"baidu_qps": "很快"})
# 越界收敛到上下限
self.assertEqual(store.update({"baidu_qps": 999})["baidu_qps"], 50.0)
self.assertEqual(store.update({"baidu_qps": 0})["baidu_qps"], 0.1)
def test_baidu_qps_falls_back_when_config_file_is_corrupt(self):
with tempfile.TemporaryDirectory() as root:
store = AssistantStore(Path(root) / "config")
store.update({"translate_service": "baidu"})
path = Path(root) / "config" / "assistant_settings.json"
raw = json.loads(path.read_text(encoding="utf-8"))
raw["baidu_qps"] = "not-a-number"
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())
+22
View File
@@ -41,6 +41,17 @@ class DictionaryStoreTests(unittest.TestCase):
self.assertTrue(store.user_path.exists())
self.assertEqual(store.user_tags()[0]["english"], "keep me")
def test_natural_language_pack_name_in_snapshot(self):
self.assertFalse(validate_tag({"english": "smile", "chinese": "微笑"})["natural"])
self.assertTrue(validate_tag({"english": "smile", "chinese": "微笑", "natural": True})["natural"])
temp, store = self.make_store()
self.addCleanup(temp.cleanup)
store.upsert({"english": "smile", "chinese": "微笑"})
store.upsert({"english": "a knight stands in the rain", "chinese": "一名骑士站在雨中", "natural": True})
names = {tag["english"]: tag["pack_name"] for tag in store.snapshot()["tags"]}
self.assertEqual(names["smile"], "个人词库")
self.assertEqual(names["a knight stands in the rain"], "自然语言词库")
def test_normalize_matches_spaces_and_underscores(self):
self.assertEqual(normalize_key(" Looking_At_Viewer "), "looking at viewer")
@@ -124,6 +135,17 @@ class DictionaryStoreTests(unittest.TestCase):
self.assertEqual(effective["smile"]["chinese"], "个人微笑")
self.assertEqual(effective["smile"]["pack_id"], "personal")
def test_natural_language_dictionary_is_a_separate_pack(self):
temp, store = self.make_pack_store()
self.addCleanup(temp.cleanup)
store.upsert({"english": "smile", "chinese": "个人微笑"})
store.upsert({"english": "a woman is standing in the rain", "chinese": "一个女人站在雨中", "natural": True})
effective = {tag["english"]: tag for tag in store.snapshot()["tags"]}
self.assertEqual(effective["smile"]["pack_id"], "personal")
self.assertEqual(effective["smile"]["pack_name"], "个人词库")
self.assertEqual(effective["a woman is standing in the rain"]["pack_id"], "natural")
self.assertEqual(effective["a woman is standing in the rain"]["pack_name"], "自然语言词库")
def test_community_pack_lifecycle(self):
temp, store = self.make_pack_store()
self.addCleanup(temp.cleanup)
+77
View File
@@ -0,0 +1,77 @@
import assert from "node:assert/strict";
import {
analyzePromptSyntax,
buildDictionaryIndex,
duplicateGroups,
normalizeKey,
parsePrompt,
removePromptToken,
} from "../js/parser.js";
import { setLanguageMode, t } from "../js/i18n.js";
const dictionary = buildDictionaryIndex([
{ english: "1girl", chinese: "一个女孩", verified: true },
{ english: "smile", chinese: "微笑", verified: true },
]);
// 同一个标签出现 3 次:权重写法不同的那次也算同一 key
const groups = duplicateGroups(parsePrompt("1girl, smile, (1girl:1.3), 1girl", dictionary));
assert.equal(groups.length, 1);
assert.equal(groups[0].key, normalizeKey("1girl"));
assert.equal(groups[0].tokens.length, 3);
// 顺序按原文,且三次都是普通标签
assert.equal(groups[0].tokens[0].raw.trim(), "1girl");
assert.equal(groups[0].tokens[1].raw.trim(), "(1girl:1.3)");
assert.ok(groups[0].tokens.every((token) => token.syntax === "tag"));
// key 归一:大小写差异算同一个标签(下划线是按空格算的,"sm_ile" 不等于 "smile")
assert.equal(duplicateGroups(parsePrompt("smile, Smile", dictionary)).length, 1);
// BREAK 与 <lora:…> 不算重复标签
assert.equal(duplicateGroups(parsePrompt("BREAK, BREAK", dictionary)).length, 0);
assert.equal(duplicateGroups(parsePrompt("<lora:a:1>, <lora:a:1>", dictionary)).length, 0);
// 问题行的「重复标签」提示与分组口径一致(同一个标签只提示一次)
const text = "1girl, 1girl, smile";
const issues = analyzePromptSyntax(text, parsePrompt(text, dictionary));
assert.equal(issues.filter((item) => item.code === "duplicate").length, 1);
assert.deepEqual(issues.find((item) => item.code === "duplicate").tokenKeys, [normalizeKey("1girl")]);
// 「唯一」的输出剔除:按出现次序删,第一次出现的那次要留着
let output = "1girl, smile, (1girl:1.3), 1girl";
for (;;) {
const parsed = parsePrompt(output, dictionary);
const target = duplicateGroups(parsed)
.filter((group) => group.key === normalizeKey("1girl"))
.flatMap((group) => group.tokens.slice(1))[0];
if (!target) break;
const step = removePromptToken(output, target);
assert.ok(step.changed);
output = step.text;
}
assert.equal(output.trim(), "1girl, smile");
// 界面文案:英文原文 + 中文译表双写,变量折成 {} 后要能命中
setLanguageMode("en");
assert.equal(t("Unique"), "Unique");
assert.equal(t("Duplicates {}").replace("{}", "3"), "Duplicates 3");
setLanguageMode("zh");
assert.equal(t("Unique"), "唯一");
assert.equal(t("Duplicates 3"), "重复 3");
assert.equal(t("Keep only the first “1girl” in the actual output; later occurrences stay here with a strikethrough"),
"只让第一次出现的「1girl」进入实际输出;其余照常显示在界面上(划线)");
assert.equal(t("Tags repeated in this prompt; click a chip to step through its occurrences, double-click to delete the one you stepped to; click empty space here to clear the selection"),
"提示词里重复的标签:单击胶囊依次跳到每一次出现,双击删掉跳到的那一处;点这里空白处取消选中");
assert.equal(t("“1girl” appears 3 times; click to jump to the first one"), "「1girl」出现了 3 次;单击跳到第一次");
// 开了「唯一」的胶囊是另一个提示语,里面嵌套的「Unique」要跟着翻成「唯一」
assert.equal(t("“1girl” appears 3 times; “Unique” is on, only the first reaches the actual output"),
"「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"), "「1girl」已设为唯一");
// 「Unique」在译文里会被二次翻译,tooltip 读起来是一致的中文
assert.equal(t("Query: “1girl” | “Unique” is on: only the first occurrence reaches the actual output"),
"查询「1girl」|已开「唯一」:只有第一次出现进入实际输出");
console.log("test_duplicates: ok");
+62 -17
View File
@@ -27,13 +27,34 @@ 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");
assert.equal(t("items 3 | recognized 1 | unknown 2 | pending 1"), "共 3 个标签|已识别 1|未收录 2|待确认 1");
assert.equal(t("Personal"), "个人");
// 过滤栏颜色下拉的第四项:按标签所在词库上色
assert.equal(t("By dictionary"), "按词库");
// 侧边栏「颜色」区:分区名、按钮、词条数、未收录项
assert.equal(t("Colors"), "颜色");
assert.equal(t("Restore defaults"), "恢复默认");
assert.equal(t("Random color"), "随机颜色");
assert.equal(t("Not indexed"), "未收录");
assert.equal(t("3 items"), "3 条");
assert.equal(t("952 items"), "952 条");
// 按 Anima 配色的分类短名(跟 anima_sorter.js 的槽位一一对应)
assert.equal(t("By Anima"), "按 Anima");
assert.equal(t("Quality"), "质量");
assert.equal(t("Expression"), "表情");
assert.equal(t("Camera"), "镜头");
assert.equal(t("Scene"), "场景");
assert.equal(t("Copyright"), "作品");
// 双击改色面板底部那句优先级说明
assert.equal(
t("This color has the lowest priority: it only shows in Default mode; other color modes override it."),
"这里改的颜色优先级最低:只有「默认」上色模式才显示,按收录、随机、按词库、按 Anima 都会盖住它。",
);
assert.equal(t("Highest"), "最高");
assert.equal(t("12 items | Local user | Always enabled, overrides same-name entries"), "12 条|本地用户|始终启用,覆盖同名条目");
@@ -53,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(
@@ -64,13 +85,20 @@ assert.equal(
// 自由文本变量必须用「"…"」包起来才能折叠成 {},否则整句永远匹配不上。
// 这里锁死「运行时真实形态」,防止以后有人把引号去掉或换成 ASCII 直引号。
for (const [source, expected] of [
["Text processing result: “Translate”", "文本处理结果:翻译"],
["applied “Translate & optimize”; press Ctrl+Z to undo", "已应用 翻译并优化;按 Ctrl+Z 可撤销"],
["“Optimize” done; please confirm in the preview window", "「优化」完成;请在预览窗口确认"],
["Optimize", "优化"],
["Fit height", "适应高度"],
["Natural Language Rule", "自然语言规则"],
["(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”", "已应用 翻译并优化"],
["Undone: “Sort to Anima order”", "已撤销:Sort to Anima order"],
["Redone: “Sort to Anima order”", "已重做:Sort to Anima order"],
["Running “Translate”…", "正在运行 翻译…"],
["“Translate”completed; result not yet synced to the English output above", "翻译已完成;结果尚未同步到上方英文输出"],
["⚠ Not indexed: “1girl”", "⚠ 未收录:1girl"],
["“Result still contains Chinese”; please fix it in the preview before confirming", "「结果仍包含中文」;请在预览里改好再确认"],
["Release failed: “network down”", "放行失败:network down"],
["Discard failed: “network down”", "放弃失败:network down"],
["Cannot pause for confirmation: “network down”", "无法暂停等待确认:network down"],
@@ -78,6 +106,12 @@ for (const [source, expected] of [
["Translation complete 5 items, rejected abnormal results 2 items", "翻译完成 5 条,已拒绝异常结果 2 条"],
["uses “12,345”", "使用 12,345 次"],
["tag: “1girl” | Anima format: (Tag:Weight)", "标签:1girl|Anima 格式:(标签:权重)"],
["Suspected natural language", "疑似自然语言"],
["Not indexed · suspected natural language", "未收录 · 疑似自然语言"],
[
"“overgrown with a variety of red wildflowers” ↔ “未收录” | suspected natural language: more than 3 spaces; confirm or split it into tags",
"overgrown with a variety of red wildflowers ↔ 未收录|疑似自然语言:空格超过 3 个;请确认或拆成标签",
],
["Node #3 · 12 tags", "节点 #3 · 12 个标签"],
["Translating: “1girl”", "正在翻译:1girl"],
["Re-translated “1girl”", "已重新翻译「1girl」"],
@@ -98,11 +132,19 @@ for (const [source, expected] of [
assert.equal(t(source), expected, `未命中:${source}`);
}
// 置信度徽章:这几个是 parser.js 直接产出的界面文本
assert.equal(t("Low · pending"), "低 · 待确认");
assert.equal(t("Medium · pending"), "中 · 待确认");
assert.equal(t("Medium · session-only"), "中 · 仅本次会话");
// 置信度:徽章只显示档位,细分原因放在 tooltip,两边都是 parser.js 直接产出的
assert.equal(t("Not judged"), "未判定");
assert.equal(t("Low"), "低");
assert.equal(t("Medium"), "中");
assert.equal(t("High"), "高");
assert.equal(t("Not in any dictionary, so it cannot be judged"), "词库里查不到,没法判断");
assert.equal(t("Machine translation, not saved to the dictionary or confirmed yet"), "机器翻译,还没存进词库,也没人工确认");
assert.equal(t("Syntax kept as-is; nothing to translate"), "语法保留项,本来就不用翻译");
assert.equal(t("In the dictionary, but not marked as verified"), "词库里有,但还没标成已校验");
assert.equal(t("In the dictionary, but no category set"), "词库里有,但还没填分类");
assert.equal(t("Changed for this session only; not saved to the dictionary"), "只改了本次会话,没存进词库");
// 带分类名的那条:变量被 normalizeKey 折成 {},键里也是 {}
assert.equal(t("In the dictionary, verified, category “发型”"), "已在词库里,已校验,分类「发型」");
// 收藏星标按钮(带符号的整串)
assert.equal(t("★ Unfavorite"), "★ 取消收藏");
@@ -118,16 +160,19 @@ assert.equal(t("(weight 1.5)"), "(权重 1.5)");
// 插件名:侧边栏标签写死中文,面板/关于框走译表,三处必须同一个名字
assert.equal(t("Bilingual Prompt Manager"), "提示词翻译与管理");
assert.equal(t("Bilingual Prompt Inspector"), "提示词翻译与管理");
// 「语言与外观 / 助手设置」两项的标题由 ComfyUI 渲染设置项名称,插件不再自带 h3
assert.equal(t("Appearance and language saved"), "外观与语言已保存");
// 两个本地预设按钮各有一套文案,别混
assert.equal(t("Use LM Studio Local Preset"), "使用 LM Studio 本地预设");
assert.equal(t("Use Ollama Local Preset"), "使用 Ollama 本地预设");
assert.equal(t("Takes effect after saving"), "保存后生效");
assert.equal(t("Ollama default URL filled; select a pulled model ID, then Save & Test"), "已填入 Ollama 默认地址;选择已拉取的模型 ID 后保存并测试");
// 确认对话框:默认确认按钮是 "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("Bilingual Prompt Inspector: Tag Manager, Dictionary, Packs & Assistant Settings"),
"提示词翻译与管理:标签管理、词库、词包与助手设置",
);
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");
// 状态提示(整句形态)
+50
View File
@@ -0,0 +1,50 @@
import assert from "node:assert/strict";
import { matchModelNames } from "../js/model_suggest.js";
const catalog = [
{ name: "anima-pencil-XL", kind: "checkpoints" },
{ name: "Anima", kind: "checkpoints" },
{ name: "animagine-xl-3.1", kind: "checkpoints" },
{ name: "Illustrious-anima", kind: "diffusion_models" },
{ name: "detail-tweaker", kind: "loras" },
];
const names = (items) => items.map((item) => item.name);
// 空输入不给候选,免得一聚焦就糊一屏
assert.deepEqual(matchModelNames(catalog, ""), []);
assert.deepEqual(matchModelNames(catalog, " "), []);
assert.deepEqual(matchModelNames(catalog, null), []);
// 前缀匹配排在包含匹配前面:输 A 先看到几个 anima 开头的大模型
assert.deepEqual(names(matchModelNames(catalog, "a")), [
"Anima",
"anima-pencil-XL",
"animagine-xl-3.1",
"detail-tweaker",
"Illustrious-anima",
]);
// 大小写不敏感
assert.deepEqual(names(matchModelNames(catalog, "ANIMA")), names(matchModelNames(catalog, "anima")));
// 位置相同时短名优先
assert.equal(names(matchModelNames(catalog, "anima"))[0], "Anima");
// 包含匹配也能命中中间的片段
assert.deepEqual(names(matchModelNames(catalog, "xl")), ["animagine-xl-3.1", "anima-pencil-XL"]);
// limit 生效,且不会改动原数组
assert.equal(matchModelNames(catalog, "a", 2).length, 2);
assert.equal(catalog.length, 5);
// 没命中返回空数组
assert.deepEqual(matchModelNames(catalog, "zzz"), []);
// 脏数据不炸
assert.deepEqual(matchModelNames(null, "a"), []);
assert.deepEqual(matchModelNames("nope", "a"), []);
assert.deepEqual(matchModelNames([{ name: 42 }, null], "a"), []);
console.log("model_suggest ok");
+53 -1
View File
@@ -6,8 +6,10 @@ import {
buildDictionaryIndex,
detectInputMode,
extractInstructionBody,
looksSuspiciousNatural,
parsePrompt,
removePromptToken,
replacePromptSpan,
replacePromptTokenWeight,
splitMixedText,
splitNaturalText,
@@ -19,7 +21,7 @@ import {
const source = "masterpiece, (looking at viewer:1.2), <lora:test:0.8>\nBREAK, hand_on_hip";
const dictionary = buildDictionaryIndex([
{ english: "masterpiece", chinese: "杰作", verified: true },
{ english: "masterpiece", chinese: "杰作", verified: true, category: "画质" },
{ english: "looking at viewer", chinese: "看向镜头", verified: true },
{ english: "hand on hip", chinese: "叉腰", verified: true },
]);
@@ -90,6 +92,26 @@ assert.equal(tagsAroundTokens[7].segmentKind, "natural");
assert.equal(tagsAroundTokens[8].term, "best quality");
assert.equal(tagsAroundTokens.at(-1).term, "newest");
// 开头的自然语言句很长、后面的标签又偏冷门(没有 masterpiece/best quality 那类信号词)时,
// 仍要把开头那句切出来,而不是把整段当成一个自然语言段
const coldTailSource = "A knight in dark gold armor holds a polearm spear amid overgrown ruins with red banners, cracked white stone, and red wildflowers under dramatic cinematic lighting\nultra-HD, masterwork, detailed, fs_ornstein, dark gold armor, breastplate, full armor, holding polearm, overgrown ruins, white and red theme";
const coldTailMode = detectInputMode(coldTailSource);
assert.equal(coldTailMode.mode, "mixed");
assert.equal(coldTailMode.naturalStart, 0);
const coldTailTokens = parsePrompt(coldTailSource, dictionary);
assert.equal(coldTailTokens.filter((token) => token.segmentKind === "natural").length, 1);
assert.equal(coldTailTokens.at(-1).term, "white and red theme");
// 空格超过 3 个(不含 3 个)就标成「疑似自然语言」
assert.equal(looksSuspiciousNatural("white and red theme"), false);
assert.equal(looksSuspiciousNatural("dragon slayer ornstein"), false);
assert.equal(looksSuspiciousNatural("overgrown with a variety of red wildflowers"), true);
assert.equal(looksSuspiciousNatural("and red wildflowers under dramatic cinematic lighting"), true);
const suspectedTokens = parsePrompt("1girl, white and red theme, overgrown with a variety of red wildflowers", dictionary);
assert.equal(suspectedTokens[0].suspectedNatural, false);
assert.equal(suspectedTokens[1].suspectedNatural, false);
assert.equal(suspectedTokens[2].suspectedNatural, true);
const commaRichNatural = "A woman walks home after the rain, feeling tired and cold, carrying a small umbrella, while the city grows quiet.";
assert.equal(detectInputMode(commaRichNatural).mode, "natural");
const shortClauseNatural = "She came home, tired, cold, wet, alone, at night.";
@@ -113,6 +135,12 @@ assert.equal(
"tags",
);
assert.equal(detectInputMode("A woman sits on a bed, asking for a hug.").mode, "natural");
// 权重里的句点不能把整串标签判定成自然语言
const weightedTagStream = "8K, ultra detailed, masterpiece, best quality, newest, absurdres, highres, very aesthetic, sharp focus, ultra-HD, amazing quality, masterwork, detailed, high detail, great atmosphere, amazing ambience, perfect composition, intricate, dynamic, rich color, 1boy, male focus, fs_ornstein, dragon slayer ornstein";
assert.equal(detectInputMode(`${weightedTagStream}, (@rei (sanbonzakura):1. )`).mode, "tags");
assert.equal(detectInputMode(`${weightedTagStream}, (@rei (sanbonzakura):1.\t`).mode, "tags");
assert.equal(detectInputMode(`${weightedTagStream}, (@rei (sanbonzakura):1.2). `).mode, "tags");
assert.equal(parsePrompt(`${weightedTagStream}, (@rei (sanbonzakura):1.2)`, dictionary).length, 25);
assert.equal(detectInputMode("whatever", "natural").reason, "Manual override");
const instructed = "翻译要求:转换为 Anima 标签\n正文:一位成年女性站在雨中";
assert.equal(detectInputMode(instructed).mode, "instruction");
@@ -155,6 +183,23 @@ assert.equal(replacePromptTokenWeight(weightSource, weightTokens[1], 4).changed,
const naturalWeightToken = parsePrompt("A woman is standing in the rain.", dictionary)[0];
assert.equal(replacePromptTokenWeight("A woman is standing in the rain.", naturalWeightToken, 1.2).changed, false);
// 「剪切出来改完再粘回去」:整段替换,段首段尾的空白是原文排版,要跟着补回去
const spanSource = "masterpiece, best quality, solo";
const spanTokens = parsePrompt(spanSource, dictionary);
assert.deepEqual(replacePromptSpan(spanSource, spanTokens[1].start, spanTokens[1].end, "high quality, ultra detailed"), {
text: "masterpiece, high quality, ultra detailed, solo",
cursor: 41,
changed: true,
});
assert.equal(replacePromptSpan(spanSource, spanTokens[1].start, spanTokens[2].end, "1girl").text, "masterpiece, 1girl");
// 清空 = 删除这一段,接缝处的逗号只留一个;开头那段没有左逗号就吃右边那个
assert.equal(replacePromptSpan(spanSource, spanTokens[1].start, spanTokens[2].end, "").text, "masterpiece");
assert.equal(replacePromptSpan(spanSource, spanTokens[0].start, spanTokens[0].end, "").text, "best quality, solo");
// 原样写回不算改动
assert.equal(replacePromptSpan(spanSource, spanTokens[1].start, spanTokens[1].end, "best quality").changed, false);
// 换行不是逗号,删掉标签后要留下,别把多行排版压平
assert.equal(replacePromptSpan("a\n1girl\n, b", 2, 7, "").text, "a\nb");
const diagnosticSource = "1girl, solo, 2girls, standing, sitting, red hair, red_hair, (smile:abc), [open";
const diagnosticTokens = parsePrompt(diagnosticSource, dictionary);
const diagnostics = analyzePromptSyntax(diagnosticSource, diagnosticTokens);
@@ -165,6 +210,13 @@ assert.ok(diagnostics.some((item) => item.code === "conflict"));
const knownToken = parsePrompt("masterpiece", dictionary)[0];
assert.equal(knownToken.confidence, "high");
// 可信度看词条整理得怎么样:填了分类才高,没填分类(含默认的「未分类」)只是中
const plain = buildDictionaryIndex([{ english: "masterpiece", chinese: "杰作", verified: true }]);
assert.equal(parsePrompt("masterpiece", plain)[0].confidence, "medium");
const uncategorized = buildDictionaryIndex([{ english: "masterpiece", chinese: "杰作", verified: true, category: "Uncategorized" }]);
assert.equal(parsePrompt("masterpiece", uncategorized)[0].confidence, "medium");
const unverified = buildDictionaryIndex([{ english: "masterpiece", chinese: "杰作", verified: false, category: "画质" }]);
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");
+62
View File
@@ -4,6 +4,7 @@ from pathlib import Path
from saved_prompt_store import (
MAX_IMAGE_BYTES,
MAX_MODELS,
SavedPromptStore,
sniff_image,
)
@@ -66,6 +67,67 @@ class SavedPromptStoreTests(unittest.TestCase):
store.create_prompt("大图", "1girl", image_bytes=PNG_HEADER + b"\0" * (MAX_IMAGE_BYTES + 1))
self.assertEqual(store.list_prompts(), [])
def test_create_cleans_models(self):
store = self.make_store()
entry = store.create_prompt("带模型", "1girl", models=[" Anima ", "anima", "Pony", 42, None, ""])
# 去空白、忽略大小写去重、只留字符串
self.assertEqual(entry["models"], ["Anima", "Pony"])
def test_create_caps_models(self):
store = self.make_store()
entry = store.create_prompt("很多模型", "1girl", models=[f"model-{index}" for index in range(MAX_MODELS + 5)])
self.assertEqual(len(entry["models"]), MAX_MODELS)
def test_create_without_models_stores_empty_list(self):
store = self.make_store()
self.assertEqual(store.create_prompt("无模型", "1girl", models="Anima")["models"], [])
self.assertEqual(store.create_prompt("无模型2", "1girl")["models"], [])
def test_export_and_import_keep_models(self):
store = self.make_store()
store.create_prompt("带模型", "1girl", models=["Anima"])
bundle = store.export_bundle()
self.assertEqual(bundle["prompts"][0]["models"], ["Anima"])
other = self.make_store()
other.import_bundle(bundle)
self.assertEqual(other.list_prompts()[0]["models"], ["Anima"])
def test_update_changes_fields_and_clears_note(self):
store = self.make_store()
entry = store.create_prompt("原名", "1girl", note="备注", models=["Anima"])
updated = store.update_prompt(entry["id"], "新名", "1girl, sunset", note=None, models=["Pony", "pony"])
self.assertEqual(updated["name"], "新名")
self.assertEqual(updated["text"], "1girl, sunset")
self.assertIsNone(updated["note"])
self.assertEqual(updated["models"], ["Pony"])
self.assertEqual(store.list_prompts()[0]["name"], "新名")
def test_update_keeps_image_when_no_new_one_given(self):
store = self.make_store()
entry = store.create_prompt("带图", "1girl", image_bytes=PNG_HEADER + b"payload")
updated = store.update_prompt(entry["id"], "带图", "1girl, sunset")
self.assertEqual(updated["image"], entry["image"])
self.assertTrue((store.store_dir / entry["image"]).is_file())
def test_update_replaces_image_and_deletes_old_file(self):
store = self.make_store()
entry = store.create_prompt("带图", "1girl", image_bytes=PNG_HEADER + b"payload")
old_path = store.store_dir / entry["image"]
updated = store.update_prompt(entry["id"], "带图", "1girl", image_bytes=JPEG_HEADER + b"payload")
self.assertEqual(updated["image"], f"{entry['id']}.jpg")
self.assertFalse(old_path.exists())
self.assertTrue((store.store_dir / updated["image"]).is_file())
def test_update_rejects_empty_text_and_missing_entry(self):
store = self.make_store()
entry = store.create_prompt("原名", "1girl")
with self.assertRaises(ValueError):
store.update_prompt(entry["id"], "空", " ")
with self.assertRaises(ValueError):
store.update_prompt("does-not-exist", "名", "1girl")
self.assertEqual(store.list_prompts()[0]["text"], "1girl")
def test_delete_removes_entry_and_image(self):
store = self.make_store()
entry = store.create_prompt("配图", "1girl", image_bytes=PNG_HEADER + b"payload")
+76
View File
@@ -0,0 +1,76 @@
import json
import tempfile
import unittest
from pathlib import Path
from token_color_store import (
DEFAULT_PRESETS,
DEFAULT_RANDOM_POOL,
TokenColorStore,
normalize_color,
)
class TokenColorStoreTests(unittest.TestCase):
def make_store(self, payload=None):
temp = tempfile.TemporaryDirectory()
self.addCleanup(temp.cleanup)
store = TokenColorStore(Path(temp.name))
if payload is not None:
store.path.write_text(json.dumps(payload), encoding="utf-8")
return store
def test_normalize_color_accepts_short_and_long_hex(self):
self.assertEqual(normalize_color("#abc"), "#aabbcc")
self.assertEqual(normalize_color("#AABBCC"), "#aabbcc")
self.assertIsNone(normalize_color("#abcde"))
self.assertIsNone(normalize_color("red"))
self.assertIsNone(normalize_color(None))
def test_missing_file_falls_back_to_defaults(self):
config = self.make_store().config()
self.assertEqual(config["presets"], DEFAULT_PRESETS)
self.assertEqual(config["random_pool"], DEFAULT_RANDOM_POOL)
self.assertEqual(config["pack_colors"], {})
self.assertEqual(config["anima_colors"], {})
def test_broken_or_partial_file_still_reads_pools(self):
config = self.make_store({"presets": ["#112233"], "random_pool": [], "pack_colors": "bad"}).config()
self.assertEqual(config["presets"], ["#112233"])
self.assertEqual(config["random_pool"], [])
self.assertEqual(config["pack_colors"], {})
def test_save_colors_writes_back_and_keeps_pools(self):
store = self.make_store()
config = store.save_colors(pack_colors={"personal": "#ABC", "camera": None})
self.assertEqual(config["pack_colors"], {"personal": "#aabbcc"})
self.assertEqual(config["presets"], DEFAULT_PRESETS)
written = json.loads(store.path.read_text(encoding="utf-8"))
self.assertEqual(written["pack_colors"], {"personal": "#aabbcc"})
def test_save_colors_rejects_bad_input(self):
store = self.make_store()
with self.assertRaises(ValueError):
store.save_colors(pack_colors={"personal": "not-a-color"})
with self.assertRaises(ValueError):
store.save_colors(anima_colors={"bad id!": "#112233"})
with self.assertRaises(ValueError):
store.save_colors(pack_colors=[])
def test_empty_value_clears_existing_pack_color(self):
store = self.make_store({"pack_colors": {"personal": "#112233"}})
self.assertEqual(store.config()["pack_colors"], {"personal": "#112233"})
self.assertEqual(store.save_colors(pack_colors={"personal": ""})["pack_colors"], {})
def test_anima_colors_are_saved_alongside_pack_colors(self):
store = self.make_store({"pack_colors": {"personal": "#112233"}})
config = store.save_colors(anima_colors={"quality": "#ABC", "expression": ""})
self.assertEqual(config["anima_colors"], {"quality": "#aabbcc"})
# 只传了一类,另一类保持原样
self.assertEqual(config["pack_colors"], {"personal": "#112233"})
self.assertEqual(store.config()["anima_colors"], {"quality": "#aabbcc"})
def test_save_colors_without_payload_is_rejected(self):
store = self.make_store()
with self.assertRaises(ValueError):
store.save_colors()
+130
View File
@@ -0,0 +1,130 @@
"""节点标签卡的配色配置。
色块池(presets / random_pool)只读,用户可以直接编辑 data/token_colors.json 增删色块;
按词库配色(pack_colors)由界面写回同一个文件,格式不对时回退默认,不影响功能。
"""
import json
import os
import re
import tempfile
from pathlib import Path
DEFAULT_PRESETS = [
"#6b9b78", "#9b8b6b", "#6b7b9b", "#9b6b8b",
"#8b9b6b", "#6b9b9b", "#9b7b6b", "#7b6b9b",
]
DEFAULT_RANDOM_POOL = [
"#5b8a72", "#8a725b", "#5b6e8a", "#8a5b7a",
"#7a8a5b", "#5b8a8a", "#8a6b5b", "#6b5b8a",
]
# 未命中任何词库的标签也当成一个可配色的来源,key 固定为 unindexed
UNINDEXED_PACK_ID = "unindexed"
MAX_COLOR_ENTRIES = 200
PACK_ID_PATTERN = re.compile(r"[A-Za-z0-9_-]{1,64}")
HEX_COLOR_PATTERN = re.compile(r"#[0-9a-fA-F]{6}")
SHORT_HEX_COLOR_PATTERN = re.compile(r"#[0-9a-fA-F]{3}")
def normalize_color(value):
"""把 #RGB / #RRGGBB 统一成小写的 #rrggbb,认不出来返回 None。"""
text = str(value or "").strip()
if HEX_COLOR_PATTERN.fullmatch(text):
return text.lower()
if SHORT_HEX_COLOR_PATTERN.fullmatch(text):
return "#" + "".join(char * 2 for char in text[1:]).lower()
return None
class TokenColorStore:
def __init__(self, data_dir=None):
self.data_dir = Path(data_dir or Path(__file__).resolve().parent / "data")
self.path = self.data_dir / "token_colors.json"
@staticmethod
def defaults():
return {
"presets": list(DEFAULT_PRESETS),
"random_pool": list(DEFAULT_RANDOM_POOL),
"pack_colors": {},
"anima_colors": {},
}
def config(self):
"""读取配置:色块池原样透传,缺字段或文件坏了就回退默认。"""
payload = self._read()
if payload is None:
return self.defaults()
config = self.defaults()
for key in ("presets", "random_pool"):
values = payload.get(key)
if isinstance(values, list):
config[key] = values
for key in ("pack_colors", "anima_colors"):
values = payload.get(key)
if isinstance(values, dict):
config[key] = self._clean_map(values)
return config
@staticmethod
def _clean_map(raw):
cleaned = {}
for key, value in raw.items():
color = normalize_color(value)
if color and PACK_ID_PATTERN.fullmatch(str(key)):
cleaned[str(key)] = color
return cleaned
@staticmethod
def _validated_map(raw, label):
if not isinstance(raw, dict):
raise ValueError(f"{label}必须是对象")
if len(raw) > MAX_COLOR_ENTRIES:
raise ValueError(f"{label}最多 {MAX_COLOR_ENTRIES} 项")
cleaned = {}
for raw_key, value in raw.items():
key = str(raw_key).strip()
if not PACK_ID_PATTERN.fullmatch(key):
raise ValueError(f"分类ID格式不正确:{key}")
if value in (None, ""):
continue
color = normalize_color(value)
if color is None:
raise ValueError(f"颜色必须是 #RGB 或 #RRGGBB 十六进制值:{value}")
cleaned[key] = color
return cleaned
def save_colors(self, pack_colors=None, anima_colors=None):
"""整体覆盖某一类配色:传空值表示该项恢复默认。只更新传进来的那一类。"""
if pack_colors is None and anima_colors is None:
raise ValueError("没有要保存的配色")
config = self.config()
if pack_colors is not None:
config["pack_colors"] = self._validated_map(pack_colors, "按词库配色")
if anima_colors is not None:
config["anima_colors"] = self._validated_map(anima_colors, "按 Anima 配色")
self._write(config)
return config
def _read(self):
if not self.path.exists():
return None
try:
payload = json.loads(self.path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return None
return payload if isinstance(payload, dict) else None
def _write(self, payload):
self.path.parent.mkdir(parents=True, exist_ok=True)
fd, temp_name = tempfile.mkstemp(prefix="token_colors_", suffix=".json", dir=self.path.parent)
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.path)
finally:
if os.path.exists(temp_name):
os.unlink(temp_name)
+95
View File
@@ -0,0 +1,95 @@
"""一键跑全部测试(JS + Python)。
用法(在插件目录下,或者任何地方都行):
X:\\webui\\comfyui\\ComfyUI\\.venv\\Scripts\\python.exe tools\\main_test.py
会跑两轮:
1. tests\\*.mjs —— JS 测试
2. tests\\test_*.py —— Python 测试(PYTHONPATH 自己设好,不用手动加)
两轮都会实时输出,能看着它一条条走完。最后给一个汇总,
有失败的会单独列出来。全部通过退出码 0,有失败 1。
注意:必须用 ComfyUI 的 venv 跑(server.py 要 aiohttp),
用别的 Python 会出现 "No module named 'aiohttp'" 且用例数偏少。
"""
import os
import pathlib
import subprocess
import sys
# 这个脚本放在 tools/ 下(该目录被 .comfyignore 排除,不进发布包),
# 所以插件根目录在上一级。放根目录时也能用,这里两种位置都认。
ROOT = pathlib.Path(__file__).resolve().parent
if not (ROOT / "tests").is_dir():
ROOT = ROOT.parent
TESTS = ROOT / "tests"
# 不设成行缓冲的话,接管道(python main_test.py | tail)时我们自己的输出会被攒到最后,
# 跟子进程的实时输出插在一起,看起来顺序全乱。
sys.stdout.reconfigure(line_buffering=True)
def find_node():
"""找 node 可执行文件。
managed node 的目录号会随环境升级变(22.22.2-3 → 22.22.2-6),
写死路径会某天突然全部 FAIL,所以这里动态取最新的那个。
"""
versions = pathlib.Path.home() / ".workbuddy" / "binaries" / "node" / "versions"
if versions.is_dir():
candidates = [p for p in versions.iterdir() if p.is_dir() and p.name[:1].isdigit()]
for candidate in sorted(candidates, reverse=True):
exe = candidate / ("node.exe" if os.name == "nt" else "node")
if exe.exists():
return str(exe)
return "node"
def run_js():
files = sorted(TESTS.glob("*.mjs"))
if not files:
print("[js] 没有找到 tests/*.mjs,跳过")
return []
node = find_node()
print(f"\n=== JS 测试({len(files)} 个)用 {node} ===")
failed = []
for path in files:
result = subprocess.run([node, str(path)], cwd=ROOT)
if result.returncode == 0:
print(f" PASS {path.name}")
else:
print(f" FAIL {path.name}")
failed.append(path.name)
return failed
def run_python():
print(f"\n=== Python 测试 用 {sys.executable} ===")
env = dict(os.environ, PYTHONPATH=str(ROOT))
result = subprocess.run(
[sys.executable, "-m", "unittest", "discover", "-s", "tests", "-p", "test_*.py"],
cwd=ROOT,
env=env,
)
return [] if result.returncode == 0 else ["python 测试(见上面的输出)"]
def main():
if not TESTS.is_dir():
sys.exit(f"[test] 找不到 {TESTS}")
failed = run_js() + run_python()
print("\n" + "=" * 46)
if failed:
print(f"有 {len(failed)} 项失败:")
for name in failed:
print(f" - {name}")
sys.exit(1)
print("全部通过")
if __name__ == "__main__":
main()