refactor(ux): consolidate BMAD skills, update design system, and clean up Prisma generated client
This commit is contained in:
1106
keep-notes/lib/ai/services/agent-executor.service.ts
Normal file
1106
keep-notes/lib/ai/services/agent-executor.service.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -62,6 +62,7 @@ export class AutoLabelCreationService {
|
||||
where: {
|
||||
notebookId,
|
||||
userId,
|
||||
trashedAt: null,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
@@ -471,7 +472,7 @@ Deine Antwort (nur JSON):
|
||||
await prisma.note.update({
|
||||
where: { id: noteId },
|
||||
data: {
|
||||
labels: names as any,
|
||||
labels: JSON.stringify(names),
|
||||
labelRelations: {
|
||||
connect: { id: label.id },
|
||||
},
|
||||
|
||||
@@ -45,6 +45,7 @@ export class BatchOrganizationService {
|
||||
where: {
|
||||
userId,
|
||||
notebookId: null,
|
||||
trashedAt: null,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
|
||||
141
keep-notes/lib/ai/services/chat.service.ts
Normal file
141
keep-notes/lib/ai/services/chat.service.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* 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()
|
||||
@@ -68,3 +68,24 @@ export {
|
||||
notebookSummaryService,
|
||||
type NotebookSummary
|
||||
} from './notebook-summary.service'
|
||||
|
||||
// Chat
|
||||
export {
|
||||
ChatService,
|
||||
chatService,
|
||||
type ChatResponse
|
||||
} from './chat.service'
|
||||
|
||||
// Scrape
|
||||
export {
|
||||
ScrapeService,
|
||||
scrapeService,
|
||||
type ScrapedContent
|
||||
} from './scrape.service'
|
||||
|
||||
// Tool Registry
|
||||
export {
|
||||
toolRegistry,
|
||||
type ToolContext,
|
||||
type RegisteredTool
|
||||
} from '../tools'
|
||||
|
||||
@@ -61,6 +61,7 @@ export class MemoryEchoService {
|
||||
where: {
|
||||
userId,
|
||||
isArchived: false,
|
||||
trashedAt: null,
|
||||
noteEmbedding: { isNot: null } // Only notes with embeddings
|
||||
},
|
||||
select: {
|
||||
@@ -284,6 +285,11 @@ Explain in one brief sentence (max 15 words) why these notes are connected. Focu
|
||||
)
|
||||
|
||||
// Store insight in database
|
||||
// In demo mode, add milliseconds offset to avoid @@unique([userId, insightDate]) collision
|
||||
const insightDateValue = demoMode
|
||||
? new Date(Date.now() + Math.floor(Math.random() * 1000))
|
||||
: new Date()
|
||||
|
||||
const insight = await prisma.memoryEchoInsight.create({
|
||||
data: {
|
||||
userId,
|
||||
@@ -291,7 +297,7 @@ Explain in one brief sentence (max 15 words) why these notes are connected. Focu
|
||||
note2Id: newConnection.note2.id,
|
||||
similarityScore: newConnection.similarityScore,
|
||||
insight: insightText,
|
||||
insightDate: new Date(),
|
||||
insightDate: insightDateValue,
|
||||
viewed: false
|
||||
},
|
||||
include: {
|
||||
@@ -410,6 +416,7 @@ Explain in one brief sentence (max 15 words) why these notes are connected. Focu
|
||||
userId,
|
||||
id: { not: noteId },
|
||||
isArchived: false,
|
||||
trashedAt: null,
|
||||
noteEmbedding: { isNot: null }
|
||||
},
|
||||
select: {
|
||||
|
||||
@@ -55,6 +55,7 @@ export class NotebookSummaryService {
|
||||
where: {
|
||||
notebookId,
|
||||
userId,
|
||||
trashedAt: null,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
|
||||
92
keep-notes/lib/ai/services/rss.service.ts
Normal file
92
keep-notes/lib/ai/services/rss.service.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* RSS/Atom Feed Service
|
||||
* Parses RSS and Atom feeds and returns structured article entries.
|
||||
* Used by the scraper pipeline to get individual article URLs from feeds.
|
||||
*/
|
||||
|
||||
import Parser from 'rss-parser'
|
||||
|
||||
export interface FeedArticle {
|
||||
title: string
|
||||
link: string
|
||||
pubDate?: string
|
||||
contentSnippet?: string
|
||||
content?: string
|
||||
creator?: string
|
||||
}
|
||||
|
||||
export interface ParsedFeed {
|
||||
title: string
|
||||
description?: string
|
||||
link?: string
|
||||
articles: FeedArticle[]
|
||||
}
|
||||
|
||||
const parser = new Parser({
|
||||
timeout: 15000,
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
'Accept': 'application/rss+xml, application/xml, text/xml, application/atom+xml, text/html;q=0.9',
|
||||
},
|
||||
})
|
||||
|
||||
const MAX_ARTICLES_PER_FEED = 8
|
||||
|
||||
export class RssService {
|
||||
/**
|
||||
* Detect if a URL looks like an RSS/Atom feed
|
||||
*/
|
||||
isFeedUrl(url: string): boolean {
|
||||
const feedPatterns = [
|
||||
'/feed', '/rss', '/atom', '/feed/', '/rss/',
|
||||
'.xml', '.rss', '.atom',
|
||||
'/feed/json',
|
||||
]
|
||||
const lower = url.toLowerCase()
|
||||
return feedPatterns.some(p => lower.includes(p))
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to parse a URL as an RSS/Atom feed.
|
||||
* Returns null if the URL is not a valid feed.
|
||||
*/
|
||||
async parseFeed(feedUrl: string): Promise<ParsedFeed | null> {
|
||||
try {
|
||||
const result = await parser.parseURL(feedUrl)
|
||||
return {
|
||||
title: result.title || feedUrl,
|
||||
description: result.description,
|
||||
link: result.link,
|
||||
articles: (result.items || [])
|
||||
.slice(0, MAX_ARTICLES_PER_FEED)
|
||||
.map(item => ({
|
||||
title: item.title || 'Sans titre',
|
||||
link: item.link || '',
|
||||
pubDate: item.pubDate || item.isoDate,
|
||||
contentSnippet: (item.contentSnippet || '').substring(0, 500),
|
||||
content: item['content:encoded'] || item.content || '',
|
||||
creator: item.creator || item.dc?.creator,
|
||||
}))
|
||||
.filter(a => a.link), // Only keep entries with a link
|
||||
}
|
||||
} catch {
|
||||
// Not a valid feed or fetch failed
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch an RSS feed and return only the article URLs for scraping.
|
||||
* Useful when you want to scrape articles individually.
|
||||
*/
|
||||
async getArticleUrls(feedUrl: string): Promise<{ feedTitle: string; urls: string[] }> {
|
||||
const feed = await this.parseFeed(feedUrl)
|
||||
if (!feed) return { feedTitle: '', urls: [] }
|
||||
return {
|
||||
feedTitle: feed.title,
|
||||
urls: feed.articles.map(a => a.link),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const rssService = new RssService()
|
||||
68
keep-notes/lib/ai/services/scrape.service.ts
Normal file
68
keep-notes/lib/ai/services/scrape.service.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Scrape Service
|
||||
* Advanced content extraction using Readability and jsdom
|
||||
*/
|
||||
|
||||
import { JSDOM } from 'jsdom'
|
||||
import { Readability } from '@mozilla/readability'
|
||||
|
||||
export interface ScrapedContent {
|
||||
title: string
|
||||
content: string // Markdown or clean text
|
||||
textContent: string
|
||||
excerpt: string
|
||||
byline: string
|
||||
siteName: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export class ScrapeService {
|
||||
async scrapeUrl(url: string): Promise<ScrapedContent | null> {
|
||||
try {
|
||||
// Add protocol if missing
|
||||
let targetUrl = url
|
||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||
targetUrl = 'https://' + url
|
||||
}
|
||||
|
||||
console.log(`[ScrapeService] Fetching ${targetUrl}...`)
|
||||
|
||||
const response = await fetch(targetUrl, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
|
||||
},
|
||||
next: { revalidate: 3600 }
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
|
||||
const html = await response.text()
|
||||
const dom = new JSDOM(html, { url: targetUrl })
|
||||
|
||||
const reader = new Readability(dom.window.document)
|
||||
const article = reader.parse()
|
||||
|
||||
if (!article) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
title: article.title,
|
||||
content: article.content, // HTML fragment from readability
|
||||
textContent: article.textContent, // Clean text
|
||||
excerpt: article.excerpt,
|
||||
byline: article.byline,
|
||||
siteName: article.siteName,
|
||||
url: targetUrl
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[ScrapeService] Error scraping ${url}:`, error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const scrapeService = new ScrapeService()
|
||||
@@ -22,6 +22,7 @@ export interface SearchOptions {
|
||||
threshold?: number // Minimum similarity score (0-1)
|
||||
includeExactMatches?: boolean
|
||||
notebookId?: string // NEW: Filter by notebook for contextual search (IA5)
|
||||
defaultTitle?: string // Optional default title for untitled notes (i18n)
|
||||
}
|
||||
|
||||
export class SemanticSearchService {
|
||||
@@ -40,7 +41,8 @@ export class SemanticSearchService {
|
||||
limit = this.DEFAULT_LIMIT,
|
||||
threshold = this.DEFAULT_THRESHOLD,
|
||||
includeExactMatches = true,
|
||||
notebookId // NEW: Contextual search within notebook (IA5)
|
||||
notebookId, // NEW: Contextual search within notebook (IA5)
|
||||
defaultTitle = 'Untitled' // Default title for i18n
|
||||
} = options
|
||||
|
||||
if (!query || query.trim().length < 2) {
|
||||
@@ -63,14 +65,15 @@ export class SemanticSearchService {
|
||||
semanticResults
|
||||
)
|
||||
|
||||
// 4. Sort by final score and limit
|
||||
return fusedResults
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, limit)
|
||||
.map(result => ({
|
||||
...result,
|
||||
matchType: result.score > 0.8 ? 'exact' : 'related'
|
||||
}))
|
||||
// 4. Sort by final score and limit
|
||||
return fusedResults
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, limit)
|
||||
.map(result => ({
|
||||
...result,
|
||||
title: result.title || defaultTitle,
|
||||
matchType: result.score > 0.8 ? 'exact' : 'related'
|
||||
}))
|
||||
} catch (error) {
|
||||
console.error('Error in hybrid search:', error)
|
||||
// Fallback to keyword-only search
|
||||
@@ -79,7 +82,7 @@ export class SemanticSearchService {
|
||||
// Fetch note details for keyword results
|
||||
const noteIds = keywordResults.slice(0, limit).map(r => r.noteId)
|
||||
const notes = await prisma.note.findMany({
|
||||
where: { id: { in: noteIds } },
|
||||
where: { id: { in: noteIds }, trashedAt: null },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
@@ -90,7 +93,7 @@ export class SemanticSearchService {
|
||||
|
||||
return notes.map(note => ({
|
||||
noteId: note.id,
|
||||
title: note.title,
|
||||
title: note.title || defaultTitle,
|
||||
content: note.content,
|
||||
score: 1.0, // Default score for keyword-only results
|
||||
matchType: 'related' as const,
|
||||
@@ -107,17 +110,27 @@ export class SemanticSearchService {
|
||||
userId: string | null,
|
||||
notebookId?: string // NEW: Filter by notebook (IA5)
|
||||
): Promise<Array<{ noteId: string; rank: number }>> {
|
||||
// Build query for case-insensitive search
|
||||
const searchPattern = `%${query}%`
|
||||
// Extract keywords (words with > 3 characters) to avoid entire sentence matching failing
|
||||
const stopWords = new Set(['comment', 'pourquoi', 'lequel', 'laquelle', 'avec', 'pour', 'dans', 'sur', 'est-ce']);
|
||||
const keywords = query.toLowerCase()
|
||||
.split(/[^a-z0-9àáâäçéèêëíìîïñóòôöúùûü]/i)
|
||||
.filter(w => w.length > 3 && !stopWords.has(w));
|
||||
|
||||
// If no good keywords found, fallback to the original query but it'll likely fail
|
||||
const searchTerms = keywords.length > 0 ? keywords : [query];
|
||||
|
||||
// Build Prisma OR clauses for each keyword
|
||||
const searchConditions = searchTerms.flatMap(term => [
|
||||
{ title: { contains: term } },
|
||||
{ content: { contains: term } }
|
||||
]);
|
||||
|
||||
const notes = await prisma.note.findMany({
|
||||
where: {
|
||||
...(userId ? { userId } : {}),
|
||||
...(notebookId !== undefined ? { notebookId } : {}), // NEW: Notebook filter
|
||||
OR: [
|
||||
{ title: { contains: query } },
|
||||
{ content: { contains: query } }
|
||||
]
|
||||
trashedAt: null,
|
||||
OR: searchConditions
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
@@ -178,6 +191,7 @@ export class SemanticSearchService {
|
||||
where: {
|
||||
...(userId ? { userId } : {}),
|
||||
...(notebookId !== undefined ? { notebookId } : {}),
|
||||
trashedAt: null,
|
||||
noteEmbedding: { isNot: null }
|
||||
},
|
||||
select: {
|
||||
@@ -245,7 +259,7 @@ export class SemanticSearchService {
|
||||
// Fetch note details
|
||||
const noteIds = Array.from(scores.keys())
|
||||
const notes = await prisma.note.findMany({
|
||||
where: { id: { in: noteIds } },
|
||||
where: { id: { in: noteIds }, trashedAt: null },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
@@ -313,6 +327,46 @@ export class SemanticSearchService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search as a specific user (no auth() call).
|
||||
* Used by agent tools that run server-side without HTTP session.
|
||||
*/
|
||||
async searchAsUser(
|
||||
userId: string,
|
||||
query: string,
|
||||
options: SearchOptions = {}
|
||||
): Promise<SearchResult[]> {
|
||||
const {
|
||||
limit = this.DEFAULT_LIMIT,
|
||||
threshold = this.DEFAULT_THRESHOLD,
|
||||
includeExactMatches = true,
|
||||
notebookId,
|
||||
defaultTitle = 'Untitled'
|
||||
} = options
|
||||
|
||||
if (!query || query.trim().length < 2) {
|
||||
return []
|
||||
}
|
||||
|
||||
try {
|
||||
const keywordResults = await this.keywordSearch(query, userId, notebookId)
|
||||
const semanticResults = await this.semanticVectorSearch(query, userId, threshold, notebookId)
|
||||
const fusedResults = await this.reciprocalRankFusion(keywordResults, semanticResults)
|
||||
|
||||
return fusedResults
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, limit)
|
||||
.map(result => ({
|
||||
...result,
|
||||
title: result.title || defaultTitle,
|
||||
matchType: result.score > 0.8 ? 'exact' : 'related'
|
||||
}))
|
||||
} catch (error) {
|
||||
console.error('Error in searchAsUser:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch index multiple notes (for initial migration or bulk updates)
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user