209 lines
7.1 KiB
JavaScript
209 lines
7.1 KiB
JavaScript
// Patch helpers shared by AIAssistant.svelte's legacy single-shot path and
|
|
// the new chat agent flow. Extracted verbatim — every body kept identical
|
|
// to the originals so the patch pipeline sees byte-equivalent inputs.
|
|
|
|
// Pull the live editor text — `node._pcrEditor` is rebound to the
|
|
// fullscreen group editor when applicable, so this works in both modes.
|
|
// Falls back to the prompt widget value if the CM view isn't ready yet.
|
|
export function readNodePrompt(node) {
|
|
const view = node?._pcrEditor;
|
|
if (view?.state?.doc) return view.state.doc.toString();
|
|
const widget = node?.widgets?.find?.(w => w.name === "prompt");
|
|
return widget?.value ?? "";
|
|
}
|
|
|
|
// Standalone-main = top of its chain (its `out` output feeds nothing
|
|
// OR feeds non-PromptChain consumers like a CLIPTextEncode) AND has
|
|
// no PromptChain children feeding into it.
|
|
export function isPromptChainNode(n) {
|
|
return n?.comfyClass === "PromptChain_PromptChain"
|
|
|| n?.constructor?.comfyClass === "PromptChain_PromptChain"
|
|
|| n?.type === "PromptChain_PromptChain";
|
|
}
|
|
|
|
export function isStandaloneMainPromptChain(n) {
|
|
if (!n) return false;
|
|
const inputs = (n.inputs || []).filter(slot =>
|
|
(slot.name?.startsWith("in_") || slot.name?.startsWith("inputs.in_"))
|
|
&& slot.link != null
|
|
);
|
|
if (inputs.length > 0) return false;
|
|
const graph = n.graph || (typeof window !== "undefined" && window.app?.graph);
|
|
if (!graph) return true;
|
|
for (const out of n.outputs || []) {
|
|
if (out.name !== "out") continue;
|
|
for (const lid of out.links || []) {
|
|
const link = graph.links?.[lid];
|
|
if (!link) continue;
|
|
const target = graph.getNodeById?.(link.target_id);
|
|
if (target && isPromptChainNode(target)) return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// ---- Tag Builder preflight ----
|
|
// Extract n-grams from the user's request and call match-characters
|
|
// (exact-match against TB chars table) so /ai/patch sees pre-loaded
|
|
// bios for every character the user mentioned.
|
|
|
|
export function extractNGrams(text) {
|
|
if (!text) return [];
|
|
// Strip A1111 weight syntax first: `(cammy white:1.1)` → `cammy white`.
|
|
// Then strip possessive 's so "cammy white's outfit" tokenizes as
|
|
// [cammy, white, outfit] — surface bare 2-grams for downstream DB lookup.
|
|
const words = text
|
|
.replace(/\(\s*([^():]+?)\s*:\s*[\d.]+\s*\)/g, "$1")
|
|
.replace(/'s\b/gi, "")
|
|
.replace(/[^\w\s()'\\-]/g, " ")
|
|
.split(/\s+/)
|
|
.filter(Boolean);
|
|
const out = new Set();
|
|
for (let n = 1; n <= 4; n++) {
|
|
for (let i = 0; i + n <= words.length; i++) {
|
|
const phrase = words.slice(i, i + n).join(" ").trim();
|
|
if (!phrase) continue;
|
|
if (n === 1 && phrase.length < 3) continue;
|
|
out.add(phrase);
|
|
}
|
|
}
|
|
for (const variant of synthesizeParenVariants(text)) {
|
|
out.add(variant);
|
|
}
|
|
return Array.from(out);
|
|
}
|
|
|
|
export function synthesizeParenVariants(text) {
|
|
if (!text) return [];
|
|
const variants = [];
|
|
const prepRe = /\s+(?:from|in|of|for|as)\s+/gi;
|
|
let m;
|
|
while ((m = prepRe.exec(text)) !== null) {
|
|
const leftText = text.slice(0, m.index).trim();
|
|
const rightText = text.slice(m.index + m[0].length).trim();
|
|
if (!leftText || !rightText) continue;
|
|
const leftWords = leftText.replace(/[^\w\s'\\-]/g, " ").split(/\s+/).filter(Boolean);
|
|
const rightWords = rightText.replace(/[^\w\s'\\-]/g, " ").split(/\s+/).filter(Boolean);
|
|
for (let nl = 1; nl <= Math.min(3, leftWords.length); nl++) {
|
|
const name = leftWords.slice(-nl).join(" ");
|
|
for (let rl = 1; rl <= Math.min(3, rightWords.length); rl++) {
|
|
const series = rightWords.slice(0, rl).join(" ");
|
|
variants.push(`${name} (${series})`);
|
|
}
|
|
}
|
|
}
|
|
return variants;
|
|
}
|
|
|
|
export async function matchCharactersInDb(tokens, userText, nodePromptText) {
|
|
if (!Array.isArray(tokens) || tokens.length === 0) return [];
|
|
try {
|
|
const r = await fetch("/promptchain/tag-builder/match-characters", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
tokens,
|
|
user_text: userText || "",
|
|
node_prompt: nodePromptText || "",
|
|
}),
|
|
});
|
|
if (!r.ok) {
|
|
console.warn("[PromptChain ai-patch] match-characters failed:", r.status);
|
|
return [];
|
|
}
|
|
const { matched } = await r.json();
|
|
return Array.isArray(matched) ? matched : [];
|
|
} catch (err) {
|
|
console.warn("[PromptChain ai-patch] match-characters error:", err);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
// Canonicalize for token matching: trim, drop backslash escapes used
|
|
// by SD to literalize parens, swap underscores to spaces, collapse
|
|
// internal whitespace.
|
|
export function canonicalize(s) {
|
|
return (s || "")
|
|
.trim()
|
|
.replace(/\\\(/g, "(")
|
|
.replace(/\\\)/g, ")")
|
|
.replace(/_/g, " ")
|
|
.replace(/\s+/g, " ");
|
|
}
|
|
|
|
export const WEIGHTED_TOKEN_RE = /^\(\s*(.+?)\s*:\s*[\d.]+\s*\)$/;
|
|
|
|
// Match a source token against a target token. Direct canonical equality,
|
|
// OR — if the source is a weighted form `(inner:weight)` — match by inner.
|
|
export function tokenMatches(srcToken, target) {
|
|
const a = canonicalize(srcToken);
|
|
const b = canonicalize(target);
|
|
if (a === b) return true;
|
|
const m = a.match(WEIGHTED_TOKEN_RE);
|
|
if (m && canonicalize(m[1]) === b) return true;
|
|
return false;
|
|
}
|
|
|
|
// Tokenize a line on commas, preserving paren/bracket weighted forms.
|
|
export function tokenizeLine(line) {
|
|
const tokens = [];
|
|
let depth = 0;
|
|
let buf = "";
|
|
for (const ch of line) {
|
|
if (ch === "(" || ch === "[") depth++;
|
|
else if (ch === ")" || ch === "]") depth = Math.max(0, depth - 1);
|
|
if (ch === "," && depth === 0) {
|
|
if (buf.trim()) tokens.push(buf.trim());
|
|
buf = "";
|
|
} else {
|
|
buf += ch;
|
|
}
|
|
}
|
|
if (buf.trim()) tokens.push(buf.trim());
|
|
return tokens;
|
|
}
|
|
|
|
// Section-header heuristic: section comments and `Negative Prompt:` are
|
|
// NOT tag-content lines.
|
|
export function isStructuralLine(line) {
|
|
const t = line.trim();
|
|
if (!t) return true;
|
|
if (t.startsWith("//")) return true;
|
|
if (/^[A-Z][a-zA-Z ]+ Prompt:\s*$/.test(t)) return true;
|
|
return false;
|
|
}
|
|
|
|
// Walks backwards from `idx` looking for a "Negative Prompt:" header.
|
|
export function isInNegativeBlock(lines, idx) {
|
|
for (let i = idx - 1; i >= 0; i--) {
|
|
const t = lines[i].trim();
|
|
if (/^[A-Z][a-zA-Z ]+ Prompt:\s*$/.test(t)) {
|
|
return /^Negative Prompt:/i.test(t);
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// Apply rendered prompt text + structured prompt_state to a CodeMirror view
|
|
// and persist state on the node. Returns true on a successful dispatch,
|
|
// false if the view is unavailable.
|
|
export function applyPromptText(view, outputText, node, promptState) {
|
|
if (!view || !outputText) return false;
|
|
const source = view.state.doc.toString();
|
|
if (outputText !== source) {
|
|
view.dispatch({ changes: { from: 0, to: source.length, insert: outputText } });
|
|
}
|
|
if (node && promptState !== undefined) {
|
|
if (!node.properties) node.properties = {};
|
|
node.properties.pcrPromptState = promptState;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
export function cryptoId() {
|
|
if (typeof window !== "undefined" && window.crypto?.randomUUID) {
|
|
return window.crypto.randomUUID().replace(/-/g, "");
|
|
}
|
|
return Math.random().toString(36).slice(2) + Date.now().toString(36);
|
|
}
|