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
371 lines
12 KiB
TypeScript
371 lines
12 KiB
TypeScript
'use client'
|
|
|
|
import React, { useCallback, useMemo, useRef, useState } from 'react'
|
|
import dynamic from 'next/dynamic'
|
|
import { BrainstormSession, BrainstormIdea } from '@/types/brainstorm'
|
|
import { useExpandIdea, useDismissIdea, useConvertIdea, useExportBrainstorm } from '@/hooks/use-brainstorm'
|
|
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Sparkles, X, FileText, Download, ChevronDown } from 'lucide-react'
|
|
import { toast } from 'sonner'
|
|
|
|
const ForceGraph2D = dynamic(() => import('react-force-graph-2d'), {
|
|
ssr: false,
|
|
})
|
|
|
|
interface GraphNode {
|
|
id: string
|
|
name: string
|
|
val: number
|
|
color: string
|
|
borderColor: string
|
|
wave: number
|
|
status: string
|
|
idea: BrainstormIdea
|
|
x?: number
|
|
y?: number
|
|
__bckgDimensions?: [number, number]
|
|
}
|
|
|
|
interface GraphLink {
|
|
source: string | GraphNode
|
|
target: string | GraphNode
|
|
color: string
|
|
}
|
|
|
|
const WAVE_COLORS: Record<number, string> = {
|
|
0: '#ffffff',
|
|
1: '#f97316',
|
|
2: '#3b82f6',
|
|
3: '#a855f7',
|
|
}
|
|
|
|
const WAVE_BORDER: Record<number, string> = {
|
|
0: '#e5e5e5',
|
|
1: '#ea580c',
|
|
2: '#2563eb',
|
|
3: '#9333ea',
|
|
}
|
|
|
|
const STATUS_ALPHA: Record<string, number> = {
|
|
active: 1,
|
|
dismissed: 0.25,
|
|
converted: 0.9,
|
|
}
|
|
|
|
interface BrainstormCanvasProps {
|
|
session: BrainstormSession
|
|
}
|
|
|
|
export function BrainstormCanvas({ session }: BrainstormCanvasProps) {
|
|
const fgRef = useRef<any>(null)
|
|
const [selectedIdea, setSelectedIdea] = useState<BrainstormIdea | null>(null)
|
|
const [isSheetOpen, setIsSheetOpen] = useState(false)
|
|
const expandIdea = useExpandIdea(session.id)
|
|
const dismissIdea = useDismissIdea(session.id)
|
|
const convertIdea = useConvertIdea(session.id)
|
|
const exportBrainstorm = useExportBrainstorm(session.id)
|
|
|
|
const { graphData } = useMemo(() => {
|
|
const nodes: GraphNode[] = []
|
|
const links: GraphLink[] = []
|
|
|
|
nodes.push({
|
|
id: 'seed',
|
|
name: session.seedIdea.length > 30 ? session.seedIdea.slice(0, 30) + '...' : session.seedIdea,
|
|
val: 25,
|
|
color: WAVE_COLORS[0],
|
|
borderColor: WAVE_BORDER[0],
|
|
wave: 0,
|
|
status: 'active',
|
|
idea: {
|
|
id: 'seed',
|
|
sessionId: session.id,
|
|
waveNumber: 0,
|
|
title: session.seedIdea,
|
|
description: 'Original seed idea',
|
|
connectionToSeed: null,
|
|
noveltyScore: null,
|
|
parentIdeaId: null,
|
|
convertedToNoteId: null,
|
|
relatedNoteIds: null,
|
|
status: 'active',
|
|
positionX: null,
|
|
positionY: null,
|
|
createdAt: session.createdAt,
|
|
} as BrainstormIdea,
|
|
})
|
|
|
|
for (const idea of session.ideas) {
|
|
const parentNode = idea.parentIdeaId
|
|
? idea.parentIdeaId
|
|
: 'seed'
|
|
|
|
nodes.push({
|
|
id: idea.id,
|
|
name: idea.title,
|
|
val: idea.status === 'dismissed' ? 8 : 15,
|
|
color: WAVE_COLORS[idea.waveNumber] || WAVE_COLORS[3],
|
|
borderColor: idea.status === 'converted' ? '#22c55e' : (WAVE_BORDER[idea.waveNumber] || WAVE_BORDER[3]),
|
|
wave: idea.waveNumber,
|
|
status: idea.status,
|
|
idea,
|
|
})
|
|
|
|
const linkColor = WAVE_COLORS[idea.waveNumber] || WAVE_COLORS[3]
|
|
links.push({
|
|
source: parentNode,
|
|
target: idea.id,
|
|
color: idea.status === 'dismissed' ? '#444444' : linkColor,
|
|
})
|
|
}
|
|
|
|
return { graphData: { nodes, links } }
|
|
}, [session])
|
|
|
|
const handleNodeClick = useCallback((node: any) => {
|
|
setSelectedIdea(node.idea)
|
|
setIsSheetOpen(true)
|
|
}, [])
|
|
|
|
const handleExpand = useCallback(async () => {
|
|
if (!selectedIdea || selectedIdea.id === 'seed') return
|
|
try {
|
|
await expandIdea.mutateAsync(selectedIdea.id)
|
|
toast.success('Ideas expanded!')
|
|
} catch (err: any) {
|
|
toast.error(err.message || 'Failed to expand')
|
|
}
|
|
}, [selectedIdea, expandIdea])
|
|
|
|
const handleDismiss = useCallback(async () => {
|
|
if (!selectedIdea || selectedIdea.id === 'seed') return
|
|
try {
|
|
await dismissIdea.mutateAsync(selectedIdea.id)
|
|
setIsSheetOpen(false)
|
|
setSelectedIdea(null)
|
|
toast.success('Idea dismissed')
|
|
} catch (err: any) {
|
|
toast.error(err.message || 'Failed to dismiss')
|
|
}
|
|
}, [selectedIdea, dismissIdea])
|
|
|
|
const handleConvert = useCallback(async () => {
|
|
if (!selectedIdea || selectedIdea.id === 'seed') return
|
|
try {
|
|
await convertIdea.mutateAsync(selectedIdea.id)
|
|
toast.success('Idea converted to note!')
|
|
} catch (err: any) {
|
|
toast.error(err.message || 'Failed to convert')
|
|
}
|
|
}, [selectedIdea, convertIdea])
|
|
|
|
const handleExport = useCallback(async () => {
|
|
try {
|
|
const note = await exportBrainstorm.mutateAsync()
|
|
toast.success('Exported as note!')
|
|
} catch (err: any) {
|
|
toast.error(err.message || 'Failed to export')
|
|
}
|
|
}, [exportBrainstorm])
|
|
|
|
const paintNode = useCallback((node: any, ctx: CanvasRenderingContext2D, globalScale: number) => {
|
|
const label = node.name
|
|
const fontSize = Math.max(12 / globalScale, 4)
|
|
ctx.font = `${fontSize}px Sans-Serif`
|
|
const textWidth = ctx.measureText(label).width
|
|
const nodeSize = node.val
|
|
|
|
const bgDimensions: [number, number] = [textWidth + nodeSize * 0.8, nodeSize * 1.2]
|
|
node.__bckgDimensions = bgDimensions
|
|
|
|
const alpha = STATUS_ALPHA[node.status] || 1
|
|
ctx.globalAlpha = alpha
|
|
|
|
ctx.fillStyle = node.color
|
|
ctx.strokeStyle = node.borderColor
|
|
ctx.lineWidth = node.status === 'converted' ? 3 / globalScale : 1.5 / globalScale
|
|
|
|
ctx.beginPath()
|
|
ctx.roundRect(
|
|
node.x! - bgDimensions[0] / 2,
|
|
node.y! - bgDimensions[1] / 2,
|
|
bgDimensions[0],
|
|
bgDimensions[1],
|
|
nodeSize * 0.3
|
|
)
|
|
ctx.fill()
|
|
ctx.stroke()
|
|
|
|
ctx.fillStyle = node.wave === 0 ? '#000000' : '#ffffff'
|
|
ctx.textAlign = 'center'
|
|
ctx.textBaseline = 'middle'
|
|
ctx.fillText(label, node.x!, node.y!)
|
|
|
|
if (node.status === 'converted') {
|
|
ctx.fillStyle = '#22c55e'
|
|
ctx.font = `${fontSize * 1.2}px Sans-Serif`
|
|
ctx.fillText('✓', node.x! + bgDimensions[0] / 2 - fontSize * 0.5, node.y! - bgDimensions[1] / 2 + fontSize * 0.5)
|
|
}
|
|
|
|
ctx.globalAlpha = 1
|
|
}, [])
|
|
|
|
const waveLegend = [
|
|
{ label: 'Seed', color: WAVE_COLORS[0] },
|
|
{ label: 'Variations', color: WAVE_COLORS[1] },
|
|
{ label: 'Analogies', color: WAVE_COLORS[2] },
|
|
{ label: 'Disruptions', color: WAVE_COLORS[3] },
|
|
]
|
|
|
|
return (
|
|
<div className="relative w-full h-full bg-zinc-950 rounded-xl overflow-hidden">
|
|
<ForceGraph2D
|
|
ref={fgRef}
|
|
graphData={graphData}
|
|
nodeCanvasObject={paintNode}
|
|
nodePointerAreaPaint={(node: any, color: string, ctx: CanvasRenderingContext2D) => {
|
|
const dims = node.__bckgDimensions
|
|
if (!dims) return
|
|
ctx.fillStyle = color
|
|
ctx.beginPath()
|
|
ctx.roundRect(node.x! - dims[0] / 2, node.y! - dims[1] / 2, dims[0], dims[1], (node.val || 10) * 0.3)
|
|
ctx.fill()
|
|
}}
|
|
onNodeClick={handleNodeClick}
|
|
linkColor={(link: any) => link.color}
|
|
linkWidth={1}
|
|
linkDirectionalArrowLength={3}
|
|
linkDirectionalArrowRelPos={1}
|
|
backgroundColor="#09090b"
|
|
nodeVal={(node: any) => node.val}
|
|
cooldownTicks={100}
|
|
enableNodeDrag={true}
|
|
enableZoomInteraction={true}
|
|
enablePanInteraction={true}
|
|
warmupTicks={50}
|
|
/>
|
|
|
|
<div className="absolute top-4 left-4 flex flex-col gap-1.5">
|
|
{waveLegend.map(w => (
|
|
<div key={w.label} className="flex items-center gap-2 text-xs text-zinc-400">
|
|
<div className="w-3 h-3 rounded-sm border border-zinc-600" style={{ backgroundColor: w.color }} />
|
|
<span>{w.label}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<div className="absolute top-4 right-4">
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
className="bg-zinc-800 border-zinc-700 text-zinc-300 hover:bg-zinc-700 hover:text-white"
|
|
onClick={handleExport}
|
|
disabled={exportBrainstorm.isPending}
|
|
>
|
|
<Download size={14} className="mr-1" />
|
|
{exportBrainstorm.isPending ? 'Exporting...' : 'Export'}
|
|
</Button>
|
|
</div>
|
|
|
|
<Sheet open={isSheetOpen} onOpenChange={setIsSheetOpen}>
|
|
<SheetContent className="bg-zinc-900 border-zinc-800 text-white w-96">
|
|
{selectedIdea && (
|
|
<>
|
|
<SheetHeader>
|
|
<SheetTitle className="text-white text-left">
|
|
{selectedIdea.id === 'seed' ? session.seedIdea : selectedIdea.title}
|
|
</SheetTitle>
|
|
</SheetHeader>
|
|
<div className="mt-6 space-y-4">
|
|
{selectedIdea.description && selectedIdea.id !== 'seed' && (
|
|
<div>
|
|
<p className="text-xs text-zinc-500 uppercase tracking-wider mb-1">Description</p>
|
|
<p className="text-sm text-zinc-300">{selectedIdea.description}</p>
|
|
</div>
|
|
)}
|
|
|
|
{selectedIdea.connectionToSeed && (
|
|
<div>
|
|
<p className="text-xs text-zinc-500 uppercase tracking-wider mb-1">Connection</p>
|
|
<p className="text-sm text-zinc-300">{selectedIdea.connectionToSeed}</p>
|
|
</div>
|
|
)}
|
|
|
|
{selectedIdea.noveltyScore && (
|
|
<div>
|
|
<p className="text-xs text-zinc-500 uppercase tracking-wider mb-1">Novelty</p>
|
|
<div className="flex items-center gap-2">
|
|
<div className="flex-1 h-2 bg-zinc-800 rounded-full overflow-hidden">
|
|
<div
|
|
className="h-full bg-gradient-to-r from-orange-500 via-memento-blue to-purple-500 rounded-full"
|
|
style={{ width: `${selectedIdea.noveltyScore * 10}%` }}
|
|
/>
|
|
</div>
|
|
<span className="text-sm text-zinc-400">{selectedIdea.noveltyScore}/10</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{selectedIdea.waveNumber > 0 && (
|
|
<div>
|
|
<p className="text-xs text-zinc-500 uppercase tracking-wider mb-1">Wave</p>
|
|
<span
|
|
className="inline-block px-2 py-0.5 rounded text-xs font-medium"
|
|
style={{
|
|
backgroundColor: WAVE_COLORS[selectedIdea.waveNumber] + '30',
|
|
color: WAVE_COLORS[selectedIdea.waveNumber],
|
|
}}
|
|
>
|
|
{selectedIdea.waveNumber === 1 ? 'Variation' : selectedIdea.waveNumber === 2 ? 'Analogy' : 'Disruption'}
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{selectedIdea.status === 'converted' && (
|
|
<div className="p-3 bg-green-500/10 border border-green-500/20 rounded-lg">
|
|
<p className="text-xs text-green-400 flex items-center gap-1">
|
|
<FileText size={12} />
|
|
Converted to note
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{selectedIdea.id !== 'seed' && selectedIdea.status === 'active' && (
|
|
<div className="flex flex-col gap-2 pt-4 border-t border-zinc-800">
|
|
<Button
|
|
onClick={handleExpand}
|
|
disabled={expandIdea.isPending}
|
|
className="w-full bg-orange-600 hover:bg-orange-700 text-white"
|
|
>
|
|
<Sparkles size={14} className="mr-1" />
|
|
{expandIdea.isPending ? 'Generating...' : 'Dig Deeper'}
|
|
</Button>
|
|
<Button
|
|
onClick={handleConvert}
|
|
disabled={convertIdea.isPending}
|
|
className="w-full bg-memento-blue hover:bg-blue-700 text-white"
|
|
>
|
|
<FileText size={14} className="mr-1" />
|
|
{convertIdea.isPending ? 'Converting...' : 'Create Note'}
|
|
</Button>
|
|
<Button
|
|
onClick={handleDismiss}
|
|
disabled={dismissIdea.isPending}
|
|
variant="outline"
|
|
className="w-full border-zinc-700 text-zinc-400 hover:text-white hover:bg-zinc-800"
|
|
>
|
|
<X size={14} className="mr-1" />
|
|
Dismiss
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
</SheetContent>
|
|
</Sheet>
|
|
</div>
|
|
)
|
|
}
|