src/promptFilesAndSuggestions.js

/**
 * @file
 * @brief Loader-injected dynamic-prompt classifier (full vs partial) and random promptSuggestion() builder; also feeds the web file pickers. Notes: notes/reference/dynamic-prompts.md.
 */

import _ from "lodash";

import cleanup from "./prompt-modules/cleanup.js";
import { isGatedList, hasNsfwToken, isGatedDynPrompt } from "./gatedLists.js";
import { hasVariantSuffix, computeButtonNames } from "./listManifest.js";

// Dynamic-prompt classification + random "suggestion" builder.
//
// This is loader-injected: instead of scanning the filesystem and require()-ing
// plugins itself, it reads the catalog through a loader (the same interface the
// engine uses), so it runs in Node (fs + createRequire loader) and in the browser
// (Vite import.meta.glob loader). Call `configure(loader)` once at startup before
// `loadAll()`. See notes/plans/web-migration.md and notes/reference/esm-patterns.md.

let loader = null;

/**
 * Inject the data loader (fs in Node, glob in the browser). Call once before `loadAll()`.
 * @param {object} _loader The loader implementation.
 * @returns {void}
 */
function configure(_loader) {
  loader = _loader;
}

const fullRegular = [];
const fullRegularExcluded = [];
const partialRegular = [];
const userFiles = [];
const v1Files = [];
const v2Files = [];
const allDynPrompts = [];

const listFiles = [];
const expansionFiles = [];

const fullDynPrompt = []; // Excludes v1 files

// Artists should always come at the end
const listFilesNoArtist = [];

// Partial prompts should not include artists
const partialNoArtistFx = [];

let settings;

/**
 * Provide the settings accessor used during suggestion cleanup.
 * @param {Function} _settings The `settings()` accessor.
 * @returns {void}
 */
function init(_settings) {
  settings = _settings;
}

/**
 * @returns {object} The configured loader.
 * @throws {Error} If `configure()` has not been called.
 */
function requireLoader() {
  if (!loader)
    throw new Error("promptFilesAndSuggestions: configure(loader) must be called before loadAll()");
  return loader;
}

/**
 * Classify every dynamic prompt into full / partial (plus the v1 and user-submitted
 * buckets) — the lists used by `promptSuggestion()` and the web file pickers.
 * @returns {object} `{fullRegular, partialRegular, userFiles, v1Files, all}`.
 */
function loadDynPromptList() {
  const l = requireLoader();

  fullRegular.length = 0;
  fullRegularExcluded.length = 0;
  partialRegular.length = 0;
  userFiles.length = 0;
  v1Files.length = 0;
  v2Files.length = 0;
  allDynPrompts.length = 0;
  fullDynPrompt.length = 0;
  partialNoArtistFx.length = 0;

  // The loader returns catalog keys relative to the dynamic-prompts root, e.g.
  //   "v3/scene/cave"  |  "v3/user/beach-merk"  |  "v2/scene/beach"  |  "v1/castle"
  // v3 is the DEFAULT (active) catalog; v1 and v2 are FROZEN, addressed only by their path
  // prefix ({#v1/castle}, {#v2/scene/cave}) and kept out of the random-suggestion pools.
  // Active generators are stored by their SHORTEST unambiguous token (computeButtonNames —
  // basenames are unique), so a bare `{#token}` resolves by suffix.
  const activeKeys = [];
  for (const key of l.dynamicPromptNames()) {
    if (key.startsWith("v1/")) {
      v1Files.push(key); // frozen — token is the full path; {#v1/castle}
      continue;
    }
    if (key.startsWith("v2/")) {
      v2Files.push(key); // frozen — {#v2/scene/cave}
      continue;
    }
    if (key.startsWith("v3/user/")) {
      userFiles.push(`user-${key.slice("v3/user/".length)}`);
      continue;
    }
    activeKeys.push(key); // active v3 catalog
  }

  const forced = l.dynPromptForcedPrefixDirs ? l.dynPromptForcedPrefixDirs() : [];
  const buttonNames = computeButtonNames(activeKeys, forced);
  for (const key of activeKeys) {
    const mod = l.loadDynamicPrompt(key);
    if (!mod) continue;

    // v3 has no full/partial distinction — every active generator is just a "prompt". The whole
    // active set is the suggestion pool (minus any `suggestions: off`); `partialRegular` stays empty.
    const token = buttonNames[key];
    fullRegular.push(token);
    if (mod.suggestion_exclude !== true) fullRegularExcluded.push(token);
  }

  // Partial prompts, minus artists and the fx one
  for (const name of partialRegular) {
    if (name.includes("artist")) continue;
    if (name == "fx") continue;
    partialNoArtistFx.push(name);
  }

  allDynPrompts.splice(
    0,
    0,
    ...fullRegular,
    ...partialRegular,
    ...userFiles,
    ...v1Files,
    ...v2Files,
  );
  fullDynPrompt.splice(0, 0, ...fullRegularExcluded, ...userFiles);

  return {
    fullRegular,
    partialRegular,
    userFiles,
    v1Files,
    v2Files,
    all: [...fullRegular, ...partialRegular, ...userFiles, ...v1Files, ...v2Files],
  };
}

/**
 * @returns {string[]} The expansion names (cached).
 */
function loadExpansionFileList() {
  expansionFiles.length = 0;
  expansionFiles.push(...requireLoader().expansionNames());
  return expansionFiles;
}

/**
 * Load the list names (and cache the artist-excluded subset).
 * @returns {string[]} The list names.
 */
function loadListFileList() {
  const l = requireLoader();
  listFiles.length = 0;
  listFilesNoArtist.length = 0;

  listFiles.push(...l.listNames());
  for (const name of listFiles) {
    if (name.includes("artist")) continue;
    // Skip explicit `-sfw`/`-nsfw` variants: the random pool draws only the bare base
    // name, which already resolves SFW (adult off) or SFW+NSFW (adult on). This avoids
    // double-weighting a base against its variants. (A standalone `-nsfw` list with no
    // base is dropped here too; it is reachable only by its explicit gated name.)
    if (hasVariantSuffix(name)) continue;
    listFilesNoArtist.push(name);
  }

  return listFiles;
}

/**
 * The list names to show in the web picker, honoring adult mode. When adult is off,
 * every name carrying an `nsfw` token is hidden (the app behaves as if it doesn't
 * exist). When on, each base that has a `<base>-nsfw` sibling also offers the explicit
 * `<base>-sfw` reference, so the picker shows all three (default / SFW-only / NSFW).
 * @returns {string[]} The picker-facing list names, in load order.
 */
function pickerListNames() {
  const names = requireLoader().listNames(); // logical names (base + -sfw/-nsfw variants)
  // Reserved wildcard: {keyword} draws from all loaded vocabulary. Offered as a
  // first-class button (its -sfw/-nsfw variants follow the same mode rules as files).
  if (adultAllowed()) return ["keyword", "keyword-sfw", "keyword-nsfw", ...names];
  // Adult off: behave as if NSFW doesn't exist. Hide every `-nsfw` name and the
  // redundant `-sfw` variants, leaving just the bare/default names (which are SFW).
  return ["keyword", ...names.filter((n) => !hasNsfwToken(n) && !hasVariantSuffix(n))];
}

/**
 * Load the dynamic-prompt, expansion, and list catalogs.
 * @returns {void}
 */
function loadAll() {
  loadDynPromptList();
  loadExpansionFileList();
  loadListFileList();
}

/**
 * Build a random garnish of `<expansion>` / `#partial` / `{list}` tokens to prefix
 * a suggestion (each added at ~25% chance).
 * @param {number} maxCount How many garnish rounds to roll.
 * @returns {string} The garnish string.
 */
/**
 * @returns {boolean} Whether adult/explicit lists and prompts are enabled.
 */
function adultAllowed() {
  const ctx = settings ? settings() : {};
  return !!(ctx.settings && ctx.settings.includeAdult === true);
}

/**
 * Drop gated (adult) names from a pool unless `includeAdult` is on.
 * @param {string[]} names The candidate names.
 * @param {function(string): boolean} isGated Predicate: true if a name is adult-gated.
 * @returns {string[]} The filtered pool.
 */
function gatePool(names, isGated) {
  return adultAllowed() ? names : names.filter((n) => !isGated(n));
}

function prePrompt(maxCount) {
  let prePrompt = "";

  const partialPool = gatePool(partialNoArtistFx, isGatedDynPrompt);
  const listPool = gatePool(listFilesNoArtist, isGatedList);

  // Garnish with a few partial generators and lists (each ~25%). Pools may be empty (v3 has no
  // partials; expansions are unified into generators), so guard every sample.
  for (let i = 0; i < maxCount; i++) {
    if (partialPool.length && _.random(0.0, 1.0, true) < 0.25)
      prePrompt += `, {#${_.sample(partialPool)}}`;

    if (listPool.length && _.random(0.0, 1.0, true) < 0.25)
      prePrompt += `, {${_.sample(listPool)}}`;
  }

  return prePrompt;
}

/**
 * Build a random prompt suggestion (the engine behind `#random`): one to three full
 * dynamic prompts, sometimes AND-weighted, with optional garnish, then cleaned up.
 * @param {boolean} [full] Use the richer multi-prompt form.
 * @returns {string} The suggested prompt.
 */
function promptSuggestion(full) {
  // Prepare building final prompt
  let ret = "";

  // Keep gated (adult) dynamic prompts out unless explicitly enabled
  const fullPool = gatePool(fullDynPrompt, isGatedDynPrompt);

  let maxOptions = full == true ? 3 : 0;
  let maxCount = full == true ? 3 : 1;

  switch (_.random(0, maxOptions, false)) {
    // Option 0: Pick 1 full dynamic prompt
    case 0:
      ret = `${prePrompt(maxCount)}, {#${_.sample(fullPool)}}`;
      break;

    case 1:
      ret = `${prePrompt(maxCount)}, {#${_.sample(fullPool)}} :0.75 AND ${prePrompt(maxCount)}, {#${_.sample(fullPool)}} :1.1`;
      break;

    case 2:
      ret = `${prePrompt(maxCount)}, {#${_.sample(fullPool)}} :0.75 AND ${prePrompt(maxCount)}, {#${_.sample(fullPool)}} :1.1 AND ${prePrompt(maxCount)}, {#${_.sample(fullPool)}} :0.50`;
      break;

    case 3:
      ret = `${prePrompt(maxCount)}, {#${_.sample(fullPool)}}, ${prePrompt(maxCount)}, {#${_.sample(fullPool)}}`;
      break;
  }

  // Cleanup prompt (image/upscale settings are optional here)
  const ctx = settings ? settings() : {};
  ret = cleanup(ret, ctx.settings, ctx.imageSettings, ctx.upscaleSettings);

  // Somehow this still slips through, this time, explicitly search for it
  ret = ret.replaceAll("AND,", "AND");

  return ret;
}

export default {
  configure,
  init,
  loadDynPromptList,
  loadExpansionFileList,
  loadListFileList,
  pickerListNames,
  loadAll,
  promptSuggestion,
};