diff --git a/backend/utils/chatFreeEndpoint.js b/backend/utils/chatFreeEndpoint.js index db192b0..f4c4474 100644 --- a/backend/utils/chatFreeEndpoint.js +++ b/backend/utils/chatFreeEndpoint.js @@ -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 = '<>'; /* 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"; - 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 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 = [ diff --git a/src/App.js b/src/App.js index e0ba509..9b5e2ed 100644 --- a/src/App.js +++ b/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, }} > - + { setDrawerPane('support'); setDrawerOpen(true); }, + openRetire : (props) => { + setRetireProps(props); // { scenario, financialProfile, onScenarioPatch } + setDrawerPane('retire'); + setDrawerOpen(true); + }}}>
{/* Header */}
@@ -555,9 +564,16 @@ const uiToolHandlers = useMemo(() => { diff --git a/src/ai/agent_support_reference.json b/src/ai/agent_support_reference.json index 362698d..1696d99 100644 --- a/src/ai/agent_support_reference.json +++ b/src/ai/agent_support_reference.json @@ -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" } ] } diff --git a/src/components/ChatDrawer.js b/src/components/ChatDrawer.js index 4a3c2f8..d22ddad 100644 --- a/src/components/ChatDrawer.js +++ b/src/components/ChatDrawer.js @@ -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 + ------------------------------------------------------------------ */ 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 ( - - {/* floating button */} - + + {/* floating FAB */} + - {/* drawer */} + {/* side-drawer */} -
- {pageContext} -
- - {/* transcript */} -
- {messages.map((m, i) => ( -
+ {[ + { id: 'support', label: 'Aptiva Support' }, + { id: 'retire', label: 'Retirement Helper' }, + ].map((tab) => ( +
+ {tab.label} + ))}
- {/* prompt box */} -
-
{ - e.preventDefault(); - sendPrompt(); - }} - className="flex gap-2" - > - setPrompt(e.target.value)} - onKeyDown={handleKeyDown} - placeholder="Ask me anything…" - className="flex-1" - /> - -
-
+ {/* body – conditional panes */} + {pane === 'support' ? ( + /* ─────────── Support bot ─────────── */ + <> +
+ {messages.map((m, i) => ( +
+ {m.content} +
+ ))} +
+ +
+
{ + e.preventDefault(); + sendPrompt(); + }} + className="flex gap-2" + > + setPrompt(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Ask me anything…" + className="flex-1" + /> + +
+
+ + ) : retireProps ? ( + /* ───────── Retirement helper ─────── */ + + ) : ( +
+ Select a scenario in  + Retirement Planner +
+ )}
); diff --git a/src/components/RetirementPlanner.js b/src/components/RetirementPlanner.js index 8ee51b4..c6a105b 100644 --- a/src/components/RetirementPlanner.js +++ b/src/components/RetirementPlanner.js @@ -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,50 +159,16 @@ export default function RetirementPlanner () {
- {/* ================= CHAT RAIL ============================ */} - - {/* ================= MOBILE FABS ========================== */} {isMobile && ( - <> - {/* chat toggle */} - - - {/* add scenario */} - - - )} + +)} {/* ================= DIFF DRAWER ========================== */} {diff && ( diff --git a/src/contexts/ChatCtx.js b/src/contexts/ChatCtx.js index 4df5048..fd2c675 100644 --- a/src/contexts/ChatCtx.js +++ b/src/contexts/ChatCtx.js @@ -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; \ No newline at end of file