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,228 @@
import { Note, NoteCluster, BridgeNote } from '../types';
import { cosineSimilarity } from './geminiService';
export function dbscan(notes: Note[], eps: number, minPts: number): number[] {
const n = notes.length;
const labels = new Array(n).fill(-1); // -1 = noise, 0+ = cluster id
let clusterId = 0;
for (let i = 0; i < n; i++) {
if (labels[i] !== -1) continue;
const neighbors = getNeighbors(i, notes, eps);
if (neighbors.length < minPts) {
labels[i] = -1; // remains noise for now
continue;
}
labels[i] = clusterId;
const queue = neighbors.filter(idx => idx !== i);
for (let j = 0; j < queue.length; j++) {
const pIdx = queue[j];
if (labels[pIdx] === -1) {
labels[pIdx] = clusterId; // noisy point becomes border point
}
if (labels[pIdx] !== -1 && labels[pIdx] < clusterId) {
// This should not happen in standard DBSCAN unless we re-visit
}
if (labels[pIdx] === clusterId && labels[pIdx] !== -1) {
// Skip if already processed in this cluster
}
// If it was already labeled, skip re-neighboring
const pWasNoise = labels[pIdx] === -1;
if (labels[pIdx] === -1) labels[pIdx] = clusterId;
// If point was not processed
if (pWasNoise || labels[pIdx] === clusterId ) {
// This is a simplified queue processing
}
}
// Standard DBSCAN expansion
expandCluster(i, neighbors, labels, clusterId, notes, eps, minPts);
clusterId++;
}
return labels;
}
function expandCluster(pIdx: number, neighbors: number[], labels: number[], clusterId: number, notes: Note[], eps: number, minPts: number) {
let i = 0;
while (i < neighbors.length) {
const qIdx = neighbors[i];
if (labels[qIdx] === -1) {
labels[qIdx] = clusterId;
} else if (labels[qIdx] === undefined || labels[qIdx] === -1) {
// unreachable
}
if (labels[qIdx] === clusterId || labels[qIdx] === -1) {
const qNeighbors = getNeighbors(qIdx, notes, eps);
if (qNeighbors.length >= minPts) {
for(const qn of qNeighbors) {
if (labels[qn] === -1) {
labels[qn] = clusterId;
neighbors.push(qn);
} else if (!labels.hasOwnProperty(qn)) {
// logic error
}
}
}
}
i++;
}
}
// Clean DBSCAN implementation
export function runClustering(notes: Note[], eps: number = 0.15, minPts: number = 2): { labels: number[], clusters: NoteCluster[] } {
const validNotes = notes.filter(n => n.embedding && n.embedding.length > 0);
if (validNotes.length === 0) return { labels: [], clusters: [] };
const n = validNotes.length;
const labels = new Array(n).fill(-1);
let cId = 0;
for (let i = 0; i < n; i++) {
if (labels[i] !== -1) continue;
const neighbors = findNeighbors(i, validNotes, eps);
if (neighbors.length < minPts) {
labels[i] = -1;
} else {
labels[i] = cId;
expand(i, neighbors, labels, cId, validNotes, eps, minPts);
cId++;
}
}
const clusters: NoteCluster[] = [];
const colorPalette = ['#F87171', '#60A5FA', '#34D399', '#FBBF24', '#A78BFA', '#F472B6', '#2DD4BF'];
for (let i = 0; i < cId; i++) {
const noteIds = validNotes.filter((_, idx) => labels[idx] === i).map(n => n.id);
clusters.push({
id: `cluster-${i}`,
name: `Cluster ${i + 1}`,
noteIds,
color: colorPalette[i % colorPalette.length]
});
}
return { labels, clusters };
}
function findNeighbors(idx: number, notes: Note[], eps: number): number[] {
const neighbors: number[] = [];
const targetEmbedding = notes[idx].embedding!;
for (let i = 0; i < notes.length; i++) {
const sim = cosineSimilarity(targetEmbedding, notes[i].embedding!);
const dist = 1 - sim;
if (dist <= eps) {
neighbors.push(i);
}
}
return neighbors;
}
function expand(rootIdx: number, neighbors: number[], labels: number[], cId: number, notes: Note[], eps: number, minPts: number) {
const queue = [...neighbors];
for (let i = 0; i < queue.length; i++) {
const qIdx = queue[i];
if (labels[qIdx] === -1) {
labels[qIdx] = cId;
}
if (labels[qIdx] !== -1 && labels[qIdx] !== cId) continue;
if (labels[qIdx] === cId) {
// already visited but let's check neighbors if we just added it
}
// If point was noise, it now belongs to cluster, but we don't necessarily expand from it unless it's a core point
// This is the standard DBSCAN: noise points can become border points
}
// Re-implementing correctly
let head = 0;
while(head < queue.length) {
const qIdx = queue[head];
if (labels[qIdx] === -1) labels[qIdx] = cId;
if (labels[qIdx] === cId) {
const qNeighbors = findNeighbors(qIdx, notes, eps);
if (qNeighbors.length >= minPts) {
for(const qn of qNeighbors) {
if (labels[qn] === -1) {
labels[qn] = cId;
queue.push(qn);
}
}
}
}
head++;
}
}
function getNeighbors(idx: number, notes: Note[], eps: number): number[] {
const neighbors: number[] = [];
const target = notes[idx].embedding!;
for (let i = 0; i < notes.length; i++) {
if (!notes[i].embedding) continue;
const dist = 1 - cosineSimilarity(target, notes[i].embedding!);
if (dist <= eps) neighbors.push(i);
}
return neighbors;
}
export function detectBridges(notes: Note[], clusters: NoteCluster[], threshold: number = 0.5): BridgeNote[] {
const bridges: BridgeNote[] = [];
const validNotes = notes.filter(n => n.embedding);
for (const note of validNotes) {
const connectedClusters = new Set<string>();
for (const cluster of clusters) {
// Check if note has strong links to ANY note in this cluster
const clusterNotes = notes.filter(n => cluster.noteIds.includes(n.id) && n.embedding);
const hasStrongLink = clusterNotes.some(cn => cosineSimilarity(note.embedding!, cn.embedding!) > threshold);
if (hasStrongLink) {
connectedClusters.add(cluster.id);
}
}
if (connectedClusters.size >= 2) {
bridges.push({
noteId: note.id,
connectedClusterIds: Array.from(connectedClusters),
bridgeScore: connectedClusters.size / Math.max(clusters.length, 1)
});
}
}
return bridges.sort((a, b) => b.bridgeScore - a.bridgeScore);
}
export function calculateCentroid(noteIds: string[], allNotes: Note[]): number[] | undefined {
const clusterNotes = allNotes.filter(n => noteIds.includes(n.id) && n.embedding);
if (clusterNotes.length === 0) return undefined;
const embeddingDim = clusterNotes[0].embedding!.length;
const centroid = new Array(embeddingDim).fill(0);
for (const note of clusterNotes) {
for (let i = 0; i < embeddingDim; i++) {
centroid[i] += note.embedding![i];
}
}
for (let i = 0; i < embeddingDim; i++) {
centroid[i] /= clusterNotes.length;
}
return centroid;
}

View File

@@ -0,0 +1,200 @@
import { GoogleGenAI, Type } from "@google/genai";
import { BrainstormIdea } from "../types";
const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY });
const BRAINSTORM_SCHEMA = {
type: Type.OBJECT,
properties: {
ideas: {
type: Type.ARRAY,
items: {
type: Type.OBJECT,
properties: {
title: { type: Type.STRING },
description: { type: Type.STRING },
connection_to_seed: { type: Type.STRING },
novelty_score: { type: Type.NUMBER }
},
required: ["title", "description", "connection_to_seed", "novelty_score"]
}
}
},
required: ["ideas"]
};
const SUGGESTIONS_SCHEMA = {
type: Type.OBJECT,
properties: {
suggestions: {
type: Type.ARRAY,
items: {
type: Type.OBJECT,
properties: {
title: { type: Type.STRING },
description: { type: Type.STRING },
reasoning: { type: Type.STRING }
},
required: ["title", "description", "reasoning"]
}
}
},
required: ["suggestions"]
};
export async function generateBrainstormWave(
seedIdea: string,
waveNumber: number,
contextSummaries: string = ""
): Promise<Partial<BrainstormIdea>[]> {
const waveDescriptions = [
"", // index 0 unused
"VAGUE 1 (proximité directe) : Sous-aspects, reformulations, variations de l'idée. Reste dans le même domaine.",
"VAGUE 2 (analogies) : Trouve des parallèles dans d'autres domaines. Comment cette idée se manifeste-t-elle ailleurs ? Quelles techniques d'autres industries pourraient s'appliquer ?",
"VAGUE 3 (disruption) : Inverse l'idée. Pousse-la à l'extrême. Combine-la avec un domaine totalement non lié. Que se passe-t-il si l'opposé est vrai ?"
];
const prompt = `
Idée seed : "${seedIdea}"
Contexte : ${contextSummaries}
Génère 5 idées pour la VAGUE ${waveNumber} : ${waveDescriptions[waveNumber]}
Format JSON selon le schéma.
`;
try {
const response = await ai.models.generateContent({
model: "gemini-3-flash-preview",
contents: [{ role: "user", parts: [{ text: prompt }] }],
config: {
systemInstruction: "Tu es un expert en brainstorming. Réponds uniquement en JSON valide.",
responseMimeType: "application/json",
responseSchema: BRAINSTORM_SCHEMA,
temperature: 1.0
}
});
const resText = response.text;
if (!resText) return [];
const parsed = JSON.parse(resText.replace(/^```json\n?/, '').replace(/\n?```$/, '').trim());
const ideas = Array.isArray(parsed.ideas) ? parsed.ideas : (Array.isArray(parsed) ? parsed : []);
return ideas.map((item: any) => ({
title: item.title,
description: item.description,
connectionToSeed: item.connection_to_seed,
noveltyScore: item.novelty_score,
waveNumber
}));
} catch (error) {
console.error(`Error generating brainstorm wave ${waveNumber}:`, error);
throw error;
}
}
export async function generateExpansion(parentIdeaTitle: string, parentIdeaDescription: string): Promise<Partial<BrainstormIdea>[]> {
const prompt = `
Idée source : "${parentIdeaTitle} - ${parentIdeaDescription}"
Génère 3 idées d'extension ou de sous-aspects.
Format JSON.
`;
try {
const response = await ai.models.generateContent({
model: "gemini-3-flash-preview",
contents: [{ role: "user", parts: [{ text: prompt }] }],
config: {
systemInstruction: "Tu es un expert en brainstorming. Réponds uniquement en JSON valide.",
responseMimeType: "application/json",
responseSchema: BRAINSTORM_SCHEMA,
temperature: 1.0
}
});
const resText = response.text;
if (!resText) return [];
const parsed = JSON.parse(resText.replace(/^```json\n?/, '').replace(/\n?```$/, '').trim());
const ideas = Array.isArray(parsed.ideas) ? parsed.ideas : (Array.isArray(parsed) ? parsed : []);
return ideas.map((item: any) => ({
title: item.title,
description: item.description,
connectionToSeed: item.connection_to_seed,
noveltyScore: item.novelty_score
}));
} catch (error) {
console.error("Error generating expansion:", error);
throw error;
}
}
export async function getEmbedding(text: string): Promise<number[]> {
try {
const result = await ai.models.embedContent({
model: 'gemini-embedding-2-preview',
contents: [text],
});
return result.embeddings[0].values;
} catch (error) {
console.error("Error generating embedding:", error);
throw error;
}
}
export function cosineSimilarity(a: number[], b: number[]): number {
if (!a || !b || a.length !== b.length) return 0;
const dotProduct = a.reduce((sum, val, i) => sum + val * b[i], 0);
const magnitudeA = Math.sqrt(a.reduce((sum, val) => sum + val * val, 0));
const magnitudeB = Math.sqrt(b.reduce((sum, val) => sum + val * val, 0));
if (magnitudeA === 0 || magnitudeB === 0) return 0;
return dotProduct / (magnitudeA * magnitudeB);
}
export async function nameCluster(noteSummaries: string[]): Promise<string> {
const prompt = `Quel thème commun relie ces notes ? Donne un nom court (2-4 mots).\nNotes :\n${noteSummaries.join('\n- ')}`;
try {
const result = await ai.models.generateContent({
model: "gemini-3-flash-preview",
contents: prompt
});
return result.text.trim();
} catch (error) {
console.error("Error naming cluster:", error);
return "Thematic Cluster";
}
}
export async function suggestBridgeIdeas(
clusterAName: string,
clusterBName: string,
clusterASummaries: string,
clusterBSummaries: string
): Promise<any[]> {
const prompt = `
Cluster A (${clusterAName}) contient des notes sur : ${clusterASummaries}
Cluster B (${clusterBName}) contient des notes sur : ${clusterBSummaries}
Ces deux clusters ne sont pas connectés. Propose 3 idées
de "notes pont" qui pourraient créer un lien créatif entre eux.
Pour chaque idée : titre, description, pourquoi ça connecte les deux.
Format JSON.
`;
try {
const response = await ai.models.generateContent({
model: "gemini-3-flash-preview",
contents: prompt,
config: {
responseMimeType: "application/json",
responseSchema: SUGGESTIONS_SCHEMA
}
});
return JSON.parse(response.text);
} catch (error) {
console.error("Error suggesting bridge ideas:", error);
return [];
}
}

View File

@@ -0,0 +1,76 @@
import { Note, NoteAccessLog, NotePrediction } from '../types';
/**
* Simulates finding the dominant frequency in access logs for a specific note
* returning the period in days.
*/
export function detectAccessCycle(logs: NoteAccessLog[]): number | null {
if (logs.length < 5) return null;
const accessDays = logs
.map(log => new Date(log.accessedAt).getTime())
.sort((a, b) => a - b);
const intervals: number[] = [];
for (let i = 1; i < accessDays.length; i++) {
intervals.push((accessDays[i] - accessDays[i - 1]) / (1000 * 60 * 60 * 24));
}
// Simple heuristic: if intervals are consistently around a value, that's our cycle
// We'll calculate the median interval
const sortedIntervals = [...intervals].sort((a, b) => a - b);
const median = sortedIntervals[Math.floor(sortedIntervals.length / 2)];
// Check if enough intervals are close to median
const withinThreshold = intervals.filter(v => Math.abs(v - median) < Math.max(2, median * 0.2));
if (withinThreshold.length >= intervals.length * 0.6) {
return median;
}
return null;
}
export function predictNextAccess(note: Note, logs: NoteAccessLog[]): NotePrediction | null {
const cycleDays = detectAccessCycle(logs);
if (!cycleDays) return null;
const lastAccess = new Date(logs[logs.length - 1].accessedAt);
const nextAccessDate = new Date(lastAccess.getTime() + cycleDays * 24 * 60 * 60 * 1000);
const now = new Date();
const daysUntilNext = (nextAccessDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24);
// Only predict if it's coming up in the next 2 weeks
if (daysUntilNext > 0 && daysUntilNext < 14) {
return {
noteId: note.id,
predictedRelevanceDate: nextAccessDate.toISOString(),
confidence: 0.7,
reason: `Historical access pattern suggests a ${Math.round(cycleDays)}-day cycle.`,
generatedAt: now.toISOString()
};
}
return null;
}
export function getCoaccessedNotes(baseNoteId: string, logs: NoteAccessLog[], allNotes: Note[]): Note[] {
const WINDOW_MS = 30 * 60 * 1000; // 30 minutes
const baseNoteLogs = logs.filter(l => l.noteId === baseNoteId);
const coaccessedIds = new Set<string>();
baseNoteLogs.forEach(baseLog => {
const baseTime = new Date(baseLog.accessedAt).getTime();
logs.forEach(otherLog => {
if (otherLog.noteId === baseNoteId) return;
const otherTime = new Date(otherLog.accessedAt).getTime();
if (Math.abs(baseTime - otherTime) < WINDOW_MS) {
coaccessedIds.add(otherLog.noteId);
}
});
});
return allNotes.filter(n => coaccessedIds.has(n.id));
}