Files
Keep/keep-notes/lib/ai/services/chat.service.ts

142 lines
4.3 KiB
TypeScript

/**
* Chat Service
* Handles conversational AI with context retrieval (RAG)
*/
import { semanticSearchService } from './semantic-search.service'
import { getChatProvider } from '../factory'
import { getSystemConfig } from '@/lib/config'
import { prisma } from '@/lib/prisma'
import { auth } from '@/auth'
import { loadTranslations, getTranslationValue, SupportedLanguage } from '@/lib/i18n'
// Default untitled text for fallback
const DEFAULT_UNTITLED = 'Untitled'
export interface ChatMessage {
role: 'user' | 'assistant' | 'system'
content: string
}
export interface ChatResponse {
message: string
conversationId?: string
suggestedNotes?: Array<{ id: string; title: string }>
}
export class ChatService {
/**
* Main chat entry point with context retrieval
*/
async chat(
message: string,
conversationId?: string,
notebookId?: string,
language: SupportedLanguage = 'en'
): Promise<ChatResponse> {
const session = await auth()
if (!session?.user?.id) {
throw new Error('Unauthorized')
}
const userId = session.user.id
// Load translations for the requested language
const translations = await loadTranslations(language)
const untitledText = getTranslationValue(translations, 'notes.untitled') || DEFAULT_UNTITLED
const noNotesFoundText = getTranslationValue(translations, 'chat.noNotesFoundForContext') ||
'No relevant notes found for this question. Answer with your general knowledge.'
// 1. Manage Conversation
let conversation: any
if (conversationId) {
conversation = await prisma.conversation.findUnique({
where: { id: conversationId },
include: { messages: { orderBy: { createdAt: 'asc' }, take: 10 } }
})
}
if (!conversation) {
conversation = await prisma.conversation.create({
data: {
userId,
notebookId,
title: message.substring(0, 50) + '...'
},
include: { messages: true }
})
}
// 2. Retrieval (RAG)
// We search for relevant notes based on the current message or notebook context
// Lower threshold for notebook-specific searches to ensure we find relevant content
const searchResults = await semanticSearchService.search(message, {
notebookId,
limit: 10,
threshold: notebookId ? 0.3 : 0.5
})
const contextNotes = searchResults.map(r =>
`NOTE [${r.title || untitledText}]: ${r.content}`
).join('\n\n---\n\n')
// 3. System Prompt Synthesis
const systemPrompt = `Tu es l'Assistant IA de Memento. Tu accompagnes l'utilisateur dans sa réflexion.
Tes réponses doivent être concises, premium et utiles.
${contextNotes.length > 0 ? `Voici des extraits de notes de l'utilisateur qui pourraient t'aider à répondre :\n\n${contextNotes}\n\nUtilise ces informations si elles sont pertinentes, mais ne les cite pas mot pour mot sauf si demandé.` : noNotesFoundText}
Si l'utilisateur pose une question sur un carnet spécifique, reste focalisé sur ce contexte.`
// 4. Call AI Provider
const history = (conversation.messages || []).map((m: any) => ({
role: m.role,
content: m.content
}))
const currentMessages = [...history, { role: 'user', content: message }]
const config = await getSystemConfig()
const provider = getChatProvider(config)
const aiResponse = await provider.chat(currentMessages, systemPrompt)
// 5. Save Messages to DB
await prisma.chatMessage.createMany({
data: [
{ conversationId: conversation.id, role: 'user', content: message },
{ conversationId: conversation.id, role: 'assistant', content: aiResponse.text }
]
})
return {
message: aiResponse.text,
conversationId: conversation.id,
suggestedNotes: searchResults.map(r => ({ id: r.noteId, title: r.title || untitledText }))
}
}
/**
* Get conversation history
*/
async getHistory(conversationId: string) {
return prisma.conversation.findUnique({
where: { id: conversationId },
include: {
messages: {
orderBy: { createdAt: 'asc' }
}
}
})
}
/**
* List user conversations
*/
async listConversations(userId: string) {
return prisma.conversation.findMany({
where: { userId },
orderBy: { updatedAt: 'desc' },
take: 20
})
}
}
export const chatService = new ChatService()