Files
Momento/architectural-grid11/src/components/BrainstormView/BrainstormView.tsx
Antigravity 1fcea6ed7d
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 7s
feat: brainstorm sessions, PDF document Q&A, embedding fixes, and UI improvements
- 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
2026-05-14 17:43:21 +00:00

446 lines
20 KiB
TypeScript

import React, { useState, useEffect, useMemo } from 'react';
import { motion, AnimatePresence } from 'motion/react';
import {
Zap,
Search,
ArrowRight,
History,
Plus,
Wind,
PlusCircle,
FileText,
ChevronRight,
Maximize2
} from 'lucide-react';
import { WaveCanvas } from './WaveCanvas';
import { BrainstormSession, BrainstormIdea, Note } from '../../types';
import { generateBrainstormWave, generateExpansion, getEmbedding, cosineSimilarity } from '../../services/geminiService';
interface BrainstormViewProps {
notes: Note[];
onConvertNote: (idea: BrainstormIdea) => void;
}
export const BrainstormView: React.FC<BrainstormViewProps> = ({ notes, onConvertNote }) => {
const [seedInput, setSeedInput] = useState('');
const [isGenerating, setIsGenerating] = useState(false);
const [error, setError] = useState<string | null>(null);
const [sessions, setSessions] = useState<BrainstormSession[]>([]);
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
const [ideas, setIdeas] = useState<BrainstormIdea[]>([]);
const [selectedIdeaId, setSelectedIdeaId] = useState<string | null>(null);
useEffect(() => {
fetch('/api/brainstorm/sessions')
.then(res => res.json())
.then(data => setSessions(data))
.catch(err => console.error("Failed to load sessions", err));
}, []);
useEffect(() => {
if (activeSessionId) {
fetch(`/api/brainstorm/${activeSessionId}`)
.then(res => res.json())
.then(data => {
if (data.ideas) {
setIdeas(prev => {
const filtered = prev.filter(i => i.sessionId !== activeSessionId);
return [...filtered, ...data.ideas];
});
}
})
.catch(err => console.error("Failed to load ideas", err));
}
}, [activeSessionId]);
const activeSession = useMemo(() =>
sessions.find(s => s.id === activeSessionId),
[activeSessionId, sessions]);
const activeIdeas = useMemo(() =>
ideas.filter(i => i.sessionId === activeSessionId),
[activeSessionId, ideas]);
const selectedIdea = useMemo(() =>
ideas.find(i => i.id === selectedIdeaId),
[selectedIdeaId, ideas]);
useEffect(() => {
const handleRemoteStart = (e: any) => {
if (e.detail?.seed) {
handleStartBrainstorm(e.detail.seed, e.detail.sourceNoteId);
}
};
window.addEventListener('start-brainstorm', handleRemoteStart);
return () => window.removeEventListener('start-brainstorm', handleRemoteStart);
}, [notes]);
const handleStartBrainstorm = async (seed: string, sourceNoteId?: string) => {
if (!seed.trim()) return;
setIsGenerating(true);
setError(null);
try {
// 1. Create session on backend
const sessionRes = await fetch('/api/brainstorm/sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
seedIdea: seed,
sourceNoteId
})
});
const session = await sessionRes.json();
if (!sessionRes.ok) throw new Error(session.error || "Failed to create session");
setSessions(prev => [session, ...prev]);
setActiveSessionId(session.id);
setSeedInput('');
// 2. Generate waves in frontend concurrently
const contextSummaries = notes.slice(0, 5).map(n => n.title).join(', ');
const wavePromises = [1, 2, 3].map(async (num) => {
try {
const generated = await generateBrainstormWave(seed, num, contextSummaries);
return generated.map(g => ({
...g,
waveNumber: num
}));
} catch (e) {
console.error(`Wave ${num} failed`, e);
return [];
}
});
const wavesResults = await Promise.all(wavePromises);
const allNewIdeas = wavesResults.flat();
if (allNewIdeas.length === 0) {
throw new Error("No ideas were generated. Gemini might be shy today.");
}
// 3. Save ideas to backend
const ideasRes = await fetch(`/api/brainstorm/${session.id}/ideas`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ideas: allNewIdeas })
});
const savedIdeas = await ideasRes.json();
setIdeas(prev => [...prev, ...savedIdeas]);
} catch (err: any) {
console.error("Brainstorm failed:", err);
setError(err.message || "An unexpected error occurred while brainstorming.");
} finally {
setIsGenerating(false);
}
};
const updateIdea = async (ideaId: string, updates: Partial<BrainstormIdea>) => {
try {
const res = await fetch(`/api/brainstorm/ideas/${ideaId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates)
});
const updated = await res.json();
setIdeas(prev => prev.map(i => i.id === ideaId ? updated : i));
} catch (err) {
console.error("Update failed", err);
}
};
const handleDeepenIdea = async (idea: BrainstormIdea) => {
setIsGenerating(true);
try {
const generated = await generateExpansion(idea.title, idea.description);
const newIdeasData = generated.map(g => ({
...g,
waveNumber: Math.min(idea.waveNumber + 1, 3),
parentIdeaId: idea.id
}));
const res = await fetch(`/api/brainstorm/${idea.sessionId}/ideas`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ideas: newIdeasData })
});
const savedIdeas = await res.json();
setIdeas(prev => [...prev, ...savedIdeas]);
} catch (err) {
console.error("Deepen failed", err);
setError("Failed to expand this idea.");
} finally {
setIsGenerating(false);
}
};
const handleDismissIdea = (ideaId: string) => {
updateIdea(ideaId, { status: 'dismissed' });
setSelectedIdeaId(null);
};
const handleConvertToNote = (idea: BrainstormIdea) => {
updateIdea(idea.id, { status: 'converted' });
onConvertNote(idea);
};
return (
<div className="h-full flex flex-col bg-[#F8F7F2] dark:bg-[#0A0A0A] overflow-hidden">
{/* Header / Start area */}
<div className="p-12 border-b border-border/20 backdrop-blur-md bg-white/20 dark:bg-dark-paper/20 z-10 relative overflow-hidden">
{/* Architectural Grid Background */}
<div className="absolute inset-0 pointer-events-none opacity-[0.03] dark:opacity-[0.05]"
style={{ backgroundImage: 'linear-gradient(#000 1px, transparent 1px), linear-gradient(90deg, #000 1px, transparent 1px)', backgroundSize: '40px 40px' }} />
<div className="max-w-4xl mx-auto relative">
<div className="flex items-center gap-5 mb-8">
<motion.div
animate={{ rotate: isGenerating ? 360 : 0 }}
transition={{ repeat: isGenerating ? Infinity : 0, duration: 20, ease: "linear" }}
className="w-14 h-14 rounded-2xl bg-ochre shadow-[0_0_20px_rgba(212,163,115,0.2)] flex items-center justify-center text-paper"
>
<Wind size={28} />
</motion.div>
<div>
<h1 className="text-4xl font-serif font-medium text-ink dark:text-dark-ink tracking-tight">Waves of Thought</h1>
<div className="flex items-center gap-2 mt-1">
<span className="w-8 h-px bg-ochre/40" />
<p className="text-[10px] text-concrete tracking-[0.3em] uppercase font-bold">Unfold dimensions of potentiality</p>
</div>
</div>
</div>
<div className="relative group">
<div className="absolute -inset-1 bg-gradient-to-r from-ochre/20 to-blueprint/20 rounded-[28px] blur-xl opacity-0 group-focus-within:opacity-100 transition-opacity duration-700" />
<input
type="text"
value={seedInput}
onChange={(e) => setSeedInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleStartBrainstorm(seedInput)}
placeholder="Enter a concept to unfold..."
className={`w-full relative bg-white dark:bg-[#1A1A1A] border-2 rounded-2xl px-8 py-7 pr-20 outline-none transition-all text-2xl font-serif italic text-ink dark:text-dark-ink shadow-sm group-hover:shadow-md
${error ? 'border-rose-400 focus:ring-rose-100 shadow-rose-100' : 'border-border/40 focus:border-ochre/40 focus:ring-4 focus:ring-ochre/5'}`}
/>
<button
onClick={() => handleStartBrainstorm(seedInput)}
disabled={isGenerating || !seedInput.trim()}
className="absolute right-4 top-4 bottom-4 px-6 bg-ink dark:bg-ochre text-paper rounded-xl disabled:opacity-50 transition-all hover:scale-[1.02] active:scale-[0.98] flex items-center justify-center gap-2 min-w-[70px] shadow-lg"
>
{isGenerating ? (
<div className="w-6 h-6 border-3 border-paper/30 border-t-paper rounded-full animate-spin" />
) : (
<Plus size={24} />
)}
</button>
</div>
<AnimatePresence>
{error && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="mt-6 p-5 bg-rose-50 dark:bg-rose-500/10 border border-rose-200 dark:border-rose-500/20 rounded-2xl flex items-start gap-4 text-rose-600 dark:text-rose-400 text-sm overflow-hidden shadow-sm"
>
<div className="w-5 h-5 rounded-full bg-rose-100 dark:bg-rose-500/20 flex items-center justify-center shrink-0 mt-0.5">
<div className="w-2 h-2 rounded-full bg-rose-500" />
</div>
<div className="flex-1">
<p className="font-bold uppercase tracking-wider text-[10px] mb-1">Obstruction detected</p>
<span>{error}</span>
</div>
</motion.div>
)}
{isGenerating && !error && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="mt-6 flex items-center gap-4 text-ochre/80 italic font-serif"
>
<div className="flex gap-1.5">
{[0.2, 0.4, 0.6].map((d, i) => (
<motion.div
key={i}
animate={{ scale: [1, 1.5, 1], opacity: [0.3, 1, 0.3] }}
transition={{ duration: 1.5, repeat: Infinity, delay: d }}
className="w-1.5 h-1.5 rounded-full bg-ochre"
/>
))}
</div>
<span className="text-base tracking-tight">Gemini is harvesting seeds of thought from the digital ether...</span>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
<div className="flex-1 flex overflow-hidden relative">
{/* Main Canvas Area */}
<div className="flex-1 relative bg-[radial-gradient(#e5e7eb_1px,transparent_1px)] dark:bg-[radial-gradient(#ffffff10_1px,transparent_1px)] [background-size:20px_20px]">
{activeSession ? (
<WaveCanvas
session={activeSession}
ideas={activeIdeas}
onNodeSelect={setSelectedIdeaId}
onPositionUpdate={(id, pos) => updateIdea(id, { position: pos })}
selectedNodeId={selectedIdeaId}
relatedNotes={notes}
/>
) : (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none opacity-20 flex-col gap-6">
<Wind size={120} strokeWidth={1} className="text-concrete animate-pulse" />
<p className="text-xl font-serif italic text-concrete">The canvas is waiting for your spark...</p>
</div>
)}
{/* Floating UI overlays */}
<AnimatePresence>
{activeSession && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="absolute bottom-6 left-6 flex gap-2"
>
<div className="px-4 py-2 bg-paper/80 dark:bg-black/60 backdrop-blur-xl border border-border shadow-xl rounded-full flex items-center gap-6">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-orange-400 shadow-[0_0_8px_rgba(251,146,60,0.6)]" />
<span className="text-[10px] font-bold uppercase tracking-widest text-concrete">Wave 1</span>
</div>
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-blue-400 shadow-[0_0_8px_rgba(96,165,250,0.6)]" />
<span className="text-[10px] font-bold uppercase tracking-widest text-concrete">Wave 2</span>
</div>
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-violet-400 shadow-[0_0_8px_rgba(167,139,250,0.6)]" />
<span className="text-[10px] font-bold uppercase tracking-widest text-concrete">Wave 3</span>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
{/* Right Sidebar Detail Panel */}
<AnimatePresence>
{selectedIdea && (
<motion.div
initial={{ x: '100%' }}
animate={{ x: 0 }}
exit={{ x: '100%' }}
className="w-[400px] border-l border-border bg-paper dark:bg-dark-paper flex flex-col z-20 shadow-[-20px_0_40px_rgba(0,0,0,0.05)]"
>
<div className="p-8 flex-1 overflow-y-auto custom-scrollbar">
<div className="flex items-center justify-between mb-8">
<div className={`px-3 py-1 rounded-full text-[10px] font-bold uppercase tracking-widest border
${selectedIdea.waveNumber === 1 ? 'border-orange-200 bg-orange-50 text-orange-600' :
selectedIdea.waveNumber === 2 ? 'border-blue-200 bg-blue-50 text-blue-600' :
'border-violet-200 bg-violet-50 text-violet-600'}`}>
Vague {selectedIdea.waveNumber}
</div>
<div className="flex items-center gap-2">
{selectedIdea.status === 'converted' && (
<span className="text-[10px] font-bold text-emerald-500 uppercase tracking-widest bg-emerald-500/10 px-2 py-1 rounded-full">Note Created</span>
)}
<button onClick={() => setSelectedIdeaId(null)} className="p-2 hover:bg-ink/5 rounded-full transition-colors">
<ChevronRight size={20} />
</button>
</div>
</div>
<h2 className="text-3xl font-serif font-medium text-ink dark:text-dark-ink mb-2">{selectedIdea.title}</h2>
<div className="flex items-center gap-4 mb-8">
<div className="flex items-center gap-1">
<Zap size={14} className="text-ochre" />
<span className="text-xs font-bold text-concrete">Novelty: {selectedIdea.noveltyScore}/10</span>
</div>
</div>
<p className="text-ink/80 dark:text-dark-ink/80 leading-relaxed font-light mb-10 text-lg">
{selectedIdea.description}
</p>
<div className="p-6 bg-slate-50 dark:bg-white/5 rounded-2xl border border-border/40 mb-10">
<h4 className="text-[10px] font-bold uppercase tracking-widest text-concrete mb-3">Origin connection</h4>
<p className="text-sm italic text-muted-ink leading-relaxed">
"{selectedIdea.connectionToSeed}"
</p>
</div>
{selectedIdea.relatedNoteIds && selectedIdea.relatedNoteIds.length > 0 && (
<div className="space-y-4 mb-10">
<h4 className="text-[10px] font-bold uppercase tracking-widest text-concrete px-1">Semantic Context</h4>
{selectedIdea.relatedNoteIds.map(noteId => {
const note = notes.find(n => n.id === noteId);
return note ? (
<div key={noteId} className="p-4 rounded-xl border border-border bg-white dark:bg-white/5 hover:border-ink/20 transition-all cursor-pointer group">
<div className="flex items-center justify-between">
<h5 className="text-sm font-medium text-ink dark:text-dark-ink truncate">{note.title}</h5>
<ArrowRight size={14} className="text-concrete group-hover:text-ink transition-colors" />
</div>
</div>
) : null;
})}
</div>
)}
<div className="grid grid-cols-1 gap-4">
<div className="grid grid-cols-2 gap-4">
<button
onClick={() => handleDeepenIdea(selectedIdea)}
disabled={isGenerating}
className="flex flex-col items-center justify-center p-6 border-2 border-dashed border-border rounded-2xl hover:border-ochre/40 hover:bg-ochre/5 transition-all group disabled:opacity-50"
>
<Wind size={24} className="text-concrete group-hover:text-ochre mb-2" />
<span className="text-[11px] font-bold uppercase tracking-widest text-muted-ink group-hover:text-ink">Deepen</span>
</button>
<button
onClick={() => handleConvertToNote(selectedIdea)}
disabled={selectedIdea.status === 'converted'}
className="flex flex-col items-center justify-center p-6 border-2 border-dashed border-border rounded-2xl hover:border-blueprint/40 hover:bg-blueprint/5 transition-all group disabled:opacity-50"
>
<FileText size={24} className="text-concrete group-hover:text-blueprint mb-2" />
<span className="text-[11px] font-bold uppercase tracking-widest text-muted-ink group-hover:text-ink">Extract</span>
</button>
</div>
<button
onClick={() => handleDismissIdea(selectedIdea.id)}
className="w-full py-4 text-[10px] font-bold uppercase tracking-[0.2em] text-concrete hover:text-rose-500 hover:bg-rose-500/5 rounded-xl transition-all border border-transparent hover:border-rose-500/10"
>
Not pertinent
</button>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
{/* History Rail */}
<div className="w-16 border-l border-border flex flex-col items-center py-6 gap-6 bg-paper dark:bg-dark-paper z-10">
<History size={18} className="text-concrete" />
<div className="w-px flex-1 bg-border/40" />
<div className="flex flex-col gap-3 overflow-y-auto px-2 custom-scrollbar">
{sessions.map(session => (
<button
key={session.id}
onClick={() => setActiveSessionId(session.id)}
className={`w-10 h-10 min-h-[40px] rounded-xl flex items-center justify-center text-xs font-bold transition-all shrink-0
${activeSessionId === session.id ? 'bg-ink text-paper scale-110 shadow-lg' : 'bg-paper dark:bg-white/10 text-concrete hover:bg-black/5 hover:text-ink'}`}
title={session.seedIdea}
>
{session.seedIdea.charAt(0).toUpperCase()}
</button>
))}
</div>
<div className="w-px h-12 bg-border/40" />
</div>
</div>
</div>
);
};