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>
|
</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>
|
||||||
|
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
+18
-16
@@ -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 }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user