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

@@ -0,0 +1,258 @@
'use client'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { useState, useEffect } from 'react'
import { toast } from 'sonner'
import { Loader2, CheckCircle2, XCircle, Clock, Zap, Info } from 'lucide-react'
interface TestResult {
success: boolean
provider?: string
model?: string
responseTime?: number
tags?: Array<{ tag: string; confidence: number }>
embeddingLength?: number
firstValues?: number[]
error?: string
details?: any
}
export function AI_TESTER({ type }: { type: 'tags' | 'embeddings' }) {
const [isLoading, setIsLoading] = useState(false)
const [result, setResult] = useState<TestResult | null>(null)
const [config, setConfig] = useState<any>(null)
useEffect(() => {
fetchConfig()
}, [])
const fetchConfig = async () => {
try {
const response = await fetch('/api/ai/config')
const data = await response.json()
setConfig(data)
// Set previous result if available
if (data.previousTest) {
setResult(data.previousTest[type] || null)
}
} catch (error) {
console.error('Failed to fetch config:', error)
}
}
const runTest = async () => {
setIsLoading(true)
const startTime = Date.now()
try {
const response = await fetch(`/api/ai/test-${type}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
})
const endTime = Date.now()
const data = await response.json()
setResult({
...data,
responseTime: endTime - startTime
})
if (data.success) {
toast.success(
`${type === 'tags' ? 'Tags' : 'Embeddings'} Test Successful!`,
{
description: `Provider: ${data.provider} | Time: ${endTime - startTime}ms`
}
)
} else {
toast.error(
`${type === 'tags' ? 'Tags' : 'Embeddings'} Test Failed`,
{
description: data.error || 'Unknown error'
}
)
}
} catch (error: any) {
const endTime = Date.now()
const errorResult = {
success: false,
error: error.message || 'Network error',
responseTime: endTime - startTime
}
setResult(errorResult)
toast.error(`❌ Test Error: ${error.message}`)
} finally {
setIsLoading(false)
}
}
const getProviderInfo = () => {
if (!config) return { provider: 'Loading...', model: 'Loading...' }
if (type === 'tags') {
return {
provider: config.AI_PROVIDER_TAGS || 'ollama',
model: config.AI_MODEL_TAGS || 'granite4:latest'
}
} else {
return {
provider: config.AI_PROVIDER_EMBEDDING || 'ollama',
model: config.AI_MODEL_EMBEDDING || 'embeddinggemma:latest'
}
}
}
const providerInfo = getProviderInfo()
return (
<div className="space-y-4">
{/* Provider Info */}
<div className="space-y-3 p-4 bg-muted/50 rounded-lg">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Provider:</span>
<Badge variant="outline" className="text-xs">
{providerInfo.provider.toUpperCase()}
</Badge>
</div>
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Model:</span>
<span className="text-sm text-muted-foreground font-mono">
{providerInfo.model}
</span>
</div>
</div>
{/* Test Button */}
<Button
onClick={runTest}
disabled={isLoading}
className="w-full"
variant={result?.success ? 'default' : result?.success === false ? 'destructive' : 'default'}
>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Testing...
</>
) : (
<>
<Zap className="mr-2 h-4 w-4" />
Run Test
</>
)}
</Button>
{/* Results */}
{result && (
<Card className={result.success ? 'border-green-200 dark:border-green-900' : 'border-red-200 dark:border-red-900'}>
<CardContent className="pt-6">
{/* Status */}
<div className="flex items-center gap-2 mb-4">
{result.success ? (
<>
<CheckCircle2 className="h-5 w-5 text-green-600" />
<span className="font-semibold text-green-600 dark:text-green-400">Test Passed</span>
</>
) : (
<>
<XCircle className="h-5 w-5 text-red-600" />
<span className="font-semibold text-red-600 dark:text-red-400">Test Failed</span>
</>
)}
</div>
{/* Response Time */}
{result.responseTime && (
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-4">
<Clock className="h-4 w-4" />
<span>Response time: {result.responseTime}ms</span>
</div>
)}
{/* Tags Results */}
{type === 'tags' && result.success && result.tags && (
<div className="space-y-3">
<div className="flex items-center gap-2">
<Info className="h-4 w-4 text-blue-600" />
<span className="text-sm font-medium">Generated Tags:</span>
</div>
<div className="flex flex-wrap gap-2">
{result.tags.map((tag, idx) => (
<Badge
key={idx}
variant="secondary"
className="text-sm"
>
{tag.tag}
<span className="ml-1 text-xs opacity-70">
({Math.round(tag.confidence * 100)}%)
</span>
</Badge>
))}
</div>
</div>
)}
{/* Embeddings Results */}
{type === 'embeddings' && result.success && result.embeddingLength && (
<div className="space-y-3">
<div className="flex items-center gap-2">
<Info className="h-4 w-4 text-green-600" />
<span className="text-sm font-medium">Embedding Dimensions:</span>
</div>
<div className="p-3 bg-muted rounded-lg">
<div className="text-2xl font-bold text-center">
{result.embeddingLength}
</div>
<div className="text-xs text-center text-muted-foreground mt-1">
vector dimensions
</div>
</div>
{result.firstValues && result.firstValues.length > 0 && (
<div className="space-y-1">
<span className="text-xs font-medium">First 5 values:</span>
<div className="p-2 bg-muted rounded font-mono text-xs">
[{result.firstValues.slice(0, 5).map((v, i) => v.toFixed(4)).join(', ')}]
</div>
</div>
)}
</div>
)}
{/* Error Details */}
{!result.success && result.error && (
<div className="mt-4 p-3 bg-red-50 dark:bg-red-950/20 rounded-lg border border-red-200 dark:border-red-900">
<p className="text-sm font-medium text-red-900 dark:text-red-100">Error:</p>
<p className="text-sm text-red-700 dark:text-red-300 mt-1">{result.error}</p>
{result.details && (
<details className="mt-2">
<summary className="text-xs cursor-pointer text-red-600 dark:text-red-400">
Technical details
</summary>
<pre className="mt-2 text-xs overflow-auto p-2 bg-red-100 dark:bg-red-900/30 rounded">
{JSON.stringify(result.details, null, 2)}
</pre>
</details>
)}
</div>
)}
</CardContent>
</Card>
)}
{/* Loading State */}
{isLoading && (
<div className="text-center py-4">
<Loader2 className="h-8 w-8 animate-spin mx-auto text-blue-600" />
<p className="text-sm text-muted-foreground mt-2">
Testing {type === 'tags' ? 'tags generation' : 'embeddings'}...
</p>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,106 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { auth } from '@/auth'
import { redirect } from 'next/navigation'
import Link from 'next/link'
import { ArrowLeft, TestTube } from 'lucide-react'
import { AI_TESTER } from './ai-tester'
export default async function AITestPage() {
const session = await auth()
if ((session?.user as any)?.role !== 'ADMIN') {
redirect('/')
}
return (
<div className="container mx-auto py-10 px-4 max-w-6xl">
<div className="flex justify-between items-center mb-8">
<div className="flex items-center gap-3">
<Link href="/admin/settings">
<Button variant="outline" size="icon">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<h1 className="text-3xl font-bold flex items-center gap-2">
<TestTube className="h-8 w-8" />
AI Provider Testing
</h1>
<p className="text-muted-foreground mt-1">
Test your AI providers for tag generation and semantic search embeddings
</p>
</div>
</div>
</div>
<div className="grid gap-6 md:grid-cols-2">
{/* Tags Provider Test */}
<Card className="border-blue-200 dark:border-blue-900">
<CardHeader className="bg-blue-50/50 dark:bg-blue-950/20">
<CardTitle className="flex items-center gap-2">
<span className="text-2xl">🏷</span>
Tags Generation Test
</CardTitle>
<CardDescription>
Test the AI provider responsible for automatic tag suggestions
</CardDescription>
</CardHeader>
<CardContent className="pt-6">
<AI_TESTER type="tags" />
</CardContent>
</Card>
{/* Embeddings Provider Test */}
<Card className="border-green-200 dark:border-green-900">
<CardHeader className="bg-green-50/50 dark:bg-green-950/20">
<CardTitle className="flex items-center gap-2">
<span className="text-2xl">🔍</span>
Embeddings Test
</CardTitle>
<CardDescription>
Test the AI provider responsible for semantic search embeddings
</CardDescription>
</CardHeader>
<CardContent className="pt-6">
<AI_TESTER type="embeddings" />
</CardContent>
</Card>
</div>
{/* Info Section */}
<Card className="mt-6">
<CardHeader>
<CardTitle> How Testing Works</CardTitle>
</CardHeader>
<CardContent className="space-y-4 text-sm">
<div>
<h4 className="font-semibold mb-2">🏷 Tags Generation Test:</h4>
<ul className="list-disc list-inside space-y-1 text-muted-foreground">
<li>Sends a sample note to the AI provider</li>
<li>Requests 3-5 relevant tags based on the content</li>
<li>Displays the generated tags with confidence scores</li>
<li>Measures response time</li>
</ul>
</div>
<div>
<h4 className="font-semibold mb-2">🔍 Embeddings Test:</h4>
<ul className="list-disc list-inside space-y-1 text-muted-foreground">
<li>Sends a sample text to the embedding provider</li>
<li>Generates a vector representation (list of numbers)</li>
<li>Displays embedding dimensions and sample values</li>
<li>Verifies the vector is valid and properly formatted</li>
</ul>
</div>
<div className="bg-amber-50 dark:bg-amber-950/20 p-4 rounded-lg border border-amber-200 dark:border-amber-900">
<p className="font-semibold text-amber-900 dark:text-amber-100">💡 Tip:</p>
<p className="text-amber-800 dark:text-amber-200 mt-1">
You can use different providers for tags and embeddings! For example, use Ollama (free) for tags
and OpenAI (best quality) for embeddings to optimize costs and performance.
</p>
</div>
</CardContent>
</Card>
</div>
)
}

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)}
/>

View File

@@ -10,11 +10,11 @@ export default async function MainLayout({
const session = await auth();
return (
<div className="flex min-h-screen flex-col">
<div className="flex h-screen flex-col">
<HeaderWrapper user={session?.user} />
<div className="flex flex-1">
<Sidebar className="shrink-0 border-r" user={session?.user} />
<main className="flex-1">
<div className="flex flex-1 overflow-hidden">
<Sidebar className="shrink-0 border-r overflow-y-auto" user={session?.user} />
<main className="flex-1 overflow-y-auto">
{children}
</main>
</div>