feat(mcp): add guided construction workflows
This commit is contained in:
@@ -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',
|
||||
},
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user