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))]"
|
||||
>
|
||||
<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
|
||||
type="button"
|
||||
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>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { insertKeepSnippet, type KeepPrompt } from '~/utils/keepPrompts'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
open: boolean
|
||||
title?: string
|
||||
text?: string
|
||||
editable?: boolean
|
||||
placeholder?: string
|
||||
keepItems?: KeepPrompt[]
|
||||
keepHeading?: string
|
||||
}>(), {
|
||||
title: 'Prompt',
|
||||
text: '',
|
||||
editable: true,
|
||||
placeholder: ''
|
||||
placeholder: '',
|
||||
keepItems: () => [],
|
||||
keepHeading: 'Saved prompts'
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -52,6 +67,10 @@ function onInput(event: Event) {
|
||||
emit('update:text', (event.target as HTMLTextAreaElement).value)
|
||||
}
|
||||
|
||||
function onKeepInsert(item: KeepPrompt) {
|
||||
emit('update:text', insertKeepSnippet(props.text || '', item.text))
|
||||
}
|
||||
|
||||
function close() {
|
||||
emit('update:open', false)
|
||||
}
|
||||
|
||||
@@ -192,6 +192,63 @@
|
||||
</div>
|
||||
</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">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold uppercase tracking-wider text-zinc-500">Prompt helper</h3>
|
||||
@@ -261,6 +318,15 @@
|
||||
<script setup lang="ts">
|
||||
import { defaultLoraFilenameKey, isFilenameSystemLora } from '~/utils/outputNames'
|
||||
import { loraDisplayName, loraIdentityKey } from '~/utils/loras'
|
||||
import {
|
||||
KEEP_PROMPT_MAX,
|
||||
emptyKeepPrompts,
|
||||
normalizeKeepPrompts,
|
||||
sanitizeKeepPromptLabel,
|
||||
sanitizeKeepPromptText,
|
||||
type KeepPromptKind,
|
||||
type KeepPromptLists
|
||||
} from '~/utils/keepPrompts'
|
||||
|
||||
export interface SettingsFolder {
|
||||
id: string
|
||||
@@ -287,6 +353,15 @@ const emit = defineEmits<{
|
||||
const newName = ref('')
|
||||
const newPresetName = 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 loraRowsLoading = ref(true)
|
||||
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
|
||||
try {
|
||||
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 triggers = library.loraTriggers || {}
|
||||
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 }) {
|
||||
try {
|
||||
emit('updated', await $fetch('/api/library/lora-aliases', {
|
||||
|
||||
Reference in New Issue
Block a user