| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195 |
- /**
- * Stateless Chat API Endpoint
- *
- * POST /api/chat - Send message, receive SSE stream
- *
- * This endpoint:
- * 1. Receives full state from client (messages + storeState)
- * 2. Runs single-pass generation
- * 3. Streams events as SSE (text deltas + tool calls)
- *
- * Fully stateless: interruption is handled by the client aborting
- * the fetch request, which triggers req.signal on the server side.
- */
- import { NextRequest } from 'next/server';
- import { statelessGenerate } from '@/lib/orchestration/stateless-generate';
- import { isProviderKeyRequired } from '@/lib/ai/providers';
- import type { StatelessChatRequest, StatelessEvent } from '@/lib/types/chat';
- import type { ThinkingConfig } from '@/lib/types/provider';
- import { apiError } from '@/lib/server/api-response';
- import { createLogger } from '@/lib/logger';
- import { resolveModel } from '@/lib/server/resolve-model';
- const log = createLogger('Chat API');
- // Allow streaming responses up to 60 seconds
- export const maxDuration = 60;
- /**
- * POST /api/chat
- * Send a message and receive SSE stream of generation events
- *
- * Request body: StatelessChatRequest
- * {
- * messages: UIMessage[],
- * storeState: { stage, scenes, currentSceneId, mode },
- * config: { agentIds, sessionType? },
- * apiKey: string,
- * baseUrl?: string,
- * model?: string
- * }
- *
- * Response: SSE stream of StatelessEvent
- */
- export async function POST(req: NextRequest) {
- const encoder = new TextEncoder();
- let chatModel: string | undefined;
- let chatMessageCount: number | undefined;
- try {
- const body: StatelessChatRequest = await req.json();
- chatModel = body.model;
- chatMessageCount = body.messages?.length;
- // Validate required fields
- if (!body.messages || !Array.isArray(body.messages)) {
- return apiError('MISSING_REQUIRED_FIELD', 400, 'Missing required field: messages');
- }
- if (!body.storeState) {
- return apiError('MISSING_REQUIRED_FIELD', 400, 'Missing required field: storeState');
- }
- if (!body.config || !body.config.agentIds || body.config.agentIds.length === 0) {
- return apiError('MISSING_REQUIRED_FIELD', 400, 'Missing required field: config.agentIds');
- }
- const {
- model: languageModel,
- apiKey: resolvedApiKey,
- providerId,
- } = await resolveModel({
- modelString: body.model,
- apiKey: body.apiKey,
- baseUrl: body.baseUrl,
- providerType: body.providerType,
- });
- if (isProviderKeyRequired(providerId) && !resolvedApiKey) {
- return apiError('MISSING_API_KEY', 401, 'API Key is required');
- }
- log.info('Processing request');
- log.info(
- `Agents: ${body.config.agentIds.join(', ')}, Messages: ${body.messages.length}, Turn: ${body.directorState?.turnCount ?? 0}`,
- );
- // Use the native request signal for abort propagation
- const signal = req.signal;
- // Create SSE stream
- const { readable, writable } = new TransformStream();
- const writer = writable.getWriter();
- // Stream generation in background with heartbeat to prevent connection timeout
- const HEARTBEAT_INTERVAL_MS = 15_000;
- (async () => {
- // Heartbeat: periodically send SSE comments to keep the connection alive.
- // Proxies / browsers may close idle SSE connections after 30-120s of silence.
- let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
- const startHeartbeat = () => {
- stopHeartbeat();
- heartbeatTimer = setInterval(() => {
- try {
- writer.write(encoder.encode(`:heartbeat\n\n`)).catch(() => stopHeartbeat());
- } catch {
- stopHeartbeat();
- }
- }, HEARTBEAT_INTERVAL_MS);
- };
- const stopHeartbeat = () => {
- if (heartbeatTimer) {
- clearInterval(heartbeatTimer);
- heartbeatTimer = null;
- }
- };
- try {
- startHeartbeat();
- const generator = statelessGenerate(
- {
- ...body,
- apiKey: resolvedApiKey,
- },
- signal,
- languageModel,
- { enabled: false } satisfies ThinkingConfig,
- );
- for await (const event of generator) {
- if (signal.aborted) {
- log.info('Request was aborted');
- break;
- }
- const data = `data: ${JSON.stringify(event)}\n\n`;
- await writer.write(encoder.encode(data));
- }
- stopHeartbeat();
- await writer.close();
- } catch (error) {
- stopHeartbeat();
- // If aborted, just close the writer silently
- if (signal.aborted) {
- log.info('Request aborted during streaming');
- try {
- await writer.close();
- } catch {
- /* already closed */
- }
- return;
- }
- log.error(
- `Chat stream error [model=${body.model ?? 'unknown'}, agents=${body.config?.agentIds?.length ?? 0}, messages=${body.messages?.length ?? 0}]:`,
- error,
- );
- // Try to send error event
- try {
- const errorEvent: StatelessEvent = {
- type: 'error',
- data: {
- message: error instanceof Error ? error.message : String(error),
- },
- };
- await writer.write(encoder.encode(`data: ${JSON.stringify(errorEvent)}\n\n`));
- await writer.close();
- } catch {
- // Writer may already be closed
- }
- }
- })();
- return new Response(readable, {
- headers: {
- 'Content-Type': 'text/event-stream',
- 'Cache-Control': 'no-cache',
- Connection: 'keep-alive',
- },
- });
- } catch (error) {
- log.error(
- `Chat request failed [model=${chatModel ?? 'unknown'}, messages=${chatMessageCount ?? 0}]:`,
- error,
- );
- return apiError(
- 'INTERNAL_ERROR',
- 500,
- error instanceof Error ? error.message : 'Failed to process request',
- );
- }
- }
|