diff --git a/_bmad-output/brainstorming/brainstorming-session-2026-04-13-133700.md b/_bmad-output/brainstorming/brainstorming-session-2026-04-13-133700.md new file mode 100644 index 0000000..c8429ae --- /dev/null +++ b/_bmad-output/brainstorming/brainstorming-session-2026-04-13-133700.md @@ -0,0 +1,635 @@ +--- +stepsCompleted: [1, 2, 3] +inputDocuments: [] +session_topic: 'Nouvelles fonctionnalités IA pour Keep (Memento) au-delà du roadmap existant' +session_goals: 'Générer des idées innovantes et différenciantes de fonctionnalités IA non couvertes par les phases 1-4 déjà planifiées' +selected_approach: 'ai-recommended' +techniques_used: ['Cross-Pollination', 'Reversal Inversion', 'SCAMPER Method'] +ideas_generated: [] +context_file: '' +mode: 'autonomous' +--- + +# Brainstorming Session Results + +**Facilitator:** Ramez +**Date:** 2026-04-13 + +## Session Overview + +**Topic:** Nouvelles fonctionnalités IA pour Keep (Memento) +**Goals:** Générer des idées innovantes et différenciantes de fonctionnalités IA non couvertes par les phases 1-4 déjà planifiées + +### Context Guidance + +_Projet Keep est une application de notes self-hosted, privacy-first, avec architecture multi-provider IA (OpenAI, Ollama, DeepSeek, OpenRouter, Custom). Phase 1 MVP AI déjà implémentée : suggestions de titre, recherche sémantique, reformulation, Memory Echo, fusion de notes, auto-tagging, organisation par lot (15 langues), détection de langue (62 langues). Roadmap Phase 2-4 couvre : OCR images, résumés URLs, Chat RAG, vue graphe, Voice-to-Note, Super AI mode, templates IA, organisation autonome._ + +### Session Setup + +_Session axée sur la découverte de fonctionnalités IA totalement nouvelles, hors du roadmap existant, qui capitalisent sur l'architecture existante et le positionnement "Zero-Friction Intelligence" du produit._ + +## Technique Selection + +**Approach:** AI-Recommended Techniques (Mode Autonome) +**Analysis Context:** Fonctionnalités IA nouvelles pour Keep avec focus sur innovation hors roadmap + +**Recommended Techniques:** + +- **Cross-Pollination:** Transférer des solutions d'industries différentes (santé, jeux, musique, éducation) +- **Reversal Inversion:** Retourner le problème pour révéler des angles cachés +- **SCAMPER Method:** Affiner les meilleures idées à travers 7 lentilles systématiques + +--- + +## Phase 1: Cross-Pollination — Idées venues d'ailleurs + +_Sources d'inspiration : santé mentale, jeux vidéo, musique, éducation, fitness wearables, reseaux sociaux, AI second brain_ + +### Idée 1 : Mood Weaving (inspiré des apps de journaling santé mentale) +L'IA analyse le sentiment et le ton émotionnel de chaque note au fil du temps. Un "tapis émotionnel" (mood tapestry) se tisse visuellement — un graphique de couleurs qui montre vos états émotionnels récurrents. L'IA détecte les cycles ("Tu es plus anxieux le dimanche soir", "Tes notes de voyages sont systématiquement plus positives"). **Différenciateur :** Aucune app de notes ne fait d'analyse émotionnelle longitudinale. Utilise le provider IA existant + TinyLD pour corréler langue et émotion. + +### Idée 2 : Knowledge Podcast Generator (inspiré de Google NotebookLM) +Transformez un notebook ou un ensemble de notes en un **podcast audio généré par IA** — deux voix AI discutent de vos notes comme une émission radio. Idéal pour réviser ses notes de cours en marchant, ou consommer sa propre base de connaissance en mobilité. **Différenciateur :** Aucune app de notes self-hosted n'offre ça. Utilise les embeddings existants pour sélectionner le contenu pertinent. + +### Idée 3 : Spaced Resurfacing (inspiré d'Anki et la répétition espacée) +Au lieu de simplement connecter des notes (Memory Echo), l'IA **resurface les notes que vous êtes sur le point d'oublier** basé sur la courbe d'oubli d'Ebbinghaus. Une note revient dans votre flux exactement au moment optimal pour la revoir. **Différenciateur :** Transforme Keep d'un simple outil de notes en un **système d'apprentissage actif**. Capitalise sur les embeddings existants pour mesurer la relatedness. + +### Idée 4 : Thought Trajectory (inspiré des wearables fitness) +Comme une montre qui trace votre évolution physique, l'IA trace l'**évolution de votre pensée** sur un sujet. Vous avez écrit 15 notes sur "l'IA" en 6 mois ? L'IA vous montre comment votre position a évolué, quels concepts vous avez approfondis, lesquels vous avez abandonnés. **Différenciateur :** Visualisation temporelle de l'évolution intellectuelle. Utilise la recherche sémantique existante pour clusterer par sujet. + +### Idée 5 : Note DNA (inspiré de Spotify DNA / musical fingerprinting) +Chaque note reçoit un "ADN" visuel unique basé sur ses caractéristiques IA : longueur, sentiment, langue, complexité, topics, connexions. Un petit badge visuel qui permet de **reconnaître instantanément** le type de note au coup d'oeil dans le masonry grid. **Différenciateur :** Approche visuelle/biomimétique unique. Les métadonnées IA existent déjà (embedding, langue, confidence). + +### Idée 6 : Ghost Writer Mode (inspiré des "digital twins" comme Personal.ai) +L'IA apprend votre **style d'écriture** à partir de toutes vos notes existantes. Quand vous commencez à écrire, elle complète les phrases dans VOTRE voix, pas une voix générique. Plus vous écrivez, plus elle vous ressemble. **Différenciateur :** Privacy-first (modèle local via Ollama) — votre clone d'écriture ne quitte jamais votre serveur. Unique vs les apps cloud. + +### Idée 7 : Contextual Nudge Engine (inspiré des wearables et nudge theory) +Au lieu de suggestions IA uniquement quand l'utilisateur le demande, l'IA **détecte le bon moment** pour intervenir. Exemple : vous écrivez 3 notes sur le même sujet en 2 jours → l'IA vous suggère doucement de les fusionner ou créer un notebook. Vous n'avez pas écrit depuis 5 jours → l'IA resurface une note ancienne pertinente. **Différenciateur :** "Zero-Friction Intelligence" poussé à son paroxysme — l'IA est proactive mais non-intrusive. + +### Idée 8 : Knowledge Decay Indicator (inspiré de la physique radioactive) +Chaque note a un **indicateur de "demi-vie"** — un score qui diminue avec le temps si la note n'est ni consultée, ni connectée, ni mise à jour. Les notes "mortes" apparaissent différemment visuellement. L'IA vous alerte quand un concept important est en train de "se dégrader" dans votre base de connaissance. **Différenciateur :** Transforme la gestion de notes en gestion active de connaissance. + +### Idée 9 : Daily Note Cocktail (inspiré de l'industrie du gaming — daily rewards) +Chaque jour, l'IA prépare un "cocktail" personnalisé de 3 notes : une revisitée (spaced resurfacing), une connexion inattendue (memory echo), et une "nouvelle perspective" (l'IA reformule une vieille note à la lumière de vos notes récentes). Un petit rituel quotidien qui encourage l'engagement. **Différenciateur :** Mécanique de rétention empruntée au gaming, adaptée à la connaissance. + +### Idée 10 : Note-to-Timeline (inspiré des timelines de projets et GitHub) +L'IA reconstruit automatiquement une **timeline narrative** à partir de vos notes sur un sujet. Au lieu de voir des notes isolées, vous voyez une histoire : "Comment votre projet X a évolué de l'idée à la réalisation" avec les notes comme chapitres. **Différenciateur :** Narrative intelligence — transforme des fragments en récit cohérent. + +--- + +## Phase 2: Reversal Inversion — Retourner le problème + +_Et si on inversait les hypothèses fondamentales d'une app de notes ?_ + +### Idée 11 : Reverse Search (Et si c'était la note qui vous cherchait ?) +Au lieu de l'utilisateur qui cherche une note, les notes "postulent" pour être utiles. Quand vous ouvrez l'app, les notes les plus pertinentes compte tenu de l'heure, du jour, et de votre historique récent se placent en haut. **La note vient à vous.** + +### Idée 12 : Strategic Forgetting (Et si l'IA oubliait délibérément ?) +L'IA marque certaines notes comme "archivées cognitivement" — elles restent accessibles mais sont retirées du flux actif pour réduire la surcharge informationnelle. L'inverse du "tout garder". L'IA décide ce que vous n'avez plus besoin de voir régulièrement, en se basant sur vos patterns de consultation. + +### Idée 13 : Anti-Organization (Et si moins de structure = plus de valeur ?) +Un mode "Zen" où l'IA supprime toutes les étiquettes, notebooks et métadonnées visuelles. Vous voyez uniquement vos notes, nues. L'IA gère toute l'organisation en arrière-plan. L'utilisateur n'a qu'à écrire. **La désorganisation visible est un choix, pas un problème.** + +### Idée 14 : Note Dissolve (Et si les notes pouvaient fusionner et disparaître ?) +Quand deux notes deviennent trop similaires (seuil de similarité élevé), l'IA propose de les "dissoudre" en une seule note enrichie, en supprimant les redondances. La note résultante est meilleure que chacune individuellement. **La réduction, pas l'accumulation, crée la valeur.** + +### Idée 15 : Silent AI (Et si l'IA se taisait complètement ?) +Un mode où l'IA travaille entièrement en silence — elle organise, connecte, tag, resurface — mais l'utilisateur ne voit JAMAIS de suggestions, badges, ou prompts. L'effet se ressent indirectement : les bonnes notes sont toujours au bon endroit, les connexions se font seules. **L'IA parfaite est invisible.** + +### Idée 16 : Note-as-Question (Et si chaque note générait des questions au lieu de réponses ?) +L'IA lit votre note et génère 3 questions que vous n'avez PAS posées. "Tu as écrit sur X, mais as-tu considéré Y ?" "C'est intéressant, mais qu'en est-il de Z ?" **La note ne clôt pas la pensée, elle l'ouvre.** + +### Idée 17 : Reverse Onboarding (Et si l'app apprenait de vous au lieu de vous apprendre ?) +Pendant les 7 premiers jours, l'IA observe silencieusement COMMENT vous prenez des notes — style, longueur, fréquence, sujets. Ensuite, l'app s'adapte à VOTRE workflow au lieu de vous forcer dans un template prédéfini. **L'utilisateur ne remplit jamais un questionnaire de setup.** + +### Idée 18 : Collaborative Silence (Et si le partage de notes n'avait pas de chat ?) +Au lieu d'un système de commentaires/discussion, les collaborateurs partagent des notes et l'IA détecte les **zones de friction ou désaccord** entre les notes de différentes personnes, et génère un "rapport de tensions" productif. **Pas de chat, pas de bruit — juste de l'intelligence.** + +### Idée 19 : Time-Release Notes (Et si les notes avaient une date de sortie ?) +L'utilisateur peut écrire une note et la "sceller" avec une date future. La note n'apparaît dans le flux qu'à la date prévue. Comme une capsule temporelle. L'IA peut aussi suggérer des dates : "Cette réflexion pourrait te concerner dans 3 mois." **Le temps comme filtre actif.** + +### Idée 20 : Emotion-First Organization (Et si l'organisation se faisait par émotion, pas par sujet ?) +L'IA classe vos notes non pas par notebook/tag mais par état émotionnel : "Notes écrites dans un moment d'enthousiasme", "Notes de doute", "Notes de découverte". Une dimension d'organisation totalement nouvelle. **Votre base de connaissance reflète qui vous étiez quand vous l'avez écrite.** + +--- + +## Phase 3: SCAMPER — Affinage des meilleures idées + +_7 lentilles appliquées aux concepts les plus prometteurs_ + +### S — Substitute (Remplacer) + +**Idée 21 : Visual Note Identity (substitution de l'ADN note)** +Au lieu de badges de couleur simples, chaque note reçoit un **mini-glyph unique généré par IA** — un symbole abstrait qui encode sa signature sémantique. Comme les favicons mais pour les idées. Visuellement unique et mémorable. + +### C — Combine (Combiner) + +**Idée 22 : Daily Cocktail + Spaced Resurfacing = "Keep Daily"** +Combiner les idées 3 et 9 en une seule feature "Keep Daily" : chaque matin, une page personnalisée avec : +- 1 note à reviser (spaced repetition) +- 1 connexion inattendue (memory echo) +- 1 perspective nouvelle (reformulation IA d'une vieille note) +- 1 insight émotionnel (mood weaving) +- Le tout dans une interface éphémère qui change chaque jour. + +**Idée 23 : Ghost Writer + Note DNA = "Your Voice Profile"** +Combiner l'apprentissage de style (idée 6) avec le fingerprinting de note (idée 5) pour créer un **profil vocal d'écriture** unique. L'UI montre votre "identité d'écrivain" : ton, longueur moyenne, vocabulaire préféré, patterns récurrents. Comme Spotify Wrapped, mais pour votre écriture. + +### A — Adapt (Adapter) + +**Idée 24 : Knowledge Podcast → Notebook Audiobook** +Adapter l'idée du podcast (idée 2) en quelque chose de plus simple techniquement : un **résumé audio lu par TTS** d'un notebook entier. Moins ambitieux qu'un podcast dialogué, plus facile à implémenter, et tout aussi utile pour consommer ses notes en marchant. + +**Idée 25 : Thought Trajectory → "Thinking Map"** +Adapter l'idée 4 en une carte visuelle style "skill tree" de jeux vidéo. Chaque topic est un noeud, et l'arbre montre comment vos pensées ont branché, mergé, ou cul-de-sac. Utilise React Flow (déjà dans le roadmap Memory Echo V2). + +### M — Modify (Modifier) + +**Idée 26 : Contextual Nudge Engine → "AI Concierge"** +Modifier l'idée 7 en un personnage IA visible (un petit assistant dans un coin de l'écran) qui commente intelligemment votre activité. Pas intrusif, mais présent. Comme Clippy mais intelligent et optionnel. Utilise le provider IA pour générer des micro-commentaires contextuels. + +**Idée 27 : Knowledge Decay → "Note Health Score"** +Modifier l'idée 8 en un système de **score de santé** pour chaque note (0-100) basé sur : fraîcheur (quand dernière mise à jour), connectivité (combien de liens vers elle), consultation (fréquence de lecture), richesse (longueur, médias). Un dashboard "Santé de votre base de connaissance". + +### P — Put to Other Uses (Autres usages) + +**Idée 28 : Keep comme outil de thérapie réflexive** +Utiliser le Mood Weaving (idée 1) + les Note-as-Question (idée 16) pour créer un mode **"Reflective Journal"** — Keep devient un outil de thérapie réflexive guidée par IA. L'IA pose des questions profondes, suit vos patterns émotionnels, et vous aide à prendre du recul. Positionnement unique : ni app de notes, ni app de thérapie, mais un hybride. + +**Idée 29 : Keep comme outil d'enseignement** +Utiliser le Spaced Resurfacing (idée 3) + le Knowledge Podcast (idée 2) pour créer un mode **"Study Mode"** — Keep devient un outil d'apprentissage actif. L'étudiant prend des notes de cours, Keep gère la révision espacée, génère des quiz automatiques, et produit des résumés audio pour réviser en marchant. + +### E — Eliminate (Éliminer) + +**Idée 30 : Silent AI + Anti-Organization = "Zen Mode"** +Combiner les idées 13 et 15 en un mode radical : **aucune interface IA visible**. L'utilisateur voit uniquement ses notes brutes. Mais en arrière-plan, l'IA organise, connecte, et optimise tout. Quand l'utilisateur a besoin d'une note, elle est toujours exactement là où il s'attend à la trouver. L'IA est un **invisible concierge**. + +### R — Reverse (Inverser) + +**Idée 31 : Reverse Search → "Serendipity Engine"** +Pousser l'idée 11 plus loin : au lieu de chercher, vous recevez une **"feed de sérendipité"** — un flux continu de notes que vous ne cherchiez pas mais qui sont exactement ce dont vous avez besoin. Comme un fil Instagram, mais pour vos propres notes. Alimenté par le contexte temporel, l'historique récent, et les patterns comportementaux. + +--- + +## Top 10 — Sélection finale + +_Classement par combinaison : innovation, faisabilité technique (avec l'architecture existante), et différenciation marché_ + +| # | Nom | Description courte | Pourquoi c'est un pépites | +|---|-----|--------------------|---------------------------| +| 1 | **Keep Daily** | Page quotidienne personnalisée : révision, connexion, perspective, insight émotionnel | Combine 4 features en 1 rituel quotidien. Mécanique de rétention gaming. Réutilise Memory Echo, reformulation, embeddings existants. | +| 2 | **Spaced Resurfacing** | Les notes réapparaissent au moment optimal basé sur la courbe d'oubli | Transforme Keep en système d'apprentissage actif. Personne ne fait ça dans les apps de notes. Utilise les embeddings existants. | +| 3 | **Mood Tapestry** | Analyse émotionnelle longitudinale des notes avec visualisation | Positionnement unique santé mentale x notes. Privacy-first = avantage compétitif vs apps cloud. | +| 4 | **Ghost Writer** | Autocomplétion dans votre style personnel appris par IA | Clone d'écriture local (Ollama) = pitch marketing puissant. Plus vous écrivez, mieux c'est. | +| 5 | **Note Health Score** | Dashboard de santé de votre base de connaissance (0-100 par note) | Gamification subtile. Encourage l'entretien actif. Utilise les métadonnées IA existantes. | +| 6 | **Thought Trajectory** | Visualisation de l'évolution de votre pensée sur un sujet dans le temps | "Spotify Wrapped pour votre cerveau". Unique sur le marché. Utilise la recherche sémantique existante. | +| 7 | **Notebook Audiobook** | Résumé audio TTS d'un notebook entier | Consommer ses notes en marchant. Simple techniquement. Forte demande mobile. | +| 8 | **Note-as-Question** | L'IA génère 3 questions que vous n'avez PAS posées après chaque note | Transforme l'écriture passive en pensée active. Aligné avec "Zero-Friction Intelligence". | +| 9 | **Serendipity Engine** | Feed de notes pertinentes que vous ne cherchiez pas | Remplace le search par la découverte. "La note vient à vous." Utilise embeddings + contexte temporel. | +| 10 | **Reflective Journal Mode** | Mode thérapie réflexive guidée par IA avec suivi émotionnel | Nouveau segment de marché. Positionnement hybride unique. Privacy-first = confiance. | + +--- + +## Features Sélectionnées — Spécifications Détaillées + +_Les 3 features retenues après validation utilisateur, avec analyse d'intégration technique dans l'architecture existante_ + +--- + +### Feature A : Mood Tapestry (Tapis Émotionnel) + +**Vision :** L'IA analyse le sentiment de chaque note au fil du temps et tisse un "tapis" visuel des états émotionnels de l'utilisateur. Détection de cycles, tendances, et patterns récurrents. + +#### Expérience Utilisateur + +**Vue principale :** Un ruban coloré horizontal dans le sidebar ou une page dédiée `/mood`. Chaque pixel représente une note, colorée par sentiment (rouge=anxieux, bleu=calme, vert=positif, jaune=enthousiaste, gris=neutre). Le tout forme un gradient continu chronologique. + +**Insights IA :** Sous le ruban, 2-3 insights générés par IA : +- "Tes notes du dimanche soir sont 40% plus anxieuses que la moyenne" +- "Depuis mars, tes notes sur le travail sont devenues plus positives" +- "Cycle détecté : pics d'anxiété tous les 14 jours environ" + +**Badge note :** Chaque note reçoit un petit indicateur coloré de son sentiment dominant. + +**Notification proactive :** "Cette semaine, ton Mood Tapestry montre une tendance inhabituelle vers le négatif. Voici 3 notes positives de ton passé qui pourraient t'aider." + +#### Architecture Technique + +**Nouveau service :** `lib/ai/services/mood-tapestry.service.ts` + +``` +MoodTapestryService +├── analyzeSentiment(content: string, language: string) → SentimentResult +│ → Appelle provider.generateText() avec prompt structuré +│ → Retourne: { valence: number, arousal: number, emotions: string[], confidence: number } +│ +├── analyzeNoteBatch(userId: string) → MoodSnapshot[] +│ → Lit toutes les notes de l'utilisateur (avec langue détectée) +│ → Batch processing avec rate limiting +│ → Stocke résultats dans NoteSentiment (nouveau modèle) +│ +├── generateInsights(userId: string) → MoodInsight[] +│ → Analyse les patterns temporels des sentiments stockés +│ → Détecte cycles, tendances, anomalies +│ → Appelle provider.generateText() pour formuler les insights en langage naturel +│ +├── getMoodTimeline(userId: string, period: 'week'|'month'|'year') → MoodDataPoint[] +│ → Agrège les sentiments par jour/semaine pour la visualisation +``` + +**Nouveau modèle Prisma :** +```prisma +model NoteSentiment { + id String @id @default(cuid()) + noteId String @unique + note Note @relation(fields: [noteId], references: [id], onDelete: Cascade) + userId String + valence Float // -1.0 (négatif) à +1.0 (positif) + arousal Float // 0.0 (calme) à 1.0 (intensif) + dominantEmotion String // "anxious", "joyful", "calm", "frustrated", "curious", "nostalgic", "neutral" + emotions String // JSON array: ["curious", "hopeful", "anxious"] + confidence Float // 0.0-1.0 + analyzedAt DateTime @default(now()) + + @@index([userId, analyzedAt]) + @@index([dominantEmotion]) +} +``` + +**Extensions Prisma existantes :** +```prisma +model UserAISettings { + // ... champs existants + moodTapestry Boolean @default(true) // ON/OFF toggle + moodTapestryFrequency String @default('weekly') // 'daily'|'weekly'|'monthly' +} + +model AiFeedback { + // Réutiliser le modèle existant avec feature: 'mood_tapestry' +} +``` + +**Prompt IA (structure) :** +``` +Analyze the emotional sentiment of this note. Return JSON: +{ + "valence": , + "arousal": , + "dominant_emotion": "", + "emotions": ["", ""], + "confidence": , + "reasoning": "" +} + +Note content (in {language}): +""" +{content} +""" +``` + +**Points d'intégration :** +- **Trigger :** `analyzeSentiment()` appelé après la création/mise à jour d'une note (dans le même flow que `language-detection.service.ts`) +- **Batch :** Route admin ou cron pour analyser les notes existantes +- **UI :** Nouveau composant `components/ai/mood-tapestry.tsx` (ruban coloré + insights) +- **Settings :** Toggle dans `ai-settings-panel.tsx` + fréquence + +**Faisabilité avec l'architecture existante :** +| Aspect | Compatible ? | Détail | +|--------|-------------|--------| +| Provider IA | ✅ | `provider.generateText()` — même pattern que paragraph-refactor | +| Embeddings existants | ✅ | Peut corréler sentiment avec similarité sémantique | +| Langue détectée | ✅ | `Note.language` déjà disponible pour adapter le prompt | +| Privacy-first | ✅ | Ollama local = analyse émotionnelle 100% locale | +| Feedback | ✅ | `AiFeedback` avec `feature: 'mood_tapestry'` | + +**Risques :** +- L'analyse émotionnelle est subjective — le feedback utilisateur est critique pour ajuster +- Sur Ollama avec un petit modèle, la qualité sentiment peut être variable — prévoir un seuil de confiance minimum + +--- + +### Feature B : Ghost Writer (Clone d'Écriture) + +**Vision :** L'IA apprend votre style d'écriture à partir de toutes vos notes existantes. Quand vous écrivez, elle suggère des complétions dans VOTRE voix — pas une voix générique. Plus vous écrivez, plus elle vous ressemble. + +#### Expérience Utilisateur + +**Écriture assistée :** Dans l'éditeur de note, après chaque phrase, une suggestion fantôme (texte grisé) apparaît en inline — pas dans un panneau séparé. L'utilisateur appuie sur Tab pour accepter, Escape pour ignorer, ou continue à taper pour la remplacer. + +**Voice Profile :** Dans les paramètres IA, un onglet "Mon Profil Vocal" montre : +- Votre ton dominant (formel, décontracté, technique, poétique) +- Longueur moyenne de phrases +- Vocabulaire préféré (top 20 mots distinctifs) +- Structures récurrentes (listes, questions, exclamations) +- Un bouton "Réanalyser mon style" pour recalibrer + +**Apprentissage progressif :** Le profil s'améliore avec chaque note écrite. Un indicateur montre le "niveau de calibration" : "Profil basé sur 47 notes — Bonne calibration" + +**Mode privacy :** Avec Ollama, le profil et les suggestions ne quittent jamais le serveur. Pitch marketing fort. + +#### Architecture Technique + +**Nouveau service :** `lib/ai/services/ghost-writer.service.ts` + +``` +GhostWriterService +├── buildVoiceProfile(userId: string) → VoiceProfile +│ → Lit les 50 dernières notes de l'utilisateur +│ → Analyse: longueur phrases, vocabulaire, structures, ton +│ → Appelle provider.generateText() pour extraire le profil stylistique +│ → Stocke dans VoiceProfile (nouveau modèle) +│ +├── getSuggestion(userId: string, currentText: string, context: string) → string +│ → Charge le VoiceProfile de l'utilisateur +│ → Construit un prompt avec le profil + le texte en cours +│ → Appelle provider.generateText() avec maxTokens limité (50-100) +│ → Retourne la suggestion inline +│ +├── updateProfileFromNote(userId: string, noteId: string) → void +│ → Après sauvegarde d'une note, met à jour incrémentalement le profil +│ → Recalcul: fréquence mots, patterns syntaxiques, ton +│ +├── getVoiceProfileDisplay(userId: string) → VoiceProfileDisplay +│ → Retourne le profil formaté pour l'UI (ton, vocabulaire, stats) +``` + +**Nouveau modèle Prisma :** +```prisma +model VoiceProfile { + id String @id @default(cuid()) + userId String @unique + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + // Profil stylistique extrait par IA + toneProfile String // JSON: { primary: "décontracté", secondary: "technique", formality: 0.3 } + vocabularyProfile String // JSON: { topWords: [...], avgWordLength: 5.2, uniqueRatio: 0.7 } + structureProfile String // JSON: { avgSentenceLength: 15, usesLists: true, usesQuestions: false, ... } + writingPatterns String // JSON: { commonPhrases: [...], paragraphLength: "medium", ... } + + // Métadonnées + notesAnalyzed Int @default(0) + calibrationLevel String @default("low") // "low" | "medium" | "high" | "expert" + lastAnalyzedAt DateTime @default(now()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([userId]) +} +``` + +**Extensions Prisma existantes :** +```prisma +model UserAISettings { + // ... champs existants + ghostWriter Boolean @default(true) // ON/OFF toggle +} +``` + +**Prompt IA — Extraction de profil (buildVoiceProfile) :** +``` +Analyze the writing style of these notes from the same author. Extract a writing profile as JSON: +{ + "tone": { "primary": "", "secondary": "", "formality": <0-1> }, + "vocabulary": { "top_words": ["", ...], "avg_word_length": , "unique_ratio": <0-1> }, + "structure": { "avg_sentence_length": , "uses_lists": , "uses_questions": , "paragraph_style": "" }, + "patterns": { "common_phrases": ["", ...], "punctuation_style": "" } +} + +Author's notes: +""" +{notes_concatenated} +""" +``` + +**Prompt IA — Suggestion inline (getSuggestion) :** +``` +You are a ghost writer that perfectly mimics the author's writing style. + +AUTHOR'S STYLE PROFILE: +- Tone: {toneProfile} +- Average sentence length: {avgSentenceLength} words +- Common phrases: {commonPhrases} +- The author writes in {language} + +Continue this text in the author's EXACT voice. Write only the continuation (1-3 sentences), nothing else: + +""" +{currentText} +""" +``` + +**Points d'intégration :** +- **Trigger :** `getSuggestion()` appelé après un délai d'inactivité de 1.5s dans l'éditeur (debounced) +- **Apprentissage :** `updateProfileFromNote()` appelé après sauvegarde d'une note +- **UI :** Nouveau composant `components/ai/ghost-writer-suggestion.tsx` (texte grisé inline dans l'éditeur) +- **Settings :** Toggle dans `ai-settings-panel.tsx` + page "Voice Profile" +- **API Route :** `app/api/ai/ghost-writer/route.ts` pour les suggestions en temps réel + +**Faisabilité avec l'architecture existante :** +| Aspect | Compatible ? | Détail | +|--------|-------------|--------| +| Provider IA | ✅ | `provider.generateText()` — même pattern, mais nécessite low latency | +| Embeddings | ❌ pas nécessaire | Pas besoin d'embeddings pour cette feature | +| Notes existantes | ✅ | Source d'entraînement = toutes les notes de l'utilisateur | +| Privacy-first | ✅✅ | C'est LE cas d'usage parfait pour Ollama — votre style ne quitte pas le serveur | +| Feedback | ✅ | `AiFeedback` avec `feature: 'ghost_writer'` — accept/reject rate comme métrique | + +**Risques :** +- **Latence critique :** Les suggestions inline doivent être < 500ms. Sur Ollama avec un petit modèle local, c'est jouable pour de courts textes. Sur OpenAI, c'est rapide mais moins privacy. +- **Qualité du profil :** Avec < 10 notes, le profil sera imprécis. Prévoir un minimum avant activation ou afficher un avertissement. +- **Sur-Ollama :** Les petits modèles (phi3-mini, etc.) peuvent avoir du mal à capturer un style subtil. Tester avec llama3.1+ pour de meilleurs résultats. + +**Pitch marketing unique :** +> "Ghost Writer apprend votre voix. Votre clone d'écriture vit sur VOTRE serveur. Personne d'autre ne peut lire votre style — pas même nous. C'est le pouvoir de l'IA locale." + +--- + +### Feature C : Thought Trajectory (Trajectoire de Pensée) + +**Vision :** L'IA trace l'évolution de votre pensée sur un sujet au fil du temps. Vous visualisez comment vos idées ont branché, mergé, ou changé de direction. "Spotify Wrapped pour votre cerveau." + +#### Expérience Utilisateur + +**Vue Thinking Map :** Une page dédiée `/thinking-map` avec une visualisation interactive style "skill tree" ou constellation. Chaque noeud = un cluster de notes sur un sujet. Les liens montrent les connexions sémantiques. Le temps est représenté par la position ou la couleur (plus récent = plus lumineux). + +**Timeline d'un sujet :** En cliquant sur un noeud, une vue détaillée montre l'évolution chronologique : +- "Mars 2025 : Première mention — curieux, exploratoire" +- "Juin 2025 : Approfondissement — 4 notes, ton plus technique" +- "Septembre 2025 : Changement — position plus critique" +- "Janvier 2026 : Consolidation — 2 notes de synthèse" + +**Wrapped annuel/mensuel :** Une page "Mon Année en Pensées" style Spotify Wrapped : +- Top 5 sujets les plus explorés +- "Plus grande évolution" — sujet où votre pensée a le plus changé +- "Connexions surprises" — sujets éloignés que vous avez connectés +- Stats : nombre de notes, mots, langues utilisées, diversité émotionnelle + +**Insight proactif :** "Ton opinion sur [X] a significativement changé ce mois-ci. Voir la trajectoire →" + +#### Architecture Technique + +**Nouveau service :** `lib/ai/services/thought-trajectory.service.ts` + +``` +ThoughtTrajectoryService +├── clusterNotesByTopic(userId: string) → TopicCluster[] +│ → Utilise les embeddings existants (Note.embedding) +│ → K-means ou clustering hiérarchique sur les vecteurs +│ → Identifie les topics/thèmes récurrents +│ → Appelle provider.generateText() pour nommer chaque cluster +│ +├── buildTrajectory(userId: string, topicClusterId: string) → ThoughtTrajectory +│ → Trie les notes du cluster chronologiquement +│ → Analyse l'évolution sémantique entre les notes successives +│ → Détecte les "shifts" (changements de direction significatifs) +│ → Génère une narrative de l'évolution +│ +├── detectEvolutionShifts(notes: Note[]) → EvolutionShift[] +│ → Compare les embeddings consécutifs dans un cluster +│ → Quand la distance cosinus dépasse un seuil → "shift" détecté +│ → provider.generateText() pour décrire le changement +│ +├── generateWrapped(userId: string, period: 'month'|'year') → ThoughtWrapped +│ → Récapitulatif statistique + narratif +│ → Top topics, plus grande évolution, connexions surprises +│ → provider.generateText() pour la narration +│ +├── getThinkingMap(userId: string) → ThinkingMapData +│ → Retourne les clusters + liens pour React Flow +│ → Positionnement basé sur similarité (t-SNE simplifié ou force-directed) +``` + +**Nouveau modèle Prisma :** +```prisma +model TopicCluster { + id String @id @default(cuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + name String // Nom généré par IA : "Intelligence Artificielle" + description String? // Description IA du cluster + centroidEmbedding String? // Embedding moyen du cluster (JSON number[]) + noteIds String // JSON array de note IDs + + // Stats + noteCount Int @default(0) + firstNoteAt DateTime + lastNoteAt DateTime + trajectorySummary String? // Résumé de l'évolution narrative + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([userId]) + @@index([userId, lastNoteAt]) +} + +model ThoughtShift { + id String @id @default(cuid()) + userId String + clusterId String + cluster TopicCluster @relation(fields: [clusterId], references: [id], onDelete: Cascade) + + fromNoteId String + toNoteId String + semanticDistance Float // Distance cosinus entre les deux notes + description String // Description IA du shift : "Passage d'une position optimiste à critique" + shiftType String // "deepening" | "reversal" | "branching" | "consolidation" | "abandonment" + + createdAt DateTime @default(now()) + + @@index([clusterId]) + @@index([userId]) +} +``` + +**Extensions Prisma existantes :** +```prisma +model UserAISettings { + // ... champs existants + thoughtTrajectory Boolean @default(true) // ON/OFF toggle +} +``` + +**Algorithme de clustering (simplifié) :** +``` +1. Charger tous les embeddings de notes de l'utilisateur (déjà en DB) +2. Calculer la matrice de similarité cosinus (O(n²) — acceptable jusqu'à ~1000 notes) +3. Appliquer un clustering agglomératif : + - Seuil de similarité > 0.65 → même cluster + - Si un cluster dépasse 20 notes → sous-clustering +4. Pour chaque cluster, appeler provider.generateText() avec les titres/résumés pour nommer le topic +5. Stocker les résultats dans TopicCluster +``` + +**Algorithme de trajectoire :** +``` +1. Trier les notes du cluster par date +2. Pour chaque paire de notes consécutives, calculer distance cosinus +3. Si distance > 0.3 → "shift" détecté → appeler IA pour décrire le changement +4. Classifier le shift : deepening (même direction), reversal (changement), branching (nouvel angle) +5. Générer un résumé narratif : "Vous avez commencé par X, puis évolué vers Y" +``` + +**Points d'intégration :** +- **Trigger :** Recalcul périodique (cron ou on-demand) — pas en temps réel +- **Embeddings :** Utilise les embeddings DÉJÀ stockés dans `Note.embedding` — zéro coût IA supplémentaire pour le clustering +- **IA générative :** Uniquement pour nommer les clusters, décrire les shifts, et générer les narrations +- **UI :** `app/thinking-map/page.tsx` avec React Flow (déjà prévu dans le roadmap Memory Echo V2) +- **Settings :** Toggle dans `ai-settings-panel.tsx` + +**Faisabilité avec l'architecture existante :** +| Aspect | Compatible ? | Détail | +|--------|-------------|--------| +| Embeddings existants | ✅✅ | Cœur de la feature — `Note.embedding` déjà disponible | +| Recherche sémantique | ✅ | `cosineSimilarity()` déjà implémenté dans utils | +| Memory Echo | ✅ | Complémentaire — Echo trouve des connexions, Trajectory montre l'évolution | +| React Flow | ✅ | Déjà prévu dans le roadmap Memory Echo V2 | +| Provider IA | ✅ | Uniquement pour naming et narration — faible usage | +| Privacy-first | ✅ | Calculs de similarité entièrement locaux | + +**Risques :** +- **Performance O(n²) :** La matrice de similarité sur 1000+ notes sera lente. Solution : pré-calculer et cacher les clusters, recalcul mensuel seulement. +- **Qualité du clustering :** Le seuil de similarité est empirique. Prévoir un mode "demo" avec seuil abaissé (comme Memory Echo). +- **Embeddings manquants :** Les notes sans embeddings seront invisibles. Nécessite un batch embedding préalable. + +--- + +## Matrice de Dépendances entre Features + +``` + Mood Ghost Thought + Tapestry Writer Trajectory + ─────── ──────── ────────── +Note.embedding - ❌ ✅✅ +Note.language ✅ - - +provider.generate ✅✅ ✅✅ ✅ +provider.embeddings - ❌ ✅✅ +cosineSimilarity - ❌ ✅✅ +React Flow ❌ ❌ ✅ +Nouveau modèle DB ✅ ✅ ✅✅ +UserAISettings ✅ ✅ ✅ +AiFeedback ✅ ✅ ✅ +``` + +## Ordre d'Implémentation Recommandé + +1. **Mood Tapestry** en premier — le plus autonome, pas de dépendance aux embeddings, feedback rapide +2. **Ghost Writer** en second — nécessite un volume minimum de notes, mais architecture simple +3. **Thought Trajectory** en dernier — le plus complexe (clustering + React Flow), mais capitalise sur Mood Tapestry (corrélation sentiment/évolution) et les embeddings existants + +--- + +## Références et Sources d'Inspiration + +- **Google NotebookLM** — AI podcast generation from notes +- **Mem.ai** — Self-organizing AI memory +- **Limitless (Rewind)** — Ambient capture +- **Anki / SuperMemo** — Spaced repetition algorithms +- **Personal.ai** — Digital twin / personal AI clone +- **Whoop / Oura Ring** — Recovery scores, nudge theory, JITAI +- **Spotify Wrapped / DNA** — Personalized data storytelling +- **Reflect** — Frictionless AI voice notes +- **Khoj** — Open-source AI personal assistant +- **Fabric** — AI-first second brain + diff --git a/_bmad-output/planning-artifacts/prd-ai-innovations.md b/_bmad-output/planning-artifacts/prd-ai-innovations.md new file mode 100644 index 0000000..7f7e1a7 --- /dev/null +++ b/_bmad-output/planning-artifacts/prd-ai-innovations.md @@ -0,0 +1,21 @@ +--- +stepsCompleted: [step-01-init] +inputDocuments: + - _bmad-output/brainstorming/brainstorming-session-2026-04-13-133700.md + - _bmad-output/planning-artifacts/project-context.md + - _bmad-output/planning-artifacts/prd.md + - docs/project-overview.md +workflowType: 'prd' +documentCounts: + brainstorming: 1 + projectContext: 1 + existingPrd: 1 + projectDocs: 1 + briefs: 0 + research: 0 +--- + +# Product Requirements Document - Keep + +**Author:** Ramez +**Date:** 2026-04-13 diff --git a/docs/guide-utilisateur-mcp.md b/docs/guide-utilisateur-mcp.md new file mode 100644 index 0000000..8a77018 --- /dev/null +++ b/docs/guide-utilisateur-mcp.md @@ -0,0 +1,364 @@ +# Guide Utilisateur : MCP dans Keep Notes + +## Table des matières + +1. [Qu'est-ce que MCP ? (en termes simples)](#1-quest-ce-que-mcp--en-termes-simples) +2. [Comment ça marche dans Keep Notes](#2-comment-ca-marche-dans-keep-notes) +3. [Les clés API : à quoi ça sert](#3-les-cles-api--a-quoi-ca-sert) +4. [Gérer ses clés API depuis l'interface](#4-gerer-ses-cles-api-depuis-linterface) +5. [Configurer Claude Code](#5-configurer-claude-code) +6. [Configurer Cursor](#6-configurer-cursor) +7. [Configurer N8N](#7-configurer-n8n) +8. [Questions fréquentes](#8-questions-frequentes) + +--- + +## 1. Qu'est-ce que MCP ? (en termes simples) + +**MCP** = **Model Context Protocol** (Protocole de Contexte de Modèle). + +C'est un standard qui permet à des outils IA (comme Claude, Cursor, N8N) de **parler** à votre application Keep Notes. + +### Analogie simple + +Imaginez que Keep Notes est un restaurant. Normalement, pour lire le menu ou commander, vous devez ouvrir l'application web dans votre navigateur. + +MCP, c'est comme un **service de livraison** : il permet à d'autres applications de passer des commandes directement au restaurant, sans ouvrir le site web. Votre clé API, c'est votre **numéro de client** qui prouve que vous avez le droit de commander. + +### Concrètement, que permet MCP ? + +Avec MCP, vous pouvez depuis Claude Code, Cursor ou N8N : + +- **Lire** vos notes existantes +- **Créer** de nouvelles notes +- **Rechercher** dans vos notes (recherche sémantique IA) +- **Organiser** vos notes dans des carnets +- **Gérer** vos étiquettes +- **Générer des titres** avec l'IA +- Et 30+ autres actions + +### Les deux modes de connexion + +Votre serveur MCP Keep Notes peut fonctionner de deux façons : + +| Mode | Comment ça marche | Pour qui | Clé API nécessaire ? | +|------|-------------------|----------|----------------------| +| **Stdio** | L'outil IA lance directement le serveur MCP en arrière-plan | Claude Code, Cursor (local) | Non | +| **HTTP** | Le serveur MCP tourne en continu sur le port 3001, les outils s'y connectent via le réseau | N8N, Cursor (distant), tout outil réseau | Oui | + +--- + +## 2. Comment ça marche dans Keep Notes + +Votre projet Keep Notes contient **deux serveurs** qui partagent la même base de données : + +``` +Keep/ +├── keep-notes/ ← Application web (port 3000) +│ └── prisma/dev.db ← Base de données SQLite +│ +└── mcp-server/ ← Serveur MCP (port 3001 en mode HTTP) + ├── index.js ← Mode Stdio + ├── index-sse.js ← Mode HTTP + └── tools.js ← Les 37 outils disponibles +``` + +**Important** : Les deux serveurs accèdent à la **même base de données**. Quand vous créez une note via MCP, elle apparaît immédiatement dans l'interface web, et inversement. + +--- + +## 3. Les clés API : à quoi ça sert + +Une **clé API** est un mot de passe secret qui permet à un outil externe de prouver son identité au serveur MCP. + +### Quand vous avez besoin d'une clé API + +- **Mode HTTP** (N8N, Cursor distant, etc.) : **OUI**, vous devez fournir votre clé API à chaque requête. +- **Mode Stdio** (Claude Code, Cursor local) : **NON**, l'outil accède directement à la base de données. + +### Format d'une clé + +Les clés générées par Keep Notes ressemblent à ça : + +``` +mcp_sk_0f0fe746d34dabdf7370d06352a2fd5fabf5994d2a980a31 +``` + +- Préfixe `mcp_sk_` (pour "MCP Secret Key") +- Suivi de 48 caractères hexadécimaux + +### Securité + +- La clé brute n'est affichée **qu'une seule fois** au moment de sa création +- En base de données, seul le **hash SHA-256** est stocké (comme un mot de passe) +- Personne (même pas vous) ne peut retrouver la clé brute après fermeture de la fenêtre +- Si vous perdez une clé, il faut la révoquer et en générer une nouvelle + +--- + +## 4. Gérer ses clés API depuis l'interface + +### Accéder à la page MCP + +1. Ouvrez Keep Notes dans votre navigateur (`http://localhost:3000`) +2. Cliquez sur **Paramètres** (icône engrenage) +3. Dans le menu latéral, cliquez sur **MCP Settings** (icône clé) + +### Générer une nouvelle clé + +1. Cliquez sur le bouton **"Générer une nouvelle clé"** +2. Donnez un nom à votre clé (ex: "Mon Claude Code", "N8N Prod") +3. Cliquez sur **"Générer"** +4. **Copiez immédiatement la clé** — elle ne sera plus jamais visible +5. Cliquez sur **"Terminé"** + +### Gérer vos clés existantes + +Pour chaque clé, vous verrez : + +- Le **nom** que vous lui avez donné +- La **date de création** +- La **date de dernière utilisation** +- Le **statut** : Active (verte) ou Révoquée (grise) + +#### Révoquer une clé + +La révocation **désactive** la clé sans la supprimer. L'outil qui l'utilisait perdra son accès. + +> Utile si vous pensez qu'une clé a été compromise mais voulez garder un historique. + +#### Supprimer une clé + +La suppression est **définitive**. La clé et son historique disparaissent. + +--- + +## 5. Configurer Claude Code + +Claude Code est l'outil en ligne de commande de Claude (celui que vous utilisez actuellement). + +### Méthode recommandée : Mode Stdio (sans clé API) + +Avec le mode stdio, Claude Code lance le serveur MCP directement. Pas besoin de clé API car il accède à la base SQLite directement. + +#### Étape 1 : Ouvrir la configuration MCP + +Dans votre terminal Claude Code, tapez : + +``` +/mcp +``` + +#### Étape 2 : Ajouter le serveur + +Choisissez d'ajouter un nouveau serveur et collez cette configuration : + +```json +{ + "mcpServers": { + "keep-notes": { + "command": "node", + "args": ["/Users/sepehr/dev/Keep/mcp-server/index.js"], + "env": { + "DATABASE_URL": "file:/Users/sepehr/dev/Keep/keep-notes/prisma/dev.db", + "APP_BASE_URL": "http://localhost:3000" + } + } + } +} +``` + +#### Explication de chaque champ + +| Champ | Valeur | À quoi ça sert | +|-------|--------|----------------| +| `command` | `"node"` | Lance Node.js | +| `args` | `["/chemin/vers/mcp-server/index.js"]` | Chemin vers le serveur MCP (mode stdio) | +| `env.DATABASE_URL` | `"file:/chemin/vers/prisma/dev.db"` | Où trouver la base de données | +| `env.APP_BASE_URL` | `"http://localhost:3000"` | URL de l'app web (pour les fonctions IA) | + +#### Étape 3 : Vérifier + +Redémarrez Claude Code. Tapez un message demandant d'utiliser un outil Keep Notes, par exemple : + +> "Liste mes notes Keep Notes" + +Claude Code devrait automatiquement détecter les outils MCP disponibles. + +### Méthode alternative : fichier de configuration + +Vous pouvez aussi éditer directement le fichier de config : + +```bash +# Configuration globale (tous les projets) +nano ~/.claude.json + +# OU configuration par projet +nano /Users/sepehr/dev/Keep/.claude/settings.local.json +``` + +Ajoutez le bloc `mcpServers` dans le fichier JSON. + +--- + +## 6. Configurer Cursor + +Cursor est un éditeur de code avec IA intégrée qui supporte MCP. + +### Mode local (Stdio — sans clé API) + +Ouvrez les paramètres MCP dans Cursor (`Settings` > `MCP`) et ajoutez : + +```json +{ + "mcpServers": { + "keep-notes": { + "command": "node", + "args": ["/Users/sepehr/dev/Keep/mcp-server/index.js"], + "env": { + "DATABASE_URL": "file:/Users/sepehr/dev/Keep/keep-notes/prisma/dev.db", + "APP_BASE_URL": "http://localhost:3000" + } + } + } +} +``` + +C'est **exactement la même config** que Claude Code en mode stdio. + +### Mode distant (HTTP — avec clé API) + +Si le serveur MCP tourne sur une autre machine ou si vous préférez le mode HTTP : + +#### 1. Démarrez le serveur MCP en mode HTTP + +```bash +cd /Users/sepehr/dev/Keep/mcp-server +node index-sse.js +``` + +Le serveur démarre sur `http://localhost:3001`. + +#### 2. Configurez Cursor + +```json +{ + "mcpServers": { + "keep-notes": { + "url": "http://localhost:3001/mcp", + "headers": { + "x-api-key": "mcp_sk_VOTRE_CLE_API_ICI" + } + } + } +} +``` + +Remplacez `mcp_sk_VOTRE_CLE_API_ICI` par votre vraie clé (générée depuis `/settings/mcp`). + +--- + +## 7. Configurer N8N + +N8N est un outil d'automatisation qui peut utiliser MCP pour interagir avec vos notes. + +### Prérequis + +1. Le serveur MCP doit tourner en mode HTTP : + +```bash +cd /Users/sepehr/dev/Keep/mcp-server +node index-sse.js +``` + +2. Vous devez avoir généré une clé API depuis `/settings/mcp` + +### Configuration dans N8N + +#### Étape 1 : Ajouter un nœud MCP Client + +Dans votre workflow N8N, ajoutez un nœud **"MCP Client"**. + +#### Étape 2 : Configurer la connexion + +| Paramètre | Valeur | +|-----------|--------| +| **Transport** | HTTP Streamable | +| **URL** | `http://VOTRE_IP:3001/mcp` | +| **Authentication** | API Key | +| **Header** | `x-api-key` | +| **Valeur** | `mcp_sk_VOTRE_CLE_API_ICI` | + +#### Trouver votre IP locale + +```bash +# macOS +ifconfig | grep "inet " | grep -v 127.0.0.1 + +# Résultat typique : 192.168.1.XX +``` + +Utilisez cette IP (pas `localhost`) si N8N tourne sur une autre machine. + +#### Étape 3 : Utiliser un outil + +Après connexion, N8N charge la liste des 37 outils disponibles. Choisissez celui que vous voulez utiliser, par exemple : + +- `create_note` — Créer une note +- `search_notes` — Rechercher des notes +- `get_notes` — Lister les notes + +### Exemple de workflow + +**Workflow "Sauvegarder un article web en note"** : + +``` +Webhook (reçoit un article) + → MCP Client : create_note + title = article.title + content = article.body + labels = ["article", "web"] +``` + +--- + +## 8. Questions fréquentes + +### J'ai perdu ma clé API, que faire ? + +Les clés ne sont jamais stockées en clair. Si vous avez perdu une clé : +1. Allez dans `/settings/mcp` +2. **Révoquez** l'ancienne clé +3. **Générez** une nouvelle clé +4. **Copiez-la** immédiatement +5. Mettez à jour la config de votre outil (Claude Code, Cursor, N8N) + +### Peut-on utiliser MCP sans clé API ? + +**Oui**, en mode stdio. C'est le cas pour Claude Code et Cursor configurés en local. La clé API n'est nécessaire que pour le mode HTTP distant. + +### Le serveur MCP doit-il tourner en permanence ? + +- **Mode Stdio** : Non. L'outil (Claude Code/Cursor) lance le serveur automatiquement quand il en a besoin. +- **Mode HTTP** : Oui. Le serveur doit tourner en continu (`node index-sse.js`) pour que N8N et les autres outils puissent s'y connecter. + +### Mes notes sont-elles en sécurité ? + +- Les clés API sont hashées (SHA-256) en base de données, comme des mots de passe +- Le serveur MCP accède à la même base que l'app web — les permissions utilisateur sont respectées +- En mode HTTP, le trafic peut être sécurisé avec HTTPS (configuration du reverse proxy) + +### Combien de clés API puis-je créer ? + +Pas de limite. Il est recommandé de créer une clé par outil (une pour Claude Code, une pour N8N, etc.) pour pouvoir les révoquer indépendamment. + +### Quels sont les 37 outils disponibles ? + +| Catégorie | Outils | +|-----------|--------| +| **Notes** | `create_note`, `get_note`, `get_notes`, `update_note`, `delete_note`, `search_notes`, `toggle_pin`, `toggle_archive`, `export_notes`, `import_notes`, `restore_note` | +| **Carnets** | `create_notebook`, `get_notebooks`, `get_notebook`, `update_notebook`, `delete_notebook`, `reorder_notebooks` | +| **Etiquettes** | `create_label`, `get_labels`, `update_label`, `delete_label` | +| **IA** | `generate_title_suggestions`, `reformulate_text`, `generate_tags`, `fuse_notes`, `batch_organize`, `generate_notebook_summary`, etc. | +| **Rappels** | `get_due_reminders` | +| **Clés API** | `generate_api_key`, `list_api_keys`, `revoke_api_key` | diff --git a/keep-notes/app/(main)/layout.tsx b/keep-notes/app/(main)/layout.tsx index 44dfe91..ef437ff 100644 --- a/keep-notes/app/(main)/layout.tsx +++ b/keep-notes/app/(main)/layout.tsx @@ -24,7 +24,7 @@ export default async function MainLayout({ {/* Main Content Area */} -
+
{children}
diff --git a/keep-notes/app/(main)/page.tsx b/keep-notes/app/(main)/page.tsx index a714aca..75054c3 100644 --- a/keep-notes/app/(main)/page.tsx +++ b/keep-notes/app/(main)/page.tsx @@ -6,7 +6,8 @@ import { Note } from '@/lib/types' import { getAllNotes, searchNotes } from '@/app/actions/notes' import { getAISettings } from '@/app/actions/ai-settings' import { NoteInput } from '@/components/note-input' -import { MasonryGrid } from '@/components/masonry-grid' +import { NotesMainSection, type NotesViewMode } from '@/components/notes-main-section' +import { NotesViewToggle } from '@/components/notes-view-toggle' import { MemoryEchoNotification } from '@/components/memory-echo-notification' import { NotebookSuggestionToast } from '@/components/notebook-suggestion-toast' import { NoteEditor } from '@/components/note-editor' @@ -25,6 +26,7 @@ import { Folder, Briefcase, FileText, Zap, BarChart3, Globe, Sparkles, Book, Hea import { cn } from '@/lib/utils' import { LabelFilter } from '@/components/label-filter' import { useLanguage } from '@/lib/i18n' +import { useHomeView } from '@/context/home-view-context' export default function HomePage() { @@ -36,12 +38,14 @@ export default function HomePage() { const [pinnedNotes, setPinnedNotes] = useState([]) const [recentNotes, setRecentNotes] = useState([]) const [showRecentNotes, setShowRecentNotes] = useState(true) + const [notesViewMode, setNotesViewMode] = useState('masonry') const [editingNote, setEditingNote] = useState<{ note: Note; readOnly?: boolean } | null>(null) const [isLoading, setIsLoading] = useState(true) const [notebookSuggestion, setNotebookSuggestion] = useState<{ noteId: string; content: string } | null>(null) const [batchOrganizationOpen, setBatchOrganizationOpen] = useState(false) const { refreshKey } = useNoteRefresh() const { labels } = useLabels() + const { setControls } = useHomeView() // Auto label suggestion (IA4) const { shouldSuggest: shouldSuggestLabels, notebookId: suggestNotebookId, dismiss: dismissLabelSuggestion } = useAutoLabelSuggestion() @@ -159,15 +163,23 @@ export default function HomePage() { const load = async () => { // Load settings first let showRecent = true + let viewMode: NotesViewMode = 'masonry' try { const settings = await getAISettings() if (cancelled) return showRecent = settings?.showRecentNotes !== false + viewMode = + settings?.notesViewMode === 'masonry' + ? 'masonry' + : settings?.notesViewMode === 'tabs' || settings?.notesViewMode === 'list' + ? 'tabs' + : 'masonry' } catch { // Default to true on error } if (cancelled) return setShowRecentNotes(showRecent) + setNotesViewMode(viewMode) // Then load notes setIsLoading(true) @@ -247,6 +259,14 @@ export default function HomePage() { const currentNotebook = notebooks.find((n: any) => n.id === searchParams.get('notebook')) const [showNoteInput, setShowNoteInput] = useState(false) + useEffect(() => { + setControls({ + isTabsMode: notesViewMode === 'tabs', + openNoteComposer: () => setShowNoteInput(true), + }) + return () => setControls(null) + }, [notesViewMode, setControls]) + // Get icon component for header const getNotebookIcon = (iconName: string) => { const ICON_MAP: Record = { @@ -282,11 +302,23 @@ export default function HomePage() { ) + const isTabs = notesViewMode === 'tabs' + return ( -
+
{/* Notebook Specific Header */} {currentNotebook ? ( -
+
{/* Breadcrumbs */} @@ -308,7 +340,8 @@ export default function HomePage() {
{/* Actions Section */} -
+
+ { @@ -319,21 +352,28 @@ export default function HomePage() { }} className="border-gray-200" /> - + {!isTabs && ( + + )}
) : ( /* Default Header for Home/Inbox */ -
+
{/* Breadcrumbs Placeholder or just spacing */} -
+ {!isTabs &&
}
{/* Title Section */} @@ -345,7 +385,8 @@ export default function HomePage() {
{/* Actions Section */} -
+
+ { @@ -370,25 +411,31 @@ export default function HomePage() { )} - + {!isTabs && ( + + )}
)} - {/* Note Input - Conditionally Visible or Always Visible on Home */} - {/* Note Input - Conditionally Rendered */} {showNoteInput && ( -
+
)} @@ -397,26 +444,25 @@ export default function HomePage() {
{t('general.loading')}
) : ( <> - {/* Favorites Section - Pinned Notes */} setEditingNote({ note, readOnly })} /> - {/* Recent Notes Section - Only shown if enabled in settings */} - {showRecentNotes && ( + {!isTabs && showRecentNotes && ( setEditingNote({ note, readOnly })} /> )} - {/* Main Notes Grid - Unpinned Notes Only */} - {notes.filter(note => !note.isPinned).length > 0 && ( -
- !note.isPinned)} + {notes.filter((note) => !note.isPinned).length > 0 && ( +
+ !note.isPinned)} onEdit={(note, readOnly) => setEditingNote({ note, readOnly })} + currentNotebookId={searchParams.get('notebook')} />
)} @@ -473,6 +519,6 @@ export default function HomePage() { onClose={() => setEditingNote(null)} /> )} -
+ ) } diff --git a/keep-notes/app/(main)/settings/appearance/appearance-settings-client.tsx b/keep-notes/app/(main)/settings/appearance/appearance-settings-client.tsx new file mode 100644 index 0000000..903dc5a --- /dev/null +++ b/keep-notes/app/(main)/settings/appearance/appearance-settings-client.tsx @@ -0,0 +1,127 @@ +'use client' + +import { useState } from 'react' +import { SettingsSection, SettingSelect } from '@/components/settings' +import { updateAISettings } from '@/app/actions/ai-settings' +import { updateUserSettings } from '@/app/actions/user-settings' +import { useLanguage } from '@/lib/i18n' +import { toast } from 'sonner' + +interface AppearanceSettingsClientProps { + initialFontSize: string + initialTheme: string + initialNotesViewMode: 'masonry' | 'tabs' +} + +export function AppearanceSettingsClient({ initialFontSize, initialTheme, initialNotesViewMode }: AppearanceSettingsClientProps) { + const { t } = useLanguage() + const [theme, setTheme] = useState(initialTheme || 'light') + const [fontSize, setFontSize] = useState(initialFontSize || 'medium') + const [notesViewMode, setNotesViewMode] = useState<'masonry' | 'tabs'>(initialNotesViewMode) + + const handleThemeChange = async (value: string) => { + setTheme(value) + localStorage.setItem('theme-preference', value) + + const root = document.documentElement + root.removeAttribute('data-theme') + root.classList.remove('dark') + + if (value === 'auto') { + if (window.matchMedia('(prefers-color-scheme: dark)').matches) root.classList.add('dark') + } else if (value === 'dark') { + root.classList.add('dark') + } else { + root.setAttribute('data-theme', value) + if (['midnight'].includes(value)) root.classList.add('dark') + } + + await updateUserSettings({ theme: value as 'light' | 'dark' | 'auto' }) + toast.success(t('settings.settingsSaved') || 'Saved') + } + + const handleFontSizeChange = async (value: string) => { + setFontSize(value) + + const fontSizeMap: Record = { + 'small': '14px', 'medium': '16px', 'large': '18px', 'extra-large': '20px' + } + document.documentElement.style.setProperty('--user-font-size', fontSizeMap[value] || '16px') + + await updateAISettings({ fontSize: value as any }) + toast.success(t('settings.settingsSaved') || 'Saved') + } + + const handleNotesViewChange = async (value: string) => { + const mode = value === 'tabs' ? 'tabs' : 'masonry' + setNotesViewMode(mode) + await updateAISettings({ notesViewMode: mode }) + toast.success(t('settings.settingsSaved') || 'Saved') + } + + return ( +
+
+

{t('appearance.title')}

+

+ {t('appearance.description')} +

+
+ + 🎨} + description={t('settings.themeLight') + ' / ' + t('settings.themeDark')} + > + + + + 📝} + description={t('profile.fontSizeDescription')} + > + + + + 📋} + description={t('appearance.notesViewDescription')} + > + + +
+ ) +} diff --git a/keep-notes/app/(main)/settings/appearance/page.tsx b/keep-notes/app/(main)/settings/appearance/page.tsx index ce355d8..7014103 100644 --- a/keep-notes/app/(main)/settings/appearance/page.tsx +++ b/keep-notes/app/(main)/settings/appearance/page.tsx @@ -1,113 +1,25 @@ -'use client' +import { auth } from '@/auth' +import { redirect } from 'next/navigation' +import { getAISettings } from '@/app/actions/ai-settings' +import { getUserSettings } from '@/app/actions/user-settings' +import { AppearanceSettingsClient } from './appearance-settings-client' -import { useState, useEffect } from 'react' -import { SettingsNav, SettingsSection, SettingSelect } from '@/components/settings' -import { updateAISettings, getAISettings } from '@/app/actions/ai-settings' -import { updateUserSettings, getUserSettings } from '@/app/actions/user-settings' -import { useLanguage } from '@/lib/i18n' - -export default function AppearanceSettingsPage() { - const { t } = useLanguage() - const [theme, setTheme] = useState('auto') - const [fontSize, setFontSize] = useState('medium') - - // Load settings on mount - useEffect(() => { - async function loadSettings() { - try { - const [aiSettings, userSettings] = await Promise.all([ - getAISettings(), - getUserSettings() - ]) - if (aiSettings.fontSize) setFontSize(aiSettings.fontSize) - if (userSettings.theme) setTheme(userSettings.theme) - } catch (error) { - console.error('Error loading settings:', error) - } - } - loadSettings() - }, []) - - const handleThemeChange = async (value: string) => { - setTheme(value) - localStorage.setItem('theme-preference', value) - - // Instant visual update - const root = document.documentElement - root.removeAttribute('data-theme') - root.classList.remove('dark') - - if (value === 'auto') { - if (window.matchMedia('(prefers-color-scheme: dark)').matches) root.classList.add('dark') - } else if (value === 'dark') { - root.classList.add('dark') - } else { - root.setAttribute('data-theme', value) - if (['midnight'].includes(value)) root.classList.add('dark') - } - - await updateUserSettings({ theme: value as 'light' | 'dark' | 'auto' }) +export default async function AppearanceSettingsPage() { + const session = await auth() + if (!session?.user) { + redirect('/api/auth/signin') } - const handleFontSizeChange = async (value: string) => { - setFontSize(value) - - // Instant visual update - const fontSizeMap: Record = { - 'small': '14px', 'medium': '16px', 'large': '18px', 'extra-large': '20px' - } - const root = document.documentElement - root.style.setProperty('--user-font-size', fontSizeMap[value] || '16px') - - await updateAISettings({ fontSize: value as any }) - } + const [aiSettings, userSettings] = await Promise.all([ + getAISettings(), + getUserSettings() + ]) return ( -
-
-

{t('appearance.title')}

-

- {t('appearance.description')} -

-
- - 🎨} - description={t('settings.themeLight') + ' / ' + t('settings.themeDark')} - > - - - - 📝} - description={t('profile.fontSizeDescription')} - > - - -
+ ) } diff --git a/keep-notes/app/(main)/settings/general/general-settings-client.tsx b/keep-notes/app/(main)/settings/general/general-settings-client.tsx new file mode 100644 index 0000000..04278a7 --- /dev/null +++ b/keep-notes/app/(main)/settings/general/general-settings-client.tsx @@ -0,0 +1,134 @@ +'use client' + +import { useState } from 'react' +import { SettingsSection, SettingToggle, SettingSelect } from '@/components/settings' +import { useLanguage } from '@/lib/i18n' +import { updateAISettings } from '@/app/actions/ai-settings' +import { toast } from 'sonner' +import { useRouter } from 'next/navigation' + +interface GeneralSettingsClientProps { + initialSettings: { + preferredLanguage: string + emailNotifications: boolean + desktopNotifications: boolean + anonymousAnalytics: boolean + } +} + +export function GeneralSettingsClient({ initialSettings }: GeneralSettingsClientProps) { + const { t, setLanguage: setContextLanguage } = useLanguage() + const router = useRouter() + const [language, setLanguage] = useState(initialSettings.preferredLanguage || 'auto') + const [emailNotifications, setEmailNotifications] = useState(initialSettings.emailNotifications ?? false) + const [desktopNotifications, setDesktopNotifications] = useState(initialSettings.desktopNotifications ?? false) + const [anonymousAnalytics, setAnonymousAnalytics] = useState(initialSettings.anonymousAnalytics ?? false) + + const handleLanguageChange = async (value: string) => { + setLanguage(value) + await updateAISettings({ preferredLanguage: value as any }) + + if (value === 'auto') { + localStorage.removeItem('user-language') + toast.success(t('settings.languageAuto') || 'Language set to Auto') + } else { + localStorage.setItem('user-language', value) + setContextLanguage(value as any) + toast.success(t('profile.languageUpdateSuccess') || 'Language updated') + } + + setTimeout(() => router.refresh(), 300) + } + + const handleEmailNotificationsChange = async (enabled: boolean) => { + setEmailNotifications(enabled) + await updateAISettings({ emailNotifications: enabled }) + toast.success(t('settings.settingsSaved') || 'Saved') + } + + const handleDesktopNotificationsChange = async (enabled: boolean) => { + setDesktopNotifications(enabled) + await updateAISettings({ desktopNotifications: enabled }) + toast.success(t('settings.settingsSaved') || 'Saved') + } + + const handleAnonymousAnalyticsChange = async (enabled: boolean) => { + setAnonymousAnalytics(enabled) + await updateAISettings({ anonymousAnalytics: enabled }) + toast.success(t('settings.settingsSaved') || 'Saved') + } + + return ( +
+
+

{t('generalSettings.title')}

+

+ {t('generalSettings.description')} +

+
+ + 🌍} + description={t('profile.languagePreferencesDescription')} + > + + + + 🔔} + description={t('settings.notificationsDesc')} + > + + + + + 🔒} + description={t('settings.privacyDesc')} + > + + +
+ ) +} diff --git a/keep-notes/app/(main)/settings/general/page.tsx b/keep-notes/app/(main)/settings/general/page.tsx index 1b47e58..33760d1 100644 --- a/keep-notes/app/(main)/settings/general/page.tsx +++ b/keep-notes/app/(main)/settings/general/page.tsx @@ -1,142 +1,15 @@ -'use client' +import { auth } from '@/auth' +import { redirect } from 'next/navigation' +import { getAISettings } from '@/app/actions/ai-settings' +import { GeneralSettingsClient } from './general-settings-client' -import { useState, useEffect } from 'react' -import { SettingsNav, SettingsSection, SettingToggle, SettingSelect } from '@/components/settings' -import { useLanguage } from '@/lib/i18n' -import { updateAISettings, getAISettings } from '@/app/actions/ai-settings' -import { toast } from 'sonner' -import { useRouter } from 'next/navigation' - -export default function GeneralSettingsPage() { - const { t, setLanguage: setContextLanguage } = useLanguage() - const router = useRouter() - const [language, setLanguage] = useState('auto') - const [emailNotifications, setEmailNotifications] = useState(false) - const [desktopNotifications, setDesktopNotifications] = useState(false) - const [anonymousAnalytics, setAnonymousAnalytics] = useState(false) - - // Load settings on mount - useEffect(() => { - async function loadSettings() { - try { - const settings = await getAISettings() - if (settings.preferredLanguage) setLanguage(settings.preferredLanguage) - if (settings.emailNotifications !== undefined) setEmailNotifications(settings.emailNotifications) - if (settings.desktopNotifications !== undefined) setDesktopNotifications(settings.desktopNotifications) - if (settings.anonymousAnalytics !== undefined) setAnonymousAnalytics(settings.anonymousAnalytics) - } catch (error) { - console.error('Error loading settings:', error) - } - } - loadSettings() - }, []) - - const handleLanguageChange = async (value: string) => { - setLanguage(value) - - // 1. Update database settings - await updateAISettings({ preferredLanguage: value as any }) - - // 2. Update local storage and application state - if (value === 'auto') { - localStorage.removeItem('user-language') - toast.success("Language set to Auto") - } else { - localStorage.setItem('user-language', value) - setContextLanguage(value as any) - toast.success(t('profile.languageUpdateSuccess') || "Language updated") - } - - // 3. Refresh server components to ensure all components update (metadata, etc.) - setTimeout(() => router.refresh(), 500) +export default async function GeneralSettingsPage() { + const session = await auth() + if (!session?.user) { + redirect('/api/auth/signin') } - const handleEmailNotificationsChange = async (enabled: boolean) => { - setEmailNotifications(enabled) - await updateAISettings({ emailNotifications: enabled }) - } + const settings = await getAISettings() - const handleDesktopNotificationsChange = async (enabled: boolean) => { - setDesktopNotifications(enabled) - await updateAISettings({ desktopNotifications: enabled }) - } - - const handleAnonymousAnalyticsChange = async (enabled: boolean) => { - setAnonymousAnalytics(enabled) - await updateAISettings({ anonymousAnalytics: enabled }) - } - - return ( -
-
-

{t('generalSettings.title')}

-

- {t('generalSettings.description')} -

-
- - 🌍} - description={t('profile.languagePreferencesDescription')} - > - - - - 🔔} - description={t('settings.notifications')} - > - - - - - 🔒} - description={t('settings.privacy')} - > - - -
- ) + return } diff --git a/keep-notes/app/(main)/settings/loading.tsx b/keep-notes/app/(main)/settings/loading.tsx new file mode 100644 index 0000000..f3d5fd4 --- /dev/null +++ b/keep-notes/app/(main)/settings/loading.tsx @@ -0,0 +1,26 @@ +export default function SettingsLoading() { + return ( +
+
+
+
+
+ {[1, 2, 3].map((i) => ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ))} +
+ ) +} diff --git a/keep-notes/app/(main)/settings/mcp/page.tsx b/keep-notes/app/(main)/settings/mcp/page.tsx new file mode 100644 index 0000000..05e693f --- /dev/null +++ b/keep-notes/app/(main)/settings/mcp/page.tsx @@ -0,0 +1,23 @@ +import { auth } from '@/auth' +import { redirect } from 'next/navigation' +import { McpSettingsPanel } from '@/components/mcp/mcp-settings-panel' +import { listMcpKeys, getMcpServerStatus } from '@/app/actions/mcp-keys' + +export default async function McpSettingsPage() { + const session = await auth() + + if (!session?.user) { + redirect('/api/auth/signin') + } + + const [keys, serverStatus] = await Promise.all([ + listMcpKeys(), + getMcpServerStatus(), + ]) + + return ( +
+ +
+ ) +} diff --git a/keep-notes/app/actions/ai-settings.ts b/keep-notes/app/actions/ai-settings.ts index 74532a5..f5d65b1 100644 --- a/keep-notes/app/actions/ai-settings.ts +++ b/keep-notes/app/actions/ai-settings.ts @@ -14,13 +14,54 @@ export type UserAISettingsData = { preferredLanguage?: 'auto' | 'en' | 'fr' | 'es' | 'de' | 'fa' | 'it' | 'pt' | 'ru' | 'zh' | 'ja' | 'ko' | 'ar' | 'hi' | 'nl' | 'pl' demoMode?: boolean showRecentNotes?: boolean + notesViewMode?: 'masonry' | 'tabs' | 'list' emailNotifications?: boolean desktopNotifications?: boolean anonymousAnalytics?: boolean - theme?: 'light' | 'dark' | 'auto' fontSize?: 'small' | 'medium' | 'large' } +/** Only fields that exist on `UserAISettings` in Prisma (excludes e.g. `theme`, which lives on `User`). */ +const USER_AI_SETTINGS_PRISMA_KEYS = [ + 'titleSuggestions', + 'semanticSearch', + 'paragraphRefactor', + 'memoryEcho', + 'memoryEchoFrequency', + 'aiProvider', + 'preferredLanguage', + 'fontSize', + 'demoMode', + 'showRecentNotes', + 'notesViewMode', + 'emailNotifications', + 'desktopNotifications', + 'anonymousAnalytics', +] as const + +type UserAISettingsPrismaKey = (typeof USER_AI_SETTINGS_PRISMA_KEYS)[number] + +function pickUserAISettingsForDb(input: UserAISettingsData): Partial> { + const out: Partial> = {} + for (const key of USER_AI_SETTINGS_PRISMA_KEYS) { + const v = input[key] + if (v !== undefined) { + out[key] = v + } + } + if (out.notesViewMode === 'list') { + out.notesViewMode = 'tabs' + } + if ( + out.notesViewMode != null && + out.notesViewMode !== 'masonry' && + out.notesViewMode !== 'tabs' + ) { + delete out.notesViewMode + } + return out +} + /** * Update AI settings for the current user */ @@ -35,24 +76,41 @@ export async function updateAISettings(settings: UserAISettingsData) { } try { + const data = pickUserAISettingsForDb(settings) + if (Object.keys(data).length === 0) { + return { success: true } + } + + // Valeurs scalaires uniquement (pickUserAISettingsForDb) — cast pour éviter UpdateOperations vs create. + const payload = data as Record + // Upsert settings (create if not exists, update if exists) - const result = await prisma.userAISettings.upsert({ + await prisma.userAISettings.upsert({ where: { userId: session.user.id }, create: { userId: session.user.id, - ...settings + ...payload, }, - update: settings + update: payload, }) - revalidatePath('/settings/ai', 'page') + revalidatePath('/settings/appearance', 'page') revalidatePath('/', 'layout') updateTag('ai-settings') return { success: true } } catch (error) { console.error('Error updating AI settings:', error) + const raw = error instanceof Error ? error.message : String(error) + const isSchema = + /no such column|notesViewMode|Unknown column|does not exist/i.test(raw) || + (typeof raw === 'string' && raw.includes('UserAISettings') && raw.includes('column')) + if (isSchema) { + throw new Error( + 'Schéma base de données obsolète : colonne notesViewMode manquante. Dans le dossier keep-notes, exécutez : npx prisma db push (ou appliquez les migrations Prisma).' + ) + } throw new Error('Failed to update AI settings') } } @@ -81,6 +139,7 @@ const getCachedAISettings = unstable_cache( preferredLanguage: 'auto' as const, demoMode: false, showRecentNotes: false, + notesViewMode: 'masonry' as const, emailNotifications: false, desktopNotifications: false, anonymousAnalytics: false, @@ -89,6 +148,14 @@ const getCachedAISettings = unstable_cache( } } + const raw = settings.notesViewMode + const viewMode = + raw === 'masonry' + ? ('masonry' as const) + : raw === 'list' || raw === 'tabs' + ? ('tabs' as const) + : ('masonry' as const) + return { titleSuggestions: settings.titleSuggestions, semanticSearch: settings.semanticSearch, @@ -99,6 +166,7 @@ const getCachedAISettings = unstable_cache( preferredLanguage: (settings.preferredLanguage || 'auto') as 'auto' | 'en' | 'fr' | 'es' | 'de' | 'fa' | 'it' | 'pt' | 'ru' | 'zh' | 'ja' | 'ko' | 'ar' | 'hi' | 'nl' | 'pl', demoMode: settings.demoMode, showRecentNotes: settings.showRecentNotes, + notesViewMode: viewMode, emailNotifications: settings.emailNotifications, desktopNotifications: settings.desktopNotifications, anonymousAnalytics: settings.anonymousAnalytics, @@ -118,6 +186,7 @@ const getCachedAISettings = unstable_cache( preferredLanguage: 'auto' as const, demoMode: false, showRecentNotes: false, + notesViewMode: 'masonry' as const, emailNotifications: false, desktopNotifications: false, anonymousAnalytics: false, @@ -150,6 +219,7 @@ export async function getAISettings(userId?: string) { preferredLanguage: 'auto' as const, demoMode: false, showRecentNotes: false, + notesViewMode: 'masonry' as const, emailNotifications: false, desktopNotifications: false, anonymousAnalytics: false, diff --git a/keep-notes/app/actions/mcp-keys.ts b/keep-notes/app/actions/mcp-keys.ts new file mode 100644 index 0000000..9e10841 --- /dev/null +++ b/keep-notes/app/actions/mcp-keys.ts @@ -0,0 +1,167 @@ +'use server' + +import { auth } from '@/auth' +import { prisma } from '@/lib/prisma' +import { revalidatePath } from 'next/cache' +import { createHash, randomBytes } from 'crypto' + +const KEY_PREFIX = 'mcp_key_' + +function hashKey(rawKey: string): string { + return createHash('sha256').update(rawKey).digest('hex') +} + +export type McpKeyInfo = { + shortId: string + name: string + userId: string + userName: string + active: boolean + createdAt: string + lastUsedAt: string | null +} + +/** + * List all MCP API keys for the current user. + */ +export async function listMcpKeys(): Promise { + const session = await auth() + if (!session?.user?.id) throw new Error('Unauthorized') + + const allKeys = await prisma.systemConfig.findMany({ + where: { key: { startsWith: KEY_PREFIX } }, + }) + + const keys: McpKeyInfo[] = [] + for (const entry of allKeys) { + try { + const info = JSON.parse(entry.value) + if (info.userId !== session.user.id) continue + keys.push({ + shortId: info.shortId, + name: info.name, + userId: info.userId, + userName: info.userName, + active: info.active, + createdAt: info.createdAt, + lastUsedAt: info.lastUsedAt, + }) + } catch { + // skip invalid JSON + } + } + + return keys +} + +/** + * Generate a new MCP API key for the current user. + * Returns the raw key (shown only once) and key info. + */ +export async function generateMcpKey(name: string): Promise<{ rawKey: string; info: { shortId: string; name: string } }> { + const session = await auth() + if (!session?.user?.id) throw new Error('Unauthorized') + + const user = await prisma.user.findUnique({ + where: { id: session.user.id }, + select: { id: true, name: true, email: true }, + }) + if (!user) throw new Error('User not found') + + const rawBytes = randomBytes(24) + const shortId = rawBytes.toString('hex').substring(0, 8) + const rawKey = `mcp_sk_${rawBytes.toString('hex')}` + const keyHash = hashKey(rawKey) + + const keyInfo = { + shortId, + name: name || `Key for ${user.name}`, + userId: user.id, + userName: user.name, + userEmail: user.email, + keyHash, + createdAt: new Date().toISOString(), + lastUsedAt: null, + active: true, + } + + await prisma.systemConfig.create({ + data: { + key: `${KEY_PREFIX}${shortId}`, + value: JSON.stringify(keyInfo), + }, + }) + + revalidatePath('/settings/mcp') + + return { + rawKey, + info: { shortId, name: keyInfo.name }, + } +} + +/** + * Revoke (deactivate) an MCP API key. Only the owner can revoke. + */ +export async function revokeMcpKey(shortId: string): Promise { + const session = await auth() + if (!session?.user?.id) throw new Error('Unauthorized') + + const configKey = `${KEY_PREFIX}${shortId}` + const entry = await prisma.systemConfig.findUnique({ where: { key: configKey } }) + if (!entry) throw new Error('Key not found') + + const info = JSON.parse(entry.value) + if (info.userId !== session.user.id) throw new Error('Forbidden') + if (!info.active) return false + + info.active = false + info.revokedAt = new Date().toISOString() + + await prisma.systemConfig.update({ + where: { key: configKey }, + data: { value: JSON.stringify(info) }, + }) + + revalidatePath('/settings/mcp') + return true +} + +/** + * Permanently delete an MCP API key. Only the owner can delete. + */ +export async function deleteMcpKey(shortId: string): Promise { + const session = await auth() + if (!session?.user?.id) throw new Error('Unauthorized') + + const configKey = `${KEY_PREFIX}${shortId}` + const entry = await prisma.systemConfig.findUnique({ where: { key: configKey } }) + if (!entry) throw new Error('Key not found') + + const info = JSON.parse(entry.value) + if (info.userId !== session.user.id) throw new Error('Forbidden') + + try { + await prisma.systemConfig.delete({ where: { key: configKey } }) + revalidatePath('/settings/mcp') + return true + } catch { + return false + } +} + +export type McpServerStatus = { + mode: 'stdio' | 'sse' | 'unknown' + url: string | null +} + +/** + * Get MCP server status — mode and URL. + */ +export async function getMcpServerStatus(): Promise { + // Check if SSE mode is configured via env + const mode = process.env.MCP_SERVER_MODE === 'sse' ? 'sse' : 'stdio' + const url = process.env.MCP_SERVER_URL || null + + return { mode, url } +} diff --git a/keep-notes/app/actions/notes.ts b/keep-notes/app/actions/notes.ts index bc1fd01..d54e48b 100644 --- a/keep-notes/app/actions/notes.ts +++ b/keep-notes/app/actions/notes.ts @@ -385,9 +385,9 @@ export async function createNote(data: { reminder?: Date | null isMarkdown?: boolean size?: 'small' | 'medium' | 'large' - sharedWith?: string[] autoGenerated?: boolean notebookId?: string | undefined // Assign note to a notebook if provided + skipRevalidation?: boolean // Option to prevent full page refresh for smooth optimistic UI updates }) { const session = await auth(); if (!session?.user?.id) throw new Error('Unauthorized'); @@ -421,8 +421,10 @@ export async function createNote(data: { await syncLabels(session.user.id, data.labels, data.notebookId ?? null) } - // Revalidate main page (handles both inbox and notebook views via query params) - revalidatePath('/') + if (!data.skipRevalidation) { + // Revalidate main page (handles both inbox and notebook views via query params) + revalidatePath('/') + } // Fire-and-forget: run AI operations in background without blocking the response const userId = session.user.id @@ -470,7 +472,9 @@ export async function createNote(data: { data: { labels: JSON.stringify(appliedLabels) } }) await syncLabels(userId, appliedLabels, notebookId ?? null) - revalidatePath('/') + if (!data.skipRevalidation) { + revalidatePath('/') + } } } } catch (error) { @@ -503,7 +507,7 @@ export async function updateNote(id: string, data: { size?: 'small' | 'medium' | 'large' autoGenerated?: boolean | null notebookId?: string | null -}) { +}, options?: { skipContentTimestamp?: boolean; skipRevalidation?: boolean }) { const session = await auth(); if (!session?.user?.id) throw new Error('Unauthorized'); @@ -556,9 +560,10 @@ export async function updateNote(id: string, data: { // Only update contentUpdatedAt for actual content changes, NOT for property changes // (size, color, isPinned, isArchived are properties, not content) + // skipContentTimestamp=true is used by the inline editor to avoid bumping "Récent" on every auto-save const contentFields = ['title', 'content', 'checkItems', 'images', 'links'] const isContentChange = contentFields.some(field => field in data) - if (isContentChange) { + if (isContentChange && !options?.skipContentTimestamp) { updateData.contentUpdatedAt = new Date() } @@ -582,7 +587,7 @@ export async function updateNote(id: string, data: { const structuralFields = ['isPinned', 'isArchived', 'labels', 'notebookId'] const isStructuralChange = structuralFields.some(field => field in data) - if (isStructuralChange) { + if (isStructuralChange && !options?.skipRevalidation) { revalidatePath('/') revalidatePath(`/note/${id}`) diff --git a/keep-notes/app/api/ai/tags/route.ts b/keep-notes/app/api/ai/tags/route.ts index a03d403..49a5ed6 100644 --- a/keep-notes/app/api/ai/tags/route.ts +++ b/keep-notes/app/api/ai/tags/route.ts @@ -45,7 +45,7 @@ export async function POST(req: NextRequest) { // Otherwise, use legacy auto-tagging (generates new tags) const config = await getSystemConfig(); const provider = getAIProvider(config); - const tags = await provider.generateTags(content); + const tags = await provider.generateTags(content, language); return NextResponse.json({ tags }); } catch (error: any) { diff --git a/keep-notes/app/api/ai/translate/route.ts b/keep-notes/app/api/ai/translate/route.ts new file mode 100644 index 0000000..6c01a63 --- /dev/null +++ b/keep-notes/app/api/ai/translate/route.ts @@ -0,0 +1,30 @@ +import { NextRequest, NextResponse } from 'next/server' +import { auth } from '@/auth' +import { getTagsProvider } from '@/lib/ai/factory' +import { getSystemConfig } from '@/lib/config' + +export async function POST(request: NextRequest) { + try { + const session = await auth() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { text, targetLanguage } = await request.json() + + if (!text || !targetLanguage) { + return NextResponse.json({ error: 'text and targetLanguage are required' }, { status: 400 }) + } + + const config = await getSystemConfig() + const provider = getTagsProvider(config) + + const prompt = `Translate the following text to ${targetLanguage}. Return ONLY the translated text, no explanation, no preamble, no quotes:\n\n${text}` + + const translatedText = await provider.generateText(prompt) + + return NextResponse.json({ translatedText: translatedText.trim() }) + } catch (error: any) { + return NextResponse.json({ error: error.message || 'Translation failed' }, { status: 500 }) + } +} diff --git a/keep-notes/app/api/labels/[id]/route.ts b/keep-notes/app/api/labels/[id]/route.ts index 61a433e..409756a 100644 --- a/keep-notes/app/api/labels/[id]/route.ts +++ b/keep-notes/app/api/labels/[id]/route.ts @@ -101,10 +101,11 @@ export async function PUT( const newName = name ? name.trim() : currentLabel.name // For backward compatibility, update old label field in notes if renaming - if (name && name.trim() !== currentLabel.name && currentLabel.userId) { + const targetUserIdPut = currentLabel.userId || currentLabel.notebook?.userId || session.user.id; + if (name && name.trim() !== currentLabel.name && targetUserIdPut) { const allNotes = await prisma.note.findMany({ where: { - userId: currentLabel.userId, + userId: targetUserIdPut, labels: { not: null } }, select: { id: true, labels: true } @@ -197,10 +198,11 @@ export async function DELETE( } // For backward compatibility, remove from old label field in notes - if (label.userId) { + const targetUserIdDel = label.userId || label.notebook?.userId || session.user.id; + if (targetUserIdDel) { const allNotes = await prisma.note.findMany({ where: { - userId: label.userId, + userId: targetUserIdDel, labels: { not: null } }, select: { id: true, labels: true } diff --git a/keep-notes/components/ai/ai-settings-panel.tsx b/keep-notes/components/ai/ai-settings-panel.tsx index 3d74228..12f6b23 100644 --- a/keep-notes/components/ai/ai-settings-panel.tsx +++ b/keep-notes/components/ai/ai-settings-panel.tsx @@ -64,21 +64,7 @@ export function AISettingsPanel({ initialSettings }: AISettingsPanelProps) { } } - const handleProviderChange = async (value: 'auto' | 'openai' | 'ollama') => { - setSettings(prev => ({ ...prev, aiProvider: value })) - try { - setIsPending(true) - await updateAISettings({ aiProvider: value }) - toast.success(t('aiSettings.saved')) - } catch (error) { - console.error('Error updating provider:', error) - toast.error(t('aiSettings.error')) - setSettings(initialSettings) - } finally { - setIsPending(false) - } - } const handleLanguageChange = async (value: 'auto' | 'en' | 'fr' | 'es' | 'de' | 'fa' | 'it' | 'pt' | 'ru' | 'zh' | 'ja' | 'ko' | 'ar' | 'hi' | 'nl' | 'pl') => { setSettings(prev => ({ ...prev, preferredLanguage: value })) @@ -188,54 +174,7 @@ export function AISettingsPanel({ initialSettings }: AISettingsPanelProps) { />
- {/* AI Provider Selection */} - - -

- {t('aiSettings.providerDesc')} -

- -
- -
- -

- {t('aiSettings.providerAutoDesc')} -

-
-
- -
- -
- -

- {t('aiSettings.providerOllamaDesc')} -

-
-
- -
- -
- -

- {t('aiSettings.providerOpenAIDesc')} -

-
-
-
-
) } diff --git a/keep-notes/components/header.tsx b/keep-notes/components/header.tsx index 3e27270..03f9b5a 100644 --- a/keep-notes/components/header.tsx +++ b/keep-notes/components/header.tsx @@ -330,12 +330,12 @@ export function Header({
{/* Settings Button */} - + {/* User Avatar Menu */} @@ -356,13 +356,17 @@ export function Header({ {currentUser?.email &&

{currentUser.email}

}
- router.push('/settings/profile')} className="cursor-pointer"> - - {t('settings.profile') || 'Profile'} + + + + {t('settings.profile') || 'Profile'} + - router.push('/admin')} className="cursor-pointer"> - - {t('nav.adminDashboard')} + + + + {t('nav.adminDashboard')} + signOut()} className="cursor-pointer text-red-600 focus:text-red-600"> diff --git a/keep-notes/components/label-filter.tsx b/keep-notes/components/label-filter.tsx index ca9b9aa..8f837ff 100644 --- a/keep-notes/components/label-filter.tsx +++ b/keep-notes/components/label-filter.tsx @@ -24,7 +24,7 @@ interface LabelFilterProps { export function LabelFilter({ selectedLabels, onFilterChange, className }: LabelFilterProps) { const { labels, loading } = useLabels() - const { t } = useLanguage() + const { t, language } = useLanguage() const [allLabelNames, setAllLabelNames] = useState([]) useEffect(() => { @@ -47,10 +47,11 @@ export function LabelFilter({ selectedLabels, onFilterChange, className }: Label if (loading || allLabelNames.length === 0) return null return ( -
- +
+
-
- - -
+ {confirmDeleteId === label.id ? ( +
+ {t('labels.confirmDeleteShort') || 'Confirmer ?'} + + +
+ ) : ( +
+ + +
+ )}
) }) @@ -188,14 +205,14 @@ export function LabelManagementDialog(props: LabelManagementDialogProps = {}) { if (controlled) { return ( - + {dialogContent} ) } return ( - + + + + +
+ + {keys.length === 0 ? ( +
+ +

{t('mcpSettings.apiKeys.empty')}

+
+ ) : ( +
+ {keys.map(k => ( + + ))} +
+ )} + + + {/* Section 4: Configuration Instructions */} + + + {/* Raw Key Display Dialog */} + { if (!open) setShowRawKey(null) }}> + + + {t('mcpSettings.createDialog.successTitle')} + + {t('mcpSettings.createDialog.successDescription')} + + +
+
+ +
+ + {showRawKey} + + +
+
+
+ + + +
+
+
+ ) +} + +// ── Sub-components ────────────────────────────────────────────────────────────── + +function CreateKeyDialog({ + onGenerate, + isPending, +}: { + onGenerate: (name: string) => void + isPending: boolean +}) { + const [name, setName] = useState('') + const { t } = useLanguage() + + return ( + + + {t('mcpSettings.createDialog.title')} + + {t('mcpSettings.createDialog.description')} + + +
+
+ + setName(e.target.value)} + className="mt-1" + /> +
+
+ + + +
+ ) +} + +function KeyCard({ + keyInfo, + onRevoke, + onDelete, + isPending, +}: { + keyInfo: McpKeyInfo + onRevoke: (shortId: string) => void + onDelete: (shortId: string) => void + isPending: boolean +}) { + const { t } = useLanguage() + + const formatDate = (iso: string | null) => { + if (!iso) return t('mcpSettings.apiKeys.never') + return new Date(iso).toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + }) + } + + return ( +
+
+
+ {keyInfo.name} + + {keyInfo.active + ? t('mcpSettings.apiKeys.active') + : t('mcpSettings.apiKeys.revoked')} + +
+
+ + {t('mcpSettings.apiKeys.createdAt')}: {formatDate(keyInfo.createdAt)} + + + {t('mcpSettings.apiKeys.lastUsed')}: {formatDate(keyInfo.lastUsedAt)} + +
+
+
+ {keyInfo.active ? ( + + ) : ( + + )} +
+
+ ) +} + +function ConfigInstructions({ serverStatus }: { serverStatus: McpServerStatus }) { + const { t } = useLanguage() + const [expanded, setExpanded] = useState(null) + + const baseUrl = serverStatus.url || 'http://localhost:3001' + + const configs = [ + { + id: 'claude-code', + title: t('mcpSettings.configInstructions.claudeCode.title'), + description: t('mcpSettings.configInstructions.claudeCode.description'), + snippet: JSON.stringify( + { + mcpServers: { + 'keep-notes': { + command: 'node', + args: ['path/to/mcp-server/index.js'], + env: { + DATABASE_URL: 'file:path/to/keep-notes/prisma/dev.db', + APP_BASE_URL: 'http://localhost:3000', + }, + }, + }, + }, + null, + 2 + ), + }, + { + id: 'cursor', + title: t('mcpSettings.configInstructions.cursor.title'), + description: t('mcpSettings.configInstructions.cursor.description'), + snippet: JSON.stringify( + { + mcpServers: { + 'keep-notes': { + url: baseUrl + '/mcp', + headers: { + 'x-api-key': 'YOUR_API_KEY', + }, + }, + }, + }, + null, + 2 + ), + }, + { + id: 'n8n', + title: t('mcpSettings.configInstructions.n8n.title'), + description: t('mcpSettings.configInstructions.n8n.description'), + snippet: `MCP Server URL: ${baseUrl}/mcp +Header: x-api-key: YOUR_API_KEY +Transport: Streamable HTTP`, + }, + ] + + return ( + +
+ +
+

+ {t('mcpSettings.configInstructions.title')} +

+

+ {t('mcpSettings.configInstructions.description')} +

+
+
+
+ {configs.map(cfg => ( +
+ + {expanded === cfg.id && ( +
+

{cfg.description}

+
+                  {cfg.snippet}
+                
+
+ )} +
+ ))} +
+
+ ) +} diff --git a/keep-notes/components/note-card.tsx b/keep-notes/components/note-card.tsx index 725ef39..49669ab 100644 --- a/keep-notes/components/note-card.tsx +++ b/keep-notes/components/note-card.tsx @@ -169,7 +169,10 @@ export const NoteCard = memo(function NoteCard({ (state, newProps: Partial) => ({ ...state, ...newProps }) ) - const colorClasses = NOTE_COLORS[optimisticNote.color as NoteColor] || NOTE_COLORS.default + // Local color state so color persists after transition ends + const [localColor, setLocalColor] = useState(note.color) + + const colorClasses = NOTE_COLORS[(localColor || optimisticNote.color) as NoteColor] || NOTE_COLORS.default // Check if this note is currently open in the editor const isNoteOpenInEditor = searchParams.get('note') === note.id @@ -263,10 +266,10 @@ export const NoteCard = memo(function NoteCard({ } const handleColorChange = async (color: string) => { + setLocalColor(color) // instant visual update, survives transition startTransition(async () => { addOptimisticNote({ color }) - await updateColor(note.id, color) - router.refresh() + await updateNote(note.id, { color }, { skipRevalidation: false }) }) } diff --git a/keep-notes/components/note-editor.tsx b/keep-notes/components/note-editor.tsx index b2801ad..1f04bf6 100644 --- a/keep-notes/components/note-editor.tsx +++ b/keep-notes/components/note-editor.tsx @@ -142,10 +142,11 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps) } // Filtrer les suggestions pour ne pas afficher celles rejetées par l'utilisateur - // (On garde celles déjà ajoutées pour les afficher en mode "validé") + // ni celles déjà présentes sur la note + const existingLabelsLower = (note.labels || []).map((l) => l.toLowerCase()) const filteredSuggestions = suggestions.filter(s => { if (!s || !s.tag) return false - return !dismissedTags.includes(s.tag) + return !dismissedTags.includes(s.tag) && !existingLabelsLower.includes(s.tag.toLowerCase()) }) const handleImageUpload = async (e: React.ChangeEvent) => { diff --git a/keep-notes/components/note-inline-editor.tsx b/keep-notes/components/note-inline-editor.tsx new file mode 100644 index 0000000..3a961a5 --- /dev/null +++ b/keep-notes/components/note-inline-editor.tsx @@ -0,0 +1,896 @@ +'use client' + +import { useState, useEffect, useRef, useCallback, useTransition } from 'react' +import { Note, CheckItem, NOTE_COLORS, NoteColor } from '@/lib/types' +import { Button } from '@/components/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover' +import { LabelBadge } from '@/components/label-badge' +import { useLanguage } from '@/lib/i18n' +import { cn } from '@/lib/utils' +import { + updateNote, + togglePin, + toggleArchive, + updateColor, + deleteNote, +} from '@/app/actions/notes' +import { fetchLinkMetadata } from '@/app/actions/scrape' +import { + Pin, + Palette, + Archive, + ArchiveRestore, + Trash2, + ImageIcon, + Link as LinkIcon, + X, + Plus, + CheckSquare, + FileText, + Eye, + Sparkles, + Loader2, + Check, + Wand2, + AlignLeft, + Minimize2, + Lightbulb, + RotateCcw, + Languages, + ChevronRight, +} from 'lucide-react' +import { toast } from 'sonner' +import { MarkdownContent } from '@/components/markdown-content' +import { EditorImages } from '@/components/editor-images' +import { useAutoTagging } from '@/hooks/use-auto-tagging' +import { GhostTags } from '@/components/ghost-tags' +import { useTitleSuggestions } from '@/hooks/use-title-suggestions' +import { TitleSuggestions } from '@/components/title-suggestions' +import { useLabels } from '@/context/LabelContext' +import { formatDistanceToNow } from 'date-fns' +import { fr } from 'date-fns/locale/fr' +import { enUS } from 'date-fns/locale/en-US' + +interface NoteInlineEditorProps { + note: Note + onDelete?: (noteId: string) => void + onArchive?: (noteId: string) => void + onChange?: (noteId: string, fields: Partial) => void + colorKey: NoteColor + /** If true and the note is a Markdown note, open directly in preview mode */ + defaultPreviewMode?: boolean +} + +function getDateLocale(language: string) { + if (language === 'fr') return fr; + if (language === 'fa') return require('date-fns/locale').faIR; + return enUS; +} + +/** Save content via REST API (not Server Action) to avoid Next.js implicit router re-renders */ +async function saveInline( + id: string, + data: { title?: string | null; content?: string; checkItems?: CheckItem[]; isMarkdown?: boolean } +) { + await fetch(`/api/notes/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }) +} + +export function NoteInlineEditor({ + note, + onDelete, + onArchive, + onChange, + colorKey, + defaultPreviewMode = false, +}: NoteInlineEditorProps) { + const { t, language } = useLanguage() + const { labels: globalLabels, addLabel } = useLabels() + const [, startTransition] = useTransition() + + // ── Local edit state ────────────────────────────────────────────────────── + const [title, setTitle] = useState(note.title || '') + const [content, setContent] = useState(note.content || '') + const [checkItems, setCheckItems] = useState(note.checkItems || []) + const [isMarkdown, setIsMarkdown] = useState(note.isMarkdown || false) + const [showMarkdownPreview, setShowMarkdownPreview] = useState( + defaultPreviewMode && (note.isMarkdown || false) + ) + const [isDirty, setIsDirty] = useState(false) + const [isSaving, setIsSaving] = useState(false) + const [dismissedTags, setDismissedTags] = useState([]) + + const changeTitle = (t: string) => { setTitle(t); onChange?.(note.id, { title: t }) } + const changeContent = (c: string) => { setContent(c); onChange?.(note.id, { content: c }) } + const changeCheckItems = (ci: CheckItem[]) => { setCheckItems(ci); onChange?.(note.id, { checkItems: ci }) } + + // Link dialog + const [linkUrl, setLinkUrl] = useState('') + const [showLinkInput, setShowLinkInput] = useState(false) + const [isAddingLink, setIsAddingLink] = useState(false) + + // AI popover + const [aiOpen, setAiOpen] = useState(false) + const [isProcessingAI, setIsProcessingAI] = useState(false) + // Undo after AI: saves content before transformation + const [previousContent, setPreviousContent] = useState(null) + // Translate sub-panel + const [showTranslate, setShowTranslate] = useState(false) + + const fileInputRef = useRef(null) + const saveTimerRef = useRef | undefined>(undefined) + const pendingRef = useRef({ title, content, checkItems, isMarkdown }) + const noteIdRef = useRef(note.id) + + // Title suggestions + const [dismissedTitleSuggestions, setDismissedTitleSuggestions] = useState(false) + const { suggestions: titleSuggestions, isAnalyzing: isAnalyzingTitles } = useTitleSuggestions({ + content: note.type === 'text' ? content : '', + enabled: note.type === 'text' && !title + }) + + // Keep pending ref in sync for unmount save + useEffect(() => { + pendingRef.current = { title, content, checkItems, isMarkdown } + }, [title, content, checkItems, isMarkdown]) + + // ── Sync when selected note switches ───────────────────────────────────── + useEffect(() => { + // Flush unsaved changes for the PREVIOUS note before switching + if (isDirty && noteIdRef.current !== note.id) { + const { title: t, content: c, checkItems: ci, isMarkdown: im } = pendingRef.current + saveInline(noteIdRef.current, { + title: t.trim() || null, + content: c, + checkItems: note.type === 'checklist' ? ci : undefined, + isMarkdown: im, + }).catch(() => {}) + } + + noteIdRef.current = note.id + setTitle(note.title || '') + setContent(note.content || '') + setCheckItems(note.checkItems || []) + setIsMarkdown(note.isMarkdown || false) + setShowMarkdownPreview(defaultPreviewMode && (note.isMarkdown || false)) + setIsDirty(false) + setDismissedTitleSuggestions(false) + clearTimeout(saveTimerRef.current) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [note.id]) + + // ── Auto-save (1.5 s debounce, skipContentTimestamp) ───────────────────── + const scheduleSave = useCallback(() => { + setIsDirty(true) + clearTimeout(saveTimerRef.current) + saveTimerRef.current = setTimeout(async () => { + const { title: t, content: c, checkItems: ci, isMarkdown: im } = pendingRef.current + setIsSaving(true) + try { + await saveInline(noteIdRef.current, { + title: t.trim() || null, + content: c, + checkItems: note.type === 'checklist' ? ci : undefined, + isMarkdown: im, + }) + setIsDirty(false) + } catch { + // silent — retry on next keystroke + } finally { + setIsSaving(false) + } + }, 1500) + }, [note.type]) + + // Flush on unmount + useEffect(() => { + return () => { + clearTimeout(saveTimerRef.current) + const { title: t, content: c, checkItems: ci, isMarkdown: im } = pendingRef.current + saveInline(noteIdRef.current, { + title: t.trim() || null, + content: c, + checkItems: note.type === 'checklist' ? ci : undefined, + isMarkdown: im, + }).catch(() => {}) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + // ── Auto-tagging ────────────────────────────────────────────────────────── + const { suggestions, isAnalyzing } = useAutoTagging({ + content: note.type === 'text' ? content : '', + notebookId: note.notebookId, + enabled: note.type === 'text', + }) + const existingLabelsLower = (note.labels || []).map((l) => l.toLowerCase()) + const filteredSuggestions = suggestions.filter( + (s) => s?.tag && !dismissedTags.includes(s.tag) && !existingLabelsLower.includes(s.tag.toLowerCase()) + ) + const handleSelectGhostTag = async (tag: string) => { + const exists = (note.labels || []).some((l) => l.toLowerCase() === tag.toLowerCase()) + if (!exists) { + const newLabels = [...(note.labels || []), tag] + // Optimistic UI — update sidebar immediately, no page refresh needed + onChange?.(note.id, { labels: newLabels }) + await updateNote(note.id, { labels: newLabels }, { skipRevalidation: true }) + const globalExists = globalLabels.some((l) => l.name.toLowerCase() === tag.toLowerCase()) + if (!globalExists) { + try { await addLabel(tag) } catch {} + } + toast.success(t('ai.tagAdded', { tag })) + } + } + + // ── Quick actions (pin, archive, color, delete) ─────────────────────────── + const handleTogglePin = () => { + startTransition(async () => { + // Optimitistic update + onChange?.(note.id, { isPinned: !note.isPinned }) + // Call with skipRevalidation to avoid server layout refresh interfering with optimistic state + await updateNote(note.id, { isPinned: !note.isPinned }, { skipRevalidation: true }) + toast.success(note.isPinned ? t('notes.unpinned') || 'Désépinglée' : t('notes.pinned') || 'Épinglée') + }) + } + + const handleToggleArchive = () => { + startTransition(async () => { + onArchive?.(note.id) + await updateNote(note.id, { isArchived: !note.isArchived }, { skipRevalidation: true }) + }) + } + + const handleColorChange = (color: string) => { + startTransition(async () => { + onChange?.(note.id, { color }) + await updateNote(note.id, { color }, { skipRevalidation: true }) + }) + } + + const handleDelete = () => { + if (!confirm(t('notes.confirmDelete'))) return + startTransition(async () => { + await deleteNote(note.id) + onDelete?.(note.id) + }) + } + + // ── Image upload ────────────────────────────────────────────────────────── + const handleImageUpload = async (e: React.ChangeEvent) => { + const files = e.target.files + if (!files) return + for (const file of Array.from(files)) { + const formData = new FormData() + formData.append('file', file) + try { + const res = await fetch('/api/upload', { method: 'POST', body: formData }) + if (!res.ok) throw new Error('Upload failed') + const data = await res.json() + const newImages = [...(note.images || []), data.url] + onChange?.(note.id, { images: newImages }) + await updateNote(note.id, { images: newImages }) + } catch { + toast.error(t('notes.uploadFailed', { filename: file.name })) + } + } + if (fileInputRef.current) fileInputRef.current.value = '' + } + + const handleRemoveImage = async (index: number) => { + const newImages = (note.images || []).filter((_, i) => i !== index) + onChange?.(note.id, { images: newImages }) + await updateNote(note.id, { images: newImages }) + } + + // ── Link ────────────────────────────────────────────────────────────────── + const handleAddLink = async () => { + if (!linkUrl) return + setIsAddingLink(true) + try { + const metadata = await fetchLinkMetadata(linkUrl) + const newLink = metadata || { url: linkUrl, title: linkUrl } + const newLinks = [...(note.links || []), newLink] + onChange?.(note.id, { links: newLinks }) + await updateNote(note.id, { links: newLinks }) + toast.success(t('notes.linkAdded')) + } catch { + toast.error(t('notes.linkAddFailed')) + } finally { + setLinkUrl('') + setShowLinkInput(false) + setIsAddingLink(false) + } + } + + const handleRemoveLink = async (index: number) => { + const newLinks = (note.links || []).filter((_, i) => i !== index) + onChange?.(note.id, { links: newLinks }) + await updateNote(note.id, { links: newLinks }) + } + + // ── AI actions (called from Popover in toolbar) ─────────────────────────── + const callAI = async (option: 'clarify' | 'shorten' | 'improve') => { + const wc = content.split(/\s+/).filter(Boolean).length + if (!content || wc < 10) { + toast.error(t('ai.reformulationMinWords', { count: wc })) + return + } + setAiOpen(false) + setShowTranslate(false) + setPreviousContent(content) // save for undo + setIsProcessingAI(true) + try { + const res = await fetch('/api/ai/reformulate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text: content, option }), + }) + const data = await res.json() + if (!res.ok) throw new Error(data.error || 'Failed to reformulate') + changeContent(data.reformulatedText || data.text) + scheduleSave() + toast.success(t('ai.reformulationApplied')) + } catch { + toast.error(t('ai.reformulationFailed')) + setPreviousContent(null) + } finally { + setIsProcessingAI(false) + } + } + + const callTranslate = async (targetLanguage: string) => { + const wc = content.split(/\s+/).filter(Boolean).length + if (!content || wc < 3) { toast.error(t('ai.reformulationMinWords', { count: wc })); return } + setAiOpen(false) + setShowTranslate(false) + setPreviousContent(content) + setIsProcessingAI(true) + try { + const res = await fetch('/api/ai/translate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text: content, targetLanguage }), + }) + const data = await res.json() + if (!res.ok) throw new Error(data.error || 'Translation failed') + changeContent(data.translatedText) + scheduleSave() + toast.success(t('ai.translationApplied') || `Traduit en ${targetLanguage}`) + } catch { + toast.error(t('ai.translationFailed') || 'Traduction échouée') + setPreviousContent(null) + } finally { + setIsProcessingAI(false) + } + } + + const handleTransformMarkdown = async () => { + const wc = content.split(/\s+/).filter(Boolean).length + if (!content || wc < 10) { toast.error(t('ai.reformulationMinWords', { count: wc })); return } + setAiOpen(false) + setShowTranslate(false) + setPreviousContent(content) + setIsProcessingAI(true) + try { + const res = await fetch('/api/ai/transform-markdown', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text: content }), + }) + const data = await res.json() + if (!res.ok) throw new Error(data.error) + changeContent(data.transformedText) + setIsMarkdown(true) + scheduleSave() + toast.success(t('ai.transformSuccess')) + } catch { + toast.error(t('ai.transformError')) + setPreviousContent(null) + } finally { + setIsProcessingAI(false) + } + } + + // ── Checklist helpers ───────────────────────────────────────────────────── + const handleToggleCheckItem = (id: string) => { + const updated = checkItems.map((ci) => + ci.id === id ? { ...ci, checked: !ci.checked } : ci + ) + setCheckItems(updated) + scheduleSave() + } + + const handleUpdateCheckText = (id: string, text: string) => { + const updated = checkItems.map((ci) => (ci.id === id ? { ...ci, text } : ci)) + setCheckItems(updated) + scheduleSave() + } + + const handleAddCheckItem = () => { + const updated = [...checkItems, { id: Date.now().toString(), text: '', checked: false }] + setCheckItems(updated) + scheduleSave() + } + + const handleRemoveCheckItem = (id: string) => { + const updated = checkItems.filter((ci) => ci.id !== id) + setCheckItems(updated) + scheduleSave() + } + + const dateLocale = getDateLocale(language) + + return ( +
+ + {/* ── Toolbar ────────────────────────────────────────────────────────── */} +
+
+ {/* Image upload */} + + + + {/* Link */} + + + {/* Markdown toggle */} + + + {isMarkdown && ( + + )} + + {/* ── AI Popover (in toolbar, non-intrusive) ─────────────────────── */} + {note.type === 'text' && ( + { setAiOpen(o); if (!o) setShowTranslate(false) }}> + + + + + {!showTranslate ? ( +
+ + + + +
+ +
+ ) : ( +
+ +
+ {[ + { code: 'French', label: 'Français 🇫🇷' }, + { code: 'English', label: 'English 🇬🇧' }, + { code: 'Persian', label: 'فارسی 🇮🇷' }, + { code: 'Spanish', label: 'Español 🇪🇸' }, + { code: 'German', label: 'Deutsch 🇩🇪' }, + { code: 'Italian', label: 'Italiano 🇮🇹' }, + { code: 'Portuguese', label: 'Português 🇵🇹' }, + { code: 'Arabic', label: 'العربية 🇸🇦' }, + { code: 'Chinese', label: '中文 🇨🇳' }, + { code: 'Japanese', label: '日本語 🇯🇵' }, + ].map(({ code, label }) => ( + + ))} +
+ )} + + + )} + + {/* ── Undo AI button ─────────────────────────────────────────────── */} + {previousContent !== null && ( + + )} +
+ +
+ {/* Save status indicator */} + + {isSaving ? ( + <> Sauvegarde… + ) : isDirty ? ( + <> Modifié + ) : ( + <> Sauvegardé + )} + + + {/* Pin */} + + + {/* Color picker */} + + + + + +
+ {Object.entries(NOTE_COLORS).map(([name, cls]) => ( +
+
+
+ + {/* More actions */} + + + + + + + {note.isArchived + ? <>{t('notes.unarchive')} + : <>{t('notes.archive')}} + + + + {t('notes.delete')} + + + +
+
+ + {/* ── Link input bar (inline) ───────────────────────────────────────── */} + {showLinkInput && ( +
+ setLinkUrl(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') handleAddLink() }} + autoFocus + /> + + +
+ )} + + {/* ── Labels strip + AI suggestions — always visible outside scroll area ─ */} + {((note.labels?.length ?? 0) > 0 || filteredSuggestions.length > 0 || isAnalyzing) && ( +
+ {/* Existing labels */} + {(note.labels ?? []).map((label) => ( + + ))} + {/* AI-suggested tags inline with labels */} + setDismissedTags((p) => [...p, tag])} + /> +
+ )} + + {/* ── Scrollable editing area (takes all remaining height) ─────────── */} +
+ {/* Title row with optional AI suggest button */} +
+ { changeTitle(e.target.value); scheduleSave() }} + /> + {/* AI title suggestion — show when title is empty and there's content */} + {!title && content.trim().split(/\s+/).filter(Boolean).length >= 5 && ( + + )} +
+ + {/* Title Suggestions Dropdown / Inline list */} + {!title && !dismissedTitleSuggestions && titleSuggestions.length > 0 && ( +
+ { changeTitle(selectedTitle); scheduleSave() }} + onDismiss={() => setDismissedTitleSuggestions(true)} + /> +
+ )} + + {/* Images */} + {note.images && note.images.length > 0 && ( +
+ +
+ )} + + {/* Link previews */} + {note.links && note.links.length > 0 && ( +
+ {note.links.map((link, idx) => ( +
+ {link.imageUrl && ( +
+ )} +
+

{link.title || link.url}

+ {link.description &&

{link.description}

} + + {(() => { try { return new URL(link.url).hostname } catch { return link.url } })()} + +
+ +
+ ))} +
+ )} + + {/* ── Text / Checklist content ───────────────────────────────────── */} +
+ {note.type === 'text' ? ( +
+ {showMarkdownPreview && isMarkdown ? ( +
+ +
+ ) : ( +