feat(dashboard): tableau de bord Second Brain configurable avec chargement progressif
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:
204
memento-note/app/api/briefing/paths/route.ts
Normal file
204
memento-note/app/api/briefing/paths/route.ts
Normal 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(/ /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)
|
||||
}
|
||||
240
memento-note/app/api/briefing/route.ts
Normal file
240
memento-note/app/api/briefing/route.ts
Normal 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(/ /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,
|
||||
})),
|
||||
})
|
||||
}
|
||||
87
memento-note/app/api/briefing/sentiment/route.ts
Normal file
87
memento-note/app/api/briefing/sentiment/route.ts
Normal 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(/ /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' })
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user