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>
This commit is contained in:
Antigravity
2026-07-15 20:42:25 +00:00
parent 30da592ba2
commit 4fe31ebc99
75 changed files with 2949 additions and 785 deletions

View File

@@ -78,3 +78,6 @@ TELEGRAM_CHAT_ID=<var>
# Monitoring # Monitoring
METRICS_TOKEN=<secret> METRICS_TOKEN=<secret>
GRAFANA_ADMIN_PASSWORD=<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 }} TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
METRICS_TOKEN: ${{ secrets.METRICS_TOKEN }} METRICS_TOKEN: ${{ secrets.METRICS_TOKEN }}
GRAFANA_ADMIN_PASSWORD: ${{ secrets.GRAFANA_ADMIN_PASSWORD }} GRAFANA_ADMIN_PASSWORD: ${{ secrets.GRAFANA_ADMIN_PASSWORD }}
GRAFANA_BIND: ${{ vars.GRAFANA_BIND }}
GRAFANA_URL: ${{ vars.GRAFANA_URL }}
MCP_API_KEY: ${{ secrets.MCP_API_KEY }} MCP_API_KEY: ${{ secrets.MCP_API_KEY }}
run: | run: |
ENV_FILE="/opt/memento/.env.docker" ENV_FILE="/opt/memento/.env.docker"
@@ -232,6 +234,8 @@ jobs:
upsert TELEGRAM_CHAT_ID "$TELEGRAM_CHAT_ID" upsert TELEGRAM_CHAT_ID "$TELEGRAM_CHAT_ID"
upsert METRICS_TOKEN "$METRICS_TOKEN" upsert METRICS_TOKEN "$METRICS_TOKEN"
upsert GRAFANA_ADMIN_PASSWORD "$GRAFANA_ADMIN_PASSWORD" upsert GRAFANA_ADMIN_PASSWORD "$GRAFANA_ADMIN_PASSWORD"
upsert GRAFANA_BIND "$GRAFANA_BIND"
upsert GRAFANA_URL "$GRAFANA_URL"
upsert MCP_API_KEY "$MCP_API_KEY" upsert MCP_API_KEY "$MCP_API_KEY"
# Write metrics token file for Prometheus (same secret) # 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 [ -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 }} TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
METRICS_TOKEN: ${{ secrets.METRICS_TOKEN }} METRICS_TOKEN: ${{ secrets.METRICS_TOKEN }}
GRAFANA_ADMIN_PASSWORD: ${{ secrets.GRAFANA_ADMIN_PASSWORD }} GRAFANA_ADMIN_PASSWORD: ${{ secrets.GRAFANA_ADMIN_PASSWORD }}
GRAFANA_BIND: ${{ vars.GRAFANA_BIND }}
GRAFANA_URL: ${{ vars.GRAFANA_URL }}
MCP_API_KEY: ${{ secrets.MCP_API_KEY }} MCP_API_KEY: ${{ secrets.MCP_API_KEY }}
run: | run: |
ENV_FILE="/opt/memento/.env.docker" ENV_FILE="/opt/memento/.env.docker"
@@ -121,6 +123,8 @@ jobs:
upsert TELEGRAM_CHAT_ID "$TELEGRAM_CHAT_ID" upsert TELEGRAM_CHAT_ID "$TELEGRAM_CHAT_ID"
upsert METRICS_TOKEN "$METRICS_TOKEN" upsert METRICS_TOKEN "$METRICS_TOKEN"
upsert GRAFANA_ADMIN_PASSWORD "$GRAFANA_ADMIN_PASSWORD" upsert GRAFANA_ADMIN_PASSWORD "$GRAFANA_ADMIN_PASSWORD"
upsert GRAFANA_BIND "$GRAFANA_BIND"
upsert GRAFANA_URL "$GRAFANA_URL"
upsert MCP_API_KEY "$MCP_API_KEY" 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 [ -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)" echo "env.docker sanity-check passed ($(wc -l < "$ENV_FILE") lines)"

View File

@@ -2,18 +2,18 @@
## Learned User Preferences ## Learned User Preferences
- Préfère les échanges en français, avec des explications détaillées et claires (éviter le jargon flou). - Préfère les échanges en français, avec des explications détaillées et claires (éviter le jargon flou) ; **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. - 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.** - 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. - 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). - Flux Excalidraw / diagrammes générés : accès via notification en plus d'une simple redirection ; priorité à la mise en page et au texte contenu dans les formes ; proposer des modes visuels (ex. coloré vs plus austère) tout en visant un rendu proche du style Excalidraw (polices, look).
- **Interdiction d'écrire des tests** sauf demande explicite ; en CI, seul `npm run test:unit` (`tests/unit/**`) — pas `tests/migration/` ; ne jamais générer de code superflu. - **Interdiction d'écrire des tests** sauf demande explicite ; en CI, seul `npm run test:unit` (`tests/unit/**`) — pas `tests/migration/` ; ne jamais générer de code superflu.
- Déploiement : privilégier le chemin rapide (artifact Next.js en CI + `Dockerfile.prebuilt`) ; CI/CD très robuste (pas d'image Docker obsolète en prod, pas de migrations/schéma DB via le workflow deploy) ; éviter les rebuild Docker complets inutiles (~15 min par itération) ; **ne pas pousser un déploiement quand des features clés sont inachevées** ; ne pas insister sur le déploiement tant que le produit n'est pas fini ou meilleur. **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. - 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). - 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 ») ; **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. - 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 »). - 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`. - **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 ## 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). - 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`. - 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`. - **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). - **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`, deploy sur runner **`docker-host`** (sur le serveur) ; deploy manuel via `.gitea/workflows/deploy.yaml` ou `bash scripts/deploy-prod.sh`. - 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. - 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**. - É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). - 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' cpus: '0.25'
memory: 128M memory: 128M
healthcheck: 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 interval: 30s
timeout: 10s timeout: 10s
retries: 3 retries: 3

View File

@@ -28,6 +28,28 @@ function setCachedKey(keyHash, data) {
keyCache.set(keyHash, { data, timestamp: Date.now() }); 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. * Generate a new API key.
* @param {import('@prisma/client').PrismaClient} prisma * @param {import('@prisma/client').PrismaClient} prisma
@@ -111,6 +133,9 @@ export async function validateApiKey(prisma, rawKey) {
const info = JSON.parse(entry.value); const info = JSON.parse(entry.value);
if (info.keyHash !== keyHash || !info.active) return null; 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(); info.lastUsedAt = new Date().toISOString();
prisma.systemConfig.update({ prisma.systemConfig.update({
where: { key: configKey }, where: { key: configKey },

File diff suppressed because it is too large Load Diff

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 { auth } from '@/auth'
import { redirect } from 'next/navigation' import { redirect } from 'next/navigation'
import { McpSettingsPanel } from '@/components/mcp/mcp-settings-panel' import { McpSettingsPanel } from '@/components/mcp/mcp-settings-panel'
import { listMcpKeys, getMcpServerStatus } from '@/app/actions/mcp-keys' import { listMcpKeys, getMcpServerStatus, getMcpAccessStatus } from '@/app/actions/mcp-keys'
import { SettingsHelpBox } from '@/components/settings/settings-help-box' import { McpSettingsHeader } from './mcp-settings-header'
export default async function McpSettingsPage() { export default async function McpSettingsPage() {
const session = await auth() const session = await auth()
if (!session?.user) redirect('/api/auth/signin') if (!session?.user) redirect('/api/auth/signin')
const [keys, serverStatus] = await Promise.all([ const [keys, serverStatus, access] = await Promise.all([
listMcpKeys(), listMcpKeys(),
getMcpServerStatus(), getMcpServerStatus(),
getMcpAccessStatus(),
]) ])
return ( return (
<div className="space-y-8"> <div className="space-y-8">
<SettingsHelpBox <McpSettingsHeader />
title="Qu'est-ce que MCP (Model Context Protocol) ?" <McpSettingsPanel initialKeys={keys} serverStatus={serverStatus} mcpAllowed={access.allowed} tier={access.tier} />
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} />
</div> </div>
) )
} }

View File

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

View File

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

View File

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

View File

@@ -4,6 +4,7 @@ import { auth } from '@/auth'
import { autoLabelCreationService } from '@/lib/ai/services' import { autoLabelCreationService } from '@/lib/ai/services'
import { getAISettings } from '@/app/actions/ai-settings' import { getAISettings } from '@/app/actions/ai-settings'
import { hasUserAiConsent } from '@/lib/consent/server-consent' 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 * 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 withAiQuota(
const suggestions = await autoLabelCreationService.suggestLabels(
notebookId,
session.user.id, session.user.id,
language 'auto_tag',
() =>
autoLabelCreationService.suggestLabels(
notebookId,
session.user!.id,
language,
),
{ lane: 'tags' },
) )
if (!suggestions) { if (!suggestions) {
@@ -79,8 +85,10 @@ export async function POST(request: NextRequest) {
data: suggestions, data: suggestions,
}) })
} catch (error) { } catch (error) {
const quotaResp = handleQuotaHttpError(error)
if (quotaResp) return quotaResp
console.error('[/api/ai/auto-labels] POST failed:', error) 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 { auth } from '@/auth'
import { batchOrganizationService } from '@/lib/ai/services' import { batchOrganizationService } from '@/lib/ai/services'
import { hasUserAiConsent } from '@/lib/consent/server-consent' 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 * 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 // Create organization plan
const plan = await batchOrganizationService.createOrganizationPlan( const plan = await batchOrganizationService.createOrganizationPlan(
session.user.id, session.user.id,

View File

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

View File

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

View File

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

View File

@@ -3,6 +3,7 @@ import { auth } from '@/auth'
import { getSystemConfig } from '@/lib/config' import { getSystemConfig } from '@/lib/config'
import { getTagsProvider } from '@/lib/ai/factory' import { getTagsProvider } from '@/lib/ai/factory'
import { hasUserAiConsent } from '@/lib/consent/server-consent' import { hasUserAiConsent } from '@/lib/consent/server-consent'
import { withAiQuota, handleQuotaHttpError } from '@/lib/ai-quota'
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
@@ -32,7 +33,6 @@ export async function POST(request: NextRequest) {
let prompt: string let prompt: string
if (mode === 'complete') { 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. 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. 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 - Format the new content consistently with the existing note style and the requested FORMAT RULE
- Respond ONLY with the enriched note content, no explanations` - Respond ONLY with the enriched note content, no explanations`
} else { } 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. 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. 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` - 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() }) 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) console.error('[enrich-from-resource] Error:', error)
return NextResponse.json( const message = error instanceof Error ? error.message : 'Failed to enrich content'
{ error: error.message || 'Failed to enrich content' }, return NextResponse.json({ error: message }, { status: 500 })
{ status: 500 }
)
} }
} }

View File

@@ -3,6 +3,7 @@ import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth' import { auth } from '@/auth'
import { notebookSummaryService } from '@/lib/ai/services' import { notebookSummaryService } from '@/lib/ai/services'
import { hasUserAiConsent } from '@/lib/consent/server-consent' import { hasUserAiConsent } from '@/lib/consent/server-consent'
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
/** /**
* POST /api/ai/notebook-summary - Generate summary for a notebook * 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 // Generate summary
const summary = await notebookSummaryService.generateSummary( const summary = await notebookSummaryService.generateSummary(
notebookId, notebookId,

View File

@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth' import { auth } from '@/auth'
import prisma from '@/lib/prisma' import prisma from '@/lib/prisma'
import { notebookOrganizerService } from '@/lib/ai/services/notebook-organizer.service' 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' import { syncNoteLabels } from '@/app/actions/notes'
export async function POST(request: NextRequest) { 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 }) 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({ const notes = await prisma.note.findMany({
where: { notebookId, trashedAt: null, userId: session.user.id }, where: { notebookId, trashedAt: null, userId: session.user.id },
select: { id: true, title: true, content: true }, 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), 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) return NextResponse.json(result)
} catch (error: any) { } catch (error: unknown) {
const quotaResp = handleQuotaHttpError(error)
if (quotaResp) return quotaResp
console.error('[Notebook Organizer] Error:', error) 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 { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth' import { auth } from '@/auth'
import { searchOverviewService } from '@/lib/ai/services/search-overview.service' import { searchOverviewService } from '@/lib/ai/services/search-overview.service'
import { withAiQuota, handleQuotaHttpError } from '@/lib/ai-quota'
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
@@ -15,11 +15,19 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'query and results required' }, { status: 400 }) 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) return NextResponse.json(overview)
} catch (error: any) { } catch (error: unknown) {
const quotaResp = handleQuotaHttpError(error)
if (quotaResp) return quotaResp
console.error('[Search Overview] Error:', error) 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 { auth } from '@/auth'
import { notebookSuggestionService } from '@/lib/ai/services/notebook-suggestion.service' import { notebookSuggestionService } from '@/lib/ai/services/notebook-suggestion.service'
import { hasUserAiConsent } from '@/lib/consent/server-consent' import { hasUserAiConsent } from '@/lib/consent/server-consent'
import { withAiQuota, handleQuotaHttpError } from '@/lib/ai-quota'
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
try { try {
@@ -21,7 +22,6 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: 'noteContent is required' }, { status: 400 }) 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 const wordCount = noteContent.trim().split(/\s+/).length
if (wordCount < 20) { if (wordCount < 20) {
return NextResponse.json({ return NextResponse.json({
@@ -31,18 +31,25 @@ export async function POST(req: NextRequest) {
}) })
} }
// Get suggestion from AI service const suggestedNotebook = await withAiQuota(
const suggestedNotebook = await notebookSuggestionService.suggestNotebook(
noteContent,
session.user.id, session.user.id,
language 'auto_tag',
() => notebookSuggestionService.suggestNotebook(
noteContent,
session.user!.id,
language
),
{ lane: 'tags' },
) )
return NextResponse.json({ return NextResponse.json({
suggestion: suggestedNotebook, suggestion: suggestedNotebook,
confidence: suggestedNotebook ? 0.8 : 0 // Placeholder confidence score confidence: suggestedNotebook ? 0.8 : 0
}) })
} catch (error) { } catch (error) {
const quotaResp = handleQuotaHttpError(error)
if (quotaResp) return quotaResp
console.error('[/api/ai/suggest-notebook] Error:', error)
return NextResponse.json( return NextResponse.json(
{ error: 'Failed to generate suggestion' }, { error: 'Failed to generate suggestion' },
{ status: 500 } { status: 500 }

View File

@@ -1,10 +1,11 @@
import { rateLimit } from '@/lib/rate-limit' import { rateLimit } from '@/lib/rate-limit'
import { NextRequest, NextResponse } from 'next/server' 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 { getSystemConfig } from '@/lib/config'
import { auth } from '@/auth' import { auth } from '@/auth'
import { getAISettings } from '@/app/actions/ai-settings' 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 { z } from 'zod'
import { hasUserAiConsent } from '@/lib/consent/server-consent' import { hasUserAiConsent } from '@/lib/consent/server-consent'
@@ -61,19 +62,16 @@ export async function POST(req: NextRequest) {
} }
const config = await getSystemConfig() const config = await getSystemConfig()
const { usedByok: willUseByok } = await willUseByokForLane('tags', config, session.user.id) try {
if (!willUseByok) { await reserveAiUsageOrThrow(session.user.id, 'auto_title', { lane: 'tags' })
try { } catch (err) {
await reserveUsageOrThrow(session.user.id, 'auto_title') if (err instanceof QuotaExceededError) {
} catch (err) { return NextResponse.json(err.toJSON(), { status: 402 })
if (err instanceof QuotaExceededError) {
return NextResponse.json(err.toJSON(), { status: 402 })
}
if (err instanceof QuotaServiceUnavailableError || process.env.NODE_ENV === 'production') {
return NextResponse.json({ error: 'QUOTA_SERVICE_UNAVAILABLE' }, { status: 503 })
}
console.error('[/api/ai/title-suggestions] Quota check error (fail-open):', err)
} }
if (err instanceof QuotaServiceUnavailableError || process.env.NODE_ENV === 'production') {
return NextResponse.json({ error: 'QUOTA_SERVICE_UNAVAILABLE' }, { status: 503 })
}
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) // Détecter la langue du contenu (simple détection basée sur les caractères et mots)

View File

@@ -3,6 +3,7 @@ import { auth } from '@/auth'
import { getChatProvider } from '@/lib/ai/factory' import { getChatProvider } from '@/lib/ai/factory'
import { getSystemConfig } from '@/lib/config' import { getSystemConfig } from '@/lib/config'
import { hasUserAiConsent } from '@/lib/consent/server-consent' import { hasUserAiConsent } from '@/lib/consent/server-consent'
import { withAiQuota, handleQuotaHttpError } from '@/lib/ai-quota'
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
@@ -18,12 +19,10 @@ export async function POST(request: NextRequest) {
const { text } = await request.json() const { text } = await request.json()
// Validation
if (!text || typeof text !== 'string') { if (!text || typeof text !== 'string') {
return NextResponse.json({ error: 'Text is required' }, { status: 400 }) return NextResponse.json({ error: 'Text is required' }, { status: 400 })
} }
// Validate word count
const wordCount = text.split(/\s+/).length const wordCount = text.split(/\s+/).length
if (wordCount < 10) { if (wordCount < 10) {
return NextResponse.json( return NextResponse.json(
@@ -42,11 +41,9 @@ export async function POST(request: NextRequest) {
const config = await getSystemConfig() const config = await getSystemConfig()
const provider = getChatProvider(config) const provider = getChatProvider(config)
// Detect language from text
const hasFrench = /[àâäéèêëïîôùûüÿç]/i.test(text) const hasFrench = /[àâäéèêëïîôùûüÿç]/i.test(text)
const responseLanguage = hasFrench ? 'French' : 'English' const responseLanguage = hasFrench ? 'French' : 'English'
// Build prompt to transform text to Markdown
const prompt = hasFrench const prompt = hasFrench
? `Tu es un expert en Markdown. Transforme ce texte ${responseLanguage} en Markdown bien formaté. ? `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.` Respond ONLY with the transformed Markdown text, no explanations.`
const transformedText = await withAiQuota(
const transformedText = await provider.generateText(prompt) session.user.id,
'reformulate',
() => provider.generateText(prompt),
{ lane: 'chat' },
)
return NextResponse.json({ return NextResponse.json({
originalText: text, originalText: text,
transformedText: transformedText, transformedText,
language: responseLanguage language: responseLanguage
}) })
} catch (error: any) { } catch (error: unknown) {
return NextResponse.json( const quotaResp = handleQuotaHttpError(error)
{ error: error.message || 'Failed to transform text to Markdown' }, if (quotaResp) return quotaResp
{ status: 500 } 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 { getTagsProvider } from '@/lib/ai/factory'
import { getSystemConfig } from '@/lib/config' import { getSystemConfig } from '@/lib/config'
import { hasUserAiConsent } from '@/lib/consent/server-consent' import { hasUserAiConsent } from '@/lib/consent/server-consent'
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
@@ -24,12 +25,18 @@ export async function POST(request: NextRequest) {
const config = await getSystemConfig() const config = await getSystemConfig()
const provider = getTagsProvider(config) 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 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) const translatedText = await provider.generateText(prompt)
return NextResponse.json({ translatedText: translatedText.trim() }) return NextResponse.json({ translatedText: translatedText.trim() })
} catch (error: any) { } catch (error: unknown) {
return NextResponse.json({ error: error.message || 'Translation failed' }, { status: 500 }) 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 { auth } from '@/auth'
import { runLaneWithBillingUser } from '@/lib/ai/provider-for-user' import { runLaneWithBillingUser } from '@/lib/ai/provider-for-user'
import { getSystemConfig } from '@/lib/config' 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 { function localeToLanguageName(locale?: string): string {
const map: Record<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 }) return NextResponse.json({ error: 'No ideas to summarize' }, { status: 400 })
} }
const { billingOwnerId, isGuestActor } = billingOwnerFromSession(
brainstormSession.userId,
session.user.id,
)
const ideasText = ideas const ideasText = ideas
.map((idea, i) => `${i + 1}. [Wave ${idea.waveNumber}] ${idea.title}: ${idea.description}`) .map((idea, i) => `${i + 1}. [Wave ${idea.waveNumber}] ${idea.title}: ${idea.description}`)
.join('\n') .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.` Be direct and action-oriented. No bullet points, just flowing prose.`
const config = await getSystemConfig() const config = await getSystemConfig()
const { result: summary } = await runLaneWithBillingUser( const summary = await withSessionAiQuota(
'tags', billingOwnerId,
config,
session.user.id, session.user.id,
(provider) => provider.generateText(prompt), isGuestActor,
'brainstorm_enrich',
async () => {
const { result } = await runLaneWithBillingUser(
'tags',
config,
billingOwnerId,
(provider) => provider.generateText(prompt),
)
return result
},
{ lane: 'tags' },
) )
return NextResponse.json({ success: true, data: { summary: summary.trim() } }) return NextResponse.json({ success: true, data: { summary: summary.trim() } })
} catch (error) { } catch (error) {
const quotaResp = handleQuotaHttpError(error)
if (quotaResp) return quotaResp
console.error('Error summarizing brainstorm:', error) console.error('Error summarizing brainstorm:', error)
return NextResponse.json({ error: 'Failed to summarize' }, { status: 500 }) return NextResponse.json({ error: 'Failed to summarize' }, { status: 500 })
} }

View File

@@ -76,7 +76,7 @@ export async function GET() {
notebookId: true, updatedAt: true, createdAt: true, notebookId: true, updatedAt: true, createdAt: true,
}, },
orderBy: { updatedAt: 'desc' }, orderBy: { updatedAt: 'desc' },
take: 6, take: 8,
}), }),
prisma.note.count({ prisma.note.count({

View File

@@ -5,6 +5,8 @@ import { getChatProvider } from '@/lib/ai/factory'
import { getSystemConfig } from '@/lib/config' import { getSystemConfig } from '@/lib/config'
import { redis } from '@/lib/redis' import { redis } from '@/lib/redis'
import { detectUserLanguage } from '@/lib/i18n/detect-user-language' 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 const CACHE_TTL_SEC = 3600
@@ -70,7 +72,12 @@ Write "summary" and "topTopic" in ${localeLabel} (locale code: ${locale}). Keep
Notes: Notes:
${snippets.slice(0, 3000)}` ${snippets.slice(0, 3000)}`
const result = await provider.generateText(prompt) const result = await withAiQuota(
userId,
'reformulate',
() => provider.generateText(prompt),
{ lane: 'chat' },
)
const jsonMatch = result.match(/\{[\s\S]*\}/) const jsonMatch = result.match(/\{[\s\S]*\}/)
if (!jsonMatch) { if (!jsonMatch) {
return NextResponse.json({ available: false, reason: 'Parse error' }) return NextResponse.json({ available: false, reason: 'Parse error' })
@@ -81,6 +88,11 @@ ${snippets.slice(0, 3000)}`
try { await redis.setex(cacheKey, CACHE_TTL_SEC, JSON.stringify(payload)) } catch {} try { await redis.setex(cacheKey, CACHE_TTL_SEC, JSON.stringify(payload)) } catch {}
return NextResponse.json(payload) return NextResponse.json(payload)
} catch (error) { } 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) console.error('[briefing/sentiment]', error)
return NextResponse.json({ available: false, reason: 'Analysis failed' }) return NextResponse.json({ available: false, reason: 'Analysis failed' })
} }

View File

@@ -1,6 +1,6 @@
import { streamText, UIMessage, stepCountIs } from 'ai' import { streamText, UIMessage, stepCountIs } from 'ai'
import { resolveAiRouteWithTiming, formatAiRouteDebug } from '@/lib/ai/router' 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 { getSystemConfig } from '@/lib/config'
import { getChatProvider } from '@/lib/ai/factory' import { getChatProvider } from '@/lib/ai/factory'
import { semanticSearchService } from '@/lib/ai/services/semantic-search.service' 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 { hasUserAiConsent } from '@/lib/consent/server-consent'
import { loadTranslations, getTranslationValue, SupportedLanguage } from '@/lib/i18n' import { loadTranslations, getTranslationValue, SupportedLanguage } from '@/lib/i18n'
import { toolRegistry } from '@/lib/ai/tools' 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 { ByokUnavailableError } from '@/lib/byok'
import { trackFeatureUsage } from '@/lib/usage-tracker' import { trackFeatureUsage } from '@/lib/usage-tracker'
import { readFile } from 'fs/promises' 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) // 1.5 Quota check (per-provider BYOK bypass — only when BYOK will be used for resolved provider)
try { try {
const sysConfigEarly = await getSystemConfig() await reserveAiUsageOrThrow(userId, 'chat', { lane: 'chat' })
const { usedByok: willUseByok } = await willUseByokForLane('chat', sysConfigEarly, userId)
if (!willUseByok) {
await reserveUsageOrThrow(userId, 'chat')
}
} catch (err) { } catch (err) {
if (err instanceof QuotaExceededError) { if (err instanceof QuotaExceededError) {
return Response.json(err.toJSON(), { status: 402 }) return Response.json(err.toJSON(), { status: 402 })

View File

@@ -18,6 +18,9 @@ export async function GET(request: NextRequest) {
const userId = session.user.id 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) // Check for stored results (even if stale/périmés)
const stored = await clusteringService.getStoredClusters(userId) const stored = await clusteringService.getStoredClusters(userId)
const lite = request.nextUrl.searchParams.get('lite') === '1' const lite = request.nextUrl.searchParams.get('lite') === '1'
@@ -51,7 +54,11 @@ export async function GET(request: NextRequest) {
bridgeScore: b.bridgeScore, bridgeScore: b.bridgeScore,
clustersConnected, clustersConnected,
clusterNames: clustersConnected.map( clusterNames: clustersConnected.map(
cid => cached.find(c => c.clusterId === cid)?.name || `Cluster ${cid}`, cid =>
clusteringService.displayName(
cached.find(c => c.clusterId === cid)?.name,
cid,
),
), ),
note: bridgeNoteDetailsMap.get(b.noteId), note: bridgeNoteDetailsMap.get(b.noteId),
} }
@@ -112,8 +119,11 @@ export async function GET(request: NextRequest) {
noteId: b.noteId, noteId: b.noteId,
bridgeScore: b.bridgeScore, bridgeScore: b.bridgeScore,
clustersConnected, clustersConnected,
clusterNames: clustersConnected.map( clusterNames: clustersConnected.map(cid =>
cid => cached.find(c => c.clusterId === cid)?.name || `Cluster ${cid}` clusteringService.displayName(
cached.find(c => c.clusterId === cid)?.name,
cid,
),
), ),
note: bridgeNoteDetailsMap.get(b.noteId) note: bridgeNoteDetailsMap.get(b.noteId)
} }
@@ -199,12 +209,10 @@ export async function POST(request: NextRequest) {
}) })
} }
// 2. Generate cluster names with AI // 2. Name from in-memory members (DB members do not exist until save)
for (const cluster of results.clusters) { await clusteringService.nameClustersFromResults(userId, results)
cluster.name = await clusteringService.generateClusterName(cluster.clusterId, userId)
}
// 3. Save clustering results // 3. Save clustering results (with real names)
await clusteringService.saveClusteringResults(userId, results) await clusteringService.saveClusteringResults(userId, results)
// 4. Detect and save bridge notes // 4. Detect and save bridge notes

View File

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

View File

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

View File

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

View File

@@ -76,9 +76,11 @@ export function DashboardAgentCarousel({
/> />
{suggestions.length === 0 ? ( {suggestions.length === 0 ? (
<p className="text-[11px] text-concrete italic leading-relaxed py-2"> <div className="rounded-xl border border-dashed border-border/35 bg-stone-50/50 dark:bg-zinc-950/30 p-3">
{t('homeDashboard.agentsEmpty')} <p className="text-[11px] text-concrete leading-relaxed">
</p> {t('homeDashboard.agentsEmpty')}
</p>
</div>
) : ( ) : (
<AgentSlide <AgentSlide
current={suggestions[idx]} current={suggestions[idx]}

View File

@@ -294,6 +294,7 @@ export function DashboardActivityWidget({
loading: boolean loading: boolean
}) { }) {
const { t } = useLanguage() const { t } = useLanguage()
const hasActivity = data.some(d => d.count > 0)
return ( return (
<DashboardWidgetShell <DashboardWidgetShell
widgetId="activity" widgetId="activity"
@@ -301,11 +302,17 @@ export function DashboardActivityWidget({
title={t('homeDashboard.widgets.activity')} title={t('homeDashboard.widgets.activity')}
> >
{loading ? ( {loading ? (
<div className="h-24 rounded-lg bg-stone-50 animate-pulse" /> <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} /> <>
<RevisionHeatmap data={data} />
<p className="text-[9px] text-concrete mt-2">{t('homeDashboard.widgetActivityHint')}</p>
</>
)} )}
<p className="text-[9px] text-concrete mt-2">{t('homeDashboard.widgetActivityHint')}</p>
</DashboardWidgetShell> </DashboardWidgetShell>
) )
} }
@@ -329,16 +336,20 @@ export function DashboardFlashcardsProgressWidget({
retentionRate, retentionRate,
streak, streak,
totalCards, totalCards,
dueCount = 0,
loading, loading,
onOpen, onOpen,
}: { }: {
retentionRate: number retentionRate: number
streak: number streak: number
totalCards: number totalCards: number
dueCount?: number
loading?: boolean loading?: boolean
onOpen: () => void onOpen: () => void
}) { }) {
const { t } = useLanguage() const { t } = useLanguage()
const retention = Math.max(0, Math.min(100, retentionRate))
return ( return (
<DashboardWidgetShell <DashboardWidgetShell
widgetId="flashcards-progress" widgetId="flashcards-progress"
@@ -347,12 +358,28 @@ export function DashboardFlashcardsProgressWidget({
compact compact
> >
{loading ? ( {loading ? (
<div className="h-14 rounded-lg bg-stone-50 animate-pulse" /> <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"> <button type="button" onClick={onOpen} className="w-full text-start group">
<div className="grid grid-cols-3 gap-2"> <div className="grid grid-cols-3 gap-2 mb-2.5">
<div className="text-center p-2 rounded-xl border border-border/20"> <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">{retentionRate}%</p> <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> <p className="text-[8px] font-mono uppercase text-concrete">{t('homeDashboard.flashRetention')}</p>
</div> </div>
<div className="text-center p-2 rounded-xl border border-border/20"> <div className="text-center p-2 rounded-xl border border-border/20">
@@ -360,10 +387,25 @@ export function DashboardFlashcardsProgressWidget({
<p className="text-[8px] font-mono uppercase text-concrete">{t('homeDashboard.flashStreak')}</p> <p className="text-[8px] font-mono uppercase text-concrete">{t('homeDashboard.flashStreak')}</p>
</div> </div>
<div className="text-center p-2 rounded-xl border border-border/20"> <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}</p> <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> <p className="text-[8px] font-mono uppercase text-concrete">{t('homeDashboard.flashTotal')}</p>
</div> </div>
</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> </button>
)} )}
</DashboardWidgetShell> </DashboardWidgetShell>

View File

@@ -1,7 +1,7 @@
'use client' 'use client'
import { motion } from 'motion/react' import { motion } from 'motion/react'
import { Clock, ChevronRight, Play } from 'lucide-react' import { Clock, ChevronRight, Play, PenLine } from 'lucide-react'
import { useLanguage } from '@/lib/i18n' import { useLanguage } from '@/lib/i18n'
import { DashboardWidgetTitleRow } from '@/components/dashboard-widget-title-row' import { DashboardWidgetTitleRow } from '@/components/dashboard-widget-title-row'
@@ -19,6 +19,7 @@ export interface DashboardResumeHeroProps {
notes: ResumeNote[] notes: ResumeNote[]
loading?: boolean loading?: boolean
onSelect: (id: string, notebookId: string | null) => void onSelect: (id: string, notebookId: string | null) => void
onCaptureFocus?: () => void
formatRelativeTime: (date: string) => string formatRelativeTime: (date: string) => string
prefersReducedMotion?: boolean prefersReducedMotion?: boolean
} }
@@ -27,28 +28,24 @@ export function DashboardResumeHero({
notes, notes,
loading, loading,
onSelect, onSelect,
onCaptureFocus,
formatRelativeTime, formatRelativeTime,
prefersReducedMotion, prefersReducedMotion,
}: DashboardResumeHeroProps) { }: DashboardResumeHeroProps) {
const { t } = useLanguage() const { t } = useLanguage()
const hero = notes[0] const hero = notes[0]
const rest = notes.slice(1, 4) const rest = notes.slice(1, 6)
if (loading) { if (loading) {
return ( return (
<div className="rounded-2xl border border-border/30 bg-white dark:bg-zinc-900 p-5 min-h-[200px] animate-pulse"> <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-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-6 w-3/4 bg-stone-100 dark:bg-zinc-800 rounded mb-3" />
<div className="h-12 bg-stone-50 dark:bg-zinc-950 rounded-xl" /> <div className="h-16 bg-stone-50 dark:bg-zinc-950 rounded-xl mb-3" />
</div> <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>
if (!hero) {
return (
<div className="rounded-2xl border border-dashed border-border/40 bg-white/50 dark:bg-zinc-900/50 p-8 text-center">
<Clock size={24} className="mx-auto text-concrete/30 mb-3" strokeWidth={1.25} />
<p className="text-xs text-concrete italic">{t('homeDashboard.noRecentNotes')}</p>
</div> </div>
) )
} }
@@ -64,59 +61,93 @@ export function DashboardResumeHero({
/> />
</div> </div>
<motion.button {!hero ? (
type="button" <div className="px-5 pb-5">
whileHover={prefersReducedMotion ? undefined : { y: -1 }} <div className="rounded-xl border border-dashed border-border/40 bg-stone-50/60 dark:bg-zinc-950/40 p-5 text-center">
onClick={() => onSelect(hero.id, hero.notebookId)} <Clock size={22} className="mx-auto text-concrete/35 mb-2" strokeWidth={1.25} />
className="w-full text-start px-5 pb-5 group cursor-pointer" <p className="text-xs text-concrete mb-3 leading-relaxed">
> {t('homeDashboard.resumeEmptyHint')}
<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> </p>
)} {onCaptureFocus && (
<div className="flex items-center gap-1 mt-3 text-[9px] font-mono font-bold uppercase text-brand-accent opacity-0 group-hover:opacity-100 transition-opacity"> <button
{t('homeDashboard.resumeOpen')} type="button"
<ChevronRight size={12} /> 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>
</div> </div>
</motion.button> ) : (
<>
<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 && ( {rest.length > 0 && (
<div className="flex gap-2 px-5 pb-4 overflow-x-auto custom-scrollbar"> <div className="px-5 pb-4 space-y-1.5">
{rest.map(note => ( <p className="text-[8px] font-mono font-bold uppercase tracking-wider text-concrete/70 mb-1">
<button {t('homeDashboard.resumeAlso')}
key={note.id}
type="button"
onClick={() => onSelect(note.id, note.notebookId)}
className="shrink-0 max-w-[200px] p-2.5 rounded-xl border border-border/20 bg-stone-50/50 dark:bg-zinc-950/30 hover:border-brand-accent/30 transition-all text-start group"
>
<p className="text-[10px] font-semibold text-ink dark:text-dark-ink truncate group-hover:text-brand-accent transition-colors">
{note.title || t('homeDashboard.untitled')}
</p> </p>
<p className="text-[8px] font-mono text-concrete/60 mt-0.5"> {rest.map(note => (
{formatRelativeTime(note.updatedAt)} <button
</p> key={note.id}
</button> type="button"
))} onClick={() => onSelect(note.id, note.notebookId)}
</div> 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> </div>
) )

View File

@@ -29,6 +29,7 @@ import {
} from '@/components/dashboard-path-widgets' } from '@/components/dashboard-path-widgets'
import type { DashboardPath } from '@/lib/dashboard/path-types' import type { DashboardPath } from '@/lib/dashboard/path-types'
import { buildFastPathsFromBriefing } from '@/lib/dashboard/paths-fast' import { buildFastPathsFromBriefing } from '@/lib/dashboard/paths-fast'
import { emitAiUsageChanged } from '@/lib/ai-usage-sync'
import { import {
DashboardInboxWidget, DashboardInboxWidget,
DashboardRevisionWidget, DashboardRevisionWidget,
@@ -192,7 +193,8 @@ function localeForLanguage(language: string): string {
return map[language] || language return map[language] || language
} }
function stripHtml(html: string): string { function stripHtml(html: string | null | undefined): string {
if (!html) return ''
return html.replace(/<[^>]+>/g, ' ').replace(/&nbsp;/g, ' ').replace(/\s+/g, ' ').trim() return html.replace(/<[^>]+>/g, ' ').replace(/&nbsp;/g, ' ').replace(/\s+/g, ' ').trim()
} }
@@ -293,7 +295,10 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
const loadSentiment = useCallback(async () => { const loadSentiment = useCallback(async () => {
try { try {
const res = await fetch('/api/briefing/sentiment', { cache: 'no-store' }) const res = await fetch('/api/briefing/sentiment', { cache: 'no-store' })
if (res.ok) setSentiment(await res.json()) if (res.ok) {
setSentiment(await res.json())
emitAiUsageChanged()
}
} catch { } catch {
/* widget sentiment en état vide */ /* widget sentiment en état vide */
} finally { } finally {
@@ -322,15 +327,15 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
fetch('/api/flashcards/stats', { cache: 'no-store' }) fetch('/api/flashcards/stats', { cache: 'no-store' })
.then(r => r.ok ? r.json() : null) .then(r => r.ok ? r.json() : null)
.then(json => { .then(json => {
if (json) { setFlashcardStats({
setFlashcardStats({ retentionRate: json?.retentionRate ?? 0,
retentionRate: json.retentionRate ?? 0, streak: json?.streak ?? 0,
streak: json.streak ?? 0, totalCards: json?.totalCards ?? 0,
totalCards: json.totalCards ?? 0, })
}) })
} .catch(() => {
setFlashcardStats({ retentionRate: 0, streak: 0, totalCards: 0 })
}) })
.catch(() => {})
}, [loadBriefing, loadMindMap]) }, [loadBriefing, loadMindMap])
// Pistes rapides dès le briefing, puis enrichissement en arrière-plan // Pistes rapides dès le briefing, puis enrichissement en arrière-plan
@@ -482,6 +487,7 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
if (!res.ok) throw new Error(json.error) if (!res.ok) throw new Error(json.error)
if (json.insight) { if (json.insight) {
await Promise.all([loadBriefing(), loadPaths()]) await Promise.all([loadBriefing(), loadPaths()])
emitAiUsageChanged()
toast.success(t('homeDashboard.echoFound')) toast.success(t('homeDashboard.echoFound'))
} else { } else {
toast.info(t('homeDashboard.echoNone')) toast.info(t('homeDashboard.echoNone'))
@@ -789,6 +795,7 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
retentionRate={flashcardStats?.retentionRate ?? 0} retentionRate={flashcardStats?.retentionRate ?? 0}
streak={flashcardStats?.streak ?? 0} streak={flashcardStats?.streak ?? 0}
totalCards={flashcardStats?.totalCards ?? 0} totalCards={flashcardStats?.totalCards ?? 0}
dueCount={dueFlashcards}
loading={!flashcardStats} loading={!flashcardStats}
onOpen={() => router.push('/revision')} onOpen={() => router.push('/revision')}
/>, />,
@@ -799,6 +806,14 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
notes={resumeNotes} notes={resumeNotes}
loading={briefingLoading} loading={briefingLoading}
onSelect={onNoteSelect} onSelect={onNoteSelect}
onCaptureFocus={() => {
document.getElementById('dashboard-widget-capture')?.scrollIntoView({
behavior: prefersReducedMotion ? 'auto' : 'smooth',
block: 'center',
})
const ta = document.querySelector<HTMLTextAreaElement>('#dashboard-widget-capture textarea')
ta?.focus()
}}
formatRelativeTime={relTime} formatRelativeTime={relTime}
prefersReducedMotion={!!prefersReducedMotion} prefersReducedMotion={!!prefersReducedMotion}
/>, />,
@@ -1005,7 +1020,7 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
<div className="h-full w-full bg-[#F9F8F6] dark:bg-[#0D0D0D] overflow-y-auto custom-scrollbar relative"> <div className="h-full w-full bg-[#F9F8F6] dark:bg-[#0D0D0D] overflow-y-auto custom-scrollbar relative">
<div className="absolute inset-0 bg-[linear-gradient(to_right,#80808005_1px,transparent_1px),linear-gradient(to_bottom,#80808005_1px,transparent_1px)] bg-[size:28px_28px] pointer-events-none z-0" /> <div className="absolute inset-0 bg-[linear-gradient(to_right,#80808005_1px,transparent_1px),linear-gradient(to_bottom,#80808005_1px,transparent_1px)] bg-[size:28px_28px] pointer-events-none z-0" />
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-10 py-6 sm:py-8 relative z-10"> <div className="w-full px-4 sm:px-6 lg:px-8 xl:px-10 2xl:px-12 py-6 sm:py-8 relative z-10">
{/* ── En-tête : orientation en 2 secondes ── */} {/* ── En-tête : orientation en 2 secondes ── */}
<header className="mb-5"> <header className="mb-5">
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-2 pb-4 border-b border-border/20"> <div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-2 pb-4 border-b border-border/20">

View File

@@ -151,7 +151,20 @@ export function DashboardWidgetGrid({ renderWidget }: DashboardWidgetGridProps)
fetch('/api/dashboard/layout', { cache: 'no-store' }) fetch('/api/dashboard/layout', { cache: 'no-store' })
.then(r => r.ok ? r.json() : null) .then(r => r.ok ? r.json() : null)
.then(json => { .then(json => {
if (json?.layout) setLayout(normalizeDashboardLayout(json.layout)) 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(() => {}) .catch(() => {})
.finally(() => setLoaded(true)) .finally(() => setLoaded(true))

View File

@@ -41,9 +41,16 @@ import { cn } from '@/lib/utils'
interface McpSettingsPanelProps { interface McpSettingsPanelProps {
initialKeys: McpKeyInfo[] initialKeys: McpKeyInfo[]
serverStatus: McpServerStatus 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 [keys, setKeys] = useState<McpKeyInfo[]>(initialKeys)
const [createOpen, setCreateOpen] = useState(false) const [createOpen, setCreateOpen] = useState(false)
const [isPending, startTransition] = useTransition() const [isPending, startTransition] = useTransition()
@@ -69,7 +76,7 @@ export function McpSettingsPanel({ initialKeys, serverStatus }: McpSettingsPanel
...prev, ...prev,
]) ])
} catch (err) { } 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')) toast.success(t('toast.operationSuccess'))
} catch (err) { } 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)) setKeys(prev => prev.filter(k => k.shortId !== shortId))
toast.success(t('toast.operationSuccess')) toast.success(t('toast.operationSuccess'))
} catch (err) { } 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> <p className="text-[10px] text-concrete mt-0.5">{t('mcpSettings.apiKeys.description')}</p>
</div> </div>
</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}> <Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogTrigger asChild> <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"> <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> </DialogTrigger>
<CreateKeyDialog onGenerate={handleGenerate} isPending={isPending} /> <CreateKeyDialog onGenerate={handleGenerate} isPending={isPending} />
</Dialog> </Dialog>
)}
</div> </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"> <div className="p-6">
{keys.length === 0 ? ( {keys.length === 0 ? (
<div className="text-center py-8"> <div className="text-center py-8">

View File

@@ -1,8 +1,8 @@
'use client' 'use client'
import { useEffect, useRef } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import * as d3 from 'd3' import * as d3 from 'd3'
import { Maximize2 } from 'lucide-react' import { Maximize2, Search, ChevronDown } from 'lucide-react'
interface Note { interface Note {
id: string id: string
@@ -35,8 +35,15 @@ interface NetworkGraphProps {
untitledLabel?: string untitledLabel?: string
resetFocusLabel?: string resetFocusLabel?: string
fitViewLabel?: 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({ export function NetworkGraph({
notes, notes,
clusters, clusters,
@@ -47,10 +54,45 @@ export function NetworkGraph({
untitledLabel = 'Untitled', untitledLabel = 'Untitled',
resetFocusLabel = 'Reset focus', resetFocusLabel = 'Reset focus',
fitViewLabel = 'Fit view', fitViewLabel = 'Fit view',
legendFilterPlaceholder = 'Filter themes…',
legendShowMoreLabel = 'Show {count} more',
legendShowLessLabel = 'Show less',
legendPreviewCount = DEFAULT_LEGEND_PREVIEW,
}: NetworkGraphProps) { }: NetworkGraphProps) {
const svgRef = useRef<SVGSVGElement>(null) const svgRef = useRef<SVGSVGElement>(null)
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
const zoomRef = useRef<any>(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(() => { useEffect(() => {
if (!svgRef.current || !containerRef.current) return if (!svgRef.current || !containerRef.current) return
@@ -362,43 +404,88 @@ export function NetworkGraph({
return ( return (
<div ref={containerRef} className="w-full h-full bg-paper dark:bg-[#121212] rounded-3xl overflow-hidden border border-border/40 relative"> <div ref={containerRef} className="w-full h-full bg-paper dark:bg-[#121212] rounded-3xl overflow-hidden border border-border/40 relative">
{/* Pastilles de cluster — triées par taille, avec compteurs */} {/* Pastilles de cluster — aperçu compact + filtre quand élargi */}
<div className="absolute top-6 left-6 z-10 flex flex-wrap gap-1.5 max-w-[85%]"> <div className="absolute top-6 start-6 z-10 flex flex-col gap-2 max-w-[min(420px,85%)]">
{[...clusters] {(legendExpanded || sortedClusters.length > legendPreviewCount) && (
.sort((a, b) => b.noteIds.length - a.noteIds.length) <div className="relative">
.map(c => { <Search
const isSelected = String(c.id) === selectedClusterId size={12}
return ( className="absolute start-2.5 top-1/2 -translate-y-1/2 text-concrete pointer-events-none"
<button aria-hidden
key={c.id} />
onClick={() => onClusterSelect?.(isSelected ? null : String(c.id))} <input
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 ${ type="search"
isSelected value={legendFilter}
? 'bg-ink text-white dark:bg-white dark:text-black border-ink dark:border-white scale-105 shadow-md' onChange={e => {
: 'bg-white/90 dark:bg-black/80 text-concrete hover:text-ink hover:border-concrete/40 border-border shadow-sm' setLegendFilter(e.target.value)
}`} if (e.target.value.trim()) setLegendExpanded(true)
> }}
<div className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: c.color }} /> placeholder={legendFilterPlaceholder}
<span className="truncate max-w-[120px]">{c.name ?? String(c.id)}</span> aria-label={legendFilterPlaceholder}
<span className={`text-[8px] px-1 rounded-full shrink-0 ${isSelected ? 'bg-white/20' : 'bg-black/5 dark:bg-white/10'}`}> 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"
{c.noteIds.length} />
</span> </div>
</button>
)
})}
{selectedClusterId && (
<button
onClick={() => onClusterSelect?.(null)}
className="px-3 py-1.5 rounded-full border border-rose-200 bg-rose-50 dark:bg-rose-950/20 dark:border-rose-900/40 text-rose-500 text-[9px] font-bold uppercase tracking-wider hover:bg-rose-100 dark:hover:bg-rose-950/30 transition-all shadow-sm"
>
{resetFocusLabel}
</button>
)} )}
<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
? 'bg-ink text-white dark:bg-white dark:text-black border-ink dark:border-white scale-105 shadow-md'
: 'bg-white/90 dark:bg-black/80 text-concrete hover:text-ink hover:border-concrete/40 border-border shadow-sm'
}`}
>
<div className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: c.color }} />
<span className="truncate max-w-[120px]">{c.name ?? String(c.id)}</span>
<span className={`text-[8px] px-1 rounded-full shrink-0 ${isSelected ? 'bg-white/20' : 'bg-black/5 dark:bg-white/10'}`}>
{c.noteIds.length}
</span>
</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 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> </div>
{/* Fit view button */} {/* Fit view button */}
<button <button
onClick={handleFitView} 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} aria-label={fitViewLabel}
> >
<Maximize2 size={11} /> <Maximize2 size={11} />

View File

@@ -112,7 +112,7 @@ export function BillingHistory() {
{error && ( {error && (
<p className="text-xs text-rose-500 text-center py-4"> <p className="text-xs text-rose-500 text-center py-4">
Impossible de charger l'historique de facturation. {t('billing.fetchInvoicesFailed')}
</p> </p>
)} )}
@@ -152,7 +152,7 @@ export function BillingHistory() {
target="_blank" target="_blank"
rel="noreferrer" 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" 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} /> <Download size={14} />
</a> </a>

View File

@@ -62,7 +62,7 @@ export function BillingPlans() {
queryFn: async () => { queryFn: async () => {
const search = typeof window !== 'undefined' ? window.location.search : ''; const search = typeof window !== 'undefined' ? window.location.search : '';
const res = await fetch(`/api/billing/status${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(); return res.json();
}, },
}); });
@@ -71,7 +71,7 @@ export function BillingPlans() {
queryKey: ['usage', 'current'], queryKey: ['usage', 'current'],
queryFn: async () => { queryFn: async () => {
const res = await fetch('/api/usage/current'); 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(); return res.json();
}, },
refetchInterval: 30000, refetchInterval: 30000,
@@ -122,7 +122,7 @@ export function BillingPlans() {
} }
} catch (err) { } catch (err) {
console.error('[BillingPlans] checkout error:', err); console.error('[BillingPlans] checkout error:', err);
toast.error('Failed to start checkout. Please try again.'); toast.error(t('billing.checkoutFailed'));
} finally { } finally {
setCheckoutLoading(null); setCheckoutLoading(null);
} }
@@ -139,20 +139,20 @@ export function BillingPlans() {
}); });
const data = await res.json(); const data = await res.json();
if (!res.ok) { if (!res.ok) {
toast.error(data.error || 'Failed to open billing portal.'); toast.error(data.error || t('billing.portalFailed'));
return; return;
} }
window.location.href = data.url; window.location.href = data.url;
} catch (err) { } catch (err) {
console.error('[BillingPlans] portal error:', err); console.error('[BillingPlans] portal error:', err);
toast.error('Failed to open billing portal.'); toast.error(t('billing.portalFailed'));
} finally { } finally {
setPortalLoading(false); setPortalLoading(false);
} }
}; };
const handleCancelSubscription = async () => { 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)) { if (!window.confirm(confirmMsg)) {
return; return;
} }
@@ -165,15 +165,15 @@ export function BillingPlans() {
}); });
const data = await res.json(); const data = await res.json();
if (!res.ok) { if (!res.ok) {
toast.error(data.error || 'Failed to cancel subscription.'); toast.error(data.error || t('billing.cancelFailed'));
return; 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: ['billing', 'status'] });
queryClient.invalidateQueries({ queryKey: ['usage', 'current'] }); queryClient.invalidateQueries({ queryKey: ['usage', 'current'] });
} catch (err) { } catch (err) {
console.error('[BillingPlans] cancel error:', err); console.error('[BillingPlans] cancel error:', err);
toast.error('Failed to cancel subscription.'); toast.error(t('billing.cancelFailed'));
} finally { } finally {
setCancelLoading(false); setCancelLoading(false);
} }
@@ -229,7 +229,7 @@ export function BillingPlans() {
name: t('billing.proPlan'), name: t('billing.proPlan'),
price: status?.prices?.PRO?.[interval]?.display ?? price: status?.prices?.PRO?.[interval]?.display ??
(interval === 'month' ? (t('billing.proPrice') || '9,90€') : (t('billing.proAnnualPrice') || '99€')), (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.', description: t('billing.proDescription') || 'Pour les consultants et créateurs exigeants.',
features: [ features: [
t('billing.proFeature1') || 'Notes illimitées', t('billing.proFeature1') || 'Notes illimitées',
@@ -252,8 +252,7 @@ export function BillingPlans() {
name: t('billing.businessPlan'), name: t('billing.businessPlan'),
price: status?.prices?.BUSINESS?.[interval]?.display ?? price: status?.prices?.BUSINESS?.[interval]?.display ??
(interval === 'month' ? (t('billing.businessPrice') || '29,90€') : (t('billing.businessAnnualPrice') || '299€')), (interval === 'month' ? (t('billing.businessPrice') || '29,90€') : (t('billing.businessAnnualPrice') || '299€')),
period: interval === 'month' ? '/mois' : '/an', period: interval === 'month' ? t('billing.perMonth') : t('billing.perYear'),
description: t('billing.businessDescription') || 'Pour les équipes et chefs de produit.',
features: [ features: [
t('billing.businessFeature1') || '10 Collaborateurs inclus', t('billing.businessFeature1') || '10 Collaborateurs inclus',
t('billing.businessFeature2') || 'BYOK (13 fournisseurs)', t('billing.businessFeature2') || 'BYOK (13 fournisseurs)',
@@ -560,7 +559,7 @@ export function BillingPlans() {
)} )}
> >
{t('billing.annual')} {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> </button>
</div> </div>
) : ( ) : (

View File

@@ -103,7 +103,7 @@ export function InlinePaywall({ feature, onDismiss }: InlinePaywallProps) {
</button> </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"> <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>
</div> </div>
</motion.div> </motion.div>

View File

@@ -1296,18 +1296,29 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
ref={asideRef} ref={asideRef}
style={sidebarWidth !== null ? { width: sidebarWidth } : undefined} style={sidebarWidth !== null ? { width: sidebarWidth } : undefined}
className={cn( 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 isImmersiveRoute && userCollapsed
? 'fixed inset-y-0 start-0 z-[70]' ? cn(
: 'fixed inset-y-0 start-0 z-[70] md:relative md:z-auto', 'fixed inset-y-0 start-0 z-[70]',
sidebarWidth === null && 'w-80 md:w-[26rem] 2xl:w-[30rem]', isRtl ? 'translate-x-full' : '-translate-x-full',
'h-full min-h-0 shrink-0 flex flex-row self-stretch relative overflow-visible', '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', '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', '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 className
)} )}
aria-hidden={isImmersiveRoute && userCollapsed ? true : undefined}
> >
{/* ── Column 1 : Rail d'icônes (54px) — inspiré du prototype ── */} {/* ── 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 overflow-hidden"> <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">

View File

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

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

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

View File

@@ -10,6 +10,7 @@ import { clusteringService } from './clustering.service'
import { getChatProvider } from '@/lib/ai/factory' import { getChatProvider } from '@/lib/ai/factory'
import { getSystemConfig } from '@/lib/config' import { getSystemConfig } from '@/lib/config'
import { calculateNextRun } from '@/lib/agents/schedule' import { calculateNextRun } from '@/lib/agents/schedule'
import { withAiQuota } from '@/lib/ai-quota'
const MIN_CLUSTER_NOTES = 3 const MIN_CLUSTER_NOTES = 3
const MAX_SUGGESTIONS_PER_RUN = 3 const MAX_SUGGESTIONS_PER_RUN = 3
@@ -51,6 +52,7 @@ function clusterCoveredByAgent(
} }
async function buildSuggestionWithLlm( async function buildSuggestionWithLlm(
userId: string,
topic: string, topic: string,
noteTitles: string[], noteTitles: string[],
noteCount: number, noteCount: number,
@@ -63,10 +65,6 @@ async function buildSuggestionWithLlm(
} }
try { try {
const config = await getSystemConfig()
const provider = getChatProvider(config)
if (!provider) return fallback
const prompt = `Tu proposes un agent IA pour un second cerveau (prise de notes). const prompt = `Tu proposes un agent IA pour un second cerveau (prise de notes).
Thème détecté: "${topic}" Thème détecté: "${topic}"
Notes liées (titres): ${noteTitles.slice(0, 5).join(' | ') || 'sans titre'} Notes liées (titres): ${noteTitles.slice(0, 5).join(' | ') || 'sans titre'}
@@ -75,7 +73,14 @@ Nombre de notes: ${noteCount}
Retourne UNIQUEMENT du JSON valide: 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"}` {"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 provider.generateText(prompt) 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]*\}/) const match = raw.match(/\{[\s\S]*\}/)
if (!match) return fallback if (!match) return fallback
const parsed = JSON.parse(match[0]) const parsed = JSON.parse(match[0])
@@ -122,6 +127,7 @@ export class AgentSuggestionService {
select: { title: true }, select: { title: true },
}) })
const llm = await buildSuggestionWithLlm( const llm = await buildSuggestionWithLlm(
userId,
topic, topic,
notes.map(n => n.title || 'Sans titre'), notes.map(n => n.title || 'Sans titre'),
cluster.noteIds.length, cluster.noteIds.length,

View File

@@ -1,24 +1,32 @@
/** /**
* Bridge Notes Service * Bridge Notes Service
* *
* Detects and manages "bridge notes" — notes that connect multiple clusters. * Detects and manages "bridge notes" — notes that connect multiple thematic clusters.
* A bridge note has strong similarities (cosine > 0.5) with notes from
* at least two different clusters.
* *
* Also generates AI-powered suggestions for creating new bridge notes * Scientific grounding (semantic similarity networks / brokerage):
* to connect isolated clusters. * - 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 prisma from '@/lib/prisma'
import { clusteringService } from './clustering.service' import { clusteringService } from './clustering.service'
import { getChatProvider } from '@/lib/ai/factory' import { getChatProvider } from '@/lib/ai/factory'
import { getSystemConfig } from '@/lib/config' import { getSystemConfig } from '@/lib/config'
import { withAiQuota } from '@/lib/ai-quota'
export interface BridgeNote { export interface BridgeNote {
noteId: string noteId: string
/** 01 mean cosine affinity to the top bridge clusters (typically a pair). */
bridgeScore: number bridgeScore: number
/** Cluster ids ranked by affinity (strongest first). Prefer length 2. */
clustersConnected: number[] clustersConnected: number[]
clusterNames?: string[] clusterNames?: string[]
/** Per-cluster max cosine affinities, aligned with clustersConnected. */
clusterAffinities?: number[]
} }
export interface BridgeSuggestion { export interface BridgeSuggestion {
@@ -31,26 +39,37 @@ export interface BridgeSuggestion {
justification: string justification: string
} }
interface ClusterAffinity {
clusterId: number
maxSimilarity: number
hitCount: number
}
export class BridgeNotesService { 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 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. * For one note, return max cosine similarity to each cluster that clears the threshold.
* Returns notes grouped by their cluster membership.
*/ */
private async getSimilarNotesByCluster( private async getClusterAffinities(
noteId: string, noteId: string,
userId: string, userId: string,
threshold: number = this.BRIDGE_SIMILARITY_THRESHOLD threshold: number = this.BRIDGE_SIMILARITY_THRESHOLD
): Promise<Map<number, string[]>> { ): Promise<ClusterAffinity[]> {
const cosineDistance = 1 - threshold const cosineDistance = 1 - threshold
const result = await prisma.$queryRawUnsafe<Array<{ const result = await prisma.$queryRawUnsafe<Array<{
noteId: string noteId: string
clusterId: number | null 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 FROM "NoteEmbedding" e1
CROSS JOIN "NoteEmbedding" e2 CROSS JOIN "NoteEmbedding" e2
INNER JOIN "Note" n ON n.id = e2."noteId" INNER JOIN "Note" n ON n.id = e2."noteId"
@@ -65,25 +84,36 @@ export class BridgeNotesService {
cosineDistance cosineDistance
) )
const clusterMap = new Map<number, string[]>() const byCluster = new Map<number, { maxSimilarity: number; hitCount: number }>()
for (const row of result) { for (const row of result) {
const clusterId = row.clusterId ?? -1 // -1 for noise/uncategorized const clusterId = row.clusterId
if (!clusterMap.has(clusterId)) { if (clusterId === null || clusterId === -1) continue
clusterMap.set(clusterId, []) 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. * Detect bridge notes: notes with strong affinity to at least two clusters.
* A note is a bridge if it has similarities to >= 2 distinct clusters. * Keeps only the top-2 clusters (pair brokerage) and scores by their mean affinity.
*/ */
async detectBridgeNotes(userId: string): Promise<BridgeNote[]> { async detectBridgeNotes(userId: string): Promise<BridgeNote[]> {
// Get all user's clusters
const clusters = await prisma.noteCluster.findMany({ const clusters = await prisma.noteCluster.findMany({
where: { userId }, where: { userId },
select: { clusterId: true, name: true }, select: { clusterId: true, name: true },
@@ -94,42 +124,40 @@ export class BridgeNotesService {
return [] return []
} }
const maxClusters = clusters.length const nameById = new Map(
clusters.map(c => [c.clusterId, clusteringService.displayName(c.name, c.clusterId)])
)
const bridgeNotes: BridgeNote[] = [] const bridgeNotes: BridgeNote[] = []
// Check each note for bridge potential
const notes = await prisma.note.findMany({ const notes = await prisma.note.findMany({
where: { userId, trashedAt: null }, where: { userId, trashedAt: null },
select: { id: true } select: { id: true }
}) })
for (const note of notes) { 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 top = affinities.slice(0, this.MAX_BRIDGED_CLUSTERS)
const clustersWithSimilarNotes: number[] = [] // Both legs of the bridge must be strong
for (const [clusterId, similarNotes] of similarByCluster) { if (top.length < this.MIN_CLUSTERS_FOR_BRIDGE) continue
if (clusterId !== -1 && similarNotes.length > 0) { if (top.some(a => a.maxSimilarity < this.BRIDGE_SIMILARITY_THRESHOLD)) continue
clustersWithSimilarNotes.push(clusterId)
}
}
// Check if this note connects >= 2 clusters const bridgeScore =
if (clustersWithSimilarNotes.length >= this.MIN_CLUSTERS_FOR_BRIDGE) { top.reduce((sum, a) => sum + a.maxSimilarity, 0) / top.length
const bridgeScore = clustersWithSimilarNotes.length / maxClusters
bridgeNotes.push({ const clustersConnected = top.map(a => a.clusterId)
noteId: note.id, bridgeNotes.push({
bridgeScore, noteId: note.id,
clustersConnected: clustersWithSimilarNotes, bridgeScore,
clusterNames: clustersWithSimilarNotes.map( clustersConnected,
cid => clusters.find(c => c.clusterId === cid)?.name || `Cluster ${cid}` 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) 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[]> { 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({ const clusters = await prisma.noteCluster.findMany({
where: { userId }, where: { userId },
select: { clusterId: true, name: true }, select: { clusterId: true, name: true },
@@ -191,116 +231,254 @@ export class BridgeNotesService {
if (clusters.length < 2) return [] if (clusters.length < 2) return []
// Get existing bridges to see which clusters are already connected
const existingBridges = await prisma.bridgeNote.findMany({ const existingBridges = await prisma.bridgeNote.findMany({
where: { userId }, where: { userId },
select: { clustersConnected: true } select: { clustersConnected: true }
}) })
const connectedPairs = new Set<string>() const connectedPairs = new Set<string>()
for (const bridge of existingBridges) { for (const bridge of existingBridges) {
const clusters = JSON.parse(bridge.clustersConnected) as number[] const ids = JSON.parse(bridge.clustersConnected) as number[]
for (let i = 0; i < clusters.length; i++) { for (let i = 0; i < ids.length; i++) {
for (let j = i + 1; j < clusters.length; j++) { for (let j = i + 1; j < ids.length; j++) {
const pair = [clusters[i], clusters[j]].sort().join('-') connectedPairs.add([ids[i], ids[j]].sort((a, b) => a - b).join('-'))
connectedPairs.add(pair)
} }
} }
} }
// Find unconnected cluster pairs const centroids = new Map<number, number[]>()
const suggestions: BridgeSuggestion[] = [] 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)
})
)
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 i = 0; i < clusters.length; i++) {
for (let j = i + 1; j < clusters.length; j++) { for (let j = i + 1; j < clusters.length; j++) {
const pair = `${clusters[i].clusterId}-${clusters[j].clusterId}` 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
if (connectedPairs.has(pair)) continue // Already connected const ca = centroids.get(a.clusterId)
const cb = centroids.get(b.clusterId)
if (!ca || !cb) continue
// Generate suggestion for this unconnected pair const similarity = this.cosineSimilarity(ca, cb)
const suggestion = await this.generateConnectionSuggestion( if (similarity < MIN_PAIR_SIMILARITY || similarity > MAX_PAIR_SIMILARITY) continue
clusters[i].clusterId,
clusters[j].clusterId,
clusters[i].name || `Cluster ${clusters[i].clusterId}`,
clusters[j].name || `Cluster ${clusters[j].clusterId}`,
userId
)
if (suggestion) { // Lexical gate: no shared vocabulary → no suggestion (blocks Stripe↔gaz)
suggestions.push(suggestion) 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 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( private async generateConnectionSuggestion(
clusterAId: number, clusterAId: number,
clusterBId: number, clusterBId: number,
clusterAName: string, clusterAName: string,
clusterBName: string, clusterBName: string,
userId: string userId: string,
pairSimilarity: number
): Promise<BridgeSuggestion | null> { ): Promise<BridgeSuggestion | null> {
const summaryA = await this.getClusterSummary(clusterAId, userId) const summaryA = await this.getClusterSummary(clusterAId, userId)
const summaryB = await this.getClusterSummary(clusterBId, userId) const summaryB = await this.getClusterSummary(clusterBId, userId)
const systemPrompt = `You are a creative assistant that helps users connect ideas. const systemPrompt = `You help a personal knowledge base propose MISSING LINKS (link prediction).
Suggest a "bridge note" that could connect two unrelated topics. Rules:
Be specific and creative. Your suggestions should help users discover new insights.` - 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} ${summaryA}
Group B (${clusterBName}): Theme B (${clusterBName}):
${summaryB} ${summaryB}
Suggest 3 creative bridge note ideas to connect these groups. For each idea, provide: 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.
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)
Format as JSON: Return ONLY JSON:
{ {
"ideas": [ "viable": true,
{"title": "...", "description": "...", "justification": "..."}, "title": "≤12 words",
... "description": "≤40 words: what the note covers",
] "justification": "≤25 words: why this link is real (shared object/method)"
} }
or {"viable": false}`
Return ONLY the JSON, no other text.`
try { try {
const config = await getSystemConfig() const response = await withAiQuota(userId, 'reformulate', async () => {
const provider = getChatProvider(config) const config = await getSystemConfig()
const response = await provider.chat( const provider = getChatProvider(config)
[{ role: 'user', content: userPrompt }], return provider.chat(
systemPrompt [{ role: 'user', content: userPrompt }],
) systemPrompt,
)
}, { lane: 'chat' })
// Parse JSON response
const jsonMatch = response.text.match(/\{[\s\S]*\}/) const jsonMatch = response.text.match(/\{[\s\S]*\}/)
if (!jsonMatch) return null if (!jsonMatch) return null
const parsed = JSON.parse(jsonMatch[0]) const parsed = JSON.parse(jsonMatch[0]) as {
const bestIdea = parsed.ideas?.[0] 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 { return {
clusterAId, clusterAId,
clusterBId, clusterBId,
clusterAName, clusterAName,
clusterBName, clusterBName,
suggestedTitle: bestIdea.title, suggestedTitle: idea.title,
suggestedContent: bestIdea.description, suggestedContent: idea.description,
justification: bestIdea.justification justification: idea.justification || '',
} }
} catch (error) { } catch (error) {
console.error('Error generating bridge suggestion:', 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[]> { async getBridgeSuggestions(userId: string, includeDismissed: boolean = false): Promise<BridgeSuggestion[]> {
const nameById = await clusteringService.getClusterNameMap(userId)
const suggestions = await prisma.bridgeSuggestion.findMany({ const suggestions = await prisma.bridgeSuggestion.findMany({
where: { where: {
userId, userId,
...(includeDismissed ? {} : { isDismissed: false }) ...(includeDismissed ? {} : { isDismissed: false })
}, },
orderBy: { createdAt: 'desc' } orderBy: { createdAt: 'desc' },
take: 40,
}) })
return suggestions.map(s => ({ const mapped = suggestions.map(s => {
clusterAId: s.clusterAId, const clusterAName = nameById.get(s.clusterAId) || clusteringService.displayName(s.clusterAName, s.clusterAId)
clusterBId: s.clusterBId, const clusterBName = nameById.get(s.clusterBId) || clusteringService.displayName(s.clusterBName, s.clusterBId)
clusterAName: s.clusterAName, return {
clusterBName: s.clusterBName, clusterAId: s.clusterAId,
suggestedTitle: s.suggestedTitle, clusterBId: s.clusterBId,
suggestedContent: s.suggestedContent, clusterAName,
justification: s.justification 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> { async dismissSuggestion(userId: string, clusterAId: number, clusterBId: number): Promise<void> {
await prisma.bridgeSuggestion.updateMany({ await prisma.bridgeSuggestion.updateMany({
where: { where: { userId, clusterAId, clusterBId },
userId,
clusterAId,
clusterBId
},
data: { isDismissed: true } data: { isDismissed: true }
}) })
} }

View File

@@ -15,6 +15,7 @@ import prisma from '@/lib/prisma'
import { embeddingService } from './embedding.service' import { embeddingService } from './embedding.service'
import { getChatProvider } from '@/lib/ai/factory' import { getChatProvider } from '@/lib/ai/factory'
import { getSystemConfig } from '@/lib/config' import { getSystemConfig } from '@/lib/config'
import { withAiQuota } from '@/lib/ai-quota'
import { upsertNoteEmbedding } from '@/lib/embeddings' import { upsertNoteEmbedding } from '@/lib/embeddings'
export interface ClusterResult { export interface ClusterResult {
@@ -570,22 +571,36 @@ export class ClusteringService {
/** /**
* Get the N most central notes from a cluster for naming purposes. * 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 }>> { 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 }>>( const central = await prisma.$queryRawUnsafe<Array<{ noteId: string; title: string | null; content: string }>>(
`SELECT DISTINCT n.id AS "noteId", n.title, n.content `SELECT n.id AS "noteId", n.title, n.content
FROM "ClusterMember" cm FROM "ClusterMember" cm
INNER JOIN "Note" n ON n.id = cm."noteId" INNER JOIN "Note" n ON n.id = cm."noteId"
WHERE cm."clusterId" = $1 WHERE cm."clusterId" = $1
AND cm."userId" = $2 AND cm."userId" = $2
AND cm."isCentral" = true AND cm."isCentral" = true
ORDER BY cm."membershipScore" DESC
LIMIT $3`, LIMIT $3`,
clusterId, clusterId,
userId, userId,
n 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,38 +643,156 @@ export class ClusteringService {
}) })
} }
isPlaceholderClusterName(name: string | null | undefined): boolean {
if (!name || !name.trim()) return true
return /^cluster\s*\d+$/i.test(name.trim())
}
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}`
}
/** /**
* Generate a name for a cluster using the LLM. * Name a cluster from in-memory notes (does NOT require ClusterMember rows in DB).
* Analyzes the 5 most central notes to extract a common theme. * 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 à 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 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,
)
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> { async generateClusterName(clusterId: number, userId: string): Promise<string> {
const centralNotes = await this.getCentralNotes(clusterId, userId, 5) const centralNotes = await this.getCentralNotes(clusterId, userId, 5)
return this.generateClusterNameFromNotes(centralNotes, clusterId, userId)
}
if (centralNotes.length === 0) { displayName(name: string | null | undefined, clusterId: number): string {
return `Cluster ${clusterId}` 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)
} }
}
const notesText = centralNotes /**
.map((note, i) => `${i + 1}. "${note.title || 'Untitled'}" - ${note.content.slice(0, 100)}...`) * Rename clusters that still have null / "Cluster N" placeholders.
.join('\n') * 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.
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." */
async ensureClusterNames(userId: string, options?: { useAi?: boolean }): Promise<void> {
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 clusters = await prisma.noteCluster.findMany({
where: { userId },
try { select: { clusterId: true, name: true },
const config = await getSystemConfig() })
const provider = getChatProvider(config) for (const cluster of clusters) {
const response = await provider.chat( if (!this.isPlaceholderClusterName(cluster.name)) continue
[{ role: 'user', content: userPrompt }], const name = options?.useAi
systemPrompt ? await this.generateClusterName(cluster.clusterId, userId)
) : this.heuristicNameFromNotes(
return response.text.trim().slice(0, 50) await this.getCentralNotes(cluster.clusterId, userId, 5),
} catch { cluster.clusterId
return `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)]))
}
/** /**
* Check if recalculation is needed based on data change percentage. * Check if recalculation is needed based on data change percentage.
*/ */
@@ -723,7 +856,7 @@ export class ClusteringService {
result.push({ result.push({
clusterId: cluster.clusterId, clusterId: cluster.clusterId,
noteIds: members.map(m => m.noteId), noteIds: members.map(m => m.noteId),
name: cluster.name || undefined name: this.displayName(cluster.name, cluster.clusterId),
}) })
} }

View File

@@ -16,6 +16,7 @@ import {
buildMemoryEchoInsightPrompt, buildMemoryEchoInsightPrompt,
getMemoryEchoInsightFallback, getMemoryEchoInsightFallback,
} from '@/lib/ai/memory-echo-i18n' } from '@/lib/ai/memory-echo-i18n'
import { withAiQuota } from '@/lib/ai-quota'
import { import {
SEMANTIC_SIMILARITY_FLOOR_CLIP, SEMANTIC_SIMILARITY_FLOOR_CLIP,
SEMANTIC_SIMILARITY_FLOOR_DEMO, SEMANTIC_SIMILARITY_FLOOR_DEMO,
@@ -463,14 +464,20 @@ export class MemoryEchoService {
return null // All connections already shown return null // All connections already shown
} }
// Generate AI insight in user's language // Generate AI insight in user's language (quota: reformulate, with rollback on failure)
const userLanguage = await detectUserLanguage() const userLanguage = await detectUserLanguage()
const insightText = await this.generateInsight( const insightText = await withAiQuota(
newConnection.note1.title, userId,
newConnection.note1.content, 'reformulate',
newConnection.note2.title, () =>
newConnection.note2.content || '', this.generateInsight(
userLanguage, newConnection.note1.title,
newConnection.note1.content,
newConnection.note2.title,
newConnection.note2.content || '',
userLanguage,
),
{ lane: 'chat' },
) )
// Store insight in database // Store insight in database

View File

@@ -1,4 +1,4 @@
export const DASHBOARD_LAYOUT_VERSION = 5 as const export const DASHBOARD_LAYOUT_VERSION = 6 as const
/** Widgets visibles dans la mise en page par défaut (réf. prototype / capture utilisateur). */ /** Widgets visibles dans la mise en page par défaut (réf. prototype / capture utilisateur). */
export const CANONICAL_VISIBLE_WIDGET_IDS: readonly DashboardWidgetId[] = [ export const CANONICAL_VISIBLE_WIDGET_IDS: readonly DashboardWidgetId[] = [
@@ -6,6 +6,7 @@ export const CANONICAL_VISIBLE_WIDGET_IDS: readonly DashboardWidgetId[] = [
'next-paths', 'next-paths',
'intelligence', 'intelligence',
'resume', 'resume',
'activity',
'mind-map', 'mind-map',
'sentiment', 'sentiment',
'inbox', 'inbox',
@@ -14,6 +15,7 @@ export const CANONICAL_VISIBLE_WIDGET_IDS: readonly DashboardWidgetId[] = [
'reminders', 'reminders',
'flashcards-progress', 'flashcards-progress',
'agents', 'agents',
'pinned',
] as const ] as const
export type DashboardWidgetId = export type DashboardWidgetId =
@@ -80,8 +82,8 @@ export const DASHBOARD_WIDGET_META: Record<DashboardWidgetId, {
stats: { required: false, zone: 'side', category: 'cognition', defaultVisible: false }, stats: { required: false, zone: 'side', category: 'cognition', defaultVisible: false },
'agent-activity': { required: false, zone: 'side', category: 'agents', defaultVisible: false }, 'agent-activity': { required: false, zone: 'side', category: 'agents', defaultVisible: false },
gmail: { required: false, zone: 'side', category: 'productivity', defaultVisible: false }, gmail: { required: false, zone: 'side', category: 'productivity', defaultVisible: false },
activity: { required: false, zone: 'main', category: 'cognition', defaultVisible: false }, activity: { required: false, zone: 'main', category: 'cognition', defaultVisible: true },
pinned: { required: false, zone: 'side', category: 'cognition', defaultVisible: false }, pinned: { required: false, zone: 'side', category: 'cognition', defaultVisible: true },
usage: { required: false, zone: 'side', category: 'agents', defaultVisible: false }, usage: { required: false, zone: 'side', category: 'agents', defaultVisible: false },
'daily-review': { required: false, zone: 'side', category: 'paths', defaultVisible: false }, 'daily-review': { required: false, zone: 'side', category: 'paths', defaultVisible: false },
'open-loops': { required: false, zone: 'side', category: 'paths', defaultVisible: false }, 'open-loops': { required: false, zone: 'side', category: 'paths', defaultVisible: false },
@@ -108,21 +110,21 @@ export const DEFAULT_DASHBOARD_LAYOUT: DashboardLayout = {
{ id: 'next-paths', visible: true, order: 1, zone: 'main' }, { id: 'next-paths', visible: true, order: 1, zone: 'main' },
{ id: 'intelligence', visible: true, order: 2, zone: 'main' }, { id: 'intelligence', visible: true, order: 2, zone: 'main' },
{ id: 'resume', visible: true, order: 3, zone: 'main' }, { id: 'resume', visible: true, order: 3, zone: 'main' },
{ id: 'mind-map', visible: true, order: 4, 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 // Colonne latérale (droite) — cartes compactes + widgets IA
{ id: 'sentiment', visible: true, order: 5, zone: 'side' }, { id: 'sentiment', visible: true, order: 6, zone: 'side' },
{ id: 'inbox', visible: true, order: 6, zone: 'side' }, { id: 'inbox', visible: true, order: 7, zone: 'side' },
{ id: 'revision', visible: true, order: 7, zone: 'side' }, { id: 'revision', visible: true, order: 8, zone: 'side' },
{ id: 'stats', visible: true, order: 8, zone: 'side' }, { id: 'stats', visible: true, order: 9, zone: 'side' },
{ id: 'reminders', visible: true, order: 9, zone: 'side' }, { id: 'reminders', visible: true, order: 10, zone: 'side' },
{ id: 'flashcards-progress', visible: true, order: 10, zone: 'side' }, { id: 'flashcards-progress', visible: true, order: 11, zone: 'side' },
{ id: 'agents', 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 » // Catalogue — masqués par défaut, ajoutables via « Personnaliser »
{ id: 'daily-review', visible: false, order: 12, zone: 'side' }, { id: 'daily-review', visible: false, order: 14, zone: 'side' },
{ id: 'agent-activity', visible: false, order: 13, zone: 'side' }, { id: 'agent-activity', visible: false, order: 15, zone: 'side' },
{ id: 'gmail', visible: false, order: 14, zone: 'side' }, { id: 'gmail', visible: false, order: 16, zone: 'side' },
{ id: 'activity', visible: false, order: 15, zone: 'main' },
{ id: 'pinned', visible: false, order: 16, zone: 'side' },
{ id: 'usage', visible: false, order: 17, zone: 'side' }, { id: 'usage', visible: false, order: 17, zone: 'side' },
{ id: 'open-loops', visible: false, order: 18, zone: 'side' }, { id: 'open-loops', visible: false, order: 18, zone: 'side' },
{ id: 'daily-note', visible: false, order: 19, zone: 'side' }, { id: 'daily-note', visible: false, order: 19, zone: 'side' },

View File

@@ -1,6 +1,7 @@
import 'server-only' import 'server-only'
import prisma from '@/lib/prisma' import prisma from '@/lib/prisma'
import { clusteringService } from '@/lib/ai/services/clustering.service'
import type { DashboardPath } from '@/lib/dashboard/path-types' import type { DashboardPath } from '@/lib/dashboard/path-types'
export type { DashboardPath, DashboardPathType } from '@/lib/dashboard/path-types' export type { DashboardPath, DashboardPathType } from '@/lib/dashboard/path-types'
@@ -248,7 +249,7 @@ export async function buildDashboardPaths(input: BuildPathsInput): Promise<Dashb
id: `explore-cluster-${focusClusterId}`, id: `explore-cluster-${focusClusterId}`,
type: 'explore', type: 'explore',
priority: 60, priority: 60,
title: cluster.name || `Cluster ${focusClusterId}`, title: clusteringService.displayName(cluster.name, focusClusterId),
description: `${cluster.noteCount} notes in this theme`, description: `${cluster.noteCount} notes in this theme`,
actionKey: 'exploreTheme', actionKey: 'exploreTheme',
clusterAId: focusClusterId, clusterAId: focusClusterId,

View File

@@ -190,6 +190,46 @@ local newCount = tonumber(redis.call('GET', KEYS[1]))
return newCount 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 = ` const RESERVE_LUA = `
local limit = tonumber(ARGV[1]) local limit = tonumber(ARGV[1])
local ttl = tonumber(ARGV[2]) local ttl = tonumber(ARGV[2])
@@ -472,6 +512,9 @@ export async function getUserQuotas(
return result; return result;
} catch (err) { } catch (err) {
console.error('[entitlements] getUserQuotas Redis error:', err); console.error('[entitlements] getUserQuotas Redis error:', err);
if (shouldFailClosedOnRedisError()) {
throw new QuotaServiceUnavailableError();
}
const result: Record<string, { remaining: number; limit: number; used: number }> = {}; const result: Record<string, { remaining: number; limit: number; used: number }> = {};
for (const feature of features) { for (const feature of features) {
const limit = (await getLimitAsync(tier, feature)) ?? 0; const limit = (await getLimitAsync(tier, feature)) ?? 0;

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'; 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. */ /** Hardcoded defaults — used when DB is empty or unavailable. */
export const FALLBACK_TIER_LIMITS: Record< export const FALLBACK_TIER_LIMITS: Record<
SubscriptionTier, SubscriptionTier,
@@ -98,18 +101,24 @@ function buildLimitMapFromRows(
for (const row of rows) { for (const row of rows) {
map[row.tier][row.feature] = 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; 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 { function mergeWithFallback(map: LimitMap): LimitMap {
const fallback = fallbackToLimitMap(); const fallback = fallbackToLimitMap();
for (const tier of Object.keys(fallback) as SubscriptionTier[]) { for (const tier of Object.keys(fallback) as SubscriptionTier[]) {
for (const [feature, limit] of Object.entries(fallback[tier])) { 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; map[tier][feature] = limit;
} }
} }
@@ -157,7 +166,7 @@ export async function getLimitAsync(
): Promise<number | undefined> { ): Promise<number | undefined> {
const map = await loadLimitMap(); const map = await loadLimitMap();
const limit = map[tier]?.[feature]; const limit = map[tier]?.[feature];
if (limit === undefined) return undefined; if (limit === undefined || limit === ENTITLEMENT_UNAVAILABLE) return undefined;
if (limit === Infinity) return Infinity; if (limit === Infinity) return Infinity;
return limit; return limit;
} }
@@ -184,9 +193,11 @@ export async function getAllEntitlementsForAdmin(): Promise<
feature: r.feature, feature: r.feature,
limitValue: r.limitValue, limitValue: r.limitValue,
mode: mode:
r.limitValue === null r.limitValue === ENTITLEMENT_UNAVAILABLE
? ('unlimited' as const) ? ('unavailable' as const)
: ('limited' as const), : r.limitValue === null
? ('unlimited' as const)
: ('limited' as const),
})); }));
} }

View File

@@ -1718,6 +1718,8 @@
"mcpSettings": { "mcpSettings": {
"title": "MCP", "title": "MCP",
"description": "إدارة مفاتيح API وتكوين الأدوات الخارجية", "description": "إدارة مفاتيح API وتكوين الأدوات الخارجية",
"tierRequired": "Pro+ فقط",
"upgradeHint": "يتطلب الوصول إلى MCP (مفاتيح API لـ Cursor وClaude Desktop إلخ) خطة Pro أو أعلى. قم بالترقية في الفوترة لفتح هذه الميزة.",
"whatIsMcp": { "whatIsMcp": {
"title": "ما هو MCP؟", "title": "ما هو MCP؟",
"description": "بروتوكول سياق النموذج (MCP) هو بروتوكول مفتوح يمكّن نماذج الذكاء الاصطناعي من التفاعل بأمان مع الأدوات ومصادر البيانات الخارجية. باستخدام MCP، يمكنك ربط أدوات مثل Claude Code و Cursor و N8N بمثيل Memento الخاص بك لقراءة ملاحظاتك وإنشائها وتنظيمها برمجيًا.", "description": "بروتوكول سياق النموذج (MCP) هو بروتوكول مفتوح يمكّن نماذج الذكاء الاصطناعي من التفاعل بأمان مع الأدوات ومصادر البيانات الخارجية. باستخدام MCP، يمكنك ربط أدوات مثل Claude Code و Cursor و N8N بمثيل Memento الخاص بك لقراءة ملاحظاتك وإنشائها وتنظيمها برمجيًا.",
@@ -1743,7 +1745,12 @@
"lastUsed": "آخر استخدام", "lastUsed": "آخر استخدام",
"never": "أبدًا", "never": "أبدًا",
"confirmRevoke": "هل أنت متأكد من إلغاء هذا المفتاح؟ ستفقد الأدوات التي تستخدمه صلاحية الوصول.", "confirmRevoke": "هل أنت متأكد من إلغاء هذا المفتاح؟ ستفقد الأدوات التي تستخدمه صلاحية الوصول.",
"confirmDelete": "هل أنت متأكد من حذف هذا المفتاح نهائيًا؟" "confirmDelete": "هل أنت متأكد من حذف هذا المفتاح نهائيًا؟",
"errors": {
"generate": "فشل في إنشاء المفتاح",
"revoke": "فشل في إبطال المفتاح",
"delete": "فشل في حذف المفتاح"
}
}, },
"createDialog": { "createDialog": {
"title": "إنشاء مفتاح API", "title": "إنشاء مفتاح API",
@@ -1773,6 +1780,15 @@
"title": "N8N", "title": "N8N",
"description": "استخدم بيانات الاعتماد هذه في عقدة N8N MCP:" "description": "استخدم بيانات الاعتماد هذه في عقدة N8N MCP:"
} }
},
"helpBox": {
"title": "ما هو MCP (Model Context Protocol)؟",
"step1": "MCP هو بروتوكول يسمح لوكلاء الذكاء الاصطناعي في Memento بالاتصال بالأدوات الخارجية (قواعد البيانات، واجهات API، الملفات، إلخ).",
"step2": "يوفر Memento خادم MCP مع 22 أداة — يمكن لوكلائك قراءة/إنشاء الملاحظات، والبحث في قاعدتك، وإدارة الدفاتر، إلخ.",
"step3": "أنشئ مفتاح API هنا، ثم قم بتكوينه في عميل MCP الخاص بك (Claude Desktop، Cursor، Continue.dev…) مع عنوان URL للخادم.",
"step4": "تنسيق التكوين: عنوان URL لخادم MCP + مفتاحك في رأس Authorization.",
"step4Link": "توثيق MCP",
"step5": "حالة الاستخدام: اطلب من Claude Desktop كتابة ملاحظة في Memento، أو البحث في دفاترك، أو إنشاء وكيل."
} }
}, },
"agents": { "agents": {
@@ -2500,7 +2516,23 @@
"instantActivation": "تفعيل فوري", "instantActivation": "تفعيل فوري",
"secureDesc": "مدفوعات آمنة عبر Stripe", "secureDesc": "مدفوعات آمنة عبر Stripe",
"secureTransactions": "معاملات آمنة", "secureTransactions": "معاملات آمنة",
"satisfactionGuarantee": "ضمان استرداد 30 يومًا" "satisfactionGuarantee": "ضمان استرداد 30 يومًا",
"cancelConfirm": "هل أنت متأكد من إلغاء اشتراكك؟ ستحتفظ بوصولك Pro/Business حتى نهاية فترة الفوترة الحالية.",
"cancelSuccess": "تم إلغاء اشتراكك بنجاح. سينتهي في نهاية فترة الفوترة الحالية.",
"invoices": "الفواتير والإيصالات",
"invoiceAmount": "المبلغ",
"invoiceDate": "التاريخ",
"invoiceNumber": "الرقم",
"invoiceStatus": "الحالة",
"noInvoices": "لا توجد فواتير متاحة.",
"checkoutFailed": "تعذر بدء الدفع. حاول مرة أخرى.",
"portalFailed": "تعذر فتح بوابة الفوترة.",
"cancelFailed": "تعذر إلغاء الاشتراك.",
"downloadPdf": "تنزيل PDF",
"fetchStatusFailed": "تعذر جلب حالة الفوترة",
"fetchQuotasFailed": "تعذر جلب الحصص",
"fetchInvoicesFailed": "تعذر تحميل سجل الفوترة.",
"savePercent": "وفّر ~17%"
}, },
"landing": { "landing": {
"nav": { "nav": {
@@ -3029,5 +3061,13 @@
"hintNavigate": "تنقل", "hintNavigate": "تنقل",
"hintOpen": "فتح", "hintOpen": "فتح",
"hintClose": "إغلاق" "hintClose": "إغلاق"
},
"quotaPaywall": {
"title": "تم الوصول إلى الحد الشهري",
"description": "لقد استخدمت كل أرصدة {feature} لهذا الشهر.",
"upgrade": "ترقية الخطة",
"useOwnKey": "استخدم مفتاح API الخاص بك",
"later": "ربما لاحقاً",
"closingIn": "إغلاق خلال {n}ث"
} }
} }

View File

@@ -1718,6 +1718,8 @@
"mcpSettings": { "mcpSettings": {
"title": "MCP", "title": "MCP",
"description": "API-Schlüssel verwalten und externe Tools konfigurieren", "description": "API-Schlüssel verwalten und externe Tools konfigurieren",
"tierRequired": "Nur Pro+",
"upgradeHint": "MCP-Zugriff (API-Schlüssel für Cursor, Claude Desktop usw.) erfordert einen Pro-Plan oder höher. In der Abrechnung upgraden, um diese Funktion zu aktivieren.",
"whatIsMcp": { "whatIsMcp": {
"title": "Was ist MCP?", "title": "Was ist MCP?",
"description": "Das Model Context Protocol (MCP) ist ein offenes Protokoll, das es KI-Modellen ermöglicht, sicher mit externen Tools und Datenquellen zu interagieren. Mit MCP können Sie Tools wie Claude Code, Cursor oder N8N mit Ihrer Memento-Instanz verbinden, um Ihre Notes programmgesteuert zu lesen, zu erstellen und zu organisieren.", "description": "Das Model Context Protocol (MCP) ist ein offenes Protokoll, das es KI-Modellen ermöglicht, sicher mit externen Tools und Datenquellen zu interagieren. Mit MCP können Sie Tools wie Claude Code, Cursor oder N8N mit Ihrer Memento-Instanz verbinden, um Ihre Notes programmgesteuert zu lesen, zu erstellen und zu organisieren.",
@@ -1743,7 +1745,12 @@
"lastUsed": "Zuletzt verwendet", "lastUsed": "Zuletzt verwendet",
"never": "Nie", "never": "Nie",
"confirmRevoke": "Sind Sie sicher, dass Sie diesen Schlüssel widerrufen möchten? Tools, die ihn verwenden, verlieren den Zugriff.", "confirmRevoke": "Sind Sie sicher, dass Sie diesen Schlüssel widerrufen möchten? Tools, die ihn verwenden, verlieren den Zugriff.",
"confirmDelete": "Sind Sie sicher, dass Sie diesen Schlüssel dauerhaft löschen möchten?" "confirmDelete": "Sind Sie sicher, dass Sie diesen Schlüssel dauerhaft löschen möchten?",
"errors": {
"generate": "Schlüssel konnte nicht generiert werden",
"revoke": "Schlüssel konnte nicht widerrufen werden",
"delete": "Schlüssel konnte nicht gelöscht werden"
}
}, },
"createDialog": { "createDialog": {
"title": "API-Schlüssel generieren", "title": "API-Schlüssel generieren",
@@ -1773,6 +1780,15 @@
"title": "N8N", "title": "N8N",
"description": "Verwenden Sie diese Anmeldeinformationen in Ihrem N8N MCP-Knoten:" "description": "Verwenden Sie diese Anmeldeinformationen in Ihrem N8N MCP-Knoten:"
} }
},
"helpBox": {
"title": "Was ist MCP (Model Context Protocol)?",
"step1": "MCP ist ein Protokoll, das es den KI-Agenten von Memento ermöglicht, sich mit externen Tools (Datenbanken, APIs, Dateien usw.) zu verbinden.",
"step2": "Memento stellt einen MCP-Server mit 22 Tools bereit — Ihre Agenten können Notizen lesen/erstellen, Ihre Datenbank durchsuchen, Notizbücher verwalten usw.",
"step3": "Erstellen Sie hier einen API-Schlüssel und konfigurieren Sie ihn dann in Ihrem MCP-Client (Claude Desktop, Cursor, Continue.dev…) mit der Server-URL.",
"step4": "Konfigurationsformat: MCP-Server-URL + Ihr Schlüssel im Authorization-Header.",
"step4Link": "MCP-Dokumentation",
"step5": "Anwendungsfall: Bitten Sie Claude Desktop, eine Notiz in Memento zu schreiben, Ihre Notizbücher zu durchsuchen oder einen Agenten zu erstellen."
} }
}, },
"agents": { "agents": {
@@ -2500,7 +2516,23 @@
"instantActivation": "Sofortige Aktivierung", "instantActivation": "Sofortige Aktivierung",
"secureDesc": "Sichere Zahlungen durch Stripe", "secureDesc": "Sichere Zahlungen durch Stripe",
"secureTransactions": "Sichere Transaktionen", "secureTransactions": "Sichere Transaktionen",
"satisfactionGuarantee": "30 Tage Zufriedenheitsgarantie" "satisfactionGuarantee": "30 Tage Zufriedenheitsgarantie",
"cancelConfirm": "Sind Sie sicher, dass Sie Ihr Abonnement kündigen möchten? Sie behalten Ihren Pro/Business-Zugriff bis zum Ende der aktuellen Abrechnungsperiode.",
"cancelSuccess": "Ihr Abonnement wurde erfolgreich gekündigt. Es endet am Ende der aktuellen Abrechnungsperiode.",
"invoices": "Rechnungen & Belege",
"invoiceAmount": "Betrag",
"invoiceDate": "Datum",
"invoiceNumber": "Nummer",
"invoiceStatus": "Status",
"noInvoices": "Keine Rechnungen verfügbar.",
"checkoutFailed": "Checkout konnte nicht gestartet werden. Bitte versuchen Sie es erneut.",
"portalFailed": "Abrechnungsportal konnte nicht geöffnet werden.",
"cancelFailed": "Abonnement konnte nicht gekündigt werden.",
"downloadPdf": "PDF herunterladen",
"fetchStatusFailed": "Abrechnungsstatus konnte nicht abgerufen werden",
"fetchQuotasFailed": "Kontingente konnten nicht abgerufen werden",
"fetchInvoicesFailed": "Rechnungsverlauf konnte nicht geladen werden.",
"savePercent": "~17% sparen"
}, },
"landing": { "landing": {
"nav": { "nav": {
@@ -3029,5 +3061,13 @@
"hintNavigate": "navigieren", "hintNavigate": "navigieren",
"hintOpen": "öffnen", "hintOpen": "öffnen",
"hintClose": "schließen" "hintClose": "schließen"
},
"quotaPaywall": {
"title": "Monatliches Limit erreicht",
"description": "Sie haben alle Ihre {feature}-Guthaben für diesen Monat verwendet.",
"upgrade": "Plan upgraden",
"useOwnKey": "Eigenen API-Schlüssel verwenden",
"later": "Vielleicht später",
"closingIn": "Schließt in {n}s"
} }
} }

View File

@@ -1758,7 +1758,11 @@
"featureCharts": "AI Charts", "featureCharts": "AI Charts",
"featurePublishEnhance": "AI Publishing", "featurePublishEnhance": "AI Publishing",
"featureBrainstormExpand": "Brainstorm expansions", "featureBrainstormExpand": "Brainstorm expansions",
"featureBrainstormEnrich": "Brainstorm enrichments" "featureBrainstormEnrich": "Brainstorm enrichments",
"featureFlashcards": "AI flashcards",
"featureVoice": "Voice transcription",
"featureSlides": "Slide generation",
"featureDiagrams": "Diagram generation"
}, },
"generalSettings": { "generalSettings": {
"title": "General", "title": "General",
@@ -2036,6 +2040,8 @@
"mcpSettings": { "mcpSettings": {
"title": "MCP", "title": "MCP",
"description": "Manage API keys and configure external tools", "description": "Manage API keys and configure external tools",
"tierRequired": "Pro+ only",
"upgradeHint": "MCP access (API keys for Cursor, Claude Desktop, etc.) requires a Pro plan or higher. Upgrade in Billing to unlock this feature.",
"whatIsMcp": { "whatIsMcp": {
"title": "What is MCP?", "title": "What is MCP?",
"description": "The Model Context Protocol (MCP) is an open protocol that enables AI models to securely interact with external tools and data sources. With MCP, you can connect tools like Claude Code, Cursor, or N8N to your Memento instance to read, create, and organize your notes programmatically.", "description": "The Model Context Protocol (MCP) is an open protocol that enables AI models to securely interact with external tools and data sources. With MCP, you can connect tools like Claude Code, Cursor, or N8N to your Memento instance to read, create, and organize your notes programmatically.",
@@ -2061,7 +2067,12 @@
"lastUsed": "Last used", "lastUsed": "Last used",
"never": "Never", "never": "Never",
"confirmRevoke": "Are you sure you want to revoke this key? Tools using it will lose access.", "confirmRevoke": "Are you sure you want to revoke this key? Tools using it will lose access.",
"confirmDelete": "Are you sure you want to permanently delete this key?" "confirmDelete": "Are you sure you want to permanently delete this key?",
"errors": {
"generate": "Failed to generate key",
"revoke": "Failed to revoke key",
"delete": "Failed to delete key"
}
}, },
"createDialog": { "createDialog": {
"title": "Generate API Key", "title": "Generate API Key",
@@ -2091,6 +2102,15 @@
"title": "N8N", "title": "N8N",
"description": "Use these credentials in your N8N MCP node:" "description": "Use these credentials in your N8N MCP node:"
} }
},
"helpBox": {
"title": "What is MCP (Model Context Protocol)?",
"step1": "MCP is a protocol that allows Memento's AI agents to connect to external tools (databases, APIs, files, etc.).",
"step2": "Memento exposes an MCP server with 22 tools — your agents can read/create notes, search your base, manage notebooks, etc.",
"step3": "Create an API key here, then configure it in your MCP client (Claude Desktop, Cursor, Continue.dev…) with the server URL.",
"step4": "Configuration format: MCP server URL + your key in the Authorization header.",
"step4Link": "MCP Documentation",
"step5": "Use case: ask Claude Desktop to write a note in Memento, search your notebooks, or create an agent."
} }
}, },
"agents": { "agents": {
@@ -3275,14 +3295,19 @@
"checkoutFailed": "Failed to start checkout. Please try again.", "checkoutFailed": "Failed to start checkout. Please try again.",
"portalFailed": "Failed to open billing portal.", "portalFailed": "Failed to open billing portal.",
"cancelFailed": "Failed to cancel subscription.", "cancelFailed": "Failed to cancel subscription.",
"downloadPdf": "Download PDF" "downloadPdf": "Download PDF",
"fetchStatusFailed": "Failed to fetch billing status",
"fetchQuotasFailed": "Failed to fetch quotas",
"fetchInvoicesFailed": "Failed to load billing history.",
"savePercent": "Save ~17%"
}, },
"quotaPaywall": { "quotaPaywall": {
"title": "Monthly limit reached", "title": "Monthly limit reached",
"description": "You've used all your {feature} credits for this month.", "description": "You've used all your {feature} credits for this month.",
"upgrade": "Upgrade plan", "upgrade": "Upgrade plan",
"useOwnKey": "Use your own API key", "useOwnKey": "Use your own API key",
"later": "Maybe later" "later": "Maybe later",
"closingIn": "Closing in {n}s"
}, },
"landing": { "landing": {
"nav": { "nav": {
@@ -3548,6 +3573,10 @@
"staleResults": "Showing results from the last analysis. Many notes changed since then — click “Resynchronize network” to refresh.", "staleResults": "Showing results from the last analysis. Many notes changed since then — click “Resynchronize network” to refresh.",
"semanticGraphLegend": "Detected themes overview (not the link map)", "semanticGraphLegend": "Detected themes overview (not the link map)",
"fitGraphView": "Fit view", "fitGraphView": "Fit view",
"legendFilterPlaceholder": "Filter themes…",
"legendShowMore": "+{count} more",
"legendShowLess": "Show less",
"dashboardFilterPlaceholder": "Filter bridge notes, themes…",
"graphPreviewHint": "Theme overview: the number is how many notes belong here. Hover a dot for the title, click to open. Full list on the right.", "graphPreviewHint": "Theme overview: the number is how many notes belong here. Hover a dot for the title, click to open. Full list on the right.",
"graphMoreNotes": "+{count} more in this theme", "graphMoreNotes": "+{count} more in this theme",
"graphNotesLabel": "notes", "graphNotesLabel": "notes",
@@ -3556,8 +3585,10 @@
"emptyTitle": "Discover your knowledge clusters", "emptyTitle": "Discover your knowledge clusters",
"emptyDescription": "Click \"Re-sync network\" to analyze your notes and find hidden connections", "emptyDescription": "Click \"Re-sync network\" to analyze your notes and find hidden connections",
"stats": { "stats": {
"clusters": "Clusters", "clusters": "Themes",
"bridgeNotes": "Bridge notes" "bridgeNotes": "Bridge notes",
"themesSubtitle": "detected themes",
"bridgesSubtitle": "cross-theme notes"
}, },
"clusters": { "clusters": {
"title": "Semantic clusters", "title": "Semantic clusters",
@@ -3566,16 +3597,24 @@
"emptyCluster": "No notes in this cluster" "emptyCluster": "No notes in this cluster"
}, },
"bridgeNotes": { "bridgeNotes": {
"title": "Powerful bridge notes", "title": "Bridge notes",
"score": "Score: {score}%", "score": "Score: {score}%",
"affinity": "Affinity {score}%",
"scoreHint": "Mean semantic affinity to the two themes this note bridges (cosine similarity).",
"moreThemes": "+{count}",
"needsResync": "Re-sync the network to refresh bridge pairs.",
"empty": "No significant bridge notes yet. Deepen your research to find new connections." "empty": "No significant bridge notes yet. Deepen your research to find new connections."
}, },
"suggestions": { "suggestions": {
"title": "Missing links (AI generated)", "title": "Missing links",
"bridging": "Bridging {clusterA} & {clusterB}", "bridging": "Link {clusterA} & {clusterB}",
"emptyTitle": "No connection suggestions yet", "emptyTitle": "No connection suggestions yet",
"emptyDescription": "All your clusters may already be connected!", "emptyDescription": "No strong near-miss theme pairs right now — or re-sync to refresh predictions.",
"createNote": "Create bridge note" "createNote": "Create bridge note",
"created": "Bridge note created",
"createError": "Could not create the bridge note",
"dismiss": "Dismiss",
"dismissError": "Could not dismiss this suggestion"
}, },
"unknownNote": "Untitled note", "unknownNote": "Untitled note",
"viewSplit": "Split", "viewSplit": "Split",
@@ -3596,11 +3635,11 @@
"echoTitle": "You keep returning to this idea", "echoTitle": "You keep returning to this idea",
"tipClusters": "AI grouped your notes by semantic affinity — regardless of which notebook they're in. Each theme represents a subject your mind keeps returning to.", "tipClusters": "AI grouped your notes by semantic affinity — regardless of which notebook they're in. Each theme represents a subject your mind keeps returning to.",
"tipClustersAction": "Click a theme to see its notes. Click a note to open it.", "tipClustersAction": "Click a theme to see its notes. Click a note to open it.",
"tipBridgeNotes": "These notes speak to two different themes at once. They reveal where your thinking crosses boundaries — often where the most original ideas hide.", "tipBridgeNotes": "A bridge note sits at the crossing of two themes (semantic brokerage). We keep the strongest pair — not every weak theme touch.",
"tipBridgeNotesAction": "Click a note to open it and understand the connection.", "tipBridgeNotesAction": "Click a note to open it and understand the connection.",
"tipEcho": "Memory Echo detects two notes written at very different times that cover the same idea. Your mind revisited a thought without realising it.", "tipEcho": "Memory Echo detects two notes written at very different times that cover the same idea. Your mind revisited a thought without realising it.",
"tipEchoAction": "Two notes, same idea, different moments. Click to explore.", "tipEchoAction": "Two notes, same idea, different moments. Click to explore.",
"tipSuggestions": "These themes have no note linking them yet. AI proposes a starting idea. Click 'Create bridge note' to write it and open it in the editor.", "tipSuggestions": "Suggested only for near-miss theme pairs (link prediction): related enough to deserve a bridge, not forced metaphors. Create the note if the shared object or method is real.",
"tipSuggestionsAction": "Click 'Create bridge note' to write the note and open it immediately.", "tipSuggestionsAction": "Click 'Create bridge note' to write the note and open it immediately.",
"tipIsolated": "These themes are isolated: no note connects them to the others. Maybe you're exploring a fragile idea? One synthesis note would be enough to create the link.", "tipIsolated": "These themes are isolated: no note connects them to the others. Maybe you're exploring a fragile idea? One synthesis note would be enough to create the link.",
"tipIsolatedAction": "These themes have no note connecting them to the rest of your thinking.", "tipIsolatedAction": "These themes have no note connecting them to the rest of your thinking.",
@@ -3612,6 +3651,13 @@
}, },
"resetFocus": "Reset focus", "resetFocus": "Reset focus",
"listView": "List", "listView": "List",
"listFilterPlaceholder": "Filter themes or notes…",
"listFilterEmpty": "No theme or note matches this filter.",
"listFilterMatch": "matches",
"listSortLabel": "Sort",
"listSortSize": "By size",
"listSortBridges": "By bridges",
"listSortAlpha": "AZ",
"graphAriaLabel": "Semantic network: {clusters} clusters, {notes} notes, {bridges} bridge notes. Switch to List view for accessible navigation.", "graphAriaLabel": "Semantic network: {clusters} clusters, {notes} notes, {bridges} bridge notes. Switch to List view for accessible navigation.",
"listAriaLabel": "Accessible cluster list with notes and bridge connections" "listAriaLabel": "Accessible cluster list with notes and bridge connections"
}, },
@@ -4018,6 +4064,9 @@
"notes": "notes", "notes": "notes",
"continue": "Continue", "continue": "Continue",
"noRecentNotes": "No recent notes.", "noRecentNotes": "No recent notes.",
"resumeEmptyHint": "Your recent notes will show up here. Capture an idea or open a notebook to get started.",
"resumeEmptyCta": "Capture an idea",
"resumeAlso": "Also recently",
"untitled": "Untitled", "untitled": "Untitled",
"inbox": "Inbox", "inbox": "Inbox",
"sentiment": "Sentiment", "sentiment": "Sentiment",
@@ -4154,6 +4203,11 @@
"flashRetention": "Mastered", "flashRetention": "Mastered",
"flashStreak": "Streak", "flashStreak": "Streak",
"flashTotal": "Cards", "flashTotal": "Cards",
"flashEmptyHint": "No flashcards yet. Generate some from a note (graduation-cap icon in the editor), then track your progress here.",
"flashEmptyCta": "Open reviews",
"flashDueCta": "{count} cards due today",
"flashOpenCta": "View progress",
"activityEmptyHint": "Edit notes to see your writing rhythm over the last 90 days.",
"pathTypes": { "pathTypes": {
"continue": "Continue", "continue": "Continue",
"connect": "Semantic link", "connect": "Semantic link",

View File

@@ -1718,6 +1718,8 @@
"mcpSettings": { "mcpSettings": {
"title": "MCP", "title": "MCP",
"description": "Gestiona tus claves API y configura herramientas externas", "description": "Gestiona tus claves API y configura herramientas externas",
"tierRequired": "Solo Pro+",
"upgradeHint": "El acceso MCP (claves API para Cursor, Claude Desktop, etc.) requiere un plan Pro o superior. Actualiza en Facturación para desbloquear esta función.",
"whatIsMcp": { "whatIsMcp": {
"title": "¿Qué es MCP?", "title": "¿Qué es MCP?",
"description": "El Model Context Protocol (MCP) es un protocolo abierto que permite a los modelos de IA interactuar de forma segura con herramientas y fuentes de datos externas. Con MCP, puedes conectar herramientas como Claude Code, Cursor o N8N a tu instancia de Memento para leer, crear y organizar tus notas mediante programación.", "description": "El Model Context Protocol (MCP) es un protocolo abierto que permite a los modelos de IA interactuar de forma segura con herramientas y fuentes de datos externas. Con MCP, puedes conectar herramientas como Claude Code, Cursor o N8N a tu instancia de Memento para leer, crear y organizar tus notas mediante programación.",
@@ -1743,7 +1745,12 @@
"lastUsed": "Último uso", "lastUsed": "Último uso",
"never": "Nunca", "never": "Nunca",
"confirmRevoke": "¿Estás seguro de que quieres revocar esta clave? Las herramientas que la usen perderán el acceso.", "confirmRevoke": "¿Estás seguro de que quieres revocar esta clave? Las herramientas que la usen perderán el acceso.",
"confirmDelete": "¿Estás seguro de que quieres eliminar permanentemente esta clave?" "confirmDelete": "¿Estás seguro de que quieres eliminar permanentemente esta clave?",
"errors": {
"generate": "Error al generar la clave",
"revoke": "Error al revocar la clave",
"delete": "Error al eliminar la clave"
}
}, },
"createDialog": { "createDialog": {
"title": "Generar clave API", "title": "Generar clave API",
@@ -1773,6 +1780,15 @@
"title": "N8N", "title": "N8N",
"description": "Usa estas credenciales en tu nodo MCP de N8N:" "description": "Usa estas credenciales en tu nodo MCP de N8N:"
} }
},
"helpBox": {
"title": "¿Qué es MCP (Model Context Protocol)?",
"step1": "MCP es un protocolo que permite a los agentes IA de Memento conectarse a herramientas externas (bases de datos, APIs, archivos, etc.).",
"step2": "Memento expone un servidor MCP con 22 herramientas — tus agentes pueden leer/crear notas, buscar en tu base, gestionar cuadernos, etc.",
"step3": "Crea una clave API aquí y configúrala en tu cliente MCP (Claude Desktop, Cursor, Continue.dev…) con la URL del servidor.",
"step4": "Formato de configuración: URL del servidor MCP + tu clave en el header Authorization.",
"step4Link": "Documentación MCP",
"step5": "Caso de uso: pide a Claude Desktop que escriba una nota en Memento, busque en tus cuadernos o cree un agente."
} }
}, },
"agents": { "agents": {
@@ -2500,7 +2516,23 @@
"instantActivation": "Activación instantánea", "instantActivation": "Activación instantánea",
"secureDesc": "Pagos seguros por Stripe", "secureDesc": "Pagos seguros por Stripe",
"secureTransactions": "Transacciones seguras", "secureTransactions": "Transacciones seguras",
"satisfactionGuarantee": "Garantía de satisfacción de 30 días" "satisfactionGuarantee": "Garantía de satisfacción de 30 días",
"cancelConfirm": "¿Estás seguro de que quieres cancelar tu suscripción? Mantendrás tu acceso Pro/Business hasta el final del período de facturación actual.",
"cancelSuccess": "Tu suscripción se ha cancelado correctamente. Finalizará al final del período de facturación actual.",
"invoices": "Facturas y recibos",
"invoiceAmount": "Importe",
"invoiceDate": "Fecha",
"invoiceNumber": "Número",
"invoiceStatus": "Estado",
"noInvoices": "No hay facturas disponibles.",
"checkoutFailed": "No se pudo iniciar el pago. Inténtalo de nuevo.",
"portalFailed": "No se pudo abrir el portal de facturación.",
"cancelFailed": "No se pudo cancelar la suscripción.",
"downloadPdf": "Descargar PDF",
"fetchStatusFailed": "No se pudo obtener el estado de facturación",
"fetchQuotasFailed": "No se pudieron obtener las cuotas",
"fetchInvoicesFailed": "No se pudo cargar el historial de facturación.",
"savePercent": "Ahorra ~17%"
}, },
"landing": { "landing": {
"nav": { "nav": {
@@ -3029,5 +3061,13 @@
"hintNavigate": "navegar", "hintNavigate": "navegar",
"hintOpen": "abrir", "hintOpen": "abrir",
"hintClose": "cerrar" "hintClose": "cerrar"
},
"quotaPaywall": {
"title": "Límite mensual alcanzado",
"description": "Has agotado tus créditos de {feature} para este mes.",
"upgrade": "Mejorar plan",
"useOwnKey": "Usar tu propia clave API",
"later": "Quizás más tarde",
"closingIn": "Cerrando en {n}s"
} }
} }

View File

@@ -1753,6 +1753,8 @@
"mcpSettings": { "mcpSettings": {
"title": "MCP", "title": "MCP",
"description": "مدیریت کلیدهای API و پیکربندی ابزارهای خارجی", "description": "مدیریت کلیدهای API و پیکربندی ابزارهای خارجی",
"tierRequired": "فقط Pro+",
"upgradeHint": "دسترسی به MCP (کلیدهای API برای Cursor، Claude Desktop و غیره) نیاز به طرح Pro یا بالاتر دارد. برای فعال کردن این قابلیت، در بخش صورتحساب ارتقا دهید.",
"whatIsMcp": { "whatIsMcp": {
"title": "MCP چیست؟", "title": "MCP چیست؟",
"description": "پروتکل زمینه مدل (MCP) یک پروتکل باز است که به مدل‌های هوش مصنوعی امکان تعامل امن با ابزارها و منابع داده خارجی را می‌دهد. با MCP می‌توانید ابزارهایی مانند Claude Code، Cursor یا N8N را به نمونه Memento خود متصل کنید تا یادداشت‌های خود را به صورت برنامه‌نویسی بخوانید، ایجاد کنید و سازماندهی کنید.", "description": "پروتکل زمینه مدل (MCP) یک پروتکل باز است که به مدل‌های هوش مصنوعی امکان تعامل امن با ابزارها و منابع داده خارجی را می‌دهد. با MCP می‌توانید ابزارهایی مانند Claude Code، Cursor یا N8N را به نمونه Memento خود متصل کنید تا یادداشت‌های خود را به صورت برنامه‌نویسی بخوانید، ایجاد کنید و سازماندهی کنید.",
@@ -1778,7 +1780,12 @@
"lastUsed": "آخرین استفاده", "lastUsed": "آخرین استفاده",
"never": "هرگز", "never": "هرگز",
"confirmRevoke": "آیا مطمئن هستید که می‌خواهید این کلید را لغو کنید؟ ابزارهایی که از آن استفاده می‌کنند دسترسی خود را از دست می‌دهند.", "confirmRevoke": "آیا مطمئن هستید که می‌خواهید این کلید را لغو کنید؟ ابزارهایی که از آن استفاده می‌کنند دسترسی خود را از دست می‌دهند.",
"confirmDelete": "آیا مطمئن هستید که می‌خواهید این کلید را برای همیشه حذف کنید؟" "confirmDelete": "آیا مطمئن هستید که می‌خواهید این کلید را برای همیشه حذف کنید؟",
"errors": {
"generate": "تولید کلید ناموفق بود",
"revoke": "ابطال کلید ناموفق بود",
"delete": "حذف کلید ناموفق بود"
}
}, },
"createDialog": { "createDialog": {
"title": "ایجاد کلید API", "title": "ایجاد کلید API",
@@ -1808,6 +1815,15 @@
"title": "N8N", "title": "N8N",
"description": "از این اعتبارنامه‌ها در گره N8N MCP خود استفاده کنید:" "description": "از این اعتبارنامه‌ها در گره N8N MCP خود استفاده کنید:"
} }
},
"helpBox": {
"title": "MCP (Model Context Protocol) چیست؟",
"step1": "MCP یک پروتکل است که به عامل‌های هوش مصنوعی Memento اجازه می‌دهد به ابزارهای خارجی (پایگاه داده، API، فایل‌ها و غیره) متصل شوند.",
"step2": "Memento یک سرور MCP با ۲۲ ابزار ارائه می‌دهد — عامل‌های شما می‌توانند یادداشت بخوانید/ایجاد کنید، در پایگاه داده جستجو کنید، دفترچه‌ها را مدیریت کنید و غیره.",
"step3": "اینجا یک کلید API ایجاد کنید، سپس آن را در کلاینت MCP خود (Claude Desktop، Cursor، Continue.dev…) با URL سرور پیکربندی کنید.",
"step4": "فرمت پیکربندی: URL سرور MCP + کلید شما در هدر Authorization.",
"step4Link": "مستندات MCP",
"step5": "کاربرد: از Claude Desktop بخواهید یک یادداشت در Memento بنویسد، در دفترچه‌های شما جستجو کند یا یک عامل ایجاد کند."
} }
}, },
"agents": { "agents": {
@@ -2539,7 +2555,23 @@
"instantActivation": "فعال‌سازی فوری", "instantActivation": "فعال‌سازی فوری",
"secureDesc": "پرداخت امن از طریق Stripe", "secureDesc": "پرداخت امن از طریق Stripe",
"secureTransactions": "تراکنش‌های امن", "secureTransactions": "تراکنش‌های امن",
"satisfactionGuarantee": "ضمان بازگشت 30 روزه" "satisfactionGuarantee": "ضمان بازگشت 30 روزه",
"cancelConfirm": "آیا مطمئن هستید که می‌خواهید اشتراک خود را لغو کنید؟ تا پایان دوره صورت‌حساب جاری، دسترسی Pro/Business خود را حفظ خواهید کرد.",
"cancelSuccess": "اشتراک شما با موفقیت لغو شد. در پایان دوره صورت‌حساب جاری به پایان خواهد رسید.",
"invoices": "فاکتورها و رسیدها",
"invoiceAmount": "مبلغ",
"invoiceDate": "تاریخ",
"invoiceNumber": "شماره",
"invoiceStatus": "وضعیت",
"noInvoices": "هیچ فاکتوری موجود نیست.",
"checkoutFailed": "شروع پرداخت ناموفق بود. دوباره تلاش کنید.",
"portalFailed": "باز کردن پورتال صورتحساب ناموفق بود.",
"cancelFailed": "لغو اشتراک ناموفق بود.",
"downloadPdf": "دانلود PDF",
"fetchStatusFailed": "دریافت وضعیت صورتحساب ناموفق بود",
"fetchQuotasFailed": "دریافت سهمیه‌ها ناموفق بود",
"fetchInvoicesFailed": "بارگذاری تاریخچه صورتحساب ناموفق بود.",
"savePercent": "~۱۷٪ صرفه‌جویی"
}, },
"landing": { "landing": {
"nav": { "nav": {
@@ -3068,5 +3100,13 @@
"hintNavigate": "پیمایش", "hintNavigate": "پیمایش",
"hintOpen": "باز کردن", "hintOpen": "باز کردن",
"hintClose": "بستن" "hintClose": "بستن"
},
"quotaPaywall": {
"title": "محدودیت ماهانه رسید",
"description": "شما تمام اعتبار {feature} خود را برای این ماه مصرف کرده‌اید.",
"upgrade": "ارتقای طرح",
"useOwnKey": "از کلید API خود استفاده کنید",
"later": "شاید بعداً",
"closingIn": "بستن در {n}ثانیه"
} }
} }

View File

@@ -1764,7 +1764,11 @@
"featureCharts": "Graphiques IA", "featureCharts": "Graphiques IA",
"featurePublishEnhance": "Publication IA", "featurePublishEnhance": "Publication IA",
"featureBrainstormExpand": "Extensions brainstorm", "featureBrainstormExpand": "Extensions brainstorm",
"featureBrainstormEnrich": "Enrichissements brainstorm" "featureBrainstormEnrich": "Enrichissements brainstorm",
"featureFlashcards": "Flashcards IA",
"featureVoice": "Transcription vocale",
"featureSlides": "Génération de diapositives",
"featureDiagrams": "Génération de diagrammes"
}, },
"generalSettings": { "generalSettings": {
"title": "Généraux", "title": "Généraux",
@@ -2042,6 +2046,8 @@
"mcpSettings": { "mcpSettings": {
"title": "MCP", "title": "MCP",
"description": "Gérez vos clés API et configurez les outils externes", "description": "Gérez vos clés API et configurez les outils externes",
"tierRequired": "Réservé Pro+",
"upgradeHint": "L'accès MCP (clés API pour Cursor, Claude Desktop, etc.) nécessite un abonnement Pro ou supérieur. Passez à Pro dans Facturation pour débloquer cette fonctionnalité.",
"whatIsMcp": { "whatIsMcp": {
"title": "Qu'est-ce que MCP ?", "title": "Qu'est-ce que MCP ?",
"description": "Le Model Context Protocol (MCP) est un protocole ouvert qui permet aux modèles IA d'interagir de manière sécurisée avec des outils et sources de données externes. Avec MCP, vous pouvez connecter des outils comme Claude Code, Cursor ou N8N à votre instance Memento pour lire, créer et organiser vos notes par programmation.", "description": "Le Model Context Protocol (MCP) est un protocole ouvert qui permet aux modèles IA d'interagir de manière sécurisée avec des outils et sources de données externes. Avec MCP, vous pouvez connecter des outils comme Claude Code, Cursor ou N8N à votre instance Memento pour lire, créer et organiser vos notes par programmation.",
@@ -2067,7 +2073,12 @@
"lastUsed": "Dernière utilisation", "lastUsed": "Dernière utilisation",
"never": "Jamais", "never": "Jamais",
"confirmRevoke": "Êtes-vous sûr de vouloir révoquer cette clé ? Les outils qui l'utilisent perdront leur accès.", "confirmRevoke": "Êtes-vous sûr de vouloir révoquer cette clé ? Les outils qui l'utilisent perdront leur accès.",
"confirmDelete": "Êtes-vous sûr de vouloir supprimer définitivement cette clé ?" "confirmDelete": "Êtes-vous sûr de vouloir supprimer définitivement cette clé ?",
"errors": {
"generate": "Échec de la génération de la clé",
"revoke": "Échec de la révocation de la clé",
"delete": "Échec de la suppression de la clé"
}
}, },
"createDialog": { "createDialog": {
"title": "Générer une clé API", "title": "Générer une clé API",
@@ -2097,6 +2108,15 @@
"title": "N8N", "title": "N8N",
"description": "Utilisez ces identifiants dans votre nœud MCP N8N :" "description": "Utilisez ces identifiants dans votre nœud MCP N8N :"
} }
},
"helpBox": {
"title": "Qu'est-ce que MCP (Model Context Protocol) ?",
"step1": "MCP est un protocole qui permet aux agents IA de Memento de se connecter à des outils externes (bases de données, APIs, fichiers, etc.).",
"step2": "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.",
"step3": "Créez une clé API ici, puis configurez-la dans votre client MCP (Claude Desktop, Cursor, Continue.dev…) avec l'URL du serveur.",
"step4": "Format de configuration : URL du serveur MCP + votre clé dans le header Authorization.",
"step4Link": "Documentation MCP",
"step5": "Cas d'usage : demandez à Claude Desktop d'écrire une note dans Memento, de chercher dans vos carnets, ou de créer un agent."
} }
}, },
"agents": { "agents": {
@@ -3281,14 +3301,19 @@
"checkoutFailed": "Échec du démarrage du paiement. Veuillez réessayer.", "checkoutFailed": "Échec du démarrage du paiement. Veuillez réessayer.",
"portalFailed": "Échec de l'ouverture du portail de facturation.", "portalFailed": "Échec de l'ouverture du portail de facturation.",
"cancelFailed": "Échec de l'annulation de l'abonnement.", "cancelFailed": "Échec de l'annulation de l'abonnement.",
"downloadPdf": "Télécharger le PDF" "downloadPdf": "Télécharger le PDF",
"fetchStatusFailed": "Échec du chargement des informations de facturation",
"fetchQuotasFailed": "Échec du chargement des quotas",
"fetchInvoicesFailed": "Impossible de charger l'historique de facturation.",
"savePercent": "Économisez ~17%"
}, },
"quotaPaywall": { "quotaPaywall": {
"title": "Limite mensuelle atteinte", "title": "Limite mensuelle atteinte",
"description": "Vous avez épuisé vos crédits pour la fonctionnalité {feature} ce mois-ci.", "description": "Vous avez épuisé vos crédits pour la fonctionnalité {feature} ce mois-ci.",
"upgrade": "Passer au plan supérieur", "upgrade": "Passer au plan supérieur",
"useOwnKey": "Clé API perso", "useOwnKey": "Clé API perso",
"later": "Peut-être plus tard" "later": "Peut-être plus tard",
"closingIn": "Fermeture dans {n}s"
}, },
"landing": { "landing": {
"nav": { "nav": {
@@ -3554,6 +3579,10 @@
"staleResults": "Résultats affichés depuis la dernière analyse. Beaucoup de notes ont changé depuis — cliquez « Resynchroniser le réseau » pour mettre à jour.", "staleResults": "Résultats affichés depuis la dernière analyse. Beaucoup de notes ont changé depuis — cliquez « Resynchroniser le réseau » pour mettre à jour.",
"semanticGraphLegend": "Aperçu des thèmes détectés (pas la carte des liens)", "semanticGraphLegend": "Aperçu des thèmes détectés (pas la carte des liens)",
"fitGraphView": "Ajuster la vue", "fitGraphView": "Ajuster la vue",
"legendFilterPlaceholder": "Filtrer les thèmes…",
"legendShowMore": "+{count} autres",
"legendShowLess": "Réduire",
"dashboardFilterPlaceholder": "Filtrer notes-ponts, thèmes…",
"graphPreviewHint": "Aperçu par thème : le chiffre = nombre de notes. Survolez un point pour le titre, cliquez pour ouvrir. Liste complète à droite.", "graphPreviewHint": "Aperçu par thème : le chiffre = nombre de notes. Survolez un point pour le titre, cliquez pour ouvrir. Liste complète à droite.",
"graphMoreNotes": "+{count} autres dans ce thème", "graphMoreNotes": "+{count} autres dans ce thème",
"graphNotesLabel": "notes", "graphNotesLabel": "notes",
@@ -3562,8 +3591,10 @@
"emptyTitle": "Découvrez vos clusters de connaissance", "emptyTitle": "Découvrez vos clusters de connaissance",
"emptyDescription": "Cliquez sur « Resynchroniser le réseau » pour analyser vos notes et révéler des connexions cachées", "emptyDescription": "Cliquez sur « Resynchroniser le réseau » pour analyser vos notes et révéler des connexions cachées",
"stats": { "stats": {
"clusters": "Clusters", "clusters": "Thèmes",
"bridgeNotes": "Notes pont" "bridgeNotes": "Notes pont",
"themesSubtitle": "thèmes détectés",
"bridgesSubtitle": "notes transversales"
}, },
"clusters": { "clusters": {
"title": "Clusters sémantiques", "title": "Clusters sémantiques",
@@ -3572,16 +3603,24 @@
"emptyCluster": "Aucune note dans ce cluster" "emptyCluster": "Aucune note dans ce cluster"
}, },
"bridgeNotes": { "bridgeNotes": {
"title": "Notes pont stratégiques", "title": "Notes pont",
"score": "Score : {score} %", "score": "Score : {score} %",
"affinity": "Affinité {score} %",
"scoreHint": "Affinité sémantique moyenne avec les deux thèmes que cette note relie (similarité cosinus).",
"moreThemes": "+{count}",
"needsResync": "Resynchronisez le réseau pour rafraîchir les paires de thèmes.",
"empty": "Aucune note pont significative pour l'instant. Approfondissez vos recherches pour découvrir de nouvelles connexions." "empty": "Aucune note pont significative pour l'instant. Approfondissez vos recherches pour découvrir de nouvelles connexions."
}, },
"suggestions": { "suggestions": {
"title": "Liens manquants (générés par l'IA)", "title": "Liens manquants",
"bridging": "Relier {clusterA} et {clusterB}", "bridging": "Relier {clusterA} et {clusterB}",
"emptyTitle": "Aucune suggestion de connexion", "emptyTitle": "Aucune suggestion de connexion",
"emptyDescription": "Vos clusters sont peut-être déjà bien reliés entre eux !", "emptyDescription": "Aucune paire de thèmes « presque liés » pour l'instant — ou resynchronisez pour rafraîchir les prédictions.",
"createNote": "Créer la note pont" "createNote": "Créer la note pont",
"created": "Note pont créée",
"createError": "Impossible de créer la note pont",
"dismiss": "Ignorer",
"dismissError": "Impossible d'ignorer cette suggestion"
}, },
"unknownNote": "Note sans titre", "unknownNote": "Note sans titre",
"viewSplit": "Combiné", "viewSplit": "Combiné",
@@ -3602,11 +3641,11 @@
"echoTitle": "Tu reviens sur cette idée", "echoTitle": "Tu reviens sur cette idée",
"tipClusters": "L'IA a regroupé tes notes par affinité sémantique — indépendamment de tes carnets. Chaque thème représente un sujet sur lequel ton esprit revient régulièrement.", "tipClusters": "L'IA a regroupé tes notes par affinité sémantique — indépendamment de tes carnets. Chaque thème représente un sujet sur lequel ton esprit revient régulièrement.",
"tipClustersAction": "Clique sur un thème pour voir ses notes. Clique sur une note pour l'ouvrir.", "tipClustersAction": "Clique sur un thème pour voir ses notes. Clique sur une note pour l'ouvrir.",
"tipBridgeNotes": "Ces notes parlent de deux thèmes différents à la fois. Elles montrent où ta pensée traverse des frontières — souvent là où se cachent les idées les plus originales.", "tipBridgeNotes": "Une note pont se situe au croisement de deux thèmes (brokerage sémantique). On garde la paire la plus forte — pas tous les thèmes faibles touchés au passage.",
"tipBridgeNotesAction": "Clique sur une note pour l'ouvrir et comprendre le lien.", "tipBridgeNotesAction": "Clique sur une note pour l'ouvrir et comprendre le lien.",
"tipEcho": "Le Memory Echo détecte deux notes écrites à des moments très différents mais qui parlent de la même chose. Ton esprit a revisité une idée sans que tu t'en rendes compte.", "tipEcho": "Le Memory Echo détecte deux notes écrites à des moments très différents mais qui parlent de la même chose. Ton esprit a revisité une idée sans que tu t'en rendes compte.",
"tipEchoAction": "Deux notes, même idée, moments différents. Clique pour explorer.", "tipEchoAction": "Deux notes, même idée, moments différents. Clique pour explorer.",
"tipSuggestions": "Ces thèmes n'ont pas encore de note qui les relie. L'IA te propose une idée de départ. Clique sur « Créer la note pont » pour la rédiger et l'ouvrir dans l'éditeur.", "tipSuggestions": "Suggestions uniquement pour des paires de thèmes proches (link prediction) : assez liées pour mériter un pont, pas des métaphores forcées. Créez la note si l'objet ou la méthode partagée est réelle.",
"tipSuggestionsAction": "Clique sur « Créer la note pont » pour créer la note et l'ouvrir immédiatement.", "tipSuggestionsAction": "Clique sur « Créer la note pont » pour créer la note et l'ouvrir immédiatement.",
"tipIsolated": "Ces thèmes sont isolés : aucune note ne les relie aux autres. Peut-être explores-tu une idée encore fragile ? Une note de synthèse suffirait à créer le lien.", "tipIsolated": "Ces thèmes sont isolés : aucune note ne les relie aux autres. Peut-être explores-tu une idée encore fragile ? Une note de synthèse suffirait à créer le lien.",
"tipIsolatedAction": "Ces thèmes n'ont aucune note qui les relie au reste de ta réflexion.", "tipIsolatedAction": "Ces thèmes n'ont aucune note qui les relie au reste de ta réflexion.",
@@ -3618,6 +3657,13 @@
}, },
"resetFocus": "Réinitialiser focus", "resetFocus": "Réinitialiser focus",
"listView": "Liste", "listView": "Liste",
"listFilterPlaceholder": "Filtrer thèmes ou notes…",
"listFilterEmpty": "Aucun thème ni note ne correspond à ce filtre.",
"listFilterMatch": "correspondances",
"listSortLabel": "Trier",
"listSortSize": "Par taille",
"listSortBridges": "Par ponts",
"listSortAlpha": "AZ",
"graphAriaLabel": "Réseau sémantique : {clusters} thèmes, {notes} notes, {bridges} notes-ponts. Basculez en vue Liste pour une navigation accessible.", "graphAriaLabel": "Réseau sémantique : {clusters} thèmes, {notes} notes, {bridges} notes-ponts. Basculez en vue Liste pour une navigation accessible.",
"listAriaLabel": "Liste accessible des clusters avec notes et connexions ponts" "listAriaLabel": "Liste accessible des clusters avec notes et connexions ponts"
}, },
@@ -4024,6 +4070,9 @@
"notes": "notes", "notes": "notes",
"continue": "Reprendre ici", "continue": "Reprendre ici",
"noRecentNotes": "Aucune note récente.", "noRecentNotes": "Aucune note récente.",
"resumeEmptyHint": "Vos notes récentes apparaîtront ici. Capturez une idée ou ouvrez un carnet pour commencer.",
"resumeEmptyCta": "Capturer une idée",
"resumeAlso": "Aussi récemment",
"untitled": "Sans titre", "untitled": "Sans titre",
"inbox": "Inbox", "inbox": "Inbox",
"sentiment": "Analyse émotionnelle", "sentiment": "Analyse émotionnelle",
@@ -4160,6 +4209,11 @@
"flashRetention": "Maîtrisées", "flashRetention": "Maîtrisées",
"flashStreak": "Série", "flashStreak": "Série",
"flashTotal": "Cartes", "flashTotal": "Cartes",
"flashEmptyHint": "Pas encore de flashcards. Générez-en depuis une note (icône diplôme dans léditeur), puis suivez votre progression ici.",
"flashEmptyCta": "Ouvrir les révisions",
"flashDueCta": "{count} cartes à réviser aujourdhui",
"flashOpenCta": "Voir la progression",
"activityEmptyHint": "Éditez des notes pour voir votre rythme décriture sur 90 jours.",
"pathTypes": { "pathTypes": {
"continue": "Continuer", "continue": "Continuer",
"connect": "Lien sémantique", "connect": "Lien sémantique",

View File

@@ -1718,6 +1718,8 @@
"mcpSettings": { "mcpSettings": {
"title": "MCP", "title": "MCP",
"description": "API कुंजियाँ प्रबंधित करें और बाहरी टूल कॉन्फ़िगर करें", "description": "API कुंजियाँ प्रबंधित करें और बाहरी टूल कॉन्फ़िगर करें",
"tierRequired": "केवल Pro+",
"upgradeHint": "MCP एक्सेस (Cursor, Claude Desktop आदि के लिए API कुंजियाँ) के लिए Pro या उससे उच्च योजना आवश्यक है। इस सुविधा को अनलॉक करने के लिए बिलिंग में अपग्रेड करें।",
"whatIsMcp": { "whatIsMcp": {
"title": "MCP क्या है?", "title": "MCP क्या है?",
"description": "मॉडल कॉन्टेक्स्ट प्रोटोकॉल (MCP) एक खुला प्रोटोकॉल है जो AI मॉडल को बाहरी टूल और डेटा स्रोतों के साथ सुरक्षित रूप से इंटरैक्ट करने में सक्षम बनाता है। MCP के साथ, आप Claude Code, Cursor या N8N जैसे टूल को अपने Memento इंस्टेंस से कनेक्ट करके प्रोग्रामेटिक रूप से अपने नोट्स को पढ़ सकते हैं, बना सकते हैं और व्यवस्थित कर सकते हैं।", "description": "मॉडल कॉन्टेक्स्ट प्रोटोकॉल (MCP) एक खुला प्रोटोकॉल है जो AI मॉडल को बाहरी टूल और डेटा स्रोतों के साथ सुरक्षित रूप से इंटरैक्ट करने में सक्षम बनाता है। MCP के साथ, आप Claude Code, Cursor या N8N जैसे टूल को अपने Memento इंस्टेंस से कनेक्ट करके प्रोग्रामेटिक रूप से अपने नोट्स को पढ़ सकते हैं, बना सकते हैं और व्यवस्थित कर सकते हैं।",
@@ -1743,7 +1745,12 @@
"lastUsed": "अंतिम उपयोग", "lastUsed": "अंतिम उपयोग",
"never": "कभी नहीं", "never": "कभी नहीं",
"confirmRevoke": "क्या आप वाकई इस कुंजी को निरस्त करना चाहते हैं? इसका उपयोग करने वाले टूल की पहुँच हट जाएगी।", "confirmRevoke": "क्या आप वाकई इस कुंजी को निरस्त करना चाहते हैं? इसका उपयोग करने वाले टूल की पहुँच हट जाएगी।",
"confirmDelete": "क्या आप वाकई इस कुंजी को स्थायी रूप से हटाना चाहते हैं?" "confirmDelete": "क्या आप वाकई इस कुंजी को स्थायी रूप से हटाना चाहते हैं?",
"errors": {
"generate": "कुंजी जनरेट करने में विफल",
"revoke": "कुंजी निरस्त करने में विफल",
"delete": "कुंजी हटाने में विफल"
}
}, },
"createDialog": { "createDialog": {
"title": "API कुंजी जनरेट करें", "title": "API कुंजी जनरेट करें",
@@ -1773,6 +1780,15 @@
"title": "N8N", "title": "N8N",
"description": "अपने N8N MCP नोड में इन क्रेडेंशियल का उपयोग करें:" "description": "अपने N8N MCP नोड में इन क्रेडेंशियल का उपयोग करें:"
} }
},
"helpBox": {
"title": "MCP (Model Context Protocol) क्या है?",
"step1": "MCP एक प्रोटोकॉल है जो Memento के AI एजेंटों को बाहरी टूल (डेटाबेस, API, फ़ाइलें आदि) से जुड़ने की अनुमति देता है।",
"step2": "Memento 22 टूल के साथ एक MCP सर्वर प्रदान करता है — आपके एजेंट नोट पढ़/बना सकते हैं, अपने डेटाबेस में खोज सकते हैं, नोटबुक प्रबंधित कर सकते हैं आदि।",
"step3": "यहाँ एक API कुंजी बनाएँ, फिर इसे अपने MCP क्लाइंट (Claude Desktop, Cursor, Continue.dev…) में सर्वर URL के साथ कॉन्फ़िगर करें।",
"step4": "कॉन्फ़िगरेशन प्रारूप: MCP सर्वर URL + Authorization हेडर में आपकी कुंजी।",
"step4Link": "MCP दस्तावेज़",
"step5": "उपयोग का मामला: Claude Desktop से Memento में नोट लिखने, अपने नोटबुक में खोजने या एजेंट बनाने के लिए कहें।"
} }
}, },
"agents": { "agents": {
@@ -2500,7 +2516,23 @@
"instantActivation": "तत्काल सक्रियण", "instantActivation": "तत्काल सक्रियण",
"secureDesc": "Stripe द्वारा सुरक्षित भुगतान", "secureDesc": "Stripe द्वारा सुरक्षित भुगतान",
"secureTransactions": "सुरक्षित लेनदेन", "secureTransactions": "सुरक्षित लेनदेन",
"satisfactionGuarantee": "30 दिन की संतुष्टि गारंटी" "satisfactionGuarantee": "30 दिन की संतुष्टि गारंटी",
"cancelConfirm": "क्या आप वाकई अपनी सदस्यता रद्द करना चाहते हैं? वर्तमान बिलिंग अवधि के अंत तक आपकी Pro/Business पहुँच बनी रहेगी।",
"cancelSuccess": "आपकी सदस्यता सफलतापूर्वक रद्द कर दी गई है। यह वर्तमान बिलिंग अवधि के अंत में समाप्त हो जाएगी।",
"invoices": "चालान और रसीदें",
"invoiceAmount": "राशि",
"invoiceDate": "तारीख",
"invoiceNumber": "नंबर",
"invoiceStatus": "स्थिति",
"noInvoices": "कोई चालान उपलब्ध नहीं है।",
"checkoutFailed": "चेकआउट शुरू करने में विफल। कृपया पुनः प्रयास करें।",
"portalFailed": "बिलिंग पोर्टल खोलने में विफल।",
"cancelFailed": "सदस्यता रद्द करने में विफल।",
"downloadPdf": "PDF डाउनलोड करें",
"fetchStatusFailed": "बिलिंग स्थिति प्राप्त करने में विफल",
"fetchQuotasFailed": "कोटा प्राप्त करने में विफल",
"fetchInvoicesFailed": "बिलिंग इतिहास लोड करने में विफल।",
"savePercent": "~17% बचाएँ"
}, },
"landing": { "landing": {
"nav": { "nav": {
@@ -3029,5 +3061,13 @@
"hintNavigate": "नेविगेट", "hintNavigate": "नेविगेट",
"hintOpen": "खोलें", "hintOpen": "खोलें",
"hintClose": "बंद करें" "hintClose": "बंद करें"
},
"quotaPaywall": {
"title": "मासिक सीमा पहुँची",
"description": "आपने इस महीने के लिए अपने सभी {feature} क्रेडिट खत्म कर लिए हैं।",
"upgrade": "प्लान अपग्रेड करें",
"useOwnKey": "अपनी खुद की API कुंजी का उपयोग करें",
"later": "शायद बाद में",
"closingIn": "{n}सेकंड में बंद हो रहा है"
} }
} }

View File

@@ -1718,6 +1718,8 @@
"mcpSettings": { "mcpSettings": {
"title": "MCP", "title": "MCP",
"description": "Gestisci le chiavi API e configura gli strumenti esterni", "description": "Gestisci le chiavi API e configura gli strumenti esterni",
"tierRequired": "Solo Pro+",
"upgradeHint": "L'accesso MCP (chiavi API per Cursor, Claude Desktop, ecc.) richiede un piano Pro o superiore. Esegui l'upgrade in Fatturazione per sbloccare questa funzionalità.",
"whatIsMcp": { "whatIsMcp": {
"title": "Cos'è MCP?", "title": "Cos'è MCP?",
"description": "Il Model Context Protocol (MCP) è un protocollo aperto che consente ai modelli di IA di interagire in modo sicuro con strumenti e fonti di dati esterni. Con MCP puoi collegare strumenti come Claude Code, Cursor o N8N alla tua istanza Memento per leggere, creare e organizzare le tue note a livello di programmazione.", "description": "Il Model Context Protocol (MCP) è un protocollo aperto che consente ai modelli di IA di interagire in modo sicuro con strumenti e fonti di dati esterni. Con MCP puoi collegare strumenti come Claude Code, Cursor o N8N alla tua istanza Memento per leggere, creare e organizzare le tue note a livello di programmazione.",
@@ -1743,7 +1745,12 @@
"lastUsed": "Ultimo utilizzo", "lastUsed": "Ultimo utilizzo",
"never": "Mai", "never": "Mai",
"confirmRevoke": "Sei sicuro di voler revocare questa chiave? Gli strumenti che la utilizzano perderanno l'accesso.", "confirmRevoke": "Sei sicuro di voler revocare questa chiave? Gli strumenti che la utilizzano perderanno l'accesso.",
"confirmDelete": "Sei sicuro di voler eliminare definitivamente questa chiave?" "confirmDelete": "Sei sicuro di voler eliminare definitivamente questa chiave?",
"errors": {
"generate": "Generazione della chiave non riuscita",
"revoke": "Revoca della chiave non riuscita",
"delete": "Eliminazione della chiave non riuscita"
}
}, },
"createDialog": { "createDialog": {
"title": "Genera chiave API", "title": "Genera chiave API",
@@ -1773,6 +1780,15 @@
"title": "N8N", "title": "N8N",
"description": "Usa queste credenziali nel tuo nodo MCP N8N:" "description": "Usa queste credenziali nel tuo nodo MCP N8N:"
} }
},
"helpBox": {
"title": "Cos'è MCP (Model Context Protocol)?",
"step1": "MCP è un protocollo che consente agli agenti IA di Memento di connettersi a strumenti esterni (database, API, file, ecc.).",
"step2": "Memento espone un server MCP con 22 strumenti — i tuoi agenti possono leggere/creare note, cercare nel tuo database, gestire i taccuini, ecc.",
"step3": "Crea una chiave API qui, poi configurala nel tuo client MCP (Claude Desktop, Cursor, Continue.dev…) con l'URL del server.",
"step4": "Formato di configurazione: URL del server MCP + la tua chiave nell'header Authorization.",
"step4Link": "Documentazione MCP",
"step5": "Caso d'uso: chiedi a Claude Desktop di scrivere una nota in Memento, cercare nei tuoi taccuini o creare un agente."
} }
}, },
"agents": { "agents": {
@@ -2500,7 +2516,23 @@
"instantActivation": "Attivazione istantanea", "instantActivation": "Attivazione istantanea",
"secureDesc": "Pagamenti sicuri tramite Stripe", "secureDesc": "Pagamenti sicuri tramite Stripe",
"secureTransactions": "Transazioni sicure", "secureTransactions": "Transazioni sicure",
"satisfactionGuarantee": "Garanzia di soddisfazione di 30 giorni" "satisfactionGuarantee": "Garanzia di soddisfazione di 30 giorni",
"cancelConfirm": "Sei sicuro di voler annullare il tuo abbonamento? Manterrai l'accesso Pro/Business fino alla fine del periodo di fatturazione corrente.",
"cancelSuccess": "Il tuo abbonamento è stato annullato con successo. Terminerà alla fine del periodo di fatturazione corrente.",
"invoices": "Fatture e ricevute",
"invoiceAmount": "Importo",
"invoiceDate": "Data",
"invoiceNumber": "Numero",
"invoiceStatus": "Stato",
"noInvoices": "Nessuna fattura disponibile.",
"checkoutFailed": "Impossibile avviare il checkout. Riprova.",
"portalFailed": "Impossibile aprire il portale di fatturazione.",
"cancelFailed": "Impossibile annullare l'abbonamento.",
"downloadPdf": "Scarica PDF",
"fetchStatusFailed": "Impossibile recuperare lo stato della fatturazione",
"fetchQuotasFailed": "Impossibile recuperare le quote",
"fetchInvoicesFailed": "Impossibile caricare lo storico delle fatture.",
"savePercent": "Risparmia ~17%"
}, },
"landing": { "landing": {
"nav": { "nav": {
@@ -3029,5 +3061,13 @@
"hintNavigate": "navigare", "hintNavigate": "navigare",
"hintOpen": "aprire", "hintOpen": "aprire",
"hintClose": "chiudere" "hintClose": "chiudere"
},
"quotaPaywall": {
"title": "Limite mensile raggiunto",
"description": "Hai esaurito i tuoi crediti {feature} per questo mese.",
"upgrade": "Migliora piano",
"useOwnKey": "Usa la tua chiave API",
"later": "Forse più tardi",
"closingIn": "Chiusura tra {n}s"
} }
} }

View File

@@ -1718,6 +1718,8 @@
"mcpSettings": { "mcpSettings": {
"title": "MCP", "title": "MCP",
"description": "APIキーの管理と外部ツールの設定", "description": "APIキーの管理と外部ツールの設定",
"tierRequired": "Pro+ のみ",
"upgradeHint": "MCPアクセスCursor、Claude DesktopなどのAPIキーにはProプラン以上が必要です。請求設定でアップグレードしてこの機能を有効にしてください。",
"whatIsMcp": { "whatIsMcp": {
"title": "MCPとは", "title": "MCPとは",
"description": "Model Context ProtocolMCPは、AIモデルが外部ツールやデータソースと安全にやり取りできるようにするオープンプロトコルです。MCPを使用すると、Claude Code、Cursor、N8NなどのツールをMementoインスタンスに接続し、プログラムでートの読み取り、作成、整理を行うことができます。", "description": "Model Context ProtocolMCPは、AIモデルが外部ツールやデータソースと安全にやり取りできるようにするオープンプロトコルです。MCPを使用すると、Claude Code、Cursor、N8NなどのツールをMementoインスタンスに接続し、プログラムでートの読み取り、作成、整理を行うことができます。",
@@ -1743,7 +1745,12 @@
"lastUsed": "最終使用", "lastUsed": "最終使用",
"never": "未使用", "never": "未使用",
"confirmRevoke": "このキーを無効化してよろしいですか?使用中のツールがアクセスを失います。", "confirmRevoke": "このキーを無効化してよろしいですか?使用中のツールがアクセスを失います。",
"confirmDelete": "このキーを永久に削除してよろしいですか?" "confirmDelete": "このキーを永久に削除してよろしいですか?",
"errors": {
"generate": "キーの生成に失敗しました",
"revoke": "キーの取り消しに失敗しました",
"delete": "キーの削除に失敗しました"
}
}, },
"createDialog": { "createDialog": {
"title": "APIキーを生成", "title": "APIキーを生成",
@@ -1773,6 +1780,15 @@
"title": "N8N", "title": "N8N",
"description": "N8N MCPードで以下の認証情報を使用してください" "description": "N8N MCPードで以下の認証情報を使用してください"
} }
},
"helpBox": {
"title": "MCPModel Context Protocolとは",
"step1": "MCPは、MementoのAIエージェントが外部ツールデータベース、API、ファイルなどに接続できるようにするプロトコルです。",
"step2": "Mementoは22のツールを備えたMCPサーバーを提供します — エージェントはノートの読み取り/作成、データベースの検索、ノートブックの管理などが可能です。",
"step3": "ここでAPIキーを作成し、MCPクライアントClaude Desktop、Cursor、Continue.dev…でサーバーURLと共に設定してください。",
"step4": "設定形式MCPサーバーURL + Authorizationヘッダーにあなたのキー。",
"step4Link": "MCPドキュメント",
"step5": "使用例Claude DesktopにMementoへートの作成、ートブックの検索、エージェントの作成を依頼できます。"
} }
}, },
"agents": { "agents": {
@@ -2500,7 +2516,23 @@
"instantActivation": "即座の有効化", "instantActivation": "即座の有効化",
"secureDesc": "Stripeによる安全な決済", "secureDesc": "Stripeによる安全な決済",
"secureTransactions": "安全なトランザクション", "secureTransactions": "安全なトランザクション",
"satisfactionGuarantee": "30日間満足保証" "satisfactionGuarantee": "30日間満足保証",
"cancelConfirm": "サブスクリプションを本当にキャンセルしますか?現在の請求期間の終了まで Pro/Business へのアクセスは維持されます。",
"cancelSuccess": "サブスクリプションは正常にキャンセルされました。現在の請求期間の終了時に終了します。",
"invoices": "請求書と領収書",
"invoiceAmount": "金額",
"invoiceDate": "日付",
"invoiceNumber": "番号",
"invoiceStatus": "ステータス",
"noInvoices": "請求書はありません。",
"checkoutFailed": "チェックアウトを開始できませんでした。もう一度お試しください。",
"portalFailed": "請求ポータルを開けませんでした。",
"cancelFailed": "サブスクリプションをキャンセルできませんでした。",
"downloadPdf": "PDFをダウンロード",
"fetchStatusFailed": "請求ステータスを取得できませんでした",
"fetchQuotasFailed": "クォータを取得できませんでした",
"fetchInvoicesFailed": "請求履歴を読み込めませんでした。",
"savePercent": "17%お得"
}, },
"landing": { "landing": {
"nav": { "nav": {
@@ -3029,5 +3061,13 @@
"hintNavigate": "移動", "hintNavigate": "移動",
"hintOpen": "開く", "hintOpen": "開く",
"hintClose": "閉じる" "hintClose": "閉じる"
},
"quotaPaywall": {
"title": "月制限に達しました",
"description": "今月分の{feature}クレジットを使い切りました。",
"upgrade": "プランをアップグレード",
"useOwnKey": "独自のAPIキーを使用",
"later": "後で",
"closingIn": "{n}秒後に閉じます"
} }
} }

View File

@@ -1718,6 +1718,8 @@
"mcpSettings": { "mcpSettings": {
"title": "MCP", "title": "MCP",
"description": "API 키 관리 및 외부 도구 구성", "description": "API 키 관리 및 외부 도구 구성",
"tierRequired": "Pro+ 전용",
"upgradeHint": "MCP 액세스(Cursor, Claude Desktop 등의 API 키)에는 Pro 플랜 이상이 필요합니다. 결제에서 업그레이드하여 이 기능을 잠금 해제하세요.",
"whatIsMcp": { "whatIsMcp": {
"title": "MCP란 무엇인가요?", "title": "MCP란 무엇인가요?",
"description": "Model Context Protocol(MCP)은 AI 모델이 외부 도구 및 데이터 소스와 안전하게 상호 작용할 수 있게 하는 오픈 프로토콜입니다. MCP를 사용하면 Claude Code, Cursor, N8N 등의 도구를 Memento 인스턴스에 연결하여 프로그래밍 방식으로 노트를 읽고, 만들고, 정리할 수 있습니다.", "description": "Model Context Protocol(MCP)은 AI 모델이 외부 도구 및 데이터 소스와 안전하게 상호 작용할 수 있게 하는 오픈 프로토콜입니다. MCP를 사용하면 Claude Code, Cursor, N8N 등의 도구를 Memento 인스턴스에 연결하여 프로그래밍 방식으로 노트를 읽고, 만들고, 정리할 수 있습니다.",
@@ -1743,7 +1745,12 @@
"lastUsed": "마지막 사용", "lastUsed": "마지막 사용",
"never": "없음", "never": "없음",
"confirmRevoke": "이 키를 취소하시겠습니까? 이 키를 사용하는 도구의 접근이 차단됩니다.", "confirmRevoke": "이 키를 취소하시겠습니까? 이 키를 사용하는 도구의 접근이 차단됩니다.",
"confirmDelete": "이 키를 영구적으로 삭제하시겠습니까?" "confirmDelete": "이 키를 영구적으로 삭제하시겠습니까?",
"errors": {
"generate": "키 생성 실패",
"revoke": "키 취소 실패",
"delete": "키 삭제 실패"
}
}, },
"createDialog": { "createDialog": {
"title": "API 키 생성", "title": "API 키 생성",
@@ -1773,6 +1780,15 @@
"title": "N8N", "title": "N8N",
"description": "N8N MCP 노드에서 다음 자격 증명을 사용하세요:" "description": "N8N MCP 노드에서 다음 자격 증명을 사용하세요:"
} }
},
"helpBox": {
"title": "MCP(Model Context Protocol)란 무엇인가요?",
"step1": "MCP는 Memento의 AI 에이전트가 외부 도구(데이터베이스, API, 파일 등)에 연결할 수 있게 해주는 프로토콜입니다.",
"step2": "Memento는 22개의 도구를 갖춘 MCP 서버를 제공합니다 — 에이전트가 노트를 읽고/만들고, 데이터베이스를 검색하고, 노트북을 관리할 수 있습니다.",
"step3": "여기서 API 키를 생성한 후 MCP 클라이언트(Claude Desktop, Cursor, Continue.dev…)에서 서버 URL과 함께 설정하세요.",
"step4": "설정 형식: MCP 서버 URL + Authorization 헤더에 키.",
"step4Link": "MCP 문서",
"step5": "사용 사례: Claude Desktop에 Memento에 노트 작성, 노트북 검색 또는 에이전트 생성을 요청하세요."
} }
}, },
"agents": { "agents": {
@@ -2500,7 +2516,23 @@
"instantActivation": "즉시 활성화", "instantActivation": "즉시 활성화",
"secureDesc": "Stripe를 통한 안전한 결제", "secureDesc": "Stripe를 통한 안전한 결제",
"secureTransactions": "보안 거래", "secureTransactions": "보안 거래",
"satisfactionGuarantee": "30일 환불 보장" "satisfactionGuarantee": "30일 환불 보장",
"cancelConfirm": "구독을 취소하시겠습니까? 현재 청구 기간이 끝날 때까지 Pro/Business 액세스가 유지됩니다.",
"cancelSuccess": "구독이 성공적으로 취소되었습니다. 현재 청구 기간이 끝나면 종료됩니다.",
"invoices": "청구서 및 영수증",
"invoiceAmount": "금액",
"invoiceDate": "날짜",
"invoiceNumber": "번호",
"invoiceStatus": "상태",
"noInvoices": "사용 가능한 청구서가 없습니다.",
"checkoutFailed": "결제를 시작할 수 없습니다. 다시 시도해 주세요.",
"portalFailed": "결제 포털을 열 수 없습니다.",
"cancelFailed": "구독을 취소할 수 없습니다.",
"downloadPdf": "PDF 다운로드",
"fetchStatusFailed": "결제 상태를 가져올 수 없습니다",
"fetchQuotasFailed": "할당량을 가져올 수 없습니다",
"fetchInvoicesFailed": "결제 내역을 불러올 수 없습니다.",
"savePercent": "~17% 절약"
}, },
"landing": { "landing": {
"nav": { "nav": {
@@ -3029,5 +3061,13 @@
"hintNavigate": "탐색", "hintNavigate": "탐색",
"hintOpen": "열기", "hintOpen": "열기",
"hintClose": "닫기" "hintClose": "닫기"
},
"quotaPaywall": {
"title": "월별 한도에 도달했습니다",
"description": "이번 달 {feature} 크레딧을 모두 사용했습니다.",
"upgrade": "플랜 업그레이드",
"useOwnKey": "자체 API 키 사용",
"later": "나중에",
"closingIn": "{n}초 후 닫기"
} }
} }

View File

@@ -1718,6 +1718,8 @@
"mcpSettings": { "mcpSettings": {
"title": "MCP", "title": "MCP",
"description": "Beheer uw API-sleutels en configureer externe tools", "description": "Beheer uw API-sleutels en configureer externe tools",
"tierRequired": "Alleen Pro+",
"upgradeHint": "MCP-toegang (API-sleutels voor Cursor, Claude Desktop, etc.) vereist een Pro-plan of hoger. Upgrade in Facturatie om deze functie te ontgrendelen.",
"whatIsMcp": { "whatIsMcp": {
"title": "Wat is MCP?", "title": "Wat is MCP?",
"description": "Het Model Context Protocol (MCP) is een open protocol waarmee AI-modellen veilig kunnen communiceren met externe tools en gegevensbronnen. Met MCP kunt u tools zoals Claude Code, Cursor of N8N koppelen aan uw Memento-instantie om uw notities programmatisch te lezen, maken en organiseren.", "description": "Het Model Context Protocol (MCP) is een open protocol waarmee AI-modellen veilig kunnen communiceren met externe tools en gegevensbronnen. Met MCP kunt u tools zoals Claude Code, Cursor of N8N koppelen aan uw Memento-instantie om uw notities programmatisch te lezen, maken en organiseren.",
@@ -1743,7 +1745,12 @@
"lastUsed": "Laatst gebruikt", "lastUsed": "Laatst gebruikt",
"never": "Nooit", "never": "Nooit",
"confirmRevoke": "Weet u zeker dat u deze sleutel wilt intrekken? Tools die deze gebruiken, verliezen toegang.", "confirmRevoke": "Weet u zeker dat u deze sleutel wilt intrekken? Tools die deze gebruiken, verliezen toegang.",
"confirmDelete": "Weet u zeker dat u deze sleutel permanent wilt verwijderen?" "confirmDelete": "Weet u zeker dat u deze sleutel permanent wilt verwijderen?",
"errors": {
"generate": "Sleutel genereren mislukt",
"revoke": "Sleutel intrekken mislukt",
"delete": "Sleutel verwijderen mislukt"
}
}, },
"createDialog": { "createDialog": {
"title": "API-sleutel genereren", "title": "API-sleutel genereren",
@@ -1773,6 +1780,15 @@
"title": "N8N", "title": "N8N",
"description": "Gebruik deze referenties in uw N8N MCP-node:" "description": "Gebruik deze referenties in uw N8N MCP-node:"
} }
},
"helpBox": {
"title": "Wat is MCP (Model Context Protocol)?",
"step1": "MCP is een protocol waarmee de AI-agents van Memento verbinding kunnen maken met externe tools (databases, API's, bestanden, etc.).",
"step2": "Memento biedt een MCP-server met 22 tools — uw agents kunnen notities lezen/maken, zoeken in uw database, notitieboeken beheren, etc.",
"step3": "Maak hier een API-sleutel aan en configureer deze vervolgens in uw MCP-client (Claude Desktop, Cursor, Continue.dev…) met de server-URL.",
"step4": "Configuratieformaat: MCP-server-URL + uw sleutel in de Authorization-header.",
"step4Link": "MCP-documentatie",
"step5": "Toepassing: vraag Claude Desktop om een notitie in Memento te schrijven, uw notitieboeken te doorzoeken of een agent te maken."
} }
}, },
"agents": { "agents": {
@@ -2500,7 +2516,23 @@
"instantActivation": "Directe activatie", "instantActivation": "Directe activatie",
"secureDesc": "Veilige betalingen via Stripe", "secureDesc": "Veilige betalingen via Stripe",
"secureTransactions": "Veilige transacties", "secureTransactions": "Veilige transacties",
"satisfactionGuarantee": "30 dagen tevredenheidsgarantie" "satisfactionGuarantee": "30 dagen tevredenheidsgarantie",
"cancelConfirm": "Weet u zeker dat u uw abonnement wilt opzeggen? U behoudt uw Pro/Business-toegang tot het einde van de huidige factuurperiode.",
"cancelSuccess": "Uw abonnement is succesvol opgezegd. Het eindigt aan het einde van de huidige factuurperiode.",
"invoices": "Facturen en bonnen",
"invoiceAmount": "Bedrag",
"invoiceDate": "Datum",
"invoiceNumber": "Nummer",
"invoiceStatus": "Status",
"noInvoices": "Geen facturen beschikbaar.",
"checkoutFailed": "Kon afrekenen niet starten. Probeer het opnieuw.",
"portalFailed": "Kon facturatieportaal niet openen.",
"cancelFailed": "Kon abonnement niet opzeggen.",
"downloadPdf": "PDF downloaden",
"fetchStatusFailed": "Facturatiestatus kon niet worden opgehaald",
"fetchQuotasFailed": "Quota konden niet worden opgehaald",
"fetchInvoicesFailed": "Factuurgeschiedenis kon niet worden geladen.",
"savePercent": "Bespaar ~17%"
}, },
"landing": { "landing": {
"nav": { "nav": {
@@ -3029,5 +3061,13 @@
"hintNavigate": "navigeren", "hintNavigate": "navigeren",
"hintOpen": "openen", "hintOpen": "openen",
"hintClose": "sluiten" "hintClose": "sluiten"
},
"quotaPaywall": {
"title": "Maandelijkse limiet bereikt",
"description": "U heeft al uw {feature}-tegoeden voor deze maand gebruikt.",
"upgrade": "Abonnement upgraden",
"useOwnKey": "Uw eigen API-sleutel gebruiken",
"later": "Misschien later",
"closingIn": "Sluit over {n}s"
} }
} }

View File

@@ -1718,6 +1718,8 @@
"mcpSettings": { "mcpSettings": {
"title": "MCP", "title": "MCP",
"description": "Zarządzaj kluczami API i konfiguruj narzędzia zewnętrzne", "description": "Zarządzaj kluczami API i konfiguruj narzędzia zewnętrzne",
"tierRequired": "Tylko Pro+",
"upgradeHint": "Dostęp MCP (klucze API dla Cursor, Claude Desktop itp.) wymaga planu Pro lub wyższego. Wykonaj upgrade w Rozliczeniach, aby odblokować tę funkcję.",
"whatIsMcp": { "whatIsMcp": {
"title": "Czym jest MCP?", "title": "Czym jest MCP?",
"description": "Model Context Protocol (MCP) to otwarty protokół umożliwiający modelom AI bezpieczną interakcję z zewnętrznymi narzędziami i źródłami danych. Dzięki MCP możesz połączyć narzędzia takie jak Claude Code, Cursor czy N8N ze swoją instancją Memento, aby programowo czytać, tworzyć i organizować notatki.", "description": "Model Context Protocol (MCP) to otwarty protokół umożliwiający modelom AI bezpieczną interakcję z zewnętrznymi narzędziami i źródłami danych. Dzięki MCP możesz połączyć narzędzia takie jak Claude Code, Cursor czy N8N ze swoją instancją Memento, aby programowo czytać, tworzyć i organizować notatki.",
@@ -1743,7 +1745,12 @@
"lastUsed": "Ostatnio użyty", "lastUsed": "Ostatnio użyty",
"never": "Nigdy", "never": "Nigdy",
"confirmRevoke": "Czy na pewno chcesz unieważnić ten klucz? Narzędzia korzystające z niego stracą dostęp.", "confirmRevoke": "Czy na pewno chcesz unieważnić ten klucz? Narzędzia korzystające z niego stracą dostęp.",
"confirmDelete": "Czy na pewno chcesz trwale usunąć ten klucz?" "confirmDelete": "Czy na pewno chcesz trwale usunąć ten klucz?",
"errors": {
"generate": "Nie udało się wygenerować klucza",
"revoke": "Nie udało się cofnąć klucza",
"delete": "Nie udało się usunąć klucza"
}
}, },
"createDialog": { "createDialog": {
"title": "Wygeneruj klucz API", "title": "Wygeneruj klucz API",
@@ -1773,6 +1780,15 @@
"title": "N8N", "title": "N8N",
"description": "Użyj tych danych logowania w węźle N8N MCP:" "description": "Użyj tych danych logowania w węźle N8N MCP:"
} }
},
"helpBox": {
"title": "Czym jest MCP (Model Context Protocol)?",
"step1": "MCP to protokół, który pozwala agentom AI Memento łączyć się z zewnętrznymi narzędziami (bazami danych, API, plikami itp.).",
"step2": "Memento udostępnia serwer MCP z 22 narzędziami — Twoi agenci mogą odczytywać/tworzyć notatki, przeszukiwać bazę, zarządzać notatnikami itp.",
"step3": "Utwórz klucz API tutaj, a następnie skonfiguruj go w swoim kliencie MCP (Claude Desktop, Cursor, Continue.dev…) z adresem URL serwera.",
"step4": "Format konfiguracji: adres URL serwera MCP + Twój klucz w nagłówku Authorization.",
"step4Link": "Dokumentacja MCP",
"step5": "Przypadek użycia: poproś Claude Desktop o napisanie notatki w Memento, przeszukanie notatników lub utworzenie agenta."
} }
}, },
"agents": { "agents": {
@@ -2500,7 +2516,23 @@
"instantActivation": "Natychmiastowa aktywacja", "instantActivation": "Natychmiastowa aktywacja",
"secureDesc": "Bezpieczne płatności przez Stripe", "secureDesc": "Bezpieczne płatności przez Stripe",
"secureTransactions": "Bezpieczne transakcje", "secureTransactions": "Bezpieczne transakcje",
"satisfactionGuarantee": "30-dniowa gwarancja satysfakcji" "satisfactionGuarantee": "30-dniowa gwarancja satysfakcji",
"cancelConfirm": "Czy na pewno chcesz anulować subskrypcję? Zachowasz dostęp Pro/Business do końca bieżącego okresu rozliczeniowego.",
"cancelSuccess": "Twoja subskrypcja została pomyślnie anulowana. Zakończy się na koniec bieżącego okresu rozliczeniowego.",
"invoices": "Faktury i rachunki",
"invoiceAmount": "Kwota",
"invoiceDate": "Data",
"invoiceNumber": "Numer",
"invoiceStatus": "Status",
"noInvoices": "Brak dostępnych faktur.",
"checkoutFailed": "Nie udało się uruchomić płatności. Spróbuj ponownie.",
"portalFailed": "Nie udało się otworzyć portalu rozliczeń.",
"cancelFailed": "Nie udało się anulować subskrypcji.",
"downloadPdf": "Pobierz PDF",
"fetchStatusFailed": "Nie udało się pobrać statusu rozliczeń",
"fetchQuotasFailed": "Nie udało się pobrać limitów",
"fetchInvoicesFailed": "Nie udało się załadować historii rozliczeń.",
"savePercent": "Oszczędź ~17%"
}, },
"landing": { "landing": {
"nav": { "nav": {
@@ -3029,5 +3061,13 @@
"hintNavigate": "nawiguj", "hintNavigate": "nawiguj",
"hintOpen": "otwórz", "hintOpen": "otwórz",
"hintClose": "zamknij" "hintClose": "zamknij"
},
"quotaPaywall": {
"title": "Osiągnięto miesięczny limit",
"description": "Zużyłeś wszystkie swoje kredyty {feature} w tym miesiącu.",
"upgrade": "Ulepsz plan",
"useOwnKey": "Użyj własnego klucza API",
"later": "Może później",
"closingIn": "Zamykanie za {n}s"
} }
} }

View File

@@ -1718,6 +1718,8 @@
"mcpSettings": { "mcpSettings": {
"title": "MCP", "title": "MCP",
"description": "Gerencie suas chaves API e configure ferramentas externas", "description": "Gerencie suas chaves API e configure ferramentas externas",
"tierRequired": "Apenas Pro+",
"upgradeHint": "O acesso MCP (chaves de API para Cursor, Claude Desktop, etc.) requer um plano Pro ou superior. Faça upgrade em Faturamento para desbloquear este recurso.",
"whatIsMcp": { "whatIsMcp": {
"title": "O que é MCP?", "title": "O que é MCP?",
"description": "O Model Context Protocol (MCP) é um protocolo aberto que permite que modelos de IA interajam de forma segura com ferramentas e fontes de dados externas. Com o MCP, você pode conectar ferramentas como Claude Code, Cursor ou N8N à sua instância do Memento para ler, criar e organizar suas notas programaticamente.", "description": "O Model Context Protocol (MCP) é um protocolo aberto que permite que modelos de IA interajam de forma segura com ferramentas e fontes de dados externas. Com o MCP, você pode conectar ferramentas como Claude Code, Cursor ou N8N à sua instância do Memento para ler, criar e organizar suas notas programaticamente.",
@@ -1743,7 +1745,12 @@
"lastUsed": "Último uso", "lastUsed": "Último uso",
"never": "Nunca", "never": "Nunca",
"confirmRevoke": "Tem certeza de que deseja revogar esta chave? As ferramentas que a usam perderão o acesso.", "confirmRevoke": "Tem certeza de que deseja revogar esta chave? As ferramentas que a usam perderão o acesso.",
"confirmDelete": "Tem certeza de que deseja excluir permanentemente esta chave?" "confirmDelete": "Tem certeza de que deseja excluir permanentemente esta chave?",
"errors": {
"generate": "Falha ao gerar chave",
"revoke": "Falha ao revogar chave",
"delete": "Falha ao excluir chave"
}
}, },
"createDialog": { "createDialog": {
"title": "Gerar chave API", "title": "Gerar chave API",
@@ -1773,6 +1780,15 @@
"title": "N8N", "title": "N8N",
"description": "Use estas credenciais no seu nó MCP do N8N:" "description": "Use estas credenciais no seu nó MCP do N8N:"
} }
},
"helpBox": {
"title": "O que é MCP (Model Context Protocol)?",
"step1": "MCP é um protocolo que permite que os agentes IA do Memento se conectem a ferramentas externas (bancos de dados, APIs, arquivos, etc.).",
"step2": "O Memento expõe um servidor MCP com 22 ferramentas — seus agentes podem ler/criar notas, pesquisar em sua base, gerenciar cadernos, etc.",
"step3": "Crie uma chave de API aqui e configure-a no seu cliente MCP (Claude Desktop, Cursor, Continue.dev…) com a URL do servidor.",
"step4": "Formato de configuração: URL do servidor MCP + sua chave no header Authorization.",
"step4Link": "Documentação MCP",
"step5": "Caso de uso: peça ao Claude Desktop para escrever uma nota no Memento, pesquisar em seus cadernos ou criar um agente."
} }
}, },
"agents": { "agents": {
@@ -2500,7 +2516,23 @@
"instantActivation": "Ativação instantânea", "instantActivation": "Ativação instantânea",
"secureDesc": "Pagamentos seguros pelo Stripe", "secureDesc": "Pagamentos seguros pelo Stripe",
"secureTransactions": "Transações seguras", "secureTransactions": "Transações seguras",
"satisfactionGuarantee": "Garantia de satisfação de 30 dias" "satisfactionGuarantee": "Garantia de satisfação de 30 dias",
"cancelConfirm": "Tem certeza de que deseja cancelar sua assinatura? Você manterá seu acesso Pro/Business até o final do período de cobrança atual.",
"cancelSuccess": "Sua assinatura foi cancelada com sucesso. Ela terminará ao final do período de cobrança atual.",
"invoices": "Faturas e recibos",
"invoiceAmount": "Valor",
"invoiceDate": "Data",
"invoiceNumber": "Número",
"invoiceStatus": "Status",
"noInvoices": "Nenhuma fatura disponível.",
"checkoutFailed": "Falha ao iniciar o checkout. Tente novamente.",
"portalFailed": "Falha ao abrir o portal de cobrança.",
"cancelFailed": "Falha ao cancelar a assinatura.",
"downloadPdf": "Baixar PDF",
"fetchStatusFailed": "Falha ao buscar o status de cobrança",
"fetchQuotasFailed": "Falha ao buscar as cotas",
"fetchInvoicesFailed": "Falha ao carregar o histórico de cobrança.",
"savePercent": "Economize ~17%"
}, },
"landing": { "landing": {
"nav": { "nav": {
@@ -3029,5 +3061,13 @@
"hintNavigate": "navegar", "hintNavigate": "navegar",
"hintOpen": "abrir", "hintOpen": "abrir",
"hintClose": "fechar" "hintClose": "fechar"
},
"quotaPaywall": {
"title": "Limite mensal atingido",
"description": "Você esgotou seus créditos de {feature} para este mês.",
"upgrade": "Melhorar plano",
"useOwnKey": "Usar sua própria chave de API",
"later": "Talvez mais tarde",
"closingIn": "Fechando em {n}s"
} }
} }

View File

@@ -1718,6 +1718,8 @@
"mcpSettings": { "mcpSettings": {
"title": "MCP", "title": "MCP",
"description": "Управление ключами API и настройка внешних инструментов", "description": "Управление ключами API и настройка внешних инструментов",
"tierRequired": "Только Pro+",
"upgradeHint": "Доступ к MCP (ключи API для Cursor, Claude Desktop и т.д.) требует тарифа Pro или выше. Обновите тариф в разделе Биллинг, чтобы разблокировать эту функцию.",
"whatIsMcp": { "whatIsMcp": {
"title": "Что такое MCP?", "title": "Что такое MCP?",
"description": "Model Context Protocol (MCP) — это открытый протокол, позволяющий моделям ИИ безопасно взаимодействовать с внешними инструментами и источниками данных. С помощью MCP вы можете подключить такие инструменты, как Claude Code, Cursor или N8N, к вашему экземпляру Memento для программного чтения, создания и организации заметок.", "description": "Model Context Protocol (MCP) — это открытый протокол, позволяющий моделям ИИ безопасно взаимодействовать с внешними инструментами и источниками данных. С помощью MCP вы можете подключить такие инструменты, как Claude Code, Cursor или N8N, к вашему экземпляру Memento для программного чтения, создания и организации заметок.",
@@ -1743,7 +1745,12 @@
"lastUsed": "Последнее использование", "lastUsed": "Последнее использование",
"never": "Никогда", "never": "Никогда",
"confirmRevoke": "Вы уверены, что хотите отозвать этот ключ? Инструменты, использующие его, потеряют доступ.", "confirmRevoke": "Вы уверены, что хотите отозвать этот ключ? Инструменты, использующие его, потеряют доступ.",
"confirmDelete": "Вы уверены, что хотите навсегда удалить этот ключ?" "confirmDelete": "Вы уверены, что хотите навсегда удалить этот ключ?",
"errors": {
"generate": "Не удалось создать ключ",
"revoke": "Не удалось отозвать ключ",
"delete": "Не удалось удалить ключ"
}
}, },
"createDialog": { "createDialog": {
"title": "Создать ключ API", "title": "Создать ключ API",
@@ -1773,6 +1780,15 @@
"title": "N8N", "title": "N8N",
"description": "Используйте эти учётные данные в узле N8N MCP:" "description": "Используйте эти учётные данные в узле N8N MCP:"
} }
},
"helpBox": {
"title": "Что такое MCP (Model Context Protocol)?",
"step1": "MCP — это протокол, который позволяет ИИ-агентам Memento подключаться к внешним инструментам (базам данных, API, файлам и т.д.).",
"step2": "Memento предоставляет MCP-сервер с 22 инструментами — ваши агенты могут читать/создавать заметки, искать по базе, управлять записными книжками и т.д.",
"step3": "Создайте ключ API здесь, затем настройте его в вашем MCP-клиенте (Claude Desktop, Cursor, Continue.dev…) с URL-адресом сервера.",
"step4": "Формат конфигурации: URL-адрес MCP-сервера + ваш ключ в заголовке Authorization.",
"step4Link": "Документация MCP",
"step5": "Сценарий использования: попросите Claude Desktop написать заметку в Memento, найти записи в блокнотах или создать агента."
} }
}, },
"agents": { "agents": {
@@ -2500,7 +2516,23 @@
"instantActivation": "Мгновенная активация", "instantActivation": "Мгновенная активация",
"secureDesc": "Безопасные платежи через Stripe", "secureDesc": "Безопасные платежи через Stripe",
"secureTransactions": "Безопасные транзакции", "secureTransactions": "Безопасные транзакции",
"satisfactionGuarantee": "Гарантия удовлетворения 30 дней" "satisfactionGuarantee": "Гарантия удовлетворения 30 дней",
"cancelConfirm": "Вы уверены, что хотите отменить подписку? Вы сохраните доступ Pro/Business до конца текущего расчётного периода.",
"cancelSuccess": "Ваша подписка успешно отменена. Она завершится в конце текущего расчётного периода.",
"invoices": "Счета и квитанции",
"invoiceAmount": "Сумма",
"invoiceDate": "Дата",
"invoiceNumber": "Номер",
"invoiceStatus": "Статус",
"noInvoices": "Счета отсутствуют.",
"checkoutFailed": "Не удалось начать оформление покупки. Попробуйте снова.",
"portalFailed": "Не удалось открыть портал биллинга.",
"cancelFailed": "Не удалось отменить подписку.",
"downloadPdf": "Скачать PDF",
"fetchStatusFailed": "Не удалось получить статус биллинга",
"fetchQuotasFailed": "Не удалось получить квоты",
"fetchInvoicesFailed": "Не удалось загрузить историю счетов.",
"savePercent": "Экономия ~17%"
}, },
"landing": { "landing": {
"nav": { "nav": {
@@ -3029,5 +3061,13 @@
"hintNavigate": "навигация", "hintNavigate": "навигация",
"hintOpen": "открыть", "hintOpen": "открыть",
"hintClose": "закрыть" "hintClose": "закрыть"
},
"quotaPaywall": {
"title": "Достигнут месячный лимит",
"description": "Вы исчерпали свои кредиты {feature} на этот месяц.",
"upgrade": "Улучшить тариф",
"useOwnKey": "Использовать свой ключ API",
"later": "Может быть позже",
"closingIn": "Закрытие через {n}с"
} }
} }

View File

@@ -1718,6 +1718,8 @@
"mcpSettings": { "mcpSettings": {
"title": "MCP", "title": "MCP",
"description": "管理 API 密钥并配置外部工具", "description": "管理 API 密钥并配置外部工具",
"tierRequired": "仅限 Pro+",
"upgradeHint": "MCP 访问权限Cursor、Claude Desktop 等 API 密钥)需要 Pro 或更高版本的计划。请在计费页面升级以解锁此功能。",
"whatIsMcp": { "whatIsMcp": {
"title": "什么是 MCP", "title": "什么是 MCP",
"description": "模型上下文协议MCP是一个开放协议使 AI 模型能够与外部工具和数据源安全交互。通过 MCP您可以将 Claude Code、Cursor 或 N8N 等工具连接到您的 Memento 实例,以编程方式读取、创建和整理笔记。", "description": "模型上下文协议MCP是一个开放协议使 AI 模型能够与外部工具和数据源安全交互。通过 MCP您可以将 Claude Code、Cursor 或 N8N 等工具连接到您的 Memento 实例,以编程方式读取、创建和整理笔记。",
@@ -1743,7 +1745,12 @@
"lastUsed": "上次使用", "lastUsed": "上次使用",
"never": "从未使用", "never": "从未使用",
"confirmRevoke": "确定要撤销此密钥吗?使用该密钥的工具将失去访问权限。", "confirmRevoke": "确定要撤销此密钥吗?使用该密钥的工具将失去访问权限。",
"confirmDelete": "确定要永久删除此密钥吗?" "confirmDelete": "确定要永久删除此密钥吗?",
"errors": {
"generate": "生成密钥失败",
"revoke": "撤销密钥失败",
"delete": "删除密钥失败"
}
}, },
"createDialog": { "createDialog": {
"title": "生成 API 密钥", "title": "生成 API 密钥",
@@ -1773,6 +1780,15 @@
"title": "N8N", "title": "N8N",
"description": "在您的 N8N MCP 节点中使用以下凭据:" "description": "在您的 N8N MCP 节点中使用以下凭据:"
} }
},
"helpBox": {
"title": "什么是 MCP模型上下文协议",
"step1": "MCP 是一种协议,允许 Memento 的 AI 代理连接到外部工具数据库、API、文件等。",
"step2": "Memento 提供了一个包含 22 个工具的 MCP 服务器 — 您的代理可以读取/创建笔记、搜索您的库、管理笔记本等。",
"step3": "在此创建 API 密钥,然后在您的 MCP 客户端Claude Desktop、Cursor、Continue.dev…中配置该密钥和服务器 URL。",
"step4": "配置格式MCP 服务器 URL + 您的密钥放在 Authorization 请求头中。",
"step4Link": "MCP 文档",
"step5": "使用示例:让 Claude Desktop 在 Memento 中写一条笔记、搜索您的笔记本或创建一个代理。"
} }
}, },
"agents": { "agents": {
@@ -2500,7 +2516,23 @@
"instantActivation": "即时激活", "instantActivation": "即时激活",
"secureDesc": "通过Stripe安全支付", "secureDesc": "通过Stripe安全支付",
"secureTransactions": "安全交易", "secureTransactions": "安全交易",
"satisfactionGuarantee": "30天满意度保证" "satisfactionGuarantee": "30天满意度保证",
"cancelConfirm": "确定要取消订阅吗?您将在当前计费周期结束前保留 Pro/Business 访问权限。",
"cancelSuccess": "您的订阅已成功取消。将在当前计费周期结束时终止。",
"invoices": "发票和收据",
"invoiceAmount": "金额",
"invoiceDate": "日期",
"invoiceNumber": "编号",
"invoiceStatus": "状态",
"noInvoices": "暂无发票。",
"checkoutFailed": "无法开始结账,请重试。",
"portalFailed": "无法打开计费门户。",
"cancelFailed": "无法取消订阅。",
"downloadPdf": "下载 PDF",
"fetchStatusFailed": "无法获取计费状态",
"fetchQuotasFailed": "无法获取配额",
"fetchInvoicesFailed": "无法加载计费历史记录。",
"savePercent": "节省 ~17%"
}, },
"landing": { "landing": {
"nav": { "nav": {
@@ -3029,5 +3061,13 @@
"hintNavigate": "导航", "hintNavigate": "导航",
"hintOpen": "打开", "hintOpen": "打开",
"hintClose": "关闭" "hintClose": "关闭"
},
"quotaPaywall": {
"title": "已达到月度限制",
"description": "您已用完本月的 {feature} 额度。",
"upgrade": "升级方案",
"useOwnKey": "使用您自己的 API 密钥",
"later": "以后再说",
"closingIn": "{n}秒后关闭"
} }
} }

View File

@@ -0,0 +1,6 @@
INSERT INTO "PlanEntitlement" ("id", "tier", "feature", "limitValue", "createdAt", "updatedAt")
VALUES
('pe-basic-publish_enhance', 'BASIC', 'publish_enhance', 2, NOW(), NOW()),
('pe-pro-publish_enhance', 'PRO', 'publish_enhance', 15, NOW(), NOW())
ON CONFLICT ("tier", "feature") DO UPDATE
SET "limitValue" = EXCLUDED."limitValue", "updatedAt" = NOW();

View File

@@ -0,0 +1,19 @@
import { describe, it, expect } from 'vitest';
import {
ENTITLEMENT_UNAVAILABLE,
FALLBACK_TIER_LIMITS,
} from '@/lib/plan-entitlements';
describe('plan-entitlements unavailable sentinel', () => {
it('ENTITLEMENT_UNAVAILABLE should be -1', () => {
expect(ENTITLEMENT_UNAVAILABLE).toBe(-1);
});
it('fallback limits should not use unavailable sentinel', () => {
for (const tier of Object.keys(FALLBACK_TIER_LIMITS)) {
for (const [, limit] of Object.entries(FALLBACK_TIER_LIMITS[tier as keyof typeof FALLBACK_TIER_LIMITS])) {
expect(limit).not.toBe(ENTITLEMENT_UNAVAILABLE);
}
}
});
});

View File

@@ -35,7 +35,8 @@ services:
- ./grafana-provisioning:/etc/grafana/provisioning:ro - ./grafana-provisioning:/etc/grafana/provisioning:ro
- ./grafana-dashboards:/etc/grafana/dashboards:ro - ./grafana-dashboards:/etc/grafana/dashboards:ro
ports: ports:
- "127.0.0.1:3002:3000" # GRAFANA_BIND via .env.docker (ex. 0.0.0.0 pour LAN) ; défaut localhost seulement
- "${GRAFANA_BIND:-127.0.0.1}:3002:3000"
networks: networks:
- memento-monitoring - memento-monitoring