feat(insights): fix DBSCAN, Persian embeddings crash, D3 physics layouts, and D3 node not found runtime error
Some checks failed
CI / Lint, Test & Build (push) Failing after 1m7s
CI / Deploy production (on server) (push) Has been skipped

This commit is contained in:
Antigravity
2026-05-24 18:57:33 +00:00
parent e2672cd2c2
commit e881004c77
63 changed files with 5729 additions and 563 deletions

View File

@@ -1,6 +1,7 @@
{
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/0c6fb2d9-1b82-4ca3-b0f4-f8373a62faca/0c6fb2d9-1b82-4ca3-b0f4-f8373a62faca.jsonl": 1778182618469,
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/137b1f4b-59d9-4ce6-8d74-01f7cbae2ba7/137b1f4b-59d9-4ce6-8d74-01f7cbae2ba7.jsonl": 1778966645519,
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/16214191-7091-4aef-a309-f922d351d79f/16214191-7091-4aef-a309-f922d351d79f.jsonl": 1779646940751,
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/2e0ce74c-a31e-49d8-a0d0-a8b224813533/2e0ce74c-a31e-49d8-a0d0-a8b224813533.jsonl": 1778188935902,
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/38000361-5c66-4032-8e1e-ef405e843de0/38000361-5c66-4032-8e1e-ef405e843de0.jsonl": 1778968570815,
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/394af47d-c5cd-4cef-bef2-2192717439f8/394af47d-c5cd-4cef-bef2-2192717439f8.jsonl": 1778951280378,
@@ -10,10 +11,12 @@
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/65570f8a-5cd2-4573-b2d9-0983f2922d1f/65570f8a-5cd2-4573-b2d9-0983f2922d1f.jsonl": 1778231172346,
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/7b6c0ed0-caad-4157-b048-535452685b73/7b6c0ed0-caad-4157-b048-535452685b73.jsonl": 1778852401511,
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/8c2fc9f5-c359-4c67-a0f5-325ee44cebc9/8c2fc9f5-c359-4c67-a0f5-325ee44cebc9.jsonl": 1778751052502,
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/90c791ad-a274-4673-b5f6-ec1bccaccc98/90c791ad-a274-4673-b5f6-ec1bccaccc98.jsonl": 1779566465299,
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/92d73875-5939-48fb-9f68-86c88b0f2ff7/92d73875-5939-48fb-9f68-86c88b0f2ff7.jsonl": 1778966017038,
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/9902a438-467f-4d57-8f43-28e7d579a95f/9902a438-467f-4d57-8f43-28e7d579a95f.jsonl": 1778839341001,
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/a64d78ce-86d3-4ec8-8f79-7589ad05a62c/a64d78ce-86d3-4ec8-8f79-7589ad05a62c.jsonl": 1778846298067,
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/b85430f3-4520-47fd-9b4b-5200ca340a36/b85430f3-4520-47fd-9b4b-5200ca340a36.jsonl": 1779026409041,
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/af84066e-c0c2-435e-8caf-73037ebf4320/af84066e-c0c2-435e-8caf-73037ebf4320.jsonl": 1779569075175,
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/b85430f3-4520-47fd-9b4b-5200ca340a36/b85430f3-4520-47fd-9b4b-5200ca340a36.jsonl": 1779039005865,
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/ca85061e-6af9-4250-8dc7-9c3bb4839c48/ca85061e-6af9-4250-8dc7-9c3bb4839c48.jsonl": 1778849848444,
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/d92dfb04-c148-4a14-a48a-39d4c634caee/d92dfb04-c148-4a14-a48a-39d4c634caee.jsonl": 1778861502433,
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/e3745f62-c3b9-4a21-8942-71bc6f603f77/e3745f62-c3b9-4a21-8942-71bc6f603f77.jsonl": 1778018654221

View File

@@ -1,8 +1,8 @@
{
"version": 1,
"lastRunAtMs": 1779034436676,
"turnsSinceLastRun": 13,
"lastTranscriptMtimeMs": 1779034436585.3164,
"lastProcessedGenerationId": "fac69259-f459-4519-94aa-2b72a9453b24",
"lastRunAtMs": 1779646930753,
"turnsSinceLastRun": 6,
"lastTranscriptMtimeMs": 1779646930615.2869,
"lastProcessedGenerationId": "c2a9fd9d-5b50-42a8-8b17-e414b0be891e",
"trialStartedAtMs": null
}

View File

@@ -3,15 +3,15 @@
## Learned User Preferences
- Préfère les échanges en français, avec des explications détaillées et claires (éviter le jargon flou).
- Interface : tout libellé via i18n dans les 15 fichiers `memento-note/locales/*.json` (FR et EN comme références de contenu) ; éviter le texte en dur ; traductions **contextuelles** (sens produit, pas mot à mot — ex. « connecter votre propre fournisseur ») ; lors d'une traduction complète, mettre à jour toutes les locales concernées ; si l'utilisateur demande seulement les **clés i18n**, ajouter les clés (souvent EN/FR) sans remplir les 15 locales — il traduit souvent avec un autre modèle.
- Interface : tout libellé via i18n dans les 15 fichiers `memento-note/locales/*.json` (FR et EN comme références de contenu) ; éviter le texte en dur ; traductions **contextuelles** (sens produit, pas mot à mot — ex. « connecter votre propre fournisseur ») ; libellés FR **lisibles** (éviter jargon non expliqué : « wiki », « embed », etc.) et **aide contextuelle** où l'UX l'exige ; lors d'une traduction complète, mettre à jour toutes les locales concernées ; si l'utilisateur demande seulement les **clés i18n**, ajouter les clés (souvent EN/FR) sans remplir les 15 locales — il traduit souvent avec un autre modèle.
- Base de données : **INTERDIT TOTALEMENT** de lancer `prisma db push --force-reset`, `prisma migrate reset`, `DROP TABLE`, `TRUNCATE`, `pg_restore` avec clean, ou TOUTE commande qui vide/supprime des données — MÊME SI l'utilisateur est d'accord — sans avoir d'abord : (1) dumpé la base avec `bash /home/devparsa/dev/Momento/dump-db.sh`, (2) vérifié le dump fait au moins 1Mo, (3) obtenu un "OUI" explicite de l'utilisateur. **4 incidents de perte de données documentés (14/05, 15/05 x2, 16/05). NE JAMAIS REFAIRE ÇA.**
- Design produit : migration depuis `architectural-grid1` (base) et `architectural-grid` (prototype UI courant) ; **consulter le prototype avant toute implémentation UI** ; logique liste/carte puis contenu au clic ; parité actions liste/carte (menus « … », déplacer, génération SVG, etc.) ; contraste éditeur clair / sidebar sombre ; retirer thèmes obsolètes ; **pas de refresh/revalidation complets inutiles** (aligné prototype — mutations optimistes, pas de `revalidatePath` systématique ni resync depuis `initialNotes`) ; **Memory Echo en section inline dans l'éditeur** (pas l'ancienne modale) ; si l'utilisateur hésite entre variantes UX, **trancher pour le design prototype** plutôt que multiplier les toggles.
- Locale persane : dates en calendrier iranien (conversion), chiffres persans, et vérification RTL/positionnement global ; attention à ne pas confondre un nom de carnet (ex. « Persan ») avec le libellé de langue.
- Design produit : migration depuis `architectural-grid1` (base) et `architectural-grid` (prototype UI courant) ; **consulter le prototype avant toute implémentation UI** ; logique liste/carte puis contenu au clic ; parité actions liste/carte (menus « … », déplacer, génération SVG, etc.) ; contraste éditeur clair / sidebar sombre ; retirer thèmes obsolètes ; **pas de refresh/revalidation complets inutiles** (aligné prototype — mutations optimistes, pas de `revalidatePath` systématique ni resync depuis `initialNotes`) ; **Memory Echo en section inline dans l'éditeur** (pas l'ancienne modale) — similarité sur contenu **représentatif** (pas de troncature arbitraire type 200/800 car.) ; **recherche (sidebar / résultats, ex. flux « ouvrir la note ») et navigation liste des notes** (modes affichage, icônes vs initiales…) : suivre **`SearchModal` et les patterns actuels** dans `architectural-grid`, pas une approximation ou un ancien flux ; **`/insights` (insights sémantiques)** : suivre **`InsightsView.tsx` + graphe réseau associé dans le prototype** (ex. composition type `NetworkGraph.tsx`) ; **distincte de `/graph`** ; ne pas substituer par une UX « géométrique » décorative ou un regroupement par carnet hors spec prototype ; lorsque données clusters en retard ou partiellement périmées, **montrer létat dégradé exploitable plutôt quune page vide** ; si l'utilisateur hésite entre variantes UX, **trancher pour le design prototype** plutôt que multiplier les toggles.
- Locale persane : dates en calendrier iranien (conversion), chiffres persans, et vérification RTL/positionnement global ; **Memory Echo** et recherche sémantique doivent fonctionner en persan (RTL, embeddings — pas de contournement « EN only ») ; attention à ne pas confondre un nom de carnet (ex. « Persan ») avec le libellé de langue.
- Flux Excalidraw / diagrammes générés : accès via notification en plus d'une simple redirection ; priorité à la mise en page et au texte contenu dans les formes ; proposer des modes visuels (ex. coloré vs plus austère) tout en visant un rendu proche du style Excalidraw (polices, look).
- **Interdiction d'écrire des tests** sauf demande explicite ; en CI, seul `npm run test:unit` (`tests/unit/**`) — pas `tests/migration/` ; ne jamais générer de code superflu.
- Déploiement : privilégier le chemin rapide (artifact Next.js en CI + `Dockerfile.prebuilt`) ; CI/CD très robuste (pas d'image Docker obsolète en prod, pas de migrations/schéma DB via le workflow deploy) ; éviter les rebuild Docker complets inutiles (~15 min par itération) ; **ne pas pousser un déploiement quand des features clés sont inachevées** ; ne pas insister sur le déploiement tant que le produit n'est pas fini ou meilleur.
- Authentification : priorité à l'inscription/connexion via **Google OAuth** (plutôt qu'un compte email/mot de passe) ; exiger une vraie déconnexion (invalidation session/cookies — pas de reconnexion implicite, y compris en navigation privée).
- Priorité absolue à la qualité UX, même si l'implémentation est complexe (« je m'en fous si c'est complexe ») ; **ne jamais affirmer qu'un correctif ou une feature est fait sans vérification réelle** (app, prototype `architectural-grid`, ou test) — l'utilisateur sanctionne fermement les fausses déclarations.
- Priorité absolue à la qualité UX, même si l'implémentation est complexe (« je m'en fous si c'est complexe ») ; **ne jamais affirmer qu'un correctif ou une feature est fait sans vérification réelle** (app, prototype `architectural-grid`, ou test), **notamment navigation recherche/liste notes et vue `/insights` vs fichiers prototype** — l'utilisateur sanctionne fermement les fausses déclarations ; en frustration ou pour déléguer, **prévoir des prompts / briefs d'implémentation détaillés** (autre modèle ou dev), en plus des briefs outil de design.
- Livraison : **une user story à la fois**, tester et valider avec l'utilisateur avant la suivante ; suivi dans `docs/user-stories.md`.
- Quand demandé, **fournir des briefs pour un outil de design externe** plutôt que produire les maquettes UX soi-même.
@@ -28,4 +28,4 @@
- Migrations dans l'image prebuilt : `docker compose exec memento-note node ./node_modules/prisma/build/index.js migrate deploy` (pas `npx prisma` dans le PATH) ; helper `scripts/migrate-docker.sh`.
- Vérification deploy : `GET /api/build-info` (SHA Git) ; comparer `127.0.0.1:3000` et le domaine Cloudflare — purger le cache si versions divergent ; 403 sur `/api/manifest` côté domaine = souvent Cloudflare, pas l'app.
- Sync mutations notes entre composants : `memento-note/lib/note-change-sync.ts` (`emitNoteChange`, événement `NOTE_CHANGE_EVENT`).
- Roadmap / écart prototype vs prod : Web Clipper, Living Blocks (TipTap UniqueID), Structured Views, Flashcards IA (SM-2), Graph Knowledge Map — prototypes dans `architectural-grid/` (`ClipperSimulator.tsx`, `RevisionView.tsx`, `GraphKnowledgeMap.tsx`) ; en prod : `network-graph.tsx`, `note-document-info-panel.tsx`, `note-history-modal.tsx`, `rich-text-editor.tsx` (sans UniqueID pour l'instant).
- Roadmap / écart prototype vs prod : Web Clipper**`ClipperSimulator.tsx` = référence design uniquement** (pas de simulateur en prod à la place de l'extension) ; Living Blocks (`UniqueID` / embeds dans le prototype), Structured Views, Flashcards IA SM-2 (`RevisionView.tsx`), graphe knowledge (`GraphKnowledgeMap.tsx`), **insights sémantiques** (`InsightsView.tsx` + graphe réseau associé dans le prototype) — **`/insights``/graph`** ; prod : extension navigateur **`memento-note/extension/`** v0.2 **Side Panel** (mode sélection : popup Chrome se ferme au clic page — limitation plateforme) ; **`host_permissions` / origins** couvrant l'URL serveur y compris **LAN** ; **URL serveur configurable** dans les paramètres extension en dev ; cookies/session alignés avec l'instance cible ; publication **Chrome Web Store** : icônes 16/48/128, privacy policy, `host_permissions` prod restreints vs build dev ; `network-graph.tsx`, `/insights`, `note-document-info-panel.tsx`, `note-history-modal.tsx`, `rich-text-editor.tsx`.

Binary file not shown.

After

Width:  |  Height:  |  Size: 249 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 370 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 933 KiB

View File

@@ -12,11 +12,11 @@
| **US-SIDEBAR** | Sidebar deux colonnes (rail d'icônes + panneau) | ✅ **LIVRÉ** | `sidebar.tsx` restructuré |
| **US-SEARCH** | Recherche Globale Dual-Panel + Ctrl+K | ✅ **LIVRÉ** | `search-modal.tsx`, `search-modal-context.tsx`, bug `openNote` corrigé |
| **US-LIVING-BLOCKS** | Blocs Vivants (Transclusion Bidirectionnelle) | ✅ **LIVRÉ** | `tiptap-unique-id-extension.ts`, `tiptap-live-block-extension.tsx`, `block-picker.tsx`, `app/api/blocks/*`, migration `LiveBlockRef` |
| **US-MEMORY-ECHO** | Résonance Sémantique + Embed depuis Echo | 🚧 **En cours** | `memory-echo-section.tsx`, `/api/notes/[id]/live-block-refs`, `/api/blocks/resolve` |
| **US-INFO-RÉSEAU** | Panneau Info + Réseau Local | ⏳ À faire | — |
| **US-CLIPPER** | Web Clipper | ⏳ À faire | — |
| **US-GRAPH** | Graphe de Connaissance Global enrichi | ⏳ À faire | — |
| **US-INSIGHTS** | Clusters Sémantiques + Bridge Notes | ⏳ À faire | — |
| **US-MEMORY-ECHO** | Résonance Sémantique + Embed depuis Echo | **LIVRÉ** | `memory-echo-section.tsx`, `/api/notes/[id]/live-block-refs`, `/api/blocks/resolve` |
| **US-INFO-RÉSEAU** | Panneau Info + Réseau Local | **LIVRÉ** | `note-network-tab.tsx`, `sync-note-links.ts`, migration `NoteLink`, picker `[[` |
| **US-CLIPPER** | Web Clipper | 🚧 **En cours** | `extension/`, `/api/clip/*`, migration `sourceUrl`, badge panneau Info |
| **US-GRAPH** | Graphe de Connaissance Global enrichi | **LIVRÉ** | `note-graph-view.tsx` — filtres liens, seuil sémantique, focus voisinage, couleurs carnets, double-clic ouverture |
| **US-INSIGHTS** | Clusters Sémantiques + Bridge Notes | 🚧 **EN COURS** | clusters en base mais page masquait les résultats périmés — correction affichage |
| **US-TEMPORAL** | Prédictions d'accès temporelles | ⏳ À faire | — |
| **US-FLASHCARDS** | Révision IA — Répétition espacée SM-2 | ⏳ À faire | — |
| **US-STRUCTURED-VIEWS** | Vues Structurées (Tableau/Kanban/Galerie) | ⏳ À faire | — |

View File

@@ -1,10 +1,23 @@
'use client'
import { useState, useEffect } from 'react'
import { useState, useEffect, useMemo } from 'react'
import { NetworkGraph } from '@/components/network-graph'
import { useRouter } from 'next/navigation'
import { motion } from 'motion/react'
import { Sparkles, RefreshCw, Layers, Trophy, Zap, Lightbulb } from 'lucide-react'
import { motion, AnimatePresence } from 'motion/react'
import {
Sparkles,
RefreshCw,
Layers,
Trophy,
Zap,
Lightbulb,
Sliders,
CheckCircle2,
Clock,
AlertCircle,
ChevronRight,
Database,
} from 'lucide-react'
interface Note {
id: string
@@ -53,21 +66,51 @@ export default function InsightsPage() {
const [suggestions, setSuggestions] = useState<BridgeSuggestion[]>([])
const [loading, setLoading] = useState(true)
const [isCalculating, setIsCalculating] = useState(false)
const [isReindexing, setIsReindexing] = useState(false)
const [embeddingStats, setEmbeddingStats] = useState<{ indexed: number; total: number } | null>(null)
const [isStale, setIsStale] = useState(false)
const [selectedClusterId, setSelectedClusterId] = useState<string | null>(null)
const [viewMode, setViewMode] = useState<'graph' | 'dashboard'>('dashboard')
const [lastSyncTime, setLastSyncTime] = useState<string>('')
useEffect(() => {
loadInitialData()
}, [])
// ─── Données calculées ───────────────────────────────────────────────────────
const selectedCluster = useMemo(
() => clusters.find(c => c.id === selectedClusterId) ?? null,
[clusters, selectedClusterId]
)
const selectedClusterNotes = useMemo(
() => (selectedCluster ? notes.filter(n => selectedCluster.noteIds.includes(n.id)) : []),
[notes, selectedCluster]
)
const isolatedClusters = useMemo(() => {
const networkedIds = new Set(
bridgeNotes.flatMap(b => b.clustersConnected.map(cid => String(cid)))
)
return clusters.filter(c => !networkedIds.has(c.id))
}, [clusters, bridgeNotes])
const bridgeList = useMemo(
() => bridgeNotes.map(b => ({ ...b, title: b.note?.title || 'Note sans titre' })),
[bridgeNotes]
)
// ─── Chargement initial ──────────────────────────────────────────────────────
const loadInitialData = async () => {
setLoading(true)
try {
// First, try to get cached clusters
const res = await fetch('/api/clusters')
if (res.ok) {
const data = await res.json()
// Check if we have clusters
if (data.clusters && data.clusters.length > 0) {
if (data.clusters?.length > 0) {
const clustersWithColors = data.clusters.map((c: Cluster, i: number) => ({
...c,
id: c.clusterId.toString(),
@@ -75,26 +118,35 @@ export default function InsightsPage() {
}))
setNotes(data.notes || [])
setClusters(clustersWithColors)
setIsStale(!!data.stale)
// Load bridge notes
const bridgeRes = await fetch('/api/bridge-notes?details=true')
if (bridgeRes.ok) {
const bridgeData = await bridgeRes.json()
setBridgeNotes(bridgeData.bridgeNotes || [])
// Bridge notes incluses dans la réponse GET /clusters (enrichies)
if (data.bridgeNotes?.length > 0) {
setBridgeNotes(data.bridgeNotes)
} else {
const bridgeRes = await fetch('/api/bridge-notes?details=true')
if (bridgeRes.ok) {
const bridgeData = await bridgeRes.json()
setBridgeNotes(bridgeData.bridgeNotes || [])
}
}
// Load suggestions
const suggestionsRes = await fetch('/api/bridge-notes/suggestions')
if (suggestionsRes.ok) {
const suggestionsData = await suggestionsRes.json()
setSuggestions(suggestionsData.suggestions || [])
}
setLastSyncTime(
new Date().toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })
)
if (typeof data.embeddingCount === 'number' && typeof data.totalNotes === 'number') {
setEmbeddingStats({ indexed: data.embeddingCount, total: data.totalNotes })
}
} else {
// No clusters - trigger calculation if we have enough notes
if (data.totalNotes >= 10) {
await performAnalysis()
} else {
// Not enough notes - show empty state
setIsStale(false)
if (typeof data.embeddingCount === 'number' && typeof data.totalNotes === 'number') {
setEmbeddingStats({ indexed: data.embeddingCount, total: data.totalNotes })
}
}
}
@@ -105,6 +157,29 @@ export default function InsightsPage() {
}
}
const handleReindexEmbeddings = async () => {
setIsReindexing(true)
try {
const res = await fetch('/api/notes/reindex', { method: 'POST' })
if (!res.ok) throw new Error('reindex failed')
const data = await res.json()
setEmbeddingStats(prev => ({
indexed: data.count ?? prev?.indexed ?? 0,
total: data.total ?? prev?.total ?? notes.length,
}))
setLastSyncTime(
new Date().toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })
)
setIsStale(true)
} catch (error) {
console.error('Error reindexing embeddings:', error)
} finally {
setIsReindexing(false)
}
}
// ─── Analyse (POST) ──────────────────────────────────────────────────────────
const performAnalysis = async () => {
setIsCalculating(true)
try {
@@ -124,13 +199,23 @@ export default function InsightsPage() {
setNotes(data.notes || [])
setClusters(clustersWithColors)
setBridgeNotes(data.bridgeNotes || [])
setIsStale(false)
// Load suggestions (they were generated during POST)
const suggestionsRes = await fetch('/api/bridge-notes/suggestions')
if (suggestionsRes.ok) {
const suggestionsData = await suggestionsRes.json()
setSuggestions(suggestionsData.suggestions || [])
}
setLastSyncTime(
new Date().toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })
)
if (data.notes?.length) {
setEmbeddingStats(prev => ({
indexed: prev?.indexed ?? data.notes.length,
total: data.notes.length,
}))
}
}
} catch (error) {
console.error('Error running analysis:', error)
@@ -140,131 +225,420 @@ export default function InsightsPage() {
}
const handleNoteClick = (noteId: string) => {
router.push(`/home?note=${noteId}`)
router.push(`/home?openNote=${noteId}`)
}
const bridgeList = bridgeNotes.map(b => ({
...b,
title: b.note?.title || 'Unknown Note'
}))
// ─── Rendu ───────────────────────────────────────────────────────────────────
return (
<div className="h-full flex flex-col bg-paper 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">
<div className="h-full flex flex-col bg-[#F9F8F6] dark:bg-[#0D0D0D] overflow-hidden">
{/* ── 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 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} />
</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>
<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 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
onClick={() => setViewMode('graph')}
className={`px-3 py-1.5 text-[10px] font-bold uppercase tracking-wider rounded-lg transition-all ${
viewMode === 'graph'
? 'bg-white dark:bg-black text-ink dark:text-dark-ink shadow-sm'
: 'text-concrete'
}`}
>
Réseau
</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>
</div>
<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>
<button
onClick={performAnalysis}
disabled={isCalculating}
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"
>
{isCalculating ? <RefreshCw size={14} className="animate-spin" /> : <RefreshCw size={14} />}
{isCalculating ? 'Mapping...' : 'Re-sync Network'}
</button>
</div>
{/* Loading State */}
{/* ── Chargement ── */}
{loading && (
<div className="flex-1 flex items-center justify-center">
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
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>
<p className="text-concrete">Analyzing your notes...</p>
<div className="animate-spin rounded-full h-10 w-10 border-b-2 border-ochre mx-auto" />
<p className="text-sm text-concrete">Chargement de vos clusters...</p>
</motion.div>
</div>
)}
{/* Empty State - only if truly no notes */}
{/* ── État vide ── */}
{!loading && clusters.length === 0 && !isCalculating && (
<div className="flex-1 flex items-center justify-center">
<div className="text-center max-w-md">
<div className="w-24 h-24 rounded-full bg-concrete/10 flex items-center justify-center mx-auto mb-6">
<Sparkles size={40} className="text-concrete/40" />
<motion.div
initial={{ opacity: 0, y: 20 }}
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>
<h3 className="text-xl font-serif font-medium text-ink dark:text-dark-ink mb-2">
Discover your knowledge clusters
<h3 className="text-xl font-serif font-medium text-ink dark:text-dark-ink mb-3">
Vos notes forment des thèmes
</h3>
<p className="text-concrete mb-6">
Click "Re-sync Network" to analyze your notes and find hidden connections
<p className="text-sm text-concrete leading-relaxed mb-6">
Cliquez sur &ldquo;Re-analyser&rdquo; pour découvrir les groupes sémantiques de vos notes
et les connexions cachées entre vos idées.
</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>
)}
{/* Main Content */}
{!loading && clusters.length > 0 && (
<div className="flex-1 flex overflow-hidden">
{/* Left: Graph View */}
<div className="flex-[1.5] p-6 relative">
{/* ── Calcul en cours ── */}
{isCalculating && !loading && (
<div className="flex-1 flex items-center justify-center">
<motion.div
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
notes={notes}
clusters={clusters}
bridgeNotes={bridgeNotes}
onNoteSelect={handleNoteClick}
selectedClusterId={selectedClusterId}
onClusterSelect={setSelectedClusterId}
/>
</div>
{/* Right: Insight Dashboard */}
<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 className="p-8 flex-1 overflow-y-auto custom-scrollbar space-y-12">
{/* Stats Summary */}
{/* ── Dashboard (droite) ── */}
<div
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 ${
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 é 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="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">
<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-2xl font-serif font-semibold text-ink dark:text-dark-ink">
{clusters.length}
</div>
<p className="text-[9px] text-concrete font-medium uppercase mt-1">
Détectés sans à priori
</p>
</div>
<div className="text-3xl font-serif font-medium 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">
<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">
<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&apos;idées
</p>
</div>
<div className="text-3xl font-serif font-medium text-ink dark:text-dark-ink">{bridgeNotes.length}</div>
</div>
</div>
{/* Bridge Notes Section */}
<section>
<div className="flex items-center gap-2 mb-6 px-1">
{/* ③ Système de Recalcul */}
<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 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&nbsp;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&apos;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" />
<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 className="space-y-3">
{bridgeList.map((bridge) => (
{bridgeList.map(bridge => (
<motion.div
key={bridge.noteId}
whileHover={{ x: 4 }}
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">
<h4 className="text-sm font-medium text-ink dark:text-dark-ink truncate flex-1">
<div className="flex items-center justify-between mb-2 gap-4">
<h4 className="text-xs font-semibold text-ink dark:text-dark-ink truncate flex-1 group-hover:text-ochre transition-colors">
{bridge.title}
</h4>
<span className="text-[10px] font-bold text-ochre bg-ochre/10 px-2 py-0.5 rounded-full">
Score: {(bridge.bridgeScore * 100).toFixed(0)}%
<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">
Lien : {(bridge.bridgeScore * 100).toFixed(0)}%
</span>
</div>
<div className="flex items-center gap-2">
{bridge.clusterNames?.map((name, i) => {
const cluster = clusters.find(c => c.name === name)
<div className="flex flex-wrap gap-1.5 pt-1.5 border-t border-black/5 dark:border-white/5">
{bridge.clustersConnected.map(cid => {
const cluster = clusters.find(c => c.id === String(cid))
return (
<div key={i} className="flex items-center gap-1">
<div className="w-1.5 h-1.5 rounded-full" style={{ backgroundColor: cluster?.color || '#cbd5e1' }} />
<span className="text-[9px] text-concrete font-medium whitespace-nowrap">{name}</span>
<div
key={cid}
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>
)
})}
@@ -272,61 +646,76 @@ export default function InsightsPage() {
</motion.div>
))}
{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&apos;a é détectée. Créez des notes
transversales pour forger de nouveaux liens créatifs.
</div>
)}
</div>
</section>
{/* Connection Suggestions */}
<section>
<div className="flex items-center gap-2 mb-6 px-1">
{/* ⑥ Opportunités de Connexion */}
<section className="space-y-4">
<div className="flex items-center gap-2 px-1">
<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 className="space-y-4">
{suggestions.map((s, idx) => (
<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 className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-3 mb-4">
<div className="flex -space-x-2">
<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-6 h-6 rounded-full border-2 border-paper bg-ochre flex items-center justify-center text-[10px] text-white">B</div>
</div>
<span className="text-[9px] font-bold uppercase tracking-widest text-indigo-500/60">
Bridging {s.clusterAName} & {s.clusterBName}
</span>
{suggestions.map(s => (
<div
key={`${s.clusterAId}-${s.clusterBId}`}
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 -space-x-2 shrink-0">
<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">
A
</div>
<h4 className="text-base font-serif font-medium text-ink dark:text-dark-ink mb-2">{s.suggestedTitle}</h4>
<p className="text-xs text-muted-ink leading-relaxed mb-4">{s.suggestedContent}</p>
<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">
<Zap size={12} className="shrink-0" />
<span>{s.justification}</span>
<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">
B
</div>
</div>
<span className="text-[9px] font-bold uppercase tracking-wider text-indigo-500/70 truncate">
Relier {s.clusterAName} & {s.clusterBName}
</span>
</div>
<h4 className="text-sm font-semibold text-ink dark:text-dark-ink mb-2">
{s.suggestedTitle}
</h4>
<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>
</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 && (
<div className="animate-pulse space-y-4">
{[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>
)}
{!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>
</section>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -7,6 +7,7 @@ import { auth } from '@/auth'
import { getAIProvider } from '@/lib/ai/factory'
import { parseNote, getHashColor } from '@/lib/utils'
import { upsertNoteEmbedding } from '@/lib/embeddings'
import { embeddingService } from '@/lib/ai/services/embedding.service'
import { syncNoteLinksForNote } from '@/lib/notes/sync-note-links'
import { getSystemConfig, getConfigNumber, getConfigBoolean, SEARCH_DEFAULTS } from '@/lib/config'
import { contextualAutoTagService } from '@/lib/ai/services/contextual-auto-tag.service'
@@ -122,7 +123,7 @@ async function syncLabels(userId: string, noteLabels: string[] = [], notebookId?
/** Sync both Note.labels (JSON) AND labelRelations for a single note.
* Also cleans up orphan labels in the same notebook scope. */
async function syncNoteLabels(noteId: string, labelNames: string[], notebookId: string | null, userId: string) {
export async function syncNoteLabels(noteId: string, labelNames: string[], notebookId: string | null, userId: string) {
const uniqueNames = [...new Set(labelNames.map(n => n.trim()).filter(Boolean))]
const labelRows = await syncLabels(userId, uniqueNames, notebookId)
const labelIds = labelRows.map(l => l.id)
@@ -444,9 +445,7 @@ export async function createNote(data: {
// Use setImmediate-like pattern to not block the response
; (async () => {
try {
const bgConfig = await getSystemConfig()
const provider = getAIProvider(bgConfig)
const embedding = await provider.getEmbeddings(content)
const { embedding } = await embeddingService.generateNoteEmbedding(data.title, content)
if (embedding) {
await upsertNoteEmbedding(noteId, embedding)
}
@@ -574,10 +573,10 @@ export async function updateNote(id: string, data: {
if (data.content !== undefined) {
const noteId = id
const content = data.content;
const title = data.title !== undefined ? data.title : oldNote?.title ?? null;
(async () => {
try {
const provider = getAIProvider(await getSystemConfig());
const embedding = await provider.getEmbeddings(content);
const { embedding } = await embeddingService.generateNoteEmbedding(title, content)
if (embedding) {
await upsertNoteEmbedding(noteId, embedding);
}
@@ -928,11 +927,10 @@ export async function syncAllEmbeddings() {
noteEmbedding: { is: null }
}
})
const provider = getAIProvider(await getSystemConfig());
for (const note of notesToSync) {
if (!note.content) continue;
try {
const embedding = await provider.getEmbeddings(note.content);
const { embedding } = await embeddingService.generateNoteEmbedding(note.title, note.content)
if (embedding) {
await upsertNoteEmbedding(note.id, embedding)
updatedCount++;

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

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

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

View File

@@ -7,6 +7,7 @@ import { bridgeNotesService } from '@/lib/ai/services/bridge-notes.service'
/**
* GET /api/clusters
* Get all clusters for the current user.
* Returns cached clusters + bridge notes enriched with cluster names.
*/
export async function GET(request: NextRequest) {
try {
@@ -17,9 +18,10 @@ export async function GET(request: NextRequest) {
const userId = session.user.id
// Check for cached results
const cached = await clusteringService.getCachedClusters(userId)
if (cached) {
// Check for stored results (even if stale/périmés)
const stored = await clusteringService.getStoredClusters(userId)
if (stored) {
const cached = stored.clusters
// Fetch notes with their cluster assignments
const notes = await prisma.note.findMany({
where: { userId, trashedAt: null },
@@ -29,20 +31,65 @@ export async function GET(request: NextRequest) {
// Get cluster member mappings
const clusterMembers = await prisma.clusterMember.findMany({
where: { userId },
select: { noteId: true, clusterId: true }
select: { noteId: true, clusterId: true, isCentral: true }
})
const noteClusterMap = new Map(clusterMembers.map(cm => [cm.noteId, cm.clusterId]))
const notesWithClusters = notes.map(n => ({
...n,
clusterId: noteClusterMap.get(n.id)
}))
const noteClusterMap = new Map(clusterMembers.map(cm => [cm.noteId, { clusterId: cm.clusterId, isCentral: cm.isCentral }]))
const notesWithClusters = notes.map(n => {
const mapping = noteClusterMap.get(n.id)
return {
...n,
clusterId: mapping?.clusterId,
isCentral: mapping?.isCentral || false
}
})
// Fetch bridge notes with enrichment (cluster names + note details)
const bridgeNotesData = await prisma.bridgeNote.findMany({
where: { userId },
orderBy: { bridgeScore: 'desc' },
take: 10
})
let enrichedBridgeNotes: object[] = []
if (bridgeNotesData.length > 0) {
const bridgeNoteIds = bridgeNotesData.map(b => b.noteId)
const bridgeNoteDetails = await prisma.note.findMany({
where: { id: { in: bridgeNoteIds } },
select: { id: true, title: true, content: true }
})
const bridgeNoteDetailsMap = new Map(bridgeNoteDetails.map(n => [n.id, n]))
enrichedBridgeNotes = bridgeNotesData.map(b => {
const clustersConnected = JSON.parse(b.clustersConnected) as number[]
return {
noteId: b.noteId,
bridgeScore: b.bridgeScore,
clustersConnected,
clusterNames: clustersConnected.map(
cid => cached.find(c => c.clusterId === cid)?.name || `Cluster ${cid}`
),
note: bridgeNoteDetailsMap.get(b.noteId)
}
})
}
const embeddingCountRow = await prisma.$queryRawUnsafe<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({
clusters: cached,
notes: notesWithClusters,
bridgeNotes: enrichedBridgeNotes,
cached: true,
totalNotes: cached.reduce((sum, c) => sum + c.noteIds.length, 0)
stale: stored.stale,
lastCalculated: stored.lastCalculated,
totalNotes: notes.length,
embeddingCount: Number(embeddingCountRow[0]?.count || 0),
})
}
@@ -61,6 +108,7 @@ export async function GET(request: NextRequest) {
return NextResponse.json({
clusters: [],
notes: [],
bridgeNotes: [],
totalNotes: notesCount,
embeddingCount: Number(embeddingCount[0]?.count || 0),
needsCalculation: true
@@ -77,6 +125,7 @@ export async function GET(request: NextRequest) {
/**
* POST /api/clusters
* Trigger a full recalculation of clusters and bridge notes.
* Returns clusters + bridge notes enriched for immediate display.
*/
export async function POST(request: NextRequest) {
try {
@@ -86,66 +135,77 @@ export async function POST(request: NextRequest) {
}
const userId = session.user.id
const body = await request.json().catch(() => ({}))
const force = Boolean(body?.force)
// Use the PROPER clustering service (DBSCAN algorithm)
// 0. Indexer / réindexer les embeddings (texte complet, multi-chunks)
await clusteringService.ensureEmbeddings(userId, { force: force || true })
// 1. Run DBSCAN clustering
const results = await clusteringService.clusterNotes(userId)
if (results.clusters.length === 0) {
return NextResponse.json({
clusters: [],
bridgeNotes: [],
message: 'Could not generate clusters. Notes may be too diverse or not enough.',
noiseCount: results.noiseCount
})
}
// Generate cluster names using AI
// 2. Generate cluster names with AI
for (const cluster of results.clusters) {
cluster.name = await clusteringService.generateClusterName(cluster.clusterId, userId)
}
// Save clustering results
// 3. Save clustering results
await clusteringService.saveClusteringResults(userId, results)
// Detect and save bridge notes
// 4. Detect and save bridge notes
const bridgeNotes = await bridgeNotesService.detectBridgeNotes(userId)
await bridgeNotesService.saveBridgeNotes(userId, bridgeNotes)
// Generate and save bridge suggestions
// 5. Generate and save bridge suggestions
const suggestions = await bridgeNotesService.generateBridgeSuggestions(userId)
await bridgeNotesService.saveBridgeSuggestions(userId, suggestions)
// Fetch notes with their cluster assignments
// 6. Fetch notes with cluster assignments
const notes = await prisma.note.findMany({
where: { userId, trashedAt: null },
select: { id: true, title: true, content: true }
})
// Get cluster member mappings
const clusterMembers = await prisma.clusterMember.findMany({
where: { userId },
select: { noteId: true, clusterId: true }
select: { noteId: true, clusterId: true, isCentral: true }
})
const noteClusterMap = new Map(clusterMembers.map(cm => [cm.noteId, cm.clusterId]))
const notesWithClusters = notes.map(n => ({
...n,
clusterId: noteClusterMap.get(n.id)
}))
const noteClusterMap = new Map(clusterMembers.map(cm => [cm.noteId, { clusterId: cm.clusterId, isCentral: cm.isCentral }]))
const notesWithClusters = notes.map(n => {
const mapping = noteClusterMap.get(n.id)
return {
...n,
clusterId: mapping?.clusterId,
isCentral: mapping?.isCentral || false
}
})
// Get enriched bridge notes with note details
// 7. Enrich bridge notes with cluster names + note details
const noteMap = new Map(notes.map(n => [n.id, n]))
const enrichedBridgeNotes = bridgeNotes.slice(0, 10).map(b => ({
noteId: b.noteId,
bridgeScore: b.bridgeScore,
clustersConnected: b.clustersConnected,
clusterNames: b.clusterNames,
note: notes.find(n => n.id === b.noteId)
note: noteMap.get(b.noteId)
}))
return NextResponse.json({
clusters: results.clusters,
notes: notesWithClusters,
bridgeNotes: enrichedBridgeNotes,
totalNotes: results.clusters.reduce((sum, c) => sum + c.noteIds.length, 0) + results.noiseCount,
totalNotes:
results.clusters.reduce((sum, c) => sum + c.noteIds.length, 0) + results.noiseCount,
noiseCount: results.noiseCount,
message: `Generated ${results.clusters.length} clusters with ${bridgeNotes.length} bridge notes`
})

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

View File

@@ -24,7 +24,7 @@ export async function POST(req: NextRequest) {
for (let i = 0; i < notes.length; i += BATCH_SIZE) {
const batch = notes.slice(i, i + BATCH_SIZE)
const results = await Promise.allSettled(
batch.map(note => semanticSearchService.indexNote(note.id))
batch.map(note => semanticSearchService.indexNote(note.id, { force: true }))
)
for (const r of results) {

View File

@@ -1187,6 +1187,113 @@ html.font-system * {
border-end-end-radius: 4px;
}
/* --- Clipped RTL content (persan, arabe) --- */
.notion-editor-wrapper .ProseMirror .clip-article--rtl,
.notion-editor-wrapper .ProseMirror [dir='rtl'],
.fullpage-editor .ProseMirror .clip-article--rtl,
.fullpage-editor .ProseMirror [dir='rtl'] {
direction: rtl;
text-align: right;
font-family: 'Vazirmatn', var(--font-sans), sans-serif !important;
line-height: 1.85;
}
.notion-editor-wrapper .ProseMirror p[dir='rtl'],
.notion-editor-wrapper .ProseMirror h1[dir='rtl'],
.notion-editor-wrapper .ProseMirror h2[dir='rtl'],
.notion-editor-wrapper .ProseMirror h3[dir='rtl'],
.notion-editor-wrapper .ProseMirror blockquote[dir='rtl'],
.fullpage-editor .ProseMirror p[dir='rtl'],
.fullpage-editor .ProseMirror h1[dir='rtl'],
.fullpage-editor .ProseMirror h2[dir='rtl'],
.fullpage-editor .ProseMirror h3[dir='rtl'],
.fullpage-editor .ProseMirror blockquote[dir='rtl'] {
direction: rtl;
text-align: right;
font-family: 'Vazirmatn', var(--font-sans), sans-serif !important;
line-height: 1.85;
}
.notion-editor-wrapper .ProseMirror .clip-article--rtl p,
.notion-editor-wrapper .ProseMirror .clip-article--rtl li,
.notion-editor-wrapper .ProseMirror .clip-article--rtl h1,
.notion-editor-wrapper .ProseMirror .clip-article--rtl h2,
.notion-editor-wrapper .ProseMirror .clip-article--rtl h3,
.fullpage-editor .ProseMirror .clip-article--rtl p,
.fullpage-editor .ProseMirror .clip-article--rtl li,
.fullpage-editor .ProseMirror .clip-article--rtl h1,
.fullpage-editor .ProseMirror .clip-article--rtl h2,
.fullpage-editor .ProseMirror .clip-article--rtl h3 {
direction: rtl;
text-align: right;
font-family: 'Vazirmatn', var(--font-sans), sans-serif !important;
}
/* Titres d'articles dans les listes (structure BBC Persian : ul > li > h2) */
.notion-editor-wrapper .ProseMirror .clip-article--rtl li > h1,
.notion-editor-wrapper .ProseMirror .clip-article--rtl li > h2,
.notion-editor-wrapper .ProseMirror .clip-article--rtl li > h3,
.notion-editor-wrapper .ProseMirror li[dir='rtl'] > h1,
.notion-editor-wrapper .ProseMirror li[dir='rtl'] > h2,
.notion-editor-wrapper .ProseMirror li[dir='rtl'] > h3,
.fullpage-editor .ProseMirror .clip-article--rtl li > h1,
.fullpage-editor .ProseMirror .clip-article--rtl li > h2,
.fullpage-editor .ProseMirror .clip-article--rtl li > h3,
.fullpage-editor .ProseMirror li[dir='rtl'] > h1,
.fullpage-editor .ProseMirror li[dir='rtl'] > h2,
.fullpage-editor .ProseMirror li[dir='rtl'] > h3 {
direction: rtl;
text-align: right;
font-family: 'Vazirmatn', var(--font-sans), sans-serif !important;
letter-spacing: 0;
}
.notion-editor-wrapper .ProseMirror .clip-article--rtl blockquote,
.fullpage-editor .ProseMirror .clip-article--rtl blockquote {
border-inline-start: none;
border-inline-end: 3px solid var(--primary);
padding-inline-start: 0;
padding-inline-end: 1em;
}
/* RTL lists — puces à droite, texte aligné */
.notion-editor-wrapper .ProseMirror .clip-article--rtl ul,
.notion-editor-wrapper .ProseMirror .clip-article--rtl ol,
.fullpage-editor .ProseMirror .clip-article--rtl ul,
.fullpage-editor .ProseMirror .clip-article--rtl ol,
.notion-editor-wrapper .ProseMirror ul[dir='rtl'],
.notion-editor-wrapper .ProseMirror ol[dir='rtl'],
.fullpage-editor .ProseMirror ul[dir='rtl'],
.fullpage-editor .ProseMirror ol[dir='rtl'],
.notion-editor-wrapper .ProseMirror ul:has(> li[dir='rtl']),
.notion-editor-wrapper .ProseMirror ol:has(> li[dir='rtl']),
.fullpage-editor .ProseMirror ul:has(> li[dir='rtl']),
.fullpage-editor .ProseMirror ol:has(> li[dir='rtl']) {
direction: rtl;
text-align: right;
marker-side: match-parent;
padding-inline-start: 0;
padding-inline-end: 1.5rem;
list-style-position: outside;
}
.notion-editor-wrapper .ProseMirror .clip-article--rtl li,
.notion-editor-wrapper .ProseMirror li[dir='rtl'],
.fullpage-editor .ProseMirror .clip-article--rtl li,
.fullpage-editor .ProseMirror li[dir='rtl'] {
direction: rtl;
text-align: right;
font-family: 'Vazirmatn', var(--font-sans), sans-serif !important;
}
.notion-editor-wrapper .ProseMirror .clip-article--rtl li > p,
.notion-editor-wrapper .ProseMirror li[dir='rtl'] > p,
.fullpage-editor .ProseMirror .clip-article--rtl li > p,
.fullpage-editor .ProseMirror li[dir='rtl'] > p {
direction: rtl;
text-align: right;
}
/* --- Code --- */
.notion-editor-wrapper .ProseMirror code {
background: var(--muted);
@@ -2007,6 +2114,22 @@ html.font-system * {
line-height: 1.4 !important;
}
/* RTL headings — override serif editorial styles (chiffres persans, flux bidi) */
.fullpage-editor .ProseMirror h1[dir='rtl'],
.fullpage-editor .ProseMirror h2[dir='rtl'],
.fullpage-editor .ProseMirror h3[dir='rtl'],
.fullpage-editor .tiptap h1[dir='rtl'],
.fullpage-editor .tiptap h2[dir='rtl'],
.fullpage-editor .tiptap h3[dir='rtl'],
.fullpage-editor .ProseMirror .clip-article--rtl h1,
.fullpage-editor .ProseMirror .clip-article--rtl h2,
.fullpage-editor .ProseMirror .clip-article--rtl h3 {
direction: rtl !important;
text-align: right !important;
font-family: 'Vazirmatn', var(--font-sans), sans-serif !important;
letter-spacing: 0 !important;
}
/* ──────────────────────────────────────────────
SONNER TOASTS — Architectural Grid Styling
────────────────────────────────────────────── */

View File

@@ -10,6 +10,9 @@ import { useLanguage } from '@/lib/i18n/LanguageProvider'
import type { BlockSuggestion } from '@/components/block-picker'
import { toast } from 'sonner'
import { useNoteEditorContext } from '@/components/note-editor/note-editor-context'
import { stripHtmlToPlainText } from '@/lib/text/plain-text'
import { detectTextDirection } from '@/lib/clip/rtl-content'
import { SEMANTIC_SIMILARITY_FLOOR_CLIP } from '@/lib/ai/semantic-proximity'
interface ConnectionData {
noteId: string
@@ -40,16 +43,16 @@ interface PreviewTarget {
excerpt: string
}
function stripHtml(html: string): string {
return html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
}
function excerpt(text: string, max = 150): string {
const plain = stripHtml(text)
const plain = stripHtmlToPlainText(text)
if (plain.length <= max) return plain
return `${plain.slice(0, max).trim()}`
}
function isRtlText(text: string): boolean {
return detectTextDirection(text) === 'rtl'
}
async function resolveBlockForEmbed(sourceNoteId: string, hint: string): Promise<{ block: BlockSuggestion; mode: 'live' | 'citation' } | null> {
const params = new URLSearchParams({ noteId: sourceNoteId, hint })
const res = await fetch(`/api/blocks/resolve?${params}`)
@@ -155,7 +158,7 @@ export function MemoryEchoSection({
useEffect(() => {
if (isLoading || connections.length === 0) return
const top = connections[0]
if (!top || top.similarity < 0.75) return
if (!top || top.similarity < SEMANTIC_SIMILARITY_FLOOR_CLIP) return
const key = `memory-echo-scroll-${noteId}`
if (sessionStorage.getItem(key)) return
sessionStorage.setItem(key, '1')
@@ -167,7 +170,7 @@ export function MemoryEchoSection({
const handleEmbed = useCallback(async (conn: ConnectionData) => {
setEmbeddingId(conn.noteId)
try {
const hint = excerpt(stripHtml(conn.content), 300)
const hint = (conn.content || '').trim().slice(0, 12000)
const resolved = await resolveBlockForEmbed(conn.noteId, hint)
const noteTitle = conn.title || t('memoryEcho.comparison.untitled')
@@ -200,7 +203,7 @@ export function MemoryEchoSection({
return
}
const citationText = stripHtml(
const citationText = stripHtmlToPlainText(
resolved?.block.content || conn.content || hint
).slice(0, 1200)
@@ -213,7 +216,9 @@ export function MemoryEchoSection({
if (editorCtx.state.isMarkdown) {
const quoted = citationText.split('\n').map(line => `> ${line}`).join('\n')
const mdCitation = `\n\n${quoted}\n\n— [${noteTitle}](/home?openNote=${conn.noteId})\n`
const mdCitation = isRtlText(citationText)
? `\n\n<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)
toast.success(t('memoryEcho.editorSection.citationSuccess'))
return
@@ -326,7 +331,14 @@ export function MemoryEchoSection({
</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)} »
</blockquote>

View File

@@ -3,69 +3,25 @@
import { useEffect, useRef } from 'react'
import * as d3 from 'd3'
// Force to group nodes by cluster
function forceCluster() {
let nodes: any[] = []
let clusters: Map<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 {
id: string
title: string | null
clusterId?: string | number
isCentral?: boolean
}
interface NoteCluster {
id: string | number
name: string
name?: string
noteIds: string[]
color: string
color?: string
}
interface BridgeNote {
noteId: string
bridgeScore: number
clustersConnected?: (string | number)[]
connectedClusterIds?: (string | number)[]
clusterNames?: string[]
}
interface NetworkGraphProps {
@@ -73,13 +29,17 @@ interface NetworkGraphProps {
clusters: NoteCluster[]
bridgeNotes: BridgeNote[]
onNoteSelect: (id: string) => void
selectedClusterId?: string | null
onClusterSelect?: (id: string | null) => void
}
export function NetworkGraph({
notes,
clusters,
bridgeNotes,
onNoteSelect
onNoteSelect,
selectedClusterId = null,
onClusterSelect
}: NetworkGraphProps) {
const svgRef = useRef<SVGSVGElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
@@ -103,8 +63,10 @@ export function NetworkGraph({
svg.call(zoom as any)
// Filter notes with cluster assignments (properly check for undefined/null)
const visibleNotes = notes.filter(n => n.clusterId !== undefined && n.clusterId !== null)
// Filter notes with cluster assignments
const visibleNotes = notes.filter(n => n.clusterId !== undefined && n.clusterId !== null && String(n.clusterId) !== '-1')
if (visibleNotes.length === 0) return
interface D3Node extends d3.SimulationNodeDatum {
id: string
@@ -112,6 +74,7 @@ export function NetworkGraph({
clusterId: string | number
color: string
isBridge: boolean
isCentral: boolean
radius: number
}
@@ -119,80 +82,189 @@ export function NetworkGraph({
source: string
target: string
strength: number
type?: 'inner' | 'bridge'
}
const bridgeSet = new Set(bridgeNotes.map(b => b.noteId))
// 1. Initialisation des nœuds avec rôles et diamètres distincts
const nodes: D3Node[] = visibleNotes.map(n => {
const cluster = clusters.find(c => c.id === String(n.clusterId))
const cluster = clusters.find(c => String(c.id) === String(n.clusterId))
const isBridge = bridgeSet.has(n.id)
const isCentral = !!n.isCentral
// Hiérarchie de tailles premium
let radius = 6
if (isCentral) radius = 13
else if (isBridge) radius = 10
return {
id: n.id,
title: n.title,
clusterId: n.clusterId!,
color: cluster?.color || '#cbd5e1',
isBridge,
radius: isBridge ? 12 : 8
isCentral,
radius
}
})
const links: D3Link[] = []
// Connect notes within the same cluster
for (let i = 0; i < visibleNotes.length; i++) {
for (let j = i + 1; j < visibleNotes.length; j++) {
const ni = visibleNotes[i]
const nj = visibleNotes[j]
if (ni.clusterId === nj.clusterId) {
links.push({ source: ni.id, target: nj.id, strength: 0.5 })
}
// Groupement des nœuds par cluster
const clusterGroups = new Map<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[] = []
// 2. Création de la structure en étoile (Star-Network) par cluster
clusterGroups.forEach((groupNodes, cid) => {
if (groupNodes.length <= 1) return
// Trouver le nœud central existant, ou en désigner un par défaut (le premier)
let hub = groupNodes.find(n => n.isCentral)
if (!hub) {
hub = groupNodes[0]
hub.isCentral = true
hub.radius = 13 // Augmenter sa taille pour la hiérarchie visuelle
}
// Relier chaque feuille du cluster UNIQUEMENT à son nœud central (Hub)
groupNodes.forEach(node => {
if (node.id !== hub!.id) {
links.push({
source: node.id,
target: hub!.id,
strength: 0.5,
type: 'inner'
})
}
})
})
// 3. Liaison de ponts dorées reliant les nœuds centraux (Hubs) (Garde-fou D3 contre les nœuds manquants)
const nodeSet = new Set(nodes.map(n => n.id))
bridgeNotes.forEach(b => {
if (!b.clustersConnected) return
if (!nodeSet.has(b.noteId)) return // Évite d'ajouter un lien si la note-pont n'est pas dans les nœuds affichés
b.clustersConnected.forEach(cid => {
const targetNodes = clusterGroups.get(cid) || []
if (targetNodes.length > 0) {
const targetHub = targetNodes.find(n => n.isCentral) || targetNodes[0]
if (nodeSet.has(targetHub.id)) {
links.push({
source: b.noteId,
target: targetHub.id,
strength: 0.15,
type: 'bridge'
})
}
}
})
})
// 4. Pré-positionnement géométrique des Hubs en cercle pour éviter toute superposition initiale
const uniqueClusterIds = Array.from(clusterGroups.keys())
const numClusters = uniqueClusterIds.length
const radiusCircle = Math.min(width, height) * 0.28 // Rayon de répartition
uniqueClusterIds.forEach((cid, index) => {
const angle = (index * 2 * Math.PI) / numClusters
const hubX = width / 2 + radiusCircle * Math.cos(angle)
const hubY = height / 2 + radiusCircle * Math.sin(angle)
const groupNodes = clusterGroups.get(cid) || []
const hub = groupNodes.find(n => n.isCentral) || groupNodes[0]
if (hub) {
hub.x = hubX
hub.y = hubY
}
// Positionner les feuilles autour de leur propre hub
groupNodes.forEach(node => {
if (node.id !== hub?.id) {
const leafAngle = Math.random() * 2 * Math.PI
const leafDist = 25 + Math.random() * 20
node.x = hubX + leafDist * Math.cos(leafAngle)
node.y = hubY + leafDist * Math.sin(leafAngle)
}
})
})
// D3 simulation — Paramètres de physique ultra-stables, centrés et étalés comme des galaxies
const simulation = d3.forceSimulation<D3Node>(nodes)
.force('link', d3.forceLink<D3Node, D3Link>(links).id(d => d.id).distance(50))
.force('charge', d3.forceManyBody().strength(-300))
.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(d => (d as D3Node).isCentral ? -500 : -80)) // Répulsion équilibrée pour éviter de projeter les Hubs contre les bords de l'écran
.force('center', d3.forceCenter(width / 2, height / 2))
.force('collision', d3.forceCollide<D3Node>().radius(d => d.radius + 15))
.force('cluster', forceCluster())
.force('x', d3.forceX(width / 2).strength(0.12)) // Recentrage X renforcé pour l'équilibre central
.force('y', d3.forceY(height / 2).strength(0.12)) // Recentrage Y renforcé
.force('collision', d3.forceCollide<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')
.selectAll('line')
.data(links)
.enter()
.append('line')
.attr('stroke', '#e2e8f0')
.attr('stroke-opacity', 0.6)
.attr('stroke-width', 1)
.attr('stroke', (d: any) => d.type === 'bridge' ? '#E2B13C' : '#cbd5e1')
.attr('stroke-dasharray', (d: any) => d.type === 'bridge' ? '4,4' : 'none')
.attr('stroke-opacity', (d: any) => {
if (d.type === 'bridge') return selectedClusterId ? 0.15 : 0.6
if (!selectedClusterId) return 0.4
const sId = typeof d.source === 'string' ? d.source : (d.source as any).id
const tId = typeof d.target === 'string' ? d.target : (d.target as any).id
const sourceNode = nodes.find(n => n.id === sId)
const targetNode = nodes.find(n => n.id === tId)
const sCluster = String(sourceNode?.clusterId)
const tCluster = String(targetNode?.clusterId)
return sCluster === selectedClusterId && tCluster === selectedClusterId ? 0.7 : 0.04
})
.attr('stroke-width', (d: any) => d.type === 'bridge' ? 1.5 : 1)
// Nodes
// Nœuds avec opacité focus
const node = g.append('g')
.selectAll('.node')
.data(nodes)
.enter()
.append('g')
.attr('class', 'node cursor-pointer')
.attr('opacity', d => {
if (!selectedClusterId) return 1
return String(d.clusterId) === selectedClusterId ? 1 : 0.15
})
.on('click', (event, d) => onNoteSelect(d.id))
.call(d3.drag<SVGGElement, D3Node>()
.on('start', dragstarted)
.on('drag', dragged)
.on('end', dragended) as any)
// Cercles avec tailles hiérarchiques et halos
node.append('circle')
.attr('r', d => d.radius)
.attr('fill', d => d.color)
.attr('stroke', d => d.isBridge ? '#D4AF37' : '#fff')
.attr('stroke-width', d => d.isBridge ? 3 : 2)
.style('filter', d => d.isBridge ? 'drop-shadow(0 0 4px rgba(212, 175, 55, 0.4))' : 'none')
.attr('stroke', d => d.isCentral ? 'rgba(255,255,255,0.9)' : d.isBridge ? '#D4AF37' : '#fff')
.attr('stroke-width', d => d.isCentral ? 3 : d.isBridge ? 2.5 : 1.5)
.style('filter', d => d.isBridge ? 'drop-shadow(0 0 6px rgba(212, 175, 55, 0.5))' : 'none')
// Labels de textes ultra-lisibles claire/sombre sans chevauchement
node.append('text')
.attr('dy', d => d.radius + 14)
.attr('dy', d => d.radius + 13)
.attr('text-anchor', 'middle')
.attr('class', 'text-[10px] fill-concrete dark:fill-concrete/60 font-medium pointer-events-none')
.attr('font-size', d => d.isCentral ? '10px' : '9px')
.attr('font-weight', d => d.isCentral ? '700' : '500')
.attr('fill', '#4b5563')
.attr('class', 'dark:fill-zinc-300 font-sans pointer-events-none')
.text(d => {
const title = d.title || 'Untitled'
return title.length > 20 ? title.substring(0, 20) + '...' : title
const title = d.title || 'Sans titre'
return title.length > 20 ? title.substring(0, 18) + '...' : title
})
simulation.on('tick', () => {
@@ -203,9 +275,46 @@ export function NetworkGraph({
.attr('y2', d => (d.target as any).y)
node
.attr('transform', d => `translate(${d.x},${d.y})`)
.attr('transform', d => {
// Bounding box rigide : maintient à 100% les clusters sur l'écran
const padding = 35
d.x = Math.max(padding, Math.min(width - padding, d.x || width / 2))
d.y = Math.max(padding, Math.min(height - padding, d.y || height / 2))
return `translate(${d.x},${d.y})`
})
})
// Zoom automatique sur le cluster sélectionné (800ms)
if (selectedClusterId && width && height) {
const clusterNodes = nodes.filter(n => String(n.clusterId) === selectedClusterId)
if (clusterNodes.length > 0) {
// Avancer la simulation pour obtenir des coordonnées stabilisées
for (let i = 0; i < 60; ++i) simulation.tick()
const xCoords = clusterNodes.map(cn => cn.x).filter((x): x is number => x !== undefined)
const yCoords = clusterNodes.map(cn => cn.y).filter((y): y is number => y !== undefined)
if (xCoords.length > 0 && yCoords.length > 0) {
const avgX = d3.mean(xCoords) || width / 2
const avgY = d3.mean(yCoords) || height / 2
svg.transition()
.duration(800)
.call(
zoom.transform,
d3.zoomIdentity
.translate(width / 2, height / 2)
.scale(1.3)
.translate(-avgX, -avgY)
)
}
}
} else if (!selectedClusterId) {
svg.transition()
.duration(800)
.call(zoom.transform, d3.zoomIdentity)
}
function dragstarted(event: any, d: D3Node) {
if (!event.active) simulation.alphaTarget(0.3).restart()
d.fx = d.x
@@ -226,17 +335,37 @@ export function NetworkGraph({
return () => {
simulation.stop()
}
}, [notes, clusters, bridgeNotes, onNoteSelect])
}, [notes, clusters, bridgeNotes, onNoteSelect, selectedClusterId])
return (
<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]">
{clusters.map(c => (
<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">
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: c.color }} />
<span className="text-[9px] font-bold uppercase tracking-widest text-concrete whitespace-nowrap">{c.name}</span>
</div>
))}
{/* Pastilles de cluster — cliquables pour activer le focus */}
<div className="absolute top-6 left-6 z-10 flex flex-wrap gap-2 max-w-[90%]">
{clusters.map(c => {
const isSelected = String(c.id) === selectedClusterId
return (
<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>
<svg ref={svgRef} className="w-full h-full" />
</div>

View File

@@ -7,7 +7,7 @@ import { fr } from 'date-fns/locale/fr'
import { enUS } from 'date-fns/locale/en-US'
import { faIR } from 'date-fns/locale/fa-IR'
import { formatAbsoluteDateLocalized } from '@/lib/utils/format-localized-date'
import { X, Info, Clock, Hash, Book, FileText, Calendar, Tag, ChevronRight, Trash2, RotateCcw, Loader2, Check, History as HistoryIcon, Network, Copy } from 'lucide-react'
import { X, Info, Clock, Hash, Book, FileText, Calendar, Tag, ChevronRight, Trash2, RotateCcw, Loader2, Check, History as HistoryIcon, Network, Copy, ExternalLink } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useLanguage } from '@/lib/i18n'
import { useNotebooks } from '@/context/notebooks-context'
@@ -73,6 +73,7 @@ export function NoteDocumentInfoPanel({ note, content, onClose, onNoteRestored }
const locale = getLocale(language)
const displayNoteType = useMemo(() => {
if (note.sourceUrl) return t('notes.noteTypes.clip')
const map: Record<string, string> = {
richtext: t('notes.noteTypes.richtext'),
markdown: t('notes.noteTypes.markdown'),
@@ -80,7 +81,7 @@ export function NoteDocumentInfoPanel({ note, content, onClose, onNoteRestored }
checklist: t('notes.noteTypes.checklist'),
}
return map[note.type] || note.type
}, [t, note.type])
}, [t, note.type, note.sourceUrl])
useEffect(() => {
if (activeTab === 'versions' && historyEnabled) {
@@ -227,6 +228,23 @@ export function NoteDocumentInfoPanel({ note, content, onClose, onNoteRestored }
</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 && (
<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" />

View File

@@ -107,6 +107,7 @@ export function NoteContentArea() {
className="min-h-[280px]"
onImageUpload={uploadImageFile}
noteId={note.id}
sourceUrl={note.sourceUrl}
/>
</div>
)
@@ -121,6 +122,7 @@ export function NoteContentArea() {
className="min-h-[200px]"
onImageUpload={uploadImageFile}
noteId={note.id}
sourceUrl={note.sourceUrl}
/>
<GhostTags
suggestions={state.filteredSuggestions}

View File

@@ -7,12 +7,17 @@ import { useLanguage } from '@/lib/i18n'
import { useAiConsent } from '@/components/legal/ai-consent-provider'
import { cn } from '@/lib/utils'
import { toast } from 'sonner'
import { resolveTitleDirection, resolveTitleLang } from '@/lib/clip/rtl-content'
export function NoteTitleBlock() {
const { state, actions, readOnly, fullPage } = useNoteEditorContext()
const { note, state, actions, readOnly, fullPage } = useNoteEditorContext()
const { t } = useLanguage()
const { requestAiConsent } = useAiConsent()
const titleDir = resolveTitleDirection(state.title || '', note.sourceUrl)
const titleLang = resolveTitleLang(state.title || '', note.sourceUrl)
const titleIsRtl = titleDir === 'rtl'
if (fullPage) {
// Adaptive font size: short = big editorial, long = smaller but still premium
const titleLen = (state.title || '').length
@@ -28,7 +33,8 @@ export function NoteTitleBlock() {
{/* Title — auto-resizing textarea, adaptive size */}
<div className="group relative">
<textarea
dir="auto"
dir={titleDir}
lang={titleLang}
rows={1}
placeholder={t('notes.titlePlaceholder') || 'Untitled…'}
value={state.title}
@@ -40,11 +46,14 @@ export function NoteTitleBlock() {
}}
disabled={readOnly}
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',
'placeholder:text-foreground/20 resize-none overflow-hidden',
titleSizeClass,
!readOnly && 'pr-12'
!readOnly && (titleIsRtl ? 'ps-12' : 'pe-12')
)}
style={{ height: 'auto' }}
ref={(el) => {
@@ -92,7 +101,10 @@ export function NoteTitleBlock() {
} finally { actions.setIsProcessingAI(false) }
}}
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')}
>
{state.isProcessingAI ? <Loader2 className="h-5 w-5 animate-spin" /> : <Sparkles className="h-5 w-5" />}
@@ -117,14 +129,16 @@ export function NoteTitleBlock() {
return (
<div className="relative">
<input
dir="auto"
dir={titleDir}
lang={titleLang}
placeholder={t('notes.titlePlaceholder')}
value={state.title}
onChange={(e) => actions.setTitle(e.target.value)}
disabled={readOnly}
className={cn(
"w-full text-lg font-semibold border-0 focus-visible:ring-0 px-0 bg-transparent pr-10",
readOnly && "cursor-default"
'w-full text-lg font-semibold border-0 focus-visible:ring-0 px-0 bg-transparent',
titleIsRtl ? 'text-right font-[family-name:var(--font-sans)] ps-10' : 'text-left pe-10',
readOnly && 'cursor-default',
)}
/>
<button

View File

@@ -3,6 +3,8 @@
import { useEffect, useState, useMemo, useCallback, useRef } from 'react'
import dynamic from 'next/dynamic'
import { useRouter } from 'next/navigation'
import { useNotebooks } from '@/context/notebooks-context'
import { openNotePath } from '@/lib/navigation/open-note'
import {
Loader2,
Network,
@@ -50,8 +52,19 @@ interface NotePreview {
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 { notebooks } = useNotebooks()
const containerRef = useRef<HTMLDivElement>(null)
const graphRef = useRef<any>(null)
const [dimensions, setDimensions] = useState({ width: 800, height: 600 })
@@ -63,6 +76,10 @@ export function NoteGraphView() {
const [notePreview, setNotePreview] = useState<NotePreview | null>(null)
const [previewLoading, setPreviewLoading] = useState(false)
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()
@@ -150,26 +167,48 @@ export function NoteGraphView() {
const colorMap = useMemo(() => {
if (!rawData) return new Map<string | null, string>()
const map = new Map<string | null, string>()
const ids = [...new Set(rawData.nodes.map(n => n.notebookId).filter(Boolean))]
ids.forEach((id, i) => map.set(id, PALETTE[i % PALETTE.length]))
const ids = [...new Set(rawData.nodes.map(n => n.notebookId).filter(Boolean))] as string[]
ids.forEach((id, i) => {
const nb = notebooks.find(n => n.id === id)
map.set(id, nb?.color || PALETTE[i % PALETTE.length])
})
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 ───────────────────────────────────────────────────────────
const graphData = useMemo(() => {
if (!rawData) return { nodes: [], links: [] }
// Filter by notebook
let filtered = selectedNotebookId
? rawData.nodes.filter(n => n.notebookId === selectedNotebookId)
: rawData.nodes
// Filter by text search
if (neighborIds) {
filtered = filtered.filter(n => neighborIds.has(n.id))
}
filtered = searchFilter.trim()
? filtered.filter(n => n.title.toLowerCase().includes(searchFilter.toLowerCase()))
: filtered
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 {
nodes: filtered.map(n => ({
id: n.id,
@@ -179,39 +218,37 @@ export function NoteGraphView() {
notebookId: n.notebookId,
degree: n.degree,
})),
links: rawData.edges
.filter(e => filteredIds.has(e.source) && filteredIds.has(e.target))
.map(e => {
let color = '#e2e8f0'
let width = 0.6
let dash = false
links: visibleEdges.map(e => {
let color = '#cbd5e1'
let width = 2.5
let dash = false
if (e.type === 'explicit_link') {
color = '#10b981' // Green
width = 2.2
} else if (e.type === 'semantic_echo') {
color = '#a78bfa' // Purple
width = 1.8
dash = true
} else if (e.type === 'title_mention') {
color = '#f59e0b' // Amber/Orange
width = 1.6
} else if (e.type === 'shared_label') {
color = '#3b82f6' // Blue
width = 1.2
}
if (e.type === 'explicit_link') {
color = '#10b981'
width = 4.5
} else if (e.type === 'semantic_echo') {
color = '#8b5cf6'
width = 3.5
dash = true
} else if (e.type === 'title_mention') {
color = '#f59e0b'
width = 3.2
} else if (e.type === 'shared_label') {
color = '#3b82f6'
width = 2.8
}
return {
source: e.source,
target: e.target,
color,
width,
dash,
type: e.type,
}
}),
return {
source: e.source,
target: e.target,
color,
width,
dash,
type: e.type,
}
}),
}
}, [rawData, searchFilter, colorMap, selectedNotebookId])
}, [rawData, searchFilter, colorMap, selectedNotebookId, edgeFilters, semanticMinWeight, neighborIds])
const selectedNotebookName = useMemo(() => {
if (!selectedNode || !rawData) return null
@@ -226,20 +263,39 @@ export function NoteGraphView() {
const now = Date.now()
const last = lastClickRef.current
if (last && last.id === node.id && now - last.time < 350) {
// Double-click → zoom
lastClickRef.current = null
graphRef.current?.centerAt(node.x, node.y, 600)
graphRef.current?.zoom(3, 600)
router.push(openNotePath(node.id))
return
}
lastClickRef.current = { id: node.id, time: now }
setSelectedNode(rawData.nodes.find(n => n.id === node.id) ?? null)
}, [rawData])
}, [rawData, router])
const handleZoomToFit = useCallback(() => {
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) ──────────────────────────────
@@ -296,14 +352,17 @@ export function NoteGraphView() {
// ─── Render ───────────────────────────────────────────────────────────────
return (
<div className="flex flex-col h-full bg-[#FAFAF9]">
{/* Header */}
<div className={`flex flex-col h-full ${embedded ? 'bg-transparent' : 'bg-[#FAFAF9]'}`}>
{!embedded && (
<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" />
<h1 className="text-sm font-semibold text-ink">{t('graphView.title')}</h1>
{rawData && (
<span className="text-[10px] text-concrete/50 font-medium">
{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>
)}
<div className="flex-1" />
@@ -323,6 +382,7 @@ export function NoteGraphView() {
)}
</div>
</div>
)}
{/* Canvas */}
<div ref={containerRef} className="flex-1 relative overflow-hidden">
@@ -349,7 +409,10 @@ export function NoteGraphView() {
nodeLabel="name"
linkColor="color"
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}
onNodeHover={(node: any) => {
if (containerRef.current) containerRef.current.style.cursor = node ? 'pointer' : 'default'
@@ -401,16 +464,29 @@ export function NoteGraphView() {
{/* Cluster legend (Interactive Notebook Filter) */}
{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>
{selectedNotebookId && (
<button
onClick={() => setSelectedNotebookId(null)}
className="flex items-center gap-1.5 px-2.5 py-1 bg-white border border-rose-200 text-rose-600 rounded-full shadow-sm hover:bg-rose-50 transition-all text-[9px] font-semibold w-fit"
>
<X size={10} />
{t('graphView.resetFilter')}
</button>
{(selectedNotebookId || focusNodeId) && (
<div className="flex flex-col gap-1">
{selectedNotebookId && (
<button
onClick={() => setSelectedNotebookId(null)}
className="flex items-center gap-1.5 px-2.5 py-1 bg-white border border-rose-200 text-rose-600 rounded-full shadow-sm hover:bg-rose-50 transition-all text-[9px] font-semibold w-fit"
>
<X size={10} />
{t('graphView.resetFilter')}
</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">
{rawData.clusters.map(c => {
@@ -439,31 +515,69 @@ export function NoteGraphView() {
</div>
)}
{/* Legend of relationship types */}
{!loading && !error && graphData.nodes.length > 0 && (
<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">
<h3 className="text-[9px] font-bold text-slate-800 uppercase tracking-wider mb-1">{t('graphView.relationshipTypes')}</h3>
<div className="flex flex-col gap-1.5">
<div className="flex items-center gap-2">
<span className="w-5 h-0.5 rounded shrink-0 bg-[#10b981]" />
<span className="text-[10px] font-medium text-concrete/70">WikiLink (Manuel)</span>
</div>
<div className="flex items-center gap-2">
<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>
</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>
{/* Filtres de liens + seuil sémantique */}
{!loading && !error && rawData && (
<div className="absolute bottom-4 left-4 z-10 flex flex-col gap-2 max-w-[220px]">
<button
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">
<span className="w-5 h-0.5 rounded shrink-0 bg-[#10b981]" />
<span className="text-[9px] font-medium text-concrete/70">{t('graphView.edgeTypes.explicitLink')}</span>
</div>
<div className="flex items-center gap-2">
<span className="w-5 border-t-2 border-dashed shrink-0 border-[#a78bfa]" />
<span className="text-[9px] font-medium text-concrete/70">{t('graphView.edgeTypes.semanticEcho')}</span>
</div>
</div>
)}
@@ -605,9 +719,17 @@ export function NoteGraphView() {
</div>
{/* 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
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"
>
<BookOpen size={12} className="group-hover:scale-110 transition-transform" />

View File

@@ -4,7 +4,7 @@ import { useState, useEffect, useCallback } from 'react'
import { useRouter } from 'next/navigation'
import { Button } from '@/components/ui/button'
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 {
Popover,
PopoverContent,
@@ -179,6 +179,7 @@ export function NotificationPanel() {
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_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' }
}
@@ -278,6 +279,7 @@ export function NotificationPanel() {
: isCanvas ? <Pencil 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 === 'clip' ? <Scissors 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" />}
</div>
@@ -293,6 +295,7 @@ export function NotificationPanel() {
{notif.type === 'agent_failure' && (t('notification.agentFailed') || 'Agent échoué')}
{notif.type === 'brainstorm_invite' && (t('notification.brainstormInvite') || 'Brainstorm')}
{notif.type === 'brainstorm_joined' && (t('notification.brainstormJoined') || 'Brainstorm')}
{notif.type === 'clip' && (t('notification.clipSaved') || 'Web clip')}
{notif.type === 'system' && t('notification.systemNotification')}
</span>
<p className="text-[13px] font-semibold truncate mt-0.5">{notif.title}</p>

View File

@@ -25,9 +25,13 @@ import { ChartExtension } from './tiptap-chart-extension'
import { ChartSuggestionsDialog } from './chart-suggestions-dialog'
import { UniqueIdExtension } from './tiptap-unique-id-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 { detectTextDirection } from '@/lib/clip/rtl-content'
import { stripHtmlToPlainText } from '@/lib/text/plain-text'
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 type { Editor } from '@tiptap/core'
import type { EditorState } from '@tiptap/pm/state'
@@ -60,6 +64,8 @@ interface RichTextEditorProps {
placeholder?: string
onImageUpload?: (file: File) => Promise<string>
noteId?: string
/** URL source du clip (BBC Persian, etc.) — pour RTL explicite des listes */
sourceUrl?: string | null
}
interface RichTextEditorRef {
@@ -227,7 +233,7 @@ function useImageInsert() {
}
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 { requestAiConsent } = useAiConsent()
const imageInsert = useImageInsert()
@@ -350,6 +356,8 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
ChartExtension,
UniqueIdExtension,
LiveBlockExtension,
ClipArticleExtension,
RtlPreserveExtension,
Placeholder.configure({ placeholder: placeholder || t('richTextEditor.placeholder') || "Tapez '/' pour voir les commandes..." }),
],
content: content || '',
@@ -428,6 +436,11 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
noteLinkRangeRef.current = null
}
},
onCreate: ({ editor: e }) => {
requestAnimationFrame(() => {
applyClipRtlDirection(e, { sourceUrl })
})
},
})
useEffect(() => {
@@ -442,12 +455,15 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
if (editor && content !== undefined && content !== lastEmittedContent.current) {
editor.commands.setContent(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) {
setCurrentNoteContent(content || '')
}
}, [content, editor])
}, [content, editor, sourceUrl])
// Chart suggestion handlers
const handleOpenChartSuggestions = useCallback(async () => {
@@ -463,8 +479,10 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
options?: { atEnd?: boolean }
) => {
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
const isRtl = detectTextDirection(`${payload.noteTitle}\n${plainExcerpt}`) === 'rtl'
const rtlAttrs = isRtl ? { dir: 'rtl' as const, lang: 'fa' as const } : {}
const chain = editor.chain()
if (options?.atEnd !== false) {
chain.focus('end')
@@ -475,10 +493,16 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
{ type: 'paragraph', content: [] },
{
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',
attrs: rtlAttrs,
content: [
{ type: 'text', text: '— ' },
{

View File

@@ -32,7 +32,6 @@ import {
Network,
Search,
GraduationCap,
Scissors,
FileText,
Folder,
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">
{([
{ 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: 'revision', icon: GraduationCap, label: 'Révisions', onClick: () => setActiveView('revision'), isActive: activeView === 'revision' },
{ id: 'graph', icon: Network, label: t('nav.graphView'), onClick: () => router.push('/graph'), isActive: pathname === '/graph' },
{ 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: '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 => (

View 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,
]
},
})

View 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 }
},
},
},
},
]
},
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 590 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 590 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 590 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 590 KiB

View 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 lextension non empaquetée** → dossier `memento-note/extension`
3. Épingle licô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 licô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`

View File

@@ -0,0 +1,8 @@
/** Service worker — ouvre le panneau latéral au clic sur licône. */
chrome.runtime.onInstalled.addListener(() => {
chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true }).catch(() => {})
})
chrome.runtime.onStartup.addListener(() => {
chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true }).catch(() => {})
})

View 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 longlet é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()
})()

View 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"
}
}

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

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

View 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 &amp; 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>

View 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 laccès à votre instance Momento dans Chrome.')
}
}
function escapeHtml(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
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()
})

View File

@@ -1,6 +1,8 @@
/** 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_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 laffichage au-dessus du seuil. */
export function semanticProximityRatio(

View File

@@ -50,19 +50,16 @@ export class BridgeNotesService {
noteId: string
clusterId: number | null
}>>(
`SELECT similar."noteId", cm."clusterId"
FROM (
SELECT e2."noteId"
FROM "NoteEmbedding" e1
CROSS JOIN "NoteEmbedding" e2
INNER JOIN "Note" n ON n.id = e2."noteId"
WHERE e1."noteId" = $1
AND e2."noteId" != e1."noteId"
AND n."userId" = $2
AND n."trashedAt" IS NULL
AND (e1."embedding"::vector <=> e2."embedding"::vector) <= $3
) similar
LEFT JOIN "ClusterMember" cm ON cm."noteId" = similar."noteId" AND cm."userId" = $2`,
`SELECT e2."noteId", cm."clusterId"
FROM "NoteEmbedding" e1
CROSS JOIN "NoteEmbedding" e2
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
AND e2."noteId" != e1."noteId"
AND n."userId" = $2
AND n."trashedAt" IS NULL
AND (e1."embedding"::vector <=> e2."embedding"::vector) <= $3`,
noteId,
userId,
cosineDistance

View File

@@ -15,6 +15,7 @@ import prisma from '@/lib/prisma'
import { embeddingService } from './embedding.service'
import { getChatProvider } from '@/lib/ai/factory'
import { getSystemConfig } from '@/lib/config'
import { upsertNoteEmbedding } from '@/lib/embeddings'
export interface ClusterResult {
clusterId: number
@@ -34,6 +35,8 @@ export interface ClusteringOptions {
minClusterSize?: number
epsilon?: number // Cosine distance threshold (lower = more strict)
maxClusters?: number
/** usage interne — évite une boucle de retry */
_relaxedRetry?: boolean
}
export class ClusteringService {
@@ -42,6 +45,67 @@ export class ClusteringService {
private readonly DEFAULT_MAX_CLUSTERS = 50
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.
* Uses 1 - cosine_distance where cosine_distance is computed via pgvector.
@@ -126,8 +190,29 @@ export class ClusteringService {
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.
* 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(
userId: string,
@@ -143,9 +228,9 @@ export class ClusteringService {
maxClusters = this.DEFAULT_MAX_CLUSTERS
} = options
// Get all user's notes with embeddings
const notesWithEmbeddings = await prisma.$queryRawUnsafe<Array<{ noteId: string }>>(
`SELECT ne."noteId"
// Fetch all user note embeddings in a single highly-optimized DB query
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
@@ -154,7 +239,19 @@ export class ClusteringService {
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) {
return {
@@ -164,76 +261,274 @@ export class ClusteringService {
}
}
const visited = new Set<string>()
const clustered = new Map<string, number>() // noteId -> clusterId
const clusterResults: ClusterResult[] = []
let clusterId = 0
// In-memory neighbor lookup
const findNeighborsInMemory = (noteId: string, currentEpsilon: number): string[] => {
const vecA = embeddingMap.get(noteId)
if (!vecA) return []
const neighbors: string[] = []
// DBSCAN algorithm
for (const noteId of allNoteIds) {
if (visited.has(noteId)) continue
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
}
visited.add(noteId)
const neighbors = await this.findNeighbors(noteId, allNoteIds, epsilon)
// 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]
if (neighbors.length < minClusterSize) {
// Mark as noise (cluster_id = -1)
clustered.set(noteId, -1)
continue
// 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)
}
}
}
// Expand cluster
const clusterMembers = await this.expandCluster(
noteId,
neighbors,
clusterId,
visited,
clustered,
allNoteIds,
epsilon,
minClusterSize
)
while (queue.length > 0) {
const currentNoteId = queue.shift()!
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)
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 clustered = new Map<string, number>() // noteId -> clusterId
const clusterResults: ClusterResult[] = []
let currentClusterId = 0
// Core DBSCAN loop
for (const noteId of allNoteIds) {
if (visited.has(noteId)) continue
visited.add(noteId)
const neighbors = findNeighborsInMemory(noteId, config.eps)
if (neighbors.length < config.minSize) {
clustered.set(noteId, -1)
continue
}
// Found a new cluster core node
clustered.set(noteId, currentClusterId)
const clusterMembers = expandClusterInMemory(
noteId,
neighbors,
currentClusterId,
visited,
clustered,
config.eps,
config.minSize
)
if (clusterMembers.length >= config.minSize && currentClusterId < maxClusters) {
clusterResults.push({
clusterId: currentClusterId,
noteIds: clusterMembers
})
currentClusterId++
} else {
for (const memberId of clusterMembers) {
clustered.set(memberId, -1)
}
}
}
const noiseCount = Array.from(clustered.values()).filter(id => id === -1).length
// 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)})`)
}
}
}
// Calculate membership scores and identify central notes
const clusteredNotes: ClusteredNote[] = []
for (const [noteId, cid] of clustered.entries()) {
if (cid === -1) continue // Skip noise
// Recalculer le noiseCount réel après intégration des paires
const finalNoiseCount = Array.from(bestClustered.values()).filter(id => id === -1).length
const cluster = clusterResults[cid]
// 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
// Calculate membership score as average similarity to other cluster members
const score = await this.calculateMembershipScore(noteId, cluster.noteIds)
const isCentral = await this.isCentralNote(noteId, cluster.noteIds)
const score = calculateMembershipScoreInMemory(noteId, cluster.noteIds)
clusteredNotes.push({
noteId,
clusterId: cid,
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 {
clusters: clusterResults,
clusters: bestClusters,
clusteredNotes,
noiseCount
noiseCount: finalNoiseCount
}
}
@@ -350,9 +645,9 @@ export class ClusteringService {
.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 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 {
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({
where: { userId },
orderBy: { clusterId: 'asc' }
@@ -410,11 +709,12 @@ export class ClusteringService {
if (clusters.length === 0) return null
// Check if data is still fresh
const needsUpdate = await this.shouldRecalculate(userId)
if (needsUpdate) return null
const stale = await this.shouldRecalculate(userId)
const lastCalculated = clusters.reduce<Date | null>((latest, c) => {
if (!c.lastCalculated) return latest
return !latest || c.lastCalculated > latest ? c.lastCalculated : latest
}, null)
// Get cluster members
const result: ClusterResult[] = []
for (const cluster of clusters) {
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
}
}

View File

@@ -7,61 +7,96 @@
import { withAiProviderFallback } from '../fallback'
import { getSystemConfig } from '@/lib/config'
import { prisma } from '@/lib/prisma'
import {
meanPoolEmbeddingVectors,
prepareNoteTextForEmbedding,
prepareTextForEmbedding,
splitPlainTextForEmbeddingChunks,
} from '@/lib/text/plain-text'
export interface EmbeddingResult {
embedding: number[]
model: string
dimension: number
/** Nombre de caractères plain text indexés */
indexedChars?: number
/** Nombre de chunks API utilisés */
chunkCount?: number
}
export class EmbeddingService {
private readonly MAX_CHARS = 15000
private truncateForEmbedding(text: string): string {
if (text.length <= this.MAX_CHARS) return text
return text.slice(0, this.MAX_CHARS)
prepareTextForEmbedding(content: string): string {
return prepareTextForEmbedding(content)
}
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> {
if (!text || text.trim().length === 0) {
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 {
embedding,
model: 'text-embedding-3-small',
dimension: embedding.length
}
} catch (error) {
console.error('Error generating embedding:', error)
throw new Error(`Failed to generate embedding: ${error}`)
return {
embedding,
model: 'text-embedding-3-small',
dimension: embedding.length,
indexedChars: plain.length,
chunkCount: 1,
}
}
async generateBatchEmbeddings(texts: string[]): Promise<EmbeddingResult[]> {
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 []
try {
const config = await getSystemConfig()
const embeddings = await withAiProviderFallback('embedding', config, (provider) =>
Promise.all(validTexts.map((text) => provider.getEmbeddings(text)))
)
const embeddings = await Promise.all(validTexts.map((text) => this.embedPlainText(text)))
return embeddings.map(embedding => ({
return embeddings.map((embedding, i) => ({
embedding,
model: 'text-embedding-3-small',
dimension: embedding.length
dimension: embedding.length,
indexedChars: validTexts[i].length,
chunkCount: 1,
}))
} catch (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 {
return `[${embedding.join(',')}]`
}
/**
* Parse a pgvector string from the DB back into number[].
* e.g. '[0.1,0.2,0.3]' → [0.1, 0.2, 0.3]
*/
fromVectorString(vec: string): number[] {
if (Array.isArray(vec)) return vec
if (!vec || typeof vec !== 'string') return []
return vec.replace(/^\[/, '').replace(/\]$/, '').split(',').map(Number)
}
/**
* JS cosine similarity — still used by memory-echo pairwise comparisons.
*/
calculateCosineSimilarity(a: number[], b: number[]): number {
if (!a.length || !b.length) return 0
const minLen = Math.min(a.length, b.length)
let dot = 0, mA = 0, mB = 0
let dot = 0
let mA = 0
let mB = 0
for (let i = 0; i < minLen; i++) {
dot += a[i] * b[i]
mA += a[i] * a[i]
@@ -105,10 +131,6 @@ export class EmbeddingService {
return dot / (mA * mB)
}
/**
* Check if a note needs embedding regeneration.
* Uses a content-content comparison (not embedding-content).
*/
async getDbDimension(): Promise<number | null> {
try {
const result: Array<{ dim: number | null }> = await prisma.$queryRawUnsafe(
@@ -142,10 +164,13 @@ export class EmbeddingService {
}
shouldRegenerateEmbedding(
noteContent: string,
_noteContent: string,
_lastEmbeddingContent: string | null,
lastAnalysis: Date | null
lastAnalysis: Date | null,
options?: { force?: boolean; isClip?: boolean },
): boolean {
if (options?.force) return true
if (options?.isClip) return true
if (!lastAnalysis) return true
const daysSinceAnalysis = (Date.now() - lastAnalysis.getTime()) / (1000 * 60 * 60 * 24)
return daysSinceAnalysis > 7

View File

@@ -1,10 +1,20 @@
import { getAIProvider, getChatProvider } from '../factory'
import { getChatProvider } from '../factory'
import { cosineSimilarity } from '@/lib/utils'
import { embeddingService } from './embedding.service'
import { getSystemConfig } from '@/lib/config'
import prisma from '@/lib/prisma'
import { Prisma } from '@prisma/client'
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 {
note1: {
@@ -50,45 +60,109 @@ export interface MemoryEchoInsight {
* "I didn't search, it found me"
*/
export class MemoryEchoService {
private readonly SIMILARITY_THRESHOLD = 0.75 // High threshold for quality connections
private readonly SIMILARITY_THRESHOLD_DEMO = 0.50 // Lower threshold for demo mode
private readonly SIMILARITY_THRESHOLD = SEMANTIC_SIMILARITY_FLOOR
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_CLIP = 0 // Notes clippées (sourceUrl) : même jour OK
private readonly MIN_DAYS_APART_DEMO = 0 // No delay for demo mode
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
*/
private async ensureEmbeddings(userId: string): Promise<void> {
const notesWithoutEmbeddings = await prisma.note.findMany({
const notes = await prisma.note.findMany({
where: {
userId,
isArchived: false,
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 {
const config = await getSystemConfig()
const provider = getAIProvider(config)
for (const note of notesWithoutEmbeddings) {
if (!note.content || note.content.trim().length === 0) continue
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
for (const note of notes) {
if (!note.content?.trim()) continue
const isClip = this.isClippedNote(note)
const missing = !note.noteEmbedding
if (!missing && !isClip) continue
await this.upsertNoteEmbeddingFromNote(note)
}
}
@@ -121,6 +195,7 @@ export class MemoryEchoService {
id: true,
title: true,
content: true,
sourceUrl: true,
noteEmbedding: true,
createdAt: true
},
@@ -151,10 +226,6 @@ export class MemoryEchoService {
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
const feedbackInsights = await prisma.memoryEchoInsight.findMany({
where: { userId, feedback: { not: null } },
@@ -183,8 +254,8 @@ export class MemoryEchoService {
Math.floor((note1.createdAt.getTime() - note2.createdAt.getTime()) / (1000 * 60 * 60 * 24))
)
// Time diversity filter: notes must be from different time periods
if (daysApart < minDaysApart) {
// Time diversity filter: notes must be from different time periods (sauf clips récents)
if (!this.passesTimeDiversityFilter(daysApart, note1, note2, demoMode)) {
continue
}
@@ -192,7 +263,8 @@ export class MemoryEchoService {
const similarity = cosineSimilarity(note1.embedding!, note2.embedding!)
// 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(note2.id) || 0)
if (similarity >= adjustedThreshold) {
@@ -200,13 +272,13 @@ export class MemoryEchoService {
note1: {
id: note1.id,
title: note1.title,
content: note1.content.substring(0, 200) + (note1.content.length > 200 ? '...' : ''),
content: this.connectionPlainText(note1.title, note1.content),
createdAt: note1.createdAt
},
note2: {
id: note2.id,
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
},
similarityScore: similarity,
@@ -239,30 +311,52 @@ export class MemoryEchoService {
const note1Desc = note1Title || '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}"
Content: ${note1Content.substring(0, 300)}
Content: ${excerpt1}
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.`
const response = await provider.generateText(prompt)
// Clean up response
const insight = response
.replace(/^["']|["']$/g, '') // Remove quotes
.replace(/^[^.]+\.\s*/, '') // Remove "Here is..." prefix
.replace(/^["'«»]|["'«»]$/g, '')
.replace(/^[^.]+\.\s*/, '')
.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) {
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.'
}
}
@@ -459,6 +553,7 @@ Explain in one brief sentence (max 15 words) why these notes are connected. Focu
id: true,
title: true,
content: true,
sourceUrl: true,
createdAt: 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
if (!targetEmbeddingStr) {
return [] // Note has no embedding
let targetEmbedding = targetEmbeddingStr
? 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)
@@ -514,6 +617,7 @@ Explain in one brief sentence (max 15 words) why these notes are connected. Focu
id: true,
title: true,
content: true,
sourceUrl: true,
createdAt: true
},
orderBy: { createdAt: 'desc' }
@@ -523,11 +627,6 @@ Explain in one brief sentence (max 15 words) why these notes are connected. Focu
return []
}
const targetEmbedding = targetEmbeddingStr
? embeddingService.fromVectorString(targetEmbeddingStr)
: null
if (!targetEmbedding) return []
// Fetch all other embeddings
const otherNoteIds = otherNotes.map(n => n.id)
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 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
const feedbackInsights = await prisma.memoryEchoInsight.findMany({
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
for (const otherNote of otherNotes) {
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
// 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))
)
// Time diversity filter
if (daysApart < minDaysApart) {
// Time diversity filter (clips récents autorisés sans délai de 7 jours)
if (!this.passesTimeDiversityFilter(daysApart, targetNote, otherNote, demoMode)) {
continue
}
@@ -591,7 +691,8 @@ Explain in one brief sentence (max 15 words) why these notes are connected. Focu
const similarity = cosineSimilarity(targetEmbedding, otherEmbedding)
// 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(otherNote.id) || 0)
if (similarity >= adjustedThreshold) {
@@ -599,13 +700,13 @@ Explain in one brief sentence (max 15 words) why these notes are connected. Focu
note1: {
id: targetNote.id,
title: targetNote.title,
content: targetNote.content.substring(0, 200) + (targetNote.content.length > 200 ? '...' : ''),
content: this.connectionPlainText(targetNote.title, targetNote.content),
createdAt: targetNote.createdAt
},
note2: {
id: otherNote.id,
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
},
similarityScore: similarity,

View File

@@ -333,26 +333,27 @@ export class SemanticSearchService {
* SECURITY: Uses parameterized bind params ($1, $2).
* noteId validated via assertSafeId().
*/
async indexNote(noteId: string): Promise<void> {
async indexNote(noteId: string, options?: { force?: boolean }): Promise<void> {
try {
assertSafeId(noteId, 'noteId')
const note = await prisma.note.findUnique({
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(
note.content,
null,
note.lastAiAnalysis
note.lastAiAnalysis,
{ force: options?.force, isClip: Boolean(note.sourceUrl?.trim()) },
)
if (!shouldRegenerate) return
const { embedding } = await embeddingService.generateEmbedding(note.content)
const { embedding } = await embeddingService.generateNoteEmbedding(note.title, note.content)
const vecStr = embeddingService.toVectorString(embedding)
await prisma.$queryRawUnsafe(

View File

@@ -1,3 +1,5 @@
import { stripHtmlToPlainText, tokenizeForSimilarity } from '@/lib/text/plain-text'
export interface ExtractedBlock {
blockId: string
content: string
@@ -9,7 +11,7 @@ export function extractBlocksFromHtml(html: string): ExtractedBlock[] {
let match
while ((match = regex.exec(html)) !== null) {
const blockId = match[1]
const content = match[2].replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
const content = stripHtmlToPlainText(match[2])
if (content.length >= 10) {
blocks.push({ blockId, content })
}
@@ -18,16 +20,8 @@ export function extractBlocksFromHtml(html: string): ExtractedBlock[] {
}
export function jaccardSimilarity(a: string, b: string): number {
const tokenize = (s: string) =>
new Set(
s
.toLowerCase()
.replace(/[^\w\s]/g, '')
.split(/\s+/)
.filter(w => w.length > 3)
)
const A = tokenize(a)
const B = tokenize(b)
const A = tokenizeForSimilarity(a)
const B = tokenizeForSimilarity(b)
if (A.size === 0 || B.size === 0) return 0
let intersection = 0
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
let match
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) {
blocks.push({ blockId: '', content })
}

View 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,
}
}

View 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>`
}

View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
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 }
}

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

View File

@@ -8,10 +8,10 @@ import prisma from '@/lib/prisma'
export async function upsertNoteEmbedding(noteId: string, embedding: number[]): Promise<void> {
const vecStr = `[${embedding.join(',')}]`
await prisma.$executeRawUnsafe(
`INSERT INTO "NoteEmbedding" ("id", "noteId", "embedding", "createdAt", "updatedAt")
VALUES (gen_random_uuid(), $1, $2::vector, now(), now())
`INSERT INTO "NoteEmbedding" ("id", "noteId", "embedding", "createdAt")
VALUES (gen_random_uuid(), $1, $2::vector, now())
ON CONFLICT ("noteId")
DO UPDATE SET "embedding" = EXCLUDED."embedding", "updatedAt" = now()`,
DO UPDATE SET "embedding" = EXCLUDED."embedding"`,
noteId,
vecStr
)

View 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(/&nbsp;/gi, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/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))
}

View File

@@ -83,6 +83,7 @@ export interface Note {
autoGenerated?: boolean | null;
aiProvider?: string | null;
historyEnabled?: boolean;
sourceUrl?: string | null;
matchType?: 'exact' | 'related' | null;
searchScore?: number | null;
}

View File

@@ -85,7 +85,8 @@
"richtext": "Rich Text",
"markdown": "Markdown",
"text": "Plain text",
"checklist": "Checklist"
"checklist": "Checklist",
"clip": "Web clip"
},
"listItem": "List item",
"addListItem": "+ List item",
@@ -861,9 +862,47 @@
"brainstormInvite": "Brainstorm",
"brainstormJoined": "Brainstorm",
"systemNotification": "System",
"clipSaved": "Web clip saved",
"downloadFailed": "Download failed",
"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": {
"home": "Home",
"notes": "Notes",
@@ -880,6 +919,9 @@
"trash": "Trash",
"support": "Support Memento ☕",
"reminders": "Reminders",
"graphView": "Link map",
"insights": "Semantic themes",
"revision": "Review",
"userManagement": "User Management",
"accountSettings": "Account Settings",
"manageAISettings": "Manage AI Settings",
@@ -1745,6 +1787,8 @@
"imagesLabel": "Images",
"notebookLabel": "Notebook",
"typeLabel": "Type",
"sourceWebLabel": "Web source",
"openSource": "Open original page",
"createdLabel": "Created",
"modifiedLabel": "Updated",
"labelsSection": "Labels",
@@ -2847,11 +2891,23 @@
"title": "Graph View",
"notesCount": "{count} notes",
"connectionsCount": "{count} connections",
"visibleConnections": "{count} visible",
"globalView": "Fit to View",
"searchPlaceholder": "Filter...",
"noNotesFound": "No notes found",
"notebooks": "Notebooks",
"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",
"connections": "{count} connection",
"connectionsPlural": "{count} connections",
@@ -2866,6 +2922,82 @@
"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": {
"banner": {
"title": "Cookie Preferences",

View File

@@ -714,24 +714,54 @@
"error": "خطا در بارگذاری ارتباطات"
},
"comparison": {
"title": "💡 مقایسه یادداشت‌ها",
"similarityInfo": "این یادداشت‌ها با {similarity}% شباهت مرتبط هستند",
"highSimilarityInsight": "این یادداشت‌ها در مورد یک موضوع با درجه بالایی از شباهت صحبت می‌کنند. می‌توانند ادغام یا تلفیق شوند.",
"title": "مقایسه کنار هم",
"subtitle": "یادداشت‌های مرتبط را مرور کنید تا تصمیم بگیرید چه چیزی را نگه دارید یا ادغام کنید.",
"similarityInfo": "این یادداشت‌ها با {similarity}٪ شباهت به هم مرتبط‌اند",
"highSimilarityInsight": "این یادداشت‌ها دربارهٔ یک موضوع با شباهت بالا هستند. می‌توان آن‌ها را ادغام یا یکپارچه کرد.",
"stayOnCurrentNote": "در یادداشت فعلی می‌مانید. این پنجره را ببندید تا به نوشتن ادامه دهید.",
"untitled": "بدون عنوان",
"clickToView": "برای مشاهده یادداشت کلیک کنید",
"helpfulQuestion": "این مقایسه مفید است؟",
"helpfulQuestion": "آیا این مقایسه مفید بود؟",
"helpful": "مفید",
"notHelpful": "غیرمفید"
},
"preview": {
"subtitle": "پیش‌نمایش یادداشت مرتبط — از یادداشتی که ویرایش می‌کنید خارج نمی‌شوید.",
"loadError": "بارگذاری محتوای این یادداشت ممکن نشد."
},
"editorSection": {
"title": "یادداشت‌های مرتبط ({count})",
"loading": "در حال بارگذاری...",
"title": "یادداشت‌های مرتبط ({count})",
"loading": "در حال بارگذاری",
"view": "مشاهده",
"viewLinkedNote": "مشاهده یادداشت مرتبط",
"compare": "مقایسه",
"merge": "ادغام",
"compareAll": "مقایسه همه",
"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": {
"title": "🔗 ادغام هوشمند",

View File

@@ -85,7 +85,8 @@
"richtext": "Texte enrichi",
"markdown": "Markdown",
"text": "Texte brut",
"checklist": "Liste de tâches"
"checklist": "Liste de tâches",
"clip": "Clip web"
},
"listItem": "Élément de liste",
"addListItem": "+ Élément de liste",
@@ -867,9 +868,47 @@
"brainstormInvite": "Brainstorm",
"brainstormJoined": "Brainstorm",
"systemNotification": "Système",
"clipSaved": "Clip web enregistré",
"downloadFailed": "Échec du téléchargement",
"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": {
"home": "Accueil",
"notes": "Notes",
@@ -886,6 +925,9 @@
"trash": "Corbeille",
"support": "Soutenir Memento ☕",
"reminders": "Rappels",
"graphView": "Carte des liens",
"insights": "Thèmes sémantiques",
"revision": "Révisions",
"userManagement": "Gestion des utilisateurs",
"accountSettings": "Paramètres du compte",
"manageAISettings": "Gérer les paramètres IA",
@@ -1751,6 +1793,8 @@
"imagesLabel": "Images",
"notebookLabel": "Carnet",
"typeLabel": "Type",
"sourceWebLabel": "Source web",
"openSource": "Ouvrir la page d'origine",
"createdLabel": "Créée le",
"modifiedLabel": "Modifiée",
"labelsSection": "Étiquettes",
@@ -2851,11 +2895,23 @@
"title": "Vue en graphe",
"notesCount": "{count} notes",
"connectionsCount": "{count} liens",
"visibleConnections": "{count} visibles",
"globalView": "Vue globale",
"searchPlaceholder": "Filtrer...",
"noNotesFound": "Aucune note trouvée",
"notebooks": "Carnets",
"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",
"connections": "{count} liaison",
"connectionsPlural": "{count} liaisons",
@@ -2870,6 +2926,82 @@
"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 lIA. Lanalyse va dabord les préparer (cela peut prendre plusieurs minutes).",
"vsGraphHint": "Ce nest pas la même chose que la « Carte des liens » (icône réseau dans la barre latérale) : ici, lIA regroupe vos notes par thèmes.",
"openGraphMap": "Ouvrir la carte des liens",
"analysisFailed": "Lanalyse 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": {
"banner": {
"title": "Préférences de Cookies",

View File

@@ -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()

View 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()

View File

@@ -0,0 +1,2 @@
-- AlterTable: web clip source URL on notes
ALTER TABLE "Note" ADD COLUMN IF NOT EXISTS "sourceUrl" TEXT;

View File

@@ -170,6 +170,8 @@ model Note {
lastAiAnalysis DateTime?
trashedAt DateTime?
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
illustrationSvg String?
tsv Unsupported("tsvector")?

View 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())

View 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())