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()
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">

View File

@@ -2,7 +2,7 @@ import fs from 'fs'
import path from 'path'
import type { Library, Tag, TagCategory } from '@/types'
import { getDb } from './db'
import { getAiConfig, getPreferredLanguage } from './app-settings'
import { getAiConfig, getEffectiveAiConfig, getPreferredLanguage } from './app-settings'
import { getTags, getCategories, addTagToItem, getActiveCategoryIdsForLibrary, getResolvedTagsForItem } from './tags'
import { getThumbnailPath, getVideoFramePaths } from './thumbnails'
import { findFile } from './media-utils'
@@ -105,10 +105,11 @@ interface TagPromptContext {
mediaContext?: 'image' | 'video'
aiDescription?: string | null
extractedText?: string | null
customInstruction?: string
}
function buildTagPrompt(tags: Tag[], categories: TagCategory[], ctx: TagPromptContext = {}): string {
const { currentTags, mediaContext = 'image', aiDescription, extractedText } = ctx
const { currentTags, mediaContext = 'image', aiDescription, extractedText, customInstruction } = ctx
const categoryMap = new Map(categories.map((c) => [c.id, c.name]))
const grouped: Record<string, { id: string; name: string }[]> = {}
@@ -132,6 +133,11 @@ function buildTagPrompt(tags: Tag[], categories: TagCategory[], ctx: TagPromptCo
'If no tags match, return an empty array (e.i., [])',
]
if (customInstruction) {
parts.push('')
parts.push(customInstruction)
}
if (aiDescription) {
parts.push('')
parts.push(`AI-generated description of this content: ${aiDescription}`)
@@ -222,7 +228,7 @@ async function callVisionApi(
* Processes up to BATCH_LIMIT untagged items per invocation.
*/
export async function runAiTagging(library: Library, libraryRoot: string): Promise<void> {
const config = getAiConfig()
const config = getEffectiveAiConfig(library.id)
const taggingModel = config.modelTagging || config.model
if (!config.enabled || !config.endpoint || !taggingModel) return
@@ -284,6 +290,7 @@ export async function runAiTagging(library: Library, libraryRoot: string): Promi
mediaContext: resolvedMedia.mediaType,
aiDescription: aiFields.aiDescription,
extractedText: aiFields.extractedTextTranslated ?? aiFields.extractedText,
customInstruction: config.promptTagger || undefined,
})
const suggestedIds = await callVisionApi(config.endpoint, taggingModel, base64Images, systemPrompt)
@@ -317,14 +324,13 @@ export async function runAiTagging(library: Library, libraryRoot: string): Promi
* Throws descriptive errors so the API route can return appropriate status codes.
*/
export async function tagSingleItem(itemKey: string): Promise<string[]> {
const config = getAiConfig()
const libraryId = itemKey.split(':')[0]
const config = getEffectiveAiConfig(libraryId)
const taggingModel = config.modelTagging || config.model
if (!config.endpoint || !taggingModel) {
throw Object.assign(new Error('AI tagging endpoint and model are not configured'), { code: 'NOT_CONFIGURED' })
}
const libraryId = itemKey.split(':')[0]
const activeCategoryIds = new Set(getActiveCategoryIdsForLibrary(libraryId))
const allTags = getTags()
const allCategories = getCategories()
@@ -372,6 +378,7 @@ export async function tagSingleItem(itemKey: string): Promise<string[]> {
mediaContext: imagePath.mediaType,
aiDescription: aiFields.aiDescription,
extractedText: aiFields.extractedTextTranslated ?? aiFields.extractedText,
customInstruction: config.promptTagger || undefined,
})
const suggestedIds = await callVisionApi(config.endpoint, taggingModel, base64Images, systemPromptWithContext)
@@ -491,13 +498,13 @@ async function callChatApiText(
* Stores the result in the ai_description column and returns it.
*/
export async function generateItemDescription(itemKey: string): Promise<string> {
const config = getAiConfig()
const libraryId = itemKey.split(':')[0]
const config = getEffectiveAiConfig(libraryId)
const describeModel = config.modelDescribe || config.model
if (!config.endpoint || !describeModel) {
throw Object.assign(new Error('AI endpoint and model are not configured'), { code: 'NOT_CONFIGURED' })
}
const libraryId = itemKey.split(':')[0]
const db = getDb()
const item = db
.prepare('SELECT item_key, item_type, file_path, metadata FROM media_items WHERE item_key = ?')
@@ -526,7 +533,7 @@ export async function generateItemDescription(itemKey: string): Promise<string>
base64Images = [fs.readFileSync(thumbnailPath, 'base64')]
}
const systemPrompt = 'You are a media cataloging assistant. Describe the given image briefly and objectively in 1-3 sentences. Focus on the visual content, subjects, setting, and mood. Do not speculate about context outside the image. Do not preface the description with any phrases like "This image shows" or "This image features". Return only the description text with no additional commentary.'
const systemPrompt = `You are a media cataloging assistant. Describe the given image briefly and objectively in 1-3 sentences.${config.promptDescribe ? ' ' + config.promptDescribe : ''}`
const description = await callVisionApiText(config.endpoint, describeModel, base64Images, systemPrompt)
@@ -544,13 +551,13 @@ export async function generateItemDescription(itemKey: string): Promise<string>
* Returns { extractedText, translatedText }.
*/
export async function extractItemText(itemKey: string): Promise<{ extractedText: string; translatedText: string | null }> {
const config = getAiConfig()
const libraryId = itemKey.split(':')[0]
const config = getEffectiveAiConfig(libraryId)
const extractModel = config.modelExtract || config.model
if (!config.endpoint || !extractModel) {
throw Object.assign(new Error('AI endpoint and model are not configured'), { code: 'NOT_CONFIGURED' })
}
const libraryId = itemKey.split(':')[0]
const db = getDb()
const item = db
.prepare('SELECT item_key, item_type, file_path, metadata FROM media_items WHERE item_key = ?')
@@ -579,7 +586,7 @@ export async function extractItemText(itemKey: string): Promise<{ extractedText:
const thumbnailPath = await getThumbnailPath(resolvedMedia.path, libraryId, 'image')
const base64Images = [fs.readFileSync(thumbnailPath, 'base64')]
const systemPrompt = 'You are an OCR assistant. Extract ALL text visible in the image exactly as it appears. Preserve line breaks and formatting. Be mindful of different colors of text that may indicate different speakers or emphasis. If there is no text in the image, respond with exactly: [NO TEXT]'
const systemPrompt = `You are an OCR assistant. Extract ALL text visible in the image exactly as it appears. Preserve line breaks and formatting.${config.promptExtract ? ' ' + config.promptExtract : ''} If there is no text in the image, respond with exactly: [NO TEXT]`
const extractedText = await callVisionApiText(config.endpoint, extractModel, base64Images, systemPrompt)
@@ -596,7 +603,7 @@ export async function extractItemText(itemKey: string): Promise<{ extractedText:
if (preferredLanguage) {
const translateModel = config.modelTranslate || config.model
try {
translatedText = await translateText(config.endpoint, translateModel, extractedText, preferredLanguage)
translatedText = await translateText(config.endpoint, translateModel, extractedText, preferredLanguage, config.promptTranslate)
if (translatedText) {
db.prepare('UPDATE media_items SET extracted_text_translated = ? WHERE item_key = ?').run(translatedText, itemKey)
}
@@ -613,7 +620,8 @@ export async function extractItemText(itemKey: string): Promise<{ extractedText:
* Returns the translated text or null if no text to translate.
*/
export async function translateItemText(itemKey: string): Promise<string | null> {
const config = getAiConfig()
const libraryId = itemKey.split(':')[0]
const config = getEffectiveAiConfig(libraryId)
const translateModel = config.modelTranslate || config.model
if (!config.endpoint || !translateModel) {
throw Object.assign(new Error('AI endpoint and model are not configured'), { code: 'NOT_CONFIGURED' })
@@ -633,7 +641,7 @@ export async function translateItemText(itemKey: string): Promise<string | null>
const preferredLanguage = getPreferredLanguage()
if (!preferredLanguage) return null
const translatedText = await translateText(config.endpoint, translateModel, row.extracted_text, preferredLanguage)
const translatedText = await translateText(config.endpoint, translateModel, row.extracted_text, preferredLanguage, config.promptTranslate)
if (translatedText) {
db.prepare('UPDATE media_items SET extracted_text_translated = ? WHERE item_key = ?').run(translatedText, itemKey)
}
@@ -649,9 +657,10 @@ async function translateText(
endpoint: string,
model: string,
text: string,
targetLanguage: string
targetLanguage: string,
customInstruction = '',
): Promise<string | null> {
const systemPrompt = `You are a translator. Determine if the following text is already in ${targetLanguage}. If it is, respond with exactly: [ALREADY_TARGET_LANGUAGE]. If it is not, translate it to ${targetLanguage}. Return ONLY the translated text with no additional commentary.`
const systemPrompt = `You are a translator. Determine if the following text is already in ${targetLanguage}. If it is, respond with exactly: [ALREADY_TARGET_LANGUAGE]. If it is not, translate it to ${targetLanguage}.${customInstruction ? ' ' + customInstruction : ''}`
const result = await callChatApiText(endpoint, model, systemPrompt, text)
@@ -667,7 +676,7 @@ async function translateText(
* Returns the number of items processed.
*/
export async function extractDirectoryText(libraryId: string, dirPath: string): Promise<number> {
const config = getAiConfig()
const config = getEffectiveAiConfig(libraryId)
const extractModel = config.modelExtract || config.model
if (!config.endpoint || !extractModel) {
throw Object.assign(new Error('AI endpoint and model are not configured'), { code: 'NOT_CONFIGURED' })
@@ -718,7 +727,7 @@ export async function extractDirectoryText(libraryId: string, dirPath: string):
* Returns the number of items processed.
*/
export async function describeDirectoryItems(libraryId: string, dirPath: string): Promise<number> {
const config = getAiConfig()
const config = getEffectiveAiConfig(libraryId)
const describeModel = config.modelDescribe || config.model
if (!config.endpoint || !describeModel) {
throw Object.assign(new Error('AI endpoint and model are not configured'), { code: 'NOT_CONFIGURED' })

View File

@@ -39,7 +39,14 @@ export function setScanLastRan(ts: number): void {
// ─── AI Settings ─────────────────────────────────────────────────────────────
interface AiConfig {
const DEFAULT_PROMPT_DESCRIBE =
'Focus on the visual content, subjects, setting, and mood. Do not speculate about context outside the image. Do not preface the description with any phrases like "This image shows" or "This image features". Return only the description text with no additional commentary.'
const DEFAULT_PROMPT_TAGGER = ''
const DEFAULT_PROMPT_EXTRACT =
'Be mindful of different colors of text that may indicate different speakers or emphasis.'
const DEFAULT_PROMPT_TRANSLATE = 'Return ONLY the translated text with no additional commentary.'
export interface AiConfig {
endpoint: string
model: string
modelTagging: string
@@ -47,6 +54,10 @@ interface AiConfig {
modelExtract: string
modelTranslate: string
enabled: boolean
promptDescribe: string
promptTagger: string
promptExtract: string
promptTranslate: string
}
export function getAiConfig(): AiConfig {
@@ -57,7 +68,18 @@ export function getAiConfig(): AiConfig {
const modelExtract = getSetting('ai_model_extract') ?? ''
const modelTranslate = getSetting('ai_model_translate') ?? ''
const enabled = getSetting('ai_enabled') === 'true'
return { endpoint, model, modelTagging, modelDescribe, modelExtract, modelTranslate, enabled }
const promptDescribeRaw = getSetting('ai_prompt_describe')
const promptDescribe = promptDescribeRaw !== null ? promptDescribeRaw : DEFAULT_PROMPT_DESCRIBE
const promptTaggerRaw = getSetting('ai_prompt_tagger')
const promptTagger = promptTaggerRaw !== null ? promptTaggerRaw : DEFAULT_PROMPT_TAGGER
const promptExtractRaw = getSetting('ai_prompt_extract')
const promptExtract = promptExtractRaw !== null ? promptExtractRaw : DEFAULT_PROMPT_EXTRACT
const promptTranslateRaw = getSetting('ai_prompt_translate')
const promptTranslate = promptTranslateRaw !== null ? promptTranslateRaw : DEFAULT_PROMPT_TRANSLATE
return {
endpoint, model, modelTagging, modelDescribe, modelExtract, modelTranslate, enabled,
promptDescribe, promptTagger, promptExtract, promptTranslate,
}
}
export function updateAiConfig(
@@ -68,6 +90,10 @@ export function updateAiConfig(
modelDescribe?: string,
modelExtract?: string,
modelTranslate?: string,
promptDescribe?: string,
promptTagger?: string,
promptExtract?: string,
promptTranslate?: string,
): void {
setSetting('ai_endpoint', endpoint)
setSetting('ai_model', model)
@@ -76,6 +102,10 @@ export function updateAiConfig(
if (modelDescribe !== undefined) setSetting('ai_model_describe', modelDescribe)
if (modelExtract !== undefined) setSetting('ai_model_extract', modelExtract)
if (modelTranslate !== undefined) setSetting('ai_model_translate', modelTranslate)
if (promptDescribe !== undefined) setSetting('ai_prompt_describe', promptDescribe)
if (promptTagger !== undefined) setSetting('ai_prompt_tagger', promptTagger)
if (promptExtract !== undefined) setSetting('ai_prompt_extract', promptExtract)
if (promptTranslate !== undefined) setSetting('ai_prompt_translate', promptTranslate)
}
export function getPreferredLanguage(): string {
@@ -85,3 +115,90 @@ export function getPreferredLanguage(): string {
export function setPreferredLanguage(language: string): void {
setSetting('preferred_language', language)
}
// ─── Per-library AI overrides ─────────────────────────────────────────────────
export interface LibraryAiOverrides {
modelTagging: string
modelDescribe: string
modelExtract: string
modelTranslate: string
promptDescribe: string
promptTagger: string
promptExtract: string
promptTranslate: string
}
interface LibraryAiSettingsRow {
model_tagging: string | null
model_describe: string | null
model_extract: string | null
model_translate: string | null
prompt_describe: string | null
prompt_tagger: string | null
prompt_extract: string | null
prompt_translate: string | null
}
export function getLibraryAiOverrides(libraryId: string): LibraryAiOverrides {
const db = getDb()
const row = db
.prepare('SELECT * FROM library_ai_settings WHERE library_id = ?')
.get(libraryId) as LibraryAiSettingsRow | undefined
return {
modelTagging: row?.model_tagging ?? '',
modelDescribe: row?.model_describe ?? '',
modelExtract: row?.model_extract ?? '',
modelTranslate: row?.model_translate ?? '',
promptDescribe: row?.prompt_describe ?? '',
promptTagger: row?.prompt_tagger ?? '',
promptExtract: row?.prompt_extract ?? '',
promptTranslate: row?.prompt_translate ?? '',
}
}
export function setLibraryAiOverrides(libraryId: string, overrides: Partial<LibraryAiOverrides>): void {
const db = getDb()
// Ensure a row exists
db.prepare(
'INSERT OR IGNORE INTO library_ai_settings (library_id) VALUES (?)'
).run(libraryId)
const fields: Record<string, string | undefined> = {
model_tagging: overrides.modelTagging,
model_describe: overrides.modelDescribe,
model_extract: overrides.modelExtract,
model_translate: overrides.modelTranslate,
prompt_describe: overrides.promptDescribe,
prompt_tagger: overrides.promptTagger,
prompt_extract: overrides.promptExtract,
prompt_translate: overrides.promptTranslate,
}
for (const [col, val] of Object.entries(fields)) {
if (val !== undefined) {
db.prepare(`UPDATE library_ai_settings SET ${col} = ? WHERE library_id = ?`).run(
val === '' ? null : val,
libraryId,
)
}
}
}
export function getEffectiveAiConfig(libraryId: string): AiConfig {
const global = getAiConfig()
const overrides = getLibraryAiOverrides(libraryId)
return {
endpoint: global.endpoint,
model: global.model,
enabled: global.enabled,
modelTagging: overrides.modelTagging || global.modelTagging,
modelDescribe: overrides.modelDescribe || global.modelDescribe,
modelExtract: overrides.modelExtract || global.modelExtract,
modelTranslate: overrides.modelTranslate || global.modelTranslate,
promptDescribe: overrides.promptDescribe || global.promptDescribe,
promptTagger: overrides.promptTagger || global.promptTagger,
promptExtract: overrides.promptExtract || global.promptExtract,
promptTranslate: overrides.promptTranslate || global.promptTranslate,
}
}

View File

@@ -104,6 +104,7 @@ function initDb(db: Database.Database): void {
migrateMediaTagsToItemKey(db)
migrateMediaItemsAiTagged(db)
migrateMediaItemsAiFields(db)
migrateLibraryAiSettings(db)
seedAppSettings(db)
}
@@ -259,6 +260,22 @@ function migrateMediaItemsAiFields(db: Database.Database): void {
}
}
function migrateLibraryAiSettings(db: Database.Database): void {
db.exec(`
CREATE TABLE IF NOT EXISTS library_ai_settings (
library_id TEXT PRIMARY KEY REFERENCES libraries(id) ON DELETE CASCADE,
model_tagging TEXT,
model_describe TEXT,
model_extract TEXT,
model_translate TEXT,
prompt_describe TEXT,
prompt_tagger TEXT,
prompt_extract TEXT,
prompt_translate TEXT
);
`)
}
function migrateLibrariesType(db: Database.Database): void {
const row = db
.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='libraries'")