Files
Momento/memento-note/app/(admin)/admin/settings/admin-settings-form.tsx
Antigravity bd495be965
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 12s
feat: design system overhaul — sidebar, AI chats, settings, brainstorm, color cleanup
- Sidebar: dynamic brand-accent colors, brainstorm section restyled
- AI chat general: popup panel with expand/collapse, hides when contextual AI open
- AI chat contextual: tabs reordered (Actions first), X close button, height fix
- Settings: all tabs restyled, 6 new color presets (sage, terracotta, iron, etc.)
- Global color cleanup: emerald/orange hardcoded → brand-accent dynamic
- Brainstorm page: orange → brand-accent throughout
- PageEntry animation component added to key pages
- Floating AI button: bg-brand-accent instead of hardcoded black
- i18n: all 15 locales updated with new AI/billing keys
- Billing: freemium quota tracking, BYOK, stripe subscription scaffolding
- Admin: integrated into new design
- AGENTS.md + CLAUDE.md project rules added
2026-05-16 12:59:30 +00:00

1120 lines
54 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, Shield, Brain, Mail, Wrench } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
type AIProvider =
| 'ollama'
| 'openai'
| 'anthropic'
| 'anthropic_custom'
| 'custom'
| 'deepseek'
| 'openrouter'
| 'mistral'
| 'zai'
| 'lmstudio'
| 'google'
| 'minimax'
| 'glm'
/** Providers that cannot be used for embeddings in Memento (no embedding API wired). */
const PROVIDERS_WITHOUT_EMBEDDINGS: AIProvider[] = ['anthropic', 'anthropic_custom']
// Provider config metadata
const PROVIDER_META: Record<AIProvider, { apiKeyLabel: string; baseUrlLabel: string; hasApiKey: boolean; hasBaseUrl: boolean; isLocal: boolean }> = {
ollama: { apiKeyLabel: '', baseUrlLabel: 'admin.ai.baseUrl', hasApiKey: false, hasBaseUrl: true, isLocal: true },
openai: { apiKeyLabel: 'OPENAI_API_KEY', baseUrlLabel: '', hasApiKey: true, hasBaseUrl: false, isLocal: false },
anthropic: { apiKeyLabel: 'ANTHROPIC_API_KEY', baseUrlLabel: '', hasApiKey: true, hasBaseUrl: false, isLocal: false },
anthropic_custom: {
apiKeyLabel: 'ANTHROPIC_CUSTOM_API_KEY',
baseUrlLabel: 'admin.ai.baseUrl',
hasApiKey: true,
hasBaseUrl: true,
isLocal: false,
},
deepseek: { apiKeyLabel: 'DEEPSEEK_API_KEY', baseUrlLabel: '', hasApiKey: true, hasBaseUrl: false, isLocal: false },
openrouter:{ apiKeyLabel: 'OPENROUTER_API_KEY', baseUrlLabel: '', hasApiKey: true, hasBaseUrl: false, isLocal: false },
mistral: { apiKeyLabel: 'MISTRAL_API_KEY', baseUrlLabel: '', hasApiKey: true, hasBaseUrl: false, isLocal: false },
zai: { apiKeyLabel: 'ZAI_API_KEY', baseUrlLabel: '', hasApiKey: true, hasBaseUrl: false, isLocal: false },
lmstudio: { apiKeyLabel: '', baseUrlLabel: 'admin.ai.baseUrl', hasApiKey: false, hasBaseUrl: true, isLocal: true },
custom: { apiKeyLabel: 'CUSTOM_OPENAI_API_KEY', baseUrlLabel: 'admin.ai.baseUrl', hasApiKey: true, hasBaseUrl: true, isLocal: false },
google: { apiKeyLabel: 'GOOGLE_GENERATIVE_AI_API_KEY', baseUrlLabel: '', hasApiKey: true, hasBaseUrl: false, isLocal: false },
minimax: { apiKeyLabel: 'MINIMAX_API_KEY', baseUrlLabel: '', hasApiKey: true, hasBaseUrl: false, isLocal: false },
glm: { apiKeyLabel: 'GLM_API_KEY', baseUrlLabel: '', hasApiKey: true, hasBaseUrl: false, isLocal: false },
}
// Config key names for each provider's API key (used to read/save from config)
const API_KEY_CONFIG: Record<AIProvider, string> = {
ollama: '',
openai: 'OPENAI_API_KEY',
anthropic: 'ANTHROPIC_API_KEY',
anthropic_custom: 'ANTHROPIC_CUSTOM_API_KEY',
deepseek: 'DEEPSEEK_API_KEY',
openrouter: 'OPENROUTER_API_KEY',
mistral: 'MISTRAL_API_KEY',
zai: 'ZAI_API_KEY',
lmstudio: 'LMSTUDIO_API_KEY',
custom: 'CUSTOM_OPENAI_API_KEY',
google: 'GOOGLE_GENERATIVE_AI_API_KEY',
minimax: 'MINIMAX_API_KEY',
glm: 'GLM_API_KEY',
}
const BASE_URL_CONFIG: Record<AIProvider, string> = {
ollama: 'OLLAMA_BASE_URL',
openai: '',
anthropic: '',
anthropic_custom: 'ANTHROPIC_CUSTOM_BASE_URL',
deepseek: '',
openrouter: '',
mistral: '',
zai: '',
lmstudio: 'LMSTUDIO_BASE_URL',
custom: 'CUSTOM_OPENAI_BASE_URL',
google: '',
minimax: '',
glm: '',
}
const DEFAULT_BASE_URLS: Record<AIProvider, string> = {
ollama: 'http://localhost:11434',
openai: '',
anthropic: '',
anthropic_custom: '',
deepseek: 'https://api.deepseek.com/v1',
openrouter: 'https://openrouter.ai/api/v1',
mistral: 'https://api.mistral.ai/v1',
zai: 'https://api.zukijourney.com/v1',
lmstudio: 'http://localhost:1234/v1',
custom: '',
google: '',
minimax: 'https://api.minimax.io/v1',
glm: 'https://open.bigmodel.ai/api/paas/v4',
}
// Suggested models per provider (shown as hints in Combobox - user can always type a custom name)
const SUGGESTED_MODELS: Record<string, string[]> = {
openai: ['gpt-4.1', 'gpt-4.1-mini', 'gpt-4.1-nano', 'gpt-4o', 'gpt-4o-mini', 'o3-mini', 'o4-mini', 'gpt-4-turbo', 'gpt-3.5-turbo'],
anthropic: [
'claude-sonnet-4-20250514',
'claude-sonnet-4-5',
'claude-opus-4-20250514',
'claude-opus-4-5',
'claude-haiku-4-5',
'claude-3-haiku-20240307',
],
anthropic_custom: [
'MiniMax-M2.7',
'MiniMax-M2.7-highspeed',
'MiniMax-M2.5',
'MiniMax-M2.5-highspeed',
'MiniMax-M2.1',
'MiniMax-M2.1-highspeed',
'MiniMax-M2',
'claude-sonnet-4-20250514',
],
openrouter: ['openai/gpt-4o-mini', 'openai/gpt-4.1-mini', 'anthropic/claude-sonnet-4', 'google/gemini-2.5-flash-preview', 'google/gemma-4-26b-a4b-it', 'meta-llama/llama-4-maverick', 'deepseek/deepseek-chat-v3-0324'],
deepseek: ['deepseek-chat', 'deepseek-reasoner'],
mistral: ['mistral-small-latest', 'mistral-medium-latest', 'mistral-large-latest', 'codestral-latest', 'mistral-embed'],
zai: ['gpt-4.1', 'gpt-4.1-mini', 'gpt-4o', 'gpt-4o-mini', 'claude-sonnet-4', 'gemini-2.5-flash'],
google: ['gemini-2.0-flash', 'gemini-1.5-flash', 'gemini-1.5-pro', 'gemini-1.0-pro'],
minimax: ['abab6.5-chat', 'abab6.5s-chat', 'abab5.5-chat'],
glm: ['glm-4', 'glm-4-air', 'glm-4-flash', 'glm-3-turbo'],
}
const SUGGESTED_EMBEDDINGS: Record<string, string[]> = {
openai: ['text-embedding-3-small', 'text-embedding-3-large', 'text-embedding-ada-002'],
openrouter: ['openai/text-embedding-3-small'],
mistral: ['mistral-embed'],
zai: ['text-embedding-3-small', 'text-embedding-3-large'],
}
type ModelPurpose = 'tags' | 'embeddings' | 'chat'
export function AdminSettingsForm({ config }: { config: Record<string, string> }) {
const { t } = useLanguage()
const [activeAiTab, setActiveAiTab] = useState<'tags' | 'embeddings' | 'chat'>('tags')
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>(() => {
const v = (config.AI_PROVIDER_EMBEDDING as AIProvider) || 'ollama'
return PROVIDERS_WITHOUT_EMBEDDINGS.includes(v) ? 'ollama' : v
})
const [chatProvider, setChatProvider] = useState<AIProvider>((config.AI_PROVIDER_CHAT as AIProvider) || 'ollama')
// Selected Models State
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 || '')
const [tagsFallbackProvider, setTagsFallbackProvider] = useState<string>(config.AI_PROVIDER_TAGS_FALLBACK || '')
const [embedFallbackProvider, setEmbedFallbackProvider] = useState<string>(config.AI_PROVIDER_EMBEDDING_FALLBACK || '')
const [chatFallbackProvider, setChatFallbackProvider] = useState<string>(config.AI_PROVIDER_CHAT_FALLBACK || '')
// Dynamic Models State (for local providers with /api/tags or /v1/models endpoints)
const [dynamicModels, setDynamicModels] = useState<Record<ModelPurpose, string[]>>({
tags: [],
embeddings: [],
chat: [],
})
const [isLoadingModels, setIsLoadingModels] = useState<Record<ModelPurpose, boolean>>({
tags: false,
embeddings: false,
chat: false,
})
// Fetch models from local providers (Ollama, LM Studio) or cloud providers with /v1/models
const fetchModels = useCallback(async (purpose: ModelPurpose, provider: AIProvider, url: string, apiKey?: string) => {
setIsLoadingModels(prev => ({ ...prev, [purpose]: true }))
try {
const params = new URLSearchParams({ type: provider === 'ollama' ? 'ollama' : 'custom', url, kind: purpose })
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) {
setDynamicModels(prev => ({ ...prev, [purpose]: result.models }))
} else if (!result.success) {
toast.error(`${t('admin.ai.fetchModelsFailed')}: ${result.error}`)
}
} catch (error) {
console.error(error)
toast.error(t('admin.ai.fetchModelsFailed'))
} finally {
setIsLoadingModels(prev => ({ ...prev, [purpose]: false }))
}
}, [t])
// Get the API URL for fetching models for a given provider
const getModelFetchUrl = useCallback((provider: AIProvider): string => {
if (provider === 'ollama') {
const input = document.getElementById(`BASE_URL_${provider}`) as HTMLInputElement
return input?.value || config.OLLAMA_BASE_URL || 'http://localhost:11434'
}
if (provider === 'lmstudio') {
const input = document.getElementById('BASE_URL_lmstudio') as HTMLInputElement
return input?.value || config.LMSTUDIO_BASE_URL || 'http://localhost:1234/v1'
}
// Cloud providers have known URLs
return DEFAULT_BASE_URLS[provider] || ''
}, [config])
const getApiKey = useCallback((provider: AIProvider, purpose: ModelPurpose): string => {
const configKey = API_KEY_CONFIG[provider]
if (!configKey) return ''
const input = document.getElementById(`API_KEY_${provider}_${purpose}`) as HTMLInputElement
return input?.value || config[configKey] || ''
}, [config])
// Initial model fetch
useEffect(() => {
const fetchInitial = async () => {
if (tagsProvider === 'ollama') {
await fetchModels('tags', 'ollama', config.OLLAMA_BASE_URL_TAGS || config.OLLAMA_BASE_URL || 'http://localhost:11434')
} else if (tagsProvider === 'lmstudio') {
await fetchModels('tags', 'lmstudio', config.LMSTUDIO_BASE_URL || 'http://localhost:1234/v1')
} else if (PROVIDER_META[tagsProvider]?.hasApiKey && tagsProvider !== 'anthropic_custom') {
const url = DEFAULT_BASE_URLS[tagsProvider]
const key = config[API_KEY_CONFIG[tagsProvider]] || ''
if (url && key) await fetchModels('tags', tagsProvider, url, key)
}
if (embeddingsProvider === 'ollama') {
await fetchModels('embeddings', 'ollama', config.OLLAMA_BASE_URL_EMBEDDING || config.OLLAMA_BASE_URL || 'http://localhost:11434')
} else if (embeddingsProvider === 'lmstudio') {
await fetchModels('embeddings', 'lmstudio', config.LMSTUDIO_BASE_URL || 'http://localhost:1234/v1')
} else if (PROVIDER_META[embeddingsProvider]?.hasApiKey && embeddingsProvider !== 'anthropic_custom') {
const url = DEFAULT_BASE_URLS[embeddingsProvider]
const key = config[API_KEY_CONFIG[embeddingsProvider]] || ''
if (url && key) await fetchModels('embeddings', embeddingsProvider, url, key)
}
if (chatProvider === 'ollama') {
await fetchModels('chat', 'ollama', config.OLLAMA_BASE_URL_CHAT || config.OLLAMA_BASE_URL || 'http://localhost:11434')
} else if (chatProvider === 'lmstudio') {
await fetchModels('chat', 'lmstudio', config.LMSTUDIO_BASE_URL || 'http://localhost:1234/v1')
} else if (PROVIDER_META[chatProvider]?.hasApiKey && chatProvider !== 'anthropic_custom') {
const url = DEFAULT_BASE_URLS[chatProvider]
const key = config[API_KEY_CONFIG[chatProvider]] || ''
if (url && key) await fetchModels('chat', chatProvider, url, key)
}
}
fetchInitial()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// Build model options for Combobox: dynamic models + current saved + suggested
const buildModelOptions = useCallback((purpose: ModelPurpose, provider: AIProvider, currentValue: string) => {
const dynamic = dynamicModels[purpose] || []
const suggested = purpose === 'embeddings' ? (SUGGESTED_EMBEDDINGS[provider] || []) : (SUGGESTED_MODELS[provider] || [])
const allModels = new Set<string>()
dynamic.forEach(m => allModels.add(m))
suggested.forEach(m => allModels.add(m))
if (currentValue) allModels.add(currentValue)
const options = Array.from(allModels).sort().map(m => ({ value: m, label: m }))
return options
}, [dynamicModels])
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 {
// Tags provider
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') as string
if (tagsModel) data.AI_MODEL_TAGS = tagsModel
const tagsFallbackProv = formData.get('AI_PROVIDER_TAGS_FALLBACK') as string
data.AI_PROVIDER_TAGS_FALLBACK = tagsFallbackProv?.trim() ?? ''
const tagsFallbackModel = formData.get('AI_MODEL_TAGS_FALLBACK') as string
if (tagsFallbackModel) data.AI_MODEL_TAGS_FALLBACK = tagsFallbackModel
// Save provider-specific config for tags
if (tagsProv === 'ollama') {
const ollamaUrl = formData.get('BASE_URL_ollama_tags') as string
if (ollamaUrl) data.OLLAMA_BASE_URL_TAGS = ollamaUrl
} else if (tagsProv === 'lmstudio') {
const url = formData.get('BASE_URL_lmstudio_tags') as string
if (url) data.LMSTUDIO_BASE_URL = url
} else {
const apiKeyConfigKey = API_KEY_CONFIG[tagsProv]
if (apiKeyConfigKey) {
const apiKey = formData.get(`API_KEY_${tagsProv}_tags`) as string
if (apiKey) data[apiKeyConfigKey] = apiKey
}
const baseUrlConfigKey = BASE_URL_CONFIG[tagsProv]
if (baseUrlConfigKey) {
const baseUrl = formData.get(`BASE_URL_${tagsProv}_tags`) as string
if (baseUrl) data[baseUrlConfigKey] = baseUrl
}
}
// Embeddings provider
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') as string
if (embedModel) data.AI_MODEL_EMBEDDING = embedModel
const embedFallbackProv = formData.get('AI_PROVIDER_EMBEDDING_FALLBACK') as string
data.AI_PROVIDER_EMBEDDING_FALLBACK = embedFallbackProv?.trim() ?? ''
const embedFallbackModel = formData.get('AI_MODEL_EMBEDDING_FALLBACK') as string
if (embedFallbackModel) data.AI_MODEL_EMBEDDING_FALLBACK = embedFallbackModel
// Save provider-specific config for embeddings
if (embedProv === 'ollama') {
const ollamaUrl = formData.get('BASE_URL_ollama_embeddings') as string
if (ollamaUrl) data.OLLAMA_BASE_URL_EMBEDDING = ollamaUrl
} else if (embedProv === 'lmstudio') {
const url = formData.get('BASE_URL_lmstudio_embeddings') as string
if (url) data.LMSTUDIO_BASE_URL = url
} else {
const apiKeyConfigKey = API_KEY_CONFIG[embedProv]
if (apiKeyConfigKey) {
const apiKey = formData.get(`API_KEY_${embedProv}_embeddings`) as string
if (apiKey) data[apiKeyConfigKey] = apiKey
}
const baseUrlConfigKey = BASE_URL_CONFIG[embedProv]
if (baseUrlConfigKey) {
const baseUrl = formData.get(`BASE_URL_${embedProv}_embeddings`) as string
if (baseUrl) data[baseUrlConfigKey] = baseUrl
}
}
// Chat provider
const chatProv = formData.get('AI_PROVIDER_CHAT') as AIProvider
if (chatProv) {
data.AI_PROVIDER_CHAT = chatProv
const chatModel = formData.get('AI_MODEL_CHAT') as string
if (chatModel) data.AI_MODEL_CHAT = chatModel
const chatFallbackProv = formData.get('AI_PROVIDER_CHAT_FALLBACK') as string
data.AI_PROVIDER_CHAT_FALLBACK = chatFallbackProv?.trim() ?? ''
const chatFallbackModel = formData.get('AI_MODEL_CHAT_FALLBACK') as string
if (chatFallbackModel) data.AI_MODEL_CHAT_FALLBACK = chatFallbackModel
// Save provider-specific config for chat
if (chatProv === 'ollama') {
const ollamaUrl = formData.get('BASE_URL_ollama_chat') as string
if (ollamaUrl) data.OLLAMA_BASE_URL_CHAT = ollamaUrl
} else if (chatProv === 'lmstudio') {
const url = formData.get('BASE_URL_lmstudio_chat') as string
if (url) data.LMSTUDIO_BASE_URL = url
} else {
const apiKeyConfigKey = API_KEY_CONFIG[chatProv]
if (apiKeyConfigKey) {
const apiKey = formData.get(`API_KEY_${chatProv}_chat`) as string
if (apiKey) data[apiKeyConfigKey] = apiKey
}
const baseUrlConfigKey = BASE_URL_CONFIG[chatProv]
if (baseUrlConfigKey) {
const baseUrl = formData.get(`BASE_URL_${chatProv}_chat`) as string
if (baseUrl) data[baseUrlConfigKey] = baseUrl
}
}
}
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'))
}
}
// Renders the provider config fields (API key, base URL) for a given provider and purpose
const renderProviderConfig = (provider: AIProvider, purpose: ModelPurpose, currentValue: string, onModelChange: (v: string) => void) => {
const meta = PROVIDER_META[provider]
const loading = isLoadingModels[purpose]
return (
<div className="space-y-3">
{/* API Key field */}
{meta.hasApiKey && (
<div className="space-y-2">
<Label htmlFor={`API_KEY_${provider}_${purpose}`}>{t('admin.ai.apiKey')}</Label>
<Input
id={`API_KEY_${provider}_${purpose}`}
name={`API_KEY_${provider}_${purpose}`}
type="password"
defaultValue={config[API_KEY_CONFIG[provider]] || ''}
placeholder={`${API_KEY_CONFIG[provider]}...`}
/>
</div>
)}
{/* Base URL field */}
{meta.hasBaseUrl && (
<div className="space-y-2">
<Label htmlFor={`BASE_URL_${provider}_${purpose}`}>{t('admin.ai.baseUrl')}</Label>
<div className="flex gap-2">
<Input
id={`BASE_URL_${provider}_${purpose}`}
name={`BASE_URL_${provider}_${purpose}`}
defaultValue={
provider === 'ollama'
? (purpose === 'tags' ? (config.OLLAMA_BASE_URL_TAGS || config.OLLAMA_BASE_URL) :
purpose === 'embeddings' ? (config.OLLAMA_BASE_URL_EMBEDDING || config.OLLAMA_BASE_URL) :
(config.OLLAMA_BASE_URL_CHAT || config.OLLAMA_BASE_URL))
: provider === 'lmstudio'
? (config.LMSTUDIO_BASE_URL || DEFAULT_BASE_URLS.lmstudio)
: (config[BASE_URL_CONFIG[provider]] || DEFAULT_BASE_URLS[provider] || '')
}
placeholder={
provider === 'anthropic_custom'
? 'https://api.minimax.io/anthropic'
: DEFAULT_BASE_URLS[provider] || t('admin.ai.baseUrl')
}
/>
<Button
type="button"
size="icon"
variant="outline"
onClick={() => {
if (provider === 'anthropic_custom') {
toast.info(t('admin.ai.anthropicCustomNoModelList'))
return
}
const urlInput = document.getElementById(`BASE_URL_${provider}_${purpose}`) as HTMLInputElement
const keyInput = meta.hasApiKey
? document.getElementById(`API_KEY_${provider}_${purpose}`) as HTMLInputElement
: null
const url = urlInput?.value || DEFAULT_BASE_URLS[provider] || ''
const key = keyInput?.value || (meta.hasApiKey ? config[API_KEY_CONFIG[provider]] : undefined)
if (url) fetchModels(purpose, provider, url, key)
}}
disabled={loading || provider === 'anthropic_custom'}
title={t('admin.ai.refreshModels')}
>
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
</Button>
</div>
</div>
)}
{/* API Key field for cloud providers without base URL - refresh button inline */}
{meta.hasApiKey && !meta.hasBaseUrl && (
<div className="flex gap-2 items-end">
<div className="flex-1 space-y-2">
{/* API key already rendered above */}
</div>
<Button
type="button"
size="icon"
variant="outline"
className="mb-0"
onClick={() => {
const keyInput = document.getElementById(`API_KEY_${provider}_${purpose}`) as HTMLInputElement
const url = DEFAULT_BASE_URLS[provider] || ''
const key = keyInput?.value || config[API_KEY_CONFIG[provider]] || ''
if (url && key) fetchModels(purpose, provider, url, key)
}}
disabled={loading || provider === 'anthropic'}
title={t('admin.ai.refreshModels')}
>
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
</Button>
</div>
)}
{/* Model selection - always editable Combobox */}
<div className="space-y-2">
<Label>{t('admin.ai.model')}</Label>
<Combobox
options={buildModelOptions(purpose, provider, currentValue)}
value={currentValue}
onChange={onModelChange}
placeholder={currentValue || t('admin.ai.clickToLoadModels')}
searchPlaceholder={t('admin.ai.searchModel')}
emptyMessage={t('admin.ai.noModels')}
allowCustomValue
/>
<p className="text-xs text-muted-foreground">
{loading
? t('admin.ai.fetchingModels')
: dynamicModels[purpose].length > 0
? t('admin.ai.modelsAvailable', { count: dynamicModels[purpose].length })
: provider === 'anthropic'
? t('admin.ai.anthropicModelHint')
: provider === 'anthropic_custom'
? t('admin.ai.anthropicCustomModelHint')
: provider === 'ollama' || provider === 'lmstudio'
? t('admin.ai.selectOllamaModel')
: t('admin.ai.enterUrlToLoad')}
</p>
</div>
</div>
)
}
// Provider dropdown options
const providerOptions = [
{ value: 'ollama', label: t('admin.ai.providerOllamaOption') },
{ value: 'openai', label: t('admin.ai.providerOpenAIOption') },
{ value: 'anthropic', label: t('admin.ai.providerAnthropicOption') },
{ value: 'anthropic_custom', label: t('admin.ai.providerAnthropicCustomOption') },
{ value: 'deepseek', label: t('admin.ai.providerDeepSeekOption') },
{ value: 'openrouter', label: t('admin.ai.providerOpenRouterOption') },
{ value: 'mistral', label: t('admin.ai.providerMistralOption') },
{ value: 'zai', label: t('admin.ai.providerZAIOption') },
{ value: 'lmstudio', label: t('admin.ai.providerLMStudioOption') },
{ value: 'google', label: 'Google Gemini' },
{ value: 'minimax', label: 'MiniMax' },
{ value: 'glm', label: 'Zhipu GLM' },
{ value: 'custom', label: t('admin.ai.providerCustomOption') },
]
const embeddingsProviderOptions = providerOptions.filter(
(opt) => !PROVIDERS_WITHOUT_EMBEDDINGS.includes(opt.value as AIProvider)
)
return (
<div className="columns-1 lg:columns-2 gap-6">
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden break-inside-avoid mb-6">
<div className="flex items-center gap-3 p-6 border-b border-border">
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary shrink-0">
<Shield className="h-5 w-5" />
</div>
<div>
<h2 className="font-semibold text-foreground">{t('admin.security.title')}</h2>
<p className="text-sm text-muted-foreground">{t('admin.security.description')}</p>
</div>
</div>
<form onSubmit={(e) => { e.preventDefault(); handleSaveSecurity(new FormData(e.currentTarget)) }}>
<div className="p-6 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>
</div>
<div className="px-6 pb-6">
<Button type="submit" disabled={isSaving}>{t('admin.security.title')}</Button>
</div>
</form>
</div>
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden break-inside-avoid mb-6">
<div className="flex items-center gap-3 p-6 border-b border-border">
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary shrink-0">
<Brain className="h-5 w-5" />
</div>
<div>
<h2 className="font-semibold text-foreground">{t('admin.ai.title')}</h2>
<p className="text-sm text-muted-foreground">{t('admin.ai.description')}</p>
</div>
</div>
<form onSubmit={(e) => { e.preventDefault(); handleSaveAI(new FormData(e.currentTarget)) }}>
<div className="px-6 pt-6">
<div className="flex border-b border-border/50 overflow-x-auto">
<button type="button" onClick={() => setActiveAiTab('tags')} className={`px-4 py-2.5 text-sm font-medium border-b-2 whitespace-nowrap ${activeAiTab === 'tags' ? 'border-primary text-primary' : 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'}`}>🏷 Tags</button>
<button type="button" onClick={() => setActiveAiTab('embeddings')} className={`px-4 py-2.5 text-sm font-medium border-b-2 whitespace-nowrap ${activeAiTab === 'embeddings' ? 'border-primary text-primary' : 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'}`}>🔍 Embeddings</button>
<button type="button" onClick={() => setActiveAiTab('chat')} className={`px-4 py-2.5 text-sm font-medium border-b-2 whitespace-nowrap ${activeAiTab === 'chat' ? 'border-primary text-primary' : 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'}`}>💬 Chat</button>
</div>
</div>
<div className="p-6 space-y-6">
{/* Tags Generation Provider */}
<div className={`space-y-4 p-4 border border-border/50 rounded-lg bg-muted/50 ${activeAiTab === 'tags' ? 'block' : 'hidden'}`}>
<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) => {
setTagsProvider(e.target.value as AIProvider)
setSelectedTagsModel('')
setDynamicModels(prev => ({ ...prev, tags: [] }))
}}
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"
>
{providerOptions.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
<input type="hidden" name="AI_MODEL_TAGS" value={selectedTagsModel} />
{renderProviderConfig(tagsProvider, 'tags', selectedTagsModel, setSelectedTagsModel)}
<div className="space-y-2 pt-3 border-t border-border/40">
<p className="text-xs font-medium text-muted-foreground">{t('admin.ai.fallbackSectionTitle')}</p>
<p className="text-xs text-muted-foreground">{t('admin.ai.fallbackSectionDescription')}</p>
<Label htmlFor="AI_PROVIDER_TAGS_FALLBACK">{t('admin.ai.fallbackProvider')}</Label>
<select
id="AI_PROVIDER_TAGS_FALLBACK"
name="AI_PROVIDER_TAGS_FALLBACK"
value={tagsFallbackProvider}
onChange={(e) => setTagsFallbackProvider(e.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
>
<option value="">{t('admin.ai.fallbackNone')}</option>
{providerOptions.map((opt) => (
<option key={`fb-tags-${opt.value}`} value={opt.value}>
{opt.label}
</option>
))}
</select>
<Label htmlFor="AI_MODEL_TAGS_FALLBACK">{t('admin.ai.fallbackModel')}</Label>
<Input
id="AI_MODEL_TAGS_FALLBACK"
name="AI_MODEL_TAGS_FALLBACK"
defaultValue={config.AI_MODEL_TAGS_FALLBACK || ''}
placeholder={t('admin.ai.fallbackModelPlaceholder')}
/>
</div>
</div>
{/* Embeddings Provider */}
<div className={`space-y-4 p-4 border border-border/50 rounded-lg bg-muted/50 ${activeAiTab === 'embeddings' ? 'block' : 'hidden'}`}>
<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) => {
setEmbeddingsProvider(e.target.value as AIProvider)
setSelectedEmbeddingModel('')
setDynamicModels(prev => ({ ...prev, embeddings: [] }))
}}
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"
>
{embeddingsProviderOptions.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
<input type="hidden" name="AI_MODEL_EMBEDDING" value={selectedEmbeddingModel} />
{renderProviderConfig(embeddingsProvider, 'embeddings', selectedEmbeddingModel, setSelectedEmbeddingModel)}
<div className="space-y-2 pt-3 border-t border-border/40">
<p className="text-xs font-medium text-muted-foreground">{t('admin.ai.fallbackSectionTitle')}</p>
<p className="text-xs text-muted-foreground">{t('admin.ai.fallbackSectionDescription')}</p>
<Label htmlFor="AI_PROVIDER_EMBEDDING_FALLBACK">{t('admin.ai.fallbackProvider')}</Label>
<select
id="AI_PROVIDER_EMBEDDING_FALLBACK"
name="AI_PROVIDER_EMBEDDING_FALLBACK"
value={embedFallbackProvider}
onChange={(e) => setEmbedFallbackProvider(e.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
>
<option value="">{t('admin.ai.fallbackNone')}</option>
{embeddingsProviderOptions.map((opt) => (
<option key={`fb-embed-${opt.value}`} value={opt.value}>
{opt.label}
</option>
))}
</select>
<Label htmlFor="AI_MODEL_EMBEDDING_FALLBACK">{t('admin.ai.fallbackModel')}</Label>
<Input
id="AI_MODEL_EMBEDDING_FALLBACK"
name="AI_MODEL_EMBEDDING_FALLBACK"
defaultValue={config.AI_MODEL_EMBEDDING_FALLBACK || ''}
placeholder={t('admin.ai.fallbackModelPlaceholder')}
/>
</div>
</div>
{/* Chat Provider */}
<div className={`space-y-4 p-4 border border-border/50 rounded-lg bg-muted/50 ${activeAiTab === 'chat' ? 'block' : 'hidden'}`}>
<h3 className="text-base font-semibold flex items-center gap-2">
<span className="text-zinc-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) => {
setChatProvider(e.target.value as AIProvider)
setSelectedChatModel('')
setDynamicModels(prev => ({ ...prev, chat: [] }))
}}
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"
>
{providerOptions.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
<input type="hidden" name="AI_MODEL_CHAT" value={selectedChatModel} />
{renderProviderConfig(chatProvider, 'chat', selectedChatModel, setSelectedChatModel)}
<div className="space-y-2 pt-3 border-t border-border/40">
<p className="text-xs font-medium text-muted-foreground">{t('admin.ai.fallbackSectionTitle')}</p>
<p className="text-xs text-muted-foreground">{t('admin.ai.fallbackSectionDescription')}</p>
<Label htmlFor="AI_PROVIDER_CHAT_FALLBACK">{t('admin.ai.fallbackProvider')}</Label>
<select
id="AI_PROVIDER_CHAT_FALLBACK"
name="AI_PROVIDER_CHAT_FALLBACK"
value={chatFallbackProvider}
onChange={(e) => setChatFallbackProvider(e.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
>
<option value="">{t('admin.ai.fallbackNone')}</option>
{providerOptions.map((opt) => (
<option key={`fb-chat-${opt.value}`} value={opt.value}>
{opt.label}
</option>
))}
</select>
<Label htmlFor="AI_MODEL_CHAT_FALLBACK">{t('admin.ai.fallbackModel')}</Label>
<Input
id="AI_MODEL_CHAT_FALLBACK"
name="AI_MODEL_CHAT_FALLBACK"
defaultValue={config.AI_MODEL_CHAT_FALLBACK || ''}
placeholder={t('admin.ai.fallbackModelPlaceholder')}
/>
</div>
</div>
</div>
<div className="px-6 pb-6 flex flex-col sm:flex-row gap-3 sm: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>
</div>
</form>
</div>
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden break-inside-avoid mb-6">
<div className="flex items-center gap-3 p-6 border-b border-border">
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary shrink-0">
<Mail className="h-5 w-5" />
</div>
<div>
<h2 className="font-semibold text-foreground">{t('admin.email.title')}</h2>
<p className="text-sm text-muted-foreground">{t('admin.email.description')}</p>
</div>
</div>
<form onSubmit={(e) => { e.preventDefault(); handleSaveEmail(new FormData(e.currentTarget)) }}>
<div className="p-6 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>
)}
</div>
<div className="px-6 pb-6 flex flex-col sm:flex-row gap-3 sm: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>
</div>
</form>
</div>
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden break-inside-avoid mb-6">
<div className="flex items-center gap-3 p-6 border-b border-border">
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary shrink-0">
<Wrench className="h-5 w-5" />
</div>
<div>
<h2 className="font-semibold text-foreground">{t('admin.tools.title')}</h2>
<p className="text-sm text-muted-foreground">{t('admin.tools.description')}</p>
</div>
</div>
<form onSubmit={(e) => { e.preventDefault(); handleSaveTools(new FormData(e.currentTarget)) }}>
<div className="p-6 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>
{/* Search test result */}
{searchTestResult && (
<div className={`rounded-lg border p-3 text-sm flex items-start gap-2 ${searchTestResult.success ? 'border-green-500/20 bg-green-500/10 text-green-600' : 'border-red-500/20 bg-red-500/10 text-red-600'}`}>
<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>
)}
</div>
<div className="px-6 pb-6 flex flex-col sm:flex-row gap-3 sm: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>
</div>
</form>
</div>
</div>
)
}