refactor(ux): consolidate BMAD skills, update design system, and clean up Prisma generated client
This commit is contained in:
@@ -15,7 +15,7 @@ export default async function AdminLayout({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-gray-50 dark:bg-zinc-950">
|
||||
<div className="flex h-full bg-gray-50 dark:bg-zinc-950">
|
||||
<AdminSidebar />
|
||||
<AdminContentArea>{children}</AdminContentArea>
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,8 @@ 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 { Combobox } from '@/components/ui/combobox'
|
||||
import { updateSystemConfig, testEmail } from '@/app/actions/admin-settings'
|
||||
import { getOllamaModels } from '@/app/actions/ollama'
|
||||
import { getCustomModels, getCustomEmbeddingModels } from '@/app/actions/custom-provider'
|
||||
import { toast } from 'sonner'
|
||||
@@ -38,43 +39,55 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
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
|
||||
// Agent tools state
|
||||
const [webSearchProvider, setWebSearchProvider] = useState(config.WEB_SEARCH_PROVIDER || 'searxng')
|
||||
|
||||
// Email provider state
|
||||
const [emailProvider, setEmailProvider] = useState<'resend' | 'smtp'>(config.EMAIL_PROVIDER as 'resend' | 'smtp' || (config.RESEND_API_KEY ? 'resend' : 'smtp'))
|
||||
const [emailTestResult, setEmailTestResult] = useState<{ provider: 'resend' | 'smtp'; success: boolean; message?: string } | null>(null)
|
||||
|
||||
// AI Provider state - separated for tags, embeddings, and chat
|
||||
const [tagsProvider, setTagsProvider] = useState<AIProvider>((config.AI_PROVIDER_TAGS as AIProvider) || 'ollama')
|
||||
const [embeddingsProvider, setEmbeddingsProvider] = useState<AIProvider>((config.AI_PROVIDER_EMBEDDING as AIProvider) || 'ollama')
|
||||
const [chatProvider, setChatProvider] = useState<AIProvider>((config.AI_PROVIDER_CHAT as AIProvider) || 'ollama')
|
||||
|
||||
// Selected Models State (Controlled Inputs)
|
||||
const [selectedTagsModel, setSelectedTagsModel] = useState<string>(config.AI_MODEL_TAGS || '')
|
||||
const [selectedEmbeddingModel, setSelectedEmbeddingModel] = useState<string>(config.AI_MODEL_EMBEDDING || '')
|
||||
const [selectedChatModel, setSelectedChatModel] = useState<string>(config.AI_MODEL_CHAT || '')
|
||||
|
||||
|
||||
// Dynamic Models State
|
||||
const [ollamaTagsModels, setOllamaTagsModels] = useState<string[]>([])
|
||||
const [ollamaEmbeddingsModels, setOllamaEmbeddingsModels] = useState<string[]>([])
|
||||
const [ollamaChatModels, setOllamaChatModels] = useState<string[]>([])
|
||||
const [isLoadingTagsModels, setIsLoadingTagsModels] = useState(false)
|
||||
const [isLoadingEmbeddingsModels, setIsLoadingEmbeddingsModels] = useState(false)
|
||||
const [isLoadingChatModels, setIsLoadingChatModels] = useState(false)
|
||||
|
||||
// Custom provider dynamic models
|
||||
const [customTagsModels, setCustomTagsModels] = useState<string[]>([])
|
||||
const [customEmbeddingsModels, setCustomEmbeddingsModels] = useState<string[]>([])
|
||||
const [customChatModels, setCustomChatModels] = useState<string[]>([])
|
||||
const [isLoadingCustomTagsModels, setIsLoadingCustomTagsModels] = useState(false)
|
||||
const [isLoadingCustomEmbeddingsModels, setIsLoadingCustomEmbeddingsModels] = useState(false)
|
||||
const [customTagsSearch, setCustomTagsSearch] = useState('')
|
||||
const [customEmbeddingsSearch, setCustomEmbeddingsSearch] = useState('')
|
||||
|
||||
const [isLoadingCustomChatModels, setIsLoadingCustomChatModels] = useState(false)
|
||||
|
||||
// Fetch Ollama models
|
||||
const fetchOllamaModels = useCallback(async (type: 'tags' | 'embeddings', url: string) => {
|
||||
const fetchOllamaModels = useCallback(async (type: 'tags' | 'embeddings' | 'chat', url: string) => {
|
||||
if (!url) return
|
||||
|
||||
if (type === 'tags') setIsLoadingTagsModels(true)
|
||||
else setIsLoadingEmbeddingsModels(true)
|
||||
else if (type === 'embeddings') setIsLoadingEmbeddingsModels(true)
|
||||
else setIsLoadingChatModels(true)
|
||||
|
||||
try {
|
||||
const result = await getOllamaModels(url)
|
||||
|
||||
if (result.success) {
|
||||
if (type === 'tags') setOllamaTagsModels(result.models)
|
||||
else setOllamaEmbeddingsModels(result.models)
|
||||
else if (type === 'embeddings') setOllamaEmbeddingsModels(result.models)
|
||||
else setOllamaChatModels(result.models)
|
||||
} else {
|
||||
toast.error(`Failed to fetch Ollama models: ${result.error}`)
|
||||
}
|
||||
@@ -83,16 +96,18 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
toast.error('Failed to fetch Ollama models')
|
||||
} finally {
|
||||
if (type === 'tags') setIsLoadingTagsModels(false)
|
||||
else setIsLoadingEmbeddingsModels(false)
|
||||
else if (type === 'embeddings') setIsLoadingEmbeddingsModels(false)
|
||||
else setIsLoadingChatModels(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Fetch Custom provider models — tags use /v1/models, embeddings use /v1/embeddings/models
|
||||
const fetchCustomModels = useCallback(async (type: 'tags' | 'embeddings', url: string, apiKey?: string) => {
|
||||
const fetchCustomModels = useCallback(async (type: 'tags' | 'embeddings' | 'chat', url: string, apiKey?: string) => {
|
||||
if (!url) return
|
||||
|
||||
if (type === 'tags') setIsLoadingCustomTagsModels(true)
|
||||
else setIsLoadingCustomEmbeddingsModels(true)
|
||||
else if (type === 'embeddings') setIsLoadingCustomEmbeddingsModels(true)
|
||||
else setIsLoadingCustomChatModels(true)
|
||||
|
||||
try {
|
||||
const result = type === 'embeddings'
|
||||
@@ -101,7 +116,8 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
|
||||
if (result.success && result.models.length > 0) {
|
||||
if (type === 'tags') setCustomTagsModels(result.models)
|
||||
else setCustomEmbeddingsModels(result.models)
|
||||
else if (type === 'embeddings') setCustomEmbeddingsModels(result.models)
|
||||
else setCustomChatModels(result.models)
|
||||
} else {
|
||||
toast.error(`Impossible de récupérer les modèles : ${result.error}`)
|
||||
}
|
||||
@@ -110,7 +126,8 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
toast.error('Erreur lors de la récupération des modèles')
|
||||
} finally {
|
||||
if (type === 'tags') setIsLoadingCustomTagsModels(false)
|
||||
else setIsLoadingCustomEmbeddingsModels(false)
|
||||
else if (type === 'embeddings') setIsLoadingCustomEmbeddingsModels(false)
|
||||
else setIsLoadingCustomChatModels(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
@@ -144,6 +161,18 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [tagsProvider])
|
||||
|
||||
useEffect(() => {
|
||||
if (chatProvider === 'ollama') {
|
||||
const url = config.OLLAMA_BASE_URL_CHAT || config.OLLAMA_BASE_URL || 'http://localhost:11434'
|
||||
fetchOllamaModels('chat', url)
|
||||
} else if (chatProvider === 'custom') {
|
||||
const url = config.CUSTOM_OPENAI_BASE_URL || ''
|
||||
const key = config.CUSTOM_OPENAI_API_KEY || ''
|
||||
if (url) fetchCustomModels('chat', url, key)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [chatProvider])
|
||||
|
||||
const handleSaveSecurity = async (formData: FormData) => {
|
||||
setIsSaving(true)
|
||||
const data = {
|
||||
@@ -205,6 +234,27 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
if (customUrl) data.CUSTOM_OPENAI_BASE_URL = customUrl
|
||||
}
|
||||
|
||||
// Chat provider config
|
||||
const chatProv = formData.get('AI_PROVIDER_CHAT') as AIProvider
|
||||
if (chatProv) {
|
||||
data.AI_PROVIDER_CHAT = chatProv
|
||||
|
||||
const chatModel = formData.get(`AI_MODEL_CHAT_${chatProv.toUpperCase()}`) as string
|
||||
if (chatModel) data.AI_MODEL_CHAT = chatModel
|
||||
|
||||
if (chatProv === 'ollama') {
|
||||
const ollamaUrl = formData.get('OLLAMA_BASE_URL_CHAT') as string
|
||||
if (ollamaUrl) data.OLLAMA_BASE_URL_CHAT = ollamaUrl
|
||||
} else if (chatProv === 'openai') {
|
||||
const openaiKey = formData.get('OPENAI_API_KEY') as string
|
||||
if (openaiKey) data.OPENAI_API_KEY = openaiKey
|
||||
} else if (chatProv === 'custom') {
|
||||
const customKey = formData.get('CUSTOM_OPENAI_API_KEY_CHAT') as string
|
||||
const customUrl = formData.get('CUSTOM_OPENAI_BASE_URL_CHAT') as string
|
||||
if (customKey) data.CUSTOM_OPENAI_API_KEY = customKey
|
||||
if (customUrl) data.CUSTOM_OPENAI_BASE_URL = customUrl
|
||||
}
|
||||
}
|
||||
|
||||
const result = await updateSystemConfig(data)
|
||||
setIsSaving(false)
|
||||
@@ -220,16 +270,21 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveSMTP = async (formData: FormData) => {
|
||||
const handleSaveEmail = async (formData: FormData) => {
|
||||
setIsSaving(true)
|
||||
const data = {
|
||||
SMTP_HOST: formData.get('SMTP_HOST') as string,
|
||||
SMTP_PORT: formData.get('SMTP_PORT') as string,
|
||||
SMTP_USER: formData.get('SMTP_USER') as string,
|
||||
SMTP_PASS: formData.get('SMTP_PASS') as string,
|
||||
SMTP_FROM: formData.get('SMTP_FROM') as string,
|
||||
SMTP_IGNORE_CERT: smtpIgnoreCert ? 'true' : 'false',
|
||||
SMTP_SECURE: smtpSecure ? 'true' : 'false',
|
||||
const data: Record<string, string> = { EMAIL_PROVIDER: emailProvider }
|
||||
|
||||
if (emailProvider === 'resend') {
|
||||
const key = formData.get('RESEND_API_KEY') as string
|
||||
if (key) data.RESEND_API_KEY = key
|
||||
} else {
|
||||
data.SMTP_HOST = formData.get('SMTP_HOST') as string
|
||||
data.SMTP_PORT = formData.get('SMTP_PORT') as string
|
||||
data.SMTP_USER = formData.get('SMTP_USER') as string
|
||||
data.SMTP_PASS = formData.get('SMTP_PASS') as string
|
||||
data.SMTP_FROM = formData.get('SMTP_FROM') as string
|
||||
data.SMTP_IGNORE_CERT = smtpIgnoreCert ? 'true' : 'false'
|
||||
data.SMTP_SECURE = smtpSecure ? 'true' : 'false'
|
||||
}
|
||||
|
||||
const result = await updateSystemConfig(data)
|
||||
@@ -245,19 +300,41 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
const handleTestEmail = async () => {
|
||||
setIsTesting(true)
|
||||
try {
|
||||
const result: any = await testSMTP()
|
||||
const result: any = await testEmail(emailProvider)
|
||||
if (result.success) {
|
||||
toast.success(t('admin.smtp.testSuccess'))
|
||||
setEmailTestResult({ provider: emailProvider, success: true })
|
||||
} else {
|
||||
toast.error(t('admin.smtp.testFailed', { error: result.error }))
|
||||
setEmailTestResult({ provider: emailProvider, success: false, message: result.error })
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast.error(t('general.error') + ': ' + e.message)
|
||||
setEmailTestResult({ provider: emailProvider, success: false, message: e.message })
|
||||
} finally {
|
||||
setIsTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveTools = async (formData: FormData) => {
|
||||
setIsSaving(true)
|
||||
const data = {
|
||||
WEB_SEARCH_PROVIDER: formData.get('WEB_SEARCH_PROVIDER') as string || 'searxng',
|
||||
SEARXNG_URL: formData.get('SEARXNG_URL') as string || '',
|
||||
BRAVE_SEARCH_API_KEY: formData.get('BRAVE_SEARCH_API_KEY') as string || '',
|
||||
JINA_API_KEY: formData.get('JINA_API_KEY') as string || '',
|
||||
}
|
||||
|
||||
const result = await updateSystemConfig(data)
|
||||
setIsSaving(false)
|
||||
|
||||
if (result.error) {
|
||||
toast.error(t('admin.tools.updateFailed'))
|
||||
} else {
|
||||
toast.success(t('admin.tools.updateSuccess'))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
@@ -433,35 +510,21 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
<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">{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"
|
||||
<Label>{t('admin.ai.model')}</Label>
|
||||
<input type="hidden" name="AI_MODEL_TAGS_CUSTOM" value={selectedTagsModel} />
|
||||
<Combobox
|
||||
options={customTagsModels.length > 0
|
||||
? customTagsModels.map((m) => ({ value: m, label: m }))
|
||||
: selectedTagsModel
|
||||
? [{ value: selectedTagsModel, label: selectedTagsModel }]
|
||||
: []
|
||||
}
|
||||
value={selectedTagsModel}
|
||||
onChange={(e) => setSelectedTagsModel(e.target.value)}
|
||||
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}
|
||||
>
|
||||
{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>
|
||||
onChange={setSelectedTagsModel}
|
||||
placeholder={selectedTagsModel || 'Cliquez sur ↺ pour charger les modèles'}
|
||||
searchPlaceholder="Rechercher un modèle..."
|
||||
emptyMessage="Aucun modèle. Cliquez sur ↺"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{isLoadingCustomTagsModels
|
||||
? 'Récupération des modèles...'
|
||||
@@ -619,35 +682,21 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
<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">{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"
|
||||
<Label>{t('admin.ai.model')}</Label>
|
||||
<input type="hidden" name="AI_MODEL_EMBEDDING_CUSTOM" value={selectedEmbeddingModel} />
|
||||
<Combobox
|
||||
options={customEmbeddingsModels.length > 0
|
||||
? customEmbeddingsModels.map((m) => ({ value: m, label: m }))
|
||||
: selectedEmbeddingModel
|
||||
? [{ value: selectedEmbeddingModel, label: selectedEmbeddingModel }]
|
||||
: []
|
||||
}
|
||||
value={selectedEmbeddingModel}
|
||||
onChange={(e) => setSelectedEmbeddingModel(e.target.value)}
|
||||
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}
|
||||
>
|
||||
{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>
|
||||
onChange={setSelectedEmbeddingModel}
|
||||
placeholder={selectedEmbeddingModel || 'Cliquez sur ↺ pour charger les modèles'}
|
||||
searchPlaceholder="Rechercher un modèle..."
|
||||
emptyMessage="Aucun modèle. Cliquez sur ↺"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{isLoadingCustomEmbeddingsModels
|
||||
? 'Récupération des modèles...'
|
||||
@@ -659,6 +708,171 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Chat Provider 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> {t('admin.ai.chatProvider')}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground">{t('admin.ai.chatDescription')}</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="AI_PROVIDER_CHAT">{t('admin.ai.provider')}</Label>
|
||||
<select
|
||||
id="AI_PROVIDER_CHAT"
|
||||
name="AI_PROVIDER_CHAT"
|
||||
value={chatProvider}
|
||||
onChange={(e) => {
|
||||
const newProvider = e.target.value as AIProvider
|
||||
setChatProvider(newProvider)
|
||||
const defaultModels: Record<string, string> = {
|
||||
ollama: '',
|
||||
openai: MODELS_2026.openai.tags[0],
|
||||
custom: '',
|
||||
}
|
||||
setSelectedChatModel(defaultModels[newProvider] || '')
|
||||
}}
|
||||
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">{t('admin.ai.providerOllamaOption')}</option>
|
||||
<option value="openai">{t('admin.ai.providerOpenAIOption')}</option>
|
||||
<option value="custom">{t('admin.ai.providerCustomOption')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{chatProvider === 'ollama' && (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="OLLAMA_BASE_URL_CHAT">{t('admin.ai.baseUrl')}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="OLLAMA_BASE_URL_CHAT"
|
||||
name="OLLAMA_BASE_URL_CHAT"
|
||||
defaultValue={config.OLLAMA_BASE_URL_CHAT || config.OLLAMA_BASE_URL || 'http://localhost:11434'}
|
||||
placeholder="http://localhost:11434"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
const input = document.getElementById('OLLAMA_BASE_URL_CHAT') as HTMLInputElement
|
||||
fetchOllamaModels('chat', input.value)
|
||||
}}
|
||||
disabled={isLoadingChatModels}
|
||||
title="Refresh Models"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${isLoadingChatModels ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="AI_MODEL_CHAT_OLLAMA">{t('admin.ai.model')}</Label>
|
||||
<select
|
||||
id="AI_MODEL_CHAT_OLLAMA"
|
||||
name="AI_MODEL_CHAT_OLLAMA"
|
||||
value={selectedChatModel}
|
||||
onChange={(e) => setSelectedChatModel(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"
|
||||
>
|
||||
{ollamaChatModels.length > 0 ? (
|
||||
ollamaChatModels.map((model) => (
|
||||
<option key={model} value={model}>{model}</option>
|
||||
))
|
||||
) : (
|
||||
<option value={selectedChatModel || 'granite4:latest'}>{selectedChatModel || 'granite4:latest'} {t('admin.ai.saved')}</option>
|
||||
)}
|
||||
</select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{isLoadingChatModels ? 'Fetching models...' : t('admin.ai.selectOllamaModel')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{chatProvider === 'openai' && (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="OPENAI_API_KEY_CHAT">{t('admin.ai.apiKey')}</Label>
|
||||
<Input id="OPENAI_API_KEY_CHAT" name="OPENAI_API_KEY" type="password" defaultValue={config.OPENAI_API_KEY || ''} placeholder="sk-..." />
|
||||
<p className="text-xs text-muted-foreground">{t('admin.ai.openAIKeyDescription')}</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="AI_MODEL_CHAT_OPENAI">{t('admin.ai.model')}</Label>
|
||||
<select
|
||||
id="AI_MODEL_CHAT_OPENAI"
|
||||
name="AI_MODEL_CHAT_OPENAI"
|
||||
value={selectedChatModel}
|
||||
onChange={(e) => setSelectedChatModel(e.target.value)}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
{MODELS_2026.openai.tags.map((model) => (
|
||||
<option key={model} value={model}>{model}</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-muted-foreground"><strong className="text-green-600">gpt-4o-mini</strong> = {t('admin.ai.bestValue')} • <strong className="text-primary">gpt-4o</strong> = {t('admin.ai.bestQuality')}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{chatProvider === 'custom' && (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="CUSTOM_OPENAI_BASE_URL_CHAT">{t('admin.ai.baseUrl')}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="CUSTOM_OPENAI_BASE_URL_CHAT"
|
||||
name="CUSTOM_OPENAI_BASE_URL_CHAT"
|
||||
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_CHAT') as HTMLInputElement
|
||||
const keyInput = document.getElementById('CUSTOM_OPENAI_API_KEY_CHAT') as HTMLInputElement
|
||||
fetchCustomModels('chat', urlInput.value, keyInput.value)
|
||||
}}
|
||||
disabled={isLoadingCustomChatModels}
|
||||
title="Récupérer les modèles"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${isLoadingCustomChatModels ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="CUSTOM_OPENAI_API_KEY_CHAT">{t('admin.ai.apiKey')}</Label>
|
||||
<Input id="CUSTOM_OPENAI_API_KEY_CHAT" name="CUSTOM_OPENAI_API_KEY_CHAT" type="password" defaultValue={config.CUSTOM_OPENAI_API_KEY || ''} placeholder="sk-..." />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('admin.ai.model')}</Label>
|
||||
<input type="hidden" name="AI_MODEL_CHAT_CUSTOM" value={selectedChatModel} />
|
||||
<Combobox
|
||||
options={customChatModels.length > 0
|
||||
? customChatModels.map((m) => ({ value: m, label: m }))
|
||||
: selectedChatModel
|
||||
? [{ value: selectedChatModel, label: selectedChatModel }]
|
||||
: []
|
||||
}
|
||||
value={selectedChatModel}
|
||||
onChange={setSelectedChatModel}
|
||||
placeholder={selectedChatModel || 'Cliquez sur ↺ pour charger les modèles'}
|
||||
searchPlaceholder="Rechercher un modèle..."
|
||||
emptyMessage="Aucun modèle. Cliquez sur ↺"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{isLoadingCustomChatModels
|
||||
? 'Récupération des modèles...'
|
||||
: customChatModels.length > 0
|
||||
? `${customChatModels.length} modèle(s) disponible(s)`
|
||||
: 'Renseignez l\'URL et cliquez sur ↺ pour charger les modèles'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-between pt-6">
|
||||
<Button type="submit" disabled={isSaving}>{isSaving ? t('admin.ai.saving') : t('admin.ai.saveSettings')}</Button>
|
||||
@@ -675,73 +889,195 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('admin.smtp.title')}</CardTitle>
|
||||
<CardDescription>{t('admin.smtp.description')}</CardDescription>
|
||||
<CardTitle>{t('admin.email.title')}</CardTitle>
|
||||
<CardDescription>{t('admin.email.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<form onSubmit={(e) => { e.preventDefault(); handleSaveSMTP(new FormData(e.currentTarget)) }}>
|
||||
<form onSubmit={(e) => { e.preventDefault(); handleSaveEmail(new FormData(e.currentTarget)) }}>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="SMTP_HOST" className="text-sm font-medium">{t('admin.smtp.host')}</label>
|
||||
<Input id="SMTP_HOST" name="SMTP_HOST" defaultValue={config.SMTP_HOST || ''} placeholder="smtp.example.com" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="SMTP_PORT" className="text-sm font-medium">{t('admin.smtp.port')}</label>
|
||||
<Input id="SMTP_PORT" name="SMTP_PORT" defaultValue={config.SMTP_PORT || '587'} placeholder="587" />
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">{t('admin.email.provider')}</label>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEmailProvider('resend')}
|
||||
className={`flex-1 px-4 py-2.5 rounded-lg border text-sm font-medium transition-colors ${
|
||||
emailProvider === 'resend'
|
||||
? 'border-primary bg-primary/10 text-primary'
|
||||
: 'border-border bg-background text-muted-foreground hover:bg-muted'
|
||||
}`}
|
||||
>
|
||||
Resend
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEmailProvider('smtp')}
|
||||
className={`flex-1 px-4 py-2.5 rounded-lg border text-sm font-medium transition-colors ${
|
||||
emailProvider === 'smtp'
|
||||
? 'border-primary bg-primary/10 text-primary'
|
||||
: 'border-border bg-background text-muted-foreground hover:bg-muted'
|
||||
}`}
|
||||
>
|
||||
SMTP
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="SMTP_USER" className="text-sm font-medium">{t('admin.smtp.username')}</label>
|
||||
<Input id="SMTP_USER" name="SMTP_USER" defaultValue={config.SMTP_USER || ''} />
|
||||
{/* Email service status */}
|
||||
<div className="rounded-lg border bg-muted/30 p-3 space-y-1.5">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">{t('admin.email.status')}</div>
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={`inline-block w-2 h-2 rounded-full ${config.RESEND_API_KEY ? 'bg-green-500' : 'bg-slate-300'}`} />
|
||||
<span className={config.RESEND_API_KEY ? 'text-green-700' : 'text-slate-400'}>Resend</span>
|
||||
{config.RESEND_API_KEY && <span className="text-xs text-muted-foreground">({t('admin.email.keySet')})</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={`inline-block w-2 h-2 rounded-full ${config.SMTP_HOST ? 'bg-green-500' : 'bg-slate-300'}`} />
|
||||
<span className={config.SMTP_HOST ? 'text-green-700' : 'text-slate-400'}>SMTP</span>
|
||||
{config.SMTP_HOST && <span className="text-xs text-muted-foreground">({config.SMTP_HOST}:{config.SMTP_PORT || '587'})</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs font-medium text-primary">
|
||||
{t('admin.email.activeProvider')}: {emailProvider === 'resend' ? 'Resend' : 'SMTP'}
|
||||
</div>
|
||||
{emailTestResult && (
|
||||
<div className={`flex items-center gap-1.5 text-xs pt-1 border-t ${emailTestResult.success ? 'text-green-600' : 'text-red-500'}`}>
|
||||
<span className={`inline-block w-2 h-2 rounded-full ${emailTestResult.success ? 'bg-green-500' : 'bg-red-500'}`} />
|
||||
<span>
|
||||
{emailTestResult.provider === 'resend' ? 'Resend' : 'SMTP'} — {emailTestResult.success ? t('admin.email.testOk') : `${t('admin.email.testFail')}: ${emailTestResult.message || ''}`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="SMTP_PASS" className="text-sm font-medium">{t('admin.smtp.password')}</label>
|
||||
<Input id="SMTP_PASS" name="SMTP_PASS" type="password" defaultValue={config.SMTP_PASS || ''} />
|
||||
</div>
|
||||
{emailProvider === 'resend' ? (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="RESEND_API_KEY" className="text-sm font-medium">{t('admin.resend.apiKey')}</label>
|
||||
<Input id="RESEND_API_KEY" name="RESEND_API_KEY" type="password" defaultValue={config.RESEND_API_KEY || ''} placeholder="re_..." />
|
||||
<p className="text-xs text-muted-foreground">{t('admin.resend.apiKeyHint')}</p>
|
||||
</div>
|
||||
{config.RESEND_API_KEY && (
|
||||
<div className="flex items-center gap-2 text-xs text-green-600">
|
||||
<span className="inline-block w-2 h-2 rounded-full bg-green-500" />
|
||||
{t('admin.resend.configured')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="SMTP_HOST" className="text-sm font-medium">{t('admin.smtp.host')}</label>
|
||||
<Input id="SMTP_HOST" name="SMTP_HOST" defaultValue={config.SMTP_HOST || ''} placeholder="smtp.example.com" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="SMTP_PORT" className="text-sm font-medium">{t('admin.smtp.port')}</label>
|
||||
<Input id="SMTP_PORT" name="SMTP_PORT" defaultValue={config.SMTP_PORT || '587'} placeholder="587" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="SMTP_FROM" className="text-sm font-medium">{t('admin.smtp.fromEmail')}</label>
|
||||
<Input id="SMTP_FROM" name="SMTP_FROM" defaultValue={config.SMTP_FROM || 'noreply@memento.app'} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="SMTP_USER" className="text-sm font-medium">{t('admin.smtp.username')}</label>
|
||||
<Input id="SMTP_USER" name="SMTP_USER" defaultValue={config.SMTP_USER || ''} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="SMTP_SECURE"
|
||||
checked={smtpSecure}
|
||||
onCheckedChange={(c) => setSmtpSecure(!!c)}
|
||||
/>
|
||||
<label
|
||||
htmlFor="SMTP_SECURE"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
{t('admin.smtp.forceSSL')}
|
||||
</label>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="SMTP_PASS" className="text-sm font-medium">{t('admin.smtp.password')}</label>
|
||||
<Input id="SMTP_PASS" name="SMTP_PASS" type="password" defaultValue={config.SMTP_PASS || ''} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="SMTP_IGNORE_CERT"
|
||||
checked={smtpIgnoreCert}
|
||||
onCheckedChange={(c) => setSmtpIgnoreCert(!!c)}
|
||||
/>
|
||||
<label
|
||||
htmlFor="SMTP_IGNORE_CERT"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 text-yellow-600"
|
||||
>
|
||||
{t('admin.smtp.ignoreCertErrors')}
|
||||
</label>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="SMTP_FROM" className="text-sm font-medium">{t('admin.smtp.fromEmail')}</label>
|
||||
<Input id="SMTP_FROM" name="SMTP_FROM" defaultValue={config.SMTP_FROM || 'noreply@memento.app'} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="SMTP_SECURE"
|
||||
checked={smtpSecure}
|
||||
onCheckedChange={(c) => setSmtpSecure(!!c)}
|
||||
/>
|
||||
<label
|
||||
htmlFor="SMTP_SECURE"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
{t('admin.smtp.forceSSL')}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="SMTP_IGNORE_CERT"
|
||||
checked={smtpIgnoreCert}
|
||||
onCheckedChange={(c) => setSmtpIgnoreCert(!!c)}
|
||||
/>
|
||||
<label
|
||||
htmlFor="SMTP_IGNORE_CERT"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 text-yellow-600"
|
||||
>
|
||||
{t('admin.smtp.ignoreCertErrors')}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-between pt-6">
|
||||
<Button type="submit" disabled={isSaving}>{t('admin.smtp.saveSettings')}</Button>
|
||||
<Button type="submit" disabled={isSaving}>{t('admin.email.saveSettings')}</Button>
|
||||
<Button type="button" variant="secondary" onClick={handleTestEmail} disabled={isTesting}>
|
||||
{isTesting ? t('admin.smtp.sending') : t('admin.smtp.testEmail')}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('admin.tools.title')}</CardTitle>
|
||||
<CardDescription>{t('admin.tools.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<form onSubmit={(e) => { e.preventDefault(); handleSaveTools(new FormData(e.currentTarget)) }}>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="WEB_SEARCH_PROVIDER" className="text-sm font-medium">{t('admin.tools.searchProvider')}</label>
|
||||
<select
|
||||
id="WEB_SEARCH_PROVIDER"
|
||||
name="WEB_SEARCH_PROVIDER"
|
||||
value={webSearchProvider}
|
||||
onChange={(e) => setWebSearchProvider(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"
|
||||
>
|
||||
<option value="searxng">{t('admin.tools.searxng')}</option>
|
||||
<option value="brave">{t('admin.tools.brave')}</option>
|
||||
<option value="both">{t('admin.tools.both')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{(webSearchProvider === 'searxng' || webSearchProvider === 'both') && (
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="SEARXNG_URL" className="text-sm font-medium">{t('admin.tools.searxngUrl')}</label>
|
||||
<Input id="SEARXNG_URL" name="SEARXNG_URL" defaultValue={config.SEARXNG_URL || 'http://localhost:8080'} placeholder="http://localhost:8080" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(webSearchProvider === 'brave' || webSearchProvider === 'both') && (
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="BRAVE_SEARCH_API_KEY" className="text-sm font-medium">{t('admin.tools.braveKey')}</label>
|
||||
<Input id="BRAVE_SEARCH_API_KEY" name="BRAVE_SEARCH_API_KEY" type="password" defaultValue={config.BRAVE_SEARCH_API_KEY || ''} placeholder="BSA-..." />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="JINA_API_KEY" className="text-sm font-medium">{t('admin.tools.jinaKey')}</label>
|
||||
<Input id="JINA_API_KEY" name="JINA_API_KEY" type="password" defaultValue={config.JINA_API_KEY || ''} placeholder={t('admin.tools.jinaKeyOptional')} />
|
||||
<p className="text-xs text-muted-foreground">{t('admin.tools.jinaKeyDescription')}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button type="submit" disabled={isSaving}>{t('admin.tools.saveSettings')}</Button>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ interface AgentItem {
|
||||
tools?: string | null
|
||||
maxSteps?: number
|
||||
notifyEmail?: boolean
|
||||
includeImages?: boolean
|
||||
_count: { actions: number }
|
||||
actions: { id: string; status: string; createdAt: string | Date }[]
|
||||
notebook?: { id: string; name: string; icon?: string | null } | null
|
||||
@@ -118,6 +119,7 @@ export function AgentsPageClient({
|
||||
tools: formData.get('tools') ? JSON.parse(formData.get('tools') as string) : undefined,
|
||||
maxSteps: formData.get('maxSteps') ? Number(formData.get('maxSteps')) : undefined,
|
||||
notifyEmail: formData.get('notifyEmail') === 'true',
|
||||
includeImages: formData.get('includeImages') === 'true',
|
||||
}
|
||||
|
||||
if (editingAgent) {
|
||||
|
||||
33
keep-notes/app/(main)/agents/page.tsx
Normal file
33
keep-notes/app/(main)/agents/page.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { auth } from '@/auth'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { getAgents } from '@/app/actions/agent-actions'
|
||||
import { AgentsPageClient } from './agents-page-client'
|
||||
|
||||
export default async function AgentsPage() {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) redirect('/login')
|
||||
|
||||
const userId = session.user.id
|
||||
|
||||
const [agents, notebooks] = await Promise.all([
|
||||
getAgents(),
|
||||
prisma.notebook.findMany({
|
||||
where: { userId },
|
||||
orderBy: { order: 'asc' }
|
||||
})
|
||||
])
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col h-full bg-slate-50/50">
|
||||
<div className="flex-1 p-8 overflow-y-auto">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<AgentsPageClient
|
||||
agents={agents}
|
||||
notebooks={notebooks}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
36
keep-notes/app/(main)/chat/page.tsx
Normal file
36
keep-notes/app/(main)/chat/page.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Metadata } from 'next'
|
||||
import { auth } from '@/auth'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { ChatContainer } from '@/components/chat/chat-container'
|
||||
import { getConversations } from '@/app/actions/chat-actions'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Chat IA | Memento',
|
||||
description: 'Discutez avec vos notes et vos agents IA',
|
||||
}
|
||||
|
||||
export default async function ChatPage() {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) redirect('/login')
|
||||
|
||||
const userId = session.user.id
|
||||
|
||||
// Fetch initial data
|
||||
const [conversations, notebooks] = await Promise.all([
|
||||
getConversations(),
|
||||
prisma.notebook.findMany({
|
||||
where: { userId },
|
||||
orderBy: { order: 'asc' }
|
||||
})
|
||||
])
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col h-full bg-white dark:bg-[#1a1c22]">
|
||||
<ChatContainer
|
||||
initialConversations={conversations}
|
||||
notebooks={notebooks}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
56
keep-notes/app/(main)/lab/page.tsx
Normal file
56
keep-notes/app/(main)/lab/page.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { Metadata } from 'next'
|
||||
import { auth } from '@/auth'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { getCanvases, createCanvas } from '@/app/actions/canvas-actions'
|
||||
import { LabHeader } from '@/components/lab/lab-header'
|
||||
import { CanvasWrapper } from '@/components/lab/canvas-wrapper'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Le Lab | Memento',
|
||||
description: 'Visualisez et connectez vos idées sur un canvas interactif',
|
||||
}
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const revalidate = 0
|
||||
|
||||
export default async function LabPage(props: {
|
||||
searchParams: Promise<{ id?: string }>
|
||||
}) {
|
||||
const searchParams = await props.searchParams
|
||||
const id = searchParams.id
|
||||
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) redirect('/login')
|
||||
|
||||
const canvases = await getCanvases()
|
||||
|
||||
// Resolve current canvas correctly
|
||||
const currentCanvasId = searchParams.id || (canvases.length > 0 ? canvases[0].id : undefined)
|
||||
const currentCanvas = currentCanvasId ? canvases.find(c => c.id === currentCanvasId) : undefined
|
||||
|
||||
// Wrapper for server action creation
|
||||
async function handleCreate() {
|
||||
'use server'
|
||||
const newCanvas = await createCanvas()
|
||||
redirect(`/lab?id=${newCanvas.id}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col h-full bg-slate-50 dark:bg-[#1a1c22] overflow-hidden">
|
||||
<LabHeader
|
||||
canvases={canvases}
|
||||
currentCanvasId={currentCanvasId ?? null}
|
||||
onCreateCanvas={handleCreate}
|
||||
/>
|
||||
|
||||
<div className="flex-1 relative">
|
||||
<CanvasWrapper
|
||||
key={currentCanvasId || 'new'}
|
||||
canvasId={currentCanvas?.id}
|
||||
name={currentCanvas?.name || "Nouvel Espace de Pensée"}
|
||||
initialData={currentCanvas?.data}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,13 +2,7 @@ import { getAllNotes } from '@/app/actions/notes'
|
||||
import { getAISettings } from '@/app/actions/ai-settings'
|
||||
import { HomeClient } from '@/components/home-client'
|
||||
|
||||
/**
|
||||
* Page principale — Server Component.
|
||||
* Les notes et settings sont chargés côté serveur en parallèle,
|
||||
* éliminant le spinner de chargement initial et améliorant le TTI.
|
||||
*/
|
||||
export default async function HomePage() {
|
||||
// Charge notes + settings en parallèle côté serveur
|
||||
const [allNotes, settings] = await Promise.all([
|
||||
getAllNotes(),
|
||||
getAISettings(),
|
||||
|
||||
@@ -10,12 +10,14 @@ import { useLanguage } from '@/lib/i18n'
|
||||
interface AppearanceSettingsFormProps {
|
||||
initialTheme: string
|
||||
initialFontSize: string
|
||||
initialCardSizeMode?: string
|
||||
}
|
||||
|
||||
export function AppearanceSettingsForm({ initialTheme, initialFontSize }: AppearanceSettingsFormProps) {
|
||||
export function AppearanceSettingsForm({ initialTheme, initialFontSize, initialCardSizeMode = 'variable' }: AppearanceSettingsFormProps) {
|
||||
const router = useRouter()
|
||||
const [theme, setTheme] = useState(initialTheme)
|
||||
const [fontSize, setFontSize] = useState(initialFontSize)
|
||||
const [cardSizeMode, setCardSizeMode] = useState(initialCardSizeMode)
|
||||
const { t } = useLanguage()
|
||||
|
||||
const handleThemeChange = async (value: string) => {
|
||||
@@ -55,6 +57,12 @@ export function AppearanceSettingsForm({ initialTheme, initialFontSize }: Appear
|
||||
await updateAI({ fontSize: value as any })
|
||||
}
|
||||
|
||||
const handleCardSizeModeChange = async (value: string) => {
|
||||
setCardSizeMode(value)
|
||||
localStorage.setItem('card-size-mode', value)
|
||||
await updateUser({ cardSizeMode: value as 'variable' | 'uniform' })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
@@ -102,6 +110,23 @@ export function AppearanceSettingsForm({ initialTheme, initialFontSize }: Appear
|
||||
onChange={handleFontSizeChange}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
title={t('settings.cardSizeMode')}
|
||||
icon={<span className="text-2xl">📐</span>}
|
||||
description={t('settings.cardSizeModeDescription')}
|
||||
>
|
||||
<SettingSelect
|
||||
label={t('settings.cardSizeMode')}
|
||||
description={t('settings.selectCardSizeMode')}
|
||||
value={cardSizeMode}
|
||||
options={[
|
||||
{ value: 'variable', label: t('settings.cardSizeVariable') },
|
||||
{ value: 'uniform', label: t('settings.cardSizeUniform') },
|
||||
]}
|
||||
onChange={handleCardSizeModeChange}
|
||||
/>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -11,13 +11,15 @@ interface AppearanceSettingsClientProps {
|
||||
initialFontSize: string
|
||||
initialTheme: string
|
||||
initialNotesViewMode: 'masonry' | 'tabs'
|
||||
initialCardSizeMode?: 'variable' | 'uniform'
|
||||
}
|
||||
|
||||
export function AppearanceSettingsClient({ initialFontSize, initialTheme, initialNotesViewMode }: AppearanceSettingsClientProps) {
|
||||
export function AppearanceSettingsClient({ initialFontSize, initialTheme, initialNotesViewMode, initialCardSizeMode = 'variable' }: AppearanceSettingsClientProps) {
|
||||
const { t } = useLanguage()
|
||||
const [theme, setTheme] = useState(initialTheme || 'light')
|
||||
const [fontSize, setFontSize] = useState(initialFontSize || 'medium')
|
||||
const [notesViewMode, setNotesViewMode] = useState<'masonry' | 'tabs'>(initialNotesViewMode)
|
||||
const [cardSizeMode, setCardSizeMode] = useState<'variable' | 'uniform'>(initialCardSizeMode)
|
||||
|
||||
const handleThemeChange = async (value: string) => {
|
||||
setTheme(value)
|
||||
@@ -59,6 +61,14 @@ export function AppearanceSettingsClient({ initialFontSize, initialTheme, initia
|
||||
toast.success(t('settings.settingsSaved') || 'Saved')
|
||||
}
|
||||
|
||||
const handleCardSizeModeChange = async (value: string) => {
|
||||
const mode = value === 'uniform' ? 'uniform' : 'variable'
|
||||
setCardSizeMode(mode)
|
||||
localStorage.setItem('card-size-mode', mode)
|
||||
await updateUserSettings({ cardSizeMode: mode })
|
||||
toast.success(t('settings.settingsSaved') || 'Saved')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
@@ -122,6 +132,23 @@ export function AppearanceSettingsClient({ initialFontSize, initialTheme, initia
|
||||
onChange={handleNotesViewChange}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
title={t('settings.cardSizeMode')}
|
||||
icon={<span className="text-2xl">📐</span>}
|
||||
description={t('settings.cardSizeModeDescription')}
|
||||
>
|
||||
<SettingSelect
|
||||
label={t('settings.cardSizeMode')}
|
||||
description={t('settings.selectCardSizeMode')}
|
||||
value={cardSizeMode}
|
||||
options={[
|
||||
{ value: 'variable', label: t('settings.cardSizeVariable') },
|
||||
{ value: 'uniform', label: t('settings.cardSizeUniform') },
|
||||
]}
|
||||
onChange={handleCardSizeModeChange}
|
||||
/>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ export default async function AppearanceSettingsPage() {
|
||||
initialFontSize={aiSettings.fontSize}
|
||||
initialTheme={userSettings.theme}
|
||||
initialNotesViewMode={aiSettings.notesViewMode === 'masonry' ? 'masonry' : 'tabs'}
|
||||
initialCardSizeMode={userSettings.cardSizeMode}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ interface GeneralSettingsClientProps {
|
||||
preferredLanguage: string
|
||||
emailNotifications: boolean
|
||||
desktopNotifications: boolean
|
||||
anonymousAnalytics: boolean
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +21,6 @@ export function GeneralSettingsClient({ initialSettings }: GeneralSettingsClient
|
||||
const [language, setLanguage] = useState(initialSettings.preferredLanguage || 'auto')
|
||||
const [emailNotifications, setEmailNotifications] = useState(initialSettings.emailNotifications ?? false)
|
||||
const [desktopNotifications, setDesktopNotifications] = useState(initialSettings.desktopNotifications ?? false)
|
||||
const [anonymousAnalytics, setAnonymousAnalytics] = useState(initialSettings.anonymousAnalytics ?? false)
|
||||
|
||||
const handleLanguageChange = async (value: string) => {
|
||||
setLanguage(value)
|
||||
@@ -52,12 +50,6 @@ export function GeneralSettingsClient({ initialSettings }: GeneralSettingsClient
|
||||
toast.success(t('settings.settingsSaved') || 'Saved')
|
||||
}
|
||||
|
||||
const handleAnonymousAnalyticsChange = async (enabled: boolean) => {
|
||||
setAnonymousAnalytics(enabled)
|
||||
await updateAISettings({ anonymousAnalytics: enabled })
|
||||
toast.success(t('settings.settingsSaved') || 'Saved')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
@@ -116,19 +108,6 @@ export function GeneralSettingsClient({ initialSettings }: GeneralSettingsClient
|
||||
onChange={handleDesktopNotificationsChange}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
title={t('settings.privacy')}
|
||||
icon={<span className="text-2xl">🔒</span>}
|
||||
description={t('settings.privacyDesc')}
|
||||
>
|
||||
<SettingToggle
|
||||
label={t('settings.anonymousAnalytics')}
|
||||
description={t('settings.anonymousAnalyticsDesc')}
|
||||
checked={anonymousAnalytics}
|
||||
onChange={handleAnonymousAnalyticsChange}
|
||||
/>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,28 +1,21 @@
|
||||
import { Trash2 } from 'lucide-react'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { getTrashedNotes } from '@/app/actions/notes'
|
||||
import { MasonryGrid } from '@/components/masonry-grid'
|
||||
import { TrashHeader } from '@/components/trash-header'
|
||||
import { TrashEmptyState } from './trash-empty-state'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default function TrashPage() {
|
||||
return (
|
||||
<main className="container mx-auto px-4 py-8 max-w-7xl">
|
||||
<TrashContent />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
export default async function TrashPage() {
|
||||
const notes = await getTrashedNotes()
|
||||
|
||||
function TrashContent() {
|
||||
const { t } = useLanguage()
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[60vh] text-center text-gray-500">
|
||||
<div className="bg-gray-100 dark:bg-gray-800 p-6 rounded-full mb-4">
|
||||
<Trash2 className="w-12 h-12 text-gray-400" />
|
||||
</div>
|
||||
<h2 className="text-xl font-medium mb-2">{t('trash.empty')}</h2>
|
||||
<p className="max-w-md text-sm opacity-80">
|
||||
{t('trash.restore')}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
return (
|
||||
<main className="container mx-auto px-4 py-8 max-w-7xl">
|
||||
<TrashHeader noteCount={notes.length} />
|
||||
{notes.length > 0 ? (
|
||||
<MasonryGrid notes={notes} isTrashView />
|
||||
) : (
|
||||
<TrashEmptyState />
|
||||
)}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
20
keep-notes/app/(main)/trash/trash-empty-state.tsx
Normal file
20
keep-notes/app/(main)/trash/trash-empty-state.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
'use client'
|
||||
|
||||
import { Trash2 } from 'lucide-react'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
export function TrashEmptyState() {
|
||||
const { t } = useLanguage()
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[60vh] text-center text-gray-500">
|
||||
<div className="bg-gray-100 dark:bg-gray-800 p-6 rounded-full mb-4">
|
||||
<Trash2 className="w-12 h-12 text-gray-400" />
|
||||
</div>
|
||||
<h2 className="text-xl font-medium mb-2">{t('trash.empty')}</h2>
|
||||
<p className="max-w-md text-sm opacity-80">
|
||||
{t('trash.emptyDescription')}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -13,17 +13,25 @@ async function checkAdmin() {
|
||||
return session
|
||||
}
|
||||
|
||||
export async function testSMTP() {
|
||||
export async function testEmail(provider: 'resend' | 'smtp' = 'smtp') {
|
||||
const session = await checkAdmin()
|
||||
const email = session.user?.email
|
||||
|
||||
if (!email) throw new Error("No admin email found")
|
||||
|
||||
const subject = provider === 'resend'
|
||||
? "Memento Resend Test"
|
||||
: "Memento SMTP Test"
|
||||
|
||||
const html = provider === 'resend'
|
||||
? "<p>This is a test email from your Memento instance. <strong>Resend is working!</strong></p>"
|
||||
: "<p>This is a test email from your Memento instance. <strong>SMTP is working!</strong></p>"
|
||||
|
||||
const result = await sendEmail({
|
||||
to: email,
|
||||
subject: "Memento SMTP Test",
|
||||
html: "<p>This is a test email from your Memento instance. <strong>SMTP is working!</strong></p>"
|
||||
})
|
||||
subject,
|
||||
html,
|
||||
}, provider)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
import { auth } from '@/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { executeAgent } from '@/lib/ai/services/agent-executor.service'
|
||||
|
||||
// --- CRUD ---
|
||||
|
||||
@@ -21,6 +20,10 @@ export async function createAgent(data: {
|
||||
sourceNotebookId?: string
|
||||
targetNotebookId?: string
|
||||
frequency?: string
|
||||
tools?: string[]
|
||||
maxSteps?: number
|
||||
notifyEmail?: boolean
|
||||
includeImages?: boolean
|
||||
}) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
@@ -38,6 +41,10 @@ export async function createAgent(data: {
|
||||
sourceNotebookId: data.sourceNotebookId || null,
|
||||
targetNotebookId: data.targetNotebookId || null,
|
||||
frequency: data.frequency || 'manual',
|
||||
tools: data.tools ? JSON.stringify(data.tools) : '[]',
|
||||
maxSteps: data.maxSteps || 10,
|
||||
notifyEmail: data.notifyEmail || false,
|
||||
includeImages: data.includeImages || false,
|
||||
userId: session.user.id,
|
||||
}
|
||||
})
|
||||
@@ -60,6 +67,10 @@ export async function updateAgent(id: string, data: {
|
||||
targetNotebookId?: string | null
|
||||
frequency?: string
|
||||
isEnabled?: boolean
|
||||
tools?: string[]
|
||||
maxSteps?: number
|
||||
notifyEmail?: boolean
|
||||
includeImages?: boolean
|
||||
}) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
@@ -82,6 +93,10 @@ export async function updateAgent(id: string, data: {
|
||||
if (data.targetNotebookId !== undefined) updateData.targetNotebookId = data.targetNotebookId
|
||||
if (data.frequency !== undefined) updateData.frequency = data.frequency
|
||||
if (data.isEnabled !== undefined) updateData.isEnabled = data.isEnabled
|
||||
if (data.tools !== undefined) updateData.tools = JSON.stringify(data.tools)
|
||||
if (data.maxSteps !== undefined) updateData.maxSteps = data.maxSteps
|
||||
if (data.notifyEmail !== undefined) updateData.notifyEmail = data.notifyEmail
|
||||
if (data.includeImages !== undefined) updateData.includeImages = data.includeImages
|
||||
|
||||
const agent = await prisma.agent.update({
|
||||
where: { id },
|
||||
@@ -155,6 +170,7 @@ export async function runAgent(id: string) {
|
||||
}
|
||||
|
||||
try {
|
||||
const { executeAgent } = await import('@/lib/ai/services/agent-executor.service')
|
||||
const result = await executeAgent(id, session.user.id)
|
||||
revalidatePath('/agents')
|
||||
revalidatePath('/')
|
||||
@@ -182,6 +198,16 @@ export async function getAgentActions(agentId: string) {
|
||||
where: { agentId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 20,
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
result: true,
|
||||
log: true,
|
||||
input: true,
|
||||
toolLog: true,
|
||||
tokensUsed: true,
|
||||
createdAt: true,
|
||||
}
|
||||
})
|
||||
return actions
|
||||
} catch (error) {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import prisma from '@/lib/prisma'
|
||||
import { sendEmail } from '@/lib/mail'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { getEmailTemplate } from '@/lib/email-template'
|
||||
|
||||
@@ -42,11 +43,14 @@ export async function forgotPassword(email: string) {
|
||||
"Reset Password"
|
||||
);
|
||||
|
||||
const sysConfig = await getSystemConfig()
|
||||
const emailProvider = (sysConfig.EMAIL_PROVIDER || 'auto') as 'resend' | 'smtp' | 'auto'
|
||||
|
||||
await sendEmail({
|
||||
to: user.email,
|
||||
subject: "Reset your Memento password",
|
||||
html
|
||||
});
|
||||
}, emailProvider);
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
|
||||
111
keep-notes/app/actions/canvas-actions.ts
Normal file
111
keep-notes/app/actions/canvas-actions.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
'use server'
|
||||
|
||||
import { auth } from '@/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { revalidatePath } from 'next/cache'
|
||||
|
||||
export async function saveCanvas(id: string | null, name: string, data: string) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) throw new Error('Unauthorized')
|
||||
|
||||
if (id) {
|
||||
const canvas = await prisma.canvas.update({
|
||||
where: { id, userId: session.user.id },
|
||||
data: { name, data }
|
||||
})
|
||||
revalidatePath('/lab')
|
||||
return { success: true, canvas }
|
||||
} else {
|
||||
const canvas = await prisma.canvas.create({
|
||||
data: {
|
||||
name,
|
||||
data,
|
||||
userId: session.user.id
|
||||
}
|
||||
})
|
||||
revalidatePath('/lab')
|
||||
return { success: true, canvas }
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCanvases() {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) return []
|
||||
|
||||
return prisma.canvas.findMany({
|
||||
where: { userId: session.user.id },
|
||||
orderBy: { createdAt: 'asc' }
|
||||
})
|
||||
}
|
||||
|
||||
export async function getCanvasDetails(id: string) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) return null
|
||||
|
||||
return prisma.canvas.findUnique({
|
||||
where: { id, userId: session.user.id }
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteCanvas(id: string) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) throw new Error('Unauthorized')
|
||||
|
||||
await prisma.canvas.delete({
|
||||
where: { id, userId: session.user.id }
|
||||
})
|
||||
|
||||
revalidatePath('/lab')
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
export async function renameCanvas(id: string, name: string) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) throw new Error('Unauthorized')
|
||||
|
||||
await prisma.canvas.update({
|
||||
where: { id, userId: session.user.id },
|
||||
data: { name }
|
||||
})
|
||||
|
||||
revalidatePath('/lab')
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
export async function createCanvas(lang?: string) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) throw new Error('Unauthorized')
|
||||
|
||||
const count = await prisma.canvas.count({
|
||||
where: { userId: session.user.id }
|
||||
})
|
||||
|
||||
const defaultNames: Record<string, string> = {
|
||||
en: `Space ${count + 1}`,
|
||||
fr: `Espace ${count + 1}`,
|
||||
fa: `فضای ${count + 1}`,
|
||||
es: `Espacio ${count + 1}`,
|
||||
de: `Bereich ${count + 1}`,
|
||||
it: `Spazio ${count + 1}`,
|
||||
pt: `Espaço ${count + 1}`,
|
||||
ru: `Пространство ${count + 1}`,
|
||||
ja: `スペース ${count + 1}`,
|
||||
ko: `공간 ${count + 1}`,
|
||||
zh: `空间 ${count + 1}`,
|
||||
ar: `مساحة ${count + 1}`,
|
||||
hi: `स्थान ${count + 1}`,
|
||||
nl: `Ruimte ${count + 1}`,
|
||||
pl: `Przestrzeń ${count + 1}`,
|
||||
}
|
||||
|
||||
const newCanvas = await prisma.canvas.create({
|
||||
data: {
|
||||
name: defaultNames[lang || 'en'] || defaultNames.en,
|
||||
data: JSON.stringify({}),
|
||||
userId: session.user.id
|
||||
}
|
||||
})
|
||||
|
||||
revalidatePath('/lab')
|
||||
return newCanvas
|
||||
}
|
||||
74
keep-notes/app/actions/chat-actions.ts
Normal file
74
keep-notes/app/actions/chat-actions.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
'use server'
|
||||
|
||||
import { chatService } from '@/lib/ai/services'
|
||||
import { auth } from '@/auth'
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
/**
|
||||
* Create a new empty conversation and return its id.
|
||||
* Called before streaming so the client knows the conversationId upfront.
|
||||
*/
|
||||
export async function createConversation(title: string, notebookId?: string) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) throw new Error('Unauthorized')
|
||||
|
||||
const conversation = await prisma.conversation.create({
|
||||
data: {
|
||||
userId: session.user.id,
|
||||
notebookId: notebookId || null,
|
||||
title: title.substring(0, 80) + (title.length > 80 ? '...' : ''),
|
||||
},
|
||||
})
|
||||
|
||||
revalidatePath('/chat')
|
||||
return { id: conversation.id, title: conversation.title }
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use the streaming API route /api/chat instead.
|
||||
* Kept for backward compatibility with the debug route.
|
||||
*/
|
||||
export async function sendChatMessage(
|
||||
message: string,
|
||||
conversationId?: string,
|
||||
notebookId?: string
|
||||
) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) throw new Error('Unauthorized')
|
||||
|
||||
try {
|
||||
const result = await chatService.chat(message, conversationId, notebookId)
|
||||
revalidatePath('/chat')
|
||||
return { success: true, ...result }
|
||||
} catch (error: any) {
|
||||
console.error('[ChatAction] Error:', error)
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
}
|
||||
|
||||
export async function getConversations() {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) return []
|
||||
|
||||
return chatService.listConversations(session.user.id)
|
||||
}
|
||||
|
||||
export async function getConversationDetails(id: string) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) return null
|
||||
|
||||
return chatService.getHistory(id)
|
||||
}
|
||||
|
||||
export async function deleteConversation(id: string) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) throw new Error('Unauthorized')
|
||||
|
||||
await prisma.conversation.delete({
|
||||
where: { id, userId: session.user.id }
|
||||
})
|
||||
|
||||
revalidatePath('/chat')
|
||||
return { success: true }
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { getAIProvider } from '@/lib/ai/factory'
|
||||
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'
|
||||
import { cleanupNoteImages, parseImageUrls, deleteImageFileSafely } from '@/lib/image-cleanup'
|
||||
|
||||
/**
|
||||
* Champs sélectionnés pour les listes de notes (sans embedding pour économiser ~6KB/note).
|
||||
@@ -20,6 +21,7 @@ const NOTE_LIST_SELECT = {
|
||||
color: true,
|
||||
isPinned: true,
|
||||
isArchived: true,
|
||||
trashedAt: true,
|
||||
type: true,
|
||||
dismissedFromRecent: true,
|
||||
checkItems: true,
|
||||
@@ -219,6 +221,7 @@ export async function getNotes(includeArchived = false) {
|
||||
const notes = await prisma.note.findMany({
|
||||
where: {
|
||||
userId: session.user.id,
|
||||
trashedAt: null,
|
||||
...(includeArchived ? {} : { isArchived: false }),
|
||||
},
|
||||
select: NOTE_LIST_SELECT,
|
||||
@@ -245,6 +248,7 @@ export async function getNotesWithReminders() {
|
||||
const notes = await prisma.note.findMany({
|
||||
where: {
|
||||
userId: session.user.id,
|
||||
trashedAt: null,
|
||||
isArchived: false,
|
||||
reminder: { not: null }
|
||||
},
|
||||
@@ -286,7 +290,8 @@ export async function getArchivedNotes() {
|
||||
const notes = await prisma.note.findMany({
|
||||
where: {
|
||||
userId: session.user.id,
|
||||
isArchived: true
|
||||
isArchived: true,
|
||||
trashedAt: null
|
||||
},
|
||||
select: NOTE_LIST_SELECT,
|
||||
orderBy: { updatedAt: 'desc' }
|
||||
@@ -321,6 +326,7 @@ export async function searchNotes(query: string, useSemantic: boolean = false, n
|
||||
where: {
|
||||
userId: session.user.id,
|
||||
isArchived: false,
|
||||
trashedAt: null,
|
||||
OR: [
|
||||
{ title: { contains: query } },
|
||||
{ content: { contains: query } },
|
||||
@@ -349,6 +355,7 @@ async function semanticSearch(query: string, userId: string, notebookId?: string
|
||||
where: {
|
||||
userId: userId,
|
||||
isArchived: false,
|
||||
trashedAt: null,
|
||||
...(notebookId !== undefined ? { notebookId } : {})
|
||||
},
|
||||
include: { noteEmbedding: true }
|
||||
@@ -650,17 +657,16 @@ export async function updateNote(id: string, data: {
|
||||
}
|
||||
}
|
||||
|
||||
// Delete a note
|
||||
// Soft-delete a note (move to trash)
|
||||
export async function deleteNote(id: string) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) throw new Error('Unauthorized');
|
||||
|
||||
try {
|
||||
await prisma.note.delete({ where: { id, userId: session.user.id } })
|
||||
|
||||
// Sync labels with empty array to trigger cleanup of any orphans
|
||||
// The syncLabels function will scan all remaining notes and clean up unused labels
|
||||
await syncLabels(session.user.id, [])
|
||||
await prisma.note.update({
|
||||
where: { id, userId: session.user.id },
|
||||
data: { trashedAt: new Date() }
|
||||
})
|
||||
|
||||
revalidatePath('/')
|
||||
return { success: true }
|
||||
@@ -670,6 +676,192 @@ export async function deleteNote(id: string) {
|
||||
}
|
||||
}
|
||||
|
||||
// Trash actions
|
||||
export async function trashNote(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: { trashedAt: new Date() }
|
||||
})
|
||||
revalidatePath('/')
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
console.error('Error trashing note:', error)
|
||||
throw new Error('Failed to trash note')
|
||||
}
|
||||
}
|
||||
|
||||
export async function restoreNote(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: { trashedAt: null }
|
||||
})
|
||||
revalidatePath('/')
|
||||
revalidatePath('/trash')
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
console.error('Error restoring note:', error)
|
||||
throw new Error('Failed to restore note')
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTrashedNotes() {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return [];
|
||||
|
||||
try {
|
||||
const notes = await prisma.note.findMany({
|
||||
where: {
|
||||
userId: session.user.id,
|
||||
trashedAt: { not: null }
|
||||
},
|
||||
select: NOTE_LIST_SELECT,
|
||||
orderBy: { trashedAt: 'desc' }
|
||||
})
|
||||
|
||||
return notes.map(parseNote)
|
||||
} catch (error) {
|
||||
console.error('Error fetching trashed notes:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export async function permanentDeleteNote(id: string) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) throw new Error('Unauthorized');
|
||||
|
||||
try {
|
||||
// Fetch images before deleting so we can clean up files
|
||||
const note = await prisma.note.findUnique({
|
||||
where: { id, userId: session.user.id },
|
||||
select: { images: true }
|
||||
})
|
||||
const imageUrls = parseImageUrls(note?.images ?? null)
|
||||
|
||||
await prisma.note.delete({ where: { id, userId: session.user.id } })
|
||||
|
||||
// Clean up orphaned image files (safe: skips if referenced by other notes)
|
||||
if (imageUrls.length > 0) {
|
||||
await cleanupNoteImages(id, imageUrls)
|
||||
}
|
||||
|
||||
await syncLabels(session.user.id, [])
|
||||
revalidatePath('/trash')
|
||||
revalidatePath('/')
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
console.error('Error permanently deleting note:', error)
|
||||
throw new Error('Failed to permanently delete note')
|
||||
}
|
||||
}
|
||||
|
||||
export async function emptyTrash() {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) throw new Error('Unauthorized');
|
||||
|
||||
try {
|
||||
// Fetch trashed notes with images before deleting
|
||||
const trashedNotes = await prisma.note.findMany({
|
||||
where: {
|
||||
userId: session.user.id,
|
||||
trashedAt: { not: null }
|
||||
},
|
||||
select: { id: true, images: true }
|
||||
})
|
||||
|
||||
await prisma.note.deleteMany({
|
||||
where: {
|
||||
userId: session.user.id,
|
||||
trashedAt: { not: null }
|
||||
}
|
||||
})
|
||||
|
||||
// Clean up image files for all deleted notes
|
||||
for (const note of trashedNotes) {
|
||||
const imageUrls = parseImageUrls(note.images)
|
||||
if (imageUrls.length > 0) {
|
||||
await cleanupNoteImages(note.id, imageUrls)
|
||||
}
|
||||
}
|
||||
|
||||
await syncLabels(session.user.id, [])
|
||||
revalidatePath('/trash')
|
||||
revalidatePath('/')
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
console.error('Error emptying trash:', error)
|
||||
throw new Error('Failed to empty trash')
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeImageFromNote(noteId: string, imageIndex: number) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) throw new Error('Unauthorized')
|
||||
|
||||
try {
|
||||
const note = await prisma.note.findUnique({
|
||||
where: { id: noteId, userId: session.user.id },
|
||||
select: { images: true },
|
||||
})
|
||||
if (!note) throw new Error('Note not found')
|
||||
|
||||
const imageUrls = parseImageUrls(note.images)
|
||||
if (imageIndex < 0 || imageIndex >= imageUrls.length) throw new Error('Invalid image index')
|
||||
|
||||
const removedUrl = imageUrls[imageIndex]
|
||||
const newImages = imageUrls.filter((_, i) => i !== imageIndex)
|
||||
|
||||
await prisma.note.update({
|
||||
where: { id: noteId },
|
||||
data: { images: newImages.length > 0 ? JSON.stringify(newImages) : null },
|
||||
})
|
||||
|
||||
// Clean up file if no other note references it
|
||||
await deleteImageFileSafely(removedUrl, noteId)
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
console.error('Error removing image:', error)
|
||||
throw new Error('Failed to remove image')
|
||||
}
|
||||
}
|
||||
|
||||
export async function cleanupOrphanedImages(imageUrls: string[], noteId: string) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) return
|
||||
|
||||
try {
|
||||
for (const url of imageUrls) {
|
||||
await deleteImageFileSafely(url, noteId)
|
||||
}
|
||||
} catch {
|
||||
// Silent — best-effort cleanup
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTrashCount() {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return 0;
|
||||
|
||||
try {
|
||||
return await prisma.note.count({
|
||||
where: {
|
||||
userId: session.user.id,
|
||||
trashedAt: { not: null }
|
||||
}
|
||||
})
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle functions
|
||||
export async function togglePin(id: string, isPinned: boolean) { return updateNote(id, { isPinned }) }
|
||||
export async function toggleArchive(id: string, isArchived: boolean) { return updateNote(id, { isArchived }) }
|
||||
@@ -710,7 +902,7 @@ export async function reorderNotes(draggedId: string, targetId: string) {
|
||||
const targetNote = await prisma.note.findUnique({ where: { id: targetId, userId: session.user.id } })
|
||||
if (!draggedNote || !targetNote) throw new Error('Notes not found')
|
||||
const allNotes = await prisma.note.findMany({
|
||||
where: { userId: session.user.id, isPinned: draggedNote.isPinned, isArchived: false },
|
||||
where: { userId: session.user.id, isPinned: draggedNote.isPinned, isArchived: false, trashedAt: null },
|
||||
orderBy: { order: 'asc' }
|
||||
})
|
||||
const reorderedNotes = allNotes.filter((n: any) => n.id !== draggedId)
|
||||
@@ -865,11 +1057,12 @@ export async function syncAllEmbeddings() {
|
||||
const userId = session.user.id;
|
||||
let updatedCount = 0;
|
||||
try {
|
||||
const notesToSync = await prisma.note.findMany({
|
||||
where: {
|
||||
userId,
|
||||
const notesToSync = await prisma.note.findMany({
|
||||
where: {
|
||||
userId,
|
||||
trashedAt: null,
|
||||
noteEmbedding: { is: null }
|
||||
}
|
||||
}
|
||||
})
|
||||
const provider = getAIProvider(await getSystemConfig());
|
||||
for (const note of notesToSync) {
|
||||
@@ -905,6 +1098,7 @@ export async function getAllNotes(includeArchived = false) {
|
||||
prisma.note.findMany({
|
||||
where: {
|
||||
userId,
|
||||
trashedAt: null,
|
||||
...(includeArchived ? {} : { isArchived: false }),
|
||||
},
|
||||
select: NOTE_LIST_SELECT,
|
||||
@@ -923,6 +1117,7 @@ export async function getAllNotes(includeArchived = false) {
|
||||
const sharedNotes = acceptedShares
|
||||
.map(share => share.note)
|
||||
.filter(note => includeArchived || !note.isArchived)
|
||||
.map(note => ({ ...note, _isShared: true }))
|
||||
|
||||
return [...ownNotes.map(parseNote), ...sharedNotes.map(parseNote)]
|
||||
} catch (error) {
|
||||
@@ -944,6 +1139,7 @@ export async function getPinnedNotes(notebookId?: string) {
|
||||
userId: userId,
|
||||
isPinned: true,
|
||||
isArchived: false,
|
||||
trashedAt: null,
|
||||
...(notebookId !== undefined ? { notebookId } : {})
|
||||
},
|
||||
orderBy: [
|
||||
@@ -977,6 +1173,7 @@ export async function getRecentNotes(limit: number = 3) {
|
||||
userId: userId,
|
||||
contentUpdatedAt: { gte: sevenDaysAgo },
|
||||
isArchived: false,
|
||||
trashedAt: null,
|
||||
dismissedFromRecent: false // Filter out dismissed notes
|
||||
},
|
||||
orderBy: { contentUpdatedAt: 'desc' },
|
||||
@@ -1118,8 +1315,20 @@ export async function getNoteCollaborators(noteId: string) {
|
||||
throw new Error('Note not found')
|
||||
}
|
||||
|
||||
// Owner can always see collaborators
|
||||
// Shared users can also see collaborators if they have accepted access
|
||||
if (note.userId !== session.user.id) {
|
||||
throw new Error('You do not have access to this note')
|
||||
const share = await prisma.noteShare.findUnique({
|
||||
where: {
|
||||
noteId_userId: {
|
||||
noteId,
|
||||
userId: session.user.id
|
||||
}
|
||||
}
|
||||
})
|
||||
if (!share || share.status !== 'accepted') {
|
||||
throw new Error('You do not have access to this note')
|
||||
}
|
||||
}
|
||||
|
||||
// Get all users who have been shared this note (any status)
|
||||
|
||||
@@ -6,6 +6,7 @@ import { revalidatePath, updateTag } from 'next/cache'
|
||||
|
||||
export type UserSettingsData = {
|
||||
theme?: 'light' | 'dark' | 'auto' | 'sepia' | 'midnight' | 'blue'
|
||||
cardSizeMode?: 'variable' | 'uniform'
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -48,11 +49,12 @@ const getCachedUserSettings = unstable_cache(
|
||||
try {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { theme: true }
|
||||
select: { theme: true, cardSizeMode: true }
|
||||
})
|
||||
|
||||
return {
|
||||
theme: (user?.theme || 'light') as 'light' | 'dark' | 'auto' | 'sepia' | 'midnight' | 'blue'
|
||||
theme: (user?.theme || 'light') as 'light' | 'dark' | 'auto' | 'sepia' | 'midnight' | 'blue',
|
||||
cardSizeMode: (user?.cardSizeMode || 'variable') as 'variable' | 'uniform'
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error getting user settings:', error)
|
||||
@@ -75,7 +77,8 @@ export async function getUserSettings(userId?: string) {
|
||||
|
||||
if (!id) {
|
||||
return {
|
||||
theme: 'light' as const
|
||||
theme: 'light' as const,
|
||||
cardSizeMode: 'variable' as const
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -25,18 +25,26 @@ export async function POST(req: NextRequest) {
|
||||
const config = await getSystemConfig()
|
||||
const provider = getAIProvider(config)
|
||||
|
||||
// Détecter la langue du contenu (simple détection basée sur les caractères)
|
||||
// Détecter la langue du contenu (simple détection basée sur les caractères et mots)
|
||||
const hasNonLatinChars = /[\u0400-\u04FF\u0600-\u06FF\u4E00-\u9FFF\u0E00-\u0E7F]/.test(content)
|
||||
const isPersian = /[\u0600-\u06FF]/.test(content)
|
||||
const isChinese = /[\u4E00-\u9FFF]/.test(content)
|
||||
const isRussian = /[\u0400-\u04FF]/.test(content)
|
||||
const isArabic = /[\u0600-\u06FF]/.test(content)
|
||||
|
||||
// Détection du français par des mots et caractères caractéristiques
|
||||
const frenchWords = /\b(le|la|les|un|une|des|et|ou|mais|donc|pour|dans|sur|avec|sans|très|plus|moins|tout|tous|toute|toutes|ce|cette|ces|mon|ma|mes|ton|ta|tes|son|sa|ses|notre|nos|votre|vos|leur|leurs|je|tu|il|elle|nous|vous|ils|elles|est|sont|été|être|avoir|faire|aller|venir|voir|savoir|pouvoir|vouloir|falloir|comme|que|qui|dont|où|quand|pourquoi|comment|quel|quelle|quels|quelles)\b/i
|
||||
const frenchAccents = /[éèêàâôûùïüç]/i
|
||||
const isFrench = frenchWords.test(content) || frenchAccents.test(content)
|
||||
|
||||
// Déterminer la langue du prompt système
|
||||
let promptLanguage = 'en'
|
||||
let responseLanguage = 'English'
|
||||
|
||||
if (isPersian) {
|
||||
if (isFrench) {
|
||||
promptLanguage = 'fr' // Français
|
||||
responseLanguage = 'French'
|
||||
} else if (isPersian) {
|
||||
promptLanguage = 'fa' // Persan
|
||||
responseLanguage = 'Persian'
|
||||
} else if (isChinese) {
|
||||
|
||||
33
keep-notes/app/api/canvas/route.ts
Normal file
33
keep-notes/app/api/canvas/route.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { auth } from '@/auth'
|
||||
import { revalidatePath } from 'next/cache'
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
|
||||
const body = await req.json()
|
||||
const { id, name, data } = body
|
||||
|
||||
if (id) {
|
||||
const canvas = await prisma.canvas.update({
|
||||
where: { id, userId: session.user.id },
|
||||
data: { name, data }
|
||||
})
|
||||
return NextResponse.json({ success: true, canvas })
|
||||
} else {
|
||||
const canvas = await prisma.canvas.create({
|
||||
data: {
|
||||
name,
|
||||
data,
|
||||
userId: session.user.id
|
||||
}
|
||||
})
|
||||
return NextResponse.json({ success: true, canvas })
|
||||
}
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
297
keep-notes/app/api/chat/route.ts
Normal file
297
keep-notes/app/api/chat/route.ts
Normal file
@@ -0,0 +1,297 @@
|
||||
import { streamText, UIMessage } from 'ai'
|
||||
import { getChatProvider } from '@/lib/ai/factory'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
import { semanticSearchService } from '@/lib/ai/services/semantic-search.service'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { auth } from '@/auth'
|
||||
import { loadTranslations, getTranslationValue, SupportedLanguage } from '@/lib/i18n'
|
||||
|
||||
export const maxDuration = 60
|
||||
|
||||
/**
|
||||
* Extract text content from a UIMessage's parts array.
|
||||
*/
|
||||
function extractTextFromUIMessage(msg: { parts?: Array<{ type: string; text?: string }>; content?: string }): string {
|
||||
if (typeof msg.content === 'string') return msg.content
|
||||
if (msg.parts && Array.isArray(msg.parts)) {
|
||||
return msg.parts
|
||||
.filter((p) => p.type === 'text' && typeof p.text === 'string')
|
||||
.map((p) => p.text!)
|
||||
.join('')
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an array of UIMessages (from the client) to CoreMessage[] for streamText.
|
||||
*/
|
||||
function toCoreMessages(uiMessages: UIMessage[]): Array<{ role: 'user' | 'assistant'; content: string }> {
|
||||
return uiMessages
|
||||
.filter((m) => m.role === 'user' || m.role === 'assistant')
|
||||
.map((m) => ({
|
||||
role: m.role as 'user' | 'assistant',
|
||||
content: extractTextFromUIMessage(m),
|
||||
}))
|
||||
.filter((m) => m.content.length > 0)
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
// 1. Auth check
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return new Response('Unauthorized', { status: 401 })
|
||||
}
|
||||
const userId = session.user.id
|
||||
|
||||
// 2. Parse request body — messages arrive as UIMessage[] from DefaultChatTransport
|
||||
const body = await req.json()
|
||||
const { messages: rawMessages, conversationId, notebookId, language } = body as {
|
||||
messages: UIMessage[]
|
||||
conversationId?: string
|
||||
notebookId?: string
|
||||
language?: string
|
||||
}
|
||||
|
||||
// Convert UIMessages to CoreMessages for streamText
|
||||
const incomingMessages = toCoreMessages(rawMessages)
|
||||
|
||||
// 3. Manage conversation (create or fetch)
|
||||
let conversation: { id: string; messages: Array<{ role: string; content: string }> }
|
||||
|
||||
if (conversationId) {
|
||||
const existing = await prisma.conversation.findUnique({
|
||||
where: { id: conversationId, userId },
|
||||
include: { messages: { orderBy: { createdAt: 'asc' } } },
|
||||
})
|
||||
if (!existing) {
|
||||
return new Response('Conversation not found', { status: 404 })
|
||||
}
|
||||
conversation = existing
|
||||
} else {
|
||||
const userMessage = incomingMessages[incomingMessages.length - 1]?.content || 'New conversation'
|
||||
const created = await prisma.conversation.create({
|
||||
data: {
|
||||
userId,
|
||||
notebookId: notebookId || null,
|
||||
title: userMessage.substring(0, 50) + (userMessage.length > 50 ? '...' : ''),
|
||||
},
|
||||
include: { messages: true },
|
||||
})
|
||||
conversation = created
|
||||
}
|
||||
|
||||
// 4. RAG retrieval
|
||||
const currentMessage = incomingMessages[incomingMessages.length - 1]?.content || ''
|
||||
|
||||
// Load translations for the requested language
|
||||
const lang = (language || 'en') as SupportedLanguage
|
||||
const translations = await loadTranslations(lang)
|
||||
const untitledText = getTranslationValue(translations, 'notes.untitled') || 'Untitled'
|
||||
|
||||
// If a notebook is selected, fetch its recent notes directly as context
|
||||
// This ensures the AI always has access to the notebook content,
|
||||
// even for vague queries like "what's in this notebook?"
|
||||
let notebookContext = ''
|
||||
if (notebookId) {
|
||||
const notebookNotes = await prisma.note.findMany({
|
||||
where: {
|
||||
notebookId,
|
||||
userId,
|
||||
trashedAt: null,
|
||||
},
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
take: 20,
|
||||
select: { id: true, title: true, content: true, updatedAt: true },
|
||||
})
|
||||
if (notebookNotes.length > 0) {
|
||||
notebookContext = notebookNotes
|
||||
.map(n => `NOTE [${n.title || untitledText}] (updated ${n.updatedAt.toLocaleDateString()}):\n${(n.content || '').substring(0, 1500)}`)
|
||||
.join('\n\n---\n\n')
|
||||
}
|
||||
}
|
||||
|
||||
// Also run semantic search for the specific query
|
||||
const searchResults = await semanticSearchService.search(currentMessage, {
|
||||
notebookId,
|
||||
limit: notebookId ? 10 : 5,
|
||||
threshold: notebookId ? 0.3 : 0.5,
|
||||
defaultTitle: untitledText,
|
||||
})
|
||||
|
||||
const searchNotes = searchResults
|
||||
.map((r) => `NOTE [${r.title || untitledText}]: ${r.content}`)
|
||||
.join('\n\n---\n\n')
|
||||
|
||||
// Combine: full notebook context + semantic search results (deduplicated)
|
||||
const contextNotes = [notebookContext, searchNotes].filter(Boolean).join('\n\n---\n\n')
|
||||
|
||||
// 5. System prompt synthesis with RAG context
|
||||
// Language-aware prompts to avoid forcing French responses
|
||||
// Note: lang is already declared above when loading translations
|
||||
const promptLang: Record<string, { contextWithNotes: string; contextNoNotes: string; system: string }> = {
|
||||
en: {
|
||||
contextWithNotes: `## User's notes\n\n${contextNotes}\n\nWhen using info from the notes above, cite the source note title in parentheses, e.g.: "Deployment is done via Docker (💻 Development Guide)". Don't copy word for word — rephrase. If the notes don't cover the topic, say so and supplement with your general knowledge.`,
|
||||
contextNoNotes: "No relevant notes found for this question. Answer with your general knowledge.",
|
||||
system: `You are the AI assistant of Memento. The user asks you questions about their projects, technical docs, and notes. You must respond in a structured and helpful way.
|
||||
|
||||
## Format rules
|
||||
- Use markdown freely: headings (##, ###), lists, code blocks, bold, tables — anything that makes the response readable.
|
||||
- Structure your response with sections for technical questions or complex topics.
|
||||
- For simple, short questions, a direct paragraph is enough.
|
||||
|
||||
## Tone rules
|
||||
- Natural tone, neither corporate nor too casual.
|
||||
- No unnecessary intro phrases ("Here's what I found", "Based on your notes"). Answer directly.
|
||||
- No upsell questions at the end ("Would you like me to...", "Do you want..."). If you have useful additional info, just give it.
|
||||
- If the user says "Momento" they mean Memento (this app).`,
|
||||
},
|
||||
fr: {
|
||||
contextWithNotes: `## Notes de l'utilisateur\n\n${contextNotes}\n\nQuand tu utilises une info venant des notes ci-dessus, cite le titre de la note source entre parenthèses, ex: "Le déploiement se fait via Docker (💻 Development Guide)". Ne recopie pas mot pour mot — reformule. Si les notes ne couvrent pas le sujet, dis-le et complète avec tes connaissances générales.`,
|
||||
contextNoNotes: "Aucune note pertinente trouvée pour cette question. Réponds avec tes connaissances générales.",
|
||||
system: `Tu es l'assistant IA de Memento. L'utilisateur te pose des questions sur ses projets, sa doc technique, ses notes. Tu dois répondre de façon structurée et utile.
|
||||
|
||||
## Règles de format
|
||||
- Utilise le markdown librement : titres (##, ###), listes, code blocks, gras, tables — tout ce qui rend la réponse lisible.
|
||||
- Structure ta réponse avec des sections quand c'est une question technique ou un sujet complexe.
|
||||
- Pour les questions simples et courtes, un paragraphe direct suffit.
|
||||
|
||||
## Règles de ton
|
||||
- Ton naturel, ni corporate ni trop familier.
|
||||
- Pas de phrase d'intro inutile ("Voici ce que j'ai trouvé", "Basé sur vos notes"). Réponds directement.
|
||||
- Pas de question upsell à la fin ("Souhaitez-vous que je...", "Acceptez-vous que..."). Si tu as une info complémentaire utile, donne-la.
|
||||
- Si l'utilisateur dit "Momento" il parle de Memento (cette application).`,
|
||||
},
|
||||
fa: {
|
||||
contextWithNotes: `## یادداشتهای کاربر\n\n${contextNotes}\n\nهنگام استفاده از اطلاعات یادداشتهای بالا، عنوان یادداشت منبع را در پرانتز ذکر کنید. کپی نکنید — بازنویسی کنید. اگر یادداشتها موضوع را پوشش نمیدهند، بگویید و با دانش عمومی خود تکمیل کنید.`,
|
||||
contextNoNotes: "هیچ یادداشت مرتبطی برای این سؤال یافت نشد. با دانش عمومی خود پاسخ دهید.",
|
||||
system: `شما دستیار هوش مصنوعی Memento هستید. کاربر از شما درباره پروژهها، مستندات فنی و یادداشتهایش سؤال میکند. باید به شکلی ساختاریافته و مفید پاسخ دهید.
|
||||
|
||||
## قوانین قالببندی
|
||||
- از مارکداون آزادانه استفاده کنید: عناوین (##, ###)، لیستها، بلوکهای کد، پررنگ، جداول.
|
||||
- برای سؤالات فنی یا موضوعات پیچیده، پاسخ خود را بخشبندی کنید.
|
||||
- برای سؤالات ساده و کوتاه، یک پاراگراف مستقیم کافی است.
|
||||
|
||||
## قوانین لحن
|
||||
- لحن طبیعی، نه رسمی بیش از حد و نه خیلی غیررسمی.
|
||||
- بدون جمله مقدمه اضافی. مستقیم پاسخ دهید.
|
||||
- بدون سؤال فروشی در انتها. اگر اطلاعات تکمیلی مفید دارید، مستقیم بدهید.
|
||||
- اگر کاربر "Momento" میگوید، منظورش Memento (این برنامه) است.`,
|
||||
},
|
||||
es: {
|
||||
contextWithNotes: `## Notas del usuario\n\n${contextNotes}\n\nCuando uses información de las notas anteriores, cita el título de la nota fuente entre paréntesis. No copies palabra por palabra — reformula. Si las notas no cubren el tema, dilo y complementa con tu conocimiento general.`,
|
||||
contextNoNotes: "No se encontraron notas relevantes para esta pregunta. Responde con tu conocimiento general.",
|
||||
system: `Eres el asistente de IA de Memento. El usuario te hace preguntas sobre sus proyectos, documentación técnica y notas. Debes responder de forma estructurada y útil.
|
||||
|
||||
## Reglas de formato
|
||||
- Usa markdown libremente: títulos (##, ###), listas, bloques de código, negritas, tablas.
|
||||
- Estructura tu respuesta con secciones para preguntas técnicas o temas complejos.
|
||||
- Para preguntas simples y cortas, un párrafo directo es suficiente.
|
||||
|
||||
## Reglas de tono
|
||||
- Tono natural, ni corporativo ni demasiado informal.
|
||||
- Sin frases de introducción innecesarias. Responde directamente.
|
||||
- Sin preguntas de venta al final. Si tienes información complementaria útil, dala directamente.`,
|
||||
},
|
||||
de: {
|
||||
contextWithNotes: `## Notizen des Benutzers\n\n${contextNotes}\n\nWenn du Infos aus den obigen Notizen verwendest, zitiere den Titel der Quellnotiz in Klammern. Nicht Wort für Wort kopieren — umformulieren. Wenn die Notizen das Thema nicht abdecken, sag es und ergänze mit deinem Allgemeinwissen.`,
|
||||
contextNoNotes: "Keine relevanten Notizen für diese Frage gefunden. Antworte mit deinem Allgemeinwissen.",
|
||||
system: `Du bist der KI-Assistent von Memento. Der Benutzer stellt dir Fragen zu seinen Projekten, technischen Dokumentationen und Notizen. Du musst strukturiert und hilfreich antworten.
|
||||
|
||||
## Formatregeln
|
||||
- Verwende Markdown frei: Überschriften (##, ###), Listen, Code-Blöcke, Fettdruck, Tabellen.
|
||||
- Strukturiere deine Antwort mit Abschnitten bei technischen Fragen oder komplexen Themen.
|
||||
- Bei einfachen, kurzen Fragen reicht ein direkter Absatz.
|
||||
|
||||
## Tonregeln
|
||||
- Natürlicher Ton, weder zu geschäftsmäßig noch zu umgangssprachlich.
|
||||
- Keine unnötigen Einleitungssätze. Antworte direkt.
|
||||
- Keine Upsell-Fragen am Ende. Gib nützliche Zusatzinfos einfach direkt.`,
|
||||
},
|
||||
it: {
|
||||
contextWithNotes: `## Note dell'utente\n\n${contextNotes}\n\nQuando usi informazioni dalle note sopra, cita il titolo della nota fonte tra parentesi. Non copiare parola per parola — riformula. Se le note non coprono l'argomento, dillo e integra con la tua conoscenza generale.`,
|
||||
contextNoNotes: "Nessuna nota rilevante trovata per questa domanda. Rispondi con la tua conoscenza generale.",
|
||||
system: `Sei l'assistente IA di Memento. L'utente ti fa domande sui suoi progetti, documentazione tecnica e note. Devi rispondere in modo strutturato e utile.
|
||||
|
||||
## Regole di formato
|
||||
- Usa markdown liberamente: titoli (##, ###), elenchi, blocchi di codice, grassetto, tabelle.
|
||||
- Struttura la risposta con sezioni per domande tecniche o argomenti complessi.
|
||||
- Per domande semplici e brevi, un paragrafo diretto basta.
|
||||
|
||||
## Regole di tono
|
||||
- Tono naturale, né aziendale né troppo informale.
|
||||
- Nessuna frase introduttiva non necessaria. Rispondi direttamente.
|
||||
- Nessuna domanda di upsell alla fine. Se hai informazioni aggiuntive utili, dalle direttamente.`,
|
||||
},
|
||||
}
|
||||
|
||||
// Fallback to English if language not supported
|
||||
const prompts = promptLang[lang] || promptLang.en
|
||||
const contextBlock = contextNotes.length > 0
|
||||
? prompts.contextWithNotes
|
||||
: prompts.contextNoNotes
|
||||
|
||||
const systemPrompt = `${prompts.system}
|
||||
|
||||
${contextBlock}
|
||||
|
||||
${lang === 'en' ? 'Respond in the user\'s language.' : lang === 'fr' ? 'Réponds dans la langue de l\'utilisateur.' : 'Respond in the user\'s language.'}`
|
||||
|
||||
// 6. Build message history from DB + current messages
|
||||
const dbHistory = conversation.messages.map((m: { role: string; content: string }) => ({
|
||||
role: m.role as 'user' | 'assistant' | 'system',
|
||||
content: m.content,
|
||||
}))
|
||||
|
||||
// Only add the current user message if it's not already in DB history
|
||||
const lastIncoming = incomingMessages[incomingMessages.length - 1]
|
||||
const currentDbMessage = dbHistory[dbHistory.length - 1]
|
||||
const isNewMessage =
|
||||
lastIncoming &&
|
||||
(!currentDbMessage ||
|
||||
currentDbMessage.role !== 'user' ||
|
||||
currentDbMessage.content !== lastIncoming.content)
|
||||
|
||||
const allMessages: Array<{ role: 'user' | 'assistant' | 'system'; content: string }> = isNewMessage
|
||||
? [...dbHistory, { role: lastIncoming.role, content: lastIncoming.content }]
|
||||
: dbHistory
|
||||
|
||||
// 7. Get chat provider model
|
||||
const config = await getSystemConfig()
|
||||
const provider = getChatProvider(config)
|
||||
const model = provider.getModel()
|
||||
|
||||
// 8. Save user message to DB before streaming
|
||||
if (isNewMessage && lastIncoming) {
|
||||
await prisma.chatMessage.create({
|
||||
data: {
|
||||
conversationId: conversation.id,
|
||||
role: 'user',
|
||||
content: lastIncoming.content,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 9. Stream response
|
||||
const result = streamText({
|
||||
model,
|
||||
system: systemPrompt,
|
||||
messages: allMessages,
|
||||
async onFinish({ text }) {
|
||||
// Save assistant message to DB after streaming completes
|
||||
await prisma.chatMessage.create({
|
||||
data: {
|
||||
conversationId: conversation.id,
|
||||
role: 'assistant',
|
||||
content: text,
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
// 10. Return streaming response with conversation ID header
|
||||
return result.toUIMessageStreamResponse({
|
||||
headers: {
|
||||
'X-Conversation-Id': conversation.id,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -15,6 +15,7 @@ export async function POST(request: Request) {
|
||||
},
|
||||
isReminderDone: false,
|
||||
isArchived: false, // Optional: exclude archived notes
|
||||
trashedAt: null, // Exclude trashed notes
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
|
||||
29
keep-notes/app/api/debug/test-chat/route.ts
Normal file
29
keep-notes/app/api/debug/test-chat/route.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { chatService } from '@/lib/ai/services/chat.service';
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
console.log("TEST ROUTE INCOMING BODY:", body);
|
||||
|
||||
// Simulate what the server action does
|
||||
const result = await chatService.chat(body.message, body.conversationId, body.notebookId);
|
||||
|
||||
return NextResponse.json({ success: true, result });
|
||||
} catch (err: any) {
|
||||
console.error("====== TEST ROUTE CHAT ERROR ======");
|
||||
console.error("NAME:", err.name);
|
||||
console.error("MSG:", err.message);
|
||||
if (err.cause) console.error("CAUSE:", JSON.stringify(err.cause, null, 2));
|
||||
if (err.data) console.error("DATA:", JSON.stringify(err.data, null, 2));
|
||||
if (err.stack) console.error("STACK:", err.stack);
|
||||
console.error("===================================");
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: err.message,
|
||||
name: err.name,
|
||||
cause: err.cause,
|
||||
data: err.data
|
||||
}, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -30,10 +30,20 @@ export async function GET(
|
||||
}
|
||||
|
||||
if (note.userId !== session.user.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Forbidden' },
|
||||
{ status: 403 }
|
||||
)
|
||||
const share = await prisma.noteShare.findUnique({
|
||||
where: {
|
||||
noteId_userId: {
|
||||
noteId: note.id,
|
||||
userId: session.user.id
|
||||
}
|
||||
}
|
||||
})
|
||||
if (!share || share.status !== 'accepted') {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Forbidden' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
@@ -92,11 +102,29 @@ export async function PUT(
|
||||
if ('labels' in body) {
|
||||
updateData.labels = body.labels ?? null
|
||||
}
|
||||
updateData.updatedAt = new Date()
|
||||
|
||||
// Only update if data actually changed
|
||||
const hasChanges = Object.keys(updateData).some((key) => {
|
||||
const newValue = updateData[key]
|
||||
const oldValue = (existingNote as any)[key]
|
||||
// Handle arrays/objects by comparing JSON
|
||||
if (typeof newValue === 'object' && newValue !== null) {
|
||||
return JSON.stringify(newValue) !== JSON.stringify(oldValue)
|
||||
}
|
||||
return newValue !== oldValue
|
||||
})
|
||||
|
||||
// If no changes, return existing note without updating timestamp
|
||||
if (!hasChanges) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: parseNote(existingNote),
|
||||
})
|
||||
}
|
||||
|
||||
const note = await prisma.note.update({
|
||||
where: { id },
|
||||
data: updateData
|
||||
data: updateData,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
@@ -146,13 +174,14 @@ export async function DELETE(
|
||||
)
|
||||
}
|
||||
|
||||
await prisma.note.delete({
|
||||
where: { id }
|
||||
await prisma.note.update({
|
||||
where: { id },
|
||||
data: { trashedAt: new Date() }
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Note deleted successfully'
|
||||
message: 'Note moved to trash'
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error deleting note:', error)
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { deleteImageFileSafely, parseImageUrls } from '@/lib/image-cleanup'
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
@@ -14,6 +15,12 @@ export async function POST(req: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
// Fetch notes with images before deleting for cleanup
|
||||
const notesWithImages = await prisma.note.findMany({
|
||||
where: { userId: session.user.id },
|
||||
select: { id: true, images: true },
|
||||
})
|
||||
|
||||
// Delete all notes for the user (cascade will handle labels-note relationships)
|
||||
const result = await prisma.note.deleteMany({
|
||||
where: {
|
||||
@@ -21,6 +28,13 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
})
|
||||
|
||||
// Clean up image files from disk (best-effort, don't block response)
|
||||
const imageCleanup = Promise.allSettled(
|
||||
notesWithImages.flatMap(note =>
|
||||
parseImageUrls(note.images).map(url => deleteImageFileSafely(url, note.id))
|
||||
)
|
||||
)
|
||||
|
||||
// Delete all labels for the user
|
||||
await prisma.label.deleteMany({
|
||||
where: {
|
||||
@@ -39,6 +53,9 @@ export async function POST(req: NextRequest) {
|
||||
revalidatePath('/')
|
||||
revalidatePath('/settings/data')
|
||||
|
||||
// Await cleanup in background (don't block response)
|
||||
imageCleanup.catch(() => {})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
deletedNotes: result.count
|
||||
|
||||
@@ -16,7 +16,8 @@ export async function GET(req: NextRequest) {
|
||||
// Fetch all notes with related data
|
||||
const notes = await prisma.note.findMany({
|
||||
where: {
|
||||
userId: session.user.id
|
||||
userId: session.user.id,
|
||||
trashedAt: null
|
||||
},
|
||||
include: {
|
||||
labelRelations: {
|
||||
@@ -107,7 +108,7 @@ export async function GET(req: NextRequest) {
|
||||
return new NextResponse(jsonString, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Disposition': `attachment; filename="keep-notes-export-${new Date().toISOString().split('T')[0]}.json"`
|
||||
'Content-Disposition': `attachment; filename="memento-export-${new Date().toISOString().split('T')[0]}.json"`
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
|
||||
@@ -19,7 +19,8 @@ export async function GET(request: NextRequest) {
|
||||
const search = searchParams.get('search')
|
||||
|
||||
let where: any = {
|
||||
userId: session.user.id
|
||||
userId: session.user.id,
|
||||
trashedAt: null
|
||||
}
|
||||
|
||||
if (!includeArchived) {
|
||||
@@ -210,13 +211,14 @@ export async function DELETE(request: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
await prisma.note.delete({
|
||||
where: { id }
|
||||
await prisma.note.update({
|
||||
where: { id },
|
||||
data: { trashedAt: new Date() }
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Note deleted successfully'
|
||||
message: 'Note moved to trash'
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error deleting note:', error)
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
--color-background-dark: #1a1d23;
|
||||
}
|
||||
|
||||
/* Custom scrollbar for better aesthetics - Keep style */
|
||||
/* Custom scrollbar for better aesthetics */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
@@ -489,4 +489,11 @@
|
||||
/* Ensure note cards work properly with Muuri */
|
||||
.muuri-item>* {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Force URLs/links to render LTR even in RTL mode */
|
||||
[dir="rtl"] .prose a {
|
||||
direction: ltr;
|
||||
unicode-bidi: embed;
|
||||
display: inline-block;
|
||||
}
|
||||
@@ -6,6 +6,7 @@ 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 { DirectionInitializer } from "@/components/direction-initializer";
|
||||
import { auth } from "@/auth";
|
||||
|
||||
const inter = Inter({
|
||||
@@ -14,7 +15,7 @@ const inter = Inter({
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Memento - Your Digital Notepad",
|
||||
description: "A beautiful note-taking app inspired by Google Keep, built with Next.js 16",
|
||||
description: "A beautiful note-taking app built with Next.js 16",
|
||||
manifest: "/manifest.json",
|
||||
icons: {
|
||||
icon: "/icons/icon-512.svg",
|
||||
@@ -37,6 +38,23 @@ function getHtmlClass(theme?: string): string {
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline script that runs BEFORE React hydrates.
|
||||
* Reads the user's saved language from localStorage and sets
|
||||
* `dir` on <html> immediately — prevents RTL/LTR flash.
|
||||
*/
|
||||
const directionScript = `
|
||||
(function(){
|
||||
try {
|
||||
var lang = localStorage.getItem('user-language');
|
||||
if (lang === 'fa' || lang === 'ar') {
|
||||
document.documentElement.dir = 'rtl';
|
||||
document.documentElement.lang = lang;
|
||||
}
|
||||
} catch(e) {}
|
||||
})();
|
||||
`;
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
@@ -45,16 +63,17 @@ export default async function RootLayout({
|
||||
const session = await auth();
|
||||
const userId = session?.user?.id;
|
||||
|
||||
// Fetch user settings server-side with optimized single session check
|
||||
const [aiSettings, userSettings] = await Promise.all([
|
||||
getAISettings(userId),
|
||||
getUserSettings(userId)
|
||||
getUserSettings(userId),
|
||||
])
|
||||
|
||||
return (
|
||||
<html suppressHydrationWarning className={getHtmlClass(userSettings.theme)}>
|
||||
<head />
|
||||
<body className={inter.className}>
|
||||
<SessionProviderWrapper>
|
||||
<DirectionInitializer />
|
||||
<ThemeInitializer theme={userSettings.theme} fontSize={aiSettings.fontSize} />
|
||||
{children}
|
||||
<Toaster />
|
||||
|
||||
Reference in New Issue
Block a user