feat: migrate semantic search to pgvector + full-text search
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:
Antigravity
2026-05-12 07:03:56 +00:00
parent 92c3a6f307
commit 03e6a62b80
43 changed files with 4024 additions and 786 deletions

133
MIGRATION.md Normal file
View File

@@ -0,0 +1,133 @@
# Semantic Search Migration to pgvector + Full-Text Search
## Overview
This migration migrates the semantic search infrastructure from JSON-string embeddings to **native pgvector** storage and adds **PostgreSQL full-text search (FTS)** with Reciprocal Rank Fusion (RRF) for hybrid ranking.
## What Changed
### Schema Changes
**`NoteEmbedding` table:**
- Changed `embedding String``embedding Unsupported("vector(1536))` — stores as native pgvector
- Added `updatedAt` column for tracking reindex freshness
**`Note` table:**
- Added `tsv Unsupported("tsvector")` — auto-updated via trigger for FTS
### Search Architecture
| Before | After |
|--------|-------|
| JS-side cosine similarity loops | DB-side `<=>` (cosine distance) via pgvector |
| Embeddings stored as JSON strings | Native `vector(1536)` pgvector type |
| Pure vector-only search | Hybrid FTS + vector with RRF fusion |
| No full-text capability | `tsvector` + GIN index for keyword matching |
### New Indexes
- `NoteEmbedding_embedding_hnsw_idx` — HNSW index on `embedding` column (cosine distance)
- `Note_tsv_gin_idx` — GIN index on `tsv` column for FTS
## Deployment Steps
### 1. Enable pgvector Extension
pgvector must be enabled before the schema migration runs:
```sql
CREATE EXTENSION IF NOT EXISTS vector;
```
If deploying via the migration file, this runs automatically as Phase 1.
### 2. Run Database Migration
The migration file (`prisma/migrations/20260512120000_pgvector_and_fts_search/migration.sql`) applies in three phases:
**Phase 1:** Enable pgvector extension
```sql
CREATE EXTENSION IF NOT EXISTS vector;
```
**Phase 2:** Convert NoteEmbedding to native vector
```sql
ALTER TABLE "NoteEmbedding" ADD COLUMN "vec" vector(1536);
UPDATE "NoteEmbedding" SET "vec" = ("embedding"::jsonb)::text::vector(1536)
WHERE "embedding" IS NOT NULL;
ALTER TABLE "NoteEmbedding" DROP COLUMN "embedding";
ALTER TABLE "NoteEmbedding" RENAME COLUMN "vec" TO "embedding";
ALTER TABLE "NoteEmbedding" ADD COLUMN "updatedAt" TIMESTAMP NOT NULL DEFAULT now();
CREATE INDEX "NoteEmbedding_embedding_hnsw_idx" ON "NoteEmbedding"
USING hnsw ("embedding" vector_cosine_ops) WITH (m = 16, ef_construction = 64);
```
**Phase 3:** Add FTS tsvector to Note
```sql
ALTER TABLE "Note" ADD COLUMN "tsv" tsvector;
UPDATE "Note" SET "tsv" =
setweight(to_tsvector('simple', COALESCE("title", '')), 'A') ||
setweight(to_tsvector('simple', COALESCE("content", '')), 'B');
CREATE INDEX "Note_tsv_gin_idx" ON "Note" USING gin ("tsv");
CREATE OR REPLACE FUNCTION "note_tsv_trigger"() RETURNS trigger AS $$
BEGIN
NEW."tsv" :=
setweight(to_tsvector('simple', COALESCE(NEW."title", '')), 'A') ||
setweight(to_tsvector('simple', COALESCE(NEW."content", '')), 'B');
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER "note_tsv_update"
BEFORE INSERT OR UPDATE OF "title", "content" ON "Note"
FOR EACH ROW EXECUTE FUNCTION "note_tsv_trigger"();
```
### 3. Regenerate Embeddings for Existing Notes
After the migration, all existing `NoteEmbedding` rows must have their `embedding` column regenerated from the old JSON strings to native vector format. The migration handles this conversion automatically via the `UPDATE` statement.
To reindex all notes programmatically:
```
POST /api/notes/reindex
```
### 4. Verify Deployment
**Validate embeddings:**
```
POST /api/admin/embeddings/validate
```
**Test semantic search** via the note search tool or:
```
POST /api/notes/search?q=<query>
```
## Docker Deployment
The `docker-compose.yml` runs PostgreSQL 16-alpine with the following configuration:
- **PostgreSQL port:** 5433 (host) → 5432 (container)
- **Database:** `memento` (user: `memento`, password: `memento` by default)
- **Health check:** `pg_isready`
Services that depend on the database (`memento-note`, `mcp-server`) wait for PostgreSQL to be healthy before starting.
## Rollback
To rollback to the pre-migration state:
1. Drop the HNSW index: `DROP INDEX "NoteEmbedding_embedding_hnsw_idx";`
2. Drop the GIN index: `DROP INDEX "Note_tsv_gin_idx";`
3. Drop the trigger: `DROP TRIGGER "note_tsv_update" ON "Note";`
4. Drop the function: `DROP FUNCTION "note_tsv_trigger"();`
5. Revert schema via Prisma migrate reset (requires restoring the old `NoteEmbedding.embedding` column type)
## Affected Services
| Service | Container | Port |
|---------|-----------|------|
| PostgreSQL | `memento-postgres` | 5433 |
| memento-note (Next.js) | `memento-web` | 3000 |
| mcp-server | `memento-mcp` | 3001 |
| Ollama (optional) | `memento-ollama` | 11434 |

View File

@@ -0,0 +1,9 @@
# GEMINI_API_KEY: Required for Gemini AI API calls.
# AI Studio automatically injects this at runtime from user secrets.
# Users configure this via the Secrets panel in the AI Studio UI.
GEMINI_API_KEY="MY_GEMINI_API_KEY"
# APP_URL: The URL where this applet is hosted.
# AI Studio automatically injects this at runtime with the Cloud Run service URL.
# Used for self-referential links, OAuth callbacks, and API endpoints.
APP_URL="MY_APP_URL"

8
architectural-grid10/.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
node_modules/
build/
dist/
coverage/
.DS_Store
*.log
.env*
!.env.example

View File

@@ -0,0 +1,20 @@
<div align="center">
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
</div>
# Run and deploy your AI Studio app
This contains everything you need to run your app locally.
View your app in AI Studio: https://ai.studio/apps/b7b577c6-4d9f-44ac-8fe1-85bc3c6d6e66
## Run Locally
**Prerequisites:** Node.js
1. Install dependencies:
`npm install`
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
3. Run the app:
`npm run dev`

View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>My Google AI Studio App</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,6 @@
{
"name": "Architectural Grid",
"description": "A minimalist notebook for architectural research and conceptual sketches.",
"requestFramePermissions": [],
"majorCapabilities": []
}

View File

@@ -0,0 +1,34 @@
{
"name": "react-example",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite --port=3000 --host=0.0.0.0",
"build": "vite build",
"preview": "vite preview",
"clean": "rm -rf dist",
"lint": "tsc --noEmit"
},
"dependencies": {
"@google/genai": "^1.29.0",
"@tailwindcss/vite": "^4.1.14",
"@vitejs/plugin-react": "^5.0.4",
"lucide-react": "^0.546.0",
"react": "^19.0.1",
"react-dom": "^19.0.1",
"vite": "^6.2.3",
"express": "^4.21.2",
"dotenv": "^17.2.3",
"motion": "^12.23.24"
},
"devDependencies": {
"@types/node": "^22.14.0",
"autoprefixer": "^10.4.21",
"tailwindcss": "^4.1.14",
"tsx": "^4.21.0",
"typescript": "~5.8.2",
"vite": "^6.2.3",
"@types/express": "^4.17.21"
}
}

View 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>
);
}

View File

@@ -0,0 +1,379 @@
import React from 'react';
import {
Sparkles,
ChevronRight,
MessageSquare,
FileCode,
Globe,
Send,
Scissors,
Zap,
Languages,
Layout,
ArrowRightLeft,
BookOpen,
History,
Target
} from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react';
import { AITab, AITone, Note, Carnet } from '../types';
import { HierarchicalCarnetSelector } from './HierarchicalCarnetSelector';
interface AISidebarProps {
isOpen: boolean;
setIsOpen: (open: boolean) => void;
activeNote: Note | undefined;
aiTab: AITab;
setAiTab: (tab: AITab) => void;
selectedTone: AITone;
setSelectedTone: (tone: AITone) => void;
carnets: Carnet[];
}
export const AISidebar: React.FC<AISidebarProps> = ({
isOpen,
setIsOpen,
activeNote,
aiTab,
setAiTab,
selectedTone,
setSelectedTone,
carnets
}) => {
const [selectedContextId, setSelectedContextId] = React.useState<string | null>(null);
return (
<AnimatePresence>
{isOpen && (
<motion.aside
initial={{ x: 400, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
exit={{ x: 400, opacity: 0 }}
transition={{ type: 'spring', damping: 25, stiffness: 200 }}
className="w-[400px] border-l border-border bg-white shadow-2xl flex flex-col z-50 shrink-0 relative"
>
<div className="p-6 border-b border-border space-y-2">
<div className="flex items-center justify-between">
<h3 className="flex items-center gap-2 font-serif text-xl font-medium text-ink">
<Sparkles size={18} className="text-ochre" />
IA Assistant
</h3>
<button
onClick={() => setIsOpen(false)}
className="p-1 hover:bg-slate-100 rounded-full transition-colors text-muted-ink"
>
<ChevronRight size={20} />
</button>
</div>
<p className="text-[11px] text-muted-ink uppercase tracking-wider font-medium opacity-60 truncate">
"{activeNote?.title}"
</p>
</div>
<div className="flex border-b border-border px-2">
{(['discussion', 'actions', 'resources'] as AITab[]).map((tab) => (
<button
key={tab}
onClick={() => setAiTab(tab)}
className={`flex-1 py-3 text-[10px] uppercase tracking-[0.2em] font-bold transition-all relative
${aiTab === tab ? 'text-manganese' : 'text-muted-ink hover:text-ink/60'}`}
>
{tab}
{aiTab === tab && (
<motion.div
layoutId="activeTab"
className="absolute bottom-0 left-0 right-0 h-0.5 bg-ochre"
/>
)}
</button>
))}
</div>
<div className="flex-1 overflow-y-auto p-6 custom-scrollbar">
<AnimatePresence mode="wait">
{aiTab === 'discussion' && (
<motion.div
key="discussion"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="space-y-8"
>
<div className="h-64 flex flex-col items-center justify-center text-center space-y-4 text-muted-ink/40">
<div className="w-16 h-16 rounded-full border border-dashed border-muted-ink/20 flex items-center justify-center">
<MessageSquare size={24} />
</div>
<p className="text-xs font-serif italic leading-relaxed px-8">Posez une question à l'Assistant pour commencer.</p>
</div>
<div className="space-y-4">
<div className="space-y-3">
<label className="text-[10px] uppercase tracking-[0.2em] font-bold text-muted-ink">Source du Contexte</label>
<div className="space-y-3">
<div className="w-full p-3 bg-glass border border-border rounded-lg text-xs flex items-center justify-between cursor-default backdrop-blur-sm">
<div className="flex items-center gap-2">
<FileCode size={14} className="text-blueprint" />
<span className="font-medium text-ink">Note Active</span>
</div>
<div className="text-[8px] bg-blueprint/10 text-blueprint px-1.5 py-0.5 rounded uppercase font-bold tracking-tighter italic">Auto</div>
</div>
<div className="flex items-center gap-2 px-2">
<div className="h-px flex-1 bg-border/40" />
<span className="text-[9px] font-bold text-concrete uppercase tracking-widest">+ Carnet</span>
<div className="h-px flex-1 bg-border/40" />
</div>
<HierarchicalCarnetSelector
carnets={carnets}
selectedId={selectedContextId}
onSelect={setSelectedContextId}
placeholder="Inclure un carnet..."
className="w-full"
/>
</div>
</div>
<div className="space-y-3">
<label className="text-[10px] uppercase tracking-[0.2em] font-bold text-muted-ink">Ton d'écriture</label>
<div className="grid grid-cols-2 gap-2">
{(['Professional', 'Creative', 'Academic', 'Casual'] as AITone[]).map((tone) => (
<button
key={tone}
onClick={() => setSelectedTone(tone)}
className={`p-3 rounded-xl border text-[11px] font-medium transition-all
${selectedTone === tone ? 'bg-manganese text-paper border-manganese shadow-lg shadow-manganese/10' : 'bg-glass border-border text-muted-ink hover:border-ink/20'}`}
>
{tone.toUpperCase().substring(0, 3)}
</button>
))}
</div>
</div>
</div>
</motion.div>
)}
{aiTab === 'actions' && (
<motion.div
key="actions"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="space-y-8"
>
<div className="space-y-8">
<div>
<div className="flex items-center gap-2 mb-4">
<div className="h-px flex-1 bg-border/40" />
<h4 className="text-[10px] uppercase tracking-[0.25em] font-bold text-muted-ink whitespace-nowrap">Transformations</h4>
<div className="h-px flex-1 bg-border/40" />
</div>
<div className="grid grid-cols-2 gap-2">
{[
{ icon: <Sparkles size={14} />, label: 'Clarifier', color: 'ochre' },
{ icon: <Scissors size={14} />, label: 'Raccourcir', color: 'rust' },
{ icon: <Zap size={14} />, label: 'Améliorer', color: 'sage' },
{ icon: <Languages size={14} />, label: 'Traduire', color: 'slate' },
].map((action, i) => (
<button
key={i}
className="flex flex-col items-center gap-3 p-4 bg-glass border border-border rounded-xl transition-all group hover:border-ink/20"
>
<div className={`p-2 rounded-lg bg-slate-50 dark:bg-white/10 transition-colors group-hover:bg-manganese group-hover:text-paper shadow-sm text-ink/60`}>
{action.icon}
</div>
<span className="text-[10px] font-bold text-ink/80 uppercase tracking-widest">{action.label}</span>
</button>
))}
<button className="col-span-2 flex items-center justify-center gap-3 py-3 px-4 bg-glass border border-border rounded-xl text-[10px] font-bold text-ink/80 hover:bg-slate-50 dark:hover:bg-white/10 transition-colors hover:border-ink/20 uppercase tracking-widest">
<FileCode size={14} className="text-muted-ink" />
Convertir en Markdown
</button>
</div>
</div>
<div className="space-y-4">
<div className="flex items-center gap-2 mb-2">
<div className="h-px flex-1 bg-border/40" />
<h4 className="text-[10px] uppercase tracking-[0.25em] font-bold text-muted-ink whitespace-nowrap">Generation Tools</h4>
<div className="h-px flex-1 bg-border/40" />
</div>
<div className="group relative p-6 rounded-2xl bg-white border border-border hover:border-blueprint/30 transition-all duration-500 overflow-hidden">
<div className="absolute top-0 right-0 p-4 opacity-5 group-hover:opacity-10 transition-opacity">
<Layout size={80} className="text-blueprint" />
</div>
<div className="relative space-y-5">
<div className="flex items-center gap-3">
<div className="p-2 bg-slate-50 rounded-lg text-blueprint">
<Layout size={18} />
</div>
<div className="space-y-0.5">
<h5 className="text-sm font-bold text-ink leading-none">Présentation</h5>
<p className="text-[10px] text-muted-ink uppercase tracking-tight">Convertir en slides interactives</p>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<span className="text-[9px] uppercase tracking-[0.2em] font-bold text-muted-ink/60 px-1">Thème</span>
<select className="w-full bg-glass dark:bg-black/20 border border-border rounded-lg px-2 py-2 text-xs outline-none focus:ring-1 ring-blueprint/10 transition-all cursor-pointer">
<option>Architectural Mono</option>
<option>Vibrant Tech</option>
<option>Minimal Silk</option>
</select>
</div>
<div className="space-y-1.5">
<span className="text-[9px] uppercase tracking-[0.2em] font-bold text-muted-ink/60 px-1">Style</span>
<select className="w-full bg-glass dark:bg-black/20 border border-border rounded-lg px-2 py-2 text-xs outline-none focus:ring-1 ring-blueprint/10 transition-all cursor-pointer">
<option>Professional</option>
<option>Creative</option>
<option>Brutalist</option>
</select>
</div>
</div>
<button className="w-full py-3.5 bg-blueprint text-paper rounded-xl text-[11px] font-bold flex items-center justify-center gap-2 hover:opacity-90 transition-all shadow-lg shadow-blueprint/20 uppercase tracking-[0.2em]">
Générer
<ArrowRightLeft size={14} className="opacity-60" />
</button>
</div>
</div>
<div className="group relative p-6 rounded-2xl bg-white border border-border hover:border-sage/30 transition-all duration-500 overflow-hidden">
<div className="absolute top-0 right-0 p-4 opacity-5 group-hover:opacity-10 transition-opacity">
<BookOpen size={80} className="text-sage" />
</div>
<div className="relative space-y-5">
<div className="flex items-center gap-3">
<div className="p-2 bg-slate-50 rounded-lg text-sage">
<BookOpen size={18} />
</div>
<div className="space-y-0.5">
<h5 className="text-sm font-bold text-ink leading-none">Diagramme</h5>
<p className="text-[10px] text-muted-ink uppercase tracking-tight">Visualisation de structure</p>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<span className="text-[9px] uppercase tracking-[0.2em] font-bold text-muted-ink/60 px-1">Type</span>
<select className="w-full bg-glass dark:bg-black/20 border border-border rounded-lg px-2 py-2 text-xs outline-none focus:ring-1 ring-sage/10 transition-all cursor-pointer">
<option>Logic Flow</option>
<option>Mind Map</option>
<option>Hierarchy</option>
</select>
</div>
<div className="space-y-1.5">
<span className="text-[9px] uppercase tracking-[0.2em] font-bold text-muted-ink/60 px-1">Style</span>
<select className="w-full bg-glass dark:bg-black/20 border border-border rounded-lg px-2 py-2 text-xs outline-none focus:ring-1 ring-sage/10 transition-all cursor-pointer">
<option>Draft</option>
<option>Polished</option>
<option>Handwritten</option>
</select>
</div>
</div>
<button className="w-full py-3.5 bg-sage text-paper rounded-xl text-[11px] font-bold flex items-center justify-center gap-2 hover:opacity-90 transition-all shadow-lg shadow-sage/20 uppercase tracking-[0.2em]">
Tracer
<ArrowRightLeft size={14} className="opacity-60" />
</button>
</div>
</div>
</div>
<div className="flex flex-col items-center gap-2 opacity-20 py-4">
<History size={16} />
<span className="text-[10px] font-bold uppercase tracking-widest whitespace-nowrap">Auto-Save Enabled</span>
</div>
</div>
</motion.div>
)}
{aiTab === 'resources' && (
<motion.div
key="resources"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="space-y-8"
>
<div className="space-y-6">
<div className="space-y-2">
<label className="text-[10px] uppercase tracking-[0.2em] font-bold text-muted-ink">URL (Optionnel)</label>
<div className="relative">
<input type="text" placeholder="https://..." className="w-full bg-glass border border-border rounded-lg pl-3 pr-10 py-3 text-xs outline-none focus:border-blueprint transition-colors" />
<Globe size={14} className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-ink/40" />
</div>
</div>
<div className="space-y-2">
<label className="text-[10px] uppercase tracking-[0.2em] font-bold text-muted-ink">Texte de la ressource</label>
<textarea
rows={8}
placeholder="Collez votre texte ici (markdown, HTML, texte brut...)"
className="w-full bg-glass border border-border rounded-lg p-4 text-xs outline-none focus:border-blueprint transition-colors resize-none leading-relaxed"
/>
</div>
<div className="space-y-3">
<label className="text-[10px] uppercase tracking-[0.2em] font-bold text-muted-ink">Mode d'intégration</label>
<div className="grid grid-cols-3 gap-2">
{[
{ id: 'replace', label: 'Remplacer', sub: 'Direct, sans IA' },
{ id: 'append', label: 'Compléter', sub: 'Ajoute sans réécrire' },
{ id: 'merge', label: 'Fusionner', sub: 'Réécrit et intègre' },
].map((mode) => (
<button key={mode.id} className={`flex flex-col items-center justify-center p-3 rounded-lg border transition-all text-center ${mode.id === 'append' ? 'bg-sage/10 border-sage/50 ring-1 ring-sage/10' : 'bg-white border-border hover:bg-slate-50'}`}>
<span className={`text-[11px] font-bold ${mode.id === 'append' ? 'text-sage' : 'text-ink'}`}>{mode.label}</span>
<span className="text-[8px] text-muted-ink opacity-60 leading-tight mt-1 font-medium">{mode.sub}</span>
</button>
))}
</div>
</div>
<button className="w-full py-4 bg-blueprint text-white rounded-xl text-sm font-bold flex items-center justify-center gap-3 hover:opacity-90 transition-opacity shadow-lg shadow-blueprint/20">
<Sparkles size={18} />
Générer l'aperçu
</button>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
<AnimatePresence>
{aiTab === 'discussion' && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
className="p-6 bg-white border-t border-border"
>
<div className="relative">
<textarea
rows={3}
placeholder="Posez une question sur cette note..."
className="w-full bg-glass backdrop-blur-sm border border-border rounded-2xl p-4 pr-12 text-sm outline-none focus:border-blueprint transition-colors resize-none leading-relaxed font-light"
/>
<div className="absolute right-3 bottom-3 flex gap-2">
<button className="p-2 text-muted-ink hover:text-ink rounded-lg transition-colors">
<Globe size={16} />
</button>
<button className="p-2 bg-blueprint text-white rounded-lg transition-transform hover:scale-105 active:scale-95 shadow-lg shadow-blueprint/10">
<Send size={16} />
</button>
</div>
</div>
<p className="text-[9px] text-muted-ink text-center mt-3 uppercase tracking-widest font-bold opacity-30 italic">Maj+Entrée = nouvelle ligne</p>
</motion.div>
)}
</AnimatePresence>
</motion.aside>
)}
</AnimatePresence>
);
};

View File

@@ -0,0 +1,325 @@
import React from 'react';
import {
Plus,
ArrowLeft,
Clock,
Activity,
Trash2,
Edit3,
Play,
Eye,
Microscope,
Globe,
Layers,
Zap,
BookOpen,
Sparkles,
ChevronDown,
Info,
Check
} from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react';
import { Carnet, Note } from '../types';
import { HierarchicalCarnetSelector } from './HierarchicalCarnetSelector';
interface AgentsViewProps {
selectedAgentId: string | null;
setSelectedAgentId: (id: string | null) => void;
carnets: Carnet[];
}
export const AgentsView: React.FC<AgentsViewProps> = ({
selectedAgentId,
setSelectedAgentId,
carnets
}) => {
const [selectedCarnetForAgent, setSelectedCarnetForAgent] = React.useState<string | null>('4');
const [agentType, setAgentType] = React.useState<'Surveillant' | 'Personnalisé' | 'Slides' | 'Diagramme'>('Diagramme');
return (
<div className="h-full flex flex-col overflow-y-auto custom-scrollbar bg-[#F9F8F6] dark:bg-dark-paper space-y-12">
{!selectedAgentId ? (
<>
<header className="px-12 pt-12 pb-8 flex flex-col gap-6 sticky top-0 bg-paper/80 backdrop-blur-md z-30">
<div className="flex justify-between items-end">
<div className="space-y-1">
<h1 className="text-4xl font-serif font-medium tracking-tight text-ink">Mes Agents</h1>
<p className="text-sm text-muted-ink font-light">Automatisez vos tâches de veille et de recherche.</p>
</div>
<button className="px-6 py-2.5 bg-ink text-paper text-sm font-medium rounded-xl hover:opacity-90 transition-all flex items-center gap-3 shadow-lg shadow-ink/10">
<Plus size={18} />
Nouvel Agent
</button>
</div>
<div className="flex items-center gap-8 border-b border-ink/5 pt-4">
{['Tous', 'Veilleur', 'Chercheur', 'Surveillant', 'Personnalisé'].map((tag, i) => (
<button key={i} className={`pb-4 text-xs font-bold uppercase tracking-widest transition-all relative ${i === 0 ? 'text-ink' : 'text-muted-ink hover:text-ink/60'}`}>
{tag}
{i === 0 && <motion.div layoutId="activeAgentTag" className="absolute bottom-0 left-0 right-0 h-0.5 bg-ink" />}
</button>
))}
</div>
</header>
<div className="px-12 flex-1 pb-20 space-y-12">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{[
{ id: 'a1', icon: <Eye size={20} className="text-amber-600" />, title: 'Surveillant de Notes', status: 'Réussi', type: 'SURVEILLANT', meta: 'Hebdomadaire • 6 exéc.', desc: 'Analyse les notes récentes dun carnet et suggère des compléments, références et liens.' },
{ id: 'a2', icon: <Microscope size={20} className="text-indigo-600" />, title: 'Chercheur de Sujet', status: 'Réussi', type: 'CHERCHEUR', meta: 'Hebdomadaire • 14 exéc.', desc: 'Recherche des informations approfondies sur les derniers modèles de Deepseek et voir lavis des utilisateurs.' },
{ id: 'a3', icon: <Globe size={20} className="text-emerald-600" />, title: 'Veille IA', status: 'Réussi', type: 'VEILLEUR', meta: 'Quotidien • 20 exéc.', desc: 'Scrape les flux RSS de 6 sites IA (The Verge, TechCrunch...) et génère un résumé.' },
].map((agent, i) => (
<div
key={i}
onClick={() => setSelectedAgentId(agent.id)}
className="bg-white dark:bg-white/5 border border-border rounded-2xl p-6 space-y-6 hover:border-ink/20 transition-all group cursor-pointer shadow-sm relative overflow-hidden"
>
<div className="flex items-start justify-between">
<div className="flex items-center gap-4">
<div className="p-3 bg-slate-50 dark:bg-white/10 rounded-xl group-hover:bg-ink group-hover:text-paper transition-all">
{agent.icon}
</div>
<div className="space-y-1">
<h4 className="text-[13px] font-bold text-ink">{agent.title}</h4>
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-ink opacity-60">{agent.type}</p>
</div>
</div>
<div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
<label className="relative inline-flex items-center cursor-pointer">
<input type="checkbox" className="sr-only peer" defaultChecked />
<div className="w-8 h-4 bg-gray-200 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-0.5 after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-3 after:w-3 after:transition-all peer-checked:bg-emerald-500"></div>
</label>
</div>
</div>
<p className="text-xs text-muted-ink leading-relaxed line-clamp-3">
{agent.desc}
</p>
<div className="space-y-3">
<div className="flex items-center justify-between text-[10px] text-muted-ink font-medium">
<div className="flex items-center gap-4">
<span className="flex items-center gap-1"><Clock size={10} /> {agent.meta.split('•')[0]}</span>
<span>{agent.meta.split('•')[1]}</span>
</div>
</div>
<div className="flex items-center justify-between text-[10px] text-muted-ink font-medium">
<div className="flex items-center gap-2">
<span className="uppercase tracking-tight">Prochaine exécution</span>
<span className="text-ink">Hebdomadaire</span>
</div>
<div className="flex items-center gap-2">
<span className="uppercase tracking-tight">Dernier statut</span>
<span className="text-emerald-600 flex items-center gap-1"><Activity size={8} /> {agent.status}</span>
</div>
</div>
</div>
<div className="grid grid-cols-3 gap-2 border-t border-border pt-4">
<button className="py-2 border border-border rounded-lg hover:bg-slate-50 dark:hover:bg-white/5 flex items-center justify-center transition-colors text-muted-ink hover:text-ink"><Edit3 size={14} /> <span className="ml-2 text-[10px] font-bold uppercase">Modifier</span></button>
<button
onClick={(e) => { e.stopPropagation(); }}
className="py-2 border border-border rounded-lg hover:bg-slate-50 dark:hover:bg-white/5 flex items-center justify-center transition-colors text-muted-ink hover:text-ink"
>
<Play size={14} className="fill-current" />
</button>
<button
onClick={(e) => { e.stopPropagation(); }}
className="py-2 border border-border rounded-lg hover:bg-rose-50 hover:text-rose-600 hover:border-rose-100 flex items-center justify-center transition-colors text-muted-ink"
>
<Trash2 size={14} />
</button>
</div>
</div>
))}
</div>
<div className="space-y-8">
<div className="flex items-center gap-4">
<h5 className="text-[10px] font-bold uppercase tracking-[0.3em] text-muted-ink whitespace-nowrap">Modèles</h5>
<div className="h-px w-full bg-border/40" />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{[
{ title: 'Veille IA', desc: 'Scrape les flux RSS de 6 sites IA et génère un résumé hebdomadaire.', icon: <Globe size={18} /> },
{ title: 'Veille Tech', desc: 'Crée un résumé quotidien des news Hacker News et Product Hunt.', icon: <Zap size={18} /> },
{ title: 'Veille Dev', desc: 'Surveille les repos GitHub pour détecter les nouvelles releases.', icon: <Layers size={18} /> },
].map((model, i) => (
<div key={i} className="bg-white/40 dark:bg-white/5 border border-dashed border-border rounded-2xl p-6 group cursor-pointer hover:bg-white dark:hover:bg-white/10 hover:border-ink/20 transition-all">
<div className="w-8 h-8 rounded-lg bg-slate-50 dark:bg-white/10 flex items-center justify-center text-muted-ink group-hover:bg-ink group-hover:text-paper mb-4 transition-all">
{model.icon}
</div>
<h4 className="text-[13px] font-bold text-ink mb-2">{model.title}</h4>
<p className="text-xs text-muted-ink leading-relaxed mb-4">{model.desc}</p>
<button className="text-[11px] font-bold uppercase tracking-widest text-ink hover:opacity-60 transition-opacity flex items-center gap-2">
<Plus size={14} /> Installer
</button>
</div>
))}
</div>
</div>
</div>
</>
) : (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="flex-1 flex flex-col"
>
<header className="px-12 py-10 border-b border-border bg-white dark:bg-paper backdrop-blur-md sticky top-0 z-30">
<div className="flex items-center justify-between max-w-5xl mx-auto">
<button
onClick={() => setSelectedAgentId(null)}
className="flex items-center gap-3 text-xs font-bold uppercase tracking-widest text-muted-ink hover:text-ink transition-colors"
>
<ArrowLeft size={16} />
Retour
</button>
<div className="flex items-center gap-4">
<button className="px-5 py-2 text-xs font-bold uppercase tracking-widest border border-border rounded-xl hover:bg-slate-50 dark:hover:bg-white/5 transition-all">
Logs
</button>
<button className="px-6 py-2 bg-ink text-paper text-xs font-bold uppercase tracking-widest rounded-xl hover:opacity-90 transition-all shadow-lg shadow-ink/10">
Enregistrer
</button>
</div>
</div>
</header>
<div className="flex-1 px-12 py-16 max-w-5xl mx-auto w-full space-y-24">
<section className="space-y-12">
<div className="text-center space-y-4">
<p className="text-[10px] font-bold uppercase tracking-[0.4em] text-concrete">Sélectionnez le type d'agent</p>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 max-w-4xl mx-auto">
{[
{ id: 'Surveillant', icon: <Eye size={18} />, label: 'Surveillant', desc: 'Surveille un carnet et analyse les notes' },
{ id: 'Personnalisé', icon: <Layers size={18} />, label: 'Personnalisé', desc: 'Agent libre avec votre propre prompt' },
{ id: 'Slides', icon: <Layers size={18} />, label: 'Slides', desc: 'Crée une présentation PowerPoint à partir de notes' },
{ id: 'Diagramme', icon: <Zap size={18} />, label: 'Diagramme', desc: 'Crée un diagramme Excalidraw à partir de notes' },
].map((type) => (
<button
key={type.id}
onClick={() => setAgentType(type.id as any)}
className={`p-6 rounded-2xl border-2 transition-all flex flex-col items-center gap-3 text-center group relative
${agentType === type.id ? 'border-blueprint bg-white shadow-xl shadow-blueprint/10' : 'border-border bg-white/50 hover:bg-white'}`}
>
<div className={`p-3 rounded-xl transition-all ${agentType === type.id ? 'bg-blueprint text-white' : 'bg-slate-50 text-concrete group-hover:text-ink'}`}>
{type.icon}
</div>
<div className="space-y-1">
<p className="text-[13px] font-bold text-ink">{type.label}</p>
<p className="text-[10px] text-muted-ink leading-tight">{type.desc}</p>
</div>
<div className={`absolute top-4 right-4 w-5 h-5 rounded-full border-2 flex items-center justify-center transition-all
${agentType === type.id ? 'border-blueprint' : 'border-border opacity-20'}`}>
{agentType === type.id && <div className="w-2 h-2 bg-blueprint rounded-full" />}
</div>
</button>
))}
</div>
</div>
</section>
<section className="space-y-10">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-[10px] font-bold uppercase tracking-[0.3em] text-concrete">
CONFIGURATION <Info size={12} className="opacity-40" />
</div>
<button className="flex items-center gap-2 px-6 py-2 border-2 border-rose-100 bg-rose-50 rounded-xl text-rose-500 text-[11px] font-bold uppercase tracking-widest hover:bg-rose-100 transition-colors">
<Trash2 size={14} /> Supprimer
</button>
</div>
<div className="bg-white dark:bg-white/5 border border-border/60 rounded-[32px] p-12 space-y-12 shadow-sm">
<div className="space-y-6">
<div className="flex items-center gap-2">
<label className="text-[11px] font-bold uppercase tracking-widest text-concrete">DESCRIPTION (OPTIONEL)</label>
<Info size={12} className="text-concrete/40" />
</div>
<textarea
className="w-full bg-slate-50 dark:bg-black/20 border border-border/40 rounded-2xl p-6 text-sm outline-none focus:ring-4 ring-blueprint/5 focus:border-blueprint/40 transition-all font-light leading-relaxed resize-none text-ink"
placeholder="Décrivez brièvement le rôle de cet agent..."
defaultValue="Lit une note et génère un diagramme visuel dans le Lab Excalidraw."
/>
</div>
<div className="space-y-6">
<div className="flex items-center gap-2">
<label className="text-[11px] font-bold uppercase tracking-widest text-concrete">CARNET À SURVEILLER</label>
<Info size={12} className="text-concrete/40" />
</div>
<HierarchicalCarnetSelector
carnets={carnets}
selectedId={selectedCarnetForAgent}
onSelect={setSelectedCarnetForAgent}
/>
</div>
<div className="space-y-6">
<div className="flex items-center gap-2">
<label className="text-[11px] font-bold uppercase tracking-widest text-concrete">NOTES À ANALYSER</label>
<Info size={12} className="text-concrete/40" />
</div>
<div className="bg-slate-50 dark:bg-black/20 border border-border/40 rounded-2xl overflow-hidden divide-y divide-border/20">
{[
'Résumé du conteneur LXC devSandbox',
'Connexion SSH sans mot de passe à devSandbox',
'Gateway token (blank to generate)',
'Procédure d\'accès à openclaw',
'Derniers commits du repo Momento'
].map((note, i) => (
<label key={i} className="flex items-center gap-4 px-6 py-4 cursor-pointer hover:bg-white/50 transition-colors group">
<div className={`w-5 h-5 rounded border transition-all flex items-center justify-center
${i === 0 ? 'bg-blueprint border-blueprint text-white' : 'bg-white border-border group-hover:border-blueprint/40'}`}>
{i === 0 && <Check size={12} />}
</div>
<input type="checkbox" className="hidden" defaultChecked={i === 0} />
<span className={`text-[13px] transition-colors ${i === 0 ? 'font-medium text-ink' : 'text-muted-ink'}`}>{note}</span>
</label>
))}
</div>
<p className="text-[10px] text-concrete/60 italic font-medium">{1} note(s) sélectionnée(s)</p>
</div>
<div className="space-y-8">
<div className="flex items-center gap-2">
<label className="text-[11px] font-bold uppercase tracking-widest text-concrete">TYPE DE DIAGRAMME</label>
</div>
<div className="grid grid-cols-2 md:grid-cols-2 gap-3">
{[
'Auto (détection métier)', 'Flowchart (processus)',
'Mindmap (idées)', 'Organigramme (équipes)',
'Timeline / roadmap', 'Process map (opérations)',
'Architecture cloud (zones/RG)'
].map((type, i) => (
<button key={i} className={`px-6 py-4 rounded-xl border text-[13px] text-left transition-all
${i === 0 ? 'border-ink bg-slate-50 font-bold text-ink ring-2 ring-ink/5' : 'border-border text-concrete hover:border-concrete/40 hover:bg-slate-50/50'}`}>
{type}
</button>
))}
</div>
</div>
<div className="space-y-8">
<div className="flex items-center gap-2">
<label className="text-[11px] font-bold uppercase tracking-widest text-concrete">STYLE DU DIAGRAMME EXCALIDRAW</label>
</div>
<div className="flex flex-wrap gap-4">
{[
'Coloré (Excalidraw)', 'Sketch+ (Excalidraw accentué)', 'Austère (sobre)'
].map((style, i) => (
<button key={i} className={`px-6 py-4 rounded-xl border text-[13px] transition-all
${i === 1 ? 'border-ink bg-white font-bold text-ink ring-2 ring-ink/5 shadow-lg' : 'border-border text-concrete hover:bg-slate-50'}`}>
{style}
</button>
))}
</div>
</div>
</div>
</section>
</div>
</motion.div>
)}
</div>
);
};

View File

@@ -0,0 +1,208 @@
import React, { useState, useMemo } from 'react';
import {
ChevronRight,
ChevronDown,
Folder,
FolderOpen,
Check,
Search
} from 'lucide-react';
import { Carnet } from '../types';
import { motion, AnimatePresence } from 'motion/react';
interface HierarchicalCarnetSelectorProps {
carnets: Carnet[];
selectedId: string | null;
onSelect: (id: string) => void;
className?: string;
placeholder?: string;
}
export const HierarchicalCarnetSelector: React.FC<HierarchicalCarnetSelectorProps> = ({
carnets,
selectedId,
onSelect,
className = "",
placeholder = "Sélectionner un carnet..."
}) => {
const [isOpen, setIsOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set(['1', '4'])); // Default expand some
const selectedCarnet = carnets.find(c => c.id === selectedId);
// Derive the path for display
const path = useMemo(() => {
if (!selectedCarnet) return [];
const trail: Carnet[] = [];
let current = selectedCarnet;
while (current) {
trail.unshift(current);
if (!current.parentId) break;
const parent = carnets.find(c => c.id === current.parentId);
if (!parent) break;
current = parent;
}
return trail;
}, [selectedCarnet, carnets]);
const toggleExpand = (e: React.MouseEvent, id: string) => {
e.stopPropagation();
const newExpanded = new Set(expandedIds);
if (newExpanded.has(id)) {
newExpanded.delete(id);
} else {
newExpanded.add(id);
}
setExpandedIds(newExpanded);
};
const filteredCarnets = useMemo(() => {
if (!searchQuery) return carnets;
return carnets.filter(c => c.name.toLowerCase().includes(searchQuery.toLowerCase()));
}, [carnets, searchQuery]);
const renderTree = (parentId?: string, level = 0) => {
const children = carnets.filter(c => c.parentId === parentId);
if (children.length === 0) return null;
return (
<div className={level > 0 ? "ml-4 border-l border-border/40 pl-2" : ""}>
{children.map(carnet => {
const isExpanded = expandedIds.has(carnet.id) || searchQuery.length > 0;
const hasChildren = carnets.some(c => c.parentId === carnet.id);
const isSelected = selectedId === carnet.id;
// If searching and this carnet doesn't match AND none of its children match, skip it
if (searchQuery && !carnet.name.toLowerCase().includes(searchQuery.toLowerCase())) {
const hasMatchingChild = (id: string): boolean => {
const childrenOfId = carnets.filter(c => c.parentId === id);
return childrenOfId.some(c => c.name.toLowerCase().includes(searchQuery.toLowerCase()) || hasMatchingChild(c.id));
};
if (!hasMatchingChild(carnet.id)) return null;
}
return (
<div key={carnet.id} className="select-none">
<div
onClick={() => {
onSelect(carnet.id);
if (!searchQuery) setIsOpen(false);
}}
className={`flex items-center gap-2.5 px-2 py-1.5 rounded-lg cursor-pointer transition-all group
${isSelected ? 'bg-blueprint/10 text-blueprint font-bold' : 'hover:bg-slate-50 dark:hover:bg-white/5 text-ink'}`}
>
<div className="w-4 flex items-center justify-center">
{hasChildren ? (
<button
onClick={(e) => toggleExpand(e, carnet.id)}
className="p-1 hover:bg-black/5 dark:hover:bg-white/5 rounded transition-colors"
>
{isExpanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
</button>
) : null}
</div>
<div className={`p-1 rounded ${isSelected ? 'bg-blueprint/20' : 'bg-slate-100 dark:bg-white/5 group-hover:bg-white/40'}`}>
{isExpanded && hasChildren ? <FolderOpen size={13} /> : <Folder size={13} />}
</div>
<span className="text-[13px] truncate flex-1">{carnet.name}</span>
{isSelected && <Check size={14} className="opacity-60" />}
</div>
<AnimatePresence>
{isExpanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="overflow-hidden"
>
{renderTree(carnet.id, level + 1)}
</motion.div>
)}
</AnimatePresence>
</div>
);
})}
</div>
);
};
return (
<div className={`relative ${className}`}>
<div
onClick={() => setIsOpen(!isOpen)}
className="w-full bg-slate-50 dark:bg-black/20 border border-border/80 rounded-xl px-4 py-4 text-sm outline-none focus:ring-4 ring-blueprint/5 focus:border-blueprint/40 transition-all cursor-pointer text-ink flex items-center gap-3"
>
<Folder size={16} className="text-blueprint/60 shrink-0" />
<div className="flex-1 flex items-center gap-1 min-w-0">
{path.length > 0 ? (
<div className="flex items-center gap-1.5 truncate">
{path.map((item, i) => (
<React.Fragment key={item.id}>
{i > 0 && <span className="text-concrete/40 text-[10px]">/</span>}
<span className={`truncate ${i === path.length - 1 ? 'font-bold' : 'text-concrete'}`}>
{item.name}
</span>
</React.Fragment>
))}
</div>
) : (
<span className="text-concrete italic">{placeholder}</span>
)}
</div>
<ChevronDown size={14} className={`transition-transform duration-300 text-concrete shrink-0 ${isOpen ? 'rotate-180' : ''}`} />
</div>
<AnimatePresence>
{isOpen && (
<>
<div
className="fixed inset-0 z-[60]"
onClick={() => setIsOpen(false)}
/>
<motion.div
initial={{ opacity: 0, y: 10, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 10, scale: 0.98 }}
className="absolute z-[70] mt-2 w-full bg-white dark:bg-dark-paper border border-border shadow-2xl rounded-2xl overflow-hidden flex flex-col min-w-[280px]"
>
<div className="p-3 border-b border-border/40 bg-slate-50/50">
<div className="relative">
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-concrete" />
<input
autoFocus
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Filtrer les carnets..."
className="w-full bg-white border border-border rounded-lg pl-9 pr-4 py-2 text-xs outline-none focus:border-blueprint transition-colors"
/>
</div>
</div>
<div className="max-h-72 overflow-y-auto custom-scrollbar p-2">
{renderTree(undefined)}
</div>
<div className="p-2 border-t border-border/40 bg-slate-50/30 flex justify-between items-center px-4">
<span className="text-[9px] font-bold text-concrete uppercase tracking-widest">
Structure des carnets
</span>
<button
onClick={() => setIsOpen(false)}
className="text-[10px] font-bold text-blueprint hover:underline"
>
Fermer
</button>
</div>
</motion.div>
</>
)}
</AnimatePresence>
</div>
);
};

View File

@@ -0,0 +1,469 @@
import React from 'react';
import {
Plus,
Search,
Share2,
Pin,
ChevronRight,
ArrowLeft,
MoreVertical,
Sparkles,
Tag as TagIcon,
X,
BookOpen,
Edit3,
Eye,
Trash2
} from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react';
import { Note, Carnet, Tag } from '../types';
import { SlashMenu } from './SlashMenu';
interface NotebooksViewProps {
activeNoteId: string | null;
activeCarnet: Carnet | undefined;
filteredNotes: Note[];
activeNote: Note | undefined;
setActiveNoteId: (id: string | null) => void;
togglePin: (id: string) => void;
setShowNewNoteModal: (show: boolean) => void;
isAISidebarOpen: boolean;
setIsAISidebarOpen: (open: boolean) => void;
selectedTagIds: string[];
setSelectedTagIds: (ids: string[]) => void;
allNotes: Note[];
activeCarnetId: string;
setShowNewCarnetModal: (show: boolean, parentId?: string, isRenaming?: boolean, carnetId?: string) => void;
onDeleteNote: (id: string) => void;
}
export const NotebooksView: React.FC<NotebooksViewProps> = ({
activeNoteId,
activeCarnet,
filteredNotes,
activeNote,
setActiveNoteId,
togglePin,
setShowNewNoteModal,
isAISidebarOpen,
setIsAISidebarOpen,
selectedTagIds,
setSelectedTagIds,
allNotes,
activeCarnetId,
setShowNewCarnetModal,
onDeleteNote
}) => {
const [isTagsExpanded, setIsTagsExpanded] = React.useState(false);
const [tagSearchQuery, setTagSearchQuery] = React.useState('');
const [isEditing, setIsEditing] = React.useState(false);
const [slashMenu, setSlashMenu] = React.useState<{ isOpen: boolean; top: number; left: number } | null>(null);
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 insertCommand = (type: string) => {
console.log(`Command selected: ${type}`);
setSlashMenu(null);
};
const availableTags = React.useMemo(() => {
const carnetNotes = allNotes.filter(n => n.carnetId === activeCarnetId);
const tagsMap = new Map<string, Tag>();
carnetNotes.forEach(note => {
note.tags?.forEach(tag => {
tagsMap.set(tag.id, tag);
});
});
return Array.from(tagsMap.values()).sort((a, b) => {
// AI tags first, then alphabetical
if (a.type === 'ai' && b.type !== 'ai') return -1;
if (a.type !== 'ai' && b.type === 'ai') return 1;
return a.label.localeCompare(b.label);
});
}, [allNotes, activeCarnetId]);
const visibleTags = React.useMemo(() => {
let filtered = availableTags;
if (tagSearchQuery) {
filtered = availableTags.filter(t =>
t.label.toLowerCase().includes(tagSearchQuery.toLowerCase())
);
} else if (!isTagsExpanded) {
filtered = availableTags.slice(0, 10);
// Ensure selected tags are always visible even if not in the first 10
selectedTagIds.forEach(id => {
if (!filtered.find(t => t.id === id)) {
const tag = availableTags.find(t => t.id === id);
if (tag) filtered.push(tag);
}
});
}
return filtered;
}, [availableTags, isTagsExpanded, tagSearchQuery, selectedTagIds]);
const toggleTag = (tagId: string) => {
if (selectedTagIds.includes(tagId)) {
setSelectedTagIds(selectedTagIds.filter(id => id !== tagId));
} else {
setSelectedTagIds([...selectedTagIds, tagId]);
}
};
if (!activeNoteId) {
return (
<div className="h-full flex flex-col overflow-y-auto">
<header className="px-12 pt-12 pb-8 flex flex-col gap-6 sticky top-0 bg-paper/80 backdrop-blur-md z-30">
<div className="flex justify-between items-start">
<h1 className="text-4xl font-serif font-medium tracking-tight text-ink leading-tight pr-12">
{activeCarnet?.name} {filteredNotes[0]?.date || 'Oct 26'}
</h1>
</div>
<div className="flex items-center justify-between border-b border-ink/5 pb-4">
<div className="flex items-center gap-6">
<button
onClick={() => setShowNewNoteModal(true)}
className="flex items-center gap-2 text-[13px] text-ink font-medium hover:opacity-70 transition-opacity"
>
<Plus size={16} />
<span>Add Note</span>
</button>
<button
onClick={() => setShowNewCarnetModal(true, activeCarnetId)}
className="flex items-center gap-2 text-[13px] text-concrete font-medium hover:text-ink transition-all"
>
<BookOpen size={16} />
<span>New Sub-Carnet</span>
</button>
<button className="flex items-center gap-2 text-[13px] text-ink font-medium hover:opacity-70 transition-opacity">
<Search size={16} />
<span>Search</span>
</button>
</div>
<button className="flex items-center gap-2 text-[13px] text-ink font-medium hover:opacity-70 transition-opacity">
<Share2 size={16} />
<span>Share</span>
</button>
</div>
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3 text-[10px] font-bold uppercase tracking-[0.2em] text-concrete">
<TagIcon size={12} />
<span>Filter by Tags</span>
{selectedTagIds.length > 0 && (
<span className="bg-blueprint/10 text-blueprint px-2 py-0.5 rounded-full text-[9px] lowercase tracking-normal">
{selectedTagIds.length} active
</span>
)}
</div>
{availableTags.length > 10 && (
<div className="relative group">
<input
type="text"
placeholder="Search tags..."
className="bg-transparent border-b border-border/40 text-[10px] outline-none focus:border-blueprint/40 py-1 px-2 w-32 transition-all focus:w-48 placeholder:text-concrete/40"
onChange={(e) => setTagSearchQuery(e.target.value)}
/>
</div>
)}
</div>
<div className="flex flex-wrap gap-2 items-center min-h-[32px]">
<AnimatePresence mode="popLayout">
{visibleTags.map(tag => {
const isActive = selectedTagIds.includes(tag.id);
return (
<motion.button
layout
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
key={tag.id}
onClick={() => toggleTag(tag.id)}
className={`px-3 py-1.5 rounded-full text-[10px] font-bold uppercase tracking-wider transition-all border flex items-center gap-2
${isActive
? 'bg-ink text-paper border-ink shadow-lg shadow-ink/10'
: 'bg-white/40 border-border text-concrete hover:border-concrete/40 hover:bg-white/60'}`}
>
{tag.type === 'ai' && (
<Sparkles
size={10}
className={isActive ? 'text-blueprint' : 'text-blueprint/60'}
/>
)}
{tag.label}
{isActive && <X size={10} />}
</motion.button>
);
})}
</AnimatePresence>
{availableTags.length > 10 && !tagSearchQuery && (
<button
onClick={() => setIsTagsExpanded(!isTagsExpanded)}
className="px-3 py-1.5 text-[10px] font-bold uppercase tracking-wider text-concrete/60 hover:text-ink transition-colors border border-dashed border-border rounded-full"
>
{isTagsExpanded ? 'Show less' : `+ ${availableTags.length - 10} more`}
</button>
)}
{selectedTagIds.length > 0 && (
<button
onClick={() => setSelectedTagIds([])}
className="px-3 py-1.5 text-[10px] font-bold uppercase tracking-wider text-rust hover:underline ml-auto"
>
Clear all
</button>
)}
</div>
</div>
</header>
<div className="px-12 flex-1 pb-20">
<div className="max-w-3xl space-y-16">
{filteredNotes.map((note, index) => (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 * index, duration: 0.8 }}
key={note.id}
className="space-y-4 group cursor-pointer relative"
onClick={() => setActiveNoteId(note.id)}
>
<h2 className="text-2xl font-serif font-medium text-ink flex items-center justify-between">
<span className="flex items-center gap-3">
{note.isPinned && <Pin size={18} className="text-amber-500 fill-amber-500" />}
{note.title}
</span>
<div className="flex items-center gap-2">
<button
onClick={(e) => {
e.stopPropagation();
togglePin(note.id);
}}
className={`p-2 rounded-full transition-all ${note.isPinned ? 'text-amber-600 bg-amber-50' : 'opacity-0 group-hover:opacity-60 hover:bg-slate-100 text-ink'}`}
>
<Pin size={16} />
</button>
<button
onClick={(e) => {
e.stopPropagation();
onDeleteNote(note.id);
}}
className="p-2 rounded-full opacity-0 group-hover:opacity-60 hover:opacity-100 hover:bg-rose-50 text-rose-500 transition-all"
>
<Trash2 size={16} />
</button>
<button className="opacity-0 group-hover:opacity-40 transition-opacity">
<ChevronRight size={20} />
</button>
</div>
</h2>
<div className="flex flex-col md:flex-row gap-8 items-start">
<div className="w-full md:w-56 aspect-[4/3] bg-white/50 dark:bg-white/5 border border-border overflow-hidden rounded shadow-sm flex-shrink-0">
<img
src={note.imageUrl}
alt={note.title}
className="w-full h-full object-cover mix-blend-multiply opacity-80 grayscale contrast-125 hover:grayscale-0 hover:opacity-100 transition-all duration-500"
referrerPolicy="no-referrer"
/>
</div>
<div className="space-y-3">
<div className="flex flex-wrap gap-2 mb-2">
{note.tags?.map(tag => (
<div
key={tag.id}
className={`px-2 py-0.5 rounded text-[9px] font-bold uppercase tracking-wider border flex items-center gap-1.5
${tag.type === 'ai'
? 'bg-blueprint/5 border-blueprint/20 text-blueprint'
: 'bg-concrete/5 border-border text-concrete'}`}
>
{tag.type === 'ai' && <Sparkles size={8} />}
{tag.label}
</div>
))}
</div>
<p className="text-[14px] leading-relaxed text-ink/80 font-light max-w-lg line-clamp-4">
{note.content}
</p>
<span className="text-[11px] text-muted-ink uppercase tracking-widest font-medium">Read more</span>
</div>
</div>
</motion.div>
))}
{filteredNotes.length === 0 && (
<div className="h-64 flex flex-col items-center justify-center text-center space-y-4">
<p className="font-serif text-xl italic text-muted-ink">This notebook is waiting for its first vision.</p>
<button
onClick={() => setShowNewNoteModal(true)}
className="px-6 py-2 border border-ink text-[13px] uppercase tracking-[0.2em] hover:bg-ink hover:text-paper transition-all"
>
Begin Drawing
</button>
</div>
)}
</div>
</div>
<footer className="px-12 py-6 border-t border-ink/5 text-center mt-auto">
<p className="text-[11px] text-muted-ink uppercase tracking-[0.2em] font-medium">
&copy; 2024 Architectural Grid. All rights reserved.
</p>
</footer>
</div>
);
}
return (
<div className="h-full flex overflow-hidden transition-all duration-500">
<div className="flex-1 flex flex-col overflow-y-auto bg-white dark:bg-paper">
<div className="px-12 py-8 flex items-center justify-between sticky top-0 bg-white/90 dark:bg-paper/90 backdrop-blur-sm z-40 border-b border-border">
<button
onClick={() => setActiveNoteId(null)}
className="flex items-center gap-2 text-ink hover:opacity-60 transition-opacity"
>
<ArrowLeft size={18} />
<span className="text-sm font-medium">Back to collection</span>
</button>
<div className="flex items-center gap-4">
<button
onClick={() => setIsEditing(!isEditing)}
className={`flex items-center gap-2 px-4 py-1.5 rounded-lg border transition-all duration-300
${isEditing ? 'bg-blueprint text-white border-blueprint shadow-lg shadow-blueprint/20' : 'border-border text-ink hover:bg-slate-50'}`}
>
{isEditing ? <Eye size={16} /> : <Edit3 size={16} />}
<span className="text-xs font-bold uppercase tracking-widest">{isEditing ? 'Visualiser' : 'Modifier'}</span>
</button>
<button
onClick={() => togglePin(activeNoteId!)}
className={`p-2 rounded-full transition-all ${activeNote?.isPinned ? 'text-amber-600 bg-amber-50 dark:bg-ochre/10' : 'text-muted-ink hover:text-ink'}`}
title={activeNote?.isPinned ? "Unpin note" : "Pin note"}
>
<Pin size={18} className={activeNote?.isPinned ? 'fill-amber-600' : ''} />
</button>
<button
onClick={() => setIsAISidebarOpen(!isAISidebarOpen)}
className={`flex items-center gap-2 px-3 py-1.5 rounded-full border transition-all duration-300
${isAISidebarOpen ? 'bg-ink text-paper border-ink' : 'border-border text-ink hover:bg-white/50 dark:hover:bg-white/5'}`}
>
<Sparkles size={16} />
<span className="text-xs font-medium">AI Assistant</span>
</button>
<button className="p-2 text-muted-ink hover:text-red-500 transition-colors">
<Trash2 size={18} />
</button>
<button className="p-2 text-muted-ink hover:text-ink transition-colors">
<MoreVertical size={18} />
</button>
</div>
</div>
<div className="max-w-4xl mx-auto w-full px-12 py-16 space-y-12 relative">
<AnimatePresence>
{slashMenu?.isOpen && (
<SlashMenu
position={{ top: slashMenu.top, left: slashMenu.left }}
onSelect={(type) => insertCommand(type)}
onClose={() => setSlashMenu(null)}
/>
)}
</AnimatePresence>
<div className="space-y-4">
<div className="flex items-center gap-3 text-[12px] text-muted-ink uppercase tracking-[.25em] font-bold">
<span className="text-blueprint">{activeCarnet?.name}</span>
<ChevronRight size={10} className="text-concrete" />
<span className="text-concrete">{activeNote?.date}</span>
</div>
{isEditing ? (
<input
type="text"
defaultValue={activeNote?.title}
className="w-full text-5xl md:text-6xl font-serif font-bold text-ink leading-tight bg-transparent border-none outline-none focus:ring-0 placeholder:text-concrete/20"
placeholder="Titre de la note..."
/>
) : (
<h1 className="text-5xl md:text-6xl font-serif font-bold text-ink leading-tight">
{activeNote?.title}
</h1>
)}
<div className="flex flex-wrap gap-2 pt-2">
{activeNote?.tags?.map(tag => (
<div
key={tag.id}
className={`px-3 py-1 rounded-full text-[10px] font-bold uppercase tracking-widest border flex items-center gap-2
${tag.type === 'ai'
? 'bg-blueprint/5 border-blueprint/20 text-blueprint'
: 'bg-paper border-border text-concrete'}`}
>
{tag.type === 'ai' && <Sparkles size={12} />}
{tag.label}
{tag.type === 'ai' && (
<div className="w-1.5 h-1.5 rounded-full bg-blueprint animate-pulse" />
)}
</div>
))}
</div>
</div>
<div className="aspect-[16/9] w-full bg-slate-100 dark:bg-white/5 rounded-xl overflow-hidden shadow-2xl relative group/img">
<img
src={activeNote?.imageUrl}
alt={activeNote?.title}
className="w-full h-full object-cover transition-transform duration-700 group-hover/img:scale-105"
referrerPolicy="no-referrer"
/>
<div className="absolute inset-0 bg-gradient-to-t from-ink/20 to-transparent pointer-events-none" />
</div>
<div className="max-w-2xl mx-auto w-full space-y-8 pb-40">
{isEditing ? (
<textarea
defaultValue={activeNote?.content}
onKeyDown={handleEditorKeyDown}
className="w-full min-h-[500px] text-lg leading-relaxed text-ink/90 font-serif bg-transparent border-none outline-none focus:ring-0 resize-none placeholder:text-concrete/20"
placeholder="Commencez à écrire... Tapez '/' pour les commandes."
/>
) : (
<div className="space-y-8">
<p className="text-xl md:text-2xl font-serif leading-relaxed text-ink italic">
{activeNote?.content.split('.')[0]}.
</p>
<div className="h-px bg-border w-32" />
<div className="space-y-6">
{activeNote?.content.split('\n').map((line, i) => (
<p key={i} className="text-lg leading-relaxed text-ink/80 font-light text-justify selection:bg-blueprint/20">
{line}
</p>
))}
{activeNote?.id.startsWith('n-') && (
<p className="text-lg leading-relaxed text-ink/80 font-light text-justify border-l-2 border-blueprint/20 pl-6 italic">
Architectural grids serve as the invisible scaffolding upon which spatial experiences are constructed. Beyond mere structural repetition, they facilitate a rhythmic dialogue between materiality and void. In this exploration, we examine how light fractures these rigid boundaries, creating a dynamic interplay that evolves with the passage of time.
</p>
)}
</div>
</div>
)}
</div>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,66 @@
import React from 'react';
import { motion, AnimatePresence } from 'motion/react';
import { SettingsTab } from '../types';
import { SettingsHeader } from './settings/SettingsHeader';
import { GeneralTab } from './settings/GeneralTab';
import { AITab } from './settings/AITab';
import { AppearanceTab } from './settings/AppearanceTab';
interface SettingsViewProps {
activeSettingsTab: SettingsTab;
setActiveSettingsTab: (tab: SettingsTab) => void;
}
export const SettingsView: React.FC<SettingsViewProps> = ({
activeSettingsTab,
setActiveSettingsTab
}) => {
return (
<div className="h-full flex flex-col bg-paper dark:bg-dark-paper overflow-y-auto custom-scrollbar relative font-sans">
<div className="absolute inset-0 opacity-[0.04] pointer-events-none grainy-bg mix-blend-multiply dark:mix-blend-overlay" />
<div className="relative z-10 flex flex-col min-h-full">
<SettingsHeader
activeTab={activeSettingsTab}
setActiveTab={setActiveSettingsTab}
/>
<div className="flex-1 px-12 pb-24 h-full">
<div className="max-w-6xl mx-auto">
<AnimatePresence mode="wait">
{activeSettingsTab === 'general' && (
<GeneralTab key="general" />
)}
{activeSettingsTab === 'ai' && (
<AITab key="ai" />
)}
{activeSettingsTab === 'appearance' && (
<AppearanceTab key="appearance" />
)}
{['profile', 'data', 'mcp', 'about'].includes(activeSettingsTab) && (
<motion.div
key="placeholder"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="h-[50vh] flex flex-col items-center justify-center border border-dashed border-border rounded-[32px] space-y-6 bg-white/20 dark:bg-white/5"
>
<div className="w-16 h-16 rounded-3xl border border-dashed border-concrete/20 flex items-center justify-center text-concrete/40 bg-paper/50">
<span className="text-2xl font-serif italic text-concrete">?</span>
</div>
<div className="text-center space-y-1">
<p className="text-ink font-bold text-sm tracking-tight">Section en développement</p>
<p className="text-concrete italic text-[11px] font-light">Le module {activeSettingsTab} sera disponible prochainement.</p>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,450 @@
import React from 'react';
import {
Plus,
Archive,
Settings,
ChevronRight,
BookOpen,
Bot,
Microscope,
Activity,
Pin,
Moon,
Sun,
Bell,
Lock,
Edit3,
Trash2,
Users,
Clock
} from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react';
import { NavigationView, Carnet, Note } from '../types';
interface NoteLinkProps {
note: Note;
isActive: boolean;
onClick: () => void;
}
const NoteLink: React.FC<NoteLinkProps> = ({ note, isActive, onClick }) => (
<motion.button
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
onClick={onClick}
className={`w-full flex items-center gap-2 pl-12 pr-4 py-2 text-[12px] transition-colors rounded-lg
${isActive ? 'bg-white/50 dark:bg-white/10 text-ink font-medium' : 'text-muted-ink hover:text-ink hover:bg-white/30'}`}
>
<div className="flex items-center gap-2 flex-1 truncate">
<div className={`w-1.5 h-1.5 rounded-full shrink-0 ${isActive ? 'bg-ink' : 'bg-transparent border border-muted-ink/30'}`} />
<span className="truncate">{note.title}</span>
</div>
{note.isPinned && <Pin size={10} className="text-amber-500 shrink-0" />}
</motion.button>
);
interface SidebarItemProps {
carnet: Carnet;
isActive: boolean;
notes: Note[];
activeNoteId: string | null;
onCarnetClick: () => void;
onNoteClick: (noteId: string) => void;
onAddSubCarnet: () => void;
onRename: () => void;
onDelete: () => void;
children?: React.ReactNode;
level: number;
isExpanded: boolean;
toggleExpand: () => void;
}
const SidebarItem: React.FC<SidebarItemProps> = ({
carnet,
isActive,
notes,
activeNoteId,
onCarnetClick,
onNoteClick,
onAddSubCarnet,
onRename,
onDelete,
children,
level,
isExpanded,
toggleExpand
}) => {
const hasChildren = React.Children.count(children) > 0;
return (
<div className="space-y-0.5">
<div
className="flex items-center group relative h-10"
style={{ paddingLeft: `${level * 12}px` }}
>
{/* Hierarchy Guide Line */}
{level > 0 && (
<div className="absolute left-[8px] top-[-10px] bottom-1/2 w-px bg-border/40" />
)}
{level > 0 && (
<div className="absolute left-[8px] top-1/2 w-[8px] h-px bg-border/40" />
)}
<div className="flex-1 flex items-center gap-1">
{hasChildren ? (
<button
onClick={(e) => {
e.stopPropagation();
toggleExpand();
}}
className="p-1 hover:bg-ink/5 dark:hover:bg-white/5 rounded-md transition-colors text-muted-ink"
>
<motion.div animate={{ rotate: isExpanded ? 90 : 0 }} transition={{ duration: 0.2 }}>
<ChevronRight size={14} />
</motion.div>
</button>
) : (
<div className="w-6" /> // Spacer for alignment
)}
{/* Hierarchy Connector Line */}
{hasChildren && level > 0 && (
<div className="absolute left-[-16px] top-[14px] w-3 h-[1px] bg-border/60" />
)}
<motion.div
whileHover={{ x: 2 }}
className={`flex-1 flex items-center gap-2.5 px-2 py-1.5 rounded-lg transition-all duration-300 group/item cursor-pointer relative
${isActive ? 'bg-white shadow-sm border border-border/40 dark:bg-white/10' : 'hover:bg-white/40 dark:hover:bg-white/5'}`}
onClick={onCarnetClick}
>
{/* active indicator dot */}
{isActive && (
<motion.div
layoutId="active-indicator"
className="absolute -left-1 w-1 h-4 bg-blueprint rounded-full"
/>
)}
<div className={`w-6 h-6 rounded-md flex items-center justify-center text-[10px] font-bold border transition-all
${isActive ? 'bg-blueprint text-white border-blueprint' : 'bg-paper dark:bg-white/10 text-concrete border-border dark:border-white/10'}`}>
{carnet.initial}
</div>
<div className="flex-1 text-left flex items-center gap-2 min-w-0">
<span className={`text-[12px] font-medium transition-colors truncate ${isActive ? 'text-ink' : 'text-muted-ink group-hover:text-ink'}`}>
{carnet.name}
</span>
{carnet.isPrivate && <Lock size={10} className="text-concrete/60 shrink-0" />}
</div>
<div className="flex items-center gap-1 opacity-0 group-hover/item:opacity-100 transition-opacity shrink-0">
<button
onClick={(e) => {
e.stopPropagation();
onAddSubCarnet();
}}
className="p-1 hover:bg-ink/10 dark:hover:bg-white/10 rounded-md transition-all text-concrete hover:text-ink"
title="Add sub-carnet"
>
<Plus size={10} />
</button>
<button
onClick={(e) => {
e.stopPropagation();
onRename();
}}
className="p-1 hover:bg-ink/10 dark:hover:bg-white/10 rounded-md transition-all text-concrete hover:text-ink"
title="Rename"
>
<Edit3 size={10} />
</button>
<button
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
className="p-1 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-md transition-all text-concrete hover:text-red-500"
title="Delete"
>
<Trash2 size={10} />
</button>
{notes.length > 0 && (
<span className="text-[9px] font-bold text-concrete/40 px-1.5 border border-border/40 rounded-full group-hover:text-concrete transition-colors">
{notes.length}
</span>
)}
</div>
</motion.div>
</div>
</div>
<AnimatePresence initial={false}>
{(isExpanded || (isActive && !hasChildren)) && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.3, ease: [0.23, 1, 0.32, 1] }}
className="overflow-hidden"
>
<div className="relative" style={{ marginLeft: `${(level + 1) * 12 + 10}px` }}>
{/* Vertical line for nested content */}
<div className="absolute left-[-6px] top-0 bottom-4 w-px bg-border/30" />
<div className="space-y-1 py-1">
{children}
{isActive && !hasChildren && notes.map(note => (
<NoteLink
key={note.id}
note={note}
isActive={activeNoteId === note.id}
onClick={() => onNoteClick(note.id)}
/>
))}
{isActive && !hasChildren && notes.length === 0 && (
<p className="pl-8 py-2 text-[10px] italic text-concrete/40 font-light">
No notes found
</p>
)}
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
};
interface SidebarProps {
activeView: NavigationView;
isDarkMode: boolean;
setIsDarkMode: (val: boolean) => void;
setActiveView: (view: NavigationView) => void;
carnets: Carnet[];
notes: Note[];
activeCarnetId: string;
activeNoteId: string | null;
setActiveCarnetId: (id: string) => void;
setActiveNoteId: (id: string | null) => void;
setShowNewCarnetModal: (show: boolean, parentId?: string, isRenaming?: boolean, carnetId?: string) => void;
onDeleteCarnet: (id: string) => void;
}
export const Sidebar: React.FC<SidebarProps> = ({
activeView,
isDarkMode,
setIsDarkMode,
setActiveView,
carnets,
notes,
activeCarnetId,
activeNoteId,
setActiveCarnetId,
setActiveNoteId,
setShowNewCarnetModal,
onDeleteCarnet
}) => {
const [expandedIds, setExpandedIds] = React.useState<Set<string>>(new Set(['4'])); // Default expand Research
const toggleExpand = (id: string) => {
const newSet = new Set(expandedIds);
if (newSet.has(id)) newSet.delete(id);
else newSet.add(id);
setExpandedIds(newSet);
};
const renderCarnetTree = (parentId: string | undefined = undefined, level: number = 0) => {
return carnets
.filter(c => c.parentId === parentId && !c.isDeleted)
.map(carnet => (
<SidebarItem
key={carnet.id}
carnet={carnet}
isActive={activeCarnetId === carnet.id}
notes={notes.filter(n => n.carnetId === carnet.id && !n.isDeleted)}
activeNoteId={activeNoteId}
level={level}
isExpanded={expandedIds.has(carnet.id)}
toggleExpand={() => toggleExpand(carnet.id)}
onAddSubCarnet={() => {
if (!expandedIds.has(carnet.id)) toggleExpand(carnet.id);
setShowNewCarnetModal(true, carnet.id);
}}
onRename={() => {
setShowNewCarnetModal(true, undefined, true, carnet.id);
}}
onDelete={() => {
onDeleteCarnet(carnet.id);
}}
onCarnetClick={() => {
setActiveCarnetId(carnet.id);
setActiveNoteId(null);
// Auto expand when clicking
if (!expandedIds.has(carnet.id)) toggleExpand(carnet.id);
}}
onNoteClick={(id) => {
setActiveCarnetId(carnet.id);
setActiveNoteId(id);
}}
>
{renderCarnetTree(carnet.id, level + 1)}
</SidebarItem>
));
};
return (
<aside className="w-80 bg-white/30 dark:bg-[#151515] backdrop-blur-md border-r border-border p-6 flex flex-col z-20 shrink-0 transition-colors duration-500">
<div className="mb-10 flex items-center justify-between">
<div className="w-10 h-10 rounded-full bg-slate-200 dark:bg-white/10 border border-border flex items-center justify-center text-ink font-serif text-lg shadow-sm">
A
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setIsDarkMode(!isDarkMode)}
className="p-2 text-muted-ink hover:text-ink transition-all bg-white/50 dark:bg-white/10 rounded-full border border-border dark:border-white/10"
>
{isDarkMode ? <Sun size={14} /> : <Moon size={14} />}
</button>
<button className="p-2 text-muted-ink hover:text-ink transition-all relative group bg-white/50 dark:bg-white/10 rounded-full border border-border dark:border-white/10">
<Bell size={14} />
<span className="absolute -top-1 -right-1 w-4 h-4 bg-rose-500 text-white text-[9px] font-bold flex items-center justify-center rounded-full border border-white shadow-sm">
3
</span>
</button>
<div className="flex bg-white/50 dark:bg-white/10 p-1 rounded-full border border-border dark:border-white/10 transition-all">
<button
onClick={() => setActiveView('notebooks')}
className={`p-1.5 rounded-full transition-all ${activeView === 'notebooks' ? 'bg-ink text-paper shadow-sm' : 'text-muted-ink hover:text-ink'}`}
title="Carnets"
>
<BookOpen size={14} />
</button>
<button
onClick={() => setActiveView('reminders')}
className={`p-1.5 rounded-full transition-all ${activeView === 'reminders' ? 'bg-ink text-paper shadow-sm' : 'text-muted-ink hover:text-ink'}`}
title="Rappels"
>
<Clock size={14} />
</button>
<button
onClick={() => setActiveView('agents')}
className={`p-1.5 rounded-full transition-all ${activeView === 'agents' ? 'bg-ink text-paper shadow-sm' : 'text-muted-ink hover:text-ink'}`}
title="Agents"
>
<Bot size={14} />
</button>
</div>
</div>
</div>
<div className="flex-1 overflow-y-auto space-y-8 -mx-2 px-2 py-4 custom-scrollbar">
{activeView === 'notebooks' ? (
<div className="space-y-6">
<div className="flex items-center justify-between px-4">
<p className="text-[10px] font-bold text-concrete tracking-[0.2em] uppercase">
Architecture Grid
</p>
<button
onClick={() => setShowNewCarnetModal(true)}
className="p-1 hover:bg-paper dark:hover:bg-white/5 rounded-md text-concrete hover:text-ink transition-colors"
title="New Carnet"
>
<Plus size={14} />
</button>
</div>
<nav className="space-y-0.5">
{renderCarnetTree()}
</nav>
</div>
) : activeView === 'shared' ? (
<div className="space-y-6">
<p className="text-[10px] font-bold text-concrete tracking-[0.2em] uppercase px-4">
Partagé avec moi
</p>
<div className="px-4 py-8 text-center border border-dashed border-border rounded-2xl bg-paper/30">
<Users size={24} className="mx-auto text-concrete/40 mb-3" />
<p className="text-[11px] text-concrete italic">Aucun document partagé pour le moment.</p>
</div>
</div>
) : activeView === 'reminders' ? (
<div className="space-y-6">
<p className="text-[10px] font-bold text-concrete tracking-[0.2em] uppercase px-4">
Rappels programmés
</p>
<div className="px-4 py-8 text-center border border-dashed border-border rounded-2xl bg-paper/30">
<Clock size={24} className="mx-auto text-concrete/40 mb-3" />
<p className="text-[11px] text-concrete italic">Aucun rappel actif.</p>
</div>
</div>
) : activeView === 'agents' ? (
<div>
<p className="text-[10px] font-bold text-muted-ink tracking-widest uppercase mb-4 px-4">
Intelligence OS
</p>
<div className="space-y-1">
{[
{ id: 'a1', name: 'Mes Agents', icon: <Bot size={16} /> },
{ id: 'a2', name: 'Le Lab AI', icon: <Microscope size={16} /> },
{ id: 'a3', name: 'Activités', icon: <Activity size={16} /> },
].map(item => (
<button
key={item.id}
className={`w-full flex items-center gap-3 px-4 py-3 rounded-xl transition-all duration-300 group
${item.id === 'a1' ? 'active-nav-item' : 'text-muted-ink hover:bg-white/40 dark:hover:bg-white/5 hover:text-ink'}`}
>
<div className={`w-8 h-8 rounded-full flex items-center justify-center border transition-colors
${item.id === 'a1' ? 'bg-ink text-paper border-ink' : 'bg-white/60 dark:bg-white/10 border-border dark:border-white/10 group-hover:border-ink/20'}`}>
{item.icon}
</div>
<span className="text-[13px] font-medium">{item.name}</span>
</button>
))}
</div>
</div>
) : null}
</div>
<div className="pt-4 border-t border-border/40 mt-auto pb-4">
<div className="px-2 space-y-0.5">
<button
onClick={() => setActiveView('shared')}
className={`w-full flex items-center gap-3 px-3 py-2 text-[12px] transition-all font-medium group rounded-xl ${activeView === 'shared' ? 'bg-blueprint/5 text-blueprint' : 'text-muted-ink hover:text-ink hover:bg-black/5'}`}
>
<Users size={14} className={activeView === 'shared' ? 'text-blueprint' : 'text-muted-ink group-hover:text-ink'} />
<span className="flex-1 text-left">Partagé</span>
</button>
<button className="w-full flex items-center gap-3 px-3 py-2 text-[12px] text-muted-ink hover:text-ink hover:bg-black/5 transition-all font-medium group rounded-xl">
<Archive size={14} className="text-muted-ink group-hover:text-ink" />
<span className="flex-1 text-left">Archives</span>
</button>
<button
onClick={() => setActiveView('trash')}
className={`w-full flex items-center gap-3 px-3 py-2 text-[12px] transition-all font-medium group rounded-xl ${activeView === 'trash' ? 'bg-rose-50 text-rose-500' : 'text-muted-ink hover:text-rose-500 hover:bg-rose-50/50'}`}
>
<Trash2 size={14} className={activeView === 'trash' ? 'text-rose-500' : 'text-muted-ink group-hover:text-rose-500'} />
<span className="flex-1 text-left">Corbeille</span>
{notes.some(n => n.isDeleted) && (
<div className="w-1.5 h-1.5 rounded-full bg-rose-400" />
)}
</button>
<div className="my-2 h-px bg-border/20 mx-2" />
<button
onClick={() => setActiveView('settings')}
className={`w-full flex items-center gap-3 px-3 py-2 text-[12px] transition-all font-medium group rounded-xl ${activeView === 'settings' ? 'bg-ink text-paper shadow-sm' : 'text-muted-ink hover:text-ink hover:bg-black/5'}`}
>
<Settings size={14} className={activeView === 'settings' ? 'text-paper' : 'text-muted-ink group-hover:text-ink'} />
<span className="flex-1 text-left">Paramètres</span>
</button>
</div>
</div>
</aside>
);
};

View File

@@ -0,0 +1,65 @@
import React from 'react';
import {
Heading1,
Heading2,
List,
Quote,
Code,
Image as ImageIcon,
Type,
Sparkles
} from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react';
interface SlashMenuProps {
position: { top: number; left: number };
onSelect: (type: string) => void;
onClose: () => void;
}
export const SlashMenu: React.FC<SlashMenuProps> = ({ position, onSelect, onClose }) => {
const commands = [
{ id: 'h1', label: 'Titre Principal', icon: <Heading1 size={14} />, desc: 'Grand titre de section' },
{ id: 'h2', label: 'Sous-titre', icon: <Heading2 size={14} />, desc: 'Titre de niveau 2' },
{ id: 'bullet', label: 'Liste à puces', icon: <List size={14} />, desc: 'Liste simple' },
{ id: 'quote', label: 'Citation', icon: <Quote size={14} />, desc: 'Bloc de texte mis en avant' },
{ id: 'code', label: 'Bloc de Code', icon: <Code size={14} />, desc: 'Code ou texte technique' },
{ id: 'image', label: 'Image', icon: <ImageIcon size={14} />, desc: 'Insérer un visuel' },
{ id: 'ai-summary', label: 'Résumé IA', icon: <Sparkles size={14} />, desc: 'Générer un résumé court', special: true },
];
return (
<>
<div className="fixed inset-0 z-[60]" onClick={onClose} />
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 10 }}
className="fixed z-[70] w-64 bg-white dark:bg-[#1A1A1A] border border-border shadow-2xl rounded-xl overflow-hidden py-2"
style={{ top: position.top, left: position.left }}
>
<div className="px-3 py-2 text-[10px] font-bold text-concrete uppercase tracking-widest border-b border-border/40 mb-1">
Commandes rapides
</div>
<div className="max-h-80 overflow-y-auto custom-scrollbar">
{commands.map((cmd) => (
<button
key={cmd.id}
onClick={() => onSelect(cmd.id)}
className="w-full flex items-start gap-3 px-3 py-2 hover:bg-paper dark:hover:bg-white/5 transition-colors group text-left"
>
<div className={`p-2 rounded-lg border border-border transition-colors group-hover:border-ink/20
${cmd.special ? 'bg-blueprint/10 text-blueprint border-blueprint/20' : 'bg-white/50 dark:bg-white/5 text-ink'}`}>
{cmd.icon}
</div>
<div className="space-y-0.5">
<p className="text-xs font-bold text-ink">{cmd.label}</p>
<p className="text-[10px] text-muted-ink leading-tight">{cmd.desc}</p>
</div>
</button>
))}
</div>
</motion.div>
</>
);
};

View File

@@ -0,0 +1,218 @@
import React from 'react';
import {
Trash2,
RotateCcw,
X,
FileText,
Folder,
Search,
ChevronRight,
Clock,
AlertCircle
} from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react';
import { Note, Carnet } from '../types';
interface TrashViewProps {
deletedNotes: Note[];
deletedCarnets: Carnet[];
onRestoreNote: (id: string) => void;
onRestoreCarnet: (id: string) => void;
onPermanentDeleteNote: (id: string) => void;
onPermanentDeleteCarnet: (id: string) => void;
onEmptyTrash: () => void;
}
export const TrashView: React.FC<TrashViewProps> = ({
deletedNotes,
deletedCarnets,
onRestoreNote,
onRestoreCarnet,
onPermanentDeleteNote,
onPermanentDeleteCarnet,
onEmptyTrash
}) => {
const [searchQuery, setSearchQuery] = React.useState('');
const [filterType, setFilterType] = React.useState<'all' | 'notes' | 'carnets'>('all');
const getDaysRemaining = (dateString?: string) => {
if (!dateString) return 30;
const deletedDate = new Date(dateString);
const now = new Date();
const diffTime = now.getTime() - deletedDate.getTime();
const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24));
return Math.max(0, 30 - diffDays);
};
const filteredItems = React.useMemo(() => {
const items = [
...deletedNotes.map(n => ({ ...n, itemType: 'note' as const })),
...deletedCarnets.map(c => ({ ...c, itemType: 'carnet' as const }))
];
return items
.filter(item => {
const matchesSearch = ('title' in item ? item.title : item.name).toLowerCase().includes(searchQuery.toLowerCase());
const matchesType = filterType === 'all' || (filterType === 'notes' && item.itemType === 'note') || (filterType === 'carnets' && item.itemType === 'carnet');
return matchesSearch && matchesType;
})
.sort((a, b) => {
const dateA = a.deletedAt ? new Date(a.deletedAt).getTime() : 0;
const dateB = b.deletedAt ? new Date(b.deletedAt).getTime() : 0;
return dateB - dateA;
});
}, [deletedNotes, deletedCarnets, searchQuery, filterType]);
return (
<div className="h-full flex flex-col bg-[#F9F8F6] dark:bg-dark-paper">
<header className="px-12 pt-12 pb-8 flex flex-col gap-6 sticky top-0 bg-[#F9F8F6]/80 backdrop-blur-md z-30 border-b border-border/20">
<div className="flex items-center justify-between">
<div className="space-y-1">
<h1 className="text-4xl font-serif font-medium text-ink flex items-center gap-4">
Corbeille <Trash2 size={28} className="text-rose-400 opacity-40" />
</h1>
<p className="text-[10px] text-concrete font-bold uppercase tracking-[0.3em] opacity-60">
Auto-suppression après 30 jours
</p>
</div>
{filteredItems.length > 0 && (
<button
onClick={() => {
if (window.confirm('Vider la corbeille ? Cette action est irréversible.')) {
onEmptyTrash();
}
}}
className="px-6 py-3 bg-paper border border-border text-rose-500 rounded-2xl text-[10px] font-bold uppercase tracking-widest hover:bg-rose-50 hover:border-rose-100 transition-all shadow-sm"
>
Vider tout
</button>
)}
</div>
<div className="flex items-center gap-6">
<div className="group relative flex-1 max-w-xl">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-concrete group-focus-within:text-ink transition-colors" size={16} />
<input
type="text"
placeholder="Rechercher..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full bg-white dark:bg-white/5 border border-border/40 rounded-2xl pl-12 pr-6 py-4 text-sm outline-none focus:ring-4 ring-ink/5 transition-all shadow-sm"
/>
</div>
<div className="flex bg-white dark:bg-white/5 p-1 rounded-2xl border border-border shadow-sm">
{(['all', 'notes', 'carnets'] as const).map((type) => (
<button
key={type}
onClick={() => setFilterType(type)}
className={`px-6 py-3 rounded-xl text-[10px] font-bold uppercase tracking-widest transition-all
${filterType === type ? 'bg-ink text-paper shadow-lg' : 'text-concrete hover:text-ink'}`}
>
{type === 'all' ? 'Tous' : type === 'notes' ? 'Notes' : 'Carnets'}
</button>
))}
</div>
</div>
</header>
<main className="flex-1 px-12 py-12 overflow-y-auto custom-scrollbar">
{filteredItems.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
<AnimatePresence mode="popLayout">
{filteredItems.map((item) => {
const daysLeft = getDaysRemaining(item.deletedAt);
return (
<motion.div
key={item.id}
layout
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9 }}
className="bg-white dark:bg-white/5 border border-border/60 rounded-[32px] p-8 group hover:shadow-2xl hover:border-blueprint/20 transition-all relative overflow-hidden flex flex-col"
>
{/* Countdown Progress Bar */}
<div className="absolute top-0 left-0 w-full h-1 bg-slate-100 overflow-hidden">
<motion.div
initial={{ width: 0 }}
animate={{ width: `${(daysLeft / 30) * 100}%` }}
className={`h-full ${daysLeft < 5 ? 'bg-rose-500' : 'bg-blueprint'}`}
/>
</div>
<div className="flex justify-between items-start mb-6">
<div className={`p-3 rounded-2xl ${item.itemType === 'note' ? 'bg-blueprint/10 text-blueprint' : 'bg-concrete/10 text-concrete'}`}>
{item.itemType === 'note' ? <FileText size={20} /> : <Folder size={20} />}
</div>
<div className="flex items-center gap-2">
<button
onClick={() => item.itemType === 'note' ? onRestoreNote(item.id) : onRestoreCarnet(item.id)}
className="flex items-center gap-2 px-4 py-2 bg-emerald-50 text-emerald-600 rounded-xl text-[10px] font-bold uppercase tracking-widest hover:bg-emerald-100 transition-colors"
>
<RotateCcw size={12} /> Restaurer
</button>
<button
onClick={() => item.itemType === 'note' ? onPermanentDeleteNote(item.id) : onPermanentDeleteCarnet(item.id)}
className="p-2 hover:bg-rose-50 text-rose-500 rounded-xl transition-colors"
title="Supprimer définitivement"
>
<X size={16} />
</button>
</div>
</div>
<div className="space-y-2 mb-8 flex-1">
<h3 className="text-base font-serif font-medium text-ink leading-tight">
{'title' in item ? item.title : item.name}
</h3>
<div className="flex items-center gap-3">
<div className={`text-[9px] font-bold uppercase tracking-widest px-2 py-0.5 rounded border ${daysLeft < 5 ? 'border-rose-200 text-rose-500 bg-rose-50' : 'border-blueprint/20 text-blueprint bg-blueprint/5'}`}>
{daysLeft} JOURS RESTANTS
</div>
<span className="text-[10px] text-concrete font-medium uppercase tracking-tight flex items-center gap-1">
<Clock size={10} /> {('deletedAt' in item && item.deletedAt) ? new Date(item.deletedAt).toLocaleDateString() : ''}
</span>
</div>
</div>
{item.itemType === 'note' && 'content' in item ? (
<div className="text-[12px] text-concrete line-clamp-3 leading-relaxed opacity-60 font-light border-t border-border/40 pt-4">
{item.content.replace(/[#*`]/g, '')}
</div>
) : (
<div className="border-t border-border/40 pt-4">
<div className="text-[9px] font-bold text-concrete/40 uppercase tracking-widest">
Contenu du dossier préservé
</div>
</div>
)}
</motion.div>
);
})}
</AnimatePresence>
</div>
) : (
<div className="h-full flex flex-col items-center justify-center text-center space-y-6 opacity-40">
<div className="p-8 rounded-full bg-slate-100 border-2 border-dashed border-border flex items-center justify-center">
<Trash2 size={64} className="text-concrete" />
</div>
<div className="space-y-2">
<h2 className="text-2xl font-serif text-ink italic">Corbeille vide</h2>
<p className="text-sm text-concrete max-w-xs">
Les éléments que vous supprimez apparaîtront ici. Ils seront conservés pendant 30 jours avant suppression définitive.
</p>
</div>
</div>
)}
</main>
<footer className="px-12 py-6 bg-white/50 border-t border-border flex items-center gap-4">
<AlertCircle size={14} className="text-concrete" />
<p className="text-[10px] text-concrete font-medium uppercase tracking-widest">
Conseil : La restauration d'un carnet restaurera également toutes les notes à l'intérieur.
</p>
</footer>
</div>
);
};

View File

@@ -0,0 +1,152 @@
import React from 'react';
import { Sparkles, Edit3, MessageCircle, Languages, Tag, History, FlaskConical } from 'lucide-react';
import { motion } from 'motion/react';
const AISettingCard = ({ icon, title, description, defaultChecked = false }: any) => (
<div className="bg-white/40 dark:bg-white/5 border border-border rounded-2xl p-6 flex items-center justify-between group hover:shadow-xl hover:shadow-blueprint/5 transition-all duration-300">
<div className="flex items-center gap-5">
<div className="p-3 bg-paper dark:bg-blueprint/10 rounded-2xl text-blueprint group-hover:bg-blueprint group-hover:text-white group-hover:scale-110 transition-all duration-300 border border-blueprint/20">
{icon}
</div>
<div className="space-y-1">
<h4 className="text-[13px] font-bold text-ink">{title}</h4>
<p className="text-[10px] text-muted-ink leading-relaxed pr-4 line-clamp-2">{description}</p>
</div>
</div>
<label className="relative inline-flex items-center cursor-pointer shrink-0 ml-4">
<input type="checkbox" className="sr-only peer" defaultChecked={defaultChecked} />
<div className="w-11 h-6 bg-gray-200 dark:bg-white/10 rounded-full peer peer-checked:after:translate-x-[20px] peer-checked:after:border-white after:content-[''] after:absolute after:top-[4px] after:left-[4px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all duration-300 ease-in-out peer-checked:bg-blueprint"></div>
</label>
</div>
);
export const AITab: React.FC = () => {
return (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="space-y-16 pb-20"
>
<div className="space-y-10">
<h3 className="text-[10px] font-bold uppercase tracking-[0.4em] text-muted-ink opacity-60">Configurez vos fonctionnalités IA et préférences</h3>
<div className="space-y-6">
<h4 className="text-sm font-bold text-ink border-b border-border/40 pb-4">Fonctionnalités IA</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
<AISettingCard
icon={<Edit3 size={18} />}
title="Suggestions de titre"
description="Suggérer des titres pour les notes sans titre après 50+ mots"
defaultChecked
/>
<AISettingCard
icon={<Sparkles size={18} />}
title="IA Note"
description="Active le bouton de chat IA et les outils d'amélioration du texte"
defaultChecked
/>
<AISettingCard
icon={<MessageCircle size={18} />}
title="💡 J'ai remarqué quelque chose..."
description="Aperçu quotidien de vos notes"
defaultChecked
/>
<AISettingCard
icon={<Languages size={18} />}
title="Détection de langue"
description="Détecte automatiquement la langue de vos notes"
defaultChecked
/>
<AISettingCard
icon={<Tag size={18} />}
title="Suggestion des labels"
description="Suggère et applique des étiquettes automatiquement à vos notes"
defaultChecked
/>
<AISettingCard
icon={<History size={18} />}
title="Historique des notes"
description="Active les snapshots de versions et la restauration depuis History"
defaultChecked
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 pt-6">
{/* Fréquence */}
<div className="bg-white/40 dark:bg-white/5 border border-border rounded-2xl p-8 space-y-10">
<div className="space-y-1.5 text-left text-blueprint">
<h4 className="text-sm font-bold">Fréquence</h4>
<p className="text-[10px] opacity-60 uppercase tracking-wider font-semibold">Fréquence d'analyse des connexions</p>
</div>
<div className="space-y-6">
<label className="flex items-center gap-4 cursor-pointer group">
<input type="radio" name="freq" className="sr-only peer" defaultChecked />
<div className="w-5 h-5 rounded-full border-2 border-border peer-checked:border-blueprint flex items-center justify-center p-0.5 transition-all">
<div className="w-full h-full rounded-full bg-transparent peer-checked:bg-blueprint" />
</div>
<span className="text-sm font-medium text-ink group-hover:opacity-70 transition-opacity">Quotidienne</span>
</label>
<label className="flex items-center gap-4 cursor-pointer group">
<input type="radio" name="freq" className="sr-only peer" />
<div className="w-5 h-5 rounded-full border-2 border-border peer-checked:border-blueprint flex items-center justify-center p-0.5 transition-all">
<div className="w-full h-full rounded-full bg-transparent peer-checked:bg-blueprint" />
</div>
<span className="text-sm font-medium text-ink group-hover:opacity-70 transition-opacity">Hebdomadaire</span>
</label>
</div>
</div>
{/* Mode d'historique */}
<div className="bg-white/40 dark:bg-white/5 border border-border rounded-2xl p-8 space-y-10">
<div className="space-y-1.5 text-left text-blueprint">
<h4 className="text-sm font-bold">Mode d'historique</h4>
<p className="text-[10px] opacity-60 uppercase tracking-wider font-semibold">Gestion des snapshots</p>
</div>
<div className="space-y-6">
<label className="flex items-center gap-4 cursor-pointer group">
<input type="radio" name="hist" className="sr-only peer" defaultChecked />
<div className="w-5 h-5 rounded-full border-2 border-border peer-checked:border-blueprint flex items-center justify-center p-0.5 transition-all">
<div className="w-full h-full rounded-full bg-transparent peer-checked:bg-blueprint" />
</div>
<div className="space-y-0.5">
<p className="text-sm font-bold text-ink">Manuel (bouton commit)</p>
<p className="text-[10px] text-muted-ink">Créer des snapshots manuellement</p>
</div>
</label>
<label className="flex items-center gap-4 cursor-pointer group">
<input type="radio" name="hist" className="sr-only peer" />
<div className="w-5 h-5 rounded-full border-2 border-border peer-checked:border-blueprint flex items-center justify-center p-0.5 transition-all">
<div className="w-full h-full rounded-full bg-transparent peer-checked:bg-blueprint" />
</div>
<div className="space-y-0.5">
<p className="text-sm font-bold text-ink">Automatique (intelligent)</p>
<p className="text-[10px] text-muted-ink">Snapshots automatiques avec détection</p>
</div>
</label>
</div>
</div>
</div>
{/* Mode Démo */}
<div className="bg-ochre/5 dark:bg-ochre/10 border border-ochre/20 rounded-2xl p-8 flex items-center justify-between group transition-all duration-300 hover:bg-ochre/10">
<div className="flex items-center gap-6">
<div className="p-3 bg-paper dark:bg-ochre/20 rounded-2xl text-ochre border border-ochre/30">
<FlaskConical size={20} />
</div>
<div className="space-y-1.5 text-left">
<h4 className="text-sm font-bold text-ink flex items-center gap-3">
🧪 Mode Démo
</h4>
<p className="text-[11px] text-muted-ink leading-relaxed font-medium">Accélère Memory Echo pour les tests. Les connexions apparaissent instantanément.</p>
</div>
</div>
<label className="relative inline-flex items-center cursor-pointer shrink-0 ml-4">
<input type="checkbox" className="sr-only peer" />
<div className="w-11 h-6 bg-gray-200 dark:bg-white/10 rounded-full peer peer-checked:after:translate-x-[20px] peer-checked:after:border-white after:content-[''] after:absolute after:top-[4px] after:left-[4px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all duration-300 ease-in-out peer-checked:bg-ochre"></div>
</label>
</div>
</div>
</motion.div>
);
};

View File

@@ -0,0 +1,85 @@
import React from 'react';
import { Palette, Type, LayoutGrid, Maximize } from 'lucide-react';
import { motion } from 'motion/react';
const AppearanceSelect = ({ icon, title, description, options, defaultValue }: any) => (
<div className="bg-white/40 dark:bg-white/5 border border-border rounded-2xl p-8 space-y-8 group transition-all duration-300 hover:shadow-xl hover:shadow-slate/5">
<div className="flex items-center gap-5">
<div className="p-3 bg-paper dark:bg-white/10 rounded-2xl text-slate border border-border group-hover:scale-110 transition-transform duration-300">
{icon}
</div>
<div className="space-y-0.5 text-left">
<h4 className="text-base font-bold text-ink">{title}</h4>
<p className="text-[11px] text-concrete leading-tight">{description}</p>
</div>
</div>
<div className="relative group/select">
<select
defaultValue={defaultValue}
className="w-full bg-white/50 dark:bg-black/40 border border-border rounded-xl px-5 py-4 text-sm outline-none focus:ring-1 ring-slate/20 appearance-none cursor-pointer text-ink font-bold transition-all hover:bg-white dark:hover:bg-black/60"
>
{options.map((opt: string) => (
<option key={opt}>{opt}</option>
))}
</select>
<div className="absolute right-5 top-1/2 -translate-y-1/2 pointer-events-none text-concrete group-hover/select:text-slate transition-colors">
<svg width="10" height="6" viewBox="0 0 10 6" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 1L5 5L9 1" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</div>
</div>
</div>
);
export const AppearanceTab: React.FC = () => {
return (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="space-y-16 pb-20"
>
<div className="space-y-10">
<h3 className="text-[10px] font-bold uppercase tracking-[0.4em] text-concrete opacity-60">Personnaliser l'apparence de l'application</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<AppearanceSelect
icon={<Palette size={20} />}
title="Thème"
description="Sélectionner le mode visuel"
options={['Clair', 'Sombre', 'Système']}
defaultValue="Clair"
/>
<AppearanceSelect
icon={<Type size={20} />}
title="Taille de la police"
description="Ajustez la lisibilité globale de l'interface"
options={['Petite', 'Moyenne', 'Grande']}
defaultValue="Moyenne"
/>
<AppearanceSelect
icon={<Type size={20} />}
title="Famille de polices"
description="La typographie définit l'âme de l'application"
options={['Inter', 'JetBrains Mono', 'Public Sans', 'Outfit']}
defaultValue="JetBrains Mono"
/>
<AppearanceSelect
icon={<LayoutGrid size={20} />}
title="Affichage des notes"
description="Gestion visuelle de la grille de composition"
options={['Cartes (grille)', 'Liste', 'Tableau']}
defaultValue="Cartes (grille)"
/>
<AppearanceSelect
icon={<Maximize size={20} />}
title="Taille des notes"
description="Structure de la mise en page des éléments"
options={['Taille uniforme', 'Variable (Masonry)']}
defaultValue="Taille uniforme"
/>
</div>
</div>
</motion.div>
);
};

View File

@@ -0,0 +1,82 @@
import React from 'react';
import { Globe, Bell } from 'lucide-react';
import { motion } from 'motion/react';
export const GeneralTab: React.FC = () => {
return (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="space-y-12"
>
<div className="space-y-4">
<h3 className="text-[10px] font-bold uppercase tracking-[0.3em] text-concrete">Paramètres généraux de l'application</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Langue */}
<div className="bg-white/40 dark:bg-white/5 border border-border rounded-xl p-8 space-y-6">
<div className="flex items-center gap-5">
<div className="p-3 bg-paper dark:bg-white/10 rounded-2xl text-slate border border-border">
<Globe size={18} />
</div>
<div className="space-y-0.5">
<h4 className="text-base font-bold text-ink">Langue</h4>
<p className="text-[11px] text-concrete">Sélectionner une langue</p>
</div>
</div>
<div className="relative group">
<select className="w-full bg-white/50 dark:bg-black/40 border border-border rounded-xl px-5 py-3.5 text-sm outline-none focus:ring-1 ring-blueprint/20 appearance-none cursor-pointer transition-all hover:bg-white dark:hover:bg-black/60 text-ink font-medium">
<option>Français</option>
<option>English</option>
<option>Español</option>
</select>
<div className="absolute right-5 top-1/2 -translate-y-1/2 pointer-events-none opacity-40 text-concrete">
<svg width="10" height="6" viewBox="0 0 10 6" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 1L5 5L9 1" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</div>
</div>
</div>
{/* Notifications */}
<div className="bg-white/40 dark:bg-white/5 border border-border rounded-xl p-8 space-y-6">
<div className="flex items-center gap-5">
<div className="p-3 bg-paper dark:bg-white/10 rounded-2xl text-slate border border-border">
<Bell size={18} />
</div>
<div className="space-y-0.5">
<h4 className="text-base font-bold text-ink">Notifications</h4>
<p className="text-[11px] text-concrete">Gérez vos préférences de notifications</p>
</div>
</div>
<div className="space-y-6 divide-y divide-border/40 text-left">
<div className="flex items-center justify-between pt-0">
<div className="space-y-1">
<p className="text-xs font-bold text-ink">Notifications par email</p>
<p className="text-[10px] text-concrete leading-relaxed">Recevoir des notifications importantes par email</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input type="checkbox" className="sr-only peer" />
<div className="w-11 h-6 bg-gray-200 dark:bg-white/10 rounded-full peer peer-checked:after:translate-x-[20px] peer-checked:after:border-white after:content-[''] after:absolute after:top-[4px] after:left-[4px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all duration-300 ease-in-out peer-checked:bg-slate"></div>
</label>
</div>
<div className="flex items-center justify-between pt-6">
<div className="space-y-1">
<p className="text-xs font-bold text-ink">Notifications bureau</p>
<p className="text-[10px] text-concrete leading-relaxed">Recevoir des notifications dans votre navigateur</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input type="checkbox" className="sr-only peer" defaultChecked />
<div className="w-11 h-6 bg-gray-200 dark:bg-white/10 rounded-full peer peer-checked:after:translate-x-[20px] peer-checked:after:border-white after:content-[''] after:absolute after:top-[4px] after:left-[4px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all duration-300 ease-in-out peer-checked:bg-slate"></div>
</label>
</div>
</div>
</div>
</div>
</div>
</motion.div>
);
};

View File

@@ -0,0 +1,51 @@
import React from 'react';
import { Settings, Sparkles, Palette, User, Database, Code, Info } from 'lucide-react';
import { motion } from 'motion/react';
import { SettingsTab } from '../../types';
interface SettingsHeaderProps {
activeTab: SettingsTab;
setActiveTab: (tab: SettingsTab) => void;
}
export const SettingsHeader: React.FC<SettingsHeaderProps> = ({ activeTab, setActiveTab }) => {
const tabs = [
{ id: 'general', label: 'Paramètres généraux', icon: <Settings size={14} /> },
{ id: 'ai', label: 'Paramètres IA', icon: <Sparkles size={14} /> },
{ id: 'appearance', label: 'Apparence', icon: <Palette size={14} /> },
{ id: 'profile', label: 'Profil', icon: <User size={14} /> },
{ id: 'data', label: 'Gestion des données', icon: <Database size={14} /> },
{ id: 'mcp', label: 'Paramètres MCP', icon: <Code size={14} /> },
{ id: 'about', label: 'À propos', icon: <Info size={14} /> },
];
return (
<header className="px-12 pt-20 pb-16 space-y-12">
<div className="space-y-4">
<h1 className="text-[64px] font-serif text-ink tracking-tight leading-none italic font-medium">Paramètres</h1>
<p className="text-[10px] font-bold uppercase tracking-[0.4em] text-concrete opacity-60">Configuration & Préférences</p>
</div>
<nav className="flex items-center gap-1 border-b border-border/40 pb-px">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as SettingsTab)}
className={`flex items-center gap-2.5 px-6 py-5 text-[10px] font-bold uppercase tracking-[0.18em] transition-all relative whitespace-nowrap
${activeTab === tab.id ? 'text-ink' : 'text-concrete hover:text-ink/60'}`}
>
<span className={activeTab === tab.id ? 'text-ink' : 'text-concrete'}>{tab.icon}</span>
{tab.label}
{activeTab === tab.id && (
<motion.div
layoutId="activeSettingsTabLine"
className="absolute bottom-0 left-0 right-0 h-0.5 bg-ink"
transition={{ type: 'spring', bounce: 0.1, duration: 0.8 }}
/>
)}
</button>
))}
</nav>
</header>
);
};

View File

@@ -0,0 +1,62 @@
import { Carnet, Note } from './types';
export const CARNETS: Carnet[] = [
{ id: '1', name: 'Daily Notes', initial: 'D', type: 'Private', isPrivate: true },
{ id: '2', name: 'Project: Neo', initial: 'P', type: 'Project' },
{ id: '3', name: 'Shared Docs', initial: 'S', type: 'Shared' },
{ id: '4', name: 'Architecture Research', initial: 'A', type: 'Project' },
{ id: '5', name: 'History of Architecture', initial: 'H', type: 'Project', parentId: '4' },
{ id: '6', name: 'Modernism', initial: 'M', type: 'Project', parentId: '5' },
{ id: '7', name: 'Sustainable Design', initial: 'S', type: 'Project', parentId: '4' },
];
export const ALL_NOTES: Note[] = [
{
id: 'n1',
carnetId: '4',
title: 'Grid Systems',
date: 'Oct 26, 2024',
content: 'Grid Systems is streathen in ognitiacs clesign and simulhere desipmalt: complded structurer and manamateriai-s: ci arevenuatingly used, asiller straterty of insaee to the tmn and usaes of disrension, architecture of emiornabious tracious structures.',
imageUrl: 'https://images.unsplash.com/photo-1503387762-592dea58ef23?auto=format&fit=crop&q=80&w=800&h=600',
tags: [
{ id: 't1', label: 'Architecture', type: 'user' },
{ id: 't2', label: 'Systems', type: 'ai' }
]
},
{
id: 'n2',
carnetId: '4',
title: 'Materiality',
date: 'Oct 24, 2024',
content: 'Materiality is combinated by relliaitic structureirs measure of plastics, natural, materials and priotical structures. Materialed coasts erabiocera alann light spaces and octicm employed design on thodolen of materiality, and tohlite tersev/ used in the gridin structures en obain materials, coms pathetic structure.',
imageUrl: 'https://images.unsplash.com/photo-1486406146926-c627a92ad1ab?auto=format&fit=crop&q=80&w=800&h=600',
tags: [
{ id: 't3', label: 'Materials', type: 'user' },
{ id: 't4', label: 'Sustainabilty', type: 'ai' }
]
},
{
id: 'n3',
carnetId: '4',
title: 'Light & Space',
date: 'Oct 22, 2024',
content: 'Light & Space is a creaivity of light & Space inralicated in sizazant or dark crotrcning and netrescenations of avant trurme sivonpaltures for in inncr-en allimativefiting is cerriadating and sityle.',
imageUrl: 'https://images.unsplash.com/photo-1497366216548-37526070297c?auto=format&fit=crop&q=80&w=800&h=600',
tags: [
{ id: 't5', label: 'Lighting', type: 'user' },
{ id: 't6', label: 'Atmosphere', type: 'ai' }
]
},
{
id: 'n4',
carnetId: '2',
title: 'Neo-Brutalism study',
date: 'Sep 12, 2024',
content: 'Exploring the raw aesthetic of neo-brutalism in urban environments. Focus on concrete textures and massive forms.',
imageUrl: 'https://images.unsplash.com/photo-1518005020951-eccb494ad742?auto=format&fit=crop&q=80&w=800&h=600',
tags: [
{ id: 't7', label: 'Brutalism', type: 'user' },
{ id: 't8', label: 'Urban', type: 'ai' }
]
}
];

View File

@@ -0,0 +1,98 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Playfair+Display:ital,wght@0,400;0,700;1,400&family=JetBrains+Mono:wght@400;500&display=swap');
@import "tailwindcss";
@theme {
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
--font-serif: "Playfair Display", serif;
--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, monospace;
/* Foundation */
--color-paper: #F2F0E9;
--color-ink: #1C1C1C;
--color-muted-ink: rgba(28, 28, 28, 0.6);
--color-border: rgba(28, 28, 28, 0.1);
--color-concrete: #8D8D8D;
/* Architectural Accents */
--color-blueprint: #75B2D6;
--color-slate: #4A4E69;
--color-ochre: #D4A373;
--color-sage: #A3B18A;
--color-rust: #9B2226;
--color-glass: rgba(255, 255, 255, 0.4);
/* Dark Theme Aliases */
--color-dark-paper: #0D0D0D;
--color-dark-ink: #EAEAEA;
--color-dark-muted: rgba(234, 234, 234, 0.5);
--color-dark-border: rgba(234, 234, 234, 0.1);
}
@layer base {
body {
@apply bg-paper text-ink font-sans antialiased transition-colors duration-500;
}
.dark body {
@apply bg-dark-paper;
}
.dark {
--color-paper: #121212;
--color-ink: #EAEAEA;
--color-muted-ink: rgba(234, 234, 234, 0.6);
--color-border: rgba(255, 255, 255, 0.08);
--color-glass: rgba(0, 0, 0, 0.4);
--color-concrete: #555555;
}
}
.paper-texture {
background-color: var(--color-paper);
background-image: url("https://www.transparenttextures.com/patterns/natural-paper.png");
}
/* Custom Scrollbar - Architectural Minimalist */
.custom-scrollbar::-webkit-scrollbar {
width: 3px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: rgba(28, 28, 28, 0.08);
border-radius: 10px;
}
.custom-scrollbar:hover::-webkit-scrollbar-thumb {
background: rgba(28, 28, 28, 0.2);
}
.ai-glass {
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.5);
}
.dark .ai-glass {
background: rgba(30, 30, 30, 0.85);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.sidebar-shadow {
box-shadow: 1px 0 10px rgba(0, 0, 0, 0.05);
}
.active-nav-item {
background: linear-gradient(to right, rgba(255, 255, 255, 0.8), rgba(255, 255, 255, 0.4));
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
border: 1px solid rgba(255, 255, 255, 0.5);
}
.dark .active-nav-item {
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}

View File

@@ -0,0 +1,10 @@
import {StrictMode} from 'react';
import {createRoot} from 'react-dom/client';
import App from './App.tsx';
import './index.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);

View File

@@ -0,0 +1,34 @@
export type NavigationView = 'notebooks' | 'agents' | 'settings' | 'shared' | 'reminders' | 'trash';
export type AITone = 'Professional' | 'Creative' | 'Academic' | 'Casual';
export type AITab = 'discussion' | 'actions' | 'resources';
export type SettingsTab = 'general' | 'ai' | 'appearance' | 'profile' | 'data' | 'mcp' | 'about';
export interface Tag {
id: string;
label: string;
type: 'ai' | 'user';
}
export interface Note {
id: string;
carnetId: string;
title: string;
content: string;
imageUrl: string;
date: string;
tags: Tag[];
isPinned?: boolean;
isDeleted?: boolean;
deletedAt?: string;
}
export interface Carnet {
id: string;
name: string;
initial: string;
type: 'Private' | 'Project' | 'Shared';
isPrivate?: boolean;
parentId?: string;
isDeleted?: boolean;
deletedAt?: string;
}

View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "ES2022",
"experimentalDecorators": true,
"useDefineForClassFields": false,
"module": "ESNext",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"skipLibCheck": true,
"moduleResolution": "bundler",
"isolatedModules": true,
"moduleDetection": "force",
"allowJs": true,
"jsx": "react-jsx",
"paths": {
"@/*": [
"./*"
]
},
"allowImportingTsExtensions": true,
"noEmit": true
}
}

View File

@@ -0,0 +1,24 @@
import tailwindcss from '@tailwindcss/vite';
import react from '@vitejs/plugin-react';
import path from 'path';
import {defineConfig, loadEnv} from 'vite';
export default defineConfig(({mode}) => {
const env = loadEnv(mode, '.', '');
return {
plugins: [react(), tailwindcss()],
define: {
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY),
},
resolve: {
alias: {
'@': path.resolve(__dirname, '.'),
},
},
server: {
// HMR is disabled in AI Studio via DISABLE_HMR env var.
// Do not modify—file watching is disabled to prevent flickering during agent edits.
hmr: process.env.DISABLE_HMR !== 'true',
},
};
});

View File

@@ -99,7 +99,7 @@ services:
cpus: '0.25'
memory: 128M
healthcheck:
test: ["CMD-SHELL", "wget -q -O /dev/null http://localhost:3001/health || exit 1"]
test: ["CMD-SHELL", "wget --header \"x-api-key: 1b11f42537c1442456ea413feee75bac\" -q -O /dev/null http://localhost:3001/ || exit 1"]
interval: 30s
timeout: 10s
retries: 3

View File

@@ -538,21 +538,25 @@ export function registerTools(server, prisma) {
}
case 'search_notes': {
const where = noteWhere(uid, {
isArchived: args.includeArchived || false,
OR: [
{ title: { contains: args.query } },
{ content: { contains: args.query } },
],
...(args.notebookId ? { notebookId: args.notebookId } : {}),
});
const safeQuery = (args.query || '').replace(/'/g, "''");
const userClause = uid ? `AND "userId" = '${uid}'` : '';
const notebookClause = args.notebookId ? `AND "notebookId" = '${args.notebookId.replace(/'/g, "''")}'` : '';
const notes = await prisma.note.findMany({
where,
orderBy: [{ isPinned: 'desc' }, { updatedAt: 'desc' }],
take: DEFAULT_SEARCH_LIMIT,
});
return textResult(notes.map(parseNoteLightweight));
const ftsRows = await prisma.$queryRawUnsafe(`
SELECT id, title, content, color, type, "isPinned", "isArchived",
"isMarkdown", size, "createdAt", "updatedAt", "notebookId",
images, labels, "checkItems", reminder, "isReminderDone"
FROM "Note"
WHERE "tsv" @@ plainto_tsquery('simple', '${safeQuery}')
AND "trashedAt" IS NULL
AND "isArchived" = ${args.includeArchived ? 'true' : 'false'}
${userClause}
${notebookClause}
ORDER BY ts_rank("tsv", plainto_tsquery('simple', '${safeQuery}')) DESC
LIMIT ${DEFAULT_SEARCH_LIMIT}
`);
return textResult(ftsRows.map(parseNoteLightweight));
}
case 'move_note': {

View File

@@ -5,9 +5,10 @@ import prisma from '@/lib/prisma'
import { Note, CheckItem, NoteType } from '@/lib/types'
import { auth } from '@/auth'
import { getAIProvider } from '@/lib/ai/factory'
import { parseNote as parseNoteUtil, cosineSimilarity, calculateRRFK, detectQueryType, getSearchWeights } from '@/lib/utils'
import { parseNote as parseNoteUtil } from '@/lib/utils'
import { getSystemConfig, getConfigNumber, getConfigBoolean, SEARCH_DEFAULTS } from '@/lib/config'
import { contextualAutoTagService } from '@/lib/ai/services/contextual-auto-tag.service'
import { semanticSearchService } from '@/lib/ai/services/semantic-search.service'
import { cleanupNoteImages, parseImageUrls, deleteImageFileSafely } from '@/lib/image-cleanup'
import { getAISettings } from '@/app/actions/ai-settings'
import {
@@ -486,122 +487,54 @@ export async function enableNoteHistory(noteId: string) {
})
}
// Search notes - DB-side filtering (fast) with optional semantic search
// Supports contextual search within notebook (IA5)
export async function searchNotes(query: string, useSemantic: boolean = false, notebookId?: string) {
// Unified hybrid search — always uses FTS + pgvector with RRF fusion.
// Supports contextual search within notebook (IA5).
export async function searchNotes(query: string, _useSemantic: boolean = true, notebookId?: string) {
const session = await auth();
if (!session?.user?.id) return [];
try {
// If query empty, return all notes
if (!query || !query.trim()) {
return await getAllNotes();
}
// If semantic search is requested, use the full implementation
if (useSemantic) {
return await semanticSearch(query, session.user.id, notebookId);
}
const results = await semanticSearchService.searchAsUser(session.user.id, query, {
limit: 50,
threshold: 0.25,
notebookId
});
// DB-side keyword search using LIKE — much faster than loading all notes in memory
const noteIds = results.map(r => r.noteId);
const notes = await prisma.note.findMany({
where: {
id: { in: noteIds },
userId: session.user.id,
isArchived: false,
trashedAt: null,
OR: [
{ title: { contains: query } },
{ content: { contains: query } },
{ labels: { contains: query } },
],
},
select: NOTE_LIST_SELECT,
orderBy: [
{ isPinned: 'desc' },
{ order: 'asc' },
{ updatedAt: 'desc' }
]
});
return notes.map(parseNote);
const orderMap = new Map(results.map((r, i) => [r.noteId, i]));
const parsed = notes.map(parseNote);
parsed.sort((a, b) => (orderMap.get(a.id) ?? 999) - (orderMap.get(b.id) ?? 999));
if (parsed.length > 0) {
const topResult = results[0];
if (topResult) {
parsed[0].matchType = topResult.matchType;
parsed[0].searchScore = topResult.score;
}
}
return parsed;
} catch (error) {
console.error('Search error:', error);
return [];
}
}
// Semantic search with AI embeddings - SIMPLE VERSION
// Supports contextual search within notebook (IA5)
async function semanticSearch(query: string, userId: string, notebookId?: string) {
const allNotes = await prisma.note.findMany({
where: {
userId: userId,
isArchived: false,
trashedAt: null,
...(notebookId !== undefined ? { notebookId } : {})
},
include: { noteEmbedding: true }
});
const queryLower = query.toLowerCase().trim();
// Get query embedding
let queryEmbedding: number[] | null = null;
try {
const provider = getAIProvider(await getSystemConfig());
queryEmbedding = await provider.getEmbeddings(query);
} catch (e) {
console.error('Failed to generate query embedding:', e);
// Fallback to simple keyword search
queryEmbedding = null;
}
// Filter notes: keyword match OR semantic match (threshold 30%)
const results = allNotes.map(note => {
const title = (note.title || '').toLowerCase();
const content = note.content.toLowerCase();
const labels = note.labels ? JSON.parse(note.labels) : [];
// Keyword match
const keywordMatch = title.includes(queryLower) ||
content.includes(queryLower) ||
labels.some((l: string) => l.toLowerCase().includes(queryLower));
// Semantic match (if embedding available)
let semanticMatch = false;
let similarity = 0;
if (queryEmbedding && note.noteEmbedding?.embedding) {
similarity = cosineSimilarity(queryEmbedding, JSON.parse(note.noteEmbedding.embedding));
semanticMatch = similarity > 0.3; // 30% threshold - works well for related concepts
}
return {
note,
keywordMatch,
semanticMatch,
similarity
};
}).filter(r => r.keywordMatch || r.semanticMatch);
// Parse and add match info
return results.map(r => {
const parsed = parseNote(r.note);
// Determine match type
let matchType: 'exact' | 'related' | null = null;
if (r.semanticMatch) {
matchType = 'related';
} else if (r.keywordMatch) {
matchType = 'exact';
}
return {
...parsed,
matchType
};
});
}
// Create a new note
export async function createNote(data: {
title?: string
@@ -683,16 +616,19 @@ export async function createNote(data: {
// Use setImmediate-like pattern to not block the response
; (async () => {
try {
// Background task 1: Generate embedding
const bgConfig = await getSystemConfig()
const provider = getAIProvider(bgConfig)
const embedding = await provider.getEmbeddings(content)
if (embedding) {
await prisma.noteEmbedding.upsert({
where: { noteId: noteId },
create: { noteId: noteId, embedding: JSON.stringify(embedding) },
update: { embedding: JSON.stringify(embedding) }
})
const vecStr = `[${embedding.join(',')}]`
await prisma.$executeRawUnsafe(
`INSERT INTO "NoteEmbedding" ("id", "noteId", "embedding", "createdAt", "updatedAt")
VALUES (gen_random_uuid(), $1, $2::vector, now(), now())
ON CONFLICT ("noteId")
DO UPDATE SET "embedding" = $2::vector, "updatedAt" = now()`,
noteId,
vecStr
)
}
} catch (e) {
console.error('[BG] Embedding generation failed:', e)
@@ -815,7 +751,6 @@ export async function updateNote(id: string, data: {
}
}
// Generate embedding in background — don't block the update
if (data.content !== undefined) {
const noteId = id
const content = data.content
@@ -824,11 +759,15 @@ export async function updateNote(id: string, data: {
const provider = getAIProvider(await getSystemConfig());
const embedding = await provider.getEmbeddings(content);
if (embedding) {
await prisma.noteEmbedding.upsert({
where: { noteId: noteId },
create: { noteId: noteId, embedding: JSON.stringify(embedding) },
update: { embedding: JSON.stringify(embedding) }
})
const vecStr = `[${embedding.join(',')}]`
await prisma.$executeRawUnsafe(
`INSERT INTO "NoteEmbedding" ("id", "noteId", "embedding", "createdAt", "updatedAt")
VALUES (gen_random_uuid(), $1, $2::vector, now(), now())
ON CONFLICT ("noteId")
DO UPDATE SET "embedding" = $2::vector, "updatedAt" = now()`,
noteId,
vecStr
)
}
} catch (e) {
console.error('[BG] Embedding regeneration failed:', e);
@@ -1409,11 +1348,15 @@ export async function syncAllEmbeddings() {
try {
const embedding = await provider.getEmbeddings(note.content);
if (embedding) {
await prisma.noteEmbedding.upsert({
where: { noteId: note.id },
create: { noteId: note.id, embedding: JSON.stringify(embedding) },
update: { embedding: JSON.stringify(embedding) }
})
const vecStr = `[${embedding.join(',')}]`
await prisma.$executeRawUnsafe(
`INSERT INTO "NoteEmbedding" ("id", "noteId", "embedding", "createdAt", "updatedAt")
VALUES (gen_random_uuid(), $1, $2::vector, now(), now())
ON CONFLICT ("noteId")
DO UPDATE SET "embedding" = $2::vector, "updatedAt" = now()`,
note.id,
vecStr
)
updatedCount++;
}
} catch (e) { }

View File

@@ -23,7 +23,7 @@ export async function semanticSearch(
try {
const results = await semanticSearchService.search(query, {
limit: options?.limit || 20,
threshold: options?.threshold || 0.6,
threshold: options?.threshold || 0.3,
notebookId: options?.notebookId // NEW: Pass notebook filter
})

View File

@@ -1,11 +1,10 @@
import { NextResponse } from 'next/server'
import prisma from '@/lib/prisma'
import { prisma } from '@/lib/prisma'
import { auth } from '@/auth'
import { validateEmbedding } from '@/lib/utils'
/**
* Admin endpoint to validate all embeddings in the database
* Returns a list of notes with invalid embeddings
* Admin endpoint to validate all pgvector embeddings in the database.
* Uses native SQL to check for valid vector format.
*/
export async function GET() {
try {
@@ -14,7 +13,6 @@ export async function GET() {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Check if user is admin
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: { role: true }
@@ -24,72 +22,34 @@ export async function GET() {
return NextResponse.json({ error: 'Forbidden - Admin only' }, { status: 403 })
}
// Fetch all notes with embeddings
const allNotes = await prisma.note.findMany({
select: {
id: true,
title: true,
noteEmbedding: true
}
})
const totalResult: Array<{ total: bigint }> = await prisma.$queryRawUnsafe(
`SELECT COUNT(*)::bigint as total FROM "Note" WHERE "trashedAt" IS NULL`
)
const total = Number(totalResult[0]?.total ?? 0)
const invalidNotes: Array<{
id: string
title: string
issues: string[]
}> = []
const withEmbedding: Array<{ count: bigint }> = await prisma.$queryRawUnsafe(
`SELECT COUNT(*)::bigint as count FROM "NoteEmbedding"`
)
const validCount = Number(withEmbedding[0]?.count ?? 0)
let validCount = 0
let missingCount = 0
let invalidCount = 0
const invalidResult: Array<{ count: bigint }> = await prisma.$queryRawUnsafe(
`SELECT COUNT(*)::bigint as count FROM "NoteEmbedding" e
WHERE e."embedding" IS NULL
OR array_length(string_to_array(replace(replace(e."embedding"::text, '[', ''), ']', ''), ','), 1) != 1536`
)
const invalidCount = Number(invalidResult[0]?.count ?? 0)
for (const note of allNotes) {
// Check if embedding is missing
if (!note.noteEmbedding?.embedding) {
missingCount++
invalidNotes.push({
id: note.id,
title: note.title || 'Untitled',
issues: ['Missing embedding']
})
continue
}
// Validate embedding
try {
if (!note.noteEmbedding?.embedding) continue
const embedding = JSON.parse(note.noteEmbedding.embedding) as number[]
const validation = validateEmbedding(embedding)
if (!validation.valid) {
invalidCount++
invalidNotes.push({
id: note.id,
title: note.title || 'Untitled',
issues: validation.issues
})
} else {
validCount++
}
} catch (error) {
invalidCount++
invalidNotes.push({
id: note.id,
title: note.title || 'Untitled',
issues: [`Failed to parse embedding: ${error}`]
})
}
}
const missingCount = total - validCount
return NextResponse.json({
success: true,
summary: {
total: allNotes.length,
valid: validCount,
missing: missingCount,
total,
valid: validCount - invalidCount,
missing: missingCount > 0 ? missingCount : 0,
invalid: invalidCount
},
invalidNotes
invalidNotes: []
})
} catch (error) {
console.error('[EMBEDDING_VALIDATION] Error:', error)

View File

@@ -27,14 +27,18 @@ export async function POST(req: NextRequest) {
}
})
// 2. Clean up NoteEmbeddings that don't have a corresponding Note (shouldn't happen with Cascade, but good for cleanup)
const orphanedEmbeddings = await prisma.noteEmbedding.findMany({
where: {
note: { userId: { not: userId } } // Or just those where note is null if not using cascade
}
})
// Actually, let's just focus on user-specific cleanup
// 2. Clean up NoteEmbeddings that don't have a corresponding Note
const orphanedEmbeddings: Array<{ id: string }> = await prisma.$queryRawUnsafe(
`SELECT e.id FROM "NoteEmbedding" e
LEFT JOIN "Note" n ON n.id = e."noteId"
WHERE n.id IS NULL`
)
if (orphanedEmbeddings.length > 0) {
await prisma.$executeRawUnsafe(
`DELETE FROM "NoteEmbedding" WHERE id = ANY(${`ARRAY['${orphanedEmbeddings.map(e => e.id).join("','")}']`}::text[])`
)
}
// 3. Remove note history entries for notes that were deleted (cascade should handle this, but let's be safe)

View File

@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { prisma } from '@/lib/prisma'
import { EmbeddingService } from '@/lib/ai/services/embedding.service'
import { semanticSearchService } from '@/lib/ai/services/semantic-search.service'
export async function POST(req: NextRequest) {
try {
@@ -12,41 +12,31 @@ export async function POST(req: NextRequest) {
const userId = session.user.id
// Fetch all notes for the user
const notes = await prisma.note.findMany({
where: { userId, trashedAt: null },
select: { id: true, title: true, content: true }
select: { id: true }
})
const embeddingService = new EmbeddingService()
let processedCount = 0
let failedCount = 0
const BATCH_SIZE = 20
// Process in small batches to avoid timeouts if possible
// Note: In a real production app, this should be a background job
for (const note of notes) {
try {
const textToEmbed = `${note.title || ''}\n${note.content}`
if (textToEmbed.trim()) {
const embedding = await embeddingService.generateEmbedding(textToEmbed)
await prisma.noteEmbedding.upsert({
where: { noteId: note.id },
update: { embedding: JSON.stringify(embedding) },
create: {
noteId: note.id,
embedding: JSON.stringify(embedding)
}
})
processedCount++
}
} catch (err) {
console.error(`Failed to reindex note ${note.id}:`, err)
for (let i = 0; i < notes.length; i += BATCH_SIZE) {
const batch = notes.slice(i, i + BATCH_SIZE)
const results = await Promise.allSettled(
batch.map(note => semanticSearchService.indexNote(note.id))
)
for (const r of results) {
if (r.status === 'fulfilled') processedCount++
else failedCount++
}
}
return NextResponse.json({
success: true,
count: processedCount,
failed: failedCount,
total: notes.length
})
} catch (error) {

View File

@@ -259,7 +259,6 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
const labelFilter = searchParams.get('labels')?.split(',').filter(Boolean) || []
const colorFilter = searchParams.get('color')
const notebook = searchParams.get('notebook')
const semanticMode = searchParams.get('semantic') === 'true'
const isBackgroundRefresh = refreshKey > prevRefreshKey.current
prevRefreshKey.current = refreshKey
@@ -271,7 +270,7 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
setIsLoading(true)
}
let allNotes = search
? await searchNotes(search, semanticMode, notebook || undefined)
? await searchNotes(search, true, notebook || undefined)
: await getAllNotes(false, notebook || undefined)
const sharedOnly = searchParams.get('shared') === '1'

View File

@@ -1,7 +1,7 @@
/**
* Embedding Service
* Generates vector embeddings for semantic search and similarity analysis
* Uses text-embedding-3-small model via OpenAI (or Ollama alternatives)
* Generates vector embeddings for semantic search and similarity analysis.
* Stores embeddings as native pgvector(1536) in PostgreSQL.
*/
import { getAIProvider } from '../factory'
@@ -13,16 +13,9 @@ export interface EmbeddingResult {
dimension: number
}
/**
* Service for generating and managing text embeddings
*/
export class EmbeddingService {
private readonly EMBEDDING_MODEL = 'text-embedding-3-small'
private readonly EMBEDDING_DIMENSION = 1536 // OpenAI's embedding dimension
private readonly EMBEDDING_DIMENSION = 1536
/**
* Generate embedding for a single text
*/
async generateEmbedding(text: string): Promise<EmbeddingResult> {
if (!text || text.trim().length === 0) {
throw new Error('Cannot generate embedding for empty text')
@@ -31,17 +24,11 @@ export class EmbeddingService {
try {
const config = await getSystemConfig()
const provider = getAIProvider(config)
// Use the existing getEmbeddings method from AIProvider
const embedding = await provider.getEmbeddings(text)
// Validate embedding dimension
if (embedding.length !== this.EMBEDDING_DIMENSION) {
}
return {
embedding,
model: this.EMBEDDING_MODEL,
model: 'text-embedding-3-small',
dimension: embedding.length
}
} catch (error) {
@@ -50,34 +37,22 @@ export class EmbeddingService {
}
}
/**
* Generate embeddings for multiple texts in batch
* More efficient than calling generateEmbedding multiple times
*/
async generateBatchEmbeddings(texts: string[]): Promise<EmbeddingResult[]> {
if (!texts || texts.length === 0) {
return []
}
if (!texts || texts.length === 0) return []
// Filter out empty texts
const validTexts = texts.filter(t => t && t.trim().length > 0)
if (validTexts.length === 0) {
return []
}
if (validTexts.length === 0) return []
try {
const config = await getSystemConfig()
const provider = getAIProvider(config)
// Batch embedding using the existing getEmbeddings method
const embeddings = await Promise.all(
validTexts.map(text => provider.getEmbeddings(text))
)
return embeddings.map(embedding => ({
embedding,
model: this.EMBEDDING_MODEL,
model: 'text-embedding-3-small',
dimension: embedding.length
}))
} catch (error) {
@@ -87,132 +62,54 @@ export class EmbeddingService {
}
/**
* Calculate cosine similarity between two embeddings
* Returns value between -1 and 1, where 1 is identical
* Format a number[] embedding as a pgvector-compatible string literal.
* e.g. [0.1, 0.2, 0.3] → '[0.1,0.2,0.3]'
*/
calculateCosineSimilarity(embedding1: number[], embedding2: number[]): number {
if (embedding1.length !== embedding2.length) {
throw new Error('Embeddings must have the same dimension')
toVectorString(embedding: number[]): string {
return `[${embedding.join(',')}]`
}
/**
* Parse a pgvector string from the DB back into number[].
* e.g. '[0.1,0.2,0.3]' → [0.1, 0.2, 0.3]
*/
fromVectorString(vec: string): number[] {
if (Array.isArray(vec)) return vec
if (!vec || typeof vec !== 'string') return []
return vec.replace(/^\[/, '').replace(/\]$/, '').split(',').map(Number)
}
/**
* JS cosine similarity — still used by memory-echo pairwise comparisons.
*/
calculateCosineSimilarity(a: number[], b: number[]): number {
if (!a.length || !b.length) return 0
const minLen = Math.min(a.length, b.length)
let dot = 0, mA = 0, mB = 0
for (let i = 0; i < minLen; i++) {
dot += a[i] * b[i]
mA += a[i] * a[i]
mB += b[i] * b[i]
}
let dotProduct = 0
let magnitude1 = 0
let magnitude2 = 0
for (let i = 0; i < embedding1.length; i++) {
dotProduct += embedding1[i] * embedding2[i]
magnitude1 += embedding1[i] * embedding1[i]
magnitude2 += embedding2[i] * embedding2[i]
}
magnitude1 = Math.sqrt(magnitude1)
magnitude2 = Math.sqrt(magnitude2)
if (magnitude1 === 0 || magnitude2 === 0) {
return 0
}
return dotProduct / (magnitude1 * magnitude2)
mA = Math.sqrt(mA)
mB = Math.sqrt(mB)
if (mA === 0 || mB === 0) return 0
return dot / (mA * mB)
}
/**
* Calculate similarity between an embedding and multiple other embeddings
* Returns array of similarities
*/
calculateSimilarities(
queryEmbedding: number[],
targetEmbeddings: number[][]
): number[] {
return targetEmbeddings.map(embedding =>
this.calculateCosineSimilarity(queryEmbedding, embedding)
)
}
/**
* Find most similar embeddings to a query
* Returns top-k results with their similarities
*/
findMostSimilar(
queryEmbedding: number[],
targetEmbeddings: Array<{ id: string; embedding: number[] }>,
topK: number = 10
): Array<{ id: string; similarity: number }> {
const similarities = targetEmbeddings.map(({ id, embedding }) => ({
id,
similarity: this.calculateCosineSimilarity(queryEmbedding, embedding)
}))
// Sort by similarity descending and return top-k
return similarities
.sort((a, b) => b.similarity - a.similarity)
.slice(0, topK)
}
/**
* Get average embedding from multiple embeddings
* Useful for clustering or centroid calculation
*/
averageEmbeddings(embeddings: number[][]): number[] {
if (embeddings.length === 0) {
throw new Error('Cannot average empty embeddings array')
}
const dimension = embeddings[0].length
const average = new Array(dimension).fill(0)
for (const embedding of embeddings) {
if (embedding.length !== dimension) {
throw new Error('All embeddings must have the same dimension')
}
for (let i = 0; i < dimension; i++) {
average[i] += embedding[i]
}
}
// Divide by number of embeddings
return average.map(val => val / embeddings.length)
}
/**
* Pass-through — embeddings are stored as native JSONB in PostgreSQL
*/
serialize(embedding: number[]): number[] {
return embedding
}
/**
* Pass-through — embeddings come back already parsed from PostgreSQL
*/
deserialize(embedding: number[]): number[] {
return embedding
}
/**
* Check if a note needs embedding regeneration
* (e.g., if content has changed significantly)
* Check if a note needs embedding regeneration.
* Uses a content-content comparison (not embedding-content).
*/
shouldRegenerateEmbedding(
noteContent: string,
lastEmbeddingContent: string | null,
_lastEmbeddingContent: string | null,
lastAnalysis: Date | null
): boolean {
// If no previous embedding, generate one
if (!lastEmbeddingContent || !lastAnalysis) {
return true
}
// If content has changed more than 20% (simple heuristic)
const contentChanged =
Math.abs(noteContent.length - lastEmbeddingContent.length) / lastEmbeddingContent.length > 0.2
// If last analysis is more than 7 days old
if (!lastAnalysis) return true
const daysSinceAnalysis = (Date.now() - lastAnalysis.getTime()) / (1000 * 60 * 60 * 24)
const isStale = daysSinceAnalysis > 7
return contentChanged || isStale
return daysSinceAnalysis > 7
}
}
// Singleton instance
export const embeddingService = new EmbeddingService()

View File

@@ -1,5 +1,6 @@
import { getAIProvider, getChatProvider } from '../factory'
import { cosineSimilarity } from '@/lib/utils'
import { embeddingService } from './embedding.service'
import { getSystemConfig } from '@/lib/config'
import prisma from '@/lib/prisma'
@@ -78,11 +79,15 @@ export class MemoryEchoService {
try {
const embedding = await provider.getEmbeddings(note.content)
if (embedding && embedding.length > 0) {
await prisma.noteEmbedding.upsert({
where: { noteId: note.id },
create: { noteId: note.id, embedding: JSON.stringify(embedding) },
update: { embedding: JSON.stringify(embedding) }
})
const vecStr = `[${embedding.join(',')}]`
await prisma.$executeRawUnsafe(
`INSERT INTO "NoteEmbedding" ("id", "noteId", "embedding", "createdAt", "updatedAt")
VALUES (gen_random_uuid(), $1, $2::vector, now(), now())
ON CONFLICT ("noteId")
DO UPDATE SET "embedding" = $2::vector, "updatedAt" = now()`,
note.id,
vecStr
)
}
} catch {
// Skip this note, continue with others
@@ -122,11 +127,12 @@ export class MemoryEchoService {
return [] // Need at least 2 notes to find connections
}
// Parse embeddings (already native Json from PostgreSQL)
const notesWithEmbeddings = notes
.map(note => ({
...note,
embedding: note.noteEmbedding?.embedding ? JSON.parse(note.noteEmbedding.embedding) as number[] : null
embedding: note.noteEmbedding?.embedding
? embeddingService.fromVectorString(note.noteEmbedding.embedding as unknown as string)
: null
}))
.filter(note => note.embedding && Array.isArray(note.embedding))
@@ -500,8 +506,9 @@ Explain in one brief sentence (max 15 words) why these notes are connected. Focu
return []
}
// Target note embedding (already native Json from PostgreSQL)
const targetEmbedding = targetNote.noteEmbedding?.embedding ? JSON.parse(targetNote.noteEmbedding.embedding) as number[] : null
const targetEmbedding = targetNote.noteEmbedding?.embedding
? embeddingService.fromVectorString(targetNote.noteEmbedding.embedding as unknown as string)
: null
if (!targetEmbedding) return []
// Check if user has demo mode enabled
@@ -535,7 +542,9 @@ Explain in one brief sentence (max 15 words) why these notes are connected. Focu
for (const otherNote of otherNotes) {
if (!otherNote.noteEmbedding) continue
const otherEmbedding = otherNote.noteEmbedding?.embedding ? JSON.parse(otherNote.noteEmbedding.embedding) as number[] : null
const otherEmbedding = otherNote.noteEmbedding?.embedding
? embeddingService.fromVectorString(otherNote.noteEmbedding.embedding as unknown as string)
: null
if (!otherEmbedding) continue
// Check if this connection was dismissed

View File

@@ -1,7 +1,12 @@
/**
* Semantic Search Service
* Hybrid search combining keyword matching and semantic similarity
* Uses Reciprocal Rank Fusion (RRF) for result ranking
*
* Unified hybrid search combining:
* 1. PostgreSQL full-text search (tsvector / tsquery) via GIN index
* 2. pgvector cosine-distance nearest-neighbor search via HNSW index
* 3. Reciprocal Rank Fusion (RRF) for final ranking
*
* All vector operations happen in the database — no JS cosine-similarity loops.
*/
import { embeddingService } from './embedding.service'
@@ -19,19 +24,22 @@ export interface SearchResult {
export interface SearchOptions {
limit?: number
threshold?: number // Minimum similarity score (0-1)
threshold?: number
includeExactMatches?: boolean
notebookId?: string // NEW: Filter by notebook for contextual search (IA5)
defaultTitle?: string // Optional default title for untitled notes (i18n)
notebookId?: string
defaultTitle?: string
}
export class SemanticSearchService {
private readonly RRF_K = 60 // RRF constant (default recommended value)
private readonly RRF_K = 60
private readonly DEFAULT_LIMIT = 20
private readonly DEFAULT_THRESHOLD = 0.6
private readonly DEFAULT_THRESHOLD = 0.3
private readonly VECTOR_CANDIDATES = 50
private readonly FTS_CANDIDATES = 50
/**
* Hybrid search: keyword + semantic with RRF fusion
* Hybrid search: FTS + pgvector with RRF fusion.
* Accepts an optional userId to skip auth() (used by agent tools).
*/
async search(
query: string,
@@ -40,292 +48,15 @@ export class SemanticSearchService {
const {
limit = this.DEFAULT_LIMIT,
threshold = this.DEFAULT_THRESHOLD,
includeExactMatches = true,
notebookId, // NEW: Contextual search within notebook (IA5)
defaultTitle = 'Untitled' // Default title for i18n
notebookId,
defaultTitle = 'Untitled'
} = options
if (!query || query.trim().length < 2) {
return []
}
if (!query || query.trim().length < 2) return []
const session = await auth()
const userId = session?.user?.id || null
try {
// 1. Keyword search (SQLite FTS)
const keywordResults = await this.keywordSearch(query, userId, notebookId)
// 2. Semantic search (vector similarity)
const semanticResults = await this.semanticVectorSearch(query, userId, threshold, notebookId)
// 3. Reciprocal Rank Fusion
const fusedResults = await this.reciprocalRankFusion(
keywordResults,
semanticResults
)
// 4. Sort by final score and limit
return fusedResults
.sort((a, b) => b.score - a.score)
.slice(0, limit)
.map(result => ({
...result,
title: result.title || defaultTitle,
matchType: result.score > 0.8 ? 'exact' : 'related'
}))
} catch (error) {
console.error('Error in hybrid search:', error)
// Fallback to keyword-only search
const keywordResults = await this.keywordSearch(query, userId)
// Fetch note details for keyword results
const noteIds = keywordResults.slice(0, limit).map(r => r.noteId)
const notes = await prisma.note.findMany({
where: { id: { in: noteIds }, trashedAt: null },
select: {
id: true,
title: true,
content: true,
language: true
}
})
return notes.map(note => ({
noteId: note.id,
title: note.title || defaultTitle,
content: note.content,
score: 1.0, // Default score for keyword-only results
matchType: 'related' as const,
language: note.language
}))
}
}
/**
* Keyword search using SQLite LIKE/FTS
*/
private async keywordSearch(
query: string,
userId: string | null,
notebookId?: string // NEW: Filter by notebook (IA5)
): Promise<Array<{ noteId: string; rank: number }>> {
// Extract keywords (words with > 3 characters) to avoid entire sentence matching failing
const stopWords = new Set(['comment', 'pourquoi', 'lequel', 'laquelle', 'avec', 'pour', 'dans', 'sur', 'est-ce']);
const keywords = query.toLowerCase()
.split(/[^a-z0-9àáâäçéèêëíìîïñóòôöúùûü]/i)
.filter(w => w.length > 3 && !stopWords.has(w));
// If no good keywords found, fallback to the original query but it'll likely fail
const searchTerms = keywords.length > 0 ? keywords : [query];
// Build Prisma OR clauses for each keyword
const searchConditions = searchTerms.flatMap(term => [
{ title: { contains: term, mode: 'insensitive' as const } },
{ content: { contains: term, mode: 'insensitive' as const } }
]);
const notes = await prisma.note.findMany({
where: {
...(userId ? { userId } : {}),
...(notebookId !== undefined ? { notebookId } : {}), // NEW: Notebook filter
trashedAt: null,
OR: searchConditions
},
select: {
id: true,
title: true,
content: true
}
})
// Simple relevance scoring based on match position and frequency
const results = notes.map(note => {
const title = note.title || ''
const content = note.content || ''
const queryLower = query.toLowerCase()
// Count occurrences — escape regex special chars to avoid crashes
const escaped = queryLower.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const titleMatches = (title.match(new RegExp(escaped, 'gi')) || []).length
const contentMatches = (content.match(new RegExp(escaped, 'gi')) || []).length
// Boost title matches significantly
const titlePosition = title.toLowerCase().indexOf(queryLower)
const contentPosition = content.toLowerCase().indexOf(queryLower)
// Calculate rank (lower is better)
let rank = 100
if (titleMatches > 0) {
rank = titlePosition === 0 ? 1 : 10
rank -= titleMatches * 2
} else if (contentMatches > 0) {
rank = contentPosition < 100 ? 20 : 30
rank -= contentMatches
}
return {
noteId: note.id,
rank
}
})
return results.sort((a, b) => a.rank - b.rank)
}
/**
* Semantic vector search using embeddings
*/
private async semanticVectorSearch(
query: string,
userId: string | null,
threshold: number,
notebookId?: string // NEW: Filter by notebook (IA5)
): Promise<Array<{ noteId: string; rank: number }>> {
try {
// Generate query embedding
const { embedding: queryEmbedding } = await embeddingService.generateEmbedding(query)
// Fetch all user's notes with embeddings
const notes = await prisma.note.findMany({
where: {
...(userId ? { userId } : {}),
...(notebookId !== undefined ? { notebookId } : {}),
trashedAt: null,
noteEmbedding: { isNot: null }
},
select: {
id: true,
noteEmbedding: true
}
})
if (notes.length === 0) {
return []
}
// Calculate similarities for all notes
const similarities = notes.map(note => {
const noteEmbedding = note.noteEmbedding?.embedding ? JSON.parse(note.noteEmbedding.embedding) as number[] : []
const similarity = embeddingService.calculateCosineSimilarity(
queryEmbedding,
noteEmbedding
)
return {
noteId: note.id,
similarity
}
})
// Filter by threshold and convert to rank
return similarities
.filter(s => s.similarity >= threshold)
.sort((a, b) => b.similarity - a.similarity)
.map((s, index) => ({
noteId: s.noteId,
rank: index + 1 // 1-based rank
}))
} catch (error) {
console.error('Error in semantic vector search:', error)
return []
}
}
/**
* Reciprocal Rank Fusion algorithm
* Combines multiple ranked lists into a single ranking
* Formula: RRF(score) = 1 / (k + rank)
* k = 60 (default, prevents high rank from dominating)
*/
private async reciprocalRankFusion(
keywordResults: Array<{ noteId: string; rank: number }>,
semanticResults: Array<{ noteId: string; rank: number }>
): Promise<SearchResult[]> {
const scores = new Map<string, number>()
// Add keyword scores
for (const result of keywordResults) {
const rrfScore = 1 / (this.RRF_K + result.rank)
scores.set(result.noteId, (scores.get(result.noteId) || 0) + rrfScore)
}
// Add semantic scores
for (const result of semanticResults) {
const rrfScore = 1 / (this.RRF_K + result.rank)
scores.set(result.noteId, (scores.get(result.noteId) || 0) + rrfScore)
}
// Fetch note details
const noteIds = Array.from(scores.keys())
const notes = await prisma.note.findMany({
where: { id: { in: noteIds }, trashedAt: null },
select: {
id: true,
title: true,
content: true,
language: true
}
})
// Combine scores with note details
return notes.map(note => ({
noteId: note.id,
title: note.title,
content: note.content,
score: scores.get(note.id) || 0,
matchType: 'related' as const,
language: note.language
}))
}
/**
* Generate or update embedding for a note
* Called when note is created or significantly updated
*/
async indexNote(noteId: string): Promise<void> {
try {
const note = await prisma.note.findUnique({
where: { id: noteId },
select: { content: true, noteEmbedding: true, lastAiAnalysis: true }
})
if (!note) {
throw new Error('Note not found')
}
// Check if embedding needs regeneration
const shouldRegenerate = embeddingService.shouldRegenerateEmbedding(
note.content,
note.noteEmbedding?.embedding as any,
note.lastAiAnalysis
)
if (!shouldRegenerate) {
return
}
// Generate new embedding
const { embedding } = await embeddingService.generateEmbedding(note.content)
// Save to database
await prisma.noteEmbedding.upsert({
where: { noteId: noteId },
create: { noteId: noteId, embedding: embeddingService.serialize(embedding) as any },
update: { embedding: embeddingService.serialize(embedding) as any }
})
await prisma.note.update({
where: { id: noteId },
data: {
lastAiAnalysis: new Date()
}
})
} catch (error) {
console.error(`Error indexing note ${noteId}:`, error)
throw error
}
return this._doSearch(query, userId, { limit, threshold, notebookId, defaultTitle })
}
/**
@@ -340,50 +71,251 @@ export class SemanticSearchService {
const {
limit = this.DEFAULT_LIMIT,
threshold = this.DEFAULT_THRESHOLD,
includeExactMatches = true,
notebookId,
defaultTitle = 'Untitled'
} = options
if (!query || query.trim().length < 2) {
return []
}
if (!query || query.trim().length < 2) return []
return this._doSearch(query, userId, { limit, threshold, notebookId, defaultTitle })
}
private async _doSearch(
query: string,
userId: string | null,
opts: { limit: number; threshold: number; notebookId?: string; defaultTitle: string }
): Promise<SearchResult[]> {
try {
const keywordResults = await this.keywordSearch(query, userId, notebookId)
const semanticResults = await this.semanticVectorSearch(query, userId, threshold, notebookId)
const fusedResults = await this.reciprocalRankFusion(keywordResults, semanticResults)
const [keywordResults, semanticResults] = await Promise.all([
this.ftsSearch(query, userId, opts.notebookId),
this.vectorSearch(query, userId, opts.threshold, opts.notebookId)
])
const fusedResults = this.reciprocalRankFusion(keywordResults, semanticResults)
return fusedResults
.sort((a, b) => b.score - a.score)
.slice(0, limit)
.slice(0, opts.limit)
.map(result => ({
...result,
title: result.title || defaultTitle,
matchType: result.score > 0.8 ? 'exact' : 'related'
title: result.title || opts.defaultTitle,
matchType: result.score > 0.8 ? 'exact' as const : 'related' as const
}))
} catch (error) {
console.error('Error in searchAsUser:', error)
console.error('Error in hybrid search:', error)
return this._ftsFallback(query, userId, opts)
}
}
/**
* PostgreSQL full-text search using tsvector + GIN index.
* Returns ranked results using ts_rank.
*/
private async ftsSearch(
query: string,
userId: string | null,
notebookId?: string
): Promise<Array<{ noteId: string; rank: number }>> {
const safeQuery = query.replace(/'/g, "''")
const userClause = userId ? `AND "userId" = '${userId}'` : ''
const notebookClause = notebookId !== undefined
? `AND "notebookId" ${notebookId ? `= '${notebookId.replace(/'/g, "''")}'` : 'IS NULL'}`
: ''
const sql = `
SELECT id AS "noteId", ts_rank("tsv", plainto_tsquery('simple', '${safeQuery}')) AS rank
FROM "Note"
WHERE "tsv" @@ plainto_tsquery('simple', '${safeQuery}')
AND "trashedAt" IS NULL
AND "isArchived" = false
${userClause}
${notebookClause}
ORDER BY rank DESC
LIMIT ${this.FTS_CANDIDATES}
`
const rows: Array<{ noteId: string; rank: number }> = await prisma.$queryRawUnsafe(sql)
const maxRank = rows.length > 0 ? rows[0].rank : 1
return rows.map((r, i) => ({
noteId: r.noteId,
rank: i + 1
}))
}
/**
* pgvector cosine-distance search using the HNSW index.
* Returns nearest neighbors above the similarity threshold.
*/
private async vectorSearch(
query: string,
userId: string | null,
threshold: number,
notebookId?: string
): Promise<Array<{ noteId: string; rank: number }>> {
let queryEmbedding: number[]
try {
const result = await embeddingService.generateEmbedding(query)
queryEmbedding = result.embedding
} catch (error) {
console.error('Failed to generate query embedding:', error)
return []
}
const vecStr = embeddingService.toVectorString(queryEmbedding)
const userClause = userId ? `AND n."userId" = '${userId}'` : ''
const notebookClause = notebookId !== undefined
? `AND n."notebookId" ${notebookId ? `= '${notebookId.replace(/'/g, "''")}'` : 'IS NULL'}`
: ''
const sql = `
SELECT n.id AS "noteId",
1 - (e."embedding" <=> '${vecStr}'::vector) AS similarity
FROM "Note" n
INNER JOIN "NoteEmbedding" e ON e."noteId" = n.id
WHERE n."trashedAt" IS NULL
AND n."isArchived" = false
${userClause}
${notebookClause}
AND 1 - (e."embedding" <=> '${vecStr}'::vector) >= ${threshold}
ORDER BY e."embedding" <=> '${vecStr}'::vector ASC
LIMIT ${this.VECTOR_CANDIDATES}
`
const rows: Array<{ noteId: string; similarity: number }> = await prisma.$queryRawUnsafe(sql)
return rows.map((r, i) => ({
noteId: r.noteId,
rank: i + 1
}))
}
/**
* Reciprocal Rank Fusion algorithm.
* Combines keyword and semantic ranked lists into a single ranking.
*/
private async reciprocalRankFusion(
keywordResults: Array<{ noteId: string; rank: number }>,
semanticResults: Array<{ noteId: string; rank: number }>
): Promise<SearchResult[]> {
const scores = new Map<string, number>()
for (const result of keywordResults) {
const rrfScore = 1 / (this.RRF_K + result.rank)
scores.set(result.noteId, (scores.get(result.noteId) || 0) + rrfScore)
}
for (const result of semanticResults) {
const rrfScore = 1 / (this.RRF_K + result.rank)
scores.set(result.noteId, (scores.get(result.noteId) || 0) + rrfScore)
}
const noteIds = Array.from(scores.keys())
if (noteIds.length === 0) return []
const notes = await prisma.note.findMany({
where: { id: { in: noteIds }, trashedAt: null },
select: {
id: true,
title: true,
content: true,
language: true
}
})
return notes.map(note => ({
noteId: note.id,
title: note.title,
content: note.content,
score: scores.get(note.id) || 0,
matchType: 'related' as const,
language: note.language
}))
}
/**
* Fallback to FTS-only when vector search fails entirely.
*/
private async _ftsFallback(
query: string,
userId: string | null,
opts: { limit: number; threshold: number; notebookId?: string; defaultTitle: string }
): Promise<SearchResult[]> {
try {
const keywordResults = await this.ftsSearch(query, userId, opts.notebookId)
const noteIds = keywordResults.slice(0, opts.limit).map(r => r.noteId)
const notes = await prisma.note.findMany({
where: { id: { in: noteIds }, trashedAt: null },
select: { id: true, title: true, content: true, language: true }
})
return notes.map(note => ({
noteId: note.id,
title: note.title || opts.defaultTitle,
content: note.content,
score: 1.0,
matchType: 'related' as const,
language: note.language
}))
} catch {
return []
}
}
/**
* Batch index multiple notes (for initial migration or bulk updates)
* Generate or update embedding for a note.
* Stores as native pgvector via raw SQL.
*/
async indexNote(noteId: string): Promise<void> {
try {
const note = await prisma.note.findUnique({
where: { id: noteId },
select: { content: true, lastAiAnalysis: true }
})
if (!note) throw new Error('Note not found')
const shouldRegenerate = embeddingService.shouldRegenerateEmbedding(
note.content,
null,
note.lastAiAnalysis
)
if (!shouldRegenerate) return
const { embedding } = await embeddingService.generateEmbedding(note.content)
const vecStr = embeddingService.toVectorString(embedding)
await prisma.$executeRawUnsafe(
`INSERT INTO "NoteEmbedding" ("id", "noteId", "embedding", "createdAt", "updatedAt")
VALUES (gen_random_uuid(), $1, $2::vector, now(), now())
ON CONFLICT ("noteId")
DO UPDATE SET "embedding" = $2::vector, "updatedAt" = now()`,
noteId,
vecStr
)
await prisma.note.update({
where: { id: noteId },
data: { lastAiAnalysis: new Date() }
})
} catch (error) {
console.error(`Error indexing note ${noteId}:`, error)
throw error
}
}
/**
* Batch index multiple notes.
*/
async indexBatchNotes(noteIds: string[]): Promise<void> {
const BATCH_SIZE = 10 // Process in batches to avoid overwhelming
const BATCH_SIZE = 20
for (let i = 0; i < noteIds.length; i += BATCH_SIZE) {
const batch = noteIds.slice(i, i + BATCH_SIZE)
await Promise.allSettled(
batch.map(noteId => this.indexNote(noteId))
)
await Promise.allSettled(batch.map(noteId => this.indexNote(noteId)))
}
}
}
// Singleton instance
export const semanticSearchService = new SemanticSearchService()

View File

@@ -1,16 +1,16 @@
/**
* Note Search Tool
* Wraps semanticSearchService.searchAsUser()
* Uses the unified SemanticSearchService (FTS + pgvector + RRF).
*/
import { tool } from 'ai'
import { z } from 'zod'
import { toolRegistry } from './registry'
import { prisma } from '@/lib/prisma'
import { semanticSearchService } from '@/lib/ai/services/semantic-search.service'
toolRegistry.register({
name: 'note_search',
description: 'Search the user\'s notes using semantic search. Returns matching notes with titles and content excerpts.',
description: 'Search the user\'s notes using hybrid semantic + keyword search. Returns matching notes with titles and content excerpts.',
isInternal: true,
buildTool: (ctx) =>
tool({
@@ -21,34 +21,20 @@ toolRegistry.register({
notebookId: z.string().optional().describe('Optional notebook ID to restrict search to a specific notebook'),
}),
execute: async ({ query, limit = 5, notebookId: explicitNotebookId }) => {
// If no notebookId passed explicitly, fall back to the chat scope from context
const notebookId = explicitNotebookId || ctx.notebookId
try {
// Keyword fallback search using Prisma
const keywords = query.toLowerCase().split(/\s+/).filter(w => w.length > 2)
const conditions = keywords.flatMap(term => [
{ title: { contains: term } },
{ content: { contains: term } }
])
const notes = await prisma.note.findMany({
where: {
userId: ctx.userId,
...(notebookId ? { notebookId } : {}),
...(conditions.length > 0 ? { OR: conditions } : {}),
isArchived: false,
trashedAt: null,
},
select: { id: true, title: true, content: true, createdAt: true },
take: limit,
orderBy: { createdAt: 'desc' },
const results = await semanticSearchService.searchAsUser(ctx.userId, query, {
limit,
threshold: 0.25,
notebookId
})
return notes.map(n => ({
id: n.id,
title: n.title || 'Untitled',
excerpt: n.content.substring(0, 300),
createdAt: n.createdAt.toISOString(),
return results.map(r => ({
id: r.noteId,
title: r.title || 'Untitled',
excerpt: r.content.substring(0, 300),
score: r.score,
matchType: r.matchType,
}))
} catch (e: any) {
return { error: `Note search failed: ${e.message}` }

View File

@@ -0,0 +1,52 @@
-- Phase 1: Enable pgvector extension
CREATE EXTENSION IF NOT EXISTS vector;
-- Phase 2: Add native vector column to NoteEmbedding
-- Convert existing JSON-string embeddings to native vector(1536)
ALTER TABLE "NoteEmbedding" ADD COLUMN "vec" vector(1536);
-- Migrate existing data: parse JSON arrays into pgvector format
UPDATE "NoteEmbedding"
SET "vec" = ("embedding"::jsonb)::text::vector(1536)
WHERE "embedding" IS NOT NULL;
-- Drop old string column, rename new one
ALTER TABLE "NoteEmbedding" DROP COLUMN "embedding";
ALTER TABLE "NoteEmbedding" RENAME COLUMN "vec" TO "embedding";
-- Add updatedAt column for tracking reindex freshness
ALTER TABLE "NoteEmbedding" ADD COLUMN "updatedAt" TIMESTAMP NOT NULL DEFAULT now();
-- HNSW index for fast approximate nearest neighbor search (cosine distance)
CREATE INDEX "NoteEmbedding_embedding_hnsw_idx" ON "NoteEmbedding"
USING hnsw ("embedding" vector_cosine_ops)
WITH (m = 16, ef_construction = 64);
-- Phase 3: Add full-text search tsvector column to Note
ALTER TABLE "Note" ADD COLUMN "tsv" tsvector;
-- Populate tsv from existing title + content
UPDATE "Note"
SET "tsv" =
setweight(to_tsvector('simple', COALESCE("title", '')), 'A') ||
setweight(to_tsvector('simple', COALESCE("content", '')), 'B');
-- GIN index for fast FTS queries
CREATE INDEX "Note_tsv_gin_idx" ON "Note" USING gin ("tsv");
-- Trigger function to auto-update tsv on INSERT or UPDATE of title/content
CREATE OR REPLACE FUNCTION "note_tsv_trigger"() RETURNS trigger AS $$
BEGIN
NEW."tsv" :=
setweight(to_tsvector('simple', COALESCE(NEW."title", '')), 'A') ||
setweight(to_tsvector('simple', COALESCE(NEW."content", '')), 'B');
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Attach trigger
DROP TRIGGER IF EXISTS "note_tsv_update" ON "Note";
CREATE TRIGGER "note_tsv_update"
BEFORE INSERT OR UPDATE OF "title", "content" ON "Note"
FOR EACH ROW
EXECUTE FUNCTION "note_tsv_trigger"();

View File

@@ -155,6 +155,7 @@ model Note {
languageConfidence Float?
lastAiAnalysis DateTime?
trashedAt DateTime?
tsv Unsupported("tsvector")?
aiFeedback AiFeedback[]
memoryEchoAsNote1 MemoryEchoInsight[] @relation("EchoNote1")
memoryEchoAsNote2 MemoryEchoInsight[] @relation("EchoNote2")
@@ -299,8 +300,9 @@ model UserAISettings {
model NoteEmbedding {
id String @id @default(cuid())
noteId String @unique
embedding String
embedding Unsupported("vector(1536)")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
note Note @relation(fields: [noteId], references: [id], onDelete: Cascade)
@@index([noteId])

View File

@@ -1,59 +1,67 @@
// scripts/migrate-embeddings.ts
const { PrismaClient } = require('../prisma/client-generated')
// Re-indexes all notes that lack a NoteEmbedding row using pgvector format.
// Run with: npx tsx scripts/migrate-embeddings.ts
const { PrismaClient } = require('../node_modules/.prisma/client')
const prisma = new PrismaClient({
datasources: {
db: {
url: process.env.DATABASE_URL || "file:../prisma/dev.db"
url: process.env.DATABASE_URL
}
}
})
async function main() {
console.log("Fetching notes with embeddings...")
console.log('Fetching notes without embeddings...')
const notes = await prisma.note.findMany({
where: {
embedding: { not: null }
trashedAt: null,
noteEmbedding: { is: null }
},
select: {
id: true,
embedding: true
content: true,
title: true
}
})
console.log(`Found ${notes.length} notes with an embedding.`)
console.log(`Found ${notes.length} notes without an embedding.`)
if (notes.length === 0) {
console.log("Nothing to migrate.")
console.log('Nothing to migrate.')
return
}
let count = 0
let failed = 0
for (const note of notes) {
if (!note.embedding) continue
await prisma.noteEmbedding.upsert({
where: { noteId: note.id },
create: {
noteId: note.id,
embedding: note.embedding
},
update: {
embedding: note.embedding
if (!note.content) continue
try {
// Embedding will be generated by the indexNote method which handles pgvector format
await prisma.$executeRawUnsafe(
`INSERT INTO "NoteEmbedding" ("id", "noteId", "embedding", "createdAt", "updatedAt")
VALUES (gen_random_uuid(), $1, '[0]'::vector(1536), now(), now())
ON CONFLICT ("noteId") DO NOTHING`,
note.id
)
count++
if (count % 10 === 0) {
console.log(`Placeholder for ${count}/${notes.length}...`)
}
})
count++
if (count % 10 === 0) {
console.log(`Migrated ${count}/${notes.length}...`)
} catch (e) {
failed++
console.error(`Failed for note ${note.id}:`, e.message)
}
}
console.log(`✅ Successfully migrated ${count} note embeddings to the NoteEmbedding table.`)
console.log(`Created ${count} embedding placeholders (${failed} failed).`)
console.log('Run /api/notes/reindex to populate with real embeddings.')
}
main()
.catch((e) => {
console.error("Migration failed:", e)
console.error('Migration failed:', e)
process.exit(1)
})
.finally(async () => {

View File

@@ -1,63 +1,40 @@
import { prisma } from '../lib/prisma'
// Copy of parseNote from app/actions/notes.ts (since it's not exported)
function parseNote(dbNote: any) {
const embedding = dbNote.embedding ? JSON.parse(dbNote.embedding) : null
if (embedding && Array.isArray(embedding)) {
// Simplified validation check for test
if (embedding.length !== 1536 && embedding.length !== 768 && embedding.length !== 384) {
return {
...dbNote,
checkItems: dbNote.checkItems ? JSON.parse(dbNote.checkItems) : null,
labels: dbNote.labels ? JSON.parse(dbNote.labels) : null,
images: dbNote.images ? JSON.parse(dbNote.images) : null,
links: dbNote.links ? JSON.parse(dbNote.links) : null,
embedding: null,
sharedWith: dbNote.sharedWith ? JSON.parse(dbNote.sharedWith) : [],
size: dbNote.size || 'small',
}
}
}
return {
...dbNote,
checkItems: dbNote.checkItems ? JSON.parse(dbNote.checkItems) : null,
labels: dbNote.labels ? JSON.parse(dbNote.labels) : null,
images: dbNote.images ? JSON.parse(dbNote.images) : null,
links: dbNote.links ? JSON.parse(dbNote.links) : null,
embedding,
sharedWith: dbNote.sharedWith ? JSON.parse(dbNote.sharedWith) : [],
size: dbNote.size || 'small',
}
}
async function main() {
console.log('🧪 Testing parseNote logic...')
console.log('Testing parseNote logic...')
// 1. Fetch a real note from DB that is KNOWN to be large
const rawNote = await prisma.note.findFirst({
where: { size: 'large' }
})
if (!rawNote) {
console.error('No large note found in DB. Create one first.')
console.error('No large note found in DB.')
return
}
console.log('📊 Raw Note from DB:', { id: rawNote.id, size: rawNote.size })
console.log('Raw Note from DB:', { id: rawNote.id, size: rawNote.size })
// 2. Pass it through parseNote
const parsed = parseNote(rawNote)
console.log('🔄 Parsed Note:', { id: parsed.id, size: parsed.size })
console.log('Parsed Note:', { id: parsed.id, size: parsed.size })
if (parsed.size === 'large') {
console.log('parseNote preserves size correctly.')
console.log('parseNote preserves size correctly.')
} else {
console.error('parseNote returned wrong size:', parsed.size)
console.error('parseNote returned wrong size:', parsed.size)
}
}
main().catch(console.error).finally(() => prisma.$disconnect())

View File

@@ -220,32 +220,30 @@ describe('Data Integrity Tests', () => {
expect(parsedLabels).toContain('project')
})
test('should preserve embedding JSON structure', async () => {
const embedding = JSON.stringify({
vector: [0.1, 0.2, 0.3, 0.4, 0.5],
model: 'text-embedding-ada-002',
timestamp: new Date().toISOString()
})
test('should preserve embedding vector structure in NoteEmbedding table', async () => {
const note = await prisma.note.create({
data: {
title: 'Embedding Test Note',
content: 'Note with embedding',
embedding,
userId: 'test-user-id'
}
})
// Verify embedding is preserved and can be parsed
const retrieved = await prisma.note.findUnique({
where: { id: note.id }
})
expect(retrieved?.embedding).toBeDefined()
const parsedEmbedding = JSON.parse(retrieved?.embedding || '{}')
expect(parsedEmbedding.vector).toEqual([0.1, 0.2, 0.3, 0.4, 0.5])
expect(parsedEmbedding.model).toBe('text-embedding-ada-002')
const vecStr = '[0.1,0.2,0.3,0.4,0.5]'
await prisma.$executeRawUnsafe(
`INSERT INTO "NoteEmbedding" ("id", "noteId", "embedding", "createdAt", "updatedAt")
VALUES (gen_random_uuid(), $1, $2::vector(1536), now(), now())`,
note.id,
vecStr
)
const retrieved: Array<{ noteId: string }> = await prisma.$queryRawUnsafe(
`SELECT "noteId" FROM "NoteEmbedding" WHERE "noteId" = $1`,
note.id
)
expect(retrieved.length).toBe(1)
expect(retrieved[0].noteId).toBe(note.id)
})
test('should preserve links JSON structure', async () => {