|
|
|
@@ -0,0 +1,241 @@
|
|
|
|
|
<template>
|
|
|
|
|
<MaintenanceTile
|
|
|
|
|
icon="mdi-brain"
|
|
|
|
|
title="Concept heads (the learning suggester)"
|
|
|
|
|
blurb="Train the per-concept heads that turn your tags into suggestions — they replace Camie and sharpen every time you accept or reject."
|
|
|
|
|
:open="headCount > 0 || running"
|
|
|
|
|
>
|
|
|
|
|
<p class="fc-muted text-body-2 mb-3">
|
|
|
|
|
A <strong>head</strong> is a tiny classifier trained on the SigLIP
|
|
|
|
|
embeddings already stored on your images — your positives plus your
|
|
|
|
|
negatives (rejections). One is built per general/character concept with at
|
|
|
|
|
least <strong>{{ minPositives }}</strong> tagged images. Retrain after a
|
|
|
|
|
tagging session to fold in your latest accepts/rejects; scoring is live, so
|
|
|
|
|
the rail reflects a retrain on the next image you open.
|
|
|
|
|
</p>
|
|
|
|
|
|
|
|
|
|
<!-- Summary stats -->
|
|
|
|
|
<div class="fc-stats mb-3">
|
|
|
|
|
<div class="fc-stat">
|
|
|
|
|
<div class="fc-stat__n">{{ headCount }}</div>
|
|
|
|
|
<div class="fc-stat__l">heads</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="fc-stat">
|
|
|
|
|
<div class="fc-stat__n">{{ graduatedCount }}</div>
|
|
|
|
|
<div class="fc-stat__l" title="Heads precise enough to auto-apply without review">auto-apply ready</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="fc-stat">
|
|
|
|
|
<div class="fc-stat__n fc-stat__n--time">{{ lastTrained }}</div>
|
|
|
|
|
<div class="fc-stat__l">last trained</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<v-btn
|
|
|
|
|
v-if="!running"
|
|
|
|
|
color="accent" variant="flat" rounded="pill"
|
|
|
|
|
prepend-icon="mdi-play" :loading="busy" @click="onTrain"
|
|
|
|
|
>{{ headCount > 0 ? 'Retrain heads' : 'Train heads' }}</v-btn>
|
|
|
|
|
|
|
|
|
|
<div v-if="running" class="mt-3">
|
|
|
|
|
<v-progress-linear indeterminate color="accent" />
|
|
|
|
|
<div class="text-body-2 mt-2 fc-muted">Training… (started {{ startedAgo }})</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<v-alert
|
|
|
|
|
v-if="lastError"
|
|
|
|
|
type="error" variant="tonal" density="compact" class="mt-3"
|
|
|
|
|
>Training failed: {{ lastError }}</v-alert>
|
|
|
|
|
|
|
|
|
|
<!-- Empty state -->
|
|
|
|
|
<div v-if="!running && headCount === 0" class="fc-empty mt-4">
|
|
|
|
|
<v-icon size="32" color="accent">mdi-brain</v-icon>
|
|
|
|
|
<p class="fc-muted text-body-2 mt-2 mb-0">
|
|
|
|
|
No heads yet. Tag a handful of images for the concepts you care about,
|
|
|
|
|
then train — each concept with ≥ {{ minPositives }} tags becomes a head.
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Per-concept table -->
|
|
|
|
|
<div v-if="heads.length" class="mt-4">
|
|
|
|
|
<div class="fc-muted text-caption mb-2">
|
|
|
|
|
{{ heads.length }} concept{{ heads.length === 1 ? '' : 's' }}, strongest first
|
|
|
|
|
(AP = average precision; auto-apply ⚡ = precise enough to fire without review)
|
|
|
|
|
</div>
|
|
|
|
|
<div class="fc-table-wrap">
|
|
|
|
|
<table class="fc-table">
|
|
|
|
|
<thead>
|
|
|
|
|
<tr>
|
|
|
|
|
<th class="fc-l">Concept</th>
|
|
|
|
|
<th>Cat</th>
|
|
|
|
|
<th class="fc-r" title="Tagged positives the head trained on">+tags</th>
|
|
|
|
|
<th class="fc-r">AP</th>
|
|
|
|
|
<th class="fc-r" title="Precision at the suggest operating point">P</th>
|
|
|
|
|
<th class="fc-r" title="Recall at the suggest operating point">R</th>
|
|
|
|
|
<th class="fc-c">⚡</th>
|
|
|
|
|
</tr>
|
|
|
|
|
</thead>
|
|
|
|
|
<tbody>
|
|
|
|
|
<tr v-for="h in heads" :key="h.tag_id">
|
|
|
|
|
<td class="fc-l">{{ h.name }}</td>
|
|
|
|
|
<td><span class="fc-cat">{{ h.category }}</span></td>
|
|
|
|
|
<td class="fc-r fc-mono">{{ h.n_pos }}</td>
|
|
|
|
|
<td class="fc-r fc-mono" :class="apClass(h.ap)">{{ pct(h.ap) }}</td>
|
|
|
|
|
<td class="fc-r fc-mono">{{ pct(h.precision) }}</td>
|
|
|
|
|
<td class="fc-r fc-mono">{{ pct(h.recall) }}</td>
|
|
|
|
|
<td class="fc-c">
|
|
|
|
|
<v-icon v-if="h.auto_apply" size="16" color="success"
|
|
|
|
|
title="Auto-apply ready">mdi-lightning-bolt</v-icon>
|
|
|
|
|
<span v-else class="fc-muted">—</span>
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
|
|
|
|
</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 { useHeadsStore } from '../../stores/heads.js'
|
|
|
|
|
import { useMLStore } from '../../stores/ml.js'
|
|
|
|
|
|
|
|
|
|
const store = useHeadsStore()
|
|
|
|
|
const mlSettings = useMLStore()
|
|
|
|
|
const summary = ref(null)
|
|
|
|
|
const busy = ref(false)
|
|
|
|
|
let pollTimer = null
|
|
|
|
|
|
|
|
|
|
const headCount = computed(() => summary.value?.head_count ?? 0)
|
|
|
|
|
const graduatedCount = computed(() => summary.value?.graduated_count ?? 0)
|
|
|
|
|
const heads = computed(() => summary.value?.heads ?? [])
|
|
|
|
|
const running = computed(() => summary.value?.running_id != null)
|
|
|
|
|
const minPositives = computed(() => mlSettings.settings?.head_min_positives ?? 8)
|
|
|
|
|
const lastTrained = computed(() =>
|
|
|
|
|
summary.value?.last_trained_at ? relTime(summary.value.last_trained_at) : 'never')
|
|
|
|
|
// Surface the most recent terminal run's error (if it ended in error).
|
|
|
|
|
const lastError = computed(() => {
|
|
|
|
|
const r = (summary.value?.runs || []).find(x => x.status !== 'running')
|
|
|
|
|
return r && r.status === 'error' ? r.error : null
|
|
|
|
|
})
|
|
|
|
|
const startedAgo = computed(() => {
|
|
|
|
|
const r = (summary.value?.runs || []).find(x => x.status === 'running')
|
|
|
|
|
return r?.started_at ? formatTime(r.started_at) : ''
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
onMounted(async () => {
|
|
|
|
|
// Settings power the "min N tags" copy; non-fatal if it fails.
|
|
|
|
|
mlSettings.loadSettings().catch(() => {})
|
|
|
|
|
await refresh()
|
|
|
|
|
if (running.value) startPoll()
|
|
|
|
|
})
|
|
|
|
|
onUnmounted(stopPoll)
|
|
|
|
|
|
|
|
|
|
async function refresh() {
|
|
|
|
|
try {
|
|
|
|
|
summary.value = await store.status()
|
|
|
|
|
} catch { /* non-fatal — the card still offers a fresh train */ }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function startPoll() {
|
|
|
|
|
stopPoll()
|
|
|
|
|
pollTimer = setInterval(async () => {
|
|
|
|
|
await refresh()
|
|
|
|
|
if (!running.value) stopPoll()
|
|
|
|
|
}, 5000)
|
|
|
|
|
}
|
|
|
|
|
function stopPoll() {
|
|
|
|
|
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function onTrain() {
|
|
|
|
|
busy.value = true
|
|
|
|
|
try {
|
|
|
|
|
await store.train()
|
|
|
|
|
await refresh()
|
|
|
|
|
startPoll()
|
|
|
|
|
} catch (e) {
|
|
|
|
|
const msg = e.body?.running_id ? 'Training is already running.' : e.message
|
|
|
|
|
toast({ text: `Could not start training: ${msg}`, type: 'error' })
|
|
|
|
|
} finally {
|
|
|
|
|
busy.value = false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function pct(x) { return x == null ? '—' : `${Math.round(x * 100)}%` }
|
|
|
|
|
function apClass(ap) {
|
|
|
|
|
if (ap == null) return ''
|
|
|
|
|
if (ap >= 0.85) return 'fc-good'
|
|
|
|
|
if (ap >= 0.7) return 'fc-ok'
|
|
|
|
|
return 'fc-weak'
|
|
|
|
|
}
|
|
|
|
|
function formatTime(iso) {
|
|
|
|
|
if (!iso) return ''
|
|
|
|
|
try { return new Date(iso).toLocaleString() } catch { return iso }
|
|
|
|
|
}
|
|
|
|
|
function relTime(iso) {
|
|
|
|
|
try {
|
|
|
|
|
const d = (Date.now() - new Date(iso).getTime()) / 1000
|
|
|
|
|
if (d < 60) return 'just now'
|
|
|
|
|
if (d < 3600) return `${Math.floor(d / 60)}m ago`
|
|
|
|
|
if (d < 86400) return `${Math.floor(d / 3600)}h ago`
|
|
|
|
|
return `${Math.floor(d / 86400)}d ago`
|
|
|
|
|
} catch { return iso }
|
|
|
|
|
}
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
|
|
|
|
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
|
|
|
|
|
|
|
|
|
|
.fc-stats { display: flex; gap: 28px; }
|
|
|
|
|
.fc-stat__n {
|
|
|
|
|
font-size: 22px; font-weight: 700; line-height: 1.1;
|
|
|
|
|
color: rgb(var(--v-theme-on-surface));
|
|
|
|
|
font-family: 'JetBrains Mono', monospace;
|
|
|
|
|
}
|
|
|
|
|
.fc-stat__n--time { font-size: 15px; font-weight: 600; }
|
|
|
|
|
.fc-stat__l {
|
|
|
|
|
font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em;
|
|
|
|
|
color: rgb(var(--v-theme-on-surface-variant));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.fc-empty {
|
|
|
|
|
text-align: center; padding: 18px 12px;
|
|
|
|
|
border: 1px dashed rgb(var(--v-theme-surface-light)); border-radius: 8px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.fc-table-wrap {
|
|
|
|
|
max-height: 360px; overflow-y: auto;
|
|
|
|
|
border: 1px solid rgb(var(--v-theme-surface-light)); border-radius: 8px;
|
|
|
|
|
}
|
|
|
|
|
.fc-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
|
|
|
|
.fc-table thead th {
|
|
|
|
|
position: sticky; top: 0; z-index: 1;
|
|
|
|
|
background: rgb(var(--v-theme-surface));
|
|
|
|
|
text-align: right; padding: 6px 10px; font-weight: 600;
|
|
|
|
|
color: rgb(var(--v-theme-on-surface-variant));
|
|
|
|
|
border-bottom: 1px solid rgb(var(--v-theme-surface-light));
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
}
|
|
|
|
|
.fc-table td {
|
|
|
|
|
padding: 5px 10px; text-align: right;
|
|
|
|
|
border-bottom: 1px solid rgba(var(--v-theme-surface-light), 0.5);
|
|
|
|
|
}
|
|
|
|
|
.fc-table tbody tr:hover { background: rgb(var(--v-theme-surface-light)); }
|
|
|
|
|
.fc-l { text-align: left !important; }
|
|
|
|
|
.fc-r { text-align: right; }
|
|
|
|
|
.fc-c { text-align: center !important; }
|
|
|
|
|
.fc-mono { font-family: 'JetBrains Mono', monospace; }
|
|
|
|
|
.fc-cat {
|
|
|
|
|
font-size: 10px; text-transform: uppercase; letter-spacing: 0.03em;
|
|
|
|
|
color: rgb(var(--v-theme-on-surface-variant));
|
|
|
|
|
background: rgb(var(--v-theme-surface-light));
|
|
|
|
|
padding: 1px 6px; border-radius: 999px;
|
|
|
|
|
}
|
|
|
|
|
.fc-good { color: rgb(var(--v-theme-success)); }
|
|
|
|
|
.fc-ok { color: rgb(var(--v-theme-on-surface)); }
|
|
|
|
|
.fc-weak { color: rgb(var(--v-theme-error)); }
|
|
|
|
|
</style>
|