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

- 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:
Antigravity
2026-05-14 17:43:21 +00:00
parent 195e845f0a
commit 1fcea6ed7d
228 changed files with 57656 additions and 1059 deletions

View File

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

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

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

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

View File

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

View File

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