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:
@@ -55,6 +55,13 @@
|
||||
role="menuitem"
|
||||
@click="menuOpen = false"
|
||||
>Director</NuxtLink>
|
||||
<NuxtLink
|
||||
to="/prompt-builder"
|
||||
class="block rounded-xl px-3 py-2 text-sm hover:bg-white/5 hover:text-white"
|
||||
:class="current === 'prompt-builder' ? 'text-amber-100' : 'text-zinc-200'"
|
||||
role="menuitem"
|
||||
@click="menuOpen = false"
|
||||
>Prompt Builder</NuxtLink>
|
||||
<NuxtLink
|
||||
to="/music"
|
||||
class="block rounded-xl px-3 py-2 text-sm hover:bg-white/5 hover:text-white"
|
||||
@@ -131,7 +138,7 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(defineProps<{
|
||||
subtitle: string
|
||||
current?: 'studio' | 'library' | 'director' | 'queue' | 'settings' | 'music'
|
||||
current?: 'studio' | 'library' | 'director' | 'prompt-builder' | 'queue' | 'settings' | 'music'
|
||||
wide?: boolean
|
||||
queueCount?: number
|
||||
}>(), {
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
<div class="bench-header-right">
|
||||
<nav class="bench-nav" aria-label="Studio links">
|
||||
<NuxtLink to="/library">Library</NuxtLink>
|
||||
<NuxtLink to="/prompt-builder">Prompt Builder</NuxtLink>
|
||||
<NuxtLink to="/queue">Queue</NuxtLink>
|
||||
<NuxtLink to="/settings">Settings</NuxtLink>
|
||||
</nav>
|
||||
|
||||
@@ -0,0 +1,395 @@
|
||||
<template>
|
||||
<div class="min-h-dvh min-w-0">
|
||||
<AppHeader subtitle="Prompt Builder" current="prompt-builder" />
|
||||
|
||||
<main class="mx-auto grid min-w-0 max-w-7xl gap-6 px-4 py-6 sm:px-6 lg:grid-cols-[minmax(0,1fr)_minmax(18rem,22rem)]">
|
||||
<section class="glass min-w-0 max-w-full rounded-3xl p-5 sm:p-6 space-y-5">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="font-display text-2xl font-bold">Prompt Builder</h2>
|
||||
<p class="mt-1 max-w-2xl text-sm text-zinc-400">
|
||||
Build multi-image prompt sequences. Copy the preview into Generate with Batch on — scenes split on the double blank line.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-full border border-white/10 px-3 py-1.5 text-xs text-zinc-300 hover:border-amber-300/50 hover:text-white"
|
||||
@click="startNew"
|
||||
>
|
||||
New
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-full border border-white/10 px-3 py-1.5 text-xs text-zinc-300 hover:border-amber-300/50 hover:text-white disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="busy || !name.trim()"
|
||||
@click="save"
|
||||
>
|
||||
{{ busy === 'save' ? 'Saving…' : selectedId ? 'Save' : 'Save sequence' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="rounded-2xl border border-red-400/30 bg-red-400/10 px-4 py-3 text-sm text-red-200">{{ error }}</p>
|
||||
<p v-else-if="notice" class="rounded-2xl border border-amber-300/30 bg-amber-400/10 px-4 py-3 text-sm text-amber-100">{{ notice }}</p>
|
||||
|
||||
<label class="block text-sm">
|
||||
<span class="mb-1 block font-medium text-zinc-300">Sequence name</span>
|
||||
<input
|
||||
v-model="name"
|
||||
class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2"
|
||||
maxlength="80"
|
||||
placeholder="e.g. Badge set A"
|
||||
>
|
||||
<p class="mt-1 text-[11px] text-zinc-500">Save key only — never copied into the prompt text.</p>
|
||||
</label>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2 border-b border-white/10 pb-2">
|
||||
<button
|
||||
v-for="(scene, index) in scenes"
|
||||
:key="scene.id"
|
||||
type="button"
|
||||
class="rounded-full px-3 py-1.5 text-xs transition"
|
||||
:class="index === activeIndex
|
||||
? 'bg-amber-400/15 text-amber-100 ring-1 ring-amber-300/40'
|
||||
: 'text-zinc-400 hover:bg-white/5 hover:text-zinc-200'"
|
||||
@click="activeIndex = index"
|
||||
>
|
||||
{{ scene.title || `Scene ${index + 1}` }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-full border border-white/10 px-3 py-1.5 text-xs text-zinc-300 hover:border-amber-300/50 hover:text-white"
|
||||
@click="addNextScene"
|
||||
>
|
||||
Next scene
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="activeScene" class="space-y-4">
|
||||
<label class="block text-sm">
|
||||
<span class="mb-1 flex items-center justify-between gap-2 font-medium text-zinc-300">
|
||||
Scene tab name
|
||||
<span class="text-[11px] font-normal text-zinc-500">optional</span>
|
||||
</span>
|
||||
<input
|
||||
v-model="activeScene.title"
|
||||
class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2"
|
||||
maxlength="60"
|
||||
:placeholder="`Scene ${activeIndex + 1}`"
|
||||
>
|
||||
</label>
|
||||
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-sm font-medium text-zinc-300">Style</span>
|
||||
<button type="button" class="text-[11px] text-zinc-500 hover:text-amber-200" @click="activeScene.style = ''">Clear</button>
|
||||
</div>
|
||||
<textarea
|
||||
v-model="activeScene.style"
|
||||
rows="3"
|
||||
class="w-full rounded-2xl border border-white/10 bg-zinc-950/70 px-3 py-2 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2"
|
||||
placeholder="Visual style for this image"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-sm font-medium text-zinc-300">Settings</span>
|
||||
<button type="button" class="text-[11px] text-zinc-500 hover:text-amber-200" @click="activeScene.settings = ''">Clear</button>
|
||||
</div>
|
||||
<textarea
|
||||
v-model="activeScene.settings"
|
||||
rows="3"
|
||||
class="w-full rounded-2xl border border-white/10 bg-zinc-950/70 px-3 py-2 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2"
|
||||
placeholder="Lighting, framing, technical notes"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<span class="text-sm font-medium text-zinc-300">Characters</span>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-full border border-white/10 px-3 py-1 text-[11px] text-zinc-300 hover:border-amber-300/50 hover:text-white"
|
||||
@click="addCharacter"
|
||||
>
|
||||
Add character
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-for="(character, cIndex) in activeScene.characters"
|
||||
:key="character.id"
|
||||
class="rounded-2xl border border-white/10 bg-zinc-950/40 p-3 space-y-2"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-[11px] uppercase tracking-wide text-zinc-500">Character {{ cIndex + 1 }}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<button type="button" class="text-[11px] text-zinc-500 hover:text-amber-200" @click="clearCharacter(cIndex)">Clear</button>
|
||||
<button
|
||||
v-if="activeScene.characters.length > 1"
|
||||
type="button"
|
||||
class="text-[11px] text-zinc-500 hover:text-red-300"
|
||||
@click="removeCharacter(cIndex)"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
v-model="character.name"
|
||||
class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2"
|
||||
maxlength="120"
|
||||
placeholder="Name"
|
||||
>
|
||||
<textarea
|
||||
v-model="character.description"
|
||||
rows="2"
|
||||
class="w-full rounded-xl border border-white/10 bg-zinc-950/70 px-3 py-2 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2"
|
||||
placeholder="Description"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-sm font-medium text-zinc-300">Prompt</span>
|
||||
<button type="button" class="text-[11px] text-zinc-500 hover:text-amber-200" @click="activeScene.prompt = ''">Clear</button>
|
||||
</div>
|
||||
<textarea
|
||||
v-model="activeScene.prompt"
|
||||
rows="5"
|
||||
class="w-full rounded-2xl border border-white/10 bg-zinc-950/70 px-3 py-2 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2"
|
||||
placeholder="What happens in this image"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="rounded-2xl border border-white/10 bg-zinc-950/30">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2 border-b border-white/5 px-4 py-3">
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-zinc-200">Preview</h3>
|
||||
<p class="text-[11px] text-zinc-500">Assembled text ready to copy. Sequence name is omitted.</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-full border border-white/10 px-3 py-1.5 text-xs text-zinc-300 hover:border-amber-300/50 hover:text-white disabled:opacity-50"
|
||||
:disabled="!previewText"
|
||||
@click="copyPreview"
|
||||
>
|
||||
{{ copied ? 'Copied' : 'Copy' }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-full border border-white/10 px-3 py-1.5 text-xs text-zinc-300 hover:border-amber-300/50 hover:text-white"
|
||||
@click="previewOpen = !previewOpen"
|
||||
>
|
||||
{{ previewOpen ? 'Hide' : 'Show' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<pre
|
||||
v-if="previewOpen"
|
||||
class="max-h-[min(28rem,50vh)] overflow-auto whitespace-pre-wrap px-4 py-3 text-xs text-zinc-300 [overflow-wrap:anywhere]"
|
||||
>{{ previewText || 'Fill a scene to see the assembled prompt.' }}</pre>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<aside class="min-w-0 space-y-4">
|
||||
<section class="glass rounded-3xl p-5 sm:p-6">
|
||||
<h3 class="font-display text-lg font-bold">Saved sequences</h3>
|
||||
<p class="mt-1 text-xs text-zinc-500">Stored with your other library data. Open one to edit.</p>
|
||||
<p v-if="!sequences.length" class="mt-4 rounded-2xl border border-white/5 bg-zinc-950/40 px-4 py-6 text-center text-sm text-zinc-500">
|
||||
Nothing saved yet.
|
||||
</p>
|
||||
<div v-else class="mt-4 space-y-2">
|
||||
<button
|
||||
v-for="sequence in sequences"
|
||||
:key="sequence.id"
|
||||
type="button"
|
||||
class="w-full rounded-2xl border p-3 text-left transition"
|
||||
:class="sequence.id === selectedId
|
||||
? 'border-amber-300/30 bg-amber-400/5'
|
||||
: 'border-white/5 bg-zinc-950/40 hover:border-white/15'"
|
||||
@click="select(sequence)"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-sm font-medium text-zinc-100">{{ sequence.name }}</p>
|
||||
<p class="mt-0.5 text-[11px] text-zinc-500">
|
||||
{{ sequence.scenes?.length || 0 }} scene{{ (sequence.scenes?.length || 0) === 1 ? '' : 's' }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 text-xs text-zinc-500 hover:text-red-300"
|
||||
@click.stop="remove(sequence)"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { PromptSequence, PromptSequenceScene } from '~/utils/promptSequences'
|
||||
import {
|
||||
assembleSequenceText,
|
||||
cloneSceneForNext,
|
||||
emptyCharacter,
|
||||
emptyScene
|
||||
} from '~/utils/promptSequences'
|
||||
|
||||
useHead({ title: 'Prompt Builder' })
|
||||
|
||||
const sequences = ref<PromptSequence[]>([])
|
||||
const selectedId = ref('')
|
||||
const name = ref('')
|
||||
const scenes = ref<PromptSequenceScene[]>([emptyScene(1)])
|
||||
const activeIndex = ref(0)
|
||||
const previewOpen = ref(true)
|
||||
const busy = ref('')
|
||||
const error = ref('')
|
||||
const notice = ref('')
|
||||
const copied = ref(false)
|
||||
let copyTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const activeScene = computed(() => scenes.value[activeIndex.value] || null)
|
||||
const previewText = computed(() => assembleSequenceText({ scenes: scenes.value }))
|
||||
|
||||
function startNew() {
|
||||
selectedId.value = ''
|
||||
name.value = ''
|
||||
scenes.value = [emptyScene(1)]
|
||||
activeIndex.value = 0
|
||||
error.value = ''
|
||||
notice.value = ''
|
||||
}
|
||||
|
||||
function select(sequence: PromptSequence) {
|
||||
selectedId.value = sequence.id
|
||||
name.value = sequence.name
|
||||
scenes.value = (sequence.scenes?.length ? sequence.scenes : [emptyScene(1)]).map((scene, index) => ({
|
||||
...emptyScene(index + 1),
|
||||
...scene,
|
||||
characters: (scene.characters?.length ? scene.characters : [emptyCharacter()]).map(character => ({
|
||||
...emptyCharacter(),
|
||||
...character
|
||||
}))
|
||||
}))
|
||||
activeIndex.value = 0
|
||||
error.value = ''
|
||||
notice.value = ''
|
||||
}
|
||||
|
||||
function addNextScene() {
|
||||
const previous = scenes.value[scenes.value.length - 1] || emptyScene(1)
|
||||
const next = cloneSceneForNext(previous, scenes.value.length + 1)
|
||||
scenes.value.push(next)
|
||||
activeIndex.value = scenes.value.length - 1
|
||||
}
|
||||
|
||||
function addCharacter() {
|
||||
if (!activeScene.value) return
|
||||
activeScene.value.characters.push(emptyCharacter())
|
||||
}
|
||||
|
||||
function clearCharacter(index: number) {
|
||||
const character = activeScene.value?.characters[index]
|
||||
if (!character) return
|
||||
character.name = ''
|
||||
character.description = ''
|
||||
}
|
||||
|
||||
function removeCharacter(index: number) {
|
||||
if (!activeScene.value || activeScene.value.characters.length <= 1) return
|
||||
activeScene.value.characters.splice(index, 1)
|
||||
}
|
||||
|
||||
async function loadSequences() {
|
||||
const listed = await $fetch<{ sequences?: PromptSequence[] }>('/api/prompt-sequences')
|
||||
sequences.value = listed.sequences || []
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const nextName = name.value.trim()
|
||||
if (!nextName) return
|
||||
const existingByName = sequences.value.find(item => item.name.toLowerCase() === nextName.toLowerCase())
|
||||
const updateExisting = Boolean(selectedId.value) && (!existingByName || existingByName.id === selectedId.value)
|
||||
if (existingByName && existingByName.id !== selectedId.value && !window.confirm(`Overwrite sequence “${existingByName.name}”?`)) return
|
||||
busy.value = 'save'
|
||||
error.value = ''
|
||||
notice.value = ''
|
||||
try {
|
||||
if (updateExisting && selectedId.value) {
|
||||
const saved = await $fetch<{ sequence: PromptSequence; sequences: PromptSequence[] }>(`/api/prompt-sequences/${selectedId.value}`, {
|
||||
method: 'PATCH',
|
||||
body: { name: nextName, scenes: scenes.value }
|
||||
})
|
||||
sequences.value = saved.sequences
|
||||
select(saved.sequence)
|
||||
notice.value = `Saved “${saved.sequence.name}”.`
|
||||
} else {
|
||||
const saved = await $fetch<{ sequence: PromptSequence; sequences: PromptSequence[] }>('/api/prompt-sequences', {
|
||||
method: 'POST',
|
||||
body: { name: nextName, scenes: scenes.value, overwrite: Boolean(existingByName) }
|
||||
})
|
||||
sequences.value = saved.sequences
|
||||
select(saved.sequence)
|
||||
notice.value = existingByName ? `Overwrote “${saved.sequence.name}”.` : `Saved “${saved.sequence.name}”.`
|
||||
}
|
||||
} catch (err: any) {
|
||||
error.value = err?.data?.statusMessage || err?.statusMessage || err?.message || 'Could not save that sequence'
|
||||
} finally {
|
||||
busy.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(sequence: PromptSequence) {
|
||||
if (!window.confirm(`Delete sequence “${sequence.name}”?`)) return
|
||||
busy.value = 'delete'
|
||||
error.value = ''
|
||||
notice.value = ''
|
||||
try {
|
||||
const saved = await $fetch<{ sequences: PromptSequence[] }>(`/api/prompt-sequences/${sequence.id}`, { method: 'DELETE' })
|
||||
sequences.value = saved.sequences
|
||||
if (selectedId.value === sequence.id) startNew()
|
||||
notice.value = `Deleted “${sequence.name}”.`
|
||||
} catch (err: any) {
|
||||
error.value = err?.data?.statusMessage || err?.statusMessage || err?.message || 'Could not delete that sequence'
|
||||
} finally {
|
||||
busy.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function copyPreview() {
|
||||
const text = previewText.value
|
||||
if (!text) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
copied.value = true
|
||||
if (copyTimer) clearTimeout(copyTimer)
|
||||
copyTimer = setTimeout(() => { copied.value = false }, 1500)
|
||||
} catch {
|
||||
error.value = 'Could not copy to the clipboard'
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await loadSequences()
|
||||
} catch (err: any) {
|
||||
error.value = err?.data?.statusMessage || err?.message || 'Could not load saved sequences'
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (copyTimer) clearTimeout(copyTimer)
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,8 @@
|
||||
import { listPromptSequences } from '~/server/utils/promptSequences'
|
||||
|
||||
export default defineEventHandler((event) => {
|
||||
const { owner } = assertLibraryOwner(event)
|
||||
return {
|
||||
sequences: listPromptSequences(owner)
|
||||
}
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/** Pure prompt-sequence helpers (shared by page + tests). */
|
||||
|
||||
export function newId() {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID()
|
||||
}
|
||||
return `ps-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
|
||||
}
|
||||
|
||||
export function emptyCharacter() {
|
||||
return { id: newId(), name: '', description: '' }
|
||||
}
|
||||
|
||||
export function emptyScene(index = 1) {
|
||||
return {
|
||||
id: newId(),
|
||||
title: `Scene ${Math.max(1, index)}`,
|
||||
style: '',
|
||||
settings: '',
|
||||
characters: [emptyCharacter()],
|
||||
prompt: ''
|
||||
}
|
||||
}
|
||||
|
||||
export function cloneSceneForNext(previous, nextIndex) {
|
||||
return {
|
||||
id: newId(),
|
||||
title: `Scene ${nextIndex}`,
|
||||
style: previous.style,
|
||||
settings: previous.settings,
|
||||
characters: (previous.characters || []).map(character => ({
|
||||
id: newId(),
|
||||
name: character.name,
|
||||
description: character.description
|
||||
})),
|
||||
prompt: previous.prompt
|
||||
}
|
||||
}
|
||||
|
||||
export function formatCharacterLine(character) {
|
||||
const name = String(character?.name || '').trim()
|
||||
const description = String(character?.description || '').trim()
|
||||
if (!name && !description) return ''
|
||||
if (name && description) return `${name}: ${description}`
|
||||
return name || description
|
||||
}
|
||||
|
||||
export function assembleSceneText(scene) {
|
||||
const characterBlock = (scene?.characters || [])
|
||||
.map(formatCharacterLine)
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
const parts = [
|
||||
String(scene?.style || '').trim(),
|
||||
String(scene?.settings || '').trim(),
|
||||
characterBlock,
|
||||
String(scene?.prompt || '').trim()
|
||||
].filter(Boolean)
|
||||
return parts.join('\n\n')
|
||||
}
|
||||
|
||||
/** Scenes join with \\n\\n\\n (desktop Batch double blank). Name is never included. */
|
||||
export function assembleSequenceText(sequence) {
|
||||
return (sequence?.scenes || [])
|
||||
.map(assembleSceneText)
|
||||
.filter(Boolean)
|
||||
.join('\n\n\n')
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import {
|
||||
assembleSceneText,
|
||||
assembleSequenceText,
|
||||
formatCharacterLine,
|
||||
emptyScene,
|
||||
cloneSceneForNext
|
||||
} from '../shared/prompt-sequences.mjs'
|
||||
|
||||
test('formatCharacterLine covers name/description cases', () => {
|
||||
assert.equal(formatCharacterLine({ name: 'Ada', description: 'tall' }), 'Ada: tall')
|
||||
assert.equal(formatCharacterLine({ name: 'Ada', description: '' }), 'Ada')
|
||||
assert.equal(formatCharacterLine({ name: '', description: 'tall' }), 'tall')
|
||||
assert.equal(formatCharacterLine({ name: '', description: '' }), '')
|
||||
})
|
||||
|
||||
test('assembleSceneText joins non-empty boxes with a single blank line', () => {
|
||||
const text = assembleSceneText({
|
||||
style: 'oil paint',
|
||||
settings: 'night',
|
||||
characters: [
|
||||
{ name: 'Ada', description: 'tall' },
|
||||
{ name: '', description: '' },
|
||||
{ name: 'Bo', description: 'hat' }
|
||||
],
|
||||
prompt: 'walks'
|
||||
})
|
||||
assert.equal(text, 'oil paint\n\nnight\n\nAda: tall\nBo: hat\n\nwalks')
|
||||
})
|
||||
|
||||
test('assembleSequenceText uses double blank line between scenes and skips empties', () => {
|
||||
const text = assembleSequenceText({
|
||||
name: 'ignored',
|
||||
scenes: [
|
||||
{ style: 's1', settings: '', characters: [], prompt: 'p1' },
|
||||
{ style: '', settings: '', characters: [{ name: '', description: '' }], prompt: '' },
|
||||
{ style: 's2', settings: 'set2', characters: [{ name: 'C', description: 'd' }], prompt: 'p2' }
|
||||
]
|
||||
})
|
||||
assert.equal(text, 's1\n\np1\n\n\ns2\n\nset2\n\nC: d\n\np2')
|
||||
assert.ok(!text.includes('ignored'))
|
||||
})
|
||||
|
||||
test('example shape is two jobs for desktop Batch', () => {
|
||||
const text = assembleSequenceText({
|
||||
scenes: [
|
||||
{
|
||||
style: 'style scene 1',
|
||||
settings: 'settings scene 1',
|
||||
characters: [
|
||||
{ name: 'Character one', description: 'desc' },
|
||||
{ name: 'Character two', description: 'desc' }
|
||||
],
|
||||
prompt: 'prompt scene 1'
|
||||
},
|
||||
{
|
||||
style: 'style scene 2',
|
||||
settings: 'settings scene 2',
|
||||
characters: [{ name: 'Character one', description: 'desc' }],
|
||||
prompt: 'prompt scene 2'
|
||||
}
|
||||
]
|
||||
})
|
||||
assert.equal(
|
||||
text,
|
||||
[
|
||||
'style scene 1',
|
||||
'',
|
||||
'settings scene 1',
|
||||
'',
|
||||
'Character one: desc',
|
||||
'Character two: desc',
|
||||
'',
|
||||
'prompt scene 1',
|
||||
'',
|
||||
'',
|
||||
'style scene 2',
|
||||
'',
|
||||
'settings scene 2',
|
||||
'',
|
||||
'Character one: desc',
|
||||
'',
|
||||
'prompt scene 2'
|
||||
].join('\n')
|
||||
)
|
||||
})
|
||||
|
||||
test('cloneSceneForNext copies fields with new ids', () => {
|
||||
const first = emptyScene(1)
|
||||
first.style = 'a'
|
||||
first.characters[0].name = 'Ada'
|
||||
const next = cloneSceneForNext(first, 2)
|
||||
assert.equal(next.title, 'Scene 2')
|
||||
assert.equal(next.style, 'a')
|
||||
assert.equal(next.characters[0].name, 'Ada')
|
||||
assert.notEqual(next.id, first.id)
|
||||
assert.notEqual(next.characters[0].id, first.characters[0].id)
|
||||
})
|
||||
@@ -0,0 +1,96 @@
|
||||
export type PromptSequenceCharacter = {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export type PromptSequenceScene = {
|
||||
id: string
|
||||
title: string
|
||||
style: string
|
||||
settings: string
|
||||
characters: PromptSequenceCharacter[]
|
||||
prompt: string
|
||||
}
|
||||
|
||||
export type PromptSequence = {
|
||||
id: string
|
||||
name: string
|
||||
scenes: PromptSequenceScene[]
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export const PROMPT_SEQUENCE_NAME_MAX = 80
|
||||
export const PROMPT_SEQUENCE_FIELD_MAX = 50_000
|
||||
export const PROMPT_SEQUENCE_TITLE_MAX = 60
|
||||
|
||||
export {
|
||||
newId,
|
||||
emptyCharacter,
|
||||
emptyScene,
|
||||
cloneSceneForNext,
|
||||
formatCharacterLine,
|
||||
assembleSceneText,
|
||||
assembleSequenceText
|
||||
} from '../shared/prompt-sequences.mjs'
|
||||
|
||||
import {
|
||||
newId,
|
||||
emptyCharacter,
|
||||
emptyScene
|
||||
} from '../shared/prompt-sequences.mjs'
|
||||
|
||||
export function normalizePromptSequenceName(raw: unknown) {
|
||||
return String(raw || '').trim().replace(/\s+/g, ' ').slice(0, PROMPT_SEQUENCE_NAME_MAX)
|
||||
}
|
||||
|
||||
export function normalizePromptSequenceField(raw: unknown) {
|
||||
return String(raw || '').replace(/\r\n/g, '\n').slice(0, PROMPT_SEQUENCE_FIELD_MAX)
|
||||
}
|
||||
|
||||
export function normalizePromptSequenceTitle(raw: unknown, fallback: string) {
|
||||
const title = String(raw || '').trim().replace(/\s+/g, ' ').slice(0, PROMPT_SEQUENCE_TITLE_MAX)
|
||||
return title || fallback
|
||||
}
|
||||
|
||||
export function normalizeCharacter(raw: unknown): PromptSequenceCharacter {
|
||||
const rec = (raw && typeof raw === 'object' ? raw : {}) as Record<string, unknown>
|
||||
return {
|
||||
id: String(rec.id || '').trim() || newId(),
|
||||
name: String(rec.name || '').trim().slice(0, 120),
|
||||
description: normalizePromptSequenceField(rec.description).trim()
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeScene(raw: unknown, index: number): PromptSequenceScene {
|
||||
const rec = (raw && typeof raw === 'object' ? raw : {}) as Record<string, unknown>
|
||||
const characters = Array.isArray(rec.characters)
|
||||
? rec.characters.map(normalizeCharacter)
|
||||
: [emptyCharacter()]
|
||||
return {
|
||||
id: String(rec.id || '').trim() || newId(),
|
||||
title: normalizePromptSequenceTitle(rec.title, `Scene ${index + 1}`),
|
||||
style: normalizePromptSequenceField(rec.style),
|
||||
settings: normalizePromptSequenceField(rec.settings),
|
||||
characters: characters.length ? characters : [emptyCharacter()],
|
||||
prompt: normalizePromptSequenceField(rec.prompt)
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizePromptSequence(raw: unknown): PromptSequence | null {
|
||||
if (!raw || typeof raw !== 'object') return null
|
||||
const rec = raw as Record<string, unknown>
|
||||
const name = normalizePromptSequenceName(rec.name)
|
||||
if (!name) return null
|
||||
const scenesRaw = Array.isArray(rec.scenes) ? rec.scenes : []
|
||||
const scenes = scenesRaw.map((scene, index) => normalizeScene(scene, index))
|
||||
const createdAt = Number(rec.createdAt) || Date.now()
|
||||
return {
|
||||
id: String(rec.id || '').trim() || newId(),
|
||||
name,
|
||||
scenes: scenes.length ? scenes : [emptyScene(1)],
|
||||
createdAt,
|
||||
updatedAt: Number(rec.updatedAt) || createdAt
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user