feat(mcp): add guided construction workflows

This commit is contained in:
Aymeric Rabot
2026-04-27 14:31:04 -04:00
parent b3d1f663f6
commit 3d5c87a651
48 changed files with 3877 additions and 115 deletions
@@ -0,0 +1,101 @@
import { NextResponse } from 'next/server'
import { getSceneStore } from '@/lib/scene-store-server'
export const dynamic = 'force-dynamic'
export const runtime = 'nodejs'
type RouteParams = { params: Promise<{ id: string }> }
const POLL_MS = 250
const HEARTBEAT_MS = 15_000
const MAX_EVENTS_PER_POLL = 50
export async function GET(request: Request, { params }: RouteParams) {
const { id } = await params
const store = await getSceneStore()
if (!store.listSceneEvents) {
return NextResponse.json({ error: 'scene_events_unavailable' }, { status: 501 })
}
const scene = await store.load(id)
if (!scene) {
return NextResponse.json({ error: 'not_found' }, { status: 404 })
}
const url = new URL(request.url)
const afterFromQuery = Number.parseInt(url.searchParams.get('after') ?? '0', 10)
const afterFromHeader = Number.parseInt(request.headers.get('Last-Event-ID') ?? '0', 10)
let cursor = Math.max(
0,
Number.isFinite(afterFromQuery) ? afterFromQuery : 0,
Number.isFinite(afterFromHeader) ? afterFromHeader : 0,
)
const encoder = new TextEncoder()
let closed = false
let pollTimer: ReturnType<typeof setTimeout> | undefined
let heartbeatTimer: ReturnType<typeof setInterval> | undefined
const stream = new ReadableStream<Uint8Array>({
start(controller) {
const enqueue = (chunk: string) => {
if (!closed) controller.enqueue(encoder.encode(chunk))
}
const close = () => {
if (closed) return
closed = true
if (pollTimer) clearTimeout(pollTimer)
if (heartbeatTimer) clearInterval(heartbeatTimer)
try {
controller.close()
} catch {
// The client may have already closed the stream.
}
}
request.signal.addEventListener('abort', close, { once: true })
enqueue('retry: 1000\n\n')
const poll = async () => {
if (closed) return
try {
const events = await store.listSceneEvents!(id, {
afterEventId: cursor,
limit: MAX_EVENTS_PER_POLL,
})
for (const event of events) {
cursor = event.eventId
enqueue(`id: ${event.eventId}\n`)
enqueue('event: scene\n')
enqueue(`data: ${JSON.stringify(event)}\n\n`)
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
enqueue('event: error\n')
enqueue(`data: ${JSON.stringify({ message })}\n\n`)
} finally {
if (!closed) pollTimer = setTimeout(poll, POLL_MS)
}
}
heartbeatTimer = setInterval(() => enqueue(': keepalive\n\n'), HEARTBEAT_MS)
void poll()
},
cancel() {
closed = true
if (pollTimer) clearTimeout(pollTimer)
if (heartbeatTimer) clearInterval(heartbeatTimer)
},
})
return new Response(stream, {
headers: {
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
'Content-Type': 'text/event-stream; charset=utf-8',
'X-Accel-Buffering': 'no',
},
})
}
+64 -1
View File
@@ -1,6 +1,7 @@
'use client'
import {
applySceneGraphToEditor,
Editor,
type SceneGraph,
type SidebarTab,
@@ -9,7 +10,7 @@ import {
} from '@pascal-app/editor'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useCallback, useRef, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
export interface SceneMeta {
id: string
@@ -37,9 +38,32 @@ interface SceneLoaderProps {
meta: SceneMeta
}
type SceneGraphWithCollections = SceneGraph & {
collections?: Record<string, unknown>
}
interface LiveSceneEvent {
eventId: number
sceneId: string
version: number
kind: string
createdAt: string
graph: SceneGraphWithCollections
}
function sceneGraphSignature(graph: SceneGraphWithCollections): string {
return JSON.stringify({
nodes: graph.nodes,
rootNodeIds: graph.rootNodeIds,
collections: graph.collections,
})
}
export function SceneLoader({ initialScene, meta }: SceneLoaderProps) {
const router = useRouter()
const versionRef = useRef(meta.version)
const lastRemoteGraphJsonRef = useRef<string | null>(null)
const suppressRemoteSaveUntilRef = useRef(0)
const [conflict, setConflict] = useState(false)
const [saveError, setSaveError] = useState<string | null>(null)
@@ -47,6 +71,15 @@ export function SceneLoader({ initialScene, meta }: SceneLoaderProps) {
const handleSave = useCallback(
async (graph: SceneGraph) => {
const graphJson = sceneGraphSignature(graph)
const isRecentRemoteApply = Date.now() < suppressRemoteSaveUntilRef.current
if (lastRemoteGraphJsonRef.current === graphJson) {
lastRemoteGraphJsonRef.current = null
suppressRemoteSaveUntilRef.current = 0
return
}
if (isRecentRemoteApply) return
try {
const response = await fetch(`/api/scenes/${meta.id}`, {
method: 'PUT',
@@ -77,6 +110,36 @@ export function SceneLoader({ initialScene, meta }: SceneLoaderProps) {
[meta.id, meta.name],
)
useEffect(() => {
const source = new EventSource(`/api/scenes/${meta.id}/events`)
source.addEventListener('scene', (event) => {
let payload: LiveSceneEvent
try {
payload = JSON.parse((event as MessageEvent<string>).data) as LiveSceneEvent
} catch {
return
}
if (payload.sceneId !== meta.id) return
if (payload.version <= versionRef.current) return
versionRef.current = payload.version
lastRemoteGraphJsonRef.current = sceneGraphSignature(payload.graph)
suppressRemoteSaveUntilRef.current = Date.now() + 2500
applySceneGraphToEditor(payload.graph)
setConflict(false)
setSaveError(null)
})
source.addEventListener('error', () => {
if (source.readyState === EventSource.CLOSED) {
setSaveError('Live scene connection closed')
}
})
return () => source.close()
}, [meta.id])
const handleThumb = useCallback(
async (_blob: Blob) => {
// TODO(phase7): upload thumbnail via POST /api/scenes/[id]/thumbnail.