feat: brainstorm sessions, PDF document Q&A, embedding fixes, and UI improvements
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 7s
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 7s
- Add brainstorm feature with collaborative canvas, AI idea generation, live cursors, playback, and export - Add PDF upload/extraction/ingestion pipeline with pgvector document search (RAG) - Add document Q&A overlay with streaming chat and PDF preview - Add note attachments UI with status polling, grid layout, and auto-scroll - Add task extraction AI tool and agent executor improvements - Fix NoteEmbedding missing updatedAt column, re-index 66 notes with 1536-dim embeddings - Fix brainstorm 'Create Note' button: add success toast and redirect to created note - Fix memory echo notification infinite polling - Fix chat route to always include document_search tool - Add brainstorm i18n keys across all 14 locales - Add socket server for real-time brainstorm collaboration - Add hierarchical notebook selector and organize notebook dialog improvements - Add sidebar brainstorm section with session management - Update prisma schema with brainstorm tables, attachments, and document chunks
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { auth } from '@/auth'
|
||||
import { verifyParticipant } from '@/lib/brainstorm-collab'
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ sessionId: string }> }
|
||||
) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const { sessionId } = await params
|
||||
const { isParticipant } = await verifyParticipant(sessionId, session.user.id)
|
||||
|
||||
if (!isParticipant) {
|
||||
return NextResponse.json({ error: 'Not a participant' }, { status: 403 })
|
||||
}
|
||||
|
||||
const activities = await prisma.brainstormActivity.findMany({
|
||||
where: { sessionId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 50,
|
||||
include: {
|
||||
user: { select: { id: true, name: true, image: true } },
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: activities.map(a => ({
|
||||
id: a.id,
|
||||
action: a.action,
|
||||
details: a.details ? JSON.parse(a.details) : null,
|
||||
createdAt: a.createdAt,
|
||||
user: a.user ? { name: a.user.name, image: a.user.image } : null,
|
||||
})),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error fetching activity:', error)
|
||||
return NextResponse.json({ error: 'Failed to fetch activity' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
167
memento-note/app/api/brainstorm/[sessionId]/convert/route.ts
Normal file
167
memento-note/app/api/brainstorm/[sessionId]/convert/route.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { auth } from '@/auth'
|
||||
import { z } from 'zod'
|
||||
|
||||
const convertSchema = z.object({
|
||||
ideaId: z.string().min(1),
|
||||
})
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ sessionId: string }> }
|
||||
) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const { sessionId } = await params
|
||||
const body = await request.json()
|
||||
const { ideaId } = convertSchema.parse(body)
|
||||
|
||||
const brainstormSession = await prisma.brainstormSession.findFirst({
|
||||
where: {
|
||||
id: sessionId,
|
||||
OR: [
|
||||
{ userId: session.user.id },
|
||||
{ participants: { some: { userId: session.user.id } } },
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
if (!brainstormSession) {
|
||||
return NextResponse.json({ error: 'Session not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const idea = await prisma.brainstormIdea.findFirst({
|
||||
where: { id: ideaId, sessionId },
|
||||
include: { noteRefs: { include: { note: true } } },
|
||||
})
|
||||
|
||||
if (!idea) {
|
||||
return NextResponse.json({ error: 'Idea not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (idea.status === 'converted') {
|
||||
return NextResponse.json({ error: 'Already converted' }, { status: 400 })
|
||||
}
|
||||
|
||||
let sourceSection = ''
|
||||
let targetNotebookId: string | null = null
|
||||
|
||||
if (brainstormSession.exportedNoteId) {
|
||||
const exportedNote = await prisma.note.findUnique({
|
||||
where: { id: brainstormSession.exportedNoteId },
|
||||
select: { notebookId: true },
|
||||
})
|
||||
if (exportedNote?.notebookId) {
|
||||
targetNotebookId = exportedNote.notebookId
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetNotebookId && brainstormSession.sourceNoteId) {
|
||||
const sourceNote = await prisma.note.findUnique({
|
||||
where: { id: brainstormSession.sourceNoteId },
|
||||
select: { title: true, id: true, notebookId: true },
|
||||
})
|
||||
if (sourceNote) {
|
||||
sourceSection = `\n\n**Source note**: [${sourceNote.title || 'Untitled'}](note:${sourceNote.id})`
|
||||
targetNotebookId = sourceNote.notebookId
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetNotebookId) {
|
||||
const notebookName = brainstormSession.seedIdea.length > 40
|
||||
? brainstormSession.seedIdea.substring(0, 40).trim() + '…'
|
||||
: brainstormSession.seedIdea
|
||||
let notebook = await prisma.notebook.findFirst({
|
||||
where: { userId: session.user.id, name: notebookName, trashedAt: null },
|
||||
})
|
||||
if (!notebook) {
|
||||
const notebookCount = await prisma.notebook.count({ where: { userId: session.user.id, trashedAt: null } })
|
||||
notebook = await prisma.notebook.create({
|
||||
data: { userId: session.user.id, name: notebookName, order: notebookCount, icon: 'wind' },
|
||||
})
|
||||
}
|
||||
targetNotebookId = notebook.id
|
||||
}
|
||||
|
||||
let originSection = ''
|
||||
const validRefs = idea.noteRefs.filter(r => r.noteId && r.note)
|
||||
if (validRefs.length > 0) {
|
||||
originSection = '\n\n## Origin\n'
|
||||
for (const ref of validRefs) {
|
||||
const relLabel = {
|
||||
derived_from: 'Derived from',
|
||||
opposes: 'In opposition with',
|
||||
extends: 'Extends',
|
||||
synthesizes: 'Synthesizes',
|
||||
transposes: 'Transposes',
|
||||
}[ref.relation] || 'Related to'
|
||||
originSection += `- **${relLabel}** [${ref.note?.title || 'Untitled'}](note:${ref.noteId}): ${ref.explanation}\n`
|
||||
}
|
||||
}
|
||||
|
||||
const noteContent = `# ${idea.title}\n\n${idea.description}\n\n---\n\n**Connection to seed**: ${idea.connectionToSeed || 'N/A'}\n**Novelty score**: ${idea.noveltyScore || 'N/A'}/10\n\n**Source brainstorm**: "${brainstormSession.seedIdea}"${sourceSection}${originSection}`
|
||||
|
||||
const note = await prisma.note.create({
|
||||
data: {
|
||||
userId: session.user.id,
|
||||
title: idea.title,
|
||||
content: noteContent,
|
||||
type: 'markdown',
|
||||
labels: JSON.stringify(['brainstorm', 'idée']),
|
||||
notebookId: targetNotebookId,
|
||||
},
|
||||
})
|
||||
|
||||
const tagPromises: Promise<any>[] = []
|
||||
|
||||
tagPromises.push(
|
||||
prisma.brainstormIdea.update({
|
||||
where: { id: ideaId },
|
||||
data: {
|
||||
status: 'converted',
|
||||
convertedToNoteId: note.id,
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
tagPromises.push(
|
||||
prisma.brainstormNoteRef.updateMany({
|
||||
where: { ideaId },
|
||||
data: { verdict: 'accepted' },
|
||||
})
|
||||
)
|
||||
|
||||
for (const ref of validRefs) {
|
||||
const existingLabels: string[] = (() => {
|
||||
try { return JSON.parse((ref.note as any)?.labels || '[]') } catch { return [] }
|
||||
})()
|
||||
if (!existingLabels.includes('brainstorm-fruitful')) {
|
||||
existingLabels.push('brainstorm-fruitful')
|
||||
tagPromises.push(
|
||||
prisma.note.update({
|
||||
where: { id: ref.noteId! },
|
||||
data: { labels: JSON.stringify(existingLabels) },
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.$transaction(tagPromises)
|
||||
|
||||
return NextResponse.json({ success: true, data: note }, { status: 201 })
|
||||
} catch (error: any) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ error: error.issues }, { status: 400 })
|
||||
}
|
||||
console.error('Error converting idea:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error.message || 'Failed to convert idea' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
72
memento-note/app/api/brainstorm/[sessionId]/dismiss/route.ts
Normal file
72
memento-note/app/api/brainstorm/[sessionId]/dismiss/route.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { auth } from '@/auth'
|
||||
import { z } from 'zod'
|
||||
import { verifyParticipant, logActivity, captureSnapshot } from '@/lib/brainstorm-collab'
|
||||
|
||||
const dismissSchema = z.object({
|
||||
ideaId: z.string().min(1),
|
||||
})
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ sessionId: string }> }
|
||||
) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const { sessionId } = await params
|
||||
const body = await request.json()
|
||||
const { ideaId } = dismissSchema.parse(body)
|
||||
|
||||
const brainstormSession = await prisma.brainstormSession.findFirst({
|
||||
where: { id: sessionId },
|
||||
})
|
||||
|
||||
if (!brainstormSession) {
|
||||
return NextResponse.json({ error: 'Session not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const { isParticipant } = await verifyParticipant(sessionId, session.user.id, 'editor')
|
||||
if (!isParticipant) {
|
||||
return NextResponse.json({ error: 'No edit permission' }, { status: 403 })
|
||||
}
|
||||
|
||||
const idea = await prisma.brainstormIdea.findFirst({
|
||||
where: { id: ideaId, sessionId },
|
||||
})
|
||||
|
||||
if (!idea) {
|
||||
return NextResponse.json({ error: 'Idea not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.brainstormIdea.update({
|
||||
where: { id: ideaId },
|
||||
data: { status: 'dismissed' },
|
||||
}),
|
||||
prisma.brainstormNoteRef.updateMany({
|
||||
where: { ideaId },
|
||||
data: { verdict: 'dismissed' },
|
||||
}),
|
||||
])
|
||||
|
||||
await logActivity(sessionId, 'idea_dismissed', session.user.id, { ideaTitle: idea.title })
|
||||
|
||||
await captureSnapshot(sessionId, `Dismissed: ${idea.title}`).catch(() => {})
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error: any) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ error: error.issues }, { status: 400 })
|
||||
}
|
||||
console.error('Error dismissing idea:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error.message || 'Failed to dismiss idea' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
323
memento-note/app/api/brainstorm/[sessionId]/expand/route.ts
Normal file
323
memento-note/app/api/brainstorm/[sessionId]/expand/route.ts
Normal file
@@ -0,0 +1,323 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { auth } from '@/auth'
|
||||
import { z } from 'zod'
|
||||
import { getTagsProvider } from '@/lib/ai/factory'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
import { embeddingService } from '@/lib/ai/services/embedding.service'
|
||||
import {
|
||||
verifyParticipant,
|
||||
resolveAiContextUserId,
|
||||
sanitizeNotesForGuest,
|
||||
captureSnapshot,
|
||||
} from '@/lib/brainstorm-collab'
|
||||
|
||||
const expandSchema = z.object({
|
||||
ideaId: z.string().min(1),
|
||||
locale: z.string().optional(),
|
||||
})
|
||||
|
||||
interface ParentNoteRef {
|
||||
noteId: string | null
|
||||
relation: string
|
||||
explanation: string
|
||||
noteTitle?: string | null
|
||||
noteSnippet?: string
|
||||
}
|
||||
|
||||
// [UPDATE - SÉCURITÉ] allowedNoteIds : null = hôte (accès total), string[] = invité (IDs publics uniquement)
|
||||
async function getParentContext(
|
||||
ideaId: string,
|
||||
hostUserId: string,
|
||||
allowedNoteIds: string[] | null
|
||||
): Promise<{ notes: ParentNoteRef[]; noteIds: string[] }> {
|
||||
const refs = await prisma.brainstormNoteRef.findMany({
|
||||
where: { ideaId },
|
||||
include: { note: { select: { id: true, title: true, content: true } } },
|
||||
})
|
||||
|
||||
let noteIds = refs.map(r => r.noteId).filter(Boolean) as string[]
|
||||
|
||||
const notes: ParentNoteRef[] = refs.map(r => ({
|
||||
noteId: r.noteId,
|
||||
relation: r.relation,
|
||||
explanation: r.explanation,
|
||||
noteTitle: r.note?.title || null,
|
||||
noteSnippet: (r.note?.content || '').slice(0, 200),
|
||||
}))
|
||||
|
||||
// [UPDATE - SÉCURITÉ] Enrichissement vectoriel uniquement pour l'hôte
|
||||
const isGuest = allowedNoteIds !== null
|
||||
if (!isGuest && noteIds.length < 3) {
|
||||
try {
|
||||
const idea = await prisma.brainstormIdea.findUnique({ where: { id: ideaId } })
|
||||
if (idea) {
|
||||
const embedding = await embeddingService.generateEmbedding(`${idea.title} ${idea.description}`)
|
||||
const vectorStr = embeddingService.toVectorString(embedding.embedding)
|
||||
const excludeList = noteIds.length > 0
|
||||
? noteIds.map(id => `'${id}'`).join(',')
|
||||
: "''"
|
||||
const extra = await prisma.$queryRawUnsafe(
|
||||
`SELECT n.id, n.title
|
||||
FROM "NoteEmbedding" e
|
||||
JOIN "Note" n ON n.id = e."noteId"
|
||||
WHERE n."userId" = $1 AND n."trashedAt" IS NULL AND n.id NOT IN (${excludeList})
|
||||
ORDER BY e.embedding <=> $2::vector
|
||||
LIMIT 5`,
|
||||
hostUserId, vectorStr
|
||||
) as any[]
|
||||
for (const n of extra) {
|
||||
if (!noteIds.includes(n.id)) {
|
||||
noteIds.push(n.id)
|
||||
notes.push({ noteId: n.id, relation: 'extends', explanation: `Related to your note "${n.title}"`, noteTitle: n.title })
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// [UPDATE - SÉCURITÉ] Filtrer selon les permissions invité
|
||||
if (isGuest) {
|
||||
const allowedSet = new Set(allowedNoteIds)
|
||||
return {
|
||||
notes: notes.filter(n => n.noteId === null || allowedSet.has(n.noteId!)),
|
||||
noteIds: noteIds.filter(id => allowedSet.has(id)),
|
||||
}
|
||||
}
|
||||
|
||||
return { notes, noteIds }
|
||||
}
|
||||
|
||||
function buildExpandPromptV2(
|
||||
parentTitle: string,
|
||||
parentDesc: string,
|
||||
seedIdea: string,
|
||||
parentRefs: ParentNoteRef[],
|
||||
extraNotes: { id: string; title: string; snippet: string }[],
|
||||
locale?: string
|
||||
): string {
|
||||
let notesSection = ''
|
||||
const allNotes = [
|
||||
...parentRefs.filter(r => r.noteId).map(r => ({
|
||||
id: r.noteId!,
|
||||
title: r.noteTitle || 'Untitled',
|
||||
snippet: r.noteSnippet || '',
|
||||
relation: r.relation,
|
||||
})),
|
||||
...extraNotes,
|
||||
]
|
||||
|
||||
if (allNotes.length > 0) {
|
||||
notesSection = `\nUSER'S NOTES (context from parent idea and knowledge base):\n`
|
||||
notesSection += allNotes.map(n => `- [ID: ${n.id}] "${n.title}": ${n.snippet || 'See note for details'}`).join('\n')
|
||||
}
|
||||
|
||||
return `You are a creative brainstorming assistant. The user wants to DEEPEN a specific idea from a brainstorming session. Generate 3 waves of sub-ideas that CROSS with the user's existing notes.
|
||||
|
||||
ORIGINAL SESSION SEED: ${seedIdea}
|
||||
PARENT IDEA TO EXPAND: ${parentTitle}: ${parentDesc}
|
||||
${notesSection}
|
||||
|
||||
GENERATION RULES:
|
||||
|
||||
WAVE 1 — VARIATIONS (3 sub-ideas):
|
||||
- Direct expansions, details, or implementations of the parent idea
|
||||
- At least 1 should build on a note referenced above
|
||||
|
||||
WAVE 2 — ANALOGIES (3 sub-ideas):
|
||||
- Cross-domain parallels from other fields
|
||||
- At least 1 should transpose a pattern from the user's notes
|
||||
|
||||
WAVE 3 — DISRUPTIONS (3 sub-ideas):
|
||||
- Radical inversions or challenges to the parent idea
|
||||
- At least 1 should synthesize or oppose a note concept
|
||||
|
||||
RESPOND ONLY with a valid JSON array of 9 objects:
|
||||
{
|
||||
"wave": number (1, 2, or 3),
|
||||
"title": string (short, 2-6 words),
|
||||
"description": string (1-2 sentences, specific and actionable),
|
||||
"connectionToSeed": string (how it relates to the parent idea),
|
||||
"noveltyScore": number (1-10),
|
||||
"noteRefs": [
|
||||
{
|
||||
"noteId": string (must match an ID provided above, or null),
|
||||
"relation": "derived_from" | "opposes" | "extends" | "synthesizes" | "transposes",
|
||||
"explanation": string
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
CRITICAL: Each idea MUST have at least 1 noteRef when notes are provided.
|
||||
|
||||
LANGUAGE: You MUST write ALL titles, descriptions, connectionToSeed, and explanation fields in ${locale === 'fr' ? 'French' : locale === 'es' ? 'Spanish' : locale === 'de' ? 'German' : locale === 'it' ? 'Italian' : locale === 'pt' ? 'Portuguese' : locale === 'nl' ? 'Dutch' : locale === 'ru' ? 'Russian' : locale === 'zh' ? 'Chinese' : locale === 'ja' ? 'Japanese' : locale === 'ko' ? 'Korean' : locale === 'ar' ? 'Arabic' : locale === 'fa' ? 'Farsi' : locale === 'hi' ? 'Hindi' : locale === 'pl' ? 'Polish' : 'the same language as the seed idea'}.`
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ sessionId: string }> }
|
||||
) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const { sessionId } = await params
|
||||
const body = await request.json()
|
||||
const { ideaId, locale } = expandSchema.parse(body)
|
||||
|
||||
// [UPDATE - SÉCURITÉ] Vérification du rôle participant (couvre hôte + invités éditeurs)
|
||||
const { isParticipant } = await verifyParticipant(sessionId, session.user.id, 'editor')
|
||||
if (!isParticipant) {
|
||||
return NextResponse.json({ error: 'No edit permission' }, { status: 403 })
|
||||
}
|
||||
|
||||
const brainstormSession = await prisma.brainstormSession.findFirst({
|
||||
where: { id: sessionId },
|
||||
include: { ideas: true },
|
||||
})
|
||||
|
||||
if (!brainstormSession) {
|
||||
return NextResponse.json({ error: 'Session not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const parentIdea = brainstormSession.ideas.find(i => i.id === ideaId)
|
||||
if (!parentIdea) {
|
||||
return NextResponse.json({ error: 'Idea not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// [UPDATE - SÉCURITÉ] Résoudre le périmètre de notes autorisé selon le rôle
|
||||
const { isGuest, publicNoteIds, aiUserId } = await resolveAiContextUserId(sessionId, session.user.id)
|
||||
|
||||
const { notes: parentRefs, noteIds } = await getParentContext(ideaId, aiUserId, isGuest ? (publicNoteIds ?? []) : null)
|
||||
|
||||
let extraNotes: { id: string; title: string; snippet: string }[] = []
|
||||
if (noteIds.length > 0) {
|
||||
const dbNotes = await prisma.note.findMany({
|
||||
where: { id: { in: noteIds }, trashedAt: null },
|
||||
select: { id: true, title: true, content: true },
|
||||
})
|
||||
const rawNotes = dbNotes.map(n => ({ id: n.id, title: n.title || 'Untitled', summary: (n.content || '').slice(0, 200) }))
|
||||
// [UPDATE - SÉCURITÉ] Sanitize le contenu si invité
|
||||
const sanitized = isGuest ? sanitizeNotesForGuest(rawNotes) : rawNotes
|
||||
extraNotes = sanitized.map(n => ({ id: n.id, title: n.title, snippet: n.summary }))
|
||||
}
|
||||
|
||||
const config = await getSystemConfig()
|
||||
const provider = getTagsProvider(config)
|
||||
|
||||
const prompt = buildExpandPromptV2(
|
||||
parentIdea.title,
|
||||
parentIdea.description,
|
||||
brainstormSession.seedIdea,
|
||||
parentRefs,
|
||||
extraNotes,
|
||||
locale
|
||||
)
|
||||
|
||||
const llmResponse = await provider.generateText(prompt)
|
||||
|
||||
let newIdeas: any[]
|
||||
try {
|
||||
const cleaned = llmResponse.replace(/```json\n?/g, '').replace(/```\n?/g, '').trim()
|
||||
newIdeas = JSON.parse(cleaned)
|
||||
if (!Array.isArray(newIdeas)) throw new Error('Not an array')
|
||||
} catch {
|
||||
const pickNote = (idx: number) => {
|
||||
const n = extraNotes[idx % extraNotes.length]
|
||||
return n ? { noteId: n.id, relation: 'extends' as const, explanation: `Related to "${n.title}"` } : { noteId: null, relation: 'extends' as const, explanation: 'Purely generative' }
|
||||
}
|
||||
newIdeas = [
|
||||
{ wave: 1, title: 'Sub-variation A', description: 'Direct expansion.', connectionToSeed: 'Expansion', noveltyScore: 4, noteRefs: [pickNote(0)] },
|
||||
{ wave: 1, title: 'Sub-variation B', description: 'Another angle.', connectionToSeed: 'Detail', noveltyScore: 5, noteRefs: [pickNote(1)] },
|
||||
{ wave: 1, title: 'Sub-variation C', description: 'Implementation detail.', connectionToSeed: 'Implementation', noveltyScore: 3, noteRefs: [pickNote(2)] },
|
||||
{ wave: 2, title: 'Sub-analogy A', description: 'Cross-domain parallel.', connectionToSeed: 'Analogy', noveltyScore: 7, noteRefs: [pickNote(0)] },
|
||||
{ wave: 2, title: 'Sub-analogy B', description: 'From another field.', connectionToSeed: 'Parallel', noveltyScore: 6, noteRefs: [pickNote(1)] },
|
||||
{ wave: 2, title: 'Sub-analogy C', description: 'Inspired by nature.', connectionToSeed: 'Bio-inspired', noveltyScore: 7, noteRefs: [pickNote(2)] },
|
||||
{ wave: 3, title: 'Sub-disruption A', description: 'Challenge assumption.', connectionToSeed: 'Inversion', noveltyScore: 9, noteRefs: [{ ...pickNote(0), relation: 'opposes' as const }] },
|
||||
{ wave: 3, title: 'Sub-disruption B', description: 'Remove constraint.', connectionToSeed: 'Removal', noveltyScore: 8, noteRefs: [{ ...pickNote(1), relation: 'synthesizes' as const }] },
|
||||
{ wave: 3, title: 'Sub-disruption C', description: 'Radical reframe.', connectionToSeed: 'Reframe', noveltyScore: 10, noteRefs: [pickNote(2)] },
|
||||
]
|
||||
}
|
||||
|
||||
const validNoteIds = new Set(extraNotes.map(n => n.id))
|
||||
|
||||
for (let idx = 0; idx < newIdeas.length; idx++) {
|
||||
const idea = newIdeas[idx]
|
||||
const angle = (idx % 3) * (2 * Math.PI / 3) + (idea.wave - 1) * 0.5
|
||||
const baseRadius = 150
|
||||
const parentRadius = (parentIdea.positionX && parentIdea.positionY)
|
||||
? Math.sqrt(parentIdea.positionX ** 2 + parentIdea.positionY ** 2)
|
||||
: 0
|
||||
const radius = parentRadius + idea.wave * baseRadius
|
||||
const baseAngle = (parentIdea.positionX && parentIdea.positionY)
|
||||
? Math.atan2(parentIdea.positionY, parentIdea.positionX)
|
||||
: 0
|
||||
|
||||
const created = await prisma.brainstormIdea.create({
|
||||
data: {
|
||||
sessionId,
|
||||
waveNumber: idea.wave || Math.floor(idx / 3) + 1,
|
||||
title: idea.title || `Idea ${idx + 1}`,
|
||||
description: idea.description || '',
|
||||
connectionToSeed: idea.connectionToSeed || null,
|
||||
noveltyScore: idea.noveltyScore || null,
|
||||
parentIdeaId: ideaId,
|
||||
positionX: Math.cos(baseAngle + angle) * radius,
|
||||
positionY: Math.sin(baseAngle + angle) * radius,
|
||||
},
|
||||
})
|
||||
|
||||
if (idea.noteRefs && Array.isArray(idea.noteRefs)) {
|
||||
for (const ref of idea.noteRefs) {
|
||||
const noteId = ref.noteId && validNoteIds.has(ref.noteId) ? ref.noteId : null
|
||||
await prisma.brainstormNoteRef.create({
|
||||
data: {
|
||||
ideaId: created.id,
|
||||
noteId,
|
||||
relation: ref.relation || 'extends',
|
||||
explanation: ref.explanation || '',
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const updatedSession = await prisma.brainstormSession.findUnique({
|
||||
where: { id: sessionId },
|
||||
include: {
|
||||
ideas: {
|
||||
orderBy: [{ waveNumber: 'asc' }, { createdAt: 'asc' }],
|
||||
include: {
|
||||
noteRefs: {
|
||||
include: {
|
||||
note: { select: { id: true, title: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const cIds = [...new Set((updatedSession?.ideas || []).map((i: any) => i.createdBy).filter(Boolean))]
|
||||
if (cIds.length > 0) {
|
||||
const crs = await prisma.user.findMany({ where: { id: { in: cIds } }, select: { id: true, name: true, image: true } })
|
||||
const cm = new Map(crs.map((c: any) => [c.id, c]))
|
||||
for (const idea of updatedSession?.ideas || []) { (idea as any).creator = (idea as any).createdBy ? cm.get((idea as any).createdBy) || null : null }
|
||||
}
|
||||
|
||||
await captureSnapshot(sessionId, `Wave expanded: ${parentIdea.title}`).catch(() => {})
|
||||
|
||||
return NextResponse.json({ success: true, data: updatedSession })
|
||||
} catch (error: any) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ error: error.issues }, { status: 400 })
|
||||
}
|
||||
console.error('Error expanding idea:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error.message || 'Failed to expand idea' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
194
memento-note/app/api/brainstorm/[sessionId]/export/route.ts
Normal file
194
memento-note/app/api/brainstorm/[sessionId]/export/route.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { auth } from '@/auth'
|
||||
import { verifyParticipant } from '@/lib/brainstorm-collab'
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ sessionId: string }> }
|
||||
) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const { sessionId } = await params
|
||||
|
||||
const brainstormSession = await prisma.brainstormSession.findFirst({
|
||||
where: {
|
||||
id: sessionId,
|
||||
OR: [
|
||||
{ userId: session.user.id },
|
||||
{ participants: { some: { userId: session.user.id } } },
|
||||
],
|
||||
},
|
||||
include: {
|
||||
ideas: {
|
||||
orderBy: [{ waveNumber: 'asc' }, { createdAt: 'asc' }],
|
||||
include: {
|
||||
noteRefs: {
|
||||
include: {
|
||||
note: { select: { id: true, title: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!brainstormSession) {
|
||||
return NextResponse.json({ error: 'Session not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (brainstormSession.exportedNoteId) {
|
||||
const existingNote = await prisma.note.findUnique({
|
||||
where: { id: brainstormSession.exportedNoteId },
|
||||
})
|
||||
if (existingNote) {
|
||||
return NextResponse.json({ success: true, data: existingNote })
|
||||
}
|
||||
}
|
||||
|
||||
const waveLabels: Record<number, string> = {
|
||||
1: '🔄 Variations',
|
||||
2: '🔗 Analogies',
|
||||
3: '💥 Disruptions',
|
||||
}
|
||||
|
||||
const activeIdeas = brainstormSession.ideas.filter(i => i.status !== 'dismissed')
|
||||
const convertedIdeas = brainstormSession.ideas.filter(i => i.status === 'converted')
|
||||
const dismissedCount = brainstormSession.ideas.filter(i => i.status === 'dismissed').length
|
||||
|
||||
let markdown = `# Brainstorm: ${brainstormSession.seedIdea}\n\n`
|
||||
markdown += `> Generated on ${brainstormSession.createdAt.toLocaleDateString()}\n\n`
|
||||
markdown += `---\n\n`
|
||||
markdown += `**Summary**: ${activeIdeas.length} active ideas, ${convertedIdeas.length} converted to notes, ${dismissedCount} dismissed.\n\n`
|
||||
|
||||
for (let wave = 1; wave <= 3; wave++) {
|
||||
const waveIdeas = activeIdeas.filter(i => i.waveNumber === wave)
|
||||
if (waveIdeas.length === 0) continue
|
||||
|
||||
markdown += `## ${waveLabels[wave] || `Wave ${wave}`}\n\n`
|
||||
for (const idea of waveIdeas) {
|
||||
const statusIcon = idea.status === 'converted' ? '✅' : idea.status === 'dismissed' ? '❌' : '💡'
|
||||
markdown += `### ${statusIcon} ${idea.title}\n`
|
||||
markdown += `${idea.description}\n\n`
|
||||
markdown += `- **Connection**: ${idea.connectionToSeed || 'N/A'}\n`
|
||||
markdown += `- **Novelty**: ${idea.noveltyScore || 'N/A'}/10\n`
|
||||
|
||||
const validRefs = (idea.noteRefs || []).filter(r => r.noteId && r.note)
|
||||
if (validRefs.length > 0) {
|
||||
markdown += `- **Origin**:\n`
|
||||
for (const ref of validRefs) {
|
||||
const relLabel = {
|
||||
derived_from: 'Derived from',
|
||||
opposes: 'Opposes',
|
||||
extends: 'Extends',
|
||||
synthesizes: 'Synthesizes',
|
||||
transposes: 'Transposes',
|
||||
}[ref.relation] || 'Related to'
|
||||
markdown += ` - ${relLabel} [${ref.note?.title || 'Untitled'}](note:${ref.noteId}): ${ref.explanation}\n`
|
||||
}
|
||||
}
|
||||
|
||||
if (idea.convertedToNoteId) {
|
||||
markdown += `- **→ Converted to note**: ${idea.convertedToNoteId}\n`
|
||||
}
|
||||
if (idea.parentIdeaId) {
|
||||
const parent = brainstormSession.ideas.find(i => i.id === idea.parentIdeaId)
|
||||
if (parent) {
|
||||
markdown += `- **Parent idea**: ${parent.title}\n`
|
||||
}
|
||||
}
|
||||
markdown += `\n`
|
||||
}
|
||||
}
|
||||
|
||||
const allReferencedNoteIds = new Set<string>()
|
||||
for (const idea of brainstormSession.ideas) {
|
||||
for (const ref of idea.noteRefs || []) {
|
||||
if (ref.noteId) allReferencedNoteIds.add(ref.noteId)
|
||||
}
|
||||
}
|
||||
|
||||
if (allReferencedNoteIds.size > 0) {
|
||||
markdown += `---\n\n## Notes sollicitées\n\n`
|
||||
const refNotes = await prisma.note.findMany({
|
||||
where: { id: { in: Array.from(allReferencedNoteIds) } },
|
||||
select: { id: true, title: true },
|
||||
})
|
||||
for (const note of refNotes) {
|
||||
const refsForNote = brainstormSession.ideas.flatMap(i =>
|
||||
(i.noteRefs || []).filter(r => r.noteId === note.id)
|
||||
)
|
||||
const accepted = refsForNote.filter(r => r.verdict === 'accepted').length
|
||||
const dismissed = refsForNote.filter(r => r.verdict === 'dismissed').length
|
||||
const verdictStr = accepted > 0 ? `✅ ${accepted} idea(s) accepted` : dismissed > 0 ? `❌ all dismissed` : '⏳ unresolved'
|
||||
markdown += `- [${note.title || 'Untitled'}](note:${note.id}) — ${verdictStr}\n`
|
||||
}
|
||||
markdown += `\n`
|
||||
}
|
||||
|
||||
if (convertedIdeas.length > 0) {
|
||||
markdown += `## Converted Notes\n\n`
|
||||
const convertedNoteIds = convertedIdeas
|
||||
.map(i => i.convertedToNoteId)
|
||||
.filter(Boolean) as string[]
|
||||
|
||||
if (convertedNoteIds.length > 0) {
|
||||
const notes = await prisma.note.findMany({
|
||||
where: { id: { in: convertedNoteIds } },
|
||||
select: { id: true, title: true },
|
||||
})
|
||||
for (const note of notes) {
|
||||
markdown += `- [${note.title || 'Untitled'}](note:${note.id})\n`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const notebookName = brainstormSession.seedIdea.length > 40
|
||||
? brainstormSession.seedIdea.substring(0, 40).trim() + '…'
|
||||
: brainstormSession.seedIdea
|
||||
|
||||
let notebook = await prisma.notebook.findFirst({
|
||||
where: { userId: session.user.id, name: notebookName, trashedAt: null },
|
||||
})
|
||||
|
||||
if (!notebook) {
|
||||
const notebookCount = await prisma.notebook.count({ where: { userId: session.user.id, trashedAt: null } })
|
||||
notebook = await prisma.notebook.create({
|
||||
data: {
|
||||
userId: session.user.id,
|
||||
name: notebookName,
|
||||
order: notebookCount,
|
||||
icon: 'wind',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const note = await prisma.note.create({
|
||||
data: {
|
||||
userId: session.user.id,
|
||||
title: `Synthèse: ${brainstormSession.seedIdea.slice(0, 50)}`,
|
||||
content: markdown,
|
||||
type: 'markdown',
|
||||
labels: JSON.stringify(['brainstorm', 'export']),
|
||||
notebookId: notebook.id,
|
||||
},
|
||||
})
|
||||
|
||||
await prisma.brainstormSession.update({
|
||||
where: { id: sessionId },
|
||||
data: { exportedNoteId: note.id },
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true, data: { ...note, _notebookName: notebookName } }, { status: 201 })
|
||||
} catch (error) {
|
||||
console.error('Error exporting brainstorm:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to export brainstorm' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { auth } from '@/auth'
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ sessionId: string }> }
|
||||
) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const { sessionId } = await params
|
||||
|
||||
const brainstormSession = await prisma.brainstormSession.findFirst({
|
||||
where: {
|
||||
id: sessionId,
|
||||
OR: [
|
||||
{ userId: session.user.id },
|
||||
{ participants: { some: { userId: session.user.id } } },
|
||||
],
|
||||
},
|
||||
include: {
|
||||
ideas: {
|
||||
include: {
|
||||
noteRefs: {
|
||||
include: {
|
||||
note: { select: { id: true, title: true, labels: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!brainstormSession) {
|
||||
return NextResponse.json({ error: 'Session not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const noteImpactMap = new Map<string, { title: string; labels: string[]; acceptedCount: number; dismissedCount: number }>()
|
||||
|
||||
for (const idea of brainstormSession.ideas) {
|
||||
for (const ref of idea.noteRefs) {
|
||||
if (!ref.noteId || !ref.note) continue
|
||||
const existing = noteImpactMap.get(ref.noteId) || {
|
||||
title: ref.note.title || 'Untitled',
|
||||
labels: (() => { try { return JSON.parse(ref.note.labels || '[]') } catch { return [] } })(),
|
||||
acceptedCount: 0,
|
||||
dismissedCount: 0,
|
||||
}
|
||||
if (ref.verdict === 'accepted') existing.acceptedCount++
|
||||
else if (ref.verdict === 'dismissed') existing.dismissedCount++
|
||||
noteImpactMap.set(ref.noteId, existing)
|
||||
}
|
||||
}
|
||||
|
||||
const updatePromises: Promise<any>[] = []
|
||||
|
||||
for (const [noteId, impact] of noteImpactMap) {
|
||||
if (impact.acceptedCount === 0 && impact.dismissedCount > 0) {
|
||||
if (!impact.labels.includes('brainstorm-dry')) {
|
||||
impact.labels.push('brainstorm-dry')
|
||||
updatePromises.push(
|
||||
prisma.note.update({
|
||||
where: { id: noteId },
|
||||
data: { labels: JSON.stringify(impact.labels) },
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (updatePromises.length > 0) {
|
||||
await prisma.$transaction(updatePromises)
|
||||
}
|
||||
|
||||
const fruitful = Array.from(noteImpactMap.values()).filter(n => n.acceptedCount > 0).length
|
||||
const dry = Array.from(noteImpactMap.values()).filter(n => n.acceptedCount === 0 && n.dismissedCount > 0).length
|
||||
const totalRefs = Array.from(noteImpactMap.values()).length
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
impact: {
|
||||
notesSolicited: totalRefs,
|
||||
notesEnriched: fruitful,
|
||||
notesMarkedDry: dry,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error finalizing brainstorm:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to finalize brainstorm' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
123
memento-note/app/api/brainstorm/[sessionId]/invite/route.ts
Normal file
123
memento-note/app/api/brainstorm/[sessionId]/invite/route.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { auth } from '@/auth'
|
||||
import { z } from 'zod'
|
||||
import crypto from 'crypto'
|
||||
import { verifyParticipant, logActivity } from '@/lib/brainstorm-collab'
|
||||
|
||||
const inviteSchema = z.object({
|
||||
role: z.enum(['editor', 'viewer']).default('editor'),
|
||||
expiresInHours: z.number().min(1).max(168).default(24),
|
||||
email: z.string().email().optional(),
|
||||
})
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ sessionId: string }> }
|
||||
) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const { sessionId } = await params
|
||||
const { isParticipant } = await verifyParticipant(sessionId, session.user.id, 'host')
|
||||
|
||||
if (!isParticipant) {
|
||||
return NextResponse.json({ error: 'Only the host can invite' }, { status: 403 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { role: inviteRole, expiresInHours, email } = inviteSchema.parse(body)
|
||||
|
||||
const brainstormSession = await prisma.brainstormSession.findUnique({
|
||||
where: { id: sessionId },
|
||||
select: { id: true, seedIdea: true, userId: true, inviteToken: true, inviteExpiry: true },
|
||||
})
|
||||
|
||||
if (!brainstormSession) {
|
||||
return NextResponse.json({ error: 'Session not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
let token = brainstormSession.inviteToken
|
||||
let expiry = brainstormSession.inviteExpiry
|
||||
|
||||
if (!token || (expiry && expiry < new Date())) {
|
||||
token = crypto.randomBytes(24).toString('hex')
|
||||
expiry = new Date(Date.now() + expiresInHours * 60 * 60 * 1000)
|
||||
await prisma.brainstormSession.update({
|
||||
where: { id: sessionId },
|
||||
data: { inviteToken: token, inviteExpiry: expiry },
|
||||
})
|
||||
}
|
||||
|
||||
const inviteUrl = `/brainstorm?invite=${token}`
|
||||
|
||||
if (email) {
|
||||
const recipient = await prisma.user.findUnique({
|
||||
where: { email },
|
||||
select: { id: true, name: true, email: true },
|
||||
})
|
||||
|
||||
if (!recipient) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (recipient.id === session.user.id) {
|
||||
return NextResponse.json({ error: 'Cannot invite yourself' }, { status: 400 })
|
||||
}
|
||||
|
||||
const existing = await prisma.brainstormParticipant.findFirst({
|
||||
where: { sessionId, userId: recipient.id },
|
||||
})
|
||||
|
||||
if (existing) {
|
||||
return NextResponse.json({ error: 'Already a participant' }, { status: 409 })
|
||||
}
|
||||
|
||||
await prisma.notification.create({
|
||||
data: {
|
||||
userId: recipient.id,
|
||||
type: 'brainstorm_invite',
|
||||
title: brainstormSession.seedIdea.length > 50
|
||||
? brainstormSession.seedIdea.substring(0, 50) + '…'
|
||||
: brainstormSession.seedIdea,
|
||||
message: `${session.user.name || 'Someone'} invited you to a brainstorm session`,
|
||||
actionUrl: inviteUrl,
|
||||
relatedId: sessionId,
|
||||
},
|
||||
})
|
||||
|
||||
await logActivity(sessionId, 'invite_created', session.user.id, {
|
||||
role: inviteRole,
|
||||
targetEmail: email,
|
||||
targetUserId: recipient.id,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
mode: 'email',
|
||||
invitedUser: { name: recipient.name, email: recipient.email },
|
||||
inviteUrl,
|
||||
expiresAt: expiry,
|
||||
})
|
||||
}
|
||||
|
||||
await logActivity(sessionId, 'invite_created', session.user.id, { role: inviteRole })
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
mode: 'link',
|
||||
inviteUrl,
|
||||
token,
|
||||
expiresAt: expiry,
|
||||
})
|
||||
} catch (error: any) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ error: error.issues }, { status: 400 })
|
||||
}
|
||||
console.error('Error creating invite:', error)
|
||||
return NextResponse.json({ error: 'Failed to create invite' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
224
memento-note/app/api/brainstorm/[sessionId]/manual-idea/route.ts
Normal file
224
memento-note/app/api/brainstorm/[sessionId]/manual-idea/route.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { auth } from '@/auth'
|
||||
import { z } from 'zod'
|
||||
import { verifyParticipant, logActivity, resolveAiContextUserId, captureSnapshot } from '@/lib/brainstorm-collab'
|
||||
import { embeddingService } from '@/lib/ai/services/embedding.service'
|
||||
import { getTagsProvider } from '@/lib/ai/factory'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
import { emitToSession } from '@/lib/socket-emit'
|
||||
|
||||
const manualSchema = z.object({
|
||||
title: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
parentIdeaId: z.string().optional(),
|
||||
locale: z.string().optional(),
|
||||
})
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ sessionId: string }> }
|
||||
) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const { sessionId } = await params
|
||||
const { isParticipant } = await verifyParticipant(sessionId, session.user.id, 'editor')
|
||||
|
||||
if (!isParticipant) {
|
||||
return NextResponse.json({ error: 'No edit permission' }, { status: 403 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { title, description, parentIdeaId, locale } = manualSchema.parse(body)
|
||||
|
||||
const brainstormSession = await prisma.brainstormSession.findFirst({
|
||||
where: { id: sessionId },
|
||||
})
|
||||
|
||||
if (!brainstormSession) {
|
||||
return NextResponse.json({ error: 'Session not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
let wave = 1
|
||||
let parentIdea: any = null
|
||||
if (parentIdeaId) {
|
||||
parentIdea = await prisma.brainstormIdea.findFirst({
|
||||
where: { id: parentIdeaId, sessionId },
|
||||
})
|
||||
if (parentIdea) {
|
||||
wave = Math.min((parentIdea.waveNumber || 1) + 1, 3)
|
||||
}
|
||||
}
|
||||
|
||||
const existingIdeas = await prisma.brainstormIdea.count({
|
||||
where: { sessionId, waveNumber: wave },
|
||||
})
|
||||
const angle = (existingIdeas % 4) * (2 * Math.PI / 4) + Math.random() * 0.5
|
||||
const radius = wave * 200 + Math.random() * 50
|
||||
|
||||
const idea = await prisma.brainstormIdea.create({
|
||||
data: {
|
||||
sessionId,
|
||||
waveNumber: wave,
|
||||
title,
|
||||
description: description || '',
|
||||
connectionToSeed: parentIdea
|
||||
? `Manual response to "${(parentIdea.title || '').substring(0, 40)}"`
|
||||
: 'Manual idea added by participant',
|
||||
noveltyScore: null,
|
||||
parentIdeaId: parentIdeaId || null,
|
||||
createdBy: session.user.id,
|
||||
createdByType: 'human',
|
||||
positionX: Math.cos(angle) * radius,
|
||||
positionY: Math.sin(angle) * radius,
|
||||
},
|
||||
})
|
||||
|
||||
// [UPDATE - SÉCURITÉ] Recherche vectorielle guest-safe
|
||||
let relatedNoteIds: string[] = []
|
||||
try {
|
||||
const { isGuest, publicNoteIds, aiUserId } = await resolveAiContextUserId(sessionId, session.user.id)
|
||||
|
||||
if (isGuest && (!publicNoteIds || publicNoteIds.length === 0)) {
|
||||
// Invité sans notes publiques → skip la recherche vectorielle
|
||||
relatedNoteIds = []
|
||||
} else {
|
||||
const embedding = await embeddingService.generateEmbedding(`${title} ${description || ''}`)
|
||||
const vectorStr = embeddingService.toVectorString(embedding.embedding)
|
||||
|
||||
let results: any[]
|
||||
if (isGuest && publicNoteIds && publicNoteIds.length > 0) {
|
||||
// Invité : restreindre aux notes publiques uniquement
|
||||
const idList = publicNoteIds.map(id => `'${id}'`).join(',')
|
||||
results = await prisma.$queryRawUnsafe(
|
||||
`SELECT n.id
|
||||
FROM "NoteEmbedding" e
|
||||
JOIN "Note" n ON n.id = e."noteId"
|
||||
WHERE n.id IN (${idList}) AND n."trashedAt" IS NULL
|
||||
ORDER BY e.embedding <=> $1::vector
|
||||
LIMIT 3`,
|
||||
vectorStr
|
||||
) as any[]
|
||||
} else {
|
||||
// Hôte : accès complet
|
||||
results = await prisma.$queryRawUnsafe(
|
||||
`SELECT n.id
|
||||
FROM "NoteEmbedding" e
|
||||
JOIN "Note" n ON n.id = e."noteId"
|
||||
WHERE n."userId" = $1 AND n."trashedAt" IS NULL
|
||||
ORDER BY e.embedding <=> $2::vector
|
||||
LIMIT 3`,
|
||||
aiUserId, vectorStr
|
||||
) as any[]
|
||||
}
|
||||
relatedNoteIds = results.map((r: any) => r.id)
|
||||
}
|
||||
} catch {}
|
||||
|
||||
if (relatedNoteIds.length > 0) {
|
||||
const notes = await prisma.note.findMany({
|
||||
where: { id: { in: relatedNoteIds } },
|
||||
select: { id: true, title: true },
|
||||
})
|
||||
for (const note of notes) {
|
||||
await prisma.brainstormNoteRef.create({
|
||||
data: {
|
||||
ideaId: idea.id,
|
||||
noteId: note.id,
|
||||
relation: 'extends',
|
||||
explanation: `Manual idea — related to your note "${note.title}"`,
|
||||
},
|
||||
})
|
||||
}
|
||||
await prisma.brainstormIdea.update({
|
||||
where: { id: idea.id },
|
||||
data: { relatedNoteIds: JSON.stringify(relatedNoteIds) },
|
||||
})
|
||||
}
|
||||
|
||||
await logActivity(sessionId, 'manual_idea', session.user.id, { ideaTitle: title, ideaId: idea.id })
|
||||
|
||||
await captureSnapshot(sessionId, `Manual idea: ${title}`).catch(() => {})
|
||||
|
||||
// [UPDATE - TEMPS RÉEL] Retourner immédiatement, enrichissement IA en arrière-plan
|
||||
// Le client est notifié via Socket : idea:ai_processing → idea:ai_completed | idea:ai_failed
|
||||
const immediateResponse = NextResponse.json(
|
||||
{ success: true, data: { ideaId: idea.id, title, status: 'ai_processing' } },
|
||||
{ status: 201 }
|
||||
)
|
||||
|
||||
// Capturer les valeurs avant la closure asynchrone (session.user peut être undefined plus tard)
|
||||
const requestingUserId = session.user!.id
|
||||
|
||||
const enrichAsync = async () => {
|
||||
try {
|
||||
// Notifier le room que l'IA traite ce nœud (bordure pulsante violet)
|
||||
await emitToSession(sessionId, 'idea:ai_processing', {
|
||||
ideaId: idea.id,
|
||||
triggeredBy: requestingUserId,
|
||||
})
|
||||
|
||||
const config = await getSystemConfig()
|
||||
const provider = getTagsProvider(config)
|
||||
const lang = locale === 'fr' ? 'French' : locale === 'es' ? 'Spanish' : locale === 'de' ? 'German' : locale === 'it' ? 'Italian' : locale === 'pt' ? 'Portuguese' : locale === 'ja' ? 'Japanese' : locale === 'ko' ? 'Korean' : locale === 'zh' ? 'Chinese' : locale === 'ar' ? 'Arabic' : "the user's language"
|
||||
|
||||
const enrichPrompt = `You are an idea enrichment assistant. Given a user's raw brainstorm idea and context, produce a JSON object with:
|
||||
- "enrichedTitle": a polished, concise version of the title (max 60 chars)
|
||||
- "enrichedDescription": an expanded 2-3 sentence description that develops the idea further
|
||||
- "connectionToSeed": a 1-sentence explanation of how this idea connects to the seed topic
|
||||
- "noveltyScore": a number 1-10 rating how novel/original this idea is
|
||||
|
||||
IMPORTANT: You MUST write ALL text in ${lang}.
|
||||
|
||||
Seed topic: "${brainstormSession.seedIdea}"
|
||||
${parentIdea ? `Parent idea this responds to: "${parentIdea.title}"` : ''}
|
||||
User's raw idea title: "${title}"
|
||||
User's raw description: "${description || 'none provided'}"
|
||||
|
||||
Respond ONLY with the JSON object, no markdown.`
|
||||
|
||||
const raw = await provider.generateText(enrichPrompt)
|
||||
const cleaned = raw.replace(/```json\n?/g, '').replace(/```\n?/g, '').trim()
|
||||
const enriched = JSON.parse(cleaned)
|
||||
|
||||
const enrichedTitle = enriched.enrichedTitle?.substring(0, 60) || title
|
||||
const enrichedDescription = enriched.enrichedDescription || description || ''
|
||||
const connectionToSeed = enriched.connectionToSeed || 'Manual idea added by participant'
|
||||
const noveltyScore = enriched.noveltyScore || null
|
||||
|
||||
await prisma.brainstormIdea.update({
|
||||
where: { id: idea.id },
|
||||
data: { title: enrichedTitle, description: enrichedDescription, connectionToSeed, noveltyScore },
|
||||
})
|
||||
|
||||
// [UPDATE - TEMPS RÉEL] Notifier la complétion avec les données enrichies
|
||||
await emitToSession(sessionId, 'idea:ai_completed', {
|
||||
ideaId: idea.id,
|
||||
title: enrichedTitle,
|
||||
description: enrichedDescription,
|
||||
noveltyScore,
|
||||
connectionToSeed,
|
||||
})
|
||||
} catch (enrichError) {
|
||||
console.error('Enrichment failed, keeping raw idea:', enrichError)
|
||||
// [UPDATE - TEMPS RÉEL] Notifier l'échec — le nœud reste en état dégradé
|
||||
await emitToSession(sessionId, 'idea:ai_failed', { ideaId: idea.id })
|
||||
}
|
||||
}
|
||||
|
||||
// setImmediate pour ne pas bloquer la réponse HTTP
|
||||
setImmediate(() => { enrichAsync() })
|
||||
|
||||
return immediateResponse
|
||||
} catch (error: any) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ error: error.issues }, { status: 400 })
|
||||
}
|
||||
console.error('Error adding manual idea:', error)
|
||||
return NextResponse.json({ error: 'Failed to add idea' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
215
memento-note/app/api/brainstorm/[sessionId]/route.ts
Normal file
215
memento-note/app/api/brainstorm/[sessionId]/route.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { auth } from '@/auth'
|
||||
|
||||
function resolveAccessRole(
|
||||
brainstormSession: any,
|
||||
userId: string | null
|
||||
): 'owner' | 'editor' | 'viewer' | 'guest' | 'none' {
|
||||
if (!userId) {
|
||||
return brainstormSession.isPublic ? 'guest' : 'none'
|
||||
}
|
||||
if (brainstormSession.userId === userId) return 'owner'
|
||||
const participant = brainstormSession.participants?.find(
|
||||
(p: any) => p.userId === userId
|
||||
)
|
||||
if (participant) return participant.role
|
||||
const acceptedShare = brainstormSession.shares?.find(
|
||||
(s: any) => s.userId === userId && s.status === 'accepted'
|
||||
)
|
||||
if (acceptedShare) return acceptedShare.permission
|
||||
return brainstormSession.isPublic ? 'guest' : 'none'
|
||||
}
|
||||
|
||||
function filterNoteRefsByRole(ideas: any[], role: string): any[] {
|
||||
const allowedVis = role === 'owner'
|
||||
? ['public', 'participants', 'owner_only']
|
||||
: role === 'editor'
|
||||
? ['public', 'participants', 'owner_only']
|
||||
: role === 'viewer'
|
||||
? ['public', 'participants']
|
||||
: ['public']
|
||||
|
||||
return ideas.map((idea: any) => ({
|
||||
...idea,
|
||||
noteRefs: (idea.noteRefs || []).filter(
|
||||
(ref: any) => allowedVis.includes(ref.visibility || 'participants')
|
||||
),
|
||||
}))
|
||||
}
|
||||
|
||||
function sanitizeForGuest(session: any): any {
|
||||
const { participants, shares, ...rest } = session
|
||||
const filteredIdeas = filterNoteRefsByRole(rest.ideas || [], 'guest')
|
||||
return {
|
||||
...rest,
|
||||
ideas: filteredIdeas,
|
||||
sourceNote: null,
|
||||
exportedNote: null,
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ sessionId: string }> }
|
||||
) {
|
||||
const authSession = await auth()
|
||||
const userId = authSession?.user?.id || null
|
||||
|
||||
try {
|
||||
const { sessionId } = await params
|
||||
const brainstormSession = await prisma.brainstormSession.findFirst({
|
||||
where: { id: sessionId },
|
||||
include: {
|
||||
ideas: {
|
||||
orderBy: [{ waveNumber: 'asc' }, { createdAt: 'asc' }],
|
||||
include: {
|
||||
noteRefs: {
|
||||
include: {
|
||||
note: { select: { id: true, title: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
sourceNote: {
|
||||
select: { id: true, title: true },
|
||||
},
|
||||
exportedNote: {
|
||||
select: { id: true, title: true },
|
||||
},
|
||||
participants: {
|
||||
where: userId ? { userId } : undefined,
|
||||
select: { userId: true, role: true },
|
||||
},
|
||||
shares: userId
|
||||
? {
|
||||
where: { userId, status: 'accepted' },
|
||||
select: { userId: true, permission: true },
|
||||
}
|
||||
: false,
|
||||
},
|
||||
})
|
||||
|
||||
if (!brainstormSession) {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const creatorIds = [...new Set(brainstormSession.ideas.map((i: any) => i.createdBy).filter(Boolean))]
|
||||
const creators = creatorIds.length > 0
|
||||
? await prisma.user.findMany({ where: { id: { in: creatorIds } }, select: { id: true, name: true, image: true } })
|
||||
: []
|
||||
const creatorMap = new Map(creators.map((c: any) => [c.id, c]))
|
||||
for (const idea of brainstormSession.ideas) {
|
||||
(idea as any).creator = (idea as any).createdBy ? creatorMap.get((idea as any).createdBy) || null : null
|
||||
}
|
||||
|
||||
const role = resolveAccessRole(brainstormSession, userId)
|
||||
|
||||
if (role === 'none') {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
let responseData: any = brainstormSession
|
||||
|
||||
if (role === 'guest') {
|
||||
responseData = sanitizeForGuest(brainstormSession)
|
||||
} else if (role === 'viewer') {
|
||||
const { participants: _p, shares: _s, ...rest } = brainstormSession as any
|
||||
responseData = {
|
||||
...rest,
|
||||
ideas: filterNoteRefsByRole(rest.ideas || [], 'viewer'),
|
||||
}
|
||||
} else {
|
||||
const { participants: _p, shares: _s, ...rest } = brainstormSession as any
|
||||
responseData = rest
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: responseData,
|
||||
_meta: { role, canEdit: ['owner', 'editor'].includes(role) || (role === 'guest' && brainstormSession.guestCanEdit) },
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error fetching brainstorm session:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch session' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ sessionId: string }> }
|
||||
) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const { sessionId } = await params
|
||||
const body = await request.json()
|
||||
const brainstormSession = await prisma.brainstormSession.findFirst({
|
||||
where: { id: sessionId, userId: session.user.id },
|
||||
})
|
||||
|
||||
if (!brainstormSession) {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const updates: Record<string, any> = {}
|
||||
if (typeof body.isPublic === 'boolean') updates.isPublic = body.isPublic
|
||||
if (typeof body.guestCanEdit === 'boolean') updates.guestCanEdit = body.guestCanEdit
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
return NextResponse.json({ error: 'No valid fields to update' }, { status: 400 })
|
||||
}
|
||||
|
||||
const updated = await prisma.brainstormSession.update({
|
||||
where: { id: sessionId },
|
||||
data: updates,
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true, data: updated })
|
||||
} catch (error) {
|
||||
console.error('Error updating brainstorm session:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to update session' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ sessionId: string }> }
|
||||
) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const { sessionId } = await params
|
||||
const brainstormSession = await prisma.brainstormSession.findFirst({
|
||||
where: { id: sessionId, userId: session.user.id },
|
||||
})
|
||||
|
||||
if (!brainstormSession) {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
await prisma.brainstormSession.delete({
|
||||
where: { id: sessionId },
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error('Error deleting brainstorm session:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to delete session' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { auth } from '@/auth'
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ sessionId: string }> }
|
||||
) {
|
||||
const authSession = await auth()
|
||||
if (!authSession?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const { sessionId } = await params
|
||||
const brainstormSession = await prisma.brainstormSession.findFirst({
|
||||
where: {
|
||||
id: sessionId,
|
||||
OR: [
|
||||
{ userId: authSession.user.id },
|
||||
{ participants: { some: { userId: authSession.user.id } } },
|
||||
{ shares: { some: { userId: authSession.user.id, status: 'accepted' } } },
|
||||
],
|
||||
} as any,
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
if (!brainstormSession) {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const snapshots = await prisma.brainstormSnapshot.findMany({
|
||||
where: { sessionId },
|
||||
orderBy: { step: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
step: true,
|
||||
label: true,
|
||||
activityId: true,
|
||||
ideaGraph: true,
|
||||
createdAt: true,
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true, data: snapshots })
|
||||
} catch (error) {
|
||||
console.error('Error fetching snapshots:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch snapshots' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { auth } from '@/auth'
|
||||
import { z } from 'zod'
|
||||
|
||||
const positionSchema = z.object({
|
||||
ideaId: z.string().min(1),
|
||||
positionX: z.number(),
|
||||
positionY: z.number(),
|
||||
})
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ sessionId: string }> }
|
||||
) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const { sessionId } = await params
|
||||
const body = await request.json()
|
||||
const { ideaId, positionX, positionY } = positionSchema.parse(body)
|
||||
|
||||
const brainstormSession = await prisma.brainstormSession.findFirst({
|
||||
where: { id: sessionId, userId: session.user.id },
|
||||
})
|
||||
|
||||
if (!brainstormSession) {
|
||||
return NextResponse.json({ error: 'Session not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
await prisma.brainstormIdea.update({
|
||||
where: { id: ideaId },
|
||||
data: { positionX, positionY },
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error: any) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ error: error.issues }, { status: 400 })
|
||||
}
|
||||
console.error('Error updating position:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error.message || 'Failed to update position' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
85
memento-note/app/api/brainstorm/join/route.ts
Normal file
85
memento-note/app/api/brainstorm/join/route.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { auth } from '@/auth'
|
||||
import { z } from 'zod'
|
||||
import { logActivity } from '@/lib/brainstorm-collab'
|
||||
|
||||
const joinSchema = z.object({
|
||||
token: z.string().min(1),
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { token } = joinSchema.parse(body)
|
||||
|
||||
const brainstormSession = await prisma.brainstormSession.findFirst({
|
||||
where: { inviteToken: token },
|
||||
select: { id: true, inviteExpiry: true, userId: true, seedIdea: true },
|
||||
})
|
||||
|
||||
if (!brainstormSession) {
|
||||
return NextResponse.json({ error: 'Invalid invite token' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (brainstormSession.inviteExpiry && brainstormSession.inviteExpiry < new Date()) {
|
||||
return NextResponse.json({ error: 'Invite expired' }, { status: 410 })
|
||||
}
|
||||
|
||||
const existing = await prisma.brainstormParticipant.findFirst({
|
||||
where: { sessionId: brainstormSession.id, userId: session.user.id },
|
||||
})
|
||||
|
||||
if (existing) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
sessionId: brainstormSession.id,
|
||||
role: existing.role,
|
||||
})
|
||||
}
|
||||
|
||||
const participant = await prisma.brainstormParticipant.create({
|
||||
data: {
|
||||
sessionId: brainstormSession.id,
|
||||
userId: session.user.id,
|
||||
role: 'editor',
|
||||
},
|
||||
})
|
||||
|
||||
await logActivity(brainstormSession.id, 'joined', session.user.id)
|
||||
|
||||
try {
|
||||
const joinerName = session.user.name || 'Someone'
|
||||
const seedPreview = brainstormSession.seedIdea.length > 40
|
||||
? brainstormSession.seedIdea.substring(0, 40) + '…'
|
||||
: brainstormSession.seedIdea
|
||||
await prisma.notification.create({
|
||||
data: {
|
||||
userId: brainstormSession.userId,
|
||||
type: 'brainstorm_joined',
|
||||
title: `${joinerName} joined your brainstorm`,
|
||||
message: seedPreview,
|
||||
actionUrl: `/brainstorm?session=${brainstormSession.id}`,
|
||||
relatedId: brainstormSession.id,
|
||||
},
|
||||
})
|
||||
} catch {}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
sessionId: brainstormSession.id,
|
||||
role: participant.role,
|
||||
}, { status: 201 })
|
||||
} catch (error: any) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ error: error.issues }, { status: 400 })
|
||||
}
|
||||
console.error('Error joining brainstorm:', error)
|
||||
return NextResponse.json({ error: 'Failed to join' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
407
memento-note/app/api/brainstorm/route.ts
Normal file
407
memento-note/app/api/brainstorm/route.ts
Normal file
@@ -0,0 +1,407 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { auth } from '@/auth'
|
||||
import { z } from 'zod'
|
||||
import { getTagsProvider } from '@/lib/ai/factory'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
import { embeddingService } from '@/lib/ai/services/embedding.service'
|
||||
import { logActivity, captureSnapshot } from '@/lib/brainstorm-collab'
|
||||
|
||||
const waveSchema = z.object({
|
||||
seedIdea: z.string().min(1, 'Seed idea is required'),
|
||||
sourceNoteId: z.string().optional(),
|
||||
contextNoteIds: z.array(z.string()).optional(),
|
||||
locale: z.string().optional(),
|
||||
})
|
||||
|
||||
interface ClassifiedNote {
|
||||
id: string
|
||||
title: string
|
||||
summary: string
|
||||
category: 'SUPPORT' | 'TENSION' | 'EXTENSION'
|
||||
}
|
||||
|
||||
async function autoContextSearch(
|
||||
userId: string,
|
||||
seedIdea: string,
|
||||
userNoteIds?: string[]
|
||||
): Promise<ClassifiedNote[]> {
|
||||
let candidateIds: string[] = []
|
||||
|
||||
if (userNoteIds && userNoteIds.length > 0) {
|
||||
candidateIds = userNoteIds
|
||||
} else {
|
||||
try {
|
||||
const embedding = await embeddingService.generateEmbedding(seedIdea)
|
||||
const vectorStr = embeddingService.toVectorString(embedding.embedding)
|
||||
const results = await prisma.$queryRawUnsafe(
|
||||
`SELECT n.id
|
||||
FROM "NoteEmbedding" e
|
||||
JOIN "Note" n ON n.id = e."noteId"
|
||||
WHERE n."userId" = $1 AND n."trashedAt" IS NULL
|
||||
ORDER BY e.embedding <=> $2::vector
|
||||
LIMIT 8`,
|
||||
userId, vectorStr
|
||||
) as any[]
|
||||
candidateIds = results.map((r: any) => r.id)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
if (candidateIds.length === 0) return []
|
||||
|
||||
const notes = await prisma.note.findMany({
|
||||
where: { id: { in: candidateIds }, userId, trashedAt: null },
|
||||
select: { id: true, title: true, content: true },
|
||||
})
|
||||
|
||||
if (notes.length === 0) return []
|
||||
|
||||
const notesForLLM = notes.map(n => ({
|
||||
id: n.id,
|
||||
title: n.title || 'Untitled',
|
||||
snippet: (n.content || '').slice(0, 300),
|
||||
}))
|
||||
|
||||
try {
|
||||
const config = await getSystemConfig()
|
||||
const provider = getTagsProvider(config)
|
||||
|
||||
const classifyPrompt = `Given the seed idea: "${seedIdea}"
|
||||
|
||||
Classify each note as SUPPORT (confirms/reinforces the seed), TENSION (contradicts/questions the seed), or EXTENSION (extends the seed into an adjacent domain).
|
||||
|
||||
Notes:
|
||||
${notesForLLM.map(n => `[${n.id}] "${n.title}": ${n.snippet}`).join('\n')}
|
||||
|
||||
Respond ONLY with a valid JSON array of objects:
|
||||
{ "noteId": string, "category": "SUPPORT" | "TENSION" | "EXTENSION" }`
|
||||
|
||||
const raw = await provider.generateText(classifyPrompt)
|
||||
const cleaned = raw.replace(/```json\n?/g, '').replace(/```\n?/g, '').trim()
|
||||
const classifications: { noteId: string; category: 'SUPPORT' | 'TENSION' | 'EXTENSION' }[] = JSON.parse(cleaned)
|
||||
|
||||
return notes.map(n => {
|
||||
const cls = classifications.find(c => c.noteId === n.id)
|
||||
return {
|
||||
id: n.id,
|
||||
title: n.title || 'Untitled',
|
||||
summary: (n.content || '').slice(0, 200),
|
||||
category: cls?.category || 'EXTENSION',
|
||||
}
|
||||
})
|
||||
} catch {
|
||||
return notes.map(n => ({
|
||||
id: n.id,
|
||||
title: n.title || 'Untitled',
|
||||
summary: (n.content || '').slice(0, 200),
|
||||
category: 'EXTENSION' as const,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
function buildPromptV2(seedIdea: string, classifiedNotes: ClassifiedNote[], locale?: string): string {
|
||||
const supportNotes = classifiedNotes.filter(n => n.category === 'SUPPORT')
|
||||
const tensionNotes = classifiedNotes.filter(n => n.category === 'TENSION')
|
||||
const extensionNotes = classifiedNotes.filter(n => n.category === 'EXTENSION')
|
||||
|
||||
let notesSection = ''
|
||||
if (classifiedNotes.length > 0) {
|
||||
notesSection = `\nUSER'S EXISTING NOTES (classified by relationship to the seed):\n`
|
||||
if (supportNotes.length > 0) {
|
||||
notesSection += `\nSUPPORTING NOTES (confirm/reinforce the seed):\n`
|
||||
notesSection += supportNotes.map(n => `- [ID: ${n.id}] "${n.title}": ${n.summary}`).join('\n')
|
||||
}
|
||||
if (tensionNotes.length > 0) {
|
||||
notesSection += `\nTENSION NOTES (contradict or question the seed):\n`
|
||||
notesSection += tensionNotes.map(n => `- [ID: ${n.id}] "${n.title}": ${n.summary}`).join('\n')
|
||||
}
|
||||
if (extensionNotes.length > 0) {
|
||||
notesSection += `\nEXTENSION NOTES (extend the seed into adjacent domains):\n`
|
||||
notesSection += extensionNotes.map(n => `- [ID: ${n.id}] "${n.title}": ${n.summary}`).join('\n')
|
||||
}
|
||||
}
|
||||
|
||||
return `You are a creative brainstorming assistant with access to the user's personal knowledge base. Your job is to generate ideas that DELIBERATELY CROSS the seed concept with existing notes — creating productive tension, not just variations.
|
||||
|
||||
USER'S SEED IDEA: ${seedIdea}
|
||||
${notesSection}
|
||||
|
||||
GENERATION RULES:
|
||||
|
||||
WAVE 1 — VARIATIONS (3 ideas):
|
||||
- At least 1 idea must BUILD ON a SUPPORTING note
|
||||
- At least 1 idea must RESPOND TO a TENSION note (resolve or embrace the contradiction)
|
||||
|
||||
WAVE 2 — ANALOGIES (3 ideas):
|
||||
- At least 1 idea must FUSE a concept from an EXTENSION note with the seed
|
||||
- At least 1 idea must be a PATTERN TRANSPOSITION from any note
|
||||
|
||||
WAVE 3 — DISRUPTIONS (3 ideas):
|
||||
- At least 1 idea must INVERT an assumption found in a SUPPORTING note
|
||||
- At least 1 idea must SYNTHESIZE two notes that appear contradictory
|
||||
|
||||
RESPOND ONLY with a valid JSON array of 9 objects:
|
||||
{
|
||||
"wave": number (1, 2, or 3),
|
||||
"title": string (short, 2-6 words),
|
||||
"description": string (1-2 sentences, specific and actionable),
|
||||
"connectionToSeed": string (how it relates to the seed),
|
||||
"noveltyScore": number (1-10),
|
||||
"noteRefs": [
|
||||
{
|
||||
"noteId": string (must match an ID provided above, or null if genuinely no note connects),
|
||||
"relation": "derived_from" | "opposes" | "extends" | "synthesizes" | "transposes",
|
||||
"explanation": string (natural language, e.g. "Built on your exploration of occupancy-based scheduling")
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
CRITICAL: Each idea MUST have at least 1 noteRef. Only use null noteId if genuinely no note connects to that idea.
|
||||
|
||||
LANGUAGE: You MUST write ALL titles, descriptions, connectionToSeed, and explanation fields in ${locale === 'fr' ? 'French' : locale === 'es' ? 'Spanish' : locale === 'de' ? 'German' : locale === 'it' ? 'Italian' : locale === 'pt' ? 'Portuguese' : locale === 'nl' ? 'Dutch' : locale === 'ru' ? 'Russian' : locale === 'zh' ? 'Chinese' : locale === 'ja' ? 'Japanese' : locale === 'ko' ? 'Korean' : locale === 'ar' ? 'Arabic' : locale === 'fa' ? 'Farsi' : locale === 'hi' ? 'Hindi' : locale === 'pl' ? 'Polish' : 'the same language as the seed idea'}.`
|
||||
}
|
||||
|
||||
function buildFallbackIdeas(classifiedNotes: ClassifiedNote[]): any[] {
|
||||
const notesById = Object.fromEntries(classifiedNotes.map(n => [n.id, n]))
|
||||
const support = classifiedNotes.filter(n => n.category === 'SUPPORT')
|
||||
const tension = classifiedNotes.filter(n => n.category === 'TENSION')
|
||||
const extension = classifiedNotes.filter(n => n.category === 'EXTENSION')
|
||||
|
||||
const pickNote = (list: ClassifiedNote[], idx: number) => {
|
||||
if (list.length === 0) return { noteId: null, relation: 'extends' as const, explanation: 'Purely generative idea, no direct note link' }
|
||||
const n = list[idx % list.length]
|
||||
return { noteId: n.id, relation: 'extends' as const, explanation: `Inspired by your note "${n.title}"` }
|
||||
}
|
||||
|
||||
return [
|
||||
{ wave: 1, title: 'Variation A', description: 'A direct variation of the seed idea.', connectionToSeed: 'Direct extension', noveltyScore: 3, noteRefs: [pickNote(support, 0)] },
|
||||
{ wave: 1, title: 'Variation B', description: 'Another angle on the seed idea.', connectionToSeed: 'Reformulation', noveltyScore: 4, noteRefs: [pickNote(tension, 0)] },
|
||||
{ wave: 1, title: 'Variation C', description: 'A sub-aspect of the seed.', connectionToSeed: 'Sub-component', noveltyScore: 5, noteRefs: [pickNote(support, 1)] },
|
||||
{ wave: 2, title: 'Analogy A', description: 'Inspired by biological systems.', connectionToSeed: 'Cross-domain analogy', noveltyScore: 6, noteRefs: [pickNote(extension, 0)] },
|
||||
{ wave: 2, title: 'Analogy B', description: 'Drawn from technology patterns.', connectionToSeed: 'Tech parallel', noveltyScore: 7, noteRefs: [pickNote(extension, 1)] },
|
||||
{ wave: 2, title: 'Analogy C', description: 'Based on natural phenomena.', connectionToSeed: 'Nature metaphor', noveltyScore: 6, noteRefs: [pickNote(classifiedNotes, 0)] },
|
||||
{ wave: 3, title: 'Disruption A', description: 'What if we inverted the core assumption?', connectionToSeed: 'Inversion', noveltyScore: 9, noteRefs: [{ ...(pickNote(support, 0)), relation: 'opposes' as const }] },
|
||||
{ wave: 3, title: 'Disruption B', description: 'A provocative reframing.', connectionToSeed: 'Challenge premise', noveltyScore: 8, noteRefs: [{ ...(pickNote(tension, 0)), relation: 'synthesizes' as const }] },
|
||||
{ wave: 3, title: 'Disruption C', description: 'Removing a key constraint entirely.', connectionToSeed: 'Constraint removal', noveltyScore: 10, noteRefs: [pickNote(extension, 2)] },
|
||||
]
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
const userId = session.user.id
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { seedIdea, sourceNoteId, contextNoteIds, locale } = waveSchema.parse(body)
|
||||
|
||||
const classifiedNotes = await autoContextSearch(userId, seedIdea, contextNoteIds)
|
||||
|
||||
const config = await getSystemConfig()
|
||||
const provider = getTagsProvider(config)
|
||||
|
||||
const prompt = buildPromptV2(seedIdea, classifiedNotes, locale)
|
||||
const llmResponse = await provider.generateText(prompt)
|
||||
|
||||
let ideas: any[]
|
||||
try {
|
||||
const cleaned = llmResponse.replace(/```json\n?/g, '').replace(/```\n?/g, '').trim()
|
||||
ideas = JSON.parse(cleaned)
|
||||
if (!Array.isArray(ideas)) throw new Error('Not an array')
|
||||
} catch {
|
||||
ideas = buildFallbackIdeas(classifiedNotes)
|
||||
}
|
||||
|
||||
const validNoteIds = new Set(classifiedNotes.map(n => n.id))
|
||||
|
||||
const brainstormSession = await prisma.brainstormSession.create({
|
||||
data: {
|
||||
seedIdea,
|
||||
sourceNoteId: sourceNoteId || null,
|
||||
contextNoteIds: contextNoteIds ? JSON.stringify(contextNoteIds) : null,
|
||||
liveblocksRoomId: `brainstorm-session`,
|
||||
userId,
|
||||
},
|
||||
})
|
||||
|
||||
await prisma.brainstormParticipant.create({
|
||||
data: {
|
||||
sessionId: brainstormSession.id,
|
||||
userId,
|
||||
role: 'host',
|
||||
},
|
||||
})
|
||||
|
||||
await logActivity(brainstormSession.id, 'wave_generated', userId, { count: ideas.length })
|
||||
|
||||
const createdIdeas = []
|
||||
for (let idx = 0; idx < ideas.length; idx++) {
|
||||
const idea = ideas[idx]
|
||||
const angle = (idx % 3) * (2 * Math.PI / 3) + (idea.wave - 1) * 0.5
|
||||
const radius = idea.wave * 150
|
||||
|
||||
const created = await prisma.brainstormIdea.create({
|
||||
data: {
|
||||
sessionId: brainstormSession.id,
|
||||
waveNumber: idea.wave || Math.floor(idx / 3) + 1,
|
||||
title: idea.title || `Idea ${idx + 1}`,
|
||||
description: idea.description || '',
|
||||
connectionToSeed: idea.connectionToSeed || null,
|
||||
noveltyScore: idea.noveltyScore || null,
|
||||
relatedNoteIds: JSON.stringify(
|
||||
(idea.noteRefs || []).map((r: any) => r.noteId).filter(Boolean)
|
||||
),
|
||||
positionX: Math.cos(angle) * radius,
|
||||
positionY: Math.sin(angle) * radius,
|
||||
},
|
||||
})
|
||||
|
||||
if (idea.noteRefs && Array.isArray(idea.noteRefs)) {
|
||||
for (const ref of idea.noteRefs) {
|
||||
const noteId = ref.noteId && validNoteIds.has(ref.noteId) ? ref.noteId : null
|
||||
await prisma.brainstormNoteRef.create({
|
||||
data: {
|
||||
ideaId: created.id,
|
||||
noteId,
|
||||
relation: ref.relation || 'extends',
|
||||
explanation: ref.explanation || '',
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
createdIdeas.push(created)
|
||||
}
|
||||
|
||||
const fullSession = await prisma.brainstormSession.findUnique({
|
||||
where: { id: brainstormSession.id },
|
||||
include: {
|
||||
ideas: {
|
||||
orderBy: [{ waveNumber: 'asc' }, { createdAt: 'asc' }],
|
||||
include: {
|
||||
noteRefs: {
|
||||
include: {
|
||||
note: { select: { id: true, title: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const cIds = [...new Set((fullSession?.ideas || []).map((i: any) => i.createdBy).filter(Boolean))]
|
||||
if (cIds.length > 0) {
|
||||
const crs = await prisma.user.findMany({ where: { id: { in: cIds } }, select: { id: true, name: true, image: true } })
|
||||
const cm = new Map(crs.map((c: any) => [c.id, c]))
|
||||
for (const idea of fullSession?.ideas || []) { (idea as any).creator = (idea as any).createdBy ? cm.get((idea as any).createdBy) || null : null }
|
||||
}
|
||||
|
||||
await captureSnapshot(brainstormSession.id, `Initial wave: ${brainstormSession.seedIdea.substring(0, 30)}`).catch(() => {})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: fullSession,
|
||||
contextSummary: {
|
||||
support: classifiedNotes.filter(n => n.category === 'SUPPORT').length,
|
||||
tension: classifiedNotes.filter(n => n.category === 'TENSION').length,
|
||||
extension: classifiedNotes.filter(n => n.category === 'EXTENSION').length,
|
||||
},
|
||||
}, { status: 201 })
|
||||
} catch (error: any) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ error: error.issues }, { status: 400 })
|
||||
}
|
||||
console.error('Error creating brainstorm:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error.message || 'Failed to create brainstorm' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
const userId = session.user.id
|
||||
|
||||
try {
|
||||
const ownedSessions = await prisma.brainstormSession.findMany({
|
||||
where: { userId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
_count: { select: { ideas: true } },
|
||||
ideas: {
|
||||
where: { status: 'active' },
|
||||
select: { id: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const owned = ownedSessions.map(s => ({
|
||||
id: s.id,
|
||||
seedIdea: s.seedIdea,
|
||||
sourceNoteId: s.sourceNoteId,
|
||||
exportedNoteId: s.exportedNoteId,
|
||||
createdAt: s.createdAt,
|
||||
updatedAt: s.updatedAt,
|
||||
totalIdeas: s._count.ideas,
|
||||
activeIdeas: s.ideas.length,
|
||||
_owned: true,
|
||||
}))
|
||||
|
||||
const ownedIds = new Set(owned.map(s => s.id))
|
||||
|
||||
let shared: any[] = []
|
||||
try {
|
||||
const acceptedShareRows = await prisma.brainstormShare.findMany({
|
||||
where: { userId, status: 'accepted' },
|
||||
include: {
|
||||
session: {
|
||||
select: {
|
||||
id: true,
|
||||
seedIdea: true,
|
||||
sourceNoteId: true,
|
||||
exportedNoteId: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
_count: { select: { ideas: true } },
|
||||
ideas: { where: { status: 'active' }, select: { id: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
shared = acceptedShareRows
|
||||
.filter(s => s.session != null && !ownedIds.has(s.session.id))
|
||||
.map(s => ({
|
||||
id: s.session.id,
|
||||
seedIdea: s.session.seedIdea,
|
||||
sourceNoteId: s.session.sourceNoteId,
|
||||
exportedNoteId: s.session.exportedNoteId,
|
||||
createdAt: s.session.createdAt,
|
||||
updatedAt: s.session.updatedAt,
|
||||
totalIdeas: s.session._count.ideas,
|
||||
activeIdeas: s.session.ideas.length,
|
||||
_owned: false,
|
||||
}))
|
||||
} catch (shareError) {
|
||||
console.error('Error fetching shared brainstorms (non-fatal):', shareError)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: [...owned, ...shared],
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error fetching brainstorms:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch brainstorms' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
48
memento-note/app/api/brainstorm/shared/route.ts
Normal file
48
memento-note/app/api/brainstorm/shared/route.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { auth } from '@/auth'
|
||||
|
||||
export async function GET() {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const shares = await prisma.brainstormShare.findMany({
|
||||
where: {
|
||||
userId: session.user.id,
|
||||
status: 'accepted',
|
||||
},
|
||||
select: {
|
||||
sessionId: true,
|
||||
permission: true,
|
||||
session: {
|
||||
select: {
|
||||
id: true,
|
||||
seedIdea: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const data = shares.map((s) => ({
|
||||
id: s.session.id,
|
||||
seedIdea: s.session.seedIdea,
|
||||
sourceNoteId: null,
|
||||
exportedNoteId: null,
|
||||
createdAt: s.session.createdAt,
|
||||
updatedAt: s.session.updatedAt,
|
||||
totalIdeas: 0,
|
||||
activeIdeas: 0,
|
||||
_isShared: true,
|
||||
}))
|
||||
|
||||
return NextResponse.json({ success: true, data })
|
||||
} catch (error) {
|
||||
console.error('Error fetching shared brainstorms:', error)
|
||||
return NextResponse.json({ success: true, data: [] })
|
||||
}
|
||||
}
|
||||
@@ -48,7 +48,7 @@ export async function POST(req: Request) {
|
||||
|
||||
// 2. Parse request body
|
||||
const body = await req.json()
|
||||
const { messages: rawMessages, conversationId, notebookId, language, webSearch, noteContext, format } = body as {
|
||||
const { messages: rawMessages, conversationId, notebookId, language, webSearch, noteContext, format, noteId } = body as {
|
||||
messages: UIMessage[]
|
||||
conversationId?: string
|
||||
notebookId?: string
|
||||
@@ -56,6 +56,7 @@ export async function POST(req: Request) {
|
||||
webSearch?: boolean
|
||||
noteContext?: { title: string; content: string; tone: string; images?: string[] }
|
||||
format?: 'html' | 'markdown'
|
||||
noteId?: string
|
||||
}
|
||||
|
||||
const incomingMessages = toCoreMessages(rawMessages)
|
||||
@@ -107,17 +108,60 @@ export async function POST(req: Request) {
|
||||
|
||||
let searchResults: any[] = []
|
||||
try {
|
||||
searchResults = await semanticSearchService.search(currentMessage, {
|
||||
notebookId,
|
||||
limit: notebookId ? 10 : 5,
|
||||
threshold: notebookId ? 0.3 : 0.5,
|
||||
defaultTitle: untitledText,
|
||||
})
|
||||
const documentMention = currentMessage.match(
|
||||
/\b(pdf|document|fichier|pi[eè]ce jointe|attachment|file)\b/i
|
||||
)
|
||||
|
||||
if (documentMention) {
|
||||
const docResults = await semanticSearchService.searchWithDocuments(
|
||||
userId, currentMessage, {
|
||||
notebookId,
|
||||
limit: notebookId ? 10 : 5,
|
||||
threshold: notebookId ? 0.3 : 0.5,
|
||||
includeDocuments: true,
|
||||
defaultTitle: untitledText,
|
||||
}
|
||||
)
|
||||
searchResults = docResults
|
||||
} else {
|
||||
searchResults = await semanticSearchService.search(currentMessage, {
|
||||
notebookId,
|
||||
limit: notebookId ? 10 : 5,
|
||||
threshold: notebookId ? 0.3 : 0.5,
|
||||
defaultTitle: untitledText,
|
||||
})
|
||||
}
|
||||
} catch {}
|
||||
|
||||
searchNotes = searchResults
|
||||
.map((r) => `NOTE [${r.title || untitledText}]: ${r.content}`)
|
||||
.map((r) => {
|
||||
if ((r as any).source === 'document') {
|
||||
return `DOCUMENT [${(r as any).fileName} p.${(r as any).pageNumber}] (from note: ${r.title || untitledText}):\n${r.content}`
|
||||
}
|
||||
return `NOTE [${r.title || untitledText}]: ${r.content}`
|
||||
})
|
||||
.join('\n\n---\n\n')
|
||||
} else if (noteId) {
|
||||
try {
|
||||
const docResults = await semanticSearchService.searchWithDocuments(
|
||||
userId, currentMessage, {
|
||||
noteId,
|
||||
limit: 8,
|
||||
threshold: 0.3,
|
||||
includeDocuments: true,
|
||||
defaultTitle: untitledText,
|
||||
}
|
||||
)
|
||||
searchNotes = docResults
|
||||
.map((r) => {
|
||||
if ((r as any).source === 'document') {
|
||||
return `DOCUMENT [${(r as any).fileName} p.${(r as any).pageNumber}]:\n${r.content}`
|
||||
}
|
||||
return ''
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('\n\n---\n\n')
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const contextNotes = [notebookContext, searchNotes].filter(Boolean).join('\n\n---\n\n')
|
||||
@@ -125,7 +169,7 @@ export async function POST(req: Request) {
|
||||
// 5. System prompt synthesis
|
||||
const promptLang: Record<string, { contextWithNotes: string; contextNoNotes: string; system: string }> = {
|
||||
en: {
|
||||
contextWithNotes: `## User's notes\n\n${contextNotes}\n\nWhen using info from the notes above, cite the source note title in parentheses, e.g.: "Deployment is done via Docker (💻 Development Guide)". Don't copy word for word — rephrase. If the notes don't cover the topic, say so and supplement with your general knowledge.`,
|
||||
contextWithNotes: `## User's notes & documents\n\n${contextNotes}\n\nWhen using info from the notes above, cite the source note title in parentheses, e.g.: "Deployment is done via Docker (💻 Development Guide)". For document passages, cite the filename and page number, e.g.: "The revenue was $5M (📄 report.pdf p.12)". Don't copy word for word — rephrase. If the notes don't cover the topic, say so and supplement with your general knowledge.`,
|
||||
contextNoNotes: "No relevant notes found for this question. Answer with your general knowledge.",
|
||||
system: `You are the AI assistant of Memento. The user asks you questions about their projects, technical docs, and notes. You must respond in a structured and helpful way.
|
||||
|
||||
@@ -159,11 +203,13 @@ Momento is an intelligent note-taking application. Key features include:
|
||||
- **Lab**: Experimental AI tools for data analysis and deeper insights.
|
||||
|
||||
## Available tools
|
||||
You have access to: note_search, note_read, web_search, web_scrape.
|
||||
Only use tools if you need more information. Never invent note IDs or URLs.`,
|
||||
You have access to: note_search, note_read, document_search, task_extract, web_search, web_scrape.
|
||||
Only use tools if you need more information. Never invent note IDs or URLs.
|
||||
- document_search: Searches attached PDF documents for the current note/notebook. Use when the user asks about documents or files.
|
||||
- task_extract: Extracts action items from notes and creates a synthesis note. Use when the user asks to extract tasks or TODOs.`,
|
||||
},
|
||||
fr: {
|
||||
contextWithNotes: `## Notes de l'utilisateur\n\n${contextNotes}\n\nQuand tu utilises une info venant des notes ci-dessus, cite le titre de la note source entre parenthèses, ex: "Le déploiement se fait via Docker (💻 Development Guide)". Ne recopie pas mot pour mot — reformule.`,
|
||||
contextWithNotes: `## Notes et documents de l'utilisateur\n\n${contextNotes}\n\nQuand tu utilises une info venant des notes ci-dessus, cite le titre de la note source entre parenthèses, ex: "Le déploiement se fait via Docker (💻 Development Guide)". Pour les documents PDF, cite le nom du fichier et la page, ex: "Le chiffre d'affaires est de 5M$ (📄 rapport.pdf p.12)". Ne recopie pas mot pour mot — reformule.`,
|
||||
contextNoNotes: "Aucune note pertinente trouvée pour cette question. Réponds avec tes connaissances générales.",
|
||||
system: `Tu es l'assistant IA de Memento. L'utilisateur te pose des questions sur ses projets, sa doc technique, ses notes. Tu dois répondre de façon structurée et utile.
|
||||
|
||||
@@ -191,7 +237,9 @@ Only use tools if you need more information. Never invent note IDs or URLs.`,
|
||||
Momento est une application de prise de notes intelligente. Ses fonctionnalités : Éditeur Markdown riche, Copilot IA, Organisation par Carnets, Recherche sémantique, Agents IA, Lab.
|
||||
|
||||
## Outils disponibles
|
||||
Tu as accès à : note_search, note_read, web_search, web_scrape.`,
|
||||
Tu as accès à : note_search, note_read, document_search, task_extract, web_search, web_scrape.
|
||||
- document_search : Recherche dans les documents PDF attachés à la note/au carnet.
|
||||
- task_extract : Extrait les tâches/action items des notes et crée une note de synthèse.`,
|
||||
},
|
||||
fa: {
|
||||
contextWithNotes: `## یادداشتهای کاربر\n\n${contextNotes}\n\nهنگام استفاده از اطلاعات یادداشتهای بالا، عنوان یادداشت منبع را در پرانتز ذکر کنید.`,
|
||||
@@ -276,7 +324,7 @@ Focus ONLY on this note unless asked otherwise.`
|
||||
// 6. Execute stream
|
||||
const sysConfig = await getSystemConfig()
|
||||
const chatTools = noteContext
|
||||
? toolRegistry.buildToolsForChat({ userId, config: sysConfig, webSearch, webOnly: true })
|
||||
? toolRegistry.buildToolsForChat({ userId, config: sysConfig, webSearch, notebookId: notebookId || undefined })
|
||||
: toolRegistry.buildToolsForChat({ userId, config: sysConfig, webSearch, notebookId: notebookId || undefined })
|
||||
|
||||
const provider = getChatProvider(sysConfig)
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { auth } from '@/auth'
|
||||
import fs from 'fs'
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string; attachmentId: string }> }
|
||||
) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const { id: noteId, attachmentId } = await params
|
||||
|
||||
const download = request.nextUrl.searchParams.get('download')
|
||||
|
||||
const attachment = await prisma.noteAttachment.findFirst({
|
||||
where: { id: attachmentId, noteId, note: { userId: session.user.id } },
|
||||
include: download ? undefined : {
|
||||
chunks: {
|
||||
select: { id: true, chunkIndex: true, pageNumber: true },
|
||||
orderBy: { chunkIndex: 'asc' },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!attachment) {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (download) {
|
||||
if (!fs.existsSync(attachment.filePath)) {
|
||||
return NextResponse.json({ error: 'File not found' }, { status: 404 })
|
||||
}
|
||||
const fileBuffer = fs.readFileSync(attachment.filePath)
|
||||
return new NextResponse(fileBuffer, {
|
||||
headers: {
|
||||
'Content-Type': attachment.mimeType || 'application/pdf',
|
||||
'Content-Disposition': `inline; filename="${attachment.fileName}"`,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, data: attachment })
|
||||
} catch (error) {
|
||||
console.error('Error fetching attachment:', error)
|
||||
return NextResponse.json({ error: 'Failed to fetch attachment' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string; attachmentId: string }> }
|
||||
) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const { id: noteId, attachmentId } = await params
|
||||
|
||||
const attachment = await prisma.noteAttachment.findFirst({
|
||||
where: { id: attachmentId, noteId, note: { userId: session.user.id } },
|
||||
})
|
||||
|
||||
if (!attachment) {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
try {
|
||||
if (fs.existsSync(attachment.filePath)) {
|
||||
fs.unlinkSync(attachment.filePath)
|
||||
}
|
||||
} catch {}
|
||||
|
||||
await prisma.noteAttachment.delete({ where: { id: attachmentId } })
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error('Error deleting attachment:', error)
|
||||
return NextResponse.json({ error: 'Failed to delete attachment' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
112
memento-note/app/api/notes/[id]/attachments/route.ts
Normal file
112
memento-note/app/api/notes/[id]/attachments/route.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { auth } from '@/auth'
|
||||
import { documentIngestionService } from '@/lib/ai/services/document-ingestion.service'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { randomUUID } from 'crypto'
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const { id: noteId } = await params
|
||||
|
||||
const note = await prisma.note.findFirst({
|
||||
where: { id: noteId, userId: session.user.id, trashedAt: null },
|
||||
})
|
||||
if (!note) {
|
||||
return NextResponse.json({ error: 'Note not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const formData = await request.formData()
|
||||
const file = formData.get('file') as File | null
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: 'No file provided' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (file.size > 20 * 1024 * 1024) {
|
||||
return NextResponse.json({ error: 'File too large (max 20MB)' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (file.type !== 'application/pdf') {
|
||||
return NextResponse.json({ error: 'Only PDF files are supported' }, { status: 400 })
|
||||
}
|
||||
|
||||
const dir = path.join(process.cwd(), 'data', 'uploads', 'attachments', noteId)
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
const fileId = randomUUID()
|
||||
const filePath = path.join(dir, `${fileId}.pdf`)
|
||||
fs.writeFileSync(filePath, Buffer.from(await file.arrayBuffer()))
|
||||
|
||||
const attachment = await prisma.noteAttachment.create({
|
||||
data: {
|
||||
noteId,
|
||||
fileName: file.name,
|
||||
fileType: file.type,
|
||||
fileSize: file.size,
|
||||
filePath,
|
||||
mimeType: file.type,
|
||||
status: 'pending',
|
||||
},
|
||||
})
|
||||
|
||||
setImmediate(() => {
|
||||
documentIngestionService.ingest(attachment.id).catch((err) => {
|
||||
console.error('Document ingestion failed:', err)
|
||||
})
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true, data: attachment }, { status: 201 })
|
||||
} catch (error) {
|
||||
console.error('Error uploading attachment:', error)
|
||||
return NextResponse.json({ error: 'Upload failed' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const { id: noteId } = await params
|
||||
|
||||
const note = await prisma.note.findFirst({
|
||||
where: { id: noteId, userId: session.user.id, trashedAt: null },
|
||||
})
|
||||
if (!note) {
|
||||
return NextResponse.json({ error: 'Note not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const attachments = await prisma.noteAttachment.findMany({
|
||||
where: { noteId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: {
|
||||
id: true,
|
||||
fileName: true,
|
||||
fileSize: true,
|
||||
mimeType: true,
|
||||
status: true,
|
||||
pageCount: true,
|
||||
error: true,
|
||||
createdAt: true,
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true, data: attachments })
|
||||
} catch (error) {
|
||||
console.error('Error fetching attachments:', error)
|
||||
return NextResponse.json({ error: 'Failed to fetch attachments' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
29
memento-note/app/api/users/search/route.ts
Normal file
29
memento-note/app/api/users/search/route.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { auth } from '@/auth'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const q = request.nextUrl.searchParams.get('q') || ''
|
||||
if (q.length < 2) {
|
||||
return NextResponse.json({ users: [] })
|
||||
}
|
||||
|
||||
const users = await prisma.user.findMany({
|
||||
where: {
|
||||
id: { not: session.user.id },
|
||||
OR: [
|
||||
{ email: { contains: q, mode: 'insensitive' } },
|
||||
{ name: { contains: q, mode: 'insensitive' } },
|
||||
],
|
||||
},
|
||||
select: { id: true, name: true, email: true, image: true },
|
||||
take: 8,
|
||||
})
|
||||
|
||||
return NextResponse.json({ users })
|
||||
}
|
||||
Reference in New Issue
Block a user