fix: génération asynchrone (fire-and-forget + polling)
Some checks failed
Deploy to Production / Build and Deploy (push) Failing after 23s
Some checks failed
Deploy to Production / Build and Deploy (push) Failing after 23s
Le problème : la route POST /api/agents/run-for-note bloquait le thread
HTTP pendant toute la durée de génération (2-5 min), provoquant un
spinner infini sans résultat.
Solution :
- POST retourne immédiatement avec { agentId } et lance executeAgent
en arrière-plan (fire-and-forget, sans await)
- GET /api/agents/run-for-note?agentId= retourne le statut de la
dernière agentAction (running | success | failure) + canvasId/noteId
- Le client poll le statut toutes les 3 secondes jusqu'au résultat,
le toast persistant Sonner se met à jour automatiquement
- Cleanup du polling au démontage du composant
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -21,6 +21,7 @@ const TYPE_DEFAULTS: Record<GenerateType, {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── POST : kick off generation (fire-and-forget) ──────────────────────────
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
const session = await auth()
|
const session = await auth()
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
@@ -40,7 +41,6 @@ export async function POST(req: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'Paramètres invalides' }, { status: 400 })
|
return NextResponse.json({ error: 'Paramètres invalides' }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify note belongs to user
|
|
||||||
const note = await prisma.note.findFirst({
|
const note = await prisma.note.findFirst({
|
||||||
where: { id: noteId, userId },
|
where: { id: noteId, userId },
|
||||||
select: { id: true, title: true, notebookId: true },
|
select: { id: true, title: true, notebookId: true },
|
||||||
@@ -54,7 +54,6 @@ export async function POST(req: NextRequest) {
|
|||||||
? `Slides — ${(note.title || 'Note').substring(0, 40)}`
|
? `Slides — ${(note.title || 'Note').substring(0, 40)}`
|
||||||
: `Diagramme — ${(note.title || 'Note').substring(0, 40)}`
|
: `Diagramme — ${(note.title || 'Note').substring(0, 40)}`
|
||||||
|
|
||||||
// Create a dedicated one-shot agent for this note
|
|
||||||
const agent = await prisma.agent.create({
|
const agent = await prisma.agent.create({
|
||||||
data: {
|
data: {
|
||||||
name: agentName,
|
name: agentName,
|
||||||
@@ -72,15 +71,70 @@ export async function POST(req: NextRequest) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
try {
|
// ── Fire and forget — do NOT await so the HTTP response returns immediately ──
|
||||||
const { executeAgent } = await import('@/lib/ai/services/agent-executor.service')
|
// In Node.js / Docker self-hosted, the process keeps running after response.
|
||||||
const result = await executeAgent(agent.id, userId)
|
import('@/lib/ai/services/agent-executor.service')
|
||||||
return NextResponse.json(result)
|
.then(({ executeAgent }) => executeAgent(agent.id, userId))
|
||||||
} catch (err) {
|
.catch(err => console.error('[run-for-note] Background agent error:', err))
|
||||||
console.error('[run-for-note] executeAgent error:', err)
|
|
||||||
return NextResponse.json(
|
return NextResponse.json({ success: true, agentId: agent.id, status: 'running' })
|
||||||
{ success: false, error: err instanceof Error ? err.message : 'Erreur inconnue' },
|
}
|
||||||
{ status: 500 },
|
|
||||||
)
|
// ─── GET : poll current agent status ──────────────────────────────────────
|
||||||
}
|
export async function GET(req: NextRequest) {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: 'Non autorisé' }, { status: 401 })
|
||||||
|
}
|
||||||
|
const userId = session.user.id
|
||||||
|
|
||||||
|
const agentId = req.nextUrl.searchParams.get('agentId')
|
||||||
|
if (!agentId) {
|
||||||
|
return NextResponse.json({ error: 'agentId manquant' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify ownership
|
||||||
|
const agent = await prisma.agent.findFirst({
|
||||||
|
where: { id: agentId, userId },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
if (!agent) {
|
||||||
|
return NextResponse.json({ error: 'Agent introuvable' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return latest action for this agent
|
||||||
|
const action = await prisma.agentAction.findFirst({
|
||||||
|
where: { agentId },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
select: { id: true, status: true, result: true, log: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!action) {
|
||||||
|
// Action not created yet (race condition in first poll) — still running
|
||||||
|
return NextResponse.json({ status: 'running' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// If success, find canvasId from the related canvas (result stores canvas id)
|
||||||
|
let canvasId: string | null = null
|
||||||
|
let noteId: string | null = null
|
||||||
|
if (action.status === 'success' && action.result) {
|
||||||
|
// result field: the executor stores canvas.id or note.id
|
||||||
|
const canvas = await prisma.canvas.findFirst({
|
||||||
|
where: { id: action.result },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
if (canvas) {
|
||||||
|
canvasId = canvas.id
|
||||||
|
} else {
|
||||||
|
noteId = action.result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
status: action.status, // 'running' | 'success' | 'failure'
|
||||||
|
actionId: action.id,
|
||||||
|
canvasId,
|
||||||
|
noteId,
|
||||||
|
error: action.status === 'failure' ? (action.log || 'Erreur inconnue') : undefined,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -264,6 +264,8 @@ export function ContextualAIChat({
|
|||||||
|
|
||||||
// ── Generate slides / diagram ────────────────────────────────────────────────
|
// ── Generate slides / diagram ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const generatePollRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||||
|
|
||||||
const handleGenerate = async (type: 'slides' | 'diagram') => {
|
const handleGenerate = async (type: 'slides' | 'diagram') => {
|
||||||
if (!noteId) {
|
if (!noteId) {
|
||||||
toast.error(t('ai.generate.noNoteId') || 'Note non sauvegardée')
|
toast.error(t('ai.generate.noNoteId') || 'Note non sauvegardée')
|
||||||
@@ -272,7 +274,7 @@ export function ContextualAIChat({
|
|||||||
setGenerateLoading(type)
|
setGenerateLoading(type)
|
||||||
setGenerateResult(null)
|
setGenerateResult(null)
|
||||||
|
|
||||||
// Persistent loading toast — survives navigation (Sonner is layout-level)
|
// Persistent loading toast — layout-level (Sonner), survives navigation
|
||||||
const toastId = toast.loading(
|
const toastId = toast.loading(
|
||||||
type === 'slides'
|
type === 'slides'
|
||||||
? (t('ai.generate.toastLoading.slides') || '⏳ Génération de la présentation en cours…')
|
? (t('ai.generate.toastLoading.slides') || '⏳ Génération de la présentation en cours…')
|
||||||
@@ -281,6 +283,7 @@ export function ContextualAIChat({
|
|||||||
)
|
)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// POST starts the agent immediately and returns agentId (fire-and-forget on server)
|
||||||
const res = await fetch('/api/agents/run-for-note', {
|
const res = await fetch('/api/agents/run-for-note', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -292,38 +295,69 @@ export function ContextualAIChat({
|
|||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (!res.ok || !data.success) {
|
if (!res.ok || !data.success) {
|
||||||
toast.error(data.error || t('ai.generate.error') || 'Erreur lors de la génération', { id: toastId })
|
toast.error(data.error || t('ai.generate.error') || 'Erreur lors de la génération', { id: toastId })
|
||||||
|
setGenerateLoading(null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setGenerateResult({ type, canvasId: data.canvasId, noteId: data.noteId })
|
|
||||||
|
|
||||||
if (type === 'slides' && data.canvasId) {
|
const { agentId } = data as { agentId: string }
|
||||||
toast.success(t('ai.generate.slidesReady') || 'Présentation générée !', {
|
|
||||||
id: toastId,
|
// Poll status every 3 s until terminal state
|
||||||
duration: 10000,
|
if (generatePollRef.current) clearInterval(generatePollRef.current)
|
||||||
description: t('ai.generate.toastSuccessSlides') || 'Cliquez sur le bouton Télécharger dans le panneau IA.',
|
generatePollRef.current = setInterval(async () => {
|
||||||
action: { label: t('ai.generate.downloadPptx') || 'Télécharger', onClick: () => window.open(`/api/canvas/download?id=${data.canvasId}`, '_blank') },
|
try {
|
||||||
})
|
const pollRes = await fetch(`/api/agents/run-for-note?agentId=${agentId}`)
|
||||||
} else if (type === 'diagram' && data.canvasId) {
|
const poll = await pollRes.json()
|
||||||
toast.success(t('ai.generate.diagramReady') || 'Diagramme généré !', {
|
|
||||||
id: toastId,
|
if (poll.status === 'success') {
|
||||||
duration: 10000,
|
clearInterval(generatePollRef.current!)
|
||||||
description: t('ai.generate.toastSuccessDiagram') || 'Votre diagramme est disponible dans le Lab.',
|
generatePollRef.current = null
|
||||||
action: { label: t('ai.generate.openDiagram') || 'Ouvrir', onClick: () => window.location.href = `/lab?canvas=${data.canvasId}` },
|
setGenerateLoading(null)
|
||||||
})
|
setGenerateResult({ type, canvasId: poll.canvasId, noteId: poll.noteId })
|
||||||
} else {
|
|
||||||
toast.success(type === 'slides'
|
if (type === 'slides' && poll.canvasId) {
|
||||||
? (t('ai.generate.slidesReady') || 'Présentation générée !')
|
toast.success(t('ai.generate.slidesReady') || 'Présentation générée !', {
|
||||||
: (t('ai.generate.diagramReady') || 'Diagramme généré !'),
|
id: toastId, duration: 10000,
|
||||||
{ id: toastId }
|
description: t('ai.generate.toastSuccessSlides') || 'Cliquez sur Télécharger dans le panneau IA.',
|
||||||
)
|
action: { label: t('ai.generate.downloadPptx') || 'Télécharger', onClick: () => window.open(`/api/canvas/download?id=${poll.canvasId}`, '_blank') },
|
||||||
}
|
})
|
||||||
|
} else if (type === 'diagram' && poll.canvasId) {
|
||||||
|
toast.success(t('ai.generate.diagramReady') || 'Diagramme généré !', {
|
||||||
|
id: toastId, duration: 10000,
|
||||||
|
description: t('ai.generate.toastSuccessDiagram') || 'Votre diagramme est disponible dans le Lab.',
|
||||||
|
action: { label: t('ai.generate.openDiagram') || 'Ouvrir', onClick: () => { window.location.href = `/lab?canvas=${poll.canvasId}` } },
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
toast.success(type === 'slides'
|
||||||
|
? (t('ai.generate.slidesReady') || 'Présentation générée !')
|
||||||
|
: (t('ai.generate.diagramReady') || 'Diagramme généré !'),
|
||||||
|
{ id: toastId }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else if (poll.status === 'failure') {
|
||||||
|
clearInterval(generatePollRef.current!)
|
||||||
|
generatePollRef.current = null
|
||||||
|
setGenerateLoading(null)
|
||||||
|
toast.error(poll.error || t('ai.generate.error') || 'Erreur lors de la génération', { id: toastId })
|
||||||
|
}
|
||||||
|
// If still 'running', do nothing — next poll will check again
|
||||||
|
} catch {
|
||||||
|
// Network error during poll — keep polling
|
||||||
|
}
|
||||||
|
}, 3000)
|
||||||
|
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(t('ai.generate.error') || 'Erreur lors de la génération', { id: toastId })
|
toast.error(t('ai.generate.error') || 'Erreur lors de la génération', { id: toastId })
|
||||||
} finally {
|
|
||||||
setGenerateLoading(null)
|
setGenerateLoading(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cleanup polling on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (generatePollRef.current) clearInterval(generatePollRef.current)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
// ── Resource tab handlers ────────────────────────────────────────────────────
|
// ── Resource tab handlers ────────────────────────────────────────────────────
|
||||||
|
|
||||||
const handleScrapeUrl = async () => {
|
const handleScrapeUrl = async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user