add per-library AI model and prompt customization

- Add library_ai_settings table with migration for per-library overrides
- Extend AiConfig with editable prompt parts for description, tagging,
  extraction, and translation steps; defaults match previous hardcoded values
- Add getEffectiveAiConfig(libraryId) that merges global settings with
  library-level overrides (empty override falls through to global)
- Update all ai-tagger functions to use getEffectiveAiConfig and build
  prompts from configurable parts
- Add GET/PUT /api/ai-settings/library/[id] for per-library overrides
- Update /api/ai-settings GET/PUT to include prompt fields
- Add Prompts section and Library Overrides section to admin UI

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Garret Patti
2026-04-12 20:37:11 -04:00
parent afb9540df2
commit 887cc05901
6 changed files with 588 additions and 30 deletions

View File

@@ -0,0 +1,44 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireAdmin } from '@/lib/auth'
import { getLibraryAiOverrides, setLibraryAiOverrides } from '@/lib/app-settings'
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const auth = await requireAdmin(request)
if (auth instanceof NextResponse) return auth
const { id } = await params
return NextResponse.json(getLibraryAiOverrides(id))
}
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const auth = await requireAdmin(request)
if (auth instanceof NextResponse) return auth
const { id } = await params
let body: Record<string, unknown>
try {
body = await request.json()
} catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
}
setLibraryAiOverrides(id, {
modelTagging: typeof body.modelTagging === 'string' ? body.modelTagging : undefined,
modelDescribe: typeof body.modelDescribe === 'string' ? body.modelDescribe : undefined,
modelExtract: typeof body.modelExtract === 'string' ? body.modelExtract : undefined,
modelTranslate: typeof body.modelTranslate === 'string' ? body.modelTranslate : undefined,
promptDescribe: typeof body.promptDescribe === 'string' ? body.promptDescribe : undefined,
promptTagger: typeof body.promptTagger === 'string' ? body.promptTagger : undefined,
promptExtract: typeof body.promptExtract === 'string' ? body.promptExtract : undefined,
promptTranslate: typeof body.promptTranslate === 'string' ? body.promptTranslate : undefined,
})
return NextResponse.json(getLibraryAiOverrides(id))
}

View File

@@ -6,23 +6,40 @@ export async function GET(request: NextRequest) {
const auth = await requireAdmin(request)
if (auth instanceof NextResponse) return auth
const { endpoint, model, modelTagging, modelDescribe, modelExtract, modelTranslate, enabled } = getAiConfig()
const config = getAiConfig()
const preferredLanguage = getPreferredLanguage()
return NextResponse.json({ endpoint, model, modelTagging, modelDescribe, modelExtract, modelTranslate, enabled, preferredLanguage })
return NextResponse.json({ ...config, preferredLanguage })
}
export async function PUT(request: NextRequest) {
const auth = await requireAdmin(request)
if (auth instanceof NextResponse) return auth
let body: { endpoint?: string; model?: string; modelTagging?: string; modelDescribe?: string; modelExtract?: string; modelTranslate?: string; enabled?: boolean; preferredLanguage?: string }
let body: {
endpoint?: string
model?: string
modelTagging?: string
modelDescribe?: string
modelExtract?: string
modelTranslate?: string
enabled?: boolean
preferredLanguage?: string
promptDescribe?: string
promptTagger?: string
promptExtract?: string
promptTranslate?: string
}
try {
body = await request.json()
} catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
}
const { endpoint, model, enabled, preferredLanguage, modelTagging, modelDescribe, modelExtract, modelTranslate } = body
const {
endpoint, model, enabled, preferredLanguage,
modelTagging, modelDescribe, modelExtract, modelTranslate,
promptDescribe, promptTagger, promptExtract, promptTranslate,
} = body
if (typeof endpoint !== 'string') {
return NextResponse.json({ error: 'endpoint is required' }, { status: 400 })
@@ -42,6 +59,10 @@ export async function PUT(request: NextRequest) {
typeof modelDescribe === 'string' ? modelDescribe : undefined,
typeof modelExtract === 'string' ? modelExtract : undefined,
typeof modelTranslate === 'string' ? modelTranslate : undefined,
typeof promptDescribe === 'string' ? promptDescribe : undefined,
typeof promptTagger === 'string' ? promptTagger : undefined,
typeof promptExtract === 'string' ? promptExtract : undefined,
typeof promptTranslate === 'string' ? promptTranslate : undefined,
)
if (typeof preferredLanguage === 'string' && preferredLanguage.trim()) {

View File

@@ -11,10 +11,34 @@ interface AiSettings {
modelTranslate: string
enabled: boolean
preferredLanguage: string
promptDescribe: string
promptTagger: string
promptExtract: string
promptTranslate: string
}
interface Library {
id: string
name: string
}
interface LibraryOverride {
modelTagging: string
modelDescribe: string
modelExtract: string
modelTranslate: string
promptDescribe: string
promptTagger: string
promptExtract: string
promptTranslate: string
}
export default function AiTaggingPage() {
const [settings, setSettings] = useState<AiSettings>({ endpoint: '', model: '', modelTagging: '', modelDescribe: '', modelExtract: '', modelTranslate: '', enabled: false, preferredLanguage: 'English' })
const [settings, setSettings] = useState<AiSettings>({
endpoint: '', model: '', modelTagging: '', modelDescribe: '', modelExtract: '', modelTranslate: '',
enabled: false, preferredLanguage: 'English',
promptDescribe: '', promptTagger: '', promptExtract: '', promptTranslate: '',
})
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [saveError, setSaveError] = useState<string | null>(null)
@@ -24,12 +48,26 @@ export default function AiTaggingPage() {
const [retagging, setRetagging] = useState(false)
const [retagResult, setRetagResult] = useState<string | null>(null)
const [libraries, setLibraries] = useState<Library[]>([])
const [libraryOverrides, setLibraryOverrides] = useState<Record<string, LibraryOverride>>({})
const [expandedLibrary, setExpandedLibrary] = useState<string | null>(null)
const [librarySaving, setLibrarySaving] = useState<Record<string, boolean>>({})
const [librarySaveResult, setLibrarySaveResult] = useState<Record<string, { ok: boolean; message: string }>>({})
const fetchSettings = useCallback(async () => {
try {
const res = await fetch('/api/ai-settings')
if (!res.ok) return
const data: AiSettings = await res.json()
setSettings(data)
const [settingsRes, librariesRes] = await Promise.all([
fetch('/api/ai-settings'),
fetch('/api/libraries'),
])
if (settingsRes.ok) {
const data: AiSettings = await settingsRes.json()
setSettings(data)
}
if (librariesRes.ok) {
const data: Library[] = await librariesRes.json()
setLibraries(data)
}
} catch {
// ignore
} finally {
@@ -41,6 +79,29 @@ export default function AiTaggingPage() {
fetchSettings()
}, [fetchSettings])
const fetchLibraryOverrides = useCallback(async (libraryId: string) => {
try {
const res = await fetch(`/api/ai-settings/library/${libraryId}`)
if (res.ok) {
const data: LibraryOverride = await res.json()
setLibraryOverrides((prev) => ({ ...prev, [libraryId]: data }))
}
} catch {
// ignore
}
}, [])
const handleToggleLibrary = (libraryId: string) => {
if (expandedLibrary === libraryId) {
setExpandedLibrary(null)
} else {
setExpandedLibrary(libraryId)
if (!libraryOverrides[libraryId]) {
fetchLibraryOverrides(libraryId)
}
}
}
const handleSave = async (e: React.FormEvent) => {
e.preventDefault()
setSaveError(null)
@@ -104,6 +165,39 @@ export default function AiTaggingPage() {
}
}
const handleSaveLibraryOverride = async (libraryId: string) => {
const overrides = libraryOverrides[libraryId]
if (!overrides) return
setLibrarySaving((prev) => ({ ...prev, [libraryId]: true }))
setLibrarySaveResult((prev) => ({ ...prev, [libraryId]: { ok: false, message: '' } }))
try {
const res = await fetch(`/api/ai-settings/library/${libraryId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(overrides),
})
const data = await res.json()
if (res.ok) {
setLibraryOverrides((prev) => ({ ...prev, [libraryId]: data }))
setLibrarySaveResult((prev) => ({ ...prev, [libraryId]: { ok: true, message: 'Saved.' } }))
setTimeout(() => setLibrarySaveResult((prev) => ({ ...prev, [libraryId]: { ok: true, message: '' } })), 3000)
} else {
setLibrarySaveResult((prev) => ({ ...prev, [libraryId]: { ok: false, message: data.error ?? 'Failed to save.' } }))
}
} catch {
setLibrarySaveResult((prev) => ({ ...prev, [libraryId]: { ok: false, message: 'Network error.' } }))
} finally {
setLibrarySaving((prev) => ({ ...prev, [libraryId]: false }))
}
}
const updateLibraryOverride = (libraryId: string, field: keyof LibraryOverride, value: string) => {
setLibraryOverrides((prev) => ({
...prev,
[libraryId]: { ...(prev[libraryId] ?? emptyOverride()), [field]: value },
}))
}
return (
<div className="max-w-2xl">
<h1 className="text-2xl font-semibold mb-1" style={{ color: 'var(--text-primary)' }}>
@@ -334,6 +428,255 @@ export default function AiTaggingPage() {
)}
</Section>
<Section title="Prompts">
{loading ? (
<LoadingRows />
) : (
<form onSubmit={handleSave} className="flex flex-col gap-5">
<Field label="Description Instructions">
<textarea
rows={4}
value={settings.promptDescribe}
onChange={(e) => setSettings((s) => ({ ...s, promptDescribe: e.target.value }))}
className="w-full rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 resize-y"
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)')}
/>
</Field>
<Field label="Tagger Instructions">
<textarea
rows={4}
value={settings.promptTagger}
onChange={(e) => setSettings((s) => ({ ...s, promptTagger: e.target.value }))}
placeholder="(blank by default)"
className="w-full rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 resize-y"
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)')}
/>
</Field>
<Field label="Text Extraction Instructions">
<textarea
rows={4}
value={settings.promptExtract}
onChange={(e) => setSettings((s) => ({ ...s, promptExtract: e.target.value }))}
className="w-full rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 resize-y"
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)')}
/>
</Field>
<Field label="Translation Instructions">
<textarea
rows={4}
value={settings.promptTranslate}
onChange={(e) => setSettings((s) => ({ ...s, promptTranslate: e.target.value }))}
className="w-full rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 resize-y"
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)')}
/>
</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>
<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 Prompts'}
</button>
</div>
</form>
)}
</Section>
<Section title="Library Overrides">
{loading ? (
<LoadingRows />
) : libraries.length === 0 ? (
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>No libraries configured.</p>
) : (
<div className="flex flex-col divide-y" style={{ borderColor: 'var(--border)' }}>
{libraries.map((lib) => {
const isExpanded = expandedLibrary === lib.id
const overrides = libraryOverrides[lib.id] ?? null
const isSaving = librarySaving[lib.id] ?? false
const saveResult = librarySaveResult[lib.id]
return (
<div key={lib.id}>
<button
type="button"
onClick={() => handleToggleLibrary(lib.id)}
className="w-full flex items-center justify-between py-3 text-left"
style={{ color: 'var(--text-primary)' }}
>
<span className="text-sm font-medium">{lib.name}</span>
<span
className="text-xs transition-transform"
style={{
color: 'var(--text-secondary)',
transform: isExpanded ? 'rotate(180deg)' : 'rotate(0deg)',
display: 'inline-block',
}}
>
</span>
</button>
{isExpanded && (
<div className="pb-5 flex flex-col gap-5">
{overrides === null ? (
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>Loading</p>
) : (
<>
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>
Leave any field blank to use the global default.
</p>
<div className="flex flex-col gap-3">
<p className="text-xs font-medium uppercase tracking-wide" style={{ color: 'var(--text-secondary)' }}>Models</p>
{(
[
['modelTagging', 'Tagging Model'],
['modelDescribe', 'Description Model'],
['modelExtract', 'Text Extraction Model'],
['modelTranslate', 'Translation Model'],
] as [keyof LibraryOverride, string][]
).map(([field, label]) => (
<Field key={field} label={label}>
<input
type="text"
value={overrides[field]}
onChange={(e) => updateLibraryOverride(lib.id, field, e.target.value)}
placeholder={`Leave blank to use global default${settings[field as keyof AiSettings] ? ` (${settings[field as keyof AiSettings]})` : ''}`}
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)')}
/>
</Field>
))}
</div>
<div className="flex flex-col gap-3">
<p className="text-xs font-medium uppercase tracking-wide" style={{ color: 'var(--text-secondary)' }}>Prompts</p>
{(
[
['promptDescribe', 'Description Instructions', settings.promptDescribe],
['promptTagger', 'Tagger Instructions', settings.promptTagger],
['promptExtract', 'Text Extraction Instructions', settings.promptExtract],
['promptTranslate', 'Translation Instructions', settings.promptTranslate],
] as [keyof LibraryOverride, string, string][]
).map(([field, label, globalValue]) => (
<Field key={field} label={label}>
<textarea
rows={3}
value={overrides[field]}
onChange={(e) => updateLibraryOverride(lib.id, field, e.target.value)}
placeholder={globalValue ? `Leave blank to use global default:\n${globalValue}` : 'Leave blank to use global default'}
className="w-full rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 resize-y"
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)')}
/>
</Field>
))}
</div>
{saveResult?.message && (
<p
className="text-sm rounded-lg px-3 py-2"
style={{
backgroundColor: saveResult.ok ? '#14532d33' : '#7f1d1d33',
color: saveResult.ok ? '#4ade80' : '#fca5a5',
}}
>
{saveResult.message}
</p>
)}
<div>
<button
type="button"
onClick={() => handleSaveLibraryOverride(lib.id)}
disabled={isSaving}
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 (!isSaving) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--accent-hover)'
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--accent)'
}}
>
{isSaving ? 'Saving...' : `Save ${lib.name} Overrides`}
</button>
</div>
</>
)}
</div>
)}
</div>
)
})}
</div>
)}
</Section>
<Section title="Re-tag">
<div className="flex flex-col gap-3">
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
@@ -365,6 +708,13 @@ export default function AiTaggingPage() {
)
}
function emptyOverride(): LibraryOverride {
return {
modelTagging: '', modelDescribe: '', modelExtract: '', modelTranslate: '',
promptDescribe: '', promptTagger: '', promptExtract: '', promptTranslate: '',
}
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="mb-10">