New components for AI ops/REtirement planning
This commit is contained in:
parent
fe2ec2d3c1
commit
8c78540e6c
@ -28,7 +28,7 @@ import FinancialProfileForm from './components/FinancialProfileForm.js';
|
||||
import CareerRoadmap from './components/CareerRoadmap.js';
|
||||
import Paywall from './components/Paywall.js';
|
||||
import OnboardingContainer from './components/PremiumOnboarding/OnboardingContainer.js';
|
||||
import MultiScenarioView from './components/MultiScenarioView.js';
|
||||
import RetirementPlanner from './components/RetirementPlanner.js';
|
||||
import ResumeRewrite from './components/ResumeRewrite.js';
|
||||
|
||||
function App() {
|
||||
@ -53,7 +53,7 @@ function App() {
|
||||
'/career-roadmap',
|
||||
'/paywall',
|
||||
'/financial-profile',
|
||||
'/multi-scenario',
|
||||
'/retirement-planner',
|
||||
'/premium-onboarding',
|
||||
'/enhancing',
|
||||
'/retirement',
|
||||
@ -446,10 +446,10 @@ function App() {
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/multi-scenario"
|
||||
path="/retirement-planner"
|
||||
element={
|
||||
<PremiumRoute user={user}>
|
||||
<MultiScenarioView />
|
||||
<RetirementPlanner />
|
||||
</PremiumRoute>
|
||||
}
|
||||
/>
|
||||
|
@ -28,6 +28,7 @@ import { simulateFinancialProjection } from '../utils/FinancialProjectionService
|
||||
import parseFloatOrZero from '../utils/ParseFloatorZero.js';
|
||||
import { getFullStateName } from '../utils/stateUtils.js';
|
||||
import CareerCoach from "./CareerCoach.js";
|
||||
|
||||
import { Button } from './ui/button.js';
|
||||
import { Pencil } from 'lucide-react';
|
||||
import ScenarioEditModal from './ScenarioEditModal.js';
|
||||
|
244
src/components/RetirementChatBar.js
Normal file
244
src/components/RetirementChatBar.js
Normal file
@ -0,0 +1,244 @@
|
||||
// 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';
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────
|
||||
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 bottomRef = useRef(null);
|
||||
|
||||
/* wipe chat on scenario change */
|
||||
useEffect(() => setChatHistory([]), [scenario?.id]);
|
||||
|
||||
/* autoscroll */
|
||||
useEffect(() => bottomRef.current?.scrollIntoView({ behavior: 'smooth' }),
|
||||
[chatHistory]);
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* SEND PROMPT — guarded diff + safe input-handling */
|
||||
/* ------------------------------------------------------------------ */
|
||||
async function sendPrompt() {
|
||||
const prompt = input.trim();
|
||||
if (!prompt || !scenario?.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 : scenario,
|
||||
milestoneGrid,
|
||||
largeSummaryCard: window.CACHED_SUMMARY || '',
|
||||
forceContext : forceCtx
|
||||
});
|
||||
|
||||
/* ③ POST to the retirement endpoint */
|
||||
const res = await authFetch('/api/premium/retirement/aichat', {
|
||||
method : 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body : JSON.stringify({
|
||||
prompt,
|
||||
scenario_id : scenario.id, // ← keep it minimal
|
||||
chatHistory : messagesToSend // ← backend needs this to find userMsg
|
||||
})
|
||||
});
|
||||
|
||||
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(scenario.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 = scenario
|
||||
? (scenario.scenario_title || scenario.career_name || 'Untitled Scenario')
|
||||
: 'Select a scenario';
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
'flex flex-col shrink-0 w-full md:w-[360px] border-l bg-white',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<header className="p-3 border-b flex items-center gap-2 text-sm font-semibold">
|
||||
{scenarioLabel}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={!scenario}
|
||||
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={!scenario}
|
||||
placeholder={scenario
|
||||
? 'Ask about this scenario…'
|
||||
: 'Click a scenario card first'}
|
||||
className="flex-1 resize-none border rounded px-2 py-1 text-sm disabled:bg-gray-100"
|
||||
/>
|
||||
<Button type="submit" disabled={!scenario || loading || !input.trim()}>
|
||||
{loading ? '…' : 'Send'}
|
||||
</Button>
|
||||
</form>
|
||||
</aside>
|
||||
);
|
||||
}
|
@ -16,7 +16,7 @@ function RetirementLanding() {
|
||||
</p>
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<Button onClick={() => navigate('/financial-profile')}>Update Financial Profile</Button>
|
||||
<Button onClick={() => navigate('/multi-scenario')}>Set Retirement Milestones and get AI help with planning</Button>
|
||||
<Button onClick={() => navigate('/retirement-planner')}>Set Retirement Milestones and get AI help with planning</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,14 +1,29 @@
|
||||
// src/components/MultiScenarioView.js
|
||||
// src/components/RetirementPlanner.js
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import authFetch from '../utils/authFetch.js';
|
||||
import ScenarioContainer from './ScenarioContainer.js';
|
||||
import { Button } from './ui/button.js';
|
||||
import RetirementChatBar from './RetirementChatBar.js';
|
||||
import ScenarioDiffDrawer from './ScenarioDiffDrawer.js';
|
||||
|
||||
export default function MultiScenarioView() {
|
||||
export default function RetirementPlanner() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [financialProfile, setFinancialProfile] = useState(null);
|
||||
const [scenarios, setScenarios] = useState([]);
|
||||
const [diff , setDiff ] = useState(null);
|
||||
const [chatId, setChatId] = useState(null);
|
||||
const [selectedScenario, setSelectedScenario] = useState(null);
|
||||
|
||||
|
||||
const applyPatch = (id, patch) => {
|
||||
setScenarios(prev => {
|
||||
const base = prev.find(s => s.id === id);
|
||||
const next = prev.map(s => (s.id === id ? { ...s, ...patch } : s));
|
||||
setDiff({ base, patch });
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadScenariosAndFinancial();
|
||||
@ -29,7 +44,7 @@ export default function MultiScenarioView() {
|
||||
setFinancialProfile(finData);
|
||||
setScenarios(scenData.careerProfiles || []);
|
||||
} catch (err) {
|
||||
console.error('MultiScenarioView =>', err);
|
||||
console.error('RetirementPlanner =>', err);
|
||||
setError(err.message || 'Failed to load');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@ -290,18 +305,21 @@ export default function MultiScenarioView() {
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <p>Loading scenarios...</p>;
|
||||
if (error) return <p style={{ color: 'red' }}>{error}</p>;
|
||||
|
||||
const visible = scenarios.slice(0, 2);
|
||||
|
||||
if (loading) return <p>Loading scenarios…</p>;
|
||||
if (error) return <p className="text-red-600">{error}</p>;
|
||||
|
||||
|
||||
return (
|
||||
<div style={{ margin: '1rem' }}>
|
||||
<Button onClick={handleAddScenario} style={{ marginBottom: '1rem' }}>
|
||||
<div className="flex flex-col md:flex-row">
|
||||
{/* main column */}
|
||||
<div className="flex-1 p-4">
|
||||
<Button onClick={handleAddScenario} className="mb-4">
|
||||
+ Add Scenario
|
||||
</Button>
|
||||
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '1rem' }}>
|
||||
<div className="grid gap-4 auto-cols-max md:grid-cols-[repeat(auto-fill,minmax(420px,1fr))]">
|
||||
{visible.map(sc => (
|
||||
<ScenarioContainer
|
||||
key={sc.id}
|
||||
@ -309,9 +327,29 @@ export default function MultiScenarioView() {
|
||||
financialProfile={financialProfile}
|
||||
onClone={handleCloneScenario}
|
||||
onRemove={handleRemoveScenario}
|
||||
isSelected={selectedScenario?.id === sc.id}
|
||||
onSelect={() => setSelectedScenario(sc)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* right rail */}
|
||||
<RetirementChatBar
|
||||
scenario={selectedScenario}
|
||||
financialProfile={financialProfile}
|
||||
onScenarioPatch={applyPatch}
|
||||
className="w-full md:w-[360px] border-l bg-white"
|
||||
/>
|
||||
|
||||
{/* diff drawer */}
|
||||
{!!diff && (
|
||||
<ScenarioDiffDrawer
|
||||
base={diff.base}
|
||||
patch={diff.patch}
|
||||
onClose={() => setDiff(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
@ -17,7 +17,8 @@ export default function ScenarioContainer({
|
||||
scenario,
|
||||
financialProfile,
|
||||
onRemove,
|
||||
onClone
|
||||
onClone,
|
||||
onSelect
|
||||
}) {
|
||||
// -------------------------------------------------------------
|
||||
// 1) States
|
||||
@ -846,7 +847,11 @@ export default function ScenarioContainer({
|
||||
// 10) Render
|
||||
// -------------------------------------------------------------
|
||||
return (
|
||||
<div style={{ width: '420px', border: '1px solid #ccc', padding: '1rem' }}>
|
||||
<article
|
||||
onClick={() => onSelect(localScenario.id)}
|
||||
className="w-[420px] border border-gray-300 p-4 rounded cursor-pointer
|
||||
hover:shadow-sm transition-shadow bg-white"
|
||||
>
|
||||
<select
|
||||
style={{ marginBottom: '0.5rem', width: '100%' }}
|
||||
value={localScenario?.id || ''}
|
||||
@ -1375,6 +1380,6 @@ export default function ScenarioContainer({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
36
src/components/ScenarioDiffDrawer.js
Normal file
36
src/components/ScenarioDiffDrawer.js
Normal file
@ -0,0 +1,36 @@
|
||||
import React from "react";
|
||||
import { Button } from "./ui/button.js";
|
||||
|
||||
export default function ScenarioDiffDrawer({ base, patch, onClose }) {
|
||||
if (!patch) return null;
|
||||
const keys = Object.keys(patch);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-x-0 bottom-0 max-h-60 overflow-y-auto bg-white border-t border-gray-300 z-50 p-4 shadow-lg">
|
||||
<h4 className="font-semibold mb-3">Scenario changes</h4>
|
||||
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left border-b">
|
||||
<th className="py-1 pr-4">Field</th>
|
||||
<th className="py-1 pr-4">Before</th>
|
||||
<th className="py-1">After</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{keys.map(k => (
|
||||
<tr key={k} className="border-b last:border-0">
|
||||
<td className="py-1 pr-4">{k}</td>
|
||||
<td className="py-1 pr-4">{String(base[k] ?? "—")}</td>
|
||||
<td className="py-1">{String(patch[k])}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<Button onClick={onClose} className="mt-4">
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
49
src/utils/opsEngine.js
Normal file
49
src/utils/opsEngine.js
Normal file
@ -0,0 +1,49 @@
|
||||
export async function applyOps(opsObj, { req, userId, scenarioId }) {
|
||||
if (!Array.isArray(opsObj?.milestones)) return [];
|
||||
|
||||
const apiBase = process.env.APTIVA_INTERNAL_API || 'http://localhost:5002/api';
|
||||
const auth = (p, o = {}) => internalFetch(req, `${apiBase}${p}`, o);
|
||||
|
||||
const confirmations = [];
|
||||
|
||||
for (const m of opsObj.milestones) {
|
||||
const op = (m?.op || '').toUpperCase();
|
||||
|
||||
/* ---------- DELETE ---------- */
|
||||
if (op === 'DELETE' && m.id) {
|
||||
const cleanId = m.id.trim();
|
||||
const r = await auth(`/premium/milestones/${cleanId}`, { method: 'DELETE' });
|
||||
if (r.ok) confirmations.push(`Deleted milestone ${cleanId}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
/* ---------- UPDATE ---------- */
|
||||
if (op === 'UPDATE' && m.id && m.patch) {
|
||||
const r = await auth(`/premium/milestones/${m.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(m.patch),
|
||||
});
|
||||
if (r.ok) confirmations.push(`Updated milestone ${m.id}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
/* ---------- CREATE ---------- */
|
||||
if (op === 'CREATE' && m.data) {
|
||||
// inject career_profile_id if the bot forgot it
|
||||
m.data.career_profile_id = m.data.career_profile_id || scenarioId;
|
||||
const r = await auth('/premium/milestone', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(m.data),
|
||||
});
|
||||
if (r.ok) {
|
||||
const j = await r.json();
|
||||
const newId = Array.isArray(j) ? j[0]?.id : j.id;
|
||||
confirmations.push(`Created milestone ${newId || '(new)'}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return confirmations;
|
||||
}
|
Loading…
Reference in New Issue
Block a user