fix(audit): nettoyage accès, garde-fous build, unification quotas
Semaine 1 — fuites et accès : - /archive rendu accessible via sidebar (était invisible) - Suppression pages orphelines : /chat, /graph, /support, test-title-suggestions - Fixes i18n : search-modal (clé non interpolée), sidebar tooltips, export PDF - 15 locales mises à jour Semaine 2 — garde-fous : - 8 erreurs TS fixees → ignoreBuildErrors: false (build type-safe) - reactStrictMode: true (dev-only, zero impact prod) - reserveUsageOrThrow → wrapper deprecie vers reserveAiUsageOrThrow - auth-guard.ts : requireAuthOrResponse() + requireAdminOrResponse() Tests 212/212 | Lint 0 erreur | Build OK | tsc 0 erreur
This commit is contained in:
@@ -10,8 +10,8 @@
|
||||
- Flux Excalidraw / diagrammes générés : accès via notification en plus d'une simple redirection ; priorité à la mise en page et au texte contenu dans les formes ; proposer des modes visuels (ex. coloré vs plus austère) tout en visant un rendu proche du style Excalidraw (polices, look).
|
||||
- **Interdiction d'écrire des tests** sauf demande explicite ; en CI, seul `npm run test:unit` (`tests/unit/**`) — pas `tests/migration/` ; ne jamais générer de code superflu.
|
||||
- Déploiement : privilégier le chemin rapide (artifact Next.js en CI + `Dockerfile.prebuilt`) ; CI/CD très robuste (pas d'image Docker obsolète en prod, pas de migrations/schéma DB via le workflow deploy) ; éviter les rebuild Docker complets inutiles (~15 min par itération) ; **ne pas pousser un déploiement quand des features clés sont inachevées** ; ne pas insister sur le déploiement tant que le produit n'est pas fini ou meilleur ; **ne jamais hardcoder d'IP/URL** dans le repo ou les défauts CI — uniquement variables d'env (ex. `GRAFANA_BIND`, `GRAFANA_URL`) ; la config runtime reste sur le serveur dans `.env.docker`. **Machines** : agent/dev = **192.168.1.83** (devSanbox) ; prod = **192.168.1.190** (`/opt/memento`) — **ne jamais confondre** les deux au diagnostic services. **CI/CD Gitea spécifique** : (1) CI `ubuntu-24.04` = lint + tests + build (validation) ; deploy `docker-host` = build sur le serveur → `/tmp/web-artifact.tgz` → `deploy-prod.sh` — **sans** upload/download-artifact inter-runners (stockage Gitea instable ; si artifacts utilisés, **@v3** uniquement — @v4 → `GHESNotSupportedError`) ; (2) `Dockerfile.socket.prebuilt` doit utiliser `--legacy-peer-deps` dans `npm install` (conflit TipTap 3.22.5 vs 3.23.6) ; (3) le serveur de prod (192.168.1.190) **ne peut pas pull Docker Hub** (DNS cassé) — le build Docker complet échoue, seul le chemin prebuilt artifact fonctionne ; (4) `docker-entrypoint.sh` applique les migrations Prisma **avant** de démarrer le serveur Next.js (ordre correct) ; (5) rollback d'urgence : `docker tag memento-memento-note:rollback memento-memento-note:latest && docker compose up -d --force-recreate memento-note` ; (6) `scripts/deploy-prod.sh` charge `/opt/memento/.env.docker` via un **parseur robuste ligne-par-ligne** (jamais `source` — crash `unexpected EOF` sur guillemet non fermé) et teste Postgres avec `pg_isready` + credentials **du conteneur** (`$POSTGRES_USER` interne) ; (7) lint CI = `eslint --max-warnings 9999` : les **warnings ne bloquent pas**, seules les **erreurs** bloquent (ex. `no-html-link-for-pages` → `<Link>` obligatoire pour la navigation interne, pas `<a href="/">`) ; **TRAVAILLER SUR UNE BRANCHE** pendant le dev, ne push sur `main` que quand le code est testé et fonctionnel — **avant push `main`** : exécuter localement `npm run lint`, `npm run test:unit`, `npm run build` (même enchaînement que CI) ; chaque push sur `main` déclenche un déploiement automatique en production.
|
||||
- Authentification : priorité à l'inscription/connexion via **Google OAuth** (plutôt qu'un compte email/mot de passe) ; exiger une vraie déconnexion (invalidation session/cookies — pas de reconnexion implicite, y compris en navigation privée) ; prod : `AUTH_GOOGLE_ID`/`AUTH_GOOGLE_SECRET` dans `/opt/memento/.env.docker` **et** variables/secrets Gitea Actions (workflow deploy n'écrit pas les valeurs vides) — absence → `invalid_client` ; redirect URI exacte `https://memento-note.com/api/auth/callback/google` ; config via **Google Auth Platform** (Branding → Audience **In production**, pas Testing → Clients Web) ; `ALLOW_REGISTRATION=true` requis pour inscription email (env ou `SystemConfig` en base — la DB peut primer sur l'env).
|
||||
- Priorité absolue à la qualité UX, même si l'implémentation est complexe (« je m'en fous si c'est complexe ») ; préférer les **solutions long terme** aux rustines ; si l'utilisateur demande explicitement d'**analyser sans coder**, expliquer d'abord et **ne pas modifier le code** tant qu'il n'a pas validé ; 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.
|
||||
- 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) ; **navigateur intégré Cursor peu fiable pour le login** (focus volé, cookies isolés, OAuth Google souvent bloqué) — préférer un vrai navigateur pour la connexion manuelle, et pour l'automation (démos/Playwright) une **session JWT/cookies stockée** plutôt que de rejouer le login dans le browser Cursor.
|
||||
- Priorité absolue à la qualité UX, même si l'implémentation est complexe (« je m'en fous si c'est complexe ») ; préférer les **solutions long terme** aux rustines ; si l'utilisateur demande explicitement d'**analyser sans coder**, expliquer d'abord et **ne pas modifier le code** tant qu'il n'a pas validé ; 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 ; **vidéos promo** : VO **anglaise** ; montrer de **vraies features authentifiées** (dashboard, Memory Echo, Insights, Revision, Agents…) — pas un slideshow landing seul ; **interdit Ken Burns / `zoompan`** (surtout sur fonds pointillés → moiré / tremblement) — images fixes, fond uni stable à la capture.
|
||||
- Livraison : **une user story à la fois**, tester et valider avec l'utilisateur avant la suivante (pas d'auto-validation ni d'enchaînement de code non demandé) ; suivi dans `docs/user-stories.md` ; briefs pour outil de design externe sur demande ; **avant de développer une story, vérifier d'abord dans le code si la feature existe déjà** — les docs de stories (`docs/story-nextgen-editor.md`, `docs/user-stories.md`) sont **souvent périmés** (features livrées mais marquées « à faire ») ; **dashboard Second Brain** : même rythme — **une feature à la fois**, validation explicite avant la suivante.
|
||||
- **Facturation & quotas IA** : limites mensuelles, tiers (BASIC/PRO/BUSINESS/ENTERPRISE) et Price IDs Stripe via **Admin > Facturation & quotas** (`/admin/billing`) — pas via `.env` pour le métier ; secrets Stripe (`STRIPE_SECRET_KEY`, webhook) restent en env serveur ; doc `memento-note/docs/admin-billing-quotas-guide.md` ; **tier BASIC sans accès MCP** ; chaque usage IA (dashboard, agents, MCP si autorisé, etc.) doit **décompter le quota** — mécanisme = **réservation atomique upfront** (Redis/Postgres) **avant** l'appel IA, sans rollback si l'appel échoue ensuite ; BYOK peut contourner selon config — ne pas affirmer un déploiement prod sans vérif réelle (ex. travail MCP/quotas encore local).
|
||||
|
||||
@@ -28,4 +28,4 @@
|
||||
- Migrations prebuilt + vérif deploy : `docker compose exec memento-note node ./node_modules/prisma/build/index.js migrate deploy` (pas `npx prisma`) ; helper `scripts/migrate-docker.sh` ; `GET /api/build-info` (SHA Git) ; comparer `127.0.0.1:3000` et domaine Cloudflare — purger cache si versions divergent ; 403 `/api/manifest` côté domaine = souvent Cloudflare.
|
||||
- Éditeur riche : `rich-text-editor.tsx` — `immediatelyRender: false` ; activer **`shouldRerenderOnTransaction: false`** (quick win perf TipTap 2.5) ; **drag handle / menu bloc** via **`@tiptap/extension-drag-handle-react`** (spec officielle — pas de double plugin `DragHandleExtension` + composant React, pas de repositionnement maison) ; poignée dans **colonne gutter fixe** du wrapper (padding + `getReferencedVirtualElement`), pas sur le bord des listes/numéros ; CSS : **pas `opacity:0` sur `.drag-handle`** (visibilité gérée par le plugin) ; config/callbacks **stables hors composant** ; fondation blocs : `tiptap-unique-id-extension.ts` / **`data-id` persisté à la sauvegarde** (références « Copier la référence ») ; **Smart Paste** : `lib/editor/smart-paste-extension.ts` ; **peek split** note source : `lib/note-peek-sync.ts` + `note-editor-split-peek.tsx` ; **conversion markdown → texte enrichi** : un **seul** convertisseur `lib/markdown-to-html.ts` (`markdownToHtml`, gfm+breaks) + une **action atomique** `convertToRichText(html)` dans `note-editor-context.tsx` (applique le HTML **immédiatement** via `setContentImmediate` — pas le `setContent` débouncé 800ms — et bascule `isMarkdown=false`) ; toolbar (`handleConvertToRichtext`) **et** chat IA contextuel (`contextual-ai-chat.tsx`, action `toRichText` → `/api/ai/convert-markdown`) passent par cette action unique (ne pas dupliquer la conversion ni oublier de basculer `isMarkdown`) ; **US-4 `structuredViewBlock`** (`tiptap-structured-view-block-extension.tsx`, `structured-view-block-embed.tsx`) : **dual-mode** — base locale autonome par défaut (`/database`, `/vue`, `isLocal: true`) + option « Lier à un carnet » (Structured Views) ; i18n `structuredViewBlock.*` ; **rejeté** : ancien `databaseBlock` « Auteurs & Œuvres » et spec embed-only `docs/story-nextgen-editor-us4-redesign.md` ; epic active `docs/story-nextgen-editor.md` — priorité **PERF > NEXTGEN > UX > MOBILE > MARKDOWN**.
|
||||
- Sync mutations notes entre composants : `memento-note/lib/note-change-sync.ts` (`emitNoteChange`, événement `NOTE_CHANGE_EVENT`) ; tracking quotas IA : événement `ai-usage-changed` (window) — doit être dispatché depuis **tous** les points d'appel IA (recherche sémantique, auto-tag, auto-title, chat, reformulation, analyse carnet, etc.) ; UI quotas : `usage-meter.tsx` (polling 5s + écoute événement).
|
||||
- Roadmap / écart prototype vs prod : Web Clipper — **`ClipperSimulator.tsx` = référence design uniquement** (pas de simulateur en prod) ; extension **`memento-note/extension/`** v0.3 **Side Panel** (clip page/sélection/lien ; popup Chrome se ferme au clic page — Side Panel pour la sélection) ; i18n extension **15 langues** (`_locales/`, détection locale navigateur ; script `extension/i18n/generate-translations.cjs`) ; **`host_permissions`** incl. LAN ; **URL serveur configurable en dev**, adresse prod figée en release ; cookies/session alignés avec l'instance cible ; **Flashcards IA SM-2 livrées** : `/revision`, `/api/flashcards/*`, génération depuis l'éditeur (GraduationCap) — réf. prototype `RevisionView.tsx` ; **Structured Views partiellement livrées** : schéma par carnet, Table/Kanban, champs partagés et valeurs par note (`/home` + toolbar carnet) — **suivi de tâches par carnet via Kanban structuré** (pas de vue agrégée Notes/Tâches sur la home ; cases à cocher inline dans les notes) ; **Éditeur Next-Gen livré** (doc `docs/story-nextgen-editor.md` **périmé** — vérifier le code) : US-1 poignée gutter unique (`editor-block-drag-handle.tsx` + `lib/editor/global-drag-handle-extension.ts`, extension Novel `tiptap-extension-global-drag-handle`), US-2 menu action bloc (`block-action-menu.tsx`), US-3 Smart Paste transclusion (`smart-paste-extension.ts` + `smart-paste-menu.tsx`), US-4 database inline (`structuredViewBlock`), plus `data-id` / `liveBlock` / peek split ; **`/insights` livré** : `app/(main)/insights/page.tsx` + `network-graph.tsx` (clusters sémantiques, bridge notes ; **≠ `/graph`**) ; **US-TEMPORAL reporté** ; encore en gap : transclusion bidirectionnelle complète, graphe knowledge enrichi (`GraphKnowledgeMap.tsx`) ; publication **Chrome Web Store** : icônes 16/48/128, privacy policy, `host_permissions` prod restreints vs build dev ; **Wizards** : `NotebookOrganizerDialog` (tags/doublons) branché via bouton "Tags IA" dans `home-client.tsx` — `StructuredViewsWizard` encore **orphelin** (pas de point d'entrée UI) ; **Publication web** (`note-editor-toolbar.tsx`) : deux modes — simple (copie directe) + IA (2-3 templates **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.
|
||||
- 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 ; **vidéos promo** : pipeline `promo-video/` (`make_promo.py`, `make_promo_features.py`, `SCENARIO.md`) — captures app authentifiée (Playwright + session JWT), VO EN (edge-tts), montage stable sans zoom.
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import { Metadata } from 'next'
|
||||
import { auth } from '@/auth'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { ChatContainer } from '@/components/chat/chat-container'
|
||||
import { getConversations } from '@/app/actions/chat-actions'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Chat IA | Memento',
|
||||
description: 'Discutez avec vos notes et vos agents IA',
|
||||
}
|
||||
|
||||
export default async function ChatPage() {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) redirect('/login')
|
||||
|
||||
const userId = session.user.id
|
||||
|
||||
// Fetch initial data
|
||||
const [conversations, notebooks, config] = await Promise.all([
|
||||
getConversations(),
|
||||
prisma.notebook.findMany({
|
||||
where: { userId },
|
||||
orderBy: { order: 'asc' }
|
||||
}),
|
||||
getSystemConfig(),
|
||||
])
|
||||
|
||||
// Check if web search tools are configured
|
||||
const webSearchAvailable = !!(
|
||||
config.WEB_SEARCH_PROVIDER || config.BRAVE_SEARCH_API_KEY || config.SEARXNG_URL || config.JINA_API_KEY
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col h-full bg-white dark:bg-[#1a1c22]">
|
||||
<ChatContainer
|
||||
initialConversations={conversations}
|
||||
notebooks={notebooks}
|
||||
webSearchAvailable={webSearchAvailable}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import dynamic from 'next/dynamic'
|
||||
|
||||
// D3 uses browser APIs — must be loaded client-side only
|
||||
const NoteGraphView = dynamic(
|
||||
() => import('@/components/note-graph-view').then(m => m.NoteGraphView),
|
||||
{ ssr: false }
|
||||
)
|
||||
|
||||
export default function GraphPage() {
|
||||
return (
|
||||
<div className="h-screen overflow-hidden">
|
||||
<NoteGraphView />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
Database,
|
||||
ArrowRight,
|
||||
Menu,
|
||||
Network,
|
||||
List,
|
||||
@@ -28,7 +27,6 @@ import {
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import Link from 'next/link'
|
||||
import { useNotePeek, NotePeekPanel } from '@/components/note-peek'
|
||||
import { createNote } from '@/app/actions/notes'
|
||||
import { emitNoteChange } from '@/lib/note-change-sync'
|
||||
@@ -455,9 +453,6 @@ export default function InsightsPage() {
|
||||
</p>
|
||||
<div className="flex items-center gap-1.5 mt-1.5 text-[10px] text-concrete">
|
||||
<span>{t('insightsView.semanticGraphLegend')}</span>
|
||||
<Link href="/graph" className="inline-flex items-center gap-0.5 text-ochre hover:underline font-medium">
|
||||
{t('insightsView.openGraphMap')} <ArrowRight size={9} />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useLanguage } from '@/lib/i18n';
|
||||
|
||||
export default function SupportPage() {
|
||||
const { t } = useLanguage();
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-10 max-w-4xl">
|
||||
<div className="text-center mb-10">
|
||||
<h1 className="text-2xl sm:text-3xl md:text-4xl font-bold mb-4">
|
||||
{t('support.title')}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-lg">
|
||||
{t('support.description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2 mb-10">
|
||||
<Card className="border-2 border-primary">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<span className="text-2xl">☕</span>
|
||||
{t('support.buyMeACoffee')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="mb-4">
|
||||
{t('support.donationDescription')}
|
||||
</p>
|
||||
<Button asChild className="w-full">
|
||||
<a href="https://ko-fi.com/yourusername" target="_blank" rel="noopener noreferrer">
|
||||
{t('support.donateOnKofi')}
|
||||
</a>
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
{t('support.kofiDescription')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-2 border-primary">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<span className="text-2xl">💚</span>
|
||||
{t('support.sponsorOnGithub')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="mb-4">
|
||||
{t('support.sponsorDescription')}
|
||||
</p>
|
||||
<Button asChild variant="outline" className="w-full">
|
||||
<a href="https://github.com/sponsors/yourusername" target="_blank" rel="noopener noreferrer">
|
||||
{t('support.sponsorOnGithub')}
|
||||
</a>
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
{t('support.githubDescription')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card className="mb-10">
|
||||
<CardHeader>
|
||||
<CardTitle>{t('support.howSupportHelps')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<h3 className="font-semibold mb-2">💰 {t('support.directImpact')}</h3>
|
||||
<ul className="space-y-2 text-sm">
|
||||
<li>☕ Keeps me fueled with coffee</li>
|
||||
<li>🐛 Covers hosting and server costs</li>
|
||||
<li>✨ Funds development of new features</li>
|
||||
<li>📚 Improves documentation</li>
|
||||
<li>🌍 Keeps Memento 100% open-source</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold mb-2">🎁 {t('support.sponsorPerks')}</h3>
|
||||
<ul className="space-y-2 text-sm">
|
||||
<li>🥉 $5/month - Bronze: Name in supporters list</li>
|
||||
<li>🥈 $15/month - Silver: Priority feature requests</li>
|
||||
<li>🥇 $50/month - Gold: Logo in footer, priority support</li>
|
||||
<li>💎 $100/month - Platinum: Custom features, consulting</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>💡 {t('support.transparency')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm mb-4">
|
||||
{t('support.transparencyDescription')}
|
||||
</p>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span>{t('support.hostingServers')}</span>
|
||||
<span className="font-mono">~$20/month</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>{t('support.domainSSL')}</span>
|
||||
<span className="font-mono">~$15/year</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>{t('support.aiApiCosts')}</span>
|
||||
<span className="font-mono">~$30/month</span>
|
||||
</div>
|
||||
<div className="flex justify-between border-t pt-2">
|
||||
<span className="font-semibold">{t('support.totalExpenses')}</span>
|
||||
<span className="font-mono font-semibold">~$50/month</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-4">
|
||||
Any amount beyond these costs goes directly into improving Memento
|
||||
and funding new features. Thank you for your support! 💚
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="mt-10 text-center">
|
||||
<h2 className="text-2xl font-bold mb-4">{t('support.otherWaysTitle')}</h2>
|
||||
<div className="flex flex-wrap justify-center gap-4">
|
||||
<Button variant="outline" asChild>
|
||||
<a href="https://github.com/yourusername/memento" target="_blank" rel="noopener noreferrer">
|
||||
⭐ {t('support.starGithub')}
|
||||
</a>
|
||||
</Button>
|
||||
<Button variant="outline" asChild>
|
||||
<a href="https://github.com/yourusername/memento/issues" target="_blank" rel="noopener noreferrer">
|
||||
🐛 {t('support.reportBug')}
|
||||
</a>
|
||||
</Button>
|
||||
<Button variant="outline" asChild>
|
||||
<a href="https://github.com/yourusername/memento" target="_blank" rel="noopener noreferrer">
|
||||
📝 {t('support.contributeCode')}
|
||||
</a>
|
||||
</Button>
|
||||
<Button variant="outline" asChild>
|
||||
<a href="https://twitter.com/intent/tweet?text=Check%20out%20Memento%20-%20a%20great%20open-source%20note-taking%20app!%20https://github.com/yourusername/memento" target="_blank" rel="noopener noreferrer">
|
||||
🐦 {t('support.shareTwitter')}
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { chatService } from '@/lib/ai/services'
|
||||
import { auth } from '@/auth'
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
/**
|
||||
@@ -38,7 +37,6 @@ export async function sendChatMessage(
|
||||
|
||||
try {
|
||||
const result = await chatService.chat(message, conversationId, notebookId)
|
||||
revalidatePath('/chat')
|
||||
return { success: true, ...result }
|
||||
} catch (error: any) {
|
||||
console.error('[ChatAction] Error:', error)
|
||||
|
||||
@@ -19,6 +19,7 @@ export async function POST(request: NextRequest) {
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
const userId = session.user.id
|
||||
|
||||
// GDPR AI Consent check
|
||||
if (!(await hasUserAiConsent())) {
|
||||
@@ -61,12 +62,12 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
const suggestions = await withAiQuota(
|
||||
session.user.id,
|
||||
userId,
|
||||
'auto_tag',
|
||||
() =>
|
||||
autoLabelCreationService.suggestLabels(
|
||||
notebookId,
|
||||
session.user!.id,
|
||||
userId,
|
||||
language,
|
||||
),
|
||||
{ lane: 'tags' },
|
||||
|
||||
@@ -10,6 +10,7 @@ export async function POST(req: NextRequest) {
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
const userId = session.user.id
|
||||
|
||||
if (!(await hasUserAiConsent())) {
|
||||
return NextResponse.json({ error: 'ai_consent_required' }, { status: 403 })
|
||||
@@ -32,11 +33,11 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
|
||||
const suggestedNotebook = await withAiQuota(
|
||||
session.user.id,
|
||||
userId,
|
||||
'auto_tag',
|
||||
() => notebookSuggestionService.suggestNotebook(
|
||||
noteContent,
|
||||
session.user!.id,
|
||||
userId,
|
||||
language
|
||||
),
|
||||
{ lane: 'tags' },
|
||||
|
||||
@@ -1,195 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { auth } from '@/auth'
|
||||
|
||||
// ── Stopwords FR + EN ─────────────────────────────────────────────────────────
|
||||
const STOPWORDS = new Set([
|
||||
'le','la','les','de','du','des','un','une','et','en','au','aux','ce','se',
|
||||
'sa','son','ses','mon','ma','mes','ton','ta','tes','que','qui','quoi','dont',
|
||||
'il','elle','ils','elles','nous','vous','je','tu','on','par','pour','sur',
|
||||
'sous','avec','dans','est','sont','pas','ne','plus','très','tout','comme',
|
||||
'mais','donc','car','cet','cette','ces','leur','leurs','note','notes',
|
||||
'the','a','an','and','or','but','in','on','at','to','for','of','with','by',
|
||||
'from','is','are','was','were','be','been','have','has','had','do','does',
|
||||
'did','will','would','could','should','may','might','this','that','these',
|
||||
'those','it','its','they','them','their','he','she','we','you','not','no',
|
||||
'so','if','as','up','out','about','also','just','can','all','any','get',
|
||||
])
|
||||
|
||||
function stripHtml(html: string): string {
|
||||
return html.replace(/<[^>]+>/g, ' ').replace(/&\w+;/g, ' ')
|
||||
}
|
||||
|
||||
function extractKeywords(text: string): Set<string> {
|
||||
return new Set(
|
||||
stripHtml(text)
|
||||
.toLowerCase()
|
||||
.split(/[\s\p{P}]+/u)
|
||||
.filter(w => w.length >= 3 && !STOPWORDS.has(w) && !/^\d+$/.test(w))
|
||||
)
|
||||
}
|
||||
|
||||
function jaccardSimilarity(a: Set<string>, b: Set<string>): number {
|
||||
if (a.size === 0 || b.size === 0) return 0
|
||||
let intersection = 0
|
||||
for (const w of a) if (b.has(w)) intersection++
|
||||
return intersection / (a.size + b.size - intersection)
|
||||
}
|
||||
|
||||
type EdgeType = 'title_mention' | 'shared_label' | 'jaccard' | 'explicit_link' | 'semantic_echo'
|
||||
|
||||
interface GraphEdge { source: string; target: string; weight: number; type: EdgeType }
|
||||
|
||||
// GET /api/graph — connexions automatiques à 3 niveaux
|
||||
export async function GET(request: NextRequest) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
|
||||
const userId = session.user.id
|
||||
const { searchParams } = new URL(request.url)
|
||||
const notebookId = searchParams.get('notebookId') || undefined
|
||||
|
||||
const notes = await prisma.note.findMany({
|
||||
where: { userId, trashedAt: null, ...(notebookId ? { notebookId } : {}) },
|
||||
select: {
|
||||
id: true, title: true, content: true, notebookId: true, createdAt: true,
|
||||
labelRelations: { select: { id: true } },
|
||||
notebook: { select: { id: true, name: true } },
|
||||
},
|
||||
take: 500,
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
})
|
||||
|
||||
if (notes.length === 0) return NextResponse.json({ nodes: [], edges: [] })
|
||||
|
||||
const ids = notes.map(n => n.id)
|
||||
|
||||
// Query NoteLink manually created relationships
|
||||
const noteLinks = await (prisma as any).noteLink.findMany({
|
||||
where: {
|
||||
sourceNoteId: { in: ids },
|
||||
targetNoteId: { in: ids }
|
||||
},
|
||||
select: {
|
||||
sourceNoteId: true,
|
||||
targetNoteId: true,
|
||||
contextSnippet: true
|
||||
}
|
||||
})
|
||||
|
||||
// Query MemoryEchoInsight semantic relationships
|
||||
const echoInsights = await (prisma as any).memoryEchoInsight.findMany({
|
||||
where: {
|
||||
userId,
|
||||
dismissed: false,
|
||||
note1Id: { in: ids },
|
||||
note2Id: { in: ids }
|
||||
},
|
||||
select: {
|
||||
note1Id: true,
|
||||
note2Id: true,
|
||||
similarityScore: true,
|
||||
insight: true
|
||||
}
|
||||
})
|
||||
|
||||
// Pré-calcul
|
||||
const keywordsMap = new Map<string, Set<string>>()
|
||||
const labelMap = new Map<string, Set<string>>()
|
||||
for (const note of notes) {
|
||||
keywordsMap.set(note.id, extractKeywords(`${note.title ?? ''} ${note.content}`))
|
||||
labelMap.set(note.id, new Set(note.labelRelations.map((l: any) => l.id)))
|
||||
}
|
||||
|
||||
const EDGE_TYPE_PRIORITY: Record<EdgeType, number> = {
|
||||
explicit_link: 5,
|
||||
semantic_echo: 4,
|
||||
title_mention: 3,
|
||||
shared_label: 2,
|
||||
jaccard: 1,
|
||||
}
|
||||
|
||||
const edgeMap = new Map<string, GraphEdge>()
|
||||
function upsertEdge(a: string, b: string, weight: number, type: EdgeType) {
|
||||
const key = a < b ? `${a}--${b}` : `${b}--${a}`
|
||||
const ex = edgeMap.get(key)
|
||||
if (!ex) {
|
||||
edgeMap.set(key, { source: a < b ? a : b, target: a < b ? b : a, weight, type })
|
||||
} else {
|
||||
const exPriority = EDGE_TYPE_PRIORITY[ex.type] || 0
|
||||
const curPriority = EDGE_TYPE_PRIORITY[type] || 0
|
||||
if (curPriority > exPriority || (curPriority === exPriority && weight > ex.weight)) {
|
||||
edgeMap.set(key, { source: a < b ? a : b, target: a < b ? b : a, weight, type })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Niveau 1 : Title Mention (comme Obsidian "unlinked mentions") ──────────
|
||||
for (const noteA of notes) {
|
||||
const title = (noteA.title ?? '').trim().toLowerCase()
|
||||
if (title.length < 3) continue
|
||||
const escaped = title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
const re = new RegExp(`(?<!\\p{L})${escaped}(?!\\p{L})`, 'ui')
|
||||
for (const noteB of notes) {
|
||||
if (noteA.id === noteB.id) continue
|
||||
if (re.test(stripHtml(noteB.content))) upsertEdge(noteA.id, noteB.id, 1.0, 'title_mention')
|
||||
}
|
||||
}
|
||||
|
||||
// ── Niveau 2 : Labels partagés ────────────────────────────────────────────
|
||||
for (let i = 0; i < ids.length; i++) {
|
||||
for (let j = i + 1; j < ids.length; j++) {
|
||||
const la = labelMap.get(ids[i])!
|
||||
const lb = labelMap.get(ids[j])!
|
||||
const shared = [...la].filter(l => lb.has(l)).length
|
||||
if (shared > 0) upsertEdge(ids[i], ids[j], Math.min(0.5 + shared * 0.15, 0.9), 'shared_label')
|
||||
}
|
||||
}
|
||||
|
||||
// ── Niveau 3 : Jaccard (désactivé > 500 notes) ───────────────────────────
|
||||
if (notes.length <= 500) {
|
||||
for (let i = 0; i < ids.length; i++) {
|
||||
const kwI = keywordsMap.get(ids[i])!
|
||||
const candidates: { j: number; score: number }[] = []
|
||||
for (let j = i + 1; j < ids.length; j++) {
|
||||
const score = jaccardSimilarity(kwI, keywordsMap.get(ids[j])!)
|
||||
if (score >= 0.12) candidates.push({ j, score })
|
||||
}
|
||||
candidates.sort((a, b) => b.score - a.score).slice(0, 10)
|
||||
.forEach(({ j, score }) => upsertEdge(ids[i], ids[j], score * 0.8, 'jaccard'))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Niveau 4 : WikiLinks explicites (NoteLink) ─────────────────────────────
|
||||
for (const link of noteLinks) {
|
||||
upsertEdge(link.sourceNoteId, link.targetNoteId, 1.0, 'explicit_link')
|
||||
}
|
||||
|
||||
// ── Niveau 5 : Échos sémantiques IA (MemoryEchoInsight) ────────────────────
|
||||
for (const echo of echoInsights) {
|
||||
upsertEdge(echo.note1Id, echo.note2Id, echo.similarityScore, 'semantic_echo')
|
||||
}
|
||||
|
||||
const degreeMap = new Map<string, number>()
|
||||
for (const e of edgeMap.values()) {
|
||||
degreeMap.set(e.source, (degreeMap.get(e.source) ?? 0) + 1)
|
||||
degreeMap.set(e.target, (degreeMap.get(e.target) ?? 0) + 1)
|
||||
}
|
||||
|
||||
const nodes = notes.map(n => ({
|
||||
id: n.id,
|
||||
title: n.title || 'Sans titre',
|
||||
notebookId: n.notebookId,
|
||||
createdAt: n.createdAt,
|
||||
degree: degreeMap.get(n.id) ?? 0,
|
||||
}))
|
||||
|
||||
// Build clusters (notebooks)
|
||||
const notebookMap = new Map<string, string>()
|
||||
for (const n of notes) {
|
||||
if (n.notebook) notebookMap.set(n.notebook.id, n.notebook.name)
|
||||
}
|
||||
const clusters = [...notebookMap.entries()].map(([id, name]) => ({ id, name }))
|
||||
|
||||
return NextResponse.json({ nodes, edges: Array.from(edgeMap.values()), clusters })
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { auth } from '@/auth'
|
||||
|
||||
const WIKILINK_RE = /\[\[([^\]|#]+?)(?:[|#][^\]]+)?\]\]/g
|
||||
|
||||
function extractWikilinks(content: string): { title: string; snippet: string }[] {
|
||||
const plain = content.replace(/<[^>]+>/g, ' ')
|
||||
const results: { title: string; snippet: string }[] = []
|
||||
const seen = new Set<string>()
|
||||
let match: RegExpExecArray | null
|
||||
|
||||
WIKILINK_RE.lastIndex = 0
|
||||
while ((match = WIKILINK_RE.exec(plain)) !== null) {
|
||||
const title = match[1].trim()
|
||||
if (!title || seen.has(title.toLowerCase())) continue
|
||||
seen.add(title.toLowerCase())
|
||||
const start = Math.max(0, match.index - 50)
|
||||
const end = Math.min(plain.length, match.index + match[0].length + 50)
|
||||
const snippet = plain.slice(start, end).replace(/\s+/g, ' ').trim()
|
||||
results.push({ title, snippet })
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/graph/sync-all
|
||||
* Batch-sync [[wikilinks]] for ALL notes of the authenticated user.
|
||||
* Call once to populate the NoteLink table from existing notes.
|
||||
*/
|
||||
export async function POST() {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
const userId = session.user.id
|
||||
|
||||
// Get all non-trashed notes with content
|
||||
const notes = await prisma.note.findMany({
|
||||
where: { userId, trashedAt: null, content: { not: '' } },
|
||||
select: { id: true, content: true, notebookId: true },
|
||||
})
|
||||
|
||||
let totalLinks = 0
|
||||
|
||||
for (const note of notes) {
|
||||
if (!note.content.includes('[[')) continue
|
||||
|
||||
const wikilinks = extractWikilinks(note.content)
|
||||
if (wikilinks.length === 0) continue
|
||||
|
||||
for (const { title, snippet } of wikilinks) {
|
||||
const targetNote = await prisma.note.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
title: { equals: title, mode: 'insensitive' },
|
||||
trashedAt: null,
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
if (!targetNote) {
|
||||
// Skip stubs in batch sync — we only link existing notes
|
||||
continue
|
||||
}
|
||||
|
||||
if (targetNote.id === note.id) continue
|
||||
|
||||
try {
|
||||
await (prisma as any).noteLink.upsert({
|
||||
where: { sourceNoteId_targetNoteId: { sourceNoteId: note.id, targetNoteId: targetNote.id } },
|
||||
update: { contextSnippet: snippet },
|
||||
create: { sourceNoteId: note.id, targetNoteId: targetNote.id, contextSnippet: snippet },
|
||||
})
|
||||
totalLinks++
|
||||
} catch {
|
||||
// ignore duplicate constraint errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ synced: notes.length, links: totalLinks })
|
||||
}
|
||||
@@ -101,7 +101,7 @@ export default async function RootLayout({
|
||||
const htmlStyle = {
|
||||
'--color-brand-accent': serverAccent,
|
||||
// Aide le navigateur à peindre scrollbars/inputs dans le bon mode
|
||||
colorScheme: wantsDarkClass || resolvedTheme === 'dark' || resolvedTheme === 'midnight' ? 'dark' : resolvedTheme === 'light' ? 'light' : undefined,
|
||||
colorScheme: wantsDarkClass ? 'dark' : resolvedTheme === 'light' ? 'light' : undefined,
|
||||
} as CSSProperties
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useTitleSuggestions } from '@/hooks/use-title-suggestions'
|
||||
|
||||
export default function TestTitleSuggestionsPage() {
|
||||
const [content, setContent] = useState('')
|
||||
|
||||
const { suggestions, isAnalyzing, error } = useTitleSuggestions({
|
||||
content,
|
||||
enabled: true // Always enabled for testing
|
||||
})
|
||||
|
||||
const wordCount = content.split(/\s+/).filter(w => w.length > 0).length
|
||||
|
||||
return (
|
||||
<div style={{ padding: '20px', maxWidth: '800px', margin: '0 auto' }}>
|
||||
<h1>Test Title Suggestions</h1>
|
||||
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<label>Content (need 50+ words):</label>
|
||||
<textarea
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
style={{ width: '100%', height: '200px', marginTop: '10px' }}
|
||||
placeholder="Type at least 50 words here..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '20px', padding: '10px', background: '#f0f0f0' }}>
|
||||
<p><strong>Word count:</strong> {wordCount} / 50</p>
|
||||
<p><strong>Status:</strong> {isAnalyzing ? 'Analyzing...' : 'Idle'}</p>
|
||||
{error && <p style={{ color: 'red' }}><strong>Error:</strong> {error}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2>Suggestions ({suggestions.length}):</h2>
|
||||
{suggestions.length > 0 ? (
|
||||
<ul>
|
||||
{suggestions.map((s, i) => (
|
||||
<li key={i}>
|
||||
<strong>{s.title}</strong> (confidence: {s.confidence}%)
|
||||
{s.reasoning && <p>→ {s.reasoning}</p>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p style={{ color: '#666' }}>No suggestions yet. Type 50+ words and wait 2 seconds.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: '20px' }}>
|
||||
<button onClick={() => {
|
||||
setContent('word '.repeat(50))
|
||||
}}>
|
||||
Fill with 50 words (test)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -23,15 +23,12 @@ export const authConfig = {
|
||||
nextUrl.pathname.startsWith('/settings') ||
|
||||
nextUrl.pathname.startsWith('/lab') ||
|
||||
nextUrl.pathname.startsWith('/agents') ||
|
||||
nextUrl.pathname.startsWith('/chat') ||
|
||||
nextUrl.pathname.startsWith('/canvas') ||
|
||||
nextUrl.pathname.startsWith('/notebooks') ||
|
||||
nextUrl.pathname.startsWith('/note/') ||
|
||||
nextUrl.pathname.startsWith('/brainstorm') ||
|
||||
nextUrl.pathname.startsWith('/insights') ||
|
||||
nextUrl.pathname.startsWith('/graph') ||
|
||||
nextUrl.pathname.startsWith('/revision') ||
|
||||
nextUrl.pathname.startsWith('/support');
|
||||
nextUrl.pathname.startsWith('/revision');
|
||||
const isAdminPage = nextUrl.pathname.startsWith('/admin');
|
||||
const isPublicPage = nextUrl.pathname === '/' ||
|
||||
nextUrl.pathname === '/login' ||
|
||||
|
||||
@@ -1,222 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { useChat } from '@ai-sdk/react'
|
||||
import { DefaultChatTransport } from 'ai'
|
||||
import { ChatSidebar } from './chat-sidebar'
|
||||
import { ChatMessages } from './chat-messages'
|
||||
import { ChatInput } from './chat-input'
|
||||
import { createConversation, getConversationDetails, getConversations, deleteConversation } from '@/app/actions/chat-actions'
|
||||
import { toast } from 'sonner'
|
||||
import type { UIMessage } from 'ai'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
interface ChatContainerProps {
|
||||
initialConversations: any[]
|
||||
notebooks: any[]
|
||||
webSearchAvailable?: boolean
|
||||
}
|
||||
|
||||
export function ChatContainer({ initialConversations, notebooks, webSearchAvailable }: ChatContainerProps) {
|
||||
const { t, language } = useLanguage()
|
||||
const [conversations, setConversations] = useState(initialConversations)
|
||||
const [currentId, setCurrentId] = useState<string | null>(null)
|
||||
const [selectedNotebook, setSelectedNotebook] = useState<string | undefined>(undefined)
|
||||
const [webSearchEnabled, setWebSearchEnabled] = useState(false)
|
||||
const [historyMessages, setHistoryMessages] = useState<UIMessage[]>([])
|
||||
const [isLoadingHistory, setIsLoadingHistory] = useState(false)
|
||||
|
||||
// Prevents the useEffect from loading an empty conversation
|
||||
// when we just created one via createConversation()
|
||||
const skipHistoryLoad = useRef(false)
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const transport = useRef(new DefaultChatTransport({
|
||||
api: '/api/chat',
|
||||
})).current
|
||||
|
||||
const {
|
||||
messages,
|
||||
sendMessage,
|
||||
status,
|
||||
setMessages,
|
||||
stop,
|
||||
} = useChat({
|
||||
transport,
|
||||
onError: (error) => {
|
||||
toast.error(error.message || t('chat.assistantError'))
|
||||
},
|
||||
})
|
||||
|
||||
const isLoading = status === 'submitted' || status === 'streaming'
|
||||
|
||||
const refreshConversations = useCallback(async () => {
|
||||
try {
|
||||
const updated = await getConversations()
|
||||
setConversations(updated)
|
||||
} catch {}
|
||||
}, [])
|
||||
|
||||
// Timeout warning: show toast if response takes > 30s
|
||||
useEffect(() => {
|
||||
if (!isLoading) return
|
||||
const timer = setTimeout(() => {
|
||||
toast.warning(t('chat.timeoutWarning') || 'Response is taking longer than expected...')
|
||||
}, 30000)
|
||||
return () => clearTimeout(timer)
|
||||
}, [isLoading, t])
|
||||
|
||||
// Sync historyMessages after each completed streaming response
|
||||
// so the display doesn't revert to stale history.
|
||||
// Also refresh sidebar so new conversation appears.
|
||||
useEffect(() => {
|
||||
if (status === 'ready' && messages.length > 0) {
|
||||
setHistoryMessages([...messages])
|
||||
refreshConversations()
|
||||
}
|
||||
}, [status, messages, refreshConversations])
|
||||
|
||||
// Load conversation details when the user selects a different conversation
|
||||
useEffect(() => {
|
||||
// Skip if we just created the conversation — useChat already has the messages
|
||||
if (skipHistoryLoad.current) {
|
||||
skipHistoryLoad.current = false
|
||||
return
|
||||
}
|
||||
|
||||
if (currentId) {
|
||||
const loadMessages = async () => {
|
||||
setIsLoadingHistory(true)
|
||||
try {
|
||||
const details = await getConversationDetails(currentId)
|
||||
if (details) {
|
||||
const loaded: UIMessage[] = details.messages.map((m: any, i: number) => ({
|
||||
id: m.id || `hist-${i}`,
|
||||
role: m.role as 'user' | 'assistant',
|
||||
parts: [{ type: 'text' as const, text: m.content }],
|
||||
}))
|
||||
setHistoryMessages(loaded)
|
||||
setMessages(loaded)
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(t('chat.loadError'))
|
||||
} finally {
|
||||
setIsLoadingHistory(false)
|
||||
}
|
||||
}
|
||||
loadMessages()
|
||||
} else {
|
||||
setMessages([])
|
||||
setHistoryMessages([])
|
||||
}
|
||||
|
||||
}, [currentId])
|
||||
|
||||
const handleSendMessage = async (content: string, notebookId?: string) => {
|
||||
if (notebookId) {
|
||||
setSelectedNotebook(notebookId)
|
||||
}
|
||||
|
||||
// If no active conversation, create one BEFORE streaming
|
||||
let convId = currentId
|
||||
if (!convId) {
|
||||
try {
|
||||
const result = await createConversation(content, notebookId || selectedNotebook)
|
||||
convId = result.id
|
||||
// Tell the useEffect to skip — we don't want to load an empty conversation
|
||||
skipHistoryLoad.current = true
|
||||
setCurrentId(convId)
|
||||
setHistoryMessages([])
|
||||
setConversations((prev) => [
|
||||
{ id: result.id, title: result.title, updatedAt: new Date() },
|
||||
...prev,
|
||||
])
|
||||
} catch {
|
||||
toast.error(t('chat.createError'))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await sendMessage(
|
||||
{ text: content },
|
||||
{
|
||||
body: {
|
||||
conversationId: convId,
|
||||
notebookId: notebookId || selectedNotebook || undefined,
|
||||
language,
|
||||
webSearch: webSearchEnabled,
|
||||
},
|
||||
}
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('Chat send error:', error)
|
||||
toast.error(t('chat.assistantError') || 'Failed to send message')
|
||||
}
|
||||
}
|
||||
|
||||
const handleNewChat = () => {
|
||||
setCurrentId(null)
|
||||
setMessages([])
|
||||
setHistoryMessages([])
|
||||
setSelectedNotebook(undefined)
|
||||
setWebSearchEnabled(false)
|
||||
}
|
||||
|
||||
const handleDeleteConversation = async (id: string) => {
|
||||
try {
|
||||
await deleteConversation(id)
|
||||
if (currentId === id) {
|
||||
handleNewChat()
|
||||
}
|
||||
await refreshConversations()
|
||||
} catch {
|
||||
toast.error(t('chat.deleteError'))
|
||||
}
|
||||
}
|
||||
|
||||
// During streaming or if useChat has more messages than history, prefer useChat
|
||||
const displayMessages = isLoading || messages.length > historyMessages.length
|
||||
? messages
|
||||
: historyMessages
|
||||
|
||||
// Auto-scroll to bottom when messages change
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
|
||||
}
|
||||
}, [displayMessages])
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex overflow-hidden bg-background">
|
||||
<ChatSidebar
|
||||
conversations={conversations}
|
||||
currentId={currentId}
|
||||
onSelect={setCurrentId}
|
||||
onNew={handleNewChat}
|
||||
onDelete={handleDeleteConversation}
|
||||
/>
|
||||
|
||||
<div className="flex-1 flex flex-col h-full overflow-hidden">
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto scrollbar-hide pb-6 w-full flex justify-center">
|
||||
<ChatMessages messages={displayMessages} isLoading={isLoading || isLoadingHistory} />
|
||||
</div>
|
||||
|
||||
<div className="w-full flex justify-center sticky bottom-0 bg-gradient-to-t from-background via-background/90 to-transparent pt-6 pb-4">
|
||||
<div className="w-full max-w-4xl px-4">
|
||||
<ChatInput
|
||||
onSend={handleSendMessage}
|
||||
isLoading={isLoading}
|
||||
onStop={stop}
|
||||
notebooks={notebooks}
|
||||
currentNotebookId={selectedNotebook || null}
|
||||
webSearchEnabled={webSearchEnabled}
|
||||
onToggleWebSearch={() => setWebSearchEnabled(prev => !prev)}
|
||||
webSearchAvailable={webSearchAvailable}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { Send, BookOpen, X, Globe, Square } from 'lucide-react'
|
||||
import { getNotebookIcon } from '@/lib/notebook-icon'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
interface ChatInputProps {
|
||||
onSend: (message: string, notebookId?: string) => void
|
||||
isLoading?: boolean
|
||||
onStop?: () => void
|
||||
notebooks: any[]
|
||||
currentNotebookId?: string | null
|
||||
webSearchEnabled?: boolean
|
||||
onToggleWebSearch?: () => void
|
||||
webSearchAvailable?: boolean
|
||||
}
|
||||
|
||||
export function ChatInput({ onSend, isLoading, onStop, notebooks, currentNotebookId, webSearchEnabled, onToggleWebSearch, webSearchAvailable }: ChatInputProps) {
|
||||
const { t } = useLanguage()
|
||||
const [input, setInput] = useState('')
|
||||
const [selectedNotebook, setSelectedNotebook] = useState<string | undefined>(currentNotebookId || undefined)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (currentNotebookId) {
|
||||
setSelectedNotebook(currentNotebookId)
|
||||
}
|
||||
}, [currentNotebookId])
|
||||
|
||||
const handleSend = () => {
|
||||
if (!input.trim() || isLoading) return
|
||||
onSend(input, selectedNotebook)
|
||||
setInput('')
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto'
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSend()
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto'
|
||||
textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`
|
||||
}
|
||||
}, [input])
|
||||
|
||||
return (
|
||||
<div className="w-full relative">
|
||||
<div className="relative flex flex-col bg-muted/50 rounded-[24px] border border-border/60 shadow-sm focus-within:shadow-md focus-within:border-border transition-all duration-300 overflow-hidden">
|
||||
|
||||
{/* Input Area */}
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
placeholder={t('chat.placeholder')}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="flex-1 min-h-[56px] max-h-[40vh] bg-transparent border-none focus-visible:ring-0 resize-none py-4 px-5 text-[15px] placeholder:text-slate-400"
|
||||
/>
|
||||
|
||||
{/* Bottom Actions Bar */}
|
||||
<div className="flex items-center justify-between px-3 pb-3 pt-1">
|
||||
{/* Context Selector */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={selectedNotebook || 'global'}
|
||||
onValueChange={(val) => setSelectedNotebook(val === 'global' ? undefined : val)}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-auto min-w-[130px] rounded-full bg-background border-border/60 shadow-sm text-xs font-medium gap-2 ring-offset-transparent focus:ring-0 focus:ring-offset-0 hover:bg-muted/50 transition-colors">
|
||||
<BookOpen className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<SelectValue placeholder={t('chat.allNotebooks')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="rounded-xl shadow-lg border-slate-200 dark:border-white/10">
|
||||
<SelectItem value="global" className="rounded-lg text-sm text-muted-foreground">{t('chat.inAllNotebooks')}</SelectItem>
|
||||
{notebooks.map((nb) => (
|
||||
<SelectItem key={nb.id} value={nb.id} className="rounded-lg text-sm">
|
||||
{(() => {
|
||||
const Icon = getNotebookIcon(nb.icon)
|
||||
return <Icon className="w-3.5 h-3.5" />
|
||||
})()} {nb.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{selectedNotebook && (
|
||||
<Badge variant="secondary" className="text-[10px] bg-primary/10 text-primary border-none rounded-full px-2.5 h-6 font-semibold tracking-wide">
|
||||
{t('chat.active')}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{webSearchAvailable && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleWebSearch}
|
||||
className={cn(
|
||||
"h-8 rounded-full border shadow-sm text-xs font-medium gap-1.5 flex items-center px-3 transition-all duration-200",
|
||||
webSearchEnabled
|
||||
? "bg-primary/10 text-primary border-primary/30 hover:bg-primary/20"
|
||||
: "bg-background border-border/60 text-muted-foreground hover:bg-muted/50"
|
||||
)}
|
||||
>
|
||||
<Globe className="h-3.5 w-3.5" />
|
||||
{webSearchEnabled && t('chat.webSearch')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Send / Stop Button */}
|
||||
{isLoading && onStop ? (
|
||||
<Button
|
||||
onClick={onStop}
|
||||
size="icon"
|
||||
className="rounded-full h-8 w-8 bg-red-500 text-white shadow-sm hover:bg-red-600 transition-all duration-200"
|
||||
>
|
||||
<Square className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
disabled={!input.trim() || isLoading}
|
||||
onClick={handleSend}
|
||||
size="icon"
|
||||
className={cn(
|
||||
"rounded-full h-8 w-8 transition-all duration-200",
|
||||
input.trim() ? "bg-primary text-primary-foreground shadow-sm hover:scale-105" : "bg-slate-200 dark:bg-slate-700 text-slate-400 dark:text-slate-500"
|
||||
)}
|
||||
>
|
||||
<Send className="h-4 w-4 ml-0.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center mt-3">
|
||||
<span className="text-[11px] text-muted-foreground/60 w-full block">
|
||||
{t('chat.disclaimer')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { User, Bot, Loader2 } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
interface ChatMessagesProps {
|
||||
messages: any[]
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
function getMessageContent(msg: any): string {
|
||||
if (typeof msg.content === 'string') return msg.content
|
||||
if (msg.parts && Array.isArray(msg.parts)) {
|
||||
return msg.parts
|
||||
.filter((p: any) => p.type === 'text')
|
||||
.map((p: any) => p.text)
|
||||
.join('')
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
export function ChatMessages({ messages, isLoading }: ChatMessagesProps) {
|
||||
const { t } = useLanguage()
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-4xl flex flex-col pt-8 pb-4">
|
||||
{messages.length === 0 && !isLoading && (
|
||||
<div className="flex flex-col items-center justify-center h-[60vh] text-center space-y-6">
|
||||
<div className="p-5 bg-gradient-to-br from-primary/10 to-primary/5 rounded-full shadow-inner ring-1 ring-primary/10">
|
||||
<Bot className="h-12 w-12 text-primary opacity-60" />
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm md:text-base max-w-md px-4 font-medium">
|
||||
{t('chat.welcome')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.map((msg, index) => {
|
||||
const content = getMessageContent(msg)
|
||||
const isLastAssistant = msg.role === 'assistant' && index === messages.length - 1 && isLoading
|
||||
|
||||
return (
|
||||
<div
|
||||
key={msg.id || index}
|
||||
className={cn(
|
||||
"flex w-full px-4 md:px-0 py-6 my-2 group",
|
||||
msg.role === 'user' ? "justify-end" : "justify-start border-y border-transparent dark:border-transparent"
|
||||
)}
|
||||
>
|
||||
{msg.role === 'user' ? (
|
||||
<div dir="auto" className="max-w-[85%] md:max-w-[70%] bg-muted text-foreground rounded-3xl rounded-br-md px-6 py-4 shadow-sm border border-border/50">
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none text-[15px] leading-relaxed">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-4 md:gap-6 w-full max-w-3xl">
|
||||
<Avatar className="h-8 w-8 shrink-0 bg-transparent border border-primary/20 text-primary mt-1 shadow-sm">
|
||||
<AvatarFallback className="bg-transparent"><Bot className="h-4 w-4" /></AvatarFallback>
|
||||
</Avatar>
|
||||
<div dir="auto" className="flex-1 overflow-hidden pt-1">
|
||||
{content ? (
|
||||
<div className="prose prose-slate dark:prose-invert max-w-none prose-p:leading-relaxed prose-pre:bg-slate-900 prose-pre:shadow-sm prose-pre:border prose-pre:border-slate-800 prose-headings:font-semibold marker:text-primary/50 text-[15px] prose-table:border prose-table:border-slate-300 prose-th:border prose-th:border-slate-300 prose-th:px-3 prose-th:py-2 prose-th:bg-slate-100 dark:prose-th:bg-slate-800 prose-td:border prose-td:border-slate-300 prose-td:px-3 prose-td:py-2">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
|
||||
</div>
|
||||
) : isLastAssistant ? (
|
||||
<div className="flex items-center gap-3 text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-primary" />
|
||||
<span className="text-[15px] animate-pulse">{t('chat.searching')}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import { fr } from 'date-fns/locale/fr'
|
||||
import { enUS } from 'date-fns/locale/en-US'
|
||||
import { MessageSquare, Trash2, Plus, X } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
interface ChatSidebarProps {
|
||||
conversations: any[]
|
||||
currentId?: string | null
|
||||
onSelect: (id: string) => void
|
||||
onNew: () => void
|
||||
onDelete?: (id: string) => void
|
||||
}
|
||||
|
||||
export function ChatSidebar({
|
||||
conversations,
|
||||
currentId,
|
||||
onSelect,
|
||||
onNew,
|
||||
onDelete,
|
||||
}: ChatSidebarProps) {
|
||||
const { t, language } = useLanguage()
|
||||
const dateLocale = language === 'fr' ? fr : enUS
|
||||
const [pendingDelete, setPendingDelete] = useState<string | null>(null)
|
||||
|
||||
const confirmDelete = (id: string) => {
|
||||
setPendingDelete(id)
|
||||
}
|
||||
|
||||
const cancelDelete = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
setPendingDelete(null)
|
||||
}
|
||||
|
||||
const executeDelete = async (e: React.MouseEvent, id: string) => {
|
||||
e.stopPropagation()
|
||||
setPendingDelete(null)
|
||||
if (onDelete) {
|
||||
await onDelete(id)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-64 border-r flex flex-col h-full bg-sidebar">
|
||||
<div className="p-4 border-bottom">
|
||||
<Button
|
||||
onClick={onNew}
|
||||
className="w-full justify-start gap-2 shadow-sm"
|
||||
variant="outline"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t('chat.newConversation')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-2 space-y-1">
|
||||
{conversations.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground text-sm">
|
||||
{t('chat.noHistory')}
|
||||
</div>
|
||||
) : (
|
||||
conversations.map((chat) => (
|
||||
<div
|
||||
key={chat.id}
|
||||
onClick={() => onSelect(chat.id)}
|
||||
className={cn(
|
||||
"relative cursor-pointer rounded-lg transition-all group",
|
||||
currentId === chat.id
|
||||
? "bg-primary/10 text-primary dark:bg-primary/20"
|
||||
: "hover:bg-muted/50 text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<div className="p-3 flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageSquare className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate text-sm font-medium pr-6">
|
||||
{chat.title || t('chat.untitled')}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[10px] opacity-60 ml-6" suppressHydrationWarning>
|
||||
{formatDistanceToNow(new Date(chat.updatedAt), { addSuffix: true, locale: dateLocale })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Delete button — visible on hover or when confirming */}
|
||||
{pendingDelete !== chat.id && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); confirmDelete(chat.id) }}
|
||||
className="absolute top-3 right-2 opacity-0 group-hover:opacity-100 p-1 hover:text-destructive transition-all"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Inline confirmation banner */}
|
||||
{pendingDelete === chat.id && (
|
||||
<div
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-destructive/10 text-destructive text-xs border-t border-destructive/20 rounded-b-lg"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<span className="flex-1 font-medium">{t('chat.deleteConfirm')}</span>
|
||||
<button
|
||||
onClick={(e) => executeDelete(e, chat.id)}
|
||||
className="px-2 py-0.5 bg-destructive text-white rounded text-[10px] font-semibold hover:bg-destructive/90 transition-colors"
|
||||
>
|
||||
{t('chat.yes')}
|
||||
</button>
|
||||
<button
|
||||
onClick={cancelDelete}
|
||||
className="p-0.5 hover:text-foreground transition-colors"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -165,11 +165,11 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
|
||||
|
||||
// Remove all action buttons, toolbars, and UI elements
|
||||
clone.querySelectorAll('button, .drag-handle, [contenteditable="false"], .opacity-0, .group-hover\\:opacity-100').forEach(el => el.remove())
|
||||
// Remove remaining UI controls (block action icons with tooltips) regardless of
|
||||
// UI language — keep user content that may carry a title (links, abbreviations).
|
||||
clone.querySelectorAll('[title]').forEach(el => {
|
||||
const title = el.getAttribute('title')
|
||||
if (title && (title.includes('Supprimer') || title.includes('Delete') || title.includes('Désactiver') || title.includes('Disable') || title.includes('Modifier') || title.includes('Edit'))) {
|
||||
el.remove()
|
||||
}
|
||||
const tag = el.tagName.toLowerCase()
|
||||
if (tag !== 'a' && tag !== 'abbr') el.remove()
|
||||
})
|
||||
|
||||
// Render KaTeX equations properly
|
||||
|
||||
@@ -1,789 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState, useMemo, useCallback, useRef } from 'react'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useNotebooks } from '@/context/notebooks-context'
|
||||
import { openNotePath } from '@/lib/navigation/open-note'
|
||||
import {
|
||||
Loader2,
|
||||
Network,
|
||||
Filter,
|
||||
X,
|
||||
ExternalLink,
|
||||
Maximize2,
|
||||
Calendar,
|
||||
Clock,
|
||||
Link2,
|
||||
FileText,
|
||||
Check,
|
||||
Tag,
|
||||
Sparkles,
|
||||
ChevronRight,
|
||||
BookOpen
|
||||
} from 'lucide-react'
|
||||
import DOMPurify from 'isomorphic-dompurify'
|
||||
import { markdownToHtml } from '@/lib/markdown-to-html'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { LabelBadge } from './label-badge'
|
||||
import { NoteChecklist } from './note-checklist'
|
||||
|
||||
const ForceGraph2D = dynamic(() => import('react-force-graph-2d'), { ssr: false }) as any
|
||||
const MarkdownContent = dynamic(() => import('./markdown-content').then(m => ({ default: m.MarkdownContent })), {
|
||||
ssr: false,
|
||||
loading: () => <div className="h-20 w-full animate-pulse bg-concrete/5 rounded" />
|
||||
})
|
||||
|
||||
interface GraphNode { id: string; title: string; notebookId: string | null; createdAt: string; degree: number }
|
||||
interface GraphEdge { source: string; target: string; weight: number; type: string }
|
||||
interface Cluster { id: string; name: string }
|
||||
interface RawData { nodes: GraphNode[]; edges: GraphEdge[]; clusters: Cluster[] }
|
||||
interface NotePreview {
|
||||
id: string
|
||||
title: string | null
|
||||
content: string
|
||||
createdAt: string | Date
|
||||
updatedAt?: string | Date
|
||||
labels?: string[] | null
|
||||
type?: 'text' | 'markdown' | 'richtext' | 'checklist'
|
||||
checkItems?: { id: string; text: string; checked: boolean }[] | null
|
||||
isMarkdown?: boolean
|
||||
}
|
||||
|
||||
const PALETTE = ['#6366f1', '#10b981', '#f59e0b', '#ec4899', '#14b8a6', '#8b5cf6', '#ef4444', '#3b82f6', '#84cc16', '#A47148']
|
||||
|
||||
type EdgeTypeKey = 'explicit_link' | 'semantic_echo' | 'title_mention' | 'shared_label' | 'jaccard'
|
||||
|
||||
const DEFAULT_EDGE_FILTERS: Record<EdgeTypeKey, boolean> = {
|
||||
explicit_link: true,
|
||||
semantic_echo: true,
|
||||
title_mention: true,
|
||||
shared_label: true,
|
||||
jaccard: false,
|
||||
}
|
||||
|
||||
export function NoteGraphView({ embedded = false }: { embedded?: boolean }) {
|
||||
const router = useRouter()
|
||||
const { notebooks } = useNotebooks()
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const graphRef = useRef<any>(null)
|
||||
const existingNodesRef = useRef<Map<string, any>>(new Map())
|
||||
const [dimensions, setDimensions] = useState({ width: 800, height: 600 })
|
||||
const [rawData, setRawData] = useState<RawData | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [searchFilter, setSearchFilter] = useState('')
|
||||
const [selectedNode, setSelectedNode] = useState<GraphNode | null>(null)
|
||||
const [notePreview, setNotePreview] = useState<NotePreview | null>(null)
|
||||
const [previewLoading, setPreviewLoading] = useState(false)
|
||||
const [selectedNotebookId, setSelectedNotebookId] = useState<string | null>(null)
|
||||
const [edgeFilters, setEdgeFilters] = useState(DEFAULT_EDGE_FILTERS)
|
||||
const [semanticMinWeight, setSemanticMinWeight] = useState(0.45)
|
||||
const [focusNodeId, setFocusNodeId] = useState<string | null>(null)
|
||||
const [controlsOpen, setControlsOpen] = useState(!embedded)
|
||||
|
||||
const { t, language } = useLanguage()
|
||||
|
||||
const plainText = useCallback((html: string | null | undefined) =>
|
||||
(html ?? '')
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/#{1,6}\s/g, '')
|
||||
.replace(/\*{1,3}([^*]+)\*{1,3}/g, '$1')
|
||||
.replace(/_{1,2}([^_]+)_{1,2}/g, '$1')
|
||||
.replace(/`[^`]+`/g, '')
|
||||
.replace(/!?\[[^\]]*\]\([^)]*\)/g, '')
|
||||
.replace(/\s+/g, ' ').trim().slice(0, 400), [])
|
||||
|
||||
const htmlContent = useMemo(() => {
|
||||
if (!notePreview?.content) return ''
|
||||
const isMarkdown = notePreview.type === 'markdown' || notePreview.isMarkdown || (!notePreview.content.includes('<') && !notePreview.content.includes('</'))
|
||||
let rawHtml = notePreview.content
|
||||
if (isMarkdown) {
|
||||
rawHtml = markdownToHtml(notePreview.content)
|
||||
}
|
||||
return DOMPurify.sanitize(rawHtml)
|
||||
}, [notePreview])
|
||||
|
||||
const wordCount = useMemo(() => {
|
||||
if (!notePreview?.content) return 0
|
||||
const text = plainText(notePreview.content)
|
||||
return text.split(/\s+/).filter(Boolean).length
|
||||
}, [notePreview, plainText])
|
||||
|
||||
const charCount = useMemo(() => {
|
||||
if (!notePreview?.content) return 0
|
||||
return plainText(notePreview.content).length
|
||||
}, [notePreview, plainText])
|
||||
|
||||
const isRtl = useMemo(() => {
|
||||
if (!notePreview?.content) return false
|
||||
const sample = plainText(notePreview.content).replace(/\s+/g, '').slice(0, 400)
|
||||
const rtlChars = /[\u0590-\u05FF\u0600-\u06FF\u0700-\u074F\u0750-\u077F\u08A0-\u08FF\uFB50-\uFDFF\uFE70-\uFEFF]/
|
||||
let rtl = 0
|
||||
let ltr = 0
|
||||
for (const ch of sample) {
|
||||
if (rtlChars.test(ch)) rtl++
|
||||
else if (/[A-Za-z]/.test(ch)) ltr++
|
||||
}
|
||||
return rtl > 0 && rtl >= ltr
|
||||
}, [notePreview, plainText])
|
||||
|
||||
// ─── Resize ───────────────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
const el = containerRef.current
|
||||
if (!el) return
|
||||
const ro = new ResizeObserver(entries => {
|
||||
const { width, height } = entries[0].contentRect
|
||||
setDimensions({ width: Math.floor(width), height: Math.floor(height) })
|
||||
})
|
||||
ro.observe(el)
|
||||
return () => ro.disconnect()
|
||||
}, [])
|
||||
|
||||
// ─── Fetch data ───────────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
setLoading(true)
|
||||
fetch('/api/graph')
|
||||
.then(r => { if (!r.ok) throw new Error(t('errors.network')); return r.json() })
|
||||
.then(d => setRawData(d))
|
||||
.catch(e => setError(e.message))
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
// ─── Configure forces once graph is mounted ───────────────────────────────
|
||||
const forcesConfigured = useRef(false)
|
||||
useEffect(() => {
|
||||
if (!rawData || forcesConfigured.current) return
|
||||
// Wait for the ForceGraph to mount
|
||||
const timer = setTimeout(() => {
|
||||
const fg = graphRef.current
|
||||
if (!fg) return
|
||||
fg.d3Force('charge')?.strength(-120)
|
||||
fg.d3Force('link')?.distance(55)
|
||||
fg.d3Force('center')?.strength(0.05)
|
||||
forcesConfigured.current = true
|
||||
}, 200)
|
||||
return () => clearTimeout(timer)
|
||||
}, [rawData])
|
||||
|
||||
// ─── Note preview ─────────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!selectedNode) { setNotePreview(null); return }
|
||||
setPreviewLoading(true)
|
||||
fetch(`/api/notes/${selectedNode.id}`)
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(res => setNotePreview(res?.data ?? null))
|
||||
.catch(() => setNotePreview(null))
|
||||
.finally(() => setPreviewLoading(false))
|
||||
}, [selectedNode])
|
||||
|
||||
// ─── Color map ────────────────────────────────────────────────────────────
|
||||
const colorMap = useMemo(() => {
|
||||
if (!rawData) return new Map<string | null, string>()
|
||||
const map = new Map<string | null, string>()
|
||||
const ids = [...new Set(rawData.nodes.map(n => n.notebookId).filter(Boolean))] as string[]
|
||||
ids.forEach((id, i) => {
|
||||
const nb = notebooks.find(n => n.id === id)
|
||||
map.set(id, nb?.color || PALETTE[i % PALETTE.length])
|
||||
})
|
||||
return map
|
||||
}, [rawData, notebooks])
|
||||
|
||||
const neighborIds = useMemo(() => {
|
||||
if (!focusNodeId || !rawData) return null
|
||||
const ids = new Set<string>([focusNodeId])
|
||||
for (const edge of rawData.edges) {
|
||||
if (edge.source === focusNodeId) ids.add(edge.target)
|
||||
if (edge.target === focusNodeId) ids.add(edge.source)
|
||||
}
|
||||
return ids
|
||||
}, [focusNodeId, rawData])
|
||||
|
||||
// ─── Graph data ───────────────────────────────────────────────────────────
|
||||
const graphData = useMemo(() => {
|
||||
if (!rawData) return { nodes: [], links: [] }
|
||||
|
||||
let filtered = selectedNotebookId
|
||||
? rawData.nodes.filter(n => n.notebookId === selectedNotebookId)
|
||||
: rawData.nodes
|
||||
|
||||
if (neighborIds) {
|
||||
filtered = filtered.filter(n => neighborIds.has(n.id))
|
||||
}
|
||||
|
||||
filtered = searchFilter.trim()
|
||||
? filtered.filter(n => n.title.toLowerCase().includes(searchFilter.toLowerCase()))
|
||||
: filtered
|
||||
|
||||
const filteredIds = new Set(filtered.map(n => n.id))
|
||||
const visibleEdges = rawData.edges.filter(e => {
|
||||
const type = e.type as EdgeTypeKey
|
||||
if (!(type in edgeFilters) || !edgeFilters[type]) return false
|
||||
if (type === 'semantic_echo' && e.weight < semanticMinWeight) return false
|
||||
return filteredIds.has(e.source) && filteredIds.has(e.target)
|
||||
})
|
||||
|
||||
return {
|
||||
nodes: filtered.map(n => {
|
||||
const existing = existingNodesRef.current.get(n.id)
|
||||
if (existing) {
|
||||
existing.name = n.title
|
||||
existing.val = 1 + Math.min(n.degree, 8) * 0.5
|
||||
existing.color = colorMap.get(n.notebookId) ?? '#94a3b8'
|
||||
existing.notebookId = n.notebookId
|
||||
existing.degree = n.degree
|
||||
return existing
|
||||
}
|
||||
const newNode = {
|
||||
id: n.id,
|
||||
name: n.title,
|
||||
val: 1 + Math.min(n.degree, 8) * 0.5,
|
||||
color: colorMap.get(n.notebookId) ?? '#94a3b8',
|
||||
notebookId: n.notebookId,
|
||||
degree: n.degree,
|
||||
}
|
||||
existingNodesRef.current.set(n.id, newNode)
|
||||
return newNode
|
||||
}),
|
||||
links: visibleEdges.map(e => {
|
||||
let color = '#cbd5e1'
|
||||
let width = 2.5
|
||||
let dash = false
|
||||
|
||||
if (e.type === 'explicit_link') {
|
||||
color = '#10b981'
|
||||
width = 4.5
|
||||
} else if (e.type === 'semantic_echo') {
|
||||
color = '#8b5cf6'
|
||||
width = 3.5
|
||||
dash = true
|
||||
} else if (e.type === 'title_mention') {
|
||||
color = '#f59e0b'
|
||||
width = 3.2
|
||||
} else if (e.type === 'shared_label') {
|
||||
color = '#3b82f6'
|
||||
width = 2.8
|
||||
}
|
||||
|
||||
return {
|
||||
source: e.source,
|
||||
target: e.target,
|
||||
color,
|
||||
width,
|
||||
dash,
|
||||
type: e.type,
|
||||
}
|
||||
}),
|
||||
}
|
||||
}, [rawData, searchFilter, colorMap, selectedNotebookId, edgeFilters, semanticMinWeight, neighborIds])
|
||||
|
||||
const selectedNotebookName = useMemo(() => {
|
||||
if (!selectedNode || !rawData) return null
|
||||
return rawData.clusters.find(c => c.id === selectedNode.notebookId)?.name ?? null
|
||||
}, [selectedNode, rawData])
|
||||
|
||||
// ─── Handlers (double-click via timer) ──────────────────────────────────
|
||||
const lastClickRef = useRef<{ id: string; time: number } | null>(null)
|
||||
|
||||
const handleNodeClick = useCallback((node: any) => {
|
||||
if (!rawData) return
|
||||
const now = Date.now()
|
||||
const last = lastClickRef.current
|
||||
if (last && last.id === node.id && now - last.time < 350) {
|
||||
lastClickRef.current = null
|
||||
router.push(openNotePath(node.id))
|
||||
return
|
||||
}
|
||||
lastClickRef.current = { id: node.id, time: now }
|
||||
setSelectedNode(rawData.nodes.find(n => n.id === node.id) ?? null)
|
||||
}, [rawData, router])
|
||||
|
||||
const handleZoomToFit = useCallback(() => {
|
||||
graphRef.current?.zoomToFit(400, 50)
|
||||
}, [])
|
||||
|
||||
const toggleEdgeFilter = useCallback((key: EdgeTypeKey) => {
|
||||
setEdgeFilters(prev => ({ ...prev, [key]: !prev[key] }))
|
||||
}, [])
|
||||
|
||||
// Zoom vers le premier nœud correspondant à la recherche
|
||||
useEffect(() => {
|
||||
if (!searchFilter.trim() || graphData.nodes.length === 0) return
|
||||
const timer = window.setTimeout(() => {
|
||||
const fg = graphRef.current
|
||||
if (!fg) return
|
||||
const match = fg.graphData()?.nodes?.find((n: { id: string; name?: string }) =>
|
||||
(n.name ?? '').toLowerCase().includes(searchFilter.toLowerCase())
|
||||
)
|
||||
if (match?.x != null && match?.y != null) {
|
||||
fg.centerAt(match.x, match.y, 500)
|
||||
fg.zoom(2.2, 500)
|
||||
}
|
||||
}, 600)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [searchFilter, graphData.nodes.length])
|
||||
|
||||
|
||||
|
||||
// ─── Cluster painting (stable ref, no deps) ──────────────────────────────
|
||||
const dataRef = useRef<{ nodes: any[]; colorMap: Map<string|null,string>; clusters: Cluster[] }>({ nodes: [], colorMap: new Map(), clusters: [] })
|
||||
dataRef.current = { nodes: graphData.nodes, colorMap, clusters: rawData?.clusters ?? [] }
|
||||
|
||||
const paintClusters = useRef((ctx: CanvasRenderingContext2D, globalScale: number) => {
|
||||
const { nodes, colorMap: cm, clusters } = dataRef.current
|
||||
if (!nodes || nodes.length === 0) return
|
||||
|
||||
const groups = new Map<string, { x: number; y: number }[]>()
|
||||
for (const node of nodes) {
|
||||
if (!node.notebookId || node.x === undefined || node.y === undefined) continue
|
||||
if (!groups.has(node.notebookId)) groups.set(node.notebookId, [])
|
||||
groups.get(node.notebookId)!.push({ x: node.x, y: node.y })
|
||||
}
|
||||
|
||||
for (const [nbId, pts] of groups) {
|
||||
if (pts.length < 3) continue
|
||||
const color = cm.get(nbId) ?? '#94a3b8'
|
||||
const cx = pts.reduce((s, p) => s + p.x, 0) / pts.length
|
||||
const cy = pts.reduce((s, p) => s + p.y, 0) / pts.length
|
||||
let maxR = 0
|
||||
for (const p of pts) {
|
||||
const d = Math.sqrt((p.x - cx) ** 2 + (p.y - cy) ** 2)
|
||||
if (d > maxR) maxR = d
|
||||
}
|
||||
const r = maxR + 30
|
||||
|
||||
ctx.beginPath()
|
||||
ctx.arc(cx, cy, r, 0, 2 * Math.PI)
|
||||
ctx.fillStyle = color + '0A'
|
||||
ctx.fill()
|
||||
ctx.strokeStyle = color + '30'
|
||||
ctx.lineWidth = 1.5 / globalScale
|
||||
ctx.setLineDash([5 / globalScale, 5 / globalScale])
|
||||
ctx.stroke()
|
||||
ctx.setLineDash([])
|
||||
|
||||
// Cluster name
|
||||
if (globalScale > 0.4) {
|
||||
const name = clusters.find(c => c.id === nbId)?.name ?? ''
|
||||
if (name) {
|
||||
const fs = Math.min(12, 9 / globalScale)
|
||||
ctx.font = `600 ${fs}px -apple-system, sans-serif`
|
||||
ctx.fillStyle = color + 'BB'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.textBaseline = 'bottom'
|
||||
ctx.fillText(name, cx, cy - r + 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
}).current
|
||||
|
||||
// ─── Render ───────────────────────────────────────────────────────────────
|
||||
return (
|
||||
<div className={`flex flex-col h-full ${embedded ? 'bg-transparent' : 'bg-[#FAFAF9]'}`}>
|
||||
{!embedded && (
|
||||
<div className="px-5 py-3 flex items-center gap-4 shrink-0 border-b border-border/40 bg-white">
|
||||
<Network size={16} className="text-indigo-500" />
|
||||
<h1 className="text-sm font-semibold text-ink">{t('graphView.title')}</h1>
|
||||
{rawData && (
|
||||
<span className="text-[10px] text-concrete/50 font-medium">
|
||||
{t('graphView.notesCount', { count: rawData.nodes.length })} · {t('graphView.connectionsCount', { count: rawData.edges.length })}
|
||||
{graphData.links.length !== rawData.edges.length && (
|
||||
<> · {t('graphView.visibleConnections', { count: graphData.links.length })}</>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
<div className="relative">
|
||||
<Filter size={12} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-concrete/40" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('graphView.searchPlaceholder')}
|
||||
value={searchFilter}
|
||||
onChange={e => setSearchFilter(e.target.value)}
|
||||
className="pl-7 pr-7 py-1.5 bg-white border border-border/60 rounded-md text-xs text-ink outline-none focus:border-indigo-400 w-44 placeholder:text-concrete/40"
|
||||
/>
|
||||
{searchFilter && (
|
||||
<button onClick={() => setSearchFilter('')} className="absolute right-2 top-1/2 -translate-y-1/2 text-concrete/40 hover:text-ink">
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Canvas */}
|
||||
<div ref={containerRef} className="flex-1 relative overflow-hidden">
|
||||
{loading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-[#FAFAF9]">
|
||||
<Loader2 size={24} className="animate-spin text-concrete/40" />
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<p className="text-sm text-rose-500">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
{!loading && !error && graphData.nodes.length > 0 && (
|
||||
<ForceGraph2D
|
||||
ref={graphRef}
|
||||
graphData={graphData}
|
||||
width={dimensions.width}
|
||||
height={dimensions.height}
|
||||
backgroundColor="#FAFAF9"
|
||||
nodeRelSize={5}
|
||||
nodeVal="val"
|
||||
nodeColor="color"
|
||||
nodeLabel="name"
|
||||
linkColor="color"
|
||||
linkWidth="width"
|
||||
linkOpacity={0.92}
|
||||
linkDirectionalParticles={2}
|
||||
linkDirectionalParticleWidth={2.5}
|
||||
linkLineDash={(link: any) => link.dash ? [6, 4] : null}
|
||||
onNodeClick={handleNodeClick}
|
||||
onNodeHover={(node: any) => {
|
||||
if (containerRef.current) containerRef.current.style.cursor = node ? 'pointer' : 'default'
|
||||
}}
|
||||
onRenderFramePre={paintClusters}
|
||||
nodeCanvasObjectMode={() => 'after'}
|
||||
nodeCanvasObject={(node: any, ctx: CanvasRenderingContext2D, globalScale: number) => {
|
||||
const n = node as any
|
||||
if (globalScale < 0.7) return
|
||||
const name: string = n.name ?? ''
|
||||
const label = name.length > 20 ? name.slice(0, 18) + '…' : name
|
||||
const fontSize = 11 / globalScale
|
||||
if (fontSize > 18) return
|
||||
ctx.font = `${fontSize}px -apple-system, sans-serif`
|
||||
ctx.textAlign = 'center'
|
||||
ctx.textBaseline = 'top'
|
||||
const r = Math.sqrt(n.val ?? 1) * 5
|
||||
// White background behind label
|
||||
const tw = ctx.measureText(label).width
|
||||
const lx = n.x - tw / 2 - 2
|
||||
const ly = n.y + r + 2
|
||||
ctx.fillStyle = 'rgba(250,250,249,0.85)'
|
||||
ctx.fillRect(lx, ly, tw + 4, fontSize + 2)
|
||||
// Label text
|
||||
ctx.fillStyle = '#334155'
|
||||
ctx.fillText(label, n.x, ly + 1)
|
||||
}}
|
||||
cooldownTicks={80}
|
||||
d3AlphaDecay={0.03}
|
||||
d3VelocityDecay={0.4}
|
||||
/>
|
||||
)}
|
||||
{!loading && !error && graphData.nodes.length === 0 && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 text-concrete/40">
|
||||
<Network size={32} />
|
||||
<p className="text-xs">{t('graphView.noNotesFound')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Zoom to fit */}
|
||||
{!loading && graphData.nodes.length > 0 && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleZoomToFit() }}
|
||||
className="absolute top-4 right-4 z-10 flex items-center gap-1.5 px-3 py-1.5 bg-white border border-border/50 rounded-md text-[11px] text-ink font-medium shadow-sm hover:bg-gray-50 transition-colors animate-fade-in"
|
||||
>
|
||||
<Maximize2 size={12} />
|
||||
{t('graphView.globalView')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Cluster legend (Interactive Notebook Filter) */}
|
||||
{rawData && rawData.clusters && rawData.clusters.length > 0 && (
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
className="absolute top-4 left-4 z-10 flex flex-col gap-2 max-h-[42vh] overflow-y-auto pr-1"
|
||||
>
|
||||
<span className="text-[9px] font-bold text-slate-800 uppercase tracking-wider pl-1 select-none">{t('graphView.notebooks')}</span>
|
||||
{(selectedNotebookId || focusNodeId) && (
|
||||
<div className="flex flex-col gap-1">
|
||||
{selectedNotebookId && (
|
||||
<button
|
||||
onClick={() => setSelectedNotebookId(null)}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1 bg-white border border-rose-200 text-rose-600 rounded-full shadow-sm hover:bg-rose-50 transition-all text-[9px] font-semibold w-fit"
|
||||
>
|
||||
<X size={10} />
|
||||
{t('graphView.resetFilter')}
|
||||
</button>
|
||||
)}
|
||||
{focusNodeId && (
|
||||
<button
|
||||
onClick={() => setFocusNodeId(null)}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1 bg-white border border-indigo-200 text-indigo-600 rounded-full shadow-sm hover:bg-indigo-50 transition-all text-[9px] font-semibold w-fit"
|
||||
>
|
||||
<X size={10} />
|
||||
{t('graphView.resetFocus')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{rawData.clusters.map(c => {
|
||||
const isSelected = selectedNotebookId === c.id
|
||||
const isAnySelected = selectedNotebookId !== null
|
||||
const color = colorMap.get(c.id) ?? '#94a3b8'
|
||||
return (
|
||||
<button
|
||||
key={c.id}
|
||||
onClick={() => setSelectedNotebookId(isSelected ? null : c.id)}
|
||||
className={`flex items-center gap-2 px-3 py-1 bg-white border rounded-full shadow-sm transition-all duration-200 hover:scale-105 w-fit text-left ${
|
||||
isSelected
|
||||
? 'border-indigo-500 ring-2 ring-indigo-500/20 font-semibold'
|
||||
: isAnySelected
|
||||
? 'border-border/30 opacity-40 hover:opacity-100'
|
||||
: 'border-border/30 hover:border-concrete/40'
|
||||
}`}
|
||||
>
|
||||
<span className="w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: color }} />
|
||||
<span className="text-[10px] text-concrete/80 whitespace-nowrap">{c.name}</span>
|
||||
{isSelected && <X size={10} className="text-concrete/40 ml-0.5 shrink-0" />}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filtres de liens + seuil sémantique */}
|
||||
{!loading && !error && rawData && (
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
className="absolute bottom-4 left-4 z-10 flex flex-col gap-2 max-w-[220px]"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setControlsOpen(v => !v)}
|
||||
className="flex items-center gap-2 px-3 py-1.5 bg-white/95 border border-border/40 rounded-lg shadow-sm text-[10px] font-semibold text-slate-700 w-fit"
|
||||
>
|
||||
<Filter size={12} />
|
||||
{t('graphView.linkFilters')}
|
||||
</button>
|
||||
{controlsOpen && (
|
||||
<div className="p-3 bg-white/95 border border-border/40 rounded-lg shadow-sm space-y-2.5 select-none">
|
||||
<h3 className="text-[9px] font-bold text-slate-800 uppercase tracking-wider">{t('graphView.relationshipTypes')}</h3>
|
||||
{([
|
||||
['explicit_link', t('graphView.edgeTypes.explicitLink')],
|
||||
['semantic_echo', t('graphView.edgeTypes.semanticEcho')],
|
||||
['title_mention', t('graphView.edgeTypes.titleMention')],
|
||||
['shared_label', t('graphView.edgeTypes.sharedLabel')],
|
||||
['jaccard', t('graphView.edgeTypes.jaccard')],
|
||||
] as [EdgeTypeKey, string][]).map(([key, label]) => (
|
||||
<label key={key} className="flex items-center gap-2.5 cursor-pointer text-[10px] text-slate-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={edgeFilters[key]}
|
||||
onChange={() => toggleEdgeFilter(key)}
|
||||
className="w-3.5 h-3.5 shrink-0 rounded border-2 border-slate-300 accent-indigo-600 cursor-pointer"
|
||||
/>
|
||||
<span>{label}</span>
|
||||
</label>
|
||||
))}
|
||||
{edgeFilters.semantic_echo && (
|
||||
<div className="pt-1 border-t border-border/30 space-y-1">
|
||||
<div className="flex items-center justify-between text-[9px] text-concrete/70">
|
||||
<span>{t('graphView.semanticThreshold')}</span>
|
||||
<span className="font-mono">{Math.round(semanticMinWeight * 100)}%</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0.3}
|
||||
max={0.9}
|
||||
step={0.05}
|
||||
value={semanticMinWeight}
|
||||
onChange={e => setSemanticMinWeight(Number(e.target.value))}
|
||||
className="w-full h-1 accent-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Legend of relationship types (compact) */}
|
||||
{!loading && !error && graphData.nodes.length > 0 && controlsOpen && (
|
||||
<div className="absolute bottom-4 right-[21rem] z-10 hidden xl:flex flex-col gap-1.5 p-2.5 bg-white/90 border border-border/40 rounded-lg shadow-sm max-w-xs select-none pointer-events-none opacity-80">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-5 h-0.5 rounded shrink-0 bg-[#10b981]" />
|
||||
<span className="text-[9px] font-medium text-concrete/70">{t('graphView.edgeTypes.explicitLink')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-5 border-t-2 border-dashed shrink-0 border-[#a78bfa]" />
|
||||
<span className="text-[9px] font-medium text-concrete/70">{t('graphView.edgeTypes.semanticEcho')}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Note detail panel */}
|
||||
{selectedNode && (
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
className="absolute inset-y-0 right-0 w-80 backdrop-blur-md bg-white/95 dark:bg-stone-900/95 border-l border-border/40 flex flex-col shadow-[0_8px_30px_rgb(0,0,0,0.06)] z-20 transition-all duration-300"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between p-5 border-b border-border/40">
|
||||
<div className="flex-1 min-w-0 pr-3">
|
||||
{selectedNotebookName && (
|
||||
<button
|
||||
onClick={() => setSelectedNotebookId(selectedNode.notebookId === selectedNotebookId ? null : selectedNode.notebookId)}
|
||||
className="mb-2 inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[9px] font-bold uppercase tracking-wider bg-slate-100 hover:bg-slate-200/80 text-concrete transition-all border border-border/20 hover:scale-105"
|
||||
>
|
||||
<span className="w-1.5 h-1.5 rounded-full shrink-0" style={{ backgroundColor: colorMap.get(selectedNode.notebookId) ?? '#94a3b8' }} />
|
||||
{selectedNotebookName}
|
||||
</button>
|
||||
)}
|
||||
<h2
|
||||
dir={isRtl ? 'rtl' : 'ltr'}
|
||||
className={`text-sm font-semibold text-slate-800 dark:text-slate-100 leading-snug tracking-tight select-all ${isRtl ? 'text-right font-persian font-semibold' : 'text-left font-sans'}`}
|
||||
>
|
||||
{selectedNode.title || <span className="italic text-concrete/40">{t('notes.untitled')}</span>}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSelectedNode(null)}
|
||||
className="p-1.5 rounded-full text-concrete/40 hover:text-slate-800 dark:hover:text-slate-100 hover:bg-slate-100 dark:hover:bg-stone-800 transition-all"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Quick Metadata Info */}
|
||||
<div className="px-5 py-3.5 bg-slate-50/50 dark:bg-stone-950/20 border-b border-border/30 grid grid-cols-2 gap-y-2 gap-x-4 text-[10px] text-concrete/60 select-none">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Calendar size={11} className="text-concrete/40 shrink-0" />
|
||||
<span className="truncate" title={new Date(selectedNode.createdAt).toLocaleString(language === 'fa' ? 'fa-IR' : language)}>
|
||||
{new Date(selectedNode.createdAt).toLocaleDateString(language === 'fa' ? 'fa-IR' : language, { day: '2-digit', month: 'short', year: 'numeric' })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Link2 size={11} className="text-concrete/40 shrink-0" />
|
||||
<span>
|
||||
{t(selectedNode.degree === 1 ? 'graphView.connections' : 'graphView.connectionsPlural', { count: selectedNode.degree })}
|
||||
</span>
|
||||
</div>
|
||||
{!previewLoading && notePreview && (
|
||||
<>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<FileText size={11} className="text-concrete/40 shrink-0" />
|
||||
<span>{t('graphView.preview.words', { count: wordCount })}</span>
|
||||
</div>
|
||||
{notePreview.updatedAt && (
|
||||
<div className="flex items-center gap-1.5 col-span-2 border-t border-border/20 pt-1.5 mt-0.5">
|
||||
<Clock size={11} className="text-concrete/40 shrink-0" />
|
||||
<span className="truncate">
|
||||
{t('graphView.preview.updated')}{' '}
|
||||
{new Date(notePreview.updatedAt).toLocaleDateString(language === 'fa' ? 'fa-IR' : language, { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Scrollable Content */}
|
||||
<div className="flex-1 overflow-y-auto p-5 space-y-4">
|
||||
{previewLoading ? (
|
||||
/* Sleek Skeleton Loader */
|
||||
<div className="space-y-4 animate-pulse">
|
||||
<div className="h-4 bg-stone-200/60 dark:bg-stone-800/60 rounded w-3/4" />
|
||||
<div className="space-y-2">
|
||||
<div className="h-3 bg-stone-200/50 dark:bg-stone-800/50 rounded w-full" />
|
||||
<div className="h-3 bg-stone-200/50 dark:bg-stone-800/50 rounded w-11/12" />
|
||||
<div className="h-3 bg-stone-200/50 dark:bg-stone-800/50 rounded w-4/5" />
|
||||
</div>
|
||||
<div className="space-y-2 pt-2">
|
||||
<div className="h-3 bg-stone-200/50 dark:bg-stone-800/50 rounded w-full" />
|
||||
<div className="h-3 bg-stone-200/50 dark:bg-stone-800/50 rounded w-5/6" />
|
||||
</div>
|
||||
</div>
|
||||
) : !notePreview || (!notePreview.content && (!notePreview.checkItems || notePreview.checkItems.length === 0)) ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-concrete/30 gap-2.5">
|
||||
<FileText size={32} className="stroke-[1.2] text-concrete/20" />
|
||||
<p className="text-xs italic font-medium">{t('graphView.preview.emptyNote')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Note Content Renderer */}
|
||||
{notePreview.type === 'checklist' && notePreview.checkItems && notePreview.checkItems.length > 0 ? (
|
||||
<div className="space-y-2 select-none" dir={isRtl ? 'rtl' : 'ltr'}>
|
||||
<NoteChecklist
|
||||
items={notePreview.checkItems}
|
||||
onToggleItem={() => {}}
|
||||
/>
|
||||
</div>
|
||||
) : notePreview.type === 'markdown' || notePreview.isMarkdown ? (
|
||||
<div className="text-xs text-slate-600 dark:text-stone-300" dir={isRtl ? 'rtl' : 'ltr'}>
|
||||
<MarkdownContent
|
||||
content={notePreview.content}
|
||||
className={`prose-h1:text-sm prose-h1:font-bold prose-h1:text-slate-800 dark:prose-h1:text-stone-100 prose-h1:mt-3 prose-h1:mb-1 prose-h2:text-xs prose-h2:font-bold prose-h2:text-slate-700 dark:prose-h2:text-stone-200 prose-h2:mt-2 prose-h2:mb-1 prose-p:text-xs prose-p:leading-relaxed prose-p:mb-2 prose-ul:list-disc prose-ul:pl-4 prose-ol:list-decimal prose-ol:pl-4 ${isRtl ? 'text-right font-persian' : 'text-left font-sans'}`}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
dir={isRtl ? 'rtl' : 'ltr'}
|
||||
className={`text-xs text-slate-600 dark:text-stone-300 space-y-2 leading-relaxed break-words ${isRtl ? 'text-right font-persian' : 'text-left font-sans'}
|
||||
[&_h1]:text-sm [&_h1]:font-bold [&_h1]:text-slate-800 dark:[&_h1]:text-stone-100 [&_h1]:mt-4 [&_h1]:mb-1.5 [&_h1]:first:mt-0
|
||||
[&_h2]:text-xs [&_h2]:font-bold [&_h2]:text-slate-700 dark:[&_h2]:text-stone-200 [&_h2]:mt-3 [&_h2]:mb-1 [&_h2]:first:mt-0
|
||||
[&_h3]:text-xs [&_h3]:font-semibold [&_h3]:text-slate-600 dark:[&_h3]:text-stone-300 [&_h3]:mt-2 [&_h3]:mb-1 [&_h3]:first:mt-0
|
||||
[&_p]:mb-2 [&_p]:last:mb-0
|
||||
[&_ul]:list-disc [&_ul]:pl-4 [&_ul]:mb-2
|
||||
[&_ol]:list-decimal [&_ol]:pl-4 [&_ol]:mb-2
|
||||
[&_li]:mb-0.5
|
||||
[&_strong]:font-semibold [&_strong]:text-slate-800 dark:[&_strong]:text-stone-100
|
||||
[&_em]:italic
|
||||
[&_code]:px-1 [&_code]:py-0.5 [&_code]:bg-slate-100 dark:[&_code]:bg-stone-850 [&_code]:rounded [&_code]:font-mono [&_code]:text-[10px]
|
||||
[&_pre]:p-2.5 [&_pre]:bg-slate-900 [&_pre]:text-slate-100 [&_pre]:rounded-lg [&_pre]:overflow-x-auto [&_pre]:font-mono [&_pre]:text-[10px] [&_pre]:my-2
|
||||
[&_blockquote]:border-l-2 [&_blockquote]:border-slate-300 dark:[&_blockquote]:border-stone-700 [&_blockquote]:pl-3 [&_blockquote]:italic [&_blockquote]:text-slate-500 [&_blockquote]:my-2
|
||||
[&_a]:text-indigo-600 dark:[&_a]:text-indigo-400 [&_a]:underline [&_a]:hover:text-indigo-500`}
|
||||
dangerouslySetInnerHTML={{ __html: htmlContent }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Refined Tags list */}
|
||||
{Array.isArray(notePreview.labels) && notePreview.labels.length > 0 && (
|
||||
<div className="border-t border-border/20 pt-4 mt-4 select-none">
|
||||
<div className="flex items-center gap-1 text-[10px] font-bold text-slate-800 dark:text-stone-300 uppercase tracking-wider mb-2">
|
||||
<Tag size={10} className="text-concrete/40 shrink-0" />
|
||||
<span>{t('graphView.preview.tags')}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{notePreview.labels.map((label: string) => (
|
||||
<LabelBadge key={label} label={label} variant="default" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Premium Action Footer */}
|
||||
<div className="p-4 border-t border-border/40 bg-slate-50/50 dark:bg-stone-950/20 space-y-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFocusNodeId(prev => prev === selectedNode.id ? null : selectedNode.id)}
|
||||
className="w-full flex items-center justify-center gap-2 py-2 px-4 bg-white dark:bg-stone-900 border border-border/50 hover:border-indigo-400 text-xs font-medium rounded-lg transition-colors"
|
||||
>
|
||||
<Sparkles size={12} className="text-indigo-500" />
|
||||
<span>{focusNodeId === selectedNode.id ? t('graphView.resetFocus') : t('graphView.exploreFromNode')}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => router.push(openNotePath(selectedNode.id))}
|
||||
className="group w-full flex items-center justify-center gap-2 py-2.5 px-4 bg-brand-accent hover:bg-brand-accent/90 text-white active:scale-[0.98] text-xs font-semibold rounded-lg shadow-sm transition-all duration-200"
|
||||
>
|
||||
<BookOpen size={12} className="group-hover:scale-110 transition-transform" />
|
||||
<span>{t('graphView.preview.openNote')}</span>
|
||||
<ChevronRight size={12} className="group-hover:translate-x-0.5 transition-transform ml-0.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -565,7 +565,7 @@ export function SearchModal({ isOpen, onClose }: SearchModalProps) {
|
||||
{overviewLoading ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 size={12} className="animate-spin text-brand-accent" />
|
||||
<span className="text-[11px] text-muted-foreground">t('searchModal.resultsSummary')</span>
|
||||
<span className="text-[11px] text-muted-foreground">{t('searchModal.resultsSummary')}</span>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-[12px] leading-relaxed text-foreground/80">{overview}</p>
|
||||
@@ -687,7 +687,7 @@ export function SearchModal({ isOpen, onClose }: SearchModalProps) {
|
||||
<div className="space-y-1">
|
||||
<p className="text-[11.5px] font-bold">{t('searchModal.documentPreview')}</p>
|
||||
<p className="text-[10px] italic opacity-60">
|
||||
Sélectionnez un résultat pour explorer son contenu.
|
||||
{t('searchModal.selectResultToPreview')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1536,6 +1536,22 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/archive"
|
||||
className={cn(
|
||||
'w-9 h-9 rounded-lg flex items-center justify-center transition-all relative group',
|
||||
pathname === '/archive'
|
||||
? 'bg-amber-500/10 text-amber-500 border border-amber-500/25'
|
||||
: 'text-concrete hover:text-amber-500 hover:bg-amber-500/5'
|
||||
)}
|
||||
>
|
||||
{pathname === '/archive' && <div className="absolute left-0 top-1/2 -translate-y-1/2 w-[3px] h-4 bg-amber-500 rounded-r-full" />}
|
||||
<Archive size={16} />
|
||||
<span className="absolute left-[50px] top-1/2 -translate-y-1/2 bg-ink dark:bg-white dark:text-ink text-paper text-[9px] font-bold py-1 px-2 rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-50 pointer-events-none shadow-md uppercase tracking-wider">
|
||||
{t('sidebar.archive')}
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/home?shared=1&forceList=1"
|
||||
className={cn(
|
||||
@@ -1558,7 +1574,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
>
|
||||
<Search size={15} />
|
||||
<span className="absolute left-[50px] top-1/2 -translate-y-1/2 bg-ink dark:bg-white dark:text-ink text-paper text-[9px] font-bold py-1 px-2 rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-50 pointer-events-none shadow-md uppercase tracking-wider">
|
||||
Recherche (Ctrl+K)
|
||||
{t('sidebar.searchShortcut')}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -1568,7 +1584,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
>
|
||||
{isDark ? <Sun size={15} /> : <Moon size={15} />}
|
||||
<span className="absolute left-[50px] top-1/2 -translate-y-1/2 bg-ink dark:bg-white dark:text-ink text-paper text-[9px] font-bold py-1 px-2 rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-50 pointer-events-none shadow-md uppercase tracking-wider">
|
||||
{isDark ? 'Mode clair' : 'Mode sombre'}
|
||||
{isDark ? t('sidebar.lightMode') : t('sidebar.darkMode')}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
|
||||
@@ -251,8 +251,8 @@ Rules:
|
||||
|
||||
const raw = await provider.generateText(prompt)
|
||||
const parsed = this.parseJsonObject(raw)
|
||||
const titles = Array.isArray(parsed?.titles)
|
||||
? parsed.titles.map((t: unknown) => String(t || '').trim()).filter(Boolean).slice(0, count)
|
||||
const titles: string[] = Array.isArray(parsed?.titles)
|
||||
? (parsed.titles as unknown[]).map((t) => String(t || '').trim()).filter(Boolean).slice(0, count)
|
||||
: []
|
||||
while (titles.length < count) {
|
||||
titles.push(lang === 'fr' ? `Chapitre ${titles.length + 1}` : `Chapter ${titles.length + 1}`)
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { auth } from '@/auth'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
export type AuthResult =
|
||||
| { ok: true; userId: string }
|
||||
| { ok: false; response: NextResponse }
|
||||
|
||||
/**
|
||||
* Retrieves the authenticated user ID or throws an Unauthorized error.
|
||||
* Use this in Server Actions and API routes to eliminate the repetitive
|
||||
* `const session = await auth(); if (!session?.user?.id) ...` boilerplate.
|
||||
* Use in Server Actions where throw semantics are preferred.
|
||||
*
|
||||
* @throws {Error} 'Unauthorized' if no valid session exists.
|
||||
* @returns The authenticated user's ID string.
|
||||
@@ -15,3 +20,54 @@ export async function requireAuth(): Promise<string> {
|
||||
}
|
||||
return session.user.id
|
||||
}
|
||||
|
||||
/**
|
||||
* Auth guard for API routes — returns a 401 NextResponse instead of throwing.
|
||||
*
|
||||
* Usage:
|
||||
* ```ts
|
||||
* const auth = await requireAuthOrResponse()
|
||||
* if (!auth.ok) return auth.response
|
||||
* const userId = auth.userId
|
||||
* ```
|
||||
*
|
||||
* @param format 'plain' (default) returns `{ error: 'Unauthorized' }`.
|
||||
* 'success' returns `{ success: false, error: 'Unauthorized' }`.
|
||||
*/
|
||||
export async function requireAuthOrResponse(
|
||||
format: 'plain' | 'success' = 'plain',
|
||||
): Promise<AuthResult> {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
const body = format === 'success'
|
||||
? { success: false, error: 'Unauthorized' }
|
||||
: { error: 'Unauthorized' }
|
||||
return { ok: false, response: NextResponse.json(body, { status: 401 }) }
|
||||
}
|
||||
return { ok: true, userId: session.user.id }
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin guard for API routes — checks user role in DB, returns 401/403 on failure.
|
||||
*
|
||||
* Usage:
|
||||
* ```ts
|
||||
* const auth = await requireAdminOrResponse()
|
||||
* if (!auth.ok) return auth.response
|
||||
* const userId = auth.userId
|
||||
* ```
|
||||
*/
|
||||
export async function requireAdminOrResponse(): Promise<AuthResult> {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return { ok: false, response: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) }
|
||||
}
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: session.user.id },
|
||||
select: { role: true },
|
||||
})
|
||||
if (user?.role !== 'ADMIN') {
|
||||
return { ok: false, response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }) }
|
||||
}
|
||||
return { ok: true, userId: session.user.id }
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { prisma } from '@/lib/prisma'
|
||||
import { redis } from '@/lib/redis'
|
||||
import { getCurrentPeriodKey, VALID_FEATURES, type FeatureName } from '@/lib/quota-utils'
|
||||
import type { SubscriptionTier } from '@/lib/plan-entitlements'
|
||||
import { QuotaExceededError } from '@/lib/entitlements'
|
||||
|
||||
// Imports dynamiques vers entitlements pour éviter les cycles
|
||||
// (entitlements délègue aussi vers credits pour reserveUsageOrThrow).
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
parseRedisInt,
|
||||
isValidFeature,
|
||||
VALID_FEATURES,
|
||||
type FeatureName,
|
||||
} from './quota-utils';
|
||||
import {
|
||||
getLimitAsync,
|
||||
@@ -261,6 +262,11 @@ export async function getEffectiveTier(
|
||||
return 'BASIC';
|
||||
}
|
||||
|
||||
// NOTE: canUseFeature reads the old per-feature Redis counters + UsageLog table.
|
||||
// Since reserveUsageOrThrow now delegates to the unified credits engine (AiCreditAccount),
|
||||
// these counters are no longer written to — this function returns stale/zero usage data.
|
||||
// Only caller: checkEntitlementOrThrow → app/api/ai/tags/route.ts (pre-check that always passes).
|
||||
// Actual enforcement happens via reserveAiUsageOrThrow at the debit site.
|
||||
export async function canUseFeature(
|
||||
userId: string,
|
||||
feature: string,
|
||||
@@ -365,10 +371,10 @@ function featureToAiLane(feature: string): 'chat' | 'tags' | 'embedding' {
|
||||
}
|
||||
|
||||
/**
|
||||
* Réserve des crédits globaux (modèle A).
|
||||
* @deprecated Use `reserveAiUsageOrThrow` from `@/lib/ai-quota` instead.
|
||||
* Thin wrapper kept for backward compat — delegates to the unified credits engine.
|
||||
* `amount` = coût en crédits (ex. slides multi-étapes : 1+N).
|
||||
* Si l'utilisateur a une clé perso (BYOK) pour la voie concernée, aucun débit.
|
||||
* Conserve le nom historique pour ne pas casser tous les appelants.
|
||||
*/
|
||||
export async function reserveUsageOrThrow(
|
||||
userId: string,
|
||||
@@ -378,17 +384,8 @@ export async function reserveUsageOrThrow(
|
||||
if (!isValidFeature(feature)) {
|
||||
throw new QuotaExceededError('PRO', feature, 0, 0, false, { currentTier: 'BASIC' });
|
||||
}
|
||||
|
||||
const { getSystemConfig } = await import('@/lib/config');
|
||||
const { willUseByokForLane } = await import('@/lib/ai/provider-for-user');
|
||||
const config = await getSystemConfig();
|
||||
const lane = featureToAiLane(feature);
|
||||
const { usedByok } = await willUseByokForLane(lane, config, userId);
|
||||
if (usedByok) return;
|
||||
|
||||
const { reserveCreditsOrThrow, resolveCreditCost } = await import('@/lib/credits');
|
||||
const cost = resolveCreditCost(feature, { amount });
|
||||
await reserveCreditsOrThrow(userId, cost, { feature });
|
||||
const { reserveAiUsageOrThrow } = await import('@/lib/ai-quota');
|
||||
await reserveAiUsageOrThrow(userId, feature as FeatureName, { amount });
|
||||
}
|
||||
|
||||
/** Units charged for one multi-step slide generation (outline + per-slide expand). */
|
||||
|
||||
@@ -3318,10 +3318,8 @@
|
||||
"title": "الحساب",
|
||||
"link0": "تسجيل الدخول",
|
||||
"link1": "إنشاء حساب",
|
||||
"link2": "الدعم",
|
||||
"link0Href": "/login",
|
||||
"link1Href": "/register",
|
||||
"link2Href": "/support"
|
||||
"link1Href": "/register"
|
||||
},
|
||||
"legal": {
|
||||
"title": "قانوني",
|
||||
@@ -3685,7 +3683,8 @@
|
||||
"typeForResults": "اكتب للحصول على نتائج فورية.",
|
||||
"hintNavigate": "تنقل",
|
||||
"hintOpen": "فتح",
|
||||
"hintClose": "إغلاق"
|
||||
"hintClose": "إغلاق",
|
||||
"selectResultToPreview": "Select a result to explore its content."
|
||||
},
|
||||
"quotaPaywall": {
|
||||
"title": "تم الوصول إلى الحد الشهري",
|
||||
|
||||
@@ -3318,10 +3318,8 @@
|
||||
"title": "Konto",
|
||||
"link0": "Anmelden",
|
||||
"link1": "Registrieren",
|
||||
"link2": "Support",
|
||||
"link0Href": "/login",
|
||||
"link1Href": "/register",
|
||||
"link2Href": "/support"
|
||||
"link1Href": "/register"
|
||||
},
|
||||
"legal": {
|
||||
"title": "Rechtliches",
|
||||
@@ -3685,7 +3683,8 @@
|
||||
"typeForResults": "Tippen für sofortige Ergebnisse.",
|
||||
"hintNavigate": "navigieren",
|
||||
"hintOpen": "öffnen",
|
||||
"hintClose": "schließen"
|
||||
"hintClose": "schließen",
|
||||
"selectResultToPreview": "Select a result to explore its content."
|
||||
},
|
||||
"quotaPaywall": {
|
||||
"title": "Monatliches Limit erreicht",
|
||||
|
||||
@@ -3585,10 +3585,8 @@
|
||||
"title": "Account",
|
||||
"link0": "Log in",
|
||||
"link1": "Sign up",
|
||||
"link2": "Support",
|
||||
"link0Href": "/login",
|
||||
"link1Href": "/register",
|
||||
"link2Href": "/support"
|
||||
"link1Href": "/register"
|
||||
},
|
||||
"legal": {
|
||||
"title": "Legal",
|
||||
@@ -4492,7 +4490,8 @@
|
||||
"typeForResults": "Type for instant results.",
|
||||
"hintNavigate": "navigate",
|
||||
"hintOpen": "open",
|
||||
"hintClose": "close"
|
||||
"hintClose": "close",
|
||||
"selectResultToPreview": "Select a result to explore its content."
|
||||
},
|
||||
"mobile": {
|
||||
"aiNote": "AI Note",
|
||||
|
||||
@@ -3318,10 +3318,8 @@
|
||||
"title": "Cuenta",
|
||||
"link0": "Iniciar sesión",
|
||||
"link1": "Registrarse",
|
||||
"link2": "Soporte",
|
||||
"link0Href": "/login",
|
||||
"link1Href": "/register",
|
||||
"link2Href": "/support"
|
||||
"link1Href": "/register"
|
||||
},
|
||||
"legal": {
|
||||
"title": "Legal",
|
||||
@@ -3685,7 +3683,8 @@
|
||||
"typeForResults": "Escribe para resultados instantáneos.",
|
||||
"hintNavigate": "navegar",
|
||||
"hintOpen": "abrir",
|
||||
"hintClose": "cerrar"
|
||||
"hintClose": "cerrar",
|
||||
"selectResultToPreview": "Select a result to explore its content."
|
||||
},
|
||||
"quotaPaywall": {
|
||||
"title": "Límite mensual alcanzado",
|
||||
|
||||
@@ -3318,10 +3318,8 @@
|
||||
"title": "حساب",
|
||||
"link0": "ورود",
|
||||
"link1": "ثبتنام",
|
||||
"link2": "پشتیبانی",
|
||||
"link0Href": "/login",
|
||||
"link1Href": "/register",
|
||||
"link2Href": "/support"
|
||||
"link1Href": "/register"
|
||||
},
|
||||
"legal": {
|
||||
"title": "حقوقی",
|
||||
@@ -3685,7 +3683,8 @@
|
||||
"typeForResults": "برای نتایج فوری تایپ کنید.",
|
||||
"hintNavigate": "پیمایش",
|
||||
"hintOpen": "باز کردن",
|
||||
"hintClose": "بستن"
|
||||
"hintClose": "بستن",
|
||||
"selectResultToPreview": "Select a result to explore its content."
|
||||
},
|
||||
"quotaPaywall": {
|
||||
"title": "محدودیت ماهانه رسید",
|
||||
|
||||
@@ -3591,10 +3591,8 @@
|
||||
"title": "Compte",
|
||||
"link0": "Se connecter",
|
||||
"link1": "S'inscrire",
|
||||
"link2": "Support",
|
||||
"link0Href": "/login",
|
||||
"link1Href": "/register",
|
||||
"link2Href": "/support"
|
||||
"link1Href": "/register"
|
||||
},
|
||||
"legal": {
|
||||
"title": "Légal",
|
||||
@@ -4498,7 +4496,8 @@
|
||||
"typeForResults": "Tapez pour des résultats instantanés.",
|
||||
"hintNavigate": "naviguer",
|
||||
"hintOpen": "ouvrir",
|
||||
"hintClose": "fermer"
|
||||
"hintClose": "fermer",
|
||||
"selectResultToPreview": "Sélectionnez un résultat pour explorer son contenu."
|
||||
},
|
||||
"mobile": {
|
||||
"aiNote": "IA Note",
|
||||
|
||||
@@ -3318,10 +3318,8 @@
|
||||
"title": "खाता",
|
||||
"link0": "लॉग इन",
|
||||
"link1": "साइन अप",
|
||||
"link2": "सपोर्ट",
|
||||
"link0Href": "/login",
|
||||
"link1Href": "/register",
|
||||
"link2Href": "/support"
|
||||
"link1Href": "/register"
|
||||
},
|
||||
"legal": {
|
||||
"title": "कानूनी",
|
||||
@@ -3685,7 +3683,8 @@
|
||||
"typeForResults": "तत्काल परिणाम के लिए टाइप करें।",
|
||||
"hintNavigate": "नेविगेट",
|
||||
"hintOpen": "खोलें",
|
||||
"hintClose": "बंद करें"
|
||||
"hintClose": "बंद करें",
|
||||
"selectResultToPreview": "Select a result to explore its content."
|
||||
},
|
||||
"quotaPaywall": {
|
||||
"title": "मासिक सीमा पहुँची",
|
||||
|
||||
@@ -3318,10 +3318,8 @@
|
||||
"title": "Account",
|
||||
"link0": "Accedi",
|
||||
"link1": "Registrati",
|
||||
"link2": "Supporto",
|
||||
"link0Href": "/login",
|
||||
"link1Href": "/register",
|
||||
"link2Href": "/support"
|
||||
"link1Href": "/register"
|
||||
},
|
||||
"legal": {
|
||||
"title": "Legale",
|
||||
@@ -3685,7 +3683,8 @@
|
||||
"typeForResults": "Digita per risultati immediati.",
|
||||
"hintNavigate": "navigare",
|
||||
"hintOpen": "aprire",
|
||||
"hintClose": "chiudere"
|
||||
"hintClose": "chiudere",
|
||||
"selectResultToPreview": "Select a result to explore its content."
|
||||
},
|
||||
"quotaPaywall": {
|
||||
"title": "Limite mensile raggiunto",
|
||||
|
||||
@@ -3318,10 +3318,8 @@
|
||||
"title": "アカウント",
|
||||
"link0": "ログイン",
|
||||
"link1": "登録",
|
||||
"link2": "サポート",
|
||||
"link0Href": "/login",
|
||||
"link1Href": "/register",
|
||||
"link2Href": "/support"
|
||||
"link1Href": "/register"
|
||||
},
|
||||
"legal": {
|
||||
"title": "法務",
|
||||
@@ -3685,7 +3683,8 @@
|
||||
"typeForResults": "入力で即座に結果表示。",
|
||||
"hintNavigate": "移動",
|
||||
"hintOpen": "開く",
|
||||
"hintClose": "閉じる"
|
||||
"hintClose": "閉じる",
|
||||
"selectResultToPreview": "Select a result to explore its content."
|
||||
},
|
||||
"quotaPaywall": {
|
||||
"title": "月制限に達しました",
|
||||
|
||||
@@ -3318,10 +3318,8 @@
|
||||
"title": "계정",
|
||||
"link0": "로그인",
|
||||
"link1": "가입",
|
||||
"link2": "지원",
|
||||
"link0Href": "/login",
|
||||
"link1Href": "/register",
|
||||
"link2Href": "/support"
|
||||
"link1Href": "/register"
|
||||
},
|
||||
"legal": {
|
||||
"title": "법률",
|
||||
@@ -3685,7 +3683,8 @@
|
||||
"typeForResults": "입력하여 즉시 결과 표시.",
|
||||
"hintNavigate": "탐색",
|
||||
"hintOpen": "열기",
|
||||
"hintClose": "닫기"
|
||||
"hintClose": "닫기",
|
||||
"selectResultToPreview": "Select a result to explore its content."
|
||||
},
|
||||
"quotaPaywall": {
|
||||
"title": "월별 한도에 도달했습니다",
|
||||
|
||||
@@ -3318,10 +3318,8 @@
|
||||
"title": "Account",
|
||||
"link0": "Inloggen",
|
||||
"link1": "Registreren",
|
||||
"link2": "Support",
|
||||
"link0Href": "/login",
|
||||
"link1Href": "/register",
|
||||
"link2Href": "/support"
|
||||
"link1Href": "/register"
|
||||
},
|
||||
"legal": {
|
||||
"title": "Juridisch",
|
||||
@@ -3685,7 +3683,8 @@
|
||||
"typeForResults": "Typ voor directe resultaten.",
|
||||
"hintNavigate": "navigeren",
|
||||
"hintOpen": "openen",
|
||||
"hintClose": "sluiten"
|
||||
"hintClose": "sluiten",
|
||||
"selectResultToPreview": "Select a result to explore its content."
|
||||
},
|
||||
"quotaPaywall": {
|
||||
"title": "Maandelijkse limiet bereikt",
|
||||
|
||||
@@ -3318,10 +3318,8 @@
|
||||
"title": "Konto",
|
||||
"link0": "Zaloguj się",
|
||||
"link1": "Zarejestruj się",
|
||||
"link2": "Wsparcie",
|
||||
"link0Href": "/login",
|
||||
"link1Href": "/register",
|
||||
"link2Href": "/support"
|
||||
"link1Href": "/register"
|
||||
},
|
||||
"legal": {
|
||||
"title": "Prawne",
|
||||
@@ -3685,7 +3683,8 @@
|
||||
"typeForResults": "Zacznij pisać dla natychmiastowych wyników.",
|
||||
"hintNavigate": "nawiguj",
|
||||
"hintOpen": "otwórz",
|
||||
"hintClose": "zamknij"
|
||||
"hintClose": "zamknij",
|
||||
"selectResultToPreview": "Select a result to explore its content."
|
||||
},
|
||||
"quotaPaywall": {
|
||||
"title": "Osiągnięto miesięczny limit",
|
||||
|
||||
@@ -3318,10 +3318,8 @@
|
||||
"title": "Conta",
|
||||
"link0": "Entrar",
|
||||
"link1": "Cadastrar",
|
||||
"link2": "Suporte",
|
||||
"link0Href": "/login",
|
||||
"link1Href": "/register",
|
||||
"link2Href": "/support"
|
||||
"link1Href": "/register"
|
||||
},
|
||||
"legal": {
|
||||
"title": "Legal",
|
||||
@@ -3685,7 +3683,8 @@
|
||||
"typeForResults": "Digite para resultados instantâneos.",
|
||||
"hintNavigate": "navegar",
|
||||
"hintOpen": "abrir",
|
||||
"hintClose": "fechar"
|
||||
"hintClose": "fechar",
|
||||
"selectResultToPreview": "Select a result to explore its content."
|
||||
},
|
||||
"quotaPaywall": {
|
||||
"title": "Limite mensal atingido",
|
||||
|
||||
@@ -3318,10 +3318,8 @@
|
||||
"title": "Аккаунт",
|
||||
"link0": "Войти",
|
||||
"link1": "Регистрация",
|
||||
"link2": "Поддержка",
|
||||
"link0Href": "/login",
|
||||
"link1Href": "/register",
|
||||
"link2Href": "/support"
|
||||
"link1Href": "/register"
|
||||
},
|
||||
"legal": {
|
||||
"title": "Правовое",
|
||||
@@ -3685,7 +3683,8 @@
|
||||
"typeForResults": "Введите для мгновенных результатов.",
|
||||
"hintNavigate": "навигация",
|
||||
"hintOpen": "открыть",
|
||||
"hintClose": "закрыть"
|
||||
"hintClose": "закрыть",
|
||||
"selectResultToPreview": "Select a result to explore its content."
|
||||
},
|
||||
"quotaPaywall": {
|
||||
"title": "Достигнут месячный лимит",
|
||||
|
||||
@@ -3318,10 +3318,8 @@
|
||||
"title": "账户",
|
||||
"link0": "登录",
|
||||
"link1": "注册",
|
||||
"link2": "支持",
|
||||
"link0Href": "/login",
|
||||
"link1Href": "/register",
|
||||
"link2Href": "/support"
|
||||
"link1Href": "/register"
|
||||
},
|
||||
"legal": {
|
||||
"title": "法律",
|
||||
@@ -3685,7 +3683,8 @@
|
||||
"typeForResults": "输入获取即时结果。",
|
||||
"hintNavigate": "导航",
|
||||
"hintOpen": "打开",
|
||||
"hintClose": "关闭"
|
||||
"hintClose": "关闭",
|
||||
"selectResultToPreview": "Select a result to explore its content."
|
||||
},
|
||||
"quotaPaywall": {
|
||||
"title": "已达到月度限制",
|
||||
|
||||
@@ -5,10 +5,9 @@ const nextConfig: NextConfig = {
|
||||
output: 'standalone',
|
||||
|
||||
|
||||
// Pre-existing TS errors in 3rd-party import paths (next/cache cookies, AI SDK v6 maxSteps)
|
||||
// are false positives that don't affect runtime — skip type-check at build time
|
||||
// Type-check at build time (TS errors block the build)
|
||||
typescript: {
|
||||
ignoreBuildErrors: true,
|
||||
ignoreBuildErrors: false,
|
||||
},
|
||||
|
||||
// These server-side packages use Node.js internals (buffers, native modules, etc.)
|
||||
@@ -77,9 +76,10 @@ const nextConfig: NextConfig = {
|
||||
// Hide the "compiling" indicator
|
||||
devIndicators: false,
|
||||
|
||||
// Disable strict mode: React 19 strict mode can cause double-invocation of render
|
||||
// functions during concurrent transitions, amplifying timing issues.
|
||||
reactStrictMode: false,
|
||||
// React strict mode: double-invokes effects/renders in DEV ONLY (zero prod impact).
|
||||
// Catches missing cleanups and side-effects in render. May surface pre-existing
|
||||
// dev-mode issues that were hidden when this was disabled.
|
||||
reactStrictMode: true,
|
||||
|
||||
// Allow development origins for HMR and Dev Server access
|
||||
allowedDevOrigins: ['192.168.1.83'],
|
||||
|
||||
@@ -33,8 +33,28 @@ vi.mock('@/lib/byok', () => ({
|
||||
hasAnyActiveByok: vi.fn().mockResolvedValue(false),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/config', () => ({
|
||||
getSystemConfig: vi.fn().mockResolvedValue({
|
||||
AI_PROVIDER_CHAT: 'openai',
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/ai/provider-for-user', () => ({
|
||||
willUseByokForLane: vi.fn().mockResolvedValue({
|
||||
providerType: 'openai',
|
||||
usedByok: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/credits', () => ({
|
||||
reserveCreditsOrThrow: vi.fn(),
|
||||
resolveCreditCost: vi.fn().mockReturnValue(1),
|
||||
releaseCredits: vi.fn(),
|
||||
}))
|
||||
|
||||
import { redis } from '@/lib/redis'
|
||||
import { hasAnyActiveByok } from '@/lib/byok'
|
||||
import { willUseByokForLane } from '@/lib/ai/provider-for-user'
|
||||
import { reserveCreditsOrThrow } from '@/lib/credits'
|
||||
|
||||
function mockActiveSubscription(tier: string) {
|
||||
prismaMock.subscription.findUnique.mockResolvedValue({
|
||||
@@ -49,6 +69,14 @@ function mockActiveSubscription(tier: string) {
|
||||
describe('brainstorm host-pays billing (Story 3.4)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(willUseByokForLane).mockResolvedValue({
|
||||
providerType: 'openai',
|
||||
usedByok: false,
|
||||
})
|
||||
vi.mocked(reserveCreditsOrThrow).mockResolvedValue({
|
||||
cost: 1,
|
||||
unlimited: false,
|
||||
})
|
||||
})
|
||||
|
||||
describe('billingOwnerFromSession', () => {
|
||||
@@ -79,7 +107,11 @@ describe('brainstorm host-pays billing (Story 3.4)', () => {
|
||||
describe('checkSessionEntitlementOrThrow', () => {
|
||||
it('throws QuotaExceededError with guest metadata when host quota exhausted', async () => {
|
||||
mockActiveSubscription('BASIC')
|
||||
vi.mocked(redis.eval).mockResolvedValue(-1)
|
||||
vi.mocked(reserveCreditsOrThrow).mockRejectedValue(
|
||||
new QuotaExceededError('PRO', 'brainstorm_expand', 10, 10, false, {
|
||||
currentTier: 'BASIC',
|
||||
}),
|
||||
)
|
||||
|
||||
await expect(
|
||||
checkSessionEntitlementOrThrow('host-1', 'guest-9', true, 'brainstorm_expand'),
|
||||
@@ -90,49 +122,43 @@ describe('brainstorm host-pays billing (Story 3.4)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('increments host redis key when guest action bills host', async () => {
|
||||
it('increments host credits when guest action bills host', async () => {
|
||||
mockActiveSubscription('PRO')
|
||||
vi.mocked(redis.eval).mockResolvedValue(1)
|
||||
|
||||
await checkSessionEntitlementOrThrow('host-1', 'guest-9', true, 'brainstorm_expand')
|
||||
|
||||
expect(redis.eval).toHaveBeenCalled()
|
||||
const keyArg = String(vi.mocked(redis.eval).mock.calls[0]?.[2])
|
||||
expect(keyArg).toContain('host-1')
|
||||
expect(keyArg).not.toContain('guest-9')
|
||||
|
||||
const allKeyArgs = vi.mocked(redis.eval).mock.calls.map(call => String(call[2]))
|
||||
const guestKeyUsed = allKeyArgs.some(k => k.includes('guest-9'))
|
||||
expect(guestKeyUsed).toBe(false)
|
||||
expect(reserveCreditsOrThrow).toHaveBeenCalledTimes(1)
|
||||
const [billedUserId, , options] = vi.mocked(reserveCreditsOrThrow).mock.calls[0]
|
||||
expect(billedUserId).toBe('host-1')
|
||||
expect(options).toMatchObject({ feature: 'brainstorm_expand' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('host BYOK bypass (Story 3.5)', () => {
|
||||
it('reserveUsage still runs when host has BYOK (bypass is per-provider in route layer)', async () => {
|
||||
vi.mocked(hasAnyActiveByok).mockResolvedValue(true)
|
||||
mockActiveSubscription('BASIC')
|
||||
vi.mocked(redis.eval).mockResolvedValue(-1)
|
||||
|
||||
await expect(
|
||||
checkSessionEntitlementOrThrow('host-1', 'guest-9', true, 'brainstorm_expand'),
|
||||
).rejects.toThrow()
|
||||
it('does not debit credits when host has active BYOK', async () => {
|
||||
vi.mocked(willUseByokForLane).mockResolvedValue({
|
||||
providerType: 'openai',
|
||||
usedByok: true,
|
||||
})
|
||||
|
||||
it('AC10: host BYOK + guest quota empty still bills host (guest has no quota, host pays)', async () => {
|
||||
vi.mocked(hasAnyActiveByok).mockResolvedValue(true)
|
||||
mockActiveSubscription('PRO')
|
||||
vi.mocked(redis.eval).mockResolvedValue(1)
|
||||
|
||||
// Guest's quota is empty (simulated by checking guest's quota returns 0)
|
||||
vi.mocked(redis.get).mockResolvedValue('0')
|
||||
mockActiveSubscription('BASIC')
|
||||
vi.mocked(reserveCreditsOrThrow).mockRejectedValue(
|
||||
new Error('reserveCreditsOrThrow should not be called when BYOK is active'),
|
||||
)
|
||||
|
||||
await checkSessionEntitlementOrThrow('host-1', 'guest-9', true, 'brainstorm_expand')
|
||||
|
||||
// Verify that host's redis key was incremented, not guest's
|
||||
expect(redis.eval).toHaveBeenCalled()
|
||||
const keyArg = String(vi.mocked(redis.eval).mock.calls[0]?.[2])
|
||||
expect(keyArg).toContain('host-1')
|
||||
expect(keyArg).not.toContain('guest-9')
|
||||
expect(reserveCreditsOrThrow).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('AC10: guest action bills host, not guest', async () => {
|
||||
mockActiveSubscription('PRO')
|
||||
|
||||
await checkSessionEntitlementOrThrow('host-1', 'guest-9', true, 'brainstorm_expand')
|
||||
|
||||
expect(reserveCreditsOrThrow).toHaveBeenCalledTimes(1)
|
||||
const [billedUserId] = vi.mocked(reserveCreditsOrThrow).mock.calls[0]
|
||||
expect(billedUserId).toBe('host-1')
|
||||
expect(billedUserId).not.toBe('guest-9')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user