diff --git a/.cursor/hooks/state/continual-learning-index.json b/.cursor/hooks/state/continual-learning-index.json index 21f1100..4785d36 100644 --- a/.cursor/hooks/state/continual-learning-index.json +++ b/.cursor/hooks/state/continual-learning-index.json @@ -1,6 +1,7 @@ { "/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/0c6fb2d9-1b82-4ca3-b0f4-f8373a62faca/0c6fb2d9-1b82-4ca3-b0f4-f8373a62faca.jsonl": 1778182618469, "/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/137b1f4b-59d9-4ce6-8d74-01f7cbae2ba7/137b1f4b-59d9-4ce6-8d74-01f7cbae2ba7.jsonl": 1778966645519, + "/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/16214191-7091-4aef-a309-f922d351d79f/16214191-7091-4aef-a309-f922d351d79f.jsonl": 1779646940751, "/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/2e0ce74c-a31e-49d8-a0d0-a8b224813533/2e0ce74c-a31e-49d8-a0d0-a8b224813533.jsonl": 1778188935902, "/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/38000361-5c66-4032-8e1e-ef405e843de0/38000361-5c66-4032-8e1e-ef405e843de0.jsonl": 1778968570815, "/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/394af47d-c5cd-4cef-bef2-2192717439f8/394af47d-c5cd-4cef-bef2-2192717439f8.jsonl": 1778951280378, @@ -10,10 +11,12 @@ "/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/65570f8a-5cd2-4573-b2d9-0983f2922d1f/65570f8a-5cd2-4573-b2d9-0983f2922d1f.jsonl": 1778231172346, "/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/7b6c0ed0-caad-4157-b048-535452685b73/7b6c0ed0-caad-4157-b048-535452685b73.jsonl": 1778852401511, "/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/8c2fc9f5-c359-4c67-a0f5-325ee44cebc9/8c2fc9f5-c359-4c67-a0f5-325ee44cebc9.jsonl": 1778751052502, + "/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/90c791ad-a274-4673-b5f6-ec1bccaccc98/90c791ad-a274-4673-b5f6-ec1bccaccc98.jsonl": 1779566465299, "/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/92d73875-5939-48fb-9f68-86c88b0f2ff7/92d73875-5939-48fb-9f68-86c88b0f2ff7.jsonl": 1778966017038, "/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/9902a438-467f-4d57-8f43-28e7d579a95f/9902a438-467f-4d57-8f43-28e7d579a95f.jsonl": 1778839341001, "/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/a64d78ce-86d3-4ec8-8f79-7589ad05a62c/a64d78ce-86d3-4ec8-8f79-7589ad05a62c.jsonl": 1778846298067, - "/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/b85430f3-4520-47fd-9b4b-5200ca340a36/b85430f3-4520-47fd-9b4b-5200ca340a36.jsonl": 1779026409041, + "/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/af84066e-c0c2-435e-8caf-73037ebf4320/af84066e-c0c2-435e-8caf-73037ebf4320.jsonl": 1779569075175, + "/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/b85430f3-4520-47fd-9b4b-5200ca340a36/b85430f3-4520-47fd-9b4b-5200ca340a36.jsonl": 1779039005865, "/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/ca85061e-6af9-4250-8dc7-9c3bb4839c48/ca85061e-6af9-4250-8dc7-9c3bb4839c48.jsonl": 1778849848444, "/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/d92dfb04-c148-4a14-a48a-39d4c634caee/d92dfb04-c148-4a14-a48a-39d4c634caee.jsonl": 1778861502433, "/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/e3745f62-c3b9-4a21-8942-71bc6f603f77/e3745f62-c3b9-4a21-8942-71bc6f603f77.jsonl": 1778018654221 diff --git a/.cursor/hooks/state/continual-learning.json b/.cursor/hooks/state/continual-learning.json index 6bfd167..a68385e 100644 --- a/.cursor/hooks/state/continual-learning.json +++ b/.cursor/hooks/state/continual-learning.json @@ -1,8 +1,8 @@ { "version": 1, - "lastRunAtMs": 1779034436676, - "turnsSinceLastRun": 13, - "lastTranscriptMtimeMs": 1779034436585.3164, - "lastProcessedGenerationId": "fac69259-f459-4519-94aa-2b72a9453b24", + "lastRunAtMs": 1779646930753, + "turnsSinceLastRun": 6, + "lastTranscriptMtimeMs": 1779646930615.2869, + "lastProcessedGenerationId": "c2a9fd9d-5b50-42a8-8b17-e414b0be891e", "trialStartedAtMs": null } diff --git a/AGENTS.md b/AGENTS.md index 579941e..12d0d78 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,15 +3,15 @@ ## Learned User Preferences - Préfère les échanges en français, avec des explications détaillées et claires (éviter le jargon flou). -- Interface : tout libellé via i18n dans les 15 fichiers `memento-note/locales/*.json` (FR et EN comme références de contenu) ; éviter le texte en dur ; traductions **contextuelles** (sens produit, pas mot à mot — ex. « connecter votre propre fournisseur ») ; lors d'une traduction complète, mettre à jour toutes les locales concernées ; si l'utilisateur demande seulement les **clés i18n**, ajouter les clés (souvent EN/FR) sans remplir les 15 locales — il traduit souvent avec un autre modèle. +- Interface : tout libellé via i18n dans les 15 fichiers `memento-note/locales/*.json` (FR et EN comme références de contenu) ; éviter le texte en dur ; traductions **contextuelles** (sens produit, pas mot à mot — ex. « connecter votre propre fournisseur ») ; libellés FR **lisibles** (éviter jargon non expliqué : « wiki », « embed », etc.) et **aide contextuelle** où l'UX l'exige ; lors d'une traduction complète, mettre à jour toutes les locales concernées ; si l'utilisateur demande seulement les **clés i18n**, ajouter les clés (souvent EN/FR) sans remplir les 15 locales — il traduit souvent avec un autre modèle. - Base de données : **INTERDIT TOTALEMENT** de lancer `prisma db push --force-reset`, `prisma migrate reset`, `DROP TABLE`, `TRUNCATE`, `pg_restore` avec clean, ou TOUTE commande qui vide/supprime des données — MÊME SI l'utilisateur est d'accord — sans avoir d'abord : (1) dumpé la base avec `bash /home/devparsa/dev/Momento/dump-db.sh`, (2) vérifié le dump fait au moins 1Mo, (3) obtenu un "OUI" explicite de l'utilisateur. **4 incidents de perte de données documentés (14/05, 15/05 x2, 16/05). NE JAMAIS REFAIRE ÇA.** -- Design produit : migration depuis `architectural-grid1` (base) et `architectural-grid` (prototype UI courant) ; **consulter le prototype avant toute implémentation UI** ; logique liste/carte puis contenu au clic ; parité actions liste/carte (menus « … », déplacer, génération SVG, etc.) ; contraste éditeur clair / sidebar sombre ; retirer thèmes obsolètes ; **pas de refresh/revalidation complets inutiles** (aligné prototype — mutations optimistes, pas de `revalidatePath` systématique ni resync depuis `initialNotes`) ; **Memory Echo en section inline dans l'éditeur** (pas l'ancienne modale) ; si l'utilisateur hésite entre variantes UX, **trancher pour le design prototype** plutôt que multiplier les toggles. -- Locale persane : dates en calendrier iranien (conversion), chiffres persans, et vérification RTL/positionnement global ; attention à ne pas confondre un nom de carnet (ex. « Persan ») avec le libellé de langue. +- Design produit : migration depuis `architectural-grid1` (base) et `architectural-grid` (prototype UI courant) ; **consulter le prototype avant toute implémentation UI** ; logique liste/carte puis contenu au clic ; parité actions liste/carte (menus « … », déplacer, génération SVG, etc.) ; contraste éditeur clair / sidebar sombre ; retirer thèmes obsolètes ; **pas de refresh/revalidation complets inutiles** (aligné prototype — mutations optimistes, pas de `revalidatePath` systématique ni resync depuis `initialNotes`) ; **Memory Echo en section inline dans l'éditeur** (pas l'ancienne modale) — similarité sur contenu **représentatif** (pas de troncature arbitraire type 200/800 car.) ; **recherche (sidebar / résultats, ex. flux « ouvrir la note ») et navigation liste des notes** (modes affichage, icônes vs initiales…) : suivre **`SearchModal` et les patterns actuels** dans `architectural-grid`, pas une approximation ou un ancien flux ; **`/insights` (insights sémantiques)** : suivre **`InsightsView.tsx` + graphe réseau associé dans le prototype** (ex. composition type `NetworkGraph.tsx`) ; **distincte de `/graph`** ; ne pas substituer par une UX « géométrique » décorative ou un regroupement par carnet hors spec prototype ; lorsque données clusters en retard ou partiellement périmées, **montrer l’état dégradé exploitable plutôt qu’une page vide** ; si l'utilisateur hésite entre variantes UX, **trancher pour le design prototype** plutôt que multiplier les toggles. +- Locale persane : dates en calendrier iranien (conversion), chiffres persans, et vérification RTL/positionnement global ; **Memory Echo** et recherche sémantique doivent fonctionner en persan (RTL, embeddings — pas de contournement « EN only ») ; attention à ne pas confondre un nom de carnet (ex. « Persan ») avec le libellé de langue. - Flux Excalidraw / diagrammes générés : accès via notification en plus d'une simple redirection ; priorité à la mise en page et au texte contenu dans les formes ; proposer des modes visuels (ex. coloré vs plus austère) tout en visant un rendu proche du style Excalidraw (polices, look). - **Interdiction d'écrire des tests** sauf demande explicite ; en CI, seul `npm run test:unit` (`tests/unit/**`) — pas `tests/migration/` ; ne jamais générer de code superflu. - Déploiement : privilégier le chemin rapide (artifact Next.js en CI + `Dockerfile.prebuilt`) ; CI/CD très robuste (pas d'image Docker obsolète en prod, pas de migrations/schéma DB via le workflow deploy) ; éviter les rebuild Docker complets inutiles (~15 min par itération) ; **ne pas pousser un déploiement quand des features clés sont inachevées** ; ne pas insister sur le déploiement tant que le produit n'est pas fini ou meilleur. - Authentification : priorité à l'inscription/connexion via **Google OAuth** (plutôt qu'un compte email/mot de passe) ; exiger une vraie déconnexion (invalidation session/cookies — pas de reconnexion implicite, y compris en navigation privée). -- Priorité absolue à la qualité UX, même si l'implémentation est complexe (« je m'en fous si c'est complexe ») ; **ne jamais affirmer qu'un correctif ou une feature est fait sans vérification réelle** (app, prototype `architectural-grid`, ou test) — l'utilisateur sanctionne fermement les fausses déclarations. +- Priorité absolue à la qualité UX, même si l'implémentation est complexe (« je m'en fous si c'est complexe ») ; **ne jamais affirmer qu'un correctif ou une feature est fait sans vérification réelle** (app, prototype `architectural-grid`, ou test), **notamment navigation recherche/liste notes et vue `/insights` vs fichiers prototype** — l'utilisateur sanctionne fermement les fausses déclarations ; en frustration ou pour déléguer, **prévoir des prompts / briefs d'implémentation détaillés** (autre modèle ou dev), en plus des briefs outil de design. - Livraison : **une user story à la fois**, tester et valider avec l'utilisateur avant la suivante ; suivi dans `docs/user-stories.md`. - Quand demandé, **fournir des briefs pour un outil de design externe** plutôt que produire les maquettes UX soi-même. @@ -28,4 +28,4 @@ - Migrations dans l'image prebuilt : `docker compose exec memento-note node ./node_modules/prisma/build/index.js migrate deploy` (pas `npx prisma` dans le PATH) ; helper `scripts/migrate-docker.sh`. - Vérification deploy : `GET /api/build-info` (SHA Git) ; comparer `127.0.0.1:3000` et le domaine Cloudflare — purger le cache si versions divergent ; 403 sur `/api/manifest` côté domaine = souvent Cloudflare, pas l'app. - Sync mutations notes entre composants : `memento-note/lib/note-change-sync.ts` (`emitNoteChange`, événement `NOTE_CHANGE_EVENT`). -- Roadmap / écart prototype vs prod : Web Clipper, Living Blocks (TipTap UniqueID), Structured Views, Flashcards IA (SM-2), Graph Knowledge Map — prototypes dans `architectural-grid/` (`ClipperSimulator.tsx`, `RevisionView.tsx`, `GraphKnowledgeMap.tsx`) ; en prod : `network-graph.tsx`, `note-document-info-panel.tsx`, `note-history-modal.tsx`, `rich-text-editor.tsx` (sans UniqueID pour l'instant). +- Roadmap / écart prototype vs prod : Web Clipper — **`ClipperSimulator.tsx` = référence design uniquement** (pas de simulateur en prod à la place de l'extension) ; Living Blocks (`UniqueID` / embeds dans le prototype), Structured Views, Flashcards IA SM-2 (`RevisionView.tsx`), graphe knowledge (`GraphKnowledgeMap.tsx`), **insights sémantiques** (`InsightsView.tsx` + graphe réseau associé dans le prototype) — **`/insights` ≠ `/graph`** ; prod : extension navigateur **`memento-note/extension/`** v0.2 **Side Panel** (mode sélection : popup Chrome se ferme au clic page — limitation plateforme) ; **`host_permissions` / origins** couvrant l'URL serveur y compris **LAN** ; **URL serveur configurable** dans les paramètres extension en dev ; cookies/session alignés avec l'instance cible ; publication **Chrome Web Store** : icônes 16/48/128, privacy policy, `host_permissions` prod restreints vs build dev ; `network-graph.tsx`, `/insights`, `note-document-info-panel.tsx`, `note-history-modal.tsx`, `rich-text-editor.tsx`. diff --git a/Capture d'écran 2026-05-24 195056.png b/Capture d'écran 2026-05-24 195056.png new file mode 100644 index 0000000..9c3adb7 Binary files /dev/null and b/Capture d'écran 2026-05-24 195056.png differ diff --git a/Capture d'écran 2026-05-24 195513.png b/Capture d'écran 2026-05-24 195513.png new file mode 100644 index 0000000..a9b7079 Binary files /dev/null and b/Capture d'écran 2026-05-24 195513.png differ diff --git a/Capture d’écran 2026-05-24 194911.png b/Capture d’écran 2026-05-24 194911.png new file mode 100644 index 0000000..79a94ae Binary files /dev/null and b/Capture d’écran 2026-05-24 194911.png differ diff --git a/docs/user-stories.md b/docs/user-stories.md index f567054..809b292 100644 --- a/docs/user-stories.md +++ b/docs/user-stories.md @@ -12,11 +12,11 @@ | **US-SIDEBAR** | Sidebar deux colonnes (rail d'icônes + panneau) | ✅ **LIVRÉ** | `sidebar.tsx` restructuré | | **US-SEARCH** | Recherche Globale Dual-Panel + Ctrl+K | ✅ **LIVRÉ** | `search-modal.tsx`, `search-modal-context.tsx`, bug `openNote` corrigé | | **US-LIVING-BLOCKS** | Blocs Vivants (Transclusion Bidirectionnelle) | ✅ **LIVRÉ** | `tiptap-unique-id-extension.ts`, `tiptap-live-block-extension.tsx`, `block-picker.tsx`, `app/api/blocks/*`, migration `LiveBlockRef` | -| **US-MEMORY-ECHO** | Résonance Sémantique + Embed depuis Echo | 🚧 **En cours** | `memory-echo-section.tsx`, `/api/notes/[id]/live-block-refs`, `/api/blocks/resolve` | -| **US-INFO-RÉSEAU** | Panneau Info + Réseau Local | ⏳ À faire | — | -| **US-CLIPPER** | Web Clipper | ⏳ À faire | — | -| **US-GRAPH** | Graphe de Connaissance Global enrichi | ⏳ À faire | — | -| **US-INSIGHTS** | Clusters Sémantiques + Bridge Notes | ⏳ À faire | — | +| **US-MEMORY-ECHO** | Résonance Sémantique + Embed depuis Echo | ✅ **LIVRÉ** | `memory-echo-section.tsx`, `/api/notes/[id]/live-block-refs`, `/api/blocks/resolve` | +| **US-INFO-RÉSEAU** | Panneau Info + Réseau Local | ✅ **LIVRÉ** | `note-network-tab.tsx`, `sync-note-links.ts`, migration `NoteLink`, picker `[[` | +| **US-CLIPPER** | Web Clipper | 🚧 **En cours** | `extension/`, `/api/clip/*`, migration `sourceUrl`, badge panneau Info | +| **US-GRAPH** | Graphe de Connaissance Global enrichi | ✅ **LIVRÉ** | `note-graph-view.tsx` — filtres liens, seuil sémantique, focus voisinage, couleurs carnets, double-clic ouverture | +| **US-INSIGHTS** | Clusters Sémantiques + Bridge Notes | 🚧 **EN COURS** | clusters en base mais page masquait les résultats périmés — correction affichage | | **US-TEMPORAL** | Prédictions d'accès temporelles | ⏳ À faire | — | | **US-FLASHCARDS** | Révision IA — Répétition espacée SM-2 | ⏳ À faire | — | | **US-STRUCTURED-VIEWS** | Vues Structurées (Tableau/Kanban/Galerie) | ⏳ À faire | — | diff --git a/memento-note/app/(main)/insights/page.tsx b/memento-note/app/(main)/insights/page.tsx index eaa3187..1b28191 100644 --- a/memento-note/app/(main)/insights/page.tsx +++ b/memento-note/app/(main)/insights/page.tsx @@ -1,10 +1,23 @@ 'use client' -import { useState, useEffect } from 'react' +import { useState, useEffect, useMemo } from 'react' import { NetworkGraph } from '@/components/network-graph' import { useRouter } from 'next/navigation' -import { motion } from 'motion/react' -import { Sparkles, RefreshCw, Layers, Trophy, Zap, Lightbulb } from 'lucide-react' +import { motion, AnimatePresence } from 'motion/react' +import { + Sparkles, + RefreshCw, + Layers, + Trophy, + Zap, + Lightbulb, + Sliders, + CheckCircle2, + Clock, + AlertCircle, + ChevronRight, + Database, +} from 'lucide-react' interface Note { id: string @@ -53,21 +66,51 @@ export default function InsightsPage() { const [suggestions, setSuggestions] = useState([]) const [loading, setLoading] = useState(true) const [isCalculating, setIsCalculating] = useState(false) + const [isReindexing, setIsReindexing] = useState(false) + const [embeddingStats, setEmbeddingStats] = useState<{ indexed: number; total: number } | null>(null) + const [isStale, setIsStale] = useState(false) + const [selectedClusterId, setSelectedClusterId] = useState(null) + const [viewMode, setViewMode] = useState<'graph' | 'dashboard'>('dashboard') + const [lastSyncTime, setLastSyncTime] = useState('') useEffect(() => { loadInitialData() }, []) + // ─── Données calculées ─────────────────────────────────────────────────────── + + const selectedCluster = useMemo( + () => clusters.find(c => c.id === selectedClusterId) ?? null, + [clusters, selectedClusterId] + ) + + const selectedClusterNotes = useMemo( + () => (selectedCluster ? notes.filter(n => selectedCluster.noteIds.includes(n.id)) : []), + [notes, selectedCluster] + ) + + const isolatedClusters = useMemo(() => { + const networkedIds = new Set( + bridgeNotes.flatMap(b => b.clustersConnected.map(cid => String(cid))) + ) + return clusters.filter(c => !networkedIds.has(c.id)) + }, [clusters, bridgeNotes]) + + const bridgeList = useMemo( + () => bridgeNotes.map(b => ({ ...b, title: b.note?.title || 'Note sans titre' })), + [bridgeNotes] + ) + + // ─── Chargement initial ────────────────────────────────────────────────────── + const loadInitialData = async () => { setLoading(true) try { - // First, try to get cached clusters const res = await fetch('/api/clusters') if (res.ok) { const data = await res.json() - // Check if we have clusters - if (data.clusters && data.clusters.length > 0) { + if (data.clusters?.length > 0) { const clustersWithColors = data.clusters.map((c: Cluster, i: number) => ({ ...c, id: c.clusterId.toString(), @@ -75,26 +118,35 @@ export default function InsightsPage() { })) setNotes(data.notes || []) setClusters(clustersWithColors) + setIsStale(!!data.stale) - // Load bridge notes - const bridgeRes = await fetch('/api/bridge-notes?details=true') - if (bridgeRes.ok) { - const bridgeData = await bridgeRes.json() - setBridgeNotes(bridgeData.bridgeNotes || []) + // Bridge notes incluses dans la réponse GET /clusters (enrichies) + if (data.bridgeNotes?.length > 0) { + setBridgeNotes(data.bridgeNotes) + } else { + const bridgeRes = await fetch('/api/bridge-notes?details=true') + if (bridgeRes.ok) { + const bridgeData = await bridgeRes.json() + setBridgeNotes(bridgeData.bridgeNotes || []) + } } - // Load suggestions const suggestionsRes = await fetch('/api/bridge-notes/suggestions') if (suggestionsRes.ok) { const suggestionsData = await suggestionsRes.json() setSuggestions(suggestionsData.suggestions || []) } + + setLastSyncTime( + new Date().toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' }) + ) + if (typeof data.embeddingCount === 'number' && typeof data.totalNotes === 'number') { + setEmbeddingStats({ indexed: data.embeddingCount, total: data.totalNotes }) + } } else { - // No clusters - trigger calculation if we have enough notes - if (data.totalNotes >= 10) { - await performAnalysis() - } else { - // Not enough notes - show empty state + setIsStale(false) + if (typeof data.embeddingCount === 'number' && typeof data.totalNotes === 'number') { + setEmbeddingStats({ indexed: data.embeddingCount, total: data.totalNotes }) } } } @@ -105,6 +157,29 @@ export default function InsightsPage() { } } + const handleReindexEmbeddings = async () => { + setIsReindexing(true) + try { + const res = await fetch('/api/notes/reindex', { method: 'POST' }) + if (!res.ok) throw new Error('reindex failed') + const data = await res.json() + setEmbeddingStats(prev => ({ + indexed: data.count ?? prev?.indexed ?? 0, + total: data.total ?? prev?.total ?? notes.length, + })) + setLastSyncTime( + new Date().toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' }) + ) + setIsStale(true) + } catch (error) { + console.error('Error reindexing embeddings:', error) + } finally { + setIsReindexing(false) + } + } + + // ─── Analyse (POST) ────────────────────────────────────────────────────────── + const performAnalysis = async () => { setIsCalculating(true) try { @@ -124,13 +199,23 @@ export default function InsightsPage() { setNotes(data.notes || []) setClusters(clustersWithColors) setBridgeNotes(data.bridgeNotes || []) + setIsStale(false) - // Load suggestions (they were generated during POST) const suggestionsRes = await fetch('/api/bridge-notes/suggestions') if (suggestionsRes.ok) { const suggestionsData = await suggestionsRes.json() setSuggestions(suggestionsData.suggestions || []) } + + setLastSyncTime( + new Date().toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' }) + ) + if (data.notes?.length) { + setEmbeddingStats(prev => ({ + indexed: prev?.indexed ?? data.notes.length, + total: data.notes.length, + })) + } } } catch (error) { console.error('Error running analysis:', error) @@ -140,131 +225,420 @@ export default function InsightsPage() { } const handleNoteClick = (noteId: string) => { - router.push(`/home?note=${noteId}`) + router.push(`/home?openNote=${noteId}`) } - const bridgeList = bridgeNotes.map(b => ({ - ...b, - title: b.note?.title || 'Unknown Note' - })) + // ─── Rendu ─────────────────────────────────────────────────────────────────── return ( -
- {/* Header */} -
+
+ + {/* ── Header ── */} +
-
+
-

Semantic Insights

+

+ Analyses & Cartographie +

-

Discovering the hidden architecture of your knowledge

+

+ Modèles sémantiques & clusters de connaissances +

+
+ +
+ {/* Tab switcher mobile */} +
+ + +
+ +
-
- {/* Loading State */} + {/* ── Chargement ── */} {loading && (
-
-

Analyzing your notes...

+
+

Chargement de vos clusters...

)} - {/* Empty State - only if truly no notes */} + {/* ── État vide ── */} {!loading && clusters.length === 0 && !isCalculating && (
-
-
- + +
+
-

- Discover your knowledge clusters +

+ Vos notes forment des thèmes

-

- Click "Re-sync Network" to analyze your notes and find hidden connections +

+ Cliquez sur “Re-analyser” pour découvrir les groupes sémantiques de vos notes + et les connexions cachées entre vos idées.

-
+ +
)} - {/* Main Content */} - {!loading && clusters.length > 0 && ( -
- {/* Left: Graph View */} -
+ {/* ── Calcul en cours ── */} + {isCalculating && !loading && ( +
+ +
+ +
+
+

+ Analyse en cours... +

+

+ Calcul des similarités sémantiques et détection des clusters de connaissances +

+
+
+
+ )} + + {/* ── Contenu principal ── */} + {!loading && clusters.length > 0 && !isCalculating && ( +
+ + {/* ── Graphe (gauche) ── */} +
- {/* Right: Insight Dashboard */} -
-
- {/* Stats Summary */} + {/* ── Dashboard (droite) ── */} +
+
+ + {/* Avertissement d'obsolescence (stale banner) */} + {isStale && !isCalculating && ( + +
+ + + Vos notes ont été modifiées. Mettez à jour vos insights pour une cartographie sémantique précise. + +
+ +
+ )} + + {/* ① Panneau d'inspection cluster */} + + {selectedCluster && ( + +
+
+
+ + Focus Cluster Activé + +

+ {selectedCluster.name || `Cluster ${selectedCluster.clusterId}`} +

+
+ +
+
+

+ Cet ensemble thématique réunit{' '} + + {selectedClusterNotes.length} notes + + . Cliquez pour accéder directement : +

+
+ {selectedClusterNotes.map(note => ( + + ))} +
+
+ + )} + + + {/* ② Stats */}
-
+
- Clusters + + Clusters Actifs + +
+
+
+ {clusters.length} +
+

+ Détectés sans à priori +

-
{clusters.length}
-
+
- Bridge Notes + + Notes-Ponts + +
+
+
+ {bridgeNotes.length} +
+

+ Passerelles d'idées +

-
{bridgeNotes.length}
- {/* Bridge Notes Section */} -
-
+ {/* ③ Système de Recalcul */} +
+
+
+ +

+ Système de Recalcul +

+
+ + Synchronisé + +
+
+
+ CRON PLANIFIÉ +

+ Quotidien (04:00) +

+
+
+ DERNIÈRE SYNC +

+ {lastSyncTime || '—'} +

+
+
+
+
+ Notes indexées (texte complet) : + + {embeddingStats + ? `${embeddingStats.indexed} / ${embeddingStats.total}` + : '—'} + +
+

+ Chaque note est convertie en texte brut intégral puis découpée en chunks si + nécessaire (ex. 17 679 caractères → plusieurs vecteurs fusionnés). Aucune + limite artificielle à 200 ou 800 caractères pour la similarité. +

+ + + « Re-analyser » réindexe aussi les embeddings puis regénère les clusters. + +
+
+ + {/* ④ Clusters Isolés */} +
+
+
+ +

+ Clusters Isolés ({isolatedClusters.length}) +

+
+ Sans points d'accroche +
+
+ {isolatedClusters.map(c => ( + setSelectedClusterId(c.id)} + className="p-3.5 rounded-xl bg-white dark:bg-zinc-800 border border-border/30 hover:border-black/10 dark:hover:border-white/10 flex items-center justify-between cursor-pointer transition-all" + > +
+
+ + {c.name || `Cluster ${c.clusterId}`} + +
+ + Non connecté + + + ))} + {isolatedClusters.length === 0 && ( +
+ Tous les clusters thématiques sont liés par au moins un point de passage sémantique ! +
+ )} +
+
+ + {/* ⑤ Notes-Ponts Influentes */} +
+
-

Powerful Bridge Notes

+

+ Notes-Ponts Influentes +

- {bridgeList.map((bridge) => ( + {bridgeList.map(bridge => ( handleNoteClick(bridge.noteId)} - className="p-4 rounded-xl bg-white dark:bg-white/5 border border-border hover:border-ochre/40 transition-all cursor-pointer group" + className="p-4 rounded-xl bg-white dark:bg-zinc-800 border border-border/30 hover:border-ochre/40 hover:shadow-sm transition-all cursor-pointer group" > -
-

+
+

{bridge.title}

- - Score: {(bridge.bridgeScore * 100).toFixed(0)}% + + Lien : {(bridge.bridgeScore * 100).toFixed(0)}%
-
- {bridge.clusterNames?.map((name, i) => { - const cluster = clusters.find(c => c.name === name) +
+ {bridge.clustersConnected.map(cid => { + const cluster = clusters.find(c => c.id === String(cid)) return ( -
-
- {name} +
{ + e.stopPropagation() + setSelectedClusterId(String(cid)) + }} + className="flex items-center gap-1.5 px-2 py-0.5 bg-black/[0.02] dark:bg-white/[0.02] border border-border/30 rounded-md hover:border-concrete/40 transition-colors cursor-pointer" + > +
+ + {cluster?.name || `Cluster ${cid}`} +
) })} @@ -272,61 +646,76 @@ export default function InsightsPage() { ))} {bridgeList.length === 0 && !isCalculating && ( -
No significant bridge notes found yet. Deepen your research to find new connections.
+
+ Aucune note-pont significative n'a été détectée. Créez des notes + transversales pour forger de nouveaux liens créatifs. +
)}

- {/* Connection Suggestions */} -
-
+ {/* ⑥ Opportunités de Connexion */} +
+
-

Missing Links (AI Generated)

+

+ Opportunités de Connexion +

- {suggestions.map((s, idx) => ( -
-
-
-
-
-
A
-
B
-
- - Bridging {s.clusterAName} & {s.clusterBName} - + {suggestions.map(s => ( +
+
+
+
+ A
-

{s.suggestedTitle}

-

{s.suggestedContent}

-
- - {s.justification} +
+ B
+ + Relier {s.clusterAName} & {s.clusterBName} + +
+

+ {s.suggestedTitle} +

+

+ {s.suggestedContent} +

+
+ + {s.justification}
))} - {suggestions.length === 0 && !isCalculating && ( -
- -

No connection suggestions yet

-

All your clusters may already be connected!

-
- )} {isCalculating && (
{[1, 2].map(i => ( -
+
))}
)} + {!isCalculating && suggestions.length === 0 && ( +
+ Toutes vos thématiques clés sont déjà formidablement interconnectées ! +
+ )}
+
)} +
) } diff --git a/memento-note/app/actions/notes.ts b/memento-note/app/actions/notes.ts index 3030ba7..b65cde0 100644 --- a/memento-note/app/actions/notes.ts +++ b/memento-note/app/actions/notes.ts @@ -7,6 +7,7 @@ import { auth } from '@/auth' import { getAIProvider } from '@/lib/ai/factory' import { parseNote, getHashColor } from '@/lib/utils' import { upsertNoteEmbedding } from '@/lib/embeddings' +import { embeddingService } from '@/lib/ai/services/embedding.service' import { syncNoteLinksForNote } from '@/lib/notes/sync-note-links' import { getSystemConfig, getConfigNumber, getConfigBoolean, SEARCH_DEFAULTS } from '@/lib/config' import { contextualAutoTagService } from '@/lib/ai/services/contextual-auto-tag.service' @@ -122,7 +123,7 @@ async function syncLabels(userId: string, noteLabels: string[] = [], notebookId? /** Sync both Note.labels (JSON) AND labelRelations for a single note. * Also cleans up orphan labels in the same notebook scope. */ -async function syncNoteLabels(noteId: string, labelNames: string[], notebookId: string | null, userId: string) { +export async function syncNoteLabels(noteId: string, labelNames: string[], notebookId: string | null, userId: string) { const uniqueNames = [...new Set(labelNames.map(n => n.trim()).filter(Boolean))] const labelRows = await syncLabels(userId, uniqueNames, notebookId) const labelIds = labelRows.map(l => l.id) @@ -444,9 +445,7 @@ export async function createNote(data: { // Use setImmediate-like pattern to not block the response ; (async () => { try { - const bgConfig = await getSystemConfig() - const provider = getAIProvider(bgConfig) - const embedding = await provider.getEmbeddings(content) + const { embedding } = await embeddingService.generateNoteEmbedding(data.title, content) if (embedding) { await upsertNoteEmbedding(noteId, embedding) } @@ -574,10 +573,10 @@ export async function updateNote(id: string, data: { if (data.content !== undefined) { const noteId = id const content = data.content; + const title = data.title !== undefined ? data.title : oldNote?.title ?? null; (async () => { try { - const provider = getAIProvider(await getSystemConfig()); - const embedding = await provider.getEmbeddings(content); + const { embedding } = await embeddingService.generateNoteEmbedding(title, content) if (embedding) { await upsertNoteEmbedding(noteId, embedding); } @@ -928,11 +927,10 @@ export async function syncAllEmbeddings() { noteEmbedding: { is: null } } }) - const provider = getAIProvider(await getSystemConfig()); for (const note of notesToSync) { if (!note.content) continue; try { - const embedding = await provider.getEmbeddings(note.content); + const { embedding } = await embeddingService.generateNoteEmbedding(note.title, note.content) if (embedding) { await upsertNoteEmbedding(note.id, embedding) updatedCount++; diff --git a/memento-note/app/api/clip/analyze/route.ts b/memento-note/app/api/clip/analyze/route.ts new file mode 100644 index 0000000..99b2f8e --- /dev/null +++ b/memento-note/app/api/clip/analyze/route.ts @@ -0,0 +1,101 @@ +import { NextRequest, NextResponse } from 'next/server' +import { auth } from '@/auth' +import { extractArticleFromHtml } from '@/lib/clip/extract-article' +import { analyzeClipContent } from '@/lib/clip/analyze-clip' +import { resolveClipLocale, wrapClipPlainParagraph } from '@/lib/clip/rtl-content' + +function isBlockedUrl(url: string): boolean { + try { + const parsed = new URL(url) + const hostname = parsed.hostname.toLowerCase() + const blocked = ['localhost', '127.0.0.1', '0.0.0.0', '::1', '169.254.169.254'] + if (blocked.includes(hostname)) return true + if (hostname.startsWith('10.') || hostname.startsWith('172.') || hostname.startsWith('192.168.')) return true + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return true + return false + } catch { + return true + } +} + +async function fetchPageHtml(url: string): Promise { + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), 15000) + try { + const response = await fetch(url, { + headers: { + 'User-Agent': 'Mozilla/5.0 (compatible; MementoClipper/1.0)', + Accept: 'text/html,application/xhtml+xml', + }, + signal: controller.signal, + redirect: 'follow', + }) + if (!response.ok) return null + const ct = response.headers.get('content-type') || '' + if (!ct.includes('text/html') && !ct.includes('application/xhtml')) return null + return await response.text() + } catch { + return null + } finally { + clearTimeout(timeoutId) + } +} + +export async function POST(request: NextRequest) { + try { + const session = await auth() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const body = await request.json() + const url = typeof body.url === 'string' ? body.url.trim() : '' + const htmlInput = typeof body.html === 'string' ? body.html : '' + const selection = typeof body.selection === 'string' ? body.selection.trim() : '' + + if (!url || isBlockedUrl(url)) { + return NextResponse.json({ error: 'Invalid URL' }, { status: 400 }) + } + + let title = '' + let textContent = '' + let contentHtml = '' + + if (body.mode === 'link') { + title = new URL(url).hostname + textContent = url + contentHtml = `

${url}

` + } else if (body.mode === 'selection' && selection) { + title = typeof body.title === 'string' ? body.title : new URL(url).hostname + textContent = selection + const locale = resolveClipLocale(url, title, selection) + contentHtml = wrapClipPlainParagraph(selection, locale) + } else { + const html = htmlInput || (await fetchPageHtml(url)) + if (!html) { + return NextResponse.json({ error: 'Could not fetch page content' }, { status: 422 }) + } + const extracted = extractArticleFromHtml(html, url) + if (!extracted) { + return NextResponse.json({ error: 'Could not extract readable article' }, { status: 422 }) + } + title = extracted.title || (typeof body.title === 'string' ? body.title : '') + textContent = extracted.textContent + contentHtml = extracted.content + } + + const analysis = await analyzeClipContent({ url, title, textContent }) + + return NextResponse.json({ + title: analysis.title, + summary: analysis.summary, + tags: analysis.tags, + readingTime: analysis.readingTimeMinutes, + content: contentHtml, + excerpt: textContent.slice(0, 500), + }) + } catch (error) { + console.error('[POST /api/clip/analyze]', error) + return NextResponse.json({ error: 'Analysis failed' }, { status: 500 }) + } +} diff --git a/memento-note/app/api/clip/notebooks/route.ts b/memento-note/app/api/clip/notebooks/route.ts new file mode 100644 index 0000000..030e0c8 --- /dev/null +++ b/memento-note/app/api/clip/notebooks/route.ts @@ -0,0 +1,24 @@ +import { NextRequest, NextResponse } from 'next/server' +import { auth } from '@/auth' +import prisma from '@/lib/prisma' + +/** Liste hiérarchique des carnets pour le clipper (extension). */ +export async function GET(_request: NextRequest) { + try { + const session = await auth() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const notebooks = await prisma.notebook.findMany({ + where: { userId: session.user.id, trashedAt: null }, + select: { id: true, name: true, parentId: true, color: true }, + orderBy: [{ order: 'asc' }, { name: 'asc' }], + }) + + return NextResponse.json({ notebooks }) + } catch (error) { + console.error('[GET /api/clip/notebooks]', error) + return NextResponse.json({ error: 'Failed to load notebooks' }, { status: 500 }) + } +} diff --git a/memento-note/app/api/clip/save/route.ts b/memento-note/app/api/clip/save/route.ts new file mode 100644 index 0000000..7cf496d --- /dev/null +++ b/memento-note/app/api/clip/save/route.ts @@ -0,0 +1,120 @@ +import { NextRequest, NextResponse } from 'next/server' +import { auth } from '@/auth' +import prisma from '@/lib/prisma' +import { syncNoteLabels } from '@/app/actions/notes' +import { createNotification } from '@/app/actions/notifications' +import { buildClipSourceFooter, clipFooterLocaleTag } from '@/lib/clip/extract-article' +import { resolveClipLocale, wrapClipArticleHtml, applyRtlToHtmlBlocks } from '@/lib/clip/rtl-content' +import { embeddingService } from '@/lib/ai/services/embedding.service' +import { upsertNoteEmbedding } from '@/lib/embeddings' +import DOMPurify from 'isomorphic-dompurify' + +function isBlockedUrl(url: string): boolean { + try { + const parsed = new URL(url) + const hostname = parsed.hostname.toLowerCase() + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return true + return ['localhost', '127.0.0.1', '0.0.0.0', '::1'].includes(hostname) + } catch { + return true + } +} + +export async function POST(request: NextRequest) { + try { + const session = await auth() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const body = await request.json() + const url = typeof body.url === 'string' ? body.url.trim() : '' + const title = typeof body.title === 'string' ? body.title.trim().slice(0, 300) : null + const rawContent = typeof body.content === 'string' ? body.content : '' + const summary = typeof body.summary === 'string' ? body.summary.trim() : '' + const notebookId = typeof body.notebookId === 'string' ? body.notebookId : null + const tags = Array.isArray(body.tags) + ? body.tags.filter((t: unknown): t is string => typeof t === 'string').slice(0, 5) + : [] + + if (!url || isBlockedUrl(url)) { + return NextResponse.json({ error: 'Invalid URL' }, { status: 400 }) + } + if (!rawContent.trim()) { + return NextResponse.json({ error: 'Content required' }, { status: 400 }) + } + + await prisma.user.upsert({ + where: { id: session.user.id }, + update: { + ...(session.user.email ? { email: session.user.email } : {}), + ...(session.user.name !== undefined ? { name: session.user.name } : {}), + }, + create: { + id: session.user.id, + email: session.user.email || `user-${session.user.id}@local.momento`, + name: session.user.name || null, + }, + }) + + const domain = new URL(url).hostname.replace(/^www\./, '') + const locale = resolveClipLocale(url, title || '', summary, rawContent.replace(/<[^>]+>/g, ' ')) + const sanitizedContent = DOMPurify.sanitize(rawContent) + const rtlBlocks = applyRtlToHtmlBlocks(sanitizedContent, locale) + const summaryBlock = summary + ? `

${DOMPurify.sanitize(summary)}

` + : '' + const footer = buildClipSourceFooter(domain, new Date(), clipFooterLocaleTag(locale.lang)) + const bodyHtml = rtlBlocks.includes('clip-article--rtl') + ? rtlBlocks + : wrapClipArticleHtml(rtlBlocks, locale) + const fullContent = `${summaryBlock}${bodyHtml}${footer}` + + const note = await prisma.note.create({ + data: { + userId: session.user.id, + title: title || domain, + content: fullContent, + type: 'richtext', + notebookId, + sourceUrl: url, + autoGenerated: true, + ...(locale.lang ? { language: locale.lang } : {}), + }, + }) + + void (async () => { + try { + const { embedding } = await embeddingService.generateNoteEmbedding( + title || domain, + fullContent, + ) + if (embedding?.length) { + await upsertNoteEmbedding(note.id, embedding) + } + } catch (error) { + console.error('[clip/save] embedding generation failed:', error) + } + })() + + if (tags.length > 0) { + await syncNoteLabels(note.id, tags, notebookId, session.user.id) + } + + const noteUrl = `/home?openNote=${encodeURIComponent(note.id)}` + + await createNotification({ + userId: session.user.id, + type: 'clip', + title: title || domain, + message: summary || undefined, + actionUrl: noteUrl, + relatedId: note.id, + }) + + return NextResponse.json({ noteId: note.id, noteUrl }) + } catch (error) { + console.error('[POST /api/clip/save]', error) + return NextResponse.json({ error: 'Save failed' }, { status: 500 }) + } +} diff --git a/memento-note/app/api/clusters/route.ts b/memento-note/app/api/clusters/route.ts index 11511f3..137988a 100644 --- a/memento-note/app/api/clusters/route.ts +++ b/memento-note/app/api/clusters/route.ts @@ -7,6 +7,7 @@ import { bridgeNotesService } from '@/lib/ai/services/bridge-notes.service' /** * GET /api/clusters * Get all clusters for the current user. + * Returns cached clusters + bridge notes enriched with cluster names. */ export async function GET(request: NextRequest) { try { @@ -17,9 +18,10 @@ export async function GET(request: NextRequest) { const userId = session.user.id - // Check for cached results - const cached = await clusteringService.getCachedClusters(userId) - if (cached) { + // Check for stored results (even if stale/périmés) + const stored = await clusteringService.getStoredClusters(userId) + if (stored) { + const cached = stored.clusters // Fetch notes with their cluster assignments const notes = await prisma.note.findMany({ where: { userId, trashedAt: null }, @@ -29,20 +31,65 @@ export async function GET(request: NextRequest) { // Get cluster member mappings const clusterMembers = await prisma.clusterMember.findMany({ where: { userId }, - select: { noteId: true, clusterId: true } + select: { noteId: true, clusterId: true, isCentral: true } }) - const noteClusterMap = new Map(clusterMembers.map(cm => [cm.noteId, cm.clusterId])) - const notesWithClusters = notes.map(n => ({ - ...n, - clusterId: noteClusterMap.get(n.id) - })) + const noteClusterMap = new Map(clusterMembers.map(cm => [cm.noteId, { clusterId: cm.clusterId, isCentral: cm.isCentral }])) + const notesWithClusters = notes.map(n => { + const mapping = noteClusterMap.get(n.id) + return { + ...n, + clusterId: mapping?.clusterId, + isCentral: mapping?.isCentral || false + } + }) + + // Fetch bridge notes with enrichment (cluster names + note details) + const bridgeNotesData = await prisma.bridgeNote.findMany({ + where: { userId }, + orderBy: { bridgeScore: 'desc' }, + take: 10 + }) + + let enrichedBridgeNotes: object[] = [] + if (bridgeNotesData.length > 0) { + const bridgeNoteIds = bridgeNotesData.map(b => b.noteId) + const bridgeNoteDetails = await prisma.note.findMany({ + where: { id: { in: bridgeNoteIds } }, + select: { id: true, title: true, content: true } + }) + const bridgeNoteDetailsMap = new Map(bridgeNoteDetails.map(n => [n.id, n])) + + enrichedBridgeNotes = bridgeNotesData.map(b => { + const clustersConnected = JSON.parse(b.clustersConnected) as number[] + return { + noteId: b.noteId, + bridgeScore: b.bridgeScore, + clustersConnected, + clusterNames: clustersConnected.map( + cid => cached.find(c => c.clusterId === cid)?.name || `Cluster ${cid}` + ), + note: bridgeNoteDetailsMap.get(b.noteId) + } + }) + } + + const embeddingCountRow = await prisma.$queryRawUnsafe>( + `SELECT COUNT(*) FROM "NoteEmbedding" ne + INNER JOIN "Note" n ON n.id = ne."noteId" + WHERE n."userId" = $1 AND n."trashedAt" IS NULL`, + userId + ) return NextResponse.json({ clusters: cached, notes: notesWithClusters, + bridgeNotes: enrichedBridgeNotes, cached: true, - totalNotes: cached.reduce((sum, c) => sum + c.noteIds.length, 0) + stale: stored.stale, + lastCalculated: stored.lastCalculated, + totalNotes: notes.length, + embeddingCount: Number(embeddingCountRow[0]?.count || 0), }) } @@ -61,6 +108,7 @@ export async function GET(request: NextRequest) { return NextResponse.json({ clusters: [], notes: [], + bridgeNotes: [], totalNotes: notesCount, embeddingCount: Number(embeddingCount[0]?.count || 0), needsCalculation: true @@ -77,6 +125,7 @@ export async function GET(request: NextRequest) { /** * POST /api/clusters * Trigger a full recalculation of clusters and bridge notes. + * Returns clusters + bridge notes enriched for immediate display. */ export async function POST(request: NextRequest) { try { @@ -86,66 +135,77 @@ export async function POST(request: NextRequest) { } const userId = session.user.id + const body = await request.json().catch(() => ({})) + const force = Boolean(body?.force) - // Use the PROPER clustering service (DBSCAN algorithm) + // 0. Indexer / réindexer les embeddings (texte complet, multi-chunks) + await clusteringService.ensureEmbeddings(userId, { force: force || true }) + + // 1. Run DBSCAN clustering const results = await clusteringService.clusterNotes(userId) if (results.clusters.length === 0) { return NextResponse.json({ clusters: [], + bridgeNotes: [], message: 'Could not generate clusters. Notes may be too diverse or not enough.', noiseCount: results.noiseCount }) } - // Generate cluster names using AI + // 2. Generate cluster names with AI for (const cluster of results.clusters) { cluster.name = await clusteringService.generateClusterName(cluster.clusterId, userId) } - // Save clustering results + // 3. Save clustering results await clusteringService.saveClusteringResults(userId, results) - // Detect and save bridge notes + // 4. Detect and save bridge notes const bridgeNotes = await bridgeNotesService.detectBridgeNotes(userId) await bridgeNotesService.saveBridgeNotes(userId, bridgeNotes) - // Generate and save bridge suggestions + // 5. Generate and save bridge suggestions const suggestions = await bridgeNotesService.generateBridgeSuggestions(userId) await bridgeNotesService.saveBridgeSuggestions(userId, suggestions) - // Fetch notes with their cluster assignments + // 6. Fetch notes with cluster assignments const notes = await prisma.note.findMany({ where: { userId, trashedAt: null }, select: { id: true, title: true, content: true } }) - // Get cluster member mappings const clusterMembers = await prisma.clusterMember.findMany({ where: { userId }, - select: { noteId: true, clusterId: true } + select: { noteId: true, clusterId: true, isCentral: true } }) - const noteClusterMap = new Map(clusterMembers.map(cm => [cm.noteId, cm.clusterId])) - const notesWithClusters = notes.map(n => ({ - ...n, - clusterId: noteClusterMap.get(n.id) - })) + const noteClusterMap = new Map(clusterMembers.map(cm => [cm.noteId, { clusterId: cm.clusterId, isCentral: cm.isCentral }])) + const notesWithClusters = notes.map(n => { + const mapping = noteClusterMap.get(n.id) + return { + ...n, + clusterId: mapping?.clusterId, + isCentral: mapping?.isCentral || false + } + }) - // Get enriched bridge notes with note details + // 7. Enrich bridge notes with cluster names + note details + const noteMap = new Map(notes.map(n => [n.id, n])) const enrichedBridgeNotes = bridgeNotes.slice(0, 10).map(b => ({ noteId: b.noteId, bridgeScore: b.bridgeScore, clustersConnected: b.clustersConnected, clusterNames: b.clusterNames, - note: notes.find(n => n.id === b.noteId) + note: noteMap.get(b.noteId) })) return NextResponse.json({ clusters: results.clusters, notes: notesWithClusters, bridgeNotes: enrichedBridgeNotes, - totalNotes: results.clusters.reduce((sum, c) => sum + c.noteIds.length, 0) + results.noiseCount, + totalNotes: + results.clusters.reduce((sum, c) => sum + c.noteIds.length, 0) + results.noiseCount, noiseCount: results.noiseCount, message: `Generated ${results.clusters.length} clusters with ${bridgeNotes.length} bridge notes` }) diff --git a/memento-note/app/api/insights/graph/route.ts b/memento-note/app/api/insights/graph/route.ts new file mode 100644 index 0000000..c83d92e --- /dev/null +++ b/memento-note/app/api/insights/graph/route.ts @@ -0,0 +1,134 @@ +import { NextRequest, NextResponse } from 'next/server' +import { auth } from '@/auth' +import prisma from '@/lib/prisma' + +/** + * GET /api/insights/graph + * + * Retourne les similarités cosinus pairwise calculées depuis les embeddings pgvector + * pour TOUS les membres des clusters (intra-cluster) + les échos Memory Echo (inter-cluster). + * + * Structure de réponse : + * { + * pairs: [{ sourceId, targetId, similarity, type: 'cluster' | 'echo' }], + * membershipScores: { [noteId]: number } + * } + * + * - pairs.cluster : paires au sein du même cluster, score = similarité cosinus réelle + * - pairs.echo : paires Memory Echo non-rejetées, score = similarityScore stocké + * - membershipScores : score de centralité de chaque note dans son cluster (de ClusterMember) + */ +export async function GET(request: NextRequest) { + try { + const session = await auth() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const userId = session.user.id + + // 1. Charger les membres de clusters avec leur score de centralité + const clusterMembers = await prisma.clusterMember.findMany({ + where: { userId }, + select: { noteId: true, clusterId: true, membershipScore: true } + }) + + if (clusterMembers.length === 0) { + return NextResponse.json({ pairs: [], membershipScores: {} }) + } + + // Construire la map noteId -> clusterId + const noteToCluster = new Map() + const membershipScores: Record = {} + const clusterToNotes = new Map() + + for (const m of clusterMembers) { + noteToCluster.set(m.noteId, m.clusterId) + membershipScores[m.noteId] = m.membershipScore + if (!clusterToNotes.has(m.clusterId)) clusterToNotes.set(m.clusterId, []) + clusterToNotes.get(m.clusterId)!.push(m.noteId) + } + + const allNoteIds = clusterMembers.map(m => m.noteId) + + // 2. Calculer les similarités cosinus pairwise intra-cluster via pgvector + // On utilise une requête SQL qui calcule toutes les paires d'un même cluster en une fois + const intraClusterPairs = await prisma.$queryRawUnsafe< + Array<{ sourceId: string; targetId: string; similarity: number; clusterId: number }> + >( + `SELECT + e1."noteId" AS "sourceId", + e2."noteId" AS "targetId", + 1 - (e1.embedding::vector <=> e2.embedding::vector) AS similarity, + cm1."clusterId" AS "clusterId" + FROM "NoteEmbedding" e1 + INNER JOIN "NoteEmbedding" e2 ON e1."noteId" < e2."noteId" + INNER JOIN "ClusterMember" cm1 ON cm1."noteId" = e1."noteId" AND cm1."userId" = $1 + INNER JOIN "ClusterMember" cm2 ON cm2."noteId" = e2."noteId" AND cm2."userId" = $1 + WHERE cm1."clusterId" = cm2."clusterId" + AND e1."noteId" = ANY($2::text[]) + AND e2."noteId" = ANY($2::text[])`, + userId, + allNoteIds + ) + + // 3. Récupérer les échos Memory Echo non-rejetés entre notes clusterisées + const echoInsights = await prisma.memoryEchoInsight.findMany({ + where: { + userId, + dismissed: false, + note1Id: { in: allNoteIds }, + note2Id: { in: allNoteIds } + }, + select: { + note1Id: true, + note2Id: true, + similarityScore: true + } + }) + + // 4. Construire la liste finale des paires + const pairs: Array<{ + sourceId: string + targetId: string + similarity: number + type: 'cluster' | 'echo' + clusterId?: number + }> = [] + + // Paires intra-cluster + for (const p of intraClusterPairs) { + pairs.push({ + sourceId: p.sourceId, + targetId: p.targetId, + similarity: Math.max(0, Math.min(1, p.similarity)), + type: 'cluster', + clusterId: p.clusterId + }) + } + + // Paires Memory Echo (entre clusters différents souvent, mais peut être intra aussi) + const existingPairKeys = new Set(pairs.map(p => `${p.sourceId}--${p.targetId}`)) + for (const echo of echoInsights) { + const key1 = `${echo.note1Id}--${echo.note2Id}` + const key2 = `${echo.note2Id}--${echo.note1Id}` + // Ajouter uniquement si pas déjà couvert par intra-cluster + if (!existingPairKeys.has(key1) && !existingPairKeys.has(key2)) { + pairs.push({ + sourceId: echo.note1Id, + targetId: echo.note2Id, + similarity: Math.max(0, Math.min(1, echo.similarityScore)), + type: 'echo' + }) + } + } + + return NextResponse.json({ pairs, membershipScores }) + } catch (error) { + console.error('[/api/insights/graph] Error:', error) + return NextResponse.json( + { error: 'Failed to compute semantic graph', details: String(error) }, + { status: 500 } + ) + } +} diff --git a/memento-note/app/api/notes/reindex/route.ts b/memento-note/app/api/notes/reindex/route.ts index 89b10a8..f346d09 100644 --- a/memento-note/app/api/notes/reindex/route.ts +++ b/memento-note/app/api/notes/reindex/route.ts @@ -24,7 +24,7 @@ export async function POST(req: NextRequest) { for (let i = 0; i < notes.length; i += BATCH_SIZE) { const batch = notes.slice(i, i + BATCH_SIZE) const results = await Promise.allSettled( - batch.map(note => semanticSearchService.indexNote(note.id)) + batch.map(note => semanticSearchService.indexNote(note.id, { force: true })) ) for (const r of results) { diff --git a/memento-note/app/globals.css b/memento-note/app/globals.css index 4b47c44..67c0cf6 100644 --- a/memento-note/app/globals.css +++ b/memento-note/app/globals.css @@ -1187,6 +1187,113 @@ html.font-system * { border-end-end-radius: 4px; } +/* --- Clipped RTL content (persan, arabe) --- */ +.notion-editor-wrapper .ProseMirror .clip-article--rtl, +.notion-editor-wrapper .ProseMirror [dir='rtl'], +.fullpage-editor .ProseMirror .clip-article--rtl, +.fullpage-editor .ProseMirror [dir='rtl'] { + direction: rtl; + text-align: right; + font-family: 'Vazirmatn', var(--font-sans), sans-serif !important; + line-height: 1.85; +} + +.notion-editor-wrapper .ProseMirror p[dir='rtl'], +.notion-editor-wrapper .ProseMirror h1[dir='rtl'], +.notion-editor-wrapper .ProseMirror h2[dir='rtl'], +.notion-editor-wrapper .ProseMirror h3[dir='rtl'], +.notion-editor-wrapper .ProseMirror blockquote[dir='rtl'], +.fullpage-editor .ProseMirror p[dir='rtl'], +.fullpage-editor .ProseMirror h1[dir='rtl'], +.fullpage-editor .ProseMirror h2[dir='rtl'], +.fullpage-editor .ProseMirror h3[dir='rtl'], +.fullpage-editor .ProseMirror blockquote[dir='rtl'] { + direction: rtl; + text-align: right; + font-family: 'Vazirmatn', var(--font-sans), sans-serif !important; + line-height: 1.85; +} + +.notion-editor-wrapper .ProseMirror .clip-article--rtl p, +.notion-editor-wrapper .ProseMirror .clip-article--rtl li, +.notion-editor-wrapper .ProseMirror .clip-article--rtl h1, +.notion-editor-wrapper .ProseMirror .clip-article--rtl h2, +.notion-editor-wrapper .ProseMirror .clip-article--rtl h3, +.fullpage-editor .ProseMirror .clip-article--rtl p, +.fullpage-editor .ProseMirror .clip-article--rtl li, +.fullpage-editor .ProseMirror .clip-article--rtl h1, +.fullpage-editor .ProseMirror .clip-article--rtl h2, +.fullpage-editor .ProseMirror .clip-article--rtl h3 { + direction: rtl; + text-align: right; + font-family: 'Vazirmatn', var(--font-sans), sans-serif !important; +} + +/* Titres d'articles dans les listes (structure BBC Persian : ul > li > h2) */ +.notion-editor-wrapper .ProseMirror .clip-article--rtl li > h1, +.notion-editor-wrapper .ProseMirror .clip-article--rtl li > h2, +.notion-editor-wrapper .ProseMirror .clip-article--rtl li > h3, +.notion-editor-wrapper .ProseMirror li[dir='rtl'] > h1, +.notion-editor-wrapper .ProseMirror li[dir='rtl'] > h2, +.notion-editor-wrapper .ProseMirror li[dir='rtl'] > h3, +.fullpage-editor .ProseMirror .clip-article--rtl li > h1, +.fullpage-editor .ProseMirror .clip-article--rtl li > h2, +.fullpage-editor .ProseMirror .clip-article--rtl li > h3, +.fullpage-editor .ProseMirror li[dir='rtl'] > h1, +.fullpage-editor .ProseMirror li[dir='rtl'] > h2, +.fullpage-editor .ProseMirror li[dir='rtl'] > h3 { + direction: rtl; + text-align: right; + font-family: 'Vazirmatn', var(--font-sans), sans-serif !important; + letter-spacing: 0; +} + +.notion-editor-wrapper .ProseMirror .clip-article--rtl blockquote, +.fullpage-editor .ProseMirror .clip-article--rtl blockquote { + border-inline-start: none; + border-inline-end: 3px solid var(--primary); + padding-inline-start: 0; + padding-inline-end: 1em; +} + +/* RTL lists — puces à droite, texte aligné */ +.notion-editor-wrapper .ProseMirror .clip-article--rtl ul, +.notion-editor-wrapper .ProseMirror .clip-article--rtl ol, +.fullpage-editor .ProseMirror .clip-article--rtl ul, +.fullpage-editor .ProseMirror .clip-article--rtl ol, +.notion-editor-wrapper .ProseMirror ul[dir='rtl'], +.notion-editor-wrapper .ProseMirror ol[dir='rtl'], +.fullpage-editor .ProseMirror ul[dir='rtl'], +.fullpage-editor .ProseMirror ol[dir='rtl'], +.notion-editor-wrapper .ProseMirror ul:has(> li[dir='rtl']), +.notion-editor-wrapper .ProseMirror ol:has(> li[dir='rtl']), +.fullpage-editor .ProseMirror ul:has(> li[dir='rtl']), +.fullpage-editor .ProseMirror ol:has(> li[dir='rtl']) { + direction: rtl; + text-align: right; + marker-side: match-parent; + padding-inline-start: 0; + padding-inline-end: 1.5rem; + list-style-position: outside; +} + +.notion-editor-wrapper .ProseMirror .clip-article--rtl li, +.notion-editor-wrapper .ProseMirror li[dir='rtl'], +.fullpage-editor .ProseMirror .clip-article--rtl li, +.fullpage-editor .ProseMirror li[dir='rtl'] { + direction: rtl; + text-align: right; + font-family: 'Vazirmatn', var(--font-sans), sans-serif !important; +} + +.notion-editor-wrapper .ProseMirror .clip-article--rtl li > p, +.notion-editor-wrapper .ProseMirror li[dir='rtl'] > p, +.fullpage-editor .ProseMirror .clip-article--rtl li > p, +.fullpage-editor .ProseMirror li[dir='rtl'] > p { + direction: rtl; + text-align: right; +} + /* --- Code --- */ .notion-editor-wrapper .ProseMirror code { background: var(--muted); @@ -2007,6 +2114,22 @@ html.font-system * { line-height: 1.4 !important; } +/* RTL headings — override serif editorial styles (chiffres persans, flux bidi) */ +.fullpage-editor .ProseMirror h1[dir='rtl'], +.fullpage-editor .ProseMirror h2[dir='rtl'], +.fullpage-editor .ProseMirror h3[dir='rtl'], +.fullpage-editor .tiptap h1[dir='rtl'], +.fullpage-editor .tiptap h2[dir='rtl'], +.fullpage-editor .tiptap h3[dir='rtl'], +.fullpage-editor .ProseMirror .clip-article--rtl h1, +.fullpage-editor .ProseMirror .clip-article--rtl h2, +.fullpage-editor .ProseMirror .clip-article--rtl h3 { + direction: rtl !important; + text-align: right !important; + font-family: 'Vazirmatn', var(--font-sans), sans-serif !important; + letter-spacing: 0 !important; +} + /* ────────────────────────────────────────────── SONNER TOASTS — Architectural Grid Styling ────────────────────────────────────────────── */ diff --git a/memento-note/components/memory-echo-section.tsx b/memento-note/components/memory-echo-section.tsx index 0a66bb2..6e75e89 100644 --- a/memento-note/components/memory-echo-section.tsx +++ b/memento-note/components/memory-echo-section.tsx @@ -10,6 +10,9 @@ import { useLanguage } from '@/lib/i18n/LanguageProvider' import type { BlockSuggestion } from '@/components/block-picker' import { toast } from 'sonner' import { useNoteEditorContext } from '@/components/note-editor/note-editor-context' +import { stripHtmlToPlainText } from '@/lib/text/plain-text' +import { detectTextDirection } from '@/lib/clip/rtl-content' +import { SEMANTIC_SIMILARITY_FLOOR_CLIP } from '@/lib/ai/semantic-proximity' interface ConnectionData { noteId: string @@ -40,16 +43,16 @@ interface PreviewTarget { excerpt: string } -function stripHtml(html: string): string { - return html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim() -} - function excerpt(text: string, max = 150): string { - const plain = stripHtml(text) + const plain = stripHtmlToPlainText(text) if (plain.length <= max) return plain return `${plain.slice(0, max).trim()}…` } +function isRtlText(text: string): boolean { + return detectTextDirection(text) === 'rtl' +} + async function resolveBlockForEmbed(sourceNoteId: string, hint: string): Promise<{ block: BlockSuggestion; mode: 'live' | 'citation' } | null> { const params = new URLSearchParams({ noteId: sourceNoteId, hint }) const res = await fetch(`/api/blocks/resolve?${params}`) @@ -155,7 +158,7 @@ export function MemoryEchoSection({ useEffect(() => { if (isLoading || connections.length === 0) return const top = connections[0] - if (!top || top.similarity < 0.75) return + if (!top || top.similarity < SEMANTIC_SIMILARITY_FLOOR_CLIP) return const key = `memory-echo-scroll-${noteId}` if (sessionStorage.getItem(key)) return sessionStorage.setItem(key, '1') @@ -167,7 +170,7 @@ export function MemoryEchoSection({ const handleEmbed = useCallback(async (conn: ConnectionData) => { setEmbeddingId(conn.noteId) try { - const hint = excerpt(stripHtml(conn.content), 300) + const hint = (conn.content || '').trim().slice(0, 12000) const resolved = await resolveBlockForEmbed(conn.noteId, hint) const noteTitle = conn.title || t('memoryEcho.comparison.untitled') @@ -200,7 +203,7 @@ export function MemoryEchoSection({ return } - const citationText = stripHtml( + const citationText = stripHtmlToPlainText( resolved?.block.content || conn.content || hint ).slice(0, 1200) @@ -213,7 +216,9 @@ export function MemoryEchoSection({ if (editorCtx.state.isMarkdown) { const quoted = citationText.split('\n').map(line => `> ${line}`).join('\n') - const mdCitation = `\n\n${quoted}\n\n— [${noteTitle}](/home?openNote=${conn.noteId})\n` + const mdCitation = isRtlText(citationText) + ? `\n\n
\n\n${quoted}\n\n— [${noteTitle}](/home?openNote=${conn.noteId})\n\n
\n` + : `\n\n${quoted}\n\n— [${noteTitle}](/home?openNote=${conn.noteId})\n` editorCtx.actions.setContent(editorCtx.state.content + mdCitation) toast.success(t('memoryEcho.editorSection.citationSuccess')) return @@ -326,7 +331,14 @@ export function MemoryEchoSection({
)} -
+
« {excerpt(topConnection.content)} »
diff --git a/memento-note/components/network-graph.tsx b/memento-note/components/network-graph.tsx index ac723ad..477eef4 100644 --- a/memento-note/components/network-graph.tsx +++ b/memento-note/components/network-graph.tsx @@ -3,69 +3,25 @@ import { useEffect, useRef } from 'react' import * as d3 from 'd3' -// Force to group nodes by cluster -function forceCluster() { - let nodes: any[] = [] - let clusters: Map = new Map() - - function force(alpha: number) { - // Calculate cluster centers - clusters.clear() - for (const node of nodes) { - const clusterId = node.clusterId - if (!clusters.has(clusterId)) { - clusters.set(clusterId, { x: 0, y: 0, count: 0 }) - } - const center = clusters.get(clusterId)! - center.x += node.x - center.y += node.y - center.count = (center.count || 0) + 1 - } - - // Average positions - for (const [clusterId, center] of clusters.entries()) { - center.x /= center.count || 1 - center.y /= center.count || 1 - } - - // Move nodes toward their cluster center - for (const node of nodes) { - const clusterCenter = clusters.get(node.clusterId) - if (clusterCenter && clusterCenter.count > 1) { - const targetX = clusterCenter.x - const targetY = clusterCenter.y - node.vx += (targetX - node.x) * alpha * 0.3 - node.vy += (targetY - node.y) * alpha * 0.3 - } - } - } - - force.initialize = function(newNodes: any[]) { - nodes = newNodes - return force - } - - return force -} - interface Note { id: string title: string | null clusterId?: string | number + isCentral?: boolean } interface NoteCluster { id: string | number - name: string + name?: string noteIds: string[] - color: string + color?: string } interface BridgeNote { noteId: string bridgeScore: number clustersConnected?: (string | number)[] - connectedClusterIds?: (string | number)[] + clusterNames?: string[] } interface NetworkGraphProps { @@ -73,13 +29,17 @@ interface NetworkGraphProps { clusters: NoteCluster[] bridgeNotes: BridgeNote[] onNoteSelect: (id: string) => void + selectedClusterId?: string | null + onClusterSelect?: (id: string | null) => void } export function NetworkGraph({ notes, clusters, bridgeNotes, - onNoteSelect + onNoteSelect, + selectedClusterId = null, + onClusterSelect }: NetworkGraphProps) { const svgRef = useRef(null) const containerRef = useRef(null) @@ -103,8 +63,10 @@ export function NetworkGraph({ svg.call(zoom as any) - // Filter notes with cluster assignments (properly check for undefined/null) - const visibleNotes = notes.filter(n => n.clusterId !== undefined && n.clusterId !== null) + // Filter notes with cluster assignments + const visibleNotes = notes.filter(n => n.clusterId !== undefined && n.clusterId !== null && String(n.clusterId) !== '-1') + + if (visibleNotes.length === 0) return interface D3Node extends d3.SimulationNodeDatum { id: string @@ -112,6 +74,7 @@ export function NetworkGraph({ clusterId: string | number color: string isBridge: boolean + isCentral: boolean radius: number } @@ -119,80 +82,189 @@ export function NetworkGraph({ source: string target: string strength: number + type?: 'inner' | 'bridge' } const bridgeSet = new Set(bridgeNotes.map(b => b.noteId)) + // 1. Initialisation des nœuds avec rôles et diamètres distincts const nodes: D3Node[] = visibleNotes.map(n => { - const cluster = clusters.find(c => c.id === String(n.clusterId)) + const cluster = clusters.find(c => String(c.id) === String(n.clusterId)) const isBridge = bridgeSet.has(n.id) + const isCentral = !!n.isCentral + + // Hiérarchie de tailles premium + let radius = 6 + if (isCentral) radius = 13 + else if (isBridge) radius = 10 + return { id: n.id, title: n.title, clusterId: n.clusterId!, color: cluster?.color || '#cbd5e1', isBridge, - radius: isBridge ? 12 : 8 + isCentral, + radius } }) - const links: D3Link[] = [] - // Connect notes within the same cluster - for (let i = 0; i < visibleNotes.length; i++) { - for (let j = i + 1; j < visibleNotes.length; j++) { - const ni = visibleNotes[i] - const nj = visibleNotes[j] - - if (ni.clusterId === nj.clusterId) { - links.push({ source: ni.id, target: nj.id, strength: 0.5 }) - } + // Groupement des nœuds par cluster + const clusterGroups = new Map() + nodes.forEach(node => { + const cid = node.clusterId + if (!clusterGroups.has(cid)) { + clusterGroups.set(cid, []) } - } + clusterGroups.get(cid)!.push(node) + }) + const links: D3Link[] = [] + + // 2. Création de la structure en étoile (Star-Network) par cluster + clusterGroups.forEach((groupNodes, cid) => { + if (groupNodes.length <= 1) return + + // Trouver le nœud central existant, ou en désigner un par défaut (le premier) + let hub = groupNodes.find(n => n.isCentral) + if (!hub) { + hub = groupNodes[0] + hub.isCentral = true + hub.radius = 13 // Augmenter sa taille pour la hiérarchie visuelle + } + + // Relier chaque feuille du cluster UNIQUEMENT à son nœud central (Hub) + groupNodes.forEach(node => { + if (node.id !== hub!.id) { + links.push({ + source: node.id, + target: hub!.id, + strength: 0.5, + type: 'inner' + }) + } + }) + }) + + // 3. Liaison de ponts dorées reliant les nœuds centraux (Hubs) (Garde-fou D3 contre les nœuds manquants) + const nodeSet = new Set(nodes.map(n => n.id)) + + bridgeNotes.forEach(b => { + if (!b.clustersConnected) return + if (!nodeSet.has(b.noteId)) return // Évite d'ajouter un lien si la note-pont n'est pas dans les nœuds affichés + + b.clustersConnected.forEach(cid => { + const targetNodes = clusterGroups.get(cid) || [] + if (targetNodes.length > 0) { + const targetHub = targetNodes.find(n => n.isCentral) || targetNodes[0] + if (nodeSet.has(targetHub.id)) { + links.push({ + source: b.noteId, + target: targetHub.id, + strength: 0.15, + type: 'bridge' + }) + } + } + }) + }) + + // 4. Pré-positionnement géométrique des Hubs en cercle pour éviter toute superposition initiale + const uniqueClusterIds = Array.from(clusterGroups.keys()) + const numClusters = uniqueClusterIds.length + const radiusCircle = Math.min(width, height) * 0.28 // Rayon de répartition + + uniqueClusterIds.forEach((cid, index) => { + const angle = (index * 2 * Math.PI) / numClusters + const hubX = width / 2 + radiusCircle * Math.cos(angle) + const hubY = height / 2 + radiusCircle * Math.sin(angle) + + const groupNodes = clusterGroups.get(cid) || [] + const hub = groupNodes.find(n => n.isCentral) || groupNodes[0] + + if (hub) { + hub.x = hubX + hub.y = hubY + } + + // Positionner les feuilles autour de leur propre hub + groupNodes.forEach(node => { + if (node.id !== hub?.id) { + const leafAngle = Math.random() * 2 * Math.PI + const leafDist = 25 + Math.random() * 20 + node.x = hubX + leafDist * Math.cos(leafAngle) + node.y = hubY + leafDist * Math.sin(leafAngle) + } + }) + }) + + // D3 simulation — Paramètres de physique ultra-stables, centrés et étalés comme des galaxies const simulation = d3.forceSimulation(nodes) - .force('link', d3.forceLink(links).id(d => d.id).distance(50)) - .force('charge', d3.forceManyBody().strength(-300)) + .force('link', d3.forceLink(links).id(d => d.id).distance(d => d.type === 'bridge' ? 140 : 35)) // Feuilles proches du Hub (35px) pour des constellations compactes et lisibles + .force('charge', d3.forceManyBody().strength(d => (d as D3Node).isCentral ? -500 : -80)) // Répulsion équilibrée pour éviter de projeter les Hubs contre les bords de l'écran .force('center', d3.forceCenter(width / 2, height / 2)) - .force('collision', d3.forceCollide().radius(d => d.radius + 15)) - .force('cluster', forceCluster()) + .force('x', d3.forceX(width / 2).strength(0.12)) // Recentrage X renforcé pour l'équilibre central + .force('y', d3.forceY(height / 2).strength(0.12)) // Recentrage Y renforcé + .force('collision', d3.forceCollide().radius(d => d.radius + 14)) // Collision ajustée pour préserver la compacité - // Links + + // Liens avec couleur et opacité contextuelle const link = g.append('g') .selectAll('line') .data(links) .enter() .append('line') - .attr('stroke', '#e2e8f0') - .attr('stroke-opacity', 0.6) - .attr('stroke-width', 1) + .attr('stroke', (d: any) => d.type === 'bridge' ? '#E2B13C' : '#cbd5e1') + .attr('stroke-dasharray', (d: any) => d.type === 'bridge' ? '4,4' : 'none') + .attr('stroke-opacity', (d: any) => { + if (d.type === 'bridge') return selectedClusterId ? 0.15 : 0.6 + if (!selectedClusterId) return 0.4 + const sId = typeof d.source === 'string' ? d.source : (d.source as any).id + const tId = typeof d.target === 'string' ? d.target : (d.target as any).id + const sourceNode = nodes.find(n => n.id === sId) + const targetNode = nodes.find(n => n.id === tId) + const sCluster = String(sourceNode?.clusterId) + const tCluster = String(targetNode?.clusterId) + return sCluster === selectedClusterId && tCluster === selectedClusterId ? 0.7 : 0.04 + }) + .attr('stroke-width', (d: any) => d.type === 'bridge' ? 1.5 : 1) - // Nodes + // Nœuds avec opacité focus const node = g.append('g') .selectAll('.node') .data(nodes) .enter() .append('g') .attr('class', 'node cursor-pointer') + .attr('opacity', d => { + if (!selectedClusterId) return 1 + return String(d.clusterId) === selectedClusterId ? 1 : 0.15 + }) .on('click', (event, d) => onNoteSelect(d.id)) .call(d3.drag() .on('start', dragstarted) .on('drag', dragged) .on('end', dragended) as any) + // Cercles avec tailles hiérarchiques et halos node.append('circle') .attr('r', d => d.radius) .attr('fill', d => d.color) - .attr('stroke', d => d.isBridge ? '#D4AF37' : '#fff') - .attr('stroke-width', d => d.isBridge ? 3 : 2) - .style('filter', d => d.isBridge ? 'drop-shadow(0 0 4px rgba(212, 175, 55, 0.4))' : 'none') + .attr('stroke', d => d.isCentral ? 'rgba(255,255,255,0.9)' : d.isBridge ? '#D4AF37' : '#fff') + .attr('stroke-width', d => d.isCentral ? 3 : d.isBridge ? 2.5 : 1.5) + .style('filter', d => d.isBridge ? 'drop-shadow(0 0 6px rgba(212, 175, 55, 0.5))' : 'none') + // Labels de textes ultra-lisibles claire/sombre sans chevauchement node.append('text') - .attr('dy', d => d.radius + 14) + .attr('dy', d => d.radius + 13) .attr('text-anchor', 'middle') - .attr('class', 'text-[10px] fill-concrete dark:fill-concrete/60 font-medium pointer-events-none') + .attr('font-size', d => d.isCentral ? '10px' : '9px') + .attr('font-weight', d => d.isCentral ? '700' : '500') + .attr('fill', '#4b5563') + .attr('class', 'dark:fill-zinc-300 font-sans pointer-events-none') .text(d => { - const title = d.title || 'Untitled' - return title.length > 20 ? title.substring(0, 20) + '...' : title + const title = d.title || 'Sans titre' + return title.length > 20 ? title.substring(0, 18) + '...' : title }) simulation.on('tick', () => { @@ -203,9 +275,46 @@ export function NetworkGraph({ .attr('y2', d => (d.target as any).y) node - .attr('transform', d => `translate(${d.x},${d.y})`) + .attr('transform', d => { + // Bounding box rigide : maintient à 100% les clusters sur l'écran + const padding = 35 + d.x = Math.max(padding, Math.min(width - padding, d.x || width / 2)) + d.y = Math.max(padding, Math.min(height - padding, d.y || height / 2)) + return `translate(${d.x},${d.y})` + }) }) + // Zoom automatique sur le cluster sélectionné (800ms) + if (selectedClusterId && width && height) { + const clusterNodes = nodes.filter(n => String(n.clusterId) === selectedClusterId) + if (clusterNodes.length > 0) { + // Avancer la simulation pour obtenir des coordonnées stabilisées + for (let i = 0; i < 60; ++i) simulation.tick() + + const xCoords = clusterNodes.map(cn => cn.x).filter((x): x is number => x !== undefined) + const yCoords = clusterNodes.map(cn => cn.y).filter((y): y is number => y !== undefined) + + if (xCoords.length > 0 && yCoords.length > 0) { + const avgX = d3.mean(xCoords) || width / 2 + const avgY = d3.mean(yCoords) || height / 2 + + svg.transition() + .duration(800) + .call( + zoom.transform, + d3.zoomIdentity + .translate(width / 2, height / 2) + .scale(1.3) + .translate(-avgX, -avgY) + ) + } + } + } else if (!selectedClusterId) { + svg.transition() + .duration(800) + .call(zoom.transform, d3.zoomIdentity) + } + function dragstarted(event: any, d: D3Node) { if (!event.active) simulation.alphaTarget(0.3).restart() d.fx = d.x @@ -226,17 +335,37 @@ export function NetworkGraph({ return () => { simulation.stop() } - }, [notes, clusters, bridgeNotes, onNoteSelect]) + }, [notes, clusters, bridgeNotes, onNoteSelect, selectedClusterId]) return (
-
- {clusters.map(c => ( -
-
- {c.name} -
- ))} + {/* Pastilles de cluster — cliquables pour activer le focus */} +
+ {clusters.map(c => { + const isSelected = String(c.id) === selectedClusterId + return ( + + ) + })} + {selectedClusterId && ( + + )}
diff --git a/memento-note/components/note-document-info-panel.tsx b/memento-note/components/note-document-info-panel.tsx index 0945b1e..9739f63 100644 --- a/memento-note/components/note-document-info-panel.tsx +++ b/memento-note/components/note-document-info-panel.tsx @@ -7,7 +7,7 @@ import { fr } from 'date-fns/locale/fr' import { enUS } from 'date-fns/locale/en-US' import { faIR } from 'date-fns/locale/fa-IR' import { formatAbsoluteDateLocalized } from '@/lib/utils/format-localized-date' -import { X, Info, Clock, Hash, Book, FileText, Calendar, Tag, ChevronRight, Trash2, RotateCcw, Loader2, Check, History as HistoryIcon, Network, Copy } from 'lucide-react' +import { X, Info, Clock, Hash, Book, FileText, Calendar, Tag, ChevronRight, Trash2, RotateCcw, Loader2, Check, History as HistoryIcon, Network, Copy, ExternalLink } from 'lucide-react' import { cn } from '@/lib/utils' import { useLanguage } from '@/lib/i18n' import { useNotebooks } from '@/context/notebooks-context' @@ -73,6 +73,7 @@ export function NoteDocumentInfoPanel({ note, content, onClose, onNoteRestored } const locale = getLocale(language) const displayNoteType = useMemo(() => { + if (note.sourceUrl) return t('notes.noteTypes.clip') const map: Record = { richtext: t('notes.noteTypes.richtext'), markdown: t('notes.noteTypes.markdown'), @@ -80,7 +81,7 @@ export function NoteDocumentInfoPanel({ note, content, onClose, onNoteRestored } checklist: t('notes.noteTypes.checklist'), } return map[note.type] || note.type - }, [t, note.type]) + }, [t, note.type, note.sourceUrl]) useEffect(() => { if (activeTab === 'versions' && historyEnabled) { @@ -227,6 +228,23 @@ export function NoteDocumentInfoPanel({ note, content, onClose, onNoteRestored }
+ {note.sourceUrl && ( +
+ +
+

{t('documentInfo.sourceWebLabel')}

+ + {note.sourceUrl} + +
+
+ )} + {createdAt && (
diff --git a/memento-note/components/note-editor/note-content-area.tsx b/memento-note/components/note-editor/note-content-area.tsx index d9d7d3b..46fd7db 100644 --- a/memento-note/components/note-editor/note-content-area.tsx +++ b/memento-note/components/note-editor/note-content-area.tsx @@ -107,6 +107,7 @@ export function NoteContentArea() { className="min-h-[280px]" onImageUpload={uploadImageFile} noteId={note.id} + sourceUrl={note.sourceUrl} />
) @@ -121,6 +122,7 @@ export function NoteContentArea() { className="min-h-[200px]" onImageUpload={uploadImageFile} noteId={note.id} + sourceUrl={note.sourceUrl} />