route.ts 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195
  1. /**
  2. * Stateless Chat API Endpoint
  3. *
  4. * POST /api/chat - Send message, receive SSE stream
  5. *
  6. * This endpoint:
  7. * 1. Receives full state from client (messages + storeState)
  8. * 2. Runs single-pass generation
  9. * 3. Streams events as SSE (text deltas + tool calls)
  10. *
  11. * Fully stateless: interruption is handled by the client aborting
  12. * the fetch request, which triggers req.signal on the server side.
  13. */
  14. import { NextRequest } from 'next/server';
  15. import { statelessGenerate } from '@/lib/orchestration/stateless-generate';
  16. import { isProviderKeyRequired } from '@/lib/ai/providers';
  17. import type { StatelessChatRequest, StatelessEvent } from '@/lib/types/chat';
  18. import type { ThinkingConfig } from '@/lib/types/provider';
  19. import { apiError } from '@/lib/server/api-response';
  20. import { createLogger } from '@/lib/logger';
  21. import { resolveModel } from '@/lib/server/resolve-model';
  22. const log = createLogger('Chat API');
  23. // Allow streaming responses up to 60 seconds
  24. export const maxDuration = 60;
  25. /**
  26. * POST /api/chat
  27. * Send a message and receive SSE stream of generation events
  28. *
  29. * Request body: StatelessChatRequest
  30. * {
  31. * messages: UIMessage[],
  32. * storeState: { stage, scenes, currentSceneId, mode },
  33. * config: { agentIds, sessionType? },
  34. * apiKey: string,
  35. * baseUrl?: string,
  36. * model?: string
  37. * }
  38. *
  39. * Response: SSE stream of StatelessEvent
  40. */
  41. export async function POST(req: NextRequest) {
  42. const encoder = new TextEncoder();
  43. let chatModel: string | undefined;
  44. let chatMessageCount: number | undefined;
  45. try {
  46. const body: StatelessChatRequest = await req.json();
  47. chatModel = body.model;
  48. chatMessageCount = body.messages?.length;
  49. // Validate required fields
  50. if (!body.messages || !Array.isArray(body.messages)) {
  51. return apiError('MISSING_REQUIRED_FIELD', 400, 'Missing required field: messages');
  52. }
  53. if (!body.storeState) {
  54. return apiError('MISSING_REQUIRED_FIELD', 400, 'Missing required field: storeState');
  55. }
  56. if (!body.config || !body.config.agentIds || body.config.agentIds.length === 0) {
  57. return apiError('MISSING_REQUIRED_FIELD', 400, 'Missing required field: config.agentIds');
  58. }
  59. const {
  60. model: languageModel,
  61. apiKey: resolvedApiKey,
  62. providerId,
  63. } = await resolveModel({
  64. modelString: body.model,
  65. apiKey: body.apiKey,
  66. baseUrl: body.baseUrl,
  67. providerType: body.providerType,
  68. });
  69. if (isProviderKeyRequired(providerId) && !resolvedApiKey) {
  70. return apiError('MISSING_API_KEY', 401, 'API Key is required');
  71. }
  72. log.info('Processing request');
  73. log.info(
  74. `Agents: ${body.config.agentIds.join(', ')}, Messages: ${body.messages.length}, Turn: ${body.directorState?.turnCount ?? 0}`,
  75. );
  76. // Use the native request signal for abort propagation
  77. const signal = req.signal;
  78. // Create SSE stream
  79. const { readable, writable } = new TransformStream();
  80. const writer = writable.getWriter();
  81. // Stream generation in background with heartbeat to prevent connection timeout
  82. const HEARTBEAT_INTERVAL_MS = 15_000;
  83. (async () => {
  84. // Heartbeat: periodically send SSE comments to keep the connection alive.
  85. // Proxies / browsers may close idle SSE connections after 30-120s of silence.
  86. let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
  87. const startHeartbeat = () => {
  88. stopHeartbeat();
  89. heartbeatTimer = setInterval(() => {
  90. try {
  91. writer.write(encoder.encode(`:heartbeat\n\n`)).catch(() => stopHeartbeat());
  92. } catch {
  93. stopHeartbeat();
  94. }
  95. }, HEARTBEAT_INTERVAL_MS);
  96. };
  97. const stopHeartbeat = () => {
  98. if (heartbeatTimer) {
  99. clearInterval(heartbeatTimer);
  100. heartbeatTimer = null;
  101. }
  102. };
  103. try {
  104. startHeartbeat();
  105. const generator = statelessGenerate(
  106. {
  107. ...body,
  108. apiKey: resolvedApiKey,
  109. },
  110. signal,
  111. languageModel,
  112. { enabled: false } satisfies ThinkingConfig,
  113. );
  114. for await (const event of generator) {
  115. if (signal.aborted) {
  116. log.info('Request was aborted');
  117. break;
  118. }
  119. const data = `data: ${JSON.stringify(event)}\n\n`;
  120. await writer.write(encoder.encode(data));
  121. }
  122. stopHeartbeat();
  123. await writer.close();
  124. } catch (error) {
  125. stopHeartbeat();
  126. // If aborted, just close the writer silently
  127. if (signal.aborted) {
  128. log.info('Request aborted during streaming');
  129. try {
  130. await writer.close();
  131. } catch {
  132. /* already closed */
  133. }
  134. return;
  135. }
  136. log.error(
  137. `Chat stream error [model=${body.model ?? 'unknown'}, agents=${body.config?.agentIds?.length ?? 0}, messages=${body.messages?.length ?? 0}]:`,
  138. error,
  139. );
  140. // Try to send error event
  141. try {
  142. const errorEvent: StatelessEvent = {
  143. type: 'error',
  144. data: {
  145. message: error instanceof Error ? error.message : String(error),
  146. },
  147. };
  148. await writer.write(encoder.encode(`data: ${JSON.stringify(errorEvent)}\n\n`));
  149. await writer.close();
  150. } catch {
  151. // Writer may already be closed
  152. }
  153. }
  154. })();
  155. return new Response(readable, {
  156. headers: {
  157. 'Content-Type': 'text/event-stream',
  158. 'Cache-Control': 'no-cache',
  159. Connection: 'keep-alive',
  160. },
  161. });
  162. } catch (error) {
  163. log.error(
  164. `Chat request failed [model=${chatModel ?? 'unknown'}, messages=${chatMessageCount ?? 0}]:`,
  165. error,
  166. );
  167. return apiError(
  168. 'INTERNAL_ERROR',
  169. 500,
  170. error instanceof Error ? error.message : 'Failed to process request',
  171. );
  172. }
  173. }