Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
575ab1814f | ||
|
|
7759128dac | ||
|
|
2cf72b1a10 | ||
|
|
194a6a8206 | ||
|
|
d38de335bd | ||
|
|
f2d3de3659 | ||
|
|
464a6c79dc | ||
|
|
e2b2834298 | ||
|
|
ae96399bdd | ||
|
|
84bbc97cba | ||
|
|
b09ce43898 | ||
|
|
5bb839964a | ||
|
|
1233c9f0c9 | ||
|
|
ca916f5b82 | ||
|
|
6565157f2a | ||
|
|
737458163b | ||
|
|
d6c51f9de2 | ||
|
|
b8218f7e10 | ||
|
|
01123cef5c | ||
|
|
a8b7aca8f8 | ||
|
|
c72422e648 | ||
|
|
ff8395302a | ||
|
|
cab56b2f60 | ||
|
|
cd55defc87 | ||
|
|
79dc337c0e | ||
|
|
642837842c | ||
|
|
13c2fecce2 | ||
|
|
7cb8d80b02 | ||
|
|
ed0ab5eee2 | ||
|
|
d45a9d9e63 | ||
|
|
35ed25d69c | ||
|
|
d3e6d7edce | ||
|
|
43a049b43f | ||
|
|
78924e8244 | ||
|
|
905d165348 | ||
|
|
3806ae7ae1 | ||
|
|
45eb64a5bf | ||
|
|
c19242700e | ||
|
|
6fe88a6bae | ||
|
|
238612920c | ||
|
|
84d3430576 | ||
|
|
125654864c | ||
|
|
97c87043d7 | ||
|
|
36daee2bed |
@@ -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 }}
|
||||
@@ -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"
|
||||
|
||||
+155
-5
@@ -1,5 +1,158 @@
|
||||
# 更新记录
|
||||
|
||||
## 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」改为直接处理英文提示词,出结果后先弹预览确认再写回。
|
||||
@@ -23,6 +176,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,百度密钥只在显式清除时才清。
|
||||
@@ -31,7 +185,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` 撤销历史。
|
||||
- 排序按“提取—插入”建模:保留原有逗号、空格与换行排版,多行结构不被合并;自然语言块整体移动,内部不拆分;重复标签按出现位置独立移动。
|
||||
- 明细表筛选视图(未知/机器译/个人词库/收藏)与自然语言、指令模式下不启用拖拽,避免局部顺序被误解为完整顺序。
|
||||
@@ -52,16 +205,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`)把「“…”」片段和数字折叠成 `{}` 占位符再回填,模板字符串也能整句翻译。
|
||||
@@ -71,7 +221,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
@@ -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` 以外的字段也会触发发布,同样会因版本已存在而失败——非必要别动那些字段。
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# ComfyUI 双语提示词管理器 v1.2.10
|
||||
# ComfyUI 双语提示词管理器 v2.1.9
|
||||
|
||||
> ComfyUI 双语提示词节点:把英文提示词拆成可点击的标签,边写边看中文解释、调顺序、试效果。
|
||||
|
||||
@@ -67,14 +67,16 @@
|
||||
<td>提示词里的换行会渲染成行尾的 <code>↵</code> 块,标签区真的断行,中英两栏行数对齐。换行块本身也能拖动。</td>
|
||||
</tr>
|
||||
</table>
|
||||

|
||||

|
||||
|
||||
### 翻译与整理
|
||||
|
||||
| 能力 | 说明 |
|
||||
|---|:-:|
|
||||
| **四种处理方式** | 仅翻译 / 翻译并优化 / 优化为 Anima / 按 Anima 官方顺序整理(不新增标签,只重排分段) |
|
||||
| **自动识别模式** | 自动区分纯标签、纯自然语言、混合文本和翻译指令,不用手选模式 |
|
||||
| **四种处理方式** | 仅翻译 / 翻译并优化 / 优化 / 按 Anima 官方顺序整理(不新增标签,只重排分段) |
|
||||
| **优化按模式分派** | 标签模式下「优化」整理现有英文标签流(只吃英文);自然语言模式下改写为英文自然语句,中文会先译成英文。两种模式各用一套规则,可在助手设置里分别自定义 |
|
||||
| **自动识别模式** | 自动区分纯标签、纯自然语言、混合文本和翻译指令,也可以在明细表上方手动指定;指定的模式跟着节点保存 |
|
||||
| **自然语言模式** | 模式切到「自然语言」后,「按 Anima 顺序排序」自动隐藏——整段句子没有标签流可排 |
|
||||
| **提示词诊断** | 自动标出可疑权重、重复标签、语法问题 |
|
||||
| **快捷添加** | 底部的快捷输入框:写中文自动翻译成英文标签后追加,写英文标签直接追加;`Enter` 添加、`Shift+Enter` 换行、`Ctrl+Enter` 翻译并优化 |
|
||||
|
||||
@@ -85,7 +87,7 @@
|
||||
| **多来源词库** | 内置词库 + 个人词库 + 社区词库包,每条解释都会标注来源 |
|
||||
| **概念搜索** | 搜「丝袜」能返回连裤袜、长筒袜、过膝袜等相关标签,支持滚动分页加载 |
|
||||
| **大型 Danbooru 词库(可选)** | 按需安装,覆盖面更广,不装也不影响其它功能 |
|
||||
| **翻译服务可切换** | 纯词库(离线、免费)/ 百度翻译 / AI(OpenAI 兼容 API 或 Ollama)。翻译与解释可选任意一种;「翻译并优化」「优化为 Anima」恒定走 AI |
|
||||
| **翻译服务可切换** | 纯词库(离线、免费)/ 百度翻译 / OpenAI 兼容 API / Ollama。翻译与解释可选任意一种;「翻译并优化」「优化」恒定走 AI |
|
||||
| **密钥互不干扰** | AI 与百度密钥独立留存,切换翻译服务不会清空;均不写入工作流、不返回浏览器 |
|
||||
|
||||
### 上游提示词接管
|
||||
@@ -122,7 +124,9 @@
|
||||
|
||||
### 侧边栏管理面板
|
||||
|
||||
标签管理、词库维护、词库包、助手设置、收藏五个分页都收在侧边栏的「双语提示词管理」面板里,节点本身保持简洁。面板里的改动会实时同步到页面上所有同类节点,不需要刷新页面。
|
||||
标签管理、词库维护、词库包、收藏四个分页收在侧边栏的「双语提示词管理」面板里,节点本身保持简洁。面板里的改动会实时同步到页面上所有同类节点,不需要刷新页面。
|
||||
|
||||
助手设置不在这个面板里:它在 ComfyUI 的「设置」面板中,「设置 → 双语提示词检查器」下有两项 ——「语言与外观」和「助手设置」,表单都直接铺在设置面板右侧。
|
||||
|
||||
---
|
||||
|
||||
@@ -132,7 +136,7 @@
|
||||
|
||||
节点上可以大致分成四块:
|
||||
|
||||
1. **英文原文(实际输出)**——节点真正输出给下游的文本。上面有「编辑文本 / 完成」「清空」「插入换行」「收藏」「优化为 Anima」「按 Anima 顺序排序」几个按钮。
|
||||
1. **英文原文(实际输出)**——节点真正输出给下游的文本。上面有「编辑文本 / 完成」「清空」「插入换行」「收藏」「优化」「按 Anima 顺序排序」几个按钮。
|
||||
2. **中文对照区**——英文标签逐条对应的中文,只用于阅读,不进入输出,也不会传给下游。
|
||||
3. **明细表**——每行是一个标签:来源、翻译、权重、分类,右侧按行状态给出操作(翻译 / 确认并存 / 重新翻译 / 编辑中文 / 收藏 / 复制 / 删除个人词条)。上方有搜索框、模式选择、颜色模式、筛选(全部 / 未知 / 机器翻译 / 个人词库 / 收藏)和问题提示。
|
||||
4. **快捷添加**——底部输入框,中文进英文出(自动翻译后追加),英文则直接追加。
|
||||
@@ -141,15 +145,24 @@
|
||||
|
||||
## 安装
|
||||
|
||||
**用 ComfyUI-Manager(推荐)**:在 Manager 的节点列表里搜索 `Bilingual Prompt Inspector` 安装;也可以走 Manager 的「Install via Git URL」,填入下面的仓库地址。
|
||||
**用 ComfyUI-Manager(推荐)**:在 Manager 的节点列表里搜索 `Bilingual Prompt Inspector` 安装;也可以走 Manager 的「Install via Git URL」,填入仓库的仓库地址。
|
||||
|
||||
**使用comfy-cli安装**:
|
||||
|
||||
```bash
|
||||
cd ComfyUI/
|
||||
comfy node install prompt-translator-manager
|
||||
```
|
||||
|
||||
**手动安装**:
|
||||
|
||||
```bash
|
||||
cd ComfyUI/custom_nodes/
|
||||
git clone https://github.com/mengshengzhijie/ComfyUI-Bilingual-Prompt-Inspector.git
|
||||
git clone --depth 1 https://github.com/mengshengzhijie/ComfyUI-Bilingual-Prompt-Inspector.git
|
||||
```
|
||||
|
||||
不过这里如果有动手能力的话推荐使用git安转,因为现在就我和我的朋友在使用,有时候修复bug和制作新功能我是总结下来然后一步一步的修复。直到把这次收录的bug和建议给完成之后才bump版本。
|
||||
|
||||
重启 ComfyUI,在节点菜单的 **text/prompt** 分类里找到 **提示词翻译与管理(英文输出)**(英文界面下显示为 Prompt Translator & Manager),也可以直接搜索「提示词」。
|
||||
|
||||
两个常见坑:
|
||||
@@ -168,7 +181,7 @@ git clone https://github.com/mengshengzhijie/ComfyUI-Bilingual-Prompt-Inspector.
|
||||
3. 在标签视图里逐个检查:点标签看中文解释,双击调权重,拖动手柄排顺序。
|
||||
4. 想确认某个标签到底有没有用?点眼睛图标隐藏它,重新出图对比,再一键恢复。
|
||||
|
||||

|
||||

|
||||
|
||||
---
|
||||
|
||||
@@ -176,13 +189,19 @@ git clone https://github.com/mengshengzhijie/ComfyUI-Bilingual-Prompt-Inspector.
|
||||
|
||||
### 翻译服务配置
|
||||
|
||||
默认走纯词库,不联网也能用。需要机器翻译或 AI 优化时,打开侧边栏「双语提示词管理」面板的「助手设置」分页。面板分三段:
|
||||
默认走纯词库,不联网也能用。需要机器翻译或 AI 优化时,打开「设置 → 双语提示词检查器 → 助手设置」。这一项分三段:
|
||||
|
||||
| 分段 | 内容 |
|
||||
|---|---|
|
||||
| **翻译服务** | 翻译 / 解释按此选择:纯词库(离线、免费)/ 百度翻译(需自行申请开放平台的 APP ID 与密钥)/ AI(走下面的 AI 配置) |
|
||||
| **翻译服务** | 翻译 / 解释按此选择:纯词库(离线、免费)/ 百度翻译(需自行申请开放平台的 APP ID 与密钥)/ OpenAI 兼容接口 / Ollama(本地)。选后面两个时,它们本身就是下面的 AI 服务 |
|
||||
| **百度翻译** | 独立配置 APP ID 与密钥,切换翻译服务不会清空 |
|
||||
| **AI 服务** | 后端(OpenAI 兼容 / Ollama)、API 地址与模型、可选 API Key、温度、超时。「翻译并优化」「优化为 Anima」恒定走这里 |
|
||||
| **AI 服务** | 「翻译并优化」「优化」恒定走这里。翻译服务选了 OpenAI 兼容 / Ollama 时,下面直接就是那个后端的配置;选纯词库 / 百度翻译时,这里多出一个「AI 后端」下拉,决定优化用哪一套。两个后端各存一套配置,切换不清空: |
|
||||
| ↳ OpenAI 兼容 | API 地址、模型、可选 API Key、温度、超时,另有「使用 LM Studio 本地预设」一键填入 `1234/v1`。远端地址必须 https,本机回环(127.0.0.1 / localhost)才允许 http;内网、保留、链路本地地址一律拒绝 |
|
||||
| ↳ Ollama | API 地址、模型、温度、超时,没有密钥,另有「使用 Ollama 本地预设」一键填入 `11434`。地址只能是本机回环地址,其它一律拒绝 |
|
||||
|
||||
模型名称旁边的「获取模型」按钮会拿输入框里当前填的地址问一次服务端有哪些模型(OpenAI 兼容问 `/models`,Ollama 问 `/api/tags`),拉到的名字直接填进输入框自带的下拉里——点输入框里的箭头就能选,也可以完全不管它自己手写。再点一次按钮就是重新拉取。列表只在本次会话内有效、地址一改就作废,不写进配置文件。密钥以输入框里填的为准,输入框留空就用你已经保存的那一份;无论哪种都只随这一次请求发出,不会随着设置一起回传给浏览器。服务端返回看不懂的内容(纯文本报错、登录页之类)时会原话告诉你,不会甩一段 JSON 报错。
|
||||
|
||||
两个后端还各有一套规则(翻译 / 翻译并优化 / 优化 / 自然语言):本地小模型与云端大模型要写的提示词往往不一样,分开存可以随时切换后端而不用重改规则。其中「自然语言」那套只在自然语言模式下被「优化」使用,结果按完整英文句子保留标点,不套 Anima 的标点限制。地址不合规时会在发出请求前直接报错。
|
||||
|
||||
### 大型词库(可选)
|
||||
|
||||
@@ -190,7 +209,7 @@ git clone https://github.com/mengshengzhijie/ComfyUI-Bilingual-Prompt-Inspector.
|
||||
|
||||
### 界面语言与外观
|
||||
|
||||
界面文案跟随 ComfyUI 的语言设置,也可以在侧边栏面板的「助手设置」分页里固定为中文或英文。外观同样可在「跟随 ComfyUI / 暗色 / 亮色」之间切换。
|
||||
界面文案跟随 ComfyUI 的语言设置,也可以在「设置 → 双语提示词检查器 → 语言与外观」里固定为中文或英文。外观同样可在「跟随 ComfyUI / 暗色 / 亮色」之间切换,两项改完立即生效并单独保存。
|
||||
|
||||
---
|
||||
|
||||
@@ -204,7 +223,7 @@ git clone https://github.com/mengshengzhijie/ComfyUI-Bilingual-Prompt-Inspector.
|
||||
<details>
|
||||
<summary><b>点翻译提示要先配置翻译服务</b></summary>
|
||||
|
||||
纯词库模式下只能查词库,不会翻译。想翻译请在侧边栏面板的「助手设置」分页里选一个翻译服务(百度或 AI)。
|
||||
纯词库模式下只能查词库,不会翻译。想翻译请在「设置 → 双语提示词检查器 → 助手设置」里选一个翻译服务(百度翻译、OpenAI 兼容接口或 Ollama)。
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
||||
+21
@@ -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
@@ -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"
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 726 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 156 KiB |
+332
-98
@@ -46,17 +46,30 @@ 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": "",
|
||||
@@ -66,11 +79,24 @@ DEFAULT_CONFIG = {
|
||||
"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,企业认证更高。
|
||||
@@ -78,6 +104,8 @@ BAIDU_TRANSLATE_ENDPOINT = "https://fanyi-api.baidu.com/api/trans/vip/translate"
|
||||
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):
|
||||
@@ -110,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("/")
|
||||
@@ -124,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 "")
|
||||
@@ -157,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)
|
||||
@@ -165,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(",.():@_-'<>%+/&")
|
||||
@@ -187,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
|
||||
@@ -253,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)}"
|
||||
@@ -273,7 +464,7 @@ class AssistantStore:
|
||||
return {
|
||||
**DEFAULT_CONFIG,
|
||||
"installation_id": self.installation_id,
|
||||
"ai_credential_binding": "",
|
||||
"openai_credential_binding": "",
|
||||
"baidu_credential_binding": "",
|
||||
}
|
||||
|
||||
@@ -304,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
|
||||
@@ -320,20 +545,27 @@ 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"] = ""
|
||||
@@ -351,15 +583,18 @@ class AssistantStore:
|
||||
|
||||
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):
|
||||
@@ -375,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 过长")
|
||||
@@ -428,7 +664,7 @@ class AssistantStore:
|
||||
raise ValueError("百度密钥过长")
|
||||
baidu_secret = incoming_secret
|
||||
baidu_qps = normalize_baidu_qps(payload.get("baidu_qps", current.get("baidu_qps", BAIDU_QPS_DEFAULT)))
|
||||
ai_credential_binding = self._credential_binding(ai_provider, ai_base_url) if ai_api_key else ""
|
||||
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"}:
|
||||
@@ -437,37 +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()]
|
||||
@@ -488,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:
|
||||
|
||||
+14
-1
@@ -22,5 +22,18 @@
|
||||
"#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
@@ -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,
|
||||
|
||||
+13
-7
@@ -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,8 +60,11 @@
|
||||
- `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 栈。
|
||||
@@ -68,7 +73,7 @@
|
||||
- 标签排序是结构化编辑:英文标签区支持指针拖拽(4px 阈值区分点击与拖动,拖动后吞掉 click/dblclick),明细表行首提供 `⠿` 手柄(带插入指示线与边缘自动滚动),`Alt+↑/↓` 键盘微调;三个入口都汇入同一个 `moveTokenTo` → `movePromptToken` → 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)
|
||||
→ 结果写回绿色暂存区
|
||||
|
||||
@@ -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 之类照常列出,免得误伤名字奇怪但确实可用的模型。
|
||||
|
||||
@@ -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"])
|
||||
+971
-240
File diff suppressed because it is too large
Load Diff
+636
-77
@@ -1,6 +1,7 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { normalizePreferences } from "./dictionary_tools.js";
|
||||
import { matchModelNames } from "./model_suggest.js";
|
||||
import { ANIMA_SLOTS } from "./anima_sorter.js";
|
||||
import {
|
||||
LANGUAGE_CHANGED_EVENT,
|
||||
applyLanguageToDom,
|
||||
@@ -23,6 +24,14 @@ const MANAGER_TAB_ID = "bpi-manager";
|
||||
const MANAGER_OPEN_EVENT = "bpi:open-manager";
|
||||
// 后端把上游传来的提示词推给前端时使用的事件名
|
||||
const UPSTREAM_ARRIVED_EVENT = "bpi/upstream-arrived";
|
||||
// 按词库配色时,「没命中任何词库」也当成一个来源,与后端 token_color_store.UNINDEXED_PACK_ID 对齐
|
||||
const UNINDEXED_PACK_ID = "unindexed";
|
||||
// 骰子:点一下从 random_pool 里随机抽一个颜色
|
||||
const DICE_ICON = '<svg viewBox="0 0 14 14" width="13" height="13" aria-hidden="true">'
|
||||
+ '<rect x="1" y="1" width="12" height="12" rx="3" fill="none" stroke="currentColor" stroke-width="1.2"/>'
|
||||
+ '<circle cx="4.5" cy="4.5" r="1.15" fill="currentColor"/><circle cx="9.5" cy="4.5" r="1.15" fill="currentColor"/>'
|
||||
+ '<circle cx="7" cy="7" r="1.15" fill="currentColor"/>'
|
||||
+ '<circle cx="4.5" cy="9.5" r="1.15" fill="currentColor"/><circle cx="9.5" cy="9.5" r="1.15" fill="currentColor"/></svg>';
|
||||
|
||||
let sessionTokenPromise = null;
|
||||
let dictionaryPromise = null;
|
||||
@@ -75,6 +84,17 @@ async function postUpstreamAction(action, nodeId, text = null) {
|
||||
return result;
|
||||
}
|
||||
|
||||
async function checkDuplicateTags(text) {
|
||||
const response = await bpiFetch(`${API_ROOT}/duplicate-check`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ text: String(text ?? "") }),
|
||||
});
|
||||
const result = await response.json().catch(() => null);
|
||||
if (!response.ok || !result?.success) throw new Error(result?.error || `Duplicate check failed (${response.status})`);
|
||||
return result.data;
|
||||
}
|
||||
|
||||
function loadPreferences() {
|
||||
try {
|
||||
return normalizePreferences(JSON.parse(localStorage.getItem(PREFERENCES_KEY) || "{}"));
|
||||
@@ -179,6 +199,18 @@ async function getAssistantConfig() {
|
||||
return result.data;
|
||||
}
|
||||
|
||||
// 拿表单当前填的地址和密钥问一次「你有哪些模型」;后端不读已保存的密钥,也不写配置
|
||||
async function listAssistantModels(payload) {
|
||||
const response = await bpiFetch(`${API_ROOT}/assistant/models`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const result = await response.json().catch(() => null);
|
||||
if (!response.ok || !result?.success) throw new Error(result?.error || "Failed to fetch models");
|
||||
return Array.isArray(result.data?.models) ? result.data.models : [];
|
||||
}
|
||||
|
||||
// 读 data/token_colors.json;用户可以不改源码只改这个文件管理节点标签卡的颜色池。
|
||||
// 文件不在或格式不对时后端回退内置默认,这里只管透传。
|
||||
let tokenColorsPromise = null;
|
||||
@@ -198,6 +230,20 @@ function loadTokenColors(force = false) {
|
||||
return tokenColorsPromise;
|
||||
}
|
||||
|
||||
// 只传要更新的那一类;没传的那类后端保持原样
|
||||
async function saveTokenColors({ pack_colors: packColors, anima_colors: animaColors } = {}) {
|
||||
const response = await bpiFetch(`${API_ROOT}/token-colors/colors`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ pack_colors: packColors, anima_colors: animaColors }),
|
||||
});
|
||||
const result = await response.json();
|
||||
if (!response.ok || !result.success) throw new Error(result.error || "Failed to save colors");
|
||||
// 缓存失效:保存完节点那边要重新拉一次才能看到新配色
|
||||
tokenColorsPromise = null;
|
||||
return result.data;
|
||||
}
|
||||
|
||||
async function saveAssistantConfig(payload) {
|
||||
const response = await bpiFetch(`${API_ROOT}/assistant/config`, {
|
||||
method: "POST",
|
||||
@@ -689,24 +735,243 @@ function openSavePromptDialog({ name = "", text = "", note = null, models = [],
|
||||
return { close };
|
||||
}
|
||||
|
||||
// 分类候选:只收词库里真实出现过的 category,用得多的排前面。
|
||||
// 不预置任何「应该有但还没用过」的分类;中英文混着也不翻译,原样列出。
|
||||
function tagCategoryCounts(data) {
|
||||
const counts = new Map();
|
||||
for (const tag of data?.tags ?? []) {
|
||||
const name = String(tag?.category ?? "").trim();
|
||||
if (!name) continue;
|
||||
counts.set(name, (counts.get(name) ?? 0) + 1);
|
||||
}
|
||||
return [...counts]
|
||||
.map(([name, count]) => ({ name, count }))
|
||||
.sort((left, right) => right.count - left.count || left.name.localeCompare(right.name, "zh-CN"));
|
||||
}
|
||||
|
||||
// 插件自己写死的默认分类:词库里存的其实是英文,显示时才翻成中文,存回去再还原,
|
||||
// 免得同一个「未分类」在词库里分成两条。别的分类都是词库数据,不进翻译层。
|
||||
const UNCATEGORIZED = "Uncategorized";
|
||||
const categoryName = (value) => (value === UNCATEGORIZED ? t("Uncategorized") : value);
|
||||
const isUncategorized = (value) => value === UNCATEGORIZED || value === t("Uncategorized");
|
||||
|
||||
// 「分类」输入框:下拉列出已有分类,边打字边收窄,第一项恒为「自定义」。
|
||||
// 青绿色的「自定义」标识只表示「这个值不是词库里的现成分类」—— 选了自定义才会挂上,
|
||||
// 手打的值一旦命中某个候选就自动摘掉;编辑历史标签时带进来的陌生值也当场算自定义。
|
||||
function buildCategoryPicker({ initial = "", categories = [] } = {}) {
|
||||
const root = element("div", "bpi-autocomplete");
|
||||
const wrap = element("div", "bpi-input-wrap");
|
||||
const input = element("input");
|
||||
input.id = "bpi-field-category";
|
||||
input.name = "category";
|
||||
input.autocomplete = "off";
|
||||
input.value = categoryName(initial ?? "");
|
||||
setPlaceholder(input, "Type to filter; ↑↓ to choose");
|
||||
const badge = element("span", "bpi-custom-badge", "Custom");
|
||||
badge.hidden = true;
|
||||
const menu = element("div", "bpi-ac-menu");
|
||||
menu.hidden = true;
|
||||
wrap.append(input, badge);
|
||||
root.append(wrap, menu);
|
||||
|
||||
let catalog = categories;
|
||||
let rows = [];
|
||||
let active = -1;
|
||||
let custom = false;
|
||||
|
||||
const setCustom = (flag) => {
|
||||
custom = flag;
|
||||
badge.hidden = !flag;
|
||||
};
|
||||
const closeMenu = () => {
|
||||
menu.hidden = true;
|
||||
active = -1;
|
||||
};
|
||||
const renderMenu = () => {
|
||||
menu.replaceChildren();
|
||||
if (!rows.length) {
|
||||
closeMenu();
|
||||
return;
|
||||
}
|
||||
rows.forEach((row, index) => {
|
||||
const item = element("div", `bpi-ac-item${index === active ? " bpi-ac-active" : ""}${row.custom ? " bpi-ac-custom" : ""}`);
|
||||
// 分类名是词库数据,不进翻译层;只有插件自己的默认分类翻成中文
|
||||
const name = element("span", "bpi-ac-name");
|
||||
name.textContent = row.custom ? t("Custom") : categoryName(row.name);
|
||||
const hint = element("span", "bpi-ac-kind");
|
||||
hint.textContent = row.custom ? (input.value.trim() || t("Manual input")) : t(`${row.count} items`);
|
||||
item.append(name, hint);
|
||||
// 用 mousedown:等到 click 时输入框已经失焦、菜单早被关掉了
|
||||
item.addEventListener("mousedown", (event) => {
|
||||
event.preventDefault();
|
||||
commit(row);
|
||||
});
|
||||
menu.appendChild(item);
|
||||
});
|
||||
menu.hidden = false;
|
||||
};
|
||||
const update = ({ reveal = true } = {}) => {
|
||||
const needle = input.value.trim().toLowerCase();
|
||||
const matches = catalog.filter((entry) => !needle || categoryName(entry.name).toLowerCase().includes(needle));
|
||||
rows = [{ custom: true }, ...matches];
|
||||
if (!reveal) {
|
||||
menu.hidden = true;
|
||||
return;
|
||||
}
|
||||
// 打出来的字正好等于某个候选时,默认就落在它身上,省一次↓
|
||||
const exact = rows.findIndex((row) => !row.custom && categoryName(row.name) === input.value.trim());
|
||||
active = exact > 0 ? exact : 0;
|
||||
renderMenu();
|
||||
};
|
||||
const commit = (row) => {
|
||||
if (!row) return;
|
||||
if (row.custom) {
|
||||
setCustom(true);
|
||||
} else {
|
||||
input.value = categoryName(row.name);
|
||||
setCustom(false);
|
||||
}
|
||||
closeMenu();
|
||||
input.focus();
|
||||
};
|
||||
|
||||
input.addEventListener("input", () => {
|
||||
if (custom && catalog.some((entry) => categoryName(entry.name) === input.value.trim())) setCustom(false);
|
||||
update();
|
||||
});
|
||||
input.addEventListener("focus", () => update());
|
||||
input.addEventListener("click", () => {
|
||||
if (menu.hidden) update();
|
||||
});
|
||||
input.addEventListener("keydown", (event) => {
|
||||
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
if (menu.hidden) {
|
||||
update(); // 关着的时候先把列表开出来
|
||||
return;
|
||||
}
|
||||
const step = event.key === "ArrowDown" ? 1 : -1;
|
||||
active = (active + step + rows.length) % rows.length;
|
||||
renderMenu();
|
||||
return;
|
||||
}
|
||||
if (event.key === "Enter") {
|
||||
// 菜单开着才拦回车;关着的时候回车照旧提交整个表单
|
||||
if (menu.hidden) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
commit(rows[Math.max(0, active)]);
|
||||
return;
|
||||
}
|
||||
if (event.key === "Escape" && !menu.hidden) {
|
||||
event.stopPropagation();
|
||||
closeMenu();
|
||||
}
|
||||
});
|
||||
|
||||
// Nodes 2.0 会在捕获阶段拦掉指针事件,所以点空白关闭也挂 document + 捕获
|
||||
const onOutside = (event) => {
|
||||
if (!root.contains(event.target)) closeMenu();
|
||||
};
|
||||
document.addEventListener("mousedown", onOutside, true);
|
||||
|
||||
return {
|
||||
root,
|
||||
destroy: () => document.removeEventListener("mousedown", onOutside, true),
|
||||
// 存回词库的是英文原值:显示用的「未分类」要还原,不然会多出一个中文分类
|
||||
value: () => (isUncategorized(input.value.trim()) ? UNCATEGORIZED : input.value.trim()),
|
||||
// 候选是异步取来的,取到之后不能把用户的菜单硬弹开
|
||||
setCategories: (list) => {
|
||||
catalog = list;
|
||||
const current = input.value.trim();
|
||||
if (current && !catalog.some((entry) => categoryName(entry.name) === current)) setCustom(true);
|
||||
// 菜单本来开着就重画,没开就别突然弹出来打断用户
|
||||
update({ reveal: !menu.hidden });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 「标签字段」那一整套:独立的「新增/编辑个人标签」弹窗和节点里双击标签卡后的
|
||||
// 「编辑」页签共用同一份,免得两边各抄一遍字段。
|
||||
// withTarget = false 时不给「保存到哪个词库」的下拉:双击进来的一定是标签
|
||||
// (自然语言片段进不了这个入口),恒存个人词库,让选项出现反而是噪音。
|
||||
// readonlyEnglish:英文只显示、不给输入框。改它并不会改提示词,后端按英文做主键,
|
||||
// 只会另存一条新词条、旧的还留着——要改提示词里的文字得走节点的「编辑」页。
|
||||
function buildTagFields(parent, initial = {}, { withTarget = true, readonlyEnglish = false } = {}) {
|
||||
const englishValue = String(initial?.english ?? "");
|
||||
let english;
|
||||
if (readonlyEnglish) {
|
||||
const label = element("label", "", "English Tag");
|
||||
const text = element("span", "bpi-field-static", englishValue);
|
||||
parent.append(label, text);
|
||||
// 兼容下面按 input 用法读写的地方:只提供 value / focus 两个口子
|
||||
english = { value: englishValue, focus: () => {} };
|
||||
} else {
|
||||
english = field(parent, "English Tag", "english", initial?.english, "e.g.: looking at viewer");
|
||||
}
|
||||
// 中文名称的示例要给中文:这格本来就是填中文的,示例还写英文会让人以为该填英文
|
||||
const chinese = field(parent, "Chinese Name", "chinese", initial?.chinese, "e.g.: apple");
|
||||
const aliases = field(parent, "Chinese Aliases", "aliases", initial?.aliases?.join(","), "Comma-separated");
|
||||
const categoryLabel = element("label", "", "Category");
|
||||
categoryLabel.htmlFor = "bpi-field-category";
|
||||
// 分类留空也行:后端会把空值落成「自定义」,所以新建时不要预先塞值、免得一开窗就挂着标识
|
||||
const categoryPicker = buildCategoryPicker({ initial: initial?.category ?? "" });
|
||||
parent.append(categoryLabel, categoryPicker.root);
|
||||
// 候选按词库实际用词实时汇总;取不到就退化成普通手输,这行本来也能打字
|
||||
loadDictionary()
|
||||
.then((data) => categoryPicker.setCategories(tagCategoryCounts(data)))
|
||||
.catch(() => {});
|
||||
const models = field(parent, "Models", "models", initial?.models?.join(",") ?? "general, anima", "Comma-separated");
|
||||
const weight = field(parent, "Recommended Weight", "recommended_weight", initial?.recommended_weight ?? "", "Optional");
|
||||
const notes = field(parent, "Notes", "notes", initial?.notes, "Optional");
|
||||
let dictionary = null;
|
||||
if (withTarget) {
|
||||
const dictionaryLabel = element("label", "", "Save to Dictionary");
|
||||
dictionaryLabel.htmlFor = "bpi-field-dictionary";
|
||||
dictionary = element("select", "bpi-mode");
|
||||
dictionary.id = dictionaryLabel.htmlFor;
|
||||
dictionary.name = "dictionary";
|
||||
for (const [value, label] of [["personal", "Personal dictionary"], ["natural", "Natural Language Dictionary"]]) {
|
||||
const option = element("option", "", label);
|
||||
option.value = value;
|
||||
dictionary.appendChild(option);
|
||||
}
|
||||
dictionary.value = initial?.natural ? "natural" : "personal";
|
||||
parent.append(dictionaryLabel, dictionary);
|
||||
}
|
||||
return {
|
||||
destroy: () => categoryPicker.destroy(),
|
||||
focus: () => (initial?.english ? chinese : english).focus(),
|
||||
collect: () => ({
|
||||
english: english.value,
|
||||
chinese: chinese.value,
|
||||
aliases: aliases.value,
|
||||
category: categoryPicker.value(),
|
||||
models: models.value,
|
||||
recommended_weight: weight.value,
|
||||
notes: notes.value,
|
||||
natural: dictionary ? dictionary.value === "natural" : Boolean(initial?.natural),
|
||||
source: initial?.source === "bpi-assistant" ? initial.source : "user",
|
||||
verified: initial?.verified ?? true,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function openTagDialog(initial, onSaved) {
|
||||
const shade = element("div", "bpi-modal-shade");
|
||||
const modal = element("div", "bpi-modal");
|
||||
const title = element("h3", "", initial?.english ? "Add or Edit Personal Tag" : "Add Personal Tag");
|
||||
const form = element("form", "bpi-form");
|
||||
const english = field(form, "English Tag", "english", initial?.english, "e.g.: looking at viewer");
|
||||
const chinese = field(form, "Chinese Name", "chinese", initial?.chinese, "e.g.: looking at viewer");
|
||||
const aliases = field(form, "Chinese Aliases", "aliases", initial?.aliases?.join(","), "Comma-separated");
|
||||
const category = field(form, "Category", "category", initial?.category ?? "Custom", "e.g.: pose, camera, style");
|
||||
const models = field(form, "Models", "models", initial?.models?.join(",") ?? "general, anima", "Comma-separated");
|
||||
const weight = field(form, "Recommended Weight", "recommended_weight", initial?.recommended_weight ?? "", "Optional");
|
||||
const notes = field(form, "Notes", "notes", initial?.notes, "Optional");
|
||||
const fields = buildTagFields(form, initial);
|
||||
const error = element("div", "bpi-status");
|
||||
error.dataset.kind = "error";
|
||||
error.style.gridColumn = "1 / -1";
|
||||
form.appendChild(error);
|
||||
const actions = element("div", "bpi-modal-actions");
|
||||
const close = () => shade.remove();
|
||||
const close = () => {
|
||||
fields.destroy();
|
||||
shade.remove();
|
||||
};
|
||||
actions.append(
|
||||
button("Cancel", close),
|
||||
button("Save to Personal Dictionary", () => form.requestSubmit(), "bpi-primary"),
|
||||
@@ -722,24 +987,235 @@ function openTagDialog(initial, onSaved) {
|
||||
event.preventDefault();
|
||||
error.textContent = "";
|
||||
try {
|
||||
await saveTag({
|
||||
english: english.value,
|
||||
chinese: chinese.value,
|
||||
aliases: aliases.value,
|
||||
category: category.value,
|
||||
models: models.value,
|
||||
recommended_weight: weight.value,
|
||||
notes: notes.value,
|
||||
source: initial?.source === "bpi-assistant" ? initial.source : "user",
|
||||
verified: initial?.verified ?? true,
|
||||
});
|
||||
await saveTag(fields.collect());
|
||||
close();
|
||||
await onSaved();
|
||||
} catch (saveError) {
|
||||
error.textContent = saveError.message;
|
||||
}
|
||||
});
|
||||
setTimeout(() => (initial?.english ? chinese : english).focus(), 0);
|
||||
setTimeout(() => fields.focus(), 0);
|
||||
}
|
||||
|
||||
// 颜色区能配色的「来源」:内置 / 社区词库包 + 大词库 + 两个本机词库 + 未收录。
|
||||
// 后三者不在 packs 里(个人词库和自然语言词库共用 user_tags.json,未收录压根没有词条),得手工补。
|
||||
function colorTargets(data) {
|
||||
const userTags = data?.user ?? [];
|
||||
const items = (count) => t(`${count ?? 0} items`);
|
||||
const targets = (data?.packs ?? []).map((pack) => ({ id: pack.id, name: pack.name, meta: items(pack.count) }));
|
||||
const large = data?.large_dictionary;
|
||||
if (large?.available) {
|
||||
targets.push({ id: "danbooru_large", name: large.name, meta: items(large.count) });
|
||||
}
|
||||
targets.push({ id: "personal", name: t("Personal dictionary"), meta: items(userTags.filter((tag) => !tag.natural).length) });
|
||||
targets.push({ id: "natural", name: t("Natural Language Dictionary"), meta: items(userTags.filter((tag) => tag.natural).length) });
|
||||
targets.push({ id: UNINDEXED_PACK_ID, name: t("Not indexed"), meta: t("Tags matching no dictionary") });
|
||||
return targets;
|
||||
}
|
||||
|
||||
// #RGB / #RRGGBB 统一成小写 6 位;认不出来返回空串(= 不着色)
|
||||
function normalizeHexColor(value) {
|
||||
const text = String(value ?? "").trim();
|
||||
if (/^#[0-9a-fA-F]{6}$/.test(text)) return text.toLowerCase();
|
||||
if (/^#[0-9a-fA-F]{3}$/.test(text)) return "#" + text.slice(1).split("").map((char) => char + char).join("").toLowerCase();
|
||||
return "";
|
||||
}
|
||||
|
||||
// 按 Anima 配色时的分类名:跟 anima_sorter.js 的 ANIMA_SLOTS 一一对应。
|
||||
// 网格卡片窄,用短名,ANIMA_SLOTS 里的英文全称放 meta。
|
||||
const ANIMA_SHORT_LABELS = {
|
||||
quality: "Quality",
|
||||
people: "People count",
|
||||
character: "Character",
|
||||
copyright: "Copyright",
|
||||
artist: "Artist",
|
||||
appearance: "Appearance",
|
||||
action: "Expression",
|
||||
camera: "Camera",
|
||||
style: "Style",
|
||||
environment: "Scene",
|
||||
natural: "Natural language",
|
||||
uncertain: "Uncertain",
|
||||
};
|
||||
|
||||
function animaColorTargets() {
|
||||
return ANIMA_SLOTS.map((slot) => ({
|
||||
id: slot.id,
|
||||
name: t(ANIMA_SHORT_LABELS[slot.id] ?? slot.label),
|
||||
meta: slot.label,
|
||||
}));
|
||||
}
|
||||
|
||||
// 一块配色网格:按词库一块、按 Anima 一块,共用这套卡片。改动后由 onCommit 落盘。
|
||||
function buildColorGrid({ targets, initial, presets, dicePool, error, onCommit }) {
|
||||
const grid = element("div", "bpi-color-grid");
|
||||
const picked = new Map();
|
||||
const setters = [];
|
||||
for (const target of targets) {
|
||||
picked.set(target.id, initial?.[target.id] || "");
|
||||
const card = element("div", "bpi-color-card");
|
||||
const chip = element("span", "bpi-color-chip");
|
||||
const head = element("div", "bpi-color-card-head");
|
||||
head.append(chip, element("span", "", target.name));
|
||||
const swatchRow = element("div", "bpi-color-swatches");
|
||||
const swatches = [];
|
||||
for (const hex of presets) {
|
||||
const dot = element("i");
|
||||
dot.style.background = hex;
|
||||
dot.dataset.color = hex;
|
||||
setTitle(dot, hex);
|
||||
swatchRow.appendChild(dot);
|
||||
swatches.push(dot);
|
||||
}
|
||||
const input = element("input");
|
||||
setPlaceholder(input, "#6b9b78");
|
||||
const dice = element("button", "bpi-color-dice");
|
||||
dice.type = "button";
|
||||
dice.innerHTML = DICE_ICON;
|
||||
setTitle(dice, "Random color");
|
||||
const row = element("div", "bpi-color-row");
|
||||
row.append(input, dice, button("None", () => {
|
||||
apply("");
|
||||
onCommit();
|
||||
}));
|
||||
|
||||
function apply(value) {
|
||||
const color = normalizeHexColor(value);
|
||||
picked.set(target.id, color);
|
||||
chip.style.background = color || "transparent";
|
||||
for (const dot of swatches) dot.classList.toggle("bpi-swatch-on", Boolean(color) && dot.dataset.color === color);
|
||||
input.value = color;
|
||||
error.textContent = "";
|
||||
}
|
||||
swatchRow.addEventListener("click", (event) => {
|
||||
const hex = event.target?.dataset?.color;
|
||||
if (!hex) return;
|
||||
apply(hex);
|
||||
onCommit();
|
||||
});
|
||||
dice.addEventListener("click", () => {
|
||||
apply(dicePool[Math.floor(Math.random() * dicePool.length)]);
|
||||
onCommit();
|
||||
});
|
||||
input.addEventListener("change", () => {
|
||||
const value = input.value.trim();
|
||||
if (!value) {
|
||||
apply("");
|
||||
onCommit();
|
||||
return;
|
||||
}
|
||||
if (!normalizeHexColor(value)) {
|
||||
error.textContent = t("Invalid color; use #RGB or #RRGGBB");
|
||||
return;
|
||||
}
|
||||
apply(value);
|
||||
onCommit();
|
||||
});
|
||||
apply(picked.get(target.id));
|
||||
setters.push(apply);
|
||||
|
||||
card.append(head, element("div", "bpi-color-meta", target.meta), swatchRow, row);
|
||||
grid.appendChild(card);
|
||||
}
|
||||
return {
|
||||
root: grid,
|
||||
values() {
|
||||
const payload = {};
|
||||
for (const [id, color] of picked) {
|
||||
if (color) payload[id] = color;
|
||||
}
|
||||
return payload;
|
||||
},
|
||||
reset() {
|
||||
for (const set of setters) set("");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 颜色面板:节点过滤栏点「颜色」就在过滤栏下面就地展开,不做弹窗。
|
||||
// 跟左边那排筛选按钮一个操作逻辑:点一下立刻生效;按词库 / 按 Anima 才展开对应的配色网格。
|
||||
function buildColorPanel({ data, colors, mode, onChange }) {
|
||||
const root = element("div", "bpi-color-panel");
|
||||
const modes = [
|
||||
["default", t("Default"), ["var(--bpi-border-2)"]],
|
||||
["status", t("By inclusion"), ["#c08a4a", "#8a6bb0"]],
|
||||
["random", t("Random"), ["#5b8a72", "#8a5b7a", "#5b6e8a"]],
|
||||
["by_pack", t("By dictionary"), ["#6b9b78", "#6b7b9b", "#9b6b8b"]],
|
||||
["by_anima", t("By Anima"), ["#9b8b6b", "#9b6b8b", "#6b9b9b"]],
|
||||
];
|
||||
// 旧存档里可能有不在选项里的值,回落到默认,别让按钮组一个都不亮
|
||||
let currentMode = modes.some(([value]) => value === mode) ? mode : "default";
|
||||
let live = colors;
|
||||
const error = element("div", "bpi-color-error");
|
||||
const presets = (colors?.presets ?? []).length ? colors.presets : ["#6b9b78"];
|
||||
const dicePool = (colors?.random_pool ?? []).length ? colors.random_pool : presets;
|
||||
|
||||
const modeRow = element("div", "bpi-color-modes");
|
||||
const modeButtons = new Map();
|
||||
for (const [value, label, dots] of modes) {
|
||||
const item = element("button", "bpi-color-mode");
|
||||
item.type = "button";
|
||||
for (const dot of dots) {
|
||||
const mark = element("i", "bpi-color-mode-dot");
|
||||
mark.style.background = dot;
|
||||
item.appendChild(mark);
|
||||
}
|
||||
item.appendChild(element("span", "", label));
|
||||
// 模式本身不落盘,只有配色才写文件
|
||||
item.addEventListener("click", () => setMode(value));
|
||||
modeButtons.set(value, item);
|
||||
modeRow.appendChild(item);
|
||||
}
|
||||
|
||||
async function commit() {
|
||||
try {
|
||||
live = await saveTokenColors({ pack_colors: packGrid.values(), anima_colors: animaGrid.values() });
|
||||
error.textContent = "";
|
||||
} catch (saveError) {
|
||||
error.textContent = saveError.message;
|
||||
return;
|
||||
}
|
||||
onChange(currentMode, live);
|
||||
}
|
||||
|
||||
const gridOptions = { presets, dicePool, error, onCommit: commit };
|
||||
const packGrid = buildColorGrid({ ...gridOptions, targets: colorTargets(data), initial: colors?.pack_colors });
|
||||
const animaGrid = buildColorGrid({ ...gridOptions, targets: animaColorTargets(), initial: colors?.anima_colors });
|
||||
|
||||
const gridActions = (reset) => {
|
||||
const actions = element("div", "bpi-color-panel-actions");
|
||||
actions.appendChild(button("Restore defaults", () => {
|
||||
reset();
|
||||
commit();
|
||||
}));
|
||||
return actions;
|
||||
};
|
||||
const packWrap = element("div");
|
||||
packWrap.append(
|
||||
element("div", "bpi-color-note", t("Pick a color for each dictionary; uncolored ones keep the default.")),
|
||||
packGrid.root,
|
||||
gridActions(() => packGrid.reset()),
|
||||
);
|
||||
const animaWrap = element("div");
|
||||
animaWrap.append(
|
||||
element("div", "bpi-color-note", t("Pick a color for each Anima category; unset ones keep the default.")),
|
||||
animaGrid.root,
|
||||
gridActions(() => animaGrid.reset()),
|
||||
);
|
||||
|
||||
const setMode = (value, notify = true) => {
|
||||
currentMode = value;
|
||||
for (const [id, item] of modeButtons) item.classList.toggle("bpi-color-mode-active", id === value);
|
||||
packWrap.hidden = value !== "by_pack";
|
||||
animaWrap.hidden = value !== "by_anima";
|
||||
error.textContent = "";
|
||||
if (notify) onChange(value, live);
|
||||
};
|
||||
|
||||
root.append(modeRow, packWrap, animaWrap, error);
|
||||
// 首次只是把当前模式点亮、决定网格显不显示,不该顺带触发一次重绘和同步
|
||||
setMode(currentMode, false);
|
||||
return root;
|
||||
}
|
||||
|
||||
function openManagerPanel(section = "", nodeId = null) {
|
||||
@@ -758,53 +1234,55 @@ function injectBpiStyles() {
|
||||
const style = document.createElement("style");
|
||||
style.id = "bpi-styles";
|
||||
style.textContent = `
|
||||
:root{--bpi-surface:rgba(255,255,255,.92);--bpi-surface-2:#f5f5f5;--bpi-surface-3:#eaeaea;--bpi-surface-table:#f0f0f0;--bpi-surface-modal:#fff;--bpi-text:#333;--bpi-text-muted:#666;--bpi-text-faint:#999;--bpi-border:#ccc;--bpi-border-2:#bbb;--bpi-border-3:#e0e0e0;--bpi-button-bg:#e8e8e8;--bpi-button-text:#333;--bpi-input-bg:#fff;--bpi-input-text:#333;--bpi-head-bg:#e8e8e8;--bpi-editor-bg:#fff}
|
||||
.dark-theme{--bpi-surface:rgba(10,12,18,.72);--bpi-surface-2:#171b22;--bpi-surface-3:#14181f;--bpi-surface-table:#11151b;--bpi-surface-modal:#20252d;--bpi-text:#e6edf7;--bpi-text-muted:#aab5c5;--bpi-text-faint:#7f8a9a;--bpi-border:#3a4250;--bpi-border-2:#4c5668;--bpi-border-3:#29313d;--bpi-button-bg:#2c3340;--bpi-button-text:#e8edf5;--bpi-input-bg:#171b22;--bpi-input-text:#eef3fb;--bpi-head-bg:#252c36;--bpi-editor-bg:#111923}
|
||||
[data-bpi-theme="dark"]{--bpi-surface:rgba(10,12,18,.72);--bpi-surface-2:#171b22;--bpi-surface-3:#14181f;--bpi-surface-table:#11151b;--bpi-surface-modal:#20252d;--bpi-text:#e6edf7;--bpi-text-muted:#aab5c5;--bpi-text-faint:#7f8a9a;--bpi-border:#3a4250;--bpi-border-2:#4c5668;--bpi-border-3:#29313d;--bpi-button-bg:#2c3340;--bpi-button-text:#e8edf5;--bpi-input-bg:#171b22;--bpi-input-text:#eef3fb;--bpi-head-bg:#252c36;--bpi-editor-bg:#111923}
|
||||
[data-bpi-theme="light"]{--bpi-surface:rgba(255,255,255,.92);--bpi-surface-2:#f5f5f5;--bpi-surface-3:#eaeaea;--bpi-surface-table:#f0f0f0;--bpi-surface-modal:#fff;--bpi-text:#333;--bpi-text-muted:#666;--bpi-text-faint:#999;--bpi-border:#ccc;--bpi-border-2:#bbb;--bpi-border-3:#e0e0e0;--bpi-button-bg:#e8e8e8;--bpi-button-text:#333;--bpi-input-bg:#fff;--bpi-input-text:#333;--bpi-head-bg:#e8e8e8;--bpi-editor-bg:#fff}
|
||||
.bpi-panel{box-sizing:border-box;width:100%;height:100%;min-height:var(--bpi-collapsed-height,390px);max-height:var(--bpi-collapsed-height,390px);padding:8px;display:flex;flex-direction:column;gap:7px;color:var(--bpi-text);font:12px/1.4 Arial,sans-serif;background:var(--bpi-surface);border:1px solid var(--bpi-border);border-radius:8px;overflow-y:auto;scrollbar-gutter:stable}
|
||||
.bpi-english-section{flex:none;border:1px solid #3e7197;border-radius:7px;background:var(--bpi-surface);overflow:hidden}.bpi-english-head{display:flex;align-items:center;gap:7px;padding:5px 8px;background:var(--bpi-head-bg);color:#cbe8ff;font-weight:700;flex-wrap:wrap}.bpi-english-hint{margin-left:auto;color:#91b4cf;font-size:10px;font-weight:400}.bpi-english-token-view{box-sizing:border-box;width:100%;height:auto;min-height:92px;max-height:360px;overflow:auto;scrollbar-gutter:stable;padding:8px 9px;outline:none;color:var(--bpi-text);font:12px/1.85 Consolas,monospace;white-space:normal}.bpi-english-token-view:focus{box-shadow:inset 0 0 0 1px #5aa6d8}.bpi-english-editor{box-sizing:border-box;width:100%;height:120px;min-height:92px;max-height:420px;overflow-y:auto;resize:vertical;border:0;border-top:1px solid #3e7197;background:var(--bpi-editor-bg);color:var(--bpi-text);padding:9px;outline:none;font:12px/1.55 Consolas,monospace}.bpi-english-editor:focus{box-shadow:inset 0 0 0 1px #5aa6d8}.bpi-english-token{font-family:Consolas,monospace;color:var(--bpi-text)}.bpi-english-token.bpi-linked{background:#147fc3;border-color:#76c9ff;color:#fff;box-shadow:0 0 0 1px rgba(118,201,255,.32)}
|
||||
.bpi-mirror-section{flex:none;border:1px solid #488739;border-radius:7px;background:var(--bpi-surface);overflow:visible}.bpi-section-head{display:flex;align-items:center;gap:7px;padding:5px 8px;background:var(--bpi-head-bg);color:#cbe9c8;font-weight:700;flex-wrap:wrap}.bpi-section-hint{margin-left:auto;color:#91ad93;font-size:10px;font-weight:400}.bpi-mirror-actions{display:flex;gap:4px;align-items:center;flex-wrap:wrap}.bpi-chinese-mirror{box-sizing:border-box;width:100%;height:auto;min-height:92px;max-height:360px;overflow:auto;resize:none;scrollbar-gutter:stable;padding:8px 9px;outline:none;color:var(--bpi-text);line-height:1.85;cursor:text;white-space:normal}.bpi-chinese-mirror:focus{box-shadow:inset 0 0 0 1px #70b35f}.bpi-category-table{display:grid;grid-template-columns:minmax(110px,150px) minmax(0,1fr);border:1px solid #315f3a;border-radius:6px;overflow:hidden;background:var(--bpi-surface-3)}.bpi-category-row{display:contents}.bpi-category-name,.bpi-category-content{padding:6px 8px;border-top:1px solid #31513a}.bpi-category-row:first-child .bpi-category-name,.bpi-category-row:first-child .bpi-category-content{border-top:0}.bpi-category-name{background:var(--bpi-head-bg);border-right:1px solid #31513a;color:#9ee8a7;font-weight:700}.bpi-category-content{min-width:0}.bpi-chinese-editor{box-sizing:border-box;width:100%;height:auto;min-height:110px;max-height:600px;overflow-y:auto;resize:vertical;border:0;border-top:1px solid #3b693c;background:var(--bpi-editor-bg);color:var(--bpi-text);padding:9px;outline:none;font:12px/1.65 Arial,sans-serif}.bpi-chinese-editor:focus{box-shadow:inset 0 0 0 1px #70b35f}.bpi-hidden{display:none!important}.bpi-mirror-empty{color:var(--bpi-text-muted)}.bpi-mirror-token{display:inline-flex;align-items:center;border:1px solid transparent;border-radius:5px;padding:0 3px;margin:1px 0;cursor:pointer;transition:background .12s,border-color .12s,color .12s}.bpi-mirror-token:hover{background:#345a3b;border-color:#5c8d63;color:#fff}.bpi-mirror-token.bpi-linked{background:#315f83;border-color:#76a9ff;color:#fff}
|
||||
:root{--bpi-field-bg:#fff;--bpi-field-border:#bbb;--bpi-switch-off:#c9ced6;--bpi-switch-knob:#fff;--bpi-switch-off-border:#b0b6bf;--bpm-accent:#45926f;--bpm-accent-hover:#3a7d5e;--bpm-accent-border:#35755a;--bpi-custom-bg:#e6f6f1;--bpi-custom-border:#7fc9b0;--bpi-custom-text:#0f6e56;--bpi-surface:rgba(255,255,255,.92);--bpi-surface-2:#f5f5f5;--bpi-surface-3:#eaeaea;--bpi-surface-table:#f0f0f0;--bpi-surface-modal:#fff;--bpi-text:#333;--bpi-text-muted:#666;--bpi-text-faint:#999;--bpi-border:#ccc;--bpi-border-2:#bbb;--bpi-border-3:#e0e0e0;--bpi-button-bg:#e8e8e8;--bpi-button-text:#333;--bpi-input-bg:#fff;--bpi-input-text:#333;--bpi-head-bg:#e8e8e8;--bpi-danger-bg:#c0392b;--bpi-danger-bg-hover:#a53023;--bpi-danger-border:#a53023;--bpi-danger-text:#fff;--bpi-break-bg:#e6f6f1;--bpi-break-bg-hover:#0f6e56;--bpi-break-border:#7fc9b0;--bpi-break-text:#0f6e56;--bpi-primary-bg:#2b6cb0;--bpi-primary-bg-hover:#245a94;--bpi-primary-border:#1e4f85;--bpi-primary-text:#fff;--bpi-en-head-text:#17557f;--bpi-en-hint-text:#5b7f9e;--bpi-zh-head-text:#1f5c22;--bpi-zh-hint-text:#5b7d5e;--bpi-cat-name-text:#1e5a28;--bpi-info-text:#1f5f96;--bpi-link-text:#1a5f96;--bpi-zh-text:#18508a;--bpi-status-error:#b3261e;--bpi-status-ok:#1a7f45;--bpi-status-busy:#8a5a00;--bpi-badge-warn:#8a5a00;--bpi-badge-source:#0f6e56;--bpi-badge-suspected:#1c5f80;--bpi-badge-machine:#5b3fa8;--bpi-star-on:#a8750a;--bpi-star-off:#6b7280;--bpi-quick-head-text:#4a3a7a;--bpi-quick-hint-text:#6b5a8f;--bpi-handle-text:#4a6a85;--bpi-handle-hover:#1f5f96;--bpi-mini-hover-bg:#d8dee7;--bpi-handle-hover-bg:#e2e8ef;--bpi-chip-hide-bg:#eef1f5;--bpi-waiting-bg:#fdf1dc;--bpi-waiting-border:#d9a95a;--bpi-hidden-chip-hover-bg:#e8eef5;--bpi-issues-bg:#fdf6e8;--bpi-issues-border:#d9b98a;--bpi-issue-text:#7a5410;--bpi-row-hover-overlay:rgba(47,108,176,.06);--bpi-row-pinned-overlay:#cfe3f7;--bpi-row-hover-ring:rgba(47,108,176,.55);--bpi-row-pinned-ring:#1a5f96;--bpi-row-hover-text:#1f1f1f;--bpi-row-hover-shadow:none;--bpi-row-selected-bg:#dcebf8;--bpi-en-section-bg:rgba(43,108,176,.06);--bpi-quick-section-bg:rgba(107,85,148,.06);--bpi-dup-overlay:rgba(179,38,30,.12);--bpi-editor-bg:#fff;--bpi-hit-bg:#fbe49c;--bpi-hit-text:#7a5410}
|
||||
.dark-theme{--bpi-field-bg:#0b0d10;--bpi-field-border:#333a45;--bpi-switch-off:#39414f;--bpi-switch-knob:#e8edf5;--bpi-switch-off-border:#4c5668;--bpi-custom-bg:#17342c;--bpi-custom-border:#3f8f77;--bpi-custom-text:#7fdcbd;--bpi-surface:rgba(10,12,18,.72);--bpi-surface-2:#171b22;--bpi-surface-3:#14181f;--bpi-surface-table:#11151b;--bpi-surface-modal:#20252d;--bpi-text:#e6edf7;--bpi-text-muted:#aab5c5;--bpi-text-faint:#7f8a9a;--bpi-border:#3a4250;--bpi-border-2:#4c5668;--bpi-border-3:#29313d;--bpi-button-bg:#2c3340;--bpi-button-text:#e8edf5;--bpi-input-bg:#171b22;--bpi-input-text:#eef3fb;--bpi-head-bg:#252c36;--bpi-danger-bg:#65313b;--bpi-danger-bg-hover:#7d3d49;--bpi-danger-border:#8d4653;--bpi-danger-text:#ffd9de;--bpi-break-bg:#17342c;--bpi-break-bg-hover:#0f6e56;--bpi-break-border:#3f8f77;--bpi-break-text:#7fdcbd;--bpi-primary-bg:#285f8e;--bpi-primary-bg-hover:#346f9f;--bpi-primary-border:#3f83ba;--bpi-primary-text:#eaf3fb;--bpi-en-head-text:#cbe8ff;--bpi-en-hint-text:#91b4cf;--bpi-zh-head-text:#cbe9c8;--bpi-zh-hint-text:#91ad93;--bpi-cat-name-text:#9ee8a7;--bpi-info-text:#87bfea;--bpi-link-text:#76c9ff;--bpi-zh-text:#9ed0ff;--bpi-status-error:#ff8b93;--bpi-status-ok:#7fdda2;--bpi-status-busy:#ffd27a;--bpi-badge-warn:#e2ca78;--bpi-badge-source:#86d5c4;--bpi-badge-suspected:#7fc4e0;--bpi-badge-machine:#cbb1ff;--bpi-star-on:#ffd45f;--bpi-star-off:#8f9aaa;--bpi-quick-head-text:#d9ccff;--bpi-quick-hint-text:#a795c6;--bpi-handle-text:#7194ad;--bpi-handle-hover:#a9c8e4;--bpi-mini-hover-bg:#3c4b60;--bpi-handle-hover-bg:#2b3a4d;--bpi-chip-hide-bg:#1d2937;--bpi-waiting-bg:rgba(70,52,25,.5);--bpi-waiting-border:#8a5f16;--bpi-hidden-chip-hover-bg:#31404f;--bpi-issues-bg:rgba(55,39,25,.55);--bpi-issues-border:#5b4a36;--bpi-issue-text:#e6c792;--bpi-row-hover-overlay:rgba(76,167,232,.34);--bpi-row-pinned-overlay:#314b68;--bpi-row-hover-ring:rgba(76,167,232,.7);--bpi-row-pinned-ring:#76c9ff;--bpi-row-hover-text:#fff;--bpi-row-hover-shadow:0 0 7px rgba(109,190,255,.6);--bpi-row-selected-bg:#314b68;--bpi-en-section-bg:rgba(76,167,232,.08);--bpi-quick-section-bg:rgba(167,149,198,.08);--bpi-dup-overlay:rgba(255,139,147,.22);--bpi-editor-bg:#111923;--bpi-hit-bg:#5a4413;--bpi-hit-text:#ffe6a8}
|
||||
[data-bpi-theme="dark"]{--bpi-field-bg:#0b0d10;--bpi-field-border:#333a45;--bpi-switch-off:#39414f;--bpi-switch-knob:#e8edf5;--bpi-switch-off-border:#4c5668;--bpi-custom-bg:#17342c;--bpi-custom-border:#3f8f77;--bpi-custom-text:#7fdcbd;--bpi-surface:rgba(10,12,18,.72);--bpi-surface-2:#171b22;--bpi-surface-3:#14181f;--bpi-surface-table:#11151b;--bpi-surface-modal:#20252d;--bpi-text:#e6edf7;--bpi-text-muted:#aab5c5;--bpi-text-faint:#7f8a9a;--bpi-border:#3a4250;--bpi-border-2:#4c5668;--bpi-border-3:#29313d;--bpi-button-bg:#2c3340;--bpi-button-text:#e8edf5;--bpi-input-bg:#171b22;--bpi-input-text:#eef3fb;--bpi-head-bg:#252c36;--bpi-danger-bg:#65313b;--bpi-danger-bg-hover:#7d3d49;--bpi-danger-border:#8d4653;--bpi-danger-text:#ffd9de;--bpi-break-bg:#17342c;--bpi-break-bg-hover:#0f6e56;--bpi-break-border:#3f8f77;--bpi-break-text:#7fdcbd;--bpi-primary-bg:#285f8e;--bpi-primary-bg-hover:#346f9f;--bpi-primary-border:#3f83ba;--bpi-primary-text:#eaf3fb;--bpi-en-head-text:#cbe8ff;--bpi-en-hint-text:#91b4cf;--bpi-zh-head-text:#cbe9c8;--bpi-zh-hint-text:#91ad93;--bpi-cat-name-text:#9ee8a7;--bpi-info-text:#87bfea;--bpi-link-text:#76c9ff;--bpi-zh-text:#9ed0ff;--bpi-status-error:#ff8b93;--bpi-status-ok:#7fdda2;--bpi-status-busy:#ffd27a;--bpi-badge-warn:#e2ca78;--bpi-badge-source:#86d5c4;--bpi-badge-suspected:#7fc4e0;--bpi-badge-machine:#cbb1ff;--bpi-star-on:#ffd45f;--bpi-star-off:#8f9aaa;--bpi-quick-head-text:#d9ccff;--bpi-quick-hint-text:#a795c6;--bpi-handle-text:#7194ad;--bpi-handle-hover:#a9c8e4;--bpi-mini-hover-bg:#3c4b60;--bpi-handle-hover-bg:#2b3a4d;--bpi-chip-hide-bg:#1d2937;--bpi-waiting-bg:rgba(70,52,25,.5);--bpi-waiting-border:#8a5f16;--bpi-hidden-chip-hover-bg:#31404f;--bpi-issues-bg:rgba(55,39,25,.55);--bpi-issues-border:#5b4a36;--bpi-issue-text:#e6c792;--bpi-row-hover-overlay:rgba(76,167,232,.34);--bpi-row-pinned-overlay:#314b68;--bpi-row-hover-ring:rgba(76,167,232,.7);--bpi-row-pinned-ring:#76c9ff;--bpi-row-hover-text:#fff;--bpi-row-hover-shadow:0 0 7px rgba(109,190,255,.6);--bpi-row-selected-bg:#314b68;--bpi-en-section-bg:rgba(76,167,232,.08);--bpi-quick-section-bg:rgba(167,149,198,.08);--bpi-dup-overlay:rgba(255,139,147,.22);--bpi-editor-bg:#111923;--bpi-hit-bg:#5a4413;--bpi-hit-text:#ffe6a8}
|
||||
[data-bpi-theme="light"]{--bpi-field-bg:#fff;--bpi-field-border:#bbb;--bpi-switch-off:#c9ced6;--bpi-switch-knob:#fff;--bpi-switch-off-border:#b0b6bf;--bpi-custom-bg:#e6f6f1;--bpi-custom-border:#7fc9b0;--bpi-custom-text:#0f6e56;--bpi-surface:rgba(255,255,255,.92);--bpi-surface-2:#f5f5f5;--bpi-surface-3:#eaeaea;--bpi-surface-table:#f0f0f0;--bpi-surface-modal:#fff;--bpi-text:#333;--bpi-text-muted:#666;--bpi-text-faint:#999;--bpi-border:#ccc;--bpi-border-2:#bbb;--bpi-border-3:#e0e0e0;--bpi-button-bg:#e8e8e8;--bpi-button-text:#333;--bpi-input-bg:#fff;--bpi-input-text:#333;--bpi-head-bg:#e8e8e8;--bpi-danger-bg:#c0392b;--bpi-danger-bg-hover:#a53023;--bpi-danger-border:#a53023;--bpi-danger-text:#fff;--bpi-break-bg:#e6f6f1;--bpi-break-bg-hover:#0f6e56;--bpi-break-border:#7fc9b0;--bpi-break-text:#0f6e56;--bpi-primary-bg:#2b6cb0;--bpi-primary-bg-hover:#245a94;--bpi-primary-border:#1e4f85;--bpi-primary-text:#fff;--bpi-en-head-text:#17557f;--bpi-en-hint-text:#5b7f9e;--bpi-zh-head-text:#1f5c22;--bpi-zh-hint-text:#5b7d5e;--bpi-cat-name-text:#1e5a28;--bpi-info-text:#1f5f96;--bpi-link-text:#1a5f96;--bpi-zh-text:#18508a;--bpi-status-error:#b3261e;--bpi-status-ok:#1a7f45;--bpi-status-busy:#8a5a00;--bpi-badge-warn:#8a5a00;--bpi-badge-source:#0f6e56;--bpi-badge-suspected:#1c5f80;--bpi-badge-machine:#5b3fa8;--bpi-star-on:#a8750a;--bpi-star-off:#6b7280;--bpi-quick-head-text:#4a3a7a;--bpi-quick-hint-text:#6b5a8f;--bpi-handle-text:#4a6a85;--bpi-handle-hover:#1f5f96;--bpi-mini-hover-bg:#d8dee7;--bpi-handle-hover-bg:#e2e8ef;--bpi-chip-hide-bg:#eef1f5;--bpi-waiting-bg:#fdf1dc;--bpi-waiting-border:#d9a95a;--bpi-hidden-chip-hover-bg:#e8eef5;--bpi-issues-bg:#fdf6e8;--bpi-issues-border:#d9b98a;--bpi-issue-text:#7a5410;--bpi-row-hover-overlay:rgba(47,108,176,.06);--bpi-row-pinned-overlay:#cfe3f7;--bpi-row-hover-ring:rgba(47,108,176,.55);--bpi-row-pinned-ring:#1a5f96;--bpi-row-hover-text:#1f1f1f;--bpi-row-hover-shadow:none;--bpi-row-selected-bg:#dcebf8;--bpi-en-section-bg:rgba(43,108,176,.06);--bpi-quick-section-bg:rgba(107,85,148,.06);--bpi-dup-overlay:rgba(179,38,30,.12);--bpi-editor-bg:#fff;--bpi-hit-bg:#fbe49c;--bpi-hit-text:#7a5410}
|
||||
.bpi-panel{box-sizing:border-box;width:100%;height:100%;min-height:var(--bpi-collapsed-height,390px);max-height:var(--bpi-collapsed-height,390px);padding:8px;display:flex;flex-direction:column;gap:7px;color:var(--bpi-text);font:12px/1.4 Arial,sans-serif;background:var(--bpi-surface);border:1px solid var(--bpi-border);border-radius:8px;overflow-y:auto;scrollbar-gutter:stable}.bpi-panel.bpi-fill{max-height:none}.bpi-panel.bpi-fill > .bpi-english-section{display:flex;flex-direction:column;flex:1 1 auto;min-height:0}.bpi-panel.bpi-fill > .bpi-english-section > *{flex:none}.bpi-panel.bpi-fill > .bpi-english-section > .bpi-english-token-view{flex:1 1 auto;min-height:92px;max-height:none}.bpi-panel.bpi-fill > .bpi-english-section > .bpi-english-editor{flex:1 1 auto;min-height:120px}
|
||||
.bpi-english-section{flex:none;border:1px solid #3e7197;border-radius:7px;background-color:var(--bpi-en-section-bg);overflow:hidden}.bpi-english-head{display:flex;align-items:center;gap:7px;padding:5px 8px;background:var(--bpi-head-bg);color:var(--bpi-en-head-text);font-weight:700;flex-wrap:wrap}.bpi-english-hint{margin-left:auto;color:var(--bpi-en-hint-text);font-size:10px;font-weight:400}.bpi-english-token-view{box-sizing:border-box;width:100%;height:auto;min-height:92px;max-height:360px;overflow:auto;scrollbar-gutter:stable;padding:8px 9px;outline:none;color:var(--bpi-text);font:12px/1.85 Consolas,monospace;white-space:normal}.bpi-english-token-view:focus{box-shadow:inset 0 0 0 1px #5aa6d8}.bpi-english-editor{box-sizing:border-box;width:100%;height:120px;min-height:92px;max-height:420px;overflow-y:auto;resize:vertical;border:0;border-top:1px solid #3e7197;background-color:transparent;color:var(--bpi-text);padding:9px;outline:none;font:12px/1.55 Consolas,monospace}.bpi-english-editor:focus{box-shadow:inset 0 0 0 1px #5aa6d8}.bpi-english-token{font-family:Consolas,monospace;color:var(--bpi-text)}.bpi-english-token.bpi-linked{background:#147fc3;border-color:#76c9ff;color:#fff;box-shadow:0 0 0 1px rgba(118,201,255,.32)}
|
||||
.bpi-mirror-section{flex:none;border:1px solid #488739;border-radius:7px;background:var(--bpi-surface);overflow:visible}.bpi-section-head{display:flex;align-items:center;gap:7px;padding:5px 8px;background:var(--bpi-head-bg);color:var(--bpi-zh-head-text);font-weight:700;flex-wrap:wrap}.bpi-section-hint{margin-left:auto;color:var(--bpi-zh-hint-text);font-size:10px;font-weight:400}.bpi-mirror-actions{display:flex;gap:4px;align-items:center;flex-wrap:wrap}.bpi-chinese-mirror{box-sizing:border-box;width:100%;height:auto;min-height:92px;max-height:360px;overflow:auto;resize:none;scrollbar-gutter:stable;padding:8px 9px;outline:none;color:var(--bpi-text);line-height:1.85;cursor:text;white-space:normal}.bpi-chinese-mirror:focus{box-shadow:inset 0 0 0 1px #70b35f}.bpi-category-table{display:grid;grid-template-columns:minmax(110px,150px) minmax(0,1fr);border:1px solid #315f3a;border-radius:6px;overflow:hidden;background:var(--bpi-surface-3)}.bpi-category-row{display:contents}.bpi-category-name,.bpi-category-content{padding:6px 8px;border-top:1px solid #31513a}.bpi-category-row:first-child .bpi-category-name,.bpi-category-row:first-child .bpi-category-content{border-top:0}.bpi-category-name{background:var(--bpi-head-bg);border-right:1px solid #31513a;color:var(--bpi-cat-name-text);font-weight:700}.bpi-category-content{min-width:0}.bpi-chinese-editor{box-sizing:border-box;width:100%;height:auto;min-height:110px;max-height:600px;overflow-y:auto;resize:vertical;border:0;border-top:1px solid #3b693c;background:var(--bpi-editor-bg);color:var(--bpi-text);padding:9px;outline:none;font:12px/1.65 Arial,sans-serif}.bpi-chinese-editor:focus{box-shadow:inset 0 0 0 1px #70b35f}.bpi-hidden{display:none!important}.bpi-mirror-empty{color:var(--bpi-text-muted)}.bpi-mirror-token{display:inline-flex;align-items:center;border:1px solid transparent;border-radius:5px;padding:0 3px;margin:1px 0;cursor:pointer;transition:background .12s,border-color .12s,color .12s}.bpi-mirror-token:hover{background:#345a3b;border-color:#5c8d63;color:#fff}.bpi-mirror-token.bpi-linked{background:#315f83;border-color:#76a9ff;color:#fff}
|
||||
.bpi-mirror-token.bpi-linked,.bpi-english-token.bpi-linked{background:#147fc3;border-color:#76c9ff;color:#fff;box-shadow:0 0 0 1px rgba(118,201,255,.32)}.bpi-english-token-view .bpi-mirror-separator{color:#7194ad}
|
||||
.bpi-toolbar,.bpi-search-line,.bpi-summary{display:flex;gap:6px;align-items:center;flex-wrap:wrap}
|
||||
.bpi-toolbar{justify-content:space-between}.bpi-toolbar-group{display:flex;gap:5px;align-items:center;flex-wrap:wrap}
|
||||
.bpi-button{border:1px solid var(--bpi-border-2);border-radius:5px;padding:4px 8px;background:var(--bpi-button-bg);color:var(--bpi-button-text);cursor:pointer;font-size:11px;line-height:1.25}
|
||||
.bpi-button:hover{background:var(--bpi-border-3);border-color:#6c83a8}.bpi-button:disabled{opacity:.45;cursor:default}.bpi-button.bpi-primary{background:#285f8e;border-color:#3f83ba}.bpi-button.bpi-danger{background:#65313b;border-color:#8d4653}
|
||||
.bpi-button:hover{background:var(--bpi-border-3);border-color:#6c83a8}.bpi-button:disabled{opacity:.45;cursor:default}.bpi-button.bpi-primary,.bpi-mini.bpi-primary{background:var(--bpi-primary-bg);border-color:var(--bpi-primary-border);color:var(--bpi-primary-text)}.bpi-button.bpi-primary:hover,.bpi-mini.bpi-primary:hover{background:var(--bpi-primary-bg-hover);border-color:var(--bpi-primary-border);color:var(--bpi-primary-text)}.bpi-button.bpi-danger{background:var(--bpi-danger-bg);border-color:var(--bpi-danger-border);color:var(--bpi-danger-text)}.bpi-button.bpi-danger:hover,.bpi-mini.bpi-danger:hover{background:var(--bpi-danger-bg-hover);border-color:var(--bpi-danger-border);color:var(--bpi-danger-text)}.bpi-mini.bpi-danger{background:var(--bpi-danger-bg);border-color:var(--bpi-danger-border);color:var(--bpi-danger-text)}
|
||||
.bpi-search{flex:1;min-width:140px;border:1px solid var(--bpi-border-2);border-radius:5px;background:var(--bpi-input-bg);color:var(--bpi-input-text);padding:5px 7px;outline:none}.bpi-search:focus{border-color:#4ca7e8}.bpi-searching{padding:5px 8px;color:var(--bpi-text-muted);font-size:10px}
|
||||
.bpi-mode{border:1px solid var(--bpi-border-2);border-radius:5px;background:var(--bpi-input-bg);color:var(--bpi-input-text);padding:5px 6px;font-size:11px;outline:none}.bpi-mode:focus{border-color:#4ca7e8}.bpi-mode-info{color:#87bfea}
|
||||
.bpi-summary{color:var(--bpi-text-muted);font-size:11px;min-height:17px}.bpi-status{margin-left:auto}.bpi-status[data-kind="error"]{color:#ff8b93}.bpi-status[data-kind="ok"]{color:#7fdda2}.bpi-status[data-kind="busy"]{color:#ffd27a}
|
||||
.bpi-issues{display:none;max-height:105px;overflow:auto;border:1px solid #5b4a36;border-radius:6px;background:rgba(55,39,25,.55);padding:4px 6px}.bpi-issues.bpi-visible{display:block}.bpi-issue{padding:2px 4px;color:#e6c792}.bpi-issue[data-severity="error"]{color:#ff9299}.bpi-issue[data-severity="info"]{color:#86c8eb}.bpi-issue::before{content:"⚠ ";}.bpi-issue[data-severity="error"]::before{content:"⛔ ";}.bpi-issue[data-severity="info"]::before{content:"ℹ ";}
|
||||
.bpi-search-label{color:var(--bpi-text-muted);font-size:10px;white-space:nowrap}.bpi-results{display:none;max-height:220px;overflow:auto;border:1px solid var(--bpi-border-2);border-radius:6px;background:var(--bpi-surface-2)}.bpi-results.bpi-visible{display:block}.bpi-result{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr) minmax(150px,auto);gap:7px;padding:5px 7px;border-bottom:1px solid var(--bpi-border-3);cursor:pointer}.bpi-result:last-child{border-bottom:0}.bpi-result:hover{background:var(--bpi-surface-3)}.bpi-result-en{color:var(--bpi-text)}.bpi-result-zh{color:#9ed0ff}.bpi-category{color:var(--bpi-text-muted);font-size:10px;white-space:normal}.bpi-search-reason{display:block;color:#77b6df;margin-top:2px}.bpi-search-more{display:block;margin:7px auto}.bpi-results>.bpi-searching{text-align:center}
|
||||
.bpi-table{position:relative;height:300px;min-height:180px;max-height:360px;flex:1 1 300px;overflow-x:hidden;overflow-y:scroll;scrollbar-gutter:stable;border:1px solid var(--bpi-border-2);border-radius:6px;background:var(--bpi-surface-table)}.bpi-split-handle{position:absolute;top:0;bottom:0;left:var(--bpi-split,40%);width:9px;margin-left:-4px;cursor:col-resize;z-index:4}.bpi-split-handle:hover{background:rgba(76,167,232,.35)}.bpi-head,.bpi-row{display:grid;grid-template-columns:var(--bpi-split,minmax(0,2fr)) minmax(0,1fr);gap:1px}.bpi-head{position:sticky;top:0;z-index:2;background:var(--bpi-head-bg);color:var(--bpi-text-muted);font-weight:700}.bpi-head>div,.bpi-cell{padding:6px 8px}.bpi-head>div+div,.bpi-cell+.bpi-cell{border-left:1px solid var(--bpi-border-2)}
|
||||
.bpi-row{border-top:1px solid var(--bpi-border-3);cursor:pointer}.bpi-row:hover,.bpi-row.bpi-pinned{background:#314b68}.bpi-row:hover .bpi-cell,.bpi-row.bpi-pinned .bpi-cell{color:#fff}.bpi-row.bpi-unknown{background:rgba(112,70,28,.18)}.bpi-row.bpi-machine{background:rgba(85,62,131,.22)}.bpi-row.bpi-has-warning{box-shadow:inset 3px 0 #d39845}.bpi-row.bpi-has-error{box-shadow:inset 3px 0 #e35b66}
|
||||
.bpi-cell{min-width:0;word-break:break-word;display:flex;align-items:flex-start;gap:5px}.bpi-en{color:var(--bpi-text);font-family:Consolas,monospace}.bpi-zh{color:#9ed0ff}.bpi-row:hover .bpi-en,.bpi-row:hover .bpi-zh,.bpi-row.bpi-pinned .bpi-en,.bpi-row.bpi-pinned .bpi-zh{color:#fff;text-shadow:0 0 7px rgba(109,190,255,.6)}
|
||||
.bpi-badge{flex:none;border:1px solid var(--bpi-border-2);border-radius:8px;padding:0 5px;color:var(--bpi-text-muted);font-size:9px;line-height:15px}.bpi-source{border-color:#3e6f67;color:#86d5c4}.bpi-confidence-high{border-color:#3f785c;color:#85d5a6}.bpi-confidence-medium{border-color:#8a7538;color:#e2ca78}.bpi-confidence-low,.bpi-confidence-none{border-color:#7a4c55;color:#ee9ca8}.bpi-unknown .bpi-badge{color:#ffc277;border-color:#85602f}.bpi-machine .bpi-badge{color:#cbb1ff;border-color:#6b5594}.bpi-inline-actions{display:flex;gap:3px;margin-left:auto;flex-wrap:wrap}.bpi-mini{border:1px solid var(--bpi-border-2);border-radius:4px;background:var(--bpi-button-bg);color:var(--bpi-button-text);padding:1px 5px;cursor:pointer;font-size:9px}.bpi-mini:hover{background:#3c4b60}.bpi-inline-editor{min-width:90px;flex:1;border:1px solid #4ca7e8;border-radius:4px;background:var(--bpi-input-bg);color:var(--bpi-input-text);padding:3px 5px}.bpi-empty{padding:24px;text-align:center;color:var(--bpi-text-faint)}
|
||||
.bpi-mode{border:1px solid var(--bpi-border-2);border-radius:5px;background:var(--bpi-input-bg);color:var(--bpi-input-text);padding:5px 6px;font-size:11px;outline:none}.bpi-mode:focus{border-color:#4ca7e8}.bpi-mode-info{color:var(--bpi-info-text)}
|
||||
.bpi-summary{color:var(--bpi-text-muted);font-size:11px;min-height:17px}.bpi-status{margin-left:auto}.bpi-status[data-kind="error"]{color:var(--bpi-status-error)}.bpi-status[data-kind="ok"]{color:var(--bpi-status-ok)}.bpi-status[data-kind="busy"]{color:var(--bpi-status-busy)}
|
||||
.bpi-issues{display:none;max-height:105px;overflow:auto;border:1px solid var(--bpi-issues-border);border-radius:6px;background:var(--bpi-issues-bg);padding:4px 6px}.bpi-issues.bpi-visible{display:block}.bpi-issue{padding:2px 4px;color:var(--bpi-issue-text)}.bpi-issue[data-severity="error"]{color:var(--bpi-status-error)}.bpi-issue[data-severity="info"]{color:var(--bpi-info-text)}.bpi-issue::before{content:"⚠ ";}.bpi-issue[data-severity="error"]::before{content:"⛔ ";}.bpi-issue[data-severity="info"]::before{content:"ℹ ";}
|
||||
.bpi-search-label{color:var(--bpi-text-muted);font-size:10px;white-space:nowrap}.bpi-results{display:none;max-height:220px;overflow:auto;border:1px solid var(--bpi-border-2);border-radius:6px;background:var(--bpi-surface-2)}.bpi-results.bpi-visible{display:block}.bpi-result{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr) minmax(150px,auto);gap:7px;padding:5px 7px;border-bottom:1px solid var(--bpi-border-3);cursor:pointer}.bpi-result:last-child{border-bottom:0}.bpi-result:hover{background:var(--bpi-surface-3)}.bpi-result-en{color:var(--bpi-text)}.bpi-result-zh{color:var(--bpi-zh-text)}.bpi-category{color:var(--bpi-text-muted);font-size:10px;white-space:normal}.bpi-search-reason{display:block;color:var(--bpi-info-text);margin-top:2px}.bpi-search-more{display:block;margin:7px auto}.bpi-results>.bpi-searching{text-align:center}
|
||||
.bpi-table{position:relative;height:300px;min-height:180px;max-height:360px;flex:1 1 300px;overflow-x:hidden;overflow-y:scroll;scrollbar-gutter:stable;border:1px solid var(--bpi-border-2);border-radius:0 0 6px 6px;background:var(--bpi-surface-table)}.bpi-split-handle{position:absolute;top:0;bottom:0;left:var(--bpi-split,40%);width:9px;margin-left:-4px;cursor:col-resize;z-index:4}.bpi-split-handle:hover{background:rgba(76,167,232,.35)}.bpi-head,.bpi-row{display:grid;grid-template-columns:var(--bpi-split,minmax(0,2fr)) minmax(0,1fr);gap:1px}.bpi-head{position:sticky;top:0;z-index:2;background:var(--bpi-head-bg);color:var(--bpi-text-muted);font-weight:700}.bpi-head>div,.bpi-cell{padding:6px 8px}.bpi-head>div+div,.bpi-cell+.bpi-cell{border-left:1px solid var(--bpi-border-2)}
|
||||
.bpi-row{border-top:1px solid var(--bpi-border-3);cursor:pointer}.bpi-row:hover{background-image:linear-gradient(var(--bpi-row-hover-overlay),var(--bpi-row-hover-overlay));outline:1px dashed var(--bpi-row-hover-ring);outline-offset:-1px}.bpi-row.bpi-pinned{background-image:linear-gradient(var(--bpi-row-pinned-overlay),var(--bpi-row-pinned-overlay));outline:1px solid var(--bpi-row-pinned-ring);outline-offset:-1px;font-weight:500}.bpi-row:hover .bpi-cell,.bpi-row.bpi-pinned .bpi-cell{color:var(--bpi-row-hover-text)}.bpi-row.bpi-unknown{background-color:rgba(112,70,28,.18)}.bpi-row.bpi-machine{background-color:rgba(85,62,131,.22)}.bpi-row.bpi-suspected-natural{background-color:rgba(58,132,160,.20)}.bpi-row.bpi-has-warning{box-shadow:inset 3px 0 #d39845}.bpi-row.bpi-has-error{box-shadow:inset 3px 0 #e35b66}
|
||||
.bpi-cell{min-width:0;word-break:break-word;display:flex;align-items:flex-start;gap:5px}.bpi-en{color:var(--bpi-text);font-family:Consolas,monospace}.bpi-zh{color:var(--bpi-zh-text)}.bpi-row:hover .bpi-en,.bpi-row:hover .bpi-zh,.bpi-row.bpi-pinned .bpi-en,.bpi-row.bpi-pinned .bpi-zh{color:var(--bpi-row-hover-text);text-shadow:var(--bpi-row-hover-shadow)}
|
||||
.bpi-badge{flex:none;border:1px solid var(--bpi-border-2);border-radius:8px;padding:0 5px;color:var(--bpi-text-muted);font-size:9px;line-height:15px}.bpi-source{border-color:#3e6f67;color:var(--bpi-badge-source)}.bpi-suspected{border-color:#3a84a0;color:var(--bpi-badge-suspected)}.bpi-confidence-high{border-color:#3f785c;color:var(--bpi-status-ok)}.bpi-confidence-medium{border-color:#8a7538;color:var(--bpi-badge-warn)}.bpi-confidence-low,.bpi-confidence-none{border-color:#7a4c55;color:var(--bpi-status-error)}.bpi-unknown .bpi-badge{color:var(--bpi-badge-warn);border-color:#85602f}.bpi-machine .bpi-badge{color:var(--bpi-badge-machine);border-color:#6b5594}.bpi-inline-actions{display:flex;gap:3px;margin-left:auto;flex-wrap:wrap}.bpi-mini{border:1px solid var(--bpi-border-2);border-radius:4px;background:var(--bpi-button-bg);color:var(--bpi-button-text);padding:1px 5px;cursor:pointer;font-size:9px}.bpi-mini:hover{background:var(--bpi-mini-hover-bg)}.bpi-inline-editor{min-width:90px;flex:1;border:1px solid #4ca7e8;border-radius:4px;background:var(--bpi-input-bg);color:var(--bpi-input-text);padding:3px 5px}.bpi-empty{padding:24px;text-align:center;color:var(--bpi-text-faint)}
|
||||
.bpi-filters{display:flex;gap:4px;align-items:center;flex-wrap:wrap}
|
||||
.bpi-batch-bar{flex:none;gap:8px;padding:6px 8px;margin-bottom:6px;border:1px solid var(--bpi-border-2);border-radius:6px;background:var(--bpi-surface-3)}.bpi-filter-active{background:#365f84;border-color:#5792c4;color:#fff}.bpi-star{color:#8f9aaa}.bpi-star.bpi-starred{color:#ffd45f;border-color:#8f772f}.bpi-result.bpi-result-selected{background:#314b68;outline:1px solid #5688b5}.bpi-result-star{font-size:14px;color:#8995a8;align-self:center}.bpi-result-star.bpi-starred{color:#ffd45f}
|
||||
.bpi-quick-section{flex:none;border:1px solid #6b5594;border-radius:7px;background:var(--bpi-surface);overflow:hidden}.bpi-quick-head{display:flex;align-items:center;gap:7px;padding:5px 8px;background:var(--bpi-head-bg);color:#d9ccff;font-weight:700;flex-wrap:wrap}.bpi-quick-hint{margin-left:auto;color:#a795c6;font-size:10px;font-weight:400}.bpi-quick-row{display:flex;gap:6px;align-items:flex-start;padding:6px 8px}.bpi-quick-input{flex:1;min-width:0;box-sizing:border-box;height:44px;min-height:44px;max-height:140px;overflow-y:auto;resize:vertical;border:1px solid var(--bpi-border-2);border-radius:5px;background:var(--bpi-input-bg);color:var(--bpi-input-text);padding:5px 7px;outline:none;font:12px/1.55 Arial,sans-serif}.bpi-quick-input:focus{box-shadow:inset 0 0 0 1px #a98be0}.bpi-quick-row .bpi-button{flex:none}
|
||||
.bpi-drag-handle{flex:none;cursor:grab;color:#7194ad;font-size:11px;line-height:1;user-select:none;padding:1px 2px;border-radius:3px}.bpi-drag-handle:hover{color:#a9c8e4;background:#2b3a4d}.bpi-drag-handle:active{cursor:grabbing}
|
||||
.bpi-table-search{flex:none;display:flex;align-items:center;gap:6px;box-sizing:border-box;padding:4px 6px;margin-bottom:-7px;border:1px solid var(--bpi-border-2);border-bottom:0;border-radius:6px 6px 0 0;background-color:var(--bpi-head-bg)}.bpi-table-search-icon{flex:none;display:inline-flex;color:var(--bpi-text-faint)}.bpi-search.bpi-table-search-input{flex:1;min-width:0;padding:3px 6px}.bpi-table-search-count{flex:none;color:var(--bpi-text-muted);font-size:10px;white-space:nowrap}.bpi-table-search-clear{flex:none;display:none;border:0;background-color:transparent;color:var(--bpi-text-muted);cursor:pointer;font-size:13px;line-height:1;padding:1px 3px;border-radius:3px}.bpi-table-search-clear.bpi-visible{display:block}.bpi-table-search-clear:hover{color:var(--bpi-text);background-color:var(--bpi-border-3)}.bpi-hit{background-color:var(--bpi-hit-bg);color:var(--bpi-hit-text);border-radius:2px}
|
||||
.bpi-batch-bar{flex:none;gap:8px;padding:6px 8px;margin-bottom:6px;border:1px solid var(--bpi-border-2);border-radius:6px;background:var(--bpi-surface-3)}.bpi-filter-active{background:#365f84;border-color:#5792c4;color:#fff}.bpi-star{color:var(--bpi-star-off)}.bpi-star.bpi-starred{color:var(--bpi-star-on);border-color:#8f772f}.bpi-result.bpi-result-selected{background:var(--bpi-row-selected-bg);outline:1px solid var(--bpi-link-text)}.bpi-result-star{font-size:14px;color:var(--bpi-star-off);align-self:center}.bpi-result-star.bpi-starred{color:var(--bpi-star-on)}
|
||||
.bpi-quick-section{flex:none;border:1px solid #6b5594;border-radius:7px;background-color:var(--bpi-quick-section-bg);overflow:hidden}.bpi-quick-head{display:flex;align-items:center;gap:7px;padding:5px 8px;background:var(--bpi-head-bg);color:var(--bpi-quick-head-text);font-weight:700;flex-wrap:wrap}.bpi-quick-hint{margin-left:auto;color:var(--bpi-quick-hint-text);font-size:10px;font-weight:400}.bpi-quick-target{padding:0 8px 6px;font-size:11px;color:var(--bpi-text-muted)}.bpi-quick-target-linked{color:var(--bpi-link-text)}.bpi-quick-row{display:flex;gap:6px;align-items:flex-start;padding:6px 8px}.bpi-quick-input{flex:1;min-width:0;box-sizing:border-box;height:44px;min-height:44px;max-height:140px;overflow-y:auto;resize:vertical;border:1px solid var(--bpi-border-2);border-radius:5px;background:var(--bpi-input-bg);color:var(--bpi-input-text);padding:5px 7px;outline:none;font:12px/1.55 Arial,sans-serif}.bpi-quick-input:focus{box-shadow:inset 0 0 0 1px #a98be0}.bpi-quick-row .bpi-button{flex:none}
|
||||
.bpi-drag-handle{flex:none;cursor:grab;color:var(--bpi-handle-text);font-size:11px;line-height:1;user-select:none;padding:1px 2px;border-radius:3px}.bpi-drag-handle:hover{color:var(--bpi-handle-hover);background:var(--bpi-handle-hover-bg)}.bpi-drag-handle:active{cursor:grabbing}
|
||||
.bpi-row.bpi-dragging,.bpi-english-token.bpi-dragging{opacity:.45}.bpi-english-token.bpi-dragging{cursor:grabbing}
|
||||
.bpi-drop-indicator{position:absolute;left:3px;right:3px;height:0;border-top:2px solid #76c9ff;box-shadow:0 0 5px rgba(118,201,255,.55);z-index:3;pointer-events:none}
|
||||
.bpi-break-row{display:flex;align-items:center;gap:8px;border-top:1px solid var(--bpi-border-3);background:#0f6e56;color:#bff0dd;padding:6px 10px;cursor:default}
|
||||
.bpi-break-row{display:flex;align-items:center;gap:8px;border-top:1px solid var(--bpi-border-3);background:var(--bpi-break-bg);color:var(--bpi-break-text);padding:6px 10px;cursor:default}
|
||||
.bpi-row.bpi-break-row:hover{background-color:var(--bpi-break-bg-hover);background-image:none;outline:0;color:#fff}
|
||||
.bpi-break-row.bpi-dragging{opacity:.45}
|
||||
.bpi-break-row .bpi-drag-handle{color:#bff0dd}.bpi-break-row .bpi-drag-handle:hover{color:#fff;background:#0a5240}
|
||||
.bpi-break-row .bpi-drag-handle{color:var(--bpi-break-text)}.bpi-break-row .bpi-drag-handle:hover{color:#fff;background:var(--bpi-break-bg-hover)}
|
||||
.bpi-break-row-label{flex:1;font-size:12px;font-weight:500;letter-spacing:.5px}
|
||||
.bpi-mirror-token{user-select:none}.bpi-english-token.bpi-drop-before,.bpi-mirror-token.bpi-drop-before{box-shadow:-2px 0 0 0 #76c9ff,0 0 5px rgba(118,201,255,.4)}.bpi-english-token.bpi-drop-after,.bpi-mirror-token.bpi-drop-after{box-shadow:2px 0 0 0 #76c9ff,0 0 5px rgba(118,201,255,.4)}
|
||||
.bpi-english-token{position:relative}.bpi-hide-btn{flex:none;cursor:pointer;color:#7194ad;font-size:11px;line-height:1;user-select:none;padding:1px 2px;border-radius:3px;display:inline-flex;align-items:center}.bpi-hide-btn:hover{color:#9ed0ff;background:#2b3a4d}.bpi-eye-icon{display:inline-flex;align-items:center}
|
||||
.bpi-english-token{position:relative}.bpi-hide-btn{flex:none;cursor:pointer;color:var(--bpi-handle-text);font-size:11px;line-height:1;user-select:none;padding:1px 2px;border-radius:3px;display:inline-flex;align-items:center}.bpi-hide-btn:hover{color:var(--bpi-link-text);background:var(--bpi-handle-hover-bg)}.bpi-eye-icon{display:inline-flex;align-items:center}
|
||||
.bpi-card-hidden{opacity:.55;filter:grayscale(.7)}
|
||||
.bpi-card-hidden:hover{opacity:.75}
|
||||
.bpi-card-hidden .bpi-english-token{text-decoration:line-through}
|
||||
.bpi-card-hidden .bpi-token-card-zh{color:var(--bpi-text-faint)}
|
||||
.bpi-card-hidden .bpi-chip-hide{display:inline-flex}
|
||||
.bpi-chip-hidden-mark{color:#e8b06a}
|
||||
.bpi-chip-hidden-mark{color:var(--bpi-badge-warn)}
|
||||
.bpi-row-hidden{opacity:.6}
|
||||
.bpi-row-hidden:hover{opacity:.8}
|
||||
.bpi-hidden-term{text-decoration:line-through;color:var(--bpi-text-faint)}
|
||||
.bpi-hide-btn-off{color:#e8b06a}
|
||||
.bpi-eye-off{color:#e8b06a}
|
||||
.bpi-chip-hide{position:absolute;top:-5px;right:-5px;display:none;cursor:pointer;padding:1px 2px;border-radius:999px;background:#1d2937;border:1px solid #4c5668;color:#9ed0ff;line-height:1;z-index:2}.bpi-english-token:hover .bpi-chip-hide{display:inline-flex}.bpi-chip-hide:hover{background:#285f8e;border-color:#76c9ff;color:#fff}
|
||||
.bpi-hide-btn-off{color:var(--bpi-badge-warn)}
|
||||
.bpi-eye-off{color:var(--bpi-badge-warn)}
|
||||
.bpi-chip-hide{position:absolute;top:2px;right:3px;display:none;cursor:pointer;padding:1px 2px;border-radius:999px;background:var(--bpi-chip-hide-bg);border:1px solid var(--bpi-border-2);color:var(--bpi-link-text);line-height:1;z-index:2}.bpi-token-card:hover .bpi-chip-hide{display:inline-flex}.bpi-chip-hide:hover{background:var(--bpi-primary-bg);border-color:var(--bpi-primary-border);color:#fff}
|
||||
.bpi-token-line{display:flex;flex-wrap:wrap;align-items:flex-end;gap:2px 0}
|
||||
.bpi-token-card{display:inline-flex;flex-direction:column;align-items:flex-start;max-width:100%;border:1px solid var(--bpi-border-3);border-radius:5px;background:var(--bpi-surface-3);padding:1px 5px;margin:2px 3px 2px 0;vertical-align:top;cursor:pointer}
|
||||
.bpi-token-card{position:relative;display:inline-flex;flex-direction:column;align-items:flex-start;max-width:100%;border:1px solid var(--bpi-border-3);border-radius:5px;background:var(--bpi-surface-3);padding:1px 5px;margin:2px 3px 2px 0;vertical-align:top;cursor:pointer}
|
||||
.bpi-token-card:hover{border-color:var(--bpi-border-2)}
|
||||
.bpi-token-card.bpi-dragging{opacity:.45;cursor:grabbing}
|
||||
.bpi-token-card .bpi-english-token{margin:0;padding:0}
|
||||
@@ -813,37 +1291,41 @@ function injectBpiStyles() {
|
||||
.bpi-token-card.bpi-card-linked .bpi-english-token{background:transparent;border-color:transparent;box-shadow:none;color:#fff}
|
||||
.bpi-token-card-zh{min-height:15px;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--bpi-text-muted);font:11px/1.35 Arial,sans-serif}
|
||||
.bpi-category-content{display:flex;flex-wrap:wrap;gap:2px 3px}
|
||||
.bpi-break-chip{position:relative;display:inline-flex;align-items:center;gap:3px;border:1px dashed #6c83a8;border-radius:5px;background:#1d2937;color:#9ed0ff;padding:0 5px;margin:1px 0;cursor:grab;user-select:none;font-family:Consolas,monospace}
|
||||
.bpi-break-chip:hover{background:#285f8e;border-color:#76c9ff;color:#fff}
|
||||
.bpi-break-chip{position:relative;display:inline-flex;align-items:center;gap:3px;border:1px dashed var(--bpi-break-border);border-radius:5px;background:var(--bpi-break-bg);color:var(--bpi-break-text);padding:0 5px;margin:1px 0;cursor:grab;user-select:none;font-family:Consolas,monospace}
|
||||
.bpi-break-chip:hover,.bpi-break-chip.bpi-drop-target{background:var(--bpi-break-bg-hover);border-color:var(--bpi-break-text);color:#fff}
|
||||
.bpi-break-chip.bpi-dragging{opacity:.45;cursor:grabbing}
|
||||
.bpi-break-chip.bpi-drop-target{background:#285f8e;border-color:#76c9ff;color:#fff}
|
||||
.bpi-break-x{display:none;cursor:pointer;color:#9ed0ff;line-height:1}
|
||||
.bpi-break-x{display:none;cursor:pointer;line-height:1}
|
||||
.bpi-break-chip:hover .bpi-break-x{display:inline-flex}
|
||||
.bpi-break-x:hover{color:#fff}
|
||||
.bpi-source-bar{display:flex;gap:7px;align-items:center;flex-wrap:wrap;padding:5px 7px;margin-bottom:7px;border:1px solid var(--bpi-border-2);border-radius:6px;background:var(--bpi-surface-3)}
|
||||
.bpi-source-bar.bpi-source-waiting{border-color:#8a5f16;background:rgba(70,52,25,.5)}
|
||||
.bpi-source-label{color:#87bfea;font-size:11px}
|
||||
.bpi-source-bar.bpi-source-waiting{border-color:var(--bpi-waiting-border);background:var(--bpi-waiting-bg)}
|
||||
.bpi-source-label{color:var(--bpi-info-text);font-size:11px}
|
||||
.bpi-source-state{color:var(--bpi-text-muted);font-size:11px}
|
||||
.bpi-source-state.bpi-source-waiting-text{color:#ffd27a}
|
||||
.bpi-source-state.bpi-source-waiting-text{color:var(--bpi-badge-warn)}
|
||||
.bpi-source-toggle{display:inline-flex;gap:5px;align-items:center;font-size:11px;color:var(--bpi-text);cursor:pointer;user-select:none}
|
||||
.bpi-source-toggle input{margin:0;accent-color:#3f83ba}
|
||||
.bpi-source-select{border:1px solid var(--bpi-border-2);border-radius:5px;background:var(--bpi-input-bg);color:var(--bpi-input-text);padding:3px 5px;font-size:11px}
|
||||
.bpi-source-select:disabled{opacity:.45}
|
||||
.bpi-save-drop{position:relative;display:flex;flex-direction:column;gap:5px;align-items:center;justify-content:center;min-height:86px;border:1px dashed var(--bpi-border-2);border-radius:7px;background:var(--bpi-surface-3);color:var(--bpi-text-faint);font-size:11px;text-align:center;cursor:pointer}
|
||||
.bpi-save-drop.bpi-dragover{border-color:#4ca7e8;color:#9ed0ff}
|
||||
.bpi-save-drop.bpi-dragover{border-color:#4ca7e8;color:var(--bpi-link-text)}
|
||||
.bpi-autocomplete{position:relative}
|
||||
.bpi-chip-row{display:flex;flex-wrap:wrap;gap:5px;align-items:center;box-sizing:border-box;width:100%;min-height:30px;border:1px solid var(--bpi-border-2);border-radius:5px;background:var(--bpi-input-bg);padding:5px 6px}
|
||||
.bpi-form .bpi-chip-row input{flex:1;min-width:90px;width:auto;border:0;outline:none;background:transparent;color:var(--bpi-input-text);padding:2px 0;font:12px Arial,sans-serif}
|
||||
.bpi-chip{display:inline-flex;align-items:center;gap:4px;max-width:100%;padding:2px 5px 2px 8px;border:1px solid var(--bpi-border-2);border-radius:11px;background:var(--bpi-surface-3);color:var(--bpi-text);font:11px Arial,sans-serif}
|
||||
.bpi-chip-label{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:160px}
|
||||
.bpi-chip-x{border:0;background:transparent;color:var(--bpi-text-muted);cursor:pointer;font:12px Arial,sans-serif;line-height:1;padding:0 1px}
|
||||
.bpi-chip-x:hover{color:#ff8b93}
|
||||
.bpi-chip-x:hover{color:var(--bpi-status-error)}
|
||||
.bpi-ac-menu{position:absolute;left:0;right:0;top:calc(100% + 4px);z-index:95;max-height:188px;overflow:auto;padding:3px;border:1px solid var(--bpi-border-2);border-radius:6px;background:var(--bpi-surface-modal);box-shadow:0 6px 16px rgba(0,0,0,.25)}
|
||||
.bpi-ac-menu[hidden]{display:none}
|
||||
.bpi-ac-item{display:flex;align-items:center;gap:8px;padding:5px 7px;border-radius:4px;color:var(--bpi-text);font:12px Arial,sans-serif;cursor:pointer}
|
||||
.bpi-ac-item:hover,.bpi-ac-item.bpi-ac-active{background:var(--bpi-surface-3)}
|
||||
.bpi-ac-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.bpi-ac-kind{margin-left:auto;flex:none;color:var(--bpi-text-faint);font-size:10px}
|
||||
.bpi-ac-custom{color:var(--bpi-custom-text);border-bottom:1px solid var(--bpi-border-3);border-radius:4px 4px 0 0}
|
||||
.bpi-input-wrap{display:flex;align-items:center;gap:5px;box-sizing:border-box;width:100%;border:1px solid var(--bpi-border-2);border-radius:5px;background:var(--bpi-input-bg);padding:5px 6px}
|
||||
.bpi-input-wrap:focus-within{border-color:#4ca7e8}
|
||||
.bpi-form .bpi-input-wrap input{flex:1;min-width:0;width:auto;border:0;outline:none;background:transparent;color:var(--bpi-input-text);padding:0;font:12px Arial,sans-serif}
|
||||
.bpi-custom-badge{flex:none;border:1px solid var(--bpi-custom-border);border-radius:8px;background:var(--bpi-custom-bg);color:var(--bpi-custom-text);font-size:10px;line-height:15px;padding:0 5px}
|
||||
.bpi-form-hint{color:var(--bpi-text-faint);font-size:10px}
|
||||
.bpi-model-tags{display:flex;flex-wrap:wrap;gap:4px}
|
||||
.bpi-model-tag{padding:1px 7px;border:1px solid var(--bpi-border-2);border-radius:10px;background:var(--bpi-surface-3);color:var(--bpi-text-muted);font-size:10px}
|
||||
@@ -858,61 +1340,132 @@ function injectBpiStyles() {
|
||||
.bpi-fav-meta{display:flex;flex-wrap:wrap;gap:5px;align-items:center;color:var(--bpi-text-faint);font-size:10px}
|
||||
.bpi-fav-actions{display:flex;flex-wrap:wrap;gap:5px;margin-top:2px}
|
||||
.bpi-hidden-bar{display:flex;align-items:center;gap:6px;flex-wrap:wrap;padding:5px 2px;border-top:1px solid var(--bpi-border-3)}.bpi-hidden-bar.bpi-hidden{display:none}.bpi-hidden-label{flex:none;color:var(--bpi-text-muted);font-size:11px}
|
||||
.bpi-hidden-chip{display:inline-flex;align-items:center;gap:5px;max-width:280px;overflow:hidden;border:1px dashed var(--bpi-border-2);border-radius:999px;padding:2px 8px;background:var(--bpi-surface-3);color:var(--bpi-text-muted);cursor:pointer;font-size:11px}.bpi-hidden-chip:hover{background:#31404f;border-color:#76c9ff;color:#d7e6f7}.bpi-hidden-en{text-decoration:line-through;white-space:nowrap}.bpi-hidden-zh{color:var(--bpi-text-muted);font-size:10px}.bpi-hidden-restore{color:#76c9ff;font-size:12px}
|
||||
.bpi-hidden-chip{display:inline-flex;align-items:center;gap:5px;max-width:280px;overflow:hidden;border:1px dashed var(--bpi-border-2);border-radius:999px;padding:2px 8px;background:var(--bpi-surface-3);color:var(--bpi-text-muted);cursor:pointer;font-size:11px}.bpi-hidden-chip:hover{background:var(--bpi-hidden-chip-hover-bg);border-color:var(--bpi-link-text);color:var(--bpi-row-hover-text)}.bpi-hidden-en{text-decoration:line-through;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}.bpi-hidden-zh{color:var(--bpi-text-muted);font-size:10px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}.bpi-hidden-restore{color:var(--bpi-link-text);font-size:12px}
|
||||
.bpi-hidden-all{margin-left:auto}
|
||||
.bpi-modal-shade{position:fixed;inset:0;z-index:10020;background:rgba(0,0,0,.58);display:flex;align-items:center;justify-content:center}.bpi-modal{width:min(520px,calc(100vw - 32px));max-height:calc(100vh - 40px);overflow:auto;background:var(--bpi-surface-modal);color:var(--bpi-text);border:1px solid var(--bpi-border-2);border-radius:10px;padding:16px;box-shadow:0 18px 55px rgba(0,0,0,.55)}.bpi-modal h3{margin:0 0 12px;font-size:16px}.bpi-form{display:grid;grid-template-columns:92px minmax(0,1fr);gap:9px;align-items:center}.bpi-form label{color:var(--bpi-text-muted)}.bpi-form input,.bpi-form select,.bpi-form textarea{box-sizing:border-box;width:100%;border:1px solid var(--bpi-border-2);border-radius:5px;background:var(--bpi-input-bg);color:var(--bpi-input-text);padding:6px 7px}.bpi-form textarea{min-height:112px;resize:vertical;font:11px/1.45 Arial,sans-serif}.bpi-form input:focus,.bpi-form select:focus,.bpi-form textarea:focus{outline:none;border-color:#4ca7e8}.bpi-modal-actions{display:flex;justify-content:flex-end;gap:7px;margin-top:14px;flex-wrap:wrap}.bpi-weight-presets{display:flex;gap:5px;flex-wrap:wrap;margin-top:10px}
|
||||
.bpi-modal-shade{position:fixed;inset:0;z-index:10020;background:rgba(0,0,0,.58);display:flex;align-items:center;justify-content:center}.bpi-modal{width:min(520px,calc(100vw - 32px));max-height:calc(100vh - 40px);overflow:auto;background:var(--bpi-surface-modal);color:var(--bpi-text);border:1px solid var(--bpi-border-2);border-radius:10px;padding:16px;box-shadow:0 18px 55px rgba(0,0,0,.55)}.bpi-modal h3{margin:0 0 12px;font-size:16px}.bpi-form{display:grid;grid-template-columns:92px minmax(0,1fr);gap:9px;align-items:center}.bpi-form [hidden]{display:none!important}.bpi-form label{color:var(--bpi-text-muted)}.bpi-form input,.bpi-form select,.bpi-form textarea{box-sizing:border-box;width:100%;border:1px solid var(--bpi-border-2);border-radius:5px;background:var(--bpi-input-bg);color:var(--bpi-input-text);padding:6px 7px}.bpi-form textarea{min-height:112px;resize:vertical;font:11px/1.45 Arial,sans-serif}.bpi-form input:focus,.bpi-form select:focus,.bpi-form textarea:focus{outline:none;border-color:#4ca7e8}.bpi-modal-actions{display:flex;justify-content:flex-end;gap:7px;margin-top:14px;flex-wrap:wrap}.bpi-weight-presets{display:flex;gap:5px;flex-wrap:wrap;margin-top:10px}
|
||||
.bpi-editor-tabs{display:flex;gap:0;border-bottom:1px solid var(--bpi-border);margin-bottom:12px}
|
||||
.bpi-editor-tab{background:none;border:none;border-bottom:2px solid transparent;padding:5px 12px;color:var(--bpi-text-muted);font:12px Arial,sans-serif;cursor:pointer}
|
||||
.bpi-editor-tab:hover{color:var(--bpi-text)}
|
||||
.bpi-editor-tab.bpi-editor-tab-active{color:#76c9ff;border-bottom-color:#76c9ff}
|
||||
.bpi-editor-tab.bpi-editor-tab-active{color:var(--bpi-link-text);border-bottom-color:var(--bpi-link-text)}
|
||||
.bpi-editor-panel{display:block}
|
||||
.bpi-editor-divider{height:1px;background:var(--bpi-border-3);margin:12px 0}
|
||||
.bpi-form .bpi-field-static{box-sizing:border-box;padding:6px 0;color:var(--bpi-text);font-family:Consolas,monospace;word-break:break-all}
|
||||
.bpi-raw-editor{box-sizing:border-box;width:100%;min-height:72px;max-height:220px;resize:vertical;border:1px solid var(--bpi-border-2);border-radius:5px;background:var(--bpi-input-bg);color:var(--bpi-input-text);padding:5px 7px;outline:none;font:12px/1.55 Consolas,monospace}
|
||||
.bpi-raw-editor:focus{border-color:#4ca7e8}
|
||||
.bpi-token-card.bpi-card-multi{position:relative}
|
||||
.bpi-row.bpi-row-multi{position:relative}
|
||||
/* 多选的虚线框做成会流动的「蚂蚁线」:四边各一条虚线渐变,靠 background-position 走一个周期,
|
||||
看着就是虚线绕着选中的提示词转。用伪元素画,不占元素自己的 background-image(行选中态要用它) */
|
||||
.bpi-card-multi::after,.bpi-row-multi::after{content:"";position:absolute;border-radius:inherit;pointer-events:none;background-image:repeating-linear-gradient(90deg,var(--bpi-link-text) 0 4px,transparent 4px 8px),repeating-linear-gradient(90deg,var(--bpi-link-text) 0 4px,transparent 4px 8px),repeating-linear-gradient(180deg,var(--bpi-link-text) 0 4px,transparent 4px 8px),repeating-linear-gradient(180deg,var(--bpi-link-text) 0 4px,transparent 4px 8px);background-size:100% 1px,100% 1px,1px 100%,1px 100%;background-position:0 0,0 100%,0 0,100% 0;background-repeat:repeat-x,repeat-x,repeat-y,repeat-y;animation:bpi-ants .6s linear infinite}
|
||||
.bpi-card-multi::after{inset:-2px}
|
||||
.bpi-row-multi::after{inset:0}
|
||||
@keyframes bpi-ants{to{background-position:8px 0,-8px 100%,0 -8px,100% 8px}}
|
||||
.bpi-color-presets{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:10px}
|
||||
.bpi-color-swatch{width:26px;height:26px;border-radius:4px;border:1px solid var(--bpi-border);cursor:pointer;display:inline-block}
|
||||
.bpi-color-swatch:hover{border-color:var(--bpi-border-2)}
|
||||
.bpi-color-swatch.bpi-color-swatch-active{border-color:#76c9ff;box-shadow:0 0 0 1px #76c9ff}
|
||||
.bpi-color-swatch.bpi-color-swatch-active{border-color:var(--bpi-link-text);box-shadow:0 0 0 1px var(--bpi-link-text)}
|
||||
.bpi-color-swatch-none.bpi-color-swatch-active{border-style:solid;color:var(--bpi-text)}
|
||||
.bpi-color-custom{display:flex;align-items:center;gap:6px}
|
||||
.bpi-color-custom input{width:150px}
|
||||
.bpi-color-swatch-none{display:inline-flex;align-items:center;justify-content:center;background:var(--bpi-surface-3);border-style:dashed;border-color:var(--bpi-border-2);color:var(--bpi-text-muted);font:11px Arial,sans-serif}
|
||||
.bpi-color-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(196px,1fr));gap:6px}
|
||||
.bpi-color-card{border:1px solid var(--bpi-border-2);border-radius:6px;background:var(--bpi-surface-2);padding:7px}
|
||||
.bpi-color-card-head{display:flex;align-items:center;gap:6px;font-weight:700;color:var(--bpi-text);word-break:break-word}
|
||||
.bpi-color-chip{width:13px;height:13px;border-radius:3px;border:1px solid var(--bpi-border-2);background:transparent;flex:none}
|
||||
.bpi-color-meta{font-size:10px;color:var(--bpi-text-muted);margin-top:2px}
|
||||
.bpi-color-swatches{display:grid;grid-template-columns:repeat(4,1fr);gap:5px;margin-top:6px}
|
||||
.bpi-color-swatches i{height:20px;border-radius:4px;border:1px solid var(--bpi-border);cursor:pointer;display:block}
|
||||
.bpi-color-swatches i.bpi-swatch-on{border-color:var(--bpi-link-text);box-shadow:0 0 0 1px var(--bpi-link-text)}
|
||||
.bpi-color-row{display:flex;align-items:center;gap:5px;margin-top:6px}
|
||||
.bpi-color-row input{width:92px;border:1px solid var(--bpi-border-2);border-radius:5px;background:var(--bpi-input-bg);color:var(--bpi-input-text);padding:3px 5px;font-size:11px}
|
||||
.bpi-color-dice{width:24px;height:24px;display:inline-flex;align-items:center;justify-content:center;border:1px solid var(--bpi-border-2);border-radius:5px;background:transparent;color:var(--bpi-text-muted);cursor:pointer;padding:0;flex:none}
|
||||
.bpi-color-dice:hover{color:var(--bpi-text)}
|
||||
.bpi-color-error{color:var(--bpi-status-error);font-size:11px;margin-top:6px}
|
||||
.bpi-color-panel{border:1px solid var(--bpi-border-2);border-radius:7px;background:var(--bpi-surface-3);padding:8px;margin:5px 0}
|
||||
.bpi-color-panel-actions{display:flex;justify-content:flex-end;margin-top:8px}
|
||||
.bpi-color-modes{display:flex;gap:6px;flex-wrap:wrap}
|
||||
.bpi-color-mode{display:inline-flex;align-items:center;gap:5px;border:1px solid var(--bpi-border-2);border-radius:6px;background:var(--bpi-surface-2);color:var(--bpi-text);padding:5px 9px;cursor:pointer;font-size:12px;font-family:inherit}
|
||||
.bpi-color-mode:hover{border-color:var(--bpi-text-muted)}
|
||||
.bpi-color-mode-active{border-color:var(--bpi-link-text);box-shadow:0 0 0 1px var(--bpi-link-text)}
|
||||
.bpi-color-mode-dot{width:9px;height:9px;border-radius:50%;display:inline-block;border:1px solid var(--bpi-border)}
|
||||
.bpi-color-note{font-size:11px;color:var(--bpi-text-muted);margin:10px 0 6px}
|
||||
.bpi-color-open{border-color:var(--bpi-link-text);box-shadow:0 0 0 1px var(--bpi-link-text)}
|
||||
.bpi-filter-sep{width:1px;height:18px;background:var(--bpi-border);margin:0 4px;align-self:center}
|
||||
.bpi-color-label{display:inline-flex;align-items:center;gap:5px;font-size:12px}
|
||||
.bpi-dropdown{position:relative;display:inline-flex}
|
||||
.bpi-dropdown-trigger{display:inline-flex;align-items:center;gap:6px;padding:3px 9px;border:1px solid var(--bpi-border-2);border-radius:6px;background:var(--bpi-button-bg);color:var(--bpi-button-text);font:12px Arial,sans-serif;line-height:1.2;cursor:pointer;user-select:none}
|
||||
.bpi-dropdown-trigger:hover{border-color:var(--bpi-text-faint)}
|
||||
.bpi-dropdown-caret{font-size:9px;color:var(--bpi-text-muted);transition:transform .12s}
|
||||
.bpi-dropdown-open .bpi-dropdown-caret{transform:rotate(180deg)}
|
||||
.bpi-dropdown-menu{position:absolute;top:calc(100% + 5px);left:0;z-index:90;min-width:150px;padding:4px;border:1px solid var(--bpi-border-2);border-radius:6px;background:var(--bpi-surface-modal);box-shadow:0 6px 16px rgba(0,0,0,.25)}
|
||||
.bpi-dropdown-menu.bpi-hidden{display:none}
|
||||
.bpi-dropdown-item{display:flex;align-items:center;gap:8px;padding:5px 8px;border-radius:4px;color:var(--bpi-text);font:12px Arial,sans-serif;white-space:nowrap;cursor:pointer}
|
||||
.bpi-dropdown-item:hover{background:var(--bpi-surface-3)}
|
||||
.bpi-dropdown-item-active{color:#76c9ff}
|
||||
.bpi-dropdown-dots{display:inline-flex;gap:3px}
|
||||
.bpi-dropdown-dot{width:9px;height:9px;border-radius:50%;display:inline-block}
|
||||
.bpi-dropdown-check{margin-left:auto;font-size:11px;color:#76c9ff}.bpi-about-modal{width:min(460px,calc(100vw - 32px))}.bpi-about-name{margin-bottom:7px;color:var(--bpi-text);font-weight:700}.bpi-config-note{color:var(--bpi-text-muted);font-size:10px;word-break:break-all}
|
||||
.bpi-about-modal{width:min(460px,calc(100vw - 32px))}.bpi-about-name{margin-bottom:7px;color:var(--bpi-text);font-weight:700}.bpi-config-note{color:var(--bpi-text-muted);font-size:10px;word-break:break-all}
|
||||
/* 同一句说明变成警告时用:只换颜色,不动字号字重,免得换行数变了把下面的表单顶走 */
|
||||
.bpi-config-note-warn{color:var(--bpi-status-error)}
|
||||
/* 「获取模型」:输入框照旧独占一行宽度,按钮挤在右边不换行 */
|
||||
.bpi-model-line{display:flex;gap:6px;align-items:stretch;min-width:0}
|
||||
.bpi-model-line input{flex:1 1 auto;min-width:0}
|
||||
.bpi-model-line .bpi-button{flex:none;white-space:nowrap;background-color:var(--bpm-accent);border-color:var(--bpm-accent-border);color:#fff}
|
||||
.bpi-model-line .bpi-button:hover{background-color:var(--bpm-accent-hover);border-color:var(--bpm-accent-border);color:#fff}
|
||||
.bpi-help{position:relative;display:inline-flex;align-items:center;justify-content:center;width:13px;height:13px;margin-left:5px;border:1px solid var(--bpi-border);border-radius:50%;color:var(--bpi-text-muted);font:10px/1 Arial,sans-serif;vertical-align:middle;cursor:help;user-select:none}
|
||||
.bpi-help:hover,.bpi-help:focus{color:var(--bpi-text);border-color:var(--bpi-border-2)}
|
||||
.bpi-help-pop{position:absolute;left:50%;bottom:calc(100% + 6px);transform:translateX(-50%);z-index:80;display:none;box-sizing:border-box;width:290px;max-width:70vw;padding:7px 9px;border:1px solid var(--bpi-border-2);border-radius:6px;background:var(--bpi-surface-modal);color:var(--bpi-text-muted);font:11px/1.65 Arial,sans-serif;white-space:pre-line;text-align:left;cursor:default}
|
||||
.bpi-help:hover .bpi-help-pop,.bpi-help:focus .bpi-help-pop{display:block}.bpi-preview-text{box-sizing:border-box;width:100%;min-height:130px;max-height:300px;resize:vertical;border:1px solid var(--bpi-border-2);border-radius:6px;background:var(--bpi-surface-3);color:var(--bpi-text);padding:8px;font:12px/1.5 monospace}.bpi-sort-groups{max-height:230px;overflow:auto;border:1px solid var(--bpi-border-2);border-radius:6px;background:var(--bpi-surface-3);padding:6px;margin:8px 0}.bpi-sort-group{display:grid;grid-template-columns:145px minmax(0,1fr);gap:7px;padding:4px;border-top:1px solid var(--bpi-border-3)}.bpi-sort-group:first-child{border-top:0}.bpi-sort-group strong{color:#9fd3ff}
|
||||
.bpi-about-footer{flex:none;min-height:20px;display:flex;align-items:center;justify-content:space-between;gap:8px;padding:1px 4px 0;color:var(--bpi-text-faint);font-size:9px}.bpi-about-button{border:0;background:transparent;color:var(--bpi-text-muted);padding:1px 3px;cursor:pointer;font-size:9px}.bpi-about-button:hover{color:#b8dcfa;text-decoration:underline}
|
||||
.bpi-help:hover .bpi-help-pop,.bpi-help:focus .bpi-help-pop{display:block}.bpi-preview-text{box-sizing:border-box;width:100%;min-height:130px;max-height:300px;resize:vertical;border:1px solid var(--bpi-border-2);border-radius:6px;background:var(--bpi-surface-3);color:var(--bpi-text);padding:8px;font:12px/1.5 monospace}.bpi-sort-groups{max-height:230px;overflow:auto;border:1px solid var(--bpi-border-2);border-radius:6px;background:var(--bpi-surface-3);padding:6px;margin:8px 0}.bpi-sort-group{display:grid;grid-template-columns:145px minmax(0,1fr);gap:7px;padding:4px;border-top:1px solid var(--bpi-border-3)}.bpi-sort-group:first-child{border-top:0}.bpi-sort-group strong{color:var(--bpi-zh-text)}
|
||||
.bpi-about-footer{flex:none;min-height:20px;display:flex;align-items:center;justify-content:space-between;gap:8px;padding:1px 4px 0;color:var(--bpi-text-faint);font-size:9px}.bpi-about-button{border:0;background:transparent;color:var(--bpi-text-muted);padding:1px 3px;cursor:pointer;font-size:9px}.bpi-about-button:hover{color:var(--bpi-link-text);text-decoration:underline}
|
||||
.bpi-pack-section{border:1px solid var(--bpi-border-2);border-radius:7px;background:var(--bpi-surface-3);padding:7px;margin-bottom:8px}.bpi-pack-toolbar{display:flex;gap:6px;align-items:center;justify-content:space-between;flex-wrap:wrap;margin-bottom:6px}.bpi-pack-toolbar strong{color:var(--bpi-text)}.bpi-pack-list{display:grid;grid-template-columns:repeat(auto-fit,minmax(225px,1fr));gap:5px;max-height:166px;overflow:auto}.bpi-pack-card{border:1px solid var(--bpi-border-2);border-radius:6px;background:var(--bpi-surface-2);padding:6px;display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:6px;align-items:start}.bpi-pack-card.bpi-pack-disabled{opacity:.58}.bpi-pack-name{font-weight:700;color:var(--bpi-text)}.bpi-pack-meta{font-size:10px;color:var(--bpi-text-muted);margin-top:2px;word-break:break-word}.bpi-pack-controls{display:flex;gap:3px;flex-wrap:wrap;justify-content:flex-end}.bpi-switch{margin-top:3px}.bpi-pack-personal{border-color:#397062}.bpi-community-form{display:grid;grid-template-columns:105px minmax(0,1fr);gap:8px;align-items:center}.bpi-community-form input{border:1px solid var(--bpi-border-2);border-radius:5px;background:var(--bpi-input-bg);color:var(--bpi-input-text);padding:6px}.bpi-community-preview{max-height:150px;overflow:auto;border:1px solid var(--bpi-border-2);border-radius:5px;padding:5px;margin-top:8px;color:var(--bpi-text-muted)}
|
||||
.bpi-import-conflicts{max-height:240px;overflow:auto;border:1px solid var(--bpi-border-2);border-radius:6px;margin-top:10px}.bpi-import-conflict{display:grid;grid-template-columns:1fr 1fr 1fr;gap:6px;padding:6px;border-top:1px solid var(--bpi-border-3)}.bpi-import-conflict:first-child{border-top:0}.bpi-import-stat{display:flex;gap:12px;flex-wrap:wrap;color:var(--bpi-text)}.bpi-danger-text{color:#ff9ba3}
|
||||
.bpi-import-conflicts{max-height:240px;overflow:auto;border:1px solid var(--bpi-border-2);border-radius:6px;margin-top:10px}.bpi-import-conflict{display:grid;grid-template-columns:1fr 1fr 1fr;gap:6px;padding:6px;border-top:1px solid var(--bpi-border-3)}.bpi-import-conflict:first-child{border-top:0}.bpi-import-stat{display:flex;gap:12px;flex-wrap:wrap;color:var(--bpi-text)}.bpi-danger-text{color:var(--bpi-status-error)}
|
||||
.bpm-panel{box-sizing:border-box;width:100%;height:100%;min-height:280px;display:flex;flex-direction:column;gap:7px;padding:8px;color:var(--bpi-text);font:12px/1.4 Arial,sans-serif;overflow:hidden}
|
||||
.bpm-head{flex:none;display:flex;align-items:center;gap:7px;flex-wrap:wrap}.bpm-head strong{color:var(--bpi-text)}.bpm-head .bpi-status{margin-left:auto}
|
||||
.bpm-tabs{flex:none;display:flex;gap:4px}.bpm-tab{flex:1;border:1px solid var(--bpi-border-2);border-radius:5px;padding:4px 6px;background:var(--bpi-button-bg);color:var(--bpi-button-text);cursor:pointer;font-size:11px;line-height:1.25}.bpm-tab:hover{background:var(--bpi-border-3)}.bpm-tab.bpm-tab-active{background:#285f8e;border-color:#3f83ba;color:#fff}
|
||||
.bpm-tabs{flex:none;display:flex;gap:4px}.bpm-tab{flex:1;border:1px solid var(--bpi-border-2);border-radius:5px;padding:4px 6px;background:var(--bpi-button-bg);color:var(--bpi-button-text);cursor:pointer;font-size:11px;line-height:1.25}.bpm-tab:hover{background:var(--bpi-border-3)}.bpm-tab.bpm-tab-active{background:var(--bpm-accent);border-color:var(--bpm-accent-border);color:#fff}
|
||||
.bpm-section{flex:1;min-height:0;display:flex;flex-direction:column;gap:6px}.bpm-section[hidden]{display:none}
|
||||
.bpm-toolbar{flex:none;display:flex;gap:4px;align-items:center;flex-wrap:wrap}.bpm-toolbar .bpi-search{min-width:120px}.bpm-toolbar .bpi-mode{flex:1;min-width:90px}
|
||||
.bpm-summary{flex:none;color:var(--bpi-text-muted);font-size:11px;min-height:15px;word-break:break-all}
|
||||
.bpm-rows{flex:1;min-height:120px;overflow-y:auto;scrollbar-gutter:stable;border:1px solid var(--bpi-border-2);border-radius:6px;background:var(--bpi-surface-table)}.bpm-tagmanager-host{flex:1;min-height:0;overflow-y:auto;scrollbar-gutter:stable;display:flex;flex-direction:column;gap:7px;padding-right:2px}.bpm-tagmanager-host .bpi-details-body{display:flex;flex-direction:column;gap:7px}.bpm-tagmanager-host .bpi-table{height:auto;min-height:220px;max-height:none;flex:0 1 auto}
|
||||
.bpm-row{border-top:1px solid var(--bpi-border-3);padding:6px 8px;display:flex;flex-direction:column;gap:3px}.bpm-row:first-child{border-top:0}.bpm-row:hover{background:var(--bpi-surface-3)}
|
||||
.bpm-row-top{display:flex;align-items:flex-start;gap:5px}.bpm-row-top input[type="checkbox"]{margin-top:2px}.bpm-row-en{color:var(--bpi-text);font-family:Consolas,monospace;word-break:break-word;min-width:0;flex:1}
|
||||
.bpm-row-zh{color:#9ed0ff;word-break:break-word}
|
||||
.bpm-row-zh{color:var(--bpi-zh-text);word-break:break-word}
|
||||
.bpm-row-meta{display:flex;align-items:center;gap:5px;flex-wrap:wrap;color:var(--bpi-text-muted);font-size:10px}
|
||||
.bpm-actions{display:flex;gap:4px;flex-wrap:wrap}
|
||||
.bpm-footer{flex:none;display:flex;gap:4px;align-items:center;justify-content:space-between;flex-wrap:wrap}.bpm-footer .bpi-toolbar-group{justify-content:flex-start}
|
||||
.bpm-section .bpi-pack-list{flex:1;min-height:0;max-height:none;grid-template-columns:1fr}
|
||||
.bpm-section .bpi-form{grid-template-columns:78px minmax(0,1fr)}.bpm-section .bpi-form textarea{min-height:84px}
|
||||
.bpm-assistant-actions{display:flex;gap:7px;flex-wrap:wrap;margin-top:10px}
|
||||
.bpm-loading{padding:24px;text-align:center;color:var(--bpi-text-faint)}
|
||||
.bpi-form-actions{display:flex;justify-content:flex-end;gap:7px;margin-top:12px;flex-wrap:wrap}
|
||||
/* 助手设置在 ComfyUI「设置」面板里:原生那一行是「标签 + 控件」,把标签收掉、控件占满整行 */
|
||||
.bpi-settings-form{box-sizing:border-box;width:100%;padding:2px 0 6px}.bpi-settings-form h3{margin:0 0 10px;font-size:15px}
|
||||
/* 设置面板每一项 = 名称列 + 控件列,名称列宽度跟着名字长短走:插件这两项一个叫
|
||||
「语言与外观」一个叫「助手设置」,字数不同,右边的内容就被推得差了一格。
|
||||
让名称列不占位、控件列占满,两块都从最左边开始,标题由各自的 h3 显示。
|
||||
只在「我们的块不在第一列」时才生效,结构对不上时不会把内容自己藏掉。 */
|
||||
*:has(> :not(:first-child) > .bpi-settings-form){align-items:flex-start}
|
||||
*:has(> :not(:first-child) > .bpi-settings-form) > :first-child{display:none}
|
||||
*:has(> :not(:first-child) > .bpi-settings-form) > :not(:first-child){flex:1 1 0%;min-width:0;justify-content:flex-start}
|
||||
/* ComfyUI 给控件列自带 justify-end:宽度一占满就会把内容推到最右,这里改回靠左 */
|
||||
*:has(> .bpi-settings-form){flex:1 1 0%;min-width:0}
|
||||
.bpi-switch-row{display:flex;align-items:center;gap:8px;cursor:pointer;user-select:none;width:fit-content}
|
||||
.bpi-switch-row input{position:absolute;width:0;height:0;opacity:0;margin:0}
|
||||
.bpi-switch-track{position:relative;flex:0 0 auto;width:34px;height:18px;border-radius:9px;background-color:var(--bpi-switch-off);border:1px solid var(--bpi-switch-off-border);transition:background-color .16s ease,border-color .16s ease}
|
||||
.bpi-switch-thumb{position:absolute;top:2px;left:2px;width:12px;height:12px;border-radius:50%;background-color:var(--bpi-switch-knob);transition:transform .16s ease}
|
||||
.bpi-switch-row:hover .bpi-switch-track{border-color:var(--bpi-border-2)}
|
||||
.bpi-switch-row input:checked + .bpi-switch-track{background-color:var(--bpi-danger-bg);border-color:var(--bpi-danger-border)}
|
||||
.bpi-switch-row input:checked + .bpi-switch-track .bpi-switch-thumb{transform:translateX(16px)}
|
||||
.bpi-switch-row input:focus-visible + .bpi-switch-track{outline:2px solid var(--bpi-row-pinned-ring);outline-offset:1px}
|
||||
.bpi-switch-text{font-size:12px;color:var(--bpi-text-muted)}
|
||||
.bpi-switch-row input:checked ~ .bpi-switch-text{color:var(--bpi-status-error)}
|
||||
.bpi-switch-hint{display:none;font-size:11px;color:var(--bpi-status-error)}
|
||||
.bpi-switch-row input:checked ~ .bpi-switch-hint{display:inline}.bpi-switch-row.bpi-switch-accent input:checked + .bpi-switch-track{background-color:var(--bpm-accent);border-color:var(--bpm-accent-border)}.bpi-switch-row.bpi-switch-accent input:checked ~ .bpi-switch-text{color:var(--bpi-text)}
|
||||
/* 设置面板里的输入框 / 下拉:直接用 ComfyUI 的字段底色与描边,取不到才用兜底值,
|
||||
免得暗色下我们的蓝灰底比面板亮一截、显得突兀 */
|
||||
.bpi-settings-form .bpi-form input,.bpi-settings-form .bpi-form select,.bpi-settings-form .bpi-form textarea{background-color:var(--p-textfield-background,var(--p-inputtext-background,var(--bpi-field-bg)));border-color:var(--p-textfield-border-color,var(--p-inputtext-border-color,var(--bpi-field-border)))}
|
||||
.bpi-settings-form .bpi-form input:focus,.bpi-settings-form .bpi-form select:focus,.bpi-settings-form .bpi-form textarea:focus{border-color:var(--p-primary-color,#4ca7e8)}
|
||||
.bpm-panel .bpi-button.bpi-primary,.bpm-panel .bpi-mini.bpi-primary{background:var(--bpm-accent);border-color:var(--bpm-accent-border);color:#fff}
|
||||
.bpm-panel .bpi-button.bpi-primary:hover,.bpm-panel .bpi-mini.bpi-primary:hover{background:var(--bpm-accent-hover);border-color:var(--bpm-accent-border);color:#fff}
|
||||
.bpm-panel .bpi-filter-active{background:var(--bpm-accent);border-color:var(--bpm-accent-border);color:#fff}
|
||||
.bpi-modal .bpi-button.bpi-primary,.bpi-modal .bpi-mini.bpi-primary{background:var(--bpm-accent);border-color:var(--bpm-accent-border);color:#fff}
|
||||
.bpi-modal .bpi-button.bpi-primary:hover,.bpi-modal .bpi-mini.bpi-primary:hover{background:var(--bpm-accent-hover);border-color:var(--bpm-accent-border);color:#fff}
|
||||
.bpi-dup-bar{display:flex;align-items:center;gap:6px;flex-wrap:wrap;padding:5px 2px;border-top:1px solid var(--bpi-border-3)}.bpi-dup-bar.bpi-hidden{display:none}.bpi-dup-label{flex:none;color:var(--bpi-text-muted);font-size:11px}
|
||||
.bpi-dup-chip{display:inline-flex;align-items:center;gap:5px;max-width:260px;overflow:hidden;border:1px dashed var(--bpi-border-2);border-radius:999px;padding:2px 8px;background-color:var(--bpi-surface-3);color:var(--bpi-text);cursor:pointer;font-size:11px;user-select:none}
|
||||
.bpi-dup-chip:hover{background-color:var(--bpi-hidden-chip-hover-bg);border-color:var(--bpi-link-text)}.bpi-dup-en{font-family:Consolas,monospace;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}.bpi-dup-count{flex:none;color:var(--bpi-text-muted);font-size:10px}.bpi-dup-index{flex:none;color:var(--bpi-status-error);font-size:10px;font-weight:700}.bpi-dup-chip-armed{border-style:solid;border-color:var(--bpi-status-error);color:var(--bpi-status-error)}
|
||||
.bpi-dup-off{opacity:.6}.bpi-dup-off .bpi-en,.bpi-dup-off .bpi-english-token{text-decoration:line-through;color:var(--bpi-text-faint)}
|
||||
.bpi-row.bpi-dup-hit,.bpi-token-card.bpi-dup-hit{background-image:linear-gradient(var(--bpi-dup-overlay),var(--bpi-dup-overlay));outline:1px solid var(--bpi-status-error);outline-offset:-1px}
|
||||
.bpi-token-card.bpi-dup-hit .bpi-english-token{background-color:transparent;border-color:transparent;box-shadow:none}.bpi-unique-toggle{margin-left:6px;font-size:10px}
|
||||
.bpi-dup-chip-unique{border-style:solid;border-color:var(--bpi-custom-border);background-color:var(--bpi-custom-bg)}.bpi-dup-chip-unique:hover{border-color:var(--bpi-link-text)}.bpi-dup-flag{flex:none;color:var(--bpi-custom-text);font-size:10px}
|
||||
.bpi-dup-space{flex:1 1 32px;min-width:32px;align-self:stretch;cursor:default}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
@@ -931,10 +1484,13 @@ export {
|
||||
LANGUAGE_CHANGED_EVENT,
|
||||
MANAGER_TAB_ID,
|
||||
MANAGER_OPEN_EVENT,
|
||||
UNINDEXED_PACK_ID,
|
||||
UPSTREAM_ARRIVED_EVENT,
|
||||
applyBpiAppearance,
|
||||
applyLanguageToDom,
|
||||
buildTagFields,
|
||||
button,
|
||||
checkDuplicateTags,
|
||||
createSavedPrompt,
|
||||
deleteCommunityPack,
|
||||
deletePersonalTag,
|
||||
@@ -954,11 +1510,13 @@ export {
|
||||
importTags,
|
||||
bulkUpdateTags,
|
||||
injectBpiStyles,
|
||||
listAssistantModels,
|
||||
listModelNames,
|
||||
listSavedPrompts,
|
||||
loadDictionary,
|
||||
loadPreferences,
|
||||
lookupLargeDictionary,
|
||||
buildColorPanel,
|
||||
openManagerPanel,
|
||||
openSavePromptDialog,
|
||||
openTagDialog,
|
||||
@@ -968,6 +1526,7 @@ export {
|
||||
saveAssistantConfig,
|
||||
savePreferences,
|
||||
saveTag,
|
||||
saveTokenColors,
|
||||
searchLargeDictionary,
|
||||
setLanguageMode,
|
||||
setLargeDictionaryEnabled,
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
},
|
||||
});
|
||||
+119
-25
@@ -44,12 +44,29 @@ const ZH = {
|
||||
Discard: "丢弃",
|
||||
Resume: "继续",
|
||||
Weight: "权重",
|
||||
Color: "颜色",
|
||||
"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: "英文",
|
||||
@@ -71,7 +88,6 @@ const ZH = {
|
||||
"Chinese explanation (read-only)": "中文解释(只读)",
|
||||
"Category table view | click tags to link; category names are not written to the prompt":
|
||||
"分类表视图 | 点击标签联动;分类名不会写入提示词",
|
||||
"Click tags to link; select then press Delete": "点击标签联动;选中后按 Delete",
|
||||
"Enter the English prompt; after editing, it will be shown as selectable, deletable tags.":
|
||||
"输入英文提示词;编辑完成后会显示成可选中、可删除的标签。",
|
||||
"Enter a prompt in the English text box above; per-tag bilingual mapping will appear here.":
|
||||
@@ -87,17 +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 顺序排序",
|
||||
"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":
|
||||
"输入中文、英文、别名或概念;点击结果即可插入英文标签",
|
||||
@@ -128,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": "直接编辑",
|
||||
|
||||
@@ -155,8 +179,6 @@ const ZH = {
|
||||
"No content change": "内容没有变化",
|
||||
"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 之间",
|
||||
"Use “Edit text” to process it": "请用「编辑文本」处理",
|
||||
"Click “Edit text”, then use the inspector's own “Translate” or “Translate & optimize”":
|
||||
@@ -177,6 +199,8 @@ const ZH = {
|
||||
"deleted {}; press Ctrl+Z to undo": "已删除「{}」;按 Ctrl+Z 可撤销",
|
||||
"{}; press Ctrl+Z to undo": "{};按 Ctrl+Z 可撤销",
|
||||
"moved {}; press Ctrl+Z to undo": "已移动「{}」;按 Ctrl+Z 可撤销",
|
||||
"rewrote {}; press Ctrl+Z to undo": "已改写「{}」;按 Ctrl+Z 可撤销",
|
||||
"rewrote {} tags; press Ctrl+Z to undo": "已改写 {} 个标签;按 Ctrl+Z 可撤销",
|
||||
"hidden {} (excluded from actual output); can be restored anytime in the hidden section; press Ctrl+Z to undo":
|
||||
"已隐藏「{}」(不计入实际输出);随时可在隐藏区恢复;按 Ctrl+Z 可撤销",
|
||||
"restored {} to its original position; press Ctrl+Z to undo": "已把「{}」恢复到原位置;按 Ctrl+Z 可撤销",
|
||||
@@ -208,6 +232,8 @@ const ZH = {
|
||||
"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": "删除个人覆盖并恢复内置解释",
|
||||
@@ -222,8 +248,8 @@ const ZH = {
|
||||
"Aliases: {}": "别名:{}",
|
||||
"{} ↔ {} | 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+↑/↓ 微调",
|
||||
"{} ↔ {} | 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": "移动换行",
|
||||
@@ -264,6 +290,10 @@ const ZH = {
|
||||
"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 {}…": "正在翻译「{}」…",
|
||||
@@ -289,6 +319,7 @@ const ZH = {
|
||||
Models: "适用模型",
|
||||
"Recommended Weight": "推荐权重",
|
||||
"e.g.: looking at viewer": "例如:looking at viewer",
|
||||
"e.g.: apple": "例如:苹果",
|
||||
"e.g.: pose, camera, style": "例如:pose、camera、style",
|
||||
|
||||
// ── 弹窗:保存 / 编辑收藏 ────────────────────────────────────────────
|
||||
@@ -298,6 +329,13 @@ const ZH = {
|
||||
"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": "不换图就保持原样,选一张新的会替换掉原来的",
|
||||
|
||||
@@ -317,7 +355,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.":
|
||||
@@ -364,18 +401,17 @@ 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(每秒请求数)",
|
||||
@@ -387,14 +423,26 @@ const ZH = {
|
||||
"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: "温度",
|
||||
@@ -415,11 +463,13 @@ 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 & Language": "语言与外观",
|
||||
"Appearance and language saved": "外观与语言已保存",
|
||||
|
||||
// ── 标签来源 / 分类 / 模式 ──────────────────────────────────────────
|
||||
"Inspector assistant": "检查器助手",
|
||||
@@ -431,6 +481,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: "语法",
|
||||
@@ -444,6 +502,9 @@ const ZH = {
|
||||
"English prompt tag view": "英文提示词标签视图",
|
||||
"Anima official sort": "Anima 官方顺序",
|
||||
"Anima official order preview": "Anima 官方顺序预览",
|
||||
"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": "已复制",
|
||||
|
||||
@@ -502,6 +563,8 @@ const ZH = {
|
||||
"Save Personal": "保存为个人",
|
||||
"Reset Built-in": "重置为内置",
|
||||
"Select all ({} items)": "全选({} 条)",
|
||||
"Edit {}": "编辑「{}」",
|
||||
"Edit {} tags": "编辑 {} 个标签",
|
||||
"pack:": "词包:",
|
||||
"Copied {}": "已复制 {}",
|
||||
"Processed {}": "已处理「{}」",
|
||||
@@ -539,6 +602,8 @@ const ZH = {
|
||||
"Added {}": "新增 {}",
|
||||
"Conflicts {}": "冲突 {}",
|
||||
"Duplicates {}": "重复 {}",
|
||||
"No duplicates": "无重复",
|
||||
"Waiting for prompt": "等待提示词",
|
||||
"Invalid {}": "无效 {}",
|
||||
"Import complete: added {}, overwrote {}, skipped {} | auto-backed up": "导入完成:新增 {}、覆盖 {}、跳过 {}|已自动备份",
|
||||
"Import complete: {} added": "导入完成:新增 {} 条",
|
||||
@@ -564,6 +629,9 @@ 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: {}"。
|
||||
@@ -577,8 +645,6 @@ const ZH = {
|
||||
"Delete {}? Reference image will be deleted too.": "删除「{}」?参考图也会一并删除。",
|
||||
"Node #{}": "节点 #{}",
|
||||
"Node #{}: {}": "节点 #{}:{}",
|
||||
"Bilingual Prompt Inspector: Tag Manager, Dictionary, Packs & Assistant Settings":
|
||||
"提示词翻译与管理:标签管理、词库、词包与助手设置",
|
||||
|
||||
// ── 语法诊断(parser.js)────────────────────────────────────────────
|
||||
"Manual override": "手动覆盖",
|
||||
@@ -604,6 +670,27 @@ 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: only the first occurrence enters the actual output; press Ctrl+Z to undo":
|
||||
"「{}」已设为唯一:只有第一次出现进入实际输出;按 Ctrl+Z 可撤销",
|
||||
"{} no longer unique: all occurrences enter the actual output again; press Ctrl+Z to undo":
|
||||
"「{}」已取消唯一:所有重复项重新进入实际输出;按 Ctrl+Z 可撤销",
|
||||
"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": "检测到连续逗号或空标签",
|
||||
@@ -682,9 +769,15 @@ 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…": "已暂停,等待确认…",
|
||||
@@ -713,7 +806,8 @@ const ZH = {
|
||||
|
||||
"Click a tag to link it with the detail table below; select then press Delete": "点击标签可与下方明细表联动;选中后按 Delete 删除",
|
||||
"English prompt is empty; nothing to optimize": "英文提示词是空的,没有可优化的内容",
|
||||
"“Optimize to Anima” only processes English; translate Chinese in the sidebar first": "「优化为 Anima」只处理英文;中文请先在侧边栏翻译",
|
||||
"“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": "「{}」;请在预览里改好再确认",
|
||||
};
|
||||
|
||||
+455
-151
@@ -24,6 +24,7 @@ import {
|
||||
bulkUpdateTags,
|
||||
applyBpiAppearance,
|
||||
injectBpiStyles,
|
||||
listAssistantModels,
|
||||
listSavedPrompts,
|
||||
loadDictionary,
|
||||
loadPreferences,
|
||||
@@ -48,7 +49,6 @@ const SECTIONS = [
|
||||
["tag-manager", "Tag Manager"],
|
||||
["tags", "Dictionary"],
|
||||
["packs", "Packs"],
|
||||
["assistant", "Assistant Settings"],
|
||||
["favorites", "Favorites"],
|
||||
];
|
||||
|
||||
@@ -58,7 +58,6 @@ const manager = {
|
||||
selected: new Set(),
|
||||
section: "tags",
|
||||
loaded: false,
|
||||
assistantLoaded: false,
|
||||
favoritesLoaded: false,
|
||||
favorites: [],
|
||||
favoritesChangeHandler: null,
|
||||
@@ -136,6 +135,9 @@ function rebuildSourceOptions() {
|
||||
["all", "All Packs"],
|
||||
["builtin", "Built-in pack enabled"],
|
||||
["personal", "Personal"],
|
||||
// 保存时选了「自然语言词库」的条目:和个人词库共用文件,只靠 natural 标记区分,
|
||||
// 与分类叫「自然语言」的标签是两回事(后者是 Anima 分类名)。
|
||||
["natural", "Natural Language Dictionary"],
|
||||
...(manager.data.large_dictionary?.available ? [["large", "Danbooru Large Dict"]] : []),
|
||||
["machine", "Pending Machine"],
|
||||
["favorites", "Favorites"],
|
||||
@@ -149,6 +151,13 @@ function rebuildSourceOptions() {
|
||||
refs.source.value = options.some(([value]) => value === current) ? current : "all";
|
||||
}
|
||||
|
||||
// 自然语言跟普通标签分类不是一个层级,按拼音排会掉到很后面;
|
||||
// 词库里这个分类名中英两种写法都有(Anima 分类是英文,手填的多是中文),都认。
|
||||
function isNaturalCategory(name) {
|
||||
const value = String(name ?? "").trim().toLowerCase();
|
||||
return value === "自然语言" || value === "natural language";
|
||||
}
|
||||
|
||||
function rebuildCategoryOptions() {
|
||||
const current = refs.category.value;
|
||||
refs.category.replaceChildren();
|
||||
@@ -156,7 +165,7 @@ function rebuildCategoryOptions() {
|
||||
all.value = "all";
|
||||
refs.category.appendChild(all);
|
||||
const categories = [...new Set(managerRows().map((row) => row.tag.category).filter(Boolean))].sort((a, b) => a.localeCompare(b, "zh-CN"));
|
||||
for (const category of categories) {
|
||||
for (const category of [...categories.filter(isNaturalCategory), ...categories.filter((name) => !isNaturalCategory(name))]) {
|
||||
const option = element("option", "", category);
|
||||
option.value = category;
|
||||
refs.category.appendChild(option);
|
||||
@@ -218,9 +227,10 @@ function renderRows() {
|
||||
const category = refs.category.value;
|
||||
const source = refs.source.value;
|
||||
const rows = managerRows().filter((row) => {
|
||||
if (source === "natural" && !row.tag.natural) return false;
|
||||
if (source === "favorites" && !isFavorite(row.tag.english)) return false;
|
||||
if (source.startsWith("pack:") && row.tag.pack_id !== source.slice(5)) return false;
|
||||
if (!["all", "favorites"].includes(source) && !source.startsWith("pack:") && row.kind !== source) return false;
|
||||
if (!["all", "favorites", "natural"].includes(source) && !source.startsWith("pack:") && row.kind !== source) return false;
|
||||
if (category !== "all" && row.tag.category !== category) return false;
|
||||
if (query) {
|
||||
const haystack = [row.tag.english, row.tag.chinese, row.tag.category, ...(row.tag.aliases ?? [])].join("\n").toLowerCase();
|
||||
@@ -575,10 +585,58 @@ function openImportPreview(payload) {
|
||||
modal.addEventListener("mousedown", (event) => event.stopPropagation());
|
||||
}
|
||||
|
||||
async function buildAssistantSection(section) {
|
||||
section.replaceChildren();
|
||||
// 设置面板每一项 = 名称列 + 控件列,名称列宽度跟着设置项名字长短走:
|
||||
// 插件这两项一个「语言与外观」一个「助手设置」,字数不同,右边的内容就被推得差了一格。
|
||||
// CSS 里已经用 :has() 处理过一次,这里再兜一次——面板重建后内联样式会丢,
|
||||
// 而设置面板每次打开都会重新调用下面的 control,正好顺势对齐一次。
|
||||
// 只认「恰好两列、且第二列装着我们内容」的那一行,结构对不上就不动,免得误伤别的元素。
|
||||
function alignSettingRow(node) {
|
||||
let row = node.parentElement;
|
||||
while (row && row !== document.body) {
|
||||
if (row.children.length === 2 && row.children[1].contains(node)) break;
|
||||
row = row.parentElement;
|
||||
}
|
||||
if (!row || row === document.body) return;
|
||||
const [name, control] = row.children;
|
||||
name.style.display = "none";
|
||||
control.style.flex = "1 1 0%";
|
||||
control.style.minWidth = "0";
|
||||
// ComfyUI 给控件列自带 justify-end:宽度一占满,它反而把内容整个推到右边。
|
||||
// 改回靠左,并让包着我们的那一层也占满,否则内容仍会缩在右侧。
|
||||
control.style.justifyContent = "flex-start";
|
||||
if (node.parentElement && node.parentElement !== control) {
|
||||
node.parentElement.style.flex = "1 1 0%";
|
||||
node.parentElement.style.minWidth = "0";
|
||||
}
|
||||
}
|
||||
|
||||
function alignWhenMounted(node, frames = 30) {
|
||||
if (node.isConnected) {
|
||||
alignSettingRow(node);
|
||||
return;
|
||||
}
|
||||
if (frames > 0) requestAnimationFrame(() => alignWhenMounted(node, frames - 1));
|
||||
}
|
||||
|
||||
// 外观 / 语言从助手设置里拆出来,和它并列挂在「设置」面板的插件分类下。
|
||||
// 改动立刻生效并单独保存,不需要去点助手设置的保存按钮。
|
||||
let appearanceSettingsNode = null;
|
||||
|
||||
function appearanceControl() {
|
||||
if (appearanceSettingsNode) {
|
||||
alignWhenMounted(appearanceSettingsNode);
|
||||
return appearanceSettingsNode;
|
||||
}
|
||||
appearanceSettingsNode = element("div", "bpi-settings-form");
|
||||
appearanceSettingsNode.appendChild(element("h3", "", "Appearance & Language"));
|
||||
const loading = element("div", "bpm-loading", "Loading settings...");
|
||||
section.appendChild(loading);
|
||||
appearanceSettingsNode.appendChild(loading);
|
||||
buildAppearanceForm(appearanceSettingsNode, loading);
|
||||
alignWhenMounted(appearanceSettingsNode);
|
||||
return appearanceSettingsNode;
|
||||
}
|
||||
|
||||
async function buildAppearanceForm(host, loading) {
|
||||
let config;
|
||||
try {
|
||||
config = await getAssistantConfig();
|
||||
@@ -587,110 +645,11 @@ async function buildAssistantSection(section) {
|
||||
loading.classList.add("bpi-danger-text");
|
||||
return;
|
||||
}
|
||||
manager.assistantLoaded = true;
|
||||
applyBpiAppearance(config.appearance);
|
||||
section.replaceChildren();
|
||||
loading.remove();
|
||||
const form = element("form", "bpi-form");
|
||||
form.addEventListener("submit", (event) => event.preventDefault());
|
||||
const error = element("div", "bpi-status");
|
||||
error.style.gridColumn = "1 / -1";
|
||||
const status = element("div", "bpi-status");
|
||||
status.style.gridColumn = "1 / -1";
|
||||
|
||||
// 翻译服务:翻译/解释按此选择;“翻译并优化”“优化为 Anima”恒走 AI
|
||||
const serviceLabel = element("label", "", "Translation Service");
|
||||
const service = element("select");
|
||||
for (const [value, label] of [
|
||||
["dictionary", "Dictionary only (no API)"],
|
||||
["baidu", "Baidu Translate"],
|
||||
["ai", "AI (OpenAI-compatible / Ollama)"],
|
||||
]) {
|
||||
const option = element("option", "", label);
|
||||
option.value = value;
|
||||
service.appendChild(option);
|
||||
}
|
||||
service.value = config.translate_service;
|
||||
form.append(serviceLabel, service);
|
||||
const serviceNote = element("div", "bpi-config-note", '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).');
|
||||
serviceNote.style.gridColumn = "1 / -1";
|
||||
form.append(serviceNote);
|
||||
|
||||
// 百度翻译(独立留存,切翻译服务不清)
|
||||
const baiduHead = element("div", "bpm-toolbar");
|
||||
baiduHead.style.gridColumn = "1 / -1";
|
||||
baiduHead.append(element("strong", "", "Baidu Translate"));
|
||||
form.append(baiduHead);
|
||||
const baiduAppId = field(form, "APP ID", "assistant-baidu-appid", config.baidu_appid, 'Baidu Cloud "General Text Translation" APP ID');
|
||||
const baiduSecretKey = field(form, "Secret Key", "assistant-baidu-secret", "", config.baidu_secret_key_configured ? "Saved; leave empty to keep" : 'Baidu Cloud "General Text Translation" Secret Key');
|
||||
baiduSecretKey.type = "password";
|
||||
const clearBaiduLine = element("label", "bpm-toolbar");
|
||||
const clearBaiduSecretKey = element("input");
|
||||
clearBaiduSecretKey.type = "checkbox";
|
||||
clearBaiduLine.append(clearBaiduSecretKey, element("span", "", "Clear saved Baidu secret"));
|
||||
const baiduSpacer = element("span");
|
||||
form.append(baiduSpacer, clearBaiduLine);
|
||||
const baiduQps = field(form, "QPS (requests/second)", "assistant-baidu-qps", config.baidu_qps ?? 1, "1");
|
||||
baiduQps.type = "number";
|
||||
baiduQps.min = "0.1";
|
||||
baiduQps.max = "50";
|
||||
baiduQps.step = "0.5";
|
||||
const baiduQpsHelp = [
|
||||
"Baidu rate-limits General Text Translation by account tier. Enter your tier's number and the plugin queues requests to match:",
|
||||
"· Unverified / Standard: 1",
|
||||
"· Personal verified: 10",
|
||||
"· Enterprise verified: higher, see the Baidu AI Cloud console",
|
||||
"Find your tier under Baidu AI Cloud → Machine Translation → General Text Translation.",
|
||||
"Too high and Baidu still returns 54003 (too many requests); too low is only slower, never an error.",
|
||||
"If unsure, use 1.",
|
||||
].join("\n");
|
||||
form.querySelector('label[for="bpi-field-assistant-baidu-qps"]')?.appendChild(helpMark(baiduQpsHelp));
|
||||
const baiduQpsNote = element("div", "bpi-config-note", "How many requests per second Baidu may receive at most. Batch translation queues by this value and never exceeds it.");
|
||||
baiduQpsNote.style.gridColumn = "1 / -1";
|
||||
form.append(baiduQpsNote);
|
||||
|
||||
// AI 服务(优化恒走它;翻译在选 AI 时也走它)
|
||||
const aiHead = element("div", "bpm-toolbar");
|
||||
aiHead.style.gridColumn = "1 / -1";
|
||||
aiHead.append(element("strong", "", "AI Service (Translate & Optimize / Optimize / AI Translate)"));
|
||||
form.append(aiHead);
|
||||
const aiProviderLabel = element("label", "", "AI Backend");
|
||||
const aiProvider = element("select");
|
||||
for (const [value, label] of [["openai_compatible", "OpenAI-compatible API"], ["ollama", "Ollama (local)"]]) {
|
||||
const option = element("option", "", label);
|
||||
option.value = value;
|
||||
aiProvider.appendChild(option);
|
||||
}
|
||||
aiProvider.value = config.ai_provider;
|
||||
form.append(aiProviderLabel, aiProvider);
|
||||
const baseUrl = field(form, "API URL", "assistant-base-url", config.ai_base_url, "Optional for Ollama; e.g.: https://host/v1");
|
||||
const model = field(form, "Model Name", "assistant-model", config.ai_model, "e.g.: qwen3:8b or provider model ID");
|
||||
const presetLine = element("div", "bpm-toolbar");
|
||||
presetLine.style.gridColumn = "1 / -1";
|
||||
const lmStudioPreset = button("Use LM Studio Local Preset", () => {
|
||||
aiProvider.value = "openai_compatible";
|
||||
baseUrl.value = "http://127.0.0.1:1234/v1";
|
||||
setText(error, "LM Studio default URL filled; select a loaded model ID, then Save & Test");
|
||||
error.dataset.kind = "ok";
|
||||
});
|
||||
presetLine.append(lmStudioPreset, element("span", "bpi-config-note", "Connects to localhost:1234 by default; no external API required."));
|
||||
form.append(presetLine);
|
||||
const apiKey = field(form, "API Key", "assistant-api-key", "", config.ai_api_key_configured ? "Saved; leave empty to keep" : "Optional for local Ollama / LM Studio");
|
||||
apiKey.type = "password";
|
||||
const clearLine = element("label", "bpm-toolbar");
|
||||
const clearApiKey = element("input");
|
||||
clearApiKey.type = "checkbox";
|
||||
clearLine.append(clearApiKey, element("span", "", "Clear saved API Key"));
|
||||
const apiKeySpacer = element("span");
|
||||
form.append(apiKeySpacer, clearLine);
|
||||
const temperature = field(form, "Temperature", "assistant-temperature", config.ai_temperature, "0–2");
|
||||
temperature.type = "number";
|
||||
temperature.min = "0";
|
||||
temperature.max = "2";
|
||||
temperature.step = "0.05";
|
||||
const timeout = field(form, "Timeout (s)", "assistant-timeout", config.ai_timeout_seconds, "5–600");
|
||||
timeout.type = "number";
|
||||
timeout.min = "5";
|
||||
timeout.max = "600";
|
||||
|
||||
// 外观模式
|
||||
const appearanceLabel = element("label", "", "Appearance");
|
||||
const appearanceSelect = element("select");
|
||||
for (const [value, label] of [
|
||||
@@ -718,31 +677,350 @@ async function buildAssistantSection(section) {
|
||||
languageSelect.appendChild(option);
|
||||
}
|
||||
languageSelect.value = config.language || "auto";
|
||||
form.append(languageLabel, languageSelect);
|
||||
const languageNote = element("div", "bpi-config-note", "Choose the language for this plugin's own panels. \"Follow ComfyUI\" reads the ComfyUI language setting; \"Chinese\" and \"English\" force one. Applied to open panels right away; reload the page if anything still shows the old language.");
|
||||
languageNote.style.gridColumn = "1 / -1";
|
||||
form.append(languageNote);
|
||||
form.append(languageLabel, languageSelect, languageNote, status);
|
||||
host.appendChild(form);
|
||||
|
||||
// 规则
|
||||
const addRule = (labelText, value) => {
|
||||
// 只提交这两项:后端 update 对没给的字段一律保留原值,不会动翻译服务和密钥
|
||||
const save = async () => {
|
||||
try {
|
||||
const saved = await saveAssistantConfig({ appearance: appearanceSelect.value, language: languageSelect.value });
|
||||
applyBpiAppearance(saved.appearance);
|
||||
setLanguageMode(saved.language);
|
||||
setText(status, "Appearance and language saved");
|
||||
status.dataset.kind = "ok";
|
||||
} catch (saveError) {
|
||||
setText(status, saveError.message);
|
||||
status.dataset.kind = "error";
|
||||
}
|
||||
};
|
||||
appearanceSelect.addEventListener("change", save);
|
||||
languageSelect.addEventListener("change", save);
|
||||
}
|
||||
|
||||
// 助手设置直接长在 ComfyUI「设置」面板里(入口见下方 registerExtension 的 settings),
|
||||
// 这一个函数返回的就是设置项右侧那块控件。
|
||||
// 面板每次渲染都会重建控件容器,所以整块 DOM 只建一次后复用,输入到一半的内容不会丢。
|
||||
let assistantSettingsNode = null;
|
||||
|
||||
function assistantSettingsControl() {
|
||||
if (assistantSettingsNode) {
|
||||
alignWhenMounted(assistantSettingsNode);
|
||||
return assistantSettingsNode;
|
||||
}
|
||||
assistantSettingsNode = element("div", "bpi-settings-form");
|
||||
assistantSettingsNode.appendChild(element("h3", "", "Assistant Settings"));
|
||||
const loading = element("div", "bpm-loading", "Loading settings...");
|
||||
assistantSettingsNode.appendChild(loading);
|
||||
buildAssistantForm(assistantSettingsNode, loading);
|
||||
alignWhenMounted(assistantSettingsNode);
|
||||
return assistantSettingsNode;
|
||||
}
|
||||
|
||||
async function buildAssistantForm(host, loading) {
|
||||
let config;
|
||||
try {
|
||||
config = await getAssistantConfig();
|
||||
} catch (error) {
|
||||
setText(loading, error.message);
|
||||
loading.classList.add("bpi-danger-text");
|
||||
return;
|
||||
}
|
||||
applyBpiAppearance(config.appearance);
|
||||
loading.remove();
|
||||
const form = element("form", "bpi-form");
|
||||
form.addEventListener("submit", (event) => event.preventDefault());
|
||||
const error = element("div", "bpi-status");
|
||||
error.style.gridColumn = "1 / -1";
|
||||
|
||||
// 翻译服务:翻译/解释按此选择;“翻译并优化”“优化”恒走 AI
|
||||
const serviceLabel = element("label", "", "Translation Service");
|
||||
const service = element("select");
|
||||
for (const [value, label] of [
|
||||
["dictionary", "Dictionary only (no API)"],
|
||||
["baidu", "Baidu Translate"],
|
||||
["openai_compatible", "OpenAI-compatible API"],
|
||||
["ollama", "Ollama (local)"],
|
||||
]) {
|
||||
const option = element("option", "", label);
|
||||
option.value = value;
|
||||
service.appendChild(option);
|
||||
}
|
||||
// 后端已把旧的 "ai" 拆成两个具体服务;这里再兜一次,避免下拉落空
|
||||
service.value = config.translate_service === "ai" ? config.ai_provider || "openai_compatible" : config.translate_service;
|
||||
form.append(serviceLabel, service);
|
||||
const serviceNote = element("div", "bpi-config-note", '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.');
|
||||
serviceNote.style.gridColumn = "1 / -1";
|
||||
form.append(serviceNote);
|
||||
|
||||
// 按翻译服务分组显示:隐藏的字段不提交也不清空,切换前后值都在
|
||||
const baiduOnly = [];
|
||||
// AI 两个后端各一组:地址规则不同(Ollama 只认本机,OpenAI 远端必须 https),
|
||||
// 规则也各一套 —— 本地小模型和云端大模型要写的提示词不一样
|
||||
const openaiOnly = [];
|
||||
const ollamaOnly = [];
|
||||
// AI 整段(标题 + 后端下拉 + 两套具体配置 + 规则)只在翻译服务就是 AI 时出现;
|
||||
// 选词库 / 百度时一段都不显示,但优化仍会走上次选过的那个后端
|
||||
const aiOnly = [];
|
||||
// 翻译服务里属于 AI 的两个值:选了它们,翻译服务自己就是 AI 后端
|
||||
const AI_SERVICES = new Set(["openai_compatible", "ollama"]);
|
||||
const currentBackend = () => (AI_SERVICES.has(service.value) ? service.value : aiProvider.value);
|
||||
const addField = (list, ...args) => {
|
||||
const input = field(form, ...args);
|
||||
list.push(form.querySelector(`label[for="${input.id}"]`), input);
|
||||
return input;
|
||||
};
|
||||
// 模型名称这一行:输入框照旧可以手写,旁边的按钮去问一次服务端有哪些模型,填进输入框自带的下拉里。
|
||||
// 用原生 datalist 而不是弹窗:助手设置长在 ComfyUI 的「设置」面板里,浮层会被它盖住且点不动。
|
||||
const addModelRow = (list, name, value, placeholder, options) => {
|
||||
const label = element("label", "", "Model Name");
|
||||
label.htmlFor = `bpi-field-${name}`;
|
||||
const input = element("input");
|
||||
input.id = label.htmlFor;
|
||||
input.name = name;
|
||||
input.value = value ?? "";
|
||||
setPlaceholder(input, placeholder);
|
||||
// datalist 本身不渲染,挂在 form 里跟着这块设置 DOM 一起活;选项只在本次会话内有效
|
||||
const dataList = element("datalist");
|
||||
dataList.id = `bpi-models-${name}`;
|
||||
input.setAttribute("list", dataList.id);
|
||||
let models = null;
|
||||
const trigger = button("Fetch Models", () => pick());
|
||||
const line = element("div", "bpi-model-line");
|
||||
line.append(input, trigger);
|
||||
form.append(label, line, dataList);
|
||||
list.push(label, line, dataList);
|
||||
const countLabel = () => (models ? `Fetch Models (${models.length})` : "Fetch Models");
|
||||
const forget = () => {
|
||||
models = null;
|
||||
dataList.textContent = "";
|
||||
setText(trigger, countLabel());
|
||||
};
|
||||
// 地址改了就地作废:下拉里不能留着上一个地址的模型
|
||||
options.baseUrl().addEventListener("change", forget);
|
||||
const pick = async () => {
|
||||
trigger.disabled = true;
|
||||
setText(trigger, models ? countLabel() : "Fetching...");
|
||||
try {
|
||||
const url = options.baseUrl().value.trim();
|
||||
const keyInput = options.apiKey();
|
||||
models = await listAssistantModels({
|
||||
backend: options.backend,
|
||||
base_url: url,
|
||||
api_key: keyInput ? keyInput.value.trim() : "",
|
||||
});
|
||||
dataList.textContent = "";
|
||||
for (const model of models) {
|
||||
const option = element("option");
|
||||
option.value = model; // 模型名是服务端原样返回值,不参与翻译
|
||||
dataList.appendChild(option);
|
||||
}
|
||||
setText(trigger, countLabel());
|
||||
input.focus();
|
||||
setText(error, `Fetched ${models.length} models`);
|
||||
error.dataset.kind = "ok";
|
||||
} catch (fetchError) {
|
||||
setText(trigger, countLabel());
|
||||
setText(error, fetchError.message);
|
||||
error.dataset.kind = "error";
|
||||
} finally {
|
||||
trigger.disabled = false;
|
||||
}
|
||||
};
|
||||
return input;
|
||||
};
|
||||
// 清除已保存的密钥:开关样式,打开后要保存才生效。返回的 input 上挂了整行(`.row`)
|
||||
const clearSwitch = (labelText) => {
|
||||
const row = element("label", "bpi-switch-row");
|
||||
const input = element("input");
|
||||
input.type = "checkbox";
|
||||
const track = element("span", "bpi-switch-track");
|
||||
track.appendChild(element("span", "bpi-switch-thumb"));
|
||||
row.append(input, track, element("span", "bpi-switch-text", labelText), element("span", "bpi-switch-hint", "Takes effect after saving"));
|
||||
input.row = row;
|
||||
return input;
|
||||
};
|
||||
|
||||
// 百度翻译(独立留存,切翻译服务不清)
|
||||
const baiduHead = element("div", "bpm-toolbar");
|
||||
baiduHead.style.gridColumn = "1 / -1";
|
||||
baiduHead.append(element("strong", "", "Baidu Translate"));
|
||||
form.append(baiduHead);
|
||||
baiduOnly.push(baiduHead);
|
||||
const baiduAppId = addField(baiduOnly, "APP ID", "assistant-baidu-appid", config.baidu_appid, 'Baidu Cloud "General Text Translation" APP ID');
|
||||
const baiduSecretKey = addField(baiduOnly, "Secret Key", "assistant-baidu-secret", "", config.baidu_secret_key_configured ? "Saved; leave empty to keep" : 'Baidu Cloud "General Text Translation" Secret Key');
|
||||
baiduSecretKey.type = "password";
|
||||
const clearBaiduSecretKey = clearSwitch("Clear saved Baidu secret");
|
||||
const baiduSpacer = element("span");
|
||||
form.append(baiduSpacer, clearBaiduSecretKey.row);
|
||||
baiduOnly.push(baiduSpacer, clearBaiduSecretKey.row);
|
||||
const baiduQps = addField(baiduOnly, "QPS (requests/second)", "assistant-baidu-qps", config.baidu_qps ?? 1, "1");
|
||||
baiduQps.type = "number";
|
||||
baiduQps.min = "0.1";
|
||||
baiduQps.max = "50";
|
||||
baiduQps.step = "0.5";
|
||||
const baiduQpsHelp = [
|
||||
"Baidu rate-limits General Text Translation by account tier. Enter your tier's number and the plugin queues requests to match:",
|
||||
"· Unverified / Standard: 1",
|
||||
"· Personal verified: 10",
|
||||
"· Enterprise verified: higher, see the Baidu AI Cloud console",
|
||||
"Find your tier under Baidu AI Cloud → Machine Translation → General Text Translation.",
|
||||
"Too high and Baidu still returns 54003 (too many requests); too low is only slower, never an error.",
|
||||
"If unsure, use 1.",
|
||||
].join("\n");
|
||||
form.querySelector('label[for="bpi-field-assistant-baidu-qps"]')?.appendChild(helpMark(baiduQpsHelp));
|
||||
const baiduQpsNote = element("div", "bpi-config-note", "How many requests per second Baidu may receive at most. Batch translation queues by this value and never exceeds it.");
|
||||
baiduQpsNote.style.gridColumn = "1 / -1";
|
||||
form.append(baiduQpsNote);
|
||||
baiduOnly.push(baiduQpsNote);
|
||||
|
||||
// AI 服务(优化恒走它;翻译在选 AI 时也走它)
|
||||
const aiHead = element("div", "bpm-toolbar");
|
||||
aiHead.style.gridColumn = "1 / -1";
|
||||
aiHead.append(element("strong", "", "AI Service (Translate & Optimize / Optimize / AI Translate)"));
|
||||
form.append(aiHead);
|
||||
aiOnly.push(aiHead);
|
||||
const aiProviderLabel = element("label", "", "AI Backend");
|
||||
const aiProvider = element("select");
|
||||
for (const [value, label] of [["openai_compatible", "OpenAI-compatible API"], ["ollama", "Ollama (local)"]]) {
|
||||
const option = element("option", "", label);
|
||||
option.value = value;
|
||||
aiProvider.appendChild(option);
|
||||
}
|
||||
aiProvider.value = config.ai_provider;
|
||||
form.append(aiProviderLabel, aiProvider);
|
||||
aiOnly.push(aiProviderLabel, aiProvider);
|
||||
|
||||
// OpenAI 兼容:远端必须 https,本机回环才允许 http
|
||||
const openaiHead = element("div", "bpm-toolbar");
|
||||
openaiHead.style.gridColumn = "1 / -1";
|
||||
openaiHead.append(element("strong", "", "OpenAI-compatible API"));
|
||||
form.append(openaiHead);
|
||||
openaiOnly.push(openaiHead);
|
||||
const openaiBaseUrl = addField(openaiOnly, "API URL", "assistant-openai-base-url", config.openai_base_url, "https://host/v1");
|
||||
const openaiModel = addModelRow(openaiOnly, "assistant-openai-model", config.openai_model, "provider model ID", {
|
||||
backend: "openai_compatible",
|
||||
baseUrl: () => openaiBaseUrl,
|
||||
apiKey: () => openaiApiKey,
|
||||
});
|
||||
const presetLine = element("div", "bpm-toolbar");
|
||||
presetLine.style.gridColumn = "1 / -1";
|
||||
const lmStudioPreset = button("Use LM Studio Local Preset", () => {
|
||||
aiProvider.value = "openai_compatible";
|
||||
openaiBaseUrl.value = "http://127.0.0.1:1234/v1";
|
||||
setText(error, "LM Studio default URL filled; select a loaded model ID, then Save & Test");
|
||||
error.dataset.kind = "ok";
|
||||
});
|
||||
presetLine.append(lmStudioPreset, element("span", "bpi-config-note", "Connects to localhost:1234 by default; no external API required."));
|
||||
form.append(presetLine);
|
||||
openaiOnly.push(presetLine);
|
||||
const openaiApiKey = addField(openaiOnly, "API Key", "assistant-openai-api-key", "", config.openai_api_key_configured ? "Saved; leave empty to keep" : "Optional for local LM Studio");
|
||||
openaiApiKey.type = "password";
|
||||
const clearApiKey = clearSwitch("Clear saved API Key");
|
||||
const apiKeySpacer = element("span");
|
||||
form.append(apiKeySpacer, clearApiKey.row);
|
||||
openaiOnly.push(apiKeySpacer, clearApiKey.row);
|
||||
const openaiTemperature = addField(openaiOnly, "Temperature", "assistant-openai-temperature", config.openai_temperature, "0–2");
|
||||
openaiTemperature.type = "number";
|
||||
openaiTemperature.min = "0";
|
||||
openaiTemperature.max = "2";
|
||||
openaiTemperature.step = "0.05";
|
||||
const openaiTimeout = addField(openaiOnly, "Timeout (s)", "assistant-openai-timeout", config.openai_timeout_seconds, "5–600");
|
||||
openaiTimeout.type = "number";
|
||||
openaiTimeout.min = "5";
|
||||
openaiTimeout.max = "600";
|
||||
const openaiUrlNote = element("div", "bpi-config-note", "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.");
|
||||
openaiUrlNote.style.gridColumn = "1 / -1";
|
||||
form.append(openaiUrlNote);
|
||||
openaiOnly.push(openaiUrlNote);
|
||||
|
||||
// Ollama:只允许本机回环,没有密钥
|
||||
const ollamaHead = element("div", "bpm-toolbar");
|
||||
ollamaHead.style.gridColumn = "1 / -1";
|
||||
ollamaHead.append(element("strong", "", "Ollama (local)"));
|
||||
form.append(ollamaHead);
|
||||
ollamaOnly.push(ollamaHead);
|
||||
const ollamaBaseUrl = addField(ollamaOnly, "API URL", "assistant-ollama-base-url", config.ollama_base_url, "http://127.0.0.1:11434");
|
||||
const ollamaModel = addModelRow(ollamaOnly, "assistant-ollama-model", config.ollama_model, "e.g.: qwen3:8b", {
|
||||
backend: "ollama",
|
||||
baseUrl: () => ollamaBaseUrl,
|
||||
apiKey: () => null,
|
||||
});
|
||||
const ollamaPresetLine = element("div", "bpm-toolbar");
|
||||
ollamaPresetLine.style.gridColumn = "1 / -1";
|
||||
const ollamaPreset = button("Use Ollama Local Preset", () => {
|
||||
aiProvider.value = "ollama";
|
||||
ollamaBaseUrl.value = "http://127.0.0.1:11434";
|
||||
setText(error, "Ollama default URL filled; select a pulled model ID, then Save & Test");
|
||||
error.dataset.kind = "ok";
|
||||
});
|
||||
ollamaPresetLine.append(ollamaPreset, element("span", "bpi-config-note", "Connects to localhost:11434 by default; no external API required."));
|
||||
form.append(ollamaPresetLine);
|
||||
ollamaOnly.push(ollamaPresetLine);
|
||||
const ollamaTemperature = addField(ollamaOnly, "Temperature", "assistant-ollama-temperature", config.ollama_temperature, "0–2");
|
||||
ollamaTemperature.type = "number";
|
||||
ollamaTemperature.min = "0";
|
||||
ollamaTemperature.max = "2";
|
||||
ollamaTemperature.step = "0.05";
|
||||
const ollamaTimeout = addField(ollamaOnly, "Timeout (s)", "assistant-ollama-timeout", config.ollama_timeout_seconds, "5–600");
|
||||
ollamaTimeout.type = "number";
|
||||
ollamaTimeout.min = "5";
|
||||
ollamaTimeout.max = "600";
|
||||
const ollamaUrlNote = element("div", "bpi-config-note", "Ollama only accepts a loopback address on this machine; anything else is rejected before a request is sent.");
|
||||
ollamaUrlNote.style.gridColumn = "1 / -1";
|
||||
form.append(ollamaUrlNote);
|
||||
ollamaOnly.push(ollamaUrlNote);
|
||||
|
||||
// 规则:只有 AI 会读,两个后端各一套(server.py 按当前后端取对应的那套)
|
||||
const addRule = (list, labelText, value) => {
|
||||
const labelElement = element("label", "", labelText);
|
||||
const textarea = element("textarea");
|
||||
textarea.value = value;
|
||||
form.append(labelElement, textarea);
|
||||
return { labelElement, textarea };
|
||||
list.push(labelElement, textarea);
|
||||
return textarea;
|
||||
};
|
||||
const rules = {
|
||||
openai_compatible: {
|
||||
translation: addRule(openaiOnly, "Translation Rule", config.openai_translation_rule),
|
||||
translateOptimize: addRule(openaiOnly, "Translate & Optimize Rule", config.openai_translate_optimize_rule),
|
||||
optimization: addRule(openaiOnly, "Optimize Rule", config.openai_optimization_rule),
|
||||
natural: addRule(openaiOnly, "Natural Language Rule", config.openai_natural_rule),
|
||||
},
|
||||
ollama: {
|
||||
translation: addRule(ollamaOnly, "Translation Rule", config.ollama_translation_rule),
|
||||
translateOptimize: addRule(ollamaOnly, "Translate & Optimize Rule", config.ollama_translate_optimize_rule),
|
||||
optimization: addRule(ollamaOnly, "Optimize Rule", config.ollama_optimization_rule),
|
||||
natural: addRule(ollamaOnly, "Natural Language Rule", config.ollama_natural_rule),
|
||||
},
|
||||
};
|
||||
const translationRule = addRule("Translation Rule", config.translation_rule);
|
||||
const translateOptimizeRule = addRule("Translate & Optimize Rule", config.translate_optimize_rule);
|
||||
const optimizationRule = addRule("Optimize Rule", config.optimization_rule);
|
||||
form.appendChild(error);
|
||||
|
||||
section.appendChild(form);
|
||||
section.appendChild(element("div", "bpi-config-note", "Settings are stored in the ComfyUI user directory. AI key and Baidu secret are retained independently; switching service never clears either. AI key is only cleared when AI backend/URL changes or on a different machine. Neither is written to workflows or returned to the browser."));
|
||||
const actions = element("div", "bpm-assistant-actions");
|
||||
const applyServiceVisibility = () => {
|
||||
const value = service.value;
|
||||
// 只有翻译服务本身就是 AI 时才展开 AI 整段;选词库 / 百度时一段都不显示
|
||||
for (const node of baiduOnly) node.hidden = value !== "baidu";
|
||||
for (const node of aiOnly) node.hidden = !AI_SERVICES.has(value);
|
||||
for (const node of openaiOnly) node.hidden = value !== "openai_compatible";
|
||||
for (const node of ollamaOnly) node.hidden = value !== "ollama";
|
||||
};
|
||||
service.addEventListener("change", applyServiceVisibility);
|
||||
aiProvider.addEventListener("change", applyServiceVisibility);
|
||||
applyServiceVisibility();
|
||||
|
||||
host.appendChild(form);
|
||||
host.appendChild(element("div", "bpi-config-note", "Settings are stored in the ComfyUI user directory. AI key and Baidu secret are retained independently; switching service never clears either. AI key is only cleared when AI backend/URL changes or on a different machine. Neither is written to workflows or returned to the browser."));
|
||||
const actions = element("div", "bpi-form-actions");
|
||||
// 只恢复当前所选后端那套规则,另一套不动
|
||||
const resetButton = button("Reset to Default Rules", () => {
|
||||
translationRule.textarea.value = config.default_translation_rule;
|
||||
translateOptimizeRule.textarea.value = config.default_translate_optimize_rule;
|
||||
optimizationRule.textarea.value = config.default_optimization_rule;
|
||||
const backend = currentBackend();
|
||||
const prefix = backend === "ollama" ? "ollama" : "openai";
|
||||
const active = rules[backend] ?? rules.openai_compatible;
|
||||
active.translation.value = config[`default_${prefix}_translation_rule`];
|
||||
active.translateOptimize.value = config[`default_${prefix}_translate_optimize_rule`];
|
||||
active.optimization.value = config[`default_${prefix}_optimization_rule`];
|
||||
active.natural.value = config[`default_${prefix}_natural_rule`];
|
||||
setText(error, "Defaults restored in editor; click Save to apply");
|
||||
error.dataset.kind = "ok";
|
||||
});
|
||||
@@ -752,7 +1030,6 @@ async function buildAssistantSection(section) {
|
||||
error.dataset.kind = "busy";
|
||||
try {
|
||||
config = await saveAssistantConfig(payload());
|
||||
setLanguageMode(config.language);
|
||||
const result = await testAssistantConnection();
|
||||
setText(error, `Connection OK: “${result.message}”`);
|
||||
error.dataset.kind = "ok";
|
||||
@@ -767,8 +1044,6 @@ async function buildAssistantSection(section) {
|
||||
saveButton.disabled = true;
|
||||
try {
|
||||
config = await saveAssistantConfig(payload());
|
||||
applyBpiAppearance(config.appearance);
|
||||
setLanguageMode(config.language);
|
||||
setText(error, "Assistant settings saved");
|
||||
error.dataset.kind = "ok";
|
||||
} catch (saveError) {
|
||||
@@ -779,25 +1054,34 @@ async function buildAssistantSection(section) {
|
||||
}
|
||||
}, "bpi-primary");
|
||||
actions.append(resetButton, testButton, saveButton);
|
||||
section.appendChild(actions);
|
||||
host.appendChild(actions);
|
||||
// 两套 AI 配置和两套规则都提交(后端对没给的字段才保留原值,这里给全最省心);
|
||||
// 外观 / 语言已在「设置」面板里独立成项(appearanceControl),这里既不提交也不应用
|
||||
const payload = () => ({
|
||||
translate_service: service.value,
|
||||
ai_provider: aiProvider.value,
|
||||
ai_base_url: baseUrl.value.trim(),
|
||||
ai_model: model.value.trim(),
|
||||
ai_api_key: apiKey.value.trim(),
|
||||
clear_ai_api_key: clearApiKey.checked,
|
||||
ai_provider: currentBackend(),
|
||||
openai_base_url: openaiBaseUrl.value.trim(),
|
||||
openai_model: openaiModel.value.trim(),
|
||||
openai_api_key: openaiApiKey.value.trim(),
|
||||
clear_openai_api_key: clearApiKey.checked,
|
||||
openai_temperature: openaiTemperature.value,
|
||||
openai_timeout_seconds: openaiTimeout.value,
|
||||
ollama_base_url: ollamaBaseUrl.value.trim(),
|
||||
ollama_model: ollamaModel.value.trim(),
|
||||
ollama_temperature: ollamaTemperature.value,
|
||||
ollama_timeout_seconds: ollamaTimeout.value,
|
||||
baidu_appid: baiduAppId.value.trim(),
|
||||
baidu_secret_key: baiduSecretKey.value.trim(),
|
||||
clear_baidu_secret_key: clearBaiduSecretKey.checked,
|
||||
baidu_qps: baiduQps.value.trim(),
|
||||
ai_temperature: temperature.value,
|
||||
ai_timeout_seconds: timeout.value,
|
||||
appearance: appearanceSelect.value,
|
||||
language: languageSelect.value,
|
||||
translation_rule: translationRule.textarea.value,
|
||||
translate_optimize_rule: translateOptimizeRule.textarea.value,
|
||||
optimization_rule: optimizationRule.textarea.value,
|
||||
openai_translation_rule: rules.openai_compatible.translation.value,
|
||||
openai_translate_optimize_rule: rules.openai_compatible.translateOptimize.value,
|
||||
openai_optimization_rule: rules.openai_compatible.optimization.value,
|
||||
openai_natural_rule: rules.openai_compatible.natural.value,
|
||||
ollama_translation_rule: rules.ollama.translation.value,
|
||||
ollama_translate_optimize_rule: rules.ollama.translateOptimize.value,
|
||||
ollama_optimization_rule: rules.ollama.optimization.value,
|
||||
ollama_natural_rule: rules.ollama.natural.value,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -809,7 +1093,6 @@ function setSection(section) {
|
||||
refs.sections?.querySelector(`.bpm-section[data-section="${id}"]`)?.toggleAttribute("hidden", id !== section);
|
||||
}
|
||||
if (section === "tag-manager") rebuildTagManagerSection();
|
||||
if (section === "assistant" && !manager.assistantLoaded) buildAssistantSection(refs.assistantSection);
|
||||
if (section === "favorites" && !manager.favoritesLoaded) {
|
||||
manager.favoritesLoaded = true;
|
||||
refs.sections.appendChild(buildFavoritesSection());
|
||||
@@ -1109,8 +1392,21 @@ function inspectorNodes() {
|
||||
return (app.graph?._nodes ?? []).filter((node) => node._bilingualPromptInspector?.detailsBody);
|
||||
}
|
||||
|
||||
// 用户在画布上双击节点顶部改名后,列表里显示他自己起的名字;
|
||||
// 名字后面保留节点号,免得两个节点改成同一个名字时认不出来。
|
||||
function customNodeTitle(node) {
|
||||
const current = String(node.title ?? "").trim();
|
||||
// defaultNodeTitle 是节点创建时记下的出厂标题;拿不到就退回节点类型的默认标题,
|
||||
// 两者都拿不到时不做判断,免得把出厂标题当成用户改过的名字。
|
||||
const factory = String(node._bilingualPromptInspector?.defaultNodeTitle ?? node.constructor?.title ?? "").trim();
|
||||
if (!current || !factory || current === factory) return null;
|
||||
return current;
|
||||
}
|
||||
|
||||
function tagNodeLabel(node) {
|
||||
const text = String(node.widgets?.find((widget) => widget.name === "text")?.value ?? "").trim();
|
||||
const custom = customNodeTitle(node);
|
||||
if (custom) return `${custom} · ${t(`Node #${node.id}`)}`;
|
||||
return `${t(`Node #${node.id}`)}: ${text ? text.slice(0, 30) : t("(empty prompt)")}`;
|
||||
}
|
||||
|
||||
@@ -1186,8 +1482,6 @@ function build() {
|
||||
refs.sections.appendChild(refs.tagManagerSection);
|
||||
refs.sections.appendChild(buildTagsSection());
|
||||
refs.sections.appendChild(buildPacksSection());
|
||||
refs.assistantSection = buildAssistantPlaceholder();
|
||||
refs.sections.appendChild(refs.assistantSection);
|
||||
root.append(head, refs.tabs, refs.sections);
|
||||
root.addEventListener("mousedown", (event) => event.stopPropagation());
|
||||
root.addEventListener("wheel", (event) => event.stopPropagation(), { passive: true });
|
||||
@@ -1303,14 +1597,6 @@ function buildPacksSection() {
|
||||
return section;
|
||||
}
|
||||
|
||||
function buildAssistantPlaceholder() {
|
||||
const section = element("div", "bpm-section");
|
||||
section.dataset.section = "assistant";
|
||||
section.hidden = true;
|
||||
section.appendChild(element("div", "bpm-loading", "Settings load on first visit..."));
|
||||
return section;
|
||||
}
|
||||
|
||||
function renderTab(container) {
|
||||
if (!root) build();
|
||||
container.replaceChildren(root);
|
||||
@@ -1341,6 +1627,24 @@ getAssistantConfig()
|
||||
|
||||
app.registerExtension({
|
||||
name: "ComfyUI.BilingualPromptInspector.ManagerPanel",
|
||||
// 助手设置整体挂在 ComfyUI 的「设置」面板里:项名和分类由前端原生渲染,
|
||||
// 不走插件的 t(),直接写中文;控件本体是 assistantSettingsControl() 返回的那块 DOM。
|
||||
settings: [
|
||||
{
|
||||
id: "BilingualPromptInspector.Appearance",
|
||||
name: "语言与外观",
|
||||
type: appearanceControl,
|
||||
defaultValue: "",
|
||||
category: ["双语提示词检查器", "语言与外观"],
|
||||
},
|
||||
{
|
||||
id: "BilingualPromptInspector.AssistantSettings",
|
||||
name: "助手设置",
|
||||
type: assistantSettingsControl,
|
||||
defaultValue: "",
|
||||
category: ["双语提示词检查器", "助手设置"],
|
||||
},
|
||||
],
|
||||
async setup() {
|
||||
if (!app.extensionManager?.registerSidebarTab) {
|
||||
console.warn("[BilingualPromptInspector] Current frontend does not support sidebar; panel unavailable");
|
||||
@@ -1352,7 +1656,7 @@ app.registerExtension({
|
||||
// 侧边栏标签显示的就是插件名,直接写中文:注册发生在启动时,
|
||||
// 语言设置要等后端配置返回才落地,走 t() 会先渲染成英文且不会再刷新。
|
||||
title: "提示词翻译与管理",
|
||||
tooltip: "提示词翻译与管理:标签管理、词库、词包与助手设置",
|
||||
tooltip: "提示词翻译与管理:标签管理、词库、词包与收藏",
|
||||
type: "custom",
|
||||
render: renderTab,
|
||||
destroy: destroyTab,
|
||||
|
||||
+110
-10
@@ -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,3 +1,4 @@
|
||||
from .duplicate_check import dedupe_prompt, find_duplicates, format_duplicates
|
||||
from .server import announce_upstream, current_prompt_id, upstream_gate
|
||||
|
||||
|
||||
@@ -52,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)),
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "comfyui-bilingual-prompt-inspector",
|
||||
"version": "1.2.10",
|
||||
"version": "2.1.9",
|
||||
"description": "ComfyUI bilingual prompt inspection, translation, Anima sorting, and on-demand Danbooru dictionary extension.",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
+6
-5
@@ -1,7 +1,8 @@
|
||||
[project]
|
||||
name = "prompt-translator-manager"
|
||||
version = "1.2.10"
|
||||
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.1.9"
|
||||
# 与 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(未缩放),站点会自行裁切并加背景虚化
|
||||
|
||||
@@ -14,31 +14,44 @@ 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()
|
||||
@@ -167,7 +180,7 @@ async def _baidu_translate(text, to_lang):
|
||||
if not chunks:
|
||||
raise ValueError("待翻译文本为空")
|
||||
qps = config.get("baidu_qps") or BAIDU_QPS_DEFAULT
|
||||
timeout = ClientTimeout(total=config["ai_timeout_seconds"])
|
||||
timeout = ClientTimeout(total=BAIDU_TIMEOUT_SECONDS)
|
||||
results = []
|
||||
async with ClientSession(timeout=timeout) as session:
|
||||
for chunk in chunks:
|
||||
@@ -191,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 = "你是连接测试助手。"
|
||||
@@ -213,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
|
||||
|
||||
@@ -269,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 "纯词库模式可用"
|
||||
@@ -288,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")
|
||||
@@ -315,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")
|
||||
@@ -349,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("输入内容为空")
|
||||
@@ -388,26 +455,26 @@ async def get_dictionary(_request):
|
||||
return error_response(error, 500)
|
||||
|
||||
|
||||
# 节点标签卡的颜色配置:用户可以直接编辑 data/token_colors.json 增删色块,
|
||||
# 不用改源码。文件不在或格式不对时回退内置默认,不影响功能。
|
||||
_DEFAULT_TOKEN_COLORS = {
|
||||
"presets": ["#6b9b78", "#9b8b6b", "#6b7b9b", "#9b6b8b", "#8b9b6b", "#6b9b9b", "#9b7b6b", "#7b6b9b"],
|
||||
"random_pool": ["#5b8a72", "#8a725b", "#5b6e8a", "#8a5b7a", "#7a8a5b", "#5b8a8a", "#8a6b5b", "#6b5b8a"],
|
||||
}
|
||||
|
||||
|
||||
# 节点标签卡的颜色配置:色块池只读(用户可以直接编辑 data/token_colors.json),
|
||||
# 按词库配色由侧边栏「颜色」区写回同一个文件。
|
||||
@protected_route("get", "/bpi/token-colors")
|
||||
async def get_token_colors(_request):
|
||||
from pathlib import Path
|
||||
path = Path(__file__).resolve().parent / "data" / "token_colors.json"
|
||||
try:
|
||||
if path.exists():
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
if isinstance(data, dict) and isinstance(data.get("presets"), list) and isinstance(data.get("random_pool"), list):
|
||||
return web.json_response({"success": True, "data": data})
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
return web.json_response({"success": True, "data": _DEFAULT_TOKEN_COLORS})
|
||||
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")
|
||||
@@ -852,3 +919,21 @@ def collect_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"},
|
||||
)
|
||||
|
||||
+305
-40
@@ -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")
|
||||
|
||||
@@ -215,6 +448,38 @@ class AssistantStoreTests(unittest.TestCase):
|
||||
path.write_text(json.dumps(raw, ensure_ascii=False), encoding="utf-8")
|
||||
self.assertEqual(store.config()["baidu_qps"], 1.0)
|
||||
|
||||
def test_model_list_endpoints_reuse_the_saved_base_url_shape(self):
|
||||
self.assertEqual(openai_models_endpoint("https://api.openai.com/v1"), "https://api.openai.com/v1/models")
|
||||
self.assertEqual(openai_models_endpoint("http://127.0.0.1:1234/v1/"), "http://127.0.0.1:1234/v1/models")
|
||||
self.assertEqual(openai_models_endpoint("https://x.cn/v1/chat/completions"), "https://x.cn/v1/models")
|
||||
self.assertEqual(openai_models_endpoint("https://x.cn/models"), "https://x.cn/models")
|
||||
self.assertEqual(ollama_tags_endpoint("http://127.0.0.1:11434"), "http://127.0.0.1:11434/api/tags")
|
||||
self.assertEqual(ollama_tags_endpoint("http://127.0.0.1:11434/v1"), "http://127.0.0.1:11434/api/tags")
|
||||
self.assertEqual(ollama_tags_endpoint("http://127.0.0.1:11434/api/chat"), "http://127.0.0.1:11434/api/tags")
|
||||
self.assertEqual(openai_models_endpoint(""), "")
|
||||
self.assertEqual(ollama_tags_endpoint(""), "")
|
||||
|
||||
def test_error_bodies_that_are_not_json_still_read_as_a_sentence(self):
|
||||
# DeepSeek 缺密钥时回的是纯文本一行,不是 JSON;解析不了也不能把整条请求打崩
|
||||
# 第三个参数和 server.py 里的调用保持一致:整条路径改签名时这里会先炸出来
|
||||
self.assertIn("Authentication Fails", upstream_error_message("Authentication Fails (governor)", 401, "API"))
|
||||
self.assertIn("请检查 API 密钥", upstream_error_message("Authentication Fails (governor)", 401, "API"))
|
||||
self.assertEqual(
|
||||
upstream_error_message('{"error":{"message":"Invalid API key"}}', 401, "API"),
|
||||
"Invalid API key(HTTP 401;请检查 API 密钥是否已填写、是否有效)",
|
||||
)
|
||||
self.assertEqual(upstream_error_message("404 page not found", 404, "API"), "404 page not found(HTTP 404)")
|
||||
self.assertEqual(upstream_error_message("", 502, "Ollama"), "Ollama 请求失败(HTTP 502)")
|
||||
self.assertTrue(len(upstream_error_message("<html>" + "x" * 500 + "</html>", 403, "API")) < 220)
|
||||
|
||||
def test_non_json_success_body_reports_instead_of_crashing(self):
|
||||
self.assertEqual(parse_json_body('{"data": []}', "API"), {"data": []})
|
||||
with self.assertRaises(ValueError) as raised:
|
||||
parse_json_body("<html>login</html>", "API")
|
||||
self.assertIn("返回的不是 JSON", str(raised.exception))
|
||||
with self.assertRaises(ValueError):
|
||||
parse_json_body("[1, 2, 3]", "API")
|
||||
|
||||
def test_baidu_params_and_query_splitting(self):
|
||||
params = baidu_translate_params("appid", "secret", "你好", "zh", salt="12345")
|
||||
self.assertEqual(params["sign"], hashlib.md5("appid你好12345secret".encode("utf-8")).hexdigest())
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
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: only the first occurrence enters the actual output; press Ctrl+Z to undo"),
|
||||
"「1girl」已设为唯一:只有第一次出现进入实际输出;按 Ctrl+Z 可撤销");
|
||||
// 「Unique」在译文里会被二次翻译,tooltip 读起来是一致的中文
|
||||
assert.equal(t("Query: “1girl” | “Unique” is on: only the first occurrence reaches the actual output"),
|
||||
"查询「1girl」|已开「唯一」:只有第一次出现进入实际输出");
|
||||
|
||||
console.log("test_duplicates: ok");
|
||||
+56
-9
@@ -34,6 +34,27 @@ assert.equal(t("restored 3 hidden tags; press Ctrl+Z to undo"), "已恢复 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 条|本地用户|始终启用,覆盖同名条目");
|
||||
|
||||
@@ -64,7 +85,15 @@ assert.equal(
|
||||
// 自由文本变量必须用「"…"」包起来才能折叠成 {},否则整句永远匹配不上。
|
||||
// 这里锁死「运行时真实形态」,防止以后有人把引号去掉或换成 ASCII 直引号。
|
||||
for (const [source, expected] of [
|
||||
["“Optimize to Anima” done; please confirm in the preview window", "「优化为 Anima」完成;请在预览窗口确认"],
|
||||
["“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”; press Ctrl+Z to undo", "已应用 翻译并优化;按 Ctrl+Z 可撤销"],
|
||||
["Undone: “Sort to Anima order”", "已撤销:Sort to Anima order"],
|
||||
["Redone: “Sort to Anima order”", "已重做:Sort to Anima order"],
|
||||
@@ -77,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」"],
|
||||
@@ -97,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"), "★ 取消收藏");
|
||||
@@ -117,15 +160,19 @@ assert.equal(t("(weight 1.5)"), "(权重 1.5)");
|
||||
// 插件名:侧边栏标签写死中文,面板/关于框走译表,三处必须同一个名字
|
||||
assert.equal(t("Bilingual Prompt Manager"), "提示词翻译与管理");
|
||||
assert.equal(t("Bilingual Prompt Inspector"), "提示词翻译与管理");
|
||||
// 设置面板里「语言与外观」那一项
|
||||
assert.equal(t("Appearance & Language"), "语言与外观");
|
||||
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("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("Node #3"), "节点 #3");
|
||||
|
||||
|
||||
+53
-1
@@ -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");
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user