Files
Momento/memento-note/app/(admin)/admin/settings/admin-settings-form.tsx
Sepehr Ramezani dbd49d6fcb
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 1m25s
feat: 8 AI providers, rich text editor, agent notifications, UI contrast & font settings
- Add DeepSeek, OpenRouter, Mistral, Z.AI, LM Studio as AI providers
  with editable model names via Combobox in admin settings
- Fix OpenRouter broken by normalizeProvider bug in config.ts
- Convert agent-created notes from Markdown to HTML (TipTap rich text)
- Add Notification model + in-app notifications for agent results
- Agent notification click opens the created note directly
- Add note count display on notebook and inbox headers
- Fix checklist toggle in card view (persist state via localCheckItems)
- Add checklist creation option in tabs/list view (dropdown on + button)
- Fix image description ENOENT error with HTTP fallback
- Improve UI contrast across all themes (input, border, checkbox visibility)
- Add font family setting (Inter vs System Default) in Appearance settings
- Fix CSS font-sans variable conflict (removed dead Geist references)
- Update README with new features and 8 providers

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-01 16:14:07 +02:00

905 lines
42 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Checkbox } from '@/components/ui/checkbox'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { Label } from '@/components/ui/label'
import { Combobox } from '@/components/ui/combobox'
import { updateSystemConfig, testEmail } from '@/app/actions/admin-settings'
import { toast } from 'sonner'
import { useState, useEffect, useCallback } from 'react'
import { TestTube, ExternalLink, RefreshCw } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
type AIProvider = 'ollama' | 'openai' | 'custom' | 'deepseek' | 'openrouter' | 'mistral' | 'zai' | 'lmstudio'
// 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 },
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',
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: '',
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: '',
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'],
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 [isSaving, setIsSaving] = useState(false)
const [isTesting, setIsTesting] = useState(false)
// Local state for Checkbox
const [allowRegister, setAllowRegister] = useState(config.ALLOW_REGISTRATION !== 'false')
const [smtpSecure, setSmtpSecure] = useState(config.SMTP_SECURE === 'true')
const [smtpIgnoreCert, setSmtpIgnoreCert] = useState(config.SMTP_IGNORE_CERT === 'true')
// Agent tools state
const [webSearchProvider, setWebSearchProvider] = useState(config.WEB_SEARCH_PROVIDER || 'searxng')
// Email provider state
const [emailProvider, setEmailProvider] = useState<'resend' | 'smtp'>(config.EMAIL_PROVIDER as 'resend' | 'smtp' || (config.RESEND_API_KEY ? 'resend' : 'smtp'))
const [emailTestResult, setEmailTestResult] = useState<{ provider: 'resend' | 'smtp'; success: boolean; message?: string } | null>(null)
const [isTestingSearch, setIsTestingSearch] = useState(false)
const [searchTestResult, setSearchTestResult] = useState<{ success: boolean; message: string } | null>(null)
// AI Provider state - separated for tags, embeddings, and chat
const [tagsProvider, setTagsProvider] = useState<AIProvider>((config.AI_PROVIDER_TAGS as AIProvider) || 'ollama')
const [embeddingsProvider, setEmbeddingsProvider] = useState<AIProvider>((config.AI_PROVIDER_EMBEDDING as AIProvider) || 'ollama')
const [chatProvider, setChatProvider] = useState<AIProvider>((config.AI_PROVIDER_CHAT as AIProvider) || 'ollama')
// Selected Models State
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) {
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) {
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) {
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={DEFAULT_BASE_URLS[provider] || t('admin.ai.baseUrl')}
/>
<Button
type="button"
size="icon"
variant="outline"
onClick={() => {
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}
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}
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 === '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: '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') },
]
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">
{/* Tags Generation Provider */}
<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) => {
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 rounded-lg bg-green-50/50 dark:bg-green-950/20">
<h3 className="text-base font-semibold flex items-center gap-2">
<span className="text-green-600">🔍</span> {t('admin.ai.embeddingsProvider')}
</h3>
<p className="text-xs text-muted-foreground">{t('admin.ai.embeddingsDescription')}</p>
<div className="space-y-2">
<Label htmlFor="AI_PROVIDER_EMBEDDING">
{t('admin.ai.provider')}
<span className="ml-2 text-xs text-muted-foreground">
{t('admin.ai.currentProvider', { provider: embeddingsProvider })}
</span>
</Label>
<select
id="AI_PROVIDER_EMBEDDING"
name="AI_PROVIDER_EMBEDDING"
value={embeddingsProvider}
onChange={(e) => {
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"
>
{providerOptions.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 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) => {
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>
</CardContent>
<CardFooter className="flex justify-between pt-6">
<Button type="submit" disabled={isSaving}>{isSaving ? t('admin.ai.saving') : t('admin.ai.saveSettings')}</Button>
<a href="/admin/ai-test">
<Button type="button" variant="outline" className="gap-2">
<TestTube className="h-4 w-4" />
{t('admin.ai.openTestPanel')}
<ExternalLink className="h-3 w-3" />
</Button>
</a>
</CardFooter>
</form>
</Card>
<Card>
<CardHeader>
<CardTitle>{t('admin.email.title')}</CardTitle>
<CardDescription>{t('admin.email.description')}</CardDescription>
</CardHeader>
<form onSubmit={(e) => { e.preventDefault(); handleSaveEmail(new FormData(e.currentTarget)) }}>
<CardContent className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium">{t('admin.email.provider')}</label>
<div className="flex gap-2">
<button
type="button"
onClick={() => setEmailProvider('resend')}
className={`flex-1 px-4 py-2.5 rounded-lg border text-sm font-medium transition-colors ${
emailProvider === 'resend'
? 'border-primary bg-primary/10 text-primary'
: 'border-border bg-background text-muted-foreground hover:bg-muted'
}`}
>
Resend
</button>
<button
type="button"
onClick={() => setEmailProvider('smtp')}
className={`flex-1 px-4 py-2.5 rounded-lg border text-sm font-medium transition-colors ${
emailProvider === 'smtp'
? 'border-primary bg-primary/10 text-primary'
: 'border-border bg-background text-muted-foreground hover:bg-muted'
}`}
>
SMTP
</button>
</div>
</div>
{/* Email service status */}
<div className="rounded-lg border bg-muted/30 p-3 space-y-1.5">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">{t('admin.email.status')}</div>
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm">
<div className="flex items-center gap-1.5">
<span className={`inline-block w-2 h-2 rounded-full ${config.RESEND_API_KEY ? 'bg-green-500' : 'bg-slate-300'}`} />
<span className={config.RESEND_API_KEY ? 'text-green-700' : 'text-slate-400'}>Resend</span>
{config.RESEND_API_KEY && <span className="text-xs text-muted-foreground">({t('admin.email.keySet')})</span>}
</div>
<div className="flex items-center gap-1.5">
<span className={`inline-block w-2 h-2 rounded-full ${config.SMTP_HOST ? 'bg-green-500' : 'bg-slate-300'}`} />
<span className={config.SMTP_HOST ? 'text-green-700' : 'text-slate-400'}>SMTP</span>
{config.SMTP_HOST && <span className="text-xs text-muted-foreground">({config.SMTP_HOST}:{config.SMTP_PORT || '587'})</span>}
</div>
</div>
<div className="text-xs font-medium text-primary">
{t('admin.email.activeProvider')}: {emailProvider === 'resend' ? 'Resend' : 'SMTP'}
</div>
{emailTestResult && (
<div className={`flex items-center gap-1.5 text-xs pt-1 border-t ${emailTestResult.success ? 'text-green-600' : 'text-red-500'}`}>
<span className={`inline-block w-2 h-2 rounded-full ${emailTestResult.success ? 'bg-green-500' : 'bg-red-500'}`} />
<span>
{emailTestResult.provider === 'resend' ? 'Resend' : 'SMTP'} {emailTestResult.success ? t('admin.email.testOk') : `${t('admin.email.testFail')}: ${emailTestResult.message || ''}`}
</span>
</div>
)}
</div>
{emailProvider === 'resend' ? (
<div className="space-y-3">
<div className="space-y-2">
<label htmlFor="RESEND_API_KEY" className="text-sm font-medium">{t('admin.resend.apiKey')}</label>
<Input id="RESEND_API_KEY" name="RESEND_API_KEY" type="password" defaultValue={config.RESEND_API_KEY || ''} placeholder="re_..." />
<p className="text-xs text-muted-foreground">{t('admin.resend.apiKeyHint')}</p>
</div>
{config.RESEND_API_KEY && (
<div className="flex items-center gap-2 text-xs text-green-600">
<span className="inline-block w-2 h-2 rounded-full bg-green-500" />
{t('admin.resend.configured')}
</div>
)}
</div>
) : (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<label htmlFor="SMTP_HOST" className="text-sm font-medium">{t('admin.smtp.host')}</label>
<Input id="SMTP_HOST" name="SMTP_HOST" defaultValue={config.SMTP_HOST || ''} placeholder="smtp.example.com" />
</div>
<div className="space-y-2">
<label htmlFor="SMTP_PORT" className="text-sm font-medium">{t('admin.smtp.port')}</label>
<Input id="SMTP_PORT" name="SMTP_PORT" defaultValue={config.SMTP_PORT || '587'} placeholder="587" />
</div>
</div>
<div className="space-y-2">
<label htmlFor="SMTP_USER" className="text-sm font-medium">{t('admin.smtp.username')}</label>
<Input id="SMTP_USER" name="SMTP_USER" defaultValue={config.SMTP_USER || ''} />
</div>
<div className="space-y-2">
<label htmlFor="SMTP_PASS" className="text-sm font-medium">{t('admin.smtp.password')}</label>
<Input id="SMTP_PASS" name="SMTP_PASS" type="password" defaultValue={config.SMTP_PASS || ''} />
</div>
<div className="space-y-2">
<label htmlFor="SMTP_FROM" className="text-sm font-medium">{t('admin.smtp.fromEmail')}</label>
<Input id="SMTP_FROM" name="SMTP_FROM" defaultValue={config.SMTP_FROM || 'noreply@memento.app'} />
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="SMTP_SECURE"
checked={smtpSecure}
onCheckedChange={(c) => setSmtpSecure(!!c)}
/>
<label
htmlFor="SMTP_SECURE"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{t('admin.smtp.forceSSL')}
</label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="SMTP_IGNORE_CERT"
checked={smtpIgnoreCert}
onCheckedChange={(c) => setSmtpIgnoreCert(!!c)}
/>
<label
htmlFor="SMTP_IGNORE_CERT"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 text-yellow-600"
>
{t('admin.smtp.ignoreCertErrors')}
</label>
</div>
</div>
)}
</CardContent>
<CardFooter className="flex justify-between pt-6">
<Button type="submit" disabled={isSaving}>{t('admin.email.saveSettings')}</Button>
<Button type="button" variant="secondary" onClick={handleTestEmail} disabled={isTesting}>
{isTesting ? t('admin.smtp.sending') : t('admin.smtp.testEmail')}
</Button>
</CardFooter>
</form>
</Card>
<Card>
<CardHeader>
<CardTitle>{t('admin.tools.title')}</CardTitle>
<CardDescription>{t('admin.tools.description')}</CardDescription>
</CardHeader>
<form onSubmit={(e) => { e.preventDefault(); handleSaveTools(new FormData(e.currentTarget)) }}>
<CardContent className="space-y-4">
<div className="space-y-2">
<label htmlFor="WEB_SEARCH_PROVIDER" className="text-sm font-medium">{t('admin.tools.searchProvider')}</label>
<select
id="WEB_SEARCH_PROVIDER"
name="WEB_SEARCH_PROVIDER"
value={webSearchProvider}
onChange={(e) => setWebSearchProvider(e.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
<option value="searxng">{t('admin.tools.searxng')}</option>
<option value="brave">{t('admin.tools.brave')}</option>
<option value="both">{t('admin.tools.both')}</option>
</select>
</div>
{(webSearchProvider === 'searxng' || webSearchProvider === 'both') && (
<div className="space-y-2">
<label htmlFor="SEARXNG_URL" className="text-sm font-medium">{t('admin.tools.searxngUrl')}</label>
<Input id="SEARXNG_URL" name="SEARXNG_URL" defaultValue={config.SEARXNG_URL || 'http://localhost:8080'} placeholder="http://localhost:8080" />
</div>
)}
{(webSearchProvider === 'brave' || webSearchProvider === 'both') && (
<div className="space-y-2">
<label htmlFor="BRAVE_SEARCH_API_KEY" className="text-sm font-medium">{t('admin.tools.braveKey')}</label>
<Input id="BRAVE_SEARCH_API_KEY" name="BRAVE_SEARCH_API_KEY" type="password" defaultValue={config.BRAVE_SEARCH_API_KEY || ''} placeholder="BSA-..." />
</div>
)}
<div className="space-y-2">
<label htmlFor="JINA_API_KEY" className="text-sm font-medium">{t('admin.tools.jinaKey')}</label>
<Input id="JINA_API_KEY" name="JINA_API_KEY" type="password" defaultValue={config.JINA_API_KEY || ''} placeholder={t('admin.tools.jinaKeyOptional')} />
<p className="text-xs text-muted-foreground">{t('admin.tools.jinaKeyDescription')}</p>
</div>
{/* Search test result */}
{searchTestResult && (
<div className={`rounded-lg border p-3 text-sm flex items-start gap-2 ${searchTestResult.success ? 'border-green-200 bg-green-50 text-green-800 dark:border-green-800 dark:bg-green-950/30 dark:text-green-300' : 'border-red-200 bg-red-50 text-red-800 dark:border-red-800 dark:bg-red-950/30 dark:text-red-300'}`}>
<span className={`mt-0.5 inline-block w-2 h-2 rounded-full flex-shrink-0 ${searchTestResult.success ? 'bg-green-500' : 'bg-red-500'}`} />
<span>{searchTestResult.message}</span>
</div>
)}
</CardContent>
<CardFooter className="flex justify-between">
<Button type="submit" disabled={isSaving}>{t('admin.tools.saveSettings')}</Button>
<Button
type="button"
variant="secondary"
onClick={handleTestSearch}
disabled={isTestingSearch}
>
{isTestingSearch ? t('admin.tools.testing') : t('admin.tools.testSearch')}
</Button>
</CardFooter>
</form>
</Card>
</div>
)
}