refactor(ui): settings-card primitives + fix threshold clamp / card misgroup (#161)
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:
@@ -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>
|
||||
|
||||
<div v-for="p in proposers" :key="p.key" class="fc-proposer">
|
||||
<div class="d-flex align-center mb-1" style="gap: 10px;">
|
||||
<v-icon size="18" :color="p.on ? 'accent' : undefined">{{ p.icon }}</v-icon>
|
||||
<span class="fc-section-h">{{ p.label }}</span>
|
||||
<v-switch
|
||||
v-model="p.on" :loading="busy" hide-details density="compact"
|
||||
color="success" class="ml-auto"
|
||||
@update:model-value="v => saveToggle(p, v)"
|
||||
/>
|
||||
</div>
|
||||
<SettingToggleRow
|
||||
v-model="p.on" :loading="busy" :icon="p.icon"
|
||||
:icon-color="p.on ? 'accent' : null" :label="p.label"
|
||||
@change="v => saveToggle(p, v)"
|
||||
/>
|
||||
<p class="fc-muted text-body-2 mb-2">{{ p.help }}</p>
|
||||
<div class="d-flex flex-wrap mb-4" style="gap: 12px;">
|
||||
<v-text-field
|
||||
v-model="p.weights" label="Weights" density="compact" hide-details
|
||||
style="min-width: 300px; flex: 1;" :disabled="busy || !p.on"
|
||||
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
|
||||
v-model.number="p.conf" label="Confidence" type="number"
|
||||
min="0" max="1" step="0.05" density="compact" hide-details
|
||||
style="max-width: 140px;" :disabled="busy || !p.on"
|
||||
@change="save({ [`detector_${p.key}_conf`]: Number(p.conf) })"
|
||||
<SettingNumberField
|
||||
v-model="p.conf" label="Confidence" :min="0" :max="1" :step="0.05"
|
||||
max-width="140px" :disabled="busy || !p.on"
|
||||
@change="saveField({ [`detector_${p.key}_conf`]: Number(p.conf) })"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -48,12 +43,12 @@
|
||||
storage. Dedupe IoU drops near-duplicate crops before embedding.
|
||||
</p>
|
||||
<div class="d-flex flex-wrap" style="gap: 12px;">
|
||||
<v-text-field
|
||||
<SettingNumberField
|
||||
v-for="c in caps" :key="c.key"
|
||||
v-model.number="c.val" :label="c.label" type="number"
|
||||
:min="c.min" :max="c.max" :step="c.step || 1" density="compact"
|
||||
hide-details style="max-width: 165px;" :disabled="busy"
|
||||
@change="save({ [c.key]: Number(c.val) })"
|
||||
v-model="c.val" :label="c.label"
|
||||
:min="c.min" :max="c.max" :step="c.step || 1"
|
||||
max-width="165px" :disabled="busy"
|
||||
@change="saveField({ [c.key]: Number(c.val) })"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -61,14 +56,16 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { toast } from '../../utils/toast.js'
|
||||
import { onMounted, ref } from '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'
|
||||
|
||||
const mlSettings = useMLStore()
|
||||
const busy = ref(false)
|
||||
const { busy, save } = useSettingSave(mlSettings.patchSettings)
|
||||
const proposers = ref([])
|
||||
const caps = ref([])
|
||||
|
||||
@@ -111,22 +108,16 @@ onMounted(async () => {
|
||||
caps.value = CAP_DEFS.map(c => ({ ...c, val: s[c.key] ?? 0 }))
|
||||
})
|
||||
|
||||
async function save(patch, revert) {
|
||||
busy.value = true
|
||||
try {
|
||||
await mlSettings.patchSettings(patch)
|
||||
toast({ text: 'Saved', type: 'success' })
|
||||
} catch (e) {
|
||||
if (revert) revert()
|
||||
toast({ text: `Could not save: ${e.message}`, type: 'error' })
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
// Field @change → persist with a "Saved" confirmation. SettingNumberField has
|
||||
// already clamped numeric values to their [min,max] before this fires.
|
||||
function saveField(patch) {
|
||||
save(patch, { successMessage: 'Saved' })
|
||||
}
|
||||
|
||||
function saveToggle (p, v) {
|
||||
async function saveToggle(p, v) {
|
||||
// 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>
|
||||
|
||||
|
||||
@@ -95,14 +95,10 @@
|
||||
|
||||
<!-- 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>
|
||||
<SettingToggleRow
|
||||
v-model="autoEnabled" :loading="settingBusy"
|
||||
icon="mdi-lightning-bolt" label="Auto-apply" @change="onToggleAuto"
|
||||
/>
|
||||
<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) }}%
|
||||
@@ -111,17 +107,14 @@
|
||||
</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"
|
||||
<SettingNumberField
|
||||
v-model="autoPrecisionInput" label="Precision target"
|
||||
:min="0.5" :max="0.999" :step="0.01" :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"
|
||||
<SettingNumberField
|
||||
v-model="autoMinPosInput" label="Min examples to fire"
|
||||
:min="1" :disabled="settingBusy" @change="onSaveSettings"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -161,15 +154,11 @@
|
||||
|
||||
<!-- Presentation chrome auto-hide (#141) -->
|
||||
<div class="fc-auto mt-6">
|
||||
<div class="d-flex align-center mb-1" style="gap: 10px;">
|
||||
<v-icon size="18" color="accent">mdi-image-off-outline</v-icon>
|
||||
<span class="fc-section-h">Hide presentation chrome</span>
|
||||
<v-switch
|
||||
v-model="presentationEnabled" :loading="settingBusy" hide-details
|
||||
density="compact" color="success" class="ml-auto"
|
||||
@update:model-value="onTogglePresentation"
|
||||
/>
|
||||
</div>
|
||||
<SettingToggleRow
|
||||
v-model="presentationEnabled" :loading="settingBusy"
|
||||
icon="mdi-image-off-outline" label="Hide presentation chrome"
|
||||
@change="onTogglePresentation"
|
||||
/>
|
||||
<p class="fc-muted text-body-2 mb-3">
|
||||
Auto-hide <code>banner</code> chrome from the gallery once a head has
|
||||
learned it (≥ {{ minPositives }} examples) and clears
|
||||
@@ -180,16 +169,14 @@
|
||||
tag), it's flagged for review instead of buried. Every auto-hide is reversible.
|
||||
</p>
|
||||
<div class="d-flex mb-3" style="gap: 12px;">
|
||||
<v-text-field
|
||||
v-model.number="presentationThresholdInput" label="Hide confidence"
|
||||
type="number" min="0.5" max="0.999" step="0.01" density="compact"
|
||||
hide-details style="max-width: 200px;" :disabled="settingBusy"
|
||||
<SettingNumberField
|
||||
v-model="presentationThresholdInput" label="Hide confidence"
|
||||
:min="0.5" :max="0.999" :step="0.01" :disabled="settingBusy"
|
||||
@change="onSavePresentation"
|
||||
/>
|
||||
<v-text-field
|
||||
v-model.number="presentationConflictInput" label="Flag if content ≥"
|
||||
type="number" min="0" max="1" step="0.05" density="compact"
|
||||
hide-details style="max-width: 200px;" :disabled="settingBusy"
|
||||
<SettingNumberField
|
||||
v-model="presentationConflictInput" label="Flag if content ≥"
|
||||
:min="0" :max="1" :step="0.05" :disabled="settingBusy"
|
||||
@change="onSavePresentation"
|
||||
/>
|
||||
</div>
|
||||
@@ -197,15 +184,11 @@
|
||||
|
||||
<!-- Process auto-tagging (#1464): wip / editor screenshot -->
|
||||
<div class="fc-auto mt-6">
|
||||
<div class="d-flex align-center mb-1" style="gap: 10px;">
|
||||
<v-icon size="18" color="accent">mdi-progress-wrench</v-icon>
|
||||
<span class="fc-section-h">Auto-tag work-in-progress</span>
|
||||
<v-switch
|
||||
v-model="processEnabled" :loading="settingBusy" hide-details
|
||||
density="compact" color="success" class="ml-auto"
|
||||
@update:model-value="onToggleProcess"
|
||||
/>
|
||||
</div>
|
||||
<SettingToggleRow
|
||||
v-model="processEnabled" :loading="settingBusy"
|
||||
icon="mdi-progress-wrench" label="Auto-tag work-in-progress"
|
||||
@change="onToggleProcess"
|
||||
/>
|
||||
<p class="fc-muted text-body-2 mb-3">
|
||||
Auto-tag <code>wip</code> and <code>editor screenshot</code> process art
|
||||
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.
|
||||
</p>
|
||||
<div class="d-flex mb-3" style="gap: 12px;">
|
||||
<v-text-field
|
||||
v-model.number="processThresholdInput" label="Tag confidence"
|
||||
type="number" min="0.5" max="0.999" step="0.01" density="compact"
|
||||
hide-details style="max-width: 200px;" :disabled="settingBusy"
|
||||
<SettingNumberField
|
||||
v-model="processThresholdInput" label="Tag confidence"
|
||||
:min="0.5" :max="0.999" :step="0.01" :disabled="settingBusy"
|
||||
@change="onSaveProcess"
|
||||
/>
|
||||
<v-text-field
|
||||
v-model.number="processConflictInput" label="Flag if content ≥"
|
||||
type="number" min="0" max="1" step="0.05" density="compact"
|
||||
hide-details style="max-width: 200px;" :disabled="settingBusy"
|
||||
<SettingNumberField
|
||||
v-model="processConflictInput" label="Flag if content ≥"
|
||||
:min="0" :max="1" :step="0.05" :disabled="settingBusy"
|
||||
@change="onSaveProcess"
|
||||
/>
|
||||
</div>
|
||||
@@ -272,6 +253,9 @@ import { toast } from '../../utils/toast.js'
|
||||
import { computed, onMounted, onUnmounted, ref } from '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 { useMLStore } from '../../stores/ml.js'
|
||||
|
||||
@@ -285,7 +269,9 @@ let pollTimer = null
|
||||
const autoEnabled = ref(false)
|
||||
const autoPrecisionInput = ref(0.97)
|
||||
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 autoStatus = ref(null)
|
||||
const metricsData = ref(null)
|
||||
@@ -395,81 +381,39 @@ function startAutoPoll() {
|
||||
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
|
||||
}
|
||||
const ok = await save({ head_auto_apply_enabled: !!val },
|
||||
{ successMessage: val ? 'Auto-apply on' : 'Auto-apply off', errorPrefix: 'Could not update' })
|
||||
if (!ok) autoEnabled.value = !val // revert the switch
|
||||
}
|
||||
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
|
||||
}
|
||||
await save({
|
||||
head_auto_apply_precision: Number(autoPrecisionInput.value),
|
||||
head_auto_apply_min_positives: Number(autoMinPosInput.value),
|
||||
})
|
||||
}
|
||||
|
||||
async function onTogglePresentation(val) {
|
||||
settingBusy.value = true
|
||||
try {
|
||||
await mlSettings.patchSettings({ presentation_auto_apply_enabled: !!val })
|
||||
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
|
||||
}
|
||||
const ok = await save({ presentation_auto_apply_enabled: !!val },
|
||||
{ successMessage: val ? 'Chrome auto-hide on' : 'Chrome auto-hide off', errorPrefix: 'Could not update' })
|
||||
if (!ok) presentationEnabled.value = !val // revert the switch
|
||||
}
|
||||
async function onSavePresentation() {
|
||||
settingBusy.value = true
|
||||
try {
|
||||
await mlSettings.patchSettings({
|
||||
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
|
||||
}
|
||||
await save({
|
||||
presentation_auto_apply_threshold: Number(presentationThresholdInput.value),
|
||||
presentation_conflict_threshold: Number(presentationConflictInput.value),
|
||||
})
|
||||
}
|
||||
|
||||
async function onToggleProcess(val) {
|
||||
settingBusy.value = true
|
||||
try {
|
||||
await mlSettings.patchSettings({ process_auto_apply_enabled: !!val })
|
||||
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
|
||||
}
|
||||
const ok = await save({ process_auto_apply_enabled: !!val },
|
||||
{ successMessage: val ? 'WIP auto-tag on' : 'WIP auto-tag off', errorPrefix: 'Could not update' })
|
||||
if (!ok) processEnabled.value = !val // revert the switch
|
||||
}
|
||||
async function onSaveProcess() {
|
||||
settingBusy.value = true
|
||||
try {
|
||||
await mlSettings.patchSettings({
|
||||
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
|
||||
}
|
||||
await save({
|
||||
process_auto_apply_threshold: Number(processThresholdInput.value),
|
||||
process_conflict_threshold: Number(processConflictInput.value),
|
||||
})
|
||||
}
|
||||
function onPreview() { startSweep(true) }
|
||||
function onApplyNow() { startSweep(false) }
|
||||
|
||||
@@ -39,13 +39,14 @@
|
||||
import { toast } from '../../utils/toast.js'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useMLStore } from '../../stores/ml.js'
|
||||
import { useSettingSave } from '../../composables/useSettingSave.js'
|
||||
import MaintenanceTile from '../common/MaintenanceTile.vue'
|
||||
import QueueStatusBar from './QueueStatusBar.vue'
|
||||
const store = useMLStore()
|
||||
const { busy: saving, save } = useSettingSave(store.patchSettings)
|
||||
const busy = ref(false)
|
||||
const done = ref(false)
|
||||
const enabled = ref(true)
|
||||
const saving = ref(false)
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await store.loadSettings()
|
||||
@@ -55,21 +56,12 @@ onMounted(async () => {
|
||||
} catch { /* non-fatal */ }
|
||||
})
|
||||
async function onToggle() {
|
||||
saving.value = true
|
||||
try {
|
||||
await store.patchSettings({ cpu_embed_enabled: enabled.value })
|
||||
toast({
|
||||
text: enabled.value
|
||||
? 'CPU embedding on — imports queue embeds for the ml-worker'
|
||||
: '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
|
||||
}
|
||||
const ok = await save({ cpu_embed_enabled: enabled.value }, {
|
||||
successMessage: enabled.value
|
||||
? 'CPU embedding on — imports queue embeds for the ml-worker'
|
||||
: 'CPU embedding off — the GPU embed backfill owns whole-image embeds',
|
||||
})
|
||||
if (!ok) enabled.value = !enabled.value
|
||||
}
|
||||
async function run() {
|
||||
busy.value = true
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
the CPU fallback.
|
||||
</p>
|
||||
<div class="fc-tile-stack">
|
||||
<VideoEmbeddingCard />
|
||||
<GpuAgentCard />
|
||||
<GpuTriageCard />
|
||||
<MLBackfillCard />
|
||||
@@ -36,7 +37,6 @@
|
||||
Suggestion thresholds, trained heads and tag aliases.
|
||||
</p>
|
||||
<div class="fc-tile-stack">
|
||||
<MLThresholdSliders />
|
||||
<CropProposersCard />
|
||||
<HeadsCard />
|
||||
<AliasTable />
|
||||
@@ -77,7 +77,7 @@ import ArchiveReextractCard from './ArchiveReextractCard.vue'
|
||||
import MissingFileRepairCard from './MissingFileRepairCard.vue'
|
||||
import GpuTriageCard from './GpuTriageCard.vue'
|
||||
import DbMaintenanceCard from './DbMaintenanceCard.vue'
|
||||
import MLThresholdSliders from './MLThresholdSliders.vue'
|
||||
import VideoEmbeddingCard from './VideoEmbeddingCard.vue'
|
||||
import CropProposersCard from './CropProposersCard.vue'
|
||||
import HeadsCard from './HeadsCard.vue'
|
||||
import GpuAgentCard from './GpuAgentCard.vue'
|
||||
|
||||
+18
-16
@@ -12,17 +12,17 @@
|
||||
</div>
|
||||
<v-row>
|
||||
<v-col cols="12" sm="6">
|
||||
<v-text-field
|
||||
v-model.number="local.video_frame_interval_seconds"
|
||||
label="Frame interval (s)" type="number" min="0.5" step="0.5"
|
||||
density="comfortable" hide-details @change="save"
|
||||
<SettingNumberField
|
||||
v-model="local.video_frame_interval_seconds"
|
||||
label="Frame interval (s)" :min="0.5" :step="0.5"
|
||||
density="comfortable" max-width="none" @change="onSave"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6">
|
||||
<v-text-field
|
||||
v-model.number="local.video_max_frames"
|
||||
label="Max frames" type="number" min="1" step="1"
|
||||
density="comfortable" hide-details @change="save"
|
||||
<SettingNumberField
|
||||
v-model="local.video_max_frames"
|
||||
label="Max frames" :min="1" :step="1"
|
||||
density="comfortable" max-width="none" @change="onSave"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
@@ -32,21 +32,23 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { toast } from '../../utils/toast.js'
|
||||
import { reactive, watch } from 'vue'
|
||||
import { useMLStore } from '../../stores/ml.js'
|
||||
import MaintenanceTile from '../common/MaintenanceTile.vue'
|
||||
import SettingNumberField from '../common/SettingNumberField.vue'
|
||||
import { useSettingSave } from '../../composables/useSettingSave.js'
|
||||
|
||||
const store = useMLStore()
|
||||
const { save } = useSettingSave(store.patchSettings)
|
||||
const local = reactive({})
|
||||
watch(() => store.settings, (s) => { if (s) Object.assign(local, s) }, { immediate: true })
|
||||
|
||||
async function save() {
|
||||
const patch = {
|
||||
video_frame_interval_seconds: local.video_frame_interval_seconds,
|
||||
video_max_frames: local.video_max_frames
|
||||
}
|
||||
try { await store.patchSettings(patch) }
|
||||
catch (e) { toast({ text: e.message, type: 'error' }) }
|
||||
// SettingNumberField clamps interval to ≥0.5 and max-frames to ≥1 before this
|
||||
// fires, so an out-of-range value never reaches the API.
|
||||
function onSave() {
|
||||
save({
|
||||
video_frame_interval_seconds: Number(local.video_frame_interval_seconds),
|
||||
video_max_frames: Number(local.video_max_frames),
|
||||
})
|
||||
}
|
||||
</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 }
|
||||
}
|
||||
Reference in New Issue
Block a user