3 Commits

Author SHA1 Message Date
Antigravity
4fe31ebc99 fix(quotas): unifier le décompte IA (BYOK, rollback) et combler les fuites
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 6m55s
CI / Deploy production (on server) (push) Successful in 36s
Centralise la réserve via ai-quota, corrige admin unavailable (-1), brancher les routes sans quota et le host-pays brainstorm, avec usage-meter élargi, noms de clusters, MCP et ajustements dashboard/insights.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-15 20:42:25 +00:00
Antigravity
30da592ba2 feat(dashboard): tableau de bord Second Brain configurable avec chargement progressif
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 7m3s
CI / Deploy production (on server) (push) Successful in 23s
Briefing granulaire, pistes rapides puis enrichissement async, layout persisté v5,
suggestions agents, intégration Gmail et navigation sidebar alignée sur /home.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 16:50:53 +00:00
Antigravity
d38a99586b feat(sidebar): redimensionnement largeur et zone carnets, retrait note du jour
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 7m5s
CI / Deploy production (on server) (push) Successful in 23s
Ajoute une poignée drag pour la largeur de la sidebar (persistée) et sépare inbox/arbre carnets avec slider vertical ; supprime le bouton Note du jour devenu inutile.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 11:35:03 +00:00
122 changed files with 10769 additions and 1063 deletions

View File

@@ -78,3 +78,6 @@ TELEGRAM_CHAT_ID=<var>
# Monitoring
METRICS_TOKEN=<secret>
GRAFANA_ADMIN_PASSWORD=<secret>
# Monitoring LAN : définir GRAFANA_BIND et GRAFANA_URL dans .env.docker (voir deploy)
# GRAFANA_BIND=127.0.0.1
# GRAFANA_URL=http://localhost:3002

View File

@@ -174,6 +174,8 @@ jobs:
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
METRICS_TOKEN: ${{ secrets.METRICS_TOKEN }}
GRAFANA_ADMIN_PASSWORD: ${{ secrets.GRAFANA_ADMIN_PASSWORD }}
GRAFANA_BIND: ${{ vars.GRAFANA_BIND }}
GRAFANA_URL: ${{ vars.GRAFANA_URL }}
MCP_API_KEY: ${{ secrets.MCP_API_KEY }}
run: |
ENV_FILE="/opt/memento/.env.docker"
@@ -232,6 +234,8 @@ jobs:
upsert TELEGRAM_CHAT_ID "$TELEGRAM_CHAT_ID"
upsert METRICS_TOKEN "$METRICS_TOKEN"
upsert GRAFANA_ADMIN_PASSWORD "$GRAFANA_ADMIN_PASSWORD"
upsert GRAFANA_BIND "$GRAFANA_BIND"
upsert GRAFANA_URL "$GRAFANA_URL"
upsert MCP_API_KEY "$MCP_API_KEY"
# Write metrics token file for Prometheus (same secret)
[ -n "$METRICS_TOKEN" ] && echo "$METRICS_TOKEN" > /opt/memento/monitoring/metrics-token && chmod 600 /opt/memento/monitoring/metrics-token || true

View File

@@ -63,6 +63,8 @@ jobs:
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
METRICS_TOKEN: ${{ secrets.METRICS_TOKEN }}
GRAFANA_ADMIN_PASSWORD: ${{ secrets.GRAFANA_ADMIN_PASSWORD }}
GRAFANA_BIND: ${{ vars.GRAFANA_BIND }}
GRAFANA_URL: ${{ vars.GRAFANA_URL }}
MCP_API_KEY: ${{ secrets.MCP_API_KEY }}
run: |
ENV_FILE="/opt/memento/.env.docker"
@@ -121,6 +123,8 @@ jobs:
upsert TELEGRAM_CHAT_ID "$TELEGRAM_CHAT_ID"
upsert METRICS_TOKEN "$METRICS_TOKEN"
upsert GRAFANA_ADMIN_PASSWORD "$GRAFANA_ADMIN_PASSWORD"
upsert GRAFANA_BIND "$GRAFANA_BIND"
upsert GRAFANA_URL "$GRAFANA_URL"
upsert MCP_API_KEY "$MCP_API_KEY"
[ -n "$METRICS_TOKEN" ] && echo "$METRICS_TOKEN" > /opt/memento/monitoring/metrics-token && chmod 600 /opt/memento/monitoring/metrics-token || true
echo "env.docker sanity-check passed ($(wc -l < "$ENV_FILE") lines)"

View File

@@ -2,18 +2,18 @@
## 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 ») ; 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.
- Préfère les échanges en français, avec des explications détaillées et claires (éviter le jargon flou) ; **nom produit** : **Memento** (jamais « Momento ») — libellés, metadata, pages publiques, docs.
- 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 ; **insights Memory Echo** (texte généré/stocké) : langue utilisateur à la génération + fallback i18n à l'affichage (`memoryEcho.defaultInsight`) — jamais d'anglais en dur quand l'UI est FR.
- 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/Memento/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) — 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 ; **sidebar rail** (`sidebar.tsx`) : une seule icône active ; **largeur sidebar responsive** (`components/sidebar.tsx`) — `w-80` (320px) de base, plus large en `xl`/`2xl` (grands écrans) ; garder le fallback `Suspense` de `app/(main)/layout.tsx` **aligné** sur ces largeurs (rail fixe 54px) ; `activeView` synchronisé avec pathname et query (`/insights`, `/revision`, `/home?reminders=1`) ; panneau latéral contextuel par route (pas la liste carnets sur `/insights` ou Rappels) ; **`/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.
- 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 ; **sidebar rail** (`sidebar.tsx`) : une seule icône active ; **largeur sidebar** (`components/sidebar.tsx`) — redimensionnement utilisateur par poignée sur le bord droit (desktop `md+`, 280560px, persisté `localStorage` `memento-sidebar-width`) ; défaut responsive `w-80` puis plus large en `xl`/`2xl` si aucune valeur stockée ; **hauteur zone carnets** ajustable via poignée horizontale (`memento-sidebar-notebooks-height`, min 120px) ; fallback `Suspense` de `app/(main)/layout.tsx` **aligné** sur les largeurs par défaut (rail fixe 54px) ; **pas de bouton « Note du jour »** dans la sidebar (retiré volontairement) ; **dashboard Second Brain** (`/home`, `dashboard-view.tsx`) : vue d'ensemble **interactive** (pas dashboard statique/mort) — layout **v5** configurable (catalogue ~15 widgets, persisté `/api/dashboard/layout`) ; widget **« Prochaines pistes »** (`next-paths`) = recommandations actionnables depuis la dernière note éditée (fast paths briefing puis enrichissement async `/api/briefing/paths`) ; widget **« Revue quotidienne »** = **checklist matinale interactive** (inbox, découvertes IA, connexion, flashcards) — **pas** une revue automatique de contenu ; cartes cliquables, section Memory Echo « L'IA a trouvé » exploitable (pas vide après 1ère visite), consentement/provider IA + **quotas** vérifiés sur chaque action IA (dashboard et agents) ; icônes **Lucide** (pas emojis système type 🎯) ; `MindMapCard` remplace l'ancien `MiniResonanceGraph` D3 (graphe décoratif qui débordait) ; navigation sidebar alignée sur `/home` ; `activeView` synchronisé avec pathname et query (`/insights`, `/revision`, `/home?reminders=1`) ; panneau latéral contextuel par route (pas la liste carnets sur `/insights` ou Rappels) ; **`/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** ; bouton Menu immersif `/insights` : **cacher la sidebar ET agrandir le contenu principal** (pas de bandeau beige vide — attention `twMerge` qui peut faire perdre `fixed` au profit de `relative`) ; 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 (app **et** extension Web Clipper) ; **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. **CI/CD Gitea spécifique** : (1) `actions/upload-artifact` et `download-artifact` doivent utiliser **@v3** (pas @v4 — non supporté par Gitea, erreur `GHESNotSupportedError`) ; (2) `Dockerfile.socket.prebuilt` doit utiliser `--legacy-peer-deps` dans `npm install` (conflit TipTap 3.22.5 vs 3.23.6) ; (3) le serveur de prod (192.168.1.190) **ne peut pas pull Docker Hub** (DNS cassé) — le build Docker complet échoue, seul le chemin prebuilt artifact fonctionne ; (4) `docker-entrypoint.sh` applique les migrations Prisma **avant** de démarrer le serveur Next.js (ordre correct) ; (5) rollback d'urgence : `docker tag memento-memento-note:rollback memento-memento-note:latest && docker compose up -d --force-recreate memento-note` ; (6) `scripts/deploy-prod.sh` charge `/opt/memento/.env.docker` via un **parseur robuste ligne-par-ligne** (jamais `source` — crash `unexpected EOF` sur guillemet non fermé) et teste Postgres avec `pg_isready` + credentials **du conteneur** (`$POSTGRES_USER` interne) ; (7) lint CI = `eslint --max-warnings 9999` : les **warnings ne bloquent pas**, seules les **erreurs** bloquent (ex. `no-html-link-for-pages``<Link>` obligatoire pour la navigation interne, pas `<a href="/">`) ; **TRAVAILLER SUR UNE BRANCHE** pendant le dev, ne push sur `main` que quand le code est testé et fonctionnel — chaque push sur `main` déclenche un déploiement automatique en production.
- 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), **notamment navigation recherche/liste notes et vue `/insights` vs fichiers prototype** — l'utilisateur sanctionne fermement les fausses déclarations ; **ouverture note liée depuis l'éditeur** (ex. bloc live « Ouvrir ») : **split peek inline** animé (`lib/note-peek-sync.ts`, `note-editor-split-peek.tsx` — éditeur courant à **gauche**, note liée en lecture seule à **droite** en LTR ; **inversé en RTL** `fa`/`ar`), **pas nouvel onglet** ; **ne jamais annuler du code non commité** (`git checkout`, reset fichier) **sans demande explicite** (perte de travail documentée, ex. drag handle éditeur) ; **ne jamais remettre du code que l'utilisateur a explicitement retiré sans demander d'abord** (ex. `reserveUsageOrThrow` retiré intentionnellement de `organize-notebook.ts` — agent l'a remis sans demander → user mécontent) ; **correction i18n ou spec doc** : **ne pas refondre logique/UI** hors scope (ex. US-4 `structuredViewBlock` — garder le dual-mode base locale + lien carnet, pas de suppression du mode local) ; 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 (pas d'auto-validation ni d'enchaînement de code non demandé) ; suivi dans `docs/user-stories.md` ; briefs pour outil de design externe sur demande ; **avant de développer une story, vérifier d'abord dans le code si la feature existe déjà** — les docs de stories (`docs/story-nextgen-editor.md`, `docs/user-stories.md`) sont **souvent périmés** (features livrées mais marquées « à faire »).
- **Facturation & quotas IA** : limites mensuelles, tiers (BASIC/PRO/BUSINESS/ENTERPRISE) et Price IDs Stripe via **Admin > Facturation & quotas** (`/admin/billing`) — pas via `.env` pour le métier ; secrets Stripe (`STRIPE_SECRET_KEY`, webhook) restent en env serveur ; doc `memento-note/docs/admin-billing-quotas-guide.md`.
- 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 ; **ne jamais hardcoder d'IP/URL** dans le repo ou les défauts CI — uniquement variables d'env (ex. `GRAFANA_BIND`, `GRAFANA_URL`) ; la config runtime reste sur le serveur dans `.env.docker`. **Machines** : agent/dev = **192.168.1.83** (devSanbox) ; prod = **192.168.1.190** (`/opt/memento`) — **ne jamais confondre** les deux au diagnostic services. **CI/CD Gitea spécifique** : (1) CI `ubuntu-24.04` = lint + tests + build (validation) ; deploy `docker-host` = build sur le serveur → `/tmp/web-artifact.tgz` `deploy-prod.sh`**sans** upload/download-artifact inter-runners (stockage Gitea instable ; si artifacts utilisés, **@v3** uniquement — @v4 `GHESNotSupportedError`) ; (2) `Dockerfile.socket.prebuilt` doit utiliser `--legacy-peer-deps` dans `npm install` (conflit TipTap 3.22.5 vs 3.23.6) ; (3) le serveur de prod (192.168.1.190) **ne peut pas pull Docker Hub** (DNS cassé) — le build Docker complet échoue, seul le chemin prebuilt artifact fonctionne ; (4) `docker-entrypoint.sh` applique les migrations Prisma **avant** de démarrer le serveur Next.js (ordre correct) ; (5) rollback d'urgence : `docker tag memento-memento-note:rollback memento-memento-note:latest && docker compose up -d --force-recreate memento-note` ; (6) `scripts/deploy-prod.sh` charge `/opt/memento/.env.docker` via un **parseur robuste ligne-par-ligne** (jamais `source` — crash `unexpected EOF` sur guillemet non fermé) et teste Postgres avec `pg_isready` + credentials **du conteneur** (`$POSTGRES_USER` interne) ; (7) lint CI = `eslint --max-warnings 9999` : les **warnings ne bloquent pas**, seules les **erreurs** bloquent (ex. `no-html-link-for-pages``<Link>` obligatoire pour la navigation interne, pas `<a href="/">`) ; **TRAVAILLER SUR UNE BRANCHE** pendant le dev, ne push sur `main` que quand le code est testé et fonctionnel — **avant push `main`** : exécuter localement `npm run lint`, `npm run test:unit`, `npm run build` (même enchaînement que CI) ; chaque push sur `main` déclenche un déploiement automatique en production.
- 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) ; prod : `AUTH_GOOGLE_ID`/`AUTH_GOOGLE_SECRET` dans `/opt/memento/.env.docker` **et** variables/secrets Gitea Actions (workflow deploy n'écrit pas les valeurs vides) — absence → `invalid_client` ; redirect URI exacte `https://memento-note.com/api/auth/callback/google` ; config via **Google Auth Platform** (Branding → Audience **In production**, pas Testing → Clients Web) ; `ALLOW_REGISTRATION=true` requis pour inscription email (env ou `SystemConfig` en base — la DB peut primer sur l'env).
- Priorité absolue à la qualité UX, même si l'implémentation est complexe (« je m'en fous si c'est complexe ») ; pour les **tâches ops** (config OAuth prod, serveur), préfère un **guidage interactif étape par étape** plutôt qu'un bloc d'instructions monolithique ; **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 ; **ouverture note liée depuis l'éditeur** (ex. bloc live « Ouvrir ») : **split peek inline** animé (`lib/note-peek-sync.ts`, `note-editor-split-peek.tsx` — éditeur courant à **gauche**, note liée en lecture seule à **droite** en LTR ; **inversé en RTL** `fa`/`ar`), **pas nouvel onglet** ; **ne jamais annuler du code non commité** (`git checkout`, reset fichier) **sans demande explicite** (perte de travail documentée, ex. drag handle éditeur) ; **ne jamais remettre du code que l'utilisateur a explicitement retiré sans demander d'abord** (ex. `reserveUsageOrThrow` retiré intentionnellement de `organize-notebook.ts` — agent l'a remis sans demander → user mécontent) ; **correction i18n ou spec doc** : **ne pas refondre logique/UI** hors scope (ex. US-4 `structuredViewBlock` — garder le dual-mode base locale + lien carnet, pas de suppression du mode local) ; 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 (pas d'auto-validation ni d'enchaînement de code non demandé) ; suivi dans `docs/user-stories.md` ; briefs pour outil de design externe sur demande ; **avant de développer une story, vérifier d'abord dans le code si la feature existe déjà** — les docs de stories (`docs/story-nextgen-editor.md`, `docs/user-stories.md`) sont **souvent périmés** (features livrées mais marquées « à faire ») ; **dashboard Second Brain** : même rythme — **une feature à la fois**, validation explicite avant la suivante.
- **Facturation & quotas IA** : limites mensuelles, tiers (BASIC/PRO/BUSINESS/ENTERPRISE) et Price IDs Stripe via **Admin > Facturation & quotas** (`/admin/billing`) — pas via `.env` pour le métier ; secrets Stripe (`STRIPE_SECRET_KEY`, webhook) restent en env serveur ; doc `memento-note/docs/admin-billing-quotas-guide.md` ; **tier BASIC sans accès MCP** ; chaque usage IA (dashboard, agents, MCP si autorisé, etc.) doit **décompter le quota** — ne pas affirmer un déploiement prod sans vérif réelle (ex. travail MCP/quotas encore local).
## Learned Workspace Facts
@@ -23,9 +23,9 @@
- Workflow BMad : stories sous `docs/` (ex. `3-4-host-pays-session-logic.md`), suivi sprint dans `docs/sprint-status.yaml` et stories courantes dans `docs/user-stories.md` ; skills sous `.claude/skills/bmad-*` ; `_bmad-output/planning-artifacts` souvent vide — planification de référence dans `docs/` ; préférer **une user story par feature** (pas de stories groupées).
- PostgreSQL Docker (`memento-postgres`) port 5433 ; Redis (`memento-redis`) port 6379 ; règles Prisma/DB dans `CLAUDE.md`.
- **Admin facturation** : page `/admin/billing` (`billing-admin-client.tsx`, actions `admin-billing.ts`) — quotas par feature IA et config Stripe métier en base, effet ~60 s ; guide `memento-note/docs/admin-billing-quotas-guide.md`.
- Production : dépôt `/opt/memento` sur `192.168.1.190`, conteneur `memento-note` sur le port **3000**, URL publique **https://memento-note.com** (nginx + Cloudflare ; ancien domaine note.parsanet.org) ; `NEXTAUTH_URL` aligné sur ce domaine ; email sortant via **Resend** (`SMTP_FROM` ex. `noreply@memento-note.com`, domaine vérifié sur resend.com) ; deploy (`deploy.yaml` / `deploy-prod.sh`) **sans toucher Postgres** (pas de `postgresql-client`, pas de migrations auto en prod).
- CI/CD Gitea : `.gitea/workflows/ci.yaml` — CI sur `ubuntu-24.04`, deploy sur runner **`docker-host`** (sur le serveur) ; deploy manuel via `.gitea/workflows/deploy.yaml` ou `bash scripts/deploy-prod.sh`.
- **Hosts** : machine agent/dev (**192.168.1.83**, Vibe/annotations locales) ≠ prod (**192.168.1.190**, dépôt `/opt/memento`) — ne jamais diagnostiquer l'un pour l'autre. Production : conteneur `memento-note` port **3000**, URL publique **https://memento-note.com** (nginx + Cloudflare ; ancien domaine note.parsanet.org) ; `NEXTAUTH_URL` aligné sur ce domaine ; auth prod requiert `AUTH_GOOGLE_ID`/`AUTH_GOOGLE_SECRET` dans `.env.docker` (sinon OAuth `invalid_client`) et `ALLOW_REGISTRATION=true` pour l'inscription email ; email sortant via **Resend** (`SMTP_FROM` ex. `noreply@memento-note.com`, domaine vérifié sur resend.com) ; monitoring Grafana via `GRAFANA_BIND` / `GRAFANA_URL` dans `.env.docker` (pas d'IP en dur dans le compose) ; deploy (`deploy.yaml` / `deploy-prod.sh`) **sans toucher Postgres** (pas de `postgresql-client`, pas de migrations auto en prod).
- CI/CD Gitea : `.gitea/workflows/ci.yaml` — CI sur `ubuntu-24.04` (lint + tests + build validation), deploy sur runner **`docker-host`** (build local `/tmp/web-artifact.tgz`, sans artifacts inter-runners) ; deploy manuel via `.gitea/workflows/deploy.yaml` ou `bash scripts/deploy-prod.sh`.
- Migrations prebuilt + vérif deploy : `docker compose exec memento-note node ./node_modules/prisma/build/index.js migrate deploy` (pas `npx prisma`) ; helper `scripts/migrate-docker.sh` ; `GET /api/build-info` (SHA Git) ; comparer `127.0.0.1:3000` et domaine Cloudflare — purger cache si versions divergent ; 403 `/api/manifest` côté domaine = souvent Cloudflare.
- Éditeur riche : `rich-text-editor.tsx``immediatelyRender: false` ; activer **`shouldRerenderOnTransaction: false`** (quick win perf TipTap 2.5) ; **drag handle / menu bloc** via **`@tiptap/extension-drag-handle-react`** (spec officielle — pas de double plugin `DragHandleExtension` + composant React, pas de repositionnement maison) ; poignée dans **colonne gutter fixe** du wrapper (padding + `getReferencedVirtualElement`), pas sur le bord des listes/numéros ; CSS : **pas `opacity:0` sur `.drag-handle`** (visibilité gérée par le plugin) ; config/callbacks **stables hors composant** ; fondation blocs : `tiptap-unique-id-extension.ts` / **`data-id` persisté à la sauvegarde** (références « Copier la référence ») ; **Smart Paste** : `lib/editor/smart-paste-extension.ts` ; **peek split** note source : `lib/note-peek-sync.ts` + `note-editor-split-peek.tsx` ; **conversion markdown → texte enrichi** : un **seul** convertisseur `lib/markdown-to-html.ts` (`markdownToHtml`, gfm+breaks) + une **action atomique** `convertToRichText(html)` dans `note-editor-context.tsx` (applique le HTML **immédiatement** via `setContentImmediate` — pas le `setContent` débouncé 800ms — et bascule `isMarkdown=false`) ; toolbar (`handleConvertToRichtext`) **et** chat IA contextuel (`contextual-ai-chat.tsx`, action `toRichText``/api/ai/convert-markdown`) passent par cette action unique (ne pas dupliquer la conversion ni oublier de basculer `isMarkdown`) ; **US-4 `structuredViewBlock`** (`tiptap-structured-view-block-extension.tsx`, `structured-view-block-embed.tsx`) : **dual-mode** — base locale autonome par défaut (`/database`, `/vue`, `isLocal: true`) + option « Lier à un carnet » (Structured Views) ; i18n `structuredViewBlock.*` ; **rejeté** : ancien `databaseBlock` « Auteurs & Œuvres » et spec embed-only `docs/story-nextgen-editor-us4-redesign.md` ; epic active `docs/story-nextgen-editor.md` — priorité **PERF > NEXTGEN > UX > MOBILE > MARKDOWN**.
- Sync mutations notes entre composants : `memento-note/lib/note-change-sync.ts` (`emitNoteChange`, événement `NOTE_CHANGE_EVENT`) ; tracking quotas IA : événement `ai-usage-changed` (window) — doit être dispatché depuis **tous** les points d'appel IA (recherche sémantique, auto-tag, auto-title, chat, reformulation, analyse carnet, etc.) ; UI quotas : `usage-meter.tsx` (polling 5s + écoute événement).
- Roadmap / écart prototype vs prod : Web Clipper — **`ClipperSimulator.tsx` = référence design uniquement** (pas de simulateur en prod) ; extension **`memento-note/extension/`** v0.3 **Side Panel** (clip page/sélection/lien ; popup Chrome se ferme au clic page — Side Panel pour la sélection) ; i18n extension **15 langues** (`_locales/`, détection locale navigateur ; script `extension/i18n/generate-translations.cjs`) ; **`host_permissions`** incl. LAN ; **URL serveur configurable en dev**, adresse prod figée en release ; cookies/session alignés avec l'instance cible ; **Flashcards IA SM-2 livrées** : `/revision`, `/api/flashcards/*`, génération depuis l'éditeur (GraduationCap) — réf. prototype `RevisionView.tsx` ; **Structured Views partiellement livrées** : schéma par carnet, Table/Kanban, champs partagés et valeurs par note (`/home` + toolbar carnet) — **suivi de tâches par carnet via Kanban structuré** (pas de vue agrégée Notes/Tâches sur la home ; cases à cocher inline dans les notes) ; **Éditeur Next-Gen livré** (doc `docs/story-nextgen-editor.md` **périmé** — vérifier le code) : US-1 poignée gutter unique (`editor-block-drag-handle.tsx` + `lib/editor/global-drag-handle-extension.ts`, extension Novel `tiptap-extension-global-drag-handle`), US-2 menu action bloc (`block-action-menu.tsx`), US-3 Smart Paste transclusion (`smart-paste-extension.ts` + `smart-paste-menu.tsx`), US-4 database inline (`structuredViewBlock`), plus `data-id` / `liveBlock` / peek split ; **`/insights` livré** : `app/(main)/insights/page.tsx` + `network-graph.tsx` (clusters sémantiques, bridge notes ; **`/graph`**) ; **US-TEMPORAL reporté** ; encore en gap : transclusion bidirectionnelle complète, graphe knowledge enrichi (`GraphKnowledgeMap.tsx`) ; publication **Chrome Web Store** : icônes 16/48/128, privacy policy, `host_permissions` prod restreints vs build dev ; **Wizards** : `NotebookOrganizerDialog` (tags/doublons) branché via bouton "Tags IA" dans `home-client.tsx``StructuredViewsWizard` encore **orphelin** (pas de point d'entrée UI) ; **Publication web** (`note-editor-toolbar.tsx`) : deux modes — simple (copie directe) + IA (2-3 templates, reformulation adaptée au contenu, KaTeX pour équations, images incluses) — quota IA consommé uniquement sur publication IA ; **champs DB publication** sur modèle `Note` : `publicSlug` (pas `publishedSlug`), `publishedContent` (pas `publishedHtml`) — `publishMode` est paramètre API uniquement (`mode: 'simple' | 'ai'`), **pas** un champ en base.
- Roadmap / écart prototype vs prod : Web Clipper — **`ClipperSimulator.tsx` = référence design uniquement** (pas de simulateur en prod) ; extension **`memento-note/extension/`** v0.3 **Side Panel** (clip page/sélection/lien ; popup Chrome se ferme au clic page — Side Panel pour la sélection) ; i18n extension **15 langues** (`_locales/`, détection locale navigateur ; script `extension/i18n/generate-translations.cjs`) ; **`host_permissions`** incl. LAN ; **URL serveur configurable en dev**, adresse prod figée en release ; cookies/session alignés avec l'instance cible ; **Flashcards IA SM-2 livrées** : `/revision`, `/api/flashcards/*`, génération depuis l'éditeur (GraduationCap) — réf. prototype `RevisionView.tsx` ; **Structured Views partiellement livrées** : schéma par carnet, Table/Kanban, champs partagés et valeurs par note (`/home` + toolbar carnet) — **suivi de tâches par carnet via Kanban structuré** (pas de vue agrégée Notes/Tâches sur la home ; cases à cocher inline dans les notes) ; **Éditeur Next-Gen livré** (doc `docs/story-nextgen-editor.md` **périmé** — vérifier le code) : US-1 poignée gutter unique (`editor-block-drag-handle.tsx` + `lib/editor/global-drag-handle-extension.ts`, extension Novel `tiptap-extension-global-drag-handle`), US-2 menu action bloc (`block-action-menu.tsx`), US-3 Smart Paste transclusion (`smart-paste-extension.ts` + `smart-paste-menu.tsx`), US-4 database inline (`structuredViewBlock`), plus `data-id` / `liveBlock` / peek split ; **`/insights` livré** : `app/(main)/insights/page.tsx` + `network-graph.tsx` (clusters sémantiques, bridge notes ; **`/graph`**) ; **US-TEMPORAL reporté** ; encore en gap : transclusion bidirectionnelle complète, graphe knowledge enrichi (`GraphKnowledgeMap.tsx`) ; publication **Chrome Web Store** : icônes 16/48/128, privacy policy, `host_permissions` prod restreints vs build dev ; **Wizards** : `NotebookOrganizerDialog` (tags/doublons) branché via bouton "Tags IA" dans `home-client.tsx``StructuredViewsWizard` encore **orphelin** (pas de point d'entrée UI) ; **Publication web** (`note-editor-toolbar.tsx`) : deux modes — simple (copie directe) + IA (2-3 templates **visuellement distincts**, reformulation/mise en page **adaptée au contenu** — exercices, toggles, callouts — **images incluses**, KaTeX pour équations) — quota IA consommé uniquement sur publication IA ; l'utilisateur rejette les rendus « copie du texte » / mise en page nulle ; **champs DB publication** sur modèle `Note` : `publicSlug` (pas `publishedSlug`), `publishedContent` (pas `publishedHtml`) — `publishMode` est paramètre API uniquement (`mode: 'simple' | 'ai'`), **pas** un champ en base ; **Second Brain dashboard** (`/home`, `dashboard-view.tsx`) **livré** : layout v5 configurable (~15 widgets, `/api/dashboard/layout`), briefing agrégé (`/api/briefing`), pistes fast+enrich (`/api/briefing/paths`, `paths-fast.ts`), suggestions agents, scan Gmail (`GmailScanHistory`), navigation sidebar `/home` ; bento interactif — `MindMapCard` (clusters), suggestions agents (`AgentSuggestion`, cron), Memory Echo, sentiment, inbox/révisions ; **Gmail OAuth** (`/api/integrations/gmail/*`, pattern Calendar) — scan vols/colis/abonnements → notes + rappels ; crons entrypoint : agents, agent-suggestions, clusters, reminders, gmail-scan, sync-usage.

View File

@@ -150,7 +150,7 @@ services:
cpus: '0.25'
memory: 128M
healthcheck:
test: ["CMD-SHELL", "wget --header \"x-api-key: $${MCP_API_KEY:-dev-key}\" -q -O /dev/null http://localhost:3001/ || exit 1"]
test: ["CMD-SHELL", "wget -q -O /dev/null http://localhost:3001/health || exit 1"]
interval: 30s
timeout: 10s
retries: 3

View File

@@ -28,6 +28,28 @@ function setCachedKey(keyHash, data) {
keyCache.set(keyHash, { data, timestamp: Date.now() });
}
async function getEffectiveTier(prisma, userId) {
if (!userId) return 'BASIC';
const sub = await prisma.subscription.findUnique({
where: { userId },
select: { tier: true, status: true, currentPeriodEnd: true },
});
if (!sub) return 'BASIC';
if (sub.status === 'ACTIVE' || sub.status === 'TRIALING') return sub.tier;
if (
(sub.status === 'PAST_DUE' || sub.status === 'CANCELED') &&
sub.currentPeriodEnd &&
new Date() < new Date(sub.currentPeriodEnd)
) {
return sub.tier;
}
return 'BASIC';
}
function isMcpTierAllowed(tier) {
return tier !== 'BASIC';
}
/**
* Generate a new API key.
* @param {import('@prisma/client').PrismaClient} prisma
@@ -111,6 +133,9 @@ export async function validateApiKey(prisma, rawKey) {
const info = JSON.parse(entry.value);
if (info.keyHash !== keyHash || !info.active) return null;
const tier = await getEffectiveTier(prisma, info.userId);
if (!isMcpTierAllowed(tier)) return null;
info.lastUsedAt = new Date().toISOString();
prisma.systemConfig.update({
where: { key: configKey },

View File

@@ -17,15 +17,21 @@ import {
Clock,
AlertCircle,
ChevronRight,
ChevronDown,
Database,
ArrowRight,
Menu,
Network,
List,
Search,
ArrowUpDown,
X,
} from 'lucide-react'
import { toast } from 'sonner'
import Link from 'next/link'
import { useNotePeek, NotePeekPanel } from '@/components/note-peek'
import { createNote } from '@/app/actions/notes'
import { emitNoteChange } from '@/lib/note-change-sync'
const NetworkGraph = dynamic(
() => import('@/components/network-graph').then(m => ({ default: m.NetworkGraph })),
@@ -99,10 +105,28 @@ export default function InsightsPage() {
const [selectedClusterId, setSelectedClusterId] = useState<string | null>(null)
const [viewMode, setViewMode] = useState<'graph' | 'dashboard'>('dashboard')
const [graphMode, setGraphMode] = useState<'visual' | 'list'>('visual')
const [listFilter, setListFilter] = useState('')
const [listSort, setListSort] = useState<'size' | 'alpha' | 'bridges'>('size')
/** Un seul cluster déplié à la fois (null = tous repliés) */
const [expandedListClusterId, setExpandedListClusterId] = useState<string | null>(null)
const [dashboardFilter, setDashboardFilter] = useState('')
const [bridgesShowAll, setBridgesShowAll] = useState(false)
const [isolatedShowAll, setIsolatedShowAll] = useState(false)
const [suggestionsShowAll, setSuggestionsShowAll] = useState(false)
const [actingSuggestionKey, setActingSuggestionKey] = useState<string | null>(null)
const [dashboardSectionOpen, setDashboardSectionOpen] = useState({
recalc: false,
isolated: true,
bridges: true,
suggestions: false,
})
const [lastSyncTime, setLastSyncTime] = useState<string>('')
const peek = useNotePeek()
const prefersReducedMotion = useReducedMotion()
const DASHBOARD_PREVIEW = 8
const SUGGESTIONS_PREVIEW = 3
useEffect(() => {
loadInitialData()
}, [])
@@ -126,6 +150,43 @@ export default function InsightsPage() {
return clusters.filter(c => !networkedIds.has(c.id))
}, [clusters, bridgeNotes])
const listClusters = useMemo(() => {
const q = listFilter.trim().toLowerCase()
const rows = clusters.map(cluster => {
const clusterNotes = notes.filter(n => cluster.noteIds.includes(n.id))
const clusterBridges = bridgeNotes.filter(b =>
b.clustersConnected?.some(cid => String(cid) === cluster.id || cid === cluster.clusterId)
)
const name =
cluster.name ||
t('insightsView.clusterFallback', { index: cluster.clusterId })
const matchingNotes = q
? clusterNotes.filter(n =>
(n.title || '').toLowerCase().includes(q)
)
: clusterNotes
const nameMatches = !q || name.toLowerCase().includes(q)
return { cluster, name, clusterNotes, matchingNotes, clusterBridges, nameMatches }
})
const filtered = q
? rows.filter(r => r.nameMatches || r.matchingNotes.length > 0)
: rows
const sorted = [...filtered].sort((a, b) => {
if (listSort === 'alpha') return a.name.localeCompare(b.name, locale)
if (listSort === 'bridges') {
const d = b.clusterBridges.length - a.clusterBridges.length
return d !== 0 ? d : b.clusterNotes.length - a.clusterNotes.length
}
// size (default)
const d = b.clusterNotes.length - a.clusterNotes.length
return d !== 0 ? d : a.name.localeCompare(b.name, locale)
})
return sorted
}, [clusters, notes, bridgeNotes, listFilter, listSort, locale, t])
const bridgeList = useMemo(
() =>
bridgeNotes.map(b => ({
@@ -135,6 +196,53 @@ export default function InsightsPage() {
[bridgeNotes, t]
)
const filteredIsolatedClusters = useMemo(() => {
const q = dashboardFilter.trim().toLowerCase()
if (!q) return isolatedClusters
return isolatedClusters.filter(c =>
(c.name || t('insightsView.clusterFallback', { index: c.clusterId }))
.toLowerCase()
.includes(q)
)
}, [isolatedClusters, dashboardFilter, t])
const filteredBridgeList = useMemo(() => {
const q = dashboardFilter.trim().toLowerCase()
const list = [...bridgeList].sort((a, b) => b.bridgeScore - a.bridgeScore)
if (!q) return list
return list.filter(b => {
const titleMatch = b.title.toLowerCase().includes(q)
const clusterMatch = b.clustersConnected.some(cid => {
const cluster = clusters.find(c => c.id === String(cid))
const name =
cluster?.name || t('insightsView.clusterFallback', { index: cid })
return name.toLowerCase().includes(q)
})
return titleMatch || clusterMatch
})
}, [bridgeList, dashboardFilter, clusters, t])
const visibleIsolatedClusters = isolatedShowAll || dashboardFilter.trim()
? filteredIsolatedClusters
: filteredIsolatedClusters.slice(0, DASHBOARD_PREVIEW)
const visibleBridgeList = bridgesShowAll || dashboardFilter.trim()
? filteredBridgeList
: filteredBridgeList.slice(0, DASHBOARD_PREVIEW)
/** Affiche au plus 2 thèmes (pair de brokerage) ; le reste derrière +N. */
const getBridgeThemeSlice = useCallback((clusterIds: number[]) => {
const ranked = [...clusterIds].sort((a, b) => {
const sizeA = clusters.find(c => c.id === String(a))?.noteIds.length ?? 0
const sizeB = clusters.find(c => c.id === String(b))?.noteIds.length ?? 0
return sizeB - sizeA
})
return {
primary: ranked.slice(0, 2),
extraCount: Math.max(0, ranked.length - 2),
}
}, [clusters])
// ─── Chargement initial ──────────────────────────────────────────────────────
const loadInitialData = async () => {
@@ -263,6 +371,55 @@ export default function InsightsPage() {
peek.open(noteId)
}
const visibleSuggestions = suggestionsShowAll
? suggestions.slice(0, 12)
: suggestions.slice(0, SUGGESTIONS_PREVIEW)
const handleCreateSuggestion = useCallback(async (s: BridgeSuggestion) => {
const key = `${s.clusterAId}-${s.clusterBId}`
setActingSuggestionKey(key)
try {
const html = `<p>${s.suggestedContent.replace(/</g, '&lt;')}</p><p><em>${s.justification.replace(/</g, '&lt;')}</em></p>`
const note = await createNote({ title: s.suggestedTitle, content: html })
if (!note) throw new Error('create failed')
emitNoteChange({ type: 'created', note })
await fetch('/api/bridge-notes', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ clusterAId: s.clusterAId, clusterBId: s.clusterBId }),
})
setSuggestions(prev =>
prev.filter(x => !(x.clusterAId === s.clusterAId && x.clusterBId === s.clusterBId))
)
toast.success(t('insightsView.suggestions.created'))
router.push(`/home?openNote=${note.id}`)
} catch {
toast.error(t('insightsView.suggestions.createError'))
} finally {
setActingSuggestionKey(null)
}
}, [router, t])
const handleDismissSuggestion = useCallback(async (s: BridgeSuggestion) => {
const key = `${s.clusterAId}-${s.clusterBId}`
setActingSuggestionKey(key)
try {
const res = await fetch('/api/bridge-notes', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ clusterAId: s.clusterAId, clusterBId: s.clusterBId }),
})
if (!res.ok) throw new Error()
setSuggestions(prev =>
prev.filter(x => !(x.clusterAId === s.clusterAId && x.clusterBId === s.clusterBId))
)
} catch {
toast.error(t('insightsView.suggestions.dismissError'))
} finally {
setActingSuggestionKey(null)
}
}, [t])
const isRtl = locale === 'fa' || locale === 'ar'
const motionConfig = prefersReducedMotion
@@ -436,7 +593,10 @@ export default function InsightsPage() {
<Network size={12} /> {t('insightsView.viewGraph')}
</button>
<button
onClick={() => setGraphMode('list')}
onClick={() => {
setGraphMode('list')
if (selectedClusterId) setExpandedListClusterId(selectedClusterId)
}}
className={`flex items-center gap-1.5 px-3 py-1.5 text-[10px] font-bold uppercase tracking-wider rounded-lg transition-all cursor-pointer focus-visible:ring-2 focus-visible:ring-ochre/50 focus-visible:outline-none ${
graphMode === 'list'
? 'bg-ink text-paper dark:bg-white dark:text-black shadow-sm'
@@ -468,53 +628,145 @@ export default function InsightsPage() {
untitledLabel={t('insightsView.unknownNote')}
resetFocusLabel={t('insightsView.resetFocus')}
fitViewLabel={t('insightsView.fitGraphView')}
legendFilterPlaceholder={t('insightsView.legendFilterPlaceholder')}
legendShowMoreLabel={t('insightsView.legendShowMore')}
legendShowLessLabel={t('insightsView.legendShowLess')}
/>
</div>
)}
{/* Vue liste accessible (a11y fallback) */}
{/* Vue liste accessible (accordion + filtre + tri) */}
{graphMode === 'list' && (
<div
className="flex-1 min-h-0 overflow-y-auto custom-scrollbar space-y-4"
className="flex-1 min-h-0 flex flex-col gap-3"
role="region"
aria-label={t('insightsView.listAriaLabel') || 'Accessible cluster list'}
>
{clusters.map(cluster => {
const clusterNotes = notes.filter(n => cluster.noteIds.includes(n.id))
const clusterBridges = bridgeNotes.filter(b =>
b.clustersConnected?.some(cid => String(cid) === cluster.id)
)
<div className="shrink-0 flex flex-col sm:flex-row gap-2">
<div className="relative flex-1 min-w-0">
<Search
size={14}
className="absolute start-3 top-1/2 -translate-y-1/2 text-concrete pointer-events-none"
aria-hidden
/>
<input
type="search"
value={listFilter}
onChange={e => setListFilter(e.target.value)}
placeholder={t('insightsView.listFilterPlaceholder')}
aria-label={t('insightsView.listFilterPlaceholder')}
className="w-full bg-white dark:bg-zinc-800/60 border border-border/40 rounded-xl ps-9 pe-3 py-2 text-xs outline-none focus:ring-2 focus:ring-ochre/30 focus:border-ochre/40 transition-all placeholder:text-concrete/70"
/>
</div>
<label className="shrink-0 flex items-center gap-1.5 px-3 py-2 rounded-xl bg-white dark:bg-zinc-800/60 border border-border/40 text-[10px] font-bold uppercase tracking-wider text-concrete">
<ArrowUpDown size={12} aria-hidden />
<span className="sr-only">{t('insightsView.listSortLabel')}</span>
<select
value={listSort}
onChange={e => setListSort(e.target.value as 'size' | 'alpha' | 'bridges')}
className="bg-transparent outline-none cursor-pointer text-ink dark:text-dark-ink font-bold uppercase tracking-wider"
aria-label={t('insightsView.listSortLabel')}
>
<option value="size">{t('insightsView.listSortSize')}</option>
<option value="bridges">{t('insightsView.listSortBridges')}</option>
<option value="alpha">{t('insightsView.listSortAlpha')}</option>
</select>
</label>
</div>
<div className="flex-1 min-h-0 overflow-y-auto custom-scrollbar space-y-2">
{listClusters.length === 0 ? (
<p className="text-xs text-concrete text-center py-10 px-4">
{t('insightsView.listFilterEmpty')}
</p>
) : (
listClusters.map(({ cluster, name, clusterNotes, matchingNotes, clusterBridges, nameMatches }) => {
const isExpanded = expandedListClusterId === cluster.id
const displayNotes = !listFilter.trim() || nameMatches
? clusterNotes
: matchingNotes
return (
<div
key={cluster.id}
className="p-4 rounded-xl bg-white dark:bg-zinc-800 border border-border/30"
className="rounded-xl bg-white dark:bg-zinc-800 border border-border/30 overflow-hidden"
>
<div className="flex items-center gap-2 mb-3">
<div className="w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: cluster.color }} />
<h3 className="text-xs font-bold uppercase tracking-wider text-ink dark:text-dark-ink">
{cluster.name || t('insightsView.clusterFallback', { index: cluster.clusterId })}
<button
type="button"
onClick={() => {
setExpandedListClusterId(prev =>
prev === cluster.id ? null : cluster.id
)
setSelectedClusterId(cluster.id)
}}
aria-expanded={isExpanded}
className="w-full flex items-center gap-2 p-3.5 text-start hover:bg-black/[0.03] dark:hover:bg-white/[0.04] transition-colors cursor-pointer focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ochre/50 focus-visible:outline-none"
>
{isExpanded ? (
<ChevronDown size={14} className="text-concrete shrink-0" aria-hidden />
) : (
<ChevronRight size={14} className="text-concrete shrink-0" aria-hidden />
)}
<div
className="w-2.5 h-2.5 rounded-full shrink-0"
style={{ backgroundColor: cluster.color }}
aria-hidden
/>
<h3 className="text-xs font-bold uppercase tracking-wider text-ink dark:text-dark-ink truncate flex-1 min-w-0">
{name}
</h3>
<span className="text-[9px] text-concrete ml-auto shrink-0">
<span className="text-[9px] text-concrete shrink-0 tabular-nums">
{clusterNotes.length} {t('insightsView.graphNotesLabel')}
{clusterBridges.length > 0 && ` · ${clusterBridges.length} ${t('insightsView.bridgeCount')}`}
{clusterBridges.length > 0 &&
` · ${clusterBridges.length} ${t('insightsView.bridgeCount')}`}
{listFilter.trim() && matchingNotes.length > 0 && matchingNotes.length < clusterNotes.length && (
<span className="text-ochre">
{` · ${matchingNotes.length} ${t('insightsView.listFilterMatch')}`}
</span>
</div>
<ul className="space-y-1">
{clusterNotes.map(note => (
)}
</span>
</button>
<AnimatePresence initial={false}>
{isExpanded && (
<motion.div
key="notes"
initial={prefersReducedMotion ? false : { height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={prefersReducedMotion ? undefined : { height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
<ul className="px-3 pb-3 space-y-0.5 border-t border-border/20 pt-2">
{displayNotes.length === 0 ? (
<li className="px-2.5 py-2 text-[11px] text-concrete">
{t('insightsView.clusters.emptyCluster')}
</li>
) : (
displayNotes.map(note => (
<li key={note.id}>
<button
type="button"
onClick={() => handleNoteClick(note.id)}
className="w-full text-left px-2.5 py-1.5 rounded-lg hover:bg-black/5 dark:hover:bg-white/5 text-xs text-ink dark:text-dark-ink flex items-center gap-2 cursor-pointer transition-colors focus-visible:ring-2 focus-visible:ring-ochre/50 focus-visible:outline-none"
className="w-full text-start px-2.5 py-1.5 rounded-lg hover:bg-black/5 dark:hover:bg-white/5 text-xs text-ink dark:text-dark-ink flex items-center gap-2 cursor-pointer transition-colors focus-visible:ring-2 focus-visible:ring-ochre/50 focus-visible:outline-none"
>
<ChevronRight size={11} className="text-concrete shrink-0" />
<span className="truncate">{note.title || t('insightsView.unknownNote')}</span>
<ChevronRight size={11} className="text-concrete shrink-0" aria-hidden />
<span className="truncate">
{note.title || t('insightsView.unknownNote')}
</span>
</button>
</li>
))}
))
)}
</ul>
</motion.div>
)}
</AnimatePresence>
</div>
)
})}
})
)}
</div>
</div>
)}
</div>
@@ -525,7 +777,33 @@ export default function InsightsPage() {
viewMode === 'dashboard' ? 'flex' : 'hidden lg:flex'
}`}
>
<div className="p-6 sm:p-8 flex-1 overflow-y-auto custom-scrollbar space-y-10">
<div className="p-6 sm:p-8 flex-1 overflow-y-auto custom-scrollbar space-y-8">
{/* Filtre panneau droit (sticky) */}
<div className="sticky top-0 z-20 -mx-1 px-1 pb-2 bg-[#fcfbfa]/95 dark:bg-[#0D0D0D]/90 backdrop-blur-md">
<div className="relative">
<Search
size={14}
className="absolute start-3 top-1/2 -translate-y-1/2 text-concrete pointer-events-none"
aria-hidden
/>
<input
type="search"
value={dashboardFilter}
onChange={e => {
setDashboardFilter(e.target.value)
if (e.target.value.trim()) {
setBridgesShowAll(true)
setIsolatedShowAll(true)
setDashboardSectionOpen(s => ({ ...s, bridges: true, isolated: true }))
}
}}
placeholder={t('insightsView.dashboardFilterPlaceholder')}
aria-label={t('insightsView.dashboardFilterPlaceholder')}
className="w-full bg-white dark:bg-zinc-800/60 border border-border/40 rounded-xl ps-9 pe-3 py-2.5 text-xs outline-none focus:ring-2 focus:ring-ochre/30 focus:border-ochre/40 transition-all placeholder:text-concrete/70 shadow-sm"
/>
</div>
</div>
{/* Avertissement d'obsolescence (stale banner) */}
{isStale && !isCalculating && (
@@ -618,7 +896,7 @@ export default function InsightsPage() {
{clusters.length}
</div>
<p className="text-[9px] text-concrete font-medium uppercase mt-1">
{t('insightsView.graphNotesLabel')}
{t('insightsView.stats.themesSubtitle')}
</p>
</div>
</div>
@@ -634,26 +912,48 @@ export default function InsightsPage() {
{bridgeNotes.length}
</div>
<p className="text-[9px] text-concrete font-medium uppercase mt-1">
{t('insightsView.bridgeCount')}
{t('insightsView.stats.bridgesSubtitle')}
</p>
</div>
</div>
</div>
{/* ③ 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">
{/* ③ Système de Recalcul (replié par défaut) */}
<section className="rounded-2xl bg-white dark:bg-zinc-800 border border-border/40 shadow-sm overflow-hidden">
<button
type="button"
onClick={() => setDashboardSectionOpen(s => ({ ...s, recalc: !s.recalc }))}
aria-expanded={dashboardSectionOpen.recalc}
className="w-full flex items-center justify-between gap-4 p-5 text-start hover:bg-black/[0.02] dark:hover:bg-white/[0.03] transition-colors cursor-pointer"
>
<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">
{t('insightsView.recalcSystem.title')}
</h4>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="flex items-center gap-1 text-[9.5px] font-bold text-emerald-500 uppercase">
<CheckCircle2 size={11} /> {t('insightsView.recalcSystem.statusSynced')}
</span>
{dashboardSectionOpen.recalc ? (
<ChevronDown size={14} className="text-concrete" aria-hidden />
) : (
<ChevronRight size={14} className="text-concrete" aria-hidden />
)}
</div>
<div className="grid grid-cols-2 gap-4 pt-1">
</button>
<AnimatePresence initial={false}>
{dashboardSectionOpen.recalc && (
<motion.div
initial={prefersReducedMotion ? false : { height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={prefersReducedMotion ? undefined : { height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
<div className="px-5 pb-5 space-y-4 border-t border-border/10 pt-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<span className="text-[9px] text-concrete block">{t('insightsView.recalcSystem.scheduledCron')}</span>
<p className="text-xs text-ink dark:text-dark-ink font-semibold flex items-center gap-1.5">
@@ -677,15 +977,12 @@ export default function InsightsPage() {
})
: '—'}
</span>
<span className="font-bold font-mono text-ink dark:text-dark-ink shrink-0 ml-3">
<span className="font-bold font-mono text-ink dark:text-dark-ink shrink-0 ms-3">
{embeddingStats
? `${embeddingStats.indexed} / ${embeddingStats.total}`
: '—'}
</span>
</div>
<p className="text-[8px] text-concrete italic block leading-relaxed">
{t('insightsView.tipClusters')}
</p>
<button
type="button"
onClick={() => void handleReindexEmbeddings()}
@@ -699,61 +996,122 @@ export default function InsightsPage() {
)}
{isReindexing ? t('insightsView.mapping') : t('insightsView.resync')}
</button>
<span className="text-[8px] text-concrete italic block leading-relaxed">
{t('insightsView.tipClustersAction')}
</span>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</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">
{t('insightsView.isolatedClusters.title', { count: isolatedClusters.length })}
<section className="space-y-3">
<button
type="button"
onClick={() => setDashboardSectionOpen(s => ({ ...s, isolated: !s.isolated }))}
aria-expanded={dashboardSectionOpen.isolated}
className="w-full flex items-center justify-between gap-4 px-1 text-start cursor-pointer group"
>
<div className="flex items-center gap-2 min-w-0">
<AlertCircle size={15} className="text-rose-400 shrink-0" />
<h3 className="text-xs font-bold uppercase tracking-[0.2em] text-ink dark:text-dark-ink truncate">
{t('insightsView.isolatedClusters.title', { count: filteredIsolatedClusters.length })}
</h3>
</div>
<span className="text-[9px] text-concrete italic">{t('insightsView.tipIsolatedAction')}</span>
</div>
{dashboardSectionOpen.isolated ? (
<ChevronDown size={14} className="text-concrete shrink-0" aria-hidden />
) : (
<ChevronRight size={14} className="text-concrete shrink-0" aria-hidden />
)}
</button>
{dashboardSectionOpen.isolated && (
<div className="space-y-2">
{isolatedClusters.map(c => (
<p className="text-[10px] text-concrete italic px-1 leading-relaxed">
{t('insightsView.tipIsolatedAction')}
</p>
{visibleIsolatedClusters.map(c => (
<motion.div
key={c.id}
whileHover={prefersReducedMotion ? undefined : { 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 focus-visible:ring-2 focus-visible:ring-ochre/50 focus-visible:outline-none"
>
<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">
<div className="flex items-center gap-2.5 min-w-0">
<div className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: c.color }} />
<span className="text-xs font-medium text-ink dark:text-dark-ink truncate">
{c.name || t('insightsView.clusterFallback', { index: 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">
<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 shrink-0">
{t('insightsView.isolatedClusters.badge')}
</span>
</motion.div>
))}
{isolatedClusters.length === 0 && (
{!dashboardFilter.trim() && filteredIsolatedClusters.length > DASHBOARD_PREVIEW && (
<button
type="button"
onClick={() => setIsolatedShowAll(v => !v)}
className="w-full py-2 text-[10px] font-bold uppercase tracking-widest text-concrete hover:text-ink border border-dashed border-border/50 rounded-xl cursor-pointer"
>
{isolatedShowAll
? t('insightsView.legendShowLess')
: t('insightsView.legendShowMore', {
count: filteredIsolatedClusters.length - DASHBOARD_PREVIEW,
})}
</button>
)}
{filteredIsolatedClusters.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">
{t('insightsView.isolatedClusters.empty')}
{dashboardFilter.trim()
? t('insightsView.listFilterEmpty')
: t('insightsView.isolatedClusters.empty')}
</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-xs font-bold uppercase tracking-[0.2em] text-ink dark:text-dark-ink">
<section className="space-y-3">
<button
type="button"
onClick={() => setDashboardSectionOpen(s => ({ ...s, bridges: !s.bridges }))}
aria-expanded={dashboardSectionOpen.bridges}
className="w-full flex items-center justify-between gap-4 px-1 text-start cursor-pointer"
>
<div className="flex items-center gap-2 min-w-0">
<Zap size={16} className="text-ochre shrink-0" />
<h3 className="text-xs font-bold uppercase tracking-[0.2em] text-ink dark:text-dark-ink truncate">
{t('insightsView.bridgeNotes.title')}
<span className="ms-2 text-concrete font-mono normal-case tracking-normal">
({filteredBridgeList.length})
</span>
</h3>
</div>
<p className="text-[10px] text-concrete italic px-1 -mt-2 leading-relaxed">{t('insightsView.tipBridgeNotes')}</p>
{dashboardSectionOpen.bridges ? (
<ChevronDown size={14} className="text-concrete shrink-0" aria-hidden />
) : (
<ChevronRight size={14} className="text-concrete shrink-0" aria-hidden />
)}
</button>
{dashboardSectionOpen.bridges && (
<div className="space-y-3">
{bridgeList.map(bridge => (
<p className="text-[10px] text-concrete italic px-1 leading-relaxed">
{t('insightsView.tipBridgeNotes')}
</p>
{visibleBridgeList.map(bridge => {
const { primary, extraCount } = getBridgeThemeSlice(bridge.clustersConnected)
const themeA = clusters.find(c => c.id === String(primary[0]))
const themeB = clusters.find(c => c.id === String(primary[1]))
const nameA =
themeA?.name ||
t('insightsView.clusterFallback', { index: primary[0] ?? 0 })
const nameB =
themeB?.name ||
(primary[1] !== undefined
? t('insightsView.clusterFallback', { index: primary[1] })
: null)
return (
<motion.div
key={bridge.noteId}
whileHover={prefersReducedMotion ? undefined : { x: 4 }}
@@ -763,98 +1121,212 @@ export default function InsightsPage() {
role="button"
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleNoteClick(bridge.noteId) } }}
>
<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">
<div className="flex items-start justify-between gap-3 mb-3">
<h4 className="text-xs font-semibold text-ink dark:text-dark-ink line-clamp-2 flex-1 group-hover:text-ochre transition-colors">
{bridge.title}
</h4>
<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">
{t('insightsView.bridgeNotes.score', {
<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"
title={t('insightsView.bridgeNotes.scoreHint')}
>
{t('insightsView.bridgeNotes.affinity', {
score: (bridge.bridgeScore * 100).toFixed(0),
})}
</span>
</div>
<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={cid}
{nameB ? (
<div className="flex items-center gap-2 flex-wrap">
<button
type="button"
onClick={e => {
e.stopPropagation()
setSelectedClusterId(String(cid))
setSelectedClusterId(String(primary[0]))
}}
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"
className="inline-flex items-center gap-1.5 max-w-[42%] px-2 py-1 rounded-lg bg-black/[0.03] dark:bg-white/[0.04] border border-border/30 hover:border-concrete/40 cursor-pointer"
>
<div
className="w-1.5 h-1.5 rounded-full"
style={{ backgroundColor: cluster?.color || '#cbd5e1' }}
<span
className="w-2 h-2 rounded-full shrink-0"
style={{ backgroundColor: themeA?.color || '#cbd5e1' }}
/>
<span className="text-[9.5px] text-concrete font-medium uppercase tracking-wider">
{cluster?.name || t('insightsView.clusterFallback', { index: cid })}
<span className="text-[10px] font-medium text-ink dark:text-dark-ink truncate">
{nameA}
</span>
</button>
<span className="text-[10px] font-bold text-ochre shrink-0" aria-hidden>
</span>
<button
type="button"
onClick={e => {
e.stopPropagation()
setSelectedClusterId(String(primary[1]))
}}
className="inline-flex items-center gap-1.5 max-w-[42%] px-2 py-1 rounded-lg bg-black/[0.03] dark:bg-white/[0.04] border border-border/30 hover:border-concrete/40 cursor-pointer"
>
<span
className="w-2 h-2 rounded-full shrink-0"
style={{ backgroundColor: themeB?.color || '#cbd5e1' }}
/>
<span className="text-[10px] font-medium text-ink dark:text-dark-ink truncate">
{nameB}
</span>
</button>
{extraCount > 0 && (
<span className="text-[9px] text-concrete font-bold uppercase tracking-wider">
{t('insightsView.bridgeNotes.moreThemes', { count: extraCount })}
</span>
)}
</div>
) : (
<p className="text-[10px] text-concrete italic">
{t('insightsView.bridgeNotes.needsResync')}
</p>
)}
</motion.div>
)
})}
</div>
</motion.div>
))}
{bridgeList.length === 0 && !isCalculating && (
{!dashboardFilter.trim() && filteredBridgeList.length > DASHBOARD_PREVIEW && (
<button
type="button"
onClick={() => setBridgesShowAll(v => !v)}
className="w-full py-2.5 text-[10px] font-bold uppercase tracking-widest text-concrete hover:text-ink border border-dashed border-border/50 rounded-xl cursor-pointer"
>
{bridgesShowAll
? t('insightsView.legendShowLess')
: t('insightsView.legendShowMore', {
count: filteredBridgeList.length - DASHBOARD_PREVIEW,
})}
</button>
)}
{filteredBridgeList.length === 0 && !isCalculating && (
<div className="text-xs text-concrete italic text-center p-6 bg-white dark:bg-zinc-800 rounded-xl border border-border/20">
{t('insightsView.bridgeNotes.empty')}
{dashboardFilter.trim()
? t('insightsView.listFilterEmpty')
: t('insightsView.bridgeNotes.empty')}
</div>
)}
</div>
)}
</section>
{/* ⑥ 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-xs font-bold uppercase tracking-[0.2em] text-ink dark:text-dark-ink">
{/* ⑥ Opportunités de Connexion (link prediction — top near-miss pairs) */}
<section className="space-y-3">
<button
type="button"
onClick={() => setDashboardSectionOpen(s => ({ ...s, suggestions: !s.suggestions }))}
aria-expanded={dashboardSectionOpen.suggestions}
className="w-full flex items-center justify-between gap-4 px-1 text-start cursor-pointer"
>
<div className="flex items-center gap-2 min-w-0">
<Lightbulb size={16} className="text-indigo-500 shrink-0" />
<h3 className="text-xs font-bold uppercase tracking-[0.2em] text-ink dark:text-dark-ink truncate">
{t('insightsView.suggestions.title')}
{suggestions.length > 0 && (
<span className="ms-2 text-concrete font-mono normal-case tracking-normal">
({Math.min(suggestions.length, 12)})
</span>
)}
</h3>
</div>
<p className="text-[10px] text-concrete italic px-1 -mt-2 leading-relaxed">{t('insightsView.tipSuggestions')}</p>
<div className="space-y-4">
{suggestions.map(s => (
{dashboardSectionOpen.suggestions ? (
<ChevronDown size={14} className="text-concrete shrink-0" aria-hidden />
) : (
<ChevronRight size={14} className="text-concrete shrink-0" aria-hidden />
)}
</button>
{dashboardSectionOpen.suggestions && (
<div className="space-y-3">
<p className="text-[10px] text-concrete italic px-1 leading-relaxed">
{t('insightsView.tipSuggestions')}
</p>
{visibleSuggestions.map(s => {
const key = `${s.clusterAId}-${s.clusterBId}`
const busy = actingSuggestionKey === key
return (
<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"
key={key}
className="p-4 rounded-xl bg-white dark:bg-zinc-800 border border-indigo-500/15 hover:border-indigo-500/30 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>
<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">
{t('insightsView.suggestions.bridging', {
clusterA: s.clusterAName,
clusterB: s.clusterBName,
})}
<div className="flex items-start justify-between gap-2 mb-2">
<div className="flex items-center gap-2 min-w-0 flex-1">
<span
className="w-2 h-2 rounded-full shrink-0 bg-indigo-500"
aria-hidden
/>
<span className="text-[10px] font-medium text-ink dark:text-dark-ink truncate">
{s.clusterAName}
</span>
<span className="text-[10px] font-bold text-ochre shrink-0" aria-hidden>
</span>
<span
className="w-2 h-2 rounded-full shrink-0 bg-ochre"
aria-hidden
/>
<span className="text-[10px] font-medium text-ink dark:text-dark-ink truncate">
{s.clusterBName}
</span>
</div>
<h4 className="text-sm font-semibold text-ink dark:text-dark-ink mb-2">
<button
type="button"
disabled={busy}
onClick={() => void handleDismissSuggestion(s)}
className="p-1 rounded-md text-concrete hover:text-ink hover:bg-black/5 dark:hover:bg-white/5 disabled:opacity-50 cursor-pointer"
aria-label={t('insightsView.suggestions.dismiss')}
title={t('insightsView.suggestions.dismiss')}
>
<X size={14} />
</button>
</div>
<h4 className="text-sm font-semibold text-ink dark:text-dark-ink mb-1.5 line-clamp-2">
{s.suggestedTitle}
</h4>
<p className="text-xs text-muted-ink leading-relaxed mb-4">
<p className="text-[11px] text-muted-ink leading-relaxed line-clamp-2 mb-3">
{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>
<button
type="button"
disabled={busy}
onClick={() => void handleCreateSuggestion(s)}
className="w-full flex items-center justify-center gap-2 px-3 py-2 rounded-lg bg-ink text-paper dark:bg-white dark:text-black text-[10px] font-bold uppercase tracking-widest hover:opacity-90 disabled:opacity-50 transition-opacity cursor-pointer"
>
{busy ? (
<RefreshCw size={12} className="animate-spin" />
) : (
<Lightbulb size={12} />
)}
{t('insightsView.suggestions.createNote')}
</button>
</div>
</div>
))}
)
})}
{!suggestionsShowAll && suggestions.length > SUGGESTIONS_PREVIEW && (
<button
type="button"
onClick={() => setSuggestionsShowAll(true)}
className="w-full py-2.5 text-[10px] font-bold uppercase tracking-widest text-concrete hover:text-ink border border-dashed border-border/50 rounded-xl cursor-pointer"
>
{t('insightsView.legendShowMore', {
count: Math.min(suggestions.length, 12) - SUGGESTIONS_PREVIEW,
})}
</button>
)}
{suggestionsShowAll && suggestions.length > SUGGESTIONS_PREVIEW && (
<button
type="button"
onClick={() => setSuggestionsShowAll(false)}
className="w-full py-2.5 text-[10px] font-bold uppercase tracking-widest text-concrete hover:text-ink border border-dashed border-border/50 rounded-xl cursor-pointer"
>
{t('insightsView.legendShowLess')}
</button>
)}
{isCalculating && (
<div className="animate-pulse space-y-4">
<div className="animate-pulse space-y-3">
{[1, 2].map(i => (
<div
key={i}
className="h-32 bg-indigo-500/5 rounded-2xl border border-indigo-500/10"
className="h-24 bg-indigo-500/5 rounded-xl border border-indigo-500/10"
/>
))}
</div>
@@ -865,6 +1337,7 @@ export default function InsightsPage() {
</div>
)}
</div>
)}
</section>
</div>

View File

@@ -1,11 +1,13 @@
'use client'
import { useState, useEffect } from 'react'
import { BookOpen, Loader2, Check, X, RefreshCw, Trash2, CalendarDays } from 'lucide-react'
import { BookOpen, Loader2, Check, X, RefreshCw, Trash2, CalendarDays, Mail } from 'lucide-react'
import { toast } from 'sonner'
import { SettingsHelpBox } from '@/components/settings/settings-help-box'
import { useLanguage } from '@/lib/i18n'
export default function IntegrationsPage() {
const { t } = useLanguage()
// ── Readwise ───────────────────────────────────────────────────────────
const [rwToken, setRwToken] = useState('')
const [rwConnected, setRwConnected] = useState(false)
@@ -19,32 +21,47 @@ export default function IntegrationsPage() {
const [calEvents, setCalEvents] = useState<any[]>([])
const [calFetching, setCalFetching] = useState(false)
// ── Gmail ────────────────────────────────────────────────────────────
const [gmailConnected, setGmailConnected] = useState(false)
const [gmailLoading, setGmailLoading] = useState(true)
const [gmailRecent, setGmailRecent] = useState(0)
const [gmailScanning, setGmailScanning] = useState(false)
const [loading, setLoading] = useState(true)
useEffect(() => {
Promise.all([
fetch('/api/integrations/readwise').then((r) => r.json()),
fetch('/api/integrations/calendar').then((r) => r.json()),
]).then(([rw, cal]) => {
fetch('/api/integrations/gmail').then((r) => r.json()),
]).then(([rw, cal, gmail]) => {
setRwConnected(rw.connected)
setCalConnected(cal.connected)
setGmailConnected(gmail.connected)
setGmailRecent(gmail.recentCaptures ?? 0)
}).finally(() => {
setLoading(false)
setCalLoading(false)
setGmailLoading(false)
})
// Handle redirect params
const params = new URLSearchParams(window.location.search)
if (params.get('connected') === 'calendar') {
setCalConnected(true)
toast.success('Google Calendar connecté !')
toast.success(t('integrations.calendarConnected'))
window.history.replaceState({}, '', '/settings/integrations')
}
if (params.get('connected') === 'gmail') {
setGmailConnected(true)
toast.success(t('integrations.gmail.connected'))
window.history.replaceState({}, '', '/settings/integrations')
}
if (params.get('error')) {
toast.error(`Erreur: ${params.get('error')}`)
toast.error(t('integrations.errorWith', { error: params.get('error') || '' }))
window.history.replaceState({}, '', '/settings/integrations')
}
}, [])
}, [t])
// ── Readwise handlers ──────────────────────────────────────────────────
const handleRwConnect = async () => {
@@ -57,12 +74,12 @@ export default function IntegrationsPage() {
body: JSON.stringify({ token: rwToken.trim() }),
})
const data = await res.json()
if (!res.ok) { toast.error(data.error || 'Erreur Readwise'); return }
if (!res.ok) { toast.error(data.error || t('integrations.readwiseError')); return }
setRwConnected(true)
setRwToken('')
setRwLastSync({ created: data.created, updated: data.updated })
toast.success(`Readwise connecté — ${data.created} notes créées, ${data.updated} mises à jour`)
} catch { toast.error('Erreur de connexion Readwise') } finally { setRwConnecting(false) }
toast.success(t('integrations.readwiseConnected', { created: data.created, updated: data.updated }))
} catch { toast.error(t('integrations.readwiseConnectError')) } finally { setRwConnecting(false) }
}
const handleRwSync = async () => {
@@ -74,16 +91,16 @@ export default function IntegrationsPage() {
body: JSON.stringify({}),
})
const data = await res.json()
if (!res.ok) { toast.error(data.error || 'Erreur de sync'); return }
if (!res.ok) { toast.error(data.error || t('integrations.syncError')); return }
setRwLastSync({ created: data.created, updated: data.updated })
toast.success(`Sync Readwise — ${data.created} créées, ${data.updated} mises à jour`)
} catch { toast.error('Erreur de synchronisation') } finally { setRwSyncing(false) }
toast.success(t('integrations.readwiseSynced', { created: data.created, updated: data.updated }))
} catch { toast.error(t('integrations.syncFailed')) } finally { setRwSyncing(false) }
}
const handleRwDisconnect = async () => {
await fetch('/api/integrations/readwise', { method: 'DELETE' })
setRwConnected(false)
toast.success('Readwiseconnecté')
toast.success(t('integrations.readwiseDisconnected'))
}
// ── Calendar handlers ──────────────────────────────────────────────────
@@ -96,10 +113,10 @@ export default function IntegrationsPage() {
try {
const res = await fetch('/api/integrations/calendar?events=1')
const data = await res.json()
if (!res.ok) { toast.error(data.error || 'Erreur'); return }
if (!res.ok) { toast.error(data.error || t('common.error')); return }
setCalEvents(data.events ?? [])
if (data.events.length === 0) toast.info('Aucun événement aujourd\'hui')
} catch { toast.error('Erreur de chargement des événements') } finally { setCalFetching(false) }
if (data.events.length === 0) toast.info(t('integrations.noEvents'))
} catch { toast.error(t('integrations.eventsLoadError')) } finally { setCalFetching(false) }
}
const handleCreateMeetingNote = async (event: any) => {
@@ -110,11 +127,11 @@ export default function IntegrationsPage() {
})
const data = await res.json()
if (data.success) {
toast.success(`Note de réunion créée : ${event.summary}`, {
action: { label: 'Ouvrir', onClick: () => window.location.href = `/home?openNote=${data.note.id}` },
toast.success(t('integrations.meetingNoteCreated', { summary: event.summary }), {
action: { label: t('integrations.open'), onClick: () => window.location.href = `/home?openNote=${data.note.id}` },
})
} else {
toast.error('Erreur lors de la création de la note')
toast.error(t('integrations.createNoteError'))
}
}
@@ -122,25 +139,114 @@ export default function IntegrationsPage() {
await fetch('/api/integrations/calendar', { method: 'DELETE' })
setCalConnected(false)
setCalEvents([])
toast.success('Google Calendarconnecté')
toast.success(t('integrations.calendarDisconnected'))
}
// ── Gmail handlers ─────────────────────────────────────────────────────
const handleGmailConnect = () => {
window.location.href = '/api/integrations/gmail?connect=1'
}
const handleGmailScan = async () => {
setGmailScanning(true)
try {
const res = await fetch('/api/integrations/gmail?scan=1')
const data = await res.json()
if (!res.ok) { toast.error(data.error || t('common.error')); return }
setGmailRecent(prev => prev + (data.created ?? 0))
toast.success(t('integrations.gmail.scanDone', { created: data.created ?? 0 }))
} catch {
toast.error(t('integrations.gmailScanError'))
} finally {
setGmailScanning(false)
}
}
const handleGmailDisconnect = async () => {
await fetch('/api/integrations/gmail', { method: 'DELETE' })
setGmailConnected(false)
setGmailRecent(0)
toast.success(t('integrations.gmail.disconnected'))
}
const StatusBadge = ({ connected }: { connected: boolean }) =>
connected ? (
<span className="flex items-center gap-1.5 text-[11px] font-semibold text-emerald-700 dark:text-emerald-300 bg-emerald-50 dark:bg-emerald-950/40 border border-emerald-200 dark:border-emerald-800/50 rounded-full px-2.5 py-1">
<Check size={11} /> Connecté
<Check size={11} /> {t('integrations.connected')}
</span>
) : (
<span className="flex items-center gap-1.5 text-[11px] font-semibold text-concrete bg-border/20 border border-border/40 rounded-full px-2.5 py-1">
<X size={11} /> Non connecté
<X size={11} /> {t('integrations.notConnected')}
</span>
)
return (
<div className="space-y-8">
<div>
<h2 className="text-2xl font-serif font-medium text-ink italic tracking-tight">Intégrations</h2>
<p className="text-sm text-concrete mt-1">Connectez des services externes à Memento.</p>
<h2 className="text-2xl font-serif font-medium text-ink italic tracking-tight">{t('settings.integrationsTitle')}</h2>
<p className="text-sm text-concrete mt-1">{t('settings.integrationsDesc')}</p>
</div>
{/* ── Gmail ──────────────────────────────────────────────────────── */}
<div className="border border-border/40 rounded-2xl p-6 bg-paper space-y-4">
<div className="flex items-start justify-between gap-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
<Mail size={20} className="text-red-600 dark:text-red-400" />
</div>
<div>
<h3 className="font-semibold text-ink text-sm">{t('integrations.gmail.title')}</h3>
<p className="text-[12px] text-concrete">{t('integrations.gmail.description')}</p>
</div>
</div>
{gmailLoading ? <Loader2 size={16} className="animate-spin text-concrete mt-2" /> : <StatusBadge connected={gmailConnected} />}
</div>
{!gmailConnected ? (
<div className="space-y-3">
<SettingsHelpBox
title={t('integrations.gmail.helpTitle')}
steps={[
{ text: t('integrations.gmail.helpStep1') },
{ text: t('integrations.gmail.helpStep2') },
{ text: t('integrations.gmail.helpStep3') },
{ text: t('integrations.gmail.helpStep4') },
]}
/>
<button
onClick={handleGmailConnect}
className="px-4 py-2 text-sm font-semibold bg-ink text-paper rounded-xl hover:bg-ink/80 transition-all flex items-center gap-2"
>
<Mail size={14} />
{t('integrations.gmail.connect')}
</button>
</div>
) : (
<div className="space-y-3">
<div className="flex flex-wrap gap-2">
<button
onClick={handleGmailScan}
disabled={gmailScanning}
className="flex items-center gap-2 px-4 py-2 text-sm font-semibold border border-border/40 rounded-xl hover:bg-ink/5 transition-all disabled:opacity-50"
>
{gmailScanning ? <Loader2 size={14} className="animate-spin" /> : <RefreshCw size={14} />}
{t('integrations.gmail.scanNow')}
</button>
<button
onClick={handleGmailDisconnect}
className="flex items-center gap-2 px-4 py-2 text-sm font-semibold text-rose-600 dark:text-rose-400 border border-rose-200 dark:border-rose-800/40 rounded-xl hover:bg-rose-50 dark:hover:bg-rose-950/20 transition-all"
>
<Trash2 size={14} />
{t('integrations.gmail.disconnect')}
</button>
</div>
{gmailRecent > 0 && (
<p className="text-[11px] text-concrete">
{t('integrations.gmail.recentCaptures', { count: gmailRecent })}
</p>
)}
</div>
)}
</div>
{/* ── Google Calendar ────────────────────────────────────────────── */}
@@ -152,7 +258,7 @@ export default function IntegrationsPage() {
</div>
<div>
<h3 className="font-semibold text-ink text-sm">Google Calendar</h3>
<p className="text-[12px] text-concrete">Accédez à vos événements et créez des notes de réunion</p>
<p className="text-[12px] text-concrete">{t('integrations.calendarDesc')}</p>
</div>
</div>
{calLoading ? <Loader2 size={16} className="animate-spin text-concrete mt-2" /> : <StatusBadge connected={calConnected} />}
@@ -161,12 +267,12 @@ export default function IntegrationsPage() {
{!calConnected ? (
<div className="space-y-3">
<SettingsHelpBox
title="Comment fonctionne Google Calendar ?"
title={t('integrations.calendarInfo')}
steps={[
{ text: 'Cliquez "Connecter Google Calendar" — vous serez redirigé vers Google pour autoriser l\'accès.' },
{ text: 'Une fois connecté, revenez ici et cliquez "Événements aujourd\'hui" pour voir votre agenda.' },
{ text: 'Sur chaque événement, cliquez "+ Note" pour créer automatiquement une note de réunion avec template (Ordre du jour / Notes / Actions).' },
{ text: 'La note s\'ouvre directement dans Memento — ajoutez vos notes en temps réel pendant la réunion.' },
{ text: t('integrations.calendarHelpStep1') },
{ text: t('integrations.calendarHelpStep2') },
{ text: t('integrations.calendarHelpStep3') },
{ text: t('integrations.calendarHelpStep4') },
]}
/>
<button
@@ -174,7 +280,7 @@ export default function IntegrationsPage() {
className="px-4 py-2 text-sm font-semibold bg-ink text-paper rounded-xl hover:bg-ink/80 transition-all flex items-center gap-2"
>
<CalendarDays size={14} />
Connecter Google Calendar
{t('integrations.connectCalendar')}
</button>
</div>
) : (
@@ -186,14 +292,14 @@ export default function IntegrationsPage() {
className="flex items-center gap-2 px-4 py-2 text-sm font-semibold border border-border/40 rounded-xl hover:bg-ink/5 transition-all disabled:opacity-50"
>
{calFetching ? <Loader2 size={14} className="animate-spin" /> : <CalendarDays size={14} />}
Événements aujourd&apos;hui
{t('integrations.todayEvents')}
</button>
<button
onClick={handleCalDisconnect}
className="flex items-center gap-2 px-4 py-2 text-sm font-semibold text-rose-600 dark:text-rose-400 border border-rose-200 dark:border-rose-800/40 rounded-xl hover:bg-rose-50 dark:hover:bg-rose-950/20 transition-all"
>
<Trash2 size={14} />
Déconnecter
{t('integrations.disconnect')}
</button>
</div>
@@ -211,7 +317,7 @@ export default function IntegrationsPage() {
onClick={() => handleCreateMeetingNote(ev)}
className="shrink-0 text-[11px] font-semibold px-3 py-1.5 border border-border/40 rounded-full hover:bg-ink/5 transition-all"
>
+ Note
{t('integrations.addNote')}
</button>
</div>
))}
@@ -230,20 +336,20 @@ export default function IntegrationsPage() {
</div>
<div>
<h3 className="font-semibold text-ink text-sm">Readwise</h3>
<p className="text-[12px] text-concrete">Importez vos surlignages de livres, articles et Kindle</p>
<p className="text-[12px] text-concrete">{t('integrations.readwiseDesc')}</p>
</div>
</div>
{loading ? <Loader2 size={16} className="animate-spin text-concrete mt-2" /> : <StatusBadge connected={rwConnected} />}
</div>
<SettingsHelpBox
title="Comment fonctionne Readwise ?"
title={t('integrations.readwiseInfo')}
steps={[
{ text: 'Copiez votre token d\'accès Readwise.', link: { label: 'readwise.io/access_token', href: 'https://readwise.io/access_token' } },
{ text: 'Collez-le dans le champ ci-dessous et cliquez "Connecter". La première synchronisation importe tous vos livres et articles.' },
{ text: 'Chaque livre devient une note dans un carnet "Readwise 📚" — avec tous vos surlignages organisés.' },
{ text: 'Pour mettre à jour avec de nouveaux surlignages, revenez ici et cliquez "Synchroniser maintenant".' },
{ icon: '💡', text: 'Astuce : créez des flashcards IA depuis une note Readwise (bouton 🎓 dans l\'éditeur) pour réviser vos lectures.' },
{ text: t('integrations.readwiseHelpStep1'), link: { label: 'readwise.io/access_token', href: 'https://readwise.io/access_token' } },
{ text: t('integrations.readwiseHelpStep2') },
{ text: t('integrations.readwiseHelpStep3') },
{ text: t('integrations.readwiseHelpStep4') },
{ icon: '💡', text: t('integrations.readwiseHelpStep5') },
]}
/>
@@ -254,7 +360,7 @@ export default function IntegrationsPage() {
type="password"
value={rwToken}
onChange={(e) => setRwToken(e.target.value)}
placeholder="Token Readwise…"
placeholder={t('integrations.readwiseTokenPlaceholder')}
className="flex-1 text-sm border border-border/40 rounded-xl px-3 py-2 bg-paper text-ink placeholder:text-concrete/50 focus:outline-none focus:ring-1 focus:ring-brand-accent/40"
onKeyDown={(e) => e.key === 'Enter' && handleRwConnect()}
/>
@@ -264,7 +370,7 @@ export default function IntegrationsPage() {
className="px-4 py-2 text-sm font-semibold bg-ink text-paper rounded-xl hover:bg-ink/80 transition-all disabled:opacity-50 flex items-center gap-2"
>
{rwConnecting ? <Loader2 size={14} className="animate-spin" /> : null}
Connecter
{t('integrations.connect')}
</button>
</div>
</div>
@@ -278,28 +384,28 @@ export default function IntegrationsPage() {
className="flex items-center gap-2 px-4 py-2 text-sm font-semibold border border-border/40 rounded-xl hover:bg-ink/5 transition-all disabled:opacity-50"
>
{rwSyncing ? <Loader2 size={14} className="animate-spin" /> : <RefreshCw size={14} />}
Synchroniser maintenant
{t('integrations.syncNow')}
</button>
<button
onClick={handleRwDisconnect}
className="flex items-center gap-2 px-4 py-2 text-sm font-semibold text-rose-600 dark:text-rose-400 border border-rose-200 dark:border-rose-800/40 rounded-xl hover:bg-rose-50 dark:hover:bg-rose-950/20 transition-all"
>
<Trash2 size={14} />
Déconnecter
{t('integrations.disconnect')}
</button>
</div>
)}
{rwLastSync && (
<p className="text-[11px] text-concrete">
Dernière sync : <strong>{rwLastSync.created}</strong> notes créées, <strong>{rwLastSync.updated}</strong> mises à jour
{t('integrations.lastSync')} <strong>{rwLastSync.created}</strong> {t('integrations.createdLabel')}, <strong>{rwLastSync.updated}</strong> {t('integrations.updatedLabel')}
</p>
)}
</div>
{/* Placeholder */}
<div className="border border-dashed border-border/40 rounded-2xl p-6 text-center">
<p className="text-sm text-concrete italic">D'autres intégrations arrivent bientôt Zapier, GitHub, Notion import</p>
<p className="text-sm text-concrete italic">{t('integrations.moreComing')}</p>
</div>
</div>
)

View File

@@ -0,0 +1,22 @@
'use client'
import { useLanguage } from '@/lib/i18n'
import { SettingsHelpBox } from '@/components/settings/settings-help-box'
export function McpSettingsHeader() {
const { t } = useLanguage()
return (
<SettingsHelpBox
title={t('mcpSettings.helpBox.title')}
defaultOpen={true}
steps={[
{ text: t('mcpSettings.helpBox.step1') },
{ text: t('mcpSettings.helpBox.step2') },
{ text: t('mcpSettings.helpBox.step3') },
{ text: t('mcpSettings.helpBox.step4'), link: { label: t('mcpSettings.helpBox.step4Link'), href: 'https://modelcontextprotocol.io/docs' } },
{ icon: '⚡', text: t('mcpSettings.helpBox.step5') },
]}
/>
)
}

View File

@@ -1,32 +1,23 @@
import { auth } from '@/auth'
import { redirect } from 'next/navigation'
import { McpSettingsPanel } from '@/components/mcp/mcp-settings-panel'
import { listMcpKeys, getMcpServerStatus } from '@/app/actions/mcp-keys'
import { SettingsHelpBox } from '@/components/settings/settings-help-box'
import { listMcpKeys, getMcpServerStatus, getMcpAccessStatus } from '@/app/actions/mcp-keys'
import { McpSettingsHeader } from './mcp-settings-header'
export default async function McpSettingsPage() {
const session = await auth()
if (!session?.user) redirect('/api/auth/signin')
const [keys, serverStatus] = await Promise.all([
const [keys, serverStatus, access] = await Promise.all([
listMcpKeys(),
getMcpServerStatus(),
getMcpAccessStatus(),
])
return (
<div className="space-y-8">
<SettingsHelpBox
title="Qu'est-ce que MCP (Model Context Protocol) ?"
defaultOpen={true}
steps={[
{ text: 'MCP est un protocole qui permet aux agents IA de Memento de se connecter à des outils externes (bases de données, APIs, fichiers, etc.).' },
{ text: 'Memento expose un serveur MCP avec 22 outils — vos agents peuvent lire/créer des notes, chercher dans votre base, gérer les carnets, etc.' },
{ text: 'Créez une clé API ici, puis configurez-la dans votre client MCP (Claude Desktop, Cursor, Continue.dev…) avec l\'URL du serveur.' },
{ text: 'Format de configuration : URL du serveur MCP + votre clé dans le header Authorization.', link: { label: 'Documentation MCP', href: 'https://modelcontextprotocol.io/docs' } },
{ icon: '⚡', text: 'Cas d\'usage : demandez à Claude Desktop d\'écrire une note dans Memento, de chercher dans vos carnets, ou de créer un agent.' },
]}
/>
<McpSettingsPanel initialKeys={keys} serverStatus={serverStatus} />
<McpSettingsHeader />
<McpSettingsPanel initialKeys={keys} serverStatus={serverStatus} mcpAllowed={access.allowed} tier={access.tier} />
</div>
)
}

View File

@@ -7,6 +7,7 @@ import { VALID_FEATURES, getCurrentPeriodKey } from '@/lib/quota-utils'
import {
getAllEntitlementsForAdmin,
invalidateEntitlementCache,
ENTITLEMENT_UNAVAILABLE,
type SubscriptionTier as TierType,
} from '@/lib/plan-entitlements'
import { logAuditEventAsync } from '@/lib/audit-log'
@@ -73,9 +74,20 @@ export async function updatePlanEntitlement(
}
if (mode === 'unavailable') {
await prisma.planEntitlement.deleteMany({
where: { tier: tier as SubscriptionTier, feature },
})
await prisma.planEntitlement.upsert({
where: {
tier_feature: {
tier: tier as SubscriptionTier,
feature,
},
},
update: { limitValue: ENTITLEMENT_UNAVAILABLE },
create: {
tier: tier as SubscriptionTier,
feature,
limitValue: ENTITLEMENT_UNAVAILABLE,
},
});
} else {
await prisma.planEntitlement.upsert({
where: {
@@ -158,7 +170,7 @@ export async function updateBillingConfig(data: Record<string, string>) {
async function getUsageOverviewInternal() {
const period = getCurrentPeriodKey()
const periodStart = new Date(`${period}-01`)
const periodStart = new Date(`${period}-01T00:00:00.000Z`)
const aggregated = await prisma.usageLog.groupBy({
by: ['feature'],

View File

@@ -4,6 +4,8 @@ import { auth } from '@/auth'
import { prisma } from '@/lib/prisma'
import { revalidatePath } from 'next/cache'
import { createHash, randomBytes } from 'crypto'
import { assertMcpAccess, isMcpTierAllowed } from '@/lib/mcp-access'
import { getEffectiveTier } from '@/lib/entitlements'
const KEY_PREFIX = 'mcp_key_'
@@ -62,6 +64,8 @@ export async function generateMcpKey(name: string): Promise<{ rawKey: string; in
const session = await auth()
if (!session?.user?.id) throw new Error('Unauthorized')
await assertMcpAccess(session.user.id)
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: { id: true, name: true, email: true },
@@ -155,6 +159,13 @@ export type McpServerStatus = {
url: string | null
}
export async function getMcpAccessStatus(): Promise<{ allowed: boolean; tier: string }> {
const session = await auth()
if (!session?.user?.id) return { allowed: false, tier: 'BASIC' }
const tier = await getEffectiveTier(session.user.id)
return { allowed: isMcpTierAllowed(tier), tier }
}
/**
* Get MCP server status — mode and URL.
*/

View File

@@ -1082,6 +1082,27 @@ export async function getRecentNotes(limit: number = 3) {
}
}
// Get count of unassigned notes (Inbox count) — notes without a notebook
export async function getInboxCount() {
const session = await auth()
if (!session?.user?.id) return 0
try {
const count = await prisma.note.count({
where: {
userId: session.user.id,
notebookId: null,
isArchived: false,
trashedAt: null,
},
})
return count
} catch (error) {
console.error('Error counting inbox notes:', error)
return 0
}
}
// Dismiss a note from Recent section
export async function dismissFromRecent(id: string) {
const session = await auth();

View File

@@ -5,6 +5,8 @@ import prisma from '@/lib/prisma'
import { getChatProvider } from '@/lib/ai/factory'
import { getSystemConfig } from '@/lib/config'
import { revalidatePath } from 'next/cache'
import { withAiQuota } from '@/lib/ai-quota'
import { QuotaExceededError } from '@/lib/entitlements'
export interface OrganizationNote {
id: string
@@ -134,8 +136,16 @@ Format de réponse JSON attendu:
let rawResponse: string
try {
rawResponse = await provider.generateText(prompt)
rawResponse = await withAiQuota(
session.user.id,
'reformulate',
() => provider.generateText(prompt),
{ lane: 'chat' },
)
} catch (aiErr) {
if (aiErr instanceof QuotaExceededError) {
return { success: false, error: 'Quota IA épuisé pour cette fonctionnalité.' }
}
console.error('[organize-notebook] AI generateText failed:', aiErr)
return { success: false, error: `L'IA n'a pas pu répondre : ${(aiErr as Error).message}` }
}

View File

@@ -0,0 +1,21 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { agentSuggestionService } from '@/lib/ai/services/agent-suggestion.service'
export async function POST(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id } = await params
const result = await agentSuggestionService.accept(session.user.id, id)
if (!result) {
return NextResponse.json({ error: 'Suggestion introuvable' }, { status: 404 })
}
return NextResponse.json({ success: true, agentId: result.agentId })
}

View File

@@ -0,0 +1,21 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { agentSuggestionService } from '@/lib/ai/services/agent-suggestion.service'
export async function POST(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id } = await params
const result = await agentSuggestionService.dismiss(session.user.id, id)
if (!result) {
return NextResponse.json({ error: 'Suggestion introuvable' }, { status: 404 })
}
return NextResponse.json({ success: true })
}

View File

@@ -0,0 +1,30 @@
import { NextResponse } from 'next/server'
import { auth } from '@/auth'
import { agentSuggestionService } from '@/lib/ai/services/agent-suggestion.service'
export async function GET() {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const suggestions = await agentSuggestionService.getPending(session.user.id, 3)
return NextResponse.json({
suggestions: suggestions.map(s => ({
id: s.id,
topic: s.topic,
reason: s.reason,
suggestedType: s.suggestedType,
suggestedFrequency: s.suggestedFrequency,
relatedNoteCount: (() => {
try {
return JSON.parse(s.relatedNoteIds).length
} catch {
return 0
}
})(),
clusterId: s.clusterId,
createdAt: s.createdAt.toISOString(),
})),
})
}

View File

@@ -4,6 +4,7 @@ import { auth } from '@/auth'
import { autoLabelCreationService } from '@/lib/ai/services'
import { getAISettings } from '@/app/actions/ai-settings'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
import { withAiQuota, handleQuotaHttpError } from '@/lib/ai-quota'
/**
* POST /api/ai/auto-labels - Suggest new labels for a notebook
@@ -59,11 +60,16 @@ export async function POST(request: NextRequest) {
)
}
// Get label suggestions
const suggestions = await autoLabelCreationService.suggestLabels(
notebookId,
const suggestions = await withAiQuota(
session.user.id,
language
'auto_tag',
() =>
autoLabelCreationService.suggestLabels(
notebookId,
session.user!.id,
language,
),
{ lane: 'tags' },
)
if (!suggestions) {
@@ -79,8 +85,10 @@ export async function POST(request: NextRequest) {
data: suggestions,
})
} catch (error) {
const quotaResp = handleQuotaHttpError(error)
if (quotaResp) return quotaResp
console.error('[/api/ai/auto-labels] POST failed:', error)
return NextResponse.json({ success: true, data: null })
return NextResponse.json({ success: false, error: 'auto_labels_failed' }, { status: 500 })
}
}

View File

@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { batchOrganizationService } from '@/lib/ai/services'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
/**
* POST /api/ai/batch-organize - Create organization plan for notes in Inbox
@@ -40,6 +41,8 @@ export async function POST(request: NextRequest) {
}
}
await reserveUsageOrThrow(session.user.id, 'reformulate')
// Create organization plan
const plan = await batchOrganizationService.createOrganizationPlan(
session.user.id,

View File

@@ -3,6 +3,7 @@ import { auth } from '@/auth'
import { getAISettings } from '@/app/actions/ai-settings'
import { describeImages } from '@/lib/ai/services/image-description.service'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
import { withAiQuota, handleQuotaHttpError } from '@/lib/ai-quota'
export async function POST(req: NextRequest) {
try {
@@ -11,7 +12,6 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// GDPR AI Consent check
if (!(await hasUserAiConsent())) {
return NextResponse.json({ error: 'ai_consent_required' }, { status: 403 })
}
@@ -27,23 +27,31 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: 'imageUrls must be a non-empty array' }, { status: 400 })
}
const result = await describeImages(
const isTitleMode = mode === 'title'
const feature = isTitleMode ? 'auto_title' : 'reformulate'
const lane = isTitleMode ? 'tags' : 'chat'
const result = await withAiQuota(
session.user.id,
feature,
() => describeImages(
imageUrls,
mode === 'title' ? 'title' : 'description',
isTitleMode ? 'title' : 'description',
language || 'fr'
),
{ lane },
)
// For title mode, return suggestions in same format as /api/ai/title-suggestions
if (mode === 'title' && result.suggestions) {
if (isTitleMode && result.suggestions) {
return NextResponse.json({ suggestions: result.suggestions })
}
return NextResponse.json(result)
} catch (error: any) {
} catch (error: unknown) {
const quotaResp = handleQuotaHttpError(error)
if (quotaResp) return quotaResp
console.error('[describe-image] Error:', error)
return NextResponse.json(
{ error: error.message || 'Failed to describe image' },
{ status: 500 }
)
const message = error instanceof Error ? error.message : 'Failed to describe image'
return NextResponse.json({ error: message }, { status: 500 })
}
}

View File

@@ -4,6 +4,7 @@ import { getChatProvider } from '@/lib/ai/factory'
import { getSystemConfig } from '@/lib/config'
import prisma from '@/lib/prisma'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
import { withAiQuota, handleQuotaHttpError } from '@/lib/ai-quota'
/**
* POST /api/ai/echo/fusion
@@ -34,7 +35,6 @@ export async function POST(req: NextRequest) {
)
}
// Fetch the notes
const notes = await prisma.note.findMany({
where: {
id: { in: noteIds },
@@ -55,11 +55,9 @@ export async function POST(req: NextRequest) {
)
}
// Get AI provider
const config = await getSystemConfig()
const provider = getChatProvider(config)
// Build fusion prompt
const notesDescriptions = notes.map((note, index) => {
return `Note ${index + 1}: "${note.title || 'Untitled'}"
${note.content}`
@@ -90,21 +88,21 @@ Output format:
Begin:`
try {
const fusedContent = await provider.generateText(fusionPrompt)
const fusedContent = await withAiQuota(
session.user.id,
'reformulate',
() => provider.generateText(fusionPrompt),
{ lane: 'chat' },
)
return NextResponse.json({
fusedNote: fusedContent,
notesCount: notes.length
})
} catch (error) {
return NextResponse.json(
{ error: 'Failed to generate fusion' },
{ status: 500 }
)
}
} catch (error) {
const quotaResp = handleQuotaHttpError(error)
if (quotaResp) return quotaResp
console.error('[/api/ai/echo/fusion] Error:', error)
return NextResponse.json(
{ error: 'Failed to process fusion request' },
{ status: 500 }

View File

@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { memoryEchoService } from '@/lib/ai/services/memory-echo.service'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
/**
* GET /api/ai/echo
@@ -27,6 +28,7 @@ export async function GET(req: NextRequest) {
}
// Get next insight (respects frequency limits)
await reserveUsageOrThrow(session.user.id, 'reformulate')
const insight = await memoryEchoService.getNextInsight(session.user.id)
if (!insight) {
@@ -41,6 +43,9 @@ export async function GET(req: NextRequest) {
return NextResponse.json({ insight })
} catch (error) {
if (error instanceof QuotaExceededError) {
return NextResponse.json(error.toJSON(), { status: 429 })
}
console.error('[/api/ai/echo] GET error:', error)
return NextResponse.json(
{ error: 'Failed to fetch Memory Echo insight' },

View File

@@ -3,6 +3,7 @@ import { auth } from '@/auth'
import { getSystemConfig } from '@/lib/config'
import { getTagsProvider } from '@/lib/ai/factory'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
import { withAiQuota, handleQuotaHttpError } from '@/lib/ai-quota'
export async function POST(request: NextRequest) {
try {
@@ -32,7 +33,6 @@ export async function POST(request: NextRequest) {
let prompt: string
if (mode === 'complete') {
// Add missing info from resource without rewriting the existing note
prompt = `You are an expert note editor. Your task is to enrich an existing note by adding relevant information from a provided resource, WITHOUT modifying or rewriting the existing content.
LANGUAGE RULE: Respond in ${lang}. Match the language of the existing note.
@@ -56,7 +56,6 @@ INSTRUCTIONS:
- Format the new content consistently with the existing note style and the requested FORMAT RULE
- Respond ONLY with the enriched note content, no explanations`
} else {
// Merge: intelligently rewrite integrating both sources
prompt = `You are an expert note writer. Your task is to intelligently merge an existing note with a resource into a single, coherent, well-structured document.
LANGUAGE RULE: Respond in ${lang}. Match the language of the existing note.
@@ -81,14 +80,19 @@ INSTRUCTIONS:
- Respond ONLY with the merged content, no meta-commentary or explanations`
}
const enrichedContent = await provider.generateText(prompt)
const enrichedContent = await withAiQuota(
session.user.id,
'reformulate',
() => provider.generateText(prompt),
{ lane: 'chat' },
)
return NextResponse.json({ enrichedContent: enrichedContent.trim() })
} catch (error: any) {
} catch (error: unknown) {
const quotaResp = handleQuotaHttpError(error)
if (quotaResp) return quotaResp
console.error('[enrich-from-resource] Error:', error)
return NextResponse.json(
{ error: error.message || 'Failed to enrich content' },
{ status: 500 }
)
const message = error instanceof Error ? error.message : 'Failed to enrich content'
return NextResponse.json({ error: message }, { status: 500 })
}
}

View File

@@ -3,6 +3,7 @@ import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { notebookSummaryService } from '@/lib/ai/services'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
/**
* POST /api/ai/notebook-summary - Generate summary for a notebook
@@ -51,6 +52,8 @@ export async function POST(request: NextRequest) {
)
}
await reserveUsageOrThrow(session.user.id, 'reformulate')
// Generate summary
const summary = await notebookSummaryService.generateSummary(
notebookId,

View File

@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import prisma from '@/lib/prisma'
import { notebookOrganizerService } from '@/lib/ai/services/notebook-organizer.service'
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
import { withAiQuota, handleQuotaHttpError } from '@/lib/ai-quota'
import { syncNoteLabels } from '@/app/actions/notes'
export async function POST(request: NextRequest) {
@@ -17,19 +17,6 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'notebookId is required' }, { status: 400 })
}
try {
await reserveUsageOrThrow(session.user.id, 'reformulate')
} catch (err) {
if (err instanceof QuotaExceededError) {
const isTierLocked = err.currentQuota === 0
return NextResponse.json(
{ error: isTierLocked ? 'feature_locked' : 'quota_exceeded', errorKey: isTierLocked ? 'ai.featureLocked' : 'ai.quotaExceeded' },
{ status: 402 },
)
}
throw err
}
const notes = await prisma.note.findMany({
where: { notebookId, trashedAt: null, userId: session.user.id },
select: { id: true, title: true, content: true },
@@ -46,13 +33,20 @@ export async function POST(request: NextRequest) {
contentPreview: n.content.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 300),
}))
const result = await notebookOrganizerService.analyze(notesForAnalysis)
const result = await withAiQuota(
session.user.id,
'reformulate',
() => notebookOrganizerService.analyze(notesForAnalysis),
{ lane: 'chat' },
)
return NextResponse.json(result)
} catch (error: any) {
} catch (error: unknown) {
const quotaResp = handleQuotaHttpError(error)
if (quotaResp) return quotaResp
console.error('[Notebook Organizer] Error:', error)
return NextResponse.json({ error: error.message || 'Failed to organize notebook' }, { status: 500 })
const message = error instanceof Error ? error.message : 'Failed to organize notebook'
return NextResponse.json({ error: message }, { status: 500 })
}
}

View File

@@ -1,7 +1,7 @@
import { rateLimit } from '@/lib/rate-limit'
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { searchOverviewService } from '@/lib/ai/services/search-overview.service'
import { withAiQuota, handleQuotaHttpError } from '@/lib/ai-quota'
export async function POST(request: NextRequest) {
try {
@@ -15,11 +15,19 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'query and results required' }, { status: 400 })
}
const overview = await searchOverviewService.generate(query, results, language)
const overview = await withAiQuota(
session.user.id,
'semantic_search',
() => searchOverviewService.generate(query, results, language),
{ lane: 'chat' },
)
return NextResponse.json(overview)
} catch (error: any) {
} catch (error: unknown) {
const quotaResp = handleQuotaHttpError(error)
if (quotaResp) return quotaResp
console.error('[Search Overview] Error:', error)
return NextResponse.json({ error: error.message, hasRelevantInfo: false, answer: '' }, { status: 500 })
const message = error instanceof Error ? error.message : 'Search overview failed'
return NextResponse.json({ error: message, hasRelevantInfo: false, answer: '' }, { status: 500 })
}
}

View File

@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { notebookSuggestionService } from '@/lib/ai/services/notebook-suggestion.service'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
import { withAiQuota, handleQuotaHttpError } from '@/lib/ai-quota'
export async function POST(req: NextRequest) {
try {
@@ -21,7 +22,6 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: 'noteContent is required' }, { status: 400 })
}
// Minimum content length for suggestion (20 words as per specs)
const wordCount = noteContent.trim().split(/\s+/).length
if (wordCount < 20) {
return NextResponse.json({
@@ -31,18 +31,25 @@ export async function POST(req: NextRequest) {
})
}
// Get suggestion from AI service
const suggestedNotebook = await notebookSuggestionService.suggestNotebook(
noteContent,
const suggestedNotebook = await withAiQuota(
session.user.id,
'auto_tag',
() => notebookSuggestionService.suggestNotebook(
noteContent,
session.user!.id,
language
),
{ lane: 'tags' },
)
return NextResponse.json({
suggestion: suggestedNotebook,
confidence: suggestedNotebook ? 0.8 : 0 // Placeholder confidence score
confidence: suggestedNotebook ? 0.8 : 0
})
} catch (error) {
const quotaResp = handleQuotaHttpError(error)
if (quotaResp) return quotaResp
console.error('[/api/ai/suggest-notebook] Error:', error)
return NextResponse.json(
{ error: 'Failed to generate suggestion' },
{ status: 500 }

View File

@@ -1,10 +1,11 @@
import { rateLimit } from '@/lib/rate-limit'
import { NextRequest, NextResponse } from 'next/server'
import { runLaneWithBillingUser, willUseByokForLane } from '@/lib/ai/provider-for-user'
import { runLaneWithBillingUser } from '@/lib/ai/provider-for-user'
import { getSystemConfig } from '@/lib/config'
import { auth } from '@/auth'
import { getAISettings } from '@/app/actions/ai-settings'
import { reserveUsageOrThrow, QuotaExceededError, QuotaServiceUnavailableError } from '@/lib/entitlements'
import { reserveAiUsageOrThrow } from '@/lib/ai-quota'
import { QuotaExceededError, QuotaServiceUnavailableError } from '@/lib/entitlements'
import { z } from 'zod'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
@@ -61,10 +62,8 @@ export async function POST(req: NextRequest) {
}
const config = await getSystemConfig()
const { usedByok: willUseByok } = await willUseByokForLane('tags', config, session.user.id)
if (!willUseByok) {
try {
await reserveUsageOrThrow(session.user.id, 'auto_title')
await reserveAiUsageOrThrow(session.user.id, 'auto_title', { lane: 'tags' })
} catch (err) {
if (err instanceof QuotaExceededError) {
return NextResponse.json(err.toJSON(), { status: 402 })
@@ -74,7 +73,6 @@ export async function POST(req: NextRequest) {
}
console.error('[/api/ai/title-suggestions] Quota check error (fail-open):', err)
}
}
// Détecter la langue du contenu (simple détection basée sur les caractères et mots)
const isPersian = /[\u0600-\u06FF]/.test(content)

View File

@@ -3,6 +3,7 @@ import { auth } from '@/auth'
import { getChatProvider } from '@/lib/ai/factory'
import { getSystemConfig } from '@/lib/config'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
import { withAiQuota, handleQuotaHttpError } from '@/lib/ai-quota'
export async function POST(request: NextRequest) {
try {
@@ -18,12 +19,10 @@ export async function POST(request: NextRequest) {
const { text } = await request.json()
// Validation
if (!text || typeof text !== 'string') {
return NextResponse.json({ error: 'Text is required' }, { status: 400 })
}
// Validate word count
const wordCount = text.split(/\s+/).length
if (wordCount < 10) {
return NextResponse.json(
@@ -42,11 +41,9 @@ export async function POST(request: NextRequest) {
const config = await getSystemConfig()
const provider = getChatProvider(config)
// Detect language from text
const hasFrench = /[àâäéèêëïîôùûüÿç]/i.test(text)
const responseLanguage = hasFrench ? 'French' : 'English'
// Build prompt to transform text to Markdown
const prompt = hasFrench
? `Tu es un expert en Markdown. Transforme ce texte ${responseLanguage} en Markdown bien formaté.
@@ -77,19 +74,22 @@ ${text}
Respond ONLY with the transformed Markdown text, no explanations.`
const transformedText = await provider.generateText(prompt)
const transformedText = await withAiQuota(
session.user.id,
'reformulate',
() => provider.generateText(prompt),
{ lane: 'chat' },
)
return NextResponse.json({
originalText: text,
transformedText: transformedText,
transformedText,
language: responseLanguage
})
} catch (error: any) {
return NextResponse.json(
{ error: error.message || 'Failed to transform text to Markdown' },
{ status: 500 }
)
} catch (error: unknown) {
const quotaResp = handleQuotaHttpError(error)
if (quotaResp) return quotaResp
const message = error instanceof Error ? error.message : 'Failed to transform text to Markdown'
return NextResponse.json({ error: message }, { status: 500 })
}
}

View File

@@ -3,6 +3,7 @@ import { auth } from '@/auth'
import { getTagsProvider } from '@/lib/ai/factory'
import { getSystemConfig } from '@/lib/config'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
export async function POST(request: NextRequest) {
try {
@@ -24,12 +25,18 @@ export async function POST(request: NextRequest) {
const config = await getSystemConfig()
const provider = getTagsProvider(config)
await reserveUsageOrThrow(session.user.id, 'reformulate')
const prompt = `Translate the following text to ${targetLanguage}. Return ONLY the translated text, no explanation, no preamble, no quotes:\n\n${text}`
const translatedText = await provider.generateText(prompt)
return NextResponse.json({ translatedText: translatedText.trim() })
} catch (error: any) {
return NextResponse.json({ error: error.message || 'Translation failed' }, { status: 500 })
} catch (error: unknown) {
if (error instanceof QuotaExceededError) {
return NextResponse.json(error.toJSON(), { status: 429 })
}
const message = error instanceof Error ? error.message : 'Translation failed'
return NextResponse.json({ error: message }, { status: 500 })
}
}

View File

@@ -3,7 +3,8 @@ import prisma from '@/lib/prisma'
import { auth } from '@/auth'
import { runLaneWithBillingUser } from '@/lib/ai/provider-for-user'
import { getSystemConfig } from '@/lib/config'
import { verifyParticipant } from '@/lib/brainstorm-collab'
import { billingOwnerFromSession, verifyParticipant } from '@/lib/brainstorm-collab'
import { withSessionAiQuota, handleQuotaHttpError } from '@/lib/ai-quota'
function localeToLanguageName(locale?: string): string {
const map: Record<string, string> = {
@@ -53,6 +54,11 @@ export async function POST(
return NextResponse.json({ error: 'No ideas to summarize' }, { status: 400 })
}
const { billingOwnerId, isGuestActor } = billingOwnerFromSession(
brainstormSession.userId,
session.user.id,
)
const ideasText = ideas
.map((idea, i) => `${i + 1}. [Wave ${idea.waveNumber}] ${idea.title}: ${idea.description}`)
.join('\n')
@@ -79,15 +85,27 @@ Write a concise synthesis (4-6 sentences) that:
Be direct and action-oriented. No bullet points, just flowing prose.`
const config = await getSystemConfig()
const { result: summary } = await runLaneWithBillingUser(
const summary = await withSessionAiQuota(
billingOwnerId,
session.user.id,
isGuestActor,
'brainstorm_enrich',
async () => {
const { result } = await runLaneWithBillingUser(
'tags',
config,
session.user.id,
billingOwnerId,
(provider) => provider.generateText(prompt),
)
return result
},
{ lane: 'tags' },
)
return NextResponse.json({ success: true, data: { summary: summary.trim() } })
} catch (error) {
const quotaResp = handleQuotaHttpError(error)
if (quotaResp) return quotaResp
console.error('Error summarizing brainstorm:', error)
return NextResponse.json({ error: 'Failed to summarize' }, { status: 500 })
}

View File

@@ -0,0 +1,204 @@
import { NextResponse } from 'next/server'
import { auth } from '@/auth'
import prisma from '@/lib/prisma'
import { agentSuggestionService } from '@/lib/ai/services/agent-suggestion.service'
import { bridgeNotesService } from '@/lib/ai/services/bridge-notes.service'
import { buildDashboardPaths, buildOpenLoops } from '@/lib/dashboard/paths.service'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
import { getSystemConfig } from '@/lib/config'
import { getChatProvider } from '@/lib/ai/factory'
function excerptNoteContent(content: string | null | undefined, max = 200): string {
if (!content) return ''
const plain = content.replace(/<[^>]+>/g, ' ').replace(/&nbsp;/g, ' ').replace(/\s+/g, ' ').trim()
if (plain.length <= max) return plain
return `${plain.slice(0, max)}`
}
interface PathsBriefingSnapshot {
recentNote?: {
id: string
title: string | null
content: string
notebookId: string | null
} | null
inboxCount?: number
dueFlashcards?: number
insights?: Array<{
id: string
insight: string
score: number
viewed: boolean
note1: { id: string; title: string | null }
note2: { id: string; title: string | null }
note1Excerpt?: string
note2Excerpt?: string
}>
bridgeSuggestions?: Array<{
clusterAId: number
clusterBId: number
clusterAName: string
clusterBName: string
suggestedTitle: string
suggestedContent: string
justification: string
}>
agentSuggestions?: Array<{
id: string
topic: string
reason: string
clusterId: number | null
}>
includeOpenLoops?: boolean
}
async function resolvePathsPayload(userId: string, snapshot?: PathsBriefingSnapshot | null) {
const now = new Date()
const hasSnapshot = !!snapshot?.recentNote
const needsAgents = !snapshot?.agentSuggestions
const needsBridges = !snapshot?.bridgeSuggestions
const needsInsights = !snapshot?.insights
const needsCounts = snapshot?.inboxCount === undefined || snapshot?.dueFlashcards === undefined
const needsFocusNote = !snapshot?.recentNote
const [
aiConsent,
aiSettings,
systemConfig,
recentNotes,
inboxCount,
dueFlashcards,
unviewedInsights,
agentSuggestions,
bridgeSuggestions,
] = await Promise.all([
hasUserAiConsent(),
prisma.userAISettings.findUnique({
where: { userId },
select: { memoryEcho: true },
}),
getSystemConfig(),
needsFocusNote
? prisma.note.findMany({
where: { userId, trashedAt: null, isArchived: false },
select: { id: true, title: true, content: true, notebookId: true, updatedAt: true },
orderBy: { updatedAt: 'desc' },
take: 1,
})
: Promise.resolve([]),
needsCounts
? prisma.note.count({
where: { userId, notebookId: null, isArchived: false, trashedAt: null },
})
: Promise.resolve(snapshot!.inboxCount!),
needsCounts
? prisma.flashcard.count({
where: { deck: { userId }, nextReviewAt: { lte: now } },
})
: Promise.resolve(snapshot!.dueFlashcards!),
needsInsights
? prisma.memoryEchoInsight.findMany({
where: { userId, dismissed: false },
select: {
id: true, insight: true, similarityScore: true, viewed: true,
note1: { select: { id: true, title: true, content: true } },
note2: { select: { id: true, title: true, content: true } },
},
orderBy: { insightDate: 'desc' },
take: 5,
}).catch(() => [])
: Promise.resolve([]),
needsAgents
? agentSuggestionService.getPending(userId, 3).catch(() => [])
: Promise.resolve([]),
needsBridges
? bridgeNotesService.getBridgeSuggestions(userId).catch(() => [])
: Promise.resolve([]),
])
const aiProviderReady = !!getChatProvider(systemConfig)
const memoryEchoEnabled = aiSettings?.memoryEcho ?? true
const aiActive = aiConsent && aiProviderReady && memoryEchoEnabled
const focusNotes = hasSnapshot && snapshot?.recentNote
? [{
...snapshot.recentNote,
updatedAt: now,
}]
: recentNotes
const insights = snapshot?.insights ?? unviewedInsights.map(i => ({
id: i.id,
insight: i.insight,
score: i.similarityScore,
viewed: i.viewed,
note1: { id: i.note1.id, title: i.note1.title },
note2: { id: i.note2.id, title: i.note2.title },
note1Excerpt: excerptNoteContent(i.note1.content),
note2Excerpt: excerptNoteContent(i.note2.content),
}))
const resolvedBridges = snapshot?.bridgeSuggestions
?? bridgeSuggestions.slice(0, 3).map(s => ({
clusterAId: s.clusterAId,
clusterBId: s.clusterBId,
clusterAName: s.clusterAName,
clusterBName: s.clusterBName,
suggestedTitle: s.suggestedTitle,
suggestedContent: s.suggestedContent,
justification: s.justification,
}))
const resolvedAgents = snapshot?.agentSuggestions
?? agentSuggestions.map(s => ({
id: s.id,
topic: s.topic,
reason: s.reason,
clusterId: s.clusterId,
}))
const paths = await buildDashboardPaths({
userId,
aiActive,
recentNotes: focusNotes,
inboxCount: needsCounts ? inboxCount : snapshot!.inboxCount!,
dueFlashcards: needsCounts ? dueFlashcards : snapshot!.dueFlashcards!,
insights,
bridgeSuggestions: resolvedBridges,
agentSuggestions: resolvedAgents,
}).catch(() => [])
const openLoops = snapshot?.includeOpenLoops === false
? []
: await buildOpenLoops(userId).catch(() => [])
return { paths, openLoops }
}
/**
* GET /api/briefing/paths — pistes enrichies (fallback sans snapshot client).
*/
export async function GET() {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const payload = await resolvePathsPayload(session.user.id)
return NextResponse.json(payload)
}
/**
* POST /api/briefing/paths — enrichissement avec snapshot briefing (évite re-fetch agents/ponts).
*/
export async function POST(request: Request) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const snapshot = await request.json().catch(() => null) as PathsBriefingSnapshot | null
const payload = await resolvePathsPayload(session.user.id, snapshot)
return NextResponse.json(payload)
}

View File

@@ -0,0 +1,240 @@
import { NextResponse } from 'next/server'
import { auth } from '@/auth'
import prisma from '@/lib/prisma'
import { agentSuggestionService } from '@/lib/ai/services/agent-suggestion.service'
import { bridgeNotesService } from '@/lib/ai/services/bridge-notes.service'
import { gmailScannerService } from '@/lib/integrations/gmail-scanner.service'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
import { getSystemConfig } from '@/lib/config'
import { getChatProvider } from '@/lib/ai/factory'
function excerptNoteContent(content: string | null | undefined, max = 200): string {
if (!content) return ''
const plain = content.replace(/<[^>]+>/g, ' ').replace(/&nbsp;/g, ' ').replace(/\s+/g, ' ').trim()
if (plain.length <= max) return plain
return `${plain.slice(0, max)}`
}
async function fetchWritingActivity(userId: string, since: Date) {
const rows = await prisma.$queryRaw<Array<{ date: string; count: number }>>`
SELECT to_char("updatedAt" AT TIME ZONE 'UTC', 'YYYY-MM-DD') AS date,
COUNT(*)::int AS count
FROM "Note"
WHERE "userId" = ${userId}
AND "trashedAt" IS NULL
AND "updatedAt" >= ${since}
GROUP BY 1
ORDER BY 1 ASC
`
return rows.map(r => ({ date: r.date, count: Number(r.count) }))
}
/**
* GET /api/briefing — agrégat rapide pour le tableau de bord.
* Pas de génération IA synchrone ; les pistes sont dans GET /api/briefing/paths.
*/
export async function GET() {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = session.user.id
const now = new Date()
const ninetyDaysAgo = new Date(now)
ninetyDaysAgo.setDate(ninetyDaysAgo.getDate() - 90)
const [aiConsent, aiSettings, systemConfig] = await Promise.all([
hasUserAiConsent(),
prisma.userAISettings.findUnique({
where: { userId },
select: { memoryEcho: true },
}),
getSystemConfig(),
])
const aiProviderReady = !!getChatProvider(systemConfig)
const memoryEchoEnabled = aiSettings?.memoryEcho ?? true
const aiActive = aiConsent && aiProviderReady && memoryEchoEnabled
const [
recentNotes,
inboxCount,
dueFlashcards,
upcomingReminders,
unviewedInsights,
recentAgentActions,
pinnedNotes,
writingActivity,
gmailStatus,
agentSuggestions,
bridgeSuggestions,
] = await Promise.all([
prisma.note.findMany({
where: { userId, trashedAt: null, isArchived: false },
select: {
id: true, title: true, content: true, color: true,
notebookId: true, updatedAt: true, createdAt: true,
},
orderBy: { updatedAt: 'desc' },
take: 8,
}),
prisma.note.count({
where: { userId, notebookId: null, isArchived: false, trashedAt: null },
}),
prisma.flashcard.count({
where: { deck: { userId }, nextReviewAt: { lte: now } },
}),
prisma.note.findMany({
where: {
userId, reminder: { gte: now }, isReminderDone: false, trashedAt: null,
},
select: { id: true, title: true, reminder: true, notebookId: true },
orderBy: { reminder: 'asc' },
take: 5,
}),
prisma.memoryEchoInsight.findMany({
where: { userId, viewed: false, dismissed: false },
select: {
id: true, insight: true, similarityScore: true,
insightDate: true, viewed: true,
note1: { select: { id: true, title: true, content: true } },
note2: { select: { id: true, title: true, content: true } },
},
orderBy: { insightDate: 'desc' },
take: 5,
}).catch(() => []),
prisma.agentAction.findMany({
where: {
agent: { userId },
status: 'completed',
createdAt: { gte: new Date(now.getTime() - 48 * 60 * 60 * 1000) },
},
select: {
id: true, result: true, createdAt: true,
agent: { select: { id: true, name: true, type: true } },
},
orderBy: { createdAt: 'desc' },
take: 3,
}).catch(() => []),
prisma.note.findMany({
where: { userId, isPinned: true, trashedAt: null, isArchived: false },
select: { id: true, title: true, notebookId: true, updatedAt: true },
orderBy: { updatedAt: 'desc' },
take: 5,
}),
fetchWritingActivity(userId, ninetyDaysAgo).catch(() => []),
gmailScannerService.getStatus(userId).catch(() => ({
connected: false,
recentCaptures: 0,
})),
agentSuggestionService.getPending(userId, 3).catch(() => []),
bridgeNotesService.getBridgeSuggestions(userId).catch(() => []),
])
let insightRows = unviewedInsights
if (insightRows.length < 5) {
const viewedRecent = await prisma.memoryEchoInsight.findMany({
where: {
userId,
dismissed: false,
viewed: true,
id: { notIn: insightRows.map(i => i.id) },
},
select: {
id: true, insight: true, similarityScore: true,
insightDate: true, viewed: true,
note1: { select: { id: true, title: true, content: true } },
note2: { select: { id: true, title: true, content: true } },
},
orderBy: { insightDate: 'desc' },
take: 5 - insightRows.length,
}).catch(() => [])
insightRows = [...insightRows, ...viewedRecent]
}
const notebookIds = [...new Set(recentNotes.map(n => n.notebookId).filter(Boolean))] as string[]
const notebooks = notebookIds.length > 0
? await prisma.notebook.findMany({
where: { id: { in: notebookIds } },
select: { id: true, name: true, color: true, icon: true },
})
: []
const notebookMap = new Map(notebooks.map(n => [n.id, n]))
return NextResponse.json({
recentNotes: recentNotes.map(n => ({
...n,
notebook: n.notebookId ? notebookMap.get(n.notebookId) || null : null,
})),
inboxCount,
dueFlashcards,
upcomingReminders: upcomingReminders.map(r => ({
id: r.id,
title: r.title,
reminder: r.reminder?.toISOString() || null,
notebookId: r.notebookId,
})),
insights: insightRows.map(i => ({
id: i.id,
insight: i.insight,
score: i.similarityScore,
date: i.insightDate.toISOString(),
viewed: i.viewed,
note1: { id: i.note1.id, title: i.note1.title },
note2: { id: i.note2.id, title: i.note2.title },
note1Excerpt: excerptNoteContent(i.note1.content),
note2Excerpt: excerptNoteContent(i.note2.content),
})),
bridgeSuggestions: bridgeSuggestions.slice(0, 3).map(s => ({
clusterAId: s.clusterAId,
clusterBId: s.clusterBId,
clusterAName: s.clusterAName,
clusterBName: s.clusterBName,
suggestedTitle: s.suggestedTitle,
suggestedContent: s.suggestedContent,
justification: s.justification,
})),
ai: {
consent: aiConsent,
memoryEchoEnabled,
providerReady: aiProviderReady,
active: aiActive,
},
agentActions: recentAgentActions.map(a => ({
id: a.id,
agentName: a.agent?.name || 'Agent',
agentType: a.agent?.type || 'custom',
result: a.result ? String(a.result).slice(0, 200) : null,
createdAt: a.createdAt.toISOString(),
})),
gmail: gmailStatus,
pinnedNotes: pinnedNotes.map(n => ({
id: n.id,
title: n.title,
notebookId: n.notebookId,
updatedAt: n.updatedAt.toISOString(),
})),
writingActivity,
agentSuggestions: agentSuggestions.map(s => ({
id: s.id,
topic: s.topic,
reason: s.reason,
suggestedType: s.suggestedType,
suggestedFrequency: s.suggestedFrequency,
relatedNoteCount: (() => {
try { return JSON.parse(s.relatedNoteIds).length } catch { return 0 }
})(),
clusterId: s.clusterId,
})),
})
}

View File

@@ -0,0 +1,99 @@
import { NextResponse } from 'next/server'
import { auth } from '@/auth'
import prisma from '@/lib/prisma'
import { getChatProvider } from '@/lib/ai/factory'
import { getSystemConfig } from '@/lib/config'
import { redis } from '@/lib/redis'
import { detectUserLanguage } from '@/lib/i18n/detect-user-language'
import { withAiQuota, handleQuotaHttpError } from '@/lib/ai-quota'
import { QuotaExceededError } from '@/lib/entitlements'
const CACHE_TTL_SEC = 3600
/**
* GET /api/briefing/sentiment
* Analyzes the emotional tone of recent notes (last 7 days) using LLM.
*/
export async function GET() {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = session.user.id
const locale = await detectUserLanguage()
const cacheKey = `briefing:sentiment:${userId}:${locale}`
try {
const cached = await redis.get(cacheKey)
if (cached) return NextResponse.json(JSON.parse(cached))
} catch {}
const weekAgo = new Date()
weekAgo.setDate(weekAgo.getDate() - 7)
const recentNotes = await prisma.note.findMany({
where: {
userId,
trashedAt: null,
isArchived: false,
updatedAt: { gte: weekAgo },
},
select: { title: true, content: true },
take: 20,
orderBy: { updatedAt: 'desc' },
})
if (recentNotes.length < 3) {
return NextResponse.json({
available: false,
reason: 'Not enough notes this week',
})
}
const snippets = recentNotes.map(n => {
const text = n.content.replace(/<[^>]+>/g, ' ').replace(/&nbsp;/g, ' ').replace(/\s+/g, ' ').trim()
return `${n.title || ''}: ${text.slice(0, 200)}`
}).join('\n---\n')
try {
const config = await getSystemConfig()
const provider = getChatProvider(config)
if (!provider) {
return NextResponse.json({ available: false, reason: 'No AI provider' })
}
const localeLabel = locale === 'fr' ? 'French' : locale === 'en' ? 'English' : locale
const prompt = `Analyze the emotional patterns in these notes from the past week. Return ONLY valid JSON (no markdown, no code fences).
Write "summary" and "topTopic" in ${localeLabel} (locale code: ${locale}). Keep dominantEmotion keys in English as listed.
{"dominantEmotion":"focused|curious|enthusiastic|frustrated|calm|anxious|creative|reflective","sentimentScore":number from -1 to 1,"emotions":{"focused":number,"curious":number,"enthusiastic":number,"frustrated":number,"calm":number,"anxious":number,"creative":number,"reflective":number},"summary":"one sentence describing the emotional pattern","topTopic":"most discussed topic"}
Notes:
${snippets.slice(0, 3000)}`
const result = await withAiQuota(
userId,
'reformulate',
() => provider.generateText(prompt),
{ lane: 'chat' },
)
const jsonMatch = result.match(/\{[\s\S]*\}/)
if (!jsonMatch) {
return NextResponse.json({ available: false, reason: 'Parse error' })
}
const parsed = JSON.parse(jsonMatch[0])
const payload = { available: true, ...parsed }
try { await redis.setex(cacheKey, CACHE_TTL_SEC, JSON.stringify(payload)) } catch {}
return NextResponse.json(payload)
} catch (error) {
const quotaResp = handleQuotaHttpError(error)
if (quotaResp) return quotaResp
if (error instanceof QuotaExceededError) {
return NextResponse.json(error.toJSON(), { status: 402 })
}
console.error('[briefing/sentiment]', error)
return NextResponse.json({ available: false, reason: 'Analysis failed' })
}
}

View File

@@ -1,6 +1,6 @@
import { streamText, UIMessage, stepCountIs } from 'ai'
import { resolveAiRouteWithTiming, formatAiRouteDebug } from '@/lib/ai/router'
import { runLaneWithBillingUser, willUseByokForLane } from '@/lib/ai/provider-for-user'
import { runLaneWithBillingUser } from '@/lib/ai/provider-for-user'
import { getSystemConfig } from '@/lib/config'
import { getChatProvider } from '@/lib/ai/factory'
import { semanticSearchService } from '@/lib/ai/services/semantic-search.service'
@@ -9,7 +9,8 @@ import { auth } from '@/auth'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
import { loadTranslations, getTranslationValue, SupportedLanguage } from '@/lib/i18n'
import { toolRegistry } from '@/lib/ai/tools'
import { reserveUsageOrThrow, QuotaExceededError, QuotaServiceUnavailableError } from '@/lib/entitlements'
import { reserveAiUsageOrThrow, handleQuotaHttpError } from '@/lib/ai-quota'
import { QuotaExceededError, QuotaServiceUnavailableError } from '@/lib/entitlements'
import { ByokUnavailableError } from '@/lib/byok'
import { trackFeatureUsage } from '@/lib/usage-tracker'
import { readFile } from 'fs/promises'
@@ -72,11 +73,7 @@ export async function POST(req: Request) {
// 1.5 Quota check (per-provider BYOK bypass — only when BYOK will be used for resolved provider)
try {
const sysConfigEarly = await getSystemConfig()
const { usedByok: willUseByok } = await willUseByokForLane('chat', sysConfigEarly, userId)
if (!willUseByok) {
await reserveUsageOrThrow(userId, 'chat')
}
await reserveAiUsageOrThrow(userId, 'chat', { lane: 'chat' })
} catch (err) {
if (err instanceof QuotaExceededError) {
return Response.json(err.toJSON(), { status: 402 })

View File

@@ -18,10 +18,63 @@ export async function GET(request: NextRequest) {
const userId = session.user.id
// Fix any leftover "Cluster N" placeholders from pre-save naming bug
await clusteringService.ensureClusterNames(userId)
// Check for stored results (even if stale/périmés)
const stored = await clusteringService.getStoredClusters(userId)
const lite = request.nextUrl.searchParams.get('lite') === '1'
if (stored) {
const cached = stored.clusters
if (lite) {
const [bridgeNotesData, totalNotes] = await Promise.all([
prisma.bridgeNote.findMany({
where: { userId },
orderBy: { bridgeScore: 'desc' },
take: 5,
select: { noteId: true, bridgeScore: true, clustersConnected: true },
}),
prisma.note.count({ where: { userId, trashedAt: null } }),
])
let enrichedBridgeNotes: object[] = []
if (bridgeNotesData.length > 0) {
const bridgeNoteDetails = await prisma.note.findMany({
where: { id: { in: bridgeNotesData.map(b => b.noteId) } },
select: { id: true, title: 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 =>
clusteringService.displayName(
cached.find(c => c.clusterId === cid)?.name,
cid,
),
),
note: bridgeNoteDetailsMap.get(b.noteId),
}
})
}
return NextResponse.json({
clusters: cached,
bridgeNotes: enrichedBridgeNotes,
cached: true,
stale: stored.stale,
lastCalculated: stored.lastCalculated,
totalNotes,
})
}
// Fetch notes with their cluster assignments
const notes = await prisma.note.findMany({
where: { userId, trashedAt: null },
@@ -66,8 +119,11 @@ export async function GET(request: NextRequest) {
noteId: b.noteId,
bridgeScore: b.bridgeScore,
clustersConnected,
clusterNames: clustersConnected.map(
cid => cached.find(c => c.clusterId === cid)?.name || `Cluster ${cid}`
clusterNames: clustersConnected.map(cid =>
clusteringService.displayName(
cached.find(c => c.clusterId === cid)?.name,
cid,
),
),
note: bridgeNoteDetailsMap.get(b.noteId)
}
@@ -153,12 +209,10 @@ export async function POST(request: NextRequest) {
})
}
// 2. Generate cluster names with AI
for (const cluster of results.clusters) {
cluster.name = await clusteringService.generateClusterName(cluster.clusterId, userId)
}
// 2. Name from in-memory members (DB members do not exist until save)
await clusteringService.nameClustersFromResults(userId, results)
// 3. Save clustering results
// 3. Save clustering results (with real names)
await clusteringService.saveClusteringResults(userId, results)
// 4. Detect and save bridge notes

View File

@@ -0,0 +1,41 @@
import { NextRequest, NextResponse } from 'next/server'
import prisma from '@/lib/prisma'
import { agentSuggestionService } from '@/lib/ai/services/agent-suggestion.service'
export const dynamic = 'force-dynamic'
function authorizeCron(request: NextRequest): boolean {
const cronSecret = process.env.CRON_SECRET
if (!cronSecret) return false
const authHeader = request.headers.get('authorization')
const bearer = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null
const querySecret = new URL(request.url).searchParams.get('secret')
return bearer === cronSecret || querySecret === cronSecret
}
/**
* POST /api/cron/agent-suggestions
* Génère des suggestions d'agents à partir des clusters sémantiques existants.
*/
export async function POST(request: NextRequest) {
if (!authorizeCron(request)) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const users = await prisma.user.findMany({
where: { noteClusters: { some: {} } },
select: { id: true },
take: 30,
})
let total = 0
for (const user of users) {
try {
total += await agentSuggestionService.generateForUser(user.id)
} catch (err) {
console.error('[CronAgentSuggestions] user', user.id, err)
}
}
return NextResponse.json({ success: true, users: users.length, suggestionsUpserted: total })
}

View File

@@ -102,10 +102,8 @@ export async function GET(request: NextRequest) {
continue
}
// Generate cluster names
for (const cluster of clusterResults.clusters) {
cluster.name = await clusteringService.generateClusterName(cluster.clusterId, user.id)
}
// Name from in-memory members (before save — generateClusterName needs DB rows)
await clusteringService.nameClustersFromResults(user.id, clusterResults)
// Save results
await clusteringService.saveClusteringResults(user.id, clusterResults)
@@ -174,9 +172,9 @@ export async function POST(request: NextRequest) {
if (userId) {
// Process specific user
const clusterResults = await clusteringService.clusterNotes(userId)
const bridgeNotes = await bridgeNotesService.detectBridgeNotes(userId)
await clusteringService.nameClustersFromResults(userId, clusterResults)
await clusteringService.saveClusteringResults(userId, clusterResults)
const bridgeNotes = await bridgeNotesService.detectBridgeNotes(userId)
await bridgeNotesService.saveBridgeNotes(userId, bridgeNotes)
return NextResponse.json({
@@ -202,9 +200,9 @@ export async function POST(request: NextRequest) {
for (const user of users) {
try {
const clusterResults = await clusteringService.clusterNotes(user.id)
const bridgeNotes = await bridgeNotesService.detectBridgeNotes(user.id)
await clusteringService.nameClustersFromResults(user.id, clusterResults)
await clusteringService.saveClusteringResults(user.id, clusterResults)
const bridgeNotes = await bridgeNotesService.detectBridgeNotes(user.id)
await bridgeNotesService.saveBridgeNotes(user.id, bridgeNotes)
results.processed++

View File

@@ -0,0 +1,49 @@
import { NextRequest, NextResponse } from 'next/server'
import prisma from '@/lib/prisma'
import { gmailScannerService } from '@/lib/integrations/gmail-scanner.service'
export const dynamic = 'force-dynamic'
function authorize(request: NextRequest): boolean {
const cronSecret = process.env.CRON_SECRET
if (!cronSecret) return false
const authHeader = request.headers.get('authorization')
const bearer = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null
return bearer === cronSecret
}
/** POST /api/cron/gmail — scan Gmail for connected users (1×/jour en prod via entrypoint) */
export async function POST(request: NextRequest) {
if (!authorize(request)) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const users = await prisma.userAISettings.findMany({
select: { userId: true, integrationTokens: true },
take: 200,
})
const connected = users.filter(u => {
if (!u.integrationTokens) return false
try {
const meta = typeof u.integrationTokens === 'string'
? JSON.parse(u.integrationTokens)
: u.integrationTokens
return !!(meta?.gmailAccessToken && meta?.gmailRefreshToken)
} catch {
return false
}
})
let totalCreated = 0
for (const row of connected) {
try {
const { created } = await gmailScannerService.scanUser(row.userId)
totalCreated += created
} catch (err) {
console.error('[CronGmail]', row.userId, err)
}
}
return NextResponse.json({ success: true, users: connected.length, notesCreated: totalCreated })
}

View File

@@ -77,12 +77,12 @@ export async function POST(req: NextRequest) {
redis.get(`${key}:tokens`),
]);
const periodStart = new Date(`${period}-01`);
const periodEnd = new Date(
periodStart.getFullYear(),
periodStart.getMonth() + 1,
const periodStart = new Date(`${period}-01T00:00:00.000Z`);
const periodEnd = new Date(Date.UTC(
periodStart.getUTCFullYear(),
periodStart.getUTCMonth() + 1,
1,
);
));
await prisma.usageLog.upsert({
where: {

View File

@@ -0,0 +1,49 @@
import { NextRequest, NextResponse } from 'next/server'
import { Prisma } from '@prisma/client'
import { auth } from '@/auth'
import prisma from '@/lib/prisma'
import {
getDefaultDashboardLayout,
normalizeDashboardLayout,
type DashboardLayout,
} from '@/lib/dashboard/layout'
export async function GET() {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const settings = await prisma.userAISettings.findUnique({
where: { userId: session.user.id },
select: { dashboardLayout: true },
})
const layout = normalizeDashboardLayout(settings?.dashboardLayout ?? getDefaultDashboardLayout())
return NextResponse.json({ layout })
}
export async function PUT(request: NextRequest) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
let body: { layout?: DashboardLayout }
try {
body = await request.json()
} catch {
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 })
}
const layout = normalizeDashboardLayout(body.layout ?? getDefaultDashboardLayout())
const layoutJson = layout as unknown as Prisma.InputJsonValue
await prisma.userAISettings.upsert({
where: { userId: session.user.id },
create: { userId: session.user.id, dashboardLayout: layoutJson },
update: { dashboardLayout: layoutJson },
})
return NextResponse.json({ layout, ok: true })
}

View File

@@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from 'next/server'
import { getGoogleOAuthCredentials, readIntegrationMeta, writeIntegrationMeta } from '@/lib/integrations/google-integration-tokens'
export async function GET(req: NextRequest) {
const url = new URL(req.url)
const code = url.searchParams.get('code')
const userId = url.searchParams.get('state')
const error = url.searchParams.get('error')
if (error || !code || !userId) {
return NextResponse.redirect(`${url.origin}/settings/integrations?error=gmail_auth_failed`)
}
const { clientId, clientSecret } = getGoogleOAuthCredentials()
const tokenRes = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: clientId,
client_secret: clientSecret,
code,
redirect_uri: `${url.origin}/api/integrations/gmail/callback`,
grant_type: 'authorization_code',
}),
})
const tokenData = await tokenRes.json()
if (!tokenData.access_token) {
return NextResponse.redirect(`${url.origin}/settings/integrations?error=gmail_token_failed`)
}
const meta = await readIntegrationMeta(userId)
meta.gmailAccessToken = tokenData.access_token
if (tokenData.refresh_token) meta.gmailRefreshToken = tokenData.refresh_token
await writeIntegrationMeta(userId, meta)
return NextResponse.redirect(`${url.origin}/settings/integrations?connected=gmail`)
}

View File

@@ -0,0 +1,67 @@
/**
* Google Gmail Integration (OAuth + scan trigger)
*/
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import {
getGoogleOAuthCredentials,
getGoogleTokens,
readIntegrationMeta,
writeIntegrationMeta,
} from '@/lib/integrations/google-integration-tokens'
import { gmailScannerService } from '@/lib/integrations/gmail-scanner.service'
const GMAIL_SCOPE = 'https://www.googleapis.com/auth/gmail.readonly'
const GOOGLE_AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth'
function redirectUri(origin: string) {
return `${origin}/api/integrations/gmail/callback`
}
export async function GET(req: NextRequest) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const url = new URL(req.url)
const userId = session.user.id
if (url.searchParams.get('connect') === '1') {
const { clientId } = getGoogleOAuthCredentials()
if (!clientId) {
return NextResponse.json({ error: 'Google OAuth not configured' }, { status: 503 })
}
const authUrl = new URL(GOOGLE_AUTH_URL)
authUrl.searchParams.set('client_id', clientId)
authUrl.searchParams.set('redirect_uri', redirectUri(url.origin))
authUrl.searchParams.set('response_type', 'code')
authUrl.searchParams.set('scope', GMAIL_SCOPE)
authUrl.searchParams.set('access_type', 'offline')
authUrl.searchParams.set('prompt', 'consent')
authUrl.searchParams.set('state', userId)
return NextResponse.redirect(authUrl.toString())
}
if (url.searchParams.get('scan') === '1') {
const result = await gmailScannerService.scanUser(userId)
return NextResponse.json(result)
}
const status = await gmailScannerService.getStatus(userId)
return NextResponse.json(status)
}
export async function DELETE() {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const meta = await readIntegrationMeta(session.user.id)
delete meta.gmailAccessToken
delete meta.gmailRefreshToken
await writeIntegrationMeta(session.user.id, meta)
return NextResponse.json({ success: true })
}

View File

@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'
import { getMobileUserId } from '@/lib/mobile-auth'
import { getSystemConfig } from '@/lib/config'
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
export async function POST(req: NextRequest) {
const userId = await getMobileUserId(req)
@@ -18,6 +19,15 @@ export async function POST(req: NextRequest) {
const apiKey = config.OPENAI_API_KEY
if (!apiKey) return NextResponse.json({ error: 'Service non disponible' }, { status: 503 })
try {
await reserveUsageOrThrow(userId, 'voice_transcribe')
} catch (err) {
if (err instanceof QuotaExceededError) {
return NextResponse.json(err.toJSON(), { status: 429 })
}
throw err
}
const whisperForm = new FormData()
whisperForm.append('file', file, 'audio.m4a')
whisperForm.append('model', 'whisper-1')

View File

@@ -24,14 +24,14 @@ const PROVIDER_INFO: Record<string, { name: string; hint: string }> = {
minimax: { name: 'MiniMax', hint: 'MiniMax-M2.7, M2.5' },
google: { name: 'Google AI', hint: 'Gemini 1.5 Flash, Pro' },
deepseek: { name: 'DeepSeek', hint: 'DeepSeek Chat, Reasoner' },
openrouter: { name: 'OpenRouter', hint: 'Accès multi-fournisseurs' },
openrouter: { name: 'OpenRouter', hint: 'Multi-provider access' },
mistral: { name: 'Mistral AI', hint: 'Mistral Small, Large' },
glm: { name: 'GLM (Zhipu)', hint: 'GLM-4, GLM-4-Flash' },
zai: { name: 'Zuki Journey', hint: 'Proxy OpenAI/Anthropic' },
anthropic_custom: { name: 'Anthropic (custom)', hint: 'Proxy compatible Anthropic' },
custom_openai: { name: 'Compatible OpenAI', hint: 'Tout proxy compatible OpenAI' },
custom_anthropic: { name: 'Compatible Anthropic', hint: 'Tout proxy compatible Anthropic' },
custom: { name: 'API personnalisée', hint: 'Votre propre endpoint' },
zai: { name: 'Zuki Journey', hint: 'OpenAI/Anthropic proxy' },
anthropic_custom: { name: 'Anthropic (custom)', hint: 'Anthropic-compatible proxy' },
custom_openai: { name: 'Compatible OpenAI', hint: 'Any OpenAI-compatible proxy' },
custom_anthropic: { name: 'Compatible Anthropic', hint: 'Any Anthropic-compatible proxy' },
custom: { name: 'Custom API', hint: 'Your own endpoint' },
}
function displayName(provider: string): string {
@@ -59,7 +59,7 @@ type SavedKey = {
async function loadKeys(): Promise<{ keys: SavedKey[]; allowedProviders: string[] }> {
const res = await fetch('/api/user/api-keys')
if (!res.ok) throw new Error('Erreur de chargement')
if (!res.ok) throw new Error('Failed to load')
return res.json()
}
@@ -92,6 +92,8 @@ function EditKeyForm({
const [testing, setTesting] = useState(false)
const [testResult, setTestResult] = useState<{ ok: boolean; latency?: number; reply?: string; error?: string } | null>(null)
const { t } = useLanguage()
const needsUrl = NEEDS_BASE_URL.has(savedKey.provider)
const manualModel = MANUAL_MODEL_PROVIDERS.has(savedKey.provider)
@@ -118,10 +120,10 @@ function EditKeyForm({
body: JSON.stringify(payload),
})
const body = await res.json().catch(() => ({}))
if (!res.ok) throw new Error(body.message ?? body.error ?? 'Échec')
if (!res.ok) throw new Error(body.message ?? body.error ?? t('common.error'))
},
onSuccess: () => {
toast.success(`${displayName(savedKey.provider)} mis à jour ✓`)
toast.success(t('byok.updated', { name: displayName(savedKey.provider) }))
onInvalidate()
onDone()
},
@@ -132,7 +134,7 @@ function EditKeyForm({
const keyToUse = newKey.length >= 8 ? newKey : ''
if (!keyToUse && !model) return
if (!keyToUse) {
toast.info('Pour tester, entrez la clé API dans le champ "Nouvelle clé"')
toast.info(t('byok.testHint'))
return
}
setTesting(true)
@@ -143,7 +145,7 @@ function EditKeyForm({
const res = await fetch(`/api/user/api-keys/test-model?${q}`)
setTestResult(await res.json())
} catch {
setTestResult({ ok: false, error: 'Impossible de contacter le serveur' })
setTestResult({ ok: false, error: t('byok.contactError') })
} finally {
setTesting(false)
}
@@ -155,18 +157,18 @@ function EditKeyForm({
return (
<div className="mt-2 border border-violet-500/20 rounded-xl bg-violet-500/5 p-4 space-y-3">
<p className="text-[10px] font-bold uppercase tracking-widest text-violet-600 dark:text-violet-400">
Modifier {displayName(savedKey.provider)}
{t('byok.editLabel', { name: displayName(savedKey.provider) })}
</p>
{/* Alias */}
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1">
<Label htmlFor={`edit-alias-${savedKey.provider}`} className="text-[9px] font-semibold uppercase tracking-widest text-concrete">Alias</Label>
<Input id={`edit-alias-${savedKey.provider}`} value={alias} onChange={(e) => setAlias(e.target.value)} placeholder="ex. Ma clé pro" />
<Label htmlFor={`edit-alias-${savedKey.provider}`} className="text-[9px] font-semibold uppercase tracking-widest text-concrete">{t('byok.aliasLabel')}</Label>
<Input id={`edit-alias-${savedKey.provider}`} value={alias} onChange={(e) => setAlias(e.target.value)} placeholder={t('byok.aliasPlaceholder')} />
</div>
{needsUrl && (
<div className="space-y-1">
<Label htmlFor={`edit-baseurl-${savedKey.provider}`} className="text-[9px] font-semibold uppercase tracking-widest text-concrete">URL de l'API</Label>
<Label htmlFor={`edit-baseurl-${savedKey.provider}`} className="text-[9px] font-semibold uppercase tracking-widest text-concrete">{t('byok.apiUrl')}</Label>
<Input id={`edit-baseurl-${savedKey.provider}`} value={baseUrl} onChange={(e) => setBaseUrl(e.target.value.trim())} placeholder="https://api.example.com/v1" />
</div>
)}
@@ -174,21 +176,21 @@ function EditKeyForm({
{/* Model */}
<div className="space-y-1">
<Label htmlFor={`edit-model-${savedKey.provider}`} className="text-[9px] font-semibold uppercase tracking-widest text-concrete">Modèle</Label>
<Label htmlFor={`edit-model-${savedKey.provider}`} className="text-[9px] font-semibold uppercase tracking-widest text-concrete">{t('byok.model')}</Label>
{showModelDropdown ? (
<Select value={model} onValueChange={setModel}>
<SelectTrigger id={`edit-model-${savedKey.provider}`}><SelectValue placeholder="Choisir..." /></SelectTrigger>
<SelectTrigger id={`edit-model-${savedKey.provider}`}><SelectValue placeholder={t('byok.choose')} /></SelectTrigger>
<SelectContent>{models.map((m) => <SelectItem key={m} value={m}>{m}</SelectItem>)}</SelectContent>
</Select>
) : showModelInput ? (
<Input id={`edit-model-${savedKey.provider}`} value={model} onChange={(e) => setModel(e.target.value)} placeholder="ex. MiniMax-M2.7" />
) : <div className="flex items-center gap-2 text-xs text-concrete py-1"><Loader2 className="h-3 w-3 animate-spin" />Chargement...</div>}
) : <div className="flex items-center gap-2 text-xs text-concrete py-1"><Loader2 className="h-3 w-3 animate-spin" />{t('common.loading')}</div>}
</div>
{/* Key rotation (optional) */}
<div className="space-y-1">
<Label htmlFor={`edit-key-${savedKey.provider}`} className="text-[9px] font-semibold uppercase tracking-widest text-concrete">
Nouvelle clé API <span className="normal-case font-normal text-concrete">(laisser vide pour conserver l'actuelle)</span>
{t('byok.newKey')} <span className="normal-case font-normal text-concrete">{t('byok.newKeyHint')}</span>
</Label>
<Input
id={`edit-key-${savedKey.provider}`}
@@ -196,7 +198,7 @@ function EditKeyForm({
autoComplete="off"
value={newKey}
onChange={(e) => { setNewKey(e.target.value); setTestResult(null) }}
placeholder="sk-... (optionnel)"
placeholder={t('byok.keyPlaceholder')}
/>
</div>
@@ -209,8 +211,8 @@ function EditKeyForm({
{testResult.ok ? <CheckCircle2 size={12} className="shrink-0 mt-0.5" /> : <XCircle size={12} className="shrink-0 mt-0.5" />}
<span>
{testResult.ok
? `✓ Opérationnel${testResult.latency ? ` (${testResult.latency}ms)` : ''}${testResult.reply ? ` · ${testResult.reply}` : ''}`
: testResult.error ?? 'Échec'
? `${t('byok.operational')}${testResult.latency ? ` (${testResult.latency}ms)` : ''}${testResult.reply ? ` · ${testResult.reply}` : ''}`
: testResult.error ?? t('common.error')
}
</span>
</div>
@@ -225,7 +227,7 @@ function EditKeyForm({
className="flex items-center gap-1.5 px-3 py-2 rounded-lg text-[9px] font-bold uppercase tracking-[0.1em] border border-border bg-white dark:bg-white/5 hover:border-violet-400 transition-colors disabled:opacity-40 disabled:pointer-events-none"
>
{testing ? <Loader2 size={11} className="animate-spin" /> : <FlaskConical size={11} />}
Tester
{t('byok.test')}
</button>
<Button
type="button"
@@ -233,7 +235,7 @@ function EditKeyForm({
onClick={() => saveMutation.mutate()}
className="flex-1 py-2 rounded-lg text-[9px] font-bold uppercase tracking-[0.12em] bg-ink text-paper shadow hover:scale-[1.01] active:scale-[0.99] transition-all disabled:opacity-40"
>
{saveMutation.isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : 'Enregistrer les modifications'}
{saveMutation.isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : t('byok.saveChanges')}
</Button>
<button
type="button"
@@ -305,7 +307,7 @@ export function ByokSettingsPanel() {
async function verifyKey() {
if (!provider || apiKey.length < 8) return
if (NEEDS_BASE_URL.has(provider) && !baseUrl) { toast.error("Veuillez renseigner l'URL de l'API"); return }
if (NEEDS_BASE_URL.has(provider) && !baseUrl) { toast.error(t('byok.baseUrlRequired')); return }
setVerifying(true)
setTestResult(null)
try {
@@ -316,12 +318,12 @@ export function ByokSettingsPanel() {
if (res.ok && body.valid) {
setKeyOk(true)
if (Array.isArray(body.models) && body.models.length > 0) { setModels(body.models); if (!model) setModel(body.models[0]) }
toast.success('Clé API valide ✓')
toast.success(t('byok.keyValid'))
} else {
setKeyOk(false)
toast.error(body.message ?? 'Clé API invalide')
toast.error(body.message ?? t('byok.keyInvalid'))
}
} catch { setKeyOk(false); toast.error('Erreur de vérification') }
} catch { setKeyOk(false); toast.error(t('byok.verifyError')) }
finally { setVerifying(false) }
}
@@ -334,7 +336,7 @@ export function ByokSettingsPanel() {
if (NEEDS_BASE_URL.has(provider) && baseUrl) q.append('baseUrl', baseUrl)
const res = await fetch(`/api/user/api-keys/test-model?${q}`)
setTestResult(await res.json())
} catch { setTestResult({ ok: false, error: 'Impossible de contacter le serveur' }) }
} catch { setTestResult({ ok: false, error: t('byok.contactError') }) }
finally { setTesting(false) }
}
@@ -352,10 +354,10 @@ export function ByokSettingsPanel() {
body: JSON.stringify(payload),
})
const body = await res.json().catch(() => ({}))
if (!res.ok) throw new Error(body.message ?? body.error ?? 'Échec')
if (!res.ok) throw new Error(body.message ?? body.error ?? t('common.error'))
},
onSuccess: () => {
toast.success(`Clé ${displayName(provider)} enregistrée ✓`)
toast.success(t('byok.keySaved', { name: displayName(provider) }))
setProvider(''); setApiKey(''); setAlias(''); setBaseUrl('')
setModel(''); setModels([]); setKeyOk(false); setTestResult(null)
invalidate()
@@ -369,7 +371,7 @@ export function ByokSettingsPanel() {
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ isActive }),
})
if (!res.ok) throw new Error('Erreur')
if (!res.ok) throw new Error(t('common.error'))
},
onSuccess: invalidate,
onError: () => toast.error(t('byokSettings.error')),
@@ -378,9 +380,9 @@ export function ByokSettingsPanel() {
const deleteMutation = useMutation({
mutationFn: async (p: string) => {
const res = await fetch(`/api/user/api-keys/${encodeURIComponent(p)}`, { method: 'DELETE' })
if (!res.ok) throw new Error('Erreur')
if (!res.ok) throw new Error(t('common.error'))
},
onSuccess: () => { toast.success('Clé supprimée'); invalidate() },
onSuccess: () => { toast.success(t('byok.keyDeleted')); invalidate() },
onError: () => toast.error(t('byokSettings.error')),
})
@@ -417,9 +419,9 @@ export function ByokSettingsPanel() {
: 'bg-amber-500/10 text-amber-700 dark:text-amber-400 border-amber-500/20'
)}>
{activeKey ? (
<><Zap size={14} className="shrink-0" /><span>BYOK actif · <strong>{displayName(activeKey.provider)}</strong>{activeKey.model && <> · <code className="font-mono text-[10px]">{activeKey.model}</code></>}{activeKey.alias && <> · {activeKey.alias}</>}</span></>
<><Zap size={14} className="shrink-0" /><span>{t('byok.byokActive')} · <strong>{displayName(activeKey.provider)}</strong>{activeKey.model && <> · <code className="font-mono text-[10px]">{activeKey.model}</code></>}{activeKey.alias && <> · {activeKey.alias}</>}</span></>
) : (
<><Shield size={14} className="shrink-0" /><span>Aucune clé active utilisation des quotas Memento</span></>
<><Shield size={14} className="shrink-0" /><span>{t('byok.noActiveKey')}</span></>
)}
</div>
)}
@@ -431,7 +433,7 @@ export function ByokSettingsPanel() {
{/* Saved keys */}
{keys.length > 0 && (
<div className="space-y-2">
<p className="text-[10px] font-bold uppercase tracking-widest text-concrete">Clés enregistrées</p>
<p className="text-[10px] font-bold uppercase tracking-widest text-concrete">{t('byok.savedKeys')}</p>
<ul className="space-y-1">
{keys.map((key) => (
<li key={key.provider}>
@@ -448,7 +450,7 @@ export function ByokSettingsPanel() {
{key.model && <code className="text-[9px] font-mono text-brand-accent bg-brand-accent/10 px-1.5 py-0.5 rounded">{key.model}</code>}
{key.alias && <span className="text-[9px] text-concrete">{key.alias}</span>}
<span className={cn('text-[9px] font-medium', key.isActive ? 'text-emerald-600 dark:text-emerald-400' : 'text-concrete')}>
{key.isActive ? '● Actif' : '○ Inactif'}
{key.isActive ? t('byok.activeStatus') : t('byok.inactiveStatus')}
</span>
</div>
</div>
@@ -481,7 +483,7 @@ export function ByokSettingsPanel() {
type="button"
className="h-7 w-7 rounded-lg flex items-center justify-center text-destructive/50 hover:text-destructive hover:bg-rose-50 dark:hover:bg-rose-500/10 transition-colors"
disabled={deleteMutation.isPending}
onClick={() => { if (confirm(`Supprimer la clé ${displayName(key.provider)} ?`)) deleteMutation.mutate(key.provider) }}
onClick={() => { if (confirm(t('byok.confirmDelete', { name: displayName(key.provider) }))) deleteMutation.mutate(key.provider) }}
>
<Trash2 size={12} />
</button>
@@ -507,14 +509,14 @@ export function ByokSettingsPanel() {
{/* Add key form */}
<div className="space-y-4 border border-border/60 rounded-2xl p-5">
<p className="text-[10px] font-bold uppercase tracking-widest text-concrete">
{keys.length > 0 ? 'Ajouter / remplacer une clé' : 'Connecter votre fournisseur IA'}
{keys.length > 0 ? t('byok.addOrReplace') : t('byok.connectProvider')}
</p>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="byok-provider" className="text-[10px] font-semibold uppercase tracking-widest text-concrete">Fournisseur</Label>
<Label htmlFor="byok-provider" className="text-[10px] font-semibold uppercase tracking-widest text-concrete">{t('byokSettings.provider')}</Label>
<Select value={provider} onValueChange={onProviderChange} disabled={saveMutation.isPending}>
<SelectTrigger id="byok-provider"><SelectValue placeholder="Choisir un fournisseur..." /></SelectTrigger>
<SelectTrigger id="byok-provider"><SelectValue placeholder={t('byok.chooseProvider')} /></SelectTrigger>
<SelectContent>
{allowed.map((p) => (
<SelectItem key={p} value={p}>
@@ -526,20 +528,20 @@ export function ByokSettingsPanel() {
</Select>
</div>
<div className="space-y-1.5">
<Label htmlFor="byok-alias" className="text-[10px] font-semibold uppercase tracking-widest text-concrete">Alias <span className="normal-case font-normal">(optionnel)</span></Label>
<Input id="byok-alias" value={alias} onChange={(e) => setAlias(e.target.value)} placeholder="ex. Ma clé pro" disabled={saveMutation.isPending} />
<Label htmlFor="byok-alias" className="text-[10px] font-semibold uppercase tracking-widest text-concrete">{t('byok.aliasLabel')} <span className="normal-case font-normal">{t('byok.optional')}</span></Label>
<Input id="byok-alias" value={alias} onChange={(e) => setAlias(e.target.value)} placeholder={t('byok.aliasPlaceholder')} disabled={saveMutation.isPending} />
</div>
</div>
{NEEDS_BASE_URL.has(provider) && (
<div className="space-y-1.5">
<Label htmlFor="byok-baseurl" className="text-[10px] font-semibold uppercase tracking-widest text-concrete">URL de l'API</Label>
<Label htmlFor="byok-baseurl" className="text-[10px] font-semibold uppercase tracking-widest text-concrete">{t('byok.apiUrl')}</Label>
<Input id="byok-baseurl" value={baseUrl} onChange={(e) => setBaseUrl(e.target.value.trim())} placeholder="https://api.example.com/v1" disabled={saveMutation.isPending} />
</div>
)}
<div className="space-y-1.5">
<Label htmlFor="byok-key" className="text-[10px] font-semibold uppercase tracking-widest text-concrete">Clé API</Label>
<Label htmlFor="byok-key" className="text-[10px] font-semibold uppercase tracking-widest text-concrete">{t('byok.apiKey')}</Label>
<div className="flex gap-2">
<div className="relative flex-1">
<Input
@@ -555,19 +557,19 @@ export function ByokSettingsPanel() {
className={cn('shrink-0 px-4 py-2 rounded-xl text-[10px] font-bold uppercase tracking-[0.1em] transition-all border disabled:opacity-40 disabled:pointer-events-none',
keyOk ? 'bg-emerald-500/10 border-emerald-500/40 text-emerald-700 dark:text-emerald-400' : 'bg-white dark:bg-white/5 border-border hover:border-violet-400')}
>
{verifying ? <Loader2 className="h-4 w-4 animate-spin" /> : keyOk ? <CheckCircle2 className="h-4 w-4" /> : 'Vérifier'}
{verifying ? <Loader2 className="h-4 w-4 animate-spin" /> : keyOk ? <CheckCircle2 className="h-4 w-4" /> : t('byok.verify')}
</button>
</div>
</div>
{provider && (
<div className="space-y-1.5">
<Label htmlFor="byok-model" className="text-[10px] font-semibold uppercase tracking-widest text-concrete">Modèle</Label>
<Label htmlFor="byok-model" className="text-[10px] font-semibold uppercase tracking-widest text-concrete">{t('byok.model')}</Label>
{loadingModels ? (
<div className="flex items-center gap-2 text-xs text-concrete py-2"><Loader2 className="h-3 w-3 animate-spin" />Récupération des modèles...</div>
<div className="flex items-center gap-2 text-xs text-concrete py-2"><Loader2 className="h-3 w-3 animate-spin" />{t('byok.fetchingModels')}</div>
) : showModelDropdown ? (
<Select value={model} onValueChange={(v) => { setModel(v); setTestResult(null) }} disabled={saveMutation.isPending}>
<SelectTrigger id="byok-model"><SelectValue placeholder="Choisir un modèle..." /></SelectTrigger>
<SelectTrigger id="byok-model"><SelectValue placeholder={t('byok.chooseModel')} /></SelectTrigger>
<SelectContent>{models.map((m) => <SelectItem key={m} value={m}>{m}</SelectItem>)}</SelectContent>
</Select>
) : showModelInput ? (
@@ -582,8 +584,8 @@ export function ByokSettingsPanel() {
{testResult.ok ? <CheckCircle2 size={14} className="shrink-0 mt-0.5" /> : <XCircle size={14} className="shrink-0 mt-0.5" />}
<div className="space-y-0.5">
{testResult.ok ? (
<><p className="font-semibold">Modèle opérationnel ✓{testResult.latency && <span className="font-normal ml-1">({testResult.latency}ms)</span>}</p>{testResult.reply && <p className="opacity-70 font-mono text-[10px]">Réponse : {testResult.reply}</p>}</>
) : <p className="font-semibold">{testResult.error ?? 'Échec du test'}</p>}
<><p className="font-semibold">{t('byok.modelOperational')}{testResult.latency && <span className="font-normal ml-1">({testResult.latency}ms)</span>}</p>{testResult.reply && <p className="opacity-70 font-mono text-[10px]">{t('byok.reply')} {testResult.reply}</p>}</>
) : <p className="font-semibold">{testResult.error ?? t('byok.testFailed')}</p>}
</div>
</div>
)}
@@ -593,7 +595,7 @@ export function ByokSettingsPanel() {
type="button" disabled={!canTest || testing || saveMutation.isPending} onClick={testModel}
className="flex items-center gap-2 px-4 py-2.5 rounded-xl text-[10px] font-bold uppercase tracking-[0.1em] transition-all border bg-white dark:bg-white/5 border-border hover:border-violet-400 disabled:opacity-40 disabled:pointer-events-none"
>
{testing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <FlaskConical size={13} />}Tester
{testing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <FlaskConical size={13} />}{t('byok.test')}
</button>
<Button
type="button" disabled={saveDisabled} onClick={() => saveMutation.mutate()}
@@ -601,7 +603,7 @@ export function ByokSettingsPanel() {
>
<div className="flex items-center justify-center gap-2">
{saveMutation.isPending && <Loader2 className="h-4 w-4 animate-spin" />}
{provider ? `Enregistrer — ${displayName(provider)}` : 'Enregistrer'}
{provider ? t('byok.saveWithName', { name: displayName(provider) }) : t('byok.save')}
</div>
</Button>
</div>

View File

@@ -3,6 +3,7 @@
import { useState, useEffect, useMemo } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { Search, Sparkles, Link2, X, Folder } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
export interface BlockSuggestion {
blockId: string
@@ -22,6 +23,7 @@ interface BlockPickerProps {
}
export function BlockPicker({ isOpen, onClose, currentNoteId, onSelectBlock }: BlockPickerProps) {
const { t } = useLanguage()
const [activeTab, setActiveTab] = useState<'suggestions' | 'search'>('suggestions')
const [searchQuery, setSearchQuery] = useState('')
const [suggestions, setSuggestions] = useState<BlockSuggestion[]>([])
@@ -134,7 +136,7 @@ export function BlockPicker({ isOpen, onClose, currentNoteId, onSelectBlock }: B
type="text"
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
placeholder="Rechercher un extrait de note..."
placeholder={t('blockPicker.searchPlaceholder')}
className="w-full bg-white dark:bg-zinc-850 border border-[#D5D2CD] dark:border-neutral-800 rounded-xl pl-9 pr-4 py-2 text-xs outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/20 transition-all font-sans"
autoFocus
/>

View File

@@ -1,6 +1,7 @@
'use client'
import { useState, useEffect } from 'react'
import { useLanguage } from '@/lib/i18n'
import { motion } from 'motion/react'
import { Zap, Lightbulb, Trophy } from 'lucide-react'
@@ -32,6 +33,7 @@ interface BridgeNotesDashboardProps {
}
export function BridgeNotesDashboard({ onNoteClick, clusters }: BridgeNotesDashboardProps) {
const { t } = useLanguage()
const [bridgeNotes, setBridgeNotes] = useState<BridgeNote[]>([])
const [suggestions, setSuggestions] = useState<BridgeSuggestion[]>([])
const [loading, setLoading] = useState(true)
@@ -109,14 +111,14 @@ export function BridgeNotesDashboard({ onNoteClick, clusters }: BridgeNotesDashb
<div className="p-5 rounded-2xl bg-white dark:bg-white/5 border border-border shadow-sm">
<div className="flex items-center gap-2 text-indigo-500 mb-2">
<Trophy size={14} />
<span className="text-[10px] font-bold uppercase tracking-widest">Bridges</span>
<span className="text-[10px] font-bold uppercase tracking-widest">{t('bridgeNotes.bridges')}</span>
</div>
<div className="text-3xl font-memento-serif font-medium text-ink dark:text-dark-ink">{bridgeNotes.length}</div>
</div>
<div className="p-5 rounded-2xl bg-white dark:bg-white/5 border border-border shadow-sm">
<div className="flex items-center gap-2 text-ochre mb-2">
<Lightbulb size={14} />
<span className="text-[10px] font-bold uppercase tracking-widest">Suggestions</span>
<span className="text-[10px] font-bold uppercase tracking-widest">{t('bridgeNotes.suggestions')}</span>
</div>
<div className="text-3xl font-memento-serif font-medium text-ink dark:text-dark-ink">{suggestions.length}</div>
</div>
@@ -126,7 +128,7 @@ export function BridgeNotesDashboard({ onNoteClick, clusters }: BridgeNotesDashb
<section>
<div className="flex items-center gap-2 mb-6 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-sm font-bold uppercase tracking-widest text-ink dark:text-dark-ink">{t('bridgeNotes.powerfulTitle')}</h3>
</div>
<div className="space-y-3">
{bridgeNotes.map((bridge) => (
@@ -138,10 +140,10 @@ export function BridgeNotesDashboard({ onNoteClick, clusters }: BridgeNotesDashb
>
<div className="flex items-center justify-between mb-2">
<h4 className="text-sm font-medium text-ink dark:text-dark-ink truncate flex-1">
{bridge.note?.title || 'Untitled'}
{bridge.note?.title || t('common.untitled')}
</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)}%
{t('bridgeNotes.score')}: {(bridge.bridgeScore * 100).toFixed(0)}%
</span>
</div>
<div className="flex items-center gap-2">
@@ -158,7 +160,7 @@ export function BridgeNotesDashboard({ onNoteClick, clusters }: BridgeNotesDashb
</motion.div>
))}
{bridgeNotes.length === 0 && (
<div className="text-xs text-concrete italic p-4">No significant bridge notes found yet. Deepen your research to find new connections.</div>
<div className="text-xs text-concrete italic p-4">{t('bridgeNotes.empty')}</div>
)}
</div>
</section>
@@ -167,7 +169,7 @@ export function BridgeNotesDashboard({ onNoteClick, clusters }: BridgeNotesDashb
<section>
<div className="flex items-center gap-2 mb-6 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-sm font-bold uppercase tracking-widest text-ink dark:text-dark-ink">{t('bridgeNotes.missingLinks')}</h3>
</div>
<div className="space-y-4">
{suggestions.map((s, idx) => (
@@ -180,7 +182,7 @@ export function BridgeNotesDashboard({ onNoteClick, clusters }: BridgeNotesDashb
<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}
{t('bridgeNotes.bridging')} {s.clusterAName} & {s.clusterBName}
</span>
</div>
<h4 className="text-base font-memento-serif font-medium text-ink dark:text-dark-ink mb-2">{s.suggestedTitle}</h4>
@@ -193,7 +195,7 @@ export function BridgeNotesDashboard({ onNoteClick, clusters }: BridgeNotesDashb
<button
onClick={() => dismissSuggestion(s.clusterAId, s.clusterBId)}
className="ml-4 p-2 text-concrete hover:text-ink dark:hover:text-dark-ink hover:bg-concrete/10 rounded-lg transition-colors"
title="Dismiss suggestion"
title={t('bridgeNotes.dismiss')}
>
×
</button>
@@ -203,13 +205,13 @@ export function BridgeNotesDashboard({ onNoteClick, clusters }: BridgeNotesDashb
{suggestions.length === 0 && !loading && (
<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>
<p className="text-sm">{t('bridgeNotes.noSuggestions')}</p>
<p className="text-xs mt-1">{t('bridgeNotes.allConnected')}</p>
<button
onClick={generateNewSuggestions}
className="mt-4 px-4 py-2 bg-indigo-500 text-white text-xs rounded-lg hover:bg-indigo-600 transition-colors"
>
Generate Suggestions
{t('bridgeNotes.generate')}
</button>
</div>
)}

View File

@@ -6,6 +6,7 @@ import { NoteChart } from './note-chart'
import { suggestCharts, chartSuggestionToMarkdown, type ChartSuggestion, type SuggestChartsResponse } from '@/lib/ai/services/chart-suggestion.service'
import { BarChart3, X, Search, AlertCircle, Sparkles } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useLanguage } from '@/lib/i18n'
// Chart type to color mapping for visual variety
const CHART_TYPE_COLORS: Record<string, string> = {
@@ -36,6 +37,7 @@ export function ChartSuggestionsDialog({
onClose,
onSelectChart,
}: ChartSuggestionsDialogProps) {
const { t } = useLanguage()
const [response, setResponse] = useState<SuggestChartsResponse | null>(null)
const [loading, setLoading] = useState(false)
const [selectedIndex, setSelectedIndex] = useState<number | null>(null)
@@ -127,12 +129,12 @@ export function ChartSuggestionsDialog({
{loading ? (
<span className="flex items-center gap-2">
<Search className="w-3 h-3 animate-spin" />
Analyzing {isAnalyzingSelection ? 'selection' : 'note'} ({wordCount} words)...
{isAnalyzingSelection ? t('chart.analyzingSelection') : t('chart.analyzingNote')} ({wordCount} {t('chart.words')})...
</span>
) : response?.hasData ? (
`Found ${response?.detectedData || 'data'}`
`${t('chart.found')} ${response?.detectedData || ''}`
) : (
'No data detected'
t('chart.noDataDetected')
)}
</p>
</div>
@@ -140,7 +142,7 @@ export function ChartSuggestionsDialog({
<button
onClick={onClose}
className="p-2 hover:bg-muted rounded-lg transition-colors"
aria-label="Close"
aria-label={t('common.close')}
>
<X className="w-5 h-5" />
</button>
@@ -152,7 +154,7 @@ export function ChartSuggestionsDialog({
<div className="flex items-center justify-center py-12">
<div className="text-center">
<Search className="w-12 h-12 mx-auto mb-4 text-muted-foreground animate-pulse" />
<p className="text-muted-foreground">Analyzing your content for chart data...</p>
<p className="text-muted-foreground">{t('chart.analyzing')}</p>
</div>
</div>
) : response?.quotaExceeded ? (
@@ -167,7 +169,7 @@ export function ChartSuggestionsDialog({
className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors"
onClick={() => (window.location.href = '/settings/billing')}
>
Upgrade Plan
{t('ai.featureLocked')}
</button>
</div>
</div>
@@ -175,10 +177,10 @@ export function ChartSuggestionsDialog({
<div className="flex items-center justify-center py-12">
<div className="text-center max-w-md">
<AlertCircle className="w-12 h-12 mx-auto mb-4 text-destructive" />
<h3 className="text-lg font-semibold mb-2">Error</h3>
<h3 className="text-lg font-semibold mb-2">{t('common.error')}</h3>
<p className="text-sm text-muted-foreground mb-2">{response.error}</p>
<details className="text-left text-xs text-muted-foreground mt-4">
<summary className="cursor-pointer hover:text-foreground">Debug info</summary>
<summary className="cursor-pointer hover:text-foreground">{t('chart.debugInfo')}</summary>
<pre className="mt-2 bg-muted p-2 rounded overflow-auto max-h-32">
analyzedText: {response.analyzedText}
{'\n'}detectedData: {response.detectedData}
@@ -190,20 +192,20 @@ export function ChartSuggestionsDialog({
<div className="flex items-center justify-center py-12">
<div className="text-center max-w-md">
<AlertCircle className="w-12 h-12 mx-auto mb-4 text-muted-foreground" />
<h3 className="text-lg font-semibold mb-2">No Data Detected</h3>
<h3 className="text-lg font-semibold mb-2">{t('chart.noDataDetected')}</h3>
<p className="text-muted-foreground mb-4">
Try adding numerical data to your note. Charts work best with:
{t('chart.noDataHint')}
</p>
<ul className="text-sm text-muted-foreground text-left inline-block">
<li> Sales figures, metrics, or measurements</li>
<li> Lists with values (e.g., "Jan: 5000, Feb: 7500")</li>
<li> Percentages or proportions</li>
<li> Time-series data</li>
<li>{t('chart.dataHint1')}</li>
<li>{t('chart.dataHint2')}</li>
<li>{t('chart.dataHint3')}</li>
<li>{t('chart.dataHint4')}</li>
</ul>
<div className="mt-4 pt-4 border-t border-border/50">
<details className="text-left">
<summary className="text-xs text-muted-foreground cursor-pointer hover:text-foreground">
Debug: Show what was analyzed
{t('chart.debugShowAnalyzed')}
</summary>
<pre className="mt-2 text-xs bg-muted p-2 rounded overflow-auto max-h-32">
{textToAnalyze.substring(0, 500)}
@@ -276,7 +278,7 @@ export function ChartSuggestionsDialog({
{/* Data preview */}
<div className="mt-2 pt-2 border-t border-border/50">
<p className="text-xs text-muted-foreground">
{suggestion.data.length} data points
{suggestion.data.length} {t('chart.dataPoints')}
</p>
</div>
</button>
@@ -286,9 +288,9 @@ export function ChartSuggestionsDialog({
{/* Keyboard hint */}
<div className="flex items-center justify-center gap-4 pt-4 text-xs text-muted-foreground">
<span> Navigate</span>
<span> Select</span>
<span>Esc Cancel</span>
<span>{t('chart.navigateHint')}</span>
<span>{t('chart.selectHint')}</span>
<span>{t('chart.escCancel')}</span>
</div>
</div>
)}

View File

@@ -1,6 +1,7 @@
'use client'
import { useEffect, useState, useCallback } from 'react'
import { useLanguage } from '@/lib/i18n'
import ReactFlow, {
Node,
Edge,
@@ -47,6 +48,7 @@ export function ClusterVisualization({
bridgeNotes,
onNodeClick
}: ClusterVisualizationProps) {
const { t } = useLanguage()
const [nodes, setNodes, onNodesChange] = useNodesState([])
const [edges, setEdges, onEdgesChange] = useEdgesState([])
const [selectedCluster, setSelectedCluster] = useState<number | null>(null)
@@ -79,8 +81,8 @@ export function ClusterVisualization({
data: {
label: (
<div className="px-3 py-1 rounded-full text-sm font-medium" style={{ backgroundColor: color }}>
{cluster.name || `Cluster ${cluster.clusterId}`}
<span className="ml-2 text-xs opacity-75">({cluster.noteIds.length} notes)</span>
{cluster.name || t('clusters.clusterLabel', { id: cluster.clusterId })}
<span className="ml-2 text-xs opacity-75">({cluster.noteIds.length} {t('clusters.notes')})</span>
</div>
)
},
@@ -174,8 +176,8 @@ export function ClusterVisualization({
<svg className="w-16 h-16 mx-auto mb-4 opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
<p>No clusters to display</p>
<p className="text-sm mt-2">Create more notes to generate clusters</p>
<p>{t('clusters.empty')}</p>
<p className="text-sm mt-2">{t('clusters.emptyHint')}</p>
</div>
</div>
)
@@ -200,10 +202,10 @@ export function ClusterVisualization({
{selectedCluster !== null && (
<div className="absolute bottom-4 left-4 bg-white rounded-lg shadow-lg p-4 max-w-xs">
<h3 className="font-semibold mb-2">
{clusters.find(c => c.clusterId === selectedCluster)?.name || `Cluster ${selectedCluster}`}
{clusters.find(c => c.clusterId === selectedCluster)?.name || t('clusters.clusterLabel', { id: selectedCluster })}
</h3>
<p className="text-sm text-gray-600">
{clusters.find(c => c.clusterId === selectedCluster)?.noteIds.length || 0} notes
{clusters.find(c => c.clusterId === selectedCluster)?.noteIds.length || 0} {t('clusters.notes')}
</p>
</div>
)}
@@ -212,11 +214,11 @@ export function ClusterVisualization({
<div className="flex items-center gap-4 text-sm">
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded-full bg-yellow-400 border-2 border-yellow-600"></div>
<span>Bridge note</span>
<span>{t('clusters.bridgeNote')}</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded-full bg-blue-500"></div>
<span>Regular note</span>
<span>{t('clusters.regularNote')}</span>
</div>
</div>
</div>

View File

@@ -0,0 +1,126 @@
'use client'
import { motion } from 'motion/react'
import { Inbox, GraduationCap, Bell, Sparkles, Layers } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
export interface DashboardActionStripProps {
inboxCount: number
dueFlashcards: number
reminderCount: number
discoveryCount: number
themeCount: number
inboxPulse?: number
onInbox: () => void
onReview: () => void
onReminders: () => void
onDiscoveries: () => void
onThemes: () => void
prefersReducedMotion?: boolean
}
export function DashboardActionStrip({
inboxCount,
dueFlashcards,
reminderCount,
discoveryCount,
themeCount,
inboxPulse = 0,
onInbox,
onReview,
onReminders,
onDiscoveries,
onThemes,
prefersReducedMotion,
}: DashboardActionStripProps) {
const { t } = useLanguage()
const items = [
{
key: 'inbox',
icon: Inbox,
label: t('homeDashboard.inbox'),
value: inboxCount,
onClick: onInbox,
accent: inboxCount > 0,
pulse: inboxPulse > 0,
},
{
key: 'review',
icon: GraduationCap,
label: t('homeDashboard.review'),
value: dueFlashcards,
onClick: onReview,
accent: dueFlashcards > 0,
},
{
key: 'reminders',
icon: Bell,
label: t('homeDashboard.reminders'),
value: reminderCount,
onClick: onReminders,
accent: reminderCount > 0,
},
{
key: 'discoveries',
icon: Sparkles,
label: t('homeDashboard.aiFound'),
value: discoveryCount,
onClick: onDiscoveries,
accent: discoveryCount > 0,
},
{
key: 'themes',
icon: Layers,
label: t('homeDashboard.themes'),
value: themeCount,
onClick: onThemes,
accent: themeCount > 0,
},
]
return (
<div className="flex gap-2 overflow-x-auto custom-scrollbar pb-0.5 -mx-0.5 px-0.5">
{items.map(item => {
const Icon = item.icon
const Wrapper = item.pulse && !prefersReducedMotion ? motion.button : 'button'
const motionProps = item.pulse && !prefersReducedMotion
? {
key: `inbox-pulse-${inboxPulse}`,
animate: { scale: [1, 1.03, 1] },
transition: { duration: 0.45 },
}
: {}
return (
<Wrapper
key={item.key}
type="button"
onClick={item.onClick}
{...motionProps}
className={`shrink-0 flex items-center gap-2.5 px-3.5 py-2.5 rounded-xl border transition-all text-start min-w-[108px] ${
item.accent
? 'border-brand-accent/30 bg-brand-accent/[0.06] hover:border-brand-accent/50 hover:bg-brand-accent/10 shadow-sm'
: 'border-border/25 bg-white/60 dark:bg-zinc-900/40 hover:border-border/50'
}`}
>
<div className={`w-7 h-7 rounded-lg flex items-center justify-center shrink-0 ${
item.accent ? 'bg-brand-accent/15 text-brand-accent' : 'bg-stone-100 dark:bg-zinc-800 text-concrete'
}`}>
<Icon size={13} strokeWidth={2} />
</div>
<div className="min-w-0">
<p className={`text-lg font-serif font-bold leading-none ${
item.accent ? 'text-ink dark:text-dark-ink' : 'text-concrete/80'
}`}>
{item.value}
</p>
<p className="text-[8px] font-mono font-bold uppercase tracking-wider text-concrete truncate mt-0.5">
{item.label}
</p>
</div>
</Wrapper>
)
})}
</div>
)
}

View File

@@ -0,0 +1,160 @@
'use client'
import { useState } from 'react'
import { motion, AnimatePresence } from 'motion/react'
import { Search, Bot, ChevronLeft, ChevronRight, Loader2 } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import { DashboardWidgetTitleRow } from '@/components/dashboard-widget-title-row'
export interface AgentSuggestion {
id: string
topic: string
reason: string
relatedNoteCount: number
suggestedFrequency: string
}
export interface DashboardAgentCarouselProps {
suggestions: AgentSuggestion[]
loading?: boolean
actingId: string | null
formatFrequency: (f: string) => string
onAccept: (id: string) => void
onDismiss: (id: string) => void
prefersReducedMotion?: boolean
}
export function DashboardAgentCarousel({
suggestions,
loading,
actingId,
formatFrequency,
onAccept,
onDismiss,
prefersReducedMotion,
}: DashboardAgentCarouselProps) {
const { t } = useLanguage()
const [idx, setIdx] = useState(0)
const navActions = suggestions.length > 1 ? (
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => setIdx(i => Math.max(0, i - 1))}
disabled={idx === 0}
className="p-1 rounded border border-border/30 disabled:opacity-25"
aria-label={t('homeDashboard.intelPrev')}
>
<ChevronLeft size={12} />
</button>
<span className="text-[8px] font-mono text-concrete px-1">
{idx + 1}/{suggestions.length}
</span>
<button
type="button"
onClick={() => setIdx(i => Math.min(suggestions.length - 1, i + 1))}
disabled={idx >= suggestions.length - 1}
className="p-1 rounded border border-border/30 disabled:opacity-25"
aria-label={t('homeDashboard.intelNext')}
>
<ChevronRight size={12} />
</button>
</div>
) : null
if (loading) {
return <div className="h-[140px] rounded-2xl bg-stone-50 dark:bg-zinc-950/30 animate-pulse" />
}
return (
<div className="rounded-2xl border border-border/30 bg-white dark:bg-zinc-900 p-4">
<DashboardWidgetTitleRow
widgetId="agents"
icon={<Bot size={12} className="text-brand-accent" />}
title={t('homeDashboard.suggestedResearch')}
actions={navActions}
/>
{suggestions.length === 0 ? (
<div className="rounded-xl border border-dashed border-border/35 bg-stone-50/50 dark:bg-zinc-950/30 p-3">
<p className="text-[11px] text-concrete leading-relaxed">
{t('homeDashboard.agentsEmpty')}
</p>
</div>
) : (
<AgentSlide
current={suggestions[idx]}
actingId={actingId}
formatFrequency={formatFrequency}
onAccept={onAccept}
onDismiss={onDismiss}
prefersReducedMotion={prefersReducedMotion}
t={t}
/>
)}
</div>
)
}
function AgentSlide({
current,
actingId,
formatFrequency,
onAccept,
onDismiss,
prefersReducedMotion,
t,
}: {
current: AgentSuggestion
actingId: string | null
formatFrequency: (f: string) => string
onAccept: (id: string) => void
onDismiss: (id: string) => void
prefersReducedMotion?: boolean
t: (key: string) => string
}) {
const slide = prefersReducedMotion
? { initial: {}, animate: {}, exit: {} }
: { initial: { opacity: 0, y: 8 }, animate: { opacity: 1, y: 0 }, exit: { opacity: 0, y: -8 } }
return (
<AnimatePresence mode="wait">
<motion.div
key={current.id}
initial={slide.initial}
animate={slide.animate}
exit={slide.exit}
transition={{ duration: prefersReducedMotion ? 0 : 0.18 }}
className="p-3.5 rounded-xl border border-border/25 bg-stone-50/60 dark:bg-zinc-950/40"
>
<div className="flex items-start gap-2 mb-2">
<Search size={12} className="text-brand-accent shrink-0 mt-0.5" />
<p className="text-sm font-semibold text-ink dark:text-dark-ink leading-snug">{current.topic}</p>
</div>
<p className="text-[11px] text-concrete leading-relaxed line-clamp-2 mb-3">{current.reason}</p>
<p className="text-[8px] font-mono uppercase text-concrete mb-3">
{current.relatedNoteCount} {t('homeDashboard.notes')} · {formatFrequency(current.suggestedFrequency)}
</p>
<div className="flex gap-2">
<button
type="button"
disabled={actingId === current.id}
onClick={() => onDismiss(current.id)}
className="text-[9px] font-mono uppercase px-2.5 py-1.5 rounded-lg border border-border/40 text-concrete hover:text-ink transition-colors disabled:opacity-40"
>
{t('homeDashboard.dismiss')}
</button>
<button
type="button"
disabled={actingId === current.id}
onClick={() => onAccept(current.id)}
className="flex-1 inline-flex items-center justify-center gap-1 text-[9px] font-mono uppercase px-2.5 py-1.5 rounded-lg bg-ink text-white dark:bg-white dark:text-black font-bold hover:opacity-90 disabled:opacity-40"
>
{actingId === current.id ? <Loader2 size={10} className="animate-spin" /> : null}
{t('homeDashboard.createAgent')}
</button>
</div>
</motion.div>
</AnimatePresence>
)
}

View File

@@ -0,0 +1,413 @@
'use client'
import { Inbox, GraduationCap, Mail, Pin, Bot, BarChart3 } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import { RevisionHeatmap } from '@/components/flashcards/revision-heatmap'
import { UsageMeter } from '@/components/usage-meter'
import { DashboardWidgetTitleRow } from '@/components/dashboard-widget-title-row'
import type { DashboardWidgetId } from '@/lib/dashboard/layout'
interface DashboardWidgetShellProps {
widgetId: DashboardWidgetId
icon: React.ReactNode
title: string
children: React.ReactNode
compact?: boolean
headerActions?: React.ReactNode
}
export function DashboardWidgetShell({
widgetId,
icon,
title,
children,
compact,
headerActions,
}: DashboardWidgetShellProps) {
return (
<div className={`rounded-2xl border border-border/30 bg-white dark:bg-zinc-900 h-full ${
compact ? 'p-3' : 'p-4'
}`}>
<DashboardWidgetTitleRow
widgetId={widgetId}
icon={icon}
title={title}
actions={headerActions}
className={compact ? 'mb-2' : 'mb-3'}
/>
{children}
</div>
)
}
export function DashboardInboxWidget({
count,
loading,
onOpen,
}: {
count: number
loading: boolean
onOpen: () => void
}) {
const { t } = useLanguage()
return (
<DashboardWidgetShell
widgetId="inbox"
icon={<Inbox size={12} className="text-brand-accent" />}
title={t('homeDashboard.inbox')}
compact
>
{loading ? (
<div className="h-10 rounded-lg bg-stone-50 animate-pulse" />
) : (
<button
type="button"
onClick={onOpen}
className="w-full flex items-center justify-between gap-3 p-3 rounded-xl border border-border/20 hover:border-brand-accent/30 hover:bg-brand-accent/[0.03] transition-all text-start"
>
<div>
<p className="text-2xl font-serif font-bold text-ink dark:text-dark-ink leading-none">{count}</p>
<p className="text-[10px] text-concrete mt-1">{t('homeDashboard.toOrganize')}</p>
</div>
<span className="text-[9px] font-mono uppercase font-bold text-brand-accent">{t('homeDashboard.widgetOpen')} </span>
</button>
)}
</DashboardWidgetShell>
)
}
export function DashboardRevisionWidget({
dueCount,
loading,
onOpen,
}: {
dueCount: number
loading: boolean
onOpen: () => void
}) {
const { t } = useLanguage()
return (
<DashboardWidgetShell
widgetId="revision"
icon={<GraduationCap size={12} className="text-brand-accent" />}
title={t('homeDashboard.review')}
compact
>
{loading ? (
<div className="h-10 rounded-lg bg-stone-50 animate-pulse" />
) : (
<button
type="button"
onClick={onOpen}
className="w-full flex items-center justify-between gap-3 p-3 rounded-xl border border-border/20 hover:border-brand-accent/30 hover:bg-brand-accent/[0.03] transition-all text-start"
>
<div>
<p className="text-2xl font-serif font-bold text-ink dark:text-dark-ink leading-none">{dueCount}</p>
<p className="text-[10px] text-concrete mt-1">{t('homeDashboard.cardsDue')}</p>
</div>
<span className="text-[9px] font-mono uppercase font-bold text-brand-accent">{t('homeDashboard.widgetOpen')} </span>
</button>
)}
</DashboardWidgetShell>
)
}
export function DashboardStatsWidget({
clusterCount,
bridgeCount,
noteCount,
loading,
onOpen,
}: {
clusterCount: number
bridgeCount: number
noteCount: number
loading: boolean
onOpen: () => void
}) {
const { t } = useLanguage()
return (
<DashboardWidgetShell
widgetId="stats"
icon={<BarChart3 size={12} className="text-brand-accent" />}
title={t('homeDashboard.widgets.stats')}
compact
>
{loading ? (
<div className="grid grid-cols-3 gap-2">
{[0, 1, 2].map(i => <div key={i} className="h-12 rounded-lg bg-stone-50 animate-pulse" />)}
</div>
) : (
<button type="button" onClick={onOpen} className="w-full text-start">
<div className="grid grid-cols-3 gap-2">
{[
{ label: t('homeDashboard.themes'), value: clusterCount },
{ label: t('homeDashboard.widgetStatsBridges'), value: bridgeCount },
{ label: t('homeDashboard.widgetStatsNotes'), value: noteCount },
].map(item => (
<div key={item.label} className="p-2 rounded-xl border border-border/20 bg-stone-50/50 dark:bg-zinc-950/30 text-center">
<p className="text-lg font-serif font-bold text-ink dark:text-dark-ink leading-none">{item.value}</p>
<p className="text-[8px] font-mono uppercase text-concrete mt-1 truncate">{item.label}</p>
</div>
))}
</div>
</button>
)}
</DashboardWidgetShell>
)
}
export function DashboardAgentActivityWidget({
actions,
loading,
onOpen,
}: {
actions: { id: string; agentName: string; result: string | null; createdAt: string }[]
loading: boolean
onOpen: () => void
}) {
const { t } = useLanguage()
return (
<DashboardWidgetShell
widgetId="agent-activity"
icon={<Bot size={12} className="text-brand-accent" />}
title={t('homeDashboard.widgets.agent-activity')}
compact
>
{loading ? (
<div className="space-y-2">
<div className="h-8 rounded-lg bg-stone-50 animate-pulse" />
<div className="h-8 rounded-lg bg-stone-50 animate-pulse" />
</div>
) : actions.length === 0 ? (
<p className="text-[10px] text-concrete italic py-2">{t('homeDashboard.widgetAgentActivityEmpty')}</p>
) : (
<div className="space-y-1.5">
{actions.slice(0, 3).map(a => (
<button
key={a.id}
type="button"
onClick={onOpen}
className="w-full p-2 rounded-xl border border-border/20 hover:border-brand-accent/25 text-start transition-all"
>
<p className="text-[10px] font-mono font-bold text-ink dark:text-dark-ink truncate">{a.agentName}</p>
<p className="text-[9px] text-concrete truncate mt-0.5">
{(a.result || t('homeDashboard.intelAgentNoResult')).slice(0, 80)}
</p>
</button>
))}
</div>
)}
</DashboardWidgetShell>
)
}
export function DashboardGmailWidget({
connected,
recentCaptures,
loading,
onOpen,
}: {
connected: boolean
recentCaptures: number
loading: boolean
onOpen: () => void
}) {
const { t } = useLanguage()
return (
<DashboardWidgetShell
widgetId="gmail"
icon={<Mail size={12} className="text-brand-accent" />}
title={t('homeDashboard.gmailCaptures')}
compact
>
{loading ? (
<div className="h-10 rounded-lg bg-stone-50 animate-pulse" />
) : connected ? (
<button
type="button"
onClick={onOpen}
className="w-full flex items-center justify-between gap-2 p-2.5 rounded-xl border border-border/20 hover:border-brand-accent/25 transition-all text-start"
>
<span className="text-[10px] text-ink dark:text-dark-ink">{t('homeDashboard.gmailRecent', { count: recentCaptures })}</span>
<span className="text-[8px] font-mono font-bold text-brand-accent bg-brand-accent/10 px-1.5 py-0.5 rounded shrink-0">Gmail</span>
</button>
) : (
<button
type="button"
onClick={onOpen}
className="w-full text-[10px] font-mono uppercase tracking-wider text-concrete hover:text-brand-accent transition-colors text-start py-2"
>
{t('homeDashboard.gmailConnect')}
</button>
)}
</DashboardWidgetShell>
)
}
export function DashboardPinnedWidget({
notes,
loading,
onSelect,
}: {
notes: { id: string; title: string | null; notebookId: string | null }[]
loading: boolean
onSelect: (id: string, notebookId: string | null) => void
}) {
const { t } = useLanguage()
return (
<DashboardWidgetShell
widgetId="pinned"
icon={<Pin size={12} className="text-brand-accent" />}
title={t('homeDashboard.widgets.pinned')}
compact
>
{loading ? (
<div className="space-y-2">
<div className="h-8 rounded-lg bg-stone-50 animate-pulse" />
</div>
) : notes.length === 0 ? (
<p className="text-[10px] text-concrete italic py-2">{t('homeDashboard.widgetPinnedEmpty')}</p>
) : (
<div className="space-y-1">
{notes.map(n => (
<button
key={n.id}
type="button"
onClick={() => onSelect(n.id, n.notebookId)}
className="w-full text-[10px] text-ink dark:text-dark-ink truncate text-start p-2 rounded-lg hover:bg-brand-accent/[0.04] transition-colors"
>
{n.title || t('homeDashboard.untitled')}
</button>
))}
</div>
)}
</DashboardWidgetShell>
)
}
export function DashboardActivityWidget({
data,
loading,
}: {
data: { date: string; count: number }[]
loading: boolean
}) {
const { t } = useLanguage()
const hasActivity = data.some(d => d.count > 0)
return (
<DashboardWidgetShell
widgetId="activity"
icon={<BarChart3 size={12} className="text-brand-accent" />}
title={t('homeDashboard.widgets.activity')}
>
{loading ? (
<div className="h-24 rounded-lg bg-stone-50 dark:bg-zinc-950/40 animate-pulse" />
) : !hasActivity ? (
<div className="rounded-xl border border-dashed border-border/35 bg-stone-50/50 dark:bg-zinc-950/30 p-4 text-center">
<p className="text-[10px] text-concrete leading-relaxed">{t('homeDashboard.activityEmptyHint')}</p>
</div>
) : (
<>
<RevisionHeatmap data={data} />
<p className="text-[9px] text-concrete mt-2">{t('homeDashboard.widgetActivityHint')}</p>
</>
)}
</DashboardWidgetShell>
)
}
export function DashboardUsageWidget() {
const { t } = useLanguage()
return (
<DashboardWidgetShell
widgetId="usage"
icon={<BarChart3 size={12} className="text-brand-accent" />}
title={t('homeDashboard.widgets.usage')}
compact
>
<UsageMeter className="px-0 py-0" />
<p className="text-[9px] text-concrete mt-2">{t('homeDashboard.widgetUsageHint')}</p>
</DashboardWidgetShell>
)
}
export function DashboardFlashcardsProgressWidget({
retentionRate,
streak,
totalCards,
dueCount = 0,
loading,
onOpen,
}: {
retentionRate: number
streak: number
totalCards: number
dueCount?: number
loading?: boolean
onOpen: () => void
}) {
const { t } = useLanguage()
const retention = Math.max(0, Math.min(100, retentionRate))
return (
<DashboardWidgetShell
widgetId="flashcards-progress"
icon={<GraduationCap size={12} className="text-brand-accent" />}
title={t('homeDashboard.widgets.flashcards-progress')}
compact
>
{loading ? (
<div className="space-y-2">
<div className="h-12 rounded-lg bg-stone-50 dark:bg-zinc-950/40 animate-pulse" />
<div className="h-8 rounded-lg bg-stone-50 dark:bg-zinc-950/40 animate-pulse" />
</div>
) : totalCards === 0 && dueCount === 0 ? (
<div className="rounded-xl border border-dashed border-border/35 bg-stone-50/50 dark:bg-zinc-950/30 p-3 text-center">
<p className="text-[10px] text-concrete leading-relaxed mb-2">
{t('homeDashboard.flashEmptyHint')}
</p>
<button
type="button"
onClick={onOpen}
className="text-[9px] font-mono font-bold uppercase text-brand-accent hover:underline"
>
{t('homeDashboard.flashEmptyCta')}
</button>
</div>
) : (
<button type="button" onClick={onOpen} className="w-full text-start group">
<div className="grid grid-cols-3 gap-2 mb-2.5">
<div className="text-center p-2 rounded-xl border border-border/20">
<p className="text-lg font-serif font-bold text-ink dark:text-dark-ink">{retention}%</p>
<p className="text-[8px] font-mono uppercase text-concrete">{t('homeDashboard.flashRetention')}</p>
</div>
<div className="text-center p-2 rounded-xl border border-border/20">
<p className="text-lg font-serif font-bold text-ink dark:text-dark-ink">{streak}</p>
<p className="text-[8px] font-mono uppercase text-concrete">{t('homeDashboard.flashStreak')}</p>
</div>
<div className="text-center p-2 rounded-xl border border-border/20">
<p className="text-lg font-serif font-bold text-ink dark:text-dark-ink">{totalCards || dueCount}</p>
<p className="text-[8px] font-mono uppercase text-concrete">{t('homeDashboard.flashTotal')}</p>
</div>
</div>
<div className="h-1.5 rounded-full bg-stone-100 dark:bg-zinc-800 overflow-hidden mb-2">
<div
className="h-full rounded-full bg-brand-accent transition-all"
style={{ width: `${retention}%` }}
/>
</div>
{dueCount > 0 ? (
<p className="text-[10px] font-medium text-brand-accent group-hover:underline">
{t('homeDashboard.flashDueCta', { count: dueCount })}
</p>
) : (
<p className="text-[9px] text-concrete group-hover:text-brand-accent transition-colors">
{t('homeDashboard.flashOpenCta')}
</p>
)}
</button>
)}
</DashboardWidgetShell>
)
}

View File

@@ -0,0 +1,139 @@
'use client'
import { motion } from 'motion/react'
import { Layers, Zap, ArrowUpRight } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import { DashboardWidgetTitleRow } from '@/components/dashboard-widget-title-row'
interface Cluster {
clusterId: number
name?: string
noteIds: string[]
}
interface BridgeNote {
noteId: string
bridgeScore: number
clusterNames?: string[]
note?: { id: string; title: string | null }
}
const CLUSTER_COLORS = ['#F87171', '#60A5FA', '#34D399', '#FBBF24', '#A78BFA', '#F472B6', '#2DD4BF']
export interface DashboardMindOrbitProps {
clusters: Cluster[]
bridgeNotes: BridgeNote[]
loading?: boolean
onOpenInsights: () => void
onNoteSelect: (id: string) => void
prefersReducedMotion?: boolean
}
export function DashboardMindOrbit({
clusters,
bridgeNotes,
loading,
onOpenInsights,
onNoteSelect,
prefersReducedMotion,
}: DashboardMindOrbitProps) {
const { t } = useLanguage()
const topClusters = [...clusters]
.sort((a, b) => b.noteIds.length - a.noteIds.length)
.slice(0, 5)
const maxCount = topClusters[0]?.noteIds.length || 1
const topBridge = bridgeNotes[0]
if (loading) {
return <div className="h-[180px] rounded-2xl bg-stone-50 dark:bg-zinc-950/30 animate-pulse" />
}
if (topClusters.length === 0) {
return (
<button
type="button"
onClick={onOpenInsights}
className="w-full h-[180px] rounded-2xl border border-dashed border-border/35 bg-white/50 dark:bg-zinc-900/50 flex flex-col items-center justify-center gap-2 p-6 text-center hover:border-brand-accent/30 transition-all"
>
<Layers size={22} className="text-concrete/35" />
<p className="text-xs text-concrete italic max-w-[220px]">{t('homeDashboard.mindMapEmpty')}</p>
<span className="text-[9px] font-mono uppercase font-bold text-brand-accent">{t('homeDashboard.mindMapOpen')}</span>
</button>
)
}
return (
<div className="rounded-2xl border border-border/30 bg-white dark:bg-zinc-900 p-4">
<DashboardWidgetTitleRow
widgetId="mind-map"
icon={<Layers size={12} className="text-brand-accent" />}
title={t('homeDashboard.mindMap')}
actions={(
<button
type="button"
onClick={onOpenInsights}
className="inline-flex items-center gap-0.5 text-[8px] font-mono uppercase font-bold text-brand-accent hover:underline"
>
{t('homeDashboard.fullMap')}
<ArrowUpRight size={10} />
</button>
)}
/>
<div className="flex flex-wrap items-center justify-center gap-3 min-h-[100px] py-2">
{topClusters.map((cluster, idx) => {
const color = CLUSTER_COLORS[cluster.clusterId % CLUSTER_COLORS.length]
const scale = 0.65 + (cluster.noteIds.length / maxCount) * 0.55
const size = Math.round(56 * scale)
const label = cluster.name || `${t('homeDashboard.theme')} ${cluster.clusterId + 1}`
return (
<motion.button
key={cluster.clusterId}
type="button"
whileHover={prefersReducedMotion ? undefined : { scale: 1.06 }}
whileTap={prefersReducedMotion ? undefined : { scale: 0.97 }}
onClick={onOpenInsights}
className="relative flex flex-col items-center gap-1.5 group"
style={{ width: size + 16 }}
>
<div
className="rounded-full border-2 flex items-center justify-center font-mono font-bold text-white shadow-sm group-hover:shadow-md transition-shadow"
style={{
width: size,
height: size,
backgroundColor: `${color}cc`,
borderColor: `${color}40`,
fontSize: Math.max(9, size * 0.22),
}}
>
{cluster.noteIds.length}
</div>
<span className="text-[8px] font-medium text-ink/80 dark:text-dark-ink/80 text-center line-clamp-2 leading-tight max-w-[72px] group-hover:text-brand-accent transition-colors">
{label}
</span>
</motion.button>
)
})}
</div>
{topBridge?.note && (
<button
type="button"
onClick={() => onNoteSelect(topBridge.noteId)}
className="w-full mt-2 p-2.5 rounded-xl border border-brand-accent/20 bg-brand-accent/[0.04] hover:bg-brand-accent/[0.08] transition-all text-start flex items-center gap-2 group"
>
<Zap size={11} className="text-brand-accent shrink-0" />
<div className="min-w-0 flex-1">
<p className="text-[10px] font-semibold text-ink dark:text-dark-ink truncate group-hover:text-brand-accent transition-colors">
{topBridge.note.title || t('homeDashboard.untitled')}
</p>
<p className="text-[8px] font-mono uppercase text-concrete">{t('homeDashboard.bridgeNote')}</p>
</div>
<span className="text-[8px] font-mono font-bold text-brand-accent bg-brand-accent/10 px-1.5 py-0.5 rounded-full shrink-0">
{Math.round(topBridge.bridgeScore * 100)}%
</span>
</button>
)}
</div>
)
}

View File

@@ -0,0 +1,165 @@
'use client'
import { motion } from 'motion/react'
import { Loader2 } from 'lucide-react'
import {
ArrowRight, GitBranch, Lightbulb, Link2, BookOpen, Inbox,
GraduationCap, Compass, Plus, Sparkles, PenLine,
} from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import { DashboardWidgetTitleRow } from '@/components/dashboard-widget-title-row'
import type { DashboardPath, DashboardPathType } from '@/lib/dashboard/path-types'
const TYPE_META: Record<DashboardPathType, { Icon: LucideIcon; accent: string }> = {
continue: { Icon: PenLine, accent: 'text-ink' },
connect: { Icon: Link2, accent: 'text-brand-accent' },
'add-link': { Icon: Plus, accent: 'text-emerald-600' },
bridge: { Icon: GitBranch, accent: 'text-violet-600' },
research: { Icon: Sparkles, accent: 'text-sky-600' },
explore: { Icon: Compass, accent: 'text-amber-600' },
organize: { Icon: Inbox, accent: 'text-brand-accent' },
review: { Icon: GraduationCap, accent: 'text-brand-accent' },
resurface: { Icon: Lightbulb, accent: 'text-brand-accent' },
daily: { Icon: BookOpen, accent: 'text-concrete' },
}
export interface DashboardNextPathsProps {
paths: DashboardPath[]
loading?: boolean
enriching?: boolean
focusNoteTitle?: string | null
onAction: (path: DashboardPath) => void
prefersReducedMotion?: boolean
}
export function DashboardNextPaths({
paths,
loading,
enriching,
focusNoteTitle,
onAction,
prefersReducedMotion,
}: DashboardNextPathsProps) {
const { t } = useLanguage()
if (loading) {
return (
<div className="rounded-2xl border border-brand-accent/20 bg-gradient-to-br from-white via-white to-brand-accent/[0.04] dark:from-zinc-900 dark:via-zinc-900 dark:to-brand-accent/[0.06] shadow-sm overflow-hidden">
<div className="px-5 pt-4 pb-3 border-b border-border/15">
<DashboardWidgetTitleRow
widgetId="next-paths"
title={t('homeDashboard.pathsTitle')}
className="mb-0"
/>
{focusNoteTitle && (
<p className="text-[11px] text-concrete mt-1">
{t('homeDashboard.pathsFromNote', { title: focusNoteTitle })}
</p>
)}
</div>
<div className="flex flex-col items-center justify-center gap-3 px-5 py-10">
<Loader2 size={22} className="text-brand-accent animate-spin" strokeWidth={2} />
<p className="text-[11px] text-concrete text-center leading-relaxed max-w-[240px]">
{t('homeDashboard.pathsLoading')}
</p>
</div>
</div>
)
}
if (paths.length === 0) {
return (
<div className="rounded-2xl border border-border/25 bg-white/60 dark:bg-zinc-900/60 p-5">
<DashboardWidgetTitleRow
widgetId="next-paths"
title={t('homeDashboard.pathsTitle')}
className="mb-2"
/>
<p className="text-[10px] text-concrete italic">{t('homeDashboard.pathsEmpty')}</p>
</div>
)
}
const hero = paths[0]
const rest = paths.slice(1, 5)
const HeroIcon = TYPE_META[hero.type].Icon
return (
<div className="rounded-2xl border border-brand-accent/20 bg-gradient-to-br from-white via-white to-brand-accent/[0.04] dark:from-zinc-900 dark:via-zinc-900 dark:to-brand-accent/[0.06] shadow-sm overflow-hidden">
<div className="px-5 pt-4 pb-3 border-b border-border/15">
<DashboardWidgetTitleRow
widgetId="next-paths"
title={t('homeDashboard.pathsTitle')}
className="mb-0"
actions={enriching ? (
<span className="inline-flex items-center gap-1.5 text-[8px] font-mono uppercase text-concrete">
<Loader2 size={10} className="text-brand-accent animate-spin" />
{t('homeDashboard.pathsEnriching')}
</span>
) : undefined}
/>
{focusNoteTitle && (
<p className="text-[11px] text-concrete mt-1">
{t('homeDashboard.pathsFromNote', { title: focusNoteTitle })}
</p>
)}
</div>
<motion.button
type="button"
onClick={() => onAction(hero)}
whileHover={prefersReducedMotion ? undefined : { y: -1 }}
className="w-full text-start px-5 py-4 hover:bg-brand-accent/[0.03] transition-colors border-b border-border/10"
>
<div className="flex items-start gap-3">
<div className={`w-9 h-9 rounded-xl bg-brand-accent/10 flex items-center justify-center shrink-0 ${TYPE_META[hero.type].accent}`}>
<HeroIcon size={16} strokeWidth={1.75} />
</div>
<div className="min-w-0 flex-1">
<p className="text-[8px] font-mono font-bold uppercase tracking-wider text-concrete mb-0.5">
{t(`homeDashboard.pathTypes.${hero.type}`)}
{hero.score ? ` · ${hero.score}%` : ''}
</p>
<p className="text-sm font-serif font-semibold text-ink dark:text-dark-ink leading-snug">
{hero.title}
</p>
<p className="text-[11px] text-concrete leading-relaxed mt-1 line-clamp-2">
{hero.description}
</p>
</div>
<span className="shrink-0 inline-flex items-center gap-1 text-[9px] font-mono font-bold uppercase text-brand-accent mt-1">
{t(`homeDashboard.pathActions.${hero.actionKey}`)}
<ArrowRight size={10} />
</span>
</div>
</motion.button>
{rest.length > 0 && (
<div className="divide-y divide-border/10">
{rest.map(path => {
const meta = TYPE_META[path.type]
const Icon = meta.Icon
return (
<button
key={path.id}
type="button"
onClick={() => onAction(path)}
className="w-full flex items-center gap-3 px-5 py-3 text-start hover:bg-stone-50/80 dark:hover:bg-zinc-950/40 transition-colors"
>
<Icon size={13} className={`shrink-0 ${meta.accent}`} />
<div className="min-w-0 flex-1">
<p className="text-[11px] font-medium text-ink dark:text-dark-ink truncate">{path.title}</p>
<p className="text-[9px] text-concrete truncate">{path.description}</p>
</div>
<span className="text-[8px] font-mono uppercase text-brand-accent shrink-0">
{t(`homeDashboard.pathActions.${path.actionKey}`)}
</span>
</button>
)
})}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,258 @@
'use client'
import { CheckCircle2, Circle } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import { DashboardWidgetShell } from '@/components/dashboard-catalog-widgets'
export interface DailyReviewItem {
key: string
label: string
done: boolean
count?: number
onClick: () => void
}
export function DashboardDailyReview({
items,
loading,
}: {
items: DailyReviewItem[]
loading?: boolean
}) {
const { t } = useLanguage()
return (
<DashboardWidgetShell
widgetId="daily-review"
icon={<CheckCircle2 size={12} className="text-brand-accent" />}
title={t('homeDashboard.widgets.daily-review')}
compact
>
{loading ? (
<div className="space-y-2">
{[0, 1, 2].map(i => <div key={i} className="h-8 rounded-lg bg-stone-50 animate-pulse" />)}
</div>
) : (
<ul className="space-y-1.5">
{items.map(item => (
<li key={item.key}>
<button
type="button"
onClick={item.onClick}
className="w-full flex items-center gap-2.5 p-2 rounded-xl hover:bg-brand-accent/[0.04] transition-colors text-start"
>
{item.done ? (
<CheckCircle2 size={14} className="text-brand-accent shrink-0" />
) : (
<Circle size={14} className="text-concrete/50 shrink-0" />
)}
<span className={`text-[10px] flex-1 ${item.done ? 'text-concrete line-through' : 'text-ink dark:text-dark-ink'}`}>
{item.label}
{item.count !== undefined && item.count > 0 ? ` (${item.count})` : ''}
</span>
</button>
</li>
))}
</ul>
)}
<p className="text-[9px] text-concrete mt-2 leading-relaxed">{t('homeDashboard.dailyReviewHint')}</p>
</DashboardWidgetShell>
)
}
export function DashboardOpenLoops({
loops,
loading,
onSelect,
}: {
loops: Array<{ id: string; title: string | null; notebookId: string | null; daysStale: number }>
loading?: boolean
onSelect: (id: string, notebookId: string | null) => void
}) {
const { t } = useLanguage()
return (
<DashboardWidgetShell
widgetId="open-loops"
icon={<Circle size={12} className="text-brand-accent" />}
title={t('homeDashboard.widgets.open-loops')}
compact
>
{loading ? (
<div className="h-16 rounded-lg bg-stone-50 animate-pulse" />
) : loops.length === 0 ? (
<p className="text-[10px] text-concrete italic py-2">{t('homeDashboard.openLoopsEmpty')}</p>
) : (
<div className="space-y-1">
{loops.map(loop => (
<button
key={loop.id}
type="button"
onClick={() => onSelect(loop.id, loop.notebookId)}
className="w-full flex items-center justify-between gap-2 p-2 rounded-lg hover:bg-brand-accent/[0.04] text-start transition-colors"
>
<span className="text-[10px] text-ink dark:text-dark-ink truncate flex-1">
{loop.title || t('homeDashboard.untitled')}
</span>
<span className="text-[8px] font-mono text-concrete shrink-0">
{t('homeDashboard.openLoopsStale', { days: loop.daysStale })}
</span>
</button>
))}
</div>
)}
</DashboardWidgetShell>
)
}
export function DashboardDailyNoteWidget({
loading,
onOpen,
}: {
loading?: boolean
onOpen: () => void
}) {
const { t } = useLanguage()
return (
<DashboardWidgetShell
widgetId="daily-note"
icon={<CheckCircle2 size={12} className="text-brand-accent" />}
title={t('homeDashboard.widgets.daily-note')}
compact
>
{loading ? (
<div className="h-10 rounded-lg bg-stone-50 animate-pulse" />
) : (
<button
type="button"
onClick={onOpen}
className="w-full p-3 rounded-xl border border-border/20 hover:border-brand-accent/30 text-start transition-all"
>
<p className="text-[11px] font-serif font-semibold text-ink dark:text-dark-ink">
{new Date().toLocaleDateString(undefined, { weekday: 'long', day: 'numeric', month: 'long' })}
</p>
<p className="text-[9px] font-mono uppercase text-brand-accent mt-1">{t('homeDashboard.dailyNoteOpen')} </p>
</button>
)}
</DashboardWidgetShell>
)
}
export function DashboardLinkSuggestions({
paths,
loading,
onAction,
}: {
paths: Array<{ id: string; title: string; description: string; score?: number }>
loading?: boolean
onAction: (id: string) => void
}) {
const { t } = useLanguage()
return (
<DashboardWidgetShell
widgetId="link-suggestions"
icon={<Circle size={12} className="text-emerald-600" />}
title={t('homeDashboard.widgets.link-suggestions')}
>
{loading ? (
<div className="space-y-2">
<div className="h-12 rounded-lg bg-stone-50 animate-pulse" />
</div>
) : paths.length === 0 ? (
<p className="text-[10px] text-concrete italic">{t('homeDashboard.linkSuggestionsEmpty')}</p>
) : (
<div className="space-y-2">
{paths.map(p => (
<button
key={p.id}
type="button"
onClick={() => onAction(p.id)}
className="w-full p-3 rounded-xl border border-border/20 hover:border-emerald-500/30 text-start transition-all"
>
<div className="flex items-center justify-between gap-2 mb-1">
<p className="text-[11px] font-mono font-bold text-ink dark:text-dark-ink truncate">{p.title}</p>
{p.score ? (
<span className="text-[8px] font-mono text-emerald-600 shrink-0">{p.score}%</span>
) : null}
</div>
<p className="text-[10px] text-concrete line-clamp-2">{p.description}</p>
<p className="text-[8px] font-mono uppercase text-emerald-600 mt-2">{t('homeDashboard.pathActions.addLink')} </p>
</button>
))}
</div>
)}
</DashboardWidgetShell>
)
}
export function DashboardBridgesWidget({
suggestions,
loading,
actingKey,
onCreate,
onDismiss,
}: {
suggestions: Array<{
clusterAId: number
clusterBId: number
clusterAName: string
clusterBName: string
suggestedTitle: string
justification: string
}>
loading?: boolean
actingKey?: string | null
onCreate: (s: { clusterAId: number; clusterBId: number; clusterAName: string; clusterBName: string; suggestedTitle: string; suggestedContent: string; justification: string }) => void
onDismiss: (s: { clusterAId: number; clusterBId: number }) => void
}) {
const { t } = useLanguage()
return (
<DashboardWidgetShell
widgetId="bridges"
icon={<Circle size={12} className="text-violet-600" />}
title={t('homeDashboard.widgets.bridges')}
>
{loading ? (
<div className="h-20 rounded-lg bg-stone-50 animate-pulse" />
) : suggestions.length === 0 ? (
<p className="text-[10px] text-concrete italic">{t('homeDashboard.bridgesEmpty')}</p>
) : (
<div className="space-y-2">
{suggestions.slice(0, 3).map(s => {
const key = `${s.clusterAId}-${s.clusterBId}`
return (
<div key={key} className="p-3 rounded-xl border border-border/20 bg-stone-50/40 dark:bg-zinc-950/30">
<p className="text-[10px] font-mono font-bold text-ink dark:text-dark-ink">{s.suggestedTitle}</p>
<p className="text-[9px] text-concrete mt-1">
{t('homeDashboard.suggestedBridge', { clusterA: s.clusterAName, clusterB: s.clusterBName })}
</p>
<p className="text-[10px] text-concrete mt-1 line-clamp-2">{s.justification}</p>
<div className="flex gap-2 mt-2">
<button
type="button"
disabled={actingKey === key}
onClick={() => onCreate({ ...s, suggestedContent: s.justification })}
className="text-[8px] font-mono uppercase font-bold text-brand-accent hover:underline disabled:opacity-50"
>
{t('homeDashboard.createBridgeNote')}
</button>
<button
type="button"
disabled={actingKey === key}
onClick={() => onDismiss(s)}
className="text-[8px] font-mono uppercase text-concrete hover:text-rose-500 disabled:opacity-50"
>
{t('homeDashboard.dismissConnection')}
</button>
</div>
</div>
)
})}
</div>
)}
</DashboardWidgetShell>
)
}

View File

@@ -0,0 +1,154 @@
'use client'
import { motion } from 'motion/react'
import { Clock, ChevronRight, Play, PenLine } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import { DashboardWidgetTitleRow } from '@/components/dashboard-widget-title-row'
export interface ResumeNote {
id: string
title: string | null
excerpt: string
notebookName: string
notebookColor: string
updatedAt: string
notebookId: string | null
}
export interface DashboardResumeHeroProps {
notes: ResumeNote[]
loading?: boolean
onSelect: (id: string, notebookId: string | null) => void
onCaptureFocus?: () => void
formatRelativeTime: (date: string) => string
prefersReducedMotion?: boolean
}
export function DashboardResumeHero({
notes,
loading,
onSelect,
onCaptureFocus,
formatRelativeTime,
prefersReducedMotion,
}: DashboardResumeHeroProps) {
const { t } = useLanguage()
const hero = notes[0]
const rest = notes.slice(1, 6)
if (loading) {
return (
<div className="rounded-2xl border border-border/30 bg-white dark:bg-zinc-900 p-5 min-h-[220px] animate-pulse">
<div className="h-4 w-32 bg-stone-100 dark:bg-zinc-800 rounded mb-4" />
<div className="h-6 w-3/4 bg-stone-100 dark:bg-zinc-800 rounded mb-3" />
<div className="h-16 bg-stone-50 dark:bg-zinc-950 rounded-xl mb-3" />
<div className="space-y-2">
<div className="h-10 bg-stone-50 dark:bg-zinc-950 rounded-xl" />
<div className="h-10 bg-stone-50 dark:bg-zinc-950 rounded-xl" />
</div>
</div>
)
}
return (
<div className="rounded-2xl border border-border/30 bg-white dark:bg-zinc-900 overflow-hidden">
<div className="px-5 pt-4">
<DashboardWidgetTitleRow
widgetId="resume"
icon={<Play size={11} className="text-brand-accent" fill="currentColor" />}
title={t('homeDashboard.continue')}
className="mb-2"
/>
</div>
{!hero ? (
<div className="px-5 pb-5">
<div className="rounded-xl border border-dashed border-border/40 bg-stone-50/60 dark:bg-zinc-950/40 p-5 text-center">
<Clock size={22} className="mx-auto text-concrete/35 mb-2" strokeWidth={1.25} />
<p className="text-xs text-concrete mb-3 leading-relaxed">
{t('homeDashboard.resumeEmptyHint')}
</p>
{onCaptureFocus && (
<button
type="button"
onClick={onCaptureFocus}
className="inline-flex items-center gap-1.5 text-[9px] font-mono font-bold uppercase text-brand-accent hover:underline"
>
<PenLine size={11} />
{t('homeDashboard.resumeEmptyCta')}
</button>
)}
</div>
</div>
) : (
<>
<motion.button
type="button"
whileHover={prefersReducedMotion ? undefined : { y: -1 }}
onClick={() => onSelect(hero.id, hero.notebookId)}
className="w-full text-start px-5 pb-3 group cursor-pointer"
>
<div className="p-4 rounded-xl border border-border/25 bg-gradient-to-br from-stone-50/80 to-white dark:from-zinc-950/50 dark:to-zinc-900/80 group-hover:border-brand-accent/35 group-hover:shadow-md transition-all">
<div className="flex items-start justify-between gap-3 mb-2">
<span
className="text-[8px] font-mono font-bold uppercase px-2 py-0.5 rounded text-white shrink-0"
style={{ backgroundColor: hero.notebookColor }}
>
{hero.notebookName}
</span>
<span className="text-[9px] font-mono text-concrete/70 shrink-0">
{formatRelativeTime(hero.updatedAt)}
</span>
</div>
<h3 className="text-base sm:text-lg font-serif font-semibold text-ink dark:text-dark-ink group-hover:text-brand-accent transition-colors leading-snug mb-2 line-clamp-2">
{hero.title || t('homeDashboard.untitled')}
</h3>
{hero.excerpt ? (
<p className="text-[11px] text-concrete leading-relaxed line-clamp-3">
{hero.excerpt}
</p>
) : null}
<div className="flex items-center gap-1 mt-3 text-[9px] font-mono font-bold uppercase text-brand-accent opacity-80 group-hover:opacity-100 transition-opacity">
{t('homeDashboard.resumeOpen')}
<ChevronRight size={12} />
</div>
</div>
</motion.button>
{rest.length > 0 && (
<div className="px-5 pb-4 space-y-1.5">
<p className="text-[8px] font-mono font-bold uppercase tracking-wider text-concrete/70 mb-1">
{t('homeDashboard.resumeAlso')}
</p>
{rest.map(note => (
<button
key={note.id}
type="button"
onClick={() => onSelect(note.id, note.notebookId)}
className="w-full flex items-center gap-3 p-2.5 rounded-xl border border-border/20 bg-stone-50/40 dark:bg-zinc-950/30 hover:border-brand-accent/30 hover:bg-brand-accent/[0.03] transition-all text-start group"
>
<span
className="w-1.5 h-8 rounded-full shrink-0"
style={{ backgroundColor: note.notebookColor }}
aria-hidden
/>
<div className="min-w-0 flex-1">
<p className="text-[11px] font-semibold text-ink dark:text-dark-ink truncate group-hover:text-brand-accent transition-colors">
{note.title || t('homeDashboard.untitled')}
</p>
<p className="text-[9px] text-concrete truncate mt-0.5">
{note.notebookName}
{' · '}
{formatRelativeTime(note.updatedAt)}
</p>
</div>
<ChevronRight size={12} className="text-concrete/40 group-hover:text-brand-accent shrink-0" />
</button>
))}
</div>
)}
</>
)}
</div>
)
}

View File

@@ -0,0 +1,113 @@
'use client'
import { Heart } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import { DashboardWidgetTitleRow } from '@/components/dashboard-widget-title-row'
interface EmotionMeta {
Icon: LucideIcon
color: string
}
export interface DashboardSentimentChipProps {
available: boolean
loading?: boolean
dominantEmotion?: string
summary?: string
emotions?: Record<string, number>
emotionMeta: Record<string, EmotionMeta>
}
export function DashboardSentimentChip({
available,
loading,
dominantEmotion,
summary,
emotions,
emotionMeta,
}: DashboardSentimentChipProps) {
const { t } = useLanguage()
if (loading) {
return (
<div className="rounded-2xl border border-border/30 bg-white dark:bg-zinc-900 p-4">
<div className="h-24 rounded-xl bg-stone-50 dark:bg-zinc-950/30 animate-pulse" />
</div>
)
}
return (
<div className="rounded-2xl border border-border/30 bg-white dark:bg-zinc-900 p-4 min-h-[140px]">
<DashboardWidgetTitleRow
widgetId="sentiment"
icon={<Heart size={12} className="text-brand-accent" />}
title={t('homeDashboard.sentiment')}
/>
{!available || !dominantEmotion ? (
<p className="text-[11px] text-concrete italic leading-relaxed">
{t('homeDashboard.notEnoughNotes')}
</p>
) : (
<div className="space-y-3">
<div className="flex items-center gap-3">
{(() => {
const meta = emotionMeta[dominantEmotion] || emotionMeta.reflective
const Icon = meta?.Icon
return (
<div
className="w-10 h-10 rounded-xl flex items-center justify-center shrink-0"
style={{ backgroundColor: `${meta.color}18`, color: meta.color }}
>
{Icon && <Icon size={18} strokeWidth={1.75} />}
</div>
)
})()}
<div className="min-w-0">
<p className="text-lg font-serif font-semibold text-ink dark:text-dark-ink">
{t(`homeDashboard.emotions.${dominantEmotion}`)}
</p>
<p className="text-[9px] font-mono uppercase tracking-wider text-concrete">
{t('homeDashboard.sentimentDominant')}
</p>
</div>
</div>
{summary && (
<p className="text-[11px] text-concrete leading-relaxed border-s-2 border-brand-accent/30 ps-3">
{summary}
</p>
)}
{emotions && (
<div className="space-y-2 pt-1">
{Object.entries(emotions)
.sort(([, a], [, b]) => b - a)
.slice(0, 4)
.map(([key, val]) => {
const em = emotionMeta[key]
return (
<div key={key} className="flex items-center gap-2">
<span className="text-[8px] font-mono uppercase text-concrete w-16 truncate">
{t(`homeDashboard.emotions.${key}`)}
</span>
<div className="flex-1 h-1.5 rounded-full bg-stone-100 dark:bg-zinc-800 overflow-hidden">
<div
className="h-full rounded-full"
style={{
width: `${Math.min(val * 100, 100)}%`,
backgroundColor: em?.color || 'var(--color-brand-accent)',
}}
/>
</div>
</div>
)
})}
</div>
)}
</div>
)}
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,415 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import {
DndContext,
closestCenter,
PointerSensor,
TouchSensor,
useSensor,
useSensors,
type DragEndEvent,
} from '@dnd-kit/core'
import {
SortableContext,
rectSortingStrategy,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { GripVertical, X, Plus, RotateCcw, Check, LayoutGrid, Eye, EyeOff } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import {
getDefaultDashboardLayout,
resetDashboardLayout,
DASHBOARD_CATEGORY_ORDER,
DASHBOARD_WIDGET_META,
catalogByCategory,
hiddenWidgetIds,
isWidgetVisible,
normalizeDashboardLayout,
reorderWidgets,
setWidgetVisibility,
visibleWidgetsInZone,
type DashboardLayout,
type DashboardWidgetId,
type DashboardWidgetPlacement,
type DashboardWidgetZone,
} from '@/lib/dashboard/layout'
import { toast } from 'sonner'
interface DashboardWidgetGridProps {
renderWidget: (id: DashboardWidgetId) => React.ReactNode
}
function SortableWidget({
placement,
editMode,
onHide,
children,
}: {
placement: DashboardWidgetPlacement
editMode: boolean
onHide: (id: DashboardWidgetId) => void
children: React.ReactNode
}) {
const { t } = useLanguage()
const meta = DASHBOARD_WIDGET_META[placement.id]
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: placement.id, disabled: !editMode })
const style = {
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? 20 : undefined,
}
return (
<div
ref={setNodeRef}
style={style}
className={`relative w-full ${
isDragging ? 'opacity-90 scale-[1.01]' : ''
} ${editMode ? 'ring-2 ring-brand-accent/25 ring-offset-2 ring-offset-[#F9F8F6] dark:ring-offset-[#0D0D0D] rounded-2xl' : ''}`}
>
{editMode && (
<div className="absolute top-2 end-2 z-20 flex items-center gap-1">
<button
type="button"
className="p-1.5 rounded-lg bg-white/95 dark:bg-zinc-900/95 border border-border/40 shadow-sm cursor-grab active:cursor-grabbing text-concrete hover:text-ink"
aria-label={t('homeDashboard.widgetDrag')}
{...attributes}
{...listeners}
>
<GripVertical size={12} />
</button>
{!meta.required && (
<button
type="button"
onClick={() => onHide(placement.id)}
className="p-1.5 rounded-lg bg-white/95 dark:bg-zinc-900/95 border border-border/40 shadow-sm text-concrete hover:text-rose-500"
aria-label={t('homeDashboard.widgetHide')}
>
<X size={12} />
</button>
)}
</div>
)}
{children}
</div>
)
}
function ZoneColumn({
zone,
placements,
editMode,
onHide,
renderWidget,
}: {
zone: DashboardWidgetZone
placements: DashboardWidgetPlacement[]
editMode: boolean
onHide: (id: DashboardWidgetId) => void
renderWidget: (id: DashboardWidgetId) => React.ReactNode
}) {
if (placements.length === 0) return null
return (
<SortableContext items={placements.map(w => w.id)} strategy={verticalListSortingStrategy}>
<div className="flex flex-col gap-4">
{placements.map(placement => (
<SortableWidget
key={placement.id}
placement={placement}
editMode={editMode}
onHide={onHide}
>
{renderWidget(placement.id)}
</SortableWidget>
))}
</div>
</SortableContext>
)
}
export function DashboardWidgetGrid({ renderWidget }: DashboardWidgetGridProps) {
const { t } = useLanguage()
const [layout, setLayout] = useState<DashboardLayout>(() => getDefaultDashboardLayout())
const [editMode, setEditMode] = useState(false)
const [catalogOpen, setCatalogOpen] = useState(false)
const [loaded, setLoaded] = useState(false)
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
fetch('/api/dashboard/layout', { cache: 'no-store' })
.then(r => r.ok ? r.json() : null)
.then(json => {
if (json?.layout) {
const normalized = normalizeDashboardLayout(json.layout)
setLayout(normalized)
const incomingVersion = typeof (json.layout as { version?: number })?.version === 'number'
? (json.layout as { version: number }).version
: 0
if (incomingVersion < normalized.version) {
fetch('/api/dashboard/layout', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ layout: normalized }),
}).catch(() => {})
}
}
})
.catch(() => {})
.finally(() => setLoaded(true))
}, [])
const persistLayout = useCallback((next: DashboardLayout, immediate = false) => {
if (saveTimer.current) clearTimeout(saveTimer.current)
const save = () => {
fetch('/api/dashboard/layout', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ layout: next }),
}).catch(() => {})
}
if (immediate) save()
else saveTimer.current = setTimeout(save, 600)
}, [])
const applyLayout = useCallback((next: DashboardLayout, immediate = false) => {
const normalized = normalizeDashboardLayout(next)
setLayout(normalized)
persistLayout(normalized, immediate)
}, [persistLayout])
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
useSensor(TouchSensor, { activationConstraint: { delay: 180, tolerance: 8 } }),
)
const handleDragEnd = useCallback((event: DragEndEvent) => {
const { active, over } = event
if (!over || active.id === over.id) return
applyLayout(reorderWidgets(layout, active.id as DashboardWidgetId, over.id as DashboardWidgetId))
}, [applyLayout, layout])
const handleHide = useCallback((id: DashboardWidgetId) => {
applyLayout(setWidgetVisibility(layout, id, false))
}, [applyLayout, layout])
const handleToggle = useCallback((id: DashboardWidgetId) => {
const visible = isWidgetVisible(layout, id)
if (visible) {
applyLayout(setWidgetVisibility(layout, id, false))
} else {
const maxOrder = Math.max(...layout.widgets.map(w => w.order), 0)
applyLayout({
...layout,
widgets: layout.widgets.map(w =>
w.id === id ? { ...w, visible: true, order: maxOrder + 1 } : w,
),
})
}
}, [applyLayout, layout])
const handleReset = useCallback(() => {
const fresh = resetDashboardLayout()
setLayout(fresh)
persistLayout(fresh, true)
setCatalogOpen(false)
toast.success(t('homeDashboard.widgetResetDone'))
}, [persistLayout, t])
const fullWidgets = visibleWidgetsInZone(layout, 'full')
const mainWidgets = visibleWidgetsInZone(layout, 'main')
const sideWidgets = visibleWidgetsInZone(layout, 'side')
const hidden = hiddenWidgetIds(layout)
const catalog = catalogByCategory(layout)
const hasVisibleWidgets = fullWidgets.length + mainWidgets.length + sideWidgets.length > 0
return (
<>
<div className="space-y-4">
{loaded ? (
hasVisibleWidgets ? (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
{fullWidgets.length > 0 && (
<SortableContext items={fullWidgets.map(w => w.id)} strategy={rectSortingStrategy}>
<div className="flex flex-col gap-4">
{fullWidgets.map(placement => (
<SortableWidget
key={placement.id}
placement={placement}
editMode={editMode}
onHide={handleHide}
>
{renderWidget(placement.id)}
</SortableWidget>
))}
</div>
</SortableContext>
)}
{(mainWidgets.length > 0 || sideWidgets.length > 0) && (
<div className="grid grid-cols-12 gap-4 items-start">
{mainWidgets.length > 0 && (
<div className="col-span-12 lg:col-span-8">
<ZoneColumn
zone="main"
placements={mainWidgets}
editMode={editMode}
onHide={handleHide}
renderWidget={renderWidget}
/>
</div>
)}
{sideWidgets.length > 0 && (
<div className="col-span-12 lg:col-span-4">
<ZoneColumn
zone="side"
placements={sideWidgets}
editMode={editMode}
onHide={handleHide}
renderWidget={renderWidget}
/>
</div>
)}
</div>
)}
</DndContext>
) : (
<div className="rounded-2xl border border-dashed border-border/40 bg-white/60 dark:bg-zinc-900/60 p-8 text-center">
<p className="text-sm font-serif text-ink dark:text-dark-ink mb-2">{t('homeDashboard.layoutEmptyTitle')}</p>
<p className="text-[11px] text-concrete mb-4 max-w-md mx-auto">{t('homeDashboard.layoutEmptyHint')}</p>
<button
type="button"
onClick={handleReset}
className="inline-flex items-center gap-2 px-4 py-2 rounded-xl bg-brand-accent text-white text-[10px] font-mono uppercase font-bold hover:bg-brand-accent/90 transition-colors"
>
<RotateCcw size={12} />
{t('homeDashboard.widgetReset')}
</button>
</div>
)
) : (
<div className="grid grid-cols-12 gap-5">
<div className="col-span-12 h-20 rounded-2xl bg-stone-50 dark:bg-zinc-950/30 animate-pulse" />
<div className="col-span-12 lg:col-span-8 h-48 rounded-2xl bg-stone-50 dark:bg-zinc-950/30 animate-pulse" />
<div className="col-span-12 lg:col-span-4 h-48 rounded-2xl bg-stone-50 dark:bg-zinc-950/30 animate-pulse" />
</div>
)}
</div>
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-40 flex items-center gap-2 px-3 py-2 rounded-2xl bg-ink/92 dark:bg-zinc-900/95 text-white shadow-xl border border-white/10 backdrop-blur-md">
{editMode ? (
<>
<button
type="button"
onClick={() => setCatalogOpen(v => !v)}
className="inline-flex items-center gap-1.5 text-[10px] font-mono uppercase font-bold px-3 py-2 rounded-xl bg-white/10 hover:bg-white/15 transition-colors"
>
<Plus size={12} />
{t('homeDashboard.widgetCatalog')}
{hidden.length > 0 && (
<span className="px-1.5 py-0.5 rounded-md bg-brand-accent/90 text-[9px]">{hidden.length}</span>
)}
</button>
<button
type="button"
onClick={handleReset}
className="inline-flex items-center gap-1.5 text-[10px] font-mono uppercase font-bold px-3 py-2 rounded-xl bg-white/10 hover:bg-white/15 transition-colors"
>
<RotateCcw size={12} />
{t('homeDashboard.widgetReset')}
</button>
<button
type="button"
onClick={() => { setEditMode(false); setCatalogOpen(false) }}
className="inline-flex items-center gap-1.5 text-[10px] font-mono uppercase font-bold px-3 py-2 rounded-xl bg-brand-accent text-white hover:bg-brand-accent/90 transition-colors"
>
<Check size={12} />
{t('homeDashboard.widgetDone')}
</button>
</>
) : (
<button
type="button"
onClick={() => setEditMode(true)}
className="inline-flex items-center gap-1.5 text-[10px] font-mono uppercase font-bold px-4 py-2 rounded-xl bg-white/10 hover:bg-white/15 transition-colors"
>
<LayoutGrid size={12} />
{t('homeDashboard.widgetCustomize')}
</button>
)}
</div>
{editMode && catalogOpen && (
<div className="fixed bottom-20 left-1/2 -translate-x-1/2 z-40 w-[min(520px,calc(100vw-2rem))] max-h-[min(60vh,480px)] overflow-y-auto custom-scrollbar p-4 rounded-2xl bg-white dark:bg-zinc-900 border border-border/40 shadow-2xl">
<p className="text-[9px] font-mono font-bold uppercase tracking-wider text-concrete mb-3">
{t('homeDashboard.widgetCatalogTitle')}
</p>
<div className="space-y-4">
{DASHBOARD_CATEGORY_ORDER.map(category => {
const ids = catalog[category]
if (ids.length === 0) return null
return (
<div key={category}>
<p className="text-[8px] font-mono font-bold uppercase tracking-[0.15em] text-brand-accent mb-2">
{t(`homeDashboard.widgetCategories.${category}`)}
</p>
<div className="space-y-1.5">
{ids.map(id => {
const meta = DASHBOARD_WIDGET_META[id]
const active = isWidgetVisible(layout, id)
return (
<button
key={id}
type="button"
onClick={() => !meta.required && handleToggle(id)}
disabled={meta.required}
className={`w-full flex items-start gap-3 p-2.5 rounded-xl border text-start transition-colors ${
active
? 'border-brand-accent/30 bg-brand-accent/[0.04]'
: 'border-border/25 bg-stone-50/80 dark:bg-zinc-950/40 hover:border-brand-accent/25'
} ${meta.required ? 'opacity-80 cursor-default' : 'cursor-pointer'}`}
>
<div className={`mt-0.5 shrink-0 w-6 h-6 rounded-lg flex items-center justify-center ${
active ? 'bg-brand-accent/15 text-brand-accent' : 'bg-stone-200/60 dark:bg-zinc-800 text-concrete'
}`}>
{active ? <Eye size={11} /> : <EyeOff size={11} />}
</div>
<div className="min-w-0 flex-1">
<p className="text-[11px] font-mono font-bold uppercase tracking-wide text-ink dark:text-dark-ink">
{t(`homeDashboard.widgets.${id}`)}
</p>
<p className="text-[10px] text-concrete leading-snug mt-0.5">
{t(`homeDashboard.widgetDescriptions.${id}`)}
</p>
</div>
{!meta.required && (
<span className={`shrink-0 text-[9px] font-mono font-bold uppercase px-2 py-1 rounded-lg ${
active ? 'text-concrete' : 'text-brand-accent bg-brand-accent/10'
}`}>
{active ? t('homeDashboard.widgetActive') : t('homeDashboard.widgetAddShort')}
</span>
)}
</button>
)
})}
</div>
</div>
)
})}
</div>
</div>
)}
</>
)
}

View File

@@ -0,0 +1,57 @@
'use client'
import { useState } from 'react'
import { HelpCircle, X } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import type { DashboardWidgetId } from '@/lib/dashboard/layout'
interface DashboardWidgetHelpProps {
widgetId: DashboardWidgetId
className?: string
}
export function DashboardWidgetHelp({ widgetId, className = '' }: DashboardWidgetHelpProps) {
const { t } = useLanguage()
const [open, setOpen] = useState(false)
const helpKey = `homeDashboard.widgetHelp.${widgetId}`
const text = t(helpKey)
if (text === helpKey) return null
return (
<div className={`relative ${className}`}>
<button
type="button"
onClick={() => setOpen(v => !v)}
className={`p-1 rounded-full border transition-colors ${
open
? 'border-brand-accent/40 bg-brand-accent/10 text-brand-accent'
: 'border-border/30 bg-white/90 dark:bg-zinc-900/90 text-concrete hover:text-brand-accent hover:border-brand-accent/30'
}`}
aria-label={t('homeDashboard.widgetHelpLabel')}
aria-expanded={open}
>
<HelpCircle size={12} strokeWidth={2} />
</button>
{open && (
<div className="absolute top-full end-0 mt-1.5 z-30 w-[min(280px,calc(100vw-3rem))] p-3 rounded-xl border border-brand-accent/20 bg-white dark:bg-zinc-900 shadow-lg">
<div className="flex items-start justify-between gap-2 mb-1">
<p className="text-[8px] font-mono font-bold uppercase tracking-wider text-brand-accent">
{t(`homeDashboard.widgets.${widgetId}`)}
</p>
<button
type="button"
onClick={() => setOpen(false)}
className="text-concrete hover:text-ink shrink-0"
aria-label={t('homeDashboard.widgetHelpClose')}
>
<X size={10} />
</button>
</div>
<p className="text-[10px] text-concrete leading-relaxed">{text}</p>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,37 @@
'use client'
import type { ReactNode } from 'react'
import { DashboardWidgetHelp } from '@/components/dashboard-widget-help'
import type { DashboardWidgetId } from '@/lib/dashboard/layout'
interface DashboardWidgetTitleRowProps {
widgetId: DashboardWidgetId
icon?: ReactNode
title: string
actions?: ReactNode
className?: string
}
/** Titre widget : actions dabord, aide « ? » en dernier — ne masque jamais la navigation. */
export function DashboardWidgetTitleRow({
widgetId,
icon,
title,
actions,
className = 'mb-3',
}: DashboardWidgetTitleRowProps) {
return (
<div className={`flex items-center justify-between gap-2 ${className}`}>
<div className="flex items-center gap-2 min-w-0 flex-1">
{icon}
<h2 className="text-[10px] font-mono font-bold uppercase tracking-widest text-ink dark:text-dark-ink truncate">
{title}
</h2>
</div>
<div className="flex items-center gap-1.5 shrink-0">
{actions}
<DashboardWidgetHelp widgetId={widgetId} />
</div>
</div>
)
}

View File

@@ -34,6 +34,7 @@ import { StudyPlannerDialog } from '@/components/wizard/study-planner-dialog'
import { NotebookOrganizerDialog } from '@/components/wizard/notebook-organizer-dialog'
import { toast } from 'sonner'
import { AnimatePresence, motion } from 'motion/react'
import { isDashboardHomeRoute } from '@/lib/dashboard/home-route'
type SortOrder = 'newest' | 'oldest' | 'alpha' | 'manual'
@@ -58,6 +59,10 @@ const OrganizeNotebookDialog = dynamic(
() => import('@/components/organize-notebook-dialog').then(m => ({ default: m.OrganizeNotebookDialog })),
{ ssr: false }
)
const DashboardView = dynamic(
() => import('@/components/dashboard-view').then(m => ({ default: m.DashboardView })),
{ ssr: false }
)
const NotebookSiteDialog = dynamic(
() => import('@/components/wizard/notebook-site-dialog').then(m => ({ default: m.NotebookSiteDialog })),
{ ssr: false }
@@ -225,15 +230,12 @@ export function HomeClient({
}, [])
// Sidebar carnet / inbox: fermer l'éditeur plein écran (comme la ref. architectural-grid)
// On GARDE forceList dans l'URL pour distinguer "liste" du "dashboard"
useEffect(() => {
if (searchParams.get('forceList') === '1') {
setEditingNote(null)
const params = new URLSearchParams(searchParams.toString())
params.delete('forceList')
const newUrl = params.toString() ? `/home?${params.toString()}` : '/home'
router.replace(newUrl, { scroll: false })
}
}, [searchParams, router])
}, [searchParams])
const fetchNotesForCurrentView = useCallback(
async (options?: { silent?: boolean }) => {
@@ -821,6 +823,16 @@ export function HomeClient({
emitNoteChange({ type: 'updated', note: savedNote })
}, [])
// Show dashboard when no active filter/view params
const showDashboard = !editingNote && isDashboardHomeRoute('/home', searchParams)
const handleDashboardNoteSelect = useCallback((noteId: string, notebookId: string | null) => {
const params = new URLSearchParams()
params.set('openNote', noteId)
if (notebookId) params.set('notebook', notebookId)
router.push(`/home?${params.toString()}`)
}, [router])
return (
<div
className={cn(
@@ -838,6 +850,8 @@ export function HomeClient({
fullPage
/>
</div>
) : showDashboard ? (
<DashboardView onNoteSelect={handleDashboardNoteSelect} />
) : (
<div className="flex-1 overflow-y-auto min-h-0 bg-memento-paper dark:bg-background flex flex-col">
<div

View File

@@ -0,0 +1,729 @@
'use client'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useRouter } from 'next/navigation'
import { motion, AnimatePresence } from 'motion/react'
import {
Sparkles, Zap, Lightbulb, Bot, ChevronLeft, ChevronRight,
ExternalLink, GitCompare, X, RefreshCw, Loader2, Link2, Brain, ArrowRight,
} from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import { DashboardWidgetTitleRow } from '@/components/dashboard-widget-title-row'
import { MEMORY_ECHO_LEGACY_EN_FALLBACKS } from '@/lib/ai/memory-echo-i18n'
// ─── Types ─────────────────────────────────────────────
export interface IntelBriefingInsight {
id: string
insight: string
score: number
date: string
viewed: boolean
note1: { id: string; title: string | null }
note2: { id: string; title: string | null }
note1Excerpt?: string
note2Excerpt?: string
}
export interface IntelBridgeNote {
noteId: string
bridgeScore: number
clusterNames?: string[]
note?: { id: string; title: string | null; content?: string }
}
export interface IntelBridgeSuggestion {
clusterAId: number
clusterBId: number
clusterAName: string
clusterBName: string
suggestedTitle: string
suggestedContent: string
justification: string
}
export interface IntelAgentAction {
id: string
agentName: string
result: string | null
createdAt: string
}
type IntelFilter = 'all' | 'connections' | 'bridges' | 'ideas' | 'agents'
type IntelItem =
| { kind: 'insight'; id: string; priority: number; insight: IntelBriefingInsight }
| { kind: 'bridge'; id: string; priority: number; bridge: IntelBridgeNote }
| { kind: 'suggestion'; id: string; priority: number; suggestion: IntelBridgeSuggestion }
| { kind: 'agent'; id: string; priority: number; agent: IntelAgentAction }
const CLUSTER_COLORS = ['#F87171', '#60A5FA', '#34D399', '#FBBF24', '#A78BFA', '#F472B6', '#2DD4BF']
const FILTER_KINDS: Record<IntelFilter, IntelItem['kind'][] | null> = {
all: null,
connections: ['insight'],
bridges: ['bridge'],
ideas: ['suggestion'],
agents: ['agent'],
}
function stripHtml(html: string): string {
return html.replace(/<[^>]+>/g, ' ').replace(/&nbsp;/g, ' ').replace(/\s+/g, ' ').trim()
}
function formatRelativeTime(
dateStr: string,
t: (key: string, params?: Record<string, string | number>) => string,
): string {
const diff = Date.now() - new Date(dateStr).getTime()
const mins = Math.floor(diff / 60000)
const hours = Math.floor(diff / 3600000)
const days = Math.floor(diff / 86400000)
if (mins < 1) return t('time.justNow')
if (mins < 60) return t('time.minutesAgo', { count: mins })
if (hours < 24) return t('time.hoursAgo', { count: hours })
return t('time.daysAgo', { count: days })
}
// ─── Visual: lien entre deux notes ─────────────────────
function ConnectionDiagram({
note1Title,
note2Title,
score,
color = '#6366F1',
}: {
note1Title: string
note2Title: string
score: number
color?: string
}) {
return (
<div className="flex items-center gap-2 py-3">
<div
className="flex-1 min-w-0 p-2.5 rounded-xl border border-border/30 bg-white/80 dark:bg-zinc-900/60 text-start"
style={{ borderColor: `${color}30` }}
>
<p className="text-[10px] font-semibold text-ink dark:text-dark-ink truncate leading-tight">
{note1Title}
</p>
</div>
<div className="shrink-0 flex flex-col items-center gap-0.5 px-1">
<div className="w-8 h-px" style={{ background: `linear-gradient(90deg, transparent, ${color}, transparent)` }} />
<span
className="text-[9px] font-mono font-bold px-2 py-0.5 rounded-full"
style={{ color, backgroundColor: `${color}14`, border: `1px solid ${color}25` }}
>
{Math.round(score * 100)}%
</span>
<div className="w-8 h-px" style={{ background: `linear-gradient(90deg, transparent, ${color}, transparent)` }} />
</div>
<div
className="flex-1 min-w-0 p-2.5 rounded-xl border border-border/30 bg-white/80 dark:bg-zinc-900/60 text-start"
style={{ borderColor: `${color}30` }}
>
<p className="text-[10px] font-semibold text-ink dark:text-dark-ink truncate leading-tight">
{note2Title}
</p>
</div>
</div>
)
}
// ─── Props ─────────────────────────────────────────────
export interface IntelligenceHubProps {
loading: boolean
aiActive: boolean
hasAiConsent: boolean
providerReady: boolean
memoryEchoEnabled: boolean
insights: IntelBriefingInsight[]
bridgeNotes: IntelBridgeNote[]
bridgeSuggestions: IntelBridgeSuggestion[]
agentActions: IntelAgentAction[]
onNoteSelect: (noteId: string) => void
onRefreshEcho: () => void
onEnableAi: () => void
echoRefreshing: boolean
onDismissInsight: (insight: IntelBriefingInsight) => void
onDismissBridgeSuggestion: (s: IntelBridgeSuggestion) => void
onCreateBridgeSuggestion: (s: IntelBridgeSuggestion) => void
onOpenInsightNote: (insight: IntelBriefingInsight, noteId: string) => void
dismissingInsightId: string | null
actingBridgeSuggestionKey: string | null
prefersReducedMotion: boolean
}
// ─── Component ─────────────────────────────────────────
export function IntelligenceHub({
loading,
aiActive,
hasAiConsent,
providerReady,
memoryEchoEnabled,
insights,
bridgeNotes,
bridgeSuggestions,
agentActions,
onNoteSelect,
onRefreshEcho,
onEnableAi,
echoRefreshing,
onDismissInsight,
onDismissBridgeSuggestion,
onCreateBridgeSuggestion,
onOpenInsightNote,
dismissingInsightId,
actingBridgeSuggestionKey,
prefersReducedMotion,
}: IntelligenceHubProps) {
const router = useRouter()
const { t } = useLanguage()
const [filter, setFilter] = useState<IntelFilter>('all')
const [activeIndex, setActiveIndex] = useState(0)
const localizeInsightText = useCallback((insight: IntelBriefingInsight) => {
const text = insight.insight.trim()
if (MEMORY_ECHO_LEGACY_EN_FALLBACKS.has(text)) {
if (insight.note1Excerpt && insight.note2Excerpt) {
return t('homeDashboard.connectionBetween', {
note1: insight.note1.title || insight.note1Excerpt.slice(0, 50),
note2: insight.note2.title || insight.note2Excerpt.slice(0, 50),
})
}
return t('memoryEcho.defaultInsight')
}
return text
}, [t])
const allItems = useMemo(() => {
const insightNoteIds = new Set<string>()
for (const i of insights) {
insightNoteIds.add(i.note1.id)
insightNoteIds.add(i.note2.id)
}
const items: IntelItem[] = []
for (const insight of insights) {
items.push({
kind: 'insight',
id: `insight-${insight.id}`,
priority: (insight.viewed ? 40 : 100) + insight.score * 10,
insight,
})
}
for (const bridge of bridgeNotes) {
if (insightNoteIds.has(bridge.noteId)) continue
items.push({
kind: 'bridge',
id: `bridge-${bridge.noteId}`,
priority: 60 + bridge.bridgeScore * 10,
bridge,
})
}
for (const suggestion of bridgeSuggestions) {
items.push({
kind: 'suggestion',
id: `suggestion-${suggestion.clusterAId}-${suggestion.clusterBId}`,
priority: 80,
suggestion,
})
}
for (const agent of agentActions) {
items.push({
kind: 'agent',
id: `agent-${agent.id}`,
priority: 30,
agent,
})
}
return items.sort((a, b) => b.priority - a.priority).slice(0, 10)
}, [insights, bridgeNotes, bridgeSuggestions, agentActions])
const counts = useMemo(() => ({
all: allItems.length,
connections: allItems.filter(i => i.kind === 'insight').length,
bridges: allItems.filter(i => i.kind === 'bridge').length,
ideas: allItems.filter(i => i.kind === 'suggestion').length,
agents: allItems.filter(i => i.kind === 'agent').length,
}), [allItems])
const filteredItems = useMemo(() => {
const kinds = FILTER_KINDS[filter]
if (!kinds) return allItems
return allItems.filter(i => kinds.includes(i.kind))
}, [allItems, filter])
useEffect(() => {
setActiveIndex(0)
}, [filter])
useEffect(() => {
if (activeIndex >= filteredItems.length && filteredItems.length > 0) {
setActiveIndex(filteredItems.length - 1)
}
}, [activeIndex, filteredItems.length])
const activeItem = filteredItems[activeIndex] ?? null
const newCount = insights.filter(i => !i.viewed).length
const goPrev = () => setActiveIndex(i => Math.max(0, i - 1))
const goNext = () => setActiveIndex(i => Math.min(filteredItems.length - 1, i + 1))
const filters: { key: IntelFilter; label: string }[] = [
{ key: 'all', label: t('homeDashboard.intelFilterAll') },
{ key: 'connections', label: t('homeDashboard.intelFilterConnections') },
{ key: 'bridges', label: t('homeDashboard.intelFilterBridges') },
{ key: 'ideas', label: t('homeDashboard.intelFilterIdeas') },
{ key: 'agents', label: t('homeDashboard.intelFilterAgents') },
]
const slideVariants = prefersReducedMotion
? { initial: {}, animate: {}, exit: {} }
: {
initial: { opacity: 0, x: 24, scale: 0.98 },
animate: { opacity: 1, x: 0, scale: 1 },
exit: { opacity: 0, x: -24, scale: 0.98 },
}
const renderSpotlight = (item: IntelItem) => {
switch (item.kind) {
case 'insight': {
const { insight } = item
const text = localizeInsightText(insight)
const excerpt = insight.note1Excerpt || insight.note2Excerpt
return (
<div className="flex flex-col h-full">
<div className="flex items-center gap-2 mb-3">
<Sparkles size={12} className="text-indigo-500" />
<span className="text-[9px] font-mono font-bold uppercase tracking-wider text-indigo-600 dark:text-indigo-400">
{t('homeDashboard.semanticConnection')}
</span>
{!insight.viewed && (
<span className="w-1.5 h-1.5 rounded-full bg-ochre animate-pulse" />
)}
</div>
<ConnectionDiagram
note1Title={insight.note1.title || t('homeDashboard.untitled')}
note2Title={insight.note2.title || t('homeDashboard.untitled')}
score={insight.score}
/>
<p className="text-[11px] text-ink/75 dark:text-dark-ink/75 font-serif italic leading-relaxed line-clamp-3 px-1">
« {text} »
</p>
{excerpt && (
<p className="text-[9px] text-concrete/80 line-clamp-2 mt-2 px-1 border-s-2 border-indigo-500/20 ps-2">
{excerpt}
</p>
)}
<div className="flex items-center gap-1.5 mt-auto pt-3 flex-wrap">
<button
type="button"
onClick={() => onOpenInsightNote(insight, insight.note1.id)}
className="inline-flex items-center gap-1 text-[8.5px] font-mono uppercase font-bold px-2.5 py-1.5 rounded-lg bg-indigo-600 text-white hover:bg-indigo-700 transition-colors"
>
<ExternalLink size={9} />
{t('homeDashboard.intelOpenNote')}
</button>
<button
type="button"
onClick={() => onOpenInsightNote(insight, insight.note2.id)}
className="inline-flex items-center gap-1 text-[8.5px] font-mono uppercase font-bold px-2.5 py-1.5 rounded-lg border border-border/40 hover:border-indigo-400/40 transition-colors"
>
<GitCompare size={9} />
{t('homeDashboard.intelCompare')}
</button>
<button
type="button"
disabled={dismissingInsightId === insight.id}
onClick={() => onDismissInsight(insight)}
className="p-1.5 rounded-lg border border-border/30 text-concrete hover:text-rose-500 ms-auto disabled:opacity-40"
title={t('homeDashboard.dismissConnection')}
>
{dismissingInsightId === insight.id
? <Loader2 size={10} className="animate-spin" />
: <X size={10} />}
</button>
</div>
</div>
)
}
case 'bridge': {
const { bridge } = item
const title = bridge.note?.title || t('homeDashboard.untitled')
const excerpt = bridge.note?.content ? stripHtml(bridge.note.content).slice(0, 160) : ''
const names = bridge.clusterNames ?? []
return (
<div className="flex flex-col h-full">
<div className="flex items-center justify-between gap-2 mb-3">
<div className="flex items-center gap-2">
<Zap size={12} className="text-ochre" />
<span className="text-[9px] font-mono font-bold uppercase tracking-wider text-ochre">
{t('homeDashboard.bridgeNote')}
</span>
</div>
<span className="text-[9px] font-mono font-bold text-ochre bg-ochre/10 px-2 py-0.5 rounded-full">
{Math.round(bridge.bridgeScore * 100)}%
</span>
</div>
<button
type="button"
onClick={() => onNoteSelect(bridge.noteId)}
className="text-start group flex-1"
>
<p className="text-sm font-semibold text-ink dark:text-dark-ink group-hover:text-ochre transition-colors mb-2 leading-snug">
{title}
</p>
{excerpt && (
<p className="text-[10px] text-concrete leading-relaxed line-clamp-3 mb-3">{excerpt}</p>
)}
{names.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{names.map((name, i) => (
<span
key={`${bridge.noteId}-${name}`}
className="inline-flex items-center gap-1 px-2 py-1 rounded-full border border-border/25 bg-white/70 dark:bg-zinc-900/50"
>
<span
className="w-1.5 h-1.5 rounded-full"
style={{ backgroundColor: CLUSTER_COLORS[i % CLUSTER_COLORS.length] }}
/>
<span className="text-[8px] font-mono uppercase text-concrete">{name}</span>
</span>
))}
</div>
)}
</button>
<button
type="button"
onClick={() => onNoteSelect(bridge.noteId)}
className="mt-3 inline-flex items-center gap-1 text-[8.5px] font-mono uppercase font-bold px-2.5 py-1.5 rounded-lg bg-ochre/90 text-white hover:bg-ochre transition-colors w-fit"
>
<ExternalLink size={9} />
{t('homeDashboard.intelOpenNote')}
</button>
</div>
)
}
case 'suggestion': {
const { suggestion } = item
const key = `${suggestion.clusterAId}-${suggestion.clusterBId}`
const busy = actingBridgeSuggestionKey === key
return (
<div className="flex flex-col h-full">
<div className="flex items-center gap-2 mb-3">
<Lightbulb size={12} className="text-violet-500" />
<span className="text-[9px] font-mono font-bold uppercase tracking-wider text-violet-600 dark:text-violet-400 truncate">
{t('homeDashboard.intelMissingLink')}
</span>
</div>
<div className="flex items-center gap-2 mb-3">
<span className="px-2 py-1 rounded-lg bg-violet-500/10 text-[9px] font-mono font-bold text-violet-700 dark:text-violet-300 truncate max-w-[45%]">
{suggestion.clusterAName}
</span>
<div className="flex-1 h-px bg-gradient-to-r from-violet-400/40 via-ochre/60 to-violet-400/40" />
<span className="px-2 py-1 rounded-lg bg-ochre/10 text-[9px] font-mono font-bold text-ochre truncate max-w-[45%]">
{suggestion.clusterBName}
</span>
</div>
<p className="text-sm font-semibold text-ink dark:text-dark-ink mb-1.5">{suggestion.suggestedTitle}</p>
<p className="text-[10px] text-concrete leading-relaxed line-clamp-2 flex-1">{suggestion.suggestedContent}</p>
<div className="flex items-center gap-1.5 mt-3 pt-3 border-t border-border/15">
<button
type="button"
disabled={busy}
onClick={() => onCreateBridgeSuggestion(suggestion)}
className="inline-flex items-center gap-1 text-[8.5px] font-mono uppercase font-bold px-2.5 py-1.5 rounded-lg bg-violet-600 text-white hover:bg-violet-700 transition-colors disabled:opacity-40"
>
{busy ? <Loader2 size={9} className="animate-spin" /> : <Link2 size={9} />}
{t('homeDashboard.createBridgeNote')}
</button>
<button
type="button"
disabled={busy}
onClick={() => onDismissBridgeSuggestion(suggestion)}
className="text-[8.5px] font-mono uppercase px-2 py-1.5 rounded-lg border border-border/30 text-concrete hover:text-rose-500 disabled:opacity-40"
>
{t('homeDashboard.dismiss')}
</button>
</div>
</div>
)
}
case 'agent': {
const { agent } = item
return (
<div className="flex flex-col h-full">
<div className="flex items-center gap-2 mb-3">
<Bot size={12} className="text-ochre" />
<span className="text-[9px] font-mono font-bold uppercase tracking-wider text-ochre">
{agent.agentName}
</span>
<span className="text-[8px] font-mono text-concrete/60 ms-auto">
{formatRelativeTime(agent.createdAt, t)}
</span>
</div>
{agent.result ? (
<p className="text-[11px] text-concrete leading-relaxed line-clamp-4 flex-1">
{agent.result}
</p>
) : (
<p className="text-[11px] text-concrete/60 italic flex-1">{t('homeDashboard.intelAgentNoResult')}</p>
)}
<button
type="button"
onClick={() => router.push('/agents')}
className="mt-3 inline-flex items-center gap-1 text-[8.5px] font-mono uppercase font-bold px-2.5 py-1.5 rounded-lg border border-ochre/30 text-ochre hover:bg-ochre/10 transition-colors w-fit"
>
{t('homeDashboard.intelViewAgent')}
<ArrowRight size={9} />
</button>
</div>
)
}
}
}
const spotlightAccent = (item: IntelItem | null) => {
if (!item) return 'from-stone-100/50 to-transparent border-border/30'
switch (item.kind) {
case 'insight': return 'from-indigo-500/[0.06] via-transparent to-transparent border-indigo-400/25'
case 'bridge': return 'from-ochre/[0.06] via-transparent to-transparent border-ochre/25'
case 'suggestion': return 'from-violet-500/[0.06] via-transparent to-transparent border-violet-400/25'
case 'agent': return 'from-ochre/[0.04] via-transparent to-transparent border-border/30'
}
}
return (
<section className="p-5 rounded-2xl bg-white dark:bg-zinc-900 border border-border/30 flex flex-col">
{/* Header */}
<DashboardWidgetTitleRow
widgetId="intelligence"
icon={(
<div className="w-5 h-5 rounded bg-brand-accent/10 flex items-center justify-center text-brand-accent shrink-0">
<Sparkles size={12} />
</div>
)}
title={t('homeDashboard.aiFound')}
actions={(
<>
{newCount > 0 && (
<span className="text-[8px] font-mono font-bold text-brand-accent bg-brand-accent/10 px-2 py-0.5 rounded uppercase">
{newCount} {t('homeDashboard.new')}
</span>
)}
{aiActive && (
<button
type="button"
onClick={onRefreshEcho}
disabled={echoRefreshing}
title={t('homeDashboard.analyzeNotes')}
className="p-1.5 rounded-lg border border-border/30 hover:border-brand-accent/40 hover:bg-brand-accent/5 transition-all disabled:opacity-40"
>
{echoRefreshing
? <Loader2 size={11} className="animate-spin text-brand-accent" />
: <RefreshCw size={11} className="text-concrete hover:text-brand-accent" />}
</button>
)}
</>
)}
/>
{loading ? (
<div className="h-[220px] rounded-2xl bg-stone-50 dark:bg-zinc-950/30 animate-pulse" />
) : !aiActive ? (
<div className="p-5 rounded-2xl border border-dashed border-border/40 bg-stone-50/50 dark:bg-zinc-950/30 text-center space-y-3 min-h-[180px] flex flex-col justify-center">
<p className="text-xs text-concrete leading-relaxed">
{!hasAiConsent
? t('homeDashboard.aiConsentRequired')
: !providerReady
? t('homeDashboard.aiProviderUnavailable')
: t('homeDashboard.memoryEchoDisabled')}
</p>
{!hasAiConsent && (
<button
type="button"
onClick={onEnableAi}
className="text-[9px] font-mono uppercase font-bold px-3 py-1.5 rounded-lg bg-ink text-white dark:bg-white dark:text-black hover:opacity-90 transition-opacity mx-auto"
>
{t('homeDashboard.enableAi')}
</button>
)}
</div>
) : allItems.length === 0 ? (
<div className="p-5 rounded-2xl border border-dashed border-border/40 bg-stone-50/50 dark:bg-zinc-950/30 text-center space-y-3 min-h-[180px] flex flex-col justify-center">
<Brain size={22} className="mx-auto text-concrete/40" strokeWidth={1.25} />
<p className="text-xs text-concrete leading-relaxed">{t('homeDashboard.noConnections')}</p>
<button
type="button"
onClick={onRefreshEcho}
disabled={echoRefreshing}
className="inline-flex items-center gap-1.5 text-[9px] font-mono uppercase font-bold px-3 py-1.5 rounded-lg border border-ochre/30 text-ochre hover:bg-ochre/10 transition-all disabled:opacity-40 mx-auto"
>
{echoRefreshing ? <Loader2 size={11} className="animate-spin" /> : <Sparkles size={11} />}
{t('homeDashboard.analyzeNotes')}
</button>
</div>
) : (
<>
{/* Filtres */}
<div className="flex gap-1 overflow-x-auto custom-scrollbar pb-1 -mx-0.5 px-0.5 mb-3">
{filters.map(f => {
const count = counts[f.key]
if (f.key !== 'all' && count === 0) return null
const active = filter === f.key
return (
<button
key={f.key}
type="button"
onClick={() => setFilter(f.key)}
className={`shrink-0 inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[8.5px] font-mono font-bold uppercase tracking-wider transition-all ${
active
? 'bg-ink text-white dark:bg-white dark:text-black shadow-sm'
: 'bg-stone-100 dark:bg-zinc-800 text-concrete hover:text-ink dark:hover:text-dark-ink'
}`}
>
{f.label}
{count > 0 && (
<span className={`text-[7px] px-1 py-px rounded-full ${active ? 'bg-white/20' : 'bg-black/5 dark:bg-white/10'}`}>
{count}
</span>
)}
</button>
)
})}
</div>
{filteredItems.length === 0 ? (
<p className="text-xs text-concrete italic text-center py-8">{t('homeDashboard.intelFilterEmpty')}</p>
) : (
<>
{/* Spotlight carousel */}
<div className="relative">
{filteredItems.length > 1 && (
<>
<button
type="button"
onClick={goPrev}
disabled={activeIndex === 0}
className="absolute start-0 top-1/2 -translate-y-1/2 -translate-x-1 z-10 p-1 rounded-full border border-border/40 bg-white/90 dark:bg-zinc-900/90 shadow-sm disabled:opacity-25 hover:border-ochre/40 transition-all"
aria-label={t('homeDashboard.intelPrev')}
>
<ChevronLeft size={14} />
</button>
<button
type="button"
onClick={goNext}
disabled={activeIndex >= filteredItems.length - 1}
className="absolute end-0 top-1/2 -translate-y-1/2 translate-x-1 z-10 p-1 rounded-full border border-border/40 bg-white/90 dark:bg-zinc-900/90 shadow-sm disabled:opacity-25 hover:border-ochre/40 transition-all"
aria-label={t('homeDashboard.intelNext')}
>
<ChevronRight size={14} />
</button>
</>
)}
<div
className={`min-h-[200px] mx-5 p-4 rounded-2xl border bg-gradient-to-br ${spotlightAccent(activeItem)} transition-colors`}
>
<AnimatePresence mode="wait">
{activeItem && (
<motion.div
key={activeItem.id}
initial={slideVariants.initial}
animate={slideVariants.animate}
exit={slideVariants.exit}
transition={{ duration: prefersReducedMotion ? 0 : 0.22, ease: 'easeOut' }}
className="min-h-[180px] flex flex-col"
>
{renderSpotlight(activeItem)}
</motion.div>
)}
</AnimatePresence>
</div>
</div>
{/* Navigation dots + position */}
{filteredItems.length > 1 && (
<div className="flex items-center justify-center gap-3 mt-3">
<div className="flex items-center gap-1.5">
{filteredItems.map((item, idx) => (
<button
key={item.id}
type="button"
onClick={() => setActiveIndex(idx)}
className={`rounded-full transition-all ${
idx === activeIndex
? 'w-5 h-1.5 bg-ochre'
: 'w-1.5 h-1.5 bg-concrete/25 hover:bg-concrete/50'
}`}
aria-label={t('homeDashboard.intelGoTo', { index: idx + 1 })}
/>
))}
</div>
<span className="text-[8px] font-mono text-concrete/60 uppercase">
{t('homeDashboard.intelPosition', { current: activeIndex + 1, total: filteredItems.length })}
</span>
</div>
)}
{/* File d'attente compacte */}
{filteredItems.length > 1 && (
<div className="flex gap-1.5 mt-3 overflow-x-auto custom-scrollbar pt-1">
{filteredItems.map((item, idx) => {
const isActive = idx === activeIndex
let label = ''
let Icon = Sparkles
let accent = 'text-indigo-500'
if (item.kind === 'insight') {
label = item.insight.note1.title?.slice(0, 28) || t('homeDashboard.untitled')
Icon = Sparkles
accent = 'text-indigo-500'
} else if (item.kind === 'bridge') {
label = item.bridge.note?.title?.slice(0, 28) || t('homeDashboard.untitled')
Icon = Zap
accent = 'text-ochre'
} else if (item.kind === 'suggestion') {
label = item.suggestion.suggestedTitle.slice(0, 28)
Icon = Lightbulb
accent = 'text-violet-500'
} else {
label = item.agent.agentName
Icon = Bot
accent = 'text-ochre'
}
return (
<button
key={item.id}
type="button"
onClick={() => setActiveIndex(idx)}
className={`shrink-0 flex items-center gap-1.5 px-2 py-1.5 rounded-lg border text-start max-w-[130px] transition-all ${
isActive
? 'border-ochre/40 bg-ochre/5 shadow-sm'
: 'border-border/25 bg-stone-50/50 dark:bg-zinc-950/30 hover:border-border/50'
}`}
>
<Icon size={9} className={`shrink-0 ${accent}`} />
<span className="text-[8px] font-medium text-ink dark:text-dark-ink truncate">{label}</span>
</button>
)
})}
</div>
)}
</>
)}
</>
)}
</section>
)
}

View File

@@ -10,6 +10,12 @@ import type { AppState, BinaryFiles } from '@excalidraw/excalidraw/types'
import type { ExcalidrawImperativeAPI } from '@excalidraw/excalidraw/types'
import '@excalidraw/excalidraw/index.css'
import type { PresentationSpec } from '@/lib/types/presentation'
import { useLanguage } from '@/lib/i18n'
function SlidesLoadingFallback() {
const { t } = useLanguage()
return <div className="absolute inset-0 flex items-center justify-center bg-zinc-950 text-white/40 text-sm">{t('lab.loadingSlides')}</div>
}
const Excalidraw = dynamic(
async () => (await import('@excalidraw/excalidraw')).Excalidraw,
@@ -18,7 +24,7 @@ const Excalidraw = dynamic(
const SlidesRenderer = dynamic(
() => import('./slides-renderer').then(m => ({ default: m.SlidesRenderer })),
{ ssr: false, loading: () => <div className="absolute inset-0 flex items-center justify-center bg-zinc-950 text-white/40 text-sm">Chargement des slides</div> }
{ ssr: false, loading: () => <SlidesLoadingFallback /> }
)
interface CanvasBoardProps {
@@ -62,6 +68,7 @@ function parseCanvasScene(initialData?: string): {
}
function PptxViewer({ data, name }: { data: PptxPayload; name: string }) {
const { t } = useLanguage()
const handleDownload = () => {
try {
const byteCharacters = atob(data.base64)
@@ -83,7 +90,7 @@ function PptxViewer({ data, name }: { data: PptxPayload; name: string }) {
URL.revokeObjectURL(url)
} catch (e) {
console.error('[PptxViewer] Download failed:', e)
toast.error('Failed to download presentation')
toast.error(t('lab.downloadFailed'))
}
}
@@ -106,7 +113,7 @@ function PptxViewer({ data, name }: { data: PptxPayload; name: string }) {
className="flex items-center gap-2 px-6 py-3 bg-primary text-primary-foreground rounded-xl hover:bg-primary/90 transition-colors font-medium shadow-sm"
>
<Download className="w-5 h-5" />
Download .pptx
{t('lab.exportPpt')}
</button>
<p className="text-xs text-muted-foreground max-w-sm text-center">
This file can be opened in Microsoft PowerPoint, Google Slides, or Keynote.
@@ -117,6 +124,7 @@ function PptxViewer({ data, name }: { data: PptxPayload; name: string }) {
}
function SlidesViewer({ data, name, canvasId }: { data: SlidesPayload; name: string; canvasId?: string | null }) {
const { t } = useLanguage()
const iframeRef = useRef<HTMLIFrameElement>(null)
const [currentSlide, setCurrentSlide] = useState(1)
const [isLoaded, setIsLoaded] = useState(!!data.spec) // spec → React renderer, no iframe loading
@@ -194,27 +202,27 @@ function SlidesViewer({ data, name, canvasId }: { data: SlidesPayload; name: str
href={`/api/canvas/slides/pptx?id=${canvasId}`}
download
className="flex items-center gap-1.5 px-3 py-1.5 bg-white/10 hover:bg-white/20 rounded-lg text-white/70 hover:text-white text-xs font-medium transition-all"
title="Exporter en PowerPoint"
title={t('lab.exportPpt')}
>
<Download className="w-3.5 h-3.5" />
Export PPTX
{t('lab.exportPpt')}
</a>
)}
<button
onClick={downloadHtml}
className="flex items-center gap-1.5 px-3 py-1.5 bg-white/10 hover:bg-white/20 rounded-lg text-white/70 hover:text-white text-xs font-medium transition-all"
title="Télécharger le HTML"
title={t('lab.downloadHtml')}
>
<Download className="w-3.5 h-3.5" />
Export HTML
{t('lab.downloadHtml')}
</button>
<button
onClick={openFullscreen}
className="flex items-center gap-1.5 px-3 py-1.5 bg-white/10 hover:bg-white/20 rounded-lg text-white/70 hover:text-white text-xs font-medium transition-all"
title="Ouvrir en plein écran"
title={t('lab.openFullscreen')}
>
<Maximize2 className="w-3.5 h-3.5" />
Plein écran
{t('lab.openFullscreen')}
</button>
</div>
</div>
@@ -228,7 +236,7 @@ function SlidesViewer({ data, name, canvasId }: { data: SlidesPayload; name: str
{!isLoaded && (
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center bg-zinc-950 gap-3">
<div className="w-8 h-8 rounded-full border-2 border-white/10 border-t-white/60 animate-spin" />
<span className="text-xs text-white/30">Chargement de la présentation</span>
<span className="text-xs text-white/30">{t('lab.loadingPresentation')}</span>
</div>
)}
<iframe
@@ -244,7 +252,7 @@ function SlidesViewer({ data, name, canvasId }: { data: SlidesPayload; name: str
<button
onClick={() => navigate(-1)}
className="absolute left-2 top-1/2 -translate-y-1/2 z-10 w-9 h-9 flex items-center justify-center rounded-full bg-black/40 hover:bg-black/70 backdrop-blur-sm border border-white/10 text-white/40 hover:text-white transition-all opacity-0 group-hover:opacity-100"
aria-label="Slide précédent"
aria-label={t('lab.previousSlide')}
>
<ChevronLeft className="w-5 h-5" />
</button>
@@ -252,7 +260,7 @@ function SlidesViewer({ data, name, canvasId }: { data: SlidesPayload; name: str
<button
onClick={() => navigate(1)}
className="absolute right-2 top-1/2 -translate-y-1/2 z-10 w-9 h-9 flex items-center justify-center rounded-full bg-black/40 hover:bg-black/70 backdrop-blur-sm border border-white/10 text-white/40 hover:text-white transition-all opacity-0 group-hover:opacity-100"
aria-label="Slide suivant"
aria-label={t('lab.nextSlide')}
>
<ChevronRight className="w-5 h-5" />
</button>

View File

@@ -11,10 +11,16 @@ import {
XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer,
FunnelChart, Funnel, LabelList,
} from 'recharts'
import { useLanguage } from '@/lib/i18n'
function MermaidLoadingFallback() {
const { t } = useLanguage()
return <div style={{ color: 'rgba(255,255,255,0.3)', padding: 24 }}>{t('common.loading')}</div>
}
const MermaidDiagram = dynamic(
() => import('./mermaid-diagram').then(m => ({ default: m.MermaidDiagram })),
{ ssr: false, loading: () => <div style={{ color: 'rgba(255,255,255,0.3)', padding: 24 }}>Chargement</div> }
{ ssr: false, loading: () => <MermaidLoadingFallback /> }
)
// ── Constants ──────────────────────────────────────────────────────────────────
@@ -688,6 +694,7 @@ export interface SlidesRendererProps {
}
export function SlidesRenderer({ spec }: SlidesRendererProps) {
const { t } = useLanguage()
const [current, setCurrent] = useState(0)
const [showNotes, setShowNotes] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
@@ -795,12 +802,12 @@ export function SlidesRenderer({ spec }: SlidesRendererProps) {
{/* Navigation buttons */}
{current > 0 && (
<button style={navBtn(true)} onClick={prev} aria-label="Précédent">
<button style={navBtn(true)} onClick={prev} aria-label={t('lab.previous')}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><polyline points="15 18 9 12 15 6" /></svg>
</button>
)}
{current < total - 1 && (
<button style={navBtn(false)} onClick={next} aria-label="Suivant">
<button style={navBtn(false)} onClick={next} aria-label={t('lab.next')}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><polyline points="9 6 15 12 9 18" /></svg>
</button>
)}
@@ -830,12 +837,12 @@ export function SlidesRenderer({ spec }: SlidesRendererProps) {
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<span style={{ fontSize: 10, fontWeight: 700, letterSpacing: '0.12em', textTransform: 'uppercase', color: palette.accent }}>
Notes de présentation
{t('lab.notes')}
</span>
<button
onClick={() => setShowNotes(false)}
style={{ background: 'none', border: 'none', color: isDark ? '#94a3b8' : '#64748b', cursor: 'pointer', fontSize: 13, padding: 4 }}
title="Masquer les notes"
title={t('lab.hideNotes')}
>
</button>
@@ -860,10 +867,10 @@ export function SlidesRenderer({ spec }: SlidesRendererProps) {
display: 'flex', alignItems: 'center', gap: 6, transition: 'all 0.2s',
backdropFilter: 'blur(4px)',
}}
title="Bascule des notes de présentation (Raccourci: N)"
title={t('lab.toggleNotes')}
>
<span>📝</span>
<span>Notes</span>
<span>{t('lab.notes')}</span>
</button>
)}
<div style={{ fontSize: 12, fontWeight: 600, color: isDark ? 'rgba(255,255,255,0.4)' : 'rgba(0,0,0,0.35)', background: isDark ? 'rgba(0,0,0,0.4)' : 'rgba(255,255,255,0.8)', padding: '3px 10px', borderRadius: 100, backdropFilter: 'blur(4px)', border: `1px solid ${isDark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)'}` }}>

View File

@@ -41,9 +41,16 @@ import { cn } from '@/lib/utils'
interface McpSettingsPanelProps {
initialKeys: McpKeyInfo[]
serverStatus: McpServerStatus
mcpAllowed?: boolean
tier?: string
}
export function McpSettingsPanel({ initialKeys, serverStatus }: McpSettingsPanelProps) {
export function McpSettingsPanel({
initialKeys,
serverStatus,
mcpAllowed = true,
tier = 'PRO',
}: McpSettingsPanelProps) {
const [keys, setKeys] = useState<McpKeyInfo[]>(initialKeys)
const [createOpen, setCreateOpen] = useState(false)
const [isPending, startTransition] = useTransition()
@@ -69,7 +76,7 @@ export function McpSettingsPanel({ initialKeys, serverStatus }: McpSettingsPanel
...prev,
])
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to generate key')
toast.error(err instanceof Error ? err.message : t('mcpSettings.apiKeys.errors.generate'))
}
})
}
@@ -84,7 +91,7 @@ export function McpSettingsPanel({ initialKeys, serverStatus }: McpSettingsPanel
)
toast.success(t('toast.operationSuccess'))
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to revoke key')
toast.error(err instanceof Error ? err.message : t('mcpSettings.apiKeys.errors.revoke'))
}
})
}
@@ -97,7 +104,7 @@ export function McpSettingsPanel({ initialKeys, serverStatus }: McpSettingsPanel
setKeys(prev => prev.filter(k => k.shortId !== shortId))
toast.success(t('toast.operationSuccess'))
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to delete key')
toast.error(err instanceof Error ? err.message : t('mcpSettings.apiKeys.errors.delete'))
}
})
}
@@ -183,6 +190,11 @@ export function McpSettingsPanel({ initialKeys, serverStatus }: McpSettingsPanel
<p className="text-[10px] text-concrete mt-0.5">{t('mcpSettings.apiKeys.description')}</p>
</div>
</div>
{!mcpAllowed ? (
<p className="text-[10px] text-concrete max-w-[200px] text-end leading-relaxed">
{t('mcpSettings.tierRequired', { tier })}
</p>
) : (
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogTrigger asChild>
<button className="flex items-center gap-1.5 px-4 py-2 rounded-xl bg-ink text-paper text-[10px] font-bold uppercase tracking-[0.15em] hover:scale-[1.02] active:scale-95 transition-all duration-300 shadow-lg shadow-ink/20">
@@ -192,8 +204,17 @@ export function McpSettingsPanel({ initialKeys, serverStatus }: McpSettingsPanel
</DialogTrigger>
<CreateKeyDialog onGenerate={handleGenerate} isPending={isPending} />
</Dialog>
)}
</div>
{!mcpAllowed && (
<div className="px-6 pb-4">
<p className="text-[11px] text-amber-800 dark:text-amber-200 bg-amber-500/10 border border-amber-500/20 rounded-xl px-4 py-3 leading-relaxed">
{t('mcpSettings.upgradeHint')}
</p>
</div>
)}
<div className="p-6">
{keys.length === 0 ? (
<div className="text-center py-8">

View File

@@ -47,7 +47,7 @@ export function MobileActionSheet({
const end = $pos.end(1)
editor.chain().focus().setTextSelection({ from: start, to: end }).run()
toast.success(t('richTextEditor.blockSelected') || 'Bloc sélectionné en entier')
toast.success(t('richTextEditor.blockSelected'))
onClose()
}
@@ -62,7 +62,7 @@ export function MobileActionSheet({
const nodeText = editor.state.doc.slice(start, end)
editor.chain().focus().insertContentAt(end, nodeText.content.toJSON()).run()
toast.success(t('richTextEditor.blockDuplicated') || 'Bloc dupliqué')
toast.success(t('richTextEditor.blockDuplicated'))
onClose()
}
@@ -75,7 +75,7 @@ export function MobileActionSheet({
const start = $pos.before(1)
const end = $pos.after(1)
editor.chain().focus().deleteRange({ from: start, to: end }).run()
toast.success(t('richTextEditor.blockDeleted') || 'Bloc supprimé')
toast.success(t('richTextEditor.blockDeleted'))
onClose()
}
@@ -85,7 +85,7 @@ export function MobileActionSheet({
onClose()
const tab = action === 'improve' ? 'actions' : 'chat'
window.dispatchEvent(new CustomEvent('memento-open-ai', { detail: { tab, scroll: action } }))
toast.info(t('richTextEditor.aiActionStarted') || 'IA Note sollicitée...')
toast.info(t('richTextEditor.aiActionStarted'))
}
return createPortal(
@@ -101,26 +101,26 @@ export function MobileActionSheet({
<div className="mobile-action-sheet-body">
{/* Section 1 : Actions de bloc */}
<div className="mobile-action-sheet-section">
<h4 className="section-title">{t('mobile.blockActions') || 'Actions sur le bloc'}</h4>
<h4 className="section-title">{t('mobile.blockActions')}</h4>
<div className="grid grid-cols-3 gap-2">
<button type="button" className="action-tile-btn" onClick={handleSelectAllBlock}>
<FileText size={20} className="text-blue-500" />
<span>{t('mobile.selectAll') || 'Sélectionner tout'}</span>
<span>{t('mobile.selectAll')}</span>
</button>
<button type="button" className="action-tile-btn" onClick={handleDuplicateBlock}>
<Copy size={20} className="text-emerald-500" />
<span>{t('mobile.duplicate') || 'Dupliquer'}</span>
<span>{t('mobile.duplicate')}</span>
</button>
<button type="button" className="action-tile-btn text-rose-500" onClick={handleDeleteBlock}>
<Trash2 size={20} className="text-rose-500" />
<span>{t('mobile.delete') || 'Supprimer'}</span>
<span>{t('mobile.delete')}</span>
</button>
</div>
</div>
{/* Section 2 : IA Note */}
<div className="mobile-action-sheet-section">
<h4 className="section-title">{t('mobile.aiNote') || 'IA Note'}</h4>
<h4 className="section-title">{t('mobile.aiNote')}</h4>
<div className="grid grid-cols-4 gap-2">
<button type="button" className="action-tile-btn" onClick={() => handleAiAction('improve')}>
<Wand2 size={18} className="text-purple-500 animate-pulse" />
@@ -143,42 +143,42 @@ export function MobileActionSheet({
{/* Section 3 : Format de bloc */}
<div className="mobile-action-sheet-section">
<h4 className="section-title">{t('mobile.convertFormat') || 'Convertir le format'}</h4>
<h4 className="section-title">{t('mobile.convertFormat')}</h4>
<div className="flex gap-2 overflow-x-auto pb-2 scrollbar-none">
<button
type="button"
className="format-pill-btn"
onClick={() => { editor.chain().focus().setParagraph().run(); onClose() }}
>
{t('mobile.paragraph') || 'Paragraphe'}
{t('mobile.paragraph')}
</button>
<button
type="button"
className="format-pill-btn"
onClick={() => { editor.chain().focus().toggleHeading({ level: 1 }).run(); onClose() }}
>
<Heading1 size={14} className="inline mr-1" /> {t('notes.heading1') || 'Titre 1'}
<Heading1 size={14} className="inline mr-1" /> {t('notes.heading1')}
</button>
<button
type="button"
className="format-pill-btn"
onClick={() => { editor.chain().focus().toggleHeading({ level: 2 }).run(); onClose() }}
>
<Heading2 size={14} className="inline mr-1" /> {t('notes.heading2') || 'Titre 2'}
<Heading2 size={14} className="inline mr-1" /> {t('notes.heading2')}
</button>
<button
type="button"
className="format-pill-btn"
onClick={() => { editor.chain().focus().toggleHeading({ level: 3 }).run(); onClose() }}
>
<Heading3 size={14} className="inline mr-1" /> {t('notes.heading3') || 'Titre 3'}
<Heading3 size={14} className="inline mr-1" /> {t('notes.heading3')}
</button>
<button
type="button"
className="format-pill-btn"
onClick={() => { editor.chain().focus().toggleBlockquote().run(); onClose() }}
>
<Quote size={14} className="inline mr-1" /> {t('mobile.quote') || 'Citation'}
<Quote size={14} className="inline mr-1" /> {t('mobile.quote')}
</button>
</div>
</div>

View File

@@ -7,6 +7,7 @@ import {
Heading, Code, Sparkles, MessageSquare, Quote, AlignLeft
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { useLanguage } from '@/lib/i18n'
export type MobileEditorToolbarProps = {
editor: Editor | null
@@ -19,6 +20,7 @@ export function MobileEditorToolbar({
onOpenActionSheet,
onInsertImage,
}: MobileEditorToolbarProps) {
const { t } = useLanguage()
if (!editor) return null
// Format states
@@ -61,7 +63,7 @@ export function MobileEditorToolbar({
type="button"
className={cn('mobile-toolbar-btn', isBold && 'active')}
onClick={() => editor.chain().focus().toggleBold().run()}
aria-label="Bold"
aria-label={t('editor.bold')}
>
<Bold size={18} />
</button>
@@ -70,7 +72,7 @@ export function MobileEditorToolbar({
type="button"
className={cn('mobile-toolbar-btn', isItalic && 'active')}
onClick={() => editor.chain().focus().toggleItalic().run()}
aria-label="Italic"
aria-label={t('editor.italic')}
>
<Italic size={18} />
</button>
@@ -79,7 +81,7 @@ export function MobileEditorToolbar({
type="button"
className={cn('mobile-toolbar-btn', isHighlight && 'active')}
onClick={() => editor.chain().focus().toggleHighlight().run()}
aria-label="Highlight"
aria-label={t('editor.highlight')}
>
<Highlighter size={18} />
</button>
@@ -88,7 +90,7 @@ export function MobileEditorToolbar({
type="button"
className={cn('mobile-toolbar-btn', isLink && 'active')}
onClick={handleLinkPress}
aria-label="Link"
aria-label={t('editor.link')}
>
<Link2 size={18} />
</button>
@@ -97,7 +99,7 @@ export function MobileEditorToolbar({
type="button"
className={cn('mobile-toolbar-btn', isBulletList && 'active')}
onClick={() => editor.chain().focus().toggleBulletList().run()}
aria-label="Bullet List"
aria-label={t('editor.bulletList')}
>
<List size={18} />
</button>
@@ -106,7 +108,7 @@ export function MobileEditorToolbar({
type="button"
className={cn('mobile-toolbar-btn', isTaskList && 'active')}
onClick={() => editor.chain().focus().toggleTaskList().run()}
aria-label="Task List"
aria-label={t('editor.taskList')}
>
<CheckSquare size={18} />
</button>
@@ -115,7 +117,7 @@ export function MobileEditorToolbar({
type="button"
className={cn('mobile-toolbar-btn', isHeading && 'active')}
onClick={toggleHeadingCycle}
aria-label="Heading"
aria-label={t('editor.heading')}
>
<Heading size={18} />
</button>
@@ -124,7 +126,7 @@ export function MobileEditorToolbar({
type="button"
className={cn('mobile-toolbar-btn', isCodeBlock && 'active')}
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
aria-label="Code Block"
aria-label={t('editor.codeBlock')}
>
<Code size={18} />
</button>
@@ -133,7 +135,7 @@ export function MobileEditorToolbar({
type="button"
className="mobile-toolbar-btn highlight-btn"
onClick={onOpenActionSheet}
aria-label="Plus"
aria-label={t('editor.plus')}
>
<Sparkles size={18} className="text-amber-500 animate-pulse" />
</button>

View File

@@ -1,8 +1,8 @@
'use client'
import { useEffect, useRef } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import * as d3 from 'd3'
import { Maximize2 } from 'lucide-react'
import { Maximize2, Search, ChevronDown } from 'lucide-react'
interface Note {
id: string
@@ -35,8 +35,15 @@ interface NetworkGraphProps {
untitledLabel?: string
resetFocusLabel?: string
fitViewLabel?: string
legendFilterPlaceholder?: string
legendShowMoreLabel?: string
legendShowLessLabel?: string
/** Nombre de pastilles visibles avant « voir plus » (défaut 8) */
legendPreviewCount?: number
}
const DEFAULT_LEGEND_PREVIEW = 8
export function NetworkGraph({
notes,
clusters,
@@ -47,10 +54,45 @@ export function NetworkGraph({
untitledLabel = 'Untitled',
resetFocusLabel = 'Reset focus',
fitViewLabel = 'Fit view',
legendFilterPlaceholder = 'Filter themes…',
legendShowMoreLabel = 'Show {count} more',
legendShowLessLabel = 'Show less',
legendPreviewCount = DEFAULT_LEGEND_PREVIEW,
}: NetworkGraphProps) {
const svgRef = useRef<SVGSVGElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const zoomRef = useRef<any>(null)
const [legendExpanded, setLegendExpanded] = useState(false)
const [legendFilter, setLegendFilter] = useState('')
const sortedClusters = useMemo(
() => [...clusters].sort((a, b) => b.noteIds.length - a.noteIds.length),
[clusters]
)
const filteredClusters = useMemo(() => {
const q = legendFilter.trim().toLowerCase()
if (!q) return sortedClusters
return sortedClusters.filter(c =>
(c.name ?? String(c.id)).toLowerCase().includes(q)
)
}, [sortedClusters, legendFilter])
const visibleLegendClusters = useMemo(() => {
if (legendExpanded || legendFilter.trim()) return filteredClusters
const preview = filteredClusters.slice(0, legendPreviewCount)
// Toujours garder le cluster sélectionné visible même hors top N
if (
selectedClusterId &&
!preview.some(c => String(c.id) === selectedClusterId)
) {
const selected = filteredClusters.find(c => String(c.id) === selectedClusterId)
if (selected) return [selected, ...preview.slice(0, Math.max(0, legendPreviewCount - 1))]
}
return preview
}, [filteredClusters, legendExpanded, legendFilter, legendPreviewCount, selectedClusterId])
const hiddenLegendCount = Math.max(0, filteredClusters.length - visibleLegendClusters.length)
useEffect(() => {
if (!svgRef.current || !containerRef.current) return
@@ -362,15 +404,36 @@ export function NetworkGraph({
return (
<div ref={containerRef} className="w-full h-full bg-paper dark:bg-[#121212] rounded-3xl overflow-hidden border border-border/40 relative">
{/* Pastilles de cluster — triées par taille, avec compteurs */}
<div className="absolute top-6 left-6 z-10 flex flex-wrap gap-1.5 max-w-[85%]">
{[...clusters]
.sort((a, b) => b.noteIds.length - a.noteIds.length)
.map(c => {
{/* Pastilles de cluster — aperçu compact + filtre quand élargi */}
<div className="absolute top-6 start-6 z-10 flex flex-col gap-2 max-w-[min(420px,85%)]">
{(legendExpanded || sortedClusters.length > legendPreviewCount) && (
<div className="relative">
<Search
size={12}
className="absolute start-2.5 top-1/2 -translate-y-1/2 text-concrete pointer-events-none"
aria-hidden
/>
<input
type="search"
value={legendFilter}
onChange={e => {
setLegendFilter(e.target.value)
if (e.target.value.trim()) setLegendExpanded(true)
}}
placeholder={legendFilterPlaceholder}
aria-label={legendFilterPlaceholder}
className="w-full max-w-[260px] bg-white/95 dark:bg-black/85 border border-border/40 rounded-full ps-8 pe-3 py-1.5 text-[10px] outline-none focus:ring-2 focus:ring-ochre/30 shadow-sm backdrop-blur-sm placeholder:text-concrete/70"
/>
</div>
)}
<div className="flex flex-wrap gap-1.5 max-h-[28vh] overflow-y-auto custom-scrollbar pe-1">
{visibleLegendClusters.map(c => {
const isSelected = String(c.id) === selectedClusterId
return (
<button
key={c.id}
type="button"
onClick={() => onClusterSelect?.(isSelected ? null : String(c.id))}
className={`flex items-center gap-1.5 px-2.5 py-1 rounded-full border transition-all text-[9px] font-bold uppercase tracking-wider cursor-pointer focus-visible:ring-2 focus-visible:ring-ochre/50 focus-visible:outline-none ${
isSelected
@@ -386,19 +449,43 @@ export function NetworkGraph({
</button>
)
})}
{selectedClusterId && (
<button
type="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"
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 cursor-pointer"
>
{resetFocusLabel}
</button>
)}
{!legendFilter.trim() && (hiddenLegendCount > 0 || legendExpanded) && filteredClusters.length > legendPreviewCount && (
<button
type="button"
onClick={() => {
setLegendExpanded(v => !v)
if (legendExpanded) setLegendFilter('')
}}
className="flex items-center gap-1 px-2.5 py-1 rounded-full border border-dashed border-border/60 bg-white/80 dark:bg-black/70 text-concrete hover:text-ink text-[9px] font-bold uppercase tracking-wider shadow-sm cursor-pointer focus-visible:ring-2 focus-visible:ring-ochre/50 focus-visible:outline-none"
>
<ChevronDown
size={11}
className={`transition-transform ${legendExpanded ? 'rotate-180' : ''}`}
aria-hidden
/>
{legendExpanded
? legendShowLessLabel
: legendShowMoreLabel.replace('{count}', String(hiddenLegendCount))}
</button>
)}
</div>
</div>
{/* Fit view button */}
<button
onClick={handleFitView}
className="absolute top-6 right-6 z-10 flex items-center gap-1.5 px-3 py-1.5 rounded-full border border-border/40 bg-white/90 dark:bg-black/80 text-concrete hover:text-ink dark:hover:text-dark-ink hover:border-concrete/40 text-[9px] font-bold uppercase tracking-wider transition-all shadow-sm cursor-pointer focus-visible:ring-2 focus-visible:ring-ochre/50 focus-visible:outline-none backdrop-blur-sm"
className="absolute top-6 end-6 z-10 flex items-center gap-1.5 px-3 py-1.5 rounded-full border border-border/40 bg-white/90 dark:bg-black/80 text-concrete hover:text-ink dark:hover:text-dark-ink hover:border-concrete/40 text-[9px] font-bold uppercase tracking-wider transition-all shadow-sm cursor-pointer focus-visible:ring-2 focus-visible:ring-ochre/50 focus-visible:outline-none backdrop-blur-sm"
aria-label={fitViewLabel}
>
<Maximize2 size={11} />

View File

@@ -144,7 +144,7 @@ export function NoteGraphView({ embedded = false }: { embedded?: boolean }) {
useEffect(() => {
setLoading(true)
fetch('/api/graph')
.then(r => { if (!r.ok) throw new Error('Erreur réseau'); return r.json() })
.then(r => { if (!r.ok) throw new Error(t('errors.network')); return r.json() })
.then(d => setRawData(d))
.catch(e => setError(e.message))
.finally(() => setLoading(false))

View File

@@ -189,7 +189,7 @@ function NoteGridThumbnail({
await updateNote(note.id, { illustrationSvg: null })
await onNoteIllustrationDeleted?.(note.id)
} catch {
toast.error('Erreur lors de la suppression')
toast.error(t('notes.deleteError'))
} finally {
setBusy(false)
}

View File

@@ -42,7 +42,7 @@ export function OrganizeNotebookDialog({
const handleAnalyze = useCallback(async () => {
setStep('analyzing'); setError(null); setPlan(null)
const res = await analyzeNotebookForOrganization(notebookId, language)
if (!res.success || !res.plan) { setError(res.error ?? 'Erreur'); setStep('idle'); return }
if (!res.success || !res.plan) { setError(res.error ?? t('common.error')); setStep('idle'); return }
setPlan(res.plan)
setEditableGroups(res.plan.groups.map(g => ({ ...g, notes: [...g.notes] })))
setExpandedGroups(new Set(res.plan.groups.map((_, i) => i)))
@@ -74,7 +74,7 @@ export function OrganizeNotebookDialog({
groups: editableGroups.filter(g => g.notes.length > 0 && g.name.trim()),
}
const res = await executeNotebookOrganization(finalPlan)
if (!res.success) { setError(res.error ?? 'Erreur'); setStep('preview'); return }
if (!res.success) { setError(res.error ?? t('common.error')); setStep('preview'); return }
setResult({ created: res.created, moved: res.moved })
setStep('done')
toast.success(t('organizeNotebook.toastSuccess', { created: res.created, moved: res.moved }))

View File

@@ -9,6 +9,7 @@ import {
import { cn } from '@/lib/utils'
import { MarkdownContent } from '@/components/markdown-content'
import { useAiConsent } from '@/components/legal/ai-consent-provider'
import { useLanguage } from '@/lib/i18n'
// ── Personas definition ──────────────────────────────────────────────────────
@@ -87,6 +88,7 @@ interface PersonasPanelProps {
export function PersonasPanel({ noteTitle, noteContent }: PersonasPanelProps) {
const { requestAiConsent } = useAiConsent()
const { t } = useLanguage()
const [loadingId, setLoadingId] = useState<PersonaId | null>(null)
const [results, setResults] = useState<Map<PersonaId, PersonaResult>>(new Map())
const [expanded, setExpanded] = useState<PersonaId | null>(null)
@@ -122,7 +124,7 @@ export function PersonasPanel({ noteTitle, noteContent }: PersonasPanelProps) {
body: JSON.stringify({ content: noteContent, title: noteTitle, personaId }),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error || 'Erreur')
if (!res.ok) throw new Error(data.error || t('common.error'))
setResults(prev => new Map(prev).set(personaId, data))
setExpanded(personaId)
} catch {

View File

@@ -37,9 +37,9 @@ export function ProvidersWrapper({
return (
<QueryProvider>
<NoteRefreshProvider>
<LanguageProvider initialLanguage={initialLanguage as any} initialTranslations={initialTranslations}>
<NotebooksProvider>
<EditorUIProvider>
<LanguageProvider initialLanguage={initialLanguage as any} initialTranslations={initialTranslations}>
<AiConsentProvider initialPersistentConsent={initialAiProcessingConsent}>
<DirWrapper>
<SearchModalProvider>
@@ -49,9 +49,9 @@ export function ProvidersWrapper({
</SearchModalProvider>
</DirWrapper>
</AiConsentProvider>
</LanguageProvider>
</EditorUIProvider>
</NotebooksProvider>
</LanguageProvider>
</NoteRefreshProvider>
</QueryProvider>
)

View File

@@ -112,7 +112,7 @@ export function BillingHistory() {
{error && (
<p className="text-xs text-rose-500 text-center py-4">
Impossible de charger l'historique de facturation.
{t('billing.fetchInvoicesFailed')}
</p>
)}
@@ -152,7 +152,7 @@ export function BillingHistory() {
target="_blank"
rel="noreferrer"
className="inline-flex items-center justify-center p-2 rounded-lg text-concrete hover:text-brand-accent hover:bg-brand-accent/10 transition-all"
title="Download PDF"
title={t('billing.downloadPdf')}
>
<Download size={14} />
</a>

View File

@@ -62,7 +62,7 @@ export function BillingPlans() {
queryFn: async () => {
const search = typeof window !== 'undefined' ? window.location.search : '';
const res = await fetch(`/api/billing/status${search}`);
if (!res.ok) throw new Error('Failed to fetch billing status');
if (!res.ok) throw new Error(t('billing.fetchStatusFailed'));
return res.json();
},
});
@@ -71,7 +71,7 @@ export function BillingPlans() {
queryKey: ['usage', 'current'],
queryFn: async () => {
const res = await fetch('/api/usage/current');
if (!res.ok) throw new Error('Failed to fetch quotas');
if (!res.ok) throw new Error(t('billing.fetchQuotasFailed'));
return res.json();
},
refetchInterval: 30000,
@@ -122,7 +122,7 @@ export function BillingPlans() {
}
} catch (err) {
console.error('[BillingPlans] checkout error:', err);
toast.error('Failed to start checkout. Please try again.');
toast.error(t('billing.checkoutFailed'));
} finally {
setCheckoutLoading(null);
}
@@ -139,20 +139,20 @@ export function BillingPlans() {
});
const data = await res.json();
if (!res.ok) {
toast.error(data.error || 'Failed to open billing portal.');
toast.error(data.error || t('billing.portalFailed'));
return;
}
window.location.href = data.url;
} catch (err) {
console.error('[BillingPlans] portal error:', err);
toast.error('Failed to open billing portal.');
toast.error(t('billing.portalFailed'));
} finally {
setPortalLoading(false);
}
};
const handleCancelSubscription = async () => {
const confirmMsg = t('billing.cancelConfirm') || "Êtes-vous sûr de vouloir résilier votre abonnement ? Vous conserverez vos accès Pro/Business jusqu'à la fin de la période en cours.";
const confirmMsg = t('billing.cancelConfirm');
if (!window.confirm(confirmMsg)) {
return;
}
@@ -165,15 +165,15 @@ export function BillingPlans() {
});
const data = await res.json();
if (!res.ok) {
toast.error(data.error || 'Failed to cancel subscription.');
toast.error(data.error || t('billing.cancelFailed'));
return;
}
toast.success(t('billing.cancelSuccess') || "Votre abonnement a été résilié avec succès. Il prendra fin à la fin de la période de facturation en cours.");
toast.success(t('billing.cancelSuccess'));
queryClient.invalidateQueries({ queryKey: ['billing', 'status'] });
queryClient.invalidateQueries({ queryKey: ['usage', 'current'] });
} catch (err) {
console.error('[BillingPlans] cancel error:', err);
toast.error('Failed to cancel subscription.');
toast.error(t('billing.cancelFailed'));
} finally {
setCancelLoading(false);
}
@@ -229,7 +229,7 @@ export function BillingPlans() {
name: t('billing.proPlan'),
price: status?.prices?.PRO?.[interval]?.display ??
(interval === 'month' ? (t('billing.proPrice') || '9,90€') : (t('billing.proAnnualPrice') || '99€')),
period: interval === 'month' ? '/mois' : '/an',
period: interval === 'month' ? t('billing.perMonth') : t('billing.perYear'),
description: t('billing.proDescription') || 'Pour les consultants et créateurs exigeants.',
features: [
t('billing.proFeature1') || 'Notes illimitées',
@@ -252,8 +252,7 @@ export function BillingPlans() {
name: t('billing.businessPlan'),
price: status?.prices?.BUSINESS?.[interval]?.display ??
(interval === 'month' ? (t('billing.businessPrice') || '29,90€') : (t('billing.businessAnnualPrice') || '299€')),
period: interval === 'month' ? '/mois' : '/an',
description: t('billing.businessDescription') || 'Pour les équipes et chefs de produit.',
period: interval === 'month' ? t('billing.perMonth') : t('billing.perYear'),
features: [
t('billing.businessFeature1') || '10 Collaborateurs inclus',
t('billing.businessFeature2') || 'BYOK (13 fournisseurs)',
@@ -560,7 +559,7 @@ export function BillingPlans() {
)}
>
{t('billing.annual')}
<span className="ms-1 text-primary/80 dark:text-primary">{t('billing.save')} ~17%</span>
<span className="ms-1 text-primary/80 dark:text-primary">{t('billing.savePercent')}</span>
</button>
</div>
) : (

View File

@@ -103,7 +103,7 @@ export function InlinePaywall({ feature, onDismiss }: InlinePaywallProps) {
</button>
<div className="sm:ml-auto flex items-center justify-center gap-1.5 text-[10px] text-rose-500/60 dark:text-rose-400/40 font-mono">
<span>Fermeture dans {timeLeft}s</span>
<span>{t('quotaPaywall.closingIn').replace('{n}', String(timeLeft))}</span>
</div>
</div>
</motion.div>

View File

@@ -12,7 +12,6 @@ import {
BookMarked,
Bot,
Inbox,
CalendarDays,
FlaskConical,
ArrowUpDown,
Archive,
@@ -36,12 +35,13 @@ import {
FileText,
Folder,
FolderOpen,
LayoutGrid,
} from 'lucide-react'
import { useSearchModal } from '@/context/search-modal-context'
import { useLanguage } from '@/lib/i18n'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { applyDocumentTheme } from '@/lib/apply-document-theme'
import { getAllNotes, getTrashCount, getNotesWithReminders, toggleReminderDone } from '@/app/actions/notes'
import { getAllNotes, getTrashCount, getNotesWithReminders, toggleReminderDone, getInboxCount } from '@/app/actions/notes'
import { NOTE_CHANGE_EVENT, type NoteChangeEvent } from '@/lib/note-change-sync'
import { useNotebooks } from '@/context/notebooks-context'
import { Notebook, Note } from '@/lib/types'
@@ -63,11 +63,18 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { performSignOut } from '@/lib/auth-client'
import { isDashboardHomeRoute } from '@/lib/dashboard/home-route'
import { useBrainstormSessions, useDeleteBrainstorm } from '@/hooks/use-brainstorm'
type NavigationView = 'notebooks' | 'agents' | 'reminders' | 'brainstorms' | 'revision' | 'insights'
type NavigationView = 'dashboard' | 'notebooks' | 'agents' | 'reminders' | 'brainstorms' | 'revision' | 'insights'
type SortOrder = 'newest' | 'oldest' | 'alpha' | 'manual'
const NOTEBOOKS_PANEL_HEIGHT_KEY = 'memento-sidebar-notebooks-height'
const NOTEBOOKS_PANEL_MIN_PX = 120
const SIDEBAR_WIDTH_KEY = 'memento-sidebar-width'
const SIDEBAR_WIDTH_MIN_PX = 280
const SIDEBAR_WIDTH_MAX_PX = 560
function NoteLink({
title,
isActive,
@@ -634,6 +641,120 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
const [showSortMenu, setShowSortMenu] = useState(false)
const [notebookSearchQuery, setNotebookSearchQuery] = useState('')
const [trashCount, setTrashCount] = useState(0)
const [inboxCount, setInboxCount] = useState(0)
const notebooksContainerRef = useRef<HTMLDivElement>(null)
const notebooksPanelRef = useRef<HTMLDivElement>(null)
const notebooksPanelHeightRef = useRef<number | null>(null)
const [notebooksPanelHeight, setNotebooksPanelHeight] = useState<number | null>(null)
const asideRef = useRef<HTMLElement>(null)
const sidebarWidthRef = useRef<number | null>(null)
const [sidebarWidth, setSidebarWidth] = useState<number | null>(null)
useEffect(() => {
try {
const stored = localStorage.getItem(NOTEBOOKS_PANEL_HEIGHT_KEY)
if (stored) {
const n = parseInt(stored, 10)
if (!Number.isNaN(n) && n >= NOTEBOOKS_PANEL_MIN_PX) {
setNotebooksPanelHeight(n)
notebooksPanelHeightRef.current = n
}
}
const storedW = localStorage.getItem(SIDEBAR_WIDTH_KEY)
if (storedW) {
const w = parseInt(storedW, 10)
if (!Number.isNaN(w) && w >= SIDEBAR_WIDTH_MIN_PX && w <= SIDEBAR_WIDTH_MAX_PX) {
setSidebarWidth(w)
sidebarWidthRef.current = w
}
}
} catch {
/* ignore */
}
}, [])
const handleNotebooksResizeStart = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
e.preventDefault()
e.stopPropagation()
const panel = notebooksPanelRef.current
const container = notebooksContainerRef.current
if (!panel || !container) return
const target = e.currentTarget
target.setPointerCapture(e.pointerId)
const startY = e.clientY
const startH = panel.getBoundingClientRect().height
const topBlock = container.querySelector('[data-sidebar-notebooks-top]') as HTMLElement | null
const handleH = 12
const containerH = container.getBoundingClientRect().height
const maxH = Math.max(
NOTEBOOKS_PANEL_MIN_PX,
containerH - (topBlock?.offsetHeight ?? 160) - handleH,
)
const onMove = (ev: PointerEvent) => {
const delta = ev.clientY - startY
const newH = Math.min(maxH, Math.max(NOTEBOOKS_PANEL_MIN_PX, startH + delta))
notebooksPanelHeightRef.current = newH
setNotebooksPanelHeight(newH)
}
const onUp = (ev: PointerEvent) => {
target.releasePointerCapture(ev.pointerId)
document.removeEventListener('pointermove', onMove)
document.removeEventListener('pointerup', onUp)
const h = notebooksPanelHeightRef.current
if (h !== null) {
try {
localStorage.setItem(NOTEBOOKS_PANEL_HEIGHT_KEY, String(h))
} catch {
/* ignore */
}
}
}
document.addEventListener('pointermove', onMove)
document.addEventListener('pointerup', onUp)
}, [])
const handleSidebarWidthResizeStart = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
e.preventDefault()
e.stopPropagation()
const aside = asideRef.current
if (!aside) return
const target = e.currentTarget
target.setPointerCapture(e.pointerId)
document.body.style.cursor = 'col-resize'
document.body.style.userSelect = 'none'
const startX = e.clientX
const startW = aside.getBoundingClientRect().width
const onMove = (ev: PointerEvent) => {
const delta = isRtl ? startX - ev.clientX : ev.clientX - startX
const newW = Math.min(SIDEBAR_WIDTH_MAX_PX, Math.max(SIDEBAR_WIDTH_MIN_PX, startW + delta))
sidebarWidthRef.current = newW
setSidebarWidth(newW)
}
const onUp = (ev: PointerEvent) => {
target.releasePointerCapture(ev.pointerId)
document.body.style.cursor = ''
document.body.style.userSelect = ''
document.removeEventListener('pointermove', onMove)
document.removeEventListener('pointerup', onUp)
const w = sidebarWidthRef.current
if (w !== null) {
try {
localStorage.setItem(SIDEBAR_WIDTH_KEY, String(w))
} catch {
/* ignore */
}
}
}
document.addEventListener('pointermove', onMove)
document.addEventListener('pointerup', onUp)
}, [isRtl])
const [draggedId, setDraggedId] = useState<string | null>(null)
// Ref stable pour éviter les stale closures dans les handlers de drop
@@ -686,8 +807,11 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
})
}, [currentNotebookId, notebooks])
const isDashboardRoute = isDashboardHomeRoute(pathname, searchParams)
const isInboxActive =
pathname === '/home' &&
searchParams.get('forceList') === '1' &&
!searchParams.get('notebook') &&
!searchParams.get('labels') &&
!searchParams.get('archived') &&
@@ -699,8 +823,9 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
else if (pathname === '/insights') setActiveView('insights')
else if (pathname.startsWith('/revision')) setActiveView('revision')
else if (searchParams.get('reminders') === '1' && pathname === '/home') setActiveView('reminders')
else if (isDashboardRoute) setActiveView('dashboard')
else if (pathname === '/home' || pathname.startsWith('/notes')) setActiveView('notebooks')
}, [pathname, searchParams])
}, [pathname, searchParams, isDashboardRoute])
const isRemindersRoute = pathname === '/home' && searchParams.get('reminders') === '1'
const isSharedRoute = pathname === '/home' && searchParams.get('shared') === '1'
@@ -730,7 +855,8 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
(pathname === '/home' || pathname.startsWith('/notes')) &&
!pathname.startsWith('/settings') &&
!isRemindersRoute &&
!isSharedRoute
!isSharedRoute &&
!isDashboardRoute
const displayName = user?.name || user?.email || ''
const initial = displayName ? displayName.charAt(0).toUpperCase() : '?'
@@ -754,6 +880,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
useEffect(() => {
let cancelled = false
getTrashCount().then(count => { if (!cancelled) setTrashCount(count) })
getInboxCount().then((count: number) => { if (!cancelled) setInboxCount(count) })
return () => { cancelled = true }
}, [])
@@ -784,6 +911,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
useEffect(() => {
const onNoteChange = (e: Event) => {
const detail = (e as CustomEvent<NoteChangeEvent>).detail
getInboxCount().then((count: number) => setInboxCount(count))
if (detail.type === 'deleted') {
setNotebookNotes((prev) => {
const next = { ...prev }
@@ -855,21 +983,6 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
router.push('/home?forceList=1')
}
const handleDailyNoteClick = async () => {
try {
const res = await fetch('/api/notes/daily')
const data = await res.json()
if (data.success && data.note) {
const params = new URLSearchParams()
if (data.note.notebookId) params.set('notebook', data.note.notebookId)
params.set('openNote', data.note.id)
router.push(`/home?${params.toString()}`)
}
} catch {
toast.error(t('sidebar.dailyNoteError') || 'Impossible d\'ouvrir la note du jour')
}
}
const handleNoteClick = (noteId: string, notebookId: string) => {
const params = new URLSearchParams(searchParams.toString())
params.set('notebook', notebookId)
@@ -1180,21 +1293,35 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
</AnimatePresence>
<aside
ref={asideRef}
style={sidebarWidth !== null ? { width: sidebarWidth } : undefined}
className={cn(
// Sur /insights replié : fixed hors flux pour que <main flex-1> s'agrandisse.
// Ne jamais ajouter un `relative` nu ensuite — twMerge le ferait gagner sur `fixed`
// et réserverait toujours l'espace (bande beige vide).
isImmersiveRoute && userCollapsed
? 'fixed inset-y-0 start-0 z-[70]'
: 'fixed inset-y-0 start-0 z-[70] md:relative md:z-auto',
'h-full min-h-0 w-80 md:w-[26rem] 2xl:w-[30rem] shrink-0 flex flex-row overflow-hidden self-stretch',
? cn(
'fixed inset-y-0 start-0 z-[70]',
isRtl ? 'translate-x-full' : '-translate-x-full',
'pointer-events-none',
sidebarWidth === null && 'w-80 md:w-[26rem] 2xl:w-[30rem]',
)
: cn(
'fixed inset-y-0 start-0 z-[70] md:relative md:z-auto',
sidebarWidth === null && 'w-80 md:w-[26rem] 2xl:w-[30rem]',
isMobileOpen
? 'translate-x-0 shadow-2xl'
: cn(isRtl ? 'translate-x-full md:translate-x-0' : '-translate-x-full md:translate-x-0'),
),
'h-full min-h-0 shrink-0 flex flex-row self-stretch overflow-visible',
'transition-transform duration-300 ease-in-out',
isImmersiveRoute && userCollapsed
? '-translate-x-full'
: (isMobileOpen ? 'translate-x-0 shadow-2xl' : '-translate-x-full md:translate-x-0'),
'border-e border-border/40 bg-white/95 md:bg-white/30 backdrop-blur-md sidebar-shadow dark:border-white/6 dark:bg-[#151515] dark:backdrop-blur-none',
className
)}
aria-hidden={isImmersiveRoute && userCollapsed ? true : undefined}
>
{/* ── Column 1 : Rail d'icônes (54px) — inspiré du prototype ── */}
<div className="w-[54px] border-e border-border/40 bg-[#FAF9F5] dark:bg-[#0E0E0E] flex flex-col items-center justify-between py-5 shrink-0 select-none">
<div className="w-[54px] border-e border-border/40 bg-[#FAF9F5] dark:bg-[#0E0E0E] flex flex-col items-center justify-between py-5 shrink-0 select-none overflow-hidden">
{/* Top : Logo + navigation */}
<div className="flex flex-col items-center gap-[18px] w-full">
@@ -1234,6 +1361,16 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
{/* Boutons de navigation principaux */}
<div className="flex flex-col gap-2 w-full px-1.5">
{([
{
id: 'dashboard',
icon: LayoutGrid,
label: t('nav.dashboard') || 'Dashboard',
onClick: () => {
setActiveView('dashboard')
router.push('/home')
},
isActive: isDashboardRoute,
},
{
id: 'notebooks',
icon: BookOpen,
@@ -1401,19 +1538,99 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
<div className="flex-1 h-full flex flex-col overflow-hidden bg-[#FCFCFA] dark:bg-[#111111]">
{/* ── Scrollable content ── */}
<div className="flex-1 flex flex-col min-h-0 overflow-y-auto space-y-6 -mx-0 custom-scrollbar pb-4">
<div
className={cn(
'flex-1 flex flex-col min-h-0 -mx-0 pb-4',
activeView === 'notebooks'
? 'overflow-hidden'
: 'overflow-y-auto custom-scrollbar space-y-6',
)}
>
<AnimatePresence mode="wait">
{activeView === 'notebooks' ? (
{activeView === 'dashboard' ? (
<motion.div
key="dashboard"
initial={{ opacity: 0, x: isRtl ? 10 : -10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: isRtl ? -10 : 10 }}
transition={{ duration: 0.2 }}
className="px-4 pt-4 space-y-4"
>
<div className="flex items-center gap-1.5">
<LayoutGrid size={14} className="text-brand-accent" />
<h3 className="text-xs font-black tracking-widest uppercase text-ink dark:text-dark-ink">
{t('nav.dashboard')}
</h3>
</div>
<p className="text-[12px] leading-relaxed text-muted-foreground">
{t('sidebar.dashboardPanelBody')}
</p>
<div className="space-y-1.5">
<button
type="button"
onClick={handleInboxClick}
className="w-full flex items-center justify-between gap-2 px-3 py-2.5 rounded-xl border border-border/40 bg-white/60 dark:bg-zinc-800/40 hover:border-brand-accent/30 hover:bg-brand-accent/5 transition-all text-[12px] font-medium text-foreground"
>
<span className="flex items-center gap-2 min-w-0">
<Inbox size={14} className="text-brand-accent shrink-0" />
{t('homeDashboard.inbox')}
</span>
{inboxCount > 0 && (
<span className="text-[10px] font-mono font-bold text-brand-accent bg-brand-accent/10 px-1.5 py-0.5 rounded shrink-0">
{inboxCount}
</span>
)}
</button>
<button
type="button"
onClick={() => router.push('/revision')}
className="w-full flex items-center gap-2 px-3 py-2.5 rounded-xl border border-border/40 bg-white/60 dark:bg-zinc-800/40 hover:border-brand-accent/30 hover:bg-brand-accent/5 transition-all text-[12px] font-medium text-foreground"
>
<GraduationCap size={14} className="text-brand-accent shrink-0" />
{t('homeDashboard.review')}
</button>
<button
type="button"
onClick={handleRemindersClick}
className="w-full flex items-center gap-2 px-3 py-2.5 rounded-xl border border-border/40 bg-white/60 dark:bg-zinc-800/40 hover:border-brand-accent/30 hover:bg-brand-accent/5 transition-all text-[12px] font-medium text-foreground"
>
<Bell size={14} className="text-brand-accent shrink-0" />
{t('homeDashboard.reminders')}
</button>
<button
type="button"
onClick={() => router.push('/insights')}
className="w-full flex items-center gap-2 px-3 py-2.5 rounded-xl border border-border/40 bg-white/60 dark:bg-zinc-800/40 hover:border-brand-accent/30 hover:bg-brand-accent/5 transition-all text-[12px] font-medium text-foreground"
>
<Sparkles size={14} className="text-brand-accent shrink-0" />
{t('homeDashboard.themes')}
</button>
<button
type="button"
onClick={() => {
setActiveView('notebooks')
router.push('/home?forceList=1')
}}
className="w-full flex items-center gap-2 px-3 py-2.5 rounded-xl border border-border/40 bg-white/60 dark:bg-zinc-800/40 hover:border-brand-accent/30 hover:bg-brand-accent/5 transition-all text-[12px] font-medium text-foreground"
>
<BookOpen size={14} className="text-brand-accent shrink-0" />
{t('sidebar.backToNotebooks')}
</button>
</div>
</motion.div>
) : activeView === 'notebooks' ? (
<motion.div
key="notebooks"
ref={notebooksContainerRef}
initial={{ opacity: 0, x: isRtl ? 10 : -10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: isRtl ? -10 : 10 }}
transition={{ duration: 0.2 }}
className="flex flex-col flex-1 min-h-0 overflow-hidden"
>
<div className="px-4 pt-4 shrink-0">
<div data-sidebar-notebooks-top className="shrink-0">
<div className="px-4 pt-4">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-1.5">
<BookMarked size={14} className="text-brand-accent" />
@@ -1508,7 +1725,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
</div>
</div>
<div className="flex-1 overflow-y-auto custom-scrollbar min-h-0 px-4 pb-4">
<div className="px-4 pb-2">
<button
type="button"
onClick={handleInboxClick}
@@ -1526,28 +1743,44 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
</div>
<span
className={cn(
'text-[13px] font-medium truncate',
'text-[13px] font-medium truncate flex-1',
isInboxActive ? 'text-ink' : 'text-muted-ink',
)}
>
{t('sidebar.inbox')}
</span>
</button>
<button
type="button"
onClick={handleDailyNoteClick}
className="sidebar-inbox-item"
title={t('sidebar.dailyNoteTooltip') || "Ouvrir ou créer la note personnelle pour le journal d'aujourd'hui"}
>
<div className="w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium border shrink-0 bg-paper dark:bg-white/5 text-muted-ink border-border">
<CalendarDays size={14} />
</div>
<span className="text-[13px] font-medium truncate text-muted-ink">
{t('sidebar.dailyNote') || 'Note du jour'}
{inboxCount > 0 && (
<span className={cn(
'text-[10px] font-bold min-w-[18px] h-[18px] px-1 flex items-center justify-center rounded-full transition-colors',
isInboxActive
? 'bg-brand-accent text-white'
: 'bg-brand-accent/10 text-brand-accent',
)}>
{inboxCount > 99 ? '99+' : inboxCount}
</span>
)}
</button>
</div>
</div>
<div
role="separator"
aria-orientation="horizontal"
aria-label={t('sidebar.resizeNotebooksPanel')}
onPointerDown={handleNotebooksResizeStart}
className="flex shrink-0 h-3 mx-3 cursor-row-resize touch-none items-center justify-center group"
>
<div className="h-1 w-10 rounded-full bg-border/70 group-hover:bg-brand-accent/60 group-active:bg-brand-accent transition-colors" />
</div>
<div
ref={notebooksPanelRef}
className={cn(
'overflow-y-auto custom-scrollbar min-h-0 px-4 pb-4 shrink-0',
notebooksPanelHeight === null && 'flex-1',
)}
style={notebooksPanelHeight !== null ? { height: notebooksPanelHeight } : undefined}
>
<div className="my-3 h-px bg-border/40" />
{/* Zone de dépôt racine — toujours présente pour éviter tout décalage DOM */}
@@ -1722,6 +1955,17 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
</div>
</div>{/* fin colonne 2 */}
{/* Poignée redimensionnement largeur sidebar (bord vers le contenu) */}
<div
role="separator"
aria-orientation="vertical"
aria-label={t('sidebar.resizeSidebar')}
onPointerDown={handleSidebarWidthResizeStart}
className="hidden md:flex absolute inset-y-0 end-0 translate-x-1/2 w-4 cursor-col-resize touch-none z-30 items-center justify-center group"
>
<div className="h-10 w-1 rounded-full bg-border/80 group-hover:bg-brand-accent/70 group-active:bg-brand-accent transition-colors shadow-sm" />
</div>
</aside>
<CreateNotebookDialog

View File

@@ -6,6 +6,7 @@ import { NoteChartFromCode } from './note-chart'
import { useState } from 'react'
import { Code, BarChart3, AlertCircle } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useLanguage } from '@/lib/i18n'
/**
* ChartExtension - TipTap Node extension for rendering chart blocks
@@ -107,6 +108,7 @@ export const ChartExtension = Node.create({
* - Error handling for invalid chart data
*/
function ChartBlockView(props: any) {
const { t } = useLanguage()
const [isEditing, setIsEditing] = useState(false)
const [parseError, setParseError] = useState(false)
@@ -120,7 +122,7 @@ function ChartBlockView(props: any) {
<NodeViewWrapper className="notion-chart-code-block my-4">
<div className="flex items-center gap-2 px-3 py-2 bg-muted/50 rounded-t-lg border-b border-border">
<Code className="w-4 h-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">Chart Code</span>
<span className="text-sm text-muted-foreground">{t('chart.code')}</span>
<button
onClick={() => setIsEditing(false)}
className="ml-auto text-xs px-2 py-1 bg-primary text-primary-foreground rounded hover:opacity-90 transition-opacity"
@@ -144,8 +146,8 @@ function ChartBlockView(props: any) {
<div className="flex items-center gap-3 text-muted-foreground">
<AlertCircle className="w-5 h-5" />
<div>
<p className="font-medium">Invalid Chart</p>
<p className="text-sm">This chart block contains invalid or empty data.</p>
<p className="font-medium">{t('chart.invalid')}</p>
<p className="text-sm">{t('chart.invalidDescription')}</p>
</div>
</div>
</div>
@@ -158,7 +160,7 @@ function ChartBlockView(props: any) {
'absolute top-2 right-2 p-2 rounded-lg bg-background/80 backdrop-blur border border-border shadow-sm opacity-0 group-hover:opacity-100 transition-opacity',
'hover:bg-background hover:shadow-md'
)}
title="Edit chart source"
title={t('chart.editSource')}
>
<Code className="w-4 h-4 text-muted-foreground" />
</button>

View File

@@ -69,9 +69,15 @@ export function UsageMeter({ className }: UsageMeterProps) {
'auto_title',
'reformulate',
'chat',
'brainstorm_create', // Sera affiché comme "Sessions brainstorm"
'brainstorm_create',
'brainstorm_expand',
'brainstorm_enrich',
'suggest_charts',
'publish_enhance',
'ai_flashcard',
'voice_transcribe',
'slide_generate',
'excalidraw_generate',
]);
const featureLabels: Record<string, string> = {
@@ -80,9 +86,15 @@ export function UsageMeter({ className }: UsageMeterProps) {
auto_title: t('usageMeter.featureTitles'),
reformulate: t('usageMeter.featureReformulate'),
chat: t('usageMeter.featureChat'),
brainstorm_create: t('usageMeter.featureBrainstormSessions'), // Label simplifié
brainstorm_create: t('usageMeter.featureBrainstormSessions'),
brainstorm_expand: t('usageMeter.featureBrainstormExpand'),
brainstorm_enrich: t('usageMeter.featureBrainstormEnrich'),
suggest_charts: t('usageMeter.featureCharts'),
publish_enhance: t('usageMeter.featurePublishEnhance'),
ai_flashcard: t('usageMeter.featureFlashcards'),
voice_transcribe: t('usageMeter.featureVoice'),
slide_generate: t('usageMeter.featureSlides'),
excalidraw_generate: t('usageMeter.featureDiagrams'),
};
const featureQuotas = Object.entries(data.quotas)

View File

@@ -10,6 +10,7 @@ import { getNoteById } from '@/app/actions/notes'
import { useNoteRefresh } from './NoteRefreshContext'
import { toast } from 'sonner'
import { queryKeys } from '@/lib/query-keys'
import { useLanguage } from '@/lib/i18n'
// ===== INPUT TYPES =====
export interface CreateNotebookInput {
@@ -101,6 +102,7 @@ interface NotebooksProviderProps {
export function NotebooksProvider({ children, initialNotebooks = [] }: NotebooksProviderProps) {
// ===== BASE STATE =====
const queryClient = useQueryClient()
const { t } = useLanguage()
const [notebooks, setNotebooks] = useState<Notebook[]>(initialNotebooks)
const [currentNotebook, setCurrentNotebook] = useState<Notebook | null>(null)
const [isLoading, setIsLoading] = useState(true)
@@ -401,14 +403,12 @@ export function NotebooksProvider({ children, initialNotebooks = [] }: Notebooks
emitNoteChange({ type: 'updated', note: movedNote })
}
} catch (error) {
toast.error('Failed to move note. Please try again.')
toast.error(t('notes.moveFailed'))
throw error
} finally {
setIsMovingNote(false)
}
}, [queryClient, triggerNotebooksRefresh])
// ===== ACTIONS: AI (STUBS) =====
}, [queryClient, triggerNotebooksRefresh, t])
const suggestNotebookForNote = useCallback(async (_noteContent: string) => {
// Stub pour l'instant - retourne null
return null

View File

@@ -117,17 +117,48 @@ fi
# --- Background cron scheduler ---
(
sleep 60
CRON_SECRET="${CRON_SECRET:-}"
tick=0
while true; do
node -e "
if [ -n "$CRON_SECRET" ]; then
CRON_TICK="$tick" node -e "
const http = require('http');
const req = http.request('http://localhost:3000/api/cron/agents', { method: 'POST' }, (res) => {
const secret = process.env.CRON_SECRET || '';
const tick = Number(process.env.CRON_TICK || '0');
const call = (path, method) => new Promise((resolve) => {
const req = http.request({
hostname: 'localhost',
port: 3000,
path,
method: method || 'POST',
headers: { Authorization: 'Bearer ' + secret },
}, (res) => {
let body = '';
res.on('data', (d) => body += d);
res.on('end', () => console.log('[Scheduler] Cron response:', res.statusCode, body));
res.on('end', () => { console.log('[Scheduler]', path, res.statusCode, body.slice(0, 200)); resolve(); });
});
req.on('error', (e) => console.error('[Scheduler] Cron error:', e.message));
req.on('error', (e) => { console.error('[Scheduler]', path, e.message); resolve(); });
req.end();
});
(async () => {
await call('/api/cron/agents');
await call('/api/cron/agent-suggestions');
if (tick % 12 === 0) {
await call('/api/cron/reminders');
await call('/api/cron/sync-usage');
}
if (tick % 72 === 0) {
await call('/api/cron/clusters', 'GET');
}
if (tick % 288 === 0) {
await call('/api/cron/gmail');
}
})();
" 2>&1 || true
else
warn "CRON_SECRET unset — scheduled crons disabled"
fi
tick=$((tick + 1))
sleep 300
done
) &

View File

@@ -0,0 +1,135 @@
import { willUseByokForLane } from '@/lib/ai/provider-for-user'
import type { AiFeatureLane } from '@/lib/ai/router'
import { getSystemConfig } from '@/lib/config'
import {
reserveUsageOrThrow,
releaseUsage,
checkSessionEntitlementOrThrow,
QuotaExceededError,
QuotaServiceUnavailableError,
} from '@/lib/entitlements'
import type { FeatureName } from '@/lib/quota-utils'
import { emitAiUsageChanged } from '@/lib/ai-usage-sync'
export type ReserveAiUsageOptions = {
lane?: AiFeatureLane
/** Defaults to userId — host-pays brainstorm bills this account. */
billingUserId?: string
}
/** Reserve one AI unit; skips when BYOK will be used for the lane. */
export async function reserveAiUsageOrThrow(
userId: string,
feature: FeatureName,
options?: ReserveAiUsageOptions,
): Promise<{ usedByok: boolean; billedUserId: string }> {
const billedUserId = options?.billingUserId ?? userId
const lane = options?.lane ?? 'chat'
const config = await getSystemConfig()
const { usedByok } = await willUseByokForLane(lane, config, billedUserId)
if (!usedByok) {
await reserveUsageOrThrow(billedUserId, feature)
}
return { usedByok, billedUserId }
}
/** Host-pays: reserve on session owner with guest metadata on 402. */
export async function reserveSessionAiUsageOrThrow(
billingOwnerId: string,
triggeredByUserId: string,
isGuestActor: boolean,
feature: FeatureName,
options?: { lane?: AiFeatureLane },
): Promise<{ usedByok: boolean }> {
const lane = options?.lane ?? 'chat'
const config = await getSystemConfig()
const { usedByok } = await willUseByokForLane(lane, config, billingOwnerId)
if (!usedByok) {
await checkSessionEntitlementOrThrow(
billingOwnerId,
triggeredByUserId,
isGuestActor,
feature,
)
}
return { usedByok }
}
/** Run fn under quota; releases the unit if fn throws (unless BYOK). */
export async function withAiQuota<T>(
userId: string,
feature: FeatureName,
fn: () => Promise<T>,
options?: ReserveAiUsageOptions,
): Promise<T> {
const { usedByok, billedUserId } = await reserveAiUsageOrThrow(userId, feature, options)
try {
const result = await fn()
if (!usedByok) {
emitAiUsageChanged()
}
return result
} catch (err) {
if (!usedByok) {
await releaseUsage(billedUserId, feature)
}
throw err
}
}
/** Host-pays collaborative flows (brainstorm) with rollback on failure. */
export async function withSessionAiQuota<T>(
billingOwnerId: string,
triggeredByUserId: string,
isGuestActor: boolean,
feature: FeatureName,
fn: () => Promise<T>,
options?: { lane?: AiFeatureLane },
): Promise<T> {
const { usedByok } = await reserveSessionAiUsageOrThrow(
billingOwnerId,
triggeredByUserId,
isGuestActor,
feature,
options,
)
try {
const result = await fn()
if (!usedByok) {
emitAiUsageChanged()
}
return result
} catch (err) {
if (!usedByok) {
await releaseUsage(billingOwnerId, feature)
}
throw err
}
}
export function isQuotaError(err: unknown): err is QuotaExceededError {
return err instanceof QuotaExceededError
}
export function quotaExceededJson(err: QuotaExceededError) {
return err.toJSON()
}
export function quotaErrorStatus() {
return 402
}
/** Standard Next.js / Response handlers for quota errors. */
export function handleQuotaHttpError(err: unknown): Response | null {
if (err instanceof QuotaExceededError) {
return Response.json(err.toJSON(), { status: 402 })
}
if (err instanceof QuotaServiceUnavailableError) {
return Response.json({ error: err.code }, { status: 503 })
}
return null
}
export function notifyAiUsageChanged(): void {
emitAiUsageChanged()
}

View File

@@ -0,0 +1,5 @@
/** Notifie l'UI (usage-meter, dashboard) qu'un quota IA vient d'être consommé. */
export function emitAiUsageChanged(): void {
if (typeof window === 'undefined') return
window.dispatchEvent(new CustomEvent('ai-usage-changed'))
}

View File

@@ -0,0 +1,116 @@
import type { SupportedLanguage } from '@/lib/i18n/load-translations'
/** Texte de secours quand le LLM ne renvoie rien — une entrée par locale supportée. */
export const MEMORY_ECHO_INSIGHT_FALLBACKS: Record<SupportedLanguage, string> = {
en: 'These notes appear to be semantically related.',
fr: 'Ces notes semblent sémantiquement liées.',
es: 'Estas notas parecen estar relacionadas semánticamente.',
de: 'Diese Notizen scheinen semantisch miteinander verbunden zu sein.',
it: 'Queste note sembrano semanticamente collegate.',
pt: 'Estas notas parecem estar semanticamente relacionadas.',
nl: 'Deze notities lijken semantisch met elkaar verbonden.',
pl: 'Te notatki wydają się semantycznie powiązane.',
ru: 'Эти заметки кажутся семантическement связанными.',
zh: '这些笔记在语义上似乎相关。',
ja: 'これらのノートは意味的に関連しているようです。',
ko: '이 노트들은 의미적으로 관련된 것으로 보입니다.',
ar: 'يبدو أن هذه الملاحظات مرتبطة دلاليًا.',
fa: 'این یادداشت‌ها از نظر معنایی به هم مرتبط به نظر می‌رسند.',
hi: 'ये नोट्स अर्थ के हिसाब से जुड़े हुए लगते हैं।',
}
/** Anciens insights stockés en anglais — à remapper côté UI. */
export const MEMORY_ECHO_LEGACY_EN_FALLBACKS = new Set([
'These notes appear to be semantically related.',
])
const PROMPT_LANGUAGE_LABEL: Record<SupportedLanguage, string> = {
en: 'English',
fr: 'French',
es: 'Spanish',
de: 'German',
it: 'Italian',
pt: 'Portuguese',
nl: 'Dutch',
pl: 'Polish',
ru: 'Russian',
zh: 'Chinese',
ja: 'Japanese',
ko: 'Korean',
ar: 'Arabic',
fa: 'Persian',
hi: 'Hindi',
}
const UNTITLED_NOTE: Record<SupportedLanguage, string> = {
en: 'Untitled note',
fr: 'Note sans titre',
es: 'Nota sin título',
de: 'Unbenannte Notiz',
it: 'Nota senza titolo',
pt: 'Nota sem título',
nl: 'Naamloze notitie',
pl: 'Notatka bez tytułu',
ru: 'Без названия',
zh: '无标题笔记',
ja: '無題のノート',
ko: '제목 없는 노트',
ar: 'ملاحظة بدون عنوان',
fa: 'یادداشت بدون عنوان',
hi: 'शीर्षक रहित नोट',
}
export function getMemoryEchoInsightFallback(language: SupportedLanguage): string {
return MEMORY_ECHO_INSIGHT_FALLBACKS[language] ?? MEMORY_ECHO_INSIGHT_FALLBACKS.en
}
export function getUntitledNoteLabel(language: SupportedLanguage): string {
return UNTITLED_NOTE[language] ?? UNTITLED_NOTE.en
}
export function buildMemoryEchoInsightPrompt(
language: SupportedLanguage,
note1Title: string | null,
excerpt1: string,
note2Title: string | null,
excerpt2: string,
): string {
const untitled = getUntitledNoteLabel(language)
const note1Desc = note1Title || untitled
const note2Desc = note2Title || untitled
const langLabel = PROMPT_LANGUAGE_LABEL[language] ?? 'English'
if (language === 'fa' || language === 'ar') {
return `تو یک دستیار هستی که ارتباط بین یادداشت‌ها را تحلیل می‌کنی.
یادداشت ۱: «${note1Desc}»
متن: ${excerpt1}
یادداشت ۲: «${note2Desc}»
متن: ${excerpt2}
در یک جمله کوتاه (حداکثر ۱۵ کلمه) به ${language === 'fa' ? 'فارسی' : 'العربية'} توضیح بده چرا این دو یادداشت به هم مرتبط‌اند. فقط رابطه معنایی را بگو.`
}
if (language === 'fr') {
return `Tu analyses les connexions entre notes.
Note 1 : « ${note1Desc} »
Contenu : ${excerpt1}
Note 2 : « ${note2Desc} »
Contenu : ${excerpt2}
En une phrase courte (15 mots max), en français, explique pourquoi ces notes sont liées. Concentre-toi sur le lien sémantique.`
}
return `You analyze connections between notes.
Note 1: "${note1Desc}"
Content: ${excerpt1}
Note 2: "${note2Desc}"
Content: ${excerpt2}
Reply in ${langLabel} only. One brief sentence (max 15 words) explaining the semantic relationship between these notes.`
}

View File

@@ -17,6 +17,8 @@ import { extractAndDownloadImages, extractImageUrlsFromHtml, downloadImage } fro
import { calculateNextRun } from '@/lib/agents/schedule'
import { markdownToHtml } from '@/lib/markdown-to-html'
import { createNotification } from '@/app/actions/notifications'
import { withAiQuota } from '@/lib/ai-quota'
import type { FeatureName } from '@/lib/quota-utils'
// Import tools for side-effect registration
import '../tools'
@@ -25,6 +27,12 @@ import '../tools'
export type AgentType = 'scraper' | 'researcher' | 'monitor' | 'custom' | 'slide-generator' | 'excalidraw-generator' | 'task-extractor'
function quotaFeatureForAgentType(type: string): FeatureName {
if (type === 'slide-generator') return 'slide_generate'
if (type === 'excalidraw-generator') return 'excalidraw_generate'
return 'reformulate'
}
export interface AgentExecutionResult {
success: boolean
actionId: string
@@ -1759,36 +1767,45 @@ export async function executeAgent(agentId: string, userId: string, promptOverri
// Detect user language
const lang = await getUserLanguage(userId)
const quotaFeature = quotaFeatureForAgentType(agent.type || 'scraper')
try {
let result: AgentExecutionResult
const result = await withAiQuota(
userId,
quotaFeature,
async () => {
let inner: AgentExecutionResult
const hasTools = agent.tools && agent.tools !== '[]' && agent.tools !== 'null'
if (hasTools) {
result = await executeToolUseAgent(agent, action.id, lang, promptOverride)
inner = await executeToolUseAgent(agent, action.id, lang, promptOverride)
} else {
switch ((agent.type || 'scraper') as AgentType) {
case 'scraper':
result = await executeScraperAgent(agent, action.id, lang)
inner = await executeScraperAgent(agent, action.id, lang)
break
case 'researcher':
result = await executeResearcherAgent(agent, action.id, lang)
inner = await executeResearcherAgent(agent, action.id, lang)
break
case 'monitor':
result = await executeMonitorAgent(agent, action.id, lang)
inner = await executeMonitorAgent(agent, action.id, lang)
break
case 'custom':
result = await executeCustomAgent(agent, action.id, lang)
inner = await executeCustomAgent(agent, action.id, lang)
break
case 'slide-generator':
case 'excalidraw-generator':
result = await executeToolUseAgent(agent, action.id, lang, promptOverride)
inner = await executeToolUseAgent(agent, action.id, lang, promptOverride)
break
default:
result = await executeScraperAgent(agent, action.id, lang)
inner = await executeScraperAgent(agent, action.id, lang)
}
}
return inner
},
{ lane: 'chat' },
)
const nextRunUpdate: Record<string, Date | null> = {}
if (agent.frequency !== 'manual') {

View File

@@ -0,0 +1,222 @@
/**
* Agent Suggestion Service
*
* Proposes research/monitor agents from existing semantic clusters (no duplicate DBSCAN).
* Runs after clustering; compares cluster themes vs existing agents.
*/
import prisma from '@/lib/prisma'
import { clusteringService } from './clustering.service'
import { getChatProvider } from '@/lib/ai/factory'
import { getSystemConfig } from '@/lib/config'
import { calculateNextRun } from '@/lib/agents/schedule'
import { withAiQuota } from '@/lib/ai-quota'
const MIN_CLUSTER_NOTES = 3
const MAX_SUGGESTIONS_PER_RUN = 3
export interface AgentSuggestionPayload {
topic: string
reason: string
suggestedRole: string
suggestedType: string
suggestedFrequency: string
relatedNoteIds: string[]
clusterId: number
}
function parseJsonArray(raw: string | null | undefined): string[] {
if (!raw) return []
try {
const parsed = JSON.parse(raw)
return Array.isArray(parsed) ? parsed.filter((x): x is string => typeof x === 'string') : []
} catch {
return []
}
}
function clusterCoveredByAgent(
topic: string,
noteIds: string[],
agent: { name: string; role: string; description: string | null; sourceNoteIds: string | null },
): boolean {
const topicLower = topic.toLowerCase()
const haystack = `${agent.name} ${agent.role} ${agent.description || ''}`.toLowerCase()
const topicWords = topicLower.split(/\s+/).filter(w => w.length > 3)
if (topicWords.some(w => haystack.includes(w))) return true
const agentNoteIds = parseJsonArray(agent.sourceNoteIds)
if (agentNoteIds.length === 0 || noteIds.length === 0) return false
const overlap = agentNoteIds.filter(id => noteIds.includes(id)).length
return overlap / noteIds.length >= 0.4
}
async function buildSuggestionWithLlm(
userId: string,
topic: string,
noteTitles: string[],
noteCount: number,
): Promise<Pick<AgentSuggestionPayload, 'reason' | 'suggestedRole' | 'suggestedType' | 'suggestedFrequency'>> {
const fallback = {
reason: `${noteCount} notes récentes sur ce thème — aucun agent ne le couvre encore.`,
suggestedRole: `Recherche et synthétise les dernières informations sur « ${topic} ». Croise avec les notes existantes de l'utilisateur et produis un résumé actionnable.`,
suggestedType: 'researcher',
suggestedFrequency: 'weekly',
}
try {
const prompt = `Tu proposes un agent IA pour un second cerveau (prise de notes).
Thème détecté: "${topic}"
Notes liées (titres): ${noteTitles.slice(0, 5).join(' | ') || 'sans titre'}
Nombre de notes: ${noteCount}
Retourne UNIQUEMENT du JSON valide:
{"reason":"1 phrase pourquoi un agent est utile","suggestedRole":"prompt système de l'agent (2-3 phrases, français)","suggestedType":"researcher|monitor","suggestedFrequency":"daily|weekly"}`
const raw = await withAiQuota(userId, 'reformulate', async () => {
const config = await getSystemConfig()
const provider = getChatProvider(config)
if (!provider) return null
return provider.generateText(prompt)
}, { lane: 'chat' })
if (!raw) return fallback
const match = raw.match(/\{[\s\S]*\}/)
if (!match) return fallback
const parsed = JSON.parse(match[0])
return {
reason: String(parsed.reason || fallback.reason).slice(0, 500),
suggestedRole: String(parsed.suggestedRole || fallback.suggestedRole).slice(0, 2000),
suggestedType: parsed.suggestedType === 'monitor' ? 'monitor' : 'researcher',
suggestedFrequency: ['daily', 'weekly'].includes(parsed.suggestedFrequency) ? parsed.suggestedFrequency : 'weekly',
}
} catch {
return fallback
}
}
export class AgentSuggestionService {
async generateForUser(userId: string): Promise<number> {
const stored = await clusteringService.getStoredClusters(userId)
if (!stored || stored.clusters.length === 0) return 0
const agents = await prisma.agent.findMany({
where: { userId, isEnabled: true },
select: { name: true, role: true, description: true, sourceNoteIds: true },
})
const candidates = stored.clusters
.filter(c => c.noteIds.length >= MIN_CLUSTER_NOTES)
.sort((a, b) => b.noteIds.length - a.noteIds.length)
let created = 0
for (const cluster of candidates) {
if (created >= MAX_SUGGESTIONS_PER_RUN) break
const topic = cluster.name || `Thème ${cluster.clusterId + 1}`
const covered = agents.some(a => clusterCoveredByAgent(topic, cluster.noteIds, a))
if (covered) continue
const existing = await prisma.agentSuggestion.findUnique({
where: { userId_clusterId: { userId, clusterId: cluster.clusterId } },
})
if (existing && existing.status !== 'pending') continue
const notes = await prisma.note.findMany({
where: { id: { in: cluster.noteIds.slice(0, 8) }, userId },
select: { title: true },
})
const llm = await buildSuggestionWithLlm(
userId,
topic,
notes.map(n => n.title || 'Sans titre'),
cluster.noteIds.length,
)
const payload = {
topic,
reason: llm.reason,
suggestedRole: llm.suggestedRole,
suggestedType: llm.suggestedType,
suggestedFrequency: llm.suggestedFrequency,
relatedNoteIds: JSON.stringify(cluster.noteIds.slice(0, 20)),
status: 'pending' as const,
}
if (existing) {
await prisma.agentSuggestion.update({
where: { id: existing.id },
data: payload,
})
} else {
await prisma.agentSuggestion.create({
data: { userId, clusterId: cluster.clusterId, ...payload },
})
}
created++
}
return created
}
async getPending(userId: string, limit = 3) {
return prisma.agentSuggestion.findMany({
where: { userId, status: 'pending' },
orderBy: { updatedAt: 'desc' },
take: limit,
})
}
async dismiss(userId: string, id: string) {
const row = await prisma.agentSuggestion.findFirst({ where: { id, userId } })
if (!row) return null
return prisma.agentSuggestion.update({
where: { id },
data: { status: 'dismissed' },
})
}
async accept(userId: string, id: string): Promise<{ agentId: string } | null> {
const row = await prisma.agentSuggestion.findFirst({ where: { id, userId, status: 'pending' } })
if (!row) return null
const noteIds = parseJsonArray(row.relatedNoteIds)
const agent = await prisma.agent.create({
data: {
userId,
name: `Recherche — ${row.topic}`.slice(0, 80),
description: row.reason,
type: row.suggestedType,
role: row.suggestedRole,
frequency: row.suggestedFrequency,
sourceNoteIds: noteIds.length > 0 ? JSON.stringify(noteIds) : null,
tools: JSON.stringify(['web_search', 'read_url']),
isEnabled: true,
scheduledTime: '08:00',
},
})
if (row.suggestedFrequency !== 'manual') {
const nextRun = calculateNextRun({
frequency: row.suggestedFrequency,
scheduledTime: '08:00',
})
if (nextRun) {
await prisma.agent.update({ where: { id: agent.id }, data: { nextRun } })
}
}
await prisma.agentSuggestion.update({
where: { id: row.id },
data: { status: 'accepted' },
})
import('@/lib/ai/services/agent-executor.service')
.then(({ executeAgent }) => executeAgent(agent.id, userId))
.catch(err => console.error('[AgentSuggestion] execute failed:', err))
return { agentId: agent.id }
}
}
export const agentSuggestionService = new AgentSuggestionService()

View File

@@ -1,24 +1,32 @@
/**
* Bridge Notes Service
*
* Detects and manages "bridge notes" — notes that connect multiple clusters.
* A bridge note has strong similarities (cosine > 0.5) with notes from
* at least two different clusters.
* Detects and manages "bridge notes" — notes that connect multiple thematic clusters.
*
* Also generates AI-powered suggestions for creating new bridge notes
* to connect isolated clusters.
* Scientific grounding (semantic similarity networks / brokerage):
* - A bridge document is valued when it spans *distinct communities*, typically the
* strongest link between a *pair* of clusters — not weak ties to every theme
* (see semantic similarity network bridging; betweenness / cross-community brokerage).
* - Literature commonly uses cosine thresholds ≈ 0.650.85 for semantic edges
* (Vec2GC, PMC semantic graphs ~0.65; looser 0.5 floods the graph with false bridges).
* - Score = mean affinity to the top-2 clusters (not "% of all themes touched").
*/
import prisma from '@/lib/prisma'
import { clusteringService } from './clustering.service'
import { getChatProvider } from '@/lib/ai/factory'
import { getSystemConfig } from '@/lib/config'
import { withAiQuota } from '@/lib/ai-quota'
export interface BridgeNote {
noteId: string
/** 01 mean cosine affinity to the top bridge clusters (typically a pair). */
bridgeScore: number
/** Cluster ids ranked by affinity (strongest first). Prefer length 2. */
clustersConnected: number[]
clusterNames?: string[]
/** Per-cluster max cosine affinities, aligned with clustersConnected. */
clusterAffinities?: number[]
}
export interface BridgeSuggestion {
@@ -31,26 +39,37 @@ export interface BridgeSuggestion {
justification: string
}
interface ClusterAffinity {
clusterId: number
maxSimilarity: number
hitCount: number
}
export class BridgeNotesService {
private readonly BRIDGE_SIMILARITY_THRESHOLD = 0.5
/** Cosine similarity floor for semantic edges (literature ≈ 0.65). */
private readonly BRIDGE_SIMILARITY_THRESHOLD = 0.65
private readonly MIN_CLUSTERS_FOR_BRIDGE = 2
/** A true bridge is primarily a pair of communities (brokerage). */
private readonly MAX_BRIDGED_CLUSTERS = 2
/**
* Get similar notes for a given note across all clusters.
* Returns notes grouped by their cluster membership.
* For one note, return max cosine similarity to each cluster that clears the threshold.
*/
private async getSimilarNotesByCluster(
private async getClusterAffinities(
noteId: string,
userId: string,
threshold: number = this.BRIDGE_SIMILARITY_THRESHOLD
): Promise<Map<number, string[]>> {
): Promise<ClusterAffinity[]> {
const cosineDistance = 1 - threshold
const result = await prisma.$queryRawUnsafe<Array<{
noteId: string
clusterId: number | null
similarity: number
}>>(
`SELECT e2."noteId", cm."clusterId"
`SELECT e2."noteId",
cm."clusterId",
(1 - (e1."embedding"::vector <=> e2."embedding"::vector))::float8 AS similarity
FROM "NoteEmbedding" e1
CROSS JOIN "NoteEmbedding" e2
INNER JOIN "Note" n ON n.id = e2."noteId"
@@ -65,25 +84,36 @@ export class BridgeNotesService {
cosineDistance
)
const clusterMap = new Map<number, string[]>()
const byCluster = new Map<number, { maxSimilarity: number; hitCount: number }>()
for (const row of result) {
const clusterId = row.clusterId ?? -1 // -1 for noise/uncategorized
if (!clusterMap.has(clusterId)) {
clusterMap.set(clusterId, [])
const clusterId = row.clusterId
if (clusterId === null || clusterId === -1) continue
const sim = Number(row.similarity)
if (!Number.isFinite(sim) || sim < threshold) continue
const prev = byCluster.get(clusterId)
if (!prev) {
byCluster.set(clusterId, { maxSimilarity: sim, hitCount: 1 })
} else {
prev.maxSimilarity = Math.max(prev.maxSimilarity, sim)
prev.hitCount += 1
}
clusterMap.get(clusterId)!.push(row.noteId)
}
return clusterMap
return [...byCluster.entries()]
.map(([clusterId, v]) => ({
clusterId,
maxSimilarity: v.maxSimilarity,
hitCount: v.hitCount,
}))
.sort((a, b) => b.maxSimilarity - a.maxSimilarity || b.hitCount - a.hitCount)
}
/**
* Detect all bridge notes for a user.
* A note is a bridge if it has similarities to >= 2 distinct clusters.
* Detect bridge notes: notes with strong affinity to at least two clusters.
* Keeps only the top-2 clusters (pair brokerage) and scores by their mean affinity.
*/
async detectBridgeNotes(userId: string): Promise<BridgeNote[]> {
// Get all user's clusters
const clusters = await prisma.noteCluster.findMany({
where: { userId },
select: { clusterId: true, name: true },
@@ -94,42 +124,40 @@ export class BridgeNotesService {
return []
}
const maxClusters = clusters.length
const nameById = new Map(
clusters.map(c => [c.clusterId, clusteringService.displayName(c.name, c.clusterId)])
)
const bridgeNotes: BridgeNote[] = []
// Check each note for bridge potential
const notes = await prisma.note.findMany({
where: { userId, trashedAt: null },
select: { id: true }
})
for (const note of notes) {
const similarByCluster = await this.getSimilarNotesByCluster(note.id, userId)
const affinities = await this.getClusterAffinities(note.id, userId)
if (affinities.length < this.MIN_CLUSTERS_FOR_BRIDGE) continue
// Filter out noise (-1) and get clusters with actual similar notes
const clustersWithSimilarNotes: number[] = []
for (const [clusterId, similarNotes] of similarByCluster) {
if (clusterId !== -1 && similarNotes.length > 0) {
clustersWithSimilarNotes.push(clusterId)
}
}
const top = affinities.slice(0, this.MAX_BRIDGED_CLUSTERS)
// Both legs of the bridge must be strong
if (top.length < this.MIN_CLUSTERS_FOR_BRIDGE) continue
if (top.some(a => a.maxSimilarity < this.BRIDGE_SIMILARITY_THRESHOLD)) continue
// Check if this note connects >= 2 clusters
if (clustersWithSimilarNotes.length >= this.MIN_CLUSTERS_FOR_BRIDGE) {
const bridgeScore = clustersWithSimilarNotes.length / maxClusters
const bridgeScore =
top.reduce((sum, a) => sum + a.maxSimilarity, 0) / top.length
const clustersConnected = top.map(a => a.clusterId)
bridgeNotes.push({
noteId: note.id,
bridgeScore,
clustersConnected: clustersWithSimilarNotes,
clusterNames: clustersWithSimilarNotes.map(
cid => clusters.find(c => c.clusterId === cid)?.name || `Cluster ${cid}`
)
clustersConnected,
clusterNames: clustersConnected.map(
cid => nameById.get(cid) || clusteringService.displayName(null, cid)
),
clusterAffinities: top.map(a => a.maxSimilarity),
})
}
}
// Sort by bridge score (most influential first)
return bridgeNotes.sort((a, b) => b.bridgeScore - a.bridgeScore)
}
@@ -179,10 +207,22 @@ export class BridgeNotesService {
}
/**
* Generate AI-powered suggestions for connecting isolated clusters.
* Generate AI suggestions for *plausible* missing links between clusters.
*
* Scientific grounding (link prediction / KG completion):
* - Do NOT propose a link for every unconnected pair (combinatorial explosion →
* forced metaphors like "Stripe ↔ gas dynamics").
* - Pre-filter by centroid cosine similarity: keep "near-miss" pairs that are
* related enough to deserve a bridge, but not already tightly fused.
* - Cap candidate count; rank by structural proximity before spending AI calls.
* - Prompt: grounded synthesis only; return null if no real shared object/method.
*/
async generateBridgeSuggestions(userId: string): Promise<BridgeSuggestion[]> {
// Get all clusters
const MAX_SUGGESTIONS = 3
/** Near-miss only — wide band (0.300.62) still let Stripe↔gaz through. */
const MIN_PAIR_SIMILARITY = 0.45
const MAX_PAIR_SIMILARITY = 0.58
const clusters = await prisma.noteCluster.findMany({
where: { userId },
select: { clusterId: true, name: true },
@@ -191,116 +231,254 @@ export class BridgeNotesService {
if (clusters.length < 2) return []
// Get existing bridges to see which clusters are already connected
const existingBridges = await prisma.bridgeNote.findMany({
where: { userId },
select: { clustersConnected: true }
})
const connectedPairs = new Set<string>()
for (const bridge of existingBridges) {
const clusters = JSON.parse(bridge.clustersConnected) as number[]
for (let i = 0; i < clusters.length; i++) {
for (let j = i + 1; j < clusters.length; j++) {
const pair = [clusters[i], clusters[j]].sort().join('-')
connectedPairs.add(pair)
const ids = JSON.parse(bridge.clustersConnected) as number[]
for (let i = 0; i < ids.length; i++) {
for (let j = i + 1; j < ids.length; j++) {
connectedPairs.add([ids[i], ids[j]].sort((a, b) => a - b).join('-'))
}
}
}
// Find unconnected cluster pairs
const suggestions: BridgeSuggestion[] = []
for (let i = 0; i < clusters.length; i++) {
for (let j = i + 1; j < clusters.length; j++) {
const pair = `${clusters[i].clusterId}-${clusters[j].clusterId}`
if (connectedPairs.has(pair)) continue // Already connected
// Generate suggestion for this unconnected pair
const suggestion = await this.generateConnectionSuggestion(
clusters[i].clusterId,
clusters[j].clusterId,
clusters[i].name || `Cluster ${clusters[i].clusterId}`,
clusters[j].name || `Cluster ${clusters[j].clusterId}`,
userId
const centroids = new Map<number, number[]>()
const summaries = new Map<number, string>()
await Promise.all(
clusters.map(async c => {
const [centroid, summary] = await Promise.all([
this.getClusterCentroid(c.clusterId, userId),
this.getClusterSummary(c.clusterId, userId),
])
if (centroid) centroids.set(c.clusterId, centroid)
summaries.set(c.clusterId, summary)
})
)
if (suggestion) {
suggestions.push(suggestion)
type RankedPair = {
a: typeof clusters[0]
b: typeof clusters[0]
similarity: number
}
const ranked: RankedPair[] = []
for (let i = 0; i < clusters.length; i++) {
for (let j = i + 1; j < clusters.length; j++) {
const a = clusters[i]
const b = clusters[j]
const pairKey = [a.clusterId, b.clusterId].sort((x, y) => x - y).join('-')
if (connectedPairs.has(pairKey)) continue
const ca = centroids.get(a.clusterId)
const cb = centroids.get(b.clusterId)
if (!ca || !cb) continue
const similarity = this.cosineSimilarity(ca, cb)
if (similarity < MIN_PAIR_SIMILARITY || similarity > MAX_PAIR_SIMILARITY) continue
// Lexical gate: no shared vocabulary → no suggestion (blocks Stripe↔gaz)
if (!this.hasLexicalOverlap(summaries.get(a.clusterId) || '', summaries.get(b.clusterId) || '')) {
continue
}
ranked.push({ a, b, similarity })
}
}
ranked.sort((x, y) => y.similarity - x.similarity)
const candidates = ranked.slice(0, MAX_SUGGESTIONS)
const suggestions: BridgeSuggestion[] = []
for (const { a, b, similarity } of candidates) {
const suggestion = await this.generateConnectionSuggestion(
a.clusterId,
b.clusterId,
clusteringService.displayName(a.name, a.clusterId),
clusteringService.displayName(b.name, b.clusterId),
userId,
similarity
)
if (suggestion && !this.looksLikeForcedMetaphor(suggestion)) {
suggestions.push(suggestion)
}
}
return suggestions
}
/** Shared tokens in note snippets — blocks ornamental cross-domain pairs. */
private hasLexicalOverlap(textA: string, textB: string): boolean {
const stop = new Set([
'the', 'and', 'for', 'with', 'that', 'this', 'from', 'are', 'was', 'were', 'have', 'has',
'les', 'des', 'une', 'dans', 'pour', 'avec', 'que', 'qui', 'sur', 'par', 'est', 'sont',
'not', 'note', 'notes', 'untitled', 'sans', 'titre', 'cette', 'plus', 'comme',
])
const tokens = (text: string) =>
new Set(
text
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.split(/[^a-z0-9]+/i)
.filter(t => t.length >= 4 && !stop.has(t))
)
const a = tokens(textA)
const b = tokens(textB)
if (a.size === 0 || b.size === 0) return false
let shared = 0
for (const t of a) {
if (b.has(t)) shared += 1
if (shared >= 2) return true
}
return false
}
private looksLikeForcedMetaphor(s: BridgeSuggestion): boolean {
const blob = `${s.suggestedTitle} ${s.suggestedContent} ${s.justification}`.toLowerCase()
const redFlags = [
'analogie', 'analogy', 'métaphore', 'metaphor', 'comme si', 'as if',
'parallel between', 'parallèle entre', 'friction', 'entropie', 'entropy',
'fluide', 'fluid dynamics', 'rate limit', 'rate-limiting', 'adiabatique',
'viscosity', 'viscosité', 'théorie de la friction', 'meets digital',
]
return redFlags.some(f => blob.includes(f))
}
private cosineSimilarity(a: number[], b: number[]): number {
if (a.length === 0 || a.length !== b.length) return 0
let dot = 0
let na = 0
let nb = 0
for (let i = 0; i < a.length; i++) {
dot += a[i] * b[i]
na += a[i] * a[i]
nb += b[i] * b[i]
}
const den = Math.sqrt(na) * Math.sqrt(nb)
return den === 0 ? 0 : dot / den
}
private async getClusterCentroid(clusterId: number, userId: string): Promise<number[] | null> {
const rows = await prisma.$queryRawUnsafe<Array<{ embedding: string }>>(
`SELECT e."embedding"::text AS embedding
FROM "ClusterMember" cm
INNER JOIN "NoteEmbedding" e ON e."noteId" = cm."noteId"
WHERE cm."clusterId" = $1
AND cm."userId" = $2
AND e."embedding" IS NOT NULL
LIMIT 40`,
clusterId,
userId
)
const vectors: number[][] = []
for (const row of rows) {
try {
const v = JSON.parse(row.embedding) as number[]
if (Array.isArray(v) && v.length > 0) vectors.push(v)
} catch {
/* skip bad vector */
}
}
if (vectors.length === 0) return null
const dim = vectors[0].length
const centroid = new Array(dim).fill(0)
for (const v of vectors) {
if (v.length !== dim) continue
for (let i = 0; i < dim; i++) centroid[i] += v[i]
}
for (let i = 0; i < dim; i++) centroid[i] /= vectors.length
return centroid
}
/**
* Generate a specific connection suggestion between two clusters.
* Generate a grounded connection suggestion between two near-miss clusters.
*/
private async generateConnectionSuggestion(
clusterAId: number,
clusterBId: number,
clusterAName: string,
clusterBName: string,
userId: string
userId: string,
pairSimilarity: number
): Promise<BridgeSuggestion | null> {
const summaryA = await this.getClusterSummary(clusterAId, userId)
const summaryB = await this.getClusterSummary(clusterBId, userId)
const systemPrompt = `You are a creative assistant that helps users connect ideas.
Suggest a "bridge note" that could connect two unrelated topics.
Be specific and creative. Your suggestions should help users discover new insights.`
const systemPrompt = `You help a personal knowledge base propose MISSING LINKS (link prediction).
Rules:
- ONLY suggest a bridge when both themes share a concrete object, tool, method, dataset, or decision visible in the notes.
- NEVER invent witty analogies between unrelated domains (billing ≠ thermodynamics, hosting ≠ air conditioning).
- If themes are about different products/domains with no shared practice, return {"viable": false}.
- Write in the same language as the note summaries.
- Be concise and actionable.`
const userPrompt = `I have two groups of notes that are not connected:
const userPrompt = `Two thematic clusters are unconnected but somewhat related (centroid cosine ≈ ${pairSimilarity.toFixed(2)}).
Group A (${clusterAName}):
Theme A (${clusterAName}):
${summaryA}
Group B (${clusterBName}):
Theme B (${clusterBName}):
${summaryB}
Suggest 3 creative bridge note ideas to connect these groups. For each idea, provide:
1. A catchy title (max 10 words)
2. A brief description of what the note would contain (max 50 words)
3. A justification for why this connection is valuable (max 30 words)
Propose ONE bridge note that a thoughtful researcher would actually write — a synthesis, comparison with a shared criterion, or decision note — NOT a witty forced analogy.
Format as JSON:
Return ONLY JSON:
{
"ideas": [
{"title": "...", "description": "...", "justification": "..."},
...
]
"viable": true,
"title": "≤12 words",
"description": "≤40 words: what the note covers",
"justification": "≤25 words: why this link is real (shared object/method)"
}
Return ONLY the JSON, no other text.`
or {"viable": false}`
try {
const response = await withAiQuota(userId, 'reformulate', async () => {
const config = await getSystemConfig()
const provider = getChatProvider(config)
const response = await provider.chat(
return provider.chat(
[{ role: 'user', content: userPrompt }],
systemPrompt
systemPrompt,
)
}, { lane: 'chat' })
// Parse JSON response
const jsonMatch = response.text.match(/\{[\s\S]*\}/)
if (!jsonMatch) return null
const parsed = JSON.parse(jsonMatch[0])
const bestIdea = parsed.ideas?.[0]
const parsed = JSON.parse(jsonMatch[0]) as {
viable?: boolean
title?: string
description?: string
justification?: string
ideas?: Array<{ title: string; description: string; justification: string }>
}
if (!bestIdea) return null
if (parsed.viable === false) return null
const idea =
parsed.title && parsed.description
? {
title: parsed.title,
description: parsed.description,
justification: parsed.justification || '',
}
: parsed.ideas?.[0]
if (!idea?.title || !idea?.description) return null
return {
clusterAId,
clusterBId,
clusterAName,
clusterBName,
suggestedTitle: bestIdea.title,
suggestedContent: bestIdea.description,
justification: bestIdea.justification
suggestedTitle: idea.title,
suggestedContent: idea.description,
justification: idea.justification || '',
}
} catch (error) {
console.error('Error generating bridge suggestion:', error)
@@ -351,26 +529,93 @@ Return ONLY the JSON, no other text.`
}
/**
* Get bridge suggestions for a user.
* Get bridge suggestions — purge ornamental leftovers from older algorithms.
* Resolve cluster display names from NoteCluster (fixes frozen "Cluster 15" in BridgeSuggestion rows).
*/
async getBridgeSuggestions(userId: string, includeDismissed: boolean = false): Promise<BridgeSuggestion[]> {
const nameById = await clusteringService.getClusterNameMap(userId)
const suggestions = await prisma.bridgeSuggestion.findMany({
where: {
userId,
...(includeDismissed ? {} : { isDismissed: false })
},
orderBy: { createdAt: 'desc' }
orderBy: { createdAt: 'desc' },
take: 40,
})
return suggestions.map(s => ({
const mapped = suggestions.map(s => {
const clusterAName = nameById.get(s.clusterAId) || clusteringService.displayName(s.clusterAName, s.clusterAId)
const clusterBName = nameById.get(s.clusterBId) || clusteringService.displayName(s.clusterBName, s.clusterBId)
return {
clusterAId: s.clusterAId,
clusterBId: s.clusterBId,
clusterAName: s.clusterAName,
clusterBName: s.clusterBName,
clusterAName,
clusterBName,
suggestedTitle: s.suggestedTitle,
suggestedContent: s.suggestedContent,
justification: s.justification
}))
}
})
// Persist corrected names when suggestions still store placeholders
const nameFixes = suggestions.filter(s => {
const resolvedA = nameById.get(s.clusterAId)
const resolvedB = nameById.get(s.clusterBId)
return (
(resolvedA && clusteringService.isPlaceholderClusterName(s.clusterAName)) ||
(resolvedB && clusteringService.isPlaceholderClusterName(s.clusterBName))
)
})
if (nameFixes.length > 0) {
await Promise.all(
nameFixes.map(s =>
prisma.bridgeSuggestion.updateMany({
where: { userId, clusterAId: s.clusterAId, clusterBId: s.clusterBId },
data: {
clusterAName: nameById.get(s.clusterAId) || clusteringService.displayName(s.clusterAName, s.clusterAId),
clusterBName: nameById.get(s.clusterBId) || clusteringService.displayName(s.clusterBName, s.clusterBId),
},
})
)
)
}
const keep: BridgeSuggestion[] = []
const dropIds: Array<{ clusterAId: number; clusterBId: number }> = []
for (const s of mapped) {
if (this.looksLikeForcedMetaphor(s)) {
dropIds.push({ clusterAId: s.clusterAId, clusterBId: s.clusterBId })
continue
}
const [summaryA, summaryB, ca, cb] = await Promise.all([
this.getClusterSummary(s.clusterAId, userId),
this.getClusterSummary(s.clusterBId, userId),
this.getClusterCentroid(s.clusterAId, userId),
this.getClusterCentroid(s.clusterBId, userId),
])
if (!ca || !cb) {
dropIds.push({ clusterAId: s.clusterAId, clusterBId: s.clusterBId })
continue
}
const sim = this.cosineSimilarity(ca, cb)
if (sim < 0.45 || sim > 0.58 || !this.hasLexicalOverlap(summaryA, summaryB)) {
dropIds.push({ clusterAId: s.clusterAId, clusterBId: s.clusterBId })
continue
}
keep.push(s)
}
if (dropIds.length > 0) {
await Promise.all(
dropIds.map(({ clusterAId, clusterBId }) =>
prisma.bridgeSuggestion.deleteMany({ where: { userId, clusterAId, clusterBId } })
)
)
}
return keep.slice(0, 5)
}
/**
@@ -378,11 +623,7 @@ Return ONLY the JSON, no other text.`
*/
async dismissSuggestion(userId: string, clusterAId: number, clusterBId: number): Promise<void> {
await prisma.bridgeSuggestion.updateMany({
where: {
userId,
clusterAId,
clusterBId
},
where: { userId, clusterAId, clusterBId },
data: { isDismissed: true }
})
}

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 { withAiQuota } from '@/lib/ai-quota'
import { upsertNoteEmbedding } from '@/lib/embeddings'
export interface ClusterResult {
@@ -570,22 +571,36 @@ export class ClusteringService {
/**
* Get the N most central notes from a cluster for naming purposes.
* Falls back to any members ranked by membershipScore if none marked central.
*/
async getCentralNotes(clusterId: number, userId: string, n: number = 5): Promise<Array<{ noteId: string; title: string | null; content: string }>> {
const result = await prisma.$queryRawUnsafe<Array<{ noteId: string; title: string | null; content: string }>>(
`SELECT DISTINCT n.id AS "noteId", n.title, n.content
const central = await prisma.$queryRawUnsafe<Array<{ noteId: string; title: string | null; content: string }>>(
`SELECT 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
ORDER BY cm."membershipScore" DESC
LIMIT $3`,
clusterId,
userId,
n
)
if (central.length > 0) return central
return result
return prisma.$queryRawUnsafe<Array<{ noteId: string; title: string | null; content: string }>>(
`SELECT 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
ORDER BY cm."membershipScore" DESC
LIMIT $3`,
clusterId,
userId,
n
)
}
/**
@@ -628,36 +643,154 @@ export class ClusteringService {
})
}
/**
* Generate a name for a cluster using the LLM.
* Analyzes the 5 most central notes to extract a common theme.
*/
async generateClusterName(clusterId: number, userId: string): Promise<string> {
const centralNotes = await this.getCentralNotes(clusterId, userId, 5)
if (centralNotes.length === 0) {
return `Cluster ${clusterId}`
isPlaceholderClusterName(name: string | null | undefined): boolean {
if (!name || !name.trim()) return true
return /^cluster\s*\d+$/i.test(name.trim())
}
const notesText = centralNotes
.map((note, i) => `${i + 1}. "${note.title || 'Untitled'}" - ${note.content.slice(0, 100)}...`)
private heuristicNameFromNotes(
notes: Array<{ title: string | null; content: string }>,
clusterId: number
): string {
for (const note of notes) {
const title = note.title?.trim()
if (title && title.length >= 3) {
return title.length > 48 ? `${title.slice(0, 45)}` : title
}
}
const snippet = notes[0]?.content?.replace(/<[^>]+>/g, ' ').trim()
if (snippet && snippet.length >= 3) {
return snippet.length > 48 ? `${snippet.slice(0, 45)}` : snippet
}
return `Thème ${clusterId}`
}
/**
* Name a cluster from in-memory notes (does NOT require ClusterMember rows in DB).
* Critical: POST /api/clusters used to call generateClusterName *before* save → empty centrals → "Cluster 15".
*/
async generateClusterNameFromNotes(
notes: Array<{ title: string | null; content: string }>,
clusterId: number,
userId?: string,
): Promise<string> {
if (notes.length === 0) {
return this.heuristicNameFromNotes(notes, clusterId)
}
const notesText = notes
.slice(0, 5)
.map((note, i) => `${i + 1}. "${note.title || 'Untitled'}" - ${note.content.replace(/<[^>]+>/g, ' ').slice(0, 100)}...`)
.join('\n')
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 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 à 6 mots), dans la langue principale des notes. Ne donnez QUE le titre thématique final, sans ponctuation finale, sans guillemets, sans explication. N'utilisez JAMAIS le format « Cluster 12 »."
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 :`
const userPrompt = `Voici des notes du même groupe thématique. Quel est leur thème commun ?\n\n${notesText}\n\nThème :`
try {
const runLlm = async () => {
const config = await getSystemConfig()
const provider = getChatProvider(config)
const response = await provider.chat(
[{ role: 'user', content: userPrompt }],
systemPrompt
systemPrompt,
)
return response.text.trim().slice(0, 50)
} catch {
return `Cluster ${clusterId}`
return response.text.trim().replace(/^["«]|["»]$/g, '').slice(0, 50)
}
const named = userId
? await withAiQuota(userId, 'reformulate', runLlm, { lane: 'chat' })
: await runLlm()
if (this.isPlaceholderClusterName(named)) {
return this.heuristicNameFromNotes(notes, clusterId)
}
return named || this.heuristicNameFromNotes(notes, clusterId)
} catch {
return this.heuristicNameFromNotes(notes, clusterId)
}
}
/**
* Generate a name for a cluster using the LLM (after members are saved).
*/
async generateClusterName(clusterId: number, userId: string): Promise<string> {
const centralNotes = await this.getCentralNotes(clusterId, userId, 5)
return this.generateClusterNameFromNotes(centralNotes, clusterId, userId)
}
displayName(name: string | null | undefined, clusterId: number): string {
if (this.isPlaceholderClusterName(name)) return `Thème ${clusterId}`
return name!.trim()
}
/**
* Name clusters from in-memory results (before or without depending on DB members).
* Prefer central notes when available.
*/
async nameClustersFromResults(
userId: string,
results: { clusters: ClusterResult[]; clusteredNotes: ClusteredNote[] }
): Promise<void> {
const allNoteIds = [...new Set(results.clusteredNotes.map(n => n.noteId))]
const notes = allNoteIds.length
? await prisma.note.findMany({
where: { id: { in: allNoteIds }, userId },
select: { id: true, title: true, content: true },
})
: []
const byId = new Map(notes.map(n => [n.id, n]))
for (const cluster of results.clusters) {
const members = results.clusteredNotes.filter(n => n.clusterId === cluster.clusterId)
const preferred = members.filter(n => n.isCentral)
const ordered = (preferred.length > 0 ? preferred : members)
.sort((a, b) => b.membershipScore - a.membershipScore)
.slice(0, 5)
const notePayload = ordered
.map(m => byId.get(m.noteId))
.filter((n): n is { id: string; title: string | null; content: string } => Boolean(n))
.map(n => ({ title: n.title, content: n.content }))
cluster.name = await this.generateClusterNameFromNotes(notePayload, cluster.clusterId, userId)
}
}
/**
* Rename clusters that still have null / "Cluster N" placeholders.
* Defaults to fast heuristic titles (central note titles) — no LLM on page load.
* Pass useAi: true from cron/admin if needed; normal POST already names via nameClustersFromResults.
*/
async ensureClusterNames(userId: string, options?: { useAi?: boolean }): Promise<void> {
const clusters = await prisma.noteCluster.findMany({
where: { userId },
select: { clusterId: true, name: true },
})
for (const cluster of clusters) {
if (!this.isPlaceholderClusterName(cluster.name)) continue
const name = options?.useAi
? await this.generateClusterName(cluster.clusterId, userId)
: this.heuristicNameFromNotes(
await this.getCentralNotes(cluster.clusterId, userId, 5),
cluster.clusterId
)
await prisma.noteCluster.updateMany({
where: { userId, clusterId: cluster.clusterId },
data: { name },
})
}
}
/**
* Current clusterId → human name map (never "Cluster N").
*/
async getClusterNameMap(userId: string): Promise<Map<number, string>> {
await this.ensureClusterNames(userId)
const clusters = await prisma.noteCluster.findMany({
where: { userId },
select: { clusterId: true, name: true },
})
return new Map(clusters.map(c => [c.clusterId, this.displayName(c.name, c.clusterId)]))
}
/**
@@ -723,7 +856,7 @@ export class ClusteringService {
result.push({
clusterId: cluster.clusterId,
noteIds: members.map(m => m.noteId),
name: cluster.name || undefined
name: this.displayName(cluster.name, cluster.clusterId),
})
}

View File

@@ -10,6 +10,13 @@ import {
prepareNoteTextForEmbedding,
} from '@/lib/text/plain-text'
import { detectTextDirection } from '@/lib/clip/rtl-content'
import { detectUserLanguage } from '@/lib/i18n/detect-user-language'
import type { SupportedLanguage } from '@/lib/i18n/load-translations'
import {
buildMemoryEchoInsightPrompt,
getMemoryEchoInsightFallback,
} from '@/lib/ai/memory-echo-i18n'
import { withAiQuota } from '@/lib/ai-quota'
import {
SEMANTIC_SIMILARITY_FLOOR_CLIP,
SEMANTIC_SIMILARITY_FLOOR_DEMO,
@@ -334,38 +341,16 @@ export class MemoryEchoService {
note1Title: string | null,
note1Content: string,
note2Title: string | null,
note2Content: string
note2Content: string,
language: SupportedLanguage = 'en',
): Promise<string> {
try {
const config = await getSystemConfig()
const provider = getChatProvider(config)
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 = isRtl
? `تو یک دستیار هستی که ارتباط بین یادداشت‌ها را تحلیل می‌کنی.
یادداشت ۱: «${note1Desc}»
متن: ${excerpt1}
یادداشت ۲: «${note2Desc}»
متن: ${excerpt2}
در یک جمله کوتاه (حداکثر ۱۵ کلمه) به فارسی توضیح بده چرا این دو یادداشت به هم مرتبط‌اند. فقط رابطه معنایی را بگو.`
: `You are a helpful assistant analyzing connections between notes.
Note 1: "${note1Desc}"
Content: ${excerpt1}
Note 2: "${note2Desc}"
Content: ${excerpt2}
Explain in one brief sentence (max 15 words) why these notes are connected. Focus on the semantic relationship.`
const prompt = buildMemoryEchoInsightPrompt(language, note1Title, excerpt1, note2Title, excerpt2)
const response = await provider.generateText(prompt)
@@ -375,20 +360,15 @@ Explain in one brief sentence (max 15 words) why these notes are connected. Focu
.trim()
.substring(0, 150)
const fallback = isRtl
? 'این یادداشت‌ها از نظر معنایی به هم مرتبط به نظر می‌رسند.'
: 'These notes appear to be semantically related.'
return insight || fallback
return insight || getMemoryEchoInsightFallback(language)
} 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.'
const rtl = detectTextDirection(sample) === 'rtl'
if (rtl) return getMemoryEchoInsightFallback(language === 'fa' || language === 'ar' ? language : 'fa')
return getMemoryEchoInsightFallback(language)
}
}
@@ -484,12 +464,20 @@ Explain in one brief sentence (max 15 words) why these notes are connected. Focu
return null // All connections already shown
}
// Generate AI insight
const insightText = await this.generateInsight(
// Generate AI insight in user's language (quota: reformulate, with rollback on failure)
const userLanguage = await detectUserLanguage()
const insightText = await withAiQuota(
userId,
'reformulate',
() =>
this.generateInsight(
newConnection.note1.title,
newConnection.note1.content,
newConnection.note2.title,
newConnection.note2.content || ''
newConnection.note2.content || '',
userLanguage,
),
{ lane: 'chat' },
)
// Store insight in database

View File

@@ -0,0 +1,17 @@
/** True when /home shows the Second Brain dashboard (not list, editor, or filters). */
export function isDashboardHomeRoute(
pathname: string,
searchParams: Pick<URLSearchParams, 'get'>,
): boolean {
if (pathname !== '/home') return false
return (
searchParams.get('forceList') !== '1' &&
!searchParams.get('notebook') &&
!searchParams.get('search') &&
!searchParams.get('labels') &&
!searchParams.get('shared') &&
!searchParams.get('reminders') &&
!searchParams.get('color') &&
!searchParams.get('openNote')
)
}

View File

@@ -0,0 +1,299 @@
export const DASHBOARD_LAYOUT_VERSION = 6 as const
/** Widgets visibles dans la mise en page par défaut (réf. prototype / capture utilisateur). */
export const CANONICAL_VISIBLE_WIDGET_IDS: readonly DashboardWidgetId[] = [
'capture',
'next-paths',
'intelligence',
'resume',
'activity',
'mind-map',
'sentiment',
'inbox',
'revision',
'stats',
'reminders',
'flashcards-progress',
'agents',
'pinned',
] as const
export type DashboardWidgetId =
| 'capture'
| 'next-paths'
| 'resume'
| 'intelligence'
| 'reminders'
| 'mind-map'
| 'agents'
| 'sentiment'
| 'inbox'
| 'revision'
| 'stats'
| 'agent-activity'
| 'gmail'
| 'activity'
| 'pinned'
| 'usage'
| 'daily-review'
| 'open-loops'
| 'daily-note'
| 'link-suggestions'
| 'bridges'
| 'flashcards-progress'
export type DashboardWidgetZone = 'full' | 'main' | 'side'
export type DashboardWidgetCategory =
| 'essential'
| 'paths'
| 'cognition'
| 'productivity'
| 'agents'
export interface DashboardWidgetPlacement {
id: DashboardWidgetId
visible: boolean
order: number
zone: DashboardWidgetZone
}
export interface DashboardLayout {
version: typeof DASHBOARD_LAYOUT_VERSION
widgets: DashboardWidgetPlacement[]
}
export const DASHBOARD_WIDGET_META: Record<DashboardWidgetId, {
required: boolean
zone: DashboardWidgetZone
category: DashboardWidgetCategory
defaultVisible: boolean
}> = {
capture: { required: true, zone: 'full', category: 'essential', defaultVisible: true },
'next-paths': { required: false, zone: 'main', category: 'paths', defaultVisible: true },
resume: { required: true, zone: 'main', category: 'essential', defaultVisible: true },
intelligence: { required: true, zone: 'main', category: 'essential', defaultVisible: true },
reminders: { required: false, zone: 'side', category: 'productivity', defaultVisible: true },
'mind-map': { required: false, zone: 'main', category: 'cognition', defaultVisible: true },
agents: { required: false, zone: 'side', category: 'agents', defaultVisible: true },
sentiment: { required: false, zone: 'side', category: 'agents', defaultVisible: true },
inbox: { required: false, zone: 'side', category: 'productivity', defaultVisible: false },
revision: { required: false, zone: 'side', category: 'productivity', defaultVisible: false },
stats: { required: false, zone: 'side', category: 'cognition', defaultVisible: false },
'agent-activity': { required: false, zone: 'side', category: 'agents', defaultVisible: false },
gmail: { required: false, zone: 'side', category: 'productivity', defaultVisible: false },
activity: { required: false, zone: 'main', category: 'cognition', defaultVisible: true },
pinned: { required: false, zone: 'side', category: 'cognition', defaultVisible: true },
usage: { required: false, zone: 'side', category: 'agents', defaultVisible: false },
'daily-review': { required: false, zone: 'side', category: 'paths', defaultVisible: false },
'open-loops': { required: false, zone: 'side', category: 'paths', defaultVisible: false },
'daily-note': { required: false, zone: 'side', category: 'paths', defaultVisible: false },
'link-suggestions': { required: false, zone: 'main', category: 'paths', defaultVisible: false },
bridges: { required: false, zone: 'main', category: 'cognition', defaultVisible: false },
'flashcards-progress': { required: false, zone: 'side', category: 'productivity', defaultVisible: false },
}
export const DASHBOARD_CATEGORY_ORDER: DashboardWidgetCategory[] = [
'essential',
'paths',
'cognition',
'productivity',
'agents',
]
export const DEFAULT_DASHBOARD_LAYOUT: DashboardLayout = {
version: DASHBOARD_LAYOUT_VERSION,
widgets: [
// Pleine largeur
{ id: 'capture', visible: true, order: 0, zone: 'full' },
// Colonne principale (gauche) — ordre vertical
{ id: 'next-paths', visible: true, order: 1, zone: 'main' },
{ id: 'intelligence', visible: true, order: 2, zone: 'main' },
{ id: 'resume', visible: true, order: 3, zone: 'main' },
{ id: 'activity', visible: true, order: 4, zone: 'main' },
{ id: 'mind-map', visible: true, order: 5, zone: 'main' },
// Colonne latérale (droite) — cartes compactes + widgets IA
{ id: 'sentiment', visible: true, order: 6, zone: 'side' },
{ id: 'inbox', visible: true, order: 7, zone: 'side' },
{ id: 'revision', visible: true, order: 8, zone: 'side' },
{ id: 'stats', visible: true, order: 9, zone: 'side' },
{ id: 'reminders', visible: true, order: 10, zone: 'side' },
{ id: 'flashcards-progress', visible: true, order: 11, zone: 'side' },
{ id: 'agents', visible: true, order: 12, zone: 'side' },
{ id: 'pinned', visible: true, order: 13, zone: 'side' },
// Catalogue — masqués par défaut, ajoutables via « Personnaliser »
{ id: 'daily-review', visible: false, order: 14, zone: 'side' },
{ id: 'agent-activity', visible: false, order: 15, zone: 'side' },
{ id: 'gmail', visible: false, order: 16, zone: 'side' },
{ id: 'usage', visible: false, order: 17, zone: 'side' },
{ id: 'open-loops', visible: false, order: 18, zone: 'side' },
{ id: 'daily-note', visible: false, order: 19, zone: 'side' },
{ id: 'link-suggestions', visible: false, order: 20, zone: 'main' },
{ id: 'bridges', visible: false, order: 21, zone: 'main' },
],
}
/** Clone frais — évite de muter le constant partagé via useState. */
export function getDefaultDashboardLayout(): DashboardLayout {
return structuredClone(DEFAULT_DASHBOARD_LAYOUT)
}
export function resetDashboardLayout(): DashboardLayout {
return getDefaultDashboardLayout()
}
const ALL_WIDGET_IDS = Object.keys(DASHBOARD_WIDGET_META) as DashboardWidgetId[]
const LEGACY_SPAN_ZONE: Record<string, DashboardWidgetZone> = {
full: 'full',
large: 'main',
medium: 'main',
small: 'side',
compact: 'side',
}
function isValidZone(zone: unknown): zone is DashboardWidgetZone {
return zone === 'full' || zone === 'main' || zone === 'side'
}
function legacyZoneFromItem(item: Record<string, unknown>, id: DashboardWidgetId): DashboardWidgetZone {
if (isValidZone(item.zone)) return item.zone
const span = item.span
if (typeof span === 'string' && LEGACY_SPAN_ZONE[span]) return LEGACY_SPAN_ZONE[span]
return DASHBOARD_WIDGET_META[id].zone
}
export function normalizeDashboardLayout(raw: unknown): DashboardLayout {
if (!raw || typeof raw !== 'object') return getDefaultDashboardLayout()
const data = raw as Partial<DashboardLayout> & { version?: number }
if (!Array.isArray(data.widgets) || data.widgets.length === 0) return getDefaultDashboardLayout()
// Layout périmé ou vide → preset canonique
const incomingVersion = typeof data.version === 'number' ? data.version : 0
if (incomingVersion < DASHBOARD_LAYOUT_VERSION) {
return getDefaultDashboardLayout()
}
const byId = new Map<DashboardWidgetId, DashboardWidgetPlacement>()
for (const item of data.widgets) {
if (!item || typeof item !== 'object') continue
const id = (item as DashboardWidgetPlacement).id
if (!ALL_WIDGET_IDS.includes(id)) continue
const meta = DASHBOARD_WIDGET_META[id]
byId.set(id, {
id,
visible: meta.required ? true : Boolean((item as DashboardWidgetPlacement).visible),
order: Number.isFinite((item as DashboardWidgetPlacement).order)
? Number((item as DashboardWidgetPlacement).order)
: 0,
zone: legacyZoneFromItem(item as unknown as Record<string, unknown>, id),
})
}
for (const id of ALL_WIDGET_IDS) {
if (!byId.has(id)) {
const meta = DASHBOARD_WIDGET_META[id]
const fallback = DEFAULT_DASHBOARD_LAYOUT.widgets.find(w => w.id === id)
byId.set(id, {
id,
visible: meta.defaultVisible,
order: fallback?.order ?? 99,
zone: meta.zone,
})
}
}
const widgets = [...byId.values()]
.sort((a, b) => a.order - b.order)
.map((w, index) => ({ ...w, order: index }))
const layout = { version: DASHBOARD_LAYOUT_VERSION, widgets }
const visibleCount = layout.widgets.filter(w => w.visible).length
if (visibleCount === 0) return getDefaultDashboardLayout()
return layout
}
export function reorderWidgets(
layout: DashboardLayout,
activeId: DashboardWidgetId,
overId: DashboardWidgetId,
): DashboardLayout {
const activeMeta = DASHBOARD_WIDGET_META[activeId]
const overMeta = DASHBOARD_WIDGET_META[overId]
if (activeMeta.zone !== overMeta.zone) return layout
const visible = layout.widgets
.filter(w => w.visible && w.zone === activeMeta.zone)
.sort((a, b) => a.order - b.order)
const activeIdx = visible.findIndex(w => w.id === activeId)
const overIdx = visible.findIndex(w => w.id === overId)
if (activeIdx === -1 || overIdx === -1 || activeIdx === overIdx) return layout
const next = [...visible]
const [moved] = next.splice(activeIdx, 1)
next.splice(overIdx, 0, moved)
const orderMap = new Map(next.map((w, i) => [w.id, i]))
return {
...layout,
widgets: layout.widgets.map(w => {
if (w.zone !== activeMeta.zone || !w.visible) return w
return { ...w, order: orderMap.has(w.id) ? orderMap.get(w.id)! : w.order + next.length }
}),
}
}
export function setWidgetVisibility(
layout: DashboardLayout,
id: DashboardWidgetId,
visible: boolean,
): DashboardLayout {
if (DASHBOARD_WIDGET_META[id].required && !visible) return layout
return {
...layout,
widgets: layout.widgets.map(w => w.id === id ? { ...w, visible } : w),
}
}
export function visibleWidgets(layout: DashboardLayout): DashboardWidgetPlacement[] {
return layout.widgets.filter(w => w.visible).sort((a, b) => a.order - b.order)
}
export function visibleWidgetsInZone(
layout: DashboardLayout,
zone: DashboardWidgetZone,
): DashboardWidgetPlacement[] {
return visibleWidgets(layout).filter(w => w.zone === zone)
}
export function hiddenWidgetIds(layout: DashboardLayout): DashboardWidgetId[] {
return layout.widgets.filter(w => !w.visible).map(w => w.id)
}
export function catalogByCategory(layout: DashboardLayout): Record<DashboardWidgetCategory, DashboardWidgetId[]> {
const result = Object.fromEntries(
DASHBOARD_CATEGORY_ORDER.map(c => [c, [] as DashboardWidgetId[]]),
) as Record<DashboardWidgetCategory, DashboardWidgetId[]>
for (const id of ALL_WIDGET_IDS) {
result[DASHBOARD_WIDGET_META[id].category].push(id)
}
for (const cat of DASHBOARD_CATEGORY_ORDER) {
result[cat].sort((a, b) => {
const aVis = layout.widgets.find(w => w.id === a)?.visible ? 0 : 1
const bVis = layout.widgets.find(w => w.id === b)?.visible ? 0 : 1
if (aVis !== bVis) return aVis - bVis
return a.localeCompare(b)
})
}
return result
}
export function isWidgetVisible(layout: DashboardLayout, id: DashboardWidgetId): boolean {
return layout.widgets.find(w => w.id === id)?.visible ?? DASHBOARD_WIDGET_META[id].defaultVisible
}

View File

@@ -0,0 +1,36 @@
export type DashboardPathType =
| 'continue'
| 'connect'
| 'bridge'
| 'research'
| 'organize'
| 'explore'
| 'review'
| 'resurface'
| 'daily'
| 'add-link'
export interface DashboardPath {
id: string
type: DashboardPathType
priority: number
title: string
description: string
actionKey: string
noteId?: string
note2Id?: string
notebookId?: string
clusterAId?: number
clusterBId?: number
agentSuggestionId?: string
insightId?: string
snippet?: string
score?: number
}
export interface DashboardOpenLoop {
id: string
title: string | null
notebookId: string | null
daysStale: number
}

View File

@@ -0,0 +1,156 @@
import type { DashboardPath } from '@/lib/dashboard/path-types'
function excerpt(text: string, max = 120): string {
const plain = text.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
if (plain.length <= max) return plain
return `${plain.slice(0, max)}`
}
export interface BriefingPathsInput {
recentNotes: Array<{
id: string
title: string | null
content: string
notebookId: string | null
}>
inboxCount: number
dueFlashcards: number
insights: Array<{
id: string
insight: string
score: number
viewed: boolean
note1: { id: string; title: string | null }
note2: { id: string; title: string | null }
note1Excerpt?: string
note2Excerpt?: string
}>
bridgeSuggestions: Array<{
clusterAId: number
clusterBId: number
clusterAName: string
clusterBName: string
suggestedTitle: string
suggestedContent: string
justification: string
}>
agentSuggestions: Array<{
id: string
topic: string
reason: string
clusterId: number | null
}>
}
/** Pistes instantanées à partir du briefing déjà chargé — sans requête lourde. */
export function buildFastPathsFromBriefing(input: BriefingPathsInput): DashboardPath[] {
const paths: DashboardPath[] = []
const focus = input.recentNotes[0]
if (focus) {
paths.push({
id: `continue-${focus.id}`,
type: 'continue',
priority: 100,
title: focus.title || 'Untitled',
description: excerpt(focus.content, 140),
actionKey: 'continue',
noteId: focus.id,
notebookId: focus.notebookId ?? undefined,
})
for (const ins of input.insights
.filter(i => i.note1.id === focus.id || i.note2.id === focus.id)
.slice(0, 2)) {
const other = ins.note1.id === focus.id ? ins.note2 : ins.note1
paths.push({
id: `connect-${focus.id}-${other.id}`,
type: 'connect',
priority: 88,
title: other.title || 'Untitled',
description: ins.insight,
actionKey: 'compare',
noteId: focus.id,
note2Id: other.id,
notebookId: focus.notebookId ?? undefined,
score: Math.round(ins.score * 100),
})
}
}
const freshInsight = input.insights.find(i => !i.viewed)
if (freshInsight) {
paths.push({
id: `resurface-${freshInsight.id}`,
type: 'resurface',
priority: 85,
title: `${freshInsight.note1.title || '…'}${freshInsight.note2.title || '…'}`,
description: freshInsight.insight,
actionKey: 'openInsight',
insightId: freshInsight.id,
noteId: freshInsight.note1.id,
note2Id: freshInsight.note2.id,
score: Math.round(freshInsight.score * 100),
})
}
for (const bridge of input.bridgeSuggestions.slice(0, 2)) {
paths.push({
id: `bridge-${bridge.clusterAId}-${bridge.clusterBId}`,
type: 'bridge',
priority: 70,
title: bridge.suggestedTitle,
description: bridge.justification || bridge.suggestedContent.slice(0, 140),
actionKey: 'createBridge',
clusterAId: bridge.clusterAId,
clusterBId: bridge.clusterBId,
notebookId: focus?.notebookId ?? undefined,
})
}
for (const agent of input.agentSuggestions.slice(0, 2)) {
paths.push({
id: `research-${agent.id}`,
type: 'research',
priority: 65,
title: agent.topic,
description: agent.reason,
actionKey: 'createAgent',
agentSuggestionId: agent.id,
notebookId: focus?.notebookId ?? undefined,
})
}
if (input.inboxCount > 0) {
paths.push({
id: 'organize-inbox',
type: 'organize',
priority: 60,
title: `${input.inboxCount} notes`,
description: 'inbox',
actionKey: 'organizeInbox',
})
}
if (input.dueFlashcards > 0) {
paths.push({
id: 'review-flashcards',
type: 'review',
priority: 55,
title: `${input.dueFlashcards}`,
description: 'flashcards',
actionKey: 'reviewCards',
})
}
paths.push({
id: 'daily-journal',
type: 'daily',
priority: 40,
title: new Date().toISOString().slice(0, 10),
description: 'daily',
actionKey: 'openDaily',
})
return paths.sort((a, b) => b.priority - a.priority).slice(0, 8)
}

View File

@@ -0,0 +1,298 @@
import 'server-only'
import prisma from '@/lib/prisma'
import { clusteringService } from '@/lib/ai/services/clustering.service'
import type { DashboardPath } from '@/lib/dashboard/path-types'
export type { DashboardPath, DashboardPathType } from '@/lib/dashboard/path-types'
interface BuildPathsInput {
userId: string
aiActive: boolean
recentNotes: Array<{
id: string
title: string | null
notebookId: string | null
updatedAt: Date
content: string
}>
inboxCount: number
dueFlashcards: number
insights: Array<{
id: string
insight: string
score: number
viewed: boolean
note1: { id: string; title: string | null }
note2: { id: string; title: string | null }
note1Excerpt?: string
note2Excerpt?: string
}>
bridgeSuggestions: Array<{
clusterAId: number
clusterBId: number
clusterAName: string
clusterBName: string
suggestedTitle: string
suggestedContent: string
justification: string
}>
agentSuggestions: Array<{
id: string
topic: string
reason: string
clusterId: number | null
}>
}
function excerpt(text: string, max = 120): string {
const plain = text.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
if (plain.length <= max) return plain
return `${plain.slice(0, max)}`
}
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)
if (A.size === 0 || B.size === 0) return 0
let intersection = 0
A.forEach(w => { if (B.has(w)) intersection++ })
return intersection / (A.size + B.size - intersection)
}
async function findLinkSuggestions(
userId: string,
sourceNote: { id: string; title: string | null; content: string },
limit = 2,
): Promise<Array<{ noteId: string; noteTitle: string; snippet: string; score: number }>> {
const sourcePlain = excerpt(sourceNote.content, 400)
const sourceText = `${sourceNote.title || ''} ${sourcePlain}`.toLowerCase()
const candidates = await prisma.note.findMany({
where: { userId, id: { not: sourceNote.id }, isArchived: false, trashedAt: null },
select: { id: true, title: true, content: true },
take: 12,
orderBy: { updatedAt: 'desc' },
})
const scored: Array<{ noteId: string; noteTitle: string; snippet: string; score: number }> = []
for (const note of candidates) {
const notePlain = excerpt(note.content, 300)
const sim = jaccardSimilarity(sourceText, `${note.title || ''} ${notePlain}`)
if (sim < 0.06) continue
scored.push({
noteId: note.id,
noteTitle: note.title || '',
snippet: notePlain.slice(0, 100),
score: Math.round(sim * 100),
})
}
return scored.sort((a, b) => b.score - a.score).slice(0, limit)
}
function pathsFromInsights(
focus: BuildPathsInput['recentNotes'][0],
insights: BuildPathsInput['insights'],
paths: DashboardPath[],
): void {
for (const ins of insights
.filter(i => i.note1.id === focus.id || i.note2.id === focus.id)
.slice(0, 2)) {
const other = ins.note1.id === focus.id ? ins.note2 : ins.note1
if (paths.some(p => p.note2Id === other.id && p.type === 'connect')) continue
paths.push({
id: `connect-${focus.id}-${other.id}`,
type: 'connect',
priority: 90 - paths.length,
title: other.title || 'Untitled',
description: ins.insight || ins.note2Excerpt || ins.note1Excerpt || '',
actionKey: 'compare',
noteId: focus.id,
note2Id: other.id,
notebookId: focus.notebookId ?? undefined,
score: Math.round(ins.score * 100),
})
}
}
async function findOpenLoops(userId: string, limit = 3): Promise<Array<{ id: string; title: string | null; notebookId: string | null; daysStale: number }>> {
const threeDaysAgo = new Date()
threeDaysAgo.setDate(threeDaysAgo.getDate() - 3)
const thirtyDaysAgo = new Date()
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30)
const stale = await prisma.note.findMany({
where: {
userId,
trashedAt: null,
isArchived: false,
updatedAt: { lt: threeDaysAgo, gte: thirtyDaysAgo },
OR: [
{ notebookId: null },
{ isPinned: true },
{ reminder: { not: null }, isReminderDone: false },
],
},
select: { id: true, title: true, notebookId: true, updatedAt: true },
orderBy: { updatedAt: 'desc' },
take: limit,
})
const now = Date.now()
return stale.map(n => ({
id: n.id,
title: n.title,
notebookId: n.notebookId,
daysStale: Math.floor((now - n.updatedAt.getTime()) / (1000 * 60 * 60 * 24)),
}))
}
export async function buildDashboardPaths(input: BuildPathsInput): Promise<DashboardPath[]> {
const paths: DashboardPath[] = []
const focus = input.recentNotes[0]
let focusClusterId: number | null = null
if (focus) {
const member = await prisma.clusterMember.findFirst({
where: { userId: input.userId, noteId: focus.id },
select: { clusterId: true },
})
focusClusterId = member?.clusterId ?? null
paths.push({
id: `continue-${focus.id}`,
type: 'continue',
priority: 100,
title: focus.title || 'Untitled',
description: excerpt(focus.content, 140),
actionKey: 'continue',
noteId: focus.id,
notebookId: focus.notebookId ?? undefined,
})
pathsFromInsights(focus, input.insights, paths)
const linkSuggestions = await findLinkSuggestions(input.userId, focus, 2)
for (const link of linkSuggestions) {
paths.push({
id: `add-link-${focus.id}-${link.noteId}`,
type: 'add-link',
priority: 75,
title: link.noteTitle || 'Untitled',
description: link.snippet,
actionKey: 'addLink',
noteId: focus.id,
note2Id: link.noteId,
notebookId: focus.notebookId ?? undefined,
score: link.score,
})
}
}
const freshInsight = input.insights.find(i => !i.viewed)
if (freshInsight) {
paths.push({
id: `resurface-${freshInsight.id}`,
type: 'resurface',
priority: 85,
title: `${freshInsight.note1.title || '…'}${freshInsight.note2.title || '…'}`,
description: freshInsight.insight,
actionKey: 'openInsight',
insightId: freshInsight.id,
noteId: freshInsight.note1.id,
note2Id: freshInsight.note2.id,
score: Math.round(freshInsight.score * 100),
})
}
for (const bridge of input.bridgeSuggestions.slice(0, 2)) {
const relevant = focusClusterId === null
|| bridge.clusterAId === focusClusterId
|| bridge.clusterBId === focusClusterId
paths.push({
id: `bridge-${bridge.clusterAId}-${bridge.clusterBId}`,
type: 'bridge',
priority: relevant ? 80 : 55,
title: bridge.suggestedTitle,
description: bridge.justification || bridge.suggestedContent.slice(0, 140),
actionKey: 'createBridge',
clusterAId: bridge.clusterAId,
clusterBId: bridge.clusterBId,
notebookId: focus?.notebookId ?? undefined,
})
}
for (const agent of input.agentSuggestions.slice(0, 2)) {
const relevant = focusClusterId !== null && agent.clusterId === focusClusterId
paths.push({
id: `research-${agent.id}`,
type: 'research',
priority: relevant ? 78 : 50,
title: agent.topic,
description: agent.reason,
actionKey: 'createAgent',
agentSuggestionId: agent.id,
notebookId: focus?.notebookId ?? undefined,
})
}
if (focusClusterId !== null) {
const cluster = await prisma.noteCluster.findFirst({
where: { userId: input.userId, clusterId: focusClusterId },
select: { name: true, noteCount: true },
})
if (cluster) {
paths.push({
id: `explore-cluster-${focusClusterId}`,
type: 'explore',
priority: 60,
title: clusteringService.displayName(cluster.name, focusClusterId),
description: `${cluster.noteCount} notes in this theme`,
actionKey: 'exploreTheme',
clusterAId: focusClusterId,
})
}
}
if (input.inboxCount > 0) {
paths.push({
id: 'organize-inbox',
type: 'organize',
priority: 70,
title: `${input.inboxCount} notes`,
description: 'inbox',
actionKey: 'organizeInbox',
})
}
if (input.dueFlashcards > 0) {
paths.push({
id: 'review-flashcards',
type: 'review',
priority: 65,
title: `${input.dueFlashcards}`,
description: 'flashcards',
actionKey: 'reviewCards',
})
}
paths.push({
id: 'daily-journal',
type: 'daily',
priority: 40,
title: new Date().toISOString().slice(0, 10),
description: 'daily',
actionKey: 'openDaily',
})
return paths
.sort((a, b) => b.priority - a.priority)
.slice(0, 8)
}
export async function buildOpenLoops(userId: string) {
return findOpenLoops(userId, 5)
}

View File

@@ -190,6 +190,46 @@ local newCount = tonumber(redis.call('GET', KEYS[1]))
return newCount
`;
const RELEASE_LUA = `
local current = tonumber(redis.call('GET', KEYS[1]) or '0')
if current <= 0 then return 0 end
redis.call('DECR', KEYS[1])
return tonumber(redis.call('GET', KEYS[1]) or '0')
`;
export async function releaseUsage(userId: string, feature: string): Promise<void> {
if (!isValidFeature(feature)) return;
const tier = await getEffectiveTier(userId);
const limit = await getLimitAsync(tier, feature);
if (limit === undefined || limit === Infinity) return;
try {
const key = getRedisKey(userId, feature);
const raw = await redis.eval(RELEASE_LUA, 1, key);
const newCount = parseReserveResult(raw);
if (Number.isFinite(newCount) && newCount >= 0) {
await mirrorUsageCountToDatabase(userId, feature, newCount);
}
} catch (err) {
console.error('[entitlements] Redis release failed, trying DB fallback:', err);
try {
const { periodStart } = getPeriodDates();
const existing = await prisma.usageLog.findUnique({
where: { userId_feature_periodStart: { userId, feature, periodStart } },
});
const current = existing?.requestsCount ?? 0;
if (current <= 0) return;
await prisma.usageLog.update({
where: { userId_feature_periodStart: { userId, feature, periodStart } },
data: { requestsCount: current - 1, syncedAt: new Date() },
});
} catch (dbErr) {
console.error('[entitlements] DB release fallback failed:', dbErr);
}
}
}
const RESERVE_LUA = `
local limit = tonumber(ARGV[1])
local ttl = tonumber(ARGV[2])
@@ -472,6 +512,9 @@ export async function getUserQuotas(
return result;
} catch (err) {
console.error('[entitlements] getUserQuotas Redis error:', err);
if (shouldFailClosedOnRedisError()) {
throw new QuotaServiceUnavailableError();
}
const result: Record<string, { remaining: number; limit: number; used: number }> = {};
for (const feature of features) {
const limit = (await getLimitAsync(tier, feature)) ?? 0;

View File

@@ -0,0 +1,204 @@
/**
* Gmail Scanner — extrait vols, colis, abonnements → notes + rappels
*/
import prisma from '@/lib/prisma'
import { getChatProvider } from '@/lib/ai/factory'
import { getSystemConfig } from '@/lib/config'
import { getGoogleTokens, refreshGoogleAccessToken } from '@/lib/integrations/google-integration-tokens'
const GMAIL_API = 'https://gmail.googleapis.com/gmail/v1/users/me'
const SCAN_QUERIES: { category: string; q: string }[] = [
{ category: 'flight', q: 'subject:(flight OR boarding OR vol OR "boarding pass") newer_than:7d' },
{ category: 'package', q: 'from:(amazon OR ups OR fedex OR dhl OR laposte OR colissimo) newer_than:7d' },
{ category: 'subscription', q: 'subject:(renewal OR reconduction OR renouvellement OR abonnement) newer_than:30d' },
]
function decodeBase64Url(data: string): string {
const normalized = data.replace(/-/g, '+').replace(/_/g, '/')
return Buffer.from(normalized, 'base64').toString('utf-8')
}
function extractBody(payload: any): string {
if (!payload) return ''
if (payload.body?.data) {
const raw = decodeBase64Url(payload.body.data)
return raw.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 4000)
}
if (Array.isArray(payload.parts)) {
for (const part of payload.parts) {
if (part.mimeType === 'text/plain' && part.body?.data) {
return decodeBase64Url(part.body.data).slice(0, 4000)
}
}
for (const part of payload.parts) {
const nested = extractBody(part)
if (nested) return nested
}
}
return ''
}
async function gmailFetch(userId: string, path: string, accessToken: string, refreshToken: string) {
let token = accessToken
let res = await fetch(`${GMAIL_API}${path}`, {
headers: { Authorization: `Bearer ${token}` },
})
if (res.status === 401 && refreshToken) {
const refreshed = await refreshGoogleAccessToken(userId, 'gmail', refreshToken)
if (refreshed) {
token = refreshed
res = await fetch(`${GMAIL_API}${path}`, {
headers: { Authorization: `Bearer ${token}` },
})
}
}
return res
}
async function extractStructured(
category: string,
subject: string,
body: string,
): Promise<Record<string, unknown> | null> {
const config = await getSystemConfig()
const provider = getChatProvider(config)
if (!provider) return null
const schemas: Record<string, string> = {
flight: '{"airline":"","flightNumber":"","departureDate":"ISO8601","departureAirport":"","arrivalAirport":"","confirmationCode":""}',
package: '{"carrier":"","trackingNumber":"","expectedDelivery":"ISO8601","status":""}',
subscription: '{"serviceName":"","renewalDate":"ISO8601","amount":0,"currency":"EUR"}',
}
const prompt = `Extrais les données structurées de cet email (${category}). Retourne UNIQUEMENT du JSON valide selon ce schéma: ${schemas[category] || '{}'}
Sujet: ${subject}
Corps: ${body.slice(0, 2500)}`
try {
const raw = await provider.generateText(prompt)
const match = raw.match(/\{[\s\S]*\}/)
if (!match) return null
return JSON.parse(match[0])
} catch {
return null
}
}
function buildNoteFromExtraction(category: string, subject: string, data: Record<string, unknown>) {
if (category === 'flight') {
const title = `Vol ${data.flightNumber || data.airline || ''}`.trim() || subject.slice(0, 80)
const dep = data.departureDate ? new Date(String(data.departureDate)) : null
const reminder = dep && !Number.isNaN(dep.getTime())
? new Date(dep.getTime() - 3 * 60 * 60 * 1000)
: null
const html = `<p><strong>${title}</strong></p><p>${data.departureAirport || ''}${data.arrivalAirport || ''}</p><p>Confirmation: ${data.confirmationCode || '—'}</p>`
return { title, content: html, reminder }
}
if (category === 'package') {
const title = `Colis ${data.trackingNumber || data.carrier || ''}`.trim() || subject.slice(0, 80)
const del = data.expectedDelivery ? new Date(String(data.expectedDelivery)) : null
const reminder = del && !Number.isNaN(del.getTime()) ? del : null
const html = `<p><strong>${title}</strong></p><p>Transporteur: ${data.carrier || '—'}</p><p>Suivi: ${data.trackingNumber || '—'}</p><p>Statut: ${data.status || '—'}</p>`
return { title, content: html, reminder }
}
const title = `Abonnement ${data.serviceName || ''}`.trim() || subject.slice(0, 80)
const ren = data.renewalDate ? new Date(String(data.renewalDate)) : null
const reminder = ren && !Number.isNaN(ren.getTime())
? new Date(ren.getTime() - 7 * 24 * 60 * 60 * 1000)
: null
const html = `<p><strong>${title}</strong></p><p>Renouvellement: ${data.renewalDate || '—'}</p><p>Montant: ${data.amount ?? '—'} ${data.currency || ''}</p>`
return { title, content: html, reminder }
}
export class GmailScannerService {
async scanUser(userId: string): Promise<{ processed: number; created: number }> {
const tokens = await getGoogleTokens(userId, 'gmail')
if (!tokens) return { processed: 0, created: 0 }
let processed = 0
let created = 0
for (const { category, q } of SCAN_QUERIES) {
const listRes = await gmailFetch(
userId,
`/messages?maxResults=5&q=${encodeURIComponent(q)}`,
tokens.accessToken,
tokens.refreshToken,
)
if (!listRes.ok) continue
const listJson = await listRes.json()
const ids: string[] = (listJson.messages || []).map((m: { id: string }) => m.id)
for (const messageId of ids) {
const exists = await prisma.gmailScanHistory.findUnique({
where: { userId_messageId: { userId, messageId } },
})
if (exists) continue
const msgRes = await gmailFetch(
userId,
`/messages/${messageId}?format=full`,
tokens.accessToken,
tokens.refreshToken,
)
if (!msgRes.ok) continue
processed++
const msg = await msgRes.json()
const headers: { name: string; value: string }[] = msg.payload?.headers || []
const subject = headers.find(h => h.name.toLowerCase() === 'subject')?.value || '(Sans objet)'
const body = extractBody(msg.payload)
const extracted = await extractStructured(category, subject, body)
if (!extracted) {
await prisma.gmailScanHistory.create({
data: { userId, messageId, category, data: { subject, skipped: true } },
})
continue
}
const built = buildNoteFromExtraction(category, subject, extracted)
const note = await prisma.note.create({
data: {
userId,
title: built.title,
content: built.content,
type: 'richtext',
labels: JSON.stringify(['gmail', category]),
reminder: built.reminder,
autoGenerated: true,
},
})
await prisma.gmailScanHistory.create({
data: {
userId,
messageId,
category,
data: extracted as object,
noteId: note.id,
},
})
created++
}
}
return { processed, created }
}
async getStatus(userId: string) {
const tokens = await getGoogleTokens(userId, 'gmail')
const recent = await prisma.gmailScanHistory.count({
where: {
userId,
scannedAt: { gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) },
noteId: { not: null },
},
})
return { connected: !!tokens, recentCaptures: recent }
}
}
export const gmailScannerService = new GmailScannerService()

View File

@@ -0,0 +1,66 @@
import prisma from '@/lib/prisma'
export function getGoogleOAuthCredentials() {
return {
clientId: process.env.GOOGLE_CALENDAR_CLIENT_ID || process.env.AUTH_GOOGLE_ID || '',
clientSecret: process.env.GOOGLE_CALENDAR_CLIENT_SECRET || process.env.AUTH_GOOGLE_SECRET || '',
}
}
export async function readIntegrationMeta(userId: string): Promise<Record<string, unknown>> {
const aiSettings = await prisma.userAISettings.findUnique({
where: { userId },
select: { integrationTokens: true },
})
try {
const raw = aiSettings?.integrationTokens
if (!raw) return {}
return typeof raw === 'string' ? JSON.parse(raw) : (raw as Record<string, unknown>)
} catch {
return {}
}
}
export async function writeIntegrationMeta(userId: string, meta: Record<string, unknown>) {
await prisma.userAISettings.upsert({
where: { userId },
update: { integrationTokens: JSON.stringify(meta) },
create: { userId, integrationTokens: JSON.stringify(meta) },
})
}
export async function getGoogleTokens(
userId: string,
prefix: 'calendar' | 'gmail',
): Promise<{ accessToken: string; refreshToken: string } | null> {
const meta = await readIntegrationMeta(userId)
const accessToken = meta[`${prefix}AccessToken`] as string | undefined
const refreshToken = meta[`${prefix}RefreshToken`] as string | undefined
if (accessToken && refreshToken) return { accessToken, refreshToken }
return null
}
export async function refreshGoogleAccessToken(
userId: string,
prefix: 'calendar' | 'gmail',
refreshToken: string,
): Promise<string | null> {
const { clientId, clientSecret } = getGoogleOAuthCredentials()
const res = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: clientId,
client_secret: clientSecret,
refresh_token: refreshToken,
grant_type: 'refresh_token',
}),
})
const data = await res.json()
if (!data.access_token) return null
const meta = await readIntegrationMeta(userId)
meta[`${prefix}AccessToken`] = data.access_token
await writeIntegrationMeta(userId, meta)
return data.access_token as string
}

View File

@@ -0,0 +1,35 @@
import { getEffectiveTier, QuotaExceededError } from '@/lib/entitlements'
import { reserveAiUsageOrThrow } from '@/lib/ai-quota'
import type { SubscriptionTier } from '@/lib/plan-entitlements'
import type { FeatureName } from '@/lib/quota-utils'
/** MCP (clés API + outils externes) réservé aux plans payants + 1 unité chat par invocation. */
export function isMcpTierAllowed(tier: SubscriptionTier): boolean {
return tier !== 'BASIC'
}
export class McpTierLimitedError extends Error {
code = 'MCP_TIER_LIMITED' as const
constructor(public tier: SubscriptionTier) {
super('MCP requires a Pro plan or higher')
}
}
export async function assertMcpAccess(userId: string): Promise<SubscriptionTier> {
const tier = await getEffectiveTier(userId)
if (!isMcpTierAllowed(tier)) {
throw new McpTierLimitedError(tier)
}
return tier
}
/** Tier gate + monthly chat quota (MCP tool calls bill like chat). */
export async function assertMcpAccessWithQuota(
userId: string,
feature: FeatureName = 'chat',
): Promise<SubscriptionTier> {
const tier = await assertMcpAccess(userId)
await reserveAiUsageOrThrow(userId, feature, { lane: 'chat' })
return tier
}

View File

@@ -3,6 +3,9 @@ import { VALID_FEATURES } from './quota-utils';
export type SubscriptionTier = 'BASIC' | 'PRO' | 'BUSINESS' | 'ENTERPRISE';
/** Stored in PlanEntitlement.limitValue — feature explicitly disabled via admin. */
export const ENTITLEMENT_UNAVAILABLE = -1;
/** Hardcoded defaults — used when DB is empty or unavailable. */
export const FALLBACK_TIER_LIMITS: Record<
SubscriptionTier,
@@ -98,18 +101,24 @@ function buildLimitMapFromRows(
for (const row of rows) {
map[row.tier][row.feature] =
row.limitValue === null ? Infinity : row.limitValue;
row.limitValue === null
? Infinity
: row.limitValue === ENTITLEMENT_UNAVAILABLE
? ENTITLEMENT_UNAVAILABLE
: row.limitValue;
}
return map;
}
/** Merge DB entitlements with FALLBACK for features added after admin seeding. */
/** Merge DB entitlements with FALLBACK for features added after admin seeding.
* Does NOT override ENTITLEMENT_UNAVAILABLE (-1) or explicit limits. */
function mergeWithFallback(map: LimitMap): LimitMap {
const fallback = fallbackToLimitMap();
for (const tier of Object.keys(fallback) as SubscriptionTier[]) {
for (const [feature, limit] of Object.entries(fallback[tier])) {
if (map[tier][feature] === undefined) {
const current = map[tier][feature];
if (current === undefined) {
map[tier][feature] = limit;
}
}
@@ -157,7 +166,7 @@ export async function getLimitAsync(
): Promise<number | undefined> {
const map = await loadLimitMap();
const limit = map[tier]?.[feature];
if (limit === undefined) return undefined;
if (limit === undefined || limit === ENTITLEMENT_UNAVAILABLE) return undefined;
if (limit === Infinity) return Infinity;
return limit;
}
@@ -184,7 +193,9 @@ export async function getAllEntitlementsForAdmin(): Promise<
feature: r.feature,
limitValue: r.limitValue,
mode:
r.limitValue === null
r.limitValue === ENTITLEMENT_UNAVAILABLE
? ('unavailable' as const)
: r.limitValue === null
? ('unlimited' as const)
: ('limited' as const),
}));

Some files were not shown because too many files have changed in this diff Show More