ai-feature-setup #20
@@ -3,7 +3,7 @@ import path from 'path'
|
|||||||
import type { Library, Tag, TagCategory } from '@/types'
|
import type { Library, Tag, TagCategory } from '@/types'
|
||||||
import { getDb } from './db'
|
import { getDb } from './db'
|
||||||
import { getAiConfig } from './app-settings'
|
import { getAiConfig } from './app-settings'
|
||||||
import { getTags, getCategories, addTagToItem } from './tags'
|
import { getTags, getCategories, addTagToItem, getActiveCategoryIdsForLibrary, getResolvedTagsForItem } from './tags'
|
||||||
import { getThumbnailPath } from './thumbnails'
|
import { getThumbnailPath } from './thumbnails'
|
||||||
import { findFile } from './media-utils'
|
import { findFile } from './media-utils'
|
||||||
import { getLibrary, resolveLibraryRoot } from './libraries'
|
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.
|
* 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 categoryMap = new Map(categories.map((c) => [c.id, c.name]))
|
||||||
|
|
||||||
const grouped: Record<string, { id: string; name: string }[]> = {}
|
const grouped: Record<string, { id: string; name: string }[]> = {}
|
||||||
@@ -105,14 +107,24 @@ function buildTagPrompt(tags: Tag[], categories: TagCategory[]): string {
|
|||||||
lines.push(`[${catName}] ${tagList}`)
|
lines.push(`[${catName}] ${tagList}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
const parts: string[] = [
|
||||||
'You are an image tagger. Given the image, select which of the following tags apply.',
|
'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.',
|
'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: []',
|
'If no tags match, return an empty array (e.i., [])',
|
||||||
'',
|
]
|
||||||
'Available tags:',
|
|
||||||
...lines,
|
if (currentTags && currentTags.length > 0) {
|
||||||
].join('\n')
|
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,
|
temperature: 0.1,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
@@ -184,12 +196,15 @@ export async function runAiTagging(library: Library, libraryRoot: string): Promi
|
|||||||
const config = getAiConfig()
|
const config = getAiConfig()
|
||||||
if (!config.enabled || !config.endpoint || !config.model) return
|
if (!config.enabled || !config.endpoint || !config.model) return
|
||||||
|
|
||||||
const tags = getTags()
|
const activeCategoryIds = new Set(getActiveCategoryIdsForLibrary(library.id))
|
||||||
const categories = getCategories()
|
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
|
if (tags.length === 0) return
|
||||||
|
|
||||||
const validTagIds = new Set(tags.map((t) => t.id))
|
const validTagIds = new Set(tags.map((t) => t.id))
|
||||||
const systemPrompt = buildTagPrompt(tags, categories)
|
|
||||||
|
|
||||||
const db = getDb()
|
const db = getDb()
|
||||||
const untaggedItems = db
|
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 thumbnailPath = await getThumbnailPath(imagePath, library.id, 'image')
|
||||||
const base64 = fs.readFileSync(thumbnailPath, 'base64')
|
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)
|
const suggestedIds = await callVisionApi(config.endpoint, config.model, base64, systemPrompt)
|
||||||
|
|
||||||
// Filter to valid tags only
|
// 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' })
|
throw Object.assign(new Error('AI tagging endpoint and model are not configured'), { code: 'NOT_CONFIGURED' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const tags = getTags()
|
const libraryId = itemKey.split(':')[0]
|
||||||
const categories = getCategories()
|
|
||||||
|
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) {
|
if (tags.length === 0) {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
const validTagIds = new Set(tags.map((t) => t.id))
|
const validTagIds = new Set(tags.map((t) => t.id))
|
||||||
const systemPrompt = buildTagPrompt(tags, categories)
|
|
||||||
|
|
||||||
const db = getDb()
|
const db = getDb()
|
||||||
const item = db
|
const item = db
|
||||||
@@ -280,8 +303,6 @@ export async function tagSingleItem(itemKey: string): Promise<string[]> {
|
|||||||
if (!item) {
|
if (!item) {
|
||||||
throw Object.assign(new Error(`Item not found: ${itemKey}`), { code: 'NOT_FOUND' })
|
throw Object.assign(new Error(`Item not found: ${itemKey}`), { code: 'NOT_FOUND' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const libraryId = itemKey.split(':')[0]
|
|
||||||
const library = getLibrary(libraryId)
|
const library = getLibrary(libraryId)
|
||||||
if (!library) {
|
if (!library) {
|
||||||
throw Object.assign(new Error(`Library not found: ${libraryId}`), { code: 'NOT_FOUND' })
|
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 thumbnailPath = await getThumbnailPath(imagePath, libraryId, 'image')
|
||||||
const base64 = fs.readFileSync(thumbnailPath, 'base64')
|
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))
|
const validIds = suggestedIds.filter((id) => validTagIds.has(id))
|
||||||
|
|
||||||
for (const tagId of validIds) {
|
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[]
|
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 {
|
export function addCategory(name: string): TagCategory {
|
||||||
const trimmed = name.trim()
|
const trimmed = name.trim()
|
||||||
if (!trimmed) throw new Error('Category name is required.')
|
if (!trimmed) throw new Error('Category name is required.')
|
||||||
|
|||||||
Reference in New Issue
Block a user