feat: brainstorm sessions, PDF document Q&A, embedding fixes, and UI improvements
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 7s
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 7s
- Add brainstorm feature with collaborative canvas, AI idea generation, live cursors, playback, and export - Add PDF upload/extraction/ingestion pipeline with pgvector document search (RAG) - Add document Q&A overlay with streaming chat and PDF preview - Add note attachments UI with status polling, grid layout, and auto-scroll - Add task extraction AI tool and agent executor improvements - Fix NoteEmbedding missing updatedAt column, re-index 66 notes with 1536-dim embeddings - Fix brainstorm 'Create Note' button: add success toast and redirect to created note - Fix memory echo notification infinite polling - Fix chat route to always include document_search tool - Add brainstorm i18n keys across all 14 locales - Add socket server for real-time brainstorm collaboration - Add hierarchical notebook selector and organize notebook dialog improvements - Add sidebar brainstorm section with session management - Update prisma schema with brainstorm tables, attachments, and document chunks
This commit is contained in:
@@ -23,7 +23,7 @@ import '../tools'
|
||||
|
||||
// --- Types ---
|
||||
|
||||
export type AgentType = 'scraper' | 'researcher' | 'monitor' | 'custom' | 'slide-generator' | 'excalidraw-generator'
|
||||
export type AgentType = 'scraper' | 'researcher' | 'monitor' | 'custom' | 'slide-generator' | 'excalidraw-generator' | 'task-extractor'
|
||||
|
||||
export interface AgentExecutionResult {
|
||||
success: boolean
|
||||
@@ -941,6 +941,28 @@ IMPERATIVE DESIGN RULES:
|
||||
- Concise points (max 100 chars), punchy and short titles
|
||||
- Strict JSON for generate_pptx, no text outside JSON.`,
|
||||
},
|
||||
'task-extractor': {
|
||||
fr: `Tu es un expert en gestion de tâches et extraction d'action items. Tu analyses des notes et documents pour identifier toutes les tâches, TODOs, et actions à accomplir.
|
||||
|
||||
Utilise OBLIGATOIREMENT l'outil task_extract. Ne réponds PAS avec du texte, appelle directement l'outil.
|
||||
|
||||
## RÈGLES
|
||||
- Identifie TOUTES les tâches explicites et implicites
|
||||
- Pour chaque tâche, détermine: priorité (High/Medium/Low), assigné, deadline, statut
|
||||
- Les priorités High = urgent/dates proches, Medium = important, Low = Nice to have
|
||||
- Regroupe par priorité dans la note de synthèse
|
||||
- Utilise le format Markdown avec une table récapitulative`,
|
||||
en: `You are a task extraction specialist. You analyze notes and documents to identify ALL action items, TODOs, and tasks to accomplish.
|
||||
|
||||
You MUST use the task_extract tool. Do NOT respond with text, call the tool directly.
|
||||
|
||||
## RULES
|
||||
- Identify ALL explicit and implicit tasks
|
||||
- For each task, determine: priority (High/Medium/Low), assignee, deadline, status
|
||||
- High priority = urgent/close deadlines, Medium = important, Low = Nice to have
|
||||
- Group by priority in the synthesis note
|
||||
- Use Markdown format with a summary table`,
|
||||
},
|
||||
}
|
||||
|
||||
// --- Tool-Use Agent ---
|
||||
@@ -1172,6 +1194,38 @@ async function executeToolUseAgent(
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'task-extractor': {
|
||||
const untitled = lang === 'fr' ? 'Sans titre' : 'Untitled'
|
||||
const dateLocale = lang === 'fr' ? 'fr-FR' : 'en-US'
|
||||
let notes: any[] = []
|
||||
if (agent.sourceNotebookId) {
|
||||
notes = await prisma.note.findMany({
|
||||
where: { notebookId: agent.sourceNotebookId, userId: agent.userId, isArchived: false, trashedAt: null },
|
||||
orderBy: { createdAt: 'desc' }, take: 20,
|
||||
select: { id: true, title: true, content: true, createdAt: true }
|
||||
})
|
||||
} else {
|
||||
notes = await prisma.note.findMany({
|
||||
where: { userId: agent.userId, isArchived: false, trashedAt: null },
|
||||
orderBy: { updatedAt: 'desc' }, take: 20,
|
||||
select: { id: true, title: true, content: true, createdAt: true }
|
||||
})
|
||||
}
|
||||
const notebookId = agent.sourceNotebookId || agent.targetNotebookId || null
|
||||
prompt = lang === 'fr'
|
||||
? `Analyse les notes suivantes et extrais TOUS les action items, tâches et TODOs. Utilise l'outil task_extract pour créer une note de synthèse.${notebookId ? ` Passe notebookId="${notebookId}" à task_extract.` : ''}`
|
||||
: `Analyze the following notes and extract ALL action items, tasks and TODOs. Use the task_extract tool to create a synthesis note.${notebookId ? ` Pass notebookId="${notebookId}" to task_extract.` : ''}`
|
||||
if (notes.length > 0) {
|
||||
const notesContext = notes.map(n =>
|
||||
`### ${n.title || untitled} (${n.createdAt.toLocaleDateString(dateLocale)})\n${n.content.substring(0, 500)}`
|
||||
).join('\n\n')
|
||||
prompt += `\n\n${lang === 'fr' ? 'Notes à analyser' : 'Notes to analyze'}:\n\n${notesContext}`
|
||||
}
|
||||
prompt += `\n\n${lang === 'fr'
|
||||
? 'IMPORTANT : Utilise OBLIGATOIREMENT l\'outil task_extract. Ne réponds pas avec du texte, appelle directement l\'outil.'
|
||||
: 'IMPORTANT: You MUST use the task_extract tool. Do NOT respond with text, call the tool directly.'}`
|
||||
break
|
||||
}
|
||||
default: {
|
||||
const urls: string[] = agent.sourceUrls ? JSON.parse(agent.sourceUrls) : []
|
||||
prompt = agent.role || (lang === 'fr' ? 'Accomplis la tâche demandée en utilisant les outils disponibles.' : 'Accomplish the requested task using available tools.')
|
||||
|
||||
83
memento-note/lib/ai/services/document-chunking.service.ts
Normal file
83
memento-note/lib/ai/services/document-chunking.service.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
interface ChunkInput {
|
||||
text: string
|
||||
pageNumber: number
|
||||
}
|
||||
|
||||
export interface DocumentChunkData {
|
||||
content: string
|
||||
chunkIndex: number
|
||||
pageNumber: number
|
||||
startChar: number
|
||||
endChar: number
|
||||
metadata?: string
|
||||
}
|
||||
|
||||
export class DocumentChunkingService {
|
||||
private readonly CHUNK_SIZE = 800
|
||||
private readonly OVERLAP = 200
|
||||
|
||||
chunk(pages: ChunkInput[]): DocumentChunkData[] {
|
||||
const chunks: DocumentChunkData[] = []
|
||||
let globalIndex = 0
|
||||
let previousTail = ''
|
||||
|
||||
for (const page of pages) {
|
||||
const text = page.text.trim()
|
||||
if (!text) continue
|
||||
|
||||
const sections = this.splitSections(text)
|
||||
let buffer = previousTail
|
||||
let bufferStart = 0
|
||||
|
||||
for (const section of sections) {
|
||||
if (buffer.length + section.length > this.CHUNK_SIZE && buffer.length > 0) {
|
||||
chunks.push({
|
||||
content: buffer.trim(),
|
||||
chunkIndex: globalIndex++,
|
||||
pageNumber: page.pageNumber,
|
||||
startChar: bufferStart,
|
||||
endChar: bufferStart + buffer.length,
|
||||
})
|
||||
previousTail = buffer.slice(-this.OVERLAP)
|
||||
buffer = previousTail + '\n' + section
|
||||
bufferStart += buffer.length - section.length - previousTail.length
|
||||
} else {
|
||||
buffer += (buffer ? '\n\n' : '') + section
|
||||
}
|
||||
}
|
||||
|
||||
if (buffer.trim()) {
|
||||
chunks.push({
|
||||
content: buffer.trim(),
|
||||
chunkIndex: globalIndex++,
|
||||
pageNumber: page.pageNumber,
|
||||
startChar: bufferStart,
|
||||
endChar: bufferStart + buffer.length,
|
||||
})
|
||||
previousTail = buffer.slice(-this.OVERLAP)
|
||||
}
|
||||
}
|
||||
|
||||
return chunks
|
||||
}
|
||||
|
||||
private splitSections(text: string): string[] {
|
||||
const lines = text.split('\n')
|
||||
const sections: string[] = []
|
||||
let current = ''
|
||||
|
||||
for (const line of lines) {
|
||||
const isHeading = /^(#{1,6}\s|[A-Z][A-Z\s]{5,}$)/.test(line.trim())
|
||||
if (isHeading && current.trim()) {
|
||||
sections.push(current.trim())
|
||||
current = line
|
||||
} else {
|
||||
current += (current ? '\n' : '') + line
|
||||
}
|
||||
}
|
||||
if (current.trim()) sections.push(current.trim())
|
||||
return sections
|
||||
}
|
||||
}
|
||||
|
||||
export const documentChunkingService = new DocumentChunkingService()
|
||||
56
memento-note/lib/ai/services/document-extraction.service.ts
Normal file
56
memento-note/lib/ai/services/document-extraction.service.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf.mjs'
|
||||
|
||||
if (typeof pdfjsLib.GlobalWorkerOptions !== 'undefined') {
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = path.join(
|
||||
process.cwd(),
|
||||
'node_modules/pdfjs-dist/legacy/build/pdf.worker.mjs'
|
||||
)
|
||||
}
|
||||
|
||||
interface ExtractedPage {
|
||||
pageNumber: number
|
||||
text: string
|
||||
}
|
||||
|
||||
export interface ExtractedDocument {
|
||||
pages: ExtractedPage[]
|
||||
totalPages: number
|
||||
metadata: { title?: string; author?: string }
|
||||
}
|
||||
|
||||
export class DocumentExtractionService {
|
||||
async extractPdf(filePath: string): Promise<ExtractedDocument> {
|
||||
const dataBuffer = fs.readFileSync(filePath)
|
||||
const doc = await pdfjsLib.getDocument({
|
||||
data: new Uint8Array(dataBuffer),
|
||||
useSystemFonts: true,
|
||||
useWorkerFetch: false,
|
||||
isEvalSupported: false,
|
||||
}).promise
|
||||
|
||||
const pages: ExtractedPage[] = []
|
||||
for (let i = 1; i <= doc.numPages; i++) {
|
||||
const page = await doc.getPage(i)
|
||||
const content = await page.getTextContent()
|
||||
const text = content.items
|
||||
.map((item: any) => item.str)
|
||||
.join(' ')
|
||||
pages.push({ pageNumber: i, text })
|
||||
}
|
||||
|
||||
const metadata = await doc.getMetadata().catch(() => null) as any
|
||||
|
||||
return {
|
||||
pages,
|
||||
totalPages: doc.numPages,
|
||||
metadata: {
|
||||
title: metadata?.info?.Title,
|
||||
author: metadata?.info?.Author,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const documentExtractionService = new DocumentExtractionService()
|
||||
79
memento-note/lib/ai/services/document-ingestion.service.ts
Normal file
79
memento-note/lib/ai/services/document-ingestion.service.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import prisma from '@/lib/prisma'
|
||||
import { documentExtractionService } from './document-extraction.service'
|
||||
import { documentChunkingService } from './document-chunking.service'
|
||||
import { embeddingService } from './embedding.service'
|
||||
|
||||
export class DocumentIngestionService {
|
||||
async ingest(attachmentId: string): Promise<void> {
|
||||
const attachment = await prisma.noteAttachment.findUnique({
|
||||
where: { id: attachmentId },
|
||||
})
|
||||
if (!attachment) throw new Error('Attachment not found')
|
||||
|
||||
await prisma.noteAttachment.update({
|
||||
where: { id: attachmentId },
|
||||
data: { status: 'processing' },
|
||||
})
|
||||
|
||||
try {
|
||||
const extracted = await documentExtractionService.extractPdf(attachment.filePath)
|
||||
|
||||
await prisma.noteAttachment.update({
|
||||
where: { id: attachmentId },
|
||||
data: { pageCount: extracted.totalPages },
|
||||
})
|
||||
|
||||
const chunkInputs = extracted.pages.map(p => ({
|
||||
text: p.text,
|
||||
pageNumber: p.pageNumber,
|
||||
}))
|
||||
const chunks = documentChunkingService.chunk(chunkInputs)
|
||||
|
||||
const created = await Promise.all(
|
||||
chunks.map(c =>
|
||||
prisma.documentChunk.create({
|
||||
data: {
|
||||
attachmentId,
|
||||
content: c.content,
|
||||
chunkIndex: c.chunkIndex,
|
||||
pageNumber: c.pageNumber,
|
||||
startChar: c.startChar,
|
||||
endChar: c.endChar,
|
||||
metadata: c.metadata,
|
||||
},
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
const BATCH_SIZE = 20
|
||||
for (let i = 0; i < created.length; i += BATCH_SIZE) {
|
||||
const batch = created.slice(i, i + BATCH_SIZE)
|
||||
const texts = batch.map(c => c.content)
|
||||
const embeddings = await embeddingService.generateBatchEmbeddings(texts)
|
||||
|
||||
await Promise.all(
|
||||
batch.map((chunk, idx) =>
|
||||
prisma.$executeRawUnsafe(
|
||||
`UPDATE "DocumentChunk" SET embedding = $1::vector WHERE id = $2`,
|
||||
embeddingService.toVectorString(embeddings[idx].embedding),
|
||||
chunk.id
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
await prisma.noteAttachment.update({
|
||||
where: { id: attachmentId },
|
||||
data: { status: 'ready' },
|
||||
})
|
||||
} catch (error: any) {
|
||||
await prisma.noteAttachment.update({
|
||||
where: { id: attachmentId },
|
||||
data: { status: 'failed', error: error.message?.substring(0, 500) },
|
||||
})
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const documentIngestionService = new DocumentIngestionService()
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Embedding Service
|
||||
* Generates vector embeddings for semantic search and similarity analysis.
|
||||
* Stores embeddings as native pgvector(1536) in PostgreSQL.
|
||||
* Stores embeddings as native pgvector in PostgreSQL.
|
||||
*/
|
||||
|
||||
import { getAIProvider } from '../factory'
|
||||
|
||||
@@ -385,6 +385,85 @@ export class SemanticSearchService {
|
||||
await Promise.allSettled(batch.map(noteId => this.indexNote(noteId)))
|
||||
}
|
||||
}
|
||||
|
||||
async searchWithDocuments(
|
||||
userId: string,
|
||||
query: string,
|
||||
options?: SearchOptions & { noteId?: string; includeDocuments?: boolean }
|
||||
): Promise<(SearchResult & { source?: 'note' | 'document'; pageNumber?: number; fileName?: string })[]> {
|
||||
const includeDocuments = options?.includeDocuments !== false
|
||||
const noteResults = await this.searchAsUser(userId, query, options)
|
||||
|
||||
if (!includeDocuments) return noteResults
|
||||
|
||||
const queryEmbedding = await embeddingService.generateEmbedding(query)
|
||||
const vectorStr = embeddingService.toVectorString(queryEmbedding.embedding)
|
||||
|
||||
let noteFilter = ''
|
||||
const params: any[] = [vectorStr, 50, userId]
|
||||
|
||||
if (options?.noteId) {
|
||||
assertSafeId(options.noteId, 'noteId')
|
||||
params.push(options.noteId)
|
||||
noteFilter = `AND na."noteId" = $${params.length}`
|
||||
} else if (options?.notebookId) {
|
||||
assertSafeId(options.notebookId, 'notebookId')
|
||||
params.push(options.notebookId)
|
||||
noteFilter = `AND n."notebookId" = $${params.length}`
|
||||
}
|
||||
|
||||
const documentResults = await prisma.$queryRawUnsafe(
|
||||
`SELECT
|
||||
dc.content,
|
||||
dc."pageNumber",
|
||||
na."fileName",
|
||||
na."noteId",
|
||||
n.title as "noteTitle"
|
||||
FROM "DocumentChunk" dc
|
||||
JOIN "NoteAttachment" na ON na.id = dc."attachmentId"
|
||||
JOIN "Note" n ON n.id = na."noteId"
|
||||
WHERE dc."embedding" IS NOT NULL
|
||||
AND na.status = 'ready'
|
||||
AND n."trashedAt" IS NULL
|
||||
AND n."userId" = $3
|
||||
${noteFilter}
|
||||
ORDER BY dc."embedding" <=> $1::vector
|
||||
LIMIT $2`,
|
||||
...params
|
||||
) as any[]
|
||||
|
||||
const K = 60
|
||||
const fused = new Map<string, any>()
|
||||
|
||||
for (let i = 0; i < noteResults.length; i++) {
|
||||
const r = noteResults[i]
|
||||
fused.set(r.noteId, {
|
||||
...r,
|
||||
source: 'note',
|
||||
rrfScore: 1 / (K + i + 1),
|
||||
})
|
||||
}
|
||||
|
||||
for (let i = 0; i < documentResults.length; i++) {
|
||||
const r = documentResults[i]
|
||||
const key = `doc_${r.noteId}_${r.pageNumber}_${i}`
|
||||
fused.set(key, {
|
||||
noteId: r.noteId,
|
||||
title: `${r.noteTitle || 'Untitled'} → ${r.fileName} (p.${r.pageNumber})`,
|
||||
content: r.content.substring(0, 500),
|
||||
score: 0.5,
|
||||
matchType: 'related' as const,
|
||||
source: 'document',
|
||||
pageNumber: r.pageNumber,
|
||||
fileName: r.fileName,
|
||||
rrfScore: 1 / (K + i + 1),
|
||||
})
|
||||
}
|
||||
|
||||
return Array.from(fused.values())
|
||||
.sort((a, b) => b.rrfScore - a.rrfScore)
|
||||
.slice(0, options?.limit || 20)
|
||||
}
|
||||
}
|
||||
|
||||
export const semanticSearchService = new SemanticSearchService()
|
||||
|
||||
Reference in New Issue
Block a user