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>
193 lines
6.7 KiB
TypeScript
193 lines
6.7 KiB
TypeScript
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
import { join } from 'node:path'
|
|
import { ownerRoot } from '~/server/utils/library'
|
|
import {
|
|
normalizePromptSequence,
|
|
normalizePromptSequenceName,
|
|
normalizeCharacter,
|
|
normalizeSet,
|
|
normalizeScene,
|
|
normalizeSectionOrder,
|
|
emptyCharacter,
|
|
emptyScene,
|
|
DEFAULT_PROMPT_SEQUENCE_SECTION_ORDER,
|
|
type PromptSequence,
|
|
type PromptSequenceCharacter,
|
|
type PromptSequenceSectionKey,
|
|
type PromptSequenceSet,
|
|
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 normalizeCharactersInput(raw: unknown): PromptSequenceCharacter[] {
|
|
if (!Array.isArray(raw) || !raw.length) return [emptyCharacter()]
|
|
return raw.map(normalizeCharacter)
|
|
}
|
|
|
|
function normalizeSetsInput(raw: unknown): PromptSequenceSet[] {
|
|
if (!Array.isArray(raw)) return []
|
|
return raw.map(normalizeSet)
|
|
}
|
|
|
|
function normalizeScenesInput(
|
|
raw: unknown,
|
|
characters: PromptSequenceCharacter[],
|
|
sets: PromptSequenceSet[]
|
|
): PromptSequenceScene[] {
|
|
if (!Array.isArray(raw) || !raw.length) return [emptyScene(1, characters, sets)]
|
|
return raw.map((scene, index) => normalizeScene(scene, index, characters, sets))
|
|
}
|
|
|
|
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
|
|
sectionOrder?: unknown
|
|
characters?: unknown
|
|
sets?: unknown
|
|
scenes?: unknown
|
|
overwrite?: boolean
|
|
}) {
|
|
const name = normalizePromptSequenceName(input.name)
|
|
if (!name) throw createError({ statusCode: 400, statusMessage: 'A sequence name is required' })
|
|
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())
|
|
if (existing && !input.overwrite) {
|
|
throw createError({ statusCode: 409, statusMessage: `Overwrite sequence “${existing.name}”?` })
|
|
}
|
|
if (existing) {
|
|
existing.sectionOrder = sectionOrder
|
|
existing.characters = characters
|
|
existing.sets = sets
|
|
existing.scenes = scenes
|
|
existing.updatedAt = now
|
|
return existing
|
|
}
|
|
const created: PromptSequence = {
|
|
id: crypto.randomUUID(),
|
|
name,
|
|
sectionOrder,
|
|
characters,
|
|
sets,
|
|
scenes,
|
|
createdAt: now,
|
|
updatedAt: now
|
|
}
|
|
sequences.unshift(created)
|
|
return created
|
|
})
|
|
}
|
|
|
|
export async function updatePromptSequence(owner: string, id: string, input: {
|
|
name?: string
|
|
sectionOrder?: unknown
|
|
characters?: unknown
|
|
sets?: unknown
|
|
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.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)
|
|
: existing.characters
|
|
const sets = input.sets !== undefined
|
|
? normalizeSetsInput(input.sets)
|
|
: (existing.sets || [])
|
|
const scenes = input.scenes !== undefined
|
|
? normalizeScenesInput(input.scenes, characters, sets)
|
|
: existing.scenes.map((scene, index) => normalizeScene(scene, index, characters, sets))
|
|
existing.characters = characters
|
|
existing.sets = sets
|
|
existing.scenes = 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
|
|
})
|
|
}
|