add ai job queue
This commit is contained in:
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 { 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) {
|
||||
const auth = await requireAdmin(request)
|
||||
@@ -8,7 +8,8 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
const config = getAiConfig()
|
||||
const preferredLanguage = getPreferredLanguage()
|
||||
return NextResponse.json({ ...config, preferredLanguage })
|
||||
const maxRetries = getAiMaxRetries()
|
||||
return NextResponse.json({ ...config, preferredLanguage, maxRetries })
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
@@ -28,6 +29,7 @@ export async function PUT(request: NextRequest) {
|
||||
promptTagger?: string
|
||||
promptExtract?: string
|
||||
promptTranslate?: string
|
||||
maxRetries?: number
|
||||
}
|
||||
try {
|
||||
body = await request.json()
|
||||
@@ -39,6 +41,7 @@ export async function PUT(request: NextRequest) {
|
||||
endpoint, model, enabled, preferredLanguage,
|
||||
modelTagging, modelDescribe, modelExtract, modelTranslate,
|
||||
promptDescribe, promptTagger, promptExtract, promptTranslate,
|
||||
maxRetries,
|
||||
} = body
|
||||
|
||||
if (typeof endpoint !== 'string') {
|
||||
@@ -69,6 +72,10 @@ export async function PUT(request: NextRequest) {
|
||||
setPreferredLanguage(preferredLanguage.trim())
|
||||
}
|
||||
|
||||
if (typeof maxRetries === 'number' && Number.isFinite(maxRetries)) {
|
||||
setAiMaxRetries(maxRetries)
|
||||
}
|
||||
|
||||
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 { 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) {
|
||||
let body: { libraryId?: string; path?: string }
|
||||
@@ -18,21 +22,6 @@ export async function POST(request: NextRequest) {
|
||||
const auth = await requireLibraryAccess(request, libraryId)
|
||||
if (auth instanceof NextResponse) return auth
|
||||
|
||||
try {
|
||||
const processed = await describeDirectoryItems(libraryId, dirPath ?? '')
|
||||
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 })
|
||||
}
|
||||
const jobIds = enqueueBulkJobs(libraryId, dirPath ?? '', 'describe', 'mixed_file', MEDIA_EXTENSIONS)
|
||||
return NextResponse.json({ jobIds, queued: jobIds.length }, { status: 202 })
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { requireLibraryAccess } from '@/lib/auth'
|
||||
import { generateItemDescription } from '@/lib/ai-tagger'
|
||||
import { enqueueJob } from '@/lib/ai-jobs'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
let body: { itemKey?: string }
|
||||
@@ -19,21 +19,6 @@ export async function POST(request: NextRequest) {
|
||||
const auth = await requireLibraryAccess(request, libraryId)
|
||||
if (auth instanceof NextResponse) return auth
|
||||
|
||||
try {
|
||||
const description = await generateItemDescription(itemKey)
|
||||
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 })
|
||||
}
|
||||
const jobId = enqueueJob(itemKey, 'describe', libraryId)
|
||||
return NextResponse.json({ jobId }, { status: 202 })
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
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) {
|
||||
let body: { libraryId?: string; path?: string }
|
||||
@@ -18,21 +20,6 @@ export async function POST(request: NextRequest) {
|
||||
const auth = await requireLibraryAccess(request, libraryId)
|
||||
if (auth instanceof NextResponse) return auth
|
||||
|
||||
try {
|
||||
const processed = await extractDirectoryText(libraryId, dirPath ?? '')
|
||||
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 })
|
||||
}
|
||||
const jobIds = enqueueBulkJobs(libraryId, dirPath ?? '', 'extract', 'mixed_file', IMAGE_EXTENSIONS)
|
||||
return NextResponse.json({ jobIds, queued: jobIds.length }, { status: 202 })
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { requireLibraryAccess } from '@/lib/auth'
|
||||
import { extractItemText } from '@/lib/ai-tagger'
|
||||
import { enqueueJob } from '@/lib/ai-jobs'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
let body: { itemKey?: string }
|
||||
@@ -19,21 +19,6 @@ export async function POST(request: NextRequest) {
|
||||
const auth = await requireLibraryAccess(request, libraryId)
|
||||
if (auth instanceof NextResponse) return auth
|
||||
|
||||
try {
|
||||
const result = await extractItemText(itemKey)
|
||||
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 })
|
||||
}
|
||||
const jobId = enqueueJob(itemKey, 'extract', libraryId)
|
||||
return NextResponse.json({ jobId }, { status: 202 })
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { requireLibraryAccess } from '@/lib/auth'
|
||||
import { tagSingleItem } from '@/lib/ai-tagger'
|
||||
import { enqueueJob } from '@/lib/ai-jobs'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
let body: { itemKey?: string }
|
||||
@@ -19,21 +19,6 @@ export async function POST(request: NextRequest) {
|
||||
const auth = await requireLibraryAccess(request, libraryId)
|
||||
if (auth instanceof NextResponse) return auth
|
||||
|
||||
try {
|
||||
const tagIds = await tagSingleItem(itemKey)
|
||||
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 })
|
||||
}
|
||||
const jobId = enqueueJob(itemKey, 'tag', libraryId)
|
||||
return NextResponse.json({ jobId }, { status: 202 })
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { requireLibraryAccess } from '@/lib/auth'
|
||||
import { translateItemText } from '@/lib/ai-tagger'
|
||||
import { enqueueJob } from '@/lib/ai-jobs'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
let body: { itemKey?: string; sourceLanguage?: string }
|
||||
@@ -19,18 +19,6 @@ export async function POST(request: NextRequest) {
|
||||
const auth = await requireLibraryAccess(request, libraryId)
|
||||
if (auth instanceof NextResponse) return auth
|
||||
|
||||
try {
|
||||
const translatedText = await translateItemText(itemKey, sourceLanguage || undefined)
|
||||
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 })
|
||||
}
|
||||
const jobId = enqueueJob(itemKey, 'translate', libraryId, sourceLanguage || undefined)
|
||||
return NextResponse.json({ jobId }, { status: 202 })
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { useEffect, useState, useCallback, useRef } from 'react'
|
||||
|
||||
interface AiSettings {
|
||||
endpoint: string
|
||||
@@ -15,6 +15,22 @@ interface AiSettings {
|
||||
promptTagger: string
|
||||
promptExtract: 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 {
|
||||
@@ -33,11 +49,24 @@ interface LibraryOverride {
|
||||
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() {
|
||||
const [settings, setSettings] = useState<AiSettings>({
|
||||
endpoint: '', model: '', modelTagging: '', modelDescribe: '', modelExtract: '', modelTranslate: '',
|
||||
enabled: false, preferredLanguage: 'English',
|
||||
promptDescribe: '', promptTagger: '', promptExtract: '', promptTranslate: '',
|
||||
maxRetries: 3,
|
||||
})
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
@@ -54,6 +83,11 @@ export default function AiTaggingPage() {
|
||||
const [librarySaving, setLibrarySaving] = useState<Record<string, boolean>>({})
|
||||
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 () => {
|
||||
try {
|
||||
const [settingsRes, librariesRes] = await Promise.all([
|
||||
@@ -79,6 +113,77 @@ export default function AiTaggingPage() {
|
||||
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) => {
|
||||
try {
|
||||
const res = await fetch(`/api/ai-settings/library/${libraryId}`)
|
||||
@@ -201,12 +306,182 @@ export default function AiTaggingPage() {
|
||||
return (
|
||||
<div className="max-w-2xl">
|
||||
<h1 className="text-2xl font-semibold mb-1" style={{ color: 'var(--text-primary)' }}>
|
||||
AI Tagging
|
||||
AI Integrations
|
||||
</h1>
|
||||
<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>
|
||||
|
||||
{/* ─── 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">
|
||||
{loading ? (
|
||||
<LoadingRows />
|
||||
@@ -365,6 +640,29 @@ export default function AiTaggingPage() {
|
||||
</p>
|
||||
</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 && (
|
||||
<p
|
||||
className="text-sm rounded-lg px-3 py-2"
|
||||
|
||||
Reference in New Issue
Block a user