ai-customization #22
44
src/app/api/ai-settings/library/[id]/route.ts
Normal file
44
src/app/api/ai-settings/library/[id]/route.ts
Normal 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))
|
||||||
|
}
|
||||||
@@ -6,23 +6,40 @@ export async function GET(request: NextRequest) {
|
|||||||
const auth = await requireAdmin(request)
|
const auth = await requireAdmin(request)
|
||||||
if (auth instanceof NextResponse) return auth
|
if (auth instanceof NextResponse) return auth
|
||||||
|
|
||||||
const { endpoint, model, modelTagging, modelDescribe, modelExtract, modelTranslate, enabled } = getAiConfig()
|
const config = getAiConfig()
|
||||||
const preferredLanguage = getPreferredLanguage()
|
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) {
|
export async function PUT(request: NextRequest) {
|
||||||
const auth = await requireAdmin(request)
|
const auth = await requireAdmin(request)
|
||||||
if (auth instanceof NextResponse) return auth
|
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 {
|
try {
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
} catch {
|
} catch {
|
||||||
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
|
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') {
|
if (typeof endpoint !== 'string') {
|
||||||
return NextResponse.json({ error: 'endpoint is required' }, { status: 400 })
|
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 modelDescribe === 'string' ? modelDescribe : undefined,
|
||||||
typeof modelExtract === 'string' ? modelExtract : undefined,
|
typeof modelExtract === 'string' ? modelExtract : undefined,
|
||||||
typeof modelTranslate === 'string' ? modelTranslate : 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()) {
|
if (typeof preferredLanguage === 'string' && preferredLanguage.trim()) {
|
||||||
|
|||||||
@@ -11,10 +11,34 @@ interface AiSettings {
|
|||||||
modelTranslate: string
|
modelTranslate: string
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
preferredLanguage: string
|
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() {
|
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 [loading, setLoading] = useState(true)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [saveError, setSaveError] = useState<string | null>(null)
|
const [saveError, setSaveError] = useState<string | null>(null)
|
||||||
@@ -24,12 +48,26 @@ export default function AiTaggingPage() {
|
|||||||
const [retagging, setRetagging] = useState(false)
|
const [retagging, setRetagging] = useState(false)
|
||||||
const [retagResult, setRetagResult] = useState<string | null>(null)
|
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 () => {
|
const fetchSettings = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/ai-settings')
|
const [settingsRes, librariesRes] = await Promise.all([
|
||||||
if (!res.ok) return
|
fetch('/api/ai-settings'),
|
||||||
const data: AiSettings = await res.json()
|
fetch('/api/libraries'),
|
||||||
setSettings(data)
|
])
|
||||||
|
if (settingsRes.ok) {
|
||||||
|
const data: AiSettings = await settingsRes.json()
|
||||||
|
setSettings(data)
|
||||||
|
}
|
||||||
|
if (librariesRes.ok) {
|
||||||
|
const data: Library[] = await librariesRes.json()
|
||||||
|
setLibraries(data)
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
} finally {
|
} finally {
|
||||||
@@ -41,6 +79,29 @@ export default function AiTaggingPage() {
|
|||||||
fetchSettings()
|
fetchSettings()
|
||||||
}, [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) => {
|
const handleSave = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setSaveError(null)
|
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 (
|
return (
|
||||||
<div className="max-w-2xl">
|
<div className="max-w-2xl">
|
||||||
<h1 className="text-2xl font-semibold mb-1" style={{ color: 'var(--text-primary)' }}>
|
<h1 className="text-2xl font-semibold mb-1" style={{ color: 'var(--text-primary)' }}>
|
||||||
@@ -334,6 +428,255 @@ export default function AiTaggingPage() {
|
|||||||
)}
|
)}
|
||||||
</Section>
|
</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">
|
<Section title="Re-tag">
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
<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 }) {
|
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="mb-10">
|
<div className="mb-10">
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import fs from 'fs'
|
|||||||
import path from 'path'
|
import path from 'path'
|
||||||
import type { Library, Tag, TagCategory } from '@/types'
|
import type { Library, Tag, TagCategory } from '@/types'
|
||||||
import { getDb } from './db'
|
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 { getTags, getCategories, addTagToItem, getActiveCategoryIdsForLibrary, getResolvedTagsForItem } from './tags'
|
||||||
import { getThumbnailPath, getVideoFramePaths } from './thumbnails'
|
import { getThumbnailPath, getVideoFramePaths } from './thumbnails'
|
||||||
import { findFile } from './media-utils'
|
import { findFile } from './media-utils'
|
||||||
@@ -105,10 +105,11 @@ interface TagPromptContext {
|
|||||||
mediaContext?: 'image' | 'video'
|
mediaContext?: 'image' | 'video'
|
||||||
aiDescription?: string | null
|
aiDescription?: string | null
|
||||||
extractedText?: string | null
|
extractedText?: string | null
|
||||||
|
customInstruction?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildTagPrompt(tags: Tag[], categories: TagCategory[], ctx: TagPromptContext = {}): 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 categoryMap = new Map(categories.map((c) => [c.id, c.name]))
|
||||||
|
|
||||||
const grouped: Record<string, { id: string; name: string }[]> = {}
|
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 no tags match, return an empty array (e.i., [])',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
if (customInstruction) {
|
||||||
|
parts.push('')
|
||||||
|
parts.push(customInstruction)
|
||||||
|
}
|
||||||
|
|
||||||
if (aiDescription) {
|
if (aiDescription) {
|
||||||
parts.push('')
|
parts.push('')
|
||||||
parts.push(`AI-generated description of this content: ${aiDescription}`)
|
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.
|
* Processes up to BATCH_LIMIT untagged items per invocation.
|
||||||
*/
|
*/
|
||||||
export async function runAiTagging(library: Library, libraryRoot: string): Promise<void> {
|
export async function runAiTagging(library: Library, libraryRoot: string): Promise<void> {
|
||||||
const config = getAiConfig()
|
const config = getEffectiveAiConfig(library.id)
|
||||||
const taggingModel = config.modelTagging || config.model
|
const taggingModel = config.modelTagging || config.model
|
||||||
if (!config.enabled || !config.endpoint || !taggingModel) return
|
if (!config.enabled || !config.endpoint || !taggingModel) return
|
||||||
|
|
||||||
@@ -284,6 +290,7 @@ export async function runAiTagging(library: Library, libraryRoot: string): Promi
|
|||||||
mediaContext: resolvedMedia.mediaType,
|
mediaContext: resolvedMedia.mediaType,
|
||||||
aiDescription: aiFields.aiDescription,
|
aiDescription: aiFields.aiDescription,
|
||||||
extractedText: aiFields.extractedTextTranslated ?? aiFields.extractedText,
|
extractedText: aiFields.extractedTextTranslated ?? aiFields.extractedText,
|
||||||
|
customInstruction: config.promptTagger || undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
const suggestedIds = await callVisionApi(config.endpoint, taggingModel, base64Images, systemPrompt)
|
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.
|
* Throws descriptive errors so the API route can return appropriate status codes.
|
||||||
*/
|
*/
|
||||||
export async function tagSingleItem(itemKey: string): Promise<string[]> {
|
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
|
const taggingModel = config.modelTagging || config.model
|
||||||
if (!config.endpoint || !taggingModel) {
|
if (!config.endpoint || !taggingModel) {
|
||||||
throw Object.assign(new Error('AI tagging endpoint and model are not configured'), { code: 'NOT_CONFIGURED' })
|
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 activeCategoryIds = new Set(getActiveCategoryIdsForLibrary(libraryId))
|
||||||
const allTags = getTags()
|
const allTags = getTags()
|
||||||
const allCategories = getCategories()
|
const allCategories = getCategories()
|
||||||
@@ -372,6 +378,7 @@ export async function tagSingleItem(itemKey: string): Promise<string[]> {
|
|||||||
mediaContext: imagePath.mediaType,
|
mediaContext: imagePath.mediaType,
|
||||||
aiDescription: aiFields.aiDescription,
|
aiDescription: aiFields.aiDescription,
|
||||||
extractedText: aiFields.extractedTextTranslated ?? aiFields.extractedText,
|
extractedText: aiFields.extractedTextTranslated ?? aiFields.extractedText,
|
||||||
|
customInstruction: config.promptTagger || undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
const suggestedIds = await callVisionApi(config.endpoint, taggingModel, base64Images, systemPromptWithContext)
|
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.
|
* Stores the result in the ai_description column and returns it.
|
||||||
*/
|
*/
|
||||||
export async function generateItemDescription(itemKey: string): Promise<string> {
|
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
|
const describeModel = config.modelDescribe || config.model
|
||||||
if (!config.endpoint || !describeModel) {
|
if (!config.endpoint || !describeModel) {
|
||||||
throw Object.assign(new Error('AI endpoint and model are not configured'), { code: 'NOT_CONFIGURED' })
|
throw Object.assign(new Error('AI endpoint and model are not configured'), { code: 'NOT_CONFIGURED' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const libraryId = itemKey.split(':')[0]
|
|
||||||
const db = getDb()
|
const db = getDb()
|
||||||
const item = db
|
const item = db
|
||||||
.prepare('SELECT item_key, item_type, file_path, metadata FROM media_items WHERE item_key = ?')
|
.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')]
|
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)
|
const description = await callVisionApiText(config.endpoint, describeModel, base64Images, systemPrompt)
|
||||||
|
|
||||||
@@ -544,13 +551,13 @@ export async function generateItemDescription(itemKey: string): Promise<string>
|
|||||||
* Returns { extractedText, translatedText }.
|
* Returns { extractedText, translatedText }.
|
||||||
*/
|
*/
|
||||||
export async function extractItemText(itemKey: string): Promise<{ extractedText: string; translatedText: string | null }> {
|
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
|
const extractModel = config.modelExtract || config.model
|
||||||
if (!config.endpoint || !extractModel) {
|
if (!config.endpoint || !extractModel) {
|
||||||
throw Object.assign(new Error('AI endpoint and model are not configured'), { code: 'NOT_CONFIGURED' })
|
throw Object.assign(new Error('AI endpoint and model are not configured'), { code: 'NOT_CONFIGURED' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const libraryId = itemKey.split(':')[0]
|
|
||||||
const db = getDb()
|
const db = getDb()
|
||||||
const item = db
|
const item = db
|
||||||
.prepare('SELECT item_key, item_type, file_path, metadata FROM media_items WHERE item_key = ?')
|
.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 thumbnailPath = await getThumbnailPath(resolvedMedia.path, libraryId, 'image')
|
||||||
const base64Images = [fs.readFileSync(thumbnailPath, 'base64')]
|
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)
|
const extractedText = await callVisionApiText(config.endpoint, extractModel, base64Images, systemPrompt)
|
||||||
|
|
||||||
@@ -596,7 +603,7 @@ export async function extractItemText(itemKey: string): Promise<{ extractedText:
|
|||||||
if (preferredLanguage) {
|
if (preferredLanguage) {
|
||||||
const translateModel = config.modelTranslate || config.model
|
const translateModel = config.modelTranslate || config.model
|
||||||
try {
|
try {
|
||||||
translatedText = await translateText(config.endpoint, translateModel, extractedText, preferredLanguage)
|
translatedText = await translateText(config.endpoint, translateModel, extractedText, preferredLanguage, config.promptTranslate)
|
||||||
if (translatedText) {
|
if (translatedText) {
|
||||||
db.prepare('UPDATE media_items SET extracted_text_translated = ? WHERE item_key = ?').run(translatedText, itemKey)
|
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.
|
* Returns the translated text or null if no text to translate.
|
||||||
*/
|
*/
|
||||||
export async function translateItemText(itemKey: string): Promise<string | null> {
|
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
|
const translateModel = config.modelTranslate || config.model
|
||||||
if (!config.endpoint || !translateModel) {
|
if (!config.endpoint || !translateModel) {
|
||||||
throw Object.assign(new Error('AI endpoint and model are not configured'), { code: 'NOT_CONFIGURED' })
|
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()
|
const preferredLanguage = getPreferredLanguage()
|
||||||
if (!preferredLanguage) return null
|
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) {
|
if (translatedText) {
|
||||||
db.prepare('UPDATE media_items SET extracted_text_translated = ? WHERE item_key = ?').run(translatedText, itemKey)
|
db.prepare('UPDATE media_items SET extracted_text_translated = ? WHERE item_key = ?').run(translatedText, itemKey)
|
||||||
}
|
}
|
||||||
@@ -649,9 +657,10 @@ async function translateText(
|
|||||||
endpoint: string,
|
endpoint: string,
|
||||||
model: string,
|
model: string,
|
||||||
text: string,
|
text: string,
|
||||||
targetLanguage: string
|
targetLanguage: string,
|
||||||
|
customInstruction = '',
|
||||||
): Promise<string | null> {
|
): 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)
|
const result = await callChatApiText(endpoint, model, systemPrompt, text)
|
||||||
|
|
||||||
@@ -667,7 +676,7 @@ async function translateText(
|
|||||||
* Returns the number of items processed.
|
* Returns the number of items processed.
|
||||||
*/
|
*/
|
||||||
export async function extractDirectoryText(libraryId: string, dirPath: string): Promise<number> {
|
export async function extractDirectoryText(libraryId: string, dirPath: string): Promise<number> {
|
||||||
const config = getAiConfig()
|
const config = getEffectiveAiConfig(libraryId)
|
||||||
const extractModel = config.modelExtract || config.model
|
const extractModel = config.modelExtract || config.model
|
||||||
if (!config.endpoint || !extractModel) {
|
if (!config.endpoint || !extractModel) {
|
||||||
throw Object.assign(new Error('AI endpoint and model are not configured'), { code: 'NOT_CONFIGURED' })
|
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.
|
* Returns the number of items processed.
|
||||||
*/
|
*/
|
||||||
export async function describeDirectoryItems(libraryId: string, dirPath: string): Promise<number> {
|
export async function describeDirectoryItems(libraryId: string, dirPath: string): Promise<number> {
|
||||||
const config = getAiConfig()
|
const config = getEffectiveAiConfig(libraryId)
|
||||||
const describeModel = config.modelDescribe || config.model
|
const describeModel = config.modelDescribe || config.model
|
||||||
if (!config.endpoint || !describeModel) {
|
if (!config.endpoint || !describeModel) {
|
||||||
throw Object.assign(new Error('AI endpoint and model are not configured'), { code: 'NOT_CONFIGURED' })
|
throw Object.assign(new Error('AI endpoint and model are not configured'), { code: 'NOT_CONFIGURED' })
|
||||||
|
|||||||
@@ -39,7 +39,14 @@ export function setScanLastRan(ts: number): void {
|
|||||||
|
|
||||||
// ─── AI Settings ─────────────────────────────────────────────────────────────
|
// ─── 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
|
endpoint: string
|
||||||
model: string
|
model: string
|
||||||
modelTagging: string
|
modelTagging: string
|
||||||
@@ -47,6 +54,10 @@ interface AiConfig {
|
|||||||
modelExtract: string
|
modelExtract: string
|
||||||
modelTranslate: string
|
modelTranslate: string
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
|
promptDescribe: string
|
||||||
|
promptTagger: string
|
||||||
|
promptExtract: string
|
||||||
|
promptTranslate: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAiConfig(): AiConfig {
|
export function getAiConfig(): AiConfig {
|
||||||
@@ -57,7 +68,18 @@ export function getAiConfig(): AiConfig {
|
|||||||
const modelExtract = getSetting('ai_model_extract') ?? ''
|
const modelExtract = getSetting('ai_model_extract') ?? ''
|
||||||
const modelTranslate = getSetting('ai_model_translate') ?? ''
|
const modelTranslate = getSetting('ai_model_translate') ?? ''
|
||||||
const enabled = getSetting('ai_enabled') === 'true'
|
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(
|
export function updateAiConfig(
|
||||||
@@ -68,6 +90,10 @@ export function updateAiConfig(
|
|||||||
modelDescribe?: string,
|
modelDescribe?: string,
|
||||||
modelExtract?: string,
|
modelExtract?: string,
|
||||||
modelTranslate?: string,
|
modelTranslate?: string,
|
||||||
|
promptDescribe?: string,
|
||||||
|
promptTagger?: string,
|
||||||
|
promptExtract?: string,
|
||||||
|
promptTranslate?: string,
|
||||||
): void {
|
): void {
|
||||||
setSetting('ai_endpoint', endpoint)
|
setSetting('ai_endpoint', endpoint)
|
||||||
setSetting('ai_model', model)
|
setSetting('ai_model', model)
|
||||||
@@ -76,6 +102,10 @@ export function updateAiConfig(
|
|||||||
if (modelDescribe !== undefined) setSetting('ai_model_describe', modelDescribe)
|
if (modelDescribe !== undefined) setSetting('ai_model_describe', modelDescribe)
|
||||||
if (modelExtract !== undefined) setSetting('ai_model_extract', modelExtract)
|
if (modelExtract !== undefined) setSetting('ai_model_extract', modelExtract)
|
||||||
if (modelTranslate !== undefined) setSetting('ai_model_translate', modelTranslate)
|
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 {
|
export function getPreferredLanguage(): string {
|
||||||
@@ -85,3 +115,90 @@ export function getPreferredLanguage(): string {
|
|||||||
export function setPreferredLanguage(language: string): void {
|
export function setPreferredLanguage(language: string): void {
|
||||||
setSetting('preferred_language', language)
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ function initDb(db: Database.Database): void {
|
|||||||
migrateMediaTagsToItemKey(db)
|
migrateMediaTagsToItemKey(db)
|
||||||
migrateMediaItemsAiTagged(db)
|
migrateMediaItemsAiTagged(db)
|
||||||
migrateMediaItemsAiFields(db)
|
migrateMediaItemsAiFields(db)
|
||||||
|
migrateLibraryAiSettings(db)
|
||||||
seedAppSettings(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 {
|
function migrateLibrariesType(db: Database.Database): void {
|
||||||
const row = db
|
const row = db
|
||||||
.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='libraries'")
|
.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='libraries'")
|
||||||
|
|||||||
Reference in New Issue
Block a user