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:
+43
-1
@@ -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.
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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: "" }]) }),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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); },
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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_;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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"
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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"}]
|
||||
|
||||
@@ -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(" ") == ""
|
||||
@@ -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"]
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user