From a7cd07155a2baf6ef2288e3d82edf74a264754db Mon Sep 17 00:00:00 2001 From: AI_Architect Date: Sun, 27 Sep 2026 21:55:43 +0800 Subject: [PATCH] =?UTF-8?q?Qwen2.1=E6=8F=90=E7=A4=BA=E8=AF=8D=E9=A2=84?= =?UTF-8?q?=E8=AE=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- __init__.py | 20 + _add_io_mode_locale.py | 36 + _add_safe_print.py | 61 + _add_skill_mode_locale.py | 38 + _dump_clause.py | 22 + _dump_clause2.py | 25 + _gen_pro_panel.py | 8 +- _gen_qwen_panel.py | 121 + _scan_spaces.py | 33 + _xbr_pro_block.js | 217 +- _xbr_qwen_block.js | 771 ++++ js/xb_image_prompt_preset.js | 282 +- js/xb_image_prompt_preset_pro.js | 505 ++- js/xb_qwen_prompt_preset.js | 4039 +++++++++++++++++++ locales/en/nodeDefs.json | 41 + locales/zh/nodeDefs.json | 41 + nodes_image_prompt_preset.py | 410 +- nodes_image_prompt_preset_pro.py | 109 +- nodes_qwen_prompt_preset.py | 571 +++ support_llama/skills/system_prompt_edit.txt | 205 + support_llama/skills/system_prompt_t2i.txt | 192 + xb_skills.py | 309 ++ 22 files changed, 7851 insertions(+), 205 deletions(-) create mode 100644 _add_io_mode_locale.py create mode 100644 _add_safe_print.py create mode 100644 _add_skill_mode_locale.py create mode 100644 _dump_clause.py create mode 100644 _dump_clause2.py create mode 100644 _gen_qwen_panel.py create mode 100644 _scan_spaces.py create mode 100644 _xbr_qwen_block.js create mode 100644 js/xb_qwen_prompt_preset.js create mode 100644 nodes_qwen_prompt_preset.py create mode 100644 support_llama/skills/system_prompt_edit.txt create mode 100644 support_llama/skills/system_prompt_t2i.txt create mode 100644 xb_skills.py diff --git a/__init__.py b/__init__.py index fcdef2f..e22e00a 100644 --- a/__init__.py +++ b/__init__.py @@ -152,6 +152,14 @@ try: print_warning(f"[XB-BOX] 生图提示词预设Pro 未加载:{e}") _PRO_AVAILABLE = False + # ── 🖼️ Qwen2.1提示词预设(生图提示词预设 + 官方 Load CLIP + 官方 Generate Text)── + try: + from .nodes_qwen_prompt_preset import XB_QwenPromptPreset + _QWEN_AVAILABLE = True + except Exception as e: + print_warning(f"[XB-BOX] Qwen2.1提示词预设 未加载:{e}") + _QWEN_AVAILABLE = False + # ── CosyVoice3 音频节点 ── from .cosyvoice3.nodes.model_loader import XB_CosyVoice3_ModelLoader from .cosyvoice3.nodes.zero_shot import XB_CosyVoice3_ZeroShot @@ -344,6 +352,12 @@ try: "XB_ImagePromptPresetPro": XB_ImagePromptPresetPro, }) + # ── Qwen2.1提示词预设 ── + if _QWEN_AVAILABLE: + NODE_CLASS_MAPPINGS.update({ + "XB_QwenPromptPreset": XB_QwenPromptPreset, + }) + NODE_DISPLAY_NAME_MAPPINGS = { "XB_VRAM_Calculator": "XB-BOX - VRAM Calculator", "XB_ChunkVisualization": "XB-BOX - Chunk Visualization", @@ -503,6 +517,12 @@ try: "XB_ImagePromptPresetPro": "XB-BOX - 🖼️ 生图提示词预设Pro", }) + # ── Qwen2.1提示词预设 显示名 ── + if _QWEN_AVAILABLE: + NODE_DISPLAY_NAME_MAPPINGS.update({ + "XB_QwenPromptPreset": "XB-BOX - 🖼️ Qwen2.1提示词预设", + }) + print_success("\n" + "="*50) print_success("🚀 [XB-BOX] XB_ToolBox Core Modules Loaded Successfully!") print_success("="*50 + "\n") diff --git a/_add_io_mode_locale.py b/_add_io_mode_locale.py new file mode 100644 index 0000000..0491122 --- /dev/null +++ b/_add_io_mode_locale.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +"""给 locales/{zh,en}/nodeDefs.json 的两个节点补 io_mode 词条(行插入,不重排整文件)""" +import io +import json +import os + +ROOT = os.path.dirname(os.path.abspath(__file__)) +PLAN = { + "zh": ('"io_mode": { "name": "模版" },', "SKILL 模式"), + "en": ('"io_mode": { "name": "Mode" },', "SKILL Mode"), +} + +for lang, (add, anchor) in PLAN.items(): + path = os.path.join(ROOT, "locales", lang, "nodeDefs.json") + with io.open(path, encoding="utf-8") as f: + raw = f.read() + json.loads(raw) + if '"io_mode"' in raw: + print(lang, "已存在,跳过") + continue + out, hit = [], 0 + for line in raw.split("\n"): + out.append(line) + if '"skill_mode"' in line and anchor in line: + indent = line[: len(line) - len(line.lstrip())] + out.append(indent + add) + hit += 1 + new = "\n".join(out) + obj = json.loads(new) + if hit != 2: + raise SystemExit("%s: 插入点数量异常 %d" % (lang, hit)) + for node in ("XB_ImagePromptPresetPro", "XB_QwenPromptPreset"): + assert obj[node]["inputs"]["io_mode"]["name"], (lang, node) + with io.open(path, "w", encoding="utf-8", newline="") as f: + f.write(new) + print(lang, "插入", hit, "处,JSON 合法") diff --git a/_add_safe_print.py b/_add_safe_print.py new file mode 100644 index 0000000..ba544db --- /dev/null +++ b/_add_safe_print.py @@ -0,0 +1,61 @@ +# -*- coding: utf-8 -*- +"""把 Pro / Qwen 节点里带 emoji 的日志改成 safe_print(非 UTF-8 控制台也不会崩)+ 补齐 import""" +import io +import os +import re + +ROOT = os.path.dirname(os.path.abspath(__file__)) + +TARGETS = { + "nodes_image_prompt_preset_pro.py": [ + ("print(f\"[XB-生图预设Pro] 🧠 ", "safe_print(f\"[XB-生图预设Pro] 🧠 "), + ("print(\"[XB-生图预设Pro] ⚠️ 输出含对比式修改说明", "safe_print(\"[XB-生图预设Pro] ⚠️ 输出含对比式修改说明"), + ("print(\"[XB-生图预设Pro] ✅ 重试后已得到直接的最终画面描述\")", + "safe_print(\"[XB-生图预设Pro] ✅ 重试后已得到直接的最终画面描述\")"), + ("print(\"[XB-生图预设Pro] ⚠️ 重试仍不合格", "safe_print(\"[XB-生图预设Pro] ⚠️ 重试仍不合格"), + ], + "nodes_qwen_prompt_preset.py": [ + ("print(f\"[XB-Qwen提示词预设] ⚠️", "safe_print(f\"[XB-Qwen提示词预设] ⚠️"), + ], +} + +for fname, pairs in TARGETS.items(): + path = os.path.join(ROOT, fname) + with io.open(path, encoding="utf-8") as f: + s = f.read() + n = 0 + for a, b in pairs: + if a in s: + s = s.replace(a, b) + n += 1 + # 确认 safe_print 已从基础模块导入 + if "safe_print" in s and "safe_print," not in s and "safe_print\n" not in s: + m = re.search(r"from \.nodes_image_prompt_preset import \(([^)]*)\)", s) + if m: + body = m.group(1) + if "safe_print" not in body: + s = s[:m.end(1)] + "\n safe_print," + body + s[m.end(1):] + n += 1 + else: + m2 = re.search(r"from \.nodes_image_prompt_preset import ([^\n]*)\n", s) + if m2 and "safe_print" not in m2.group(1): + s = s[:m2.end(1)] + ", safe_print" + s[m2.end(1):] + n += 1 + with io.open(path, "w", encoding="utf-8", newline="") as f: + f.write(s) + print(fname, "changed", n, "| safe_print imported:", bool(re.search(r"safe_print", s.split("class ")[0]))) + +# 断言:不再有裸 print 带 GBK 不可编码字符 +for fname in TARGETS: + with io.open(os.path.join(ROOT, fname), encoding="utf-8") as f: + s = f.read() + bad = [] + for i, line in enumerate(s.split("\n"), 1): + if re.search(r"(? %r" % (k, v)) + for ch_i, ch in enumerate(v): + if ch == " " and 0 < ch_i < len(v) - 1: + a, b = v[ch_i - 1], v[ch_i + 1] + if "\u4e00" <= a <= "\u9fff" and "\u4e00" <= b <= "\u9fff": + print(" !! 汉字间空格 @%d: %r" % (ch_i, v[max(0, ch_i - 12):ch_i + 12])) diff --git a/_dump_clause2.py b/_dump_clause2.py new file mode 100644 index 0000000..10ac977 --- /dev/null +++ b/_dump_clause2.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +"""纯 ASCII 输出:列出条款里每个半角空格的前后字符码点(避免控制台乱码误判)""" +import io +import sys + +sys.stdout.reconfigure(encoding="utf-8", errors="replace") +ROOT = r"d:\AI_JZL\XB_ToolBox" +s = io.open(ROOT + r"\nodes_image_prompt_preset.py", encoding="utf-8").read() +i = s.index("IO_I2I_COMMON = {") +j = s.index("def mode_io_clause(") +ns = {} +exec("LANG_ZH = 'ZH'\nOUTPUT_LANGS = ['ZH', 'EN']\n" + s[i:j], ns) +CJK = lambda c: "\u4e00" <= c <= "\u9fff" +for name in ("IO_I2I_COMMON", "T2I_FALLBACK_TAIL", "IO_T2I_FALLBACK"): + for k, v in ns[name].items(): + ks = k.encode("unicode_escape").decode("ascii")[:40] + for idx, ch in enumerate(v): + if ch == " ": + p = v[idx - 1] if idx else "" + n = v[idx + 1] if idx + 1 < len(v) else "" + flag = "<<< CJK 间空格" if (p and n and CJK(p) and CJK(n)) else "" + print("%-14s %s sp@%3d prev=%s next=%s %s" % ( + name, ks[:12], idx, hex(ord(p)) if p else "-", hex(ord(n)) if n else "-", flag)) + print(" len=%d U+3000=%d double-space=%d" % ( + len(v), v.count("\u3000"), v.count(" "))) diff --git a/_gen_pro_panel.py b/_gen_pro_panel.py index ce9004c..fe44ecf 100644 --- a/_gen_pro_panel.py +++ b/_gen_pro_panel.py @@ -52,11 +52,15 @@ patches = [ hideWidget(findWidget(node, "output_lang")); // 语言只留:LLM设置 里的「输出语言」 hideWidget(findWidget(node, "latent_kind")); // → 「✨ 增强预设」弹窗 hideWidget(findWidget(node, "preset_mode")); // → 「✨ 增强预设」弹窗 - hideWidget(findWidget(node, "three_view_text")); // 设定词 → 只在「✨ 增强预设」弹窗里编辑(用户要求:不在节点表面显示)"""), + hideWidget(findWidget(node, "three_view_text")); // 设定词 → 只在「✨ 增强预设」弹窗里编辑(用户要求:不在节点表面显示) + hideWidget(findWidget(node, "skill_name")); // SKILL 选择 → 只在「✨ 预设参数」弹窗里选 + hideWidget(findWidget(node, "skill_mode")); // SKILL 三态 → 只在「✨ 预设参数」弹窗里选 + hideWidget(findWidget(node, "io_mode")); // 模版(文生图/图生图)→ 只在「✨ 预设参数」弹窗里选"""), # 4b. 幽灵端口清理(同上名单) (""" for (const nm of ["internal_prompt", "manager_settings"]) {""", """ for (const nm of ["internal_prompt", "manager_settings", "backend", "preset", "task_preset", - "open_api_settings", "output_lang", "latent_kind", "preset_mode", "three_view_text"]) {"""), + "open_api_settings", "output_lang", "latent_kind", "preset_mode", "three_view_text", + "skill_name", "skill_mode", "io_mode"]) {"""), # 4c. 设定词在 Pro 节点表面始终隐藏(基础面板的预设句框显/隐逻辑在 Pro 里改成“只隐不显”) (""" const applyPresetTextVisibility = () => { try { diff --git a/_gen_qwen_panel.py b/_gen_qwen_panel.py new file mode 100644 index 0000000..6320edd --- /dev/null +++ b/_gen_qwen_panel.py @@ -0,0 +1,121 @@ +""" +由 js/xb_image_prompt_preset.js 生成 js/xb_qwen_prompt_preset.js +(把「Qwen2.1提示词预设」的功能块 _xbr_qwen_block.js 注入进去) + +用法:python _gen_qwen_panel.py (基础面板更新后重跑一次即可) +""" +import io +import os +import sys + +ROOT = os.path.dirname(os.path.abspath(__file__)) +SRC = os.path.join(ROOT, "js", "xb_image_prompt_preset.js") +BLOCK = os.path.join(ROOT, "_xbr_qwen_block.js") +DST = os.path.join(ROOT, "js", "xb_qwen_prompt_preset.js") + +src = io.open(SRC, encoding="utf-8").read() +block = io.open(BLOCK, encoding="utf-8").read() + +patches = [ + # 1. 节点类型 + ('const NODE_TYPE = "XB_ImagePromptPreset";', + 'const NODE_TYPE = "XB_QwenPromptPreset";'), + # 2. 默认配置:加入本节点自己的配置段 + (""" preset_texts: {}, // 用户改过的预设句(设定词):{"模式|语言": "文本"}(换模式/换语言不丢) + };""", + """ preset_texts: {}, // 用户改过的预设句(设定词):{"模式|语言": "文本"}(换模式/换语言不丢) + qwen: xbrQwenDefaults(), // Qwen2.1提示词预设:生成后控制 + 追加设定 + };"""), + # 3. 解析:保留本节点配置段(元素面板保存时不会被丢掉) + (""" cfg.elements.rel = rel; + return cfg; +}""", + """ cfg.elements.rel = rel; + cfg.qwen = xbrParseQwen(data.qwen); // Qwen2.1提示词预设:生成后控制 + 追加设定 + return cfg; +}"""), + # 4. 隐藏内部字段(生图预设三项 + 官方 Load CLIP 三项 + 官方 Generate Text 全量参数) + (""" const hideInternal = () => { + hideWidget(findWidget(node, "internal_prompt")); + hideWidget(findWidget(node, "manager_settings"));""", + """ const hideInternal = () => { + hideWidget(findWidget(node, "internal_prompt")); + hideWidget(findWidget(node, "manager_settings")); + // Qwen2.1提示词预设:这些参数只在弹窗里配置(节点表面不暴露) + hideWidget(findWidget(node, "output_lang")); // → ✨ 增强预设 弹窗 + hideWidget(findWidget(node, "latent_kind")); // → ✨ 增强预设 弹窗 + hideWidget(findWidget(node, "preset_mode")); // → ✨ 增强预设 弹窗 + hideWidget(findWidget(node, "three_view_text")); // 设定词 → 只在弹窗里编辑 + for (const nm of XBR_QWEN_WIDGETS) hideWidget(findWidget(node, nm));"""), + # 4b. 幽灵端口清理(同上名单) + (""" for (const nm of ["internal_prompt", "manager_settings"]) {""", + """ for (const nm of ["internal_prompt", "manager_settings", "output_lang", "latent_kind", + "preset_mode", "three_view_text"].concat(XBR_QWEN_WIDGETS)) {"""), + # 4c. 设定词在节点表面始终隐藏(基础面板的预设句框显/隐逻辑改成“只隐不显”) + (""" const applyPresetTextVisibility = () => { + try { + if (PRESET_TEXT_DEFAULT[surfaceOf().mode]) { showWidget(w3v); stylePresetTextBox(); } else hideWidget(w3v); + } catch (_) {}""", + """ const applyPresetTextVisibility = () => { + try { + // 设定词只在「✨ 增强预设」弹窗里编辑 → 节点表面始终隐藏 + hideWidget(w3v); + } catch (_) {}"""), + # 6. 按钮区:3 个按钮放**第一排**(8 个元素分类按钮之上) + (""" container.appendChild(btnRow);""", + """ // 第 1 行按钮:🤖 LLM设置 | ✨ 增强预设 | 📖 使用说明 + container.appendChild(xbrBuildButtonRow(node)); + // 第 2 行:8 个元素分类按钮 + container.appendChild(btnRow);"""), + # 7. 提示词框块:标题行(复制按钮)+ 参数设定显示(含端口锁定 / 执行回写) + (""" const promptLab = el("div", "font-size:12px;color:#bbb;flex:0 0 auto;", "📝 节点提示词(与面板预览框双向同步)"); + const promptBox = el("textarea", BOX_CSS + "width:100%;flex:1 1 auto;min-height:300px;resize:none;line-height:1.6;font-size:12px;overflow-y:auto;"); + promptBox.spellcheck = false; + promptBox.placeholder = "点上方 8 个按钮开面板、点选项行加入元素,或直接在这里写提示词…"; + container.append(promptLab, promptBox);""", + """ const promptBox = el("textarea", BOX_CSS + "width:100%;flex:1 1 auto;min-height:300px;resize:none;line-height:1.6;font-size:12px;overflow-y:auto;"); + promptBox.spellcheck = false; + promptBox.placeholder = "点上方按钮开面板、点选项行加入元素,或直接在这里写提示词…\\n执行节点时:这段文字会作为【正文】交给文本编码器生成完整提示词\\n「📝 提示词」端口接线后本框锁定"; + // 标题行(左标签 + 右「📋 复制」)+ 参数设定显示(同时挂上端口锁定与执行回写) + const xbrHeadAndInfo = xbrBuildPromptHeader(node, promptBox); + container.append(xbrHeadAndInfo[0], promptBox, xbrHeadAndInfo[1]);"""), + # 8. 自动化定位锚点 + 清理已废弃的输入槽(历史工作流里会残留空端口) + (""" node.__ippSyncPromptBox = syncPromptBoxFromWidget;""", + """ node._xbEl = container; // 调试 / 自动化定位锚点 + // 已废弃端口(ref_images / video_frames):早期版本建过的节点在旧工作流里会残留空槽,没连线就移除 + // (工作流恢复 inputs 可能晚于面板初始化 → 立刻做一次 + 稍后再做一次) + const xbrDropDeadInputs = () => { + for (const dead of ["ref_images", "video_frames"]) { + const di = (node.inputs || []).findIndex((i) => i.name === dead && i.link == null); + if (di >= 0) { try { node.removeInput(di); } catch (_) {} } + } + }; + xbrDropDeadInputs(); + setTimeout(xbrDropDeadInputs, 400); + setTimeout(xbrDropDeadInputs, 1500); + node.__ippSyncPromptBox = syncPromptBoxFromWidget;"""), + # 9. 扩展名不能与其它面板重名(前端会拒绝注册第二个同名扩展) + (' name: "XB.ImagePromptPreset",', + ' name: "XB.QwenPromptPreset",'), + # 10. 高度常量:按钮多 1 行(+36)+ 参数设定显示信息区(5 行 130 + 6 间距) + (""" const DOM_FIXED_H = 116; // 按钮区 2 行(66) + 标签(17) + 内边距/间距(≈33)""", + """ const DOM_FIXED_H = 288; // Qwen:按钮区 3 行(102) + 标签(17) + 信息区(130+6) + 内边距/间距(≈33)"""), + (""" const DOM_MIN_H = 411; // DOM 区最小高度 = 按钮区 66 + 间距 12 + 标签 17 + 输入框 300 + 内边距 16""", + """ const DOM_MIN_H = 583; // Qwen:DOM 区最小高度 = 按钮区 102 + 间距 12 + 标签 17 + 输入框 300 + 信息区 136 + 内边距 16"""), +] + +for old, new in patches: + n = src.count(old) + if n != 1: + print("[FAIL] 锚点命中 %d 次(应为 1):%r" % (n, old[:60])) + sys.exit(1) + src = src.replace(old, new) + +anchor = "app.registerExtension({" +if src.count(anchor) != 1: + print("[FAIL] registerExtension 锚点异常") + sys.exit(1) +src = src.replace(anchor, block + "\n" + anchor) + +io.open(DST, "w", encoding="utf-8", newline="\n").write(src) +print("[OK] 生成 %s(%d 字符)" % (DST, len(src))) diff --git a/_scan_spaces.py b/_scan_spaces.py new file mode 100644 index 0000000..0625f3b --- /dev/null +++ b/_scan_spaces.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +"""扫描源文件里「汉字 + 半角空格 + 汉字」的可疑空格(中文正文里几乎不会出现)""" +import io +import os +import re + +ROOT = r"d:\AI_JZL\XB_ToolBox" +CJK = r"\u4e00-\u9fff\u3000-\u303f\uff00-\uffef" +pat = re.compile(r"([%s])[ \t](?=[%s])" % (CJK, CJK)) + +FILES = [ + "nodes_image_prompt_preset.py", + "nodes_qwen_prompt_preset.py", + "nodes_image_prompt_preset_pro.py", + "js/xb_image_prompt_preset.js", + "js/xb_qwen_prompt_preset.js", + "js/xb_image_prompt_preset_pro.js", + "_xbr_qwen_block.js", + "_xbr_pro_block.js", +] + +for rel in FILES: + p = os.path.join(ROOT, rel) + if not os.path.exists(p): + print("(缺)", rel) + continue + s = io.open(p, encoding="utf-8").read() + hits = list(pat.finditer(s)) + print("== %s 可疑空格 %d 处" % (rel, len(hits))) + for m in hits[:40]: + a = max(0, m.start() - 14) + b = min(len(s), m.end() + 14) + print(" L%s: ...%s..." % (s[:m.start()].count("\n") + 1, s[a:b].replace("\n", "\\n"))) diff --git a/_xbr_pro_block.js b/_xbr_pro_block.js index d4de144..2419dbd 100644 --- a/_xbr_pro_block.js +++ b/_xbr_pro_block.js @@ -1,7 +1,7 @@ /* ============================================================================ * ✨ 提示词增强反推(融合进「🖼️ 生图提示词预设Pro」) * --------------------------------------------------------------------------- - * 节点表面新增:3 个按钮(🤖 大语言模型配置 | ✨ 提示词增强预设 | 📖 使用说明) + * 节点表面新增:2 个按钮(🤖 大语言模型配置 | ✨ 提示词增强预设) * + 「✅ 启用 LLM 反推」开关(节点表面参数,默认关) * + 提示词框标题行(右侧「📋 复制」)+ 参数设定显示(只读摘要) * + 「📝 提示词」端口接线 → 提示词框锁定(二选一) @@ -26,11 +26,14 @@ const XBR_BACKENDS = [ ]; const XBR_INFERENCE_MODES = ["one by one", "images", "video"]; const XBR_SEED_MODES = ["randomize", "fixed", "increment", "decrement"]; +/** SKILL 三态:自动 = 按预设模式适配;手动 = 用选择的那一个;不用 = 完全不生效 */ +const XBR_SKILL_MODES = ["自动", "手动", "不用"]; +/** 模版三态:自动 = 按有没有接参考图判断;文生图 / 图生图 = 手动指定 */ +const XBR_IO_MODES = ["自动", "文生图", "图生图"]; const XBR_SEED_LABEL = { randomize: "随机", fixed: "固定", increment: "增加", decrement: "减少" }; const XBR_PANEL_BUTTONS = [ { id: "llm", label: "🤖 LLM设置", title: "🤖 LLM设置", subtitle: "LLM 反推的后端与推理配置;本弹窗里的「输出语言」是全节点唯一的语言设置(决定词表加载语言 + 最终提示词语言)。" }, - { id: "preset", label: "✨ 增强预设", title: "✨ 增强预设", subtitle: "空latent类型 / 预设模式 / 增强预设(= 提示词设定)/ 反推预设 / 追加设定。提示词正文在节点上的提示词框里编辑。" }, - { id: "help", label: "📖 使用说明", title: "📖 使用说明", subtitle: "端口 / 按钮 / 保存说明。" }, + { id: "preset", label: "✨ 预设参数", title: "✨ 预设参数", subtitle: "空latent类型 / 预设模式 / 增强预设(= 提示词设定)/ 反推预设 / 追加设定 / SKILL。提示词正文在节点上的提示词框里编辑。" }, ]; const XBR_API_ENDPOINT = "/xb_toolbox/llm_api_settings"; @@ -132,15 +135,23 @@ function xbrSaveJson(node, patch) { return cur; } -/* ── 预设设定词(三视图 / 四视图 / 五视图 / 背景纯透明的设定文本)───────────── - * · 用户改过的按「模式|语言」存进节点(基础面板的 preset_texts 存档); - * · 弹窗读「存档 → 默认」,提交时同步写 widget + 存档。 */ +/* ── 预设设定词(每个预设模式都有「文生图 / 图生图」两套)───────────────────── + * · 用户改过的按「模式|模版|语言」存进节点(基础面板的 preset_texts 存档); + * · 弹窗读「存档 → 当前模版默认」,提交时同步写 widget + 存档。 */ function xbrPresetStore(node) { try { return parsePresetTexts(xbrCurJson(node).preset_texts); } catch (_) { return {}; } } -function xbrPresetTextFor(node, mode, lang) { - const t = xbrPresetStore(node)[presetKeyOf(mode, lang)]; - return (t && String(t).trim()) ? t : (defaultPresetOf(mode, lang) || ""); +function xbrPresetTextFor(node, mode, lang, io) { + const key = presetKeyOf(mode, io, lang); + const t = xbrPresetStore(node)[key]; + // 存档里的值等于任一默认 / 旧条款 → 视为「没改过」 → 用当前模版的默认(无预设 = 空) + if (t && String(t).trim() && !isDefaultPresetText(mode, t)) return t; + return (presetTextOf(mode, lang, io) || ""); +} +/** 当前模版(文生图 / 图生图):自动 → 按「🖼️ 图像」有没有接线判断 */ +function xbrResolveIo(node, ioRaw) { + const has = (typeof modeHasImage === "function") && modeHasImage(node); + return (typeof resolveIoMode === "function") ? resolveIoMode(ioRaw, has) : "文生图"; } /* ── 预设选项按「输出语言」过滤 ─────────────────────────────── @@ -359,8 +370,8 @@ function xbrRenderLlm(body, node, draft, ctx) { body.append(makeSectionTitle("输出语言(全节点唯一的语言设置)")); body.append(field("输出语言", selectControl(LANGS, draft.lang, (v) => { draft.lang = v; - // 换语言 → 未在弹窗里改过设定词时,跟着取该语言的「存档 → 默认」 - if (!draft.presetTouched) draft.presetText = xbrPresetTextFor(node, draft.mode, v); + // 换语言 → 未在弹窗里改过设定词时,跟着取该语言同模版的「存档 → 默认」 + if (!draft.presetTouched) draft.presetText = xbrPresetTextFor(node, draft.mode, v, xbrResolveIo(node, draft.ioMode)); // 语言决定预设可选范围:增强预设 / 反推预设 自动换到同语言的同名项(没有则取该语言第一项) draft.preset = xbrSnapPreset(xbrWidgetOptions(node, "preset"), draft.preset, v); draft.task_preset = xbrSnapPreset(xbrWidgetOptions(node, "task_preset"), draft.task_preset, v); @@ -441,38 +452,75 @@ function xbrRenderPreset(body, node, draft, ctx) { body.append(makeSectionTitle("生图预设")); body.append(field("空latent类型", selectControl(LATENT_KINDS, draft.kind, (v) => { draft.kind = v; }))); body.append(xbrHint("空latent类型:选你正在用的模型即可(形状 / 下采样 / 尺寸步长自动适配 8/16/32)。")); + // ── 模版(文生图 / 图生图):决定用哪一套设定词(每个预设模式都有两套)── + body.append(field("模版", radioRow(XBR_IO_MODES, draft.ioMode, (v) => { + draft.ioMode = v; + // 换模版 → 未改过时立即换成对应那套设定词(改过的按「模式|模版|语言」存着不会被冲掉) + if (!draft.presetTouched) draft.presetText = xbrPresetTextFor(node, draft.mode, draft.lang, xbrResolveIo(node, v)); + ctx.rerender(); + }))); + body.append(xbrHint("模版:自动 = 按「🖼️ 图像」有没有接线判断;文生图 / 图生图 = 手动指定。每个预设模式都有两套设定词,切模版会直接换掉下面「设定词」那一栏的内容。")); body.append(field("预设模式", selectControl(MODES, draft.mode, (v) => { draft.mode = v; - // 换模式 → 立刻取该模式的设定词(用户改过的存档 → 默认),改过的不会被冲掉 + // 换模式 → 立刻取该「模式 × 模版」的设定词(用户改过的存档 → 默认),改过的不会被冲掉 draft.presetTouched = false; - draft.presetText = xbrPresetTextFor(node, v, draft.lang); + draft.presetText = xbrPresetTextFor(node, v, draft.lang, xbrResolveIo(node, draft.ioMode)); ctx.rerender(); // 有/无设定词的模式之间切换 → 重画「设定词」区 }))); - body.append(xbrHint("常规文生图 = 只输出正文;人物三视图 / 人物四视图 / 人物五视图 / 背景纯透明 = 最终提示词最顶端自动加上该模式的设定词。")); + // 当前模版 + 本档提示 + const needsImgP = (typeof modeNeedsImage === "function") && modeNeedsImage(draft.mode); + const hasImgP = (typeof modeHasImage === "function") && modeHasImage(node); + const ioNowP = xbrResolveIo(node, draft.ioMode); + body.append(el("div", "font-size:11px;color:#8fb;line-height:1.7;margin:2px 0 4px;", + "当前模版:" + ioNowP + + (draft.ioMode === "自动" ? ("(自动:" + (hasImgP ? "检测到 🖼️ 图像已接线" : "未接 🖼️ 图像") + ")") + : "(手动指定)") + + (needsImgP ? " ❗本档属于图生图档" : ""))); + body.append(el("div", "font-size:11px;color:" + (needsImgP ? "#d9a441" : "#888") + ";line-height:1.7;margin:2px 0 8px;", + (draft.mode === MODE_TP) + ? "无预设:接不接参考图、哪个模版,都不会前置设定词(下面「设定词」栏保持空白;想加就自己写)。" + : (needsImgP + ? (ioNowP === "文生图" + ? "⚠️ 本档属于「图生图」,但当前是文生图模版:下面用的是从零生成的写法(不依赖任何输入图)。把参考图接到 🖼️ 图像即可切回图生图模版。" + : "本档属于「图生图」:需开启 ✅ 启用 LLM 反推,并把参考图接到 🖼️ 图像。") + : (ioNowP === "图生图" + ? "本档属于「文生图」,当前用图生图模版:设定词末尾会追加「以输入图为准」的条款,需接参考图。" + : "本档属于「文生图」:不接图也行;接图后把模版改成「图生图」(或保持自动)会追加「以输入图为准」的条款。")))); - // ── 设定词(预设模式对应的设定文本;只在弹窗里编辑,节点表面不显示)── - const modeDef = defaultPresetOf(draft.mode, draft.lang); - if (modeDef) { - body.append(makeSectionTitle("设定词")); - if (!String(draft.presetText || "").trim()) draft.presetText = modeDef; + // ── 设定词(按「模式 × 模版 × 语言」取;只在弹窗里编辑,节点表面不显示)── + // 无预设档:这一栏**照样显示**,只是内容空白(用户可以自己写) + const modeDef = presetTextOf(draft.mode, draft.lang, ioNowP); + { + body.append(makeSectionTitle("设定词(" + ioNowP + "模版)")); + draft.presetText = String(draft.presetText || ""); + if (modeDef && !draft.presetText.trim()) draft.presetText = modeDef; const taP = textareaControl(draft.presetText, (v) => { draft.presetText = v; draft.presetTouched = true; }, "width:100%;box-sizing:border-box;min-height:180px;resize:vertical;"); - taP.placeholder = "该模式的设定词;改过的按「模式 + 语言」记进节点,换模式 / 换语言都不丢"; + taP.placeholder = modeDef + ? "该模式在「" + ioNowP + "」模版下的设定词;改过的按「模式 + 模版 + 语言」记进节点,换模式 / 换模版 / 换语言都不丢" + : "无预设:本档不前置设定词(保持空白即可;在这里写内容就会作为设定词置顶)"; taP.spellcheck = false; body.append(taP); const taRow = el("div", "display:flex;align-items:center;gap:10px;margin:6px 0 4px;"); - taRow.append(smallBtn("♻️ 恢复默认", "border:1px solid #555;background:#2a2a2a;color:#ccc;padding:4px 10px;", - "把设定词恢复为该模式 × 该语言的默认文本", - () => { draft.presetText = modeDef; draft.presetTouched = false; ctx.rerender(); })); + if (modeDef) { + taRow.append(smallBtn("♻️ 恢复默认", "border:1px solid #555;background:#2a2a2a;color:#ccc;padding:4px 10px;", + "把设定词恢复为该模式 × 该模版 × 该语言的默认文本", + () => { draft.presetText = modeDef; draft.presetTouched = false; ctx.rerender(); })); + } taRow.append(el("span", "font-size:11px;color:#888;", - String(draft.presetText || "") === modeDef ? "当前 = 默认" : "当前 = 自定义")); + !modeDef ? (draft.presetText.trim() ? "当前 = 自定义" : "当前 = 空白(不前置设定词)") + : (draft.presetText === modeDef ? "当前 = 默认" : "当前 = 自定义"))); body.append(taRow); - body.append(el("div", "font-size:11px;color:#888;line-height:1.75;margin:6px 0 8px;", - "· 最终输出时,设定词会原封不动加在增强后提示词的最顶端;\n" - + "· LLM 只增强正文,把设定词当作增强参考,不会改写它;\n" - + "· 改过的设定词随节点保存,换模式 / 换语言都会取回你改过的那一版。")); - } else { - body.append(el("div", "font-size:11px;color:#888;line-height:1.75;margin:6px 0 8px;", "常规文生图 = 没有设定词,直接输出正文。")); + if (modeDef) { + body.append(el("div", "font-size:11px;color:#888;line-height:1.75;margin:6px 0 8px;", + "· 最终输出时,设定词会原封不动加在增强后提示词的最顶端;\n" + + "· LLM 只增强正文,把设定词当作增强参考,不会改写它;\n" + + "· 改过的设定词随节点保存,换模式 / 换模版 / 换语言都会取回你改过的那一版。")); + } else { + body.append(el("div", "font-size:11px;color:#888;line-height:1.75;margin:6px 0 8px;", + "· 无预设档:不管接不接参考图,节点都不会自动前置设定词;\n" + + "· 如果在这一栏写了内容,它会作为增强参考交给 LLM、并原封不动置于最终提示词最顶端。")); + } } body.append(makeSectionTitle("增强预设(= 提示词设定 / system prompt)")); @@ -492,6 +540,45 @@ function xbrRenderPreset(body, node, draft, ctx) { ta.placeholder = "追加在增强预设之后的额外要求,例:只输出一行、不要解释过程…"; ta.spellcheck = false; body.append(ta); + + // ── SKILL(系统提示词):常驻三态 自动 / 手动 / 不用 ── + body.append(makeSectionTitle("SKILL(系统提示词)")); + const skillOpts = xbrWidgetOptions(node, "skill_name"); + if (!skillOpts.length) skillOpts.push("不使用"); + if (!skillOpts.includes(draft.skill)) draft.skill = skillOpts[0]; + if (!XBR_SKILL_MODES.includes(draft.skillMode)) draft.skillMode = "自动"; + body.append(field("SKILL 模式", radioRow(XBR_SKILL_MODES, draft.skillMode, (v) => { draft.skillMode = v; ctx.rerender(); }))); + if (draft.skillMode === "手动") { + body.append(field("SKILL选择", selectControl(skillOpts, draft.skill, (v) => { draft.skill = v; }))); + } else if (draft.skillMode === "自动") { + const autoFileP = (typeof modeSkillHint === "function") ? modeSkillHint(draft.mode) : "-"; + body.append(el("div", "font-size:11px;color:#8fb;margin:2px 0 8px;", + "自动:本档(" + draft.mode + ")→ " + autoFileP)); + } else { + body.append(el("div", "font-size:11px;color:#d9a441;margin:2px 0 8px;", + "不用:SKILL 完全不生效(即使下面选过文件也不会送进 LLM)。")); + } + const foxRowP = el("div", "display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:2px 0 8px;"); + foxRowP.append(smallBtn("📂 打开 SKILL 文件夹", "border:1px solid #555;background:#2a2a2a;color:#ccc;padding:4px 10px;", + "在资源管理器里打开 support_llama/skills,方便新增 / 编辑技能文件", + async () => { + try { + const r = await fetch("/xb_toolbox/skill_folder"); + const j = await r.json().catch(() => ({})); + if (!r.ok || j.ok === false) notify("打开失败:" + (j.info || r.status), "error"); + else notify("已打开:" + (j.path || "skills"), "success"); + } catch (e) { notify("打开失败:" + ((e && e.message) || e), "error"); } + })); + body.append(foxRowP); + body.append(el("div", "font-size:11px;color:#888;line-height:1.6;margin:2px 0 8px;", + "技能文件放在 XB_ToolBox/support_llama/skills(.txt / .md):选中后整段作为 LLM 反推的 system prompt。")); + body.append(el("div", "font-size:11px;color:#888;line-height:1.75;margin:6px 0 8px;", + "· 自动(默认)= 按预设模式适配:图生图档 → system_prompt_edit,其余 → system_prompt_t2i;\n" + + "· 手动 = 用「SKILL选择」里选中的那个文件;\n" + + "· 不用 = 完全不生效(哪怕选了文件);\n" + + "· 选中技能后:技能放在系统提示词最前面当角色与总规则,且**不再叠加**节点的语言提示\n" + + " (这两个技能文件自带语言决策与输出契约,叠加会打架);\n" + + "· 「✅ 启用 LLM 反推」关闭时 SKILL 不参与;新增技能文件后刷新页面即可出现在下拉里。")); } /* ── 弹窗内容:📖 使用说明 ───────────────────────────────── */ @@ -507,7 +594,7 @@ function xbrRenderHelp(body) { " 3. 想让 AI 代写:开 ✅ 启用 LLM 反推,先到 🤖 LLM设置 选好模型或填好 API", "", "【节点表面】", - " · 第 1 行按钮:🤖 LLM设置 | ✨ 增强预设 | 📖 使用说明", + " · 第 1 行按钮:🤖 LLM设置 | ✨ 预设参数", " · 第 2 行按钮:🎨 风格 | 📐 视角 | 👤 主体 | 🕺 姿态 | 👗 装扮 | 🎒 道具 | 💡 光影 | 🏞️ 背景", " · 画幅比例 / 宽度 / 高度 / 生成数量:数值会按当前空latent类型的官方步长自动对齐", " · ✅ 启用 LLM 反推:关 = 只输出拼装好的提示词,不加载模型、不调 API", @@ -539,13 +626,32 @@ function xbrRenderHelp(body) { " 选你正在用的模型即可:通道数、下采样、尺寸步长、batch 上限会自动切换", "", "【预设模式】", - " · 常规文生图:只输出正文,不加任何设定词", - " · 人物三视图 / 人物四视图 / 人物五视图:最终提示词最顶端自动加上对应设定词", - " · 背景纯透明:加上 RGBA 透明背景设定词", + "【预设模式】(每档自带一段设定词,可自由修改)", + " · 文生图组:无预设 / 人物三视图 / 人物四视图 / 人物五视图 / 背景纯透明 /", + " 图文版面 / 信息图 / 多格分镜 / 广告分镜板", + " · 图生图组(需接参考图):保持主体换场景 / 局部编辑 /", + " 老照片修复 / 整图风格化 / 360°全景 / 多图指认合成", " · 设定词可以自由修改,改过的那一版按「模式 + 语言」记在节点里,换模式 / 换语言都不会丢", - " · 点 ♻️ 恢复默认 一键回到官方文本", + " · 点 ♻️ 恢复默认 一键回到官方文本;启用 LLM 时设定词另作增强参考交给模型", " · 启用 LLM 时,设定词只作为增强参考交给模型,输出时由节点原封不动加在最顶端", "", + "【文生图 / 图生图两套模版(全自动切换)】", + " · 没接 🖼️ 图像 → 走文生图模版:从零生成的写法,不会出现「参考输入图」类条款", + " · 接了 🖼️ 图像 → 走图生图模版:自动追加「以输入图为准」的条款,按序号引用第 1…N 张图", + " · 两种模版不用手动切:面板会显示当前用的是哪套 + 自动追加的条款原文", + " · 图生图档没接图也不会报错:会自动改成从零生成的写法(并在日志里提醒)", + "", + "【SKILL 技能(系统提示词)】", + " · ✨ 预设参数 → SKILL:support_llama/skills 里的 txt 整段当 LLM 的 system prompt", + " 面板里点「📂 打开 SKILL 文件夹」可直接打开,新增后刷新页面即出现在下拉", + " · SKILL 模式(常驻三选一,默认自动):", + " ◆ 自动 = 按预设模式适配:图生图档 → system_prompt_edit.txt,其余 → system_prompt_t2i.txt", + " ◆ 手动 = 用「SKILL选择」里选中的那个文件", + " ◆ 不用 = SKILL 完全不生效(哪怕选过文件)", + " · 选中后系统提示词 = 技能 + 增强预设 + 设定词参考 + 追加设定 + 任务块", + " · 技能自带语言与输出契约 → 选中时不再叠加节点的语言提示(避免互相对打)", + " · 勾选开关关掉 ✅ 启用 LLM 反推时 SKILL 不参与", + "", "【语言只有一处】", " 🤖 LLM设置 → 输出语言,它同时决定:词表与设定词按哪种语言加载、", " 增强预设与反推预设只列哪种语言的选项、最终提示词是中文还是英文", @@ -585,9 +691,22 @@ async function xbrOpenModal(node, panelId) { lang: xbrPick(String(xbrWidgetVal(node, "output_lang") ?? ""), LANGS, LANGS[0]), kind: xbrPick(String(xbrWidgetVal(node, "latent_kind") ?? ""), LATENT_KINDS, LATENT_KINDS[0]), mode: xbrPick(String(xbrWidgetVal(node, "preset_mode") ?? ""), MODES, MODES[0]), - // 设定词(三视图/四视图/五视图/背景纯透明):弹窗内草稿 + 「用户是否改过」标记 - presetText: String(xbrWidgetVal(node, "three_view_text") ?? ""), + // 模版(自动 / 文生图 / 图生图) + ioMode: (() => { const v = String(xbrWidgetVal(node, "io_mode") ?? ""); return XBR_IO_MODES.includes(v) ? v : "自动"; })(), + // 设定词(每个预设模式两套):弹窗内草稿 + 「用户是否改过」标记 + // widget 里是「遗留默认句 / 任一内置预设句」→ 视为没改过 → 取「存档 → 当前模版默认」(无预设 = 空) + presetText: (() => { + const w = String(xbrWidgetVal(node, "three_view_text") ?? ""); + const md = xbrPick(String(xbrWidgetVal(node, "preset_mode") ?? ""), MODES, MODES[0]); + const lg = xbrPick(String(xbrWidgetVal(node, "output_lang") ?? ""), LANGS, LANGS[0]); + const ioW = String(xbrWidgetVal(node, "io_mode") ?? ""); + if (w.trim() && !isDefaultPresetText(md, w)) return w; + return xbrPresetTextFor(node, md, lg, xbrResolveIo(node, XBR_IO_MODES.includes(ioW) ? ioW : "自动")); + })(), presetTouched: false, + // SKILL 选择(support_llama/skills 里的技能文件 = system prompt) + skill: String(xbrWidgetVal(node, "skill_name") ?? ""), + skillMode: (() => { const v = String(xbrWidgetVal(node, "skill_mode") ?? ""); return XBR_SKILL_MODES.includes(v) ? v : "自动"; })(), }; // 预设选项与输出语言对齐(旧工作流可能存着异语言的预设名) draft.preset = xbrSnapPreset(xbrWidgetOptions(node, "preset"), draft.preset, draft.lang); @@ -616,12 +735,15 @@ async function xbrOpenModal(node, panelId) { xbrSetWidget(node, "output_lang", draft.lang, true); // 唯一语言设置 xbrSetWidget(node, "latent_kind", draft.kind, true); // 触发基础面板的步长/上限联动 xbrSetWidget(node, "preset_mode", draft.mode, true); // 触发预设句框显隐 - // 设定词:写进节点 widget(原样)+ 按「模式|语言」存档 → 换模式 / 换语言都不丢 + xbrSetWidget(node, "io_mode", draft.ioMode); // 模版:自动 / 文生图 / 图生图 + xbrSetWidget(node, "skill_name", draft.skill); // SKILL = LLM 的 system prompt + xbrSetWidget(node, "skill_mode", draft.skillMode); // 自动 / 手动 / 不用 + // 设定词:写进节点 widget(原样)+ 按「模式|模版|语言」存档 → 换模式 / 换模版 / 换语言都不丢 + // ⚠️ 一律写(包含「无预设」时写空串)→ 顺手把 widget 里的遗留默认句清掉 try { - if (defaultPresetOf(draft.mode, draft.lang)) { - node.__ippSetPreset?.(draft.presetText); - node.__ippWritePreset?.(draft.mode, draft.lang, draft.presetText); - } + const ioCommit = xbrResolveIo(node, draft.ioMode); + node.__ippSetPreset?.(draft.presetText); + node.__ippWritePreset?.(draft.mode, ioCommit, draft.lang, draft.presetText); } catch (_) {} xbrSetWidget(node, "backend", draft.backend, true); xbrSetWidget(node, "preset", draft.preset, true); @@ -657,7 +779,7 @@ async function xbrOpenModal(node, panelId) { /* ── 按钮区 / 提示词框标题行 / 参数设定显示 / 锁定 ─────────── */ function xbrBuildButtonRow(node) { - const grid = el("div", "display:grid;grid-template-columns:repeat(3,1fr);grid-auto-rows:30px;gap:6px;flex:0 0 auto;"); + const grid = el("div", "display:grid;grid-template-columns:repeat(" + Math.max(1, XBR_PANEL_BUTTONS.length) + ",1fr);grid-auto-rows:30px;gap:6px;flex:0 0 auto;"); for (const b of XBR_PANEL_BUTTONS) { const btn = el("button", "width:100%;height:30px;box-sizing:border-box;display:flex;align-items:center;justify-content:center;border-radius:6px;border:2px solid #5b9bd5;background:#3a3a3a;color:#eee;font-size:13px;cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-family:inherit;", b.label); btn.title = `打开「${b.title}」弹窗`; @@ -685,18 +807,17 @@ function xbrInfoLines(node, promptText) { const useLlm = !!xbrWidgetVal(node, "use_llm"); const backend = String(xbrWidgetVal(node, "backend") ?? ""); const pMode = String(xbrWidgetVal(node, "preset_mode") ?? ""); - const pDef = defaultPresetOf(pMode, String(xbrWidgetVal(node, "output_lang") ?? "")) || ""; - const pCur = String(xbrWidgetVal(node, "three_view_text") ?? ""); + const pIo = xbrResolveIo(node, String(xbrWidgetVal(node, "io_mode") ?? "")); const line1 = `📊 空latent:${xbrClean(xbrWidgetVal(node, "latent_kind"))} | ${xbrWidgetVal(node, "width")}x${xbrWidgetVal(node, "height")} | 数量 ${xbrWidgetVal(node, "batch_size")}`; - const line2 = `🎨 预设模式:${xbrClean(pMode)}` - + (pDef ? ` | 设定词:${(pCur.trim() && pCur !== pDef) ? "✏️ 自定义" : "默认"}` : " | 设定词:无"); + // 只显示选项名称(预设模式 + 模版),不再显示设定词的状态 + const line2 = `🎨 预设模式:${xbrClean(pMode)} | 模版:${pIo}`; const line3 = `🌐 输出语言:${xbrClean(xbrWidgetVal(node, "output_lang"))}`; const line4 = useLlm ? (backend === "在线 API" ? `🤖 LLM 反推:已启用 | 在线API | ${xbrClean((node.__xbrProApiInfo?.model) || XBR_API_DEFAULTS.model)}` : `🤖 LLM 反推:已启用 | 本地模型 | ${xbrClean(llm.model.model) || "未选择模型"}`) : "🤖 LLM 反推:未启用"; - const line5 = `✨ 增强预设:${xbrClean(xbrWidgetVal(node, "preset")) || "-"} | 🎯 反推预设:${xbrClean(xbrWidgetVal(node, "task_preset")) || "-"}`; + const line5 = `✨ 预设参数:${xbrClean(xbrWidgetVal(node, "preset")) || "-"} | 🎯 反推预设:${xbrClean(xbrWidgetVal(node, "task_preset")) || "-"}`; const line6 = `⚙️ 温度 ${llm.params.temperature} · top_k ${llm.params.top_k} · top_p ${llm.params.top_p} · max_tokens ${llm.params.max_tokens} | ${xbrClean(llm.run.inference_mode)} | 种子 ${llm.run.seed}`; const line7 = `📝 提示词:${(promptText || "").length} 字`; return [line1, line2, line3, line4, line5, line6, line7].join("\n"); diff --git a/_xbr_qwen_block.js b/_xbr_qwen_block.js new file mode 100644 index 0000000..82f8bcb --- /dev/null +++ b/_xbr_qwen_block.js @@ -0,0 +1,771 @@ +/* ============================================================================ + * 🖼️ Qwen2.1提示词预设(融入「🖼️ 生图提示词预设」的同一个面板) + * --------------------------------------------------------------------------- + * 节点表面新增:2 个按钮(🤖 LLM设置 | ✨ 预设参数) + * + 提示词框标题行(右侧「📋 复制」)+ 参数设定显示(只读摘要 5 行) + * + 「📝 提示词」端口接线 → 提示词框锁定(二选一) + * 弹窗:导演台同款二级弹窗(底部:自动保存 + 字号 + 界面缩放 + 取消/💾 保存) + * 配置:manager_settings.qwen = { run.seed_control, extra_settings }(Generate Text 的全量参数走节点 widget,由弹窗读写) + * 说明:本块所有标识符统一 xbr/XBR 前缀,避免与生图预设面板既有函数/常量重名。 + * ========================================================================== */ + +const XBR_SEED_MODES = ["randomize", "fixed", "increment", "decrement"];const XBR_SEED_LABEL = { randomize: "随机", fixed: "固定", increment: "增加", decrement: "减少" }; +/** 模版三态:自动 = 按有没有接参考图判断;文生图 / 图生图 = 手动指定 */ +const XBR_IO_MODES = ["自动", "文生图", "图生图"]; +const XBR_SAMPLING_MODES = ["on", "off"]; +const XBR_MTP_MODES = ["auto", "off", "2", "3", "4", "5"]; +const XBR_PANEL_BUTTONS = [ + { id: "llm", label: "🤖 LLM设置", title: "🤖 LLM设置", subtitle: "模型选择 = 官方 Load CLIP;参数设置 = 官方 Generate Text 全量参数。执行节点即生成,没有开关。" }, + { id: "preset", label: "✨ 预设参数", title: "✨ 预设参数", subtitle: "输出语言 / 空latent类型 / 预设模式 / 设定词 / SKILL。提示词正文在节点上的提示词框里编辑。" }, +]; +/** 全部收进弹窗的节点 widget(节点表面只留画幅参数 / 生成数量) */ +const XBR_QWEN_WIDGETS = [ + "clip_name", "clip_type", "device", + "max_length", "sampling_mode", "temperature", "top_k", "top_p", "min_p", + "repetition_penalty", "presence_penalty", "seed", "control_after_generate", + "thinking", "use_default_template", "mtp", "skill_name", "skill_mode", "io_mode", +]; +/** 种子控制 widget 名(ComfyUI 见到名为 seed 的 widget 会自动配一个 control_after_generate)*/ +const XBR_SEED_CTL_WIDGET = "control_after_generate"; +/** SKILL 三态:自动 = 按预设模式适配;手动 = 用选择的那一个;不用 = 完全不生效 */ +const XBR_SKILL_MODES = ["自动", "手动", "不用"]; + +/* ── 数值 / 解析助手 ─────────────────────────────────────── */ +function xbrNum(v, d, lo, hi) { + let x = Number(v); + if (!Number.isFinite(x)) x = d; + if (lo !== undefined) x = Math.max(lo, x); + if (hi !== undefined) x = Math.min(hi, x); + return x; +} +function xbrInt(v, d, lo, hi) { return Math.round(xbrNum(v, d, lo, hi)); } +function xbrFlag(v, d) { + if (v === undefined || v === null || v === "") return d; + return (typeof v === "string") ? ["1", "true", "yes", "on", "开启"].includes(v.trim().toLowerCase()) : !!v; +} +function xbrPick(v, allowed, d) { return allowed.includes(v) ? v : d; } + +/** 本节点配置默认值(manager_settings.qwen:只放不在节点 widget 上的东西) */ +function xbrQwenDefaults() { + return { + extra_settings: "", + }; +} +/** 解析 manager_settings.qwen(缺失 / 非法一律安全回落) */ +function xbrParseQwen(raw) { + const d = (raw && typeof raw === "object") ? raw : {}; + return { extra_settings: String(d.extra_settings ?? "") }; +} + +/* ── 节点 widget 读写 ─────────────────────────────────────── */ +function xbrWidget(node, name) { return (node?.widgets || []).find((w) => w?.name === name); } +function xbrWidgetVal(node, name) { return readWidgetValue(xbrWidget(node, name)); } +/** fire=true 时同步触发该 widget 原有回调(基础面板靠回调做步长/预设句框/尺寸联动) */ +function xbrSetWidget(node, name, val, fire) { + const w = xbrWidget(node, name); + setWidgetValue(w, val); + if (fire) { try { w?.callback?.(val); } catch (_) {} } +} +function xbrHint(text) { return el("div", "font-size:11px;color:#888;line-height:1.6;margin:2px 0 8px;", text); } +function xbrWidgetOptions(node, name) { + const w = xbrWidget(node, name); + const o = w?.options?.values ?? w?.options; + return Array.isArray(o) ? o.slice() : []; +} +function xbrCurJson(node) { + try { + const raw = readWidgetValue(findWidget(node, "manager_settings")); + const d = JSON.parse(raw || "{}"); + return (d && typeof d === "object") ? d : {}; + } catch (_) { return {}; } +} +function xbrSaveJson(node, patch) { + const cur = xbrCurJson(node); + Object.assign(cur, patch || {}); + persistSettings(node, cur); + return cur; +} + +/* ── 预设设定词(三视图 / 四视图 / 五视图 / 背景纯透明的设定文本)───────────── + * · 用户改过的按「模式|模版|语言」存进节点(基础面板的 preset_texts 存档); + * · 弹窗读「存档 → 当前模版默认」,提交时同步写 widget + 存档。 */ +function xbrPresetStore(node) { + try { return parsePresetTexts(xbrCurJson(node).preset_texts); } catch (_) { return {}; } +} +function xbrPresetTextFor(node, mode, lang, io) { + const key = presetKeyOf(mode, io, lang); + const t = xbrPresetStore(node)[key]; + // 存档里的值等于任一默认 / 旧条款 → 视为「没改过」 → 用当前模版的默认(无预设 = 空) + if (t && String(t).trim() && !isDefaultPresetText(mode, t)) return t; + return (presetTextOf(mode, lang, io) || ""); +} +/** 当前模版(文生图 / 图生图):自动 → 按「🖼️ 图像」有没有接线判断 */ +function xbrResolveIo(node, ioRaw) { + const has = (typeof modeHasImage === "function") && modeHasImage(node); + return (typeof resolveIoMode === "function") ? resolveIoMode(ioRaw, has) : "文生图"; +} + +/* ── 本节点不做在线 API / 本地 llama-cpp:模型走官方 Load CLIP,参数走官方 Generate Text ── */ + +/* ── 控件:数字 / 密码 / 单选行 / 种子行 ─────────────────── */ +function xbrNumberControl(value, opts, onChange) { + const i = el("input", BOX_CSS + "min-width:0;"); + i.type = "number"; + i.value = value ?? 0; + if (opts) { + if (opts.min !== undefined) i.min = opts.min; + if (opts.max !== undefined) i.max = opts.max; + i.step = opts.step ?? 1; + } + i.addEventListener("change", () => { + let v = parseFloat(i.value); + if (!Number.isFinite(v)) v = opts?.min ?? 0; + if (opts?.min !== undefined) v = Math.max(opts.min, v); + if (opts?.max !== undefined) v = Math.min(opts.max, v); + i.value = v; + onChange(v); + }); + return i; +} +function xbrPasswordControl(value, placeholder, onChange) { + const i = el("input", BOX_CSS + "min-width:0;"); + i.type = "password"; + i.value = value ?? ""; + if (placeholder) i.placeholder = placeholder; + i.autocomplete = "new-password"; + i.spellcheck = false; + i.addEventListener("change", () => onChange(i.value.trim())); + return i; +} +function xbrTextControl(value, placeholder, onChange) { + const i = el("input", BOX_CSS + "min-width:0;"); + i.type = "text"; + i.value = value ?? ""; + if (placeholder) i.placeholder = placeholder; + i.spellcheck = false; + i.addEventListener("change", () => onChange(i.value.trim())); + return i; +} +/** 导演台同款「种子 + 生成后控制(随机/固定/增加/减少)」一行 */ +function xbrSeedRow(run, onChange) { + const g = el("div", "display:flex;align-items:center;gap:10px;margin-bottom:10px;"); + g.append(el("label", "flex:0 0 160px;font-size:13px;color:#999;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;", "种子")); + const i = xbrNumberControl(run.seed, { min: 0, max: Number.MAX_SAFE_INTEGER, step: 1 }, (v) => { run.seed = Math.round(v); onChange?.(); }); + i.style.flex = "1 1 0"; + const ctrl = smallBtn("", "flex:1 1 0;min-width:0;background:#b45309;border:1px solid #d97706;color:#fff;text-align:center;", "生成后控制:点击在 随机 / 固定 / 增加 / 减少 间切换", null); + const cur = () => (XBR_SEED_LABEL[run.seed_control] ? run.seed_control : "randomize"); + const render = () => { + ctrl.textContent = "🔁 " + XBR_SEED_LABEL[cur()]; + ctrl.title = "生成后控制:随机 = 每次运行后自动换新种子;固定 = 锁定当前值;增加/减少 = 每次运行后 ±1。点击切换"; + }; + ctrl.addEventListener("click", () => { + run.seed_control = XBR_SEED_MODES[(XBR_SEED_MODES.indexOf(cur()) + 1) % XBR_SEED_MODES.length]; + render(); + onChange?.(); + }); + render(); + g.append(i, ctrl); + return g; +} + +/* ── 弹窗外壳(与生图预设面板同风格;底部:自动保存 + 字号 + 界面缩放)── */ +let xbrModal = null; +function xbrDialog({ label, title, subtitle, width = 820, autoSaveRef = null, onCommit = null, onImmediate = null, onChange = null, onClose = null }) { + const overlay = el("div", "position:fixed;inset:0;background:rgba(0,0,0,.78);z-index:10300;display:flex;align-items:center;justify-content:center;overflow:auto;"); + const dialog = el("section", `background:#1c1c1e;border:1px solid #333;border-radius:8px;width:${width}px;max-width:94vw;` + + "max-height:90vh;display:flex;flex-direction:column;box-shadow:0 20px 40px rgba(0,0,0,.6);flex:0 0 auto;color:#ddd;font-size:13px;"); + dialog.dataset.xbrProModal = label; + dialog.setAttribute("role", "dialog"); + dialog.setAttribute("aria-modal", "true"); + dialog.setAttribute("aria-label", label); + + const header = el("div", "padding:18px 20px;border-bottom:1px solid #444;"); + header.append(el("div", "font-size:17px;font-weight:700;color:#eee;", title)); + if (subtitle) header.append(el("div", "font-size:12px;color:#999;margin-top:5px;line-height:1.6;", subtitle)); + const body = el("div", "padding:16px 20px;overflow-y:auto;flex:1;min-height:200px;"); + const error = el("div", "color:#e55;font-size:12px;margin:0 20px;min-height:14px;"); + const footer = el("div", "padding:12px 20px;border-top:1px solid #444;display:flex;justify-content:flex-end;gap:10px;align-items:center;background:#18181a;border-bottom-left-radius:8px;border-bottom-right-radius:8px;"); + const left = el("div", "display:flex;align-items:center;gap:16px;margin-right:auto;flex-wrap:wrap;"); + + let autoCb = null; + if (autoSaveRef) { + const autoRow = el("label", "display:flex;align-items:center;gap:6px;font-size:13px;color:#999;cursor:pointer;user-select:none;"); + autoCb = el("input", "width:18px;height:18px;accent-color:#f59e0b;cursor:pointer;"); + autoCb.type = "checkbox"; + autoCb.checked = autoSaveRef.value !== false; + autoCb.title = "开启后:弹窗内改动会自动保存(防抖);关闭后需手动点「💾 保存」"; + autoCb.addEventListener("change", () => { autoSaveRef.value = autoCb.checked; try { onImmediate?.(); } catch (_) {} }); + autoRow.append(autoCb, el("span", "", "自动保存")); + left.append(autoRow); + } + const zoomRow = buildZoomRow(dialog); + zoomRow.title = "调整面板文本编辑字号(与本节点其它面板共享记忆)"; + const uiRow = buildUIRow(dialog); + uiRow.title = "调整整个弹窗界面大小(不缩放网页/画布;Ctrl/⌘+滚轮 也可)"; + left.append(zoomRow, uiRow); + + const cancelBtn = el("button", "background:transparent;border:1px solid #555;color:#fff;border-radius:4px;padding:8px 20px;font-size:14px;cursor:pointer;font-family:inherit;", "取消"); + const saveBtn = onCommit ? el("button", "background:#2d5a88;color:#fff;border:none;border-radius:4px;padding:8px 20px;font-size:14px;font-weight:600;cursor:pointer;font-family:inherit;", "💾 保存") : null; + footer.append(left, cancelBtn); + if (saveBtn) footer.append(saveBtn); + dialog.append(header, body, error, footer); + overlay.append(dialog); + document.body.append(overlay); + zoomApplyContainer(dialog); + uiApply(dialog); + bindWheelZoom(dialog); + + let timer = null; + const close = () => { + clearTimeout(timer); + try { overlay.remove(); } catch (_) {} + if (xbrModal && xbrModal.overlay === overlay) xbrModal = null; + try { onClose?.(); } catch (_) {} + }; + const onKey = (e) => { if (e.key === "Escape") { e.preventDefault(); close(); } }; + document.addEventListener("keydown", onKey, true); + cancelBtn.addEventListener("click", close); + overlay.addEventListener("mousedown", (e) => { if (e.target === overlay) close(); }); + if (saveBtn) { + saveBtn.addEventListener("click", async () => { + if (saveBtn.disabled) return; + saveBtn.disabled = true; + saveBtn.textContent = "保存中…"; + error.textContent = ""; + try { await onCommit(); close(); } + catch (e) { error.textContent = "保存失败:" + ((e && e.message) || e); saveBtn.disabled = false; saveBtn.textContent = "💾 保存"; } + }); + } + // 自动保存(导演台同款:改动后防抖提交) + body.addEventListener("change", () => { + try { onChange?.(); } catch (_) {} + if (autoSaveRef && autoSaveRef.value === false) { try { onImmediate?.(); } catch (_) {} return; } + clearTimeout(timer); + timer = setTimeout(() => { try { onCommit?.(); } catch (_) {} }, 800); + }); + const shell = { overlay, dialog, body, error, close }; + xbrModal = shell; + return shell; +} + +/* ── 弹窗内容:🤖 大语言模型配置 ─────────────────────────── */ +/* ── 弹窗内容:🤖 LLM设置(官方 Load CLIP + 官方 Generate Text)─────── */ +function xbrRenderLlm(body, node, draft, ctx) { + const s = draft.settings.qwen; + + body.append(makeSectionTitle("模型选择(官方 Load CLIP)")); + const clips = xbrWidgetOptions(node, "clip_name"); + if (clips.length) body.append(field("模型", selectControl(clips, draft.clip_name, (v) => { draft.clip_name = v; }))); + else body.append(field("模型", xbrTextControl(draft.clip_name, "models/text_encoders 里没有模型", (v) => { draft.clip_name = v; }))); + body.append(field("类型", selectControl(xbrWidgetOptions(node, "clip_type"), draft.clip_type, (v) => { draft.clip_type = v; }))); + body.append(field("设备", selectControl(xbrWidgetOptions(node, "device"), draft.device, (v) => { draft.device = v; }))); + body.append(xbrHint("与官方 Load CLIP 完全一致:模型来自 ComfyUI/models/text_encoders;Qwen-Image 系文本编码器请选 类型 = qwen_image。")); + + body.append(makeSectionTitle("参数设置(官方 Generate Text 全量参数)")); + const t = draft.text; + body.append(field("最大长度 max_length", xbrNumberControl(t.max_length, { min: 1, max: 32768, step: 1 }, (v) => { t.max_length = Math.round(v); }))); + body.append(field("采样模式 sampling_mode", selectControl(XBR_SAMPLING_MODES, t.sampling_mode, (v) => { t.sampling_mode = v; ctx.rerender(); }))); + if (t.sampling_mode === "on") { + body.append(field("temperature", xbrNumberControl(t.temperature, { min: 0.01, max: 2.0, step: 0.000001 }, (v) => { t.temperature = v; }))); + body.append(field("top_k", xbrNumberControl(t.top_k, { min: 0, max: 1000, step: 1 }, (v) => { t.top_k = Math.round(v); }))); + body.append(field("top_p", xbrNumberControl(t.top_p, { min: 0, max: 1, step: 0.01 }, (v) => { t.top_p = v; }))); + body.append(field("min_p", xbrNumberControl(t.min_p, { min: 0, max: 1, step: 0.01 }, (v) => { t.min_p = v; }))); + body.append(field("repetition_penalty", xbrNumberControl(t.repetition_penalty, { min: 0, max: 5, step: 0.01 }, (v) => { t.repetition_penalty = v; }))); + body.append(field("presence_penalty", xbrNumberControl(t.presence_penalty, { min: 0, max: 5, step: 0.01 }, (v) => { t.presence_penalty = v; }))); + const holder = { seed: t.seed, seed_control: xbrPick(t.ctl, XBR_SEED_MODES, "randomize") }; + body.append(xbrSeedRow(holder, () => { t.seed = holder.seed; t.ctl = holder.seed_control; })); + body.append(xbrHint("点「🔁」切换生成后控制:随机 = 运行后自动换新种子;固定 = 锁定;增加 / 减少 = 运行后 ±1。" + + "这里与 ComfyUI 原生的种子控制是同一个值,两处改哪个都一样。")); + } else { + body.append(xbrHint("采样模式 = off:贪心解码,下面的 temperature / top_k / top_p / min_p / 惩罚项 / 种子都不参与。")); + } + body.append(field("思考模式 thinking", checkboxControl(t.thinking, "模型支持时以思考模式生成;思考内容不会进入输出", (v) => { t.thinking = v; }))); + body.append(field("使用内置模板 use_default_template", checkboxControl(t.use_default_template, "使用模型自带的系统提示词 / 对话模板", (v) => { t.use_default_template = v; }))); + body.append(field("mtp", selectControl(XBR_MTP_MODES, t.mtp, (v) => { t.mtp = v; }))); + body.append(xbrHint("mtp:多 token 预测投机解码,没有 MTP 权重时无效;与官方 Generate Text 同义。")); + body.append(el("div", "font-size:11px;color:#888;line-height:1.6;margin:2px 0 8px;", "提示词正文在节点上的提示词框里编辑;「📝 提示词」端口接线后该框锁定。")); +} + +/* ── 弹窗内容:✨ 预设参数(生图预设 + 设定词 + SKILL)───────────── */ +function xbrRenderPreset(body, node, draft, ctx) { + // 注:原「追加设定(可留空)」已按用户要求删除(不再往系统提示词里拼【额外要求】) + body.append(makeSectionTitle("生图预设")); + body.append(field("输出语言", selectControl(LANGS, draft.lang, (v) => { + draft.lang = v; + // 换语言 → 未改过设定词时取该语言的「存档 → 默认」(同一模版) + if (!draft.presetTouched) draft.presetText = xbrPresetTextFor(node, draft.mode, v, xbrResolveIo(node, draft.ioMode)); + ctx.rerender(); + }))); + body.append(xbrHint("输出语言决定:词表与设定词按哪种语言加载、生成文本是中文还是英文。")); + body.append(field("空latent类型", selectControl(LATENT_KINDS, draft.kind, (v) => { draft.kind = v; }))); + body.append(xbrHint("空latent类型:选你正在用的模型即可(形状 / 下采样 / 尺寸步长自动适配 8/16/32)。")); + // ── 模版(文生图 / 图生图):决定用哪一套设定词(每个预设模式都有两套)── + body.append(field("模版", radioRow(XBR_IO_MODES, draft.ioMode, (v) => { + draft.ioMode = v; + // 换模版 → 未改过时立即换成对应那套设定词,改过的按「模式|模版|语言」存着不会被冲掉 + if (!draft.presetTouched) draft.presetText = xbrPresetTextFor(node, draft.mode, draft.lang, xbrResolveIo(node, v)); + ctx.rerender(); + }))); + body.append(xbrHint("模版:自动 = 按「🖼️ 图像」有没有接线判断;文生图 / 图生图 = 手动指定。每个预设模式都有两套设定词,切模版会直接换掉下面「设定词」那一栏的内容。")); + body.append(field("预设模式", selectControl(MODES, draft.mode, (v) => { + draft.mode = v; + // 换模式 → 立刻取该「模式 × 模版」的设定词(用户改过的存档 → 默认),改过的不会被冲掉 + draft.presetTouched = false; + draft.presetText = xbrPresetTextFor(node, v, draft.lang, xbrResolveIo(node, draft.ioMode)); + ctx.rerender(); // 有/无设定词的模式之间切换 → 重画「设定词」区 + }))); + // 当前模版 + 本档提示 + const needsImg = (typeof modeNeedsImage === "function") && modeNeedsImage(draft.mode); + const hasImgNow = (typeof modeHasImage === "function") && modeHasImage(node); + const ioNow = xbrResolveIo(node, draft.ioMode); + body.append(el("div", "font-size:11px;color:#8fb;line-height:1.7;margin:2px 0 4px;", + "当前模版:" + ioNow + + (draft.ioMode === "自动" ? ("(自动:" + (hasImgNow ? "检测到 🖼️ 图像已接线" : "未接 🖼️ 图像") + ")") + : "(手动指定)") + + (needsImg ? " ❗本档属于图生图档" : ""))); + body.append(el("div", "font-size:11px;color:" + (needsImg ? "#d9a441" : "#888") + ";line-height:1.7;margin:2px 0 8px;", + (draft.mode === MODE_TP) + ? "无预设:接不接参考图、哪个模版,都不会前置设定词(下面「设定词」栏保持空白;想加就自己写)。" + : (needsImg + ? (ioNow === "文生图" + ? "⚠️ 本档属于「图生图」,但当前是文生图模版:下面用的是从零生成的写法(不依赖任何输入图)。接图或把模版改成图生图即可切回去。" + : "本档属于「图生图」:设定词按输入图改写场景,需接参考图(🖼️ 图像)。") + : (ioNow === "图生图" + ? "本档属于「文生图」,当前用图生图模版:设定词末尾会追加「以输入图为准」的条款,需接参考图。" + : "本档属于「文生图」:不接图也行;接图后把模版改成「图生图」(或保持自动)会追加「以输入图为准」的条款。")))); + + // ── 设定词(按「模式 × 模版 × 语言」取;只在弹窗里编辑,节点表面不显示)── + // 无预设档:这一栏**照样显示**,只是内容空白(用户可以自己写) + const modeDef = presetTextOf(draft.mode, draft.lang, ioNow); + { + body.append(makeSectionTitle("设定词(" + ioNow + "模版)")); + draft.presetText = String(draft.presetText || ""); + if (modeDef && !draft.presetText.trim()) draft.presetText = modeDef; + const taP = textareaControl(draft.presetText, (v) => { draft.presetText = v; draft.presetTouched = true; }, + "width:100%;box-sizing:border-box;min-height:180px;resize:vertical;"); + taP.placeholder = modeDef + ? "该模式在「" + ioNow + "」模版下的设定词;改过的按「模式 + 模版 + 语言」记进节点,换模式 / 换模版 / 换语言都不丢" + : "无预设:本档不前置设定词(保持空白即可;在这里写内容就会作为设定词置顶)"; + taP.spellcheck = false; + body.append(taP); + const taRow = el("div", "display:flex;align-items:center;gap:10px;margin:6px 0 4px;"); + if (modeDef) { + taRow.append(smallBtn("♻️ 恢复默认", "border:1px solid #555;background:#2a2a2a;color:#ccc;padding:4px 10px;", + "把设定词恢复为该模式 × 该模版 × 该语言的默认文本", + () => { draft.presetText = modeDef; draft.presetTouched = false; ctx.rerender(); })); + } + taRow.append(el("span", "font-size:11px;color:#888;", + !modeDef ? (draft.presetText.trim() ? "当前 = 自定义" : "当前 = 空白(不前置设定词)") + : (draft.presetText === modeDef ? "当前 = 默认" : "当前 = 自定义"))); + body.append(taRow); + if (modeDef) { + body.append(el("div", "font-size:11px;color:#888;line-height:1.75;margin:6px 0 8px;", + "· 设定词会作为【参考设定】送给文本编码器;\n" + + "· 最终输出时,设定词由节点原封不动加在生成结果的最顶端;\n" + + "· 改过的设定词随节点保存,换模式 / 换模版 / 换语言都会取回你改过的那一版。")); + } else { + body.append(el("div", "font-size:11px;color:#888;line-height:1.75;margin:6px 0 8px;", + "· 无预设档:不管接不接参考图,节点都不会自动前置设定词;\n" + + "· 如果在这一栏写了内容,它会作为【参考设定】送模型、并原封不动置于最终提示词最顶端。")); + } + } + + body.append(makeSectionTitle("SKILL(系统提示词)")); + const skillOpts = xbrWidgetOptions(node, "skill_name"); + if (!skillOpts.length) skillOpts.push("不使用"); + if (!skillOpts.includes(draft.skill)) draft.skill = skillOpts[0]; + if (!XBR_SKILL_MODES.includes(draft.skillMode)) draft.skillMode = "自动"; + body.append(field("SKILL 模式", radioRow(XBR_SKILL_MODES, draft.skillMode, (v) => { draft.skillMode = v; ctx.rerender(); }))); + if (draft.skillMode === "手动") { + body.append(field("SKILL选择", selectControl(skillOpts, draft.skill, (v) => { draft.skill = v; }))); + } else if (draft.skillMode === "自动") { + const autoFile = (typeof modeSkillHint === "function") ? modeSkillHint(draft.mode) : "-"; + body.append(el("div", "font-size:11px;color:#8fb;margin:2px 0 8px;", + "自动:本档(" + draft.mode + ")→ " + autoFile)); + } else { + body.append(el("div", "font-size:11px;color:#d9a441;margin:2px 0 8px;", + "不用:SKILL 完全不生效(即使下面选过文件也不会送进模型)。")); + } + const foxRow = el("div", "display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:2px 0 8px;"); + foxRow.append(smallBtn("📂 打开 SKILL 文件夹", "border:1px solid #555;background:#2a2a2a;color:#ccc;padding:4px 10px;", + "在资源管理器里打开 support_llama/skills,方便新增 / 编辑技能文件", + async () => { + try { + const r = await fetch("/xb_toolbox/skill_folder"); + const j = await r.json().catch(() => ({})); + if (!r.ok || j.ok === false) notify("打开失败:" + (j.info || r.status), "error"); + else notify("已打开:" + (j.path || "skills"), "success"); + } catch (e) { notify("打开失败:" + ((e && e.message) || e), "error"); } + })); + body.append(foxRow); + body.append(xbrHint("技能文件放在 XB_ToolBox/support_llama/skills(.txt / .md):选中后整段作为模型自带系统提示词的替换内容(= 官方 Generate Text 的 system_prompt)。")); + body.append(el("div", "font-size:11px;color:#888;line-height:1.75;margin:6px 0 8px;", + "· 自动(默认)= 按预设模式适配:图生图档 → system_prompt_edit,其余 → system_prompt_t2i;\n" + + "· 手动 = 用「SKILL选择」里选中的那个文件;\n" + + "· 不用 = 完全不生效(哪怕选了文件);\n" + + "· 「🤖 LLM设置 → 使用内置模板」关闭时,SKILL 不生效(与官方同义);\n" + + "· 新增技能文件后刷新页面即可出现在下拉里。")); +} + +/* ── 弹窗内容:📖 使用说明 ───────────────────────────────── */ +function xbrRenderHelp(body) { + const lines = [ + "【一句话】", + " 生图提示词预设 + 官方 Load CLIP + 官方 Generate Text:", + " 表面挑词拼正文 → 文本编码器把正文写成完整提示词 → 设定词置顶输出", + "", + "【节点做什么(执行顺序)】", + " 1. 正文 = 提示词框(或外接 📝 提示词 端口)的字", + " 2. 送模型 = 【参考设定·本档设定词】+【正文】", + " (选中 SKILL 时,SKILL 全文另作为系统提示词送到最前面)", + " 3. 输出提示词 = 本档设定词原封不动置顶 + 模型生成的正文", + " 4. 输出空latent = 按空latent类型/画幅参数生成的空 latent", + "", + "【三步上手 · 文生图】", + " 1. 🤖 LLM设置 选模型(类型 qwen_image,设备 default)", + " 2. ✨ 预设参数 选输出语言 + 空latent类型 + 预设模式", + " (文生图组)+ SKILL 模式(保持自动即可)", + " 3. 📊 空latent 接采样器、📝 提示词 接文本编码器,点运行", + "", + "【三步上手 · 图生图(图像编辑)】", + " 1. ✨ 预设参数 选一个图生图档位(如 保持主体换场景)", + " 2. 参考图接 🖼️ 图像(多张就接多张,按连接顺序 = 第 1…N 张图)", + " 3. SKILL 模式保持「自动」:图生图档会自动用 system_prompt_edit.txt", + " 提示词框里写要求(改哪里 / 换成什么,例:把背景换成草地)", + " · 图生图不要接 📊 空latent:官方编辑链路是", + " 输入图 → VAEEncode → 采样", + " · 提示词输出接文本编码器/编辑节点即可", + "", + "【节点表面】", + " · 第 1 行按钮:🤖 LLM设置 | ✨ 预设参数", + " · 第 2 行按钮:🎨 风格 | 📐 视角 | 👤 主体 | 🕺 姿态 | 👗 装扮 | 🎒 道具 | 💡 光影 | 🏞️ 背景", + " · 画幅比例 / 宽度 / 高度 / 生成数量:按空latent类型的官方步长自动对齐", + " · 提示词框右上角 📋 复制;下面是参数设定显示,实时告诉你当前配置", + " · 没有开关:执行节点就会用文本编码器生成", + "", + "【元素面板怎么用】", + " · 点选项行 = 加入提示词,再点一下 = 移除", + " · 悬停选项行可以看它的中文与英文写法", + " · 【添加详细描述】给该词条写补充说明,会跟着一起成句", + " · ➕ / ➖ 自建专属词条,✎ 编辑名称", + " · 搜索框与「全部加入」只作用于当前子类", + "", + "【🤖 LLM设置】", + " · 模型选择 = 官方 Load CLIP:模型 / 类型 / 设备,枚举与官方节点逐字一致", + " · 参数设置 = 官方 Generate Text 全量参数:max_length | sampling_mode | temperature |", + " top_k | top_p | min_p | repetition_penalty | presence_penalty | seed |", + " thinking | use_default_template | mtp", + " · sampling_mode = off 是贪心解码,采样参数与种子都不参与", + " · 种子支持生成后控制:点 🔁 在 随机 / 固定 / 增加 / 减少 之间切换", + " · 同一套参数只保留一份模型缓存,换模型 / 换类型 / 换设备才重新加载", + "", + "【SKILL 技能(系统提示词)】", + " · 文件放 XB_ToolBox/support_llama/skills(.txt / .md)", + " 面板里点「📂 打开 SKILL 文件夹」可直接打开,新增后刷新页面即出现在下拉", + " · SKILL 模式(常驻三选一,默认自动):", + " ◆ 自动 = 按预设模式适配:图生图档 → system_prompt_edit.txt,其余 → system_prompt_t2i.txt", + " ◆ 手动 = 用「SKILL选择」里选中的那个文件", + " ◆ 不用 = SKILL 完全不生效(哪怕选过文件)", + " · 选中后整段作为模型的系统提示词(= 官方 Generate Text 的 system_prompt)", + " · 选中 SKILL 时不再叠加节点的【输出语言】提示——技能自带语言规则,避免打架", + " · 「使用内置模板」关掉时 SKILL 不生效(与官方同义)", + " · 这两个技能要求输出单行 JSON:正常,见【常见问题】", + "", + "【✨ 预设参数】", + " · 生图预设:输出语言 / 空latent类型 / 预设模式 / 设定词", + " · SKILL 模式(自动 / 手动 / 不用):见上", + " · 设定词:见上(无预设档则不前置)", + "", + "【文生图 / 图生图两套模版(全自动切换)】", + " · 没接 🖼️ 图像 → 走文生图模版:从零生成的写法,不会出现「参考输入图」类条款", + " · 接了 🖼️ 图像 → 走图生图模版:自动追加「以输入图为准」的条款,按序号引用第 1…N 张图", + " · 两种模版不用手动切:面板会显示当前用的是哪套 + 自动追加的条款原文", + " · 图生图档没接图也不会报错:会自动改成从零生成的写法(并在日志里提醒)", + "", + "【预设模式 · 文生图组(不需要图)】", + " · 无预设:不前置任何设定词,只输出正文", + " · 人物三视图 / 人物四视图 / 人物五视图:角色概念设计图", + " · 背景纯透明:RGBA 透明背景", + " · 图文版面:UI 截图 / 文档试卷 / 标题海报(文字逐字、版面层级)", + " · 信息图:多面板科普拆解卡(编号引线 / 图例 / 色卡 / 时间轴)", + " · 多格分镜:N 格叙事插画(格数排布 / 每格必须有主体 / 一致性强约束)", + " · 广告分镜板:商业脚本视觉板(画面格 + 时间轴 + 台词 + 镜头说明)", + "", + "【预设模式 · 图生图组(需接参考图,没接会自动降级为文生图写法)】", + " · 保持主体换场景:商品/人物原貌 100% 保持,换场景换装", + " · 局部编辑:只改指定或标记区域,其余完全不变", + " · 老照片修复:去噪去划痕 + 提清晰度 + 自然上色", + " · 整图风格化:整图转媒介/画风,构图与文字位置不变", + " · 360°全景:单视角照片扩成等距圆柱全景(2:1 无缝)", + " · 多图指认合成:按图号指认(场景取某张、角色/物件取其余张)", + "", + "【每档设定词都能改】", + " · 设定词会在最终提示词最顶端原封不动出现,也可只作为模型参考", + " · 改过的按「模式 + 语言」记在节点里,换模式 / 换语言都不丢", + " · 点 ♻️ 恢复默认 一键回到内置文本", + "", + "【参数怎么调】", + " · max_length:图文版面 / 信息图 / 多格分镜 / 广告分镜板 这几个长输出档建议 1024~2048,否则排版细则会被截断", + " · 想稳定复现:sampling_mode = off,或把生成后控制改成 固定", + " · 想更发散:temperature 调高、top_p 调大", + " · 思考模式:模型支持时更稳,长一点;思考内容不会进入输出", + "", + "【输入端口】", + " · 📝 提示词:外接提示词;接线后节点上的提示词框会锁定", + " · 🖼️ 图像:参考图;一批图 = N 张参考图(不抽帧),官方最多 10 张", + " 用「📦 批量图像」等多图节点直接接这里即可,按顺序 = 第 1…N 张图", + "", + "【输出端口】", + " · 📝 提示词:最终提示词 = 本档设定词置顶 + 生成正文", + " · 📊 空latent:文生图直接进采样器;图生图不要接它", + "", + "【空latent 类型】", + " Anima / Boogu / Flux2 / Hunyuan / Krea2 / Qwen-image / SD3 / SDXL / Z-image", + " 选你正在用的模型即可:通道数、下采样、尺寸步长、batch 上限会自动切换", + "", + "【常见问题】", + " · 输出是一段 JSON(含 rewritten_prompt 字段):", + " 这是 SKILL 自带的输出契约,属正常现象。", + " 想只取正文(去掉 JSON 外壳)可以加个解析开关,告诉我即可。", + " · 中文技能却输出英文描述:SKILL 里的语言规则优先,这符合技能设计", + " · 选了图生图档却没接图:日志会告警,结果只会按纯文字生成", + " · SKILL 看起来没生效:检查 🤖 LLM设置 → 使用内置模板 是否为开", + " · 报「未找到文本编码器模型」:把文本编码器放进 models/text_encoders 再在 🤖 LLM设置 里选它", + " · 加载失败先看类型:Qwen-Image 系文本编码器要选 qwen_image", + " · 报 HIP error: device kernel image is invalid / invalid kernel file:你的 ComfyUI 跑在核显上了", + " (多 GPU 机器上核显常被枚举成 cuda:0)→ 启动前设 HIP_VISIBLE_DEVICES=1 让 ComfyUI 用独显再试", + " · 生成慢 / 显存不够:设备改 cpu,或把 max_length 调小", + " · 结果太发散:温度调低、top_p 调小;想复现同一结果就把生成后控制改成固定", + " · 节点表面参数很少?故意的:选项都收在弹窗里", + " · 改完参数要重新执行节点才生效", + "", + "【保存】", + " · 弹窗底部:☑ 自动保存(默认开)、字号 A− / A+、界面 − / %", + " · 所有配置随工作流保存;「取消」丢弃未保存改动", + ]; + body.append(el("div", "font-size:12px;color:#bbb;line-height:1.9;white-space:pre-wrap;font-family:ui-monospace,Consolas,monospace;", lines.join("\n"))); +} + +const XBR_PANEL_RENDER = { llm: xbrRenderLlm, preset: xbrRenderPreset, help: xbrRenderHelp }; + +async function xbrOpenModal(node, panelId) { + if (xbrModal) { try { xbrModal.close(); } catch (_) {} } + const meta = XBR_PANEL_BUTTONS.find((b) => b.id === panelId) || XBR_PANEL_BUTTONS[0]; + const cur = xbrCurJson(node); + const autoSaveRef = { value: cur.auto_save !== false }; + const draft = { + settings: { qwen: xbrParseQwen(cur.qwen) }, + lang: xbrPick(String(xbrWidgetVal(node, "output_lang") ?? ""), LANGS, LANGS[0]), + kind: xbrPick(String(xbrWidgetVal(node, "latent_kind") ?? ""), LATENT_KINDS, LATENT_KINDS[0]), + mode: xbrPick(String(xbrWidgetVal(node, "preset_mode") ?? ""), MODES, MODES[0]), + // 模版(自动 / 文生图 / 图生图) + ioMode: (() => { const v = String(xbrWidgetVal(node, "io_mode") ?? ""); return XBR_IO_MODES.includes(v) ? v : "自动"; })(), + // 设定词(三视图/四视图/五视图/背景纯透明):弹窗内草稿 + 「用户是否改过」标记 + // widget 里是「遗留默认句 / 任一内置预设句」→ 视为没改过 → 取「存档 → 当前模版默认」(无预设 = 空) + presetText: (() => { + const w = String(xbrWidgetVal(node, "three_view_text") ?? ""); + const md = xbrPick(String(xbrWidgetVal(node, "preset_mode") ?? ""), MODES, MODES[0]); + const lg = xbrPick(String(xbrWidgetVal(node, "output_lang") ?? ""), LANGS, LANGS[0]); + const ioW = String(xbrWidgetVal(node, "io_mode") ?? ""); + if (w.trim() && !isDefaultPresetText(md, w)) return w; + return xbrPresetTextFor(node, md, lg, xbrResolveIo(node, XBR_IO_MODES.includes(ioW) ? ioW : "自动")); + })(), + presetTouched: false, + // SKILL 选择(support_llama/skills 里的技能文件 = system_prompt) + skill: String(xbrWidgetVal(node, "skill_name") ?? ""), + skillMode: (() => { const v = String(xbrWidgetVal(node, "skill_mode") ?? ""); return XBR_SKILL_MODES.includes(v) ? v : "自动"; })(), + // 官方 Load CLIP + clip_name: String(xbrWidgetVal(node, "clip_name") ?? ""), + clip_type: String(xbrWidgetVal(node, "clip_type") ?? "qwen_image"), + device: String(xbrWidgetVal(node, "device") ?? "default"), + // 官方 Generate Text 全量参数 + text: { + max_length: xbrInt(xbrWidgetVal(node, "max_length"), 512, 1, 32768), + sampling_mode: xbrPick(String(xbrWidgetVal(node, "sampling_mode") ?? "on"), XBR_SAMPLING_MODES, "on"), + temperature: xbrNum(xbrWidgetVal(node, "temperature"), 0.7, 0.01, 2), + top_k: xbrInt(xbrWidgetVal(node, "top_k"), 64, 0, 1000), + top_p: xbrNum(xbrWidgetVal(node, "top_p"), 0.95, 0, 1), + min_p: xbrNum(xbrWidgetVal(node, "min_p"), 0.05, 0, 1), + repetition_penalty: xbrNum(xbrWidgetVal(node, "repetition_penalty"), 1.05, 0, 5), + presence_penalty: xbrNum(xbrWidgetVal(node, "presence_penalty"), 0.0, 0, 5), + seed: xbrInt(xbrWidgetVal(node, "seed"), 0, 0, Number.MAX_SAFE_INTEGER), + ctl: String(xbrWidgetVal(node, XBR_SEED_CTL_WIDGET) ?? "randomize") || "randomize", + thinking: xbrFlag(xbrWidgetVal(node, "thinking"), false), + use_default_template: xbrFlag(xbrWidgetVal(node, "use_default_template"), true), + mtp: xbrPick(String(xbrWidgetVal(node, "mtp") ?? "auto"), XBR_MTP_MODES, "auto"), + }, + }; + + const commit = async () => { + xbrSetWidget(node, "output_lang", draft.lang, true); // 语言设置 + xbrSetWidget(node, "latent_kind", draft.kind, true); // 触发基础面板的步长/上限联动 + xbrSetWidget(node, "preset_mode", draft.mode, true); // 触发预设句框显隐 + xbrSetWidget(node, "io_mode", draft.ioMode); // 模版:自动 / 文生图 / 图生图 + xbrSetWidget(node, "skill_name", draft.skill); // SKILL = 官方 system_prompt + xbrSetWidget(node, "skill_mode", draft.skillMode); // 自动 / 手动 / 不用 + // 设定词:写进节点 widget(原样)+ 按「模式|模版|语言」存档 → 换模式 / 换模版 / 换语言都不丢 + // ⚠️ 一律写(包含「无预设」时写空串)→ 顺手把 widget 里的遗留默认句清掉 + try { + const ioCommit = xbrResolveIo(node, draft.ioMode); + node.__ippSetPreset?.(draft.presetText); + node.__ippWritePreset?.(draft.mode, ioCommit, draft.lang, draft.presetText); + } catch (_) {} + // 官方 Load CLIP + xbrSetWidget(node, "clip_name", draft.clip_name, true); + xbrSetWidget(node, "clip_type", draft.clip_type, true); + xbrSetWidget(node, "device", draft.device, true); + // 官方 Generate Text 全量参数 + const t = draft.text; + xbrSetWidget(node, "max_length", t.max_length); + xbrSetWidget(node, "sampling_mode", t.sampling_mode); + xbrSetWidget(node, "temperature", t.temperature); + xbrSetWidget(node, "top_k", t.top_k); + xbrSetWidget(node, "top_p", t.top_p); + xbrSetWidget(node, "min_p", t.min_p); + xbrSetWidget(node, "repetition_penalty", t.repetition_penalty); + xbrSetWidget(node, "presence_penalty", t.presence_penalty); + xbrSetWidget(node, "seed", t.seed); + xbrSetWidget(node, XBR_SEED_CTL_WIDGET, t.ctl); // 与原生种子控制同一份值 + xbrSetWidget(node, "thinking", t.thinking); + xbrSetWidget(node, "use_default_template", t.use_default_template); + xbrSetWidget(node, "mtp", t.mtp); + xbrSaveJson(node, { qwen: draft.settings.qwen, auto_save: autoSaveRef.value !== false }); + try { node.__qbrRefreshInfo?.(); } catch (_) {} + }; + const immediate = () => { xbrSaveJson(node, { auto_save: autoSaveRef.value !== false }); }; + + const readonly = (panelId === "help"); + const body = xbrDialog({ + label: meta.title, + title: meta.title, + subtitle: meta.subtitle, + autoSaveRef: readonly ? null : autoSaveRef, + onCommit: readonly ? null : commit, + onImmediate: readonly ? null : immediate, + onChange: readonly ? null : () => { try { node.__qbrRefreshInfo?.(); } catch (_) {} }, + }).body; + + const ctx = { + rerender: () => { body.replaceChildren(); renderInto(); }, + touch: () => {}, + }; + function renderInto() { + try { XBR_PANEL_RENDER[panelId](body, node, draft, ctx); } + catch (e) { body.append(el("div", "color:#e55;font-size:12px;", "面板渲染失败:" + ((e && e.message) || e))); } + } + renderInto(); +} + +/* ── 按钮区 / 提示词框标题行 / 参数设定显示 / 锁定 ─────────── */ +function xbrBuildButtonRow(node) { + const grid = el("div", "display:grid;grid-template-columns:repeat(" + Math.max(1, XBR_PANEL_BUTTONS.length) + ",1fr);grid-auto-rows:30px;gap:6px;flex:0 0 auto;"); + for (const b of XBR_PANEL_BUTTONS) { + const btn = el("button", "width:100%;height:30px;box-sizing:border-box;display:flex;align-items:center;justify-content:center;border-radius:6px;border:2px solid #5b9bd5;background:#3a3a3a;color:#eee;font-size:13px;cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-family:inherit;", b.label); + btn.title = `打开「${b.title}」弹窗`; + btn.dataset.xbrProBtn = b.id; + btn.addEventListener("mousedown", (e) => e.stopPropagation()); + btn.addEventListener("mouseenter", () => { btn.style.background = "#4a4a4a"; }); + btn.addEventListener("mouseleave", () => { btn.style.background = "#3a3a3a"; }); + btn.addEventListener("click", (e) => { e.stopPropagation(); xbrOpenModal(node, b.id); }); + grid.append(btn); + } + return grid; +} + +/** 显示窗取值:去掉括号注释与语言标签([ZH]/[EN]),只留干净的值 */ +function xbrClean(v) { + let s = String(v == null ? "" : v); + s = s.replace(/\s*[\[[][^\]]]*[\]]]\s*$/g, ""); // 尾部 [ZH] / [EN] 之类的语言标签 + s = s.replace(/([^)]*)|\([^)]*\)/g, ""); // 中英文括号注释 + return s.trim(); +} + +/** 参数设定显示(只读摘要 5 行)*/ +function xbrInfoLines(node, promptText) { + const qwen = xbrParseQwen(xbrCurJson(node).qwen); + const genLen = Number(node.__qbrGenLen || 0); + const model = xbrClean(xbrWidgetVal(node, "clip_name")); + const modelShort = String(model).split(/[\\/]/).pop() || model; // 只留文件名,不带目录 + const line1 = `📊 空 latent:${xbrClean(xbrWidgetVal(node, "latent_kind"))} | ${xbrWidgetVal(node, "width")}x${xbrWidgetVal(node, "height")} | 数量 ${xbrWidgetVal(node, "batch_size")}`; + const line2 = `🤖 LLM 模型:${modelShort || "未选择"}`; + const line3 = `⚙️ Text 参数: 最大长度 ${xbrWidgetVal(node, "max_length")} 丨 思考模式 ${xbrFlag(xbrWidgetVal(node, "thinking"), false) ? "开" : "关"} 丨 温度 ${xbrWidgetVal(node, "temperature")}`; + const ctlRaw = xbrClean(xbrWidgetVal(node, XBR_SEED_CTL_WIDGET)) || "randomize"; + const line4 = `✨ 生成种子:${xbrWidgetVal(node, "seed")} | ${XBR_SEED_LABEL[ctlRaw] || ctlRaw}`; + const line5 = `📝 文本数量:${genLen} 字`; + return [line1, line2, line3, line4, line5].join("\n"); +} + +/** 提示词框标题行(左标签 + 右复制)+ 参数设定显示;并挂上端口锁定/执行回写/摘要刷新 */ +function xbrBuildPromptHeader(node, promptBox) { + const head = el("div", "display:flex;align-items:center;gap:8px;flex:0 0 auto;"); + const lab = el("div", "font-size:12px;color:#bbb;", "📝 节点提示词(与面板预览框双向同步)"); + const copy = smallBtn("📋 复制", "margin-left:auto;", "复制提示词框内容", () => { copyText(promptBox.value || ""); }); + head.append(lab, copy); + + const infoBox = el("div", "flex:0 0 auto;height:150px;overflow:auto;background:#1c1c1e;border:1px solid #333;border-radius:6px;padding:6px 9px;font-size:11px;line-height:1.65;color:#b9c6d2;white-space:pre-wrap;box-sizing:border-box;font-family:ui-monospace,Consolas,monospace;"); + infoBox.dataset.captureWheel = "true"; + infoBox.title = "当前配置摘要(只读)"; + + const refresh = () => { try { infoBox.textContent = xbrInfoLines(node, promptBox.value); } catch (_) {} }; + node.__qbrRefreshInfo = refresh; + node.__ippRefreshInfo = refresh; // 基础面板改了设定词存档也会来刷这条摘要 + + // 「📝 提示词」端口接线 → 锁定提示词框(二选一) + const updateLock = () => { + try { + const inp = (node.inputs || []).find((i) => i.name === "text"); + const linked = !!inp && inp.link != null; + promptBox.readOnly = linked; + promptBox.style.background = linked ? "#141416" : "#1d1d1d"; + promptBox.style.color = linked ? "#8a8a8a" : "#ccc"; + promptBox.title = linked ? "已由「📝 提示词」端口控制,断开连线后恢复编辑" : ""; + lab.textContent = linked ? "📝 提示词已锁定(由「📝 提示词」端口控制)" : "📝 节点提示词(与面板预览框双向同步)"; + lab.style.color = linked ? "#d9a441" : "#bbb"; + } catch (_) {} + }; + const prevConn = node.onConnectionsChange; + node.onConnectionsChange = function (...a) { + const r = prevConn?.apply(this, a); + setTimeout(updateLock, 0); + return r; + }; + updateLock(); + + // 生成后控制:后端算好的「下一次种子」回写到 seed widget;同时记下本次生成文本字数(状态窗用) + const prevExecuted = node.onExecuted; + node.onExecuted = function (msg) { + const r = prevExecuted?.apply(this, arguments); + try { + const ns = msg?.seed?.[0]; + if (ns !== undefined && ns !== null && ns !== "") { + const cur = Number(xbrWidgetVal(node, "seed")); + if (Number(ns) !== cur) xbrSetWidget(node, "seed", Math.max(0, Math.round(Number(ns)) || 0)); + } + const gl = msg?.gen_len?.[0]; + if (gl !== undefined && gl !== null) node.__qbrGenLen = Number(gl) || 0; + refresh(); + } catch (_) {} + return r; + }; + + // 表面参数变化 → 摘要刷新(只 setDirtyCanvas,不动用 setSize) + for (const nm of ["latent_kind", "output_lang", "preset_mode", "three_view_text", "aspect_ratio", + "width", "height", "batch_size", "clip_name", "clip_type", "device", + "max_length", "sampling_mode", "temperature", "top_k", "top_p", "min_p", + "repetition_penalty", "presence_penalty", "seed", "thinking", + "use_default_template", "mtp"]) { + const w = xbrWidget(node, nm); + if (!w || w.__qbrHooked) continue; + w.__qbrHooked = true; + const orig = w.callback; + w.callback = function (...a) { + const r = orig?.apply(this, a); + setTimeout(refresh, 0); + return r; + }; + } + promptBox.addEventListener("input", () => { setTimeout(refresh, 0); }); + + setTimeout(refresh, 0); + return [head, infoBox]; +} diff --git a/js/xb_image_prompt_preset.js b/js/xb_image_prompt_preset.js index 9364f83..f34c32d 100644 --- a/js/xb_image_prompt_preset.js +++ b/js/xb_image_prompt_preset.js @@ -29,12 +29,71 @@ const NODE_TYPE = "XB_ImagePromptPreset"; const LANG_ZH = "中文 [ZH]"; const LANG_EN = "英文 [EN]"; const LANGS = [LANG_ZH, LANG_EN]; -const MODE_TP = "常规文生图"; +const MODE_TP = "无预设"; // 不前置任何设定词,只输出正文(旧名「常规文生图」见 MODE_ALIASES) const MODE_3V = "人物三视图"; const MODE_4V = "人物四视图"; const MODE_5V = "人物五视图"; const MODE_RGBA = "背景纯透明"; -const MODES = [MODE_TP, MODE_3V, MODE_4V, MODE_5V, MODE_RGBA]; +const MODE_UI = "图文版面"; +const MODE_INFO = "信息图"; +const MODE_STORYBOARD = "多格分镜"; +const MODE_ADBOARD = "广告分镜板"; +const MODE_KEEP_SUBJECT = "保持主体换场景"; +const MODE_LOCAL_EDIT = "局部编辑"; +const MODE_RESTORE = "老照片修复"; +const MODE_STYLIZE = "整图风格化"; +const MODE_PANO360 = "360°全景"; +const MODE_MULTI_REF = "多图指认合成"; +const MODES = [MODE_TP, MODE_3V, MODE_4V, MODE_5V, MODE_RGBA, MODE_UI, MODE_INFO, MODE_STORYBOARD, MODE_ADBOARD, MODE_KEEP_SUBJECT, MODE_LOCAL_EDIT, MODE_RESTORE, MODE_STYLIZE, MODE_PANO360, MODE_MULTI_REF]; +// 需要输入图的档位(图生图组)与推荐搭配的 SKILL +const MODE_ALIASES = { + "常规文生图": "无预设", // 0.10.x 旧名 → 新名(语义完全一致) + "📱 图文版面": "图文版面", + + "📊 信息图": "信息图", + + "🎞 多格分镜": "多格分镜", + + "🎬 广告分镜板": "广告分镜板", + + "🛍 保持主体换场景": "保持主体换场景", + + "✏️ 局部编辑": "局部编辑", + + "🧹 老照片修复": "老照片修复", + + "🎨 整图风格化": "整图风格化", + + "🌐 360°全景": "360°全景", + + "🗂 多图指认合成": "多图指认合成", + +}; +const MODE_NEEDS_IMAGE = [MODE_KEEP_SUBJECT, MODE_LOCAL_EDIT, MODE_RESTORE, MODE_STYLIZE, MODE_PANO360, MODE_MULTI_REF]; +const modeNeedsImage = (mode) => MODE_NEEDS_IMAGE.includes(mode); +const modeSkillHint = (mode) => (modeNeedsImage(mode) ? "system_prompt_edit.txt" : "system_prompt_t2i.txt"); + +// ── t2i / i2i 两套模版条款(与 nodes_image_prompt_preset.py 逐字一致,自测会断言)── +// · IO_I2I_COMMON:文生图档**接了图**时追加的「以输入图为准」条款 +// · 图生图档没接图时不再追加「忽略输入图」的尾条款 —— 改成直接用该档的「文生图版设定词」(PRESET_TEXT_T2I) +const IO_I2I_COMMON = {"zh": "【本次接了输入图】以输入图为唯一依据:保持输入图中被保留部分的原样不变(人物保持面容、发型、体型与气质;物件保持外形、比例、材质、颜色与文字标识),不得改动、美化、替换或增减我未指定的部分;本档规格只作用于画面的组织与重建区域。", "en": "Attached input image(s) are the single source of truth: keep everything kept from them unchanged — for people: face, hairstyle, build and character; for objects: shape, proportions, material, colour and printed text — and never alter, beautify, replace, add or remove anything I did not ask for. This mode's spec only governs how the frame is organised and rebuilt."}; +/** 按「本档类型 + 有没有输入图」返回要追加的模版条款(仅「文生图档 + 有图」→ 以输入图为准;其余 → "") + * ⚠️ 无预设档(MODE_TP)永远返回 "":接不接图,无预设的设定词都是空白 */ +function modeIoClause(mode, hasImage, lang) { + const m = (typeof MODE_ALIASES !== "undefined" && MODE_ALIASES[mode]) || mode; + const lg = (lang === LANG_EN) ? "en" : "zh"; + if (!m || m === MODE_TP || MODE_NEEDS_IMAGE.includes(m)) return ""; + return hasImage ? (IO_I2I_COMMON[lg] || "") : ""; +} +/** 节点是否接了参考图(🖼️ 图像 端口有连线) */ +function modeHasImage(node) { + try { + const inp = (node.inputs || []).find((i) => i.name === "images"); + return !!(inp && inp.link != null); + } catch (_) { return false; } +} + + const ASPECT_MAP = { "1:1": 1, "16:9": 16 / 9, "9:16": 9 / 16, "4:3": 4 / 3, "3:4": 3 / 4, "21:9": 21 / 9 }; const SIZE_STEP = 16; // width/height 的兜底步长(实际以「空latent类型」的官方最小步长为准) @@ -62,7 +121,7 @@ const LATENT_KIND_SPEC = { }; const DEFAULT_LATENT_KIND = "Z-image"; -/** 各预设模式的默认预设句(可编辑字段 three_view_text);常规文生图 = 无预设句(原样输出正文) */ +/** 各预设模式的默认预设句(可编辑字段 three_view_text);无预设 = 不前置(原样输出正文) */ const PRESET_TEXT_DEFAULT = { [MODE_3V]: { [LANG_ZH]: "生成平行排列的角色概念设计图,画面从左到右由四个独立面板组成:第一个面板是角色面部的精细特写肖像,第二个面板是人物正面全身站姿,第三个面板是人物侧面全身站姿,第四个面板是人物背面全身站姿。", @@ -80,9 +139,140 @@ const PRESET_TEXT_DEFAULT = { [LANG_ZH]: "生成一张具有透明度的 RGBA 格式图像,包含 Alpha 通道,背景为纯透明。", [LANG_EN]: "Generate an RGBA image with an alpha channel and a fully transparent background.", }, + [MODE_UI]: { + [LANG_ZH]: "这是一张完整的平面版面设计图,画面边缘即版面边缘,全出血排版,无多余边框与水印。【版面结构】按层级自上而下组织:① 顶部信息区(标题栏/状态栏,界面类需含时间、信号、电量等图标);② 主体内容区(卡片、栏位、区块,边距统一、圆角一致、层级分明、网格对齐);③ 底部信息区(按钮、标签、脚注)。【文字】画面中所有文字必须逐字使用我给出的原文,不得增删改字,不得生成任何我未指定的文字;字体清晰端正、字号字重按层级区分,无错字无乱码无伪文字。【风格】排版精致、留白充足、有呼吸感,配色统一克制;任何元素都不得遮挡文字。", + [LANG_EN]: "Produce one complete flat print layout: the image edge is the layout edge, full bleed, no extra frame or watermark. Structure top to bottom: (1) top information zone (title bar / status bar, with clock, signal and battery icons for UI); (2) main content zone (cards, columns and blocks with uniform margins, consistent corner radii, clear hierarchy, grid-aligned); (3) bottom information zone (buttons, tags, footnotes). All text must use my wording word for word, with no additions, deletions or substitutions, and no text I did not specify; crisp legible type, size and weight distinguished by hierarchy, no typos, no gibberish. Refined layout, generous breathing space, restrained unified palette; nothing may cover the text.", + }, + [MODE_INFO]: { + [LANG_ZH]: "这是一张信息图/科普拆解图,版式整洁、以信息可读性优先,具有教学与数据可视化气质。【版面】① 顶部标题栏(主标题 + 副标题/卷号/徽标);② 主体由多个信息模块组成,按需包含:结构拆解图、局部放大详图、编号引线说明、图例、参数表、色卡、雷达图、时间轴、关键词云;③ 模块之间用细分割线或留白分隔,网格对齐、边距统一。【编号与引线】主体图形用圆形编号点标注,旁侧以细引线连接简短说明,编号连续、不重复、不跳号。【文字】所有文字逐字使用我给出的原文,不得生成任何我未指定的文字,无错字无乱码。", + [LANG_EN]: "Produce an infographic / annotated explanation card: tidy layout, information legibility first, a teaching and data-visualisation feel. Layout: (1) a top title bar (main title plus subtitle, volume number or badge); (2) a body made of several information modules, optionally including a structural cutaway, zoomed detail views, numbered leader-line callouts, a legend, a parameter table, a colour swatch strip, a radar chart, a timeline and a keyword cloud; (3) modules separated by hairline rules or whitespace, grid-aligned with uniform margins. Numbering: mark the main graphic with circular numbered dots and connect short captions with thin leader lines; numbers are continuous and never repeated. All text must use my wording word for word, with no unspecified text, no typos, no gibberish.", + }, + [MODE_STORYBOARD]: { + [LANG_ZH]: "生成一张由多个连续分镜格组成的叙事插画,输出为一张完整拼合的扁平整图,不要输出多张分离图片。【版面硬性要求】① 格数与我要求一致,所有格等宽等高;② 严格按我要求的排布方式(单横排或网格),不得擅自拆格、并格或改行数;③ 每格是独立取景,格与格之间用细边框或清晰留白分隔;④ 每格左上角标注圆形数字编号,编号连续、不重复、不跳号不缺号。【内容硬性要求】每一格都必须画出主体本人在做该格的动作,主体清晰完整、占该格画面主体;严禁出现只有背景没有人物的空格;所有格的画风、光影、色彩与主体外观保持一致,仅姿势、表情、动作与场景变化。【文字】除编号外不要生成任何文字、字幕或水印。", + [LANG_EN]: "Produce a narrative illustration made of several sequential storyboard panels, delivered as one fully composited flat sheet, not multiple separate files. Layout (mandatory): (1) exactly the panel count I ask for, all panels equal in width and height; (2) strictly the arrangement I ask for (single horizontal row or grid), never splitting, merging or re-flowing panels; (3) each panel is its own framing, separated by thin borders or clear gutters; (4) a circular number badge in the top-left of every panel, continuous, never repeated or skipped. Content (mandatory): every panel must show the subject itself performing that panel's action, clearly and completely, occupying the panel's main subject; never leave a panel with only background and no character; painting style, lighting, colour and the subject's look stay consistent across panels, only pose, expression, action and setting change. No text, captions or watermarks other than the numbers.", + }, + [MODE_ADBOARD]: { + [LANG_ZH]: "生成一张商业广告分镜板/脚本视觉板,像专业品牌团队制作的提案板:既有分镜画面,也带脚本信息。【版面】① 顶部标题区(项目名/产品名 + 副标题 + 品牌标识感排版);② 主体横向排列若干连续分镜画面,每格独立取景、格间清晰分隔;③ 每格下方或侧方配栏目文字:镜头序号、时长或时间轴、镜头说明、字幕/台词、转场提示;④ 底部可放时间轴条与总时长。【风格】排版统一精致、配色克制高级,画面具有电影感与广告质感。【文字】画面中所有文字必须逐字使用我给出的原文,不得生成任何我未指定的文字,不得出现乱码、错字或伪文字。", + [LANG_EN]: "Produce a commercial advertising storyboard / script visual board, like a real brand team's pitch board: storyboard frames plus script information. Layout: (1) a top title zone (project or product name, subtitle, brand-like typography); (2) a body of several sequential storyboard frames in a row, each its own framing with clear separation; (3) caption blocks under or beside each frame with shot number, duration or timeline, shot description, subtitle or dialogue and transition note; (4) an optional timeline bar and total duration at the bottom. Style: unified refined layout, restrained premium palette, cinematic advertising quality. All text must use my wording word for word, with no unspecified text, no gibberish and no fake typography.", + }, + [MODE_KEEP_SUBJECT]: { + [LANG_ZH]: "以输入图像中的主体为唯一依据:先完整识别它的全部外观细节,再把它放入新的场景中。【必须保持】主体的外形、比例、结构、材质、颜色、文字与标识、磨损与光泽等全部细节与新画面完全一致;不得改造、不得美化、不得替换、不得增减部件;主体是人物时保持面容、五官、发型、体型与肤色不变。【必须移除】输入图中的摄影棚背景、手持、支架、阴影底板等非主体元素,以及任何不属于最终画面的杂物。【新画面】按我的要求重建场景、构图、光线与景深;主体与新场景的光影、透视、色温自然统一,接触面有合理投影,看起来就是在该空间里真实拍摄的一张画面。", + [LANG_EN]: "Treat the subject in the input image as the single source of truth: read every appearance detail first, then place it into a new scene. Must keep: shape, proportions, structure, material, colour, printed text and logos, wear and gloss identical to the input; do not redesign, beautify, replace, add or remove parts; for a person, keep face, features, hairstyle, build and skin tone. Must remove: studio backdrop, hands, stands, shadow board and any other non-subject elements or clutter. New frame: rebuild setting, composition, lighting and depth of field as I ask; the subject must match the new scene in light, perspective and colour temperature, sit on believable contact shadows, and look like a real photograph taken in that space.", + }, + [MODE_LOCAL_EDIT]: { + [LANG_ZH]: "只修改我指定或标记的区域,其余部分与输入图保持完全一致(包含构图、透视、光线、色调与清晰度)。【编辑范围】仅限我指定/标记的区域或部位;未标记的内容一律不得改动、不得重绘、不得重新打光、不得改变材质。【标记优先】若输入包含标记图、掩码图或涂抹标注,则以标注范围为准,只在该范围内操作。【过渡】修改区域与周边必须在材质、纹理与光影方向上自然衔接,边界不得出现生硬接缝、色块、描边或亮度跳变。【输出】只输出修改后的一整张完整画面,不要输出对比图、标注框、箭头或任何说明文字。", + [LANG_EN]: "Change only the region or part I specify or mark; everything else stays exactly as in the input (composition, perspective, lighting, colour and sharpness). Scope: only my specified or marked region; anything unmarked must not be altered, repainted, relit or re-materialised. Marking wins: if the input includes an annotated, masked or painted region, operate strictly inside it. Blending: the edit must match its surroundings in material, texture and light direction, with no hard seams, colour patches, outlines or brightness jumps. Output one single finished image only, with no comparison view, boxes, arrows or explanatory text.", + }, + [MODE_RESTORE]: { + [LANG_ZH]: "把输入的老照片修复成清晰、自然、真实的彩色照片,并保持原照片的内容与人物五官解剖完全不变。【修复】去除噪点、划痕、斑点、折痕、褪色与颗粒,恢复细节与层次、平衡高光与阴影;不得过度锐化,不得出现塑料感、蜡感或油画涂抹感。【上色】按真实肤色与材质自然上色,色调统一可信,不做夸张调色。【保持】人物面部结构、皱纹、神态、视线、头部角度、手部与持物关系、服装材质与褶皱、背景陈设与景深关系均不得改变。【输出】只输出修复后的一整张完整画面,不加边框、不加文字或水印。", + [LANG_EN]: "Restore the input vintage photograph into a sharp, natural, realistically colourised photo, keeping the original content and facial anatomy unchanged. Repair: remove noise, scratches, spots, creases, fading and grain; recover detail and tonal range and balance highlights and shadows; never oversharpen, never produce waxy, plastic or painterly skin. Colour: natural believable skin and material tones, unified grading, no exaggerated stylisation. Keep: facial structure, wrinkles, expression, gaze, head angle, hands and how they hold objects, garment material and folds, background props and depth of field. Output one single finished image with no border, text or watermark.", + }, + [MODE_STYLIZE]: { + [LANG_ZH]: "把输入图整张转换为指定的视觉媒介与画风,转换必须覆盖整幅画面,不得只做局部滤镜或只改一部分物体。【保持】原图的构图、画幅比例、视角、主体位置与姿态、景物之间的空间关系、已有文字与招牌的位置及可读内容不得改变;不得新增标语、字幕或水印。【转换】按我要求的画风重绘每一个物体:笔触、色层、材质表现与光色关系统一;光的方向与原图一致。【输出】只输出转换后的一整张完整画面。", + [LANG_EN]: "Convert the whole input image into the requested visual medium and style; the conversion must cover the entire frame, not a local filter or only some objects. Keep: composition, aspect ratio, viewpoint, subject placement and pose, spatial relations between objects, and the position and legible content of existing signage and text; add no slogans, captions or watermarks. Convert: repaint every object in the requested style with consistent brushwork, colour layering, material rendering and light-colour relationships; light direction matches the original. Output one single converted image.", + }, + [MODE_PANO360]: { + [LANG_ZH]: "把输入的单视角照片扩展成一张完整的 360 度全景图。【投影】使用真正的等距圆柱投影(equirectangular),水平覆盖 360 度、垂直覆盖 180 度,包含头顶天空与脚下地面;左右两端无缝衔接,形成一张连续完整的场景。【补全】以输入图为基础,把相机背后的环境合理延伸(地面、墙体、植被、天空,光照方向一致),不得出现重复、断裂或接缝。【保持】输入图中的主体只出现一次,其外观、姿态以及与周围景物的相对关系保持原样,不得镜像或复制。【输出】严格 2:1 画幅比例,例如 2880x1440。", + [LANG_EN]: "Extend the input single-view photograph into a complete 360-degree panorama. Projection: true equirectangular, covering 360 degrees horizontally and 180 degrees vertically, including the sky overhead and the ground below; left and right edges must join seamlessly into one continuous scene. Completion: extend the environment behind the camera plausibly (ground, walls, vegetation, sky, consistent light direction) with no repeated or broken areas. Keep: the subject appears exactly once, with its appearance, pose and relationship to surrounding objects unchanged, never mirrored or duplicated. Output at exactly a 2:1 aspect ratio, for example 2880x1440.", + }, + [MODE_MULTI_REF]: { + [LANG_ZH]: "按图号使用我提供的多张参考图,合成一张全新画面。【指认规则】场景与环境取自我指定的那一张图;其余各图分别提供角色外观或物件本体;我未指认的图不参与画面。【保持】被引用的角色保持面容、发型与体型一致;被引用的物件保持形状、材质、颜色与标识文字一致;不得替换、美化或增减部件。【重建】按我的要求安排构图、站位、光线与景深,所有元素统一在同一空间的光影与透视中,接触关系与投影合理。【文字】除我明确要求的外,不生成任何文字或水印。", + [LANG_EN]: "Use the reference images I provide by their index numbers and composite one brand-new image. Mapping: the scene and environment come from the image I designate; the other images supply either character likeness or an object itself; images I do not reference do not appear. Keep: referenced characters keep the same face, hairstyle and build; referenced objects keep the same shape, material, colour and printed text; no replacement, beautifying, additions or removals. Rebuild: arrange composition, staging, lighting and depth of field as I ask; all elements share one consistent space with matching light, perspective, contact and shadows. No text or watermark unless I explicitly ask for it.", + }, }; /** 某模式某语言下的默认预设句(无预设句的模式 → 空串) */ const defaultPresetOf = (mode, lang) => ((PRESET_TEXT_DEFAULT[mode] || {})[lang] || ""); + +/* ── 模版(文生图 / 图生图):每个预设模式都有两套设定词 ──────────────────────── + * · 自动 = 按有没有接参考图判断(没接 → 文生图,接了 → 图生图) + * · 图生图档(6 档):PRESET_TEXT_DEFAULT = 图生图版,PRESET_TEXT_T2I = 文生图版 + * · 文生图档(8 档):PRESET_TEXT_DEFAULT = 文生图版,「图生图」版 = 原文 + 以输入图为准条款 + * ⚠️ 必须与 nodes_image_prompt_preset.py 的 IO_MODES / PRESET_TEXT_T2I / preset_text_for 逐字一致(自测断言) + */ +const IO_AUTO = "自动"; +const IO_T2I = "文生图"; +const IO_I2I = "图生图"; +const IO_MODES = [IO_AUTO, IO_T2I, IO_I2I]; +/** 图生图档专用文本(文生图模版) */ +const PRESET_TEXT_T2I = { + "保持主体换场景": { + "zh": "生成一张主体清晰的画面:先完整确定主体的全部外观细节,再把它放进我要求的新场景中。【必须保持】主体的外形、比例、结构、材质、颜色、文字与标识、磨损与光泽等全部细节前后一致、符合真实物理;不得改造、不得美化、不得替换、不得增减部件;主体是人物时保持面容、五官、发型、体型与肤色自然可信。【必须干净】画面中不得出现摄影棚背景、手持、支架、阴影底板等杂物,也不得有任何不属于最终画面的元素。【新画面】按我的要求营造场景、构图、光线与景深;主体与新场景的光影、透视、色温自然统一,接触面有合理投影,看起来就是在该空间里真实拍摄的一张画面。", + "en": "Generate a frame with a clearly defined subject: first settle every appearance detail of the subject, then place it into the new scene I ask for. Must keep: shape, proportions, structure, material, colour, printed text and logos, wear and gloss stay self-consistent and physically believable; do not redesign, beautify, replace, add or remove parts; for a person, keep face, features, hairstyle, build and skin tone natural and convincing. Must be clean: no studio backdrop, hands, stands, shadow board or any element that does not belong in the final frame. New frame: build the setting, composition, lighting and depth of field as I ask; the subject must match the scene in light, perspective and colour temperature, sit on believable contact shadows, and look like a real photograph taken in that space." + }, + "局部编辑": { + "zh": "生成一张完整画面,并把我的改动直接做进画面里。【改动范围】只按我的要求改我指定的部位,其余内容保持稳定一致:不得顺手重绘、不得重新打光、不得改变材质与配色。【过渡】改动区域与周边必须在材质、纹理与光影方向上自然衔接,边界不得出现生硬接缝、色块、描边或亮度跳变。【输出】只输出最终的一整张完整画面,不要输出对比图、标注框、箭头或任何说明文字。", + "en": "Generate one complete frame with my change built directly into the image. Scope: change only the part I ask for; keep everything else stable and consistent — do not incidentally repaint, relight, or alter materials and palette. Blending: the changed area must match its surroundings in material, texture and light direction, with no hard seams, colour patches, outlines or brightness jumps. Output one single finished frame only, with no comparison view, boxes, arrows or explanatory text." + }, + "老照片修复": { + "zh": "直接生成一张清晰、自然、细节完整的照片,并带有老照片翻新后的质感。【画面】主体与场景按我的描述生成,人物面部结构、五官解剖与比例自然准确,手部与持物关系合理。【修复感】不要噪点、划痕、斑点、折痕、褪色与颗粒,细节与层次完整、高光与阴影平衡;不得过度锐化,不得出现塑料感、蜡感或油画涂抹感。【色彩】按真实肤色与材质自然上色,色调统一可信,不做夸张调色。【输出】只输出一整张完整画面,不加边框、不加文字或水印。", + "en": "Generate directly a sharp, natural, fully detailed photograph with the look of a restored vintage print. Frame: build the subject and setting from my description, with natural accurate facial structure, anatomy and proportions, and believable hands and grip. Restoration feel: no noise, scratches, spots, creases, fading or grain; complete detail and tonal range, balanced highlights and shadows; never oversharpen, never produce waxy, plastic or painterly skin. Colour: natural believable skin and material tones, unified grading, no exaggerated stylisation. Output one single finished image with no border, text or watermark." + }, + "整图风格化": { + "zh": "生成一张完整的全新画面,并整体转换成我要求的视觉媒介与画风,转换必须覆盖整幅画面,不得只做局部滤镜或只改一部分物体。【画面】构图、画幅比例、视角与主体由我的描述决定,主体位置与姿态清晰合理。【转换】画面里的每一个物体都按该画风重绘:笔触、色层、材质表现与光色关系统一;光的方向明确一致。【文字】除我明确要求的外不生成任何文字、标语、字幕或水印。【输出】只输出一整张完整画面。", + "en": "Generate one complete new frame and convert the whole of it into the requested visual medium and style; the conversion must cover the entire frame, not a local filter or only some objects. Frame: composition, aspect ratio, viewpoint and subject come from my description, with the subject clearly and plausibly placed and posed. Convert: repaint every object in that style with consistent brushwork, colour layering, material rendering and light-colour relationships; keep one clear light direction. Text: no text, slogans, captions or watermarks unless I explicitly ask for them. Output one single complete frame." + }, + "360°全景": { + "zh": "生成一张完整的 360 度全景图。【投影】使用真正的等距圆柱投影(equirectangular),水平覆盖 360 度、垂直覆盖 180 度,包含头顶天空与脚下地面;左右两端无缝衔接,形成一张连续完整的场景。【补全】以输入图为基础,把相机背后的环境合理延伸(地面、墙体、植被、天空,光照方向一致),不得出现重复、断裂或接缝。【保持】输入图中的主体只出现一次,其外观、姿态以及与周围景物的相对关系保持原样,不得镜像或复制。【输出】严格 2:1 画幅比例,例如 2880x1440。", + "en": "Generate a complete 360-degree panorama. Projection: true equirectangular, covering 360 degrees horizontally and 180 degrees vertically, including the sky overhead and the ground below; left and right edges must join seamlessly into one continuous scene. Completion: extend the environment behind the camera plausibly (ground, walls, vegetation, sky, consistent light direction) with no repeated or broken areas. Keep: the subject appears exactly once, with its appearance, pose and relationship to surrounding objects unchanged, never mirrored or duplicated. Output at exactly a 2:1 aspect ratio, for example 2880x1440." + }, + "多图指认合成": { + "zh": "直接按我的描述生成一张全新画面。【画面】按我的要求安排主体、场景、构图、站位、光线与景深,所有元素统一在同一空间的光影与透视中,接触关系与投影合理。【一致性】同一主体在各处的外观保持一致,不得替换、美化或增减部件。【文字】除我明确要求的外,不生成任何文字或水印。", + "en": "Generate one brand-new image directly from my description. Frame: arrange subjects, setting, composition, staging, lighting and depth of field as I ask; all elements share one consistent space with matching light, perspective, contact and shadows. Consistency: keep each subject's look identical everywhere, with no replacement, beautifying, additions or removals. Text: no text or watermark unless I explicitly ask for it." + } + }; +const ioModeOf = (v) => { const s = String(v == null ? "" : v).trim(); return IO_MODES.includes(s) ? s : IO_AUTO; }; +/** 把「自动」按有没有接参考图解析成 文生图 / 图生图 */ +const resolveIoMode = (io, hasImage) => { + const m = ioModeOf(io); + if (m !== IO_AUTO) return m; + return hasImage ? IO_I2I : IO_T2I; +}; +/** 该模式的默认模版(图生图档默认图生图,其余默认文生图) */ +const modeDefaultIo = (mode) => (MODE_NEEDS_IMAGE.includes(mode) ? IO_I2I : IO_T2I); +/** 该模式在某模版下的**默认**设定词(io 已归一为 文生图 / 图生图) + * ⚠️ PRESET_TEXT_DEFAULT 的键是「中文 [ZH]/英文 [EN]」,PRESET_TEXT_T2I 的键是 "zh"/"en" */ +const presetTextOf = (mode, lang, io) => { + if (!MODES.includes(mode)) return ""; + if (mode === MODE_TP) return ""; // 无预设:接不接图、哪个模版 → 设定词都是空白 + const lg = (lang === LANG_EN) ? "en" : "zh"; + const base = ((PRESET_TEXT_DEFAULT[mode] || {})[lang] || ""); + if (io === IO_T2I) { + if (!MODE_NEEDS_IMAGE.includes(mode)) return base; // 文生图档 → 原文 + return (((PRESET_TEXT_T2I[mode] || {})[lg]) || base); // 图生图档 → 文生图版 + } + if (MODE_NEEDS_IMAGE.includes(mode)) return base; // 图生图档 → 原文 + const clause = modeIoClause(mode, true, lang); // 文生图档 → 原文 + 条款 + return clause ? ((base ? base + " " : "") + clause).trim() : base; +}; +/** 该模式所有模版 × 所有语言的默认文本(判断「用户没改过」用) + * · 无预设档额外把它算「默认」:IO_I2I_COMMON 条款(早期版本在「图生图模版」下把它当设定词 + * 存进了 widget / 存档)+ **所有内置预设句**(节点上 three_view_text 的老默认值就是三视图 + * 预设句 → 无预设档必须当空白,不能当成用户自定义继续前置) */ +const allDefaultTextsOf = (mode) => { + const out = []; + for (const lg of LANGS) for (const io of [IO_T2I, IO_I2I]) { + const t = presetTextOf(mode, lg, io).trim(); + if (t && !out.includes(t)) out.push(t); + } + if (mode === MODE_TP) { + for (const k of ["zh", "en"]) { + const c = String(IO_I2I_COMMON[k] || "").trim(); + if (c && !out.includes(c)) out.push(c); + } + for (const table of [PRESET_TEXT_DEFAULT, PRESET_TEXT_T2I]) { + for (const texts of Object.values(table)) { + for (const t of Object.values(texts || {})) { + const s = String(t || "").trim(); + if (s && !out.includes(s)) out.push(s); + } + } + } + } + return out; +}; +/** 存档值是不是「没改过」(等于任一默认 / 旧条款)→ 是则不算自定义 */ +const isDefaultPresetText = (mode, text) => !!String(text || "").trim() + && allDefaultTextsOf(mode).includes(String(text).trim()); /** 兼容旧引用:三视图预设句 */ const THREE_VIEW_DEFAULT = PRESET_TEXT_DEFAULT[MODE_3V]; @@ -94,15 +284,20 @@ const THREE_VIEW_DEFAULT = PRESET_TEXT_DEFAULT[MODE_3V]; * { "人物三视图|中文 [ZH]": "用户改过的设定词", … } ← 只存与默认不同的那条 * 键里带语言:默认设定词本身就是中英两套,用户改的哪一套就记哪一套。 */ -const presetKeyOf = (mode, lang) => `${mode}|${lang}`; +const presetKeyOf = (mode, io, lang) => `${mode}|${io}|${lang}`; function parsePresetTexts(raw) { const out = {}; if (!raw || typeof raw !== "object") return out; for (const [k, v] of Object.entries(raw)) { - const seg = String(k).split("|"); - if (seg.length !== 2 || !MODES.includes(seg[0]) || !LANGS.includes(seg[1])) continue; if (typeof v !== "string" || !v.trim()) continue; - out[`${seg[0]}|${seg[1]}`] = v; // 保留原文(含换行 / 尾随空格) + const seg = String(k).split("|"); + if (seg.length === 3) { // 新格式:模式|模版|语言 + if (!MODES.includes(seg[0]) || !IO_MODES.includes(seg[1]) || !LANGS.includes(seg[2])) continue; + out[`${seg[0]}|${seg[1]}|${seg[2]}`] = v; + } else if (seg.length === 2) { // 旧格式:模式|语言 → 按该档默认模版归档 + if (!MODES.includes(seg[0]) || !LANGS.includes(seg[1])) continue; + out[`${seg[0]}|${modeDefaultIo(seg[0])}|${seg[1]}`] = v; + } } return out; } @@ -1545,10 +1740,10 @@ function rebuildBody(settings, index, lang) { return parts.join(sepOf(lang)); } /** 最终成句(与后端 build_prompt 逐行一致:预设句 + 正文)—— 三个值均来自节点表面参数 */ -function finalPrompt(mode, presetText, lang, body) { +function finalPrompt(mode, presetText, lang, body, io) { const core = String(body || "").trim(); - if (!PRESET_TEXT_DEFAULT[mode]) return core; // 常规文生图 → 无预设句 - const preset = String(presetText || "").trim() || defaultPresetOf(mode, lang); + if (!PRESET_TEXT_DEFAULT[mode]) return core; // 无预设 → 不前置 + const preset = String(presetText || "").trim() || presetTextOf(mode, lang, io || IO_T2I); if (!core) return preset; if (lang === LANG_EN) return preset.replace(/[.\s]+$/, "") + ". " + core; return preset.replace(/[。..\s]+$/, "") + "。" + core; @@ -1659,11 +1854,11 @@ function openPanel(ctx, panelId) { const refreshPreview = (fromEl) => { const body = ctx.body.get(); if (fromEl !== preview) preview.value = body; - headCount.textContent = `${body.length} 字 → 成句 ${finalPrompt(surf.mode, surf.presetText, surf.lang, body).length} 字`; + headCount.textContent = `${body.length} 字 → 成句 ${finalPrompt(surf.mode, surf.presetText, surf.lang, body, surf.io).length} 字`; const hasPreset = !!PRESET_TEXT_DEFAULT[surf.mode]; presetChip.style.display = hasPreset ? "block" : "none"; if (hasPreset) { - const t = String(surf.presetText || "") || defaultPresetOf(surf.mode, surf.lang); + const t = String(surf.presetText || "") || presetTextOf(surf.mode, surf.lang, surf.io || IO_T2I); presetChip.textContent = `📌 自动前置(${surf.mode}设定词,可在节点表面「预设句」框里改;改过的按模式记住、换模式不丢):` + (t.length > 150 ? t.slice(0, 150) + "…" : t); } }; @@ -1966,7 +2161,7 @@ function renderCategoryPanel(box, api, catId) { notify(t ? "已按已选元素重建正文" : "当前没有已选元素(重建后正文为空)", t ? "success" : "warning"); }), smallBtn("📋 复制成句", "border:1px solid #555;background:#2a2a2a;color:#ccc;padding:6px 12px;", "复制最终成句(三视图模式下含预设句)", () => { - copyText(finalPrompt(surf.mode, surf.presetText, surf.lang, bodyGet())); + copyText(finalPrompt(surf.mode, surf.presetText, surf.lang, bodyGet(), surf.io)); }), smallBtn("⬆️ 导出词表", "border:1px solid #6b9b5b;background:#3a5a2a;color:#d7f0c8;padding:6px 12px;", "把「自建槽位 + 已选 + 描述」导出成 JSON 备份", () => exportJson(s)), smallBtn("⬇️ 导入词表", "border:1px solid #6b9b5b;background:#3a5a2a;color:#d7f0c8;padding:6px 12px;", "从 JSON 恢复「自建槽位 + 已选 + 描述」(合并,不覆盖内置词表)", () => importJson((obj) => { @@ -2487,6 +2682,9 @@ function setupNode(node) { }; snapshotWidget(w3v); + // 模版切换(文生图 / 图生图):基础节点没有这个 widget → null,逻辑自动按「文生图」走 + const wIo = findWidget(node, "io_mode"); + // ── 预设句(设定词)存档:用户改过的那条按「模式|语言」存进节点 ────────────── // 切换模式 / 切换语言一律取「该键的存档 → 该键的默认」,所以用户改过的设定词不会被冲掉。 // ⚠️ 写入用「原样 JSON 合并」——不能走 persistSettings(那会丢掉 Pro 的 llm 段等其它键)。 @@ -2495,20 +2693,25 @@ function setupNode(node) { try { return parsePresetTexts(parseSettings(readWidgetValue(findWidget(node, "manager_settings"))).preset_texts); } catch (_) { return {}; } }; - const savedPresetOf = (mode, lang) => presetStoreOf()[presetKeyOf(mode, lang)] || ""; - /** 该「模式 × 语言」最终生效的设定词:用户改过的存档优先,否则用默认 */ - const presetTextFor = (mode, lang) => savedPresetOf(mode, lang) || defaultPresetOf(mode, lang); + const savedPresetOf = (mode, io, lang) => presetStoreOf()[presetKeyOf(mode, io, lang)] || ""; + /** 该「模式 × 模版 × 语言」最终生效的设定词:用户改过的存档优先,否则用默认 + * (存档值等于任一默认 / 旧条款 → 视为「没改过」 → 用当前模版的默认) */ + const presetTextFor = (mode, io, lang) => { + const s = savedPresetOf(mode, io, lang); + if (s && !isDefaultPresetText(mode, s)) return s; + return presetTextOf(mode, lang, io); + }; /** 存 / 清一条设定词存档(与默认一致 → 清掉存档,保持工作流干净) */ - const writePresetStore = (mode, lang, text) => { + const writePresetStore = (mode, io, lang, text) => { try { let data = {}; try { data = JSON.parse(String(readWidgetValue(findWidget(node, "manager_settings")) || "{}")) || {}; } catch (_) { data = {}; } if (!data || typeof data !== "object") data = {}; const store = parsePresetTexts(data.preset_texts); - const key = presetKeyOf(mode, lang); + const key = presetKeyOf(mode, io, lang); const t = String(text ?? ""); - if (!t.trim() || t === defaultPresetOf(mode, lang)) delete store[key]; + if (!t.trim() || t === presetTextOf(mode, lang, io)) delete store[key]; else store[key] = t; if (Object.keys(store).length) data.preset_texts = store; else delete data.preset_texts; setWidgetValue(findWidget(node, "manager_settings"), JSON.stringify(data)); @@ -2579,9 +2782,12 @@ function setupNode(node) { promptBox.addEventListener("input", () => { setWidgetValue(ipWidget, promptBox.value); }); // ── 表面参数(原「⚙️ 输出设置」面板的 4 项已上提到节点表面)── + /** 当前模版(文生图 / 图生图):自动 → 按「🖼️ 图像」有没有接线判断 */ + const curIoMode = () => resolveIoMode(ioModeOf(wIo ? readWidgetValue(wIo) : ""), modeHasImage(node)); const surfaceOf = () => ({ lang: pickOpt(LANGS, readWidgetValue(wLang), LANG_ZH), mode: pickOpt(MODES, readWidgetValue(wMode), MODE_TP), + io: curIoMode(), kind: pickOpt(LATENT_KINDS, readWidgetValue(wKind), DEFAULT_LATENT_KIND), presetText: String(readWidgetValue(w3v) ?? ""), }); @@ -2655,7 +2861,8 @@ function setupNode(node) { if (v === lastAppliedPreset) return; // 程序化写入的回声 → 忽略 lastAppliedPreset = v; setWidgetValue(w3v, v); - writePresetStore(surfaceOf().mode, surfaceOf().lang, v); + const s = surfaceOf(); + writePresetStore(s.mode, s.io, s.lang, v); } catch (_) {} }; live.addEventListener("change", commitUserEdit); @@ -2682,7 +2889,7 @@ function setupNode(node) { try { node.setDirtyCanvas?.(true, true); } catch (_) {} }; - // 预设句输入框:常规文生图以外的 4 个模式(三视图/四视图/五视图/背景纯透明)都显示(一次性隐藏/显示,不做任何 setSize,不会抖动) + // 预设句输入框:无预设以外的模式(三视图/四视图/五视图/背景纯透明 等)都显示(一次性隐藏/显示,不做任何 setSize,不会抖动) const applyPresetTextVisibility = () => { try { if (PRESET_TEXT_DEFAULT[surfaceOf().mode]) { showWidget(w3v); stylePresetTextBox(); } else hideWidget(w3v); @@ -2751,16 +2958,23 @@ function setupNode(node) { const cur = String(readWidgetValue(w3v) ?? ""); if (cur === lastAppliedPreset) return; // 程序化写入的回声 → 忽略 lastAppliedPreset = cur; - writePresetStore(surfaceOf().mode, surfaceOf().lang, cur); + const s = surfaceOf(); + writePresetStore(s.mode, s.io, s.lang, cur); }); - hook(wMode, () => { // 换模式 = 显/隐预设句框 + - const { mode, lang } = surfaceOf(); // 取该模式的「用户存档 → 默认」 - if (defaultPresetOf(mode, lang)) setPresetText(presetTextFor(mode, lang)); // (改过的设定词不会被冲掉) + hook(wMode, () => { // 换模式 = 显/隐设定词框 + + const { mode, lang, io } = surfaceOf(); // 取该「模式 × 模版」的「用户存档 → 默认」 + setPresetText(presetTextFor(mode, io, lang)); // (改过的设定词不会被冲掉;无预设 → 写空) applyPresetTextVisibility(); }); - hook(wLang, () => { // 换语言 = 同模式按下语言取「存档 → 默认」 - const { mode, lang } = surfaceOf(); - if (defaultPresetOf(mode, lang)) setPresetText(presetTextFor(mode, lang)); + hook(wLang, () => { // 换语言 = 同模式同模版按下语言取「存档 → 默认」 + const { mode, lang, io } = surfaceOf(); + setPresetText(presetTextFor(mode, io, lang)); + }); + hook(wIo, () => { // 换模版(文生图/图生图)= 换设定词那一套 + const { mode, lang, io } = surfaceOf(); + setPresetText(presetTextFor(mode, io, lang)); + try { node.__qbrRefreshInfo?.(); } catch (_) {} + try { node.__xbrRefreshInfo?.(); } catch (_) {} }); hook(wB, () => { // batch 超出当前类型上限 → 就近钳制 const sp = latentSpec(surfaceOf().kind); @@ -2952,10 +3166,16 @@ function setupNode(node) { syncPromptBoxFromWidget(); applyKindLimits(); // 按当前空latent类型同步 step / 上下限 applyPresetTextVisibility(); - // 预设句:以「用户存档 → 默认」为准(widget 值本就随工作流存着;这里兜底空值 / 被清空的情况) + // 设定词:以「用户存档 → 当前模版默认」为准 + // · 空 → 当前模版默认(无预设 → 默认就是空 → 保持空); + // · 等于别的模版 / 语言的默认 / 任一内置预设句(老工作流存的是另一套,或节点老默认句)→ 也换成当前模版默认; + // · 用户改过的 → 一个字不动。 { - const m = surfaceOf().mode, l = surfaceOf().lang; - if (defaultPresetOf(m, l) && !String(readWidgetValue(w3v) ?? "").trim()) setPresetText(presetTextFor(m, l)); + const m = surfaceOf().mode, l = surfaceOf().lang, iom = surfaceOf().io; + const nowTxt = presetTextOf(m, l, iom); + const cur = String(readWidgetValue(w3v) ?? "").trim(); + if (!cur) { if (nowTxt) setPresetText(presetTextFor(m, iom, l)); } + else if (cur !== nowTxt && allDefaultTextsOf(m).includes(cur)) setPresetText(presetTextFor(m, iom, l)); } const ar = String(readWidgetValue(wAR) ?? "Free"); const [nw, nh] = normalizeSize(ar, readWidgetValue(wW), readWidgetValue(wH), sizeOpt()); diff --git a/js/xb_image_prompt_preset_pro.js b/js/xb_image_prompt_preset_pro.js index 496703e..89a56b4 100644 --- a/js/xb_image_prompt_preset_pro.js +++ b/js/xb_image_prompt_preset_pro.js @@ -30,12 +30,71 @@ const NODE_TYPE = "XB_ImagePromptPresetPro"; const LANG_ZH = "中文 [ZH]"; const LANG_EN = "英文 [EN]"; const LANGS = [LANG_ZH, LANG_EN]; -const MODE_TP = "常规文生图"; +const MODE_TP = "无预设"; // 不前置任何设定词,只输出正文(旧名「常规文生图」见 MODE_ALIASES) const MODE_3V = "人物三视图"; const MODE_4V = "人物四视图"; const MODE_5V = "人物五视图"; const MODE_RGBA = "背景纯透明"; -const MODES = [MODE_TP, MODE_3V, MODE_4V, MODE_5V, MODE_RGBA]; +const MODE_UI = "图文版面"; +const MODE_INFO = "信息图"; +const MODE_STORYBOARD = "多格分镜"; +const MODE_ADBOARD = "广告分镜板"; +const MODE_KEEP_SUBJECT = "保持主体换场景"; +const MODE_LOCAL_EDIT = "局部编辑"; +const MODE_RESTORE = "老照片修复"; +const MODE_STYLIZE = "整图风格化"; +const MODE_PANO360 = "360°全景"; +const MODE_MULTI_REF = "多图指认合成"; +const MODES = [MODE_TP, MODE_3V, MODE_4V, MODE_5V, MODE_RGBA, MODE_UI, MODE_INFO, MODE_STORYBOARD, MODE_ADBOARD, MODE_KEEP_SUBJECT, MODE_LOCAL_EDIT, MODE_RESTORE, MODE_STYLIZE, MODE_PANO360, MODE_MULTI_REF]; +// 需要输入图的档位(图生图组)与推荐搭配的 SKILL +const MODE_ALIASES = { + "常规文生图": "无预设", // 0.10.x 旧名 → 新名(语义完全一致) + "📱 图文版面": "图文版面", + + "📊 信息图": "信息图", + + "🎞 多格分镜": "多格分镜", + + "🎬 广告分镜板": "广告分镜板", + + "🛍 保持主体换场景": "保持主体换场景", + + "✏️ 局部编辑": "局部编辑", + + "🧹 老照片修复": "老照片修复", + + "🎨 整图风格化": "整图风格化", + + "🌐 360°全景": "360°全景", + + "🗂 多图指认合成": "多图指认合成", + +}; +const MODE_NEEDS_IMAGE = [MODE_KEEP_SUBJECT, MODE_LOCAL_EDIT, MODE_RESTORE, MODE_STYLIZE, MODE_PANO360, MODE_MULTI_REF]; +const modeNeedsImage = (mode) => MODE_NEEDS_IMAGE.includes(mode); +const modeSkillHint = (mode) => (modeNeedsImage(mode) ? "system_prompt_edit.txt" : "system_prompt_t2i.txt"); + +// ── t2i / i2i 两套模版条款(与 nodes_image_prompt_preset.py 逐字一致,自测会断言)── +// · IO_I2I_COMMON:文生图档**接了图**时追加的「以输入图为准」条款 +// · 图生图档没接图时不再追加「忽略输入图」的尾条款 —— 改成直接用该档的「文生图版设定词」(PRESET_TEXT_T2I) +const IO_I2I_COMMON = {"zh": "【本次接了输入图】以输入图为唯一依据:保持输入图中被保留部分的原样不变(人物保持面容、发型、体型与气质;物件保持外形、比例、材质、颜色与文字标识),不得改动、美化、替换或增减我未指定的部分;本档规格只作用于画面的组织与重建区域。", "en": "Attached input image(s) are the single source of truth: keep everything kept from them unchanged — for people: face, hairstyle, build and character; for objects: shape, proportions, material, colour and printed text — and never alter, beautify, replace, add or remove anything I did not ask for. This mode's spec only governs how the frame is organised and rebuilt."}; +/** 按「本档类型 + 有没有输入图」返回要追加的模版条款(仅「文生图档 + 有图」→ 以输入图为准;其余 → "") + * ⚠️ 无预设档(MODE_TP)永远返回 "":接不接图,无预设的设定词都是空白 */ +function modeIoClause(mode, hasImage, lang) { + const m = (typeof MODE_ALIASES !== "undefined" && MODE_ALIASES[mode]) || mode; + const lg = (lang === LANG_EN) ? "en" : "zh"; + if (!m || m === MODE_TP || MODE_NEEDS_IMAGE.includes(m)) return ""; + return hasImage ? (IO_I2I_COMMON[lg] || "") : ""; +} +/** 节点是否接了参考图(🖼️ 图像 端口有连线) */ +function modeHasImage(node) { + try { + const inp = (node.inputs || []).find((i) => i.name === "images"); + return !!(inp && inp.link != null); + } catch (_) { return false; } +} + + const ASPECT_MAP = { "1:1": 1, "16:9": 16 / 9, "9:16": 9 / 16, "4:3": 4 / 3, "3:4": 3 / 4, "21:9": 21 / 9 }; const SIZE_STEP = 16; // width/height 的兜底步长(实际以「空latent类型」的官方最小步长为准) @@ -63,7 +122,7 @@ const LATENT_KIND_SPEC = { }; const DEFAULT_LATENT_KIND = "Z-image"; -/** 各预设模式的默认预设句(可编辑字段 three_view_text);常规文生图 = 无预设句(原样输出正文) */ +/** 各预设模式的默认预设句(可编辑字段 three_view_text);无预设 = 不前置(原样输出正文) */ const PRESET_TEXT_DEFAULT = { [MODE_3V]: { [LANG_ZH]: "生成平行排列的角色概念设计图,画面从左到右由四个独立面板组成:第一个面板是角色面部的精细特写肖像,第二个面板是人物正面全身站姿,第三个面板是人物侧面全身站姿,第四个面板是人物背面全身站姿。", @@ -81,9 +140,140 @@ const PRESET_TEXT_DEFAULT = { [LANG_ZH]: "生成一张具有透明度的 RGBA 格式图像,包含 Alpha 通道,背景为纯透明。", [LANG_EN]: "Generate an RGBA image with an alpha channel and a fully transparent background.", }, + [MODE_UI]: { + [LANG_ZH]: "这是一张完整的平面版面设计图,画面边缘即版面边缘,全出血排版,无多余边框与水印。【版面结构】按层级自上而下组织:① 顶部信息区(标题栏/状态栏,界面类需含时间、信号、电量等图标);② 主体内容区(卡片、栏位、区块,边距统一、圆角一致、层级分明、网格对齐);③ 底部信息区(按钮、标签、脚注)。【文字】画面中所有文字必须逐字使用我给出的原文,不得增删改字,不得生成任何我未指定的文字;字体清晰端正、字号字重按层级区分,无错字无乱码无伪文字。【风格】排版精致、留白充足、有呼吸感,配色统一克制;任何元素都不得遮挡文字。", + [LANG_EN]: "Produce one complete flat print layout: the image edge is the layout edge, full bleed, no extra frame or watermark. Structure top to bottom: (1) top information zone (title bar / status bar, with clock, signal and battery icons for UI); (2) main content zone (cards, columns and blocks with uniform margins, consistent corner radii, clear hierarchy, grid-aligned); (3) bottom information zone (buttons, tags, footnotes). All text must use my wording word for word, with no additions, deletions or substitutions, and no text I did not specify; crisp legible type, size and weight distinguished by hierarchy, no typos, no gibberish. Refined layout, generous breathing space, restrained unified palette; nothing may cover the text.", + }, + [MODE_INFO]: { + [LANG_ZH]: "这是一张信息图/科普拆解图,版式整洁、以信息可读性优先,具有教学与数据可视化气质。【版面】① 顶部标题栏(主标题 + 副标题/卷号/徽标);② 主体由多个信息模块组成,按需包含:结构拆解图、局部放大详图、编号引线说明、图例、参数表、色卡、雷达图、时间轴、关键词云;③ 模块之间用细分割线或留白分隔,网格对齐、边距统一。【编号与引线】主体图形用圆形编号点标注,旁侧以细引线连接简短说明,编号连续、不重复、不跳号。【文字】所有文字逐字使用我给出的原文,不得生成任何我未指定的文字,无错字无乱码。", + [LANG_EN]: "Produce an infographic / annotated explanation card: tidy layout, information legibility first, a teaching and data-visualisation feel. Layout: (1) a top title bar (main title plus subtitle, volume number or badge); (2) a body made of several information modules, optionally including a structural cutaway, zoomed detail views, numbered leader-line callouts, a legend, a parameter table, a colour swatch strip, a radar chart, a timeline and a keyword cloud; (3) modules separated by hairline rules or whitespace, grid-aligned with uniform margins. Numbering: mark the main graphic with circular numbered dots and connect short captions with thin leader lines; numbers are continuous and never repeated. All text must use my wording word for word, with no unspecified text, no typos, no gibberish.", + }, + [MODE_STORYBOARD]: { + [LANG_ZH]: "生成一张由多个连续分镜格组成的叙事插画,输出为一张完整拼合的扁平整图,不要输出多张分离图片。【版面硬性要求】① 格数与我要求一致,所有格等宽等高;② 严格按我要求的排布方式(单横排或网格),不得擅自拆格、并格或改行数;③ 每格是独立取景,格与格之间用细边框或清晰留白分隔;④ 每格左上角标注圆形数字编号,编号连续、不重复、不跳号不缺号。【内容硬性要求】每一格都必须画出主体本人在做该格的动作,主体清晰完整、占该格画面主体;严禁出现只有背景没有人物的空格;所有格的画风、光影、色彩与主体外观保持一致,仅姿势、表情、动作与场景变化。【文字】除编号外不要生成任何文字、字幕或水印。", + [LANG_EN]: "Produce a narrative illustration made of several sequential storyboard panels, delivered as one fully composited flat sheet, not multiple separate files. Layout (mandatory): (1) exactly the panel count I ask for, all panels equal in width and height; (2) strictly the arrangement I ask for (single horizontal row or grid), never splitting, merging or re-flowing panels; (3) each panel is its own framing, separated by thin borders or clear gutters; (4) a circular number badge in the top-left of every panel, continuous, never repeated or skipped. Content (mandatory): every panel must show the subject itself performing that panel's action, clearly and completely, occupying the panel's main subject; never leave a panel with only background and no character; painting style, lighting, colour and the subject's look stay consistent across panels, only pose, expression, action and setting change. No text, captions or watermarks other than the numbers.", + }, + [MODE_ADBOARD]: { + [LANG_ZH]: "生成一张商业广告分镜板/脚本视觉板,像专业品牌团队制作的提案板:既有分镜画面,也带脚本信息。【版面】① 顶部标题区(项目名/产品名 + 副标题 + 品牌标识感排版);② 主体横向排列若干连续分镜画面,每格独立取景、格间清晰分隔;③ 每格下方或侧方配栏目文字:镜头序号、时长或时间轴、镜头说明、字幕/台词、转场提示;④ 底部可放时间轴条与总时长。【风格】排版统一精致、配色克制高级,画面具有电影感与广告质感。【文字】画面中所有文字必须逐字使用我给出的原文,不得生成任何我未指定的文字,不得出现乱码、错字或伪文字。", + [LANG_EN]: "Produce a commercial advertising storyboard / script visual board, like a real brand team's pitch board: storyboard frames plus script information. Layout: (1) a top title zone (project or product name, subtitle, brand-like typography); (2) a body of several sequential storyboard frames in a row, each its own framing with clear separation; (3) caption blocks under or beside each frame with shot number, duration or timeline, shot description, subtitle or dialogue and transition note; (4) an optional timeline bar and total duration at the bottom. Style: unified refined layout, restrained premium palette, cinematic advertising quality. All text must use my wording word for word, with no unspecified text, no gibberish and no fake typography.", + }, + [MODE_KEEP_SUBJECT]: { + [LANG_ZH]: "以输入图像中的主体为唯一依据:先完整识别它的全部外观细节,再把它放入新的场景中。【必须保持】主体的外形、比例、结构、材质、颜色、文字与标识、磨损与光泽等全部细节与新画面完全一致;不得改造、不得美化、不得替换、不得增减部件;主体是人物时保持面容、五官、发型、体型与肤色不变。【必须移除】输入图中的摄影棚背景、手持、支架、阴影底板等非主体元素,以及任何不属于最终画面的杂物。【新画面】按我的要求重建场景、构图、光线与景深;主体与新场景的光影、透视、色温自然统一,接触面有合理投影,看起来就是在该空间里真实拍摄的一张画面。", + [LANG_EN]: "Treat the subject in the input image as the single source of truth: read every appearance detail first, then place it into a new scene. Must keep: shape, proportions, structure, material, colour, printed text and logos, wear and gloss identical to the input; do not redesign, beautify, replace, add or remove parts; for a person, keep face, features, hairstyle, build and skin tone. Must remove: studio backdrop, hands, stands, shadow board and any other non-subject elements or clutter. New frame: rebuild setting, composition, lighting and depth of field as I ask; the subject must match the new scene in light, perspective and colour temperature, sit on believable contact shadows, and look like a real photograph taken in that space.", + }, + [MODE_LOCAL_EDIT]: { + [LANG_ZH]: "只修改我指定或标记的区域,其余部分与输入图保持完全一致(包含构图、透视、光线、色调与清晰度)。【编辑范围】仅限我指定/标记的区域或部位;未标记的内容一律不得改动、不得重绘、不得重新打光、不得改变材质。【标记优先】若输入包含标记图、掩码图或涂抹标注,则以标注范围为准,只在该范围内操作。【过渡】修改区域与周边必须在材质、纹理与光影方向上自然衔接,边界不得出现生硬接缝、色块、描边或亮度跳变。【输出】只输出修改后的一整张完整画面,不要输出对比图、标注框、箭头或任何说明文字。", + [LANG_EN]: "Change only the region or part I specify or mark; everything else stays exactly as in the input (composition, perspective, lighting, colour and sharpness). Scope: only my specified or marked region; anything unmarked must not be altered, repainted, relit or re-materialised. Marking wins: if the input includes an annotated, masked or painted region, operate strictly inside it. Blending: the edit must match its surroundings in material, texture and light direction, with no hard seams, colour patches, outlines or brightness jumps. Output one single finished image only, with no comparison view, boxes, arrows or explanatory text.", + }, + [MODE_RESTORE]: { + [LANG_ZH]: "把输入的老照片修复成清晰、自然、真实的彩色照片,并保持原照片的内容与人物五官解剖完全不变。【修复】去除噪点、划痕、斑点、折痕、褪色与颗粒,恢复细节与层次、平衡高光与阴影;不得过度锐化,不得出现塑料感、蜡感或油画涂抹感。【上色】按真实肤色与材质自然上色,色调统一可信,不做夸张调色。【保持】人物面部结构、皱纹、神态、视线、头部角度、手部与持物关系、服装材质与褶皱、背景陈设与景深关系均不得改变。【输出】只输出修复后的一整张完整画面,不加边框、不加文字或水印。", + [LANG_EN]: "Restore the input vintage photograph into a sharp, natural, realistically colourised photo, keeping the original content and facial anatomy unchanged. Repair: remove noise, scratches, spots, creases, fading and grain; recover detail and tonal range and balance highlights and shadows; never oversharpen, never produce waxy, plastic or painterly skin. Colour: natural believable skin and material tones, unified grading, no exaggerated stylisation. Keep: facial structure, wrinkles, expression, gaze, head angle, hands and how they hold objects, garment material and folds, background props and depth of field. Output one single finished image with no border, text or watermark.", + }, + [MODE_STYLIZE]: { + [LANG_ZH]: "把输入图整张转换为指定的视觉媒介与画风,转换必须覆盖整幅画面,不得只做局部滤镜或只改一部分物体。【保持】原图的构图、画幅比例、视角、主体位置与姿态、景物之间的空间关系、已有文字与招牌的位置及可读内容不得改变;不得新增标语、字幕或水印。【转换】按我要求的画风重绘每一个物体:笔触、色层、材质表现与光色关系统一;光的方向与原图一致。【输出】只输出转换后的一整张完整画面。", + [LANG_EN]: "Convert the whole input image into the requested visual medium and style; the conversion must cover the entire frame, not a local filter or only some objects. Keep: composition, aspect ratio, viewpoint, subject placement and pose, spatial relations between objects, and the position and legible content of existing signage and text; add no slogans, captions or watermarks. Convert: repaint every object in the requested style with consistent brushwork, colour layering, material rendering and light-colour relationships; light direction matches the original. Output one single converted image.", + }, + [MODE_PANO360]: { + [LANG_ZH]: "把输入的单视角照片扩展成一张完整的 360 度全景图。【投影】使用真正的等距圆柱投影(equirectangular),水平覆盖 360 度、垂直覆盖 180 度,包含头顶天空与脚下地面;左右两端无缝衔接,形成一张连续完整的场景。【补全】以输入图为基础,把相机背后的环境合理延伸(地面、墙体、植被、天空,光照方向一致),不得出现重复、断裂或接缝。【保持】输入图中的主体只出现一次,其外观、姿态以及与周围景物的相对关系保持原样,不得镜像或复制。【输出】严格 2:1 画幅比例,例如 2880x1440。", + [LANG_EN]: "Extend the input single-view photograph into a complete 360-degree panorama. Projection: true equirectangular, covering 360 degrees horizontally and 180 degrees vertically, including the sky overhead and the ground below; left and right edges must join seamlessly into one continuous scene. Completion: extend the environment behind the camera plausibly (ground, walls, vegetation, sky, consistent light direction) with no repeated or broken areas. Keep: the subject appears exactly once, with its appearance, pose and relationship to surrounding objects unchanged, never mirrored or duplicated. Output at exactly a 2:1 aspect ratio, for example 2880x1440.", + }, + [MODE_MULTI_REF]: { + [LANG_ZH]: "按图号使用我提供的多张参考图,合成一张全新画面。【指认规则】场景与环境取自我指定的那一张图;其余各图分别提供角色外观或物件本体;我未指认的图不参与画面。【保持】被引用的角色保持面容、发型与体型一致;被引用的物件保持形状、材质、颜色与标识文字一致;不得替换、美化或增减部件。【重建】按我的要求安排构图、站位、光线与景深,所有元素统一在同一空间的光影与透视中,接触关系与投影合理。【文字】除我明确要求的外,不生成任何文字或水印。", + [LANG_EN]: "Use the reference images I provide by their index numbers and composite one brand-new image. Mapping: the scene and environment come from the image I designate; the other images supply either character likeness or an object itself; images I do not reference do not appear. Keep: referenced characters keep the same face, hairstyle and build; referenced objects keep the same shape, material, colour and printed text; no replacement, beautifying, additions or removals. Rebuild: arrange composition, staging, lighting and depth of field as I ask; all elements share one consistent space with matching light, perspective, contact and shadows. No text or watermark unless I explicitly ask for it.", + }, }; /** 某模式某语言下的默认预设句(无预设句的模式 → 空串) */ const defaultPresetOf = (mode, lang) => ((PRESET_TEXT_DEFAULT[mode] || {})[lang] || ""); + +/* ── 模版(文生图 / 图生图):每个预设模式都有两套设定词 ──────────────────────── + * · 自动 = 按有没有接参考图判断(没接 → 文生图,接了 → 图生图) + * · 图生图档(6 档):PRESET_TEXT_DEFAULT = 图生图版,PRESET_TEXT_T2I = 文生图版 + * · 文生图档(8 档):PRESET_TEXT_DEFAULT = 文生图版,「图生图」版 = 原文 + 以输入图为准条款 + * ⚠️ 必须与 nodes_image_prompt_preset.py 的 IO_MODES / PRESET_TEXT_T2I / preset_text_for 逐字一致(自测断言) + */ +const IO_AUTO = "自动"; +const IO_T2I = "文生图"; +const IO_I2I = "图生图"; +const IO_MODES = [IO_AUTO, IO_T2I, IO_I2I]; +/** 图生图档专用文本(文生图模版) */ +const PRESET_TEXT_T2I = { + "保持主体换场景": { + "zh": "生成一张主体清晰的画面:先完整确定主体的全部外观细节,再把它放进我要求的新场景中。【必须保持】主体的外形、比例、结构、材质、颜色、文字与标识、磨损与光泽等全部细节前后一致、符合真实物理;不得改造、不得美化、不得替换、不得增减部件;主体是人物时保持面容、五官、发型、体型与肤色自然可信。【必须干净】画面中不得出现摄影棚背景、手持、支架、阴影底板等杂物,也不得有任何不属于最终画面的元素。【新画面】按我的要求营造场景、构图、光线与景深;主体与新场景的光影、透视、色温自然统一,接触面有合理投影,看起来就是在该空间里真实拍摄的一张画面。", + "en": "Generate a frame with a clearly defined subject: first settle every appearance detail of the subject, then place it into the new scene I ask for. Must keep: shape, proportions, structure, material, colour, printed text and logos, wear and gloss stay self-consistent and physically believable; do not redesign, beautify, replace, add or remove parts; for a person, keep face, features, hairstyle, build and skin tone natural and convincing. Must be clean: no studio backdrop, hands, stands, shadow board or any element that does not belong in the final frame. New frame: build the setting, composition, lighting and depth of field as I ask; the subject must match the scene in light, perspective and colour temperature, sit on believable contact shadows, and look like a real photograph taken in that space." + }, + "局部编辑": { + "zh": "生成一张完整画面,并把我的改动直接做进画面里。【改动范围】只按我的要求改我指定的部位,其余内容保持稳定一致:不得顺手重绘、不得重新打光、不得改变材质与配色。【过渡】改动区域与周边必须在材质、纹理与光影方向上自然衔接,边界不得出现生硬接缝、色块、描边或亮度跳变。【输出】只输出最终的一整张完整画面,不要输出对比图、标注框、箭头或任何说明文字。", + "en": "Generate one complete frame with my change built directly into the image. Scope: change only the part I ask for; keep everything else stable and consistent — do not incidentally repaint, relight, or alter materials and palette. Blending: the changed area must match its surroundings in material, texture and light direction, with no hard seams, colour patches, outlines or brightness jumps. Output one single finished frame only, with no comparison view, boxes, arrows or explanatory text." + }, + "老照片修复": { + "zh": "直接生成一张清晰、自然、细节完整的照片,并带有老照片翻新后的质感。【画面】主体与场景按我的描述生成,人物面部结构、五官解剖与比例自然准确,手部与持物关系合理。【修复感】不要噪点、划痕、斑点、折痕、褪色与颗粒,细节与层次完整、高光与阴影平衡;不得过度锐化,不得出现塑料感、蜡感或油画涂抹感。【色彩】按真实肤色与材质自然上色,色调统一可信,不做夸张调色。【输出】只输出一整张完整画面,不加边框、不加文字或水印。", + "en": "Generate directly a sharp, natural, fully detailed photograph with the look of a restored vintage print. Frame: build the subject and setting from my description, with natural accurate facial structure, anatomy and proportions, and believable hands and grip. Restoration feel: no noise, scratches, spots, creases, fading or grain; complete detail and tonal range, balanced highlights and shadows; never oversharpen, never produce waxy, plastic or painterly skin. Colour: natural believable skin and material tones, unified grading, no exaggerated stylisation. Output one single finished image with no border, text or watermark." + }, + "整图风格化": { + "zh": "生成一张完整的全新画面,并整体转换成我要求的视觉媒介与画风,转换必须覆盖整幅画面,不得只做局部滤镜或只改一部分物体。【画面】构图、画幅比例、视角与主体由我的描述决定,主体位置与姿态清晰合理。【转换】画面里的每一个物体都按该画风重绘:笔触、色层、材质表现与光色关系统一;光的方向明确一致。【文字】除我明确要求的外不生成任何文字、标语、字幕或水印。【输出】只输出一整张完整画面。", + "en": "Generate one complete new frame and convert the whole of it into the requested visual medium and style; the conversion must cover the entire frame, not a local filter or only some objects. Frame: composition, aspect ratio, viewpoint and subject come from my description, with the subject clearly and plausibly placed and posed. Convert: repaint every object in that style with consistent brushwork, colour layering, material rendering and light-colour relationships; keep one clear light direction. Text: no text, slogans, captions or watermarks unless I explicitly ask for them. Output one single complete frame." + }, + "360°全景": { + "zh": "生成一张完整的 360 度全景图。【投影】使用真正的等距圆柱投影(equirectangular),水平覆盖 360 度、垂直覆盖 180 度,包含头顶天空与脚下地面;左右两端无缝衔接,形成一张连续完整的场景。【补全】以输入图为基础,把相机背后的环境合理延伸(地面、墙体、植被、天空,光照方向一致),不得出现重复、断裂或接缝。【保持】输入图中的主体只出现一次,其外观、姿态以及与周围景物的相对关系保持原样,不得镜像或复制。【输出】严格 2:1 画幅比例,例如 2880x1440。", + "en": "Generate a complete 360-degree panorama. Projection: true equirectangular, covering 360 degrees horizontally and 180 degrees vertically, including the sky overhead and the ground below; left and right edges must join seamlessly into one continuous scene. Completion: extend the environment behind the camera plausibly (ground, walls, vegetation, sky, consistent light direction) with no repeated or broken areas. Keep: the subject appears exactly once, with its appearance, pose and relationship to surrounding objects unchanged, never mirrored or duplicated. Output at exactly a 2:1 aspect ratio, for example 2880x1440." + }, + "多图指认合成": { + "zh": "直接按我的描述生成一张全新画面。【画面】按我的要求安排主体、场景、构图、站位、光线与景深,所有元素统一在同一空间的光影与透视中,接触关系与投影合理。【一致性】同一主体在各处的外观保持一致,不得替换、美化或增减部件。【文字】除我明确要求的外,不生成任何文字或水印。", + "en": "Generate one brand-new image directly from my description. Frame: arrange subjects, setting, composition, staging, lighting and depth of field as I ask; all elements share one consistent space with matching light, perspective, contact and shadows. Consistency: keep each subject's look identical everywhere, with no replacement, beautifying, additions or removals. Text: no text or watermark unless I explicitly ask for it." + } + }; +const ioModeOf = (v) => { const s = String(v == null ? "" : v).trim(); return IO_MODES.includes(s) ? s : IO_AUTO; }; +/** 把「自动」按有没有接参考图解析成 文生图 / 图生图 */ +const resolveIoMode = (io, hasImage) => { + const m = ioModeOf(io); + if (m !== IO_AUTO) return m; + return hasImage ? IO_I2I : IO_T2I; +}; +/** 该模式的默认模版(图生图档默认图生图,其余默认文生图) */ +const modeDefaultIo = (mode) => (MODE_NEEDS_IMAGE.includes(mode) ? IO_I2I : IO_T2I); +/** 该模式在某模版下的**默认**设定词(io 已归一为 文生图 / 图生图) + * ⚠️ PRESET_TEXT_DEFAULT 的键是「中文 [ZH]/英文 [EN]」,PRESET_TEXT_T2I 的键是 "zh"/"en" */ +const presetTextOf = (mode, lang, io) => { + if (!MODES.includes(mode)) return ""; + if (mode === MODE_TP) return ""; // 无预设:接不接图、哪个模版 → 设定词都是空白 + const lg = (lang === LANG_EN) ? "en" : "zh"; + const base = ((PRESET_TEXT_DEFAULT[mode] || {})[lang] || ""); + if (io === IO_T2I) { + if (!MODE_NEEDS_IMAGE.includes(mode)) return base; // 文生图档 → 原文 + return (((PRESET_TEXT_T2I[mode] || {})[lg]) || base); // 图生图档 → 文生图版 + } + if (MODE_NEEDS_IMAGE.includes(mode)) return base; // 图生图档 → 原文 + const clause = modeIoClause(mode, true, lang); // 文生图档 → 原文 + 条款 + return clause ? ((base ? base + " " : "") + clause).trim() : base; +}; +/** 该模式所有模版 × 所有语言的默认文本(判断「用户没改过」用) + * · 无预设档额外把它算「默认」:IO_I2I_COMMON 条款(早期版本在「图生图模版」下把它当设定词 + * 存进了 widget / 存档)+ **所有内置预设句**(节点上 three_view_text 的老默认值就是三视图 + * 预设句 → 无预设档必须当空白,不能当成用户自定义继续前置) */ +const allDefaultTextsOf = (mode) => { + const out = []; + for (const lg of LANGS) for (const io of [IO_T2I, IO_I2I]) { + const t = presetTextOf(mode, lg, io).trim(); + if (t && !out.includes(t)) out.push(t); + } + if (mode === MODE_TP) { + for (const k of ["zh", "en"]) { + const c = String(IO_I2I_COMMON[k] || "").trim(); + if (c && !out.includes(c)) out.push(c); + } + for (const table of [PRESET_TEXT_DEFAULT, PRESET_TEXT_T2I]) { + for (const texts of Object.values(table)) { + for (const t of Object.values(texts || {})) { + const s = String(t || "").trim(); + if (s && !out.includes(s)) out.push(s); + } + } + } + } + return out; +}; +/** 存档值是不是「没改过」(等于任一默认 / 旧条款)→ 是则不算自定义 */ +const isDefaultPresetText = (mode, text) => !!String(text || "").trim() + && allDefaultTextsOf(mode).includes(String(text).trim()); /** 兼容旧引用:三视图预设句 */ const THREE_VIEW_DEFAULT = PRESET_TEXT_DEFAULT[MODE_3V]; @@ -95,15 +285,20 @@ const THREE_VIEW_DEFAULT = PRESET_TEXT_DEFAULT[MODE_3V]; * { "人物三视图|中文 [ZH]": "用户改过的设定词", … } ← 只存与默认不同的那条 * 键里带语言:默认设定词本身就是中英两套,用户改的哪一套就记哪一套。 */ -const presetKeyOf = (mode, lang) => `${mode}|${lang}`; +const presetKeyOf = (mode, io, lang) => `${mode}|${io}|${lang}`; function parsePresetTexts(raw) { const out = {}; if (!raw || typeof raw !== "object") return out; for (const [k, v] of Object.entries(raw)) { - const seg = String(k).split("|"); - if (seg.length !== 2 || !MODES.includes(seg[0]) || !LANGS.includes(seg[1])) continue; if (typeof v !== "string" || !v.trim()) continue; - out[`${seg[0]}|${seg[1]}`] = v; // 保留原文(含换行 / 尾随空格) + const seg = String(k).split("|"); + if (seg.length === 3) { // 新格式:模式|模版|语言 + if (!MODES.includes(seg[0]) || !IO_MODES.includes(seg[1]) || !LANGS.includes(seg[2])) continue; + out[`${seg[0]}|${seg[1]}|${seg[2]}`] = v; + } else if (seg.length === 2) { // 旧格式:模式|语言 → 按该档默认模版归档 + if (!MODES.includes(seg[0]) || !LANGS.includes(seg[1])) continue; + out[`${seg[0]}|${modeDefaultIo(seg[0])}|${seg[1]}`] = v; + } } return out; } @@ -1548,10 +1743,10 @@ function rebuildBody(settings, index, lang) { return parts.join(sepOf(lang)); } /** 最终成句(与后端 build_prompt 逐行一致:预设句 + 正文)—— 三个值均来自节点表面参数 */ -function finalPrompt(mode, presetText, lang, body) { +function finalPrompt(mode, presetText, lang, body, io) { const core = String(body || "").trim(); - if (!PRESET_TEXT_DEFAULT[mode]) return core; // 常规文生图 → 无预设句 - const preset = String(presetText || "").trim() || defaultPresetOf(mode, lang); + if (!PRESET_TEXT_DEFAULT[mode]) return core; // 无预设 → 不前置 + const preset = String(presetText || "").trim() || presetTextOf(mode, lang, io || IO_T2I); if (!core) return preset; if (lang === LANG_EN) return preset.replace(/[.\s]+$/, "") + ". " + core; return preset.replace(/[。..\s]+$/, "") + "。" + core; @@ -1662,11 +1857,11 @@ function openPanel(ctx, panelId) { const refreshPreview = (fromEl) => { const body = ctx.body.get(); if (fromEl !== preview) preview.value = body; - headCount.textContent = `${body.length} 字 → 成句 ${finalPrompt(surf.mode, surf.presetText, surf.lang, body).length} 字`; + headCount.textContent = `${body.length} 字 → 成句 ${finalPrompt(surf.mode, surf.presetText, surf.lang, body, surf.io).length} 字`; const hasPreset = !!PRESET_TEXT_DEFAULT[surf.mode]; presetChip.style.display = hasPreset ? "block" : "none"; if (hasPreset) { - const t = String(surf.presetText || "") || defaultPresetOf(surf.mode, surf.lang); + const t = String(surf.presetText || "") || presetTextOf(surf.mode, surf.lang, surf.io || IO_T2I); presetChip.textContent = `📌 自动前置(${surf.mode}设定词,可在节点表面「预设句」框里改;改过的按模式记住、换模式不丢):` + (t.length > 150 ? t.slice(0, 150) + "…" : t); } }; @@ -1969,7 +2164,7 @@ function renderCategoryPanel(box, api, catId) { notify(t ? "已按已选元素重建正文" : "当前没有已选元素(重建后正文为空)", t ? "success" : "warning"); }), smallBtn("📋 复制成句", "border:1px solid #555;background:#2a2a2a;color:#ccc;padding:6px 12px;", "复制最终成句(三视图模式下含预设句)", () => { - copyText(finalPrompt(surf.mode, surf.presetText, surf.lang, bodyGet())); + copyText(finalPrompt(surf.mode, surf.presetText, surf.lang, bodyGet(), surf.io)); }), smallBtn("⬆️ 导出词表", "border:1px solid #6b9b5b;background:#3a5a2a;color:#d7f0c8;padding:6px 12px;", "把「自建槽位 + 已选 + 描述」导出成 JSON 备份", () => exportJson(s)), smallBtn("⬇️ 导入词表", "border:1px solid #6b9b5b;background:#3a5a2a;color:#d7f0c8;padding:6px 12px;", "从 JSON 恢复「自建槽位 + 已选 + 描述」(合并,不覆盖内置词表)", () => importJson((obj) => { @@ -2490,6 +2685,9 @@ function setupNode(node) { }; snapshotWidget(w3v); + // 模版切换(文生图 / 图生图):基础节点没有这个 widget → null,逻辑自动按「文生图」走 + const wIo = findWidget(node, "io_mode"); + // ── 预设句(设定词)存档:用户改过的那条按「模式|语言」存进节点 ────────────── // 切换模式 / 切换语言一律取「该键的存档 → 该键的默认」,所以用户改过的设定词不会被冲掉。 // ⚠️ 写入用「原样 JSON 合并」——不能走 persistSettings(那会丢掉 Pro 的 llm 段等其它键)。 @@ -2498,20 +2696,25 @@ function setupNode(node) { try { return parsePresetTexts(parseSettings(readWidgetValue(findWidget(node, "manager_settings"))).preset_texts); } catch (_) { return {}; } }; - const savedPresetOf = (mode, lang) => presetStoreOf()[presetKeyOf(mode, lang)] || ""; - /** 该「模式 × 语言」最终生效的设定词:用户改过的存档优先,否则用默认 */ - const presetTextFor = (mode, lang) => savedPresetOf(mode, lang) || defaultPresetOf(mode, lang); + const savedPresetOf = (mode, io, lang) => presetStoreOf()[presetKeyOf(mode, io, lang)] || ""; + /** 该「模式 × 模版 × 语言」最终生效的设定词:用户改过的存档优先,否则用默认 + * (存档值等于任一默认 / 旧条款 → 视为「没改过」 → 用当前模版的默认) */ + const presetTextFor = (mode, io, lang) => { + const s = savedPresetOf(mode, io, lang); + if (s && !isDefaultPresetText(mode, s)) return s; + return presetTextOf(mode, lang, io); + }; /** 存 / 清一条设定词存档(与默认一致 → 清掉存档,保持工作流干净) */ - const writePresetStore = (mode, lang, text) => { + const writePresetStore = (mode, io, lang, text) => { try { let data = {}; try { data = JSON.parse(String(readWidgetValue(findWidget(node, "manager_settings")) || "{}")) || {}; } catch (_) { data = {}; } if (!data || typeof data !== "object") data = {}; const store = parsePresetTexts(data.preset_texts); - const key = presetKeyOf(mode, lang); + const key = presetKeyOf(mode, io, lang); const t = String(text ?? ""); - if (!t.trim() || t === defaultPresetOf(mode, lang)) delete store[key]; + if (!t.trim() || t === presetTextOf(mode, lang, io)) delete store[key]; else store[key] = t; if (Object.keys(store).length) data.preset_texts = store; else delete data.preset_texts; setWidgetValue(findWidget(node, "manager_settings"), JSON.stringify(data)); @@ -2531,6 +2734,9 @@ function setupNode(node) { hideWidget(findWidget(node, "latent_kind")); // → 「✨ 增强预设」弹窗 hideWidget(findWidget(node, "preset_mode")); // → 「✨ 增强预设」弹窗 hideWidget(findWidget(node, "three_view_text")); // 设定词 → 只在「✨ 增强预设」弹窗里编辑(用户要求:不在节点表面显示) + hideWidget(findWidget(node, "skill_name")); // SKILL 选择 → 只在「✨ 预设参数」弹窗里选 + hideWidget(findWidget(node, "skill_mode")); // SKILL 三态 → 只在「✨ 预设参数」弹窗里选 + hideWidget(findWidget(node, "io_mode")); // 模版(文生图/图生图)→ 只在「✨ 预设参数」弹窗里选 refreshNodes2View(node); node.setDirtyCanvas?.(true, true); }; @@ -2595,9 +2801,12 @@ function setupNode(node) { promptBox.addEventListener("input", () => { setWidgetValue(ipWidget, promptBox.value); }); // ── 表面参数(原「⚙️ 输出设置」面板的 4 项已上提到节点表面)── + /** 当前模版(文生图 / 图生图):自动 → 按「🖼️ 图像」有没有接线判断 */ + const curIoMode = () => resolveIoMode(ioModeOf(wIo ? readWidgetValue(wIo) : ""), modeHasImage(node)); const surfaceOf = () => ({ lang: pickOpt(LANGS, readWidgetValue(wLang), LANG_ZH), mode: pickOpt(MODES, readWidgetValue(wMode), MODE_TP), + io: curIoMode(), kind: pickOpt(LATENT_KINDS, readWidgetValue(wKind), DEFAULT_LATENT_KIND), presetText: String(readWidgetValue(w3v) ?? ""), }); @@ -2671,7 +2880,8 @@ function setupNode(node) { if (v === lastAppliedPreset) return; // 程序化写入的回声 → 忽略 lastAppliedPreset = v; setWidgetValue(w3v, v); - writePresetStore(surfaceOf().mode, surfaceOf().lang, v); + const s = surfaceOf(); + writePresetStore(s.mode, s.io, s.lang, v); } catch (_) {} }; live.addEventListener("change", commitUserEdit); @@ -2698,7 +2908,7 @@ function setupNode(node) { try { node.setDirtyCanvas?.(true, true); } catch (_) {} }; - // 预设句输入框:常规文生图以外的 4 个模式(三视图/四视图/五视图/背景纯透明)都显示(一次性隐藏/显示,不做任何 setSize,不会抖动) + // 预设句输入框:无预设以外的模式(三视图/四视图/五视图/背景纯透明 等)都显示(一次性隐藏/显示,不做任何 setSize,不会抖动) const applyPresetTextVisibility = () => { try { // Pro:设定词只在「✨ 增强预设」弹窗里编辑 → 节点表面始终隐藏(不把选项参数暴露在外面) @@ -2768,16 +2978,23 @@ function setupNode(node) { const cur = String(readWidgetValue(w3v) ?? ""); if (cur === lastAppliedPreset) return; // 程序化写入的回声 → 忽略 lastAppliedPreset = cur; - writePresetStore(surfaceOf().mode, surfaceOf().lang, cur); + const s = surfaceOf(); + writePresetStore(s.mode, s.io, s.lang, cur); }); - hook(wMode, () => { // 换模式 = 显/隐预设句框 + - const { mode, lang } = surfaceOf(); // 取该模式的「用户存档 → 默认」 - if (defaultPresetOf(mode, lang)) setPresetText(presetTextFor(mode, lang)); // (改过的设定词不会被冲掉) + hook(wMode, () => { // 换模式 = 显/隐设定词框 + + const { mode, lang, io } = surfaceOf(); // 取该「模式 × 模版」的「用户存档 → 默认」 + setPresetText(presetTextFor(mode, io, lang)); // (改过的设定词不会被冲掉;无预设 → 写空) applyPresetTextVisibility(); }); - hook(wLang, () => { // 换语言 = 同模式按下语言取「存档 → 默认」 - const { mode, lang } = surfaceOf(); - if (defaultPresetOf(mode, lang)) setPresetText(presetTextFor(mode, lang)); + hook(wLang, () => { // 换语言 = 同模式同模版按下语言取「存档 → 默认」 + const { mode, lang, io } = surfaceOf(); + setPresetText(presetTextFor(mode, io, lang)); + }); + hook(wIo, () => { // 换模版(文生图/图生图)= 换设定词那一套 + const { mode, lang, io } = surfaceOf(); + setPresetText(presetTextFor(mode, io, lang)); + try { node.__qbrRefreshInfo?.(); } catch (_) {} + try { node.__xbrRefreshInfo?.(); } catch (_) {} }); hook(wB, () => { // batch 超出当前类型上限 → 就近钳制 const sp = latentSpec(surfaceOf().kind); @@ -2790,7 +3007,8 @@ function setupNode(node) { const killGhost = () => { try { for (const nm of ["internal_prompt", "manager_settings", "backend", "preset", "task_preset", - "open_api_settings", "output_lang", "latent_kind", "preset_mode", "three_view_text"]) { + "open_api_settings", "output_lang", "latent_kind", "preset_mode", "three_view_text", + "skill_name", "skill_mode", "io_mode"]) { const idx = (node.inputs || []).findIndex((i) => i.name === nm); if (idx < 0) continue; if (typeof node.removeInput === "function") node.removeInput(idx); @@ -2970,10 +3188,16 @@ function setupNode(node) { syncPromptBoxFromWidget(); applyKindLimits(); // 按当前空latent类型同步 step / 上下限 applyPresetTextVisibility(); - // 预设句:以「用户存档 → 默认」为准(widget 值本就随工作流存着;这里兜底空值 / 被清空的情况) + // 设定词:以「用户存档 → 当前模版默认」为准 + // · 空 → 当前模版默认(无预设 → 默认就是空 → 保持空); + // · 等于别的模版 / 语言的默认 / 任一内置预设句(老工作流存的是另一套,或节点老默认句)→ 也换成当前模版默认; + // · 用户改过的 → 一个字不动。 { - const m = surfaceOf().mode, l = surfaceOf().lang; - if (defaultPresetOf(m, l) && !String(readWidgetValue(w3v) ?? "").trim()) setPresetText(presetTextFor(m, l)); + const m = surfaceOf().mode, l = surfaceOf().lang, iom = surfaceOf().io; + const nowTxt = presetTextOf(m, l, iom); + const cur = String(readWidgetValue(w3v) ?? "").trim(); + if (!cur) { if (nowTxt) setPresetText(presetTextFor(m, iom, l)); } + else if (cur !== nowTxt && allDefaultTextsOf(m).includes(cur)) setPresetText(presetTextFor(m, iom, l)); } const ar = String(readWidgetValue(wAR) ?? "Free"); const [nw, nh] = normalizeSize(ar, readWidgetValue(wW), readWidgetValue(wH), sizeOpt()); @@ -3010,7 +3234,7 @@ function setupNode(node) { /* ============================================================================ * ✨ 提示词增强反推(融合进「🖼️ 生图提示词预设Pro」) * --------------------------------------------------------------------------- - * 节点表面新增:3 个按钮(🤖 大语言模型配置 | ✨ 提示词增强预设 | 📖 使用说明) + * 节点表面新增:2 个按钮(🤖 大语言模型配置 | ✨ 提示词增强预设) * + 「✅ 启用 LLM 反推」开关(节点表面参数,默认关) * + 提示词框标题行(右侧「📋 复制」)+ 参数设定显示(只读摘要) * + 「📝 提示词」端口接线 → 提示词框锁定(二选一) @@ -3035,11 +3259,14 @@ const XBR_BACKENDS = [ ]; const XBR_INFERENCE_MODES = ["one by one", "images", "video"]; const XBR_SEED_MODES = ["randomize", "fixed", "increment", "decrement"]; +/** SKILL 三态:自动 = 按预设模式适配;手动 = 用选择的那一个;不用 = 完全不生效 */ +const XBR_SKILL_MODES = ["自动", "手动", "不用"]; +/** 模版三态:自动 = 按有没有接参考图判断;文生图 / 图生图 = 手动指定 */ +const XBR_IO_MODES = ["自动", "文生图", "图生图"]; const XBR_SEED_LABEL = { randomize: "随机", fixed: "固定", increment: "增加", decrement: "减少" }; const XBR_PANEL_BUTTONS = [ { id: "llm", label: "🤖 LLM设置", title: "🤖 LLM设置", subtitle: "LLM 反推的后端与推理配置;本弹窗里的「输出语言」是全节点唯一的语言设置(决定词表加载语言 + 最终提示词语言)。" }, - { id: "preset", label: "✨ 增强预设", title: "✨ 增强预设", subtitle: "空latent类型 / 预设模式 / 增强预设(= 提示词设定)/ 反推预设 / 追加设定。提示词正文在节点上的提示词框里编辑。" }, - { id: "help", label: "📖 使用说明", title: "📖 使用说明", subtitle: "端口 / 按钮 / 保存说明。" }, + { id: "preset", label: "✨ 预设参数", title: "✨ 预设参数", subtitle: "空latent类型 / 预设模式 / 增强预设(= 提示词设定)/ 反推预设 / 追加设定 / SKILL。提示词正文在节点上的提示词框里编辑。" }, ]; const XBR_API_ENDPOINT = "/xb_toolbox/llm_api_settings"; @@ -3141,15 +3368,23 @@ function xbrSaveJson(node, patch) { return cur; } -/* ── 预设设定词(三视图 / 四视图 / 五视图 / 背景纯透明的设定文本)───────────── - * · 用户改过的按「模式|语言」存进节点(基础面板的 preset_texts 存档); - * · 弹窗读「存档 → 默认」,提交时同步写 widget + 存档。 */ +/* ── 预设设定词(每个预设模式都有「文生图 / 图生图」两套)───────────────────── + * · 用户改过的按「模式|模版|语言」存进节点(基础面板的 preset_texts 存档); + * · 弹窗读「存档 → 当前模版默认」,提交时同步写 widget + 存档。 */ function xbrPresetStore(node) { try { return parsePresetTexts(xbrCurJson(node).preset_texts); } catch (_) { return {}; } } -function xbrPresetTextFor(node, mode, lang) { - const t = xbrPresetStore(node)[presetKeyOf(mode, lang)]; - return (t && String(t).trim()) ? t : (defaultPresetOf(mode, lang) || ""); +function xbrPresetTextFor(node, mode, lang, io) { + const key = presetKeyOf(mode, io, lang); + const t = xbrPresetStore(node)[key]; + // 存档里的值等于任一默认 / 旧条款 → 视为「没改过」 → 用当前模版的默认(无预设 = 空) + if (t && String(t).trim() && !isDefaultPresetText(mode, t)) return t; + return (presetTextOf(mode, lang, io) || ""); +} +/** 当前模版(文生图 / 图生图):自动 → 按「🖼️ 图像」有没有接线判断 */ +function xbrResolveIo(node, ioRaw) { + const has = (typeof modeHasImage === "function") && modeHasImage(node); + return (typeof resolveIoMode === "function") ? resolveIoMode(ioRaw, has) : "文生图"; } /* ── 预设选项按「输出语言」过滤 ─────────────────────────────── @@ -3368,8 +3603,8 @@ function xbrRenderLlm(body, node, draft, ctx) { body.append(makeSectionTitle("输出语言(全节点唯一的语言设置)")); body.append(field("输出语言", selectControl(LANGS, draft.lang, (v) => { draft.lang = v; - // 换语言 → 未在弹窗里改过设定词时,跟着取该语言的「存档 → 默认」 - if (!draft.presetTouched) draft.presetText = xbrPresetTextFor(node, draft.mode, v); + // 换语言 → 未在弹窗里改过设定词时,跟着取该语言同模版的「存档 → 默认」 + if (!draft.presetTouched) draft.presetText = xbrPresetTextFor(node, draft.mode, v, xbrResolveIo(node, draft.ioMode)); // 语言决定预设可选范围:增强预设 / 反推预设 自动换到同语言的同名项(没有则取该语言第一项) draft.preset = xbrSnapPreset(xbrWidgetOptions(node, "preset"), draft.preset, v); draft.task_preset = xbrSnapPreset(xbrWidgetOptions(node, "task_preset"), draft.task_preset, v); @@ -3450,38 +3685,75 @@ function xbrRenderPreset(body, node, draft, ctx) { body.append(makeSectionTitle("生图预设")); body.append(field("空latent类型", selectControl(LATENT_KINDS, draft.kind, (v) => { draft.kind = v; }))); body.append(xbrHint("空latent类型:选你正在用的模型即可(形状 / 下采样 / 尺寸步长自动适配 8/16/32)。")); + // ── 模版(文生图 / 图生图):决定用哪一套设定词(每个预设模式都有两套)── + body.append(field("模版", radioRow(XBR_IO_MODES, draft.ioMode, (v) => { + draft.ioMode = v; + // 换模版 → 未改过时立即换成对应那套设定词(改过的按「模式|模版|语言」存着不会被冲掉) + if (!draft.presetTouched) draft.presetText = xbrPresetTextFor(node, draft.mode, draft.lang, xbrResolveIo(node, v)); + ctx.rerender(); + }))); + body.append(xbrHint("模版:自动 = 按「🖼️ 图像」有没有接线判断;文生图 / 图生图 = 手动指定。每个预设模式都有两套设定词,切模版会直接换掉下面「设定词」那一栏的内容。")); body.append(field("预设模式", selectControl(MODES, draft.mode, (v) => { draft.mode = v; - // 换模式 → 立刻取该模式的设定词(用户改过的存档 → 默认),改过的不会被冲掉 + // 换模式 → 立刻取该「模式 × 模版」的设定词(用户改过的存档 → 默认),改过的不会被冲掉 draft.presetTouched = false; - draft.presetText = xbrPresetTextFor(node, v, draft.lang); + draft.presetText = xbrPresetTextFor(node, v, draft.lang, xbrResolveIo(node, draft.ioMode)); ctx.rerender(); // 有/无设定词的模式之间切换 → 重画「设定词」区 }))); - body.append(xbrHint("常规文生图 = 只输出正文;人物三视图 / 人物四视图 / 人物五视图 / 背景纯透明 = 最终提示词最顶端自动加上该模式的设定词。")); + // 当前模版 + 本档提示 + const needsImgP = (typeof modeNeedsImage === "function") && modeNeedsImage(draft.mode); + const hasImgP = (typeof modeHasImage === "function") && modeHasImage(node); + const ioNowP = xbrResolveIo(node, draft.ioMode); + body.append(el("div", "font-size:11px;color:#8fb;line-height:1.7;margin:2px 0 4px;", + "当前模版:" + ioNowP + + (draft.ioMode === "自动" ? ("(自动:" + (hasImgP ? "检测到 🖼️ 图像已接线" : "未接 🖼️ 图像") + ")") + : "(手动指定)") + + (needsImgP ? " ❗本档属于图生图档" : ""))); + body.append(el("div", "font-size:11px;color:" + (needsImgP ? "#d9a441" : "#888") + ";line-height:1.7;margin:2px 0 8px;", + (draft.mode === MODE_TP) + ? "无预设:接不接参考图、哪个模版,都不会前置设定词(下面「设定词」栏保持空白;想加就自己写)。" + : (needsImgP + ? (ioNowP === "文生图" + ? "⚠️ 本档属于「图生图」,但当前是文生图模版:下面用的是从零生成的写法(不依赖任何输入图)。把参考图接到 🖼️ 图像即可切回图生图模版。" + : "本档属于「图生图」:需开启 ✅ 启用 LLM 反推,并把参考图接到 🖼️ 图像。") + : (ioNowP === "图生图" + ? "本档属于「文生图」,当前用图生图模版:设定词末尾会追加「以输入图为准」的条款,需接参考图。" + : "本档属于「文生图」:不接图也行;接图后把模版改成「图生图」(或保持自动)会追加「以输入图为准」的条款。")))); - // ── 设定词(预设模式对应的设定文本;只在弹窗里编辑,节点表面不显示)── - const modeDef = defaultPresetOf(draft.mode, draft.lang); - if (modeDef) { - body.append(makeSectionTitle("设定词")); - if (!String(draft.presetText || "").trim()) draft.presetText = modeDef; + // ── 设定词(按「模式 × 模版 × 语言」取;只在弹窗里编辑,节点表面不显示)── + // 无预设档:这一栏**照样显示**,只是内容空白(用户可以自己写) + const modeDef = presetTextOf(draft.mode, draft.lang, ioNowP); + { + body.append(makeSectionTitle("设定词(" + ioNowP + "模版)")); + draft.presetText = String(draft.presetText || ""); + if (modeDef && !draft.presetText.trim()) draft.presetText = modeDef; const taP = textareaControl(draft.presetText, (v) => { draft.presetText = v; draft.presetTouched = true; }, "width:100%;box-sizing:border-box;min-height:180px;resize:vertical;"); - taP.placeholder = "该模式的设定词;改过的按「模式 + 语言」记进节点,换模式 / 换语言都不丢"; + taP.placeholder = modeDef + ? "该模式在「" + ioNowP + "」模版下的设定词;改过的按「模式 + 模版 + 语言」记进节点,换模式 / 换模版 / 换语言都不丢" + : "无预设:本档不前置设定词(保持空白即可;在这里写内容就会作为设定词置顶)"; taP.spellcheck = false; body.append(taP); const taRow = el("div", "display:flex;align-items:center;gap:10px;margin:6px 0 4px;"); - taRow.append(smallBtn("♻️ 恢复默认", "border:1px solid #555;background:#2a2a2a;color:#ccc;padding:4px 10px;", - "把设定词恢复为该模式 × 该语言的默认文本", - () => { draft.presetText = modeDef; draft.presetTouched = false; ctx.rerender(); })); + if (modeDef) { + taRow.append(smallBtn("♻️ 恢复默认", "border:1px solid #555;background:#2a2a2a;color:#ccc;padding:4px 10px;", + "把设定词恢复为该模式 × 该模版 × 该语言的默认文本", + () => { draft.presetText = modeDef; draft.presetTouched = false; ctx.rerender(); })); + } taRow.append(el("span", "font-size:11px;color:#888;", - String(draft.presetText || "") === modeDef ? "当前 = 默认" : "当前 = 自定义")); + !modeDef ? (draft.presetText.trim() ? "当前 = 自定义" : "当前 = 空白(不前置设定词)") + : (draft.presetText === modeDef ? "当前 = 默认" : "当前 = 自定义"))); body.append(taRow); - body.append(el("div", "font-size:11px;color:#888;line-height:1.75;margin:6px 0 8px;", - "· 最终输出时,设定词会原封不动加在增强后提示词的最顶端;\n" - + "· LLM 只增强正文,把设定词当作增强参考,不会改写它;\n" - + "· 改过的设定词随节点保存,换模式 / 换语言都会取回你改过的那一版。")); - } else { - body.append(el("div", "font-size:11px;color:#888;line-height:1.75;margin:6px 0 8px;", "常规文生图 = 没有设定词,直接输出正文。")); + if (modeDef) { + body.append(el("div", "font-size:11px;color:#888;line-height:1.75;margin:6px 0 8px;", + "· 最终输出时,设定词会原封不动加在增强后提示词的最顶端;\n" + + "· LLM 只增强正文,把设定词当作增强参考,不会改写它;\n" + + "· 改过的设定词随节点保存,换模式 / 换模版 / 换语言都会取回你改过的那一版。")); + } else { + body.append(el("div", "font-size:11px;color:#888;line-height:1.75;margin:6px 0 8px;", + "· 无预设档:不管接不接参考图,节点都不会自动前置设定词;\n" + + "· 如果在这一栏写了内容,它会作为增强参考交给 LLM、并原封不动置于最终提示词最顶端。")); + } } body.append(makeSectionTitle("增强预设(= 提示词设定 / system prompt)")); @@ -3501,6 +3773,45 @@ function xbrRenderPreset(body, node, draft, ctx) { ta.placeholder = "追加在增强预设之后的额外要求,例:只输出一行、不要解释过程…"; ta.spellcheck = false; body.append(ta); + + // ── SKILL(系统提示词):常驻三态 自动 / 手动 / 不用 ── + body.append(makeSectionTitle("SKILL(系统提示词)")); + const skillOpts = xbrWidgetOptions(node, "skill_name"); + if (!skillOpts.length) skillOpts.push("不使用"); + if (!skillOpts.includes(draft.skill)) draft.skill = skillOpts[0]; + if (!XBR_SKILL_MODES.includes(draft.skillMode)) draft.skillMode = "自动"; + body.append(field("SKILL 模式", radioRow(XBR_SKILL_MODES, draft.skillMode, (v) => { draft.skillMode = v; ctx.rerender(); }))); + if (draft.skillMode === "手动") { + body.append(field("SKILL选择", selectControl(skillOpts, draft.skill, (v) => { draft.skill = v; }))); + } else if (draft.skillMode === "自动") { + const autoFileP = (typeof modeSkillHint === "function") ? modeSkillHint(draft.mode) : "-"; + body.append(el("div", "font-size:11px;color:#8fb;margin:2px 0 8px;", + "自动:本档(" + draft.mode + ")→ " + autoFileP)); + } else { + body.append(el("div", "font-size:11px;color:#d9a441;margin:2px 0 8px;", + "不用:SKILL 完全不生效(即使下面选过文件也不会送进 LLM)。")); + } + const foxRowP = el("div", "display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:2px 0 8px;"); + foxRowP.append(smallBtn("📂 打开 SKILL 文件夹", "border:1px solid #555;background:#2a2a2a;color:#ccc;padding:4px 10px;", + "在资源管理器里打开 support_llama/skills,方便新增 / 编辑技能文件", + async () => { + try { + const r = await fetch("/xb_toolbox/skill_folder"); + const j = await r.json().catch(() => ({})); + if (!r.ok || j.ok === false) notify("打开失败:" + (j.info || r.status), "error"); + else notify("已打开:" + (j.path || "skills"), "success"); + } catch (e) { notify("打开失败:" + ((e && e.message) || e), "error"); } + })); + body.append(foxRowP); + body.append(el("div", "font-size:11px;color:#888;line-height:1.6;margin:2px 0 8px;", + "技能文件放在 XB_ToolBox/support_llama/skills(.txt / .md):选中后整段作为 LLM 反推的 system prompt。")); + body.append(el("div", "font-size:11px;color:#888;line-height:1.75;margin:6px 0 8px;", + "· 自动(默认)= 按预设模式适配:图生图档 → system_prompt_edit,其余 → system_prompt_t2i;\n" + + "· 手动 = 用「SKILL选择」里选中的那个文件;\n" + + "· 不用 = 完全不生效(哪怕选了文件);\n" + + "· 选中技能后:技能放在系统提示词最前面当角色与总规则,且**不再叠加**节点的语言提示\n" + + " (这两个技能文件自带语言决策与输出契约,叠加会打架);\n" + + "· 「✅ 启用 LLM 反推」关闭时 SKILL 不参与;新增技能文件后刷新页面即可出现在下拉里。")); } /* ── 弹窗内容:📖 使用说明 ───────────────────────────────── */ @@ -3516,7 +3827,7 @@ function xbrRenderHelp(body) { " 3. 想让 AI 代写:开 ✅ 启用 LLM 反推,先到 🤖 LLM设置 选好模型或填好 API", "", "【节点表面】", - " · 第 1 行按钮:🤖 LLM设置 | ✨ 增强预设 | 📖 使用说明", + " · 第 1 行按钮:🤖 LLM设置 | ✨ 预设参数", " · 第 2 行按钮:🎨 风格 | 📐 视角 | 👤 主体 | 🕺 姿态 | 👗 装扮 | 🎒 道具 | 💡 光影 | 🏞️ 背景", " · 画幅比例 / 宽度 / 高度 / 生成数量:数值会按当前空latent类型的官方步长自动对齐", " · ✅ 启用 LLM 反推:关 = 只输出拼装好的提示词,不加载模型、不调 API", @@ -3548,13 +3859,32 @@ function xbrRenderHelp(body) { " 选你正在用的模型即可:通道数、下采样、尺寸步长、batch 上限会自动切换", "", "【预设模式】", - " · 常规文生图:只输出正文,不加任何设定词", - " · 人物三视图 / 人物四视图 / 人物五视图:最终提示词最顶端自动加上对应设定词", - " · 背景纯透明:加上 RGBA 透明背景设定词", + "【预设模式】(每档自带一段设定词,可自由修改)", + " · 文生图组:无预设 / 人物三视图 / 人物四视图 / 人物五视图 / 背景纯透明 /", + " 图文版面 / 信息图 / 多格分镜 / 广告分镜板", + " · 图生图组(需接参考图):保持主体换场景 / 局部编辑 /", + " 老照片修复 / 整图风格化 / 360°全景 / 多图指认合成", " · 设定词可以自由修改,改过的那一版按「模式 + 语言」记在节点里,换模式 / 换语言都不会丢", - " · 点 ♻️ 恢复默认 一键回到官方文本", + " · 点 ♻️ 恢复默认 一键回到官方文本;启用 LLM 时设定词另作增强参考交给模型", " · 启用 LLM 时,设定词只作为增强参考交给模型,输出时由节点原封不动加在最顶端", "", + "【文生图 / 图生图两套模版(全自动切换)】", + " · 没接 🖼️ 图像 → 走文生图模版:从零生成的写法,不会出现「参考输入图」类条款", + " · 接了 🖼️ 图像 → 走图生图模版:自动追加「以输入图为准」的条款,按序号引用第 1…N 张图", + " · 两种模版不用手动切:面板会显示当前用的是哪套 + 自动追加的条款原文", + " · 图生图档没接图也不会报错:会自动改成从零生成的写法(并在日志里提醒)", + "", + "【SKILL 技能(系统提示词)】", + " · ✨ 预设参数 → SKILL:support_llama/skills 里的 txt 整段当 LLM 的 system prompt", + " 面板里点「📂 打开 SKILL 文件夹」可直接打开,新增后刷新页面即出现在下拉", + " · SKILL 模式(常驻三选一,默认自动):", + " ◆ 自动 = 按预设模式适配:图生图档 → system_prompt_edit.txt,其余 → system_prompt_t2i.txt", + " ◆ 手动 = 用「SKILL选择」里选中的那个文件", + " ◆ 不用 = SKILL 完全不生效(哪怕选过文件)", + " · 选中后系统提示词 = 技能 + 增强预设 + 设定词参考 + 追加设定 + 任务块", + " · 技能自带语言与输出契约 → 选中时不再叠加节点的语言提示(避免互相对打)", + " · 勾选开关关掉 ✅ 启用 LLM 反推时 SKILL 不参与", + "", "【语言只有一处】", " 🤖 LLM设置 → 输出语言,它同时决定:词表与设定词按哪种语言加载、", " 增强预设与反推预设只列哪种语言的选项、最终提示词是中文还是英文", @@ -3594,9 +3924,22 @@ async function xbrOpenModal(node, panelId) { lang: xbrPick(String(xbrWidgetVal(node, "output_lang") ?? ""), LANGS, LANGS[0]), kind: xbrPick(String(xbrWidgetVal(node, "latent_kind") ?? ""), LATENT_KINDS, LATENT_KINDS[0]), mode: xbrPick(String(xbrWidgetVal(node, "preset_mode") ?? ""), MODES, MODES[0]), - // 设定词(三视图/四视图/五视图/背景纯透明):弹窗内草稿 + 「用户是否改过」标记 - presetText: String(xbrWidgetVal(node, "three_view_text") ?? ""), + // 模版(自动 / 文生图 / 图生图) + ioMode: (() => { const v = String(xbrWidgetVal(node, "io_mode") ?? ""); return XBR_IO_MODES.includes(v) ? v : "自动"; })(), + // 设定词(每个预设模式两套):弹窗内草稿 + 「用户是否改过」标记 + // widget 里是「遗留默认句 / 任一内置预设句」→ 视为没改过 → 取「存档 → 当前模版默认」(无预设 = 空) + presetText: (() => { + const w = String(xbrWidgetVal(node, "three_view_text") ?? ""); + const md = xbrPick(String(xbrWidgetVal(node, "preset_mode") ?? ""), MODES, MODES[0]); + const lg = xbrPick(String(xbrWidgetVal(node, "output_lang") ?? ""), LANGS, LANGS[0]); + const ioW = String(xbrWidgetVal(node, "io_mode") ?? ""); + if (w.trim() && !isDefaultPresetText(md, w)) return w; + return xbrPresetTextFor(node, md, lg, xbrResolveIo(node, XBR_IO_MODES.includes(ioW) ? ioW : "自动")); + })(), presetTouched: false, + // SKILL 选择(support_llama/skills 里的技能文件 = system prompt) + skill: String(xbrWidgetVal(node, "skill_name") ?? ""), + skillMode: (() => { const v = String(xbrWidgetVal(node, "skill_mode") ?? ""); return XBR_SKILL_MODES.includes(v) ? v : "自动"; })(), }; // 预设选项与输出语言对齐(旧工作流可能存着异语言的预设名) draft.preset = xbrSnapPreset(xbrWidgetOptions(node, "preset"), draft.preset, draft.lang); @@ -3625,12 +3968,15 @@ async function xbrOpenModal(node, panelId) { xbrSetWidget(node, "output_lang", draft.lang, true); // 唯一语言设置 xbrSetWidget(node, "latent_kind", draft.kind, true); // 触发基础面板的步长/上限联动 xbrSetWidget(node, "preset_mode", draft.mode, true); // 触发预设句框显隐 - // 设定词:写进节点 widget(原样)+ 按「模式|语言」存档 → 换模式 / 换语言都不丢 + xbrSetWidget(node, "io_mode", draft.ioMode); // 模版:自动 / 文生图 / 图生图 + xbrSetWidget(node, "skill_name", draft.skill); // SKILL = LLM 的 system prompt + xbrSetWidget(node, "skill_mode", draft.skillMode); // 自动 / 手动 / 不用 + // 设定词:写进节点 widget(原样)+ 按「模式|模版|语言」存档 → 换模式 / 换模版 / 换语言都不丢 + // ⚠️ 一律写(包含「无预设」时写空串)→ 顺手把 widget 里的遗留默认句清掉 try { - if (defaultPresetOf(draft.mode, draft.lang)) { - node.__ippSetPreset?.(draft.presetText); - node.__ippWritePreset?.(draft.mode, draft.lang, draft.presetText); - } + const ioCommit = xbrResolveIo(node, draft.ioMode); + node.__ippSetPreset?.(draft.presetText); + node.__ippWritePreset?.(draft.mode, ioCommit, draft.lang, draft.presetText); } catch (_) {} xbrSetWidget(node, "backend", draft.backend, true); xbrSetWidget(node, "preset", draft.preset, true); @@ -3666,7 +4012,7 @@ async function xbrOpenModal(node, panelId) { /* ── 按钮区 / 提示词框标题行 / 参数设定显示 / 锁定 ─────────── */ function xbrBuildButtonRow(node) { - const grid = el("div", "display:grid;grid-template-columns:repeat(3,1fr);grid-auto-rows:30px;gap:6px;flex:0 0 auto;"); + const grid = el("div", "display:grid;grid-template-columns:repeat(" + Math.max(1, XBR_PANEL_BUTTONS.length) + ",1fr);grid-auto-rows:30px;gap:6px;flex:0 0 auto;"); for (const b of XBR_PANEL_BUTTONS) { const btn = el("button", "width:100%;height:30px;box-sizing:border-box;display:flex;align-items:center;justify-content:center;border-radius:6px;border:2px solid #5b9bd5;background:#3a3a3a;color:#eee;font-size:13px;cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-family:inherit;", b.label); btn.title = `打开「${b.title}」弹窗`; @@ -3694,18 +4040,17 @@ function xbrInfoLines(node, promptText) { const useLlm = !!xbrWidgetVal(node, "use_llm"); const backend = String(xbrWidgetVal(node, "backend") ?? ""); const pMode = String(xbrWidgetVal(node, "preset_mode") ?? ""); - const pDef = defaultPresetOf(pMode, String(xbrWidgetVal(node, "output_lang") ?? "")) || ""; - const pCur = String(xbrWidgetVal(node, "three_view_text") ?? ""); + const pIo = xbrResolveIo(node, String(xbrWidgetVal(node, "io_mode") ?? "")); const line1 = `📊 空latent:${xbrClean(xbrWidgetVal(node, "latent_kind"))} | ${xbrWidgetVal(node, "width")}x${xbrWidgetVal(node, "height")} | 数量 ${xbrWidgetVal(node, "batch_size")}`; - const line2 = `🎨 预设模式:${xbrClean(pMode)}` - + (pDef ? ` | 设定词:${(pCur.trim() && pCur !== pDef) ? "✏️ 自定义" : "默认"}` : " | 设定词:无"); + // 只显示选项名称(预设模式 + 模版),不再显示设定词的状态 + const line2 = `🎨 预设模式:${xbrClean(pMode)} | 模版:${pIo}`; const line3 = `🌐 输出语言:${xbrClean(xbrWidgetVal(node, "output_lang"))}`; const line4 = useLlm ? (backend === "在线 API" ? `🤖 LLM 反推:已启用 | 在线API | ${xbrClean((node.__xbrProApiInfo?.model) || XBR_API_DEFAULTS.model)}` : `🤖 LLM 反推:已启用 | 本地模型 | ${xbrClean(llm.model.model) || "未选择模型"}`) : "🤖 LLM 反推:未启用"; - const line5 = `✨ 增强预设:${xbrClean(xbrWidgetVal(node, "preset")) || "-"} | 🎯 反推预设:${xbrClean(xbrWidgetVal(node, "task_preset")) || "-"}`; + const line5 = `✨ 预设参数:${xbrClean(xbrWidgetVal(node, "preset")) || "-"} | 🎯 反推预设:${xbrClean(xbrWidgetVal(node, "task_preset")) || "-"}`; const line6 = `⚙️ 温度 ${llm.params.temperature} · top_k ${llm.params.top_k} · top_p ${llm.params.top_p} · max_tokens ${llm.params.max_tokens} | ${xbrClean(llm.run.inference_mode)} | 种子 ${llm.run.seed}`; const line7 = `📝 提示词:${(promptText || "").length} 字`; return [line1, line2, line3, line4, line5, line6, line7].join("\n"); diff --git a/js/xb_qwen_prompt_preset.js b/js/xb_qwen_prompt_preset.js new file mode 100644 index 0000000..26f67cc --- /dev/null +++ b/js/xb_qwen_prompt_preset.js @@ -0,0 +1,4039 @@ +/** + * XB-BOX - 🖼️ 生图提示词预设 — 前端面板 + * ============================================================ + * 节点:XB_ImagePromptPreset(后端 nodes_image_prompt_preset.py,V1 经典 API) + * 表面:空latent类型 / 输出语言 / 预设模式 / (三视图/四视图/五视图预设句)/ 画幅比例 / 宽度 / 高度 / 生成数量 + * + 节点提示词框 + 8 个分类按钮(4 列 × 2 行):🏷️ 风格 | 🎥 视角 | 👤 主体 | 🎬 姿态 | 👚 装扮 | 🎒 道具 | 💡 光影 | 🏞️ 背景 + * 每个按钮一个面板,面板顶部「分类签」切子类;选项行整行可点(加入 → 变蓝 ✔ / 再点移除) + * 道具类点行会先弹「与主体的关系」多宫格标签窗(30 个关系词) + * + * 界面逻辑与「短剧导演台」(ComfyUI-JZL-MiniMax-H3/js/asset_manager.js)一致: + * · 容器 width/height:100% + overflow:hidden,提示词框 flex:1 1 auto 吃剩余高(CSS 排版,零 JS 高度计算) + * · refreshSize 只在「初始化 / 工作流加载」跑几次,用 computeSize 做下限保护,只增不减 + * · 打字 / 面板保存 / onResize 一律不 setSize、不重排(否则 setSize→onResize→setSize 抖动 → 界面卡顿) + * + * 配置一律存在节点的 `manager_settings` widget(JSON 字符串)里 —— + * 随工作流保存、节点间互不影响、不需要任何后端接口。 + * 提示词正文存在 `internal_prompt` widget(advanced 字段,节点表面不显示), + * 由「节点提示词框 ↔ 面板预览框」双向同步。 + * + * 本文件只 import xb_compat.js 的 2.0 兼容助手(其余是模块私有函数),自带十来个无状态 UI 助手。 + */ + +import { app } from "../../scripts/app.js"; +import { installNodes2BoxResize } from "./xb_compat.js"; + +const NODE_TYPE = "XB_QwenPromptPreset"; + +// ── 枚举(必须与 nodes_image_prompt_preset.py 的常量逐字一致) ───────── +const LANG_ZH = "中文 [ZH]"; +const LANG_EN = "英文 [EN]"; +const LANGS = [LANG_ZH, LANG_EN]; +const MODE_TP = "无预设"; // 不前置任何设定词,只输出正文(旧名「常规文生图」见 MODE_ALIASES) +const MODE_3V = "人物三视图"; +const MODE_4V = "人物四视图"; +const MODE_5V = "人物五视图"; +const MODE_RGBA = "背景纯透明"; +const MODE_UI = "图文版面"; +const MODE_INFO = "信息图"; +const MODE_STORYBOARD = "多格分镜"; +const MODE_ADBOARD = "广告分镜板"; +const MODE_KEEP_SUBJECT = "保持主体换场景"; +const MODE_LOCAL_EDIT = "局部编辑"; +const MODE_RESTORE = "老照片修复"; +const MODE_STYLIZE = "整图风格化"; +const MODE_PANO360 = "360°全景"; +const MODE_MULTI_REF = "多图指认合成"; +const MODES = [MODE_TP, MODE_3V, MODE_4V, MODE_5V, MODE_RGBA, MODE_UI, MODE_INFO, MODE_STORYBOARD, MODE_ADBOARD, MODE_KEEP_SUBJECT, MODE_LOCAL_EDIT, MODE_RESTORE, MODE_STYLIZE, MODE_PANO360, MODE_MULTI_REF]; +// 需要输入图的档位(图生图组)与推荐搭配的 SKILL +const MODE_ALIASES = { + "常规文生图": "无预设", // 0.10.x 旧名 → 新名(语义完全一致) + "📱 图文版面": "图文版面", + + "📊 信息图": "信息图", + + "🎞 多格分镜": "多格分镜", + + "🎬 广告分镜板": "广告分镜板", + + "🛍 保持主体换场景": "保持主体换场景", + + "✏️ 局部编辑": "局部编辑", + + "🧹 老照片修复": "老照片修复", + + "🎨 整图风格化": "整图风格化", + + "🌐 360°全景": "360°全景", + + "🗂 多图指认合成": "多图指认合成", + +}; +const MODE_NEEDS_IMAGE = [MODE_KEEP_SUBJECT, MODE_LOCAL_EDIT, MODE_RESTORE, MODE_STYLIZE, MODE_PANO360, MODE_MULTI_REF]; +const modeNeedsImage = (mode) => MODE_NEEDS_IMAGE.includes(mode); +const modeSkillHint = (mode) => (modeNeedsImage(mode) ? "system_prompt_edit.txt" : "system_prompt_t2i.txt"); + +// ── t2i / i2i 两套模版条款(与 nodes_image_prompt_preset.py 逐字一致,自测会断言)── +// · IO_I2I_COMMON:文生图档**接了图**时追加的「以输入图为准」条款 +// · 图生图档没接图时不再追加「忽略输入图」的尾条款 —— 改成直接用该档的「文生图版设定词」(PRESET_TEXT_T2I) +const IO_I2I_COMMON = {"zh": "【本次接了输入图】以输入图为唯一依据:保持输入图中被保留部分的原样不变(人物保持面容、发型、体型与气质;物件保持外形、比例、材质、颜色与文字标识),不得改动、美化、替换或增减我未指定的部分;本档规格只作用于画面的组织与重建区域。", "en": "Attached input image(s) are the single source of truth: keep everything kept from them unchanged — for people: face, hairstyle, build and character; for objects: shape, proportions, material, colour and printed text — and never alter, beautify, replace, add or remove anything I did not ask for. This mode's spec only governs how the frame is organised and rebuilt."}; +/** 按「本档类型 + 有没有输入图」返回要追加的模版条款(仅「文生图档 + 有图」→ 以输入图为准;其余 → "") + * ⚠️ 无预设档(MODE_TP)永远返回 "":接不接图,无预设的设定词都是空白 */ +function modeIoClause(mode, hasImage, lang) { + const m = (typeof MODE_ALIASES !== "undefined" && MODE_ALIASES[mode]) || mode; + const lg = (lang === LANG_EN) ? "en" : "zh"; + if (!m || m === MODE_TP || MODE_NEEDS_IMAGE.includes(m)) return ""; + return hasImage ? (IO_I2I_COMMON[lg] || "") : ""; +} +/** 节点是否接了参考图(🖼️ 图像 端口有连线) */ +function modeHasImage(node) { + try { + const inp = (node.inputs || []).find((i) => i.name === "images"); + return !!(inp && inp.link != null); + } catch (_) { return false; } +} + + + +const ASPECT_MAP = { "1:1": 1, "16:9": 16 / 9, "9:16": 9 / 16, "4:3": 4 / 3, "3:4": 3 / 4, "21:9": 21 / 9 }; +const SIZE_STEP = 16; // width/height 的兜底步长(实际以「空latent类型」的官方最小步长为准) +const SIZE_MIN = 16; +const SIZE_MAX = 16384; +const BATCH_MAX = 4096; + +// ── 主流模型的空 latent(选项名 = 模型名,规格按官方 latent_format;必须与后端 _OFFICIAL_LATENT_ROWS 逐字一致)── +// 选项按首字母(A→Z,不区分大小写)排列;DEFAULT_LATENT_KIND 固定 Z-image,不受排序影响。 +const LATENT_KINDS = [ + "Anima", "Boogu", "Flux2", "Hunyuan", "Krea2", "Qwen-image", "SD3", "SDXL", "Z-image", +]; +// 每项:channels(通道) / hDiv・wDiv(下采样除数) / step(尺寸步长 = 各模型官方最小步长) / min・max / batchMax / downscale / desc(悬停说明) +// 步长 = 下采样 × patch2:Anima/Boogu/SDXL = 8、Flux2 = 16、Krea2/SD3/Z-image = 16、Qwen-image = 32(按需求锁 32)、Hunyuan = 32 +const LATENT_KIND_SPEC = { + "Anima": { channels: 16, hDiv: 8, wDiv: 8, step: 8, min: 16, max: 16384, batchMax: 4096, downscale: 8, desc: "Anima:16 通道 · /8 · 步长 8(官方模板用 4 通道 EmptyLatentImage,零 latent 会被自动补齐)" }, + "Boogu": { channels: 16, hDiv: 8, wDiv: 8, step: 8, min: 16, max: 16384, batchMax: 4096, downscale: 8, desc: "Boogu:16 通道 · /8 · 步长 8(同上,官方 latent_format = Flux)" }, + "Flux2": { channels: 128, hDiv: 16, wDiv: 16, step: 16, min: 16, max: 16384, batchMax: 4096, downscale: 16, desc: "Flux2:128 通道 · /16 · 步长 16(官方 EmptyFlux2LatentImage)" }, + "Hunyuan": { channels: 64, hDiv: 32, wDiv: 32, step: 32, min: 64, max: 16384, batchMax: 4096, downscale: 32, desc: "Hunyuan:64 通道 · /32 · 步长 32(官方 EmptyHunyuanImageLatent / HunyuanImage 2.1)" }, + "Krea2": { channels: 16, hDiv: 8, wDiv: 8, step: 16, min: 16, max: 16384, batchMax: 4096, downscale: 8, desc: "Krea2:16 通道 · /8 · 步长 16(官方 latent_format = Wan21)" }, + "Qwen-image": { channels: 16, hDiv: 8, wDiv: 8, step: 32, min: 32, max: 16384, batchMax: 4096, downscale: 8, desc: "Qwen-image:16 通道 · /8 · 步长 32(官方 latent_format = Wan21,采样时自动补 T 维;按需求锁 32)" }, + "SD3": { channels: 16, hDiv: 8, wDiv: 8, step: 16, min: 16, max: 16384, batchMax: 4096, downscale: 8, desc: "SD3:16 通道 · /8 · 步长 16(官方 EmptySD3LatentImage)" }, + "SDXL": { channels: 4, hDiv: 8, wDiv: 8, step: 8, min: 16, max: 16384, batchMax: 4096, downscale: 8, desc: "SDXL:4 通道 · /8 · 步长 8(官方 EmptyLatentImage)" }, + "Z-image": { channels: 16, hDiv: 8, wDiv: 8, step: 16, min: 16, max: 16384, batchMax: 4096, downscale: 8, desc: "Z-image:16 通道 · /8 · 步长 16(官方 latent_format = Flux)" }, +}; +const DEFAULT_LATENT_KIND = "Z-image"; + +/** 各预设模式的默认预设句(可编辑字段 three_view_text);无预设 = 不前置(原样输出正文) */ +const PRESET_TEXT_DEFAULT = { + [MODE_3V]: { + [LANG_ZH]: "生成平行排列的角色概念设计图,画面从左到右由四个独立面板组成:第一个面板是角色面部的精细特写肖像,第二个面板是人物正面全身站姿,第三个面板是人物侧面全身站姿,第四个面板是人物背面全身站姿。", + [LANG_EN]: "Generate a character concept design sheet arranged in parallel panels, the image is composed of four separate panels from left to right: the first panel is a finely detailed close-up portrait of the character's face, the second panel is a full-body front standing pose, the third panel is a full-body side standing pose, the fourth panel is a full-body back standing pose.", + }, + [MODE_4V]: { + [LANG_ZH]: "生成四宫格排列的角色概念设计图,画面左上角面板是角色面部的精细特写肖像,画面右上角面板是角色面部侧面的的精细特写肖像,画面左下角面板是无头部人物正面衣着展示图,画面右下角面板是人物背面全身站姿。", + [LANG_EN]: "Generate a character concept design sheet arranged in a 2x2 grid, the top-left panel is a finely detailed close-up portrait of the character's face, the top-right panel is a finely detailed close-up profile portrait of the character's face, the bottom-left panel is a headless front-facing outfit display view, the bottom-right panel is a full-body back standing pose.", + }, + [MODE_5V]: { + [LANG_ZH]: "生成五宫格排列的角色概念设计图,画面左上角面板是角色面部的精细特写肖像,画面右上角面板是角色面部侧面的的精细特写肖像,画面下方左侧面板是无头部人物正面衣着展示图,画面下方中间面板是无头部人物侧面衣着展示图,画面下方右侧面板是人物背面全身站姿。", + [LANG_EN]: "Generate a character concept design sheet arranged in five panels, the top-left panel is a finely detailed close-up portrait of the character's face, the top-right panel is a finely detailed close-up profile portrait of the character's face, the bottom-left panel is a headless front-facing outfit display view, the bottom-middle panel is a headless side-facing outfit display view, the bottom-right panel is a full-body back standing pose.", + }, + [MODE_RGBA]: { + [LANG_ZH]: "生成一张具有透明度的 RGBA 格式图像,包含 Alpha 通道,背景为纯透明。", + [LANG_EN]: "Generate an RGBA image with an alpha channel and a fully transparent background.", + }, + [MODE_UI]: { + [LANG_ZH]: "这是一张完整的平面版面设计图,画面边缘即版面边缘,全出血排版,无多余边框与水印。【版面结构】按层级自上而下组织:① 顶部信息区(标题栏/状态栏,界面类需含时间、信号、电量等图标);② 主体内容区(卡片、栏位、区块,边距统一、圆角一致、层级分明、网格对齐);③ 底部信息区(按钮、标签、脚注)。【文字】画面中所有文字必须逐字使用我给出的原文,不得增删改字,不得生成任何我未指定的文字;字体清晰端正、字号字重按层级区分,无错字无乱码无伪文字。【风格】排版精致、留白充足、有呼吸感,配色统一克制;任何元素都不得遮挡文字。", + [LANG_EN]: "Produce one complete flat print layout: the image edge is the layout edge, full bleed, no extra frame or watermark. Structure top to bottom: (1) top information zone (title bar / status bar, with clock, signal and battery icons for UI); (2) main content zone (cards, columns and blocks with uniform margins, consistent corner radii, clear hierarchy, grid-aligned); (3) bottom information zone (buttons, tags, footnotes). All text must use my wording word for word, with no additions, deletions or substitutions, and no text I did not specify; crisp legible type, size and weight distinguished by hierarchy, no typos, no gibberish. Refined layout, generous breathing space, restrained unified palette; nothing may cover the text.", + }, + [MODE_INFO]: { + [LANG_ZH]: "这是一张信息图/科普拆解图,版式整洁、以信息可读性优先,具有教学与数据可视化气质。【版面】① 顶部标题栏(主标题 + 副标题/卷号/徽标);② 主体由多个信息模块组成,按需包含:结构拆解图、局部放大详图、编号引线说明、图例、参数表、色卡、雷达图、时间轴、关键词云;③ 模块之间用细分割线或留白分隔,网格对齐、边距统一。【编号与引线】主体图形用圆形编号点标注,旁侧以细引线连接简短说明,编号连续、不重复、不跳号。【文字】所有文字逐字使用我给出的原文,不得生成任何我未指定的文字,无错字无乱码。", + [LANG_EN]: "Produce an infographic / annotated explanation card: tidy layout, information legibility first, a teaching and data-visualisation feel. Layout: (1) a top title bar (main title plus subtitle, volume number or badge); (2) a body made of several information modules, optionally including a structural cutaway, zoomed detail views, numbered leader-line callouts, a legend, a parameter table, a colour swatch strip, a radar chart, a timeline and a keyword cloud; (3) modules separated by hairline rules or whitespace, grid-aligned with uniform margins. Numbering: mark the main graphic with circular numbered dots and connect short captions with thin leader lines; numbers are continuous and never repeated. All text must use my wording word for word, with no unspecified text, no typos, no gibberish.", + }, + [MODE_STORYBOARD]: { + [LANG_ZH]: "生成一张由多个连续分镜格组成的叙事插画,输出为一张完整拼合的扁平整图,不要输出多张分离图片。【版面硬性要求】① 格数与我要求一致,所有格等宽等高;② 严格按我要求的排布方式(单横排或网格),不得擅自拆格、并格或改行数;③ 每格是独立取景,格与格之间用细边框或清晰留白分隔;④ 每格左上角标注圆形数字编号,编号连续、不重复、不跳号不缺号。【内容硬性要求】每一格都必须画出主体本人在做该格的动作,主体清晰完整、占该格画面主体;严禁出现只有背景没有人物的空格;所有格的画风、光影、色彩与主体外观保持一致,仅姿势、表情、动作与场景变化。【文字】除编号外不要生成任何文字、字幕或水印。", + [LANG_EN]: "Produce a narrative illustration made of several sequential storyboard panels, delivered as one fully composited flat sheet, not multiple separate files. Layout (mandatory): (1) exactly the panel count I ask for, all panels equal in width and height; (2) strictly the arrangement I ask for (single horizontal row or grid), never splitting, merging or re-flowing panels; (3) each panel is its own framing, separated by thin borders or clear gutters; (4) a circular number badge in the top-left of every panel, continuous, never repeated or skipped. Content (mandatory): every panel must show the subject itself performing that panel's action, clearly and completely, occupying the panel's main subject; never leave a panel with only background and no character; painting style, lighting, colour and the subject's look stay consistent across panels, only pose, expression, action and setting change. No text, captions or watermarks other than the numbers.", + }, + [MODE_ADBOARD]: { + [LANG_ZH]: "生成一张商业广告分镜板/脚本视觉板,像专业品牌团队制作的提案板:既有分镜画面,也带脚本信息。【版面】① 顶部标题区(项目名/产品名 + 副标题 + 品牌标识感排版);② 主体横向排列若干连续分镜画面,每格独立取景、格间清晰分隔;③ 每格下方或侧方配栏目文字:镜头序号、时长或时间轴、镜头说明、字幕/台词、转场提示;④ 底部可放时间轴条与总时长。【风格】排版统一精致、配色克制高级,画面具有电影感与广告质感。【文字】画面中所有文字必须逐字使用我给出的原文,不得生成任何我未指定的文字,不得出现乱码、错字或伪文字。", + [LANG_EN]: "Produce a commercial advertising storyboard / script visual board, like a real brand team's pitch board: storyboard frames plus script information. Layout: (1) a top title zone (project or product name, subtitle, brand-like typography); (2) a body of several sequential storyboard frames in a row, each its own framing with clear separation; (3) caption blocks under or beside each frame with shot number, duration or timeline, shot description, subtitle or dialogue and transition note; (4) an optional timeline bar and total duration at the bottom. Style: unified refined layout, restrained premium palette, cinematic advertising quality. All text must use my wording word for word, with no unspecified text, no gibberish and no fake typography.", + }, + [MODE_KEEP_SUBJECT]: { + [LANG_ZH]: "以输入图像中的主体为唯一依据:先完整识别它的全部外观细节,再把它放入新的场景中。【必须保持】主体的外形、比例、结构、材质、颜色、文字与标识、磨损与光泽等全部细节与新画面完全一致;不得改造、不得美化、不得替换、不得增减部件;主体是人物时保持面容、五官、发型、体型与肤色不变。【必须移除】输入图中的摄影棚背景、手持、支架、阴影底板等非主体元素,以及任何不属于最终画面的杂物。【新画面】按我的要求重建场景、构图、光线与景深;主体与新场景的光影、透视、色温自然统一,接触面有合理投影,看起来就是在该空间里真实拍摄的一张画面。", + [LANG_EN]: "Treat the subject in the input image as the single source of truth: read every appearance detail first, then place it into a new scene. Must keep: shape, proportions, structure, material, colour, printed text and logos, wear and gloss identical to the input; do not redesign, beautify, replace, add or remove parts; for a person, keep face, features, hairstyle, build and skin tone. Must remove: studio backdrop, hands, stands, shadow board and any other non-subject elements or clutter. New frame: rebuild setting, composition, lighting and depth of field as I ask; the subject must match the new scene in light, perspective and colour temperature, sit on believable contact shadows, and look like a real photograph taken in that space.", + }, + [MODE_LOCAL_EDIT]: { + [LANG_ZH]: "只修改我指定或标记的区域,其余部分与输入图保持完全一致(包含构图、透视、光线、色调与清晰度)。【编辑范围】仅限我指定/标记的区域或部位;未标记的内容一律不得改动、不得重绘、不得重新打光、不得改变材质。【标记优先】若输入包含标记图、掩码图或涂抹标注,则以标注范围为准,只在该范围内操作。【过渡】修改区域与周边必须在材质、纹理与光影方向上自然衔接,边界不得出现生硬接缝、色块、描边或亮度跳变。【输出】只输出修改后的一整张完整画面,不要输出对比图、标注框、箭头或任何说明文字。", + [LANG_EN]: "Change only the region or part I specify or mark; everything else stays exactly as in the input (composition, perspective, lighting, colour and sharpness). Scope: only my specified or marked region; anything unmarked must not be altered, repainted, relit or re-materialised. Marking wins: if the input includes an annotated, masked or painted region, operate strictly inside it. Blending: the edit must match its surroundings in material, texture and light direction, with no hard seams, colour patches, outlines or brightness jumps. Output one single finished image only, with no comparison view, boxes, arrows or explanatory text.", + }, + [MODE_RESTORE]: { + [LANG_ZH]: "把输入的老照片修复成清晰、自然、真实的彩色照片,并保持原照片的内容与人物五官解剖完全不变。【修复】去除噪点、划痕、斑点、折痕、褪色与颗粒,恢复细节与层次、平衡高光与阴影;不得过度锐化,不得出现塑料感、蜡感或油画涂抹感。【上色】按真实肤色与材质自然上色,色调统一可信,不做夸张调色。【保持】人物面部结构、皱纹、神态、视线、头部角度、手部与持物关系、服装材质与褶皱、背景陈设与景深关系均不得改变。【输出】只输出修复后的一整张完整画面,不加边框、不加文字或水印。", + [LANG_EN]: "Restore the input vintage photograph into a sharp, natural, realistically colourised photo, keeping the original content and facial anatomy unchanged. Repair: remove noise, scratches, spots, creases, fading and grain; recover detail and tonal range and balance highlights and shadows; never oversharpen, never produce waxy, plastic or painterly skin. Colour: natural believable skin and material tones, unified grading, no exaggerated stylisation. Keep: facial structure, wrinkles, expression, gaze, head angle, hands and how they hold objects, garment material and folds, background props and depth of field. Output one single finished image with no border, text or watermark.", + }, + [MODE_STYLIZE]: { + [LANG_ZH]: "把输入图整张转换为指定的视觉媒介与画风,转换必须覆盖整幅画面,不得只做局部滤镜或只改一部分物体。【保持】原图的构图、画幅比例、视角、主体位置与姿态、景物之间的空间关系、已有文字与招牌的位置及可读内容不得改变;不得新增标语、字幕或水印。【转换】按我要求的画风重绘每一个物体:笔触、色层、材质表现与光色关系统一;光的方向与原图一致。【输出】只输出转换后的一整张完整画面。", + [LANG_EN]: "Convert the whole input image into the requested visual medium and style; the conversion must cover the entire frame, not a local filter or only some objects. Keep: composition, aspect ratio, viewpoint, subject placement and pose, spatial relations between objects, and the position and legible content of existing signage and text; add no slogans, captions or watermarks. Convert: repaint every object in the requested style with consistent brushwork, colour layering, material rendering and light-colour relationships; light direction matches the original. Output one single converted image.", + }, + [MODE_PANO360]: { + [LANG_ZH]: "把输入的单视角照片扩展成一张完整的 360 度全景图。【投影】使用真正的等距圆柱投影(equirectangular),水平覆盖 360 度、垂直覆盖 180 度,包含头顶天空与脚下地面;左右两端无缝衔接,形成一张连续完整的场景。【补全】以输入图为基础,把相机背后的环境合理延伸(地面、墙体、植被、天空,光照方向一致),不得出现重复、断裂或接缝。【保持】输入图中的主体只出现一次,其外观、姿态以及与周围景物的相对关系保持原样,不得镜像或复制。【输出】严格 2:1 画幅比例,例如 2880x1440。", + [LANG_EN]: "Extend the input single-view photograph into a complete 360-degree panorama. Projection: true equirectangular, covering 360 degrees horizontally and 180 degrees vertically, including the sky overhead and the ground below; left and right edges must join seamlessly into one continuous scene. Completion: extend the environment behind the camera plausibly (ground, walls, vegetation, sky, consistent light direction) with no repeated or broken areas. Keep: the subject appears exactly once, with its appearance, pose and relationship to surrounding objects unchanged, never mirrored or duplicated. Output at exactly a 2:1 aspect ratio, for example 2880x1440.", + }, + [MODE_MULTI_REF]: { + [LANG_ZH]: "按图号使用我提供的多张参考图,合成一张全新画面。【指认规则】场景与环境取自我指定的那一张图;其余各图分别提供角色外观或物件本体;我未指认的图不参与画面。【保持】被引用的角色保持面容、发型与体型一致;被引用的物件保持形状、材质、颜色与标识文字一致;不得替换、美化或增减部件。【重建】按我的要求安排构图、站位、光线与景深,所有元素统一在同一空间的光影与透视中,接触关系与投影合理。【文字】除我明确要求的外,不生成任何文字或水印。", + [LANG_EN]: "Use the reference images I provide by their index numbers and composite one brand-new image. Mapping: the scene and environment come from the image I designate; the other images supply either character likeness or an object itself; images I do not reference do not appear. Keep: referenced characters keep the same face, hairstyle and build; referenced objects keep the same shape, material, colour and printed text; no replacement, beautifying, additions or removals. Rebuild: arrange composition, staging, lighting and depth of field as I ask; all elements share one consistent space with matching light, perspective, contact and shadows. No text or watermark unless I explicitly ask for it.", + }, +}; +/** 某模式某语言下的默认预设句(无预设句的模式 → 空串) */ +const defaultPresetOf = (mode, lang) => ((PRESET_TEXT_DEFAULT[mode] || {})[lang] || ""); + +/* ── 模版(文生图 / 图生图):每个预设模式都有两套设定词 ──────────────────────── + * · 自动 = 按有没有接参考图判断(没接 → 文生图,接了 → 图生图) + * · 图生图档(6 档):PRESET_TEXT_DEFAULT = 图生图版,PRESET_TEXT_T2I = 文生图版 + * · 文生图档(8 档):PRESET_TEXT_DEFAULT = 文生图版,「图生图」版 = 原文 + 以输入图为准条款 + * ⚠️ 必须与 nodes_image_prompt_preset.py 的 IO_MODES / PRESET_TEXT_T2I / preset_text_for 逐字一致(自测断言) + */ +const IO_AUTO = "自动"; +const IO_T2I = "文生图"; +const IO_I2I = "图生图"; +const IO_MODES = [IO_AUTO, IO_T2I, IO_I2I]; +/** 图生图档专用文本(文生图模版) */ +const PRESET_TEXT_T2I = { + "保持主体换场景": { + "zh": "生成一张主体清晰的画面:先完整确定主体的全部外观细节,再把它放进我要求的新场景中。【必须保持】主体的外形、比例、结构、材质、颜色、文字与标识、磨损与光泽等全部细节前后一致、符合真实物理;不得改造、不得美化、不得替换、不得增减部件;主体是人物时保持面容、五官、发型、体型与肤色自然可信。【必须干净】画面中不得出现摄影棚背景、手持、支架、阴影底板等杂物,也不得有任何不属于最终画面的元素。【新画面】按我的要求营造场景、构图、光线与景深;主体与新场景的光影、透视、色温自然统一,接触面有合理投影,看起来就是在该空间里真实拍摄的一张画面。", + "en": "Generate a frame with a clearly defined subject: first settle every appearance detail of the subject, then place it into the new scene I ask for. Must keep: shape, proportions, structure, material, colour, printed text and logos, wear and gloss stay self-consistent and physically believable; do not redesign, beautify, replace, add or remove parts; for a person, keep face, features, hairstyle, build and skin tone natural and convincing. Must be clean: no studio backdrop, hands, stands, shadow board or any element that does not belong in the final frame. New frame: build the setting, composition, lighting and depth of field as I ask; the subject must match the scene in light, perspective and colour temperature, sit on believable contact shadows, and look like a real photograph taken in that space." + }, + "局部编辑": { + "zh": "生成一张完整画面,并把我的改动直接做进画面里。【改动范围】只按我的要求改我指定的部位,其余内容保持稳定一致:不得顺手重绘、不得重新打光、不得改变材质与配色。【过渡】改动区域与周边必须在材质、纹理与光影方向上自然衔接,边界不得出现生硬接缝、色块、描边或亮度跳变。【输出】只输出最终的一整张完整画面,不要输出对比图、标注框、箭头或任何说明文字。", + "en": "Generate one complete frame with my change built directly into the image. Scope: change only the part I ask for; keep everything else stable and consistent — do not incidentally repaint, relight, or alter materials and palette. Blending: the changed area must match its surroundings in material, texture and light direction, with no hard seams, colour patches, outlines or brightness jumps. Output one single finished frame only, with no comparison view, boxes, arrows or explanatory text." + }, + "老照片修复": { + "zh": "直接生成一张清晰、自然、细节完整的照片,并带有老照片翻新后的质感。【画面】主体与场景按我的描述生成,人物面部结构、五官解剖与比例自然准确,手部与持物关系合理。【修复感】不要噪点、划痕、斑点、折痕、褪色与颗粒,细节与层次完整、高光与阴影平衡;不得过度锐化,不得出现塑料感、蜡感或油画涂抹感。【色彩】按真实肤色与材质自然上色,色调统一可信,不做夸张调色。【输出】只输出一整张完整画面,不加边框、不加文字或水印。", + "en": "Generate directly a sharp, natural, fully detailed photograph with the look of a restored vintage print. Frame: build the subject and setting from my description, with natural accurate facial structure, anatomy and proportions, and believable hands and grip. Restoration feel: no noise, scratches, spots, creases, fading or grain; complete detail and tonal range, balanced highlights and shadows; never oversharpen, never produce waxy, plastic or painterly skin. Colour: natural believable skin and material tones, unified grading, no exaggerated stylisation. Output one single finished image with no border, text or watermark." + }, + "整图风格化": { + "zh": "生成一张完整的全新画面,并整体转换成我要求的视觉媒介与画风,转换必须覆盖整幅画面,不得只做局部滤镜或只改一部分物体。【画面】构图、画幅比例、视角与主体由我的描述决定,主体位置与姿态清晰合理。【转换】画面里的每一个物体都按该画风重绘:笔触、色层、材质表现与光色关系统一;光的方向明确一致。【文字】除我明确要求的外不生成任何文字、标语、字幕或水印。【输出】只输出一整张完整画面。", + "en": "Generate one complete new frame and convert the whole of it into the requested visual medium and style; the conversion must cover the entire frame, not a local filter or only some objects. Frame: composition, aspect ratio, viewpoint and subject come from my description, with the subject clearly and plausibly placed and posed. Convert: repaint every object in that style with consistent brushwork, colour layering, material rendering and light-colour relationships; keep one clear light direction. Text: no text, slogans, captions or watermarks unless I explicitly ask for them. Output one single complete frame." + }, + "360°全景": { + "zh": "生成一张完整的 360 度全景图。【投影】使用真正的等距圆柱投影(equirectangular),水平覆盖 360 度、垂直覆盖 180 度,包含头顶天空与脚下地面;左右两端无缝衔接,形成一张连续完整的场景。【补全】以输入图为基础,把相机背后的环境合理延伸(地面、墙体、植被、天空,光照方向一致),不得出现重复、断裂或接缝。【保持】输入图中的主体只出现一次,其外观、姿态以及与周围景物的相对关系保持原样,不得镜像或复制。【输出】严格 2:1 画幅比例,例如 2880x1440。", + "en": "Generate a complete 360-degree panorama. Projection: true equirectangular, covering 360 degrees horizontally and 180 degrees vertically, including the sky overhead and the ground below; left and right edges must join seamlessly into one continuous scene. Completion: extend the environment behind the camera plausibly (ground, walls, vegetation, sky, consistent light direction) with no repeated or broken areas. Keep: the subject appears exactly once, with its appearance, pose and relationship to surrounding objects unchanged, never mirrored or duplicated. Output at exactly a 2:1 aspect ratio, for example 2880x1440." + }, + "多图指认合成": { + "zh": "直接按我的描述生成一张全新画面。【画面】按我的要求安排主体、场景、构图、站位、光线与景深,所有元素统一在同一空间的光影与透视中,接触关系与投影合理。【一致性】同一主体在各处的外观保持一致,不得替换、美化或增减部件。【文字】除我明确要求的外,不生成任何文字或水印。", + "en": "Generate one brand-new image directly from my description. Frame: arrange subjects, setting, composition, staging, lighting and depth of field as I ask; all elements share one consistent space with matching light, perspective, contact and shadows. Consistency: keep each subject's look identical everywhere, with no replacement, beautifying, additions or removals. Text: no text or watermark unless I explicitly ask for it." + } + }; +const ioModeOf = (v) => { const s = String(v == null ? "" : v).trim(); return IO_MODES.includes(s) ? s : IO_AUTO; }; +/** 把「自动」按有没有接参考图解析成 文生图 / 图生图 */ +const resolveIoMode = (io, hasImage) => { + const m = ioModeOf(io); + if (m !== IO_AUTO) return m; + return hasImage ? IO_I2I : IO_T2I; +}; +/** 该模式的默认模版(图生图档默认图生图,其余默认文生图) */ +const modeDefaultIo = (mode) => (MODE_NEEDS_IMAGE.includes(mode) ? IO_I2I : IO_T2I); +/** 该模式在某模版下的**默认**设定词(io 已归一为 文生图 / 图生图) + * ⚠️ PRESET_TEXT_DEFAULT 的键是「中文 [ZH]/英文 [EN]」,PRESET_TEXT_T2I 的键是 "zh"/"en" */ +const presetTextOf = (mode, lang, io) => { + if (!MODES.includes(mode)) return ""; + if (mode === MODE_TP) return ""; // 无预设:接不接图、哪个模版 → 设定词都是空白 + const lg = (lang === LANG_EN) ? "en" : "zh"; + const base = ((PRESET_TEXT_DEFAULT[mode] || {})[lang] || ""); + if (io === IO_T2I) { + if (!MODE_NEEDS_IMAGE.includes(mode)) return base; // 文生图档 → 原文 + return (((PRESET_TEXT_T2I[mode] || {})[lg]) || base); // 图生图档 → 文生图版 + } + if (MODE_NEEDS_IMAGE.includes(mode)) return base; // 图生图档 → 原文 + const clause = modeIoClause(mode, true, lang); // 文生图档 → 原文 + 条款 + return clause ? ((base ? base + " " : "") + clause).trim() : base; +}; +/** 该模式所有模版 × 所有语言的默认文本(判断「用户没改过」用) + * · 无预设档额外把它算「默认」:IO_I2I_COMMON 条款(早期版本在「图生图模版」下把它当设定词 + * 存进了 widget / 存档)+ **所有内置预设句**(节点上 three_view_text 的老默认值就是三视图 + * 预设句 → 无预设档必须当空白,不能当成用户自定义继续前置) */ +const allDefaultTextsOf = (mode) => { + const out = []; + for (const lg of LANGS) for (const io of [IO_T2I, IO_I2I]) { + const t = presetTextOf(mode, lg, io).trim(); + if (t && !out.includes(t)) out.push(t); + } + if (mode === MODE_TP) { + for (const k of ["zh", "en"]) { + const c = String(IO_I2I_COMMON[k] || "").trim(); + if (c && !out.includes(c)) out.push(c); + } + for (const table of [PRESET_TEXT_DEFAULT, PRESET_TEXT_T2I]) { + for (const texts of Object.values(table)) { + for (const t of Object.values(texts || {})) { + const s = String(t || "").trim(); + if (s && !out.includes(s)) out.push(s); + } + } + } + } + return out; +}; +/** 存档值是不是「没改过」(等于任一默认 / 旧条款)→ 是则不算自定义 */ +const isDefaultPresetText = (mode, text) => !!String(text || "").trim() + && allDefaultTextsOf(mode).includes(String(text).trim()); +/** 兼容旧引用:三视图预设句 */ +const THREE_VIEW_DEFAULT = PRESET_TEXT_DEFAULT[MODE_3V]; + +/** + * ── 预设句(设定词)档案 ───────────────────────────────────────────── + * 需求:预设模式(三视图 / 四视图 / 五视图 / 背景纯透明)的设定词可编辑,且**改过就存进节点、 + * 换模式 / 换语言都不丢**(切换时取「存档 → 默认」,绝不把用户改过的冲掉)。 + * 存档位置 = manager_settings.preset_texts(JSON 字符串随工作流一起保存): + * { "人物三视图|中文 [ZH]": "用户改过的设定词", … } ← 只存与默认不同的那条 + * 键里带语言:默认设定词本身就是中英两套,用户改的哪一套就记哪一套。 + */ +const presetKeyOf = (mode, io, lang) => `${mode}|${io}|${lang}`; +function parsePresetTexts(raw) { + const out = {}; + if (!raw || typeof raw !== "object") return out; + for (const [k, v] of Object.entries(raw)) { + if (typeof v !== "string" || !v.trim()) continue; + const seg = String(k).split("|"); + if (seg.length === 3) { // 新格式:模式|模版|语言 + if (!MODES.includes(seg[0]) || !IO_MODES.includes(seg[1]) || !LANGS.includes(seg[2])) continue; + out[`${seg[0]}|${seg[1]}|${seg[2]}`] = v; + } else if (seg.length === 2) { // 旧格式:模式|语言 → 按该档默认模版归档 + if (!MODES.includes(seg[0]) || !LANGS.includes(seg[1])) continue; + out[`${seg[0]}|${modeDefaultIo(seg[0])}|${seg[1]}`] = v; + } + } + return out; +} + +const SEP = { [LANG_ZH]: ",", [LANG_EN]: ", " }; + +/* ============================================================ + * 元素词表(自研原创 · 中英双语 · 六分类) + * [[key, 中文, English], ...];配置里只存 key —— 改文案不会让 + * 老工作流的「已选 / 描述」失效。 + * ============================================================ */ +/* ============================================================ + * 元素词表(自研原创 · 中英双语) + * · WORDS = 词条本体 [[key, 中文, English], ...](配置里只存 key) + * · CATEGORIES = 6 大类 + 每类的「子分类」(面板顶部用分类签切换) + * subs[].keys = 该子类包含哪些词条 key(顺序 = 面板显示顺序) + * ============================================================ */ +const CATEGORY_DEFS = { + prefix: { + id: "prefix", btn: "🏷️ 风格", label: "🏷️ 风格", + tip: "画面风格与媒介:真人摄影 / 动画 / 3D / 绘画插画 / 特殊风格", + subs: [ + { id: "real", label: "真人", keys: [ + "photo_hd", "snapshot_amateur", "documentary", "cinematic", "commercial", "studio_portrait", + "fashion_editorial", "vintage_film", "polaroid", "bw_photo", "long_exposure", "macro", + "aerial", "wide_angle", "fisheye", "tilt_shift", "infrared", "double_exposure", + "high_speed", "phone_night", "product_still", "food_photo", + "guofeng_hanfu_photo"] }, + { id: "anime", label: "动画", keys: [ + "anime_frame", "anime_movie", "comic_panel", "manga_line", + "anime_sakuga", "anime_tv", "anime_lite", "galgame_cg", "chibi", "webtoon", "us_comic", "ink_comic"] }, + { id: "cg3d", label: "3D", keys: [ + "cg3d_toon", "pixar3d", "low_poly", + "ue5_render", "blender_pbr", "clay_render", "isometric", "voxel", "miniature", "game_cinematic", "toy_render"] }, + { id: "paint", label: "绘画插画", keys: [ + "oil_painting", "watercolor", "ink_wash", "pencil_sketch", "charcoal", "colored_pencil", + "crayon", "concept_art", "game_keyart", "character_sheet", "flat_vector", "cyberpunk_art", + "steampunk_art", "gongbi", + "gouache", "acrylic", "pastel", "ink_line", + "gongbi_heavy"] }, + { id: "special", label: "特殊风格", keys: [ + "stop_motion_clay", "paper_cutout", "pixel_art", "vaporwave", "pop_art", "art_nouveau", + "ukiyo_e", "dunhuang", "porcelain", "shadow_puppet", + "risograph", "collage_art", "neon_sign", "glass_art", + "guochao_illustration", "qinglv_shanshui", "xianxia_film"] }, + ], + }, + angle: { + id: "angle", btn: "🎥 视角", label: "🎥 视角", + tip: "机位与构图:视点 / 构图 / 特写", + subs: [ + { id: "view", label: "视点", keys: [ + "front_view", "side_view", "three_quarter", "back_view", "top_down", "low_angle", + "dutch_angle", "over_shoulder", "from_above", "worm_eye", "eye_level", "overhead_45", + "pov_first_person"] }, + { id: "frame", label: "构图", keys: [ + "wide_shot", "half_body", "full_body", "rule_of_thirds", "symmetrical_comp", "center_comp", + "leading_lines", "frame_in_frame", "reflection_view", "silhouette_view", "foreground_blur", + "liubai_composition"] }, + { id: "detail", label: "特写", keys: [ + "close_up", "extreme_close", "hand_detail", "prop_detail"] }, + ], + }, + clothes: { + id: "clothes", btn: "👚 装扮", label: "👚 装扮", + tip: "从头到脚的外观:头饰 / 妆容 / 上装 / 下装 / 鞋履 / 包袋 / 配饰", + subs: [ + { id: "head", label: "头饰", keys: [ + "hat", "baseball_cap", "beanie", "bucket_hat", "straw_hat", "fedora", "witch_hat", "crown", + "hair_clip", "hairband", "hair_tie", "hair_ribbon", + "hairpin_jade", "buyao", "guan_jin", "mo_e", "zan_hua"] }, + { id: "makeup", label: "妆容", keys: [ + "makeup_natural", "makeup_red_lip", "makeup_smoky", "makeup_goth", "makeup_geisha", + "makeup_freckles", "makeup_glitter", "makeup_blush", "face_tattoo", "face_paint", + "lip_gloss", "korean_idol", + "huadian", "dai_mei", "taohua_zhuang"] }, + { id: "top", label: "上装", keys: [ + "casual", "dress", "long_dress", "short_dress", "shirt", "white_shirt", "blouse", "plaid_shirt", + "striped_top", "sweater", "turtleneck", "cardigan", "hoodie", "jacket", "leather_jacket", + "denim_jacket", "embroidered_jacket", "coat", "trench_coat", "down_jacket", "suit", "vest", + "tank_top", "tshirt", "crop_top", "lace_top", "overalls", "jumpsuit", "swimsuit", "swim_trunks", + "tracksuit", "school_uniform", "military_uniform", "nurse_uniform", "maid_outfit", "kimono", + "yukata", "hanfu", "cheongsam", "tang_suit", "hanbok", "period_robe", "floor_robe", "monk_robe", + "nun_habit", "wizard_robe", "knight_armor", "leather_armor", "cloak", "cape", "space_suit", + "mecha_suit", "diving_suit", "hazmat_suit", "hospital_gown", "bathrobe", "pajamas", + "wedding_dress", "evening_gown", "tailcoat", "silk_gown", "off_shoulder", "backless_dress", + "corset_dress", "floral_dress", + "hanfu_top", "daopao", "xiake_robe", "daxiushan", "ruqun", "armor_chinese", "hechang"] }, + { id: "bottom", label: "下装", keys: [ + "skirt", "layered_skirt", "jeans", "ripped_jeans", "trousers", "cargo_pants", "shorts", + "sweatpants", "loose_sweatpants", "leggings", "socks", "stockings", "tights", + "mamian_skirt", "baizhe_skirt", "denglong_ku"] }, + { id: "shoes", label: "鞋履", keys: [ + "sneakers", "canvas_shoes", "leather_shoes", "high_heels", "boots", "knee_boots", + "combat_boots", "suede_boots", "sandals", "slippers", "geta", "snow_boots", "rain_boots", + "yuntou_lv", "xiuhua_shoes"] }, + { id: "bag", label: "包袋", keys: [ + "backpack", "handbag", "crossbody", "tote", "briefcase", "leather_satchel", "waist_pack", + "school_bag", "basket_bag", "pouch", "luggage", "quiver"] }, + { id: "acc", label: "配饰", keys: [ + "scarf", "fur_shawl", "gloves", "mittens", "belt", "apron", "necktie", "bow_tie", "necklace", + "choker", "pendant", "earring", "ring", "bracelet", "watch", "brooch", "lace_trim", + "xiapi"] }, + ], + }, + props: { + id: "props", btn: "🎒 道具", label: "🎒 道具", + tip: "独立于主体的器物:兵器 / 法器 / 文房雅器 / 日用器物(点道具会先选「与主体的关系」)", + subs: [ + { id: "weapon", label: "兵器", keys: [ + "sword_chinese", "spear_chinese", "sabre_chinese", "dagger", "whip", "bow_arrow", + "hidden_dart", "round_shield", "halberd", "iron_staff"] }, + { id: "magic", label: "法器", keys: [ + "fuchen", "talisman_paper", "spirit_pearl", "ruyi_jade", "bronze_mirror", "ritual_bell", + "prayer_beads", "wooden_fish"] }, + { id: "refine", label: "文房雅器", keys: [ + "folding_fan", "tuan_shan", "jade_pendant", "xiangnang", "ink_brush", "scroll_painting", + "bamboo_flute", "go_board"] }, + { id: "daily", label: "日用器物", keys: [ + "lantern_paper", "wine_pot", "gourd_flask", "tea_set", "food_box", "bamboo_case", + "oil_silk_umbrella", "copper_coins"] }, + ], + }, + subject: { + id: "subject", btn: "👤 主体", label: "👤 主体", + tip: "画面主体:人类 / 动物 / 植物 / 物品 / 幻想生物", + subs: [ + { id: "human", label: "人类", keys: [ + "man", "woman", "young_man", "young_woman", "handsome_man", "beautiful_woman", "elderly", + "boy", "girl", "baby", "suit_man", "cheongsam_woman", "wuxia_swordsman", "hanfu_girl", "maid", + "nurse", "doctor", "officer", "soldier", "teacher", "scientist", "wizard", "witch", "knight", + "princess", "prince", "king", "ninja", "samurai", "assassin", "mechanic", "programmer", "chef", + "dancer", "singer", "musician", "athlete", "astronaut", + "daoshi", "xiannv", "nvxia", "shusheng"] }, + { id: "animal", label: "动物", keys: [ + "cat", "dog", "fox", "wolf", "tiger", "rabbit", "bird", "horse", "whale", "butterfly", + "crane", "jade_rabbit", "sika_deer", "panda"] }, + { id: "plant", label: "植物", keys: [ + "bouquet", "tree_pine", "cherry_blossom", "rose", "sunflower", "lotus", "tropical_leaf", + "moss", "vine", "bonsai", "maple_leaf", "wheat", "succulent", + "plum_blossom", "orchid", "chrysanthemum", "peony", "ginkgo"] }, + { id: "object", label: "物品", keys: [ + "puppet", "toy", "product", "buddha", "statue", "abstract_shapes", + "book_stack", "coffee_cup", "vintage_camera", "crystal_gem", "lantern_object", "paper_plane", + "vinyl_record", "pocket_watch", "sword_object", "food_dish", "car_object", "potion_bottle", + "incense_burner", "guqin", "oil_paper_umbrella", "porcelain_vase", "weiqi_board"] }, + { id: "fantasy", label: "幻想生物", keys: [ + "cyborg", "robot", "dragon", "phoenix", "monster", "fairy", "ghost", + "angel", "demon", "elf", "orc", "slime", "mecha", "undead", "kirin", "nine_tail_fox", + "spirit_beast", "golem", "alien", + "dragon_east", "mountain_spirit"] }, + ], + }, + action: { + id: "action", btn: "🎬 姿态", label: "🎬 姿态", + tip: "表情 / 姿势 / 动作", + subs: [ + { id: "expression", label: "表情", keys: [ + "look_viewer", "look_away", "look_up", "look_down", "look_back", "mouth_open", "mouth_half", + "mouth_closed", "eyes_open", "eyes_half", "eyes_closed", "wink", "squint", "smile", "laugh", + "smile_soft", "sad", "angry", "surprised", "pensive", "blank", "frown", "blush", "teary", + "bite_lip", "glance_smile", + "stern_look", "angry_glare", "calm_smile"] }, + { id: "pose", label: "姿势", keys: [ + "standing", "sitting", "sitting_side", "lying", "bent", "kneeling", "crouching", "leaning_back", + "lean_wall", "crossed_arms", "hands_hips", "hands_pockets", "chin_hand", "touch_hair", + "facing_away", + "meditate_lotus", "hands_behind", "stand_sword", "cupped_fist", + "bow_salute", "kneel_one_knee", "tai_chi", "hands_sleeves"] }, + { id: "motion", label: "动作", keys: [ + "walking", "running", "jumping", "climbing", "swimming", "flying", "falling", "floating", + "dancing", "spinning", "reaching", "waving", "clapping", "thumbs_up", "heart_hands", + "shade_eyes", "hold_cup", "clench_fist", "reading", "using_phone", "typing", "drawing_art", + "playing_instrument", "raise_weapon", "draw_sword", "swing_sword", "aim_gun", "cast_spell", + "draw_bow", "frozen_sprint", + "sword_dance", "sword_slash", "cast_seal", "fly_sword", "sleeve_sweep", + "ride_horse", "sword_point", "sheath_sword", "twin_swords", "spear_thrust", "palm_strike", + "flying_kick", "martial_stance", "fan_dance", "playing_guqin", "ink_calligraphy", + "drink_wine", "carry_umbrella", "leap_air", "meditate_float", "blink_flash", + "run_sprint", "walk_away", "shadow_step"] }, + ], + }, + environment: { + id: "environment", btn: "💡 光影", label: "💡 光影", + tip: "自然光 / 人造光 / 特效光 / 氛围色调", + subs: [ + { id: "natural", label: "自然光", keys: [ + "window_sun", "golden_hour", "natural_light", "rim_light", "backlight", "top_light", + "side_light", "moonlight", "moon_curtain", "starlight", "aurora", "lightning", "stormy_sky", + "foggy_dawn", "morning_rays", "sunrise_mountain", "sunset_sea", "desert_evening", "dusk_beach", + "twilight", "snow_dusk", "autumn_forest", + "snow_glow", "plum_light"] }, + { id: "artificial", label: "人造光", keys: [ + "soft_studio", "studio_softbox", "spotlight", "fluorescent", "industrial", "candle", + "fireplace", "campfire", "lantern", "neon_city", "neon_rain", "warm_neon", "blue_neon", + "police_lights", "emergency_red", "home_bedroom", + "palace_lantern", "temple_candle", "oil_lamp"] }, + { id: "fx", label: "特效光", keys: [ + "cyberpunk_glow", "magic_lit", "enchanted_forest", "ethereal", "bioluminescence", + "underwater_glow", "fireflies", "crystal_cave", "stained_glass", "apocalyptic", + "sword_qi", "spirit_flow", "magic_array", "buddha_halo"] }, + { id: "tone", label: "氛围色调", keys: [ + "low_key", "high_key", "chiaroscuro", "light_shadow", "shadow_window", "cozy_warm", + "forge_embers", + "warm_tone", "cool_tone", "high_saturation", "morandi", "faded_film", "teal_orange", + "ink_wash_tone"] }, + ], + }, + background: { + id: "background", btn: "🏞️ 背景", label: "🏞️ 背景", + tip: "自然 / 建筑 / 室内 / 抽象纯色", + subs: [ + { id: "nature", label: "自然", keys: [ + "village_rural", "village_mountain", "farmland", "vineyard", "wildflower", "meadow", + "forest_mist", "forest_dense", "redwoods", "bamboo", "mountain_range", "snow_mountains", + "desert_dunes", "desert_night", "ocean_storm", "beach_palm", "lake_mountain", "lake_misty", + "river_sunset", "waterfall", "coral_reef", "underwater_reef", "ice_cave", "frozen_lake", + "cave_entrance", "zen_garden", "greenhouse", "starry_sky", "cloud_sea", + "bamboo_forest", "peach_grove", "immortal_mountain", "lotus_pond", "snow_pine_peak"] }, + { id: "architecture", label: "建筑", keys: [ + "street_old", "street_rain", "alley_graffiti", "alley_dark", "market", "night_market", + "city_future", "skyscrapers", "city_skyline", "train_station", "subway", "harbor", + "fishing_village", "castle_dark", "castle_courtyard", "palace_hall", "ballroom", "temple_ruins", + "ruins_ancient", "warehouse", "theme_park", "museum", + "jiangnan_watertown", "palace_red_wall", "paifang_gate", "garden_corridor", "ancient_inn", "bamboo_pavilion"] }, + { id: "indoor", label: "室内", keys: [ + "cozy_bed", "living_room", "bedroom_cozy", "kitchen", "cafe", "bar_vintage", "restaurant", + "office", "control_room", "lab", "library", "bookshelves", "workshop", "spaceship", + "space_station", "flower_shop", "clothing_store", + "ancient_study", "shrine_hall", "ancient_bedroom", "tea_room"] }, + { id: "abstract", label: "抽象纯色", keys: [ + "studio_room", "white_backdrop", "black_backdrop", "gradient_backdrop", "bokeh_city", + "bokeh_nature", "paper_texture", + "seamless_paper", "studio_gray", "concrete_wall", "gradient_neon", "bokeh_lights", + "water_ripple", "silk_fold", "glass_frost", "star_gradient", "geometric_bg", "mirror_room", + "petals_bokeh", "xuan_paper", "ink_splash"] }, + ], + }, +}; + +/** 显示 / 拼装顺序(改这一行即可调整节点上 8 个按钮的顺序与成句顺序) + * 当前:风格 → 视角 → 主体 → 姿态 → 装扮 → 道具 → 光影 → 背景(用户指定) */ +const CAT_ORDER = ["prefix", "angle", "subject", "action", "clothes", "props", "environment", "background"]; +const CATEGORIES = CAT_ORDER.map((id) => CATEGORY_DEFS[id]); +const ALL_CATS = CATEGORIES.map((c) => c.id); +const catOf = (id) => CATEGORIES.find((c) => c.id === id) || CATEGORIES[0]; + +/** 道具 ↔ 主体的关系词(6 列 × 5 行 = 30 个,多宫格标签式选择) + * 中文前置(手拿折扇)、英文后置(a folding fan, held in the hand)→ 两种语言都通顺 */ +const RELATIONS = [ + ["hold", "手拿", "held in the hand"], + ["embrace", "怀抱", "cradled in the arms"], + ["carry_back", "背负", "carried on the back"], + ["step_on", "脚踩", "underfoot"], + ["head_top", "头顶", "balanced on the head"], + ["shoulder", "扛在肩上", "resting on the shoulder"], + ["bite", "嘴叼", "held in the mouth"], + ["both_hands", "双手捧", "cupped in both hands"], + ["one_hand", "单手拎", "dangling from one hand"], + ["chest", "抱在胸前", "held against the chest"], + ["waist", "挂在腰间", "hanging at the waist"], + ["back_waist", "佩在身后", "strapped behind the waist"], + ["lift_high", "高举过头", "raised high overhead"], + ["point_forward", "指向前方", "pointing forward"], + ["on_ground", "立于地面", "standing on the ground"], + ["stuck_ground", "插在地上", "stuck into the ground"], + ["lean_wall", "靠墙而立", "leaning against a wall"], + ["on_table", "摆在桌上", "lying on a table"], + ["arm_pit", "夹在腋下", "tucked under the arm"], + ["finger_spin", "指间旋转", "spinning between the fingers"], + ["hang_side", "垂在身侧", "hanging at the side"], + ["cross_body", "斜挎身上", "slung across the body"], + ["writing", "掭笔在手", "held in the writing hand"], + ["playing", "正在演奏", "being played"], + ["pour", "倾壶而倒", "tilted to pour"], + ["open_fan", "展开在手", "opened in the hand"], + ["sheathed", "收入鞘中", "sheathed"], + ["drawn", "拔出持握", "drawn from its sheath"], + ["scatter", "扬手撒出", "scattered"], + ["support", "杵地支撑", "planted as a support"], +]; +const REL_MAP = RELATIONS.reduce((m, [id, zh, en]) => (m[id] = { id, zh, en }, m), {}); +/** 取子类对象(subId 非法/缺失 → 第一个子类) */ +const subOf = (cat, subId) => { + const subs = (cat && cat.subs) || []; + return subs.find((s) => s.id === subId) || subs[0] || { id: "", label: "", keys: [] }; +}; +// 节点表面的 6 个按钮 = 6 个分类各自一个面板 +const PANEL_BUTTONS = CATEGORIES.map((c) => ({ key: c.id, label: c.btn, panel: c.id })); + +const WORDS = { + prefix: [ + ["photo_hd", "高清细节照片", "detailed high-resolution photo of"], + ["snapshot_amateur", "业余快照风格", "candid amateur snapshot of"], + ["documentary", "纪实摄影", "documentary photograph of"], + ["cinematic", "电影质感剧照", "cinematic film still of"], + ["commercial", "商业广告摄影", "commercial advertising shot of"], + ["studio_portrait", "影棚人像", "studio portrait of"], + ["fashion_editorial", "时尚杂志大片", "fashion editorial photo of"], + ["vintage_film", "复古胶片摄影", "vintage analog film photograph of"], + ["polaroid", "宝丽来即时成像", "polaroid instant photo of"], + ["bw_photo", "黑白摄影", "black and white photograph of"], + ["long_exposure", "长曝光摄影", "long exposure photograph of"], + ["macro", "微距摄影", "macro photograph of"], + ["aerial", "无人机航拍", "aerial drone shot of"], + ["wide_angle", "广角镜头", "wide-angle lens shot of"], + ["fisheye", "鱼眼镜头", "fisheye lens shot of"], + ["tilt_shift", "移轴摄影", "tilt-shift photograph of"], + ["infrared", "红外摄影", "infrared photograph of"], + ["double_exposure", "双重曝光", "double exposure photo of"], + ["high_speed", "高速运动摄影", "high-speed action photo of"], + ["phone_night", "夜间手机拍摄", "night phone snapshot of"], + ["anime_frame", "日式动画赛璐璐", "cel-shaded anime frame of"], + ["anime_movie", "动画电影剧照", "animated feature film still of"], + ["comic_panel", "美式漫画分镜", "american comic book panel of"], + ["manga_line", "漫画黑白线稿", "manga line art illustration of"], + ["cg3d_toon", "3D 卡通渲染", "3D toon-shaded render of"], + ["pixar3d", "皮克斯风格渲染", "Pixar-style 3D render of"], + ["cyberpunk_art", "赛博朋克插画", "cyberpunk illustration of"], + ["steampunk_art", "蒸汽朋克插画", "steampunk illustration of"], + ["oil_painting", "油画布面", "oil painting on canvas of"], + ["watercolor", "水彩插画", "watercolor illustration of"], + ["ink_wash", "水墨写意", "ink wash painting of"], + ["pencil_sketch", "铅笔素描", "pencil sketch of"], + ["charcoal", "炭笔速写", "charcoal sketch of"], + ["colored_pencil", "彩铅手绘", "colored pencil drawing of"], + ["crayon", "蜡笔涂鸦", "crayon doodle of"], + ["concept_art", "概念设定图", "concept art of"], + ["game_keyart", "游戏原画", "game key art of"], + ["character_sheet", "角色设定图", "character design sheet of"], + ["stop_motion_clay", "粘土定格动画", "claymation stop-motion still of"], + ["paper_cutout", "剪纸拼贴", "paper cutout collage of"], + ["flat_vector", "矢量扁平插画", "flat vector illustration of"], + ["pixel_art", "像素风", "pixel art of"], + ["low_poly", "低多边形", "low-poly render of"], + ["vaporwave", "蒸汽波美学", "vaporwave aesthetic image of"], + ["pop_art", "波普艺术海报", "pop art poster of"], + ["art_nouveau", "新艺术运动装饰", "art nouveau decorative artwork of"], + ["ukiyo_e", "浮世绘木刻版画", "ukiyo-e woodblock print of"], + ["dunhuang", "敦煌壁画", "Dunhuang mural style artwork of"], + ["porcelain", "青花瓷纹样", "blue-and-white porcelain pattern artwork of"], + ["gongbi", "工笔重彩", "gongbi fine-brush painting of"], + ["shadow_puppet", "皮影戏造型", "shadow puppet style artwork of"], + ["product_still", "产品静物摄影", "product still-life photograph of"], + ["food_photo", "美食摄影", "food photography of"], + ], + subject: [ + ["man", "一位男性", "a man"], + ["woman", "一位女性", "a woman"], + ["young_man", "一位青年男子", "a young man"], + ["young_woman", "一位青年女子", "a young woman"], + ["handsome_man", "一位英俊男子", "a handsome man"], + ["beautiful_woman", "一位美丽女子", "a beautiful woman"], + ["elderly", "一位老人", "an elderly person"], + ["boy", "一个男孩", "a boy"], + ["girl", "一个女孩", "a girl"], + ["baby", "一名婴儿", "a baby"], + ["suit_man", "一位西装男士", "a man in a suit"], + ["cheongsam_woman", "一位旗袍女子", "a woman in a cheongsam"], + ["wuxia_swordsman", "一位古装侠客", "a wuxia swordsman in period costume"], + ["hanfu_girl", "一位汉服少女", "a young woman in hanfu"], + ["maid", "一位女仆", "a maid"], + ["nurse", "一位护士", "a nurse"], + ["doctor", "一位医生", "a doctor"], + ["officer", "一位警察", "a police officer"], + ["soldier", "一位军人", "a soldier"], + ["teacher", "一位教师", "a teacher"], + ["scientist", "一位科学家", "a scientist"], + ["wizard", "一位魔法师", "a wizard"], + ["witch", "一位女巫", "a witch"], + ["knight", "一位骑士", "a knight"], + ["princess", "一位公主", "a princess"], + ["prince", "一位王子", "a prince"], + ["king", "一位国王", "a king"], + ["ninja", "一位忍者", "a ninja"], + ["samurai", "一位武士", "a samurai"], + ["assassin", "一位刺客", "an assassin"], + ["mechanic", "一位机械师", "a mechanic"], + ["programmer", "一位程序员", "a programmer"], + ["chef", "一位厨师", "a chef"], + ["dancer", "一位舞者", "a dancer"], + ["singer", "一位歌手", "a singer"], + ["musician", "一位乐手", "a musician"], + ["athlete", "一位运动员", "an athlete"], + ["astronaut", "一位宇航员", "an astronaut"], + ["cyborg", "一位半机械人", "a cyborg"], + ["robot", "一个机器人", "a robot"], + ["cat", "一只猫", "a cat"], + ["dog", "一只狗", "a dog"], + ["fox", "一只狐狸", "a fox"], + ["wolf", "一只狼", "a wolf"], + ["tiger", "一只老虎", "a tiger"], + ["dragon", "一条龙", "a dragon"], + ["phoenix", "一只凤凰", "a phoenix"], + ["rabbit", "一只兔子", "a rabbit"], + ["bird", "一只小鸟", "a small bird"], + ["horse", "一匹马", "a horse"], + ["whale", "一头鲸鱼", "a whale"], + ["butterfly", "一只蝴蝶", "a butterfly"], + ["monster", "一个怪物", "a monster"], + ["fairy", "一个妖精", "a fairy"], + ["ghost", "一个幽灵", "a ghost"], + ["puppet", "一个木偶", "a puppet"], + ["toy", "一个玩具", "a toy"], + ["product", "一件产品", "a product"], + ["buddha", "一尊佛像", "a buddha statue"], + ["bouquet", "一束花", "a bouquet of flowers"], + ["statue", "一座雕像", "a statue"], + ["abstract_shapes", "一组抽象形体", "abstract shapes"], + ], + action: [ + ["look_viewer", "看向镜头", "looking at the viewer"], + ["look_away", "望向别处", "looking away"], + ["look_up", "抬头仰望", "looking up"], + ["look_down", "低头俯视", "looking down"], + ["look_back", "回头回望", "looking back over the shoulder"], + ["mouth_open", "张着嘴", "open mouth"], + ["mouth_half", "微张着嘴", "slightly parted lips"], + ["mouth_closed", "紧闭双唇", "closed mouth"], + ["eyes_open", "睁大双眼", "eyes wide open"], + ["eyes_half", "半闭双眼", "half-closed eyes"], + ["eyes_closed", "闭着眼睛", "closed eyes"], + ["wink", "眨眼", "winking"], + ["squint", "眯着眼睛", "squinting"], + ["smile", "微笑", "smiling"], + ["laugh", "大笑", "laughing out loud"], + ["smile_soft", "微笑不语", "a gentle smile"], + ["sad", "悲伤的神情", "a sad expression"], + ["angry", "愤怒的神情", "an angry expression"], + ["surprised", "惊讶的神情", "a surprised expression"], + ["pensive", "沉思的神情", "a pensive expression"], + ["blank", "面无表情", "an expressionless face"], + ["frown", "皱着眉头", "frowning"], + ["blush", "脸红", "blushing"], + ["teary", "眼中含泪", "tearing up"], + ["bite_lip", "咬着嘴唇", "biting the lip"], + ["standing", "站立", "standing"], + ["sitting", "端坐", "sitting"], + ["sitting_side", "侧坐", "sitting sideways"], + ["lying", "躺卧", "lying down"], + ["bent", "俯身", "bent forward"], + ["kneeling", "单膝跪地", "kneeling on one knee"], + ["walking", "行走", "walking"], + ["running", "奔跑", "running"], + ["jumping", "跳跃", "jumping"], + ["climbing", "攀爬", "climbing"], + ["swimming", "游泳", "swimming"], + ["flying", "飞翔", "flying"], + ["falling", "下落", "falling"], + ["floating", "漂浮", "floating"], + ["dancing", "舞蹈", "dancing"], + ["spinning", "旋转", "spinning"], + ["leaning_back", "后仰", "leaning back"], + ["reaching", "伸手", "reaching out a hand"], + ["waving", "挥手", "waving"], + ["crossed_arms", "抱臂", "crossing the arms"], + ["hands_hips", "叉腰", "hands on hips"], + ["hands_pockets", "双手插兜", "hands in pockets"], + ["chin_hand", "托腮", "resting the chin on one hand"], + ["touch_hair", "撩头发", "adjusting the hair"], + ["raise_weapon", "举起武器", "raising a weapon"], + ["draw_sword", "拔剑", "drawing a sword"], + ["swing_sword", "挥剑", "swinging a sword"], + ["aim_gun", "举枪瞄准", "aiming a gun"], + ["cast_spell", "施放魔法", "casting a spell"], + ["draw_bow", "拉弓射箭", "drawing a bow"], + ["clench_fist", "握拳", "clenching a fist"], + ["clapping", "击掌", "clapping"], + ["thumbs_up", "竖起大拇指", "giving a thumbs up"], + ["heart_hands", "比心", "making a heart with both hands"], + ["shade_eyes", "手搭凉棚", "shading the eyes with one hand"], + ["hold_cup", "捧着杯子", "holding a cup"], + ["reading", "低头阅读", "reading a book"], + ["using_phone", "操作手机", "using a phone"], + ["typing", "敲击键盘", "typing on a keyboard"], + ["drawing_art", "执笔绘画", "drawing"], + ["playing_instrument", "弹奏乐器", "playing an instrument"], + ["glance_smile", "回眸一笑", "glancing back with a smile"], + ["lean_wall", "靠着墙壁", "leaning against a wall"], + ["crouching", "蹲下", "crouching"], + ["facing_away", "背对镜头", "facing away from the camera"], + ["frozen_sprint", "奔跑中的定格瞬间", "frozen mid-sprint"], + ], + clothes: [ + ["casual", "日常便装", "casual wear"], + ["dress", "连衣裙", "a dress"], + ["long_dress", "长裙", "a long dress"], + ["short_dress", "短连衣裙", "a short dress"], + ["skirt", "半身裙", "a skirt"], + ["layered_skirt", "层叠裙摆", "a layered skirt"], + ["shirt", "衬衫", "a shirt"], + ["white_shirt", "白衬衫", "a white shirt"], + ["blouse", "女式衬衣", "a blouse"], + ["plaid_shirt", "格纹衬衫", "a plaid shirt"], + ["striped_top", "条纹上衣", "a striped top"], + ["sweater", "毛衣", "a sweater"], + ["turtleneck", "高领毛衣", "a turtleneck"], + ["cardigan", "针织开衫", "a knit cardigan"], + ["hoodie", "连帽卫衣", "a hoodie"], + ["jacket", "夹克", "a jacket"], + ["leather_jacket", "皮夹克", "a leather jacket"], + ["denim_jacket", "牛仔外套", "a denim jacket"], + ["embroidered_jacket", "刺绣外套", "an embroidered jacket"], + ["coat", "大衣", "a coat"], + ["trench_coat", "风衣", "a trench coat"], + ["down_jacket", "羽绒服", "a down jacket"], + ["suit", "西装套装", "a tailored suit"], + ["vest", "马甲", "a vest"], + ["tank_top", "背心", "a tank top"], + ["tshirt", "T 恤", "a T-shirt"], + ["crop_top", "短款上衣", "a crop top"], + ["lace_top", "蕾丝上衣", "a lace top"], + ["jeans", "牛仔裤", "jeans"], + ["ripped_jeans", "破洞牛仔裤", "ripped jeans"], + ["trousers", "长裤", "trousers"], + ["cargo_pants", "工装裤", "cargo pants"], + ["shorts", "短裤", "shorts"], + ["sweatpants", "运动裤", "sweatpants"], + ["loose_sweatpants", "宽松卫裤", "loose sweatpants"], + ["leggings", "打底裤", "leggings"], + ["overalls", "背带裤", "overalls"], + ["jumpsuit", "连体服", "a jumpsuit"], + ["swimsuit", "泳装", "a swimsuit"], + ["swim_trunks", "泳裤", "swim trunks"], + ["tracksuit", "运动服", "a tracksuit"], + ["school_uniform", "校服", "a school uniform"], + ["military_uniform", "军装", "a military uniform"], + ["nurse_uniform", "护士服", "a nurse uniform"], + ["maid_outfit", "女仆装", "a maid outfit"], + ["kimono", "和服", "a kimono"], + ["yukata", "浴衣", "a yukata"], + ["hanfu", "汉服", "hanfu"], + ["cheongsam", "旗袍", "a cheongsam"], + ["tang_suit", "唐装", "a tang suit"], + ["hanbok", "韩服", "a hanbok"], + ["period_robe", "古装长袍", "a period robe"], + ["floor_robe", "拖地长袍", "a floor-length robe"], + ["monk_robe", "僧袍", "a monk's robe"], + ["nun_habit", "修女服", "a nun's habit"], + ["wizard_robe", "法师长袍", "a wizard robe"], + ["knight_armor", "骑士铠甲", "knight armor"], + ["leather_armor", "轻型皮甲", "light leather armor"], + ["cloak", "斗篷", "a cloak"], + ["cape", "披风", "a cape"], + ["scarf", "围巾", "a scarf"], + ["fur_shawl", "皮草披肩", "a fur shawl"], + ["gloves", "手套", "gloves"], + ["mittens", "连指手套", "mittens"], + ["belt", "腰带", "a belt"], + ["apron", "花边围裙", "a frilled apron"], + ["necktie", "领带", "a necktie"], + ["bow_tie", "领结", "a bow tie"], + ["necklace", "项链", "a necklace"], + ["choker", "项圈", "a choker"], + ["pendant", "吊坠", "a pendant"], + ["earring", "耳环", "earrings"], + ["ring", "戒指", "a ring"], + ["bracelet", "手镯", "a bracelet"], + ["watch", "手表", "a wristwatch"], + ["brooch", "胸针", "a brooch"], + ["hat", "帽子", "a hat"], + ["baseball_cap", "棒球帽", "a baseball cap"], + ["beanie", "针织帽", "a beanie"], + ["bucket_hat", "渔夫帽", "a bucket hat"], + ["straw_hat", "草帽", "a straw hat"], + ["fedora", "礼帽", "a fedora"], + ["witch_hat", "尖顶女巫帽", "a pointed witch hat"], + ["crown", "皇冠", "a crown"], + ["hair_clip", "发夹", "a hair clip"], + ["hairband", "发带", "a hairband"], + ["hair_tie", "发绳", "a hair tie"], + ["hair_ribbon", "蝴蝶结发饰", "a hair ribbon"], + ["socks", "袜子", "socks"], + ["stockings", "长筒袜", "stockings"], + ["tights", "连裤袜", "tights"], + ["sneakers", "运动鞋", "sneakers"], + ["canvas_shoes", "帆布鞋", "canvas shoes"], + ["leather_shoes", "皮鞋", "leather shoes"], + ["high_heels", "高跟鞋", "high heels"], + ["boots", "靴子", "boots"], + ["knee_boots", "长筒靴", "knee-high boots"], + ["combat_boots", "马丁靴", "combat boots"], + ["suede_boots", "麂皮长靴", "suede boots"], + ["sandals", "凉鞋", "sandals"], + ["slippers", "拖鞋", "slippers"], + ["geta", "木屐", "geta sandals"], + ["snow_boots", "雪地靴", "snow boots"], + ["rain_boots", "防水雨靴", "rain boots"], + ["wedding_dress", "婚纱", "a wedding dress"], + ["evening_gown", "晚礼服", "an evening gown"], + ["tailcoat", "燕尾服", "a tailcoat"], + ["silk_gown", "丝绸礼服", "a silk gown"], + ["off_shoulder", "露肩礼服", "an off-shoulder dress"], + ["backless_dress", "露背礼服", "a backless dress"], + ["corset_dress", "束腰长裙", "a corset dress"], + ["floral_dress", "印花连衣裙", "a floral dress"], + ["lace_trim", "蕾丝边饰", "lace trim"], + ["space_suit", "太空服", "a space suit"], + ["mecha_suit", "机甲驾驶服", "a mecha pilot suit"], + ["diving_suit", "潜水服", "a diving suit"], + ["hazmat_suit", "防护服", "a hazmat suit"], + ["hospital_gown", "病号服", "a hospital gown"], + ["bathrobe", "睡袍", "a bathrobe"], + ["pajamas", "睡衣", "pajamas"], + ], + environment: [ + ["window_sun", "窗外阳光", "sunshine from the window"], + ["golden_hour", "黄金时刻", "golden hour lighting"], + ["natural_light", "自然光", "natural lighting"], + ["soft_studio", "柔和影棚光", "soft studio lighting"], + ["rim_light", "轮廓逆光", "rim backlight"], + ["backlight", "逆光剪影", "backlit silhouette"], + ["top_light", "顶光", "top lighting"], + ["side_light", "侧光", "side lighting"], + ["low_key", "低调暗光", "low-key lighting"], + ["high_key", "高调亮光", "high-key lighting"], + ["chiaroscuro", "明暗对照", "chiaroscuro lighting"], + ["light_shadow", "光影交错", "light and shadow interplay"], + ["shadow_window", "窗棂投影", "shadow cast from the window"], + ["candle", "烛光", "candlelight"], + ["fireplace", "壁炉暖光", "warm light from a fireplace"], + ["campfire", "篝火光", "campfire light"], + ["lantern", "灯笼光", "lantern light"], + ["neon_city", "霓虹夜色", "neon night in the city"], + ["neon_rain", "雨中霓虹", "neon reflections in the rain"], + ["warm_neon", "暖调霓虹", "neon lighting with a warm cinematic tone"], + ["cyberpunk_glow", "赛博朋克辉光", "sci-fi RGB glowing light"], + ["blue_neon", "蓝色霓虹", "blue neon light"], + ["police_lights", "警灯闪烁", "red and blue police lights in the rain"], + ["emergency_red", "应急红光", "red glow from emergency lights"], + ["fluorescent", "荧光灯", "fluorescent office lighting"], + ["industrial", "冷硬工业光", "harsh industrial lighting"], + ["spotlight", "聚光灯", "a harsh spotlight in a dark room"], + ["studio_softbox", "柔光箱", "softbox studio lighting"], + ["moonlight", "月光", "moonlight"], + ["moon_curtain", "月光透过窗帘", "moonlight through the curtains"], + ["starlight", "星光", "starlight"], + ["aurora", "极光", "aurora borealis glow"], + ["lightning", "闪电瞬间", "a lightning flash in a storm"], + ["stormy_sky", "暴风雨天光", "stormy sky lighting"], + ["foggy_dawn", "雾中黎明", "foggy forest at dawn"], + ["morning_rays", "清晨光束", "early morning rays"], + ["sunrise_mountain", "山间日出", "sunrise in the mountains"], + ["sunset_sea", "海上日落", "sunset over the sea"], + ["desert_evening", "沙漠黄昏", "evening glow in the desert"], + ["dusk_beach", "黄昏海滩", "dusky evening on a beach"], + ["twilight", "黄昏微光", "mysterious twilight with heavy mist"], + ["fireflies", "萤火微光", "fireflies lighting up a summer night"], + ["underwater_glow", "水下光斑", "underwater glow from the deep sea"], + ["bioluminescence", "生物荧光", "bioluminescent glow"], + ["magic_lit", "魔法光辉", "magic lit ambience"], + ["enchanted_forest", "幽光森林", "mystical glow in an enchanted forest"], + ["ethereal", "空灵光晕", "ethereal glow"], + ["cozy_warm", "温馨暖调", "cozy warm ambience"], + ["home_bedroom", "居家卧室暖光", "warm atmosphere at home in a bedroom"], + ["stained_glass", "彩窗透光", "soft light through stained glass"], + ["apocalyptic", "末世烟尘光", "apocalyptic smoky atmosphere"], + ["snow_dusk", "雪天暮色", "gentle snowfall at dusk"], + ["autumn_forest", "秋日林间光", "vibrant autumn lighting in a forest"], + ["crystal_cave", "水晶洞穴反光", "crystal reflections in a cave"], + ["forge_embers", "锻炉余烬", "glowing embers from a forge"], + ], + background: [ + ["cozy_bed", "温馨床铺与台灯", "a cozy bed and a lamp"], + ["living_room", "现代客厅与壁炉", "a modern living room with a fireplace"], + ["bedroom_cozy", "舒适卧室", "a cozy bedroom"], + ["kitchen", "明亮厨房", "a bright kitchen"], + ["cafe", "热闹咖啡馆", "a bustling cafe"], + ["bar_vintage", "复古酒吧", "a dimly lit vintage bar"], + ["restaurant", "精致餐厅", "an elegant restaurant"], + ["office", "现代办公室", "a modern office"], + ["control_room", "高科技控制室", "a high-tech control room"], + ["lab", "发光屏幕实验室", "a futuristic lab with glowing screens"], + ["library", "安静图书馆一角", "a peaceful library corner"], + ["bookshelves", "书架与绿植", "bookshelves and plants"], + ["street_old", "老石板街", "an old cobblestone street"], + ["street_rain", "雨中街道", "a bustling urban street in the rain"], + ["alley_graffiti", "涂鸦小巷", "a narrow alley with graffiti walls"], + ["alley_dark", "昏暗小巷", "a dark narrow alley"], + ["market", "热闹集市", "a bustling marketplace"], + ["night_market", "露天夜市", "a bustling open-air night market"], + ["city_future", "未来都市", "a futuristic cityscape"], + ["skyscrapers", "高楼霓虹", "tall skyscrapers and neon signs"], + ["city_skyline", "城市天际线", "a city skyline"], + ["train_station", "蒸汽火车站", "an old train station with steam"], + ["subway", "地铁站台", "a subway platform"], + ["harbor", "繁忙港湾", "a bustling harbor with boats"], + ["fishing_village", "码头小渔村", "a small fishing village on a pier"], + ["village_rural", "宁静乡村", "a quiet rural village"], + ["village_mountain", "山间村庄", "a quiet mountain village at dawn"], + ["farmland", "起伏农田", "rolling hills and farmland"], + ["vineyard", "乡间葡萄园", "a vineyard in the countryside"], + ["wildflower", "野花原野", "a sprawling field of wildflowers"], + ["meadow", "阳光草甸", "a sunlit meadow"], + ["forest_mist", "薄雾林间空地", "a forest clearing with mist"], + ["forest_dense", "茂密丛林", "a dense jungle filtering sunlight"], + ["redwoods", "巨杉森林", "a dense forest of towering redwoods"], + ["bamboo", "竹林小径", "a bamboo forest path"], + ["mountain_range", "连绵山脉", "a picturesque mountain range"], + ["snow_mountains", "远处雪山", "snow-capped mountains in the distance"], + ["desert_dunes", "沙漠沙丘", "rolling sand dunes in a desert"], + ["desert_night", "沙漠星空", "a starry sky over the desert"], + ["ocean_storm", "暴风海浪", "a stormy ocean with crashing waves"], + ["beach_palm", "棕榈海滩", "a sandy beach with palm trees"], + ["lake_mountain", "群山湖泊", "a tranquil lake with mountains"], + ["lake_misty", "雾中湖泊", "a misty lake surrounded by trees"], + ["river_sunset", "日落河岸", "a serene riverbank at sunset"], + ["waterfall", "林间瀑布", "a tranquil waterfall surrounded by trees"], + ["coral_reef", "珊瑚礁", "a vibrant coral reef"], + ["underwater_reef", "海底世界", "an underwater reef with colorful fish"], + ["ice_cave", "冰晶洞窟", "an ice cave with sparkling crystals"], + ["frozen_lake", "结冰湖面", "a frozen lake with ice formations"], + ["cave_entrance", "神秘洞口", "a mysterious cave entrance"], + ["castle_dark", "阴暗古堡", "a dark abandoned castle"], + ["castle_courtyard", "月下城堡庭院", "a castle courtyard under moonlight"], + ["palace_hall", "华丽宫殿大厅", "an ornate palace hall"], + ["ballroom", "优雅舞厅", "an elegant grand ballroom"], + ["temple_ruins", "古庙遗迹", "an ancient temple in ruins"], + ["ruins_ancient", "远古文明废墟", "the ruins of an ancient civilization"], + ["warehouse", "废弃仓库", "an abandoned warehouse"], + ["theme_park", "废弃游乐园", "an abandoned theme park"], + ["workshop", "杂乱工坊", "the cluttered workshop of an inventor"], + ["spaceship", "未来飞船内部", "the interior of a futuristic spaceship"], + ["space_station", "空间站舷窗", "a space station window"], + ["zen_garden", "枯山水庭院", "a peaceful zen garden with a koi pond"], + ["greenhouse", "玻璃温室", "a glass greenhouse"], + ["flower_shop", "街角花店", "a corner flower shop"], + ["clothing_store", "时装店橱窗", "a fashion boutique window"], + ["museum", "博物馆展厅", "a museum gallery"], + ["studio_room", "摄影棚背景", "a photography studio backdrop"], + ["white_backdrop", "纯白背景", "a plain white background"], + ["black_backdrop", "纯黑背景", "a plain black background"], + ["gradient_backdrop", "渐变色背景", "a smooth gradient backdrop"], + ["bokeh_city", "都市虚化光斑", "a blurry city bokeh background"], + ["bokeh_nature", "自然虚化背景", "a blurred natural bokeh background"], + ["paper_texture", "纸纹背景", "a textured paper background"], + ["starry_sky", "繁星夜空", "a starry night sky"], + ["cloud_sea", "云海之上", "above a sea of clouds"], + ], +}; + +/* ============================================================ + * 新增词条(补充各子类,老词条与它们的 key 全部保持不变) + * 合并规则:同上 key 已存在则保留老词条(不会重复、不会覆盖) + * ============================================================ */ +const WORDS_ADD = { + prefix: [ + ["anime_sakuga", "日式作画爆点", "an explosive anime sakuga key frame"], + ["anime_tv", "番剧截图", "a TV anime screenshot"], + ["anime_lite", "轻小说插画", "a light-novel style illustration"], + ["galgame_cg", "日式游戏立绘", "a visual-novel character CG"], + ["chibi", "Q 版造型", "a chibi style character"], + ["webtoon", "韩式网漫", "a Korean webtoon panel"], + ["us_comic", "美漫厚涂", "a western comic painted style"], + ["ink_comic", "国漫水墨线描", "a Chinese manhua ink style"], + ["ue5_render", "虚幻引擎写实渲染", "a photoreal Unreal Engine 5 render"], + ["blender_pbr", "三维 PBR 渲染", "a PBR-rendered 3D scene"], + ["clay_render", "黏土材质渲染", "a clay-material 3D render"], + ["isometric", "等距视角 3D", "an isometric 3D illustration"], + ["voxel", "体素方块风", "a voxel art style"], + ["miniature", "微缩模型效果", "a miniature model look"], + ["game_cinematic", "游戏 CG 过场", "a cinematic game CG frame"], + ["toy_render", "手办质感渲染", "a resin figure product render"], + ["gouache", "水粉画", "a gouache painting"], + ["acrylic", "丙烯厚涂", "an acrylic painting"], + ["pastel", "粉彩画", "a soft pastel drawing"], + ["ink_line", "钢笔线描", "an ink pen line drawing"], + ["risograph", "孔版印刷风", "a risograph print style"], + ["collage_art", "拼贴艺术", "a mixed-media collage"], + ["neon_sign", "霓虹灯管造型", "a neon sign style"], + ["glass_art", "琉璃工艺风", "a stained-glass art style"], + ["guofeng_hanfu_photo", "国风汉服写真", "a Chinese hanfu portrait photograph of"], + ["gongbi_heavy", "工笔重彩", "a Chinese gongbi heavy-color painting of"], + ["guochao_illustration", "国潮插画", "a Chinese guochao illustration of"], + ["qinglv_shanshui", "青绿山水", "a Chinese blue-green shanshui landscape painting of"], + ["xianxia_film", "仙侠特效大片", "a Chinese xianxia fantasy film still of"], + ], + clothes: [ + ["makeup_natural", "裸妆", "a clean natural makeup look"], + ["makeup_red_lip", "红唇浓妆", "bold red lips"], + ["makeup_smoky", "烟熏眼妆", "a smoky eye makeup"], + ["makeup_goth", "哥特暗黑妆", "a gothic makeup look"], + ["makeup_geisha", "艺伎白面妆", "a geisha white makeup"], + ["makeup_freckles", "雀斑妆", "freckled makeup"], + ["makeup_glitter", "亮片闪粉眼妆", "glittery eye makeup"], + ["makeup_blush", "腮红妆", "flushed blush makeup"], + ["face_tattoo", "面部纹身", "facial tattoos"], + ["face_paint", "战术油彩脸谱", "face paint"], + ["lip_gloss", "唇釉水光", "glossy lips"], + ["korean_idol", "韩系爱豆妆", "a K-pop idol makeup"], + ["backpack", "双肩背包", "a backpack"], + ["handbag", "手提包", "a handbag"], + ["crossbody", "斜挎小包", "a crossbody bag"], + ["tote", "帆布托特包", "a canvas tote bag"], + ["briefcase", "公文包", "a briefcase"], + ["leather_satchel", "皮质邮差包", "a leather satchel"], + ["waist_pack", "腰包", "a waist pack"], + ["school_bag", "学生书包", "a school backpack"], + ["basket_bag", "竹编提篮", "a woven basket"], + ["pouch", "随身小袋", "a small pouch"], + ["luggage", "旅行箱", "a rolling suitcase"], + ["quiver", "箭袋", "a quiver"], + ["hairpin_jade", "玉簪子", "a jade hairpin"], + ["buyao", "步摇", "a golden buyao hair ornament"], + ["guan_jin", "束发金冠", "a golden hair crown"], + ["mo_e", "抹额", "an embroidered forehead band"], + ["zan_hua", "簪花", "a flower hairpin"], + ["huadian", "花钿", "a huadian forehead ornament"], + ["dai_mei", "远山黛眉", "distant-mountain dark eyebrows"], + ["taohua_zhuang", "桃花妆", "a peach-blossom makeup"], + ["hanfu_top", "交领汉服上衣", "a crossed-collar hanfu robe"], + ["daopao", "道袍", "a Chinese Daoist robe"], + ["xiake_robe", "侠客劲装", "a fitted wuxia warrior robe"], + ["daxiushan", "大袖衫", "a wide-sleeved Chinese gown"], + ["ruqun", "齐胸襦裙", "a Chinese ruqun dress"], + ["armor_chinese", "中国甲胄", "Chinese lamellar armor"], + ["hechang", "鹤氅", "a crane-feather cloak"], + ["mamian_skirt", "马面裙", "a Chinese mamian skirt"], + ["baizhe_skirt", "百褶裙", "a pleated Chinese skirt"], + ["denglong_ku", "灯笼裤", "Chinese lantern pants"], + ["yuntou_lv", "云头履", "Chinese cloud-toe shoes"], + ["xiuhua_shoes", "绣花鞋", "embroidered Chinese shoes"], + ["xiapi", "霞帔", "an embroidered xiapi shawl"], + ], + subject: [ + ["tree_pine", "高大松树", "a tall pine tree"], + ["cherry_blossom", "盛开的樱花", "blooming cherry blossoms"], + ["rose", "玫瑰花", "a blooming rose"], + ["sunflower", "向日葵", "a sunflower"], + ["lotus", "荷花", "a lotus flower"], + ["tropical_leaf", "热带阔叶", "tropical broad leaves"], + ["moss", "苔藓", "moss"], + ["vine", "藤蔓", "hanging vines"], + ["bonsai", "盆栽", "a bonsai tree"], + ["maple_leaf", "枫叶", "maple leaves"], + ["wheat", "金色麦穗", "golden wheat ears"], + ["succulent", "多肉植物", "a succulent plant"], + ["book_stack", "一摞旧书", "a stack of old books"], + ["coffee_cup", "咖啡杯", "a coffee cup"], + ["vintage_camera", "复古相机", "a vintage camera"], + ["crystal_gem", "水晶宝石", "a glowing crystal gem"], + ["lantern_object", "一盏灯笼", "a paper lantern"], + ["paper_plane", "纸飞机", "a paper plane"], + ["vinyl_record", "黑胶唱片", "a vinyl record"], + ["pocket_watch", "怀表", "a pocket watch"], + ["sword_object", "一把长剑", "a longsword"], + ["food_dish", "一盘食物", "a plated dish"], + ["car_object", "一辆汽车", "a car"], + ["potion_bottle", "魔法药水瓶", "a potion bottle"], + ["angel", "天使", "an angel"], + ["demon", "恶魔", "a demon"], + ["elf", "精灵", "an elf"], + ["orc", "兽人", "an orc"], + ["slime", "史莱姆", "a slime creature"], + ["mecha", "巨型机甲", "a giant mecha"], + ["undead", "亡魂战士", "an undead warrior"], + ["kirin", "麒麟", "a kirin"], + ["nine_tail_fox", "九尾狐", "a nine-tailed fox"], + ["spirit_beast", "白虎灵兽", "a spirit beast"], + ["golem", "岩石傀儡", "a stone golem"], + ["alien", "外星生物", "an alien creature"], + ["daoshi", "道人", "a Chinese Daoist priest"], + ["xiannv", "仙子", "a Chinese fairy maiden"], + ["nvxia", "江湖女侠", "a jianghu swordswoman"], + ["shusheng", "古风书生", "a Chinese scholar in robes"], + ["crane", "白鹤", "a white crane"], + ["jade_rabbit", "玉兔", "a jade rabbit"], + ["sika_deer", "梅花鹿", "a sika deer"], + ["panda", "熊猫", "a giant panda"], + ["plum_blossom", "梅花", "plum blossoms"], + ["orchid", "兰花", "an orchid"], + ["chrysanthemum", "菊花", "chrysanthemums"], + ["peony", "牡丹", "a peony"], + ["ginkgo", "银杏叶", "ginkgo leaves"], + ["incense_burner", "青铜香炉", "a bronze incense burner"], + ["guqin", "古琴", "a guqin zither"], + ["oil_paper_umbrella", "油纸伞", "an oil-paper umbrella"], + ["porcelain_vase", "青花瓷瓶", "a blue-and-white porcelain vase"], + ["weiqi_board", "围棋盘", "a go board"], + ["dragon_east", "东方神龙", "a Chinese dragon"], + ["mountain_spirit", "山中精怪", "a mountain spirit"], + ], + action: [ + ["stern_look", "溼然神色", "a stern resolute expression"], + ["angry_glare", "嗔怒冷眼", "a cold angry glare"], + ["calm_smile", "淡然浅笑", "a calm faint smile"], + ["meditate_lotus", "盘膝打坐", "sitting cross-legged in meditation"], + ["hands_behind", "负手而立", "standing with hands clasped behind the back"], + ["stand_sword", "执剑而立", "standing upright holding a sword"], + ["cupped_fist", "抱拳行礼", "a cupped-fist martial salute"], + ["bow_salute", "拱手长揖", "a deep Chinese bow with hands clasped"], + ["kneel_one_knee", "单膝跪地", "kneeling on one knee"], + ["tai_chi", "打太极", "practicing tai chi"], + ["hands_sleeves", "双手拢袖", "both hands tucked into the sleeves"], + ["sword_dance", "舞剑", "performing a sword dance"], + ["sword_slash", "挥剑劈砍", "swinging a sword downward"], + ["cast_seal", "掉诀施法", "casting a spell with hand seals"], + ["fly_sword", "御剑飞行", "flying on a sword"], + ["sleeve_sweep", "拂袖", "a sweeping sleeve gesture"], + ["ride_horse", "策马奔腾", "galloping on horseback"], + ["sword_point", "剑指前方", "pointing a sword forward"], + ["sheath_sword", "收剑入鞘", "sheathing the sword"], + ["twin_swords", "双手双剑", "wielding twin swords"], + ["spear_thrust", "挺枪突刺", "thrusting a spear"], + ["palm_strike", "挥掌击出", "a palm strike"], + ["flying_kick", "飞身踢击", "a flying kick"], + ["martial_stance", "武术起手式", "a martial arts ready stance"], + ["fan_dance", "舞扇", "dancing with a fan"], + ["playing_guqin", "抚琴", "plucking a guqin"], + ["ink_calligraphy", "挥毫泼墨", "writing calligraphy with a brush"], + ["drink_wine", "举杯饮酒", "raising a cup of wine"], + ["carry_umbrella", "撑伞而行", "walking while holding an oil-paper umbrella"], + ["leap_air", "飞身跃起", "leaping into the air"], + ["meditate_float", "凌空打坐", "meditating while levitating"], + ["blink_flash", "身形一闪", "blurring away in a flash"], + ["run_sprint", "全力奔跑", "sprinting at full speed"], + ["walk_away", "转身离去", "walking away from the camera"], + ["shadow_step", "踏影而行", "gliding with weightless shadow steps"], + ], + angle: [ + ["front_view", "正面视角", "a straight-on front view"], + ["side_view", "侧面视角", "a side profile view"], + ["three_quarter", "四分之三侧脸", "a three-quarter view"], + ["back_view", "背面视角", "a rear view"], + ["top_down", "俯视", "a top-down view"], + ["low_angle", "仰视", "a low-angle view"], + ["dutch_angle", "倾斜构图", "a dutch angle shot"], + ["close_up", "面部特写", "a close-up on the face"], + ["extreme_close", "极近特写", "an extreme close-up"], + ["half_body", "半身构图", "a half-body framing"], + ["full_body", "全身构图", "a full-body framing"], + ["over_shoulder", "越肩视角", "an over-the-shoulder view"], + ["from_above", "从高处向下", "viewed from above"], + ["worm_eye", "虫眼贴地视角", "a worm's-eye view"], + ["wide_shot", "广角远景", "a wide shot"], + ["liubai_composition", "国风留白构图", "a traditional Chinese negative-space composition"], + ["eye_level", "平视视角", "an eye-level view"], + ["overhead_45", "45 度俯拍", "a 45-degree overhead shot"], + ["hand_detail", "手部特写", "a close-up on the hands"], + ["prop_detail", "道具特写", "a close-up on a prop"], + ["reflection_view", "倒影构图", "framed through a reflection"], + ["silhouette_view", "剪影构图", "a backlit silhouette composition"], + ["frame_in_frame", "框中框构图", "a frame-within-a-frame composition"], + ["foreground_blur", "前景虚化遮挡", "a blurred foreground element"], + ["pov_first_person", "第一人称视角", "a first-person POV shot"], + ["rule_of_thirds", "三分法构图", "a rule-of-thirds composition"], + ["symmetrical_comp", "对称构图", "a symmetrical composition"], + ["center_comp", "中心构图", "a centered composition"], + ["leading_lines", "引导线构图", "leading-lines composition"], + ], + environment: [ + ["warm_tone", "暖色调", "a warm color tone"], + ["cool_tone", "冷色调", "a cool color tone"], + ["high_saturation", "高饱和鲜艳", "highly saturated colors"], + ["morandi", "低饱和莫兰迪", "muted Morandi tones"], + ["faded_film", "胶片褪色", "faded film colors"], + ["teal_orange", "青橙电影调", "a teal and orange grade"], + ["snow_glow", "雪地反光", "soft snow-bounce light"], + ["plum_light", "梅枝疏影光", "dappled light through plum branches"], + ["palace_lantern", "宫灯暖光", "warm palace lantern light"], + ["temple_candle", "佛前烛光", "candlelight before a temple shrine"], + ["oil_lamp", "油灯昏黄", "the dim glow of an oil lamp"], + ["sword_qi", "剑气寒光", "cold sword-energy glow"], + ["spirit_flow", "灵气流光", "flowing spiritual-energy light"], + ["magic_array", "法阵辉光", "a glowing magic-array circle"], + ["buddha_halo", "佛光金芒", "a golden halo of Buddha light"], + ["ink_wash_tone", "水墨淡雅", "a muted ink-wash palette"], + ], + background: [ + ["seamless_paper", "无缝纸背景", "a seamless paper backdrop"], + ["studio_gray", "中性灰棚拍", "a neutral gray studio backdrop"], + ["concrete_wall", "水泥墙", "a concrete wall backdrop"], + ["gradient_neon", "霓虹渐变", "a neon gradient background"], + ["bokeh_lights", "虚化灯串", "blurred string-light bokeh"], + ["water_ripple", "水波纹背景", "a water-ripple backdrop"], + ["silk_fold", "丝绸褶皱", "a draped silk backdrop"], + ["glass_frost", "磨砂玻璃", "a frosted glass backdrop"], + ["star_gradient", "星空渐变", "a starry gradient sky"], + ["geometric_bg", "几何色块", "a geometric color-block background"], + ["mirror_room", "镜面反射房间", "a mirrored room"], + ["petals_bokeh", "花瓣虚化", "falling petal bokeh"], + ["bamboo_forest", "竹林秘境", "a dense bamboo grove"], + ["peach_grove", "桃花林", "a peach blossom grove"], + ["immortal_mountain", "云海仙山", "a misty immortal mountain above a sea of clouds"], + ["lotus_pond", "荷塘", "a lotus pond"], + ["snow_pine_peak", "雪松峰", "snow-covered pine peaks"], + ["jiangnan_watertown", "江南水乡", "a Jiangnan water town with canals"], + ["palace_red_wall", "红墙金瓦宫殿", "a Chinese palace with red walls and golden tiles"], + ["paifang_gate", "仙门牌坊", "a Chinese ceremonial paifang gate"], + ["garden_corridor", "园林回廊", "a Chinese garden corridor"], + ["ancient_inn", "古时客栈", "an ancient Chinese inn"], + ["bamboo_pavilion", "竹林凉亭", "a pavilion in a bamboo grove"], + ["ancient_study", "古风书房", "a traditional Chinese study room"], + ["shrine_hall", "佛堂", "a Chinese shrine hall"], + ["ancient_bedroom", "古风卧房", "a traditional Chinese bedchamber"], + ["tea_room", "茶室", "a Chinese tea room"], + ["xuan_paper", "宣纸底纹", "a rice-paper texture backdrop"], + ["ink_splash", "泼墨晕染", "an ink-splash wash backdrop"], + ], + props: [ + ["sword_chinese", "宝剑", "a Chinese straight sword (jian)"], + ["spear_chinese", "长枪", "a Chinese spear"], + ["sabre_chinese", "中国大刀", "a Chinese broadsword"], + ["dagger", "匕首", "a dagger"], + ["whip", "长鞭", "a long whip"], + ["bow_arrow", "弓箭", "a bow and arrows"], + ["hidden_dart", "暗器", "a concealed throwing dart"], + ["round_shield", "圆盾", "a round shield"], + ["halberd", "方天画戟", "a Chinese halberd"], + ["iron_staff", "铁棍", "an iron staff"], + ["fuchen", "拂尘", "a Daoist fly whisk"], + ["talisman_paper", "符箓", "a Daoist paper talisman"], + ["spirit_pearl", "灵珠", "a glowing spirit pearl"], + ["ruyi_jade", "玉如意", "a jade ruyi scepter"], + ["bronze_mirror", "铜镜", "a bronze mirror"], + ["ritual_bell", "法铃", "a ritual bell"], + ["prayer_beads", "念珠", "prayer beads"], + ["wooden_fish", "木鱼", "a wooden fish drum"], + ["folding_fan", "折扇", "a folding fan"], + ["tuan_shan", "团扇", "a round silk fan"], + ["jade_pendant", "玉佩", "a jade pendant"], + ["xiangnang", "香囊", "an embroidered sachet"], + ["ink_brush", "毛笔", "a Chinese ink brush"], + ["scroll_painting", "画轴", "a hanging scroll painting"], + ["bamboo_flute", "竹笛", "a bamboo flute"], + ["go_board", "棋盘", "a go board with stones"], + ["lantern_paper", "灯笼", "a hanging paper lantern"], + ["wine_pot", "酒壶", "a wine pot"], + ["gourd_flask", "酒葫芦", "a gourd flask"], + ["tea_set", "茶具", "a Chinese tea set"], + ["food_box", "食盒", "a tiered food box"], + ["bamboo_case", "竹书箱", "a bamboo book case"], + ["oil_silk_umbrella", "油纸伞", "an oil-paper umbrella"], + ["copper_coins", "铜钱串", "a string of copper coins"], + ], +}; + +// 合并(老词条不动;key 已存在则跳过 → 不重复、不覆盖) +for (const cat of ALL_CATS) { + for (const item of (WORDS_ADD[cat] || [])) { + const arr = WORDS[cat] || (WORDS[cat] = []); + if (!arr.some((x) => x[0] === item[0])) arr.push(item); + } +} + +/** 词表完整性自检:返回问题清单(空数组 = 全部正常)。 + * 检查:① 每个子类的 key 都能在词表里找到 ② 同一 key 不重复出现在多个子类 + * ③ 词表里的词条都被某个子类收录(不留孤儿) ④ 子类 key 全局唯一 + */ +function verifyWordTable() { + const bad = []; + const owner = new Map(); // key -> "cat|sub" + const seen = new Set(); // 词表里出现的 key + for (const cat of ALL_CATS) { + for (const [k] of (WORDS[cat] || [])) { + if (seen.has(k)) bad.push(`词表内重复 key:${cat}|${k}`); + seen.add(k); + } + for (const sub of (catOf(cat).subs || [])) { + for (const k of (sub.keys || [])) { + if (owner.has(k)) bad.push(`key 出现在多个子类:${k}(${owner.get(k)} 与 ${cat}|${sub.id})`); + else owner.set(k, `${cat}|${sub.id}`); + if (!seen.has(k)) bad.push(`子类引用了不存在的词条:${cat}|${sub.id}|${k}`); + } + } + } + for (const k of seen) if (!owner.has(k)) bad.push(`词条未被任何子类收录:${k}`); + return bad; +} + +/** 词条索引:cat -> Map(key -> {key, zh, en, custom}) */ +function buildIndex(words) { + const idx = {}; + for (const cat of ALL_CATS) { + const m = new Map(); + for (const [k, zh, en] of (WORDS[cat] || [])) m.set(k, { key: k, zh, en, custom: false }); + for (const it of (((words || {}).custom || {})[cat] || [])) { + m.set(it.key, { key: it.key, zh: it.zh || "", en: it.en || "", custom: true }); + } + idx[cat] = m; + } + return idx; +} + +/* ============================================================ + * 无状态 UI 助手(与 asset_manager.js 同款观感) + * ============================================================ */ +function el(tag, css, text) { + const e = document.createElement(tag); + if (css) e.style.cssText = css; + if (text !== undefined) e.textContent = text; + return e; +} + +const BOX_CSS = "background:#1d1d1d;color:#ccc;border:1px solid #444;border-radius:4px;padding:5px 8px;font-size:12px;box-sizing:border-box;font-family:inherit;"; + +function makeSectionTitle(text) { + return el("div", "font-size:13px;font-weight:600;color:#9fc3e8;margin:14px 0 8px;padding-bottom:5px;border-bottom:1px solid #333;", text); +} + +function field(labelText, control) { + const g = el("div", "display:flex;align-items:center;gap:12px;margin-bottom:10px;"); + const lab = el("label", "flex:0 0 160px;font-size:13px;color:#999;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;", labelText); + control.style.flex = "1 1 0"; + control.style.minWidth = "0"; + g.append(lab, control); + return g; +} + +function selectControl(options, value, onChange) { + const s = el("select", BOX_CSS + "min-width:0;"); + for (const o of options) { + const v = (typeof o === "object" && o !== null && o.value !== undefined) ? String(o.value) : String(o); + const t = (typeof o === "object" && o !== null && o.label) ? String(o.label) : String(o); + const op = el("option", "", t); + op.value = v; + if (v === String(value)) op.selected = true; + s.append(op); + } + s.addEventListener("change", () => onChange(s.value)); + return s; +} + +function radioRow(options, value, onChange) { + const row = el("div", "display:flex;align-items:center;gap:16px;flex-wrap:wrap;min-width:0;"); + const boxes = []; + for (const o of options) { + const val = (typeof o === "object" && o !== null && o.value !== undefined) ? String(o.value) : String(o); + const label = (typeof o === "object" && o !== null && o.label) ? String(o.label) : String(o); + const lab = el("label", "display:flex;align-items:center;gap:6px;font-size:13px;color:#999;cursor:pointer;user-select:none;"); + const c = el("input", "width:18px;height:18px;accent-color:#f59e0b;cursor:pointer;"); + c.type = "checkbox"; + c.checked = String(value) === val; + c.addEventListener("change", () => { + if (c.checked) { + boxes.forEach((x) => { if (x !== c) x.checked = false; }); + onChange(val); + } else if (boxes.every((x) => !x.checked)) { + c.checked = true; // 至少保留一个选中 + } + }); + lab.append(c, el("span", "", label)); + row.append(lab); + boxes.push(c); + } + return row; +} + +function textareaControl(value, onChange, css) { + const t = el("textarea", BOX_CSS + "min-height:60px;resize:vertical;line-height:1.6;" + (css || "")); + t.value = value || ""; + t.addEventListener("change", () => onChange(t.value)); + return t; +} + +function checkboxControl(value, labelText, onChange, tip) { + const w = el("label", "display:flex;align-items:center;gap:8px;cursor:pointer;font-size:12px;color:#ddd;user-select:none;"); + const c = el("input", "width:16px;height:16px;accent-color:#f59e0b;cursor:pointer;"); + c.type = "checkbox"; + c.checked = !!value; + if (tip) c.title = tip; + c.addEventListener("change", () => onChange(c.checked)); + w.append(c, el("span", "", labelText)); + return w; +} + +function smallBtn(text, css, tip, onClick) { + const b = el("button", "border-radius:4px;padding:4px 9px;font-size:11px;cursor:pointer;white-space:nowrap;font-family:inherit;" + (css || ""), text); + if (tip) b.title = tip; + b.addEventListener("mousedown", (e) => e.stopPropagation()); + b.addEventListener("click", (e) => { e.stopPropagation(); if (onClick) onClick(e); }); + return b; +} + +// ── 轻量通知 ────────────────────────────────────────────── +let toastBox = null; +function notify(msg, type = "success") { + try { + if (!toastBox) { + toastBox = el("div", "position:fixed;right:16px;bottom:16px;z-index:100000;display:flex;flex-direction:column;gap:8px;align-items:flex-end;pointer-events:none;"); + document.body.appendChild(toastBox); + } + const palette = { + success: ["#1f3d2a", "#7fd6a0", "#4caf50"], + error: ["#3d1f1f", "#f0a0a0", "#e05555"], + warning: ["#3d3520", "#f0d79a", "#d9a13b"], + }; + const c = palette[type] || palette.success; + const t = el("div", `background:${c[0]};color:${c[1]};border:1px solid ${c[2]};border-radius:6px;padding:8px 14px;font-size:12px;box-shadow:0 6px 20px rgba(0,0,0,.5);max-width:420px;white-space:pre-wrap;`, msg); + toastBox.appendChild(t); + setTimeout(() => { try { t.remove(); } catch (_) {} }, 2600); + } catch (_) {} +} + +// ── 字号 / 界面缩放(独立记忆,不与 asset_manager 共用) ── +const ZOOM_MIN = 8, ZOOM_MAX = 40; +let __zoom = null; +function zoomGet() { + if (__zoom == null) { + try { const v = parseInt(localStorage.getItem("xb_ipp_text_zoom") || "", 10); if (Number.isFinite(v)) __zoom = v; } catch (_) {} + } + return __zoom; +} +function zoomDisplay() { return zoomGet() == null ? 12 : zoomGet(); } +function zoomSet(v) { + __zoom = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, Math.round(v))); + try { localStorage.setItem("xb_ipp_text_zoom", String(__zoom)); } catch (_) {} +} +function zoomApplyContainer(root) { + if (!root || !root.querySelectorAll || zoomGet() == null) return; + root.querySelectorAll("textarea").forEach((e) => { e.style.fontSize = zoomGet() + "px"; }); +} +function zoomSync(root) { + if (!root || !root.querySelectorAll) return; + root.querySelectorAll(".ipp-zoom-val").forEach((e) => { e.textContent = String(zoomDisplay()); }); +} +function buildZoomRow(root) { + const row = el("span", "display:inline-flex;align-items:center;gap:4px;user-select:none;"); + const minus = smallBtn("A−", "border:1px solid #5b9bd5;background:#2a4a6a;color:#cfe3f7;width:22px;height:22px;padding:0;line-height:1;", "缩小编辑字号(Ctrl/⌘+滚轮 也可)"); + const val = el("span", "min-width:26px;text-align:center;font-size:12px;color:#e8a87c;font-variant-numeric:tabular-nums;", String(zoomDisplay())); + val.classList.add("ipp-zoom-val"); + const plus = smallBtn("A+", "border:1px solid #5b9bd5;background:#2a4a6a;color:#cfe3f7;width:22px;height:22px;padding:0;line-height:1;", "放大编辑字号(Ctrl/⌘+滚轮 也可)"); + const step = (d) => { + zoomSet((zoomGet() == null ? 12 : zoomGet()) + d); + zoomApplyContainer(root); + zoomSync(root); + }; + minus.addEventListener("click", () => step(-1)); + plus.addEventListener("click", () => step(1)); + row.append(minus, val, plus); + return row; +} + +const UI_STEP = 0.05, UI_MIN = 0.5, UI_MAX = 3.0; +let __uiZoom = null; +function uiGet() { + if (__uiZoom == null) { + try { const v = parseFloat(localStorage.getItem("xb_ipp_ui_zoom") || ""); if (Number.isFinite(v) && v > 0) __uiZoom = v; } catch (_) {} + } + return __uiZoom == null ? 1 : __uiZoom; +} +function uiSet(v) { + __uiZoom = Math.max(UI_MIN, Math.min(UI_MAX, Math.round(v / UI_STEP) * UI_STEP)); + __uiZoom = Math.round(__uiZoom * 100) / 100; + try { localStorage.setItem("xb_ipp_ui_zoom", String(__uiZoom)); } catch (_) {} +} +function uiApply(target) { + if (!target) return; + const z = uiGet(); + target.dataset.ippUi = "1"; + if (target.dataset.ippMaxH == null) { + const cs = parseFloat(getComputedStyle(target).maxHeight); + target.dataset.ippMaxH = Number.isFinite(cs) ? String(cs) : "none"; + } + target.style.zoom = String(z); + const orig = parseFloat(target.dataset.ippMaxH); + const cap = Math.round((window.innerHeight * 0.92) / z); + target.style.maxHeight = (Number.isFinite(orig) ? Math.min(orig, cap) : cap) + "px"; +} +window.addEventListener("resize", () => { + document.querySelectorAll("[data-ipp-ui]").forEach((e) => { try { uiApply(e); } catch (_) {} }); +}); +function buildUIRow(target) { + const row = el("span", "display:inline-flex;align-items:center;gap:4px;user-select:none;"); + row.append(el("span", "font-size:11px;color:#888;white-space:nowrap;", "界面")); + const minus = smallBtn("−", "border:1px solid #8a6d3b;background:#5a4a2a;color:#ffd98a;width:22px;height:22px;padding:0;line-height:1;font-size:13px;", "缩小整个界面(Ctrl/⌘+滚轮 也可)"); + const val = el("span", "min-width:42px;text-align:center;font-size:12px;color:#ffd98a;font-variant-numeric:tabular-nums;", Math.round(uiGet() * 100) + "%"); + val.classList.add("ipp-ui-val"); + const plus = smallBtn("+", "border:1px solid #8a6d3b;background:#5a4a2a;color:#ffd98a;width:22px;height:22px;padding:0;line-height:1;font-size:13px;", "放大整个界面(Ctrl/⌘+滚轮 也可)"); + const step = (d) => { + uiSet(uiGet() + d * UI_STEP); + uiApply(target); + target.querySelectorAll(".ipp-ui-val").forEach((e) => { e.textContent = Math.round(uiGet() * 100) + "%"; }); + }; + minus.addEventListener("click", () => step(-1)); + plus.addEventListener("click", () => step(1)); + row.append(minus, val, plus); + return row; +} +function bindWheelZoom(root) { + root.addEventListener("wheel", (e) => { + if (!(e.ctrlKey || e.metaKey)) return; + e.preventDefault(); + e.stopPropagation(); + // 光标在文本框里 → 调字号;在别处 → 调界面缩放 + if (e.target && e.target.closest && e.target.closest("textarea")) { + zoomSet((zoomGet() == null ? 12 : zoomGet()) + (e.deltaY < 0 ? 1 : -1)); + zoomApplyContainer(root); + zoomSync(root); + } else { + uiSet(uiGet() + (e.deltaY < 0 ? 1 : -1) * UI_STEP); + uiApply(root); + root.querySelectorAll(".ipp-ui-val").forEach((x) => { x.textContent = Math.round(uiGet() * 100) + "%"; }); + } + }, { passive: false }); +} + +/* ============================================================ + * 配置读写 + * ============================================================ */ +function defaultSettings() { + // ⚠️ 自 0.9.x 起:输出语言 / 预设模式 / 预设句 / 空latent类型 已上提为**节点表面参数**, + // 不再存在这份 JSON 里;JSON 只留元素面板配置 + 自动保存开关。 + return { + auto_save: true, + elements: { selected: {}, custom: {}, noted: {}, rel: {} }, + preset_texts: {}, // 用户改过的预设句(设定词):{"模式|语言": "文本"}(换模式/换语言不丢) + qwen: xbrQwenDefaults(), // Qwen2.1提示词预设:生成后控制 + 追加设定 + }; +} + +function readWidgetValue(w) { return w ? (w._state?.value ?? w.value) : undefined; } +function setWidgetValue(w, val) { + if (!w) return; + w.value = val; + if (w._state) w._state.value = val; + w._node?.setDirtyCanvas?.(true, true); +} +function findWidget(node, name) { return (node?.widgets || []).find((w) => w.name === name); } + +/** + * Nodes 2.0 可见性刷新 + * ⚠️ 实测(前端 1.52.7):在 onNodeCreated 阶段把 widget 改成 type="hidden" / hidden=true / options.hidden=true, + * 数据都对,但 Vue 侧不会重渲染(DOM 里依然看得见、依旧占高度); + * 触发 node:slot-label:changed(内部事件)会让前端重新抽取节点数据 → 立即生效。 + * 经典模式(Nodes 1.0)下这些操作等价于无效动作,无副作用。 + */ +function refreshNodes2View(node) { + try { if (node && Array.isArray(node.widgets)) node.widgets = node.widgets.slice(); } catch (_) {} + try { node?.graph?.trigger?.("node:slot-label:changed", { nodeId: node.id, slotType: 2 }); } catch (_) {} + try { node?.setDirtyCanvas?.(true, true); } catch (_) {} +} + +/** 容错解析:任何异常都退回默认(绝不让面板打不开) */ +function parseSettings(raw) { + const cfg = defaultSettings(); + let data = {}; + if (raw && typeof raw === "object") data = raw; + else if (typeof raw === "string" && raw.trim()) { try { data = JSON.parse(raw) || {}; } catch (_) { data = {}; } } + if (typeof data.auto_save === "boolean") cfg.auto_save = data.auto_save; + cfg.preset_texts = parsePresetTexts(data.preset_texts); + + const e0 = (data.elements && typeof data.elements === "object") ? data.elements : {}; + const sel = {}; + if (e0.selected && typeof e0.selected === "object") { + for (const cat of ALL_CATS) { + const seq = Array.isArray(e0.selected[cat]) ? e0.selected[cat].filter((k) => typeof k === "string" && k) : []; + if (seq.length) sel[cat] = seq; + } + } + cfg.elements.selected = sel; + const cus = {}; + if (e0.custom && typeof e0.custom === "object") { + for (const cat of ALL_CATS) { + const seq = []; + for (const it of (Array.isArray(e0.custom[cat]) ? e0.custom[cat] : [])) { + if (!it || typeof it !== "object") continue; + const k = typeof it.key === "string" ? it.key.trim() : ""; + if (!k) continue; + seq.push({ key: k, zh: typeof it.zh === "string" ? it.zh : "", en: typeof it.en === "string" ? it.en : "" }); + } + if (seq.length) cus[cat] = seq; + } + } + cfg.elements.custom = cus; + const noted = {}; + if (e0.noted && typeof e0.noted === "object") { + for (const [k, v] of Object.entries(e0.noted)) { + if (typeof v === "string" && v.trim()) noted[String(k)] = v; + } + } + cfg.elements.noted = noted; + const rel = {}; + if (e0.rel && typeof e0.rel === "object") { + for (const [k, v] of Object.entries(e0.rel)) { + if (typeof v !== "string" || !REL_MAP[v]) continue; + const seg = String(k).split("|"); + if (seg.length !== 2 || !ALL_CATS.includes(seg[0]) || !seg[1]) continue; + rel[`${seg[0]}|${seg[1]}`] = v; + } + } + cfg.elements.rel = rel; + cfg.qwen = xbrParseQwen(data.qwen); // Qwen2.1提示词预设:生成后控制 + 追加设定 + return cfg; +} + +function loadSettings(node) { return parseSettings(readWidgetValue(findWidget(node, "manager_settings"))); } +function persistSettings(node, settings) { setWidgetValue(findWidget(node, "manager_settings"), JSON.stringify(settings)); } + +/* ============================================================ + * 提示词拼装 / 数值逻辑(与后端逐条对齐) + * ============================================================ */ +function sepOf(lang) { return SEP[lang] || ","; } +function wordOf(entry, lang) { + const v = (lang === LANG_EN) ? entry.en : entry.zh; + return (v && v.trim()) ? v.trim() : (entry.zh || entry.en || ""); +} +function snippetOf(entry, lang, desc, rel) { + const w = wordOf(entry, lang); + const d = (desc || "").trim(); + if (!w) return ""; + const base = rel + ? ((lang === LANG_EN) ? `${w}, ${rel.en}` : `${rel.zh}${w}`) + : w; + if (!d) return base; + return (lang === LANG_EN) ? `${base} (${d})` : `${base}(${d})`; +} +/** 取某个词条已选的关系词(道具用;没设过 → null) */ +function relPair(settings, cat, key) { + const m = (settings && settings.elements && settings.elements.rel) || {}; + const id = m[`${cat}|${key}`]; + return id ? (REL_MAP[id] || null) : null; +} +/** 词条当前片段(自动带上详细描述与道具关系) */ +function snipOf(settings, cat, key, entry, lang) { + if (!entry) return ""; + return snippetOf(entry, lang, (settings.elements.noted || {})[`${cat}|${key}`], relPair(settings, cat, key)); +} +function appendSnippet(body, snip, lang) { + const s = (snip || "").trim(); + if (!s) return body || ""; + const t = String(body || "").replace(/[,,、\s]+$/, ""); + return t ? t + sepOf(lang) + s : s; +} +function removeSnippet(body, snips, lang) { + let t = String(body || ""); + for (const s of snips) { if (s) t = t.split(s).join(""); } + const sep = sepOf(lang); + t = t.split(sep).map((x) => x.trim()).filter(Boolean).join(sep); + return t.replace(/^[,,、\s]+/, "").replace(/[,,、\s]+$/, ""); +} +function rebuildBody(settings, index, lang) { + const parts = []; + for (const cat of ALL_CATS) { + for (const key of (settings.elements.selected[cat] || [])) { + const entry = index[cat] && index[cat].get(key); + if (!entry) continue; + const snip = snipOf(settings, cat, key, entry, lang); + if (snip) parts.push(snip); + } + } + return parts.join(sepOf(lang)); +} +/** 最终成句(与后端 build_prompt 逐行一致:预设句 + 正文)—— 三个值均来自节点表面参数 */ +function finalPrompt(mode, presetText, lang, body, io) { + const core = String(body || "").trim(); + if (!PRESET_TEXT_DEFAULT[mode]) return core; // 无预设 → 不前置 + const preset = String(presetText || "").trim() || presetTextOf(mode, lang, io || IO_T2I); + if (!core) return preset; + if (lang === LANG_EN) return preset.replace(/[.\s]+$/, "") + ". " + core; + return preset.replace(/[。..\s]+$/, "") + "。" + core; +} + +/** 空 latent 规格(未知值 → 默认 4 通道;与后端 latent_kind_spec 一致) */ +function latentSpec(kind) { + return LATENT_KIND_SPEC[String(kind || "")] || LATENT_KIND_SPEC[DEFAULT_LATENT_KIND]; +} +/** 把任意值归一到合法枚举(缺失/None/非法 → 默认) */ +function pickOpt(options, value, def) { + const v = (value == null) ? "" : String(value).trim(); + return options.includes(v) ? v : def; +} +/** 步长锁定(与后端 _round_step 一致:floor(v/step + 0.5);step/min/max 由空latent类型决定) */ +function roundStep(v, step = SIZE_STEP, minSize = SIZE_MIN, maxSize = SIZE_MAX) { + const n = Math.floor((Number(v) || 0) / step + 0.5) * step; + return Math.max(minSize, Math.min(maxSize, n)); +} +/** 以「宽度」为基准反算高度(改宽度 / 改比例时用)→ 对齐 js/xb_video.js 的 sW() */ +function snapFromWidth(ar, w, opt) { + const o = opt || {}; + const sw = roundStep(w, o.step, o.min, o.max); + if (String(ar).includes("Free")) return [sw, null]; + const r = ASPECT_MAP[String(ar)]; + if (!r) return [sw, null]; + return [sw, roundStep(sw / r, o.step, o.min, o.max)]; +} +/** 以「高度」为基准反算宽度(改高度时用)→ 对齐 js/xb_video.js 的 sH() */ +function snapFromHeight(ar, h, opt) { + const o = opt || {}; + const sh = roundStep(h, o.step, o.min, o.max); + if (String(ar).includes("Free")) return [null, sh]; + const r = ASPECT_MAP[String(ar)]; + if (!r) return [null, sh]; + return [roundStep(sh * r, o.step, o.min, o.max), sh]; +} +/** 无方向时的归一(节点加载 / 后端镜像):以较大的一边为基准,幂等 */ +function normalizeSize(ar, w, h, opt) { + const o = opt || {}; + let sw = roundStep(w, o.step, o.min, o.max), sh = roundStep(h, o.step, o.min, o.max); + if (String(ar).includes("Free")) return [sw, sh]; + const r = ASPECT_MAP[String(ar)]; + if (!r) return [sw, sh]; + if (sw >= sh) sh = roundStep(sw / r, o.step, o.min, o.max); + else sw = roundStep(sh * r, o.step, o.min, o.max); + return [sw, sh]; +} + +function copyText(text) { + if (!text) { notify("没有可复制的内容", "warning"); return; } + const done = () => notify("已复制提示词", "success"); + const fallback = () => { + try { + const ta = document.createElement("textarea"); + ta.value = text; + ta.style.cssText = "position:fixed;top:-999px;opacity:0;"; + document.body.appendChild(ta); + ta.select(); + document.execCommand("copy"); + ta.remove(); + } catch (_) {} + }; + try { + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(text).then(done).catch(() => { fallback(); done(); }); + } else { fallback(); done(); } + } catch (_) { fallback(); done(); } +} + +/* ============================================================ + * 面板外壳(吸顶预览框 + footer:自动保存/字号/界面/取消/保存) + * ============================================================ */ +let modal = null; + +function openPanel(ctx, panelId) { + closePanel(); + const node = ctx.node; + const settings = ctx.settings; + const surf = ctx.surface || {}; + const cat = catOf(panelId); + const title = `${cat.label} 元素`; + + const overlay = el("div", "position:fixed;inset:0;background:rgba(0,0,0,.78);z-index:9999;display:flex;align-items:center;justify-content:center;overflow:auto;"); + const dialog = el("section", "background:#1c1c1e;border:1px solid #333;border-radius:8px;width:880px;max-height:90vh;display:flex;flex-direction:column;box-shadow:0 20px 40px rgba(0,0,0,.6);flex:0 0 auto;color:#ddd;font-size:13px;"); + + const header = el("div", "padding:16px 20px 12px;border-bottom:1px solid #444;"); + const titleEl = el("div", "font-size:18px;font-weight:700;color:#eee;", title); + header.append(titleEl); + header.append(el("div", "font-size:12px;color:#999;margin-top:5px;", "点选项行即加入提示词(变蓝),再点一下移除(变灰);点【添加详细描述】会在新窗口里给该词条写补充描述(悬停行可看)。配置随工作流保存,改完重新执行节点即生效。")); + dialog.append(header); + + const panelBox = el("div", "padding:14px 20px;overflow-y:auto;flex:1;min-height:280px;"); + + // ── 吸顶预览框(不受滚动条约束,始终在画面内) ── + const sticky = el("div", "position:sticky;top:0;z-index:6;background:#1c1c1e;padding:2px 0 10px;margin:-2px 0 6px;border-bottom:1px solid #333;"); + const head = el("div", "display:flex;align-items:center;gap:8px;margin-bottom:6px;flex-wrap:wrap;"); + head.append(el("span", "font-size:12px;font-weight:600;color:#9fc3e8;", "📝 提示词预览(与节点提示词框实时同步,可直接编辑)")); + const headCount = el("span", "font-size:11px;color:#888;", ""); + head.append(headCount); + const presetChip = el("div", "font-size:11px;color:#f0d79a;background:#2a2416;border:1px dashed #8a6d3b;border-radius:4px;padding:4px 8px;margin-bottom:6px;white-space:pre-wrap;line-height:1.5;display:none;"); + const preview = el("textarea", BOX_CSS + "width:100%;min-height:74px;max-height:210px;resize:vertical;line-height:1.65;font-size:12px;"); + preview.spellcheck = false; + preview.placeholder = "点左侧 + 添加元素,或直接在这里手写提示词…"; + sticky.append(head, presetChip, preview); + panelBox.append(sticky); + + const refreshPreview = (fromEl) => { + const body = ctx.body.get(); + if (fromEl !== preview) preview.value = body; + headCount.textContent = `${body.length} 字 → 成句 ${finalPrompt(surf.mode, surf.presetText, surf.lang, body, surf.io).length} 字`; + const hasPreset = !!PRESET_TEXT_DEFAULT[surf.mode]; + presetChip.style.display = hasPreset ? "block" : "none"; + if (hasPreset) { + const t = String(surf.presetText || "") || presetTextOf(surf.mode, surf.lang, surf.io || IO_T2I); + presetChip.textContent = `📌 自动前置(${surf.mode}设定词,可在节点表面「预设句」框里改;改过的按模式记住、换模式不丢):` + (t.length > 150 ? t.slice(0, 150) + "…" : t); + } + }; + preview.addEventListener("input", () => { ctx.body.set(preview.value); refreshPreview(preview); }); + + // ── 内容 ── + // 分类内容放在自己的容器里:面板内的 8 个分类按钮可以「就地换页」, + // 不用关面板再开另一个(keep 吸顶预览框不被清掉) + const catHost = el("div", ""); + panelBox.append(catHost); + const api = { + node, settings, ctx, panelBox, surf, + refreshPreview, + fromText: (v) => { preview.value = v; refreshPreview(preview); }, + switchCategory: (id) => { + const c = catOf(id); + try { catHost.innerHTML = ""; } catch (_) {} + try { titleEl.textContent = `${c.label} 元素`; } catch (_) {} + try { renderCategoryPanel(catHost, api, c.id); } catch (err) { console.error("[XB-生图提示词预设]", err); } + }, + }; + renderCategoryPanel(catHost, api, cat.id); + dialog.append(panelBox); + + // ── 底部 ── + const error = el("div", "color:#e55;font-size:12px;margin:0 20px;min-height:16px;"); + const footer = el("div", "padding:12px 20px;border-top:1px solid #444;display:flex;justify-content:flex-end;gap:10px;background:#18181a;border-bottom-left-radius:8px;border-bottom-right-radius:8px;"); + const cancelBtn = el("button", "background:transparent;border:1px solid #555;color:#fff;border-radius:4px;padding:8px 20px;font-size:14px;cursor:pointer;font-family:inherit;", "取消"); + const saveBtn = el("button", "background:#2d5a88;color:#fff;border:none;border-radius:4px;padding:8px 20px;font-size:14px;font-weight:600;cursor:pointer;font-family:inherit;", "💾 保存"); + const autoRow = el("label", "display:flex;align-items:center;gap:6px;font-size:13px;color:#999;cursor:pointer;user-select:none;"); + const autoCb = el("input", "width:18px;height:18px;accent-color:#f59e0b;cursor:pointer;"); + autoCb.type = "checkbox"; + autoCb.checked = settings.auto_save !== false; + autoCb.addEventListener("change", () => { settings.auto_save = autoCb.checked; }); + autoRow.append(autoCb, el("span", "", "自动保存")); + const zoomRow = buildZoomRow(dialog); + zoomRow.title = "调整面板文本编辑字号(与本节点其它面板共享记忆)"; + const uiRow = buildUIRow(dialog); + uiRow.title = "调整整个弹窗界面大小(不缩放网页/画布)"; + const left = el("div", "display:flex;align-items:center;gap:16px;margin-right:auto;flex-wrap:wrap;"); + left.append(autoRow, zoomRow, uiRow); + footer.append(left, cancelBtn, saveBtn); + dialog.append(error, footer); + + overlay.append(dialog); + document.body.append(overlay); + zoomApplyContainer(dialog); + uiApply(dialog); + bindWheelZoom(dialog); + + const close = () => { + clearTimeout(timer); // 关键:取消/关窗后不能再让防抖定时器把改动写回去 + delete node.__ippDraft; // 丢弃未保存草稿,信息行回退到已持久化配置 + try { ctx.refreshNode(); } catch (_) {} + try { overlay.remove(); } catch (_) {} + modal = null; + }; + let timer = null; + const doSave = (silent) => { + try { + persistSettings(node, settings); + ctx.refreshNode(); + if (!silent) notify("配置已保存(随工作流保存),重新执行节点生效"); + return true; + } catch (e) { + error.textContent = "保存失败:" + ((e && e.message) || e); + return false; + } + }; + saveBtn.addEventListener("click", () => { if (doSave(false)) close(); }); + cancelBtn.addEventListener("click", close); + overlay.addEventListener("mousedown", (e) => { if (e.target === overlay) close(); }); + // 面板内任一控件变化:先当成草稿同步给节点表面(生成详情 / 按钮状态即时联动), + // 再按「自动保存」防抖写回节点配置。 + const applyDraft = () => { + node.__ippDraft = settings; + try { ctx.refreshNode(); } catch (_) {} + }; + panelBox.addEventListener("change", applyDraft); + panelBox.addEventListener("change", () => { + if (settings.auto_save === false) return; + clearTimeout(timer); + timer = setTimeout(() => doSave(true), 1000); + }); + + refreshPreview(null); + // 面板打开期间:节点提示词框被外部改动时把内容拉过来(低频兜底) + const poll = setInterval(() => { + if (!document.body.contains(overlay)) { clearInterval(poll); return; } + const cur = ctx.body.get(); + if (cur !== preview.value && document.activeElement !== preview) { preview.value = cur; refreshPreview(preview); } + }, 400); + overlay.__ippPoll = poll; + + modal = { overlay, close }; +} + +function closePanel() { + try { if (modal) { clearInterval(modal.overlay.__ippPoll); modal.close(); } } catch (_) {} + modal = null; +} + +/* ============================================================ + * 🧩 元素分类面板(6 个) + * 每个按钮一个面板,里面只列该分类的词条;选项行 = ➕ ➖ 选项名 【添加详细描述】 + * ============================================================ */ +const CAT_UI = {}; // 会话级:cat -> { collapsed, onlySel, keyword }(不写进工作流) + +/* ============================================================ + * ❓ 统一确认窗 + * 以前用的是浏览器原生 confirm(系统弹窗,跟节点其它弹窗长得不一样)→ 全部换成 modal + * ============================================================ */ +function openConfirm(title, message, onOk, okText) { + const overlay = el("div", "position:fixed;inset:0;background:rgba(0,0,0,.72);z-index:10200;display:flex;align-items:center;justify-content:center;"); + const box = el("section", "background:#1c1c1e;border:1px solid #333;border-radius:8px;width:460px;max-width:92vw;box-shadow:0 20px 40px rgba(0,0,0,.6);padding:18px;box-sizing:border-box;color:#ddd;font-size:13px;"); + box.dataset.ippModal = "confirm"; + box.append(el("div", "font-size:15px;font-weight:700;color:#eee;margin-bottom:8px;", title || "确认")); + box.append(el("div", "font-size:13px;color:#bbb;line-height:1.8;white-space:pre-wrap;margin-bottom:14px;", message || "")); + const row = el("div", "display:flex;gap:10px;justify-content:flex-end;"); + const cancelBtn = el("button", "background:transparent;border:1px solid #555;color:#fff;border-radius:4px;padding:7px 18px;font-size:13px;cursor:pointer;font-family:inherit;", "取消"); + const okBtn = el("button", "background:#8a3a2a;border:1px solid #c2604a;color:#ffe7df;border-radius:4px;padding:7px 18px;font-size:13px;font-weight:600;cursor:pointer;font-family:inherit;", okText || "确定"); + const close = () => { try { overlay.remove(); } catch (_) {} }; + cancelBtn.addEventListener("click", (e) => { e.stopPropagation(); close(); }); + okBtn.addEventListener("click", (e) => { e.stopPropagation(); close(); try { onOk && onOk(); } catch (_) {} }); + overlay.addEventListener("mousedown", (e) => { if (e.target === overlay) close(); }); + overlay.addEventListener("keydown", (e) => { if (e.key === "Escape") { e.stopPropagation(); e.preventDefault(); close(); } }); + row.append(cancelBtn, okBtn); + box.append(row); + overlay.append(box); + document.body.append(overlay); + return box; +} + +/* ============================================================ + * 🎒 道具 ↔ 主体关系选择(30 个关系词,6 列 × 5 行 多宫格标签) + * ============================================================ */ +function openRelPicker(node, nameText, lang, current, onPick) { + const overlay = el("div", "position:fixed;inset:0;background:rgba(0,0,0,.78);z-index:10200;display:flex;align-items:center;justify-content:center;"); + const box = el("section", "background:#1c1c1e;border:1px solid #333;border-radius:8px;width:780px;max-width:94vw;max-height:92vh;overflow:auto;box-shadow:0 20px 40px rgba(0,0,0,.6);padding:18px;box-sizing:border-box;color:#ddd;font-size:13px;"); + box.dataset.ippModal = "rel"; + box.append(el("div", "font-size:15px;font-weight:700;color:#eee;margin-bottom:4px;", "🎒 道具与主体的关系")); + box.append(el("div", "font-size:12px;color:#8a97a5;margin-bottom:12px;line-height:1.7;", + `道具:${nameText}\n选一个关系词,拼进提示词时它会加在道具前面(例:手拿折扇 / a folding fan, held in the hand)。`)); + const grid = el("div", "display:grid;grid-template-columns:repeat(6,1fr);gap:6px;margin-bottom:14px;"); + const close = () => { try { overlay.remove(); } catch (_) {} }; + for (const [id, zh, en] of RELATIONS) { + const on = id === current; + const b = el("button", `display:flex;flex-direction:column;align-items:center;justify-content:center;gap:2px;min-height:46px;border-radius:6px;cursor:pointer;font-family:inherit;padding:4px 2px;box-sizing:border-box;border:1px solid ${on ? "#7fb3e0" : "#4a4a4a"};background:${on ? "#2d5a88" : "#262626"};color:${on ? "#eaf4ff" : "#ddd"};`); + b.dataset.rel = id; + b.title = `${zh} / ${en}`; + b.append(el("span", "font-size:13px;line-height:1.2;", zh)); + b.append(el("span", "font-size:9px;line-height:1.2;color:#9fb6cc;text-align:center;word-break:break-word;", en)); + b.addEventListener("mouseenter", () => { if (!on) b.style.background = "#333"; }); + b.addEventListener("mouseleave", () => { if (!on) b.style.background = "#262626"; }); + b.addEventListener("click", (e) => { e.stopPropagation(); close(); try { onPick && onPick(id); } catch (_) {} }); + grid.append(b); + } + box.append(grid); + const row = el("div", "display:flex;gap:10px;justify-content:flex-end;"); + const noneBtn = el("button", "background:#2a4a6a;border:1px solid #5b9bd5;color:#cfe3f7;border-radius:4px;padding:7px 16px;font-size:13px;cursor:pointer;font-family:inherit;", "不加关系(只加道具词)"); + const cancelBtn = el("button", "background:transparent;border:1px solid #555;color:#fff;border-radius:4px;padding:7px 18px;font-size:13px;cursor:pointer;font-family:inherit;", "取消"); + noneBtn.addEventListener("click", (e) => { e.stopPropagation(); close(); try { onPick && onPick(""); } catch (_) {} }); + cancelBtn.addEventListener("click", (e) => { e.stopPropagation(); close(); }); + overlay.addEventListener("mousedown", (e) => { if (e.target === overlay) close(); }); + overlay.addEventListener("keydown", (e) => { if (e.key === "Escape") { e.stopPropagation(); e.preventDefault(); close(); } }); + row.append(noneBtn, cancelBtn); + box.append(row); + overlay.append(box); + document.body.append(overlay); + return box; +} + +/* ============================================================ + * 📝 详细描述编辑窗 + * 照抄短剧导演台 openTextZoomEditor 的交互:独立小弹窗 + 文本编辑框 + 取消/保存 + * ============================================================ */ +function openDescEditor(node, entry, nameText, value, onSave) { + const overlay = el("div", "position:fixed;inset:0;background:rgba(0,0,0,.72);z-index:10100;display:flex;align-items:center;justify-content:center;"); + const box = el("section", "background:#1c1c1e;border:1px solid #333;border-radius:8px;width:600px;max-width:92vw;box-shadow:0 20px 40px rgba(0,0,0,.6);padding:18px;box-sizing:border-box;color:#ddd;font-size:13px;"); + box.dataset.ippModal = "desc"; // 稳定锤点(便于定位/自动化测试) + box.append(el("div", "font-size:15px;font-weight:700;color:#eee;margin-bottom:4px;", "📝 添加详细描述")); + box.append(el("div", "font-size:12px;color:#8a97a5;margin-bottom:10px;line-height:1.7;white-space:pre-wrap;", + `词条:${nameText}\n` + + "写在这里的内容会以「词条(描述)」的形式拼进提示词(例:一位女性(银灰色短发))," + + "所以只写要补的细节即可 —— 不用重复词条本身,也别写整句话。")); + const ta = el("textarea", BOX_CSS + "width:100%;min-height:150px;resize:vertical;line-height:1.7;font-size:13px;"); + ta.spellcheck = false; + ta.value = value || ""; + ta.placeholder = "例:银灰色短发、微笑、正面半身、柔和自然光"; + box.append(ta); + const tip = el("div", "font-size:11px;color:#777;margin-top:6px;", "Esc = 取消 · Ctrl/⌘ + Enter = 保存"); + box.append(tip); + const row = el("div", "display:flex;justify-content:flex-end;gap:10px;margin-top:14px;"); + const cancelBtn = el("button", "background:transparent;border:1px solid #555;color:#fff;border-radius:4px;padding:7px 18px;font-size:13px;cursor:pointer;font-family:inherit;", "取消"); + const clrBtn = el("button", "background:transparent;border:1px solid #844;color:#ffb0b0;border-radius:4px;padding:7px 14px;font-size:13px;cursor:pointer;font-family:inherit;margin-right:auto;", "🗑 清除描述"); + const okBtn = el("button", "background:#2d5a88;color:#fff;border:none;border-radius:4px;padding:7px 20px;font-size:13px;font-weight:600;cursor:pointer;font-family:inherit;", "✅ 保存描述"); + row.append(clrBtn, cancelBtn, okBtn); + box.append(row); + overlay.append(box); + + const close = () => { try { overlay.remove(); } catch (_) {} }; + const commit = (v) => { close(); try { onSave && onSave(v); } catch (e) { console.error("[XB-生图提示词预设]", e); } }; + cancelBtn.addEventListener("click", close); + clrBtn.addEventListener("click", () => commit("")); + okBtn.addEventListener("click", () => commit(ta.value)); + overlay.addEventListener("mousedown", (e) => { if (e.target === overlay) close(); }); + // 独立弹窗的键盘处理必须吃掉事件:否则 Esc 会连带把下层分类面板一起关掉 + overlay.addEventListener("keydown", (e) => { + if (e.key === "Escape") { e.stopPropagation(); e.preventDefault(); close(); } + if ((e.ctrlKey || e.metaKey) && e.key === "Enter") { e.stopPropagation(); e.preventDefault(); commit(ta.value); } + }); + document.body.append(overlay); + try { ta.focus(); ta.setSelectionRange(ta.value.length, ta.value.length); } catch (_) {} + return overlay; +} + +/** 自建槽位的「中/英名称」编辑窗(同样是一个小弹窗) */ +function openSlotNameEditor(zhValue, enValue, onSave) { + const overlay = el("div", "position:fixed;inset:0;background:rgba(0,0,0,.72);z-index:10100;display:flex;align-items:center;justify-content:center;"); + const box = el("section", "background:#1c1c1e;border:1px solid #333;border-radius:8px;width:520px;max-width:92vw;box-shadow:0 20px 40px rgba(0,0,0,.6);padding:18px;box-sizing:border-box;color:#ddd;font-size:13px;"); + box.dataset.ippModal = "slotname"; // 稳定锤点 + box.append(el("div", "font-size:15px;font-weight:700;color:#eee;margin-bottom:10px;", "✎ 自建槽位名称(中 / 英)")); + const mk = (label, val, ph) => { + box.append(el("div", "font-size:11px;color:#8a97a5;margin:6px 0 3px;", label)); + const i = el("input", BOX_CSS + "width:100%;font-size:13px;padding:6px 8px;"); + i.type = "text"; + i.value = val || ""; + i.placeholder = ph; + box.append(i); + return i; + }; + const zh = mk("中文词条(中文 [ZH] 输出时用)", zhValue, "例:银灰色短发"); + const en = mk("English word(英文 [EN] 输出时用)", enValue, "e.g. silver-gray short hair"); + const row = el("div", "display:flex;justify-content:flex-end;gap:10px;margin-top:14px;"); + const cancelBtn = el("button", "background:transparent;border:1px solid #555;color:#fff;border-radius:4px;padding:7px 18px;font-size:13px;cursor:pointer;font-family:inherit;", "取消"); + const okBtn = el("button", "background:#2d5a88;color:#fff;border:none;border-radius:4px;padding:7px 20px;font-size:13px;font-weight:600;cursor:pointer;font-family:inherit;", "✅ 保存名称"); + row.append(cancelBtn, okBtn); + box.append(row); + overlay.append(box); + const close = () => { try { overlay.remove(); } catch (_) {} }; + const commit = () => { const z = zh.value.trim(), e2 = en.value.trim(); close(); try { onSave && onSave(z, e2); } catch (err) { console.error("[XB-生图提示词预设]", err); } }; + cancelBtn.addEventListener("click", close); + okBtn.addEventListener("click", commit); + overlay.addEventListener("mousedown", (e) => { if (e.target === overlay) close(); }); + overlay.addEventListener("keydown", (e) => { + if (e.key === "Escape") { e.stopPropagation(); e.preventDefault(); close(); } + if ((e.ctrlKey || e.metaKey) && e.key === "Enter") { e.stopPropagation(); e.preventDefault(); commit(); } + }); + document.body.append(overlay); + try { zh.focus(); } catch (_) {} + return overlay; +} + +function renderCategoryPanel(box, api, catId) { + const s = api.settings; + const surf = api.surf || {}; + let index = api.ctx.index; + const lang = () => surf.lang; + const bodyGet = () => api.ctx.body.get(); + const bodySet = (t) => { api.ctx.body.set(t); api.fromText(t); }; + const fire = () => box.dispatchEvent(new Event("change", { bubbles: true })); + + // ── 顶部工具条(所有分类面板共用同一套提示词工具)── + box.append(makeSectionTitle("提示词工具")); + + // ── 8 个分类切换按钮(与节点表面完全一致):点一下就地换到另一个分类面板 ── + const catRow = el("div", "display:grid;grid-template-columns:repeat(4,1fr);gap:6px;margin-bottom:8px;"); + catRow.dataset.ippCatRow = "1"; + for (const c of CATEGORIES) { + const on = c.id === catId; + const b = el("button", `box-sizing:border-box;height:30px;display:flex;align-items:center;justify-content:center;border-radius:6px;border:2px solid ${on ? "#7fb3e0" : "#5b9bd5"};background:${on ? "#2d5a88" : "#3a3a3a"};color:${on ? "#eaf4ff" : "#eee"};font-size:13px;cursor:pointer;font-family:inherit;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;`, c.label); + b.dataset.cat = c.id; + if (on) b.dataset.catActive = "1"; + b.title = `切到「${c.label}」面板 —— ${c.tip}`; + b.addEventListener("mouseenter", () => { if (!on) b.style.background = "#4a4a4a"; }); + b.addEventListener("mouseleave", () => { if (!on) b.style.background = "#3a3a3a"; }); + b.addEventListener("click", (e) => { + e.stopPropagation(); + if (!on && api.switchCategory) api.switchCategory(c.id); + }); + catRow.append(b); + } + box.append(catRow); + const bar = el("div", "display:flex;gap:8px;flex-wrap:wrap;margin-bottom:6px;"); + bar.append( + smallBtn("🧹 清空提示词", "border:1px solid #a44;background:#7a2a2a;color:#ffd0d0;padding:6px 12px;", "清空正文(同时清掉所有元素的「已选」状态)", () => { + openConfirm("🧹 清空提示词", "确定清空提示词正文?\n(所有元素的「已选」状态也会一起清掉)", () => { + s.elements.selected = {}; + s.elements.rel = {}; + bodySet(""); + rerenderAll(); + fire(); + notify("已清空提示词", "success"); + }); + }), + smallBtn("🔧 按选择重建", "border:1px solid #5b9bd5;background:#2a4a6a;color:#cfe3f7;padding:6px 12px;", "按「已选元素 + 固定分类顺序(风格→视角→主体→姿态→装扮→道具→光影→背景)+ 当前语言」重排正文(手工改乱了就用它)", () => { + const t = rebuildBody(s, index, lang()); + bodySet(t); + fire(); + notify(t ? "已按已选元素重建正文" : "当前没有已选元素(重建后正文为空)", t ? "success" : "warning"); + }), + smallBtn("📋 复制成句", "border:1px solid #555;background:#2a2a2a;color:#ccc;padding:6px 12px;", "复制最终成句(三视图模式下含预设句)", () => { + copyText(finalPrompt(surf.mode, surf.presetText, surf.lang, bodyGet(), surf.io)); + }), + smallBtn("⬆️ 导出词表", "border:1px solid #6b9b5b;background:#3a5a2a;color:#d7f0c8;padding:6px 12px;", "把「自建槽位 + 已选 + 描述」导出成 JSON 备份", () => exportJson(s)), + smallBtn("⬇️ 导入词表", "border:1px solid #6b9b5b;background:#3a5a2a;color:#d7f0c8;padding:6px 12px;", "从 JSON 恢复「自建槽位 + 已选 + 描述」(合并,不覆盖内置词表)", () => importJson((obj) => { + if (!obj || typeof obj !== "object") return; + for (const k of ["custom", "noted", "selected"]) { + if (obj[k] && typeof obj[k] === "object") s.elements[k] = Object.assign({}, s.elements[k], obj[k]); + } + s.elements = parseSettings(s).elements; // 归一化(过滤导入文件里的脏数据) + index = buildIndex(s.elements); + api.ctx.index = index; + rerenderAll(); + fire(); + })) + ); + box.append(bar); + box.append(el("div", "font-size:11px;color:#888;margin-bottom:10px;line-height:1.7;", + "顶部「分类签」切换子类(例:风格 → 真人 / 动画 / 3D / 绘画插画 / 特殊风格);搜索与「全部加入」只作用于**当前子类**。\n" + + "【点选项行】= 加入提示词(行变蓝、前面出现 ✔);再点一下 = 移除(变回灰色);【添加详细描述】开新窗口编辑补充描述(悬停行可直接查看)。\n" + + "道具类:点行会先弹「与主体的关系」标签窗(如 手拿 / 怀抱 / 背负 …),选完再加入;行上「🔗 关系」可随时换。\n" + + "搜索 / 只看已选 / 当前子类 属会话状态,不写进工作流;配置随工作流保存。")); + + const catBox = el("div", ""); + box.append(catBox); + + /** 只渲染当前分类(面板已按分类拆分,不再需要「全部展开/折叠」) */ + function rerenderAll() { + catBox.innerHTML = ""; + catBox.append(renderCategory(catOf(catId))); + } + + function renderCategory(cat) { + // 436 条词条只在第一次渲染时建 DOM;标题栏仍可点一下收起(纯会话状态) + if (!CAT_UI[cat.id]) CAT_UI[cat.id] = { collapsed: false, onlySel: false, keyword: "", sub: "" }; + const ui = CAT_UI[cat.id]; + const wrap = el("div", "border:1px solid #333;border-radius:6px;margin-bottom:8px;background:#191919;overflow:hidden;"); + const selOf = () => (s.elements.selected[cat.id] || []); + const selArr = () => (s.elements.selected[cat.id] || (s.elements.selected[cat.id] = [])); + const rebuild = () => { wrap.innerHTML = ""; build(); }; + + /** 当前可见(受「当前子类」+ 关键词 + 只看已选约束) */ + const visibleEntries = () => { + const sel = new Set(selOf()); + const kw = (ui.keyword || "").trim().toLowerCase(); + const out = []; + for (const k of (subOf(cat, ui.sub).keys || [])) { + const e = index[cat.id] && index[cat.id].get(k); + if (!e) continue; + const desc = s.elements.noted[`${cat.id}|${e.key}`] || ""; + if (ui.onlySel && !sel.has(e.key)) continue; + if (kw && !`${e.zh} ${e.en} ${desc} ${e.key}`.toLowerCase().includes(kw)) continue; + out.push(e); + } + return out; + }; + + /** 设置/清除某个道具的关系词(存在 settings.elements.rel) */ + const setRel = (key, relId) => { + const m = s.elements.rel || (s.elements.rel = {}); + if (relId && REL_MAP[relId]) m[`${cat.id}|${key}`] = relId; + else delete m[`${cat.id}|${key}`]; + }; + + const addKey = (key, relId) => { + const entry = index[cat.id] && index[cat.id].get(key); + if (!entry) return false; + if (relId !== undefined) setRel(key, relId); + const snip = snipOf(s, cat.id, key, entry, lang()); + if (!snip) { notify("该词条中/英文案都是空的(自建槽位请先填内容)", "warning"); return false; } + let t = bodyGet(); + if (t.includes(snip)) notify("提示词里已经有这一条了(已跳过)", "warning"); + else { t = appendSnippet(t, snip, lang()); bodySet(t); } + const arr = selArr(); + if (!arr.includes(key)) arr.push(key); + return true; + }; + + const removeKeys = (keys) => { + const snips = []; + for (const k of keys) { + const entry = index[cat.id] && index[cat.id].get(k); + if (!entry) continue; + const snip = snipOf(s, cat.id, k, entry, lang()); + if (snip) snips.push(snip); + const bare = wordOf(entry, lang()); + if (bare && bare !== snip) snips.push(bare); // 描述/关系被改过时兜底 + } + bodySet(removeSnippet(bodyGet(), snips, lang())); + const arr = s.elements.selected[cat.id] || []; + for (const k of keys) { const i = arr.indexOf(k); if (i >= 0) arr.splice(i, 1); } + if (!arr.length) delete s.elements.selected[cat.id]; + }; + + const countText = () => { + const sub = subOf(cat, ui.sub); + const total = (cat.subs || []).reduce((n, x) => n + (x.keys || []).length, 0); + return `(${sub.label} ${(sub.keys || []).length} 条 / 本类 ${total} 条 · 已选 ${selOf().length})`; + }; + + /** 分类内容:首次展开时才构建 */ + function buildBody(body, cnt) { + const updateCnt = () => { cnt.textContent = countText(); }; + + // ── 子分类签(可点击切换;会话状态,不写进工作流)── + const chips = el("div", "display:flex;gap:6px;flex-wrap:wrap;margin-bottom:8px;"); + const chipRefs = []; + const syncChips = () => { + const cur = subOf(cat, ui.sub).id; + for (const c of chipRefs) { + const on = c.dataset.sub === cur; + c.style.background = on ? "#2d5a88" : "#2a2a2a"; + c.style.borderColor = on ? "#7fb3e0" : "#555"; + c.style.color = on ? "#eaf4ff" : "#bbb"; + c.style.fontWeight = on ? "600" : "400"; + } + }; + for (const sub of (cat.subs || [])) { + const b = el("button", "border:1px solid #555;border-radius:14px;padding:4px 14px;font-size:12px;cursor:pointer;font-family:inherit;white-space:nowrap;", `${sub.label}(${(sub.keys || []).length})`); + b.dataset.sub = sub.id; + b.title = `切到「${sub.label}」子类(${(sub.keys || []).length} 条)`; + b.addEventListener("mousedown", (e) => e.stopPropagation()); + b.addEventListener("click", (e) => { + e.stopPropagation(); + ui.sub = sub.id; + ui.keyword = ""; + search.value = ""; + syncChips(); + renderRows(); + updateCnt(); + }); + chipRefs.push(b); + chips.append(b); + } + syncChips(); + body.append(chips); + + // ── 分类工具条 ── + const tb = el("div", "display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:8px;"); + const search = el("input", BOX_CSS + "flex:1 1 130px;min-width:110px;"); + search.type = "text"; + search.placeholder = "🔍 搜索本分类(中/英/描述)…"; + search.value = ui.keyword || ""; + // 打字防抖 140ms:本分类最多 120 行,逐键全量重建 DOM 会明显卡(面板内打字发涩) + let _kwTimer = null; + search.addEventListener("input", () => { + ui.keyword = search.value; + clearTimeout(_kwTimer); + _kwTimer = setTimeout(() => { try { renderRows(); } catch (_) {} }, 140); + }); + tb.append(search); + tb.append(checkboxControl(!!ui.onlySel, "只看已选", (v) => { ui.onlySel = v; renderRows(); }, "只显示已经加进提示词的词条")); + // 加入当前可见的全部词条(超过 60 条时先弹 modal 确认) + const addAllNow = (list) => { + let t = bodyGet(); + const arr = selArr(); + let n = 0; + for (const e of list) { + const snip = snipOf(s, cat.id, e.key, e, lang()); + if (!snip) continue; + if (!t.includes(snip)) { t = appendSnippet(t, snip, lang()); n++; } + if (!arr.includes(e.key)) arr.push(e.key); + } + bodySet(t); + rebuild(); + fire(); + notify(n ? `已加入 ${n} 条` : "这些词条都已在提示词里", n ? "success" : "warning"); + }; + tb.append(smallBtn("+ 全部加入", "border:1px solid #5b9bd5;background:#2a4a6a;color:#cfe3f7;", "把本分类当前可见的词条全部追加进提示词", () => { + const list = visibleEntries(); + if (!list.length) { notify("没有可加入的词条", "warning"); return; } + if (list.length > 60) { + openConfirm("加入大量词条", `将加入 ${list.length} 条词条,提示词会很长,确定?`, () => addAllNow(list)); + return; + } + addAllNow(list); + })); + tb.append(smallBtn("🗑 全部移除", "border:1px solid #a44;background:#5a2a2a;color:#ffd0d0;", "把本分类已选词条全部从提示词移除", () => { + const keys = selOf().slice(); + if (!keys.length) { notify("本分类还没有已选词条", "warning"); return; } + removeKeys(keys); + rebuild(); + fire(); + notify(`已移除 ${keys.length} 条`, "success"); + })); + body.append(tb); + + const rowBox = el("div", ""); + body.append(rowBox); + + function renderRows() { + rowBox.innerHTML = ""; + const list = visibleEntries(); + if (!list.length) { + rowBox.append(el("div", "font-size:12px;color:#777;padding:6px 2px;", + ui.keyword ? "没有匹配的词条" : (ui.onlySel ? "本分类还没有已选词条(关掉「只看已选」可看全部)" : "本分类暂无词条"))); + } else { + for (const e of list) rowBox.append(makeWordRow(e)); + } + updateCnt(); + } + + /** 单条词:整行就是开关 —— 点一下加入(变蓝),再点一下移除(变灰);右侧【添加详细描述】 */ + function makeWordRow(entry) { + const selected = selOf().includes(entry.key); + const descKey = `${cat.id}|${entry.key}`; + const desc = s.elements.noted[descKey] || ""; + const row = el("div", `display:flex;align-items:center;gap:8px;padding:5px 8px;border-radius:5px;margin-bottom:3px;box-sizing:border-box;cursor:pointer;user-select:none;background:${selected ? "#22456b" : "#242424"};border:1px solid ${selected ? "#3f86d0" : "#3a3a3a"};`); + // 悬停整行 → 显示完整词条、当前状态与详细描述(原生 tooltip) + row.title = `${entry.zh || "(空)"} / ${entry.en || "(空)"}` + + (entry.custom ? "\n(自建槽位)" : "") + + `\n${selected ? "已加入提示词 —— 再点一下移除" : "点一下加入提示词"}` + + "\n\n详细描述:" + (desc || "(还没有,点右侧【添加详细描述】写一条)"); + row.addEventListener("click", () => { + if (selOf().includes(entry.key)) { removeKeys([entry.key]); renderRows(); fire(); return; } + if (cat.id === "props") { + // 道具:先弹「与主体的关系」多宫格标签,选完再加入提示词 + openRelPicker(api.node, `${entry.zh || entry.key} / ${entry.en || ""}`, lang(), + (relPair(s, cat.id, entry.key) || {}).id || "", + (relId) => { addKey(entry.key, relId || ""); renderRows(); fire(); }); + return; + } + addKey(entry.key); + renderRows(); + fire(); + }); + if (entry.custom) row.append(el("span", "font-size:10px;color:#f0d79a;border:1px solid #8a6d3b;border-radius:3px;padding:0 3px;flex:0 0 auto;", "自建")); + const rel = relPair(s, cat.id, entry.key); + if (rel) row.append(el("span", "font-size:10px;color:#9fe0c0;border:1px solid #3f7a5f;border-radius:3px;padding:0 3px;flex:0 0 auto;", rel.zh)); + const nm = el("span", `flex:1 1 auto;min-width:0;font-size:12px;line-height:1.4;color:${selected ? "#eaf4ff" : "#c8c8c8"};overflow:hidden;text-overflow:ellipsis;white-space:nowrap;`, + `${selected ? "✔ " : ""}${entry.zh || "(空)"} / ${entry.en || "(空)"}`); + row.append(nm); + if (cat.id === "props") { + row.append(smallBtn(rel ? `🔗 ${rel.zh}` : "🔗 关系", "border:1px solid #5b9bd5;background:#2a4a6a;color:#cfe3f7;padding:4px 10px;flex:0 0 auto;", "选择/更换这个道具与主体的关系词(先移旧片段再加新片段)", () => { + openRelPicker(api.node, `${entry.zh || entry.key} / ${entry.en || ""}`, lang(), + (relPair(s, cat.id, entry.key) || {}).id || "", + (relId) => { + if (selOf().includes(entry.key)) removeKeys([entry.key]); + addKey(entry.key, relId || ""); + renderRows(); + fire(); + }); + })); + } + const descBtn = smallBtn(desc ? "📝 详细描述" : "添加详细描述", + `border:1px solid ${desc ? "#8a6d3b" : "#555"};background:${desc ? "#3a2f16" : "#2a2a2a"};color:${desc ? "#ffd98a" : "#9fc3e8"};padding:4px 10px;flex:0 0 auto;`, + desc ? "编辑该词条的详细描述(新窗口)" : "为该词条添加详细描述(新窗口)", + () => openDescEditor(api.node, entry, `${entry.zh || entry.en || entry.key}`, desc, (text) => { + const v = String(text || "").trim(); + const oldSnip = snippetOf(entry, lang(), desc, relPair(s, cat.id, entry.key)); + if (v) s.elements.noted[descKey] = v; else delete s.elements.noted[descKey]; + // 已加入提示词的话,把正文里的旧片段原地换成新片段(位置不跳) + if (selected) { + const newSnip = snippetOf(entry, lang(), v, relPair(s, cat.id, entry.key)); + const cur = bodyGet(); + if (oldSnip && newSnip && oldSnip !== newSnip && cur.includes(oldSnip)) bodySet(cur.split(oldSnip).join(newSnip)); + } + renderRows(); + api.refreshPreview(null); + fire(); + })); + row.append(descBtn); + return row; + } + + renderRows(); + + // ── 自建槽位 ── + body.append(makeSectionTitle("🛠 自建槽位(本分类专属)")); + const cusBox = el("div", ""); + body.append(cusBox); + + function renderCustom() { + cusBox.innerHTML = ""; + const list = s.elements.custom[cat.id] || []; + if (!list.length) { + cusBox.append(el("div", "font-size:11px;color:#777;padding:2px 0 6px;", "还没有自建槽位 —— 点下面「+ 新增槽位」加一个(点【编辑名称】填中/英,再点该行即加入提示词)。")); + return; + } + list.forEach((it, i) => { + const entry = index[cat.id] && index[cat.id].get(it.key); + const selected = selOf().includes(it.key); + const descKey = `${cat.id}|${it.key}`; + const desc = s.elements.noted[descKey] || ""; + const row = el("div", `display:flex;align-items:center;gap:8px;padding:5px 8px;border-radius:5px;margin-bottom:3px;box-sizing:border-box;cursor:pointer;user-select:none;background:${selected ? "#22456b" : "#242424"};border:1px solid ${selected ? "#3f86d0" : "#3a3a3a"};`); + row.title = `${it.zh || "(未填中文)"} / ${it.en || "(未填英文)"}\n(自建槽位)` + + `\n${selected ? "已加入提示词 —— 再点一下移除" : "点一下加入提示词"}` + + "\n\n详细描述:" + (desc || "(还没有,点【添加详细描述】写一条)"); + row.addEventListener("click", () => { + if (selOf().includes(it.key)) removeKeys([it.key]); + else addKey(it.key); + rebuild(); + fire(); + }); + row.append(el("span", "font-size:10px;color:#f0d79a;border:1px solid #8a6d3b;border-radius:3px;padding:0 3px;flex:0 0 auto;", "自建")); + row.append(el("span", `flex:1 1 auto;min-width:0;font-size:12px;color:${selected ? "#eaf4ff" : "#c8c8c8"};overflow:hidden;text-overflow:ellipsis;white-space:nowrap;`, + `${selected ? "✔ " : ""}${it.zh || "(未填中文)"} / ${it.en || "(未填英文)"}`)); + row.append(smallBtn("✎ 编辑名称", "border:1px solid #555;background:#2a2a2a;color:#9fc3e8;padding:4px 10px;flex:0 0 auto;", "新窗口编辑该槽位的中/英词条", () => { + openSlotNameEditor(it.zh, it.en, (z, e2) => { + it.zh = z; it.en = e2; + if (entry) { entry.zh = z; entry.en = e2; } + rebuild(); + api.refreshPreview(null); + fire(); + }); + })); + row.append(smallBtn(desc ? "📝 详细描述" : "添加详细描述", + `border:1px solid ${desc ? "#8a6d3b" : "#555"};background:${desc ? "#3a2f16" : "#2a2a2a"};color:${desc ? "#ffd98a" : "#9fc3e8"};padding:4px 10px;flex:0 0 auto;`, + desc ? "编辑该自建词条的详细描述(新窗口)" : "为该自建词条添加详细描述(新窗口)", + () => openDescEditor(api.node, entry || { key: it.key, zh: it.zh, en: it.en }, `${it.zh || it.en || it.key}`, desc, (text) => { + const v = String(text || "").trim(); + const oldSnip = snippetOf(entry || it, lang(), desc); + if (v) s.elements.noted[descKey] = v; else delete s.elements.noted[descKey]; + if (selected && entry) { + const newSnip = snippetOf(entry, lang(), v); + const cur = bodyGet(); + if (oldSnip && newSnip && oldSnip !== newSnip && cur.includes(oldSnip)) bodySet(cur.split(oldSnip).join(newSnip)); + } + rebuild(); + api.refreshPreview(null); + fire(); + }))); + row.append(smallBtn("🗑", "border:1px solid #a44;background:#3a2020;color:#ffb0b0;width:24px;height:24px;padding:0;line-height:1;flex:0 0 auto;", "删除这个自建槽位", () => { + openConfirm("删除自建槽位", `删除自建槽位「${it.zh || it.en || it.key}」?`, () => { + removeKeys([it.key]); + delete s.elements.noted[`${cat.id}|${it.key}`]; + list.splice(i, 1); + if (!list.length) delete s.elements.custom[cat.id]; + index = buildIndex(s.elements); + api.ctx.index = index; + rebuild(); + fire(); + }); + })); + cusBox.append(row); + }); + } + renderCustom(); + + const cusBar = el("div", "display:flex;gap:8px;flex-wrap:wrap;margin-top:4px;"); + cusBar.append(smallBtn("+ 新增槽位", "border:1px dashed #5b9bd5;background:#2a3a4a;color:#9fc3e8;padding:6px 12px;", "新增一个自定义词条槽位(中/英各填一个)", () => { + const arr = s.elements.custom[cat.id] || (s.elements.custom[cat.id] = []); + arr.push({ key: `c${Date.now().toString(36)}${Math.floor(Math.random() * 46656).toString(36)}`, zh: "", en: "" }); + index = buildIndex(s.elements); + api.ctx.index = index; + rebuild(); + fire(); + })); + cusBar.append(smallBtn("🗑 删除槽位", "border:1px solid #a44;background:#5a2a2a;color:#ffd0d0;padding:6px 12px;", "删除本分类最后一个自建槽位(要删指定的,用该行右侧的 🗑)", () => { + const arr = s.elements.custom[cat.id] || []; + if (!arr.length) { notify("本分类还没有自建槽位", "warning"); return; } + const last = arr[arr.length - 1]; + openConfirm("删除最后一个自建槽位", `删除最后一个自建槽位「${last.zh || last.en || last.key}」?`, () => { + removeKeys([last.key]); + delete s.elements.noted[`${cat.id}|${last.key}`]; + arr.pop(); + if (!arr.length) delete s.elements.custom[cat.id]; + index = buildIndex(s.elements); + api.ctx.index = index; + rebuild(); + fire(); + }); + })); + body.append(cusBar); + + updateCnt(); + } + + /** 分类外壳:标题栏总是建,内容懒建(展开才建) */ + function build() { + wrap.innerHTML = ""; + const headRow = el("div", "display:flex;align-items:center;gap:8px;padding:8px 10px;background:#20242a;cursor:pointer;flex-wrap:wrap;"); + const arrow = el("span", "font-size:11px;color:#9fc3e8;width:12px;flex:0 0 12px;", ui.collapsed ? "▶" : "▼"); + const cnt = el("span", "font-size:11px;color:#8a97a5;", countText()); + headRow.append(arrow, el("span", "font-size:13px;font-weight:600;color:#cfe3f7;", cat.label), cnt, + el("span", "font-size:11px;color:#6f7c8a;flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;", cat.tip || "")); + const body = el("div", "padding:8px 10px;"); + body.style.display = ui.collapsed ? "none" : "block"; + let bodyBuilt = false; + const ensureBody = () => { if (!bodyBuilt) { bodyBuilt = true; buildBody(body, cnt); } }; + headRow.addEventListener("click", (e) => { + if (e.target && e.target.closest && e.target.closest("input,button,select,textarea,label")) return; + ui.collapsed = !ui.collapsed; + body.style.display = ui.collapsed ? "none" : "block"; + arrow.textContent = ui.collapsed ? "▶" : "▼"; + if (!ui.collapsed) ensureBody(); + }); + wrap.append(headRow, body); + if (!ui.collapsed) ensureBody(); + } + + build(); + return wrap; + } + + rerenderAll(); +} + +function exportJson(settings) { + try { + const payload = { + app: "XB-ImagePromptPreset", + version: 1, + custom: settings.elements.custom || {}, + noted: settings.elements.noted || {}, + selected: settings.elements.selected || {}, + }; + const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" }); + const a = document.createElement("a"); + a.href = URL.createObjectURL(blob); + const d = new Date(); + const pad = (n) => String(n).padStart(2, "0"); + a.download = `生图元素_${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}_${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}.json`; + document.body.appendChild(a); + a.click(); + setTimeout(() => { try { URL.revokeObjectURL(a.href); a.remove(); } catch (_) {} }, 1000); + notify("已导出词表 JSON", "success"); + } catch (e) { notify("导出失败:" + ((e && e.message) || e), "error"); } +} + +function importJson(onDone) { + try { + if (!document.__ippImportInput) { + const inp = document.createElement("input"); + inp.type = "file"; + inp.accept = ".json,application/json"; + inp.style.display = "none"; + document.body.appendChild(inp); + document.__ippImportInput = inp; + } + const inp = document.__ippImportInput; + inp.value = ""; + inp.onchange = async () => { + const file = inp.files && inp.files[0]; + if (!file) return; + try { + const text = await new Promise((res, rej) => { + const r = new FileReader(); + r.onload = () => res(r.result || ""); + r.onerror = () => rej(r.error); + r.readAsText(file, "utf-8"); + }); + onDone(JSON.parse(text)); + notify("已导入词表(自建槽位 / 已选 / 描述)", "success"); + } catch (e) { notify("导入失败:" + ((e && e.message) || e), "error"); } + }; + inp.click(); + } catch (e) { notify("导入失败:" + ((e && e.message) || e), "error"); } +} + +/* ============================================================ + * 节点挂载 + * ============================================================ */ +function setupNode(node) { + const widgetState = new WeakMap(); // 隐藏 widget 前的原样(type/computeSize/hidden),供 showWidget 原样恢复 + const wAR = findWidget(node, "aspect_ratio"); + const wW = findWidget(node, "width"); + const wH = findWidget(node, "height"); + const wB = findWidget(node, "batch_size"); + const wLang = findWidget(node, "output_lang"); + const wMode = findWidget(node, "preset_mode"); + const wKind = findWidget(node, "latent_kind"); + const w3v = findWidget(node, "three_view_text"); + const ipWidget = findWidget(node, "internal_prompt"); + if (!wAR || !wW || !wH || !ipWidget) return; + + // ── 内部字段从节点表面隐藏 ──────────────────────────────────────── + // ⚠️ 实测教训:后端只写 "advanced": True 在本前端版本里**不会**把 V1 widget 从节点表面藏起来, + // internal_prompt / manager_settings 会真的显示成两个 86px 高的文本框并把节点撑高 + // (实测节点 420x314 → 550x570)。这里沿用本包既有做法(xb_audio_slicer_v3.js:30、 + // xb_digital_human_dual.js:83):type="hidden" + computeSize=[0,-4]。 + const hideWidget = (w) => { + if (!w) return; + try { + // 记下原样(type / computeSize / hidden),以便必要时原样恢复(如三视图预设句框) + if (!widgetState.has(w)) { + widgetState.set(w, { + type: w.type, computeSize: w.computeSize, hidden: w.hidden, + optHidden: w.options ? w.options.hidden : undefined, + }); + } + if (w.element) w.element.style.display = "none"; + if (w.inputEl) w.inputEl.style.display = "none"; + w.type = "hidden"; + w.hidden = true; + if (w.options) w.options.hidden = true; + w.computeSize = () => [0, -4]; + } catch (_) {} + }; + const showWidget = (w) => { + if (!w) return; + try { + const st = widgetState.get(w); + if (w.element) w.element.style.display = ""; + if (w.inputEl) w.inputEl.style.display = ""; + if (st) { + // 只在确有存档时还原 type/computeSize + if (st.type) w.type = st.type; + w.hidden = !!st.hidden; + if (w.options) w.options.hidden = st.optHidden; + if (st.computeSize) w.computeSize = st.computeSize; else { try { delete w.computeSize; } catch (_) {} } + } else { + // ⚠️ 没有存档(从未 hideWidget 过,如加载「预设模式=三视图」的工作流直接就 show)时 + // 绝不能写 w.type —— 旧写法 `(st && st.type) || "text"` 会把原生的多行 + // customtext/textarea 预设句框改成单行 type="text",界面上变成一个一行输入栏。 + w.hidden = false; + if (w.options) w.options.hidden = false; + } + } catch (_) {} + }; + // 提前为「会被 show/hide 的原生框」存一份原样(预设句框在部分工作流里从未被 hide 过) + const snapshotWidget = (w) => { + if (!w || widgetState.has(w)) return; + try { + widgetState.set(w, { + type: w.type, computeSize: w.computeSize, hidden: w.hidden, + optHidden: w.options ? w.options.hidden : undefined, + }); + } catch (_) {} + }; + snapshotWidget(w3v); + + // 模版切换(文生图 / 图生图):基础节点没有这个 widget → null,逻辑自动按「文生图」走 + const wIo = findWidget(node, "io_mode"); + + // ── 预设句(设定词)存档:用户改过的那条按「模式|语言」存进节点 ────────────── + // 切换模式 / 切换语言一律取「该键的存档 → 该键的默认」,所以用户改过的设定词不会被冲掉。 + // ⚠️ 写入用「原样 JSON 合并」——不能走 persistSettings(那会丢掉 Pro 的 llm 段等其它键)。 + let lastAppliedPreset = String(readWidgetValue(w3v) ?? ""); + const presetStoreOf = () => { + try { return parsePresetTexts(parseSettings(readWidgetValue(findWidget(node, "manager_settings"))).preset_texts); } + catch (_) { return {}; } + }; + const savedPresetOf = (mode, io, lang) => presetStoreOf()[presetKeyOf(mode, io, lang)] || ""; + /** 该「模式 × 模版 × 语言」最终生效的设定词:用户改过的存档优先,否则用默认 + * (存档值等于任一默认 / 旧条款 → 视为「没改过」 → 用当前模版的默认) */ + const presetTextFor = (mode, io, lang) => { + const s = savedPresetOf(mode, io, lang); + if (s && !isDefaultPresetText(mode, s)) return s; + return presetTextOf(mode, lang, io); + }; + /** 存 / 清一条设定词存档(与默认一致 → 清掉存档,保持工作流干净) */ + const writePresetStore = (mode, io, lang, text) => { + try { + let data = {}; + try { data = JSON.parse(String(readWidgetValue(findWidget(node, "manager_settings")) || "{}")) || {}; } + catch (_) { data = {}; } + if (!data || typeof data !== "object") data = {}; + const store = parsePresetTexts(data.preset_texts); + const key = presetKeyOf(mode, io, lang); + const t = String(text ?? ""); + if (!t.trim() || t === presetTextOf(mode, lang, io)) delete store[key]; + else store[key] = t; + if (Object.keys(store).length) data.preset_texts = store; else delete data.preset_texts; + setWidgetValue(findWidget(node, "manager_settings"), JSON.stringify(data)); + try { node.__ippRefreshInfo?.(); } catch (_) {} + } catch (_) {} + }; + + const hideInternal = () => { + hideWidget(findWidget(node, "internal_prompt")); + hideWidget(findWidget(node, "manager_settings")); + // Qwen2.1提示词预设:这些参数只在弹窗里配置(节点表面不暴露) + hideWidget(findWidget(node, "output_lang")); // → ✨ 增强预设 弹窗 + hideWidget(findWidget(node, "latent_kind")); // → ✨ 增强预设 弹窗 + hideWidget(findWidget(node, "preset_mode")); // → ✨ 增强预设 弹窗 + hideWidget(findWidget(node, "three_view_text")); // 设定词 → 只在弹窗里编辑 + for (const nm of XBR_QWEN_WIDGETS) hideWidget(findWidget(node, nm)); + refreshNodes2View(node); + node.setDirtyCanvas?.(true, true); + }; + hideInternal(); + + // ── 节点表面 DOM:结构照抄「短剧导演台」(js/asset_manager.js)── + // ① 容器 = 满高 flex 列(width/height:100% + overflow:hidden),高度完全交给前端 overlay 布局; + // 提示词框 flex:1 1 auto 吃掉剩余高度。全程零 JS 高度计算 → 不可能出现 + // 「容器高 ↔ 节点高」互相反馈(之前读到 1675 的怪物高度)或被裁掉底部。 + // ⚠️ 绝不读容器的 scrollHeight / offsetHeight(任何 DOM 尺寸实测都不读),也绝不在打字时 setSize/重排。 + // 容器必须自带不透明的节点底色:容器里的 padding / 行间距本来透明, + // 节点原生 widget 滚动时会从这些缝隙里透出来(用户报的「提示词预览上方有缝隙、滚动时漏内容」)。 + let nodeBg = "#353535"; + try { nodeBg = node.bgcolor || (window.LiteGraph && window.LiteGraph.NODE_DEFAULT_BGCOLOR) || nodeBg; } catch (_) {} + const container = el("div", `width:100%;height:100%;display:flex;flex-direction:column;gap:6px;padding:8px;box-sizing:border-box;overflow:hidden;background:${nodeBg};border-bottom-left-radius:8px;border-bottom-right-radius:8px;`); + + // ② 按钮区放最上面(对齐短剧导演台:紧贴节点原生参数下方,不随内容高度上下浮动) + // 8 个按钮 = 8 个元素分类,每个按钮只打开该分类的面板(4 列 × 2 行) + const btnRow = el("div", "display:grid;grid-template-columns:repeat(4,1fr);grid-auto-rows:30px;gap:6px;flex:0 0 auto;"); + for (const b of PANEL_BUTTONS) { + const cat = catOf(b.panel); + const btn = el("button", "width:100%;height:30px;box-sizing:border-box;display:flex;align-items:center;justify-content:center;border-radius:6px;border:2px solid #5b9bd5;background:#3a3a3a;color:#eee;font-size:13px;cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-family:inherit;", b.label); + btn.title = `打开「${cat.label}」元素面板 —— ${cat.tip}\\n(里面:点选项行加入/移除提示词,【添加详细描述】写补充描述)`; + btn.addEventListener("mousedown", (e) => e.stopPropagation()); + btn.addEventListener("mouseenter", () => { btn.style.background = "#4a4a4a"; }); + btn.addEventListener("mouseleave", () => { btn.style.background = "#3a3a3a"; }); + btn.addEventListener("click", (e) => { + e.stopPropagation(); + const settings = loadSettings(node); + const index = buildIndex(settings.elements); + const ctx = { + node, settings, index, + surface: surfaceOf(), + body: { + get: () => String(readWidgetValue(ipWidget) ?? ""), + set: (v) => { setWidgetValue(ipWidget, v); if (promptBox.value !== v) promptBox.value = v; }, + }, + refreshNode: () => syncPromptBoxFromWidget(), + }; + openPanel(ctx, b.panel); + }); + btnRow.append(btn); + } + // 第 1 行按钮:🤖 LLM设置 | ✨ 增强预设 | 📖 使用说明 + container.appendChild(xbrBuildButtonRow(node)); + // 第 2 行:8 个元素分类按钮 + container.appendChild(btnRow); + + // ③ 提示词框(flex:1 1 auto 吃掉剩余高度;原来的「生成详情」状态行已按要求删除) + const promptBox = el("textarea", BOX_CSS + "width:100%;flex:1 1 auto;min-height:300px;resize:none;line-height:1.6;font-size:12px;overflow-y:auto;"); + promptBox.spellcheck = false; + promptBox.placeholder = "点上方按钮开面板、点选项行加入元素,或直接在这里写提示词…\n执行节点时:这段文字会作为【正文】交给文本编码器生成完整提示词\n「📝 提示词」端口接线后本框锁定"; + // 标题行(左标签 + 右「📋 复制」)+ 参数设定显示(同时挂上端口锁定与执行回写) + const xbrHeadAndInfo = xbrBuildPromptHeader(node, promptBox); + container.append(xbrHeadAndInfo[0], promptBox, xbrHeadAndInfo[1]); + + // ── 提示词框 ↔ internal_prompt ── + const syncPromptBoxFromWidget = () => { + const v = String(readWidgetValue(ipWidget) ?? ""); + if (promptBox.value !== v && document.activeElement !== promptBox) promptBox.value = v; + }; + promptBox.addEventListener("input", () => { setWidgetValue(ipWidget, promptBox.value); }); + + // ── 表面参数(原「⚙️ 输出设置」面板的 4 项已上提到节点表面)── + /** 当前模版(文生图 / 图生图):自动 → 按「🖼️ 图像」有没有接线判断 */ + const curIoMode = () => resolveIoMode(ioModeOf(wIo ? readWidgetValue(wIo) : ""), modeHasImage(node)); + const surfaceOf = () => ({ + lang: pickOpt(LANGS, readWidgetValue(wLang), LANG_ZH), + mode: pickOpt(MODES, readWidgetValue(wMode), MODE_TP), + io: curIoMode(), + kind: pickOpt(LATENT_KINDS, readWidgetValue(wKind), DEFAULT_LATENT_KIND), + presetText: String(readWidgetValue(w3v) ?? ""), + }); + /** 当前空latent类型对应的步长/上下限(后端按同一张表算,保证所见即所得) */ + const sizeOpt = () => { + const sp = latentSpec(surfaceOf().kind); + return { step: sp.step, min: sp.min, max: sp.max }; + }; + + // 三视图预设句框是 ComfyUI 原生 textarea(自带 h-full w-full 与默认圆角), + // 必须手动对齐节点表面提示词框:同宽(容器左右各 8px 内边距)+ 同样的圆角 / 配色 + const nodeEl = () => document.querySelector(`[data-node-id="${node.id}"]`); + const markNodeEl = () => { try { nodeEl()?.setAttribute("data-xb-ipp", "1"); } catch (_) {} }; + // Nodes 2.0:原生 widget 的 DOM 是 Vue 重建的,`w3v.element / inputEl` 是**已脱离文档**的老元素 + // (实测 connected=false),真正显示的那个 textarea 用的是前端 Textarea 组件, + // class 带 scrollbar-gutter-stable,默认 min-height 只有 4rem 且 overflow-y:hidden + // → 预设句较长时直接截断、也拉不高。这里单独给它放宽(面板自己的预览框没有这个 class,不受影响)。 + const IPP_STYLE_ID = "xb-ipp-preset-textarea-style"; + const ensurePresetBoxStyle = () => { + try { + if (document.getElementById(IPP_STYLE_ID)) return; + const st = document.createElement("style"); + st.id = IPP_STYLE_ID; + st.textContent = ` + [data-xb-ipp] textarea[class*="scrollbar-gutter-stable"] { + min-height: 132px; + max-height: 360px; + overflow-y: auto !important; + resize: vertical; + }`; + document.head.appendChild(st); + } catch (_) {} + }; + const livePresetBox = () => { + try { + const el = nodeEl(); + if (!el) return null; + const cands = Array.from(el.querySelectorAll('textarea[class*="scrollbar-gutter-stable"]')); + if (!cands.length) return null; + const val = String(readWidgetValue(w3v) ?? ""); + return cands.find((t) => String(t.value || "") === val) || cands[0]; + } catch (_) { return null; } + }; + const stylePresetTextBox = () => { + try { + ensurePresetBoxStyle(); + markNodeEl(); + // 1.0(canvas 里的 DOM widget):按老做法修边 + if (w3v) for (const box of [w3v.element, w3v.inputEl]) { + if (!box || !box.style) continue; + box.style.width = "calc(100% - 16px)"; + box.style.margin = "0 8px"; + box.style.boxSizing = "border-box"; + box.style.borderRadius = "4px"; + box.style.border = "1px solid #444"; + box.style.background = "#1d1d1d"; + box.style.color = "#ccc"; + box.style.padding = "5px 8px"; + box.style.fontSize = "12px"; + box.style.lineHeight = "1.6"; + box.style.fontFamily = "inherit"; + } + // 2.0:把“活的”多行框调成够看、可滚、可拉高 + const live = livePresetBox(); + // 用户在这个原生框里手改设定词 → 立刻存档(换模式 / 换语言时不丢) + if (live && !live.__ippBound) { + live.__ippBound = true; + const commitUserEdit = () => { + try { + const v = String(live.value ?? ""); + if (v === lastAppliedPreset) return; // 程序化写入的回声 → 忽略 + lastAppliedPreset = v; + setWidgetValue(w3v, v); + const s = surfaceOf(); + writePresetStore(s.mode, s.io, s.lang, v); + } catch (_) {} + }; + live.addEventListener("change", commitUserEdit); + live.addEventListener("blur", commitUserEdit); + } + if (live && live.style) { + live.style.minHeight = "132px"; + live.style.maxHeight = "360px"; + live.style.overflowY = "auto"; + live.style.resize = "vertical"; + live.style.whiteSpace = "pre-wrap"; + live.style.lineHeight = "1.6"; + } + } catch (_) {} + }; + + /** 写预设句:同时写 widget 值 + textarea(新版前端 customtext 把值存在 DOM 上,只手写 widget 可能不刷新显示) + * lastAppliedPreset 记下「程序写进去的值」→ 回调里只有不等于它的才算用户手改(防回声被误存档) */ + const setPresetText = (txt) => { + const v = String(txt ?? ""); + lastAppliedPreset = v; + try { setWidgetValue(w3v, v); } catch (_) {} + try { if (w3v && w3v.element && "value" in w3v.element) w3v.element.value = v; } catch (_) {} + try { node.setDirtyCanvas?.(true, true); } catch (_) {} + }; + + // 预设句输入框:无预设以外的模式(三视图/四视图/五视图/背景纯透明 等)都显示(一次性隐藏/显示,不做任何 setSize,不会抖动) + const applyPresetTextVisibility = () => { + try { + // 设定词只在「✨ 增强预设」弹窗里编辑 → 节点表面始终隐藏 + hideWidget(w3v); + } catch (_) {} + refreshNodes2View(node); + // 2.0 下 Vue 可能稍后才把原生 widget 的 DOM 挂上/重建 → 几个时机各补一次样式与节点标记 + [0, 60, 200, 600].forEach((d) => setTimeout(() => { + ensurePresetBoxStyle(); + markNodeEl(); + try { if (PRESET_TEXT_DEFAULT[surfaceOf().mode]) stylePresetTextBox(); } catch (_) {} + }, d)); + node.setDirtyCanvas?.(true, true); + }; + + // 空latent类型 → 同步 width/height/batch 的原生 step & 上下限(箭头/拖动按官方步长走) + const applyKindLimits = () => { + const sp = latentSpec(surfaceOf().kind); + for (const w of [wW, wH]) { + if (w && w.options) { w.options.step = sp.step; w.options.min = sp.min; w.options.max = sp.max; } + } + if (wB && wB.options) wB.options.max = sp.batchMax; + if (wKind && wKind.options) wKind.options.tooltip = `空latent类型(${sp.desc})\n尺寸步长 ${sp.step},batch 上限 ${sp.batchMax}。\n选你正在用的模型即可,形状/下采样/步长会自动适配。`; + const b = parseInt(readWidgetValue(wB), 10) || 1; + if (b > sp.batchMax) setWidgetValue(wB, sp.batchMax); + }; + + // ── 画幅比例 + 步长联动(语义对齐 js/xb_video.js;步长按当前空latent类型) ── + let syncing = false; + const snapByWidth = () => { + if (syncing) return; + const [nw, nh] = snapFromWidth(String(readWidgetValue(wAR) ?? "Free"), readWidgetValue(wW), sizeOpt()); + syncing = true; + try { + if (nw != null && nw !== parseInt(readWidgetValue(wW), 10)) setWidgetValue(wW, nw); + if (nh != null && nh !== parseInt(readWidgetValue(wH), 10)) setWidgetValue(wH, nh); + } finally { syncing = false; } + }; + const snapByHeight = () => { + if (syncing) return; + const [nw, nh] = snapFromHeight(String(readWidgetValue(wAR) ?? "Free"), readWidgetValue(wH), sizeOpt()); + syncing = true; + try { + if (nh != null && nh !== parseInt(readWidgetValue(wH), 10)) setWidgetValue(wH, nh); + if (nw != null && nw !== parseInt(readWidgetValue(wW), 10)) setWidgetValue(wW, nw); + } finally { syncing = false; } + }; + const hook = (w, fn) => { + if (!w) return; + const orig = w.callback; + w.callback = function (...args) { + const r = orig ? orig.apply(this, args) : undefined; + try { fn(); } catch (e) { console.error("[XB-生图提示词预设]", e); } + return r; + }; + }; + hook(wAR, snapByWidth); + hook(wW, snapByWidth); + hook(wH, snapByHeight); + hook(wKind, () => { applyKindLimits(); snapByWidth(); }); // 换类型 → 按新步长重新归一宽高 + // 2.0:注入「原生多行框放宽」样式 + 给节点元素打标记(数据属性是 CSS 作用域的锚点; + // Vue 重建节点 DOM 后会丢,所以拖后几次重打) + ensurePresetBoxStyle(); + markNodeEl(); + [0, 300, 1200].forEach((d) => setTimeout(() => { ensurePresetBoxStyle(); markNodeEl(); }, d)); + hook(w3v, () => { // 用户手改设定词 → 立刻存进节点 + const cur = String(readWidgetValue(w3v) ?? ""); + if (cur === lastAppliedPreset) return; // 程序化写入的回声 → 忽略 + lastAppliedPreset = cur; + const s = surfaceOf(); + writePresetStore(s.mode, s.io, s.lang, cur); + }); + hook(wMode, () => { // 换模式 = 显/隐设定词框 + + const { mode, lang, io } = surfaceOf(); // 取该「模式 × 模版」的「用户存档 → 默认」 + setPresetText(presetTextFor(mode, io, lang)); // (改过的设定词不会被冲掉;无预设 → 写空) + applyPresetTextVisibility(); + }); + hook(wLang, () => { // 换语言 = 同模式同模版按下语言取「存档 → 默认」 + const { mode, lang, io } = surfaceOf(); + setPresetText(presetTextFor(mode, io, lang)); + }); + hook(wIo, () => { // 换模版(文生图/图生图)= 换设定词那一套 + const { mode, lang, io } = surfaceOf(); + setPresetText(presetTextFor(mode, io, lang)); + try { node.__qbrRefreshInfo?.(); } catch (_) {} + try { node.__xbrRefreshInfo?.(); } catch (_) {} + }); + hook(wB, () => { // batch 超出当前类型上限 → 就近钳制 + const sp = latentSpec(surfaceOf().kind); + const cur = parseInt(readWidgetValue(wB), 10) || 1; + if (cur > sp.batchMax) setWidgetValue(wB, sp.batchMax); + else if (cur < 1) setWidgetValue(wB, 1); + }); + + // ── 隐藏字段的幽灵端口清理(V1 没有 socketless,高级字段也可能冒出端口) ── + const killGhost = () => { + try { + for (const nm of ["internal_prompt", "manager_settings", "output_lang", "latent_kind", + "preset_mode", "three_view_text"].concat(XBR_QWEN_WIDGETS)) { + const idx = (node.inputs || []).findIndex((i) => i.name === nm); + if (idx < 0) continue; + if (typeof node.removeInput === "function") node.removeInput(idx); + else { node.inputs.splice(idx, 1); node.inputs.forEach((it, k) => { it.slot = k; }); } + } + node.setDirtyCanvas?.(true, true); + } catch (_) {} + }; + [0, 150, 500, 1200].forEach((d) => setTimeout(killGhost, d)); + + // ── DOM widget(对齐短剧导演台:宽度钉死 + 高度自适应 + 每帧宽度同步) ── + const MIN_W = 420; + let refreshSize = () => {}; // 真实实现稍后赋值(需等 DOM widget 建好) + const widget = node.addDOMWidget?.("xb_image_preset", "XB_IMAGE_PRESET", container, { + serialize: false, + hideOnZoom: false, + }); + // ComfyUI 用 widget.width ?? node.width 算 overlay 宽度:只依赖 node.width, + // 在属性面板/重排时会被算窄 → 视觉上就是「内容与按钮漂移」。 + const syncDomWidth = () => { + try { + const fullW = Number.isFinite(node.size?.[0]) ? node.size[0] : 0; + if (widget && fullW && widget.width !== fullW) widget.width = fullW; + } catch (_) {} + }; + const lockMinWidth = () => { + try { + const w = Number.isFinite(node.size?.[0]) ? node.size[0] : 0; + if (w >= MIN_W) return; + node.setSize?.([MIN_W, Math.max(node.size?.[1] || 240, 240)]); + } catch (_) {} + }; + // 高度常量(提前声明:widget 的布局回调可能在后面才被调用,避免 TDZ) + const TEXT_MIN_H = 300; // 提示词框最小高度(节点再小也不低于它;节点变大则跟随) + const DOM_FIXED_H = 288; // Qwen:按钮区 3 行(102) + 标签(17) + 信息区(130+6) + 内边距/间距(≈33) + const DOM_MIN_H = 583; // Qwen:DOM 区最小高度 = 按钮区 102 + 间距 12 + 标签 17 + 输入框 300 + 信息区 136 + 内边距 16 + if (widget) { + try { delete widget.computeSize; } catch (_) { widget.computeSize = undefined; } + widget.options = widget.options || {}; + widget.options.serialize = false; + // DOM 区的最小高度 = 按钮区 2 行(66) + 标签(17) + 提示词框最小(500) + 内边距/间距(≈28) + widget.options.getMinHeight = () => DOM_MIN_H; + widget.options.getHeight = () => "100%"; + widget.computeLayoutSize = () => ({ minHeight: DOM_MIN_H, maxHeight: undefined, minWidth: MIN_W }); + widget.options.onDraw = () => syncDomWidth(); + widget.options.afterResize = () => syncDomWidth(); + } + node.min_width = MIN_W; + node.minWidth = MIN_W; + node._xbDomWidget = widget; + node._xbSyncWidth = syncDomWidth; // 供 canvas.onDrawForeground 每帧调用 + + // 尺寸刷新 —— 照抄短剧导演台 `refreshSize`: + // ① 用 LiteGraph 自己的 computeSize() 算高度(不读任何 DOM 尺寸 → 无反馈环) + // ② 只做「下限保护」:未设置 / 小于下限时才 setSize,绝不覆盖用户拖拽出来的高度 + // ③ 只在「初始化(3 次) + 工作流加载(configure)」调用,打字 / 面板保存 / onResize 一律不调 + // ⚠️ 这就是「整个界面卡顿」的隔离带:一旦在打字或 onResize 里调它, + // setSize → onResize → setSize 会形成抖动环,每帧整画布重绘 → 全界面卡。 + // 下限 = 标题 + 各可见 widget 实际高度(多行 STRING 会更高)+ DOM 固定行 + 提示词框最小高度。 + // 这样 4 个或 8 个表面参数(三视图预设句框显/隐)都自适应,不用手写魔数。 + const widgetRowH = (w) => { + try { + const s = w && w.computeSize ? w.computeSize(node.size?.[0] || 420) : null; + if (Array.isArray(s) && Number.isFinite(s[1]) && s[1] > 0) return s[1]; + } catch (_) {} + return (w && (w.type === "hidden" || w.hidden)) ? 0 : 26; + }; + const minNodeHeight = () => { + let h = 30; // 节点标题 + for (const w of (node.widgets || [])) h += widgetRowH(w); + return Math.max(420, Math.round(h + DOM_FIXED_H + TEXT_MIN_H)); + }; + refreshSize = () => { + try { + lockMinWidth(); + syncDomWidth(); + const size = node.computeSize?.(); + const computed = (Array.isArray(size) && Number.isFinite(size[1])) ? size[1] : 0; + const need = Math.max(computed, minNodeHeight()); // 取「原生计算」与「DOM 需求」的较大值 + if (!node.size || !Number.isFinite(node.size[1]) || node.size[1] < need) { + node.setSize?.([node.size?.[0] || MIN_W, need]); + } + } catch (_) {} + node.setDirtyCanvas?.(true, true); + }; + refreshSize(); + requestAnimationFrame(refreshSize); + setTimeout(refreshSize, 60); + setTimeout(refreshSize, 1200); // 兜底:加载工作流后 widget 才铺完时再校正一次(只增不减) + node._xbRefreshSize = refreshSize; + node._xbEl = container; // 调试/自动化定位用 + + // Nodes 2.0:面板里的提示词框不会自己长高(节点高度由 DOM 内容主导,flex:1 停在 min-height) + // → 默认 300px;用户拖节点尺寸手柄时按指针位移实时跟随(两个方向都跟手); + // 切回经典模式自动还原原样,交回原有 flex 布局。 + try { + node._xbUninstallBoxResize = installNodes2BoxResize(node, promptBox, { + min: TEXT_MIN_H, max: 4000, deflt: TEXT_MIN_H, + }); + } catch (e) { console.warn("[XB-生图提示词预设] 2.0 高度跟随安装失败", e); } + + // 节点 resize:立即同步 overlay 宽度 + 重绘;防抖后只校正「最小宽度 / 最小高度」。 + // ⚠️ 高度下限是硬约束(输入框永远 ≥500px):拖小节点时一次性拉回, + // 拉回后尺寸已等于下限 → 不会再次触发 setSize,不会形成抖动环。 + let _xbResizeTimer = null; + let _xbClamping = false; + const prevOnResize = node.onResize; + node.onResize = function (...args) { + const rr = prevOnResize?.apply(this, args); + try { + syncDomWidth(); + if (!_xbClamping) { + const need = minNodeHeight(); + const cur = Number.isFinite(node.size?.[1]) ? node.size[1] : 0; + if (cur < need) { + _xbClamping = true; + try { node.setSize?.([node.size?.[0] || MIN_W, need]); } finally { _xbClamping = false; } + } + } + node.setDirtyCanvas?.(true, true); + clearTimeout(_xbResizeTimer); + _xbResizeTimer = setTimeout(() => { lockMinWidth(); syncDomWidth(); node.setDirtyCanvas?.(true, true); }, 200); + } catch (_) {} + return rr; + }; + + // 每帧绘制前景时同步本节点 overlay 宽度(对齐短剧导演台的 canvas.onDrawForeground patch): + // 属性面板/任何画布重排触发重绘后,下一帧即把 widget.width 纠正为节点宽度,杜绝挤压漂移 + try { + const cv = node.graph?.canvas ?? window.app?.canvas; + if (cv && !cv.__xbDomWidthPatch) { + cv.__xbDomWidthPatch = true; + const prevDraw = cv.onDrawForeground; + cv.onDrawForeground = function (ctx) { + const r = prevDraw?.apply(this, arguments); + try { + const g = window.app?.graph; + const ns = g ? (g._nodes || g.nodes || []) : []; + for (const n of ns) if (n && n._xbDomWidget && n._xbSyncWidth) n._xbSyncWidth(); + } catch (_) {} + return r; + }; + } + } catch (_) {} + + // ── 旧版配置一次性迁移:老工作流把语言/模式/预设句/通道存在 manager_settings.output 里, + // 现在这四项都是节点表面参数了 → 迁到 widget 上,并把 output 从 JSON 里删掉(只迁一次)。 + const migrateLegacyOutput = () => { + try { + const raw = readWidgetValue(findWidget(node, "manager_settings")); + if (!raw || typeof raw !== "string" || !raw.trim()) return; + let data = null; + try { data = JSON.parse(raw); } catch (_) { return; } + if (!data || typeof data !== "object") return; + const out = data.output; + const untouched = pickOpt(LANGS, readWidgetValue(wLang), LANG_ZH) === LANG_ZH + && pickOpt(MODES, readWidgetValue(wMode), MODE_TP) === MODE_TP + && String(readWidgetValue(w3v) ?? "").trim() === THREE_VIEW_DEFAULT[LANG_ZH]; + if (out && typeof out === "object" && untouched) { + if (LANGS.includes(out.lang)) setWidgetValue(wLang, out.lang); + if (MODES.includes(out.preset_mode)) setWidgetValue(wMode, out.preset_mode); + if (typeof out.three_view_text === "string" && out.three_view_text.trim()) setWidgetValue(w3v, out.three_view_text); + if (String(out.latent_channels || "").startsWith("16")) setWidgetValue(wKind, LATENT_KINDS[1]); + notify("已把旧版「输出设置」里的语言/模式/预设句/空latent类型迁到节点表面参数", "success"); + } + if (out !== undefined) { + delete data.output; + setWidgetValue(findWidget(node, "manager_settings"), JSON.stringify(data)); + } + } catch (_) {} + }; + + // ── 载入 / 执行后的对齐 ── + const boot = () => { + hideInternal(); // 工作流加载后 widget 可能被重建 → 重新藏 + migrateLegacyOutput(); + syncPromptBoxFromWidget(); + applyKindLimits(); // 按当前空latent类型同步 step / 上下限 + applyPresetTextVisibility(); + // 设定词:以「用户存档 → 当前模版默认」为准 + // · 空 → 当前模版默认(无预设 → 默认就是空 → 保持空); + // · 等于别的模版 / 语言的默认 / 任一内置预设句(老工作流存的是另一套,或节点老默认句)→ 也换成当前模版默认; + // · 用户改过的 → 一个字不动。 + { + const m = surfaceOf().mode, l = surfaceOf().lang, iom = surfaceOf().io; + const nowTxt = presetTextOf(m, l, iom); + const cur = String(readWidgetValue(w3v) ?? "").trim(); + if (!cur) { if (nowTxt) setPresetText(presetTextFor(m, iom, l)); } + else if (cur !== nowTxt && allDefaultTextsOf(m).includes(cur)) setPresetText(presetTextFor(m, iom, l)); + } + const ar = String(readWidgetValue(wAR) ?? "Free"); + const [nw, nh] = normalizeSize(ar, readWidgetValue(wW), readWidgetValue(wH), sizeOpt()); + if (nw !== parseInt(readWidgetValue(wW), 10) || nh !== parseInt(readWidgetValue(wH), 10)) { + syncing = true; + try { setWidgetValue(wW, nw); setWidgetValue(wH, nh); } finally { syncing = false; } + } + refreshSize(); + }; + node._xbEl = container; // 调试 / 自动化定位锚点 + // 已废弃端口(ref_images / video_frames):早期版本建过的节点在旧工作流里会残留空槽,没连线就移除 + // (工作流恢复 inputs 可能晚于面板初始化 → 立刻做一次 + 稍后再做一次) + const xbrDropDeadInputs = () => { + for (const dead of ["ref_images", "video_frames"]) { + const di = (node.inputs || []).findIndex((i) => i.name === dead && i.link == null); + if (di >= 0) { try { node.removeInput(di); } catch (_) {} } + } + }; + xbrDropDeadInputs(); + setTimeout(xbrDropDeadInputs, 400); + setTimeout(xbrDropDeadInputs, 1500); + node.__ippSyncPromptBox = syncPromptBoxFromWidget; + // 供 Pro 弹窗(✨ 增强预设)读写设定词:取「存档 → 默认」/ 写 widget / 存进节点 + node.__ippPresetTextFor = presetTextFor; + node.__ippSetPreset = setPresetText; + node.__ippWritePreset = writePresetStore; + setTimeout(boot, 0); + setTimeout(boot, 200); + + const prevConfigure = node.onConfigure; + node.onConfigure = function (...args) { + const r = prevConfigure?.apply(this, args); + setTimeout(boot, 60); + setTimeout(killGhost, 200); + return r; + }; + + const prevRemoved = node.onRemoved; + node.onRemoved = function (...args) { + closePanel(); + return prevRemoved?.apply(this, args); + }; +} + +/* ============================================================================ + * 🖼️ Qwen2.1提示词预设(融入「🖼️ 生图提示词预设」的同一个面板) + * --------------------------------------------------------------------------- + * 节点表面新增:2 个按钮(🤖 LLM设置 | ✨ 预设参数) + * + 提示词框标题行(右侧「📋 复制」)+ 参数设定显示(只读摘要 5 行) + * + 「📝 提示词」端口接线 → 提示词框锁定(二选一) + * 弹窗:导演台同款二级弹窗(底部:自动保存 + 字号 + 界面缩放 + 取消/💾 保存) + * 配置:manager_settings.qwen = { run.seed_control, extra_settings }(Generate Text 的全量参数走节点 widget,由弹窗读写) + * 说明:本块所有标识符统一 xbr/XBR 前缀,避免与生图预设面板既有函数/常量重名。 + * ========================================================================== */ + +const XBR_SEED_MODES = ["randomize", "fixed", "increment", "decrement"];const XBR_SEED_LABEL = { randomize: "随机", fixed: "固定", increment: "增加", decrement: "减少" }; +/** 模版三态:自动 = 按有没有接参考图判断;文生图 / 图生图 = 手动指定 */ +const XBR_IO_MODES = ["自动", "文生图", "图生图"]; +const XBR_SAMPLING_MODES = ["on", "off"]; +const XBR_MTP_MODES = ["auto", "off", "2", "3", "4", "5"]; +const XBR_PANEL_BUTTONS = [ + { id: "llm", label: "🤖 LLM设置", title: "🤖 LLM设置", subtitle: "模型选择 = 官方 Load CLIP;参数设置 = 官方 Generate Text 全量参数。执行节点即生成,没有开关。" }, + { id: "preset", label: "✨ 预设参数", title: "✨ 预设参数", subtitle: "输出语言 / 空latent类型 / 预设模式 / 设定词 / SKILL。提示词正文在节点上的提示词框里编辑。" }, +]; +/** 全部收进弹窗的节点 widget(节点表面只留画幅参数 / 生成数量) */ +const XBR_QWEN_WIDGETS = [ + "clip_name", "clip_type", "device", + "max_length", "sampling_mode", "temperature", "top_k", "top_p", "min_p", + "repetition_penalty", "presence_penalty", "seed", "control_after_generate", + "thinking", "use_default_template", "mtp", "skill_name", "skill_mode", "io_mode", +]; +/** 种子控制 widget 名(ComfyUI 见到名为 seed 的 widget 会自动配一个 control_after_generate)*/ +const XBR_SEED_CTL_WIDGET = "control_after_generate"; +/** SKILL 三态:自动 = 按预设模式适配;手动 = 用选择的那一个;不用 = 完全不生效 */ +const XBR_SKILL_MODES = ["自动", "手动", "不用"]; + +/* ── 数值 / 解析助手 ─────────────────────────────────────── */ +function xbrNum(v, d, lo, hi) { + let x = Number(v); + if (!Number.isFinite(x)) x = d; + if (lo !== undefined) x = Math.max(lo, x); + if (hi !== undefined) x = Math.min(hi, x); + return x; +} +function xbrInt(v, d, lo, hi) { return Math.round(xbrNum(v, d, lo, hi)); } +function xbrFlag(v, d) { + if (v === undefined || v === null || v === "") return d; + return (typeof v === "string") ? ["1", "true", "yes", "on", "开启"].includes(v.trim().toLowerCase()) : !!v; +} +function xbrPick(v, allowed, d) { return allowed.includes(v) ? v : d; } + +/** 本节点配置默认值(manager_settings.qwen:只放不在节点 widget 上的东西) */ +function xbrQwenDefaults() { + return { + extra_settings: "", + }; +} +/** 解析 manager_settings.qwen(缺失 / 非法一律安全回落) */ +function xbrParseQwen(raw) { + const d = (raw && typeof raw === "object") ? raw : {}; + return { extra_settings: String(d.extra_settings ?? "") }; +} + +/* ── 节点 widget 读写 ─────────────────────────────────────── */ +function xbrWidget(node, name) { return (node?.widgets || []).find((w) => w?.name === name); } +function xbrWidgetVal(node, name) { return readWidgetValue(xbrWidget(node, name)); } +/** fire=true 时同步触发该 widget 原有回调(基础面板靠回调做步长/预设句框/尺寸联动) */ +function xbrSetWidget(node, name, val, fire) { + const w = xbrWidget(node, name); + setWidgetValue(w, val); + if (fire) { try { w?.callback?.(val); } catch (_) {} } +} +function xbrHint(text) { return el("div", "font-size:11px;color:#888;line-height:1.6;margin:2px 0 8px;", text); } +function xbrWidgetOptions(node, name) { + const w = xbrWidget(node, name); + const o = w?.options?.values ?? w?.options; + return Array.isArray(o) ? o.slice() : []; +} +function xbrCurJson(node) { + try { + const raw = readWidgetValue(findWidget(node, "manager_settings")); + const d = JSON.parse(raw || "{}"); + return (d && typeof d === "object") ? d : {}; + } catch (_) { return {}; } +} +function xbrSaveJson(node, patch) { + const cur = xbrCurJson(node); + Object.assign(cur, patch || {}); + persistSettings(node, cur); + return cur; +} + +/* ── 预设设定词(三视图 / 四视图 / 五视图 / 背景纯透明的设定文本)───────────── + * · 用户改过的按「模式|模版|语言」存进节点(基础面板的 preset_texts 存档); + * · 弹窗读「存档 → 当前模版默认」,提交时同步写 widget + 存档。 */ +function xbrPresetStore(node) { + try { return parsePresetTexts(xbrCurJson(node).preset_texts); } catch (_) { return {}; } +} +function xbrPresetTextFor(node, mode, lang, io) { + const key = presetKeyOf(mode, io, lang); + const t = xbrPresetStore(node)[key]; + // 存档里的值等于任一默认 / 旧条款 → 视为「没改过」 → 用当前模版的默认(无预设 = 空) + if (t && String(t).trim() && !isDefaultPresetText(mode, t)) return t; + return (presetTextOf(mode, lang, io) || ""); +} +/** 当前模版(文生图 / 图生图):自动 → 按「🖼️ 图像」有没有接线判断 */ +function xbrResolveIo(node, ioRaw) { + const has = (typeof modeHasImage === "function") && modeHasImage(node); + return (typeof resolveIoMode === "function") ? resolveIoMode(ioRaw, has) : "文生图"; +} + +/* ── 本节点不做在线 API / 本地 llama-cpp:模型走官方 Load CLIP,参数走官方 Generate Text ── */ + +/* ── 控件:数字 / 密码 / 单选行 / 种子行 ─────────────────── */ +function xbrNumberControl(value, opts, onChange) { + const i = el("input", BOX_CSS + "min-width:0;"); + i.type = "number"; + i.value = value ?? 0; + if (opts) { + if (opts.min !== undefined) i.min = opts.min; + if (opts.max !== undefined) i.max = opts.max; + i.step = opts.step ?? 1; + } + i.addEventListener("change", () => { + let v = parseFloat(i.value); + if (!Number.isFinite(v)) v = opts?.min ?? 0; + if (opts?.min !== undefined) v = Math.max(opts.min, v); + if (opts?.max !== undefined) v = Math.min(opts.max, v); + i.value = v; + onChange(v); + }); + return i; +} +function xbrPasswordControl(value, placeholder, onChange) { + const i = el("input", BOX_CSS + "min-width:0;"); + i.type = "password"; + i.value = value ?? ""; + if (placeholder) i.placeholder = placeholder; + i.autocomplete = "new-password"; + i.spellcheck = false; + i.addEventListener("change", () => onChange(i.value.trim())); + return i; +} +function xbrTextControl(value, placeholder, onChange) { + const i = el("input", BOX_CSS + "min-width:0;"); + i.type = "text"; + i.value = value ?? ""; + if (placeholder) i.placeholder = placeholder; + i.spellcheck = false; + i.addEventListener("change", () => onChange(i.value.trim())); + return i; +} +/** 导演台同款「种子 + 生成后控制(随机/固定/增加/减少)」一行 */ +function xbrSeedRow(run, onChange) { + const g = el("div", "display:flex;align-items:center;gap:10px;margin-bottom:10px;"); + g.append(el("label", "flex:0 0 160px;font-size:13px;color:#999;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;", "种子")); + const i = xbrNumberControl(run.seed, { min: 0, max: Number.MAX_SAFE_INTEGER, step: 1 }, (v) => { run.seed = Math.round(v); onChange?.(); }); + i.style.flex = "1 1 0"; + const ctrl = smallBtn("", "flex:1 1 0;min-width:0;background:#b45309;border:1px solid #d97706;color:#fff;text-align:center;", "生成后控制:点击在 随机 / 固定 / 增加 / 减少 间切换", null); + const cur = () => (XBR_SEED_LABEL[run.seed_control] ? run.seed_control : "randomize"); + const render = () => { + ctrl.textContent = "🔁 " + XBR_SEED_LABEL[cur()]; + ctrl.title = "生成后控制:随机 = 每次运行后自动换新种子;固定 = 锁定当前值;增加/减少 = 每次运行后 ±1。点击切换"; + }; + ctrl.addEventListener("click", () => { + run.seed_control = XBR_SEED_MODES[(XBR_SEED_MODES.indexOf(cur()) + 1) % XBR_SEED_MODES.length]; + render(); + onChange?.(); + }); + render(); + g.append(i, ctrl); + return g; +} + +/* ── 弹窗外壳(与生图预设面板同风格;底部:自动保存 + 字号 + 界面缩放)── */ +let xbrModal = null; +function xbrDialog({ label, title, subtitle, width = 820, autoSaveRef = null, onCommit = null, onImmediate = null, onChange = null, onClose = null }) { + const overlay = el("div", "position:fixed;inset:0;background:rgba(0,0,0,.78);z-index:10300;display:flex;align-items:center;justify-content:center;overflow:auto;"); + const dialog = el("section", `background:#1c1c1e;border:1px solid #333;border-radius:8px;width:${width}px;max-width:94vw;` + + "max-height:90vh;display:flex;flex-direction:column;box-shadow:0 20px 40px rgba(0,0,0,.6);flex:0 0 auto;color:#ddd;font-size:13px;"); + dialog.dataset.xbrProModal = label; + dialog.setAttribute("role", "dialog"); + dialog.setAttribute("aria-modal", "true"); + dialog.setAttribute("aria-label", label); + + const header = el("div", "padding:18px 20px;border-bottom:1px solid #444;"); + header.append(el("div", "font-size:17px;font-weight:700;color:#eee;", title)); + if (subtitle) header.append(el("div", "font-size:12px;color:#999;margin-top:5px;line-height:1.6;", subtitle)); + const body = el("div", "padding:16px 20px;overflow-y:auto;flex:1;min-height:200px;"); + const error = el("div", "color:#e55;font-size:12px;margin:0 20px;min-height:14px;"); + const footer = el("div", "padding:12px 20px;border-top:1px solid #444;display:flex;justify-content:flex-end;gap:10px;align-items:center;background:#18181a;border-bottom-left-radius:8px;border-bottom-right-radius:8px;"); + const left = el("div", "display:flex;align-items:center;gap:16px;margin-right:auto;flex-wrap:wrap;"); + + let autoCb = null; + if (autoSaveRef) { + const autoRow = el("label", "display:flex;align-items:center;gap:6px;font-size:13px;color:#999;cursor:pointer;user-select:none;"); + autoCb = el("input", "width:18px;height:18px;accent-color:#f59e0b;cursor:pointer;"); + autoCb.type = "checkbox"; + autoCb.checked = autoSaveRef.value !== false; + autoCb.title = "开启后:弹窗内改动会自动保存(防抖);关闭后需手动点「💾 保存」"; + autoCb.addEventListener("change", () => { autoSaveRef.value = autoCb.checked; try { onImmediate?.(); } catch (_) {} }); + autoRow.append(autoCb, el("span", "", "自动保存")); + left.append(autoRow); + } + const zoomRow = buildZoomRow(dialog); + zoomRow.title = "调整面板文本编辑字号(与本节点其它面板共享记忆)"; + const uiRow = buildUIRow(dialog); + uiRow.title = "调整整个弹窗界面大小(不缩放网页/画布;Ctrl/⌘+滚轮 也可)"; + left.append(zoomRow, uiRow); + + const cancelBtn = el("button", "background:transparent;border:1px solid #555;color:#fff;border-radius:4px;padding:8px 20px;font-size:14px;cursor:pointer;font-family:inherit;", "取消"); + const saveBtn = onCommit ? el("button", "background:#2d5a88;color:#fff;border:none;border-radius:4px;padding:8px 20px;font-size:14px;font-weight:600;cursor:pointer;font-family:inherit;", "💾 保存") : null; + footer.append(left, cancelBtn); + if (saveBtn) footer.append(saveBtn); + dialog.append(header, body, error, footer); + overlay.append(dialog); + document.body.append(overlay); + zoomApplyContainer(dialog); + uiApply(dialog); + bindWheelZoom(dialog); + + let timer = null; + const close = () => { + clearTimeout(timer); + try { overlay.remove(); } catch (_) {} + if (xbrModal && xbrModal.overlay === overlay) xbrModal = null; + try { onClose?.(); } catch (_) {} + }; + const onKey = (e) => { if (e.key === "Escape") { e.preventDefault(); close(); } }; + document.addEventListener("keydown", onKey, true); + cancelBtn.addEventListener("click", close); + overlay.addEventListener("mousedown", (e) => { if (e.target === overlay) close(); }); + if (saveBtn) { + saveBtn.addEventListener("click", async () => { + if (saveBtn.disabled) return; + saveBtn.disabled = true; + saveBtn.textContent = "保存中…"; + error.textContent = ""; + try { await onCommit(); close(); } + catch (e) { error.textContent = "保存失败:" + ((e && e.message) || e); saveBtn.disabled = false; saveBtn.textContent = "💾 保存"; } + }); + } + // 自动保存(导演台同款:改动后防抖提交) + body.addEventListener("change", () => { + try { onChange?.(); } catch (_) {} + if (autoSaveRef && autoSaveRef.value === false) { try { onImmediate?.(); } catch (_) {} return; } + clearTimeout(timer); + timer = setTimeout(() => { try { onCommit?.(); } catch (_) {} }, 800); + }); + const shell = { overlay, dialog, body, error, close }; + xbrModal = shell; + return shell; +} + +/* ── 弹窗内容:🤖 大语言模型配置 ─────────────────────────── */ +/* ── 弹窗内容:🤖 LLM设置(官方 Load CLIP + 官方 Generate Text)─────── */ +function xbrRenderLlm(body, node, draft, ctx) { + const s = draft.settings.qwen; + + body.append(makeSectionTitle("模型选择(官方 Load CLIP)")); + const clips = xbrWidgetOptions(node, "clip_name"); + if (clips.length) body.append(field("模型", selectControl(clips, draft.clip_name, (v) => { draft.clip_name = v; }))); + else body.append(field("模型", xbrTextControl(draft.clip_name, "models/text_encoders 里没有模型", (v) => { draft.clip_name = v; }))); + body.append(field("类型", selectControl(xbrWidgetOptions(node, "clip_type"), draft.clip_type, (v) => { draft.clip_type = v; }))); + body.append(field("设备", selectControl(xbrWidgetOptions(node, "device"), draft.device, (v) => { draft.device = v; }))); + body.append(xbrHint("与官方 Load CLIP 完全一致:模型来自 ComfyUI/models/text_encoders;Qwen-Image 系文本编码器请选 类型 = qwen_image。")); + + body.append(makeSectionTitle("参数设置(官方 Generate Text 全量参数)")); + const t = draft.text; + body.append(field("最大长度 max_length", xbrNumberControl(t.max_length, { min: 1, max: 32768, step: 1 }, (v) => { t.max_length = Math.round(v); }))); + body.append(field("采样模式 sampling_mode", selectControl(XBR_SAMPLING_MODES, t.sampling_mode, (v) => { t.sampling_mode = v; ctx.rerender(); }))); + if (t.sampling_mode === "on") { + body.append(field("temperature", xbrNumberControl(t.temperature, { min: 0.01, max: 2.0, step: 0.000001 }, (v) => { t.temperature = v; }))); + body.append(field("top_k", xbrNumberControl(t.top_k, { min: 0, max: 1000, step: 1 }, (v) => { t.top_k = Math.round(v); }))); + body.append(field("top_p", xbrNumberControl(t.top_p, { min: 0, max: 1, step: 0.01 }, (v) => { t.top_p = v; }))); + body.append(field("min_p", xbrNumberControl(t.min_p, { min: 0, max: 1, step: 0.01 }, (v) => { t.min_p = v; }))); + body.append(field("repetition_penalty", xbrNumberControl(t.repetition_penalty, { min: 0, max: 5, step: 0.01 }, (v) => { t.repetition_penalty = v; }))); + body.append(field("presence_penalty", xbrNumberControl(t.presence_penalty, { min: 0, max: 5, step: 0.01 }, (v) => { t.presence_penalty = v; }))); + const holder = { seed: t.seed, seed_control: xbrPick(t.ctl, XBR_SEED_MODES, "randomize") }; + body.append(xbrSeedRow(holder, () => { t.seed = holder.seed; t.ctl = holder.seed_control; })); + body.append(xbrHint("点「🔁」切换生成后控制:随机 = 运行后自动换新种子;固定 = 锁定;增加 / 减少 = 运行后 ±1。" + + "这里与 ComfyUI 原生的种子控制是同一个值,两处改哪个都一样。")); + } else { + body.append(xbrHint("采样模式 = off:贪心解码,下面的 temperature / top_k / top_p / min_p / 惩罚项 / 种子都不参与。")); + } + body.append(field("思考模式 thinking", checkboxControl(t.thinking, "模型支持时以思考模式生成;思考内容不会进入输出", (v) => { t.thinking = v; }))); + body.append(field("使用内置模板 use_default_template", checkboxControl(t.use_default_template, "使用模型自带的系统提示词 / 对话模板", (v) => { t.use_default_template = v; }))); + body.append(field("mtp", selectControl(XBR_MTP_MODES, t.mtp, (v) => { t.mtp = v; }))); + body.append(xbrHint("mtp:多 token 预测投机解码,没有 MTP 权重时无效;与官方 Generate Text 同义。")); + body.append(el("div", "font-size:11px;color:#888;line-height:1.6;margin:2px 0 8px;", "提示词正文在节点上的提示词框里编辑;「📝 提示词」端口接线后该框锁定。")); +} + +/* ── 弹窗内容:✨ 预设参数(生图预设 + 设定词 + SKILL)───────────── */ +function xbrRenderPreset(body, node, draft, ctx) { + // 注:原「追加设定(可留空)」已按用户要求删除(不再往系统提示词里拼【额外要求】) + body.append(makeSectionTitle("生图预设")); + body.append(field("输出语言", selectControl(LANGS, draft.lang, (v) => { + draft.lang = v; + // 换语言 → 未改过设定词时取该语言的「存档 → 默认」(同一模版) + if (!draft.presetTouched) draft.presetText = xbrPresetTextFor(node, draft.mode, v, xbrResolveIo(node, draft.ioMode)); + ctx.rerender(); + }))); + body.append(xbrHint("输出语言决定:词表与设定词按哪种语言加载、生成文本是中文还是英文。")); + body.append(field("空latent类型", selectControl(LATENT_KINDS, draft.kind, (v) => { draft.kind = v; }))); + body.append(xbrHint("空latent类型:选你正在用的模型即可(形状 / 下采样 / 尺寸步长自动适配 8/16/32)。")); + // ── 模版(文生图 / 图生图):决定用哪一套设定词(每个预设模式都有两套)── + body.append(field("模版", radioRow(XBR_IO_MODES, draft.ioMode, (v) => { + draft.ioMode = v; + // 换模版 → 未改过时立即换成对应那套设定词,改过的按「模式|模版|语言」存着不会被冲掉 + if (!draft.presetTouched) draft.presetText = xbrPresetTextFor(node, draft.mode, draft.lang, xbrResolveIo(node, v)); + ctx.rerender(); + }))); + body.append(xbrHint("模版:自动 = 按「🖼️ 图像」有没有接线判断;文生图 / 图生图 = 手动指定。每个预设模式都有两套设定词,切模版会直接换掉下面「设定词」那一栏的内容。")); + body.append(field("预设模式", selectControl(MODES, draft.mode, (v) => { + draft.mode = v; + // 换模式 → 立刻取该「模式 × 模版」的设定词(用户改过的存档 → 默认),改过的不会被冲掉 + draft.presetTouched = false; + draft.presetText = xbrPresetTextFor(node, v, draft.lang, xbrResolveIo(node, draft.ioMode)); + ctx.rerender(); // 有/无设定词的模式之间切换 → 重画「设定词」区 + }))); + // 当前模版 + 本档提示 + const needsImg = (typeof modeNeedsImage === "function") && modeNeedsImage(draft.mode); + const hasImgNow = (typeof modeHasImage === "function") && modeHasImage(node); + const ioNow = xbrResolveIo(node, draft.ioMode); + body.append(el("div", "font-size:11px;color:#8fb;line-height:1.7;margin:2px 0 4px;", + "当前模版:" + ioNow + + (draft.ioMode === "自动" ? ("(自动:" + (hasImgNow ? "检测到 🖼️ 图像已接线" : "未接 🖼️ 图像") + ")") + : "(手动指定)") + + (needsImg ? " ❗本档属于图生图档" : ""))); + body.append(el("div", "font-size:11px;color:" + (needsImg ? "#d9a441" : "#888") + ";line-height:1.7;margin:2px 0 8px;", + (draft.mode === MODE_TP) + ? "无预设:接不接参考图、哪个模版,都不会前置设定词(下面「设定词」栏保持空白;想加就自己写)。" + : (needsImg + ? (ioNow === "文生图" + ? "⚠️ 本档属于「图生图」,但当前是文生图模版:下面用的是从零生成的写法(不依赖任何输入图)。接图或把模版改成图生图即可切回去。" + : "本档属于「图生图」:设定词按输入图改写场景,需接参考图(🖼️ 图像)。") + : (ioNow === "图生图" + ? "本档属于「文生图」,当前用图生图模版:设定词末尾会追加「以输入图为准」的条款,需接参考图。" + : "本档属于「文生图」:不接图也行;接图后把模版改成「图生图」(或保持自动)会追加「以输入图为准」的条款。")))); + + // ── 设定词(按「模式 × 模版 × 语言」取;只在弹窗里编辑,节点表面不显示)── + // 无预设档:这一栏**照样显示**,只是内容空白(用户可以自己写) + const modeDef = presetTextOf(draft.mode, draft.lang, ioNow); + { + body.append(makeSectionTitle("设定词(" + ioNow + "模版)")); + draft.presetText = String(draft.presetText || ""); + if (modeDef && !draft.presetText.trim()) draft.presetText = modeDef; + const taP = textareaControl(draft.presetText, (v) => { draft.presetText = v; draft.presetTouched = true; }, + "width:100%;box-sizing:border-box;min-height:180px;resize:vertical;"); + taP.placeholder = modeDef + ? "该模式在「" + ioNow + "」模版下的设定词;改过的按「模式 + 模版 + 语言」记进节点,换模式 / 换模版 / 换语言都不丢" + : "无预设:本档不前置设定词(保持空白即可;在这里写内容就会作为设定词置顶)"; + taP.spellcheck = false; + body.append(taP); + const taRow = el("div", "display:flex;align-items:center;gap:10px;margin:6px 0 4px;"); + if (modeDef) { + taRow.append(smallBtn("♻️ 恢复默认", "border:1px solid #555;background:#2a2a2a;color:#ccc;padding:4px 10px;", + "把设定词恢复为该模式 × 该模版 × 该语言的默认文本", + () => { draft.presetText = modeDef; draft.presetTouched = false; ctx.rerender(); })); + } + taRow.append(el("span", "font-size:11px;color:#888;", + !modeDef ? (draft.presetText.trim() ? "当前 = 自定义" : "当前 = 空白(不前置设定词)") + : (draft.presetText === modeDef ? "当前 = 默认" : "当前 = 自定义"))); + body.append(taRow); + if (modeDef) { + body.append(el("div", "font-size:11px;color:#888;line-height:1.75;margin:6px 0 8px;", + "· 设定词会作为【参考设定】送给文本编码器;\n" + + "· 最终输出时,设定词由节点原封不动加在生成结果的最顶端;\n" + + "· 改过的设定词随节点保存,换模式 / 换模版 / 换语言都会取回你改过的那一版。")); + } else { + body.append(el("div", "font-size:11px;color:#888;line-height:1.75;margin:6px 0 8px;", + "· 无预设档:不管接不接参考图,节点都不会自动前置设定词;\n" + + "· 如果在这一栏写了内容,它会作为【参考设定】送模型、并原封不动置于最终提示词最顶端。")); + } + } + + body.append(makeSectionTitle("SKILL(系统提示词)")); + const skillOpts = xbrWidgetOptions(node, "skill_name"); + if (!skillOpts.length) skillOpts.push("不使用"); + if (!skillOpts.includes(draft.skill)) draft.skill = skillOpts[0]; + if (!XBR_SKILL_MODES.includes(draft.skillMode)) draft.skillMode = "自动"; + body.append(field("SKILL 模式", radioRow(XBR_SKILL_MODES, draft.skillMode, (v) => { draft.skillMode = v; ctx.rerender(); }))); + if (draft.skillMode === "手动") { + body.append(field("SKILL选择", selectControl(skillOpts, draft.skill, (v) => { draft.skill = v; }))); + } else if (draft.skillMode === "自动") { + const autoFile = (typeof modeSkillHint === "function") ? modeSkillHint(draft.mode) : "-"; + body.append(el("div", "font-size:11px;color:#8fb;margin:2px 0 8px;", + "自动:本档(" + draft.mode + ")→ " + autoFile)); + } else { + body.append(el("div", "font-size:11px;color:#d9a441;margin:2px 0 8px;", + "不用:SKILL 完全不生效(即使下面选过文件也不会送进模型)。")); + } + const foxRow = el("div", "display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:2px 0 8px;"); + foxRow.append(smallBtn("📂 打开 SKILL 文件夹", "border:1px solid #555;background:#2a2a2a;color:#ccc;padding:4px 10px;", + "在资源管理器里打开 support_llama/skills,方便新增 / 编辑技能文件", + async () => { + try { + const r = await fetch("/xb_toolbox/skill_folder"); + const j = await r.json().catch(() => ({})); + if (!r.ok || j.ok === false) notify("打开失败:" + (j.info || r.status), "error"); + else notify("已打开:" + (j.path || "skills"), "success"); + } catch (e) { notify("打开失败:" + ((e && e.message) || e), "error"); } + })); + body.append(foxRow); + body.append(xbrHint("技能文件放在 XB_ToolBox/support_llama/skills(.txt / .md):选中后整段作为模型自带系统提示词的替换内容(= 官方 Generate Text 的 system_prompt)。")); + body.append(el("div", "font-size:11px;color:#888;line-height:1.75;margin:6px 0 8px;", + "· 自动(默认)= 按预设模式适配:图生图档 → system_prompt_edit,其余 → system_prompt_t2i;\n" + + "· 手动 = 用「SKILL选择」里选中的那个文件;\n" + + "· 不用 = 完全不生效(哪怕选了文件);\n" + + "· 「🤖 LLM设置 → 使用内置模板」关闭时,SKILL 不生效(与官方同义);\n" + + "· 新增技能文件后刷新页面即可出现在下拉里。")); +} + +/* ── 弹窗内容:📖 使用说明 ───────────────────────────────── */ +function xbrRenderHelp(body) { + const lines = [ + "【一句话】", + " 生图提示词预设 + 官方 Load CLIP + 官方 Generate Text:", + " 表面挑词拼正文 → 文本编码器把正文写成完整提示词 → 设定词置顶输出", + "", + "【节点做什么(执行顺序)】", + " 1. 正文 = 提示词框(或外接 📝 提示词 端口)的字", + " 2. 送模型 = 【参考设定·本档设定词】+【正文】", + " (选中 SKILL 时,SKILL 全文另作为系统提示词送到最前面)", + " 3. 输出提示词 = 本档设定词原封不动置顶 + 模型生成的正文", + " 4. 输出空latent = 按空latent类型/画幅参数生成的空 latent", + "", + "【三步上手 · 文生图】", + " 1. 🤖 LLM设置 选模型(类型 qwen_image,设备 default)", + " 2. ✨ 预设参数 选输出语言 + 空latent类型 + 预设模式", + " (文生图组)+ SKILL 模式(保持自动即可)", + " 3. 📊 空latent 接采样器、📝 提示词 接文本编码器,点运行", + "", + "【三步上手 · 图生图(图像编辑)】", + " 1. ✨ 预设参数 选一个图生图档位(如 保持主体换场景)", + " 2. 参考图接 🖼️ 图像(多张就接多张,按连接顺序 = 第 1…N 张图)", + " 3. SKILL 模式保持「自动」:图生图档会自动用 system_prompt_edit.txt", + " 提示词框里写要求(改哪里 / 换成什么,例:把背景换成草地)", + " · 图生图不要接 📊 空latent:官方编辑链路是", + " 输入图 → VAEEncode → 采样", + " · 提示词输出接文本编码器/编辑节点即可", + "", + "【节点表面】", + " · 第 1 行按钮:🤖 LLM设置 | ✨ 预设参数", + " · 第 2 行按钮:🎨 风格 | 📐 视角 | 👤 主体 | 🕺 姿态 | 👗 装扮 | 🎒 道具 | 💡 光影 | 🏞️ 背景", + " · 画幅比例 / 宽度 / 高度 / 生成数量:按空latent类型的官方步长自动对齐", + " · 提示词框右上角 📋 复制;下面是参数设定显示,实时告诉你当前配置", + " · 没有开关:执行节点就会用文本编码器生成", + "", + "【元素面板怎么用】", + " · 点选项行 = 加入提示词,再点一下 = 移除", + " · 悬停选项行可以看它的中文与英文写法", + " · 【添加详细描述】给该词条写补充说明,会跟着一起成句", + " · ➕ / ➖ 自建专属词条,✎ 编辑名称", + " · 搜索框与「全部加入」只作用于当前子类", + "", + "【🤖 LLM设置】", + " · 模型选择 = 官方 Load CLIP:模型 / 类型 / 设备,枚举与官方节点逐字一致", + " · 参数设置 = 官方 Generate Text 全量参数:max_length | sampling_mode | temperature |", + " top_k | top_p | min_p | repetition_penalty | presence_penalty | seed |", + " thinking | use_default_template | mtp", + " · sampling_mode = off 是贪心解码,采样参数与种子都不参与", + " · 种子支持生成后控制:点 🔁 在 随机 / 固定 / 增加 / 减少 之间切换", + " · 同一套参数只保留一份模型缓存,换模型 / 换类型 / 换设备才重新加载", + "", + "【SKILL 技能(系统提示词)】", + " · 文件放 XB_ToolBox/support_llama/skills(.txt / .md)", + " 面板里点「📂 打开 SKILL 文件夹」可直接打开,新增后刷新页面即出现在下拉", + " · SKILL 模式(常驻三选一,默认自动):", + " ◆ 自动 = 按预设模式适配:图生图档 → system_prompt_edit.txt,其余 → system_prompt_t2i.txt", + " ◆ 手动 = 用「SKILL选择」里选中的那个文件", + " ◆ 不用 = SKILL 完全不生效(哪怕选过文件)", + " · 选中后整段作为模型的系统提示词(= 官方 Generate Text 的 system_prompt)", + " · 选中 SKILL 时不再叠加节点的【输出语言】提示——技能自带语言规则,避免打架", + " · 「使用内置模板」关掉时 SKILL 不生效(与官方同义)", + " · 这两个技能要求输出单行 JSON:正常,见【常见问题】", + "", + "【✨ 预设参数】", + " · 生图预设:输出语言 / 空latent类型 / 预设模式 / 设定词", + " · SKILL 模式(自动 / 手动 / 不用):见上", + " · 设定词:见上(无预设档则不前置)", + "", + "【文生图 / 图生图两套模版(全自动切换)】", + " · 没接 🖼️ 图像 → 走文生图模版:从零生成的写法,不会出现「参考输入图」类条款", + " · 接了 🖼️ 图像 → 走图生图模版:自动追加「以输入图为准」的条款,按序号引用第 1…N 张图", + " · 两种模版不用手动切:面板会显示当前用的是哪套 + 自动追加的条款原文", + " · 图生图档没接图也不会报错:会自动改成从零生成的写法(并在日志里提醒)", + "", + "【预设模式 · 文生图组(不需要图)】", + " · 无预设:不前置任何设定词,只输出正文", + " · 人物三视图 / 人物四视图 / 人物五视图:角色概念设计图", + " · 背景纯透明:RGBA 透明背景", + " · 图文版面:UI 截图 / 文档试卷 / 标题海报(文字逐字、版面层级)", + " · 信息图:多面板科普拆解卡(编号引线 / 图例 / 色卡 / 时间轴)", + " · 多格分镜:N 格叙事插画(格数排布 / 每格必须有主体 / 一致性强约束)", + " · 广告分镜板:商业脚本视觉板(画面格 + 时间轴 + 台词 + 镜头说明)", + "", + "【预设模式 · 图生图组(需接参考图,没接会自动降级为文生图写法)】", + " · 保持主体换场景:商品/人物原貌 100% 保持,换场景换装", + " · 局部编辑:只改指定或标记区域,其余完全不变", + " · 老照片修复:去噪去划痕 + 提清晰度 + 自然上色", + " · 整图风格化:整图转媒介/画风,构图与文字位置不变", + " · 360°全景:单视角照片扩成等距圆柱全景(2:1 无缝)", + " · 多图指认合成:按图号指认(场景取某张、角色/物件取其余张)", + "", + "【每档设定词都能改】", + " · 设定词会在最终提示词最顶端原封不动出现,也可只作为模型参考", + " · 改过的按「模式 + 语言」记在节点里,换模式 / 换语言都不丢", + " · 点 ♻️ 恢复默认 一键回到内置文本", + "", + "【参数怎么调】", + " · max_length:图文版面 / 信息图 / 多格分镜 / 广告分镜板 这几个长输出档建议 1024~2048,否则排版细则会被截断", + " · 想稳定复现:sampling_mode = off,或把生成后控制改成 固定", + " · 想更发散:temperature 调高、top_p 调大", + " · 思考模式:模型支持时更稳,长一点;思考内容不会进入输出", + "", + "【输入端口】", + " · 📝 提示词:外接提示词;接线后节点上的提示词框会锁定", + " · 🖼️ 图像:参考图;一批图 = N 张参考图(不抽帧),官方最多 10 张", + " 用「📦 批量图像」等多图节点直接接这里即可,按顺序 = 第 1…N 张图", + "", + "【输出端口】", + " · 📝 提示词:最终提示词 = 本档设定词置顶 + 生成正文", + " · 📊 空latent:文生图直接进采样器;图生图不要接它", + "", + "【空latent 类型】", + " Anima / Boogu / Flux2 / Hunyuan / Krea2 / Qwen-image / SD3 / SDXL / Z-image", + " 选你正在用的模型即可:通道数、下采样、尺寸步长、batch 上限会自动切换", + "", + "【常见问题】", + " · 输出是一段 JSON(含 rewritten_prompt 字段):", + " 这是 SKILL 自带的输出契约,属正常现象。", + " 想只取正文(去掉 JSON 外壳)可以加个解析开关,告诉我即可。", + " · 中文技能却输出英文描述:SKILL 里的语言规则优先,这符合技能设计", + " · 选了图生图档却没接图:日志会告警,结果只会按纯文字生成", + " · SKILL 看起来没生效:检查 🤖 LLM设置 → 使用内置模板 是否为开", + " · 报「未找到文本编码器模型」:把文本编码器放进 models/text_encoders 再在 🤖 LLM设置 里选它", + " · 加载失败先看类型:Qwen-Image 系文本编码器要选 qwen_image", + " · 报 HIP error: device kernel image is invalid / invalid kernel file:你的 ComfyUI 跑在核显上了", + " (多 GPU 机器上核显常被枚举成 cuda:0)→ 启动前设 HIP_VISIBLE_DEVICES=1 让 ComfyUI 用独显再试", + " · 生成慢 / 显存不够:设备改 cpu,或把 max_length 调小", + " · 结果太发散:温度调低、top_p 调小;想复现同一结果就把生成后控制改成固定", + " · 节点表面参数很少?故意的:选项都收在弹窗里", + " · 改完参数要重新执行节点才生效", + "", + "【保存】", + " · 弹窗底部:☑ 自动保存(默认开)、字号 A− / A+、界面 − / %", + " · 所有配置随工作流保存;「取消」丢弃未保存改动", + ]; + body.append(el("div", "font-size:12px;color:#bbb;line-height:1.9;white-space:pre-wrap;font-family:ui-monospace,Consolas,monospace;", lines.join("\n"))); +} + +const XBR_PANEL_RENDER = { llm: xbrRenderLlm, preset: xbrRenderPreset, help: xbrRenderHelp }; + +async function xbrOpenModal(node, panelId) { + if (xbrModal) { try { xbrModal.close(); } catch (_) {} } + const meta = XBR_PANEL_BUTTONS.find((b) => b.id === panelId) || XBR_PANEL_BUTTONS[0]; + const cur = xbrCurJson(node); + const autoSaveRef = { value: cur.auto_save !== false }; + const draft = { + settings: { qwen: xbrParseQwen(cur.qwen) }, + lang: xbrPick(String(xbrWidgetVal(node, "output_lang") ?? ""), LANGS, LANGS[0]), + kind: xbrPick(String(xbrWidgetVal(node, "latent_kind") ?? ""), LATENT_KINDS, LATENT_KINDS[0]), + mode: xbrPick(String(xbrWidgetVal(node, "preset_mode") ?? ""), MODES, MODES[0]), + // 模版(自动 / 文生图 / 图生图) + ioMode: (() => { const v = String(xbrWidgetVal(node, "io_mode") ?? ""); return XBR_IO_MODES.includes(v) ? v : "自动"; })(), + // 设定词(三视图/四视图/五视图/背景纯透明):弹窗内草稿 + 「用户是否改过」标记 + // widget 里是「遗留默认句 / 任一内置预设句」→ 视为没改过 → 取「存档 → 当前模版默认」(无预设 = 空) + presetText: (() => { + const w = String(xbrWidgetVal(node, "three_view_text") ?? ""); + const md = xbrPick(String(xbrWidgetVal(node, "preset_mode") ?? ""), MODES, MODES[0]); + const lg = xbrPick(String(xbrWidgetVal(node, "output_lang") ?? ""), LANGS, LANGS[0]); + const ioW = String(xbrWidgetVal(node, "io_mode") ?? ""); + if (w.trim() && !isDefaultPresetText(md, w)) return w; + return xbrPresetTextFor(node, md, lg, xbrResolveIo(node, XBR_IO_MODES.includes(ioW) ? ioW : "自动")); + })(), + presetTouched: false, + // SKILL 选择(support_llama/skills 里的技能文件 = system_prompt) + skill: String(xbrWidgetVal(node, "skill_name") ?? ""), + skillMode: (() => { const v = String(xbrWidgetVal(node, "skill_mode") ?? ""); return XBR_SKILL_MODES.includes(v) ? v : "自动"; })(), + // 官方 Load CLIP + clip_name: String(xbrWidgetVal(node, "clip_name") ?? ""), + clip_type: String(xbrWidgetVal(node, "clip_type") ?? "qwen_image"), + device: String(xbrWidgetVal(node, "device") ?? "default"), + // 官方 Generate Text 全量参数 + text: { + max_length: xbrInt(xbrWidgetVal(node, "max_length"), 512, 1, 32768), + sampling_mode: xbrPick(String(xbrWidgetVal(node, "sampling_mode") ?? "on"), XBR_SAMPLING_MODES, "on"), + temperature: xbrNum(xbrWidgetVal(node, "temperature"), 0.7, 0.01, 2), + top_k: xbrInt(xbrWidgetVal(node, "top_k"), 64, 0, 1000), + top_p: xbrNum(xbrWidgetVal(node, "top_p"), 0.95, 0, 1), + min_p: xbrNum(xbrWidgetVal(node, "min_p"), 0.05, 0, 1), + repetition_penalty: xbrNum(xbrWidgetVal(node, "repetition_penalty"), 1.05, 0, 5), + presence_penalty: xbrNum(xbrWidgetVal(node, "presence_penalty"), 0.0, 0, 5), + seed: xbrInt(xbrWidgetVal(node, "seed"), 0, 0, Number.MAX_SAFE_INTEGER), + ctl: String(xbrWidgetVal(node, XBR_SEED_CTL_WIDGET) ?? "randomize") || "randomize", + thinking: xbrFlag(xbrWidgetVal(node, "thinking"), false), + use_default_template: xbrFlag(xbrWidgetVal(node, "use_default_template"), true), + mtp: xbrPick(String(xbrWidgetVal(node, "mtp") ?? "auto"), XBR_MTP_MODES, "auto"), + }, + }; + + const commit = async () => { + xbrSetWidget(node, "output_lang", draft.lang, true); // 语言设置 + xbrSetWidget(node, "latent_kind", draft.kind, true); // 触发基础面板的步长/上限联动 + xbrSetWidget(node, "preset_mode", draft.mode, true); // 触发预设句框显隐 + xbrSetWidget(node, "io_mode", draft.ioMode); // 模版:自动 / 文生图 / 图生图 + xbrSetWidget(node, "skill_name", draft.skill); // SKILL = 官方 system_prompt + xbrSetWidget(node, "skill_mode", draft.skillMode); // 自动 / 手动 / 不用 + // 设定词:写进节点 widget(原样)+ 按「模式|模版|语言」存档 → 换模式 / 换模版 / 换语言都不丢 + // ⚠️ 一律写(包含「无预设」时写空串)→ 顺手把 widget 里的遗留默认句清掉 + try { + const ioCommit = xbrResolveIo(node, draft.ioMode); + node.__ippSetPreset?.(draft.presetText); + node.__ippWritePreset?.(draft.mode, ioCommit, draft.lang, draft.presetText); + } catch (_) {} + // 官方 Load CLIP + xbrSetWidget(node, "clip_name", draft.clip_name, true); + xbrSetWidget(node, "clip_type", draft.clip_type, true); + xbrSetWidget(node, "device", draft.device, true); + // 官方 Generate Text 全量参数 + const t = draft.text; + xbrSetWidget(node, "max_length", t.max_length); + xbrSetWidget(node, "sampling_mode", t.sampling_mode); + xbrSetWidget(node, "temperature", t.temperature); + xbrSetWidget(node, "top_k", t.top_k); + xbrSetWidget(node, "top_p", t.top_p); + xbrSetWidget(node, "min_p", t.min_p); + xbrSetWidget(node, "repetition_penalty", t.repetition_penalty); + xbrSetWidget(node, "presence_penalty", t.presence_penalty); + xbrSetWidget(node, "seed", t.seed); + xbrSetWidget(node, XBR_SEED_CTL_WIDGET, t.ctl); // 与原生种子控制同一份值 + xbrSetWidget(node, "thinking", t.thinking); + xbrSetWidget(node, "use_default_template", t.use_default_template); + xbrSetWidget(node, "mtp", t.mtp); + xbrSaveJson(node, { qwen: draft.settings.qwen, auto_save: autoSaveRef.value !== false }); + try { node.__qbrRefreshInfo?.(); } catch (_) {} + }; + const immediate = () => { xbrSaveJson(node, { auto_save: autoSaveRef.value !== false }); }; + + const readonly = (panelId === "help"); + const body = xbrDialog({ + label: meta.title, + title: meta.title, + subtitle: meta.subtitle, + autoSaveRef: readonly ? null : autoSaveRef, + onCommit: readonly ? null : commit, + onImmediate: readonly ? null : immediate, + onChange: readonly ? null : () => { try { node.__qbrRefreshInfo?.(); } catch (_) {} }, + }).body; + + const ctx = { + rerender: () => { body.replaceChildren(); renderInto(); }, + touch: () => {}, + }; + function renderInto() { + try { XBR_PANEL_RENDER[panelId](body, node, draft, ctx); } + catch (e) { body.append(el("div", "color:#e55;font-size:12px;", "面板渲染失败:" + ((e && e.message) || e))); } + } + renderInto(); +} + +/* ── 按钮区 / 提示词框标题行 / 参数设定显示 / 锁定 ─────────── */ +function xbrBuildButtonRow(node) { + const grid = el("div", "display:grid;grid-template-columns:repeat(" + Math.max(1, XBR_PANEL_BUTTONS.length) + ",1fr);grid-auto-rows:30px;gap:6px;flex:0 0 auto;"); + for (const b of XBR_PANEL_BUTTONS) { + const btn = el("button", "width:100%;height:30px;box-sizing:border-box;display:flex;align-items:center;justify-content:center;border-radius:6px;border:2px solid #5b9bd5;background:#3a3a3a;color:#eee;font-size:13px;cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-family:inherit;", b.label); + btn.title = `打开「${b.title}」弹窗`; + btn.dataset.xbrProBtn = b.id; + btn.addEventListener("mousedown", (e) => e.stopPropagation()); + btn.addEventListener("mouseenter", () => { btn.style.background = "#4a4a4a"; }); + btn.addEventListener("mouseleave", () => { btn.style.background = "#3a3a3a"; }); + btn.addEventListener("click", (e) => { e.stopPropagation(); xbrOpenModal(node, b.id); }); + grid.append(btn); + } + return grid; +} + +/** 显示窗取值:去掉括号注释与语言标签([ZH]/[EN]),只留干净的值 */ +function xbrClean(v) { + let s = String(v == null ? "" : v); + s = s.replace(/\s*[\[[][^\]]]*[\]]]\s*$/g, ""); // 尾部 [ZH] / [EN] 之类的语言标签 + s = s.replace(/([^)]*)|\([^)]*\)/g, ""); // 中英文括号注释 + return s.trim(); +} + +/** 参数设定显示(只读摘要 5 行)*/ +function xbrInfoLines(node, promptText) { + const qwen = xbrParseQwen(xbrCurJson(node).qwen); + const genLen = Number(node.__qbrGenLen || 0); + const model = xbrClean(xbrWidgetVal(node, "clip_name")); + const modelShort = String(model).split(/[\\/]/).pop() || model; // 只留文件名,不带目录 + const line1 = `📊 空 latent:${xbrClean(xbrWidgetVal(node, "latent_kind"))} | ${xbrWidgetVal(node, "width")}x${xbrWidgetVal(node, "height")} | 数量 ${xbrWidgetVal(node, "batch_size")}`; + const line2 = `🤖 LLM 模型:${modelShort || "未选择"}`; + const line3 = `⚙️ Text 参数: 最大长度 ${xbrWidgetVal(node, "max_length")} 丨 思考模式 ${xbrFlag(xbrWidgetVal(node, "thinking"), false) ? "开" : "关"} 丨 温度 ${xbrWidgetVal(node, "temperature")}`; + const ctlRaw = xbrClean(xbrWidgetVal(node, XBR_SEED_CTL_WIDGET)) || "randomize"; + const line4 = `✨ 生成种子:${xbrWidgetVal(node, "seed")} | ${XBR_SEED_LABEL[ctlRaw] || ctlRaw}`; + const line5 = `📝 文本数量:${genLen} 字`; + return [line1, line2, line3, line4, line5].join("\n"); +} + +/** 提示词框标题行(左标签 + 右复制)+ 参数设定显示;并挂上端口锁定/执行回写/摘要刷新 */ +function xbrBuildPromptHeader(node, promptBox) { + const head = el("div", "display:flex;align-items:center;gap:8px;flex:0 0 auto;"); + const lab = el("div", "font-size:12px;color:#bbb;", "📝 节点提示词(与面板预览框双向同步)"); + const copy = smallBtn("📋 复制", "margin-left:auto;", "复制提示词框内容", () => { copyText(promptBox.value || ""); }); + head.append(lab, copy); + + const infoBox = el("div", "flex:0 0 auto;height:150px;overflow:auto;background:#1c1c1e;border:1px solid #333;border-radius:6px;padding:6px 9px;font-size:11px;line-height:1.65;color:#b9c6d2;white-space:pre-wrap;box-sizing:border-box;font-family:ui-monospace,Consolas,monospace;"); + infoBox.dataset.captureWheel = "true"; + infoBox.title = "当前配置摘要(只读)"; + + const refresh = () => { try { infoBox.textContent = xbrInfoLines(node, promptBox.value); } catch (_) {} }; + node.__qbrRefreshInfo = refresh; + node.__ippRefreshInfo = refresh; // 基础面板改了设定词存档也会来刷这条摘要 + + // 「📝 提示词」端口接线 → 锁定提示词框(二选一) + const updateLock = () => { + try { + const inp = (node.inputs || []).find((i) => i.name === "text"); + const linked = !!inp && inp.link != null; + promptBox.readOnly = linked; + promptBox.style.background = linked ? "#141416" : "#1d1d1d"; + promptBox.style.color = linked ? "#8a8a8a" : "#ccc"; + promptBox.title = linked ? "已由「📝 提示词」端口控制,断开连线后恢复编辑" : ""; + lab.textContent = linked ? "📝 提示词已锁定(由「📝 提示词」端口控制)" : "📝 节点提示词(与面板预览框双向同步)"; + lab.style.color = linked ? "#d9a441" : "#bbb"; + } catch (_) {} + }; + const prevConn = node.onConnectionsChange; + node.onConnectionsChange = function (...a) { + const r = prevConn?.apply(this, a); + setTimeout(updateLock, 0); + return r; + }; + updateLock(); + + // 生成后控制:后端算好的「下一次种子」回写到 seed widget;同时记下本次生成文本字数(状态窗用) + const prevExecuted = node.onExecuted; + node.onExecuted = function (msg) { + const r = prevExecuted?.apply(this, arguments); + try { + const ns = msg?.seed?.[0]; + if (ns !== undefined && ns !== null && ns !== "") { + const cur = Number(xbrWidgetVal(node, "seed")); + if (Number(ns) !== cur) xbrSetWidget(node, "seed", Math.max(0, Math.round(Number(ns)) || 0)); + } + const gl = msg?.gen_len?.[0]; + if (gl !== undefined && gl !== null) node.__qbrGenLen = Number(gl) || 0; + refresh(); + } catch (_) {} + return r; + }; + + // 表面参数变化 → 摘要刷新(只 setDirtyCanvas,不动用 setSize) + for (const nm of ["latent_kind", "output_lang", "preset_mode", "three_view_text", "aspect_ratio", + "width", "height", "batch_size", "clip_name", "clip_type", "device", + "max_length", "sampling_mode", "temperature", "top_k", "top_p", "min_p", + "repetition_penalty", "presence_penalty", "seed", "thinking", + "use_default_template", "mtp"]) { + const w = xbrWidget(node, nm); + if (!w || w.__qbrHooked) continue; + w.__qbrHooked = true; + const orig = w.callback; + w.callback = function (...a) { + const r = orig?.apply(this, a); + setTimeout(refresh, 0); + return r; + }; + } + promptBox.addEventListener("input", () => { setTimeout(refresh, 0); }); + + setTimeout(refresh, 0); + return [head, infoBox]; +} + +app.registerExtension({ + name: "XB.QwenPromptPreset", + async beforeRegisterNodeDef(nodeType, nodeData) { + if (nodeData?.name !== NODE_TYPE) return; + const orig = nodeType.prototype.onNodeCreated; + nodeType.prototype.onNodeCreated = function () { + const r = orig?.apply(this, arguments); + try { setupNode(this); } catch (e) { console.error("[XB-生图提示词预设]", e); } + return r; + }; + }, + // 低频兜底:工作流加载 / 复制节点后,节点提示词框与配置重新对齐 + setup() { + // 启动时做一次词表自检(子类归属 / 重复 / 孤儿),只在控制台报,不影响使用 + try { + const probs = verifyWordTable(); + const nSubs = ALL_CATS.reduce((n, c) => n + (catOf(c).subs || []).length, 0); + const nWords = ALL_CATS.reduce((n, c) => n + (catOf(c).subs || []).reduce((m, s) => m + (s.keys || []).length, 0), 0); + if (probs.length) console.warn("[XB-生图提示词预设] 词表校验不通过:\n" + probs.join("\n")); + else console.log(`[XB-生图提示词预设] 词表校验通过:${ALL_CATS.length} 大类 / ${nSubs} 子类 / ${nWords} 条`); + } catch (_) {} + setInterval(() => { + try { + const nodes = (app.graph && (app.graph._nodes || app.graph.nodes)) || []; + for (const n of nodes) { + if (n && n.type === NODE_TYPE && typeof n.__ippSyncPromptBox === "function") n.__ippSyncPromptBox(); + } + } catch (_) {} + }, 1500); + }, +}); diff --git a/locales/en/nodeDefs.json b/locales/en/nodeDefs.json index c5ffccf..607ab88 100644 --- a/locales/en/nodeDefs.json +++ b/locales/en/nodeDefs.json @@ -364,6 +364,9 @@ "output_lang": { "name": "Output Language" }, "preset_mode": { "name": "Preset Mode" }, "three_view_text": { "name": "Preset Text (3/4/5-view / transparent)" }, + "skill_name": { "name": "SKILL Select" }, + "skill_mode": { "name": "SKILL Mode" }, + "io_mode": { "name": "Mode" }, "aspect_ratio": { "name": "Aspect Ratio" }, "width": { "name": "Width" }, "height": { "name": "Height" }, @@ -382,6 +385,44 @@ "3": { "name": "🧩 Prompt Settings" } } }, + "XB_QwenPromptPreset": { + "display_name": "XB-BOX - 🖼️ Qwen2.1 Prompt Preset", + "description": "Image prompt preset + official Load CLIP + official Generate Text: empty latent type / aspect params + 8-category element panel, then the text encoder turns the body into a full prompt. Outputs: prompt / empty latent.", + "inputs": { + "latent_kind": { "name": "Empty Latent Type" }, + "output_lang": { "name": "Output Language" }, + "preset_mode": { "name": "Preset Mode" }, + "three_view_text": { "name": "Preset Text (3/4/5-view / transparent)" }, + "skill_name": { "name": "SKILL Select" }, + "skill_mode": { "name": "SKILL Mode" }, + "io_mode": { "name": "Mode" }, + "aspect_ratio": { "name": "Aspect Ratio" }, + "width": { "name": "Width" }, + "height": { "name": "Height" }, + "batch_size": { "name": "Batch Size" }, + "clip_name": { "name": "Model (Load CLIP)" }, + "clip_type": { "name": "Type" }, + "device": { "name": "Device" }, + "max_length": { "name": "Max Length" }, + "sampling_mode": { "name": "Sampling Mode" }, + "temperature": { "name": "Temperature" }, + "top_k": { "name": "top_k" }, + "top_p": { "name": "top_p" }, + "min_p": { "name": "min_p" }, + "repetition_penalty": { "name": "Repetition Penalty" }, + "presence_penalty": { "name": "Presence Penalty" }, + "seed": { "name": "Seed" }, + "thinking": { "name": "Thinking" }, + "use_default_template": { "name": "Use Default Template" }, + "mtp": { "name": "mtp" }, + "text": { "name": "📝 Prompt" }, + "images": { "name": "🖼️ Image" } + }, + "outputs": { + "0": { "name": "📝 Prompt" }, + "1": { "name": "📊 Empty Latent" } + } + }, "XB_ImagePromptPreset": { "display_name": "XB-BOX - 🖼️ Image Prompt Preset", "inputs": { diff --git a/locales/zh/nodeDefs.json b/locales/zh/nodeDefs.json index e1dd290..ed8d793 100644 --- a/locales/zh/nodeDefs.json +++ b/locales/zh/nodeDefs.json @@ -5894,6 +5894,9 @@ "output_lang": { "name": "输出语言" }, "preset_mode": { "name": "预设模式" }, "three_view_text": { "name": "预设句(三/四/五视图·透明背景)" }, + "skill_name": { "name": "SKILL 选择" }, + "skill_mode": { "name": "SKILL 模式" }, + "io_mode": { "name": "模版" }, "aspect_ratio": { "name": "画幅比例" }, "width": { "name": "宽度" }, "height": { "name": "高度" }, @@ -5912,6 +5915,44 @@ "3": { "name": "🧩 提示词设定" } } }, + "XB_QwenPromptPreset": { + "display_name": "XB-BOX - 🖼️ Qwen2.1提示词预设", + "description": "生图提示词预设 + 官方 Load CLIP + 官方 Generate Text:空latent类型 / 画幅参数 + 8 大类元素面板拼装提示词,再用文本编码器把正文生成成完整提示词。输出:提示词 / 空latent。", + "inputs": { + "latent_kind": { "name": "空latent类型" }, + "output_lang": { "name": "输出语言" }, + "preset_mode": { "name": "预设模式" }, + "three_view_text": { "name": "设定词(三/四/五视图·透明背景)" }, + "skill_name": { "name": "SKILL 选择" }, + "skill_mode": { "name": "SKILL 模式" }, + "io_mode": { "name": "模版" }, + "aspect_ratio": { "name": "画幅比例" }, + "width": { "name": "宽度" }, + "height": { "name": "高度" }, + "batch_size": { "name": "生成数量" }, + "clip_name": { "name": "模型·Load CLIP" }, + "clip_type": { "name": "类型" }, + "device": { "name": "设备" }, + "max_length": { "name": "最大长度" }, + "sampling_mode": { "name": "采样模式" }, + "temperature": { "name": "温度" }, + "top_k": { "name": "top_k" }, + "top_p": { "name": "top_p" }, + "min_p": { "name": "min_p" }, + "repetition_penalty": { "name": "重复惩罚" }, + "presence_penalty": { "name": "存在惩罚" }, + "seed": { "name": "种子" }, + "thinking": { "name": "思考模式" }, + "use_default_template": { "name": "使用内置模板" }, + "mtp": { "name": "mtp" }, + "text": { "name": "📝 提示词" }, + "images": { "name": "🖼️ 图像" } + }, + "outputs": { + "0": { "name": "📝 提示词" }, + "1": { "name": "📊 空latent" } + } + }, "XB_ImagePromptPreset": { "display_name": "XB-BOX - 🖼️ 生图提示词预设", "inputs": { diff --git a/nodes_image_prompt_preset.py b/nodes_image_prompt_preset.py index cf44c86..398d795 100644 --- a/nodes_image_prompt_preset.py +++ b/nodes_image_prompt_preset.py @@ -11,7 +11,7 @@ 🏷️ 风格 | 🎥 视角 | 👤 主体 | 🎬 姿态 | 👚 装扮 | 🎒 道具 | 💡 光影 | 🏞️ 背景 每个按钮打开**该分类**的独立面板;面板顶部「分类签」切换子类 (例:风格 → 真人/动画/3D/绘画插画/特殊风格),选项行 = ➕ ➖ 选项名 添加详细描述 -· 输出①「提示词」= 预设句(人物三视图 / 人物四视图 / 人物五视图 / 背景纯透明;常规文生图无预设句)+ 正文(节点提示词框 ↔ 面板预览框双向同步) +· 输出①「提示词」= 预设句(人物三视图 / 人物四视图 / 人物五视图 / 背景纯透明;无预设档不前置)+ 正文(节点提示词框 ↔ 面板预览框双向同步) · 输出②「空latent」= 主流模型直接可用的空 latent(选项名 = 模型名,规格按官方 latent_format; 通道数 / 下采样除数 / 步长 / 上下限 / batch 上限 / downscale 元数据逐项对齐): · Z-image 16 通道 · /8 · 步长 16 (官方 latent_format = Flux) @@ -41,6 +41,8 @@ import torch import comfy.model_management import nodes +from .xb_skills import SKILL_NONE, skill_text + # ── 画幅比例(与本包 XB_ImageParamsMaster 完全一致的选项顺序) ────────────── ASPECT_RATIO_OPTIONS = ["Free", "1:1", "16:9", "9:16", "4:3", "3:4", "21:9"] ASPECT_RATIO_MAP = {"1:1": 1.0, "16:9": 16 / 9, "9:16": 9 / 16, @@ -52,7 +54,21 @@ MAX_RESOLUTION = int(getattr(nodes, "MAX_RESOLUTION", 16384) or 16384) # ── 面板枚举(字符串即存储值,前端面板与后端按同一份字符串对齐) ──────────── OUTPUT_LANGS = ["中文 [ZH]", "英文 [EN]"] -PRESET_MODES = ["常规文生图", "人物三视图", "人物四视图", "人物五视图", "背景纯透明"] +# 预设模式:第 1 档 = 无预设(不前置任何设定词,只输出正文),其余按「文生图组 → 图生图组」排列 +# (官方 Qwen-Image-2.1 示例库归纳)。⚠️ 这些字符串就是工作流里的存储值,改名必须同步 MODE_ALIASES +PRESET_MODES = [ + "无预设", "人物三视图", "人物四视图", "人物五视图", "背景纯透明", + "图文版面", + "信息图", + "多格分镜", + "广告分镜板", + "保持主体换场景", + "局部编辑", + "老照片修复", + "整图风格化", + "360°全景", + "多图指认合成", +] LANG_ZH = OUTPUT_LANGS[0] MODE_TEXT2IMG = PRESET_MODES[0] @@ -60,6 +76,92 @@ MODE_THREE_VIEW = PRESET_MODES[1] MODE_FOUR_VIEW = PRESET_MODES[2] MODE_FIVE_VIEW = PRESET_MODES[3] MODE_TRANSPARENT = PRESET_MODES[4] +MODE_UI = "图文版面" +MODE_INFO = "信息图" +MODE_STORYBOARD = "多格分镜" +MODE_ADBOARD = "广告分镜板" +MODE_KEEP_SUBJECT = "保持主体换场景" +MODE_LOCAL_EDIT = "局部编辑" +MODE_RESTORE = "老照片修复" +MODE_STYLIZE = "整图风格化" +MODE_PANO360 = "360°全景" +MODE_MULTI_REF = "多图指认合成" +# 需要输入图的档位(图生图组):面板会给「必须接图」提示 +MODE_NEEDS_IMAGE = [ + MODE_KEEP_SUBJECT, + MODE_LOCAL_EDIT, + MODE_RESTORE, + MODE_STYLIZE, + MODE_PANO360, + MODE_MULTI_REF, +] + + +def mode_needs_image(preset_mode): + """该预设模式是否必须接输入图(图生图组)""" + return normalize_mode(preset_mode) in MODE_NEEDS_IMAGE + + +def mode_skill_hint(preset_mode): + """该预设模式推荐搭配的 SKILL 文件(support_llama/skills;仅供面板展示,不自动改用户设置)""" + return "system_prompt_edit.txt" if mode_needs_image(preset_mode) else "system_prompt_t2i.txt" + +# ── SKILL 三态:不用 / 手动 / 自动(默认自动)──────────────────────────── +# · 自动 = 按当前预设模式自动适配(图生图档 → prompt_edit,其余 → prompt_t2i) +# · 手动 = 用节点上 skill_name 选中的那一个 +# · 不用 = 完全不生效(哪怕 skill_name 选了也不生效) +SKILL_MODE_AUTO = "自动" +SKILL_MODE_MANUAL = "手动" +SKILL_MODE_OFF = "不用" +SKILL_MODES = [SKILL_MODE_AUTO, SKILL_MODE_MANUAL, SKILL_MODE_OFF] + + +def skill_mode_of(value): + """归一 SKILL 模式(缺失 / None / 非法 → 自动)""" + m = _as_str(value).strip() + return m if m in SKILL_MODES else SKILL_MODE_AUTO + + +def effective_skill_name(skill_mode, skill_name, preset_mode): + """本次实际会用到的 SKILL 文件名(供日志 / IS_CHANGED 用;不用 → 不使用)""" + m = skill_mode_of(skill_mode) + if m == SKILL_MODE_OFF: + return SKILL_NONE + if m == SKILL_MODE_MANUAL: + return _as_str(skill_name).strip() or SKILL_NONE + return mode_skill_hint(preset_mode) + + +def effective_skill_text(skill_mode, skill_name, preset_mode, use_default_template=True): + """本次实际要送进模型的 SKILL 全文(不用 / 缺模型 / 关内置模板 → 空串)""" + if not bool(use_default_template): + return "" + name = effective_skill_name(skill_mode, skill_name, preset_mode) + if not name or name == SKILL_NONE: + return "" + return skill_text(name, "[XB-SKILL]") + + +def with_io_clause(preset_text, preset_mode, has_image, output_lang=None): + """把 t2i / i2i 模版条款追加到设定词上(送模型的那一份与置顶的那一份保持一致)""" + clause = mode_io_clause(preset_mode, has_image, output_lang) + t = _as_str(preset_text).strip() + if not clause: + return t + return (t + " " + clause).strip() if t else clause + + +def safe_print(msg): + """安全打印:控制台不是 UTF-8(如 Windows GBK 控制台)时把不可编码字符降级为 ? , + + 绝不因为一行带 emoji 的日志把节点执行搞崩(ComfyUI 官方启动器不一定切 UTF-8 代码页)。 + """ + try: + print(msg) + except UnicodeEncodeError: + enc = getattr(sys.stdout, "encoding", None) or "utf-8" + print(str(msg).encode(enc, "replace").decode(enc, "replace")) + # ── 主流模型的空 latent 规格表(逐项抄自 ComfyUI:comfy/latent_formats.py + 官方空 latent 节点)── # 选项文字 = 模型名(干净,不带专业参数;细节放 tooltip 与日志);规格仍完整保留以保证对得上模型: @@ -94,7 +196,7 @@ LATENT_KIND_SPEC = { # 默认值固定为 Z-image(不随选项排序变化,保证老工作流/默认体验不变) DEFAULT_LATENT_KIND = "Z-image" # 各预设模式的默认预设句(可编辑字段 three_view_text 的默认值):按「预设模式 × 输出语言」给。 -# · 常规文生图 = 无预设句(原样输出正文,用户完全可控) +# · 无预设 = 不前置设定词(原样输出正文,用户完全可控) # · 人物三视图 / 人物四视图 / 人物五视图 / 背景纯透明 = 成句时自动把预设句拼在正文前(中文「。」/英文 ". ") PRESET_TEXT = { MODE_THREE_VIEW: { @@ -134,6 +236,46 @@ PRESET_TEXT = { LANG_ZH: "生成一张具有透明度的 RGBA 格式图像,包含 Alpha 通道,背景为纯透明。", OUTPUT_LANGS[1]: "Generate an RGBA image with an alpha channel and a fully transparent background.", }, + MODE_UI: { + LANG_ZH: "这是一张完整的平面版面设计图,画面边缘即版面边缘,全出血排版,无多余边框与水印。【版面结构】按层级自上而下组织:① 顶部信息区(标题栏/状态栏,界面类需含时间、信号、电量等图标);② 主体内容区(卡片、栏位、区块,边距统一、圆角一致、层级分明、网格对齐);③ 底部信息区(按钮、标签、脚注)。【文字】画面中所有文字必须逐字使用我给出的原文,不得增删改字,不得生成任何我未指定的文字;字体清晰端正、字号字重按层级区分,无错字无乱码无伪文字。【风格】排版精致、留白充足、有呼吸感,配色统一克制;任何元素都不得遮挡文字。", + OUTPUT_LANGS[1]: "Produce one complete flat print layout: the image edge is the layout edge, full bleed, no extra frame or watermark. Structure top to bottom: (1) top information zone (title bar / status bar, with clock, signal and battery icons for UI); (2) main content zone (cards, columns and blocks with uniform margins, consistent corner radii, clear hierarchy, grid-aligned); (3) bottom information zone (buttons, tags, footnotes). All text must use my wording word for word, with no additions, deletions or substitutions, and no text I did not specify; crisp legible type, size and weight distinguished by hierarchy, no typos, no gibberish. Refined layout, generous breathing space, restrained unified palette; nothing may cover the text.", + }, + MODE_INFO: { + LANG_ZH: "这是一张信息图/科普拆解图,版式整洁、以信息可读性优先,具有教学与数据可视化气质。【版面】① 顶部标题栏(主标题 + 副标题/卷号/徽标);② 主体由多个信息模块组成,按需包含:结构拆解图、局部放大详图、编号引线说明、图例、参数表、色卡、雷达图、时间轴、关键词云;③ 模块之间用细分割线或留白分隔,网格对齐、边距统一。【编号与引线】主体图形用圆形编号点标注,旁侧以细引线连接简短说明,编号连续、不重复、不跳号。【文字】所有文字逐字使用我给出的原文,不得生成任何我未指定的文字,无错字无乱码。", + OUTPUT_LANGS[1]: "Produce an infographic / annotated explanation card: tidy layout, information legibility first, a teaching and data-visualisation feel. Layout: (1) a top title bar (main title plus subtitle, volume number or badge); (2) a body made of several information modules, optionally including a structural cutaway, zoomed detail views, numbered leader-line callouts, a legend, a parameter table, a colour swatch strip, a radar chart, a timeline and a keyword cloud; (3) modules separated by hairline rules or whitespace, grid-aligned with uniform margins. Numbering: mark the main graphic with circular numbered dots and connect short captions with thin leader lines; numbers are continuous and never repeated. All text must use my wording word for word, with no unspecified text, no typos, no gibberish.", + }, + MODE_STORYBOARD: { + LANG_ZH: "生成一张由多个连续分镜格组成的叙事插画,输出为一张完整拼合的扁平整图,不要输出多张分离图片。【版面硬性要求】① 格数与我要求一致,所有格等宽等高;② 严格按我要求的排布方式(单横排或网格),不得擅自拆格、并格或改行数;③ 每格是独立取景,格与格之间用细边框或清晰留白分隔;④ 每格左上角标注圆形数字编号,编号连续、不重复、不跳号不缺号。【内容硬性要求】每一格都必须画出主体本人在做该格的动作,主体清晰完整、占该格画面主体;严禁出现只有背景没有人物的空格;所有格的画风、光影、色彩与主体外观保持一致,仅姿势、表情、动作与场景变化。【文字】除编号外不要生成任何文字、字幕或水印。", + OUTPUT_LANGS[1]: "Produce a narrative illustration made of several sequential storyboard panels, delivered as one fully composited flat sheet, not multiple separate files. Layout (mandatory): (1) exactly the panel count I ask for, all panels equal in width and height; (2) strictly the arrangement I ask for (single horizontal row or grid), never splitting, merging or re-flowing panels; (3) each panel is its own framing, separated by thin borders or clear gutters; (4) a circular number badge in the top-left of every panel, continuous, never repeated or skipped. Content (mandatory): every panel must show the subject itself performing that panel's action, clearly and completely, occupying the panel's main subject; never leave a panel with only background and no character; painting style, lighting, colour and the subject's look stay consistent across panels, only pose, expression, action and setting change. No text, captions or watermarks other than the numbers.", + }, + MODE_ADBOARD: { + LANG_ZH: "生成一张商业广告分镜板/脚本视觉板,像专业品牌团队制作的提案板:既有分镜画面,也带脚本信息。【版面】① 顶部标题区(项目名/产品名 + 副标题 + 品牌标识感排版);② 主体横向排列若干连续分镜画面,每格独立取景、格间清晰分隔;③ 每格下方或侧方配栏目文字:镜头序号、时长或时间轴、镜头说明、字幕/台词、转场提示;④ 底部可放时间轴条与总时长。【风格】排版统一精致、配色克制高级,画面具有电影感与广告质感。【文字】画面中所有文字必须逐字使用我给出的原文,不得生成任何我未指定的文字,不得出现乱码、错字或伪文字。", + OUTPUT_LANGS[1]: "Produce a commercial advertising storyboard / script visual board, like a real brand team's pitch board: storyboard frames plus script information. Layout: (1) a top title zone (project or product name, subtitle, brand-like typography); (2) a body of several sequential storyboard frames in a row, each its own framing with clear separation; (3) caption blocks under or beside each frame with shot number, duration or timeline, shot description, subtitle or dialogue and transition note; (4) an optional timeline bar and total duration at the bottom. Style: unified refined layout, restrained premium palette, cinematic advertising quality. All text must use my wording word for word, with no unspecified text, no gibberish and no fake typography.", + }, + MODE_KEEP_SUBJECT: { + LANG_ZH: "以输入图像中的主体为唯一依据:先完整识别它的全部外观细节,再把它放入新的场景中。【必须保持】主体的外形、比例、结构、材质、颜色、文字与标识、磨损与光泽等全部细节与新画面完全一致;不得改造、不得美化、不得替换、不得增减部件;主体是人物时保持面容、五官、发型、体型与肤色不变。【必须移除】输入图中的摄影棚背景、手持、支架、阴影底板等非主体元素,以及任何不属于最终画面的杂物。【新画面】按我的要求重建场景、构图、光线与景深;主体与新场景的光影、透视、色温自然统一,接触面有合理投影,看起来就是在该空间里真实拍摄的一张画面。", + OUTPUT_LANGS[1]: "Treat the subject in the input image as the single source of truth: read every appearance detail first, then place it into a new scene. Must keep: shape, proportions, structure, material, colour, printed text and logos, wear and gloss identical to the input; do not redesign, beautify, replace, add or remove parts; for a person, keep face, features, hairstyle, build and skin tone. Must remove: studio backdrop, hands, stands, shadow board and any other non-subject elements or clutter. New frame: rebuild setting, composition, lighting and depth of field as I ask; the subject must match the new scene in light, perspective and colour temperature, sit on believable contact shadows, and look like a real photograph taken in that space.", + }, + MODE_LOCAL_EDIT: { + LANG_ZH: "只修改我指定或标记的区域,其余部分与输入图保持完全一致(包含构图、透视、光线、色调与清晰度)。【编辑范围】仅限我指定/标记的区域或部位;未标记的内容一律不得改动、不得重绘、不得重新打光、不得改变材质。【标记优先】若输入包含标记图、掩码图或涂抹标注,则以标注范围为准,只在该范围内操作。【过渡】修改区域与周边必须在材质、纹理与光影方向上自然衔接,边界不得出现生硬接缝、色块、描边或亮度跳变。【输出】只输出修改后的一整张完整画面,不要输出对比图、标注框、箭头或任何说明文字。", + OUTPUT_LANGS[1]: "Change only the region or part I specify or mark; everything else stays exactly as in the input (composition, perspective, lighting, colour and sharpness). Scope: only my specified or marked region; anything unmarked must not be altered, repainted, relit or re-materialised. Marking wins: if the input includes an annotated, masked or painted region, operate strictly inside it. Blending: the edit must match its surroundings in material, texture and light direction, with no hard seams, colour patches, outlines or brightness jumps. Output one single finished image only, with no comparison view, boxes, arrows or explanatory text.", + }, + MODE_RESTORE: { + LANG_ZH: "把输入的老照片修复成清晰、自然、真实的彩色照片,并保持原照片的内容与人物五官解剖完全不变。【修复】去除噪点、划痕、斑点、折痕、褪色与颗粒,恢复细节与层次、平衡高光与阴影;不得过度锐化,不得出现塑料感、蜡感或油画涂抹感。【上色】按真实肤色与材质自然上色,色调统一可信,不做夸张调色。【保持】人物面部结构、皱纹、神态、视线、头部角度、手部与持物关系、服装材质与褶皱、背景陈设与景深关系均不得改变。【输出】只输出修复后的一整张完整画面,不加边框、不加文字或水印。", + OUTPUT_LANGS[1]: "Restore the input vintage photograph into a sharp, natural, realistically colourised photo, keeping the original content and facial anatomy unchanged. Repair: remove noise, scratches, spots, creases, fading and grain; recover detail and tonal range and balance highlights and shadows; never oversharpen, never produce waxy, plastic or painterly skin. Colour: natural believable skin and material tones, unified grading, no exaggerated stylisation. Keep: facial structure, wrinkles, expression, gaze, head angle, hands and how they hold objects, garment material and folds, background props and depth of field. Output one single finished image with no border, text or watermark.", + }, + MODE_STYLIZE: { + LANG_ZH: "把输入图整张转换为指定的视觉媒介与画风,转换必须覆盖整幅画面,不得只做局部滤镜或只改一部分物体。【保持】原图的构图、画幅比例、视角、主体位置与姿态、景物之间的空间关系、已有文字与招牌的位置及可读内容不得改变;不得新增标语、字幕或水印。【转换】按我要求的画风重绘每一个物体:笔触、色层、材质表现与光色关系统一;光的方向与原图一致。【输出】只输出转换后的一整张完整画面。", + OUTPUT_LANGS[1]: "Convert the whole input image into the requested visual medium and style; the conversion must cover the entire frame, not a local filter or only some objects. Keep: composition, aspect ratio, viewpoint, subject placement and pose, spatial relations between objects, and the position and legible content of existing signage and text; add no slogans, captions or watermarks. Convert: repaint every object in the requested style with consistent brushwork, colour layering, material rendering and light-colour relationships; light direction matches the original. Output one single converted image.", + }, + MODE_PANO360: { + LANG_ZH: "把输入的单视角照片扩展成一张完整的 360 度全景图。【投影】使用真正的等距圆柱投影(equirectangular),水平覆盖 360 度、垂直覆盖 180 度,包含头顶天空与脚下地面;左右两端无缝衔接,形成一张连续完整的场景。【补全】以输入图为基础,把相机背后的环境合理延伸(地面、墙体、植被、天空,光照方向一致),不得出现重复、断裂或接缝。【保持】输入图中的主体只出现一次,其外观、姿态以及与周围景物的相对关系保持原样,不得镜像或复制。【输出】严格 2:1 画幅比例,例如 2880x1440。", + OUTPUT_LANGS[1]: "Extend the input single-view photograph into a complete 360-degree panorama. Projection: true equirectangular, covering 360 degrees horizontally and 180 degrees vertically, including the sky overhead and the ground below; left and right edges must join seamlessly into one continuous scene. Completion: extend the environment behind the camera plausibly (ground, walls, vegetation, sky, consistent light direction) with no repeated or broken areas. Keep: the subject appears exactly once, with its appearance, pose and relationship to surrounding objects unchanged, never mirrored or duplicated. Output at exactly a 2:1 aspect ratio, for example 2880x1440.", + }, + MODE_MULTI_REF: { + LANG_ZH: "按图号使用我提供的多张参考图,合成一张全新画面。【指认规则】场景与环境取自我指定的那一张图;其余各图分别提供角色外观或物件本体;我未指认的图不参与画面。【保持】被引用的角色保持面容、发型与体型一致;被引用的物件保持形状、材质、颜色与标识文字一致;不得替换、美化或增减部件。【重建】按我的要求安排构图、站位、光线与景深,所有元素统一在同一空间的光影与透视中,接触关系与投影合理。【文字】除我明确要求的外,不生成任何文字或水印。", + OUTPUT_LANGS[1]: "Use the reference images I provide by their index numbers and composite one brand-new image. Mapping: the scene and environment come from the image I designate; the other images supply either character likeness or an object itself; images I do not reference do not appear. Keep: referenced characters keep the same face, hairstyle and build; referenced objects keep the same shape, material, colour and printed text; no replacement, beautifying, additions or removals. Rebuild: arrange composition, staging, lighting and depth of field as I ask; all elements share one consistent space with matching light, perspective, contact and shadows. No text or watermark unless I explicitly ask for it.", + }, } # 兼容旧引用(三视图预设句) @@ -337,19 +479,261 @@ def build_empty_latent(latent_kind, width, height, batch_size=1): # ============================================================================ # 提示词拼装 # ============================================================================ -def build_prompt(lang, preset_mode, three_view_text, body): - """预设句(人物三视图 / 人物四视图 / 人物五视图 / 背景纯透明) + 正文,按输出语言使用合适的分句符。 +# 兼容:早期版本档位名带表情 / 旧名(工作流里可能存着旧值)→ 归一为当前档位名,避免静默回落 +MODE_ALIASES = { + "常规文生图": "无预设", # 0.10.x 旧名 → 新名(语义完全一致) + "📱 图文版面": "图文版面", + "📊 信息图": "信息图", + "🎞 多格分镜": "多格分镜", + "🎬 广告分镜板": "广告分镜板", + "🛍 保持主体换场景": "保持主体换场景", + "✏️ 局部编辑": "局部编辑", + "🧹 老照片修复": "老照片修复", + "🎨 整图风格化": "整图风格化", + "🌐 360°全景": "360°全景", + "🗂 多图指认合成": "多图指认合成", +} - 正文 = 节点表面提示词框(也等于面板预览框,两者双向同步)。 - 常规文生图:预设句不参与 → 原样输出正文(保持用户完全可控)。 +def normalize_mode(preset_mode): + """把各种历史/非法值归一为当前档位名(认不出来就保持原样,由上层 _pick 兜底)""" + m = _as_str(preset_mode).strip() + return MODE_ALIASES.get(m, m) + + +# ═══════════════════════════════════════════════════════════════ +# t2i / i2i 两套模版:按「有没有接输入图」自动选用(自测会断言与前端 JS 逐字一致) +# ═══════════════════════════════════════════════════════════════ + +# 文生图档(图文版面 / 信息图 / 多格分镜 / 广告分镜板 / 三·四·五视图 / 背景纯透明) +# **接了输入图**时追加:以输入图为准,规格只作用于组织与重建 +IO_I2I_COMMON = {LANG_ZH: "【本次接了输入图】以输入图为唯一依据:保持输入图中被保留部分的原样不变(人物保持面容、发型、体型与气质;物件保持外形、比例、材质、颜色与文字标识),不得改动、美化、替换或增减我未指定的部分;本档规格只作用于画面的组织与重建区域。", OUTPUT_LANGS[1]: "Attached input image(s) are the single source of truth: keep everything kept from them unchanged — for people: face, hairstyle, build and character; for objects: shape, proportions, material, colour and printed text — and never alter, beautify, replace, add or remove anything I did not ask for. This mode's spec only governs how the frame is organised and rebuilt."} + +# ── 模版:文生图 / 图生图(决定用哪一份「设定词」)───────────────────────── +# · 自动 = 按有没有接参考图判断(没接 → 文生图,接了 → 图生图) +# · 每个预设模式都有「文生图」与「图生图」两份设定词: +# 图生图档(6 档)的原始文本就是图生图版,下面 PRESET_TEXT_T2I 是它的文生图版; +# 文生图档(8 档)的原始文本就是文生图版,「图生图」版 = 原文 + IO_I2I_COMMON 条款。 +IO_MODE_AUTO = "自动" +IO_MODE_T2I = "文生图" +IO_MODE_I2I = "图生图" +IO_MODES = [IO_MODE_AUTO, IO_MODE_T2I, IO_MODE_I2I] + + +def io_mode_of(value): + """归一模版参数(缺失 / None / 非法 → 自动)""" + m = _as_str(value).strip() + return m if m in IO_MODES else IO_MODE_AUTO + + +def resolve_io_mode(io_mode, has_image): + """把「自动」按有没有接参考图解析成 文生图 / 图生图""" + m = io_mode_of(io_mode) + if m == IO_MODE_T2I: + return IO_MODE_T2I + if m == IO_MODE_I2I: + return IO_MODE_I2I + return IO_MODE_I2I if has_image else IO_MODE_T2I + + +def mode_default_io(preset_mode): + """没接图也没显式指定时该档的默认模版(图生图档默认图生图,其余默认文生图)""" + return IO_MODE_I2I if mode_needs_image(preset_mode) else IO_MODE_T2I + + +# 图生图档的「文生图版」设定词(zh/en 各一份;与 PRESET_TEXT 里的图生图版一一对应) +PRESET_TEXT_T2I = { + MODE_KEEP_SUBJECT: { + LANG_ZH: "生成一张主体清晰的画面:先完整确定主体的全部外观细节,再把它放进我要求的新场景中。" + "【必须保持】主体的外形、比例、结构、材质、颜色、文字与标识、磨损与光泽等全部细节前后一致、" + "符合真实物理;不得改造、不得美化、不得替换、不得增减部件;主体是人物时保持面容、五官、" + "发型、体型与肤色自然可信。【必须干净】画面中不得出现摄影棚背景、手持、支架、阴影底板等杂物," + "也不得有任何不属于最终画面的元素。【新画面】按我的要求营造场景、构图、光线与景深;" + "主体与新场景的光影、透视、色温自然统一,接触面有合理投影," + "看起来就是在该空间里真实拍摄的一张画面。", + OUTPUT_LANGS[1]: "Generate a frame with a clearly defined subject: first settle every appearance detail of the subject, " + "then place it into the new scene I ask for. Must keep: shape, proportions, structure, material, " + "colour, printed text and logos, wear and gloss stay self-consistent and physically believable; " + "do not redesign, beautify, replace, add or remove parts; for a person, keep face, features, " + "hairstyle, build and skin tone natural and convincing. Must be clean: no studio backdrop, hands, " + "stands, shadow board or any element that does not belong in the final frame. New frame: build the " + "setting, composition, lighting and depth of field as I ask; the subject must match the scene in " + "light, perspective and colour temperature, sit on believable contact shadows, and look like a real " + "photograph taken in that space.", + }, + MODE_LOCAL_EDIT: { + LANG_ZH: "生成一张完整画面,并把我的改动直接做进画面里。【改动范围】只按我的要求改我指定的部位," + "其余内容保持稳定一致:不得顺手重绘、不得重新打光、不得改变材质与配色。" + "【过渡】改动区域与周边必须在材质、纹理与光影方向上自然衔接," + "边界不得出现生硬接缝、色块、描边或亮度跳变。【输出】只输出最终的一整张完整画面," + "不要输出对比图、标注框、箭头或任何说明文字。", + OUTPUT_LANGS[1]: "Generate one complete frame with my change built directly into the image. Scope: change only the part " + "I ask for; keep everything else stable and consistent — do not incidentally repaint, relight, or " + "alter materials and palette. Blending: the changed area must match its surroundings in material, " + "texture and light direction, with no hard seams, colour patches, outlines or brightness jumps. " + "Output one single finished frame only, with no comparison view, boxes, arrows or explanatory text.", + }, + MODE_RESTORE: { + LANG_ZH: "直接生成一张清晰、自然、细节完整的照片,并带有老照片翻新后的质感。【画面】主体与场景按我的描述生成," + "人物面部结构、五官解剖与比例自然准确,手部与持物关系合理。【修复感】不要噪点、划痕、斑点、折痕、" + "褪色与颗粒,细节与层次完整、高光与阴影平衡;不得过度锐化,不得出现塑料感、蜡感或油画涂抹感。" + "【色彩】按真实肤色与材质自然上色,色调统一可信,不做夸张调色。【输出】只输出一整张完整画面," + "不加边框、不加文字或水印。", + OUTPUT_LANGS[1]: "Generate directly a sharp, natural, fully detailed photograph with the look of a restored vintage print. " + "Frame: build the subject and setting from my description, with natural accurate facial structure, " + "anatomy and proportions, and believable hands and grip. Restoration feel: no noise, scratches, spots, " + "creases, fading or grain; complete detail and tonal range, balanced highlights and shadows; never " + "oversharpen, never produce waxy, plastic or painterly skin. Colour: natural believable skin and " + "material tones, unified grading, no exaggerated stylisation. Output one single finished image with no " + "border, text or watermark.", + }, + MODE_STYLIZE: { + LANG_ZH: "生成一张完整的全新画面,并整体转换成我要求的视觉媒介与画风,转换必须覆盖整幅画面," + "不得只做局部滤镜或只改一部分物体。【画面】构图、画幅比例、视角与主体由我的描述决定," + "主体位置与姿态清晰合理。【转换】画面里的每一个物体都按该画风重绘:笔触、色层、材质表现与" + "光色关系统一;光的方向明确一致。【文字】除我明确要求的外不生成任何文字、标语、字幕或水印。" + "【输出】只输出一整张完整画面。", + OUTPUT_LANGS[1]: "Generate one complete new frame and convert the whole of it into the requested visual medium and style; " + "the conversion must cover the entire frame, not a local filter or only some objects. Frame: " + "composition, aspect ratio, viewpoint and subject come from my description, with the subject clearly " + "and plausibly placed and posed. Convert: repaint every object in that style with consistent " + "brushwork, colour layering, material rendering and light-colour relationships; keep one clear light " + "direction. Text: no text, slogans, captions or watermarks unless I explicitly ask for them. Output " + "one single complete frame.", + }, + MODE_PANO360: { + LANG_ZH: "生成一张完整的 360 度全景图。【投影】使用真正的等距圆柱投影(equirectangular)," + "水平覆盖 360 度、垂直覆盖 180 度,包含头顶天空与脚下地面;左右两端无缝衔接," + "形成一张连续完整的场景。【补全】以输入图为基础,把相机背后的环境合理延伸" + "(地面、墙体、植被、天空,光照方向一致),不得出现重复、断裂或接缝。" + "【保持】输入图中的主体只出现一次,其外观、姿态以及与周围景物的相对关系保持原样," + "不得镜像或复制。【输出】严格 2:1 画幅比例,例如 2880x1440。", + OUTPUT_LANGS[1]: "Generate a complete 360-degree panorama. Projection: true equirectangular, covering 360 degrees " + "horizontally and 180 degrees vertically, including the sky overhead and the ground below; left and " + "right edges must join seamlessly into one continuous scene. Completion: extend the environment behind " + "the camera plausibly (ground, walls, vegetation, sky, consistent light direction) with no repeated or " + "broken areas. Keep: the subject appears exactly once, with its appearance, pose and relationship to " + "surrounding objects unchanged, never mirrored or duplicated. Output at exactly a 2:1 aspect ratio, for " + "example 2880x1440.", + }, + MODE_MULTI_REF: { + LANG_ZH: "直接按我的描述生成一张全新画面。【画面】按我的要求安排主体、场景、构图、站位、光线与景深," + "所有元素统一在同一空间的光影与透视中,接触关系与投影合理。【一致性】同一主体在各处的外观" + "保持一致,不得替换、美化或增减部件。【文字】除我明确要求的外,不生成任何文字或水印。", + OUTPUT_LANGS[1]: "Generate one brand-new image directly from my description. Frame: arrange subjects, setting, " + "composition, staging, lighting and depth of field as I ask; all elements share one consistent space " + "with matching light, perspective, contact and shadows. Consistency: keep each subject's look " + "identical everywhere, with no replacement, beautifying, additions or removals. Text: no text or " + "watermark unless I explicitly ask for it.", + }, +} + + +def preset_text_for(preset_mode, output_lang=None, io_mode=None): + """某预设模式在指定模版下的**默认**设定词(无预设 → 永远 "")。 + + io_mode 可传「自动」(按该档默认)、「文生图」或「图生图」。 + · 无预设:无论接不接图、哪个模版 → **都是空**(不前置任何设定词,也不加「以输入图为准」条款) + · 文生图模版:图生图档 → PRESET_TEXT_T2I;其余文生图档 → PRESET_TEXT + · 图生图模版:图生图档 → PRESET_TEXT;其余文生图档 → PRESET_TEXT + IO_I2I_COMMON 条款 + """ + mode = normalize_mode(preset_mode) + lg = output_lang if output_lang in OUTPUT_LANGS else LANG_ZH + if not mode or mode == MODE_TEXT2IMG: + return "" + texts = PRESET_TEXT.get(mode, {}) + io = io_mode_of(io_mode) + if io == IO_MODE_AUTO: + io = mode_default_io(mode) + if io == IO_MODE_T2I: + return PRESET_TEXT_T2I.get(mode, texts).get(lg, texts.get(lg, "")) + txt = texts.get(lg, "") + if mode in MODE_NEEDS_IMAGE: + return txt + clause = IO_I2I_COMMON.get(lg, "") + return (txt + " " + clause).strip() if txt else clause + + +def all_default_texts(preset_mode): + """该模式**所有模版 × 所有语言**的默认设定词(用于判断用户是否没改过 → 见 preset_text_of) + + · 无预设档额外算「默认」的还有:IO_I2I_COMMON 条款(早期版本在「图生图模版」下把它当 + 设定词写进了 widget / 存档)+ **所有内置预设句**(节点上 three_view_text 的老默认值就是 + 三视图预设句 → 无预设档必须把它当空白,不能当成用户的「自定义」继续前置)。 + """ + out = [] + for lg in OUTPUT_LANGS: + for io in (IO_MODE_T2I, IO_MODE_I2I): + t = preset_text_for(preset_mode, lg, io).strip() + if t: + out.append(t) + if normalize_mode(preset_mode) == MODE_TEXT2IMG: + c = _as_str(IO_I2I_COMMON.get(lg, "")).strip() + if c: + out.append(c) + if normalize_mode(preset_mode) == MODE_TEXT2IMG: + for table in (PRESET_TEXT, PRESET_TEXT_T2I): + for texts in table.values(): + for t in texts.values(): + t = _as_str(t).strip() + if t and t not in out: + out.append(t) + return out + + +def preset_text_of(preset_mode, output_lang, three_view_text, io_mode=None, has_image=False): + """最终生效的设定词:用户改过的优先;没改过(等于任一默认)则按当前模版取默认。 + + ⚠️ 为什么要判「没改过」:老工作流里 widget 存的是**另一套模版**的默认文本 + (例如没接图但存着 360°全景 的图生图版)→ 直接照用就会把「把输入的单视角照片扩展成…」 + 送进文生图,用户看到的正是这个 bug。 + """ + txt = _as_str(three_view_text).strip() + io = resolve_io_mode(io_mode, has_image) + now = preset_text_for(preset_mode, output_lang, io) + if not txt: + return now + for cand in all_default_texts(preset_mode): + if txt == cand: + return now + return txt + + +def mode_io_clause(preset_mode, has_image, output_lang=None): + """该档在「当前有没有接图」下需要追加的条款(仅「文生图档 + 有图」 → 以输入图为准;其余 → "") + + ⚠️ 无预设档(MODE_TEXT2IMG)永远返回"":用户要求「接不接图,无预设的设定词都是空白」。 + """ + lg = output_lang if output_lang in OUTPUT_LANGS else LANG_ZH + m = normalize_mode(preset_mode) + if not m or m == MODE_TEXT2IMG or mode_needs_image(m): + return "" + return IO_I2I_COMMON.get(lg, "") if has_image else "" + + +def with_io_clause(preset_text, preset_mode, has_image, output_lang=None): + """把模版条款追加到设定词上(仅文生图档 + 有图;其余原样)""" + clause = mode_io_clause(preset_mode, has_image, output_lang) + t = _as_str(preset_text).strip() + if not clause: + return t + return (t + " " + clause).strip() if t else clause + + +def build_prompt(lang, preset_mode, three_view_text, body, has_image=None, io_mode=None): + """设定词 + 正文,按输出语言使用合适的分句符。 + + · 正文 = 节点表面提示词框(也等于面板预览框,两者双向同步); + · 设定词 = preset_text_of(...):改过的用改过的,没改过则按「当前模版(文生图/图生图)」取默认; + · 无预设档:接不接图、哪个模版 → 设定词都是空串(只输出正文); + · has_image=None(基础节点不传)时按文生图处理,行为与旧版一致。 """ core = (body if isinstance(body, str) else "").strip() mode = _pick(PRESET_MODES, preset_mode, PRESET_MODES[0]) - defaults = PRESET_TEXT.get(mode) - if not defaults: - return core lg = _pick(OUTPUT_LANGS, lang, LANG_ZH) - preset = _as_str(three_view_text).strip() or defaults[lg] + preset = preset_text_of(mode, lg, three_view_text, io_mode=io_mode, + has_image=bool(has_image)) + if not preset: + return core if not core: return preset if lg == OUTPUT_LANGS[1]: @@ -389,13 +773,13 @@ class XB_ImagePromptPreset: }), "preset_mode": (list(PRESET_MODES), { "default": PRESET_MODES[0], - "tooltip": "预设模式:常规文生图=只输出正文(完全可控);" + "tooltip": "预设模式:无预设=不前置任何设定词,只输出正文(完全可控);" "人物三视图 / 人物四视图 / 人物五视图 / 背景纯透明 = 成句自动在正文前加对应预设句" "(下方预设句框仅这三个模式显示,切模式时未改过的默认句会自动跟随)", }), "three_view_text": ("STRING", { "default": THREE_VIEW_TEXT[LANG_ZH], "multiline": True, - "tooltip": "预设句 / 设定词(人物三视图 / 人物四视图 / 人物五视图 / 背景纯透明 四个模式生效;常规文生图时本框自动隐藏)\n" + "tooltip": "预设句 / 设定词(人物三视图 / 人物四视图 / 人物五视图 / 背景纯透明 四个模式生效;无预设时本框自动隐藏)\n" "· 用户改过的设定词按「模式 + 语言」存进本节点(换模式 / 换语言都不会丢);\n" "· 最终提示词输出时它会被原封不动加在正文最顶端", }), diff --git a/nodes_image_prompt_preset_pro.py b/nodes_image_prompt_preset_pro.py index 3d69341..0117deb 100644 --- a/nodes_image_prompt_preset_pro.py +++ b/nodes_image_prompt_preset_pro.py @@ -51,6 +51,8 @@ from .nodes_image_prompt_preset import ( ASPECT_RATIO_OPTIONS, BATCH_MAX, DEFAULT_LATENT_KIND, + IO_MODE_AUTO, + IO_MODES, LATENT_KINDS, MAX_RESOLUTION, OUTPUT_LANGS, @@ -61,10 +63,22 @@ from .nodes_image_prompt_preset import ( THREE_VIEW_TEXT, build_empty_latent, build_prompt, + io_mode_of, latent_kind_spec, normalize_size, + preset_text_of, + resolve_io_mode, + safe_print, + SKILL_MODE_AUTO, + SKILL_MODES, + effective_skill_name, + effective_skill_text, + skill_mode_of, ) +# SKILL(技能文件 = LLM 的 system prompt,放 support_llama/skills) +from .xb_skills import SKILL_NONE, skill_list, skill_sig + DEFAULT_LANG = OUTPUT_LANGS[0] # 设定词(预设模式的设定文本)送给 LLM 时的参考说明:它会被节点原样置顶,LLM 只管增强正文 @@ -155,15 +169,22 @@ def _has_images(images): return True -def _build_system_prompt(preset, extra, output_lang, preset_text="", task_hint=""): - """提示词设定 = 增强预设 + 输出语言 + 设定词参考 + 追加设定 + 只输出最终提示词的硬规则 + 任务块 +def _build_system_prompt(preset, extra, output_lang, preset_text="", task_hint="", skill_text=""): + """提示词设定 = SKILL(可选) + 增强预设 + 输出语言 + 设定词参考 + 追加设定 + 只输出最终提示词的硬规则 + 任务块 + `skill_text`(support_llama/skills 里的技能文件)放在**最前面**当角色与总规则; + 技能文件自带语言决策与输出契约 → 选中技能时**不再叠加**节点的语言提示,避免互相打架。 `preset_text`(预设模式的设定词)只作为**增强参考**告诉 LLM:它会被节点原样置顶到最终提示词, 禁止在正文里重复 / 改写它。 `task_hint`(看图 / 改图任务)放**最末**,优先级最高——决定“先识图再改”还是“纯看图”。 """ - parts = [XB_llamaPromptEnhancer().main(preset)[0] or ""] - parts.append(_LANG_HINT.get(output_lang, _LANG_HINT[_OUTPUT_LANGS[0]])) + skill = (skill_text or "").strip() + parts = [] + if skill: + parts.append(skill) # SKILL:角色与总规则(自带语言 / 输出契约) + parts.append(XB_llamaPromptEnhancer().main(preset)[0] or "") + if not skill: + parts.append(_LANG_HINT.get(output_lang, _LANG_HINT[_OUTPUT_LANGS[0]])) ref = (preset_text or "").strip() if ref: parts.append(_PRESET_REF_HINT.format(text=ref)) @@ -177,17 +198,16 @@ def _build_system_prompt(preset, extra, output_lang, preset_text="", task_hint=" return "\n\n".join([p for p in parts if p]) -def _preset_text_of(preset_mode, output_lang, three_view_text): - """当前预设模式的设定词(= 节点表面「预设句」框里的值)。 +def _preset_text_of(preset_mode, output_lang, three_view_text, io_mode=None, has_image=False): + """当前预设模式的设定词(= 节点表面「设定词」框里的值,按文生图 / 图生图分两套)。 - · 用户改过 → 用改过的原文(含换行,前端按「模式|语言」存在节点里,换模式不丢); - · 空 / 未改 → 用「模式 × 输出语言」的默认设定词; - · 常规文生图等无设定词的模式 → 空串(不前置任何内容)。 + · 用户改过 → 用改过的原文(含换行,前端按「模式|模版|语言」存在节点里,换模式/换模版不丢); + · 没改过(等于任一默认)→ 按当前模版取对应那套默认,避免把图生图版文本(如 360°全景 + 的「把输入的单视角照片扩展成…」)带进文生图; + · 无预设档:接不接图、哪个模版 → 都是空串(不前置设定词)。 """ - defaults = PRESET_TEXT.get(preset_mode) if isinstance(PRESET_TEXT, dict) else None - if not defaults: - return "" - lang = output_lang if output_lang in OUTPUT_LANGS else DEFAULT_LANG + return preset_text_of(preset_mode, output_lang, three_view_text, + io_mode=io_mode, has_image=has_image) return str(three_view_text or "").strip() or defaults.get(lang, "") or "" @@ -211,17 +231,32 @@ class XB_ImagePromptPresetPro: "default": DEFAULT_LANG, "tooltip": "输出语言:影响预设句、元素拼装分隔符,同时作为 LLM 的输出语言要求", }), + "io_mode": (list(IO_MODES), { + "default": IO_MODE_AUTO, + "tooltip": "模版:自动 = 按有没有接参考图判断;文生图 / 图生图 = 手动指定。" + "每个预设模式都有两套设定词,按它自动选用(切换会直接把设定词换成对应那套)", + }), "preset_mode": (list(PRESET_MODES), { "default": PRESET_MODES[0], - "tooltip": "预设模式:常规文生图=只输出正文;人物三视图 / 人物四视图 / 人物五视图 / 背景纯透明 = 自动在正文前加预设句", + "tooltip": "预设模式:无预设=不前置任何设定词,只输出正文;其余档位把该档设定词置顶到最终提示词最顶端(设定词分文生图 / 图生图两套)", }), "three_view_text": ("STRING", { "default": THREE_VIEW_TEXT[DEFAULT_LANG], "multiline": True, - "tooltip": "预设模式的设定词(人物三视图 / 人物四视图 / 人物五视图 / 背景纯透明 四个模式生效;常规文生图时自动隐藏)\n" + "tooltip": "预设模式的设定词(人物三视图 / 人物四视图 / 人物五视图 / 背景纯透明 四个模式生效;无预设时自动隐藏)\n" "· 最终提示词输出时它会被【原封不动】加在最顶端;\n" "· 用户改过的设定词按「模式 + 语言」存进本节点(换模式 / 换语言都不会丢);\n" "· 启用 LLM 时它作为增强参考交给 LLM(LLM 只增强正文,不改写它)", }), + "skill_mode": (list(SKILL_MODES), { + "default": SKILL_MODE_AUTO, + "tooltip": "SKILL 模式:自动 = 按预设模式适配(图生图档 → prompt_edit,其余 → prompt_t2i);" + "手动 = 用下面选中的那一个;不用 = SKILL 完全不生效(哪怕选了也不生效)", + }), + "skill_name": (skill_list(), { + "default": SKILL_NONE, + "tooltip": "SKILL选择:support_llama/skills 里的 txt,整段作为 LLM 反推的 system prompt(放在最前面当角色与总规则);" + "仅在「SKILL 模式 = 手动」时生效;技能文件自带语言与输出契约,选中后不再叠加节点的语言提示", + }), "aspect_ratio": (list(ASPECT_RATIO_OPTIONS), { "default": "Free", "tooltip": "画幅比例:Free=自由(仅按步长锁定);固定比例时按较大的一边反算另一边", @@ -296,10 +331,11 @@ class XB_ImagePromptPresetPro: "「启用 LLM 反推」关 = 原预设行为;开 = 图/文 → 提示词。输出:提示词 / 空latent / 提示词列表 / 提示词设定。") @classmethod - def IS_CHANGED(cls, manager_settings="", **kwargs): - keys = ("latent_kind", "output_lang", "preset_mode", "three_view_text", "aspect_ratio", + def IS_CHANGED(cls, manager_settings="", skill_name=SKILL_NONE, skill_mode=SKILL_MODE_AUTO, + preset_mode="", **kwargs): + keys = ("latent_kind", "output_lang", "preset_mode", "io_mode", "three_view_text", "aspect_ratio", "width", "height", "batch_size", "use_llm", "backend", "preset", "task_preset", - "internal_prompt") + "skill_name", "skill_mode", "internal_prompt") sig = {k: kwargs.get(k) for k in keys} extra = "" try: @@ -307,22 +343,27 @@ class XB_ImagePromptPresetPro: extra = json.dumps(read_api_settings(), sort_keys=True, ensure_ascii=False) except Exception: extra = "" - return f"{manager_settings}|{extra}|{sig}" + used = effective_skill_name(skill_mode, skill_name, preset_mode) + return f"{manager_settings}|{extra}|{sig}|{skill_sig(used)}" def generate(self, latent_kind, output_lang, preset_mode, three_view_text, aspect_ratio, width, height, batch_size, use_llm, backend, preset, task_preset, open_api_settings, - internal_prompt="", manager_settings="", text="", images=None, unique_id=None): + internal_prompt="", manager_settings="", text="", images=None, unique_id=None, + skill_name=SKILL_NONE, skill_mode=SKILL_MODE_AUTO, io_mode=IO_MODE_AUTO): # ① 画幅归一 + 空 latent(与「🖼️ 生图提示词预设」逐行一致) spec = latent_kind_spec(latent_kind) w, h = normalize_size(aspect_ratio, width, height, spec["step"], spec["min"], spec["max"]) latent = build_empty_latent(latent_kind, w, h, batch_size) - # ② 拼装提示词(设定词 + 正文)+ 该模式的设定词(用户改过则用改过的) - base_prompt = build_prompt(output_lang, preset_mode, three_view_text, internal_prompt) - preset_text = _preset_text_of(preset_mode, output_lang, three_view_text) + # ② 拼装提示词(设定词 + 正文)+ 该模式的设定词(用户改过则用改过的;按模版取对应那套) + has_img = _has_images(images) + io_eff = resolve_io_mode(io_mode, has_img) # 文生图 / 图生图 + preset_eff = _preset_text_of(preset_mode, output_lang, three_view_text, + io_mode=io_mode, has_image=has_img) # 送模型 / 置顶都用这一份 # 用户输入的正文(外接「📝 提示词」优先):接了图片时它是【修改指令】,不是待增强的提示词 body_in = (text or "").strip() or (internal_prompt or "").strip() - has_img = _has_images(images) + base_prompt = build_prompt(output_lang, preset_mode, three_view_text, internal_prompt, + has_image=has_img, io_mode=io_mode) # ③ LLM 反推配置(来自 manager_settings.llm;输出语言复用节点表面参数) llm = parse_llm_settings(_llm_section(manager_settings)) @@ -331,8 +372,10 @@ class XB_ImagePromptPresetPro: task_hint = "" if has_img: task_hint = _TASK_IMAGE_EDIT_HINT if body_in else _TASK_IMAGE_ONLY_HINT + skill_body = effective_skill_text(skill_mode, skill_name, preset_mode) # 不用/手动/自动 + skill_used = effective_skill_name(skill_mode, skill_name, preset_mode) full_system = _build_system_prompt(preset, llm["extra_system"], llm["output_lang"], - preset_text, task_hint) + preset_eff, task_hint, skill_body) run = llm["run"] seed = int(run["seed"]) next_seed = _next_seed(seed, run["seed_control"]) @@ -394,20 +437,21 @@ class XB_ImagePromptPresetPro: raw_out = _ask(user_text) body_out = _clean(raw_out) if body_out != raw_out.strip(): - print(f"[XB-生图预设Pro] 🧠 已过滤思考过程:{len(raw_out)} 字 → {len(body_out)} 字") + safe_print(f"[XB-生图预设Pro] 🧠 已过滤思考过程:{len(raw_out)} 字 → {len(body_out)} 字") # ④c2 看图改图:若仍写成「原本…被替换为…」的对比式说明 → 自动重试一次 if has_img and body_in and _leaks_modification_talk(body_out): - print("[XB-生图预设Pro] ⚠️ 输出含对比式修改说明,自动重试一次…") + safe_print("[XB-生图预设Pro] ⚠️ 输出含对比式修改说明,自动重试一次…") cand = _clean(_ask(user_text + _RETRY_REMINDER, seed_shift=1)) if cand.strip() and not _leaks_modification_talk(cand): body_out = cand - print("[XB-生图预设Pro] ✅ 重试后已得到直接的最终画面描述") + safe_print("[XB-生图预设Pro] ✅ 重试后已得到直接的最终画面描述") else: - print("[XB-生图预设Pro] ⚠️ 重试仍不合格,保留本次输出") + safe_print("[XB-生图预设Pro] ⚠️ 重试仍不合格,保留本次输出") # ④d 设定词**原封不动**加在增强结果最顶端(与未启用 LLM 时的成句规则完全一致) - final_prompt = build_prompt(output_lang, preset_mode, three_view_text, body_out) + final_prompt = build_prompt(output_lang, preset_mode, three_view_text, body_out, + has_image=has_img, io_mode=io_mode) else: # 原「🖼️ 生图提示词预设」行为:直接输出拼装好的提示词 final_prompt = base_prompt @@ -422,7 +466,10 @@ class XB_ImagePromptPresetPro: f"丨数量 {shape[0] if shape else batch_size}丨空latent {shape}" f"丨模式 {preset_mode}丨设定词 {'自定义' if (preset_text and three_view_text and str(three_view_text).strip()) else ('默认' if preset_text else '无')}" f"丨语言 {output_lang}丨任务 {'看图改图' if (has_img and body_in) else ('看图反推' if has_img else '文生文')}" - f"丨LLM {'启用' if use_llm else '关闭'}({backend})丨提示词 {len(final_prompt)} 字") + f"丨LLM {'启用' if use_llm else '关闭'}({backend})丨SKILL {skill_used}({len(skill_body)} 字·{skill_mode_of(skill_mode)})" + f"丨模版 {io_eff}" + f"{'(自动' + (':检测到参考图' if has_img else ':未接参考图') + ')' if io_mode_of(io_mode) == IO_MODE_AUTO else ''}" + f"丨提示词 {len(final_prompt)} 字") ui = { "text": [final_prompt], diff --git a/nodes_qwen_prompt_preset.py b/nodes_qwen_prompt_preset.py new file mode 100644 index 0000000..6c7c120 --- /dev/null +++ b/nodes_qwen_prompt_preset.py @@ -0,0 +1,571 @@ +""" +XB-BOX - 🖼️ Qwen2.1提示词预设 +============================== +「XB-BOX - 🖼️ 生图提示词预设Pro」的兄弟节点:保留生图预设与元素面板, +把「LLM 反推」换成 **官方 Load CLIP + 官方 Generate Text** 这套纯本地文本生成链路。 + +① 生图预设 + · 空latent类型(9 种主流模型,逐字段对齐官方 latent_format) + · 输出语言 / 预设模式 / 设定词(三视图·四视图·五视图·背景纯透明) + · 画幅比例 / 宽度 / 高度 / 生成数量 + · 元素面板提示词拼装(8 大类词表,前端 js/xb_qwen_prompt_preset.js) + +② 文本生成(官方两个节点的参数,逐字对齐;没有开关,执行即生成) + · 模型选择 = 官方 Load CLIP:clip_name(models/text_encoders)+ type(默认 qwen_image)+ device(默认 default) + · 参数设置 = 官方 Generate Text 全量参数: + max_length / sampling_mode(on·off)/ temperature / top_k / top_p / min_p / + repetition_penalty / presence_penalty / seed / thinking / use_default_template / mtp + * 入参 prompt = 【参考设定·设定词】+【正文】+【额外要求·追加设定】 + · 出参 = 设定词原封不动置顶 + 生成文本(与「生图提示词预设」的成句规则一致) + · SKILL选择 = support_llama/skills 里的 txt,整段作为官方 Generate Text 的 system_prompt 送进模型 + (与官方同义:use_default_template = 关时 system_prompt 被忽略) + +输出:提示词 / 空latent(只有两路;没有 提示词列表 / 提示词设定) + +CATEGORY: XB_ToolBox/Image_Params +""" + +import gc +import json + +import folder_paths +import torch + +import comfy.sd + +from .nodes_image_prompt_preset import ( + ASPECT_RATIO_OPTIONS, + BATCH_MAX, + DEFAULT_LATENT_KIND, + IO_MODE_AUTO, + IO_MODES, + LATENT_KINDS, + MAX_RESOLUTION, + OUTPUT_LANGS, + PRESET_MODES, + PRESET_TEXT, + SIZE_MIN, + SIZE_STEP, + THREE_VIEW_TEXT, + build_empty_latent, + build_prompt, + io_mode_of, + latent_kind_spec, + mode_needs_image, + normalize_mode, + normalize_size, + preset_text_of, + resolve_io_mode, + safe_print, + skill_mode_of, +) +from .nodes_image_prompt_preset import (SKILL_MODE_AUTO, SKILL_MODES, effective_skill_name, effective_skill_text) + +from .nodes_image_prompt_preset import SKILL_MODE_AUTO, SKILL_MODES, effective_skill_name, effective_skill_text +from .xb_skills import SKILL_NONE as _SKILL_NONE +from .xb_skills import skill_list as _skill_lister +from .xb_skills import skill_path as _skill_pather +from .xb_skills import skill_sig as _skill_signer +from .xb_skills import skill_text as _read_skill_text + +DEFAULT_LANG = OUTPUT_LANGS[0] +# 用户要求:本节点的空latent类型默认 Qwen-image(与节点定位一致) +DEFAULT_LATENT = "Qwen-image" if "Qwen-image" in LATENT_KINDS else DEFAULT_LATENT_KIND + +# ══════════════════════════════════════════════════════════════ +# SKILL(技能文件 = 官方 Generate Text 的 system_prompt) +# 读取器在 xb_skills.py(与「生图提示词预设Pro」共用一份) +# ══════════════════════════════════════════════════════════════ + +SKILL_NONE = _SKILL_NONE # combo 第一项 = 不接技能(与旧工作流行为一致) + + +def skill_list(): + return _skill_lister() + + +def _skill_path(name): + return _skill_pather(name) + + +def _skill_sig(name): + return _skill_signer(name) + + +def _skill_text(name): + return _read_skill_text(name, "[XB-Qwen提示词预设]") + +# ═══════════════════════════════════════════════════════════════ +# 官方节点参数表(逐字复制,升级 ComfyUI 后如果官方枚举变了只需改这里) +# ═══════════════════════════════════════════════════════════════ + +# nodes.py::CLIPLoader 的 type 枚举 +CLIP_TYPES = [ + "stable_diffusion", "stable_cascade", "sd3", "stable_audio", "mochi", "ltxv", "pixart", + "cosmos", "lumina2", "wan", "hidream", "chroma", "ace", "omnigen2", "qwen_image", + "hunyuan_image", "flux2", "ovis", "longcat_image", "cogvideox", "lens", "pixeldit", + "ideogram4", "boogu", "krea2", "joyimage", "mage", "minimax", "yue2", +] +DEFAULT_CLIP_TYPE = "qwen_image" # 用户要求:类型默认 Qwen_image +CLIP_DEVICES = ["default", "cpu"] # nodes.py::CLIPLoader 的 device 枚举 +DEFAULT_DEVICE = "default" # 用户要求:设备默认 default + +# comfy_extras/nodes_textgen.py::TextGenerate 的参数 +SAMPLING_MODES = ["on", "off"] +MTP_MODES = ["auto", "off", "2", "3", "4", "5"] +_SEED_MAX = 0xFFFFFFFFFFFFFFFF + +# 输出语言提示(拼进送模型的 prompt;本节点不依赖其它模块,自己留一份) +_LANG_HINT = { + "中文 [ZH]": "【输出语言】请始终使用中文输出。", + "英文 [EN]": "【Output language】Always respond in English.", +} + +_SETTING_KEYS = ("latent_kind", "output_lang", "preset_mode", "io_mode", "three_view_text", "aspect_ratio", + "width", "height", "batch_size", "clip_name", "clip_type", "device", + "max_length", "sampling_mode", "temperature", "top_k", "top_p", "min_p", + "repetition_penalty", "presence_penalty", "seed", "thinking", + "use_default_template", "mtp", "skill_name", "skill_mode", "internal_prompt") + + +def text_encoder_list(): + """官方 Load CLIP 的模型列表(models/text_encoders)""" + try: + return folder_paths.get_filename_list("text_encoders") + except Exception: + return [] + + +# ═══════════════════════════════════════════════════════════════ +# 配置解析(None / 缺失 / 非法一律安全回落) +# ═══════════════════════════════════════════════════════════════ + +DEFAULT_SETTINGS = { + "extra_settings": "", # 追加设定 +} + + +def parse_settings(raw): + """manager_settings(JSON 字符串 / 字典 / None)→ 完整配置 + + 配置段在 `manager_settings.qwen` 下(与前端 xbrParseQwen / xbrSaveJson 对齐); + 为兼容早期写在顶层的写法,两处都读,qwen 优先。 + ⚠️ `extra_settings`(追加设定)的**面板入口已按用户要求删除**,这里保留读取只为兼容 + 旧工作流里已经填过的值(新节点永远是空串,等于不追加【额外要求】)。 + """ + data = {} + if isinstance(raw, dict): + data = raw + elif isinstance(raw, str) and raw.strip(): + try: + parsed = json.loads(raw) + if isinstance(parsed, dict): + data = parsed + except Exception: + data = {} + sec = data.get("qwen") if isinstance(data.get("qwen"), dict) else {} + extra = sec.get("extra_settings") + if extra is None: + extra = data.get("extra_settings") # 兼容:早期版本写在顶层 + return {"extra_settings": str(extra or "")} + + +def _num(value, default, lo=None, hi=None): + try: + v = float(value) + except (TypeError, ValueError): + return default + if v != v: # NaN + return default + if lo is not None and v < lo: + v = lo + if hi is not None and v > hi: + v = hi + return v + + +def _pick(value, allowed, default): + return value if value in allowed else default + + +def _file_sig(name): + """模型文件的 大小 + 修改时间(换文件即失效,不必手动清缓存)""" + try: + path = folder_paths.get_full_path("text_encoders", name) + if not path: + return "" + import os + st = os.stat(path) + return f"{st.st_size}:{int(st.st_mtime)}" + except Exception: + return "" + + +# ═══════════════════════════════════════════════════════════════ +# 提示词组装(与「生图提示词预设」一致:设定词原封不动置顶) +# ═══════════════════════════════════════════════════════════════ + +def _preset_text_of(preset_mode, output_lang, three_view_text, io_mode=None, has_image=False): + """当前预设模式的设定词(按模版分两套) + + · 用户改过 → 用改过的; + · 没改过(等于任一默认)→ 按当前模版(文生图 / 图生图)取默认, + 避免老工作流把「图生图版」文本(如 360°全景 的「把输入的单视角照片扩展成…」)带进文生图。 + """ + return preset_text_of(preset_mode, output_lang, three_view_text, + io_mode=io_mode, has_image=has_image) + + +def build_model_prompt(body, preset_text, extra_settings, output_lang, with_lang_hint=True): + """送给 Generate Text 的 prompt:「参考设定 + 正文 + 额外要求」三段式 + + `with_lang_hint=False`(选中了 SKILL 时)不叠加语言提示——技能文件自带语言决策, + 再叠一条「始终用中文」会直接违背技能(与「生图提示词预设Pro」的处理一致)。 + """ + parts = [] + ref = (preset_text or "").strip() + if ref: + parts.append("【参考设定】\n" + ref + + "\n(上面这段设定会被原样加在最终提示词最顶端,不需要你复述或改写它)") + parts.append("【正文】\n" + ((body or "").strip() or "")) + extra = (extra_settings or "").strip() + if extra: + parts.append("【额外要求】\n" + extra) + if with_lang_hint: + parts.append(_LANG_HINT.get(output_lang, _LANG_HINT[DEFAULT_LANG])) + return "\n\n".join([p for p in parts if p]) + + +# ═══════════════════════════════════════════════════════════════ +# CLIP 加载(逐行对齐官方 nodes.py::CLIPLoader)+ 单条缓存 +# ═══════════════════════════════════════════════════════════════ + +_CLIP_CACHE = {"key": None, "clip": None} + + +def _load_clip(clip_name, clip_type, device): + clip_type_value = getattr(comfy.sd.CLIPType, str(clip_type).upper(), comfy.sd.CLIPType.STABLE_DIFFUSION) + model_options = {} + if device == "cpu": + model_options["load_device"] = model_options["offload_device"] = torch.device("cpu") + clip_path = folder_paths.get_full_path_or_raise("text_encoders", clip_name) + return comfy.sd.load_clip( + ckpt_paths=[clip_path], + embedding_directory=folder_paths.get_folder_paths("embeddings"), + clip_type=clip_type_value, + model_options=model_options, + ) + + +def get_clip(clip_name, clip_type, device): + """取 CLIP:同参数复用缓存,换参数才重新加载(只留 1 份,避免显存里堆一堆文本编码器)""" + key = (str(clip_name or ""), str(clip_type or ""), str(device or "")) + if _CLIP_CACHE["clip"] is not None and _CLIP_CACHE["key"] == key: + return _CLIP_CACHE["clip"] + if not key[0]: + raise ValueError( + "[XB-Qwen提示词预设] 未找到文本编码器模型\n" + "请把文本编码器(例如 Qwen-Image 的 text encoder)放进 ComfyUI/models/text_encoders,\n" + "再点节点上的「🤖 LLM设置」→ 模型选择 里选它。" + ) + if _CLIP_CACHE["clip"] is not None: + _CLIP_CACHE["clip"] = None + gc.collect() + try: + import comfy.model_management + comfy.model_management.soft_empty_cache() + except Exception: + pass + clip = _load_clip(key[0], key[1], key[2]) + _CLIP_CACHE["key"] = key + _CLIP_CACHE["clip"] = clip + return clip + + +# ═══════════════════════════════════════════════════════════════ +# 节点 +# ═══════════════════════════════════════════════════════════════ + +class XB_QwenPromptPreset: + """Qwen2.1提示词预设:生图预设(提示词拼装 + 空latent)+ 官方 Load CLIP + 官方 Generate Text。""" + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + # ══ ① 生图预设(与「🖼️ 生图提示词预设」一致)══ + "latent_kind": (list(LATENT_KINDS), { + "default": DEFAULT_LATENT, + "tooltip": "空latent类型:选你正在用的模型即可(自动适配形状/下采样/步长)", + }), + "output_lang": (list(OUTPUT_LANGS), { + "default": DEFAULT_LANG, + "tooltip": "输出语言:决定词表与设定词按哪种语言加载、生成文本的语言", + }), + "io_mode": (list(IO_MODES), { + "default": IO_MODE_AUTO, + "tooltip": "模版:自动 = 按有没有接参考图判断;文生图 / 图生图 = 手动指定。" + "每个预设模式都有两套设定词,按它自动选用(模板切换会直接把设定词换成对应那套)", + }), + "preset_mode": (list(PRESET_MODES), { + "default": PRESET_MODES[0], + "tooltip": "预设模式:无预设=不前置任何设定词,只输出正文;其余档位把该档设定词置顶到最终提示词最顶端(设定词分文生图 / 图生图两套)", + }), + "three_view_text": ("STRING", { + "default": THREE_VIEW_TEXT[DEFAULT_LANG], "multiline": True, + "tooltip": "设定词:预设模式对应的设定文本;改过的按「模式 + 语言」记进节点,换模式 / 换语言都不会丢", + }), + "skill_mode": (list(SKILL_MODES), { + "default": SKILL_MODE_AUTO, + "tooltip": "SKILL 模式:自动 = 按预设模式适配(图生图档 → prompt_edit,其余 → prompt_t2i);" + "手动 = 用下面选中的那一个;不用 = SKILL 完全不生效(哪怕选了也不生效)", + }), + "skill_name": (skill_list(), { + "default": SKILL_NONE, + "tooltip": "SKILL选择:support_llama/skills 里的 txt,整段作为模型自带的系统提示词(官方 Generate Text 的 system_prompt);" + "仅在「SKILL 模式 = 手动」时生效;「使用内置模板」关闭时也不生效", + }), + "aspect_ratio": (list(ASPECT_RATIO_OPTIONS), { + "default": "Free", + "tooltip": "画幅比例:Free=自由(仅按步长锁定);固定比例时按较大的一边反算另一边", + }), + "width": ("INT", { + "default": 1024, "min": SIZE_MIN, "max": MAX_RESOLUTION, "step": SIZE_STEP, + "tooltip": "图片宽度(步长按「空latent类型」的官方最小步长 8/16/32)", + }), + "height": ("INT", { + "default": 1024, "min": SIZE_MIN, "max": MAX_RESOLUTION, "step": SIZE_STEP, + "tooltip": "图片高度(步长按「空latent类型」的官方最小步长 8/16/32)", + }), + "batch_size": ("INT", { + "default": 1, "min": 1, "max": BATCH_MAX, + "tooltip": "一次生成的图片数量(空 latent 的 batch 维度)", + }), + + # ══ ② 官方 Load CLIP(模型选择)══ + "clip_name": (text_encoder_list(), { + "tooltip": "文本编码器(models/text_encoders)。与官方 Load CLIP 同一个列表。", + }), + "clip_type": (list(CLIP_TYPES), { + "default": DEFAULT_CLIP_TYPE, + "tooltip": "类型:与官方 Load CLIP 一致;Qwen-Image 系文本编码器选 qwen_image", + }), + "device": (list(CLIP_DEVICES), { + "default": DEFAULT_DEVICE, + "tooltip": "设备:与官方 Load CLIP 一致;default=自动,cpu=放内存", + }), + + # ══ ③ 官方 Generate Text(参数设置,全量)══ + "max_length": ("INT", { + "default": 512, "min": 1, "max": 32768, + "tooltip": "Generate Text · max_length:最大生成长度", + }), + "sampling_mode": (list(SAMPLING_MODES), { + "default": "on", + "tooltip": "Generate Text · Sampling Mode:on=按下面的采样参数生成;off=贪心解码,下面的采样参数不生效", + }), + "temperature": ("FLOAT", { + "default": 0.7, "min": 0.01, "max": 2.0, "step": 0.000001, + "tooltip": "Generate Text · temperature", + }), + "top_k": ("INT", { + "default": 64, "min": 0, "max": 1000, + "tooltip": "Generate Text · top_k", + }), + "top_p": ("FLOAT", { + "default": 0.95, "min": 0.0, "max": 1.0, "step": 0.01, + "tooltip": "Generate Text · top_p", + }), + "min_p": ("FLOAT", { + "default": 0.05, "min": 0.0, "max": 1.0, "step": 0.01, + "tooltip": "Generate Text · min_p", + }), + "repetition_penalty": ("FLOAT", { + "default": 1.05, "min": 0.0, "max": 5.0, "step": 0.01, + "tooltip": "Generate Text · repetition_penalty", + }), + "presence_penalty": ("FLOAT", { + "default": 0.0, "min": 0.0, "max": 5.0, "step": 0.01, + "tooltip": "Generate Text · presence_penalty", + }), + "seed": ("INT", { + "default": 0, "min": 0, "max": _SEED_MAX, + "tooltip": "Generate Text · seed:生成种子(节点表面可点 🔁 切换生成后控制)", + }), + "thinking": ("BOOLEAN", { + "default": False, + "label_on": "🧠 思考模式 开", "label_off": "🧠 思考模式 关", + "tooltip": "Generate Text · thinking:模型支持时以思考模式生成(思考内容不会进入输出)", + }), + "use_default_template": ("BOOLEAN", { + "default": True, + "label_on": "使用内置模板", "label_off": "不使用内置模板", + "tooltip": "Generate Text · use_default_template:使用模型自带的系统提示词 / 对话模板", + }), + "mtp": (list(MTP_MODES), { + "default": "auto", + "tooltip": "Generate Text · mtp:多 token 预测投机解码;没有 MTP 权重时无效", + }), + + # ══ ④ 内部字段(节点表面由前端面板隐藏)══ + "internal_prompt": ("STRING", { + "default": "", "multiline": True, + "tooltip": "节点内拼装/编辑的提示词正文(随工作流保存)", + }), + "manager_settings": ("STRING", { + "default": "", "multiline": True, + "tooltip": "本节点配置 JSON:元素面板 elements + 追加设定 extra_settings + 生成后控制 run", + }), + }, + "optional": { + "text": ("STRING", { + "forceInput": True, + "tooltip": "外接提示词(优先于节点上的提示词框;接上线后提示词框锁定)", + }), + "images": ("IMAGE", { + "tooltip": "外接图像:一批图 = N 张参考图(官方图像编辑最多 10 张,提示词里可引用「第 N 张图」)。" + "用「📦 批量图像」等多图节点直接接这里即可。", + }), + }, + "hidden": { + "unique_id": "UNIQUE_ID", + }, + } + + RETURN_TYPES = ("STRING", "LATENT") + RETURN_NAMES = ("提示词", "空latent") + OUTPUT_IS_LIST = (False, False) + FUNCTION = "generate" + CATEGORY = "XB_ToolBox/Image_Params" + DESCRIPTION = ("Qwen2.1提示词预设:生图预设(空latent类型/输出语言/预设模式/画幅参数 + 8 大类元素面板拼装)" + "+ 官方 Load CLIP 与官方 Generate Text(纯本地文本生成,无开关,执行即生成)。" + "输出:提示词 / 空latent。") + + @classmethod + def IS_CHANGED(cls, clip_name="", skill_name=SKILL_NONE, skill_mode=SKILL_MODE_AUTO, + preset_mode="", **kwargs): + sig = {k: kwargs.get(k) for k in _SETTING_KEYS} + used = effective_skill_name(skill_mode, skill_name, preset_mode) + return f"{json.dumps(sig, sort_keys=True, ensure_ascii=False, default=str)}|{_file_sig(clip_name)}|{_skill_sig(used)}" + + def generate(self, latent_kind, output_lang, preset_mode, three_view_text, aspect_ratio, + width, height, batch_size, + clip_name, clip_type, device, + max_length, sampling_mode, temperature, top_k, top_p, min_p, + repetition_penalty, presence_penalty, seed, thinking, use_default_template, mtp, + internal_prompt="", manager_settings="", text="", images=None, unique_id=None, + skill_name=SKILL_NONE, skill_mode=SKILL_MODE_AUTO, io_mode=IO_MODE_AUTO): + # ① 画幅归一 + 空 latent(与「🖼️ 生图提示词预设」逐行一致) + spec = latent_kind_spec(latent_kind) + w, h = normalize_size(aspect_ratio, width, height, spec["step"], spec["min"], spec["max"]) + latent = build_empty_latent(latent_kind, w, h, batch_size) + + # ② 组装送模型的 prompt(设定词只作参考,输出时节点自己置顶) + st = parse_settings(manager_settings) + lang = output_lang if output_lang in OUTPUT_LANGS else DEFAULT_LANG + # 有没有接输入图 + 模版(自动/文生图/图生图)→ 决定用哪一套设定词 + has_img = False + try: + has_img = images is not None and hasattr(images, "shape") and len(images) > 0 + except Exception: + has_img = False + io_eff = resolve_io_mode(io_mode, has_img) # 文生图 / 图生图 + base_prompt = build_prompt(lang, preset_mode, three_view_text, internal_prompt, + has_image=has_img, io_mode=io_mode) + preset_eff = _preset_text_of(preset_mode, lang, three_view_text, + io_mode=io_mode, has_image=has_img) # 送模型 / 置顶都用这一份 + body = (text or "").strip() or (internal_prompt or "").strip() + # SKILL:不用 / 手动 / 自动(默认自动,按预设模式适配) + skill_text = effective_skill_text(skill_mode, skill_name, preset_mode, use_default_template) + skill_used = effective_skill_name(skill_mode, skill_name, preset_mode) + # 选中 SKILL 时不再叠加语言提示(技能自带语言决策,叠加会互相打架) + model_prompt = build_model_prompt(body or base_prompt, preset_eff, st["extra_settings"], lang, + with_lang_hint=not bool(skill_text)) + + # ③ 官方 Load CLIP + 官方 Generate Text(参数与调用方式逐字对齐官方节点) + clip = get_clip(clip_name, clip_type, device) + # 官方 image 输入:一批图 = N 张参考图(官方图像编辑最多 10 张,提示词可引用「第 N 张图」) + # ⚠️ 不能把多帧当 video:Qwen-Image-2.1 的文本编码器链里**没人读 video** + # (qwen_image21/qwen3vl/sd1_clip 全程不碰 video,全库只有 gemma4 那个 tokenizer 认), + # 一旦当成 video 传,图片会被静默丢弃 → 模型只看得到文字。 + image_arg = images if (images is not None and hasattr(images, "shape")) else None + img_count = 0 + try: + if image_arg is not None: + img_count = int(len(image_arg)) + except Exception: + img_count = 0 + if mode_needs_image(preset_mode) and img_count == 0: + safe_print(f"[XB-Qwen提示词预设] ⚠️ 当前档位「{preset_mode}」属于图生图,但没有接参考图" + f"(🖼️ 图像,多张按批接即可)→ 本次按「文生图」设定词纯文字生成") + try: # 日志留痕:看得见“到底送了什么给文本编码器” + print(f"[XB-Qwen提示词预设] 送模型 prompt({len(model_prompt)} 字):" + f"{model_prompt[:120].replace(chr(10), ' / ')}" + ("…" if len(model_prompt) > 120 else "")) + except Exception: + pass + # SKILL 已在上方读入 skill_text,并在那里决定了要不要叠加语言提示 + tokens = clip.tokenize( + model_prompt, + image=image_arg, + skip_template=not bool(use_default_template), + min_length=1, + thinking=bool(thinking), + system_prompt=skill_text, + ) + _mtp = False if mtp == "off" else (True if mtp == "auto" else int(mtp)) + generated_ids = clip.generate( + tokens, + do_sample=(sampling_mode == "on"), + max_length=int(max_length), + temperature=float(temperature), + top_k=int(top_k), + top_p=float(top_p), + min_p=float(min_p), + repetition_penalty=float(repetition_penalty), + presence_penalty=float(presence_penalty), + seed=int(seed), + mtp=_mtp, + ) + raw = clip.decode(generated_ids) or "" + + # 思考段分离(与官方 TextGenerate 完全一致:不闭合时整段都算正文) + reasoning, _, gen_text = raw.partition("") + if not reasoning.lstrip().startswith("") and not model_prompt.rstrip().endswith(""): + gen_text = raw + gen_text = gen_text.strip() + + # ④ 设定词原封不动置顶(与未生成时的成句规则一致;t2i/i2i 模版条款同样进最终提示词) + final_prompt = build_prompt(lang, preset_mode, three_view_text, gen_text, + has_image=has_img, io_mode=io_mode) + + try: + shape = tuple(latent.get("samples").shape) + except Exception: + shape = () + print(f"[XB-Qwen提示词预设] {spec['label']} 丨 {w}x{h}({aspect_ratio}·步长 {spec['step']})" + f"丨数量 {shape[0] if shape else batch_size}丨空latent {shape}" + f"丨CLIP {clip_name}({clip_type}·{device})" + f"丨Text 最大长度 {max_length}·采样 {sampling_mode}·思考 {'开' if thinking else '关'}" + f"丨SKILL {skill_used}({len(skill_text)} 字·{skill_mode_of(skill_mode)})" + f"丨模式 {preset_mode}丨模版 {io_eff}" + f"{'(自动' + (':检测到参考图' if has_img else ':未接参考图') + ')' if io_mode_of(io_mode) == IO_MODE_AUTO else ''}" + f"丨参考图 {img_count} 张" + f"丨种子 {seed}丨设定词 {'有' if preset_eff else '无'}丨生成 {len(gen_text)} 字丨输出 {len(final_prompt)} 字") + + ui = { + "text": [final_prompt], + "model": [str(clip_name or "-")], + "gen_len": [len(gen_text)], + } + # 种子回写不在后端做:节点上的 seed widget 由 ComfyUI 原生的 + # control_after_generate 机制自动游走(与官方采样节点一致,避免双头写) + return {"ui": ui, "result": (final_prompt, latent)} + + +NODE_CLASS_MAPPINGS = { + "XB_QwenPromptPreset": XB_QwenPromptPreset, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "XB_QwenPromptPreset": "XB-BOX - 🖼️ Qwen2.1提示词预设", +} diff --git a/support_llama/skills/system_prompt_edit.txt b/support_llama/skills/system_prompt_edit.txt new file mode 100644 index 0000000..424fcfb --- /dev/null +++ b/support_llama/skills/system_prompt_edit.txt @@ -0,0 +1,205 @@ +# Edit Prompt Enhancer — General (v2, 精简版) + +**FIRST — there are TWO separate language decisions. Do NOT conflate them.** + +**(A) Language of the rewritten prompt's DESCRIPTIVE prose — every word OUTSIDE double quotes (the description you write for the diffusion model, NOT the text painted into the image). This decision is final and non-negotiable:** +- User instruction is in Chinese → write the description in Chinese. +- User instruction is in English → write the description in English. +- User instruction is in ANY other language (Japanese, Korean, French, Spanish, Thai, etc.) → write the description in English. + +**(B) Language of the TEXT THAT WILL BE RENDERED INTO THE OUTPUT IMAGE — the content INSIDE double quotes. Decide it in this strict priority order:** +1. If the user's instruction gives the exact text to write, OR names a target language for the text (e.g. "改成'夏日特惠'", "把标题写成英文", "add a Japanese title", "write the caption in Thai") → render exactly that text / in exactly that specified language. +2. Otherwise, if the input image already contains text → render in the DOMINANT language of the image's existing text — even when the instruction is written in a different language. +3. Otherwise (the image contains no text AND the instruction names no target language) → render in the language of the user's instruction itself — including Japanese, Korean, Thai, Arabic, French, etc. Do NOT force it to English. +Worked example: image is mostly Thai, instruction is in English asking to add/redesign a title without giving the exact words or a language → the rendered (quoted) text must be **Thai** (the image's dominant language), while the surrounding description (A) is still written in English. + +Two reinforcements on decision (B): all rendered (quoted) text must be **monolingual** — do not mix Chinese and English inside the quotes and do not emit a bilingual pair unless the user explicitly asks for one. And **genre never overrides input language**: a "spec sheet / cinematic data-document / storyboard / technical parameter" look is achieved through layout and typography, NOT by switching rendered labels to English — every header, label, and caption stays in the decided language (standardized units and user-given proper nouns may remain Latin). + +You are an expert at clarifying image editing instructions. Given a user's vague or ambiguous edit instruction and the input image(s), rewrite it into a precise, unambiguous, actionable editing directive. An input image is ALWAYS present — this is always an image-editing task, never text-to-image from nothing. + +## Core Objective + +Rewrite the instruction so a downstream image-editing model can execute it without guessing — anchored on what the input image(s) actually show, faithful to the user's intent, inventing nothing. + +**How much you build is intent-branched.** When the user wants *this picture changed* (a local object/attribute/background edit, a text or UI edit, a quality or style change, a viewpoint/canvas transform), clarify and constrain: say exactly what changes, and let everything else stand. When the user wants *a new picture of this subject* (placing a subject in a new scene, compositing across images, a photo-shoot or poster or infographic built from a reference), construct actively: design the scene, lighting, composition and layout to a professional standard. Scale the elaboration to what was asked — a plain placement stays restrained, a styled shoot or a publication-grade poster is built out fully. + +## The Governing Principle — Attribute Disentanglement at Full Strength + +**Edit exactly the attribute(s) the user named, push each to a strong and unmistakable degree, and hold everything else at input fidelity.** + +Both halves matter, and the two failure modes are symmetric: + +- **Leakage** — touching what the user did not name (a sharpen that re-grades color, an upscale that reframes, a style change that drifts a face, an outfit swap that drops an accessory, a background change that "helpfully" cleans up something unmentioned). +- **Under-editing** — an output a viewer could mistake for the unedited input, because the requested change was applied faintly. + +Preservation locks **content, never edit strength**. Recognizability is bought by naming what stays fixed, not by holding the effect back. + +## What to Anchor, What to Decide + +**Anchor on the image.** Every spatial, tonal and contextual claim comes from what is visibly there. If you are unsure a detail exists, leave it out — a preserved element described at a higher level of abstraction is always safer than an invented specific. + +**Say what stays, without repainting it.** Name the untargeted content by type, position and role rather than describing its appearance, and prefer one blanket preservation clause over walking the frame. A preservation description reads to the model as a generation instruction: the more concretely you describe something you meant to keep, the more likely it drifts. Describe appearance concretely only for what you are actually changing, or when it is the only way to disambiguate between similar objects. + +**Identity is the hardest invariant.** A person's facial identity and the personal accessories that make them recognizable; a product's exact design, markings and count; and the input's rendering medium (photograph, anime, illustration, sketch, 3D render, painting) all survive every edit unless the user explicitly targets them. When identity comes from a reference image, point at that image rather than describing features in words — verbal descriptions make the model regenerate and degrade the likeness. + +**Resolve ambiguity, then commit.** Turn vague intent, imprecise spatial reference and unparameterized style words into something concrete and observable. Translate abstract quality language into the visual properties it implies. Where the instruction offers alternatives or contradicts itself, pick the most reasonable reading and state it as a decision. Keep the user's own action verb, spatial relations and described state intact, and treat anything they asked to preserve as absolute. Preserve creative or physically impossible intent rather than correcting it. + +**Only what was asked.** Do not add operations the user did not request, and do not clean up unmentioned defects, overlays or clutter however prominent they look. When an edit removes, moves or reveals something, say enough about the newly exposed region that the result stays physically coherent. + +**Text in the image is literal.** Whenever readable text will appear in the output, commit to the exact characters — every element, quoted, nothing summarized or abbreviated away. Text you cannot commit to should not be added at all. Match the typography and language the input establishes unless the user asks otherwise. When the operation extends the canvas outward, name it as outpainting explicitly. + +**Write it as an instruction.** Lead with the operation, not a description of the finished picture, and write from the perspective of someone holding only the input image(s). + +## Thinking Process + +Before emitting JSON, reason through: what the image(s) actually contain (including a complete reading of any text present); what the user is asking for and which attributes that names; what must therefore stay fixed; the output size; and finally the composed directive. Close with a check that every visible element is either the target of the edit or covered by what stays fixed, that the requested change is unmistakable, that nothing outside the target was touched, and that every quoted string obeys language decision (B). + +## Image Reference Rules + +For Multi-Image Input (N >= 2), the rewritten instruction MUST use ``, ``, ... to refer to each input image. Do not use natural language references like "图1", "第一张图", "the first image", or "image A". This tagging format is mandatory and non-negotiable. For single-image input (N = 1), do NOT use tags — refer to the image naturally ("图像", "图片中", "the image"). + +State each image's role explicitly — which one is the canvas whose composition and untargeted content survive, and which supply material to transfer — and say what is taken from each. For scene generation with no canvas (合影/合照 and the like), all images serve as identity sources. Describe every referenced image individually; never compress several into a range or a group to avoid describing them one by one. + +## Output Size Determination + +You must determine two output fields: `wh_ratio` and `ratio_follow`. These two fields are mutually exclusive — when one has a value, the other must be empty string "". + +### Step 1: Check if the user explicitly specified a size or aspect ratio + +Look for any of the following in the user's edit instruction: +- Exact pixel dimensions: "1920x1080", "800×600", "1080p" +- Aspect ratios: "16:9", "4:3", "3:2", "9:16", "1:1" +- Descriptive terms mapped to aspect ratios: + - "正方形" / "square" / "头像" / "avatar" / "profile picture" / "专辑封面" / "album cover" → "1:1" + - "横版" / "landscape" / "横屏" / "电脑壁纸" / "desktop wallpaper" / "宽屏" / "widescreen" / "视频封面" / "video thumbnail" / "PPT" / "幻灯片" / "slide" / "演示文稿" → "16:9" + - "竖版" / "portrait" / "竖屏" / "手机壁纸" / "phone wallpaper" / "手机屏幕" / "Instagram story" / "Stories" / "Reels" / "短视频封面" → "9:16" + - "手机全面屏" / "全面屏" / "iPhone屏幕" / "iPhone screen" → "18:39" + - "安卓全面屏" / "Android screen" → "9:20" + - "超宽" / "ultrawide" / "带鱼屏" → "7:3" + - "电影画面" / "cinematic" / "电影比例" / "宽银幕" / "cinemascope" → "21:9" + - "海报" / "poster" → "2:3" + - "证件照" / "ID photo" / "passport photo" / "小红书" / "Xiaohongshu" → "3:4" + - "iPad屏幕" / "tablet" / "平板屏幕" → "4:3" + - "全景图" / "panoramic" / "panorama" → "2:1" + - "名片" / "business card" → "9:5" + - "A4" → "5:7"(竖向)or "7:5"(横向) + - "1080p" / "720p" → "16:9" + +**High-resolution keywords ("2K", "4K", "8K") are quality descriptors, NOT aspect ratio indicators.** When the user mentions "2K", "4K", or "8K", these only express a desire for high image quality. They must NOT be used to infer or determine the aspect ratio. The aspect ratio should still be determined by other explicit cues or by the input image's ratio. For output resolution, always use 2K-level resolution regardless of whether the user says "2K", "4K", or "8K". + +If the user specified a size or ratio: +→ `wh_ratio` = the corresponding ratio (e.g., "16:9", "1:1", "3:2") +→ `ratio_follow` = "" + +If the user specified exact pixel dimensions (e.g., "1920x1080"), convert to the simplest integer ratio (1920:1080 = 16:9). + +### Step 2: If the user did NOT specify any size or ratio + +#### Single-image editing (1 input image): +The output should follow the input image's resolution. +→ `wh_ratio` = "" +→ `ratio_follow` = "" + +**Exception — Single-image scene generation**: If the task generates a new scene from scratch using the input image only as an identity reference (e.g., "拍一套写真", "cosplay成X", "穿越到古代"), do NOT follow the input image's ratio — the output is a new composition, not an edit of the existing image. Instead, choose `wh_ratio` by scene semantics: + +| Scene type | wh_ratio | +|---|---| +| Portrait / 写真 / half-body | "2:3" | +| Full-body scene / outdoor activity | "3:4" | +| Landscape-oriented scene | "3:2" | +| No clear orientation hint | Follow the input image's ratio (set `ratio_follow` to ``, `wh_ratio` to "") | + +#### Multi-image editing (N ≥ 2 input images): +You must identify the **canvas image** (the image whose composition and framing the output should follow), then set `ratio_follow` to that image's tag. + +| Edit type | Canvas | ratio_follow | +|---|---|---| +| Compositing — transfer subject into a scene ("把A P到B中", "放到", "加入到") | The target scene image | "" (scene image number) | +| Face/head swap ("换脸", "换头") | The body image | "" (body image number) | +| Clothing swap ("换衣服", "换装") | The person image | "" (person image number) | +| Style transfer ("画成X的风格", "风格迁移") | The content image (not the style reference) | "" (content image number) | +| Background replacement | The foreground subject image | "" (subject image number) | +| Local object replacement | The original image being edited | "" (original image number) | +| Scene generation — no canvas ("合影", "合照", "一起变老", "让他们X") | No canvas — you must choose a ratio | See below | + +For **scene generation tasks with no canvas** (合影, 合照, 一起吃饭, etc.), set `ratio_follow` = "" and choose `wh_ratio` by scene semantics: + +| Scene type | wh_ratio | +|---|---| +| Group photo / 合影 / 合照 | "3:2" | +| Portrait / 写真 | "2:3" | +| Poster / 海报 | "2:3" | +| Desktop wallpaper | "16:9" | +| Phone wallpaper | "9:16" | +| No clear orientation hint | Follow the last input image's ratio (set `ratio_follow` to the last image, `wh_ratio` to "") | + +#### Outpainting (扩图 / 延伸画面): + +For outpainting tasks where the user did NOT specify a target aspect ratio, do NOT simply follow the input image's ratio — outpainting changes the image's proportions by definition. Instead, infer the new ratio from the extension direction: + +- Extend **right only** or **left only**: widen the ratio. E.g., a 1:1 input → "3:2"; a 3:4 input → "1:1" or "4:3". +- Extend **both left and right**: widen more aggressively. E.g., a 1:1 input → "16:9" or "2:1". +- Extend **down only** or **up only**: make the ratio taller. E.g., a 1:1 input → "2:3"; a 16:9 input → "4:3" or "1:1". +- Extend **both up and down**: make the ratio significantly taller. E.g., a 1:1 input → "9:16". +- Extend **all sides**: keep the original ratio (the image grows uniformly). + +As a general rule, estimate the extended area as roughly 30%–50% additional space in the specified direction(s), then compute the new W:H ratio accordingly. Set `ratio_follow` = "" and `wh_ratio` = the inferred ratio. + +#### Panoramic generation (全景 / panorama): + +| Panoramic type | wh_ratio | +|---|---| +| Standard panorama / 全景 | "2:1" | +| Wide panorama / 超宽全景 | "3:1" | +| 360° / VR panorama | "2:1" | +| User specified a different ratio | Use the user's specified ratio | + +Set `ratio_follow` = "". + +#### Three-view drawings and multi-grid generation (三视图 / 多宫格): + +For three-view or multi-panel grid generation where the user did NOT specify an aspect ratio, do NOT use a fixed default. Determine it adaptively from: + +1. **Subject shape proportion**: a tall standing person is vertically oriented, a car is horizontally oriented, a round object roughly square. +2. **Panel layout arrangement**: how the panels are arranged (1×3 horizontal, 3×1 vertical, 2×2) and the shape of each panel. +3. **Combined ratio**: (single panel W × columns) : (single panel H × rows), choosing the ratio that best fits the content without excessive empty space or cropping. + +Examples: +- Three side-by-side views of a standing person (each panel ~1:3, portrait) → overall ratio = "1:1" — do NOT over-widen to "2:1" or "3:1", which would squash each portrait panel (use "3:1" only when each panel is itself landscape, e.g., a car) +- Three side-by-side views of a car (each panel ~3:2) → overall ratio = "3:1" or "9:2" +- 2×2 grid of a square object → overall ratio = "1:1" +- 3×3 grid of square panels → overall ratio = "1:1" + +Set `ratio_follow` = "" and `wh_ratio` = the adaptively determined ratio. + +## Output Format +Output a valid JSON object with exactly three fields: +```json +{ + "rewritten_prompt": "", + "wh_ratio": "", + "ratio_follow": "<'' / '' / ... / ''>" +} +``` + +`rewritten_prompt` formatting rules: +- The entire rewritten prompt must be a single continuous paragraph with NO line breaks or newline characters (`\n`). +- All text that should appear as visible, readable content in the output image must be enclosed in double quotes (""). Descriptive or structural language that does not appear as rendered text should NOT be quoted. +- **Never include any resolution or aspect ratio information in `rewritten_prompt`** (e.g., "2:3", "16:9", "1920x1080", "2K", "4K"). Resolution and aspect ratio are conveyed exclusively through the `wh_ratio` and `ratio_follow` fields. +- Write it out in full — no ellipsis, no truncation. +- State requirements affirmatively ("保持背景与输入图完全一致") rather than as prohibitions ("禁止改变背景"). Standard preservation phrasing "保持/保留[X]不变" is fine. +- Be precise and decisive: no hedging, no unresolved alternatives, no vague degree words left unresolved. +- **Language-purge self-check (do this last)**: re-scan every double-quoted string — the text that will be RENDERED in the image — and enforce language decision (B). No quoted string may mix Chinese and English, form a bilingual pair, or carry a parenthetical translation gloss unless the user explicitly asked. Standardized units and user-given proper nouns may remain Latin. + +Rules for each field: +- `rewritten_prompt`: The rewritten editing instruction. The descriptive prose (outside double quotes) follows language decision (A); the text rendered inside the image (inside double quotes) follows language decision (B). Retain proper nouns and domain-specific terms in their original language, placed in English double quotes. +- `wh_ratio`: The target aspect ratio as "W:H". Set to "" when the output resolution should follow an input image instead. +- `ratio_follow`: Which input image's resolution the output should follow ("", "", …). Set to "" when a specific aspect ratio is provided in `wh_ratio`. + +Mutual exclusivity rule: +- If `wh_ratio` has a value → `ratio_follow` must be "" +- If `ratio_follow` is "" → `wh_ratio` must be "" + +Do not include any text outside the JSON object — no greetings, no explanations, no markdown code fences. + +The user's edit instruction to rewrite is: diff --git a/support_llama/skills/system_prompt_t2i.txt b/support_llama/skills/system_prompt_t2i.txt new file mode 100644 index 0000000..7fceb85 --- /dev/null +++ b/support_llama/skills/system_prompt_t2i.txt @@ -0,0 +1,192 @@ +# Image Prompt Rewriting Expert + +You turn a user's image request into one long English paragraph that describes the +finished image as if you were looking at it, plus the aspect ratio it should be +rendered at. You are not talking to the user and not talking to a renderer: you are +an observer reporting what is in the frame. + +Work through the eight steps below in order. Each step commits one decision; later +steps never revise an earlier one. + +## Step 1 — Read the brief and split it in two + +List what the user has fixed and what they have left open. + +Fixed, and it must survive into your description unchanged: every string of text +they want shown, every named object, every count, every stated colour, every stated +position, and the aspect ratio if they gave one. Copy their text strings character +for character, in their own script, including punctuation and spacing. + +A third thing they may give you is an instruction about the job rather than about the +picture — "use double quotes", "no hard-edged blocks", "4K, no noise", "make sure the +text is sharp". That is not content. Obey it silently where it applies and never echo +it: the description states what is in the frame, never what must be done. + +Open, and you must decide it: everything they did not mention. A three-word request +and a three-hundred-word request both become a description of the same size, so a +short brief means you are inventing most of the frame, not writing less. + +## Step 2 — Fix the frame + +Decide the orientation from the subject, then pick the ratio. + +If the user states a ratio, use it. Otherwise: `3:2` for anything horizontal and +`2:3` for anything vertical — these are the two defaults and cover most images. +Use `1:1` for a square badge, icon, album cover or single centred emblem, `16:9` +for a wide cinematic or presentation frame, `1:2` or `9:16` for a phone screen or a +tall standing banner. `3:4`, `2:1`, `21:9`, `4:3`, `9:21`, `4:5`, `3:1`, `5:4`, +`1:3` exist but only when the subject or the user really calls for them. + +The ratio lives only in the `wh_ratio` field. Never write a ratio, a resolution, or +a pixel count into the description itself. + +## Step 3 — Write the opening sentence + +One sentence, around twenty words. Name the medium, the style, the subject, and the +background or palette; usually name the orientation too: + +`The image is a ⟨vertical / wide / square / tall⟩ ⟨style⟩ ⟨photograph · poster · illustration · scene · portrait · infographic · close-up · graphic · page · card · sheet · logo⟩ of ⟨subject⟩, ⟨the background and its palette⟩.` + +`This is a …` or a bare `A vertical realistic photograph of …` work equally well. The +medium noun is the one part that is never omitted. + +The style word goes here — realistic, photorealistic, minimalist, flat-vector, +cinematic, watercolour, isometric, editorial, hand-drawn, 3D-rendered, retro. Name +it once here; you may echo it in the closing sentence. + +## Step 4 — Inventory before you write + +Before any more prose, settle two lists. + +Every element that will appear, each with a place in the frame: upper-left, +across the top, on the far right, in the lower-third, in the centre, in front of, +behind, tucked into the corner. You will need eight to fourteen such positional +phrases, about ten typically, and they must reach the corners, the edges and the +centre — not cluster in the middle. + +Every piece of text that will be legible in the image, in reading order. + +## Step 5 — Walk the frame + +Now describe it in order. Which order depends on how the frame is filled. + +**If the frame is divided into regions** — a poster, a page, an interface, a layout, a +wide scene with several things in it — walk the regions: + +1. The background and the surface it sits on — this comes immediately after the + opening sentence, not at the end. +2. The top band: headline, header bar, sky, ceiling, whatever occupies the top edge. +3. Down and across the body of the frame: left side, then centre, then right side. + Give each region one or two sentences. +4. The bottom band: footer, foreground, ground plane, base row. + +**If one subject fills the frame** — a portrait, a close-up, a single object — walk +the subject instead: the background and how far it falls off, then the subject's pose +and where it is placed in the frame, then head and face, then body and each garment or +surface, then what is held or touching it, then whatever little is left at the edges. +Keep using positional phrases inside the subject — in the upper-left of the frame, +behind the left shoulder, along the lower edge — so the frame stays locatable. + +Roughly a third of your sentences should open on the positional phrase itself — +"On the right side of the frame, …", "In the upper-left corner, …", "Across the +lower third, …" — so the reader always knows where they are looking. + +Keep it to one paragraph. Break to a new paragraph only when the image is genuinely +built from stacked regions — panels, cards, sections, slides — and then one +paragraph per region, each opening on where that region sits. + +## Step 6 — Set every piece of text + +Skip this step if nothing in the image is meant to be read — a third of images have +no legible text at all, and inventing signage for them is a mistake. + +Otherwise, for each string from your Step 4 list, in reading order, name where it sits, +what it looks like, and what it says: `a bold black headline across the top reads "…"`. + +Put the string in straight double quotes, in its own script — Chinese, Russian, +Korean, Japanese and Arabic text stays in Chinese, Russian, Korean, Japanese and +Arabic. Give its weight, colour, case and relative size. Describe a line break as a +second line rather than putting a real newline inside the string. If a mark is not meant +to be read — distant signage, a label behind glass, dense body copy — call it +blurred, indistinct, or too small to read rather than inventing letters. If the image contains a chart +or a table, its axes, tick labels, legend entries, series and cell values are text +too: write them out. + +## Step 7 — Give the lighting its own sentence + +Every image has light in it, and the description always accounts for it: the source, +its direction, its quality, and the shadows and highlights it leaves. Soft diffused +daylight from a window on the left, hard overhead studio light, warm low sun, flat +even ambient light for a diagram. + +Once the contents are placed, give it a sentence of its own — `The lighting is …` — +or, if the light is what makes a particular surface look the way it does, fold it into +that surface's sentence. Either way it is stated explicitly, not left implied. + +## Step 8 — Close with the whole frame + +End on a single sentence that steps back: + +`The overall composition ⟨is / uses / feels⟩ …` + +`The composition is …`, `The overall design …`, `The overall mood …`, `The overall +palette …` and `The image has …` are the same move. Cover balance and symmetry, the +palette, the style, and the mood in that one sentence. Write exactly one such +sentence — do not follow it with a second summary. + +## Throughout + +**Size.** The description runs about twenty sentences and four to five hundred words, +roughly twenty-five words a sentence. That is the same size whether the brief was three +words or three hundred: a dense frame with many regions and a lot of text runs longer, a +single quiet subject runs shorter, but a thin brief never buys a thin description. + +**Observe, don't instruct.** Present tense, third person, declarative. No "you", no +"create", no "make sure", no "the AI should". No quality boosters — no "masterpiece", +"8K", "highly detailed", "award-winning". + +**Hedge what you cannot be certain of.** An observer describing a picture says +"appears to be", "likely", "suggesting", and offers a pair — "a notebook +or a tablet", "wood or dark laminate" — when the thing is genuinely ambiguous. Do +this often; it is the natural register here. Be flatly definite only about what the +user fixed. + +**Name colours with a modifier, almost never bare.** Deep navy, muted olive, pale +cream, warm terracotta, soft dusty rose, blue-grey, off-white, charcoal, brownish- +green. Hex codes only if the user gave them. + +**Give the material, not just the noun.** Brushed metal, matte plastic, glossy +ceramic, coarse linen, weathered wood, frosted glass, grain, scuffs, condensation, +visible brush strokes, paper fibre. + +**Enumerate; never summarise.** "Several items" and "various decorations" are not +descriptions. Say what each thing is. Write small counts as words — three, five, +twelve — and if something is partly hidden, say so and describe the visible part. + +**People get their observable surface.** Build, posture, where they are looking, +expression, hair, skin tone, and each garment with its colour and material. Age is a +life stage or a decade — a child, a teenager, a young adult, middle-aged, elderly, +in her thirties — never a number of years. If a face is turned away or cropped, say +that instead of describing it. + +**Objects by class, not by brand.** A silver laptop, a mirrorless camera, a compact +hatchback — unless the user named the brand. Photographic and design vocabulary is +welcome: shallow depth of field, bokeh, backlit, close-up, negative space, +grid, drop shadow. + +**Everything holds together physically.** Shadows fall away from the light, reflections +match what is in front of the surface, scale is consistent between neighbouring +objects, and a surface reacts to what sits on it. If the user asked for something +impossible, describe it as the image shows it and let the rest of the scene stay +coherent around it. + +## Language + +The description is always in English, whatever language the request arrives in. The +only exception is text shown inside the image, which stays in its own script. + +## Output format + +Return one strictly valid JSON object on a single line, nothing before or after: + +{"rewritten_prompt": "", "wh_ratio": ""} diff --git a/xb_skills.py b/xb_skills.py new file mode 100644 index 0000000..9365e37 --- /dev/null +++ b/xb_skills.py @@ -0,0 +1,309 @@ +""" +SKILL(技能文件 = 系统提示词)公共读取 +====================================== +· 位置:`<本包>/support_llama/skills/*.txt | *.md` +· 被两个节点共用: + - `XB_QwenPromptPreset`(🖼️ Qwen2.1提示词预设)→ 官方 Generate Text 的 system_prompt + - `XB_ImagePromptPresetPro`(🖼️ 生图提示词预设Pro)→ LLM 反推的 system prompt +· 语义:选中后整段作为系统提示词;「不使用」= 走各自节点原有的系统提示词。 + +设计要点 +-------- +· `skill_list()` 的**第一个选项固定是「不使用」**:老工作流没有这个字段时不改变行为; +· 只按文件名(`basename`)取文件,防目录穿越; +· 按 `mtime:size` 缓存全文,`skill_sig()` 供 IS_CHANGED 用(改 txt 自动重跑); +· 编码 utf-8-sig → utf-8 → gbk 逐个退,最后 replace 兜底(绝不抛异常)。 +""" + +import os +import threading +import time + +PACKAGE_DIR = os.path.dirname(os.path.abspath(__file__)) +SKILL_DIR = os.path.join(PACKAGE_DIR, "support_llama", "skills") +SKILL_NONE = "不使用" +_SKILL_EXTS = (".txt", ".md") +_CACHE = {"key": None, "text": ""} + + +def skill_list(): + """技能文件列表(combo 选项;第一个是「不使用」)""" + try: + names = [f for f in os.listdir(SKILL_DIR) if f.lower().endswith(_SKILL_EXTS)] + except Exception: + return [SKILL_NONE] + return [SKILL_NONE] + sorted(names) + + +def skill_path(name): + """只允许目录内的文件名(防目录穿越);「不使用」/ 空 → None""" + n = os.path.basename(str(name or "").strip()) + if not n or n == SKILL_NONE: + return None + return os.path.join(SKILL_DIR, n) + + +def skill_sig(name): + """技能文件指纹(mtime:size)→ 文件内容变了自动重跑""" + p = skill_path(name) + if not p: + return "-" + try: + st = os.stat(p) + return f"{st.st_mtime_ns}:{st.st_size}" + except Exception: + return "-" + + +def skill_text(name, log_prefix=""): + """技能全文(带缓存);读不到只打印提示并返回空串,交给调用方继续走原有逻辑""" + p = skill_path(name) + if not p: + return "" + try: + st = os.stat(p) + key = (p, st.st_mtime_ns, st.st_size) + if _CACHE.get("key") == key: + return _CACHE.get("text", "") + raw = open(p, "rb").read() + text = "" + for enc in ("utf-8-sig", "utf-8", "gbk"): + try: + text = raw.decode(enc) + break + except UnicodeDecodeError: + continue + else: + text = raw.decode("utf-8", errors="replace") + text = text.strip() + _CACHE.update(key=key, text=text) + return text + except Exception as exc: + print(f"{log_prefix or '[XB-SKILL]'} SKILL 读取失败({name}):{exc}") + return "" + + +# ── 「打开文件夹并弹到最前」(Windows,ctypes 零依赖)───────────────────────── +# 背景:ComfyUI 服务进程**不是前台进程** → `os.startfile` 起出来的资源管理器窗口 +# 往往落在浏览器后面(用户报「会打开但不会弹到表面」)。 +# 做法:① 先 AllowSetForegroundWindow(ASFW_ANY) 放行;② 起窗口后后台线程轮询 +# EnumWindows 找到该文件夹的 CabinetWClass 窗口,并用 5 招强制前台 +# (最小化还原 / AttachThreadInput / topmost 闪一下 / 模拟 ALT / SwitchToThisWindow), +# 每轮都重试(前台锁会过期,通常第 2~3 轮成功)。 +_SW_RESTORE = 9 +_SW_MINIMIZE = 6 +_HWND_TOPMOST = -1 +_HWND_NOTOPMOST = -2 +_SWP_NOSIZE = 0x0001 +_SWP_NOMOVE = 0x0002 +_EXPLORER_CLASSES = ("CabinetWClass", "ExploreWClass", "Progman", "WorkerW") + + +def _win_allow_foreground(): + """ASFW_ANY(-1):允许随后启动的进程抢前台(否则 SetForegroundWindow 会被静默拒绝)""" + try: + import ctypes + + ctypes.windll.user32.AllowSetForegroundWindow(-1) + return True + except Exception: + return False + + +def _win_explorer_windows(): + """当前所有资源管理器顶层窗口 → [(hwnd:int, 标题:str)](失败返回 [])""" + try: + import ctypes + from ctypes import wintypes + + user32 = ctypes.windll.user32 + found = [] + proto = ctypes.WINFUNCTYPE(ctypes.c_bool, wintypes.HWND, wintypes.LPARAM) + + def _cb(hwnd, _lparam): + cls = ctypes.create_unicode_buffer(64) + user32.GetClassNameW(hwnd, cls, 64) + if cls.value in _EXPLORER_CLASSES: + title = ctypes.create_unicode_buffer(512) + user32.GetWindowTextW(hwnd, title, 512) + found.append((int(hwnd), title.value)) + return True + + user32.EnumWindows(proto(_cb), 0) + return found + except Exception: + return [] + + +def _win_force_foreground(hwnd): + """把窗口强行拉到最前(返回是否成功成为前台窗口) + + Windows 有「前台锁」:后台进程直接 SetForegroundWindow 常被静默拒绝 → + 下面按强度依次尝试 5 招(实测浏览器里点按钮时,前 3 招内必成功)。 + """ + try: + import ctypes + + user32 = ctypes.windll.user32 + kernel32 = ctypes.windll.kernel32 + user32.ShowWindow(hwnd, _SW_RESTORE) + user32.SetForegroundWindow(hwnd) + if _win_foreground_hwnd() == hwnd: + return True + + # ① 最小化 → 还原:让窗口重走一次激活流程 + user32.ShowWindow(hwnd, _SW_MINIMIZE) + user32.ShowWindow(hwnd, _SW_RESTORE) + user32.SetForegroundWindow(hwnd) + if _win_foreground_hwnd() == hwnd: + return True + + # ② AttachThreadInput 解除前台锁(最经典的一招) + fg = _win_foreground_hwnd() + tid_fg = int(user32.GetWindowThreadProcessId(fg, None)) if fg else 0 + tid_me = int(kernel32.GetCurrentThreadId()) + attached = False + if tid_fg and tid_fg != tid_me: + attached = bool(user32.AttachThreadInput(tid_me, tid_fg, True)) + try: + user32.BringWindowToTop(hwnd) + user32.SetForegroundWindow(hwnd) + user32.SetFocus(hwnd) + finally: + if attached: + user32.AttachThreadInput(tid_me, tid_fg, False) + if _win_foreground_hwnd() == hwnd: + return True + + # ③ 置顶闪一下(topmost → 取消 topmost) + user32.BringWindowToTop(hwnd) + user32.SetWindowPos(hwnd, _HWND_TOPMOST, 0, 0, 0, 0, _SWP_NOMOVE | _SWP_NOSIZE) + user32.SetWindowPos(hwnd, _HWND_NOTOPMOST, 0, 0, 0, 0, _SWP_NOMOVE | _SWP_NOSIZE) + user32.SetForegroundWindow(hwnd) + if _win_foreground_hwnd() == hwnd: + return True + + # ④ 模拟按一下 ALT:系统会认为本进程刚收到输入 → 放行前台切换 + try: + user32.keybd_event(0x12, 0, 0, 0) # VK_MENU down + user32.keybd_event(0x12, 0, 2, 0) # VK_MENU up + except Exception: + pass + user32.SetForegroundWindow(hwnd) + if _win_foreground_hwnd() == hwnd: + return True + + # ⑤ SwitchToThisWindow(未文档化但通常有效) + try: + user32.SwitchToThisWindow(hwnd, True) + except Exception: + pass + return _win_foreground_hwnd() == hwnd + except Exception: + return False + + +def _win_foreground_hwnd(): + """当前前台窗口 handle(失败返回 0)""" + try: + import ctypes + + return int(ctypes.windll.user32.GetForegroundWindow() or 0) + except Exception: + return 0 + + +def _win_pin_topmost(hwnd, seconds=1.2): + """最后的杀招:把窗口钉在「最前」一小会儿(到点自动取消置顶) + + 比瞬间闪一下更管用(瞬间闪往往还没被系统真正前置就取消了)。 + """ + try: + import ctypes + + user32 = ctypes.windll.user32 + user32.SetWindowPos(hwnd, _HWND_TOPMOST, 0, 0, 0, 0, _SWP_NOMOVE | _SWP_NOSIZE) + threading.Timer(max(0.2, float(seconds)), _win_unpin, args=(hwnd,)).start() + return True + except Exception: + return False + + +def _win_unpin(hwnd): + """取消置顶""" + try: + import ctypes + + ctypes.windll.user32.SetWindowPos(hwnd, _HWND_NOTOPMOST, 0, 0, 0, 0, _SWP_NOMOVE | _SWP_NOSIZE) + except Exception: + pass + + +def _win_raise_folder_window(path, before=(), tries=80, interval=0.12): + """等资源管理器窗口出现并弹到最前(后台线程调用;不阻塞节点执行/HTTP 响应) + + · 最多等 tries×interval 秒(默认 ~10 秒,网络盘/冷启动的 Explorer 起得慢); + · 命中判据:标题含目录名;标题还没跟上(复用已有窗口)时取「新冒出来的窗口」; + · **每轮都重试置前**(即使上一轮失败):前台锁/用户正在操作会让单次激活失败, + 重试几轮基本都会过;一旦成功立即结束(不跟用户后续操作抢焦点)。 + """ + base = os.path.basename(str(path).rstrip("\\/")).lower() + before_hwnds = {int(h) for h, _ in (before or ())} + rounds = 0 + for _ in range(max(1, int(tries))): + time.sleep(interval) + wins = _win_explorer_windows() + if not wins: + continue + hits = [h for h, t in wins if base and base in (t or "").lower()] + if not hits: # 标题还没跟上(复用窗口)→ 取新冒出来的那个 + hits = [h for h, _ in wins if h not in before_hwnds] + if not hits: + continue + rounds += 1 + for hwnd in hits: + if _win_foreground_hwnd() == hwnd: # 已经在前台 → 收工 + return True + if _win_force_foreground(hwnd): + return True + if rounds >= 12: # 反复失败 → 钉最前 1.2 秒兜底 + for hwnd in hits: + _win_pin_topmost(hwnd, 1.2) + return True + return False + + +def open_skill_folder(): + """打开技能文件夹并**弹到最前**;返回 (ok, 绝对路径 / 错误信息)""" + try: + os.makedirs(SKILL_DIR, exist_ok=True) + if os.name != "nt": + import subprocess + + subprocess.Popen(["xdg-open", SKILL_DIR]) # Linux/macOS 兜底 + return True, SKILL_DIR + if not hasattr(os, "startfile"): # pragma: no cover + return False, "当前系统不支持自动打开,请手动打开:" + SKILL_DIR + before = _win_explorer_windows() # 先记下已有窗口,便于识别新窗口 + _win_allow_foreground() + os.startfile(SKILL_DIR) # noqa: S606 (Windows 专用) + # 起窗口要几百毫秒;置前放后台线程,路由立即返回(浏览器不会卡) + threading.Thread(target=_win_raise_folder_window, args=(SKILL_DIR, before), + daemon=True).start() + return True, SKILL_DIR + except Exception as exc: + return False, f"{exc}(手动打开:{SKILL_DIR})" + + +# ── HTTP 路由:面板上的「打开 SKILL 文件夹」按钮用 ───────────────────── +# 单独注册在 /xb_toolbox/skill_folder;服务未就绪 / 独立进程导入时静默跳过(不影响节点本身) +try: + from server import PromptServer # type: ignore + from aiohttp import web # type: ignore + + @PromptServer.instance.routes.get("/xb_toolbox/skill_folder") + async def xb_skill_folder(request): # noqa: ANN001 + ok, info = open_skill_folder() + return web.json_response({"ok": bool(ok), "path": SKILL_DIR, "info": info}, status=200 if ok else 500) +except Exception: # pragma: no cover - 仅在没有运行中的 ComfyUI 时走到 + pass