- 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>
437 lines
12 KiB
TypeScript
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;
|