ai-feature-setup #20
@@ -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) {
|
||||
|
||||
@@ -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.')
|
||||
|
||||
Reference in New Issue
Block a user