Files
FabledCurator/frontend/src/components/settings/GpuAgentCard.vue
T
bvandeusen 625336b6b4
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 25s
CI / integration (push) Successful in 3m28s
feat(ccip): tunable match threshold, default 0.85 (#114)
Live data showed the v1 flat 0.75 cosine over-fired — ~64% of matched images got
3-10 character guesses dominated by the most-referenced characters (a 27-ref
character clears a low bar on many images). A sweep showed 0.85 collapses the
noise (noisy multi-matches 47→3) while keeping the confident single-character
matches.

- ml_settings.ccip_match_threshold (migration 0063, default 0.85); match_image
  reads it (override still accepted). DEFAULT_SIM_THRESHOLD fallback 0.75→0.85.
- Exposed in GET/PATCH /api/ml/settings (validated 0.5–0.999).
- Slider in the GPU agent card ("Character-match strictness") — tune live, no
  redeploy, same observe-and-tune loop as auto-apply.

Test: a ~0.9-cosine figure matches at 0.85, dropped at 0.95.

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

205 lines
7.3 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>
<!-- 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>
</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 threshold = ref(0.85)
const savingThreshold = 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
}
} catch { /* non-fatal */ }
})
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
}
}
</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>