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

437 lines
12 KiB
TypeScript

'use client';
import React, {
createContext,
useContext,
useState,
useCallback,
useRef,
ReactNode,
} from 'react';
import {
ControlCenterConversation,
ControlCenterMessage,
StreamEvent,
ToolCall,
ToolResult,
} from '@/types/control-center';
// =============================================================================
// Types
// =============================================================================
/**
* Summary of a conversation for the list view
*/
export interface ConversationSummary {
id: string;
title: string;
createdAt: string;
updatedAt: string;
messageCount: number;
}
/**
* Context state interface
*/
interface ChatContextState {
/** List of conversation summaries */
conversations: ConversationSummary[];
/** Currently loaded conversation with full messages */
currentConversation: ControlCenterConversation | null;
/** Loading state for API calls */
isLoading: boolean;
/** Whether a response is currently streaming */
isStreaming: boolean;
/** Current streaming text content */
streamingContent: string;
/** Error message if any */
error: string | null;
}
/**
* Context actions interface
*/
interface ChatContextActions {
/** Load all conversations from the API */
loadConversations: () => Promise<void>;
/** Select and load a conversation by ID */
selectConversation: (id: string) => Promise<void>;
/** Send a message to the current conversation */
sendMessage: (message: string, provider?: string) => Promise<void>;
/** Start a new conversation */
newConversation: () => void;
/** Clear the current error */
clearError: () => void;
}
type ChatContextValue = ChatContextState & ChatContextActions;
// =============================================================================
// Context
// =============================================================================
const ChatContext = createContext<ChatContextValue | null>(null);
// =============================================================================
// Provider Component
// =============================================================================
interface ChatProviderProps {
children: ReactNode;
}
export function ChatProvider({ children }: ChatProviderProps) {
// State
const [conversations, setConversations] = useState<ConversationSummary[]>([]);
const [currentConversation, setCurrentConversation] = useState<ControlCenterConversation | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isStreaming, setIsStreaming] = useState(false);
const [streamingContent, setStreamingContent] = useState('');
const [error, setError] = useState<string | null>(null);
// Abort controller for cancelling streams
const abortControllerRef = useRef<AbortController | null>(null);
/**
* Load all conversations from the API
*/
const loadConversations = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const response = await fetch('/api/v1/control-center/conversations');
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || `Failed to load conversations: ${response.status}`);
}
const data = await response.json();
setConversations(data.conversations || []);
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to load conversations';
setError(message);
console.error('Error loading conversations:', err);
} finally {
setIsLoading(false);
}
}, []);
/**
* Select and load a conversation by ID
*/
const selectConversation = useCallback(async (id: string) => {
setIsLoading(true);
setError(null);
try {
const response = await fetch(`/api/v1/control-center/history/${id}`);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || `Failed to load conversation: ${response.status}`);
}
const data = await response.json();
setCurrentConversation(data.conversation);
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to load conversation';
setError(message);
console.error('Error loading conversation:', err);
} finally {
setIsLoading(false);
}
}, []);
/**
* Parse SSE data from a chunk
*/
const parseSSEEvents = (chunk: string): StreamEvent[] => {
const events: StreamEvent[] = [];
const lines = chunk.split('\n');
for (const line of lines) {
const trimmedLine = line.trim();
if (trimmedLine.startsWith('data:')) {
const jsonStr = trimmedLine.slice(5).trim();
if (jsonStr && jsonStr !== '[DONE]') {
try {
const parsed = JSON.parse(jsonStr);
events.push(parsed);
} catch (e) {
console.warn('Failed to parse SSE event:', jsonStr);
}
}
}
}
return events;
};
/**
* Send a message to the current conversation with SSE streaming
*/
const sendMessage = useCallback(async (message: string, provider?: string) => {
// Cancel any existing stream
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
setIsLoading(true);
setIsStreaming(true);
setStreamingContent('');
setError(null);
// Create abort controller for this request
abortControllerRef.current = new AbortController();
// Add user message to conversation optimistically
const userMessage: ControlCenterMessage = {
id: `temp-${Date.now()}`,
role: 'user',
content: message,
createdAt: new Date().toISOString(),
};
setCurrentConversation((prev) => {
if (prev) {
return {
...prev,
messages: [...prev.messages, userMessage],
updatedAt: new Date().toISOString(),
};
}
// Create new conversation if none exists
return {
id: '',
title: message.slice(0, 50) + (message.length > 50 ? '...' : ''),
messages: [userMessage],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
});
try {
const response = await fetch('/api/v1/control-center/chat', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'text/event-stream',
},
body: JSON.stringify({
conversationId: currentConversation?.id || undefined,
message,
provider: provider || 'openai',
}),
signal: abortControllerRef.current.signal,
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || `Chat request failed: ${response.status}`);
}
if (!response.body) {
throw new Error('No response body received');
}
// Process the stream
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let accumulatedContent = '';
let conversationId = currentConversation?.id || '';
let currentMessageId = '';
const toolCalls: ToolCall[] = [];
const toolResults: ToolResult[] = [];
setIsLoading(false);
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
// Decode the chunk and add to buffer
buffer += decoder.decode(value, { stream: true });
// Split by double newline to get complete events
const eventChunks = buffer.split('\n\n');
buffer = eventChunks.pop() || ''; // Keep incomplete chunk in buffer
for (const chunk of eventChunks) {
const events = parseSSEEvents(chunk);
for (const event of events) {
switch (event.type) {
case 'message_start':
conversationId = event.conversationId;
currentMessageId = event.messageId;
// Update conversation ID if it was empty
if (!currentConversation?.id) {
setCurrentConversation((prev) => prev ? { ...prev, id: conversationId } : prev);
}
break;
case 'content_delta':
accumulatedContent += event.delta;
setStreamingContent(accumulatedContent);
break;
case 'tool_call_start':
toolCalls.push(event.toolCall);
break;
case 'tool_result':
toolResults.push(event.toolResult);
break;
case 'message_complete':
// Replace streaming content with final message
setCurrentConversation((prev) => {
if (!prev) return prev;
// Update the user message ID if it was temporary
const updatedMessages = prev.messages.map((msg) => {
if (msg.id.startsWith('temp-')) {
return { ...msg, id: `user-${Date.now()}` };
}
return msg;
});
// Add the assistant message
return {
...prev,
id: conversationId || prev.id,
messages: [...updatedMessages, event.message],
updatedAt: new Date().toISOString(),
};
});
setStreamingContent('');
setIsStreaming(false);
break;
case 'error':
throw new Error(event.error);
}
}
}
}
// Handle case where stream ends without message_complete
if (isStreaming && accumulatedContent) {
const assistantMessage: ControlCenterMessage = {
id: currentMessageId || `assistant-${Date.now()}`,
role: 'assistant',
content: accumulatedContent,
toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
toolResults: toolResults.length > 0 ? toolResults : undefined,
createdAt: new Date().toISOString(),
};
setCurrentConversation((prev) => {
if (!prev) return prev;
return {
...prev,
id: conversationId || prev.id,
messages: [...prev.messages, assistantMessage],
updatedAt: new Date().toISOString(),
};
});
}
} catch (err) {
if (err instanceof Error && err.name === 'AbortError') {
// Request was aborted, don't treat as error
return;
}
const message = err instanceof Error ? err.message : 'Failed to send message';
setError(message);
console.error('Error sending message:', err);
// Remove the optimistically added user message on error
setCurrentConversation((prev) => {
if (!prev) return prev;
return {
...prev,
messages: prev.messages.filter((msg) => !msg.id.startsWith('temp-')),
};
});
} finally {
setIsLoading(false);
setIsStreaming(false);
setStreamingContent('');
abortControllerRef.current = null;
}
}, [currentConversation?.id]);
/**
* Start a new conversation
*/
const newConversation = useCallback(() => {
// Cancel any existing stream
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
setCurrentConversation(null);
setStreamingContent('');
setIsStreaming(false);
setError(null);
}, []);
/**
* Clear the current error
*/
const clearError = useCallback(() => {
setError(null);
}, []);
// Context value
const value: ChatContextValue = {
// State
conversations,
currentConversation,
isLoading,
isStreaming,
streamingContent,
error,
// Actions
loadConversations,
selectConversation,
sendMessage,
newConversation,
clearError,
};
return (
<ChatContext.Provider value={value}>
{children}
</ChatContext.Provider>
);
}
// =============================================================================
// Hook
// =============================================================================
/**
* Hook to access the chat context
* Must be used within a ChatProvider
*/
export function useChatContext(): ChatContextValue {
const context = useContext(ChatContext);
if (!context) {
throw new Error('useChatContext must be used within a ChatProvider');
}
return context;
}
export default ChatProvider;