feat: AI provider testing page + multi-provider support + UX design spec

- Add AI Provider Testing page (/admin/ai-test) with Tags and Embeddings tests
- Add new AI providers: CustomOpenAI, DeepSeek, OpenRouter
- Add API routes for AI config, models listing, and testing endpoints
- Add UX Design Specification document for Phase 1 MVP AI
- Add PRD Phase 1 MVP AI planning document
- Update admin settings and sidebar navigation
- Fix AI factory for multi-provider support
This commit is contained in:
2026-01-10 11:23:22 +01:00
parent 640fcb26f7
commit fc2c40249e
21 changed files with 5971 additions and 138 deletions

View File

@@ -4,36 +4,66 @@ 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 { updateSystemConfig, testSMTP } from '@/app/actions/admin-settings'
import { toast } from 'sonner'
import { useState, useEffect } from 'react'
import Link from 'next/link'
import { TestTube, ExternalLink } from 'lucide-react'
type AIProvider = 'ollama' | 'openai' | 'custom'
interface AvailableModels {
tags: string[]
embeddings: string[]
}
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']
},
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> }) {
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')
// AI Provider state - separated for tags and embeddings
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
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')
}, [config])
const handleSaveSecurity = async (formData: FormData) => {
setIsSaving(true)
// We override the formData get because the hidden input might be tricky
const data = {
ALLOW_REGISTRATION: allowRegister ? 'true' : 'false',
}
const result = await updateSystemConfig(data)
setIsSaving(false)
if (result.error) {
toast.error('Failed to update security settings')
} else {
@@ -43,20 +73,45 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
const handleSaveAI = async (formData: FormData) => {
setIsSaving(true)
const data = {
AI_PROVIDER: formData.get('AI_PROVIDER') as string,
OLLAMA_BASE_URL: formData.get('OLLAMA_BASE_URL') as string,
AI_MODEL_EMBEDDING: formData.get('AI_MODEL_EMBEDDING') as string,
OPENAI_API_KEY: formData.get('OPENAI_API_KEY') as string,
const data: Record<string, string> = {}
// Tags provider configuration
const tagsProv = formData.get('AI_PROVIDER_TAGS') as AIProvider
data.AI_PROVIDER_TAGS = tagsProv
data.AI_MODEL_TAGS = formData.get('AI_MODEL_TAGS') as string
if (tagsProv === 'ollama') {
data.OLLAMA_BASE_URL = formData.get('OLLAMA_BASE_URL_TAGS') as string
} else if (tagsProv === 'openai') {
data.OPENAI_API_KEY = formData.get('OPENAI_API_KEY') as string
} else if (tagsProv === 'custom') {
data.CUSTOM_OPENAI_API_KEY = formData.get('CUSTOM_OPENAI_API_KEY_TAGS') as string
data.CUSTOM_OPENAI_BASE_URL = formData.get('CUSTOM_OPENAI_BASE_URL_TAGS') as string
}
// Embeddings provider configuration
const embedProv = formData.get('AI_PROVIDER_EMBEDDING') as AIProvider
data.AI_PROVIDER_EMBEDDING = embedProv
data.AI_MODEL_EMBEDDING = formData.get('AI_MODEL_EMBEDDING') as string
if (embedProv === 'ollama') {
data.OLLAMA_BASE_URL = formData.get('OLLAMA_BASE_URL_EMBEDDING') as string
} else if (embedProv === 'openai') {
data.OPENAI_API_KEY = formData.get('OPENAI_API_KEY') as string
} else if (embedProv === 'custom') {
data.CUSTOM_OPENAI_API_KEY = formData.get('CUSTOM_OPENAI_API_KEY_EMBEDDING') as string
data.CUSTOM_OPENAI_BASE_URL = formData.get('CUSTOM_OPENAI_BASE_URL_EMBEDDING') as string
}
const result = await updateSystemConfig(data)
setIsSaving(false)
if (result.error) {
toast.error('Failed to update AI settings')
} else {
toast.success('AI Settings updated')
setTagsProvider(tagsProv)
setEmbeddingsProvider(embedProv)
}
}
@@ -71,10 +126,10 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
SMTP_IGNORE_CERT: smtpIgnoreCert ? 'true' : 'false',
SMTP_SECURE: smtpSecure ? 'true' : 'false',
}
const result = await updateSystemConfig(data)
setIsSaving(false)
if (result.error) {
toast.error('Failed to update SMTP settings')
} else {
@@ -108,8 +163,8 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
<form action={handleSaveSecurity}>
<CardContent className="space-y-4">
<div className="flex items-center space-x-2">
<Checkbox
id="ALLOW_REGISTRATION"
<Checkbox
id="ALLOW_REGISTRATION"
checked={allowRegister}
onCheckedChange={(c) => setAllowRegister(!!c)}
/>
@@ -133,40 +188,219 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
<Card>
<CardHeader>
<CardTitle>AI Configuration</CardTitle>
<CardDescription>Configure the AI provider for auto-tagging and semantic search.</CardDescription>
<CardDescription>Configure AI providers for auto-tagging and semantic search. Use different providers for optimal performance.</CardDescription>
</CardHeader>
<form action={handleSaveAI}>
<CardContent className="space-y-4">
<div className="space-y-2">
<label htmlFor="AI_PROVIDER" className="text-sm font-medium">Provider</label>
<select
id="AI_PROVIDER"
name="AI_PROVIDER"
defaultValue={config.AI_PROVIDER || 'ollama'}
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)</option>
<option value="openai">OpenAI</option>
</select>
</div>
<div className="space-y-2">
<label htmlFor="OLLAMA_BASE_URL" className="text-sm font-medium">Ollama Base URL</label>
<Input id="OLLAMA_BASE_URL" name="OLLAMA_BASE_URL" defaultValue={config.OLLAMA_BASE_URL || 'http://localhost:11434'} placeholder="http://localhost:11434" />
<CardContent className="space-y-6">
{/* Tags Generation Section */}
<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> Tags Generation Provider
</h3>
<p className="text-xs text-muted-foreground">AI provider for automatic tag suggestions. Recommended: Ollama (free, local).</p>
<div className="space-y-2">
<Label htmlFor="AI_PROVIDER_TAGS">Provider</Label>
<select
id="AI_PROVIDER_TAGS"
name="AI_PROVIDER_TAGS"
value={tagsProvider}
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>
</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" />
</div>
<div className="space-y-2">
<Label htmlFor="AI_MODEL_TAGS_OLLAMA">Model</Label>
<select
id="AI_MODEL_TAGS_OLLAMA"
name="AI_MODEL_TAGS"
defaultValue={config.AI_MODEL_TAGS || 'granite4:latest'}
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>
))}
</select>
<p className="text-xs text-muted-foreground">Select an Ollama model installed on your system</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>
<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-blue-500 hover:underline">platform.openai.com</a></p>
</div>
<div className="space-y-2">
<Label htmlFor="AI_MODEL_TAGS_OPENAI">Model</Label>
<select
id="AI_MODEL_TAGS_OPENAI"
name="AI_MODEL_TAGS"
defaultValue={config.AI_MODEL_TAGS || 'gpt-4o-mini'}
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-blue-600">gpt-4o</strong> = Best quality</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>
<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>
<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>
<select
id="AI_MODEL_TAGS_CUSTOM"
name="AI_MODEL_TAGS"
defaultValue={config.AI_MODEL_TAGS || 'gpt-4o-mini'}
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>
</div>
</div>
)}
</div>
<div className="space-y-2">
<label htmlFor="AI_MODEL_EMBEDDING" className="text-sm font-medium">Embedding Model</label>
<Input id="AI_MODEL_EMBEDDING" name="AI_MODEL_EMBEDDING" defaultValue={config.AI_MODEL_EMBEDDING || 'embeddinggemma:latest'} placeholder="embeddinggemma:latest" />
</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
</h3>
<p className="text-xs text-muted-foreground">AI provider for semantic search embeddings. Recommended: OpenAI (best quality).</p>
<div className="space-y-2">
<label htmlFor="OPENAI_API_KEY" className="text-sm font-medium">OpenAI API Key (if using OpenAI)</label>
<Input id="OPENAI_API_KEY" name="OPENAI_API_KEY" type="password" defaultValue={config.OPENAI_API_KEY || ''} placeholder="sk-..." />
<div className="space-y-2">
<Label htmlFor="AI_PROVIDER_EMBEDDING">Provider</Label>
<select
id="AI_PROVIDER_EMBEDDING"
name="AI_PROVIDER_EMBEDDING"
value={embeddingsProvider}
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>
</select>
</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" />
</div>
<div className="space-y-2">
<Label htmlFor="AI_MODEL_EMBEDDING_OLLAMA">Model</Label>
<select
id="AI_MODEL_EMBEDDING_OLLAMA"
name="AI_MODEL_EMBEDDING"
defaultValue={config.AI_MODEL_EMBEDDING || 'embeddinggemma:latest'}
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>
))}
</select>
<p className="text-xs text-muted-foreground">Select an embedding model installed on your system</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>
<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-blue-500 hover:underline">platform.openai.com</a></p>
</div>
<div className="space-y-2">
<Label htmlFor="AI_MODEL_EMBEDDING_OPENAI">Model</Label>
<select
id="AI_MODEL_EMBEDDING_OPENAI"
name="AI_MODEL_EMBEDDING"
defaultValue={config.AI_MODEL_EMBEDDING || 'text-embedding-3-small'}
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-blue-600">text-embedding-3-large</strong> = Best quality</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>
<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>
<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>
<select
id="AI_MODEL_EMBEDDING_CUSTOM"
name="AI_MODEL_EMBEDDING"
defaultValue={config.AI_MODEL_EMBEDDING || 'text-embedding-3-small'}
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>
</div>
</div>
)}
</div>
</CardContent>
<CardFooter>
<Button type="submit" disabled={isSaving}>Save AI Settings</Button>
<CardFooter className="flex justify-between">
<Button type="submit" disabled={isSaving}>{isSaving ? 'Saving...' : 'Save AI Settings'}</Button>
<Link href="/admin/ai-test">
<Button type="button" variant="outline" className="gap-2">
<TestTube className="h-4 w-4" />
Open AI Test Panel
<ExternalLink className="h-3 w-3" />
</Button>
</Link>
</CardFooter>
</form>
</Card>
@@ -188,12 +422,12 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
<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>
<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>
<Input id="SMTP_PASS" name="SMTP_PASS" type="password" defaultValue={config.SMTP_PASS || ''} />
@@ -205,10 +439,10 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="SMTP_SECURE"
checked={smtpSecure}
onCheckedChange={(c) => setSmtpSecure(!!c)}
<Checkbox
id="SMTP_SECURE"
checked={smtpSecure}
onCheckedChange={(c) => setSmtpSecure(!!c)}
/>
<label
htmlFor="SMTP_SECURE"
@@ -219,8 +453,8 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="SMTP_IGNORE_CERT"
<Checkbox
id="SMTP_IGNORE_CERT"
checked={smtpIgnoreCert}
onCheckedChange={(c) => setSmtpIgnoreCert(!!c)}
/>