text-extraction-improvements #24
63
src/app/api/ai-jobs/route.ts
Normal file
63
src/app/api/ai-jobs/route.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { requireAdmin } from '@/lib/auth'
|
||||||
|
import { getJobQueue, getJobHistory, retryJob, cancelJob, cancelAllQueued, clearJobHistory } from '@/lib/ai-jobs'
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const auth = await requireAdmin(request)
|
||||||
|
if (auth instanceof NextResponse) return auth
|
||||||
|
|
||||||
|
const queue = getJobQueue()
|
||||||
|
const history = getJobHistory(50)
|
||||||
|
return NextResponse.json({ queue, history })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const auth = await requireAdmin(request)
|
||||||
|
if (auth instanceof NextResponse) return auth
|
||||||
|
|
||||||
|
let body: { action?: string; jobId?: string }
|
||||||
|
try {
|
||||||
|
body = await request.json()
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { action, jobId } = body
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case 'retry': {
|
||||||
|
if (!jobId || typeof jobId !== 'string') {
|
||||||
|
return NextResponse.json({ error: 'jobId is required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
const ok = retryJob(jobId)
|
||||||
|
if (!ok) {
|
||||||
|
return NextResponse.json({ error: 'Job not found or not in failed state' }, { status: 404 })
|
||||||
|
}
|
||||||
|
return NextResponse.json({ ok: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'cancel': {
|
||||||
|
if (!jobId || typeof jobId !== 'string') {
|
||||||
|
return NextResponse.json({ error: 'jobId is required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
const ok = cancelJob(jobId)
|
||||||
|
if (!ok) {
|
||||||
|
return NextResponse.json({ error: 'Job not found or not in queued state' }, { status: 404 })
|
||||||
|
}
|
||||||
|
return NextResponse.json({ ok: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'cancel-all': {
|
||||||
|
const cancelled = cancelAllQueued()
|
||||||
|
return NextResponse.json({ cancelled })
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'clear-history': {
|
||||||
|
const cleared = clearJobHistory()
|
||||||
|
return NextResponse.json({ cleared })
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return NextResponse.json({ error: 'Unknown action' }, { status: 400 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { requireAdmin } from '@/lib/auth'
|
import { requireAdmin } from '@/lib/auth'
|
||||||
import { getAiConfig, updateAiConfig, getPreferredLanguage, setPreferredLanguage } from '@/lib/app-settings'
|
import { getAiConfig, updateAiConfig, getPreferredLanguage, setPreferredLanguage, getAiMaxRetries, setAiMaxRetries } from '@/lib/app-settings'
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const auth = await requireAdmin(request)
|
const auth = await requireAdmin(request)
|
||||||
@@ -8,7 +8,8 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
const config = getAiConfig()
|
const config = getAiConfig()
|
||||||
const preferredLanguage = getPreferredLanguage()
|
const preferredLanguage = getPreferredLanguage()
|
||||||
return NextResponse.json({ ...config, preferredLanguage })
|
const maxRetries = getAiMaxRetries()
|
||||||
|
return NextResponse.json({ ...config, preferredLanguage, maxRetries })
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function PUT(request: NextRequest) {
|
export async function PUT(request: NextRequest) {
|
||||||
@@ -28,6 +29,7 @@ export async function PUT(request: NextRequest) {
|
|||||||
promptTagger?: string
|
promptTagger?: string
|
||||||
promptExtract?: string
|
promptExtract?: string
|
||||||
promptTranslate?: string
|
promptTranslate?: string
|
||||||
|
maxRetries?: number
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
@@ -39,6 +41,7 @@ export async function PUT(request: NextRequest) {
|
|||||||
endpoint, model, enabled, preferredLanguage,
|
endpoint, model, enabled, preferredLanguage,
|
||||||
modelTagging, modelDescribe, modelExtract, modelTranslate,
|
modelTagging, modelDescribe, modelExtract, modelTranslate,
|
||||||
promptDescribe, promptTagger, promptExtract, promptTranslate,
|
promptDescribe, promptTagger, promptExtract, promptTranslate,
|
||||||
|
maxRetries,
|
||||||
} = body
|
} = body
|
||||||
|
|
||||||
if (typeof endpoint !== 'string') {
|
if (typeof endpoint !== 'string') {
|
||||||
@@ -69,6 +72,10 @@ export async function PUT(request: NextRequest) {
|
|||||||
setPreferredLanguage(preferredLanguage.trim())
|
setPreferredLanguage(preferredLanguage.trim())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (typeof maxRetries === 'number' && Number.isFinite(maxRetries)) {
|
||||||
|
setAiMaxRetries(maxRetries)
|
||||||
|
}
|
||||||
|
|
||||||
const config = getAiConfig()
|
const config = getAiConfig()
|
||||||
return NextResponse.json({ ...config, preferredLanguage: getPreferredLanguage() })
|
return NextResponse.json({ ...config, preferredLanguage: getPreferredLanguage(), maxRetries: getAiMaxRetries() })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { requireLibraryAccess } from '@/lib/auth'
|
import { requireLibraryAccess } from '@/lib/auth'
|
||||||
import { describeDirectoryItems } from '@/lib/ai-tagger'
|
import { enqueueBulkJobs } from '@/lib/ai-jobs'
|
||||||
|
|
||||||
|
const IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff', '.tif'])
|
||||||
|
const VIDEO_EXTENSIONS = new Set(['.mp4', '.mkv', '.avi', '.mov', '.wmv', '.m4v', '.webm', '.flv', '.ts', '.mpg', '.mpeg'])
|
||||||
|
const MEDIA_EXTENSIONS = new Set([...IMAGE_EXTENSIONS, ...VIDEO_EXTENSIONS])
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
let body: { libraryId?: string; path?: string }
|
let body: { libraryId?: string; path?: string }
|
||||||
@@ -18,21 +22,6 @@ export async function POST(request: NextRequest) {
|
|||||||
const auth = await requireLibraryAccess(request, libraryId)
|
const auth = await requireLibraryAccess(request, libraryId)
|
||||||
if (auth instanceof NextResponse) return auth
|
if (auth instanceof NextResponse) return auth
|
||||||
|
|
||||||
try {
|
const jobIds = enqueueBulkJobs(libraryId, dirPath ?? '', 'describe', 'mixed_file', MEDIA_EXTENSIONS)
|
||||||
const processed = await describeDirectoryItems(libraryId, dirPath ?? '')
|
return NextResponse.json({ jobIds, queued: jobIds.length }, { status: 202 })
|
||||||
return NextResponse.json({ processed })
|
|
||||||
} catch (err) {
|
|
||||||
const error = err as Error & { code?: string }
|
|
||||||
if (error.code === 'NOT_CONFIGURED') {
|
|
||||||
return NextResponse.json({ error: error.message }, { status: 400 })
|
|
||||||
}
|
|
||||||
if (error.code === 'NOT_FOUND') {
|
|
||||||
return NextResponse.json({ error: error.message }, { status: 404 })
|
|
||||||
}
|
|
||||||
if (error.code === 'INVALID_TYPE') {
|
|
||||||
return NextResponse.json({ error: error.message }, { status: 400 })
|
|
||||||
}
|
|
||||||
console.error('[ai-tagging/describe-bulk] Error:', error)
|
|
||||||
return NextResponse.json({ error: 'Failed to generate descriptions' }, { status: 502 })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { requireLibraryAccess } from '@/lib/auth'
|
import { requireLibraryAccess } from '@/lib/auth'
|
||||||
import { generateItemDescription } from '@/lib/ai-tagger'
|
import { enqueueJob } from '@/lib/ai-jobs'
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
let body: { itemKey?: string }
|
let body: { itemKey?: string }
|
||||||
@@ -19,21 +19,6 @@ export async function POST(request: NextRequest) {
|
|||||||
const auth = await requireLibraryAccess(request, libraryId)
|
const auth = await requireLibraryAccess(request, libraryId)
|
||||||
if (auth instanceof NextResponse) return auth
|
if (auth instanceof NextResponse) return auth
|
||||||
|
|
||||||
try {
|
const jobId = enqueueJob(itemKey, 'describe', libraryId)
|
||||||
const description = await generateItemDescription(itemKey)
|
return NextResponse.json({ jobId }, { status: 202 })
|
||||||
return NextResponse.json({ description })
|
|
||||||
} catch (err) {
|
|
||||||
const error = err as Error & { code?: string }
|
|
||||||
if (error.code === 'NOT_CONFIGURED') {
|
|
||||||
return NextResponse.json({ error: error.message }, { status: 400 })
|
|
||||||
}
|
|
||||||
if (error.code === 'NOT_FOUND') {
|
|
||||||
return NextResponse.json({ error: error.message }, { status: 404 })
|
|
||||||
}
|
|
||||||
if (error.code === 'NO_IMAGE') {
|
|
||||||
return NextResponse.json({ error: error.message }, { status: 404 })
|
|
||||||
}
|
|
||||||
console.error('[ai-tagging/describe] Error:', error)
|
|
||||||
return NextResponse.json({ error: 'Failed to generate description' }, { status: 502 })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { requireLibraryAccess } from '@/lib/auth'
|
import { requireLibraryAccess } from '@/lib/auth'
|
||||||
import { extractDirectoryText } from '@/lib/ai-tagger'
|
import { enqueueBulkJobs } from '@/lib/ai-jobs'
|
||||||
|
|
||||||
|
const IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff', '.tif'])
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
let body: { libraryId?: string; path?: string }
|
let body: { libraryId?: string; path?: string }
|
||||||
@@ -18,21 +20,6 @@ export async function POST(request: NextRequest) {
|
|||||||
const auth = await requireLibraryAccess(request, libraryId)
|
const auth = await requireLibraryAccess(request, libraryId)
|
||||||
if (auth instanceof NextResponse) return auth
|
if (auth instanceof NextResponse) return auth
|
||||||
|
|
||||||
try {
|
const jobIds = enqueueBulkJobs(libraryId, dirPath ?? '', 'extract', 'mixed_file', IMAGE_EXTENSIONS)
|
||||||
const processed = await extractDirectoryText(libraryId, dirPath ?? '')
|
return NextResponse.json({ jobIds, queued: jobIds.length }, { status: 202 })
|
||||||
return NextResponse.json({ processed })
|
|
||||||
} catch (err) {
|
|
||||||
const error = err as Error & { code?: string }
|
|
||||||
if (error.code === 'NOT_CONFIGURED') {
|
|
||||||
return NextResponse.json({ error: error.message }, { status: 400 })
|
|
||||||
}
|
|
||||||
if (error.code === 'NOT_FOUND') {
|
|
||||||
return NextResponse.json({ error: error.message }, { status: 404 })
|
|
||||||
}
|
|
||||||
if (error.code === 'INVALID_TYPE') {
|
|
||||||
return NextResponse.json({ error: error.message }, { status: 400 })
|
|
||||||
}
|
|
||||||
console.error('[ai-tagging/extract-text-bulk] Error:', error)
|
|
||||||
return NextResponse.json({ error: 'Failed to extract text' }, { status: 502 })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { requireLibraryAccess } from '@/lib/auth'
|
import { requireLibraryAccess } from '@/lib/auth'
|
||||||
import { extractItemText } from '@/lib/ai-tagger'
|
import { enqueueJob } from '@/lib/ai-jobs'
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
let body: { itemKey?: string }
|
let body: { itemKey?: string }
|
||||||
@@ -19,21 +19,6 @@ export async function POST(request: NextRequest) {
|
|||||||
const auth = await requireLibraryAccess(request, libraryId)
|
const auth = await requireLibraryAccess(request, libraryId)
|
||||||
if (auth instanceof NextResponse) return auth
|
if (auth instanceof NextResponse) return auth
|
||||||
|
|
||||||
try {
|
const jobId = enqueueJob(itemKey, 'extract', libraryId)
|
||||||
const result = await extractItemText(itemKey)
|
return NextResponse.json({ jobId }, { status: 202 })
|
||||||
return NextResponse.json(result)
|
|
||||||
} catch (err) {
|
|
||||||
const error = err as Error & { code?: string }
|
|
||||||
if (error.code === 'NOT_CONFIGURED') {
|
|
||||||
return NextResponse.json({ error: error.message }, { status: 400 })
|
|
||||||
}
|
|
||||||
if (error.code === 'NOT_FOUND') {
|
|
||||||
return NextResponse.json({ error: error.message }, { status: 404 })
|
|
||||||
}
|
|
||||||
if (error.code === 'NO_IMAGE' || error.code === 'INVALID_TYPE') {
|
|
||||||
return NextResponse.json({ error: error.message }, { status: 400 })
|
|
||||||
}
|
|
||||||
console.error('[ai-tagging/extract-text] Error:', error)
|
|
||||||
return NextResponse.json({ error: 'Failed to extract text' }, { status: 502 })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { requireLibraryAccess } from '@/lib/auth'
|
import { requireLibraryAccess } from '@/lib/auth'
|
||||||
import { getAiFields } from '@/lib/ai-tagger'
|
import { getAiFields, updateExtractedText } from '@/lib/ai-tagger'
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const { searchParams } = request.nextUrl
|
const { searchParams } = request.nextUrl
|
||||||
@@ -17,3 +17,27 @@ export async function GET(request: NextRequest) {
|
|||||||
const fields = getAiFields(itemKey)
|
const fields = getAiFields(itemKey)
|
||||||
return NextResponse.json(fields)
|
return NextResponse.json(fields)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function PATCH(request: NextRequest) {
|
||||||
|
let body: { itemKey?: string; extractedText?: string }
|
||||||
|
try {
|
||||||
|
body = await request.json()
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { itemKey, extractedText } = body
|
||||||
|
if (!itemKey || typeof itemKey !== 'string') {
|
||||||
|
return NextResponse.json({ error: 'itemKey is required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
if (typeof extractedText !== 'string') {
|
||||||
|
return NextResponse.json({ error: 'extractedText is required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const libraryId = itemKey.split(':')[0]
|
||||||
|
const auth = await requireLibraryAccess(request, libraryId)
|
||||||
|
if (auth instanceof NextResponse) return auth
|
||||||
|
|
||||||
|
updateExtractedText(itemKey, extractedText)
|
||||||
|
return NextResponse.json({ ok: true })
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { requireLibraryAccess } from '@/lib/auth'
|
import { requireLibraryAccess } from '@/lib/auth'
|
||||||
import { tagSingleItem } from '@/lib/ai-tagger'
|
import { enqueueJob } from '@/lib/ai-jobs'
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
let body: { itemKey?: string }
|
let body: { itemKey?: string }
|
||||||
@@ -19,21 +19,6 @@ export async function POST(request: NextRequest) {
|
|||||||
const auth = await requireLibraryAccess(request, libraryId)
|
const auth = await requireLibraryAccess(request, libraryId)
|
||||||
if (auth instanceof NextResponse) return auth
|
if (auth instanceof NextResponse) return auth
|
||||||
|
|
||||||
try {
|
const jobId = enqueueJob(itemKey, 'tag', libraryId)
|
||||||
const tagIds = await tagSingleItem(itemKey)
|
return NextResponse.json({ jobId }, { status: 202 })
|
||||||
return NextResponse.json({ tagIds })
|
|
||||||
} catch (err) {
|
|
||||||
const error = err as Error & { code?: string }
|
|
||||||
if (error.code === 'NOT_CONFIGURED') {
|
|
||||||
return NextResponse.json({ error: error.message }, { status: 400 })
|
|
||||||
}
|
|
||||||
if (error.code === 'NOT_FOUND') {
|
|
||||||
return NextResponse.json({ error: error.message }, { status: 404 })
|
|
||||||
}
|
|
||||||
if (error.code === 'NO_IMAGE') {
|
|
||||||
return NextResponse.json({ error: error.message }, { status: 404 })
|
|
||||||
}
|
|
||||||
console.error('[ai-tagging] Error tagging item:', error)
|
|
||||||
return NextResponse.json({ error: 'AI tagging failed' }, { status: 502 })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { requireLibraryAccess } from '@/lib/auth'
|
import { requireLibraryAccess } from '@/lib/auth'
|
||||||
import { translateItemText } from '@/lib/ai-tagger'
|
import { enqueueJob } from '@/lib/ai-jobs'
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
let body: { itemKey?: string }
|
let body: { itemKey?: string; sourceLanguage?: string }
|
||||||
try {
|
try {
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
} catch {
|
} catch {
|
||||||
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
|
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const { itemKey } = body
|
const { itemKey, sourceLanguage } = body
|
||||||
if (!itemKey || typeof itemKey !== 'string') {
|
if (!itemKey || typeof itemKey !== 'string') {
|
||||||
return NextResponse.json({ error: 'itemKey is required' }, { status: 400 })
|
return NextResponse.json({ error: 'itemKey is required' }, { status: 400 })
|
||||||
}
|
}
|
||||||
@@ -19,18 +19,6 @@ export async function POST(request: NextRequest) {
|
|||||||
const auth = await requireLibraryAccess(request, libraryId)
|
const auth = await requireLibraryAccess(request, libraryId)
|
||||||
if (auth instanceof NextResponse) return auth
|
if (auth instanceof NextResponse) return auth
|
||||||
|
|
||||||
try {
|
const jobId = enqueueJob(itemKey, 'translate', libraryId, sourceLanguage || undefined)
|
||||||
const translatedText = await translateItemText(itemKey)
|
return NextResponse.json({ jobId }, { status: 202 })
|
||||||
return NextResponse.json({ translatedText })
|
|
||||||
} catch (err) {
|
|
||||||
const error = err as Error & { code?: string }
|
|
||||||
if (error.code === 'NOT_CONFIGURED') {
|
|
||||||
return NextResponse.json({ error: error.message }, { status: 400 })
|
|
||||||
}
|
|
||||||
if (error.code === 'NOT_FOUND') {
|
|
||||||
return NextResponse.json({ error: error.message }, { status: 404 })
|
|
||||||
}
|
|
||||||
console.error('[ai-tagging/translate] Error:', error)
|
|
||||||
return NextResponse.json({ error: 'Failed to translate text' }, { status: 502 })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState, useCallback } from 'react'
|
import { useEffect, useState, useCallback, useRef } from 'react'
|
||||||
|
|
||||||
interface AiSettings {
|
interface AiSettings {
|
||||||
endpoint: string
|
endpoint: string
|
||||||
@@ -15,6 +15,22 @@ interface AiSettings {
|
|||||||
promptTagger: string
|
promptTagger: string
|
||||||
promptExtract: string
|
promptExtract: string
|
||||||
promptTranslate: string
|
promptTranslate: string
|
||||||
|
maxRetries: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AiJob {
|
||||||
|
id: string
|
||||||
|
itemKey: string
|
||||||
|
libraryId: string
|
||||||
|
jobType: string
|
||||||
|
status: string
|
||||||
|
error: string | null
|
||||||
|
attempt: number
|
||||||
|
maxRetries: number
|
||||||
|
createdAt: number
|
||||||
|
startedAt: number | null
|
||||||
|
completedAt: number | null
|
||||||
|
itemTitle: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Library {
|
interface Library {
|
||||||
@@ -33,11 +49,24 @@ interface LibraryOverride {
|
|||||||
promptTranslate: string
|
promptTranslate: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatElapsed(startedAt: number): string {
|
||||||
|
const seconds = Math.floor((Date.now() - startedAt) / 1000)
|
||||||
|
if (seconds < 60) return `${seconds}s`
|
||||||
|
const m = Math.floor(seconds / 60)
|
||||||
|
const s = seconds % 60
|
||||||
|
return `${m}m ${s}s`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(ts: number): string {
|
||||||
|
return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||||
|
}
|
||||||
|
|
||||||
export default function AiTaggingPage() {
|
export default function AiTaggingPage() {
|
||||||
const [settings, setSettings] = useState<AiSettings>({
|
const [settings, setSettings] = useState<AiSettings>({
|
||||||
endpoint: '', model: '', modelTagging: '', modelDescribe: '', modelExtract: '', modelTranslate: '',
|
endpoint: '', model: '', modelTagging: '', modelDescribe: '', modelExtract: '', modelTranslate: '',
|
||||||
enabled: false, preferredLanguage: 'English',
|
enabled: false, preferredLanguage: 'English',
|
||||||
promptDescribe: '', promptTagger: '', promptExtract: '', promptTranslate: '',
|
promptDescribe: '', promptTagger: '', promptExtract: '', promptTranslate: '',
|
||||||
|
maxRetries: 3,
|
||||||
})
|
})
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
@@ -54,6 +83,11 @@ export default function AiTaggingPage() {
|
|||||||
const [librarySaving, setLibrarySaving] = useState<Record<string, boolean>>({})
|
const [librarySaving, setLibrarySaving] = useState<Record<string, boolean>>({})
|
||||||
const [librarySaveResult, setLibrarySaveResult] = useState<Record<string, { ok: boolean; message: string }>>({})
|
const [librarySaveResult, setLibrarySaveResult] = useState<Record<string, { ok: boolean; message: string }>>({})
|
||||||
|
|
||||||
|
// Job queue state
|
||||||
|
const [jobQueue, setJobQueue] = useState<AiJob[]>([])
|
||||||
|
const [jobHistory, setJobHistory] = useState<AiJob[]>([])
|
||||||
|
const [historyExpanded, setHistoryExpanded] = useState(false)
|
||||||
|
const jobPollRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||||
const fetchSettings = useCallback(async () => {
|
const fetchSettings = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const [settingsRes, librariesRes] = await Promise.all([
|
const [settingsRes, librariesRes] = await Promise.all([
|
||||||
@@ -79,6 +113,77 @@ export default function AiTaggingPage() {
|
|||||||
fetchSettings()
|
fetchSettings()
|
||||||
}, [fetchSettings])
|
}, [fetchSettings])
|
||||||
|
|
||||||
|
// ─── Job queue polling ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const fetchJobs = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/ai-jobs')
|
||||||
|
if (res.ok) {
|
||||||
|
const data: { queue: AiJob[]; history: AiJob[] } = await res.json()
|
||||||
|
setJobQueue(data.queue)
|
||||||
|
setJobHistory(data.history)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchJobs()
|
||||||
|
}, [fetchJobs])
|
||||||
|
|
||||||
|
// Poll every 2s while there are active jobs
|
||||||
|
useEffect(() => {
|
||||||
|
const hasActive = jobQueue.length > 0
|
||||||
|
if (hasActive) {
|
||||||
|
jobPollRef.current = setInterval(fetchJobs, 2000)
|
||||||
|
} else {
|
||||||
|
if (jobPollRef.current) {
|
||||||
|
clearInterval(jobPollRef.current)
|
||||||
|
jobPollRef.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
if (jobPollRef.current) clearInterval(jobPollRef.current)
|
||||||
|
}
|
||||||
|
}, [jobQueue.length, fetchJobs])
|
||||||
|
|
||||||
|
const handleRetryJob = async (jobId: string) => {
|
||||||
|
await fetch('/api/ai-jobs', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ action: 'retry', jobId }),
|
||||||
|
})
|
||||||
|
fetchJobs()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCancelJob = async (jobId: string) => {
|
||||||
|
await fetch('/api/ai-jobs', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ action: 'cancel', jobId }),
|
||||||
|
})
|
||||||
|
fetchJobs()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCancelAll = async () => {
|
||||||
|
await fetch('/api/ai-jobs', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ action: 'cancel-all' }),
|
||||||
|
})
|
||||||
|
fetchJobs()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClearHistory = async () => {
|
||||||
|
await fetch('/api/ai-jobs', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ action: 'clear-history' }),
|
||||||
|
})
|
||||||
|
fetchJobs()
|
||||||
|
}
|
||||||
|
|
||||||
const fetchLibraryOverrides = useCallback(async (libraryId: string) => {
|
const fetchLibraryOverrides = useCallback(async (libraryId: string) => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/ai-settings/library/${libraryId}`)
|
const res = await fetch(`/api/ai-settings/library/${libraryId}`)
|
||||||
@@ -201,12 +306,182 @@ export default function AiTaggingPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="max-w-2xl">
|
<div className="max-w-2xl">
|
||||||
<h1 className="text-2xl font-semibold mb-1" style={{ color: 'var(--text-primary)' }}>
|
<h1 className="text-2xl font-semibold mb-1" style={{ color: 'var(--text-primary)' }}>
|
||||||
AI Tagging
|
AI Integrations
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm mb-8" style={{ color: 'var(--text-secondary)' }}>
|
<p className="text-sm mb-8" style={{ color: 'var(--text-secondary)' }}>
|
||||||
Automatically tag media using a vision-capable LLM on your network.
|
Manage AI-powered tagging, descriptions, and text extraction.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{/* ─── Job Queue ─────────────────────────────────────────────────────── */}
|
||||||
|
<Section title="Job Queue">
|
||||||
|
{(() => {
|
||||||
|
const running = jobQueue.filter((j) => j.status === 'running')
|
||||||
|
const queued = jobQueue.filter((j) => j.status === 'queued')
|
||||||
|
if (running.length === 0 && queued.length === 0) {
|
||||||
|
return (
|
||||||
|
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
No active jobs.
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
{running.length > 0 && <span>{running.length} running</span>}
|
||||||
|
{running.length > 0 && queued.length > 0 && ', '}
|
||||||
|
{queued.length > 0 && <span>{queued.length} queued</span>}
|
||||||
|
</p>
|
||||||
|
{queued.length > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCancelAll}
|
||||||
|
className="text-xs px-2 py-1 rounded transition-colors"
|
||||||
|
style={{ color: '#fca5a5', backgroundColor: '#7f1d1d33' }}
|
||||||
|
>
|
||||||
|
Cancel All
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col divide-y" style={{ borderColor: 'var(--border)' }}>
|
||||||
|
{running.map((job) => (
|
||||||
|
<div key={job.id} className="flex items-center gap-3 py-2">
|
||||||
|
<span
|
||||||
|
className="text-xs px-2 py-0.5 rounded-full animate-pulse"
|
||||||
|
style={{ backgroundColor: '#16a34a33', color: '#4ade80' }}
|
||||||
|
>
|
||||||
|
Running
|
||||||
|
</span>
|
||||||
|
<span className="text-sm flex-1 truncate" style={{ color: 'var(--text-primary)' }}>
|
||||||
|
{job.itemTitle || job.itemKey}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
{job.jobType}
|
||||||
|
</span>
|
||||||
|
{job.startedAt && (
|
||||||
|
<span className="text-xs tabular-nums" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
{formatElapsed(job.startedAt)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{queued.map((job) => (
|
||||||
|
<div key={job.id} className="flex items-center gap-3 py-2">
|
||||||
|
<span
|
||||||
|
className="text-xs px-2 py-0.5 rounded-full"
|
||||||
|
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
|
||||||
|
>
|
||||||
|
Queued
|
||||||
|
</span>
|
||||||
|
<span className="text-sm flex-1 truncate" style={{ color: 'var(--text-primary)' }}>
|
||||||
|
{job.itemTitle || job.itemKey}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
{job.jobType}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleCancelJob(job.id)}
|
||||||
|
className="text-xs px-2 py-0.5 rounded transition-colors"
|
||||||
|
style={{ color: 'var(--text-secondary)' }}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* ─── Job History ───────────────────────────────────────────────────── */}
|
||||||
|
<Section title="Job History">
|
||||||
|
{jobHistory.length === 0 ? (
|
||||||
|
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
No recent jobs.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setHistoryExpanded((v) => !v)}
|
||||||
|
className="flex items-center gap-2 text-left"
|
||||||
|
style={{ color: 'var(--text-secondary)' }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="text-xs transition-transform inline-block"
|
||||||
|
style={{ transform: historyExpanded ? 'rotate(180deg)' : 'rotate(0deg)' }}
|
||||||
|
>
|
||||||
|
▾
|
||||||
|
</span>
|
||||||
|
<span className="text-xs">
|
||||||
|
{jobHistory.length} recent job{jobHistory.length !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{historyExpanded && (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col divide-y" style={{ borderColor: 'var(--border)' }}>
|
||||||
|
{jobHistory.map((job) => (
|
||||||
|
<div key={job.id} className="flex items-center gap-3 py-2">
|
||||||
|
<span
|
||||||
|
className="text-xs px-2 py-0.5 rounded-full whitespace-nowrap"
|
||||||
|
style={{
|
||||||
|
backgroundColor: job.status === 'completed' ? '#14532d33' : '#7f1d1d33',
|
||||||
|
color: job.status === 'completed' ? '#4ade80' : '#fca5a5',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{job.status === 'completed' ? 'Done' : 'Failed'}
|
||||||
|
</span>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<span className="text-sm truncate block" style={{ color: 'var(--text-primary)' }}>
|
||||||
|
{job.itemTitle || job.itemKey}
|
||||||
|
</span>
|
||||||
|
{job.status === 'failed' && job.error && (
|
||||||
|
<span className="text-xs truncate block" style={{ color: '#fca5a5' }}>
|
||||||
|
{job.error}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs whitespace-nowrap" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
{job.jobType}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs whitespace-nowrap" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
{job.completedAt ? formatDate(job.completedAt) : ''}
|
||||||
|
</span>
|
||||||
|
{job.status === 'failed' && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleRetryJob(job.id)}
|
||||||
|
className="text-xs px-2 py-0.5 rounded transition-colors whitespace-nowrap"
|
||||||
|
style={{ color: 'var(--accent)' }}
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClearHistory}
|
||||||
|
className="text-xs px-3 py-1.5 rounded-lg transition-colors"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--surface)',
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear History
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
|
||||||
<Section title="Connection">
|
<Section title="Connection">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<LoadingRows />
|
<LoadingRows />
|
||||||
@@ -365,6 +640,29 @@ export default function AiTaggingPage() {
|
|||||||
</p>
|
</p>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Max Retries">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={10}
|
||||||
|
value={settings.maxRetries}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSettings((s) => ({ ...s, maxRetries: Math.max(0, Math.min(10, parseInt(e.target.value) || 0)) }))
|
||||||
|
}
|
||||||
|
className="w-24 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)')}
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
Number of times to automatically retry a failed AI job before marking it as failed (0–10).
|
||||||
|
</p>
|
||||||
|
</Field>
|
||||||
|
|
||||||
{saveError && (
|
{saveError && (
|
||||||
<p
|
<p
|
||||||
className="text-sm rounded-lg px-3 py-2"
|
className="text-sm rounded-lg px-3 py-2"
|
||||||
|
|||||||
@@ -196,6 +196,11 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose,
|
|||||||
const data = await res.json().catch(() => ({}))
|
const data = await res.json().catch(() => ({}))
|
||||||
throw new Error((data as { error?: string }).error ?? 'Extraction failed')
|
throw new Error((data as { error?: string }).error ?? 'Extraction failed')
|
||||||
}
|
}
|
||||||
|
if (res.status === 202) {
|
||||||
|
setExtractError('Queued — check AI Integrations for progress')
|
||||||
|
setTimeout(() => setExtractError(null), 4000)
|
||||||
|
return
|
||||||
|
}
|
||||||
const result = await res.json()
|
const result = await res.json()
|
||||||
setExtractedText(result.extractedText || null)
|
setExtractedText(result.extractedText || null)
|
||||||
setTranslatedText(result.translatedText || null)
|
setTranslatedText(result.translatedText || null)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ const TABS = [
|
|||||||
{ href: '/manage/tags', label: 'Tags' },
|
{ href: '/manage/tags', label: 'Tags' },
|
||||||
{ href: '/manage/users', label: 'Users' },
|
{ href: '/manage/users', label: 'Users' },
|
||||||
{ href: '/manage/scanning', label: 'Scanning' },
|
{ href: '/manage/scanning', label: 'Scanning' },
|
||||||
{ href: '/manage/ai-tagging', label: 'AI Tagging' },
|
{ href: '/manage/ai-tagging', label: 'AI Integrations' },
|
||||||
]
|
]
|
||||||
|
|
||||||
export default function ManageSubNav() {
|
export default function ManageSubNav() {
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
|
|||||||
const [extracting, setExtracting] = useState(false)
|
const [extracting, setExtracting] = useState(false)
|
||||||
const [extractError, setExtractError] = useState<string | null>(null)
|
const [extractError, setExtractError] = useState<string | null>(null)
|
||||||
const [retranslating, setRetranslating] = useState(false)
|
const [retranslating, setRetranslating] = useState(false)
|
||||||
|
const [editedExtractedText, setEditedExtractedText] = useState<string>('')
|
||||||
|
const [savingText, setSavingText] = useState(false)
|
||||||
|
const [sourceLanguage, setSourceLanguage] = useState('')
|
||||||
|
|
||||||
// Text overlay state
|
// Text overlay state
|
||||||
const [showTextOverlay, setShowTextOverlay] = useState(false)
|
const [showTextOverlay, setShowTextOverlay] = useState(false)
|
||||||
@@ -45,6 +48,7 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
|
|||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((data: { extractedText: string | null; extractedTextTranslated: string | null }) => {
|
.then((data: { extractedText: string | null; extractedTextTranslated: string | null }) => {
|
||||||
setExtractedText(data.extractedText)
|
setExtractedText(data.extractedText)
|
||||||
|
setEditedExtractedText(data.extractedText ?? '')
|
||||||
setTranslatedText(data.extractedTextTranslated)
|
setTranslatedText(data.extractedTextTranslated)
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
@@ -179,14 +183,14 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showTags ? (
|
{showTags ? (
|
||||||
<div className="flex gap-4 w-full flex-1 min-h-0 items-start overflow-hidden max-h-full">
|
<div className="flex gap-4 w-full flex-1 min-h-0 items-start overflow-hidden max-h-fit max-w-fit">
|
||||||
{/* Image */}
|
{/* Image */}
|
||||||
<div className="w-full flex-1 min-h-0 flex items-center justify-center overflow-hidden max-h-screen relative">
|
<div className="w-full flex-1 min-w-0 min-h-0 h-full flex items-center justify-center overflow-hidden relative">
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<img
|
<img
|
||||||
src={url}
|
src={url}
|
||||||
alt={name}
|
alt={name}
|
||||||
className="object-contain rounded-lg"
|
className="max-w-full max-h-full w-auto h-auto object-contain rounded-lg"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
/>
|
/>
|
||||||
{onPrev && (
|
{onPrev && (
|
||||||
@@ -265,8 +269,14 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
|
|||||||
const data = await res.json().catch(() => ({}))
|
const data = await res.json().catch(() => ({}))
|
||||||
throw new Error((data as { error?: string }).error ?? 'Failed to extract text')
|
throw new Error((data as { error?: string }).error ?? 'Failed to extract text')
|
||||||
}
|
}
|
||||||
|
if (res.status === 202) {
|
||||||
|
setExtractError('Queued — check AI Integrations for progress')
|
||||||
|
setTimeout(() => setExtractError(null), 4000)
|
||||||
|
return
|
||||||
|
}
|
||||||
const result = await res.json()
|
const result = await res.json()
|
||||||
setExtractedText(result.extractedText || null)
|
setExtractedText(result.extractedText || null)
|
||||||
|
setEditedExtractedText(result.extractedText || '')
|
||||||
setTranslatedText(result.translatedText || null)
|
setTranslatedText(result.translatedText || null)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setExtractError(err instanceof Error ? err.message : 'Failed to extract text')
|
setExtractError(err instanceof Error ? err.message : 'Failed to extract text')
|
||||||
@@ -302,12 +312,41 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
|
|||||||
<p className="text-xs font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>
|
<p className="text-xs font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>
|
||||||
Extracted Text
|
Extracted Text
|
||||||
</p>
|
</p>
|
||||||
<pre
|
<textarea
|
||||||
className="text-xs whitespace-pre-wrap rounded-lg p-2 max-h-40 overflow-y-auto"
|
value={editedExtractedText}
|
||||||
style={{ backgroundColor: 'var(--background)', color: 'var(--text-primary)', border: '1px solid var(--border)' }}
|
onChange={(e) => setEditedExtractedText(e.target.value)}
|
||||||
>
|
className="text-xs whitespace-pre-wrap rounded-lg p-2 w-full resize-y outline-none"
|
||||||
{extractedText}
|
style={{
|
||||||
</pre>
|
backgroundColor: 'var(--background)',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
minHeight: '4rem',
|
||||||
|
maxHeight: '10rem',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{editedExtractedText !== extractedText && (
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
setSavingText(true)
|
||||||
|
try {
|
||||||
|
await fetch('/api/ai-tagging/fields', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ itemKey, extractedText: editedExtractedText }),
|
||||||
|
})
|
||||||
|
setExtractedText(editedExtractedText)
|
||||||
|
} finally {
|
||||||
|
setSavingText(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={savingText}
|
||||||
|
className="mt-1 text-xs px-2 py-0.5 rounded-full transition-colors disabled:opacity-50"
|
||||||
|
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
|
||||||
|
>
|
||||||
|
{savingText ? '⟳ Saving…' : 'Save'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{translatedText && (
|
{translatedText && (
|
||||||
@@ -324,43 +363,60 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
<div className="flex items-center gap-1.5 flex-wrap">
|
||||||
onClick={async () => {
|
<input
|
||||||
setRetranslating(true)
|
type="text"
|
||||||
try {
|
value={sourceLanguage}
|
||||||
const res = await fetch('/api/ai-tagging/translate', {
|
onChange={(e) => setSourceLanguage(e.target.value)}
|
||||||
method: 'POST',
|
placeholder="Source lang…"
|
||||||
headers: { 'Content-Type': 'application/json' },
|
className="text-xs px-2 py-0.5 rounded-full outline-none"
|
||||||
body: JSON.stringify({ itemKey }),
|
style={{
|
||||||
})
|
backgroundColor: 'var(--background)',
|
||||||
if (!res.ok) {
|
border: '1px solid var(--border)',
|
||||||
const data = await res.json().catch(() => ({}))
|
color: 'var(--text-primary)',
|
||||||
throw new Error((data as { error?: string }).error ?? 'Failed to translate')
|
width: 100,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
setRetranslating(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/ai-tagging/translate', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ itemKey, ...(sourceLanguage.trim() && { sourceLanguage: sourceLanguage.trim() }) }),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}))
|
||||||
|
throw new Error((data as { error?: string }).error ?? 'Failed to translate')
|
||||||
|
}
|
||||||
|
if (res.status !== 202) {
|
||||||
|
const result = await res.json()
|
||||||
|
setTranslatedText(result.translatedText || null)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
} finally {
|
||||||
|
setRetranslating(false)
|
||||||
}
|
}
|
||||||
const result = await res.json()
|
}}
|
||||||
setTranslatedText(result.translatedText || null)
|
disabled={retranslating}
|
||||||
} catch {
|
className="text-xs px-2 py-0.5 rounded-full transition-colors disabled:opacity-50"
|
||||||
// ignore
|
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
|
||||||
} finally {
|
onMouseEnter={(e) => {
|
||||||
setRetranslating(false)
|
if (!retranslating) {
|
||||||
}
|
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'
|
||||||
}}
|
;(e.currentTarget as HTMLElement).style.color = 'var(--background)'
|
||||||
disabled={retranslating}
|
}
|
||||||
className="self-start text-xs px-2 py-0.5 rounded-full transition-colors disabled:opacity-50"
|
}}
|
||||||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
|
onMouseLeave={(e) => {
|
||||||
onMouseEnter={(e) => {
|
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
|
||||||
if (!retranslating) {
|
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
|
||||||
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'
|
}}
|
||||||
;(e.currentTarget as HTMLElement).style.color = 'var(--background)'
|
>
|
||||||
}
|
{retranslating ? '⟳ Translating…' : '🌐 Re-translate'}
|
||||||
}}
|
</button>
|
||||||
onMouseLeave={(e) => {
|
</div>
|
||||||
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
|
|
||||||
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{retranslating ? '⟳ Translating…' : '🌐 Re-translate'}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -192,6 +192,11 @@ export default function TagSelector({ itemKey, onTagsChanged, refreshKey }: Prop
|
|||||||
const data = await res.json().catch(() => ({}))
|
const data = await res.json().catch(() => ({}))
|
||||||
throw new Error((data as { error?: string }).error ?? 'Failed to generate description')
|
throw new Error((data as { error?: string }).error ?? 'Failed to generate description')
|
||||||
}
|
}
|
||||||
|
if (res.status === 202) {
|
||||||
|
setDescError('Queued — check AI Integrations for progress')
|
||||||
|
setTimeout(() => setDescError(null), 4000)
|
||||||
|
return
|
||||||
|
}
|
||||||
const { description } = await res.json()
|
const { description } = await res.json()
|
||||||
setAiDescription(description)
|
setAiDescription(description)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -5,5 +5,8 @@ export async function register() {
|
|||||||
|
|
||||||
const { startScheduler } = await import('./lib/scheduler')
|
const { startScheduler } = await import('./lib/scheduler')
|
||||||
startScheduler()
|
startScheduler()
|
||||||
|
|
||||||
|
const { initJobProcessor } = await import('./lib/ai-jobs')
|
||||||
|
initJobProcessor()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
351
src/lib/ai-jobs.ts
Normal file
351
src/lib/ai-jobs.ts
Normal file
@@ -0,0 +1,351 @@
|
|||||||
|
import crypto from 'crypto'
|
||||||
|
import { getDb } from './db'
|
||||||
|
import { getAiMaxRetries } from './app-settings'
|
||||||
|
import { tagSingleItem, generateItemDescription, extractItemText, translateItemText } from './ai-tagger'
|
||||||
|
|
||||||
|
export type AiJobType = 'tag' | 'describe' | 'extract' | 'translate'
|
||||||
|
export type AiJobStatus = 'queued' | 'running' | 'completed' | 'failed'
|
||||||
|
|
||||||
|
export interface AiJob {
|
||||||
|
id: string
|
||||||
|
itemKey: string
|
||||||
|
libraryId: string
|
||||||
|
jobType: AiJobType
|
||||||
|
status: AiJobStatus
|
||||||
|
error: string | null
|
||||||
|
attempt: number
|
||||||
|
maxRetries: number
|
||||||
|
createdAt: number
|
||||||
|
startedAt: number | null
|
||||||
|
completedAt: number | null
|
||||||
|
itemTitle: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AiJobRow {
|
||||||
|
id: string
|
||||||
|
item_key: string
|
||||||
|
library_id: string
|
||||||
|
job_type: string
|
||||||
|
status: string
|
||||||
|
error: string | null
|
||||||
|
attempt: number
|
||||||
|
max_retries: number
|
||||||
|
created_at: number
|
||||||
|
started_at: number | null
|
||||||
|
completed_at: number | null
|
||||||
|
item_title: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowToJob(row: AiJobRow): AiJob {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
itemKey: row.item_key,
|
||||||
|
libraryId: row.library_id,
|
||||||
|
jobType: row.job_type as AiJobType,
|
||||||
|
status: row.status as AiJobStatus,
|
||||||
|
error: row.error,
|
||||||
|
attempt: row.attempt,
|
||||||
|
maxRetries: row.max_retries,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
startedAt: row.started_at,
|
||||||
|
completedAt: row.completed_at,
|
||||||
|
itemTitle: row.item_title,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Look up the title of a media item for display purposes.
|
||||||
|
*/
|
||||||
|
function resolveItemTitle(itemKey: string): string | null {
|
||||||
|
const db = getDb()
|
||||||
|
const row = db
|
||||||
|
.prepare('SELECT title FROM media_items WHERE item_key = ?')
|
||||||
|
.get(itemKey) as { title: string | null } | undefined
|
||||||
|
return row?.title ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Enqueue ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enqueue an AI job. Deduplicates: if a queued/running job with the same
|
||||||
|
* item_key + job_type already exists, returns its ID instead.
|
||||||
|
*/
|
||||||
|
export function enqueueJob(
|
||||||
|
itemKey: string,
|
||||||
|
jobType: AiJobType,
|
||||||
|
libraryId: string,
|
||||||
|
sourceLanguage?: string,
|
||||||
|
): string {
|
||||||
|
const db = getDb()
|
||||||
|
|
||||||
|
// Deduplication: check for existing queued/running job
|
||||||
|
const existing = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT id FROM ai_jobs
|
||||||
|
WHERE item_key = ? AND job_type = ? AND status IN ('queued', 'running')`
|
||||||
|
)
|
||||||
|
.get(itemKey, jobType) as { id: string } | undefined
|
||||||
|
if (existing) return existing.id
|
||||||
|
|
||||||
|
const id = crypto.randomUUID()
|
||||||
|
const maxRetries = getAiMaxRetries()
|
||||||
|
const title = resolveItemTitle(itemKey)
|
||||||
|
|
||||||
|
// Store sourceLanguage in the error field temporarily for translate jobs
|
||||||
|
// (it's null at creation, so we repurpose it briefly — cleared when the job runs)
|
||||||
|
const metadata = jobType === 'translate' && sourceLanguage ? sourceLanguage : null
|
||||||
|
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO ai_jobs (id, item_key, library_id, job_type, status, error, attempt, max_retries, created_at, item_title)
|
||||||
|
VALUES (?, ?, ?, ?, 'queued', ?, 0, ?, ?, ?)`
|
||||||
|
).run(id, itemKey, libraryId, jobType, metadata, maxRetries, Date.now(), title)
|
||||||
|
|
||||||
|
// Wake the processor
|
||||||
|
wakeProcessor()
|
||||||
|
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enqueue jobs for all media items in a directory (for bulk operations).
|
||||||
|
* Returns the list of job IDs created.
|
||||||
|
*/
|
||||||
|
export function enqueueBulkJobs(
|
||||||
|
libraryId: string,
|
||||||
|
dirPath: string,
|
||||||
|
jobType: AiJobType,
|
||||||
|
itemTypeFilter?: string,
|
||||||
|
extFilter?: Set<string>,
|
||||||
|
): string[] {
|
||||||
|
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 FROM media_items WHERE item_key LIKE ? AND item_type = ?')
|
||||||
|
.all(`${prefix}%`, itemTypeFilter ?? 'mixed_file') as Array<{ item_key: string; item_type: string; file_path: string | null }>
|
||||||
|
|
||||||
|
const path = require('path')
|
||||||
|
const jobIds: string[] = []
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
if (!item.file_path) continue
|
||||||
|
if (extFilter) {
|
||||||
|
const ext = path.extname(item.file_path).toLowerCase()
|
||||||
|
if (!extFilter.has(ext)) continue
|
||||||
|
}
|
||||||
|
const jobId = enqueueJob(item.item_key, jobType, libraryId)
|
||||||
|
jobIds.push(jobId)
|
||||||
|
}
|
||||||
|
|
||||||
|
return jobIds
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Query ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function getJobQueue(): AiJob[] {
|
||||||
|
const db = getDb()
|
||||||
|
const rows = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT * FROM ai_jobs
|
||||||
|
WHERE status IN ('running', 'queued')
|
||||||
|
ORDER BY
|
||||||
|
CASE status WHEN 'running' THEN 0 ELSE 1 END,
|
||||||
|
created_at ASC`
|
||||||
|
)
|
||||||
|
.all() as AiJobRow[]
|
||||||
|
return rows.map(rowToJob)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getJobHistory(limit = 50): AiJob[] {
|
||||||
|
const db = getDb()
|
||||||
|
const rows = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT * FROM ai_jobs
|
||||||
|
WHERE status IN ('completed', 'failed')
|
||||||
|
ORDER BY completed_at DESC
|
||||||
|
LIMIT ?`
|
||||||
|
)
|
||||||
|
.all(limit) as AiJobRow[]
|
||||||
|
return rows.map(rowToJob)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getJob(jobId: string): AiJob | null {
|
||||||
|
const db = getDb()
|
||||||
|
const row = db
|
||||||
|
.prepare('SELECT * FROM ai_jobs WHERE id = ?')
|
||||||
|
.get(jobId) as AiJobRow | undefined
|
||||||
|
return row ? rowToJob(row) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Actions ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function retryJob(jobId: string): boolean {
|
||||||
|
const db = getDb()
|
||||||
|
const result = db
|
||||||
|
.prepare(
|
||||||
|
`UPDATE ai_jobs SET status = 'queued', error = NULL, attempt = 0, started_at = NULL, completed_at = NULL
|
||||||
|
WHERE id = ? AND status = 'failed'`
|
||||||
|
)
|
||||||
|
.run(jobId)
|
||||||
|
if (result.changes > 0) {
|
||||||
|
wakeProcessor()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cancelJob(jobId: string): boolean {
|
||||||
|
const db = getDb()
|
||||||
|
const result = db
|
||||||
|
.prepare("DELETE FROM ai_jobs WHERE id = ? AND status = 'queued'")
|
||||||
|
.run(jobId)
|
||||||
|
return result.changes > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cancelAllQueued(): number {
|
||||||
|
const db = getDb()
|
||||||
|
const result = db
|
||||||
|
.prepare("DELETE FROM ai_jobs WHERE status = 'queued'")
|
||||||
|
.run()
|
||||||
|
return result.changes
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearJobHistory(): number {
|
||||||
|
const db = getDb()
|
||||||
|
const result = db
|
||||||
|
.prepare("DELETE FROM ai_jobs WHERE status IN ('completed', 'failed')")
|
||||||
|
.run()
|
||||||
|
return result.changes
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Processor ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let processorRunning = false
|
||||||
|
let processorWake: (() => void) | null = null
|
||||||
|
|
||||||
|
function wakeProcessor(): void {
|
||||||
|
if (processorWake) {
|
||||||
|
processorWake()
|
||||||
|
} else if (!processorRunning) {
|
||||||
|
runProcessor()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processNextJob(): Promise<boolean> {
|
||||||
|
const db = getDb()
|
||||||
|
|
||||||
|
const row = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT * FROM ai_jobs
|
||||||
|
WHERE status = 'queued'
|
||||||
|
ORDER BY created_at ASC
|
||||||
|
LIMIT 1`
|
||||||
|
)
|
||||||
|
.get() as AiJobRow | undefined
|
||||||
|
|
||||||
|
if (!row) return false
|
||||||
|
|
||||||
|
const now = Date.now()
|
||||||
|
|
||||||
|
// Extract sourceLanguage for translate jobs (stored in error field at enqueue)
|
||||||
|
const sourceLanguage = row.job_type === 'translate' ? row.error : null
|
||||||
|
|
||||||
|
db.prepare(
|
||||||
|
"UPDATE ai_jobs SET status = 'running', started_at = ?, error = NULL WHERE id = ?"
|
||||||
|
).run(now, row.id)
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (row.job_type) {
|
||||||
|
case 'tag':
|
||||||
|
await tagSingleItem(row.item_key)
|
||||||
|
break
|
||||||
|
case 'describe':
|
||||||
|
await generateItemDescription(row.item_key)
|
||||||
|
break
|
||||||
|
case 'extract':
|
||||||
|
await extractItemText(row.item_key)
|
||||||
|
break
|
||||||
|
case 'translate':
|
||||||
|
await translateItemText(row.item_key, sourceLanguage || undefined)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
db.prepare(
|
||||||
|
"UPDATE ai_jobs SET status = 'completed', completed_at = ? WHERE id = ?"
|
||||||
|
).run(Date.now(), row.id)
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : String(err)
|
||||||
|
const attempt = row.attempt + 1
|
||||||
|
|
||||||
|
if (attempt < row.max_retries) {
|
||||||
|
// Re-queue for retry
|
||||||
|
db.prepare(
|
||||||
|
"UPDATE ai_jobs SET status = 'queued', attempt = ?, error = ?, started_at = NULL WHERE id = ?"
|
||||||
|
).run(attempt, errorMessage, row.id)
|
||||||
|
} else {
|
||||||
|
// Final failure
|
||||||
|
db.prepare(
|
||||||
|
"UPDATE ai_jobs SET status = 'failed', attempt = ?, error = ?, completed_at = ? WHERE id = ?"
|
||||||
|
).run(attempt, errorMessage, Date.now(), row.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.warn(
|
||||||
|
`[ai-jobs] Job ${row.id} (${row.job_type} for "${row.item_key}") failed (attempt ${attempt}/${row.max_retries}):`,
|
||||||
|
errorMessage
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runProcessor(): Promise<void> {
|
||||||
|
if (processorRunning) return
|
||||||
|
processorRunning = true
|
||||||
|
console.log('[ai-jobs] Processor started')
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const hadWork = await processNextJob()
|
||||||
|
if (!hadWork) {
|
||||||
|
// Wait for a wake signal or timeout after 60s (then check again for safety)
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
processorWake = resolve
|
||||||
|
setTimeout(() => {
|
||||||
|
processorWake = null
|
||||||
|
resolve()
|
||||||
|
}, 60_000)
|
||||||
|
})
|
||||||
|
processorWake = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[ai-jobs] Processor crashed:', err)
|
||||||
|
} finally {
|
||||||
|
processorRunning = false
|
||||||
|
console.log('[ai-jobs] Processor stopped')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the job processor. Called on server startup.
|
||||||
|
* Resets any jobs stuck in 'running' state (from a previous crash) back to 'queued'.
|
||||||
|
*/
|
||||||
|
export function initJobProcessor(): void {
|
||||||
|
const db = getDb()
|
||||||
|
const result = db
|
||||||
|
.prepare("UPDATE ai_jobs SET status = 'queued', started_at = NULL WHERE status = 'running'")
|
||||||
|
.run()
|
||||||
|
if (result.changes > 0) {
|
||||||
|
console.log(`[ai-jobs] Reset ${result.changes} stuck running job(s) to queued`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if there are any queued jobs and start the processor
|
||||||
|
const pending = db
|
||||||
|
.prepare("SELECT COUNT(*) as count FROM ai_jobs WHERE status = 'queued'")
|
||||||
|
.get() as { count: number }
|
||||||
|
if (pending.count > 0) {
|
||||||
|
runProcessor()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -225,7 +225,7 @@ async function callVisionApi(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Run AI tagging for a single library. Called after the scanner finishes.
|
* Run AI tagging for a single library. Called after the scanner finishes.
|
||||||
* Processes up to BATCH_LIMIT untagged items per invocation.
|
* Enqueues up to BATCH_LIMIT untagged items as jobs for the processor.
|
||||||
*/
|
*/
|
||||||
export async function runAiTagging(library: Library, libraryRoot: string): Promise<void> {
|
export async function runAiTagging(library: Library, libraryRoot: string): Promise<void> {
|
||||||
const config = getEffectiveAiConfig(library.id)
|
const config = getEffectiveAiConfig(library.id)
|
||||||
@@ -234,14 +234,10 @@ export async function runAiTagging(library: Library, libraryRoot: string): Promi
|
|||||||
|
|
||||||
const activeCategoryIds = new Set(getActiveCategoryIdsForLibrary(library.id))
|
const activeCategoryIds = new Set(getActiveCategoryIdsForLibrary(library.id))
|
||||||
const allTags = getTags()
|
const allTags = getTags()
|
||||||
const allCategories = getCategories()
|
|
||||||
|
|
||||||
const tags = allTags.filter((t) => activeCategoryIds.has(t.categoryId))
|
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 db = getDb()
|
const db = getDb()
|
||||||
const untaggedItems = db
|
const untaggedItems = db
|
||||||
.prepare(
|
.prepare(
|
||||||
@@ -254,18 +250,13 @@ export async function runAiTagging(library: Library, libraryRoot: string): Promi
|
|||||||
|
|
||||||
if (untaggedItems.length === 0) return
|
if (untaggedItems.length === 0) return
|
||||||
|
|
||||||
console.log(`[ai-tagger] Processing ${untaggedItems.length} items in library "${library.name}"`)
|
// Import enqueueJob lazily to avoid circular dependency
|
||||||
|
const { enqueueJob } = await import('./ai-jobs')
|
||||||
|
|
||||||
let tagged = 0
|
let enqueued = 0
|
||||||
let consecutiveFailures = 0
|
|
||||||
const markTagged = db.prepare('UPDATE media_items SET ai_tagged_at = ? WHERE item_key = ?')
|
const markTagged = db.prepare('UPDATE media_items SET ai_tagged_at = ? WHERE item_key = ?')
|
||||||
|
|
||||||
for (const item of untaggedItems) {
|
for (const item of untaggedItems) {
|
||||||
if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
|
|
||||||
console.warn(`[ai-tagger] Aborting after ${MAX_CONSECUTIVE_FAILURES} consecutive failures`)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolvedMedia = resolveItemImage(libraryRoot, item)
|
const resolvedMedia = resolveItemImage(libraryRoot, item)
|
||||||
if (!resolvedMedia) {
|
if (!resolvedMedia) {
|
||||||
// No image or video available — mark as tagged so we don't retry every scan
|
// No image or video available — mark as tagged so we don't retry every scan
|
||||||
@@ -273,48 +264,14 @@ export async function runAiTagging(library: Library, libraryRoot: string): Promi
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
enqueueJob(item.item_key, 'tag', library.id)
|
||||||
let base64Images: string[]
|
// Mark as tagged immediately so subsequent scans don't re-enqueue
|
||||||
if (resolvedMedia.mediaType === 'video') {
|
markTagged.run(Date.now(), item.item_key)
|
||||||
const framePaths = await getVideoFramePaths(resolvedMedia.path, library.id, VIDEO_FRAME_PERCENTAGES)
|
enqueued++
|
||||||
base64Images = framePaths.map((p) => fs.readFileSync(p, 'base64'))
|
|
||||||
} else {
|
|
||||||
const thumbnailPath = await getAiImagePath(resolvedMedia.path, library.id)
|
|
||||||
base64Images = [fs.readFileSync(thumbnailPath, 'base64')]
|
|
||||||
}
|
|
||||||
|
|
||||||
const { tags: currentItemTags } = getResolvedTagsForItem(item.item_key)
|
|
||||||
const aiFields = getAiFields(item.item_key)
|
|
||||||
const systemPrompt = buildTagPrompt(tags, categories, {
|
|
||||||
currentTags: currentItemTags,
|
|
||||||
mediaContext: resolvedMedia.mediaType,
|
|
||||||
aiDescription: aiFields.aiDescription,
|
|
||||||
extractedText: aiFields.extractedTextTranslated ?? aiFields.extractedText,
|
|
||||||
customInstruction: config.promptTagger || undefined,
|
|
||||||
})
|
|
||||||
|
|
||||||
const suggestedIds = await callVisionApi(config.endpoint, taggingModel, base64Images, systemPrompt)
|
|
||||||
|
|
||||||
// Filter to valid tags only
|
|
||||||
const validIds = suggestedIds.filter((id) => validTagIds.has(id))
|
|
||||||
for (const tagId of validIds) {
|
|
||||||
addTagToItem(item.item_key, tagId)
|
|
||||||
}
|
|
||||||
|
|
||||||
markTagged.run(Date.now(), item.item_key)
|
|
||||||
tagged++
|
|
||||||
consecutiveFailures = 0
|
|
||||||
} catch (err) {
|
|
||||||
consecutiveFailures++
|
|
||||||
console.warn(
|
|
||||||
`[ai-tagger] Failed to tag "${item.item_key}":`,
|
|
||||||
err instanceof Error ? err.message : err
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tagged > 0) {
|
if (enqueued > 0) {
|
||||||
console.log(`[ai-tagger] Tagged ${tagged}/${untaggedItems.length} items in library "${library.name}"`)
|
console.log(`[ai-tagger] Enqueued ${enqueued} tagging jobs for library "${library.name}"`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -554,6 +511,24 @@ export async function generateItemDescription(itemKey: string): Promise<string>
|
|||||||
* If the extracted text is not in the user's preferred language, auto-translates it.
|
* If the extracted text is not in the user's preferred language, auto-translates it.
|
||||||
* Returns { extractedText, translatedText }.
|
* Returns { extractedText, translatedText }.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* 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 }> {
|
export async function extractItemText(itemKey: string): Promise<{ extractedText: string; translatedText: string | null }> {
|
||||||
const libraryId = itemKey.split(':')[0]
|
const libraryId = itemKey.split(':')[0]
|
||||||
const config = getEffectiveAiConfig(libraryId)
|
const config = getEffectiveAiConfig(libraryId)
|
||||||
@@ -590,9 +565,46 @@ export async function extractItemText(itemKey: string): Promise<{ extractedText:
|
|||||||
const thumbnailPath = await getAiImagePath(resolvedMedia.path, libraryId)
|
const thumbnailPath = await getAiImagePath(resolvedMedia.path, libraryId)
|
||||||
const base64Images = [fs.readFileSync(thumbnailPath, 'base64')]
|
const base64Images = [fs.readFileSync(thumbnailPath, 'base64')]
|
||||||
|
|
||||||
const systemPrompt = `You are an OCR assistant. Extract ALL text visible in the image exactly as it appears. Preserve line breaks and formatting.${config.promptExtract ? ' ' + config.promptExtract : ''} If there is no text in the image, respond with exactly: [NO TEXT]`
|
const preferredLanguage = getPreferredLanguage()
|
||||||
|
const customInstruction = config.promptExtract ? ' ' + config.promptExtract : ''
|
||||||
|
|
||||||
const extractedText = await callVisionApiText(config.endpoint, extractModel, base64Images, systemPrompt)
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
if (!extractedText || extractedText === '[NO TEXT]') {
|
if (!extractedText || extractedText === '[NO TEXT]') {
|
||||||
db.prepare('UPDATE media_items SET extracted_text = NULL, extracted_text_translated = NULL WHERE item_key = ?').run(itemKey)
|
db.prepare('UPDATE media_items SET extracted_text = NULL, extracted_text_translated = NULL WHERE item_key = ?').run(itemKey)
|
||||||
@@ -601,10 +613,9 @@ export async function extractItemText(itemKey: string): Promise<{ extractedText:
|
|||||||
|
|
||||||
db.prepare('UPDATE media_items SET extracted_text = ? WHERE item_key = ?').run(extractedText, itemKey)
|
db.prepare('UPDATE media_items SET extracted_text = ? WHERE item_key = ?').run(extractedText, itemKey)
|
||||||
|
|
||||||
// Auto-translate if preferred language is set
|
// Only translate if the extraction step determined the text is not already in the preferred language
|
||||||
const preferredLanguage = getPreferredLanguage()
|
|
||||||
let translatedText: string | null = null
|
let translatedText: string | null = null
|
||||||
if (preferredLanguage) {
|
if (preferredLanguage && needsTranslation) {
|
||||||
const translateModel = config.modelTranslate || config.model
|
const translateModel = config.modelTranslate || config.model
|
||||||
try {
|
try {
|
||||||
translatedText = await translateText(config.endpoint, translateModel, extractedText, preferredLanguage, config.promptTranslate)
|
translatedText = await translateText(config.endpoint, translateModel, extractedText, preferredLanguage, config.promptTranslate)
|
||||||
@@ -623,7 +634,7 @@ export async function extractItemText(itemKey: string): Promise<{ extractedText:
|
|||||||
* Translate the extracted_text of an item into the preferred language.
|
* Translate the extracted_text of an item into the preferred language.
|
||||||
* Returns the translated text or null if no text to translate.
|
* Returns the translated text or null if no text to translate.
|
||||||
*/
|
*/
|
||||||
export async function translateItemText(itemKey: string): Promise<string | null> {
|
export async function translateItemText(itemKey: string, sourceLanguage?: string): Promise<string | null> {
|
||||||
const libraryId = itemKey.split(':')[0]
|
const libraryId = itemKey.split(':')[0]
|
||||||
const config = getEffectiveAiConfig(libraryId)
|
const config = getEffectiveAiConfig(libraryId)
|
||||||
const translateModel = config.modelTranslate || config.model
|
const translateModel = config.modelTranslate || config.model
|
||||||
@@ -645,7 +656,7 @@ export async function translateItemText(itemKey: string): Promise<string | null>
|
|||||||
const preferredLanguage = getPreferredLanguage()
|
const preferredLanguage = getPreferredLanguage()
|
||||||
if (!preferredLanguage) return null
|
if (!preferredLanguage) return null
|
||||||
|
|
||||||
const translatedText = await translateText(config.endpoint, translateModel, row.extracted_text, preferredLanguage, config.promptTranslate)
|
const translatedText = await translateText(config.endpoint, translateModel, row.extracted_text, preferredLanguage, config.promptTranslate, sourceLanguage)
|
||||||
if (translatedText) {
|
if (translatedText) {
|
||||||
db.prepare('UPDATE media_items SET extracted_text_translated = ? WHERE item_key = ?').run(translatedText, itemKey)
|
db.prepare('UPDATE media_items SET extracted_text_translated = ? WHERE item_key = ?').run(translatedText, itemKey)
|
||||||
}
|
}
|
||||||
@@ -653,6 +664,14 @@ export async function translateItemText(itemKey: string): Promise<string | null>
|
|||||||
return translatedText
|
return translatedText
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the extracted_text of an item.
|
||||||
|
*/
|
||||||
|
export function updateExtractedText(itemKey: string, text: string): void {
|
||||||
|
const db = getDb()
|
||||||
|
db.prepare('UPDATE media_items SET extracted_text = ? WHERE item_key = ?').run(text, itemKey)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Translate text to a target language using the chat API.
|
* Translate text to a target language using the chat API.
|
||||||
* Returns null if the text is already in the target language.
|
* Returns null if the text is already in the target language.
|
||||||
@@ -663,16 +682,22 @@ async function translateText(
|
|||||||
text: string,
|
text: string,
|
||||||
targetLanguage: string,
|
targetLanguage: string,
|
||||||
customInstruction = '',
|
customInstruction = '',
|
||||||
|
sourceLanguage?: string,
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
const 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 : ''}`
|
let systemPrompt: string
|
||||||
|
if (sourceLanguage) {
|
||||||
|
systemPrompt = `You are a translator. Translate the following text from ${sourceLanguage} to ${targetLanguage}.${customInstruction ? ' ' + customInstruction : ''}`
|
||||||
|
} else {
|
||||||
|
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)
|
||||||
|
|
||||||
if (result === '[ALREADY_TARGET_LANGUAGE]' || !result) {
|
if (!sourceLanguage && (result === '[ALREADY_TARGET_LANGUAGE]' || !result)) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
return result
|
return result || null
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -202,3 +202,15 @@ export function getEffectiveAiConfig(libraryId: string): AiConfig {
|
|||||||
promptTranslate: overrides.promptTranslate || global.promptTranslate,
|
promptTranslate: overrides.promptTranslate || global.promptTranslate,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── AI Max Retries ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function getAiMaxRetries(): number {
|
||||||
|
const raw = getSetting('ai_max_retries')
|
||||||
|
const parsed = parseInt(raw ?? '3', 10)
|
||||||
|
return Number.isFinite(parsed) && parsed >= 0 ? parsed : 3
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setAiMaxRetries(n: number): void {
|
||||||
|
setSetting('ai_max_retries', String(Math.max(0, Math.floor(n))))
|
||||||
|
}
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ function initDb(db: Database.Database): void {
|
|||||||
migrateMediaItemsAiTagged(db)
|
migrateMediaItemsAiTagged(db)
|
||||||
migrateMediaItemsAiFields(db)
|
migrateMediaItemsAiFields(db)
|
||||||
migrateLibraryAiSettings(db)
|
migrateLibraryAiSettings(db)
|
||||||
|
migrateAiJobs(db)
|
||||||
seedAppSettings(db)
|
seedAppSettings(db)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,6 +118,7 @@ function seedAppSettings(db: Database.Database): void {
|
|||||||
ai_endpoint: '',
|
ai_endpoint: '',
|
||||||
ai_model: '',
|
ai_model: '',
|
||||||
preferred_language: 'English',
|
preferred_language: 'English',
|
||||||
|
ai_max_retries: '3',
|
||||||
}
|
}
|
||||||
const insert = db.prepare(
|
const insert = db.prepare(
|
||||||
'INSERT OR IGNORE INTO app_settings (key, value) VALUES (?, ?)'
|
'INSERT OR IGNORE INTO app_settings (key, value) VALUES (?, ?)'
|
||||||
@@ -298,3 +300,25 @@ function migrateLibrariesType(db: Database.Database): void {
|
|||||||
`)
|
`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function migrateAiJobs(db: Database.Database): void {
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS ai_jobs (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
item_key TEXT NOT NULL,
|
||||||
|
library_id TEXT NOT NULL,
|
||||||
|
job_type TEXT NOT NULL CHECK(job_type IN ('tag','describe','extract','translate')),
|
||||||
|
status TEXT NOT NULL DEFAULT 'queued' CHECK(status IN ('queued','running','completed','failed')),
|
||||||
|
error TEXT,
|
||||||
|
attempt INTEGER NOT NULL DEFAULT 0,
|
||||||
|
max_retries INTEGER NOT NULL DEFAULT 3,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
started_at INTEGER,
|
||||||
|
completed_at INTEGER,
|
||||||
|
item_title TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ai_jobs_status ON ai_jobs(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS ai_jobs_created_at ON ai_jobs(created_at);
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user