add per-library AI model and prompt customization
- Add library_ai_settings table with migration for per-library overrides - Extend AiConfig with editable prompt parts for description, tagging, extraction, and translation steps; defaults match previous hardcoded values - Add getEffectiveAiConfig(libraryId) that merges global settings with library-level overrides (empty override falls through to global) - Update all ai-tagger functions to use getEffectiveAiConfig and build prompts from configurable parts - Add GET/PUT /api/ai-settings/library/[id] for per-library overrides - Update /api/ai-settings GET/PUT to include prompt fields - Add Prompts section and Library Overrides section to admin UI Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
44
src/app/api/ai-settings/library/[id]/route.ts
Normal file
44
src/app/api/ai-settings/library/[id]/route.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { requireAdmin } from '@/lib/auth'
|
||||
import { getLibraryAiOverrides, setLibraryAiOverrides } from '@/lib/app-settings'
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const auth = await requireAdmin(request)
|
||||
if (auth instanceof NextResponse) return auth
|
||||
|
||||
const { id } = await params
|
||||
return NextResponse.json(getLibraryAiOverrides(id))
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const auth = await requireAdmin(request)
|
||||
if (auth instanceof NextResponse) return auth
|
||||
|
||||
const { id } = await params
|
||||
|
||||
let body: Record<string, unknown>
|
||||
try {
|
||||
body = await request.json()
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
|
||||
}
|
||||
|
||||
setLibraryAiOverrides(id, {
|
||||
modelTagging: typeof body.modelTagging === 'string' ? body.modelTagging : undefined,
|
||||
modelDescribe: typeof body.modelDescribe === 'string' ? body.modelDescribe : undefined,
|
||||
modelExtract: typeof body.modelExtract === 'string' ? body.modelExtract : undefined,
|
||||
modelTranslate: typeof body.modelTranslate === 'string' ? body.modelTranslate : undefined,
|
||||
promptDescribe: typeof body.promptDescribe === 'string' ? body.promptDescribe : undefined,
|
||||
promptTagger: typeof body.promptTagger === 'string' ? body.promptTagger : undefined,
|
||||
promptExtract: typeof body.promptExtract === 'string' ? body.promptExtract : undefined,
|
||||
promptTranslate: typeof body.promptTranslate === 'string' ? body.promptTranslate : undefined,
|
||||
})
|
||||
|
||||
return NextResponse.json(getLibraryAiOverrides(id))
|
||||
}
|
||||
@@ -6,23 +6,40 @@ export async function GET(request: NextRequest) {
|
||||
const auth = await requireAdmin(request)
|
||||
if (auth instanceof NextResponse) return auth
|
||||
|
||||
const { endpoint, model, modelTagging, modelDescribe, modelExtract, modelTranslate, enabled } = getAiConfig()
|
||||
const config = getAiConfig()
|
||||
const preferredLanguage = getPreferredLanguage()
|
||||
return NextResponse.json({ endpoint, model, modelTagging, modelDescribe, modelExtract, modelTranslate, enabled, preferredLanguage })
|
||||
return NextResponse.json({ ...config, preferredLanguage })
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
const auth = await requireAdmin(request)
|
||||
if (auth instanceof NextResponse) return auth
|
||||
|
||||
let body: { endpoint?: string; model?: string; modelTagging?: string; modelDescribe?: string; modelExtract?: string; modelTranslate?: string; enabled?: boolean; preferredLanguage?: string }
|
||||
let body: {
|
||||
endpoint?: string
|
||||
model?: string
|
||||
modelTagging?: string
|
||||
modelDescribe?: string
|
||||
modelExtract?: string
|
||||
modelTranslate?: string
|
||||
enabled?: boolean
|
||||
preferredLanguage?: string
|
||||
promptDescribe?: string
|
||||
promptTagger?: string
|
||||
promptExtract?: string
|
||||
promptTranslate?: string
|
||||
}
|
||||
try {
|
||||
body = await request.json()
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
|
||||
}
|
||||
|
||||
const { endpoint, model, enabled, preferredLanguage, modelTagging, modelDescribe, modelExtract, modelTranslate } = body
|
||||
const {
|
||||
endpoint, model, enabled, preferredLanguage,
|
||||
modelTagging, modelDescribe, modelExtract, modelTranslate,
|
||||
promptDescribe, promptTagger, promptExtract, promptTranslate,
|
||||
} = body
|
||||
|
||||
if (typeof endpoint !== 'string') {
|
||||
return NextResponse.json({ error: 'endpoint is required' }, { status: 400 })
|
||||
@@ -42,6 +59,10 @@ export async function PUT(request: NextRequest) {
|
||||
typeof modelDescribe === 'string' ? modelDescribe : undefined,
|
||||
typeof modelExtract === 'string' ? modelExtract : undefined,
|
||||
typeof modelTranslate === 'string' ? modelTranslate : undefined,
|
||||
typeof promptDescribe === 'string' ? promptDescribe : undefined,
|
||||
typeof promptTagger === 'string' ? promptTagger : undefined,
|
||||
typeof promptExtract === 'string' ? promptExtract : undefined,
|
||||
typeof promptTranslate === 'string' ? promptTranslate : undefined,
|
||||
)
|
||||
|
||||
if (typeof preferredLanguage === 'string' && preferredLanguage.trim()) {
|
||||
|
||||
@@ -11,10 +11,34 @@ interface AiSettings {
|
||||
modelTranslate: string
|
||||
enabled: boolean
|
||||
preferredLanguage: string
|
||||
promptDescribe: string
|
||||
promptTagger: string
|
||||
promptExtract: string
|
||||
promptTranslate: string
|
||||
}
|
||||
|
||||
interface Library {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
interface LibraryOverride {
|
||||
modelTagging: string
|
||||
modelDescribe: string
|
||||
modelExtract: string
|
||||
modelTranslate: string
|
||||
promptDescribe: string
|
||||
promptTagger: string
|
||||
promptExtract: string
|
||||
promptTranslate: string
|
||||
}
|
||||
|
||||
export default function AiTaggingPage() {
|
||||
const [settings, setSettings] = useState<AiSettings>({ endpoint: '', model: '', modelTagging: '', modelDescribe: '', modelExtract: '', modelTranslate: '', enabled: false, preferredLanguage: 'English' })
|
||||
const [settings, setSettings] = useState<AiSettings>({
|
||||
endpoint: '', model: '', modelTagging: '', modelDescribe: '', modelExtract: '', modelTranslate: '',
|
||||
enabled: false, preferredLanguage: 'English',
|
||||
promptDescribe: '', promptTagger: '', promptExtract: '', promptTranslate: '',
|
||||
})
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saveError, setSaveError] = useState<string | null>(null)
|
||||
@@ -24,12 +48,26 @@ export default function AiTaggingPage() {
|
||||
const [retagging, setRetagging] = useState(false)
|
||||
const [retagResult, setRetagResult] = useState<string | null>(null)
|
||||
|
||||
const [libraries, setLibraries] = useState<Library[]>([])
|
||||
const [libraryOverrides, setLibraryOverrides] = useState<Record<string, LibraryOverride>>({})
|
||||
const [expandedLibrary, setExpandedLibrary] = useState<string | null>(null)
|
||||
const [librarySaving, setLibrarySaving] = useState<Record<string, boolean>>({})
|
||||
const [librarySaveResult, setLibrarySaveResult] = useState<Record<string, { ok: boolean; message: string }>>({})
|
||||
|
||||
const fetchSettings = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/ai-settings')
|
||||
if (!res.ok) return
|
||||
const data: AiSettings = await res.json()
|
||||
setSettings(data)
|
||||
const [settingsRes, librariesRes] = await Promise.all([
|
||||
fetch('/api/ai-settings'),
|
||||
fetch('/api/libraries'),
|
||||
])
|
||||
if (settingsRes.ok) {
|
||||
const data: AiSettings = await settingsRes.json()
|
||||
setSettings(data)
|
||||
}
|
||||
if (librariesRes.ok) {
|
||||
const data: Library[] = await librariesRes.json()
|
||||
setLibraries(data)
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
@@ -41,6 +79,29 @@ export default function AiTaggingPage() {
|
||||
fetchSettings()
|
||||
}, [fetchSettings])
|
||||
|
||||
const fetchLibraryOverrides = useCallback(async (libraryId: string) => {
|
||||
try {
|
||||
const res = await fetch(`/api/ai-settings/library/${libraryId}`)
|
||||
if (res.ok) {
|
||||
const data: LibraryOverride = await res.json()
|
||||
setLibraryOverrides((prev) => ({ ...prev, [libraryId]: data }))
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleToggleLibrary = (libraryId: string) => {
|
||||
if (expandedLibrary === libraryId) {
|
||||
setExpandedLibrary(null)
|
||||
} else {
|
||||
setExpandedLibrary(libraryId)
|
||||
if (!libraryOverrides[libraryId]) {
|
||||
fetchLibraryOverrides(libraryId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setSaveError(null)
|
||||
@@ -104,6 +165,39 @@ export default function AiTaggingPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveLibraryOverride = async (libraryId: string) => {
|
||||
const overrides = libraryOverrides[libraryId]
|
||||
if (!overrides) return
|
||||
setLibrarySaving((prev) => ({ ...prev, [libraryId]: true }))
|
||||
setLibrarySaveResult((prev) => ({ ...prev, [libraryId]: { ok: false, message: '' } }))
|
||||
try {
|
||||
const res = await fetch(`/api/ai-settings/library/${libraryId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(overrides),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (res.ok) {
|
||||
setLibraryOverrides((prev) => ({ ...prev, [libraryId]: data }))
|
||||
setLibrarySaveResult((prev) => ({ ...prev, [libraryId]: { ok: true, message: 'Saved.' } }))
|
||||
setTimeout(() => setLibrarySaveResult((prev) => ({ ...prev, [libraryId]: { ok: true, message: '' } })), 3000)
|
||||
} else {
|
||||
setLibrarySaveResult((prev) => ({ ...prev, [libraryId]: { ok: false, message: data.error ?? 'Failed to save.' } }))
|
||||
}
|
||||
} catch {
|
||||
setLibrarySaveResult((prev) => ({ ...prev, [libraryId]: { ok: false, message: 'Network error.' } }))
|
||||
} finally {
|
||||
setLibrarySaving((prev) => ({ ...prev, [libraryId]: false }))
|
||||
}
|
||||
}
|
||||
|
||||
const updateLibraryOverride = (libraryId: string, field: keyof LibraryOverride, value: string) => {
|
||||
setLibraryOverrides((prev) => ({
|
||||
...prev,
|
||||
[libraryId]: { ...(prev[libraryId] ?? emptyOverride()), [field]: value },
|
||||
}))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl">
|
||||
<h1 className="text-2xl font-semibold mb-1" style={{ color: 'var(--text-primary)' }}>
|
||||
@@ -334,6 +428,255 @@ export default function AiTaggingPage() {
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section title="Prompts">
|
||||
{loading ? (
|
||||
<LoadingRows />
|
||||
) : (
|
||||
<form onSubmit={handleSave} className="flex flex-col gap-5">
|
||||
<Field label="Description Instructions">
|
||||
<textarea
|
||||
rows={4}
|
||||
value={settings.promptDescribe}
|
||||
onChange={(e) => setSettings((s) => ({ ...s, promptDescribe: e.target.value }))}
|
||||
className="w-full rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 resize-y"
|
||||
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)')}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Tagger Instructions">
|
||||
<textarea
|
||||
rows={4}
|
||||
value={settings.promptTagger}
|
||||
onChange={(e) => setSettings((s) => ({ ...s, promptTagger: e.target.value }))}
|
||||
placeholder="(blank by default)"
|
||||
className="w-full rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 resize-y"
|
||||
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)')}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Text Extraction Instructions">
|
||||
<textarea
|
||||
rows={4}
|
||||
value={settings.promptExtract}
|
||||
onChange={(e) => setSettings((s) => ({ ...s, promptExtract: e.target.value }))}
|
||||
className="w-full rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 resize-y"
|
||||
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)')}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Translation Instructions">
|
||||
<textarea
|
||||
rows={4}
|
||||
value={settings.promptTranslate}
|
||||
onChange={(e) => setSettings((s) => ({ ...s, promptTranslate: e.target.value }))}
|
||||
className="w-full rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 resize-y"
|
||||
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)')}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{saveError && (
|
||||
<p
|
||||
className="text-sm rounded-lg px-3 py-2"
|
||||
style={{ backgroundColor: '#7f1d1d33', color: '#fca5a5' }}
|
||||
>
|
||||
{saveError}
|
||||
</p>
|
||||
)}
|
||||
{saveSuccess && (
|
||||
<p
|
||||
className="text-sm rounded-lg px-3 py-2"
|
||||
style={{ backgroundColor: '#14532d33', color: '#4ade80' }}
|
||||
>
|
||||
Settings saved.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium transition-colors disabled:opacity-50"
|
||||
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
|
||||
onMouseEnter={(e) => {
|
||||
if (!saving) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--accent-hover)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--accent)'
|
||||
}}
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save Prompts'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section title="Library Overrides">
|
||||
{loading ? (
|
||||
<LoadingRows />
|
||||
) : libraries.length === 0 ? (
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>No libraries configured.</p>
|
||||
) : (
|
||||
<div className="flex flex-col divide-y" style={{ borderColor: 'var(--border)' }}>
|
||||
{libraries.map((lib) => {
|
||||
const isExpanded = expandedLibrary === lib.id
|
||||
const overrides = libraryOverrides[lib.id] ?? null
|
||||
const isSaving = librarySaving[lib.id] ?? false
|
||||
const saveResult = librarySaveResult[lib.id]
|
||||
|
||||
return (
|
||||
<div key={lib.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleToggleLibrary(lib.id)}
|
||||
className="w-full flex items-center justify-between py-3 text-left"
|
||||
style={{ color: 'var(--text-primary)' }}
|
||||
>
|
||||
<span className="text-sm font-medium">{lib.name}</span>
|
||||
<span
|
||||
className="text-xs transition-transform"
|
||||
style={{
|
||||
color: 'var(--text-secondary)',
|
||||
transform: isExpanded ? 'rotate(180deg)' : 'rotate(0deg)',
|
||||
display: 'inline-block',
|
||||
}}
|
||||
>
|
||||
▾
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="pb-5 flex flex-col gap-5">
|
||||
{overrides === null ? (
|
||||
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>Loading…</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>
|
||||
Leave any field blank to use the global default.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-xs font-medium uppercase tracking-wide" style={{ color: 'var(--text-secondary)' }}>Models</p>
|
||||
{(
|
||||
[
|
||||
['modelTagging', 'Tagging Model'],
|
||||
['modelDescribe', 'Description Model'],
|
||||
['modelExtract', 'Text Extraction Model'],
|
||||
['modelTranslate', 'Translation Model'],
|
||||
] as [keyof LibraryOverride, string][]
|
||||
).map(([field, label]) => (
|
||||
<Field key={field} label={label}>
|
||||
<input
|
||||
type="text"
|
||||
value={overrides[field]}
|
||||
onChange={(e) => updateLibraryOverride(lib.id, field, e.target.value)}
|
||||
placeholder={`Leave blank to use global default${settings[field as keyof AiSettings] ? ` (${settings[field as keyof AiSettings]})` : ''}`}
|
||||
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)')}
|
||||
/>
|
||||
</Field>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-xs font-medium uppercase tracking-wide" style={{ color: 'var(--text-secondary)' }}>Prompts</p>
|
||||
{(
|
||||
[
|
||||
['promptDescribe', 'Description Instructions', settings.promptDescribe],
|
||||
['promptTagger', 'Tagger Instructions', settings.promptTagger],
|
||||
['promptExtract', 'Text Extraction Instructions', settings.promptExtract],
|
||||
['promptTranslate', 'Translation Instructions', settings.promptTranslate],
|
||||
] as [keyof LibraryOverride, string, string][]
|
||||
).map(([field, label, globalValue]) => (
|
||||
<Field key={field} label={label}>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={overrides[field]}
|
||||
onChange={(e) => updateLibraryOverride(lib.id, field, e.target.value)}
|
||||
placeholder={globalValue ? `Leave blank to use global default:\n${globalValue}` : 'Leave blank to use global default'}
|
||||
className="w-full rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 resize-y"
|
||||
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)')}
|
||||
/>
|
||||
</Field>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{saveResult?.message && (
|
||||
<p
|
||||
className="text-sm rounded-lg px-3 py-2"
|
||||
style={{
|
||||
backgroundColor: saveResult.ok ? '#14532d33' : '#7f1d1d33',
|
||||
color: saveResult.ok ? '#4ade80' : '#fca5a5',
|
||||
}}
|
||||
>
|
||||
{saveResult.message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSaveLibraryOverride(lib.id)}
|
||||
disabled={isSaving}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium transition-colors disabled:opacity-50"
|
||||
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isSaving) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--accent-hover)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--accent)'
|
||||
}}
|
||||
>
|
||||
{isSaving ? 'Saving...' : `Save ${lib.name} Overrides`}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section title="Re-tag">
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
@@ -365,6 +708,13 @@ export default function AiTaggingPage() {
|
||||
)
|
||||
}
|
||||
|
||||
function emptyOverride(): LibraryOverride {
|
||||
return {
|
||||
modelTagging: '', modelDescribe: '', modelExtract: '', modelTranslate: '',
|
||||
promptDescribe: '', promptTagger: '', promptExtract: '', promptTranslate: '',
|
||||
}
|
||||
}
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="mb-10">
|
||||
|
||||
Reference in New Issue
Block a user