Add AI-powered image tagging via local LLM
Adds automatic image tagging that runs as a post-scan phase, sending thumbnails to an OpenAI-compatible vision API and applying matching tags from the user-defined tag vocabulary. - New ai-tagger module with batch processing, failure tolerance, and tag validation against existing vocabulary - Admin settings page (Manage > AI Tagging) for endpoint, model, and enable toggle with connection testing - DB migration for ai_tagged_at tracking column and AI config seeds - Re-tag All support to queue items for reprocessing Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
13
src/app/api/ai-settings/retag/route.ts
Normal file
13
src/app/api/ai-settings/retag/route.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { requireAdmin } from '@/lib/auth'
|
||||
import { getDb } from '@/lib/db'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const auth = await requireAdmin(request)
|
||||
if (auth instanceof NextResponse) return auth
|
||||
|
||||
const db = getDb()
|
||||
const result = db.prepare('UPDATE media_items SET ai_tagged_at = NULL').run()
|
||||
|
||||
return NextResponse.json({ cleared: result.changes })
|
||||
}
|
||||
38
src/app/api/ai-settings/route.ts
Normal file
38
src/app/api/ai-settings/route.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { requireAdmin } from '@/lib/auth'
|
||||
import { getAiConfig, updateAiConfig } from '@/lib/app-settings'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const auth = await requireAdmin(request)
|
||||
if (auth instanceof NextResponse) return auth
|
||||
|
||||
const { endpoint, model, enabled } = getAiConfig()
|
||||
return NextResponse.json({ endpoint, model, enabled })
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
const auth = await requireAdmin(request)
|
||||
if (auth instanceof NextResponse) return auth
|
||||
|
||||
let body: { endpoint?: string; model?: string; enabled?: boolean }
|
||||
try {
|
||||
body = await request.json()
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
|
||||
}
|
||||
|
||||
const { endpoint, model, enabled } = body
|
||||
|
||||
if (typeof endpoint !== 'string') {
|
||||
return NextResponse.json({ error: 'endpoint is required' }, { status: 400 })
|
||||
}
|
||||
if (typeof model !== 'string') {
|
||||
return NextResponse.json({ error: 'model is required' }, { status: 400 })
|
||||
}
|
||||
if (typeof enabled !== 'boolean') {
|
||||
return NextResponse.json({ error: 'enabled must be a boolean' }, { status: 400 })
|
||||
}
|
||||
|
||||
updateAiConfig(endpoint, model, enabled)
|
||||
return NextResponse.json({ endpoint, model, enabled })
|
||||
}
|
||||
47
src/app/api/ai-settings/test/route.ts
Normal file
47
src/app/api/ai-settings/test/route.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { requireAdmin } from '@/lib/auth'
|
||||
import { getAiConfig } from '@/lib/app-settings'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const auth = await requireAdmin(request)
|
||||
if (auth instanceof NextResponse) return auth
|
||||
|
||||
const { endpoint, model } = getAiConfig()
|
||||
|
||||
if (!endpoint) {
|
||||
return NextResponse.json({ error: 'No endpoint configured' }, { status: 400 })
|
||||
}
|
||||
|
||||
const url = endpoint.replace(/\/+$/, '') + '/chat/completions'
|
||||
|
||||
try {
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), 10_000)
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
signal: controller.signal,
|
||||
body: JSON.stringify({
|
||||
model: model || 'test',
|
||||
messages: [{ role: 'user', content: 'Hi' }],
|
||||
max_tokens: 1,
|
||||
}),
|
||||
})
|
||||
|
||||
clearTimeout(timeout)
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '')
|
||||
return NextResponse.json(
|
||||
{ error: `LLM returned ${res.status}: ${text.slice(0, 200)}` },
|
||||
{ status: 502 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true })
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error'
|
||||
return NextResponse.json({ error: `Connection failed: ${message}` }, { status: 502 })
|
||||
}
|
||||
}
|
||||
318
src/app/manage/ai-tagging/page.tsx
Normal file
318
src/app/manage/ai-tagging/page.tsx
Normal file
@@ -0,0 +1,318 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
|
||||
interface AiSettings {
|
||||
endpoint: string
|
||||
model: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export default function AiTaggingPage() {
|
||||
const [settings, setSettings] = useState<AiSettings>({ endpoint: '', model: '', enabled: false })
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saveError, setSaveError] = useState<string | null>(null)
|
||||
const [saveSuccess, setSaveSuccess] = useState(false)
|
||||
const [testing, setTesting] = useState(false)
|
||||
const [testResult, setTestResult] = useState<{ ok: boolean; message: string } | null>(null)
|
||||
const [retagging, setRetagging] = useState(false)
|
||||
const [retagResult, setRetagResult] = useState<string | null>(null)
|
||||
|
||||
const fetchSettings = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/ai-settings')
|
||||
if (!res.ok) return
|
||||
const data: AiSettings = await res.json()
|
||||
setSettings(data)
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings()
|
||||
}, [fetchSettings])
|
||||
|
||||
const handleSave = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setSaveError(null)
|
||||
setSaveSuccess(false)
|
||||
setSaving(true)
|
||||
try {
|
||||
const res = await fetch('/api/ai-settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(settings),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) {
|
||||
setSaveError(data.error ?? 'Failed to save settings')
|
||||
} else {
|
||||
setSettings(data)
|
||||
setSaveSuccess(true)
|
||||
setTimeout(() => setSaveSuccess(false), 3000)
|
||||
}
|
||||
} catch {
|
||||
setSaveError('Network error. Please try again.')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleTest = async () => {
|
||||
setTesting(true)
|
||||
setTestResult(null)
|
||||
try {
|
||||
const res = await fetch('/api/ai-settings/test', { method: 'POST' })
|
||||
const data = await res.json()
|
||||
if (res.ok) {
|
||||
setTestResult({ ok: true, message: 'Connection successful.' })
|
||||
} else {
|
||||
setTestResult({ ok: false, message: data.error ?? 'Connection failed.' })
|
||||
}
|
||||
} catch {
|
||||
setTestResult({ ok: false, message: 'Network error.' })
|
||||
} finally {
|
||||
setTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRetag = async () => {
|
||||
if (!confirm('This will clear AI tags from all items so they get re-processed on the next scan. Continue?')) return
|
||||
setRetagging(true)
|
||||
setRetagResult(null)
|
||||
try {
|
||||
const res = await fetch('/api/ai-settings/retag', { method: 'POST' })
|
||||
const data = await res.json()
|
||||
if (res.ok) {
|
||||
setRetagResult(`Cleared AI tag status from ${data.cleared} items. They will be re-tagged on the next scan.`)
|
||||
} else {
|
||||
setRetagResult(data.error ?? 'Failed to clear tags.')
|
||||
}
|
||||
} catch {
|
||||
setRetagResult('Network error.')
|
||||
} finally {
|
||||
setRetagging(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl">
|
||||
<h1 className="text-2xl font-semibold mb-1" style={{ color: 'var(--text-primary)' }}>
|
||||
AI Tagging
|
||||
</h1>
|
||||
<p className="text-sm mb-8" style={{ color: 'var(--text-secondary)' }}>
|
||||
Automatically tag media using a vision-capable LLM on your network.
|
||||
</p>
|
||||
|
||||
<Section title="Connection">
|
||||
{loading ? (
|
||||
<LoadingRows />
|
||||
) : (
|
||||
<form onSubmit={handleSave} className="flex flex-col gap-5">
|
||||
<Field label="Endpoint URL">
|
||||
<input
|
||||
type="text"
|
||||
value={settings.endpoint}
|
||||
onChange={(e) => setSettings((s) => ({ ...s, endpoint: e.target.value }))}
|
||||
placeholder="http://192.168.1.50:8080/v1"
|
||||
className="w-full rounded-lg px-3 py-2 text-sm font-mono outline-none focus:ring-2"
|
||||
style={{
|
||||
backgroundColor: 'var(--background)',
|
||||
border: '1px solid var(--border)',
|
||||
color: 'var(--text-primary)',
|
||||
}}
|
||||
onFocus={(e) => ((e.currentTarget as HTMLElement).style.borderColor = 'var(--accent)')}
|
||||
onBlur={(e) => ((e.currentTarget as HTMLElement).style.borderColor = 'var(--border)')}
|
||||
/>
|
||||
<p className="mt-1 text-xs" style={{ color: 'var(--text-secondary)' }}>
|
||||
Base URL of an OpenAI-compatible API server (e.g. Ollama, vLLM, llama.cpp).
|
||||
</p>
|
||||
</Field>
|
||||
|
||||
<Field label="Model">
|
||||
<input
|
||||
type="text"
|
||||
value={settings.model}
|
||||
onChange={(e) => setSettings((s) => ({ ...s, model: e.target.value }))}
|
||||
placeholder="llava"
|
||||
className="w-full rounded-lg px-3 py-2 text-sm font-mono outline-none focus:ring-2"
|
||||
style={{
|
||||
backgroundColor: 'var(--background)',
|
||||
border: '1px solid var(--border)',
|
||||
color: 'var(--text-primary)',
|
||||
}}
|
||||
onFocus={(e) => ((e.currentTarget as HTMLElement).style.borderColor = 'var(--accent)')}
|
||||
onBlur={(e) => ((e.currentTarget as HTMLElement).style.borderColor = 'var(--border)')}
|
||||
/>
|
||||
<p className="mt-1 text-xs" style={{ color: 'var(--text-secondary)' }}>
|
||||
Model name to use for vision requests.
|
||||
</p>
|
||||
</Field>
|
||||
|
||||
<Field label="Automatic Tagging">
|
||||
<label className="flex items-center gap-3 cursor-pointer select-none">
|
||||
<div
|
||||
role="switch"
|
||||
aria-checked={settings.enabled}
|
||||
onClick={() => setSettings((s) => ({ ...s, enabled: !s.enabled }))}
|
||||
className="relative inline-flex h-5 w-9 items-center rounded-full transition-colors cursor-pointer"
|
||||
style={{
|
||||
backgroundColor: settings.enabled ? 'var(--accent)' : 'var(--border)',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="inline-block h-3.5 w-3.5 transform rounded-full bg-white transition-transform"
|
||||
style={{ transform: settings.enabled ? 'translateX(18px)' : 'translateX(3px)' }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm" style={{ color: 'var(--text-primary)' }}>
|
||||
{settings.enabled ? 'Enabled' : 'Disabled'}
|
||||
</span>
|
||||
</label>
|
||||
<p className="mt-1 text-xs" style={{ color: 'var(--text-secondary)' }}>
|
||||
When enabled, new media will be automatically tagged during library scans.
|
||||
</p>
|
||||
</Field>
|
||||
|
||||
{saveError && (
|
||||
<p
|
||||
className="text-sm rounded-lg px-3 py-2"
|
||||
style={{ backgroundColor: '#7f1d1d33', color: '#fca5a5' }}
|
||||
>
|
||||
{saveError}
|
||||
</p>
|
||||
)}
|
||||
{saveSuccess && (
|
||||
<p
|
||||
className="text-sm rounded-lg px-3 py-2"
|
||||
style={{ backgroundColor: '#14532d33', color: '#4ade80' }}
|
||||
>
|
||||
Settings saved.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium transition-colors disabled:opacity-50"
|
||||
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
|
||||
onMouseEnter={(e) => {
|
||||
if (!saving) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--accent-hover)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--accent)'
|
||||
}}
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save Settings'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTest}
|
||||
disabled={testing || !settings.endpoint}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium transition-colors disabled:opacity-50"
|
||||
style={{
|
||||
backgroundColor: 'var(--surface)',
|
||||
color: 'var(--text-primary)',
|
||||
border: '1px solid var(--border)',
|
||||
}}
|
||||
>
|
||||
{testing ? 'Testing...' : 'Test Connection'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{testResult && (
|
||||
<p
|
||||
className="text-sm rounded-lg px-3 py-2"
|
||||
style={{
|
||||
backgroundColor: testResult.ok ? '#14532d33' : '#7f1d1d33',
|
||||
color: testResult.ok ? '#4ade80' : '#fca5a5',
|
||||
}}
|
||||
>
|
||||
{testResult.message}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section title="Re-tag">
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
Clear the AI tag status on all items so they get re-processed during the next scan.
|
||||
Existing tag assignments are not removed.
|
||||
</p>
|
||||
<div>
|
||||
<button
|
||||
onClick={handleRetag}
|
||||
disabled={retagging}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium transition-colors disabled:opacity-50"
|
||||
style={{
|
||||
backgroundColor: 'var(--surface)',
|
||||
color: 'var(--text-primary)',
|
||||
border: '1px solid var(--border)',
|
||||
}}
|
||||
>
|
||||
{retagging ? 'Clearing...' : 'Re-tag All Items'}
|
||||
</button>
|
||||
</div>
|
||||
{retagResult && (
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
{retagResult}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="mb-10">
|
||||
<h2
|
||||
className="text-xs font-semibold uppercase tracking-wider mb-3"
|
||||
style={{ color: 'var(--text-secondary)' }}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
<div
|
||||
className="rounded-xl border"
|
||||
style={{ borderColor: 'var(--border)', backgroundColor: 'var(--surface)' }}
|
||||
>
|
||||
<div className="px-5 py-4">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium" style={{ color: 'var(--text-secondary)' }}>
|
||||
{label}
|
||||
</label>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LoadingRows() {
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{[70, 50, 40].map((w) => (
|
||||
<div key={w} className="flex items-center gap-3">
|
||||
<div
|
||||
className="h-4 rounded animate-pulse"
|
||||
style={{ width: `${w}%`, backgroundColor: 'var(--border)' }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user