Files
olivv-cs-ComfyUI-FunPack/app/shell/shortcuts.js
T
digital-garbage 2e384c48b2 core, app: shortcuts, autocomplete, and prompt-craft expansion
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.
2026-09-08 11:49:49 +03:00

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_;
}