From 7bba74affda88f25d9f04daf4d800edf72ab5ead Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 15 Nov 2025 16:01:04 +0000 Subject: [PATCH 1/7] Initial plan From 6772523badb8daa0d28772100081fb43fd482ae1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 15 Nov 2025 16:08:43 +0000 Subject: [PATCH 2/7] Add emotion-based trading psychology coach with xAI Grok API Co-authored-by: Abdulmuiz44 <192426777+Abdulmuiz44@users.noreply.github.com> --- app/api/chat/route.ts | 378 ++++++++++++++++++++++ app/chat/page.tsx | 7 +- lib/emotionClassifier.ts | 296 +++++++++++++++++ lib/grokClient.ts | 144 +++++++++ src/components/ai/EmotionCoachChat.tsx | 421 +++++++++++++++++++++++++ 5 files changed, 1242 insertions(+), 4 deletions(-) create mode 100644 app/api/chat/route.ts create mode 100644 lib/emotionClassifier.ts create mode 100644 lib/grokClient.ts create mode 100644 src/components/ai/EmotionCoachChat.tsx diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts new file mode 100644 index 00000000..2c9dc752 --- /dev/null +++ b/app/api/chat/route.ts @@ -0,0 +1,378 @@ +// app/api/chat/route.ts +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/authOptions'; +import { streamGrokResponse, parseGrokStream } from '@/lib/grokClient'; +import { detectTraderEmotionWithSentiment } from '@/lib/emotionClassifier'; +import { createAdminClient } from '@/utils/supabase/admin'; + +/** + * 5-Step Coaching Framework Template + */ +const COACHING_FRAMEWORK = ` +You are a battle-hardened trading psychology coach. Follow this 5-step framework ALWAYS: + +1. ACKNOWLEDGE: Mirror their emotional state without judgment +2. PATTERN: Identify the behavior pattern (revenge trading, FOMO, fear, etc.) +3. REFRAME: Challenge the distorted thinking with trader-tested reality +4. MICRO-ACTION: Give ONE specific action they can do RIGHT NOW +5. TRIGGER LOCK: Create a mental anchor for next time this emotion arises + +Keep responses SHORT and PUNCHY. No fluff. You're a tough mentor who's been through the trenches. +`; + +/** + * Build dynamic system prompt based on user stats and emotion + */ +function buildSystemPrompt(userStats: any, emotion: any): string { + const { primary, score, tiltLevel } = emotion; + + let emotionContext = ''; + if (tiltLevel >= 1.4) { + emotionContext = ` +⚠️ ALERT: User is showing signs of TILT (${primary} at ${Math.round(score * 100)}% confidence). +Your priority: DE-ESCALATE. Get them to PAUSE. Use the 4-7-8 breathing technique. +`; + } else if (primary !== 'neutral') { + emotionContext = ` +DETECTED EMOTION: ${primary} (${Math.round(score * 100)}% confidence, tilt level: ${tiltLevel.toFixed(1)}) +Address this emotion head-on in your response. +`; + } + + const statsContext = userStats ? ` +TRADER STATS: +- Total Trades: ${userStats.totalTrades || 0} +- Win Rate: ${userStats.winRate || 0}% +- Net P&L: $${userStats.netPnL || 0} +- Avg R:R: ${userStats.avgRR || 0} +- Max Drawdown: $${userStats.maxDrawdown || 0} +- Recent Streak: ${userStats.recentStreak || 'N/A'} +` : ''; + + return `${COACHING_FRAMEWORK} + +${emotionContext} + +${statsContext} + +Remember: You're not a therapist. You're a trader mentor who's seen it all and lost it all before winning. +Be direct, be real, be actionable. Each response should feel like advice from a seasoned pro at the bar after closing bell. +`; +} + +/** + * POST /api/chat + * Emotion-aware trading psychology coach + */ +export async function POST(req: NextRequest) { + try { + // Authenticate user + const session = await getServerSession(authOptions); + const userId = session?.user?.id; + + if (!userId) { + return NextResponse.json( + { error: 'Authentication required' }, + { status: 401 } + ); + } + + // Parse request body + const body = await req.json(); + const { message, history = [] } = body; + + if (!message || typeof message !== 'string') { + return NextResponse.json( + { error: 'Message is required' }, + { status: 400 } + ); + } + + // Detect emotion in user message + const emotion = await detectTraderEmotionWithSentiment(message); + + // Get user stats from Supabase + const supabase = createAdminClient(); + const userStats = await getUserStats(supabase, userId); + + // Load conversation history (last 10 messages from localStorage or DB) + const conversationHistory = Array.isArray(history) ? history.slice(-10) : []; + + // Build system prompt with emotion context + const systemPrompt = buildSystemPrompt(userStats, emotion); + + // Prepare messages for Grok + const messages = [ + ...conversationHistory.map((msg: any) => ({ + role: msg.role, + content: msg.content, + })), + { + role: 'user' as const, + content: message, + }, + ]; + + // Check for rate limiting + const rateLimitKey = `chat_rate_${userId}`; + const isRateLimited = await checkRateLimit(supabase, rateLimitKey); + + if (isRateLimited) { + return new Response( + createSSEMessage({ + type: 'queued', + message: 'Coach is thinking… (high demand)', + }), + { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + }, + } + ); + } + + // Stream response from Grok + const grokStream = await streamGrokResponse(messages, systemPrompt); + + // Transform to SSE format + const encoder = new TextEncoder(); + const transformedStream = new ReadableStream({ + async start(controller) { + try { + // Send emotion data first + controller.enqueue( + encoder.encode( + createSSEMessage({ + type: 'emotion', + data: emotion, + }) + ) + ); + + // Stream Grok response + let fullResponse = ''; + for await (const delta of parseGrokStream(grokStream)) { + fullResponse += delta; + controller.enqueue( + encoder.encode( + createSSEMessage({ + type: 'delta', + content: delta, + }) + ) + ); + } + + // Send completion event + controller.enqueue( + encoder.encode( + createSSEMessage({ + type: 'done', + content: fullResponse, + }) + ) + ); + + // Save conversation turn to database + await saveConversationTurn(supabase, userId, { + userMessage: message, + assistantMessage: fullResponse, + emotion, + }); + + controller.close(); + } catch (error) { + console.error('Stream error:', error); + controller.enqueue( + encoder.encode( + createSSEMessage({ + type: 'error', + error: error instanceof Error ? error.message : 'Stream error', + }) + ) + ); + controller.close(); + } + }, + }); + + return new Response(transformedStream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + }, + }); + } catch (error) { + console.error('Chat API error:', error); + + // Handle rate limiting + if (error instanceof Error && error.message.includes('rate limit')) { + return new Response( + createSSEMessage({ + type: 'queued', + message: 'Coach is thinking… (high demand). Retrying in 3s...', + }), + { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + }, + } + ); + } + + return NextResponse.json( + { + error: error instanceof Error ? error.message : 'Internal server error', + }, + { status: 500 } + ); + } +} + +/** + * Helper: Create SSE message + */ +function createSSEMessage(data: any): string { + return `data: ${JSON.stringify(data)}\n\n`; +} + +/** + * Helper: Get user trading stats + */ +async function getUserStats(supabase: any, userId: string) { + try { + const { data: trades } = await supabase + .from('trades') + .select('*') + .eq('user_id', userId) + .order('created_at', { ascending: false }) + .limit(100); + + if (!trades || trades.length === 0) { + return null; + } + + const totalTrades = trades.length; + const wins = trades.filter((t: any) => t.outcome === 'win').length; + const winRate = (wins / totalTrades) * 100; + const netPnL = trades.reduce((sum: number, t: any) => sum + (t.pnl || 0), 0); + + // Calculate recent streak + let recentStreak = 0; + const lastOutcome = trades[0]?.outcome; + for (const trade of trades) { + if (trade.outcome === lastOutcome) { + recentStreak++; + } else { + break; + } + } + + return { + totalTrades, + winRate: Math.round(winRate), + netPnL: Math.round(netPnL * 100) / 100, + avgRR: 0, // Could calculate if risk/reward data available + maxDrawdown: 0, // Would need calculation + recentStreak: `${recentStreak} ${lastOutcome}s`, + }; + } catch (error) { + console.error('Failed to get user stats:', error); + return null; + } +} + +/** + * Helper: Check rate limit + */ +async function checkRateLimit(supabase: any, key: string): Promise { + // Simple rate limiting: 20 requests per minute + const LIMIT = 20; + const WINDOW = 60 * 1000; // 1 minute + + try { + const now = Date.now(); + const { data } = await supabase + .from('rate_limits') + .select('*') + .eq('key', key) + .single(); + + if (!data) { + // First request + await supabase.from('rate_limits').insert({ + key, + count: 1, + window_start: new Date(now).toISOString(), + }); + return false; + } + + const windowStart = new Date(data.window_start).getTime(); + const isInWindow = now - windowStart < WINDOW; + + if (isInWindow && data.count >= LIMIT) { + return true; // Rate limited + } + + if (isInWindow) { + // Increment count + await supabase + .from('rate_limits') + .update({ count: data.count + 1 }) + .eq('key', key); + } else { + // Reset window + await supabase + .from('rate_limits') + .update({ + count: 1, + window_start: new Date(now).toISOString(), + }) + .eq('key', key); + } + + return false; + } catch (error) { + console.error('Rate limit check failed:', error); + return false; // Fail open + } +} + +/** + * Helper: Save conversation turn with emotion tags + */ +async function saveConversationTurn( + supabase: any, + userId: string, + data: { + userMessage: string; + assistantMessage: string; + emotion: any; + } +) { + try { + const conversationId = `conv_${Date.now()}_${userId}`; + + // Save to chat_history table (or use existing conversations table) + await supabase.from('chat_history').insert({ + id: conversationId, + user_id: userId, + user_message: data.userMessage, + assistant_message: data.assistantMessage, + emotion_primary: data.emotion.primary, + emotion_score: data.emotion.score, + tilt_level: data.emotion.tiltLevel, + emotion_triggers: data.emotion.triggers, + created_at: new Date().toISOString(), + }); + } catch (error) { + console.error('Failed to save conversation:', error); + // Don't throw - saving is optional + } +} diff --git a/app/chat/page.tsx b/app/chat/page.tsx index 3d183513..3489242f 100644 --- a/app/chat/page.tsx +++ b/app/chat/page.tsx @@ -1,14 +1,13 @@ import React from "react"; import dynamic from "next/dynamic"; -const TradiaAIChat = dynamic(() => import("@/components/ai/TradiaAIChat"), { +// Use the new emotion-based coach for /chat +const EmotionCoachChat = dynamic(() => import("@/components/ai/EmotionCoachChat"), { ssr: false, }); export default function ChatPage() { return ( -
- -
+ ); } diff --git a/lib/emotionClassifier.ts b/lib/emotionClassifier.ts new file mode 100644 index 00000000..c3a57762 --- /dev/null +++ b/lib/emotionClassifier.ts @@ -0,0 +1,296 @@ +// lib/emotionClassifier.ts +/** + * Trader emotion detection and classification + * Combines regex pattern matching with HuggingFace sentiment analysis + */ + +export interface EmotionResult { + primary: string; + score: number; + triggers: string[]; + secondary?: string; + tiltLevel: number; // 0-2: 0=calm, 1=elevated, 2=tilt +} + +// Emotion patterns with scoring weights +const EMOTION_PATTERNS = { + revenge: { + patterns: [ + /make\s+it\s+back/i, + /get\s+even/i, + /revenge\s+trad/i, + /chase\s+losses?/i, + /double\s+down/i, + /recover\s+.*\s+loss/i, + ], + weight: 1.5, + tiltContribution: 0.8, + }, + fear: { + patterns: [ + /scared/i, + /terrified/i, + /stop\s+hunt/i, + /fear/i, + /panic/i, + /freaking\s+out/i, + /anxious/i, + /worried/i, + ], + weight: 1.3, + tiltContribution: 0.6, + }, + fomo: { + patterns: [ + /miss(?:ing|ed)\s+out/i, + /fomo/i, + /now\s+or\s+never/i, + /can'?t\s+miss/i, + /everyone\s+else\s+is/i, + /too\s+late/i, + ], + weight: 1.4, + tiltContribution: 0.7, + }, + doubt: { + patterns: [ + /what\s+if/i, + /should\s+i\s+have/i, + /fake\s?out/i, + /wrong\s+again/i, + /second\s+guess/i, + /unsure/i, + /confused/i, + ], + weight: 1.2, + tiltContribution: 0.5, + }, + anger: { + patterns: [ + /furious/i, + /pissed/i, + /angry/i, + /hate\s+this/i, + /rigged/i, + /bullshit/i, + /scam/i, + /frustrat/i, + ], + weight: 1.6, + tiltContribution: 0.9, + }, + regret: { + patterns: [ + /should'?ve/i, + /if\s+only/i, + /regret/i, + /mistake/i, + /why\s+did\s+i/i, + /stupid\s+move/i, + ], + weight: 1.3, + tiltContribution: 0.6, + }, + euphoria: { + patterns: [ + /easy\s+money/i, + /can'?t\s+lose/i, + /on\s+fire/i, + /crushing\s+it/i, + /invincible/i, + ], + weight: 1.4, + tiltContribution: 0.4, + }, + calm: { + patterns: [ + /patient/i, + /following\s+plan/i, + /disciplined/i, + /waiting\s+for/i, + /stick\s+to/i, + ], + weight: 1.0, + tiltContribution: -0.5, // reduces tilt + }, +}; + +/** + * Detect trader emotions from text input + * Returns primary emotion, confidence score, and identified triggers + */ +export function detectTraderEmotion(text: string): EmotionResult { + const lowerText = text.toLowerCase(); + const emotionScores: Record = {}; + + // Check all emotion patterns + for (const [emotion, config] of Object.entries(EMOTION_PATTERNS)) { + let matchCount = 0; + const matches: string[] = []; + + for (const pattern of config.patterns) { + const match = lowerText.match(pattern); + if (match) { + matchCount++; + matches.push(match[0]); + } + } + + if (matchCount > 0) { + emotionScores[emotion] = { + score: matchCount * config.weight, + matches, + }; + } + } + + // Calculate tilt level + let tiltLevel = 0; + for (const [emotion, data] of Object.entries(emotionScores)) { + const config = EMOTION_PATTERNS[emotion as keyof typeof EMOTION_PATTERNS]; + tiltLevel += (data.score / config.weight) * config.tiltContribution; + } + + // Normalize tilt to 0-2 scale + tiltLevel = Math.max(0, Math.min(2, tiltLevel)); + + // Determine primary and secondary emotions + const sortedEmotions = Object.entries(emotionScores).sort( + ([, a], [, b]) => b.score - a.score + ); + + if (sortedEmotions.length === 0) { + // No strong emotion detected + return { + primary: 'neutral', + score: 0, + triggers: [], + tiltLevel: 0, + }; + } + + const [primaryEmotion, primaryData] = sortedEmotions[0]; + const secondary = sortedEmotions.length > 1 ? sortedEmotions[1][0] : undefined; + + // Normalize score to 0-1 range + const maxScore = 10; // Reasonable max for multiple strong matches + const normalizedScore = Math.min(primaryData.score / maxScore, 1); + + return { + primary: primaryEmotion, + score: normalizedScore, + triggers: primaryData.matches, + secondary, + tiltLevel, + }; +} + +/** + * Enhance emotion detection with HuggingFace sentiment analysis + * Falls back to regex-only if HF API unavailable + */ +export async function detectTraderEmotionWithSentiment( + text: string +): Promise { + // Get regex-based emotion detection first + const regexResult = detectTraderEmotion(text); + + // Try to enhance with HuggingFace if available + try { + const hfToken = process.env.HUGGINGFACE_API_KEY; + + if (!hfToken) { + // Return regex result if no HF token + return regexResult; + } + + const response = await fetch( + 'https://api-inference.huggingface.co/models/distilbert-base-uncased-finetuned-sst-2-english', + { + method: 'POST', + headers: { + Authorization: `Bearer ${hfToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ inputs: text }), + } + ); + + if (!response.ok) { + // Fall back to regex result on API error + return regexResult; + } + + const hfResult = await response.json(); + + // HF returns array like [{label: 'POSITIVE', score: 0.99}] + if (Array.isArray(hfResult) && hfResult[0]?.label) { + const sentiment = hfResult[0].label.toLowerCase(); + const sentimentScore = hfResult[0].score; + + // Adjust tilt based on sentiment + if (sentiment === 'negative' && sentimentScore > 0.8) { + regexResult.tiltLevel = Math.min(2, regexResult.tiltLevel + 0.3); + } else if (sentiment === 'positive' && sentimentScore > 0.8) { + regexResult.tiltLevel = Math.max(0, regexResult.tiltLevel - 0.2); + } + + // If no regex emotion but strong sentiment, use sentiment + if (regexResult.primary === 'neutral' && sentimentScore > 0.7) { + regexResult.primary = sentiment === 'negative' ? 'fear' : 'calm'; + regexResult.score = sentimentScore; + } + } + + return regexResult; + } catch (error) { + console.warn('HuggingFace sentiment analysis failed, using regex only:', error); + return regexResult; + } +} + +/** + * Get emotion color for UI visualization + */ +export function getEmotionColor(emotion: string, tiltLevel: number): string { + // High tilt overrides emotion color + if (tiltLevel >= 1.5) return 'bg-red-500'; + if (tiltLevel >= 1.0) return 'bg-orange-500'; + + const colorMap: Record = { + revenge: 'bg-red-500', + anger: 'bg-red-600', + fear: 'bg-yellow-500', + fomo: 'bg-orange-500', + doubt: 'bg-blue-400', + regret: 'bg-purple-500', + euphoria: 'bg-pink-500', + calm: 'bg-green-500', + neutral: 'bg-gray-400', + }; + + return colorMap[emotion] || 'bg-gray-400'; +} + +/** + * Get coaching message for detected emotion + */ +export function getEmotionCoachingHint(emotion: string, tiltLevel: number): string { + if (tiltLevel >= 1.5) { + return '🚨 HIGH TILT DETECTED - Consider taking a break'; + } + + const hints: Record = { + revenge: '⚠️ Revenge trading detected - Review your risk rules', + anger: '😀 High emotion - Step away and breathe', + fear: '😰 Fear response - Trust your process', + fomo: 'πŸƒ FOMO detected - Stick to your plan', + doubt: 'πŸ€” Second-guessing - Review your analysis', + regret: '😞 Regret pattern - Learn and move forward', + euphoria: 'πŸŽ‰ Overconfidence alert - Stay grounded', + calm: 'βœ… Good emotional state - Keep it up', + neutral: 'πŸ“Š Analyzing...', + }; + + return hints[emotion] || 'πŸ“Š Monitoring your state...'; +} diff --git a/lib/grokClient.ts b/lib/grokClient.ts new file mode 100644 index 00000000..8fcb2f48 --- /dev/null +++ b/lib/grokClient.ts @@ -0,0 +1,144 @@ +// lib/grokClient.ts +/** + * xAI Grok API client for streaming chat completions + * Uses only xAI Grok - no OpenAI + */ + +interface Message { + role: 'system' | 'user' | 'assistant'; + content: string; +} + +interface GrokStreamOptions { + messages: Message[]; + systemPrompt?: string; + model?: string; + temperature?: number; + max_tokens?: number; +} + +/** + * Stream response from xAI Grok API + * Returns a ReadableStream for SSE consumption + */ +export async function streamGrokResponse( + messages: Message[], + systemPrompt: string, + options: Partial = {} +): Promise> { + const apiKey = process.env.XAI_API_KEY; + + if (!apiKey) { + throw new Error('XAI_API_KEY not configured'); + } + + const model = options.model || 'grok-beta'; + const temperature = options.temperature ?? 0.7; + const max_tokens = options.max_tokens ?? 1024; + + // Build messages array with system prompt + const fullMessages: Message[] = [ + { role: 'system', content: systemPrompt }, + ...messages + ]; + + try { + const response = await fetch('https://api.x.ai/v1/chat/completions', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + model, + messages: fullMessages, + stream: true, + temperature, + max_tokens, + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Grok API error (${response.status}): ${errorText}`); + } + + if (!response.body) { + throw new Error('No response body from Grok API'); + } + + // Return the raw stream - caller will handle SSE parsing + return response.body; + } catch (error) { + console.error('Grok API request failed:', error); + throw error; + } +} + +/** + * Parse SSE stream from Grok API + * Transforms raw response into text deltas + */ +export async function* parseGrokStream(stream: ReadableStream): AsyncGenerator { + const reader = stream.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + try { + while (true) { + const { done, value } = await reader.read(); + + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + + for (const line of lines) { + const trimmed = line.trim(); + + // Skip empty lines and comments + if (!trimmed || trimmed.startsWith(':')) continue; + + // Handle [DONE] marker + if (trimmed === 'data: [DONE]') continue; + + // Parse SSE data + if (trimmed.startsWith('data: ')) { + const jsonStr = trimmed.slice(6); + + try { + const parsed = JSON.parse(jsonStr); + const delta = parsed.choices?.[0]?.delta?.content; + + if (delta) { + yield delta; + } + } catch (e) { + // Skip malformed JSON + console.warn('Failed to parse SSE line:', trimmed); + } + } + } + } + + // Process remaining buffer + if (buffer.trim()) { + const trimmed = buffer.trim(); + if (trimmed.startsWith('data: ') && !trimmed.endsWith('[DONE]')) { + const jsonStr = trimmed.slice(6); + try { + const parsed = JSON.parse(jsonStr); + const delta = parsed.choices?.[0]?.delta?.content; + if (delta) { + yield delta; + } + } catch (e) { + // Ignore + } + } + } + } finally { + reader.releaseLock(); + } +} diff --git a/src/components/ai/EmotionCoachChat.tsx b/src/components/ai/EmotionCoachChat.tsx new file mode 100644 index 00000000..8c67b54f --- /dev/null +++ b/src/components/ai/EmotionCoachChat.tsx @@ -0,0 +1,421 @@ +// src/components/ai/EmotionCoachChat.tsx +"use client"; + +import React, { useState, useEffect, useRef } from 'react'; +import { getEmotionColor, getEmotionCoachingHint } from '@/lib/emotionClassifier'; + +interface Message { + id: string; + role: 'user' | 'assistant'; + content: string; + timestamp: Date; +} + +interface EmotionData { + primary: string; + score: number; + triggers: string[]; + tiltLevel: number; + secondary?: string; +} + +export default function EmotionCoachChat() { + const [messages, setMessages] = useState([]); + const [input, setInput] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [emotion, setEmotion] = useState(null); + const [showTiltAlert, setShowTiltAlert] = useState(false); + const [streakCount, setStreakCount] = useState(0); + const [lowTiltStreak, setLowTiltStreak] = useState(0); + const messagesEndRef = useRef(null); + const abortControllerRef = useRef(null); + + // Auto-scroll to bottom + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [messages]); + + // Check for tilt alert + useEffect(() => { + if (emotion && emotion.tiltLevel >= 1.4) { + setShowTiltAlert(true); + setLowTiltStreak(0); + } else if (emotion && emotion.tiltLevel < 0.8) { + setLowTiltStreak((prev) => prev + 1); + } + }, [emotion]); + + // Load history from localStorage + useEffect(() => { + const saved = localStorage.getItem('emotionCoachHistory'); + if (saved) { + try { + const parsed = JSON.parse(saved); + setMessages(parsed.map((m: any) => ({ + ...m, + timestamp: new Date(m.timestamp), + }))); + } catch (e) { + console.error('Failed to load history:', e); + } + } + }, []); + + // Save history to localStorage + useEffect(() => { + if (messages.length > 0) { + localStorage.setItem('emotionCoachHistory', JSON.stringify(messages)); + } + }, [messages]); + + const sendMessage = async () => { + if (!input.trim() || isLoading) return; + + const userMessage: Message = { + id: `msg_${Date.now()}`, + role: 'user', + content: input, + timestamp: new Date(), + }; + + setMessages((prev) => [...prev, userMessage]); + setInput(''); + setIsLoading(true); + + // Prepare assistant message placeholder + const assistantId = `msg_${Date.now() + 1}`; + const assistantMessage: Message = { + id: assistantId, + role: 'assistant', + content: '', + timestamp: new Date(), + }; + + setMessages((prev) => [...prev, assistantMessage]); + + try { + const controller = new AbortController(); + abortControllerRef.current = controller; + + const response = await fetch('/api/chat', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + message: input, + history: messages.map((m) => ({ + role: m.role, + content: m.content, + })), + }), + signal: controller.signal, + }); + + if (!response.ok) { + throw new Error(`API error: ${response.status}`); + } + + const reader = response.body?.getReader(); + const decoder = new TextDecoder(); + + if (!reader) { + throw new Error('No response stream'); + } + + let buffer = ''; + let accumulatedContent = ''; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + + for (const line of lines) { + if (line.startsWith('data: ')) { + const jsonStr = line.slice(6); + try { + const data = JSON.parse(jsonStr); + + if (data.type === 'emotion') { + setEmotion(data.data); + } else if (data.type === 'delta') { + accumulatedContent += data.content; + setMessages((prev) => + prev.map((m) => + m.id === assistantId + ? { ...m, content: accumulatedContent } + : m + ) + ); + } else if (data.type === 'done') { + accumulatedContent = data.content; + setMessages((prev) => + prev.map((m) => + m.id === assistantId + ? { ...m, content: accumulatedContent } + : m + ) + ); + } else if (data.type === 'queued') { + setMessages((prev) => + prev.map((m) => + m.id === assistantId + ? { ...m, content: data.message } + : m + ) + ); + } else if (data.type === 'error') { + throw new Error(data.error); + } + } catch (e) { + console.error('Failed to parse SSE:', e); + } + } + } + } + } catch (error) { + console.error('Send message error:', error); + setMessages((prev) => + prev.map((m) => + m.id === assistantId + ? { + ...m, + content: + 'Sorry, I encountered an error. Please try again.', + } + : m + ) + ); + } finally { + setIsLoading(false); + abortControllerRef.current = null; + } + }; + + const handleKeyPress = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + sendMessage(); + } + }; + + const clearHistory = () => { + if (confirm('Clear all chat history?')) { + setMessages([]); + localStorage.removeItem('emotionCoachHistory'); + setEmotion(null); + setStreakCount(0); + setLowTiltStreak(0); + } + }; + + const emotionColor = emotion + ? getEmotionColor(emotion.primary, emotion.tiltLevel) + : 'bg-gray-400'; + + const emotionHint = emotion + ? getEmotionCoachingHint(emotion.primary, emotion.tiltLevel) + : ''; + + return ( +
+ {/* Header with Emotion Pulse */} +
+
+
+
+

+ Trading Psychology Coach +

+

+ Powered by xAI Grok β€’ Battle-tested mentor +

+
+
+ {/* Streak Counter */} + {lowTiltStreak >= 3 && ( +
+
+ πŸ”₯ {lowTiltStreak} Calm Messages +
+
Keep it up!
+
+ )} + +
+
+
+ {/* Emotion Pulse Bar */} +
+ {/* Emotion Hint */} + {emotionHint && ( +
+ {emotionHint} +
+ )} +
+ + {/* Messages */} +
+ {messages.length === 0 && ( +
+
🧠
+

+ Your Trading Psychology Coach +

+

+ I'm here to help you navigate trading emotions and build + discipline. +

+
+
+
+ 🎯 Pattern Recognition +
+
+ Identify revenge trading, FOMO, fear +
+
+
+
+ πŸ’ͺ Real Actions +
+
+ Immediate steps, not theory +
+
+
+
+ πŸ”₯ Tilt Detection +
+
+ Catch emotions before they hurt +
+
+
+
+ πŸŽ–οΈ Battle-Tested +
+
+ Advice from the trenches +
+
+
+
+ )} + + {messages.map((message) => ( +
+
+
{message.content}
+
+ {message.timestamp.toLocaleTimeString()} +
+
+
+ ))} + + {isLoading && messages[messages.length - 1]?.content === '' && ( +
+
+
+
●
+
●
+
●
+
+
+
+ )} + +
+
+ + {/* Input */} +
+
+