+ {/* Kebab menu — bottom-right, shown on hover */}
+ {(onDelete || onRename || (onAiTag && entry.mediaType === 'image') || (onExtractText && entry.mediaType === 'image') || (onExtractText && entry.type === 'directory') || (onDescribe && (entry.mediaType === 'image' || entry.mediaType === 'video' || entry.type === 'directory'))) && (
+
{menuOpen && (
{onAiTag && entry.mediaType === 'image' && (
@@ -654,6 +684,46 @@ function EntryTile({ entry, onOpen, onTag, onDelete, onRename, onAiTag, onExtrac
✨ AI Tag
)}
+ {onDescribe && (entry.mediaType === 'image' || entry.mediaType === 'video') && (
+
+ )}
+ {onDescribe && entry.type === 'directory' && (
+
+ )}
{onExtractText && entry.mediaType === 'image' && (
)}
+ {/* Description generation status overlay */}
+ {(describing || describeError) && (
+
e.stopPropagation()}
+ >
+
+ {describeError ?? 'Generating description…'}
+
+ {describeError && (
+
+ )}
+
+ )}
+
{/* Delete confirmation overlay */}
{confirming && (
{
const config = getAiConfig()
- if (!config.enabled || !config.endpoint || !config.model) return
+ const taggingModel = config.modelTagging || config.model
+ if (!config.enabled || !config.endpoint || !taggingModel) return
const activeCategoryIds = new Set(getActiveCategoryIdsForLibrary(library.id))
const allTags = getTags()
@@ -285,7 +286,7 @@ export async function runAiTagging(library: Library, libraryRoot: string): Promi
extractedText: aiFields.extractedTextTranslated ?? aiFields.extractedText,
})
- const suggestedIds = await callVisionApi(config.endpoint, config.model, base64Images, systemPrompt)
+ const suggestedIds = await callVisionApi(config.endpoint, taggingModel, base64Images, systemPrompt)
// Filter to valid tags only
const validIds = suggestedIds.filter((id) => validTagIds.has(id))
@@ -317,7 +318,8 @@ export async function runAiTagging(library: Library, libraryRoot: string): Promi
*/
export async function tagSingleItem(itemKey: string): Promise {
const config = getAiConfig()
- if (!config.endpoint || !config.model) {
+ 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' })
}
@@ -372,7 +374,7 @@ export async function tagSingleItem(itemKey: string): Promise {
extractedText: aiFields.extractedTextTranslated ?? aiFields.extractedText,
})
- const suggestedIds = await callVisionApi(config.endpoint, config.model, base64Images, systemPromptWithContext)
+ const suggestedIds = await callVisionApi(config.endpoint, taggingModel, base64Images, systemPromptWithContext)
const validIds = suggestedIds.filter((id) => validTagIds.has(id))
for (const tagId of validIds) {
@@ -490,7 +492,8 @@ async function callChatApiText(
*/
export async function generateItemDescription(itemKey: string): Promise {
const config = getAiConfig()
- if (!config.endpoint || !config.model) {
+ 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' })
}
@@ -525,7 +528,7 @@ export async function generateItemDescription(itemKey: string): Promise
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 description = await callVisionApiText(config.endpoint, config.model, base64Images, systemPrompt)
+ const description = await callVisionApiText(config.endpoint, describeModel, base64Images, systemPrompt)
db.prepare('UPDATE media_items SET ai_description = ? WHERE item_key = ?').run(description, itemKey)
@@ -542,7 +545,8 @@ export async function generateItemDescription(itemKey: string): Promise
*/
export async function extractItemText(itemKey: string): Promise<{ extractedText: string; translatedText: string | null }> {
const config = getAiConfig()
- if (!config.endpoint || !config.model) {
+ 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' })
}
@@ -577,7 +581,7 @@ export async function extractItemText(itemKey: string): Promise<{ extractedText:
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 extractedText = await callVisionApiText(config.endpoint, config.model, base64Images, systemPrompt)
+ const extractedText = await callVisionApiText(config.endpoint, extractModel, base64Images, systemPrompt)
if (!extractedText || extractedText === '[NO TEXT]') {
db.prepare('UPDATE media_items SET extracted_text = NULL, extracted_text_translated = NULL WHERE item_key = ?').run(itemKey)
@@ -590,8 +594,9 @@ export async function extractItemText(itemKey: string): Promise<{ extractedText:
const preferredLanguage = getPreferredLanguage()
let translatedText: string | null = null
if (preferredLanguage) {
+ const translateModel = config.modelTranslate || config.model
try {
- translatedText = await translateText(config.endpoint, config.model, extractedText, preferredLanguage)
+ translatedText = await translateText(config.endpoint, translateModel, extractedText, preferredLanguage)
if (translatedText) {
db.prepare('UPDATE media_items SET extracted_text_translated = ? WHERE item_key = ?').run(translatedText, itemKey)
}
@@ -609,7 +614,8 @@ export async function extractItemText(itemKey: string): Promise<{ extractedText:
*/
export async function translateItemText(itemKey: string): Promise {
const config = getAiConfig()
- if (!config.endpoint || !config.model) {
+ 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' })
}
@@ -627,7 +633,7 @@ export async function translateItemText(itemKey: string): Promise
const preferredLanguage = getPreferredLanguage()
if (!preferredLanguage) return null
- const translatedText = await translateText(config.endpoint, config.model, row.extracted_text, preferredLanguage)
+ const translatedText = await translateText(config.endpoint, translateModel, row.extracted_text, preferredLanguage)
if (translatedText) {
db.prepare('UPDATE media_items SET extracted_text_translated = ? WHERE item_key = ?').run(translatedText, itemKey)
}
@@ -662,7 +668,8 @@ async function translateText(
*/
export async function extractDirectoryText(libraryId: string, dirPath: string): Promise {
const config = getAiConfig()
- if (!config.endpoint || !config.model) {
+ 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' })
}
@@ -706,6 +713,55 @@ export async function extractDirectoryText(libraryId: string, dirPath: string):
return processed
}
+/**
+ * Generate AI descriptions for all media items in a directory within a mixed library.
+ * Returns the number of items processed.
+ */
+export async function describeDirectoryItems(libraryId: string, dirPath: string): Promise {
+ const config = getAiConfig()
+ 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 library = getLibrary(libraryId)
+ if (!library) {
+ throw Object.assign(new Error(`Library not found: ${libraryId}`), { code: 'NOT_FOUND' })
+ }
+ if (library.type !== 'mixed') {
+ throw Object.assign(new Error('Description generation is only available for mixed libraries'), { code: 'INVALID_TYPE' })
+ }
+
+ const db = getDb()
+ const prefix = dirPath
+ ? `${libraryId}:mixed_file:${encodeURIComponent(dirPath + '/')}`
+ : `${libraryId}:mixed_file:`
+
+ const items = db
+ .prepare('SELECT item_key, item_type, file_path, metadata FROM media_items WHERE item_key LIKE ? AND item_type = ?')
+ .all(`${prefix}%`, 'mixed_file') as MediaItemRow[]
+
+ let processed = 0
+
+ for (const item of items) {
+ if (!item.file_path) continue
+ const ext = path.extname(item.file_path).toLowerCase()
+ if (!IMAGE_EXTENSIONS.has(ext) && !VIDEO_EXTENSIONS.has(ext)) continue
+
+ try {
+ await generateItemDescription(item.item_key)
+ processed++
+ } catch (err) {
+ console.warn(
+ `[ai-tagger] Failed to describe "${item.item_key}":`,
+ err instanceof Error ? err.message : err
+ )
+ }
+ }
+
+ return processed
+}
+
/**
* Get the AI fields (description, extracted text, translation) for a media item.
*/
diff --git a/src/lib/app-settings.ts b/src/lib/app-settings.ts
index 59d982e..cd1517d 100644
--- a/src/lib/app-settings.ts
+++ b/src/lib/app-settings.ts
@@ -42,20 +42,40 @@ export function setScanLastRan(ts: number): void {
interface AiConfig {
endpoint: string
model: string
+ modelTagging: string
+ modelDescribe: string
+ modelExtract: string
+ modelTranslate: string
enabled: boolean
}
export function getAiConfig(): AiConfig {
const endpoint = getSetting('ai_endpoint') ?? ''
const model = getSetting('ai_model') ?? ''
+ const modelTagging = getSetting('ai_model_tagging') ?? ''
+ const modelDescribe = getSetting('ai_model_describe') ?? ''
+ const modelExtract = getSetting('ai_model_extract') ?? ''
+ const modelTranslate = getSetting('ai_model_translate') ?? ''
const enabled = getSetting('ai_enabled') === 'true'
- return { endpoint, model, enabled }
+ return { endpoint, model, modelTagging, modelDescribe, modelExtract, modelTranslate, enabled }
}
-export function updateAiConfig(endpoint: string, model: string, enabled: boolean): void {
+export function updateAiConfig(
+ endpoint: string,
+ model: string,
+ enabled: boolean,
+ modelTagging?: string,
+ modelDescribe?: string,
+ modelExtract?: string,
+ modelTranslate?: string,
+): void {
setSetting('ai_endpoint', endpoint)
setSetting('ai_model', model)
setSetting('ai_enabled', enabled ? 'true' : 'false')
+ if (modelTagging !== undefined) setSetting('ai_model_tagging', modelTagging)
+ if (modelDescribe !== undefined) setSetting('ai_model_describe', modelDescribe)
+ if (modelExtract !== undefined) setSetting('ai_model_extract', modelExtract)
+ if (modelTranslate !== undefined) setSetting('ai_model_translate', modelTranslate)
}
export function getPreferredLanguage(): string {