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:
@@ -713,6 +713,72 @@ export const heaps = {
|
||||
},
|
||||
}
|
||||
|
||||
// Upload API — single file per request so the browser can fan out many
|
||||
// POSTs in parallel with per-file progress. For folder uploads the
|
||||
// caller passes each File's webkitRelativePath so the backend can
|
||||
// materialise the folder structure under the destination.
|
||||
export const uploads = {
|
||||
uploadFile: async (
|
||||
file: File,
|
||||
destinationFolderId: string,
|
||||
opts: {
|
||||
relativePath?: string
|
||||
heapId?: string | null
|
||||
onProgress?: (loadedBytes: number, totalBytes: number) => void
|
||||
signal?: AbortSignal
|
||||
} = {}
|
||||
) => {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
form.append('destination_folder_id', destinationFolderId)
|
||||
if (opts.relativePath) form.append('relative_path', opts.relativePath)
|
||||
if (opts.heapId) form.append('heap_id', opts.heapId)
|
||||
|
||||
const response = await api.post('/upload', form, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
signal: opts.signal,
|
||||
onUploadProgress: (evt) => {
|
||||
if (opts.onProgress && evt.total) opts.onProgress(evt.loaded, evt.total)
|
||||
},
|
||||
})
|
||||
return response.data as {
|
||||
photo_id: string
|
||||
filename: string
|
||||
folder_id: string
|
||||
folder_path: string
|
||||
heap_id: string | null
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// Download helpers — build a URL the browser can pull directly via an
|
||||
// <a href>. The backend accepts `?token=` so an <a> works without a
|
||||
// custom fetch + save-blob dance; the Authorization header is not
|
||||
// settable on a plain link click.
|
||||
export const downloads = {
|
||||
folderUrl: (folderId: string): string => {
|
||||
const token = localStorage.getItem('access_token') || ''
|
||||
return `${API_BASE_URL}/download/folders/${folderId}?token=${encodeURIComponent(token)}`
|
||||
},
|
||||
heapUrl: (heapId: string): string => {
|
||||
const token = localStorage.getItem('access_token') || ''
|
||||
return `${API_BASE_URL}/download/heaps/${heapId}?token=${encodeURIComponent(token)}`
|
||||
},
|
||||
trigger: (url: string) => {
|
||||
// Kicking off a download via a transient <a> click keeps the
|
||||
// browser in charge of the file dialog + progress indicator. We
|
||||
// use target=_blank so the current SPA route isn't replaced if
|
||||
// the server returns an error mid-stream.
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.rel = 'noopener'
|
||||
a.target = '_blank'
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
},
|
||||
}
|
||||
|
||||
// Sharing API
|
||||
|
||||
export interface SharedHeap {
|
||||
@@ -928,6 +994,61 @@ export const admin = {
|
||||
const response = await api.delete(`/admin/users/${userId}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// --- AI / vision feature flags + manual pipeline triggers -----------
|
||||
|
||||
listFeatureFlags: async (): Promise<{ flags: FeatureFlagSnapshot }> => {
|
||||
const response = await api.get('/admin/feature-flags')
|
||||
return response.data
|
||||
},
|
||||
|
||||
/** Set ``value`` to toggle; pass ``null`` to clear the override and
|
||||
* fall back to the YAML default. */
|
||||
setFeatureFlag: async (
|
||||
name: string,
|
||||
value: boolean | null,
|
||||
): Promise<{ flags: FeatureFlagSnapshot }> => {
|
||||
const response = await api.patch(`/admin/feature-flags/${encodeURIComponent(name)}`, { value })
|
||||
return response.data
|
||||
},
|
||||
|
||||
triggerAiBackfill: async (body: {
|
||||
task?: 'embed' | 'ocr' | 'detect' | 'faces' | 'classify' | null
|
||||
limit?: number | null
|
||||
}): Promise<{ status: string; task_id: string }> => {
|
||||
const response = await api.post('/admin/ai/backfill', body)
|
||||
return response.data
|
||||
},
|
||||
|
||||
triggerFaceRecluster: async (): Promise<{ status: string; task_id: string }> => {
|
||||
const response = await api.post('/admin/ai/recluster-faces')
|
||||
return response.data
|
||||
},
|
||||
|
||||
triggerFullRescan: async (): Promise<{ status: string; task_id: string }> => {
|
||||
const response = await api.post('/admin/ai/rescan')
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
|
||||
export interface FeatureFlagState {
|
||||
effective: boolean
|
||||
default: boolean
|
||||
overridden: boolean
|
||||
}
|
||||
|
||||
export type FeatureFlagSnapshot = Record<string, FeatureFlagState>
|
||||
|
||||
export type FeaturesMap = Record<string, boolean>
|
||||
|
||||
// Public read of effective feature flags. Available to any signed-in
|
||||
// user so the frontend can hide sections that depend on a disabled
|
||||
// pipeline stage (e.g. People when faces are off).
|
||||
export const features = {
|
||||
list: async (): Promise<FeaturesMap> => {
|
||||
const response = await api.get('/features')
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
|
||||
export default api
|
||||
Reference in New Issue
Block a user