add ai descriptions and extracted text

This commit is contained in:
Garret Patti
2026-04-12 18:18:59 -04:00
parent 60790a3af1
commit 7e284383b4
13 changed files with 879 additions and 11 deletions

View File

@@ -23,6 +23,28 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
const [aiTagError, setAiTagError] = useState<string | null>(null)
const [tagRefreshKey, setTagRefreshKey] = useState(0)
// Text extraction state
const [extractedText, setExtractedText] = useState<string | null>(null)
const [translatedText, setTranslatedText] = useState<string | null>(null)
const [extracting, setExtracting] = useState(false)
const [extractError, setExtractError] = useState<string | null>(null)
const [retranslating, setRetranslating] = useState(false)
// Determine if this is an image file (for text extraction controls)
const isImage = /\.(jpe?g|png|gif|webp|bmp|tiff?)$/i.test(name)
// Fetch existing AI fields on mount / item change
useEffect(() => {
if (!itemKey) return
fetch(`/api/ai-tagging/fields?itemKey=${encodeURIComponent(itemKey)}`)
.then((r) => r.json())
.then((data: { extractedText: string | null; extractedTextTranslated: string | null }) => {
setExtractedText(data.extractedText)
setTranslatedText(data.extractedTextTranslated)
})
.catch(() => {})
}, [itemKey])
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
@@ -168,6 +190,128 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
Tags
</p>
<TagSelector itemKey={itemKey!} onTagsChanged={onTagsChanged} refreshKey={tagRefreshKey} />
{/* Text extraction section — only for images */}
{isImage && (
<div className="mt-4 pt-4" style={{ borderTop: '1px solid var(--border)' }}>
<p className="text-xs font-semibold uppercase tracking-wider mb-3" style={{ color: 'var(--text-secondary)' }}>
Text Extraction
</p>
<button
onClick={async () => {
setExtracting(true)
setExtractError(null)
try {
const res = await fetch('/api/ai-tagging/extract-text', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ itemKey }),
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error((data as { error?: string }).error ?? 'Failed to extract text')
}
const result = await res.json()
setExtractedText(result.extractedText || null)
setTranslatedText(result.translatedText || null)
} catch (err) {
setExtractError(err instanceof Error ? err.message : 'Failed to extract text')
setTimeout(() => setExtractError(null), 4000)
} finally {
setExtracting(false)
}
}}
disabled={extracting}
className="text-xs px-2 py-1 rounded-lg transition-colors disabled:opacity-50 mb-2"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
onMouseEnter={(e) => {
if (!extracting) {
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'
;(e.currentTarget as HTMLElement).style.color = 'var(--background)'
}
}}
onMouseLeave={(e) => {
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
}}
>
{extracting ? '⟳ Extracting…' : extractedText ? '🔍 Re-extract Text' : '🔍 Extract Text'}
</button>
{extractError && (
<p className="text-xs mb-2" style={{ color: '#f87171' }}>{extractError}</p>
)}
{extractedText && (
<div className="flex flex-col gap-2">
<div>
<p className="text-xs font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>
Extracted Text
</p>
<pre
className="text-xs whitespace-pre-wrap rounded-lg p-2 max-h-40 overflow-y-auto"
style={{ backgroundColor: 'var(--background)', color: 'var(--text-primary)', border: '1px solid var(--border)' }}
>
{extractedText}
</pre>
</div>
{translatedText && (
<div>
<p className="text-xs font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>
Translation
</p>
<pre
className="text-xs whitespace-pre-wrap rounded-lg p-2 max-h-40 overflow-y-auto"
style={{ backgroundColor: 'var(--background)', color: 'var(--text-primary)', border: '1px solid var(--border)' }}
>
{translatedText}
</pre>
</div>
)}
<button
onClick={async () => {
setRetranslating(true)
try {
const res = await fetch('/api/ai-tagging/translate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ itemKey }),
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error((data as { error?: string }).error ?? 'Failed to translate')
}
const result = await res.json()
setTranslatedText(result.translatedText || null)
} catch {
// ignore
} finally {
setRetranslating(false)
}
}}
disabled={retranslating}
className="self-start text-xs px-2 py-0.5 rounded-full transition-colors disabled:opacity-50"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
onMouseEnter={(e) => {
if (!retranslating) {
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'
;(e.currentTarget as HTMLElement).style.color = 'var(--background)'
}
}}
onMouseLeave={(e) => {
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
}}
>
{retranslating ? '⟳ Translating…' : '🌐 Re-translate'}
</button>
</div>
)}
</div>
)}
</div>
</div>
) : (

View File

@@ -335,6 +335,33 @@ export default function MixedView({ libraryId, initialPath }: Props) {
fetchAssignments()
setFilterRefreshKey((k) => k + 1)
}}
onExtractText={async (e) => {
if (e.type === 'directory') {
// Bulk extract for directory
const dirRel = filtersActive ? e.name : (currentPath ? `${currentPath}/${e.name}` : e.name)
const res = await fetch('/api/ai-tagging/extract-text-bulk', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ libraryId, path: dirRel }),
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error((data as { error?: string }).error ?? 'Text extraction failed')
}
} else {
// Single image extract
const itemKey = itemKeyFor(e)
const res = await fetch('/api/ai-tagging/extract-text', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ itemKey }),
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error((data as { error?: string }).error ?? 'Text extraction failed')
}
}
}}
onDelete={(e) => {
const rel = filtersActive ? e.name : (currentPath ? `${currentPath}/${e.name}` : e.name)
fetch(`/api/browse?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(rel)}`, { method: 'DELETE' })
@@ -464,7 +491,7 @@ export default function MixedView({ libraryId, initialPath }: Props) {
)
}
function EntryTile({ entry, onOpen, onTag, onDelete, onRename, onAiTag }: { entry: FileEntry; onOpen: (e: FileEntry) => void; onTag: (e: FileEntry) => void; onDelete?: (e: FileEntry) => void; onRename?: (e: FileEntry, newName: string) => Promise<boolean>; onAiTag?: (e: FileEntry) => Promise<void> }) {
function EntryTile({ entry, onOpen, onTag, onDelete, onRename, onAiTag, onExtractText }: { entry: FileEntry; onOpen: (e: FileEntry) => void; onTag: (e: FileEntry) => void; onDelete?: (e: FileEntry) => void; onRename?: (e: FileEntry, newName: string) => Promise<boolean>; onAiTag?: (e: FileEntry) => Promise<void>; onExtractText?: (e: FileEntry) => Promise<void> }) {
type ImgState = 'loading' | 'loaded' | 'error'
const [imgState, setImgState] = useState<ImgState>(
entry.thumbnailUrl ? 'loading' : 'error'
@@ -479,6 +506,8 @@ function EntryTile({ entry, onOpen, onTag, onDelete, onRename, onAiTag }: { entr
const [entryRenameSaving, setEntryRenameSaving] = useState(false)
const [aiTagging, setAiTagging] = useState(false)
const [aiTagError, setAiTagError] = useState<string | null>(null)
const [textExtracting, setTextExtracting] = useState(false)
const [textExtractError, setTextExtractError] = useState<string | null>(null)
useEffect(() => {
if (!menuOpen) return
@@ -590,7 +619,7 @@ function EntryTile({ entry, onOpen, onTag, onDelete, onRename, onAiTag }: { entr
</button>
{/* Kebab menu — top-right, shown on hover */}
{(onDelete || onRename || (onAiTag && entry.mediaType === 'image')) && (
{(onDelete || onRename || (onAiTag && entry.mediaType === 'image') || (onExtractText && entry.mediaType === 'image') || (onExtractText && entry.type === 'directory')) && (
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity hidden group-hover:block" ref={menuRef}>
<button
onClick={(e) => { e.stopPropagation(); setMenuOpen((o) => !o); setConfirming(false); setAiTagError(null) }}
@@ -625,6 +654,46 @@ function EntryTile({ entry, onOpen, onTag, onDelete, onRename, onAiTag }: { entr
AI Tag
</button>
)}
{onExtractText && entry.mediaType === 'image' && (
<button
onClick={(e) => {
e.stopPropagation()
setMenuOpen(false)
setTextExtracting(true)
setTextExtractError(null)
onExtractText(entry)
.catch((err) => setTextExtractError(err instanceof Error ? err.message : 'Text extraction failed'))
.finally(() => setTextExtracting(false))
}}
disabled={textExtracting}
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors disabled:opacity-50"
style={{ color: 'var(--text-primary)' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
>
🔍 Extract Text
</button>
)}
{onExtractText && entry.type === 'directory' && (
<button
onClick={(e) => {
e.stopPropagation()
setMenuOpen(false)
setTextExtracting(true)
setTextExtractError(null)
onExtractText(entry)
.catch((err) => setTextExtractError(err instanceof Error ? err.message : 'Text extraction failed'))
.finally(() => setTextExtracting(false))
}}
disabled={textExtracting}
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors disabled:opacity-50"
style={{ color: 'var(--text-primary)' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
>
🔍 Extract Text for Folder
</button>
)}
{onRename && (
<button
onClick={(e) => {
@@ -680,6 +749,28 @@ function EntryTile({ entry, onOpen, onTag, onDelete, onRename, onAiTag }: { entr
</div>
)}
{/* Text extraction status overlay */}
{(textExtracting || textExtractError) && (
<div
className="absolute inset-x-0 bottom-0 z-10 px-2 py-1.5 text-xs"
style={{ backgroundColor: textExtractError ? 'rgba(127,29,29,0.9)' : 'rgba(0,0,0,0.75)' }}
onClick={(e) => e.stopPropagation()}
>
<span style={{ color: textExtractError ? '#fca5a5' : 'var(--text-secondary)' }}>
{textExtractError ?? 'Extracting text…'}
</span>
{textExtractError && (
<button
onClick={() => setTextExtractError(null)}
className="ml-2 underline text-xs"
style={{ color: '#fca5a5' }}
>
dismiss
</button>
)}
</div>
)}
{/* Delete confirmation overlay */}
{confirming && (
<div

View File

@@ -24,6 +24,11 @@ export default function TagSelector({ itemKey, onTagsChanged, refreshKey }: Prop
const [loading, setLoading] = useState(true)
const [busy, setBusy] = useState<string | null>(null)
// AI description state
const [aiDescription, setAiDescription] = useState<string | null>(null)
const [generatingDesc, setGeneratingDesc] = useState(false)
const [descError, setDescError] = useState<string | null>(null)
// Per-category search text
const [categorySearches, setCategorySearches] = useState<Record<string, string>>({})
@@ -54,10 +59,19 @@ export default function TagSelector({ itemKey, onTagsChanged, refreshKey }: Prop
})
}, [])
const fetchAiFields = useCallback(() => {
return fetch(`/api/ai-tagging/fields?itemKey=${encodeURIComponent(itemKey)}`)
.then((r) => r.json())
.then((data: { aiDescription: string | null }) => {
setAiDescription(data.aiDescription)
})
.catch(() => {})
}, [itemKey])
useEffect(() => {
setLoading(true)
Promise.all([fetchAssigned(), fetchAll()]).finally(() => setLoading(false))
}, [fetchAssigned, fetchAll])
Promise.all([fetchAssigned(), fetchAll(), fetchAiFields()]).finally(() => setLoading(false))
}, [fetchAssigned, fetchAll, fetchAiFields])
useEffect(() => {
if (refreshKey !== undefined && refreshKey > 0) {
@@ -165,8 +179,63 @@ export default function TagSelector({ itemKey, onTagsChanged, refreshKey }: Prop
const assignedCategoryMap = Object.fromEntries(assigned.categories.map((c) => [c.id, c]))
const handleGenerateDescription = async () => {
setGeneratingDesc(true)
setDescError(null)
try {
const res = await fetch('/api/ai-tagging/describe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ itemKey }),
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error((data as { error?: string }).error ?? 'Failed to generate description')
}
const { description } = await res.json()
setAiDescription(description)
} catch (err) {
setDescError(err instanceof Error ? err.message : 'Failed to generate description')
setTimeout(() => setDescError(null), 4000)
} finally {
setGeneratingDesc(false)
}
}
return (
<div className="flex flex-col gap-3">
{/* AI description */}
<div className="flex flex-col gap-1">
{aiDescription && (
<p className="text-xs italic" style={{ color: 'var(--text-secondary)' }}>
{aiDescription}
</p>
)}
<div className="flex items-center gap-1.5">
<button
onClick={handleGenerateDescription}
disabled={generatingDesc}
className="text-xs px-2 py-0.5 rounded-full transition-colors disabled:opacity-50"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
onMouseEnter={(e) => {
if (!generatingDesc) {
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'
;(e.currentTarget as HTMLElement).style.color = 'var(--background)'
}
}}
onMouseLeave={(e) => {
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
}}
title={aiDescription ? 'Regenerate AI description' : 'Generate AI description'}
>
{generatingDesc ? '⟳ Generating…' : aiDescription ? '✦ Regenerate Description' : '✦ Generate Description'}
</button>
{descError && (
<span className="text-xs" style={{ color: '#f87171' }}>{descError}</span>
)}
</div>
</div>
{/* Assigned tags grouped by category */}
{assigned.tags.length > 0 && (
<div className="flex flex-wrap gap-1.5">