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

@@ -0,0 +1,73 @@
import { tool } from 'ai'
import { z } from 'zod'
import { toolRegistry } from './registry'
import { embeddingService } from '@/lib/ai/services/embedding.service'
import prisma from '@/lib/prisma'
toolRegistry.register({
name: 'document_search',
description: 'Search within PDF documents attached to notes. Returns relevant passages with page numbers and source document info.',
isInternal: true,
buildTool: (ctx) =>
tool({
description: `Search within PDF documents attached to the user's notes.
Returns matching passages with page numbers, chunk content, and the source note/document info.
Use this when the user asks about specific documents, PDFs, or attached files.`,
inputSchema: z.object({
query: z.string().describe('The search query to find relevant passages in documents'),
noteId: z.string().optional().describe('Optional: restrict search to attachments of a specific note'),
limit: z.number().optional().describe('Max results to return (default 5)').default(5),
}),
execute: async ({ query, noteId, limit = 5 }) => {
try {
const queryEmbedding = await embeddingService.generateEmbedding(query)
const vectorStr = embeddingService.toVectorString(queryEmbedding.embedding)
let noteFilter = ''
const params: any[] = [vectorStr, limit, ctx.userId]
if (noteId) {
noteFilter = `AND na."noteId" = $4`
params.push(noteId)
}
const results = await prisma.$queryRawUnsafe(
`SELECT
dc.id as "chunkId",
dc.content,
dc."pageNumber",
dc."chunkIndex",
na.id as "attachmentId",
na."fileName",
na."pageCount",
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[]
if (!results.length) return { results: [], message: 'No matching documents found' }
return results.map(r => ({
content: r.content.substring(0, 600),
pageNumber: r.pageNumber,
chunkIndex: r.chunkIndex,
fileName: r.fileName,
noteId: r.noteId,
noteTitle: r.noteTitle || 'Untitled',
}))
} catch (e: any) {
return { error: `Document search failed: ${e.message}` }
}
},
}),
})

View File

@@ -13,6 +13,8 @@ import './memory.tool'
import './excalidraw.tool'
import './pptx.tool'
import './slides.tool'
import './document-search.tool'
import './task-extract.tool'
// Re-export registry
export { toolRegistry, type ToolContext, type RegisteredTool } from './registry'

View File

@@ -60,7 +60,7 @@ const PALETTES: Record<string, Theme> = {
coastal_coral: { primary: '005f73', secondary: '0a9396', accent: 'ee9b00', light: 'e9f5f5', bg: 'ffffff' },
vibrant_orange_mint: { primary: 'e05c00', secondary: '2ec4b6', accent: 'ff9f1c', light: 'edfaf9', bg: 'ffffff' },
platinum_white_gold: { primary: '0a0a0a', secondary: '404040', accent: 'c9a84c', light: 'f5f5f0', bg: 'ffffff' },
architectural_mono: { primary: '1C1C1C', secondary: '75B2D6', accent: 'D4A373', light: 'EDE9DF', bg: 'F2F0E9' },
architectural_mono: { primary: '1C1C1C', secondary: 'A47148', accent: 'D4A373', light: 'EDE9DF', bg: 'F2F0E9' },
minimal_silk: { primary: '212529', secondary: '6c757d', accent: 'dee2e6', light: 'f8f9fa', bg: 'ffffff' },
}

View File

@@ -52,7 +52,7 @@ class ToolRegistry {
* When webOnly is true, only web tools are included (no note access).
*/
buildToolsForChat(ctx: ToolContext & { webOnly?: boolean }): Record<string, any> {
const toolNames: string[] = ctx.webOnly ? [] : ['note_search', 'note_read']
const toolNames: string[] = ctx.webOnly ? [] : ['note_search', 'note_read', 'document_search', 'task_extract']
// Add web tools only when user toggled web search AND config is present
if (ctx.webSearch) {

View File

@@ -0,0 +1,106 @@
import { tool } from 'ai'
import { z } from 'zod'
import { toolRegistry } from './registry'
import { prisma } from '@/lib/prisma'
import { getTagsProvider } from '@/lib/ai/factory'
import { getSystemConfig } from '@/lib/config'
toolRegistry.register({
name: 'task_extract',
description: 'Extract action items (TODOs) from notes in a notebook. Reads all notes, identifies tasks with assignees and deadlines, and creates a synthesis note.',
isInternal: true,
buildTool: (ctx) =>
tool({
description: 'Extract action items from notes in a notebook. Creates a new note with all identified tasks.',
inputSchema: z.object({
notebookId: z.string().optional().describe('Notebook ID to scan. If omitted, scans all user notes.'),
noteIds: z.array(z.string()).optional().describe('Specific note IDs to scan instead of a whole notebook.'),
locale: z.string().optional().describe('Language for the output (fr, en, es, de, etc.)'),
}),
execute: async ({ notebookId, noteIds, locale }) => {
try {
let where: any = { userId: ctx.userId, trashedAt: null }
if (noteIds && noteIds.length > 0) {
where.id = { in: noteIds }
} else if (notebookId) {
where.notebookId = notebookId
}
const notes = await prisma.note.findMany({
where,
select: { id: true, title: true, content: true },
orderBy: { updatedAt: 'desc' },
take: 50,
})
if (notes.length === 0) {
return { error: 'No notes found to analyze' }
}
const notesContext = notes.map(n =>
`[ID: ${n.id}] "${n.title}":\n${(n.content || '').slice(0, 800)}`
).join('\n\n---\n\n')
const lang = locale === 'fr' ? 'français' : locale === 'es' ? 'espagnol' : locale === 'de' ? 'allemand' : locale === 'it' ? 'italien' : locale === 'pt' ? 'portugais' : locale === 'nl' ? 'néerlandais' : locale === 'ru' ? 'russe' : locale === 'zh' ? 'chinois' : locale === 'ja' ? 'japonais' : locale === 'ar' ? 'arabe' : locale === 'fa' ? 'persan' : locale === 'hi' ? 'hindi' : 'English'
const config = await getSystemConfig()
const provider = getTagsProvider(config)
const prompt = `You are a task extraction specialist. Analyze the following notes and extract ALL action items, tasks, and TODOs.
For each task identified, provide:
- **Task**: Clear, actionable description
- **Source**: The note title where it was found
- **Assignee**: If mentioned (otherwise "Unassigned")
- **Deadline**: If mentioned (otherwise "No deadline")
- **Priority**: High/Medium/Low based on urgency signals in the text
- **Status**: If already completed or in-progress based on context
NOTES TO ANALYZE:
${notesContext}
Respond in ${lang}. Structure the output as a clean Markdown document with:
1. A summary paragraph
2. Tasks grouped by priority (High → Medium → Low)
3. A summary table at the end
Format each task as:
### [Priority] Task Title
- **Description**: ...
- **Source note**: ...
- **Assignee**: ...
- **Deadline**: ...
- **Status**: ...`
const result = await provider.generateText(prompt)
const summaryTitle = locale === 'fr'
? `Action Items — ${new Date().toLocaleDateString('fr-FR')}`
: `Action Items — ${new Date().toLocaleDateString('en-US')}`
const createdNote = await prisma.note.create({
data: {
title: summaryTitle,
content: result,
type: 'markdown',
isMarkdown: true,
autoGenerated: true,
userId: ctx.userId,
notebookId: notebookId || null,
},
select: { id: true, title: true },
})
return {
success: true,
noteId: createdNote.id,
title: createdNote.title,
notesAnalyzed: notes.length,
tasksNoteUrl: `/notes/${createdNote.id}`,
}
} catch (e: any) {
return { error: `Task extraction failed: ${e.message}` }
}
},
}),
})