ai-feature-setup #20

Merged
gpatti merged 4 commits from ai-feature-setup into main 2026-04-12 21:24:57 +00:00
2 changed files with 62 additions and 19 deletions
Showing only changes of commit ad9920a448 - Show all commits

View File

@@ -3,7 +3,7 @@ import path from 'path'
import type { Library, Tag, TagCategory } from '@/types'
import { getDb } from './db'
import { getAiConfig } from './app-settings'
import { getTags, getCategories, addTagToItem } from './tags'
import { getTags, getCategories, addTagToItem, getActiveCategoryIdsForLibrary, getResolvedTagsForItem } from './tags'
import { getThumbnailPath } from './thumbnails'
import { findFile } from './media-utils'
import { getLibrary, resolveLibraryRoot } from './libraries'
@@ -89,8 +89,10 @@ function resolveItemImage(libraryRoot: string, item: MediaItemRow): string | nul
/**
* Build the system prompt that instructs the LLM to select matching tags.
* If currentTags are provided they are included as context to help the model
* understand the image before selecting additional tags.
*/
function buildTagPrompt(tags: Tag[], categories: TagCategory[]): string {
function buildTagPrompt(tags: Tag[], categories: TagCategory[], currentTags?: Tag[]): string {
const categoryMap = new Map(categories.map((c) => [c.id, c.name]))
const grouped: Record<string, { id: string; name: string }[]> = {}
@@ -105,14 +107,24 @@ function buildTagPrompt(tags: Tag[], categories: TagCategory[]): string {
lines.push(`[${catName}] ${tagList}`)
}
return [
const parts: string[] = [
'You are an image tagger. Given the image, select which of the following tags apply.',
'Return ONLY a JSON array of tag IDs that match the image. Do not invent new tags.',
'If no tags match, return an empty array: []',
'',
'Available tags:',
...lines,
].join('\n')
'Return ONLY a JSON array of tag IDs that match the image (e.g., ["tag-apple", "tag-orange"]). Do not invent new tags. Do not return any text other than what is inside the JSON array.',
'If no tags match, return an empty array (e.i., [])',
]
if (currentTags && currentTags.length > 0) {
const currentTagNames = currentTags.map((t) => t.name).join(', ')
parts.push('')
parts.push(`This image already has the following tags applied: ${currentTagNames}`)
parts.push('Use these as context to better understand the image when selecting tags.')
}
parts.push('')
parts.push('Available tags:')
parts.push(...lines)
return parts.join('\n')
}
/**
@@ -148,7 +160,7 @@ async function callVisionApi(
],
},
],
max_tokens: 512,
max_tokens: 8192,
temperature: 0.1,
}),
})
@@ -184,12 +196,15 @@ export async function runAiTagging(library: Library, libraryRoot: string): Promi
const config = getAiConfig()
if (!config.enabled || !config.endpoint || !config.model) return
const tags = getTags()
const categories = getCategories()
const activeCategoryIds = new Set(getActiveCategoryIdsForLibrary(library.id))
const allTags = getTags()
const allCategories = getCategories()
const tags = allTags.filter((t) => activeCategoryIds.has(t.categoryId))
const categories = allCategories.filter((c) => activeCategoryIds.has(c.id))
if (tags.length === 0) return
const validTagIds = new Set(tags.map((t) => t.id))
const systemPrompt = buildTagPrompt(tags, categories)
const db = getDb()
const untaggedItems = db
@@ -227,6 +242,9 @@ export async function runAiTagging(library: Library, libraryRoot: string): Promi
const thumbnailPath = await getThumbnailPath(imagePath, library.id, 'image')
const base64 = fs.readFileSync(thumbnailPath, 'base64')
const { tags: currentItemTags } = getResolvedTagsForItem(item.item_key)
const systemPrompt = buildTagPrompt(tags, categories, currentItemTags)
const suggestedIds = await callVisionApi(config.endpoint, config.model, base64, systemPrompt)
// Filter to valid tags only
@@ -263,14 +281,19 @@ export async function tagSingleItem(itemKey: string): Promise<string[]> {
throw Object.assign(new Error('AI tagging endpoint and model are not configured'), { code: 'NOT_CONFIGURED' })
}
const tags = getTags()
const categories = getCategories()
const libraryId = itemKey.split(':')[0]
const activeCategoryIds = new Set(getActiveCategoryIdsForLibrary(libraryId))
const allTags = getTags()
const allCategories = getCategories()
const tags = allTags.filter((t) => activeCategoryIds.has(t.categoryId))
const categories = allCategories.filter((c) => activeCategoryIds.has(c.id))
if (tags.length === 0) {
return []
}
const validTagIds = new Set(tags.map((t) => t.id))
const systemPrompt = buildTagPrompt(tags, categories)
const db = getDb()
const item = db
@@ -280,8 +303,6 @@ export async function tagSingleItem(itemKey: string): Promise<string[]> {
if (!item) {
throw Object.assign(new Error(`Item not found: ${itemKey}`), { code: 'NOT_FOUND' })
}
const libraryId = itemKey.split(':')[0]
const library = getLibrary(libraryId)
if (!library) {
throw Object.assign(new Error(`Library not found: ${libraryId}`), { code: 'NOT_FOUND' })
@@ -296,7 +317,10 @@ export async function tagSingleItem(itemKey: string): Promise<string[]> {
const thumbnailPath = await getThumbnailPath(imagePath, libraryId, 'image')
const base64 = fs.readFileSync(thumbnailPath, 'base64')
const suggestedIds = await callVisionApi(config.endpoint, config.model, base64, systemPrompt)
const { tags: currentItemTags } = getResolvedTagsForItem(itemKey)
const systemPromptWithContext = buildTagPrompt(tags, categories, currentItemTags)
const suggestedIds = await callVisionApi(config.endpoint, config.model, base64, systemPromptWithContext)
const validIds = suggestedIds.filter((id) => validTagIds.has(id))
for (const tagId of validIds) {

View File

@@ -18,6 +18,25 @@ export function getCategories(): TagCategory[] {
return db.prepare('SELECT id, name FROM tag_categories ORDER BY name').all() as TagCategory[]
}
/**
* Returns the distinct category IDs that have at least one tag assigned to any
* item in the given library. Used by the AI tagger to restrict the tag prompt
* to categories that are actually in use within the target library.
*/
export function getActiveCategoryIdsForLibrary(libraryId: string): string[] {
const db = getDb()
const rows = db
.prepare(
`SELECT DISTINCT t.category_id
FROM tags t
JOIN media_tags mt ON mt.tag_id = t.id
JOIN media_items mi ON mi.item_key = mt.item_key
WHERE mi.library_id = ?`
)
.all(libraryId) as { category_id: string }[]
return rows.map((r) => r.category_id)
}
export function addCategory(name: string): TagCategory {
const trimmed = name.trim()
if (!trimmed) throw new Error('Category name is required.')