Remove Freeform and Find from UI. Allow Description to be added to Reviewed job
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user