/**
* The SPA's browser-engine facade: wires the shared `core/` engine to a loader that
* augments the bundled data with the user's browser-local custom expansions, and
* exposes generation, live preview, the categorized building blocks, and presets.
* @module web-app/lib/promptEngine
*/
// The browser engine facade for the SPA.
//
// Wires the shared core engine to a loader that augments the bundled data with
// the user's browser-local custom expansions, configures the suggestion builder,
// and exposes everything the UI needs: generation, live preview, the categorized
// building blocks, and presets (built-in + custom).
import _ from "lodash";
import { createEngine } from "../../../src/core/engine.js";
import { browserLoader } from "../../../src/core/browserLoader.js";
import compileDpl from "../../../src/core/dpl/dpl.js";
import promptFiles from "../../../src/promptFilesAndSuggestions.js";
import { computeButtonNames, compareNames } from "../../../src/listManifest.js";
import { getCustomExpansions, getCustomPresets } from "./customStore.js";
// Composite loader: custom expansions (localStorage) shadow/extend the bundled
// ones; everything else passes through to the build-time bundle.
const loader = {
...browserLoader,
readExpansion(name) {
const custom = getCustomExpansions();
return name in custom ? custom[name] : browserLoader.readExpansion(name);
},
expansionNames() {
return [...new Set([...browserLoader.expansionNames(), ...Object.keys(getCustomExpansions())])];
}
};
promptFiles.configure(loader);
const engine = createEngine(loader);
/**
* Scale the emphasis / alternating knobs by `settings.chaos` (mirrors the CLI `--chaos`).
* @param {object} settings The generation settings.
* @returns {object} The (possibly) chaos-scaled settings.
*/
// Chaos scales the emphasis/alternating knobs (mirrors the CLI's --chaos).
function withChaos(settings) {
const c = Number(settings.chaos);
if (!c || c === 1) return settings;
return {
...settings,
emphasisChance: settings.emphasisChance * c,
emphasisLevelChance: settings.emphasisLevelChance * c,
emphasisMaxLevels: Math.round(settings.emphasisMaxLevels * c),
deEmphasisChance: Math.min(0.5, Math.max(0.25, settings.deEmphasisChance * c)),
keywordAlternatingMaxLevels: Math.round(settings.keywordAlternatingMaxLevels * c)
};
}
/**
* @param {object} settings The generation settings.
* @returns {string} One generated prompt.
*/
export function generatePrompt(settings) {
return engine.generate(withChaos(settings));
}
/**
* @param {object} settings The generation settings (`promptCount`).
* @returns {string[]} That many generated prompts.
*/
export function generatePrompts(settings) {
return engine.generateMany(withChaos(settings));
}
/**
* Expand a specific prompt (the live preview).
* @param {string} prompt The prompt to expand.
* @param {object} settings The generation settings.
* @returns {string} The expanded prompt.
*/
export function expandPrompt(prompt, settings) {
return engine.generate({
...withChaos(settings),
prompt
});
}
/**
* Render one wrapper box (a DPL snippet — the user can use bullets/probability) into a flat token
* string. Tokens it emits ({#…}, {list}, <exp>) are resolved later by the generation pipeline.
* @param {string} text The wrapper box's DPL text.
* @param {object} [settings] The generation settings (passed to any JS the snippet calls).
* @returns {string} The rendered token string (or "" when blank).
*/
export function renderWrapperPart(text, settings = {}) {
if (!text || !text.trim()) return "";
try {
const mod = compileDpl(`Start\n===\n${text}`, {
resolveJs: () => ""
});
return mod.default(settings, {}, {}) || "";
} catch {
return text; // fall back to the raw text if it isn't valid DPL
}
}
// ---- Building blocks (the "keyword cloud"), categorized like the original ----
// Populate the classifier pools (used by the {#random-prompt} suggestion builder).
promptFiles.loadAll();
const label = name => _.startCase(name);
const toItems = (names, wrap) => names.map(n => ({
token: wrap(n),
label: label(n)
}));
// Dynamic prompts live under data/dynamic-prompts/{v3 (default),v2,v1}/<category>/. Every
// generation is browsed FIRST CLASS and identically: category-folder pills (clickable -> the
// {#folder} group, which picks ONE random generator in that folder) + each chip with its
// sidecar tooltip, split into full / partial. The only difference is the token prefix — v3 is
// bare ({#cave}, {#scene}), v1/v2 carry their path ({#v2/cave}, {#v2/scene}).
const allDynNames = browserLoader.dynamicPromptNames();
// Tooltip text for a generator. Prefer the `.dpl` front-matter `description:` (authored in the
// file itself), falling back to the optional `.json` sidecar for legacy `.js` generators.
const dpDescFor = key => {
const mod = browserLoader.loadDynamicPrompt(key);
return mod?.meta?.description || browserLoader.readDynPromptMeta(key)?.description || undefined;
};
const lastSeg = f => f === "" ? "misc" : f.split("/").pop();
// Per-generation browse data. `tag` is the token prefix ("" = v3 default, "v1"/"v2" = frozen).
const allForced = browserLoader.dynPromptForcedPrefixDirsAll ? browserLoader.dynPromptForcedPrefixDirsAll() : browserLoader.dynPromptForcedPrefixDirs();
const allGroups = browserLoader.dynPromptGroupDirsAll ? browserLoader.dynPromptGroupDirsAll() : browserLoader.dynPromptGroupDirs();
const GENS = {
v3: {
tag: ""
},
v2: {
tag: "v2"
},
v1: {
tag: "v1"
}
};
for (const [k, g] of Object.entries(GENS)) {
g.names = allDynNames.filter(n => n.startsWith(`${k}/`));
g.forced = allForced.filter(d => d.startsWith(`${k}/`));
g.groupSet = new Set(allGroups.filter(d => d.startsWith(`${k}/`)));
g.btn = computeButtonNames(g.names, g.forced);
}
// Split one generation's keys into full vs partial (v1 has no partials — all full).
function splitFP(genKey) {
const g = GENS[genKey];
if (genKey === "v1") return {
full: g.names,
partial: []
};
const full = [];
const partial = [];
for (const k of g.names) {
const mod = browserLoader.loadDynamicPrompt(k);
(mod && mod.full === true || k.startsWith(`${genKey}/user/`) ? full : partial).push(k);
}
return {
full,
partial
};
}
// Folder-grouped chips for a set of generator keys within one generation. The chip token is
// `{#<tag>/<short>}` (bare for v3); the folder pill is a clickable `{#<tag>/<folder>}` group
// when that folder is an implied group (2+ generators).
const dynCatItems = (keys, genKey) => {
const g = GENS[genKey];
const pre = g.tag ? `${g.tag}/` : "";
const byFolder = new Map();
for (const k of keys) {
const i = k.lastIndexOf("/");
const folder = i < 0 ? "" : k.slice(0, i);
if (!byFolder.has(folder)) byFolder.set(folder, []);
byFolder.get(folder).push(k);
}
const cats = [...byFolder.entries()].map(([folder, members]) => ({
label: lastSeg(folder),
token: g.groupSet.has(folder) ? `{#${pre}${lastSeg(folder)}}` : null,
description: dpDescFor(folder),
// The chip label IS the token you'd type (its inner text): bare for v3, prefixed for v1/v2.
entries: members.map(k => ({
token: `{#${pre}${g.btn[k]}}`,
label: `${pre}${g.btn[k]}`,
description: dpDescFor(k)
})).sort((a, b) => compareNames(a.label, b.label))
})).sort((a, b) => compareNames(a.label, b.label));
const out = [];
for (const c of cats) {
// A clickable group pill shows its full {#…} inner text too; a plain header shows the folder.
const pill = {
category: true,
label: c.token ? `${pre}${c.label}` : c.label,
description: c.description
};
if (c.token) pill.token = c.token;
out.push(pill, ...c.entries);
}
return out;
};
// The wildcard family that leads a generation's block list. `any` (and -sfw/-nsfw) is VERSION-LOCKED:
// it draws only from that generation and carries the same `v#/` prefix the generators do (bare for
// v3) — the button reflects the prefix. `any-ver` (and -sfw/-nsfw) is the exception: never version-
// locked or prefixed, it spans ALL generations and shows identically on every tab.
const dynWildcardItems = genKey => {
const pre = GENS[genKey].tag ? `${GENS[genKey].tag}/` : ""; // "" for v3, "v2/"/"v1/" for frozen
return [{
category: true,
label: `${pre}any`,
token: `{#${pre}any}`,
description: "One random generator from THIS version (SFW; +NSFW when adult is on)."
}, {
token: `{#${pre}any-sfw}`,
label: `${pre}any-sfw`,
description: "One random generator from this version, SFW only."
}, {
token: `{#${pre}any-nsfw}`,
label: `${pre}any-nsfw`,
description: "One random generator from this version, including NSFW (adult mode only)."
}, {
token: "{#any-ver}",
label: "any-ver",
description: "One random generator from ANY version (v1/v2/v3)."
}, {
token: "{#any-ver-sfw}",
label: "any-ver-sfw",
description: "Any version, SFW only."
}, {
token: "{#any-ver-nsfw}",
label: "any-ver-nsfw",
description: "Any version, including NSFW (adult mode only)."
}];
};
// The "special" category — version-independent engine controls (currently the seed-salt), shown
// as a category within the v3 Blocks › all list.
const specialItems = () => [{
category: true,
label: "special",
description: "Engine controls that aren't drawn from any list or generator."
}, {
token: "{salt}",
label: "salt",
description: "Inject a random seed-salt number — nudges the result without changing the prompt."
}];
// Expansions are a LEGACY (v1/v2-era) concept — reusable `<name>` snippets living on disk under
// data/expansions-obsolete/. They are shown as an "expansion" category ONLY on the frozen v1/v2
// tabs (never v3), built as folder categories the same way Lists are. The chip inserts `<name>`.
const expDisplay = computeButtonNames(browserLoader.expansionNames(), browserLoader.expansionForcedPrefixDirs ? browserLoader.expansionForcedPrefixDirs() : []);
const expDescFor = n => (browserLoader.readExpansionMeta ? browserLoader.readExpansionMeta(n) : null)?.description;
const expansionItems = () => {
const names = browserLoader.expansionNames();
if (!names.length) return [];
const groupDirs = new Set(browserLoader.expansionGroupDirs ? browserLoader.expansionGroupDirs() : []);
const lastSeg = f => f === "" ? "misc" : f.split("/").pop();
const byFolder = new Map();
for (const n of names) {
const i = n.lastIndexOf("/");
const folder = i < 0 ? "" : n.slice(0, i);
if (!byFolder.has(folder)) byFolder.set(folder, []);
byFolder.get(folder).push(n);
}
const cats = [];
for (const [folder, members] of byFolder) {
cats.push({
label: lastSeg(folder),
// A folder with 2+ expansions is an implied group: the pill inserts <folder> (pick one).
token: groupDirs.has(folder) ? `<${lastSeg(folder)}>` : null,
description: expDescFor(folder),
entries: members.map(n => ({
token: `<${expDisplay[n]}>`,
label: expDisplay[n],
description: expDescFor(n)
})).sort((a, b) => a.label.localeCompare(b.label))
});
}
cats.sort((a, b) => a.label.localeCompare(b.label));
const out = [];
for (const c of cats) {
const pill = {
category: true,
label: c.label,
description: c.description
};
if (c.token) pill.token = c.token;
out.push(pill, ...c.entries);
}
return out;
};
// v3 has no full/partial — it's one "Prompts" list. v1/v2 keep their frozen full/partial split.
const fp = {
v2: splitFP("v2"),
v1: splitFP("v1")
};
// Shortest unambiguous display token per list (filename only, unless a conflict or a
// `.force-prefix` folder like danbooru/d requires more of the path). The button shows
// and inserts this token (e.g. {color}, {d/general}) rather than the full path.
const listDisplay = computeButtonNames(browserLoader.listNames(), browserLoader.forcedPrefixDirs());
// Optional `<list>.json` sidecar description for the button tooltip. For an implicit
// base ({d/general}) or a folder, the SFW file carries it, so fall back to `<name>-sfw`.
const descFor = n => (loader.readListMeta(n) || loader.readListMeta(`${n}-sfw`) || null)?.description;
// Build the Lists block as folder categories: an alphabetical run of lists per folder,
// each preceded by a category pill (the folder's last-segment name + its description as
// the tooltip). When the folder is itself an implied group, the pill is clickable and
// inserts that group ({word}, {d}, ...) — merging the header and the group button.
const listItems = () => {
const names = browserLoader.listNames();
const groupDirs = new Set(browserLoader.groupListDirs());
const byFolder = new Map();
for (const n of names) {
if (groupDirs.has(n)) continue; // folder-group names become pills, not entries
const i = n.lastIndexOf("/");
const folder = i < 0 ? "" : n.slice(0, i);
if (!byFolder.has(folder)) byFolder.set(folder, []);
byFolder.get(folder).push(n);
}
const lastSeg = f => f === "" ? "misc" : f.split("/").pop();
const cats = [];
for (const [folder, members] of byFolder) {
cats.push({
label: lastSeg(folder),
token: groupDirs.has(folder) ? `{${listDisplay[folder]}}` : null,
description: descFor(folder),
entries: members.map(n => ({
token: `{${listDisplay[n]}}`,
label: listDisplay[n],
description: descFor(n)
})).sort((a, b) => a.label.localeCompare(b.label))
});
}
// The reserved `keyword` wildcard isn't a folder/file — give it its own category.
cats.push({
label: "keyword",
token: "{keyword}",
description: "A random word drawn from ALL loaded vocabulary (every list).",
entries: [{
token: "{keyword-sfw}",
label: "keyword-sfw",
description: "All vocabulary, SFW only."
}, {
token: "{keyword-nsfw}",
label: "keyword-nsfw",
description: "All vocabulary, including NSFW (adult mode only)."
}]
});
cats.sort((a, b) => a.label.localeCompare(b.label));
const out = [];
for (const c of cats) {
const pill = {
category: true,
label: c.label,
description: c.description
};
if (c.token) pill.token = c.token;
out.push(pill, ...c.entries);
}
return out;
};
// v3 keeps using the moved-to-block expansions (referenced as {#rays}, {#dap}, …), but they live
// under v3/expansion/ and are NOT listed as pickable v3 chips — they're excluded from the v3 walk.
// The legacy `<name>` expansions (data/expansions-obsolete/) show as their own "Expansions" tab on
// the frozen v1/v2 generations only (see expansionItems).
const isExpansionKey = n => n.startsWith("v3/expansion/");
/**
* @returns {object[]} The categorized building-block groups for the token cloud. The dynamic blocks
* (Blocks = v3 all; Full / Partial = frozen v1/v2) are `dynVersioned` and carry `variants` per
* generation; Expansions is a v1/v2-only legacy tab; then Lists + the browser-local custom blocks.
*/
export function getBlocks() {
const blocks = [{
title: "Blocks",
subLabel: "all",
hint: "Every current (v3) building block — scenes, subjects, fragments, and styles.",
dynVersioned: true,
variants: {
v3: [...dynWildcardItems("v3"), ...dynCatItems(GENS.v3.names.filter(n => !isExpansionKey(n)), "v3"), ...specialItems()],
v2: [],
v1: []
},
items: []
}, {
title: "Full prompts",
subLabel: "full",
hint: "Complete, self-contained generators (frozen generations).",
dynVersioned: true,
variants: {
v3: [],
v2: [...dynWildcardItems("v2"), ...dynCatItems(fp.v2.full, "v2")],
v1: [...dynWildcardItems("v1"), ...dynCatItems(fp.v1.full, "v1")]
},
items: []
}, {
title: "Partial prompts",
subLabel: "partial",
hint: "Accents and modifiers (frozen generations).",
dynVersioned: true,
variants: {
v3: [],
v2: dynCatItems(fp.v2.partial, "v2"),
v1: []
},
items: []
}, {
// Legacy `<name>` expansions — only the frozen v1/v2 generations use them (not v3).
title: "Expansions",
subLabel: "expansion",
hint: "Legacy reusable snippets — insert <name>. Used by the frozen v1/v2 generators.",
dynVersioned: true,
variants: {
v3: [],
v2: expansionItems(),
v1: expansionItems()
},
items: []
}, {
// Lists are version-independent — shown as a Blocks sub-tab on every generation.
title: "Lists",
subLabel: "lists",
hint: "Word lists — each insertion becomes one random entry from the list.",
dynVersioned: true,
variants: {
v3: listItems(),
v2: listItems(),
v1: listItems()
},
items: []
}];
const custom = Object.keys(getCustomExpansions());
if (custom.length) {
blocks.push({
title: "Your blocks",
hint: "Saved in this browser",
items: toItems(custom, n => `<${n}>`)
});
}
return blocks;
}
/**
* @returns {string[]} The sorted list names.
*/
export function getListNames() {
return browserLoader.listNames().slice().sort();
}
/**
* @returns {string[]} The sorted preset names (built-in + the user's custom presets).
*/
export function getPresetNames() {
return [...new Set([...browserLoader.presetNames(), ...Object.keys(getCustomPresets())])].sort();
}
/**
* Load a preset's settings (a custom preset shadows a built-in of the same name).
* @param {string} name The preset name.
* @returns {object} The preset settings (or `{}` if unknown).
*/
export function loadPreset(name) {
const custom = getCustomPresets();
if (name in custom) return custom[name];
return browserLoader.loadPreset(name) || {};
}