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)
|
||||
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()) {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -38,6 +38,9 @@ export default function MixedView({ libraryId, initialPath }: Props) {
|
||||
const [recursiveLoaded, setRecursiveLoaded] = useState(false)
|
||||
const [doomScrollActive, setDoomScrollActive] = useState(false)
|
||||
const [doomScrollLoading, setDoomScrollLoading] = useState(false)
|
||||
const [doomScrollEntries, setDoomScrollEntries] = useState<FileEntry[]>([])
|
||||
const [doomScrollEntriesLoading, setDoomScrollEntriesLoading] = useState(false)
|
||||
const [doomScrollEntriesLoaded, setDoomScrollEntriesLoaded] = useState(false)
|
||||
|
||||
const toggleTag = (tagId: string) =>
|
||||
setSelectedTagIds((prev) => {
|
||||
@@ -71,6 +74,14 @@ export default function MixedView({ libraryId, initialPath }: Props) {
|
||||
loadPath(initialPath)
|
||||
}, [loadPath, initialPath])
|
||||
|
||||
// Invalidate doom scroll entry cache when the user navigates to a different directory
|
||||
useEffect(() => {
|
||||
setDoomScrollEntries([])
|
||||
setDoomScrollEntriesLoaded(false)
|
||||
setDoomScrollEntriesLoading(false)
|
||||
setDoomScrollLoading(false)
|
||||
}, [currentPath])
|
||||
|
||||
const fetchAssignments = useCallback(() => {
|
||||
fetch(`/api/tags/library-assignments?libraryId=${encodeURIComponent(libraryId)}`)
|
||||
.then((r) => r.json())
|
||||
@@ -95,6 +106,21 @@ export default function MixedView({ libraryId, initialPath }: Props) {
|
||||
.finally(() => setRecursiveLoading(false))
|
||||
}, [libraryId, recursiveLoaded, recursiveLoading])
|
||||
|
||||
const fetchDoomScrollEntries = useCallback(() => {
|
||||
if (doomScrollEntriesLoaded || doomScrollEntriesLoading) return
|
||||
setDoomScrollEntriesLoading(true)
|
||||
fetch(
|
||||
`/api/browse?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(currentPath)}&recursive=true`
|
||||
)
|
||||
.then((r) => r.json())
|
||||
.then((data: DirectoryListing) => {
|
||||
setDoomScrollEntries(data.entries)
|
||||
setDoomScrollEntriesLoaded(true)
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setDoomScrollEntriesLoading(false))
|
||||
}, [libraryId, currentPath, doomScrollEntriesLoaded, doomScrollEntriesLoading])
|
||||
|
||||
// Fetch the full recursive listing the first time any filter becomes active
|
||||
useEffect(() => {
|
||||
if (!filtersActive) return
|
||||
@@ -182,25 +208,33 @@ export default function MixedView({ libraryId, initialPath }: Props) {
|
||||
fetchRecursive()
|
||||
return
|
||||
}
|
||||
if (recursiveLoaded) {
|
||||
// No filters: scope to current directory
|
||||
if (doomScrollEntriesLoaded) {
|
||||
setDoomScrollActive(true)
|
||||
return
|
||||
}
|
||||
setDoomScrollLoading(true)
|
||||
fetchRecursive()
|
||||
fetchDoomScrollEntries()
|
||||
}
|
||||
|
||||
// Activate doom scroll once the recursive listing finishes loading (when triggered by button)
|
||||
// Activate doom scroll once the appropriate listing finishes loading (when triggered by button)
|
||||
useEffect(() => {
|
||||
if (doomScrollLoading && !recursiveLoading && recursiveLoaded) {
|
||||
if (!doomScrollLoading) return
|
||||
const filtersDone = filtersActive && !recursiveLoading && recursiveLoaded
|
||||
const noFiltersDone = !filtersActive && !doomScrollEntriesLoading && doomScrollEntriesLoaded
|
||||
if (filtersDone || noFiltersDone) {
|
||||
setDoomScrollLoading(false)
|
||||
setDoomScrollActive(true)
|
||||
}
|
||||
}, [doomScrollLoading, recursiveLoading, recursiveLoaded])
|
||||
}, [
|
||||
doomScrollLoading, filtersActive,
|
||||
recursiveLoading, recursiveLoaded,
|
||||
doomScrollEntriesLoading, doomScrollEntriesLoaded,
|
||||
])
|
||||
|
||||
// When filters are active, doom scroll uses filteredEntries (already filtered by search/tags).
|
||||
// When no filters, doom scroll uses the full recursiveEntries.
|
||||
const doomScrollItems: DoomScrollItem[] = (filtersActive ? filteredEntries : recursiveEntries)
|
||||
// When no filters, doom scroll uses files recursively under the current directory.
|
||||
const doomScrollItems: DoomScrollItem[] = (filtersActive ? filteredEntries : doomScrollEntries)
|
||||
.filter((e) => e.type === 'file' && (e.mediaType === 'video' || e.mediaType === 'image') && e.url && isBrowserPlayable(e.name))
|
||||
.map((e) => ({ url: e.url!, name: e.name, mediaType: e.mediaType as 'video' | 'image' }))
|
||||
|
||||
|
||||
@@ -298,9 +298,13 @@ export default function TagSelector({ itemKey, onTagsChanged, refreshKey }: Prop
|
||||
{all.categories.map((category) => {
|
||||
const categoryTags = all.tags.filter((t) => t.categoryId === category.id)
|
||||
const search = categorySearches[category.id] ?? ''
|
||||
const visibleTags = categoryTags
|
||||
.filter((t) => !search || t.name.toLowerCase().includes(search.toLowerCase()))
|
||||
.slice(0, 25)
|
||||
const filtered = categoryTags.filter(
|
||||
(t) => !search || t.name.toLowerCase().includes(search.toLowerCase())
|
||||
)
|
||||
const visibleTags = [
|
||||
...filtered.filter((t) => isAssigned(t.id)),
|
||||
...filtered.filter((t) => !isAssigned(t.id)),
|
||||
].slice(0, 25)
|
||||
|
||||
return (
|
||||
<div key={category.id}>
|
||||
|
||||
@@ -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,11 @@ 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 { tags: currentTags } = getResolvedTagsForItem(itemKey)
|
||||
const tagContext = currentTags.length > 0
|
||||
? ` This content has the following tags applied describing it: ${currentTags.map((t) => t.name).join(', ')}. Use these as additional context and treat them as a source of truth, overriding any conflicting assumptions made from the image.`
|
||||
: ''
|
||||
const systemPrompt = `You are a media cataloging assistant. Describe the given image briefly and objectively in 1-3 sentences.${config.promptDescribe ? ' ' + config.promptDescribe : ''}${tagContext}`
|
||||
|
||||
const description = await callVisionApiText(config.endpoint, describeModel, base64Images, systemPrompt)
|
||||
|
||||
@@ -544,13 +555,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 +590,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 +607,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 +624,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 +645,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 +661,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 +680,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 +731,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' })
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'")
|
||||
|
||||
Reference in New Issue
Block a user