feat: runtime feature flags, upload/download, RAW decoding

Adds Redis-backed feature flags for vision stages with admin UI toggles
and manual backfill trigger, photo upload and download routers with
frontend upload modal, and rawpy-based RAW decoding with JPEG fallback
for misnamed DNGs. Fixes pgvector serialization, is_trashed filter, and
naive-datetime bind in incremental duplicate regrouping; bumps Celery
time limits on regroup tasks beyond the 5-minute default.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
root
2026-04-14 21:31:52 +02:00
parent 800ee447ad
commit 5c531f11da
16 changed files with 2232 additions and 35 deletions

View File

@@ -15,15 +15,23 @@ import {
Activity,
FolderSearch,
Shield,
Brain,
ScanText,
UserSquare2,
Boxes,
Tags as TagsIcon,
RotateCcw,
} from 'lucide-react'
import clsx from 'clsx'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import {
library,
admin as adminApi,
type MediaType,
type PipelineStage,
type ScanStatus,
type WorkerStatus,
type FeatureFlagSnapshot,
} from '../../services/api'
import { toast } from '../ToastContainer'
import { useAuth } from '../../contexts/AuthContext'
@@ -42,13 +50,16 @@ const SETTINGS_SCAN_STATUS_KEY = ['settings', 'scan-status'] as const
// the grid renders from. Imported via the canonical hook key.
import { DUPLICATE_GROUPS_QUERY_KEY } from '../../hooks/useDuplicateGroupsQuery'
type SettingsTab = 'library' | 'users'
type SettingsTab = 'library' | 'ai' | 'users'
const TABS: { id: SettingsTab; label: string; adminOnly?: boolean }[] = [
{ id: 'library', label: 'Library Management' },
{ id: 'ai', label: 'AI Features', adminOnly: true },
{ id: 'users', label: 'Users', adminOnly: true },
]
const SETTINGS_FEATURE_FLAGS_KEY = ['settings', 'feature-flags'] as const
/**
* Full-page settings view with tabbed navigation. Replaces the old
* modal dialog — renders as a top-level section in the main content
@@ -842,6 +853,13 @@ export function SettingsPage() {
</Section>
</>)}
{activeTab === 'ai' && isAdmin && (
<AiFeaturesTab
busy={busy}
runAction={runAction}
/>
)}
{activeTab === 'users' && isAdmin && (
<Section
icon={<Shield className="h-4 w-4" />}
@@ -1073,3 +1091,267 @@ function ActionButton({
</button>
)
}
// ---------------------------------------------------------------------------
// AI Features admin tab
// ---------------------------------------------------------------------------
interface AiFeaturesTabProps {
busy: Record<string, boolean>
runAction: <T>(
key: string,
fn: () => Promise<T>,
successTitle: string,
describe?: (result: T) => string | undefined,
) => Promise<void>
}
// Flags are keyed by the backend's canonical name ("vision.enabled",
// "vision.ocr.enabled", ...). The metadata here just adds presentation
// (label, short description, icon) so the tab layout stays data-driven.
const FLAG_META: Array<{
id: string
label: string
description: string
icon: React.ReactNode
// Optional "run this backfill" hook — lets the user kick off a stage's
// backfill right from the toggle row without hopping to a separate UI.
backfillTask?: 'embed' | 'ocr' | 'detect' | 'faces' | 'classify'
}> = [
{
id: 'vision.enabled',
label: 'Vision pipeline (master switch)',
description:
'When off, every AI stage below is skipped — including newly uploaded photos. ' +
'Existing results stay intact.',
icon: <Sparkles className="h-3.5 w-3.5" />,
},
{
id: 'vision.ocr.enabled',
label: 'Text recognition (OCR)',
description: 'Extract printed / handwritten text from photos so it becomes searchable.',
icon: <ScanText className="h-3.5 w-3.5" />,
backfillTask: 'ocr',
},
{
id: 'vision.detector.enabled',
label: 'Object detection',
description: 'Tag photos with detected objects (person, car, dog, …) via YOLOv8n.',
icon: <Boxes className="h-3.5 w-3.5" />,
backfillTask: 'detect',
},
{
id: 'vision.faces.enabled',
label: 'Face recognition',
description:
'Find and cluster faces across the library (RetinaFace + ArcFace). ' +
'Expensive on big libraries — disable if you don\'t need the People view.',
icon: <UserSquare2 className="h-3.5 w-3.5" />,
backfillTask: 'faces',
},
{
id: 'vision.classifier.enabled',
label: 'Content classification',
description: 'Zero-shot CLIP tags for scenes / activities (beach, wedding, …).',
icon: <TagsIcon className="h-3.5 w-3.5" />,
backfillTask: 'classify',
},
]
function AiFeaturesTab({ busy, runAction }: AiFeaturesTabProps) {
const queryClient = useQueryClient()
const flagsQuery = useQuery<{ flags: FeatureFlagSnapshot }>({
queryKey: SETTINGS_FEATURE_FLAGS_KEY,
queryFn: adminApi.listFeatureFlags,
staleTime: 5_000,
})
const flags = flagsQuery.data?.flags ?? {}
const masterOff = flags['vision.enabled'] && !flags['vision.enabled'].effective
const applyFlag = async (name: string, value: boolean | null) => {
await runAction(
`flag:${name}`,
() => adminApi.setFeatureFlag(name, value),
value === null ? 'Override cleared' : `Feature ${value ? 'enabled' : 'disabled'}`,
)
queryClient.invalidateQueries({ queryKey: SETTINGS_FEATURE_FLAGS_KEY })
// Non-admin feature map drives sidebar gating — invalidate so the
// People / Tags entries appear / disappear immediately without a
// page reload.
queryClient.invalidateQueries({ queryKey: ['features'] })
}
type BackfillTask = 'embed' | 'ocr' | 'detect' | 'faces' | 'classify' | null
const runBackfill = (task: BackfillTask) =>
runAction(
`ai-backfill:${task ?? 'all'}`,
() => adminApi.triggerAiBackfill({ task }),
task ? `Backfill queued for ${task}` : 'Full backfill queued',
(r) => `Celery task ${r.task_id}`,
)
return (
<>
<Section icon={<Brain className="h-4 w-4" />} title="AI feature flags">
<p className="text-xs text-text-muted">
Toggle each stage at runtime. Changes are observed by Celery
workers on the next task no restart needed. "Default" means
the flag hasn\'t been overridden and is tracking the YAML config;
an overridden flag is pinned to the value shown until cleared.
</p>
{flagsQuery.isLoading && (
<div className="mt-3 flex items-center gap-2 text-xs text-text-muted">
<Loader2 className="h-3 w-3 animate-spin" />
Loading feature flags…
</div>
)}
{flagsQuery.error && (
<ErrorBanner
title="Could not load feature flags"
detail={String((flagsQuery.error as Error).message || flagsQuery.error)}
/>
)}
{!flagsQuery.isLoading && !flagsQuery.error && (
<div className="mt-3 space-y-2">
{FLAG_META.map((meta) => {
const state = flags[meta.id]
if (!state) return null
const busyKey = `flag:${meta.id}`
const isBusy = !!busy[busyKey]
const isMaster = meta.id === 'vision.enabled'
const dimmed = !isMaster && masterOff
return (
<div
key={meta.id}
className={clsx(
'rounded border border-border bg-surface p-3 text-xs',
dimmed && 'opacity-60',
)}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5 text-text">
{meta.icon}
<span className="font-medium">{meta.label}</span>
{state.overridden && (
<span className="rounded bg-primary/20 px-1 py-0.5 text-[9px] font-semibold uppercase tracking-wide text-primary">
overridden
</span>
)}
</div>
<p className="mt-1 text-[11px] text-text-muted">{meta.description}</p>
<p className="mt-1 text-[10px] text-text-faint">
Default: {state.default ? 'on' : 'off'} · Currently:{' '}
<span className={state.effective ? 'text-pick' : 'text-reject'}>
{state.effective ? 'on' : 'off'}
</span>
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<button
role="switch"
aria-checked={state.effective}
onClick={() => applyFlag(meta.id, !state.effective)}
disabled={isBusy || (dimmed && !isMaster)}
className={clsx(
'relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors',
state.effective ? 'bg-primary' : 'bg-surface-2 border border-border',
(isBusy || (dimmed && !isMaster)) && 'cursor-not-allowed opacity-50',
)}
title={state.effective ? 'Click to disable' : 'Click to enable'}
>
<span
aria-hidden="true"
className={clsx(
'inline-block h-5 w-5 transform rounded-full bg-white shadow transition-transform',
state.effective ? 'translate-x-[22px]' : 'translate-x-0.5',
)}
/>
</button>
{state.overridden && (
<button
onClick={() => applyFlag(meta.id, null)}
disabled={isBusy}
className="rounded border border-border p-1 text-text-muted hover:bg-surface-2 hover:text-text disabled:opacity-50"
title="Reset to YAML default"
aria-label="Reset override"
>
<RotateCcw className="h-3 w-3" />
</button>
)}
</div>
</div>
{meta.backfillTask && state.effective && !masterOff && (
<div className="mt-2">
<ActionButton
loading={!!busy[`ai-backfill:${meta.backfillTask}`]}
onClick={() => runBackfill(meta.backfillTask!)}
>
<RefreshCw className="h-3.5 w-3.5" />
Run {meta.backfillTask} backfill
</ActionButton>
</div>
)}
</div>
)
})}
</div>
)}
</Section>
<Section icon={<Cpu className="h-4 w-4" />} title="Manual pipeline triggers">
<p className="text-xs text-text-muted">
Run a full pass across the enabled stages, recompute face
clusters, or force a fresh filesystem scan. All three are safe
to run repeatedly — the backfill only touches photos that
don\'t yet have a given output, and the rescan skips files
that are already indexed.
</p>
<div className="mt-3 flex flex-wrap gap-2">
<ActionButton
loading={!!busy['ai-backfill:all']}
onClick={() => runBackfill(null)}
disabled={masterOff}
>
<Sparkles className="h-4 w-4" />
Run full vision backfill
</ActionButton>
<ActionButton
loading={!!busy['recluster']}
onClick={() =>
runAction(
'recluster',
() => adminApi.triggerFaceRecluster(),
'Face recluster queued',
(r) => `Celery task ${r.task_id}`,
)
}
disabled={masterOff || !flags['vision.faces.enabled']?.effective}
>
<UserSquare2 className="h-4 w-4" />
Recluster faces
</ActionButton>
<ActionButton
loading={!!busy['rescan-full']}
onClick={() =>
runAction(
'rescan-full',
() => adminApi.triggerFullRescan(),
'Rescan queued',
(r) => `Celery task ${r.task_id}`,
)
}
>
<RefreshCw className="h-4 w-4" />
Rescan all source roots
</ActionButton>
</div>
</Section>
</>
)
}