RetirementPlanner chatbot changes for Support
This commit is contained in:
parent
5fc6d576b4
commit
6756f99c9b
@ -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 can’t click—just explain the steps.)`;
|
||||
}
|
||||
|
||||
/* 2) Append task catalogue so the bot can describe valid actions */
|
||||
const isPremium = req.user?.plan_type === "premium";
|
||||
/* 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### What the user can do on this screen\n" +
|
||||
`\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.)";
|
||||
'\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 = [
|
||||
|
20
src/App.js
20
src/App.js
@ -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,8 +564,15 @@ const uiToolHandlers = useMemo(() => {
|
||||
</main>
|
||||
|
||||
<ChatDrawer
|
||||
open={drawerOpen}
|
||||
onOpenChange={setDrawerOpen}
|
||||
|
||||
pane={drawerPane}
|
||||
setPane={setDrawerPane}
|
||||
|
||||
retireProps={retireProps}
|
||||
|
||||
pageContext={pageContext}
|
||||
title="Help & Support"
|
||||
snapshot={chatSnapshot}
|
||||
uiToolHandlers={uiToolHandlers}
|
||||
/>
|
||||
|
@ -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" }
|
||||
]
|
||||
}
|
||||
|
@ -1,144 +1,187 @@
|
||||
// ────────────────────────────────── 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",
|
||||
/* ─ 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,
|
||||
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 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);
|
||||
} 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}
|
||||
{/* 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'
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* transcript */}
|
||||
{/* 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.role === 'user'
|
||||
? 'text-right'
|
||||
: 'text-left text-gray-800'
|
||||
}
|
||||
>
|
||||
{m.content}
|
||||
@ -146,7 +189,6 @@ export default function ChatDrawer({
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* prompt box */}
|
||||
<div className="border-t p-4">
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
@ -167,6 +209,16 @@ export default function ChatDrawer({
|
||||
</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
|
||||
<strong>Retirement Planner</strong>
|
||||
</div>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
|
@ -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 class‑name 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,41 +159,8 @@ 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"
|
||||
@ -192,7 +168,6 @@ export default function RetirementPlanner () {
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ================= DIFF DRAWER ========================== */}
|
||||
|
@ -1,5 +1,11 @@
|
||||
// src/contexts/ChatCtx.js
|
||||
import { createContext } from 'react';
|
||||
|
||||
const ChatCtx = createContext({ setChatSnapshot: () => {} });
|
||||
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;
|
Loading…
Reference in New Issue
Block a user