904 lines
37 KiB
Vue
904 lines
37 KiB
Vue
<template>
|
|
<div class="lg:grid lg:grid-cols-[13rem_minmax(0,1fr)] lg:items-start lg:gap-10">
|
|
<nav class="mb-6 flex flex-wrap gap-2 lg:sticky lg:top-24 lg:mb-0 lg:flex-col lg:gap-1">
|
|
<button
|
|
v-for="item in navItems"
|
|
:key="item.id"
|
|
type="button"
|
|
class="rounded-full border px-3 py-1.5 text-left text-xs lg:rounded-xl lg:text-sm"
|
|
:class="activePanel === item.id
|
|
? 'border-amber-300/50 bg-amber-400/10 text-amber-100'
|
|
: 'border-white/10 text-zinc-400 hover:border-amber-300/40 hover:text-amber-100'"
|
|
@click="activePanel = item.id"
|
|
>{{ item.label }}</button>
|
|
</nav>
|
|
|
|
<div class="min-w-0 space-y-4">
|
|
<p
|
|
v-if="notice"
|
|
class="rounded-2xl border px-4 py-3 text-sm"
|
|
:class="noticeError ? 'border-red-400/30 bg-red-950/40 text-red-100' : 'border-emerald-400/20 bg-emerald-950/30 text-emerald-100'"
|
|
>{{ notice }}</p>
|
|
|
|
<section v-if="activePanel === 'gpu'" class="glass rounded-3xl p-5 sm:p-6">
|
|
<h2 class="font-display text-xl font-bold">GPU</h2>
|
|
<div class="mt-4 space-y-3 rounded-2xl border border-white/10 bg-zinc-950/50 px-4 py-3">
|
|
<p class="text-sm text-zinc-400">
|
|
Stops Comfy on the desktop GPU so it drops out of VRAM. The PC stays on. Generate or Wake GPU will start it again.
|
|
</p>
|
|
<div class="flex flex-wrap items-center gap-2">
|
|
<button
|
|
type="button"
|
|
class="rounded-xl border px-3 py-2 text-sm disabled:cursor-not-allowed disabled:opacity-60"
|
|
:class="gpuSleeping ? 'border-amber-300/40 text-amber-100' : 'border-white/15 text-zinc-200 hover:border-amber-300/50 hover:text-white'"
|
|
:disabled="gpuSleeping || gpuWaking"
|
|
@click="sleepGpu"
|
|
>
|
|
{{ gpuSleeping ? 'Stopping GPU…' : 'Sleep GPU' }}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="rounded-xl border px-3 py-2 text-sm disabled:cursor-not-allowed disabled:opacity-60"
|
|
:class="gpuWaking ? 'border-amber-300/40 text-amber-100' : 'border-amber-400/40 text-amber-200 hover:border-amber-300 hover:text-amber-50'"
|
|
:disabled="gpuWaking || gpuSleeping"
|
|
@click="wakeGpu"
|
|
>
|
|
{{ gpuWaking ? 'Starting GPU…' : 'Wake GPU' }}
|
|
</button>
|
|
</div>
|
|
<p v-if="gpuMessage" class="text-xs" :class="gpuError ? 'text-red-300' : 'text-zinc-500'">{{ gpuMessage }}</p>
|
|
</div>
|
|
<label class="mt-4 flex cursor-pointer items-center justify-between gap-3 rounded-2xl border border-white/10 bg-zinc-950/50 px-4 py-3 text-sm">
|
|
<span>
|
|
<span class="block text-zinc-200">Notify when a generation finishes</span>
|
|
<span class="mt-0.5 block text-xs text-zinc-500">Works in the installed web app. Asks for notification permission the first time you turn this on or generate.</span>
|
|
</span>
|
|
<input
|
|
v-model="notifyComplete"
|
|
type="checkbox"
|
|
class="peer sr-only"
|
|
role="switch"
|
|
@change="onNotifyCompleteChange"
|
|
>
|
|
<span class="relative inline-flex h-6 w-11 shrink-0 items-center">
|
|
<span class="absolute inset-0 rounded-full transition" :class="notifyComplete ? 'bg-amber-400' : 'bg-zinc-700'" />
|
|
<span class="absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white transition" :class="notifyComplete ? 'translate-x-5' : ''" />
|
|
</span>
|
|
</label>
|
|
<p v-if="notifyMessage" class="mt-2 text-xs text-zinc-500">{{ notifyMessage }}</p>
|
|
</section>
|
|
|
|
<section v-if="activePanel === 'storage'" class="glass rounded-3xl p-5 sm:p-6">
|
|
<h2 class="font-display text-xl font-bold">Storage</h2>
|
|
<div class="mt-4 rounded-2xl border border-white/10 bg-zinc-950/50 px-4 py-3">
|
|
<p v-if="storageLoading" class="text-sm text-zinc-500">Measuring…</p>
|
|
<p v-else-if="storageError" class="text-sm text-zinc-500">{{ storageError }}</p>
|
|
<template v-else-if="storage">
|
|
<p class="text-sm text-zinc-200">{{ storageLine }}</p>
|
|
<p v-if="storageBreakdown" class="mt-1 text-xs text-zinc-500">{{ storageBreakdown }}</p>
|
|
<p class="mt-1 text-xs text-zinc-600">{{ storageNote }}</p>
|
|
</template>
|
|
</div>
|
|
</section>
|
|
|
|
<section v-if="activePanel === 'login' && authMode === 'password'" class="glass space-y-3 rounded-3xl p-5 sm:p-6">
|
|
<h2 class="font-display text-xl font-bold">Site login</h2>
|
|
<p class="text-sm text-zinc-400">Username and password are stored encrypted on this server. Changing them does not move the library.</p>
|
|
<form class="grid gap-2 sm:grid-cols-2" @submit.prevent="saveLogin">
|
|
<input v-model="loginUser" class="rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm" placeholder="Username" autocomplete="username">
|
|
<input v-model="loginCurrent" type="password" class="rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm" placeholder="Current password" autocomplete="current-password" required>
|
|
<input v-model="loginNext" type="password" class="rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm sm:col-span-2" placeholder="New password (blank to keep)" autocomplete="new-password">
|
|
<button type="submit" class="rounded-xl bg-amber-400 px-4 py-2 text-sm font-semibold text-zinc-950 sm:col-span-2">Update login</button>
|
|
</form>
|
|
</section>
|
|
|
|
<section v-if="activePanel === 'folders'" class="glass space-y-3 rounded-3xl p-5 sm:p-6">
|
|
<div class="flex items-center justify-between gap-3">
|
|
<h2 class="font-display text-xl font-bold">Folders</h2>
|
|
<span class="text-xs text-zinc-500">{{ folders.length }} total</span>
|
|
</div>
|
|
|
|
<div
|
|
v-for="folder in folders"
|
|
:key="folder.id"
|
|
class="rounded-2xl border border-white/10 bg-zinc-950/50 p-4 space-y-3"
|
|
>
|
|
<template v-if="drafts[folder.id]">
|
|
<div class="flex flex-wrap items-center gap-2">
|
|
<input
|
|
v-model="drafts[folder.id].name"
|
|
class="min-w-[10rem] flex-1 rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm"
|
|
@keydown.enter.prevent="rename(folder)"
|
|
>
|
|
<button type="button" class="rounded-xl border border-white/15 px-3 py-2 text-sm hover:border-amber-300/50" @click="rename(folder)">Rename</button>
|
|
<button type="button" class="rounded-xl border border-red-400/30 px-3 py-2 text-sm text-red-300 hover:border-red-300" @click="remove(folder)">Delete</button>
|
|
</div>
|
|
|
|
<div class="grid gap-2 sm:grid-cols-2">
|
|
<label class="flex cursor-pointer items-center justify-between gap-3 rounded-xl border border-white/10 px-3 py-2 text-sm">
|
|
<span>Show output preview</span>
|
|
<input
|
|
type="checkbox"
|
|
class="peer sr-only"
|
|
:checked="folder.thumbnailDefault"
|
|
@change="setThumbnail(folder, ($event.target as HTMLInputElement).checked)"
|
|
>
|
|
<span class="relative inline-flex h-6 w-11 shrink-0 items-center">
|
|
<span class="absolute inset-0 rounded-full bg-zinc-700 transition" :class="folder.thumbnailDefault ? 'bg-amber-400' : 'bg-zinc-700'" />
|
|
<span class="absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white transition" :class="folder.thumbnailDefault ? 'translate-x-5' : ''" />
|
|
</span>
|
|
</label>
|
|
|
|
<label class="flex cursor-pointer items-center justify-between gap-3 rounded-xl border border-white/10 px-3 py-2 text-sm">
|
|
<span>{{ drafts[folder.id].protect ? 'Password on' : 'Password off' }}</span>
|
|
<input
|
|
type="checkbox"
|
|
class="peer sr-only"
|
|
:checked="drafts[folder.id].protect"
|
|
@change="toggleProtect(folder, ($event.target as HTMLInputElement).checked)"
|
|
>
|
|
<span class="relative inline-flex h-6 w-11 shrink-0 items-center">
|
|
<span class="absolute inset-0 rounded-full transition" :class="drafts[folder.id].protect ? 'bg-amber-400' : 'bg-zinc-700'" />
|
|
<span class="absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white transition" :class="drafts[folder.id].protect ? 'translate-x-5' : ''" />
|
|
</span>
|
|
</label>
|
|
</div>
|
|
|
|
<form v-if="drafts[folder.id].protect || folder.protected" class="grid gap-2 sm:grid-cols-2" @submit.prevent="savePassword(folder)">
|
|
<p v-if="folder.protected && !drafts[folder.id].protect" class="sm:col-span-2 text-sm text-zinc-400">
|
|
Enter the current password to turn protection off.
|
|
</p>
|
|
<input
|
|
v-if="folder.protected"
|
|
v-model="drafts[folder.id].currentPassword"
|
|
type="password"
|
|
class="rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm"
|
|
placeholder="Current password"
|
|
>
|
|
<input
|
|
v-if="drafts[folder.id].protect"
|
|
v-model="drafts[folder.id].password"
|
|
type="password"
|
|
class="rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm"
|
|
:placeholder="folder.protected ? 'New password (blank to keep)' : 'Set a password'"
|
|
>
|
|
<button type="submit" class="rounded-xl border border-white/15 px-3 py-2 text-sm hover:border-amber-300/50 sm:col-span-2">
|
|
{{ folder.protected && !drafts[folder.id].protect ? 'Remove password' : folder.protected ? 'Update folder password' : 'Protect folder' }}
|
|
</button>
|
|
</form>
|
|
<p v-if="folder.protected && folder.unlocked" class="text-xs text-emerald-300">Unlocked in this session.</p>
|
|
<p v-else-if="folder.protected" class="text-xs text-amber-200">Locked · you can generate into it, but clips stay hidden until you unlock.</p>
|
|
</template>
|
|
</div>
|
|
|
|
<form class="flex flex-wrap gap-2" @submit.prevent="addFolder">
|
|
<input
|
|
v-model="newName"
|
|
class="min-w-[10rem] flex-1 rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm"
|
|
placeholder="New folder name"
|
|
>
|
|
<button type="submit" class="rounded-xl bg-amber-400 px-4 py-2 text-sm font-semibold text-zinc-950">Add folder</button>
|
|
</form>
|
|
</section>
|
|
|
|
<section v-if="activePanel === 'loras'" class="glass space-y-4 rounded-3xl p-5 sm:p-6">
|
|
<div>
|
|
<h2 class="font-display text-xl font-bold">LoRA filename keys</h2>
|
|
<p class="mt-1 text-sm text-zinc-400">
|
|
Short names used in auto-generated output titles. Leave blank to use the file stem
|
|
(<span class="text-zinc-300">XAIgen-cline</span> becomes <span class="text-zinc-300">cline</span>).
|
|
Activation is the word or phrase the <span class="text-zinc-300">i</span> on a LoRA slider inserts into the prompt.
|
|
</p>
|
|
</div>
|
|
<div class="flex flex-col gap-2 sm:flex-row sm:items-center">
|
|
<input
|
|
v-model="loraQuery"
|
|
type="search"
|
|
class="min-w-0 flex-1 rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm"
|
|
placeholder="Filter by file, key, or activation…"
|
|
>
|
|
<div class="inline-flex shrink-0 rounded-full border border-white/10 p-0.5">
|
|
<button
|
|
v-for="option in loraKindOptions"
|
|
:key="option.id"
|
|
type="button"
|
|
class="rounded-full px-2.5 py-1.5 text-[11px]"
|
|
:class="loraKind === option.id ? 'bg-amber-400/15 text-amber-100' : 'text-zinc-500 hover:text-zinc-300'"
|
|
@click="loraKind = option.id"
|
|
>{{ option.label }}</button>
|
|
</div>
|
|
</div>
|
|
<p v-if="loraRowsLoading" class="text-sm text-zinc-500">Loading LoRAs…</p>
|
|
<p v-else-if="!loraRows.length" class="text-sm text-zinc-500">No LoRA files found on Comfy yet.</p>
|
|
<p v-else class="text-xs text-zinc-500">
|
|
Showing {{ filteredLoraRows.length }} of {{ loraRows.length }}
|
|
</p>
|
|
<p v-if="!loraRowsLoading && loraRows.length && !filteredLoraRows.length" class="text-sm text-zinc-500">No LoRAs match that filter.</p>
|
|
<div
|
|
v-for="row in filteredLoraRows"
|
|
:key="row.key"
|
|
class="flex flex-col gap-2 rounded-2xl border border-white/10 bg-zinc-950/50 px-4 py-3 sm:flex-row sm:items-center"
|
|
>
|
|
<div class="min-w-0 flex-1">
|
|
<p class="truncate text-sm text-zinc-200">{{ row.draft || row.file }}</p>
|
|
<p class="truncate text-[10px] uppercase tracking-wide text-zinc-600">
|
|
{{ row.kind === 'both' ? 'Image · Video' : row.kind }}
|
|
<span v-if="row.draft" class="normal-case tracking-normal text-zinc-500"> · {{ row.file }}</span>
|
|
</p>
|
|
</div>
|
|
<div class="flex w-full flex-col gap-2 sm:w-56">
|
|
<input
|
|
v-model="row.draft"
|
|
class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm"
|
|
:placeholder="row.placeholder"
|
|
maxlength="24"
|
|
@change="saveLoraAlias(row)"
|
|
>
|
|
<input
|
|
v-model="row.trigger"
|
|
class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm"
|
|
placeholder="Activation word"
|
|
maxlength="240"
|
|
@change="saveLoraTrigger(row)"
|
|
>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section v-if="activePanel === 'keeps'" class="glass space-y-4 rounded-3xl p-5 sm:p-6">
|
|
<div>
|
|
<h2 class="font-display text-xl font-bold">Keep prompts</h2>
|
|
<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 v-if="activePanel === 'prompts'" class="glass space-y-3 rounded-3xl p-5 sm:p-6">
|
|
<div>
|
|
<h2 class="font-display text-xl font-bold">Prompt helper</h2>
|
|
<p class="mt-1 text-sm text-zinc-400">
|
|
Instruction sets sent to Beast Qwen VL with your still and draft. Use these later for multi-shot video scripts.
|
|
</p>
|
|
</div>
|
|
|
|
<div
|
|
v-for="preset in promptPresets"
|
|
:key="preset.id"
|
|
class="rounded-2xl border border-white/10 bg-zinc-950/50 p-4 space-y-2"
|
|
>
|
|
<div class="flex flex-wrap items-center justify-between gap-2">
|
|
<p class="text-sm font-medium text-zinc-200">
|
|
{{ preset.name }}
|
|
<span v-if="preset.builtin" class="ml-2 text-[10px] uppercase tracking-wide text-zinc-500">Built-in</span>
|
|
</p>
|
|
<button
|
|
v-if="!preset.builtin"
|
|
type="button"
|
|
class="text-xs text-red-300 hover:text-red-200"
|
|
@click="removePreset(preset.id)"
|
|
>
|
|
Delete
|
|
</button>
|
|
</div>
|
|
<p v-if="preset.description" class="text-xs text-zinc-500">{{ preset.description }}</p>
|
|
<template v-if="!preset.builtin">
|
|
<input
|
|
v-model="preset.name"
|
|
class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm"
|
|
placeholder="Name"
|
|
@change="savePreset(preset)"
|
|
>
|
|
<textarea
|
|
v-model="preset.systemPrompt"
|
|
rows="5"
|
|
class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm"
|
|
placeholder="Instruction set…"
|
|
@change="savePreset(preset)"
|
|
/>
|
|
</template>
|
|
</div>
|
|
|
|
<form class="space-y-2 rounded-2xl border border-dashed border-white/15 p-4" @submit.prevent="addPreset">
|
|
<p class="text-xs font-medium uppercase tracking-wide text-zinc-500">New instruction set</p>
|
|
<input
|
|
v-model="newPresetName"
|
|
class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm"
|
|
placeholder="Name, e.g. Multi-shot tavern script"
|
|
>
|
|
<textarea
|
|
v-model="newPresetPrompt"
|
|
rows="4"
|
|
class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm"
|
|
placeholder="Tell Qwen VL how to rewrite the draft. You can add shot-script rules here later."
|
|
/>
|
|
<button type="submit" class="rounded-xl bg-amber-400 px-4 py-2 text-sm font-semibold text-zinc-950">Add instruction set</button>
|
|
</form>
|
|
</section>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<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
|
|
name: string
|
|
createdAt: number
|
|
thumbnailDefault: boolean
|
|
protected: boolean
|
|
unlocked: boolean
|
|
}
|
|
|
|
type SettingsPanel = 'gpu' | 'storage' | 'login' | 'folders' | 'loras' | 'keeps' | 'prompts'
|
|
type LoraKindFilter = 'all' | 'image' | 'video'
|
|
|
|
const route = useRoute()
|
|
const router = useRouter()
|
|
|
|
const folders = ref<SettingsFolder[]>([])
|
|
const authMode = ref('')
|
|
const userName = ref('')
|
|
const notice = ref('')
|
|
const noticeError = ref(false)
|
|
let noticeTimer: ReturnType<typeof setTimeout> | null = null
|
|
|
|
const panelIds: SettingsPanel[] = ['gpu', 'storage', 'login', 'folders', 'loras', 'keeps', 'prompts']
|
|
|
|
function parsePanel(raw: unknown): SettingsPanel {
|
|
const value = String(raw || '')
|
|
return panelIds.includes(value as SettingsPanel) ? value as SettingsPanel : 'gpu'
|
|
}
|
|
|
|
const activePanel = computed({
|
|
get() {
|
|
const id = parsePanel(route.query.panel)
|
|
if (id === 'login' && authMode.value && authMode.value !== 'password') return 'gpu'
|
|
return id
|
|
},
|
|
set(id: SettingsPanel) {
|
|
const query = { ...route.query, panel: id }
|
|
void router.replace({ query })
|
|
}
|
|
})
|
|
|
|
const navItems = computed(() => {
|
|
const items: Array<{ id: SettingsPanel; label: string }> = [
|
|
{ id: 'gpu', label: 'GPU' },
|
|
{ id: 'storage', label: 'Storage' }
|
|
]
|
|
if (authMode.value === 'password') items.push({ id: 'login', label: 'Site login' })
|
|
items.push(
|
|
{ id: 'folders', label: 'Folders' },
|
|
{ id: 'loras', label: 'LoRA keys' },
|
|
{ id: 'keeps', label: 'Keep prompts' },
|
|
{ id: 'prompts', label: 'Prompt helper' }
|
|
)
|
|
return items
|
|
})
|
|
|
|
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 }>>([])
|
|
const loraQuery = ref('')
|
|
const loraKind = ref<LoraKindFilter>('all')
|
|
const loraKindOptions: Array<{ id: LoraKindFilter; label: string }> = [
|
|
{ id: 'all', label: 'All' },
|
|
{ id: 'image', label: 'Image' },
|
|
{ id: 'video', label: 'Video' }
|
|
]
|
|
const filteredLoraRows = computed(() => {
|
|
const q = loraQuery.value.trim().toLowerCase()
|
|
return loraRows.value.filter((row) => {
|
|
if (loraKind.value === 'image' && row.kind === 'video') return false
|
|
if (loraKind.value === 'video' && row.kind === 'image') return false
|
|
if (!q) return true
|
|
const hay = `${row.file} ${row.draft} ${row.trigger} ${row.key} ${row.placeholder}`.toLowerCase()
|
|
return hay.includes(q)
|
|
}).sort((a, b) => (a.draft || a.file).localeCompare(b.draft || b.file, undefined, { sensitivity: 'base' }))
|
|
})
|
|
const loginUser = ref('')
|
|
const loginCurrent = ref('')
|
|
const loginNext = ref('')
|
|
const drafts = reactive<Record<string, { name: string; protect: boolean; password: string; currentPassword: string }>>({})
|
|
const storageLoading = ref(true)
|
|
const storageError = ref('')
|
|
const gpuSleeping = ref(false)
|
|
const gpuWaking = ref(false)
|
|
const gpuError = ref(false)
|
|
const gpuMessage = ref('')
|
|
const notifyComplete = ref(true)
|
|
const notifyMessage = ref('')
|
|
const storage = ref<{
|
|
libraryBytes: number
|
|
breakdown: { clips: number; stills: number; tmp: number; other: number }
|
|
volume: { ok: boolean; totalBytes: number | null; usedBytes: number | null; freeBytes: number | null; overlay: boolean }
|
|
} | null>(null)
|
|
|
|
function formatBytes(bytes: number) {
|
|
const n = Math.max(0, Number(bytes) || 0)
|
|
if (n < 1024) return `${Math.round(n)} B`
|
|
const units = ['KB', 'MB', 'GB', 'TB']
|
|
let value = n / 1024
|
|
let unit = 0
|
|
while (value >= 1024 && unit < units.length - 1) {
|
|
value /= 1024
|
|
unit += 1
|
|
}
|
|
const digits = value >= 10 || units[unit] === 'KB' ? 0 : 1
|
|
return `${value.toFixed(digits)} ${units[unit]}`
|
|
}
|
|
|
|
const storageLine = computed(() => {
|
|
if (!storage.value) return ''
|
|
const library = `Library ${formatBytes(storage.value.libraryBytes)}`
|
|
const volume = storage.value.volume
|
|
if (!volume.ok || volume.freeBytes == null || volume.totalBytes == null) {
|
|
return `${library} · free space unavailable`
|
|
}
|
|
const free = `${formatBytes(volume.freeBytes)} free of ${formatBytes(volume.totalBytes)}`
|
|
return volume.overlay ? `${library} · ${free} on the container disk` : `${library} · ${free}`
|
|
})
|
|
|
|
const storageBreakdown = computed(() => {
|
|
if (!storage.value) return ''
|
|
const { clips, stills, tmp } = storage.value.breakdown
|
|
const parts = [
|
|
clips ? `Clips ${formatBytes(clips)}` : '',
|
|
stills ? `Stills ${formatBytes(stills)}` : '',
|
|
tmp ? `Temp ${formatBytes(tmp)}` : ''
|
|
].filter(Boolean)
|
|
return parts.join(' · ')
|
|
})
|
|
|
|
const storageNote = computed(() => {
|
|
if (!storage.value) return ''
|
|
return storage.value.volume.overlay
|
|
? "This account's clips, stills, thumbs, and temp. Free space is the container disk, not a mounted volume."
|
|
: "This account's clips, stills, thumbs, and temp. Volume free space is shared."
|
|
})
|
|
|
|
function toast(message: string, error = true) {
|
|
notice.value = message
|
|
noticeError.value = error
|
|
if (noticeTimer) clearTimeout(noticeTimer)
|
|
noticeTimer = setTimeout(() => { notice.value = '' }, 4200)
|
|
}
|
|
|
|
function applyLibrary(data: unknown) {
|
|
const payload = (data || {}) as { folders?: SettingsFolder[] }
|
|
if (Array.isArray(payload.folders)) folders.value = payload.folders
|
|
}
|
|
|
|
async function loadAccount() {
|
|
const [library, me] = await Promise.all([
|
|
$fetch<{ folders?: SettingsFolder[] }>('/api/library?light=1').catch(() => ({ folders: [] })),
|
|
$fetch<{ user?: { name?: string; email?: string }; authMode?: string }>('/api/auth/me').catch(() => ({}))
|
|
])
|
|
applyLibrary(library)
|
|
userName.value = me.user?.name || me.user?.email || ''
|
|
authMode.value = me.authMode || ''
|
|
if (userName.value) loginUser.value = userName.value
|
|
}
|
|
|
|
onMounted(() => {
|
|
notifyComplete.value = generationNotifyPref()
|
|
void loadAccount()
|
|
void loadPresets()
|
|
void loadStorage()
|
|
void loadLoraKeys()
|
|
})
|
|
|
|
watch(activePanel, () => {
|
|
if (typeof window === 'undefined') return
|
|
window.scrollTo({ top: 0 })
|
|
})
|
|
|
|
onBeforeUnmount(() => {
|
|
if (noticeTimer) clearTimeout(noticeTimer)
|
|
})
|
|
|
|
watch(folders, (list) => {
|
|
for (const folder of list) {
|
|
if (!drafts[folder.id]) {
|
|
drafts[folder.id] = {
|
|
name: folder.name,
|
|
protect: folder.protected,
|
|
password: '',
|
|
currentPassword: ''
|
|
}
|
|
} else {
|
|
drafts[folder.id].name = folder.name
|
|
}
|
|
}
|
|
}, { immediate: true, deep: true })
|
|
|
|
async function saveLogin() {
|
|
try {
|
|
const data = await $fetch<{ username: string }>('/api/auth/credentials', {
|
|
method: 'PUT',
|
|
body: {
|
|
username: loginUser.value,
|
|
password: loginNext.value,
|
|
currentPassword: loginCurrent.value
|
|
}
|
|
})
|
|
loginCurrent.value = ''
|
|
loginNext.value = ''
|
|
userName.value = data.username
|
|
toast('Login updated', false)
|
|
} catch (error: any) {
|
|
toast(error?.data?.statusMessage || 'Could not update site login')
|
|
}
|
|
}
|
|
|
|
async function addFolder() {
|
|
const name = newName.value.trim()
|
|
if (!name) return
|
|
try {
|
|
await $fetch('/api/library/folders', { method: 'POST', body: { name } })
|
|
newName.value = ''
|
|
applyLibrary(await $fetch('/api/library'))
|
|
} catch (error: any) {
|
|
toast( error?.data?.statusMessage || error?.statusMessage || 'Could not create folder')
|
|
}
|
|
}
|
|
|
|
interface PromptPresetRow {
|
|
id: string
|
|
name: string
|
|
description?: string
|
|
builtin?: boolean
|
|
systemPrompt?: string
|
|
}
|
|
|
|
async function loadPresets() {
|
|
const data = await $fetch<{ presets?: PromptPresetRow[] }>('/api/prompt/presets').catch(() => ({ presets: [] }))
|
|
promptPresets.value = data.presets || []
|
|
}
|
|
|
|
async function loadStorage() {
|
|
storageLoading.value = true
|
|
storageError.value = ''
|
|
try {
|
|
storage.value = await $fetch('/api/settings/storage')
|
|
} catch (error: any) {
|
|
storage.value = null
|
|
storageError.value = error?.data?.statusMessage || error?.statusMessage || 'Could not read storage'
|
|
} finally {
|
|
storageLoading.value = false
|
|
}
|
|
}
|
|
|
|
async function onNotifyCompleteChange() {
|
|
const on = notifyComplete.value
|
|
setGenerationNotifyPref(on)
|
|
notifyMessage.value = ''
|
|
if (!on) return
|
|
if (!generationNotifySupported()) {
|
|
notifyMessage.value = 'This browser does not support notifications.'
|
|
return
|
|
}
|
|
const granted = await ensureGenerationNotifyPermission()
|
|
if (!granted) {
|
|
notifyComplete.value = false
|
|
setGenerationNotifyPref(false)
|
|
notifyMessage.value = Notification.permission === 'denied'
|
|
? 'Notifications are blocked in the browser. Allow them for this site, then turn this on again.'
|
|
: 'Could not enable notifications.'
|
|
}
|
|
}
|
|
|
|
async function sleepGpu() {
|
|
if (gpuSleeping.value || gpuWaking.value) return
|
|
gpuSleeping.value = true
|
|
gpuError.value = false
|
|
gpuMessage.value = 'Stopping Comfy on the GPU…'
|
|
try {
|
|
await $fetch('/api/comfy/sleep', { method: 'POST' })
|
|
gpuMessage.value = 'Comfy is stopped. VRAM is free until you generate or wake the GPU.'
|
|
} catch (error: any) {
|
|
gpuError.value = true
|
|
gpuMessage.value = error?.data?.statusMessage || error?.statusMessage || 'Could not stop Comfy.'
|
|
} finally {
|
|
gpuSleeping.value = false
|
|
}
|
|
}
|
|
|
|
async function wakeGpu() {
|
|
if (gpuWaking.value || gpuSleeping.value) return
|
|
gpuWaking.value = true
|
|
gpuError.value = false
|
|
gpuMessage.value = 'Starting Comfy on the GPU…'
|
|
try {
|
|
await $fetch('/api/comfy/wake', { method: 'POST' })
|
|
gpuMessage.value = 'Wake sent. The first job can still take a couple of minutes if Comfy is cold.'
|
|
} catch (error: any) {
|
|
gpuError.value = true
|
|
gpuMessage.value = error?.data?.statusMessage || error?.statusMessage || 'Could not wake the GPU.'
|
|
} finally {
|
|
gpuWaking.value = false
|
|
}
|
|
}
|
|
|
|
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>
|
|
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 }>()
|
|
const add = (name: string, kind: 'image' | 'video') => {
|
|
if (isFilenameSystemLora(name)) return
|
|
const key = loraIdentityKey(name)
|
|
if (!key) return
|
|
const existing = map.get(key)
|
|
if (existing) {
|
|
if (existing.kind !== kind) existing.kind = 'both'
|
|
return
|
|
}
|
|
map.set(key, {
|
|
key,
|
|
file: loraDisplayName(name),
|
|
kind,
|
|
draft: aliases[key] || '',
|
|
trigger: triggers[key] || '',
|
|
placeholder: defaultLoraFilenameKey(name)
|
|
})
|
|
}
|
|
for (const name of listed.image || []) add(name, 'image')
|
|
for (const name of listed.video || []) add(name, 'video')
|
|
loraRows.value = [...map.values()].sort((a, b) => a.file.localeCompare(b.file))
|
|
} finally {
|
|
loraRowsLoading.value = false
|
|
}
|
|
}
|
|
|
|
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)
|
|
applyLibrary(data)
|
|
} catch (error: any) {
|
|
toast( error?.data?.statusMessage || 'Could not save keep prompts')
|
|
}
|
|
}
|
|
|
|
function addKeepItem(kind: KeepPromptKind) {
|
|
const text = sanitizeKeepPromptText(newKeep[kind].text)
|
|
if (!text) {
|
|
toast( 'Snippet text is required')
|
|
return
|
|
}
|
|
if (keepPrompts.value[kind].length >= KEEP_PROMPT_MAX) {
|
|
toast( `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 {
|
|
applyLibrary(await $fetch('/api/library/lora-aliases', {
|
|
method: 'PUT',
|
|
body: { key: row.key, alias: row.draft }
|
|
}))
|
|
} catch (error: any) {
|
|
toast( error?.data?.statusMessage || 'Could not save LoRA filename key')
|
|
}
|
|
}
|
|
|
|
async function saveLoraTrigger(row: { key: string; trigger: string }) {
|
|
try {
|
|
applyLibrary(await $fetch('/api/library/lora-triggers', {
|
|
method: 'PUT',
|
|
body: { key: row.key, trigger: row.trigger }
|
|
}))
|
|
} catch (error: any) {
|
|
toast( error?.data?.statusMessage || 'Could not save LoRA activation word')
|
|
}
|
|
}
|
|
|
|
async function addPreset() {
|
|
const name = newPresetName.value.trim()
|
|
const systemPrompt = newPresetPrompt.value.trim()
|
|
if (!name || !systemPrompt) {
|
|
toast( 'Name and instruction text are required')
|
|
return
|
|
}
|
|
try {
|
|
const data = await $fetch<{ presets?: PromptPresetRow[] }>('/api/prompt/presets', {
|
|
method: 'POST',
|
|
body: { name, systemPrompt }
|
|
})
|
|
promptPresets.value = data.presets || []
|
|
newPresetName.value = ''
|
|
newPresetPrompt.value = ''
|
|
} catch (error: any) {
|
|
toast( error?.data?.statusMessage || 'Could not save instruction set')
|
|
}
|
|
}
|
|
|
|
async function savePreset(preset: PromptPresetRow) {
|
|
if (preset.builtin) return
|
|
try {
|
|
const data = await $fetch<{ presets?: PromptPresetRow[] }>(`/api/prompt/presets/${preset.id}`, {
|
|
method: 'PATCH',
|
|
body: { name: preset.name, systemPrompt: preset.systemPrompt, description: preset.description }
|
|
})
|
|
promptPresets.value = data.presets || promptPresets.value
|
|
} catch (error: any) {
|
|
toast( error?.data?.statusMessage || 'Could not update instruction set')
|
|
}
|
|
}
|
|
|
|
async function removePreset(id: string) {
|
|
if (!confirm('Delete this instruction set?')) return
|
|
try {
|
|
const data = await $fetch<{ presets?: PromptPresetRow[] }>(`/api/prompt/presets/${id}`, { method: 'DELETE' })
|
|
promptPresets.value = data.presets || []
|
|
} catch (error: any) {
|
|
toast( error?.data?.statusMessage || 'Could not delete instruction set')
|
|
}
|
|
}
|
|
|
|
async function rename(folder: SettingsFolder) {
|
|
const name = drafts[folder.id]?.name?.trim()
|
|
if (!name || name === folder.name) return
|
|
try {
|
|
applyLibrary(await $fetch(`/api/library/folders/${folder.id}`, { method: 'PATCH', body: { name } }))
|
|
} catch (error: any) {
|
|
toast( error?.data?.statusMessage || 'Could not rename folder')
|
|
}
|
|
}
|
|
|
|
async function setThumbnail(folder: SettingsFolder, thumbnailDefault: boolean) {
|
|
try {
|
|
applyLibrary(await $fetch(`/api/library/folders/${folder.id}`, {
|
|
method: 'PATCH',
|
|
body: { thumbnailDefault }
|
|
}))
|
|
} catch (error: any) {
|
|
toast( error?.data?.statusMessage || 'Could not update thumbnail default')
|
|
}
|
|
}
|
|
|
|
function toggleProtect(folder: SettingsFolder, on: boolean) {
|
|
drafts[folder.id].protect = on
|
|
if (!on && folder.protected) {
|
|
drafts[folder.id].password = ''
|
|
}
|
|
}
|
|
|
|
async function savePassword(folder: SettingsFolder) {
|
|
const draft = drafts[folder.id]
|
|
try {
|
|
if (!draft.protect && folder.protected) {
|
|
applyLibrary(await $fetch(`/api/library/folders/${folder.id}`, {
|
|
method: 'PATCH',
|
|
body: { password: '', currentPassword: draft.currentPassword }
|
|
}))
|
|
} else if (draft.protect) {
|
|
if (!folder.protected && !draft.password.trim()) {
|
|
toast( 'Set a password with at least 4 characters.')
|
|
return
|
|
}
|
|
if (!folder.protected || draft.password.trim()) {
|
|
applyLibrary(await $fetch(`/api/library/folders/${folder.id}`, {
|
|
method: 'PATCH',
|
|
body: { password: draft.password, currentPassword: draft.currentPassword }
|
|
}))
|
|
}
|
|
}
|
|
draft.password = ''
|
|
draft.currentPassword = ''
|
|
} catch (error: any) {
|
|
toast( error?.data?.statusMessage || 'Could not update folder password')
|
|
}
|
|
}
|
|
|
|
async function remove(folder: SettingsFolder) {
|
|
if (!confirm(`Delete “${folder.name}” and everything inside it?`)) return
|
|
try {
|
|
applyLibrary(await $fetch(`/api/library/folders/${folder.id}`, { method: 'DELETE' }))
|
|
} catch (error: any) {
|
|
toast( error?.data?.statusMessage || 'Could not delete folder')
|
|
}
|
|
}
|
|
</script>
|