- Build complete Next.js CRM for commercial real estate - Add authentication with JWT sessions and role-based access - Add GoHighLevel API integration for contacts, conversations, opportunities - Add AI-powered Control Center with tool calling - Add Setup page with onboarding checklist (/setup) - Add sidebar navigation with Setup menu item - Fix type errors in onboarding API, GHL services, and control center tools - Add Prisma schema with SQLite for local development - Add UI components with clay morphism design system Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
346 lines
11 KiB
TypeScript
346 lines
11 KiB
TypeScript
/**
|
|
* 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<Response> {
|
|
// 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',
|
|
},
|
|
});
|
|
}
|