Add Prompt Builder sequence section order.

Copied scene text and the scene editor follow a persisted sectionOrder, with legacy saves keeping the default style/settings/set/characters/prompt order.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Towsty
2026-10-04 07:07:43 -05:00
co-authored by Cursor
parent b6eef6a511
commit 45e97cc1c3
6 changed files with 1113 additions and 878 deletions
+961 -868
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -4,6 +4,7 @@ export default defineEventHandler(async (event) => {
const { owner } = assertLibraryOwner(event)
const body = await readBody<{
name?: string
sectionOrder?: unknown
characters?: unknown
sets?: unknown
scenes?: unknown
@@ -11,6 +12,7 @@ export default defineEventHandler(async (event) => {
}>(event).catch(() => ({}))
const sequence = await savePromptSequence(owner, {
name: body?.name || '',
sectionOrder: body?.sectionOrder,
characters: body?.characters,
sets: body?.sets,
scenes: body?.scenes,
+8 -1
View File
@@ -3,9 +3,16 @@ import { listPromptSequences, updatePromptSequence } from '~/server/utils/prompt
export default defineEventHandler(async (event) => {
const { owner } = assertLibraryOwner(event)
const id = String(getRouterParam(event, 'id') || '')
const body = await readBody<{ name?: string; characters?: unknown; sets?: unknown; scenes?: unknown }>(event).catch(() => ({}))
const body = await readBody<{
name?: string
sectionOrder?: unknown
characters?: unknown
sets?: unknown
scenes?: unknown
}>(event).catch(() => ({}))
const sequence = await updatePromptSequence(owner, id, {
name: body?.name,
sectionOrder: body?.sectionOrder,
characters: body?.characters,
sets: body?.sets,
scenes: body?.scenes
+15
View File
@@ -7,10 +7,13 @@ import {
normalizeCharacter,
normalizeSet,
normalizeScene,
normalizeSectionOrder,
emptyCharacter,
emptyScene,
DEFAULT_PROMPT_SEQUENCE_SECTION_ORDER,
type PromptSequence,
type PromptSequenceCharacter,
type PromptSequenceSectionKey,
type PromptSequenceSet,
type PromptSequenceScene
} from '~/utils/promptSequences'
@@ -91,6 +94,7 @@ export function getPromptSequence(owner: string, id: string) {
export async function savePromptSequence(owner: string, input: {
name: string
sectionOrder?: unknown
characters?: unknown
sets?: unknown
scenes?: unknown
@@ -101,6 +105,9 @@ export async function savePromptSequence(owner: string, input: {
const characters = normalizeCharactersInput(input.characters)
const sets = normalizeSetsInput(input.sets)
const scenes = normalizeScenesInput(input.scenes, characters, sets)
const sectionOrder = input.sectionOrder !== undefined
? normalizeSectionOrder(input.sectionOrder)
: [...DEFAULT_PROMPT_SEQUENCE_SECTION_ORDER]
const now = Date.now()
return mutate(owner, (sequences) => {
const existing = sequences.find(item => item.name.toLowerCase() === name.toLowerCase())
@@ -108,6 +115,7 @@ export async function savePromptSequence(owner: string, input: {
throw createError({ statusCode: 409, statusMessage: `Overwrite sequence “${existing.name}”?` })
}
if (existing) {
existing.sectionOrder = sectionOrder
existing.characters = characters
existing.sets = sets
existing.scenes = scenes
@@ -117,6 +125,7 @@ export async function savePromptSequence(owner: string, input: {
const created: PromptSequence = {
id: crypto.randomUUID(),
name,
sectionOrder,
characters,
sets,
scenes,
@@ -130,6 +139,7 @@ export async function savePromptSequence(owner: string, input: {
export async function updatePromptSequence(owner: string, id: string, input: {
name?: string
sectionOrder?: unknown
characters?: unknown
sets?: unknown
scenes?: unknown
@@ -146,6 +156,11 @@ export async function updatePromptSequence(owner: string, id: string, input: {
if (clash) throw createError({ statusCode: 409, statusMessage: `A sequence named “${clash.name}” already exists` })
existing.name = name
}
if (input.sectionOrder !== undefined) {
existing.sectionOrder = normalizeSectionOrder(input.sectionOrder)
} else if (!existing.sectionOrder?.length) {
existing.sectionOrder = [...DEFAULT_PROMPT_SEQUENCE_SECTION_ORDER] as PromptSequenceSectionKey[]
}
if (input.characters !== undefined || input.sets !== undefined || input.scenes !== undefined) {
const characters = input.characters !== undefined
? normalizeCharactersInput(input.characters)
+82
View File
@@ -26,6 +26,8 @@ const {
emptyFraming,
cloneSceneForNext,
normalizePromptSequence,
normalizeSectionOrder,
DEFAULT_PROMPT_SEQUENCE_SECTION_ORDER,
ensureSceneStatesForCharacters
} = await import(pathToFileURL(tmp).href)
unlinkSync(tmp)
@@ -204,3 +206,83 @@ test('ensureSceneStatesForCharacters turns new character on only for current sce
assert.equal(scenes[0].characterStates.find(s => s.characterId === b.id).on, false)
assert.equal(scenes[1].characterStates.find(s => s.characterId === b.id).on, true)
})
test('sectionOrder settings then prompt then characters; empty style and set omitted', () => {
const ada = {
...emptyCharacter(),
id: 'c1',
name: 'Ada',
description: 'tall',
outfits: []
}
const set = {
...emptySet(),
id: 's1',
name: 'Courtyard',
framings: [{ ...emptyFraming('wide'), id: 'f1', text: 'open yard' }]
}
const scene = {
style: '',
settings: 'soft light',
setStates: [{ setId: 's1', on: true, framingId: null }],
characterStates: [{ characterId: 'c1', on: true, outfitId: null }],
prompt: 'looks up'
}
const order = ['settings', 'prompt', 'characters']
const text = assembleSceneText(scene, [ada], [set], order)
assert.equal(text, 'soft light\n\nlooks up\n\nAda: tall')
assert.ok(!text.includes('open yard'))
assert.ok(!text.includes('Courtyard'))
assert.ok(!text.includes('wide'))
})
test('legacy sequence without sectionOrder keeps default order and \\n\\n\\n join', () => {
const legacy = normalizePromptSequence({
id: 'seq1',
name: 'Old order',
characters: [{ id: 'c1', name: 'Ada', description: 'tall', outfits: [] }],
sets: [{
id: 's1',
name: 'Hall',
framings: [{ id: 'f1', label: 'wide', text: 'stone hall' }]
}],
scenes: [
{
id: 'sc1',
title: 'One',
style: 'oil',
settings: 'day',
setStates: [{ setId: 's1', on: true, framingId: 'f1' }],
characterStates: [{ characterId: 'c1', on: true, outfitId: null }],
prompt: 'walk'
},
{
id: 'sc2',
title: 'Two',
style: 'ink',
settings: '',
setStates: [{ setId: 's1', on: false, framingId: 'f1' }],
characterStates: [{ characterId: 'c1', on: true, outfitId: null }],
prompt: 'stop'
}
],
createdAt: 1,
updatedAt: 1
})
assert.deepEqual(legacy.sectionOrder, [...DEFAULT_PROMPT_SEQUENCE_SECTION_ORDER])
assert.deepEqual(normalizeSectionOrder(undefined), [...DEFAULT_PROMPT_SEQUENCE_SECTION_ORDER])
assert.deepEqual(normalizeSectionOrder(['prompt', 'prompt', 'bogus', 'settings']), [
'prompt',
'settings',
'style',
'set',
'characters'
])
const full = assembleSequenceText(legacy)
assert.equal(
full,
'oil\n\nday\n\nstone hall\n\nAda: tall\n\nwalk\n\n\nink\n\nAda: tall\n\nstop'
)
assert.ok(!full.includes('Hall'))
assert.ok(!full.includes('wide'))
})
+45 -9
View File
@@ -50,9 +50,18 @@ export type PromptSequenceScene = {
prompt: string
}
/** Scene prompt sections. Default copy order. */
export const PROMPT_SEQUENCE_SECTION_KEYS = ['style', 'settings', 'set', 'characters', 'prompt'] as const
export type PromptSequenceSectionKey = (typeof PROMPT_SEQUENCE_SECTION_KEYS)[number]
export const DEFAULT_PROMPT_SEQUENCE_SECTION_ORDER: PromptSequenceSectionKey[] = [
...PROMPT_SEQUENCE_SECTION_KEYS
]
export type PromptSequence = {
id: string
name: string
/** Order of scene sections when assembling / in the editor. */
sectionOrder: PromptSequenceSectionKey[]
characters: PromptSequenceCharacter[]
sets: PromptSequenceSet[]
scenes: PromptSequenceScene[]
@@ -60,6 +69,26 @@ export type PromptSequence = {
updatedAt: number
}
/** Valid unique keys in given order; missing keys append in default order. */
export function normalizeSectionOrder(raw: unknown): PromptSequenceSectionKey[] {
const allowed = new Set<string>(PROMPT_SEQUENCE_SECTION_KEYS)
const seen = new Set<PromptSequenceSectionKey>()
const ordered: PromptSequenceSectionKey[] = []
if (Array.isArray(raw)) {
for (const item of raw) {
const key = String(item || '').trim()
if (!allowed.has(key) || seen.has(key as PromptSequenceSectionKey)) continue
const typed = key as PromptSequenceSectionKey
seen.add(typed)
ordered.push(typed)
}
}
for (const key of DEFAULT_PROMPT_SEQUENCE_SECTION_ORDER) {
if (!seen.has(key)) ordered.push(key)
}
return ordered
}
export const PROMPT_SEQUENCE_NAME_MAX = 80
export const PROMPT_SEQUENCE_FIELD_MAX = 50_000
export const PROMPT_SEQUENCE_TITLE_MAX = 60
@@ -206,7 +235,8 @@ export function assembleSceneText(
characters?: Array<{ name?: string; description?: string }>
},
sequenceCharacters?: PromptSequenceCharacter[],
sequenceSets?: PromptSequenceSet[]
sequenceSets?: PromptSequenceSet[],
sectionOrder?: PromptSequenceSectionKey[] | unknown
) {
const setBlock = sequenceSets?.length
? assembleSetBlock(scene, sequenceSets)
@@ -217,13 +247,15 @@ export function assembleSceneText(
.map(character => formatCharacterLine(character))
.filter(Boolean)
.join('\n')
const parts = [
String(scene.style || '').trim(),
String(scene.settings || '').trim(),
setBlock,
characterBlock,
String(scene.prompt || '').trim()
].filter(Boolean)
const blocks: Record<PromptSequenceSectionKey, string> = {
style: String(scene.style || '').trim(),
settings: String(scene.settings || '').trim(),
set: setBlock,
characters: characterBlock,
prompt: String(scene.prompt || '').trim()
}
const order = normalizeSectionOrder(sectionOrder)
const parts = order.map(key => blocks[key]).filter(Boolean)
return parts.join('\n\n')
}
@@ -233,6 +265,7 @@ export function assembleSceneText(
* Sequence name is never included.
*/
export function assembleSequenceText(sequence: {
sectionOrder?: PromptSequenceSectionKey[] | unknown
characters?: PromptSequenceCharacter[]
sets?: PromptSequenceSet[]
scenes?: Array<{
@@ -246,11 +279,13 @@ export function assembleSequenceText(sequence: {
}) {
const characters = sequence.characters || []
const sets = sequence.sets || []
const sectionOrder = normalizeSectionOrder(sequence.sectionOrder)
return (sequence.scenes || [])
.map(scene => assembleSceneText(
scene,
characters.length ? characters : undefined,
sets.length ? sets : undefined
sets.length ? sets : undefined,
sectionOrder
))
.filter(Boolean)
.join('\n\n\n')
@@ -478,6 +513,7 @@ export function normalizePromptSequence(raw: unknown): PromptSequence | null {
return {
id: String(rec.id || '').trim() || newId(),
name,
sectionOrder: normalizeSectionOrder(rec.sectionOrder),
characters,
sets,
scenes,