diff --git a/src/app/api/ai-tagging/route.ts b/src/app/api/ai-tagging/route.ts new file mode 100644 index 0000000..f248a14 --- /dev/null +++ b/src/app/api/ai-tagging/route.ts @@ -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 }) + } +} diff --git a/src/app/manage/ai-tagging/page.tsx b/src/app/manage/ai-tagging/page.tsx index 0253d2f..b8bf1b0 100644 --- a/src/app/manage/ai-tagging/page.tsx +++ b/src/app/manage/ai-tagging/page.tsx @@ -174,7 +174,7 @@ export default function AiTaggingPage() {

- 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.

diff --git a/src/components/mixed/ImageLightbox.tsx b/src/components/mixed/ImageLightbox.tsx index ebbc7f6..0bd41fc 100644 --- a/src/components/mixed/ImageLightbox.tsx +++ b/src/components/mixed/ImageLightbox.tsx @@ -11,13 +11,16 @@ interface Props { onNext?: () => void itemKey?: string onTagsChanged?: () => void + onAiTag?: () => Promise } -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(null) const [showTags, setShowTags] = useState( () => !!itemKey && typeof window !== 'undefined' && window.innerWidth >= 1280 ) + const [aiTagging, setAiTagging] = useState(false) + const [aiTagError, setAiTagError] = useState(null) useEffect(() => { const handleKey = (e: KeyboardEvent) => { @@ -71,6 +74,43 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item 🏷 )} + {onAiTag && ( + + )} {/* Kebab menu — top-right, shown on hover */} - {(onDelete || onRename) && ( + {(onDelete || onRename || (onAiTag && entry.mediaType === 'image')) && (
+ )} {onRename && (
)} + {/* AI tagging status overlay */} + {(aiTagging || aiTagError) && ( +
e.stopPropagation()} + > + + {aiTagError ?? 'AI Tagging…'} + + {aiTagError && ( + + )} +
+ )} + {/* Delete confirmation overlay */} {confirming && (
{ + 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 +}