feat(fc2b): Settings Maintenance tab
MaintenancePanel hosts: backfill + centroid recompute trigger cards, the five suggestion-threshold sliders (autosave on slider release), the allowlist table (inline editable min_confidence, delete), and the alias table (mapping display, delete). Wired as a third Settings tab, ML settings loaded lazily when the tab opens. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<v-card>
|
||||
<v-card-title>Tag aliases ({{ store.rows.length }})</v-card-title>
|
||||
<v-data-table-virtual
|
||||
:headers="headers" :items="store.rows" :loading="store.loading"
|
||||
height="360" density="compact"
|
||||
no-data-text="No aliases yet — create one from a suggestion's ⋮ menu."
|
||||
>
|
||||
<template #item.mapping="{ item }">
|
||||
<code>{{ item.alias_string }}</code>
|
||||
<span class="mx-2">→</span>
|
||||
<strong>{{ item.canonical_tag_name }}</strong>
|
||||
</template>
|
||||
<template #item.actions="{ item }">
|
||||
<v-btn
|
||||
icon="mdi-delete" size="x-small" variant="text" color="error"
|
||||
@click="store.remove(item.alias_string, item.alias_category)"
|
||||
/>
|
||||
</template>
|
||||
</v-data-table-virtual>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted } from 'vue'
|
||||
import { useAliasesStore } from '../../stores/aliases.js'
|
||||
|
||||
const store = useAliasesStore()
|
||||
const headers = [
|
||||
{ title: 'Mapping', key: 'mapping', sortable: false },
|
||||
{ title: 'Category', key: 'alias_category', sortable: true, width: 130 },
|
||||
{ title: '', key: 'actions', sortable: false, width: 60 }
|
||||
]
|
||||
onMounted(() => store.load())
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
code {
|
||||
background: rgb(var(--v-theme-surface-light));
|
||||
padding: 1px 6px; border-radius: 4px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<v-card>
|
||||
<v-card-title>Allowlisted tags ({{ store.rows.length }})</v-card-title>
|
||||
<v-data-table-virtual
|
||||
:headers="headers" :items="store.rows" :loading="store.loading"
|
||||
height="360" density="compact"
|
||||
no-data-text="No tags on the allowlist yet — accept a suggestion to add one."
|
||||
>
|
||||
<template #item.min_confidence="{ item }">
|
||||
<v-text-field
|
||||
:model-value="item.min_confidence" type="number"
|
||||
density="compact" hide-details style="max-width: 100px;"
|
||||
min="0.05" max="1" step="0.05"
|
||||
@update:model-value="(v) => onThreshold(item.tag_id, v)"
|
||||
/>
|
||||
</template>
|
||||
<template #item.actions="{ item }">
|
||||
<v-btn
|
||||
icon="mdi-delete" size="x-small" variant="text" color="error"
|
||||
@click="store.remove(item.tag_id)"
|
||||
/>
|
||||
</template>
|
||||
</v-data-table-virtual>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted } from 'vue'
|
||||
import { useAllowlistStore } from '../../stores/allowlist.js'
|
||||
|
||||
const store = useAllowlistStore()
|
||||
const headers = [
|
||||
{ title: 'Tag', key: 'tag_name', sortable: true },
|
||||
{ title: 'Kind', key: 'tag_kind', sortable: true, width: 110 },
|
||||
{ title: 'Min confidence', key: 'min_confidence', sortable: false, width: 140 },
|
||||
{ title: '', key: 'actions', sortable: false, width: 60 }
|
||||
]
|
||||
onMounted(() => store.load())
|
||||
|
||||
let debounce = null
|
||||
function onThreshold(tagId, value) {
|
||||
if (debounce) clearTimeout(debounce)
|
||||
debounce = setTimeout(() => {
|
||||
const v = parseFloat(value)
|
||||
if (v > 0 && v <= 1) store.updateThreshold(tagId, v)
|
||||
}, 500)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<v-card>
|
||||
<v-card-title>Tag centroids</v-card-title>
|
||||
<v-card-text>
|
||||
<p class="text-body-2 mb-3">
|
||||
Rebuild the per-tag SigLIP centroids that power similarity-based
|
||||
suggestions. Runs nightly automatically; trigger manually after a
|
||||
large tagging session.
|
||||
</p>
|
||||
<v-btn color="primary" rounded="pill" :loading="busy" @click="run">
|
||||
<v-icon start>mdi-vector-triangle</v-icon> Recompute centroids
|
||||
</v-btn>
|
||||
<span v-if="done" class="ml-3 text-caption">Enqueued.</span>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useMLStore } from '../../stores/ml.js'
|
||||
const store = useMLStore()
|
||||
const busy = ref(false)
|
||||
const done = ref(false)
|
||||
async function run() {
|
||||
busy.value = true
|
||||
try { await store.triggerRecomputeCentroids(); done.value = true }
|
||||
catch (e) { window.__fcToast?.({ text: e.message, type: 'error' }) }
|
||||
finally { busy.value = false }
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<v-card>
|
||||
<v-card-title>ML backfill</v-card-title>
|
||||
<v-card-text>
|
||||
<p class="text-body-2 mb-3">
|
||||
Re-run Camie + SigLIP on images missing predictions or embeddings
|
||||
for the current model versions. Safe to re-run.
|
||||
</p>
|
||||
<v-btn color="primary" rounded="pill" :loading="busy" @click="run">
|
||||
<v-icon start>mdi-refresh</v-icon> Run backfill now
|
||||
</v-btn>
|
||||
<span v-if="done" class="ml-3 text-caption">Enqueued.</span>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useMLStore } from '../../stores/ml.js'
|
||||
const store = useMLStore()
|
||||
const busy = ref(false)
|
||||
const done = ref(false)
|
||||
async function run() {
|
||||
busy.value = true
|
||||
try { await store.triggerBackfill(); done.value = true }
|
||||
catch (e) { window.__fcToast?.({ text: e.message, type: 'error' }) }
|
||||
finally { busy.value = false }
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<v-card>
|
||||
<v-card-title>Suggestion thresholds</v-card-title>
|
||||
<v-card-text v-if="store.settings">
|
||||
<v-row v-for="f in fields" :key="f.key">
|
||||
<v-col cols="12">
|
||||
<v-slider
|
||||
v-model="local[f.key]" :label="f.label"
|
||||
min="0" max="1" step="0.05" thumb-label hide-details
|
||||
color="accent" @end="save"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
<v-card-text v-else><v-skeleton-loader type="paragraph" /></v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, watch } from 'vue'
|
||||
import { useMLStore } from '../../stores/ml.js'
|
||||
|
||||
const store = useMLStore()
|
||||
const fields = [
|
||||
{ key: 'suggestion_threshold_artist', label: 'Artist' },
|
||||
{ key: 'suggestion_threshold_character', label: 'Character' },
|
||||
{ key: 'suggestion_threshold_copyright', label: 'Copyright' },
|
||||
{ key: 'suggestion_threshold_general', label: 'General' },
|
||||
{ key: 'centroid_similarity_threshold', label: 'Centroid similarity' }
|
||||
]
|
||||
const local = reactive({})
|
||||
watch(() => store.settings, (s) => { if (s) Object.assign(local, s) }, { immediate: true })
|
||||
|
||||
async function save() {
|
||||
const patch = {}
|
||||
for (const f of fields) patch[f.key] = local[f.key]
|
||||
try { await store.patchSettings(patch) }
|
||||
catch (e) { window.__fcToast?.({ text: e.message, type: 'error' }) }
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,32 @@
|
||||
<template>
|
||||
<div class="fc-maint">
|
||||
<p class="text-body-2 mb-4">
|
||||
Machine-assisted tagging controls. Backfill and centroid recompute run
|
||||
nightly automatically; the allowlist auto-applies accepted tags to new
|
||||
and existing images.
|
||||
</p>
|
||||
<div class="fc-maint__grid">
|
||||
<MLBackfillCard />
|
||||
<CentroidRecomputeCard />
|
||||
</div>
|
||||
<MLThresholdSliders class="mt-4" />
|
||||
<AllowlistTable class="mt-4" />
|
||||
<AliasTable class="mt-4" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import MLBackfillCard from './MLBackfillCard.vue'
|
||||
import CentroidRecomputeCard from './CentroidRecomputeCard.vue'
|
||||
import MLThresholdSliders from './MLThresholdSliders.vue'
|
||||
import AllowlistTable from './AllowlistTable.vue'
|
||||
import AliasTable from './AliasTable.vue'
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fc-maint__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -5,6 +5,7 @@
|
||||
<v-tabs v-model="tab" color="accent" class="mb-4">
|
||||
<v-tab value="overview">Overview</v-tab>
|
||||
<v-tab value="import">Import</v-tab>
|
||||
<v-tab value="maintenance">Maintenance</v-tab>
|
||||
</v-tabs>
|
||||
|
||||
<v-window v-model="tab">
|
||||
@@ -26,6 +27,10 @@
|
||||
<v-divider class="my-6" />
|
||||
<ImportTaskList />
|
||||
</v-window-item>
|
||||
|
||||
<v-window-item value="maintenance">
|
||||
<MaintenancePanel />
|
||||
</v-window-item>
|
||||
</v-window>
|
||||
</v-container>
|
||||
</template>
|
||||
@@ -38,10 +43,13 @@ import SystemStatsCards from '../components/settings/SystemStatsCards.vue'
|
||||
import ImportTriggerPanel from '../components/settings/ImportTriggerPanel.vue'
|
||||
import ImportFiltersForm from '../components/settings/ImportFiltersForm.vue'
|
||||
import ImportTaskList from '../components/settings/ImportTaskList.vue'
|
||||
import MaintenancePanel from '../components/settings/MaintenancePanel.vue'
|
||||
import { useMLStore } from '../stores/ml.js'
|
||||
|
||||
const tab = ref('overview')
|
||||
const system = useSystemStore()
|
||||
const importStore = useImportStore()
|
||||
const mlStore = useMLStore()
|
||||
|
||||
let pollId = null
|
||||
|
||||
@@ -72,6 +80,8 @@ watch(tab, (t) => {
|
||||
if (t === 'import') {
|
||||
importStore.refreshStatus()
|
||||
importStore.loadTasks(true)
|
||||
} else if (t === 'maintenance') {
|
||||
mlStore.loadSettings()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user