5b34c9221c
Settings IA per the approved A3 design (the old layout was the two-app merge fossilized): - Import tab retired: ImportTriggerPanel + ImportTaskList deleted (manual /import scans stay API-level; imports arrive via downloads/extension, heal via the Layer-2 auto-refetch sweep, and show in Activity). ImportFiltersForm moves to Maintenance → 'Ingestion & filters' and loads its own settings; the import store shrinks to settings-only (no remaining consumers of the scan/task-list machinery). Overview's pending banner now points at Activity. - Maintenance regrouped: Ingestion & filters / GPU agent & embeddings (GpuAgent, Failed processing, CPU embedding backfill) / Tagging (sliders, Heads, Aliases) / Library health (MissingFiles, Thumbnails, DB, Archive re-extract demoted last) / Storage. - One extension home: BrowserExtensionCard moves from Settings → Overview to Subscriptions → Settings, above the API key bar it authenticates. - Single-color import filter WIRED: skip_single_color/threshold existed since FC-2 but nothing read them (the audit module's docstring said as much) — now enforced on both import paths via the audit's canonical predicate (tolerance 30, matching the Cleanup card default; animated images exempt like the transparency check). Default stays off; test added. - Dead weight: PlaceholderView (zero refs) and the permanently-disabled 'Export failed logs (CSV — v2)' menu stub deleted; stale docs fixed (celery queue docstring, threshold comment citing retired tasks, ml package docstring, HeadsCard 'replaces Camie' blurb). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CDgx8bQS5YrGRK76v8HUnM
456 lines
16 KiB
Vue
456 lines
16 KiB
Vue
<template>
|
|
<MaintenanceTile
|
|
icon="mdi-brain"
|
|
title="Concept heads (the learning suggester)"
|
|
blurb="Train the per-concept heads that turn your tags into suggestions — they learn from your library and sharpen every time you accept or reject."
|
|
:open="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>
|
|
|
|
<!-- Earned auto-apply -->
|
|
<div class="fc-auto mt-6">
|
|
<div class="d-flex align-center mb-1" style="gap: 10px;">
|
|
<v-icon size="18" color="accent">mdi-lightning-bolt</v-icon>
|
|
<span class="fc-section-h">Auto-apply</span>
|
|
<v-switch
|
|
v-model="autoEnabled" :loading="settingBusy" hide-details density="compact"
|
|
color="success" class="ml-auto" @update:model-value="onToggleAuto"
|
|
/>
|
|
</div>
|
|
<p class="fc-muted text-body-2 mb-3">
|
|
Graduated heads (⚡, with ≥ {{ autoMinPosInput }} examples) apply their tag
|
|
on their own where they clear {{ Math.round((autoPrecisionInput || 0) * 100) }}%
|
|
precision. Every auto-tag is reversible; removing one teaches the head it
|
|
misfired.
|
|
</p>
|
|
|
|
<div class="d-flex mb-3" style="gap: 12px;">
|
|
<v-text-field
|
|
v-model.number="autoPrecisionInput" label="Precision target"
|
|
type="number" min="0.5" max="0.999" step="0.01" density="compact"
|
|
hide-details style="max-width: 200px;" :disabled="settingBusy"
|
|
@change="onSaveSettings"
|
|
/>
|
|
<v-text-field
|
|
v-model.number="autoMinPosInput" label="Min examples to fire"
|
|
type="number" min="1" density="compact" hide-details
|
|
style="max-width: 200px;" :disabled="settingBusy"
|
|
@change="onSaveSettings"
|
|
/>
|
|
</div>
|
|
|
|
<div class="d-flex" style="gap: 8px;">
|
|
<v-btn
|
|
size="small" variant="tonal" color="accent" rounded="pill"
|
|
prepend-icon="mdi-eye-outline" :loading="autoBusy" @click="onPreview"
|
|
>Preview</v-btn>
|
|
<v-btn
|
|
size="small" variant="flat" color="accent" rounded="pill"
|
|
prepend-icon="mdi-lightning-bolt"
|
|
:loading="autoBusy || autoRunning" :disabled="!autoEnabled"
|
|
@click="onApplyNow"
|
|
>Apply now</v-btn>
|
|
</div>
|
|
|
|
<div v-if="autoRunning" class="mt-3">
|
|
<v-progress-linear indeterminate color="accent" />
|
|
<div class="text-body-2 mt-2 fc-muted">Sweeping the library…</div>
|
|
</div>
|
|
|
|
<div v-if="lastSweep && !autoRunning" class="mt-3">
|
|
<div class="fc-muted text-caption mb-1">
|
|
{{ lastSweep.dry_run ? 'Preview' : 'Applied' }} ·
|
|
{{ formatTime(lastSweep.finished_at) }} ·
|
|
{{ lastSweep.dry_run ? 'would apply' : 'applied' }}
|
|
<strong>{{ sweepTotal(lastSweep) }}</strong>
|
|
tag{{ sweepTotal(lastSweep) === 1 ? '' : 's' }}
|
|
</div>
|
|
<div v-if="sweepConcepts(lastSweep).length" class="fc-chips">
|
|
<span v-for="c in sweepConcepts(lastSweep)" :key="c.tag_id" class="fc-chip">
|
|
{{ c.name }} <strong>{{ c.applied }}</strong>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Performance / tuning -->
|
|
<div v-if="metricsConcepts.length" class="mt-5">
|
|
<div class="fc-section-h mb-1">How auto-apply is landing</div>
|
|
<div class="fc-muted text-caption mb-2">
|
|
Misfire = an auto-tag you removed; missed = a tag you added by hand that a
|
|
head should have caught. Tune the precision target from the misfire rate.
|
|
</div>
|
|
<div class="fc-table-wrap">
|
|
<table class="fc-table">
|
|
<thead>
|
|
<tr>
|
|
<th class="fc-l">Concept</th>
|
|
<th class="fc-r" title="Tags currently auto-applied">applied</th>
|
|
<th class="fc-r" title="Auto-tags you removed">misfires</th>
|
|
<th class="fc-r" title="Removed / (applied + removed)">rate</th>
|
|
<th class="fc-r" title="Tags you added by hand that a head exists for">missed</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="c in metricsConcepts" :key="c.tag_id">
|
|
<td class="fc-l">{{ c.name }}</td>
|
|
<td class="fc-r fc-mono">{{ c.n_auto_applied }}</td>
|
|
<td class="fc-r fc-mono">{{ c.n_misfires }}</td>
|
|
<td class="fc-r fc-mono" :class="rateClass(c.misfire_rate)">
|
|
{{ ratePct(c.misfire_rate) }}
|
|
</td>
|
|
<td class="fc-r fc-mono">{{ c.n_underfires }}</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
|
|
|
|
// --- Auto-apply state ---
|
|
const autoEnabled = ref(false)
|
|
const autoPrecisionInput = ref(0.97)
|
|
const autoMinPosInput = ref(30)
|
|
const settingBusy = ref(false)
|
|
const autoBusy = ref(false)
|
|
const autoStatus = ref(null)
|
|
const metricsData = ref(null)
|
|
let autoTimer = null
|
|
|
|
const autoRunning = computed(() => autoStatus.value?.running_id != null)
|
|
const lastSweep = computed(() =>
|
|
(autoStatus.value?.runs || []).find(r => r.status !== 'running') || null)
|
|
const metricsConcepts = computed(() => metricsData.value?.concepts ?? [])
|
|
|
|
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 copy + the auto-apply tuning inputs.
|
|
try {
|
|
await mlSettings.loadSettings()
|
|
const s = mlSettings.settings || {}
|
|
autoEnabled.value = !!s.head_auto_apply_enabled
|
|
autoPrecisionInput.value = s.head_auto_apply_precision ?? 0.97
|
|
autoMinPosInput.value = s.head_auto_apply_min_positives ?? 30
|
|
} catch { /* non-fatal */ }
|
|
await refresh()
|
|
if (running.value) startPoll()
|
|
await refreshAuto()
|
|
if (autoRunning.value) startAutoPoll()
|
|
refreshMetrics()
|
|
})
|
|
onUnmounted(() => { stopPoll(); stopAutoPoll() })
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
// --- Auto-apply ---
|
|
async function refreshAuto() {
|
|
try { autoStatus.value = await store.autoApplyStatus() } catch { /* non-fatal */ }
|
|
}
|
|
async function refreshMetrics() {
|
|
try { metricsData.value = await store.metrics() } catch { /* non-fatal */ }
|
|
}
|
|
function startAutoPoll() {
|
|
stopAutoPoll()
|
|
autoTimer = setInterval(async () => {
|
|
const was = autoRunning.value
|
|
await refreshAuto()
|
|
// Sweep just finished → refresh the counts + landing metrics.
|
|
if (was && !autoRunning.value) { refreshMetrics(); refresh() }
|
|
if (!autoRunning.value) stopAutoPoll()
|
|
}, 4000)
|
|
}
|
|
function stopAutoPoll() { if (autoTimer) { clearInterval(autoTimer); autoTimer = null } }
|
|
|
|
async function onToggleAuto(val) {
|
|
settingBusy.value = true
|
|
try {
|
|
await mlSettings.patchSettings({ head_auto_apply_enabled: !!val })
|
|
toast({ text: val ? 'Auto-apply on' : 'Auto-apply off', type: 'success' })
|
|
} catch (e) {
|
|
autoEnabled.value = !val // revert the switch
|
|
toast({ text: `Could not update: ${e.message}`, type: 'error' })
|
|
} finally {
|
|
settingBusy.value = false
|
|
}
|
|
}
|
|
async function onSaveSettings() {
|
|
settingBusy.value = true
|
|
try {
|
|
await mlSettings.patchSettings({
|
|
head_auto_apply_precision: Number(autoPrecisionInput.value),
|
|
head_auto_apply_min_positives: Number(autoMinPosInput.value),
|
|
})
|
|
} catch (e) {
|
|
toast({ text: `Could not save: ${e.message}`, type: 'error' })
|
|
} finally {
|
|
settingBusy.value = false
|
|
}
|
|
}
|
|
function onPreview() { startSweep(true) }
|
|
function onApplyNow() { startSweep(false) }
|
|
async function startSweep(dryRun) {
|
|
autoBusy.value = true
|
|
try {
|
|
await store.autoApply(dryRun)
|
|
await refreshAuto()
|
|
startAutoPoll()
|
|
} catch (e) {
|
|
const code = e.body?.error
|
|
const msg = code === 'auto_apply_already_running' ? 'A sweep is already running.'
|
|
: code === 'auto_apply_disabled' ? 'Enable auto-apply first.'
|
|
: e.message
|
|
toast({ text: `Could not start sweep: ${msg}`, type: 'error' })
|
|
} finally {
|
|
autoBusy.value = false
|
|
}
|
|
}
|
|
function sweepConcepts(run) {
|
|
return (run?.report?.concepts || [])
|
|
.filter(c => c.applied > 0)
|
|
.sort((a, b) => b.applied - a.applied)
|
|
}
|
|
function sweepTotal(run) { return run?.n_applied ?? 0 }
|
|
function ratePct(x) { return x == null ? '—' : `${Math.round(x * 100)}%` }
|
|
function rateClass(x) {
|
|
if (x == null) return ''
|
|
if (x <= 0.03) return 'fc-good'
|
|
if (x <= 0.1) return 'fc-ok'
|
|
return 'fc-weak'
|
|
}
|
|
|
|
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-section-h {
|
|
font-size: 13px; font-weight: 700; letter-spacing: 0.03em;
|
|
text-transform: uppercase; color: rgb(var(--v-theme-on-surface));
|
|
}
|
|
.fc-auto {
|
|
border-top: 1px solid rgb(var(--v-theme-surface-light)); padding-top: 16px;
|
|
}
|
|
.fc-chips { display: flex; flex-wrap: wrap; gap: 6px; }
|
|
.fc-chip {
|
|
font-size: 12px; padding: 2px 8px; border-radius: 999px;
|
|
background: rgb(var(--v-theme-surface-light));
|
|
color: rgb(var(--v-theme-on-surface-variant));
|
|
}
|
|
.fc-chip strong { color: rgb(var(--v-theme-on-surface)); }
|
|
|
|
.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>
|