Remove Freeform and Find from UI. Allow Description to be added to Reviewed job

This commit is contained in:
Aaron Roberts
2026-06-29 13:09:01 +01:00
parent 48f958de6c
commit 04bbbebd5a
10 changed files with 394 additions and 403 deletions

View File

@@ -1,5 +1,6 @@
import { useState, useCallback } from 'react'
import { useState, useCallback, useEffect } from 'react'
import { useSuggestions } from './hooks/useSuggestions'
import { useModels } from './hooks/useModels'
import { motion, AnimatePresence } from 'framer-motion'
import {
Sparkles, Zap, Loader2, Settings, Image as ImageIcon, FileText,
@@ -7,6 +8,7 @@ import {
} from 'lucide-react'
import ImageUpload from './components/ImageUpload'
import ModeSelector from './components/ModeSelector'
import ModelSelector from './components/ModelSelector'
import ResultPanel from './components/ResultPanel'
import AdvancedSettings from './components/AdvancedSettings'
import PDFProcessor from './components/PDFProcessor'
@@ -24,6 +26,8 @@ function App() {
const [view, setView] = useState('new_job')
// OCR state
const { models, loading: modelsLoading } = useModels()
const [model, setModel] = useState(null)
const [mode, setMode] = useState('plain_ocr')
const [fileType, setFileType] = useState('image')
const [image, setImage] = useState(null)
@@ -51,8 +55,15 @@ function App() {
const [commitResult, setCommitResult] = useState(null)
// Modes that produce editable text output and can be committed to the DB
const COMMITTABLE_MODES = new Set(['plain_ocr', 'describe', 'freeform'])
const MODE_LABELS = { plain_ocr: 'OCR Text', describe: 'Description', freeform: 'Freeform' }
const COMMITTABLE_MODES = new Set(['plain_ocr', 'describe'])
const MODE_LABELS = { plain_ocr: 'OCR Text', describe: 'Description' }
// Pick the default model once the list loads
useEffect(() => {
if (!model && models.length > 0) {
setModel((models.find(m => m.default) || models[0]).id)
}
}, [models, model])
// Show the full-screen result view once at least one committable mode has a result
const showResultView = view === 'new_job' && Object.keys(modeResults).length > 0
@@ -97,6 +108,7 @@ function App() {
try {
const formData = new FormData()
formData.append('image', image)
if (model) formData.append('model', model)
formData.append('mode', mode)
formData.append('prompt', prompt)
formData.append('grounding', mode === 'find_ref')
@@ -149,6 +161,7 @@ function App() {
formData.append('describe_text', editedResults.describe || '')
formData.append('freeform_text', editedResults.freeform || '')
formData.append('mode', mode)
if (model) formData.append('ocr_model', model)
const response = await axios.post(`${API_BASE}/jobs`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
@@ -159,7 +172,7 @@ function App() {
} finally {
setCommitLoading(false)
}
}, [image, editedResults, metadata, mode])
}, [image, editedResults, metadata, mode, model])
const handleCopy = useCallback(() => {
const text = (activeResultMode && editedResults[activeResultMode]) || result?.text
@@ -263,11 +276,12 @@ function App() {
>
{/* Run additional modes */}
<div className="glass p-4 rounded-2xl flex-shrink-0">
<ModeSelector
mode={mode} onModeChange={setMode}
prompt={prompt} onPromptChange={setPrompt}
findTerm={findTerm} onFindTermChange={setFindTerm}
/>
<div className="mb-3">
<ModelSelector
models={models} value={model} onChange={setModel} loading={modelsLoading}
/>
</div>
<ModeSelector mode={mode} onModeChange={setMode} />
<div className="flex items-center gap-3 mt-3">
<motion.button
onClick={handleSubmit}
@@ -462,12 +476,12 @@ function App() {
<MetadataForm metadata={metadata} onChange={setMetadata} suggestions={suggestions} />
<ModeSelector
mode={mode} onModeChange={setMode}
prompt={prompt} onPromptChange={setPrompt}
findTerm={findTerm} onFindTermChange={setFindTerm}
<ModelSelector
models={models} value={model} onChange={setModel} loading={modelsLoading}
/>
<ModeSelector mode={mode} onModeChange={setMode} />
<ImageUpload onImageSelect={handleImageSelect} preview={imagePreview} fileType={fileType} />
<motion.button
@@ -497,7 +511,7 @@ function App() {
{fileType === 'pdf' ? (
<PDFProcessor
pdfFile={image} mode={mode} prompt={prompt}
pdfFile={image} mode={mode} prompt={prompt} model={model}
advancedSettings={advancedSettings} includeCaption={includeCaption}
/>
) : (

View File

@@ -1,9 +1,10 @@
import { useState, useEffect, useCallback } from 'react'
import { useSuggestions } from '../hooks/useSuggestions'
import { useModels } from '../hooks/useModels'
import { motion, AnimatePresence } from 'framer-motion'
import {
Search, ChevronLeft, ChevronRight, CheckCircle2, Clock,
FileText, Loader2, Save, RefreshCw, Trash2,
FileText, Loader2, Save, RefreshCw, Trash2, Sparkles,
} from 'lucide-react'
import axios from 'axios'
@@ -32,10 +33,14 @@ function StatusBadge({ status }) {
// Full-screen Job Detail
// ─────────────────────────────────────────────────────────────
function JobDetail({ jobId, onClose, onReviewed, onDeleted, suggestions = {} }) {
const { models } = useModels()
const [job, setJob] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [describeModel, setDescribeModel] = useState('')
const [generatingDescribe, setGeneratingDescribe] = useState(false)
const [editedText, setEditedText] = useState('')
const [editDescribeText, setEditDescribeText] = useState('')
const [editFreeformText, setEditFreeformText] = useState('')
@@ -71,10 +76,9 @@ function JobDetail({ jobId, onClose, onReviewed, onDeleted, suggestions = {} })
setEditChapter(d.chapter || '')
setEditPage(d.page || '')
setReviewerName(d.reviewer_name || '')
// Default to first tab that has content
// Default to the OCR tab when there's OCR text, otherwise Description
if (d.reviewed_text || d.ocr_text) setActiveTab('ocr')
else if (d.describe_text) setActiveTab('describe')
else if (d.freeform_text) setActiveTab('freeform')
else setActiveTab('describe')
}
})
.catch(err => {
@@ -85,6 +89,32 @@ function JobDetail({ jobId, onClose, onReviewed, onDeleted, suggestions = {} })
return () => { cancelled = true }
}, [jobId])
// Default the Describe model to the job's original model (if available) or the registry default
useEffect(() => {
if (!describeModel && models.length > 0) {
const def = models.find(m => m.default) || models[0]
const fromJob = job?.ocr_model && models.some(m => m.id === job.ocr_model) ? job.ocr_model : null
setDescribeModel(fromJob || def.id)
}
}, [models, job, describeModel])
const handleGenerateDescribe = async () => {
setGeneratingDescribe(true)
setSaveResult(null)
try {
const res = await axios.post(`${API_BASE}/jobs/${jobId}/describe`, {
model: describeModel || null,
})
setJob(res.data)
setEditDescribeText(res.data.describe_text || '')
onReviewed(res.data)
} catch (err) {
setSaveResult({ success: false, error: err.response?.data?.detail || err.message })
} finally {
setGeneratingDescribe(false)
}
}
const handleSave = async () => {
if (!reviewerName.trim()) {
setSaveResult({ success: false, error: 'Reviewer name is required.' })
@@ -114,16 +144,24 @@ function JobDetail({ jobId, onClose, onReviewed, onDeleted, suggestions = {} })
}
const handleToggleStatus = async () => {
const next = isReviewed ? 'unreviewed' : 'reviewed'
if (next === 'reviewed' && !reviewerName.trim()) {
setSaveResult({ success: false, error: 'Reviewer name is required to mark reviewed.' })
// Marking reviewed accepts BOTH the reviewed document text and the description,
// so it goes through the full review save (not a status-only flip).
if (!isReviewed) {
setTogglingStatus(true)
try {
await handleSave()
} finally {
setTogglingStatus(false)
}
return
}
// Reverting to unreviewed preserves the saved reviewed text and description.
setTogglingStatus(true)
setSaveResult(null)
try {
const res = await axios.put(`${API_BASE}/jobs/${jobId}/status`, {
status: next,
status: 'unreviewed',
reviewer_name: reviewerName.trim() || null,
})
setJob(res.data)
@@ -259,8 +297,7 @@ function JobDetail({ jobId, onClose, onReviewed, onDeleted, suggestions = {} })
{(() => {
const tabs = [
job.ocr_text || job.reviewed_text ? { id: 'ocr', label: 'OCR Text' } : null,
job.describe_text != null ? { id: 'describe', label: 'Description' } : null,
job.freeform_text != null ? { id: 'freeform', label: 'Freeform' } : null,
{ id: 'describe', label: 'Description' },
].filter(Boolean)
return tabs.length > 1 ? (
<div className="flex gap-1 mb-3 flex-shrink-0">
@@ -282,7 +319,7 @@ function JobDetail({ jobId, onClose, onReviewed, onDeleted, suggestions = {} })
})()}
<p className="text-xs text-gray-400 mb-2 flex-shrink-0">
{{ ocr: isReviewed ? 'Reviewed Text' : 'OCR Text', describe: 'Description', freeform: 'Freeform' }[activeTab]}
{{ ocr: isReviewed ? 'Reviewed Text' : 'OCR Text', describe: 'Description' }[activeTab]}
<span className="text-purple-400 ml-1">(editable)</span>
</p>
@@ -307,20 +344,43 @@ function JobDetail({ jobId, onClose, onReviewed, onDeleted, suggestions = {} })
</>
)}
{activeTab === 'describe' && (
<textarea
value={editDescribeText}
onChange={e => setEditDescribeText(e.target.value)}
className="flex-1 w-full bg-transparent text-sm text-gray-200 font-mono resize-none focus:outline-none min-h-0"
placeholder="Description text..."
/>
)}
{activeTab === 'freeform' && (
<textarea
value={editFreeformText}
onChange={e => setEditFreeformText(e.target.value)}
className="flex-1 w-full bg-transparent text-sm text-gray-200 font-mono resize-none focus:outline-none min-h-0"
placeholder="Freeform result..."
/>
<>
<div className="flex items-center gap-2 mb-2 flex-shrink-0">
<select
value={describeModel}
onChange={e => setDescribeModel(e.target.value)}
disabled={generatingDescribe || models.length === 0}
className="bg-white/5 border border-white/10 rounded-lg px-2 py-1.5 text-xs text-gray-200 focus:outline-none focus:border-purple-500/50"
>
{models.length === 0 && <option value="">No models</option>}
{models.map(m => (
<option key={m.id} value={m.id}>{m.label}{m.default ? ' (default)' : ''}</option>
))}
</select>
<motion.button
onClick={handleGenerateDescribe}
disabled={generatingDescribe || !describeModel}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
generatingDescribe || !describeModel
? 'opacity-50 cursor-not-allowed bg-white/5'
: 'bg-gradient-to-r from-violet-600 to-purple-600 hover:from-violet-500 hover:to-purple-500'
}`}
whileHover={!generatingDescribe && describeModel ? { scale: 1.02 } : {}}
whileTap={!generatingDescribe && describeModel ? { scale: 0.98 } : {}}
title="Run Describe on this job's image and save it"
>
{generatingDescribe
? <><Loader2 className="w-3.5 h-3.5 animate-spin" /> Generating</>
: <><Sparkles className="w-3.5 h-3.5" /> Generate Description</>}
</motion.button>
</div>
<textarea
value={editDescribeText}
onChange={e => setEditDescribeText(e.target.value)}
className="flex-1 w-full bg-transparent text-sm text-gray-200 font-mono resize-none focus:outline-none min-h-0"
placeholder="No description yet — pick a model and click Generate Description, or type one here."
/>
</>
)}
</div>
</div>
@@ -385,6 +445,12 @@ function JobDetail({ jobId, onClose, onReviewed, onDeleted, suggestions = {} })
</div>
</div>
{!isReviewed && (
<p className="text-xs text-gray-500 mt-2">
Marking reviewed accepts both the reviewed document text and the description.
</p>
)}
{saveResult && (
<motion.div
initial={{ opacity: 0, y: -4 }} animate={{ opacity: 1, y: 0 }}
@@ -405,6 +471,7 @@ function JobDetail({ jobId, onClose, onReviewed, onDeleted, suggestions = {} })
<span className="text-xs text-gray-500">Last reviewed: {new Date(job.reviewed_at).toLocaleString()}</span>
)}
{job.mode && <span className="text-xs text-gray-500">Mode: {job.mode}</span>}
{job.ocr_model && <span className="text-xs text-gray-500">Model: {job.ocr_model}</span>}
</div>
</div>
</>
@@ -573,7 +640,10 @@ export default function JobsPanel() {
{job.page && <span className="text-xs text-gray-500">p. {job.page}</span>}
</div>
{job.author && <p className="text-xs text-gray-400 mt-1">{job.author}</p>}
<p className="text-xs text-gray-600 mt-2 font-mono">{new Date(job.submitted_at).toLocaleDateString()}</p>
<div className="flex items-center justify-between mt-2">
<p className="text-xs text-gray-600 font-mono">{new Date(job.submitted_at).toLocaleDateString()}</p>
{job.ocr_model && <span className="text-[10px] text-gray-500 truncate ml-2">{job.ocr_model}</span>}
</div>
</motion.button>
))}
</AnimatePresence>

View File

@@ -1,41 +1,30 @@
import { motion } from 'framer-motion'
import { FileText, Eye, Search, Wand2 } from 'lucide-react'
import { FileText, Eye } from 'lucide-react'
const modes = [
{ id: 'plain_ocr', name: 'Plain OCR', icon: FileText, color: 'from-blue-500 to-cyan-500', desc: 'Extract raw text', needsInput: false },
{ id: 'describe', name: 'Describe', icon: Eye, color: 'from-violet-500 to-purple-500', desc: 'Image description', needsInput: false },
{ id: 'find_ref', name: 'Find', icon: Search, color: 'from-yellow-500 to-orange-500', desc: 'Locate specific terms', needsInput: 'findTerm' },
{ id: 'freeform', name: 'Freeform', icon: Wand2, color: 'from-fuchsia-500 to-pink-500', desc: 'Custom prompt', needsInput: 'prompt' },
{ id: 'plain_ocr', name: 'Plain OCR', icon: FileText, color: 'from-blue-500 to-cyan-500', desc: 'Extract raw text' },
{ id: 'describe', name: 'Describe', icon: Eye, color: 'from-violet-500 to-purple-500', desc: 'Image description' },
]
export default function ModeSelector({
mode,
onModeChange,
prompt,
onPromptChange,
findTerm,
onFindTermChange
}) {
const selectedMode = modes.find(m => m.id === mode)
const needsInput = selectedMode?.needsInput
export default function ModeSelector({ mode, onModeChange }) {
return (
<div className="glass p-4 rounded-2xl space-y-3">
<h3 className="text-sm font-semibold text-gray-200">Mode</h3>
<div className="grid grid-cols-4 gap-2">
<div className="grid grid-cols-2 gap-2">
{modes.map((m) => {
const Icon = m.icon
const isSelected = mode === m.id
return (
<motion.button
key={m.id}
onClick={() => onModeChange(m.id)}
title={m.desc}
className={`
relative p-2 rounded-xl text-center transition-all
${isSelected
? 'glass border-white/20 shadow-lg'
${isSelected
? 'glass border-white/20 shadow-lg'
: 'bg-white/5 border border-white/10 hover:border-white/20'
}
`}
@@ -49,12 +38,12 @@ export default function ModeSelector({
transition={{ type: "spring", bounce: 0.2, duration: 0.6 }}
/>
)}
<div className="relative space-y-1">
<div className={`
w-8 h-8 mx-auto rounded-lg flex items-center justify-center
${isSelected
? `bg-gradient-to-br ${m.color}`
${isSelected
? `bg-gradient-to-br ${m.color}`
: 'bg-white/10'
}
`}>
@@ -68,38 +57,6 @@ export default function ModeSelector({
)
})}
</div>
{needsInput === 'findTerm' && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
>
<input
type="text"
value={findTerm}
onChange={(e) => onFindTermChange(e.target.value)}
placeholder="Enter term to find (e.g., Total, Invoice #)"
className="w-full bg-white/5 border border-white/10 rounded-xl px-3 py-2 text-sm focus:outline-none focus:border-purple-500 transition-colors"
/>
</motion.div>
)}
{needsInput === 'prompt' && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
>
<textarea
value={prompt}
onChange={(e) => onPromptChange(e.target.value)}
placeholder="Enter your custom prompt..."
className="w-full bg-white/5 border border-white/10 rounded-xl px-3 py-2 text-sm focus:outline-none focus:border-purple-500 transition-colors resize-none"
rows={2}
/>
</motion.div>
)}
</div>
)
}

View File

@@ -5,7 +5,7 @@ import axios from 'axios'
const API_BASE = import.meta.env.VITE_API_URL || '/api'
function PDFProcessor({ pdfFile, mode, prompt, advancedSettings, includeCaption }) {
function PDFProcessor({ pdfFile, mode, prompt, model, advancedSettings, includeCaption }) {
const [processing, setProcessing] = useState(false)
const [progress, setProgress] = useState(0)
const [result, setResult] = useState(null)
@@ -29,6 +29,7 @@ function PDFProcessor({ pdfFile, mode, prompt, advancedSettings, includeCaption
try {
const formData = new FormData()
formData.append('pdf_file', pdfFile)
if (model) formData.append('model', model)
formData.append('mode', mode)
formData.append('prompt', prompt)
formData.append('output_format', outputFormat)
@@ -80,7 +81,7 @@ function PDFProcessor({ pdfFile, mode, prompt, advancedSettings, includeCaption
} finally {
setProcessing(false)
}
}, [pdfFile, mode, prompt, outputFormat, includeCaption, advancedSettings])
}, [pdfFile, mode, prompt, model, outputFormat, includeCaption, advancedSettings])
const handleDownloadJSON = useCallback(() => {
if (!result || outputFormat !== 'json') return