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
446 lines
20 KiB
TypeScript
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>
|
|
);
|
|
};
|