/**
* @file
* @brief Framework-agnostic prompt engine: createEngine(loader) runs the same pipeline as the CLI over a prompt string. Notes: notes/systems/core-engine.md.
*/
// The framework-agnostic prompt engine.
//
// `createEngine(loader)` returns an engine that runs the prompt-module pipeline
// (the same stages and order as the Node CLI) over a prompt string. All data
// access — lists, blocks — goes through the injected
// `loader`, so the identical engine runs in Node (fs + createRequire loader) and
// in the browser (Vite import.meta.glob loader). See notes/plans/web-migration.md.
//
// Loader interface:
// readListLines(name) -> string[] | null
// listNames() -> string[]
// loadBlock(key) -> { default, suggestion_exclude? } | null
//
// The pure stages (prompt-salt, cleanup) and the random* helpers are imported
// and reused directly — only the file/plugin access is reimplemented behind the
// loader, so there is no duplicated prompt logic.
import baseSettings from "../settings.js";
import { createRng } from "./rng.js";
import { withAmbientRng } from "../helpers/random.js";
import promptSalt from "./stages/prompt-salt.js";
import cleanup from "./stages/cleanup.js";
import { makeBlockStage } from "./stages/block.js";
import { makeListStage } from "./stages/list.js";
import emphasis from "./stages/emphasis.js";
import { createListStore } from "./listStore.js";
// v3-only pipeline. The legacy `<expansion>` stage was removed (v1/v2-era); the
// block stage internally re-expands up to 10 passes, so one entry suffices.
// `emphasis` runs after `list` so it sees the fully expanded text (typed `()`/`[]` from the
// prompt box AND from rendered DPL blocks) and translates it into the active dialect.
const DEFAULT_ORDER = ["block", "prompt-salt", "list", "emphasis", "cleanup"];
/**
* Create a framework-agnostic prompt engine that runs the same pipeline as the CLI.
* @param {object} loader Data-access loader (Node fs or browser glob):
* `readListLines`, `listNames`, `loadBlock`.
* @returns {{expand: Function, generate: Function, generateMany: Function}} The engine API.
*/
export function createEngine(loader) {
const store = createListStore(loader);
const stages = {
block: makeBlockStage(loader),
"prompt-salt": promptSalt,
list: makeListStage(store),
emphasis,
cleanup,
};
/**
* Run the prompt-module pipeline (in `settings.promptModules` order) over a prompt.
* @param {string} prompt The seed prompt.
* @param {object} settings The merged settings.
* @param {object} imageSettings Per-generation image-settings scratch.
* @param {object} upscaleSettings Per-generation upscale-settings scratch.
* @returns {string} The fully expanded prompt.
*/
function expand(prompt, settings, imageSettings, upscaleSettings) {
const order = settings.promptModules || DEFAULT_ORDER;
for (const name of order) {
const stage = stages[name];
if (!stage) continue;
prompt = stage(prompt, settings, imageSettings, upscaleSettings);
}
// Drop stray carriage returns, like the CLI does after the pipeline.
return prompt.replaceAll("\r", "");
}
// Whether `userSettings` carries an explicit seed that should pin the run. A present seed (any value
// except null/undefined/empty-string) makes the whole run reproducible; without one we use the
// default ambient source (`Math.random`), so every call rerolls fresh.
//
// There is NO magic seed value here: any integer — including negatives and -1 — is a valid, honoured
// seed. Whether a generation is random or pinned is decided by the CALLER (the GUI's explicit
// "Random" toggle passes no seed for a random roll and the exact seed to pin), not by a reserved
// sentinel. Empty/null simply means "no seed given", which is the natural random default.
const hasSeed = (u) => u.seed != null && u.seed !== "";
// Run one generation, optionally under a seeded ambient rng. Defaults from settings.js are merged
// under the caller's overrides so every field the stages read is present, and a shallow copy is
// used so per-generation mutations (auto-fx toggles, salt counter, etc.) don't leak.
function generateOnce(userSettings, rng) {
const run = () => {
store.reset();
const settings = { ...baseSettings, ...userSettings };
return expand(settings.prompt ?? "{#random-words}", settings, {}, {});
};
return rng ? withAmbientRng(rng, run) : run();
}
/**
* Generate a single prompt from default settings merged under the caller's overrides. When
* `userSettings.seed` is set the result is deterministic (same seed + catalog → same prompt);
* otherwise it draws from `Math.random` as before.
* @param {object} [userSettings] Settings overrides (e.g. `{ prompt, mode, seed }`).
* @returns {string} One generated prompt.
*/
function generate(userSettings = {}) {
return generateOnce(userSettings, hasSeed(userSettings) ? createRng(userSettings.seed) : null);
}
/**
* Like {@link generate}, but always deterministic and always reports the seed used — auto-generating
* a fresh one when `userSettings.seed` is absent. Pass the returned `seed` back as `settings.seed`
* to reproduce the exact prompt.
* @param {object} [userSettings] Settings overrides.
* @returns {{prompt: string, seed: string}} The prompt and the seed that produced it.
*/
function generateWithSeed(userSettings = {}) {
// No seed given → createRng mints a fresh random one and records it on rng.seed for the caller.
const rng = createRng(hasSeed(userSettings) ? userSettings.seed : undefined);
return { prompt: generateOnce(userSettings, rng), seed: rng.seed };
}
/**
* Generate `userSettings.promptCount` prompts (minimum 1). With a seed, each prompt gets its own
* deterministic sub-stream (`rng.fork(i)`), so the whole batch is reproducible.
* @param {object} [userSettings] Settings overrides.
* @returns {string[]} The generated prompts.
*/
function generateMany(userSettings = {}) {
const count = Math.max(1, Number(userSettings.promptCount) || 1);
const parent = hasSeed(userSettings) ? createRng(userSettings.seed) : null;
return Array.from({ length: count }, (_v, i) =>
generateOnce(userSettings, parent ? parent.fork(i) : null),
);
}
/**
* Async counterpart of {@link generateMany}: same output and seeding, but it yields to the event
* loop between prompts so a large batch never blocks the thread. The per-prompt render itself is
* pure CPU and stays synchronous by design (it also drives the instant live preview); this is the
* async-capable boundary for batch work.
* @param {object} [userSettings] Settings overrides.
* @returns {Promise<string[]>} The generated prompts.
*/
async function generateManyAsync(userSettings = {}) {
const count = Math.max(1, Number(userSettings.promptCount) || 1);
const parent = hasSeed(userSettings) ? createRng(userSettings.seed) : null;
const out = [];
for (let i = 0; i < count; i++) {
out.push(generateOnce(userSettings, parent ? parent.fork(i) : null));
if (i + 1 < count) await Promise.resolve(); // yield between prompts
}
return out;
}
return { expand, generate, generateWithSeed, generateMany, generateManyAsync };
}