Files
FabledCurator/frontend/src/components/settings/GpuAgentCard.vue
T
bvandeusen 359bc5a283
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m25s
feat(ml): default to SigLIP 2 (new installs) + model dropdown, no free-text (#1203)
- Migration 0069: new installs default to SigLIP 2 (so400m, 512px, 1152-d drop-in)
  — UPDATE applies ONLY where no image is embedded yet (fresh install), so an
  existing library is NOT silently invalidated; it switches deliberately via the
  dropdown → Re-embed → Retrain. Column server_defaults moved to SigLIP 2.
- GET /api/ml/embedder-models: server-authoritative supported list (SigLIP 2 512
  recommended / 384 faster / SigLIP 1 384 original) so the UI never free-types.
- GpuAgentCard: the two name/version text fields → a single model dropdown;
  Save sets name+version from the picked option (the current model is always
  selectable even if off-list).
- embedder.py DEFAULT_MODEL_NAME unchanged (stays the baked local-dir SigLIP 1)
  to avoid a local-dir/weights mismatch; SigLIP 2 loads by HF name, cached on the
  ml-worker's persistent HF_HOME.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa
2026-06-30 16:29:27 -04:00

368 lines
14 KiB
Vue

<template>
<MaintenanceTile
icon="mdi-expansion-card"
title="GPU agent (CCIP + crops)"
blurb="Connect a desktop-GPU agent to embed characters (CCIP) and crops. It pulls work over HTTP — your database and Redis stay private."
:open="true"
>
<p class="fc-muted text-body-2 mb-3">
The agent is a container you run on the machine with the GPU. It
authenticates with the token below, leases jobs from this server, computes
on the GPU, and posts results back all over HTTP. Start it when you want
a burst; stop it to reclaim the card.
</p>
<!-- Token -->
<div class="fc-section-h mb-1">Agent token</div>
<div v-if="loading" class="fc-muted text-body-2">Loading</div>
<template v-else>
<div v-if="tokenValue" class="fc-token">
<code class="fc-token__val">{{ masked ? maskedToken : tokenValue }}</code>
<v-btn
size="x-small" variant="text" :icon="masked ? 'mdi-eye' : 'mdi-eye-off'"
:title="masked ? 'Reveal' : 'Hide'" @click="masked = !masked"
/>
<v-btn
size="x-small" variant="text" icon="mdi-content-copy"
title="Copy token" @click="onCopy"
/>
<v-btn
size="small" variant="text" color="accent" class="ml-auto"
prepend-icon="mdi-refresh" :loading="rotating" @click="onRotate"
>Rotate</v-btn>
</div>
<div v-else>
<v-btn
color="accent" variant="flat" rounded="pill" size="small"
prepend-icon="mdi-key-plus" :loading="rotating" @click="onRotate"
>Generate token</v-btn>
</div>
<p class="fc-muted text-caption mt-2 mb-0">
Point the agent at <code>{{ baseUrl }}</code> with this token. Rotating
invalidates the old token update the agent after you rotate.
</p>
</template>
<!-- Queue -->
<div class="fc-section-h mt-5 mb-2">Work queue</div>
<div class="fc-queue">
<div class="fc-q"><div class="fc-q__n">{{ queue.pending }}</div><div class="fc-q__l">pending</div></div>
<div class="fc-q"><div class="fc-q__n">{{ queue.leased }}</div><div class="fc-q__l">in flight</div></div>
<div class="fc-q"><div class="fc-q__n fc-good">{{ queue.done }}</div><div class="fc-q__l">done</div></div>
<div class="fc-q"><div class="fc-q__n" :class="queue.error ? 'fc-weak' : ''">{{ queue.error }}</div><div class="fc-q__l">errored</div></div>
</div>
<v-btn
class="mt-4" color="accent" variant="tonal" rounded="pill" size="small"
prepend-icon="mdi-account-box-multiple" :loading="backfilling" @click="onBackfill"
>Queue character embedding (CCIP)</v-btn>
<p class="fc-muted text-caption mt-2 mb-0">
Enqueues every image that doesn't have a CCIP embedding yet. Nothing
processes until the agent is running.
</p>
<v-btn
class="mt-3" color="accent" variant="tonal" rounded="pill" size="small"
prepend-icon="mdi-crop" :loading="backfillingSiglip" @click="onBackfillSiglip"
>Queue concept crops (SigLIP)</v-btn>
<p class="fc-muted text-caption mt-2 mb-0">
Enqueues every image that doesn't have concept-crop embeddings yet the
localized vectors that help small/local tags (glasses, etc.) surface. New
images get these automatically; this catches the back-catalogue.
</p>
<v-btn
class="mt-3" color="warning" variant="tonal" rounded="pill" size="small"
prepend-icon="mdi-backup-restore" :loading="reprocessing" @click="onReprocess"
>Re-process library (re-detect + re-crop)</v-btn>
<p class="fc-muted text-caption mt-2 mb-0">
Re-runs the FULL pipeline (figure detection + CCIP + concept/panel crops) on
<b>every</b> image use after changing crop detectors so the back-catalogue
gets re-cropped, not just new images. Heavy: re-processes the whole library.
</p>
<!-- Match strictness -->
<div class="fc-section-h mt-5 mb-1">Character-match strictness</div>
<div v-if="ml.settings" class="d-flex align-center" style="gap:12px">
<v-slider
v-model="threshold" :min="0.70" :max="0.95" :step="0.01"
color="accent" hide-details density="compact" class="flex-grow-1"
:loading="savingThreshold" @end="onSaveThreshold"
/>
<span class="fc-q__n" style="font-size:16px">{{ threshold.toFixed(2) }}</span>
</div>
<p class="fc-muted text-caption mt-1 mb-0">
How close a figure must be (CCIP cosine) to suggest a character. Higher =
stricter fewer but more confident matches. 0.85 recommended; below ~0.80
a heavily-tagged character starts matching everything.
</p>
<!-- Auto-apply -->
<div v-if="ml.settings" class="d-flex align-center mt-5" style="gap:12px">
<v-switch
v-model="autoApply" color="accent" hide-details density="compact"
:loading="savingAuto" label="Auto-apply confident matches"
@update:model-value="onSaveAuto"
/>
<v-text-field
v-model.number="autoThreshold" type="number" min="0.80" max="0.99"
step="0.01" density="compact" hide-details variant="outlined"
style="max-width:96px" :disabled="!autoApply" label="at"
@change="onSaveAuto"
/>
</div>
<p class="fc-muted text-caption mt-1 mb-0">
When on, a very-confident character match tags the image on its own (daily,
reversible) so identity tags keep flowing without review. Stricter than
the suggest cut; 0.92 recommended.
</p>
<!-- Embedding model -->
<div v-if="ml.settings" class="fc-section-h mt-5 mb-1">Embedding model</div>
<div v-if="ml.settings">
<v-select
v-model="selectedModel" :items="modelItems" item-title="label"
item-value="name" label="Model" density="compact" hide-details
variant="outlined"
/>
<div class="d-flex mt-3" style="gap:8px">
<v-btn
size="small" variant="tonal" rounded="pill" :loading="savingModel"
prepend-icon="mdi-content-save" @click="onSaveModel"
>Save model</v-btn>
<v-btn
size="small" color="accent" variant="flat" rounded="pill"
:loading="reembedding" prepend-icon="mdi-backup-restore" @click="onReembed"
>Re-embed library (GPU)</v-btn>
</div>
<p class="fc-muted text-caption mt-2 mb-0">
Switching the model is a DIFFERENT embedding space. After <b>Save model</b>,
run <b>Re-embed library</b> (the GPU agent re-embeds whole images + concept
crops), then <b>Retrain heads</b> suggestions degrade until both finish.
SigLIP 2 (512px) is a 1152-d drop-in over SigLIP 1; new installs default to
it. Your existing library stays on its current model until you re-embed.
</p>
</div>
</MaintenanceTile>
</template>
<script setup>
import { toast } from '../../utils/toast.js'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import MaintenanceTile from '../common/MaintenanceTile.vue'
import { useGpuStore } from '../../stores/gpu.js'
import { useMLStore } from '../../stores/ml.js'
import { copyText } from '../../utils/clipboard.js'
const store = useGpuStore()
const ml = useMLStore()
const loading = ref(true)
const tokenValue = ref(null)
const masked = ref(true)
const rotating = ref(false)
const backfilling = ref(false)
const backfillingSiglip = ref(false)
const reprocessing = ref(false)
const threshold = ref(0.85)
const savingThreshold = ref(false)
const autoApply = ref(true)
const autoThreshold = ref(0.92)
const savingAuto = ref(false)
const modelItems = ref([])
const selectedModel = ref(null)
const savingModel = ref(false)
const reembedding = ref(false)
const queue = ref({ pending: 0, leased: 0, done: 0, error: 0 })
let pollTimer = null
const baseUrl = computed(() => window.location.origin)
const maskedToken = computed(() => {
const t = tokenValue.value || ''
return t.length > 8 ? `${t.slice(0, 4)}••••••••${t.slice(-4)}` : '••••••••'
})
onMounted(async () => {
try {
tokenValue.value = (await store.token()).token
} catch { /* non-fatal */ } finally {
loading.value = false
}
await refreshQueue()
pollTimer = setInterval(() => { if (!document.hidden) refreshQueue() }, 5000)
try {
await ml.loadSettings()
if (ml.settings?.ccip_match_threshold != null) {
threshold.value = ml.settings.ccip_match_threshold
}
if (ml.settings?.ccip_auto_apply_enabled != null) {
autoApply.value = ml.settings.ccip_auto_apply_enabled
autoThreshold.value = ml.settings.ccip_auto_apply_threshold
}
if (ml.settings?.embedder_model_name != null) {
const items = await ml.embedderModels()
// Make sure the current model is selectable even if it's not in the list.
const cur = ml.settings.embedder_model_name
if (!items.some((m) => m.name === cur)) {
items.push({ name: cur, version: ml.settings.embedder_model_version, label: `${cur} (current)` })
}
modelItems.value = items
selectedModel.value = cur
}
} catch { /* non-fatal */ }
})
async function onSaveModel() {
const opt = modelItems.value.find((m) => m.name === selectedModel.value)
if (!opt) return
savingModel.value = true
try {
await ml.patchSettings({
embedder_model_name: opt.name,
embedder_model_version: opt.version,
})
toast({ text: 'Embedding model saved — now Re-embed library, then Retrain heads', type: 'success' })
} catch (e) {
toast({ text: `Could not save model: ${e.message}`, type: 'error' })
} finally {
savingModel.value = false
}
}
async function onReembed() {
reembedding.value = true
try {
await store.backfill('embed')
await store.backfill('siglip')
toast({ text: 'Queued whole-image + concept re-embed — run the agent, then Retrain heads', type: 'success' })
await refreshQueue()
} catch (e) {
toast({ text: `Could not queue re-embed: ${e.message}`, type: 'error' })
} finally {
reembedding.value = false
}
}
async function onSaveAuto() {
savingAuto.value = true
try {
await ml.patchSettings({
ccip_auto_apply_enabled: autoApply.value,
ccip_auto_apply_threshold: autoThreshold.value,
})
toast({ text: 'Auto-apply settings saved', type: 'success' })
} catch (e) {
toast({ text: `Could not save: ${e.message}`, type: 'error' })
} finally {
savingAuto.value = false
}
}
onUnmounted(() => { if (pollTimer) clearInterval(pollTimer) })
async function onSaveThreshold() {
savingThreshold.value = true
try {
await ml.patchSettings({ ccip_match_threshold: threshold.value })
toast({ text: `Match strictness set to ${threshold.value.toFixed(2)}`, type: 'success' })
} catch (e) {
toast({ text: `Could not save: ${e.message}`, type: 'error' })
} finally {
savingThreshold.value = false
}
}
async function refreshQueue() {
try { queue.value = await store.status() } catch { /* non-fatal */ }
}
async function onRotate() {
rotating.value = true
try {
tokenValue.value = (await store.rotateToken()).token
masked.value = false
toast({ text: 'New agent token generated — update your agent', type: 'success' })
} catch (e) {
toast({ text: `Could not rotate token: ${e.message}`, type: 'error' })
} finally {
rotating.value = false
}
}
async function onCopy() {
try {
await copyText(tokenValue.value || '') // resolves on success, throws on fail
toast({ text: 'Token copied', type: 'success' })
} catch {
toast({ text: 'Copy failed — select and copy manually', type: 'warning' })
}
}
async function onBackfill() {
backfilling.value = true
try {
await store.backfill('ccip')
toast({ text: 'Queued CCIP embedding — run the agent to process it', type: 'success' })
await refreshQueue()
} catch (e) {
toast({ text: `Could not queue backfill: ${e.message}`, type: 'error' })
} finally {
backfilling.value = false
}
}
async function onBackfillSiglip() {
backfillingSiglip.value = true
try {
await store.backfill('siglip')
toast({ text: 'Queued concept crops — run the agent to process them', type: 'success' })
await refreshQueue()
} catch (e) {
toast({ text: `Could not queue backfill: ${e.message}`, type: 'error' })
} finally {
backfillingSiglip.value = false
}
}
async function onReprocess() {
if (!window.confirm('Re-process the ENTIRE library (re-detect + re-crop every image)? This is heavy and runs on the GPU agent.')) return
reprocessing.value = true
try {
await store.reprocess('ccip')
toast({ text: 'Library queued for re-processing — run the agent to drain it', type: 'success' })
await refreshQueue()
} catch (e) {
toast({ text: `Could not start re-process: ${e.message}`, type: 'error' })
} finally {
reprocessing.value = false
}
}
</script>
<style scoped>
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
.fc-section-h {
font-size: 13px; font-weight: 700; letter-spacing: 0.03em;
text-transform: uppercase; color: rgb(var(--v-theme-on-surface));
}
.fc-token {
display: flex; align-items: center; gap: 4px;
background: rgb(var(--v-theme-surface-light)); border-radius: 6px;
padding: 4px 6px 4px 10px;
}
.fc-token__val {
font-family: 'JetBrains Mono', monospace; font-size: 13px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.fc-queue { display: flex; gap: 24px; }
.fc-q__n {
font-size: 20px; font-weight: 700; line-height: 1.1;
font-family: 'JetBrains Mono', monospace;
}
.fc-q__l {
font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em;
color: rgb(var(--v-theme-on-surface-variant));
}
.fc-good { color: rgb(var(--v-theme-success)); }
.fc-weak { color: rgb(var(--v-theme-error)); }
</style>