134 c3faeba1c8 Squashed 'services/pascal-editor/' content from commit 312064e6
git-subtree-dir: services/pascal-editor
git-subtree-split: 312064e6e8122f958d2961e28ea85ad8b9db40c5
2026-07-12 00:43:59 +08:00

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 },
)
}
}