feat: add reminders page, BMad skills upgrade, MCP server refactor

- Add reminders page with navigation support
- Upgrade BMad builder module to skills-based architecture
- Refactor MCP server: extract tools and auth into separate modules
- Add connections cache, custom AI provider support
- Update prisma schema and generated client
- Various UI/UX improvements and i18n updates
- Add service worker for PWA support

Made-with: Cursor
This commit is contained in:
Sepehr Ramezani
2026-04-13 21:02:53 +02:00
parent 18ed116e0d
commit fa7e166f3e
3099 changed files with 397228 additions and 14584 deletions

View File

@@ -7,6 +7,7 @@ import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle }
import { Label } from '@/components/ui/label'
import { updateSystemConfig, testSMTP } from '@/app/actions/admin-settings'
import { getOllamaModels } from '@/app/actions/ollama'
import { getCustomModels, getCustomEmbeddingModels } from '@/app/actions/custom-provider'
import { toast } from 'sonner'
import { useState, useEffect, useCallback } from 'react'
import Link from 'next/link'
@@ -21,15 +22,10 @@ interface AvailableModels {
}
const MODELS_2026 = {
// 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']
},
custom: {
tags: ['gpt-4o-mini', 'gpt-4o', 'claude-3-haiku', 'claude-3-sonnet', 'llama-3.1-8b'],
embeddings: ['text-embedding-3-small', 'text-embedding-3-large', 'text-embedding-ada-002']
}
}
export function AdminSettingsForm({ config }: { config: Record<string, string> }) {
@@ -57,6 +53,14 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
const [isLoadingTagsModels, setIsLoadingTagsModels] = useState(false)
const [isLoadingEmbeddingsModels, setIsLoadingEmbeddingsModels] = useState(false)
// Custom provider dynamic models
const [customTagsModels, setCustomTagsModels] = useState<string[]>([])
const [customEmbeddingsModels, setCustomEmbeddingsModels] = useState<string[]>([])
const [isLoadingCustomTagsModels, setIsLoadingCustomTagsModels] = useState(false)
const [isLoadingCustomEmbeddingsModels, setIsLoadingCustomEmbeddingsModels] = useState(false)
const [customTagsSearch, setCustomTagsSearch] = useState('')
const [customEmbeddingsSearch, setCustomEmbeddingsSearch] = useState('')
// Fetch Ollama models
const fetchOllamaModels = useCallback(async (type: 'tags' | 'embeddings', url: string) => {
@@ -83,6 +87,33 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
}
}, [])
// Fetch Custom provider models — tags use /v1/models, embeddings use /v1/embeddings/models
const fetchCustomModels = useCallback(async (type: 'tags' | 'embeddings', url: string, apiKey?: string) => {
if (!url) return
if (type === 'tags') setIsLoadingCustomTagsModels(true)
else setIsLoadingCustomEmbeddingsModels(true)
try {
const result = type === 'embeddings'
? await getCustomEmbeddingModels(url, apiKey)
: await getCustomModels(url, apiKey)
if (result.success && result.models.length > 0) {
if (type === 'tags') setCustomTagsModels(result.models)
else setCustomEmbeddingsModels(result.models)
} else {
toast.error(`Impossible de récupérer les modèles : ${result.error}`)
}
} catch (error) {
console.error(error)
toast.error('Erreur lors de la récupération des modèles')
} finally {
if (type === 'tags') setIsLoadingCustomTagsModels(false)
else setIsLoadingCustomEmbeddingsModels(false)
}
}, [])
// Initial fetch for Ollama models if provider is selected
useEffect(() => {
if (tagsProvider === 'ollama') {
@@ -96,10 +127,23 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
if (embeddingsProvider === 'ollama') {
const url = config.OLLAMA_BASE_URL_EMBEDDING || config.OLLAMA_BASE_URL || 'http://localhost:11434'
fetchOllamaModels('embeddings', url)
} else if (embeddingsProvider === 'custom') {
const url = config.CUSTOM_OPENAI_BASE_URL || ''
const key = config.CUSTOM_OPENAI_API_KEY || ''
if (url) fetchCustomModels('embeddings', url, key)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [embeddingsProvider])
useEffect(() => {
if (tagsProvider === 'custom') {
const url = config.CUSTOM_OPENAI_BASE_URL || ''
const key = config.CUSTOM_OPENAI_API_KEY || ''
if (url) fetchCustomModels('tags', url, key)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tagsProvider])
const handleSaveSecurity = async (formData: FormData) => {
setIsSaving(true)
const data = {
@@ -270,7 +314,7 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
const defaultModels: Record<string, string> = {
ollama: '',
openai: MODELS_2026.openai.tags[0],
custom: MODELS_2026.custom.tags[0],
custom: '',
}
setSelectedTagsModel(defaultModels[newProvider] || '')
}}
@@ -361,7 +405,28 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
<div className="space-y-3">
<div className="space-y-2">
<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 className="flex gap-2">
<Input
id="CUSTOM_OPENAI_BASE_URL_TAGS"
name="CUSTOM_OPENAI_BASE_URL_TAGS"
defaultValue={config.CUSTOM_OPENAI_BASE_URL || ''}
placeholder="https://api.example.com/v1"
/>
<Button
type="button"
size="icon"
variant="outline"
onClick={() => {
const urlInput = document.getElementById('CUSTOM_OPENAI_BASE_URL_TAGS') as HTMLInputElement
const keyInput = document.getElementById('CUSTOM_OPENAI_API_KEY_TAGS') as HTMLInputElement
fetchCustomModels('tags', urlInput.value, keyInput.value)
}}
disabled={isLoadingCustomTagsModels}
title="Récupérer les modèles"
>
<RefreshCw className={`h-4 w-4 ${isLoadingCustomTagsModels ? 'animate-spin' : ''}`} />
</Button>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="CUSTOM_OPENAI_API_KEY_TAGS">{t('admin.ai.apiKey')}</Label>
@@ -369,18 +434,41 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
</div>
<div className="space-y-2">
<Label htmlFor="AI_MODEL_TAGS_CUSTOM">{t('admin.ai.model')}</Label>
{customTagsModels.length > 0 && (
<Input
placeholder="Rechercher un modèle..."
value={customTagsSearch}
onChange={(e) => setCustomTagsSearch(e.target.value)}
className="mb-1"
/>
)}
<select
id="AI_MODEL_TAGS_CUSTOM"
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"
className={`flex 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 ${customTagsModels.length > 0 ? 'h-auto min-h-[180px]' : 'h-10'}`}
size={customTagsModels.length > 0 ? Math.min(8, customTagsModels.filter(m => m.toLowerCase().includes(customTagsSearch.toLowerCase())).length) : 1}
>
{MODELS_2026.custom.tags.map((model) => (
<option key={model} value={model}>{model}</option>
))}
{customTagsModels.length > 0 ? (
customTagsModels
.filter(m => m.toLowerCase().includes(customTagsSearch.toLowerCase()))
.map((model) => (
<option key={model} value={model}>{model}</option>
))
) : (
selectedTagsModel
? <option value={selectedTagsModel}>{selectedTagsModel}</option>
: <option value="" disabled>Cliquez sur pour récupérer les modèles</option>
)}
</select>
<p className="text-xs text-muted-foreground">{t('admin.ai.commonModelsDescription')}</p>
<p className="text-xs text-muted-foreground">
{isLoadingCustomTagsModels
? 'Récupération des modèles...'
: customTagsModels.length > 0
? `${customTagsModels.length} modèle(s) disponible(s)`
: 'Renseignez l\'URL et cliquez sur ↺ pour charger les modèles'}
</p>
</div>
</div>
)}
@@ -409,7 +497,7 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
const defaultModels: Record<string, string> = {
ollama: '',
openai: MODELS_2026.openai.embeddings[0],
custom: MODELS_2026.custom.embeddings[0],
custom: '',
}
setSelectedEmbeddingModel(defaultModels[newProvider] || '')
}}
@@ -503,7 +591,28 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
<div className="space-y-3">
<div className="space-y-2">
<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 className="flex gap-2">
<Input
id="CUSTOM_OPENAI_BASE_URL_EMBEDDING"
name="CUSTOM_OPENAI_BASE_URL_EMBEDDING"
defaultValue={config.CUSTOM_OPENAI_BASE_URL || ''}
placeholder="https://api.example.com/v1"
/>
<Button
type="button"
size="icon"
variant="outline"
onClick={() => {
const urlInput = document.getElementById('CUSTOM_OPENAI_BASE_URL_EMBEDDING') as HTMLInputElement
const keyInput = document.getElementById('CUSTOM_OPENAI_API_KEY_EMBEDDING') as HTMLInputElement
fetchCustomModels('embeddings', urlInput.value, keyInput.value)
}}
disabled={isLoadingCustomEmbeddingsModels}
title="Récupérer les modèles"
>
<RefreshCw className={`h-4 w-4 ${isLoadingCustomEmbeddingsModels ? 'animate-spin' : ''}`} />
</Button>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="CUSTOM_OPENAI_API_KEY_EMBEDDING">{t('admin.ai.apiKey')}</Label>
@@ -511,18 +620,41 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
</div>
<div className="space-y-2">
<Label htmlFor="AI_MODEL_EMBEDDING_CUSTOM">{t('admin.ai.model')}</Label>
{customEmbeddingsModels.length > 0 && (
<Input
placeholder="Rechercher un modèle..."
value={customEmbeddingsSearch}
onChange={(e) => setCustomEmbeddingsSearch(e.target.value)}
className="mb-1"
/>
)}
<select
id="AI_MODEL_EMBEDDING_CUSTOM"
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"
className={`flex 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 ${customEmbeddingsModels.length > 0 ? 'h-auto min-h-[180px]' : 'h-10'}`}
size={customEmbeddingsModels.length > 0 ? Math.min(8, customEmbeddingsModels.filter(m => m.toLowerCase().includes(customEmbeddingsSearch.toLowerCase())).length) : 1}
>
{MODELS_2026.custom.embeddings.map((model) => (
<option key={model} value={model}>{model}</option>
))}
{customEmbeddingsModels.length > 0 ? (
customEmbeddingsModels
.filter(m => m.toLowerCase().includes(customEmbeddingsSearch.toLowerCase()))
.map((model) => (
<option key={model} value={model}>{model}</option>
))
) : (
selectedEmbeddingModel
? <option value={selectedEmbeddingModel}>{selectedEmbeddingModel}</option>
: <option value="" disabled>Cliquez sur pour récupérer les modèles</option>
)}
</select>
<p className="text-xs text-muted-foreground">{t('admin.ai.commonEmbeddingModels')}</p>
<p className="text-xs text-muted-foreground">
{isLoadingCustomEmbeddingsModels
? 'Récupération des modèles...'
: customEmbeddingsModels.length > 0
? `${customEmbeddingsModels.length} modèle(s) disponible(s)`
: 'Renseignez l\'URL et cliquez sur ↺ pour charger les modèles'}
</p>
</div>
</div>
)}
@@ -602,7 +734,7 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
</label>
</div>
</CardContent>
<CardFooter className="flex justify-between">
<CardFooter className="flex justify-between pt-6">
<Button type="submit" disabled={isSaving}>{t('admin.smtp.saveSettings')}</Button>
<Button type="button" variant="secondary" onClick={handleTestEmail} disabled={isTesting}>
{isTesting ? t('admin.smtp.sending') : t('admin.smtp.testEmail')}