RetirementPlanner chatbot changes for Support

This commit is contained in:
Josh 2025-07-07 18:50:10 +00:00
parent 5fc6d576b4
commit 6756f99c9b
6 changed files with 251 additions and 147 deletions

View File

@ -23,6 +23,7 @@ const FAQ_PATH = path.join(repoRoot, "backend", "user_profile.db");
/* Constants ─────────────────────────────────────────────────────────────── */ /* Constants ─────────────────────────────────────────────────────────────── */
const FAQ_THRESHOLD = 0.80; const FAQ_THRESHOLD = 0.80;
const HELP_TRIGGERS = ["help","error","bug","issue","login","password","support"]; const HELP_TRIGGERS = ["help","error","bug","issue","login","password","support"];
const TASKS_SENTINEL = '<<APTIVA TASK CATALOGUE>>';
/* Load tool manifests just once at boot ─────────────────────────────────── */ /* Load tool manifests just once at boot ─────────────────────────────────── */
const BOT_TOOLS = JSON.parse( const BOT_TOOLS = JSON.parse(
@ -259,12 +260,19 @@ export default function chatFreeEndpoint(
(Remember: you cant clickjust explain the steps.)`; (Remember: you cant clickjust explain the steps.)`;
} }
/* 2) Append task catalogue so the bot can describe valid actions */ /* 2) Append task catalogue once per conversation ─────────── */
const isPremium = req.user?.plan_type === "premium"; const alreadySent = chatHistory.some(
system += m => m.role === 'system' && m.content?.includes(TASKS_SENTINEL)
"\n\n### What the user can do on this screen\n" + );
taskListForPage(pageContext, isPremium) +
"\n\n(Remember: do not click for the user; only explain the steps.)"; if (!alreadySent) {
const isPremium = req.user?.plan_type === 'premium';
system +=
`\n\n${TASKS_SENTINEL}\n` + // ← sentinel tag
'### What the user can do on this screen\n' +
taskListForPage(pageContext, isPremium) +
'\n\n(Remember: do not click for the user; only explain the steps.)';
}
const modalPayload = snapshot?.modalCtx; const modalPayload = snapshot?.modalCtx;
@ -323,6 +331,41 @@ export default function chatFreeEndpoint(
system += "(Explain steps only; never click for the user.)"; system += "(Explain steps only; never click for the user.)";
} }
/*
RetirementPlanner extras (NEW)
- front-end <RetirementPlanner> already sends `snapshot` shaped
like: {
scenarioCtx : { count, selectedId, selectedTitle },
simCtx : { years, interest }, // eg 50 | "No Interest"
chartCtx : { nestEgg } // projected value, number
}
---------------------------------------------------------------- */
if (pageContext === "RetirementPlanner" && snapshot) {
const { scenarioCtx = {}, simCtx = {}, chartCtx = {} } = snapshot;
system += "\n\n### Current context: Retirement Planner\n";
if (scenarioCtx.count != null) {
system += `Scenarios loaded : ${scenarioCtx.count}\n`;
if (scenarioCtx.selectedTitle)
system += `Active scenario : “${scenarioCtx.selectedTitle}\n`;
}
if (simCtx.years != null)
system += `Simulation horizon : ${simCtx.years}years\n`;
if (simCtx.interest)
system += `Interest model : ${simCtx.interest}\n`;
if (chartCtx.nestEgg != null)
system += `Projected nest-egg : $${chartCtx.nestEgg.toLocaleString()}\n`;
system +=
"(Explain steps only; never click for the user. Refer to buttons/inputs by their labels from the task catalogue.)";
}
/* ── Build tool list for this request ────────────────────── */ /* ── Build tool list for this request ────────────────────── */
const tools = [...SUPPORT_TOOLS]; // guidance mode → support-only; const tools = [...SUPPORT_TOOLS]; // guidance mode → support-only;
let messages = [ let messages = [

View File

@ -44,6 +44,9 @@ function App() {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const { pageContext, snapshot: routeSnapshot } = usePageContext(); const { pageContext, snapshot: routeSnapshot } = usePageContext();
const [drawerOpen, setDrawerOpen] = useState(false);
const [drawerPane, setDrawerPane] = useState('support');
const [retireProps, setRetireProps] = useState(null);
/* ------------------------------------------ /* ------------------------------------------
ChatDrawer route-aware tool handlers ChatDrawer route-aware tool handlers
@ -215,7 +218,13 @@ const uiToolHandlers = useMemo(() => {
scenario, setScenario, scenario, setScenario,
user, }} user, }}
> >
<ChatCtx.Provider value={{ setChatSnapshot }}> <ChatCtx.Provider value={{ setChatSnapshot,
openSupport: () => { setDrawerPane('support'); setDrawerOpen(true); },
openRetire : (props) => {
setRetireProps(props); // { scenario, financialProfile, onScenarioPatch }
setDrawerPane('retire');
setDrawerOpen(true);
}}}>
<div className="flex min-h-screen flex-col bg-gray-50 text-gray-800"> <div className="flex min-h-screen flex-col bg-gray-50 text-gray-800">
{/* Header */} {/* Header */}
<header className="flex items-center justify-between border-b bg-white px-6 py-4 shadow-sm relative"> <header className="flex items-center justify-between border-b bg-white px-6 py-4 shadow-sm relative">
@ -555,9 +564,16 @@ const uiToolHandlers = useMemo(() => {
</main> </main>
<ChatDrawer <ChatDrawer
open={drawerOpen}
onOpenChange={setDrawerOpen}
pane={drawerPane}
setPane={setDrawerPane}
retireProps={retireProps}
pageContext={pageContext} pageContext={pageContext}
title="Help & Support" snapshot={chatSnapshot}
snapshot={chatSnapshot}
uiToolHandlers={uiToolHandlers} uiToolHandlers={uiToolHandlers}
/> />

View File

@ -79,5 +79,17 @@
{ "id": "CR-FTR8", "label": "Max Return (%) input (Random mode)" }, { "id": "CR-FTR8", "label": "Max Return (%) input (Random mode)" },
{ "id": "CR-FTR9", "label": "Interest bias dropdown" }, { "id": "CR-FTR9", "label": "Interest bias dropdown" },
{ "id": "CR-FTR10","label": "Annual base $ input" } { "id": "CR-FTR10","label": "Annual base $ input" }
],
"RetirementPlanner": [
{ "id": "RP-01", "label": "+ Add Scenario button" },
{ "id": "RP-02", "label": "Scenario selector dropdown (title bar)" },
{ "id": "RP-03", "label": "Simulation Years (yrs) input" },
{ "id": "RP-04", "label": "Apply Interest dropdown" },
{ "id": "RP-05", "label": "Nest-egg chart (projection graph)" },
{ "id": "RP-06", "label": "Milestones button" },
{ "id": "RP-07", "label": "Edit button (card footer)" },
{ "id": "RP-08", "label": "Clone button (card footer)" },
{ "id": "RP-09", "label": "Delete button (card footer)" },
{ "id": "RP-10", "label": "Retirement Helper chat icon / rail" }
] ]
} }

View File

@ -1,172 +1,224 @@
// ────────────────────────────────── ChatDrawer.jsx // ───────────────────────── ChatDrawer.jsx
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from 'react';
import { Sheet, SheetTrigger, SheetContent } from "./ui/sheet.js"; import { Sheet, SheetTrigger, SheetContent } from './ui/sheet.js';
import { Button } from "./ui/button.js"; import { cn } from '../utils/cn.js';
import { Input } from "./ui/input.js"; import { Button } from './ui/button.js';
import { MessageCircle } from "lucide-react"; import { Input } from './ui/input.js';
import { MessageCircle } from 'lucide-react';
import RetirementChatBar from './RetirementChatBar.js';
/* --------------------------------------------------------------- /* ------------------------------------------------------------------ */
Streams from /api/chat/free and executes UI-tool callbacks /* ChatDrawer
----------------------------------------------------------------*/ support-bot lives in this file (streamed from /api/chat/free)
retirement helper is just a passthrough to <RetirementChatBar>
------------------------------------------------------------------ */
export default function ChatDrawer({ export default function ChatDrawer({
pageContext = "Home", /* ─ props from App.js ─ */
snapshot = null, pageContext = 'Home',
snapshot = null,
open: controlledOpen = false,
onOpenChange,
pane: controlledPane = 'support',
setPane: setControlledPane,
retireProps = null, // { scenario, financialProfile, … }
}) { }) {
/* state */ /* ─────────────────────────── internal / fallback state ───────── */
const [open, setOpen] = useState(false); const [openLocal, setOpenLocal] = useState(false);
const [prompt, setPrompt] = useState(""); const [paneLocal, setPaneLocal] = useState('support');
/* prefer the controlled props when supplied */
const open = controlledOpen ?? openLocal;
const setOpen = onOpenChange ?? setOpenLocal;
const pane = controlledPane ?? paneLocal;
const setPane = setControlledPane ?? setPaneLocal;
/* ────────────────── free-tier support-bot state ─────────────── */
const [prompt, setPrompt] = useState('');
const [messages, setMessages] = useState([]); // { role, content } const [messages, setMessages] = useState([]); // { role, content }
const listRef = useRef(null); const listRef = useRef(null);
/* auto-scroll */ /* auto-scroll on incoming messages */
useEffect(() => { useEffect(() => {
listRef.current && listRef.current &&
(listRef.current.scrollTop = listRef.current.scrollHeight); (listRef.current.scrollTop = listRef.current.scrollHeight);
}, [messages]); }, [messages]);
/* helper: stream-friendly append */ /* helper: merge chunks while streaming */
const pushAssistant = (chunk) => const pushAssistant = (chunk) =>
setMessages((prev) => { setMessages((prev) => {
const last = prev.at(-1); const last = prev.at(-1);
if (last?.role === "assistant") { if (last?.role === 'assistant') {
const updated = [...prev]; const updated = [...prev];
updated[updated.length - 1] = { updated[updated.length - 1] = {
...last, ...last,
content: last.content + chunk content: last.content + chunk,
}; };
return updated; return updated;
} }
return [...prev, { role: "assistant", content: chunk }]; return [...prev, { role: 'assistant', content: chunk }];
}); });
/* send prompt */ /* ───────────────────────── send support-bot prompt ───────────── */
async function sendPrompt() { async function sendPrompt() {
const text = prompt.trim(); const text = prompt.trim();
if (!text) return; if (!text) return;
setMessages((m) => [...m, { role: "user", content: text }]); setMessages((m) => [...m, { role: 'user', content: text }]);
setPrompt(""); setPrompt('');
const body = JSON.stringify({ const body = JSON.stringify({
prompt: text, prompt: text,
pageContext, pageContext,
chatHistory: messages, chatHistory: messages,
snapshot snapshot,
}); });
try { try {
const token = localStorage.getItem("token") || ""; const token = localStorage.getItem('token') || '';
const headers = { const headers = {
"Content-Type": "application/json", 'Content-Type': 'application/json',
Accept : "text/event-stream", Accept: 'text/event-stream',
...(token ? { Authorization: `Bearer ${token}` } : {}) ...(token ? { Authorization: `Bearer ${token}` } : {}),
}; };
const resp = await fetch("/api/chat/free", { const resp = await fetch('/api/chat/free', {
method: "POST", method: 'POST',
headers, headers,
body body,
}); });
if (!resp.ok || !resp.body) throw new Error(`HTTP ${resp.status}`); if (!resp.ok || !resp.body) throw new Error(`HTTP ${resp.status}`);
const reader = resp.body.getReader(); const reader = resp.body.getReader();
const decoder = new TextDecoder(); const decoder = new TextDecoder();
let buf = ""; let buf = '';
/* ─────────────── STREAM LOOP ─────────────── */
while (true) { while (true) {
/* eslint-disable no-await-in-loop */
const { value, done } = await reader.read(); const { value, done } = await reader.read();
/* eslint-enable no-await-in-loop */
if (done) break; if (done) break;
if (!value) continue; if (!value) continue;
buf += decoder.decode(value, { stream: true }); buf += decoder.decode(value, { stream: true });
let nl; let nl;
while ((nl = buf.indexOf("\n")) !== -1) { while ((nl = buf.indexOf('\n')) !== -1) {
const line = buf.slice(0, nl).trim(); // one full line const line = buf.slice(0, nl).trim();
buf = buf.slice(nl + 1); // keep remainder buf = buf.slice(nl + 1);
if (line) pushAssistant(line + '\n');
/* 2⃣ normal assistant text */
if (line) pushAssistant(line + "\n");
} }
} }
/* ───────── END STREAM LOOP ───────── */ if (buf.trim()) pushAssistant(buf);
if (buf.trim()) pushAssistant(buf);
} catch (err) { } catch (err) {
console.error("[ChatDrawer] stream error", err); console.error('[ChatDrawer] stream error', err);
pushAssistant("Sorry — something went wrong. Please try again later."); pushAssistant(
'Sorry — something went wrong. Please try again later.'
);
} }
} }
/* Enter submits */
const handleKeyDown = (e) => { const handleKeyDown = (e) => {
if (e.key === "Enter" && !e.shiftKey) { if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault(); e.preventDefault();
sendPrompt(); sendPrompt();
} }
}; };
/* UI */ /* ──────────────────────────── UI ─────────────────────────────── */
return ( return (
<Sheet open={open} onOpenChange={setOpen}> <Sheet open={open} onOpenChange={onOpenChange}>
{/* floating button */} {/* floating FAB */}
<SheetTrigger> <SheetTrigger asChild>
<button <button
aria-label="Open chat" aria-label="Open chat"
className="fixed bottom-6 right-6 z-50 rounded-full bg-blue-600 p-3 text-white shadow-lg hover:bg-blue-700" className="fixed bottom-6 right-6 z-50 rounded-full bg-blue-600 p-3 text-white shadow-lg hover:bg-blue-700"
onClick={() => onOpenChange(!open)}
> >
<MessageCircle size={24} /> <MessageCircle size={24} />
</button> </button>
</SheetTrigger> </SheetTrigger>
{/* drawer */} {/* side-drawer */}
<SheetContent <SheetContent
side="right" side="right"
className="flex max-h-screen w-[380px] flex-col px-0 md:w-[420px]" className="flex max-h-screen w-[380px] flex-col px-0 md:w-[420px]"
> >
<div className="sticky top-0 z-10 border-b bg-white px-4 py-3 font-semibold"> {/* header tab switch */}
{pageContext} <div className="flex border-b text-sm font-semibold">
</div> {[
{ id: 'support', label: 'Aptiva Support' },
{/* transcript */} { id: 'retire', label: 'Retirement Helper' },
<div ].map((tab) => (
ref={listRef} <button
className="flex-1 space-y-4 overflow-y-auto px-4 py-2 text-sm" key={tab.id}
> onClick={() => setPane(tab.id)}
{messages.map((m, i) => ( className={cn(
<div 'flex-1 py-2',
key={i} pane === tab.id
className={ ? 'border-b-2 border-blue-600'
m.role === "user" ? "text-right" : "text-left text-gray-800" : 'text-gray-500 hover:text-gray-700'
} )}
> >
{m.content} {tab.label}
</div> </button>
))} ))}
</div> </div>
{/* prompt box */} {/* body conditional panes */}
<div className="border-t p-4"> {pane === 'support' ? (
<form /* ─────────── Support bot ─────────── */
onSubmit={(e) => { <>
e.preventDefault(); <div
sendPrompt(); ref={listRef}
}} className="flex-1 space-y-4 overflow-y-auto px-4 py-2 text-sm"
className="flex gap-2" >
> {messages.map((m, i) => (
<Input <div
value={prompt} /* eslint-disable react/no-array-index-key */
onChange={(e) => setPrompt(e.target.value)} key={i}
onKeyDown={handleKeyDown} /* eslint-enable react/no-array-index-key */
placeholder="Ask me anything…" className={
className="flex-1" m.role === 'user'
/> ? 'text-right'
<Button type="submit" disabled={!prompt.trim()}> : 'text-left text-gray-800'
Send }
</Button> >
</form> {m.content}
</div> </div>
))}
</div>
<div className="border-t p-4">
<form
onSubmit={(e) => {
e.preventDefault();
sendPrompt();
}}
className="flex gap-2"
>
<Input
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Ask me anything…"
className="flex-1"
/>
<Button type="submit" disabled={!prompt.trim()}>
Send
</Button>
</form>
</div>
</>
) : retireProps ? (
/* ───────── Retirement helper ─────── */
<RetirementChatBar {...retireProps} />
) : (
<div className="m-auto px-6 text-center text-sm text-gray-400">
Select a scenario in&nbsp;
<strong>Retirement Planner</strong>
</div>
)}
</SheetContent> </SheetContent>
</Sheet> </Sheet>
); );

View File

@ -1,10 +1,11 @@
// src/components/RetirementPlanner.js // src/components/RetirementPlanner.js
import React, { useEffect, useState, useCallback } from 'react'; import React, { useEffect, useState, useCallback, useContext } from 'react';
import authFetch from '../utils/authFetch.js'; import authFetch from '../utils/authFetch.js';
import ScenarioContainer from './ScenarioContainer.js'; import ScenarioContainer from './ScenarioContainer.js';
import { Button } from './ui/button.js'; import { Button } from './ui/button.js';
import RetirementChatBar from './RetirementChatBar.js'; import RetirementChatBar from './RetirementChatBar.js';
import ScenarioDiffDrawer from './ScenarioDiffDrawer.js'; import ScenarioDiffDrawer from './ScenarioDiffDrawer.js';
import ChatCtx from '../contexts/ChatCtx.js';
/* ------------------------------------------------------------------ /* ------------------------------------------------------------------
* tiny classname helper * tiny classname helper
@ -39,6 +40,7 @@ export default function RetirementPlanner () {
const [diff, setDiff] = useState(null); const [diff, setDiff] = useState(null);
const [simYearsMap, setSimYearsMap] = useState({}); const [simYearsMap, setSimYearsMap] = useState({});
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const { openRetire } = useContext(ChatCtx);
/* ----------------------- data loading -------------------------- */ /* ----------------------- data loading -------------------------- */
const loadAll = useCallback(async () => { const loadAll = useCallback(async () => {
@ -141,7 +143,14 @@ export default function RetirementPlanner () {
baselineYears={baselineYears} baselineYears={baselineYears}
onClone={handleCloneScenario} onClone={handleCloneScenario}
onRemove={handleRemoveScenario} onRemove={handleRemoveScenario}
onSelect={() => { setSelectedScenario(sc); if (isMobile) setChatOpen(true); }} onSelect={() => {
setSelectedScenario(sc);
openRetire({
scenario: sc,
financialProfile,
onScenarioPatch: applyPatch
});
}}
onSimDone={(id, yrs) => { onSimDone={(id, yrs) => {
setSimYearsMap(prev => ({ ...prev, [id]: yrs })); setSimYearsMap(prev => ({ ...prev, [id]: yrs }));
}} }}
@ -150,50 +159,16 @@ export default function RetirementPlanner () {
</div> </div>
</main> </main>
{/* ================= CHAT RAIL ============================ */}
<aside
className={cn(
'fixed md:static top-0 right-0 h-full bg-white border-l shadow-lg',
'transition-transform duration-300',
'w-11/12 max-w-xs md:w-[340px] z-30',
chatOpen ? 'translate-x-0' : 'translate-x-full md:translate-x-0'
)}
>
{selectedScenario ? (
<RetirementChatBar
scenario={selectedScenario}
financialProfile={financialProfile}
onScenarioPatch={applyPatch}
/>
) : (
<div className="h-full flex items-center justify-center text-gray-400 text-sm p-4">
Select a scenario to chat
</div>
)}
</aside>
{/* ================= MOBILE FABS ========================== */} {/* ================= MOBILE FABS ========================== */}
{isMobile && ( {isMobile && (
<> <button
{/* chat toggle */} onClick={handleAddScenario}
<button className="fixed bottom-4 right-4 rounded-full bg-green-600 p-4 text-white text-xl shadow-md z-40"
onClick={() => setChatOpen(o => !o)} aria-label="Add scenario"
className="fixed bottom-20 right-4 rounded-full bg-blue-600 p-3 text-white shadow-md z-40" >
aria-label="Toggle chat"
> </button>
💬 )}
</button>
{/* add scenario */}
<button
onClick={handleAddScenario}
className="fixed bottom-4 right-4 rounded-full bg-green-600 p-4 text-white text-xl shadow-md z-40"
aria-label="Add scenario"
>
</button>
</>
)}
{/* ================= DIFF DRAWER ========================== */} {/* ================= DIFF DRAWER ========================== */}
{diff && ( {diff && (

View File

@ -1,5 +1,11 @@
// src/contexts/ChatCtx.js
import { createContext } from 'react'; import { createContext } from 'react';
const ChatCtx = createContext({ setChatSnapshot: () => {} }); const ChatCtx = createContext({
export default ChatCtx; /* already used by Support bot */
setChatSnapshot: () => {},
/* NEW helpers — App.js will supply real fns */
openSupport: () => {}, // open drawer on Support pane
openRetire : () => {} // open drawer on Retirement Helper pane
});
export default ChatCtx;