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:
@@ -64,14 +64,14 @@ export function AI_TESTER({ type }: { type: 'tags' | 'embeddings' }) {
|
||||
|
||||
if (data.success) {
|
||||
toast.success(
|
||||
`✅ ${type === 'tags' ? 'Tags' : 'Embeddings'} Test Successful!`,
|
||||
`✅ ${t('admin.aiTest.testSuccessToast', { type: type === 'tags' ? 'Tags' : 'Embeddings' })}`,
|
||||
{
|
||||
description: `Provider: ${data.provider} | Time: ${endTime - startTime}ms`
|
||||
}
|
||||
)
|
||||
} else {
|
||||
toast.error(
|
||||
`❌ ${type === 'tags' ? 'Tags' : 'Embeddings'} Test Failed`,
|
||||
`❌ ${t('admin.aiTest.testFailedToast', { type: type === 'tags' ? 'Tags' : 'Embeddings' })}`,
|
||||
{
|
||||
description: data.error || 'Unknown error'
|
||||
}
|
||||
@@ -92,7 +92,7 @@ export function AI_TESTER({ type }: { type: 'tags' | 'embeddings' }) {
|
||||
}
|
||||
|
||||
const getProviderInfo = () => {
|
||||
if (!config) return { provider: 'Loading...', model: 'Loading...' }
|
||||
if (!config) return { provider: t('admin.aiTest.testing'), model: t('admin.aiTest.testing') }
|
||||
|
||||
if (type === 'tags') {
|
||||
return {
|
||||
@@ -232,7 +232,7 @@ export function AI_TESTER({ type }: { type: 'tags' | 'embeddings' }) {
|
||||
{result.details && (
|
||||
<details className="mt-2">
|
||||
<summary className="text-xs cursor-pointer text-red-600 dark:text-red-400">
|
||||
Technical details
|
||||
{t('admin.aiTest.technicalDetails')}
|
||||
</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)}
|
||||
@@ -250,7 +250,7 @@ export function AI_TESTER({ type }: { type: 'tags' | 'embeddings' }) {
|
||||
<div className="text-center py-4">
|
||||
<Loader2 className="h-8 w-8 animate-spin mx-auto text-primary" />
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
{t('admin.aiTest.testing')} {type === 'tags' ? 'tags generation' : 'embeddings'}...
|
||||
{t('admin.aiTest.testingType', { type: type === 'tags' ? 'tags generation' : 'embeddings' })}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
'use client'
|
||||
|
||||
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'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
export default async function AITestPage() {
|
||||
const session = await auth()
|
||||
|
||||
if ((session?.user as any)?.role !== 'ADMIN') {
|
||||
redirect('/')
|
||||
}
|
||||
export default function AITestPage() {
|
||||
const { t } = useLanguage()
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-10 px-4 max-w-6xl">
|
||||
@@ -25,10 +22,10 @@ export default async function AITestPage() {
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold flex items-center gap-2">
|
||||
<TestTube className="h-8 w-8" />
|
||||
AI Provider Testing
|
||||
{t('admin.aiTest.title')}
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Test your AI providers for tag generation and semantic search embeddings
|
||||
{t('admin.aiTest.description')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -40,10 +37,10 @@ export default async function AITestPage() {
|
||||
<CardHeader className="bg-primary/5 dark:bg-primary/10">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<span className="text-2xl">🏷️</span>
|
||||
Tags Generation Test
|
||||
{t('admin.aiTest.tagsTestTitle')}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Test the AI provider responsible for automatic tag suggestions
|
||||
{t('admin.aiTest.tagsTestDescription')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-6">
|
||||
@@ -56,10 +53,10 @@ export default async function AITestPage() {
|
||||
<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
|
||||
{t('admin.aiTest.embeddingsTestTitle')}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Test the AI provider responsible for semantic search embeddings
|
||||
{t('admin.aiTest.embeddingsTestDescription')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-6">
|
||||
@@ -71,32 +68,31 @@ export default async function AITestPage() {
|
||||
{/* Info Section */}
|
||||
<Card className="mt-6">
|
||||
<CardHeader>
|
||||
<CardTitle>ℹ️ How Testing Works</CardTitle>
|
||||
<CardTitle>ℹ️ {t('admin.aiTest.howItWorksTitle')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 text-sm">
|
||||
<div>
|
||||
<h4 className="font-semibold mb-2">🏷️ Tags Generation Test:</h4>
|
||||
<h4 className="font-semibold mb-2">{t('admin.aiTest.tagsGenerationTest')}</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>
|
||||
<li>{t('admin.aiTest.tagsStep1')}</li>
|
||||
<li>{t('admin.aiTest.tagsStep2')}</li>
|
||||
<li>{t('admin.aiTest.tagsStep3')}</li>
|
||||
<li>{t('admin.aiTest.tagsStep4')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold mb-2">🔍 Embeddings Test:</h4>
|
||||
<h4 className="font-semibold mb-2">{t('admin.aiTest.embeddingsTestLabel')}</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>
|
||||
<li>{t('admin.aiTest.embeddingsStep1')}</li>
|
||||
<li>{t('admin.aiTest.embeddingsStep2')}</li>
|
||||
<li>{t('admin.aiTest.embeddingsStep3')}</li>
|
||||
<li>{t('admin.aiTest.embeddingsStep4')}</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="font-semibold text-amber-900 dark:text-amber-100">💡 {t('admin.aiTest.tipTitle')}</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.
|
||||
{t('admin.aiTest.tipContent')}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -66,8 +66,8 @@ export function CreateUserDialog() {
|
||||
name="role"
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<option value="USER">User</option>
|
||||
<option value="ADMIN">Admin</option>
|
||||
<option value="USER">{t('admin.users.roles.user')}</option>
|
||||
<option value="ADMIN">{t('admin.users.roles.admin')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
|
||||
@@ -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')}
|
||||
|
||||
9
keep-notes/app/(main)/reminders/page.tsx
Normal file
9
keep-notes/app/(main)/reminders/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { getNotesWithReminders } from '@/app/actions/notes'
|
||||
import { RemindersPage } from '@/components/reminders-page'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default async function RemindersRoute() {
|
||||
const notes = await getNotesWithReminders()
|
||||
return <RemindersPage notes={notes} />
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { auth } from '@/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { revalidatePath, revalidateTag } from 'next/cache'
|
||||
import { revalidatePath, updateTag } from 'next/cache'
|
||||
|
||||
export type UserAISettingsData = {
|
||||
titleSuggestions?: boolean
|
||||
@@ -48,7 +48,7 @@ export async function updateAISettings(settings: UserAISettingsData) {
|
||||
|
||||
revalidatePath('/settings/ai', 'page')
|
||||
revalidatePath('/', 'layout')
|
||||
revalidateTag('ai-settings')
|
||||
updateTag('ai-settings')
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
|
||||
114
keep-notes/app/actions/custom-provider.ts
Normal file
114
keep-notes/app/actions/custom-provider.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
'use server'
|
||||
|
||||
interface OpenAIModel {
|
||||
id: string
|
||||
object: string
|
||||
created?: number
|
||||
owned_by?: string
|
||||
}
|
||||
|
||||
interface OpenAIModelsResponse {
|
||||
object: string
|
||||
data: OpenAIModel[]
|
||||
}
|
||||
|
||||
async function fetchModelsFromEndpoint(
|
||||
endpoint: string,
|
||||
apiKey?: string
|
||||
): Promise<{ success: boolean; models: string[]; error?: string }> {
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
if (apiKey) {
|
||||
headers['Authorization'] = `Bearer ${apiKey}`
|
||||
}
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
signal: AbortSignal.timeout(8000),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`API returned ${response.status}: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data = await response.json() as OpenAIModelsResponse
|
||||
|
||||
const modelIds = (data.data || [])
|
||||
.map((m) => m.id)
|
||||
.filter(Boolean)
|
||||
.sort()
|
||||
|
||||
return { success: true, models: modelIds }
|
||||
} catch (error: any) {
|
||||
console.error('Failed to fetch provider models:', error)
|
||||
return {
|
||||
success: false,
|
||||
models: [],
|
||||
error: error.message || 'Failed to connect to provider',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all models from a custom OpenAI-compatible provider.
|
||||
* Uses GET /v1/models (standard endpoint).
|
||||
*/
|
||||
export async function getCustomModels(
|
||||
baseUrl: string,
|
||||
apiKey?: string
|
||||
): Promise<{ success: boolean; models: string[]; error?: string }> {
|
||||
if (!baseUrl) {
|
||||
return { success: false, models: [], error: 'Base URL is required' }
|
||||
}
|
||||
|
||||
const cleanUrl = baseUrl.replace(/\/$/, '').replace(/\/v1$/, '')
|
||||
return fetchModelsFromEndpoint(`${cleanUrl}/v1/models`, apiKey)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch embedding-specific models from a custom provider.
|
||||
* Tries GET /v1/embeddings/models first (OpenRouter-specific endpoint that returns
|
||||
* only embedding models). Falls back to GET /v1/models filtered by common
|
||||
* embedding model name patterns if the dedicated endpoint is unavailable.
|
||||
*/
|
||||
export async function getCustomEmbeddingModels(
|
||||
baseUrl: string,
|
||||
apiKey?: string
|
||||
): Promise<{ success: boolean; models: string[]; error?: string }> {
|
||||
if (!baseUrl) {
|
||||
return { success: false, models: [], error: 'Base URL is required' }
|
||||
}
|
||||
|
||||
const cleanUrl = baseUrl.replace(/\/$/, '').replace(/\/v1$/, '')
|
||||
|
||||
// Try the OpenRouter-specific embeddings models endpoint first
|
||||
const embeddingsEndpoint = await fetchModelsFromEndpoint(
|
||||
`${cleanUrl}/v1/embeddings/models`,
|
||||
apiKey
|
||||
)
|
||||
|
||||
if (embeddingsEndpoint.success && embeddingsEndpoint.models.length > 0) {
|
||||
return embeddingsEndpoint
|
||||
}
|
||||
|
||||
// Fallback: fetch all models and filter by common embedding name patterns
|
||||
const allModels = await fetchModelsFromEndpoint(`${cleanUrl}/v1/models`, apiKey)
|
||||
|
||||
if (!allModels.success) {
|
||||
return allModels
|
||||
}
|
||||
|
||||
const embeddingKeywords = ['embed', 'embedding', 'ada', 'e5', 'bge', 'gte', 'minilm']
|
||||
const filtered = allModels.models.filter((id) =>
|
||||
embeddingKeywords.some((kw) => id.toLowerCase().includes(kw))
|
||||
)
|
||||
|
||||
// If the filter finds nothing, return all models so the user can still pick
|
||||
return {
|
||||
success: true,
|
||||
models: filtered.length > 0 ? filtered : allModels.models,
|
||||
}
|
||||
}
|
||||
@@ -5,43 +5,26 @@ import prisma from '@/lib/prisma'
|
||||
import { Note, CheckItem } from '@/lib/types'
|
||||
import { auth } from '@/auth'
|
||||
import { getAIProvider } from '@/lib/ai/factory'
|
||||
import { cosineSimilarity, validateEmbedding, calculateRRFK, detectQueryType, getSearchWeights } from '@/lib/utils'
|
||||
import { parseNote as parseNoteUtil, cosineSimilarity, validateEmbedding, calculateRRFK, detectQueryType, getSearchWeights } from '@/lib/utils'
|
||||
import { getSystemConfig, getConfigNumber, getConfigBoolean, SEARCH_DEFAULTS } from '@/lib/config'
|
||||
import { contextualAutoTagService } from '@/lib/ai/services/contextual-auto-tag.service'
|
||||
|
||||
// Helper function to parse JSON strings from database
|
||||
// Wrapper for parseNote that validates embeddings
|
||||
function parseNote(dbNote: any): Note {
|
||||
// Parse embedding
|
||||
const embedding = dbNote.embedding ? JSON.parse(dbNote.embedding) : null
|
||||
const note = parseNoteUtil(dbNote)
|
||||
|
||||
// Validate embedding if present
|
||||
if (embedding && Array.isArray(embedding)) {
|
||||
const validation = validateEmbedding(embedding)
|
||||
if (note.embedding && Array.isArray(note.embedding)) {
|
||||
const validation = validateEmbedding(note.embedding)
|
||||
if (!validation.valid) {
|
||||
// Don't include invalid embedding in the returned note
|
||||
return {
|
||||
...dbNote,
|
||||
checkItems: dbNote.checkItems ? JSON.parse(dbNote.checkItems) : null,
|
||||
labels: dbNote.labels ? JSON.parse(dbNote.labels) : null,
|
||||
images: dbNote.images ? JSON.parse(dbNote.images) : null,
|
||||
links: dbNote.links ? JSON.parse(dbNote.links) : null,
|
||||
embedding: null, // Exclude invalid embedding
|
||||
sharedWith: dbNote.sharedWith ? JSON.parse(dbNote.sharedWith) : [],
|
||||
size: dbNote.size || 'small',
|
||||
...note,
|
||||
embedding: null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...dbNote,
|
||||
checkItems: dbNote.checkItems ? JSON.parse(dbNote.checkItems) : null,
|
||||
labels: dbNote.labels ? JSON.parse(dbNote.labels) : null,
|
||||
images: dbNote.images ? JSON.parse(dbNote.images) : null,
|
||||
links: dbNote.links ? JSON.parse(dbNote.links) : null,
|
||||
embedding,
|
||||
sharedWith: dbNote.sharedWith ? JSON.parse(dbNote.sharedWith) : [],
|
||||
size: dbNote.size || 'small',
|
||||
}
|
||||
return note
|
||||
}
|
||||
|
||||
// Helper to get hash color for labels (copied from utils)
|
||||
@@ -170,6 +153,46 @@ export async function getNotes(includeArchived = false) {
|
||||
}
|
||||
}
|
||||
|
||||
// Get notes with reminders (upcoming, overdue, done)
|
||||
export async function getNotesWithReminders() {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return [];
|
||||
|
||||
try {
|
||||
const notes = await prisma.note.findMany({
|
||||
where: {
|
||||
userId: session.user.id,
|
||||
isArchived: false,
|
||||
reminder: { not: null }
|
||||
},
|
||||
orderBy: { reminder: 'asc' }
|
||||
})
|
||||
|
||||
return notes.map(parseNote)
|
||||
} catch (error) {
|
||||
console.error('Error fetching notes with reminders:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// Mark a reminder as done / undone
|
||||
export async function toggleReminderDone(noteId: string, done: boolean) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return { error: 'Unauthorized' }
|
||||
|
||||
try {
|
||||
await prisma.note.update({
|
||||
where: { id: noteId, userId: session.user.id },
|
||||
data: { isReminderDone: done }
|
||||
})
|
||||
revalidatePath('/reminders')
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
console.error('Error toggling reminder done:', error)
|
||||
return { error: 'Failed to update reminder' }
|
||||
}
|
||||
}
|
||||
|
||||
// Get archived notes only
|
||||
export async function getArchivedNotes() {
|
||||
const session = await auth();
|
||||
@@ -329,50 +352,7 @@ export async function createNote(data: {
|
||||
if (!session?.user?.id) throw new Error('Unauthorized');
|
||||
|
||||
try {
|
||||
let embeddingString: string | null = null;
|
||||
try {
|
||||
const provider = getAIProvider(await getSystemConfig());
|
||||
const embedding = await provider.getEmbeddings(data.content);
|
||||
if (embedding) embeddingString = JSON.stringify(embedding);
|
||||
} catch (e) {
|
||||
console.error('Embedding generation failed:', e);
|
||||
}
|
||||
|
||||
// AUTO-LABELING: If no labels provided and auto-labeling is enabled, suggest labels
|
||||
let labelsToUse = data.labels || null;
|
||||
if ((!labelsToUse || labelsToUse.length === 0) && data.notebookId) {
|
||||
try {
|
||||
const autoLabelingEnabled = await getConfigBoolean('AUTO_LABELING_ENABLED', true);
|
||||
const autoLabelingConfidence = await getConfigNumber('AUTO_LABELING_CONFIDENCE_THRESHOLD', 70);
|
||||
|
||||
if (autoLabelingEnabled) {
|
||||
|
||||
const suggestions = await contextualAutoTagService.suggestLabels(
|
||||
data.content,
|
||||
data.notebookId,
|
||||
session.user.id
|
||||
);
|
||||
|
||||
// Apply suggestions with confidence >= threshold
|
||||
const appliedLabels = suggestions
|
||||
.filter(s => s.confidence >= autoLabelingConfidence)
|
||||
.map(s => s.label);
|
||||
|
||||
if (appliedLabels.length > 0) {
|
||||
labelsToUse = appliedLabels;
|
||||
|
||||
} else {
|
||||
|
||||
}
|
||||
} else {
|
||||
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[AUTO-LABELING] Failed to suggest labels:', error);
|
||||
// Continue without auto-labeling on error
|
||||
}
|
||||
}
|
||||
|
||||
// Save note to DB immediately (fast!) — AI operations run in background after
|
||||
const note = await prisma.note.create({
|
||||
data: {
|
||||
userId: session.user.id,
|
||||
@@ -381,27 +361,83 @@ export async function createNote(data: {
|
||||
color: data.color || 'default',
|
||||
type: data.type || 'text',
|
||||
checkItems: data.checkItems ? JSON.stringify(data.checkItems) : null,
|
||||
labels: labelsToUse ? JSON.stringify(labelsToUse) : null,
|
||||
labels: data.labels && data.labels.length > 0 ? JSON.stringify(data.labels) : null,
|
||||
images: data.images ? JSON.stringify(data.images) : null,
|
||||
links: data.links ? JSON.stringify(data.links) : null,
|
||||
isArchived: data.isArchived || false,
|
||||
reminder: data.reminder || null,
|
||||
isMarkdown: data.isMarkdown || false,
|
||||
size: data.size || 'small',
|
||||
embedding: embeddingString,
|
||||
embedding: null, // Generated in background
|
||||
sharedWith: data.sharedWith && data.sharedWith.length > 0 ? JSON.stringify(data.sharedWith) : null,
|
||||
autoGenerated: data.autoGenerated || null,
|
||||
notebookId: data.notebookId || null, // Assign note to notebook if provided
|
||||
notebookId: data.notebookId || null,
|
||||
}
|
||||
})
|
||||
|
||||
// Sync labels to ensure Label records exist
|
||||
if (labelsToUse && labelsToUse.length > 0) {
|
||||
await syncLabels(session.user.id, labelsToUse)
|
||||
// Sync user-provided labels immediately
|
||||
if (data.labels && data.labels.length > 0) {
|
||||
await syncLabels(session.user.id, data.labels)
|
||||
}
|
||||
|
||||
// Revalidate main page (handles both inbox and notebook views via query params)
|
||||
revalidatePath('/')
|
||||
|
||||
// Fire-and-forget: run AI operations in background without blocking the response
|
||||
const userId = session.user.id
|
||||
const noteId = note.id
|
||||
const content = data.content
|
||||
const notebookId = data.notebookId
|
||||
const hasUserLabels = data.labels && data.labels.length > 0
|
||||
|
||||
// Use setImmediate-like pattern to not block the response
|
||||
;(async () => {
|
||||
try {
|
||||
// Background task 1: Generate embedding
|
||||
const provider = getAIProvider(await getSystemConfig())
|
||||
const embedding = await provider.getEmbeddings(content)
|
||||
if (embedding) {
|
||||
await prisma.note.update({
|
||||
where: { id: noteId },
|
||||
data: { embedding: JSON.stringify(embedding) }
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[BG] Embedding generation failed:', e)
|
||||
}
|
||||
|
||||
// Background task 2: Auto-labeling (only if no user labels and has notebook)
|
||||
if (!hasUserLabels && notebookId) {
|
||||
try {
|
||||
const autoLabelingEnabled = await getConfigBoolean('AUTO_LABELING_ENABLED', true)
|
||||
const autoLabelingConfidence = await getConfigNumber('AUTO_LABELING_CONFIDENCE_THRESHOLD', 70)
|
||||
|
||||
if (autoLabelingEnabled) {
|
||||
const suggestions = await contextualAutoTagService.suggestLabels(
|
||||
content,
|
||||
notebookId,
|
||||
userId
|
||||
)
|
||||
|
||||
const appliedLabels = suggestions
|
||||
.filter(s => s.confidence >= autoLabelingConfidence)
|
||||
.map(s => s.label)
|
||||
|
||||
if (appliedLabels.length > 0) {
|
||||
await prisma.note.update({
|
||||
where: { id: noteId },
|
||||
data: { labels: JSON.stringify(appliedLabels) }
|
||||
})
|
||||
await syncLabels(userId, appliedLabels)
|
||||
revalidatePath('/')
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[BG] Auto-labeling failed:', error)
|
||||
}
|
||||
}
|
||||
})()
|
||||
|
||||
return parseNote(note)
|
||||
} catch (error) {
|
||||
console.error('Error creating note:', error)
|
||||
@@ -433,23 +469,42 @@ export async function updateNote(id: string, data: {
|
||||
try {
|
||||
const oldNote = await prisma.note.findUnique({
|
||||
where: { id, userId: session.user.id },
|
||||
select: { labels: true, notebookId: true }
|
||||
select: { labels: true, notebookId: true, reminder: true }
|
||||
})
|
||||
const oldLabels: string[] = oldNote?.labels ? JSON.parse(oldNote.labels) : []
|
||||
const oldNotebookId = oldNote?.notebookId
|
||||
|
||||
const updateData: any = { ...data }
|
||||
|
||||
if (data.content !== undefined) {
|
||||
try {
|
||||
const provider = getAIProvider(await getSystemConfig());
|
||||
const embedding = await provider.getEmbeddings(data.content);
|
||||
updateData.embedding = embedding ? JSON.stringify(embedding) : null;
|
||||
} catch (e) {
|
||||
console.error('Embedding regeneration failed:', e);
|
||||
// Reset isReminderDone only when reminder date actually changes (not on every save)
|
||||
if ('reminder' in data && data.reminder !== null) {
|
||||
const newTime = new Date(data.reminder as Date).getTime()
|
||||
const oldTime = oldNote?.reminder ? new Date(oldNote.reminder).getTime() : null
|
||||
if (newTime !== oldTime) {
|
||||
updateData.isReminderDone = false
|
||||
}
|
||||
}
|
||||
|
||||
// Generate embedding in background — don't block the update
|
||||
if (data.content !== undefined) {
|
||||
const noteId = id
|
||||
const content = data.content
|
||||
;(async () => {
|
||||
try {
|
||||
const provider = getAIProvider(await getSystemConfig());
|
||||
const embedding = await provider.getEmbeddings(content);
|
||||
if (embedding) {
|
||||
await prisma.note.update({
|
||||
where: { id: noteId },
|
||||
data: { embedding: JSON.stringify(embedding) }
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[BG] Embedding regeneration failed:', e);
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
if ('checkItems' in data) updateData.checkItems = data.checkItems ? JSON.stringify(data.checkItems) : null
|
||||
if ('labels' in data) updateData.labels = data.labels ? JSON.stringify(data.labels) : null
|
||||
if ('images' in data) updateData.images = data.images ? JSON.stringify(data.images) : null
|
||||
@@ -766,13 +821,25 @@ export async function getAllNotes(includeArchived = false) {
|
||||
}
|
||||
})
|
||||
|
||||
// Filter out archived shared notes if needed
|
||||
const sharedNotes = acceptedShares
|
||||
.map(share => share.note)
|
||||
.filter(note => includeArchived || !note.isArchived)
|
||||
|
||||
const allNotes = [...ownNotes.map(parseNote), ...sharedNotes.map(parseNote)]
|
||||
|
||||
// Derive pinned and recent notes
|
||||
const pinned = allNotes.filter((note: Note) => note.isPinned)
|
||||
const sevenDaysAgo = new Date()
|
||||
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7)
|
||||
sevenDaysAgo.setHours(0, 0, 0, 0)
|
||||
|
||||
const recent = allNotes
|
||||
.filter((note: Note) => {
|
||||
return !note.isArchived && !note.dismissedFromRecent && note.contentUpdatedAt >= sevenDaysAgo
|
||||
})
|
||||
.sort((a, b) => new Date(b.contentUpdatedAt).getTime() - new Date(a.createdAt).getTime())
|
||||
.slice(0, 3)
|
||||
|
||||
return allNotes
|
||||
} catch (error) {
|
||||
console.error('Error fetching notes:', error)
|
||||
@@ -808,6 +875,7 @@ export async function getPinnedNotes(notebookId?: string) {
|
||||
}
|
||||
}
|
||||
|
||||
// Get recent notes (notes modified in the last 7 days)
|
||||
// Get recent notes (notes modified in the last 7 days)
|
||||
export async function getRecentNotes(limit: number = 3) {
|
||||
const session = await auth();
|
||||
@@ -824,7 +892,8 @@ export async function getRecentNotes(limit: number = 3) {
|
||||
where: {
|
||||
userId: userId,
|
||||
contentUpdatedAt: { gte: sevenDaysAgo },
|
||||
isArchived: false
|
||||
isArchived: false,
|
||||
dismissedFromRecent: false // Filter out dismissed notes
|
||||
},
|
||||
orderBy: { contentUpdatedAt: 'desc' },
|
||||
take: limit
|
||||
@@ -837,6 +906,25 @@ export async function getRecentNotes(limit: number = 3) {
|
||||
}
|
||||
}
|
||||
|
||||
// Dismiss a note from Recent section
|
||||
export async function dismissFromRecent(id: string) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) throw new Error('Unauthorized');
|
||||
|
||||
try {
|
||||
await prisma.note.update({
|
||||
where: { id, userId: session.user.id },
|
||||
data: { dismissedFromRecent: true }
|
||||
})
|
||||
|
||||
// revalidatePath('/') // Removed to prevent immediate refill of the list
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
console.error('Error dismissing note from recent:', error)
|
||||
throw new Error('Failed to dismiss note')
|
||||
}
|
||||
}
|
||||
|
||||
export async function getNoteById(noteId: string) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return null;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { auth } from '@/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { revalidatePath, revalidateTag } from 'next/cache'
|
||||
import { revalidatePath, updateTag } from 'next/cache'
|
||||
|
||||
export type UserSettingsData = {
|
||||
theme?: 'light' | 'dark' | 'auto' | 'sepia' | 'midnight' | 'blue'
|
||||
@@ -28,7 +28,7 @@ export async function updateUserSettings(settings: UserSettingsData) {
|
||||
|
||||
|
||||
revalidatePath('/', 'layout')
|
||||
revalidateTag('user-settings')
|
||||
updateTag('user-settings')
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
|
||||
@@ -49,6 +49,7 @@ export async function GET(
|
||||
data: label
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error fetching label:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to fetch label' },
|
||||
{ status: 500 }
|
||||
@@ -148,6 +149,7 @@ export async function PUT(
|
||||
data: label
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error updating label:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to update label' },
|
||||
{ status: 500 }
|
||||
@@ -239,6 +241,7 @@ export async function DELETE(
|
||||
message: `Label "${label.name}" deleted successfully`
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error deleting label:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to delete label' },
|
||||
{ status: 500 }
|
||||
|
||||
@@ -47,6 +47,7 @@ export async function GET(request: NextRequest) {
|
||||
data: labels
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error fetching labels:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to fetch labels' },
|
||||
{ status: 500 }
|
||||
@@ -121,6 +122,7 @@ export async function POST(request: NextRequest) {
|
||||
data: label
|
||||
}, { status: 201 })
|
||||
} catch (error) {
|
||||
console.error('Error creating label:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to create label' },
|
||||
{ status: 500 }
|
||||
|
||||
@@ -65,6 +65,7 @@ export async function PATCH(
|
||||
notesCount: notebook._count.notes
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error updating notebook:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to update notebook' },
|
||||
{ status: 500 }
|
||||
@@ -125,6 +126,7 @@ export async function DELETE(
|
||||
labelsCount: notebook._count.labels
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error deleting notebook:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to delete notebook' },
|
||||
{ status: 500 }
|
||||
|
||||
@@ -35,6 +35,7 @@ export async function GET(request: NextRequest) {
|
||||
}))
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error fetching notebooks:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to fetch notebooks' },
|
||||
{ status: 500 }
|
||||
@@ -94,6 +95,7 @@ export async function POST(request: NextRequest) {
|
||||
notesCount: notebook._count.notes
|
||||
}, { status: 201 })
|
||||
} catch (error) {
|
||||
console.error('Error creating notebook:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to create notebook' },
|
||||
{ status: 500 }
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import prisma from '@/lib/prisma'
|
||||
|
||||
// Helper to parse JSON fields
|
||||
function parseNote(dbNote: any) {
|
||||
return {
|
||||
...dbNote,
|
||||
checkItems: dbNote.checkItems ? JSON.parse(dbNote.checkItems) : null,
|
||||
labels: dbNote.labels ? JSON.parse(dbNote.labels) : null,
|
||||
}
|
||||
}
|
||||
import { auth } from '@/auth'
|
||||
import { parseNote } from '@/lib/utils'
|
||||
|
||||
// GET /api/notes/[id] - Get a single note
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const { id } = await params
|
||||
const note = await prisma.note.findUnique({
|
||||
@@ -28,11 +29,19 @@ export async function GET(
|
||||
)
|
||||
}
|
||||
|
||||
if (note.userId !== session.user.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Forbidden' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: parseNote(note)
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error fetching note:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to fetch note' },
|
||||
{ status: 500 }
|
||||
@@ -45,12 +54,38 @@ export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const { id } = await params
|
||||
|
||||
const existingNote = await prisma.note.findUnique({
|
||||
where: { id }
|
||||
})
|
||||
|
||||
if (!existingNote) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Note not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
if (existingNote.userId !== session.user.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Forbidden' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const updateData: any = { ...body }
|
||||
|
||||
// Stringify JSON fields if they exist
|
||||
if ('checkItems' in body) {
|
||||
updateData.checkItems = body.checkItems ? JSON.stringify(body.checkItems) : null
|
||||
}
|
||||
@@ -69,6 +104,7 @@ export async function PUT(
|
||||
data: parseNote(note)
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error updating note:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to update note' },
|
||||
{ status: 500 }
|
||||
@@ -81,8 +117,35 @@ export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const { id } = await params
|
||||
|
||||
const existingNote = await prisma.note.findUnique({
|
||||
where: { id }
|
||||
})
|
||||
|
||||
if (!existingNote) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Note not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
if (existingNote.userId !== session.user.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Forbidden' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
await prisma.note.delete({
|
||||
where: { id }
|
||||
})
|
||||
@@ -92,6 +155,7 @@ export async function DELETE(
|
||||
message: 'Note deleted successfully'
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error deleting note:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to delete note' },
|
||||
{ status: 500 }
|
||||
|
||||
@@ -19,7 +19,7 @@ export async function GET(req: NextRequest) {
|
||||
userId: session.user.id
|
||||
},
|
||||
include: {
|
||||
labels: {
|
||||
labelRelations: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true
|
||||
@@ -84,7 +84,6 @@ export async function GET(req: NextRequest) {
|
||||
notebooks: notebooks.map(notebook => ({
|
||||
id: notebook.id,
|
||||
name: notebook.name,
|
||||
description: notebook.description,
|
||||
noteCount: notebook.notes.length
|
||||
})),
|
||||
notes: notes.map(note => ({
|
||||
@@ -95,7 +94,7 @@ export async function GET(req: NextRequest) {
|
||||
updatedAt: note.updatedAt,
|
||||
isPinned: note.isPinned,
|
||||
notebookId: note.notebookId,
|
||||
labels: note.labels.map(label => ({
|
||||
labelRelations: note.labelRelations.map(label => ({
|
||||
id: label.id,
|
||||
name: label.name
|
||||
}))
|
||||
|
||||
@@ -92,8 +92,7 @@ export async function POST(req: NextRequest) {
|
||||
data: {
|
||||
userId: session.user.id,
|
||||
name: notebook.name,
|
||||
description: notebook.description || null,
|
||||
position: 0
|
||||
order: 0
|
||||
}
|
||||
})
|
||||
newNotebookId = created.id
|
||||
@@ -129,7 +128,7 @@ export async function POST(req: NextRequest) {
|
||||
content: note.content,
|
||||
isPinned: note.isPinned || false,
|
||||
notebookId: mappedNotebookId,
|
||||
labels: {
|
||||
labelRelations: {
|
||||
connect: labels.map(label => ({ id: label.id }))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +1,26 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { CheckItem } from '@/lib/types'
|
||||
|
||||
// Helper to parse JSON fields
|
||||
function parseNote(dbNote: any) {
|
||||
return {
|
||||
...dbNote,
|
||||
checkItems: dbNote.checkItems ? JSON.parse(dbNote.checkItems) : null,
|
||||
labels: dbNote.labels ? JSON.parse(dbNote.labels) : null,
|
||||
images: dbNote.images ? JSON.parse(dbNote.images) : null,
|
||||
}
|
||||
}
|
||||
import { auth } from '@/auth'
|
||||
import { parseNote } from '@/lib/utils'
|
||||
|
||||
// GET /api/notes - Get all notes
|
||||
export async function GET(request: NextRequest) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
const includeArchived = searchParams.get('archived') === 'true'
|
||||
const search = searchParams.get('search')
|
||||
|
||||
let where: any = {}
|
||||
let where: any = {
|
||||
userId: session.user.id
|
||||
}
|
||||
|
||||
if (!includeArchived) {
|
||||
where.isArchived = false
|
||||
@@ -46,6 +47,7 @@ export async function GET(request: NextRequest) {
|
||||
data: notes.map(parseNote)
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error fetching notes:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to fetch notes' },
|
||||
{ status: 500 }
|
||||
@@ -55,6 +57,14 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
// POST /api/notes - Create a new note
|
||||
export async function POST(request: NextRequest) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { title, content, color, type, checkItems, labels, images } = body
|
||||
@@ -68,6 +78,7 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
const note = await prisma.note.create({
|
||||
data: {
|
||||
userId: session.user.id,
|
||||
title: title || null,
|
||||
content: content || '',
|
||||
color: color || 'default',
|
||||
@@ -83,6 +94,7 @@ export async function POST(request: NextRequest) {
|
||||
data: parseNote(note)
|
||||
}, { status: 201 })
|
||||
} catch (error) {
|
||||
console.error('Error creating note:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to create note' },
|
||||
{ status: 500 }
|
||||
@@ -92,6 +104,14 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
// PUT /api/notes - Update an existing note
|
||||
export async function PUT(request: NextRequest) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { id, title, content, color, type, checkItems, labels, isPinned, isArchived, images } = body
|
||||
@@ -103,6 +123,24 @@ export async function PUT(request: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
const existingNote = await prisma.note.findUnique({
|
||||
where: { id }
|
||||
})
|
||||
|
||||
if (!existingNote) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Note not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
if (existingNote.userId !== session.user.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Forbidden' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const updateData: any = {}
|
||||
|
||||
if (title !== undefined) updateData.title = title
|
||||
@@ -125,6 +163,7 @@ export async function PUT(request: NextRequest) {
|
||||
data: parseNote(note)
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error updating note:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to update note' },
|
||||
{ status: 500 }
|
||||
@@ -134,6 +173,14 @@ export async function PUT(request: NextRequest) {
|
||||
|
||||
// DELETE /api/notes?id=xxx - Delete a note
|
||||
export async function DELETE(request: NextRequest) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
const id = searchParams.get('id')
|
||||
@@ -145,6 +192,24 @@ export async function DELETE(request: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
const existingNote = await prisma.note.findUnique({
|
||||
where: { id }
|
||||
})
|
||||
|
||||
if (!existingNote) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Note not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
if (existingNote.userId !== session.user.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Forbidden' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
await prisma.note.delete({
|
||||
where: { id }
|
||||
})
|
||||
@@ -154,6 +219,7 @@ export async function DELETE(request: NextRequest) {
|
||||
message: 'Note deleted successfully'
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error deleting note:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to delete note' },
|
||||
{ status: 500 }
|
||||
|
||||
@@ -3,6 +3,11 @@ import { Inter } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { Toaster } from "@/components/ui/toast";
|
||||
import { SessionProviderWrapper } from "@/components/session-provider-wrapper";
|
||||
import { getAISettings } from "@/app/actions/ai-settings";
|
||||
import { getUserSettings } from "@/app/actions/user-settings";
|
||||
import { ThemeInitializer } from "@/components/theme-initializer";
|
||||
import { getThemeScript } from "@/lib/theme-script";
|
||||
import { auth } from "@/auth";
|
||||
|
||||
const inter = Inter({
|
||||
subsets: ["latin"],
|
||||
@@ -27,24 +32,6 @@ export const viewport: Viewport = {
|
||||
themeColor: "#f59e0b",
|
||||
};
|
||||
|
||||
|
||||
|
||||
import { getAISettings } from "@/app/actions/ai-settings";
|
||||
import { getUserSettings } from "@/app/actions/user-settings";
|
||||
import { ThemeInitializer } from "@/components/theme-initializer";
|
||||
|
||||
// ... existing imports
|
||||
|
||||
import { DebugTheme } from "@/components/debug-theme";
|
||||
|
||||
// ...
|
||||
|
||||
import { getThemeScript } from "@/lib/theme-script";
|
||||
|
||||
// ...
|
||||
|
||||
import { auth } from "@/auth";
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
|
||||
Reference in New Issue
Block a user