Files
Momento/memento-note/app/(admin)/admin/settings/admin-settings-form.tsx
sepehr cd6819b905 fix: chat "this note" context searches all notes + Ollama model selector missing search
- When chat scope is "this note" (noteContext present), skip RAG/semantic
  search entirely. Previously the AI received all user notes as context
  even when scoped to a single note, causing irrelevant responses.
- Replace 3 native <select> elements for Ollama models with searchable
  Combobox component (tags, embeddings, chat providers).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 19:04:34 +02:00

1110 lines
55 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Checkbox } from '@/components/ui/checkbox'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { Label } from '@/components/ui/label'
import { Combobox } from '@/components/ui/combobox'
import { updateSystemConfig, testEmail } from '@/app/actions/admin-settings'
import { toast } from 'sonner'
import { useState, useEffect, useCallback } from 'react'
import { TestTube, ExternalLink, RefreshCw } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
type AIProvider = 'ollama' | 'openai' | 'custom'
interface AvailableModels {
tags: string[]
embeddings: string[]
}
const MODELS_2026 = {
openai: {
tags: ['gpt-4o', 'gpt-4o-mini', 'gpt-4-turbo', 'gpt-4', 'gpt-3.5-turbo'],
embeddings: ['text-embedding-3-small', 'text-embedding-3-large', 'text-embedding-ada-002']
},
}
export function AdminSettingsForm({ config }: { config: Record<string, string> }) {
const { t } = useLanguage()
const [isSaving, setIsSaving] = useState(false)
const [isTesting, setIsTesting] = useState(false)
// Local state for Checkbox
const [allowRegister, setAllowRegister] = useState(config.ALLOW_REGISTRATION !== 'false')
const [smtpSecure, setSmtpSecure] = useState(config.SMTP_SECURE === 'true')
const [smtpIgnoreCert, setSmtpIgnoreCert] = useState(config.SMTP_IGNORE_CERT === 'true')
// Agent tools state
const [webSearchProvider, setWebSearchProvider] = useState(config.WEB_SEARCH_PROVIDER || 'searxng')
// Email provider state
const [emailProvider, setEmailProvider] = useState<'resend' | 'smtp'>(config.EMAIL_PROVIDER as 'resend' | 'smtp' || (config.RESEND_API_KEY ? 'resend' : 'smtp'))
const [emailTestResult, setEmailTestResult] = useState<{ provider: 'resend' | 'smtp'; success: boolean; message?: string } | null>(null)
const [isTestingSearch, setIsTestingSearch] = useState(false)
const [searchTestResult, setSearchTestResult] = useState<{ success: boolean; message: string } | null>(null)
// AI Provider state - separated for tags, embeddings, and chat
const [tagsProvider, setTagsProvider] = useState<AIProvider>((config.AI_PROVIDER_TAGS as AIProvider) || 'ollama')
const [embeddingsProvider, setEmbeddingsProvider] = useState<AIProvider>((config.AI_PROVIDER_EMBEDDING as AIProvider) || 'ollama')
const [chatProvider, setChatProvider] = useState<AIProvider>((config.AI_PROVIDER_CHAT as AIProvider) || 'ollama')
// Selected Models State (Controlled Inputs)
const [selectedTagsModel, setSelectedTagsModel] = useState<string>(config.AI_MODEL_TAGS || '')
const [selectedEmbeddingModel, setSelectedEmbeddingModel] = useState<string>(config.AI_MODEL_EMBEDDING || '')
const [selectedChatModel, setSelectedChatModel] = useState<string>(config.AI_MODEL_CHAT || '')
// Dynamic Models State
const [ollamaTagsModels, setOllamaTagsModels] = useState<string[]>([])
const [ollamaEmbeddingsModels, setOllamaEmbeddingsModels] = useState<string[]>([])
const [ollamaChatModels, setOllamaChatModels] = useState<string[]>([])
const [isLoadingTagsModels, setIsLoadingTagsModels] = useState(false)
const [isLoadingEmbeddingsModels, setIsLoadingEmbeddingsModels] = useState(false)
const [isLoadingChatModels, setIsLoadingChatModels] = useState(false)
// Custom provider dynamic models
const [customTagsModels, setCustomTagsModels] = useState<string[]>([])
const [customEmbeddingsModels, setCustomEmbeddingsModels] = useState<string[]>([])
const [customChatModels, setCustomChatModels] = useState<string[]>([])
const [isLoadingCustomTagsModels, setIsLoadingCustomTagsModels] = useState(false)
const [isLoadingCustomEmbeddingsModels, setIsLoadingCustomEmbeddingsModels] = useState(false)
const [isLoadingCustomChatModels, setIsLoadingCustomChatModels] = useState(false)
// Fetch Ollama models via Route API (not Server Action) to avoid App Router
// action queue dispatch during render, which causes React Error #310.
const fetchOllamaModels = useCallback(async (type: 'tags' | 'embeddings' | 'chat', url: string) => {
if (!url) return
if (type === 'tags') setIsLoadingTagsModels(true)
else if (type === 'embeddings') setIsLoadingEmbeddingsModels(true)
else setIsLoadingChatModels(true)
try {
const params = new URLSearchParams({ type: 'ollama', url })
const res = await fetch(`/api/admin/models?${params}`)
const result = await res.json()
if (result.success) {
if (type === 'tags') setOllamaTagsModels(result.models)
else if (type === 'embeddings') setOllamaEmbeddingsModels(result.models)
else setOllamaChatModels(result.models)
} else {
toast.error(`${t('admin.ai.fetchModelsFailed')}: ${result.error}`)
}
} catch (error) {
console.error(error)
toast.error(t('admin.ai.fetchModelsFailed'))
} finally {
if (type === 'tags') setIsLoadingTagsModels(false)
else if (type === 'embeddings') setIsLoadingEmbeddingsModels(false)
else setIsLoadingChatModels(false)
}
}, [])
// Fetch Custom provider models via Route API (not Server Action).
const fetchCustomModels = useCallback(async (type: 'tags' | 'embeddings' | 'chat', url: string, apiKey?: string) => {
if (!url) return
if (type === 'tags') setIsLoadingCustomTagsModels(true)
else if (type === 'embeddings') setIsLoadingCustomEmbeddingsModels(true)
else setIsLoadingCustomChatModels(true)
try {
const params = new URLSearchParams({ type: 'custom', url, kind: type })
if (apiKey) params.set('key', apiKey)
const res = await fetch(`/api/admin/models?${params}`)
const result = await res.json()
if (result.success && result.models.length > 0) {
if (type === 'tags') setCustomTagsModels(result.models)
else if (type === 'embeddings') setCustomEmbeddingsModels(result.models)
else setCustomChatModels(result.models)
} else {
toast.error(`${t('admin.ai.fetchModelsFailed')}: ${result.error}`)
}
} catch (error) {
console.error(error)
toast.error(t('admin.ai.fetchModelsFailed'))
} finally {
if (type === 'tags') setIsLoadingCustomTagsModels(false)
else if (type === 'embeddings') setIsLoadingCustomEmbeddingsModels(false)
else setIsLoadingCustomChatModels(false)
}
}, [])
// Single consolidated effect for initial model fetching.
// Batching all provider checks into ONE effect prevents multiple Server Actions
// from being dispatched simultaneously, which would cause React Error #310 by
// flooding the App Router's action queue during an ongoing navigation transition.
useEffect(() => {
const fetchInitialModels = async () => {
const ollamaBase = config.OLLAMA_BASE_URL || 'http://localhost:11434'
const customUrl = config.CUSTOM_OPENAI_BASE_URL || ''
const customKey = config.CUSTOM_OPENAI_API_KEY || ''
if (tagsProvider === 'ollama') {
await fetchOllamaModels('tags', config.OLLAMA_BASE_URL_TAGS || ollamaBase)
} else if (tagsProvider === 'custom' && customUrl) {
await fetchCustomModels('tags', customUrl, customKey)
}
if (embeddingsProvider === 'ollama') {
await fetchOllamaModels('embeddings', config.OLLAMA_BASE_URL_EMBEDDING || ollamaBase)
} else if (embeddingsProvider === 'custom' && customUrl) {
await fetchCustomModels('embeddings', customUrl, customKey)
}
if (chatProvider === 'ollama') {
await fetchOllamaModels('chat', config.OLLAMA_BASE_URL_CHAT || ollamaBase)
} else if (chatProvider === 'custom' && customUrl) {
await fetchCustomModels('chat', customUrl, customKey)
}
}
fetchInitialModels()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const handleSaveSecurity = async (formData: FormData) => {
setIsSaving(true)
const data = {
ALLOW_REGISTRATION: allowRegister ? 'true' : 'false',
}
const result = await updateSystemConfig(data)
setIsSaving(false)
if (result.error) {
toast.error(t('admin.security.updateFailed'))
} else {
toast.success(t('admin.security.updateSuccess'))
}
}
const handleSaveAI = async (formData: FormData) => {
setIsSaving(true)
const data: Record<string, string> = {}
try {
const tagsProv = formData.get('AI_PROVIDER_TAGS') as AIProvider
if (!tagsProv) throw new Error(t('admin.ai.providerTagsRequired'))
data.AI_PROVIDER_TAGS = tagsProv
const tagsModel = formData.get(`AI_MODEL_TAGS_${tagsProv.toUpperCase()}`) as string
if (tagsModel) data.AI_MODEL_TAGS = tagsModel
if (tagsProv === 'ollama') {
const ollamaUrl = formData.get('OLLAMA_BASE_URL_TAGS') as string
if (ollamaUrl) data.OLLAMA_BASE_URL_TAGS = ollamaUrl
} else if (tagsProv === 'openai') {
const openaiKey = formData.get('OPENAI_API_KEY') as string
if (openaiKey) data.OPENAI_API_KEY = openaiKey
} else if (tagsProv === 'custom') {
const customKey = formData.get('CUSTOM_OPENAI_API_KEY_TAGS') as string
const customUrl = formData.get('CUSTOM_OPENAI_BASE_URL_TAGS') as string
if (customKey) data.CUSTOM_OPENAI_API_KEY = customKey
if (customUrl) data.CUSTOM_OPENAI_BASE_URL = customUrl
}
const embedProv = formData.get('AI_PROVIDER_EMBEDDING') as AIProvider
if (!embedProv) throw new Error(t('admin.ai.providerEmbeddingRequired'))
data.AI_PROVIDER_EMBEDDING = embedProv
const embedModel = formData.get(`AI_MODEL_EMBEDDING_${embedProv.toUpperCase()}`) as string
if (embedModel) data.AI_MODEL_EMBEDDING = embedModel
if (embedProv === 'ollama') {
const ollamaUrl = formData.get('OLLAMA_BASE_URL_EMBEDDING') as string
if (ollamaUrl) data.OLLAMA_BASE_URL_EMBEDDING = ollamaUrl
} else if (embedProv === 'openai') {
const openaiKey = formData.get('OPENAI_API_KEY') as string
if (openaiKey) data.OPENAI_API_KEY = openaiKey
} else if (embedProv === 'custom') {
const customKey = formData.get('CUSTOM_OPENAI_API_KEY_EMBEDDING') as string
const customUrl = formData.get('CUSTOM_OPENAI_BASE_URL_EMBEDDING') as string
if (customKey) data.CUSTOM_OPENAI_API_KEY = customKey
if (customUrl) data.CUSTOM_OPENAI_BASE_URL = customUrl
}
// Chat provider config
const chatProv = formData.get('AI_PROVIDER_CHAT') as AIProvider
if (chatProv) {
data.AI_PROVIDER_CHAT = chatProv
const chatModel = formData.get(`AI_MODEL_CHAT_${chatProv.toUpperCase()}`) as string
if (chatModel) data.AI_MODEL_CHAT = chatModel
if (chatProv === 'ollama') {
const ollamaUrl = formData.get('OLLAMA_BASE_URL_CHAT') as string
if (ollamaUrl) data.OLLAMA_BASE_URL_CHAT = ollamaUrl
} else if (chatProv === 'openai') {
const openaiKey = formData.get('OPENAI_API_KEY') as string
if (openaiKey) data.OPENAI_API_KEY = openaiKey
} else if (chatProv === 'custom') {
const customKey = formData.get('CUSTOM_OPENAI_API_KEY_CHAT') as string
const customUrl = formData.get('CUSTOM_OPENAI_BASE_URL_CHAT') as string
if (customKey) data.CUSTOM_OPENAI_API_KEY = customKey
if (customUrl) data.CUSTOM_OPENAI_BASE_URL = customUrl
}
}
const result = await updateSystemConfig(data)
setIsSaving(false)
if (result.error) {
toast.error(t('admin.ai.updateFailed') + ': ' + result.error)
} else {
toast.success(t('admin.ai.updateSuccess'))
}
} catch (error: any) {
setIsSaving(false)
toast.error(t('general.error') + ': ' + error.message)
}
}
const handleSaveEmail = async (formData: FormData) => {
setIsSaving(true)
const data: Record<string, string> = { EMAIL_PROVIDER: emailProvider }
if (emailProvider === 'resend') {
const key = formData.get('RESEND_API_KEY') as string
if (key) data.RESEND_API_KEY = key
} else {
data.SMTP_HOST = formData.get('SMTP_HOST') as string
data.SMTP_PORT = formData.get('SMTP_PORT') as string
data.SMTP_USER = formData.get('SMTP_USER') as string
data.SMTP_PASS = formData.get('SMTP_PASS') as string
data.SMTP_FROM = formData.get('SMTP_FROM') as string
data.SMTP_IGNORE_CERT = smtpIgnoreCert ? 'true' : 'false'
data.SMTP_SECURE = smtpSecure ? 'true' : 'false'
}
const result = await updateSystemConfig(data)
setIsSaving(false)
if (result.error) {
toast.error(t('admin.smtp.updateFailed'))
} else {
toast.success(t('admin.smtp.updateSuccess'))
}
}
const handleTestEmail = async () => {
setIsTesting(true)
try {
const result: any = await testEmail(emailProvider)
if (result.success) {
toast.success(t('admin.smtp.testSuccess'))
setEmailTestResult({ provider: emailProvider, success: true })
} else {
toast.error(t('admin.smtp.testFailed', { error: result.error }))
setEmailTestResult({ provider: emailProvider, success: false, message: result.error })
}
} catch (e: any) {
toast.error(t('general.error') + ': ' + e.message)
setEmailTestResult({ provider: emailProvider, success: false, message: e.message })
} finally {
setIsTesting(false)
}
}
const handleTestSearch = async () => {
setIsTestingSearch(true)
setSearchTestResult(null)
try {
const url = (document.getElementById('SEARXNG_URL') as HTMLInputElement)?.value
|| config.SEARXNG_URL || 'http://localhost:8080'
const apiKey = (document.getElementById('BRAVE_SEARCH_API_KEY') as HTMLInputElement)?.value
|| config.BRAVE_SEARCH_API_KEY || ''
const res = await fetch('/api/admin/test-search', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ provider: webSearchProvider, searxngUrl: url, braveApiKey: apiKey }),
})
const data = await res.json()
setSearchTestResult(data)
} catch (e: any) {
setSearchTestResult({ success: false, message: e.message })
} finally {
setIsTestingSearch(false)
}
}
const handleSaveTools = async (formData: FormData) => {
setIsSaving(true)
const data = {
WEB_SEARCH_PROVIDER: formData.get('WEB_SEARCH_PROVIDER') as string || 'searxng',
SEARXNG_URL: formData.get('SEARXNG_URL') as string || '',
BRAVE_SEARCH_API_KEY: formData.get('BRAVE_SEARCH_API_KEY') as string || '',
JINA_API_KEY: formData.get('JINA_API_KEY') as string || '',
}
const result = await updateSystemConfig(data)
setIsSaving(false)
if (result.error) {
toast.error(t('admin.tools.updateFailed'))
} else {
toast.success(t('admin.tools.updateSuccess'))
}
}
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>{t('admin.security.title')}</CardTitle>
<CardDescription>{t('admin.security.description')}</CardDescription>
</CardHeader>
<form onSubmit={(e) => { e.preventDefault(); handleSaveSecurity(new FormData(e.currentTarget)) }}>
<CardContent className="space-y-4">
<div className="flex items-center space-x-2">
<Checkbox
id="ALLOW_REGISTRATION"
checked={allowRegister}
onCheckedChange={(c) => setAllowRegister(!!c)}
/>
<label
htmlFor="ALLOW_REGISTRATION"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{t('admin.security.allowPublicRegistration')}
</label>
</div>
<p className="text-xs text-muted-foreground">
{t('admin.security.allowPublicRegistrationDescription')}
</p>
</CardContent>
<CardFooter>
<Button type="submit" disabled={isSaving}>{t('admin.security.title')}</Button>
</CardFooter>
</form>
</Card>
<Card>
<CardHeader>
<CardTitle>{t('admin.ai.title')}</CardTitle>
<CardDescription>{t('admin.ai.description')}</CardDescription>
</CardHeader>
<form onSubmit={(e) => { e.preventDefault(); handleSaveAI(new FormData(e.currentTarget)) }}>
<CardContent className="space-y-6"> <div className="space-y-4 p-4 border rounded-lg bg-primary/5 dark:bg-primary/10">
<h3 className="text-base font-semibold flex items-center gap-2">
<span className="text-primary">🏷</span> {t('admin.ai.tagsGenerationProvider')}
</h3>
<p className="text-xs text-muted-foreground">{t('admin.ai.tagsGenerationDescription')}</p>
<div className="space-y-2">
<Label htmlFor="AI_PROVIDER_TAGS">{t('admin.ai.provider')}</Label>
<select
id="AI_PROVIDER_TAGS"
name="AI_PROVIDER_TAGS"
value={tagsProvider}
onChange={(e) => {
const newProvider = e.target.value as AIProvider
setTagsProvider(newProvider)
const defaultModels: Record<string, string> = {
ollama: '',
openai: MODELS_2026.openai.tags[0],
custom: '',
}
setSelectedTagsModel(defaultModels[newProvider] || '')
}}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
<option value="ollama">{t('admin.ai.providerOllamaOption')}</option>
<option value="openai">{t('admin.ai.providerOpenAIOption')}</option>
<option value="custom">{t('admin.ai.providerCustomOption')}</option>
</select>
</div>
{tagsProvider === 'ollama' && (
<div className="space-y-3">
<div className="space-y-2">
<Label htmlFor="OLLAMA_BASE_URL_TAGS">{t('admin.ai.baseUrl')}</Label>
<div className="flex gap-2">
<Input
id="OLLAMA_BASE_URL_TAGS"
name="OLLAMA_BASE_URL_TAGS"
defaultValue={config.OLLAMA_BASE_URL_TAGS || config.OLLAMA_BASE_URL || 'http://localhost:11434'}
placeholder="http://localhost:11434"
/>
<Button
type="button"
size="icon"
variant="outline"
onClick={() => {
const input = document.getElementById('OLLAMA_BASE_URL_TAGS') as HTMLInputElement
fetchOllamaModels('tags', input.value)
}}
disabled={isLoadingTagsModels}
title={t('admin.ai.refreshModels')}
>
<RefreshCw className={`h-4 w-4 ${isLoadingTagsModels ? 'animate-spin' : ''}`} />
</Button>
</div>
</div>
<div className="space-y-2">
<Label>{t('admin.ai.model')}</Label>
<input type="hidden" name="AI_MODEL_TAGS_OLLAMA" value={selectedTagsModel} />
<Combobox
options={ollamaTagsModels.length > 0
? ollamaTagsModels.map((m) => ({ value: m, label: m }))
: selectedTagsModel
? [{ value: selectedTagsModel, label: `${selectedTagsModel} (${t('admin.ai.saved')})` }]
: []
}
value={selectedTagsModel}
onChange={setSelectedTagsModel}
placeholder={selectedTagsModel || t('admin.ai.clickToLoadModels')}
searchPlaceholder={t('admin.ai.searchModel')}
emptyMessage={t('admin.ai.noModels')}
/>
<p className="text-xs text-muted-foreground">
{isLoadingTagsModels ? t('admin.ai.fetchingModels') : t('admin.ai.selectOllamaModel')}
</p>
</div>
</div>
)}
{tagsProvider === 'openai' && (
<div className="space-y-3">
<div className="space-y-2">
<Label htmlFor="OPENAI_API_KEY">{t('admin.ai.apiKey')}</Label>
<Input id="OPENAI_API_KEY" name="OPENAI_API_KEY" type="password" defaultValue={config.OPENAI_API_KEY || ''} placeholder="sk-..." />
<p className="text-xs text-muted-foreground">{t('admin.ai.openAIKeyDescription')}</p>
</div>
<div className="space-y-2">
<Label htmlFor="AI_MODEL_TAGS_OPENAI">{t('admin.ai.model')}</Label>
<select
id="AI_MODEL_TAGS_OPENAI"
name="AI_MODEL_TAGS_OPENAI"
value={selectedTagsModel}
onChange={(e) => setSelectedTagsModel(e.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
{MODELS_2026.openai.tags.map((model) => (
<option key={model} value={model}>{model}</option>
))}
</select>
<p className="text-xs text-muted-foreground"><strong className="text-green-600">gpt-4o-mini</strong> = {t('admin.ai.bestValue')} <strong className="text-primary">gpt-4o</strong> = {t('admin.ai.bestQuality')}</p>
</div>
</div>
)}
{tagsProvider === 'custom' && (
<div className="space-y-3">
<div className="space-y-2">
<Label htmlFor="CUSTOM_OPENAI_BASE_URL_TAGS">{t('admin.ai.baseUrl')}</Label>
<div className="flex gap-2">
<Input
id="CUSTOM_OPENAI_BASE_URL_TAGS"
name="CUSTOM_OPENAI_BASE_URL_TAGS"
defaultValue={config.CUSTOM_OPENAI_BASE_URL || ''}
placeholder="https://api.example.com/v1"
/>
<Button
type="button"
size="icon"
variant="outline"
onClick={() => {
const urlInput = document.getElementById('CUSTOM_OPENAI_BASE_URL_TAGS') as HTMLInputElement
const keyInput = document.getElementById('CUSTOM_OPENAI_API_KEY_TAGS') as HTMLInputElement
fetchCustomModels('tags', urlInput.value, keyInput.value)
}}
disabled={isLoadingCustomTagsModels}
title={t('admin.ai.refreshModels')}
>
<RefreshCw className={`h-4 w-4 ${isLoadingCustomTagsModels ? 'animate-spin' : ''}`} />
</Button>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="CUSTOM_OPENAI_API_KEY_TAGS">{t('admin.ai.apiKey')}</Label>
<Input id="CUSTOM_OPENAI_API_KEY_TAGS" name="CUSTOM_OPENAI_API_KEY_TAGS" type="password" defaultValue={config.CUSTOM_OPENAI_API_KEY || ''} placeholder="sk-..." />
</div>
<div className="space-y-2">
<Label>{t('admin.ai.model')}</Label>
<input type="hidden" name="AI_MODEL_TAGS_CUSTOM" value={selectedTagsModel} />
<Combobox
options={customTagsModels.length > 0
? customTagsModels.map((m) => ({ value: m, label: m }))
: selectedTagsModel
? [{ value: selectedTagsModel, label: selectedTagsModel }]
: []
}
value={selectedTagsModel}
onChange={setSelectedTagsModel}
placeholder={selectedTagsModel || t('admin.ai.clickToLoadModels')}
searchPlaceholder={t('admin.ai.searchModel')}
emptyMessage={t('admin.ai.noModels')}
/>
<p className="text-xs text-muted-foreground">
{isLoadingCustomTagsModels
? t('admin.ai.fetchingModels')
: customTagsModels.length > 0
? t('admin.ai.modelsAvailable', { count: customTagsModels.length })
: t('admin.ai.enterUrlToLoad')}
</p>
</div>
</div>
)}
</div>
<div className="space-y-4 p-4 border rounded-lg bg-green-50/50 dark:bg-green-950/20">
<h3 className="text-base font-semibold flex items-center gap-2">
<span className="text-green-600">🔍</span> {t('admin.ai.embeddingsProvider')}
</h3>
<p className="text-xs text-muted-foreground">{t('admin.ai.embeddingsDescription')}</p>
<div className="space-y-2">
<Label htmlFor="AI_PROVIDER_EMBEDDING">
{t('admin.ai.provider')}
<span className="ml-2 text-xs text-muted-foreground">
{t('admin.ai.currentProvider', { provider: embeddingsProvider })}
</span>
</Label>
<select
id="AI_PROVIDER_EMBEDDING"
name="AI_PROVIDER_EMBEDDING"
value={embeddingsProvider}
onChange={(e) => {
const newProvider = e.target.value as AIProvider
setEmbeddingsProvider(newProvider)
const defaultModels: Record<string, string> = {
ollama: '',
openai: MODELS_2026.openai.embeddings[0],
custom: '',
}
setSelectedEmbeddingModel(defaultModels[newProvider] || '')
}}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
<option value="ollama">{t('admin.ai.providerOllamaOption')}</option>
<option value="openai">{t('admin.ai.providerOpenAIOption')}</option>
<option value="custom">{t('admin.ai.providerCustomOption')}</option>
</select>
</div>
{embeddingsProvider === 'ollama' && (
<div className="space-y-3">
<div className="space-y-2">
<Label htmlFor="OLLAMA_BASE_URL_EMBEDDING">{t('admin.ai.baseUrl')}</Label>
<div className="flex gap-2">
<Input
id="OLLAMA_BASE_URL_EMBEDDING"
name="OLLAMA_BASE_URL_EMBEDDING"
defaultValue={config.OLLAMA_BASE_URL_EMBEDDING || config.OLLAMA_BASE_URL || 'http://localhost:11434'}
placeholder="http://localhost:11434"
/>
<Button
type="button"
size="icon"
variant="outline"
onClick={() => {
const input = document.getElementById('OLLAMA_BASE_URL_EMBEDDING') as HTMLInputElement
fetchOllamaModels('embeddings', input.value)
}}
disabled={isLoadingEmbeddingsModels}
title={t('admin.ai.refreshModels')}
>
<RefreshCw className={`h-4 w-4 ${isLoadingEmbeddingsModels ? 'animate-spin' : ''}`} />
</Button>
</div>
</div>
<div className="space-y-2">
<Label>{t('admin.ai.model')}</Label>
<input type="hidden" name="AI_MODEL_EMBEDDING_OLLAMA" value={selectedEmbeddingModel} />
<Combobox
options={ollamaEmbeddingsModels.length > 0
? ollamaEmbeddingsModels.map((m) => ({ value: m, label: m }))
: selectedEmbeddingModel
? [{ value: selectedEmbeddingModel, label: `${selectedEmbeddingModel} (${t('admin.ai.saved')})` }]
: []
}
value={selectedEmbeddingModel}
onChange={setSelectedEmbeddingModel}
placeholder={selectedEmbeddingModel || t('admin.ai.clickToLoadModels')}
searchPlaceholder={t('admin.ai.searchModel')}
emptyMessage={t('admin.ai.noModels')}
/>
<p className="text-xs text-muted-foreground">
{isLoadingEmbeddingsModels ? t('admin.ai.fetchingModels') : t('admin.ai.selectEmbeddingModel')}
</p>
</div>
</div>
)}
{embeddingsProvider === 'openai' && (
<div className="space-y-3">
<div className="space-y-2">
<Label htmlFor="OPENAI_API_KEY">{t('admin.ai.apiKey')}</Label>
<Input id="OPENAI_API_KEY" name="OPENAI_API_KEY" type="password" defaultValue={config.OPENAI_API_KEY || ''} placeholder="sk-..." />
<p className="text-xs text-muted-foreground">{t('admin.ai.openAIKeyDescription')}</p>
</div>
<div className="space-y-2">
<Label htmlFor="AI_MODEL_EMBEDDING_OPENAI">{t('admin.ai.model')}</Label>
<select
id="AI_MODEL_EMBEDDING_OPENAI"
name="AI_MODEL_EMBEDDING_OPENAI"
value={selectedEmbeddingModel}
onChange={(e) => setSelectedEmbeddingModel(e.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
{MODELS_2026.openai.embeddings.map((model) => (
<option key={model} value={model}>{model}</option>
))}
</select>
<p className="text-xs text-muted-foreground"><strong className="text-green-600">text-embedding-3-small</strong> = {t('admin.ai.bestValue')} <strong className="text-primary">text-embedding-3-large</strong> = {t('admin.ai.bestQuality')}</p>
</div>
</div>
)}
{embeddingsProvider === 'custom' && (
<div className="space-y-3">
<div className="space-y-2">
<Label htmlFor="CUSTOM_OPENAI_BASE_URL_EMBEDDING">{t('admin.ai.baseUrl')}</Label>
<div className="flex gap-2">
<Input
id="CUSTOM_OPENAI_BASE_URL_EMBEDDING"
name="CUSTOM_OPENAI_BASE_URL_EMBEDDING"
defaultValue={config.CUSTOM_OPENAI_BASE_URL || ''}
placeholder="https://api.example.com/v1"
/>
<Button
type="button"
size="icon"
variant="outline"
onClick={() => {
const urlInput = document.getElementById('CUSTOM_OPENAI_BASE_URL_EMBEDDING') as HTMLInputElement
const keyInput = document.getElementById('CUSTOM_OPENAI_API_KEY_EMBEDDING') as HTMLInputElement
fetchCustomModels('embeddings', urlInput.value, keyInput.value)
}}
disabled={isLoadingCustomEmbeddingsModels}
title={t('admin.ai.refreshModels')}
>
<RefreshCw className={`h-4 w-4 ${isLoadingCustomEmbeddingsModels ? 'animate-spin' : ''}`} />
</Button>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="CUSTOM_OPENAI_API_KEY_EMBEDDING">{t('admin.ai.apiKey')}</Label>
<Input id="CUSTOM_OPENAI_API_KEY_EMBEDDING" name="CUSTOM_OPENAI_API_KEY_EMBEDDING" type="password" defaultValue={config.CUSTOM_OPENAI_API_KEY || ''} placeholder="sk-..." />
</div>
<div className="space-y-2">
<Label>{t('admin.ai.model')}</Label>
<input type="hidden" name="AI_MODEL_EMBEDDING_CUSTOM" value={selectedEmbeddingModel} />
<Combobox
options={customEmbeddingsModels.length > 0
? customEmbeddingsModels.map((m) => ({ value: m, label: m }))
: selectedEmbeddingModel
? [{ value: selectedEmbeddingModel, label: selectedEmbeddingModel }]
: []
}
value={selectedEmbeddingModel}
onChange={setSelectedEmbeddingModel}
placeholder={selectedEmbeddingModel || t('admin.ai.clickToLoadModels')}
searchPlaceholder={t('admin.ai.searchModel')}
emptyMessage={t('admin.ai.noModels')}
/>
<p className="text-xs text-muted-foreground">
{isLoadingCustomEmbeddingsModels
? t('admin.ai.fetchingModels')
: customEmbeddingsModels.length > 0
? t('admin.ai.modelsAvailable', { count: customEmbeddingsModels.length })
: t('admin.ai.enterUrlToLoad')}
</p>
</div>
</div>
)}
</div>
{/* Chat Provider Section */}
<div className="space-y-4 p-4 border rounded-lg bg-blue-50/50 dark:bg-blue-950/20">
<h3 className="text-base font-semibold flex items-center gap-2">
<span className="text-blue-600">💬</span> {t('admin.ai.chatProvider')}
</h3>
<p className="text-xs text-muted-foreground">{t('admin.ai.chatDescription')}</p>
<div className="space-y-2">
<Label htmlFor="AI_PROVIDER_CHAT">{t('admin.ai.provider')}</Label>
<select
id="AI_PROVIDER_CHAT"
name="AI_PROVIDER_CHAT"
value={chatProvider}
onChange={(e) => {
const newProvider = e.target.value as AIProvider
setChatProvider(newProvider)
const defaultModels: Record<string, string> = {
ollama: '',
openai: MODELS_2026.openai.tags[0],
custom: '',
}
setSelectedChatModel(defaultModels[newProvider] || '')
}}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
<option value="ollama">{t('admin.ai.providerOllamaOption')}</option>
<option value="openai">{t('admin.ai.providerOpenAIOption')}</option>
<option value="custom">{t('admin.ai.providerCustomOption')}</option>
</select>
</div>
{chatProvider === 'ollama' && (
<div className="space-y-3">
<div className="space-y-2">
<Label htmlFor="OLLAMA_BASE_URL_CHAT">{t('admin.ai.baseUrl')}</Label>
<div className="flex gap-2">
<Input
id="OLLAMA_BASE_URL_CHAT"
name="OLLAMA_BASE_URL_CHAT"
defaultValue={config.OLLAMA_BASE_URL_CHAT || config.OLLAMA_BASE_URL || 'http://localhost:11434'}
placeholder="http://localhost:11434"
/>
<Button
type="button"
size="icon"
variant="outline"
onClick={() => {
const input = document.getElementById('OLLAMA_BASE_URL_CHAT') as HTMLInputElement
fetchOllamaModels('chat', input.value)
}}
disabled={isLoadingChatModels}
title={t('admin.ai.refreshModels')}
>
<RefreshCw className={`h-4 w-4 ${isLoadingChatModels ? 'animate-spin' : ''}`} />
</Button>
</div>
</div>
<div className="space-y-2">
<Label>{t('admin.ai.model')}</Label>
<input type="hidden" name="AI_MODEL_CHAT_OLLAMA" value={selectedChatModel} />
<Combobox
options={ollamaChatModels.length > 0
? ollamaChatModels.map((m) => ({ value: m, label: m }))
: selectedChatModel
? [{ value: selectedChatModel, label: `${selectedChatModel} (${t('admin.ai.saved')})` }]
: []
}
value={selectedChatModel}
onChange={setSelectedChatModel}
placeholder={selectedChatModel || t('admin.ai.clickToLoadModels')}
searchPlaceholder={t('admin.ai.searchModel')}
emptyMessage={t('admin.ai.noModels')}
/>
<p className="text-xs text-muted-foreground">
{isLoadingChatModels ? t('admin.ai.fetchingModels') : t('admin.ai.selectOllamaModel')}
</p>
</div>
</div>
)}
{chatProvider === 'openai' && (
<div className="space-y-3">
<div className="space-y-2">
<Label htmlFor="OPENAI_API_KEY_CHAT">{t('admin.ai.apiKey')}</Label>
<Input id="OPENAI_API_KEY_CHAT" name="OPENAI_API_KEY" type="password" defaultValue={config.OPENAI_API_KEY || ''} placeholder="sk-..." />
<p className="text-xs text-muted-foreground">{t('admin.ai.openAIKeyDescription')}</p>
</div>
<div className="space-y-2">
<Label htmlFor="AI_MODEL_CHAT_OPENAI">{t('admin.ai.model')}</Label>
<select
id="AI_MODEL_CHAT_OPENAI"
name="AI_MODEL_CHAT_OPENAI"
value={selectedChatModel}
onChange={(e) => setSelectedChatModel(e.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
{MODELS_2026.openai.tags.map((model) => (
<option key={model} value={model}>{model}</option>
))}
</select>
<p className="text-xs text-muted-foreground"><strong className="text-green-600">gpt-4o-mini</strong> = {t('admin.ai.bestValue')} <strong className="text-primary">gpt-4o</strong> = {t('admin.ai.bestQuality')}</p>
</div>
</div>
)}
{chatProvider === 'custom' && (
<div className="space-y-3">
<div className="space-y-2">
<Label htmlFor="CUSTOM_OPENAI_BASE_URL_CHAT">{t('admin.ai.baseUrl')}</Label>
<div className="flex gap-2">
<Input
id="CUSTOM_OPENAI_BASE_URL_CHAT"
name="CUSTOM_OPENAI_BASE_URL_CHAT"
defaultValue={config.CUSTOM_OPENAI_BASE_URL || ''}
placeholder="https://api.example.com/v1"
/>
<Button
type="button"
size="icon"
variant="outline"
onClick={() => {
const urlInput = document.getElementById('CUSTOM_OPENAI_BASE_URL_CHAT') as HTMLInputElement
const keyInput = document.getElementById('CUSTOM_OPENAI_API_KEY_CHAT') as HTMLInputElement
fetchCustomModels('chat', urlInput.value, keyInput.value)
}}
disabled={isLoadingCustomChatModels}
title={t('admin.ai.refreshModels')}
>
<RefreshCw className={`h-4 w-4 ${isLoadingCustomChatModels ? 'animate-spin' : ''}`} />
</Button>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="CUSTOM_OPENAI_API_KEY_CHAT">{t('admin.ai.apiKey')}</Label>
<Input id="CUSTOM_OPENAI_API_KEY_CHAT" name="CUSTOM_OPENAI_API_KEY_CHAT" type="password" defaultValue={config.CUSTOM_OPENAI_API_KEY || ''} placeholder="sk-..." />
</div>
<div className="space-y-2">
<Label>{t('admin.ai.model')}</Label>
<input type="hidden" name="AI_MODEL_CHAT_CUSTOM" value={selectedChatModel} />
<Combobox
options={customChatModels.length > 0
? customChatModels.map((m) => ({ value: m, label: m }))
: selectedChatModel
? [{ value: selectedChatModel, label: selectedChatModel }]
: []
}
value={selectedChatModel}
onChange={setSelectedChatModel}
placeholder={selectedChatModel || t('admin.ai.clickToLoadModels')}
searchPlaceholder={t('admin.ai.searchModel')}
emptyMessage={t('admin.ai.noModels')}
/>
<p className="text-xs text-muted-foreground">
{isLoadingCustomChatModels
? t('admin.ai.fetchingModels')
: customChatModels.length > 0
? t('admin.ai.modelsAvailable', { count: customChatModels.length })
: t('admin.ai.enterUrlToLoad')}
</p>
</div>
</div>
)}
</div>
</CardContent>
<CardFooter className="flex justify-between pt-6">
<Button type="submit" disabled={isSaving}>{isSaving ? t('admin.ai.saving') : t('admin.ai.saveSettings')}</Button>
<a href="/admin/ai-test">
<Button type="button" variant="outline" className="gap-2">
<TestTube className="h-4 w-4" />
{t('admin.ai.openTestPanel')}
<ExternalLink className="h-3 w-3" />
</Button>
</a>
</CardFooter>
</form>
</Card>
<Card>
<CardHeader>
<CardTitle>{t('admin.email.title')}</CardTitle>
<CardDescription>{t('admin.email.description')}</CardDescription>
</CardHeader>
<form onSubmit={(e) => { e.preventDefault(); handleSaveEmail(new FormData(e.currentTarget)) }}>
<CardContent className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium">{t('admin.email.provider')}</label>
<div className="flex gap-2">
<button
type="button"
onClick={() => setEmailProvider('resend')}
className={`flex-1 px-4 py-2.5 rounded-lg border text-sm font-medium transition-colors ${
emailProvider === 'resend'
? 'border-primary bg-primary/10 text-primary'
: 'border-border bg-background text-muted-foreground hover:bg-muted'
}`}
>
Resend
</button>
<button
type="button"
onClick={() => setEmailProvider('smtp')}
className={`flex-1 px-4 py-2.5 rounded-lg border text-sm font-medium transition-colors ${
emailProvider === 'smtp'
? 'border-primary bg-primary/10 text-primary'
: 'border-border bg-background text-muted-foreground hover:bg-muted'
}`}
>
SMTP
</button>
</div>
</div>
{/* Email service status */}
<div className="rounded-lg border bg-muted/30 p-3 space-y-1.5">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">{t('admin.email.status')}</div>
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm">
<div className="flex items-center gap-1.5">
<span className={`inline-block w-2 h-2 rounded-full ${config.RESEND_API_KEY ? 'bg-green-500' : 'bg-slate-300'}`} />
<span className={config.RESEND_API_KEY ? 'text-green-700' : 'text-slate-400'}>Resend</span>
{config.RESEND_API_KEY && <span className="text-xs text-muted-foreground">({t('admin.email.keySet')})</span>}
</div>
<div className="flex items-center gap-1.5">
<span className={`inline-block w-2 h-2 rounded-full ${config.SMTP_HOST ? 'bg-green-500' : 'bg-slate-300'}`} />
<span className={config.SMTP_HOST ? 'text-green-700' : 'text-slate-400'}>SMTP</span>
{config.SMTP_HOST && <span className="text-xs text-muted-foreground">({config.SMTP_HOST}:{config.SMTP_PORT || '587'})</span>}
</div>
</div>
<div className="text-xs font-medium text-primary">
{t('admin.email.activeProvider')}: {emailProvider === 'resend' ? 'Resend' : 'SMTP'}
</div>
{emailTestResult && (
<div className={`flex items-center gap-1.5 text-xs pt-1 border-t ${emailTestResult.success ? 'text-green-600' : 'text-red-500'}`}>
<span className={`inline-block w-2 h-2 rounded-full ${emailTestResult.success ? 'bg-green-500' : 'bg-red-500'}`} />
<span>
{emailTestResult.provider === 'resend' ? 'Resend' : 'SMTP'} {emailTestResult.success ? t('admin.email.testOk') : `${t('admin.email.testFail')}: ${emailTestResult.message || ''}`}
</span>
</div>
)}
</div>
{emailProvider === 'resend' ? (
<div className="space-y-3">
<div className="space-y-2">
<label htmlFor="RESEND_API_KEY" className="text-sm font-medium">{t('admin.resend.apiKey')}</label>
<Input id="RESEND_API_KEY" name="RESEND_API_KEY" type="password" defaultValue={config.RESEND_API_KEY || ''} placeholder="re_..." />
<p className="text-xs text-muted-foreground">{t('admin.resend.apiKeyHint')}</p>
</div>
{config.RESEND_API_KEY && (
<div className="flex items-center gap-2 text-xs text-green-600">
<span className="inline-block w-2 h-2 rounded-full bg-green-500" />
{t('admin.resend.configured')}
</div>
)}
</div>
) : (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<label htmlFor="SMTP_HOST" className="text-sm font-medium">{t('admin.smtp.host')}</label>
<Input id="SMTP_HOST" name="SMTP_HOST" defaultValue={config.SMTP_HOST || ''} placeholder="smtp.example.com" />
</div>
<div className="space-y-2">
<label htmlFor="SMTP_PORT" className="text-sm font-medium">{t('admin.smtp.port')}</label>
<Input id="SMTP_PORT" name="SMTP_PORT" defaultValue={config.SMTP_PORT || '587'} placeholder="587" />
</div>
</div>
<div className="space-y-2">
<label htmlFor="SMTP_USER" className="text-sm font-medium">{t('admin.smtp.username')}</label>
<Input id="SMTP_USER" name="SMTP_USER" defaultValue={config.SMTP_USER || ''} />
</div>
<div className="space-y-2">
<label htmlFor="SMTP_PASS" className="text-sm font-medium">{t('admin.smtp.password')}</label>
<Input id="SMTP_PASS" name="SMTP_PASS" type="password" defaultValue={config.SMTP_PASS || ''} />
</div>
<div className="space-y-2">
<label htmlFor="SMTP_FROM" className="text-sm font-medium">{t('admin.smtp.fromEmail')}</label>
<Input id="SMTP_FROM" name="SMTP_FROM" defaultValue={config.SMTP_FROM || 'noreply@memento.app'} />
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="SMTP_SECURE"
checked={smtpSecure}
onCheckedChange={(c) => setSmtpSecure(!!c)}
/>
<label
htmlFor="SMTP_SECURE"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{t('admin.smtp.forceSSL')}
</label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="SMTP_IGNORE_CERT"
checked={smtpIgnoreCert}
onCheckedChange={(c) => setSmtpIgnoreCert(!!c)}
/>
<label
htmlFor="SMTP_IGNORE_CERT"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 text-yellow-600"
>
{t('admin.smtp.ignoreCertErrors')}
</label>
</div>
</div>
)}
</CardContent>
<CardFooter className="flex justify-between pt-6">
<Button type="submit" disabled={isSaving}>{t('admin.email.saveSettings')}</Button>
<Button type="button" variant="secondary" onClick={handleTestEmail} disabled={isTesting}>
{isTesting ? t('admin.smtp.sending') : t('admin.smtp.testEmail')}
</Button>
</CardFooter>
</form>
</Card>
<Card>
<CardHeader>
<CardTitle>{t('admin.tools.title')}</CardTitle>
<CardDescription>{t('admin.tools.description')}</CardDescription>
</CardHeader>
<form onSubmit={(e) => { e.preventDefault(); handleSaveTools(new FormData(e.currentTarget)) }}>
<CardContent className="space-y-4">
<div className="space-y-2">
<label htmlFor="WEB_SEARCH_PROVIDER" className="text-sm font-medium">{t('admin.tools.searchProvider')}</label>
<select
id="WEB_SEARCH_PROVIDER"
name="WEB_SEARCH_PROVIDER"
value={webSearchProvider}
onChange={(e) => setWebSearchProvider(e.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
<option value="searxng">{t('admin.tools.searxng')}</option>
<option value="brave">{t('admin.tools.brave')}</option>
<option value="both">{t('admin.tools.both')}</option>
</select>
</div>
{(webSearchProvider === 'searxng' || webSearchProvider === 'both') && (
<div className="space-y-2">
<label htmlFor="SEARXNG_URL" className="text-sm font-medium">{t('admin.tools.searxngUrl')}</label>
<Input id="SEARXNG_URL" name="SEARXNG_URL" defaultValue={config.SEARXNG_URL || 'http://localhost:8080'} placeholder="http://localhost:8080" />
</div>
)}
{(webSearchProvider === 'brave' || webSearchProvider === 'both') && (
<div className="space-y-2">
<label htmlFor="BRAVE_SEARCH_API_KEY" className="text-sm font-medium">{t('admin.tools.braveKey')}</label>
<Input id="BRAVE_SEARCH_API_KEY" name="BRAVE_SEARCH_API_KEY" type="password" defaultValue={config.BRAVE_SEARCH_API_KEY || ''} placeholder="BSA-..." />
</div>
)}
<div className="space-y-2">
<label htmlFor="JINA_API_KEY" className="text-sm font-medium">{t('admin.tools.jinaKey')}</label>
<Input id="JINA_API_KEY" name="JINA_API_KEY" type="password" defaultValue={config.JINA_API_KEY || ''} placeholder={t('admin.tools.jinaKeyOptional')} />
<p className="text-xs text-muted-foreground">{t('admin.tools.jinaKeyDescription')}</p>
</div>
{/* Résultat du test */}
{searchTestResult && (
<div className={`rounded-lg border p-3 text-sm flex items-start gap-2 ${searchTestResult.success ? 'border-green-200 bg-green-50 text-green-800 dark:border-green-800 dark:bg-green-950/30 dark:text-green-300' : 'border-red-200 bg-red-50 text-red-800 dark:border-red-800 dark:bg-red-950/30 dark:text-red-300'}`}>
<span className={`mt-0.5 inline-block w-2 h-2 rounded-full flex-shrink-0 ${searchTestResult.success ? 'bg-green-500' : 'bg-red-500'}`} />
<span>{searchTestResult.message}</span>
</div>
)}
</CardContent>
<CardFooter className="flex justify-between">
<Button type="submit" disabled={isSaving}>{t('admin.tools.saveSettings')}</Button>
<Button
type="button"
variant="secondary"
onClick={handleTestSearch}
disabled={isTestingSearch}
>
{isTestingSearch ? t('admin.tools.testing') : t('admin.tools.testSearch')}
</Button>
</CardFooter>
</form>
</Card>
</div>
)
}