Files
Momento/memento-note/app/(main)/admin/settings/admin-settings-form.tsx
Sepehr Ramezani d1cda126d8 fix: auto-tagging provider resolution, email test flow, language detection
Auto-tagging:
- CRITICAL FIX: contextual-auto-tag.service.ts was calling getAIProvider()
  (alias for getEmbeddingsProvider) instead of getTagsProvider(). This meant
  auto-tagging used the embeddings provider/model instead of the tags one.
  Now correctly uses getTagsProvider() in both suggestFromExistingLabels and
  suggestNewLabels methods.
- Pass user's detected language to suggestLabels() for localized prompts
  (was hardcoded to 'en')

Email:
- Fix Resend "from" field: pass DB config to sendViaResend() instead of
  re-fetching from DB. Uses SMTP_FROM from config, with localhost-aware fallback.
- Add "Sender email" field in admin Resend section so users can set SMTP_FROM
- Save SMTP_FROM when Resend is selected (was only saved for SMTP mode)
- Test email button now saves config to DB BEFORE testing, so unsaved form
  values are used (was reading stale DB values)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-21 22:22:02 +02:00

1117 lines
56 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 { getOllamaModels } from '@/app/actions/ollama'
import { getCustomModels, getCustomEmbeddingModels } from '@/app/actions/custom-provider'
import { toast } from 'sonner'
import { useState, useEffect, useCallback } from 'react'
import Link from 'next/link'
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)
// 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
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 result = await getOllamaModels(url)
if (result.success) {
if (type === 'tags') setOllamaTagsModels(result.models)
else if (type === 'embeddings') setOllamaEmbeddingsModels(result.models)
else setOllamaChatModels(result.models)
} else {
toast.error(`Failed to fetch Ollama models: ${result.error}`)
}
} catch (error) {
console.error(error)
toast.error('Failed to fetch Ollama models')
} finally {
if (type === 'tags') setIsLoadingTagsModels(false)
else if (type === 'embeddings') setIsLoadingEmbeddingsModels(false)
else setIsLoadingChatModels(false)
}
}, [])
// Fetch Custom provider models — tags use /v1/models, embeddings use /v1/embeddings/models
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 result = type === 'embeddings'
? await getCustomEmbeddingModels(url, apiKey)
: await getCustomModels(url, apiKey)
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(`Impossible de récupérer les modèles : ${result.error}`)
}
} catch (error) {
console.error(error)
toast.error('Erreur lors de la récupération des modèles')
} finally {
if (type === 'tags') setIsLoadingCustomTagsModels(false)
else if (type === 'embeddings') setIsLoadingCustomEmbeddingsModels(false)
else setIsLoadingCustomChatModels(false)
}
}, [])
// Initial fetch for Ollama models if provider is selected
useEffect(() => {
if (tagsProvider === 'ollama') {
const url = config.OLLAMA_BASE_URL_TAGS || config.OLLAMA_BASE_URL || 'http://localhost:11434'
fetchOllamaModels('tags', url)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tagsProvider])
useEffect(() => {
if (embeddingsProvider === 'ollama') {
const url = config.OLLAMA_BASE_URL_EMBEDDING || config.OLLAMA_BASE_URL || 'http://localhost:11434'
fetchOllamaModels('embeddings', url)
} else if (embeddingsProvider === 'custom') {
const url = config.CUSTOM_OPENAI_BASE_URL || ''
const key = config.CUSTOM_OPENAI_API_KEY || ''
if (url) fetchCustomModels('embeddings', url, key)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [embeddingsProvider])
useEffect(() => {
if (tagsProvider === 'custom') {
const url = config.CUSTOM_OPENAI_BASE_URL || ''
const key = config.CUSTOM_OPENAI_API_KEY || ''
if (url) fetchCustomModels('tags', url, key)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tagsProvider])
useEffect(() => {
if (chatProvider === 'ollama') {
const url = config.OLLAMA_BASE_URL_CHAT || config.OLLAMA_BASE_URL || 'http://localhost:11434'
fetchOllamaModels('chat', url)
} else if (chatProvider === 'custom') {
const url = config.CUSTOM_OPENAI_BASE_URL || ''
const key = config.CUSTOM_OPENAI_API_KEY || ''
if (url) fetchCustomModels('chat', url, key)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [chatProvider])
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
const from = formData.get('SMTP_FROM') as string
if (from) data.SMTP_FROM = from
} 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 {
// Save email config to DB first so test uses the latest values
const emailForm = document.querySelector('form[name="email-form"]') as HTMLFormElement
if (emailForm) {
const formData = new FormData(emailForm)
const saveData: Record<string, string> = { EMAIL_PROVIDER: emailProvider }
if (emailProvider === 'resend') {
const key = formData.get('RESEND_API_KEY') as string
if (key) saveData.RESEND_API_KEY = key
const from = formData.get('SMTP_FROM') as string
if (from) saveData.SMTP_FROM = from
} else {
saveData.SMTP_HOST = formData.get('SMTP_HOST') as string
saveData.SMTP_PORT = formData.get('SMTP_PORT') as string
saveData.SMTP_USER = formData.get('SMTP_USER') as string
saveData.SMTP_PASS = formData.get('SMTP_PASS') as string
saveData.SMTP_FROM = formData.get('SMTP_FROM') as string
saveData.SMTP_IGNORE_CERT = smtpIgnoreCert ? 'true' : 'false'
saveData.SMTP_SECURE = smtpSecure ? 'true' : 'false'
}
const saveResult = await updateSystemConfig(saveData)
if (saveResult.error) {
toast.error('Failed to save settings before testing: ' + saveResult.error)
setIsTesting(false)
return
}
}
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 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="Refresh Models"
>
<RefreshCw className={`h-4 w-4 ${isLoadingTagsModels ? 'animate-spin' : ''}`} />
</Button>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="AI_MODEL_TAGS_OLLAMA">{t('admin.ai.model')}</Label>
<select
id="AI_MODEL_TAGS_OLLAMA"
name="AI_MODEL_TAGS_OLLAMA"
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"
>
{ollamaTagsModels.length > 0 ? (
ollamaTagsModels.map((model) => (
<option key={model} value={model}>{model}</option>
))
) : (
<option value={selectedTagsModel || 'granite4:latest'}>{selectedTagsModel || 'granite4:latest'} {t('admin.ai.saved')}</option>
)}
</select>
<p className="text-xs text-muted-foreground">
{isLoadingTagsModels ? 'Fetching models...' : 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="Récupérer les modèles"
>
<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 || 'Cliquez sur ↺ pour charger les modèles'}
searchPlaceholder="Rechercher un modèle..."
emptyMessage="Aucun modèle. Cliquez sur ↺"
/>
<p className="text-xs text-muted-foreground">
{isLoadingCustomTagsModels
? 'Récupération des modèles...'
: customTagsModels.length > 0
? `${customTagsModels.length} modèle(s) disponible(s)`
: 'Renseignez l\'URL et cliquez sur ↺ pour charger les modèles'}
</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">
(Current: {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>
<p className="text-xs text-muted-foreground">
Config value: {config.AI_PROVIDER_EMBEDDING || 'Not set (defaults to ollama)'}
</p>
</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="Refresh Models"
>
<RefreshCw className={`h-4 w-4 ${isLoadingEmbeddingsModels ? 'animate-spin' : ''}`} />
</Button>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="AI_MODEL_EMBEDDING_OLLAMA">{t('admin.ai.model')}</Label>
<select
id="AI_MODEL_EMBEDDING_OLLAMA"
name="AI_MODEL_EMBEDDING_OLLAMA"
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"
>
{ollamaEmbeddingsModels.length > 0 ? (
ollamaEmbeddingsModels.map((model) => (
<option key={model} value={model}>{model}</option>
))
) : (
<option value={selectedEmbeddingModel || 'embeddinggemma:latest'}>{selectedEmbeddingModel || 'embeddinggemma:latest'} {t('admin.ai.saved')}</option>
)}
</select>
<p className="text-xs text-muted-foreground">
{isLoadingEmbeddingsModels ? 'Fetching models...' : 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="Récupérer les modèles"
>
<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 || 'Cliquez sur ↺ pour charger les modèles'}
searchPlaceholder="Rechercher un modèle..."
emptyMessage="Aucun modèle. Cliquez sur ↺"
/>
<p className="text-xs text-muted-foreground">
{isLoadingCustomEmbeddingsModels
? 'Récupération des modèles...'
: customEmbeddingsModels.length > 0
? `${customEmbeddingsModels.length} modèle(s) disponible(s)`
: 'Renseignez l\'URL et cliquez sur ↺ pour charger les modèles'}
</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="Refresh Models"
>
<RefreshCw className={`h-4 w-4 ${isLoadingChatModels ? 'animate-spin' : ''}`} />
</Button>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="AI_MODEL_CHAT_OLLAMA">{t('admin.ai.model')}</Label>
<select
id="AI_MODEL_CHAT_OLLAMA"
name="AI_MODEL_CHAT_OLLAMA"
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"
>
{ollamaChatModels.length > 0 ? (
ollamaChatModels.map((model) => (
<option key={model} value={model}>{model}</option>
))
) : (
<option value={selectedChatModel || 'granite4:latest'}>{selectedChatModel || 'granite4:latest'} {t('admin.ai.saved')}</option>
)}
</select>
<p className="text-xs text-muted-foreground">
{isLoadingChatModels ? 'Fetching models...' : 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="Récupérer les modèles"
>
<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 || 'Cliquez sur ↺ pour charger les modèles'}
searchPlaceholder="Rechercher un modèle..."
emptyMessage="Aucun modèle. Cliquez sur ↺"
/>
<p className="text-xs text-muted-foreground">
{isLoadingCustomChatModels
? 'Récupération des modèles...'
: customChatModels.length > 0
? `${customChatModels.length} modèle(s) disponible(s)`
: 'Renseignez l\'URL et cliquez sur ↺ pour charger les modèles'}
</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>
<Link 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>
</Link>
</CardFooter>
</form>
</Card>
<Card>
<CardHeader>
<CardTitle>{t('admin.email.title')}</CardTitle>
<CardDescription>{t('admin.email.description')}</CardDescription>
</CardHeader>
<form name="email-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>
<div className="space-y-2">
<label htmlFor="SMTP_FROM_RESEND" className="text-sm font-medium">Sender email</label>
<Input id="SMTP_FROM_RESEND" name="SMTP_FROM" defaultValue={config.SMTP_FROM || ''} placeholder="noreply@yourdomain.com" />
<p className="text-xs text-muted-foreground">Email address used as sender. Must be a domain verified in Resend.</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>
</CardContent>
<CardFooter>
<Button type="submit" disabled={isSaving}>{t('admin.tools.saveSettings')}</Button>
</CardFooter>
</form>
</Card>
</div>
)
}