- PromptChain_ZImageRegionalCouple: $name{} per-character regional conditioning on
Z-Image's joint attention (DiT analog of AttentionCouple); base+turbo 3D-regional templates.
- Z-Image as a pickable UPSCALE engine + 'use current model' INPAINT (source path traces
CLIPLoader/lumina2); family-aware so base=dpmpp_sde/cfg4, turbo=res_multistep/cfg1.3/0.7.
- PoseStudio true-depth fix: range from posed bones (was bind-box -> figures under-exposed).
- Base model config + templates aligned to dpmpp_sde/cfg4; user-saved templates keep their
settings (lockSettings) without freezing model-swap.
- Edit-modal region-upscale: first-apply blank-layer fix (mask snapshot vs deselect race).
479 lines
20 KiB
JavaScript
479 lines
20 KiB
JavaScript
// Model bridge — shared registry that breaks the circular dependency
|
|
// between model-settings.js and model-indicator.js.
|
|
// Also owns applyTemplate (pure graph logic used by both sides).
|
|
|
|
import { app } from "../../../scripts/app.js";
|
|
import { getLink } from "./slot-utils.js";
|
|
|
|
// ── callback registry ─────────────────────────────────────────────
|
|
|
|
let _showCatalogDownloadModal;
|
|
let _showDownloadModal;
|
|
let _showModelModal;
|
|
let _closeModelModal;
|
|
|
|
export function registerCatalogDownloadModal(fn) { _showCatalogDownloadModal = fn; }
|
|
export function showCatalogDownloadModal(...args) { _showCatalogDownloadModal?.(...args); }
|
|
|
|
let _showNodePackInstallModal;
|
|
export function registerNodePackInstallModal(fn) { _showNodePackInstallModal = fn; }
|
|
export function showNodePackInstallModal(...args) { _showNodePackInstallModal?.(...args); }
|
|
|
|
export function registerDownloadModal(fn) { _showDownloadModal = fn; }
|
|
export function showDownloadModal(...args) { _showDownloadModal?.(...args); }
|
|
|
|
export function registerModelModal(showFn, closeFn) {
|
|
_showModelModal = showFn;
|
|
_closeModelModal = closeFn;
|
|
}
|
|
export function showModelModal(...args) { _showModelModal?.(...args); }
|
|
export function closeModelModal() { _closeModelModal?.(); }
|
|
|
|
// ── template application (graph logic) ────────────────────────────
|
|
|
|
export const MODEL_WIDGET_NAMES = ["ckpt_name", "unet_name", "model_name"];
|
|
|
|
function slugify(name) {
|
|
return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
}
|
|
|
|
function removeWorkflowNodes(pcNode) {
|
|
const graph = app.graph;
|
|
if (!graph) return;
|
|
|
|
const pcType = pcNode.comfyClass || "PromptChain";
|
|
const toRemove = new Set();
|
|
|
|
// BFS downstream
|
|
const downQueue = [pcNode];
|
|
const downVisited = new Set([pcNode.id]);
|
|
while (downQueue.length) {
|
|
const n = downQueue.shift();
|
|
for (const output of n.outputs || []) {
|
|
if (!output.links?.length) continue;
|
|
for (const lid of output.links) {
|
|
const link = getLink(graph, lid);
|
|
if (!link) continue;
|
|
const target = graph.getNodeById(link.target_id);
|
|
if (!target || downVisited.has(target.id)) continue;
|
|
downVisited.add(target.id);
|
|
if (target.comfyClass === pcType) continue;
|
|
toRemove.add(target);
|
|
downQueue.push(target);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Expand upstream from captured nodes
|
|
const upQueue = [...toRemove];
|
|
while (upQueue.length) {
|
|
const n = upQueue.shift();
|
|
for (const input of n.inputs || []) {
|
|
if (input.link == null) continue;
|
|
const link = getLink(graph, input.link);
|
|
if (!link) continue;
|
|
const source = graph.getNodeById(link.origin_id);
|
|
if (!source || toRemove.has(source) || source.id === pcNode.id) continue;
|
|
if (source.comfyClass === pcType) continue;
|
|
toRemove.add(source);
|
|
upQueue.push(source);
|
|
}
|
|
}
|
|
|
|
for (const n of toRemove) graph.remove(n);
|
|
}
|
|
|
|
function ensureComboOption(widget, val) {
|
|
if (val != null && widget.options?.values && !widget.options.values.includes(val)) {
|
|
widget.options.values.push(val);
|
|
}
|
|
}
|
|
|
|
// A model filename is "<base>-<variant>.<ext>", where <variant> is a GGUF quant
|
|
// (Q5_K_M, Q8_0, IQ2_XXS…) or a safetensors precision (fp8_scaled,
|
|
// fp8_e4m3fn_scaled, fp16, bf16…). The base identifies the model file (e.g. a
|
|
// Wan high-noise expert) independent of which variant was downloaded.
|
|
const _VARIANT_RE = /^(.*)[-_]((?:Q\d+(?:_[0-9A-Za-z]+)*|IQ\d+[A-Za-z0-9_]*|fp8(?:_[a-z0-9]+)*|fp16|bf16|fp32|fp4|f16|f32)(?:_scaled)?)\.(gguf|safetensors|ckpt)$/i;
|
|
|
|
export function variantParts(name) {
|
|
const m = _VARIANT_RE.exec(name || "");
|
|
return m ? { base: m[1].toLowerCase(), token: m[2].toLowerCase(), ext: m[3].toLowerCase() } : null;
|
|
}
|
|
|
|
// Templates bake one variant; the user may have downloaded another. Remap the
|
|
// baked filename to whichever variant of the SAME model file is actually
|
|
// installed (read from the loader widget's own combo list). `ref` carries the
|
|
// variant chosen by a sibling loader so a multi-expert recipe (Wan high+low
|
|
// noise) stays on a single quant when several are on disk. No-op when the baked
|
|
// file is already installed or no same-base variant exists.
|
|
export function resolveInstalledVariant(baked, installed, ref) {
|
|
if (!Array.isArray(installed) || installed.includes(baked)) return baked;
|
|
const bp = variantParts(baked);
|
|
if (!bp) return baked;
|
|
const candidates = installed.filter((o) => {
|
|
const op = variantParts(o);
|
|
return op && op.base === bp.base && op.ext === bp.ext;
|
|
});
|
|
if (!candidates.length) return baked;
|
|
const pick = (ref?.token && candidates.find((o) => variantParts(o).token === ref.token))
|
|
|| candidates.find((o) => o === baked)
|
|
|| candidates[0];
|
|
if (ref) ref.token = variantParts(pick).token;
|
|
return pick;
|
|
}
|
|
|
|
// Checkpoint-loadable archs all share CheckpointLoaderSimple, so they can be swapped
|
|
// in place without rebuilding. Flux/Z-Image use different loaders → not swappable here.
|
|
const CHECKPOINT_LOADERS = new Set(["CheckpointLoaderSimple", "CheckpointLoader"]);
|
|
const CHECKPOINT_ARCHS = new Set(["sdxl", "sd15", "sd1.5", "pony", "illustrious", "noobai"]);
|
|
|
|
// Every node reachable from `startNode` in either direction (the wired network).
|
|
function collectConnectedNodes(startNode) {
|
|
const graph = app.graph;
|
|
const seen = new Set();
|
|
const out = [];
|
|
const queue = [startNode];
|
|
while (queue.length) {
|
|
const n = queue.shift();
|
|
if (!n || seen.has(n.id)) continue;
|
|
seen.add(n.id); out.push(n);
|
|
for (const inp of n.inputs || []) {
|
|
if (inp.link == null) continue;
|
|
const l = getLink(graph, inp.link);
|
|
const src = l && graph.getNodeById(l.origin_id);
|
|
if (src) queue.push(src);
|
|
}
|
|
for (const o of n.outputs || []) {
|
|
for (const lid of o.links || []) {
|
|
const l = getLink(graph, lid);
|
|
const tgt = l && graph.getNodeById(l.target_id);
|
|
if (tgt) queue.push(tgt);
|
|
}
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// Swap to a checkpoint-compatible model IN PLACE: change the loader's checkpoint and
|
|
// reapply the new model's saved per-node settings (cfg/steps/sampler/clip-skip/CN
|
|
// strengths/latent size/FaceDetailer…) to the EXISTING nodes — preserving custom
|
|
// wiring like the regional Attention Couple. Returns false when there's no existing
|
|
// checkpoint graph or the new model isn't checkpoint-loadable, so the caller can fall
|
|
// back to applyTemplate (a full rebuild).
|
|
// Sampling nodes that mark a live checkpoint graph. UltimateSDUpscale counts:
|
|
// an upscale-only graph (LoadImage -> USDU, no KSampler) is still a checkpoint
|
|
// graph the user wants swapped in place, not template-rebuilt out from under them.
|
|
const SWAP_SAMPLERS = new Set(["KSampler", "KSamplerAdvanced", "UltimateSDUpscale"]);
|
|
|
|
export function swapModelInPlace(pcNode, filename, settings) {
|
|
const graph = app.graph;
|
|
if (!graph || !filename) return false;
|
|
const nodes = collectConnectedNodes(pcNode);
|
|
const ks = nodes.find(n => SWAP_SAMPLERS.has(n.comfyClass));
|
|
const loader = nodes.find(n => CHECKPOINT_LOADERS.has(n.comfyClass || n.type));
|
|
if (!ks || !loader) return false; // no existing checkpoint graph to preserve
|
|
const arch = (settings?.architecture || "").toLowerCase();
|
|
if (arch && !CHECKPOINT_ARCHS.has(arch)) return false; // flux/zimage etc. → rebuild
|
|
|
|
// 1. point the loader at the new checkpoint
|
|
for (const wName of MODEL_WIDGET_NAMES) {
|
|
const w = loader.widgets?.find(x => x.name === wName);
|
|
if (!w) continue;
|
|
ensureComboOption(w, filename);
|
|
w.value = filename; w.callback?.(filename);
|
|
break;
|
|
}
|
|
// 2. reapply the model's per-node-type settings to the existing nodes
|
|
const presets = settings?.nodes || {};
|
|
for (const n of nodes) {
|
|
const pw = presets[n.comfyClass || n.type];
|
|
if (!pw || !n.widgets) continue;
|
|
for (const w of n.widgets) {
|
|
if (w.name in pw) { ensureComboOption(w, pw[w.name]); w.value = pw[w.name]; w.callback?.(pw[w.name]); }
|
|
}
|
|
}
|
|
graph.setDirtyCanvas(true, true);
|
|
return true;
|
|
}
|
|
|
|
// True when the node's network is a checkpoint graph (KSampler + CheckpointLoader),
|
|
// i.e. an in-place model swap is possible — used to offer "(Current Workflow)".
|
|
export function hasCheckpointGraph(pcNode) {
|
|
const nodes = collectConnectedNodes(pcNode);
|
|
return !!(nodes.find(n => SWAP_SAMPLERS.has(n.comfyClass))
|
|
&& nodes.find(n => CHECKPOINT_LOADERS.has(n.comfyClass || n.type)));
|
|
}
|
|
|
|
// Templates can include nodes that need a model/pip the user may not have:
|
|
// PromptChain_RegionalDetailer → ultralytics + face_yolov8n.pt; ControlNet*
|
|
// → a CN checkpoint. The nodes themselves are ours/core so the graph LOADS, but
|
|
// it would fail at queue time with no hint. After applying, check each implied
|
|
// injectable's /nodepacks/status and offer the same install modal the [add]
|
|
// menu uses for any whose model/pack isn't present. Sequential via the modal's
|
|
// done-callback; a cancel just stops the chain.
|
|
function offerTemplateDeps(template, modelFilename) {
|
|
if (!_showNodePackInstallModal) return;
|
|
const types = new Set((template?.nodes || []).map((n) => n.type || n.comfyClass));
|
|
const injectables = [];
|
|
if (types.has("PromptChain_RegionalDetailer")) injectables.push("RegionalDetailer");
|
|
if (types.has("ControlNetApplyAdvanced") || types.has("ControlNetLoader")
|
|
|| types.has("ZImageFunControlnet") || types.has("ModelPatchLoader")) {
|
|
// Match the ControlNet checkpoint family to the template's base model so the
|
|
// user is offered the pack they can actually use. Z-Image rides the model-
|
|
// patch path (ModelPatchLoader/ZImageFunControlnet); SDXL/Flux ride the
|
|
// classic ControlNetLoader. Z-Image turbo vs base both default to turbo
|
|
// unless "base" is named, since the control models aren't cross-compatible.
|
|
const hay = ((template?.name || "") + " " + (modelFilename || "")).toLowerCase();
|
|
if (/z.?image|zimage/.test(hay)) {
|
|
injectables.push(/\bbase\b/.test(hay) ? "ZImageControlNetBase" : "ZImageControlNet");
|
|
} else if (/flux/.test(hay)) {
|
|
injectables.push("FluxControlNet");
|
|
} else {
|
|
injectables.push("ControlNet");
|
|
}
|
|
}
|
|
if (!injectables.length) return;
|
|
(async () => {
|
|
const missing = [];
|
|
for (const inj of injectables) {
|
|
try {
|
|
const st = await fetch(`/promptchain/nodepacks/status?injectable=${encodeURIComponent(inj)}`).then((r) => r.json());
|
|
if (st?.present === false) missing.push(inj);
|
|
} catch { /* can't check → don't nag */ }
|
|
}
|
|
let i = 0;
|
|
const next = () => { if (i < missing.length) _showNodePackInstallModal(missing[i++], next); };
|
|
next();
|
|
})();
|
|
}
|
|
|
|
export function applyTemplate(pcNode, template, modelFilename) {
|
|
const graph = app.graph;
|
|
if (!graph) return false;
|
|
|
|
const LG = window.LiteGraph || app.graph.constructor.LiteGraph;
|
|
if (!LG) return false;
|
|
|
|
removeWorkflowNodes(pcNode);
|
|
|
|
// Template offsets are authored relative to the PromptChain node's top-left
|
|
// assuming its default width. When the node is wider (e.g. the AI Assistant
|
|
// panel is open) the first column would land on top of it — so slide the
|
|
// whole template right until its leftmost node clears the node's actual
|
|
// right edge. No-op (shift 0) when the node is narrower than the template
|
|
// already assumes, so default layouts are unchanged.
|
|
// ONLY for templates authored entirely to the RIGHT of the anchor: a template
|
|
// captured around a mid-graph PromptChain has nodes at negative offsets by
|
|
// design (e.g. the 3D Regional rig with the Poser far left), and "clearing"
|
|
// its leftmost node would shove the whole layout thousands of px right.
|
|
const SPAWN_GAP = 60;
|
|
const minOffsetX = template.nodes.length
|
|
? Math.min(...template.nodes.map(n => n.offset?.[0] ?? 0))
|
|
: 0;
|
|
const anchorShiftX = minOffsetX >= 0
|
|
? Math.max(0, (pcNode.size?.[0] || 0) + SPAWN_GAP - minOffsetX)
|
|
: 0;
|
|
|
|
// Keeps multi-expert loaders (Wan high+low noise) on a single downloaded quant.
|
|
const _variantRef = { token: "" };
|
|
|
|
const createdNodes = new Array(template.nodes.length).fill(null);
|
|
for (let i = 0; i < template.nodes.length; i++) {
|
|
const tplNode = template.nodes[i];
|
|
const newNode = LG.createNode(tplNode.type);
|
|
if (!newNode) {
|
|
console.warn(`[PromptChain] Template: unknown node type "${tplNode.type}"`);
|
|
continue;
|
|
}
|
|
newNode.pos = [
|
|
pcNode.pos[0] + tplNode.offset[0] + anchorShiftX,
|
|
pcNode.pos[1] + tplNode.offset[1],
|
|
];
|
|
if (tplNode.size) newNode.size = [...tplNode.size];
|
|
if (tplNode.title) newNode.title = tplNode.title;
|
|
if (tplNode.properties) Object.assign(newNode.properties, tplNode.properties);
|
|
graph.add(newNode);
|
|
|
|
if (tplNode.widgets_values?.length && newNode.widgets?.length) {
|
|
const modelName = modelFilename
|
|
? modelFilename.replace(/\.(safetensors|ckpt|gguf)$/i, "")
|
|
: "";
|
|
for (let w = 0; w < Math.min(tplNode.widgets_values.length, newNode.widgets.length); w++) {
|
|
let val = tplNode.widgets_values[w];
|
|
if (typeof val === "string" && val.includes("{model_name}")) {
|
|
val = val.replace(/\{model_name\}/g, slugify(modelName));
|
|
}
|
|
// Remap a baked GGUF quant / safetensors precision to the variant the
|
|
// user actually downloaded — fixes locked multi-expert templates (Wan)
|
|
// pointing at a quant that isn't on disk. Reads the loader's installed
|
|
// list before ensureComboOption appends the (possibly absent) baked name.
|
|
if (typeof val === "string" && /\.(gguf|safetensors|ckpt)$/i.test(val)) {
|
|
val = resolveInstalledVariant(val, newNode.widgets[w].options?.values, _variantRef);
|
|
}
|
|
ensureComboOption(newNode.widgets[w], val);
|
|
newNode.widgets[w].value = val;
|
|
newNode.widgets[w].callback?.(val);
|
|
}
|
|
}
|
|
|
|
// A locked template is a self-contained recipe: it bakes both its model
|
|
// filenames (see lockModels guard below) AND its sampler widget values.
|
|
// The model config's node presets carry ONE recipe per family (e.g. Wan's
|
|
// vanilla 20-step / CFG 3.5), which would clobber a sibling template's
|
|
// distinct recipe — applying the vanilla preset over the Fast 4-step /
|
|
// CFG 1.0 template left the LightX2V LoRAs running at CFG 3.5 and produced
|
|
// burnt video. So skip preset overrides for locked templates; the model-
|
|
// settings panel still reads slider RANGES from the config and current
|
|
// values from the live (template-baked) nodes.
|
|
// lockSettings (set on USER-saved templates) skips the SAME preset override —
|
|
// a user's saved sampler/cfg/etc. are deliberate and must win over the model
|
|
// config's generic defaults — WITHOUT lockModels' model-filename freeze, so
|
|
// model-swap-in-place still works on a user template.
|
|
if (template._presetNodes && !template.lockModels && !template.lockSettings) {
|
|
const nodeType = tplNode.type;
|
|
const presetWidgets = template._presetNodes[nodeType];
|
|
if (presetWidgets && newNode.widgets) {
|
|
for (const widget of newNode.widgets) {
|
|
if (widget.name in presetWidgets) {
|
|
ensureComboOption(widget, presetWidgets[widget.name]);
|
|
widget.value = presetWidgets[widget.name];
|
|
widget.callback?.(presetWidgets[widget.name]);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
createdNodes[i] = newNode;
|
|
}
|
|
|
|
// Single-loader templates inject the picked model into their loader. MoE/video
|
|
// templates (e.g. Wan's high+low noise experts) set lockModels and keep their
|
|
// own per-expert filenames — injecting one picked file would clobber the
|
|
// second expert and break the pair.
|
|
if (modelFilename && !template.lockModels) {
|
|
const LOADER_TYPES = new Set([
|
|
"CheckpointLoaderSimple", "CheckpointLoader",
|
|
"UNETLoader", "DiffusersLoader",
|
|
]);
|
|
let applied = false;
|
|
for (const node of createdNodes) {
|
|
if (!node || !LOADER_TYPES.has(node.comfyClass || node.type)) continue;
|
|
if (applied) break;
|
|
for (const w of node.widgets) {
|
|
if (MODEL_WIDGET_NAMES.includes(w.name)) {
|
|
ensureComboOption(w, modelFilename);
|
|
w.value = modelFilename;
|
|
w.callback?.(modelFilename);
|
|
applied = true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const conn of template.connections) {
|
|
const from = createdNodes[conn.from_node_idx];
|
|
const to = createdNodes[conn.to_node_idx];
|
|
if (from && to) from.connect(conn.from_slot, to, conn.to_slot);
|
|
}
|
|
|
|
if (template.anchorConnections?.length) {
|
|
const maxSlot = Math.max(...template.anchorConnections.map(c => c.from_slot));
|
|
if (!pcNode.outputs || pcNode.outputs.length <= maxSlot) {
|
|
const refNode = LG.createNode(pcNode.comfyClass || pcNode.type);
|
|
if (refNode?.outputs) {
|
|
for (let i = (pcNode.outputs?.length || 0); i < refNode.outputs.length; i++) {
|
|
pcNode.addOutput(refNode.outputs[i].name, refNode.outputs[i].type);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
for (const conn of template.anchorConnections) {
|
|
const to = createdNodes[conn.to_node_idx];
|
|
if (to) pcNode.connect(conn.from_slot, to, conn.to_slot);
|
|
}
|
|
|
|
// A template that ships a 3D Poser should arrive ready to pose: open the
|
|
// invoking node's poser side-panel (same path as the menubar toggle —
|
|
// properties.pcrPosePanel persists it). The panel docks the registry's
|
|
// last-active poser, which IS the just-created one (poseRegistry.add runs
|
|
// in the poser's nodeCreated during graph.add above); its viewport mounts
|
|
// async and the panel re-docks on signalReady.
|
|
if (createdNodes.some((n) => n && (n.comfyClass || n.type) === "PromptChain_PoseStudio")) {
|
|
if (pcNode.properties) pcNode.properties.pcrPosePanel = true;
|
|
pcNode._pcrPosePanel?.show?.();
|
|
}
|
|
|
|
if (template.prompt_id) injectTemplatePrompt(pcNode, template.prompt_id);
|
|
|
|
offerTemplateDeps(template, modelFilename);
|
|
graph.setDirtyCanvas(true, true);
|
|
return true;
|
|
}
|
|
|
|
// A template may name a prompt recipe via "prompt_id" (an id from the
|
|
// data/prompts sets): building it injects that recipe at the TOP of the
|
|
// invoking node's prompt, so the graph arrives ready to queue without a
|
|
// manual recipe pick. Resolved through the prompts endpoint so user-dir
|
|
// overrides win, exactly like the recipes tab.
|
|
async function injectTemplatePrompt(pcNode, promptId) {
|
|
let text = "";
|
|
try {
|
|
const res = await fetch("/promptchain/prompts/list");
|
|
const data = await res.json();
|
|
text = data?.prompts?.find((p) => p.id === promptId)?.text || "";
|
|
} catch (e) {
|
|
console.warn("[PromptChain] template prompt lookup failed:", e);
|
|
}
|
|
if (!text) return;
|
|
|
|
const view = pcNode._pcrEditor;
|
|
const promptWidget = pcNode.widgets?.find((w) => w.name === "prompt");
|
|
const docText = view ? view.state.doc.toString() : String(promptWidget?.value || "");
|
|
|
|
const recipeHeader = text.split("\n").find((l) => l.trim()) || "";
|
|
if (recipeHeader && docText.includes(recipeHeader)) return; // same template re-applied
|
|
|
|
// A recipe's "Negative Prompt:" section is the model's standard negative
|
|
// (e.g. Wan's anti-static string). It sits below {cursor}, so the existing-
|
|
// doc path below would drop it — capture it now to re-apply afterward.
|
|
const NEG_RE = /(^|\n)[ \t]*negative\s+prompt\s*:/i;
|
|
const negMatch = text.match(NEG_RE);
|
|
const negSection = negMatch ? text.slice(negMatch.index).replace(/^\n/, "") : "";
|
|
|
|
const cursorIdx = text.indexOf("{cursor}");
|
|
let insertText, anchor;
|
|
if (!docText.trim()) {
|
|
insertText = text.replace("{cursor}", "");
|
|
anchor = cursorIdx >= 0 ? cursorIdx : insertText.length;
|
|
} else {
|
|
// The slice above {cursor} is what recipes put ahead of user tags
|
|
// (it ends in their "// Your Tags"-style scaffold) — the existing
|
|
// text takes the {cursor} slot, landing under the injected block.
|
|
insertText = cursorIdx >= 0 ? text.slice(0, cursorIdx) : text;
|
|
if (!insertText.endsWith("\n")) insertText += "\n";
|
|
anchor = insertText.length;
|
|
}
|
|
|
|
if (view) {
|
|
view.dispatch({
|
|
changes: { from: 0, to: docText.trim() ? 0 : view.state.doc.length, insert: insertText },
|
|
selection: { anchor },
|
|
});
|
|
} else if (promptWidget) {
|
|
promptWidget.value = docText.trim() ? insertText + docText : insertText;
|
|
}
|
|
|
|
// Existing prompt with no negative of its own: append the recipe's standard
|
|
// negative (the empty-doc path already inserted it inline).
|
|
if (negSection && docText.trim() && !NEG_RE.test(docText)) {
|
|
if (view) {
|
|
const end = view.state.doc.length;
|
|
const tail = view.state.doc.toString().endsWith("\n") ? "" : "\n";
|
|
view.dispatch({ changes: { from: end, to: end, insert: tail + negSection } });
|
|
} else if (promptWidget) {
|
|
const cur = String(promptWidget.value || "");
|
|
promptWidget.value = cur + (cur.endsWith("\n") ? "" : "\n") + negSection;
|
|
}
|
|
}
|
|
}
|