Compare commits

...
5 Commits
Author SHA1 Message Date
梦圣智杰 cd55defc87 chore: 发布 v2.0.0 / Release v2.0.0 2026-10-10 01:33:23 +08:00
梦圣智杰 79dc337c0e feat: 合并重复检查节点到主线 / Merge duplicate-check node into main 2026-10-10 01:00:09 +08:00
梦圣智杰 642837842c feat: 隐藏按钮挪到方框右上角内 / Move hide button inside the card corner
- 标签卡上的隐藏(眼睛)按钮不再跟着英文行走,固定在整张卡片的右上角内侧
- 鼠标移到卡片任意位置(含中文行)都会显示该按钮
2026-10-10 00:30:18 +08:00
梦圣智杰 13c2fecce2 feat: 重复标签可设「唯一」并新增重复栏 / Unique toggle and duplicate bar for repeated tags
- 重复分组抽成 parser.js 的 duplicateGroups(),问题提示行与界面共用同一份口径
- 「重复标签:X」后面加每个标签独立的「唯一」开关:只保留第一次出现进入实际输出,
  其余在界面上划线保留,不删原文,支持 Ctrl+Z
- 英文标签区「已隐藏」下方新增重复栏,一个标签一个胶囊,单击依次定位到每一次
  出现并联动选中(标红),已定位时双击删掉当前那一处
- 新增 tests/test_duplicates.mjs,14 个 JS 测试全绿
2026-10-06 17:16:49 +08:00
梦圣智杰 7cb8d80b02 feat: 新增重复检查节点 / Add duplicate tag checker node
- 检测提示词里的重复标签,节点用淡橙色提示重复数量,无重复时保持默认配色
- 开关「只保留第一个」默认关闭,打开后只保留第一次出现的标签
- 输出提示词直通下游,重复项可接预览任意查看;判定口径沿用 parser.js
2026-10-05 22:42:56 +08:00
14 changed files with 808 additions and 22 deletions
+18
View File
@@ -1,5 +1,23 @@
# 更新记录
## v2.0.0(2026-10-10)
### 新增「重复检查」节点
- 独立节点,检测一段提示词里的重复标签,把重复数量报出来;无重复时节点保持默认配色,有重复时转成淡橙色提示。
- 开关「只保留第一个」默认关闭,打开后只保留每个标签第一次出现的写法。
- 提示词直通下游,重复项另外输出,接预览任意可看。判定口径与双语节点完全一致(大小写、空格、下划线、权重、强调括号都视为同一个标签,`<lora:…>` 与 `BREAK`/`AND` 不参与)。
### 重复标签可以设「唯一」,并新增重复栏
- 「重复标签:X」后面给每个标签加了独立的「唯一」开关:打开后只有第一次出现会进实际输出,其余在界面上划线保留、**原文一个字不动**,支持 `Ctrl+Z` 撤回。
- 英文标签区、「已隐藏」那一栏下面新增**重复栏**:一个标签一个胶囊,单击依次跳到每一次出现并联动选中(当前那处标红),已经定位时双击就删掉当前这一处。
- 重复分组抽成 `parser.js` 的 `duplicateGroups()`,问题提示行、重复栏、重复检查节点共用同一份口径,不会再各算一套。
### 其它
- 标签卡上的隐藏(眼睛)按钮不再跟着英文行走,固定在整张卡片的右上角内侧;鼠标移到卡片任意位置(含中文行)都会显示它。
## v1.2.18(2026-10-05)
- **双击标签卡打开的编辑框支持一次改多个标签**:多选后双击,编辑范围是「最前一个选中标签的开头」到「最后一个选中标签的结尾」,中间没选中的标签也一起带进来;只在选中多于一个时给标签卡和明细表行加描边。
+1 -1
View File
@@ -1,4 +1,4 @@
# ComfyUI 双语提示词管理器 v1.2.18
# ComfyUI 双语提示词管理器 v2.0.0
> ComfyUI 双语提示词节点:把英文提示词拆成可点击的标签,边写边看中文解释、调顺序、试效果。
+3 -1
View File
@@ -1,13 +1,15 @@
from .nodes import BilingualPromptInspector
from .nodes import BilingualPromptInspector, DuplicateChecker
from . import server # noqa: F401 - importing registers local API routes
NODE_CLASS_MAPPINGS = {
"BilingualPromptInspector": BilingualPromptInspector,
"DuplicateChecker": DuplicateChecker,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"BilingualPromptInspector": "Prompt Translator & Manager",
"DuplicateChecker": "重复检查",
}
WEB_DIRECTORY = "./js"
+173
View File
@@ -0,0 +1,173 @@
"""重复检查:切分、归一、去重都在这里,节点执行与前端实时提示共用同一份口径。
规则与前端 js/parser.js 保持一致(splitPrompt / analyzeSegment / normalizeKey),
改任一侧都要同步另一侧,否则节点上显示的重复数会和实际去掉的数量对不上。
"""
import re
# 段与段之间的间隙里只取第一个分隔符本身:段后的空白归下一段自己带(raw 里有前导空白),
# 两边都管就会在删掉中间段时丢掉空格(「standing, sitting」变成「standing,sitting」)。
_GAP_SEPARATOR = re.compile(r"^[ \t]*([,\r\n])")
# 间隙里只有分隔符和空白(比如两个标签之间隔了一个空行)时原样保留,
# 否则说明中间夹着被删掉的段,那点空白是被删段的前导,不能再计入。
_GAP_BLANK = re.compile(r"^[ \t]*[,\r\n][ \t\r\n]*$")
_SPECIAL_SEGMENT = re.compile(r"^<[^>]+>$")
_WEIGHTED = re.compile(r"^\((.+):\s*(-?(?:\d+(?:\.\d*)?|\.\d+))\)$", re.DOTALL)
_WHITESPACE = re.compile(r"\s+")
def strip_emphasis(value):
current = str(value or "").strip()
while len(current) >= 2 and _is_pair(current[0], current[-1]):
current = current[1:-1].strip()
return current
def _is_pair(open_char, close_char):
return (
(open_char == "(" and close_char == ")")
or (open_char == "[" and close_char == "]")
or (open_char == "{" and close_char == "}")
)
def normalize_key(value):
return _WHITESPACE.sub(" ", str(value or "").strip().lower().replace("_", " "))
def split_prompt(text):
"""按顶层逗号与换行切分;括号里、尖括号里、转义字符后面的分隔符都不算切分点。"""
value = str(text or "")
segments = []
start = 0
round_depth = 0
square_depth = 0
curly_depth = 0
angle_depth = 0
escaped = False
for index, char in enumerate(value):
if escaped:
escaped = False
continue
if char == "\\":
escaped = True
continue
if char == "<":
angle_depth += 1
elif char == ">" and angle_depth > 0:
angle_depth -= 1
elif angle_depth == 0:
if char == "(":
round_depth += 1
elif char == ")" and round_depth > 0:
round_depth -= 1
elif char == "[":
square_depth += 1
elif char == "]" and square_depth > 0:
square_depth -= 1
elif char == "{":
curly_depth += 1
elif char == "}" and curly_depth > 0:
curly_depth -= 1
if (
round_depth == 0
and square_depth == 0
and curly_depth == 0
and angle_depth == 0
and char in (",", "\n", "\r")
):
raw = value[start:index]
if raw.strip():
segments.append({"raw": raw, "start": start, "end": index})
start = index + 1
tail = value[start:]
if tail.strip():
segments.append({"raw": tail, "start": start, "end": len(value)})
return segments
def analyze_segment(raw):
"""剥掉权重与强调括号,得到用来判断重复的词条;纯特殊语法(<lora:...>)原样保留。"""
display = str(raw or "").strip()
if not display:
return None
if _SPECIAL_SEGMENT.match(display):
return {"term": display, "syntax": "special"}
term = display
weighted = _WEIGHTED.match(display)
if weighted:
term = weighted.group(1).strip()
term = strip_emphasis(term)
return {
"term": term,
"syntax": "operator" if term.upper() in ("BREAK", "AND") else "tag",
}
def find_duplicates(text):
"""返回重复报告:多余出现几处、重复词条、以及重复段的下标(保留第一次出现)。"""
value = str(text or "")
segments = split_prompt(value)
seen = {}
order = []
drop = []
for index, segment in enumerate(segments):
info = analyze_segment(segment["raw"])
if not info or info["syntax"] != "tag":
continue
key = normalize_key(info["term"])
if not key:
continue
if key in seen:
seen[key]["count"] += 1
drop.append(index)
else:
seen[key] = {"term": info["term"], "count": 1}
order.append(key)
return {
"extra": len(drop),
"terms": [seen[key] for key in order if seen[key]["count"] > 1],
"drop": drop,
"segments": segments,
}
def remove_segments(text, segments, drop):
"""删掉指定段并把剩下的段按原文的连接符接回去,不重写整段提示词的格式。"""
value = str(text or "")
doomed = set(drop or ())
keep = [index for index in range(len(segments)) if index not in doomed]
if not keep:
return ""
parts = []
previous_end = None
for index in keep:
segment = segments[index]
if previous_end is None:
parts.append(segment["raw"].strip())
else:
gap = value[previous_end:segment["start"]]
if _GAP_BLANK.match(gap):
# 只去掉首尾的空格,换行要留着(段落之间的空行是有意义的)
parts.append(gap.strip(" \t") or ",")
else:
matched = _GAP_SEPARATOR.match(gap)
parts.append(matched.group(1) if matched else ",")
parts.append(segment["raw"])
previous_end = segment["end"]
return "".join(parts)
def dedupe_prompt(text):
"""只保留第一次出现的标签,返回去重后的文本。"""
value = str(text or "")
report = find_duplicates(value)
if not report["drop"]:
return value
return remove_segments(value, report["segments"], report["drop"])
def format_duplicates(report):
"""重复项预览:一行一个「词条 ×次数」,没有重复时给空串。"""
return "\n".join(f"{item['term']} ×{item['count']}" for item in report["terms"])
+218 -8
View File
@@ -3,6 +3,7 @@ import {
analyzePromptSyntax,
buildDictionaryIndex,
detectInputMode,
duplicateGroups,
movePromptToken,
normalizeKey,
parsePrompt,
@@ -56,7 +57,7 @@ import {
} from "./bpi_shared.js";
const NODE_NAME = "BilingualPromptInspector";
const EXTENSION_VERSION = "v1.2.18";
const EXTENSION_VERSION = "v2.0.0";
const PROJECT_URL = "https://github.com/mengshengzhijie/ComfyUI-Bilingual-Prompt-Inspector";
const COLLAPSED_WIDGET_FALLBACK_HEIGHT = 390;
const COLLAPSED_NODE_MIN_HEIGHT = 360;
@@ -151,6 +152,9 @@ function createPanel(node, textWidget) {
setPlaceholder(englishEditor, "Enter the English prompt; after editing, it will be shown as selectable, deletable tags.");
const englishHiddenBar = element("div", "bpi-hidden-bar bpi-hidden");
setTitle(englishHiddenBar, "Hidden tags will not enter the actual output");
// 重复标签栏:紧跟在已隐藏栏下面,每个重复标签一个胶囊
const englishDupBar = element("div", "bpi-dup-bar bpi-hidden");
setTitle(englishDupBar, "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");
// 上游输入条:只在节点接到上游 prompt 连线时出现
const sourceBar = element("div", "bpi-source-bar bpi-hidden");
// 注意别叫 sourceLabel:模块顶层已有同名函数(标签来源文案),会把它遮蔽掉
@@ -176,7 +180,7 @@ function createPanel(node, textWidget) {
}, "bpi-primary");
const cancelButton = button("Discard", () => cancelUpstreamWait());
sourceBar.append(sourceCaption, sourceToggleLabel, sourceSelect, sourceState, resumeButton, cancelButton);
englishSection.append(sourceBar, englishHead, englishTokenView, englishHiddenBar, englishEditor);
englishSection.append(sourceBar, englishHead, englishTokenView, englishHiddenBar, englishDupBar, englishEditor);
const detailsBody = element("div", "bpi-details-body");
const detailsHiddenBar = element("div", "bpi-hidden-bar bpi-hidden");
const toolbar = element("div", "bpi-toolbar");
@@ -277,6 +281,12 @@ function createPanel(node, textWidget) {
filterButtons: new Map(),
// 表格里勾选待入库的机器翻译词条,只存 token.key
batchSelected: new Set(),
// 重复分组(render 里重算)与每个 token 在组里的次序:token.id → { rank, total }
duplicateGroups: [],
duplicateRanks: new Map(),
// 重复栏当前定位到的那一处:{ key, index };-1 表示还没定位过
dupFocus: null,
dupFocusId: null,
searchMatches: [],
searchIndex: -1,
largeCache: new Map(),
@@ -969,6 +979,37 @@ function createPanel(node, textWidget) {
const hiddenIndexOf = (token) => getHiddenTags().findIndex((item) => hiddenEntryMatches(item, token));
const isTokenHidden = (token) => hiddenIndexOf(token) >= 0;
// ---- Duplicate tags ------------------------------------------------------
// 重复标签走两条互不干扰的线路:
// 1. 「唯一」开关(问题行里):按 key 记进 node.properties.bpiUniqueKeys,
// 打开后第 2 次及以后出现的那几次不进实际输出,界面照常显示(划线)。
// 和隐藏标记一样按节点存,跟着工作流一起保存/读取。
// 2. 英文标签区下面的「重复」栏:单击依次跳到每一次出现(标红 + 联动选中),
// 已经定位过再双击就删掉当前这一处。
const getUniqueKeys = () => {
if (!node.properties || typeof node.properties !== "object") node.properties = {};
if (!Array.isArray(node.properties.bpiUniqueKeys)) node.properties.bpiUniqueKeys = [];
return node.properties.bpiUniqueKeys;
};
const setUniqueKeys = (nextList) => {
if (!node.properties || typeof node.properties !== "object") node.properties = {};
node.properties.bpiUniqueKeys = Array.isArray(nextList) ? nextList : [];
};
const isUniqueKey = (key) => Boolean(key) && getUniqueKeys().includes(key);
// 同一个标签的第 2 次及以后出现,被「唯一」开关排除在输出之外
const isTokenSuppressed = (token) => {
const rank = state.duplicateRanks.get(token.id);
return Boolean(rank && rank.rank > 0 && isUniqueKey(token.key));
};
// 重复栏当前标红的那一处所在的 token(跨 render 只记 key + 第几次,够稳)
const dupFocusToken = () => {
if (!state.dupFocus) return null;
const group = state.duplicateGroups.find((item) => item.key === state.dupFocus.key);
return group?.tokens[state.dupFocus.index] ?? null;
};
// ---- 输出管道 ------------------------------------------------------------
// 隐藏的标签留在 text 里(界面上划线显示,照常拖动/改色/改权重),真正输出
// 走两个隐藏 widget:effective_text = 剔除隐藏标签后的文本,use_effective = true。
@@ -990,6 +1031,20 @@ function createPanel(node, textWidget) {
out = step.text;
}
}
// 「唯一」再去重:同一个 key 只留第一次出现的那一次
const unique = getUniqueKeys();
if (unique.length) {
for (;;) {
const tokens = parsePrompt(out, state.index, state.machine, { mode: state.modePreference });
const target = duplicateGroups(tokens)
.filter((group) => unique.includes(group.key))
.flatMap((group) => group.tokens.slice(1))[0];
if (!target) break;
const step = removePromptToken(out, target);
if (!step.changed) break;
out = step.text;
}
}
effectiveWidget.value = out;
if (useEffectiveWidget) useEffectiveWidget.value = true;
};
@@ -1632,6 +1687,7 @@ function createPanel(node, textWidget) {
state.pinned = null;
if (typeof entry.beforeCategoryView === "boolean") state.categoryView = entry.beforeCategoryView;
if (Array.isArray(entry.hiddenBefore)) setHiddenTags(entry.hiddenBefore);
if (Array.isArray(entry.uniqueBefore)) setUniqueKeys(entry.uniqueBefore);
updateText(entry.before, entry.beforeStart);
setStatus(`Undone: “${entry.label}”`, "ok");
return true;
@@ -1645,6 +1701,7 @@ function createPanel(node, textWidget) {
state.pinned = null;
if (typeof entry.afterCategoryView === "boolean") state.categoryView = entry.afterCategoryView;
if (Array.isArray(entry.hiddenAfter)) setHiddenTags(entry.hiddenAfter);
if (Array.isArray(entry.uniqueAfter)) setUniqueKeys(entry.uniqueAfter);
updateText(entry.after, entry.afterCursor);
setStatus(`Redone: “${entry.label}”`, "ok");
return true;
@@ -2107,9 +2164,12 @@ function createPanel(node, textWidget) {
: token.segmentKind === "natural"
? `“${token.term}” ↔ “${token.chinese}” | natural language supports only whole-segment editing; can drag to reorder the whole segment`
: `“${token.term}” ↔ “${token.chinese}” | 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`);
// 隐藏角标挂在整张卡片上(不是英文那一行):定位基准是卡片方框,
// 角标才会落在方框的右上角,而不是跟着英文行的宽度跑到中间去。
let hideCorner = null;
if (canHideTokens()) {
const tokenHidden = isTokenHidden(token);
const hideCorner = element("span", tokenHidden ? "bpi-chip-hide bpi-chip-hidden-mark" : "bpi-chip-hide");
hideCorner = element("span", tokenHidden ? "bpi-chip-hide bpi-chip-hidden-mark" : "bpi-chip-hide");
hideCorner.appendChild(tokenHidden ? buildEyeOffIcon() : buildEyeIcon());
setTitle(hideCorner, tokenHidden
? `Unhide “${token.raw}”: back into the actual output`
@@ -2121,7 +2181,6 @@ function createPanel(node, textWidget) {
if (isTokenHidden(token)) unhideToken(token);
else hideToken(token);
});
chip.appendChild(hideCorner);
}
if (state.pinned === token.id && ["tags", "mixed"].includes(state.modeInfo.mode) && token.segmentKind !== "natural") {
const remove = element("span", "bpi-mirror-delete", "×");
@@ -2137,13 +2196,17 @@ function createPanel(node, textWidget) {
const card = element("span", "bpi-token-card");
// 隐藏的卡:划线 + 灰化褪色,但拖动/双击改权重/改色都照常
if (isTokenHidden(token)) card.classList.add("bpi-card-hidden");
// 「唯一」排除掉的第 2 次及以后出现:和隐藏一样划线褪色,但仍留在这里能操作
if (isTokenSuppressed(token)) card.classList.add("bpi-dup-off");
if (state.dupFocusId === token.id) card.classList.add("bpi-dup-hit");
// 选中反馈给整张卡片,而不是只有英文那一行
if (state.pinned === token.id) card.classList.add("bpi-card-linked");
// 多选的虚线框只在真的多选时出现,单选保持原样不打扰
if (state.selected.size > 1 && state.selected.has(token.id)) card.classList.add("bpi-card-multi");
const cardColor = tokenCardColor(token);
if (cardColor && !card.classList.contains("bpi-card-linked")) card.style.background = cardColor;
if (cardColor && !card.classList.contains("bpi-card-linked") && !card.classList.contains("bpi-dup-hit")) card.style.background = cardColor;
card.append(chip, chinese);
if (hideCorner) card.appendChild(hideCorner);
// 拖拽也挂在整个方块上:抓中文行一样能拖,不必精确抓英文。
// 注意必须放在 card 声明之后:在 const card 之前引用它会触发 TDZ 报错,
// 一报错整个 render 中断,标签区会整个空白。
@@ -2384,6 +2447,126 @@ function createPanel(node, textWidget) {
container.appendChild(restoreAll);
};
// 问题行里的「唯一」开关:打开后只有第一次出现进入实际输出。文本本身不动,
// 所以 undo 条目 before === after,和隐藏/恢复一样只回滚记的那份名单。
const toggleUniqueKey = (key, on, term) => {
const keys = getUniqueKeys();
const after = on ? [...keys.filter((item) => item !== key), key] : keys.filter((item) => item !== key);
pushHistoryWithHidden({
before: String(textWidget.value ?? ""),
after: String(textWidget.value ?? ""),
beforeStart: 0,
beforeEnd: 0,
afterCursor: 0,
label: on ? `keep only the first “${term}”` : `restore duplicates of “${term}”`,
uniqueBefore: keys.slice(),
uniqueAfter: after,
});
setUniqueKeys(after);
render();
setStatus(on
? `“${term}” now unique: only the first occurrence enters the actual output; press Ctrl+Z to undo`
: `“${term}” no longer unique: all occurrences enter the actual output again; press Ctrl+Z to undo`, "ok");
};
const buildUniqueToggle = (key, group) => {
const term = group?.tokens[0]?.term ?? key;
const label = element("label", "bpi-source-toggle bpi-unique-toggle");
const box = element("input");
box.type = "checkbox";
box.checked = isUniqueKey(key);
label.append(box, element("span", "", "Unique"));
setTitle(label, `Keep only the first “${term}” in the actual output; later occurrences stay here with a strikethrough`);
box.addEventListener("click", (event) => event.stopPropagation());
box.addEventListener("change", () => toggleUniqueKey(key, box.checked, term));
return label;
};
// 重复栏里单击:从「没定位」到第 1 处,之后每次往后挪一处,到底就回第 1 处。
// 顺带 activateToken 把提示词那边联动选中,便于直接改权重/拖动。
const focusNextDuplicate = (key) => {
const group = state.duplicateGroups.find((item) => item.key === key);
if (!group) { state.dupFocus = null; return; }
const current = state.dupFocus?.key === key ? state.dupFocus.index : -1;
const next = current < 0 ? 0 : (current + 1) % group.tokens.length;
state.dupFocus = { key, index: next };
const token = group.tokens[next];
activateToken(token);
setStatus(`“${token.term}” occurrence ${next + 1} of ${group.tokens.length}; click again for the next one, double-click to delete it`, "ok");
};
// 双击删掉当前定位到的那一处;没先单击定位过就不动,避免手滑删内容
const deleteFocusedDuplicate = (key) => {
if (state.dupFocus?.key !== key) {
setStatus("Click a duplicate chip once to step to it before double-clicking to delete", "error");
return;
}
const group = state.duplicateGroups.find((item) => item.key === key);
const token = group?.tokens[state.dupFocus.index];
if (!token) return;
state.dupFocus = null;
deleteTokenOccurrence(token);
};
// 英文标签区下面的「重复」栏:单击胶囊会顺带选中提示词里那一个标签,所以它的
// 空白处也得像提示词那样「点一下就取消」,否则选中了就没法干净地退出来。
const clearDuplicateFocus = () => {
state.dupFocus = null;
state.dupFocusId = null;
};
const renderDupBar = (container) => {
if (!container) return;
const groups = state.duplicateGroups;
container.replaceChildren();
if (!groups.length) {
container.classList.add("bpi-hidden");
return;
}
container.classList.remove("bpi-hidden");
container.appendChild(element("span", "bpi-dup-label", `Duplicates ${groups.length}`));
for (const group of groups) {
const armed = state.dupFocus?.key === group.key;
const term = group.tokens[0].term;
const unique = isUniqueKey(group.key);
const chip = element("span", `bpi-dup-chip${armed ? " bpi-dup-armed" : ""}${unique ? " bpi-dup-chip-unique" : ""}`);
chip.appendChild(element("span", "bpi-dup-en", term));
// 定位到某一处之后,数字位置直接换成「第几处 / 共几处」,不再显示 ×N
if (armed) chip.appendChild(element("span", "bpi-dup-index", `${state.dupFocus.index + 1}/${group.tokens.length}`));
else chip.appendChild(element("span", "bpi-dup-count", `×${group.tokens.length}`));
if (unique) chip.appendChild(element("span", "bpi-dup-flag", "Unique"));
setTitle(chip, armed
? `“${term}” appears ${group.tokens.length} times; now at ${state.dupFocus.index + 1}, click for the next one, double-click to delete it`
: unique
? `“${term}” appears ${group.tokens.length} times; “Unique” is on, only the first reaches the actual output`
: `“${term}” appears ${group.tokens.length} times; click to jump to the first one`);
// 双击会先送两个 click:把第二个吃掉,不然第 2 下会把定位挪走再删。
let clickTimer = null;
chip.addEventListener("click", (event) => {
event.stopPropagation();
if (clickTimer) { clearTimeout(clickTimer); clickTimer = null; return; }
clickTimer = setTimeout(() => { clickTimer = null; focusNextDuplicate(group.key); }, 220);
});
chip.addEventListener("dblclick", (event) => {
event.stopPropagation();
if (clickTimer) { clearTimeout(clickTimer); clickTimer = null; }
deleteFocusedDuplicate(group.key);
});
container.appendChild(chip);
}
// 末尾撑一段空白:胶囊排满时也有地方点得到,不然就取消不掉选中了
container.appendChild(element("span", "bpi-dup-space"));
};
// 点重复栏的空白处 = 取消选中(胶囊自己会吃掉 click,所以只有空白处能走到这)
englishDupBar.addEventListener("click", (event) => {
if (event.target !== englishDupBar && !event.target.classList?.contains("bpi-dup-space")) return;
if (state.pinned === null && !state.dupFocus) return;
state.pinned = null;
clearDuplicateFocus();
render();
});
const upstreamSettings = () => {
const stored = node.properties?.bpiUpstream ?? {};
return {
@@ -2526,6 +2709,18 @@ function createPanel(node, textWidget) {
};
});
queueLargeLookup(state.tokens);
// 重复分组要在渲染两个视图之前算好,标签区和明细表都吃这份数据
state.duplicateGroups = duplicateGroups(state.tokens);
state.duplicateRanks = new Map();
for (const group of state.duplicateGroups) {
group.tokens.forEach((token, index) => state.duplicateRanks.set(token.id, { rank: index, total: group.tokens.length }));
}
// 定位过的那一处被删了 / 文本换了不够那么多处了,就把定位作废
if (state.dupFocus) {
const group = state.duplicateGroups.find((item) => item.key === state.dupFocus.key);
if (!group || state.dupFocus.index >= group.tokens.length) state.dupFocus = null;
}
state.dupFocusId = dupFocusToken()?.id ?? null;
state.issues = analyzePromptSyntax(text, state.tokens, state.modeInfo);
if (state.pinned !== null && !state.tokens.some((token) => token.id === state.pinned)) state.pinned = null;
// 多选跟着 pinned 走:没有当前标签就整组清空(改文本、撤销等路径都是这么清的),
@@ -2563,9 +2758,16 @@ function createPanel(node, textWidget) {
}
}
issuesPanel.replaceChildren();
for (const item of state.issues.slice(0, 12)) {
// 重复标签要带「唯一」开关,不能被 12 条的显示上限挤掉,所以排到最前面
const duplicateIssues = state.issues.filter((item) => item.code === "duplicate");
const otherIssues = state.issues.filter((item) => item.code !== "duplicate");
for (const item of [...duplicateIssues, ...otherIssues].slice(0, Math.max(12, duplicateIssues.length))) {
const issueRow = element("div", "bpi-issue", item.message);
issueRow.dataset.severity = item.severity;
const duplicateKey = item.code === "duplicate" ? item.tokenKeys?.[0] : null;
if (duplicateKey) {
issueRow.appendChild(buildUniqueToggle(duplicateKey, state.duplicateGroups.find((group) => group.key === duplicateKey)));
}
issuesPanel.appendChild(issueRow);
}
issuesPanel.classList.toggle("bpi-visible", state.issues.length > 0);
@@ -2579,7 +2781,10 @@ function createPanel(node, textWidget) {
const row = element("div", `bpi-row bpi-${token.status}`);
if (token.suspectedNatural) row.classList.add("bpi-suspected-natural");
const tokenHidden = isTokenHidden(token);
const tokenSuppressed = isTokenSuppressed(token);
if (tokenHidden) row.classList.add("bpi-row-hidden");
if (tokenSuppressed) row.classList.add("bpi-dup-off");
if (state.dupFocusId === token.id) row.classList.add("bpi-dup-hit");
const rowSeverity = errorsByKey.get(token.key);
if (rowSeverity === "error") row.classList.add("bpi-has-error");
else if (rowSeverity === "warning") row.classList.add("bpi-has-warning");
@@ -2595,8 +2800,10 @@ function createPanel(node, textWidget) {
englishCell.appendChild(buildRowHideButton(token));
}
const chineseCell = element("div", "bpi-cell bpi-zh");
const englishText = element("span", tokenHidden ? "bpi-hidden-term" : "", token.raw);
setTitle(englishText, `Query: “${token.term}”${token.weight === null ? "" : ` | weight: ${token.weight}`}`);
const englishText = element("span", tokenHidden || tokenSuppressed ? "bpi-hidden-term" : "", token.raw);
setTitle(englishText, tokenSuppressed
? `Query: “${token.term}” | “Unique” is on: only the first occurrence reaches the actual output`
: `Query: “${token.term}”${token.weight === null ? "" : ` | weight: ${token.weight}`}`);
englishText.addEventListener("dblclick", (event) => {
event.stopPropagation();
setEnglishEditing(true);
@@ -2782,6 +2989,7 @@ function createPanel(node, textWidget) {
for (const [filter, control] of state.filterButtons) control.classList.toggle("bpi-filter-active", filter === state.tableFilter);
renderHiddenBar(detailsHiddenBar);
renderHiddenBar(englishHiddenBar);
renderDupBar(englishDupBar);
};
const scheduleRender = (immediate = false) => {
@@ -3149,6 +3357,8 @@ function createPanel(node, textWidget) {
// 分行之后空白处的点击目标是行容器,也算点在空白处
if (event.target !== englishTokenView && !event.target.classList?.contains("bpi-token-line")) return;
state.pinned = null;
// 重复栏里定位到的那一处属于同一个选中语义,一起退掉,不然红标记会一直挂着
clearDuplicateFocus();
render();
});
const bindMirrorAction = (control, action) => control.addEventListener("click", (event) => {
+28 -8
View File
@@ -84,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) || "{}"));
@@ -1203,12 +1214,12 @@ function injectBpiStyles() {
const style = document.createElement("style");
style.id = "bpi-styles";
style.textContent = `
:root{--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-editor-bg:#fff}
.dark-theme{--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-editor-bg:#111923}
[data-bpi-theme="dark"]{--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-editor-bg:#111923}
[data-bpi-theme="light"]{--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-editor-bg:#fff}
:root{--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}
.dark-theme{--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}
[data-bpi-theme="dark"]{--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}
[data-bpi-theme="light"]{--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-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: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: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-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}
@@ -1226,7 +1237,7 @@ function injectBpiStyles() {
.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: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:var(--bpi-surface);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-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}
@@ -1248,9 +1259,9 @@ function injectBpiStyles() {
.bpi-hidden-term{text-decoration:line-through;color:var(--bpi-text-faint)}
.bpi-hide-btn-off{color:var(--bpi-badge-warn)}
.bpi-eye-off{color:var(--bpi-badge-warn)}
.bpi-chip-hide{position:absolute;top:-5px;right:-5px;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-english-token:hover .bpi-chip-hide{display:inline-flex}.bpi-chip-hide:hover{background:var(--bpi-primary-bg);border-color:var(--bpi-primary-border);color:#fff}
.bpi-chip-hide{position:absolute;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}
@@ -1386,6 +1397,14 @@ function injectBpiStyles() {
.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);
}
@@ -1410,6 +1429,7 @@ export {
applyLanguageToDom,
buildTagFields,
button,
checkDuplicateTags,
createSavedPrompt,
deleteCommunityPack,
deletePersonalTag,
+185
View File
@@ -0,0 +1,185 @@
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 = [
".bpi-dup-pill{display:inline-flex;align-items:center;gap:6px;padding:3px 10px;border-radius:999px;font-size:12px;line-height:1.5;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 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);
this._duplicateCheck = { pill, dot, textNode, count: 0, connected: false };
try {
this.addDOMWidget("bpi_duplicate_status", "bpi-duplicate-status", pill, {
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);
};
},
});
+23
View File
@@ -578,6 +578,8 @@ const ZH = {
"Added {}": "新增 {}",
"Conflicts {}": "冲突 {}",
"Duplicates {}": "重复 {}",
"No duplicates": "无重复",
"Waiting for prompt": "等待提示词",
"Invalid {}": "无效 {}",
"Import complete: added {}, overwrote {}, skipped {} | auto-backed up": "导入完成:新增 {}、覆盖 {}、跳过 {}|已自动备份",
"Import complete: {} added": "导入完成:新增 {} 条",
@@ -643,6 +645,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": "检测到连续逗号或空标签",
+19 -2
View File
@@ -720,6 +720,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);
@@ -739,8 +756,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());
+41
View File
@@ -1,3 +1,4 @@
from .duplicate_check import dedupe_prompt, find_duplicates, format_duplicates
from .server import announce_upstream, current_prompt_id, upstream_gate
@@ -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
View File
@@ -1,6 +1,6 @@
{
"name": "comfyui-bilingual-prompt-inspector",
"version": "1.2.18",
"version": "2.0.0",
"description": "ComfyUI bilingual prompt inspection, translation, Anima sorting, and on-demand Danbooru dictionary extension.",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "prompt-translator-manager"
version = "1.2.18"
version = "2.0.0"
# 与 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" }
+19
View File
@@ -26,6 +26,7 @@ from .assistant_store import (
translation_direction,
)
from .dictionary_store import DictionaryStore, normalize_key
from .duplicate_check import find_duplicates
from .saved_prompt_store import (
MAX_IMAGE_BYTES,
SavedPromptStore,
@@ -854,3 +855,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"},
)
+78
View File
@@ -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");