feat: migrate semantic search to pgvector + full-text search
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m12s
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m12s
Replace JSON-string embeddings with native pgvector(1536) storage and add PostgreSQL full-text search (tsvector/GIN) with Reciprocal Rank Fusion for hybrid keyword + semantic ranking. Changes: - NoteEmbedding.embedding: String → vector(1536) via pgvector - NoteEmbedding: added updatedAt for reindex tracking - Note: added tsv (tsvector) with auto-update trigger for FTS - semantic-search.service: hybrid FTS + vector search with RRF fusion - embedding.service: toVectorString() for pgvector SQL literals - Removed JS-side cosine similarity loops (now DB-side via <=>) - Added HNSW index on NoteEmbedding.embedding (cosine distance) - Added GIN index on Note.tsv for FTS queries Schema migration in: prisma/migrations/20260512120000_pgvector_and_fts_search/ Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
450
architectural-grid10/src/App.tsx
Normal file
450
architectural-grid10/src/App.tsx
Normal file
@@ -0,0 +1,450 @@
|
||||
/**
|
||||
* @license
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
|
||||
// Components
|
||||
import { Sidebar } from './components/Sidebar';
|
||||
import { NotebooksView } from './components/NotebooksView';
|
||||
import { AgentsView } from './components/AgentsView';
|
||||
import { SettingsView } from './components/SettingsView';
|
||||
import { TrashView } from './components/TrashView';
|
||||
import { AISidebar } from './components/AISidebar';
|
||||
import { SlashMenu } from './components/SlashMenu';
|
||||
|
||||
// Data & Types
|
||||
import { CARNETS, ALL_NOTES } from './constants';
|
||||
import { NavigationView, SettingsTab, AITab, AITone, Carnet, Note } from './types';
|
||||
|
||||
export default function App() {
|
||||
const [activeView, setActiveView] = useState<NavigationView>('notebooks');
|
||||
const [activeSettingsTab, setActiveSettingsTab] = useState<SettingsTab>('general');
|
||||
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
|
||||
const [isDarkMode, setIsDarkMode] = useState(false);
|
||||
const [carnets, setCarnets] = useState<Carnet[]>(CARNETS);
|
||||
const [notes, setNotes] = useState<Note[]>(ALL_NOTES);
|
||||
const [activeCarnetId, setActiveCarnetId] = useState('4');
|
||||
const [activeNoteId, setActiveNoteId] = useState<string | null>(null);
|
||||
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]);
|
||||
const [isAISidebarOpen, setIsAISidebarOpen] = useState(false);
|
||||
const [aiTab, setAiTab] = useState<AITab>('discussion');
|
||||
const [selectedTone, setSelectedTone] = useState<AITone>('Professional');
|
||||
|
||||
// Modal States
|
||||
const [showNewCarnetModal, setShowNewCarnetModal] = useState<{ isOpen: boolean; parentId?: string; isRenaming?: boolean; carnetId?: string }>({ isOpen: false });
|
||||
const [showNewNoteModal, setShowNewNoteModal] = useState(false);
|
||||
const [slashMenu, setSlashMenu] = useState<{ isOpen: boolean; top: number; left: number } | null>(null);
|
||||
|
||||
// Form States
|
||||
const [newCarnetName, setNewCarnetName] = useState('');
|
||||
const [newNoteTitle, setNewNoteTitle] = useState('');
|
||||
const [newNoteContent, setNewNoteContent] = useState('');
|
||||
|
||||
const handleEditorKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === '/') {
|
||||
const selection = window.getSelection();
|
||||
if (selection && selection.rangeCount > 0) {
|
||||
const range = selection.getRangeAt(0);
|
||||
const rect = range.getBoundingClientRect();
|
||||
setSlashMenu({
|
||||
isOpen: true,
|
||||
top: rect.bottom + window.scrollY,
|
||||
left: rect.left + window.scrollX
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const togglePin = (noteId: string) => {
|
||||
setNotes(notes.map(n => n.id === noteId ? { ...n, isPinned: !n.isPinned } : n));
|
||||
};
|
||||
|
||||
const filteredNotes = useMemo(() => {
|
||||
let result = notes.filter(n => n.carnetId === activeCarnetId && !n.isDeleted);
|
||||
|
||||
if (selectedTagIds.length > 0) {
|
||||
result = result.filter(note =>
|
||||
selectedTagIds.every(tagId => note.tags?.some(tag => tag.id === tagId))
|
||||
);
|
||||
}
|
||||
|
||||
return [...result].sort((a, b) => {
|
||||
if (a.isPinned && !b.isPinned) return -1;
|
||||
if (!a.isPinned && b.isPinned) return 1;
|
||||
return 0;
|
||||
});
|
||||
}, [activeCarnetId, notes]);
|
||||
|
||||
const activeNote = useMemo(() =>
|
||||
notes.find(n => n.id === activeNoteId),
|
||||
[activeNoteId, notes]);
|
||||
|
||||
const activeCarnet = useMemo(() =>
|
||||
carnets.find(c => c.id === activeCarnetId),
|
||||
[activeCarnetId, carnets]);
|
||||
|
||||
const handleAddCarnet = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newCarnetName.trim()) return;
|
||||
|
||||
if (showNewCarnetModal.isRenaming && showNewCarnetModal.carnetId) {
|
||||
setCarnets(carnets.map(c => c.id === showNewCarnetModal.carnetId ? { ...c, name: newCarnetName, initial: newCarnetName.charAt(0).toUpperCase() } : c));
|
||||
setShowNewCarnetModal({ isOpen: false });
|
||||
setNewCarnetName('');
|
||||
return;
|
||||
}
|
||||
|
||||
const newCarnet: Carnet = {
|
||||
id: Date.now().toString(),
|
||||
name: newCarnetName,
|
||||
initial: newCarnetName.charAt(0).toUpperCase(),
|
||||
type: 'Project',
|
||||
parentId: showNewCarnetModal.parentId
|
||||
};
|
||||
|
||||
setCarnets([...carnets, newCarnet]);
|
||||
setNewCarnetName('');
|
||||
setShowNewCarnetModal({ isOpen: false });
|
||||
setActiveCarnetId(newCarnet.id);
|
||||
};
|
||||
|
||||
const handleDeleteCarnet = (id: string) => {
|
||||
if (window.confirm('Déplacer ce carnet et ses sous-carnets vers la corbeille ?')) {
|
||||
const idsToDelete = new Set<string>([id]);
|
||||
|
||||
const addChildren = (parentId: string) => {
|
||||
carnets.forEach(c => {
|
||||
if (c.parentId === parentId) {
|
||||
idsToDelete.add(c.id);
|
||||
addChildren(c.id);
|
||||
}
|
||||
});
|
||||
};
|
||||
addChildren(id);
|
||||
|
||||
const deletedAt = new Date().toISOString();
|
||||
setCarnets(carnets.map(c => idsToDelete.has(c.id) ? { ...c, isDeleted: true, deletedAt } : c));
|
||||
setNotes(notes.map(n => idsToDelete.has(n.carnetId) ? { ...n, isDeleted: true, deletedAt } : n));
|
||||
|
||||
if (idsToDelete.has(activeCarnetId)) {
|
||||
setActiveCarnetId('1');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteNote = (id: string) => {
|
||||
const deletedAt = new Date().toISOString();
|
||||
setNotes(notes.map(n => n.id === id ? { ...n, isDeleted: true, deletedAt } : n));
|
||||
if (activeNoteId === id) setActiveNoteId(null);
|
||||
};
|
||||
|
||||
const handleRestoreCarnet = (id: string) => {
|
||||
setCarnets(carnets.map(c => c.id === id ? { ...c, isDeleted: false, deletedAt: undefined } : c));
|
||||
// Optionally restore linked notes too? User might expect that.
|
||||
setNotes(notes.map(n => n.carnetId === id ? { ...n, isDeleted: false, deletedAt: undefined } : n));
|
||||
};
|
||||
|
||||
const handleRestoreNote = (id: string) => {
|
||||
setNotes(notes.map(n => n.id === id ? { ...n, isDeleted: false, deletedAt: undefined } : n));
|
||||
};
|
||||
|
||||
const handlePermanentDeleteNote = (id: string) => {
|
||||
setNotes(notes.filter(n => n.id !== id));
|
||||
};
|
||||
|
||||
const handlePermanentDeleteCarnet = (id: string) => {
|
||||
const idsToDelete = new Set<string>([id]);
|
||||
const addChildren = (parentId: string) => {
|
||||
carnets.forEach(c => {
|
||||
if (c.parentId === parentId) {
|
||||
idsToDelete.add(c.id);
|
||||
addChildren(c.id);
|
||||
}
|
||||
});
|
||||
};
|
||||
addChildren(id);
|
||||
setCarnets(carnets.filter(c => !idsToDelete.has(c.id)));
|
||||
setNotes(notes.filter(n => !idsToDelete.has(n.carnetId)));
|
||||
};
|
||||
|
||||
const handleAddNote = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newNoteTitle.trim() || !newNoteContent.trim()) return;
|
||||
|
||||
const newNote: Note = {
|
||||
id: `n-${Date.now()}`,
|
||||
carnetId: activeCarnetId,
|
||||
title: newNoteTitle,
|
||||
date: new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric' }).format(new Date()),
|
||||
content: newNoteContent,
|
||||
imageUrl: 'https://images.unsplash.com/photo-1487958449943-2429e8be8625?auto=format&fit=crop&q=80&w=800&h=600',
|
||||
tags: []
|
||||
};
|
||||
|
||||
setNotes([newNote, ...notes]);
|
||||
setNewNoteTitle('');
|
||||
setNewNoteContent('');
|
||||
setShowNewNoteModal(false);
|
||||
setActiveNoteId(newNote.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`h-screen flex bg-paper transition-colors duration-500 overflow-hidden font-sans ${isDarkMode ? 'dark' : ''}`}>
|
||||
<Sidebar
|
||||
activeView={activeView}
|
||||
isDarkMode={isDarkMode}
|
||||
setIsDarkMode={setIsDarkMode}
|
||||
setActiveView={setActiveView}
|
||||
carnets={carnets}
|
||||
notes={notes}
|
||||
activeCarnetId={activeCarnetId}
|
||||
activeNoteId={activeNoteId}
|
||||
setActiveCarnetId={setActiveCarnetId}
|
||||
setActiveNoteId={setActiveNoteId}
|
||||
setShowNewCarnetModal={(show, parentId, isRenaming, carnetId) => {
|
||||
setShowNewCarnetModal({ isOpen: show, parentId, isRenaming, carnetId });
|
||||
if (isRenaming && carnetId) {
|
||||
const carnet = carnets.find(c => c.id === carnetId);
|
||||
if (carnet) setNewCarnetName(carnet.name);
|
||||
} else {
|
||||
setNewCarnetName('');
|
||||
}
|
||||
}}
|
||||
onDeleteCarnet={handleDeleteCarnet}
|
||||
/>
|
||||
|
||||
<main className="flex-1 relative overflow-hidden flex bg-paper dark:bg-dark-paper transition-colors duration-500">
|
||||
<AnimatePresence mode="wait">
|
||||
{(activeView === 'notebooks' || activeView === 'shared' || activeView === 'reminders') && (
|
||||
<motion.div
|
||||
key={activeView}
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="h-full w-full"
|
||||
>
|
||||
<NotebooksView
|
||||
activeNoteId={activeNoteId}
|
||||
activeCarnet={activeCarnet}
|
||||
filteredNotes={filteredNotes}
|
||||
activeNote={activeNote}
|
||||
setActiveNoteId={setActiveNoteId}
|
||||
togglePin={togglePin}
|
||||
setShowNewNoteModal={setShowNewNoteModal}
|
||||
isAISidebarOpen={isAISidebarOpen}
|
||||
setIsAISidebarOpen={setIsAISidebarOpen}
|
||||
selectedTagIds={selectedTagIds}
|
||||
setSelectedTagIds={setSelectedTagIds}
|
||||
allNotes={notes}
|
||||
activeCarnetId={activeCarnetId}
|
||||
setShowNewCarnetModal={(show, parentId, isRenaming, carnetId) => setShowNewCarnetModal({ isOpen: show, parentId, isRenaming, carnetId })}
|
||||
onDeleteNote={handleDeleteNote}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{activeView === 'trash' && (
|
||||
<motion.div
|
||||
key="trash"
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="h-full w-full"
|
||||
>
|
||||
<TrashView
|
||||
deletedNotes={notes.filter(n => n.isDeleted)}
|
||||
deletedCarnets={carnets.filter(c => c.isDeleted)}
|
||||
onRestoreNote={handleRestoreNote}
|
||||
onRestoreCarnet={handleRestoreCarnet}
|
||||
onPermanentDeleteNote={handlePermanentDeleteNote}
|
||||
onPermanentDeleteCarnet={handlePermanentDeleteCarnet}
|
||||
onEmptyTrash={() => {
|
||||
setNotes(notes.filter(n => !n.isDeleted));
|
||||
setCarnets(carnets.filter(c => !c.isDeleted));
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{activeView === 'agents' && (
|
||||
<motion.div
|
||||
key="agents"
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="h-full w-full"
|
||||
>
|
||||
<AgentsView
|
||||
selectedAgentId={selectedAgentId}
|
||||
setSelectedAgentId={setSelectedAgentId}
|
||||
carnets={carnets}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{activeView === 'settings' && (
|
||||
<motion.div
|
||||
key="settings"
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="h-full w-full"
|
||||
>
|
||||
<SettingsView
|
||||
activeSettingsTab={activeSettingsTab}
|
||||
setActiveSettingsTab={setActiveSettingsTab}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<AISidebar
|
||||
isOpen={isAISidebarOpen}
|
||||
setIsOpen={setIsAISidebarOpen}
|
||||
activeNote={activeNote}
|
||||
aiTab={aiTab}
|
||||
setAiTab={setAiTab}
|
||||
selectedTone={selectedTone}
|
||||
setSelectedTone={setSelectedTone}
|
||||
carnets={carnets}
|
||||
/>
|
||||
</main>
|
||||
|
||||
{/* Modals */}
|
||||
<AnimatePresence>
|
||||
{showNewCarnetModal.isOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={() => setShowNewCarnetModal({ isOpen: false })}
|
||||
className="absolute inset-0 bg-ink/40 backdrop-blur-sm"
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.9, y: 20 }}
|
||||
className="relative w-full max-w-md bg-paper dark:bg-dark-paper border border-border shadow-2xl rounded-2xl p-8"
|
||||
>
|
||||
<h3 className="text-2xl font-serif font-medium text-ink dark:text-dark-ink mb-2">
|
||||
{showNewCarnetModal.isRenaming ? 'Rename Carnet' : (showNewCarnetModal.parentId ? 'Create Sub-Carnet' : 'Create New Carnet')}
|
||||
</h3>
|
||||
{showNewCarnetModal.parentId && !showNewCarnetModal.isRenaming && (
|
||||
<p className="text-[10px] text-concrete uppercase tracking-widest font-bold mb-6">
|
||||
Inside: {carnets.find(c => c.id === showNewCarnetModal.parentId)?.name}
|
||||
</p>
|
||||
)}
|
||||
<form onSubmit={handleAddCarnet} className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-[11px] uppercase tracking-widest font-bold text-muted-ink mb-2">Notebook Name</label>
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
value={newCarnetName}
|
||||
onChange={(e) => setNewCarnetName(e.target.value)}
|
||||
placeholder="E.g., Sustainable Patterns"
|
||||
className="w-full bg-white dark:bg-[#2A2A2A] border border-border rounded-lg px-4 py-3 outline-none focus:border-ink transition-colors font-serif italic text-lg text-ink dark:text-dark-ink"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-4 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowNewCarnetModal({ isOpen: false });
|
||||
setNewCarnetName('');
|
||||
}}
|
||||
className="flex-1 py-3 border border-border rounded-lg text-sm font-medium hover:bg-slate-50 dark:hover:bg-white/5 transition-colors text-ink dark:text-dark-ink"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex-1 py-3 bg-ink dark:bg-ochre text-paper rounded-lg text-sm font-medium hover:opacity-90 transition-opacity"
|
||||
>
|
||||
{showNewCarnetModal.isRenaming ? 'Rename' : 'Create Notebook'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showNewNoteModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={() => setShowNewNoteModal(false)}
|
||||
className="absolute inset-0 bg-ink/40 backdrop-blur-sm"
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.9, y: 20 }}
|
||||
className="relative w-full max-w-2xl bg-paper dark:bg-dark-paper border border-border shadow-2xl rounded-2xl p-10"
|
||||
>
|
||||
<AnimatePresence>
|
||||
{slashMenu?.isOpen && (
|
||||
<SlashMenu
|
||||
position={{ top: slashMenu.top, left: slashMenu.left }}
|
||||
onSelect={(type) => { console.log(type); setSlashMenu(null); }}
|
||||
onClose={() => setSlashMenu(null)}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<h3 className="text-3xl font-serif font-medium text-ink dark:text-dark-ink mb-8">Add Architectural Note</h3>
|
||||
<form onSubmit={handleAddNote} className="space-y-8">
|
||||
<div>
|
||||
<label className="block text-[11px] uppercase tracking-widest font-bold text-muted-ink mb-2">Concept Title</label>
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
value={newNoteTitle}
|
||||
onChange={(e) => setNewNoteTitle(e.target.value)}
|
||||
placeholder="Enter the title of your study..."
|
||||
className="w-full bg-white dark:bg-[#2A2A2A] border border-border rounded-lg px-5 py-4 outline-none focus:border-ink transition-colors font-serif text-2xl text-ink dark:text-dark-ink"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[11px] uppercase tracking-widest font-bold text-muted-ink mb-2">Observations & Analysis</label>
|
||||
<textarea
|
||||
value={newNoteContent}
|
||||
onChange={(e) => setNewNoteContent(e.target.value)}
|
||||
onKeyDown={handleEditorKeyDown}
|
||||
placeholder="Describe the spatial logic, materiality, and light interactions... (Type '/' for commands)"
|
||||
rows={6}
|
||||
className="w-full bg-white dark:bg-[#2A2A2A] border border-border rounded-lg px-5 py-4 outline-none focus:border-ink transition-colors font-light leading-relaxed resize-none text-ink dark:text-dark-ink"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-4 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowNewNoteModal(false)}
|
||||
className="flex-1 py-4 border border-border rounded-lg text-sm font-medium hover:bg-slate-50 dark:hover:bg-white/5 transition-colors text-ink dark:text-dark-ink"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex-1 py-4 bg-ink dark:bg-ochre text-paper rounded-lg text-sm font-medium hover:opacity-90 transition-opacity"
|
||||
>
|
||||
Save Note
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user