Compare commits

..

2 Commits

Author SHA1 Message Date
Garret Patti
1350a6f94b separate text extraction and translation 2026-04-13 17:45:00 -04:00
Garret Patti
2fc9a34626 add configurable max_tokens per AI activity
Allows users to configure the max_tokens sent to the AI endpoint for
each activity (tagging, description, extraction, translation) individually,
with per-library overrides following the same pattern as model overrides.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 13:57:07 -04:00
8 changed files with 281 additions and 91 deletions

View File

@@ -38,6 +38,10 @@ export async function PUT(
promptTagger: typeof body.promptTagger === 'string' ? body.promptTagger : undefined,
promptExtract: typeof body.promptExtract === 'string' ? body.promptExtract : undefined,
promptTranslate: typeof body.promptTranslate === 'string' ? body.promptTranslate : undefined,
maxTokensTag: typeof body.maxTokensTag === 'number' ? body.maxTokensTag : (body.maxTokensTag === null ? null : undefined),
maxTokensDescribe: typeof body.maxTokensDescribe === 'number' ? body.maxTokensDescribe : (body.maxTokensDescribe === null ? null : undefined),
maxTokensExtract: typeof body.maxTokensExtract === 'number' ? body.maxTokensExtract : (body.maxTokensExtract === null ? null : undefined),
maxTokensTranslate: typeof body.maxTokensTranslate === 'number' ? body.maxTokensTranslate : (body.maxTokensTranslate === null ? null : undefined),
})
return NextResponse.json(getLibraryAiOverrides(id))

View File

@@ -30,6 +30,10 @@ export async function PUT(request: NextRequest) {
promptExtract?: string
promptTranslate?: string
maxRetries?: number
maxTokensTag?: number
maxTokensDescribe?: number
maxTokensExtract?: number
maxTokensTranslate?: number
}
try {
body = await request.json()
@@ -42,6 +46,7 @@ export async function PUT(request: NextRequest) {
modelTagging, modelDescribe, modelExtract, modelTranslate,
promptDescribe, promptTagger, promptExtract, promptTranslate,
maxRetries,
maxTokensTag, maxTokensDescribe, maxTokensExtract, maxTokensTranslate,
} = body
if (typeof endpoint !== 'string') {
@@ -66,6 +71,10 @@ export async function PUT(request: NextRequest) {
typeof promptTagger === 'string' ? promptTagger : undefined,
typeof promptExtract === 'string' ? promptExtract : undefined,
typeof promptTranslate === 'string' ? promptTranslate : undefined,
typeof maxTokensTag === 'number' ? maxTokensTag : undefined,
typeof maxTokensDescribe === 'number' ? maxTokensDescribe : undefined,
typeof maxTokensExtract === 'number' ? maxTokensExtract : undefined,
typeof maxTokensTranslate === 'number' ? maxTokensTranslate : undefined,
)
if (typeof preferredLanguage === 'string' && preferredLanguage.trim()) {

View File

@@ -16,6 +16,10 @@ interface AiSettings {
promptExtract: string
promptTranslate: string
maxRetries: number
maxTokensTag: number
maxTokensDescribe: number
maxTokensExtract: number
maxTokensTranslate: number
}
interface AiJob {
@@ -47,6 +51,10 @@ interface LibraryOverride {
promptTagger: string
promptExtract: string
promptTranslate: string
maxTokensTag: number | null
maxTokensDescribe: number | null
maxTokensExtract: number | null
maxTokensTranslate: number | null
}
function formatElapsed(startedAt: number): string {
@@ -67,6 +75,7 @@ export default function AiTaggingPage() {
enabled: false, preferredLanguage: 'English',
promptDescribe: '', promptTagger: '', promptExtract: '', promptTranslate: '',
maxRetries: 3,
maxTokensTag: 8192, maxTokensDescribe: 8192, maxTokensExtract: 8192, maxTokensTranslate: 8192,
})
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
@@ -296,7 +305,7 @@ export default function AiTaggingPage() {
}
}
const updateLibraryOverride = (libraryId: string, field: keyof LibraryOverride, value: string) => {
const updateLibraryOverride = (libraryId: string, field: keyof LibraryOverride, value: string | number | null) => {
setLibraryOverrides((prev) => ({
...prev,
[libraryId]: { ...(prev[libraryId] ?? emptyOverride()), [field]: value },
@@ -544,6 +553,25 @@ export default function AiTaggingPage() {
/>
</Field>
<Field label="Tagging Max Tokens">
<input
type="number"
min={1}
value={settings.maxTokensTag}
onChange={(e) =>
setSettings((s) => ({ ...s, maxTokensTag: Math.max(1, parseInt(e.target.value) || 8192) }))
}
className="w-32 rounded-lg px-3 py-2 text-sm 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>
<Field label="Description Model">
<input
type="text"
@@ -561,6 +589,25 @@ export default function AiTaggingPage() {
/>
</Field>
<Field label="Description Max Tokens">
<input
type="number"
min={1}
value={settings.maxTokensDescribe}
onChange={(e) =>
setSettings((s) => ({ ...s, maxTokensDescribe: Math.max(1, parseInt(e.target.value) || 8192) }))
}
className="w-32 rounded-lg px-3 py-2 text-sm 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>
<Field label="Text Extraction Model">
<input
type="text"
@@ -578,6 +625,25 @@ export default function AiTaggingPage() {
/>
</Field>
<Field label="Text Extraction Max Tokens">
<input
type="number"
min={1}
value={settings.maxTokensExtract}
onChange={(e) =>
setSettings((s) => ({ ...s, maxTokensExtract: Math.max(1, parseInt(e.target.value) || 8192) }))
}
className="w-32 rounded-lg px-3 py-2 text-sm 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>
<Field label="Translation Model">
<input
type="text"
@@ -595,6 +661,25 @@ export default function AiTaggingPage() {
/>
</Field>
<Field label="Translation Max Tokens">
<input
type="number"
min={1}
value={settings.maxTokensTranslate}
onChange={(e) =>
setSettings((s) => ({ ...s, maxTokensTranslate: Math.max(1, parseInt(e.target.value) || 8192) }))
}
className="w-32 rounded-lg px-3 py-2 text-sm 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>
<Field label="Automatic Tagging">
<label className="flex items-center gap-3 cursor-pointer select-none">
<div
@@ -890,7 +975,7 @@ export default function AiTaggingPage() {
<Field key={field} label={label}>
<input
type="text"
value={overrides[field]}
value={overrides[field] as string}
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"
@@ -906,6 +991,39 @@ export default function AiTaggingPage() {
))}
</div>
<div className="flex flex-col gap-3">
<p className="text-xs font-medium uppercase tracking-wide" style={{ color: 'var(--text-secondary)' }}>Max Tokens</p>
{(
[
['maxTokensTag', 'Tagging', 'maxTokensTag'] as const,
['maxTokensDescribe', 'Description', 'maxTokensDescribe'] as const,
['maxTokensExtract', 'Text Extraction', 'maxTokensExtract'] as const,
['maxTokensTranslate', 'Translation', 'maxTokensTranslate'] as const,
]
).map(([field, label, globalField]) => (
<Field key={field} label={label}>
<input
type="number"
min={1}
value={overrides[field] ?? ''}
placeholder={`Leave blank to use global default (${settings[globalField]})`}
onChange={(e) => {
const raw = e.target.value
updateLibraryOverride(lib.id, field, raw === '' ? null : Math.max(1, parseInt(raw) || 1))
}}
className="w-40 rounded-lg px-3 py-2 text-sm 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>
{(
@@ -919,7 +1037,7 @@ export default function AiTaggingPage() {
<Field key={field} label={label}>
<textarea
rows={3}
value={overrides[field]}
value={overrides[field] as string}
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"
@@ -1010,6 +1128,7 @@ function emptyOverride(): LibraryOverride {
return {
modelTagging: '', modelDescribe: '', modelExtract: '', modelTranslate: '',
promptDescribe: '', promptTagger: '', promptExtract: '', promptTranslate: '',
maxTokensTag: null, maxTokensDescribe: null, maxTokensExtract: null, maxTokensTranslate: null,
}
}

View File

@@ -414,7 +414,7 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
}}
>
{retranslating ? '⟳ Translating…' : '🌐 Re-translate'}
{retranslating ? '⟳ Translating…' : translatedText ? '🌐 Re-translate' : '🌐 Translate'}
</button>
</div>
</div>

View File

@@ -453,6 +453,18 @@ export default function MixedView({ libraryId, initialPath }: Props) {
}
}
}}
onTranslate={async (e) => {
const itemKey = itemKeyFor(e)
const res = await fetch('/api/ai-tagging/translate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ itemKey }),
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error((data as { error?: string }).error ?? 'Translation failed')
}
}}
onDelete={(e) => {
const rel = filtersActive ? e.name : (currentPath ? `${currentPath}/${e.name}` : e.name)
fetch(`/api/browse?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(rel)}`, { method: 'DELETE' })
@@ -582,7 +594,7 @@ export default function MixedView({ libraryId, initialPath }: Props) {
)
}
function EntryTile({ entry, onOpen, onTag, onDelete, onRename, onAiTag, onExtractText, onDescribe }: { entry: FileEntry; onOpen: (e: FileEntry) => void; onTag: (e: FileEntry) => void; onDelete?: (e: FileEntry) => void; onRename?: (e: FileEntry, newName: string) => Promise<boolean>; onAiTag?: (e: FileEntry) => Promise<void>; onExtractText?: (e: FileEntry) => Promise<void>; onDescribe?: (e: FileEntry) => Promise<void> }) {
function EntryTile({ entry, onOpen, onTag, onDelete, onRename, onAiTag, onExtractText, onDescribe, onTranslate }: { entry: FileEntry; onOpen: (e: FileEntry) => void; onTag: (e: FileEntry) => void; onDelete?: (e: FileEntry) => void; onRename?: (e: FileEntry, newName: string) => Promise<boolean>; onAiTag?: (e: FileEntry) => Promise<void>; onExtractText?: (e: FileEntry) => Promise<void>; onDescribe?: (e: FileEntry) => Promise<void>; onTranslate?: (e: FileEntry) => Promise<void> }) {
type ImgState = 'loading' | 'loaded' | 'error'
const [imgState, setImgState] = useState<ImgState>(
entry.thumbnailUrl ? 'loading' : 'error'
@@ -601,6 +613,8 @@ function EntryTile({ entry, onOpen, onTag, onDelete, onRename, onAiTag, onExtrac
const [textExtractError, setTextExtractError] = useState<string | null>(null)
const [describing, setDescribing] = useState(false)
const [describeError, setDescribeError] = useState<string | null>(null)
const [translating, setTranslating] = useState(false)
const [translateError, setTranslateError] = useState<string | null>(null)
useEffect(() => {
if (!menuOpen) return
@@ -830,6 +844,26 @@ function EntryTile({ entry, onOpen, onTag, onDelete, onRename, onAiTag, onExtrac
🔍 Extract Text for Folder
</button>
)}
{onTranslate && entry.mediaType === 'image' && (
<button
onClick={(e) => {
e.stopPropagation()
setMenuOpen(false)
setTranslating(true)
setTranslateError(null)
onTranslate(entry)
.catch((err) => setTranslateError(err instanceof Error ? err.message : 'Translation failed'))
.finally(() => setTranslating(false))
}}
disabled={translating}
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors disabled:opacity-50"
style={{ color: 'var(--text-primary)' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
>
🌐 Translate
</button>
)}
{onRename && (
<button
onClick={(e) => {
@@ -929,6 +963,28 @@ function EntryTile({ entry, onOpen, onTag, onDelete, onRename, onAiTag, onExtrac
</div>
)}
{/* Translation status overlay */}
{(translating || translateError) && (
<div
className="absolute inset-x-0 bottom-0 z-10 px-2 py-1.5 text-xs"
style={{ backgroundColor: translateError ? 'rgba(127,29,29,0.9)' : 'rgba(0,0,0,0.75)' }}
onClick={(e) => e.stopPropagation()}
>
<span style={{ color: translateError ? '#fca5a5' : 'var(--text-secondary)' }}>
{translateError ?? 'Translating…'}
</span>
{translateError && (
<button
onClick={() => setTranslateError(null)}
className="ml-2 underline text-xs"
style={{ color: '#fca5a5' }}
>
dismiss
</button>
)}
</div>
)}
{/* Delete confirmation overlay */}
{confirming && (
<div

View File

@@ -171,7 +171,8 @@ async function callVisionApi(
endpoint: string,
model: string,
base64Images: string[],
systemPrompt: string
systemPrompt: string,
maxTokens: number,
): Promise<string[]> {
const url = endpoint.replace(/\/+$/, '') + '/chat/completions'
@@ -195,7 +196,7 @@ async function callVisionApi(
})),
},
],
max_tokens: 8192,
max_tokens: maxTokens,
temperature: 0.1,
}),
})
@@ -338,7 +339,7 @@ export async function tagSingleItem(itemKey: string): Promise<string[]> {
customInstruction: config.promptTagger || undefined,
})
const suggestedIds = await callVisionApi(config.endpoint, taggingModel, base64Images, systemPromptWithContext)
const suggestedIds = await callVisionApi(config.endpoint, taggingModel, base64Images, systemPromptWithContext, config.maxTokensTag)
const validIds = suggestedIds.filter((id) => validTagIds.has(id))
for (const tagId of validIds) {
@@ -359,7 +360,8 @@ async function callVisionApiText(
endpoint: string,
model: string,
base64Images: string[],
systemPrompt: string
systemPrompt: string,
maxTokens: number,
): Promise<string> {
const url = endpoint.replace(/\/+$/, '') + '/chat/completions'
@@ -383,7 +385,7 @@ async function callVisionApiText(
})),
},
],
max_tokens: 8192,
max_tokens: maxTokens,
temperature: 0.1,
}),
})
@@ -410,7 +412,8 @@ async function callChatApiText(
endpoint: string,
model: string,
systemPrompt: string,
userMessage: string
userMessage: string,
maxTokens: number,
): Promise<string> {
const url = endpoint.replace(/\/+$/, '') + '/chat/completions'
@@ -428,7 +431,7 @@ async function callChatApiText(
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userMessage },
],
max_tokens: 8192,
max_tokens: maxTokens,
temperature: 0.1,
}),
})
@@ -496,7 +499,7 @@ export async function generateItemDescription(itemKey: string): Promise<string>
: ''
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)
const description = await callVisionApiText(config.endpoint, describeModel, base64Images, systemPrompt, config.maxTokensDescribe)
db.prepare('UPDATE media_items SET ai_description = ? WHERE item_key = ?').run(description, itemKey)
@@ -508,27 +511,9 @@ export async function generateItemDescription(itemKey: string): Promise<string>
/**
* Extract text (OCR) from an image using the vision model.
* Only works for images in mixed libraries.
* If the extracted text is not in the user's preferred language, auto-translates it.
* Returns { extractedText, translatedText }.
* Translation is not performed automatically — call translateItemText() separately.
* Returns { extractedText, translatedText } where translatedText is always null.
*/
/**
* Parse a structured extraction response from the AI.
* Returns null if the response cannot be parsed as valid JSON with the expected shape.
*/
function parseStructuredExtraction(raw: string): { text: string; needsTranslation: boolean } | null {
const jsonMatch = raw.match(/\{[\s\S]*\}/)
if (!jsonMatch) return null
try {
const parsed = JSON.parse(jsonMatch[0])
if (typeof parsed.text === 'string' && typeof parsed.needsTranslation === 'boolean') {
return { text: parsed.text, needsTranslation: parsed.needsTranslation }
}
} catch {
// fall through
}
return null
}
export async function extractItemText(itemKey: string): Promise<{ extractedText: string; translatedText: string | null }> {
const libraryId = itemKey.split(':')[0]
const config = getEffectiveAiConfig(libraryId)
@@ -565,69 +550,19 @@ export async function extractItemText(itemKey: string): Promise<{ extractedText:
const thumbnailPath = await getAiImagePath(resolvedMedia.path, libraryId)
const base64Images = [fs.readFileSync(thumbnailPath, 'base64')]
const preferredLanguage = getPreferredLanguage()
const customInstruction = config.promptExtract ? ' ' + config.promptExtract : ''
const systemPrompt = `You are an OCR assistant. Extract ALL text visible in the image exactly as it appears. Preserve line breaks and formatting.${customInstruction} If there is no text in the image, respond with exactly: [NO TEXT]`
// When a preferred language is configured, ask the AI to also flag whether translation is needed.
// This avoids a separate translation API call for text already in the target language.
let systemPrompt: string
if (preferredLanguage) {
systemPrompt = `You are an OCR assistant. Extract ALL text visible in the image exactly as it appears. Preserve line breaks and formatting.${customInstruction}
Respond ONLY with a valid JSON object — no markdown, no explanation:
{"needsTranslation": boolean, "text": "extracted text"}
Rules:
- Set needsTranslation to true if the text is NOT already written in ${preferredLanguage}.
- Set needsTranslation to false if the text IS in ${preferredLanguage}, or if there is no text.
- If there is no text in the image, use exactly: {"needsTranslation": false, "text": "[NO TEXT]"}`
} else {
systemPrompt = `You are an OCR assistant. Extract ALL text visible in the image exactly as it appears. Preserve line breaks and formatting.${customInstruction} If there is no text in the image, respond with exactly: [NO TEXT]`
}
const rawResponse = await callVisionApiText(config.endpoint, extractModel, base64Images, systemPrompt)
// Parse the response — structured JSON when a preferred language is set, plain text otherwise
let extractedText: string
let needsTranslation: boolean
if (preferredLanguage) {
const parsed = parseStructuredExtraction(rawResponse)
if (parsed) {
extractedText = parsed.text
needsTranslation = parsed.needsTranslation
} else {
// Malformed JSON fallback: treat raw response as plain text and attempt translation
extractedText = rawResponse
needsTranslation = true
}
} else {
extractedText = rawResponse
needsTranslation = false
}
const extractedText = await callVisionApiText(config.endpoint, extractModel, base64Images, systemPrompt, config.maxTokensExtract)
if (!extractedText || extractedText === '[NO TEXT]') {
db.prepare('UPDATE media_items SET extracted_text = NULL, extracted_text_translated = NULL WHERE item_key = ?').run(itemKey)
return { extractedText: '', translatedText: null }
}
db.prepare('UPDATE media_items SET extracted_text = ? WHERE item_key = ?').run(extractedText, itemKey)
db.prepare('UPDATE media_items SET extracted_text = ?, extracted_text_translated = NULL WHERE item_key = ?').run(extractedText, itemKey)
// Only translate if the extraction step determined the text is not already in the preferred language
let translatedText: string | null = null
if (preferredLanguage && needsTranslation) {
const translateModel = config.modelTranslate || config.model
try {
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)
}
} catch (err) {
console.warn(`[ai-tagger] Translation failed for "${itemKey}":`, err instanceof Error ? err.message : err)
}
}
return { extractedText, translatedText }
return { extractedText, translatedText: null }
}
/**
@@ -656,7 +591,7 @@ export async function translateItemText(itemKey: string, sourceLanguage?: string
const preferredLanguage = getPreferredLanguage()
if (!preferredLanguage) return null
const translatedText = await translateText(config.endpoint, translateModel, row.extracted_text, preferredLanguage, config.promptTranslate, sourceLanguage)
const translatedText = await translateText(config.endpoint, translateModel, row.extracted_text, preferredLanguage, config.promptTranslate, config.maxTokensTranslate, sourceLanguage)
if (translatedText) {
db.prepare('UPDATE media_items SET extracted_text_translated = ? WHERE item_key = ?').run(translatedText, itemKey)
}
@@ -682,6 +617,7 @@ async function translateText(
text: string,
targetLanguage: string,
customInstruction = '',
maxTokens = 8192,
sourceLanguage?: string,
): Promise<string | null> {
let systemPrompt: string
@@ -691,7 +627,7 @@ async function translateText(
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, maxTokens)
if (!sourceLanguage && (result === '[ALREADY_TARGET_LANGUAGE]' || !result)) {
return null

View File

@@ -58,6 +58,10 @@ export interface AiConfig {
promptTagger: string
promptExtract: string
promptTranslate: string
maxTokensTag: number
maxTokensDescribe: number
maxTokensExtract: number
maxTokensTranslate: number
}
export function getAiConfig(): AiConfig {
@@ -76,9 +80,14 @@ export function getAiConfig(): AiConfig {
const promptExtract = promptExtractRaw !== null ? promptExtractRaw : DEFAULT_PROMPT_EXTRACT
const promptTranslateRaw = getSetting('ai_prompt_translate')
const promptTranslate = promptTranslateRaw !== null ? promptTranslateRaw : DEFAULT_PROMPT_TRANSLATE
const maxTokensTag = parseInt(getSetting('ai_max_tokens_tag') ?? '8192', 10) || 8192
const maxTokensDescribe = parseInt(getSetting('ai_max_tokens_describe') ?? '8192', 10) || 8192
const maxTokensExtract = parseInt(getSetting('ai_max_tokens_extract') ?? '8192', 10) || 8192
const maxTokensTranslate = parseInt(getSetting('ai_max_tokens_translate') ?? '8192', 10) || 8192
return {
endpoint, model, modelTagging, modelDescribe, modelExtract, modelTranslate, enabled,
promptDescribe, promptTagger, promptExtract, promptTranslate,
maxTokensTag, maxTokensDescribe, maxTokensExtract, maxTokensTranslate,
}
}
@@ -94,6 +103,10 @@ export function updateAiConfig(
promptTagger?: string,
promptExtract?: string,
promptTranslate?: string,
maxTokensTag?: number,
maxTokensDescribe?: number,
maxTokensExtract?: number,
maxTokensTranslate?: number,
): void {
setSetting('ai_endpoint', endpoint)
setSetting('ai_model', model)
@@ -106,6 +119,10 @@ export function updateAiConfig(
if (promptTagger !== undefined) setSetting('ai_prompt_tagger', promptTagger)
if (promptExtract !== undefined) setSetting('ai_prompt_extract', promptExtract)
if (promptTranslate !== undefined) setSetting('ai_prompt_translate', promptTranslate)
if (maxTokensTag !== undefined) setSetting('ai_max_tokens_tag', String(Math.max(1, Math.floor(maxTokensTag))))
if (maxTokensDescribe !== undefined) setSetting('ai_max_tokens_describe', String(Math.max(1, Math.floor(maxTokensDescribe))))
if (maxTokensExtract !== undefined) setSetting('ai_max_tokens_extract', String(Math.max(1, Math.floor(maxTokensExtract))))
if (maxTokensTranslate !== undefined) setSetting('ai_max_tokens_translate', String(Math.max(1, Math.floor(maxTokensTranslate))))
}
export function getPreferredLanguage(): string {
@@ -127,6 +144,10 @@ export interface LibraryAiOverrides {
promptTagger: string
promptExtract: string
promptTranslate: string
maxTokensTag: number | null
maxTokensDescribe: number | null
maxTokensExtract: number | null
maxTokensTranslate: number | null
}
interface LibraryAiSettingsRow {
@@ -138,6 +159,10 @@ interface LibraryAiSettingsRow {
prompt_tagger: string | null
prompt_extract: string | null
prompt_translate: string | null
max_tokens_tag: number | null
max_tokens_describe: number | null
max_tokens_extract: number | null
max_tokens_translate: number | null
}
export function getLibraryAiOverrides(libraryId: string): LibraryAiOverrides {
@@ -154,6 +179,10 @@ export function getLibraryAiOverrides(libraryId: string): LibraryAiOverrides {
promptTagger: row?.prompt_tagger ?? '',
promptExtract: row?.prompt_extract ?? '',
promptTranslate: row?.prompt_translate ?? '',
maxTokensTag: row?.max_tokens_tag ?? null,
maxTokensDescribe: row?.max_tokens_describe ?? null,
maxTokensExtract: row?.max_tokens_extract ?? null,
maxTokensTranslate: row?.max_tokens_translate ?? null,
}
}
@@ -164,7 +193,7 @@ export function setLibraryAiOverrides(libraryId: string, overrides: Partial<Libr
'INSERT OR IGNORE INTO library_ai_settings (library_id) VALUES (?)'
).run(libraryId)
const fields: Record<string, string | undefined> = {
const stringFields: Record<string, string | undefined> = {
model_tagging: overrides.modelTagging,
model_describe: overrides.modelDescribe,
model_extract: overrides.modelExtract,
@@ -175,7 +204,7 @@ export function setLibraryAiOverrides(libraryId: string, overrides: Partial<Libr
prompt_translate: overrides.promptTranslate,
}
for (const [col, val] of Object.entries(fields)) {
for (const [col, val] of Object.entries(stringFields)) {
if (val !== undefined) {
db.prepare(`UPDATE library_ai_settings SET ${col} = ? WHERE library_id = ?`).run(
val === '' ? null : val,
@@ -183,6 +212,22 @@ export function setLibraryAiOverrides(libraryId: string, overrides: Partial<Libr
)
}
}
const numberFields: Record<string, number | null | undefined> = {
max_tokens_tag: overrides.maxTokensTag,
max_tokens_describe: overrides.maxTokensDescribe,
max_tokens_extract: overrides.maxTokensExtract,
max_tokens_translate: overrides.maxTokensTranslate,
}
for (const [col, val] of Object.entries(numberFields)) {
if (val !== undefined) {
db.prepare(`UPDATE library_ai_settings SET ${col} = ? WHERE library_id = ?`).run(
val === null ? null : Math.max(1, Math.floor(val)),
libraryId,
)
}
}
}
export function getEffectiveAiConfig(libraryId: string): AiConfig {
@@ -200,6 +245,10 @@ export function getEffectiveAiConfig(libraryId: string): AiConfig {
promptTagger: overrides.promptTagger || global.promptTagger,
promptExtract: overrides.promptExtract || global.promptExtract,
promptTranslate: overrides.promptTranslate || global.promptTranslate,
maxTokensTag: overrides.maxTokensTag ?? global.maxTokensTag,
maxTokensDescribe: overrides.maxTokensDescribe ?? global.maxTokensDescribe,
maxTokensExtract: overrides.maxTokensExtract ?? global.maxTokensExtract,
maxTokensTranslate: overrides.maxTokensTranslate ?? global.maxTokensTranslate,
}
}

View File

@@ -119,6 +119,10 @@ function seedAppSettings(db: Database.Database): void {
ai_model: '',
preferred_language: 'English',
ai_max_retries: '3',
ai_max_tokens_tag: '8192',
ai_max_tokens_describe: '8192',
ai_max_tokens_extract: '8192',
ai_max_tokens_translate: '8192',
}
const insert = db.prepare(
'INSERT OR IGNORE INTO app_settings (key, value) VALUES (?, ?)'
@@ -276,6 +280,19 @@ function migrateLibraryAiSettings(db: Database.Database): void {
prompt_translate TEXT
);
`)
// Add max_tokens columns if they don't exist yet
const row = db
.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='library_ai_settings'")
.get() as { sql: string } | undefined
if (row && !row.sql.includes('max_tokens_tag')) {
db.exec(`
ALTER TABLE library_ai_settings ADD COLUMN max_tokens_tag INTEGER;
ALTER TABLE library_ai_settings ADD COLUMN max_tokens_describe INTEGER;
ALTER TABLE library_ai_settings ADD COLUMN max_tokens_extract INTEGER;
ALTER TABLE library_ai_settings ADD COLUMN max_tokens_translate INTEGER;
`)
}
}
function migrateLibrariesType(db: Database.Database): void {