A trigger->replacement library (core/shortcuts.py, a close port of v4's apply_prompt_shortcuts/resolve_variables), a Settings section to manage it, autocomplete on the real prompt textarea (composer.autocomplete.default, so it outranks the Constructor modal it lives inside), and a stateless /api/prompt/expand that combines anchor + shortcuts + $variables + postfix at the moment a run is queued -- scene.text itself stays exactly what was typed. Project gained anchor/postfix/variables editing UI on its own tab. Along the way: a real layout bug in the Properties column (every composer stack can flex-shrink below its own content, and once the Project tab grew past a couple of rows the column started squeezing its own children's content into each other and, at the outer level, into the sibling Generation section below) -- fixed with flex-shrink:0 at each level that needs it, caught by a Playwright regression test.
178 lines
6.7 KiB
JavaScript
178 lines
6.7 KiB
JavaScript
// The shortcut library: trigger -> replacement text, expanded into a scene's
|
|
// prompt at generation time (core/prompt_build.py, core/shortcuts.py). Global,
|
|
// not per-project -- a shortcut is reused across every project, so it lives in
|
|
// Settings beside Node packs and Updates rather than in the project's own
|
|
// properties column.
|
|
//
|
|
// Same mount({setFooter, close}) / open() shape as packs.js: content built
|
|
// once, usable standalone or hosted inside settings_window.js.
|
|
|
|
import { composer } from "../composer/composer.js";
|
|
|
|
const BASE = "/funpack/api/shortcuts";
|
|
|
|
async function ask(method, path = "", body) {
|
|
const res = await fetch(`${BASE}${path}`, {
|
|
method,
|
|
headers: body === undefined ? {} : { "Content-Type": "application/json" },
|
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
});
|
|
const payload = await res.json().catch(() => ({}));
|
|
if (!res.ok) throw new Error((payload.problems && payload.problems[0]) || `HTTP ${res.status}`);
|
|
return payload;
|
|
}
|
|
|
|
//: A short-lived cache so autocomplete.js and the run-queue expansion step
|
|
//: are not each holding (and re-fetching) their own copy. Invalidated by
|
|
//: every write this file makes; a write from elsewhere (there is none today)
|
|
//: would only go stale for the length of one generation.
|
|
let cached = null;
|
|
|
|
export async function fetchAll(force = false) {
|
|
if (cached && !force) return cached;
|
|
try {
|
|
cached = (await ask("GET")).shortcuts || [];
|
|
} catch {
|
|
cached = cached || [];
|
|
}
|
|
return cached;
|
|
}
|
|
|
|
const linesOf = (v) => String(v || "").split("\n").map((s) => s.trim()).filter(Boolean);
|
|
|
|
/** mount({ setFooter, close }) -> a composer handle. */
|
|
export function mount({ setFooter = () => {}, close = () => {} } = {}) {
|
|
let items = null;
|
|
let editing = null; // the item being edited/added, or null
|
|
let problem = null;
|
|
|
|
function startAdd() { editing = { name: "", triggers: [], replacements: [], enabled: true, category: "", sub_category: "" }; draw(); }
|
|
function startEdit(item) { editing = { ...item, original_name: item.name }; draw(); }
|
|
function cancelEdit() { editing = null; draw(); }
|
|
|
|
async function save(payload) {
|
|
problem = null;
|
|
try {
|
|
items = (await ask("POST", "", payload)).shortcuts;
|
|
editing = null;
|
|
} catch (err) {
|
|
problem = err.message;
|
|
}
|
|
cached = items;
|
|
draw();
|
|
}
|
|
|
|
async function remove(name) {
|
|
problem = null;
|
|
try {
|
|
items = (await ask("DELETE", `/${encodeURIComponent(name)}`)).shortcuts;
|
|
} catch (err) {
|
|
problem = err.message;
|
|
}
|
|
cached = items;
|
|
draw();
|
|
}
|
|
|
|
function editForm() {
|
|
const e = editing;
|
|
const nameIn = composer.input.md({ label: "Name", value: e.name, placeholder: "Golden hour" });
|
|
const trigIn = composer.textarea.md({ label: "Triggers", rows: 2,
|
|
value: e.triggers.join("\n"), placeholder: "golden hour\nsunset light" });
|
|
const repIn = composer.textarea.md({ label: "Replacements", rows: 3,
|
|
value: e.replacements.join("\n"),
|
|
placeholder: "warm golden-hour lighting\n(one per line -- more than one picks randomly)" });
|
|
const catIn = composer.input.md({ label: "Category", value: e.category, placeholder: "Lighting" });
|
|
const subIn = composer.input.md({ label: "Sub-category", value: e.sub_category, placeholder: "" });
|
|
const enabledCb = composer.toggle.default({ label: "Enabled", checked: e.enabled !== false });
|
|
|
|
const doSave = () => {
|
|
const triggers = linesOf(trigIn.value);
|
|
if (!triggers.length) { problem = "At least one trigger is required."; draw(); return; }
|
|
save({
|
|
name: nameIn.value.trim() || triggers[0],
|
|
triggers,
|
|
replacements: String(repIn.value || "").split("\n").map((s) => s.trim()),
|
|
enabled: enabledCb.value,
|
|
category: catIn.value.trim(),
|
|
sub_category: subIn.value.trim(),
|
|
original_name: e.original_name,
|
|
});
|
|
};
|
|
|
|
return composer.region.stack({ gap: "sm", children: [
|
|
composer.label.section({ text: e.original_name ? `Edit "${e.original_name}"` : "New shortcut" }),
|
|
composer.field.default({ label: "Name", control: nameIn }),
|
|
composer.field.default({ label: "Triggers", hint: "One per line, or what's typed to match.", control: trigIn }),
|
|
composer.field.default({ label: "Replacements", hint: "One per line; more than one picks at random each time.", control: repIn }),
|
|
composer.field.default({ label: "Category", control: catIn }),
|
|
composer.field.default({ label: "Sub-category", control: subIn }),
|
|
enabledCb,
|
|
composer.toolbar.default({ label: "Edit shortcut", items: [
|
|
composer.button.md({ label: "Save", tone: "primary", onClick: doSave }),
|
|
composer.button.md({ label: "Cancel", onClick: cancelEdit }),
|
|
] }),
|
|
] });
|
|
}
|
|
|
|
function rows() {
|
|
const out = [];
|
|
if (problem) out.push(composer.banner.danger({ text: problem }));
|
|
if (editing) out.push(editForm());
|
|
|
|
if (items == null) {
|
|
out.push(composer.hint.default({ text: "Loading…" }));
|
|
return out;
|
|
}
|
|
if (!items.length && !editing) {
|
|
out.push(composer.hint.default({ text: "No shortcuts yet. Add one below." }));
|
|
}
|
|
for (const item of items) {
|
|
const tag = [item.category, item.sub_category].filter(Boolean).join(" · ");
|
|
out.push(composer.settingsRow.default({
|
|
label: item.name + (item.enabled === false ? " (off)" : "") + (tag ? ` — ${tag}` : ""),
|
|
hint: `${item.triggers.join(", ")} → ${item.replacements.join(" / ") || "(removes the phrase)"}`,
|
|
control: composer.toolbar.default({ label: item.name, items: [
|
|
composer.button.sm({ label: "Edit", onClick: () => startEdit(item) }),
|
|
composer.button.sm({ label: "Delete", tone: "danger", onClick: () => remove(item.name) }),
|
|
] }),
|
|
}));
|
|
}
|
|
return out;
|
|
}
|
|
|
|
const body = composer.region.stack({ gap: "sm", label: "Shortcuts" });
|
|
function draw() {
|
|
body.set(rows());
|
|
setFooter({
|
|
note: items ? `${items.length} shortcut${items.length === 1 ? "" : "s"}` : "",
|
|
actions: [
|
|
composer.button.md({ label: "+ Add shortcut", disabled: Boolean(editing), onClick: startAdd }),
|
|
composer.button.md({ label: "Close", tone: "primary", onClick: close }),
|
|
],
|
|
});
|
|
}
|
|
|
|
async function load() {
|
|
try { items = await fetchAll(true); }
|
|
catch (err) { items = []; problem = err.message; }
|
|
draw();
|
|
}
|
|
|
|
load();
|
|
return body;
|
|
}
|
|
|
|
export function open() {
|
|
let window_ = null;
|
|
const body = mount({
|
|
setFooter: (f) => window_ && window_.setFooter(f),
|
|
close: () => window_ && window_.close("done"),
|
|
});
|
|
window_ = composer.modal.generic({
|
|
title: "Shortcuts", subtitle: "Trigger words that expand into a prompt at generation.",
|
|
size: "lg", body,
|
|
onClose: () => { window_ = null; },
|
|
});
|
|
return window_;
|
|
}
|