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"
|
role="menuitem"
|
||||||
@click="menuOpen = false"
|
@click="menuOpen = false"
|
||||||
>Director</NuxtLink>
|
>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
|
<NuxtLink
|
||||||
to="/music"
|
to="/music"
|
||||||
class="block rounded-xl px-3 py-2 text-sm hover:bg-white/5 hover:text-white"
|
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">
|
<script setup lang="ts">
|
||||||
withDefaults(defineProps<{
|
withDefaults(defineProps<{
|
||||||
subtitle: string
|
subtitle: string
|
||||||
current?: 'studio' | 'library' | 'director' | 'queue' | 'settings' | 'music'
|
current?: 'studio' | 'library' | 'director' | 'prompt-builder' | 'queue' | 'settings' | 'music'
|
||||||
wide?: boolean
|
wide?: boolean
|
||||||
queueCount?: number
|
queueCount?: number
|
||||||
}>(), {
|
}>(), {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
<div class="bench-header-right">
|
<div class="bench-header-right">
|
||||||
<nav class="bench-nav" aria-label="Studio links">
|
<nav class="bench-nav" aria-label="Studio links">
|
||||||
<NuxtLink to="/library">Library</NuxtLink>
|
<NuxtLink to="/library">Library</NuxtLink>
|
||||||
|
<NuxtLink to="/prompt-builder">Prompt Builder</NuxtLink>
|
||||||
<NuxtLink to="/queue">Queue</NuxtLink>
|
<NuxtLink to="/queue">Queue</NuxtLink>
|
||||||
<NuxtLink to="/settings">Settings</NuxtLink>
|
<NuxtLink to="/settings">Settings</NuxtLink>
|
||||||
</nav>
|
</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