diff --git a/app/boot.js b/app/boot.js index 917cd50f..353cadbb 100644 --- a/app/boot.js +++ b/app/boot.js @@ -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. diff --git a/app/shell/autocomplete.js b/app/shell/autocomplete.js new file mode 100644 index 00000000..75a75466 --- /dev/null +++ b/app/shell/autocomplete.js @@ -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)); +} diff --git a/app/shell/inspector.js b/app/shell/inspector.js index d68f2b05..711e4698 100644 --- a/app/shell/inspector.js +++ b/app/shell/inspector.js @@ -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: "" }]) }), ]; } diff --git a/app/shell/projects.js b/app/shell/projects.js index f92022de..dd2a9cfb 100644 --- a/app/shell/projects.js +++ b/app/shell/projects.js @@ -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); }, diff --git a/app/shell/prompt.js b/app/shell/prompt.js index 506d0590..eca36a43 100644 --- a/app/shell/prompt.js +++ b/app/shell/prompt.js @@ -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); diff --git a/app/shell/session.js b/app/shell/session.js index e9a4e850..1b1cc0e0 100644 --- a/app/shell/session.js +++ b/app/shell/session.js @@ -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. diff --git a/app/shell/shortcuts.js b/app/shell/shortcuts.js new file mode 100644 index 00000000..787ac682 --- /dev/null +++ b/app/shell/shortcuts.js @@ -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_; +} diff --git a/app/shell/tests/autocomplete.test.js b/app/shell/tests/autocomplete.test.js new file mode 100644 index 00000000..e2acf3b2 --- /dev/null +++ b/app/shell/tests/autocomplete.test.js @@ -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); +}); diff --git a/app/shell/tests/shortcuts.test.js b/app/shell/tests/shortcuts.test.js new file mode 100644 index 00000000..7006cf5b --- /dev/null +++ b/app/shell/tests/shortcuts.test.js @@ -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); +}); diff --git a/app/shell/tests/ui.test.js b/app/shell/tests/ui.test.js index dd354bba..948b3c97 100644 --- a/app/shell/tests/ui.test.js +++ b/app/shell/tests/ui.test.js @@ -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, }; } diff --git a/app/tests-layout/settings.spec.js b/app/tests-layout/settings.spec.js index cf109c4e..bad68527 100644 --- a/app/tests-layout/settings.spec.js +++ b/app/tests-layout/settings.spec.js @@ -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 }) => { diff --git a/app/tests-layout/shortcuts.spec.js b/app/tests-layout/shortcuts.spec.js new file mode 100644 index 00000000..0555efc9 --- /dev/null +++ b/app/tests-layout/shortcuts.spec.js @@ -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"); +}); diff --git a/core/config.py b/core/config.py index 0a5d653a..ac9e16b1 100644 --- a/core/config.py +++ b/core/config.py @@ -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" diff --git a/core/projects.py b/core/projects.py index 3532fbc3..c3a88a3c 100644 --- a/core/projects.py +++ b/core/projects.py @@ -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), ) diff --git a/core/prompt_build.py b/core/prompt_build.py new file mode 100644 index 00000000..2d576a16 --- /dev/null +++ b/core/prompt_build.py @@ -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) diff --git a/core/routes.py b/core/routes.py index ac01eca6..19634abe 100644 --- a/core/routes.py +++ b/core/routes.py @@ -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 diff --git a/core/shortcuts.py b/core/shortcuts.py new file mode 100644 index 00000000..e8e9084d --- /dev/null +++ b/core/shortcuts.py @@ -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"(? 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{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) diff --git a/core/tests/test_projects.py b/core/tests/test_projects.py index 9137bbc7..43635d8c 100644 --- a/core/tests/test_projects.py +++ b/core/tests/test_projects.py @@ -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"}] diff --git a/core/tests/test_prompt_build.py b/core/tests/test_prompt_build.py new file mode 100644 index 00000000..cc6b5129 --- /dev/null +++ b/core/tests/test_prompt_build.py @@ -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(" ") == "" diff --git a/core/tests/test_routes_shortcuts.py b/core/tests/test_routes_shortcuts.py new file mode 100644 index 00000000..cda1273a --- /dev/null +++ b/core/tests/test_routes_shortcuts.py @@ -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"] diff --git a/core/tests/test_shortcuts.py b/core/tests/test_shortcuts.py new file mode 100644 index 00000000..638efc83 --- /dev/null +++ b/core/tests/test_shortcuts.py @@ -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"