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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -45,6 +45,7 @@ export class BatchOrganizationService {
where: {
userId,
notebookId: null,
trashedAt: null,
},
select: {
id: true,

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

View File

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

View File

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

View File

@@ -55,6 +55,7 @@ export class NotebookSummaryService {
where: {
notebookId,
userId,
trashedAt: null,
},
select: {
id: true,

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

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

View File

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