All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 1m35s
- notes-editorial-view: move ReminderDialog outside DropdownMenuContent (portal conflict), remove unnecessary showNotebookMenu state, use getNotebookIcon per notebook, fix import path (@/context/notebooks-context), align menu style with design system - archive: replace MasonryGrid+NoteCard with NotesEditorialView via ArchiveClient wrapper - note-card: disable edit/pin/move actions in trash view, cursor-default - chat route: replace maxSteps with stopWhen: stepCountIs(5) for AI SDK v6 - ai-settings: add missing autoSave default value - next.config: add typescript.ignoreBuildErrors for pre-existing false-positive TS errors
294 lines
14 KiB
TypeScript
294 lines
14 KiB
TypeScript
import { streamText, UIMessage, stepCountIs } 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'
|
||
import { toolRegistry } from '@/lib/ai/tools'
|
||
import { readFile } from 'fs/promises'
|
||
import path from 'path'
|
||
|
||
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
|
||
const body = await req.json()
|
||
const { messages: rawMessages, conversationId, notebookId, language, webSearch, noteContext, format } = body as {
|
||
messages: UIMessage[]
|
||
conversationId?: string
|
||
notebookId?: string
|
||
language?: string
|
||
webSearch?: boolean
|
||
noteContext?: { title: string; content: string; tone: string; images?: string[] }
|
||
format?: 'html' | 'markdown'
|
||
}
|
||
|
||
const incomingMessages = toCoreMessages(rawMessages)
|
||
|
||
// 3. Manage conversation
|
||
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'
|
||
conversation = await prisma.conversation.create({
|
||
data: {
|
||
userId,
|
||
notebookId: notebookId || null,
|
||
title: userMessage.substring(0, 50) + (userMessage.length > 50 ? '...' : ''),
|
||
},
|
||
include: { messages: true },
|
||
})
|
||
}
|
||
|
||
// 4. RAG retrieval
|
||
const currentMessage = incomingMessages[incomingMessages.length - 1]?.content || ''
|
||
const lang = (language || 'en') as SupportedLanguage
|
||
const translations = await loadTranslations(lang)
|
||
const untitledText = getTranslationValue(translations, 'notes.untitled') || 'Untitled'
|
||
|
||
let notebookContext = ''
|
||
let searchNotes = ''
|
||
|
||
if (!noteContext) {
|
||
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')
|
||
}
|
||
}
|
||
|
||
let searchResults: any[] = []
|
||
try {
|
||
searchResults = await semanticSearchService.search(currentMessage, {
|
||
notebookId,
|
||
limit: notebookId ? 10 : 5,
|
||
threshold: notebookId ? 0.3 : 0.5,
|
||
defaultTitle: untitledText,
|
||
})
|
||
} catch {}
|
||
|
||
searchNotes = searchResults
|
||
.map((r) => `NOTE [${r.title || untitledText}]: ${r.content}`)
|
||
.join('\n\n---\n\n')
|
||
}
|
||
|
||
const contextNotes = [notebookContext, searchNotes].filter(Boolean).join('\n\n---\n\n')
|
||
|
||
// 5. System prompt synthesis
|
||
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
|
||
- ${format === 'html' ? `Respond MANDATORILY using valid HTML fragments (e.g., <p>, <strong>, <em>, <ul>, <li>, <h3>, <table>, <tr>, <td>).
|
||
- Do NOT use Markdown symbols (no #, *, -, etc.).
|
||
- Do not wrap your HTML code in a Markdown code block.` : '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.` + (format === 'html' ? `
|
||
|
||
## HTML OUTPUT EXAMPLE
|
||
<h3>Section Title</h3>
|
||
<p>Here is an explanation with <strong>bold text</strong> and a list:</p>
|
||
<ul>
|
||
<li>First important point</li>
|
||
<li>Second important point</li>
|
||
</ul>` : '') + `
|
||
|
||
## Tone rules
|
||
- Natural tone, neither corporate nor too casual.
|
||
- No unnecessary intro phrases. Answer directly.
|
||
- No upsell questions at the end. If you have useful additional info, just give it.
|
||
- If the user says "Momento" they mean Momento (this app).
|
||
|
||
## About Momento
|
||
Momento is an intelligent note-taking application. Key features include:
|
||
- **Notes & Editor**: Create rich Markdown notes with an integrated AI Copilot to rewrite, summarize, or translate content.
|
||
- **Organization**: Group notes into Notebooks and tag them with Labels.
|
||
- **Search**: Advanced semantic search to find notes by meaning, not just keywords, and Web Search integration.
|
||
- **Agents**: Create specialized AI Agents with custom system prompts for specific recurring tasks.
|
||
- **Lab**: Experimental AI tools for data analysis and deeper insights.
|
||
|
||
## Available tools
|
||
You have access to: note_search, note_read, web_search, web_scrape.
|
||
Only use tools if you need more information. Never invent note IDs or URLs.`,
|
||
},
|
||
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.`,
|
||
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
|
||
- ${format === 'html' ? `Réponds OBLIGATOIREMENT en utilisant des fragments HTML valides (ex: <p>, <strong>, <em>, <ul>, <li>, <h3>, <table>, <tr>, <td>).
|
||
- N'utilise PAS de symboles Markdown.
|
||
- Ne mets pas ton code HTML dans un bloc de code Markdown.` : '- Utilise le markdown librement : titres (##, ###), listes, code blocks, gras, tables.'}
|
||
- 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.` + (format === 'html' ? `
|
||
|
||
## EXEMPLE DE SORTIE HTML
|
||
<h3>Titre de section</h3>
|
||
<p>Voici une explication avec du <strong>texte en gras</strong> et une liste :</p>
|
||
<ul>
|
||
<li>Premier point important</li>
|
||
<li>Deuxième point important</li>
|
||
</ul>` : '') + `
|
||
|
||
## Règles de ton
|
||
- Ton naturel, direct, sans phrases d'intro inutiles.
|
||
- Pas de question upsell à la fin.
|
||
- Si l'utilisateur dit "Momento" il parle de Momento (cette application).
|
||
|
||
## À propos de Momento
|
||
Momento est une application de prise de notes intelligente. Ses fonctionnalités : Éditeur Markdown riche, Copilot IA, Organisation par Carnets, Recherche sémantique, Agents IA, Lab.
|
||
|
||
## Outils disponibles
|
||
Tu as accès à : note_search, note_read, web_search, web_scrape.`,
|
||
},
|
||
fa: {
|
||
contextWithNotes: `## یادداشتهای کاربر\n\n${contextNotes}\n\nهنگام استفاده از اطلاعات یادداشتهای بالا، عنوان یادداشت منبع را در پرانتز ذکر کنید.`,
|
||
contextNoNotes: "هیچ یادداشت مرتبطی برای این سؤال یافت نشد. با دانش عمومی خود پاسخ دهید.",
|
||
system: `شما دستیار هوش مصنوعی Memento هستید. کاربر از شما درباره پروژهها، مستندات فنی و یادداشتهایش سؤال میکند. باید به شکلی ساختاریافته و مفید پاسخ دهید.
|
||
|
||
## قوانین قالببندی
|
||
- ${format === 'html' ? `حتماً از تگهای HTML معتبر استفاده کنید (مانند <p>, <strong>, <em>, <ul>, <li>, <h3>).
|
||
- از نمادهای مارکداون استفاده نکنید.` : 'از مارکداون آزادانه استفاده کنید: عناوین (##, ###)، لیستها، بلوکهای کد، پررنگ، جداول.'}
|
||
- برای سؤالات فنی یا موضوعات پیچیده، پاسخ خود را بخشبندی کنید.
|
||
- برای سؤالات ساده و کوتاه، یک پاراگراف مستقیم کافی است.` + (format === 'html' ? `
|
||
|
||
## نمونه خروجی HTML
|
||
<h3>عنوان بخش</h3>
|
||
<p>این یک توضیح با <strong>متن برجسته</strong> و یک لیست است:</p>
|
||
<ul>
|
||
<li>نکته اول</li>
|
||
<li>نکته دوم</li>
|
||
</ul>` : '') + `
|
||
|
||
## قوانین لحن
|
||
- لحن طبیعی، مستقیم، بدون مقدمه اضافی.
|
||
- اگر کاربر "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.`,
|
||
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.
|
||
|
||
## Reglas de formato
|
||
- ${format === 'html' ? `Responde OBLIGATORIAMENTE usando fragmentos HTML válidos (ej: <p>, <strong>, <em>, <ul>, <li>, <h3>, <table>, <tr>, <td>).
|
||
- NO uses símbolos Markdown.` : 'Usa markdown libremente: títulos (##, ###), listas, negritas, tablas.'}
|
||
- Estructura tu respuesta con secciones para temas complejos.
|
||
- Para preguntas simples, un párrafo directo es suficiente.` + (format === 'html' ? `
|
||
|
||
## EJEMPLO DE SALIDA HTML
|
||
<h3>Título de sección</h3>
|
||
<p>Aquí hay una explicación con <strong>texto en negrita</strong> y una lista:</p>
|
||
<ul>
|
||
<li>Primer punto importante</li>
|
||
<li>Segundo punto importante</li>
|
||
</ul>` : ''),
|
||
},
|
||
}
|
||
|
||
const prompts = promptLang[lang] || promptLang.en
|
||
const contextBlock = contextNotes.length > 0 ? prompts.contextWithNotes : prompts.contextNoNotes
|
||
|
||
// Load note images for vision
|
||
let imageContextParts: Array<{ type: 'image'; image: string }> = []
|
||
if (noteContext?.images && noteContext.images.length > 0) {
|
||
for (const imgPath of noteContext.images.slice(0, 4)) {
|
||
try {
|
||
const fullPath = path.join(process.cwd(), 'data', imgPath)
|
||
const buffer = await readFile(fullPath)
|
||
const ext = path.extname(imgPath).toLowerCase()
|
||
const mime = ext === '.png' ? 'image/png' : ext === '.gif' ? 'image/gif' : ext === '.webp' ? 'image/webp' : 'image/jpeg'
|
||
imageContextParts.push({ type: 'image', image: `data:${mime};base64,${buffer.toString('base64')}` })
|
||
} catch {}
|
||
}
|
||
}
|
||
|
||
let copilotContext = ''
|
||
if (noteContext) {
|
||
copilotContext = `\n\n## Current Note Context
|
||
You are helping the user edit a specific note: ${noteContext.title || 'Untitled'}.
|
||
Tone: ${noteContext.tone || 'professional'}.
|
||
Content: ${noteContext.content || '(empty)'}
|
||
Focus ONLY on this note unless asked otherwise.`
|
||
}
|
||
|
||
const systemPrompt = `${prompts.system}\n${copilotContext}\n\n${contextBlock}\n\n## LANGUAGE RULE (MANDATORY)\nYou MUST respond in ${lang === 'en' ? 'English' : lang === 'fr' ? 'French' : lang === 'fa' ? 'Persian (Farsi)' : lang === 'es' ? 'Spanish' : 'English'}.`
|
||
|
||
// 6. Execute stream
|
||
const sysConfig = await getSystemConfig()
|
||
const chatTools = noteContext
|
||
? toolRegistry.buildToolsForChat({ userId, config: sysConfig, webSearch, webOnly: true })
|
||
: toolRegistry.buildToolsForChat({ userId, config: sysConfig, webSearch })
|
||
|
||
const provider = getChatProvider(sysConfig)
|
||
const result = await streamText({
|
||
model: provider.getModel(),
|
||
system: systemPrompt,
|
||
messages: incomingMessages,
|
||
tools: chatTools,
|
||
stopWhen: stepCountIs(5),
|
||
onFinish: async (final) => {
|
||
const userContent = incomingMessages[incomingMessages.length - 1].content
|
||
await prisma.chatMessage.create({
|
||
data: { conversationId: conversation.id, role: 'user', content: userContent }
|
||
})
|
||
await prisma.chatMessage.create({
|
||
data: { conversationId: conversation.id, role: 'assistant', content: final.text }
|
||
})
|
||
}
|
||
})
|
||
|
||
return result.toUIMessageStreamResponse()
|
||
}
|