diff --git a/docs/guide-utilisateur/README.md b/docs/guide-utilisateur/README.md index ea8522a..bf27eb1 100644 --- a/docs/guide-utilisateur/README.md +++ b/docs/guide-utilisateur/README.md @@ -190,19 +190,110 @@ Résultats : notification in-app et par email selon configuration. ## 7. Brainstorm radial +Le brainstorm est un espace de pensée visuelle qui génère des idées par vagues successives à partir d'une idée graine, sur un canvas interactif. Il est accessible depuis la barre latérale (icône Wind) ou directement depuis n'importe quelle note via le bouton **⌁** dans la barre d'outils de l'éditeur. + Documentation technique : [brainstorm-documentation.md](../../memento-note/docs/brainstorm-documentation.md). -### Parcours type +--- -1. Saisir une **idée graine** (ou partir d’une note existante). -2. L’IA génère **3 vagues** : Variations → Analogies → Disruptions (9 idées). -3. Sur le **canvas D3** : zoom, drag, approfondir, rejeter, convertir en note. -4. **Partager** la session (lien invité ; l’hôte paie les tokens en mode host-pays). -5. **Exporter** en note structurée ou formats de présentation. +### Démarrer une session -### Collaboration +**Depuis le canvas vide :** -Socket.io : curseurs en direct, déplacement de nœuds, présence des participants (host / editor / viewer). +1. Saisir une idée, question ou sujet dans le champ de saisie en haut. +2. Appuyer sur **Entrée** ou le bouton **+**. +3. L'IA génère immédiatement une première vague de 3 idées. + +**Prompts suggérés :** si vous manquez d'inspiration, des exemples de graines apparaissent sous le champ quand aucune session n'est active. Un clic sur l'un d'eux lance directement le brainstorm. + +**Depuis une note :** cliquer sur le bouton ⌁ (Wind) dans la barre d'outils de l'éditeur. Le titre et le début du contenu de la note deviennent automatiquement la graine. + +--- + +### Les vagues de pensée + +L'IA explore trois angles distincts, chacun représenté par une couleur sur le canvas : + +| Vague | Couleur | Angle exploré | +|-------|---------|---------------| +| 1 — Variations | Orange | Déclinaisons directes de la graine | +| 2 — Analogies | Bleu | Parallèles avec d'autres domaines | +| 3 — Disruptions | Violet | Remises en question radicales | + +Chaque vague produit 3 idées. Au total : jusqu'à 9 idées par session, organisées radialement autour du nœud graine central. + +--- + +### Naviguer sur le canvas + +- **Zoom** : molette ou pinch sur mobile. +- **Déplacer** : cliquer-glisser sur le fond. +- **Sélectionner une idée** : cliquer sur un nœud — le panneau détail s'ouvre à droite (ou en bas sur mobile). +- **Ajouter une idée manuellement** : bouton **+ Ajouter une idée** en bas à gauche, ou double-clic sur le canvas. + +**Vue mobile :** sur petit écran, un toggle **Canvas / Liste** apparaît sous le champ de saisie. La vue liste regroupe les idées par vague en cartes scrollables, plus lisibles sur téléphone. + +--- + +### Panneau détail d'une idée + +Un clic sur un nœud ouvre le panneau détail (slide depuis la droite sur desktop, bottom sheet sur mobile) qui affiche : + +- Le numéro de vague et l'angle (Variation / Analogie / Disruption). +- Le titre et la description complète. +- Le **score d'originalité** (0–10) et l'auteur (IA ou humain). +- Le **lien avec la graine** : texte explicatif de la connexion logique. +- Les **notes source** qui ont inspiré l'idée (si la session est lancée depuis une note). + +**Actions disponibles dans le panneau :** + +| Action | Effet | +|--------|-------| +| ⭐ Étoile | Marque l'idée comme favorite (visible dans la vue liste) | +| **Creuser** | L'IA génère une vague supplémentaire à partir de cette idée | +| **Créer une note** | Convertit l'idée en note dans votre carnet (badge vert affiché) | +| **Pas pertinent** | Rejette l'idée (elle disparaît du canvas) | + +--- + +### Exporter la session + +Le bouton **Export** dans la barre d'actions ouvre une modale en deux étapes : + +1. **Bilan de session** : l'IA génère automatiquement une synthèse de 4 à 6 phrases résumant les thèmes explorés et proposant une prochaine action concrète. Vous pouvez la regénérer si elle ne vous convient pas. +2. **Exporter en note** : crée une note structurée dans votre carnet par défaut avec toutes les idées actives et la synthèse. + +> Fermer la modale sans cliquer sur "Exporter en note" n'exporte rien — vous pouvez consulter le bilan seul. + +--- + +### Gérer ses sessions + +**Historique des sessions :** la colonne de droite (icône History) liste toutes vos sessions sous forme de boutons lettrés (première lettre de la graine). Cliquer sur un bouton charge la session. + +**Renommer une session :** survoler le bouton de session actif fait apparaître une icône crayon. Cliquer dessus ouvre une boîte de dialogue pour modifier le titre (l'idée graine). + +--- + +### Collaboration en temps réel + +Cliquer sur **Inviter** permet de générer un lien de partage. Deux modes : + +- **Éditeur** : peut ajouter des idées, creuser, rejeter. +- **Lecteur** : peut consulter le canvas mais pas le modifier. + +Pendant la session : +- Les **curseurs** des autres participants sont visibles en direct (couleur unique par personne). +- Les **avatars** s'affichent dans la barre d'actions avec un indicateur vert "En direct". +- Le flux **Activité** (bouton Activity) liste les actions récentes de chaque participant. + +**Modèle host-pays :** l'hôte (créateur de la session) consomme le quota IA ou sa clé BYOK pour tous les participants — les invités n'ont pas besoin de compte payant. + +--- + +### Playback + +Le **Playback** (barre en bas du canvas) permet de rejouer l'historique de la session étape par étape, comme un film. Utile pour retracer l'évolution des idées ou présenter le raisonnement à une équipe. --- diff --git a/mcp-server/tools.js b/mcp-server/tools.js index b360cae..cd4d696 100644 --- a/mcp-server/tools.js +++ b/mcp-server/tools.js @@ -352,6 +352,63 @@ const toolDefinitions = [ inputSchema: { type: 'object', properties: {} }, }, + // ═══ TRASH ═══ + { + name: 'trash_note', + description: 'Move a note to trash (soft delete). Can be restored later.', + inputSchema: { + type: 'object', + properties: { id: { type: 'string', description: 'Note ID' } }, + required: ['id'], + }, + }, + { + name: 'restore_note', + description: 'Restore a trashed note back to the active notes list.', + inputSchema: { + type: 'object', + properties: { id: { type: 'string', description: 'Note ID' } }, + required: ['id'], + }, + }, + { + name: 'get_trash', + description: 'List all notes currently in trash.', + inputSchema: { type: 'object', properties: {} }, + }, + + // ═══ ADVANCED NOTE OPERATIONS ═══ + { + name: 'append_to_note', + description: 'Append text to the end of an existing note without replacing its current content.', + inputSchema: { + type: 'object', + properties: { + id: { type: 'string', description: 'Note ID' }, + content: { type: 'string', description: 'Content to append' }, + }, + required: ['id', 'content'], + }, + }, + { + name: 'find_and_update_note', + description: 'Find a note by searching its title/content, then append, prepend, or replace information. Use when you need to update a note found by keyword (e.g. "find the note about bugs and add this info").', + inputSchema: { + type: 'object', + properties: { + query: { type: 'string', description: 'Search query to find the note' }, + newContent: { type: 'string', description: 'Content to add to the note' }, + operation: { + type: 'string', + enum: ['append', 'prepend', 'replace'], + description: 'append: add to end, prepend: add to start, replace: overwrite', + default: 'append', + }, + }, + required: ['query', 'newContent'], + }, + }, + // ═══ LABELS ═══ { name: 'create_label', @@ -538,25 +595,32 @@ export function registerTools(server, prisma) { } case 'search_notes': { - const safeQuery = (args.query || '').replace(/'/g, "''"); - const userClause = uid ? `AND "userId" = '${uid}'` : ''; - const notebookClause = args.notebookId ? `AND "notebookId" = '${args.notebookId.replace(/'/g, "''")}'` : ''; + const query = args.query || '' + const params = [query] + let paramIdx = 1 - 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} - `); + const userClause = uid ? `AND "userId" = $${++paramIdx}` : '' + if (uid) params.push(uid) - return textResult(ftsRows.map(parseNoteLightweight)); + const notebookClause = args.notebookId ? `AND "notebookId" = $${++paramIdx}` : '' + if (args.notebookId) params.push(args.notebookId) + + 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', $1) + AND "trashedAt" IS NULL + AND "isArchived" = ${args.includeArchived ? 'true' : 'false'} + ${userClause} + ${notebookClause} + ORDER BY ts_rank("tsv", plainto_tsquery('simple', $1)) DESC + LIMIT ${DEFAULT_SEARCH_LIMIT}`, + ...params + ) + + return textResult(ftsRows.map(parseNoteLightweight)) } case 'move_note': { @@ -950,6 +1014,127 @@ export function registerTools(server, prisma) { return textResult({ count: reminders.length, reminders }); } + // ═══ TRASH ═══ + case 'trash_note': { + const note = await prisma.note.update({ + where: { id: args.id, ...(uid ? { userId: uid } : {}) }, + data: { trashedAt: new Date() }, + }); + return textResult({ success: true, id: note.id, trashedAt: note.trashedAt }); + } + + case 'restore_note': { + const note = await prisma.note.findUnique({ + where: { id: args.id, ...(uid ? { userId: uid } : {}) }, + }); + if (!note) throw new McpError(ErrorCode.InvalidRequest, 'Note not found'); + if (!note.trashedAt) return textResult({ success: true, message: 'Note was not in trash' }); + const restored = await prisma.note.update({ + where: { id: args.id }, + data: { trashedAt: null }, + }); + return textResult({ success: true, id: restored.id }); + } + + case 'get_trash': { + const where = { trashedAt: { not: null }, ...(uid ? { userId: uid } : {}) }; + const trashed = await prisma.note.findMany({ + where, + select: { id: true, title: true, content: true, color: true, trashedAt: true, notebookId: true }, + orderBy: { trashedAt: 'desc' }, + }); + return textResult(trashed.map(n => ({ + ...n, + content: n.content?.slice(0, 100), + }))); + } + + // ═══ ADVANCED NOTE OPERATIONS ═══ + case 'append_to_note': { + const note = await prisma.note.findUnique({ + where: { id: args.id, ...(uid ? { userId: uid } : {}), trashedAt: null }, + select: { content: true }, + }); + if (!note) throw new McpError(ErrorCode.InvalidRequest, 'Note not found'); + const updated = await prisma.note.update({ + where: { id: args.id }, + data: { + content: note.content ? `${note.content}\n\n${args.content}` : args.content, + updatedAt: new Date(), + }, + }); + return textResult({ success: true, id: updated.id }); + } + + case 'find_and_update_note': { + const query = args.query || '' + const operation = args.operation || 'append' + const params = [query] + let paramIdx = 1 + + const userClause = uid ? `AND "userId" = $${++paramIdx}` : '' + if (uid) params.push(uid) + + const results = await prisma.$queryRawUnsafe( + `SELECT id, title, content + FROM "Note" + WHERE "tsv" @@ plainto_tsquery('simple', $1) + AND "trashedAt" IS NULL + AND "isArchived" = false + ${userClause} + ORDER BY ts_rank("tsv", plainto_tsquery('simple', $1)) DESC + LIMIT 1`, + ...params + ) + + let note = results[0] || null + + if (!note) { + // Fallback: ILIKE search + note = await prisma.note.findFirst({ + where: { + trashedAt: null, + isArchived: false, + ...(uid ? { userId: uid } : {}), + OR: [ + { title: { contains: query, mode: 'insensitive' } }, + { content: { contains: query, mode: 'insensitive' } }, + ], + }, + select: { id: true, title: true, content: true }, + }) + } + + if (!note) { + throw new McpError(ErrorCode.InvalidRequest, `No note found matching "${query}"`) + } + + let updatedContent + switch (operation) { + case 'append': + updatedContent = note.content ? `${note.content}\n\n${args.newContent}` : args.newContent + break + case 'prepend': + updatedContent = note.content ? `${args.newContent}\n\n${note.content}` : args.newContent + break + case 'replace': + default: + updatedContent = args.newContent + } + + await prisma.note.update({ + where: { id: note.id }, + data: { content: updatedContent, updatedAt: new Date() }, + }) + + return textResult({ + success: true, + noteId: note.id, + noteTitle: note.title || 'Untitled', + operation, + }) + } + default: throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`); } diff --git a/memento-note/app/(main)/agents/agents-page-client.tsx b/memento-note/app/(main)/agents/agents-page-client.tsx index 131b299..3349b83 100644 --- a/memento-note/app/(main)/agents/agents-page-client.tsx +++ b/memento-note/app/(main)/agents/agents-page-client.tsx @@ -3,7 +3,7 @@ import { useState, useCallback, useMemo, useEffect, useRef } from 'react' import { motion } from 'motion/react' -import { Plus, Bot, Search, LifeBuoy } from 'lucide-react' +import { Plus, Bot, Search, LifeBuoy, Menu } from 'lucide-react' import { toast } from 'sonner' import { useLanguage } from '@/lib/i18n' @@ -218,10 +218,18 @@ export function AgentsPageClient({ /> ) : ( <> -
-
-
-

+
+
+ {/* Hamburger mobile */} + +
+

{t('agents.myAgents')}

@@ -246,13 +254,13 @@ export function AgentsPageClient({

-
-
+
+
{typeFilterOptions.map((opt, i) => ( ))}
-
+
-
+
{agents.length === 0 ? (
diff --git a/memento-note/app/(main)/graph/page.tsx b/memento-note/app/(main)/graph/page.tsx new file mode 100644 index 0000000..4519ffa --- /dev/null +++ b/memento-note/app/(main)/graph/page.tsx @@ -0,0 +1,17 @@ +'use client' + +import dynamic from 'next/dynamic' + +// D3 uses browser APIs — must be loaded client-side only +const NoteGraphView = dynamic( + () => import('@/components/note-graph-view').then(m => m.NoteGraphView), + { ssr: false } +) + +export default function GraphPage() { + return ( +
+ +
+ ) +} diff --git a/memento-note/app/(main)/settings/layout.tsx b/memento-note/app/(main)/settings/layout.tsx index 3c94bcd..2267f60 100644 --- a/memento-note/app/(main)/settings/layout.tsx +++ b/memento-note/app/(main)/settings/layout.tsx @@ -1,5 +1,6 @@ 'use client' +import { Menu } from 'lucide-react' import { SettingsNav } from '@/components/settings' export default function SettingsLayout({ @@ -9,21 +10,30 @@ export default function SettingsLayout({ }) { return (
-
-
-

- Paramètres -

-

- Configuration & Préférences -

+
+
+ +
+

+ Paramètres +

+

+ Configuration & Préférences +

+
-
+
{children}
diff --git a/memento-note/app/(main)/support/page.tsx b/memento-note/app/(main)/support/page.tsx index 8f25ee9..36050d0 100644 --- a/memento-note/app/(main)/support/page.tsx +++ b/memento-note/app/(main)/support/page.tsx @@ -10,7 +10,7 @@ export default function SupportPage() { return (
-

+

{t('support.title')}

diff --git a/memento-note/app/(main)/trash/trash-client.tsx b/memento-note/app/(main)/trash/trash-client.tsx index ddd290b..fb8865a 100644 --- a/memento-note/app/(main)/trash/trash-client.tsx +++ b/memento-note/app/(main)/trash/trash-client.tsx @@ -13,6 +13,7 @@ import { Search, Clock, AlertCircle, + Menu, } from 'lucide-react' import { Note, Notebook } from '@/lib/types' import { restoreNote, permanentDeleteNote, emptyTrash } from '@/app/actions/notes' @@ -139,16 +140,26 @@ export function TrashClient({ return (

-
-
-
-

- {t('sidebar.trash')} -

-

- {t('trash.autoDelete30')} -

-
+
+
+
+ {/* Hamburger mobile */} + +
+

+ {t('sidebar.trash')} +

+

+ {t('trash.autoDelete30')} +

+
+
{/* end flex items-center gap-3 */} {items.length > 0 && (
-
+
{items.length > 0 ? ( -
+
{items.map(item => { const daysLeft = getDaysRemaining(item.deletedAt) @@ -281,7 +292,7 @@ export function TrashClient({ )}
-