Join pre/prompt/post for Comfy, stop zero-size generates snapping to a small square, and give the phone library a real closeable preview.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Towsty
2026-09-02 20:29:44 -05:00
co-authored by Cursor
parent d471d1bdd6
commit 369eb595d7
11 changed files with 76 additions and 28 deletions
+3 -3
View File
@@ -6686,7 +6686,7 @@ async function editImage() {
if (inputStillId.value) body.append('stillId', inputStillId.value) if (inputStillId.value) body.append('stillId', inputStillId.value)
if (extraPasses.length) body.append('passes', JSON.stringify(extraPasses)) if (extraPasses.length) body.append('passes', JSON.stringify(extraPasses))
body.append('passMode', imagePassMode.value) body.append('passMode', imagePassMode.value)
if (imageLoraStack.value.length) body.append('loraStack', JSON.stringify(imageLoraStack.value)) body.append('loraStack', JSON.stringify(imageLoraStack.value))
const downloadName = clipFileName(clipName.value.trim() || autoOutputName.value) const downloadName = clipFileName(clipName.value.trim() || autoOutputName.value)
const started = await $fetch<{ const started = await $fetch<{
jobId: string jobId: string
@@ -6942,7 +6942,7 @@ async function editImageV2() {
return return
} }
} }
if (v2Stack.length) body.append('loraStack', JSON.stringify(v2Stack)) body.append('loraStack', JSON.stringify(v2Stack))
const downloadName = clipFileName(clipName.value.trim() || autoOutputName.value) const downloadName = clipFileName(clipName.value.trim() || autoOutputName.value)
const started = await $fetch<{ const started = await $fetch<{
jobId: string jobId: string
@@ -7154,7 +7154,7 @@ async function generate() {
const shotLoraStacksList = shots const shotLoraStacksList = shots
? shots.map(shot => shotLoraStacks.value[shot.n] || []) ? shots.map(shot => shotLoraStacks.value[shot.n] || [])
: [shotLoraStacks.value[1] || [], ...queued.map(item => item.loraStack || [])] : [shotLoraStacks.value[1] || [], ...queued.map(item => item.loraStack || [])]
if (videoLoraStack.value.length) body.append('loraStack', JSON.stringify(videoLoraStack.value)) body.append('loraStack', JSON.stringify(videoLoraStack.value))
body.append('shotLoraStacks', JSON.stringify(shotLoraStacksList)) body.append('shotLoraStacks', JSON.stringify(shotLoraStacksList))
if (activeDraftId) body.append('draftId', activeDraftId) if (activeDraftId) body.append('draftId', activeDraftId)
if (queued.length) { if (queued.length) {
+25 -2
View File
@@ -200,8 +200,23 @@
</template> </template>
</section> </section>
<aside class="glass w-full shrink-0 overflow-y-auto rounded-3xl p-4 sm:p-5 lg:h-full lg:w-[22rem]"> <aside
class="glass overflow-y-auto p-4 sm:p-5"
:class="focused
? 'fixed inset-0 z-[60] rounded-none lg:static lg:z-auto lg:h-full lg:w-[22rem] lg:shrink-0 lg:rounded-3xl'
: 'hidden w-full shrink-0 rounded-3xl lg:block lg:h-full lg:w-[22rem]'"
>
<template v-if="focused"> <template v-if="focused">
<div class="mb-3 flex items-center justify-between lg:hidden">
<p class="text-sm text-zinc-400">Preview</p>
<button
type="button"
class="rounded-full border border-white/20 bg-zinc-950 px-3 py-1.5 text-sm text-amber-100"
@click="clearFocus"
>
Close
</button>
</div>
<div class="relative overflow-hidden rounded-2xl bg-zinc-900"> <div class="relative overflow-hidden rounded-2xl bg-zinc-900">
<span v-if="detailsHidden" class="sensitive-glow block aspect-video w-full" /> <span v-if="detailsHidden" class="sensitive-glow block aspect-video w-full" />
<video <video
@@ -494,7 +509,7 @@ const previewVideo = computed(() => {
const previewStillSrc = computed(() => { const previewStillSrc = computed(() => {
if (focused.value?.kind !== 'still' || !focused.value.stillGroup) return '' if (focused.value?.kind !== 'still' || !focused.value.stillGroup) return ''
const id = focusedPartId.value || focused.value.stillGroup.head.id const id = focusedPartId.value || focused.value.stillGroup.head.id
return `/api/library/stills/${id}?thumb=1` return `/api/library/stills/${id}`
}) })
const focusedParts = computed(() => { const focusedParts = computed(() => {
if (focused.value?.kind === 'clip') return focused.value.group?.parts || [] if (focused.value?.kind === 'clip') return focused.value.group?.parts || []
@@ -719,6 +734,13 @@ function clearSelection() {
lastSelectedKey.value = '' lastSelectedKey.value = ''
} }
function clearFocus() {
focusedKey.value = ''
focusedPartId.value = ''
detailsHidden.value = false
cancelRename()
}
function selectAll() { function selectAll() {
selectedKeys.value = entries.value.map(entry => entry.key) selectedKeys.value = entries.value.map(entry => entry.key)
lastSelectedKey.value = selectedKeys.value.at(-1) || '' lastSelectedKey.value = selectedKeys.value.at(-1) || ''
@@ -978,6 +1000,7 @@ function onKey(event: KeyboardEvent) {
if (event.key === 'Escape') { if (event.key === 'Escape') {
if (pendingDelete.value) pendingDelete.value = null if (pendingDelete.value) pendingDelete.value = null
else if (renaming.value) cancelRename() else if (renaming.value) cancelRename()
else if (focusedKey.value) clearFocus()
else clearSelection() else clearSelection()
} }
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'a' && !renaming.value) { if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'a' && !renaming.value) {
+4 -2
View File
@@ -4,6 +4,7 @@ import { assertImageScaleToTotalPixelsNode, comfyConfigured } from '~/server/uti
import { imageDimensions } from '~/server/utils/resolution' import { imageDimensions } from '~/server/utils/resolution'
import { parseImageAspect } from '~/utils/imageAspect' import { parseImageAspect } from '~/utils/imageAspect'
import { clampImageCfg, clampImageScaleMegapixels, clampImageSteps, parseImagePassMode, parseImageScaleToTotalPixels } from '~/utils/generationPresets' import { clampImageCfg, clampImageScaleMegapixels, clampImageSteps, parseImagePassMode, parseImageScaleToTotalPixels } from '~/utils/generationPresets'
import { joinPromptParts } from '~/utils/promptParts'
function parsePasses(raw: string | undefined) { function parsePasses(raw: string | undefined) {
if (!raw) return [] as { prompt: string }[] if (!raw) return [] as { prompt: string }[]
@@ -33,16 +34,17 @@ export default defineEventHandler(async (event) => {
} }
} }
const prompt = (fields.prompt || '').trim() const promptMid = (fields.prompt || '').trim()
const promptPre = (fields.promptPre || '').trim() const promptPre = (fields.promptPre || '').trim()
const promptPost = (fields.promptPost || '').trim() const promptPost = (fields.promptPost || '').trim()
const prompt = joinPromptParts(promptPre, promptMid, promptPost)
const extraPasses = parsePasses(fields.passes) const extraPasses = parsePasses(fields.passes)
const passMode = parseImagePassMode(fields.passMode) const passMode = parseImagePassMode(fields.passMode)
const aspect = parseImageAspect(fields.aspect) const aspect = parseImageAspect(fields.aspect)
if (!image) { if (!image) {
throw createError({ statusCode: 400, statusMessage: 'An input image is required' }) throw createError({ statusCode: 400, statusMessage: 'An input image is required' })
} }
if (!prompt && aspect === 'auto') { if (!promptMid && aspect === 'auto') {
throw createError({ statusCode: 400, statusMessage: 'An edit prompt is required' }) throw createError({ statusCode: 400, statusMessage: 'An edit prompt is required' })
} }
if (extraPasses.some(item => !item.prompt)) { if (extraPasses.some(item => !item.prompt)) {
+2 -2
View File
@@ -4,7 +4,7 @@ import { listStudioLoras, parsePostedLoraStack, parseShotLoraStacks, persistLora
import { clampVideoCfg } from '~/utils/generationPresets' import { clampVideoCfg } from '~/utils/generationPresets'
import { defaultVideoSteps, isLtxWorkflow, isTextToVideo, LTX_DISABLED_MESSAGE, ltxWorkflowEnabled, parseVideoWorkflow, videoEngineOf } from '~/utils/videoModels' import { defaultVideoSteps, isLtxWorkflow, isTextToVideo, LTX_DISABLED_MESSAGE, ltxWorkflowEnabled, parseVideoWorkflow, videoEngineOf } from '~/utils/videoModels'
import { allowIdentityRefs, normalizePermanenceRefs, resolveGlobalLocks, type PermanenceRef } from '~/utils/globalLocks' import { allowIdentityRefs, normalizePermanenceRefs, resolveGlobalLocks, type PermanenceRef } from '~/utils/globalLocks'
import { composePromptParts, persistPromptWrappers } from '~/utils/promptParts' import { composePromptParts, joinPromptParts, persistPromptWrappers } from '~/utils/promptParts'
import type { QueuedExtension } from '~/server/utils/library' import type { QueuedExtension } from '~/server/utils/library'
function parseDuration(raw: unknown) { function parseDuration(raw: unknown) {
@@ -150,7 +150,7 @@ export default defineEventHandler(async (event) => {
throw createError({ statusCode: 400, statusMessage: 'A motion prompt is required' }) throw createError({ statusCode: 400, statusMessage: 'A motion prompt is required' })
} }
const resolvedLocks = resolveGlobalLocks(fields.globalLocks || '', promptMid) const resolvedLocks = resolveGlobalLocks(fields.globalLocks || '', promptMid)
const prompt = resolvedLocks.prompt const prompt = joinPromptParts(promptPre, resolvedLocks.prompt, promptPost)
const globalLocks = resolvedLocks.locks const globalLocks = resolvedLocks.locks
const extensions = parseExtensions(fields.extensions) const extensions = parseExtensions(fields.extensions)
const permanenceRefs = parseFamilyPermanenceRefs(fields.permanenceRefs) const permanenceRefs = parseFamilyPermanenceRefs(fields.permanenceRefs)
+4 -2
View File
@@ -26,6 +26,7 @@ import {
type ImageV2Task type ImageV2Task
} from '~/utils/imageV2' } from '~/utils/imageV2'
import { isXaigenStudio } from '~/utils/videoModels' import { isXaigenStudio } from '~/utils/videoModels'
import { joinPromptParts } from '~/utils/promptParts'
import { listStudioLoras, parsePostedLoraStack, persistLoraFields, assertImageV2LoraStack } from '~/server/utils/loras' import { listStudioLoras, parsePostedLoraStack, persistLoraFields, assertImageV2LoraStack } from '~/server/utils/loras'
import { getStill, rememberInputStill, stillPath } from '~/server/utils/library' import { getStill, rememberInputStill, stillPath } from '~/server/utils/library'
import { existsSync, readFileSync } from 'node:fs' import { existsSync, readFileSync } from 'node:fs'
@@ -135,13 +136,14 @@ export default defineEventHandler(async (event) => {
fields.task, fields.task,
mode === 'generate' ? 't2i' : mode === 'refine' ? 'refine' : 'scene' mode === 'generate' ? 't2i' : mode === 'refine' ? 'refine' : 'scene'
) )
const prompt = String(fields.prompt || '').trim() const promptMid = String(fields.prompt || '').trim()
const promptPre = String(fields.promptPre || '').trim() const promptPre = String(fields.promptPre || '').trim()
const promptPost = String(fields.promptPost || '').trim() const promptPost = String(fields.promptPost || '').trim()
const prompt = joinPromptParts(promptPre, promptMid, promptPost)
const extraPasses = parsePasses(fields.passes) const extraPasses = parsePasses(fields.passes)
const passMode = parseImagePassMode(fields.passMode) const passMode = parseImagePassMode(fields.passMode)
const aspect = parseImageAspect(fields.aspect) const aspect = parseImageAspect(fields.aspect)
if (!prompt && (mode === 'generate' || aspect === 'auto')) { if (!promptMid && (mode === 'generate' || aspect === 'auto')) {
throw createError({ statusCode: 400, statusMessage: 'A prompt is required' }) throw createError({ statusCode: 400, statusMessage: 'A prompt is required' })
} }
if (extraPasses.length && (mode === 'refine' || mode === 'generate')) { if (extraPasses.length && (mode === 'refine' || mode === 'generate')) {
+2 -2
View File
@@ -8,7 +8,7 @@ import { imageDimensions } from '~/server/utils/resolution'
import { emitChainJob } from '~/server/utils/watch' import { emitChainJob } from '~/server/utils/watch'
import { saveStill, stillChainName } from '~/server/utils/library' import { saveStill, stillChainName } from '~/server/utils/library'
import { comfyJobPrefix } from '~/utils/outputNames' import { comfyJobPrefix } from '~/utils/outputNames'
import { composePromptParts } from '~/utils/promptParts' import { joinPromptParts } from '~/utils/promptParts'
export type EditImageFile = { filename: string; data: Buffer; type?: string } export type EditImageFile = { filename: string; data: Buffer; type?: string }
@@ -68,7 +68,7 @@ export async function runEdit(job: Job, params: EditRunParams) {
if (job.status === 'cancelled') throw new Error('Job interrupted.') if (job.status === 'cancelled') throw new Error('Job interrupted.')
if (index > 0 && library.stopAfterCurrent === true) break if (index > 0 && library.stopAfterCurrent === true) break
const mid = (batch || index === 0) ? composeFillPrompt(prompts[index], filled.padded) : prompts[index] const mid = (batch || index === 0) ? composeFillPrompt(prompts[index], filled.padded) : prompts[index]
const prompt = composePromptParts(library.promptPre || '', mid, library.promptPost || '') const prompt = joinPromptParts(library.promptPre, mid, library.promptPost)
const last = index === prompts.length - 1 const last = index === prompts.length - 1
if (index > 0) { if (index > 0) {
await ensureComfyReady((status) => { await ensureComfyReady((status) => {
+4 -3
View File
@@ -8,7 +8,7 @@ import { imageDimensions } from '~/server/utils/resolution'
import { emitChainJob } from '~/server/utils/watch' import { emitChainJob } from '~/server/utils/watch'
import { saveStill, stillChainName } from '~/server/utils/library' import { saveStill, stillChainName } from '~/server/utils/library'
import { comfyJobPrefix } from '~/utils/outputNames' import { comfyJobPrefix } from '~/utils/outputNames'
import { composePromptParts } from '~/utils/promptParts' import { joinPromptParts } from '~/utils/promptParts'
import { parseImageV2Engine, type ImageV2Engine, type ImageV2Mode, type ImageV2Task } from '~/utils/imageV2' import { parseImageV2Engine, type ImageV2Engine, type ImageV2Mode, type ImageV2Task } from '~/utils/imageV2'
import type { EditImageFile } from '~/server/utils/imageChain' import type { EditImageFile } from '~/server/utils/imageChain'
@@ -195,7 +195,7 @@ export async function runEditV2(job: Job, params: EditV2RunParams) {
mode: params.mode, mode: params.mode,
engine, engine,
task: params.task, task: params.task,
prompt: composePromptParts(library.promptPre || '', prompt, library.promptPost || ''), prompt: joinPromptParts(library.promptPre, prompt, library.promptPost),
negative: params.negative, negative: params.negative,
imageAName: uploaded?.name, imageAName: uploaded?.name,
imageBName: uploadedRef?.name, imageBName: uploadedRef?.name,
@@ -211,13 +211,14 @@ export async function runEditV2(job: Job, params: EditV2RunParams) {
megapixels: params.megapixels, megapixels: params.megapixels,
width: params.width, width: params.width,
height: params.height, height: params.height,
aspect: params.aspect,
turbo: params.turbo === true, turbo: params.turbo === true,
filenamePrefix: chainTotal > 1 ? `${comfyJobPrefix(job.id)}_p${index + 1}` : comfyJobPrefix(job.id), filenamePrefix: chainTotal > 1 ? `${comfyJobPrefix(job.id)}_p${index + 1}` : comfyJobPrefix(job.id),
kreaUnetName: krea?.unet, kreaUnetName: krea?.unet,
kreaClipName: krea?.clip, kreaClipName: krea?.clip,
kreaVaeName: krea?.vae, kreaVaeName: krea?.vae,
kreaConceptLora: krea?.conceptLora, kreaConceptLora: krea?.conceptLora,
loraStack: params.loraStack loraStack: params.loraStack?.length ? params.loraStack : (library.loraStack || undefined)
}) })
const queued = await queuePrompt(built.graph, job.clientId) const queued = await queuePrompt(built.graph, job.clientId)
job.promptId = queued.prompt_id job.promptId = queued.prompt_id
+3 -2
View File
@@ -70,6 +70,7 @@ export interface ImageV2BuildParams {
strength?: number strength?: number
width?: number width?: number
height?: number height?: number
aspect?: string
turbo?: boolean turbo?: boolean
snofsModel?: number snofsModel?: number
snofsClip?: number snofsClip?: number
@@ -458,7 +459,7 @@ function buildKreaGenerateWorkflow(params: ImageV2BuildParams) {
const graph = structuredClone(kreaGenerateTemplate) as WorkflowGraph const graph = structuredClone(kreaGenerateTemplate) as WorkflowGraph
const prompt = composeImageV2Prompt('generate', 't2i', params.prompt) const prompt = composeImageV2Prompt('generate', 't2i', params.prompt)
const negative = String(params.negative || '') const negative = String(params.negative || '')
const size = clampImageV2Size(params.width, params.height) const size = clampImageV2Size(params.width, params.height, params.aspect)
const steps = clampImageSteps(params.steps, IMAGE_V2_KREA_STEPS) const steps = clampImageSteps(params.steps, IMAGE_V2_KREA_STEPS)
const cfg = clampImageCfg(params.cfg, IMAGE_V2_KREA_CFG) const cfg = clampImageCfg(params.cfg, IMAGE_V2_KREA_CFG)
const workflowFile = IMAGE_V2_KREA_GENERATE_WORKFLOW const workflowFile = IMAGE_V2_KREA_GENERATE_WORKFLOW
@@ -520,7 +521,7 @@ export function buildImageV2Workflow(params: ImageV2BuildParams) {
const steps = clampImageSteps(params.steps, 24) const steps = clampImageSteps(params.steps, 24)
const cfg = clampImageCfg(params.cfg, 4) const cfg = clampImageCfg(params.cfg, 4)
const strength = refine ? clampImageV2Denoise(params.strength, IMAGE_V2_DENOISE_DEFAULT) : undefined const strength = refine ? clampImageV2Denoise(params.strength, IMAGE_V2_DENOISE_DEFAULT) : undefined
const size = generate ? clampImageV2Size(params.width, params.height) : null const size = generate ? clampImageV2Size(params.width, params.height, params.aspect) : null
const workflowFile = imageV2WorkflowFile(params.mode, engine) const workflowFile = imageV2WorkflowFile(params.mode, engine)
if (!generate) setInput(graph, LOAD_A, 'image', params.imageAName || '') if (!generate) setInput(graph, LOAD_A, 'image', params.imageAName || '')
+7 -4
View File
@@ -22,7 +22,7 @@ import {
updateShotQueue updateShotQueue
} from '~/server/utils/shotQueue' } from '~/server/utils/shotQueue'
import { composeShotPrompt, allowIdentityRefs, type PermanenceRef } from '~/utils/globalLocks' import { composeShotPrompt, allowIdentityRefs, type PermanenceRef } from '~/utils/globalLocks'
import { composePromptParts, resolvePromptWrappers } from '~/utils/promptParts' import { composePromptParts, joinPromptParts, resolvePromptWrappers } from '~/utils/promptParts'
import type { QueuedExtension } from '~/server/utils/library' import type { QueuedExtension } from '~/server/utils/library'
import { persistLoraFields, ensureComfyLoraNames } from '~/server/utils/loras' import { persistLoraFields, ensureComfyLoraNames } from '~/server/utils/loras'
import { readLoraStack, resolveLoraStack } from '~/utils/loras' import { readLoraStack, resolveLoraStack } from '~/utils/loras'
@@ -150,7 +150,10 @@ export async function queueMiniMax(
job.status = 'uploading' job.status = 'uploading'
const chainIndex = job.library?.chainIndex || 0 const chainIndex = job.library?.chainIndex || 0
const graphId = chainIndex > 0 ? workflowForExtension(params.workflow) : params.workflow const graphId = chainIndex > 0 ? workflowForExtension(params.workflow) : params.workflow
if (job.library) Object.assign(job.library, persistLoraFields(params.loraStack || params.loraName || job.library.loraStack || job.library.loraName)) if (job.library) {
const stack = resolveLoraStack(job.library.loraStack || job.library.loraName, params.loraStack || params.loraName)
if (stack.length) Object.assign(job.library, persistLoraFields(stack))
}
const engineName = isLtxWorkflow(graphId) ? 'LTX-2.3' : 'MiniMax H3' const engineName = isLtxWorkflow(graphId) ? 'LTX-2.3' : 'MiniMax H3'
const hasImage = Boolean(params.image?.data?.length) const hasImage = Boolean(params.image?.data?.length)
const uploading = !hasImage const uploading = !hasImage
@@ -188,7 +191,7 @@ export async function queueMiniMax(
const shotIndex = job.library?.chainIndex || 0 const shotIndex = job.library?.chainIndex || 0
const composedPrompt = composeShotPrompt({ const composedPrompt = composeShotPrompt({
globalLocks: job.library?.globalLocks || params.globalLocks, globalLocks: job.library?.globalLocks || params.globalLocks,
prompt: composePromptParts(job.library?.promptPre || '', params.prompt, job.library?.promptPost || ''), prompt: joinPromptParts(job.library?.promptPre, params.prompt, job.library?.promptPost),
shotIndex, shotIndex,
familyRefs: job.library?.permanenceRefs || params.permanenceRefs, familyRefs: job.library?.permanenceRefs || params.permanenceRefs,
shotRefs: job.library?.shotPermanenceRefs?.[shotIndex] || params.shotPermanenceRefs?.[shotIndex] shotRefs: job.library?.shotPermanenceRefs?.[shotIndex] || params.shotPermanenceRefs?.[shotIndex]
@@ -417,7 +420,7 @@ export async function continueQueuedExtensions(
break break
} }
await queueMiniMax(job, { await queueMiniMax(job, {
prompt: ext.prompt, prompt: joinPromptParts(wrappers.promptPre, ext.prompt, wrappers.promptPost),
image: { filename: 'last_frame.png', data: frame, type: 'image/png' }, image: { filename: 'last_frame.png', data: frame, type: 'image/png' },
width: params.width, width: params.width,
height: params.height, height: params.height,
+1 -1
View File
@@ -126,7 +126,7 @@ export function clampImageV2Denoise(raw: unknown, fallback = IMAGE_V2_DENOISE_DE
export function snapImageV2Side(raw: unknown, fallback = IMAGE_V2_GENERATE_WIDTH) { export function snapImageV2Side(raw: unknown, fallback = IMAGE_V2_GENERATE_WIDTH) {
const value = Number(raw) const value = Number(raw)
if (!Number.isFinite(value)) return fallback if (!Number.isFinite(value) || value <= 0) return fallback
return IMAGE_V2_SIZE_SIDES.reduce((best, side) => ( return IMAGE_V2_SIZE_SIDES.reduce((best, side) => (
Math.abs(side - value) < Math.abs(best - value) ? side : best Math.abs(side - value) < Math.abs(best - value) ? side : best
), IMAGE_V2_SIZE_SIDES[0]) ), IMAGE_V2_SIZE_SIDES[0])
+19 -3
View File
@@ -15,6 +15,16 @@ export function composePromptParts(pre: string, prompt: string, post: string) {
.join('\n\n') .join('\n\n')
} }
/** Combine wrappers for Comfy. If mid already includes pre/post, do not wrap twice. */
export function joinPromptParts(pre?: string, mid?: string, post?: string) {
const parts = restorePromptParts(mid || '', {
pre: pre || '',
prompt: mid || '',
post: post || ''
})
return composePromptParts(parts.pre, parts.prompt, parts.post)
}
export function applyPromptParts(parts: PromptParts, middle: string) { export function applyPromptParts(parts: PromptParts, middle: string) {
return composePromptParts(parts.pre, middle, parts.post) return composePromptParts(parts.pre, middle, parts.post)
} }
@@ -45,7 +55,7 @@ function hasWrapperKey(item: object | null | undefined, key: 'promptPre' | 'prom
return Boolean(item && Object.prototype.hasOwnProperty.call(item, key)) return Boolean(item && Object.prototype.hasOwnProperty.call(item, key))
} }
/** First source that actually sent the key wins, including empty string. Omitted keys fall through. */ /** First non-empty wrapper wins. Blank strings fall through so a shot can inherit family pre/post. */
export function resolvePromptWrappers( export function resolvePromptWrappers(
...sources: Array<PromptWrapperFields | null | undefined> ...sources: Array<PromptWrapperFields | null | undefined>
) { ) {
@@ -56,13 +66,19 @@ export function resolvePromptWrappers(
for (const source of sources) { for (const source of sources) {
if (!source) continue if (!source) continue
if (!sawPre && hasWrapperKey(source, 'promptPre') && source.promptPre != null) { if (!sawPre && hasWrapperKey(source, 'promptPre') && source.promptPre != null) {
promptPre = sanitizePromptPart(source.promptPre) const value = sanitizePromptPart(source.promptPre)
if (value.trim()) {
promptPre = value
sawPre = true sawPre = true
} }
}
if (!sawPost && hasWrapperKey(source, 'promptPost') && source.promptPost != null) { if (!sawPost && hasWrapperKey(source, 'promptPost') && source.promptPost != null) {
promptPost = sanitizePromptPart(source.promptPost) const value = sanitizePromptPart(source.promptPost)
if (value.trim()) {
promptPost = value
sawPost = true sawPost = true
} }
}
if (sawPre && sawPost) break if (sawPre && sawPost) break
} }
return { promptPre, promptPost } return { promptPre, promptPost }