ai starter implementation
This commit is contained in:
39
src/app/api/ai-tagging/route.ts
Normal file
39
src/app/api/ai-tagging/route.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { requireLibraryAccess } from '@/lib/auth'
|
||||||
|
import { tagSingleItem } from '@/lib/ai-tagger'
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
let body: { itemKey?: string }
|
||||||
|
try {
|
||||||
|
body = await request.json()
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { itemKey } = body
|
||||||
|
if (!itemKey || typeof itemKey !== 'string') {
|
||||||
|
return NextResponse.json({ error: 'itemKey is required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const libraryId = itemKey.split(':')[0]
|
||||||
|
const auth = await requireLibraryAccess(request, libraryId)
|
||||||
|
if (auth instanceof NextResponse) return auth
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tagIds = await tagSingleItem(itemKey)
|
||||||
|
return NextResponse.json({ tagIds })
|
||||||
|
} catch (err) {
|
||||||
|
const error = err as Error & { code?: string }
|
||||||
|
if (error.code === 'NOT_CONFIGURED') {
|
||||||
|
return NextResponse.json({ error: error.message }, { status: 400 })
|
||||||
|
}
|
||||||
|
if (error.code === 'NOT_FOUND') {
|
||||||
|
return NextResponse.json({ error: error.message }, { status: 404 })
|
||||||
|
}
|
||||||
|
if (error.code === 'NO_IMAGE') {
|
||||||
|
return NextResponse.json({ error: error.message }, { status: 404 })
|
||||||
|
}
|
||||||
|
console.error('[ai-tagging] Error tagging item:', error)
|
||||||
|
return NextResponse.json({ error: 'AI tagging failed' }, { status: 502 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -174,7 +174,7 @@ export default function AiTaggingPage() {
|
|||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<p className="mt-1 text-xs" style={{ color: 'var(--text-secondary)' }}>
|
<p className="mt-1 text-xs" style={{ color: 'var(--text-secondary)' }}>
|
||||||
When enabled, new media will be automatically tagged during library scans.
|
When enabled, new media will be automatically tagged during library scans. On-demand tagging from image cards is always available when an endpoint is configured.
|
||||||
</p>
|
</p>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
|
|||||||
@@ -11,13 +11,16 @@ interface Props {
|
|||||||
onNext?: () => void
|
onNext?: () => void
|
||||||
itemKey?: string
|
itemKey?: string
|
||||||
onTagsChanged?: () => void
|
onTagsChanged?: () => void
|
||||||
|
onAiTag?: () => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ImageLightbox({ url, name, onClose, onPrev, onNext, itemKey, onTagsChanged }: Props) {
|
export default function ImageLightbox({ url, name, onClose, onPrev, onNext, itemKey, onTagsChanged, onAiTag }: Props) {
|
||||||
const overlayRef = useRef<HTMLDivElement>(null)
|
const overlayRef = useRef<HTMLDivElement>(null)
|
||||||
const [showTags, setShowTags] = useState(
|
const [showTags, setShowTags] = useState(
|
||||||
() => !!itemKey && typeof window !== 'undefined' && window.innerWidth >= 1280
|
() => !!itemKey && typeof window !== 'undefined' && window.innerWidth >= 1280
|
||||||
)
|
)
|
||||||
|
const [aiTagging, setAiTagging] = useState(false)
|
||||||
|
const [aiTagError, setAiTagError] = useState<string | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKey = (e: KeyboardEvent) => {
|
const handleKey = (e: KeyboardEvent) => {
|
||||||
@@ -71,6 +74,43 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
|
|||||||
🏷
|
🏷
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{onAiTag && (
|
||||||
|
<button
|
||||||
|
onClick={async (e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setAiTagging(true)
|
||||||
|
setAiTagError(null)
|
||||||
|
try {
|
||||||
|
await onAiTag()
|
||||||
|
onTagsChanged?.()
|
||||||
|
} catch (err) {
|
||||||
|
setAiTagError(err instanceof Error ? err.message : 'AI tagging failed')
|
||||||
|
setTimeout(() => setAiTagError(null), 4000)
|
||||||
|
} finally {
|
||||||
|
setAiTagging(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={aiTagging}
|
||||||
|
className="w-12 h-12 rounded-full flex items-center justify-center text-sm transition-colors disabled:opacity-50"
|
||||||
|
style={{
|
||||||
|
backgroundColor: aiTagError ? '#7f1d1d' : 'var(--surface)',
|
||||||
|
color: aiTagError ? '#fca5a5' : 'var(--text-primary)',
|
||||||
|
fontSize: '1.5rem',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (!aiTagging && !aiTagError) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)'
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (!aiTagError) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)'
|
||||||
|
}}
|
||||||
|
aria-label="AI Tag this image"
|
||||||
|
title={aiTagError ?? (aiTagging ? 'Tagging…' : 'AI Tag')}
|
||||||
|
>
|
||||||
|
{aiTagging ? (
|
||||||
|
<span className="animate-spin" style={{ display: 'inline-block', fontSize: '1.2rem' }}>⟳</span>
|
||||||
|
) : '✨'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="w-12 h-12 rounded-full flex items-center justify-center text-sm transition-colors"
|
className="w-12 h-12 rounded-full flex items-center justify-center text-sm transition-colors"
|
||||||
|
|||||||
@@ -321,6 +321,20 @@ export default function MixedView({ libraryId, initialPath }: Props) {
|
|||||||
entry={entry}
|
entry={entry}
|
||||||
onOpen={handleEntry}
|
onOpen={handleEntry}
|
||||||
onTag={handleTagEntry}
|
onTag={handleTagEntry}
|
||||||
|
onAiTag={async (e) => {
|
||||||
|
const itemKey = itemKeyFor(e)
|
||||||
|
const res = await fetch('/api/ai-tagging', {
|
||||||
|
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 ?? 'AI tagging failed')
|
||||||
|
}
|
||||||
|
fetchAssignments()
|
||||||
|
setFilterRefreshKey((k) => k + 1)
|
||||||
|
}}
|
||||||
onDelete={(e) => {
|
onDelete={(e) => {
|
||||||
const rel = filtersActive ? e.name : (currentPath ? `${currentPath}/${e.name}` : e.name)
|
const rel = filtersActive ? e.name : (currentPath ? `${currentPath}/${e.name}` : e.name)
|
||||||
fetch(`/api/browse?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(rel)}`, { method: 'DELETE' })
|
fetch(`/api/browse?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(rel)}`, { method: 'DELETE' })
|
||||||
@@ -375,6 +389,19 @@ export default function MixedView({ libraryId, initialPath }: Props) {
|
|||||||
onClose={() => setModal(null)}
|
onClose={() => setModal(null)}
|
||||||
onPrev={modal.mediaIndex > 0 ? () => navigateModal(-1) : undefined}
|
onPrev={modal.mediaIndex > 0 ? () => navigateModal(-1) : undefined}
|
||||||
onNext={modal.mediaIndex < mediaEntries.length - 1 ? () => navigateModal(1) : undefined}
|
onNext={modal.mediaIndex < mediaEntries.length - 1 ? () => navigateModal(1) : undefined}
|
||||||
|
onAiTag={async () => {
|
||||||
|
const res = await fetch('/api/ai-tagging', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ itemKey: modal.itemKey }),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}))
|
||||||
|
throw new Error((data as { error?: string }).error ?? 'AI tagging failed')
|
||||||
|
}
|
||||||
|
fetchAssignments()
|
||||||
|
setFilterRefreshKey((k) => k + 1)
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -424,7 +451,7 @@ export default function MixedView({ libraryId, initialPath }: Props) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function EntryTile({ entry, onOpen, onTag, onDelete, onRename }: { entry: FileEntry; onOpen: (e: FileEntry) => void; onTag: (e: FileEntry) => void; onDelete?: (e: FileEntry) => void; onRename?: (e: FileEntry, newName: string) => Promise<boolean> }) {
|
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> }) {
|
||||||
type ImgState = 'loading' | 'loaded' | 'error'
|
type ImgState = 'loading' | 'loaded' | 'error'
|
||||||
const [imgState, setImgState] = useState<ImgState>(
|
const [imgState, setImgState] = useState<ImgState>(
|
||||||
entry.thumbnailUrl ? 'loading' : 'error'
|
entry.thumbnailUrl ? 'loading' : 'error'
|
||||||
@@ -437,6 +464,8 @@ function EntryTile({ entry, onOpen, onTag, onDelete, onRename }: { entry: FileEn
|
|||||||
const [entryRenameName, setEntryRenameName] = useState('')
|
const [entryRenameName, setEntryRenameName] = useState('')
|
||||||
const [entryRenameError, setEntryRenameError] = useState<string | null>(null)
|
const [entryRenameError, setEntryRenameError] = useState<string | null>(null)
|
||||||
const [entryRenameSaving, setEntryRenameSaving] = useState(false)
|
const [entryRenameSaving, setEntryRenameSaving] = useState(false)
|
||||||
|
const [aiTagging, setAiTagging] = useState(false)
|
||||||
|
const [aiTagError, setAiTagError] = useState<string | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!menuOpen) return
|
if (!menuOpen) return
|
||||||
@@ -548,10 +577,10 @@ function EntryTile({ entry, onOpen, onTag, onDelete, onRename }: { entry: FileEn
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Kebab menu — top-right, shown on hover */}
|
{/* Kebab menu — top-right, shown on hover */}
|
||||||
{(onDelete || onRename) && (
|
{(onDelete || onRename || (onAiTag && entry.mediaType === 'image')) && (
|
||||||
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity hidden group-hover:block" ref={menuRef}>
|
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity hidden group-hover:block" ref={menuRef}>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); setMenuOpen((o) => !o); setConfirming(false) }}
|
onClick={(e) => { e.stopPropagation(); setMenuOpen((o) => !o); setConfirming(false); setAiTagError(null) }}
|
||||||
className="w-6 h-6 rounded-full flex items-center justify-center text-xs"
|
className="w-6 h-6 rounded-full flex items-center justify-center text-xs"
|
||||||
style={{ backgroundColor: 'rgba(0,0,0,0.55)', color: '#fff' }}
|
style={{ backgroundColor: 'rgba(0,0,0,0.55)', color: '#fff' }}
|
||||||
aria-label="More options"
|
aria-label="More options"
|
||||||
@@ -563,6 +592,26 @@ function EntryTile({ entry, onOpen, onTag, onDelete, onRename }: { entry: FileEn
|
|||||||
className="absolute right-0 top-full mt-1 rounded-lg shadow-lg overflow-hidden z-20 min-w-max"
|
className="absolute right-0 top-full mt-1 rounded-lg shadow-lg overflow-hidden z-20 min-w-max"
|
||||||
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
|
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
|
||||||
>
|
>
|
||||||
|
{onAiTag && entry.mediaType === 'image' && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setMenuOpen(false)
|
||||||
|
setAiTagging(true)
|
||||||
|
setAiTagError(null)
|
||||||
|
onAiTag(entry)
|
||||||
|
.catch((err) => setAiTagError(err instanceof Error ? err.message : 'AI tagging failed'))
|
||||||
|
.finally(() => setAiTagging(false))
|
||||||
|
}}
|
||||||
|
disabled={aiTagging}
|
||||||
|
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')}
|
||||||
|
>
|
||||||
|
✨ AI Tag
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{onRename && (
|
{onRename && (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@@ -596,6 +645,28 @@ function EntryTile({ entry, onOpen, onTag, onDelete, onRename }: { entry: FileEn
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* AI tagging status overlay */}
|
||||||
|
{(aiTagging || aiTagError) && (
|
||||||
|
<div
|
||||||
|
className="absolute inset-x-0 bottom-0 z-10 px-2 py-1.5 text-xs"
|
||||||
|
style={{ backgroundColor: aiTagError ? 'rgba(127,29,29,0.9)' : 'rgba(0,0,0,0.75)' }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<span style={{ color: aiTagError ? '#fca5a5' : 'var(--text-secondary)' }}>
|
||||||
|
{aiTagError ?? 'AI Tagging…'}
|
||||||
|
</span>
|
||||||
|
{aiTagError && (
|
||||||
|
<button
|
||||||
|
onClick={() => setAiTagError(null)}
|
||||||
|
className="ml-2 underline text-xs"
|
||||||
|
style={{ color: '#fca5a5' }}
|
||||||
|
>
|
||||||
|
dismiss
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Delete confirmation overlay */}
|
{/* Delete confirmation overlay */}
|
||||||
{confirming && (
|
{confirming && (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { getAiConfig } from './app-settings'
|
|||||||
import { getTags, getCategories, addTagToItem } from './tags'
|
import { getTags, getCategories, addTagToItem } from './tags'
|
||||||
import { getThumbnailPath } from './thumbnails'
|
import { getThumbnailPath } from './thumbnails'
|
||||||
import { findFile } from './media-utils'
|
import { findFile } from './media-utils'
|
||||||
|
import { getLibrary, resolveLibraryRoot } from './libraries'
|
||||||
|
|
||||||
const BATCH_LIMIT = 50
|
const BATCH_LIMIT = 50
|
||||||
const REQUEST_TIMEOUT_MS = 30_000
|
const REQUEST_TIMEOUT_MS = 30_000
|
||||||
@@ -250,3 +251,59 @@ export async function runAiTagging(library: Library, libraryRoot: string): Promi
|
|||||||
console.log(`[ai-tagger] Tagged ${tagged}/${untaggedItems.length} items in library "${library.name}"`)
|
console.log(`[ai-tagger] Tagged ${tagged}/${untaggedItems.length} items in library "${library.name}"`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tag a single item on-demand by itemKey.
|
||||||
|
* Bypasses the ai_tagged_at check and batch limit — user explicitly requested this.
|
||||||
|
* Throws descriptive errors so the API route can return appropriate status codes.
|
||||||
|
*/
|
||||||
|
export async function tagSingleItem(itemKey: string): Promise<string[]> {
|
||||||
|
const config = getAiConfig()
|
||||||
|
if (!config.endpoint || !config.model) {
|
||||||
|
throw Object.assign(new Error('AI tagging endpoint and model are not configured'), { code: 'NOT_CONFIGURED' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const tags = getTags()
|
||||||
|
const categories = getCategories()
|
||||||
|
if (tags.length === 0) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const validTagIds = new Set(tags.map((t) => t.id))
|
||||||
|
const systemPrompt = buildTagPrompt(tags, categories)
|
||||||
|
|
||||||
|
const db = getDb()
|
||||||
|
const item = db
|
||||||
|
.prepare('SELECT item_key, item_type, file_path, metadata FROM media_items WHERE item_key = ?')
|
||||||
|
.get(itemKey) as MediaItemRow | undefined
|
||||||
|
|
||||||
|
if (!item) {
|
||||||
|
throw Object.assign(new Error(`Item not found: ${itemKey}`), { code: 'NOT_FOUND' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const libraryId = itemKey.split(':')[0]
|
||||||
|
const library = getLibrary(libraryId)
|
||||||
|
if (!library) {
|
||||||
|
throw Object.assign(new Error(`Library not found: ${libraryId}`), { code: 'NOT_FOUND' })
|
||||||
|
}
|
||||||
|
const libraryRoot = resolveLibraryRoot(library)
|
||||||
|
|
||||||
|
const imagePath = resolveItemImage(libraryRoot, item)
|
||||||
|
if (!imagePath) {
|
||||||
|
throw Object.assign(new Error('No image available for this item'), { code: 'NO_IMAGE' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const thumbnailPath = await getThumbnailPath(imagePath, libraryId, 'image')
|
||||||
|
const base64 = fs.readFileSync(thumbnailPath, 'base64')
|
||||||
|
|
||||||
|
const suggestedIds = await callVisionApi(config.endpoint, config.model, base64, systemPrompt)
|
||||||
|
const validIds = suggestedIds.filter((id) => validTagIds.has(id))
|
||||||
|
|
||||||
|
for (const tagId of validIds) {
|
||||||
|
addTagToItem(itemKey, tagId)
|
||||||
|
}
|
||||||
|
|
||||||
|
db.prepare('UPDATE media_items SET ai_tagged_at = ? WHERE item_key = ?').run(Date.now(), itemKey)
|
||||||
|
|
||||||
|
return validIds
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user