Add Prompt Builder page for multi-scene prompt sequences.

Name, save, and load ordered scenes on the server; assemble copyable text with a Batch-compatible double blank line between scenes.
This commit is contained in:
Towsty
2026-10-02 07:15:00 -05:00
parent 5788d74001
commit 1bf97568bf
12 changed files with 866 additions and 1 deletions
+8
View File
@@ -0,0 +1,8 @@
import { listPromptSequences } from '~/server/utils/promptSequences'
export default defineEventHandler((event) => {
const { owner } = assertLibraryOwner(event)
return {
sequences: listPromptSequences(owner)
}
})
+19
View File
@@ -0,0 +1,19 @@
import { listPromptSequences, savePromptSequence } from '~/server/utils/promptSequences'
export default defineEventHandler(async (event) => {
const { owner } = assertLibraryOwner(event)
const body = await readBody<{
name?: string
scenes?: unknown
overwrite?: boolean
}>(event).catch(() => ({}))
const sequence = await savePromptSequence(owner, {
name: body?.name || '',
scenes: body?.scenes,
overwrite: body?.overwrite === true
})
return {
sequence,
sequences: listPromptSequences(owner)
}
})
@@ -0,0 +1,11 @@
import { deletePromptSequence, listPromptSequences } from '~/server/utils/promptSequences'
export default defineEventHandler(async (event) => {
const { owner } = assertLibraryOwner(event)
const id = String(getRouterParam(event, 'id') || '')
await deletePromptSequence(owner, id)
return {
ok: true,
sequences: listPromptSequences(owner)
}
})
+9
View File
@@ -0,0 +1,9 @@
import { getPromptSequence } from '~/server/utils/promptSequences'
export default defineEventHandler((event) => {
const { owner } = assertLibraryOwner(event)
const id = String(getRouterParam(event, 'id') || '')
return {
sequence: getPromptSequence(owner, id)
}
})
+15
View File
@@ -0,0 +1,15 @@
import { listPromptSequences, updatePromptSequence } from '~/server/utils/promptSequences'
export default defineEventHandler(async (event) => {
const { owner } = assertLibraryOwner(event)
const id = String(getRouterParam(event, 'id') || '')
const body = await readBody<{ name?: string; scenes?: unknown }>(event).catch(() => ({}))
const sequence = await updatePromptSequence(owner, id, {
name: body?.name,
scenes: body?.scenes
})
return {
sequence,
sequences: listPromptSequences(owner)
}
})
+137
View File
@@ -0,0 +1,137 @@
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { ownerRoot } from '~/server/utils/library'
import {
normalizePromptSequence,
normalizePromptSequenceName,
normalizeScene,
emptyScene,
type PromptSequence,
type PromptSequenceScene
} from '~/utils/promptSequences'
const writeChains = new Map<string, Promise<unknown>>()
function sequencesPath(owner: string) {
return join(ownerRoot(owner), 'prompt-sequences.json')
}
function ensureOwner(owner: string) {
mkdirSync(ownerRoot(owner), { recursive: true })
}
function readSequences(owner: string): PromptSequence[] {
ensureOwner(owner)
const path = sequencesPath(owner)
if (!existsSync(path)) return []
try {
const parsed = JSON.parse(readFileSync(path, 'utf8'))
return Array.isArray(parsed)
? parsed.map(normalizePromptSequence).filter((item): item is PromptSequence => Boolean(item))
: []
} catch {
return []
}
}
function writeSequences(owner: string, sequences: PromptSequence[]) {
ensureOwner(owner)
const path = sequencesPath(owner)
const tmp = `${path}.tmp`
writeFileSync(tmp, JSON.stringify(sequences, null, 2))
renameSync(tmp, path)
}
function mutate<T>(owner: string, fn: (sequences: PromptSequence[]) => T): Promise<T> {
const prev = writeChains.get(owner) || Promise.resolve()
const run = prev.then(() => {
const sequences = readSequences(owner)
const result = fn(sequences)
writeSequences(owner, sequences)
return result
})
writeChains.set(owner, run.then(() => undefined, () => undefined))
return run
}
function normalizeScenesInput(raw: unknown): PromptSequenceScene[] {
if (!Array.isArray(raw) || !raw.length) return [emptyScene(1)]
return raw.map((scene, index) => normalizeScene(scene, index))
}
export function listPromptSequences(owner: string) {
return readSequences(owner).sort((a, b) => b.updatedAt - a.updatedAt || a.name.localeCompare(b.name))
}
export function getPromptSequence(owner: string, id: string) {
const wanted = String(id || '').trim()
const sequence = readSequences(owner).find(item => item.id === wanted)
if (!sequence) throw createError({ statusCode: 404, statusMessage: 'Sequence not found' })
return sequence
}
export async function savePromptSequence(owner: string, input: {
name: string
scenes?: unknown
overwrite?: boolean
}) {
const name = normalizePromptSequenceName(input.name)
if (!name) throw createError({ statusCode: 400, statusMessage: 'A sequence name is required' })
const scenes = normalizeScenesInput(input.scenes)
const now = Date.now()
return mutate(owner, (sequences) => {
const existing = sequences.find(item => item.name.toLowerCase() === name.toLowerCase())
if (existing && !input.overwrite) {
throw createError({ statusCode: 409, statusMessage: `Overwrite sequence “${existing.name}”?` })
}
if (existing) {
existing.scenes = scenes
existing.updatedAt = now
return existing
}
const created: PromptSequence = {
id: crypto.randomUUID(),
name,
scenes,
createdAt: now,
updatedAt: now
}
sequences.unshift(created)
return created
})
}
export async function updatePromptSequence(owner: string, id: string, input: {
name?: string
scenes?: unknown
}) {
const wanted = String(id || '').trim()
if (!wanted) throw createError({ statusCode: 400, statusMessage: 'Missing sequence id' })
return mutate(owner, (sequences) => {
const existing = sequences.find(item => item.id === wanted)
if (!existing) throw createError({ statusCode: 404, statusMessage: 'Sequence not found' })
if (input.name !== undefined) {
const name = normalizePromptSequenceName(input.name)
if (!name) throw createError({ statusCode: 400, statusMessage: 'A sequence name is required' })
const clash = sequences.find(item => item.id !== wanted && item.name.toLowerCase() === name.toLowerCase())
if (clash) throw createError({ statusCode: 409, statusMessage: `A sequence named “${clash.name}” already exists` })
existing.name = name
}
if (input.scenes !== undefined) {
existing.scenes = normalizeScenesInput(input.scenes)
}
existing.updatedAt = Date.now()
return existing
})
}
export async function deletePromptSequence(owner: string, id: string) {
const wanted = String(id || '').trim()
if (!wanted) throw createError({ statusCode: 400, statusMessage: 'Missing sequence id' })
return mutate(owner, (sequences) => {
const index = sequences.findIndex(item => item.id === wanted)
if (index < 0) throw createError({ statusCode: 404, statusMessage: 'Sequence not found' })
const [removed] = sequences.splice(index, 1)
return removed
})
}