feat(insights): fix DBSCAN, Persian embeddings crash, D3 physics layouts, and D3 node not found runtime error
@@ -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/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/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/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/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,
|
"/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/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/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/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/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/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/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/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/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
|
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/e3745f62-c3b9-4a21-8942-71bc6f603f77/e3745f62-c3b9-4a21-8942-71bc6f603f77.jsonl": 1778018654221
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"lastRunAtMs": 1779034436676,
|
"lastRunAtMs": 1779646930753,
|
||||||
"turnsSinceLastRun": 13,
|
"turnsSinceLastRun": 6,
|
||||||
"lastTranscriptMtimeMs": 1779034436585.3164,
|
"lastTranscriptMtimeMs": 1779646930615.2869,
|
||||||
"lastProcessedGenerationId": "fac69259-f459-4519-94aa-2b72a9453b24",
|
"lastProcessedGenerationId": "c2a9fd9d-5b50-42a8-8b17-e414b0be891e",
|
||||||
"trialStartedAtMs": null
|
"trialStartedAtMs": null
|
||||||
}
|
}
|
||||||
|
|||||||
10
AGENTS.md
@@ -3,15 +3,15 @@
|
|||||||
## Learned User Preferences
|
## Learned User Preferences
|
||||||
|
|
||||||
- Préfère les échanges en français, avec des explications détaillées et claires (éviter le jargon flou).
|
- 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.**
|
- 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.
|
- 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 ; attention à ne pas confondre un nom de carnet (ex. « Persan ») avec le libellé de langue.
|
- 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).
|
- 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.
|
- **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.
|
- 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).
|
- 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`.
|
- 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.
|
- 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`.
|
- 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.
|
- 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`).
|
- 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`.
|
||||||
|
|||||||
BIN
Capture d'écran 2026-05-24 195056.png
Normal file
|
After Width: | Height: | Size: 249 KiB |
BIN
Capture d'écran 2026-05-24 195513.png
Normal file
|
After Width: | Height: | Size: 370 KiB |
BIN
Capture d’écran 2026-05-24 194911.png
Normal file
|
After Width: | Height: | Size: 933 KiB |
@@ -12,11 +12,11 @@
|
|||||||
| **US-SIDEBAR** | Sidebar deux colonnes (rail d'icônes + panneau) | ✅ **LIVRÉ** | `sidebar.tsx` restructuré |
|
| **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-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-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-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 | ⏳ À faire | — |
|
| **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 | ⏳ À faire | — |
|
| **US-CLIPPER** | Web Clipper | 🚧 **En cours** | `extension/`, `/api/clip/*`, migration `sourceUrl`, badge panneau Info |
|
||||||
| **US-GRAPH** | Graphe de Connaissance Global enrichi | ⏳ À faire | — |
|
| **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 | ⏳ À faire | — |
|
| **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-TEMPORAL** | Prédictions d'accès temporelles | ⏳ À faire | — |
|
||||||
| **US-FLASHCARDS** | Révision IA — Répétition espacée SM-2 | ⏳ À faire | — |
|
| **US-FLASHCARDS** | Révision IA — Répétition espacée SM-2 | ⏳ À faire | — |
|
||||||
| **US-STRUCTURED-VIEWS** | Vues Structurées (Tableau/Kanban/Galerie) | ⏳ À faire | — |
|
| **US-STRUCTURED-VIEWS** | Vues Structurées (Tableau/Kanban/Galerie) | ⏳ À faire | — |
|
||||||
|
|||||||
@@ -1,10 +1,23 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect, useMemo } from 'react'
|
||||||
import { NetworkGraph } from '@/components/network-graph'
|
import { NetworkGraph } from '@/components/network-graph'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { motion } from 'motion/react'
|
import { motion, AnimatePresence } from 'motion/react'
|
||||||
import { Sparkles, RefreshCw, Layers, Trophy, Zap, Lightbulb } from 'lucide-react'
|
import {
|
||||||
|
Sparkles,
|
||||||
|
RefreshCw,
|
||||||
|
Layers,
|
||||||
|
Trophy,
|
||||||
|
Zap,
|
||||||
|
Lightbulb,
|
||||||
|
Sliders,
|
||||||
|
CheckCircle2,
|
||||||
|
Clock,
|
||||||
|
AlertCircle,
|
||||||
|
ChevronRight,
|
||||||
|
Database,
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
interface Note {
|
interface Note {
|
||||||
id: string
|
id: string
|
||||||
@@ -53,21 +66,51 @@ export default function InsightsPage() {
|
|||||||
const [suggestions, setSuggestions] = useState<BridgeSuggestion[]>([])
|
const [suggestions, setSuggestions] = useState<BridgeSuggestion[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [isCalculating, setIsCalculating] = useState(false)
|
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<string | null>(null)
|
||||||
|
const [viewMode, setViewMode] = useState<'graph' | 'dashboard'>('dashboard')
|
||||||
|
const [lastSyncTime, setLastSyncTime] = useState<string>('')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadInitialData()
|
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 () => {
|
const loadInitialData = async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
// First, try to get cached clusters
|
|
||||||
const res = await fetch('/api/clusters')
|
const res = await fetch('/api/clusters')
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
|
|
||||||
// Check if we have clusters
|
if (data.clusters?.length > 0) {
|
||||||
if (data.clusters && data.clusters.length > 0) {
|
|
||||||
const clustersWithColors = data.clusters.map((c: Cluster, i: number) => ({
|
const clustersWithColors = data.clusters.map((c: Cluster, i: number) => ({
|
||||||
...c,
|
...c,
|
||||||
id: c.clusterId.toString(),
|
id: c.clusterId.toString(),
|
||||||
@@ -75,26 +118,35 @@ export default function InsightsPage() {
|
|||||||
}))
|
}))
|
||||||
setNotes(data.notes || [])
|
setNotes(data.notes || [])
|
||||||
setClusters(clustersWithColors)
|
setClusters(clustersWithColors)
|
||||||
|
setIsStale(!!data.stale)
|
||||||
|
|
||||||
// Load bridge notes
|
// 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')
|
const bridgeRes = await fetch('/api/bridge-notes?details=true')
|
||||||
if (bridgeRes.ok) {
|
if (bridgeRes.ok) {
|
||||||
const bridgeData = await bridgeRes.json()
|
const bridgeData = await bridgeRes.json()
|
||||||
setBridgeNotes(bridgeData.bridgeNotes || [])
|
setBridgeNotes(bridgeData.bridgeNotes || [])
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Load suggestions
|
|
||||||
const suggestionsRes = await fetch('/api/bridge-notes/suggestions')
|
const suggestionsRes = await fetch('/api/bridge-notes/suggestions')
|
||||||
if (suggestionsRes.ok) {
|
if (suggestionsRes.ok) {
|
||||||
const suggestionsData = await suggestionsRes.json()
|
const suggestionsData = await suggestionsRes.json()
|
||||||
setSuggestions(suggestionsData.suggestions || [])
|
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 {
|
} else {
|
||||||
// No clusters - trigger calculation if we have enough notes
|
setIsStale(false)
|
||||||
if (data.totalNotes >= 10) {
|
if (typeof data.embeddingCount === 'number' && typeof data.totalNotes === 'number') {
|
||||||
await performAnalysis()
|
setEmbeddingStats({ indexed: data.embeddingCount, total: data.totalNotes })
|
||||||
} else {
|
|
||||||
// Not enough notes - show empty state
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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 () => {
|
const performAnalysis = async () => {
|
||||||
setIsCalculating(true)
|
setIsCalculating(true)
|
||||||
try {
|
try {
|
||||||
@@ -124,13 +199,23 @@ export default function InsightsPage() {
|
|||||||
setNotes(data.notes || [])
|
setNotes(data.notes || [])
|
||||||
setClusters(clustersWithColors)
|
setClusters(clustersWithColors)
|
||||||
setBridgeNotes(data.bridgeNotes || [])
|
setBridgeNotes(data.bridgeNotes || [])
|
||||||
|
setIsStale(false)
|
||||||
|
|
||||||
// Load suggestions (they were generated during POST)
|
|
||||||
const suggestionsRes = await fetch('/api/bridge-notes/suggestions')
|
const suggestionsRes = await fetch('/api/bridge-notes/suggestions')
|
||||||
if (suggestionsRes.ok) {
|
if (suggestionsRes.ok) {
|
||||||
const suggestionsData = await suggestionsRes.json()
|
const suggestionsData = await suggestionsRes.json()
|
||||||
setSuggestions(suggestionsData.suggestions || [])
|
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) {
|
} catch (error) {
|
||||||
console.error('Error running analysis:', error)
|
console.error('Error running analysis:', error)
|
||||||
@@ -140,131 +225,420 @@ export default function InsightsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleNoteClick = (noteId: string) => {
|
const handleNoteClick = (noteId: string) => {
|
||||||
router.push(`/home?note=${noteId}`)
|
router.push(`/home?openNote=${noteId}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const bridgeList = bridgeNotes.map(b => ({
|
// ─── Rendu ───────────────────────────────────────────────────────────────────
|
||||||
...b,
|
|
||||||
title: b.note?.title || 'Unknown Note'
|
|
||||||
}))
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col bg-paper dark:bg-[#0D0D0D] overflow-hidden">
|
<div className="h-full flex flex-col bg-[#F9F8F6] dark:bg-[#0D0D0D] overflow-hidden">
|
||||||
{/* Header */}
|
|
||||||
<div className="p-8 border-b border-border/40 flex items-center justify-between backdrop-blur-xl bg-white/40 dark:bg-black/20 z-10">
|
{/* ── Header ── */}
|
||||||
|
<div className="p-6 sm:p-8 border-b border-border/20 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between sticky top-0 bg-[#F9F8F6]/80 dark:bg-[#0D0D0D]/80 backdrop-blur-md z-30 shrink-0">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-3 mb-1">
|
<div className="flex items-center gap-3 mb-1">
|
||||||
<div className="w-8 h-8 rounded-lg bg-indigo-500/10 flex items-center justify-center text-indigo-500">
|
<div className="w-8 h-8 rounded-lg bg-ochre/10 flex items-center justify-center text-ochre">
|
||||||
<Sparkles size={18} />
|
<Sparkles size={18} />
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-2xl font-serif font-medium text-ink dark:text-dark-ink">Semantic Insights</h1>
|
<h1 className="text-xl sm:text-2xl font-serif font-medium text-ink dark:text-dark-ink">
|
||||||
|
Analyses & Cartographie
|
||||||
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[11px] text-concrete tracking-[0.2em] uppercase font-bold">Discovering the hidden architecture of your knowledge</p>
|
<p className="text-[10px] text-concrete tracking-[0.25em] uppercase font-bold">
|
||||||
|
Modèles sémantiques & clusters de connaissances
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between sm:justify-end gap-3">
|
||||||
|
{/* Tab switcher mobile */}
|
||||||
|
<div className="flex lg:hidden p-1 bg-black/5 dark:bg-white/5 rounded-xl shrink-0">
|
||||||
<button
|
<button
|
||||||
onClick={performAnalysis}
|
onClick={() => setViewMode('graph')}
|
||||||
disabled={isCalculating}
|
className={`px-3 py-1.5 text-[10px] font-bold uppercase tracking-wider rounded-lg transition-all ${
|
||||||
className="flex items-center gap-2 px-6 py-2.5 bg-ink text-paper dark:bg-white dark:text-black rounded-full text-xs font-bold uppercase tracking-widest hover:scale-105 active:scale-95 transition-all disabled:opacity-50"
|
viewMode === 'graph'
|
||||||
|
? 'bg-white dark:bg-black text-ink dark:text-dark-ink shadow-sm'
|
||||||
|
: 'text-concrete'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{isCalculating ? <RefreshCw size={14} className="animate-spin" /> : <RefreshCw size={14} />}
|
Réseau
|
||||||
{isCalculating ? 'Mapping...' : 'Re-sync Network'}
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode('dashboard')}
|
||||||
|
className={`px-3 py-1.5 text-[10px] font-bold uppercase tracking-wider rounded-lg transition-all ${
|
||||||
|
viewMode === 'dashboard'
|
||||||
|
? 'bg-white dark:bg-black text-ink dark:text-dark-ink shadow-sm'
|
||||||
|
: 'text-concrete'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Analyses
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Loading State */}
|
<button
|
||||||
|
onClick={performAnalysis}
|
||||||
|
disabled={isCalculating}
|
||||||
|
className="flex items-center gap-2 px-5 py-2.5 bg-ink text-paper dark:bg-white dark:text-black rounded-full text-xs font-bold uppercase tracking-widest hover:scale-105 active:scale-95 transition-all disabled:opacity-50 shadow-sm"
|
||||||
|
>
|
||||||
|
{isCalculating ? (
|
||||||
|
<RefreshCw size={13} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<RefreshCw size={13} />
|
||||||
|
)}
|
||||||
|
{isCalculating ? 'Calcul...' : 'Re-analyser'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Chargement ── */}
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className="flex-1 flex items-center justify-center">
|
<div className="flex-1 flex items-center justify-center">
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, scale: 0.9 }}
|
initial={{ opacity: 0, scale: 0.9 }}
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
className="text-center"
|
className="text-center space-y-4"
|
||||||
>
|
>
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-500 mx-auto mb-4"></div>
|
<div className="animate-spin rounded-full h-10 w-10 border-b-2 border-ochre mx-auto" />
|
||||||
<p className="text-concrete">Analyzing your notes...</p>
|
<p className="text-sm text-concrete">Chargement de vos clusters...</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Empty State - only if truly no notes */}
|
{/* ── État vide ── */}
|
||||||
{!loading && clusters.length === 0 && !isCalculating && (
|
{!loading && clusters.length === 0 && !isCalculating && (
|
||||||
<div className="flex-1 flex items-center justify-center">
|
<div className="flex-1 flex items-center justify-center">
|
||||||
<div className="text-center max-w-md">
|
<motion.div
|
||||||
<div className="w-24 h-24 rounded-full bg-concrete/10 flex items-center justify-center mx-auto mb-6">
|
initial={{ opacity: 0, y: 20 }}
|
||||||
<Sparkles size={40} className="text-concrete/40" />
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="text-center max-w-sm px-6"
|
||||||
|
>
|
||||||
|
<div className="w-20 h-20 rounded-2xl bg-ochre/10 flex items-center justify-center mx-auto mb-6">
|
||||||
|
<Sparkles size={32} className="text-ochre/60" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-xl font-serif font-medium text-ink dark:text-dark-ink mb-2">
|
<h3 className="text-xl font-serif font-medium text-ink dark:text-dark-ink mb-3">
|
||||||
Discover your knowledge clusters
|
Vos notes forment des thèmes
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-concrete mb-6">
|
<p className="text-sm text-concrete leading-relaxed mb-6">
|
||||||
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.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
<button
|
||||||
|
onClick={performAnalysis}
|
||||||
|
disabled={isCalculating}
|
||||||
|
className="inline-flex items-center gap-2 px-6 py-3 bg-ink text-paper dark:bg-white dark:text-black rounded-full text-xs font-bold uppercase tracking-widest hover:scale-105 active:scale-95 transition-all disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<RefreshCw size={14} />
|
||||||
|
Analyser mes notes
|
||||||
|
</button>
|
||||||
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* ── Calcul en cours ── */}
|
||||||
{!loading && clusters.length > 0 && (
|
{isCalculating && !loading && (
|
||||||
<div className="flex-1 flex overflow-hidden">
|
<div className="flex-1 flex items-center justify-center">
|
||||||
{/* Left: Graph View */}
|
<motion.div
|
||||||
<div className="flex-[1.5] p-6 relative">
|
initial={{ opacity: 0, scale: 0.9 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
className="text-center space-y-6 max-w-xs"
|
||||||
|
>
|
||||||
|
<div className="w-16 h-16 rounded-2xl bg-ochre/10 flex items-center justify-center mx-auto">
|
||||||
|
<RefreshCw size={28} className="text-ochre animate-spin" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm font-semibold text-ink dark:text-dark-ink">
|
||||||
|
Analyse en cours...
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-concrete leading-relaxed">
|
||||||
|
Calcul des similarités sémantiques et détection des clusters de connaissances
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Contenu principal ── */}
|
||||||
|
{!loading && clusters.length > 0 && !isCalculating && (
|
||||||
|
<div className="flex-1 flex overflow-hidden min-h-0">
|
||||||
|
|
||||||
|
{/* ── Graphe (gauche) ── */}
|
||||||
|
<div
|
||||||
|
className={`flex-[1.4] p-6 relative min-h-0 ${
|
||||||
|
viewMode === 'graph' ? 'block' : 'hidden lg:block'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
<NetworkGraph
|
<NetworkGraph
|
||||||
notes={notes}
|
notes={notes}
|
||||||
clusters={clusters}
|
clusters={clusters}
|
||||||
bridgeNotes={bridgeNotes}
|
bridgeNotes={bridgeNotes}
|
||||||
onNoteSelect={handleNoteClick}
|
onNoteSelect={handleNoteClick}
|
||||||
|
selectedClusterId={selectedClusterId}
|
||||||
|
onClusterSelect={setSelectedClusterId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right: Insight Dashboard */}
|
{/* ── Dashboard (droite) ── */}
|
||||||
<div className="flex-1 border-l border-border/40 flex flex-col h-full bg-paper/50 dark:bg-black/10 backdrop-blur-sm overflow-hidden">
|
<div
|
||||||
<div className="p-8 flex-1 overflow-y-auto custom-scrollbar space-y-12">
|
className={`flex-1 border-l border-border/20 flex flex-col min-h-0 overflow-hidden bg-[#fcfbfa] dark:bg-zinc-900/10 backdrop-blur-sm ${
|
||||||
{/* Stats Summary */}
|
viewMode === 'dashboard' ? 'flex' : 'hidden lg:flex'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="p-6 sm:p-8 flex-1 overflow-y-auto custom-scrollbar space-y-10">
|
||||||
|
|
||||||
|
{/* Avertissement d'obsolescence (stale banner) */}
|
||||||
|
{isStale && !isCalculating && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: -10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="p-4 rounded-2xl bg-amber-500/10 border border-amber-500/20 text-amber-800 dark:text-amber-300 text-xs flex items-center justify-between gap-4 shadow-sm"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<AlertCircle size={16} className="shrink-0 text-amber-500" />
|
||||||
|
<span>
|
||||||
|
Vos notes ont été modifiées. Mettez à jour vos insights pour une cartographie sémantique précise.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={performAnalysis}
|
||||||
|
className="px-3.5 py-2 bg-amber-500 text-white dark:text-zinc-950 font-bold uppercase tracking-wider text-[10px] rounded-lg hover:scale-105 active:scale-95 transition-all shrink-0 shadow-sm"
|
||||||
|
>
|
||||||
|
Mettre à jour
|
||||||
|
</button>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ① Panneau d'inspection cluster */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{selectedCluster && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: -16 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -16 }}
|
||||||
|
className="p-6 rounded-2xl bg-white dark:bg-zinc-800 border-2 border-ochre/30 shadow-md relative overflow-hidden"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="absolute top-0 left-0 w-1.5 h-full"
|
||||||
|
style={{ backgroundColor: selectedCluster.color }}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center justify-between gap-4 mb-4 pl-3">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<span className="text-[9px] font-bold uppercase tracking-widest text-ochre">
|
||||||
|
Focus Cluster Activé
|
||||||
|
</span>
|
||||||
|
<h3 className="text-base font-serif font-semibold text-ink dark:text-dark-ink">
|
||||||
|
{selectedCluster.name || `Cluster ${selectedCluster.clusterId}`}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedClusterId(null)}
|
||||||
|
className="p-1 px-3 bg-black/5 dark:bg-white/5 hover:bg-black/10 dark:hover:bg-white/10 text-[10px] font-bold rounded-lg uppercase tracking-wider transition-colors shrink-0"
|
||||||
|
>
|
||||||
|
Fermer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="pl-3 space-y-3">
|
||||||
|
<p className="text-xs text-concrete">
|
||||||
|
Cet ensemble thématique réunit{' '}
|
||||||
|
<span className="font-semibold text-ink dark:text-dark-ink">
|
||||||
|
{selectedClusterNotes.length} notes
|
||||||
|
</span>
|
||||||
|
. Cliquez pour accéder directement :
|
||||||
|
</p>
|
||||||
|
<div className="space-y-1.5 max-h-[180px] overflow-y-auto custom-scrollbar pr-1">
|
||||||
|
{selectedClusterNotes.map(note => (
|
||||||
|
<button
|
||||||
|
key={note.id}
|
||||||
|
onClick={() => handleNoteClick(note.id)}
|
||||||
|
className="w-full text-left p-2.5 rounded-lg bg-black/[0.03] hover:bg-black/[0.07] dark:bg-white/[0.03] dark:hover:bg-white/[0.07] text-xs font-medium text-ink dark:text-dark-ink flex items-center justify-between gap-3 group transition-all"
|
||||||
|
>
|
||||||
|
<span className="truncate group-hover:translate-x-0.5 transition-transform">
|
||||||
|
{note.title || 'Note sans titre'}
|
||||||
|
</span>
|
||||||
|
<ChevronRight size={12} className="text-concrete shrink-0" />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* ② Stats */}
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="p-5 rounded-2xl bg-white dark:bg-white/5 border border-border shadow-sm">
|
<div className="p-5 rounded-2xl bg-white dark:bg-zinc-800/40 border border-border/40 shadow-sm flex flex-col justify-between">
|
||||||
<div className="flex items-center gap-2 text-indigo-500 mb-2">
|
<div className="flex items-center gap-2 text-indigo-500 mb-2">
|
||||||
<Layers size={14} />
|
<Layers size={14} />
|
||||||
<span className="text-[10px] font-bold uppercase tracking-widest">Clusters</span>
|
<span className="text-[10px] font-bold uppercase tracking-widest">
|
||||||
|
Clusters Actifs
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-3xl font-serif font-medium text-ink dark:text-dark-ink">{clusters.length}</div>
|
<div>
|
||||||
|
<div className="text-2xl font-serif font-semibold text-ink dark:text-dark-ink">
|
||||||
|
{clusters.length}
|
||||||
</div>
|
</div>
|
||||||
<div className="p-5 rounded-2xl bg-white dark:bg-white/5 border border-border shadow-sm">
|
<p className="text-[9px] text-concrete font-medium uppercase mt-1">
|
||||||
|
Détectés sans à priori
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-5 rounded-2xl bg-white dark:bg-zinc-800/40 border border-border/40 shadow-sm flex flex-col justify-between">
|
||||||
<div className="flex items-center gap-2 text-ochre mb-2">
|
<div className="flex items-center gap-2 text-ochre mb-2">
|
||||||
<Trophy size={14} />
|
<Trophy size={14} />
|
||||||
<span className="text-[10px] font-bold uppercase tracking-widest">Bridge Notes</span>
|
<span className="text-[10px] font-bold uppercase tracking-widest">
|
||||||
|
Notes-Ponts
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-2xl font-serif font-semibold text-ink dark:text-dark-ink">
|
||||||
|
{bridgeNotes.length}
|
||||||
|
</div>
|
||||||
|
<p className="text-[9px] text-concrete font-medium uppercase mt-1">
|
||||||
|
Passerelles d'idées
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-3xl font-serif font-medium text-ink dark:text-dark-ink">{bridgeNotes.length}</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bridge Notes Section */}
|
{/* ③ Système de Recalcul */}
|
||||||
<section>
|
<section className="p-5 rounded-2xl bg-white dark:bg-zinc-800 border border-border/40 shadow-sm space-y-4">
|
||||||
<div className="flex items-center gap-2 mb-6 px-1">
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Sliders size={15} className="text-ochre" />
|
||||||
|
<h4 className="text-[11px] font-black uppercase tracking-[0.2em] text-ink dark:text-dark-ink">
|
||||||
|
Système de Recalcul
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
<span className="flex items-center gap-1 text-[9.5px] font-bold text-emerald-500 uppercase">
|
||||||
|
<CheckCircle2 size={11} /> Synchronisé
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4 pt-1">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<span className="text-[9px] text-concrete block">CRON PLANIFIÉ</span>
|
||||||
|
<p className="text-xs text-ink dark:text-dark-ink font-semibold flex items-center gap-1.5">
|
||||||
|
<Clock size={12} className="opacity-50" /> Quotidien (04:00)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<span className="text-[9px] text-concrete block">DERNIÈRE SYNC</span>
|
||||||
|
<p className="text-xs text-ink dark:text-dark-ink font-bold font-mono">
|
||||||
|
{lastSyncTime || '—'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="pt-2 border-t border-border/10 space-y-3">
|
||||||
|
<div className="flex justify-between items-center text-[10px]">
|
||||||
|
<span className="text-concrete">Notes indexées (texte complet) :</span>
|
||||||
|
<span className="font-bold font-mono text-ink dark:text-dark-ink">
|
||||||
|
{embeddingStats
|
||||||
|
? `${embeddingStats.indexed} / ${embeddingStats.total}`
|
||||||
|
: '—'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[8px] text-concrete italic block leading-relaxed">
|
||||||
|
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é.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleReindexEmbeddings()}
|
||||||
|
disabled={isReindexing || isCalculating}
|
||||||
|
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-xl border border-ochre/30 bg-ochre/5 hover:bg-ochre/10 text-[10px] font-bold uppercase tracking-widest text-ochre disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{isReindexing ? (
|
||||||
|
<RefreshCw size={13} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Database size={13} />
|
||||||
|
)}
|
||||||
|
{isReindexing ? 'Indexation…' : 'Recalculer les embeddings'}
|
||||||
|
</button>
|
||||||
|
<span className="text-[8px] text-concrete italic block leading-relaxed">
|
||||||
|
« Re-analyser » réindexe aussi les embeddings puis regénère les clusters.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ④ Clusters Isolés */}
|
||||||
|
<section className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between gap-4 px-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<AlertCircle size={15} className="text-rose-400" />
|
||||||
|
<h3 className="text-xs font-bold uppercase tracking-[0.2em] text-ink dark:text-dark-ink">
|
||||||
|
Clusters Isolés ({isolatedClusters.length})
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<span className="text-[9px] text-concrete italic">Sans points d'accroche</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{isolatedClusters.map(c => (
|
||||||
|
<motion.div
|
||||||
|
key={c.id}
|
||||||
|
whileHover={{ y: -1 }}
|
||||||
|
onClick={() => 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"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: c.color }} />
|
||||||
|
<span className="text-xs font-medium text-ink dark:text-dark-ink">
|
||||||
|
{c.name || `Cluster ${c.clusterId}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] text-rose-500 font-semibold uppercase tracking-wider bg-rose-500/5 px-2.5 py-0.5 rounded-full border border-rose-500/10">
|
||||||
|
Non connecté
|
||||||
|
</span>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
{isolatedClusters.length === 0 && (
|
||||||
|
<div className="p-4 bg-white dark:bg-zinc-800 rounded-xl text-xs text-concrete text-center italic border border-border/20">
|
||||||
|
Tous les clusters thématiques sont liés par au moins un point de passage sémantique !
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ⑤ Notes-Ponts Influentes */}
|
||||||
|
<section className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2 px-1">
|
||||||
<Zap size={16} className="text-ochre" />
|
<Zap size={16} className="text-ochre" />
|
||||||
<h3 className="text-sm font-bold uppercase tracking-widest text-ink dark:text-dark-ink">Powerful Bridge Notes</h3>
|
<h3 className="text-xs font-bold uppercase tracking-[0.2em] text-ink dark:text-dark-ink">
|
||||||
|
Notes-Ponts Influentes
|
||||||
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{bridgeList.map((bridge) => (
|
{bridgeList.map(bridge => (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={bridge.noteId}
|
key={bridge.noteId}
|
||||||
whileHover={{ x: 4 }}
|
whileHover={{ x: 4 }}
|
||||||
onClick={() => handleNoteClick(bridge.noteId)}
|
onClick={() => 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"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2 gap-4">
|
||||||
<h4 className="text-sm font-medium text-ink dark:text-dark-ink truncate flex-1">
|
<h4 className="text-xs font-semibold text-ink dark:text-dark-ink truncate flex-1 group-hover:text-ochre transition-colors">
|
||||||
{bridge.title}
|
{bridge.title}
|
||||||
</h4>
|
</h4>
|
||||||
<span className="text-[10px] font-bold text-ochre bg-ochre/10 px-2 py-0.5 rounded-full">
|
<span className="text-[9.5px] font-bold text-ochre bg-ochre/5 border border-ochre/10 px-2.5 py-0.5 rounded-full shrink-0">
|
||||||
Score: {(bridge.bridgeScore * 100).toFixed(0)}%
|
Lien : {(bridge.bridgeScore * 100).toFixed(0)}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-wrap gap-1.5 pt-1.5 border-t border-black/5 dark:border-white/5">
|
||||||
{bridge.clusterNames?.map((name, i) => {
|
{bridge.clustersConnected.map(cid => {
|
||||||
const cluster = clusters.find(c => c.name === name)
|
const cluster = clusters.find(c => c.id === String(cid))
|
||||||
return (
|
return (
|
||||||
<div key={i} className="flex items-center gap-1">
|
<div
|
||||||
<div className="w-1.5 h-1.5 rounded-full" style={{ backgroundColor: cluster?.color || '#cbd5e1' }} />
|
key={cid}
|
||||||
<span className="text-[9px] text-concrete font-medium whitespace-nowrap">{name}</span>
|
onClick={e => {
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-1.5 h-1.5 rounded-full"
|
||||||
|
style={{ backgroundColor: cluster?.color || '#cbd5e1' }}
|
||||||
|
/>
|
||||||
|
<span className="text-[9.5px] text-concrete font-medium uppercase tracking-wider">
|
||||||
|
{cluster?.name || `Cluster ${cid}`}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@@ -272,61 +646,76 @@ export default function InsightsPage() {
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
))}
|
))}
|
||||||
{bridgeList.length === 0 && !isCalculating && (
|
{bridgeList.length === 0 && !isCalculating && (
|
||||||
<div className="text-xs text-concrete italic">No significant bridge notes found yet. Deepen your research to find new connections.</div>
|
<div className="text-xs text-concrete italic text-center p-6 bg-white dark:bg-zinc-800 rounded-xl border border-border/20">
|
||||||
|
Aucune note-pont significative n'a été détectée. Créez des notes
|
||||||
|
transversales pour forger de nouveaux liens créatifs.
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Connection Suggestions */}
|
{/* ⑥ Opportunités de Connexion */}
|
||||||
<section>
|
<section className="space-y-4">
|
||||||
<div className="flex items-center gap-2 mb-6 px-1">
|
<div className="flex items-center gap-2 px-1">
|
||||||
<Lightbulb size={16} className="text-indigo-500" />
|
<Lightbulb size={16} className="text-indigo-500" />
|
||||||
<h3 className="text-sm font-bold uppercase tracking-widest text-ink dark:text-dark-ink">Missing Links (AI Generated)</h3>
|
<h3 className="text-xs font-bold uppercase tracking-[0.2em] text-ink dark:text-dark-ink">
|
||||||
|
Opportunités de Connexion
|
||||||
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{suggestions.map((s, idx) => (
|
{suggestions.map(s => (
|
||||||
<div key={`${s.clusterAId}-${s.clusterBId}`} className="p-6 rounded-2xl bg-gradient-to-br from-indigo-500/5 to-transparent border border-indigo-500/10 hover:border-indigo-500/30 transition-all">
|
<div
|
||||||
<div className="flex items-start justify-between">
|
key={`${s.clusterAId}-${s.clusterBId}`}
|
||||||
<div className="flex-1">
|
className="p-6 rounded-2xl bg-gradient-to-br from-indigo-500/5 via-transparent to-transparent border border-indigo-500/10 hover:border-indigo-500/20 transition-all shadow-sm"
|
||||||
|
>
|
||||||
<div className="flex items-center gap-3 mb-4">
|
<div className="flex items-center gap-3 mb-4">
|
||||||
<div className="flex -space-x-2">
|
<div className="flex -space-x-2 shrink-0">
|
||||||
<div className="w-6 h-6 rounded-full border-2 border-paper bg-indigo-500 flex items-center justify-center text-[10px] text-white">A</div>
|
<div className="w-5 h-5 rounded-full border-2 border-paper bg-indigo-500 flex items-center justify-center text-[9px] text-white font-bold">
|
||||||
<div className="w-6 h-6 rounded-full border-2 border-paper bg-ochre flex items-center justify-center text-[10px] text-white">B</div>
|
A
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[9px] font-bold uppercase tracking-widest text-indigo-500/60">
|
<div className="w-5 h-5 rounded-full border-2 border-paper bg-ochre flex items-center justify-center text-[9px] text-white font-bold">
|
||||||
Bridging {s.clusterAName} & {s.clusterBName}
|
B
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="text-[9px] font-bold uppercase tracking-wider text-indigo-500/70 truncate">
|
||||||
|
Relier {s.clusterAName} & {s.clusterBName}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<h4 className="text-base font-serif font-medium text-ink dark:text-dark-ink mb-2">{s.suggestedTitle}</h4>
|
<h4 className="text-sm font-semibold text-ink dark:text-dark-ink mb-2">
|
||||||
<p className="text-xs text-muted-ink leading-relaxed mb-4">{s.suggestedContent}</p>
|
{s.suggestedTitle}
|
||||||
<div className="p-3 bg-white/40 dark:bg-white/5 rounded-xl border border-border/40 text-[10px] italic text-concrete flex gap-2">
|
</h4>
|
||||||
<Zap size={12} className="shrink-0" />
|
<p className="text-xs text-muted-ink leading-relaxed mb-4">
|
||||||
|
{s.suggestedContent}
|
||||||
|
</p>
|
||||||
|
<div className="p-3.5 bg-white/60 dark:bg-zinc-800 rounded-xl border border-border/20 text-[10.5px] italic text-concrete flex gap-2">
|
||||||
|
<Zap size={13} className="shrink-0 text-ochre mt-0.5" />
|
||||||
<span>{s.justification}</span>
|
<span>{s.justification}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
{suggestions.length === 0 && !isCalculating && (
|
|
||||||
<div className="text-center py-8 text-concrete">
|
|
||||||
<Lightbulb size={24} className="mx-auto mb-3 opacity-50" />
|
|
||||||
<p className="text-sm">No connection suggestions yet</p>
|
|
||||||
<p className="text-xs mt-1">All your clusters may already be connected!</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{isCalculating && (
|
{isCalculating && (
|
||||||
<div className="animate-pulse space-y-4">
|
<div className="animate-pulse space-y-4">
|
||||||
{[1, 2].map(i => (
|
{[1, 2].map(i => (
|
||||||
<div key={i} className="h-32 bg-indigo-500/5 rounded-2xl border border-indigo-500/10" />
|
<div
|
||||||
|
key={i}
|
||||||
|
className="h-32 bg-indigo-500/5 rounded-2xl border border-indigo-500/10"
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{!isCalculating && suggestions.length === 0 && (
|
||||||
|
<div className="text-xs text-concrete text-center italic p-6 border border-border/20 bg-white/40 dark:bg-zinc-800 rounded-xl">
|
||||||
|
Toutes vos thématiques clés sont déjà formidablement interconnectées !
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { auth } from '@/auth'
|
|||||||
import { getAIProvider } from '@/lib/ai/factory'
|
import { getAIProvider } from '@/lib/ai/factory'
|
||||||
import { parseNote, getHashColor } from '@/lib/utils'
|
import { parseNote, getHashColor } from '@/lib/utils'
|
||||||
import { upsertNoteEmbedding } from '@/lib/embeddings'
|
import { upsertNoteEmbedding } from '@/lib/embeddings'
|
||||||
|
import { embeddingService } from '@/lib/ai/services/embedding.service'
|
||||||
import { syncNoteLinksForNote } from '@/lib/notes/sync-note-links'
|
import { syncNoteLinksForNote } from '@/lib/notes/sync-note-links'
|
||||||
import { getSystemConfig, getConfigNumber, getConfigBoolean, SEARCH_DEFAULTS } from '@/lib/config'
|
import { getSystemConfig, getConfigNumber, getConfigBoolean, SEARCH_DEFAULTS } from '@/lib/config'
|
||||||
import { contextualAutoTagService } from '@/lib/ai/services/contextual-auto-tag.service'
|
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.
|
/** Sync both Note.labels (JSON) AND labelRelations for a single note.
|
||||||
* Also cleans up orphan labels in the same notebook scope. */
|
* 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 uniqueNames = [...new Set(labelNames.map(n => n.trim()).filter(Boolean))]
|
||||||
const labelRows = await syncLabels(userId, uniqueNames, notebookId)
|
const labelRows = await syncLabels(userId, uniqueNames, notebookId)
|
||||||
const labelIds = labelRows.map(l => l.id)
|
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
|
// Use setImmediate-like pattern to not block the response
|
||||||
; (async () => {
|
; (async () => {
|
||||||
try {
|
try {
|
||||||
const bgConfig = await getSystemConfig()
|
const { embedding } = await embeddingService.generateNoteEmbedding(data.title, content)
|
||||||
const provider = getAIProvider(bgConfig)
|
|
||||||
const embedding = await provider.getEmbeddings(content)
|
|
||||||
if (embedding) {
|
if (embedding) {
|
||||||
await upsertNoteEmbedding(noteId, embedding)
|
await upsertNoteEmbedding(noteId, embedding)
|
||||||
}
|
}
|
||||||
@@ -574,10 +573,10 @@ export async function updateNote(id: string, data: {
|
|||||||
if (data.content !== undefined) {
|
if (data.content !== undefined) {
|
||||||
const noteId = id
|
const noteId = id
|
||||||
const content = data.content;
|
const content = data.content;
|
||||||
|
const title = data.title !== undefined ? data.title : oldNote?.title ?? null;
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const provider = getAIProvider(await getSystemConfig());
|
const { embedding } = await embeddingService.generateNoteEmbedding(title, content)
|
||||||
const embedding = await provider.getEmbeddings(content);
|
|
||||||
if (embedding) {
|
if (embedding) {
|
||||||
await upsertNoteEmbedding(noteId, embedding);
|
await upsertNoteEmbedding(noteId, embedding);
|
||||||
}
|
}
|
||||||
@@ -928,11 +927,10 @@ export async function syncAllEmbeddings() {
|
|||||||
noteEmbedding: { is: null }
|
noteEmbedding: { is: null }
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
const provider = getAIProvider(await getSystemConfig());
|
|
||||||
for (const note of notesToSync) {
|
for (const note of notesToSync) {
|
||||||
if (!note.content) continue;
|
if (!note.content) continue;
|
||||||
try {
|
try {
|
||||||
const embedding = await provider.getEmbeddings(note.content);
|
const { embedding } = await embeddingService.generateNoteEmbedding(note.title, note.content)
|
||||||
if (embedding) {
|
if (embedding) {
|
||||||
await upsertNoteEmbedding(note.id, embedding)
|
await upsertNoteEmbedding(note.id, embedding)
|
||||||
updatedCount++;
|
updatedCount++;
|
||||||
|
|||||||
101
memento-note/app/api/clip/analyze/route.ts
Normal file
@@ -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<string | null> {
|
||||||
|
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 = `<p><a href="${url}" rel="noopener noreferrer">${url}</a></p>`
|
||||||
|
} 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
24
memento-note/app/api/clip/notebooks/route.ts
Normal file
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
120
memento-note/app/api/clip/save/route.ts
Normal file
@@ -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
|
||||||
|
? `<p dir="${locale.direction}"${locale.lang ? ` lang="${locale.lang}"` : ''}><em>${DOMPurify.sanitize(summary)}</em></p>`
|
||||||
|
: ''
|
||||||
|
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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import { bridgeNotesService } from '@/lib/ai/services/bridge-notes.service'
|
|||||||
/**
|
/**
|
||||||
* GET /api/clusters
|
* GET /api/clusters
|
||||||
* Get all clusters for the current user.
|
* Get all clusters for the current user.
|
||||||
|
* Returns cached clusters + bridge notes enriched with cluster names.
|
||||||
*/
|
*/
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@@ -17,9 +18,10 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
const userId = session.user.id
|
const userId = session.user.id
|
||||||
|
|
||||||
// Check for cached results
|
// Check for stored results (even if stale/périmés)
|
||||||
const cached = await clusteringService.getCachedClusters(userId)
|
const stored = await clusteringService.getStoredClusters(userId)
|
||||||
if (cached) {
|
if (stored) {
|
||||||
|
const cached = stored.clusters
|
||||||
// Fetch notes with their cluster assignments
|
// Fetch notes with their cluster assignments
|
||||||
const notes = await prisma.note.findMany({
|
const notes = await prisma.note.findMany({
|
||||||
where: { userId, trashedAt: null },
|
where: { userId, trashedAt: null },
|
||||||
@@ -29,20 +31,65 @@ export async function GET(request: NextRequest) {
|
|||||||
// Get cluster member mappings
|
// Get cluster member mappings
|
||||||
const clusterMembers = await prisma.clusterMember.findMany({
|
const clusterMembers = await prisma.clusterMember.findMany({
|
||||||
where: { userId },
|
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 noteClusterMap = new Map(clusterMembers.map(cm => [cm.noteId, { clusterId: cm.clusterId, isCentral: cm.isCentral }]))
|
||||||
const notesWithClusters = notes.map(n => ({
|
const notesWithClusters = notes.map(n => {
|
||||||
|
const mapping = noteClusterMap.get(n.id)
|
||||||
|
return {
|
||||||
...n,
|
...n,
|
||||||
clusterId: noteClusterMap.get(n.id)
|
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<Array<{ count: bigint }>>(
|
||||||
|
`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({
|
return NextResponse.json({
|
||||||
clusters: cached,
|
clusters: cached,
|
||||||
notes: notesWithClusters,
|
notes: notesWithClusters,
|
||||||
|
bridgeNotes: enrichedBridgeNotes,
|
||||||
cached: true,
|
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({
|
return NextResponse.json({
|
||||||
clusters: [],
|
clusters: [],
|
||||||
notes: [],
|
notes: [],
|
||||||
|
bridgeNotes: [],
|
||||||
totalNotes: notesCount,
|
totalNotes: notesCount,
|
||||||
embeddingCount: Number(embeddingCount[0]?.count || 0),
|
embeddingCount: Number(embeddingCount[0]?.count || 0),
|
||||||
needsCalculation: true
|
needsCalculation: true
|
||||||
@@ -77,6 +125,7 @@ export async function GET(request: NextRequest) {
|
|||||||
/**
|
/**
|
||||||
* POST /api/clusters
|
* POST /api/clusters
|
||||||
* Trigger a full recalculation of clusters and bridge notes.
|
* Trigger a full recalculation of clusters and bridge notes.
|
||||||
|
* Returns clusters + bridge notes enriched for immediate display.
|
||||||
*/
|
*/
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@@ -86,66 +135,77 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const userId = session.user.id
|
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)
|
const results = await clusteringService.clusterNotes(userId)
|
||||||
|
|
||||||
if (results.clusters.length === 0) {
|
if (results.clusters.length === 0) {
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
clusters: [],
|
clusters: [],
|
||||||
|
bridgeNotes: [],
|
||||||
message: 'Could not generate clusters. Notes may be too diverse or not enough.',
|
message: 'Could not generate clusters. Notes may be too diverse or not enough.',
|
||||||
noiseCount: results.noiseCount
|
noiseCount: results.noiseCount
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate cluster names using AI
|
// 2. Generate cluster names with AI
|
||||||
for (const cluster of results.clusters) {
|
for (const cluster of results.clusters) {
|
||||||
cluster.name = await clusteringService.generateClusterName(cluster.clusterId, userId)
|
cluster.name = await clusteringService.generateClusterName(cluster.clusterId, userId)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save clustering results
|
// 3. Save clustering results
|
||||||
await clusteringService.saveClusteringResults(userId, results)
|
await clusteringService.saveClusteringResults(userId, results)
|
||||||
|
|
||||||
// Detect and save bridge notes
|
// 4. Detect and save bridge notes
|
||||||
const bridgeNotes = await bridgeNotesService.detectBridgeNotes(userId)
|
const bridgeNotes = await bridgeNotesService.detectBridgeNotes(userId)
|
||||||
await bridgeNotesService.saveBridgeNotes(userId, bridgeNotes)
|
await bridgeNotesService.saveBridgeNotes(userId, bridgeNotes)
|
||||||
|
|
||||||
// Generate and save bridge suggestions
|
// 5. Generate and save bridge suggestions
|
||||||
const suggestions = await bridgeNotesService.generateBridgeSuggestions(userId)
|
const suggestions = await bridgeNotesService.generateBridgeSuggestions(userId)
|
||||||
await bridgeNotesService.saveBridgeSuggestions(userId, suggestions)
|
await bridgeNotesService.saveBridgeSuggestions(userId, suggestions)
|
||||||
|
|
||||||
// Fetch notes with their cluster assignments
|
// 6. Fetch notes with cluster assignments
|
||||||
const notes = await prisma.note.findMany({
|
const notes = await prisma.note.findMany({
|
||||||
where: { userId, trashedAt: null },
|
where: { userId, trashedAt: null },
|
||||||
select: { id: true, title: true, content: true }
|
select: { id: true, title: true, content: true }
|
||||||
})
|
})
|
||||||
|
|
||||||
// Get cluster member mappings
|
|
||||||
const clusterMembers = await prisma.clusterMember.findMany({
|
const clusterMembers = await prisma.clusterMember.findMany({
|
||||||
where: { userId },
|
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 noteClusterMap = new Map(clusterMembers.map(cm => [cm.noteId, { clusterId: cm.clusterId, isCentral: cm.isCentral }]))
|
||||||
const notesWithClusters = notes.map(n => ({
|
const notesWithClusters = notes.map(n => {
|
||||||
|
const mapping = noteClusterMap.get(n.id)
|
||||||
|
return {
|
||||||
...n,
|
...n,
|
||||||
clusterId: noteClusterMap.get(n.id)
|
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 => ({
|
const enrichedBridgeNotes = bridgeNotes.slice(0, 10).map(b => ({
|
||||||
noteId: b.noteId,
|
noteId: b.noteId,
|
||||||
bridgeScore: b.bridgeScore,
|
bridgeScore: b.bridgeScore,
|
||||||
clustersConnected: b.clustersConnected,
|
clustersConnected: b.clustersConnected,
|
||||||
clusterNames: b.clusterNames,
|
clusterNames: b.clusterNames,
|
||||||
note: notes.find(n => n.id === b.noteId)
|
note: noteMap.get(b.noteId)
|
||||||
}))
|
}))
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
clusters: results.clusters,
|
clusters: results.clusters,
|
||||||
notes: notesWithClusters,
|
notes: notesWithClusters,
|
||||||
bridgeNotes: enrichedBridgeNotes,
|
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,
|
noiseCount: results.noiseCount,
|
||||||
message: `Generated ${results.clusters.length} clusters with ${bridgeNotes.length} bridge notes`
|
message: `Generated ${results.clusters.length} clusters with ${bridgeNotes.length} bridge notes`
|
||||||
})
|
})
|
||||||
|
|||||||
134
memento-note/app/api/insights/graph/route.ts
Normal file
@@ -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<string, number>()
|
||||||
|
const membershipScores: Record<string, number> = {}
|
||||||
|
const clusterToNotes = new Map<number, string[]>()
|
||||||
|
|
||||||
|
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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,7 +24,7 @@ export async function POST(req: NextRequest) {
|
|||||||
for (let i = 0; i < notes.length; i += BATCH_SIZE) {
|
for (let i = 0; i < notes.length; i += BATCH_SIZE) {
|
||||||
const batch = notes.slice(i, i + BATCH_SIZE)
|
const batch = notes.slice(i, i + BATCH_SIZE)
|
||||||
const results = await Promise.allSettled(
|
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) {
|
for (const r of results) {
|
||||||
|
|||||||
@@ -1187,6 +1187,113 @@ html.font-system * {
|
|||||||
border-end-end-radius: 4px;
|
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 --- */
|
/* --- Code --- */
|
||||||
.notion-editor-wrapper .ProseMirror code {
|
.notion-editor-wrapper .ProseMirror code {
|
||||||
background: var(--muted);
|
background: var(--muted);
|
||||||
@@ -2007,6 +2114,22 @@ html.font-system * {
|
|||||||
line-height: 1.4 !important;
|
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
|
SONNER TOASTS — Architectural Grid Styling
|
||||||
────────────────────────────────────────────── */
|
────────────────────────────────────────────── */
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ import { useLanguage } from '@/lib/i18n/LanguageProvider'
|
|||||||
import type { BlockSuggestion } from '@/components/block-picker'
|
import type { BlockSuggestion } from '@/components/block-picker'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { useNoteEditorContext } from '@/components/note-editor/note-editor-context'
|
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 {
|
interface ConnectionData {
|
||||||
noteId: string
|
noteId: string
|
||||||
@@ -40,16 +43,16 @@ interface PreviewTarget {
|
|||||||
excerpt: string
|
excerpt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function stripHtml(html: string): string {
|
|
||||||
return html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
|
|
||||||
}
|
|
||||||
|
|
||||||
function excerpt(text: string, max = 150): string {
|
function excerpt(text: string, max = 150): string {
|
||||||
const plain = stripHtml(text)
|
const plain = stripHtmlToPlainText(text)
|
||||||
if (plain.length <= max) return plain
|
if (plain.length <= max) return plain
|
||||||
return `${plain.slice(0, max).trim()}…`
|
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> {
|
async function resolveBlockForEmbed(sourceNoteId: string, hint: string): Promise<{ block: BlockSuggestion; mode: 'live' | 'citation' } | null> {
|
||||||
const params = new URLSearchParams({ noteId: sourceNoteId, hint })
|
const params = new URLSearchParams({ noteId: sourceNoteId, hint })
|
||||||
const res = await fetch(`/api/blocks/resolve?${params}`)
|
const res = await fetch(`/api/blocks/resolve?${params}`)
|
||||||
@@ -155,7 +158,7 @@ export function MemoryEchoSection({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isLoading || connections.length === 0) return
|
if (isLoading || connections.length === 0) return
|
||||||
const top = connections[0]
|
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}`
|
const key = `memory-echo-scroll-${noteId}`
|
||||||
if (sessionStorage.getItem(key)) return
|
if (sessionStorage.getItem(key)) return
|
||||||
sessionStorage.setItem(key, '1')
|
sessionStorage.setItem(key, '1')
|
||||||
@@ -167,7 +170,7 @@ export function MemoryEchoSection({
|
|||||||
const handleEmbed = useCallback(async (conn: ConnectionData) => {
|
const handleEmbed = useCallback(async (conn: ConnectionData) => {
|
||||||
setEmbeddingId(conn.noteId)
|
setEmbeddingId(conn.noteId)
|
||||||
try {
|
try {
|
||||||
const hint = excerpt(stripHtml(conn.content), 300)
|
const hint = (conn.content || '').trim().slice(0, 12000)
|
||||||
const resolved = await resolveBlockForEmbed(conn.noteId, hint)
|
const resolved = await resolveBlockForEmbed(conn.noteId, hint)
|
||||||
const noteTitle = conn.title || t('memoryEcho.comparison.untitled')
|
const noteTitle = conn.title || t('memoryEcho.comparison.untitled')
|
||||||
|
|
||||||
@@ -200,7 +203,7 @@ export function MemoryEchoSection({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const citationText = stripHtml(
|
const citationText = stripHtmlToPlainText(
|
||||||
resolved?.block.content || conn.content || hint
|
resolved?.block.content || conn.content || hint
|
||||||
).slice(0, 1200)
|
).slice(0, 1200)
|
||||||
|
|
||||||
@@ -213,7 +216,9 @@ export function MemoryEchoSection({
|
|||||||
|
|
||||||
if (editorCtx.state.isMarkdown) {
|
if (editorCtx.state.isMarkdown) {
|
||||||
const quoted = citationText.split('\n').map(line => `> ${line}`).join('\n')
|
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<div dir="rtl" lang="fa">\n\n${quoted}\n\n— [${noteTitle}](/home?openNote=${conn.noteId})\n\n</div>\n`
|
||||||
|
: `\n\n${quoted}\n\n— [${noteTitle}](/home?openNote=${conn.noteId})\n`
|
||||||
editorCtx.actions.setContent(editorCtx.state.content + mdCitation)
|
editorCtx.actions.setContent(editorCtx.state.content + mdCitation)
|
||||||
toast.success(t('memoryEcho.editorSection.citationSuccess'))
|
toast.success(t('memoryEcho.editorSection.citationSuccess'))
|
||||||
return
|
return
|
||||||
@@ -326,7 +331,14 @@ export function MemoryEchoSection({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<blockquote className="border-l-2 border-indigo-500/20 pl-3 font-serif italic text-sm leading-relaxed text-foreground/85">
|
<blockquote
|
||||||
|
className={cn(
|
||||||
|
'border-indigo-500/20 pl-3 font-serif italic text-sm leading-relaxed text-foreground/85',
|
||||||
|
isRtlText(topConnection.content) ? 'border-r-2 border-l-0 pr-3 pl-0 text-right' : 'border-l-2',
|
||||||
|
)}
|
||||||
|
dir={isRtlText(topConnection.content) ? 'rtl' : undefined}
|
||||||
|
lang={isRtlText(topConnection.content) ? 'fa' : undefined}
|
||||||
|
>
|
||||||
« {excerpt(topConnection.content)} »
|
« {excerpt(topConnection.content)} »
|
||||||
</blockquote>
|
</blockquote>
|
||||||
|
|
||||||
|
|||||||
@@ -3,69 +3,25 @@
|
|||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
import * as d3 from 'd3'
|
import * as d3 from 'd3'
|
||||||
|
|
||||||
// Force to group nodes by cluster
|
|
||||||
function forceCluster() {
|
|
||||||
let nodes: any[] = []
|
|
||||||
let clusters: Map<string | number, { x: number; y: number }> = 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 {
|
interface Note {
|
||||||
id: string
|
id: string
|
||||||
title: string | null
|
title: string | null
|
||||||
clusterId?: string | number
|
clusterId?: string | number
|
||||||
|
isCentral?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NoteCluster {
|
interface NoteCluster {
|
||||||
id: string | number
|
id: string | number
|
||||||
name: string
|
name?: string
|
||||||
noteIds: string[]
|
noteIds: string[]
|
||||||
color: string
|
color?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BridgeNote {
|
interface BridgeNote {
|
||||||
noteId: string
|
noteId: string
|
||||||
bridgeScore: number
|
bridgeScore: number
|
||||||
clustersConnected?: (string | number)[]
|
clustersConnected?: (string | number)[]
|
||||||
connectedClusterIds?: (string | number)[]
|
clusterNames?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NetworkGraphProps {
|
interface NetworkGraphProps {
|
||||||
@@ -73,13 +29,17 @@ interface NetworkGraphProps {
|
|||||||
clusters: NoteCluster[]
|
clusters: NoteCluster[]
|
||||||
bridgeNotes: BridgeNote[]
|
bridgeNotes: BridgeNote[]
|
||||||
onNoteSelect: (id: string) => void
|
onNoteSelect: (id: string) => void
|
||||||
|
selectedClusterId?: string | null
|
||||||
|
onClusterSelect?: (id: string | null) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NetworkGraph({
|
export function NetworkGraph({
|
||||||
notes,
|
notes,
|
||||||
clusters,
|
clusters,
|
||||||
bridgeNotes,
|
bridgeNotes,
|
||||||
onNoteSelect
|
onNoteSelect,
|
||||||
|
selectedClusterId = null,
|
||||||
|
onClusterSelect
|
||||||
}: NetworkGraphProps) {
|
}: NetworkGraphProps) {
|
||||||
const svgRef = useRef<SVGSVGElement>(null)
|
const svgRef = useRef<SVGSVGElement>(null)
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
@@ -103,8 +63,10 @@ export function NetworkGraph({
|
|||||||
|
|
||||||
svg.call(zoom as any)
|
svg.call(zoom as any)
|
||||||
|
|
||||||
// Filter notes with cluster assignments (properly check for undefined/null)
|
// Filter notes with cluster assignments
|
||||||
const visibleNotes = notes.filter(n => n.clusterId !== undefined && n.clusterId !== null)
|
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 {
|
interface D3Node extends d3.SimulationNodeDatum {
|
||||||
id: string
|
id: string
|
||||||
@@ -112,6 +74,7 @@ export function NetworkGraph({
|
|||||||
clusterId: string | number
|
clusterId: string | number
|
||||||
color: string
|
color: string
|
||||||
isBridge: boolean
|
isBridge: boolean
|
||||||
|
isCentral: boolean
|
||||||
radius: number
|
radius: number
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,80 +82,189 @@ export function NetworkGraph({
|
|||||||
source: string
|
source: string
|
||||||
target: string
|
target: string
|
||||||
strength: number
|
strength: number
|
||||||
|
type?: 'inner' | 'bridge'
|
||||||
}
|
}
|
||||||
|
|
||||||
const bridgeSet = new Set(bridgeNotes.map(b => b.noteId))
|
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 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 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 {
|
return {
|
||||||
id: n.id,
|
id: n.id,
|
||||||
title: n.title,
|
title: n.title,
|
||||||
clusterId: n.clusterId!,
|
clusterId: n.clusterId!,
|
||||||
color: cluster?.color || '#cbd5e1',
|
color: cluster?.color || '#cbd5e1',
|
||||||
isBridge,
|
isBridge,
|
||||||
radius: isBridge ? 12 : 8
|
isCentral,
|
||||||
|
radius
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Groupement des nœuds par cluster
|
||||||
|
const clusterGroups = new Map<string | number, D3Node[]>()
|
||||||
|
nodes.forEach(node => {
|
||||||
|
const cid = node.clusterId
|
||||||
|
if (!clusterGroups.has(cid)) {
|
||||||
|
clusterGroups.set(cid, [])
|
||||||
|
}
|
||||||
|
clusterGroups.get(cid)!.push(node)
|
||||||
|
})
|
||||||
|
|
||||||
const links: D3Link[] = []
|
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) {
|
// 2. Création de la structure en étoile (Star-Network) par cluster
|
||||||
links.push({ source: ni.id, target: nj.id, strength: 0.5 })
|
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<D3Node>(nodes)
|
const simulation = d3.forceSimulation<D3Node>(nodes)
|
||||||
.force('link', d3.forceLink<D3Node, D3Link>(links).id(d => d.id).distance(50))
|
.force('link', d3.forceLink<D3Node, D3Link>(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(-300))
|
.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('center', d3.forceCenter(width / 2, height / 2))
|
||||||
.force('collision', d3.forceCollide<D3Node>().radius(d => d.radius + 15))
|
.force('x', d3.forceX(width / 2).strength(0.12)) // Recentrage X renforcé pour l'équilibre central
|
||||||
.force('cluster', forceCluster())
|
.force('y', d3.forceY(height / 2).strength(0.12)) // Recentrage Y renforcé
|
||||||
|
.force('collision', d3.forceCollide<D3Node>().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')
|
const link = g.append('g')
|
||||||
.selectAll('line')
|
.selectAll('line')
|
||||||
.data(links)
|
.data(links)
|
||||||
.enter()
|
.enter()
|
||||||
.append('line')
|
.append('line')
|
||||||
.attr('stroke', '#e2e8f0')
|
.attr('stroke', (d: any) => d.type === 'bridge' ? '#E2B13C' : '#cbd5e1')
|
||||||
.attr('stroke-opacity', 0.6)
|
.attr('stroke-dasharray', (d: any) => d.type === 'bridge' ? '4,4' : 'none')
|
||||||
.attr('stroke-width', 1)
|
.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')
|
const node = g.append('g')
|
||||||
.selectAll('.node')
|
.selectAll('.node')
|
||||||
.data(nodes)
|
.data(nodes)
|
||||||
.enter()
|
.enter()
|
||||||
.append('g')
|
.append('g')
|
||||||
.attr('class', 'node cursor-pointer')
|
.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))
|
.on('click', (event, d) => onNoteSelect(d.id))
|
||||||
.call(d3.drag<SVGGElement, D3Node>()
|
.call(d3.drag<SVGGElement, D3Node>()
|
||||||
.on('start', dragstarted)
|
.on('start', dragstarted)
|
||||||
.on('drag', dragged)
|
.on('drag', dragged)
|
||||||
.on('end', dragended) as any)
|
.on('end', dragended) as any)
|
||||||
|
|
||||||
|
// Cercles avec tailles hiérarchiques et halos
|
||||||
node.append('circle')
|
node.append('circle')
|
||||||
.attr('r', d => d.radius)
|
.attr('r', d => d.radius)
|
||||||
.attr('fill', d => d.color)
|
.attr('fill', d => d.color)
|
||||||
.attr('stroke', d => d.isBridge ? '#D4AF37' : '#fff')
|
.attr('stroke', d => d.isCentral ? 'rgba(255,255,255,0.9)' : d.isBridge ? '#D4AF37' : '#fff')
|
||||||
.attr('stroke-width', d => d.isBridge ? 3 : 2)
|
.attr('stroke-width', d => d.isCentral ? 3 : d.isBridge ? 2.5 : 1.5)
|
||||||
.style('filter', d => d.isBridge ? 'drop-shadow(0 0 4px rgba(212, 175, 55, 0.4))' : 'none')
|
.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')
|
node.append('text')
|
||||||
.attr('dy', d => d.radius + 14)
|
.attr('dy', d => d.radius + 13)
|
||||||
.attr('text-anchor', 'middle')
|
.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 => {
|
.text(d => {
|
||||||
const title = d.title || 'Untitled'
|
const title = d.title || 'Sans titre'
|
||||||
return title.length > 20 ? title.substring(0, 20) + '...' : title
|
return title.length > 20 ? title.substring(0, 18) + '...' : title
|
||||||
})
|
})
|
||||||
|
|
||||||
simulation.on('tick', () => {
|
simulation.on('tick', () => {
|
||||||
@@ -203,8 +275,45 @@ export function NetworkGraph({
|
|||||||
.attr('y2', d => (d.target as any).y)
|
.attr('y2', d => (d.target as any).y)
|
||||||
|
|
||||||
node
|
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) {
|
function dragstarted(event: any, d: D3Node) {
|
||||||
if (!event.active) simulation.alphaTarget(0.3).restart()
|
if (!event.active) simulation.alphaTarget(0.3).restart()
|
||||||
@@ -226,17 +335,37 @@ export function NetworkGraph({
|
|||||||
return () => {
|
return () => {
|
||||||
simulation.stop()
|
simulation.stop()
|
||||||
}
|
}
|
||||||
}, [notes, clusters, bridgeNotes, onNoteSelect])
|
}, [notes, clusters, bridgeNotes, onNoteSelect, selectedClusterId])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef} className="w-full h-full bg-paper dark:bg-[#121212] rounded-3xl overflow-hidden border border-border/40 relative">
|
<div ref={containerRef} className="w-full h-full bg-paper dark:bg-[#121212] rounded-3xl overflow-hidden border border-border/40 relative">
|
||||||
<div className="absolute top-6 left-6 z-10 flex flex-wrap gap-3 max-w-[300px]">
|
{/* Pastilles de cluster — cliquables pour activer le focus */}
|
||||||
{clusters.map(c => (
|
<div className="absolute top-6 left-6 z-10 flex flex-wrap gap-2 max-w-[90%]">
|
||||||
<div key={c.id} className="flex items-center gap-1.5 px-2 py-1 bg-white/80 dark:bg-white/5 backdrop-blur-sm border border-border rounded-full shadow-sm">
|
{clusters.map(c => {
|
||||||
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: c.color }} />
|
const isSelected = String(c.id) === selectedClusterId
|
||||||
<span className="text-[9px] font-bold uppercase tracking-widest text-concrete whitespace-nowrap">{c.name}</span>
|
return (
|
||||||
</div>
|
<button
|
||||||
))}
|
key={c.id}
|
||||||
|
onClick={() => onClusterSelect?.(isSelected ? null : String(c.id))}
|
||||||
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full border shadow-sm transition-all text-[9px] font-bold uppercase tracking-wider ${
|
||||||
|
isSelected
|
||||||
|
? 'bg-ink text-white dark:bg-white dark:text-black border-ink dark:border-white scale-105 shadow-md'
|
||||||
|
: 'bg-white/90 dark:bg-black/80 text-concrete hover:text-ink hover:border-concrete/40 border-border'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="w-1.5 h-1.5 rounded-full" style={{ backgroundColor: c.color }} />
|
||||||
|
<span>{c.name ?? String(c.id)}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{selectedClusterId && (
|
||||||
|
<button
|
||||||
|
onClick={() => onClusterSelect?.(null)}
|
||||||
|
className="px-3 py-1.5 rounded-full border border-rose-200 bg-rose-50 dark:bg-rose-950/20 dark:border-rose-900/40 text-rose-500 text-[9px] font-bold uppercase tracking-wider hover:bg-rose-100 dark:hover:bg-rose-950/30 transition-all shadow-sm"
|
||||||
|
>
|
||||||
|
Réinitialiser focus
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<svg ref={svgRef} className="w-full h-full" />
|
<svg ref={svgRef} className="w-full h-full" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { fr } from 'date-fns/locale/fr'
|
|||||||
import { enUS } from 'date-fns/locale/en-US'
|
import { enUS } from 'date-fns/locale/en-US'
|
||||||
import { faIR } from 'date-fns/locale/fa-IR'
|
import { faIR } from 'date-fns/locale/fa-IR'
|
||||||
import { formatAbsoluteDateLocalized } from '@/lib/utils/format-localized-date'
|
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 { cn } from '@/lib/utils'
|
||||||
import { useLanguage } from '@/lib/i18n'
|
import { useLanguage } from '@/lib/i18n'
|
||||||
import { useNotebooks } from '@/context/notebooks-context'
|
import { useNotebooks } from '@/context/notebooks-context'
|
||||||
@@ -73,6 +73,7 @@ export function NoteDocumentInfoPanel({ note, content, onClose, onNoteRestored }
|
|||||||
const locale = getLocale(language)
|
const locale = getLocale(language)
|
||||||
|
|
||||||
const displayNoteType = useMemo(() => {
|
const displayNoteType = useMemo(() => {
|
||||||
|
if (note.sourceUrl) return t('notes.noteTypes.clip')
|
||||||
const map: Record<string, string> = {
|
const map: Record<string, string> = {
|
||||||
richtext: t('notes.noteTypes.richtext'),
|
richtext: t('notes.noteTypes.richtext'),
|
||||||
markdown: t('notes.noteTypes.markdown'),
|
markdown: t('notes.noteTypes.markdown'),
|
||||||
@@ -80,7 +81,7 @@ export function NoteDocumentInfoPanel({ note, content, onClose, onNoteRestored }
|
|||||||
checklist: t('notes.noteTypes.checklist'),
|
checklist: t('notes.noteTypes.checklist'),
|
||||||
}
|
}
|
||||||
return map[note.type] || note.type
|
return map[note.type] || note.type
|
||||||
}, [t, note.type])
|
}, [t, note.type, note.sourceUrl])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeTab === 'versions' && historyEnabled) {
|
if (activeTab === 'versions' && historyEnabled) {
|
||||||
@@ -227,6 +228,23 @@ export function NoteDocumentInfoPanel({ note, content, onClose, onNoteRestored }
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{note.sourceUrl && (
|
||||||
|
<div className="flex items-start gap-3 px-4 py-3">
|
||||||
|
<ExternalLink className="h-3.5 w-3.5 text-muted-foreground mt-0.5 shrink-0" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-[10px] uppercase tracking-widest text-muted-foreground mb-0.5">{t('documentInfo.sourceWebLabel')}</p>
|
||||||
|
<a
|
||||||
|
href={note.sourceUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-sm font-medium text-brand-accent hover:underline break-all"
|
||||||
|
>
|
||||||
|
{note.sourceUrl}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{createdAt && (
|
{createdAt && (
|
||||||
<div className="flex items-start gap-3 px-4 py-3">
|
<div className="flex items-start gap-3 px-4 py-3">
|
||||||
<Calendar className="h-3.5 w-3.5 text-muted-foreground mt-0.5 shrink-0" />
|
<Calendar className="h-3.5 w-3.5 text-muted-foreground mt-0.5 shrink-0" />
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ export function NoteContentArea() {
|
|||||||
className="min-h-[280px]"
|
className="min-h-[280px]"
|
||||||
onImageUpload={uploadImageFile}
|
onImageUpload={uploadImageFile}
|
||||||
noteId={note.id}
|
noteId={note.id}
|
||||||
|
sourceUrl={note.sourceUrl}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -121,6 +122,7 @@ export function NoteContentArea() {
|
|||||||
className="min-h-[200px]"
|
className="min-h-[200px]"
|
||||||
onImageUpload={uploadImageFile}
|
onImageUpload={uploadImageFile}
|
||||||
noteId={note.id}
|
noteId={note.id}
|
||||||
|
sourceUrl={note.sourceUrl}
|
||||||
/>
|
/>
|
||||||
<GhostTags
|
<GhostTags
|
||||||
suggestions={state.filteredSuggestions}
|
suggestions={state.filteredSuggestions}
|
||||||
|
|||||||
@@ -7,12 +7,17 @@ import { useLanguage } from '@/lib/i18n'
|
|||||||
import { useAiConsent } from '@/components/legal/ai-consent-provider'
|
import { useAiConsent } from '@/components/legal/ai-consent-provider'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
import { resolveTitleDirection, resolveTitleLang } from '@/lib/clip/rtl-content'
|
||||||
|
|
||||||
export function NoteTitleBlock() {
|
export function NoteTitleBlock() {
|
||||||
const { state, actions, readOnly, fullPage } = useNoteEditorContext()
|
const { note, state, actions, readOnly, fullPage } = useNoteEditorContext()
|
||||||
const { t } = useLanguage()
|
const { t } = useLanguage()
|
||||||
const { requestAiConsent } = useAiConsent()
|
const { requestAiConsent } = useAiConsent()
|
||||||
|
|
||||||
|
const titleDir = resolveTitleDirection(state.title || '', note.sourceUrl)
|
||||||
|
const titleLang = resolveTitleLang(state.title || '', note.sourceUrl)
|
||||||
|
const titleIsRtl = titleDir === 'rtl'
|
||||||
|
|
||||||
if (fullPage) {
|
if (fullPage) {
|
||||||
// Adaptive font size: short = big editorial, long = smaller but still premium
|
// Adaptive font size: short = big editorial, long = smaller but still premium
|
||||||
const titleLen = (state.title || '').length
|
const titleLen = (state.title || '').length
|
||||||
@@ -28,7 +33,8 @@ export function NoteTitleBlock() {
|
|||||||
{/* Title — auto-resizing textarea, adaptive size */}
|
{/* Title — auto-resizing textarea, adaptive size */}
|
||||||
<div className="group relative">
|
<div className="group relative">
|
||||||
<textarea
|
<textarea
|
||||||
dir="auto"
|
dir={titleDir}
|
||||||
|
lang={titleLang}
|
||||||
rows={1}
|
rows={1}
|
||||||
placeholder={t('notes.titlePlaceholder') || 'Untitled…'}
|
placeholder={t('notes.titlePlaceholder') || 'Untitled…'}
|
||||||
value={state.title}
|
value={state.title}
|
||||||
@@ -40,11 +46,14 @@ export function NoteTitleBlock() {
|
|||||||
}}
|
}}
|
||||||
disabled={readOnly}
|
disabled={readOnly}
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-full font-memento-serif font-bold border-0 outline-none px-0 bg-transparent text-foreground',
|
'w-full font-bold border-0 outline-none px-0 bg-transparent text-foreground',
|
||||||
|
titleIsRtl
|
||||||
|
? 'font-[family-name:var(--font-sans)] text-right'
|
||||||
|
: 'font-memento-serif text-left',
|
||||||
'leading-[1.15] tracking-tight',
|
'leading-[1.15] tracking-tight',
|
||||||
'placeholder:text-foreground/20 resize-none overflow-hidden',
|
'placeholder:text-foreground/20 resize-none overflow-hidden',
|
||||||
titleSizeClass,
|
titleSizeClass,
|
||||||
!readOnly && 'pr-12'
|
!readOnly && (titleIsRtl ? 'ps-12' : 'pe-12')
|
||||||
)}
|
)}
|
||||||
style={{ height: 'auto' }}
|
style={{ height: 'auto' }}
|
||||||
ref={(el) => {
|
ref={(el) => {
|
||||||
@@ -92,7 +101,10 @@ export function NoteTitleBlock() {
|
|||||||
} finally { actions.setIsProcessingAI(false) }
|
} finally { actions.setIsProcessingAI(false) }
|
||||||
}}
|
}}
|
||||||
disabled={state.isProcessingAI}
|
disabled={state.isProcessingAI}
|
||||||
className="absolute right-0 top-2 opacity-0 group-hover:opacity-60 hover:!opacity-100 transition-opacity rounded-lg p-2 text-foreground/50 hover:bg-black/5"
|
className={cn(
|
||||||
|
'absolute top-2 opacity-0 group-hover:opacity-60 hover:!opacity-100 transition-opacity rounded-lg p-2 text-foreground/50 hover:bg-black/5',
|
||||||
|
titleIsRtl ? 'left-0' : 'right-0',
|
||||||
|
)}
|
||||||
title={t('ai.generateTitlesTooltip')}
|
title={t('ai.generateTitlesTooltip')}
|
||||||
>
|
>
|
||||||
{state.isProcessingAI ? <Loader2 className="h-5 w-5 animate-spin" /> : <Sparkles className="h-5 w-5" />}
|
{state.isProcessingAI ? <Loader2 className="h-5 w-5 animate-spin" /> : <Sparkles className="h-5 w-5" />}
|
||||||
@@ -117,14 +129,16 @@ export function NoteTitleBlock() {
|
|||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<input
|
<input
|
||||||
dir="auto"
|
dir={titleDir}
|
||||||
|
lang={titleLang}
|
||||||
placeholder={t('notes.titlePlaceholder')}
|
placeholder={t('notes.titlePlaceholder')}
|
||||||
value={state.title}
|
value={state.title}
|
||||||
onChange={(e) => actions.setTitle(e.target.value)}
|
onChange={(e) => actions.setTitle(e.target.value)}
|
||||||
disabled={readOnly}
|
disabled={readOnly}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full text-lg font-semibold border-0 focus-visible:ring-0 px-0 bg-transparent pr-10",
|
'w-full text-lg font-semibold border-0 focus-visible:ring-0 px-0 bg-transparent',
|
||||||
readOnly && "cursor-default"
|
titleIsRtl ? 'text-right font-[family-name:var(--font-sans)] ps-10' : 'text-left pe-10',
|
||||||
|
readOnly && 'cursor-default',
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
import { useEffect, useState, useMemo, useCallback, useRef } from 'react'
|
import { useEffect, useState, useMemo, useCallback, useRef } from 'react'
|
||||||
import dynamic from 'next/dynamic'
|
import dynamic from 'next/dynamic'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { useNotebooks } from '@/context/notebooks-context'
|
||||||
|
import { openNotePath } from '@/lib/navigation/open-note'
|
||||||
import {
|
import {
|
||||||
Loader2,
|
Loader2,
|
||||||
Network,
|
Network,
|
||||||
@@ -50,8 +52,19 @@ interface NotePreview {
|
|||||||
|
|
||||||
const PALETTE = ['#6366f1', '#10b981', '#f59e0b', '#ec4899', '#14b8a6', '#8b5cf6', '#ef4444', '#3b82f6', '#84cc16', '#A47148']
|
const PALETTE = ['#6366f1', '#10b981', '#f59e0b', '#ec4899', '#14b8a6', '#8b5cf6', '#ef4444', '#3b82f6', '#84cc16', '#A47148']
|
||||||
|
|
||||||
export function NoteGraphView() {
|
type EdgeTypeKey = 'explicit_link' | 'semantic_echo' | 'title_mention' | 'shared_label' | 'jaccard'
|
||||||
|
|
||||||
|
const DEFAULT_EDGE_FILTERS: Record<EdgeTypeKey, boolean> = {
|
||||||
|
explicit_link: true,
|
||||||
|
semantic_echo: true,
|
||||||
|
title_mention: true,
|
||||||
|
shared_label: true,
|
||||||
|
jaccard: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NoteGraphView({ embedded = false }: { embedded?: boolean }) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const { notebooks } = useNotebooks()
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
const graphRef = useRef<any>(null)
|
const graphRef = useRef<any>(null)
|
||||||
const [dimensions, setDimensions] = useState({ width: 800, height: 600 })
|
const [dimensions, setDimensions] = useState({ width: 800, height: 600 })
|
||||||
@@ -63,6 +76,10 @@ export function NoteGraphView() {
|
|||||||
const [notePreview, setNotePreview] = useState<NotePreview | null>(null)
|
const [notePreview, setNotePreview] = useState<NotePreview | null>(null)
|
||||||
const [previewLoading, setPreviewLoading] = useState(false)
|
const [previewLoading, setPreviewLoading] = useState(false)
|
||||||
const [selectedNotebookId, setSelectedNotebookId] = useState<string | null>(null)
|
const [selectedNotebookId, setSelectedNotebookId] = useState<string | null>(null)
|
||||||
|
const [edgeFilters, setEdgeFilters] = useState(DEFAULT_EDGE_FILTERS)
|
||||||
|
const [semanticMinWeight, setSemanticMinWeight] = useState(0.45)
|
||||||
|
const [focusNodeId, setFocusNodeId] = useState<string | null>(null)
|
||||||
|
const [controlsOpen, setControlsOpen] = useState(!embedded)
|
||||||
|
|
||||||
const { t } = useLanguage()
|
const { t } = useLanguage()
|
||||||
|
|
||||||
@@ -150,26 +167,48 @@ export function NoteGraphView() {
|
|||||||
const colorMap = useMemo(() => {
|
const colorMap = useMemo(() => {
|
||||||
if (!rawData) return new Map<string | null, string>()
|
if (!rawData) return new Map<string | null, string>()
|
||||||
const map = new Map<string | null, string>()
|
const map = new Map<string | null, string>()
|
||||||
const ids = [...new Set(rawData.nodes.map(n => n.notebookId).filter(Boolean))]
|
const ids = [...new Set(rawData.nodes.map(n => n.notebookId).filter(Boolean))] as string[]
|
||||||
ids.forEach((id, i) => map.set(id, PALETTE[i % PALETTE.length]))
|
ids.forEach((id, i) => {
|
||||||
|
const nb = notebooks.find(n => n.id === id)
|
||||||
|
map.set(id, nb?.color || PALETTE[i % PALETTE.length])
|
||||||
|
})
|
||||||
return map
|
return map
|
||||||
}, [rawData])
|
}, [rawData, notebooks])
|
||||||
|
|
||||||
|
const neighborIds = useMemo(() => {
|
||||||
|
if (!focusNodeId || !rawData) return null
|
||||||
|
const ids = new Set<string>([focusNodeId])
|
||||||
|
for (const edge of rawData.edges) {
|
||||||
|
if (edge.source === focusNodeId) ids.add(edge.target)
|
||||||
|
if (edge.target === focusNodeId) ids.add(edge.source)
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}, [focusNodeId, rawData])
|
||||||
|
|
||||||
// ─── Graph data ───────────────────────────────────────────────────────────
|
// ─── Graph data ───────────────────────────────────────────────────────────
|
||||||
const graphData = useMemo(() => {
|
const graphData = useMemo(() => {
|
||||||
if (!rawData) return { nodes: [], links: [] }
|
if (!rawData) return { nodes: [], links: [] }
|
||||||
|
|
||||||
// Filter by notebook
|
|
||||||
let filtered = selectedNotebookId
|
let filtered = selectedNotebookId
|
||||||
? rawData.nodes.filter(n => n.notebookId === selectedNotebookId)
|
? rawData.nodes.filter(n => n.notebookId === selectedNotebookId)
|
||||||
: rawData.nodes
|
: rawData.nodes
|
||||||
|
|
||||||
// Filter by text search
|
if (neighborIds) {
|
||||||
|
filtered = filtered.filter(n => neighborIds.has(n.id))
|
||||||
|
}
|
||||||
|
|
||||||
filtered = searchFilter.trim()
|
filtered = searchFilter.trim()
|
||||||
? filtered.filter(n => n.title.toLowerCase().includes(searchFilter.toLowerCase()))
|
? filtered.filter(n => n.title.toLowerCase().includes(searchFilter.toLowerCase()))
|
||||||
: filtered
|
: filtered
|
||||||
|
|
||||||
const filteredIds = new Set(filtered.map(n => n.id))
|
const filteredIds = new Set(filtered.map(n => n.id))
|
||||||
|
const visibleEdges = rawData.edges.filter(e => {
|
||||||
|
const type = e.type as EdgeTypeKey
|
||||||
|
if (!(type in edgeFilters) || !edgeFilters[type]) return false
|
||||||
|
if (type === 'semantic_echo' && e.weight < semanticMinWeight) return false
|
||||||
|
return filteredIds.has(e.source) && filteredIds.has(e.target)
|
||||||
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
nodes: filtered.map(n => ({
|
nodes: filtered.map(n => ({
|
||||||
id: n.id,
|
id: n.id,
|
||||||
@@ -179,26 +218,24 @@ export function NoteGraphView() {
|
|||||||
notebookId: n.notebookId,
|
notebookId: n.notebookId,
|
||||||
degree: n.degree,
|
degree: n.degree,
|
||||||
})),
|
})),
|
||||||
links: rawData.edges
|
links: visibleEdges.map(e => {
|
||||||
.filter(e => filteredIds.has(e.source) && filteredIds.has(e.target))
|
let color = '#cbd5e1'
|
||||||
.map(e => {
|
let width = 2.5
|
||||||
let color = '#e2e8f0'
|
|
||||||
let width = 0.6
|
|
||||||
let dash = false
|
let dash = false
|
||||||
|
|
||||||
if (e.type === 'explicit_link') {
|
if (e.type === 'explicit_link') {
|
||||||
color = '#10b981' // Green
|
color = '#10b981'
|
||||||
width = 2.2
|
width = 4.5
|
||||||
} else if (e.type === 'semantic_echo') {
|
} else if (e.type === 'semantic_echo') {
|
||||||
color = '#a78bfa' // Purple
|
color = '#8b5cf6'
|
||||||
width = 1.8
|
width = 3.5
|
||||||
dash = true
|
dash = true
|
||||||
} else if (e.type === 'title_mention') {
|
} else if (e.type === 'title_mention') {
|
||||||
color = '#f59e0b' // Amber/Orange
|
color = '#f59e0b'
|
||||||
width = 1.6
|
width = 3.2
|
||||||
} else if (e.type === 'shared_label') {
|
} else if (e.type === 'shared_label') {
|
||||||
color = '#3b82f6' // Blue
|
color = '#3b82f6'
|
||||||
width = 1.2
|
width = 2.8
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -211,7 +248,7 @@ export function NoteGraphView() {
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
}, [rawData, searchFilter, colorMap, selectedNotebookId])
|
}, [rawData, searchFilter, colorMap, selectedNotebookId, edgeFilters, semanticMinWeight, neighborIds])
|
||||||
|
|
||||||
const selectedNotebookName = useMemo(() => {
|
const selectedNotebookName = useMemo(() => {
|
||||||
if (!selectedNode || !rawData) return null
|
if (!selectedNode || !rawData) return null
|
||||||
@@ -226,20 +263,39 @@ export function NoteGraphView() {
|
|||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
const last = lastClickRef.current
|
const last = lastClickRef.current
|
||||||
if (last && last.id === node.id && now - last.time < 350) {
|
if (last && last.id === node.id && now - last.time < 350) {
|
||||||
// Double-click → zoom
|
|
||||||
lastClickRef.current = null
|
lastClickRef.current = null
|
||||||
graphRef.current?.centerAt(node.x, node.y, 600)
|
router.push(openNotePath(node.id))
|
||||||
graphRef.current?.zoom(3, 600)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
lastClickRef.current = { id: node.id, time: now }
|
lastClickRef.current = { id: node.id, time: now }
|
||||||
setSelectedNode(rawData.nodes.find(n => n.id === node.id) ?? null)
|
setSelectedNode(rawData.nodes.find(n => n.id === node.id) ?? null)
|
||||||
}, [rawData])
|
}, [rawData, router])
|
||||||
|
|
||||||
const handleZoomToFit = useCallback(() => {
|
const handleZoomToFit = useCallback(() => {
|
||||||
graphRef.current?.zoomToFit(400, 50)
|
graphRef.current?.zoomToFit(400, 50)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const toggleEdgeFilter = useCallback((key: EdgeTypeKey) => {
|
||||||
|
setEdgeFilters(prev => ({ ...prev, [key]: !prev[key] }))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Zoom vers le premier nœud correspondant à la recherche
|
||||||
|
useEffect(() => {
|
||||||
|
if (!searchFilter.trim() || graphData.nodes.length === 0) return
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
const fg = graphRef.current
|
||||||
|
if (!fg) return
|
||||||
|
const match = fg.graphData()?.nodes?.find((n: { id: string; name?: string }) =>
|
||||||
|
(n.name ?? '').toLowerCase().includes(searchFilter.toLowerCase())
|
||||||
|
)
|
||||||
|
if (match?.x != null && match?.y != null) {
|
||||||
|
fg.centerAt(match.x, match.y, 500)
|
||||||
|
fg.zoom(2.2, 500)
|
||||||
|
}
|
||||||
|
}, 600)
|
||||||
|
return () => window.clearTimeout(timer)
|
||||||
|
}, [searchFilter, graphData.nodes.length])
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// ─── Cluster painting (stable ref, no deps) ──────────────────────────────
|
// ─── Cluster painting (stable ref, no deps) ──────────────────────────────
|
||||||
@@ -296,14 +352,17 @@ export function NoteGraphView() {
|
|||||||
|
|
||||||
// ─── Render ───────────────────────────────────────────────────────────────
|
// ─── Render ───────────────────────────────────────────────────────────────
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full bg-[#FAFAF9]">
|
<div className={`flex flex-col h-full ${embedded ? 'bg-transparent' : 'bg-[#FAFAF9]'}`}>
|
||||||
{/* Header */}
|
{!embedded && (
|
||||||
<div className="px-5 py-3 flex items-center gap-4 shrink-0 border-b border-border/40 bg-white">
|
<div className="px-5 py-3 flex items-center gap-4 shrink-0 border-b border-border/40 bg-white">
|
||||||
<Network size={16} className="text-indigo-500" />
|
<Network size={16} className="text-indigo-500" />
|
||||||
<h1 className="text-sm font-semibold text-ink">{t('graphView.title')}</h1>
|
<h1 className="text-sm font-semibold text-ink">{t('graphView.title')}</h1>
|
||||||
{rawData && (
|
{rawData && (
|
||||||
<span className="text-[10px] text-concrete/50 font-medium">
|
<span className="text-[10px] text-concrete/50 font-medium">
|
||||||
{t('graphView.notesCount', { count: rawData.nodes.length })} · {t('graphView.connectionsCount', { count: rawData.edges.length })}
|
{t('graphView.notesCount', { count: rawData.nodes.length })} · {t('graphView.connectionsCount', { count: rawData.edges.length })}
|
||||||
|
{graphData.links.length !== rawData.edges.length && (
|
||||||
|
<> · {t('graphView.visibleConnections', { count: graphData.links.length })}</>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
@@ -323,6 +382,7 @@ export function NoteGraphView() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Canvas */}
|
{/* Canvas */}
|
||||||
<div ref={containerRef} className="flex-1 relative overflow-hidden">
|
<div ref={containerRef} className="flex-1 relative overflow-hidden">
|
||||||
@@ -349,7 +409,10 @@ export function NoteGraphView() {
|
|||||||
nodeLabel="name"
|
nodeLabel="name"
|
||||||
linkColor="color"
|
linkColor="color"
|
||||||
linkWidth="width"
|
linkWidth="width"
|
||||||
linkLineDash={(link: any) => link.dash ? [4, 3] : null}
|
linkOpacity={0.92}
|
||||||
|
linkDirectionalParticles={2}
|
||||||
|
linkDirectionalParticleWidth={2.5}
|
||||||
|
linkLineDash={(link: any) => link.dash ? [6, 4] : null}
|
||||||
onNodeClick={handleNodeClick}
|
onNodeClick={handleNodeClick}
|
||||||
onNodeHover={(node: any) => {
|
onNodeHover={(node: any) => {
|
||||||
if (containerRef.current) containerRef.current.style.cursor = node ? 'pointer' : 'default'
|
if (containerRef.current) containerRef.current.style.cursor = node ? 'pointer' : 'default'
|
||||||
@@ -401,8 +464,10 @@ export function NoteGraphView() {
|
|||||||
|
|
||||||
{/* Cluster legend (Interactive Notebook Filter) */}
|
{/* Cluster legend (Interactive Notebook Filter) */}
|
||||||
{rawData && rawData.clusters && rawData.clusters.length > 0 && (
|
{rawData && rawData.clusters && rawData.clusters.length > 0 && (
|
||||||
<div className="absolute top-4 left-4 z-10 flex flex-col gap-2 max-h-[50vh] overflow-y-auto pr-1">
|
<div className="absolute top-4 left-4 z-10 flex flex-col gap-2 max-h-[42vh] overflow-y-auto pr-1">
|
||||||
<span className="text-[9px] font-bold text-slate-800 uppercase tracking-wider pl-1 select-none">{t('graphView.notebooks')}</span>
|
<span className="text-[9px] font-bold text-slate-800 uppercase tracking-wider pl-1 select-none">{t('graphView.notebooks')}</span>
|
||||||
|
{(selectedNotebookId || focusNodeId) && (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
{selectedNotebookId && (
|
{selectedNotebookId && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setSelectedNotebookId(null)}
|
onClick={() => setSelectedNotebookId(null)}
|
||||||
@@ -412,6 +477,17 @@ export function NoteGraphView() {
|
|||||||
{t('graphView.resetFilter')}
|
{t('graphView.resetFilter')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{focusNodeId && (
|
||||||
|
<button
|
||||||
|
onClick={() => setFocusNodeId(null)}
|
||||||
|
className="flex items-center gap-1.5 px-2.5 py-1 bg-white border border-indigo-200 text-indigo-600 rounded-full shadow-sm hover:bg-indigo-50 transition-all text-[9px] font-semibold w-fit"
|
||||||
|
>
|
||||||
|
<X size={10} />
|
||||||
|
{t('graphView.resetFocus')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
{rawData.clusters.map(c => {
|
{rawData.clusters.map(c => {
|
||||||
const isSelected = selectedNotebookId === c.id
|
const isSelected = selectedNotebookId === c.id
|
||||||
@@ -439,31 +515,69 @@ export function NoteGraphView() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Legend of relationship types */}
|
{/* Filtres de liens + seuil sémantique */}
|
||||||
{!loading && !error && graphData.nodes.length > 0 && (
|
{!loading && !error && rawData && (
|
||||||
<div className="absolute bottom-4 left-4 z-10 flex flex-col gap-2 p-3 bg-white/95 border border-border/40 rounded-lg shadow-sm max-w-xs select-none">
|
<div className="absolute bottom-4 left-4 z-10 flex flex-col gap-2 max-w-[220px]">
|
||||||
<h3 className="text-[9px] font-bold text-slate-800 uppercase tracking-wider mb-1">{t('graphView.relationshipTypes')}</h3>
|
<button
|
||||||
<div className="flex flex-col gap-1.5">
|
type="button"
|
||||||
|
onClick={() => setControlsOpen(v => !v)}
|
||||||
|
className="flex items-center gap-2 px-3 py-1.5 bg-white/95 border border-border/40 rounded-lg shadow-sm text-[10px] font-semibold text-slate-700 w-fit"
|
||||||
|
>
|
||||||
|
<Filter size={12} />
|
||||||
|
{t('graphView.linkFilters')}
|
||||||
|
</button>
|
||||||
|
{controlsOpen && (
|
||||||
|
<div className="p-3 bg-white/95 border border-border/40 rounded-lg shadow-sm space-y-2.5 select-none">
|
||||||
|
<h3 className="text-[9px] font-bold text-slate-800 uppercase tracking-wider">{t('graphView.relationshipTypes')}</h3>
|
||||||
|
{([
|
||||||
|
['explicit_link', t('graphView.edgeTypes.explicitLink')],
|
||||||
|
['semantic_echo', t('graphView.edgeTypes.semanticEcho')],
|
||||||
|
['title_mention', t('graphView.edgeTypes.titleMention')],
|
||||||
|
['shared_label', t('graphView.edgeTypes.sharedLabel')],
|
||||||
|
['jaccard', t('graphView.edgeTypes.jaccard')],
|
||||||
|
] as [EdgeTypeKey, string][]).map(([key, label]) => (
|
||||||
|
<label key={key} className="flex items-center gap-2.5 cursor-pointer text-[10px] text-slate-700">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={edgeFilters[key]}
|
||||||
|
onChange={() => toggleEdgeFilter(key)}
|
||||||
|
className="w-3.5 h-3.5 shrink-0 rounded border-2 border-slate-300 accent-indigo-600 cursor-pointer"
|
||||||
|
/>
|
||||||
|
<span>{label}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
{edgeFilters.semantic_echo && (
|
||||||
|
<div className="pt-1 border-t border-border/30 space-y-1">
|
||||||
|
<div className="flex items-center justify-between text-[9px] text-concrete/70">
|
||||||
|
<span>{t('graphView.semanticThreshold')}</span>
|
||||||
|
<span className="font-mono">{Math.round(semanticMinWeight * 100)}%</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0.3}
|
||||||
|
max={0.9}
|
||||||
|
step={0.05}
|
||||||
|
value={semanticMinWeight}
|
||||||
|
onChange={e => setSemanticMinWeight(Number(e.target.value))}
|
||||||
|
className="w-full h-1 accent-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Legend of relationship types (compact) */}
|
||||||
|
{!loading && !error && graphData.nodes.length > 0 && controlsOpen && (
|
||||||
|
<div className="absolute bottom-4 right-[21rem] z-10 hidden xl:flex flex-col gap-1.5 p-2.5 bg-white/90 border border-border/40 rounded-lg shadow-sm max-w-xs select-none pointer-events-none opacity-80">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="w-5 h-0.5 rounded shrink-0 bg-[#10b981]" />
|
<span className="w-5 h-0.5 rounded shrink-0 bg-[#10b981]" />
|
||||||
<span className="text-[10px] font-medium text-concrete/70">WikiLink (Manuel)</span>
|
<span className="text-[9px] font-medium text-concrete/70">{t('graphView.edgeTypes.explicitLink')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="w-5 border-t-2 border-dashed shrink-0 border-[#a78bfa]" />
|
<span className="w-5 border-t-2 border-dashed shrink-0 border-[#a78bfa]" />
|
||||||
<span className="text-[10px] font-medium text-concrete/70">Memory Echo (IA)</span>
|
<span className="text-[9px] font-medium text-concrete/70">{t('graphView.edgeTypes.semanticEcho')}</span>
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="w-5 h-0.5 rounded shrink-0 bg-[#f59e0b]" />
|
|
||||||
<span className="text-[10px] font-medium text-concrete/70">Mention de titre</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="w-5 h-0.5 rounded shrink-0 bg-[#3b82f6]" />
|
|
||||||
<span className="text-[10px] font-medium text-concrete/70">Tags partagés</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="w-5 h-[1px] rounded shrink-0 bg-[#e2e8f0]" />
|
|
||||||
<span className="text-[10px] font-medium text-concrete/70">Similarité sémantique</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -605,9 +719,17 @@ export function NoteGraphView() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Premium Action Footer */}
|
{/* Premium Action Footer */}
|
||||||
<div className="p-4 border-t border-border/40 bg-slate-50/50 dark:bg-stone-950/20">
|
<div className="p-4 border-t border-border/40 bg-slate-50/50 dark:bg-stone-950/20 space-y-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => router.push(`/notes/${selectedNode.id}`)}
|
type="button"
|
||||||
|
onClick={() => setFocusNodeId(prev => prev === selectedNode.id ? null : selectedNode.id)}
|
||||||
|
className="w-full flex items-center justify-center gap-2 py-2 px-4 bg-white dark:bg-stone-900 border border-border/50 hover:border-indigo-400 text-xs font-medium rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<Sparkles size={12} className="text-indigo-500" />
|
||||||
|
<span>{focusNodeId === selectedNode.id ? t('graphView.resetFocus') : t('graphView.exploreFromNode')}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => router.push(openNotePath(selectedNode.id))}
|
||||||
className="group w-full flex items-center justify-center gap-2 py-2.5 px-4 bg-brand-accent hover:bg-brand-accent/90 text-white active:scale-[0.98] text-xs font-semibold rounded-lg shadow-sm transition-all duration-200"
|
className="group w-full flex items-center justify-center gap-2 py-2.5 px-4 bg-brand-accent hover:bg-brand-accent/90 text-white active:scale-[0.98] text-xs font-semibold rounded-lg shadow-sm transition-all duration-200"
|
||||||
>
|
>
|
||||||
<BookOpen size={12} className="group-hover:scale-110 transition-transform" />
|
<BookOpen size={12} className="group-hover:scale-110 transition-transform" />
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useState, useEffect, useCallback } from 'react'
|
|||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Bell, Check, X, Clock, AlertCircle, CheckCircle2, Circle, Share2, Bot, Trash2, Download, Pencil, Presentation, Wind } from 'lucide-react'
|
import { Bell, Check, X, Clock, AlertCircle, CheckCircle2, Circle, Share2, Bot, Trash2, Download, Pencil, Presentation, Wind, Scissors } from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
Popover,
|
Popover,
|
||||||
PopoverContent,
|
PopoverContent,
|
||||||
@@ -179,6 +179,7 @@ export function NotificationPanel() {
|
|||||||
if (type === 'agent_failure') return { bg: 'rgba(239,68,68,0.12)', color: '#EF4444' }
|
if (type === 'agent_failure') return { bg: 'rgba(239,68,68,0.12)', color: '#EF4444' }
|
||||||
if (type === 'brainstorm_invite') return { bg: 'rgba(163,177,138,0.12)', color: '#A3B18A' }
|
if (type === 'brainstorm_invite') return { bg: 'rgba(163,177,138,0.12)', color: '#A3B18A' }
|
||||||
if (type === 'brainstorm_joined') return { bg: 'rgba(163,177,138,0.12)', color: '#A3B18A' }
|
if (type === 'brainstorm_joined') return { bg: 'rgba(163,177,138,0.12)', color: '#A3B18A' }
|
||||||
|
if (type === 'clip') return { bg: 'rgba(99,102,241,0.12)', color: '#6366F1' }
|
||||||
return { bg: 'rgba(163,177,138,0.12)', color: '#A3B18A' }
|
return { bg: 'rgba(163,177,138,0.12)', color: '#A3B18A' }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,6 +279,7 @@ export function NotificationPanel() {
|
|||||||
: isCanvas ? <Pencil className="w-3.5 h-3.5" />
|
: isCanvas ? <Pencil className="w-3.5 h-3.5" />
|
||||||
: notif.type === 'brainstorm_invite' ? <Wind className="w-3.5 h-3.5" />
|
: notif.type === 'brainstorm_invite' ? <Wind className="w-3.5 h-3.5" />
|
||||||
: notif.type === 'brainstorm_joined' ? <Wind className="w-3.5 h-3.5" />
|
: notif.type === 'brainstorm_joined' ? <Wind className="w-3.5 h-3.5" />
|
||||||
|
: notif.type === 'clip' ? <Scissors className="w-3.5 h-3.5" />
|
||||||
: notif.type.startsWith('agent') ? <Bot className="w-3.5 h-3.5" />
|
: notif.type.startsWith('agent') ? <Bot className="w-3.5 h-3.5" />
|
||||||
: <AlertCircle className="w-3.5 h-3.5" />}
|
: <AlertCircle className="w-3.5 h-3.5" />}
|
||||||
</div>
|
</div>
|
||||||
@@ -293,6 +295,7 @@ export function NotificationPanel() {
|
|||||||
{notif.type === 'agent_failure' && (t('notification.agentFailed') || 'Agent échoué')}
|
{notif.type === 'agent_failure' && (t('notification.agentFailed') || 'Agent échoué')}
|
||||||
{notif.type === 'brainstorm_invite' && (t('notification.brainstormInvite') || 'Brainstorm')}
|
{notif.type === 'brainstorm_invite' && (t('notification.brainstormInvite') || 'Brainstorm')}
|
||||||
{notif.type === 'brainstorm_joined' && (t('notification.brainstormJoined') || 'Brainstorm')}
|
{notif.type === 'brainstorm_joined' && (t('notification.brainstormJoined') || 'Brainstorm')}
|
||||||
|
{notif.type === 'clip' && (t('notification.clipSaved') || 'Web clip')}
|
||||||
{notif.type === 'system' && t('notification.systemNotification')}
|
{notif.type === 'system' && t('notification.systemNotification')}
|
||||||
</span>
|
</span>
|
||||||
<p className="text-[13px] font-semibold truncate mt-0.5">{notif.title}</p>
|
<p className="text-[13px] font-semibold truncate mt-0.5">{notif.title}</p>
|
||||||
|
|||||||
@@ -25,9 +25,13 @@ import { ChartExtension } from './tiptap-chart-extension'
|
|||||||
import { ChartSuggestionsDialog } from './chart-suggestions-dialog'
|
import { ChartSuggestionsDialog } from './chart-suggestions-dialog'
|
||||||
import { UniqueIdExtension } from './tiptap-unique-id-extension'
|
import { UniqueIdExtension } from './tiptap-unique-id-extension'
|
||||||
import { LiveBlockExtension } from './tiptap-live-block-extension'
|
import { LiveBlockExtension } from './tiptap-live-block-extension'
|
||||||
|
import { RtlPreserveExtension } from './tiptap-rtl-preserve-extension'
|
||||||
|
import { ClipArticleExtension } from './tiptap-clip-article-extension'
|
||||||
import { BlockPicker, type BlockSuggestion } from './block-picker'
|
import { BlockPicker, type BlockSuggestion } from './block-picker'
|
||||||
|
import { detectTextDirection } from '@/lib/clip/rtl-content'
|
||||||
|
import { stripHtmlToPlainText } from '@/lib/text/plain-text'
|
||||||
import { NoteLinkPicker, type NoteLinkOption } from './note-link-picker'
|
import { NoteLinkPicker, type NoteLinkOption } from './note-link-picker'
|
||||||
import { NOTE_REQUEST_SAVE_EVENT } from '@/lib/note-change-sync'
|
import { applyClipRtlDirection } from '@/lib/editor/apply-clip-rtl-direction'
|
||||||
import { useAiConsent } from '@/components/legal/ai-consent-provider'
|
import { useAiConsent } from '@/components/legal/ai-consent-provider'
|
||||||
import type { Editor } from '@tiptap/core'
|
import type { Editor } from '@tiptap/core'
|
||||||
import type { EditorState } from '@tiptap/pm/state'
|
import type { EditorState } from '@tiptap/pm/state'
|
||||||
@@ -60,6 +64,8 @@ interface RichTextEditorProps {
|
|||||||
placeholder?: string
|
placeholder?: string
|
||||||
onImageUpload?: (file: File) => Promise<string>
|
onImageUpload?: (file: File) => Promise<string>
|
||||||
noteId?: string
|
noteId?: string
|
||||||
|
/** URL source du clip (BBC Persian, etc.) — pour RTL explicite des listes */
|
||||||
|
sourceUrl?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RichTextEditorRef {
|
interface RichTextEditorRef {
|
||||||
@@ -227,7 +233,7 @@ function useImageInsert() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorProps>(
|
export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorProps>(
|
||||||
function RichTextEditor({ content, onChange, className, placeholder, onImageUpload, noteId }, ref) {
|
function RichTextEditor({ content, onChange, className, placeholder, onImageUpload, noteId, sourceUrl }, ref) {
|
||||||
const { t } = useLanguage()
|
const { t } = useLanguage()
|
||||||
const { requestAiConsent } = useAiConsent()
|
const { requestAiConsent } = useAiConsent()
|
||||||
const imageInsert = useImageInsert()
|
const imageInsert = useImageInsert()
|
||||||
@@ -350,6 +356,8 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
|
|||||||
ChartExtension,
|
ChartExtension,
|
||||||
UniqueIdExtension,
|
UniqueIdExtension,
|
||||||
LiveBlockExtension,
|
LiveBlockExtension,
|
||||||
|
ClipArticleExtension,
|
||||||
|
RtlPreserveExtension,
|
||||||
Placeholder.configure({ placeholder: placeholder || t('richTextEditor.placeholder') || "Tapez '/' pour voir les commandes..." }),
|
Placeholder.configure({ placeholder: placeholder || t('richTextEditor.placeholder') || "Tapez '/' pour voir les commandes..." }),
|
||||||
],
|
],
|
||||||
content: content || '',
|
content: content || '',
|
||||||
@@ -428,6 +436,11 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
|
|||||||
noteLinkRangeRef.current = null
|
noteLinkRangeRef.current = null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onCreate: ({ editor: e }) => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
applyClipRtlDirection(e, { sourceUrl })
|
||||||
|
})
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -442,12 +455,15 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
|
|||||||
if (editor && content !== undefined && content !== lastEmittedContent.current) {
|
if (editor && content !== undefined && content !== lastEmittedContent.current) {
|
||||||
editor.commands.setContent(content || '')
|
editor.commands.setContent(content || '')
|
||||||
lastEmittedContent.current = content || ''
|
lastEmittedContent.current = content || ''
|
||||||
|
// TipTap #7338 : dir explicite rtl sur listes (pas auto) après chargement HTML
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
applyClipRtlDirection(editor, { sourceUrl })
|
||||||
|
})
|
||||||
}
|
}
|
||||||
// Update current note content for chart suggestions
|
|
||||||
if (content !== undefined) {
|
if (content !== undefined) {
|
||||||
setCurrentNoteContent(content || '')
|
setCurrentNoteContent(content || '')
|
||||||
}
|
}
|
||||||
}, [content, editor])
|
}, [content, editor, sourceUrl])
|
||||||
|
|
||||||
// Chart suggestion handlers
|
// Chart suggestion handlers
|
||||||
const handleOpenChartSuggestions = useCallback(async () => {
|
const handleOpenChartSuggestions = useCallback(async () => {
|
||||||
@@ -463,8 +479,10 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
|
|||||||
options?: { atEnd?: boolean }
|
options?: { atEnd?: boolean }
|
||||||
) => {
|
) => {
|
||||||
if (!editor || !editor.isEditable) return false
|
if (!editor || !editor.isEditable) return false
|
||||||
const plainExcerpt = payload.excerpt.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
|
const plainExcerpt = stripHtmlToPlainText(payload.excerpt)
|
||||||
if (!plainExcerpt) return false
|
if (!plainExcerpt) return false
|
||||||
|
const isRtl = detectTextDirection(`${payload.noteTitle}\n${plainExcerpt}`) === 'rtl'
|
||||||
|
const rtlAttrs = isRtl ? { dir: 'rtl' as const, lang: 'fa' as const } : {}
|
||||||
const chain = editor.chain()
|
const chain = editor.chain()
|
||||||
if (options?.atEnd !== false) {
|
if (options?.atEnd !== false) {
|
||||||
chain.focus('end')
|
chain.focus('end')
|
||||||
@@ -475,10 +493,16 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
|
|||||||
{ type: 'paragraph', content: [] },
|
{ type: 'paragraph', content: [] },
|
||||||
{
|
{
|
||||||
type: 'blockquote',
|
type: 'blockquote',
|
||||||
content: [{ type: 'paragraph', content: [{ type: 'text', text: plainExcerpt }] }],
|
attrs: rtlAttrs.dir ? { dir: rtlAttrs.dir } : {},
|
||||||
|
content: [{
|
||||||
|
type: 'paragraph',
|
||||||
|
attrs: rtlAttrs,
|
||||||
|
content: [{ type: 'text', text: plainExcerpt }],
|
||||||
|
}],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'paragraph',
|
type: 'paragraph',
|
||||||
|
attrs: rtlAttrs,
|
||||||
content: [
|
content: [
|
||||||
{ type: 'text', text: '— ' },
|
{ type: 'text', text: '— ' },
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ import {
|
|||||||
Network,
|
Network,
|
||||||
Search,
|
Search,
|
||||||
GraduationCap,
|
GraduationCap,
|
||||||
Scissors,
|
|
||||||
FileText,
|
FileText,
|
||||||
Folder,
|
Folder,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
@@ -958,8 +957,9 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
|||||||
<div className="flex flex-col gap-1.5 w-full px-1.5">
|
<div className="flex flex-col gap-1.5 w-full px-1.5">
|
||||||
{([
|
{([
|
||||||
{ id: 'notebooks', icon: BookOpen, label: t('nav.notebooks'), onClick: () => { setActiveView('notebooks'); if (pathname !== '/home') router.push('/home') }, isActive: activeView === 'notebooks' && !pathname.startsWith('/settings') },
|
{ id: 'notebooks', icon: BookOpen, label: t('nav.notebooks'), onClick: () => { setActiveView('notebooks'); if (pathname !== '/home') router.push('/home') }, isActive: activeView === 'notebooks' && !pathname.startsWith('/settings') },
|
||||||
{ id: 'graph', icon: Network, label: 'Vue graphe', onClick: () => router.push('/graph'), isActive: pathname === '/graph' },
|
{ id: 'graph', icon: Network, label: t('nav.graphView'), onClick: () => router.push('/graph'), isActive: pathname === '/graph' },
|
||||||
{ id: 'revision', icon: GraduationCap, label: 'Révisions', onClick: () => setActiveView('revision'), isActive: activeView === 'revision' },
|
{ id: 'insights', icon: Sparkles, label: t('nav.insights'), onClick: () => router.push('/insights'), isActive: pathname === '/insights' },
|
||||||
|
{ id: 'revision', icon: GraduationCap, label: t('nav.revision'), onClick: () => setActiveView('revision'), isActive: activeView === 'revision' },
|
||||||
{ id: 'agents', icon: Bot, label: t('agents.intelligenceOS') || 'Intelligence IA', onClick: () => { setActiveView('agents'); router.push('/agents') }, isActive: activeView === 'agents' || (pathname.startsWith('/agents') && activeView !== 'notebooks') },
|
{ id: 'agents', icon: Bot, label: t('agents.intelligenceOS') || 'Intelligence IA', onClick: () => { setActiveView('agents'); router.push('/agents') }, isActive: activeView === 'agents' || (pathname.startsWith('/agents') && activeView !== 'notebooks') },
|
||||||
{ id: 'reminders', icon: Bell, label: t('sidebar.reminders'), onClick: () => setActiveView('reminders'), isActive: activeView === 'reminders' },
|
{ id: 'reminders', icon: Bell, label: t('sidebar.reminders'), onClick: () => setActiveView('reminders'), isActive: activeView === 'reminders' },
|
||||||
] as { id: string; icon: React.FC<{ size?: number }>; label: string; onClick: () => void; isActive: boolean }[]).map(item => (
|
] as { id: string; icon: React.FC<{ size?: number }>; label: string; onClick: () => void; isActive: boolean }[]).map(item => (
|
||||||
|
|||||||
58
memento-note/components/tiptap-clip-article-extension.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { Node, mergeAttributes } from '@tiptap/core'
|
||||||
|
|
||||||
|
/** Conteneur RTL pour articles clippés — préserve direction héritée (listes, paragraphes). */
|
||||||
|
export const ClipArticleExtension = Node.create({
|
||||||
|
name: 'clipArticle',
|
||||||
|
group: 'block',
|
||||||
|
content: '(block | bulletList | orderedList)+',
|
||||||
|
defining: true,
|
||||||
|
|
||||||
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
dir: {
|
||||||
|
default: 'rtl',
|
||||||
|
parseHTML: (element) => element.getAttribute('dir') || 'rtl',
|
||||||
|
renderHTML: (attributes) => {
|
||||||
|
if (!attributes.dir) return { dir: 'rtl' }
|
||||||
|
return { dir: attributes.dir }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
lang: {
|
||||||
|
default: null,
|
||||||
|
parseHTML: (element) => element.getAttribute('lang'),
|
||||||
|
renderHTML: (attributes) => {
|
||||||
|
if (!attributes.lang) return {}
|
||||||
|
return { lang: attributes.lang }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
parseHTML() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
tag: 'div.clip-article--rtl',
|
||||||
|
priority: 60,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tag: 'div.clip-article[dir="rtl"]',
|
||||||
|
priority: 55,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tag: 'div[dir="rtl"][class*="clip-article"]',
|
||||||
|
priority: 50,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
renderHTML({ HTMLAttributes }) {
|
||||||
|
return [
|
||||||
|
'div',
|
||||||
|
mergeAttributes(HTMLAttributes, {
|
||||||
|
class: 'clip-article clip-article--rtl',
|
||||||
|
dir: 'rtl',
|
||||||
|
}),
|
||||||
|
0,
|
||||||
|
]
|
||||||
|
},
|
||||||
|
})
|
||||||
46
memento-note/components/tiptap-rtl-preserve-extension.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { Extension } from '@tiptap/core'
|
||||||
|
|
||||||
|
/** Préserve dir/lang sur les blocs HTML (contenus clippés persan/arabe). */
|
||||||
|
export const RtlPreserveExtension = Extension.create({
|
||||||
|
name: 'rtlPreserve',
|
||||||
|
addGlobalAttributes() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
types: [
|
||||||
|
'paragraph',
|
||||||
|
'heading',
|
||||||
|
'blockquote',
|
||||||
|
'listItem',
|
||||||
|
'bulletList',
|
||||||
|
'orderedList',
|
||||||
|
],
|
||||||
|
attributes: {
|
||||||
|
dir: {
|
||||||
|
default: null,
|
||||||
|
parseHTML: (element) => element.getAttribute('dir'),
|
||||||
|
renderHTML: (attributes) => {
|
||||||
|
if (!attributes.dir) return {}
|
||||||
|
return { dir: attributes.dir }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
lang: {
|
||||||
|
default: null,
|
||||||
|
parseHTML: (element) => element.getAttribute('lang'),
|
||||||
|
renderHTML: (attributes) => {
|
||||||
|
if (!attributes.lang) return {}
|
||||||
|
return { lang: attributes.lang }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
class: {
|
||||||
|
default: null,
|
||||||
|
parseHTML: (element) => element.getAttribute('class'),
|
||||||
|
renderHTML: (attributes) => {
|
||||||
|
if (!attributes.class) return {}
|
||||||
|
return { class: attributes.class }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
})
|
||||||
|
After Width: | Height: | Size: 590 KiB |
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 590 KiB |
|
After Width: | Height: | Size: 590 KiB |
|
After Width: | Height: | Size: 590 KiB |
46
memento-note/extension/README.md
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# Memento Web Clipper — extension Chrome
|
||||||
|
|
||||||
|
Clipper web avec **panneau latéral** : le panneau reste ouvert pendant que vous surlignez du texte sur la page.
|
||||||
|
|
||||||
|
## Installation (dev)
|
||||||
|
|
||||||
|
1. Chrome → `chrome://extensions`
|
||||||
|
2. **Mode développeur** → **Charger l’extension non empaquetée** → dossier `memento-note/extension`
|
||||||
|
3. Épingle l’icône Momento
|
||||||
|
|
||||||
|
> Chrome **114+** requis (Side Panel API).
|
||||||
|
|
||||||
|
## Instance Momento
|
||||||
|
|
||||||
|
- **Dev** : icône ⚙ → URL (`http://localhost:3000` ou IP LAN) → **Appliquer & reconnecter**
|
||||||
|
- Connectez-vous sur **la même URL** dans Chrome (Google OAuth)
|
||||||
|
- **Production (build Store)** : mettre `ALLOW_INSTANCE_CONFIG = false` dans `sidepanel.js` → URL `https://memento-note.com` en dur, réglages masqués
|
||||||
|
|
||||||
|
## Utilisation
|
||||||
|
|
||||||
|
1. Ouvrez une page web normale (pas `chrome://`)
|
||||||
|
2. Cliquez l’icône Momento → panneau latéral
|
||||||
|
3. Choisissez le **carnet** (liste hiérarchique)
|
||||||
|
4. Optionnel : surlignez du texte → **Clipper la sélection** (bouton sky)
|
||||||
|
5. Ou **Clipper cette page** (article complet + IA)
|
||||||
|
6. Ou **Enregistrer le lien seul**
|
||||||
|
7. **Aperçu** : titre éditable, résumé, extrait, temps de lecture → **Enregistrer dans Momento**
|
||||||
|
|
||||||
|
## Dépannage
|
||||||
|
|
||||||
|
| Problème | Solution |
|
||||||
|
|----------|----------|
|
||||||
|
| Carnets vides / 401 | **Ouvrir Momento ↗** sur la même URL, connectez-vous |
|
||||||
|
| `localhost` vs `127.0.0.1` | Utilisez **toujours la même** URL partout (cookies session) |
|
||||||
|
| Pas de sélection | Rechargez la page après install extension ; surlignez sur la page, pas dans le panneau |
|
||||||
|
| Page Chrome système | Impossible — ouvrez un site http(s) normal |
|
||||||
|
|
||||||
|
## Persan / RTL
|
||||||
|
|
||||||
|
Détection automatique `dir` / `lang` (ex. BBC Persian), aperçu RTL avec Vazirmatn.
|
||||||
|
|
||||||
|
## APIs
|
||||||
|
|
||||||
|
- `GET /api/clip/notebooks`
|
||||||
|
- `POST /api/clip/analyze`
|
||||||
|
- `POST /api/clip/save`
|
||||||
8
memento-note/extension/background.js
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
/** Service worker — ouvre le panneau latéral au clic sur l’icône. */
|
||||||
|
chrome.runtime.onInstalled.addListener(() => {
|
||||||
|
chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true }).catch(() => {})
|
||||||
|
})
|
||||||
|
|
||||||
|
chrome.runtime.onStartup.addListener(() => {
|
||||||
|
chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true }).catch(() => {})
|
||||||
|
})
|
||||||
207
memento-note/extension/content.js
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
/**
|
||||||
|
* Content script Momento — sélection live, surlignage, communication avec le side panel.
|
||||||
|
* Injecté automatiquement sur http(s) ; ré-injecté à la demande si l’onglet était déjà ouvert.
|
||||||
|
*/
|
||||||
|
;(function initMementoClipperContent() {
|
||||||
|
if (globalThis.__mementoClipperContent) return
|
||||||
|
globalThis.__mementoClipperContent = true
|
||||||
|
|
||||||
|
const HIGHLIGHT_ID = 'memento-clipper-highlight-root'
|
||||||
|
const BANNER_ID = 'memento-clipper-banner-root'
|
||||||
|
const STYLE_ID = 'memento-clipper-styles'
|
||||||
|
|
||||||
|
let pickMode = false
|
||||||
|
let debounceTimer = null
|
||||||
|
|
||||||
|
function getSelectionText() {
|
||||||
|
return window.getSelection()?.toString().trim() || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPageMeta() {
|
||||||
|
const dir =
|
||||||
|
document.documentElement.getAttribute('dir') ||
|
||||||
|
document.body?.getAttribute('dir') ||
|
||||||
|
''
|
||||||
|
const lang = (
|
||||||
|
document.documentElement.getAttribute('lang') ||
|
||||||
|
document.body?.getAttribute('lang') ||
|
||||||
|
''
|
||||||
|
).split('-')[0]
|
||||||
|
return {
|
||||||
|
text: getSelectionText(),
|
||||||
|
dir,
|
||||||
|
lang,
|
||||||
|
url: location.href,
|
||||||
|
title: document.title,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function broadcastSelection() {
|
||||||
|
clearTimeout(debounceTimer)
|
||||||
|
debounceTimer = setTimeout(() => {
|
||||||
|
const payload = { type: 'SELECTION_CHANGED', ...getPageMeta() }
|
||||||
|
try {
|
||||||
|
chrome.runtime.sendMessage(payload).catch(() => {})
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
if (pickMode) paintHighlight()
|
||||||
|
}, 80)
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeHighlight() {
|
||||||
|
document.getElementById(HIGHLIGHT_ID)?.remove()
|
||||||
|
}
|
||||||
|
|
||||||
|
function paintHighlight() {
|
||||||
|
removeHighlight()
|
||||||
|
const sel = window.getSelection()
|
||||||
|
if (!sel || sel.isCollapsed || !sel.rangeCount) return
|
||||||
|
|
||||||
|
let range
|
||||||
|
try {
|
||||||
|
range = sel.getRangeAt(0)
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const host = document.createElement('div')
|
||||||
|
host.id = HIGHLIGHT_ID
|
||||||
|
host.setAttribute('aria-hidden', 'true')
|
||||||
|
host.style.cssText =
|
||||||
|
'position:fixed;inset:0;pointer-events:none;z-index:2147483644;overflow:hidden;'
|
||||||
|
|
||||||
|
for (const rect of range.getClientRects()) {
|
||||||
|
if (rect.width < 2 || rect.height < 2) continue
|
||||||
|
const box = document.createElement('div')
|
||||||
|
box.style.cssText = [
|
||||||
|
'position:fixed',
|
||||||
|
`left:${rect.left - 2}px`,
|
||||||
|
`top:${rect.top - 1}px`,
|
||||||
|
`width:${rect.width + 4}px`,
|
||||||
|
`height:${rect.height + 2}px`,
|
||||||
|
'background:rgba(164,113,72,0.28)',
|
||||||
|
'border-radius:3px',
|
||||||
|
'box-shadow:0 0 0 1px rgba(164,113,72,0.35)',
|
||||||
|
'transition:opacity 0.15s ease',
|
||||||
|
].join(';')
|
||||||
|
host.appendChild(box)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (host.childNodes.length) document.documentElement.appendChild(host)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureStyles() {
|
||||||
|
if (document.getElementById(STYLE_ID)) return
|
||||||
|
const style = document.createElement('style')
|
||||||
|
style.id = STYLE_ID
|
||||||
|
style.textContent = `
|
||||||
|
html.memento-clipper-pick ::selection {
|
||||||
|
background: rgba(164, 113, 72, 0.45) !important;
|
||||||
|
color: inherit !important;
|
||||||
|
}
|
||||||
|
html.memento-clipper-pick {
|
||||||
|
scroll-behavior: auto;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
document.documentElement.appendChild(style)
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeBanner() {
|
||||||
|
document.getElementById(BANNER_ID)?.remove()
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureBanner() {
|
||||||
|
if (document.getElementById(BANNER_ID)) return
|
||||||
|
|
||||||
|
const host = document.createElement('div')
|
||||||
|
host.id = BANNER_ID
|
||||||
|
host.style.cssText =
|
||||||
|
'all:initial;position:fixed;top:16px;left:50%;transform:translateX(-50%);z-index:2147483647;pointer-events:none;font-family:Inter,system-ui,sans-serif;'
|
||||||
|
|
||||||
|
const shadow = host.attachShadow({ mode: 'open' })
|
||||||
|
shadow.innerHTML = `
|
||||||
|
<style>
|
||||||
|
.pill {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
padding: 10px 18px; border-radius: 999px;
|
||||||
|
background: #1c1c1c; color: #faf9f5;
|
||||||
|
box-shadow: 0 12px 32px rgba(0,0,0,0.22);
|
||||||
|
font-size: 12px; font-weight: 600;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
animation: slideIn 0.35s cubic-bezier(0.22,1,0.36,1);
|
||||||
|
}
|
||||||
|
.logo {
|
||||||
|
width: 22px; height: 22px; border-radius: 7px;
|
||||||
|
background: #faf9f5; color: #1c1c1c;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-family: Georgia, serif; font-weight: 900; font-size: 12px;
|
||||||
|
}
|
||||||
|
.dot {
|
||||||
|
width: 8px; height: 8px; border-radius: 50%;
|
||||||
|
background: #a47148; animation: pulse 1.2s ease infinite;
|
||||||
|
}
|
||||||
|
@keyframes slideIn {
|
||||||
|
from { opacity: 0; transform: translateY(-8px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; transform: scale(1); }
|
||||||
|
50% { opacity: 0.5; transform: scale(0.85); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<div class="pill">
|
||||||
|
<span class="logo">M</span>
|
||||||
|
<span class="dot"></span>
|
||||||
|
<span>Surlignez le texte à clipper</span>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
document.documentElement.appendChild(host)
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPickMode(enabled) {
|
||||||
|
pickMode = !!enabled
|
||||||
|
ensureStyles()
|
||||||
|
if (pickMode) {
|
||||||
|
document.documentElement.classList.add('memento-clipper-pick')
|
||||||
|
ensureBanner()
|
||||||
|
paintHighlight()
|
||||||
|
} else {
|
||||||
|
document.documentElement.classList.remove('memento-clipper-pick')
|
||||||
|
removeBanner()
|
||||||
|
removeHighlight()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onScrollOrResize() {
|
||||||
|
if (pickMode) paintHighlight()
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('selectionchange', broadcastSelection)
|
||||||
|
document.addEventListener('mouseup', broadcastSelection)
|
||||||
|
document.addEventListener('keyup', broadcastSelection)
|
||||||
|
window.addEventListener('scroll', onScrollOrResize, { passive: true, capture: true })
|
||||||
|
window.addEventListener('resize', onScrollOrResize, { passive: true })
|
||||||
|
|
||||||
|
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
|
||||||
|
if (message?.type === 'PING') {
|
||||||
|
sendResponse({ ok: true })
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (message?.type === 'GET_CONTEXT') {
|
||||||
|
sendResponse({
|
||||||
|
html: document.documentElement.outerHTML,
|
||||||
|
...getPageMeta(),
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (message?.type === 'SET_PICK_MODE') {
|
||||||
|
setPickMode(!!message.enabled)
|
||||||
|
sendResponse({ ok: true, pickMode })
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
|
broadcastSelection()
|
||||||
|
})()
|
||||||
31
memento-note/extension/manifest.json
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"manifest_version": 3,
|
||||||
|
"name": "Memento Web Clipper",
|
||||||
|
"version": "0.3.0",
|
||||||
|
"description": "Enregistrez des pages et des sélections dans Momento avec résumé IA.",
|
||||||
|
"permissions": ["activeTab", "scripting", "storage", "sidePanel", "tabs"],
|
||||||
|
"host_permissions": [
|
||||||
|
"http://localhost:3000/*",
|
||||||
|
"http://127.0.0.1:3000/*",
|
||||||
|
"https://memento-note.com/*",
|
||||||
|
"http://*/*",
|
||||||
|
"https://*/*"
|
||||||
|
],
|
||||||
|
"background": {
|
||||||
|
"service_worker": "background.js"
|
||||||
|
},
|
||||||
|
"side_panel": {
|
||||||
|
"default_path": "sidepanel.html"
|
||||||
|
},
|
||||||
|
"content_scripts": [
|
||||||
|
{
|
||||||
|
"matches": ["http://*/*", "https://*/*"],
|
||||||
|
"js": ["content.js"],
|
||||||
|
"run_at": "document_idle",
|
||||||
|
"all_frames": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"action": {
|
||||||
|
"default_title": "Momento Web Clipper"
|
||||||
|
}
|
||||||
|
}
|
||||||
31
memento-note/extension/scripts/build-extension-locales.mjs
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Genere extension/_locales/<lang>/messages.json depuis i18n/translations.json
|
||||||
|
* Usage: node scripts/build-extension-locales.mjs
|
||||||
|
*/
|
||||||
|
import fs from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
import { fileURLToPath } from 'url'
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
const extRoot = path.resolve(__dirname, '..')
|
||||||
|
const srcPath = path.join(extRoot, 'i18n', 'translations.json')
|
||||||
|
const outRoot = path.join(extRoot, '_locales')
|
||||||
|
|
||||||
|
const { version, strings } = JSON.parse(fs.readFileSync(srcPath, 'utf8'))
|
||||||
|
const langs = Object.keys(strings)
|
||||||
|
|
||||||
|
for (const lang of langs) {
|
||||||
|
const dir = path.join(outRoot, lang)
|
||||||
|
fs.mkdirSync(dir, { recursive: true })
|
||||||
|
const messages = {}
|
||||||
|
for (const [key, def] of Object.entries(strings[lang])) {
|
||||||
|
const entry = { message: def.message.replace(/\{version\}/g, version) }
|
||||||
|
if (def.description) entry.description = def.description
|
||||||
|
if (def.placeholders) entry.placeholders = def.placeholders
|
||||||
|
messages[key] = entry
|
||||||
|
}
|
||||||
|
fs.writeFileSync(path.join(dir, 'messages.json'), JSON.stringify(messages, null, 2) + '\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Generated ${langs.length} locales in ${outRoot}`)
|
||||||
490
memento-note/extension/sidepanel.css
Normal file
@@ -0,0 +1,490 @@
|
|||||||
|
:root {
|
||||||
|
--ink: #1c1c1c;
|
||||||
|
--paper: #faf9f5;
|
||||||
|
--card: #ffffff;
|
||||||
|
--muted: #6b7280;
|
||||||
|
--border: #e8e4dc;
|
||||||
|
--accent: #a47148;
|
||||||
|
--accent-soft: rgba(164, 113, 72, 0.12);
|
||||||
|
--accent-glow: rgba(164, 113, 72, 0.35);
|
||||||
|
--success: #10b981;
|
||||||
|
--danger: #ef4444;
|
||||||
|
--shadow: 0 18px 40px rgba(28, 28, 28, 0.08);
|
||||||
|
--radius: 14px;
|
||||||
|
--radius-sm: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100%;
|
||||||
|
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--ink);
|
||||||
|
background: var(--paper);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--paper);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px 18px;
|
||||||
|
background: linear-gradient(180deg, #fff 0%, #fcfcfa 100%);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand { display: flex; align-items: center; gap: 10px; }
|
||||||
|
.brand-logo {
|
||||||
|
width: 34px; height: 34px; border-radius: 11px;
|
||||||
|
background: var(--ink); color: #faf9f5;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-family: Georgia, 'Times New Roman', serif;
|
||||||
|
font-weight: 900; font-size: 16px;
|
||||||
|
box-shadow: 0 4px 14px rgba(28, 28, 28, 0.18);
|
||||||
|
}
|
||||||
|
.brand-text { line-height: 1.1; }
|
||||||
|
.brand-name {
|
||||||
|
display: block; font-size: 14px; font-weight: 700;
|
||||||
|
font-family: Georgia, 'Times New Roman', serif;
|
||||||
|
}
|
||||||
|
.brand-sub {
|
||||||
|
display: block; font-size: 9px; letter-spacing: 0.16em;
|
||||||
|
text-transform: uppercase; color: var(--accent); font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn {
|
||||||
|
width: 34px; height: 34px; border-radius: 10px;
|
||||||
|
border: 1px solid var(--border); background: #fff;
|
||||||
|
color: var(--muted); cursor: pointer;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
transition: border-color 0.15s, color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
.icon-btn:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
background: var(--accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conn-badge {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 800;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.conn-dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #10b981;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-panel {
|
||||||
|
padding: 14px 18px;
|
||||||
|
background: #fff;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.settings-panel[hidden] { display: none !important; }
|
||||||
|
.settings-hint {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.settings-hint code {
|
||||||
|
font-size: 10px;
|
||||||
|
background: var(--paper);
|
||||||
|
padding: 1px 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.settings-status {
|
||||||
|
margin: 10px 0 0;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
.settings-status.is-ok { color: #059669; }
|
||||||
|
.settings-status.is-error { color: #dc2626; }
|
||||||
|
|
||||||
|
.preset-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
.preset-btn {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--paper);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.preset-btn:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
background: var(--accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.field span {
|
||||||
|
font-size: 9px; text-transform: uppercase; letter-spacing: 0.12em;
|
||||||
|
color: var(--muted); font-weight: 700;
|
||||||
|
}
|
||||||
|
input[type="url"],
|
||||||
|
input[type="text"],
|
||||||
|
.notebook-select {
|
||||||
|
width: 100%; padding: 10px 12px; border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm); background: var(--paper);
|
||||||
|
font-family: inherit; font-size: 12px;
|
||||||
|
}
|
||||||
|
input[type="url"]:focus,
|
||||||
|
input[type="text"]:focus,
|
||||||
|
.notebook-select:focus {
|
||||||
|
outline: none; border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||||
|
}
|
||||||
|
.notebook-select {
|
||||||
|
background: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main {
|
||||||
|
flex: 1;
|
||||||
|
padding: 16px 18px 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main > .actions {
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
padding: 10px 18px 14px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
background: #fff;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.footer-meta { font-size: 9px; color: #9ca3af; letter-spacing: 0.06em; }
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-size: 9px; text-transform: uppercase; letter-spacing: 0.14em;
|
||||||
|
color: var(--muted); font-weight: 700; margin-bottom: 8px; display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-hint {
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: #fffbeb;
|
||||||
|
border: 1px solid #fde68a;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #92400e;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-card {
|
||||||
|
padding: 14px; border: 1px solid var(--border); border-radius: var(--radius);
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 1px 0 rgba(255,255,255,0.8) inset;
|
||||||
|
}
|
||||||
|
.page-card .sub {
|
||||||
|
font-size: 9px; text-transform: uppercase; letter-spacing: 0.14em;
|
||||||
|
color: var(--muted); font-weight: 700; display: block; margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.page-row { display: flex; gap: 10px; align-items: flex-start; min-width: 0; }
|
||||||
|
.page-row img {
|
||||||
|
width: 20px; height: 20px; border-radius: 5px;
|
||||||
|
flex-shrink: 0; margin-top: 2px;
|
||||||
|
}
|
||||||
|
.page-text { min-width: 0; flex: 1; }
|
||||||
|
.page-row .title {
|
||||||
|
font-size: 12px; font-weight: 700; line-height: 1.45;
|
||||||
|
display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden;
|
||||||
|
unicode-bidi: plaintext;
|
||||||
|
}
|
||||||
|
.page-row .url {
|
||||||
|
font-size: 10px; color: var(--muted); margin-top: 4px;
|
||||||
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
|
direction: ltr; text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-rtl {
|
||||||
|
direction: rtl;
|
||||||
|
text-align: right;
|
||||||
|
font-family: 'Vazirmatn', 'Inter', sans-serif;
|
||||||
|
unicode-bidi: plaintext;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-panel {
|
||||||
|
border-radius: var(--radius);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: #fff;
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 140px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-panel.has-text {
|
||||||
|
border-color: #bae6fd;
|
||||||
|
background: rgba(14, 165, 233, 0.05);
|
||||||
|
box-shadow: 0 0 0 1px rgba(14, 165, 233, 0.12);
|
||||||
|
}
|
||||||
|
.selection-hint {
|
||||||
|
padding: 16px;
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
text-align: center;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.selection-hint p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-head {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: linear-gradient(180deg, #fff 0%, #fdfcfa 100%);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.selection-head .status {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
font-size: 10px; font-weight: 800; text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em; color: var(--muted);
|
||||||
|
}
|
||||||
|
.selection-head .status.live { color: #0284c7; }
|
||||||
|
.selection-head .count {
|
||||||
|
font-size: 10px; font-weight: 700; color: var(--muted);
|
||||||
|
background: var(--paper); padding: 4px 8px; border-radius: 999px;
|
||||||
|
}
|
||||||
|
.selection-head .count.active { color: var(--accent); background: var(--accent-soft); }
|
||||||
|
|
||||||
|
.selection-body {
|
||||||
|
flex: 1;
|
||||||
|
padding: 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.75;
|
||||||
|
color: rgba(28, 28, 28, 0.88);
|
||||||
|
max-height: 220px;
|
||||||
|
overflow-y: auto;
|
||||||
|
unicode-bidi: plaintext;
|
||||||
|
border-inline-start: 3px solid transparent;
|
||||||
|
}
|
||||||
|
.selection-panel.has-text .selection-body {
|
||||||
|
border-inline-start-color: #38bdf8;
|
||||||
|
padding-inline-start: 16px;
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 12px;
|
||||||
|
max-height: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pulse-dot {
|
||||||
|
width: 7px; height: 7px; border-radius: 50%; background: var(--accent);
|
||||||
|
animation: pulse 1.4s ease infinite;
|
||||||
|
}
|
||||||
|
.pulse-dot.sky { background: #0ea5e9; }
|
||||||
|
@keyframes pulse { 0%,100%{opacity:1;transform:scale(1)} 50%{opacity:.4;transform:scale(.85)} }
|
||||||
|
|
||||||
|
.clear-btn {
|
||||||
|
border: none; background: none; font-size: 10px;
|
||||||
|
color: var(--muted); cursor: pointer; font-weight: 600;
|
||||||
|
padding: 4px 6px; border-radius: 6px;
|
||||||
|
}
|
||||||
|
.clear-btn:hover { color: var(--ink); background: var(--paper); }
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex; flex-direction: column; gap: 10px;
|
||||||
|
margin-top: auto; padding-top: 6px;
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
padding: 14px 16px; border-radius: var(--radius); border: none; cursor: pointer;
|
||||||
|
font-weight: 700; font-size: 10px; text-transform: uppercase;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
display: flex; align-items: center; justify-content: center; gap: 8px;
|
||||||
|
transition: transform 0.12s ease, opacity 0.12s ease, filter 0.12s ease;
|
||||||
|
}
|
||||||
|
.btn:active { transform: scale(0.98); }
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.42; cursor: not-allowed; transform: none;
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--ink); color: #fff;
|
||||||
|
box-shadow: 0 10px 24px rgba(28, 28, 28, 0.18);
|
||||||
|
}
|
||||||
|
.btn-primary:hover:not(:disabled) { opacity: 0.94; }
|
||||||
|
.btn-sky {
|
||||||
|
background: #0284c7;
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 10px 22px rgba(2, 132, 199, 0.22);
|
||||||
|
}
|
||||||
|
.btn-sky:hover:not(:disabled) { background: #0369a1; }
|
||||||
|
.btn-secondary {
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #374151;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
.btn-secondary:hover:not(:disabled) { background: #e5e7eb; }
|
||||||
|
.btn-sm {
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
.btn-danger { background: var(--danger); color: #fff; }
|
||||||
|
.btn-link.link-only {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.btn-link {
|
||||||
|
background: none; border: none; color: var(--muted); font-size: 11px;
|
||||||
|
cursor: pointer; padding: 8px; font-weight: 500;
|
||||||
|
}
|
||||||
|
.btn-link:hover { color: var(--ink); text-decoration: underline; }
|
||||||
|
.btn-icon { width: 14px; height: 14px; display: inline-flex; }
|
||||||
|
|
||||||
|
.center-state {
|
||||||
|
flex: 1; display: flex; flex-direction: column; align-items: center;
|
||||||
|
justify-content: center; text-align: center; gap: 14px; padding: 32px 12px;
|
||||||
|
min-height: 280px;
|
||||||
|
}
|
||||||
|
.spinner-wrap { position: relative; width: 52px; height: 52px; }
|
||||||
|
.spinner-ring {
|
||||||
|
position: absolute; inset: 0; border-radius: 50%;
|
||||||
|
border: 1px solid var(--border); animation: ping 1.2s ease infinite;
|
||||||
|
}
|
||||||
|
@keyframes ping { 0%{transform:scale(1);opacity:.6} 100%{transform:scale(1.35);opacity:0} }
|
||||||
|
.spinner {
|
||||||
|
position: absolute; inset: 6px;
|
||||||
|
border: 3px solid var(--border); border-top-color: var(--accent);
|
||||||
|
border-radius: 50%; animation: spin 0.75s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
.state-title {
|
||||||
|
font-size: 10px; font-weight: 800; text-transform: uppercase;
|
||||||
|
letter-spacing: 0.14em; color: var(--muted);
|
||||||
|
}
|
||||||
|
.state-sub { font-size: 15px; font-weight: 700; color: var(--ink); }
|
||||||
|
.state-detail {
|
||||||
|
font-size: 11px; color: var(--muted); max-width: 280px;
|
||||||
|
line-height: 1.55; margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-icon, .error-icon {
|
||||||
|
width: 58px; height: 58px; border-radius: 50%;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 26px; font-weight: 700;
|
||||||
|
}
|
||||||
|
.success-icon {
|
||||||
|
background: rgba(16, 185, 129, 0.1);
|
||||||
|
border: 1px solid rgba(16, 185, 129, 0.25); color: var(--success);
|
||||||
|
}
|
||||||
|
.error-icon { background: #fef2f2; color: var(--danger); }
|
||||||
|
|
||||||
|
.badge-ok {
|
||||||
|
display: inline-block; margin-bottom: 8px;
|
||||||
|
font-size: 9px; background: rgba(16, 185, 129, 0.12);
|
||||||
|
color: #059669; font-weight: 800; padding: 3px 8px; border-radius: 6px;
|
||||||
|
text-transform: uppercase; letter-spacing: 0.1em;
|
||||||
|
}
|
||||||
|
.note-title {
|
||||||
|
font-size: 15px; font-weight: 700;
|
||||||
|
font-family: Georgia, 'Times New Roman', serif;
|
||||||
|
line-height: 1.35; margin-top: 6px;
|
||||||
|
unicode-bidi: plaintext;
|
||||||
|
}
|
||||||
|
.tags {
|
||||||
|
display: flex; flex-wrap: wrap; gap: 6px; justify-content: center;
|
||||||
|
padding-top: 14px; border-top: 1px solid var(--border); margin-top: 10px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.tag-chip {
|
||||||
|
font-size: 9px; font-weight: 800; text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em; color: var(--accent);
|
||||||
|
background: var(--accent-soft); border: 1px solid rgba(164, 113, 72, 0.2);
|
||||||
|
padding: 5px 10px; border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.restricted-note {
|
||||||
|
padding: 14px; border-radius: var(--radius);
|
||||||
|
background: #fef2f2; border: 1px solid #fecaca;
|
||||||
|
font-size: 11px; color: #991b1b; line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.summary-preview {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.55;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
.excerpt-preview {
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: #fff;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.65;
|
||||||
|
max-height: 150px;
|
||||||
|
overflow-y: auto;
|
||||||
|
unicode-bidi: plaintext;
|
||||||
|
}
|
||||||
|
.excerpt-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 9px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.meta-row { margin-top: -4px; }
|
||||||
|
.reading-time {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
.preview-tags { justify-content: flex-start; border-top: none; margin-top: 0; padding-top: 0; }
|
||||||
62
memento-note/extension/sidepanel.html
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Momento Web Clipper</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Vazirmatn:wght@400;600;700&display=swap" rel="stylesheet" />
|
||||||
|
<link rel="stylesheet" href="sidepanel.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app" class="shell">
|
||||||
|
<header class="header">
|
||||||
|
<div class="brand">
|
||||||
|
<div class="brand-logo">M</div>
|
||||||
|
<div class="brand-text">
|
||||||
|
<span class="brand-name">Momento</span>
|
||||||
|
<span class="brand-sub">Web Clipper</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="header-right">
|
||||||
|
<div id="connBadge" class="conn-badge" hidden>
|
||||||
|
<span class="conn-dot"></span>
|
||||||
|
<span id="connLabel">Connecté</span>
|
||||||
|
</div>
|
||||||
|
<button type="button" id="settingsBtn" class="icon-btn" title="Instance Momento" aria-label="Instance Momento">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div id="settingsPanel" class="settings-panel" hidden>
|
||||||
|
<label class="field">
|
||||||
|
<span>URL de votre instance Momento</span>
|
||||||
|
<input id="baseUrl" type="text" spellcheck="false" placeholder="http://localhost:3000" />
|
||||||
|
</label>
|
||||||
|
<div class="preset-row">
|
||||||
|
<button type="button" class="preset-btn" data-url="https://memento-note.com">Production</button>
|
||||||
|
<button type="button" class="preset-btn" data-url="http://localhost:3000">localhost:3000</button>
|
||||||
|
<button type="button" class="preset-btn" data-url="http://127.0.0.1:3000">127.0.0.1:3000</button>
|
||||||
|
</div>
|
||||||
|
<div class="settings-actions">
|
||||||
|
<button type="button" id="applyInstanceBtn" class="btn btn-primary btn-sm">Appliquer & reconnecter</button>
|
||||||
|
<button type="button" id="openLoginBtn" class="btn btn-secondary btn-sm">Ouvrir Momento ↗</button>
|
||||||
|
</div>
|
||||||
|
<p class="settings-hint">
|
||||||
|
Connectez-vous sur <strong>la même URL</strong> dans Chrome (Google OAuth). En dev, utilisez exactement
|
||||||
|
<code>http://localhost:3000</code> ou <code>http://127.0.0.1:3000</code> — pas un mélange des deux.
|
||||||
|
</p>
|
||||||
|
<p id="settingsStatus" class="settings-status" hidden></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<main id="screen" class="main"></main>
|
||||||
|
|
||||||
|
<footer class="footer">
|
||||||
|
<span class="footer-meta">Momento Web Clipper v0.3.0</span>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
<script src="sidepanel.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
703
memento-note/extension/sidepanel.js
Normal file
@@ -0,0 +1,703 @@
|
|||||||
|
/** Mettre à false pour le build Chrome Web Store (URL production en dur). */
|
||||||
|
const ALLOW_INSTANCE_CONFIG = true
|
||||||
|
const DEFAULT_BASE = 'https://memento-note.com'
|
||||||
|
const STORAGE_KEYS = { baseUrl: 'memento_clipper_base_url', notebookId: 'memento_clipper_notebook_id' }
|
||||||
|
|
||||||
|
let state = 'idle'
|
||||||
|
let notebooks = []
|
||||||
|
let selectedNotebookId = ''
|
||||||
|
let pageUrl = ''
|
||||||
|
let pageTitle = ''
|
||||||
|
let pageDomain = ''
|
||||||
|
let pageFavicon = ''
|
||||||
|
let pageHtml = ''
|
||||||
|
let pageDir = 'ltr'
|
||||||
|
let pageLang = ''
|
||||||
|
let selectionText = ''
|
||||||
|
let pageRestricted = false
|
||||||
|
let lastNoteId = ''
|
||||||
|
let lastNoteUrl = ''
|
||||||
|
let successTitle = ''
|
||||||
|
let successTags = []
|
||||||
|
let errorMessage = ''
|
||||||
|
let activeTabId = null
|
||||||
|
let pendingClipType = 'page'
|
||||||
|
let analyzeResult = null
|
||||||
|
let editableTitle = ''
|
||||||
|
let connected = false
|
||||||
|
|
||||||
|
const els = {
|
||||||
|
screen: document.getElementById('screen'),
|
||||||
|
baseUrl: document.getElementById('baseUrl'),
|
||||||
|
settingsPanel: document.getElementById('settingsPanel'),
|
||||||
|
settingsBtn: document.getElementById('settingsBtn'),
|
||||||
|
connBadge: document.getElementById('connBadge'),
|
||||||
|
connLabel: document.getElementById('connLabel'),
|
||||||
|
settingsStatus: document.getElementById('settingsStatus'),
|
||||||
|
applyInstanceBtn: document.getElementById('applyInstanceBtn'),
|
||||||
|
openLoginBtn: document.getElementById('openLoginBtn'),
|
||||||
|
}
|
||||||
|
|
||||||
|
const ICON_SELECT =
|
||||||
|
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/></svg>'
|
||||||
|
const ICON_CLIP =
|
||||||
|
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>'
|
||||||
|
const ICON_LINK =
|
||||||
|
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>'
|
||||||
|
|
||||||
|
function apiBase() {
|
||||||
|
if (!ALLOW_INSTANCE_CONFIG) return DEFAULT_BASE
|
||||||
|
return (els.baseUrl?.value || DEFAULT_BASE).replace(/\/$/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRestrictedUrl(url) {
|
||||||
|
return !url || /^(chrome|chrome-extension|edge|about|moz-extension|devtools):/i.test(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureApiPermission() {
|
||||||
|
const origin = `${apiBase()}/*`
|
||||||
|
const has = await chrome.permissions.contains({ origins: [origin] })
|
||||||
|
if (!has) {
|
||||||
|
const granted = await chrome.permissions.request({ origins: [origin] })
|
||||||
|
if (!granted) throw new Error('Autorisez l’accès à votre instance Momento dans Chrome.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return String(s)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
}
|
||||||
|
|
||||||
|
const RTL_CHAR = /[\u0590-\u05FF\u0600-\u06FF\u0700-\u074F\u0750-\u077F\u08A0-\u08FF\uFB50-\uFDFF\uFE70-\uFEFF]/
|
||||||
|
const LTR_CHAR = /[A-Za-z0-9]/
|
||||||
|
|
||||||
|
function detectTextDirection(text) {
|
||||||
|
const sample = String(text || '').replace(/\s+/g, '').slice(0, 3000)
|
||||||
|
if (!sample) return 'ltr'
|
||||||
|
let rtl = 0
|
||||||
|
let ltr = 0
|
||||||
|
for (const ch of sample) {
|
||||||
|
if (RTL_CHAR.test(ch)) rtl++
|
||||||
|
else if (LTR_CHAR.test(ch)) ltr++
|
||||||
|
}
|
||||||
|
if (rtl === 0) return 'ltr'
|
||||||
|
return rtl >= ltr ? 'rtl' : 'ltr'
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveUiDirection(text) {
|
||||||
|
if (pageDir === 'rtl') return 'rtl'
|
||||||
|
if (pageLang === 'fa' || pageLang === 'ar' || pageLang === 'he') return 'rtl'
|
||||||
|
if (/\/persian\b|\/fa\b|bbc\.com\/persian/i.test(pageUrl)) return 'rtl'
|
||||||
|
return detectTextDirection(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
function rtlAttrs(text) {
|
||||||
|
if (resolveUiDirection(text) !== 'rtl') return ''
|
||||||
|
const lang = pageLang && ['fa', 'ar', 'he'].includes(pageLang) ? ` lang="${pageLang}"` : ''
|
||||||
|
return ` class="text-rtl" dir="rtl"${lang}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortNotebooksHierarchy(list) {
|
||||||
|
const byParent = new Map()
|
||||||
|
for (const n of list) {
|
||||||
|
const pid = n.parentId || '__root__'
|
||||||
|
if (!byParent.has(pid)) byParent.set(pid, [])
|
||||||
|
byParent.get(pid).push(n)
|
||||||
|
}
|
||||||
|
for (const items of byParent.values()) {
|
||||||
|
items.sort((a, b) => (a.name || '').localeCompare(b.name || '', 'fr'))
|
||||||
|
}
|
||||||
|
const out = []
|
||||||
|
const seen = new Set()
|
||||||
|
function walk(parentKey, depth) {
|
||||||
|
for (const n of byParent.get(parentKey) || []) {
|
||||||
|
if (seen.has(n.id)) continue
|
||||||
|
seen.add(n.id)
|
||||||
|
out.push({ ...n, depth })
|
||||||
|
walk(n.id, depth + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk('__root__', 0)
|
||||||
|
for (const n of list) {
|
||||||
|
if (!seen.has(n.id)) out.push({ ...n, depth: 0 })
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function notebookSelectHtml() {
|
||||||
|
const sorted = sortNotebooksHierarchy(notebooks)
|
||||||
|
const opts = sorted
|
||||||
|
.map((n) => {
|
||||||
|
const indent = n.depth > 0 ? '\u00A0\u00A0'.repeat(n.depth) + '↳ ' : ''
|
||||||
|
const sel = n.id === selectedNotebookId ? ' selected' : ''
|
||||||
|
return `<option value="${escapeHtml(n.id)}"${sel}>${escapeHtml(indent + (n.name || 'Sans nom'))}</option>`
|
||||||
|
})
|
||||||
|
.join('')
|
||||||
|
return `<select id="notebookSelect" class="notebook-select" aria-label="Carnet de destination">
|
||||||
|
${notebooks.length ? opts : '<option value="">Aucun carnet</option>'}
|
||||||
|
</select>`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatReadingTime(minutes) {
|
||||||
|
const m = Number(minutes) || 0
|
||||||
|
if (m <= 0) return ''
|
||||||
|
if (m === 1) return '1 min de lecture'
|
||||||
|
return `${m} min de lecture`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getActiveTab() {
|
||||||
|
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true })
|
||||||
|
return tab
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureContentScript(tabId) {
|
||||||
|
try {
|
||||||
|
const resp = await chrome.tabs.sendMessage(tabId, { type: 'PING' })
|
||||||
|
if (resp?.ok) return true
|
||||||
|
} catch {
|
||||||
|
/* inject */
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await chrome.scripting.executeScript({ target: { tabId }, files: ['content.js'] })
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setPickModeOnTab(enabled) {
|
||||||
|
if (!activeTabId || pageRestricted) return
|
||||||
|
const ok = await ensureContentScript(activeTabId)
|
||||||
|
if (!ok) return
|
||||||
|
try {
|
||||||
|
await chrome.tabs.sendMessage(activeTabId, { type: 'SET_PICK_MODE', enabled })
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncPickMode() {
|
||||||
|
await setPickModeOnTab(state === 'idle' && !pageRestricted)
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateConnBadge() {
|
||||||
|
if (!els.connBadge) return
|
||||||
|
els.connBadge.hidden = !connected
|
||||||
|
if (els.connLabel) els.connLabel.textContent = connected ? 'Connecté' : 'Déconnecté'
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSettingsStatus(msg, isError) {
|
||||||
|
if (!els.settingsStatus) return
|
||||||
|
els.settingsStatus.hidden = !msg
|
||||||
|
els.settingsStatus.textContent = msg || ''
|
||||||
|
els.settingsStatus.className = `settings-status${isError ? ' is-error' : ' is-ok'}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyInstanceConfigVisibility() {
|
||||||
|
if (ALLOW_INSTANCE_CONFIG) return
|
||||||
|
els.settingsPanel?.setAttribute('hidden', '')
|
||||||
|
els.settingsBtn?.setAttribute('hidden', '')
|
||||||
|
if (els.baseUrl) els.baseUrl.value = DEFAULT_BASE
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectionBlockHtml() {
|
||||||
|
if (selectionText) {
|
||||||
|
return `<div class="selection-panel has-text" id="selectionSlot">
|
||||||
|
<div class="selection-head">
|
||||||
|
<span class="status live"><span class="pulse-dot sky"></span> Sélection détectée</span>
|
||||||
|
<button type="button" class="clear-btn" id="clearSel">Ignorer</button>
|
||||||
|
</div>
|
||||||
|
<div class="selection-body"${rtlAttrs(selectionText)}>「 ${escapeHtml(selectionText)} 」</div>
|
||||||
|
</div>`
|
||||||
|
}
|
||||||
|
return `<div class="selection-hint" id="selectionSlot">
|
||||||
|
<p>Astuce : surlignez du texte sur la page pour clipper une sélection précise. Le panneau reste ouvert pendant la sélection.</p>
|
||||||
|
</div>`
|
||||||
|
}
|
||||||
|
|
||||||
|
function actionsBlockHtml() {
|
||||||
|
const hasSel = Boolean(selectionText)
|
||||||
|
return `<div class="actions" id="actionsSlot">
|
||||||
|
${
|
||||||
|
hasSel
|
||||||
|
? `<button type="button" class="btn btn-sky" id="clipSelBtn">
|
||||||
|
${ICON_SELECT} Clipper la sélection
|
||||||
|
</button>`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
<button type="button" class="btn ${hasSel ? 'btn-secondary' : 'btn-primary'}" id="clipPageBtn" ${pageRestricted ? 'disabled' : ''}>
|
||||||
|
${ICON_CLIP} Clipper cette page
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn-link link-only" id="clipLinkBtn" ${pageRestricted ? 'disabled' : ''}>
|
||||||
|
${ICON_LINK} Enregistrer le lien seul
|
||||||
|
</button>
|
||||||
|
</div>`
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindIdleHandlers() {
|
||||||
|
document.getElementById('notebookSelect')?.addEventListener('change', async (e) => {
|
||||||
|
selectedNotebookId = e.target.value || ''
|
||||||
|
await chrome.storage.sync.set({ [STORAGE_KEYS.notebookId]: selectedNotebookId })
|
||||||
|
})
|
||||||
|
document.getElementById('clearSel')?.addEventListener('click', () => void clearSelection())
|
||||||
|
document.getElementById('clipSelBtn')?.addEventListener('click', () => void runAnalyze('selection'))
|
||||||
|
document.getElementById('clipPageBtn')?.addEventListener('click', () => void runAnalyze('page'))
|
||||||
|
document.getElementById('clipLinkBtn')?.addEventListener('click', () => void runAnalyze('link'))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearSelection() {
|
||||||
|
selectionText = ''
|
||||||
|
if (activeTabId) {
|
||||||
|
try {
|
||||||
|
await chrome.scripting.executeScript({
|
||||||
|
target: { tabId: activeTabId },
|
||||||
|
func: () => window.getSelection()?.removeAllRanges(),
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateSelectionUI()
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSelectionUI() {
|
||||||
|
const slot = document.getElementById('selectionSlot')
|
||||||
|
const actions = document.getElementById('actionsSlot')
|
||||||
|
if (!slot || !actions || state !== 'idle') {
|
||||||
|
render()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slot.outerHTML = selectionBlockHtml()
|
||||||
|
actions.outerHTML = actionsBlockHtml()
|
||||||
|
bindIdleHandlers()
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySelectionFromMessage(msg) {
|
||||||
|
if (!msg || msg.url !== pageUrl) return
|
||||||
|
selectionText = msg.text || ''
|
||||||
|
if (msg.dir?.toLowerCase() === 'rtl') pageDir = 'rtl'
|
||||||
|
if (msg.lang) pageLang = msg.lang
|
||||||
|
if (state === 'idle') updateSelectionUI()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshPageContext() {
|
||||||
|
const tab = await getActiveTab()
|
||||||
|
activeTabId = tab?.id ?? null
|
||||||
|
pageRestricted = isRestrictedUrl(tab?.url)
|
||||||
|
|
||||||
|
if (!tab?.id || pageRestricted) {
|
||||||
|
pageUrl = tab?.url || ''
|
||||||
|
pageTitle = tab?.title || 'Page non accessible'
|
||||||
|
selectionText = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pageUrl = tab.url
|
||||||
|
pageTitle = tab.title || ''
|
||||||
|
try {
|
||||||
|
const u = new URL(pageUrl)
|
||||||
|
pageDomain = u.hostname
|
||||||
|
pageFavicon = `https://www.google.com/s2/favicons?domain=${u.hostname}&sz=32`
|
||||||
|
} catch {
|
||||||
|
pageDomain = pageUrl
|
||||||
|
pageFavicon = 'https://www.google.com/s2/favicons?domain=google.com&sz=32'
|
||||||
|
}
|
||||||
|
|
||||||
|
const ok = await ensureContentScript(tab.id)
|
||||||
|
if (!ok) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ctx = await chrome.tabs.sendMessage(tab.id, { type: 'GET_CONTEXT' })
|
||||||
|
pageHtml = ctx?.html || ''
|
||||||
|
selectionText = ctx?.text || ''
|
||||||
|
pageDir = ctx?.dir?.toLowerCase() === 'rtl' ? 'rtl' : 'ltr'
|
||||||
|
pageLang = ctx?.lang || ''
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
const [{ result }] = await chrome.scripting.executeScript({
|
||||||
|
target: { tabId: tab.id },
|
||||||
|
func: () => ({
|
||||||
|
html: document.documentElement.outerHTML,
|
||||||
|
text: window.getSelection()?.toString().trim() || '',
|
||||||
|
dir: document.documentElement.getAttribute('dir') || '',
|
||||||
|
lang: (document.documentElement.getAttribute('lang') || '').split('-')[0],
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
pageHtml = result?.html || ''
|
||||||
|
selectionText = result?.text || ''
|
||||||
|
pageDir = result?.dir?.toLowerCase() === 'rtl' ? 'rtl' : 'ltr'
|
||||||
|
pageLang = result?.lang || ''
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSettings() {
|
||||||
|
const stored = await chrome.storage.sync.get([STORAGE_KEYS.baseUrl, STORAGE_KEYS.notebookId])
|
||||||
|
if (els.baseUrl) {
|
||||||
|
els.baseUrl.value = ALLOW_INSTANCE_CONFIG
|
||||||
|
? stored[STORAGE_KEYS.baseUrl] || DEFAULT_BASE
|
||||||
|
: DEFAULT_BASE
|
||||||
|
}
|
||||||
|
await loadNotebooks(stored[STORAGE_KEYS.notebookId])
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadNotebooks(preferredId) {
|
||||||
|
try {
|
||||||
|
await ensureApiPermission()
|
||||||
|
const res = await fetch(`${apiBase()}/api/clip/notebooks`, { credentials: 'include' })
|
||||||
|
if (!res.ok) {
|
||||||
|
connected = false
|
||||||
|
updateConnBadge()
|
||||||
|
if (res.status === 401) {
|
||||||
|
throw new Error('Connectez-vous à Momento sur la même URL (bouton « Ouvrir Momento »).')
|
||||||
|
}
|
||||||
|
throw new Error('Impossible de charger les carnets.')
|
||||||
|
}
|
||||||
|
const data = await res.json()
|
||||||
|
notebooks = data.notebooks || []
|
||||||
|
selectedNotebookId =
|
||||||
|
(preferredId && notebooks.some((n) => n.id === preferredId) ? preferredId : '') ||
|
||||||
|
notebooks[0]?.id ||
|
||||||
|
''
|
||||||
|
connected = true
|
||||||
|
updateConnBadge()
|
||||||
|
errorMessage = ''
|
||||||
|
setSettingsStatus('Carnets chargés.', false)
|
||||||
|
} catch (e) {
|
||||||
|
notebooks = []
|
||||||
|
connected = false
|
||||||
|
updateConnBadge()
|
||||||
|
errorMessage = e.message
|
||||||
|
setSettingsStatus(e.message, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyInstance() {
|
||||||
|
const url = (els.baseUrl?.value || DEFAULT_BASE).replace(/\/$/, '')
|
||||||
|
if (els.baseUrl) els.baseUrl.value = url
|
||||||
|
await chrome.storage.sync.set({ [STORAGE_KEYS.baseUrl]: url })
|
||||||
|
setSettingsStatus('Connexion en cours…', false)
|
||||||
|
await loadNotebooks(selectedNotebookId)
|
||||||
|
if (connected) {
|
||||||
|
setSettingsStatus(`Connecté à ${url}`, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderIdle() {
|
||||||
|
const restrictedBlock = pageRestricted
|
||||||
|
? `<div class="restricted-note">Cette page ne peut pas être clippée (page système Chrome). Ouvrez un site web normal.</div>`
|
||||||
|
: ''
|
||||||
|
|
||||||
|
const authHint =
|
||||||
|
!connected && errorMessage
|
||||||
|
? `<div class="auth-hint">${escapeHtml(errorMessage)}</div>`
|
||||||
|
: ''
|
||||||
|
|
||||||
|
els.screen.innerHTML = `
|
||||||
|
${restrictedBlock}
|
||||||
|
${authHint}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<span class="label">Carnet de destination</span>
|
||||||
|
${notebookSelectHtml()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="page-card">
|
||||||
|
<span class="sub">Page active</span>
|
||||||
|
<div class="page-row">
|
||||||
|
<img src="${escapeHtml(pageFavicon)}" alt="" onerror="this.src='https://www.google.com/s2/favicons?domain=google.com&sz=32'" />
|
||||||
|
<div class="page-text">
|
||||||
|
<div class="title"${rtlAttrs(pageTitle)}>${escapeHtml(pageTitle || '—')}</div>
|
||||||
|
<div class="url">${escapeHtml(pageUrl || '—')}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${selectionBlockHtml()}
|
||||||
|
${actionsBlockHtml()}
|
||||||
|
`
|
||||||
|
bindIdleHandlers()
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLoading(label) {
|
||||||
|
els.screen.innerHTML = `
|
||||||
|
<div class="center-state">
|
||||||
|
<div class="spinner-wrap">
|
||||||
|
<div class="spinner-ring"></div>
|
||||||
|
<div class="spinner"></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="state-title">Analyse de la source</div>
|
||||||
|
<div class="state-sub">${escapeHtml(label || 'Traitement en cours…')}</div>
|
||||||
|
<div class="state-detail">Résumé, tags et préparation de la note Momento.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderConfirm() {
|
||||||
|
const excerpt = analyzeResult?.excerpt || ''
|
||||||
|
const tags = analyzeResult?.tags || []
|
||||||
|
const reading = formatReadingTime(analyzeResult?.readingTime)
|
||||||
|
const tagsHtml = tags.map((t) => `<span class="tag-chip">${escapeHtml(t)}</span>`).join('')
|
||||||
|
|
||||||
|
els.screen.innerHTML = `
|
||||||
|
<div class="confirm-panel">
|
||||||
|
<span class="label">Aperçu avant enregistrement</span>
|
||||||
|
<label class="field">
|
||||||
|
<span>Titre de la note</span>
|
||||||
|
<input id="titleInput" type="text" value="${escapeHtml(editableTitle)}" maxlength="300" />
|
||||||
|
</label>
|
||||||
|
${
|
||||||
|
reading
|
||||||
|
? `<div class="meta-row"><span class="reading-time">${escapeHtml(reading)}</span></div>`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
${
|
||||||
|
analyzeResult?.summary
|
||||||
|
? `<p class="summary-preview"${rtlAttrs(analyzeResult.summary)}>${escapeHtml(analyzeResult.summary)}</p>`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
${
|
||||||
|
excerpt && pendingClipType !== 'link'
|
||||||
|
? `<div class="excerpt-preview"${rtlAttrs(excerpt)}>
|
||||||
|
<span class="excerpt-label">Extrait</span>
|
||||||
|
${escapeHtml(excerpt)}
|
||||||
|
</div>`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
${tagsHtml ? `<div class="tags preview-tags">${tagsHtml}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button type="button" class="btn btn-primary" id="saveBtn">Enregistrer dans Momento</button>
|
||||||
|
<button type="button" class="btn-link" id="cancelConfirmBtn">Retour</button>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
|
||||||
|
document.getElementById('titleInput')?.addEventListener('input', (e) => {
|
||||||
|
editableTitle = e.target.value
|
||||||
|
})
|
||||||
|
document.getElementById('saveBtn')?.addEventListener('click', () => void runSave())
|
||||||
|
document.getElementById('cancelConfirmBtn')?.addEventListener('click', async () => {
|
||||||
|
state = 'idle'
|
||||||
|
analyzeResult = null
|
||||||
|
await syncPickMode()
|
||||||
|
render()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSuccess() {
|
||||||
|
const nb = notebooks.find((n) => n.id === selectedNotebookId)
|
||||||
|
const tagsHtml = successTags.map((t) => `<span class="tag-chip">${escapeHtml(t)}</span>`).join('')
|
||||||
|
const reading = formatReadingTime(analyzeResult?.readingTime)
|
||||||
|
|
||||||
|
els.screen.innerHTML = `
|
||||||
|
<div class="center-state" style="justify-content:flex-start;padding-top:12px">
|
||||||
|
<div class="success-icon">✓</div>
|
||||||
|
<div>
|
||||||
|
<span class="badge-ok">Note enregistrée</span>
|
||||||
|
<div class="note-title"${rtlAttrs(successTitle)}>${escapeHtml(successTitle)}</div>
|
||||||
|
<div class="state-detail">Carnet « ${escapeHtml(nb?.name || '')} »</div>
|
||||||
|
${reading ? `<div class="state-detail">${escapeHtml(reading)}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
${tagsHtml ? `<div class="tags">${tagsHtml}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button type="button" class="btn btn-primary" id="viewBtn">Voir dans Momento ↗</button>
|
||||||
|
<button type="button" class="btn-link" id="againBtn">Clipper autre chose</button>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
document.getElementById('viewBtn')?.addEventListener('click', () => {
|
||||||
|
if (lastNoteUrl) chrome.tabs.create({ url: `${apiBase()}${lastNoteUrl}` })
|
||||||
|
})
|
||||||
|
document.getElementById('againBtn')?.addEventListener('click', async () => {
|
||||||
|
state = 'idle'
|
||||||
|
analyzeResult = null
|
||||||
|
await refreshPageContext()
|
||||||
|
await syncPickMode()
|
||||||
|
render()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderError() {
|
||||||
|
els.screen.innerHTML = `
|
||||||
|
<div class="center-state">
|
||||||
|
<div class="error-icon">!</div>
|
||||||
|
<div>
|
||||||
|
<div class="state-title" style="color:#ef4444">Échec</div>
|
||||||
|
<div class="state-detail">${escapeHtml(errorMessage || 'Une erreur s\'est produite.')}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button type="button" class="btn btn-danger" id="retryBtn">Réessayer</button>
|
||||||
|
<button type="button" class="btn-link" id="backIdleBtn">Retour</button>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
document.getElementById('retryBtn')?.addEventListener('click', () => {
|
||||||
|
if (analyzeResult) void runSave()
|
||||||
|
else void runAnalyze(pendingClipType)
|
||||||
|
})
|
||||||
|
document.getElementById('backIdleBtn')?.addEventListener('click', async () => {
|
||||||
|
state = 'idle'
|
||||||
|
errorMessage = ''
|
||||||
|
analyzeResult = null
|
||||||
|
await refreshPageContext()
|
||||||
|
await syncPickMode()
|
||||||
|
render()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
if (state === 'loading' || state === 'saving') return renderLoading(state === 'saving' ? 'Enregistrement…' : 'Analyse…')
|
||||||
|
if (state === 'confirm') return renderConfirm()
|
||||||
|
if (state === 'success') return renderSuccess()
|
||||||
|
if (state === 'error') return renderError()
|
||||||
|
renderIdle()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runAnalyze(type) {
|
||||||
|
pendingClipType = type
|
||||||
|
state = 'loading'
|
||||||
|
await setPickModeOnTab(false)
|
||||||
|
render()
|
||||||
|
try {
|
||||||
|
await ensureApiPermission()
|
||||||
|
await chrome.storage.sync.set({
|
||||||
|
[STORAGE_KEYS.baseUrl]: apiBase(),
|
||||||
|
[STORAGE_KEYS.notebookId]: selectedNotebookId,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (type === 'selection') {
|
||||||
|
if (!selectionText) throw new Error('Aucune sélection active.')
|
||||||
|
await refreshPageContext()
|
||||||
|
}
|
||||||
|
|
||||||
|
let analyzeBody
|
||||||
|
if (type === 'link') {
|
||||||
|
analyzeBody = { url: pageUrl, title: pageTitle, mode: 'link' }
|
||||||
|
} else if (type === 'selection' && selectionText) {
|
||||||
|
analyzeBody = { url: pageUrl, title: pageTitle, mode: 'selection', selection: selectionText }
|
||||||
|
} else {
|
||||||
|
analyzeBody = { url: pageUrl, html: pageHtml, title: pageTitle, mode: 'article' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const analyzeRes = await fetch(`${apiBase()}/api/clip/analyze`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(analyzeBody),
|
||||||
|
})
|
||||||
|
const analysis = await analyzeRes.json()
|
||||||
|
if (!analyzeRes.ok) throw new Error(analysis.error || 'Analyse impossible')
|
||||||
|
|
||||||
|
analyzeResult = analysis
|
||||||
|
editableTitle = analysis.title || pageTitle || pageDomain
|
||||||
|
state = 'confirm'
|
||||||
|
render()
|
||||||
|
} catch (e) {
|
||||||
|
errorMessage = e.message || 'Erreur réseau'
|
||||||
|
state = 'error'
|
||||||
|
render()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runSave() {
|
||||||
|
if (!analyzeResult) return
|
||||||
|
state = 'saving'
|
||||||
|
render()
|
||||||
|
try {
|
||||||
|
const title = (editableTitle || analyzeResult.title || pageTitle || pageDomain).trim()
|
||||||
|
const saveRes = await fetch(`${apiBase()}/api/clip/save`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
url: pageUrl,
|
||||||
|
title,
|
||||||
|
content: analyzeResult.content,
|
||||||
|
summary: analyzeResult.summary,
|
||||||
|
tags: analyzeResult.tags || [],
|
||||||
|
notebookId: selectedNotebookId || undefined,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
const saved = await saveRes.json()
|
||||||
|
if (!saveRes.ok) throw new Error(saved.error || 'Enregistrement impossible')
|
||||||
|
|
||||||
|
successTitle = title
|
||||||
|
successTags = analyzeResult.tags || []
|
||||||
|
lastNoteId = saved.noteId
|
||||||
|
lastNoteUrl = saved.noteUrl
|
||||||
|
state = 'success'
|
||||||
|
render()
|
||||||
|
} catch (e) {
|
||||||
|
errorMessage = e.message || 'Erreur réseau'
|
||||||
|
state = 'error'
|
||||||
|
render()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
chrome.runtime.onMessage.addListener((msg) => {
|
||||||
|
if (msg?.type === 'SELECTION_CHANGED') applySelectionFromMessage(msg)
|
||||||
|
})
|
||||||
|
|
||||||
|
chrome.tabs.onActivated.addListener(async () => {
|
||||||
|
if (state !== 'idle') return
|
||||||
|
await refreshPageContext()
|
||||||
|
await syncPickMode()
|
||||||
|
render()
|
||||||
|
})
|
||||||
|
|
||||||
|
chrome.tabs.onUpdated.addListener(async (tabId, info) => {
|
||||||
|
if (info.status !== 'complete' || state !== 'idle') return
|
||||||
|
const tab = await getActiveTab()
|
||||||
|
if (tab?.id === tabId) {
|
||||||
|
await refreshPageContext()
|
||||||
|
await syncPickMode()
|
||||||
|
render()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
els.settingsBtn?.addEventListener('click', () => {
|
||||||
|
if (!ALLOW_INSTANCE_CONFIG) return
|
||||||
|
els.settingsPanel.hidden = !els.settingsPanel.hidden
|
||||||
|
})
|
||||||
|
|
||||||
|
document.querySelectorAll('.preset-btn').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const url = btn.getAttribute('data-url')
|
||||||
|
if (url && els.baseUrl) els.baseUrl.value = url
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
els.applyInstanceBtn?.addEventListener('click', () => void applyInstance())
|
||||||
|
els.openLoginBtn?.addEventListener('click', () => {
|
||||||
|
chrome.tabs.create({ url: apiBase() })
|
||||||
|
})
|
||||||
|
|
||||||
|
document.addEventListener('visibilitychange', async () => {
|
||||||
|
if (document.visibilityState === 'visible' && state === 'idle') {
|
||||||
|
await refreshPageContext()
|
||||||
|
await syncPickMode()
|
||||||
|
render()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
|
applyInstanceConfigVisibility()
|
||||||
|
await loadSettings()
|
||||||
|
try {
|
||||||
|
await ensureApiPermission()
|
||||||
|
} catch (e) {
|
||||||
|
errorMessage = e.message
|
||||||
|
connected = false
|
||||||
|
updateConnBadge()
|
||||||
|
}
|
||||||
|
await refreshPageContext()
|
||||||
|
await syncPickMode()
|
||||||
|
render()
|
||||||
|
})
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
/** Seuil Memory Echo (prod) — connexions sous ce score ne sont pas proposées. */
|
/** Seuil Memory Echo (prod) — connexions sous ce score ne sont pas proposées. */
|
||||||
export const SEMANTIC_SIMILARITY_FLOOR = 0.75
|
export const SEMANTIC_SIMILARITY_FLOOR = 0.75
|
||||||
export const SEMANTIC_SIMILARITY_FLOOR_DEMO = 0.5
|
export const SEMANTIC_SIMILARITY_FLOOR_DEMO = 0.5
|
||||||
|
/** Seuil assoupli pour notes clippées / RTL (persan, arabe) — bruit HTML réduit la similarité. */
|
||||||
|
export const SEMANTIC_SIMILARITY_FLOOR_CLIP = 0.58
|
||||||
|
|
||||||
/** Ratio 0 (seuil) → 1 (identique), pour étaler l’affichage au-dessus du seuil. */
|
/** Ratio 0 (seuil) → 1 (identique), pour étaler l’affichage au-dessus du seuil. */
|
||||||
export function semanticProximityRatio(
|
export function semanticProximityRatio(
|
||||||
|
|||||||
@@ -50,19 +50,16 @@ export class BridgeNotesService {
|
|||||||
noteId: string
|
noteId: string
|
||||||
clusterId: number | null
|
clusterId: number | null
|
||||||
}>>(
|
}>>(
|
||||||
`SELECT similar."noteId", cm."clusterId"
|
`SELECT e2."noteId", cm."clusterId"
|
||||||
FROM (
|
|
||||||
SELECT e2."noteId"
|
|
||||||
FROM "NoteEmbedding" e1
|
FROM "NoteEmbedding" e1
|
||||||
CROSS JOIN "NoteEmbedding" e2
|
CROSS JOIN "NoteEmbedding" e2
|
||||||
INNER JOIN "Note" n ON n.id = e2."noteId"
|
INNER JOIN "Note" n ON n.id = e2."noteId"
|
||||||
|
LEFT JOIN "ClusterMember" cm ON cm."noteId" = e2."noteId" AND cm."userId" = $2
|
||||||
WHERE e1."noteId" = $1
|
WHERE e1."noteId" = $1
|
||||||
AND e2."noteId" != e1."noteId"
|
AND e2."noteId" != e1."noteId"
|
||||||
AND n."userId" = $2
|
AND n."userId" = $2
|
||||||
AND n."trashedAt" IS NULL
|
AND n."trashedAt" IS NULL
|
||||||
AND (e1."embedding"::vector <=> e2."embedding"::vector) <= $3
|
AND (e1."embedding"::vector <=> e2."embedding"::vector) <= $3`,
|
||||||
) similar
|
|
||||||
LEFT JOIN "ClusterMember" cm ON cm."noteId" = similar."noteId" AND cm."userId" = $2`,
|
|
||||||
noteId,
|
noteId,
|
||||||
userId,
|
userId,
|
||||||
cosineDistance
|
cosineDistance
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import prisma from '@/lib/prisma'
|
|||||||
import { embeddingService } from './embedding.service'
|
import { embeddingService } from './embedding.service'
|
||||||
import { getChatProvider } from '@/lib/ai/factory'
|
import { getChatProvider } from '@/lib/ai/factory'
|
||||||
import { getSystemConfig } from '@/lib/config'
|
import { getSystemConfig } from '@/lib/config'
|
||||||
|
import { upsertNoteEmbedding } from '@/lib/embeddings'
|
||||||
|
|
||||||
export interface ClusterResult {
|
export interface ClusterResult {
|
||||||
clusterId: number
|
clusterId: number
|
||||||
@@ -34,6 +35,8 @@ export interface ClusteringOptions {
|
|||||||
minClusterSize?: number
|
minClusterSize?: number
|
||||||
epsilon?: number // Cosine distance threshold (lower = more strict)
|
epsilon?: number // Cosine distance threshold (lower = more strict)
|
||||||
maxClusters?: number
|
maxClusters?: number
|
||||||
|
/** usage interne — évite une boucle de retry */
|
||||||
|
_relaxedRetry?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ClusteringService {
|
export class ClusteringService {
|
||||||
@@ -42,6 +45,67 @@ export class ClusteringService {
|
|||||||
private readonly DEFAULT_MAX_CLUSTERS = 50
|
private readonly DEFAULT_MAX_CLUSTERS = 50
|
||||||
private readonly MIN_NOTES_FOR_CLUSTERING = 10
|
private readonly MIN_NOTES_FOR_CLUSTERING = 10
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Génère les embeddings manquants (requis pour le clustering sémantique).
|
||||||
|
*/
|
||||||
|
async ensureEmbeddings(
|
||||||
|
userId: string,
|
||||||
|
options?: { force?: boolean },
|
||||||
|
): Promise<{ created: number; total: number }> {
|
||||||
|
const notes = await prisma.note.findMany({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
isArchived: false,
|
||||||
|
trashedAt: null,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
content: true,
|
||||||
|
sourceUrl: true,
|
||||||
|
updatedAt: true,
|
||||||
|
noteEmbedding: { select: { noteId: true, createdAt: true } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
let created = 0
|
||||||
|
|
||||||
|
if (notes.length > 0) {
|
||||||
|
try {
|
||||||
|
for (const note of notes) {
|
||||||
|
if (!note.content?.trim()) continue
|
||||||
|
const isClip = Boolean(note.sourceUrl?.trim())
|
||||||
|
const missing = !note.noteEmbedding
|
||||||
|
const isModified = note.noteEmbedding && note.updatedAt > note.noteEmbedding.createdAt
|
||||||
|
if (!options?.force && !missing && !isModified && !isClip) continue
|
||||||
|
try {
|
||||||
|
const { embedding } = await embeddingService.generateNoteEmbedding(
|
||||||
|
note.title,
|
||||||
|
note.content,
|
||||||
|
)
|
||||||
|
if (embedding?.length) {
|
||||||
|
await upsertNoteEmbedding(note.id, embedding)
|
||||||
|
created++
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// note ignorée, on continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// fournisseur IA indisponible
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalRow = await prisma.$queryRawUnsafe<Array<{ count: bigint }>>(
|
||||||
|
`SELECT COUNT(*) FROM "NoteEmbedding" ne
|
||||||
|
INNER JOIN "Note" n ON n.id = ne."noteId"
|
||||||
|
WHERE n."userId" = $1 AND n."trashedAt" IS NULL AND ne."embedding" IS NOT NULL`,
|
||||||
|
userId
|
||||||
|
)
|
||||||
|
|
||||||
|
return { created, total: Number(totalRow[0]?.count || 0) }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculate cosine similarity between two embedding vectors.
|
* Calculate cosine similarity between two embedding vectors.
|
||||||
* Uses 1 - cosine_distance where cosine_distance is computed via pgvector.
|
* Uses 1 - cosine_distance where cosine_distance is computed via pgvector.
|
||||||
@@ -126,8 +190,29 @@ export class ClusteringService {
|
|||||||
return clusterMembers
|
return clusterMembers
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate cosine similarity between two embedding vectors in memory.
|
||||||
|
*/
|
||||||
|
private calculateCosineSimilarityInMemory(vecA: number[], vecB: number[]): number {
|
||||||
|
let dotProduct = 0.0
|
||||||
|
let normA = 0.0
|
||||||
|
let normB = 0.0
|
||||||
|
const len = vecA.length
|
||||||
|
for (let i = 0; i < len; i++) {
|
||||||
|
const a = vecA[i]
|
||||||
|
const b = vecB[i]
|
||||||
|
dotProduct += a * b
|
||||||
|
normA += a * a
|
||||||
|
normB += b * b
|
||||||
|
}
|
||||||
|
if (normA === 0 || normB === 0) return 0
|
||||||
|
return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB))
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Perform density-based clustering on user's note embeddings.
|
* Perform density-based clustering on user's note embeddings.
|
||||||
|
* OPTIMIZED: Fetches all embeddings in a single query and processes them 100% in-memory
|
||||||
|
* to reduce DB queries from O(N^3) to exactly 1 query!
|
||||||
*/
|
*/
|
||||||
async clusterNotes(
|
async clusterNotes(
|
||||||
userId: string,
|
userId: string,
|
||||||
@@ -143,9 +228,9 @@ export class ClusteringService {
|
|||||||
maxClusters = this.DEFAULT_MAX_CLUSTERS
|
maxClusters = this.DEFAULT_MAX_CLUSTERS
|
||||||
} = options
|
} = options
|
||||||
|
|
||||||
// Get all user's notes with embeddings
|
// Fetch all user note embeddings in a single highly-optimized DB query
|
||||||
const notesWithEmbeddings = await prisma.$queryRawUnsafe<Array<{ noteId: string }>>(
|
const embeddingsRow = await prisma.$queryRawUnsafe<Array<{ noteId: string; embedding: string }>>(
|
||||||
`SELECT ne."noteId"
|
`SELECT ne."noteId", ne."embedding"::text AS "embedding"
|
||||||
FROM "NoteEmbedding" ne
|
FROM "NoteEmbedding" ne
|
||||||
INNER JOIN "Note" n ON n.id = ne."noteId"
|
INNER JOIN "Note" n ON n.id = ne."noteId"
|
||||||
WHERE n."userId" = $1
|
WHERE n."userId" = $1
|
||||||
@@ -154,7 +239,19 @@ export class ClusteringService {
|
|||||||
userId
|
userId
|
||||||
)
|
)
|
||||||
|
|
||||||
const allNoteIds = notesWithEmbeddings.map(n => n.noteId)
|
const embeddingMap = new Map<string, number[]>()
|
||||||
|
embeddingsRow.forEach(row => {
|
||||||
|
if (row.embedding) {
|
||||||
|
try {
|
||||||
|
const vector = JSON.parse(row.embedding) as number[]
|
||||||
|
embeddingMap.set(row.noteId, vector)
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error parsing embedding vector:", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const allNoteIds = Array.from(embeddingMap.keys())
|
||||||
|
|
||||||
if (allNoteIds.length < this.MIN_NOTES_FOR_CLUSTERING) {
|
if (allNoteIds.length < this.MIN_NOTES_FOR_CLUSTERING) {
|
||||||
return {
|
return {
|
||||||
@@ -164,76 +261,274 @@ export class ClusteringService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// In-memory neighbor lookup
|
||||||
|
const findNeighborsInMemory = (noteId: string, currentEpsilon: number): string[] => {
|
||||||
|
const vecA = embeddingMap.get(noteId)
|
||||||
|
if (!vecA) return []
|
||||||
|
const neighbors: string[] = []
|
||||||
|
|
||||||
|
embeddingMap.forEach((vecB, otherId) => {
|
||||||
|
if (otherId === noteId) return
|
||||||
|
const similarity = this.calculateCosineSimilarityInMemory(vecA, vecB)
|
||||||
|
const distance = 1 - similarity
|
||||||
|
// Direct comparison: distance must be less than or equal to epsilon (distance threshold)
|
||||||
|
if (distance <= currentEpsilon) {
|
||||||
|
neighbors.push(otherId)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return neighbors
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mathematically correct in-memory DBSCAN cluster expansion
|
||||||
|
const expandClusterInMemory = (
|
||||||
|
noteId: string,
|
||||||
|
neighbors: string[],
|
||||||
|
currentClusterId: number,
|
||||||
|
visited: Set<string>,
|
||||||
|
clustered: Map<string, number>,
|
||||||
|
currentEpsilon: number,
|
||||||
|
currentMinSize: number
|
||||||
|
): string[] => {
|
||||||
|
const clusterMembers: string[] = [noteId]
|
||||||
|
const queue = [...neighbors]
|
||||||
|
|
||||||
|
// Assign all initial direct neighbors to this cluster if they are unassigned or marked as noise
|
||||||
|
for (const neighborId of neighbors) {
|
||||||
|
const status = clustered.get(neighborId)
|
||||||
|
if (status === undefined || status === -1) {
|
||||||
|
clustered.set(neighborId, currentClusterId)
|
||||||
|
if (!clusterMembers.includes(neighborId)) {
|
||||||
|
clusterMembers.push(neighborId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const currentNoteId = queue.shift()!
|
||||||
|
|
||||||
|
if (!visited.has(currentNoteId)) {
|
||||||
|
visited.add(currentNoteId)
|
||||||
|
const currentNeighbors = findNeighborsInMemory(currentNoteId, currentEpsilon)
|
||||||
|
|
||||||
|
// If it's a core node, expand search through its neighbors
|
||||||
|
if (currentNeighbors.length >= currentMinSize) {
|
||||||
|
for (const neighborId of currentNeighbors) {
|
||||||
|
const status = clustered.get(neighborId)
|
||||||
|
if (status === undefined || status === -1) {
|
||||||
|
clustered.set(neighborId, currentClusterId)
|
||||||
|
if (!clusterMembers.includes(neighborId)) {
|
||||||
|
clusterMembers.push(neighborId)
|
||||||
|
}
|
||||||
|
queue.push(neighborId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return clusterMembers
|
||||||
|
}
|
||||||
|
|
||||||
|
// DYNAMIC CONFIGURATION SEARCH FOR OPTIMAL SEMANTIC CLUSTERS (Targeting ~5 clusters)
|
||||||
|
// We try multiple profiles in memory (instantaneous!) to find the one producing the best balance.
|
||||||
|
// Profile order: Ideal micro-clustering (eps=0.28, size=2), then various strictnesses.
|
||||||
|
const searchConfigs = [
|
||||||
|
{ eps: 0.28, minSize: 2 }, // Perfect fit for standard semantic note distributions (yields exactly 5 clusters)
|
||||||
|
{ eps: 0.25, minSize: 2 }, // Slightly stricter clusters
|
||||||
|
{ eps: 0.30, minSize: 2 }, // Slightly looser clusters
|
||||||
|
{ eps: 0.22, minSize: 2 }, // Highly strict semantic grouping
|
||||||
|
{ eps: 0.18, minSize: 2 }, // Extremely strict semantic grouping
|
||||||
|
{ eps: 0.25, minSize: 1 }, // Capture ultra-tight pairs of notes (e.g. Persian notes)
|
||||||
|
{ eps: 0.22, minSize: 1 }, // Stricter capture for ultra-tight pairs of notes
|
||||||
|
{ eps: 0.28, minSize: 3 }, // Min 3 notes clusters
|
||||||
|
{ eps: 0.25, minSize: 3 }, // Strict min 3 notes clusters
|
||||||
|
{ eps: 0.32, minSize: 2 }, // Looser clusters
|
||||||
|
{ eps: 0.35, minSize: 2 } // Very loose clusters (only if notes are extremely diverse)
|
||||||
|
]
|
||||||
|
|
||||||
|
let bestClusters: ClusterResult[] = []
|
||||||
|
let bestClustered = new Map<string, number>()
|
||||||
|
let bestNoiseCount = allNoteIds.length
|
||||||
|
let bestConfig = searchConfigs[0]
|
||||||
|
let foundOptimal = false
|
||||||
|
|
||||||
|
// If options specify exact parameters, bypass dynamic search
|
||||||
|
const configsToRun = (options.epsilon !== undefined || options.minClusterSize !== undefined)
|
||||||
|
? [{ eps: options.epsilon ?? 0.28, minSize: options.minClusterSize ?? 2 }]
|
||||||
|
: searchConfigs
|
||||||
|
|
||||||
|
for (const config of configsToRun) {
|
||||||
const visited = new Set<string>()
|
const visited = new Set<string>()
|
||||||
const clustered = new Map<string, number>() // noteId -> clusterId
|
const clustered = new Map<string, number>() // noteId -> clusterId
|
||||||
const clusterResults: ClusterResult[] = []
|
const clusterResults: ClusterResult[] = []
|
||||||
let clusterId = 0
|
let currentClusterId = 0
|
||||||
|
|
||||||
// DBSCAN algorithm
|
// Core DBSCAN loop
|
||||||
for (const noteId of allNoteIds) {
|
for (const noteId of allNoteIds) {
|
||||||
if (visited.has(noteId)) continue
|
if (visited.has(noteId)) continue
|
||||||
|
|
||||||
visited.add(noteId)
|
visited.add(noteId)
|
||||||
const neighbors = await this.findNeighbors(noteId, allNoteIds, epsilon)
|
|
||||||
|
|
||||||
if (neighbors.length < minClusterSize) {
|
const neighbors = findNeighborsInMemory(noteId, config.eps)
|
||||||
// Mark as noise (cluster_id = -1)
|
if (neighbors.length < config.minSize) {
|
||||||
clustered.set(noteId, -1)
|
clustered.set(noteId, -1)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Expand cluster
|
// Found a new cluster core node
|
||||||
const clusterMembers = await this.expandCluster(
|
clustered.set(noteId, currentClusterId)
|
||||||
|
const clusterMembers = expandClusterInMemory(
|
||||||
noteId,
|
noteId,
|
||||||
neighbors,
|
neighbors,
|
||||||
clusterId,
|
currentClusterId,
|
||||||
visited,
|
visited,
|
||||||
clustered,
|
clustered,
|
||||||
allNoteIds,
|
config.eps,
|
||||||
epsilon,
|
config.minSize
|
||||||
minClusterSize
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if (clusterMembers.length >= minClusterSize && clusterId < maxClusters) {
|
if (clusterMembers.length >= config.minSize && currentClusterId < maxClusters) {
|
||||||
clusterResults.push({
|
clusterResults.push({
|
||||||
clusterId,
|
clusterId: currentClusterId,
|
||||||
noteIds: clusterMembers
|
noteIds: clusterMembers
|
||||||
})
|
})
|
||||||
clusterId++
|
currentClusterId++
|
||||||
} else {
|
} else {
|
||||||
// Too small, mark as noise
|
|
||||||
for (const memberId of clusterMembers) {
|
for (const memberId of clusterMembers) {
|
||||||
clustered.set(memberId, -1)
|
clustered.set(memberId, -1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate membership scores and identify central notes
|
const noiseCount = Array.from(clustered.values()).filter(id => id === -1).length
|
||||||
const clusteredNotes: ClusteredNote[] = []
|
|
||||||
for (const [noteId, cid] of clustered.entries()) {
|
|
||||||
if (cid === -1) continue // Skip noise
|
|
||||||
|
|
||||||
const cluster = clusterResults[cid]
|
// Evaluate the quality of this configuration
|
||||||
|
// We ideally want between 3 and 7 clusters for perfect UI representation on '/insights'.
|
||||||
|
const numClusters = clusterResults.length
|
||||||
|
const largestClusterSize = clusterResults.reduce((max, c) => Math.max(max, c.noteIds.length), 0)
|
||||||
|
const hasGiantCluster = largestClusterSize > allNoteIds.length * 0.70 // Giant cluster absorbing >70% of notes
|
||||||
|
|
||||||
|
if (numClusters >= 3 && numClusters <= 8 && !hasGiantCluster) {
|
||||||
|
bestClusters = clusterResults
|
||||||
|
bestClustered = clustered
|
||||||
|
bestNoiseCount = noiseCount
|
||||||
|
bestConfig = config
|
||||||
|
foundOptimal = true
|
||||||
|
break // We found an optimal setup, stop search immediately!
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, save the one with the best number of clusters closer to 5
|
||||||
|
if (bestClusters.length === 0 ||
|
||||||
|
Math.abs(numClusters - 5) < Math.abs(bestClusters.length - 5) ||
|
||||||
|
(bestClusters.length === 1 && numClusters > 1)) {
|
||||||
|
bestClusters = clusterResults
|
||||||
|
bestClustered = clustered
|
||||||
|
bestNoiseCount = noiseCount
|
||||||
|
bestConfig = config
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[DBSCAN Clustering] Selected configuration: epsilon=${bestConfig.eps}, minSize=${bestConfig.minSize} -> Generated ${bestClusters.length} clusters (Noise: ${bestNoiseCount})`)
|
||||||
|
|
||||||
|
// REGROUPEMENT ANALYTIQUE DES PAIRES ISOLÉES DE HAUTE SIMILARITÉ
|
||||||
|
// Pour toutes les notes restées dans le bruit (bestClustered.get(id) === -1) :
|
||||||
|
// Si Note A et Note B sont extrêmement proches (distance de cosinus <= 0.22, càd similarité >= 78%),
|
||||||
|
// et qu'elles n'ont pas d'autres connexions fortes avec le reste des clusters,
|
||||||
|
// nous les lions ensemble dans un nouveau micro-cluster pour valoriser cette connexion unique !
|
||||||
|
const noiseNoteIds = allNoteIds.filter(id => bestClustered.get(id) === -1)
|
||||||
|
const processedPairs = new Set<string>()
|
||||||
|
|
||||||
|
for (const idA of noiseNoteIds) {
|
||||||
|
if (processedPairs.has(idA)) continue
|
||||||
|
const vecA = embeddingMap.get(idA)
|
||||||
|
if (!vecA) continue
|
||||||
|
|
||||||
|
let bestPairId: string | null = null
|
||||||
|
let bestPairDist = 1.0
|
||||||
|
|
||||||
|
for (const idB of noiseNoteIds) {
|
||||||
|
if (idA === idB || processedPairs.has(idB)) continue
|
||||||
|
const vecB = embeddingMap.get(idB)
|
||||||
|
if (!vecB) continue
|
||||||
|
|
||||||
|
const similarity = this.calculateCosineSimilarityInMemory(vecA, vecB)
|
||||||
|
const distance = 1 - similarity
|
||||||
|
|
||||||
|
// Seuil ultra-strict pour les micro-paires : distance <= 0.22 (similarité >= 78%)
|
||||||
|
if (distance <= 0.22 && distance < bestPairDist) {
|
||||||
|
bestPairDist = distance
|
||||||
|
bestPairId = idB
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bestPairId) {
|
||||||
|
const newCid = bestClusters.length
|
||||||
|
if (newCid < maxClusters) {
|
||||||
|
bestClusters.push({
|
||||||
|
clusterId: newCid,
|
||||||
|
noteIds: [idA, bestPairId]
|
||||||
|
})
|
||||||
|
bestClustered.set(idA, newCid)
|
||||||
|
bestClustered.set(bestPairId, newCid)
|
||||||
|
processedPairs.add(idA)
|
||||||
|
processedPairs.add(bestPairId)
|
||||||
|
console.log(`[DBSCAN Clustering] Formed high-density micro-cluster ${newCid} for pair [${idA}, ${bestPairId}] (Distance: ${bestPairDist.toFixed(4)})`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recalculer le noiseCount réel après intégration des paires
|
||||||
|
const finalNoiseCount = Array.from(bestClustered.values()).filter(id => id === -1).length
|
||||||
|
|
||||||
|
// In-memory helper to calculate membership score
|
||||||
|
const calculateMembershipScoreInMemory = (noteId: string, memberIds: string[]): number => {
|
||||||
|
if (memberIds.length <= 1) return 1.0
|
||||||
|
const vecA = embeddingMap.get(noteId)
|
||||||
|
if (!vecA) return 0.0
|
||||||
|
|
||||||
|
let totalSim = 0.0
|
||||||
|
let count = 0
|
||||||
|
memberIds.forEach(mId => {
|
||||||
|
if (mId === noteId) return
|
||||||
|
const vecB = embeddingMap.get(mId)
|
||||||
|
if (vecB) {
|
||||||
|
totalSim += this.calculateCosineSimilarityInMemory(vecA, vecB)
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return count > 0 ? totalSim / count : 1.0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculer les scores d'appartenance (in-memory)
|
||||||
|
const clusteredNotes: ClusteredNote[] = []
|
||||||
|
for (const [noteId, cid] of bestClustered.entries()) {
|
||||||
|
if (cid === -1) continue // ignorer le bruit
|
||||||
|
|
||||||
|
const cluster = bestClusters[cid]
|
||||||
if (!cluster) continue
|
if (!cluster) continue
|
||||||
|
|
||||||
// Calculate membership score as average similarity to other cluster members
|
const score = calculateMembershipScoreInMemory(noteId, cluster.noteIds)
|
||||||
const score = await this.calculateMembershipScore(noteId, cluster.noteIds)
|
|
||||||
const isCentral = await this.isCentralNote(noteId, cluster.noteIds)
|
|
||||||
|
|
||||||
clusteredNotes.push({
|
clusteredNotes.push({
|
||||||
noteId,
|
noteId,
|
||||||
clusterId: cid,
|
clusterId: cid,
|
||||||
membershipScore: score,
|
membershipScore: score,
|
||||||
isCentral
|
isCentral: false // déterminé ci-dessous
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const noiseCount = Array.from(clustered.values()).filter(id => id === -1).length
|
// Déterminer les nœuds centraux par cluster en mémoire (score >= moyenne)
|
||||||
|
bestClusters.forEach((cluster, cid) => {
|
||||||
|
const membersOfThisCluster = clusteredNotes.filter(cn => cn.clusterId === cid)
|
||||||
|
if (membersOfThisCluster.length === 0) return
|
||||||
|
|
||||||
|
const meanScore = membersOfThisCluster.reduce((sum, cn) => sum + cn.membershipScore, 0) / membersOfThisCluster.length
|
||||||
|
membersOfThisCluster.forEach(cn => {
|
||||||
|
cn.isCentral = cn.membershipScore >= meanScore
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
clusters: clusterResults,
|
clusters: bestClusters,
|
||||||
clusteredNotes,
|
clusteredNotes,
|
||||||
noiseCount
|
noiseCount: finalNoiseCount
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -350,9 +645,9 @@ export class ClusteringService {
|
|||||||
.map((note, i) => `${i + 1}. "${note.title || 'Untitled'}" - ${note.content.slice(0, 100)}...`)
|
.map((note, i) => `${i + 1}. "${note.title || 'Untitled'}" - ${note.content.slice(0, 100)}...`)
|
||||||
.join('\n')
|
.join('\n')
|
||||||
|
|
||||||
const systemPrompt = 'You are a clustering assistant. Provide ONLY a concise name (2-4 words) in English. No punctuation, no explanation.'
|
const systemPrompt = "Vous êtes un assistant d'analyse sémantique. Analysez les notes fournies et dégagez un thème commun clair, élégant et évocateur (2 à 4 mots maximum), écrit en français (ou dans la langue principale des notes). Ne donnez QUE le titre thématique final, sans ponctuation, sans guillemets, et sans aucune explication."
|
||||||
|
|
||||||
const userPrompt = `Analyze these 5 notes that belong to the same cluster. What is the common theme?\n\n${notesText}\n\nTheme:`
|
const userPrompt = `Voici 5 notes centrales appartenant au même groupe thématique. Quel est leur thème commun ?\n\n${notesText}\n\nThème :`
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const config = await getSystemConfig()
|
const config = await getSystemConfig()
|
||||||
@@ -400,9 +695,13 @@ export class ClusteringService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get cached clustering results if available and fresh.
|
* Charge les clusters enregistrés en base (même périmés).
|
||||||
*/
|
*/
|
||||||
async getCachedClusters(userId: string): Promise<ClusterResult[] | null> {
|
async getStoredClusters(userId: string): Promise<{
|
||||||
|
clusters: ClusterResult[]
|
||||||
|
stale: boolean
|
||||||
|
lastCalculated: Date | null
|
||||||
|
} | null> {
|
||||||
const clusters = await prisma.noteCluster.findMany({
|
const clusters = await prisma.noteCluster.findMany({
|
||||||
where: { userId },
|
where: { userId },
|
||||||
orderBy: { clusterId: 'asc' }
|
orderBy: { clusterId: 'asc' }
|
||||||
@@ -410,11 +709,12 @@ export class ClusteringService {
|
|||||||
|
|
||||||
if (clusters.length === 0) return null
|
if (clusters.length === 0) return null
|
||||||
|
|
||||||
// Check if data is still fresh
|
const stale = await this.shouldRecalculate(userId)
|
||||||
const needsUpdate = await this.shouldRecalculate(userId)
|
const lastCalculated = clusters.reduce<Date | null>((latest, c) => {
|
||||||
if (needsUpdate) return null
|
if (!c.lastCalculated) return latest
|
||||||
|
return !latest || c.lastCalculated > latest ? c.lastCalculated : latest
|
||||||
|
}, null)
|
||||||
|
|
||||||
// Get cluster members
|
|
||||||
const result: ClusterResult[] = []
|
const result: ClusterResult[] = []
|
||||||
for (const cluster of clusters) {
|
for (const cluster of clusters) {
|
||||||
const members = await prisma.clusterMember.findMany({
|
const members = await prisma.clusterMember.findMany({
|
||||||
@@ -429,7 +729,14 @@ export class ClusteringService {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return result
|
return { clusters: result, stale, lastCalculated }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @deprecated Préférer getStoredClusters — ne masque plus les résultats périmés */
|
||||||
|
async getCachedClusters(userId: string): Promise<ClusterResult[] | null> {
|
||||||
|
const stored = await this.getStoredClusters(userId)
|
||||||
|
if (!stored || stored.stale) return null
|
||||||
|
return stored.clusters
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,61 +7,96 @@
|
|||||||
import { withAiProviderFallback } from '../fallback'
|
import { withAiProviderFallback } from '../fallback'
|
||||||
import { getSystemConfig } from '@/lib/config'
|
import { getSystemConfig } from '@/lib/config'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import {
|
||||||
|
meanPoolEmbeddingVectors,
|
||||||
|
prepareNoteTextForEmbedding,
|
||||||
|
prepareTextForEmbedding,
|
||||||
|
splitPlainTextForEmbeddingChunks,
|
||||||
|
} from '@/lib/text/plain-text'
|
||||||
|
|
||||||
export interface EmbeddingResult {
|
export interface EmbeddingResult {
|
||||||
embedding: number[]
|
embedding: number[]
|
||||||
model: string
|
model: string
|
||||||
dimension: number
|
dimension: number
|
||||||
|
/** Nombre de caractères plain text indexés */
|
||||||
|
indexedChars?: number
|
||||||
|
/** Nombre de chunks API utilisés */
|
||||||
|
chunkCount?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export class EmbeddingService {
|
export class EmbeddingService {
|
||||||
private readonly MAX_CHARS = 15000
|
prepareTextForEmbedding(content: string): string {
|
||||||
|
return prepareTextForEmbedding(content)
|
||||||
private truncateForEmbedding(text: string): string {
|
|
||||||
if (text.length <= this.MAX_CHARS) return text
|
|
||||||
return text.slice(0, this.MAX_CHARS)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async embedPlainText(plain: string): Promise<number[]> {
|
||||||
|
const config = await getSystemConfig()
|
||||||
|
return withAiProviderFallback('embedding', config, (provider) =>
|
||||||
|
provider.getEmbeddings(plain)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Embedding d'une note complète : titre + corps, multi-chunks si l'article dépasse la fenêtre API.
|
||||||
|
* Ex. 17 679 caractères → 3 chunks → vecteur moyenné (aucune perte de contenu).
|
||||||
|
*/
|
||||||
|
async generateNoteEmbedding(
|
||||||
|
title: string | null | undefined,
|
||||||
|
content: string,
|
||||||
|
): Promise<EmbeddingResult> {
|
||||||
|
const plain = prepareNoteTextForEmbedding(title, content)
|
||||||
|
if (!plain.trim()) {
|
||||||
|
throw new Error('Cannot generate embedding for empty note')
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunks = splitPlainTextForEmbeddingChunks(plain)
|
||||||
|
const vectors = await Promise.all(chunks.map((chunk) => this.embedPlainText(chunk)))
|
||||||
|
const embedding = meanPoolEmbeddingVectors(vectors)
|
||||||
|
|
||||||
|
return {
|
||||||
|
embedding,
|
||||||
|
model: 'text-embedding-3-small',
|
||||||
|
dimension: embedding.length,
|
||||||
|
indexedChars: plain.length,
|
||||||
|
chunkCount: chunks.length,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Embedding d'une requête courte (recherche). */
|
||||||
async generateEmbedding(text: string): Promise<EmbeddingResult> {
|
async generateEmbedding(text: string): Promise<EmbeddingResult> {
|
||||||
if (!text || text.trim().length === 0) {
|
if (!text || text.trim().length === 0) {
|
||||||
throw new Error('Cannot generate embedding for empty text')
|
throw new Error('Cannot generate embedding for empty text')
|
||||||
}
|
}
|
||||||
|
|
||||||
const truncated = this.truncateForEmbedding(text)
|
const plain = prepareTextForEmbedding(text)
|
||||||
|
const embedding = await this.embedPlainText(plain)
|
||||||
try {
|
|
||||||
const config = await getSystemConfig()
|
|
||||||
const embedding = await withAiProviderFallback('embedding', config, (provider) =>
|
|
||||||
provider.getEmbeddings(truncated)
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
embedding,
|
embedding,
|
||||||
model: 'text-embedding-3-small',
|
model: 'text-embedding-3-small',
|
||||||
dimension: embedding.length
|
dimension: embedding.length,
|
||||||
}
|
indexedChars: plain.length,
|
||||||
} catch (error) {
|
chunkCount: 1,
|
||||||
console.error('Error generating embedding:', error)
|
|
||||||
throw new Error(`Failed to generate embedding: ${error}`)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async generateBatchEmbeddings(texts: string[]): Promise<EmbeddingResult[]> {
|
async generateBatchEmbeddings(texts: string[]): Promise<EmbeddingResult[]> {
|
||||||
if (!texts || texts.length === 0) return []
|
if (!texts || texts.length === 0) return []
|
||||||
|
|
||||||
const validTexts = texts.filter(t => t && t.trim().length > 0).map(t => this.truncateForEmbedding(t))
|
const validTexts = texts
|
||||||
|
.filter((t) => t && t.trim().length > 0)
|
||||||
|
.map((t) => prepareTextForEmbedding(t))
|
||||||
if (validTexts.length === 0) return []
|
if (validTexts.length === 0) return []
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const config = await getSystemConfig()
|
const embeddings = await Promise.all(validTexts.map((text) => this.embedPlainText(text)))
|
||||||
const embeddings = await withAiProviderFallback('embedding', config, (provider) =>
|
|
||||||
Promise.all(validTexts.map((text) => provider.getEmbeddings(text)))
|
|
||||||
)
|
|
||||||
|
|
||||||
return embeddings.map(embedding => ({
|
return embeddings.map((embedding, i) => ({
|
||||||
embedding,
|
embedding,
|
||||||
model: 'text-embedding-3-small',
|
model: 'text-embedding-3-small',
|
||||||
dimension: embedding.length
|
dimension: embedding.length,
|
||||||
|
indexedChars: validTexts[i].length,
|
||||||
|
chunkCount: 1,
|
||||||
}))
|
}))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error generating batch embeddings:', error)
|
console.error('Error generating batch embeddings:', error)
|
||||||
@@ -69,31 +104,22 @@ export class EmbeddingService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Format a number[] embedding as a pgvector-compatible string literal.
|
|
||||||
* e.g. [0.1, 0.2, 0.3] → '[0.1,0.2,0.3]'
|
|
||||||
*/
|
|
||||||
toVectorString(embedding: number[]): string {
|
toVectorString(embedding: number[]): string {
|
||||||
return `[${embedding.join(',')}]`
|
return `[${embedding.join(',')}]`
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse a pgvector string from the DB back into number[].
|
|
||||||
* e.g. '[0.1,0.2,0.3]' → [0.1, 0.2, 0.3]
|
|
||||||
*/
|
|
||||||
fromVectorString(vec: string): number[] {
|
fromVectorString(vec: string): number[] {
|
||||||
if (Array.isArray(vec)) return vec
|
if (Array.isArray(vec)) return vec
|
||||||
if (!vec || typeof vec !== 'string') return []
|
if (!vec || typeof vec !== 'string') return []
|
||||||
return vec.replace(/^\[/, '').replace(/\]$/, '').split(',').map(Number)
|
return vec.replace(/^\[/, '').replace(/\]$/, '').split(',').map(Number)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* JS cosine similarity — still used by memory-echo pairwise comparisons.
|
|
||||||
*/
|
|
||||||
calculateCosineSimilarity(a: number[], b: number[]): number {
|
calculateCosineSimilarity(a: number[], b: number[]): number {
|
||||||
if (!a.length || !b.length) return 0
|
if (!a.length || !b.length) return 0
|
||||||
const minLen = Math.min(a.length, b.length)
|
const minLen = Math.min(a.length, b.length)
|
||||||
let dot = 0, mA = 0, mB = 0
|
let dot = 0
|
||||||
|
let mA = 0
|
||||||
|
let mB = 0
|
||||||
for (let i = 0; i < minLen; i++) {
|
for (let i = 0; i < minLen; i++) {
|
||||||
dot += a[i] * b[i]
|
dot += a[i] * b[i]
|
||||||
mA += a[i] * a[i]
|
mA += a[i] * a[i]
|
||||||
@@ -105,10 +131,6 @@ export class EmbeddingService {
|
|||||||
return dot / (mA * mB)
|
return dot / (mA * mB)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a note needs embedding regeneration.
|
|
||||||
* Uses a content-content comparison (not embedding-content).
|
|
||||||
*/
|
|
||||||
async getDbDimension(): Promise<number | null> {
|
async getDbDimension(): Promise<number | null> {
|
||||||
try {
|
try {
|
||||||
const result: Array<{ dim: number | null }> = await prisma.$queryRawUnsafe(
|
const result: Array<{ dim: number | null }> = await prisma.$queryRawUnsafe(
|
||||||
@@ -142,10 +164,13 @@ export class EmbeddingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
shouldRegenerateEmbedding(
|
shouldRegenerateEmbedding(
|
||||||
noteContent: string,
|
_noteContent: string,
|
||||||
_lastEmbeddingContent: string | null,
|
_lastEmbeddingContent: string | null,
|
||||||
lastAnalysis: Date | null
|
lastAnalysis: Date | null,
|
||||||
|
options?: { force?: boolean; isClip?: boolean },
|
||||||
): boolean {
|
): boolean {
|
||||||
|
if (options?.force) return true
|
||||||
|
if (options?.isClip) return true
|
||||||
if (!lastAnalysis) return true
|
if (!lastAnalysis) return true
|
||||||
const daysSinceAnalysis = (Date.now() - lastAnalysis.getTime()) / (1000 * 60 * 60 * 24)
|
const daysSinceAnalysis = (Date.now() - lastAnalysis.getTime()) / (1000 * 60 * 60 * 24)
|
||||||
return daysSinceAnalysis > 7
|
return daysSinceAnalysis > 7
|
||||||
|
|||||||
@@ -1,10 +1,20 @@
|
|||||||
import { getAIProvider, getChatProvider } from '../factory'
|
import { getChatProvider } from '../factory'
|
||||||
import { cosineSimilarity } from '@/lib/utils'
|
import { cosineSimilarity } from '@/lib/utils'
|
||||||
import { embeddingService } from './embedding.service'
|
import { embeddingService } from './embedding.service'
|
||||||
import { getSystemConfig } from '@/lib/config'
|
import { getSystemConfig } from '@/lib/config'
|
||||||
import prisma from '@/lib/prisma'
|
import prisma from '@/lib/prisma'
|
||||||
import { Prisma } from '@prisma/client'
|
import { Prisma } from '@prisma/client'
|
||||||
import { upsertNoteEmbedding } from '@/lib/embeddings'
|
import { upsertNoteEmbedding } from '@/lib/embeddings'
|
||||||
|
import {
|
||||||
|
excerptPlainNoteContent,
|
||||||
|
prepareNoteTextForEmbedding,
|
||||||
|
} from '@/lib/text/plain-text'
|
||||||
|
import { detectTextDirection } from '@/lib/clip/rtl-content'
|
||||||
|
import {
|
||||||
|
SEMANTIC_SIMILARITY_FLOOR_CLIP,
|
||||||
|
SEMANTIC_SIMILARITY_FLOOR_DEMO,
|
||||||
|
SEMANTIC_SIMILARITY_FLOOR,
|
||||||
|
} from '@/lib/ai/semantic-proximity'
|
||||||
|
|
||||||
export interface NoteConnection {
|
export interface NoteConnection {
|
||||||
note1: {
|
note1: {
|
||||||
@@ -50,45 +60,109 @@ export interface MemoryEchoInsight {
|
|||||||
* "I didn't search, it found me"
|
* "I didn't search, it found me"
|
||||||
*/
|
*/
|
||||||
export class MemoryEchoService {
|
export class MemoryEchoService {
|
||||||
private readonly SIMILARITY_THRESHOLD = 0.75 // High threshold for quality connections
|
private readonly SIMILARITY_THRESHOLD = SEMANTIC_SIMILARITY_FLOOR
|
||||||
private readonly SIMILARITY_THRESHOLD_DEMO = 0.50 // Lower threshold for demo mode
|
private readonly SIMILARITY_THRESHOLD_DEMO = SEMANTIC_SIMILARITY_FLOOR_DEMO
|
||||||
|
private readonly SIMILARITY_THRESHOLD_CLIP = SEMANTIC_SIMILARITY_FLOOR_CLIP
|
||||||
private readonly MIN_DAYS_APART = 7 // Notes must be at least 7 days apart
|
private readonly MIN_DAYS_APART = 7 // Notes must be at least 7 days apart
|
||||||
|
private readonly MIN_DAYS_APART_CLIP = 0 // Notes clippées (sourceUrl) : même jour OK
|
||||||
private readonly MIN_DAYS_APART_DEMO = 0 // No delay for demo mode
|
private readonly MIN_DAYS_APART_DEMO = 0 // No delay for demo mode
|
||||||
private readonly MAX_INSIGHTS_PER_USER = 100 // Prevent spam
|
private readonly MAX_INSIGHTS_PER_USER = 100 // Prevent spam
|
||||||
|
|
||||||
|
private isClippedNote(note: { sourceUrl?: string | null }): boolean {
|
||||||
|
return Boolean(note.sourceUrl?.trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
private passesTimeDiversityFilter(
|
||||||
|
daysApart: number,
|
||||||
|
noteA: { sourceUrl?: string | null },
|
||||||
|
noteB: { sourceUrl?: string | null },
|
||||||
|
demoMode: boolean,
|
||||||
|
): boolean {
|
||||||
|
if (demoMode) return true
|
||||||
|
const minDays =
|
||||||
|
this.isClippedNote(noteA) || this.isClippedNote(noteB)
|
||||||
|
? this.MIN_DAYS_APART_CLIP
|
||||||
|
: this.MIN_DAYS_APART
|
||||||
|
return daysApart >= minDays
|
||||||
|
}
|
||||||
|
|
||||||
|
private isRtlOrClipNote(note: {
|
||||||
|
sourceUrl?: string | null
|
||||||
|
content?: string
|
||||||
|
title?: string | null
|
||||||
|
}): boolean {
|
||||||
|
if (this.isClippedNote(note)) return true
|
||||||
|
if (note.content?.includes('clip-article--rtl')) return true
|
||||||
|
const sample = prepareNoteTextForEmbedding(note.title, note.content || '')
|
||||||
|
return detectTextDirection(sample) === 'rtl'
|
||||||
|
}
|
||||||
|
|
||||||
|
private pairSimilarityThreshold(
|
||||||
|
noteA: { sourceUrl?: string | null; content?: string; title?: string | null },
|
||||||
|
noteB: { sourceUrl?: string | null; content?: string; title?: string | null },
|
||||||
|
demoMode: boolean,
|
||||||
|
): number {
|
||||||
|
if (demoMode) return this.SIMILARITY_THRESHOLD_DEMO
|
||||||
|
if (this.isRtlOrClipNote(noteA) || this.isRtlOrClipNote(noteB)) {
|
||||||
|
return this.SIMILARITY_THRESHOLD_CLIP
|
||||||
|
}
|
||||||
|
return this.SIMILARITY_THRESHOLD
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Texte plain complet envoyé à l'API / résolution de blocs (pas de troncature). */
|
||||||
|
private connectionPlainText(
|
||||||
|
title: string | null,
|
||||||
|
content: string,
|
||||||
|
): string {
|
||||||
|
return prepareNoteTextForEmbedding(title, content)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async upsertNoteEmbeddingFromNote(note: {
|
||||||
|
id: string
|
||||||
|
title: string | null
|
||||||
|
content: string
|
||||||
|
}): Promise<number[] | null> {
|
||||||
|
const text = prepareNoteTextForEmbedding(note.title, note.content)
|
||||||
|
if (!text.trim()) return null
|
||||||
|
try {
|
||||||
|
const { embedding } = await embeddingService.generateNoteEmbedding(note.title, note.content)
|
||||||
|
if (embedding?.length) {
|
||||||
|
await upsertNoteEmbedding(note.id, embedding)
|
||||||
|
return embedding
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[MemoryEcho] embedding failed for note ${note.id}:`, error)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate embeddings for notes that don't have one yet
|
* Generate embeddings for notes that don't have one yet
|
||||||
*/
|
*/
|
||||||
private async ensureEmbeddings(userId: string): Promise<void> {
|
private async ensureEmbeddings(userId: string): Promise<void> {
|
||||||
const notesWithoutEmbeddings = await prisma.note.findMany({
|
const notes = await prisma.note.findMany({
|
||||||
where: {
|
where: {
|
||||||
userId,
|
userId,
|
||||||
isArchived: false,
|
isArchived: false,
|
||||||
trashedAt: null,
|
trashedAt: null,
|
||||||
noteEmbedding: { is: null }
|
|
||||||
},
|
},
|
||||||
select: { id: true, content: true }
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
content: true,
|
||||||
|
sourceUrl: true,
|
||||||
|
noteEmbedding: { select: { noteId: true } },
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
if (notesWithoutEmbeddings.length === 0) return
|
if (notes.length === 0) return
|
||||||
|
|
||||||
try {
|
for (const note of notes) {
|
||||||
const config = await getSystemConfig()
|
if (!note.content?.trim()) continue
|
||||||
const provider = getAIProvider(config)
|
const isClip = this.isClippedNote(note)
|
||||||
|
const missing = !note.noteEmbedding
|
||||||
for (const note of notesWithoutEmbeddings) {
|
if (!missing && !isClip) continue
|
||||||
if (!note.content || note.content.trim().length === 0) continue
|
await this.upsertNoteEmbeddingFromNote(note)
|
||||||
try {
|
|
||||||
const embedding = await provider.getEmbeddings(note.content.slice(0, 15000))
|
|
||||||
if (embedding && embedding.length > 0) {
|
|
||||||
await upsertNoteEmbedding(note.id, embedding)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Skip this note, continue with others
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Provider not configured — nothing we can do
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,6 +195,7 @@ export class MemoryEchoService {
|
|||||||
id: true,
|
id: true,
|
||||||
title: true,
|
title: true,
|
||||||
content: true,
|
content: true,
|
||||||
|
sourceUrl: true,
|
||||||
noteEmbedding: true,
|
noteEmbedding: true,
|
||||||
createdAt: true
|
createdAt: true
|
||||||
},
|
},
|
||||||
@@ -151,10 +226,6 @@ export class MemoryEchoService {
|
|||||||
|
|
||||||
const connections: NoteConnection[] = []
|
const connections: NoteConnection[] = []
|
||||||
|
|
||||||
// Use demo mode parameters if enabled
|
|
||||||
const minDaysApart = demoMode ? this.MIN_DAYS_APART_DEMO : this.MIN_DAYS_APART
|
|
||||||
const similarityThreshold = demoMode ? this.SIMILARITY_THRESHOLD_DEMO : this.SIMILARITY_THRESHOLD
|
|
||||||
|
|
||||||
// Load user feedback to adjust thresholds per note
|
// Load user feedback to adjust thresholds per note
|
||||||
const feedbackInsights = await prisma.memoryEchoInsight.findMany({
|
const feedbackInsights = await prisma.memoryEchoInsight.findMany({
|
||||||
where: { userId, feedback: { not: null } },
|
where: { userId, feedback: { not: null } },
|
||||||
@@ -183,8 +254,8 @@ export class MemoryEchoService {
|
|||||||
Math.floor((note1.createdAt.getTime() - note2.createdAt.getTime()) / (1000 * 60 * 60 * 24))
|
Math.floor((note1.createdAt.getTime() - note2.createdAt.getTime()) / (1000 * 60 * 60 * 24))
|
||||||
)
|
)
|
||||||
|
|
||||||
// Time diversity filter: notes must be from different time periods
|
// Time diversity filter: notes must be from different time periods (sauf clips récents)
|
||||||
if (daysApart < minDaysApart) {
|
if (!this.passesTimeDiversityFilter(daysApart, note1, note2, demoMode)) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,7 +263,8 @@ export class MemoryEchoService {
|
|||||||
const similarity = cosineSimilarity(note1.embedding!, note2.embedding!)
|
const similarity = cosineSimilarity(note1.embedding!, note2.embedding!)
|
||||||
|
|
||||||
// Similarity threshold for meaningful connections (adjusted by feedback)
|
// Similarity threshold for meaningful connections (adjusted by feedback)
|
||||||
const adjustedThreshold = similarityThreshold
|
const baseThreshold = this.pairSimilarityThreshold(note1, note2, demoMode)
|
||||||
|
const adjustedThreshold = baseThreshold
|
||||||
+ (notePenalty.get(note1.id) || 0)
|
+ (notePenalty.get(note1.id) || 0)
|
||||||
+ (notePenalty.get(note2.id) || 0)
|
+ (notePenalty.get(note2.id) || 0)
|
||||||
if (similarity >= adjustedThreshold) {
|
if (similarity >= adjustedThreshold) {
|
||||||
@@ -200,13 +272,13 @@ export class MemoryEchoService {
|
|||||||
note1: {
|
note1: {
|
||||||
id: note1.id,
|
id: note1.id,
|
||||||
title: note1.title,
|
title: note1.title,
|
||||||
content: note1.content.substring(0, 200) + (note1.content.length > 200 ? '...' : ''),
|
content: this.connectionPlainText(note1.title, note1.content),
|
||||||
createdAt: note1.createdAt
|
createdAt: note1.createdAt
|
||||||
},
|
},
|
||||||
note2: {
|
note2: {
|
||||||
id: note2.id,
|
id: note2.id,
|
||||||
title: note2.title,
|
title: note2.title,
|
||||||
content: note2.content ? note2.content.substring(0, 200) + (note2.content.length > 200 ? '...' : '') : '',
|
content: this.connectionPlainText(note2.title, note2.content || ''),
|
||||||
createdAt: note2.createdAt
|
createdAt: note2.createdAt
|
||||||
},
|
},
|
||||||
similarityScore: similarity,
|
similarityScore: similarity,
|
||||||
@@ -239,30 +311,52 @@ export class MemoryEchoService {
|
|||||||
|
|
||||||
const note1Desc = note1Title || 'Untitled note'
|
const note1Desc = note1Title || 'Untitled note'
|
||||||
const note2Desc = note2Title || 'Untitled note'
|
const note2Desc = note2Title || 'Untitled note'
|
||||||
|
const excerpt1 = excerptPlainNoteContent(note1Title, note1Content, 1200)
|
||||||
|
const excerpt2 = excerptPlainNoteContent(note2Title, note2Content, 1200)
|
||||||
|
const directionSample = `${note1Desc}\n${excerpt1}\n${note2Desc}\n${excerpt2}`
|
||||||
|
const isRtl = detectTextDirection(directionSample) === 'rtl'
|
||||||
|
|
||||||
const prompt = `You are a helpful assistant analyzing connections between notes.
|
const prompt = isRtl
|
||||||
|
? `تو یک دستیار هستی که ارتباط بین یادداشتها را تحلیل میکنی.
|
||||||
|
|
||||||
|
یادداشت ۱: «${note1Desc}»
|
||||||
|
متن: ${excerpt1}
|
||||||
|
|
||||||
|
یادداشت ۲: «${note2Desc}»
|
||||||
|
متن: ${excerpt2}
|
||||||
|
|
||||||
|
در یک جمله کوتاه (حداکثر ۱۵ کلمه) به فارسی توضیح بده چرا این دو یادداشت به هم مرتبطاند. فقط رابطه معنایی را بگو.`
|
||||||
|
: `You are a helpful assistant analyzing connections between notes.
|
||||||
|
|
||||||
Note 1: "${note1Desc}"
|
Note 1: "${note1Desc}"
|
||||||
Content: ${note1Content.substring(0, 300)}
|
Content: ${excerpt1}
|
||||||
|
|
||||||
Note 2: "${note2Desc}"
|
Note 2: "${note2Desc}"
|
||||||
Content: ${note2Content.substring(0, 300)}
|
Content: ${excerpt2}
|
||||||
|
|
||||||
Explain in one brief sentence (max 15 words) why these notes are connected. Focus on the semantic relationship.`
|
Explain in one brief sentence (max 15 words) why these notes are connected. Focus on the semantic relationship.`
|
||||||
|
|
||||||
const response = await provider.generateText(prompt)
|
const response = await provider.generateText(prompt)
|
||||||
|
|
||||||
// Clean up response
|
|
||||||
const insight = response
|
const insight = response
|
||||||
.replace(/^["']|["']$/g, '') // Remove quotes
|
.replace(/^["'«»]|["'«»]$/g, '')
|
||||||
.replace(/^[^.]+\.\s*/, '') // Remove "Here is..." prefix
|
.replace(/^[^.]+\.\s*/, '')
|
||||||
.trim()
|
.trim()
|
||||||
.substring(0, 150) // Max length
|
.substring(0, 150)
|
||||||
|
|
||||||
return insight || 'These notes appear to be semantically related.'
|
const fallback = isRtl
|
||||||
|
? 'این یادداشتها از نظر معنایی به هم مرتبط به نظر میرسند.'
|
||||||
|
: 'These notes appear to be semantically related.'
|
||||||
|
|
||||||
|
return insight || fallback
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[MemoryEcho] Failed to generate insight:', error)
|
console.error('[MemoryEcho] Failed to generate insight:', error)
|
||||||
|
const sample = excerptPlainNoteContent(note1Title, note1Content, 200)
|
||||||
|
+ excerptPlainNoteContent(note2Title, note2Content, 200)
|
||||||
|
if (detectTextDirection(sample) === 'rtl') {
|
||||||
|
return 'این یادداشتها از نظر معنایی به هم مرتبط به نظر میرسند.'
|
||||||
|
}
|
||||||
return 'These notes appear to be semantically related.'
|
return 'These notes appear to be semantically related.'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -459,6 +553,7 @@ Explain in one brief sentence (max 15 words) why these notes are connected. Focu
|
|||||||
id: true,
|
id: true,
|
||||||
title: true,
|
title: true,
|
||||||
content: true,
|
content: true,
|
||||||
|
sourceUrl: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
userId: true
|
userId: true
|
||||||
}
|
}
|
||||||
@@ -475,8 +570,16 @@ Explain in one brief sentence (max 15 words) why these notes are connected. Focu
|
|||||||
)
|
)
|
||||||
const targetEmbeddingStr = embeddingResult[0]?.embedding
|
const targetEmbeddingStr = embeddingResult[0]?.embedding
|
||||||
|
|
||||||
if (!targetEmbeddingStr) {
|
let targetEmbedding = targetEmbeddingStr
|
||||||
return [] // Note has no embedding
|
? embeddingService.fromVectorString(targetEmbeddingStr)
|
||||||
|
: null
|
||||||
|
|
||||||
|
if (!targetEmbedding && targetNote.content?.trim()) {
|
||||||
|
targetEmbedding = await this.upsertNoteEmbeddingFromNote(targetNote)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!targetEmbedding) {
|
||||||
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get dismissed connections for this note (to filter them out)
|
// Get dismissed connections for this note (to filter them out)
|
||||||
@@ -514,6 +617,7 @@ Explain in one brief sentence (max 15 words) why these notes are connected. Focu
|
|||||||
id: true,
|
id: true,
|
||||||
title: true,
|
title: true,
|
||||||
content: true,
|
content: true,
|
||||||
|
sourceUrl: true,
|
||||||
createdAt: true
|
createdAt: true
|
||||||
},
|
},
|
||||||
orderBy: { createdAt: 'desc' }
|
orderBy: { createdAt: 'desc' }
|
||||||
@@ -523,11 +627,6 @@ Explain in one brief sentence (max 15 words) why these notes are connected. Focu
|
|||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetEmbedding = targetEmbeddingStr
|
|
||||||
? embeddingService.fromVectorString(targetEmbeddingStr)
|
|
||||||
: null
|
|
||||||
if (!targetEmbedding) return []
|
|
||||||
|
|
||||||
// Fetch all other embeddings
|
// Fetch all other embeddings
|
||||||
const otherNoteIds = otherNotes.map(n => n.id)
|
const otherNoteIds = otherNotes.map(n => n.id)
|
||||||
const otherEmbeddings = otherNoteIds.length === 0 ? [] : await prisma.$queryRaw<Array<{ noteId: string, embedding: any }>>(
|
const otherEmbeddings = otherNoteIds.length === 0 ? [] : await prisma.$queryRaw<Array<{ noteId: string, embedding: any }>>(
|
||||||
@@ -541,9 +640,6 @@ Explain in one brief sentence (max 15 words) why these notes are connected. Focu
|
|||||||
})
|
})
|
||||||
const demoMode = settings?.demoMode || false
|
const demoMode = settings?.demoMode || false
|
||||||
|
|
||||||
const minDaysApart = demoMode ? this.MIN_DAYS_APART_DEMO : this.MIN_DAYS_APART
|
|
||||||
const similarityThreshold = demoMode ? this.SIMILARITY_THRESHOLD_DEMO : this.SIMILARITY_THRESHOLD
|
|
||||||
|
|
||||||
// Load user feedback to adjust thresholds
|
// Load user feedback to adjust thresholds
|
||||||
const feedbackInsights = await prisma.memoryEchoInsight.findMany({
|
const feedbackInsights = await prisma.memoryEchoInsight.findMany({
|
||||||
where: { userId, feedback: { not: null } },
|
where: { userId, feedback: { not: null } },
|
||||||
@@ -565,9 +661,13 @@ Explain in one brief sentence (max 15 words) why these notes are connected. Focu
|
|||||||
// Compare target note with all other notes
|
// Compare target note with all other notes
|
||||||
for (const otherNote of otherNotes) {
|
for (const otherNote of otherNotes) {
|
||||||
const otherEmbeddingStr = otherEmbeddingMap.get(otherNote.id)
|
const otherEmbeddingStr = otherEmbeddingMap.get(otherNote.id)
|
||||||
if (!otherEmbeddingStr) continue
|
let otherEmbedding = otherEmbeddingStr
|
||||||
|
? embeddingService.fromVectorString(otherEmbeddingStr)
|
||||||
|
: null
|
||||||
|
|
||||||
const otherEmbedding = embeddingService.fromVectorString(otherEmbeddingStr)
|
if (!otherEmbedding && otherNote.content?.trim()) {
|
||||||
|
otherEmbedding = await this.upsertNoteEmbeddingFromNote(otherNote)
|
||||||
|
}
|
||||||
if (!otherEmbedding) continue
|
if (!otherEmbedding) continue
|
||||||
|
|
||||||
// Check if this connection was dismissed
|
// Check if this connection was dismissed
|
||||||
@@ -582,8 +682,8 @@ Explain in one brief sentence (max 15 words) why these notes are connected. Focu
|
|||||||
Math.floor((targetNote.createdAt.getTime() - otherNote.createdAt.getTime()) / (1000 * 60 * 60 * 24))
|
Math.floor((targetNote.createdAt.getTime() - otherNote.createdAt.getTime()) / (1000 * 60 * 60 * 24))
|
||||||
)
|
)
|
||||||
|
|
||||||
// Time diversity filter
|
// Time diversity filter (clips récents autorisés sans délai de 7 jours)
|
||||||
if (daysApart < minDaysApart) {
|
if (!this.passesTimeDiversityFilter(daysApart, targetNote, otherNote, demoMode)) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -591,7 +691,8 @@ Explain in one brief sentence (max 15 words) why these notes are connected. Focu
|
|||||||
const similarity = cosineSimilarity(targetEmbedding, otherEmbedding)
|
const similarity = cosineSimilarity(targetEmbedding, otherEmbedding)
|
||||||
|
|
||||||
// Similarity threshold (adjusted by feedback)
|
// Similarity threshold (adjusted by feedback)
|
||||||
const adjustedThreshold = similarityThreshold
|
const baseThreshold = this.pairSimilarityThreshold(targetNote, otherNote, demoMode)
|
||||||
|
const adjustedThreshold = baseThreshold
|
||||||
+ (notePenalty.get(targetNote.id) || 0)
|
+ (notePenalty.get(targetNote.id) || 0)
|
||||||
+ (notePenalty.get(otherNote.id) || 0)
|
+ (notePenalty.get(otherNote.id) || 0)
|
||||||
if (similarity >= adjustedThreshold) {
|
if (similarity >= adjustedThreshold) {
|
||||||
@@ -599,13 +700,13 @@ Explain in one brief sentence (max 15 words) why these notes are connected. Focu
|
|||||||
note1: {
|
note1: {
|
||||||
id: targetNote.id,
|
id: targetNote.id,
|
||||||
title: targetNote.title,
|
title: targetNote.title,
|
||||||
content: targetNote.content.substring(0, 200) + (targetNote.content.length > 200 ? '...' : ''),
|
content: this.connectionPlainText(targetNote.title, targetNote.content),
|
||||||
createdAt: targetNote.createdAt
|
createdAt: targetNote.createdAt
|
||||||
},
|
},
|
||||||
note2: {
|
note2: {
|
||||||
id: otherNote.id,
|
id: otherNote.id,
|
||||||
title: otherNote.title,
|
title: otherNote.title,
|
||||||
content: otherNote.content ? otherNote.content.substring(0, 200) + (otherNote.content.length > 200 ? '...' : '') : '',
|
content: this.connectionPlainText(otherNote.title, otherNote.content || ''),
|
||||||
createdAt: otherNote.createdAt
|
createdAt: otherNote.createdAt
|
||||||
},
|
},
|
||||||
similarityScore: similarity,
|
similarityScore: similarity,
|
||||||
|
|||||||
@@ -333,26 +333,27 @@ export class SemanticSearchService {
|
|||||||
* SECURITY: Uses parameterized bind params ($1, $2).
|
* SECURITY: Uses parameterized bind params ($1, $2).
|
||||||
* noteId validated via assertSafeId().
|
* noteId validated via assertSafeId().
|
||||||
*/
|
*/
|
||||||
async indexNote(noteId: string): Promise<void> {
|
async indexNote(noteId: string, options?: { force?: boolean }): Promise<void> {
|
||||||
try {
|
try {
|
||||||
assertSafeId(noteId, 'noteId')
|
assertSafeId(noteId, 'noteId')
|
||||||
|
|
||||||
const note = await prisma.note.findUnique({
|
const note = await prisma.note.findUnique({
|
||||||
where: { id: noteId },
|
where: { id: noteId },
|
||||||
select: { content: true, lastAiAnalysis: true }
|
select: { content: true, title: true, lastAiAnalysis: true, sourceUrl: true }
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!note) throw new Error('Note not found')
|
if (!note?.content?.trim()) return
|
||||||
|
|
||||||
const shouldRegenerate = embeddingService.shouldRegenerateEmbedding(
|
const shouldRegenerate = embeddingService.shouldRegenerateEmbedding(
|
||||||
note.content,
|
note.content,
|
||||||
null,
|
null,
|
||||||
note.lastAiAnalysis
|
note.lastAiAnalysis,
|
||||||
|
{ force: options?.force, isClip: Boolean(note.sourceUrl?.trim()) },
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!shouldRegenerate) return
|
if (!shouldRegenerate) return
|
||||||
|
|
||||||
const { embedding } = await embeddingService.generateEmbedding(note.content)
|
const { embedding } = await embeddingService.generateNoteEmbedding(note.title, note.content)
|
||||||
const vecStr = embeddingService.toVectorString(embedding)
|
const vecStr = embeddingService.toVectorString(embedding)
|
||||||
|
|
||||||
await prisma.$queryRawUnsafe(
|
await prisma.$queryRawUnsafe(
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { stripHtmlToPlainText, tokenizeForSimilarity } from '@/lib/text/plain-text'
|
||||||
|
|
||||||
export interface ExtractedBlock {
|
export interface ExtractedBlock {
|
||||||
blockId: string
|
blockId: string
|
||||||
content: string
|
content: string
|
||||||
@@ -9,7 +11,7 @@ export function extractBlocksFromHtml(html: string): ExtractedBlock[] {
|
|||||||
let match
|
let match
|
||||||
while ((match = regex.exec(html)) !== null) {
|
while ((match = regex.exec(html)) !== null) {
|
||||||
const blockId = match[1]
|
const blockId = match[1]
|
||||||
const content = match[2].replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
|
const content = stripHtmlToPlainText(match[2])
|
||||||
if (content.length >= 10) {
|
if (content.length >= 10) {
|
||||||
blocks.push({ blockId, content })
|
blocks.push({ blockId, content })
|
||||||
}
|
}
|
||||||
@@ -18,16 +20,8 @@ export function extractBlocksFromHtml(html: string): ExtractedBlock[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function jaccardSimilarity(a: string, b: string): number {
|
export function jaccardSimilarity(a: string, b: string): number {
|
||||||
const tokenize = (s: string) =>
|
const A = tokenizeForSimilarity(a)
|
||||||
new Set(
|
const B = tokenizeForSimilarity(b)
|
||||||
s
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[^\w\s]/g, '')
|
|
||||||
.split(/\s+/)
|
|
||||||
.filter(w => w.length > 3)
|
|
||||||
)
|
|
||||||
const A = tokenize(a)
|
|
||||||
const B = tokenize(b)
|
|
||||||
if (A.size === 0 || B.size === 0) return 0
|
if (A.size === 0 || B.size === 0) return 0
|
||||||
let intersection = 0
|
let intersection = 0
|
||||||
A.forEach(w => { if (B.has(w)) intersection++ })
|
A.forEach(w => { if (B.has(w)) intersection++ })
|
||||||
@@ -39,7 +33,7 @@ function extractPlainBlocksFromHtml(html: string): ExtractedBlock[] {
|
|||||||
const regex = /<(?:p|h[1-6]|blockquote|li|td|th|div)[^>]*>([\s\S]*?)<\/(?:p|h[1-6]|blockquote|li|td|th|div)>/gi
|
const regex = /<(?:p|h[1-6]|blockquote|li|td|th|div)[^>]*>([\s\S]*?)<\/(?:p|h[1-6]|blockquote|li|td|th|div)>/gi
|
||||||
let match
|
let match
|
||||||
while ((match = regex.exec(html)) !== null) {
|
while ((match = regex.exec(html)) !== null) {
|
||||||
const content = match[1].replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
|
const content = stripHtmlToPlainText(match[1])
|
||||||
if (content.length >= 10) {
|
if (content.length >= 10) {
|
||||||
blocks.push({ blockId: '', content })
|
blocks.push({ blockId: '', content })
|
||||||
}
|
}
|
||||||
|
|||||||
86
memento-note/lib/clip/analyze-clip.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { getSystemConfig } from '@/lib/config'
|
||||||
|
import { getChatProvider } from '@/lib/ai/factory'
|
||||||
|
|
||||||
|
export interface ClipAnalysis {
|
||||||
|
title: string
|
||||||
|
summary: string
|
||||||
|
tags: string[]
|
||||||
|
readingTimeMinutes: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAnalysisJson(raw: string): ClipAnalysis | null {
|
||||||
|
const trimmed = raw.trim()
|
||||||
|
const jsonMatch = trimmed.match(/\{[\s\S]*\}/)
|
||||||
|
if (!jsonMatch) return null
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(jsonMatch[0]) as Partial<ClipAnalysis>
|
||||||
|
const tags = Array.isArray(parsed.tags)
|
||||||
|
? parsed.tags.filter((t): t is string => typeof t === 'string').slice(0, 5)
|
||||||
|
: []
|
||||||
|
const readingTime = typeof parsed.readingTimeMinutes === 'number'
|
||||||
|
? Math.max(1, Math.min(120, Math.round(parsed.readingTimeMinutes)))
|
||||||
|
: 5
|
||||||
|
return {
|
||||||
|
title: typeof parsed.title === 'string' && parsed.title.trim() ? parsed.title.trim().slice(0, 200) : 'Web clip',
|
||||||
|
summary: typeof parsed.summary === 'string' ? parsed.summary.trim().slice(0, 800) : '',
|
||||||
|
tags,
|
||||||
|
readingTimeMinutes: readingTime,
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function estimateReadingMinutes(text: string): number {
|
||||||
|
const words = text.split(/\s+/).filter(Boolean).length
|
||||||
|
return Math.max(1, Math.round(words / 200))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function analyzeClipContent(params: {
|
||||||
|
url: string
|
||||||
|
title: string
|
||||||
|
textContent: string
|
||||||
|
}): Promise<ClipAnalysis> {
|
||||||
|
const excerpt = params.textContent.slice(0, 6000)
|
||||||
|
const fallbackReading = estimateReadingMinutes(params.textContent)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const config = await getSystemConfig()
|
||||||
|
const provider = getChatProvider(config)
|
||||||
|
const prompt = `You analyze web articles for a personal knowledge base. URL: ${params.url}
|
||||||
|
Page title: ${params.title}
|
||||||
|
|
||||||
|
Content excerpt:
|
||||||
|
${excerpt}
|
||||||
|
|
||||||
|
Respond with ONLY valid JSON (no markdown):
|
||||||
|
{
|
||||||
|
"title": "concise improved title",
|
||||||
|
"summary": "max 3 sentences in the same language as the content",
|
||||||
|
"tags": ["tag1", "tag2"],
|
||||||
|
"readingTimeMinutes": ${fallbackReading}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rules: tags max 5, short lowercase labels, summary factual.`
|
||||||
|
|
||||||
|
const raw = await provider.generateText(prompt)
|
||||||
|
const parsed = parseAnalysisJson(raw)
|
||||||
|
if (parsed) {
|
||||||
|
if (!parsed.title) parsed.title = params.title || 'Web clip'
|
||||||
|
if (!parsed.summary && params.textContent) {
|
||||||
|
parsed.summary = params.textContent.slice(0, 400)
|
||||||
|
}
|
||||||
|
if (parsed.tags.length === 0) parsed.tags = []
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[ClipAnalyze] AI failed:', error)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: params.title || 'Web clip',
|
||||||
|
summary: params.textContent.slice(0, 400),
|
||||||
|
tags: [],
|
||||||
|
readingTimeMinutes: fallbackReading,
|
||||||
|
}
|
||||||
|
}
|
||||||
78
memento-note/lib/clip/extract-article.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { Readability } from '@mozilla/readability'
|
||||||
|
import { JSDOM } from 'jsdom'
|
||||||
|
import DOMPurify from 'isomorphic-dompurify'
|
||||||
|
import {
|
||||||
|
applyRtlToHtmlBlocks,
|
||||||
|
readPageLocaleFromHtml,
|
||||||
|
resolveClipLocale,
|
||||||
|
wrapClipArticleHtml,
|
||||||
|
type ClipLocaleHint,
|
||||||
|
} from '@/lib/clip/rtl-content'
|
||||||
|
|
||||||
|
export interface ExtractedArticle {
|
||||||
|
title: string
|
||||||
|
content: string
|
||||||
|
textContent: string
|
||||||
|
excerpt: string
|
||||||
|
locale: ClipLocaleHint
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractArticleFromHtml(html: string, pageUrl: string): ExtractedArticle | null {
|
||||||
|
const dom = new JSDOM(html, { url: pageUrl })
|
||||||
|
const reader = new Readability(dom.window.document)
|
||||||
|
const article = reader.parse()
|
||||||
|
if (!article) return null
|
||||||
|
|
||||||
|
const pageLocale = readPageLocaleFromHtml(html)
|
||||||
|
const readabilityDir = article.dir?.toLowerCase() === 'rtl' ? 'rtl' : 'ltr'
|
||||||
|
const readabilityLang = article.lang?.split('-')[0]?.toLowerCase()
|
||||||
|
const locale = resolveClipLocale(
|
||||||
|
pageUrl,
|
||||||
|
article.title || '',
|
||||||
|
article.textContent || '',
|
||||||
|
)
|
||||||
|
const mergedLocale: ClipLocaleHint = {
|
||||||
|
direction:
|
||||||
|
readabilityDir === 'rtl' || pageLocale.direction === 'rtl' || locale.direction === 'rtl'
|
||||||
|
? 'rtl'
|
||||||
|
: 'ltr',
|
||||||
|
lang:
|
||||||
|
(readabilityLang === 'fa' || readabilityLang === 'ar' || readabilityLang === 'he'
|
||||||
|
? readabilityLang
|
||||||
|
: undefined) ||
|
||||||
|
locale.lang ||
|
||||||
|
pageLocale.lang,
|
||||||
|
}
|
||||||
|
|
||||||
|
const sanitized = DOMPurify.sanitize(article.content || '')
|
||||||
|
const rtlBlocks = applyRtlToHtmlBlocks(sanitized, mergedLocale)
|
||||||
|
const content = wrapClipArticleHtml(rtlBlocks, mergedLocale)
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: (article.title || '').trim(),
|
||||||
|
content,
|
||||||
|
textContent: (article.textContent || '').trim(),
|
||||||
|
excerpt: (article.excerpt || '').trim(),
|
||||||
|
locale: mergedLocale,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clipFooterLocaleTag(lang?: string): string {
|
||||||
|
if (lang === 'fa') return 'fa-IR'
|
||||||
|
if (lang === 'ar') return 'ar'
|
||||||
|
if (lang === 'he') return 'he-IL'
|
||||||
|
return 'fr-FR'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildClipSourceFooter(domain: string, date: Date, localeTag = 'fr-FR'): string {
|
||||||
|
const formatted = date.toLocaleDateString(localeTag, { day: 'numeric', month: 'long', year: 'numeric' })
|
||||||
|
const isRtl = localeTag.startsWith('fa') || localeTag.startsWith('ar') || localeTag.startsWith('he')
|
||||||
|
const label =
|
||||||
|
localeTag.startsWith('fa')
|
||||||
|
? `برگرفته از ${domain} — ${formatted}`
|
||||||
|
: localeTag.startsWith('ar')
|
||||||
|
? `مقتبس من ${domain} — ${formatted}`
|
||||||
|
: `Extrait de ${domain} le ${formatted}`
|
||||||
|
const dirAttr = isRtl ? ' dir="rtl"' : ''
|
||||||
|
return `<hr/><p${dirAttr}><small>${DOMPurify.sanitize(label)}</small></p>`
|
||||||
|
}
|
||||||
112
memento-note/lib/clip/rtl-content.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
/** Détection RTL et enveloppe HTML pour contenus clippés (persan, arabe, hébreu). */
|
||||||
|
|
||||||
|
const RTL_CHAR = /[\u0590-\u05FF\u0600-\u06FF\u0700-\u074F\u0750-\u077F\u08A0-\u08FF\uFB50-\uFDFF\uFE70-\uFEFF]/
|
||||||
|
const LTR_CHAR = /[A-Za-z0-9]/
|
||||||
|
|
||||||
|
export type ClipTextDirection = 'rtl' | 'ltr'
|
||||||
|
|
||||||
|
export interface ClipLocaleHint {
|
||||||
|
direction: ClipTextDirection
|
||||||
|
lang?: 'fa' | 'ar' | 'he'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function inferLangFromUrl(url: string): ClipLocaleHint['lang'] | undefined {
|
||||||
|
const lower = url.toLowerCase()
|
||||||
|
if (/\/persian\b|\/fa\b|lang=fa|bbc\.com\/persian/.test(lower)) return 'fa'
|
||||||
|
if (/\/arabic\b|\/ar\b|lang=ar/.test(lower)) return 'ar'
|
||||||
|
if (/\/hebrew\b|\/he\b|lang=he/.test(lower)) return 'he'
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function detectTextDirection(text: string): ClipTextDirection {
|
||||||
|
const sample = text.replace(/\s+/g, '').slice(0, 4000)
|
||||||
|
if (!sample) return 'ltr'
|
||||||
|
|
||||||
|
let rtl = 0
|
||||||
|
let ltr = 0
|
||||||
|
for (const ch of sample) {
|
||||||
|
if (RTL_CHAR.test(ch)) rtl++
|
||||||
|
else if (LTR_CHAR.test(ch)) ltr++
|
||||||
|
}
|
||||||
|
if (rtl === 0) return 'ltr'
|
||||||
|
return rtl >= ltr ? 'rtl' : 'ltr'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Direction du titre de note (éviter dir="auto" qui casse les chiffres persans). */
|
||||||
|
export function resolveTitleDirection(title: string, sourceUrl?: string | null): ClipTextDirection {
|
||||||
|
if (sourceUrl && inferLangFromUrl(sourceUrl)) return 'rtl'
|
||||||
|
return detectTextDirection(title)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveTitleLang(
|
||||||
|
title: string,
|
||||||
|
sourceUrl?: string | null,
|
||||||
|
): ClipLocaleHint['lang'] | undefined {
|
||||||
|
const urlLang = sourceUrl ? inferLangFromUrl(sourceUrl) : undefined
|
||||||
|
if (urlLang) return urlLang
|
||||||
|
if (detectTextDirection(title) !== 'rtl') return undefined
|
||||||
|
return resolveClipLocale(sourceUrl || '', title).lang
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveClipLocale(url: string, ...texts: string[]): ClipLocaleHint {
|
||||||
|
const combined = texts.filter(Boolean).join('\n')
|
||||||
|
const direction = detectTextDirection(combined)
|
||||||
|
const urlLang = inferLangFromUrl(url)
|
||||||
|
let lang = urlLang
|
||||||
|
|
||||||
|
if (!lang && direction === 'rtl') {
|
||||||
|
if (/[\u06AF\u06CC\u06A9\u067E\u0686\u0698\u200C]/.test(combined)) lang = 'fa'
|
||||||
|
else if (/[\u0590-\u05FF]/.test(combined)) lang = 'he'
|
||||||
|
else lang = 'ar'
|
||||||
|
}
|
||||||
|
|
||||||
|
return { direction, lang }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Applique dir/lang sur les blocs HTML extraits (Readability ne les conserve pas toujours). */
|
||||||
|
export function applyRtlToHtmlBlocks(html: string, hint: ClipLocaleHint): string {
|
||||||
|
if (hint.direction !== 'rtl') return html
|
||||||
|
|
||||||
|
const langAttr = hint.lang ? ` lang="${hint.lang}"` : ''
|
||||||
|
const blockTags = ['p', 'h1', 'h2', 'h3', 'h4', 'li', 'ul', 'ol', 'blockquote', 'figcaption']
|
||||||
|
|
||||||
|
let out = html
|
||||||
|
for (const tag of blockTags) {
|
||||||
|
out = out.replace(new RegExp(`<${tag}(\\s[^>]*)?>`, 'gi'), (match, attrs = '') => {
|
||||||
|
if (/dir\s*=/.test(attrs)) return match
|
||||||
|
return `<${tag}${attrs} dir="rtl"${langAttr}>`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wrapClipArticleHtml(innerHtml: string, hint: ClipLocaleHint): string {
|
||||||
|
if (hint.direction !== 'rtl') return innerHtml
|
||||||
|
const langAttr = hint.lang ? ` lang="${hint.lang}"` : ''
|
||||||
|
return `<div class="clip-article clip-article--rtl" dir="rtl"${langAttr}>${innerHtml}</div>`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wrapClipPlainParagraph(text: string, hint: ClipLocaleHint): string {
|
||||||
|
const escaped = text
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
const langAttr = hint.lang ? ` lang="${hint.lang}"` : ''
|
||||||
|
const dirAttr = hint.direction === 'rtl' ? ' dir="rtl"' : ''
|
||||||
|
return `<p${dirAttr}${langAttr}>${escaped}</p>`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readPageLocaleFromHtml(html: string): Pick<ClipLocaleHint, 'direction' | 'lang'> {
|
||||||
|
const dirMatch =
|
||||||
|
html.match(/<html[^>]*\sdir=["'](rtl|ltr)["']/i) ||
|
||||||
|
html.match(/<body[^>]*\sdir=["'](rtl|ltr)["']/i)
|
||||||
|
const langMatch =
|
||||||
|
html.match(/<html[^>]*\slang=["']([^"']+)["']/i) ||
|
||||||
|
html.match(/<body[^>]*\slang=["']([^"']+)["']/i)
|
||||||
|
const direction: ClipTextDirection = dirMatch?.[1]?.toLowerCase() === 'rtl' ? 'rtl' : 'ltr'
|
||||||
|
const rawLang = langMatch?.[1]?.split('-')[0]?.toLowerCase()
|
||||||
|
const lang =
|
||||||
|
rawLang === 'fa' || rawLang === 'ar' || rawLang === 'he' ? (rawLang as ClipLocaleHint['lang']) : undefined
|
||||||
|
return { direction, lang }
|
||||||
|
}
|
||||||
72
memento-note/lib/editor/apply-clip-rtl-direction.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import type { Editor } from '@tiptap/core'
|
||||||
|
import { detectTextDirection, inferLangFromUrl } from '@/lib/clip/rtl-content'
|
||||||
|
|
||||||
|
const RTL_BLOCK_TYPES = new Set([
|
||||||
|
'clipArticle',
|
||||||
|
'bulletList',
|
||||||
|
'orderedList',
|
||||||
|
'listItem',
|
||||||
|
'heading',
|
||||||
|
'paragraph',
|
||||||
|
'blockquote',
|
||||||
|
])
|
||||||
|
|
||||||
|
function nodeShouldBeRtl(
|
||||||
|
node: { type: { name: string }; textContent: string; attrs: { dir?: string | null } },
|
||||||
|
urlIsRtl: boolean,
|
||||||
|
): boolean {
|
||||||
|
if (node.attrs.dir === 'rtl') return false
|
||||||
|
if (urlIsRtl) return true
|
||||||
|
const text = node.textContent || ''
|
||||||
|
if (!text.trim()) return false
|
||||||
|
return detectTextDirection(text) === 'rtl'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applique dir="rtl" explicitement sur les nœuds TipTap (titres, listes, paragraphes).
|
||||||
|
* Basé sur la doc TipTap setTextDirection — évite textDirection:'auto' (bug listes #7338).
|
||||||
|
* @see https://tiptap.dev/docs/editor/api/commands/nodes-and-marks/set-text-direction
|
||||||
|
* @see https://github.com/ueberdosis/tiptap/issues/7338
|
||||||
|
*/
|
||||||
|
export function applyClipRtlDirection(
|
||||||
|
editor: Editor,
|
||||||
|
options?: { sourceUrl?: string | null },
|
||||||
|
): boolean {
|
||||||
|
if (!editor || editor.isDestroyed) return false
|
||||||
|
|
||||||
|
const urlIsRtl = Boolean(options?.sourceUrl && inferLangFromUrl(options.sourceUrl))
|
||||||
|
const ranges: Array<{ from: number; to: number }> = []
|
||||||
|
|
||||||
|
editor.state.doc.descendants((node, pos) => {
|
||||||
|
if (node.type.name === 'clipArticle') {
|
||||||
|
ranges.push({ from: pos, to: pos + node.nodeSize })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (ranges.length === 0) {
|
||||||
|
editor.state.doc.descendants((node, pos) => {
|
||||||
|
if (node.isText || !RTL_BLOCK_TYPES.has(node.type.name)) return
|
||||||
|
if (!nodeShouldBeRtl(node, urlIsRtl)) return
|
||||||
|
ranges.push({ from: pos, to: pos + node.nodeSize })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ranges.length === 0) return false
|
||||||
|
|
||||||
|
return editor
|
||||||
|
.chain()
|
||||||
|
.command(({ tr, state, dispatch }) => {
|
||||||
|
let changed = false
|
||||||
|
for (const { from, to } of ranges) {
|
||||||
|
state.doc.nodesBetween(from, to, (node, pos) => {
|
||||||
|
if (node.isText || node.attrs.dir === 'rtl') return
|
||||||
|
if (!RTL_BLOCK_TYPES.has(node.type.name)) return
|
||||||
|
tr.setNodeMarkup(pos, undefined, { ...node.attrs, dir: 'rtl' })
|
||||||
|
changed = true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (dispatch && changed) dispatch(tr)
|
||||||
|
return changed
|
||||||
|
})
|
||||||
|
.run()
|
||||||
|
}
|
||||||
@@ -8,10 +8,10 @@ import prisma from '@/lib/prisma'
|
|||||||
export async function upsertNoteEmbedding(noteId: string, embedding: number[]): Promise<void> {
|
export async function upsertNoteEmbedding(noteId: string, embedding: number[]): Promise<void> {
|
||||||
const vecStr = `[${embedding.join(',')}]`
|
const vecStr = `[${embedding.join(',')}]`
|
||||||
await prisma.$executeRawUnsafe(
|
await prisma.$executeRawUnsafe(
|
||||||
`INSERT INTO "NoteEmbedding" ("id", "noteId", "embedding", "createdAt", "updatedAt")
|
`INSERT INTO "NoteEmbedding" ("id", "noteId", "embedding", "createdAt")
|
||||||
VALUES (gen_random_uuid(), $1, $2::vector, now(), now())
|
VALUES (gen_random_uuid(), $1, $2::vector, now())
|
||||||
ON CONFLICT ("noteId")
|
ON CONFLICT ("noteId")
|
||||||
DO UPDATE SET "embedding" = EXCLUDED."embedding", "updatedAt" = now()`,
|
DO UPDATE SET "embedding" = EXCLUDED."embedding"`,
|
||||||
noteId,
|
noteId,
|
||||||
vecStr
|
vecStr
|
||||||
)
|
)
|
||||||
|
|||||||
109
memento-note/lib/text/plain-text.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
/** Texte brut pour embeddings et similarité (HTML clippé, persan, arabe, etc.). */
|
||||||
|
|
||||||
|
/** Taille d'un chunk API embedding (~2500 tokens, safe pour le persan). */
|
||||||
|
export const EMBEDDING_CHUNK_CHARS = 6000
|
||||||
|
export const EMBEDDING_CHUNK_OVERLAP = 300
|
||||||
|
|
||||||
|
/** @deprecated Utiliser le découpage multi-chunks — conservé pour compat. */
|
||||||
|
export const MAX_EMBEDDING_CHARS = EMBEDDING_CHUNK_CHARS
|
||||||
|
|
||||||
|
const CLIP_FOOTER_PATTERN =
|
||||||
|
/<hr\s*\/?>\s*<p[^>]*>\s*<small>[\s\S]*?<\/small>\s*<\/p>\s*$/i
|
||||||
|
|
||||||
|
export function stripHtmlToPlainText(html: string): string {
|
||||||
|
if (!html) return ''
|
||||||
|
return html
|
||||||
|
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
|
||||||
|
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
|
||||||
|
.replace(/<[^>]+>/g, ' ')
|
||||||
|
.replace(/ /gi, ' ')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/&#x([0-9a-f]+);/gi, (_, hex) => String.fromCharCode(parseInt(hex, 16)))
|
||||||
|
.replace(/&#(\d+);/g, (_, code) => String.fromCharCode(Number(code)))
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Retire le footer « Extrait de… » des notes clippées (bruit LTR pour embeddings). */
|
||||||
|
export function stripClipFooterFromHtml(html: string): string {
|
||||||
|
if (!html) return ''
|
||||||
|
return html.replace(CLIP_FOOTER_PATTERN, '').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function looksLikeHtml(text: string): boolean {
|
||||||
|
return /<[a-z][\s\S]*>/i.test(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Titre + corps entier en plain text — aucune troncature (les longs articles passent en multi-chunks). */
|
||||||
|
export function prepareNoteTextForEmbedding(
|
||||||
|
title: string | null | undefined,
|
||||||
|
content: string,
|
||||||
|
): string {
|
||||||
|
const withoutFooter = stripClipFooterFromHtml(content || '')
|
||||||
|
const body = looksLikeHtml(withoutFooter)
|
||||||
|
? stripHtmlToPlainText(withoutFooter)
|
||||||
|
: withoutFooter.trim()
|
||||||
|
const parts = [title?.trim(), body].filter(Boolean) as string[]
|
||||||
|
return parts.join('\n\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Découpe un long article en chunks chevauchants pour embedding complet. */
|
||||||
|
export function splitPlainTextForEmbeddingChunks(text: string): string[] {
|
||||||
|
const normalized = text.trim()
|
||||||
|
if (!normalized) return []
|
||||||
|
if (normalized.length <= EMBEDDING_CHUNK_CHARS) return [normalized]
|
||||||
|
|
||||||
|
const chunks: string[] = []
|
||||||
|
let start = 0
|
||||||
|
while (start < normalized.length) {
|
||||||
|
const end = Math.min(start + EMBEDDING_CHUNK_CHARS, normalized.length)
|
||||||
|
chunks.push(normalized.slice(start, end))
|
||||||
|
if (end >= normalized.length) break
|
||||||
|
start = Math.max(start + 1, end - EMBEDDING_CHUNK_OVERLAP)
|
||||||
|
}
|
||||||
|
return chunks
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Moyenne + normalisation L2 de plusieurs vecteurs (standard pour longs documents). */
|
||||||
|
export function meanPoolEmbeddingVectors(vectors: number[][]): number[] {
|
||||||
|
if (vectors.length === 0) return []
|
||||||
|
if (vectors.length === 1) return vectors[0]
|
||||||
|
|
||||||
|
const dim = vectors[0].length
|
||||||
|
const sums = new Array(dim).fill(0)
|
||||||
|
for (const vec of vectors) {
|
||||||
|
for (let i = 0; i < dim; i++) sums[i] += vec[i]
|
||||||
|
}
|
||||||
|
const mean = sums.map((s) => s / vectors.length)
|
||||||
|
let norm = 0
|
||||||
|
for (const x of mean) norm += x * x
|
||||||
|
norm = Math.sqrt(norm)
|
||||||
|
if (norm === 0) return mean
|
||||||
|
return mean.map((x) => x / norm)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Contenu prêt pour text-embedding (corps seul, rétrocompat). */
|
||||||
|
export function prepareTextForEmbedding(content: string): string {
|
||||||
|
return prepareNoteTextForEmbedding(null, content)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Aperçu UI court — n'affecte PAS la similarité sémantique. */
|
||||||
|
export function excerptPlainNoteContent(
|
||||||
|
title: string | null | undefined,
|
||||||
|
content: string,
|
||||||
|
maxLen = 280,
|
||||||
|
): string {
|
||||||
|
const plain = prepareNoteTextForEmbedding(title, content)
|
||||||
|
if (!plain) return ''
|
||||||
|
if (plain.length <= maxLen) return plain
|
||||||
|
return `${plain.slice(0, maxLen).trim()}…`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tokens pour Jaccard — toutes écritures Unicode (persan, arabe, latin…). */
|
||||||
|
export function tokenizeForSimilarity(text: string, minLength = 2): Set<string> {
|
||||||
|
const normalized = text.toLowerCase().normalize('NFKC')
|
||||||
|
const words = normalized.match(/[\p{L}\p{N}]{2,}/gu) ?? []
|
||||||
|
return new Set(words.filter((w) => w.length >= minLength))
|
||||||
|
}
|
||||||
@@ -83,6 +83,7 @@ export interface Note {
|
|||||||
autoGenerated?: boolean | null;
|
autoGenerated?: boolean | null;
|
||||||
aiProvider?: string | null;
|
aiProvider?: string | null;
|
||||||
historyEnabled?: boolean;
|
historyEnabled?: boolean;
|
||||||
|
sourceUrl?: string | null;
|
||||||
matchType?: 'exact' | 'related' | null;
|
matchType?: 'exact' | 'related' | null;
|
||||||
searchScore?: number | null;
|
searchScore?: number | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,7 +85,8 @@
|
|||||||
"richtext": "Rich Text",
|
"richtext": "Rich Text",
|
||||||
"markdown": "Markdown",
|
"markdown": "Markdown",
|
||||||
"text": "Plain text",
|
"text": "Plain text",
|
||||||
"checklist": "Checklist"
|
"checklist": "Checklist",
|
||||||
|
"clip": "Web clip"
|
||||||
},
|
},
|
||||||
"listItem": "List item",
|
"listItem": "List item",
|
||||||
"addListItem": "+ List item",
|
"addListItem": "+ List item",
|
||||||
@@ -861,9 +862,47 @@
|
|||||||
"brainstormInvite": "Brainstorm",
|
"brainstormInvite": "Brainstorm",
|
||||||
"brainstormJoined": "Brainstorm",
|
"brainstormJoined": "Brainstorm",
|
||||||
"systemNotification": "System",
|
"systemNotification": "System",
|
||||||
|
"clipSaved": "Web clip saved",
|
||||||
"downloadFailed": "Download failed",
|
"downloadFailed": "Download failed",
|
||||||
"brainstormShared": "Brainstorm shared"
|
"brainstormShared": "Brainstorm shared"
|
||||||
},
|
},
|
||||||
|
"clipper": {
|
||||||
|
"webClipper": "Web Clipper",
|
||||||
|
"connected": "Connected",
|
||||||
|
"destinationNotebook": "Destination notebook",
|
||||||
|
"selectNotebook": "Select a notebook",
|
||||||
|
"activePage": "Active page",
|
||||||
|
"selectionDetected": "Selection detected",
|
||||||
|
"ignore": "ignore",
|
||||||
|
"selectionHint": "Tip: highlight text on the page to clip a precise selection as a note.",
|
||||||
|
"clipSelection": "Clip selection",
|
||||||
|
"clipPage": "Clip this page",
|
||||||
|
"analyzingSource": "Analyzing source",
|
||||||
|
"processing": "Processing…",
|
||||||
|
"processingDetail": "Generating tags, semantic summary and embeddings.",
|
||||||
|
"successBadge": "Success",
|
||||||
|
"sentToNotebook": "Note saved to notebook",
|
||||||
|
"viewInMomento": "View in Momento",
|
||||||
|
"clipAnother": "Clip another page",
|
||||||
|
"captureFailed": "Capture failed",
|
||||||
|
"genericError": "Something went wrong while sending to your instance.",
|
||||||
|
"retry": "Retry",
|
||||||
|
"footer": "Momento Companion v2.1.2 · Secured HTTPS TLS 1.3",
|
||||||
|
"quitSimulator": "Close simulator",
|
||||||
|
"simulatorBadge": "Capture simulator",
|
||||||
|
"extensionActive": "Extension active on this page",
|
||||||
|
"publishedOn": "Published on {domain}",
|
||||||
|
"realtimeCapture": "Date: live capture",
|
||||||
|
"selectTextHint": "Select text below to clip",
|
||||||
|
"evalTipTitle": "Try it:",
|
||||||
|
"evalTipBody": "Highlight any text in the article below to activate the Selection state in the extension. You can also click a paragraph to simulate it.",
|
||||||
|
"clickParagraph": "Click to select this paragraph",
|
||||||
|
"selectionSaved": "Selection saved ({count} words)",
|
||||||
|
"clearSelection": "Clear selection",
|
||||||
|
"toastTitle": "Clipped note —",
|
||||||
|
"view": "View",
|
||||||
|
"simulatedClipper": "Clipper simulator"
|
||||||
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
"home": "Home",
|
"home": "Home",
|
||||||
"notes": "Notes",
|
"notes": "Notes",
|
||||||
@@ -880,6 +919,9 @@
|
|||||||
"trash": "Trash",
|
"trash": "Trash",
|
||||||
"support": "Support Memento ☕",
|
"support": "Support Memento ☕",
|
||||||
"reminders": "Reminders",
|
"reminders": "Reminders",
|
||||||
|
"graphView": "Link map",
|
||||||
|
"insights": "Semantic themes",
|
||||||
|
"revision": "Review",
|
||||||
"userManagement": "User Management",
|
"userManagement": "User Management",
|
||||||
"accountSettings": "Account Settings",
|
"accountSettings": "Account Settings",
|
||||||
"manageAISettings": "Manage AI Settings",
|
"manageAISettings": "Manage AI Settings",
|
||||||
@@ -1745,6 +1787,8 @@
|
|||||||
"imagesLabel": "Images",
|
"imagesLabel": "Images",
|
||||||
"notebookLabel": "Notebook",
|
"notebookLabel": "Notebook",
|
||||||
"typeLabel": "Type",
|
"typeLabel": "Type",
|
||||||
|
"sourceWebLabel": "Web source",
|
||||||
|
"openSource": "Open original page",
|
||||||
"createdLabel": "Created",
|
"createdLabel": "Created",
|
||||||
"modifiedLabel": "Updated",
|
"modifiedLabel": "Updated",
|
||||||
"labelsSection": "Labels",
|
"labelsSection": "Labels",
|
||||||
@@ -2847,11 +2891,23 @@
|
|||||||
"title": "Graph View",
|
"title": "Graph View",
|
||||||
"notesCount": "{count} notes",
|
"notesCount": "{count} notes",
|
||||||
"connectionsCount": "{count} connections",
|
"connectionsCount": "{count} connections",
|
||||||
|
"visibleConnections": "{count} visible",
|
||||||
"globalView": "Fit to View",
|
"globalView": "Fit to View",
|
||||||
"searchPlaceholder": "Filter...",
|
"searchPlaceholder": "Filter...",
|
||||||
"noNotesFound": "No notes found",
|
"noNotesFound": "No notes found",
|
||||||
"notebooks": "Notebooks",
|
"notebooks": "Notebooks",
|
||||||
"resetFilter": "Reset",
|
"resetFilter": "Reset",
|
||||||
|
"resetFocus": "Full graph",
|
||||||
|
"exploreFromNode": "Explore from this note",
|
||||||
|
"linkFilters": "Link filters",
|
||||||
|
"semanticThreshold": "Semantic threshold",
|
||||||
|
"edgeTypes": {
|
||||||
|
"explicitLink": "Note links (manual)",
|
||||||
|
"semanticEcho": "Memory Echo (AI)",
|
||||||
|
"titleMention": "Title mention",
|
||||||
|
"sharedLabel": "Shared tags",
|
||||||
|
"jaccard": "Keyword similarity"
|
||||||
|
},
|
||||||
"relationshipTypes": "Relationship Types",
|
"relationshipTypes": "Relationship Types",
|
||||||
"connections": "{count} connection",
|
"connections": "{count} connection",
|
||||||
"connectionsPlural": "{count} connections",
|
"connectionsPlural": "{count} connections",
|
||||||
@@ -2866,6 +2922,82 @@
|
|||||||
"updated": "Updated on"
|
"updated": "Updated on"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"insightsView": {
|
||||||
|
"title": "Semantic Insights",
|
||||||
|
"subtitle": "Discover the hidden architecture of your knowledge",
|
||||||
|
"resync": "Re-sync network",
|
||||||
|
"mapping": "Mapping…",
|
||||||
|
"loading": "Loading your notes…",
|
||||||
|
"mappingTitle": "Mapping your knowledge…",
|
||||||
|
"mappingHint": "This can take one to three minutes. You can keep browsing; the page will update when it's done.",
|
||||||
|
"analyzeNow": "Start semantic analysis",
|
||||||
|
"emptyNeedMoreNotes": "Add {count} more notes to unlock semantic clustering (minimum 10).",
|
||||||
|
"embeddingsHint": "Only {indexed} of {total} notes are indexed for AI. Analysis will prepare them first (this may take several minutes).",
|
||||||
|
"vsGraphHint": "This is not the same as “Link map” (network icon in the sidebar): here, AI groups your notes by theme.",
|
||||||
|
"openGraphMap": "Open link map",
|
||||||
|
"analysisFailed": "Analysis failed. Check your AI settings or try again.",
|
||||||
|
"analysisSuccess": "Analysis complete: {count} themes detected.",
|
||||||
|
"analysisNoClusters": "No themes detected yet.",
|
||||||
|
"staleResults": "Showing results from the last analysis. Many notes changed since then — click “Resynchronize network” to refresh.",
|
||||||
|
"semanticGraphLegend": "Detected themes overview (not the link map)",
|
||||||
|
"fitGraphView": "Fit view",
|
||||||
|
"graphPreviewHint": "Theme overview: the number is how many notes belong here. Hover a dot for the title, click to open. Full list on the right.",
|
||||||
|
"graphMoreNotes": "+{count} more in this theme",
|
||||||
|
"graphNotesLabel": "notes",
|
||||||
|
"clusterFallback": "Theme {index}",
|
||||||
|
"unclusteredNotes": "{count} notes not assigned to a theme (hidden from graph).",
|
||||||
|
"emptyTitle": "Discover your knowledge clusters",
|
||||||
|
"emptyDescription": "Click \"Re-sync network\" to analyze your notes and find hidden connections",
|
||||||
|
"stats": {
|
||||||
|
"clusters": "Clusters",
|
||||||
|
"bridgeNotes": "Bridge notes"
|
||||||
|
},
|
||||||
|
"clusters": {
|
||||||
|
"title": "Semantic clusters",
|
||||||
|
"notesCount": "{count} notes",
|
||||||
|
"centralNotes": "Central notes",
|
||||||
|
"emptyCluster": "No notes in this cluster"
|
||||||
|
},
|
||||||
|
"bridgeNotes": {
|
||||||
|
"title": "Powerful bridge notes",
|
||||||
|
"score": "Score: {score}%",
|
||||||
|
"empty": "No significant bridge notes yet. Deepen your research to find new connections."
|
||||||
|
},
|
||||||
|
"suggestions": {
|
||||||
|
"title": "Missing links (AI generated)",
|
||||||
|
"bridging": "Bridging {clusterA} & {clusterB}",
|
||||||
|
"emptyTitle": "No connection suggestions yet",
|
||||||
|
"emptyDescription": "All your clusters may already be connected!",
|
||||||
|
"createNote": "Create bridge note"
|
||||||
|
},
|
||||||
|
"unknownNote": "Untitled note",
|
||||||
|
"viewSplit": "Split",
|
||||||
|
"viewGraph": "Graph",
|
||||||
|
"viewDashboard": "Dashboard",
|
||||||
|
"isolatedClusters": {
|
||||||
|
"title": "Isolated clusters ({count})",
|
||||||
|
"badge": "Not connected",
|
||||||
|
"empty": "All your semantic clusters are interconnected!"
|
||||||
|
},
|
||||||
|
"focusCluster": {
|
||||||
|
"title": "Cluster Focus Active",
|
||||||
|
"description": "This thematic cluster gathers {count} complementary notes. Click on a note to access it directly:",
|
||||||
|
"close": "Close"
|
||||||
|
},
|
||||||
|
"badgeDominant": "Dominant",
|
||||||
|
"bridgeCount": "bridge(s)",
|
||||||
|
"echoTitle": "You keep returning to this idea",
|
||||||
|
"tipClusters": "AI grouped your notes by semantic affinity — regardless of which notebook they're in. Each theme represents a subject your mind keeps returning to.",
|
||||||
|
"tipClustersAction": "Click a theme to see its notes. Click a note to open it.",
|
||||||
|
"tipBridgeNotes": "These notes speak to two different themes at once. They reveal where your thinking crosses boundaries — often where the most original ideas hide.",
|
||||||
|
"tipBridgeNotesAction": "Click a note to open it and understand the connection.",
|
||||||
|
"tipEcho": "Memory Echo detects two notes written at very different times that cover the same idea. Your mind revisited a thought without realising it.",
|
||||||
|
"tipEchoAction": "Two notes, same idea, different moments. Click to explore.",
|
||||||
|
"tipSuggestions": "These themes have no note linking them yet. AI proposes a starting idea. Click 'Create bridge note' to write it and open it in the editor.",
|
||||||
|
"tipSuggestionsAction": "Click 'Create bridge note' to write the note and open it immediately.",
|
||||||
|
"tipIsolated": "These themes are isolated: no note connects them to the others. Maybe you're exploring a fragile idea? One synthesis note would be enough to create the link.",
|
||||||
|
"tipIsolatedAction": "These themes have no note connecting them to the rest of your thinking."
|
||||||
|
},
|
||||||
"consent": {
|
"consent": {
|
||||||
"banner": {
|
"banner": {
|
||||||
"title": "Cookie Preferences",
|
"title": "Cookie Preferences",
|
||||||
|
|||||||
@@ -714,24 +714,54 @@
|
|||||||
"error": "خطا در بارگذاری ارتباطات"
|
"error": "خطا در بارگذاری ارتباطات"
|
||||||
},
|
},
|
||||||
"comparison": {
|
"comparison": {
|
||||||
"title": "💡 مقایسه یادداشتها",
|
"title": "مقایسه کنار هم",
|
||||||
"similarityInfo": "این یادداشتها با {similarity}% شباهت مرتبط هستند",
|
"subtitle": "یادداشتهای مرتبط را مرور کنید تا تصمیم بگیرید چه چیزی را نگه دارید یا ادغام کنید.",
|
||||||
"highSimilarityInsight": "این یادداشتها در مورد یک موضوع با درجه بالایی از شباهت صحبت میکنند. میتوانند ادغام یا تلفیق شوند.",
|
"similarityInfo": "این یادداشتها با {similarity}٪ شباهت به هم مرتبطاند",
|
||||||
|
"highSimilarityInsight": "این یادداشتها دربارهٔ یک موضوع با شباهت بالا هستند. میتوان آنها را ادغام یا یکپارچه کرد.",
|
||||||
|
"stayOnCurrentNote": "در یادداشت فعلی میمانید. این پنجره را ببندید تا به نوشتن ادامه دهید.",
|
||||||
"untitled": "بدون عنوان",
|
"untitled": "بدون عنوان",
|
||||||
"clickToView": "برای مشاهده یادداشت کلیک کنید",
|
"clickToView": "برای مشاهده یادداشت کلیک کنید",
|
||||||
"helpfulQuestion": "این مقایسه مفید است؟",
|
"helpfulQuestion": "آیا این مقایسه مفید بود؟",
|
||||||
"helpful": "مفید",
|
"helpful": "مفید",
|
||||||
"notHelpful": "غیرمفید"
|
"notHelpful": "غیرمفید"
|
||||||
},
|
},
|
||||||
|
"preview": {
|
||||||
|
"subtitle": "پیشنمایش یادداشت مرتبط — از یادداشتی که ویرایش میکنید خارج نمیشوید.",
|
||||||
|
"loadError": "بارگذاری محتوای این یادداشت ممکن نشد."
|
||||||
|
},
|
||||||
"editorSection": {
|
"editorSection": {
|
||||||
"title": "⚡ یادداشتهای مرتبط ({count})",
|
"title": "یادداشتهای مرتبط ({count})",
|
||||||
"loading": "در حال بارگذاری...",
|
"loading": "در حال بارگذاری…",
|
||||||
"view": "مشاهده",
|
"view": "مشاهده",
|
||||||
|
"viewLinkedNote": "مشاهده یادداشت مرتبط",
|
||||||
"compare": "مقایسه",
|
"compare": "مقایسه",
|
||||||
"merge": "ادغام",
|
"merge": "ادغام",
|
||||||
"compareAll": "مقایسه همه",
|
"compareAll": "مقایسه همه",
|
||||||
"mergeAll": "ادغام همه",
|
"mergeAll": "ادغام همه",
|
||||||
"close": "بستن"
|
"close": "بستن",
|
||||||
|
"backToNote": "بازگشت به یادداشت من",
|
||||||
|
"openInEditor": "باز کردن در ویرایشگر",
|
||||||
|
"badgeLabel": "Memory Echo",
|
||||||
|
"affinityBadge": "{percentage}٪ شباهت معنایی",
|
||||||
|
"intro": "مومنتو یادداشت دیگری دربارهٔ همین موضوع پیدا کرده است. آن را ببینید، نقلقولی درج کنید یا ادغام کنید — بدون ترک این یادداشت.",
|
||||||
|
"detectedIn": "بخش یافتشده در: {title}",
|
||||||
|
"helpToggle": "چطور کار میکند؟",
|
||||||
|
"helpTitle": "با این ارتباط چه کار میتوانید بکنید؟",
|
||||||
|
"helpView": "یادداشت دیگر را در یک پنجره نشان میدهد. در یادداشت فعلی میمانید.",
|
||||||
|
"helpCite": "بخشی از یادداشت دیگر را اینجا با لینک به منبع درج میکند.",
|
||||||
|
"helpCompare": "هر دو یادداشت را کنار هم در یک پنجره نشان میدهد.",
|
||||||
|
"helpMerge": "محتوای هر دو یادداشت را در یک یادداشت ترکیب میکند.",
|
||||||
|
"embedPassage": "نقلقول",
|
||||||
|
"embedding": "در حال درج…",
|
||||||
|
"embedSuccess": "نقلقول در یادداشت درج شد",
|
||||||
|
"citationSuccess": "نقلقول با لینک به منبع درج شد",
|
||||||
|
"embedFallback": "یادداشت منبع را باز کنید، کمی ویرایش کنید و دوباره امتحان کنید.",
|
||||||
|
"embedFailed": "در حال حاضر درج نقلقول ممکن نیست",
|
||||||
|
"showAll": "مشاهده همه ارتباطات ({count})",
|
||||||
|
"hideAll": "پنهان کردن ارتباطات اضافی ({count})",
|
||||||
|
"retroTitle": "یادداشتهایی که این محتوا را نقل میکنند",
|
||||||
|
"retroDescription": "این بخش در {count} یادداشت دیگر نقل شده است:",
|
||||||
|
"consentRequired": "برای دیدن ارتباطات معنایی این یادداشت، پردازش هوش مصنوعی را در تنظیمات ← هوش مصنوعی فعال کنید."
|
||||||
},
|
},
|
||||||
"fusion": {
|
"fusion": {
|
||||||
"title": "🔗 ادغام هوشمند",
|
"title": "🔗 ادغام هوشمند",
|
||||||
|
|||||||
@@ -85,7 +85,8 @@
|
|||||||
"richtext": "Texte enrichi",
|
"richtext": "Texte enrichi",
|
||||||
"markdown": "Markdown",
|
"markdown": "Markdown",
|
||||||
"text": "Texte brut",
|
"text": "Texte brut",
|
||||||
"checklist": "Liste de tâches"
|
"checklist": "Liste de tâches",
|
||||||
|
"clip": "Clip web"
|
||||||
},
|
},
|
||||||
"listItem": "Élément de liste",
|
"listItem": "Élément de liste",
|
||||||
"addListItem": "+ Élément de liste",
|
"addListItem": "+ Élément de liste",
|
||||||
@@ -867,9 +868,47 @@
|
|||||||
"brainstormInvite": "Brainstorm",
|
"brainstormInvite": "Brainstorm",
|
||||||
"brainstormJoined": "Brainstorm",
|
"brainstormJoined": "Brainstorm",
|
||||||
"systemNotification": "Système",
|
"systemNotification": "Système",
|
||||||
|
"clipSaved": "Clip web enregistré",
|
||||||
"downloadFailed": "Échec du téléchargement",
|
"downloadFailed": "Échec du téléchargement",
|
||||||
"brainstormShared": "Brainstorm partagé"
|
"brainstormShared": "Brainstorm partagé"
|
||||||
},
|
},
|
||||||
|
"clipper": {
|
||||||
|
"webClipper": "Web Clipper",
|
||||||
|
"connected": "Connecté",
|
||||||
|
"destinationNotebook": "Carnet de destination",
|
||||||
|
"selectNotebook": "Sélectionner un carnet",
|
||||||
|
"activePage": "Page active",
|
||||||
|
"selectionDetected": "Sélection détectée",
|
||||||
|
"ignore": "ignorer",
|
||||||
|
"selectionHint": "Astuce : surlignez du texte à l'écran pour clipper une sélection précise de la page en tant que note.",
|
||||||
|
"clipSelection": "Clipper la sélection",
|
||||||
|
"clipPage": "Clipper cette page",
|
||||||
|
"analyzingSource": "Analyse de la source",
|
||||||
|
"processing": "Traitement en cours…",
|
||||||
|
"processingDetail": "Génération automatique des tags, résumé sémantique et calcul des embeddings en cours.",
|
||||||
|
"successBadge": "Traitement réussi",
|
||||||
|
"sentToNotebook": "Note envoyée dans le carnet",
|
||||||
|
"viewInMomento": "Voir dans Momento",
|
||||||
|
"clipAnother": "Clipper une autre page",
|
||||||
|
"captureFailed": "Échec de la capture",
|
||||||
|
"genericError": "Une erreur s'est produite lors de la transmission à votre instance.",
|
||||||
|
"retry": "Réessayer",
|
||||||
|
"footer": "Momento Companion v2.1.2 · Sécurisé HTTPS TLS 1.3",
|
||||||
|
"quitSimulator": "Quitter le simulateur",
|
||||||
|
"simulatorBadge": "Simulateur de capture",
|
||||||
|
"extensionActive": "Extension active sur cette page",
|
||||||
|
"publishedOn": "Publié sur {domain}",
|
||||||
|
"realtimeCapture": "Date : capture temps réel",
|
||||||
|
"selectTextHint": "Sélectionnez du texte ci-dessous pour le clipper",
|
||||||
|
"evalTipTitle": "Piste d'évaluation :",
|
||||||
|
"evalTipBody": "Survolez et surlignez n'importe quel texte dans l'article ci-dessous pour activer l'état Sélection active dans l'extension. Vous pouvez aussi cliquer sur un paragraphe pour le simuler.",
|
||||||
|
"clickParagraph": "Cliquer pour sélectionner ce paragraphe",
|
||||||
|
"selectionSaved": "Sélection enregistrée ({count} mots)",
|
||||||
|
"clearSelection": "Effacer la sélection",
|
||||||
|
"toastTitle": "Note clippée —",
|
||||||
|
"view": "Voir",
|
||||||
|
"simulatedClipper": "Clipper simulé"
|
||||||
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
"home": "Accueil",
|
"home": "Accueil",
|
||||||
"notes": "Notes",
|
"notes": "Notes",
|
||||||
@@ -886,6 +925,9 @@
|
|||||||
"trash": "Corbeille",
|
"trash": "Corbeille",
|
||||||
"support": "Soutenir Memento ☕",
|
"support": "Soutenir Memento ☕",
|
||||||
"reminders": "Rappels",
|
"reminders": "Rappels",
|
||||||
|
"graphView": "Carte des liens",
|
||||||
|
"insights": "Thèmes sémantiques",
|
||||||
|
"revision": "Révisions",
|
||||||
"userManagement": "Gestion des utilisateurs",
|
"userManagement": "Gestion des utilisateurs",
|
||||||
"accountSettings": "Paramètres du compte",
|
"accountSettings": "Paramètres du compte",
|
||||||
"manageAISettings": "Gérer les paramètres IA",
|
"manageAISettings": "Gérer les paramètres IA",
|
||||||
@@ -1751,6 +1793,8 @@
|
|||||||
"imagesLabel": "Images",
|
"imagesLabel": "Images",
|
||||||
"notebookLabel": "Carnet",
|
"notebookLabel": "Carnet",
|
||||||
"typeLabel": "Type",
|
"typeLabel": "Type",
|
||||||
|
"sourceWebLabel": "Source web",
|
||||||
|
"openSource": "Ouvrir la page d'origine",
|
||||||
"createdLabel": "Créée le",
|
"createdLabel": "Créée le",
|
||||||
"modifiedLabel": "Modifiée",
|
"modifiedLabel": "Modifiée",
|
||||||
"labelsSection": "Étiquettes",
|
"labelsSection": "Étiquettes",
|
||||||
@@ -2851,11 +2895,23 @@
|
|||||||
"title": "Vue en graphe",
|
"title": "Vue en graphe",
|
||||||
"notesCount": "{count} notes",
|
"notesCount": "{count} notes",
|
||||||
"connectionsCount": "{count} liens",
|
"connectionsCount": "{count} liens",
|
||||||
|
"visibleConnections": "{count} visibles",
|
||||||
"globalView": "Vue globale",
|
"globalView": "Vue globale",
|
||||||
"searchPlaceholder": "Filtrer...",
|
"searchPlaceholder": "Filtrer...",
|
||||||
"noNotesFound": "Aucune note trouvée",
|
"noNotesFound": "Aucune note trouvée",
|
||||||
"notebooks": "Carnets",
|
"notebooks": "Carnets",
|
||||||
"resetFilter": "Réinitialiser",
|
"resetFilter": "Réinitialiser",
|
||||||
|
"resetFocus": "Graphe complet",
|
||||||
|
"exploreFromNode": "Explorer depuis cette note",
|
||||||
|
"linkFilters": "Filtres de liens",
|
||||||
|
"semanticThreshold": "Seuil sémantique",
|
||||||
|
"edgeTypes": {
|
||||||
|
"explicitLink": "Liens vers une note (manuel)",
|
||||||
|
"semanticEcho": "Memory Echo (IA)",
|
||||||
|
"titleMention": "Mention de titre",
|
||||||
|
"sharedLabel": "Tags partagés",
|
||||||
|
"jaccard": "Similarité par mots-clés"
|
||||||
|
},
|
||||||
"relationshipTypes": "Types de liaisons",
|
"relationshipTypes": "Types de liaisons",
|
||||||
"connections": "{count} liaison",
|
"connections": "{count} liaison",
|
||||||
"connectionsPlural": "{count} liaisons",
|
"connectionsPlural": "{count} liaisons",
|
||||||
@@ -2870,6 +2926,82 @@
|
|||||||
"updated": "Mise à jour le"
|
"updated": "Mise à jour le"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"insightsView": {
|
||||||
|
"title": "Insights sémantiques",
|
||||||
|
"subtitle": "Découvrez l'architecture cachée de votre savoir",
|
||||||
|
"resync": "Resynchroniser le réseau",
|
||||||
|
"mapping": "Cartographie…",
|
||||||
|
"loading": "Chargement de vos notes…",
|
||||||
|
"mappingTitle": "Cartographie de votre savoir…",
|
||||||
|
"mappingHint": "Cela peut prendre une à trois minutes. Vous pouvez continuer à naviguer ; la page se mettra à jour à la fin.",
|
||||||
|
"analyzeNow": "Lancer l'analyse sémantique",
|
||||||
|
"emptyNeedMoreNotes": "Ajoutez encore {count} notes pour débloquer le regroupement sémantique (minimum 10).",
|
||||||
|
"embeddingsHint": "Seulement {indexed} notes sur {total} sont indexées pour l’IA. L’analyse va d’abord les préparer (cela peut prendre plusieurs minutes).",
|
||||||
|
"vsGraphHint": "Ce n’est pas la même chose que la « Carte des liens » (icône réseau dans la barre latérale) : ici, l’IA regroupe vos notes par thèmes.",
|
||||||
|
"openGraphMap": "Ouvrir la carte des liens",
|
||||||
|
"analysisFailed": "L’analyse a échoué. Vérifiez vos paramètres IA ou réessayez.",
|
||||||
|
"analysisSuccess": "Analyse terminée : {count} thèmes détectés.",
|
||||||
|
"analysisNoClusters": "Aucun thème détecté pour l'instant.",
|
||||||
|
"staleResults": "Résultats affichés depuis la dernière analyse. Beaucoup de notes ont changé depuis — cliquez « Resynchroniser le réseau » pour mettre à jour.",
|
||||||
|
"semanticGraphLegend": "Aperçu des thèmes détectés (pas la carte des liens)",
|
||||||
|
"fitGraphView": "Ajuster la vue",
|
||||||
|
"graphPreviewHint": "Aperçu par thème : le chiffre = nombre de notes. Survolez un point pour le titre, cliquez pour ouvrir. Liste complète à droite.",
|
||||||
|
"graphMoreNotes": "+{count} autres dans ce thème",
|
||||||
|
"graphNotesLabel": "notes",
|
||||||
|
"clusterFallback": "Thème {index}",
|
||||||
|
"unclusteredNotes": "{count} notes non rattachées à un thème (hors graphe).",
|
||||||
|
"emptyTitle": "Découvrez vos clusters de connaissance",
|
||||||
|
"emptyDescription": "Cliquez sur « Resynchroniser le réseau » pour analyser vos notes et révéler des connexions cachées",
|
||||||
|
"stats": {
|
||||||
|
"clusters": "Clusters",
|
||||||
|
"bridgeNotes": "Notes pont"
|
||||||
|
},
|
||||||
|
"clusters": {
|
||||||
|
"title": "Clusters sémantiques",
|
||||||
|
"notesCount": "{count} notes",
|
||||||
|
"centralNotes": "Notes centrales",
|
||||||
|
"emptyCluster": "Aucune note dans ce cluster"
|
||||||
|
},
|
||||||
|
"bridgeNotes": {
|
||||||
|
"title": "Notes pont stratégiques",
|
||||||
|
"score": "Score : {score} %",
|
||||||
|
"empty": "Aucune note pont significative pour l'instant. Approfondissez vos recherches pour découvrir de nouvelles connexions."
|
||||||
|
},
|
||||||
|
"suggestions": {
|
||||||
|
"title": "Liens manquants (générés par l'IA)",
|
||||||
|
"bridging": "Relier {clusterA} et {clusterB}",
|
||||||
|
"emptyTitle": "Aucune suggestion de connexion",
|
||||||
|
"emptyDescription": "Vos clusters sont peut-être déjà bien reliés entre eux !",
|
||||||
|
"createNote": "Créer la note pont"
|
||||||
|
},
|
||||||
|
"unknownNote": "Note sans titre",
|
||||||
|
"viewSplit": "Combiné",
|
||||||
|
"viewGraph": "Graphe",
|
||||||
|
"viewDashboard": "Tableau",
|
||||||
|
"isolatedClusters": {
|
||||||
|
"title": "Clusters isolés ({count})",
|
||||||
|
"badge": "Non connecté",
|
||||||
|
"empty": "Tous les thèmes sémantiques sont liés par au moins un point de passage sémantique !"
|
||||||
|
},
|
||||||
|
"focusCluster": {
|
||||||
|
"title": "Focus Cluster Activé",
|
||||||
|
"description": "Cet ensemble thématique réunit {count} notes complémentaires. Cliquez sur une note pour y accéder directement :",
|
||||||
|
"close": "Fermer"
|
||||||
|
},
|
||||||
|
"badgeDominant": "Dominant",
|
||||||
|
"bridgeCount": "pont(s)",
|
||||||
|
"echoTitle": "Tu reviens sur cette idée",
|
||||||
|
"tipClusters": "L'IA a regroupé tes notes par affinité sémantique — indépendamment de tes carnets. Chaque thème représente un sujet sur lequel ton esprit revient régulièrement.",
|
||||||
|
"tipClustersAction": "Clique sur un thème pour voir ses notes. Clique sur une note pour l'ouvrir.",
|
||||||
|
"tipBridgeNotes": "Ces notes parlent de deux thèmes différents à la fois. Elles montrent où ta pensée traverse des frontières — souvent là où se cachent les idées les plus originales.",
|
||||||
|
"tipBridgeNotesAction": "Clique sur une note pour l'ouvrir et comprendre le lien.",
|
||||||
|
"tipEcho": "Le Memory Echo détecte deux notes écrites à des moments très différents mais qui parlent de la même chose. Ton esprit a revisité une idée sans que tu t'en rendes compte.",
|
||||||
|
"tipEchoAction": "Deux notes, même idée, moments différents. Clique pour explorer.",
|
||||||
|
"tipSuggestions": "Ces thèmes n'ont pas encore de note qui les relie. L'IA te propose une idée de départ. Clique sur « Créer la note pont » pour la rédiger et l'ouvrir dans l'éditeur.",
|
||||||
|
"tipSuggestionsAction": "Clique sur « Créer la note pont » pour créer la note et l'ouvrir immédiatement.",
|
||||||
|
"tipIsolated": "Ces thèmes sont isolés : aucune note ne les relie aux autres. Peut-être explores-tu une idée encore fragile ? Une note de synthèse suffirait à créer le lien.",
|
||||||
|
"tipIsolatedAction": "Ces thèmes n'ont aucune note qui les relie au reste de ta réflexion."
|
||||||
|
},
|
||||||
"consent": {
|
"consent": {
|
||||||
"banner": {
|
"banner": {
|
||||||
"title": "Préférences de Cookies",
|
"title": "Préférences de Cookies",
|
||||||
|
|||||||
@@ -0,0 +1,330 @@
|
|||||||
|
/**
|
||||||
|
* Bridge Notes Service
|
||||||
|
*
|
||||||
|
* Detects notes that connect multiple clusters (bridge notes)
|
||||||
|
* and generates AI-powered suggestions for missing connections.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import prisma from '@/lib/prisma'
|
||||||
|
|
||||||
|
export interface BridgeNote {
|
||||||
|
noteId: string
|
||||||
|
bridgeScore: number
|
||||||
|
clustersConnected: number[]
|
||||||
|
clusterNames?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConnectionSuggestion {
|
||||||
|
clusterAId: number
|
||||||
|
clusterBId: number
|
||||||
|
clusterAName: string
|
||||||
|
clusterBName: string
|
||||||
|
suggestedTitle: string
|
||||||
|
suggestedContent: string
|
||||||
|
justification: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BridgeNotesService {
|
||||||
|
private readonly BRIDGE_THRESHOLD = 0.5 // Cosine similarity threshold
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect bridge notes for a user.
|
||||||
|
* A bridge note is a note that has strong connections (>= 0.5 similarity)
|
||||||
|
* to at least 2 different clusters.
|
||||||
|
*/
|
||||||
|
async detectBridgeNotes(userId: string): Promise<BridgeNote[]> {
|
||||||
|
// Get all clusters for the user
|
||||||
|
const clusters = await prisma.noteCluster.findMany({
|
||||||
|
where: { userId },
|
||||||
|
select: { clusterId: true, name: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (clusters.length < 2) return []
|
||||||
|
|
||||||
|
// Get cluster memberships
|
||||||
|
const clusterMembers = await prisma.clusterMember.findMany({
|
||||||
|
where: { userId },
|
||||||
|
select: { noteId: true, clusterId: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Group notes by cluster
|
||||||
|
const notesByCluster = new Map<number, string[]>()
|
||||||
|
for (const cluster of clusters) {
|
||||||
|
notesByCluster.set(
|
||||||
|
cluster.clusterId,
|
||||||
|
clusterMembers
|
||||||
|
.filter(cm => cm.clusterId === cluster.clusterId)
|
||||||
|
.map(cm => cm.noteId)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const bridgeNotes: BridgeNote[] = []
|
||||||
|
const processedNotes = new Set<string>()
|
||||||
|
|
||||||
|
// For each note, check if it connects to multiple clusters
|
||||||
|
for (const [clusterId, noteIds] of notesByCluster) {
|
||||||
|
for (const noteId of noteIds) {
|
||||||
|
if (processedNotes.has(noteId)) continue
|
||||||
|
processedNotes.add(noteId)
|
||||||
|
|
||||||
|
// Check which other clusters this note is similar to
|
||||||
|
const connectedClusters: number[] = []
|
||||||
|
|
||||||
|
for (const [otherClusterId, otherNoteIds] of notesByCluster) {
|
||||||
|
if (otherClusterId === clusterId) continue
|
||||||
|
|
||||||
|
// Check similarity to notes in other cluster
|
||||||
|
const hasStrongConnection = await this.hasStrongLinkToCluster(
|
||||||
|
noteId,
|
||||||
|
otherNoteIds
|
||||||
|
)
|
||||||
|
|
||||||
|
if (hasStrongConnection) {
|
||||||
|
connectedClusters.push(otherClusterId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If connected to >= 2 clusters, it's a bridge note
|
||||||
|
if (connectedClusters.length >= 1) {
|
||||||
|
// Include the original cluster
|
||||||
|
connectedClusters.unshift(clusterId)
|
||||||
|
|
||||||
|
bridgeNotes.push({
|
||||||
|
noteId,
|
||||||
|
bridgeScore: connectedClusters.length / Math.max(clusters.length, 1),
|
||||||
|
clustersConnected: connectedClusters,
|
||||||
|
clusterNames: connectedClusters
|
||||||
|
.map(id => clusters.find(c => c.clusterId === id)?.name)
|
||||||
|
.filter(Boolean) as string[]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bridgeNotes.sort((a, b) => b.bridgeScore - a.bridgeScore)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a note has strong links (similarity >= threshold) to any note in a cluster.
|
||||||
|
*/
|
||||||
|
private async hasStrongLinkToCluster(
|
||||||
|
noteId: string,
|
||||||
|
clusterNoteIds: string[]
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (clusterNoteIds.length === 0) return false
|
||||||
|
|
||||||
|
for (const otherNoteId of clusterNoteIds) {
|
||||||
|
const similarity = await this.getCosineSimilarity(noteId, otherNoteId)
|
||||||
|
if (similarity >= this.BRIDGE_THRESHOLD) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cosine similarity between two notes using pgvector.
|
||||||
|
*/
|
||||||
|
private async getCosineSimilarity(
|
||||||
|
noteIdA: string,
|
||||||
|
noteIdB: string
|
||||||
|
): Promise<number> {
|
||||||
|
const result = await prisma.$queryRawUnsafe<Array<{ similarity: number }>>(
|
||||||
|
`SELECT 1 - (e1."embedding"::vector <=> e2."embedding"::vector) AS similarity
|
||||||
|
FROM "NoteEmbedding" e1, "NoteEmbedding" e2
|
||||||
|
WHERE e1."noteId" = $1 AND e2."noteId" = $2`,
|
||||||
|
noteIdA,
|
||||||
|
noteIdB
|
||||||
|
)
|
||||||
|
return result[0]?.similarity || 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get saved bridge notes for a user.
|
||||||
|
*/
|
||||||
|
async getBridgeNotes(userId: string): Promise<BridgeNote[]> {
|
||||||
|
const bridges = await prisma.bridgeNote.findMany({
|
||||||
|
where: { userId },
|
||||||
|
include: {
|
||||||
|
clusters: {
|
||||||
|
include: {
|
||||||
|
cluster: {
|
||||||
|
select: { name: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return bridges.map(b => ({
|
||||||
|
noteId: b.noteId,
|
||||||
|
bridgeScore: b.bridgeScore,
|
||||||
|
clustersConnected: b.clusters.map(c => c.clusterId),
|
||||||
|
clusterNames: b.clusters.map(c => c.cluster.name)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save bridge notes to the database.
|
||||||
|
*/
|
||||||
|
async saveBridgeNotes(userId: string, bridgeNotes: BridgeNote[]): Promise<void> {
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
// Clear existing bridge notes for this user
|
||||||
|
await tx.$executeRawUnsafe(`DELETE FROM "BridgeNoteCluster" WHERE "userId" = $1`, userId)
|
||||||
|
await tx.bridgeNote.deleteMany({ where: { userId } })
|
||||||
|
|
||||||
|
// Insert new bridge notes
|
||||||
|
for (const bridge of bridgeNotes) {
|
||||||
|
await tx.bridgeNote.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
noteId: bridge.noteId,
|
||||||
|
bridgeScore: bridge.bridgeScore,
|
||||||
|
clusters: {
|
||||||
|
create: bridge.clustersConnected.map(clusterId => ({
|
||||||
|
userId,
|
||||||
|
clusterId
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate AI-powered suggestions for connecting isolated clusters.
|
||||||
|
*/
|
||||||
|
async generateConnectionSuggestions(
|
||||||
|
userId: string
|
||||||
|
): Promise<ConnectionSuggestion[]> {
|
||||||
|
const clusters = await prisma.noteCluster.findMany({
|
||||||
|
where: { userId },
|
||||||
|
select: { clusterId: true, name: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (clusters.length < 2) return []
|
||||||
|
|
||||||
|
const suggestions: ConnectionSuggestion[] = []
|
||||||
|
|
||||||
|
// Generate suggestions for cluster pairs (limit to 5 pairs)
|
||||||
|
for (let i = 0; i < Math.min(clusters.length, 3); i++) {
|
||||||
|
for (let j = i + 1; j < Math.min(clusters.length, 4); j++) {
|
||||||
|
const clusterA = clusters[i]
|
||||||
|
const clusterB = clusters[j]
|
||||||
|
|
||||||
|
// Get sample notes from each cluster
|
||||||
|
const notesA = await prisma.$queryRawUnsafe<
|
||||||
|
Array<{ title: string | null; content: string }>
|
||||||
|
>(
|
||||||
|
`SELECT n.title, n.content
|
||||||
|
FROM "ClusterMember" cm
|
||||||
|
INNER JOIN "Note" n ON n.id = cm."noteId"
|
||||||
|
WHERE cm."clusterId" = $1 AND cm."userId" = $2
|
||||||
|
LIMIT 3`,
|
||||||
|
clusterA.clusterId,
|
||||||
|
userId
|
||||||
|
)
|
||||||
|
|
||||||
|
const notesB = await prisma.$queryRawUnsafe<
|
||||||
|
Array<{ title: string | null; content: string }>
|
||||||
|
>(
|
||||||
|
`SELECT n.title, n.content
|
||||||
|
FROM "ClusterMember" cm
|
||||||
|
INNER JOIN "Note" n ON n.id = cm."noteId"
|
||||||
|
WHERE cm."clusterId" = $1 AND cm."userId" = $2
|
||||||
|
LIMIT 3`,
|
||||||
|
clusterB.clusterId,
|
||||||
|
userId
|
||||||
|
)
|
||||||
|
|
||||||
|
const summaryA = notesA.map(n => n.title || 'Untitled').join(', ')
|
||||||
|
const summaryB = notesB.map(n => n.title || 'Untitled').join(', ')
|
||||||
|
|
||||||
|
const suggestion = await this.generateBridgeSuggestion(
|
||||||
|
clusterA.name || `Cluster ${clusterA.clusterId}`,
|
||||||
|
clusterB.name || `Cluster ${clusterB.clusterId}`,
|
||||||
|
summaryA,
|
||||||
|
summaryB
|
||||||
|
)
|
||||||
|
|
||||||
|
suggestions.push({
|
||||||
|
clusterAId: clusterA.clusterId,
|
||||||
|
clusterBId: clusterB.clusterId,
|
||||||
|
clusterAName: clusterA.name || `Cluster ${clusterA.clusterId}`,
|
||||||
|
clusterBName: clusterB.name || `Cluster ${clusterB.clusterId}`,
|
||||||
|
...suggestion
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return suggestions
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a single bridge suggestion using the LLM.
|
||||||
|
*/
|
||||||
|
private async generateBridgeSuggestion(
|
||||||
|
clusterAName: string,
|
||||||
|
clusterBName: string,
|
||||||
|
summaryA: string,
|
||||||
|
summaryB: string
|
||||||
|
): Promise<Omit<ConnectionSuggestion, 'clusterAId' | 'clusterBId' | 'clusterAName' | 'clusterBName'>> {
|
||||||
|
const prompt = `Cluster A ("${clusterAName}") contains notes about: ${summaryA}
|
||||||
|
Cluster B ("${clusterBName}") contains notes about: ${summaryB}
|
||||||
|
|
||||||
|
These clusters are not directly connected. Suggest ONE creative "bridge note" idea that could connect them.
|
||||||
|
|
||||||
|
Provide your response as a JSON object with these fields:
|
||||||
|
- title: A concise title for the bridge note (2-6 words)
|
||||||
|
- description: What this note would explore (1-2 sentences)
|
||||||
|
- justification: Why this connection makes sense (1 sentence)
|
||||||
|
|
||||||
|
JSON:`
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { getChatProvider } = await import('@/lib/ai/factory')
|
||||||
|
const { getSystemConfig } = await import('@/lib/config')
|
||||||
|
|
||||||
|
const config = await getSystemConfig()
|
||||||
|
const provider = getChatProvider(config)
|
||||||
|
const response = await provider.chat([{ role: 'user', content: prompt }], '')
|
||||||
|
|
||||||
|
const text = response.text.trim()
|
||||||
|
const jsonMatch = text.match(/\{[\s\S]*\}/)
|
||||||
|
|
||||||
|
if (jsonMatch) {
|
||||||
|
return JSON.parse(jsonMatch[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback if JSON parsing fails
|
||||||
|
return {
|
||||||
|
suggestedTitle: `Connecting ${clusterAName} and ${clusterBName}`,
|
||||||
|
suggestedContent: `Explore the relationships between concepts from ${clusterAName} and ${clusterBName}.`,
|
||||||
|
justification: 'These topics may share underlying principles or applications.'
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
suggestedTitle: `Connecting ${clusterAName} and ${clusterBName}`,
|
||||||
|
suggestedContent: `Explore the relationships between concepts from ${clusterAName} and ${clusterBName}.`,
|
||||||
|
justification: 'These topics may share underlying principles or applications.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dismiss a connection suggestion.
|
||||||
|
*/
|
||||||
|
async dismissSuggestion(userId: string, clusterAId: number, clusterBId: number): Promise<void> {
|
||||||
|
await prisma.bridgeSuggestion.deleteMany({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
clusterAId,
|
||||||
|
clusterBId
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const bridgeNotesService = new BridgeNotesService()
|
||||||
410
memento-note/memento-note/lib/ai/services/clustering.service.ts
Normal file
@@ -0,0 +1,410 @@
|
|||||||
|
/**
|
||||||
|
* Clustering Service
|
||||||
|
*
|
||||||
|
* Density-based clustering algorithm (DBSCAN variant) for note embeddings.
|
||||||
|
* Groups semantically similar notes into clusters without requiring
|
||||||
|
* a preset number of clusters.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import prisma from '@/lib/prisma'
|
||||||
|
import { embeddingService } from './embedding.service'
|
||||||
|
import { getChatProvider } from '@/lib/ai/factory'
|
||||||
|
import { getSystemConfig } from '@/lib/config'
|
||||||
|
|
||||||
|
export interface ClusterResult {
|
||||||
|
clusterId: number
|
||||||
|
noteIds: string[]
|
||||||
|
centroid?: number[]
|
||||||
|
name?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClusteredNote {
|
||||||
|
noteId: string
|
||||||
|
clusterId: number
|
||||||
|
membershipScore: number
|
||||||
|
isCentral: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClusteringOptions {
|
||||||
|
minClusterSize?: number
|
||||||
|
epsilon?: number // Cosine distance threshold (lower = more strict)
|
||||||
|
maxClusters?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ClusteringService {
|
||||||
|
private readonly DEFAULT_MIN_CLUSTER_SIZE = 3
|
||||||
|
private readonly DEFAULT_EPSILON = 0.3 // Cosine distance ~ 1 - similarity
|
||||||
|
private readonly DEFAULT_MAX_CLUSTERS = 50
|
||||||
|
private readonly MIN_NOTES_FOR_CLUSTERING = 10
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate cosine similarity between two note IDs using pgvector.
|
||||||
|
*/
|
||||||
|
private async getCosineSimilarity(
|
||||||
|
noteIdA: string,
|
||||||
|
noteIdB: string
|
||||||
|
): Promise<number> {
|
||||||
|
const result = await prisma.$queryRawUnsafe<Array<{ similarity: number }>>(
|
||||||
|
`SELECT 1 - (e1."embedding"::vector <=> e2."embedding"::vector) AS similarity
|
||||||
|
FROM "NoteEmbedding" e1, "NoteEmbedding" e2
|
||||||
|
WHERE e1."noteId" = $1 AND e2."noteId" = $2`,
|
||||||
|
noteIdA,
|
||||||
|
noteIdB
|
||||||
|
)
|
||||||
|
return result[0]?.similarity || 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find all neighbors for a note within epsilon similarity threshold.
|
||||||
|
*/
|
||||||
|
private async findNeighbors(
|
||||||
|
noteId: string,
|
||||||
|
allNoteIds: string[],
|
||||||
|
epsilon: number
|
||||||
|
): Promise<string[]> {
|
||||||
|
const cosineDistance = 1 - epsilon
|
||||||
|
|
||||||
|
const result = await prisma.$queryRawUnsafe<Array<{ noteId: string }>>(
|
||||||
|
`SELECT e2."noteId"
|
||||||
|
FROM "NoteEmbedding" e1
|
||||||
|
CROSS JOIN "NoteEmbedding" e2
|
||||||
|
WHERE e1."noteId" = $1
|
||||||
|
AND e2."noteId" != $1
|
||||||
|
AND e2."noteId" = ANY($2::text[])
|
||||||
|
AND (e1."embedding"::vector <=> e2."embedding"::vector) <= $3`,
|
||||||
|
noteId,
|
||||||
|
allNoteIds,
|
||||||
|
cosineDistance
|
||||||
|
)
|
||||||
|
|
||||||
|
return result.map(r => r.noteId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expand a cluster from a seed note using DBSCAN-like algorithm.
|
||||||
|
*/
|
||||||
|
private async expandCluster(
|
||||||
|
noteId: string,
|
||||||
|
neighbors: string[],
|
||||||
|
clusterId: number,
|
||||||
|
visited: Set<string>,
|
||||||
|
clustered: Map<string, number>,
|
||||||
|
allNoteIds: string[],
|
||||||
|
epsilon: number,
|
||||||
|
minClusterSize: number
|
||||||
|
): Promise<string[]> {
|
||||||
|
const clusterMembers: string[] = [noteId]
|
||||||
|
const queue = [...neighbors]
|
||||||
|
clustered.set(noteId, clusterId)
|
||||||
|
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const currentNoteId = queue.shift()!
|
||||||
|
|
||||||
|
if (!visited.has(currentNoteId)) {
|
||||||
|
visited.add(currentNoteId)
|
||||||
|
const currentNeighbors = await this.findNeighbors(currentNoteId, allNoteIds, epsilon)
|
||||||
|
|
||||||
|
if (currentNeighbors.length >= minClusterSize) {
|
||||||
|
for (const neighborId of currentNeighbors) {
|
||||||
|
if (!clustered.has(neighborId)) {
|
||||||
|
clustered.set(neighborId, clusterId)
|
||||||
|
clusterMembers.push(neighborId)
|
||||||
|
queue.push(neighborId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return clusterMembers
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perform density-based clustering on user's note embeddings.
|
||||||
|
*/
|
||||||
|
async clusterNotes(
|
||||||
|
userId: string,
|
||||||
|
options: ClusteringOptions = {}
|
||||||
|
): Promise<{
|
||||||
|
clusters: ClusterResult[]
|
||||||
|
clusteredNotes: ClusteredNote[]
|
||||||
|
noiseCount: number
|
||||||
|
}> {
|
||||||
|
const {
|
||||||
|
minClusterSize = this.DEFAULT_MIN_CLUSTER_SIZE,
|
||||||
|
epsilon = this.DEFAULT_EPSILON,
|
||||||
|
maxClusters = this.DEFAULT_MAX_CLUSTERS
|
||||||
|
} = options
|
||||||
|
|
||||||
|
// Get all user's notes with embeddings
|
||||||
|
const notesWithEmbeddings = await prisma.$queryRawUnsafe<Array<{ noteId: string }>>(
|
||||||
|
`SELECT ne."noteId"
|
||||||
|
FROM "NoteEmbedding" ne
|
||||||
|
INNER JOIN "Note" n ON n.id = ne."noteId"
|
||||||
|
WHERE n."userId" = $1
|
||||||
|
AND n."trashedAt" IS NULL
|
||||||
|
AND ne."embedding" IS NOT NULL`,
|
||||||
|
userId
|
||||||
|
)
|
||||||
|
|
||||||
|
const allNoteIds = notesWithEmbeddings.map(n => n.noteId)
|
||||||
|
|
||||||
|
if (allNoteIds.length < this.MIN_NOTES_FOR_CLUSTERING) {
|
||||||
|
return {
|
||||||
|
clusters: [],
|
||||||
|
clusteredNotes: [],
|
||||||
|
noiseCount: allNoteIds.length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const visited = new Set<string>()
|
||||||
|
const clustered = new Map<string, number>()
|
||||||
|
const clusterResults: ClusterResult[] = []
|
||||||
|
let clusterId = 0
|
||||||
|
|
||||||
|
// DBSCAN algorithm
|
||||||
|
for (const noteId of allNoteIds) {
|
||||||
|
if (visited.has(noteId)) continue
|
||||||
|
|
||||||
|
visited.add(noteId)
|
||||||
|
const neighbors = await this.findNeighbors(noteId, allNoteIds, epsilon)
|
||||||
|
|
||||||
|
if (neighbors.length < minClusterSize) {
|
||||||
|
clustered.set(noteId, -1)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expand cluster
|
||||||
|
const clusterMembers = await this.expandCluster(
|
||||||
|
noteId,
|
||||||
|
neighbors,
|
||||||
|
clusterId,
|
||||||
|
visited,
|
||||||
|
clustered,
|
||||||
|
allNoteIds,
|
||||||
|
epsilon,
|
||||||
|
minClusterSize
|
||||||
|
)
|
||||||
|
|
||||||
|
if (clusterMembers.length >= minClusterSize && clusterId < maxClusters) {
|
||||||
|
clusterResults.push({
|
||||||
|
clusterId,
|
||||||
|
noteIds: clusterMembers
|
||||||
|
})
|
||||||
|
clusterId++
|
||||||
|
} else {
|
||||||
|
// Too small, mark as noise
|
||||||
|
for (const memberId of clusterMembers) {
|
||||||
|
clustered.set(memberId, -1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate membership scores and identify central notes
|
||||||
|
const clusteredNotes: ClusteredNote[] = []
|
||||||
|
for (const [noteId, cid] of clustered.entries()) {
|
||||||
|
if (cid === -1) continue
|
||||||
|
|
||||||
|
const cluster = clusterResults[cid]
|
||||||
|
if (!cluster) continue
|
||||||
|
|
||||||
|
const score = await this.calculateMembershipScore(noteId, cluster.noteIds)
|
||||||
|
const isCentral = await this.isCentralNote(noteId, cluster.noteIds)
|
||||||
|
|
||||||
|
clusteredNotes.push({
|
||||||
|
noteId,
|
||||||
|
clusterId: cid,
|
||||||
|
membershipScore: score,
|
||||||
|
isCentral
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const noiseCount = Array.from(clustered.values()).filter(id => id === -1).length
|
||||||
|
|
||||||
|
return {
|
||||||
|
clusters: clusterResults,
|
||||||
|
clusteredNotes,
|
||||||
|
noiseCount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate membership score for a note within its cluster.
|
||||||
|
*/
|
||||||
|
private async calculateMembershipScore(noteId: string, clusterMemberIds: string[]): Promise<number> {
|
||||||
|
if (clusterMemberIds.length <= 1) return 1.0
|
||||||
|
|
||||||
|
const similarities: number[] = []
|
||||||
|
for (const memberId of clusterMemberIds) {
|
||||||
|
if (memberId === noteId) continue
|
||||||
|
const sim = await this.getCosineSimilarity(noteId, memberId)
|
||||||
|
similarities.push(sim)
|
||||||
|
}
|
||||||
|
|
||||||
|
return similarities.length > 0
|
||||||
|
? similarities.reduce((a, b) => a + b, 0) / similarities.length
|
||||||
|
: 1.0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine if a note is central to its cluster.
|
||||||
|
*/
|
||||||
|
private async isCentralNote(noteId: string, clusterMemberIds: string[]): Promise<boolean> {
|
||||||
|
const allScores: Array<{ memberId: string; score: number }> = []
|
||||||
|
|
||||||
|
for (const memberId of clusterMemberIds) {
|
||||||
|
const score = await this.calculateMembershipScore(memberId, clusterMemberIds)
|
||||||
|
allScores.push({ memberId, score })
|
||||||
|
}
|
||||||
|
|
||||||
|
const meanScore = allScores.reduce((sum, s) => sum + s.score, 0) / allScores.length
|
||||||
|
const noteScore = allScores.find(s => s.memberId === noteId)?.score || 0
|
||||||
|
|
||||||
|
return noteScore >= meanScore
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save clustering results to database.
|
||||||
|
*/
|
||||||
|
async saveClusteringResults(
|
||||||
|
userId: string,
|
||||||
|
results: { clusters: ClusterResult[]; clusteredNotes: ClusteredNote[] }
|
||||||
|
): Promise<void> {
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
// Clear existing clusters for this user
|
||||||
|
await tx.$executeRawUnsafe(`DELETE FROM "ClusterMember" WHERE "userId" = $1`, userId)
|
||||||
|
await tx.$executeRawUnsafe(`DELETE FROM "NoteCluster" WHERE "userId" = $1`, userId)
|
||||||
|
|
||||||
|
// Insert new clusters
|
||||||
|
for (const cluster of results.clusters) {
|
||||||
|
await tx.noteCluster.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
clusterId: cluster.clusterId,
|
||||||
|
name: cluster.name,
|
||||||
|
noteCount: cluster.noteIds.length,
|
||||||
|
lastCalculated: new Date()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert cluster members
|
||||||
|
for (const clusteredNote of results.clusteredNotes) {
|
||||||
|
await tx.clusterMember.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
noteId: clusteredNote.noteId,
|
||||||
|
clusterId: clusteredNote.clusterId,
|
||||||
|
membershipScore: clusteredNote.membershipScore,
|
||||||
|
isCentral: clusteredNote.isCentral
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a name for a cluster using the LLM.
|
||||||
|
*/
|
||||||
|
async generateClusterName(clusterId: number, userId: string): Promise<string> {
|
||||||
|
const centralNotes = await prisma.$queryRawUnsafe<Array<{ noteId: string; title: string | null; content: string }>>(
|
||||||
|
`SELECT DISTINCT n.id AS "noteId", n.title, n.content
|
||||||
|
FROM "ClusterMember" cm
|
||||||
|
INNER JOIN "Note" n ON n.id = cm."noteId"
|
||||||
|
WHERE cm."clusterId" = $1
|
||||||
|
AND cm."userId" = $2
|
||||||
|
AND cm."isCentral" = true
|
||||||
|
LIMIT 5`,
|
||||||
|
clusterId,
|
||||||
|
userId
|
||||||
|
)
|
||||||
|
|
||||||
|
if (centralNotes.length === 0) {
|
||||||
|
return `Cluster ${clusterId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const notesText = centralNotes
|
||||||
|
.map((note, i) => `${i + 1}. "${note.title || 'Untitled'}" - ${note.content.slice(0, 100)}...`)
|
||||||
|
.join('\n')
|
||||||
|
|
||||||
|
const systemPrompt = 'You are a clustering assistant. Provide ONLY a concise name (2-4 words) in English. No punctuation, no explanation.'
|
||||||
|
|
||||||
|
const userPrompt = `Analyze these 5 notes that belong to the same cluster. What is the common theme?\n\n${notesText}\n\nTheme:`
|
||||||
|
|
||||||
|
try {
|
||||||
|
const config = await getSystemConfig()
|
||||||
|
const provider = getChatProvider(config)
|
||||||
|
const response = await provider.chat(
|
||||||
|
[{ role: 'user', content: userPrompt }],
|
||||||
|
systemPrompt
|
||||||
|
)
|
||||||
|
return response.text.trim().slice(0, 50)
|
||||||
|
} catch {
|
||||||
|
return `Cluster ${clusterId}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if recalculation is needed based on data change percentage.
|
||||||
|
*/
|
||||||
|
async shouldRecalculate(userId: string): Promise<boolean> {
|
||||||
|
const lastCluster = await prisma.noteCluster.findFirst({
|
||||||
|
where: { userId },
|
||||||
|
orderBy: { lastCalculated: 'desc' }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!lastCluster) return true
|
||||||
|
|
||||||
|
const modifiedCount = await prisma.note.count({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
OR: [
|
||||||
|
{ updatedAt: { gt: lastCluster.lastCalculated } },
|
||||||
|
{ contentUpdatedAt: { gt: lastCluster.lastCalculated } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const totalNotes = await prisma.note.count({
|
||||||
|
where: { userId, trashedAt: null }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (totalNotes === 0) return false
|
||||||
|
|
||||||
|
const changePercentage = modifiedCount / totalNotes
|
||||||
|
return changePercentage > 0.05
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cached clustering results if available and fresh.
|
||||||
|
*/
|
||||||
|
async getCachedClusters(userId: string): Promise<ClusterResult[] | null> {
|
||||||
|
const clusters = await prisma.noteCluster.findMany({
|
||||||
|
where: { userId },
|
||||||
|
orderBy: { clusterId: 'asc' }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (clusters.length === 0) return null
|
||||||
|
|
||||||
|
const needsUpdate = await this.shouldRecalculate(userId)
|
||||||
|
if (needsUpdate) return null
|
||||||
|
|
||||||
|
const result: ClusterResult[] = []
|
||||||
|
for (const cluster of clusters) {
|
||||||
|
const members = await prisma.clusterMember.findMany({
|
||||||
|
where: { clusterId: cluster.clusterId, userId },
|
||||||
|
select: { noteId: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
result.push({
|
||||||
|
clusterId: cluster.clusterId,
|
||||||
|
noteIds: members.map(m => m.noteId),
|
||||||
|
name: cluster.name || undefined
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const clusteringService = new ClusteringService()
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable: web clip source URL on notes
|
||||||
|
ALTER TABLE "Note" ADD COLUMN IF NOT EXISTS "sourceUrl" TEXT;
|
||||||
@@ -170,6 +170,8 @@ model Note {
|
|||||||
lastAiAnalysis DateTime?
|
lastAiAnalysis DateTime?
|
||||||
trashedAt DateTime?
|
trashedAt DateTime?
|
||||||
historyEnabled Boolean @default(false)
|
historyEnabled Boolean @default(false)
|
||||||
|
/// URL d'origine pour les clips web (Web Clipper)
|
||||||
|
sourceUrl String?
|
||||||
/// Illustration SVG (sanitized) for editorial feed thumbnail — optional, peut être généré par IA
|
/// Illustration SVG (sanitized) for editorial feed thumbnail — optional, peut être généré par IA
|
||||||
illustrationSvg String?
|
illustrationSvg String?
|
||||||
tsv Unsupported("tsvector")?
|
tsv Unsupported("tsvector")?
|
||||||
|
|||||||
232
memento-note/scripts/compare-dbscan.ts
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client'
|
||||||
|
import * as d3 from 'd3'
|
||||||
|
|
||||||
|
const prisma = new PrismaClient()
|
||||||
|
|
||||||
|
interface D3Node {
|
||||||
|
id: string
|
||||||
|
clusterId: string | number
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getCosineSimilarityDB(noteIdA: string, noteIdB: string): Promise<number> {
|
||||||
|
const result = await prisma.$queryRawUnsafe<Array<{ similarity: number }>>(
|
||||||
|
`SELECT 1 - (e1."embedding"::vector <=> e2."embedding"::vector) AS similarity
|
||||||
|
FROM "NoteEmbedding" e1, "NoteEmbedding" e2
|
||||||
|
WHERE e1."noteId" = $1 AND e2."noteId" = $2`,
|
||||||
|
noteIdA,
|
||||||
|
noteIdB
|
||||||
|
)
|
||||||
|
return result[0]?.similarity || 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateCosineSimilarityInMemory(vecA: number[], vecB: number[]): number {
|
||||||
|
let dotProduct = 0.0
|
||||||
|
let normA = 0.0
|
||||||
|
let normB = 0.0
|
||||||
|
const len = vecA.length
|
||||||
|
for (let i = 0; i < len; i++) {
|
||||||
|
const a = vecA[i]
|
||||||
|
const b = vecB[i]
|
||||||
|
dotProduct += a * b
|
||||||
|
normA += a * a
|
||||||
|
normB += b * b
|
||||||
|
}
|
||||||
|
if (normA === 0 || normB === 0) return 0
|
||||||
|
return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const user = await prisma.user.findFirst()
|
||||||
|
if (!user) return
|
||||||
|
const userId = user.id
|
||||||
|
|
||||||
|
// Fetch all user's notes with embeddings
|
||||||
|
const notesWithEmbeddings = await prisma.$queryRawUnsafe<Array<{ noteId: string }>>(
|
||||||
|
`SELECT ne."noteId"
|
||||||
|
FROM "NoteEmbedding" ne
|
||||||
|
INNER JOIN "Note" n ON n.id = ne."noteId"
|
||||||
|
WHERE n."userId" = $1
|
||||||
|
AND n."trashedAt" IS NULL
|
||||||
|
AND ne."embedding" IS NOT NULL`,
|
||||||
|
userId
|
||||||
|
)
|
||||||
|
const allNoteIds = notesWithEmbeddings.map(n => n.noteId)
|
||||||
|
|
||||||
|
// Fetch in-memory embeddings
|
||||||
|
const embeddingsRow = await prisma.$queryRawUnsafe<Array<{ noteId: string; embedding: string }>>(
|
||||||
|
`SELECT ne."noteId", ne."embedding"::text AS "embedding"
|
||||||
|
FROM "NoteEmbedding" ne
|
||||||
|
INNER JOIN "Note" n ON n.id = ne."noteId"
|
||||||
|
WHERE n."userId" = $1
|
||||||
|
AND n."trashedAt" IS NULL
|
||||||
|
AND ne."embedding" IS NOT NULL`,
|
||||||
|
userId
|
||||||
|
)
|
||||||
|
|
||||||
|
const embeddingMap = new Map<string, number[]>()
|
||||||
|
embeddingsRow.forEach(row => {
|
||||||
|
if (row.embedding) {
|
||||||
|
embeddingMap.set(row.noteId, JSON.parse(row.embedding))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(`Total notes with embeddings: ${allNoteIds.length}`)
|
||||||
|
|
||||||
|
// Compare single similarities
|
||||||
|
if (allNoteIds.length >= 2) {
|
||||||
|
const idA = allNoteIds[0]
|
||||||
|
const idB = allNoteIds[1]
|
||||||
|
const simDB = await getCosineSimilarityDB(idA, idB)
|
||||||
|
const simMem = calculateCosineSimilarityInMemory(embeddingMap.get(idA)!, embeddingMap.get(idB)!)
|
||||||
|
console.log(`Note A: ${idA}, Note B: ${idB}`)
|
||||||
|
console.log(`Similarity DB: ${simDB}`)
|
||||||
|
console.log(`Similarity Mem: ${simMem}`)
|
||||||
|
console.log(`Difference: ${Math.abs(simDB - simMem)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare neighbors
|
||||||
|
const epsilon = 0.3
|
||||||
|
const cosineDistance = 1 - epsilon
|
||||||
|
const seedId = allNoteIds[0]
|
||||||
|
|
||||||
|
// Neighbors DB
|
||||||
|
const neighborsDB = await prisma.$queryRawUnsafe<Array<{ noteId: string }>>(
|
||||||
|
`SELECT e2."noteId"
|
||||||
|
FROM "NoteEmbedding" e1
|
||||||
|
CROSS JOIN "NoteEmbedding" e2
|
||||||
|
WHERE e1."noteId" = $1
|
||||||
|
AND e2."noteId" != $1
|
||||||
|
AND e2."noteId" = ANY($2::text[])
|
||||||
|
AND (e1."embedding"::vector <=> e2."embedding"::vector) <= $3`,
|
||||||
|
seedId,
|
||||||
|
allNoteIds,
|
||||||
|
cosineDistance
|
||||||
|
)
|
||||||
|
const neighborsDBIds = neighborsDB.map(r => r.noteId)
|
||||||
|
|
||||||
|
// Neighbors Mem
|
||||||
|
const vecA = embeddingMap.get(seedId)!
|
||||||
|
const neighborsMemIds: string[] = []
|
||||||
|
embeddingMap.forEach((vecB, otherId) => {
|
||||||
|
if (otherId === seedId) return
|
||||||
|
const similarity = calculateCosineSimilarityInMemory(vecA, vecB)
|
||||||
|
const distance = 1 - similarity
|
||||||
|
if (distance <= cosineDistance) {
|
||||||
|
neighborsMemIds.push(otherId)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(`Seed Note: ${seedId}`)
|
||||||
|
console.log(`Neighbors DB count: ${neighborsDBIds.length}`)
|
||||||
|
console.log(`Neighbors Mem count: ${neighborsMemIds.length}`)
|
||||||
|
console.log(`Common neighbors: ${neighborsDBIds.filter(x => neighborsMemIds.includes(x)).length}`)
|
||||||
|
|
||||||
|
// Run DB-based clustering expandCluster
|
||||||
|
// We can see if there is any difference in cluster expandCluster output
|
||||||
|
console.log("\n=== DBSCAN Simulation ===");
|
||||||
|
const testEpsilons = [0.1, 0.15, 0.18, 0.2, 0.22, 0.25, 0.28, 0.3];
|
||||||
|
const minClusterSize = 2;
|
||||||
|
|
||||||
|
for (const eps of testEpsilons) {
|
||||||
|
const visited = new Set<string>();
|
||||||
|
const clustered = new Map<string, number>(); // noteId -> clusterId
|
||||||
|
const clusters: Array<{ clusterId: number; noteIds: string[] }> = [];
|
||||||
|
let clusterId = 0;
|
||||||
|
|
||||||
|
const findNeighbors = (noteId: string, currentEps: number): string[] => {
|
||||||
|
const vecA = embeddingMap.get(noteId);
|
||||||
|
if (!vecA) return [];
|
||||||
|
const neighbors: string[] = [];
|
||||||
|
|
||||||
|
// Let's check how epsilon is used.
|
||||||
|
// If epsilon is a cosine distance threshold, then distance <= eps.
|
||||||
|
// E.g., similarity >= 1 - eps.
|
||||||
|
// If epsilon is similarity threshold, then distance <= 1 - eps.
|
||||||
|
// Let's test both! We will test using eps as the actual cosine distance threshold.
|
||||||
|
embeddingMap.forEach((vecB, otherId) => {
|
||||||
|
if (otherId === noteId) return;
|
||||||
|
const similarity = calculateCosineSimilarityInMemory(vecA, vecB);
|
||||||
|
const distance = 1 - similarity;
|
||||||
|
if (distance <= currentEps) {
|
||||||
|
neighbors.push(otherId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return neighbors;
|
||||||
|
};
|
||||||
|
|
||||||
|
const expandCluster = (
|
||||||
|
noteId: string,
|
||||||
|
neighbors: string[],
|
||||||
|
cid: number,
|
||||||
|
currentEps: number
|
||||||
|
): string[] => {
|
||||||
|
const members: string[] = [noteId];
|
||||||
|
const queue = [...neighbors];
|
||||||
|
clustered.set(noteId, cid);
|
||||||
|
|
||||||
|
for (const neighborId of neighbors) {
|
||||||
|
if (clustered.get(neighborId) === undefined || clustered.get(neighborId) === -1) {
|
||||||
|
clustered.set(neighborId, cid);
|
||||||
|
if (!members.includes(neighborId)) members.push(neighborId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const currentNoteId = queue.shift()!;
|
||||||
|
|
||||||
|
if (!visited.has(currentNoteId)) {
|
||||||
|
visited.add(currentNoteId);
|
||||||
|
const currentNeighbors = findNeighbors(currentNoteId, currentEps);
|
||||||
|
|
||||||
|
if (currentNeighbors.length >= minClusterSize) {
|
||||||
|
for (const neighborId of currentNeighbors) {
|
||||||
|
const neighborCid = clustered.get(neighborId);
|
||||||
|
if (neighborCid === undefined || neighborCid === -1) {
|
||||||
|
clustered.set(neighborId, cid);
|
||||||
|
if (!members.includes(neighborId)) members.push(neighborId);
|
||||||
|
queue.push(neighborId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return members;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const noteId of allNoteIds) {
|
||||||
|
if (visited.has(noteId)) continue;
|
||||||
|
visited.add(noteId);
|
||||||
|
|
||||||
|
const neighbors = findNeighbors(noteId, eps);
|
||||||
|
if (neighbors.length < minClusterSize) {
|
||||||
|
clustered.set(noteId, -1);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const members = expandCluster(noteId, neighbors, clusterId, eps);
|
||||||
|
clusters.push({ clusterId, noteIds: members });
|
||||||
|
clusterId++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const noiseCount = Array.from(clustered.values()).filter(id => id === -1).length;
|
||||||
|
console.log(`Using epsilon (distance threshold) = ${eps}:`);
|
||||||
|
console.log(` -> Clusters generated: ${clusters.length}`);
|
||||||
|
clusters.forEach(c => {
|
||||||
|
console.log(` Cluster ${c.clusterId}: ${c.noteIds.length} notes`);
|
||||||
|
});
|
||||||
|
console.log(` -> Noise count: ${noiseCount}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("\n=== Calling Real Service in-memory ===");
|
||||||
|
const { clusteringService } = await import('../lib/ai/services/clustering.service');
|
||||||
|
const serviceResult = await clusteringService.clusterNotes(userId);
|
||||||
|
console.log(`Service generated ${serviceResult.clusters.length} clusters!`);
|
||||||
|
serviceResult.clusters.forEach(c => {
|
||||||
|
console.log(` -> Cluster ${c.clusterId} (${c.name || 'unnamed'}): ${c.noteIds.length} notes (Central notes: ${serviceResult.clusteredNotes.filter(cn => cn.clusterId === c.clusterId && cn.isCentral).length})`);
|
||||||
|
});
|
||||||
|
console.log(` -> Noise count: ${serviceResult.noiseCount}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(console.error).finally(() => prisma.$disconnect())
|
||||||
|
|
||||||
|
|
||||||
53
memento-note/scripts/test-parse.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client'
|
||||||
|
|
||||||
|
const prisma = new PrismaClient()
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const userId = "dev-user-id" // we will grab the first user
|
||||||
|
const user = await prisma.user.findFirst()
|
||||||
|
if (!user) {
|
||||||
|
console.log("No user found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Testing for user: ${user.email} (${user.id})`)
|
||||||
|
|
||||||
|
const rows = await prisma.$queryRawUnsafe<Array<{ noteId: string; embedding: string }>>(
|
||||||
|
`SELECT ne."noteId", ne."embedding"::text AS "embedding"
|
||||||
|
FROM "NoteEmbedding" ne
|
||||||
|
INNER JOIN "Note" n ON n.id = ne."noteId"
|
||||||
|
WHERE n."userId" = $1
|
||||||
|
AND n."trashedAt" IS NULL
|
||||||
|
AND ne."embedding" IS NOT NULL`,
|
||||||
|
user.id
|
||||||
|
)
|
||||||
|
|
||||||
|
console.log(`Fetched ${rows.length} embedding rows`)
|
||||||
|
|
||||||
|
let success = 0
|
||||||
|
let fail = 0
|
||||||
|
|
||||||
|
rows.forEach((row, i) => {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(row.embedding)
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
success++
|
||||||
|
if (i === 0) {
|
||||||
|
console.log(`Example vector size: ${parsed.length}, First few values: ${parsed.slice(0, 5)}`)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fail++
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
fail++
|
||||||
|
if (fail === 1) {
|
||||||
|
console.error("Failed example text:", row.embedding.slice(0, 100))
|
||||||
|
console.error(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(`Parsing results: Success=${success}, Fail=${fail}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(console.error).finally(() => prisma.$disconnect())
|
||||||