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>
298 lines
8.7 KiB
TypeScript
298 lines
8.7 KiB
TypeScript
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)
|
|
}
|