Files
aigen/components/SettingsForm.vue
T
TowstyandCursor 06e40793db Fix LoRA folder drops not appearing without weakening engine filters.
Stop empty Comfy blips from freezing the LoRA cache timestamp, and refresh the picker list from Comfy on each poll. Engine tagging (flux/krea) and xaigen platform filtering stay as they were.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-04 15:52:31 -05:00

969 lines
40 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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 & defaults</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 || gpuForceResetting"
@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 || gpuForceResetting"
@click="wakeGpu"
>
{{ gpuWaking ? 'Starting GPU…' : 'Wake GPU' }}
</button>
<button
type="button"
class="rounded-xl border border-red-400/40 px-3 py-2 text-sm text-red-200 hover:border-red-300 hover:text-red-50 disabled:cursor-not-allowed disabled:opacity-60"
:disabled="gpuForceResetting || gpuSleeping || gpuWaking"
title="Cancel stuck Studio jobs, clear Comfy’s queue, and kill Comfy even if a job is wedged"
@click="forceResetGpu"
>
{{ gpuForceResetting ? 'Force resetting…' : 'Force reset GPU' }}
</button>
</div>
<p class="text-[11px] text-zinc-500">
Force reset: clears stuck/invisible queue jobs, interrupts Comfy, and kills it if Sleep is blocked. Does not shut the PC down.
</p>
<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>
<div class="mt-6 border-t border-white/10 pt-5">
<h3 class="text-sm font-semibold text-zinc-200">Default video length</h3>
<p class="mt-1 text-xs text-zinc-500">New Studio jobs and new shot cards start at this length. Extend still copies the clip you are extending.</p>
<div class="mt-3 max-w-xs">
<DurationField
v-model="defaultVideoDuration"
label="Seconds"
lock-key="default-video-duration"
@update:model-value="onDefaultVideoDuration"
/>
</div>
<p class="mt-2 text-xs text-zinc-500">Saved as {{ defaultVideoDuration }}s for this browser.</p>
</div>
</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'
import {
VIDEO_DURATION_DEFAULT,
clampVideoDuration,
defaultVideoDurationPref,
setDefaultVideoDurationPref
} from '~/utils/videoDuration'
import { generationNotifyPref, setGenerationNotifyPref, generationNotifySupported, ensureGenerationNotifyPermission } from '~/utils/generationNotify'
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 & defaults' },
{ 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)
})
})
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 gpuForceResetting = ref(false)
const gpuError = ref(false)
const gpuMessage = ref('')
const notifyComplete = ref(true)
const notifyMessage = ref('')
const defaultVideoDuration = ref(VIDEO_DURATION_DEFAULT)
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()
defaultVideoDuration.value = defaultVideoDurationPref()
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.'
}
}
function onDefaultVideoDuration(value: number) {
defaultVideoDuration.value = setDefaultVideoDurationPref(value)
}
async function sleepGpu() {
if (gpuSleeping.value || gpuWaking.value || gpuForceResetting.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 || gpuForceResetting.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 forceResetGpu() {
if (gpuForceResetting.value || gpuSleeping.value || gpuWaking.value) return
const ok = window.confirm(
'Force reset cancels every running/waiting Studio and music job, wipes shot batches, and kills Comfy even if wedged. Continue?'
)
if (!ok) return
gpuForceResetting.value = true
gpuError.value = false
gpuMessage.value = 'Force-resetting Comfy and clearing stuck jobs…'
try {
const result = await $fetch<{
message?: string
cleared?: { cancelledStudio?: number; clearedPending?: number; cancelledLive?: number }
}>('/api/comfy/force-reset', { method: 'POST' })
const c = result.cleared || {}
gpuMessage.value = result.message
|| `Cleared ${c.cancelledStudio || 0} queue job(s), ${c.clearedPending || 0} pending, ${c.cancelledLive || 0} live. Comfy is stopped — Wake when ready.`
} catch (error: any) {
gpuError.value = true
gpuMessage.value = error?.data?.statusMessage || error?.statusMessage || 'Could not force-reset Comfy.'
} finally {
gpuForceResetting.value = false
}
}
async function loadLoraKeys() {
loraRowsLoading.value = true
try {
const listed = await $fetch<{ image?: string[]; video?: string[] }>('/api/loras?fresh=1').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>