This commit is contained in:
Yaojia Wang
2026-02-01 00:08:40 +01:00
parent 33ada0350d
commit a516de4320
90 changed files with 11642 additions and 398 deletions

View File

@@ -4,11 +4,13 @@ import type {
DocumentDetailResponse,
DocumentItem,
UploadDocumentResponse,
DocumentCategoriesResponse,
} from '../types'
export const documentsApi = {
list: async (params?: {
status?: string
category?: string
limit?: number
offset?: number
}): Promise<DocumentListResponse> => {
@@ -16,18 +18,29 @@ export const documentsApi = {
return data
},
getCategories: async (): Promise<DocumentCategoriesResponse> => {
const { data } = await apiClient.get('/api/v1/admin/documents/categories')
return data
},
getDetail: async (documentId: string): Promise<DocumentDetailResponse> => {
const { data } = await apiClient.get(`/api/v1/admin/documents/${documentId}`)
return data
},
upload: async (file: File, groupKey?: string): Promise<UploadDocumentResponse> => {
upload: async (
file: File,
options?: { groupKey?: string; category?: string }
): Promise<UploadDocumentResponse> => {
const formData = new FormData()
formData.append('file', file)
const params: Record<string, string> = {}
if (groupKey) {
params.group_key = groupKey
if (options?.groupKey) {
params.group_key = options.groupKey
}
if (options?.category) {
params.category = options.category
}
const { data } = await apiClient.post('/api/v1/admin/documents', formData, {
@@ -95,4 +108,15 @@ export const documentsApi = {
)
return data
},
updateCategory: async (
documentId: string,
category: string
): Promise<{ status: string; document_id: string; category: string; message: string }> => {
const { data } = await apiClient.patch(
`/api/v1/admin/documents/${documentId}/category`,
{ category }
)
return data
},
}

View File

@@ -9,6 +9,7 @@ export interface DocumentItem {
auto_label_error: string | null
upload_source: string
group_key: string | null
category: string
created_at: string
updated_at: string
annotation_count?: number
@@ -61,6 +62,7 @@ export interface DocumentDetailResponse {
upload_source: string
batch_id: string | null
group_key: string | null
category: string
csv_field_values: Record<string, string> | null
can_annotate: boolean
annotation_lock_until: string | null
@@ -101,8 +103,21 @@ export interface TrainingTask {
updated_at: string
}
export interface ModelVersionItem {
version_id: string
version: string
name: string
status: string
is_active: boolean
metrics_mAP: number | null
document_count: number
trained_at: string | null
activated_at: string | null
created_at: string
}
export interface TrainingModelsResponse {
models: TrainingTask[]
models: ModelVersionItem[]
total: number
limit: number
offset: number
@@ -118,11 +133,17 @@ export interface UploadDocumentResponse {
file_size: number
page_count: number
status: string
category: string
group_key: string | null
auto_label_started: boolean
message: string
}
export interface DocumentCategoriesResponse {
categories: string[]
total: number
}
export interface CreateAnnotationRequest {
page_number: number
class_id: number
@@ -228,6 +249,8 @@ export interface DatasetDetailResponse {
name: string
description: string | null
status: string
training_status: string | null
active_training_task_id: string | null
train_ratio: number
val_ratio: number
seed: number

View File

@@ -3,7 +3,7 @@ import { Search, ChevronDown, MoreHorizontal, FileText } from 'lucide-react'
import { Badge } from './Badge'
import { Button } from './Button'
import { UploadModal } from './UploadModal'
import { useDocuments } from '../hooks/useDocuments'
import { useDocuments, useCategories } from '../hooks/useDocuments'
import type { DocumentItem } from '../api/types'
interface DashboardProps {
@@ -34,11 +34,15 @@ export const Dashboard: React.FC<DashboardProps> = ({ onNavigate }) => {
const [isUploadOpen, setIsUploadOpen] = useState(false)
const [selectedDocs, setSelectedDocs] = useState<Set<string>>(new Set())
const [statusFilter, setStatusFilter] = useState<string>('')
const [categoryFilter, setCategoryFilter] = useState<string>('')
const [limit] = useState(20)
const [offset] = useState(0)
const { categories } = useCategories()
const { documents, total, isLoading, error, refetch } = useDocuments({
status: statusFilter || undefined,
category: categoryFilter || undefined,
limit,
offset,
})
@@ -102,6 +106,24 @@ export const Dashboard: React.FC<DashboardProps> = ({ onNavigate }) => {
</div>
<div className="flex gap-3">
<div className="relative">
<select
value={categoryFilter}
onChange={(e) => setCategoryFilter(e.target.value)}
className="h-10 pl-3 pr-8 rounded-md border border-warm-border bg-white text-sm text-warm-text-secondary focus:outline-none appearance-none cursor-pointer hover:bg-warm-hover"
>
<option value="">All Categories</option>
{categories.map((cat) => (
<option key={cat} value={cat}>
{cat.charAt(0).toUpperCase() + cat.slice(1)}
</option>
))}
</select>
<ChevronDown
className="absolute right-2.5 top-1/2 -translate-y-1/2 pointer-events-none text-warm-text-muted"
size={14}
/>
</div>
<div className="relative">
<select
value={statusFilter}
@@ -144,6 +166,9 @@ export const Dashboard: React.FC<DashboardProps> = ({ onNavigate }) => {
<th className="py-3 px-4 text-xs font-semibold text-warm-text-muted uppercase tracking-wider">
Annotations
</th>
<th className="py-3 px-4 text-xs font-semibold text-warm-text-muted uppercase tracking-wider">
Category
</th>
<th className="py-3 px-4 text-xs font-semibold text-warm-text-muted uppercase tracking-wider">
Group
</th>
@@ -156,13 +181,13 @@ export const Dashboard: React.FC<DashboardProps> = ({ onNavigate }) => {
<tbody>
{isLoading ? (
<tr>
<td colSpan={8} className="py-8 text-center text-warm-text-muted">
<td colSpan={9} className="py-8 text-center text-warm-text-muted">
Loading documents...
</td>
</tr>
) : documents.length === 0 ? (
<tr>
<td colSpan={8} className="py-8 text-center text-warm-text-muted">
<td colSpan={9} className="py-8 text-center text-warm-text-muted">
No documents found. Upload your first document to get started.
</td>
</tr>
@@ -216,6 +241,9 @@ export const Dashboard: React.FC<DashboardProps> = ({ onNavigate }) => {
<td className="py-4 px-4 text-sm text-warm-text-secondary">
{doc.annotation_count || 0} annotations
</td>
<td className="py-4 px-4 text-sm text-warm-text-secondary capitalize">
{doc.category || 'invoice'}
</td>
<td className="py-4 px-4 text-sm text-warm-text-muted">
{doc.group_key || '-'}
</td>

View File

@@ -1,5 +1,5 @@
import React from 'react'
import { ArrowLeft, Loader2, Play, AlertCircle, Check } from 'lucide-react'
import { ArrowLeft, Loader2, Play, AlertCircle, Check, Award } from 'lucide-react'
import { Button } from './Button'
import { useDatasetDetail } from '../hooks/useDatasets'
@@ -14,6 +14,23 @@ const SPLIT_STYLES: Record<string, string> = {
test: 'bg-warm-state-success/10 text-warm-state-success',
}
const STATUS_STYLES: Record<string, { bg: string; text: string; label: string }> = {
building: { bg: 'bg-warm-state-info/10', text: 'text-warm-state-info', label: 'Building' },
ready: { bg: 'bg-warm-state-success/10', text: 'text-warm-state-success', label: 'Ready' },
trained: { bg: 'bg-purple-100', text: 'text-purple-700', label: 'Trained' },
failed: { bg: 'bg-warm-state-error/10', text: 'text-warm-state-error', label: 'Failed' },
archived: { bg: 'bg-warm-border', text: 'text-warm-text-muted', label: 'Archived' },
}
const TRAINING_STATUS_STYLES: Record<string, { bg: string; text: string; label: string }> = {
pending: { bg: 'bg-warm-state-warning/10', text: 'text-warm-state-warning', label: 'Pending' },
scheduled: { bg: 'bg-warm-state-warning/10', text: 'text-warm-state-warning', label: 'Scheduled' },
running: { bg: 'bg-warm-state-info/10', text: 'text-warm-state-info', label: 'Training' },
completed: { bg: 'bg-warm-state-success/10', text: 'text-warm-state-success', label: 'Completed' },
failed: { bg: 'bg-warm-state-error/10', text: 'text-warm-state-error', label: 'Failed' },
cancelled: { bg: 'bg-warm-border', text: 'text-warm-text-muted', label: 'Cancelled' },
}
export const DatasetDetail: React.FC<DatasetDetailProps> = ({ datasetId, onBack }) => {
const { dataset, isLoading, error } = useDatasetDetail(datasetId)
@@ -36,11 +53,25 @@ export const DatasetDetail: React.FC<DatasetDetailProps> = ({ datasetId, onBack
)
}
const statusIcon = dataset.status === 'ready'
const statusConfig = STATUS_STYLES[dataset.status] || STATUS_STYLES.ready
const trainingStatusConfig = dataset.training_status
? TRAINING_STATUS_STYLES[dataset.training_status]
: null
// Determine if training button should be shown and enabled
const isTrainingInProgress = dataset.training_status === 'running' || dataset.training_status === 'pending'
const canStartTraining = dataset.status === 'ready' && !isTrainingInProgress
// Determine status icon
const statusIcon = dataset.status === 'trained'
? <Award size={14} className="text-purple-700" />
: dataset.status === 'ready'
? <Check size={14} className="text-warm-state-success" />
: dataset.status === 'failed'
? <AlertCircle size={14} className="text-warm-state-error" />
: <Loader2 size={14} className="animate-spin text-warm-state-info" />
: dataset.status === 'building'
? <Loader2 size={14} className="animate-spin text-warm-state-info" />
: null
return (
<div className="p-8 max-w-7xl mx-auto">
@@ -51,15 +82,38 @@ export const DatasetDetail: React.FC<DatasetDetailProps> = ({ datasetId, onBack
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-2xl font-bold text-warm-text-primary flex items-center gap-2">
{dataset.name} {statusIcon}
</h2>
<div className="flex items-center gap-3 mb-1">
<h2 className="text-2xl font-bold text-warm-text-primary flex items-center gap-2">
{dataset.name} {statusIcon}
</h2>
{/* Status Badge */}
<span className={`inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium ${statusConfig.bg} ${statusConfig.text}`}>
{statusConfig.label}
</span>
{/* Training Status Badge */}
{trainingStatusConfig && (
<span className={`inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium ${trainingStatusConfig.bg} ${trainingStatusConfig.text}`}>
{isTrainingInProgress && <Loader2 size={12} className="mr-1 animate-spin" />}
{trainingStatusConfig.label}
</span>
)}
</div>
{dataset.description && (
<p className="text-sm text-warm-text-muted mt-1">{dataset.description}</p>
)}
</div>
{dataset.status === 'ready' && (
<Button><Play size={14} className="mr-1" />Start Training</Button>
{/* Training Button */}
{(dataset.status === 'ready' || dataset.status === 'trained') && (
<Button
disabled={isTrainingInProgress}
className={isTrainingInProgress ? 'opacity-50 cursor-not-allowed' : ''}
>
{isTrainingInProgress ? (
<><Loader2 size={14} className="mr-1 animate-spin" />Training...</>
) : (
<><Play size={14} className="mr-1" />Start Training</>
)}
</Button>
)}
</div>

View File

@@ -72,12 +72,13 @@ const TrainDialog: React.FC<TrainDialogProps> = ({ dataset, onClose, onSubmit, i
const [augmentationConfig, setAugmentationConfig] = useState<Partial<AugmentationConfigType>>({})
const [augmentationMultiplier, setAugmentationMultiplier] = useState(2)
// Fetch available trained models
// Fetch available trained models (active or inactive, not archived)
const { data: modelsData } = useQuery({
queryKey: ['training', 'models', 'completed'],
queryFn: () => trainingApi.getModels({ status: 'completed' }),
queryKey: ['training', 'models', 'available'],
queryFn: () => trainingApi.getModels(),
})
const completedModels = modelsData?.models ?? []
// Filter out archived models - only show active/inactive models for base model selection
const availableModels = (modelsData?.models ?? []).filter(m => m.status !== 'archived')
const handleSubmit = () => {
onSubmit({
@@ -128,9 +129,9 @@ const TrainDialog: React.FC<TrainDialogProps> = ({ dataset, onClose, onSubmit, i
className="w-full h-10 px-3 rounded-md border border-warm-divider bg-white text-warm-text-primary focus:outline-none focus:ring-1 focus:ring-warm-state-info"
>
<option value="pretrained">yolo11n.pt (Pretrained)</option>
{completedModels.map(m => (
<option key={m.task_id} value={m.task_id}>
{m.name} ({m.metrics_mAP ? `${(m.metrics_mAP * 100).toFixed(1)}% mAP` : 'No metrics'})
{availableModels.map(m => (
<option key={m.version_id} value={m.version_id}>
{m.name} v{m.version} ({m.metrics_mAP ? `${(m.metrics_mAP * 100).toFixed(1)}% mAP` : 'No metrics'})
</option>
))}
</select>
@@ -293,8 +294,12 @@ const DatasetList: React.FC<{
</button>
)}
<button title="Delete" onClick={() => deleteDataset(ds.dataset_id)}
disabled={isDeleting}
className="p-1.5 rounded hover:bg-warm-selected text-warm-text-muted hover:text-warm-state-error transition-colors">
disabled={isDeleting || ds.status === 'pending' || ds.status === 'building'}
className={`p-1.5 rounded transition-colors ${
ds.status === 'pending' || ds.status === 'building'
? 'text-warm-text-muted/40 cursor-not-allowed'
: 'hover:bg-warm-selected text-warm-text-muted hover:text-warm-state-error'
}`}>
<Trash2 size={14} />
</button>
</div>

View File

@@ -1,7 +1,7 @@
import React, { useState, useRef } from 'react'
import { X, UploadCloud, File, CheckCircle, AlertCircle } from 'lucide-react'
import { X, UploadCloud, File, CheckCircle, AlertCircle, ChevronDown } from 'lucide-react'
import { Button } from './Button'
import { useDocuments } from '../hooks/useDocuments'
import { useDocuments, useCategories } from '../hooks/useDocuments'
interface UploadModalProps {
isOpen: boolean
@@ -12,11 +12,13 @@ export const UploadModal: React.FC<UploadModalProps> = ({ isOpen, onClose }) =>
const [isDragging, setIsDragging] = useState(false)
const [selectedFiles, setSelectedFiles] = useState<File[]>([])
const [groupKey, setGroupKey] = useState('')
const [category, setCategory] = useState('invoice')
const [uploadStatus, setUploadStatus] = useState<'idle' | 'uploading' | 'success' | 'error'>('idle')
const [errorMessage, setErrorMessage] = useState('')
const fileInputRef = useRef<HTMLInputElement>(null)
const { uploadDocument, isUploading } = useDocuments({})
const { categories } = useCategories()
if (!isOpen) return null
@@ -63,7 +65,7 @@ export const UploadModal: React.FC<UploadModalProps> = ({ isOpen, onClose }) =>
for (const file of selectedFiles) {
await new Promise<void>((resolve, reject) => {
uploadDocument(
{ file, groupKey: groupKey || undefined },
{ file, groupKey: groupKey || undefined, category: category || 'invoice' },
{
onSuccess: () => resolve(),
onError: (error: Error) => reject(error),
@@ -77,6 +79,7 @@ export const UploadModal: React.FC<UploadModalProps> = ({ isOpen, onClose }) =>
onClose()
setSelectedFiles([])
setGroupKey('')
setCategory('invoice')
setUploadStatus('idle')
}, 1500)
} catch (error) {
@@ -91,6 +94,7 @@ export const UploadModal: React.FC<UploadModalProps> = ({ isOpen, onClose }) =>
}
setSelectedFiles([])
setGroupKey('')
setCategory('invoice')
setUploadStatus('idle')
setErrorMessage('')
onClose()
@@ -179,6 +183,42 @@ export const UploadModal: React.FC<UploadModalProps> = ({ isOpen, onClose }) =>
</div>
)}
{/* Category Select */}
{selectedFiles.length > 0 && (
<div className="mb-6">
<label className="block text-sm font-medium text-warm-text-secondary mb-2">
Category
</label>
<div className="relative">
<select
value={category}
onChange={(e) => setCategory(e.target.value)}
className="w-full h-10 pl-3 pr-8 rounded-md border border-warm-border bg-white text-sm text-warm-text-secondary focus:outline-none focus:ring-1 focus:ring-warm-state-info appearance-none cursor-pointer"
disabled={uploadStatus === 'uploading'}
>
<option value="invoice">Invoice</option>
<option value="letter">Letter</option>
<option value="receipt">Receipt</option>
<option value="contract">Contract</option>
{categories
.filter((cat) => !['invoice', 'letter', 'receipt', 'contract'].includes(cat))
.map((cat) => (
<option key={cat} value={cat}>
{cat.charAt(0).toUpperCase() + cat.slice(1)}
</option>
))}
</select>
<ChevronDown
className="absolute right-2.5 top-1/2 -translate-y-1/2 pointer-events-none text-warm-text-muted"
size={14}
/>
</div>
<p className="text-xs text-warm-text-muted mt-1">
Select document type for training different models
</p>
</div>
)}
{/* Group Key Input */}
{selectedFiles.length > 0 && (
<div className="mb-6">

View File

@@ -1,4 +1,4 @@
export { useDocuments } from './useDocuments'
export { useDocuments, useCategories } from './useDocuments'
export { useDocumentDetail } from './useDocumentDetail'
export { useAnnotations } from './useAnnotations'
export { useTraining, useTrainingDocuments } from './useTraining'

View File

@@ -1,9 +1,10 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { documentsApi } from '../api/endpoints'
import type { DocumentListResponse, UploadDocumentResponse } from '../api/types'
import type { DocumentListResponse, DocumentCategoriesResponse } from '../api/types'
interface UseDocumentsParams {
status?: string
category?: string
limit?: number
offset?: number
}
@@ -18,10 +19,11 @@ export const useDocuments = (params: UseDocumentsParams = {}) => {
})
const uploadMutation = useMutation({
mutationFn: ({ file, groupKey }: { file: File; groupKey?: string }) =>
documentsApi.upload(file, groupKey),
mutationFn: ({ file, groupKey, category }: { file: File; groupKey?: string; category?: string }) =>
documentsApi.upload(file, { groupKey, category }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['documents'] })
queryClient.invalidateQueries({ queryKey: ['categories'] })
},
})
@@ -63,6 +65,15 @@ export const useDocuments = (params: UseDocumentsParams = {}) => {
},
})
const updateCategoryMutation = useMutation({
mutationFn: ({ documentId, category }: { documentId: string; category: string }) =>
documentsApi.updateCategory(documentId, category),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['documents'] })
queryClient.invalidateQueries({ queryKey: ['categories'] })
},
})
return {
documents: data?.documents || [],
total: data?.total || 0,
@@ -86,5 +97,24 @@ export const useDocuments = (params: UseDocumentsParams = {}) => {
updateGroupKey: updateGroupKeyMutation.mutate,
updateGroupKeyAsync: updateGroupKeyMutation.mutateAsync,
isUpdatingGroupKey: updateGroupKeyMutation.isPending,
updateCategory: updateCategoryMutation.mutate,
updateCategoryAsync: updateCategoryMutation.mutateAsync,
isUpdatingCategory: updateCategoryMutation.isPending,
}
}
export const useCategories = () => {
const { data, isLoading, error, refetch } = useQuery<DocumentCategoriesResponse>({
queryKey: ['categories'],
queryFn: () => documentsApi.getCategories(),
staleTime: 60000,
})
return {
categories: data?.categories || [],
total: data?.total || 0,
isLoading,
error,
refetch,
}
}