feat(ai): localize AI features
This commit is contained in:
@@ -6,10 +6,12 @@ import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { updateSystemConfig, testSMTP } from '@/app/actions/admin-settings'
|
||||
import { getOllamaModels } from '@/app/actions/ollama'
|
||||
import { toast } from 'sonner'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { TestTube, ExternalLink } from 'lucide-react'
|
||||
import { TestTube, ExternalLink, RefreshCw } from 'lucide-react'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
type AIProvider = 'ollama' | 'openai' | 'custom'
|
||||
|
||||
@@ -19,10 +21,7 @@ interface AvailableModels {
|
||||
}
|
||||
|
||||
const MODELS_2026 = {
|
||||
ollama: {
|
||||
tags: ['llama3:latest', 'llama3.2:latest', 'granite4:latest', 'mistral:latest', 'mixtral:latest', 'phi3:latest', 'gemma2:latest', 'qwen2:latest'],
|
||||
embeddings: ['embeddinggemma:latest', 'mxbai-embed-large:latest', 'nomic-embed-text:latest']
|
||||
},
|
||||
// Removed hardcoded Ollama models in favor of dynamic fetching
|
||||
openai: {
|
||||
tags: ['gpt-4o', 'gpt-4o-mini', 'gpt-4-turbo', 'gpt-4', 'gpt-3.5-turbo'],
|
||||
embeddings: ['text-embedding-3-small', 'text-embedding-3-large', 'text-embedding-ada-002']
|
||||
@@ -34,6 +33,7 @@ const MODELS_2026 = {
|
||||
}
|
||||
|
||||
export function AdminSettingsForm({ config }: { config: Record<string, string> }) {
|
||||
const { t } = useLanguage()
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [isTesting, setIsTesting] = useState(false)
|
||||
|
||||
@@ -46,15 +46,68 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
const [tagsProvider, setTagsProvider] = useState<AIProvider>((config.AI_PROVIDER_TAGS as AIProvider) || 'ollama')
|
||||
const [embeddingsProvider, setEmbeddingsProvider] = useState<AIProvider>((config.AI_PROVIDER_EMBEDDING as AIProvider) || 'ollama')
|
||||
|
||||
// Sync state with config when server revalidates
|
||||
// Selected Models State (Controlled Inputs)
|
||||
const [selectedTagsModel, setSelectedTagsModel] = useState<string>(config.AI_MODEL_TAGS || '')
|
||||
const [selectedEmbeddingModel, setSelectedEmbeddingModel] = useState<string>(config.AI_MODEL_EMBEDDING || '')
|
||||
|
||||
|
||||
// Dynamic Models State
|
||||
const [ollamaTagsModels, setOllamaTagsModels] = useState<string[]>([])
|
||||
const [ollamaEmbeddingsModels, setOllamaEmbeddingsModels] = useState<string[]>([])
|
||||
const [isLoadingTagsModels, setIsLoadingTagsModels] = useState(false)
|
||||
const [isLoadingEmbeddingsModels, setIsLoadingEmbeddingsModels] = useState(false)
|
||||
|
||||
// Sync state with config
|
||||
useEffect(() => {
|
||||
setAllowRegister(config.ALLOW_REGISTRATION !== 'false')
|
||||
setSmtpSecure(config.SMTP_SECURE === 'true')
|
||||
setSmtpIgnoreCert(config.SMTP_IGNORE_CERT === 'true')
|
||||
setTagsProvider((config.AI_PROVIDER_TAGS as AIProvider) || 'ollama')
|
||||
setEmbeddingsProvider((config.AI_PROVIDER_EMBEDDING as AIProvider) || 'ollama')
|
||||
setSelectedTagsModel(config.AI_MODEL_TAGS || '')
|
||||
setSelectedEmbeddingModel(config.AI_MODEL_EMBEDDING || '')
|
||||
}, [config])
|
||||
|
||||
// Fetch Ollama models
|
||||
const fetchOllamaModels = useCallback(async (type: 'tags' | 'embeddings', url: string) => {
|
||||
if (!url) return
|
||||
|
||||
if (type === 'tags') setIsLoadingTagsModels(true)
|
||||
else setIsLoadingEmbeddingsModels(true)
|
||||
|
||||
try {
|
||||
const result = await getOllamaModels(url)
|
||||
|
||||
if (result.success) {
|
||||
if (type === 'tags') setOllamaTagsModels(result.models)
|
||||
else setOllamaEmbeddingsModels(result.models)
|
||||
} else {
|
||||
toast.error(`Failed to fetch Ollama models: ${result.error}`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
toast.error('Failed to fetch Ollama models')
|
||||
} finally {
|
||||
if (type === 'tags') setIsLoadingTagsModels(false)
|
||||
else setIsLoadingEmbeddingsModels(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Initial fetch for Ollama models if provider is selected
|
||||
useEffect(() => {
|
||||
if (tagsProvider === 'ollama') {
|
||||
const url = config.OLLAMA_BASE_URL_TAGS || config.OLLAMA_BASE_URL || 'http://localhost:11434'
|
||||
fetchOllamaModels('tags', url)
|
||||
}
|
||||
}, [tagsProvider, config.OLLAMA_BASE_URL_TAGS, config.OLLAMA_BASE_URL, fetchOllamaModels])
|
||||
|
||||
useEffect(() => {
|
||||
if (embeddingsProvider === 'ollama') {
|
||||
const url = config.OLLAMA_BASE_URL_EMBEDDING || config.OLLAMA_BASE_URL || 'http://localhost:11434'
|
||||
fetchOllamaModels('embeddings', url)
|
||||
}
|
||||
}, [embeddingsProvider, config.OLLAMA_BASE_URL_EMBEDDING, config.OLLAMA_BASE_URL, fetchOllamaModels])
|
||||
|
||||
const handleSaveSecurity = async (formData: FormData) => {
|
||||
setIsSaving(true)
|
||||
const data = {
|
||||
@@ -65,9 +118,9 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
setIsSaving(false)
|
||||
|
||||
if (result.error) {
|
||||
toast.error('Failed to update security settings')
|
||||
toast.error(t('admin.security.updateFailed'))
|
||||
} else {
|
||||
toast.success('Security Settings updated')
|
||||
toast.success(t('admin.security.updateSuccess'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,9 +129,8 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
const data: Record<string, string> = {}
|
||||
|
||||
try {
|
||||
// Tags provider configuration
|
||||
const tagsProv = formData.get('AI_PROVIDER_TAGS') as AIProvider
|
||||
if (!tagsProv) throw new Error('AI_PROVIDER_TAGS is required')
|
||||
if (!tagsProv) throw new Error(t('admin.ai.providerTagsRequired'))
|
||||
data.AI_PROVIDER_TAGS = tagsProv
|
||||
|
||||
const tagsModel = formData.get('AI_MODEL_TAGS') as string
|
||||
@@ -86,7 +138,7 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
|
||||
if (tagsProv === 'ollama') {
|
||||
const ollamaUrl = formData.get('OLLAMA_BASE_URL_TAGS') as string
|
||||
if (ollamaUrl) data.OLLAMA_BASE_URL = ollamaUrl
|
||||
if (ollamaUrl) data.OLLAMA_BASE_URL_TAGS = ollamaUrl
|
||||
} else if (tagsProv === 'openai') {
|
||||
const openaiKey = formData.get('OPENAI_API_KEY') as string
|
||||
if (openaiKey) data.OPENAI_API_KEY = openaiKey
|
||||
@@ -97,9 +149,8 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
if (customUrl) data.CUSTOM_OPENAI_BASE_URL = customUrl
|
||||
}
|
||||
|
||||
// Embeddings provider configuration
|
||||
const embedProv = formData.get('AI_PROVIDER_EMBEDDING') as AIProvider
|
||||
if (!embedProv) throw new Error('AI_PROVIDER_EMBEDDING is required')
|
||||
if (!embedProv) throw new Error(t('admin.ai.providerEmbeddingRequired'))
|
||||
data.AI_PROVIDER_EMBEDDING = embedProv
|
||||
|
||||
const embedModel = formData.get('AI_MODEL_EMBEDDING') as string
|
||||
@@ -107,7 +158,7 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
|
||||
if (embedProv === 'ollama') {
|
||||
const ollamaUrl = formData.get('OLLAMA_BASE_URL_EMBEDDING') as string
|
||||
if (ollamaUrl) data.OLLAMA_BASE_URL = ollamaUrl
|
||||
if (ollamaUrl) data.OLLAMA_BASE_URL_EMBEDDING = ollamaUrl
|
||||
} else if (embedProv === 'openai') {
|
||||
const openaiKey = formData.get('OPENAI_API_KEY') as string
|
||||
if (openaiKey) data.OPENAI_API_KEY = openaiKey
|
||||
@@ -118,20 +169,30 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
if (customUrl) data.CUSTOM_OPENAI_BASE_URL = customUrl
|
||||
}
|
||||
|
||||
console.log('Saving AI config:', data)
|
||||
|
||||
const result = await updateSystemConfig(data)
|
||||
setIsSaving(false)
|
||||
|
||||
if (result.error) {
|
||||
toast.error('Failed to update AI settings: ' + result.error)
|
||||
toast.error(t('admin.ai.updateFailed') + ': ' + result.error)
|
||||
} else {
|
||||
toast.success('AI Settings updated successfully')
|
||||
toast.success(t('admin.ai.updateSuccess'))
|
||||
setTagsProvider(tagsProv)
|
||||
setEmbeddingsProvider(embedProv)
|
||||
|
||||
// Refresh models after save if Ollama is selected
|
||||
if (tagsProv === 'ollama') {
|
||||
const url = data.OLLAMA_BASE_URL_TAGS || config.OLLAMA_BASE_URL || 'http://localhost:11434'
|
||||
fetchOllamaModels('tags', url)
|
||||
}
|
||||
if (embedProv === 'ollama') {
|
||||
const url = data.OLLAMA_BASE_URL_EMBEDDING || config.OLLAMA_BASE_URL || 'http://localhost:11434'
|
||||
fetchOllamaModels('embeddings', url)
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
setIsSaving(false)
|
||||
toast.error('Error: ' + error.message)
|
||||
toast.error(t('general.error') + ': ' + error.message)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,9 +212,9 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
setIsSaving(false)
|
||||
|
||||
if (result.error) {
|
||||
toast.error('Failed to update SMTP settings')
|
||||
toast.error(t('admin.smtp.updateFailed'))
|
||||
} else {
|
||||
toast.success('SMTP Settings updated')
|
||||
toast.success(t('admin.smtp.updateSuccess'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,12 +223,12 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
try {
|
||||
const result: any = await testSMTP()
|
||||
if (result.success) {
|
||||
toast.success('Test email sent successfully!')
|
||||
toast.success(t('admin.smtp.testSuccess'))
|
||||
} else {
|
||||
toast.error(`Failed: ${result.error}`)
|
||||
toast.error(t('admin.smtp.testFailed', { error: result.error }))
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast.error(`Error: ${e.message}`)
|
||||
toast.error(t('general.error') + ': ' + e.message)
|
||||
} finally {
|
||||
setIsTesting(false)
|
||||
}
|
||||
@@ -177,8 +238,8 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Security Settings</CardTitle>
|
||||
<CardDescription>Manage access control and registration policies.</CardDescription>
|
||||
<CardTitle>{t('admin.security.title')}</CardTitle>
|
||||
<CardDescription>{t('admin.security.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<form action={handleSaveSecurity}>
|
||||
<CardContent className="space-y-4">
|
||||
@@ -192,35 +253,34 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
htmlFor="ALLOW_REGISTRATION"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
Allow Public Registration
|
||||
{t('admin.security.allowPublicRegistration')}
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
If disabled, new users can only be added by an Administrator via the User Management page.
|
||||
{t('admin.security.allowPublicRegistrationDescription')}
|
||||
</p>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button type="submit" disabled={isSaving}>Save Security Settings</Button>
|
||||
<Button type="submit" disabled={isSaving}>{t('admin.security.title')}</Button>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>AI Configuration</CardTitle>
|
||||
<CardDescription>Configure AI providers for auto-tagging and semantic search. Use different providers for optimal performance.</CardDescription>
|
||||
<CardTitle>{t('admin.ai.title')}</CardTitle>
|
||||
<CardDescription>{t('admin.ai.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<form action={handleSaveAI}>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Tags Generation Section */}
|
||||
<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> Tags Generation Provider
|
||||
<span className="text-primary">🏷️</span> {t('admin.ai.tagsGenerationProvider')}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground">AI provider for automatic tag suggestions. Recommended: Ollama (free, local).</p>
|
||||
<p className="text-xs text-muted-foreground">{t('admin.ai.tagsGenerationDescription')}</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="AI_PROVIDER_TAGS">Provider</Label>
|
||||
<Label htmlFor="AI_PROVIDER_TAGS">{t('admin.ai.provider')}</Label>
|
||||
<select
|
||||
id="AI_PROVIDER_TAGS"
|
||||
name="AI_PROVIDER_TAGS"
|
||||
@@ -228,100 +288,125 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
onChange={(e) => setTagsProvider(e.target.value as AIProvider)}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
<option value="ollama">🦙 Ollama (Local & Free)</option>
|
||||
<option value="openai">🤖 OpenAI (GPT-5, GPT-4)</option>
|
||||
<option value="custom">🔧 Custom OpenAI-Compatible</option>
|
||||
<option value="ollama">{t('admin.ai.providerOllamaOption')}</option>
|
||||
<option value="openai">{t('admin.ai.providerOpenAIOption')}</option>
|
||||
<option value="custom">{t('admin.ai.providerCustomOption')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Ollama Tags Config */}
|
||||
{tagsProvider === 'ollama' && (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="OLLAMA_BASE_URL_TAGS">Base URL</Label>
|
||||
<Input id="OLLAMA_BASE_URL_TAGS" name="OLLAMA_BASE_URL_TAGS" defaultValue={config.OLLAMA_BASE_URL || 'http://localhost:11434'} placeholder="http://localhost:11434" />
|
||||
<Label htmlFor="OLLAMA_BASE_URL_TAGS">{t('admin.ai.baseUrl')}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="OLLAMA_BASE_URL_TAGS"
|
||||
name="OLLAMA_BASE_URL_TAGS"
|
||||
defaultValue={config.OLLAMA_BASE_URL_TAGS || config.OLLAMA_BASE_URL || 'http://localhost:11434'}
|
||||
placeholder="http://localhost:11434"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
const input = document.getElementById('OLLAMA_BASE_URL_TAGS') as HTMLInputElement
|
||||
fetchOllamaModels('tags', input.value)
|
||||
}}
|
||||
disabled={isLoadingTagsModels}
|
||||
title="Refresh Models"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${isLoadingTagsModels ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="AI_MODEL_TAGS_OLLAMA">Model</Label>
|
||||
<Label htmlFor="AI_MODEL_TAGS_OLLAMA">{t('admin.ai.model')}</Label>
|
||||
<select
|
||||
id="AI_MODEL_TAGS_OLLAMA"
|
||||
name="AI_MODEL_TAGS"
|
||||
defaultValue={config.AI_MODEL_TAGS || 'granite4:latest'}
|
||||
name="AI_MODEL_TAGS_OLLAMA"
|
||||
value={selectedTagsModel}
|
||||
onChange={(e) => setSelectedTagsModel(e.target.value)}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
{MODELS_2026.ollama.tags.map((model) => (
|
||||
<option key={model} value={model}>{model}</option>
|
||||
))}
|
||||
{ollamaTagsModels.length > 0 ? (
|
||||
ollamaTagsModels.map((model) => (
|
||||
<option key={model} value={model}>{model}</option>
|
||||
))
|
||||
) : (
|
||||
<option value={selectedTagsModel || 'granite4:latest'}>{selectedTagsModel || 'granite4:latest'} {t('admin.ai.saved')}</option>
|
||||
)}
|
||||
</select>
|
||||
<p className="text-xs text-muted-foreground">Select an Ollama model installed on your system</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{isLoadingTagsModels ? 'Fetching models...' : t('admin.ai.selectOllamaModel')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* OpenAI Tags Config */}
|
||||
{tagsProvider === 'openai' && (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="OPENAI_API_KEY">API Key</Label>
|
||||
<Label htmlFor="OPENAI_API_KEY">{t('admin.ai.apiKey')}</Label>
|
||||
<Input id="OPENAI_API_KEY" name="OPENAI_API_KEY" type="password" defaultValue={config.OPENAI_API_KEY || ''} placeholder="sk-..." />
|
||||
<p className="text-xs text-muted-foreground">Your OpenAI API key from <a href="https://platform.openai.com/api-keys" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">platform.openai.com</a></p>
|
||||
<p className="text-xs text-muted-foreground">{t('admin.ai.openAIKeyDescription')}</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="AI_MODEL_TAGS_OPENAI">Model</Label>
|
||||
<Label htmlFor="AI_MODEL_TAGS_OPENAI">{t('admin.ai.model')}</Label>
|
||||
<select
|
||||
id="AI_MODEL_TAGS_OPENAI"
|
||||
name="AI_MODEL_TAGS"
|
||||
defaultValue={config.AI_MODEL_TAGS || 'gpt-4o-mini'}
|
||||
name="AI_MODEL_TAGS_OPENAI"
|
||||
value={selectedTagsModel}
|
||||
onChange={(e) => setSelectedTagsModel(e.target.value)}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
{MODELS_2026.openai.tags.map((model) => (
|
||||
<option key={model} value={model}>{model}</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-muted-foreground"><strong className="text-green-600">gpt-4o-mini</strong> = Best value • <strong className="text-primary">gpt-4o</strong> = Best quality</p>
|
||||
<p className="text-xs text-muted-foreground"><strong className="text-green-600">gpt-4o-mini</strong> = {t('admin.ai.bestValue')} • <strong className="text-primary">gpt-4o</strong> = {t('admin.ai.bestQuality')}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Custom OpenAI Tags Config */}
|
||||
{tagsProvider === 'custom' && (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="CUSTOM_OPENAI_BASE_URL_TAGS">Base URL</Label>
|
||||
<Label htmlFor="CUSTOM_OPENAI_BASE_URL_TAGS">{t('admin.ai.baseUrl')}</Label>
|
||||
<Input id="CUSTOM_OPENAI_BASE_URL_TAGS" name="CUSTOM_OPENAI_BASE_URL_TAGS" defaultValue={config.CUSTOM_OPENAI_BASE_URL || ''} placeholder="https://api.example.com/v1" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="CUSTOM_OPENAI_API_KEY_TAGS">API Key</Label>
|
||||
<Label htmlFor="CUSTOM_OPENAI_API_KEY_TAGS">{t('admin.ai.apiKey')}</Label>
|
||||
<Input id="CUSTOM_OPENAI_API_KEY_TAGS" name="CUSTOM_OPENAI_API_KEY_TAGS" type="password" defaultValue={config.CUSTOM_OPENAI_API_KEY || ''} placeholder="sk-..." />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="AI_MODEL_TAGS_CUSTOM">Model</Label>
|
||||
<Label htmlFor="AI_MODEL_TAGS_CUSTOM">{t('admin.ai.model')}</Label>
|
||||
<select
|
||||
id="AI_MODEL_TAGS_CUSTOM"
|
||||
name="AI_MODEL_TAGS"
|
||||
defaultValue={config.AI_MODEL_TAGS || 'gpt-4o-mini'}
|
||||
name="AI_MODEL_TAGS_CUSTOM"
|
||||
value={selectedTagsModel}
|
||||
onChange={(e) => setSelectedTagsModel(e.target.value)}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
{MODELS_2026.custom.tags.map((model) => (
|
||||
<option key={model} value={model}>{model}</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-muted-foreground">Common models for OpenAI-compatible APIs</p>
|
||||
<p className="text-xs text-muted-foreground">{t('admin.ai.commonModelsDescription')}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Embeddings Section */}
|
||||
<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> Embeddings Provider
|
||||
<span className="text-green-600">🔍</span> {t('admin.ai.embeddingsProvider')}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground">AI provider for semantic search embeddings. Recommended: OpenAI (best quality).</p>
|
||||
<p className="text-xs text-muted-foreground">{t('admin.ai.embeddingsDescription')}</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="AI_PROVIDER_EMBEDDING">
|
||||
Provider
|
||||
{t('admin.ai.provider')}
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
(Current: {embeddingsProvider})
|
||||
</span>
|
||||
@@ -333,99 +418,125 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
onChange={(e) => setEmbeddingsProvider(e.target.value as AIProvider)}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
<option value="ollama">🦙 Ollama (Local & Free)</option>
|
||||
<option value="openai">🤖 OpenAI (text-embedding-4)</option>
|
||||
<option value="custom">🔧 Custom OpenAI-Compatible</option>
|
||||
<option value="ollama">{t('admin.ai.providerOllamaOption')}</option>
|
||||
<option value="openai">{t('admin.ai.providerOpenAIOption')}</option>
|
||||
<option value="custom">{t('admin.ai.providerCustomOption')}</option>
|
||||
</select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Config value: {config.AI_PROVIDER_EMBEDDING || 'Not set (defaults to ollama)'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Ollama Embeddings Config */}
|
||||
{embeddingsProvider === 'ollama' && (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="OLLAMA_BASE_URL_EMBEDDING">Base URL</Label>
|
||||
<Input id="OLLAMA_BASE_URL_EMBEDDING" name="OLLAMA_BASE_URL_EMBEDDING" defaultValue={config.OLLAMA_BASE_URL || 'http://localhost:11434'} placeholder="http://localhost:11434" />
|
||||
<Label htmlFor="OLLAMA_BASE_URL_EMBEDDING">{t('admin.ai.baseUrl')}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="OLLAMA_BASE_URL_EMBEDDING"
|
||||
name="OLLAMA_BASE_URL_EMBEDDING"
|
||||
defaultValue={config.OLLAMA_BASE_URL_EMBEDDING || config.OLLAMA_BASE_URL || 'http://localhost:11434'}
|
||||
placeholder="http://localhost:11434"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
const input = document.getElementById('OLLAMA_BASE_URL_EMBEDDING') as HTMLInputElement
|
||||
fetchOllamaModels('embeddings', input.value)
|
||||
}}
|
||||
disabled={isLoadingEmbeddingsModels}
|
||||
title="Refresh Models"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${isLoadingEmbeddingsModels ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="AI_MODEL_EMBEDDING_OLLAMA">Model</Label>
|
||||
<Label htmlFor="AI_MODEL_EMBEDDING_OLLAMA">{t('admin.ai.model')}</Label>
|
||||
<select
|
||||
id="AI_MODEL_EMBEDDING_OLLAMA"
|
||||
name="AI_MODEL_EMBEDDING"
|
||||
defaultValue={config.AI_MODEL_EMBEDDING || 'embeddinggemma:latest'}
|
||||
name="AI_MODEL_EMBEDDING_OLLAMA"
|
||||
value={selectedEmbeddingModel}
|
||||
onChange={(e) => setSelectedEmbeddingModel(e.target.value)}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
{MODELS_2026.ollama.embeddings.map((model) => (
|
||||
<option key={model} value={model}>{model}</option>
|
||||
))}
|
||||
{ollamaEmbeddingsModels.length > 0 ? (
|
||||
ollamaEmbeddingsModels.map((model) => (
|
||||
<option key={model} value={model}>{model}</option>
|
||||
))
|
||||
) : (
|
||||
<option value={selectedEmbeddingModel || 'embeddinggemma:latest'}>{selectedEmbeddingModel || 'embeddinggemma:latest'} {t('admin.ai.saved')}</option>
|
||||
)}
|
||||
</select>
|
||||
<p className="text-xs text-muted-foreground">Select an embedding model installed on your system</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{isLoadingEmbeddingsModels ? 'Fetching models...' : t('admin.ai.selectEmbeddingModel')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* OpenAI Embeddings Config */}
|
||||
{embeddingsProvider === 'openai' && (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="OPENAI_API_KEY">API Key</Label>
|
||||
<Label htmlFor="OPENAI_API_KEY">{t('admin.ai.apiKey')}</Label>
|
||||
<Input id="OPENAI_API_KEY" name="OPENAI_API_KEY" type="password" defaultValue={config.OPENAI_API_KEY || ''} placeholder="sk-..." />
|
||||
<p className="text-xs text-muted-foreground">Your OpenAI API key from <a href="https://platform.openai.com/api-keys" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">platform.openai.com</a></p>
|
||||
<p className="text-xs text-muted-foreground">{t('admin.ai.openAIKeyDescription')}</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="AI_MODEL_EMBEDDING_OPENAI">Model</Label>
|
||||
<Label htmlFor="AI_MODEL_EMBEDDING_OPENAI">{t('admin.ai.model')}</Label>
|
||||
<select
|
||||
id="AI_MODEL_EMBEDDING_OPENAI"
|
||||
name="AI_MODEL_EMBEDDING"
|
||||
defaultValue={config.AI_MODEL_EMBEDDING || 'text-embedding-3-small'}
|
||||
name="AI_MODEL_EMBEDDING_OPENAI"
|
||||
value={selectedEmbeddingModel}
|
||||
onChange={(e) => setSelectedEmbeddingModel(e.target.value)}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
{MODELS_2026.openai.embeddings.map((model) => (
|
||||
<option key={model} value={model}>{model}</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-muted-foreground"><strong className="text-green-600">text-embedding-3-small</strong> = Best value • <strong className="text-primary">text-embedding-3-large</strong> = Best quality</p>
|
||||
<p className="text-xs text-muted-foreground"><strong className="text-green-600">text-embedding-3-small</strong> = {t('admin.ai.bestValue')} • <strong className="text-primary">text-embedding-3-large</strong> = {t('admin.ai.bestQuality')}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Custom OpenAI Embeddings Config */}
|
||||
{embeddingsProvider === 'custom' && (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="CUSTOM_OPENAI_BASE_URL_EMBEDDING">Base URL</Label>
|
||||
<Label htmlFor="CUSTOM_OPENAI_BASE_URL_EMBEDDING">{t('admin.ai.baseUrl')}</Label>
|
||||
<Input id="CUSTOM_OPENAI_BASE_URL_EMBEDDING" name="CUSTOM_OPENAI_BASE_URL_EMBEDDING" defaultValue={config.CUSTOM_OPENAI_BASE_URL || ''} placeholder="https://api.example.com/v1" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="CUSTOM_OPENAI_API_KEY_EMBEDDING">API Key</Label>
|
||||
<Label htmlFor="CUSTOM_OPENAI_API_KEY_EMBEDDING">{t('admin.ai.apiKey')}</Label>
|
||||
<Input id="CUSTOM_OPENAI_API_KEY_EMBEDDING" name="CUSTOM_OPENAI_API_KEY_EMBEDDING" type="password" defaultValue={config.CUSTOM_OPENAI_API_KEY || ''} placeholder="sk-..." />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="AI_MODEL_EMBEDDING_CUSTOM">Model</Label>
|
||||
<Label htmlFor="AI_MODEL_EMBEDDING_CUSTOM">{t('admin.ai.model')}</Label>
|
||||
<select
|
||||
id="AI_MODEL_EMBEDDING_CUSTOM"
|
||||
name="AI_MODEL_EMBEDDING"
|
||||
defaultValue={config.AI_MODEL_EMBEDDING || 'text-embedding-3-small'}
|
||||
name="AI_MODEL_EMBEDDING_CUSTOM"
|
||||
value={selectedEmbeddingModel}
|
||||
onChange={(e) => setSelectedEmbeddingModel(e.target.value)}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
{MODELS_2026.custom.embeddings.map((model) => (
|
||||
<option key={model} value={model}>{model}</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-muted-foreground">Common embedding models for OpenAI-compatible APIs</p>
|
||||
<p className="text-xs text-muted-foreground">{t('admin.ai.commonEmbeddingModels')}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-between">
|
||||
<Button type="submit" disabled={isSaving}>{isSaving ? 'Saving...' : 'Save AI Settings'}</Button>
|
||||
<Button type="submit" disabled={isSaving}>{isSaving ? t('admin.ai.saving') : t('admin.ai.saveSettings')}</Button>
|
||||
<Link href="/admin/ai-test">
|
||||
<Button type="button" variant="outline" className="gap-2">
|
||||
<TestTube className="h-4 w-4" />
|
||||
Open AI Test Panel
|
||||
{t('admin.ai.openTestPanel')}
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</Button>
|
||||
</Link>
|
||||
@@ -435,34 +546,34 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>SMTP Configuration</CardTitle>
|
||||
<CardDescription>Configure email server for password resets.</CardDescription>
|
||||
<CardTitle>{t('admin.smtp.title')}</CardTitle>
|
||||
<CardDescription>{t('admin.smtp.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<form action={handleSaveSMTP}>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="SMTP_HOST" className="text-sm font-medium">Host</label>
|
||||
<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">Port</label>
|
||||
<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">Username</label>
|
||||
<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">Password</label>
|
||||
<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">From Email</label>
|
||||
<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>
|
||||
|
||||
@@ -476,7 +587,7 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
htmlFor="SMTP_SECURE"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
Force SSL/TLS (usually for port 465)
|
||||
{t('admin.smtp.forceSSL')}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -490,14 +601,14 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
htmlFor="SMTP_IGNORE_CERT"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 text-yellow-600"
|
||||
>
|
||||
Ignore Certificate Errors (Self-hosted/Dev only)
|
||||
{t('admin.smtp.ignoreCertErrors')}
|
||||
</label>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-between">
|
||||
<Button type="submit" disabled={isSaving}>Save SMTP Settings</Button>
|
||||
<Button type="submit" disabled={isSaving}>{t('admin.smtp.saveSettings')}</Button>
|
||||
<Button type="button" variant="secondary" onClick={handleTestEmail} disabled={isTesting}>
|
||||
{isTesting ? 'Sending...' : 'Test Email'}
|
||||
{isTesting ? t('admin.smtp.sending') : t('admin.smtp.testEmail')}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</form>
|
||||
|
||||
Reference in New Issue
Block a user