- 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>
359 lines
10 KiB
TypeScript
359 lines
10 KiB
TypeScript
/**
|
|
* AI Client for Control Center
|
|
* CRESyncFlow - Commercial Real Estate CRM
|
|
*
|
|
* Provides a unified interface for interacting with Claude and OpenAI APIs.
|
|
* Supports text completions and tool use with both providers.
|
|
*/
|
|
|
|
import Anthropic from '@anthropic-ai/sdk';
|
|
import OpenAI from 'openai';
|
|
import { ToolDefinition, ToolCall } from './types';
|
|
|
|
// =============================================================================
|
|
// Types
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Parameters for creating an AI completion
|
|
*/
|
|
export interface AICompletionParams {
|
|
/** AI provider to use */
|
|
provider: 'claude' | 'openai';
|
|
/** API key for the provider */
|
|
apiKey: string;
|
|
/** Conversation messages */
|
|
messages: Array<{ role: 'user' | 'assistant' | 'system'; content: string }>;
|
|
/** Optional tool definitions */
|
|
tools?: ToolDefinition[];
|
|
/** Optional system prompt (defaults to CRESyncFlow assistant) */
|
|
systemPrompt?: string;
|
|
/** Optional model override */
|
|
model?: string;
|
|
/** Optional max tokens (defaults to 4096) */
|
|
maxTokens?: number;
|
|
}
|
|
|
|
/**
|
|
* Result from an AI completion
|
|
*/
|
|
export interface AICompletionResult {
|
|
/** Text content of the response */
|
|
content: string;
|
|
/** Tool calls made by the assistant (if any) */
|
|
toolCalls?: ToolCall[];
|
|
/** Model used for the completion */
|
|
model: string;
|
|
/** Number of input tokens used (if available) */
|
|
inputTokens?: number;
|
|
/** Number of output tokens generated (if available) */
|
|
outputTokens?: number;
|
|
/** Reason the completion stopped */
|
|
stopReason: string;
|
|
}
|
|
|
|
// =============================================================================
|
|
// Tool Format Converters
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Convert our tool definitions to Claude's tool format
|
|
*/
|
|
function toClaudeTools(tools: ToolDefinition[]): Anthropic.Tool[] {
|
|
return tools.map((tool) => ({
|
|
name: tool.name,
|
|
description: tool.description,
|
|
input_schema: {
|
|
type: 'object' as const,
|
|
properties: tool.inputSchema.properties || {},
|
|
required: tool.inputSchema.required || [],
|
|
},
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Convert our tool definitions to OpenAI's tool format
|
|
*/
|
|
function toOpenAITools(tools: ToolDefinition[]): OpenAI.Chat.ChatCompletionTool[] {
|
|
return tools.map((tool) => ({
|
|
type: 'function' as const,
|
|
function: {
|
|
name: tool.name,
|
|
description: tool.description,
|
|
parameters: {
|
|
type: 'object',
|
|
properties: tool.inputSchema.properties || {},
|
|
required: tool.inputSchema.required || [],
|
|
},
|
|
},
|
|
}));
|
|
}
|
|
|
|
// =============================================================================
|
|
// Claude Implementation
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Create a completion using the Claude API
|
|
*/
|
|
async function createClaudeCompletion(
|
|
params: AICompletionParams
|
|
): Promise<AICompletionResult> {
|
|
const client = new Anthropic({ apiKey: params.apiKey });
|
|
|
|
// Filter out system messages as Claude handles system prompt separately
|
|
const filteredMessages = params.messages.filter((m) => m.role !== 'system');
|
|
|
|
// Build the request parameters
|
|
const requestParams: Anthropic.MessageCreateParams = {
|
|
model: params.model || 'claude-sonnet-4-20250514',
|
|
max_tokens: params.maxTokens || 4096,
|
|
system:
|
|
params.systemPrompt ||
|
|
'You are a helpful assistant for CRESyncFlow CRM, a commercial real estate management platform.',
|
|
messages: filteredMessages.map((m) => ({
|
|
role: m.role as 'user' | 'assistant',
|
|
content: m.content,
|
|
})),
|
|
};
|
|
|
|
// Add tools if provided
|
|
if (params.tools && params.tools.length > 0) {
|
|
requestParams.tools = toClaudeTools(params.tools);
|
|
}
|
|
|
|
try {
|
|
const response = await client.messages.create(requestParams);
|
|
|
|
// Extract text content and tool calls from response
|
|
let textContent = '';
|
|
const toolCalls: ToolCall[] = [];
|
|
|
|
for (const block of response.content) {
|
|
if (block.type === 'text') {
|
|
textContent += block.text;
|
|
} else if (block.type === 'tool_use') {
|
|
toolCalls.push({
|
|
id: block.id,
|
|
name: block.name,
|
|
input: block.input as Record<string, any>,
|
|
});
|
|
}
|
|
}
|
|
|
|
return {
|
|
content: textContent,
|
|
toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
|
|
model: response.model,
|
|
inputTokens: response.usage.input_tokens,
|
|
outputTokens: response.usage.output_tokens,
|
|
stopReason: response.stop_reason || 'unknown',
|
|
};
|
|
} catch (error) {
|
|
if (error instanceof Anthropic.APIError) {
|
|
throw new AIClientError(
|
|
`Claude API error: ${error.message}`,
|
|
error.status,
|
|
'claude',
|
|
error
|
|
);
|
|
}
|
|
throw new AIClientError(
|
|
`Claude client error: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
undefined,
|
|
'claude',
|
|
error
|
|
);
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// OpenAI Implementation
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Create a completion using the OpenAI API
|
|
*/
|
|
async function createOpenAICompletion(
|
|
params: AICompletionParams
|
|
): Promise<AICompletionResult> {
|
|
const client = new OpenAI({ apiKey: params.apiKey });
|
|
|
|
// Build messages array with system prompt first
|
|
const messages: OpenAI.Chat.ChatCompletionMessageParam[] = [
|
|
{
|
|
role: 'system',
|
|
content:
|
|
params.systemPrompt ||
|
|
'You are a helpful assistant for CRESyncFlow CRM, a commercial real estate management platform.',
|
|
},
|
|
...params.messages
|
|
.filter((m) => m.role !== 'system')
|
|
.map((m) => ({
|
|
role: m.role as 'user' | 'assistant',
|
|
content: m.content,
|
|
})),
|
|
];
|
|
|
|
// Build the request parameters
|
|
const requestParams: OpenAI.Chat.ChatCompletionCreateParams = {
|
|
model: params.model || 'gpt-4o',
|
|
messages,
|
|
};
|
|
|
|
// Add tools if provided
|
|
if (params.tools && params.tools.length > 0) {
|
|
requestParams.tools = toOpenAITools(params.tools);
|
|
}
|
|
|
|
try {
|
|
const response = await client.chat.completions.create(requestParams);
|
|
|
|
const choice = response.choices[0];
|
|
if (!choice) {
|
|
throw new AIClientError('No response from OpenAI', undefined, 'openai');
|
|
}
|
|
|
|
// Extract text content
|
|
const textContent = choice.message.content || '';
|
|
|
|
// Extract tool calls if present
|
|
const toolCalls: ToolCall[] = [];
|
|
if (choice.message.tool_calls) {
|
|
for (const toolCall of choice.message.tool_calls) {
|
|
if (toolCall.type === 'function') {
|
|
let parsedArguments: Record<string, any> = {};
|
|
try {
|
|
parsedArguments = JSON.parse(toolCall.function.arguments);
|
|
} catch {
|
|
// If JSON parsing fails, use empty object
|
|
console.warn(
|
|
`Failed to parse tool call arguments: ${toolCall.function.arguments}`
|
|
);
|
|
}
|
|
|
|
toolCalls.push({
|
|
id: toolCall.id,
|
|
name: toolCall.function.name,
|
|
input: parsedArguments,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
content: textContent,
|
|
toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
|
|
model: response.model,
|
|
inputTokens: response.usage?.prompt_tokens,
|
|
outputTokens: response.usage?.completion_tokens,
|
|
stopReason: choice.finish_reason || 'unknown',
|
|
};
|
|
} catch (error) {
|
|
if (error instanceof OpenAI.APIError) {
|
|
throw new AIClientError(
|
|
`OpenAI API error: ${error.message}`,
|
|
error.status,
|
|
'openai',
|
|
error
|
|
);
|
|
}
|
|
throw new AIClientError(
|
|
`OpenAI client error: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
undefined,
|
|
'openai',
|
|
error
|
|
);
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Error Handling
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Custom error class for AI client errors
|
|
*/
|
|
export class AIClientError extends Error {
|
|
/** HTTP status code (if applicable) */
|
|
statusCode?: number;
|
|
/** Provider that caused the error */
|
|
provider: 'claude' | 'openai';
|
|
/** Original error (if wrapped) */
|
|
originalError?: unknown;
|
|
|
|
constructor(
|
|
message: string,
|
|
statusCode?: number,
|
|
provider: 'claude' | 'openai' = 'claude',
|
|
originalError?: unknown
|
|
) {
|
|
super(message);
|
|
this.name = 'AIClientError';
|
|
this.statusCode = statusCode;
|
|
this.provider = provider;
|
|
this.originalError = originalError;
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Main Export
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Create an AI completion using the specified provider
|
|
*
|
|
* @param params - Completion parameters including provider, messages, and tools
|
|
* @returns Promise resolving to the completion result
|
|
* @throws AIClientError if the API call fails
|
|
*
|
|
* @example
|
|
* ```typescript
|
|
* const result = await createAICompletion({
|
|
* provider: 'claude',
|
|
* apiKey: process.env.ANTHROPIC_API_KEY!,
|
|
* messages: [{ role: 'user', content: 'Hello!' }],
|
|
* systemPrompt: 'You are a helpful CRM assistant.',
|
|
* });
|
|
* console.log(result.content);
|
|
* ```
|
|
*/
|
|
export async function createAICompletion(
|
|
params: AICompletionParams
|
|
): Promise<AICompletionResult> {
|
|
if (!params.apiKey) {
|
|
throw new AIClientError(
|
|
`API key is required for ${params.provider}`,
|
|
401,
|
|
params.provider
|
|
);
|
|
}
|
|
|
|
if (!params.messages || params.messages.length === 0) {
|
|
throw new AIClientError(
|
|
'At least one message is required',
|
|
400,
|
|
params.provider
|
|
);
|
|
}
|
|
|
|
if (params.provider === 'claude') {
|
|
return createClaudeCompletion(params);
|
|
} else if (params.provider === 'openai') {
|
|
return createOpenAICompletion(params);
|
|
} else {
|
|
throw new AIClientError(
|
|
`Unknown provider: ${params.provider}`,
|
|
400,
|
|
params.provider as 'claude' | 'openai'
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if an error is an AIClientError
|
|
*/
|
|
export function isAIClientError(error: unknown): error is AIClientError {
|
|
return error instanceof AIClientError;
|
|
}
|