999 lines
47 KiB
TypeScript
999 lines
47 KiB
TypeScript
'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'
|
||
|
||
/** 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 },
|
||
}
|
||
|
||
// 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',
|
||
}
|
||
|
||
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',
|
||
}
|
||
|
||
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: '',
|
||
}
|
||
|
||
// 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'],
|
||
}
|
||
|
||
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 || '')
|
||
|
||
// 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
|
||
|
||
// 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
|
||
|
||
// 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
|
||
|
||
// 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: '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>
|
||
|
||
{/* 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>
|
||
|
||
{/* 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>
|
||
</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>
|
||
)
|
||
}
|