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:
269
memento-note/socket-server.ts
Normal file
269
memento-note/socket-server.ts
Normal file
@@ -0,0 +1,269 @@
|
||||
import { createServer } from 'http'
|
||||
import { Server } from 'socket.io'
|
||||
|
||||
const SOCKET_PORT = parseInt(process.env.SOCKET_PORT || '3002')
|
||||
const HTTP_PORT = parseInt(process.env.SOCKET_HTTP_PORT || '3003')
|
||||
const INTERNAL_KEY = process.env.SOCKET_INTERNAL_KEY || ''
|
||||
|
||||
// ─── Socket.io Server ────────────────────────────────────────────────────────
|
||||
|
||||
const io = new Server(SOCKET_PORT, {
|
||||
cors: {
|
||||
origin: true, // Accepte dynamiquement toutes les origines (CORS fix)
|
||||
methods: ['GET', 'POST'],
|
||||
credentials: true,
|
||||
},
|
||||
})
|
||||
|
||||
const rooms = new Map<string, Map<string, { userId: string; name: string; cursor: { x: number; y: number } | null; color: string }>>()
|
||||
|
||||
const COLORS = [
|
||||
'#e57373', '#81c784', '#64b5f6', '#ffb74d',
|
||||
'#ba68c8', '#4dd0e1', '#f06292', '#aed581',
|
||||
'#ff8a65', '#4fc3f7', '#dce775', '#f48fb1',
|
||||
]
|
||||
|
||||
// ─── [UPDATE - TEMPS RÉEL] Node Locking in-memory ───────────────────────────
|
||||
// Choix : Map in-memory + TTL automatique.
|
||||
// Justification : locks volatiles par design, O(1), pas de deadlocks PostgreSQL.
|
||||
// Migration Redis triviale si multi-instances (remplacer Map par SET NX EX).
|
||||
|
||||
const LOCK_TTL_MS = 15_000
|
||||
|
||||
interface IdeaLock {
|
||||
userId: string
|
||||
userName: string
|
||||
lockedAt: number
|
||||
timeoutHandle: ReturnType<typeof setTimeout>
|
||||
}
|
||||
|
||||
// Map<sessionId, Map<ideaId, IdeaLock>>
|
||||
const ideaLocks = new Map<string, Map<string, IdeaLock>>()
|
||||
|
||||
function acquireLock(
|
||||
sessionId: string,
|
||||
ideaId: string,
|
||||
userId: string,
|
||||
userName: string
|
||||
): boolean {
|
||||
if (!ideaLocks.has(sessionId)) {
|
||||
ideaLocks.set(sessionId, new Map())
|
||||
}
|
||||
const sessionLocks = ideaLocks.get(sessionId)!
|
||||
const existing = sessionLocks.get(ideaId)
|
||||
|
||||
if (existing && existing.userId !== userId) {
|
||||
const age = Date.now() - existing.lockedAt
|
||||
if (age < LOCK_TTL_MS) return false
|
||||
// Lock expiré → nettoyer
|
||||
clearTimeout(existing.timeoutHandle)
|
||||
sessionLocks.delete(ideaId)
|
||||
}
|
||||
|
||||
const handle = setTimeout(() => {
|
||||
releaseLock(sessionId, ideaId, userId, true)
|
||||
io.to(sessionId).emit('idea:unlocked', { ideaId, reason: 'ttl_expired' })
|
||||
}, LOCK_TTL_MS)
|
||||
|
||||
sessionLocks.set(ideaId, { userId, userName, lockedAt: Date.now(), timeoutHandle: handle })
|
||||
return true
|
||||
}
|
||||
|
||||
function releaseLock(
|
||||
sessionId: string,
|
||||
ideaId: string,
|
||||
userId: string,
|
||||
expired = false
|
||||
): boolean {
|
||||
const sessionLocks = ideaLocks.get(sessionId)
|
||||
if (!sessionLocks) return false
|
||||
const lock = sessionLocks.get(ideaId)
|
||||
if (!lock || lock.userId !== userId) return false
|
||||
if (!expired) clearTimeout(lock.timeoutHandle)
|
||||
sessionLocks.delete(ideaId)
|
||||
return true
|
||||
}
|
||||
|
||||
function releaseAllLocksForUser(sessionId: string, userId: string): string[] {
|
||||
const sessionLocks = ideaLocks.get(sessionId)
|
||||
if (!sessionLocks) return []
|
||||
const released: string[] = []
|
||||
for (const [ideaId, lock] of sessionLocks.entries()) {
|
||||
if (lock.userId === userId) {
|
||||
clearTimeout(lock.timeoutHandle)
|
||||
sessionLocks.delete(ideaId)
|
||||
released.push(ideaId)
|
||||
}
|
||||
}
|
||||
return released
|
||||
}
|
||||
|
||||
// ─── Socket.io Middleware ─────────────────────────────────────────────────────
|
||||
|
||||
io.use((socket, next) => {
|
||||
const userId = socket.handshake.auth.userId || `guest_${socket.id.slice(0, 8)}`
|
||||
const sessionId = socket.handshake.auth.sessionId
|
||||
const name = socket.handshake.auth.name || 'Guest'
|
||||
const isGuest = socket.handshake.auth.isGuest === true
|
||||
|
||||
if (!sessionId) {
|
||||
return next(new Error('Missing sessionId'))
|
||||
}
|
||||
|
||||
socket.data.userId = userId
|
||||
socket.data.sessionId = sessionId
|
||||
socket.data.name = name
|
||||
socket.data.isGuest = isGuest
|
||||
next()
|
||||
})
|
||||
|
||||
// ─── Socket.io Connection Handler ────────────────────────────────────────────
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
const sessionId = socket.data.sessionId
|
||||
const userId = socket.data.userId
|
||||
const name = socket.data.name
|
||||
|
||||
socket.join(sessionId)
|
||||
|
||||
if (!rooms.has(sessionId)) {
|
||||
rooms.set(sessionId, new Map())
|
||||
}
|
||||
|
||||
const colorIdx = rooms.get(sessionId)!.size % COLORS.length
|
||||
const color = COLORS[colorIdx]
|
||||
|
||||
// IMPORTANT: Utiliser socket.id au lieu de userId pour supporter le refresh et multi-onglets
|
||||
rooms.get(sessionId)!.set(socket.id, { name, cursor: null, color, userId }) // Note: On doit ajouter userId dans l'objet pour le retrouver
|
||||
|
||||
io.to(sessionId).emit('presence:update', Array.from(rooms.get(sessionId)!.values()).map(data => ({
|
||||
userId: data.userId, // On renvoie bien le userId attendu par le client
|
||||
name: data.name,
|
||||
cursor: data.cursor,
|
||||
color: data.color,
|
||||
})))
|
||||
|
||||
// ── Curseur ──────────────────────────────────────────────────────────────
|
||||
|
||||
socket.on('cursor:move', (cursor: { x: number; y: number }) => {
|
||||
const room = rooms.get(sessionId)
|
||||
if (!room) return
|
||||
const user = room.get(socket.id)
|
||||
if (!user) return
|
||||
user.cursor = cursor
|
||||
socket.to(sessionId).emit('cursor:update', { userId, cursor })
|
||||
})
|
||||
|
||||
// ── Idées (événements existants) ─────────────────────────────────────────
|
||||
|
||||
socket.on('idea:added', (data: { ideaId: string; title: string; userId: string; userName: string }) => {
|
||||
socket.to(sessionId).emit('idea:added', data)
|
||||
})
|
||||
|
||||
socket.on('idea:dismissed', (data: { ideaId: string; userId: string }) => {
|
||||
// Libérer le lock si l'idée était verrouillée par cet utilisateur
|
||||
releaseLock(sessionId, data.ideaId, userId)
|
||||
socket.to(sessionId).emit('idea:dismissed', data)
|
||||
})
|
||||
|
||||
socket.on('idea:moved', (data: { ideaId: string; positionX: number; positionY: number; userId: string }) => {
|
||||
socket.to(sessionId).emit('idea:moved', data)
|
||||
})
|
||||
|
||||
socket.on('activity:new', (data: { action: string; userId: string; userName: string; details: any }) => {
|
||||
io.to(sessionId).emit('activity:new', data)
|
||||
})
|
||||
|
||||
// ── [UPDATE - TEMPS RÉEL] Node Locking ───────────────────────────────────
|
||||
|
||||
socket.on('idea:lock_request', (data: { ideaId: string }) => {
|
||||
const acquired = acquireLock(sessionId, data.ideaId, userId, name)
|
||||
if (acquired) {
|
||||
// Notifier tout le room — le nœud est grisé pour tous les autres
|
||||
io.to(sessionId).emit('idea:locked', {
|
||||
ideaId: data.ideaId,
|
||||
lockedBy: userId,
|
||||
lockedByName: name,
|
||||
ttl: LOCK_TTL_MS,
|
||||
})
|
||||
} else {
|
||||
// Refus uniquement au demandeur
|
||||
const lock = ideaLocks.get(sessionId)?.get(data.ideaId)
|
||||
socket.emit('idea:lock_denied', {
|
||||
ideaId: data.ideaId,
|
||||
lockedBy: lock?.userId,
|
||||
lockedByName: lock?.userName,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
socket.on('idea:unlock', (data: { ideaId: string }) => {
|
||||
const released = releaseLock(sessionId, data.ideaId, userId)
|
||||
if (released) {
|
||||
io.to(sessionId).emit('idea:unlocked', { ideaId: data.ideaId })
|
||||
}
|
||||
})
|
||||
|
||||
// ── Déconnexion ───────────────────────────────────────────────────────────
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
// Libérer tous les locks de cet utilisateur
|
||||
const releasedIds = releaseAllLocksForUser(sessionId, userId)
|
||||
for (const ideaId of releasedIds) {
|
||||
io.to(sessionId).emit('idea:unlocked', { ideaId })
|
||||
}
|
||||
|
||||
const room = rooms.get(sessionId)
|
||||
if (room) {
|
||||
room.delete(socket.id) // Utilise socket.id, pas userId
|
||||
if (room.size === 0) {
|
||||
rooms.delete(sessionId)
|
||||
ideaLocks.delete(sessionId)
|
||||
} else {
|
||||
io.to(sessionId).emit('presence:update', Array.from(room.values()).map(d => ({
|
||||
userId: d.userId, // Renvoie le userId correct
|
||||
name: d.name,
|
||||
cursor: d.cursor,
|
||||
color: d.color,
|
||||
})))
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// ─── [UPDATE - TEMPS RÉEL] Endpoint HTTP interne pour l'émission depuis les API routes ──
|
||||
// Permet à next.js d'émettre des événements Socket sans couplage direct.
|
||||
// Sécurisé par SOCKET_INTERNAL_KEY.
|
||||
|
||||
const httpServer = createServer((req, res) => {
|
||||
if (req.method === 'POST' && req.url === '/emit') {
|
||||
const key = req.headers['x-internal-key']
|
||||
if (INTERNAL_KEY && key !== INTERNAL_KEY) {
|
||||
res.writeHead(403).end('Forbidden')
|
||||
return
|
||||
}
|
||||
let body = ''
|
||||
req.on('data', chunk => (body += chunk))
|
||||
req.on('end', () => {
|
||||
try {
|
||||
const { sessionId, event, data } = JSON.parse(body)
|
||||
if (!sessionId || !event) {
|
||||
res.writeHead(400).end('Missing sessionId or event')
|
||||
return
|
||||
}
|
||||
io.to(sessionId).emit(event, data)
|
||||
res.writeHead(200).end('ok')
|
||||
} catch {
|
||||
res.writeHead(400).end('Invalid JSON')
|
||||
}
|
||||
})
|
||||
} else {
|
||||
res.writeHead(404).end()
|
||||
}
|
||||
})
|
||||
|
||||
httpServer.listen(HTTP_PORT, () => {
|
||||
console.log(`Socket.io internal HTTP endpoint listening on port ${HTTP_PORT}`)
|
||||
})
|
||||
|
||||
console.log(`Socket.io server running on port ${SOCKET_PORT}`)
|
||||
Reference in New Issue
Block a user