dev1/src/components/RetirementChatBar.js
Josh 5838f782e7
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
removed files from tracking, dependencies, fixed encryption
2025-08-19 12:24:54 +00:00

317 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 users 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>
);
}