refactor(ui): settings-card primitives + fix threshold clamp / card misgroup (#161)
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 33s
CI / integration (push) Successful in 3m52s
extension / lint (pull_request) Successful in 10s

Tier-3 frontend DRY for the ML settings cards, plus the F-D2 clamp bug and the
F-D3 card misgrouping.

New primitives (components/common + composables):
- <SettingToggleRow> — the accent-icon + .fc-section-h label + right-aligned
  switch row (HeadsCard x3, CropProposersCard). iconColor prop absorbs the
  on/off dim.
- <SettingNumberField> — compact numeric field that CLAMPS to [min,max] on
  commit. This fixes F-D2: HeadsCard/CropProposersCard previously sent
  Number(raw) straight to the API, so an out-of-range threshold bounced off the
  400 validator (only TranslationCard clamped). density prop for the grid cards.
- useSettingSave(patchFn) — the busy + patch + toast + revert-on-failure flow
  each card hand-rolled (HeadsCard x6 handlers, CropProposersCard, MLBackfillCard,
  VideoEmbeddingCard). Returns ok/false for the optimistic-switch revert.

Adopted in HeadsCard, CropProposersCard, MLBackfillCard (handler only — its
plain labelled switch is a different affordance), VideoEmbeddingCard.

F-D3: MLThresholdSliders.vue actually rendered a "Video embedding" (frame-
sampling) card but sat under "Tagging → Suggestion thresholds". Renamed it
VideoEmbeddingCard.vue and moved it to the "GPU agent & embeddings" section.

Left deliberately (over-DRY guard): TranslationCard uses an inline error ALERT
(not a toast), already clamps its confidence with a NaN fallback, and lives on
the ImportStore — a genuinely different save pattern, so forcing it onto
useSettingSave would change its UX.

Behaviour-preserving refactor; CI has no Vue type-check so this needs a live
UI pass (toggles persist + revert on failure, thresholds clamp on blur, video
card now under Embeddings).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NsmJSQxnNxGgtM5Yz4GAqi
This commit is contained in:
2026-07-13 22:28:47 -04:00
parent e92570a31e
commit ec66ea5f83
8 changed files with 244 additions and 184 deletions
@@ -0,0 +1,56 @@
<!--
Canonical settings number field (DRY pass #161): a compact numeric v-text-field
with a built-in clamp to [min,max] on commit. Hand-rolled identically across the
ML settings cards (HeadsCard x6, CropProposersCard, VideoEmbeddingCard).
The clamp is the point: the cards previously sent Number(raw) straight to the
API, so an out-of-range value bounced off the API's 400 validator (only
TranslationCard clamped). This is now the single home for that clamp.
Binds `modelValue` (v-model) and emits `change` on blur/enter AFTER clamping, so
the parent's save reads the already-clamped value same as the prior
`v-model.number` + `@change=save` pattern.
-->
<template>
<v-text-field
:model-value="modelValue"
:label="label"
type="number"
:min="min"
:max="max"
:step="step"
:disabled="disabled"
:density="density" hide-details
:style="{ maxWidth }"
@update:model-value="v => emit('update:modelValue', v)"
@change="onCommit"
/>
</template>
<script setup>
const props = defineProps({
modelValue: { type: [Number, String], default: null },
label: { type: String, default: '' },
min: { type: [Number, String], default: null },
max: { type: [Number, String], default: null },
step: { type: [Number, String], default: 1 },
maxWidth: { type: String, default: '200px' },
density: { type: String, default: 'compact' },
disabled: { type: Boolean, default: false },
})
const emit = defineEmits(['update:modelValue', 'change'])
function onCommit() {
// On blur/enter: coerce to a number and clamp to [min,max] so an out-of-range
// value never reaches the API. props.modelValue reflects the latest keystroke
// (kept in sync by the passthrough above); re-emit the clamped number, then let
// the parent persist.
let n = Number(props.modelValue)
if (!Number.isNaN(n)) {
if (props.min !== null && props.min !== '') n = Math.max(Number(props.min), n)
if (props.max !== null && props.max !== '') n = Math.min(Number(props.max), n)
if (n !== Number(props.modelValue)) emit('update:modelValue', n)
}
emit('change')
}
</script>
@@ -0,0 +1,42 @@
<!--
Canonical settings toggle row (DRY pass #161): an accent icon + an uppercase
.fc-section-h label + a right-aligned switch. Hand-rolled identically in the
ML settings cards (HeadsCard x3, CropProposersCard, MLBackfillCard).
Two-way binds `modelValue` (so the parent switch state stays optimistic) AND
emits `change` with the new boolean, so the parent can persist + revert on
failure matching the prior `v-model` + `@update:model-value=handler` pattern.
-->
<template>
<div class="d-flex align-center mb-1" style="gap: 10px;">
<v-icon v-if="icon" size="18" :color="iconColor">{{ icon }}</v-icon>
<span class="fc-section-h">{{ label }}</span>
<v-switch
:model-value="modelValue"
:loading="loading"
:disabled="disabled"
hide-details density="compact" color="success" class="ml-auto"
@update:model-value="onSwitch"
/>
</div>
</template>
<script setup>
defineProps({
modelValue: { type: Boolean, default: false },
label: { type: String, default: '' },
icon: { type: String, default: '' },
// Icon tint. Default accent; pass null for the theme default (e.g. when a row
// is off). null (not undefined) so the default doesn't override it.
iconColor: { type: String, default: 'accent' },
loading: { type: Boolean, default: false },
disabled: { type: Boolean, default: false },
})
const emit = defineEmits(['update:modelValue', 'change'])
function onSwitch(v) {
const b = !!v
emit('update:modelValue', b)
emit('change', b)
}
</script>
@@ -15,28 +15,23 @@
</p> </p>
<div v-for="p in proposers" :key="p.key" class="fc-proposer"> <div v-for="p in proposers" :key="p.key" class="fc-proposer">
<div class="d-flex align-center mb-1" style="gap: 10px;"> <SettingToggleRow
<v-icon size="18" :color="p.on ? 'accent' : undefined">{{ p.icon }}</v-icon> v-model="p.on" :loading="busy" :icon="p.icon"
<span class="fc-section-h">{{ p.label }}</span> :icon-color="p.on ? 'accent' : null" :label="p.label"
<v-switch @change="v => saveToggle(p, v)"
v-model="p.on" :loading="busy" hide-details density="compact" />
color="success" class="ml-auto"
@update:model-value="v => saveToggle(p, v)"
/>
</div>
<p class="fc-muted text-body-2 mb-2">{{ p.help }}</p> <p class="fc-muted text-body-2 mb-2">{{ p.help }}</p>
<div class="d-flex flex-wrap mb-4" style="gap: 12px;"> <div class="d-flex flex-wrap mb-4" style="gap: 12px;">
<v-text-field <v-text-field
v-model="p.weights" label="Weights" density="compact" hide-details v-model="p.weights" label="Weights" density="compact" hide-details
style="min-width: 300px; flex: 1;" :disabled="busy || !p.on" style="min-width: 300px; flex: 1;" :disabled="busy || !p.on"
placeholder="name | URL | hf_repo::file" placeholder="name | URL | hf_repo::file"
@change="save({ [`detector_${p.key}_weights`]: p.weights })" @change="saveField({ [`detector_${p.key}_weights`]: p.weights })"
/> />
<v-text-field <SettingNumberField
v-model.number="p.conf" label="Confidence" type="number" v-model="p.conf" label="Confidence" :min="0" :max="1" :step="0.05"
min="0" max="1" step="0.05" density="compact" hide-details max-width="140px" :disabled="busy || !p.on"
style="max-width: 140px;" :disabled="busy || !p.on" @change="saveField({ [`detector_${p.key}_conf`]: Number(p.conf) })"
@change="save({ [`detector_${p.key}_conf`]: Number(p.conf) })"
/> />
</div> </div>
</div> </div>
@@ -48,12 +43,12 @@
storage. Dedupe IoU drops near-duplicate crops before embedding. storage. Dedupe IoU drops near-duplicate crops before embedding.
</p> </p>
<div class="d-flex flex-wrap" style="gap: 12px;"> <div class="d-flex flex-wrap" style="gap: 12px;">
<v-text-field <SettingNumberField
v-for="c in caps" :key="c.key" v-for="c in caps" :key="c.key"
v-model.number="c.val" :label="c.label" type="number" v-model="c.val" :label="c.label"
:min="c.min" :max="c.max" :step="c.step || 1" density="compact" :min="c.min" :max="c.max" :step="c.step || 1"
hide-details style="max-width: 165px;" :disabled="busy" max-width="165px" :disabled="busy"
@change="save({ [c.key]: Number(c.val) })" @change="saveField({ [c.key]: Number(c.val) })"
/> />
</div> </div>
</div> </div>
@@ -61,14 +56,16 @@
</template> </template>
<script setup> <script setup>
import { toast } from '../../utils/toast.js'
import { onMounted, ref } from 'vue' import { onMounted, ref } from 'vue'
import MaintenanceTile from '../common/MaintenanceTile.vue' import MaintenanceTile from '../common/MaintenanceTile.vue'
import SettingNumberField from '../common/SettingNumberField.vue'
import SettingToggleRow from '../common/SettingToggleRow.vue'
import { useSettingSave } from '../../composables/useSettingSave.js'
import { useMLStore } from '../../stores/ml.js' import { useMLStore } from '../../stores/ml.js'
const mlSettings = useMLStore() const mlSettings = useMLStore()
const busy = ref(false) const { busy, save } = useSettingSave(mlSettings.patchSettings)
const proposers = ref([]) const proposers = ref([])
const caps = ref([]) const caps = ref([])
@@ -111,22 +108,16 @@ onMounted(async () => {
caps.value = CAP_DEFS.map(c => ({ ...c, val: s[c.key] ?? 0 })) caps.value = CAP_DEFS.map(c => ({ ...c, val: s[c.key] ?? 0 }))
}) })
async function save(patch, revert) { // Field @change → persist with a "Saved" confirmation. SettingNumberField has
busy.value = true // already clamped numeric values to their [min,max] before this fires.
try { function saveField(patch) {
await mlSettings.patchSettings(patch) save(patch, { successMessage: 'Saved' })
toast({ text: 'Saved', type: 'success' })
} catch (e) {
if (revert) revert()
toast({ text: `Could not save: ${e.message}`, type: 'error' })
} finally {
busy.value = false
}
} }
function saveToggle (p, v) { async function saveToggle(p, v) {
// Revert the switch on failure so it never lies about the persisted state. // Revert the switch on failure so it never lies about the persisted state.
save({ [`detector_${p.key}_enabled`]: !!v }, () => { p.on = !v }) const ok = await save({ [`detector_${p.key}_enabled`]: !!v }, { successMessage: 'Saved' })
if (!ok) p.on = !v
} }
</script> </script>
+59 -115
View File
@@ -95,14 +95,10 @@
<!-- Earned auto-apply --> <!-- Earned auto-apply -->
<div class="fc-auto mt-6"> <div class="fc-auto mt-6">
<div class="d-flex align-center mb-1" style="gap: 10px;"> <SettingToggleRow
<v-icon size="18" color="accent">mdi-lightning-bolt</v-icon> v-model="autoEnabled" :loading="settingBusy"
<span class="fc-section-h">Auto-apply</span> icon="mdi-lightning-bolt" label="Auto-apply" @change="onToggleAuto"
<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"> <p class="fc-muted text-body-2 mb-3">
Graduated heads (, with {{ autoMinPosInput }} examples) apply their tag Graduated heads (, with {{ autoMinPosInput }} examples) apply their tag
on their own where they clear {{ Math.round((autoPrecisionInput || 0) * 100) }}% on their own where they clear {{ Math.round((autoPrecisionInput || 0) * 100) }}%
@@ -111,17 +107,14 @@
</p> </p>
<div class="d-flex mb-3" style="gap: 12px;"> <div class="d-flex mb-3" style="gap: 12px;">
<v-text-field <SettingNumberField
v-model.number="autoPrecisionInput" label="Precision target" v-model="autoPrecisionInput" label="Precision target"
type="number" min="0.5" max="0.999" step="0.01" density="compact" :min="0.5" :max="0.999" :step="0.01" :disabled="settingBusy"
hide-details style="max-width: 200px;" :disabled="settingBusy"
@change="onSaveSettings" @change="onSaveSettings"
/> />
<v-text-field <SettingNumberField
v-model.number="autoMinPosInput" label="Min examples to fire" v-model="autoMinPosInput" label="Min examples to fire"
type="number" min="1" density="compact" hide-details :min="1" :disabled="settingBusy" @change="onSaveSettings"
style="max-width: 200px;" :disabled="settingBusy"
@change="onSaveSettings"
/> />
</div> </div>
@@ -161,15 +154,11 @@
<!-- Presentation chrome auto-hide (#141) --> <!-- Presentation chrome auto-hide (#141) -->
<div class="fc-auto mt-6"> <div class="fc-auto mt-6">
<div class="d-flex align-center mb-1" style="gap: 10px;"> <SettingToggleRow
<v-icon size="18" color="accent">mdi-image-off-outline</v-icon> v-model="presentationEnabled" :loading="settingBusy"
<span class="fc-section-h">Hide presentation chrome</span> icon="mdi-image-off-outline" label="Hide presentation chrome"
<v-switch @change="onTogglePresentation"
v-model="presentationEnabled" :loading="settingBusy" hide-details />
density="compact" color="success" class="ml-auto"
@update:model-value="onTogglePresentation"
/>
</div>
<p class="fc-muted text-body-2 mb-3"> <p class="fc-muted text-body-2 mb-3">
Auto-hide <code>banner</code> chrome from the gallery once a head has Auto-hide <code>banner</code> chrome from the gallery once a head has
learned it ( {{ minPositives }} examples) and clears learned it ( {{ minPositives }} examples) and clears
@@ -180,16 +169,14 @@
tag), it's flagged for review instead of buried. Every auto-hide is reversible. tag), it's flagged for review instead of buried. Every auto-hide is reversible.
</p> </p>
<div class="d-flex mb-3" style="gap: 12px;"> <div class="d-flex mb-3" style="gap: 12px;">
<v-text-field <SettingNumberField
v-model.number="presentationThresholdInput" label="Hide confidence" v-model="presentationThresholdInput" label="Hide confidence"
type="number" min="0.5" max="0.999" step="0.01" density="compact" :min="0.5" :max="0.999" :step="0.01" :disabled="settingBusy"
hide-details style="max-width: 200px;" :disabled="settingBusy"
@change="onSavePresentation" @change="onSavePresentation"
/> />
<v-text-field <SettingNumberField
v-model.number="presentationConflictInput" label="Flag if content ≥" v-model="presentationConflictInput" label="Flag if content ≥"
type="number" min="0" max="1" step="0.05" density="compact" :min="0" :max="1" :step="0.05" :disabled="settingBusy"
hide-details style="max-width: 200px;" :disabled="settingBusy"
@change="onSavePresentation" @change="onSavePresentation"
/> />
</div> </div>
@@ -197,15 +184,11 @@
<!-- Process auto-tagging (#1464): wip / editor screenshot --> <!-- Process auto-tagging (#1464): wip / editor screenshot -->
<div class="fc-auto mt-6"> <div class="fc-auto mt-6">
<div class="d-flex align-center mb-1" style="gap: 10px;"> <SettingToggleRow
<v-icon size="18" color="accent">mdi-progress-wrench</v-icon> v-model="processEnabled" :loading="settingBusy"
<span class="fc-section-h">Auto-tag work-in-progress</span> icon="mdi-progress-wrench" label="Auto-tag work-in-progress"
<v-switch @change="onToggleProcess"
v-model="processEnabled" :loading="settingBusy" hide-details />
density="compact" color="success" class="ml-auto"
@update:model-value="onToggleProcess"
/>
</div>
<p class="fc-muted text-body-2 mb-3"> <p class="fc-muted text-body-2 mb-3">
Auto-tag <code>wip</code> and <code>editor screenshot</code> process art Auto-tag <code>wip</code> and <code>editor screenshot</code> process art
once a head has learned them (≥ {{ minPositives }} examples) and clears once a head has learned them (≥ {{ minPositives }} examples) and clears
@@ -217,16 +200,14 @@
manual tags, never its own guesses so it can't run away. Every tag reversible. manual tags, never its own guesses so it can't run away. Every tag reversible.
</p> </p>
<div class="d-flex mb-3" style="gap: 12px;"> <div class="d-flex mb-3" style="gap: 12px;">
<v-text-field <SettingNumberField
v-model.number="processThresholdInput" label="Tag confidence" v-model="processThresholdInput" label="Tag confidence"
type="number" min="0.5" max="0.999" step="0.01" density="compact" :min="0.5" :max="0.999" :step="0.01" :disabled="settingBusy"
hide-details style="max-width: 200px;" :disabled="settingBusy"
@change="onSaveProcess" @change="onSaveProcess"
/> />
<v-text-field <SettingNumberField
v-model.number="processConflictInput" label="Flag if content ≥" v-model="processConflictInput" label="Flag if content ≥"
type="number" min="0" max="1" step="0.05" density="compact" :min="0" :max="1" :step="0.05" :disabled="settingBusy"
hide-details style="max-width: 200px;" :disabled="settingBusy"
@change="onSaveProcess" @change="onSaveProcess"
/> />
</div> </div>
@@ -272,6 +253,9 @@ import { toast } from '../../utils/toast.js'
import { computed, onMounted, onUnmounted, ref } from 'vue' import { computed, onMounted, onUnmounted, ref } from 'vue'
import MaintenanceTile from '../common/MaintenanceTile.vue' import MaintenanceTile from '../common/MaintenanceTile.vue'
import SettingNumberField from '../common/SettingNumberField.vue'
import SettingToggleRow from '../common/SettingToggleRow.vue'
import { useSettingSave } from '../../composables/useSettingSave.js'
import { useHeadsStore } from '../../stores/heads.js' import { useHeadsStore } from '../../stores/heads.js'
import { useMLStore } from '../../stores/ml.js' import { useMLStore } from '../../stores/ml.js'
@@ -285,7 +269,9 @@ let pollTimer = null
const autoEnabled = ref(false) const autoEnabled = ref(false)
const autoPrecisionInput = ref(0.97) const autoPrecisionInput = ref(0.97)
const autoMinPosInput = ref(30) const autoMinPosInput = ref(30)
const settingBusy = ref(false) // Shared settings-save flow (busy + toast + revert); `settingBusy` gates the
// toggles/fields, `save` returns ok/false for the optimistic-switch revert.
const { busy: settingBusy, save } = useSettingSave(mlSettings.patchSettings)
const autoBusy = ref(false) const autoBusy = ref(false)
const autoStatus = ref(null) const autoStatus = ref(null)
const metricsData = ref(null) const metricsData = ref(null)
@@ -395,81 +381,39 @@ function startAutoPoll() {
function stopAutoPoll() { if (autoTimer) { clearInterval(autoTimer); autoTimer = null } } function stopAutoPoll() { if (autoTimer) { clearInterval(autoTimer); autoTimer = null } }
async function onToggleAuto(val) { async function onToggleAuto(val) {
settingBusy.value = true const ok = await save({ head_auto_apply_enabled: !!val },
try { { successMessage: val ? 'Auto-apply on' : 'Auto-apply off', errorPrefix: 'Could not update' })
await mlSettings.patchSettings({ head_auto_apply_enabled: !!val }) if (!ok) autoEnabled.value = !val // revert the switch
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() { async function onSaveSettings() {
settingBusy.value = true await save({
try { head_auto_apply_precision: Number(autoPrecisionInput.value),
await mlSettings.patchSettings({ head_auto_apply_min_positives: Number(autoMinPosInput.value),
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
}
} }
async function onTogglePresentation(val) { async function onTogglePresentation(val) {
settingBusy.value = true const ok = await save({ presentation_auto_apply_enabled: !!val },
try { { successMessage: val ? 'Chrome auto-hide on' : 'Chrome auto-hide off', errorPrefix: 'Could not update' })
await mlSettings.patchSettings({ presentation_auto_apply_enabled: !!val }) if (!ok) presentationEnabled.value = !val // revert the switch
toast({ text: val ? 'Chrome auto-hide on' : 'Chrome auto-hide off', type: 'success' })
} catch (e) {
presentationEnabled.value = !val // revert the switch
toast({ text: `Could not update: ${e.message}`, type: 'error' })
} finally {
settingBusy.value = false
}
} }
async function onSavePresentation() { async function onSavePresentation() {
settingBusy.value = true await save({
try { presentation_auto_apply_threshold: Number(presentationThresholdInput.value),
await mlSettings.patchSettings({ presentation_conflict_threshold: Number(presentationConflictInput.value),
presentation_auto_apply_threshold: Number(presentationThresholdInput.value), })
presentation_conflict_threshold: Number(presentationConflictInput.value),
})
} catch (e) {
toast({ text: `Could not save: ${e.message}`, type: 'error' })
} finally {
settingBusy.value = false
}
} }
async function onToggleProcess(val) { async function onToggleProcess(val) {
settingBusy.value = true const ok = await save({ process_auto_apply_enabled: !!val },
try { { successMessage: val ? 'WIP auto-tag on' : 'WIP auto-tag off', errorPrefix: 'Could not update' })
await mlSettings.patchSettings({ process_auto_apply_enabled: !!val }) if (!ok) processEnabled.value = !val // revert the switch
toast({ text: val ? 'WIP auto-tag on' : 'WIP auto-tag off', type: 'success' })
} catch (e) {
processEnabled.value = !val // revert the switch
toast({ text: `Could not update: ${e.message}`, type: 'error' })
} finally {
settingBusy.value = false
}
} }
async function onSaveProcess() { async function onSaveProcess() {
settingBusy.value = true await save({
try { process_auto_apply_threshold: Number(processThresholdInput.value),
await mlSettings.patchSettings({ process_conflict_threshold: Number(processConflictInput.value),
process_auto_apply_threshold: Number(processThresholdInput.value), })
process_conflict_threshold: Number(processConflictInput.value),
})
} catch (e) {
toast({ text: `Could not save: ${e.message}`, type: 'error' })
} finally {
settingBusy.value = false
}
} }
function onPreview() { startSweep(true) } function onPreview() { startSweep(true) }
function onApplyNow() { startSweep(false) } function onApplyNow() { startSweep(false) }
@@ -39,13 +39,14 @@
import { toast } from '../../utils/toast.js' import { toast } from '../../utils/toast.js'
import { onMounted, ref } from 'vue' import { onMounted, ref } from 'vue'
import { useMLStore } from '../../stores/ml.js' import { useMLStore } from '../../stores/ml.js'
import { useSettingSave } from '../../composables/useSettingSave.js'
import MaintenanceTile from '../common/MaintenanceTile.vue' import MaintenanceTile from '../common/MaintenanceTile.vue'
import QueueStatusBar from './QueueStatusBar.vue' import QueueStatusBar from './QueueStatusBar.vue'
const store = useMLStore() const store = useMLStore()
const { busy: saving, save } = useSettingSave(store.patchSettings)
const busy = ref(false) const busy = ref(false)
const done = ref(false) const done = ref(false)
const enabled = ref(true) const enabled = ref(true)
const saving = ref(false)
onMounted(async () => { onMounted(async () => {
try { try {
await store.loadSettings() await store.loadSettings()
@@ -55,21 +56,12 @@ onMounted(async () => {
} catch { /* non-fatal */ } } catch { /* non-fatal */ }
}) })
async function onToggle() { async function onToggle() {
saving.value = true const ok = await save({ cpu_embed_enabled: enabled.value }, {
try { successMessage: enabled.value
await store.patchSettings({ cpu_embed_enabled: enabled.value }) ? 'CPU embedding on — imports queue embeds for the ml-worker'
toast({ : 'CPU embedding off — the GPU embed backfill owns whole-image embeds',
text: enabled.value })
? 'CPU embedding on — imports queue embeds for the ml-worker' if (!ok) enabled.value = !enabled.value
: 'CPU embedding off — the GPU embed backfill owns whole-image embeds',
type: 'success',
})
} catch (e) {
toast({ text: `Could not save: ${e.message}`, type: 'error' })
enabled.value = !enabled.value
} finally {
saving.value = false
}
} }
async function run() { async function run() {
busy.value = true busy.value = true
@@ -24,6 +24,7 @@
the CPU fallback. the CPU fallback.
</p> </p>
<div class="fc-tile-stack"> <div class="fc-tile-stack">
<VideoEmbeddingCard />
<GpuAgentCard /> <GpuAgentCard />
<GpuTriageCard /> <GpuTriageCard />
<MLBackfillCard /> <MLBackfillCard />
@@ -36,7 +37,6 @@
Suggestion thresholds, trained heads and tag aliases. Suggestion thresholds, trained heads and tag aliases.
</p> </p>
<div class="fc-tile-stack"> <div class="fc-tile-stack">
<MLThresholdSliders />
<CropProposersCard /> <CropProposersCard />
<HeadsCard /> <HeadsCard />
<AliasTable /> <AliasTable />
@@ -77,7 +77,7 @@ import ArchiveReextractCard from './ArchiveReextractCard.vue'
import MissingFileRepairCard from './MissingFileRepairCard.vue' import MissingFileRepairCard from './MissingFileRepairCard.vue'
import GpuTriageCard from './GpuTriageCard.vue' import GpuTriageCard from './GpuTriageCard.vue'
import DbMaintenanceCard from './DbMaintenanceCard.vue' import DbMaintenanceCard from './DbMaintenanceCard.vue'
import MLThresholdSliders from './MLThresholdSliders.vue' import VideoEmbeddingCard from './VideoEmbeddingCard.vue'
import CropProposersCard from './CropProposersCard.vue' import CropProposersCard from './CropProposersCard.vue'
import HeadsCard from './HeadsCard.vue' import HeadsCard from './HeadsCard.vue'
import GpuAgentCard from './GpuAgentCard.vue' import GpuAgentCard from './GpuAgentCard.vue'
@@ -12,17 +12,17 @@
</div> </div>
<v-row> <v-row>
<v-col cols="12" sm="6"> <v-col cols="12" sm="6">
<v-text-field <SettingNumberField
v-model.number="local.video_frame_interval_seconds" v-model="local.video_frame_interval_seconds"
label="Frame interval (s)" type="number" min="0.5" step="0.5" label="Frame interval (s)" :min="0.5" :step="0.5"
density="comfortable" hide-details @change="save" density="comfortable" max-width="none" @change="onSave"
/> />
</v-col> </v-col>
<v-col cols="12" sm="6"> <v-col cols="12" sm="6">
<v-text-field <SettingNumberField
v-model.number="local.video_max_frames" v-model="local.video_max_frames"
label="Max frames" type="number" min="1" step="1" label="Max frames" :min="1" :step="1"
density="comfortable" hide-details @change="save" density="comfortable" max-width="none" @change="onSave"
/> />
</v-col> </v-col>
</v-row> </v-row>
@@ -32,21 +32,23 @@
</template> </template>
<script setup> <script setup>
import { toast } from '../../utils/toast.js'
import { reactive, watch } from 'vue' import { reactive, watch } from 'vue'
import { useMLStore } from '../../stores/ml.js' import { useMLStore } from '../../stores/ml.js'
import MaintenanceTile from '../common/MaintenanceTile.vue' import MaintenanceTile from '../common/MaintenanceTile.vue'
import SettingNumberField from '../common/SettingNumberField.vue'
import { useSettingSave } from '../../composables/useSettingSave.js'
const store = useMLStore() const store = useMLStore()
const { save } = useSettingSave(store.patchSettings)
const local = reactive({}) const local = reactive({})
watch(() => store.settings, (s) => { if (s) Object.assign(local, s) }, { immediate: true }) watch(() => store.settings, (s) => { if (s) Object.assign(local, s) }, { immediate: true })
async function save() { // SettingNumberField clamps interval to 0.5 and max-frames to 1 before this
const patch = { // fires, so an out-of-range value never reaches the API.
video_frame_interval_seconds: local.video_frame_interval_seconds, function onSave() {
video_max_frames: local.video_max_frames save({
} video_frame_interval_seconds: Number(local.video_frame_interval_seconds),
try { await store.patchSettings(patch) } video_max_frames: Number(local.video_max_frames),
catch (e) { toast({ text: e.message, type: 'error' }) } })
} }
</script> </script>
@@ -0,0 +1,33 @@
import { ref } from 'vue'
import { toast } from '../utils/toast.js'
// The shared "persist a settings patch" flow for the ML settings cards. Flips a
// busy flag, calls the store's patch (which rethrows on failure), toasts
// success/error, and returns true/false so a toggle handler can revert its
// optimistic switch on failure. Centralises the try/catch/toast the cards each
// hand-rolled (HeadsCard x6, CropProposersCard, MLBackfillCard) — and where the
// threshold-clamp drifted; the clamp now lives in <SettingNumberField>.
//
// Pass the store's patch fn, e.g. useSettingSave(ml.patchSettings).
export function useSettingSave(patchFn) {
const busy = ref(false)
// opts.successMessage — toast on success (toggles announce their new state;
// silent field-saves omit it). opts.errorPrefix — the failure toast prefix
// ("Could not save" default; toggles used "Could not update").
async function save(patch, { successMessage = '', errorPrefix = 'Could not save' } = {}) {
busy.value = true
try {
await patchFn(patch)
if (successMessage) toast({ text: successMessage, type: 'success' })
return true
} catch (e) {
toast({ text: `${errorPrefix}: ${e.message}`, type: 'error' })
return false
} finally {
busy.value = false
}
}
return { busy, save }
}