All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 5s
- Fix useBrainstormSocket: stable guestId via useRef, remove setState in cleanup - Fix GhostCursor: direct DOM manipulation via refs, no useState re-renders - Fix all SQL embedding queries: add ::vector cast on text columns - Fix embedding truncation to 15000 chars (under 8192 token limit) - Fix NoteEmbedding INSERT: remove non-existent updatedAt column - Fix billing page: show all quota stats in grid instead of single metric - Fix usage meter: accordion expand/collapse, per-feature detail - Fix semantic search: rebuild 103 note embeddings, ::vector cast on vectorSearch - Fix brainstorm expand/manual-idea/create: ::vector cast on embedding SQL
352 lines
14 KiB
TypeScript
352 lines
14 KiB
TypeScript
import { NextRequest, NextResponse } from 'next/server'
|
|
import prisma from '@/lib/prisma'
|
|
import { auth } from '@/auth'
|
|
import { z } from 'zod'
|
|
import { runLaneWithBillingUser, willUseByokForLane } from '@/lib/ai/provider-for-user'
|
|
import { getSystemConfig } from '@/lib/config'
|
|
import { embeddingService } from '@/lib/ai/services/embedding.service'
|
|
import {
|
|
checkSessionEntitlementOrThrow,
|
|
QuotaExceededError,
|
|
} from '@/lib/entitlements'
|
|
import {
|
|
billingOwnerFromSession,
|
|
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::vector <=> $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 { billingOwnerId, isGuestActor } = billingOwnerFromSession(
|
|
brainstormSession.userId,
|
|
session.user.id,
|
|
)
|
|
// Story 3.5: per-provider BYOK bypass
|
|
const earlyConfig = await getSystemConfig()
|
|
const { usedByok: willUseByok } = await willUseByokForLane('tags', earlyConfig, billingOwnerId)
|
|
if (!willUseByok) {
|
|
await checkSessionEntitlementOrThrow(
|
|
billingOwnerId,
|
|
session.user.id,
|
|
isGuestActor,
|
|
'brainstorm_expand',
|
|
)
|
|
}
|
|
|
|
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 prompt = buildExpandPromptV2(
|
|
parentIdea.title,
|
|
parentIdea.description,
|
|
brainstormSession.seedIdea,
|
|
parentRefs,
|
|
extraNotes,
|
|
locale
|
|
)
|
|
|
|
const { result: llmResponse } = await runLaneWithBillingUser(
|
|
'tags',
|
|
config,
|
|
billingOwnerId,
|
|
(provider) => 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 QuotaExceededError) {
|
|
return NextResponse.json(error.toJSON(), { status: 402 })
|
|
}
|
|
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 }
|
|
)
|
|
}
|
|
}
|