/** * Control Center Chat API Endpoint * CRESyncFlow - Commercial Real Estate CRM * * POST /api/v1/control-center/chat * * Handles the main AI chat functionality with SSE streaming. * Supports tool use with an agentic loop for multi-step interactions. */ import { NextRequest } from 'next/server'; import type { Prisma } from '@prisma/client'; import { getSession } from '@/lib/auth'; import { settingsService } from '@/lib/settings/settings-service'; import { conversationService } from '@/lib/control-center/conversation-service'; import { createAICompletion, AIClientError } from '@/lib/control-center/ai-client'; import { createToolRouter } from '@/lib/control-center/tool-router'; import { getGHLClientForUser } from '@/lib/ghl/helpers'; import type { StreamEvent, ControlCenterMessage, ToolCall, ToolResult, } from '@/types/control-center'; import type { ToolContext } from '@/lib/control-center/types'; // ============================================================================= // Types // ============================================================================= interface ChatRequestBody { conversationId?: string; message: string; provider?: 'claude' | 'openai'; } // ============================================================================= // SSE Helpers // ============================================================================= /** * Create an SSE-formatted string for an event */ function formatSSE(event: StreamEvent): string { return `data: ${JSON.stringify(event)}\n\n`; } /** * Create an SSE error response */ function createErrorResponse(message: string, status: number): Response { return new Response(JSON.stringify({ error: message }), { status, headers: { 'Content-Type': 'application/json' }, }); } // ============================================================================= // System Prompt // ============================================================================= const SYSTEM_PROMPT = `You are an AI assistant for CRESyncFlow, a commercial real estate CRM platform. You help users manage their leads, contacts, conversations, and business workflows. You have access to tools that allow you to: - Search and manage contacts and leads - View and send messages in conversations - Look up opportunity and deal information - Access CRM data and analytics When users ask questions about their CRM data, use the available tools to fetch real information. Be helpful, professional, and concise in your responses. If you don't have access to a specific piece of information or a tool to retrieve it, let the user know what you can help with instead.`; // ============================================================================= // Main Handler // ============================================================================= export async function POST(request: NextRequest): Promise { // Authenticate user const session = await getSession(); if (!session) { return createErrorResponse('Unauthorized', 401); } // Parse and validate request body let body: ChatRequestBody; try { body = await request.json(); } catch { return createErrorResponse('Invalid JSON body', 400); } if (!body.message || typeof body.message !== 'string' || body.message.trim() === '') { return createErrorResponse('Missing or empty message', 400); } const provider = body.provider || 'claude'; if (provider !== 'claude' && provider !== 'openai') { return createErrorResponse('Invalid provider. Must be "claude" or "openai"', 400); } // Check for AI API key const apiKeyName = provider === 'claude' ? 'claudeApiKey' : 'openaiApiKey'; const apiKey = await settingsService.get(apiKeyName); if (!apiKey) { return createErrorResponse( `No ${provider === 'claude' ? 'Claude' : 'OpenAI'} API key configured. Please add your API key in settings.`, 400 ); } // Create SSE stream const encoder = new TextEncoder(); const stream = new TransformStream(); const writer = stream.writable.getWriter(); // Start processing in the background (async () => { try { // Create or get conversation let conversationId = body.conversationId; if (!conversationId) { const conversation = await conversationService.create( session.user.id, body.message.slice(0, 100) // Use first 100 chars as title ); conversationId = conversation.id; } // Verify conversation belongs to user const existingConversation = await conversationService.getById(conversationId); if (!existingConversation) { await writer.write( encoder.encode(formatSSE({ type: 'error', error: 'Conversation not found' })) ); await writer.close(); return; } // Save user message const userMessage = await conversationService.addMessage(conversationId, { role: 'user', content: body.message, }); // Generate a message ID for the assistant response const messageId = `msg_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`; // Send message_start event await writer.write( encoder.encode( formatSSE({ type: 'message_start', conversationId, messageId, }) ) ); // Get available tools (limited to 128 for OpenAI compatibility) const toolRouter = await createToolRouter(); const allTools = await toolRouter.getAllTools(); // Prioritize CRE-relevant tools: contacts, conversations, opportunities, calendar, email const priorityPrefixes = ['contact', 'conversation', 'opportunity', 'calendar', 'email', 'location']; const priorityTools = allTools.filter(t => priorityPrefixes.some(prefix => t.name.toLowerCase().startsWith(prefix)) ); const otherTools = allTools.filter(t => !priorityPrefixes.some(prefix => t.name.toLowerCase().startsWith(prefix)) ); // Combine priority tools first, then fill with others up to 128 max const tools = [...priorityTools, ...otherTools].slice(0, 128); // Build messages array from conversation history const messages: Array<{ role: 'user' | 'assistant' | 'system'; content: string }> = existingConversation.messages.map((msg) => ({ role: msg.role as 'user' | 'assistant', content: msg.content, })); // Add the current user message messages.push({ role: 'user', content: body.message }); // Set up tool context const ghlClient = await getGHLClientForUser(session.user.id); const toolContext: ToolContext = { userId: session.user.id, ghlClient, }; // Agentic loop: continue until AI doesn't return tool calls let fullContent = ''; const allToolCalls: ToolCall[] = []; const allToolResults: ToolResult[] = []; let iterations = 0; const maxIterations = 10; // Prevent infinite loops while (iterations < maxIterations) { iterations++; // Call AI const result = await createAICompletion({ provider, apiKey, messages, tools, systemPrompt: SYSTEM_PROMPT, }); // Stream content delta if there's text if (result.content) { fullContent += result.content; await writer.write( encoder.encode( formatSSE({ type: 'content_delta', delta: result.content, }) ) ); } // If no tool calls, we're done if (!result.toolCalls || result.toolCalls.length === 0) { break; } // Process tool calls for (const toolCall of result.toolCalls) { allToolCalls.push(toolCall); // Send tool_call_start event await writer.write( encoder.encode( formatSSE({ type: 'tool_call_start', toolCall, }) ) ); // Execute the tool const toolResult = await toolRouter.execute(toolCall, toolContext); allToolResults.push(toolResult); // Send tool_result event await writer.write( encoder.encode( formatSSE({ type: 'tool_result', toolResult, }) ) ); } // Add assistant message with tool calls to conversation messages.push({ role: 'assistant', content: result.content || '', }); // Add tool results as a user message (following Claude's format) const toolResultsContent = allToolResults .slice(-result.toolCalls.length) .map((tr) => { if (tr.success) { return `Tool ${tr.toolCallId} result: ${JSON.stringify(tr.result)}`; } return `Tool ${tr.toolCallId} error: ${tr.error}`; }) .join('\n'); messages.push({ role: 'user', content: toolResultsContent, }); } // Save final assistant message const savedMessage = await conversationService.addMessage(conversationId, { role: 'assistant', content: fullContent, toolCalls: allToolCalls.length > 0 ? (allToolCalls as unknown as Prisma.InputJsonValue) : undefined, toolResults: allToolResults.length > 0 ? (allToolResults as unknown as Prisma.InputJsonValue) : undefined, }); // Build the complete message object const completeMessage: ControlCenterMessage = { id: savedMessage.id, role: 'assistant', content: fullContent, toolCalls: allToolCalls.length > 0 ? allToolCalls : undefined, toolResults: allToolResults.length > 0 ? allToolResults : undefined, createdAt: savedMessage.createdAt.toISOString(), }; // Send message_complete event await writer.write( encoder.encode( formatSSE({ type: 'message_complete', message: completeMessage, }) ) ); } catch (error) { console.error('Control Center chat error:', error); let errorMessage = 'An unexpected error occurred'; let errorCode: string | undefined; if (error instanceof AIClientError) { errorMessage = error.message; errorCode = error.statusCode?.toString(); } else if (error instanceof Error) { errorMessage = error.message; } await writer.write( encoder.encode( formatSSE({ type: 'error', error: errorMessage, code: errorCode, }) ) ); } finally { await writer.close(); } })(); // Return the SSE response return new Response(stream.readable, { headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', }, }); }