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:
116
memento-note/lib/ai/memory-echo-i18n.ts
Normal file
116
memento-note/lib/ai/memory-echo-i18n.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import type { SupportedLanguage } from '@/lib/i18n/load-translations'
|
||||
|
||||
/** Texte de secours quand le LLM ne renvoie rien — une entrée par locale supportée. */
|
||||
export const MEMORY_ECHO_INSIGHT_FALLBACKS: Record<SupportedLanguage, string> = {
|
||||
en: 'These notes appear to be semantically related.',
|
||||
fr: 'Ces notes semblent sémantiquement liées.',
|
||||
es: 'Estas notas parecen estar relacionadas semánticamente.',
|
||||
de: 'Diese Notizen scheinen semantisch miteinander verbunden zu sein.',
|
||||
it: 'Queste note sembrano semanticamente collegate.',
|
||||
pt: 'Estas notas parecem estar semanticamente relacionadas.',
|
||||
nl: 'Deze notities lijken semantisch met elkaar verbonden.',
|
||||
pl: 'Te notatki wydają się semantycznie powiązane.',
|
||||
ru: 'Эти заметки кажутся семантическement связанными.',
|
||||
zh: '这些笔记在语义上似乎相关。',
|
||||
ja: 'これらのノートは意味的に関連しているようです。',
|
||||
ko: '이 노트들은 의미적으로 관련된 것으로 보입니다.',
|
||||
ar: 'يبدو أن هذه الملاحظات مرتبطة دلاليًا.',
|
||||
fa: 'این یادداشتها از نظر معنایی به هم مرتبط به نظر میرسند.',
|
||||
hi: 'ये नोट्स अर्थ के हिसाब से जुड़े हुए लगते हैं।',
|
||||
}
|
||||
|
||||
/** Anciens insights stockés en anglais — à remapper côté UI. */
|
||||
export const MEMORY_ECHO_LEGACY_EN_FALLBACKS = new Set([
|
||||
'These notes appear to be semantically related.',
|
||||
])
|
||||
|
||||
const PROMPT_LANGUAGE_LABEL: Record<SupportedLanguage, string> = {
|
||||
en: 'English',
|
||||
fr: 'French',
|
||||
es: 'Spanish',
|
||||
de: 'German',
|
||||
it: 'Italian',
|
||||
pt: 'Portuguese',
|
||||
nl: 'Dutch',
|
||||
pl: 'Polish',
|
||||
ru: 'Russian',
|
||||
zh: 'Chinese',
|
||||
ja: 'Japanese',
|
||||
ko: 'Korean',
|
||||
ar: 'Arabic',
|
||||
fa: 'Persian',
|
||||
hi: 'Hindi',
|
||||
}
|
||||
|
||||
const UNTITLED_NOTE: Record<SupportedLanguage, string> = {
|
||||
en: 'Untitled note',
|
||||
fr: 'Note sans titre',
|
||||
es: 'Nota sin título',
|
||||
de: 'Unbenannte Notiz',
|
||||
it: 'Nota senza titolo',
|
||||
pt: 'Nota sem título',
|
||||
nl: 'Naamloze notitie',
|
||||
pl: 'Notatka bez tytułu',
|
||||
ru: 'Без названия',
|
||||
zh: '无标题笔记',
|
||||
ja: '無題のノート',
|
||||
ko: '제목 없는 노트',
|
||||
ar: 'ملاحظة بدون عنوان',
|
||||
fa: 'یادداشت بدون عنوان',
|
||||
hi: 'शीर्षक रहित नोट',
|
||||
}
|
||||
|
||||
export function getMemoryEchoInsightFallback(language: SupportedLanguage): string {
|
||||
return MEMORY_ECHO_INSIGHT_FALLBACKS[language] ?? MEMORY_ECHO_INSIGHT_FALLBACKS.en
|
||||
}
|
||||
|
||||
export function getUntitledNoteLabel(language: SupportedLanguage): string {
|
||||
return UNTITLED_NOTE[language] ?? UNTITLED_NOTE.en
|
||||
}
|
||||
|
||||
export function buildMemoryEchoInsightPrompt(
|
||||
language: SupportedLanguage,
|
||||
note1Title: string | null,
|
||||
excerpt1: string,
|
||||
note2Title: string | null,
|
||||
excerpt2: string,
|
||||
): string {
|
||||
const untitled = getUntitledNoteLabel(language)
|
||||
const note1Desc = note1Title || untitled
|
||||
const note2Desc = note2Title || untitled
|
||||
const langLabel = PROMPT_LANGUAGE_LABEL[language] ?? 'English'
|
||||
|
||||
if (language === 'fa' || language === 'ar') {
|
||||
return `تو یک دستیار هستی که ارتباط بین یادداشتها را تحلیل میکنی.
|
||||
|
||||
یادداشت ۱: «${note1Desc}»
|
||||
متن: ${excerpt1}
|
||||
|
||||
یادداشت ۲: «${note2Desc}»
|
||||
متن: ${excerpt2}
|
||||
|
||||
در یک جمله کوتاه (حداکثر ۱۵ کلمه) به ${language === 'fa' ? 'فارسی' : 'العربية'} توضیح بده چرا این دو یادداشت به هم مرتبطاند. فقط رابطه معنایی را بگو.`
|
||||
}
|
||||
|
||||
if (language === 'fr') {
|
||||
return `Tu analyses les connexions entre notes.
|
||||
|
||||
Note 1 : « ${note1Desc} »
|
||||
Contenu : ${excerpt1}
|
||||
|
||||
Note 2 : « ${note2Desc} »
|
||||
Contenu : ${excerpt2}
|
||||
|
||||
En une phrase courte (15 mots max), en français, explique pourquoi ces notes sont liées. Concentre-toi sur le lien sémantique.`
|
||||
}
|
||||
|
||||
return `You analyze connections between notes.
|
||||
|
||||
Note 1: "${note1Desc}"
|
||||
Content: ${excerpt1}
|
||||
|
||||
Note 2: "${note2Desc}"
|
||||
Content: ${excerpt2}
|
||||
|
||||
Reply in ${langLabel} only. One brief sentence (max 15 words) explaining the semantic relationship between these notes.`
|
||||
}
|
||||
216
memento-note/lib/ai/services/agent-suggestion.service.ts
Normal file
216
memento-note/lib/ai/services/agent-suggestion.service.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* Agent Suggestion Service
|
||||
*
|
||||
* Proposes research/monitor agents from existing semantic clusters (no duplicate DBSCAN).
|
||||
* Runs after clustering; compares cluster themes vs existing agents.
|
||||
*/
|
||||
|
||||
import prisma from '@/lib/prisma'
|
||||
import { clusteringService } from './clustering.service'
|
||||
import { getChatProvider } from '@/lib/ai/factory'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
import { calculateNextRun } from '@/lib/agents/schedule'
|
||||
|
||||
const MIN_CLUSTER_NOTES = 3
|
||||
const MAX_SUGGESTIONS_PER_RUN = 3
|
||||
|
||||
export interface AgentSuggestionPayload {
|
||||
topic: string
|
||||
reason: string
|
||||
suggestedRole: string
|
||||
suggestedType: string
|
||||
suggestedFrequency: string
|
||||
relatedNoteIds: string[]
|
||||
clusterId: number
|
||||
}
|
||||
|
||||
function parseJsonArray(raw: string | null | undefined): string[] {
|
||||
if (!raw) return []
|
||||
try {
|
||||
const parsed = JSON.parse(raw)
|
||||
return Array.isArray(parsed) ? parsed.filter((x): x is string => typeof x === 'string') : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function clusterCoveredByAgent(
|
||||
topic: string,
|
||||
noteIds: string[],
|
||||
agent: { name: string; role: string; description: string | null; sourceNoteIds: string | null },
|
||||
): boolean {
|
||||
const topicLower = topic.toLowerCase()
|
||||
const haystack = `${agent.name} ${agent.role} ${agent.description || ''}`.toLowerCase()
|
||||
const topicWords = topicLower.split(/\s+/).filter(w => w.length > 3)
|
||||
if (topicWords.some(w => haystack.includes(w))) return true
|
||||
|
||||
const agentNoteIds = parseJsonArray(agent.sourceNoteIds)
|
||||
if (agentNoteIds.length === 0 || noteIds.length === 0) return false
|
||||
const overlap = agentNoteIds.filter(id => noteIds.includes(id)).length
|
||||
return overlap / noteIds.length >= 0.4
|
||||
}
|
||||
|
||||
async function buildSuggestionWithLlm(
|
||||
topic: string,
|
||||
noteTitles: string[],
|
||||
noteCount: number,
|
||||
): Promise<Pick<AgentSuggestionPayload, 'reason' | 'suggestedRole' | 'suggestedType' | 'suggestedFrequency'>> {
|
||||
const fallback = {
|
||||
reason: `${noteCount} notes récentes sur ce thème — aucun agent ne le couvre encore.`,
|
||||
suggestedRole: `Recherche et synthétise les dernières informations sur « ${topic} ». Croise avec les notes existantes de l'utilisateur et produis un résumé actionnable.`,
|
||||
suggestedType: 'researcher',
|
||||
suggestedFrequency: 'weekly',
|
||||
}
|
||||
|
||||
try {
|
||||
const config = await getSystemConfig()
|
||||
const provider = getChatProvider(config)
|
||||
if (!provider) return fallback
|
||||
|
||||
const prompt = `Tu proposes un agent IA pour un second cerveau (prise de notes).
|
||||
Thème détecté: "${topic}"
|
||||
Notes liées (titres): ${noteTitles.slice(0, 5).join(' | ') || 'sans titre'}
|
||||
Nombre de notes: ${noteCount}
|
||||
|
||||
Retourne UNIQUEMENT du JSON valide:
|
||||
{"reason":"1 phrase pourquoi un agent est utile","suggestedRole":"prompt système de l'agent (2-3 phrases, français)","suggestedType":"researcher|monitor","suggestedFrequency":"daily|weekly"}`
|
||||
|
||||
const raw = await provider.generateText(prompt)
|
||||
const match = raw.match(/\{[\s\S]*\}/)
|
||||
if (!match) return fallback
|
||||
const parsed = JSON.parse(match[0])
|
||||
return {
|
||||
reason: String(parsed.reason || fallback.reason).slice(0, 500),
|
||||
suggestedRole: String(parsed.suggestedRole || fallback.suggestedRole).slice(0, 2000),
|
||||
suggestedType: parsed.suggestedType === 'monitor' ? 'monitor' : 'researcher',
|
||||
suggestedFrequency: ['daily', 'weekly'].includes(parsed.suggestedFrequency) ? parsed.suggestedFrequency : 'weekly',
|
||||
}
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
export class AgentSuggestionService {
|
||||
async generateForUser(userId: string): Promise<number> {
|
||||
const stored = await clusteringService.getStoredClusters(userId)
|
||||
if (!stored || stored.clusters.length === 0) return 0
|
||||
|
||||
const agents = await prisma.agent.findMany({
|
||||
where: { userId, isEnabled: true },
|
||||
select: { name: true, role: true, description: true, sourceNoteIds: true },
|
||||
})
|
||||
|
||||
const candidates = stored.clusters
|
||||
.filter(c => c.noteIds.length >= MIN_CLUSTER_NOTES)
|
||||
.sort((a, b) => b.noteIds.length - a.noteIds.length)
|
||||
|
||||
let created = 0
|
||||
for (const cluster of candidates) {
|
||||
if (created >= MAX_SUGGESTIONS_PER_RUN) break
|
||||
|
||||
const topic = cluster.name || `Thème ${cluster.clusterId + 1}`
|
||||
const covered = agents.some(a => clusterCoveredByAgent(topic, cluster.noteIds, a))
|
||||
if (covered) continue
|
||||
|
||||
const existing = await prisma.agentSuggestion.findUnique({
|
||||
where: { userId_clusterId: { userId, clusterId: cluster.clusterId } },
|
||||
})
|
||||
if (existing && existing.status !== 'pending') continue
|
||||
|
||||
const notes = await prisma.note.findMany({
|
||||
where: { id: { in: cluster.noteIds.slice(0, 8) }, userId },
|
||||
select: { title: true },
|
||||
})
|
||||
const llm = await buildSuggestionWithLlm(
|
||||
topic,
|
||||
notes.map(n => n.title || 'Sans titre'),
|
||||
cluster.noteIds.length,
|
||||
)
|
||||
|
||||
const payload = {
|
||||
topic,
|
||||
reason: llm.reason,
|
||||
suggestedRole: llm.suggestedRole,
|
||||
suggestedType: llm.suggestedType,
|
||||
suggestedFrequency: llm.suggestedFrequency,
|
||||
relatedNoteIds: JSON.stringify(cluster.noteIds.slice(0, 20)),
|
||||
status: 'pending' as const,
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
await prisma.agentSuggestion.update({
|
||||
where: { id: existing.id },
|
||||
data: payload,
|
||||
})
|
||||
} else {
|
||||
await prisma.agentSuggestion.create({
|
||||
data: { userId, clusterId: cluster.clusterId, ...payload },
|
||||
})
|
||||
}
|
||||
created++
|
||||
}
|
||||
|
||||
return created
|
||||
}
|
||||
|
||||
async getPending(userId: string, limit = 3) {
|
||||
return prisma.agentSuggestion.findMany({
|
||||
where: { userId, status: 'pending' },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
take: limit,
|
||||
})
|
||||
}
|
||||
|
||||
async dismiss(userId: string, id: string) {
|
||||
const row = await prisma.agentSuggestion.findFirst({ where: { id, userId } })
|
||||
if (!row) return null
|
||||
return prisma.agentSuggestion.update({
|
||||
where: { id },
|
||||
data: { status: 'dismissed' },
|
||||
})
|
||||
}
|
||||
|
||||
async accept(userId: string, id: string): Promise<{ agentId: string } | null> {
|
||||
const row = await prisma.agentSuggestion.findFirst({ where: { id, userId, status: 'pending' } })
|
||||
if (!row) return null
|
||||
|
||||
const noteIds = parseJsonArray(row.relatedNoteIds)
|
||||
const agent = await prisma.agent.create({
|
||||
data: {
|
||||
userId,
|
||||
name: `Recherche — ${row.topic}`.slice(0, 80),
|
||||
description: row.reason,
|
||||
type: row.suggestedType,
|
||||
role: row.suggestedRole,
|
||||
frequency: row.suggestedFrequency,
|
||||
sourceNoteIds: noteIds.length > 0 ? JSON.stringify(noteIds) : null,
|
||||
tools: JSON.stringify(['web_search', 'read_url']),
|
||||
isEnabled: true,
|
||||
scheduledTime: '08:00',
|
||||
},
|
||||
})
|
||||
|
||||
if (row.suggestedFrequency !== 'manual') {
|
||||
const nextRun = calculateNextRun({
|
||||
frequency: row.suggestedFrequency,
|
||||
scheduledTime: '08:00',
|
||||
})
|
||||
if (nextRun) {
|
||||
await prisma.agent.update({ where: { id: agent.id }, data: { nextRun } })
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.agentSuggestion.update({
|
||||
where: { id: row.id },
|
||||
data: { status: 'accepted' },
|
||||
})
|
||||
|
||||
import('@/lib/ai/services/agent-executor.service')
|
||||
.then(({ executeAgent }) => executeAgent(agent.id, userId))
|
||||
.catch(err => console.error('[AgentSuggestion] execute failed:', err))
|
||||
|
||||
return { agentId: agent.id }
|
||||
}
|
||||
}
|
||||
|
||||
export const agentSuggestionService = new AgentSuggestionService()
|
||||
@@ -10,6 +10,12 @@ import {
|
||||
prepareNoteTextForEmbedding,
|
||||
} from '@/lib/text/plain-text'
|
||||
import { detectTextDirection } from '@/lib/clip/rtl-content'
|
||||
import { detectUserLanguage } from '@/lib/i18n/detect-user-language'
|
||||
import type { SupportedLanguage } from '@/lib/i18n/load-translations'
|
||||
import {
|
||||
buildMemoryEchoInsightPrompt,
|
||||
getMemoryEchoInsightFallback,
|
||||
} from '@/lib/ai/memory-echo-i18n'
|
||||
import {
|
||||
SEMANTIC_SIMILARITY_FLOOR_CLIP,
|
||||
SEMANTIC_SIMILARITY_FLOOR_DEMO,
|
||||
@@ -334,38 +340,16 @@ export class MemoryEchoService {
|
||||
note1Title: string | null,
|
||||
note1Content: string,
|
||||
note2Title: string | null,
|
||||
note2Content: string
|
||||
note2Content: string,
|
||||
language: SupportedLanguage = 'en',
|
||||
): Promise<string> {
|
||||
try {
|
||||
const config = await getSystemConfig()
|
||||
const provider = getChatProvider(config)
|
||||
|
||||
const note1Desc = note1Title || 'Untitled note'
|
||||
const note2Desc = note2Title || 'Untitled note'
|
||||
const excerpt1 = excerptPlainNoteContent(note1Title, note1Content, 1200)
|
||||
const excerpt2 = excerptPlainNoteContent(note2Title, note2Content, 1200)
|
||||
const directionSample = `${note1Desc}\n${excerpt1}\n${note2Desc}\n${excerpt2}`
|
||||
const isRtl = detectTextDirection(directionSample) === 'rtl'
|
||||
|
||||
const prompt = isRtl
|
||||
? `تو یک دستیار هستی که ارتباط بین یادداشتها را تحلیل میکنی.
|
||||
|
||||
یادداشت ۱: «${note1Desc}»
|
||||
متن: ${excerpt1}
|
||||
|
||||
یادداشت ۲: «${note2Desc}»
|
||||
متن: ${excerpt2}
|
||||
|
||||
در یک جمله کوتاه (حداکثر ۱۵ کلمه) به فارسی توضیح بده چرا این دو یادداشت به هم مرتبطاند. فقط رابطه معنایی را بگو.`
|
||||
: `You are a helpful assistant analyzing connections between notes.
|
||||
|
||||
Note 1: "${note1Desc}"
|
||||
Content: ${excerpt1}
|
||||
|
||||
Note 2: "${note2Desc}"
|
||||
Content: ${excerpt2}
|
||||
|
||||
Explain in one brief sentence (max 15 words) why these notes are connected. Focus on the semantic relationship.`
|
||||
const prompt = buildMemoryEchoInsightPrompt(language, note1Title, excerpt1, note2Title, excerpt2)
|
||||
|
||||
const response = await provider.generateText(prompt)
|
||||
|
||||
@@ -375,20 +359,15 @@ Explain in one brief sentence (max 15 words) why these notes are connected. Focu
|
||||
.trim()
|
||||
.substring(0, 150)
|
||||
|
||||
const fallback = isRtl
|
||||
? 'این یادداشتها از نظر معنایی به هم مرتبط به نظر میرسند.'
|
||||
: 'These notes appear to be semantically related.'
|
||||
|
||||
return insight || fallback
|
||||
return insight || getMemoryEchoInsightFallback(language)
|
||||
|
||||
} catch (error) {
|
||||
console.error('[MemoryEcho] Failed to generate insight:', error)
|
||||
const sample = excerptPlainNoteContent(note1Title, note1Content, 200)
|
||||
+ excerptPlainNoteContent(note2Title, note2Content, 200)
|
||||
if (detectTextDirection(sample) === 'rtl') {
|
||||
return 'این یادداشتها از نظر معنایی به هم مرتبط به نظر میرسند.'
|
||||
}
|
||||
return 'These notes appear to be semantically related.'
|
||||
const rtl = detectTextDirection(sample) === 'rtl'
|
||||
if (rtl) return getMemoryEchoInsightFallback(language === 'fa' || language === 'ar' ? language : 'fa')
|
||||
return getMemoryEchoInsightFallback(language)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -484,12 +463,14 @@ Explain in one brief sentence (max 15 words) why these notes are connected. Focu
|
||||
return null // All connections already shown
|
||||
}
|
||||
|
||||
// Generate AI insight
|
||||
// Generate AI insight in user's language
|
||||
const userLanguage = await detectUserLanguage()
|
||||
const insightText = await this.generateInsight(
|
||||
newConnection.note1.title,
|
||||
newConnection.note1.content,
|
||||
newConnection.note2.title,
|
||||
newConnection.note2.content || ''
|
||||
newConnection.note2.content || '',
|
||||
userLanguage,
|
||||
)
|
||||
|
||||
// Store insight in database
|
||||
|
||||
17
memento-note/lib/dashboard/home-route.ts
Normal file
17
memento-note/lib/dashboard/home-route.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
/** True when /home shows the Second Brain dashboard (not list, editor, or filters). */
|
||||
export function isDashboardHomeRoute(
|
||||
pathname: string,
|
||||
searchParams: Pick<URLSearchParams, 'get'>,
|
||||
): boolean {
|
||||
if (pathname !== '/home') return false
|
||||
return (
|
||||
searchParams.get('forceList') !== '1' &&
|
||||
!searchParams.get('notebook') &&
|
||||
!searchParams.get('search') &&
|
||||
!searchParams.get('labels') &&
|
||||
!searchParams.get('shared') &&
|
||||
!searchParams.get('reminders') &&
|
||||
!searchParams.get('color') &&
|
||||
!searchParams.get('openNote')
|
||||
)
|
||||
}
|
||||
297
memento-note/lib/dashboard/layout.ts
Normal file
297
memento-note/lib/dashboard/layout.ts
Normal file
@@ -0,0 +1,297 @@
|
||||
export const DASHBOARD_LAYOUT_VERSION = 5 as const
|
||||
|
||||
/** Widgets visibles dans la mise en page par défaut (réf. prototype / capture utilisateur). */
|
||||
export const CANONICAL_VISIBLE_WIDGET_IDS: readonly DashboardWidgetId[] = [
|
||||
'capture',
|
||||
'next-paths',
|
||||
'intelligence',
|
||||
'resume',
|
||||
'mind-map',
|
||||
'sentiment',
|
||||
'inbox',
|
||||
'revision',
|
||||
'stats',
|
||||
'reminders',
|
||||
'flashcards-progress',
|
||||
'agents',
|
||||
] as const
|
||||
|
||||
export type DashboardWidgetId =
|
||||
| 'capture'
|
||||
| 'next-paths'
|
||||
| 'resume'
|
||||
| 'intelligence'
|
||||
| 'reminders'
|
||||
| 'mind-map'
|
||||
| 'agents'
|
||||
| 'sentiment'
|
||||
| 'inbox'
|
||||
| 'revision'
|
||||
| 'stats'
|
||||
| 'agent-activity'
|
||||
| 'gmail'
|
||||
| 'activity'
|
||||
| 'pinned'
|
||||
| 'usage'
|
||||
| 'daily-review'
|
||||
| 'open-loops'
|
||||
| 'daily-note'
|
||||
| 'link-suggestions'
|
||||
| 'bridges'
|
||||
| 'flashcards-progress'
|
||||
|
||||
export type DashboardWidgetZone = 'full' | 'main' | 'side'
|
||||
|
||||
export type DashboardWidgetCategory =
|
||||
| 'essential'
|
||||
| 'paths'
|
||||
| 'cognition'
|
||||
| 'productivity'
|
||||
| 'agents'
|
||||
|
||||
export interface DashboardWidgetPlacement {
|
||||
id: DashboardWidgetId
|
||||
visible: boolean
|
||||
order: number
|
||||
zone: DashboardWidgetZone
|
||||
}
|
||||
|
||||
export interface DashboardLayout {
|
||||
version: typeof DASHBOARD_LAYOUT_VERSION
|
||||
widgets: DashboardWidgetPlacement[]
|
||||
}
|
||||
|
||||
export const DASHBOARD_WIDGET_META: Record<DashboardWidgetId, {
|
||||
required: boolean
|
||||
zone: DashboardWidgetZone
|
||||
category: DashboardWidgetCategory
|
||||
defaultVisible: boolean
|
||||
}> = {
|
||||
capture: { required: true, zone: 'full', category: 'essential', defaultVisible: true },
|
||||
'next-paths': { required: false, zone: 'main', category: 'paths', defaultVisible: true },
|
||||
resume: { required: true, zone: 'main', category: 'essential', defaultVisible: true },
|
||||
intelligence: { required: true, zone: 'main', category: 'essential', defaultVisible: true },
|
||||
reminders: { required: false, zone: 'side', category: 'productivity', defaultVisible: true },
|
||||
'mind-map': { required: false, zone: 'main', category: 'cognition', defaultVisible: true },
|
||||
agents: { required: false, zone: 'side', category: 'agents', defaultVisible: true },
|
||||
sentiment: { required: false, zone: 'side', category: 'agents', defaultVisible: true },
|
||||
inbox: { required: false, zone: 'side', category: 'productivity', defaultVisible: false },
|
||||
revision: { required: false, zone: 'side', category: 'productivity', defaultVisible: false },
|
||||
stats: { required: false, zone: 'side', category: 'cognition', defaultVisible: false },
|
||||
'agent-activity': { required: false, zone: 'side', category: 'agents', defaultVisible: false },
|
||||
gmail: { required: false, zone: 'side', category: 'productivity', defaultVisible: false },
|
||||
activity: { required: false, zone: 'main', category: 'cognition', defaultVisible: false },
|
||||
pinned: { required: false, zone: 'side', category: 'cognition', defaultVisible: false },
|
||||
usage: { required: false, zone: 'side', category: 'agents', defaultVisible: false },
|
||||
'daily-review': { required: false, zone: 'side', category: 'paths', defaultVisible: false },
|
||||
'open-loops': { required: false, zone: 'side', category: 'paths', defaultVisible: false },
|
||||
'daily-note': { required: false, zone: 'side', category: 'paths', defaultVisible: false },
|
||||
'link-suggestions': { required: false, zone: 'main', category: 'paths', defaultVisible: false },
|
||||
bridges: { required: false, zone: 'main', category: 'cognition', defaultVisible: false },
|
||||
'flashcards-progress': { required: false, zone: 'side', category: 'productivity', defaultVisible: false },
|
||||
}
|
||||
|
||||
export const DASHBOARD_CATEGORY_ORDER: DashboardWidgetCategory[] = [
|
||||
'essential',
|
||||
'paths',
|
||||
'cognition',
|
||||
'productivity',
|
||||
'agents',
|
||||
]
|
||||
|
||||
export const DEFAULT_DASHBOARD_LAYOUT: DashboardLayout = {
|
||||
version: DASHBOARD_LAYOUT_VERSION,
|
||||
widgets: [
|
||||
// Pleine largeur
|
||||
{ id: 'capture', visible: true, order: 0, zone: 'full' },
|
||||
// Colonne principale (gauche) — ordre vertical
|
||||
{ id: 'next-paths', visible: true, order: 1, zone: 'main' },
|
||||
{ id: 'intelligence', visible: true, order: 2, zone: 'main' },
|
||||
{ id: 'resume', visible: true, order: 3, zone: 'main' },
|
||||
{ id: 'mind-map', visible: true, order: 4, zone: 'main' },
|
||||
// Colonne latérale (droite) — cartes compactes + widgets IA
|
||||
{ id: 'sentiment', visible: true, order: 5, zone: 'side' },
|
||||
{ id: 'inbox', visible: true, order: 6, zone: 'side' },
|
||||
{ id: 'revision', visible: true, order: 7, zone: 'side' },
|
||||
{ id: 'stats', visible: true, order: 8, zone: 'side' },
|
||||
{ id: 'reminders', visible: true, order: 9, zone: 'side' },
|
||||
{ id: 'flashcards-progress', visible: true, order: 10, zone: 'side' },
|
||||
{ id: 'agents', visible: true, order: 11, zone: 'side' },
|
||||
// Catalogue — masqués par défaut, ajoutables via « Personnaliser »
|
||||
{ id: 'daily-review', visible: false, order: 12, zone: 'side' },
|
||||
{ id: 'agent-activity', visible: false, order: 13, zone: 'side' },
|
||||
{ id: 'gmail', visible: false, order: 14, zone: 'side' },
|
||||
{ id: 'activity', visible: false, order: 15, zone: 'main' },
|
||||
{ id: 'pinned', visible: false, order: 16, zone: 'side' },
|
||||
{ id: 'usage', visible: false, order: 17, zone: 'side' },
|
||||
{ id: 'open-loops', visible: false, order: 18, zone: 'side' },
|
||||
{ id: 'daily-note', visible: false, order: 19, zone: 'side' },
|
||||
{ id: 'link-suggestions', visible: false, order: 20, zone: 'main' },
|
||||
{ id: 'bridges', visible: false, order: 21, zone: 'main' },
|
||||
],
|
||||
}
|
||||
|
||||
/** Clone frais — évite de muter le constant partagé via useState. */
|
||||
export function getDefaultDashboardLayout(): DashboardLayout {
|
||||
return structuredClone(DEFAULT_DASHBOARD_LAYOUT)
|
||||
}
|
||||
|
||||
export function resetDashboardLayout(): DashboardLayout {
|
||||
return getDefaultDashboardLayout()
|
||||
}
|
||||
|
||||
const ALL_WIDGET_IDS = Object.keys(DASHBOARD_WIDGET_META) as DashboardWidgetId[]
|
||||
|
||||
const LEGACY_SPAN_ZONE: Record<string, DashboardWidgetZone> = {
|
||||
full: 'full',
|
||||
large: 'main',
|
||||
medium: 'main',
|
||||
small: 'side',
|
||||
compact: 'side',
|
||||
}
|
||||
|
||||
function isValidZone(zone: unknown): zone is DashboardWidgetZone {
|
||||
return zone === 'full' || zone === 'main' || zone === 'side'
|
||||
}
|
||||
|
||||
function legacyZoneFromItem(item: Record<string, unknown>, id: DashboardWidgetId): DashboardWidgetZone {
|
||||
if (isValidZone(item.zone)) return item.zone
|
||||
const span = item.span
|
||||
if (typeof span === 'string' && LEGACY_SPAN_ZONE[span]) return LEGACY_SPAN_ZONE[span]
|
||||
return DASHBOARD_WIDGET_META[id].zone
|
||||
}
|
||||
|
||||
export function normalizeDashboardLayout(raw: unknown): DashboardLayout {
|
||||
if (!raw || typeof raw !== 'object') return getDefaultDashboardLayout()
|
||||
|
||||
const data = raw as Partial<DashboardLayout> & { version?: number }
|
||||
if (!Array.isArray(data.widgets) || data.widgets.length === 0) return getDefaultDashboardLayout()
|
||||
|
||||
// Layout périmé ou vide → preset canonique
|
||||
const incomingVersion = typeof data.version === 'number' ? data.version : 0
|
||||
if (incomingVersion < DASHBOARD_LAYOUT_VERSION) {
|
||||
return getDefaultDashboardLayout()
|
||||
}
|
||||
|
||||
const byId = new Map<DashboardWidgetId, DashboardWidgetPlacement>()
|
||||
|
||||
for (const item of data.widgets) {
|
||||
if (!item || typeof item !== 'object') continue
|
||||
const id = (item as DashboardWidgetPlacement).id
|
||||
if (!ALL_WIDGET_IDS.includes(id)) continue
|
||||
const meta = DASHBOARD_WIDGET_META[id]
|
||||
byId.set(id, {
|
||||
id,
|
||||
visible: meta.required ? true : Boolean((item as DashboardWidgetPlacement).visible),
|
||||
order: Number.isFinite((item as DashboardWidgetPlacement).order)
|
||||
? Number((item as DashboardWidgetPlacement).order)
|
||||
: 0,
|
||||
zone: legacyZoneFromItem(item as unknown as Record<string, unknown>, id),
|
||||
})
|
||||
}
|
||||
|
||||
for (const id of ALL_WIDGET_IDS) {
|
||||
if (!byId.has(id)) {
|
||||
const meta = DASHBOARD_WIDGET_META[id]
|
||||
const fallback = DEFAULT_DASHBOARD_LAYOUT.widgets.find(w => w.id === id)
|
||||
byId.set(id, {
|
||||
id,
|
||||
visible: meta.defaultVisible,
|
||||
order: fallback?.order ?? 99,
|
||||
zone: meta.zone,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const widgets = [...byId.values()]
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((w, index) => ({ ...w, order: index }))
|
||||
|
||||
const layout = { version: DASHBOARD_LAYOUT_VERSION, widgets }
|
||||
const visibleCount = layout.widgets.filter(w => w.visible).length
|
||||
if (visibleCount === 0) return getDefaultDashboardLayout()
|
||||
|
||||
return layout
|
||||
}
|
||||
|
||||
export function reorderWidgets(
|
||||
layout: DashboardLayout,
|
||||
activeId: DashboardWidgetId,
|
||||
overId: DashboardWidgetId,
|
||||
): DashboardLayout {
|
||||
const activeMeta = DASHBOARD_WIDGET_META[activeId]
|
||||
const overMeta = DASHBOARD_WIDGET_META[overId]
|
||||
if (activeMeta.zone !== overMeta.zone) return layout
|
||||
|
||||
const visible = layout.widgets
|
||||
.filter(w => w.visible && w.zone === activeMeta.zone)
|
||||
.sort((a, b) => a.order - b.order)
|
||||
const activeIdx = visible.findIndex(w => w.id === activeId)
|
||||
const overIdx = visible.findIndex(w => w.id === overId)
|
||||
if (activeIdx === -1 || overIdx === -1 || activeIdx === overIdx) return layout
|
||||
|
||||
const next = [...visible]
|
||||
const [moved] = next.splice(activeIdx, 1)
|
||||
next.splice(overIdx, 0, moved)
|
||||
|
||||
const orderMap = new Map(next.map((w, i) => [w.id, i]))
|
||||
return {
|
||||
...layout,
|
||||
widgets: layout.widgets.map(w => {
|
||||
if (w.zone !== activeMeta.zone || !w.visible) return w
|
||||
return { ...w, order: orderMap.has(w.id) ? orderMap.get(w.id)! : w.order + next.length }
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
export function setWidgetVisibility(
|
||||
layout: DashboardLayout,
|
||||
id: DashboardWidgetId,
|
||||
visible: boolean,
|
||||
): DashboardLayout {
|
||||
if (DASHBOARD_WIDGET_META[id].required && !visible) return layout
|
||||
return {
|
||||
...layout,
|
||||
widgets: layout.widgets.map(w => w.id === id ? { ...w, visible } : w),
|
||||
}
|
||||
}
|
||||
|
||||
export function visibleWidgets(layout: DashboardLayout): DashboardWidgetPlacement[] {
|
||||
return layout.widgets.filter(w => w.visible).sort((a, b) => a.order - b.order)
|
||||
}
|
||||
|
||||
export function visibleWidgetsInZone(
|
||||
layout: DashboardLayout,
|
||||
zone: DashboardWidgetZone,
|
||||
): DashboardWidgetPlacement[] {
|
||||
return visibleWidgets(layout).filter(w => w.zone === zone)
|
||||
}
|
||||
|
||||
export function hiddenWidgetIds(layout: DashboardLayout): DashboardWidgetId[] {
|
||||
return layout.widgets.filter(w => !w.visible).map(w => w.id)
|
||||
}
|
||||
|
||||
export function catalogByCategory(layout: DashboardLayout): Record<DashboardWidgetCategory, DashboardWidgetId[]> {
|
||||
const result = Object.fromEntries(
|
||||
DASHBOARD_CATEGORY_ORDER.map(c => [c, [] as DashboardWidgetId[]]),
|
||||
) as Record<DashboardWidgetCategory, DashboardWidgetId[]>
|
||||
|
||||
for (const id of ALL_WIDGET_IDS) {
|
||||
result[DASHBOARD_WIDGET_META[id].category].push(id)
|
||||
}
|
||||
|
||||
for (const cat of DASHBOARD_CATEGORY_ORDER) {
|
||||
result[cat].sort((a, b) => {
|
||||
const aVis = layout.widgets.find(w => w.id === a)?.visible ? 0 : 1
|
||||
const bVis = layout.widgets.find(w => w.id === b)?.visible ? 0 : 1
|
||||
if (aVis !== bVis) return aVis - bVis
|
||||
return a.localeCompare(b)
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export function isWidgetVisible(layout: DashboardLayout, id: DashboardWidgetId): boolean {
|
||||
return layout.widgets.find(w => w.id === id)?.visible ?? DASHBOARD_WIDGET_META[id].defaultVisible
|
||||
}
|
||||
36
memento-note/lib/dashboard/path-types.ts
Normal file
36
memento-note/lib/dashboard/path-types.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
export type DashboardPathType =
|
||||
| 'continue'
|
||||
| 'connect'
|
||||
| 'bridge'
|
||||
| 'research'
|
||||
| 'organize'
|
||||
| 'explore'
|
||||
| 'review'
|
||||
| 'resurface'
|
||||
| 'daily'
|
||||
| 'add-link'
|
||||
|
||||
export interface DashboardPath {
|
||||
id: string
|
||||
type: DashboardPathType
|
||||
priority: number
|
||||
title: string
|
||||
description: string
|
||||
actionKey: string
|
||||
noteId?: string
|
||||
note2Id?: string
|
||||
notebookId?: string
|
||||
clusterAId?: number
|
||||
clusterBId?: number
|
||||
agentSuggestionId?: string
|
||||
insightId?: string
|
||||
snippet?: string
|
||||
score?: number
|
||||
}
|
||||
|
||||
export interface DashboardOpenLoop {
|
||||
id: string
|
||||
title: string | null
|
||||
notebookId: string | null
|
||||
daysStale: number
|
||||
}
|
||||
156
memento-note/lib/dashboard/paths-fast.ts
Normal file
156
memento-note/lib/dashboard/paths-fast.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import type { DashboardPath } from '@/lib/dashboard/path-types'
|
||||
|
||||
function excerpt(text: string, max = 120): string {
|
||||
const plain = text.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
|
||||
if (plain.length <= max) return plain
|
||||
return `${plain.slice(0, max)}…`
|
||||
}
|
||||
|
||||
export interface BriefingPathsInput {
|
||||
recentNotes: Array<{
|
||||
id: string
|
||||
title: string | null
|
||||
content: string
|
||||
notebookId: string | null
|
||||
}>
|
||||
inboxCount: number
|
||||
dueFlashcards: number
|
||||
insights: Array<{
|
||||
id: string
|
||||
insight: string
|
||||
score: number
|
||||
viewed: boolean
|
||||
note1: { id: string; title: string | null }
|
||||
note2: { id: string; title: string | null }
|
||||
note1Excerpt?: string
|
||||
note2Excerpt?: string
|
||||
}>
|
||||
bridgeSuggestions: Array<{
|
||||
clusterAId: number
|
||||
clusterBId: number
|
||||
clusterAName: string
|
||||
clusterBName: string
|
||||
suggestedTitle: string
|
||||
suggestedContent: string
|
||||
justification: string
|
||||
}>
|
||||
agentSuggestions: Array<{
|
||||
id: string
|
||||
topic: string
|
||||
reason: string
|
||||
clusterId: number | null
|
||||
}>
|
||||
}
|
||||
|
||||
/** Pistes instantanées à partir du briefing déjà chargé — sans requête lourde. */
|
||||
export function buildFastPathsFromBriefing(input: BriefingPathsInput): DashboardPath[] {
|
||||
const paths: DashboardPath[] = []
|
||||
const focus = input.recentNotes[0]
|
||||
|
||||
if (focus) {
|
||||
paths.push({
|
||||
id: `continue-${focus.id}`,
|
||||
type: 'continue',
|
||||
priority: 100,
|
||||
title: focus.title || 'Untitled',
|
||||
description: excerpt(focus.content, 140),
|
||||
actionKey: 'continue',
|
||||
noteId: focus.id,
|
||||
notebookId: focus.notebookId ?? undefined,
|
||||
})
|
||||
|
||||
for (const ins of input.insights
|
||||
.filter(i => i.note1.id === focus.id || i.note2.id === focus.id)
|
||||
.slice(0, 2)) {
|
||||
const other = ins.note1.id === focus.id ? ins.note2 : ins.note1
|
||||
paths.push({
|
||||
id: `connect-${focus.id}-${other.id}`,
|
||||
type: 'connect',
|
||||
priority: 88,
|
||||
title: other.title || 'Untitled',
|
||||
description: ins.insight,
|
||||
actionKey: 'compare',
|
||||
noteId: focus.id,
|
||||
note2Id: other.id,
|
||||
notebookId: focus.notebookId ?? undefined,
|
||||
score: Math.round(ins.score * 100),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const freshInsight = input.insights.find(i => !i.viewed)
|
||||
if (freshInsight) {
|
||||
paths.push({
|
||||
id: `resurface-${freshInsight.id}`,
|
||||
type: 'resurface',
|
||||
priority: 85,
|
||||
title: `${freshInsight.note1.title || '…'} ↔ ${freshInsight.note2.title || '…'}`,
|
||||
description: freshInsight.insight,
|
||||
actionKey: 'openInsight',
|
||||
insightId: freshInsight.id,
|
||||
noteId: freshInsight.note1.id,
|
||||
note2Id: freshInsight.note2.id,
|
||||
score: Math.round(freshInsight.score * 100),
|
||||
})
|
||||
}
|
||||
|
||||
for (const bridge of input.bridgeSuggestions.slice(0, 2)) {
|
||||
paths.push({
|
||||
id: `bridge-${bridge.clusterAId}-${bridge.clusterBId}`,
|
||||
type: 'bridge',
|
||||
priority: 70,
|
||||
title: bridge.suggestedTitle,
|
||||
description: bridge.justification || bridge.suggestedContent.slice(0, 140),
|
||||
actionKey: 'createBridge',
|
||||
clusterAId: bridge.clusterAId,
|
||||
clusterBId: bridge.clusterBId,
|
||||
notebookId: focus?.notebookId ?? undefined,
|
||||
})
|
||||
}
|
||||
|
||||
for (const agent of input.agentSuggestions.slice(0, 2)) {
|
||||
paths.push({
|
||||
id: `research-${agent.id}`,
|
||||
type: 'research',
|
||||
priority: 65,
|
||||
title: agent.topic,
|
||||
description: agent.reason,
|
||||
actionKey: 'createAgent',
|
||||
agentSuggestionId: agent.id,
|
||||
notebookId: focus?.notebookId ?? undefined,
|
||||
})
|
||||
}
|
||||
|
||||
if (input.inboxCount > 0) {
|
||||
paths.push({
|
||||
id: 'organize-inbox',
|
||||
type: 'organize',
|
||||
priority: 60,
|
||||
title: `${input.inboxCount} notes`,
|
||||
description: 'inbox',
|
||||
actionKey: 'organizeInbox',
|
||||
})
|
||||
}
|
||||
|
||||
if (input.dueFlashcards > 0) {
|
||||
paths.push({
|
||||
id: 'review-flashcards',
|
||||
type: 'review',
|
||||
priority: 55,
|
||||
title: `${input.dueFlashcards}`,
|
||||
description: 'flashcards',
|
||||
actionKey: 'reviewCards',
|
||||
})
|
||||
}
|
||||
|
||||
paths.push({
|
||||
id: 'daily-journal',
|
||||
type: 'daily',
|
||||
priority: 40,
|
||||
title: new Date().toISOString().slice(0, 10),
|
||||
description: 'daily',
|
||||
actionKey: 'openDaily',
|
||||
})
|
||||
|
||||
return paths.sort((a, b) => b.priority - a.priority).slice(0, 8)
|
||||
}
|
||||
297
memento-note/lib/dashboard/paths.service.ts
Normal file
297
memento-note/lib/dashboard/paths.service.ts
Normal file
@@ -0,0 +1,297 @@
|
||||
import 'server-only'
|
||||
|
||||
import prisma from '@/lib/prisma'
|
||||
import type { DashboardPath } from '@/lib/dashboard/path-types'
|
||||
|
||||
export type { DashboardPath, DashboardPathType } from '@/lib/dashboard/path-types'
|
||||
|
||||
interface BuildPathsInput {
|
||||
userId: string
|
||||
aiActive: boolean
|
||||
recentNotes: Array<{
|
||||
id: string
|
||||
title: string | null
|
||||
notebookId: string | null
|
||||
updatedAt: Date
|
||||
content: string
|
||||
}>
|
||||
inboxCount: number
|
||||
dueFlashcards: number
|
||||
insights: Array<{
|
||||
id: string
|
||||
insight: string
|
||||
score: number
|
||||
viewed: boolean
|
||||
note1: { id: string; title: string | null }
|
||||
note2: { id: string; title: string | null }
|
||||
note1Excerpt?: string
|
||||
note2Excerpt?: string
|
||||
}>
|
||||
bridgeSuggestions: Array<{
|
||||
clusterAId: number
|
||||
clusterBId: number
|
||||
clusterAName: string
|
||||
clusterBName: string
|
||||
suggestedTitle: string
|
||||
suggestedContent: string
|
||||
justification: string
|
||||
}>
|
||||
agentSuggestions: Array<{
|
||||
id: string
|
||||
topic: string
|
||||
reason: string
|
||||
clusterId: number | null
|
||||
}>
|
||||
}
|
||||
|
||||
function excerpt(text: string, max = 120): string {
|
||||
const plain = text.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
|
||||
if (plain.length <= max) return plain
|
||||
return `${plain.slice(0, max)}…`
|
||||
}
|
||||
|
||||
function jaccardSimilarity(a: string, b: string): number {
|
||||
const tokenize = (s: string) =>
|
||||
new Set(s.toLowerCase().replace(/[^\w\s]/g, '').split(/\s+/).filter(w => w.length > 3))
|
||||
const A = tokenize(a)
|
||||
const B = tokenize(b)
|
||||
if (A.size === 0 || B.size === 0) return 0
|
||||
let intersection = 0
|
||||
A.forEach(w => { if (B.has(w)) intersection++ })
|
||||
return intersection / (A.size + B.size - intersection)
|
||||
}
|
||||
|
||||
async function findLinkSuggestions(
|
||||
userId: string,
|
||||
sourceNote: { id: string; title: string | null; content: string },
|
||||
limit = 2,
|
||||
): Promise<Array<{ noteId: string; noteTitle: string; snippet: string; score: number }>> {
|
||||
const sourcePlain = excerpt(sourceNote.content, 400)
|
||||
const sourceText = `${sourceNote.title || ''} ${sourcePlain}`.toLowerCase()
|
||||
|
||||
const candidates = await prisma.note.findMany({
|
||||
where: { userId, id: { not: sourceNote.id }, isArchived: false, trashedAt: null },
|
||||
select: { id: true, title: true, content: true },
|
||||
take: 12,
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
})
|
||||
|
||||
const scored: Array<{ noteId: string; noteTitle: string; snippet: string; score: number }> = []
|
||||
for (const note of candidates) {
|
||||
const notePlain = excerpt(note.content, 300)
|
||||
const sim = jaccardSimilarity(sourceText, `${note.title || ''} ${notePlain}`)
|
||||
if (sim < 0.06) continue
|
||||
scored.push({
|
||||
noteId: note.id,
|
||||
noteTitle: note.title || '',
|
||||
snippet: notePlain.slice(0, 100),
|
||||
score: Math.round(sim * 100),
|
||||
})
|
||||
}
|
||||
|
||||
return scored.sort((a, b) => b.score - a.score).slice(0, limit)
|
||||
}
|
||||
|
||||
function pathsFromInsights(
|
||||
focus: BuildPathsInput['recentNotes'][0],
|
||||
insights: BuildPathsInput['insights'],
|
||||
paths: DashboardPath[],
|
||||
): void {
|
||||
for (const ins of insights
|
||||
.filter(i => i.note1.id === focus.id || i.note2.id === focus.id)
|
||||
.slice(0, 2)) {
|
||||
const other = ins.note1.id === focus.id ? ins.note2 : ins.note1
|
||||
if (paths.some(p => p.note2Id === other.id && p.type === 'connect')) continue
|
||||
paths.push({
|
||||
id: `connect-${focus.id}-${other.id}`,
|
||||
type: 'connect',
|
||||
priority: 90 - paths.length,
|
||||
title: other.title || 'Untitled',
|
||||
description: ins.insight || ins.note2Excerpt || ins.note1Excerpt || '',
|
||||
actionKey: 'compare',
|
||||
noteId: focus.id,
|
||||
note2Id: other.id,
|
||||
notebookId: focus.notebookId ?? undefined,
|
||||
score: Math.round(ins.score * 100),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function findOpenLoops(userId: string, limit = 3): Promise<Array<{ id: string; title: string | null; notebookId: string | null; daysStale: number }>> {
|
||||
const threeDaysAgo = new Date()
|
||||
threeDaysAgo.setDate(threeDaysAgo.getDate() - 3)
|
||||
const thirtyDaysAgo = new Date()
|
||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30)
|
||||
|
||||
const stale = await prisma.note.findMany({
|
||||
where: {
|
||||
userId,
|
||||
trashedAt: null,
|
||||
isArchived: false,
|
||||
updatedAt: { lt: threeDaysAgo, gte: thirtyDaysAgo },
|
||||
OR: [
|
||||
{ notebookId: null },
|
||||
{ isPinned: true },
|
||||
{ reminder: { not: null }, isReminderDone: false },
|
||||
],
|
||||
},
|
||||
select: { id: true, title: true, notebookId: true, updatedAt: true },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
take: limit,
|
||||
})
|
||||
|
||||
const now = Date.now()
|
||||
return stale.map(n => ({
|
||||
id: n.id,
|
||||
title: n.title,
|
||||
notebookId: n.notebookId,
|
||||
daysStale: Math.floor((now - n.updatedAt.getTime()) / (1000 * 60 * 60 * 24)),
|
||||
}))
|
||||
}
|
||||
|
||||
export async function buildDashboardPaths(input: BuildPathsInput): Promise<DashboardPath[]> {
|
||||
const paths: DashboardPath[] = []
|
||||
const focus = input.recentNotes[0]
|
||||
|
||||
let focusClusterId: number | null = null
|
||||
if (focus) {
|
||||
const member = await prisma.clusterMember.findFirst({
|
||||
where: { userId: input.userId, noteId: focus.id },
|
||||
select: { clusterId: true },
|
||||
})
|
||||
focusClusterId = member?.clusterId ?? null
|
||||
|
||||
paths.push({
|
||||
id: `continue-${focus.id}`,
|
||||
type: 'continue',
|
||||
priority: 100,
|
||||
title: focus.title || 'Untitled',
|
||||
description: excerpt(focus.content, 140),
|
||||
actionKey: 'continue',
|
||||
noteId: focus.id,
|
||||
notebookId: focus.notebookId ?? undefined,
|
||||
})
|
||||
|
||||
pathsFromInsights(focus, input.insights, paths)
|
||||
|
||||
const linkSuggestions = await findLinkSuggestions(input.userId, focus, 2)
|
||||
for (const link of linkSuggestions) {
|
||||
paths.push({
|
||||
id: `add-link-${focus.id}-${link.noteId}`,
|
||||
type: 'add-link',
|
||||
priority: 75,
|
||||
title: link.noteTitle || 'Untitled',
|
||||
description: link.snippet,
|
||||
actionKey: 'addLink',
|
||||
noteId: focus.id,
|
||||
note2Id: link.noteId,
|
||||
notebookId: focus.notebookId ?? undefined,
|
||||
score: link.score,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const freshInsight = input.insights.find(i => !i.viewed)
|
||||
if (freshInsight) {
|
||||
paths.push({
|
||||
id: `resurface-${freshInsight.id}`,
|
||||
type: 'resurface',
|
||||
priority: 85,
|
||||
title: `${freshInsight.note1.title || '…'} ↔ ${freshInsight.note2.title || '…'}`,
|
||||
description: freshInsight.insight,
|
||||
actionKey: 'openInsight',
|
||||
insightId: freshInsight.id,
|
||||
noteId: freshInsight.note1.id,
|
||||
note2Id: freshInsight.note2.id,
|
||||
score: Math.round(freshInsight.score * 100),
|
||||
})
|
||||
}
|
||||
|
||||
for (const bridge of input.bridgeSuggestions.slice(0, 2)) {
|
||||
const relevant = focusClusterId === null
|
||||
|| bridge.clusterAId === focusClusterId
|
||||
|| bridge.clusterBId === focusClusterId
|
||||
paths.push({
|
||||
id: `bridge-${bridge.clusterAId}-${bridge.clusterBId}`,
|
||||
type: 'bridge',
|
||||
priority: relevant ? 80 : 55,
|
||||
title: bridge.suggestedTitle,
|
||||
description: bridge.justification || bridge.suggestedContent.slice(0, 140),
|
||||
actionKey: 'createBridge',
|
||||
clusterAId: bridge.clusterAId,
|
||||
clusterBId: bridge.clusterBId,
|
||||
notebookId: focus?.notebookId ?? undefined,
|
||||
})
|
||||
}
|
||||
|
||||
for (const agent of input.agentSuggestions.slice(0, 2)) {
|
||||
const relevant = focusClusterId !== null && agent.clusterId === focusClusterId
|
||||
paths.push({
|
||||
id: `research-${agent.id}`,
|
||||
type: 'research',
|
||||
priority: relevant ? 78 : 50,
|
||||
title: agent.topic,
|
||||
description: agent.reason,
|
||||
actionKey: 'createAgent',
|
||||
agentSuggestionId: agent.id,
|
||||
notebookId: focus?.notebookId ?? undefined,
|
||||
})
|
||||
}
|
||||
|
||||
if (focusClusterId !== null) {
|
||||
const cluster = await prisma.noteCluster.findFirst({
|
||||
where: { userId: input.userId, clusterId: focusClusterId },
|
||||
select: { name: true, noteCount: true },
|
||||
})
|
||||
if (cluster) {
|
||||
paths.push({
|
||||
id: `explore-cluster-${focusClusterId}`,
|
||||
type: 'explore',
|
||||
priority: 60,
|
||||
title: cluster.name || `Cluster ${focusClusterId}`,
|
||||
description: `${cluster.noteCount} notes in this theme`,
|
||||
actionKey: 'exploreTheme',
|
||||
clusterAId: focusClusterId,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (input.inboxCount > 0) {
|
||||
paths.push({
|
||||
id: 'organize-inbox',
|
||||
type: 'organize',
|
||||
priority: 70,
|
||||
title: `${input.inboxCount} notes`,
|
||||
description: 'inbox',
|
||||
actionKey: 'organizeInbox',
|
||||
})
|
||||
}
|
||||
|
||||
if (input.dueFlashcards > 0) {
|
||||
paths.push({
|
||||
id: 'review-flashcards',
|
||||
type: 'review',
|
||||
priority: 65,
|
||||
title: `${input.dueFlashcards}`,
|
||||
description: 'flashcards',
|
||||
actionKey: 'reviewCards',
|
||||
})
|
||||
}
|
||||
|
||||
paths.push({
|
||||
id: 'daily-journal',
|
||||
type: 'daily',
|
||||
priority: 40,
|
||||
title: new Date().toISOString().slice(0, 10),
|
||||
description: 'daily',
|
||||
actionKey: 'openDaily',
|
||||
})
|
||||
|
||||
return paths
|
||||
.sort((a, b) => b.priority - a.priority)
|
||||
.slice(0, 8)
|
||||
}
|
||||
|
||||
export async function buildOpenLoops(userId: string) {
|
||||
return findOpenLoops(userId, 5)
|
||||
}
|
||||
204
memento-note/lib/integrations/gmail-scanner.service.ts
Normal file
204
memento-note/lib/integrations/gmail-scanner.service.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* Gmail Scanner — extrait vols, colis, abonnements → notes + rappels
|
||||
*/
|
||||
|
||||
import prisma from '@/lib/prisma'
|
||||
import { getChatProvider } from '@/lib/ai/factory'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
import { getGoogleTokens, refreshGoogleAccessToken } from '@/lib/integrations/google-integration-tokens'
|
||||
|
||||
const GMAIL_API = 'https://gmail.googleapis.com/gmail/v1/users/me'
|
||||
|
||||
const SCAN_QUERIES: { category: string; q: string }[] = [
|
||||
{ category: 'flight', q: 'subject:(flight OR boarding OR vol OR "boarding pass") newer_than:7d' },
|
||||
{ category: 'package', q: 'from:(amazon OR ups OR fedex OR dhl OR laposte OR colissimo) newer_than:7d' },
|
||||
{ category: 'subscription', q: 'subject:(renewal OR reconduction OR renouvellement OR abonnement) newer_than:30d' },
|
||||
]
|
||||
|
||||
function decodeBase64Url(data: string): string {
|
||||
const normalized = data.replace(/-/g, '+').replace(/_/g, '/')
|
||||
return Buffer.from(normalized, 'base64').toString('utf-8')
|
||||
}
|
||||
|
||||
function extractBody(payload: any): string {
|
||||
if (!payload) return ''
|
||||
if (payload.body?.data) {
|
||||
const raw = decodeBase64Url(payload.body.data)
|
||||
return raw.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 4000)
|
||||
}
|
||||
if (Array.isArray(payload.parts)) {
|
||||
for (const part of payload.parts) {
|
||||
if (part.mimeType === 'text/plain' && part.body?.data) {
|
||||
return decodeBase64Url(part.body.data).slice(0, 4000)
|
||||
}
|
||||
}
|
||||
for (const part of payload.parts) {
|
||||
const nested = extractBody(part)
|
||||
if (nested) return nested
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
async function gmailFetch(userId: string, path: string, accessToken: string, refreshToken: string) {
|
||||
let token = accessToken
|
||||
let res = await fetch(`${GMAIL_API}${path}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
if (res.status === 401 && refreshToken) {
|
||||
const refreshed = await refreshGoogleAccessToken(userId, 'gmail', refreshToken)
|
||||
if (refreshed) {
|
||||
token = refreshed
|
||||
res = await fetch(`${GMAIL_API}${path}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
}
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
async function extractStructured(
|
||||
category: string,
|
||||
subject: string,
|
||||
body: string,
|
||||
): Promise<Record<string, unknown> | null> {
|
||||
const config = await getSystemConfig()
|
||||
const provider = getChatProvider(config)
|
||||
if (!provider) return null
|
||||
|
||||
const schemas: Record<string, string> = {
|
||||
flight: '{"airline":"","flightNumber":"","departureDate":"ISO8601","departureAirport":"","arrivalAirport":"","confirmationCode":""}',
|
||||
package: '{"carrier":"","trackingNumber":"","expectedDelivery":"ISO8601","status":""}',
|
||||
subscription: '{"serviceName":"","renewalDate":"ISO8601","amount":0,"currency":"EUR"}',
|
||||
}
|
||||
|
||||
const prompt = `Extrais les données structurées de cet email (${category}). Retourne UNIQUEMENT du JSON valide selon ce schéma: ${schemas[category] || '{}'}
|
||||
Sujet: ${subject}
|
||||
Corps: ${body.slice(0, 2500)}`
|
||||
|
||||
try {
|
||||
const raw = await provider.generateText(prompt)
|
||||
const match = raw.match(/\{[\s\S]*\}/)
|
||||
if (!match) return null
|
||||
return JSON.parse(match[0])
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function buildNoteFromExtraction(category: string, subject: string, data: Record<string, unknown>) {
|
||||
if (category === 'flight') {
|
||||
const title = `Vol ${data.flightNumber || data.airline || ''}`.trim() || subject.slice(0, 80)
|
||||
const dep = data.departureDate ? new Date(String(data.departureDate)) : null
|
||||
const reminder = dep && !Number.isNaN(dep.getTime())
|
||||
? new Date(dep.getTime() - 3 * 60 * 60 * 1000)
|
||||
: null
|
||||
const html = `<p><strong>${title}</strong></p><p>${data.departureAirport || ''} → ${data.arrivalAirport || ''}</p><p>Confirmation: ${data.confirmationCode || '—'}</p>`
|
||||
return { title, content: html, reminder }
|
||||
}
|
||||
if (category === 'package') {
|
||||
const title = `Colis ${data.trackingNumber || data.carrier || ''}`.trim() || subject.slice(0, 80)
|
||||
const del = data.expectedDelivery ? new Date(String(data.expectedDelivery)) : null
|
||||
const reminder = del && !Number.isNaN(del.getTime()) ? del : null
|
||||
const html = `<p><strong>${title}</strong></p><p>Transporteur: ${data.carrier || '—'}</p><p>Suivi: ${data.trackingNumber || '—'}</p><p>Statut: ${data.status || '—'}</p>`
|
||||
return { title, content: html, reminder }
|
||||
}
|
||||
const title = `Abonnement ${data.serviceName || ''}`.trim() || subject.slice(0, 80)
|
||||
const ren = data.renewalDate ? new Date(String(data.renewalDate)) : null
|
||||
const reminder = ren && !Number.isNaN(ren.getTime())
|
||||
? new Date(ren.getTime() - 7 * 24 * 60 * 60 * 1000)
|
||||
: null
|
||||
const html = `<p><strong>${title}</strong></p><p>Renouvellement: ${data.renewalDate || '—'}</p><p>Montant: ${data.amount ?? '—'} ${data.currency || ''}</p>`
|
||||
return { title, content: html, reminder }
|
||||
}
|
||||
|
||||
export class GmailScannerService {
|
||||
async scanUser(userId: string): Promise<{ processed: number; created: number }> {
|
||||
const tokens = await getGoogleTokens(userId, 'gmail')
|
||||
if (!tokens) return { processed: 0, created: 0 }
|
||||
|
||||
let processed = 0
|
||||
let created = 0
|
||||
|
||||
for (const { category, q } of SCAN_QUERIES) {
|
||||
const listRes = await gmailFetch(
|
||||
userId,
|
||||
`/messages?maxResults=5&q=${encodeURIComponent(q)}`,
|
||||
tokens.accessToken,
|
||||
tokens.refreshToken,
|
||||
)
|
||||
if (!listRes.ok) continue
|
||||
const listJson = await listRes.json()
|
||||
const ids: string[] = (listJson.messages || []).map((m: { id: string }) => m.id)
|
||||
|
||||
for (const messageId of ids) {
|
||||
const exists = await prisma.gmailScanHistory.findUnique({
|
||||
where: { userId_messageId: { userId, messageId } },
|
||||
})
|
||||
if (exists) continue
|
||||
|
||||
const msgRes = await gmailFetch(
|
||||
userId,
|
||||
`/messages/${messageId}?format=full`,
|
||||
tokens.accessToken,
|
||||
tokens.refreshToken,
|
||||
)
|
||||
if (!msgRes.ok) continue
|
||||
processed++
|
||||
|
||||
const msg = await msgRes.json()
|
||||
const headers: { name: string; value: string }[] = msg.payload?.headers || []
|
||||
const subject = headers.find(h => h.name.toLowerCase() === 'subject')?.value || '(Sans objet)'
|
||||
const body = extractBody(msg.payload)
|
||||
|
||||
const extracted = await extractStructured(category, subject, body)
|
||||
if (!extracted) {
|
||||
await prisma.gmailScanHistory.create({
|
||||
data: { userId, messageId, category, data: { subject, skipped: true } },
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const built = buildNoteFromExtraction(category, subject, extracted)
|
||||
const note = await prisma.note.create({
|
||||
data: {
|
||||
userId,
|
||||
title: built.title,
|
||||
content: built.content,
|
||||
type: 'richtext',
|
||||
labels: JSON.stringify(['gmail', category]),
|
||||
reminder: built.reminder,
|
||||
autoGenerated: true,
|
||||
},
|
||||
})
|
||||
|
||||
await prisma.gmailScanHistory.create({
|
||||
data: {
|
||||
userId,
|
||||
messageId,
|
||||
category,
|
||||
data: extracted as object,
|
||||
noteId: note.id,
|
||||
},
|
||||
})
|
||||
created++
|
||||
}
|
||||
}
|
||||
|
||||
return { processed, created }
|
||||
}
|
||||
|
||||
async getStatus(userId: string) {
|
||||
const tokens = await getGoogleTokens(userId, 'gmail')
|
||||
const recent = await prisma.gmailScanHistory.count({
|
||||
where: {
|
||||
userId,
|
||||
scannedAt: { gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) },
|
||||
noteId: { not: null },
|
||||
},
|
||||
})
|
||||
return { connected: !!tokens, recentCaptures: recent }
|
||||
}
|
||||
}
|
||||
|
||||
export const gmailScannerService = new GmailScannerService()
|
||||
66
memento-note/lib/integrations/google-integration-tokens.ts
Normal file
66
memento-note/lib/integrations/google-integration-tokens.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import prisma from '@/lib/prisma'
|
||||
|
||||
export function getGoogleOAuthCredentials() {
|
||||
return {
|
||||
clientId: process.env.GOOGLE_CALENDAR_CLIENT_ID || process.env.AUTH_GOOGLE_ID || '',
|
||||
clientSecret: process.env.GOOGLE_CALENDAR_CLIENT_SECRET || process.env.AUTH_GOOGLE_SECRET || '',
|
||||
}
|
||||
}
|
||||
|
||||
export async function readIntegrationMeta(userId: string): Promise<Record<string, unknown>> {
|
||||
const aiSettings = await prisma.userAISettings.findUnique({
|
||||
where: { userId },
|
||||
select: { integrationTokens: true },
|
||||
})
|
||||
try {
|
||||
const raw = aiSettings?.integrationTokens
|
||||
if (!raw) return {}
|
||||
return typeof raw === 'string' ? JSON.parse(raw) : (raw as Record<string, unknown>)
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeIntegrationMeta(userId: string, meta: Record<string, unknown>) {
|
||||
await prisma.userAISettings.upsert({
|
||||
where: { userId },
|
||||
update: { integrationTokens: JSON.stringify(meta) },
|
||||
create: { userId, integrationTokens: JSON.stringify(meta) },
|
||||
})
|
||||
}
|
||||
|
||||
export async function getGoogleTokens(
|
||||
userId: string,
|
||||
prefix: 'calendar' | 'gmail',
|
||||
): Promise<{ accessToken: string; refreshToken: string } | null> {
|
||||
const meta = await readIntegrationMeta(userId)
|
||||
const accessToken = meta[`${prefix}AccessToken`] as string | undefined
|
||||
const refreshToken = meta[`${prefix}RefreshToken`] as string | undefined
|
||||
if (accessToken && refreshToken) return { accessToken, refreshToken }
|
||||
return null
|
||||
}
|
||||
|
||||
export async function refreshGoogleAccessToken(
|
||||
userId: string,
|
||||
prefix: 'calendar' | 'gmail',
|
||||
refreshToken: string,
|
||||
): Promise<string | null> {
|
||||
const { clientId, clientSecret } = getGoogleOAuthCredentials()
|
||||
const res = await fetch('https://oauth2.googleapis.com/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
refresh_token: refreshToken,
|
||||
grant_type: 'refresh_token',
|
||||
}),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!data.access_token) return null
|
||||
|
||||
const meta = await readIntegrationMeta(userId)
|
||||
meta[`${prefix}AccessToken`] = data.access_token
|
||||
await writeIntegrationMeta(userId, meta)
|
||||
return data.access_token as string
|
||||
}
|
||||
Reference in New Issue
Block a user