BusyBee3333 4e6467ffb0 Add CRESync CRM application with Setup page
- 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>
2026-01-14 17:30:55 -05:00

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