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

- 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:
Antigravity
2026-05-14 17:43:21 +00:00
parent 195e845f0a
commit 1fcea6ed7d
228 changed files with 57656 additions and 1059 deletions

View File

@@ -23,7 +23,7 @@ import '../tools'
// --- Types ---
export type AgentType = 'scraper' | 'researcher' | 'monitor' | 'custom' | 'slide-generator' | 'excalidraw-generator'
export type AgentType = 'scraper' | 'researcher' | 'monitor' | 'custom' | 'slide-generator' | 'excalidraw-generator' | 'task-extractor'
export interface AgentExecutionResult {
success: boolean
@@ -941,6 +941,28 @@ IMPERATIVE DESIGN RULES:
- Concise points (max 100 chars), punchy and short titles
- Strict JSON for generate_pptx, no text outside JSON.`,
},
'task-extractor': {
fr: `Tu es un expert en gestion de tâches et extraction d'action items. Tu analyses des notes et documents pour identifier toutes les tâches, TODOs, et actions à accomplir.
Utilise OBLIGATOIREMENT l'outil task_extract. Ne réponds PAS avec du texte, appelle directement l'outil.
## RÈGLES
- Identifie TOUTES les tâches explicites et implicites
- Pour chaque tâche, détermine: priorité (High/Medium/Low), assigné, deadline, statut
- Les priorités High = urgent/dates proches, Medium = important, Low = Nice to have
- Regroupe par priorité dans la note de synthèse
- Utilise le format Markdown avec une table récapitulative`,
en: `You are a task extraction specialist. You analyze notes and documents to identify ALL action items, TODOs, and tasks to accomplish.
You MUST use the task_extract tool. Do NOT respond with text, call the tool directly.
## RULES
- Identify ALL explicit and implicit tasks
- For each task, determine: priority (High/Medium/Low), assignee, deadline, status
- High priority = urgent/close deadlines, Medium = important, Low = Nice to have
- Group by priority in the synthesis note
- Use Markdown format with a summary table`,
},
}
// --- Tool-Use Agent ---
@@ -1172,6 +1194,38 @@ async function executeToolUseAgent(
}
break
}
case 'task-extractor': {
const untitled = lang === 'fr' ? 'Sans titre' : 'Untitled'
const dateLocale = lang === 'fr' ? 'fr-FR' : 'en-US'
let notes: any[] = []
if (agent.sourceNotebookId) {
notes = await prisma.note.findMany({
where: { notebookId: agent.sourceNotebookId, userId: agent.userId, isArchived: false, trashedAt: null },
orderBy: { createdAt: 'desc' }, take: 20,
select: { id: true, title: true, content: true, createdAt: true }
})
} else {
notes = await prisma.note.findMany({
where: { userId: agent.userId, isArchived: false, trashedAt: null },
orderBy: { updatedAt: 'desc' }, take: 20,
select: { id: true, title: true, content: true, createdAt: true }
})
}
const notebookId = agent.sourceNotebookId || agent.targetNotebookId || null
prompt = lang === 'fr'
? `Analyse les notes suivantes et extrais TOUS les action items, tâches et TODOs. Utilise l'outil task_extract pour créer une note de synthèse.${notebookId ? ` Passe notebookId="${notebookId}" à task_extract.` : ''}`
: `Analyze the following notes and extract ALL action items, tasks and TODOs. Use the task_extract tool to create a synthesis note.${notebookId ? ` Pass notebookId="${notebookId}" to task_extract.` : ''}`
if (notes.length > 0) {
const notesContext = notes.map(n =>
`### ${n.title || untitled} (${n.createdAt.toLocaleDateString(dateLocale)})\n${n.content.substring(0, 500)}`
).join('\n\n')
prompt += `\n\n${lang === 'fr' ? 'Notes à analyser' : 'Notes to analyze'}:\n\n${notesContext}`
}
prompt += `\n\n${lang === 'fr'
? 'IMPORTANT : Utilise OBLIGATOIREMENT l\'outil task_extract. Ne réponds pas avec du texte, appelle directement l\'outil.'
: 'IMPORTANT: You MUST use the task_extract tool. Do NOT respond with text, call the tool directly.'}`
break
}
default: {
const urls: string[] = agent.sourceUrls ? JSON.parse(agent.sourceUrls) : []
prompt = agent.role || (lang === 'fr' ? 'Accomplis la tâche demandée en utilisant les outils disponibles.' : 'Accomplish the requested task using available tools.')

View File

@@ -0,0 +1,83 @@
interface ChunkInput {
text: string
pageNumber: number
}
export interface DocumentChunkData {
content: string
chunkIndex: number
pageNumber: number
startChar: number
endChar: number
metadata?: string
}
export class DocumentChunkingService {
private readonly CHUNK_SIZE = 800
private readonly OVERLAP = 200
chunk(pages: ChunkInput[]): DocumentChunkData[] {
const chunks: DocumentChunkData[] = []
let globalIndex = 0
let previousTail = ''
for (const page of pages) {
const text = page.text.trim()
if (!text) continue
const sections = this.splitSections(text)
let buffer = previousTail
let bufferStart = 0
for (const section of sections) {
if (buffer.length + section.length > this.CHUNK_SIZE && buffer.length > 0) {
chunks.push({
content: buffer.trim(),
chunkIndex: globalIndex++,
pageNumber: page.pageNumber,
startChar: bufferStart,
endChar: bufferStart + buffer.length,
})
previousTail = buffer.slice(-this.OVERLAP)
buffer = previousTail + '\n' + section
bufferStart += buffer.length - section.length - previousTail.length
} else {
buffer += (buffer ? '\n\n' : '') + section
}
}
if (buffer.trim()) {
chunks.push({
content: buffer.trim(),
chunkIndex: globalIndex++,
pageNumber: page.pageNumber,
startChar: bufferStart,
endChar: bufferStart + buffer.length,
})
previousTail = buffer.slice(-this.OVERLAP)
}
}
return chunks
}
private splitSections(text: string): string[] {
const lines = text.split('\n')
const sections: string[] = []
let current = ''
for (const line of lines) {
const isHeading = /^(#{1,6}\s|[A-Z][A-Z\s]{5,}$)/.test(line.trim())
if (isHeading && current.trim()) {
sections.push(current.trim())
current = line
} else {
current += (current ? '\n' : '') + line
}
}
if (current.trim()) sections.push(current.trim())
return sections
}
}
export const documentChunkingService = new DocumentChunkingService()

View File

@@ -0,0 +1,56 @@
import fs from 'fs'
import path from 'path'
import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf.mjs'
if (typeof pdfjsLib.GlobalWorkerOptions !== 'undefined') {
pdfjsLib.GlobalWorkerOptions.workerSrc = path.join(
process.cwd(),
'node_modules/pdfjs-dist/legacy/build/pdf.worker.mjs'
)
}
interface ExtractedPage {
pageNumber: number
text: string
}
export interface ExtractedDocument {
pages: ExtractedPage[]
totalPages: number
metadata: { title?: string; author?: string }
}
export class DocumentExtractionService {
async extractPdf(filePath: string): Promise<ExtractedDocument> {
const dataBuffer = fs.readFileSync(filePath)
const doc = await pdfjsLib.getDocument({
data: new Uint8Array(dataBuffer),
useSystemFonts: true,
useWorkerFetch: false,
isEvalSupported: false,
}).promise
const pages: ExtractedPage[] = []
for (let i = 1; i <= doc.numPages; i++) {
const page = await doc.getPage(i)
const content = await page.getTextContent()
const text = content.items
.map((item: any) => item.str)
.join(' ')
pages.push({ pageNumber: i, text })
}
const metadata = await doc.getMetadata().catch(() => null) as any
return {
pages,
totalPages: doc.numPages,
metadata: {
title: metadata?.info?.Title,
author: metadata?.info?.Author,
},
}
}
}
export const documentExtractionService = new DocumentExtractionService()

View File

@@ -0,0 +1,79 @@
import prisma from '@/lib/prisma'
import { documentExtractionService } from './document-extraction.service'
import { documentChunkingService } from './document-chunking.service'
import { embeddingService } from './embedding.service'
export class DocumentIngestionService {
async ingest(attachmentId: string): Promise<void> {
const attachment = await prisma.noteAttachment.findUnique({
where: { id: attachmentId },
})
if (!attachment) throw new Error('Attachment not found')
await prisma.noteAttachment.update({
where: { id: attachmentId },
data: { status: 'processing' },
})
try {
const extracted = await documentExtractionService.extractPdf(attachment.filePath)
await prisma.noteAttachment.update({
where: { id: attachmentId },
data: { pageCount: extracted.totalPages },
})
const chunkInputs = extracted.pages.map(p => ({
text: p.text,
pageNumber: p.pageNumber,
}))
const chunks = documentChunkingService.chunk(chunkInputs)
const created = await Promise.all(
chunks.map(c =>
prisma.documentChunk.create({
data: {
attachmentId,
content: c.content,
chunkIndex: c.chunkIndex,
pageNumber: c.pageNumber,
startChar: c.startChar,
endChar: c.endChar,
metadata: c.metadata,
},
})
)
)
const BATCH_SIZE = 20
for (let i = 0; i < created.length; i += BATCH_SIZE) {
const batch = created.slice(i, i + BATCH_SIZE)
const texts = batch.map(c => c.content)
const embeddings = await embeddingService.generateBatchEmbeddings(texts)
await Promise.all(
batch.map((chunk, idx) =>
prisma.$executeRawUnsafe(
`UPDATE "DocumentChunk" SET embedding = $1::vector WHERE id = $2`,
embeddingService.toVectorString(embeddings[idx].embedding),
chunk.id
)
)
)
}
await prisma.noteAttachment.update({
where: { id: attachmentId },
data: { status: 'ready' },
})
} catch (error: any) {
await prisma.noteAttachment.update({
where: { id: attachmentId },
data: { status: 'failed', error: error.message?.substring(0, 500) },
})
throw error
}
}
}
export const documentIngestionService = new DocumentIngestionService()

View File

@@ -1,7 +1,7 @@
/**
* Embedding Service
* Generates vector embeddings for semantic search and similarity analysis.
* Stores embeddings as native pgvector(1536) in PostgreSQL.
* Stores embeddings as native pgvector in PostgreSQL.
*/
import { getAIProvider } from '../factory'

View File

@@ -385,6 +385,85 @@ export class SemanticSearchService {
await Promise.allSettled(batch.map(noteId => this.indexNote(noteId)))
}
}
async searchWithDocuments(
userId: string,
query: string,
options?: SearchOptions & { noteId?: string; includeDocuments?: boolean }
): Promise<(SearchResult & { source?: 'note' | 'document'; pageNumber?: number; fileName?: string })[]> {
const includeDocuments = options?.includeDocuments !== false
const noteResults = await this.searchAsUser(userId, query, options)
if (!includeDocuments) return noteResults
const queryEmbedding = await embeddingService.generateEmbedding(query)
const vectorStr = embeddingService.toVectorString(queryEmbedding.embedding)
let noteFilter = ''
const params: any[] = [vectorStr, 50, userId]
if (options?.noteId) {
assertSafeId(options.noteId, 'noteId')
params.push(options.noteId)
noteFilter = `AND na."noteId" = $${params.length}`
} else if (options?.notebookId) {
assertSafeId(options.notebookId, 'notebookId')
params.push(options.notebookId)
noteFilter = `AND n."notebookId" = $${params.length}`
}
const documentResults = await prisma.$queryRawUnsafe(
`SELECT
dc.content,
dc."pageNumber",
na."fileName",
na."noteId",
n.title as "noteTitle"
FROM "DocumentChunk" dc
JOIN "NoteAttachment" na ON na.id = dc."attachmentId"
JOIN "Note" n ON n.id = na."noteId"
WHERE dc."embedding" IS NOT NULL
AND na.status = 'ready'
AND n."trashedAt" IS NULL
AND n."userId" = $3
${noteFilter}
ORDER BY dc."embedding" <=> $1::vector
LIMIT $2`,
...params
) as any[]
const K = 60
const fused = new Map<string, any>()
for (let i = 0; i < noteResults.length; i++) {
const r = noteResults[i]
fused.set(r.noteId, {
...r,
source: 'note',
rrfScore: 1 / (K + i + 1),
})
}
for (let i = 0; i < documentResults.length; i++) {
const r = documentResults[i]
const key = `doc_${r.noteId}_${r.pageNumber}_${i}`
fused.set(key, {
noteId: r.noteId,
title: `${r.noteTitle || 'Untitled'}${r.fileName} (p.${r.pageNumber})`,
content: r.content.substring(0, 500),
score: 0.5,
matchType: 'related' as const,
source: 'document',
pageNumber: r.pageNumber,
fileName: r.fileName,
rrfScore: 1 / (K + i + 1),
})
}
return Array.from(fused.values())
.sort((a, b) => b.rrfScore - a.rrfScore)
.slice(0, options?.limit || 20)
}
}
export const semanticSearchService = new SemanticSearchService()

View File

@@ -0,0 +1,73 @@
import { tool } from 'ai'
import { z } from 'zod'
import { toolRegistry } from './registry'
import { embeddingService } from '@/lib/ai/services/embedding.service'
import prisma from '@/lib/prisma'
toolRegistry.register({
name: 'document_search',
description: 'Search within PDF documents attached to notes. Returns relevant passages with page numbers and source document info.',
isInternal: true,
buildTool: (ctx) =>
tool({
description: `Search within PDF documents attached to the user's notes.
Returns matching passages with page numbers, chunk content, and the source note/document info.
Use this when the user asks about specific documents, PDFs, or attached files.`,
inputSchema: z.object({
query: z.string().describe('The search query to find relevant passages in documents'),
noteId: z.string().optional().describe('Optional: restrict search to attachments of a specific note'),
limit: z.number().optional().describe('Max results to return (default 5)').default(5),
}),
execute: async ({ query, noteId, limit = 5 }) => {
try {
const queryEmbedding = await embeddingService.generateEmbedding(query)
const vectorStr = embeddingService.toVectorString(queryEmbedding.embedding)
let noteFilter = ''
const params: any[] = [vectorStr, limit, ctx.userId]
if (noteId) {
noteFilter = `AND na."noteId" = $4`
params.push(noteId)
}
const results = await prisma.$queryRawUnsafe(
`SELECT
dc.id as "chunkId",
dc.content,
dc."pageNumber",
dc."chunkIndex",
na.id as "attachmentId",
na."fileName",
na."pageCount",
na."noteId",
n.title as "noteTitle"
FROM "DocumentChunk" dc
JOIN "NoteAttachment" na ON na.id = dc."attachmentId"
JOIN "Note" n ON n.id = na."noteId"
WHERE dc."embedding" IS NOT NULL
AND na.status = 'ready'
AND n."trashedAt" IS NULL
AND n."userId" = $3
${noteFilter}
ORDER BY dc."embedding" <=> $1::vector
LIMIT $2`,
...params
) as any[]
if (!results.length) return { results: [], message: 'No matching documents found' }
return results.map(r => ({
content: r.content.substring(0, 600),
pageNumber: r.pageNumber,
chunkIndex: r.chunkIndex,
fileName: r.fileName,
noteId: r.noteId,
noteTitle: r.noteTitle || 'Untitled',
}))
} catch (e: any) {
return { error: `Document search failed: ${e.message}` }
}
},
}),
})

View File

@@ -13,6 +13,8 @@ import './memory.tool'
import './excalidraw.tool'
import './pptx.tool'
import './slides.tool'
import './document-search.tool'
import './task-extract.tool'
// Re-export registry
export { toolRegistry, type ToolContext, type RegisteredTool } from './registry'

View File

@@ -60,7 +60,7 @@ const PALETTES: Record<string, Theme> = {
coastal_coral: { primary: '005f73', secondary: '0a9396', accent: 'ee9b00', light: 'e9f5f5', bg: 'ffffff' },
vibrant_orange_mint: { primary: 'e05c00', secondary: '2ec4b6', accent: 'ff9f1c', light: 'edfaf9', bg: 'ffffff' },
platinum_white_gold: { primary: '0a0a0a', secondary: '404040', accent: 'c9a84c', light: 'f5f5f0', bg: 'ffffff' },
architectural_mono: { primary: '1C1C1C', secondary: '75B2D6', accent: 'D4A373', light: 'EDE9DF', bg: 'F2F0E9' },
architectural_mono: { primary: '1C1C1C', secondary: 'A47148', accent: 'D4A373', light: 'EDE9DF', bg: 'F2F0E9' },
minimal_silk: { primary: '212529', secondary: '6c757d', accent: 'dee2e6', light: 'f8f9fa', bg: 'ffffff' },
}

View File

@@ -52,7 +52,7 @@ class ToolRegistry {
* When webOnly is true, only web tools are included (no note access).
*/
buildToolsForChat(ctx: ToolContext & { webOnly?: boolean }): Record<string, any> {
const toolNames: string[] = ctx.webOnly ? [] : ['note_search', 'note_read']
const toolNames: string[] = ctx.webOnly ? [] : ['note_search', 'note_read', 'document_search', 'task_extract']
// Add web tools only when user toggled web search AND config is present
if (ctx.webSearch) {

View File

@@ -0,0 +1,106 @@
import { tool } from 'ai'
import { z } from 'zod'
import { toolRegistry } from './registry'
import { prisma } from '@/lib/prisma'
import { getTagsProvider } from '@/lib/ai/factory'
import { getSystemConfig } from '@/lib/config'
toolRegistry.register({
name: 'task_extract',
description: 'Extract action items (TODOs) from notes in a notebook. Reads all notes, identifies tasks with assignees and deadlines, and creates a synthesis note.',
isInternal: true,
buildTool: (ctx) =>
tool({
description: 'Extract action items from notes in a notebook. Creates a new note with all identified tasks.',
inputSchema: z.object({
notebookId: z.string().optional().describe('Notebook ID to scan. If omitted, scans all user notes.'),
noteIds: z.array(z.string()).optional().describe('Specific note IDs to scan instead of a whole notebook.'),
locale: z.string().optional().describe('Language for the output (fr, en, es, de, etc.)'),
}),
execute: async ({ notebookId, noteIds, locale }) => {
try {
let where: any = { userId: ctx.userId, trashedAt: null }
if (noteIds && noteIds.length > 0) {
where.id = { in: noteIds }
} else if (notebookId) {
where.notebookId = notebookId
}
const notes = await prisma.note.findMany({
where,
select: { id: true, title: true, content: true },
orderBy: { updatedAt: 'desc' },
take: 50,
})
if (notes.length === 0) {
return { error: 'No notes found to analyze' }
}
const notesContext = notes.map(n =>
`[ID: ${n.id}] "${n.title}":\n${(n.content || '').slice(0, 800)}`
).join('\n\n---\n\n')
const lang = locale === 'fr' ? 'français' : locale === 'es' ? 'espagnol' : locale === 'de' ? 'allemand' : locale === 'it' ? 'italien' : locale === 'pt' ? 'portugais' : locale === 'nl' ? 'néerlandais' : locale === 'ru' ? 'russe' : locale === 'zh' ? 'chinois' : locale === 'ja' ? 'japonais' : locale === 'ar' ? 'arabe' : locale === 'fa' ? 'persan' : locale === 'hi' ? 'hindi' : 'English'
const config = await getSystemConfig()
const provider = getTagsProvider(config)
const prompt = `You are a task extraction specialist. Analyze the following notes and extract ALL action items, tasks, and TODOs.
For each task identified, provide:
- **Task**: Clear, actionable description
- **Source**: The note title where it was found
- **Assignee**: If mentioned (otherwise "Unassigned")
- **Deadline**: If mentioned (otherwise "No deadline")
- **Priority**: High/Medium/Low based on urgency signals in the text
- **Status**: If already completed or in-progress based on context
NOTES TO ANALYZE:
${notesContext}
Respond in ${lang}. Structure the output as a clean Markdown document with:
1. A summary paragraph
2. Tasks grouped by priority (High → Medium → Low)
3. A summary table at the end
Format each task as:
### [Priority] Task Title
- **Description**: ...
- **Source note**: ...
- **Assignee**: ...
- **Deadline**: ...
- **Status**: ...`
const result = await provider.generateText(prompt)
const summaryTitle = locale === 'fr'
? `Action Items — ${new Date().toLocaleDateString('fr-FR')}`
: `Action Items — ${new Date().toLocaleDateString('en-US')}`
const createdNote = await prisma.note.create({
data: {
title: summaryTitle,
content: result,
type: 'markdown',
isMarkdown: true,
autoGenerated: true,
userId: ctx.userId,
notebookId: notebookId || null,
},
select: { id: true, title: true },
})
return {
success: true,
noteId: createdNote.id,
title: createdNote.title,
notesAnalyzed: notes.length,
tasksNoteUrl: `/notes/${createdNote.id}`,
}
} catch (e: any) {
return { error: `Task extraction failed: ${e.message}` }
}
},
}),
})

View File

@@ -0,0 +1,130 @@
import prisma from '@/lib/prisma'
export async function verifyParticipant(
sessionId: string,
userId: string,
requiredRole?: 'host' | 'editor' | 'viewer'
): Promise<{ isParticipant: boolean; role: string }> {
const participant = await prisma.brainstormParticipant.findFirst({
where: { sessionId, userId },
})
if (!participant) {
return { isParticipant: false, role: 'none' }
}
await prisma.brainstormParticipant.update({
where: { id: participant.id },
data: { lastSeenAt: new Date() },
})
if (requiredRole === 'host' && participant.role !== 'host') {
return { isParticipant: false, role: participant.role }
}
if (requiredRole === 'editor' && participant.role === 'viewer') {
return { isParticipant: false, role: participant.role }
}
return { isParticipant: true, role: participant.role }
}
export async function logActivity(
sessionId: string,
action: string,
userId?: string | null,
details?: Record<string, any>
) {
await prisma.brainstormActivity.create({
data: {
sessionId,
userId: userId || null,
action,
details: details ? JSON.stringify(details) : null,
},
})
}
// [UPDATE - SÉCURITÉ] Résoudre l'userId et le périmètre de notes autorisé pour les appels IA.
// - Hôte : accès complet à ses notes (publicNoteIds = null)
// - Invité : restreint aux contextNoteIds publics de la session (publicNoteIds = string[] | [])
export async function resolveAiContextUserId(
sessionId: string,
requestingUserId: string
): Promise<{ aiUserId: string; isGuest: boolean; publicNoteIds: string[] | null }> {
const session = await prisma.brainstormSession.findUnique({
where: { id: sessionId },
select: {
userId: true,
contextNoteIds: true,
},
})
if (!session) throw new Error('Session not found')
const isHost = session.userId === requestingUserId
if (isHost) {
return { aiUserId: requestingUserId, isGuest: false, publicNoteIds: null }
}
// Invité : on restreint aux contextNoteIds déclarés publics par l'hôte
const publicNoteIds: string[] = session.contextNoteIds
? (JSON.parse(session.contextNoteIds) as string[])
: []
return {
aiUserId: session.userId,
isGuest: true,
publicNoteIds: publicNoteIds.length > 0 ? publicNoteIds : [],
}
}
// [UPDATE - SÉCURITÉ] Sanitize les notes injectées dans un prompt IA pour un invité.
// Tronque le contenu et masque les entités nommées (Prénom Nom) avec [Person].
export function sanitizeNotesForGuest(
notes: { id: string; title: string | null; summary: string }[]
): { id: string; title: string; summary: string }[] {
const namedEntityRe = /\b[A-ZÀ-Ü][a-zà-ü]+ [A-ZÀ-Ü][a-zà-ü]+\b/g
return notes.map(n => ({
id: n.id,
title: (n.title || 'Note').replace(namedEntityRe, '[Person]'),
summary: n.summary.slice(0, 80).replace(namedEntityRe, '[Person]') + '…',
}))
}
export async function captureSnapshot(
sessionId: string,
label: string,
activityId?: string
): Promise<void> {
const ideas = await prisma.brainstormIdea.findMany({
where: { sessionId, status: 'active' },
select: {
id: true,
title: true,
waveNumber: true,
positionX: true,
positionY: true,
parentIdeaId: true,
noveltyScore: true,
createdByType: true,
status: true,
},
orderBy: [{ waveNumber: 'asc' }, { createdAt: 'asc' }],
})
const maxStep = await prisma.brainstormSnapshot.findFirst({
where: { sessionId },
orderBy: { step: 'desc' },
select: { step: true },
})
await prisma.brainstormSnapshot.create({
data: {
sessionId,
activityId: activityId || null,
step: (maxStep?.step || 0) + 1,
label,
ideaGraph: JSON.stringify(ideas),
},
})
}

View File

@@ -18,6 +18,11 @@ export const queryKeys = {
aiSettings: (userId: string) => ['ai', 'settings', userId] as const,
titleSuggestions: (content: string) => ['ai', 'title-suggestions', content] as const,
autoTags: (content: string, notebookId?: string | null) => ['ai', 'auto-tags', content, notebookId] as const,
// Brainstorm
brainstormSessions: () => ['brainstorm', 'sessions'] as const,
brainstormSharedSessions: () => ['brainstorm', 'shared-sessions'] as const,
brainstormSession: (sessionId: string) => ['brainstorm', 'session', sessionId] as const,
} as const
export type QueryKeys = typeof queryKeys

View File

@@ -0,0 +1,27 @@
// [UPDATE - TEMPS RÉEL] Helper pour émettre des événements Socket.io depuis les API routes Next.js.
// Utilise un canal HTTP interne vers le process socket-server.ts séparé.
// Non-fatal : en cas d'échec, le client récupérera l'état via React Query polling.
export async function emitToSession(
sessionId: string,
event: string,
data: unknown
): Promise<void> {
const socketUrl = process.env.SOCKET_INTERNAL_URL || 'http://localhost:3003'
const internalKey = process.env.SOCKET_INTERNAL_KEY || ''
try {
await fetch(`${socketUrl}/emit`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-internal-key': internalKey,
},
body: JSON.stringify({ sessionId, event, data }),
signal: AbortSignal.timeout(2000), // 2s max — ne pas bloquer l'API
})
} catch {
// Non-fatal — le canal Socket est best-effort
// Le client se resynchronise via invalidation React Query
}
}

View File

@@ -0,0 +1,74 @@
import { format, type Locale } from 'date-fns'
import { toJalaali } from 'jalaali-js'
const JALALI_MONTHS_FA = [
'فروردین',
'اردیبهشت',
'خرداد',
'تیر',
'مرداد',
'شهریور',
'مهر',
'آبان',
'آذر',
'دی',
'بهمن',
'اسفند',
] as const
const PERSIAN_DIGITS = ['۰', '۱', '۲', '۳', '۴', '۵', '۶', '۷', '۸', '۹'] as const
/** Western digits → Persian (Extended Arabic-Indic) numerals, e.g. 1405 → ۱۴۰۵ */
export function toPersianDigits(input: string): string {
return input.replace(/\d/g, (ch) => PERSIAN_DIGITS[Number(ch)] ?? ch)
}
function pad2(n: number): string {
return n < 10 ? `0${n}` : `${n}`
}
function formatJalaliAbsolute(date: Date, pattern: string): string {
const { jy, jm, jd } = toJalaali(date)
const monthName = JALALI_MONTHS_FA[jm - 1]
const h = pad2(date.getHours())
const min = pad2(date.getMinutes())
let s: string
switch (pattern) {
case 'd MMM yyyy':
s = `${jd} ${monthName} ${jy}`
break
case 'd MMM yyyy · HH:mm':
s = `${jd} ${monthName} ${jy} · ${h}:${min}`
break
case 'd MMM yyyy HH:mm':
s = `${jd} ${monthName} ${jy} ${h}:${min}`
break
case 'd MMM · HH:mm':
s = `${jd} ${monthName} · ${h}:${min}`
break
case 'MMM d, yyyy':
s = `${monthName} ${jd}، ${jy}`
break
default:
s = `${jd} ${monthName} ${jy}`
}
return toPersianDigits(s)
}
/**
* Absolute calendar dates for Persian (`fa`) use the Solar Hijri (Jalali / هجری شمسی) calendar.
* Times remain in the user's local timezone. For relative phrases, keep using `formatDistanceToNow` with `faIR`.
*/
export function formatAbsoluteDateLocalized(
date: Date | string,
language: string,
pattern: string,
locale: Locale
): string {
const d = typeof date === 'string' ? new Date(date) : date
if (language === 'fa') {
return formatJalaliAbsolute(d, pattern)
}
return format(d, pattern, { locale })
}