feat(dashboard): tableau de bord Second Brain configurable avec chargement progressif
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 7m3s
CI / Deploy production (on server) (push) Successful in 23s

Briefing granulaire, pistes rapides puis enrichissement async, layout persisté v5,
suggestions agents, intégration Gmail et navigation sidebar alignée sur /home.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Antigravity
2026-07-14 16:50:53 +00:00
parent d38a99586b
commit 30da592ba2
62 changed files with 7741 additions and 335 deletions

View File

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

View File

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

View File

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