Files
chrmc40 8e3dceae99 ComfyUI-PromptChain
A prompt-authoring and image-iteration suite for ComfyUI.
2026-06-17 19:35:25 -07:00

710 lines
26 KiB
JavaScript

// Tag autocomplete — CodeMirror completion sources.
// Three parallel namespaces, one trigger for each:
// - tagCompletionSource: booru tag CSVs (configured in tags-dropdown.js),
// fires on plain word typing.
// - `@`-prefixed sources for the curated PromptChain tag-builder DB:
// atTypePickerSource — types list, fires on `@<word-chars>` (no space).
// <type>ValueSource — value lookup, fires on `@<type> <query>`.
// cascadeCompletionSource — internal flow after picking a character
// (auto-pops outfit then pose).
// - `$`-prefixed: mannequinRegionSource — the 3D Poser's figures that have
// no $name{} region block in this prompt yet; picking one inserts the
// block with the cursor inside.
import { getTagSourceConfig } from "./tags-dropdown.js";
let _tagAbortController = null;
let _charSearchAbortController = null;
let _charDetailAbortController = null;
let _promptListAbortController = null;
const _promptCache = new Map();
// Active cascade state. Set by the character apply handler, advanced by each
// cascade stage. Cleared when the cascade finishes or is dismissed.
let _pendingCascade = null;
// ── word extraction (booru tag source) ─────────────────────────────
function getWordAtCursor(state) {
const pos = state.selection.main.head;
const line = state.doc.lineAt(pos);
const before = line.text.slice(0, pos - line.from);
// tag boundaries: commas, parens, braces
const match = before.match(/[^,(){}]+$/);
if (!match) return null;
let raw = match[0].trimStart();
// handle wildcard :: delimiter (but not single : )
const dc = raw.lastIndexOf("::");
if (dc !== -1) raw = raw.slice(dc + 2);
if (!raw) return null;
const word = raw.toLowerCase().replace(/ /g, "_");
if (!word) return null;
return { word, from: pos - raw.length, to: pos };
}
// ── API fetch ──────────────────────────────────────────────────────
async function fetchTags(query, limit = 20) {
const config = getTagSourceConfig();
if (!config.sources.length) return [];
if (_tagAbortController) _tagAbortController.abort();
_tagAbortController = new AbortController();
try {
let url;
if (config.sources.length === 1) {
url = `/promptchain/tags/search?source=${encodeURIComponent(config.sources[0])}&q=${encodeURIComponent(query)}&limit=${limit}`;
} else {
url = `/promptchain/tags/search-stacked?sources=${encodeURIComponent(config.sources.join(","))}&q=${encodeURIComponent(query)}&limit=${limit}`;
}
const res = await fetch(url, { signal: _tagAbortController.signal });
if (!res.ok) return [];
const data = await res.json();
return Array.isArray(data) ? data : (data.tags || []);
} catch (e) {
if (e.name !== "AbortError") console.error("[PromptChain] Tag search error:", e);
return [];
}
}
async function fetchCharacters(query, limit = 10) {
if (_charSearchAbortController) _charSearchAbortController.abort();
_charSearchAbortController = new AbortController();
try {
const url = `/promptchain/tag-builder/characters?search=${encodeURIComponent(query)}&per_page=${limit}`;
const res = await fetch(url, { signal: _charSearchAbortController.signal });
if (!res.ok) return [];
const data = await res.json();
return Array.isArray(data?.characters) ? data.characters : [];
} catch (e) {
if (e.name !== "AbortError") console.error("[PromptChain] Character search error:", e);
return [];
}
}
// Find the PromptChain node that owns this editor view. Needed because
// prompt templates are scoped to the node's current model (architecture +
// hash). Returns null when called from a fullscreen group editor (those
// aren't tracked via _pcrEditor) — caller treats that as "no prompts".
function getNodeForView(view) {
for (const n of window.app?.graph?._nodes || []) {
if (n._pcrEditor === view) return n;
}
return null;
}
function getModelInfoForView(view) {
const node = getNodeForView(view);
// Modal-mounted editors (inpaint/upscale) have no graph node — the mount
// hangs a resolver directly on the view instead.
return node?._pcrGetModelInfo?.() || view._pcrGetModelInfo?.() || null;
}
async function fetchPrompts(modelInfo) {
const hash = modelInfo?.hash;
if (!hash) return [];
if (_promptCache.has(hash)) return _promptCache.get(hash);
if (_promptListAbortController) _promptListAbortController.abort();
_promptListAbortController = new AbortController();
const signal = _promptListAbortController.signal;
// Pull the model's resolved arch/family/name so the server can return
// prompts matching all applicable scopes (architecture / family / model).
let arch = modelInfo.architecture || "";
let family = "";
let name = "";
try {
const r = await fetch(`/promptchain/models/settings/${hash}`, { signal });
if (r.ok) {
const cfg = await r.json();
arch = cfg.architecture || arch;
family = cfg.family || "";
name = cfg.model_name || "";
}
} catch (e) {
if (e.name === "AbortError") return [];
}
const params = new URLSearchParams();
if (arch) params.set("arch", arch);
if (family) params.set("family", family);
if (name) params.set("name", name);
params.set("hash", hash);
try {
const r = await fetch(`/promptchain/prompts/list?${params}`, { signal });
if (!r.ok) return [];
const data = await r.json();
const prompts = Array.isArray(data?.prompts) ? data.prompts : [];
_promptCache.set(hash, prompts);
return prompts;
} catch (e) {
if (e.name !== "AbortError") console.error("[PromptChain] Prompt fetch error:", e);
return [];
}
}
async function fetchCharacterDetail(tag) {
if (_charDetailAbortController) _charDetailAbortController.abort();
_charDetailAbortController = new AbortController();
try {
const url = `/promptchain/tag-builder/characters/${encodeURIComponent(tag)}`;
const res = await fetch(url, { signal: _charDetailAbortController.signal });
if (!res.ok) return null;
return await res.json();
} catch (e) {
if (e.name !== "AbortError") console.error("[PromptChain] Character detail error:", e);
return null;
}
}
// ── formatting ─────────────────────────────────────────────────────
function formatForInsertion(tag, format) {
let result = tag;
// escape parens for SD attention syntax
result = result.replace(/\(/g, "\\(").replace(/\)/g, "\\)");
if (format === "spaces") result = result.replace(/_/g, " ");
return result;
}
function formatForDisplay(tag, format) {
return format === "spaces" ? tag.replace(/_/g, " ") : tag;
}
// base_tags / outfit_tags / pose_tags are stored as comma-separated tag
// lists that already include intentional SD weight syntax like (cammy_white:1.1).
// Those parens are meaningful — escaping them would break the weight. Only
// substitute underscores when the user has chosen "spaces" formatting.
function formatBodyTagsForInsertion(body, format) {
if (!body) return "";
return format === "spaces" ? body.replace(/_/g, " ") : body;
}
function formatPopularity(n) {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}K`;
return n > 0 ? `${n}` : "";
}
// ── completion options ─────────────────────────────────────────────
function toCompletionOptions(tags) {
const config = getTagSourceConfig();
return tags.map((tag) => {
const name = tag.tag || "";
const display = formatForDisplay(name, config.format);
const insert = formatForInsertion(name, config.format);
const pop = formatPopularity(tag.ranking || 0);
const detail = [pop, tag.source].filter(Boolean).join(" · ");
return {
label: name.toLowerCase(),
displayLabel: display,
apply: insert,
detail,
type: tag.category || "general",
boost: (tag.ranking || 0) / 10_000_000,
};
});
}
function buildCharacterInsertion(char, config) {
const display = char.display || (char.tag || "").replace(/_/g, " ");
const seriesSuffix = char.series ? ` (${char.series})` : "";
const header = `// Character: ${display}${seriesSuffix}`;
const body = config.prompt_style === "natural"
? (char.base_natlang || "").trim()
: formatBodyTagsForInsertion((char.base_tags || "").trim(), config.format);
return body ? `${header}\n${body}\n\n` : `${header}\n\n`;
}
function buildOutfitInsertion(outfit, characterDisplay, config) {
const name = outfit.outfit_name || "outfit";
const flag = outfit.is_default ? " (default)" : "";
const header = `// Outfit: ${name}${flag} from Character: ${characterDisplay}`;
const body = config.prompt_style === "natural"
? (outfit.outfit_natlang || "").trim()
: formatBodyTagsForInsertion((outfit.outfit_tags || "").trim(), config.format);
return body ? `${header}\n${body}\n\n` : `${header}\n\n`;
}
// Returns {text, cursorOffset} where text is the full insertion and
// cursorOffset is where the editor cursor should land within it.
//
// Two modes, branching on whether the editor already has prompt content
// outside the `@prompt <query>` we're about to replace:
//
// EMPTY mode — full template with `// Prompt:` comment header. Template's
// `{cursor}` token marks where user tags go; real cursor lands there.
//
// MERGE mode — the existing content IS the user's tags, so the template's
// user-tags slot (everything up to and including `{cursor}`) and the
// `// Prompt:` header are dropped. Only the style + negative section is
// appended. Cursor lands at the end of the positive section (right before
// `Negative Prompt:`), or end of insertion if there's no negative block.
// A separator of 2 newlines is prepended unless the existing content
// already ends with enough newlines.
function buildPromptInsertion(prompt, existingBefore, existingAfter) {
const category = (prompt.category || "").trim();
const name = (prompt.name || "prompt").trim();
const title = category ? `${category} > ${name}` : name;
const text = prompt.text || "";
const cursorIdx = text.indexOf("{cursor}");
const hasExistingContent = (existingBefore + existingAfter).trim().length > 0;
if (!hasExistingContent) {
const header = `// Prompt: ${title}\n`;
if (cursorIdx === -1) {
const insert = `${header}${text}\n\n`;
return { text: insert, cursorOffset: insert.length };
}
const before = text.slice(0, cursorIdx);
const after = text.slice(cursorIdx + "{cursor}".length);
const insert = `${header}${before}${after}\n\n`;
return { text: insert, cursorOffset: header.length + before.length };
}
const after = cursorIdx >= 0 ? text.slice(cursorIdx + "{cursor}".length) : text;
const trimmedAfter = after.replace(/^\s+/, "");
const negMatch = trimmedAfter.match(/\n+Negative Prompt:/i);
let cursorWithinAfter;
if (negMatch) {
let idx = negMatch.index;
while (idx > 0 && /\s/.test(trimmedAfter[idx - 1])) idx--;
cursorWithinAfter = idx;
} else {
cursorWithinAfter = trimmedAfter.length;
}
const trailNewlines = (existingBefore.match(/\n*$/) || [""])[0].length;
const separator = "\n".repeat(Math.max(0, 2 - trailNewlines));
return {
text: separator + trimmedAfter,
cursorOffset: separator.length + cursorWithinAfter,
};
}
function buildPoseInsertion(pose, characterDisplay, config) {
const name = pose.pose_name || "pose";
const flag = pose.is_signature ? " (signature)" : "";
const header = `// Pose: ${name}${flag} from Character: ${characterDisplay}`;
const body = config.prompt_style === "natural"
? (pose.pose_natlang || "").trim()
: formatBodyTagsForInsertion((pose.pose_tags || "").trim(), config.format);
return body ? `${header}\n${body}\n\n` : `${header}\n\n`;
}
// ── cascade orchestration ─────────────────────────────────────────
function endCascade() {
if (_pendingCascade?.escHandler && _pendingCascade?.view) {
_pendingCascade.view.contentDOM.removeEventListener(
"keydown", _pendingCascade.escHandler, true,
);
}
_pendingCascade = null;
}
function attachCascadeEscHandler(view) {
if (!_pendingCascade || _pendingCascade.escHandler) return;
const handler = (e) => {
if (e.key !== "Escape" || !_pendingCascade) return;
endCascade();
};
view.contentDOM.addEventListener("keydown", handler, true);
_pendingCascade.view = view;
_pendingCascade.escHandler = handler;
}
// CM6's acceptCompletion auto-attaches the pickedCompletion annotation when
// `apply` is a string, but not when it's a function — without that annotation
// the popup state never marks the completion as picked, which leaves the
// dropdown alive across our cascade transitions and lets it reappear instead
// of closing on Tab / (skip).
function dispatchWithPicked(view, completion, txn) {
const ann = window.PromptChainCM?.pickedCompletion?.of?.(completion);
view.dispatch(ann ? { ...txn, annotations: ann } : txn);
}
function triggerCompletion(view, delay = 50) {
setTimeout(() => {
const CM = window.PromptChainCM;
if (!CM?.startCompletion) return;
CM.startCompletion(view);
}, delay);
}
function triggerCascadePopup(view) {
attachCascadeEscHandler(view);
triggerCompletion(view, 50);
}
function advanceCascade(view) {
if (!_pendingCascade) return;
if (_pendingCascade.stage === "outfit") {
_pendingCascade.stage = "pose";
triggerCascadePopup(view);
} else {
endCascade();
}
}
// ── completion sources ────────────────────────────────────────────
async function tagCompletionSource(context) {
const info = getWordAtCursor(context.state);
if (!info || info.word.length < 2) return null;
// `@`-prefixed prefixes belong to the @ source family — booru tag DB must
// not also fire on them, otherwise the two namespaces collide. Same for
// `$` anywhere in the run: that's a region block being typed (booru tags
// never contain `$`).
if (info.word.startsWith("@") || info.word.includes("$")) return null;
const tags = await fetchTags(info.word, 20);
if (!tags.length) return null;
return {
from: info.from,
to: info.to,
options: toCompletionOptions(tags),
filter: false,
};
}
// Type-picker dropdown — fires on `@<word-chars>` (no space). Lists every
// available `@`-shortcut so users can discover them. Selecting an entry
// inserts `@<type> ` and re-triggers autocomplete to drop into the type's
// value-search dropdown.
function atTypePickerSource(context) {
const m = context.matchBefore(/@\w*$/);
if (!m) return null;
// Filter at the source level by typed prefix (case-insensitive). CM6's
// built-in filter still keeps options that share the leading `@` even when
// the rest of the chars don't match, so `@prompt` was leaving `@character`
// visible and pre-selected. Empty typed text → show every type (discovery).
const typed = m.text.slice(1).toLowerCase();
const matches = typed
? AT_TYPES.filter((t) => t.name.toLowerCase().startsWith(typed))
: AT_TYPES;
if (!matches.length) return null;
return {
from: m.from,
to: m.to,
options: matches.map((t) => ({
label: `@${t.name}`,
displayLabel: `@${t.name}`,
detail: t.description,
type: "namespace",
apply: (view, completion, from, to) => {
const insert = `@${t.name} `;
dispatchWithPicked(view, completion, {
changes: { from, to, insert },
selection: { anchor: from + insert.length },
});
triggerCompletion(view, 50);
},
})),
filter: false,
};
}
// ── $-region source (3D Poser mannequins) ───────────────────────────
// The Poser whose figures this prompt's $blocks bind to: trace the node's
// regions output -> AttentionCouple -> its masks input -> Poser. Falls back to
// any live Poser in the graph so the dropdown works before regional is wired.
function getPoserForView(view) {
const graph = window.app?.graph;
const node = getNodeForView(view);
if (!graph || !node) return null;
const linkOf = (id) => graph.links?.get?.(id) || graph.links?.[id] || null;
const regOut = node.outputs?.find((o) => o.name === "regions");
for (const linkId of regOut?.links || []) {
const link = linkOf(linkId);
const couple = link && graph.getNodeById(link.target_id);
if (couple?.comfyClass !== "PromptChain_AttentionCouple") continue;
const maskIn = couple.inputs?.find((i) => i.name === "masks");
const mlink = maskIn?.link != null ? linkOf(maskIn.link) : null;
const poser = mlink && graph.getNodeById(mlink.origin_id);
if (poserHasEntities(poser)) return poser;
}
return (graph._nodes || []).find(
(n) => n.comfyClass === "PromptChain_PoseStudio" && poserHasEntities(n)) || null;
}
// A poser is listable if it has ANY region entity — figures, or (figure-less
// scenes are legal now) named props via entityNames.
function poserHasEntities(poser) {
const ps = poser?._pcrPose;
if (!ps) return false;
return ps.entityNames ? ps.entityNames().length > 0 : !!ps.figures?.length;
}
// Fires on `$<word-chars>`: lists the Poser's figures that have no $name{}
// block in this prompt yet (assigned ones are hidden — the dropdown is for
// quickly attaching the rest of a large cast). Picking one inserts the block
// and parks the cursor inside it.
function mannequinRegionSource(context) {
const m = context.matchBefore(/\$\w*$/);
if (!m || !context.view) return null;
const ps = getPoserForView(context.view)?._pcrPose;
// Region entities = figures + named props (entityNames mirrors the Poser's
// mask-row names); pre-entity builds fall back to the figure list.
const names = ps?.entityNames
? ps.entityNames()
: ps?.figures?.map((f, i) => f.customName || "mannequin" + (i + 1));
if (!names?.length) return null;
const doc = context.state.doc.toString();
const typed = m.text.slice(1).toLowerCase();
const unassigned = names
.filter((name) =>
!new RegExp("\\$" + name + "\\s*\\{", "i").test(doc) &&
(!typed || name.toLowerCase().startsWith(typed)));
if (!unassigned.length) return null;
return {
from: m.from,
to: m.to,
options: unassigned.map((name) => ({
label: "$" + name,
displayLabel: "$" + name,
detail: "add this entity's region block",
type: "namespace",
apply: (view, completion, from, to) => {
const insert = "$" + name + " {\n\n}";
dispatchWithPicked(view, completion, {
changes: { from, to, insert },
selection: { anchor: from + name.length + 4 }, // on the blank line inside the braces
});
},
})),
filter: false,
};
}
async function characterValueSource(context) {
const m = context.matchBefore(/@character\s+([^,\n]*)$/);
if (!m) return null;
const queryMatch = m.text.match(/@character\s+([^,\n]*)$/);
const query = (queryMatch?.[1] || "").trim();
const chars = await fetchCharacters(query, 10);
if (!chars.length) return null;
const config = getTagSourceConfig();
return {
from: m.from,
to: m.to,
options: chars.map((c) => ({
label: c.tag,
displayLabel: c.display || c.tag,
detail: c.series || c.tag,
type: "character",
apply: (view, completion, from, to) => {
const insert = buildCharacterInsertion(c, config);
dispatchWithPicked(view, completion, {
changes: { from, to, insert },
selection: { anchor: from + insert.length },
});
endCascade();
_pendingCascade = {
stage: "outfit",
characterTag: c.tag,
characterDisplay: c.display || (c.tag || "").replace(/_/g, " "),
detail: null,
config,
view: null,
escHandler: null,
expectedPos: from + insert.length,
};
triggerCascadePopup(view);
},
})),
filter: false,
};
}
async function promptValueSource(context) {
const m = context.matchBefore(/@prompt\s+([^,\n]*)$/);
if (!m) return null;
const queryMatch = m.text.match(/@prompt\s+([^,\n]*)$/);
const query = (queryMatch?.[1] || "").trim().toLowerCase();
const modelInfo = getModelInfoForView(context.view);
if (!modelInfo) return null;
const prompts = await fetchPrompts(modelInfo);
if (!prompts.length) return null;
const filtered = query
? prompts.filter((p) =>
(p.name || "").toLowerCase().includes(query) ||
(p.category || "").toLowerCase().includes(query))
: prompts;
if (!filtered.length) return null;
return {
from: m.from,
to: m.to,
options: filtered.map((p) => ({
label: p.id || p.name || "prompt",
displayLabel: (p.category ? `${p.category} > ` : "") + (p.name || "prompt"),
detail: "prompt template",
type: "prompt",
apply: (view, completion, from, to) => {
const existingBefore = view.state.doc.sliceString(0, from);
const existingAfter = view.state.doc.sliceString(to, view.state.doc.length);
const { text, cursorOffset } = buildPromptInsertion(p, existingBefore, existingAfter);
dispatchWithPicked(view, completion, {
changes: { from, to, insert: text },
selection: { anchor: from + cursorOffset },
});
},
})),
filter: false,
};
}
async function cascadeCompletionSource(context) {
if (!_pendingCascade) return null;
// Cascade is anchored to where the previous stage's apply left the cursor.
// If the cursor has moved (user typed @ to start a fresh lookup, clicked
// away, used arrow keys, etc.) the cascade is stale — terminate so the
// dropdown doesn't co-render alongside the type-picker or whatever else.
if (_pendingCascade.expectedPos != null && context.pos !== _pendingCascade.expectedPos) {
endCascade();
return null;
}
const { stage, characterTag, characterDisplay, config } = _pendingCascade;
if (!_pendingCascade.detail) {
_pendingCascade.detail = await fetchCharacterDetail(characterTag);
if (!_pendingCascade) return null;
}
const detail = _pendingCascade.detail;
if (!detail) {
_pendingCascade = null;
return null;
}
const items = stage === "outfit" ? (detail.outfits || []) : (detail.poses || []);
const buildFn = stage === "outfit" ? buildOutfitInsertion : buildPoseInsertion;
const nameKey = stage === "outfit" ? "outfit_name" : "pose_name";
if (!items.length) {
advanceCascade(context.view ?? null);
return null;
}
const pos = context.pos;
const skipOption = {
label: `(skip ${stage})`,
displayLabel: `(skip ${stage})`,
detail: "advance without inserting",
type: "text",
boost: 99,
apply: (view, completion, from, to) => {
dispatchWithPicked(view, completion, { changes: { from, to, insert: "" } });
advanceCascade(view);
},
};
const itemOptions = items.map((it) => ({
label: it[nameKey] || stage,
displayLabel: it[nameKey] || stage,
detail: stage === "outfit"
? (it.is_default ? "default" : "outfit")
: (it.is_signature ? "signature" : "pose"),
type: stage,
apply: (view, completion, from, to) => {
const insert = buildFn(it, characterDisplay, config);
dispatchWithPicked(view, completion, {
changes: { from, to, insert },
selection: { anchor: from + insert.length },
});
// Update expectedPos so the next stage's cascade source matches the
// post-insertion cursor — without this, advancing outfit→pose would
// immediately fail the position check and terminate.
if (_pendingCascade) _pendingCascade.expectedPos = from + insert.length;
advanceCascade(view);
},
}));
return {
from: pos,
to: pos,
options: [skipOption, ...itemOptions],
filter: false,
};
}
// ── @-shortcut registry ───────────────────────────────────────────
//
// Add a new top-level @-shortcut by appending an entry here and providing
// its value source in the source list passed to activateTagAutocomplete.
const AT_TYPES = [
{
name: "character",
description: "PromptChain character bio (auto-cascades to outfit + pose)",
},
{
name: "prompt",
description: "Prompt template scoped to the current model",
},
// future:
// { name: "prop", description: "prop / clothing / fantasy / furniture" },
];
// ── activation ─────────────────────────────────────────────────────
// `@` and `$` are not word characters, so CM6's activateOnTyping won't
// auto-fire their dropdowns. Hook keydown so the type-picker / unassigned-
// mannequin list appears the instant either is typed.
function installAtTrigger(view) {
if (view._pcrAtTriggerInstalled) return;
view._pcrAtTriggerInstalled = true;
view.contentDOM.addEventListener("keydown", (e) => {
if (e.key !== "@" && e.key !== "$") return;
triggerCompletion(view, 0);
});
}
/**
* Reconfigure the editor's autocomplete compartment to include tag completion
* alongside the existing wildcard completion. Call after editor is created.
*/
export function activateTagAutocomplete(CM, view) {
CM.reconfigureAutocomplete(view, [
CM.wildcardCompletionSource,
cascadeCompletionSource,
characterValueSource,
promptValueSource,
atTypePickerSource,
mannequinRegionSource,
tagCompletionSource,
]);
installAtTrigger(view);
}