359bc5a283
- 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
368 lines
14 KiB
Vue
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>
|