Add clickable keep-prompt snippets for video and image.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,91 @@
|
|||||||
|
<template>
|
||||||
|
<div ref="root" class="relative shrink-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex h-7 w-7 items-center justify-center rounded-full border border-white/15 text-lg leading-none text-amber-200 hover:border-amber-300/50 hover:text-amber-100 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
:disabled="disabled"
|
||||||
|
:title="items.length ? 'Add a saved prompt' : 'Add snippets in Settings'"
|
||||||
|
:aria-expanded="open"
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-label="Add saved prompt"
|
||||||
|
@click.stop="open = !open"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
v-if="open"
|
||||||
|
class="absolute top-full z-30 mt-1 w-72 max-w-[calc(100vw-2rem)] overflow-hidden rounded-2xl border border-white/10 bg-zinc-950 shadow-2xl"
|
||||||
|
:class="align === 'left' ? 'left-0' : 'right-0'"
|
||||||
|
role="menu"
|
||||||
|
>
|
||||||
|
<p class="px-3 pt-2 text-[10px] uppercase tracking-wide text-zinc-500">{{ heading }}</p>
|
||||||
|
<p v-if="!items.length" class="px-3 py-3 text-xs text-zinc-500">
|
||||||
|
No saved snippets yet. Add video and image lists in Settings.
|
||||||
|
</p>
|
||||||
|
<ul v-else class="max-h-64 overflow-y-auto p-1">
|
||||||
|
<li v-for="item in items" :key="item.id">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex w-full flex-col items-start rounded-xl px-3 py-2 text-left hover:bg-white/5"
|
||||||
|
role="menuitem"
|
||||||
|
@click="pick(item)"
|
||||||
|
>
|
||||||
|
<span class="text-xs font-medium text-amber-100">{{ item.label }}</span>
|
||||||
|
<span class="mt-0.5 line-clamp-2 whitespace-pre-wrap text-[11px] text-zinc-500">{{ item.text }}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { KeepPrompt } from '~/utils/keepPrompts'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
items: KeepPrompt[]
|
||||||
|
heading?: string
|
||||||
|
disabled?: boolean
|
||||||
|
align?: 'left' | 'right'
|
||||||
|
}>(), {
|
||||||
|
heading: 'Saved prompts',
|
||||||
|
disabled: false,
|
||||||
|
align: 'right'
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
insert: [item: KeepPrompt]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const root = ref<HTMLElement | null>(null)
|
||||||
|
const open = ref(false)
|
||||||
|
|
||||||
|
function pick(item: KeepPrompt) {
|
||||||
|
emit('insert', item)
|
||||||
|
open.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDocClick(event: MouseEvent) {
|
||||||
|
const target = event.target as Node | null
|
||||||
|
if (root.value && target && root.value.contains(target)) return
|
||||||
|
open.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKey(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Escape') open.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.disabled, (value) => {
|
||||||
|
if (value) open.value = false
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener('click', onDocClick)
|
||||||
|
window.addEventListener('keydown', onKey)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('click', onDocClick)
|
||||||
|
window.removeEventListener('keydown', onKey)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -5,7 +5,16 @@
|
|||||||
class="fixed inset-0 z-50 flex flex-col bg-zinc-950 px-4 pb-4 pt-[max(1rem,env(safe-area-inset-top))]"
|
class="fixed inset-0 z-50 flex flex-col bg-zinc-950 px-4 pb-4 pt-[max(1rem,env(safe-area-inset-top))]"
|
||||||
>
|
>
|
||||||
<div class="mb-3 flex items-center justify-between gap-3">
|
<div class="mb-3 flex items-center justify-between gap-3">
|
||||||
<h2 class="min-w-0 truncate font-display text-lg font-bold">{{ title }}</h2>
|
<div class="flex min-w-0 items-center gap-2">
|
||||||
|
<h2 class="min-w-0 truncate font-display text-lg font-bold">{{ title }}</h2>
|
||||||
|
<KeepPromptMenu
|
||||||
|
v-if="editable"
|
||||||
|
:items="keepItems"
|
||||||
|
:heading="keepHeading"
|
||||||
|
align="left"
|
||||||
|
@insert="onKeepInsert"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="shrink-0 rounded-full border border-white/15 px-3 py-1 text-sm text-zinc-300 hover:text-white"
|
class="shrink-0 rounded-full border border-white/15 px-3 py-1 text-sm text-zinc-300 hover:text-white"
|
||||||
@@ -30,17 +39,23 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { insertKeepSnippet, type KeepPrompt } from '~/utils/keepPrompts'
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
open: boolean
|
open: boolean
|
||||||
title?: string
|
title?: string
|
||||||
text?: string
|
text?: string
|
||||||
editable?: boolean
|
editable?: boolean
|
||||||
placeholder?: string
|
placeholder?: string
|
||||||
|
keepItems?: KeepPrompt[]
|
||||||
|
keepHeading?: string
|
||||||
}>(), {
|
}>(), {
|
||||||
title: 'Prompt',
|
title: 'Prompt',
|
||||||
text: '',
|
text: '',
|
||||||
editable: true,
|
editable: true,
|
||||||
placeholder: ''
|
placeholder: '',
|
||||||
|
keepItems: () => [],
|
||||||
|
keepHeading: 'Saved prompts'
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -52,6 +67,10 @@ function onInput(event: Event) {
|
|||||||
emit('update:text', (event.target as HTMLTextAreaElement).value)
|
emit('update:text', (event.target as HTMLTextAreaElement).value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onKeepInsert(item: KeepPrompt) {
|
||||||
|
emit('update:text', insertKeepSnippet(props.text || '', item.text))
|
||||||
|
}
|
||||||
|
|
||||||
function close() {
|
function close() {
|
||||||
emit('update:open', false)
|
emit('update:open', false)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -192,6 +192,63 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="mt-8 space-y-4">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-semibold uppercase tracking-wider text-zinc-500">Keep prompts</h3>
|
||||||
|
<p class="mt-1 text-sm text-zinc-400">
|
||||||
|
Saved snippets for the + button next to the studio prompt. Video and image lists are separate.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div v-for="kind in keepKinds" :key="kind.id" class="space-y-2">
|
||||||
|
<p class="text-xs font-medium uppercase tracking-wide text-zinc-500">{{ kind.label }}</p>
|
||||||
|
<div
|
||||||
|
v-for="item in keepPrompts[kind.id]"
|
||||||
|
:key="item.id"
|
||||||
|
class="rounded-2xl border border-white/10 bg-zinc-950/50 p-3 space-y-2"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
v-model="item.label"
|
||||||
|
class="min-w-0 flex-1 rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm"
|
||||||
|
placeholder="Short name"
|
||||||
|
maxlength="40"
|
||||||
|
@change="saveKeepKind(kind.id)"
|
||||||
|
>
|
||||||
|
<button type="button" class="shrink-0 text-xs text-red-300 hover:text-red-200" @click="removeKeepItem(kind.id, item.id)">
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
v-model="item.text"
|
||||||
|
rows="3"
|
||||||
|
class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm"
|
||||||
|
placeholder="Text added to the prompt…"
|
||||||
|
maxlength="500"
|
||||||
|
@change="saveKeepKind(kind.id)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p v-if="!keepPrompts[kind.id].length" class="text-xs text-zinc-600">None yet.</p>
|
||||||
|
<form class="space-y-2 rounded-2xl border border-dashed border-white/15 p-3" @submit.prevent="addKeepItem(kind.id)">
|
||||||
|
<input
|
||||||
|
v-model="newKeep[kind.id].label"
|
||||||
|
class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm"
|
||||||
|
:placeholder="kind.id === 'video' ? 'e.g. Locked camera' : 'e.g. Soft daylight'"
|
||||||
|
maxlength="40"
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
v-model="newKeep[kind.id].text"
|
||||||
|
rows="2"
|
||||||
|
class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm"
|
||||||
|
placeholder="Snippet…"
|
||||||
|
maxlength="500"
|
||||||
|
/>
|
||||||
|
<button type="submit" class="rounded-xl bg-white/10 px-3 py-2 text-xs font-semibold text-white hover:bg-white/15">
|
||||||
|
Add {{ kind.label.toLowerCase() }} snippet
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="mt-8 space-y-3">
|
<section class="mt-8 space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-sm font-semibold uppercase tracking-wider text-zinc-500">Prompt helper</h3>
|
<h3 class="text-sm font-semibold uppercase tracking-wider text-zinc-500">Prompt helper</h3>
|
||||||
@@ -261,6 +318,15 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { defaultLoraFilenameKey, isFilenameSystemLora } from '~/utils/outputNames'
|
import { defaultLoraFilenameKey, isFilenameSystemLora } from '~/utils/outputNames'
|
||||||
import { loraDisplayName, loraIdentityKey } from '~/utils/loras'
|
import { loraDisplayName, loraIdentityKey } from '~/utils/loras'
|
||||||
|
import {
|
||||||
|
KEEP_PROMPT_MAX,
|
||||||
|
emptyKeepPrompts,
|
||||||
|
normalizeKeepPrompts,
|
||||||
|
sanitizeKeepPromptLabel,
|
||||||
|
sanitizeKeepPromptText,
|
||||||
|
type KeepPromptKind,
|
||||||
|
type KeepPromptLists
|
||||||
|
} from '~/utils/keepPrompts'
|
||||||
|
|
||||||
export interface SettingsFolder {
|
export interface SettingsFolder {
|
||||||
id: string
|
id: string
|
||||||
@@ -287,6 +353,15 @@ const emit = defineEmits<{
|
|||||||
const newName = ref('')
|
const newName = ref('')
|
||||||
const newPresetName = ref('')
|
const newPresetName = ref('')
|
||||||
const newPresetPrompt = ref('')
|
const newPresetPrompt = ref('')
|
||||||
|
const keepKinds: Array<{ id: KeepPromptKind; label: string }> = [
|
||||||
|
{ id: 'video', label: 'Video' },
|
||||||
|
{ id: 'image', label: 'Image' }
|
||||||
|
]
|
||||||
|
const keepPrompts = ref<KeepPromptLists>(emptyKeepPrompts())
|
||||||
|
const newKeep = reactive<Record<KeepPromptKind, { label: string; text: string }>>({
|
||||||
|
video: { label: '', text: '' },
|
||||||
|
image: { label: '', text: '' }
|
||||||
|
})
|
||||||
const promptPresets = ref<Array<{ id: string; name: string; description?: string; builtin?: boolean; systemPrompt?: string }>>([])
|
const promptPresets = ref<Array<{ id: string; name: string; description?: string; builtin?: boolean; systemPrompt?: string }>>([])
|
||||||
const loraRowsLoading = ref(true)
|
const loraRowsLoading = ref(true)
|
||||||
const loraRows = ref<Array<{ key: string; file: string; kind: 'image' | 'video' | 'both'; draft: string; trigger: string; placeholder: string }>>([])
|
const loraRows = ref<Array<{ key: string; file: string; kind: 'image' | 'video' | 'both'; draft: string; trigger: string; placeholder: string }>>([])
|
||||||
@@ -472,7 +547,12 @@ async function loadLoraKeys() {
|
|||||||
loraRowsLoading.value = true
|
loraRowsLoading.value = true
|
||||||
try {
|
try {
|
||||||
const listed = await $fetch<{ image?: string[]; video?: string[] }>('/api/loras').catch(() => ({ image: [], video: [] }))
|
const listed = await $fetch<{ image?: string[]; video?: string[] }>('/api/loras').catch(() => ({ image: [], video: [] }))
|
||||||
const library = await $fetch<{ loraAliases?: Record<string, string>; loraTriggers?: Record<string, string> }>('/api/library?light=1').catch(() => ({ loraAliases: {}, loraTriggers: {} }))
|
const library = await $fetch<{
|
||||||
|
loraAliases?: Record<string, string>
|
||||||
|
loraTriggers?: Record<string, string>
|
||||||
|
keepPrompts?: KeepPromptLists
|
||||||
|
}>('/api/library?light=1').catch(() => ({ loraAliases: {}, loraTriggers: {}, keepPrompts: emptyKeepPrompts() }))
|
||||||
|
keepPrompts.value = normalizeKeepPrompts(library.keepPrompts)
|
||||||
const aliases = library.loraAliases || {}
|
const aliases = library.loraAliases || {}
|
||||||
const triggers = library.loraTriggers || {}
|
const triggers = library.loraTriggers || {}
|
||||||
const map = new Map<string, { key: string; file: string; kind: 'image' | 'video' | 'both'; draft: string; trigger: string; placeholder: string }>()
|
const map = new Map<string, { key: string; file: string; kind: 'image' | 'video' | 'both'; draft: string; trigger: string; placeholder: string }>()
|
||||||
@@ -502,6 +582,47 @@ async function loadLoraKeys() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function saveKeepKind(kind: KeepPromptKind) {
|
||||||
|
try {
|
||||||
|
const data = await $fetch<{ keepPrompts?: KeepPromptLists }>('/api/library/keep-prompts', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: { kind, items: keepPrompts.value[kind] }
|
||||||
|
})
|
||||||
|
keepPrompts.value = normalizeKeepPrompts(data.keepPrompts)
|
||||||
|
emit('updated', data)
|
||||||
|
} catch (error: any) {
|
||||||
|
emit('error', error?.data?.statusMessage || 'Could not save keep prompts')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addKeepItem(kind: KeepPromptKind) {
|
||||||
|
const text = sanitizeKeepPromptText(newKeep[kind].text)
|
||||||
|
if (!text) {
|
||||||
|
emit('error', 'Snippet text is required')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (keepPrompts.value[kind].length >= KEEP_PROMPT_MAX) {
|
||||||
|
emit('error', `At most ${KEEP_PROMPT_MAX} snippets per list`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
keepPrompts.value[kind] = [
|
||||||
|
...keepPrompts.value[kind],
|
||||||
|
{
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
label: sanitizeKeepPromptLabel(newKeep[kind].label, text),
|
||||||
|
text
|
||||||
|
}
|
||||||
|
]
|
||||||
|
newKeep[kind].label = ''
|
||||||
|
newKeep[kind].text = ''
|
||||||
|
void saveKeepKind(kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeKeepItem(kind: KeepPromptKind, id: string) {
|
||||||
|
keepPrompts.value[kind] = keepPrompts.value[kind].filter(item => item.id !== id)
|
||||||
|
void saveKeepKind(kind)
|
||||||
|
}
|
||||||
|
|
||||||
async function saveLoraAlias(row: { key: string; draft: string }) {
|
async function saveLoraAlias(row: { key: string; draft: string }) {
|
||||||
try {
|
try {
|
||||||
emit('updated', await $fetch('/api/library/lora-aliases', {
|
emit('updated', await $fetch('/api/library/lora-aliases', {
|
||||||
|
|||||||
+54
-6
@@ -190,7 +190,15 @@
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div class="mb-2 flex flex-wrap items-center justify-between gap-2">
|
<div class="mb-2 flex flex-wrap items-center justify-between gap-2">
|
||||||
<label class="text-sm font-medium text-zinc-300">{{ studioMode === 'editv2' && v2Mode === 'generate' ? 'Generate prompt' : studioMode === 'editv2' && v2Mode === 'refine' ? 'Refine prompt' : studioMode === 'edit' || studioMode === 'editv2' ? 'Edit prompt' : 'Motion & scene prompt' }}</label>
|
<div class="flex items-center gap-2">
|
||||||
|
<label class="text-sm font-medium text-zinc-300">{{ studioMode === 'editv2' && v2Mode === 'generate' ? 'Generate prompt' : studioMode === 'editv2' && v2Mode === 'refine' ? 'Refine prompt' : studioMode === 'edit' || studioMode === 'editv2' ? 'Edit prompt' : 'Motion & scene prompt' }}</label>
|
||||||
|
<KeepPromptMenu
|
||||||
|
:items="activeKeepPrompts"
|
||||||
|
:heading="keepPromptHeading"
|
||||||
|
align="left"
|
||||||
|
@insert="insertKeepPrompt"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div class="flex flex-wrap items-center gap-3">
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
<button
|
<button
|
||||||
v-if="identityPrompting"
|
v-if="identityPrompting"
|
||||||
@@ -486,7 +494,16 @@
|
|||||||
class="rounded-2xl border border-white/10 bg-zinc-950/50 p-4"
|
class="rounded-2xl border border-white/10 bg-zinc-950/50 p-4"
|
||||||
>
|
>
|
||||||
<div class="mb-3 flex items-center justify-between gap-2">
|
<div class="mb-3 flex items-center justify-between gap-2">
|
||||||
<p class="text-sm font-medium text-zinc-200">Extension #{{ index + 1 }}</p>
|
<div class="flex items-center gap-2">
|
||||||
|
<p class="text-sm font-medium text-zinc-200">Extension #{{ index + 1 }}</p>
|
||||||
|
<KeepPromptMenu
|
||||||
|
:items="keepPrompts.video"
|
||||||
|
heading="Video prompts"
|
||||||
|
align="left"
|
||||||
|
:disabled="videoBusy"
|
||||||
|
@insert="insertKeepIntoExtension(item.id, $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="text-xs text-red-300 hover:text-red-200"
|
class="text-xs text-red-300 hover:text-red-200"
|
||||||
@@ -1360,7 +1377,16 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<label class="block text-sm">
|
<label class="block text-sm">
|
||||||
<span class="mb-1 block text-zinc-400">Extension prompt</span>
|
<span class="mb-1 flex items-center gap-2 text-zinc-400">
|
||||||
|
Extension prompt
|
||||||
|
<KeepPromptMenu
|
||||||
|
:items="keepPrompts.video"
|
||||||
|
heading="Video prompts"
|
||||||
|
align="left"
|
||||||
|
:disabled="lastFrameLoading || videoBusy"
|
||||||
|
@insert="insertKeepExtend"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
<textarea
|
<textarea
|
||||||
v-model="extendPrompt"
|
v-model="extendPrompt"
|
||||||
rows="4"
|
rows="4"
|
||||||
@@ -1965,6 +1991,8 @@
|
|||||||
v-model:text="prompt"
|
v-model:text="prompt"
|
||||||
:title="studioMode === 'edit' || studioMode === 'editv2' ? 'Edit prompt' : (shotScriptMode ? 'Shot script' : 'Prompt')"
|
:title="studioMode === 'edit' || studioMode === 'editv2' ? 'Edit prompt' : (shotScriptMode ? 'Shot script' : 'Prompt')"
|
||||||
:placeholder="promptPlaceholder"
|
:placeholder="promptPlaceholder"
|
||||||
|
:keep-items="activeKeepPrompts"
|
||||||
|
:keep-heading="keepPromptHeading"
|
||||||
@update:open="promptDrawerOpen = $event"
|
@update:open="promptDrawerOpen = $event"
|
||||||
/>
|
/>
|
||||||
<ImageBatchModal
|
<ImageBatchModal
|
||||||
@@ -2122,6 +2150,7 @@ import {
|
|||||||
} from '~/utils/libraryGeneration'
|
} from '~/utils/libraryGeneration'
|
||||||
import { parseImageAspect, generateSizeForAspect } from '~/utils/imageAspect'
|
import { parseImageAspect, generateSizeForAspect } from '~/utils/imageAspect'
|
||||||
import { buildOutputBaseName, nextUniqueName } from '~/utils/outputNames'
|
import { buildOutputBaseName, nextUniqueName } from '~/utils/outputNames'
|
||||||
|
import { emptyKeepPrompts, insertKeepSnippet, normalizeKeepPrompts, type KeepPrompt, type KeepPromptLists } from '~/utils/keepPrompts'
|
||||||
import {
|
import {
|
||||||
IMAGE_V2_CFG_DEFAULT,
|
IMAGE_V2_CFG_DEFAULT,
|
||||||
IMAGE_V2_CONSISTENCY_LORA,
|
IMAGE_V2_CONSISTENCY_LORA,
|
||||||
@@ -2567,6 +2596,9 @@ const stills = ref<LibraryStill[]>([])
|
|||||||
const drafts = ref<RetryDraft[]>([])
|
const drafts = ref<RetryDraft[]>([])
|
||||||
const loraAliases = ref<Record<string, string>>({})
|
const loraAliases = ref<Record<string, string>>({})
|
||||||
const loraTriggers = ref<Record<string, string>>({})
|
const loraTriggers = ref<Record<string, string>>({})
|
||||||
|
const keepPrompts = ref<KeepPromptLists>(emptyKeepPrompts())
|
||||||
|
const activeKeepPrompts = computed(() => studioMode.value === 'video' ? keepPrompts.value.video : keepPrompts.value.image)
|
||||||
|
const keepPromptHeading = computed(() => studioMode.value === 'video' ? 'Video prompts' : 'Image prompts')
|
||||||
const folderId = ref('')
|
const folderId = ref('')
|
||||||
const browseFolderId = ref('')
|
const browseFolderId = ref('')
|
||||||
const renamingId = ref('')
|
const renamingId = ref('')
|
||||||
@@ -3503,8 +3535,8 @@ function applyInstancePrivacy() {
|
|||||||
hideThumbnail.value = readPrivacyToggle('aigen-hide-output-preview', true)
|
hideThumbnail.value = readPrivacyToggle('aigen-hide-output-preview', true)
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyLibrary(data: { folders?: LibraryFolder[]; clips?: LibraryClip[]; stills?: LibraryStill[]; drafts?: RetryDraft[]; loraAliases?: Record<string, string>; loraTriggers?: Record<string, string> } | unknown) {
|
function applyLibrary(data: { folders?: LibraryFolder[]; clips?: LibraryClip[]; stills?: LibraryStill[]; drafts?: RetryDraft[]; loraAliases?: Record<string, string>; loraTriggers?: Record<string, string>; keepPrompts?: KeepPromptLists } | unknown) {
|
||||||
const payload = (data || {}) as { folders?: LibraryFolder[]; clips?: LibraryClip[]; stills?: LibraryStill[]; drafts?: RetryDraft[]; loraAliases?: Record<string, string>; loraTriggers?: Record<string, string> }
|
const payload = (data || {}) as { folders?: LibraryFolder[]; clips?: LibraryClip[]; stills?: LibraryStill[]; drafts?: RetryDraft[]; loraAliases?: Record<string, string>; loraTriggers?: Record<string, string>; keepPrompts?: KeepPromptLists }
|
||||||
folders.value = payload.folders || []
|
folders.value = payload.folders || []
|
||||||
clips.value = payload.clips || []
|
clips.value = payload.clips || []
|
||||||
stills.value = payload.stills || []
|
stills.value = payload.stills || []
|
||||||
@@ -3515,6 +3547,9 @@ function applyLibrary(data: { folders?: LibraryFolder[]; clips?: LibraryClip[];
|
|||||||
if (payload.loraTriggers && typeof payload.loraTriggers === 'object') {
|
if (payload.loraTriggers && typeof payload.loraTriggers === 'object') {
|
||||||
loraTriggers.value = payload.loraTriggers
|
loraTriggers.value = payload.loraTriggers
|
||||||
}
|
}
|
||||||
|
if (payload.keepPrompts) {
|
||||||
|
keepPrompts.value = normalizeKeepPrompts(payload.keepPrompts)
|
||||||
|
}
|
||||||
if (!folderId.value || !folders.value.some(folder => folder.id === folderId.value)) {
|
if (!folderId.value || !folders.value.some(folder => folder.id === folderId.value)) {
|
||||||
folderId.value = folders.value[0]?.id || ''
|
folderId.value = folders.value[0]?.id || ''
|
||||||
}
|
}
|
||||||
@@ -3524,7 +3559,7 @@ function applyLibrary(data: { folders?: LibraryFolder[]; clips?: LibraryClip[];
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadLibrary(light = false) {
|
async function loadLibrary(light = false) {
|
||||||
const data = await $fetch<{ folders?: LibraryFolder[]; clips?: LibraryClip[]; stills?: LibraryStill[]; drafts?: RetryDraft[]; loraAliases?: Record<string, string>; loraTriggers?: Record<string, string> }>(light ? '/api/library?light=1' : '/api/library')
|
const data = await $fetch<{ folders?: LibraryFolder[]; clips?: LibraryClip[]; stills?: LibraryStill[]; drafts?: RetryDraft[]; loraAliases?: Record<string, string>; loraTriggers?: Record<string, string>; keepPrompts?: KeepPromptLists }>(light ? '/api/library?light=1' : '/api/library')
|
||||||
applyLibrary(data)
|
applyLibrary(data)
|
||||||
if (light) return
|
if (light) return
|
||||||
const queued = await $fetch<{ pendingCount?: number }>('/api/queues').catch(() => ({ pendingCount: 0 }))
|
const queued = await $fetch<{ pendingCount?: number }>('/api/queues').catch(() => ({ pendingCount: 0 }))
|
||||||
@@ -3996,6 +4031,19 @@ async function saveLoraTrigger(name: string, trigger: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function insertKeepPrompt(item: KeepPrompt) {
|
||||||
|
prompt.value = insertKeepSnippet(prompt.value, item.text)
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertKeepExtend(item: KeepPrompt) {
|
||||||
|
extendPrompt.value = insertKeepSnippet(extendPrompt.value, item.text)
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertKeepIntoExtension(id: string, item: KeepPrompt) {
|
||||||
|
const row = extensionQueue.value.find(entry => entry.id === id)
|
||||||
|
if (row) row.prompt = insertKeepSnippet(row.prompt, item.text)
|
||||||
|
}
|
||||||
|
|
||||||
function insertLoraTrigger(phrase: string, target: 'main' | 'extend' | { shot: number } | { extensionId: string } = 'main') {
|
function insertLoraTrigger(phrase: string, target: 'main' | 'extend' | { shot: number } | { extensionId: string } = 'main') {
|
||||||
const token = String(phrase || '').trim()
|
const token = String(phrase || '').trim()
|
||||||
if (!token) return
|
if (!token) return
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { libraryOwnerKey, publicLibrary, setKeepPrompts } from '~/server/utils/library'
|
||||||
|
import { parseKeepPromptKind } from '~/utils/keepPrompts'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const body = await readBody<{ kind?: unknown; items?: unknown }>(event)
|
||||||
|
const kind = parseKeepPromptKind(body?.kind)
|
||||||
|
if (!kind) throw createError({ statusCode: 400, statusMessage: 'Kind must be video or image' })
|
||||||
|
const owner = libraryOwnerKey(event)
|
||||||
|
await setKeepPrompts(owner, kind, body?.items)
|
||||||
|
return publicLibrary(event)
|
||||||
|
})
|
||||||
+23
-3
@@ -10,6 +10,13 @@ import { normalizePermanenceRefs, type PermanenceRef } from '~/utils/globalLocks
|
|||||||
import { normalizeStillSettings, type StillGenerationSettings } from '~/utils/libraryGeneration'
|
import { normalizeStillSettings, type StillGenerationSettings } from '~/utils/libraryGeneration'
|
||||||
import { buildOutputBaseName, nextUniqueName, normalizeLoraAliases, sanitizeLoraAlias } from '~/utils/outputNames'
|
import { buildOutputBaseName, nextUniqueName, normalizeLoraAliases, sanitizeLoraAlias } from '~/utils/outputNames'
|
||||||
import { loraIdentityKey, normalizeLoraTriggers, sanitizeLoraTrigger, type LoraStackItem } from '~/utils/loras'
|
import { loraIdentityKey, normalizeLoraTriggers, sanitizeLoraTrigger, type LoraStackItem } from '~/utils/loras'
|
||||||
|
import {
|
||||||
|
emptyKeepPrompts,
|
||||||
|
normalizeKeepPromptList,
|
||||||
|
normalizeKeepPrompts,
|
||||||
|
type KeepPromptKind,
|
||||||
|
type KeepPromptLists
|
||||||
|
} from '~/utils/keepPrompts'
|
||||||
|
|
||||||
const scryptAsync = promisify(scrypt)
|
const scryptAsync = promisify(scrypt)
|
||||||
|
|
||||||
@@ -132,6 +139,7 @@ interface Catalog {
|
|||||||
drafts: RetryDraft[]
|
drafts: RetryDraft[]
|
||||||
loraAliases: Record<string, string>
|
loraAliases: Record<string, string>
|
||||||
loraTriggers: Record<string, string>
|
loraTriggers: Record<string, string>
|
||||||
|
keepPrompts: KeepPromptLists
|
||||||
}
|
}
|
||||||
|
|
||||||
const writeChains = new Map<string, Promise<unknown>>()
|
const writeChains = new Map<string, Promise<unknown>>()
|
||||||
@@ -172,7 +180,8 @@ function emptyCatalog(): Catalog {
|
|||||||
stills: [],
|
stills: [],
|
||||||
drafts: [],
|
drafts: [],
|
||||||
loraAliases: {},
|
loraAliases: {},
|
||||||
loraTriggers: {}
|
loraTriggers: {},
|
||||||
|
keepPrompts: emptyKeepPrompts()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,7 +242,8 @@ function normalizeCatalog(parsed: Partial<Catalog> & { passwordHash?: string | n
|
|||||||
stills: Array.isArray(parsed.stills) ? parsed.stills.map(normalizeStill) : [],
|
stills: Array.isArray(parsed.stills) ? parsed.stills.map(normalizeStill) : [],
|
||||||
drafts: Array.isArray(parsed.drafts) ? parsed.drafts : [],
|
drafts: Array.isArray(parsed.drafts) ? parsed.drafts : [],
|
||||||
loraAliases: normalizeLoraAliases(parsed.loraAliases),
|
loraAliases: normalizeLoraAliases(parsed.loraAliases),
|
||||||
loraTriggers: normalizeLoraTriggers(parsed.loraTriggers)
|
loraTriggers: normalizeLoraTriggers(parsed.loraTriggers),
|
||||||
|
keepPrompts: normalizeKeepPrompts(parsed.keepPrompts)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -390,6 +400,15 @@ export function getLoraTriggers(owner: string) {
|
|||||||
return { ...(readCatalog(owner).loraTriggers || {}) }
|
return { ...(readCatalog(owner).loraTriggers || {}) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function setKeepPrompts(owner: string, kind: KeepPromptKind, items: unknown) {
|
||||||
|
const list = normalizeKeepPromptList(items)
|
||||||
|
return mutate(owner, (catalog) => {
|
||||||
|
if (!catalog.keepPrompts) catalog.keepPrompts = emptyKeepPrompts()
|
||||||
|
catalog.keepPrompts[kind] = list
|
||||||
|
return { ...catalog.keepPrompts }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export async function setLoraTrigger(owner: string, identity: string, trigger: string) {
|
export async function setLoraTrigger(owner: string, identity: string, trigger: string) {
|
||||||
const key = loraIdentityKey(identity) || String(identity || '').trim().toLowerCase()
|
const key = loraIdentityKey(identity) || String(identity || '').trim().toLowerCase()
|
||||||
if (!key) throw createError({ statusCode: 400, statusMessage: 'A LoRA file is required' })
|
if (!key) throw createError({ statusCode: 400, statusMessage: 'A LoRA file is required' })
|
||||||
@@ -506,7 +525,8 @@ export function publicLibrary(event: H3Event, opts?: { light?: boolean }) {
|
|||||||
.map(still => ({ ...still, role: stillRole(still) })),
|
.map(still => ({ ...still, role: stillRole(still) })),
|
||||||
drafts: catalog.drafts,
|
drafts: catalog.drafts,
|
||||||
loraAliases: catalog.loraAliases || {},
|
loraAliases: catalog.loraAliases || {},
|
||||||
loraTriggers: catalog.loraTriggers || {}
|
loraTriggers: catalog.loraTriggers || {},
|
||||||
|
keepPrompts: catalog.keepPrompts || emptyKeepPrompts()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
export type KeepPromptKind = 'video' | 'image'
|
||||||
|
|
||||||
|
export type KeepPrompt = {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type KeepPromptLists = {
|
||||||
|
video: KeepPrompt[]
|
||||||
|
image: KeepPrompt[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const KEEP_PROMPT_MAX = 40
|
||||||
|
export const KEEP_PROMPT_TEXT_MAX = 500
|
||||||
|
export const KEEP_PROMPT_LABEL_MAX = 40
|
||||||
|
|
||||||
|
export function emptyKeepPrompts(): KeepPromptLists {
|
||||||
|
return { video: [], image: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeKeepPromptText(raw: unknown) {
|
||||||
|
return String(raw || '')
|
||||||
|
.replace(/\r\n/g, '\n')
|
||||||
|
.replace(/[\0\r]/g, '')
|
||||||
|
.trim()
|
||||||
|
.slice(0, KEEP_PROMPT_TEXT_MAX)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeKeepPromptLabel(raw: unknown, fallback = '') {
|
||||||
|
const label = String(raw || '').replace(/\s+/g, ' ').trim().slice(0, KEEP_PROMPT_LABEL_MAX)
|
||||||
|
if (label) return label
|
||||||
|
const fromText = sanitizeKeepPromptText(fallback).replace(/\s+/g, ' ').slice(0, KEEP_PROMPT_LABEL_MAX)
|
||||||
|
return fromText
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeItem(raw: unknown): KeepPrompt | null {
|
||||||
|
if (!raw || typeof raw !== 'object') return null
|
||||||
|
const rec = raw as Record<string, unknown>
|
||||||
|
const text = sanitizeKeepPromptText(rec.text)
|
||||||
|
if (!text) return null
|
||||||
|
const id = String(rec.id || '').trim() || crypto.randomUUID()
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
label: sanitizeKeepPromptLabel(rec.label, text),
|
||||||
|
text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeKeepPromptList(raw: unknown): KeepPrompt[] {
|
||||||
|
if (!Array.isArray(raw)) return []
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const out: KeepPrompt[] = []
|
||||||
|
for (const entry of raw) {
|
||||||
|
if (out.length >= KEEP_PROMPT_MAX) break
|
||||||
|
const item = normalizeItem(entry)
|
||||||
|
if (!item || seen.has(item.id)) continue
|
||||||
|
seen.add(item.id)
|
||||||
|
out.push(item)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeKeepPrompts(raw: unknown): KeepPromptLists {
|
||||||
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return emptyKeepPrompts()
|
||||||
|
const rec = raw as Record<string, unknown>
|
||||||
|
return {
|
||||||
|
video: normalizeKeepPromptList(rec.video),
|
||||||
|
image: normalizeKeepPromptList(rec.image)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseKeepPromptKind(raw: unknown): KeepPromptKind | '' {
|
||||||
|
return raw === 'video' || raw === 'image' ? raw : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export function insertKeepSnippet(text: string, snippet: string) {
|
||||||
|
const src = String(text || '')
|
||||||
|
const token = sanitizeKeepPromptText(snippet)
|
||||||
|
if (!token) return src
|
||||||
|
if (src.includes(token)) return src
|
||||||
|
if (!src.trim()) return token
|
||||||
|
const needsBreak = !/\n$/.test(src)
|
||||||
|
return needsBreak ? `${src.trimEnd()}\n${token}` : `${src}${token}`
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user