git-subtree-dir: services/pascal-editor git-subtree-split: 312064e6e8122f958d2961e28ea85ad8b9db40c5
162 lines
5.0 KiB
TypeScript
162 lines
5.0 KiB
TypeScript
import { createOpenAI } from '@ai-sdk/openai'
|
|
import { convertToModelMessages, stepCountIs, streamText, type UIMessage } from 'ai'
|
|
import { NextResponse } from 'next/server'
|
|
import { z } from 'zod'
|
|
import { validatePascalAgentAccess } from '@/lib/pascal-agent-access'
|
|
import { getPascalAgentConfig } from '@/lib/pascal-agent-config'
|
|
import { createPascalAgentMcpSession } from '@/lib/pascal-agent-mcp'
|
|
import { buildPascalAgentPrompt } from '@/lib/pascal-agent-prompt'
|
|
import { getPascalHostIdentity } from '@/lib/pascal-host-identity'
|
|
import { getSceneOperations } from '@/lib/scene-store-server'
|
|
|
|
export const dynamic = 'force-dynamic'
|
|
export const maxDuration = 600
|
|
export const runtime = 'nodejs'
|
|
|
|
const requestSchema = z.object({
|
|
messages: z.array(z.unknown()).min(1).max(100),
|
|
sceneId: z
|
|
.string()
|
|
.min(1)
|
|
.max(64)
|
|
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
|
|
})
|
|
|
|
const AGENT_ERROR_MESSAGE = 'AI design agent is temporarily unavailable. Please try again.'
|
|
const SCENE_ERROR_MESSAGE = 'The current scene could not be loaded.'
|
|
|
|
function accessErrorResponse(result: ReturnType<typeof validatePascalAgentAccess>) {
|
|
if (result === 'missing_proxy_secret') {
|
|
return NextResponse.json({ error: 'agent_proxy_not_configured' }, { status: 503 })
|
|
}
|
|
if (result === 'unauthorized') {
|
|
return NextResponse.json({ error: 'unauthorized' }, { status: 401 })
|
|
}
|
|
return null
|
|
}
|
|
|
|
export function GET(request: Request) {
|
|
const accessError = accessErrorResponse(validatePascalAgentAccess(request.headers))
|
|
if (accessError) return accessError
|
|
const identity = getPascalHostIdentity(request.headers)
|
|
if (!identity.allowed) {
|
|
return NextResponse.json({ error: 'host_user_required' }, { status: 401 })
|
|
}
|
|
const config = getPascalAgentConfig()
|
|
return NextResponse.json({
|
|
configured: config.configured,
|
|
maxSteps: config.maxSteps,
|
|
model: config.model,
|
|
})
|
|
}
|
|
|
|
export async function POST(request: Request) {
|
|
const accessError = accessErrorResponse(validatePascalAgentAccess(request.headers))
|
|
if (accessError) return accessError
|
|
const identity = getPascalHostIdentity(request.headers)
|
|
if (!identity.allowed) {
|
|
return NextResponse.json({ error: 'host_user_required' }, { status: 401 })
|
|
}
|
|
const config = getPascalAgentConfig()
|
|
if (!config.configured) {
|
|
return NextResponse.json(
|
|
{
|
|
error: 'agent_not_configured',
|
|
message: 'Configure PASCAL_AGENT_API_KEY before using the AI design agent.',
|
|
},
|
|
{ status: 503 },
|
|
)
|
|
}
|
|
|
|
let body: unknown
|
|
try {
|
|
body = await request.json()
|
|
} catch {
|
|
return NextResponse.json({ error: 'invalid_json' }, { status: 400 })
|
|
}
|
|
|
|
const parsed = requestSchema.safeParse(body)
|
|
if (!parsed.success) {
|
|
return NextResponse.json(
|
|
{ error: 'invalid_request', details: parsed.error.issues },
|
|
{ status: 400 },
|
|
)
|
|
}
|
|
|
|
const operations = await getSceneOperations()
|
|
const storedScene = await operations.loadStoredScene(parsed.data.sceneId)
|
|
if (!storedScene || (identity.userId && storedScene.ownerId !== identity.userId)) {
|
|
return NextResponse.json(
|
|
{ error: 'scene_unavailable', message: SCENE_ERROR_MESSAGE },
|
|
{ status: 404 },
|
|
)
|
|
}
|
|
|
|
const abortSignal = AbortSignal.any([request.signal, AbortSignal.timeout(config.totalTimeoutMs)])
|
|
let session: Awaited<ReturnType<typeof createPascalAgentMcpSession>>
|
|
try {
|
|
session = await createPascalAgentMcpSession({
|
|
abortSignal,
|
|
sceneId: parsed.data.sceneId,
|
|
stepTimeoutMs: config.stepTimeoutMs,
|
|
})
|
|
} catch (error) {
|
|
console.error('Pascal agent could not load the requested scene.', error)
|
|
return NextResponse.json(
|
|
{ error: 'scene_unavailable', message: SCENE_ERROR_MESSAGE },
|
|
{ status: 404 },
|
|
)
|
|
}
|
|
const provider = createOpenAI({
|
|
apiKey: config.apiKey,
|
|
baseURL: config.baseUrl,
|
|
name: 'pascal-agent',
|
|
})
|
|
|
|
let finalized = false
|
|
const finalize = async (save: boolean) => {
|
|
if (finalized) return
|
|
finalized = true
|
|
try {
|
|
if (save && !abortSignal.aborted) {
|
|
await session.saveDraft(abortSignal)
|
|
}
|
|
} finally {
|
|
await session.close()
|
|
}
|
|
}
|
|
|
|
try {
|
|
const result = streamText({
|
|
abortSignal,
|
|
maxOutputTokens: 1600,
|
|
messages: convertToModelMessages(parsed.data.messages as UIMessage[]),
|
|
model: provider.chat(config.model),
|
|
onAbort: async () => finalize(false),
|
|
onError: async () => finalize(false),
|
|
onFinish: async () => finalize(true),
|
|
stopWhen: stepCountIs(config.maxSteps),
|
|
system: buildPascalAgentPrompt({
|
|
sceneId: parsed.data.sceneId,
|
|
sceneName: session.sceneName,
|
|
}),
|
|
temperature: 0.2,
|
|
tools: session.tools,
|
|
})
|
|
|
|
return result.toUIMessageStreamResponse({
|
|
onError: (error) => {
|
|
console.error('Pascal agent stream failed.', error)
|
|
return AGENT_ERROR_MESSAGE
|
|
},
|
|
})
|
|
} catch (error) {
|
|
await finalize(false)
|
|
console.error('Pascal agent failed before streaming.', error)
|
|
return NextResponse.json(
|
|
{ error: 'agent_failed', message: AGENT_ERROR_MESSAGE },
|
|
{ status: 500 },
|
|
)
|
|
}
|
|
}
|