872 lines
53 KiB
JavaScript
872 lines
53 KiB
JavaScript
/* ============================================================================
|
||
* ✨ 提示词增强反推(融合进「🖼️ 生图提示词预设Pro」)
|
||
* ---------------------------------------------------------------------------
|
||
* 节点表面新增:2 个按钮(🤖 大语言模型配置 | ✨ 提示词增强预设)
|
||
* + 「✅ 启用 LLM 反推」开关(节点表面参数,默认关)
|
||
* + 提示词框标题行(右侧「📋 复制」)+ 参数设定显示(只读摘要)
|
||
* + 「📝 提示词」端口接线 → 提示词框锁定(二选一)
|
||
* 弹窗:导演台同款二级弹窗(底部:自动保存 + 字号 + 界面缩放 + 取消/💾 保存)
|
||
* 配置:manager_settings.llm = { model, run, params, extra_system }(与后端 _llm_section 对齐)
|
||
* 说明:本块所有标识符统一 xbr/XBR 前缀,避免与生图预设面板既有函数/常量重名。
|
||
* ========================================================================== */
|
||
|
||
const XBR_DEFAULT_PROVIDER = "OpenAI 兼容 (OpenAI/DeepSeek/Qwen/GLM/Kimi/Ollama/vLLM/LM Studio)";
|
||
const XBR_API_DEFAULTS = {
|
||
provider: XBR_DEFAULT_PROVIDER,
|
||
model: "deepseek-v4-flash-vision-exp",
|
||
api_key: "",
|
||
base_url: "https://api.deepseek.com/v1",
|
||
temperature: 0.6,
|
||
max_tokens: 8192,
|
||
thinking: "disabled",
|
||
};
|
||
const XBR_BACKENDS = [
|
||
{ value: "本地模型", label: "本地模型 [local]" },
|
||
{ value: "在线 API", label: "在线API [api]" },
|
||
];
|
||
const XBR_INFERENCE_MODES = ["one by one", "images", "video"];
|
||
const XBR_SEED_MODES = ["randomize", "fixed", "increment", "decrement"];
|
||
/** SKILL 三态(后端仍支持):自动 = 按模式推荐;手动 = 用选中的那份;不用 = 不生效
|
||
* ⚠️ Pro 面板不再直接暴露三态,只给「不使用 skill / 选文档名」二选一(见下面的 SKILL 分区) */
|
||
const XBR_SKILL_MODES = ["自动", "手动", "不用"];
|
||
/** 后端 xb_skills.SKILL_NONE 的字面值(= skill_name 下拉第一项)与面板上更好读的显示文案 */
|
||
const SKILL_NONE_VALUE = "不使用";
|
||
const SKILL_NONE_LABEL = "不使用 skill";
|
||
/** ⚠️ 模版(自动 / 文生图 / 图生图)已从本节点删除:Pro 固定文生图。
|
||
* 下面只保留一个常量,供基础面板的存档 key / 默认文本查询使用(值永远是「文生图」) */
|
||
const IO_T2I_ONLY = "文生图";
|
||
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类型 / 预设模式 / 增强预设(= 提示词设定)/ 反推预设 / 追加设定 / SKILL。提示词正文在节点上的提示词框里编辑。" },
|
||
];
|
||
const XBR_API_ENDPOINT = "/xb_toolbox/llm_api_settings";
|
||
|
||
/* ── 数值 / 解析助手 ─────────────────────────────────────── */
|
||
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; }
|
||
|
||
const XBR_INFERENCE_MODES_ = XBR_INFERENCE_MODES;
|
||
function xbrLlmDefaults() {
|
||
return {
|
||
model: { model: "", mmproj: "None", chat_handler: "None", n_ctx: 8192, vram_limit: -1, image_min_tokens: 0, image_max_tokens: 0 },
|
||
run: { inference_mode: "one by one", max_frames: 24, max_size: 256, seed: 0, seed_control: "randomize", force_offload: false, save_states: false, strip_thinking: true },
|
||
params: {
|
||
max_tokens: 6144, top_k: 40, top_p: 0.9, min_p: 0.05, typical_p: 1.0, temperature: 0.6,
|
||
repeat_penalty: 1.12, frequency_penalty: 0.0, present_penalty: 0.0,
|
||
mirostat_mode: 0, mirostat_eta: 0.1, mirostat_tau: 5.0, state_uid: -1,
|
||
},
|
||
extra_system: "",
|
||
};
|
||
}
|
||
/** 解析 manager_settings.llm(缺失/非法一律安全回落) */
|
||
function xbrParseLlm(raw) {
|
||
const d = (raw && typeof raw === "object") ? raw : {};
|
||
const cfg = xbrLlmDefaults();
|
||
const m = (d.model && typeof d.model === "object") ? d.model : {};
|
||
cfg.model.model = String(m.model ?? "");
|
||
cfg.model.mmproj = String(m.mmproj ?? "None");
|
||
cfg.model.chat_handler = String(m.chat_handler ?? "None");
|
||
cfg.model.n_ctx = xbrInt(m.n_ctx, cfg.model.n_ctx, 1024, 327680);
|
||
cfg.model.vram_limit = xbrInt(m.vram_limit, cfg.model.vram_limit, -1, 1024);
|
||
cfg.model.image_min_tokens = xbrInt(m.image_min_tokens, 0, 0, 4096);
|
||
cfg.model.image_max_tokens = xbrInt(m.image_max_tokens, 0, 0, 4096);
|
||
|
||
const r = (d.run && typeof d.run === "object") ? d.run : {};
|
||
cfg.run.inference_mode = xbrPick(r.inference_mode, XBR_INFERENCE_MODES, cfg.run.inference_mode);
|
||
cfg.run.max_frames = xbrInt(r.max_frames, cfg.run.max_frames, 2, 1024);
|
||
cfg.run.max_size = xbrInt(r.max_size, cfg.run.max_size, 128, 16384);
|
||
cfg.run.seed = xbrInt(r.seed, cfg.run.seed, 0, Number.MAX_SAFE_INTEGER);
|
||
cfg.run.seed_control = xbrPick(r.seed_control, XBR_SEED_MODES, cfg.run.seed_control);
|
||
cfg.run.force_offload = xbrFlag(r.force_offload, cfg.run.force_offload);
|
||
cfg.run.save_states = xbrFlag(r.save_states, cfg.run.save_states);
|
||
cfg.run.strip_thinking = xbrFlag(r.strip_thinking, cfg.run.strip_thinking); // 模型输出思考过程时自动过滤
|
||
|
||
const p = (d.params && typeof d.params === "object") ? d.params : {};
|
||
cfg.params.max_tokens = xbrInt(p.max_tokens, cfg.params.max_tokens, 0, 262144);
|
||
cfg.params.top_k = xbrInt(p.top_k, cfg.params.top_k, 0, 1000);
|
||
cfg.params.top_p = xbrNum(p.top_p, cfg.params.top_p, 0, 1);
|
||
cfg.params.min_p = xbrNum(p.min_p, cfg.params.min_p, 0, 1);
|
||
cfg.params.typical_p = xbrNum(p.typical_p, cfg.params.typical_p, 0, 1);
|
||
cfg.params.temperature = xbrNum(p.temperature, cfg.params.temperature, 0, 2);
|
||
cfg.params.repeat_penalty = xbrNum(p.repeat_penalty, cfg.params.repeat_penalty, 0, 10);
|
||
cfg.params.frequency_penalty = xbrNum(p.frequency_penalty, cfg.params.frequency_penalty, 0, 1);
|
||
cfg.params.present_penalty = xbrNum(p.present_penalty, cfg.params.present_penalty, 0, 2);
|
||
cfg.params.mirostat_mode = xbrInt(p.mirostat_mode, cfg.params.mirostat_mode, 0, 2);
|
||
cfg.params.mirostat_eta = xbrNum(p.mirostat_eta, cfg.params.mirostat_eta, 0, 1);
|
||
cfg.params.mirostat_tau = xbrNum(p.mirostat_tau, cfg.params.mirostat_tau, 0, 10);
|
||
cfg.params.state_uid = xbrInt(p.state_uid, cfg.params.state_uid, -1, 999999);
|
||
cfg.extra_system = String(d.extra_system ?? "");
|
||
return cfg;
|
||
}
|
||
|
||
/* ── 节点 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) || "");
|
||
}
|
||
/** 当前模版:Pro 固定文生图(模版选择已删;这个助手只为兼容调用点而保留) */
|
||
function xbrResolveIo(node, ioRaw) {
|
||
return IO_T2I_ONLY;
|
||
}
|
||
|
||
/* ── 预设选项按「输出语言」过滤 ───────────────────────────────
|
||
* 预设名的语言标签形如 "Z-Image Turbo [ZH]" / "Normal - 描述 [EN]";
|
||
* 输出语言选中文 → 增强预设 / 反推预设 只列 [ZH] 项,选英文 → 只列 [EN] 项。 */
|
||
function xbrLangTagOf(v) {
|
||
const m = String(v == null ? "" : v).match(/[\[[](ZH|EN)[\]]]\s*$/i);
|
||
return m ? m[1].toUpperCase() : "";
|
||
}
|
||
function xbrFilterByLang(opts, lang) {
|
||
const want = xbrLangTagOf(lang);
|
||
if (!want) return opts.slice();
|
||
const hit = opts.filter((o) => xbrLangTagOf(o) === want);
|
||
return hit.length ? hit : opts.slice(); // 全无语言标签(老预设)→ 全量兜底
|
||
}
|
||
/** 语言变了:优先换到「同名的另一语言项」,否则取该语言第一项 */
|
||
function xbrSnapPreset(opts, cur, lang) {
|
||
const list = xbrFilterByLang(opts, lang);
|
||
if (list.includes(cur)) return cur;
|
||
const base = String(cur == null ? "" : cur).replace(/\s*[\[[](ZH|EN)[\]]]\s*$/i, "").trim();
|
||
const same = list.find((o) => String(o).replace(/\s*[\[[](ZH|EN)[\]]]\s*$/i, "").trim() === base);
|
||
return same || list[0] || cur;
|
||
}
|
||
|
||
/* ── 在线 API(存 ComfyUI user 目录,Key 不进工作流)──────── */
|
||
async function xbrApiGet() {
|
||
const resp = await api.fetchApi(XBR_API_ENDPOINT);
|
||
const data = await resp.json().catch(() => ({}));
|
||
if (!resp.ok || !data?.ok) throw new Error(data?.error || `HTTP ${resp.status}`);
|
||
return { settings: data.settings || {}, providers: data.providers || [] };
|
||
}
|
||
async function xbrApiPut(payload) {
|
||
const resp = await api.fetchApi(XBR_API_ENDPOINT, {
|
||
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload),
|
||
});
|
||
const data = await resp.json().catch(() => ({}));
|
||
if (!resp.ok || !data?.ok) throw new Error(data?.error || `HTTP ${resp.status}`);
|
||
return data.settings || payload;
|
||
}
|
||
function xbrNormApi(raw) {
|
||
const x = { ...XBR_API_DEFAULTS, ...(raw && typeof raw === "object" ? raw : {}) };
|
||
x.provider = String(x.provider || "").trim() || XBR_DEFAULT_PROVIDER;
|
||
x.model = String(x.model ?? "").trim() || XBR_API_DEFAULTS.model;
|
||
x.base_url = String(x.base_url ?? "").trim() || XBR_API_DEFAULTS.base_url;
|
||
x.api_key = String(x.api_key ?? "");
|
||
x.temperature = xbrNum(x.temperature, XBR_API_DEFAULTS.temperature, 0, 2);
|
||
x.max_tokens = xbrInt(x.max_tokens, XBR_API_DEFAULTS.max_tokens, 1, 262144);
|
||
x.thinking = (x.thinking === "enabled") ? "enabled" : "disabled";
|
||
return x;
|
||
}
|
||
|
||
/* ── 本地模型候选(取「📦 模型加载器」的 object_info,单一真相)── */
|
||
let xbrModelLists = null;
|
||
async function xbrLoadModelLists(force) {
|
||
if (xbrModelLists && !force) return xbrModelLists;
|
||
try {
|
||
const resp = await api.fetchApi("/object_info/XB_llamaModelLoader");
|
||
const data = await resp.json();
|
||
const req = data?.XB_llamaModelLoader?.input?.required || {};
|
||
xbrModelLists = {
|
||
model: Array.isArray(req.model?.[0]) ? req.model[0] : [],
|
||
mmproj: Array.isArray(req.mmproj?.[0]) ? req.mmproj[0] : ["None"],
|
||
chat_handler: Array.isArray(req.chat_handler?.[0]) ? req.chat_handler[0] : ["None"],
|
||
};
|
||
} catch (_) {
|
||
xbrModelLists = { model: [], mmproj: ["None"], chat_handler: ["None"] };
|
||
}
|
||
return xbrModelLists;
|
||
}
|
||
|
||
/* ── 控件:数字 / 密码 / 单选行 / 种子行 ─────────────────── */
|
||
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;
|
||
}
|
||
|
||
/* ── 弹窗内容:🤖 大语言模型配置 ─────────────────────────── */
|
||
function xbrRenderLlm(body, node, draft, ctx) {
|
||
const s = draft.settings.llm;
|
||
|
||
body.append(makeSectionTitle("输出语言(全节点唯一的语言设置)"));
|
||
body.append(field("输出语言", selectControl(LANGS, draft.lang, (v) => {
|
||
draft.lang = v;
|
||
// 换语言 → 未在弹窗里改过设定词时,跟着取该语言的「存档 → 默认」
|
||
if (!draft.presetTouched) draft.presetText = xbrPresetTextFor(node, draft.mode, v, IO_T2I);
|
||
// 语言决定预设可选范围:增强预设 / 反推预设 自动换到同语言的同名项(没有则取该语言第一项)
|
||
draft.preset = xbrSnapPreset(xbrWidgetOptions(node, "preset"), draft.preset, v);
|
||
draft.task_preset = xbrSnapPreset(xbrWidgetOptions(node, "task_preset"), draft.task_preset, v);
|
||
})));
|
||
body.append(xbrHint("本项是唯一的语言设置:① 词表 / 设定词 / 预设句按哪种语言加载 ② 增强预设与反推预设只列该语言的选项 ③ 最终输出的提示词语言。"));
|
||
|
||
body.append(makeSectionTitle("提示词增强反推(总开关在节点表面的「启用 LLM 反推」)"));
|
||
|
||
body.append(makeSectionTitle("LLM 后端"));
|
||
body.append(field("LLM 后端", radioRow(XBR_BACKENDS, draft.backend, (v) => { draft.backend = v; ctx.rerender(); })));
|
||
|
||
if (draft.backend !== "在线 API") {
|
||
body.append(makeSectionTitle("本地 LLM 模型"));
|
||
const lists = xbrModelLists || { model: [], mmproj: ["None"], chat_handler: ["None"] };
|
||
const llm = s.model;
|
||
const models = lists.model.length ? lists.model : (llm.model ? [llm.model] : []);
|
||
if (!llm.model && models.length) llm.model = models[0];
|
||
body.append(field("强制卸载", checkboxControl(s.run.force_offload, "LLM 用完即卸载,释放显存", (v) => { s.run.force_offload = v; })));
|
||
if (models.length) body.append(field("模型", selectControl(models, llm.model, (v) => { llm.model = v; })));
|
||
else body.append(field("模型", xbrTextControl(llm.model, "未找到本地 LLM 模型(.gguf 放到 models/LLM)", (v) => { llm.model = v; })));
|
||
body.append(field("视觉模块 mmproj", selectControl(lists.mmproj.length ? lists.mmproj : ["None"], llm.mmproj, (v) => { llm.mmproj = v; })));
|
||
body.append(field("Chat Handler", selectControl(lists.chat_handler.length ? lists.chat_handler : ["None"], llm.chat_handler, (v) => { llm.chat_handler = v; })));
|
||
body.append(field("上下文长度 n_ctx", xbrNumberControl(llm.n_ctx, { min: 1024, max: 327680, step: 128 }, (v) => { llm.n_ctx = Math.round(v); })));
|
||
body.append(field("显存上限 vram_limit (GB)", xbrNumberControl(llm.vram_limit, { min: -1, max: 1024, step: 1 }, (v) => { llm.vram_limit = Math.round(v); })));
|
||
body.append(field("图像最小 tokens", xbrNumberControl(llm.image_min_tokens, { min: 0, max: 4096, step: 32 }, (v) => { llm.image_min_tokens = Math.round(v); })));
|
||
body.append(field("图像最大 tokens", xbrNumberControl(llm.image_max_tokens, { min: 0, max: 4096, step: 32 }, (v) => { llm.image_max_tokens = Math.round(v); })));
|
||
|
||
const p = s.params;
|
||
body.append(field("max_tokens", xbrNumberControl(p.max_tokens, { min: 0, max: 262144, step: 1 }, (v) => { p.max_tokens = Math.round(v); })));
|
||
body.append(field("top_k", xbrNumberControl(p.top_k, { min: 0, max: 1000, step: 1 }, (v) => { p.top_k = Math.round(v); })));
|
||
body.append(field("top_p", xbrNumberControl(p.top_p, { min: 0, max: 1, step: 0.01 }, (v) => { p.top_p = v; })));
|
||
body.append(field("min_p", xbrNumberControl(p.min_p, { min: 0, max: 1, step: 0.01 }, (v) => { p.min_p = v; })));
|
||
body.append(field("typical_p", xbrNumberControl(p.typical_p, { min: 0, max: 1, step: 0.01 }, (v) => { p.typical_p = v; })));
|
||
body.append(field("temperature", xbrNumberControl(p.temperature, { min: 0, max: 2, step: 0.01 }, (v) => { p.temperature = v; })));
|
||
body.append(field("repeat_penalty", xbrNumberControl(p.repeat_penalty, { min: 0, max: 10, step: 0.01 }, (v) => { p.repeat_penalty = v; })));
|
||
body.append(field("frequency_penalty", xbrNumberControl(p.frequency_penalty, { min: 0, max: 1, step: 0.01 }, (v) => { p.frequency_penalty = v; })));
|
||
body.append(field("present_penalty", xbrNumberControl(p.present_penalty, { min: 0, max: 2, step: 0.01 }, (v) => { p.present_penalty = v; })));
|
||
body.append(field("mirostat_mode", xbrNumberControl(p.mirostat_mode, { min: 0, max: 2, step: 1 }, (v) => { p.mirostat_mode = Math.round(v); })));
|
||
body.append(field("mirostat_eta", xbrNumberControl(p.mirostat_eta, { min: 0, max: 1, step: 0.01 }, (v) => { p.mirostat_eta = v; })));
|
||
body.append(field("mirostat_tau", xbrNumberControl(p.mirostat_tau, { min: 0, max: 10, step: 0.01 }, (v) => { p.mirostat_tau = v; })));
|
||
}
|
||
|
||
if (draft.backend === "在线 API") {
|
||
body.append(makeSectionTitle("在线 API"));
|
||
const a = draft.api || (draft.api = { ...XBR_API_DEFAULTS });
|
||
body.append(field("服务商", selectControl(ctx.providers, a.provider, (v) => { a.provider = v; })));
|
||
body.append(field("模型", xbrTextControl(a.model, XBR_API_DEFAULTS.model, (v) => { a.model = v; })));
|
||
body.append(field("API Key", xbrPasswordControl(a.api_key, "sk-… 保存在本地,不写入工作流", (v) => { a.api_key = v; })));
|
||
body.append(field("Base URL", xbrTextControl(a.base_url, XBR_API_DEFAULTS.base_url, (v) => { a.base_url = v; })));
|
||
body.append(field("temperature", xbrNumberControl(a.temperature, { min: 0, max: 2, step: 0.01 }, (v) => { a.temperature = v; })));
|
||
body.append(field("max_tokens", xbrNumberControl(a.max_tokens, { min: 1, max: 262144, step: 1 }, (v) => { a.max_tokens = Math.round(v); })));
|
||
body.append(field("thinking", selectControl(["disabled", "enabled"], a.thinking, (v) => { a.thinking = v; })));
|
||
body.append(el("div", "font-size:11px;color:#888;line-height:1.6;margin:2px 0 8px;", "默认:deepseek-v4-flash-vision-exp | https://api.deepseek.com/v1(可改)。配置保存在本地 ComfyUI user 目录,不会写入工作流。"));
|
||
}
|
||
|
||
body.append(makeSectionTitle("随机种子"));
|
||
body.append(xbrSeedRow(s.run, () => ctx.touch?.()));
|
||
body.append(el("div", "font-size:11px;color:#888;line-height:1.6;margin:2px 0 8px;", "点「🔁」切换生成后控制:随机 = 运行后自动换新种子;固定 = 锁定;增加/减少 = 运行后 ±1(后端算完会回写)。"));
|
||
|
||
body.append(makeSectionTitle("指令推理"));
|
||
body.append(field("推理模式", selectControl(XBR_INFERENCE_MODES, s.run.inference_mode, (v) => { s.run.inference_mode = v; })));
|
||
body.append(field("最大帧数", xbrNumberControl(s.run.max_frames, { min: 2, max: 1024, step: 1 }, (v) => { s.run.max_frames = Math.round(v); })));
|
||
body.append(field("最大尺寸", xbrNumberControl(s.run.max_size, { min: 128, max: 16384, step: 64 }, (v) => { s.run.max_size = Math.round(v); })));
|
||
body.append(field("保存对话状态", checkboxControl(s.run.save_states, "在内存中保留本次对话上下文,多轮连续反推", (v) => { s.run.save_states = v; })));
|
||
body.append(field("状态 UID", xbrNumberControl(s.params.state_uid, { min: -1, max: 999999, step: 1 }, (v) => { s.params.state_uid = Math.round(v); })));
|
||
body.append(field("🧠 过滤思考过程", checkboxControl(s.run.strip_thinking,
|
||
"模型把「思考过程 / 推理段 / 工作流程」一起输出时,自动只保留最终提示词", (v) => { s.run.strip_thinking = v; })));
|
||
body.append(el("div", "font-size:11px;color:#888;line-height:1.65;margin:2px 0 8px;",
|
||
"开启后=① 系统提示词末尾追加「只输出最终提示词」硬规则 ② 仍漏出思考时自动剔除思考标签、"
|
||
+ "Final Output / 最终输出 标记之前的推理段与代码围栏(拿不到内容则原文保留)。"));
|
||
body.append(el("div", "font-size:11px;color:#888;line-height:1.6;margin:2px 0 8px;", "提示词正文在节点上的提示词框里编辑;「📝 提示词」端口接线后该框锁定。"));
|
||
}
|
||
|
||
/* ── 弹窗内容:✨ 提示词增强预设 ─────────────────────────── */
|
||
function xbrRenderPreset(body, node, draft, ctx) {
|
||
const s = draft.settings.llm;
|
||
|
||
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("预设模式", selectControl(MODES, draft.mode, (v) => {
|
||
draft.mode = v;
|
||
// 换模式 → 立刻取该模式的设定词(用户改过的存档 → 默认)
|
||
draft.presetTouched = false;
|
||
draft.presetText = xbrPresetTextFor(node, v, draft.lang, IO_T2I);
|
||
ctx.rerender(); // 有/无设定词的模式之间切换 → 重画「设定词」区
|
||
})));
|
||
// 当前模式提示(Pro 没有图生图档:模版固定文生图)
|
||
body.append(el("div", "font-size:11px;color:#888;line-height:1.7;margin:2px 0 8px;",
|
||
(draft.mode === MODE_TP)
|
||
? "无预设:不前置设定词(下面「设定词」栏保持空白;想加就自己写)。"
|
||
: "设定词会作为【参考设定】送给 LLM,并原封不动置顶到最终提示词最顶端(本节点固定文生图,设定词只有一套)。"));
|
||
|
||
// ── 设定词(按「模式 × 语言」取;只在弹窗里编辑,节点表面不显示;本节点固定文生图)──
|
||
// 无预设档:这一栏**照样显示**,只是内容空白(用户可以自己写)
|
||
const modeDef = presetTextOf(draft.mode, draft.lang, IO_T2I);
|
||
{
|
||
body.append(makeSectionTitle("设定词"));
|
||
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
|
||
? "该模式在当前语言下的设定词;改过的按「模式 + 语言」记进节点,换模式 / 换语言都不丢"
|
||
: "无预设:本档不前置设定词(保持空白即可;在这里写内容就会作为设定词置顶)";
|
||
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"
|
||
+ "· 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)"));
|
||
const presetOpts = xbrFilterByLang(xbrWidgetOptions(node, "preset"), draft.lang);
|
||
if (presetOpts.length) body.append(field("增强预设", selectControl(presetOpts, draft.preset, (v) => { draft.preset = v; })));
|
||
body.append(el("div", "font-size:11px;color:#888;line-height:1.6;margin:2px 0 8px;",
|
||
`该预设内容即「🧩 提示词设定」输出的正文,同时作为 LLM 反推的系统提示词;只列与输出语言 ${draft.lang} 相符的预设。`));
|
||
|
||
body.append(makeSectionTitle("反推预设"));
|
||
const taskOpts = xbrFilterByLang(xbrWidgetOptions(node, "task_preset"), draft.lang);
|
||
if (taskOpts.length) body.append(field("反推预设", selectControl(taskOpts, draft.task_preset, (v) => { draft.task_preset = v; })));
|
||
body.append(el("div", "font-size:11px;color:#888;line-height:1.6;margin:2px 0 8px;", "带 * 的预设里 * 是必填占位符,由节点上的提示词框或外接「📝 提示词」填入。"));
|
||
|
||
body.append(makeSectionTitle("追加设定(可留空)"));
|
||
const ta = textareaControl(s.extra_system, (v) => { s.extra_system = v; },
|
||
"width:100%;box-sizing:border-box;min-height:200px;resize:vertical;");
|
||
ta.placeholder = "追加在增强预设之后的额外要求,例:只输出一行、不要解释过程…";
|
||
ta.spellcheck = false;
|
||
body.append(ta);
|
||
|
||
// ── SKILL(系统提示词):单选下拉 = 不使用 skill / 选文档名(三态勾选已按用户要求删除)──
|
||
body.append(makeSectionTitle("SKILL(系统提示词)"));
|
||
const skillOptsRaw = xbrWidgetOptions(node, "skill_name");
|
||
// 显示列:第一项固定为「不使用 skill」(值 = 后端 SKILL_NONE),其余为文档名
|
||
const skillOpts = [SKILL_NONE_LABEL].concat(skillOptsRaw.filter((v) => v && v !== SKILL_NONE_VALUE));
|
||
// 旧工作流里可能是「自动 / 手动 / 不用」三态:自动 → 显示它当时实际用的那份文档
|
||
const autoFile = (typeof modeSkillHint === "function") ? modeSkillHint(draft.mode) : "";
|
||
if (!draft.skillPicked) {
|
||
const sm = String(draft.skillMode || "");
|
||
const cur = String(draft.skill || "");
|
||
if (sm === "自动") draft.skill = skillOpts.includes(autoFile) ? autoFile : SKILL_NONE_LABEL;
|
||
else if (sm === "不用" || !cur || cur === SKILL_NONE_VALUE) draft.skill = SKILL_NONE_LABEL;
|
||
else draft.skill = skillOpts.includes(cur) ? cur : SKILL_NONE_LABEL;
|
||
draft.skillPicked = true;
|
||
}
|
||
if (!skillOpts.includes(draft.skill)) draft.skill = SKILL_NONE_LABEL;
|
||
body.append(field("SKILL", selectControl(skillOpts, draft.skill, (v) => {
|
||
draft.skill = v;
|
||
draft.skillPicked = true;
|
||
ctx.rerender();
|
||
})));
|
||
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"); }
|
||
}));
|
||
foxRowP.append(el("span", "font-size:11px;color:#8fb;",
|
||
draft.skill === SKILL_NONE_LABEL ? "当前 = 不使用 skill(LLM 用内置默认系统提示词)" : ("当前 = " + draft.skill)));
|
||
body.append(foxRowP);
|
||
body.append(el("div", "font-size:11px;color:#888;line-height:1.75;margin:6px 0 8px;",
|
||
"· 不使用 skill = 不把任何技能文档当系统提示词(LLM 用内置默认模板);\n"
|
||
+ "· 选文档 = 该文档整段作为 LLM 的 system prompt(放在最前面当角色与总规则,**不再叠加**节点的语言提示);\n"
|
||
+ "· 技能文件放在 XB_ToolBox/support_llama/skills(.txt / .md),新增后刷新页面即可出现在下拉里;\n"
|
||
+ "· 「✅ 启用 LLM 反推」关闭时 SKILL 不参与。"));
|
||
}
|
||
|
||
/* ── 弹窗内容:📖 使用说明 ───────────────────────────────── */
|
||
function xbrRenderHelp(body) {
|
||
const lines = [
|
||
"【一句话】",
|
||
" XB-BOX 生图提示词预设Pro = 「🖼️ 生图提示词预设」+「✨ 提示词增强反推」融合成 1 个节点",
|
||
" 左边拼提示词,右边配画布,中间还能让 AI 帮你写、帮你看图改图",
|
||
"",
|
||
"【三步上手】",
|
||
" 1. 📊 空latent 接采样器;📝 提示词 接文本编码器",
|
||
" 2. 点第 2 行的分类按钮挑词;短句直接写在节点提示词框里",
|
||
" 3. 想让 AI 代写:开 ✅ 启用 LLM 反推,先到 🤖 LLM设置 选好模型或填好 API",
|
||
"",
|
||
"【节点表面】",
|
||
" · 第 1 行按钮:🤖 LLM设置 | ✨ 预设参数",
|
||
" · 第 2 行按钮:🎨 风格 | 📐 视角 | 👤 主体 | 🕺 姿态 | 👗 装扮 | 🎒 道具 | 💡 光影 | 🏞️ 背景",
|
||
" · 画幅比例 / 宽度 / 高度 / 生成数量:数值会按当前空latent类型的官方步长自动对齐",
|
||
" · ✅ 启用 LLM 反推:关 = 只输出拼装好的提示词,不加载模型、不调 API",
|
||
" · 提示词框右上角 📋 复制;下面是参数设定显示,实时告诉你当前是什么配置",
|
||
"",
|
||
"【元素面板怎么用】",
|
||
" · 点选项行 = 加入提示词,再点一下 = 移除",
|
||
" · 悬停选项行可以看它的中文与英文写法",
|
||
" · 【添加详细描述】给该词条写补充说明,会跟着一起成句",
|
||
" · ➕ / ➖ 自建专属词条,✎ 编辑名称",
|
||
" · 搜索框与「全部加入」只作用于当前子类",
|
||
"",
|
||
"【输入端口】",
|
||
" · 📝 提示词:外接提示词;接线后节点上的提示词框会锁定",
|
||
" · 🖼️ 图像:外接图像 / 视频帧",
|
||
" · 只有文字 = 把这段文字写成完整的画面提示词",
|
||
" · 只有图 = 看图写提示词",
|
||
" · 有图 + 有文字 = 看图改图:先识图,再把文字当【修改指令】执行,最终只输出修改后的画面提示词,",
|
||
" 不会写成「原本…被替换为…」;万一模型写成对比式,节点会自动让它重写一次",
|
||
"",
|
||
"【输出端口】",
|
||
" · 📝 提示词:最终提示词,直接进文本编码器",
|
||
" · 📊 空latent:第二位,直接进采样器",
|
||
" · 📋 提示词列表:按行拆分,方便批量处理",
|
||
" · 🧩 提示词设定:本次的提示词设定,即增强预设 + 输出语言 + 追加设定 + 设定词",
|
||
"",
|
||
"【空latent 类型】",
|
||
" Anima / Boogu / Flux2 / Hunyuan / Krea2 / Qwen-image / SD3 / SDXL / Z-image",
|
||
" 选你正在用的模型即可:通道数、下采样、尺寸步长、batch 上限会自动切换",
|
||
"",
|
||
"【预设模式】(4 档,每档自带一段设定词,可自由修改)",
|
||
" · 无预设 / 人物三视图 / 人物四视图 / 人物五视图",
|
||
" · 设定词可以自由修改,改过的那一版按「模式 + 语言」记在节点里,换模式 / 换语言都不会丢",
|
||
" · 点 ♻️ 恢复默认 一键回到官方文本;启用 LLM 时设定词另作增强参考交给模型",
|
||
" · 启用 LLM 时,设定词只作为增强参考交给模型,输出时由节点原封不动加在最顶端",
|
||
" · 本节点固定文生图(模版选择已删):不会出现「以输入图为准」类条款",
|
||
" · 🖼️ 图像端口只用于 LLM 反推(看图改图 / 看图反推),不会改变设定词",
|
||
"",
|
||
"【SKILL 技能(系统提示词)】",
|
||
" · ✨ 预设参数 → SKILL:support_llama/skills 里的 txt 整段当 LLM 的 system prompt",
|
||
" 面板里点「📂 打开 SKILL 文件夹」可直接打开,新增后刷新页面即出现在下拉",
|
||
" · SKILL(二选一):",
|
||
" ◆ 不使用 skill = 不送任何技能文档(LLM 用内置默认系统提示词)",
|
||
" ◆ 选具体文档名 = 该文档整段当 system prompt(不再叠加节点的语言提示)",
|
||
" · 选中后系统提示词 = 技能 + 增强预设 + 设定词参考 + 追加设定 + 任务块",
|
||
" · 技能自带语言与输出契约 → 选中时不再叠加节点的语言提示(避免互相对打)",
|
||
" · 勾选开关关掉 ✅ 启用 LLM 反推时 SKILL 不参与",
|
||
"",
|
||
"【语言只有一处】",
|
||
" 🤖 LLM设置 → 输出语言,它同时决定:词表与设定词按哪种语言加载、",
|
||
" 增强预设与反推预设只列哪种语言的选项、最终提示词是中文还是英文",
|
||
"",
|
||
"【输出为什么总是干净的】",
|
||
" · 🧠 过滤思考过程 默认开:系统提示词末尾有最高优先级的「只输出最终提示词」硬规则",
|
||
" · 模型仍吐思考过程时,自动剔掉思考标签、Final Output / 最终输出 之前的推理段与代码围栏",
|
||
" · 拿不到内容就原样保留,绝不会把你的提示词清空",
|
||
"",
|
||
"【保存】",
|
||
" · 弹窗底部:☑ 自动保存(默认开)、字号 A− / A+、界面 − / %",
|
||
" · 所有配置随工作流保存;在线 API 的 Key 存在 ComfyUI 用户目录,不写进工作流",
|
||
" · 「取消」丢弃未保存改动",
|
||
"",
|
||
"【常见问题】",
|
||
" · 报「未选择本地模型」:去 🤖 LLM设置 选一个 .gguf,或者切到在线 API",
|
||
" · 在线 API 报错:检查服务商 / 模型名 / Base URL / API Key",
|
||
" · 想让结果更贴原图:温度调低一点,或换一档更偏描述的增强预设",
|
||
" · 节点表面参数很少?故意的:选项都收在弹窗里,表面只留最常动的几个",
|
||
" · 改完参数要重新执行节点才生效",
|
||
];
|
||
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: { llm: xbrParseLlm(cur.llm) },
|
||
backend: String(xbrWidgetVal(node, "backend") ?? XBR_BACKENDS[0].value),
|
||
preset: String(xbrWidgetVal(node, "preset") ?? ""),
|
||
task_preset: String(xbrWidgetVal(node, "task_preset") ?? ""),
|
||
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]),
|
||
// 设定词(Pro 固定文生图,每个模式只有一套):弹窗内草稿 + 「用户是否改过」标记
|
||
// 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]);
|
||
if (w.trim() && !isDefaultPresetText(md, w)) return w;
|
||
return xbrPresetTextFor(node, md, lg, IO_T2I);
|
||
})(),
|
||
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 : "自动"; })(),
|
||
skillPicked: false, // 面板上是否已经按「不使用 skill / 选文档」映射过一次
|
||
};
|
||
// 预设选项与输出语言对齐(旧工作流可能存着异语言的预设名)
|
||
draft.preset = xbrSnapPreset(xbrWidgetOptions(node, "preset"), draft.preset, draft.lang);
|
||
draft.task_preset = xbrSnapPreset(xbrWidgetOptions(node, "task_preset"), draft.task_preset, draft.lang);
|
||
|
||
let apiSaved = null, providers = [XBR_DEFAULT_PROVIDER];
|
||
if (panelId === "llm") {
|
||
try {
|
||
const r = await xbrApiGet();
|
||
apiSaved = xbrNormApi(r.settings);
|
||
if (r.providers?.length) providers = r.providers;
|
||
} catch (_) { apiSaved = xbrNormApi(node.__xbrProApiInfo); }
|
||
node.__xbrProApiInfo = apiSaved;
|
||
draft.api = { ...apiSaved };
|
||
}
|
||
|
||
const commit = async () => {
|
||
if (panelId === "llm" && draft.api && apiSaved
|
||
&& JSON.stringify(xbrNormApi(draft.api)) !== JSON.stringify(apiSaved)) {
|
||
try {
|
||
apiSaved = xbrNormApi(await xbrApiPut(xbrNormApi(draft.api)));
|
||
draft.api = { ...apiSaved };
|
||
node.__xbrProApiInfo = apiSaved;
|
||
} catch (e) { notify("API 配置保存失败:" + ((e && e.message) || e), "error"); }
|
||
}
|
||
xbrSetWidget(node, "output_lang", draft.lang, true); // 唯一语言设置
|
||
xbrSetWidget(node, "latent_kind", draft.kind, true); // 触发基础面板的步长/上限联动
|
||
xbrSetWidget(node, "preset_mode", draft.mode, true); // 触发预设句框显隐
|
||
// SKILL:单选下拉 = 不使用 skill / 选文档名 → 落盘成「skill_mode + skill_name」两件套
|
||
xbrSetWidget(node, "skill_name", draft.skill === SKILL_NONE_LABEL ? SKILL_NONE_VALUE : draft.skill);
|
||
xbrSetWidget(node, "skill_mode", draft.skill === SKILL_NONE_LABEL ? "不用" : "手动");
|
||
// 设定词:写进节点 widget(原样)+ 按「模式|语言」存档 → 换模式 / 换语言都不丢
|
||
// ⚠️ 一律写(包含「无预设」时写空串)→ 顺手把 widget 里的遗留默认句清掉
|
||
try {
|
||
node.__ippSetPreset?.(draft.presetText);
|
||
node.__ippWritePreset?.(draft.mode, IO_T2I, draft.lang, draft.presetText);
|
||
} catch (_) {}
|
||
xbrSetWidget(node, "backend", draft.backend, true);
|
||
xbrSetWidget(node, "preset", draft.preset, true);
|
||
xbrSetWidget(node, "task_preset", draft.task_preset, true);
|
||
xbrSaveJson(node, { llm: draft.settings.llm, auto_save: autoSaveRef.value !== false });
|
||
try { node.__xbrProRefreshInfo?.(); } 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.__xbrProRefreshInfo?.(); } catch (_) {} },
|
||
}).body;
|
||
|
||
const ctx = {
|
||
providers,
|
||
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();
|
||
if (panelId === "llm" && xbrModelLists === null) xbrLoadModelLists().then(() => ctx.rerender());
|
||
}
|
||
|
||
/* ── 按钮区 / 提示词框标题行 / 参数设定显示 / 锁定 ─────────── */
|
||
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();
|
||
}
|
||
|
||
/** 参数设定显示(只读摘要 7 行;按用户要求:不显示括号与注释) */
|
||
function xbrInfoLines(node, promptText) {
|
||
const llm = xbrParseLlm(xbrCurJson(node).llm);
|
||
const useLlm = !!xbrWidgetVal(node, "use_llm");
|
||
const backend = String(xbrWidgetVal(node, "backend") ?? "");
|
||
const pMode = String(xbrWidgetVal(node, "preset_mode") ?? "");
|
||
const line1 = `📊 空latent:${xbrClean(xbrWidgetVal(node, "latent_kind"))} | ${xbrWidgetVal(node, "width")}x${xbrWidgetVal(node, "height")} | 数量 ${xbrWidgetVal(node, "batch_size")}`;
|
||
// 只显示选项名称(预设模式),不再显示设定词 / 模版的状态
|
||
const line2 = `🎨 预设模式:${xbrClean(pMode)}`;
|
||
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 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");
|
||
}
|
||
|
||
/** 提示词框标题行(左标签 + 右复制)+ 参数设定显示;并挂上端口锁定/执行回写/摘要刷新 */
|
||
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.__xbrProRefreshInfo = 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();
|
||
|
||
// 生成后控制:后端算好的「下一次种子」回写进 manager_settings.llm.run.seed
|
||
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 = xbrCurJson(node);
|
||
const llm = xbrParseLlm(cur.llm);
|
||
if (Number(ns) !== Number(llm.run.seed)) {
|
||
llm.run.seed = Math.max(0, Math.round(Number(ns)) || 0);
|
||
xbrSaveJson(node, { llm });
|
||
}
|
||
}
|
||
refresh();
|
||
} catch (_) {}
|
||
return r;
|
||
};
|
||
|
||
// 表面参数变化 → 摘要刷新(只 setDirtyCanvas,不动用 setSize)
|
||
for (const nm of ["latent_kind", "output_lang", "preset_mode", "use_llm", "backend", "preset", "task_preset", "width", "height", "batch_size", "aspect_ratio"]) {
|
||
const w = xbrWidget(node, nm);
|
||
if (!w || w.__xbrProHooked) continue;
|
||
w.__xbrProHooked = 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); });
|
||
|
||
xbrApiGet().then((r) => { node.__xbrProApiInfo = xbrNormApi(r.settings); refresh(); }).catch(() => { refresh(); });
|
||
setTimeout(refresh, 0);
|
||
return [head, infoBox];
|
||
}
|