// src/components/RetirementChatBar.js import React, { useEffect, useRef, useState } from 'react'; import { cn } from '../utils/cn.js'; import { Button } from './ui/button.js'; import authFetch from '../utils/authFetch.js'; import { ChevronDown } from 'lucide-react'; /* ────────────────────────────────────────────────────────────── 1. Static “OPS cheat-sheet” card - sent exactly once, identified by the sentinel below - keep the FULL text here (truncated for brevity) ────────────────────────────────────────────────────────────*/ const OPS_SENTINEL = 'APTIVA OPS CHEAT-SHEET'; // do NOT change const STATIC_SYSTEM_CARD = ` ${OPS_SENTINEL} ──────────────────────────────────────────────────────── 🛠 Allowed OPS JSON ──────────────────────────────────────────────────────── • CREATE / UPDATE / DELETE milestones, tasks, impacts • Always wrap ops payload in a \`\`\`ops ... \`\`\` fence • One short confirmation line → then the fence → nothing after (…include your complete text here…) `.trim(); /* Other global helpers */ const CTX_SENTINEL = '[DETAILED USER PROFILE]'; const MAX_TURNS = 6; /* ------------------------------------------------------------------ helpers */ function buildStatusSituationCard(userProfile = {}, scenarioRow = {}) { const careerName = scenarioRow.career_name || 'your chosen career'; const status = scenarioRow.status || 'planned'; const situation = userProfile.career_situation || 'exploring'; return `[STATUS] Currently **${status}**, you are ${situation} ${careerName}.`; } function buildMilestoneGridCard(grid = []) { if (!grid.length) return '[CURRENT MILESTONES]\n- none -'; const rows = grid.map(r => `${r.id}|${r.title.trim()}|${r.date}`).join('\n'); return `[CURRENT MILESTONES] (id | title | date) ${rows}`; } /* Build the outbound message array each turn */ function buildMessages({ chatHistory = [], userProfile = {}, scenarioRow = {}, milestoneGrid = [], largeSummaryCard = '', forceContext = false }) { const safeHistory = chatHistory.map(m => m.content != null ? m : { ...m, content: m.text ?? '' } ); /* Send static card once per convo */ const needOpsCard = !safeHistory.some( m => m.role === 'system' && m.content?.includes(OPS_SENTINEL) ); const staticCards = needOpsCard ? [{ role: 'system', content: STATIC_SYSTEM_CARD }] : []; /* Send big context when missing or forced */ const needCtxCard = forceContext || !chatHistory.some( m => m.role === 'system' && m.content?.startsWith(CTX_SENTINEL) ); const contextCards = needCtxCard && largeSummaryCard ? [{ role: 'system', content: `${CTX_SENTINEL}\n${largeSummaryCard}` }] : []; /* Small per-turn helpers */ const smallCards = [ { role: 'system', content: buildStatusSituationCard(userProfile, scenarioRow) }, { role: 'system', content: buildMilestoneGridCard(milestoneGrid) } ]; const recent = safeHistory.slice(-MAX_TURNS); return [...staticCards, ...contextCards, ...smallCards, ...recent] .map(m => ({ ...m, content: m.content ?? '' })); } /* ------------------------------------------------------------------ component */ export default function RetirementChatBar({ scenario = null, // full scenario row userProfile = {}, // caller supplies or {} financialProfile = {}, collegeProfile = {}, milestoneGrid = [], onScenarioPatch, className = '' }) { const [chatHistory, setChatHistory] = useState([]); const [input, setInput] = useState(''); const [loading, setLoading] = useState(false); const [forceCtx, setForceCtx] = useState(false); const [scenarios, setScenarios] = useState([]); const [currentScenario, setCurrentScenario] = useState(scenario); const [threadId, setThreadId] = useState(null); const bottomRef = useRef(null); /* wipe chat on scenario change */ useEffect(() => setChatHistory([]), [currentScenario?.id]); useEffect(() => { (async () => { if (!currentScenario?.id) return; const r = await authFetch('/api/premium/retire/chat/threads'); const { threads = [] } = await r.json(); let id = threads.find(Boolean)?.id; if (!id) { const r2 = await authFetch('/api/premium/retire/chat/threads', { method:'POST', headers:{ 'Content-Type':'application/json' }, body: JSON.stringify({ title: `Retirement • ${scenarioLabel}` }) }); ({ id } = await r2.json()); } setThreadId(id); const r3 = await authFetch(`/api/premium/retire/chat/threads/${id}`); const { messages: msgs = [] } = await r3.json(); setChatHistory(msgs); })(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [currentScenario?.id]); /* fetch the user’s scenarios once */ useEffect(() => { (async () => { try { const res = await authFetch('/api/premium/career-profile/all'); const json = await res.json(); setScenarios(json.careerProfiles || []); } catch (e) { console.error('Scenario load failed', e); } })(); }, []); /* autoscroll */ useEffect(() => bottomRef.current?.scrollIntoView({ behavior: 'smooth' }), [chatHistory]); useEffect(() => { if (!currentScenario && scenarios.length > 0) { setCurrentScenario(scenarios[0]); // or prompt user to choose } }, [scenarios]); /* ------------------------------------------------------------------ */ /* SEND PROMPT — guarded diff + safe input-handling */ /* ------------------------------------------------------------------ */ async function sendPrompt() { const prompt = input.trim(); if (!prompt || !currentScenario?.id) return; /* ① optimistic UI – show the user bubble immediately */ const userMsg = { role: 'user', content: prompt }; setChatHistory(h => [...h, userMsg]); setInput(''); setLoading(true); try { /* ② rebuild outbound history (adds system helper cards) */ const messagesToSend = buildMessages({ chatHistory : [...chatHistory, userMsg], userProfile, scenarioRow : currentScenario, milestoneGrid, largeSummaryCard: window.CACHED_SUMMARY || '', forceContext : forceCtx }); /* ③ POST to the retirement endpoint */ const res = await authFetch(`/api/premium/retire/chat/threads/${threadId}/messages`, { method:'POST', headers:{ 'Content-Type':'application/json' }, body: JSON.stringify({ content: prompt, context: { scenario_id: currentScenario.id } // minimal — your backend uses it }) }); const data = await res.json(); const assistantReply = data.reply || '(no response)'; setChatHistory(h => [...h, { role: 'assistant', content: assistantReply }]); /* ④ if we got a real patch, build + forward the diff array */ if (data.scenarioPatch && onScenarioPatch) { onScenarioPatch(currentScenario.id, data.scenarioPatch); // ✅ id + patch } } catch (err) { console.error(err); setChatHistory(h => [ ...h, { role: 'assistant', content: '⚠️ Server error.' } ]); } finally { setLoading(false); setForceCtx(false); } } /* enter-to-send */ function handleKeyUp(e) { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendPrompt(); } } /* ----------------------- render ----------------------- */ const scenarioLabel = currentScenario ? currentScenario.scenario_title || currentScenario.career_name || 'Untitled Scenario' : 'Pick a scenario'; return (