refactor(ux): consolidate BMAD skills, update design system, and clean up Prisma generated client

This commit is contained in:
Sepehr Ramezani
2026-04-19 19:21:27 +02:00
parent 5296c4da2c
commit 25529a24b8
2476 changed files with 127934 additions and 101962 deletions

View File

@@ -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) {

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

View 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,
},
})
}

View File

@@ -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,

View 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 });
}
}

View File

@@ -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)

View File

@@ -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

View File

@@ -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) {

View File

@@ -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)