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.
This commit is contained in:
digital-garbage
2026-09-08 11:49:49 +03:00
parent e9fcfa247b
commit 2e384c48b2
21 changed files with 1607 additions and 4 deletions
+43 -1
View File
@@ -15,6 +15,7 @@ import { wire, waitForTerminal } from "./shell/session.js";
import { check, load, describe, search } from "./shell/pipeline.js";
import { mount as mountPipeline } from "./shell/pipeline_window.js";
import { createPrompts } from "./shell/prompt.js";
import { attach as attachAutocomplete } from "./shell/autocomplete.js";
import { createProject } from "./shell/projects.js";
import { createTimeline } from "./shell/timeline.js";
import { createInspector } from "./shell/inspector.js";
@@ -25,6 +26,7 @@ import { mount as mountUpdates, status as gitStatus } from "./shell/updates.js";
import { mount as mountPacks } from "./shell/packs.js";
import { mount as mountLog } from "./shell/logwindow.js";
import { mount as mountTemp } from "./shell/tempfiles.js";
import { mount as mountShortcuts } from "./shell/shortcuts.js";
import { createSettingsWindow } from "./shell/settings_window.js";
const root = document.querySelector("#app");
@@ -108,6 +110,11 @@ async function start() {
const showScene = (scene) => {
const box = prompts && prompts.at("generation.prompt");
if (box) box.setValue(scene ? scene.text : "");
// The box's own DOM node, so autocomplete has an actual field to attach
// to. `attach()` is a no-op on a node it has already seen -- the guard is
// needed because showScene() runs on every selection change, and box.node
// only actually CHANGES after a pipeline edit rebuilds the control.
if (box && box.node) attachAutocomplete(box.node);
// The properties column is about ONE scene, and says which.
const at = scene ? project.scenes.findIndex((s) => s.id === scene.id) + 1 : 0;
if (page && page.properties) page.properties.setTitle(at ? `Scene · ${at}` : "Scene");
@@ -251,6 +258,11 @@ async function start() {
keywords: "log console errors output", icon: "▤", tone: "danger",
mount: (ctx) => mountLog(ctx),
},
{
id: "shortcuts", title: "Shortcuts", subtitle: "Trigger words that expand into a prompt at generation.",
keywords: "shortcuts triggers replacements autocomplete prompt", icon: "✱", tone: "accent",
mount: (ctx) => mountShortcuts(ctx),
},
{
id: "temp", title: "Temp files", subtitle: "Where a file went when it did not land in the bin.",
keywords: "temp files output directory", icon: "▥", tone: "neutral",
@@ -322,9 +334,39 @@ async function start() {
// whatever follows it is wired to that.
},
});
// What a run actually sends for the prompt box: not the literal typed text,
// but that text with the project's anchor/postfix/$variables/shortcuts
// applied (core/prompt_build.py) -- scene.text itself stays exactly what
// was typed, so editing and regenerating always shows that, not an
// expanded copy nothing can trace back to it. Only the "generation.prompt"
// field is touched; every other override goes through unchanged.
async function queueInputs() {
const raw = prompts ? prompts.overrides() : {};
const field = prompts && prompts.fields.find((f) => f.at === "generation.prompt");
const text = field && (raw[field.slot] || {})[field.input];
if (!field || text == null) return raw;
try {
const res = await fetch("/funpack/api/prompt/expand", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({
text, anchor: project.anchor, postfix: project.postfix,
postfix_enabled: project.postfixEnabled, variables: project.variables,
}),
});
if (res.ok) {
const body = await res.json();
raw[field.slot] = { ...raw[field.slot], [field.input]: body.text };
}
// A non-OK response leaves `raw` holding the literal typed text -- the
// run still queues, just without anchor/shortcuts/variables applied,
// rather than being blocked by a prompt-craft feature failing.
} catch { /* same fallback: the network call itself failed */ }
return raw;
}
const session = wire({ run, page, check, id, queuedFor, finishedFor,
slots: () => slots, values: allValues,
inputs: () => (prompts ? prompts.overrides() : {}),
inputs: queueInputs,
// Read by run.start() at the moment IT queues -- not
// at Generate-click time, which is what onGenerate
// sets ranFor/ranForProject from.
+142
View File
@@ -0,0 +1,142 @@
// Shortcut autocomplete: as the user types in a prompt textarea, suggest
// matching shortcut triggers from the library (shortcuts.js).
//
// Positioning/dismiss/z-index is composer's own `composer.autocomplete.default`
// (composer/elements/popover.js) -- it is the ONE overlay in this kit built to
// outrank a modal (the prompt field lives inside the Constructor window), a
// z-layer rung `popover.anchored` does not claim. What stays custom here is
// the part that primitive does not offer: its `source(fieldValue)` contract
// hands a caller the WHOLE field text, and this needs to match a TRAILING
// WORD-RUN under the caret instead -- so `source` ignores the value it is
// given and reads the caret position off `ta` itself, and `onPick` replaces
// only that span rather than trusting the primitive to write the field.
//
// attach(textarea) -- safe to call once per field; a second call is a no-op.
import { composer } from "../composer/composer.js";
import { fetchAll } from "./shortcuts.js";
const DELIMS = /[,\n;]/;
const MIN_QUERY = 2;
/** Every (trigger, shortcut) pair -- one shortcut with several triggers is
* searchable by each of them. */
function triggerIndex(items) {
const out = [];
for (const s of items) {
if (s.enabled === false) continue;
for (const t of s.triggers || []) {
const trig = String(t || "").trim();
if (trig) out.push({ trigger: trig, sc: s });
}
}
return out;
}
/** Triggers containing `query` (substring match), prefix matches ranked
* first, identical triggers de-duped. */
function matchTriggers(items, query) {
const q = query.toLowerCase();
const scored = [];
for (const e of triggerIndex(items)) {
const tl = e.trigger.toLowerCase();
const at = tl.indexOf(q);
if (at < 0) continue;
scored.push({ ...e, rank: at === 0 ? 0 : 1, pos: at });
}
scored.sort((a, b) => a.rank - b.rank || a.pos - b.pos || a.trigger.length - b.trigger.length);
const seen = new Set();
const uniq = [];
for (const e of scored) {
const k = e.trigger.toLowerCase();
if (seen.has(k)) continue;
seen.add(k);
uniq.push(e);
}
return uniq;
}
/** What to suggest under the caret: the longest trailing word-run of the
* current token that matches any trigger, so a suggestion surfaces mid-prose
* without needing to retype from the last comma. */
function suggestionsFor(ta, items) {
const caret = ta.selectionStart;
if (caret == null || caret !== ta.selectionEnd) return null;
const text = ta.value.slice(0, caret);
let dStart = caret;
while (dStart > 0 && !DELIMS.test(text[dStart - 1])) dStart--;
const starts = [];
let prevWs = true;
for (let i = dStart; i < caret; i++) {
const ws = /\s/.test(text[i]);
if (!ws && prevWs) starts.push(i);
prevWs = ws;
}
for (const start of starts) {
const run = text.slice(start, caret);
if (run.trim().length < MIN_QUERY) continue;
const found = matchTriggers(items, run);
if (found.length) return { span: { start, end: caret }, items: found };
}
return null;
}
export function attach(ta) {
if (!ta || ta._acAttached) return;
ta._acAttached = true;
// The span the CURRENT suggestion list matches, set by source() and read
// by onPick() -- source's own return value carries the item labels, but
// composer's autocomplete primitive has no notion of "which slice of the
// field this item replaces", so that travels beside it instead.
let span = null;
// composer's autocomplete calls `source(fieldValue)` and uses its return
// value directly (not awaited), so it has to be synchronous -- the library
// itself, fetched over HTTP, cannot be. Kept warm here instead: fetched
// once up front and refetched (fire-and-forget) on every focus, so typing
// itself never waits on a network round-trip.
let library = [];
const warm = () => { fetchAll().then((l) => { library = l; }); };
warm();
ta.addEventListener("focus", warm);
const overlay = composer.autocomplete.default({
input: ta,
// minChars gates on the WHOLE field's length in the primitive's own
// contract; that is the wrong length to gate on (a long prompt with a
// two-letter trailing word must still suggest), so it is left at 0 and
// source() below does its own gating on the trailing word instead.
minChars: 0,
source: () => {
if (!library.length) { span = null; return []; }
const res = suggestionsFor(ta, library);
if (!res) { span = null; return []; }
span = res.span;
return res.items.map((e) => ({
label: e.trigger,
hint: [(e.sc.replacements || [])[0], [e.sc.category, e.sc.sub_category].filter(Boolean).join(" · ")]
.filter(Boolean).join(" · "),
trigger: e.trigger,
}));
},
onPick: (item) => {
if (!span) return;
const v = ta.value;
const after = v.slice(span.end);
// Finish with a trailing space so the caret is ready for the next
// trigger and the menu does not re-open on the just-completed one --
// except when a space/delimiter already follows, end-of-text included.
const sep = /^(\s|[,;])/.test(after) ? "" : " ";
ta.value = v.slice(0, span.start) + item.trigger + sep + after;
const caret = span.start + item.trigger.length + sep.length;
ta.setSelectionRange(caret, caret);
ta.dispatchEvent(new Event("input", { bubbles: true }));
ta.focus();
},
});
// composer's own popover z-layer already handles outside-click and Escape
// dismissal; nothing here needs to duplicate that.
ta.addEventListener("blur", () => setTimeout(() => overlay.close(), 120));
}
+67
View File
@@ -33,6 +33,22 @@ export function createInspector({ project, onRename } = {}) {
// that gets replaced on the next scene selection would not survive it.
const negative = composer.region.stack({ gap: "sm", label: "Negative prompt" });
host.set([tabs, rows, negative]);
// Every `.cx-stack` (composer/elements/layout.css) allows itself to shrink
// below its own content's height -- `host` is also a FILL stack (flex:1 1
// auto), so it competes for room with its OWN siblings in layout.js's outer
// Properties column (the Generation section, the Settings collapsible) the
// same way `rows`/`negative` compete with each other inside it. Without
// flex-shrink:0 at every one of these levels, once the Project tab grew
// past a couple of settings rows (Prompt craft, Variables), the column
// squeezed whichever stack ran out of room below its own content instead
// of growing past it -- and the spilled-over content landed on top of
// whatever sits next in the flow, up to and including swallowing that
// sibling's own clicks. flex-grow stays (fill:true still means "take any
// LEFTOVER room" when this tab's content is short); only shrinking below
// content is refused.
host.node.style.flexShrink = "0";
rows.node.style.flexShrink = "0";
negative.node.style.flexShrink = "0";
function sceneRows() {
const scene = project.selected;
@@ -78,6 +94,7 @@ export function createInspector({ project, onRename } = {}) {
function projectRows() {
const open = project.project;
if (!open) return [composer.hint.default({ text: "No project is open." })];
const vars = project.variables;
return [
composer.label.section({ text: "Project" }),
composer.settingsRow.default({
@@ -94,6 +111,56 @@ export function createInspector({ project, onRename } = {}) {
text: "Size and length live in the Constructor: they are what every scene "
+ "is generated at.",
}),
composer.label.section({ text: "Prompt craft" }),
composer.field.default({
label: "Anchor",
hint: "Prepended to every scene's prompt at generation.",
control: composer.textarea.md({
value: project.anchor, rows: 2, placeholder: "cinematic, shared subject/setting",
onCommit: (v) => project.setAnchor(v),
}),
}),
composer.field.default({
label: "Postfix",
hint: "Appended to every scene's prompt, while the toggle below is on.",
control: composer.textarea.md({
value: project.postfix, rows: 2, placeholder: "4k, high detail",
onCommit: (v) => project.setPostfix(v),
}),
}),
composer.toggle.default({
label: "Postfix enabled", checked: project.postfixEnabled,
onChange: (v) => project.setPostfixEnabled(v),
}),
composer.label.section({ text: "Variables" }),
composer.hint.default({ text: "$name in the anchor, a scene, or the postfix is replaced with its value." }),
// `vars` (project.variables) is mutated IN PLACE and handed back to the
// same setVariables call, rather than rebuilt via .map() from this
// closure's own index -- setVariables's changed() triggers boot.js's
// `inspector.draw()`, which tears down and rebuilds every field here
// (including the one NOT being edited) before a second, still-pending
// commit on this same row can fire. A rebuilt-`next`-array version reads
// its OWN stale `vars` snapshot at that point and would silently
// overwrite whatever the first commit just wrote; mutating the shared
// object means the second commit's own edit survives no matter which
// node the redraw already detached.
...vars.map((v, i) => composer.settingsRow.default({
label: `$${v.name || "…"}`,
control: composer.toolbar.default({ label: `$${v.name}`, items: [
composer.input.md({
value: v.name, placeholder: "name",
onCommit: (name) => { v.name = name.replace(/^\$+/, ""); project.setVariables(vars); },
}),
composer.input.md({
value: v.value, placeholder: "value",
onCommit: (value) => { v.value = value; project.setVariables(vars); },
}),
composer.button.sm({ label: "✕", tone: "danger",
onClick: () => { vars.splice(i, 1); project.setVariables(vars); } }),
] }),
})),
composer.button.md({ label: "+ Add variable",
onClick: () => project.setVariables([...vars, { name: "", value: "" }]) }),
];
}
+50
View File
@@ -308,6 +308,56 @@ export function createProject({ onChange, onError, onOpen } = {}) {
changed();
},
/** Text prepended to every scene's prompt at generation (core/prompt_build.py)
* -- a project-level choice, the same as negative. */
get anchor() { return (project && project.anchor) || ""; },
setAnchor(value) {
if (!project) return;
if (project.anchor === value) return;
remember();
project.anchor = value;
scheduleSave();
changed();
},
/** Text appended to every scene's prompt, when postfixEnabled is true. */
get postfix() { return (project && project.postfix) || ""; },
setPostfix(value) {
if (!project) return;
if (project.postfix === value) return;
remember();
project.postfix = value;
scheduleSave();
changed();
},
get postfixEnabled() { return project ? project.postfix_enabled !== false : true; },
setPostfixEnabled(value) {
if (!project) return;
if (project.postfix_enabled === value) return;
remember();
project.postfix_enabled = value;
scheduleSave();
changed();
},
/** $name -> text, substituted into anchor/scene/postfix at generation.
* A list, not a dict: order is what the user set, and the whole list is
* replaced at once -- one row is typed at a time, but no row can be
* addressed by name alone while it is still being renamed. */
get variables() { return (project && project.variables) || []; },
setVariables(list) {
if (!project) return;
remember();
project.variables = list;
scheduleSave();
changed();
},
/** The text of one scene. The prompt box on the main window edits this. */
setText(id, text) { this.setScene(id, "text", text); },
+6
View File
@@ -96,6 +96,12 @@ export async function createPrompts(slots = [], { describe, onChange } = {}) {
// without it, a project that never touched this input has no way to say
// "go back to normal" and the last project's value is left standing.
get default() { return entry.default; },
// The real DOM node, for a caller that needs to attach something to the
// FIELD rather than read/write its value -- autocomplete.js's attach(),
// concretely. Not every control renders one text input (a combo, a
// stepper), so this is undefined rather than a guess for anything but
// the multiline/text controls that have one.
get node() { return entry.control.node; },
setValue(next) {
entry.value = next;
if (entry.control.setValue) entry.control.setValue(next);
+5 -2
View File
@@ -78,8 +78,11 @@ export function createGenerator({ run, transport, check, ready, slots, values, i
// What the boxes on the main window hold, addressed by slot. Sent
// apart from `slots` on purpose: the pipeline window owns the
// structure and these own one value inside it, so neither has to
// carry the other's.
...(inputs ? { inputs: inputs() } : {}),
// carry the other's. Awaited: boot.js's own `inputs` expands the
// prompt box's text (anchor, shortcuts, $variables, postfix) with a
// server round-trip before a run reads it, and a plain synchronous
// return value awaits to itself unchanged.
...(inputs ? { inputs: await inputs() } : {}),
// What every panel in the app holds. Sent even when empty: "nothing
// is set" is an answer, and the server tells the difference between
// that and a client too old to send any.
+177
View File
@@ -0,0 +1,177 @@
// 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_;
}
+128
View File
@@ -0,0 +1,128 @@
// Shortcut autocomplete on a prompt textarea, over a mocked shortcuts.js.
//
// The matching algorithm itself (word-run under the caret, longest trigger
// first) is exercised through real typing/caret positions here rather than
// as isolated unit tests -- attach() is the only export, by design (see its
// own file), so the behaviour worth pinning is what a user actually sees.
import test from "node:test";
import assert from "node:assert/strict";
import { setupDom, teardownDom } from "../../composer/tests/_dom.js";
let attach;
const fetched = [];
test.before(async () => {
setupDom();
await import("../../composer/composer.js");
// shortcuts.js's fetchAll() hits fetch() itself; mocked once for the whole
// file since every test wants the same small library.
globalThis.fetch = async () => {
fetched.push(1);
return {
ok: true,
json: async () => ({ shortcuts: [
{ name: "Fox", triggers: ["golden hour", "golden"], replacements: ["warm light"], enabled: true, category: "", sub_category: "" },
{ name: "Off", triggers: ["off"], replacements: ["x"], enabled: false, category: "", sub_category: "" },
] }),
};
};
({ attach } = await import("../autocomplete.js"));
});
test.after(() => teardownDom());
function field(value = "") {
// A fresh document each time: a popover from the PREVIOUS test's field is
// portal-mounted onto document.body too, and without this a later test's
// querySelector(".cx-autocomplete") can find that leftover menu instead of
// (or as well as) its own, since nothing here ever closes one.
document.body.replaceChildren();
const ta = document.createElement("textarea");
ta.value = value;
document.body.appendChild(ta);
return ta;
}
const typeAt = (ta, value, caret = value.length) => {
ta.value = value;
ta.selectionStart = ta.selectionEnd = caret;
ta.dispatchEvent(new window.Event("input", { bubbles: true }));
};
const wait = () => new Promise((r) => setTimeout(r, 0));
test("attach is a no-op the second time on the same field", () => {
const ta = field();
attach(ta);
attach(ta);
assert.equal(ta._acAttached, true);
});
test("typing a matching trigger's tail shows it as a suggestion", async () => {
const ta = field();
attach(ta);
await wait(); // let the warm-up fetch land
typeAt(ta, "a gold");
await wait();
const menu = document.querySelector(".cx-autocomplete");
assert.ok(menu, "no suggestion menu appeared");
assert.match(menu.textContent, /golden/);
});
test("the longer trigger is offered ahead of the shorter one it contains", async () => {
const ta = field();
attach(ta);
await wait();
typeAt(ta, "golden hou");
await wait();
const labels = [...document.querySelectorAll(".cx-autocomplete .cx-menu-label")].map((n) => n.textContent);
assert.equal(labels[0], "golden hour");
});
test("a disabled shortcut's trigger is never suggested", async () => {
const ta = field();
attach(ta);
await wait();
typeAt(ta, "of");
await wait();
assert.equal(document.querySelector(".cx-autocomplete"), null);
});
test("accepting a suggestion replaces only the matched word run", async () => {
const ta = field();
attach(ta);
await wait();
typeAt(ta, "a gold");
await wait();
// "gold" ties "golden" and "golden hour" on rank/position, and the shorter
// trigger sorts first on that tie (matchTriggers' own final ordering) --
// so the top suggestion is "golden", not the longer one.
const item = document.querySelector(".cx-autocomplete .cx-menu-item");
assert.equal(item.querySelector(".cx-menu-label").textContent, "golden");
item.click();
await wait();
assert.equal(ta.value, "a golden ");
});
test("accepting before an existing delimiter does not add a second space", async () => {
const ta = field();
attach(ta);
await wait();
ta.value = "a gold, cool";
ta.selectionStart = ta.selectionEnd = "a gold".length;
ta.dispatchEvent(new window.Event("input", { bubbles: true }));
await wait();
const item = document.querySelector(".cx-autocomplete .cx-menu-item");
item.click();
await wait();
assert.equal(ta.value, "a golden, cool");
});
test("a query shorter than two characters suggests nothing", async () => {
const ta = field();
attach(ta);
await wait();
typeAt(ta, "a g");
await wait();
assert.equal(document.querySelector(".cx-autocomplete"), null);
});
+112
View File
@@ -0,0 +1,112 @@
// The shortcut library UI, over a mocked /funpack/api/shortcuts.
import test from "node:test";
import assert from "node:assert/strict";
import { setupDom, teardownDom } from "../../composer/tests/_dom.js";
let mount;
test.before(async () => {
setupDom();
await import("../../composer/composer.js");
({ mount } = await import("../shortcuts.js"));
});
test.after(() => teardownDom());
const sc = (name, over = {}) => ({ name, triggers: [name.toLowerCase()],
replacements: [`replacement for ${name}`], enabled: true, category: "", sub_category: "", ...over });
function mockFetch(handlers) {
globalThis.fetch = async (url, opts) => {
const method = (opts && opts.method) || "GET";
const key = `${method} ${new URL(url, "http://localhost/").pathname}`;
const h = handlers[key] || handlers[method + " *"];
if (!h) throw new Error(`unhandled: ${key}`);
return h(opts);
};
}
/** setFooter's actions are handed to whoever hosts this (the modal chrome
* in production) rather than living inside body.node -- a footer that
* did nothing with them would make "+ Add shortcut" and "Close" untestable
* and unclickable, so this stands in for that host: a footer strip actually
* appended beside the body, exactly like packs.js's own open() does. */
function open(opts = {}) {
let closed = false;
const footer = document.createElement("div");
const body = mount({
...opts,
setFooter: (f) => { footer.replaceChildren(...(f.actions || []).map((a) => a.node)); },
close: () => { closed = true; if (opts.close) opts.close(); },
});
const root = document.createElement("div");
root.append(body.node, footer);
document.body.replaceChildren(root);
return { root, get closed() { return closed; } };
}
const find = (root, text) => [...root.querySelectorAll("button")].find((b) => b.textContent.trim() === text);
test("the list loads on mount", async () => {
mockFetch({ "GET /funpack/api/shortcuts": async () => ({ ok: true, json: async () => ({ shortcuts: [sc("Fox")] }) }) });
const { root } = open();
await new Promise((r) => setTimeout(r, 0));
assert.match(root.textContent, /Fox/);
});
test("adding a shortcut with no trigger is refused before the request is sent", async () => {
let posted = false;
mockFetch({
"GET /funpack/api/shortcuts": async () => ({ ok: true, json: async () => ({ shortcuts: [] }) }),
"POST /funpack/api/shortcuts": async () => { posted = true; return { ok: true, json: async () => ({ shortcuts: [] }) }; },
});
const { root } = open();
await new Promise((r) => setTimeout(r, 0));
find(root, "+ Add shortcut").click();
find(root, "Save").click();
assert.equal(posted, false, "an empty trigger list reached the server");
assert.match(root.textContent, /at least one trigger/i);
});
test("delete calls the route for that name and redraws from the response", async () => {
const calls = [];
mockFetch({
"GET /funpack/api/shortcuts": async () => ({ ok: true, json: async () => ({ shortcuts: [sc("Fox"), sc("Owl")] }) }),
"DELETE /funpack/api/shortcuts/Fox": async () => { calls.push("Fox"); return { ok: true, json: async () => ({ shortcuts: [sc("Owl")] }) }; },
});
const { root } = open();
await new Promise((r) => setTimeout(r, 0));
find(root, "Delete").click();
await new Promise((r) => setTimeout(r, 0));
assert.deepEqual(calls, ["Fox"]);
assert.doesNotMatch(root.textContent, /Fox/);
assert.match(root.textContent, /Owl/);
});
test("a server refusal on save is shown rather than silently dropped", async () => {
mockFetch({
"GET /funpack/api/shortcuts": async () => ({ ok: true, json: async () => ({ shortcuts: [] }) }),
"POST /funpack/api/shortcuts": async () => ({ ok: false, status: 400, json: async () => ({ problems: ["a shortcut needs at least one trigger"] }) }),
});
const { root } = open();
await new Promise((r) => setTimeout(r, 0));
find(root, "+ Add shortcut").click();
const triggers = [...root.querySelectorAll("textarea")][0];
triggers.value = "fox";
triggers.dispatchEvent(new window.Event("change", { bubbles: true }));
find(root, "Save").click();
await new Promise((r) => setTimeout(r, 0));
assert.match(root.textContent, /a shortcut needs at least one trigger/);
});
test("the footer's Close action calls the close handler it was given", async () => {
mockFetch({ "GET /funpack/api/shortcuts": async () => ({ ok: true, json: async () => ({ shortcuts: [] }) }) });
const session = open();
await new Promise((r) => setTimeout(r, 0));
find(session.root, "Close").click();
assert.equal(session.closed, true);
});
+5
View File
@@ -129,8 +129,13 @@ function fakeProject(over = {}) {
selectedId: scene.id,
selected: scene,
video: { length: 97 },
anchor: "", postfix: "", postfixEnabled: true, variables: [],
setScene: (id, key, value) => { written.push([id, key, value]); scene[key] = value; },
rename: (name) => written.push(["project", "name", name]),
setAnchor: (v) => written.push(["project", "anchor", v]),
setPostfix: (v) => written.push(["project", "postfix", v]),
setPostfixEnabled: (v) => written.push(["project", "postfix_enabled", v]),
setVariables: (v) => written.push(["project", "variables", v]),
...over,
};
}
+1 -1
View File
@@ -47,7 +47,7 @@ test("picking a section swaps the content in place -- the window stays open, and
// itself inside the body, beside the still-visible nav list.
await expect(page.locator(".cx-modal-title")).toHaveText("Settings");
await expect(page.locator(".cx-modal")).toContainText("Node packs");
await expect(page.locator(".cx-filter-row")).toHaveCount(6); // About + the five, still all there
await expect(page.locator(".cx-filter-row")).toHaveCount(7); // About + the six, still all there
});
test("switching sections tears down the last one -- its own polling stops", async ({ page }) => {
+115
View File
@@ -0,0 +1,115 @@
// Shortcuts (Settings ▸ Shortcuts), autocomplete in the real Constructor
// prompt box, and the Project tab's Anchor/Postfix/Variables fields --
// against a real server, where the layout has a real box model. jsdom
// cannot see either the popover's real position (getBoundingClientRect
// answers zeros there) or a flex-shrink layout bug that only shows up once
// a column's content genuinely outgrows its box.
import { test, expect } from "@playwright/test";
async function app(page) {
await page.goto("/funpack/");
await page.waitForFunction(() => window.FunPack !== undefined);
}
async function openShortcuts(page) {
await page.getByRole("button", { name: "Settings" }).click();
await page.locator(".cx-filter-row", { hasText: "Shortcuts" }).click();
}
async function saveShortcut(page, { name, triggers, replacements }) {
await page.locator(".cx-modal").getByRole("button", { name: "+ Add shortcut" }).click();
const modal = page.locator(".cx-modal");
await modal.locator(".cx-field", { hasText: "Name" }).locator("input").fill(name);
await modal.locator(".cx-field", { hasText: "Triggers" }).locator("textarea").fill(triggers);
await modal.locator(".cx-field", { hasText: "Replacements" }).locator("textarea").fill(replacements);
await modal.getByRole("button", { name: "Save" }).click();
}
test.afterEach(async ({ page }) => {
// The library is a real file on disk (core/shortcuts.py), not per-project
// state -- left behind, it would leak into whichever test runs next.
await page.evaluate(() => fetch("/funpack/api/shortcuts/clear", { method: "POST" })).catch(() => {});
});
test("a saved shortcut round-trips through Settings", async ({ page }) => {
await app(page);
await openShortcuts(page);
await saveShortcut(page, { name: "Fox", triggers: "fox", replacements: "red fox" });
await expect(page.locator(".cx-modal")).toContainText("fox → red fox");
await expect(page.locator(".cx-modal")).toContainText("1 shortcut");
});
test("a shortcut with no trigger is refused, and nothing is sent", async ({ page }) => {
await app(page);
await openShortcuts(page);
await page.locator(".cx-modal").getByRole("button", { name: "+ Add shortcut" }).click();
await page.locator(".cx-modal").getByRole("button", { name: "Save" }).click();
await expect(page.locator(".cx-modal")).toContainText("At least one trigger is required.");
await expect(page.locator(".cx-modal")).toContainText("0 shortcuts");
});
test("typing a saved trigger in the real prompt box suggests it, above the modal", async ({ page }) => {
await app(page);
await openShortcuts(page);
await saveShortcut(page, { name: "Fox", triggers: "fox", replacements: "red fox" });
// Two "Close"s on this window (the header ✕ and the footer's own button);
// the footer one is the primary-toned action, the header's is an icon button.
await page.locator(".cx-modal button.cx-btn-primary", { hasText: "Close" }).click();
await expect(page.locator(".cx-modal")).toHaveCount(0);
await page.locator(".cx-panel-head").getByRole("button", { name: "Constructor" }).click();
const box = page.locator(".cx-modal textarea").first();
await box.fill("a fo");
const menu = page.locator(".cx-autocomplete");
await expect(menu).toContainText("fox");
await expect(menu).toContainText("red fox");
await menu.locator(".cx-menu-item").first().click();
await expect(box).toHaveValue("a fox ");
});
test("the Project tab's Anchor, Postfix and Variables render without overlapping the negative prompt", async ({ page }) => {
await app(page);
// A regression test for a real layout bug: `host`'s children (rows,
// negative) could flex-shrink below their own content once the Project
// tab grew past a couple of settings rows, spilling their text into each
// other instead of the panel's own overflow:auto ever getting to scroll.
await page.getByRole("tab", { name: "Project" }).click();
await page.locator(".cx-field", { hasText: "Anchor" }).locator("textarea").fill("cinematic");
await page.locator(".cx-field", { hasText: "Postfix" }).locator("textarea").fill("4k");
await page.getByRole("button", { name: "+ Add variable" }).click();
const varsLabel = page.locator(".cx-eyebrow", { hasText: "Variables" });
const negLabel = page.locator("label.cx-label", { hasText: "Negative prompt" });
await expect(varsLabel).toBeVisible();
await expect(negLabel).toBeVisible();
const [varsBox, negBox] = await Promise.all([varsLabel.boundingBox(), negLabel.boundingBox()]);
// Not just "different y" -- the whole vertical span of one must sit above
// the other's, the same assertion that would have caught the original bug.
expect(varsBox.y + varsBox.height).toBeLessThanOrEqual(negBox.y + 1);
});
test("anchor, postfix and a variable survive a reload", async ({ page }) => {
await app(page);
await page.getByRole("tab", { name: "Project" }).click();
await page.locator(".cx-field", { hasText: "Anchor" }).locator("textarea").fill("cinematic");
await page.locator(".cx-field", { hasText: "Anchor" }).locator("textarea").blur();
// A prior test in this file may have left rows of its own (real, persisted
// project state) -- the row THIS click added is always the last one.
await page.getByRole("button", { name: "+ Add variable" }).click();
await page.locator('input[placeholder="name"]').last().fill("subject");
await page.locator('input[placeholder="name"]').last().blur();
await page.locator('input[placeholder="value"]').last().fill("a fox");
await page.locator('input[placeholder="value"]').last().blur();
await page.reload();
await page.waitForFunction(() => window.FunPack !== undefined);
await page.getByRole("tab", { name: "Project" }).click();
await expect(page.locator(".cx-field", { hasText: "Anchor" }).locator("textarea")).toHaveValue("cinematic");
await expect(page.locator("body")).toContainText("$subject");
});
+5
View File
@@ -24,3 +24,8 @@ PROJECTS_DIR = ROOT / "projects"
# the same reason PROJECTS_DIR is: clearing ComfyUI's output tree must not take
# something the user deliberately imported with it.
MEDIA_DIR = ROOT / "media"
# The shortcut library: trigger -> replacement text. Global, not per-project --
# a shortcut is reused across every project, not redefined in each one, so it
# lives beside PROJECTS_DIR rather than inside any one project file.
SHORTCUTS_FILE = ROOT / "shortcuts.json"
+27
View File
@@ -124,6 +124,26 @@ def _clean_video(raw) -> dict:
return clean
def _clean_variables(raw) -> list:
"""[{"name": str, "value": str}, ...] -- anything else in a slot is
dropped rather than guessed at, the same rule every other field reads
back out of a project file follows. A name that is empty after stripping
the leading `$` is not a variable anyone could ever reference, so it is
dropped rather than kept as a row nothing can ever match."""
if not isinstance(raw, list):
return []
out = []
for item in raw:
if not isinstance(item, dict):
continue
name = str(item.get("name") or "").lstrip("$").strip()
if not name:
continue
value = item.get("value")
out.append({"name": name[:MAX_NAME], "value": value if isinstance(value, str) else ""})
return out
@dataclass
class Project:
id: str = field(default_factory=_new_id)
@@ -156,6 +176,12 @@ class Project:
#: any better than a fourth field would, so the "simpler" shape turns out
#: not to be. Three named fields stay more readable than an abstraction
#: that has to special-case one of its own four entries anyway.
#:
#: $name -> text, substituted into anchor/scene/postfix at generation
#: (core/prompt_build.py). A LIST, not a dict: order is what a person set,
#: and preserving it is what makes "the row I just added" stay at the
#: bottom instead of jumping around alphabetically on the next save.
variables: list = field(default_factory=list)
updated_at: float = 0.0
@staticmethod
@@ -179,6 +205,7 @@ class Project:
# from before this field existed) must read as "on", the same as
# v4's own default, not as "off" because nothing was there to say.
postfix_enabled=postfix_enabled if isinstance(postfix_enabled, bool) else True,
variables=_clean_variables(d.get("variables")),
updated_at=float(d.get("updated_at") or 0.0),
)
+27
View File
@@ -0,0 +1,27 @@
"""The text actually sent as a scene's prompt at generation time.
`Scene.text` (core/projects.py) stays the user's literal typed words -- this
module never writes back to it. It only ever runs at the moment a prompt
value is about to leave the app for a real run, so editing a scene and
looking at what it produced always shows what was actually typed, not an
expanded copy nothing can trace back.
Order matches v4's: anchor and postfix are joined to the scene text FIRST,
then shortcuts expand across the whole combined string (a shortcut trigger
sitting right at the anchor/scene boundary still matches), then `$variables`
resolve last (so a variable's value can itself contain a shortcut trigger or
another variable and still expand).
"""
from __future__ import annotations
from . import shortcuts as shortcuts_mod
def build(scene_text: str, *, anchor: str = "", postfix: str = "",
postfix_enabled: bool = True, variables=None, shortcuts=None, seed: int = 0) -> str:
anchor = (anchor or "").strip()
postfix = (postfix or "").strip() if postfix_enabled else ""
combined = " ".join(p for p in (anchor, str(scene_text or "").strip(), postfix) if p)
expanded = shortcuts_mod.expand(combined, shortcuts=shortcuts, seed=seed)
return shortcuts_mod.resolve_variables(expanded, variables)
+65
View File
@@ -10,6 +10,8 @@ import json
from . import (backend_log, config, graph as graph_mod, log, media, nodes_manager, probe as probe_mod,
projects,
prompt_build,
shortcuts as shortcuts_mod,
sysinfo,
temp_files,
update as update_mod,
@@ -729,6 +731,69 @@ def register(routes, prefix=None):
return web.json_response({"problems": ["no such media"]}, status=404)
return web.json_response({"deleted": True})
# ── shortcuts ─────────────────────────────────────────────────────────
# A trigger -> replacement text library, global across every project --
# see core/shortcuts.py. CRUD here; the expansion itself is stateless
# (below), not tied to saving or loading a shortcut.
@routes.get(P + "/api/shortcuts")
async def _shortcuts_list(_req):
return web.json_response({"shortcuts": [s.to_dict() for s in shortcuts_mod.listing()]})
@routes.post(P + "/api/shortcuts")
async def _shortcuts_save(req):
try:
body = await req.json()
except Exception: # noqa: BLE001
return web.json_response({"problems": ["that is not JSON"]}, status=400)
if not isinstance(body, dict):
return web.json_response(
{"problems": [f"a shortcut is an object, not a {type(body).__name__}"]}, status=400)
original_name = body.get("original_name")
try:
items = shortcuts_mod.save(body, original_name if isinstance(original_name, str) else None)
except ValueError as exc:
return web.json_response({"problems": [str(exc)]}, status=400)
return web.json_response({"shortcuts": [s.to_dict() for s in items]})
@routes.delete(P + "/api/shortcuts/{name}")
async def _shortcuts_delete(req):
items = shortcuts_mod.delete(req.match_info["name"])
return web.json_response({"shortcuts": [s.to_dict() for s in items]})
@routes.post(P + "/api/shortcuts/clear")
async def _shortcuts_clear(_req):
shortcuts_mod.clear()
return web.json_response({"shortcuts": []})
@routes.post(P + "/api/prompt/expand")
async def _prompt_expand(req):
"""What a scene's typed text becomes at generation: anchor + shortcuts
+ $variables + postfix, applied here rather than client-side so the
library and the algorithm have exactly one implementation. Stateless
-- everything it needs travels in the body -- for the same reason
/api/pipeline is: this runs for a preview as freely as for a real run,
and neither should require a project to exist on the server's disk."""
try:
body = await req.json()
except Exception: # noqa: BLE001
return web.json_response({"problems": ["that is not JSON"]}, status=400)
if not isinstance(body, dict):
return web.json_response(
{"problems": [f"a request is an object, not a {type(body).__name__}"]}, status=400)
variables = body.get("variables")
seed = body.get("seed")
expanded = await asyncio.to_thread(
prompt_build.build,
body.get("text") or "",
anchor=body.get("anchor") or "",
postfix=body.get("postfix") or "",
postfix_enabled=body.get("postfix_enabled") is not False,
variables=variables if isinstance(variables, list) else None,
seed=seed if isinstance(seed, int) and not isinstance(seed, bool) else 0,
)
return web.json_response({"text": expanded})
@routes.get(P + "/api/log/funpack")
async def _log(req):
level = req.query.get("level") or None
+248
View File
@@ -0,0 +1,248 @@
"""The shortcut library: trigger -> replacement text, expanded into a prompt at
generation time. Global, not per-project -- a shortcut ("cyberpunk" -> a long
style phrase) is reused across every project, not redefined in each one.
Storage is one JSON file holding the whole list, not one-per-item: the picker
browses all of it and export/import round-trips all of it, so there is no
case where only part of the library is ever read or written.
The expansion algorithm (trigger regex, longest-first, seeded multi-choice) and
`$variable` resolution (recursive, cycle-safe, undefined -> literal) are a
close port of v4's `templates.py` (`apply_prompt_shortcuts`/`resolve_variables`)
-- proven logic, not reinvented. Not ported: the shortcut revolver's
no-repeat cycling (a stateful commit-vs-preview split) and managed category
lists (empty categories kept around for a picker to offer) -- both are real
v4 features, cut here to keep this a first pass rather than a rewrite of
the whole prompt-craft surface at once.
"""
from __future__ import annotations
import hashlib
import json
import random
import re
import threading
from dataclasses import asdict, dataclass, field
from . import config
_LOCK = threading.Lock()
MAX_NAME = 120
MAX_ITEM = 4096
def _clean_list(raw, *, keep_empty=False) -> list[str]:
"""One-per-line strings: trimmed, internal whitespace collapsed, exact
duplicates dropped. Triggers and replacements are both lists of whole
phrases -- comma-splitting a replacement tears one prose phrase into
bogus variants, so callers never do that upstream of here.
`keep_empty` is for replacements only: an empty string there is a
deliberate "remove this phrase" entry (`expand()`'s `_cleanup_removed_
phrases`), not a blank line to discard -- a trigger has no such meaning
and always drops empties."""
if isinstance(raw, str):
raw = raw.split("\n")
if not isinstance(raw, list):
return []
seen, out = set(), []
for item in raw:
text = re.sub(r"\s+", " ", str(item if item is not None else "")).strip()[:MAX_ITEM]
if (text or keep_empty) and text not in seen:
seen.add(text)
out.append(text)
return out
@dataclass
class Shortcut:
name: str = ""
triggers: list[str] = field(default_factory=list)
replacements: list[str] = field(default_factory=list)
enabled: bool = True
#: Free-text grouping for the picker UI. Plain strings, not a managed
#: list -- a category exists the moment a shortcut is saved under it, and
#: goes away the moment nothing uses it any more, no separate CRUD needed.
category: str = ""
sub_category: str = ""
@staticmethod
def from_dict(d) -> "Shortcut":
d = d if isinstance(d, dict) else {}
triggers = _clean_list(d.get("triggers"))
name = str(d.get("name") or "").strip()[:MAX_NAME] or (triggers[0] if triggers else "")
return Shortcut(
name=name,
triggers=triggers,
replacements=_clean_list(d.get("replacements"), keep_empty=True),
enabled=bool(d.get("enabled", True)),
category=str(d.get("category") or "").strip()[:MAX_NAME],
sub_category=str(d.get("sub_category") or "").strip()[:MAX_NAME],
)
def to_dict(self) -> dict:
return asdict(self)
def _path():
config.ROOT.mkdir(parents=True, exist_ok=True)
return config.SHORTCUTS_FILE
def listing() -> list[Shortcut]:
p = config.SHORTCUTS_FILE
if not p.exists():
return []
try:
data = json.loads(p.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError, UnicodeDecodeError):
return [] # an unreadable library is not a reason to have no list
items = data if isinstance(data, list) else []
return [Shortcut.from_dict(it) for it in items]
def _save_all(items: list[Shortcut]) -> None:
path = _path()
tmp = path.with_suffix(".tmp")
tmp.write_text(json.dumps([it.to_dict() for it in items], indent=2), encoding="utf-8")
tmp.replace(path) # a concurrent read never sees a half-written library
def save(payload: dict, original_name: str | None = None) -> list[Shortcut]:
"""Insert, or update in place when `original_name` names an existing
entry -- a rename keeps its position; without this a save-under-a-new-
name would move the entry to the end, reordering the picker under the
user's feet for no reason they asked for. Raises ValueError for a
shortcut with no triggers: nothing can ever match it, so saving one
would be silent data loss dressed up as success."""
item = Shortcut.from_dict(payload)
if not item.triggers:
raise ValueError("a shortcut needs at least one trigger")
with _LOCK:
items = listing()
idx = next((i for i, it in enumerate(items)
if it.name == (original_name or item.name)), None)
if idx is None:
items.append(item)
else:
items[idx] = item
_save_all(items)
return items
def delete(name: str) -> list[Shortcut]:
with _LOCK:
items = [it for it in listing() if it.name != name]
_save_all(items)
return items
def clear() -> list[Shortcut]:
with _LOCK:
_save_all([])
return []
# --- shortcut expansion ------------------------------------------------------
def _trigger_pattern(trigger: str) -> str:
words = [re.escape(w) for w in re.split(r"\s+", trigger.strip()) if w]
if not words:
return ""
body = r"\s+".join(words)
return rf"(?<![\w'’-])({body})(?![\w'’-])"
def _cleanup_removed_phrases(text: str) -> str:
"""Fix punctuation/spacing left behind when a replacement is "" (a
deliberate 'remove this phrase' entry)."""
text = re.sub(r"[ \t]+([,;])", r"\1", text)
text = re.sub(r"([,;])\s*([,;])+", r"\1", text)
text = re.sub(r"^[\s,;]+", "", text)
text = re.sub(r"[\s,;]+$", "", text)
return re.sub(r"[ \t]{2,}", " ", text)
def expand(text: str, shortcuts: list[Shortcut] | None = None, seed: int = 0) -> str:
"""Every enabled shortcut's trigger, replaced with one of its
replacements (random when there is more than one). Deterministic: the
pick is seeded from `seed`, or from the text itself when seed is 0 --
so calling this twice on the same text and seed always agrees, without a
database round-trip to remember what was drawn last time.
Longest trigger wins first ("golden hour" is tried before "golden"), so
a short trigger can never shadow a longer one that contains it.
"""
original = str(text or "")
if not original:
return original
items = listing() if shortcuts is None else shortcuts
candidates = []
for sc in items:
if not sc.enabled or not sc.replacements:
continue
for trigger in sc.triggers:
pattern = _trigger_pattern(trigger)
if pattern:
candidates.append((trigger, pattern, sc.replacements))
if not candidates:
return original
candidates.sort(key=lambda c: len(c[0]), reverse=True)
combined = "|".join(f"(?P<t{i}>{p})" for i, (_, p, _) in enumerate(candidates))
rng_seed = int(seed or 0) or int(hashlib.md5(original.encode("utf-8")).hexdigest()[:12], 16)
rng = random.Random(rng_seed)
removed = False
def replace(m):
nonlocal removed
for i, (_, _, replacements) in enumerate(candidates):
if m.group(f"t{i}") is None:
continue
choice = rng.choice(replacements)
if not choice:
removed = True
return choice
return m.group(0) # unreachable: `combined` only matches a known group
expanded = re.sub(combined, replace, original, flags=re.IGNORECASE | re.UNICODE)
return _cleanup_removed_phrases(expanded) if removed else expanded
# --- $variables --------------------------------------------------------------
_VARIABLE_TOKEN = re.compile(r"\$([A-Za-z_][A-Za-z0-9_]*)")
_VARIABLE_MAX_DEPTH = 64
def resolve_variables(text: str, variables) -> str:
"""Substitute `$name` tokens from a project's variables ([{"name",
"value"}, ...]). Recursive -- a variable's value may itself reference
another -- cycle-safe (a name reappearing in its own expansion chain is
left literal rather than recursed into), and undefined names are left as
literal `$name`: an unset variable is a typo to notice and fix, not a
blank the prompt swallows silently.
"""
var_map: dict[str, str] = {}
for v in (variables or []):
name = str((v or {}).get("name") or "").lstrip("$").strip()
if name:
var_map[name] = str((v or {}).get("value") or "")
if not var_map:
return str(text or "")
def _expand(s, stack, depth):
if depth > _VARIABLE_MAX_DEPTH:
return s
def _repl(m):
name = m.group(1)
if name not in var_map or name in stack:
return m.group(0)
return _expand(var_map[name], stack | {name}, depth + 1)
return _VARIABLE_TOKEN.sub(_repl, s)
return _expand(str(text or ""), frozenset(), 0)
+40
View File
@@ -436,3 +436,43 @@ def test_postfix_enabled_read_back_is_never_trusted():
# something with a value core does not recognise as that thing.
assert projects.Project.from_dict({"postfix_enabled": junk}).postfix_enabled is True, junk
assert projects.Project.from_dict({"postfix_enabled": False}).postfix_enabled is False
def test_variables_survive_a_round_trip(tmp_path, monkeypatch):
monkeypatch.setattr(config, "PROJECTS_DIR", tmp_path)
p = projects.create("v")
p.variables = [{"name": "subject", "value": "a fox"}]
projects.save(p)
back = projects.get(p.id)
assert back.variables == [{"name": "subject", "value": "a fox"}]
def test_a_fresh_project_has_no_variables():
assert projects.Project().variables == []
def test_a_variable_with_no_name_is_dropped():
p = projects.Project.from_dict({"variables": [
{"name": "", "value": "x"}, {"name": " ", "value": "y"}, {"name": "ok", "value": "z"},
]})
assert p.variables == [{"name": "ok", "value": "z"}]
def test_a_leading_dollar_in_a_saved_variable_name_is_stripped():
p = projects.Project.from_dict({"variables": [{"name": "$subject", "value": "fox"}]})
assert p.variables == [{"name": "subject", "value": "fox"}]
def test_a_variables_value_that_is_not_a_string_reads_back_empty():
p = projects.Project.from_dict({"variables": [{"name": "a", "value": 5}, {"name": "b"}]})
assert p.variables == [{"name": "a", "value": ""}, {"name": "b", "value": ""}]
def test_variables_that_are_not_a_list_read_back_empty():
for junk in ("nope", {"a": "b"}, 5, None):
assert projects.Project.from_dict({"variables": junk}).variables == []
def test_entries_in_variables_that_are_not_objects_are_dropped():
p = projects.Project.from_dict({"variables": ["nope", 5, {"name": "ok", "value": "1"}]})
assert p.variables == [{"name": "ok", "value": "1"}]
+55
View File
@@ -0,0 +1,55 @@
"""build(): anchor + scene text + postfix, shortcuts expanded across the
whole combined string, then $variables resolved last. Order is what makes a
shortcut trigger spanning the anchor/scene boundary still match, and a
variable's value able to contain another variable or a shortcut trigger.
"""
from core import prompt_build, shortcuts
def _sc(triggers, replacements):
return shortcuts.Shortcut(name=triggers[0], triggers=triggers, replacements=replacements)
def test_anchor_and_postfix_wrap_the_scene_text():
out = prompt_build.build("a fox runs", anchor="cinematic", postfix="4k")
assert out == "cinematic a fox runs 4k"
def test_postfix_is_dropped_when_disabled():
out = prompt_build.build("a fox runs", anchor="cinematic", postfix="4k", postfix_enabled=False)
assert out == "cinematic a fox runs"
def test_empty_anchor_and_postfix_leave_only_the_scene_text():
assert prompt_build.build("a fox runs") == "a fox runs"
def test_a_shortcut_expands_inside_the_combined_string():
out = prompt_build.build("a fox runs", anchor="", postfix="",
shortcuts=[_sc(["fox"], ["red fox"])])
assert out == "a red fox runs"
def test_a_shortcut_can_span_the_anchor_and_scene_boundary():
out = prompt_build.build("hour scene", anchor="golden",
shortcuts=[_sc(["golden hour"], ["warm light"])])
assert out == "warm light scene"
def test_variables_resolve_after_shortcuts_expand():
out = prompt_build.build("a $animal runs", shortcuts=[_sc(["runs"], ["runs fast"])],
variables=[{"name": "animal", "value": "fox"}])
assert out == "a fox runs fast"
def test_a_variables_own_text_is_not_rescanned_for_shortcut_triggers():
# variables resolve AFTER shortcut expansion, so a $var's own text is not
# re-scanned for shortcut triggers -- only the scene/anchor/postfix text is.
out = prompt_build.build("$style", shortcuts=[_sc(["fox"], ["red fox"])],
variables=[{"name": "style", "value": "a fox runs"}])
assert out == "a fox runs"
def test_whitespace_only_scene_text_with_no_anchor_or_postfix_is_empty():
assert prompt_build.build(" ") == ""
+118
View File
@@ -0,0 +1,118 @@
"""/api/shortcuts and /api/prompt/expand, over real HTTP -- proves the routes
actually reach core/shortcuts.py and core/prompt_build.py, not just that
those modules are correct in isolation (test_shortcuts.py, test_prompt_build.py).
"""
import json
import socket
import threading
import pytest
pytest.importorskip("aiohttp")
from core import config, routes # noqa: E402
@pytest.fixture
def server(tmp_path, monkeypatch):
monkeypatch.setattr(config, "SHORTCUTS_FILE", tmp_path / "shortcuts.json")
from aiohttp import web as aioweb
app = aioweb.Application()
table = aioweb.RouteTableDef()
routes.register(table, prefix="/funpack")
app.add_routes(table)
holder = {"ready": threading.Event()}
def run():
import asyncio
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
runner = aioweb.AppRunner(app)
loop.run_until_complete(runner.setup())
site = aioweb.TCPSite(runner, "127.0.0.1", 0)
loop.run_until_complete(site.start())
holder["port"] = site._server.sockets[0].getsockname()[1]
holder["loop"] = loop
holder["ready"].set()
loop.run_forever()
thread = threading.Thread(target=run, daemon=True)
thread.start()
assert holder["ready"].wait(20), "the test server did not start"
yield holder["port"]
holder["loop"].call_soon_threadsafe(holder["loop"].stop)
def _request(port, method, path, body=None):
payload = b"" if body is None else json.dumps(body).encode()
head = (f"{method} {path} HTTP/1.1\r\nHost: localhost\r\n"
f"Content-Type: application/json\r\n"
f"Content-Length: {len(payload)}\r\nConnection: close\r\n\r\n").encode()
with socket.create_connection(("127.0.0.1", port), timeout=30) as sock:
sock.sendall(head + payload)
chunks = []
while True:
got = sock.recv(65536)
if not got:
break
chunks.append(got)
header, _, rest = b"".join(chunks).partition(b"\r\n\r\n")
status = int(header.split()[1])
return status, json.loads(rest.decode() or "{}")
def test_save_list_delete(server):
status, resp = _request(server, "POST", "/funpack/api/shortcuts",
{"name": "Fox", "triggers": ["fox"], "replacements": ["red fox"]})
assert status == 200, resp
assert [s["name"] for s in resp["shortcuts"]] == ["Fox"]
status, listed = _request(server, "GET", "/funpack/api/shortcuts")
assert status == 200 and [s["name"] for s in listed["shortcuts"]] == ["Fox"]
status, deleted = _request(server, "DELETE", "/funpack/api/shortcuts/Fox")
assert status == 200 and deleted["shortcuts"] == []
def test_saving_a_shortcut_with_no_triggers_is_a_400(server):
status, resp = _request(server, "POST", "/funpack/api/shortcuts",
{"name": "Nothing", "triggers": [], "replacements": ["x"]})
assert status == 400
assert resp["problems"]
def test_clear_empties_the_library(server):
_request(server, "POST", "/funpack/api/shortcuts",
{"name": "Fox", "triggers": ["fox"], "replacements": ["red fox"]})
status, resp = _request(server, "POST", "/funpack/api/shortcuts/clear")
assert status == 200 and resp["shortcuts"] == []
def test_prompt_expand_combines_anchor_scene_and_postfix(server):
status, resp = _request(server, "POST", "/funpack/api/prompt/expand",
{"text": "a fox runs", "anchor": "cinematic", "postfix": "4k"})
assert status == 200
assert resp["text"] == "cinematic a fox runs 4k"
def test_prompt_expand_uses_the_saved_shortcut_library(server):
_request(server, "POST", "/funpack/api/shortcuts",
{"name": "Fox", "triggers": ["fox"], "replacements": ["red fox"]})
status, resp = _request(server, "POST", "/funpack/api/prompt/expand", {"text": "a fox runs"})
assert status == 200 and resp["text"] == "a red fox runs"
def test_prompt_expand_with_variables_and_postfix_disabled(server):
status, resp = _request(server, "POST", "/funpack/api/prompt/expand", {
"text": "a $animal runs", "anchor": "", "postfix": "ignored",
"postfix_enabled": False, "variables": [{"name": "animal", "value": "fox"}],
})
assert status == 200 and resp["text"] == "a fox runs"
def test_prompt_expand_on_a_non_object_body_is_a_400(server):
status, resp = _request(server, "POST", "/funpack/api/prompt/expand", ["nope"])
assert status == 400 and resp["problems"]
+171
View File
@@ -0,0 +1,171 @@
"""The shortcut library and its expansion algorithm -- a close port of v4's
`templates.py` (`apply_prompt_shortcuts`/`resolve_variables`), pinned against
the same behaviour: longest-trigger-first, seeded multi-choice, cycle-safe
variables, undefined variables left literal.
"""
import pytest
from core import config, shortcuts
@pytest.fixture
def store(tmp_path, monkeypatch):
monkeypatch.setattr(config, "SHORTCUTS_FILE", tmp_path / "shortcuts.json")
return tmp_path
# ── the library (CRUD) ───────────────────────────────────────────────────
def test_a_fresh_install_has_no_shortcuts(store):
assert shortcuts.listing() == []
def test_a_saved_shortcut_round_trips(store):
shortcuts.save({"name": "Golden hour", "triggers": ["golden hour"],
"replacements": ["warm golden-hour lighting"]})
items = shortcuts.listing()
assert len(items) == 1
assert items[0].name == "Golden hour"
assert items[0].triggers == ["golden hour"]
def test_a_shortcut_with_no_triggers_is_refused(store):
with pytest.raises(ValueError):
shortcuts.save({"name": "Nothing", "triggers": [], "replacements": ["x"]})
assert shortcuts.listing() == []
def test_saving_under_original_name_updates_in_place(store):
shortcuts.save({"name": "A", "triggers": ["a"], "replacements": ["1"]})
shortcuts.save({"name": "B", "triggers": ["b"], "replacements": ["2"]})
shortcuts.save({"name": "A renamed", "triggers": ["a"], "replacements": ["1!"]},
original_name="A")
items = shortcuts.listing()
assert [i.name for i in items] == ["A renamed", "B"] # position kept, not moved to the end
def test_saving_under_a_new_name_appends(store):
shortcuts.save({"name": "A", "triggers": ["a"], "replacements": ["1"]})
shortcuts.save({"name": "B", "triggers": ["b"], "replacements": ["2"]})
assert [i.name for i in shortcuts.listing()] == ["A", "B"]
def test_delete_removes_by_name(store):
shortcuts.save({"name": "A", "triggers": ["a"], "replacements": ["1"]})
shortcuts.save({"name": "B", "triggers": ["b"], "replacements": ["2"]})
shortcuts.delete("A")
assert [i.name for i in shortcuts.listing()] == ["B"]
def test_clear_empties_the_library(store):
shortcuts.save({"name": "A", "triggers": ["a"], "replacements": ["1"]})
shortcuts.clear()
assert shortcuts.listing() == []
def test_replacement_and_trigger_lists_are_cleaned(store):
shortcuts.save({"name": "A", "triggers": [" a ", "a", "b"],
"replacements": [" x ", "x", ""]})
item = shortcuts.listing()[0]
assert item.triggers == ["a", "b"] # trimmed, de-duped
assert item.replacements == ["x", ""] # "" kept -- a deliberate remove entry
# ── expansion ─────────────────────────────────────────────────────────────
def _sc(name, triggers, replacements, enabled=True):
return shortcuts.Shortcut(name=name, triggers=triggers, replacements=replacements,
enabled=enabled)
def test_a_trigger_expands_to_its_replacement():
out = shortcuts.expand("a man at golden hour", [_sc("gh", ["golden hour"], ["warm light"])])
assert out == "a man at warm light"
def test_matching_is_case_insensitive():
out = shortcuts.expand("GOLDEN HOUR scene", [_sc("gh", ["golden hour"], ["warm light"])])
assert out == "warm light scene"
def test_a_disabled_shortcut_never_matches():
out = shortcuts.expand("golden hour", [_sc("gh", ["golden hour"], ["warm light"], enabled=False)])
assert out == "golden hour"
def test_longest_trigger_wins_over_a_shorter_one_it_contains():
scs = [_sc("short", ["golden"], ["SHORT"]), _sc("long", ["golden hour"], ["LONG"])]
assert shortcuts.expand("golden hour", scs) == "LONG"
def test_a_trigger_inside_a_longer_word_does_not_match():
out = shortcuts.expand("goldenrod field", [_sc("gh", ["golden"], ["warm"])])
assert out == "goldenrod field" # word-boundary guard, not a substring replace
def test_expansion_is_deterministic_for_the_same_text():
sc = _sc("many", ["cat"], ["tabby", "siamese", "calico", "tuxedo"])
a = shortcuts.expand("a cat sits", [sc])
b = shortcuts.expand("a cat sits", [sc])
assert a == b # same text -> same seed -> same random.Random draw
def test_expansion_can_differ_for_different_seeds():
sc = _sc("many", ["cat"], ["tabby", "siamese", "calico", "tuxedo", "ragdoll", "sphynx"])
picks = {shortcuts.expand("a cat", [sc], seed=s) for s in range(1, 8)}
assert len(picks) > 1 # different explicit seeds draw different replacements
def test_an_empty_replacement_removes_the_phrase_and_its_stray_comma():
out = shortcuts.expand("tall, blurry, man", [_sc("rm", ["blurry"], [""])])
assert out == "tall, man"
def test_no_shortcuts_leaves_text_untouched():
assert shortcuts.expand("plain text", []) == "plain text"
def test_empty_text_returns_empty():
assert shortcuts.expand("", [_sc("a", ["a"], ["b"])]) == ""
def test_a_shortcut_with_no_replacements_never_matches():
assert shortcuts.expand("trigger word", [_sc("x", ["trigger"], [])]) == "trigger word"
# ── $variables ────────────────────────────────────────────────────────────
def test_a_declared_variable_is_substituted():
out = shortcuts.resolve_variables("a $subject walks", [{"name": "subject", "value": "fox"}])
assert out == "a fox walks"
def test_an_undefined_variable_is_left_literal():
out = shortcuts.resolve_variables("a $ghost walks", [])
assert out == "a $ghost walks"
def test_a_variable_may_reference_another():
out = shortcuts.resolve_variables("$a", [{"name": "a", "value": "$b"}, {"name": "b", "value": "done"}])
assert out == "done"
def test_a_self_referencing_variable_is_left_literal_not_infinitely_expanded():
out = shortcuts.resolve_variables("$a", [{"name": "a", "value": "x $a y"}])
assert out == "x $a y"
def test_a_two_variable_cycle_is_left_literal():
out = shortcuts.resolve_variables(
"$a", [{"name": "a", "value": "$b"}, {"name": "b", "value": "$a"}])
assert out == "$a" # the cycle is detected, not expanded forever
def test_a_leading_dollar_in_the_declared_name_is_ignored():
out = shortcuts.resolve_variables("$subject", [{"name": "$subject", "value": "fox"}])
assert out == "fox"
def test_no_variables_declared_returns_text_unchanged():
assert shortcuts.resolve_variables("plain $x text", []) == "plain $x text"