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 ─────────────────────────────────────────────────────────────── */
const FAQ_THRESHOLD = 0.80;
const HELP_TRIGGERS = ["help","error","bug","issue","login","password","support"];
const TASKS_SENTINEL = '<<APTIVA TASK CATALOGUE>>';
/* Load tool manifests just once at boot ─────────────────────────────────── */
const BOT_TOOLS = JSON.parse(
@ -259,12 +260,19 @@ export default function chatFreeEndpoint(
(Remember: you cant clickjust explain the steps.)`;
}
/* 2) Append task catalogue so the bot can describe valid actions */
const isPremium = req.user?.plan_type === "premium";
system +=
"\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.)";
/* 2) Append task catalogue once per conversation ─────────── */
const alreadySent = chatHistory.some(
m => m.role === 'system' && m.content?.includes(TASKS_SENTINEL)
);
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;
@ -323,6 +331,41 @@ export default function chatFreeEndpoint(
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 ────────────────────── */
const tools = [...SUPPORT_TOOLS]; // guidance mode → support-only;
let messages = [

View File

@ -44,6 +44,9 @@ function App() {
const navigate = useNavigate();
const location = useLocation();
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
@ -215,7 +218,13 @@ const uiToolHandlers = useMemo(() => {
scenario, setScenario,
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">
{/* Header */}
<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>
<ChatDrawer
open={drawerOpen}
onOpenChange={setDrawerOpen}
pane={drawerPane}
setPane={setDrawerPane}
retireProps={retireProps}
pageContext={pageContext}
title="Help & Support"
snapshot={chatSnapshot}
snapshot={chatSnapshot}
uiToolHandlers={uiToolHandlers}
/>

View File

@ -79,5 +79,17 @@
{ "id": "CR-FTR8", "label": "Max Return (%) input (Random mode)" },
{ "id": "CR-FTR9", "label": "Interest bias dropdown" },
{ "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
import { useEffect, useRef, useState } from "react";
import { Sheet, SheetTrigger, SheetContent } from "./ui/sheet.js";
import { Button } from "./ui/button.js";
import { Input } from "./ui/input.js";
import { MessageCircle } from "lucide-react";
// ───────────────────────── ChatDrawer.jsx
import { useEffect, useRef, useState } from 'react';
import { Sheet, SheetTrigger, SheetContent } from './ui/sheet.js';
import { cn } from '../utils/cn.js';
import { Button } from './ui/button.js';
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({
pageContext = "Home",
snapshot = null,
/* ─ props from App.js ─ */
pageContext = 'Home',
snapshot = null,
open: controlledOpen = false,
onOpenChange,
pane: controlledPane = 'support',
setPane: setControlledPane,
retireProps = null, // { scenario, financialProfile, … }
}) {
/* state */
const [open, setOpen] = useState(false);
const [prompt, setPrompt] = useState("");
/* ─────────────────────────── internal / fallback state ───────── */
const [openLocal, setOpenLocal] = useState(false);
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 listRef = useRef(null);
/* auto-scroll */
/* auto-scroll on incoming messages */
useEffect(() => {
listRef.current &&
(listRef.current.scrollTop = listRef.current.scrollHeight);
}, [messages]);
/* helper: stream-friendly append */
/* helper: merge chunks while streaming */
const pushAssistant = (chunk) =>
setMessages((prev) => {
const last = prev.at(-1);
if (last?.role === "assistant") {
if (last?.role === 'assistant') {
const updated = [...prev];
updated[updated.length - 1] = {
...last,
content: last.content + chunk
content: last.content + chunk,
};
return updated;
}
return [...prev, { role: "assistant", content: chunk }];
return [...prev, { role: 'assistant', content: chunk }];
});
/* send prompt */
/* ───────────────────────── send support-bot prompt ───────────── */
async function sendPrompt() {
const text = prompt.trim();
if (!text) return;
setMessages((m) => [...m, { role: "user", content: text }]);
setPrompt("");
setMessages((m) => [...m, { role: 'user', content: text }]);
setPrompt('');
const body = JSON.stringify({
prompt: text,
prompt: text,
pageContext,
chatHistory: messages,
snapshot
snapshot,
});
try {
const token = localStorage.getItem("token") || "";
const token = localStorage.getItem('token') || '';
const headers = {
"Content-Type": "application/json",
Accept : "text/event-stream",
...(token ? { Authorization: `Bearer ${token}` } : {})
'Content-Type': 'application/json',
Accept: 'text/event-stream',
...(token ? { Authorization: `Bearer ${token}` } : {}),
};
const resp = await fetch("/api/chat/free", {
method: "POST",
const resp = await fetch('/api/chat/free', {
method: 'POST',
headers,
body
body,
});
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();
let buf = "";
let buf = '';
/* ─────────────── STREAM LOOP ─────────────── */
while (true) {
/* eslint-disable no-await-in-loop */
const { value, done } = await reader.read();
/* eslint-enable no-await-in-loop */
if (done) break;
if (!value) continue;
buf += decoder.decode(value, { stream: true });
let nl;
while ((nl = buf.indexOf("\n")) !== -1) {
const line = buf.slice(0, nl).trim(); // one full line
buf = buf.slice(nl + 1); // keep remainder
/* 2⃣ normal assistant text */
if (line) pushAssistant(line + "\n");
while ((nl = buf.indexOf('\n')) !== -1) {
const line = buf.slice(0, nl).trim();
buf = buf.slice(nl + 1);
if (line) pushAssistant(line + '\n');
}
}
/* ───────── END STREAM LOOP ───────── */
if (buf.trim()) pushAssistant(buf);
if (buf.trim()) pushAssistant(buf);
} catch (err) {
console.error("[ChatDrawer] stream error", err);
pushAssistant("Sorry — something went wrong. Please try again later.");
console.error('[ChatDrawer] stream error', err);
pushAssistant(
'Sorry — something went wrong. Please try again later.'
);
}
}
/* Enter submits */
const handleKeyDown = (e) => {
if (e.key === "Enter" && !e.shiftKey) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendPrompt();
}
};
/* UI */
/* ──────────────────────────── UI ─────────────────────────────── */
return (
<Sheet open={open} onOpenChange={setOpen}>
{/* floating button */}
<SheetTrigger>
<Sheet open={open} onOpenChange={onOpenChange}>
{/* floating FAB */}
<SheetTrigger asChild>
<button
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"
onClick={() => onOpenChange(!open)}
>
<MessageCircle size={24} />
</button>
</SheetTrigger>
{/* drawer */}
{/* side-drawer */}
<SheetContent
side="right"
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">
{pageContext}
</div>
{/* transcript */}
<div
ref={listRef}
className="flex-1 space-y-4 overflow-y-auto px-4 py-2 text-sm"
>
{messages.map((m, i) => (
<div
key={i}
className={
m.role === "user" ? "text-right" : "text-left text-gray-800"
}
{/* header tab switch */}
<div className="flex border-b text-sm font-semibold">
{[
{ id: 'support', label: 'Aptiva Support' },
{ id: 'retire', label: 'Retirement Helper' },
].map((tab) => (
<button
key={tab.id}
onClick={() => setPane(tab.id)}
className={cn(
'flex-1 py-2',
pane === tab.id
? 'border-b-2 border-blue-600'
: 'text-gray-500 hover:text-gray-700'
)}
>
{m.content}
</div>
{tab.label}
</button>
))}
</div>
{/* prompt box */}
<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>
{/* body conditional panes */}
{pane === 'support' ? (
/* ─────────── Support bot ─────────── */
<>
<div
ref={listRef}
className="flex-1 space-y-4 overflow-y-auto px-4 py-2 text-sm"
>
{messages.map((m, i) => (
<div
/* eslint-disable react/no-array-index-key */
key={i}
/* eslint-enable react/no-array-index-key */
className={
m.role === 'user'
? 'text-right'
: 'text-left text-gray-800'
}
>
{m.content}
</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>
</Sheet>
);

View File

@ -1,10 +1,11 @@
// 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 ScenarioContainer from './ScenarioContainer.js';
import { Button } from './ui/button.js';
import RetirementChatBar from './RetirementChatBar.js';
import ScenarioDiffDrawer from './ScenarioDiffDrawer.js';
import ChatCtx from '../contexts/ChatCtx.js';
/* ------------------------------------------------------------------
* tiny classname helper
@ -39,6 +40,7 @@ export default function RetirementPlanner () {
const [diff, setDiff] = useState(null);
const [simYearsMap, setSimYearsMap] = useState({});
const isMobile = useIsMobile();
const { openRetire } = useContext(ChatCtx);
/* ----------------------- data loading -------------------------- */
const loadAll = useCallback(async () => {
@ -141,7 +143,14 @@ export default function RetirementPlanner () {
baselineYears={baselineYears}
onClone={handleCloneScenario}
onRemove={handleRemoveScenario}
onSelect={() => { setSelectedScenario(sc); if (isMobile) setChatOpen(true); }}
onSelect={() => {
setSelectedScenario(sc);
openRetire({
scenario: sc,
financialProfile,
onScenarioPatch: applyPatch
});
}}
onSimDone={(id, yrs) => {
setSimYearsMap(prev => ({ ...prev, [id]: yrs }));
}}
@ -150,50 +159,16 @@ export default function RetirementPlanner () {
</div>
</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 ========================== */}
{isMobile && (
<>
{/* chat toggle */}
<button
onClick={() => setChatOpen(o => !o)}
className="fixed bottom-20 right-4 rounded-full bg-blue-600 p-3 text-white shadow-md z-40"
aria-label="Toggle chat"
>
💬
</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>
</>
)}
<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 && (

View File

@ -1,5 +1,11 @@
// src/contexts/ChatCtx.js
import { createContext } from 'react';
const ChatCtx = createContext({ setChatSnapshot: () => {} });
export default ChatCtx;
const ChatCtx = createContext({
/* 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;