317 lines
11 KiB
JavaScript
317 lines
11 KiB
JavaScript
// 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 (
|
||
<aside
|
||
className={cn(
|
||
'flex flex-col shrink-0 w-full md:w-[360px] border-l bg-white',
|
||
className
|
||
)}
|
||
>
|
||
{/* ---------- Header with scenario selector ---------- */}
|
||
<header className="p-3 border-b flex flex-col gap-2 text-sm font-semibold">
|
||
<div className="relative">
|
||
<select
|
||
value={currentScenario?.id || ''}
|
||
onChange={e => {
|
||
const sc = scenarios.find(s => s.id === e.target.value);
|
||
if (sc) {
|
||
setCurrentScenario(sc);
|
||
setChatHistory([]); // reset thread
|
||
}
|
||
}}
|
||
className="pr-6 border rounded px-2 py-[2px] text-sm bg-white w-full"
|
||
>
|
||
<option value="" disabled>-- select scenario --</option>
|
||
{scenarios.map(s => (
|
||
<option key={s.id} value={s.id}>
|
||
{s.scenario_title || s.career_name || 'Untitled'}
|
||
</option>
|
||
))}
|
||
</select>
|
||
<ChevronDown className="pointer-events-none absolute right-1 top-1.5 h-4 w-4 text-gray-500" />
|
||
</div>
|
||
{!currentScenario && (
|
||
<div className="text-red-600 text-xs mt-1">
|
||
Please select a scenario to begin chatting.
|
||
</div>
|
||
)}
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
disabled={!currentScenario}
|
||
onClick={() => setForceCtx(true)}
|
||
title="Force refresh context for next turn"
|
||
>
|
||
🔄 Refresh context
|
||
</Button>
|
||
</header>
|
||
|
||
{/* ---------- Chat log ---------- */}
|
||
<div className="flex-1 overflow-y-auto p-3 space-y-2 text-sm">
|
||
{chatHistory.map((m, i) => (
|
||
<div
|
||
key={i}
|
||
className={cn(
|
||
'max-w-[90%] rounded px-3 py-2 whitespace-pre-wrap',
|
||
m.role === 'user'
|
||
? 'ml-auto bg-blue-600 text-white'
|
||
: 'mr-auto bg-gray-100 text-gray-800'
|
||
)}
|
||
>
|
||
{m.content}
|
||
</div>
|
||
))}
|
||
<div ref={bottomRef} />
|
||
</div>
|
||
|
||
{/* ---------- Composer ---------- */}
|
||
<form
|
||
onSubmit={e => { e.preventDefault(); sendPrompt(); }}
|
||
className="p-3 border-t flex gap-2"
|
||
>
|
||
<textarea
|
||
rows={1}
|
||
value={input}
|
||
onChange={e => setInput(e.target.value)}
|
||
onKeyUp={handleKeyUp}
|
||
disabled={!currentScenario}
|
||
placeholder={currentScenario
|
||
? 'Ask about this scenario…'
|
||
: 'Pick a scenario first'}
|
||
className="flex-1 resize-none border rounded px-2 py-1 text-sm disabled:bg-gray-100"
|
||
/>
|
||
<Button type="submit" disabled={!currentScenario || loading || !input.trim()}>
|
||
{loading ? '…' : 'Send'}
|
||
</Button>
|
||
</form>
|
||
</aside>
|
||
);
|
||
|
||
}
|