diff --git a/src/app/api/ai-settings/route.ts b/src/app/api/ai-settings/route.ts index 593f37a..7eba49d 100644 --- a/src/app/api/ai-settings/route.ts +++ b/src/app/api/ai-settings/route.ts @@ -1,27 +1,28 @@ import { NextRequest, NextResponse } from 'next/server' import { requireAdmin } from '@/lib/auth' -import { getAiConfig, updateAiConfig } from '@/lib/app-settings' +import { getAiConfig, updateAiConfig, getPreferredLanguage, setPreferredLanguage } from '@/lib/app-settings' export async function GET(request: NextRequest) { const auth = await requireAdmin(request) if (auth instanceof NextResponse) return auth - const { endpoint, model, enabled } = getAiConfig() - return NextResponse.json({ endpoint, model, enabled }) + const { endpoint, model, modelTagging, modelDescribe, modelExtract, modelTranslate, enabled } = getAiConfig() + const preferredLanguage = getPreferredLanguage() + return NextResponse.json({ endpoint, model, modelTagging, modelDescribe, modelExtract, modelTranslate, enabled, preferredLanguage }) } export async function PUT(request: NextRequest) { const auth = await requireAdmin(request) if (auth instanceof NextResponse) return auth - let body: { endpoint?: string; model?: string; enabled?: boolean } + let body: { endpoint?: string; model?: string; modelTagging?: string; modelDescribe?: string; modelExtract?: string; modelTranslate?: string; enabled?: boolean; preferredLanguage?: string } try { body = await request.json() } catch { return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }) } - const { endpoint, model, enabled } = body + const { endpoint, model, enabled, preferredLanguage, modelTagging, modelDescribe, modelExtract, modelTranslate } = body if (typeof endpoint !== 'string') { return NextResponse.json({ error: 'endpoint is required' }, { status: 400 }) @@ -33,6 +34,20 @@ export async function PUT(request: NextRequest) { return NextResponse.json({ error: 'enabled must be a boolean' }, { status: 400 }) } - updateAiConfig(endpoint, model, enabled) - return NextResponse.json({ endpoint, model, enabled }) + updateAiConfig( + endpoint, + model, + enabled, + typeof modelTagging === 'string' ? modelTagging : undefined, + typeof modelDescribe === 'string' ? modelDescribe : undefined, + typeof modelExtract === 'string' ? modelExtract : undefined, + typeof modelTranslate === 'string' ? modelTranslate : undefined, + ) + + if (typeof preferredLanguage === 'string' && preferredLanguage.trim()) { + setPreferredLanguage(preferredLanguage.trim()) + } + + const config = getAiConfig() + return NextResponse.json({ ...config, preferredLanguage: getPreferredLanguage() }) } diff --git a/src/app/api/ai-tagging/describe-bulk/route.ts b/src/app/api/ai-tagging/describe-bulk/route.ts new file mode 100644 index 0000000..fb9d05e --- /dev/null +++ b/src/app/api/ai-tagging/describe-bulk/route.ts @@ -0,0 +1,38 @@ +import { NextRequest, NextResponse } from 'next/server' +import { requireLibraryAccess } from '@/lib/auth' +import { describeDirectoryItems } from '@/lib/ai-tagger' + +export async function POST(request: NextRequest) { + let body: { libraryId?: string; path?: string } + try { + body = await request.json() + } catch { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }) + } + + const { libraryId, path: dirPath } = body + if (!libraryId || typeof libraryId !== 'string') { + return NextResponse.json({ error: 'libraryId is required' }, { status: 400 }) + } + + 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 }) + } +} diff --git a/src/app/api/ai-tagging/describe/route.ts b/src/app/api/ai-tagging/describe/route.ts new file mode 100644 index 0000000..5121c65 --- /dev/null +++ b/src/app/api/ai-tagging/describe/route.ts @@ -0,0 +1,39 @@ +import { NextRequest, NextResponse } from 'next/server' +import { requireLibraryAccess } from '@/lib/auth' +import { generateItemDescription } from '@/lib/ai-tagger' + +export async function POST(request: NextRequest) { + let body: { itemKey?: string } + try { + body = await request.json() + } catch { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }) + } + + const { itemKey } = body + if (!itemKey || typeof itemKey !== 'string') { + return NextResponse.json({ error: 'itemKey is required' }, { status: 400 }) + } + + const libraryId = itemKey.split(':')[0] + 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 }) + } +} diff --git a/src/app/api/ai-tagging/extract-text-bulk/route.ts b/src/app/api/ai-tagging/extract-text-bulk/route.ts new file mode 100644 index 0000000..196ca19 --- /dev/null +++ b/src/app/api/ai-tagging/extract-text-bulk/route.ts @@ -0,0 +1,38 @@ +import { NextRequest, NextResponse } from 'next/server' +import { requireLibraryAccess } from '@/lib/auth' +import { extractDirectoryText } from '@/lib/ai-tagger' + +export async function POST(request: NextRequest) { + let body: { libraryId?: string; path?: string } + try { + body = await request.json() + } catch { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }) + } + + const { libraryId, path: dirPath } = body + if (!libraryId || typeof libraryId !== 'string') { + return NextResponse.json({ error: 'libraryId is required' }, { status: 400 }) + } + + 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 }) + } +} diff --git a/src/app/api/ai-tagging/extract-text/route.ts b/src/app/api/ai-tagging/extract-text/route.ts new file mode 100644 index 0000000..58de630 --- /dev/null +++ b/src/app/api/ai-tagging/extract-text/route.ts @@ -0,0 +1,39 @@ +import { NextRequest, NextResponse } from 'next/server' +import { requireLibraryAccess } from '@/lib/auth' +import { extractItemText } from '@/lib/ai-tagger' + +export async function POST(request: NextRequest) { + let body: { itemKey?: string } + try { + body = await request.json() + } catch { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }) + } + + const { itemKey } = body + if (!itemKey || typeof itemKey !== 'string') { + return NextResponse.json({ error: 'itemKey is required' }, { status: 400 }) + } + + const libraryId = itemKey.split(':')[0] + 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 }) + } +} diff --git a/src/app/api/ai-tagging/fields/route.ts b/src/app/api/ai-tagging/fields/route.ts new file mode 100644 index 0000000..ee647aa --- /dev/null +++ b/src/app/api/ai-tagging/fields/route.ts @@ -0,0 +1,19 @@ +import { NextRequest, NextResponse } from 'next/server' +import { requireLibraryAccess } from '@/lib/auth' +import { getAiFields } from '@/lib/ai-tagger' + +export async function GET(request: NextRequest) { + const { searchParams } = request.nextUrl + const itemKey = searchParams.get('itemKey') + + if (!itemKey) { + return NextResponse.json({ error: 'Missing itemKey' }, { status: 400 }) + } + + const libraryId = itemKey.split(':')[0] + const auth = await requireLibraryAccess(request, libraryId) + if (auth instanceof NextResponse) return auth + + const fields = getAiFields(itemKey) + return NextResponse.json(fields) +} diff --git a/src/app/api/ai-tagging/translate/route.ts b/src/app/api/ai-tagging/translate/route.ts new file mode 100644 index 0000000..740d9fb --- /dev/null +++ b/src/app/api/ai-tagging/translate/route.ts @@ -0,0 +1,36 @@ +import { NextRequest, NextResponse } from 'next/server' +import { requireLibraryAccess } from '@/lib/auth' +import { translateItemText } from '@/lib/ai-tagger' + +export async function POST(request: NextRequest) { + let body: { itemKey?: string } + try { + body = await request.json() + } catch { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }) + } + + const { itemKey } = body + if (!itemKey || typeof itemKey !== 'string') { + return NextResponse.json({ error: 'itemKey is required' }, { status: 400 }) + } + + const libraryId = itemKey.split(':')[0] + const auth = await requireLibraryAccess(request, libraryId) + if (auth instanceof NextResponse) return auth + + try { + const translatedText = await translateItemText(itemKey) + 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 }) + } +} diff --git a/src/app/manage/ai-tagging/page.tsx b/src/app/manage/ai-tagging/page.tsx index b8bf1b0..6839360 100644 --- a/src/app/manage/ai-tagging/page.tsx +++ b/src/app/manage/ai-tagging/page.tsx @@ -5,11 +5,16 @@ import { useEffect, useState, useCallback } from 'react' interface AiSettings { endpoint: string model: string + modelTagging: string + modelDescribe: string + modelExtract: string + modelTranslate: string enabled: boolean + preferredLanguage: string } export default function AiTaggingPage() { - const [settings, setSettings] = useState({ endpoint: '', model: '', enabled: false }) + const [settings, setSettings] = useState({ endpoint: '', model: '', modelTagging: '', modelDescribe: '', modelExtract: '', modelTranslate: '', enabled: false, preferredLanguage: 'English' }) const [loading, setLoading] = useState(true) const [saving, setSaving] = useState(false) const [saveError, setSaveError] = useState(null) @@ -133,7 +138,7 @@ export default function AiTaggingPage() {

- + ((e.currentTarget as HTMLElement).style.borderColor = 'var(--border)')} />

- Model name to use for vision requests. + Default model used for all AI tasks unless overridden below.

+ + setSettings((s) => ({ ...s, modelTagging: e.target.value }))} + placeholder="Leave blank to use default" + className="w-full rounded-lg px-3 py-2 text-sm font-mono 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)')} + /> + + + + setSettings((s) => ({ ...s, modelDescribe: e.target.value }))} + placeholder="Leave blank to use default" + className="w-full rounded-lg px-3 py-2 text-sm font-mono 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)')} + /> + + + + setSettings((s) => ({ ...s, modelExtract: e.target.value }))} + placeholder="Leave blank to use default" + className="w-full rounded-lg px-3 py-2 text-sm font-mono 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)')} + /> + + + + setSettings((s) => ({ ...s, modelTranslate: e.target.value }))} + placeholder="Leave blank to use default" + className="w-full rounded-lg px-3 py-2 text-sm font-mono 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)')} + /> + +