diff --git a/README.md b/README.md index 7f5ce44..ec8473c 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,10 @@ Both a **local model** and an **online API** backend are supported; API keys liv - **🖼️ Image Prompt Preset Pro** — the same, plus built-in LLM prompt enhancement / reverse. - **🖼️ Qwen2.1 Prompt Preset** — preset + element panel, wired to the official Load CLIP + Generate Text chain. - **🖌️ ShenBi** — an input-less paint board (create / edit modes) that outputs a strictly on-resolution image. + > The preview window reads files from **this server's** `input` dir (boards live in `input/xb_shenbi/`). + > A workflow only stores the file name, so after copying it to another machine (local ↔ cloud) the window + > falls back to a blank canvas and explains why in the badge / browser console. The window itself needs a + > recent ComfyUI frontend (DOM widgets). #### 9. 🖼️ Model-specific Encoders (3) diff --git a/README_zh.md b/README_zh.md index 86e5afb..44b5d1f 100644 --- a/README_zh.md +++ b/README_zh.md @@ -105,6 +105,9 @@ - **🖼️ 生图提示词预设 Pro**:同上,并内置 LLM 提示词增强 / 反推。 - **🖼️ Qwen2.1 提示词预设**:预设 + 元素面板,接官方 Load CLIP + Generate Text 链路。 - **🖌️ 神笔**:无输入端、只输出 Image 的画板节点(创作 / 编辑双模式),输出严格对齐设定分辨率。 + > 显示窗依赖**本机** input 目录里的文件(画板存在 `input/xb_shenbi/`):工作流只记文件名, + > 把工作流拷到别的机器(本地 ↔ 云机)后,除非那台机器上有同名文件,否则显示窗会回落成空白画布 + > 并在角标 / 浏览器控制台说明原因。显示窗本身需要较新的 ComfyUI 前端(支持 DOM 部件)。 #### 9. 🖼️ 专用编码器(3 个) diff --git a/js/xb_qwen_prompt_preset.js b/js/xb_qwen_prompt_preset.js index 0f597db..abc8db5 100644 --- a/js/xb_qwen_prompt_preset.js +++ b/js/xb_qwen_prompt_preset.js @@ -3249,6 +3249,8 @@ function setupNode(node) { * + 提示词框标题行(右侧「📋 复制」)+ 参数设定显示(只读摘要 5 行) * + 「📝 提示词」端口接线 → 提示词框锁定(二选一) * 弹窗:导演台同款二级弹窗(底部:自动保存 + 字号 + 界面缩放 + 取消/💾 保存) + * 🤖 LLM设置 顶部 = LLM 增强总开关(单选必选:启用LLM增强 / 关闭LLM增强,默认启用) + * → 节点 widget `llm_enable`(BOOLEAN,节点表面隐藏);关闭 = 后端不加载文本编码器、不生成文本 * 配置:manager_settings.qwen = { run.seed_control, extra_settings }(Generate Text 的全量参数走节点 widget,由弹窗读写) * 说明:本块所有标识符统一 xbr/XBR 前缀,避免与生图预设面板既有函数/常量重名。 * ========================================================================== */ @@ -3258,12 +3260,15 @@ const XBR_SEED_MODES = ["randomize", "fixed", "increment", "decrement"];const XB const XBR_IO_MODES = ["自动", "文生图", "图生图"]; const XBR_SAMPLING_MODES = ["on", "off"]; const XBR_MTP_MODES = ["auto", "off", "2", "3", "4", "5"]; +/** LLM 增强总开关(🤖 LLM设置 弹窗顶部单选,必选;默认「启用LLM增强」)*/ +const XBR_LLM_MODES = ["启用LLM增强", "关闭LLM增强"]; const XBR_PANEL_BUTTONS = [ - { id: "llm", label: "🤖 LLM设置", title: "🤖 LLM设置", subtitle: "模型选择 = 官方 Load CLIP;参数设置 = 官方 Generate Text 全量参数。执行节点即生成,没有开关。" }, + { id: "llm", label: "🤖 LLM设置", title: "🤖 LLM设置", subtitle: "顶部 = LLM 增强总开关(默认启用);模型选择 = 官方 Load CLIP;参数设置 = 官方 Generate Text 全量参数。" }, { id: "preset", label: "✨ 预设参数", title: "✨ 预设参数", subtitle: "输出语言 / 空latent类型 / 预设模式 / 设定词 / SKILL。提示词正文在节点上的提示词框里编辑。" }, ]; /** 全部收进弹窗的节点 widget(节点表面只留画幅参数 / 生成数量) */ const XBR_QWEN_WIDGETS = [ + "llm_enable", "clip_name", "clip_type", "device", "max_length", "sampling_mode", "temperature", "top_k", "top_p", "min_p", "repetition_penalty", "presence_penalty", "seed", "control_after_generate", @@ -3497,7 +3502,18 @@ function xbrDialog({ label, title, subtitle, width = 820, autoSaveRef = null, on function xbrRenderLlm(body, node, draft, ctx) { const s = draft.settings.qwen; - body.append(makeSectionTitle("模型选择(官方 Load CLIP)")); + // ── 顶部:LLM 增强总开关(单选必选,默认「启用LLM增强」)── + body.append(makeSectionTitle("LLM 增强")); + body.append(field("LLM 增强", radioRow(XBR_LLM_MODES, draft.llm ? XBR_LLM_MODES[0] : XBR_LLM_MODES[1], (v) => { + draft.llm = (v === XBR_LLM_MODES[0]); + ctx.rerender(); // 关闭 → 下面的区块标成不生效 + }))); + body.append(xbrHint(draft.llm + ? "启用(默认):执行节点时加载文本编码器,把正文生成成完整提示词(下面的模型与参数全部生效)。" + : "关闭:执行节点只输出拼装好的提示词 + 空latent —— 不加载文本编码器、不生成文本(下面的模型与参数都不生效)。")); + + const offMark = draft.llm ? "" : " — 已关闭 LLM 增强,不生效"; + body.append(makeSectionTitle("模型选择(官方 Load CLIP)" + offMark)); 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; }))); @@ -3505,7 +3521,7 @@ function xbrRenderLlm(body, node, draft, ctx) { 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 全量参数)")); + body.append(makeSectionTitle("参数设置(官方 Generate Text 全量参数)" + offMark)); 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(); }))); @@ -3527,7 +3543,10 @@ function xbrRenderLlm(body, node, draft, ctx) { 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;", "提示词正文在节点上的提示词框里编辑;「📝 提示词」端口接线后该框锁定。")); + body.append(el("div", "font-size:11px;color:" + (draft.llm ? "#888" : "#d9a441") + ";line-height:1.6;margin:2px 0 8px;", + draft.llm + ? "提示词正文在节点上的提示词框里编辑;「📝 提示词」端口接线后该框锁定。" + : "已关闭 LLM 增强:执行节点不会加载上面的模型、也不会用这些参数,输出 = 本档设定词置顶 + 提示词框正文(「📝 提示词」端口接线时用它)。")); } /* ── 弹窗内容:✨ 预设参数(生图预设 + 设定词 + SKILL)───────────── */ @@ -3685,7 +3704,8 @@ function xbrRenderHelp(body) { " · 第 2 行按钮:🎨 风格 | 📐 视角 | 👤 主体 | 🕺 姿态 | 👗 装扮 | 🎒 道具 | 💡 光影 | 🏞️ 背景", " · 画幅比例 / 宽度 / 高度 / 生成数量:按空latent类型的官方步长自动对齐", " · 提示词框右上角 📋 复制;下面是参数设定显示,实时告诉你当前配置", - " · 没有开关:执行节点就会用文本编码器生成", + " · 开关:🤖 LLM设置 顶部「启用LLM增强 / 关闭LLM增强」(默认启用);", + " 关闭后执行节点只用拼装好的提示词,不加载文本编码器、不生成文本", "", "【元素面板怎么用】", " · 点选项行 = 加入提示词,再点一下 = 移除", @@ -3695,6 +3715,10 @@ function xbrRenderHelp(body) { " · 搜索框与「全部加入」只作用于当前子类", "", "【🤖 LLM设置】", + " · 顶部总开关(单选必选,默认「启用LLM增强」):", + " ◆ 启用LLM增强 = 执行节点时加载文本编码器,把正文生成成完整提示词", + " ◆ 关闭LLM增强 = 只输出拼装好的提示词 + 空latent(不加载文本编码器、不生成文本)", + " 下面的模型选择与参数全部不生效(面板里会标出「不生效」)", " · 模型选择 = 官方 Load CLIP:模型 / 类型 / 设备,枚举与官方节点逐字一致", " · 参数设置 = 官方 Generate Text 全量参数:max_length | sampling_mode | temperature |", " top_k | top_p | min_p | repetition_penalty | presence_penalty | seed |", @@ -3780,6 +3804,7 @@ function xbrRenderHelp(body) { " (多 GPU 机器上核显常被枚举成 cuda:0)→ 启动前设 HIP_VISIBLE_DEVICES=1 让 ComfyUI 用独显再试", " · 生成慢 / 显存不够:设备改 cpu,或把 max_length 调小", " · 结果太发散:温度调低、top_p 调小;想复现同一结果就把生成后控制改成固定", + " · 只想拼提示词(不跑模型、省显存):🤖 LLM设置 顶部切成「关闭LLM增强」再执行", " · 节点表面参数很少?故意的:选项都收在弹窗里", " · 改完参数要重新执行节点才生效", "", @@ -3819,6 +3844,7 @@ async function xbrOpenModal(node, panelId) { skill: String(xbrWidgetVal(node, "skill_name") ?? ""), skillMode: (() => { const v = String(xbrWidgetVal(node, "skill_mode") ?? ""); return XBR_SKILL_MODES.includes(v) ? v : "自动"; })(), // 官方 Load CLIP + llm: xbrFlag(xbrWidgetVal(node, "llm_enable"), true), // LLM 增强总开关(默认启用) clip_name: String(xbrWidgetVal(node, "clip_name") ?? ""), clip_type: String(xbrWidgetVal(node, "clip_type") ?? "qwen_image"), device: String(xbrWidgetVal(node, "device") ?? "default"), @@ -3855,6 +3881,7 @@ async function xbrOpenModal(node, panelId) { node.__ippWritePreset?.(draft.mode, ioCommit, draft.lang, draft.presetText); } catch (_) {} // 官方 Load CLIP + xbrSetWidget(node, "llm_enable", draft.llm); // LLM 增强总开关 xbrSetWidget(node, "clip_name", draft.clip_name, true); xbrSetWidget(node, "clip_type", draft.clip_type, true); xbrSetWidget(node, "device", draft.device, true); @@ -3931,11 +3958,14 @@ function xbrInfoLines(node, promptText) { 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 llmOn = xbrFlag(xbrWidgetVal(node, "llm_enable"), true); + const line2 = llmOn ? `🤖 LLM 增强:已启用 | 模型:${modelShort || "未选择"}` + : "🤖 LLM 增强:已关闭(只输出拼装好的提示词)"; + const line3 = `⚙️ Text 参数: 最大长度 ${xbrWidgetVal(node, "max_length")} 丨 思考模式 ${xbrFlag(xbrWidgetVal(node, "thinking"), false) ? "开" : "关"} 丨 温度 ${xbrWidgetVal(node, "temperature")}` + + (llmOn ? "" : "(不生效)"); const ctlRaw = xbrClean(xbrWidgetVal(node, XBR_SEED_CTL_WIDGET)) || "randomize"; const line4 = `✨ 生成种子:${xbrWidgetVal(node, "seed")} | ${XBR_SEED_LABEL[ctlRaw] || ctlRaw}`; - const line5 = `📝 文本数量:${genLen} 字`; + const line5 = llmOn ? `📝 文本数量:${genLen} 字` : "📝 文本数量:未生成(已关闭 LLM 增强)"; return [line1, line2, line3, line4, line5].join("\n"); } diff --git a/js/xb_shenbi.js b/js/xb_shenbi.js index d9ec9d5..0477d0f 100644 --- a/js/xb_shenbi.js +++ b/js/xb_shenbi.js @@ -9,6 +9,14 @@ * 创作模式 —— 没有画板存档时显示「设定分辨率 + 画布底色」的纯色画布(默认纯白); * 编辑模式 —— 显示画板存档,没画过则显示源图; * 画过之后两个模式都显示画板存档。 + * ⚠️ 显示窗要显示「存档 / 源图」时必须满足两条**服务端本机**条件: + * ① 该文件存在于这台机器的 ComfyUI/input(画板在 input/xb_shenbi/); + * ② /api/view 能取到它。 + * 工作流只带**文件名**,画板文件不会跟着工作流走 —— 从本地拷到云机、或云端 input 被重置时, + * 文件不存在(/view 404),旧版本就是一块「图裂」黑窗(用户报的「云机上看不到画布」)。 + * 现在前端会把加载失败的 URL 记下来,回落成空白画布 / 源图,并在角标 + 控制台说明原因。 + * ⚠️ 显示窗是 DOM 部件(LGraphNode.addDOMWidget):ComfyUI 前端太旧(无该 API)时整块窗口都不出现, + * 此时会在控制台报错并弹提示(提示升级前端)。 * · 显示窗只做显示,不做点击编辑(避免和操作按钮重复成两个编辑入口); * 官方 image_upload 自带的 `upload` 按钮按模式显隐,`$$canvas-image-preview` 面板一律隐藏, * 保证「显示窗」全节点只有一个。 @@ -185,6 +193,11 @@ function isPainting(value) { return PAINT_EXT_RE.test(text) && !/[\n\r]/.test(text); } +/** 存档值的短标签:老工作流里的内嵌 base64 太长,提示里只写"内嵌画像存档" */ +function archiveLabel(value) { + return String(value || "").startsWith("data:") ? "内嵌画像存档" : String(value || ""); +} + /** 把画布 PNG 传到 input/xb_shenbi/ 下,返回节点里要存的文件名("子目录/文件名") */ async function uploadPainting(blob, filename) { const body = new FormData(); @@ -263,27 +276,36 @@ async function openBrushEditor(cfg) { let width = 0; let height = 0; let bitmap = null; - try { - if (cfg.paintedUrl) { - bitmap = await loadBitmap(cfg.paintedUrl); - width = bitmap.naturalWidth; - height = bitmap.naturalHeight; - } else if (cfg.imageUrl) { - bitmap = await loadBitmap(cfg.imageUrl); - const sw = bitmap.naturalWidth; - const sh = bitmap.naturalHeight; - // 编辑模式:大于设定分辨率才缩小裁切,小于则保持原尺寸 - if (cfg.targetW && cfg.targetH && (sw > cfg.targetW || sh > cfg.targetH)) { - width = cfg.targetW; - height = cfg.targetH; + // 底图候选:画板存档 → 源图。⚠️ 两者都是**服务端本机**的文件,工作流从别的机器 + // (如本地 → 云机)拷过来时常常读不到:读不到就退到下一个候选,都读不到按设定分辨率的 + // 空白画布打开 —— 绝不因为"存档不在本机"就把用户堵在"载入失败"上(后端也是这个兜底口径)。 + const candidates = []; + if (cfg.paintedUrl) candidates.push({ url: cfg.paintedUrl, painted: true }); + if (cfg.imageUrl) candidates.push({ url: cfg.imageUrl, painted: false }); + for (const cand of candidates) { + try { + bitmap = await loadBitmap(cand.url); + if (cand.painted) { + width = bitmap.naturalWidth; + height = bitmap.naturalHeight; } else { - width = sw; - height = sh; + const sw = bitmap.naturalWidth; + const sh = bitmap.naturalHeight; + // 编辑模式:大于设定分辨率才缩小裁切,小于则保持原尺寸 + if (cfg.targetW && cfg.targetH && (sw > cfg.targetW || sh > cfg.targetH)) { + width = cfg.targetW; + height = cfg.targetH; + } else { + width = sw; + height = sh; + } } + break; + } catch (err) { + bitmap = null; + notify((cand.painted ? "画板存档" : "源图") + "在本机读不到,已跳过(文件不在服务端 input 目录):" + + ((err && err.message) || err), "warn"); } - } catch (err) { - notify("图片载入失败:" + ((err && err.message) || err), "error"); - return null; } if (!width || !height) { width = cfg.width || 1024; height = cfg.height || 1024; } @@ -756,7 +778,11 @@ function setupNode(node) { let panelImg = null; let panelHint = null; let panelCaption = null; + let panelNote = null; let panelBox = null; + /** 本机加载失败的图 URL(存档 / 源图文件不在服务端本机时最常见)—— + 记住它,显示窗就不再拿它去撞墙,改为回落显示 + 说明 */ + const badSrcs = new Set(); /* ── 按钮:打开画板(放在显示窗上面) ─────────────────────── */ const openBoard = async () => { @@ -881,12 +907,42 @@ function setupNode(node) { panelCaption = document.createElement("span"); panelCaption.style.cssText = "position:absolute;left:10px;top:10px;padding:4px 11px;border-radius:999px;" + "background:rgba(20,20,25,.78);color:#cfd5df;font-size:12px;line-height:1;pointer-events:none;display:none;"; - box.append(panelImg, panelHint, panelCaption); + // 存档读不到时的说明角标(压在左下角:纯白/纯黑画布上都看得清) + panelNote = document.createElement("span"); + panelNote.style.cssText = "position:absolute;left:10px;bottom:10px;max-width:calc(100% - 20px);padding:7px 12px;" + + "border-radius:8px;background:rgba(58,31,31,.94);border:1px solid #7d3b3b;color:#ffd9d9;" + + "font-size:12px;line-height:1.6;pointer-events:none;display:none;white-space:pre-wrap;text-align:left;" + + "box-sizing:border-box;overflow:hidden;"; + box.append(panelImg, panelHint, panelCaption, panelNote); panelBox = box; panel = node.addDOMWidget("xb_sb_preview", "preview", box, { serialize: false, hideOnZoom: false }); } catch (err) { panel = null; - console.warn("[XB-BOX 神笔] 显示窗不可用:", err); + // 显示窗是 DOM 部件(addDOMWidget)。老版 ComfyUI 前端没有这个 API → 整个窗口都不会出现, + // 节点上只剩几个按钮。这是「云机上完全看不到画布窗口」的第一嫌疑人,必须说出来。 + // 必需要求:ComfyUI 前端 ≥ 1.3(或任何带 addDOMWidget 的版本)。 + console.error("[XB-BOX 神笔] 显示窗不可用:当前 ComfyUI 前端不支持 DOM 部件(LGraphNode.addDOMWidget)。" + + "请把 ComfyUI / ComfyUI_frontend 升级到较新版本后刷新页面。", err); + notify("显示窗不可用:当前 ComfyUI 前端太旧(缺 addDOMWidget),升级前端后刷新页面即可", "error"); + } + if (panelImg) { + // 存档 / 源图都是**服务端本机**的文件:工作流从别的机器(如本地 → 云机)拷过来、 + // 或云端 input 目录被重置时,这个文件不存在 → 只会烂成一块"图裂"空窗 + // (用户看到的就是"画布显示不出来")。这里把它记进 badSrcs,refreshPanel 回落到 + // 空白画布 / 源图,并给出明确说明,而不是留一块黑框让人猜。 + panelImg.addEventListener("error", () => { + const src = panelImg.getAttribute("src"); + if (!src || badSrcs.has(src)) return; + // img 的 error 也可能是"上一次请求被中断"的迟到事件 → 用独立探针复核,避免误判好图 + const probe = new Image(); + probe.onerror = () => { + if (badSrcs.size > 32) badSrcs.clear(); + badSrcs.add(src); + reportBadSrc(src); + refreshPanel(); + }; + probe.src = src; + }); } if (panel) { // 只给 computeLayoutSize(弹性)——不能给 computeSize,否则会被当成固定高度部件 @@ -896,6 +952,34 @@ function setupNode(node) { try { panel.serialize = false; } catch (_) { /* ignore */ } } + /** + * 显示窗图片加载失败时的诊断输出。 + * 「云机上看不到画布」最常见的一条就是:工作流里存的是画板**文件名**,而那个文件只存在于 + * 另一台机器的 input 目录里 → /view 404 → 什么都不显示(旧版本连提示都没有,只剩一块黑框)。 + */ + const reportBadSrc = (src) => { + const create = isCreateMode(); + const arch = create ? (paintValue(true) || transientPaint()) : paintValue(false); + const label = (arch && String(paintingSrc(arch)) === String(src)) + ? `画板存档「${archiveLabel(arch)}」` + : `源图「${String(getVal(node, "image") || "")}」`; + let file = "?"; + try { + const u = new URL(src, location.href); + const sub = u.searchParams.get("subfolder") || ""; + file = `input/${sub ? sub + "/" : ""}${u.searchParams.get("filename") || ""}`; + } catch (_) { /* ignore */ } + console.warn("[XB-BOX 神笔] ❌ 显示窗图片加载失败 → 已回落显示\n" + + ` · 类型:${label}\n` + + ` · URL:${src}\n` + + ` · 服务端文件:${file}\n` + + " · 排查:① 把上面的 URL 贴到浏览器地址栏:404 = 文件不在那台机器上,500 = 后端读不了\n" + + " ② 到该机器 ComfyUI 的 input 目录看这个文件在不在\n" + + " ③ 工作流是从别的机器(本地 ↔ 云机)拷来的?画板只记文件名,文件不会跟着走\n" + + " · 处理:在本机重新点「🎨 创作画布」/「🖌️ 编辑图片」作画并保存,即可重建存档"); + notify(`显示窗读不到${label}:该文件不在本机 input 目录,已回落显示(详情见浏览器控制台 [XB-BOX 神笔])`, "warn"); + }; + function refreshPanel() { if (!panelImg) return; const create = isCreateMode(); @@ -904,18 +988,39 @@ function setupNode(node) { const [targetW, targetH] = resolveTarget(node); const color = String(getVal(node, "canvas_color") || BG_WHITE); + // 画板存档:创作 = created_data(后端未重启时退到本次会话的临时存档);编辑 = painted_data + const archive = create ? (painted || transientPaint()) : painted; + const archiveUrl = archive ? paintingSrc(archive) : ""; + const sourceUrl = source ? viewUrl(source) : ""; + // 创作模式没画过 → 纯色空白画布:直接用 CSS 背景铺色,**完全不经过 canvas**。 // ⚠️ 原来这里是 blankCanvasUrl():建画布 → 填色 → toDataURL → 。 // 但画布渲染失效时不会报错(画出来是透明的),于是"创作模式加载不出空白画布"; // 而编辑模式走的是 ,不碰 canvas,所以一直正常。 + // ⚠️ 存档/源图在**服务端本机**读不到时(换机器、云端 input 被重置),后端是"忽略存档 → + // 输出空白画布",前端同样回落,绝不把一块"图裂"空窗丢给用户。 let url = ""; let solid = null; - if (painted) url = paintingSrc(painted); - else if (create) { - const temp = transientPaint(); - if (temp) url = paintingSrc(temp); - else solid = color === BG_BLACK ? BLACK : WHITE; - } else if (source) url = viewUrl(source); + let note = ""; + if (archiveUrl) { + if (badSrcs.has(archiveUrl)) { + note = `⚠️ 画板存档读不到:${archiveLabel(archive)}\n(服务端 input/${PAINT_SUBFOLDER} 里没有这个文件)`; + if (create) { + solid = color === BG_BLACK ? BLACK : WHITE; + note += `\n已按 ${targetW} × ${targetH} 显示空白画布;点「🎨 创作画布」重新作画即可`; + } else if (sourceUrl && !badSrcs.has(sourceUrl)) { + url = sourceUrl; + note += "\n已回落到源图显示"; + } + } else { + url = archiveUrl; + } + } else if (create) { + solid = color === BG_BLACK ? BLACK : WHITE; + } else if (sourceUrl) { + if (badSrcs.has(sourceUrl)) note = `⚠️ 源图读不到:${source}\n(服务端 input 目录里没有这个文件)`; + else url = sourceUrl; + } if (panelBox) { panelBox.style.background = solid || "#141419"; @@ -934,7 +1039,14 @@ function setupNode(node) { panelImg.removeAttribute("src"); panelImg.style.display = "none"; panelHint.style.display = "block"; - panelHint.textContent = "尚未作画\n请先在「图片」里选择或上传一张本地图片"; + panelHint.textContent = note || "尚未作画\n请先在「图片」里选择或上传一张本地图片"; + } + + // 说明角标:有图 / 有纯色画布时用角标;都没有时上面那行提示文字已经说了,不用重复 + if (panelNote) { + const badge = (url || solid) ? note : ""; + panelNote.style.display = badge ? "block" : "none"; + panelNote.textContent = badge; } // 标注两个模式都显示:切换分辨率时这里会立刻反映目标尺寸 diff --git a/locales/en/nodeDefs.json b/locales/en/nodeDefs.json index 1fe73aa..9c735d5 100644 --- a/locales/en/nodeDefs.json +++ b/locales/en/nodeDefs.json @@ -408,6 +408,9 @@ "batch_size": { "name": "Batch Size" }, + "llm_enable": { + "name": "LLM Enhance" + }, "clip_name": { "name": "Model (Load CLIP)" }, diff --git a/locales/zh/nodeDefs.json b/locales/zh/nodeDefs.json index facf757..82ce6fb 100644 --- a/locales/zh/nodeDefs.json +++ b/locales/zh/nodeDefs.json @@ -5315,6 +5315,9 @@ "batch_size": { "name": "生成数量" }, + "llm_enable": { + "name": "LLM 增强" + }, "clip_name": { "name": "模型·Load CLIP" }, diff --git a/nodes_qwen_prompt_preset.py b/nodes_qwen_prompt_preset.py index e60d449..a53fe2f 100644 --- a/nodes_qwen_prompt_preset.py +++ b/nodes_qwen_prompt_preset.py @@ -10,7 +10,9 @@ XB-BOX - 🖼️ Qwen2.1提示词预设 · 画幅比例 / 宽度 / 高度 / 生成数量 · 元素面板提示词拼装(8 大类词表,前端 js/xb_qwen_prompt_preset.js) -② 文本生成(官方两个节点的参数,逐字对齐;没有开关,执行即生成) +② 文本生成(官方两个节点的参数,逐字对齐) + · 总开关 llm_enable(弹窗「🤖 LLM设置」顶部的单选「启用LLM增强 / 关闭LLM增强」,默认启用) + ⛔ 关闭 = 只输出拼装好的提示词 + 空latent:不加载文本编码器、不生成文本(模型与参数都不生效) · 模型选择 = 官方 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 / @@ -711,7 +713,7 @@ _LANG_HINT = { } _SETTING_KEYS = ("latent_kind", "output_lang", "preset_mode", "io_mode", "three_view_text", "aspect_ratio", - "width", "height", "batch_size", "clip_name", "clip_type", "device", + "width", "height", "batch_size", "llm_enable", "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") @@ -913,7 +915,15 @@ class XB_QwenPromptPreset: "tooltip": "一次生成的图片数量(空 latent 的 batch 维度)", }), - # ══ ② 官方 Load CLIP(模型选择)══ + # ══ ② LLM 增强总开关(弹窗「🤖 LLM设置」顶部的单选,默认启用)══ + "llm_enable": ("BOOLEAN", { + "default": True, + "label_on": "✅ 启用 LLM 增强", "label_off": "⛔ 关闭 LLM 增强", + "tooltip": "启用(默认)= 用文本编码器把正文生成成完整提示词\n" + "关闭 = 只输出拼装好的提示词 + 空latent(不加载文本编码器、不生成文本)", + }), + + # ══ ③ 官方 Load CLIP(模型选择)══ "clip_name": (text_encoder_list(), { "tooltip": "文本编码器(models/text_encoders)。与官方 Load CLIP 同一个列表。", }), @@ -926,7 +936,7 @@ class XB_QwenPromptPreset: "tooltip": "设备:与官方 Load CLIP 一致;default=自动,cpu=放内存", }), - # ══ ③ 官方 Generate Text(参数设置,全量)══ + # ══ ④ 官方 Generate Text(参数设置,全量)══ "max_length": ("INT", { "default": 512, "min": 1, "max": 32768, "tooltip": "Generate Text · max_length:最大生成长度", @@ -978,7 +988,7 @@ class XB_QwenPromptPreset: "tooltip": "Generate Text · mtp:多 token 预测投机解码;没有 MTP 权重时无效", }), - # ══ ④ 内部字段(节点表面由前端面板隐藏)══ + # ══ ⑤ 内部字段(节点表面由前端面板隐藏)══ "internal_prompt": ("STRING", { "default": "", "multiline": True, "tooltip": "节点内拼装/编辑的提示词正文(随工作流保存)", @@ -1009,7 +1019,8 @@ class XB_QwenPromptPreset: FUNCTION = "generate" CATEGORY = "XB_ToolBox/Image_Params" DESCRIPTION = ("Qwen2.1提示词预设:生图预设(空latent类型/输出语言/预设模式/画幅参数 + 8 大类元素面板拼装)" - "+ 官方 Load CLIP 与官方 Generate Text(纯本地文本生成,无开关,执行即生成)。" + "+ 官方 Load CLIP 与官方 Generate Text(纯本地文本生成)。" + "「启用 / 关闭 LLM 增强」在弹窗「🤖 LLM设置」顶部(默认启用;关闭 = 只输出拼装好的提示词)。" "输出:提示词 / 空latent。") @classmethod @@ -1024,6 +1035,7 @@ class XB_QwenPromptPreset: 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, + llm_enable=True, # LLM 增强总开关(默认启用;关闭 = 不加载文本编码器、不生成文本) 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(与「🖼️ 生图提示词预设」逐行一致) @@ -1054,7 +1066,6 @@ class XB_QwenPromptPreset: 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 认), @@ -1069,55 +1080,67 @@ class XB_QwenPromptPreset: 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() + # ⛔「关闭 LLM 增强」(默认启用):整段跳过 —— 不加载文本编码器、不生成文本 + gen_text = "" + if llm_enable: + clip = get_clip(clip_name, clip_type, device) + 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() + else: + safe_print("[XB-Qwen提示词预设] ⛔ 已关闭 LLM 增强 → 只输出拼装好的提示词" + "(不加载文本编码器、不生成文本)") # ④ 设定词原封不动置顶(与未生成时的成句规则一致;t2i/i2i 模版条款同样进最终提示词) - final_prompt = build_prompt(lang, preset_mode, three_view_text, gen_text, + # 关闭 LLM 增强时正文用「外接提示词优先」的那一份(与外接端口时的成句规则一致) + final_prompt = build_prompt(lang, preset_mode, three_view_text, + gen_text if llm_enable else body, has_image=has_img, io_mode=io_mode) try: shape = tuple(latent.get("samples").shape) except Exception: shape = () + llm_state = (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)})" + ) if llm_enable else "丨LLM 增强 关闭(不加载 CLIP、不生成文本)" 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"{llm_state}" f"丨模式 {preset_mode}丨模版 {io_eff}" f"{'(自动' + (':检测到参考图' if has_img else ':未接参考图') + ')' if io_mode_of(io_mode) == IO_MODE_AUTO else ''}" f"丨参考图 {img_count} 张" @@ -1125,7 +1148,7 @@ class XB_QwenPromptPreset: ui = { "text": [final_prompt], - "model": [str(clip_name or "-")], + "model": [str(clip_name or "-") if llm_enable else "- (已关闭 LLM 增强)"], "gen_len": [len(gen_text)], } # 种子回写不在后端做:节点上的 seed widget 由 ComfyUI 原生的