AI agent for CareerExplorer: AddtoComparison

This commit is contained in:
Josh 2025-07-03 11:32:31 +00:00
parent 2160f20f93
commit 58a8e15e09
12 changed files with 1030 additions and 181 deletions

View File

@ -299,6 +299,21 @@ async function geocodeZipCode(zipCode) {
} }
} }
/** @aiTool {
"name": "getDistanceInMiles",
"description": "Return driving distance and duration between the user ZIP and destination(s)",
"parameters": {
"type": "object",
"properties": {
"userZipcode": { "type": "string" },
"destinations": { "type": "string", "description": "Pipe-separated lat,lng pairs or addresses" }
},
"required": ["userZipcode", "destinations"]
},
"pages": ["EducationalProgramsPage", "LoanRepayment"],
"write": false
} */
// Distance // Distance
app.post('/api/maps/distance', async (req, res) => { app.post('/api/maps/distance', async (req, res) => {
const { userZipcode, destinations } = req.body; const { userZipcode, destinations } = req.body;
@ -496,6 +511,27 @@ app.get('/api/cip/:socCode', (req, res) => {
res.status(404).json({ error: 'CIP code not found' }); res.status(404).json({ error: 'CIP code not found' });
}); });
/** @aiTool {
"name": "getSchoolsForCIPs",
"description": "Return a list of schools whose CIP codes match the supplied prefixes in the given state",
"parameters": {
"type": "object",
"properties": {
"cipCodes": {
"type": "string",
"description": "Comma-separated CIP prefixes, e.g. \"1101,1103\""
},
"state": {
"type": "string",
"description": "Two-letter state abbreviation, e.g. \"GA\""
}
},
"required": ["cipCodes", "state"]
},
"pages": ["EducationalProgramsPage"],
"write": false
} */
/************************************************** /**************************************************
* Single schools / tuition / etc. routes * Single schools / tuition / etc. routes
**************************************************/ **************************************************/
@ -553,6 +589,21 @@ app.get('/api/schools', (req, res) => {
} }
}); });
/** @aiTool {
"name": "getTuitionForCIPs",
"description": "Return in-state / out-state tuition rows for schools matching CIP prefixes in a given state",
"parameters": {
"type": "object",
"properties": {
"cipCodes": { "type": "string", "description": "Comma-separated prefixes, e.g. \"1101,1103\"" },
"state": { "type": "string", "description": "Two-letter state code, e.g. \"GA\"" }
},
"required": ["cipCodes", "state"]
},
"pages": ["EducationalProgramsPage", "LoanRepayment"],
"write": false
} */
// tuition // tuition
app.get('/api/tuition', (req, res) => { app.get('/api/tuition', (req, res) => {
const { cipCodes, state } = req.query; const { cipCodes, state } = req.query;
@ -603,6 +654,21 @@ app.get('/api/tuition', (req, res) => {
} }
}); });
/** @aiTool {
"name": "getEconomicProjections",
"description": "Return state and national employment projections for a SOC code",
"parameters": {
"type": "object",
"properties": {
"socCode": { "type": "string" },
"state": { "type": "string", "description": "Optional state abbreviation" }
},
"required": ["socCode"]
},
"pages": ["CareerExplorer"],
"write": false
} */
/************************************************** /**************************************************
* SINGLE route for projections from economicproj.json * SINGLE route for projections from economicproj.json
**************************************************/ **************************************************/
@ -661,6 +727,21 @@ app.get('/api/projections/:socCode', (req, res) => {
return res.json(result); return res.json(result);
}); });
/** @aiTool {
"name": "getSalaryData",
"description": "Return residential area and national salary percentiles for a SOC code",
"parameters": {
"type": "object",
"properties": {
"socCode": { "type": "string" },
"area": { "type": "string", "description": "User's residential area" }
},
"required": ["socCode"]
},
"pages": ["CareerExplorer"],
"write": false
} */
/************************************************** /**************************************************
* Salary route * Salary route
**************************************************/ **************************************************/

View File

@ -1,15 +1,36 @@
// utils/chatFreeEndpoint.js /* ─── backend/utils/chatFreeEndpoint.js (TOP-OF-FILE REPLACEMENT) ───────── */
import path from "path"; import fs from "fs/promises";
import path from "path";
import { fileURLToPath } from "url"; import { fileURLToPath } from "url";
import { vectorSearch } from "./vectorSearch.js";
const __filename = fileURLToPath(import.meta.url); import { vectorSearch } from "./vectorSearch.js";
const __dirname = path.dirname(__filename);
const rootPath = path.resolve(__dirname, "..", ".."); // backend/ /* Resolve current directory ─────────────────────────────────────────────── */
const FAQ_PATH = path.join(rootPath, "user_profile.db"); const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
/* Directories ───────────────────────────────────────────────────────────── */
/* repoRoot = “…/aptiva-dev1-app” (one level up from backend/) */
const repoRoot = path.resolve(__dirname, "..", "..");
/* assetsDir = “…/aptiva-dev1-app/src/assets” (where the JSONs live) */
const assetsDir = path.join(repoRoot, "src", "assets");
/* FAQ SQLite DB (unchanged) */
const FAQ_PATH = path.join(repoRoot, "backend", "user_profile.db");
/* 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"];
/* Load tool manifests just once at boot ─────────────────────────────────── */
const BOT_TOOLS = JSON.parse(
await fs.readFile(path.join(assetsDir, "botTools.json"), "utf8")
);
const PAGE_TOOLMAP = JSON.parse(
await fs.readFile(path.join(assetsDir, "pageToolMap.json"), "utf8")
);
/* ---------- helpers ---------- */ /* ---------- helpers ---------- */
const classifyIntent = txt => const classifyIntent = txt =>
HELP_TRIGGERS.some(k => txt.toLowerCase().includes(k)) ? "support" : "guide"; HELP_TRIGGERS.some(k => txt.toLowerCase().includes(k)) ? "support" : "guide";
@ -26,6 +47,47 @@ const buildContext = (user = {}, page = "", intent = "guide") => {
}; };
}; };
const INTEREST_PLAYBOOK = `
### When the user is on **CareerExplorer** and no career tiles are visible
1. Explain there are two ways to begin:
Interest Inventory (7-minute, 60-question survey)
Manual search (type a career in the Search for Career bar)
2. If the user chooses **Inventory**
1. Tell them to click the green **Start Interest Inventory** button at the top of the page.
2. Explain the answer keys (A = Agree, U = Unsure, D = Dislike) and that each click advances to the next question.
3. Wait while the UI runs the survey. **Do NOT collect answers inside chat.**
4. When career tiles appear, say: Great! Your matches are listed below. Click any blue tile for details.
3. If the user chooses **Manual search**
1. Tell them to click the **search bar** and type at least three letters.
2. After they pick a suggestion, remind them to click the blue tile to open its details.
4. **Never call \`getONetInterestQuestions\` or \`submitInterestInventory\` yourself.**
5. After tiles appear, you may call salary, projection, skills, or other data tools to answer follow-up questions.
`;
const CAREER_EXPLORER_FEATURES = `
### Aptiva Career Explorer features you can (and should) guide the user to
**Search bar** type 3 letters, pick a suggestion, then click the blue tile.
**Interest Inventory** green Start Interest Inventory button (60 Qs, 7 min).
**Blue career tile** opens a modal with:
*Overview* tab (description & Day-in-the-Life tasks)
*Salary* tab (regional & national percentiles)
*Projections* tab (state + national growth)
*AI Risk* tab (Aptivas proprietary impact level)
**Add to Comparison** button builds a side-by-side table above the tiles.
**Filters** dropdowns Preparation Level (Job Zone 1-5) and Fit Level (Best / Great / Good).
**Reload Career Suggestions** re-runs your interest-based match with updated filters.
**Select for Education** jumps to Educational Programs with the careers CIP codes.
When users ask:
Which is better? tell them to add both careers and open the comparison table.
Whats a day in the life? tell them to open the modals *Overview* tab.
How do I plan education? tell them to click *Select for Education*.
Use tools when numeric data is needed:
\`getSalaryData\`, \`getEconomicProjections\`, \`getAiRisk\`, \`getCareerDetails\`.
You may call \`addCareerToComparison\` or \`openCareerModal\`
**only after the user has clearly asked you to do so** (e.g. Yes, add it for me).
Always confirm first.
`;
/* ---------------------------------------------------------------------------- /* ----------------------------------------------------------------------------
FACTORY: registers POST /api/chat/free on the passed-in Express app FACTORY: registers POST /api/chat/free on the passed-in Express app
----------------------------------------------------------------------------- */ ----------------------------------------------------------------------------- */
@ -63,9 +125,45 @@ export default function chatFreeEndpoint(
} catch { } catch {
return { status: "db_error" }; return { status: "db_error" };
} }
} },
/* NEW — forward any UI tool-call to the browser via SSE */
async __forwardUiTool(name, argsObj, res) {
res.write(`__tool:${name}:${JSON.stringify(argsObj)}\n`);
if (typeof res.flush === "function") res.flush();
return { forwarded: true };
},
}; };
/* -------------------- UI TOOLS (CareerExplorer only) -------------------- */
const UI_TOOLS = [
{
type: "function",
function: {
name: "addCareerToComparison",
description: "Add a career tile to the comparison table in Career Explorer",
parameters: {
type: "object",
properties: { socCode: { type: "string" } },
required: ["socCode"]
}
}
},
{
type: "function",
function: {
name: "openCareerModal",
description: "Open the Career-details modal for the given SOC code",
parameters: {
type: "object",
properties: { socCode: { type: "string" } },
required: ["socCode"]
}
}
}
];
const SUPPORT_TOOLS = [ const SUPPORT_TOOLS = [
{ {
type: "function", type: "function",
@ -122,14 +220,24 @@ export default function chatFreeEndpoint(
/* --------------------------------------- */ /* --------------------------------------- */
const intent = classifyIntent(prompt); const intent = classifyIntent(prompt);
const { system } = buildContext(req.user || {}, pageContext, intent); let { system } = buildContext(req.user || {}, pageContext, intent);
const tools = intent === "support" ? SUPPORT_TOOLS : []; if (pageContext === "CareerExplorer") {
system += INTEREST_PLAYBOOK + CAREER_EXPLORER_FEATURES;
}
const messages = [ /* ── Build tool list for this request ────────────────────── */
{ role:"system", content: system }, let tools = intent === "support" ? [...SUPPORT_TOOLS] : [];
...chatHistory, const uiNamesForPage = PAGE_TOOLMAP[pageContext] || [];
{ role:"user", content: prompt } for (const def of BOT_TOOLS) {
]; if (uiNamesForPage.includes(def.name)) {
tools.push({ type: "function", function: def });
}
}
const messages = [
{ role: "system", content: system },
...chatHistory,
{ role: "user", content: prompt }
];
const chatStream = await openai.chat.completions.create({ const chatStream = await openai.chat.completions.create({
model : "gpt-4o-mini", model : "gpt-4o-mini",
@ -139,33 +247,67 @@ export default function chatFreeEndpoint(
tool_choice : tools.length ? "auto" : undefined tool_choice : tools.length ? "auto" : undefined
}); });
/* ---------- SSE headers ---------- */ /* ── keep state while a tool call streams in ─────────────── */
res.setHeader("Content-Type", "text/event-stream"); let pendingName = null; // addCareerToComparison
res.setHeader("Cache-Control", "no-cache"); let pendingArgs = ""; // '{"socCode":"15-2051"}'
res.setHeader("Connection", "keep-alive");
for await (const part of chatStream) { for await (const part of chatStream) {
const delta = part.choices?.[0]?.delta || {}; const delta = part.choices?.[0]?.delta || {};
const chunk = delta.content || "";
if (chunk) res.write(chunk); // ← plain text, no “data:” prefix
}
res.end();
/* ---------- tool calls ---------- */ /* 1⃣ handle function / tool calls immediately */
chatStream.on("tool", async call => { if (delta.tool_calls?.length) {
const fn = toolResolvers[call.name]; const callObj = delta.tool_calls[0];
if (!fn) return; const fn = callObj.function || {};
try { if (fn.name) pendingName = fn.name; // keep first
const args = JSON.parse(call.arguments || "{}"); if (fn.arguments) pendingArgs += fn.arguments; // append
await fn({ ...args, user: req.user || {} });
} catch (err) { // Try to parse the JSON only when its complete
console.error("[tool resolver]", err); let args;
try { args = JSON.parse(pendingArgs); } catch { continue; }
/* run the resolver */
let result;
if (toolResolvers[pendingName]) {
result = await toolResolvers[pendingName]({ ...args, user: req.user }, res);
} else {
result = await toolResolvers.__forwardUiTool(pendingName, args, res);
}
/* feed the result back to the model so it can finish */
const followStream = await openai.chat.completions.create({
model: "gpt-4o-mini",
stream: true,
messages: [
...messages,
{ role: "assistant", tool_call_id: callObj.id, content: null },
{
role: "tool",
tool_call_id: callObj.id,
content: JSON.stringify(result)
}
]
});
/* stream the follow-up answer */
for await (const follow of followStream) {
const txt = follow.choices?.[0]?.delta?.content;
if (txt) res.write(txt);
}
res.end();
return; // ✔ done
} }
});
} catch (err) { /* 2⃣ normal text tokens */
console.error("/api/chat/free error:", err); if (!delta.tool_calls && delta.content) {
res.status(500).json({ error: "Internal server error" }); res.write(delta.content); // SSE-safe
} }
} // ← closes the for-await loop
res.end(); // finished without tools
} catch (err) { // ← closes the try block above
console.error("/api/chat/free error:", err);
if (!res.headersSent) {
res.status(500).json({ error: "Internal server error" });
}
} }
); } // ← closes the async (req,res) => { … }
} ); // ← closes app.post(…)
} // ← closes export default chatFreeEndpoint

327
package-lock.json generated
View File

@ -7,6 +7,7 @@
"": { "": {
"name": "aptiva-dev1-app", "name": "aptiva-dev1-app",
"version": "0.1.0", "version": "0.1.0",
"hasInstallScript": true,
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@radix-ui/react-dialog": "^1.0.0", "@radix-ui/react-dialog": "^1.0.0",
@ -38,7 +39,7 @@
"multer": "^1.4.5-lts.2", "multer": "^1.4.5-lts.2",
"mysql2": "^3.14.1", "mysql2": "^3.14.1",
"node-cron": "^4.1.0", "node-cron": "^4.1.0",
"openai": "^4.97.0", "openai": "^4.104.0",
"pdf-parse": "^1.1.1", "pdf-parse": "^1.1.1",
"pdfjs-dist": "^3.11.174", "pdfjs-dist": "^3.11.174",
"pluralize": "^8.0.0", "pluralize": "^8.0.0",
@ -60,10 +61,12 @@
"xlsx": "^0.18.5" "xlsx": "^0.18.5"
}, },
"devDependencies": { "devDependencies": {
"@babel/parser": "^7.28.0",
"@babel/plugin-proposal-private-property-in-object": "^7.21.11", "@babel/plugin-proposal-private-property-in-object": "^7.21.11",
"@tailwindcss/forms": "^0.5.10", "@tailwindcss/forms": "^0.5.10",
"@tailwindcss/typography": "^0.5.16", "@tailwindcss/typography": "^0.5.16",
"autoprefixer": "^10.4.21", "autoprefixer": "^10.4.21",
"glob": "^11.0.3",
"postcss": "^8.5.3", "postcss": "^8.5.3",
"tailwindcss": "^3.4.17" "tailwindcss": "^3.4.17"
} }
@ -437,12 +440,12 @@
} }
}, },
"node_modules/@babel/parser": { "node_modules/@babel/parser": {
"version": "7.27.7", "version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.7.tgz", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.0.tgz",
"integrity": "sha512-qnzXzDXdr/po3bOTbTIQZ7+TxNKxpkN5IifVLXS+r7qwynkZfPyjZfE7hCXbo7IoO9TNcSyibgONsf2HauUd3Q==", "integrity": "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/types": "^7.27.7" "@babel/types": "^7.28.0"
}, },
"bin": { "bin": {
"parser": "bin/babel-parser.js" "parser": "bin/babel-parser.js"
@ -2049,9 +2052,9 @@
} }
}, },
"node_modules/@babel/types": { "node_modules/@babel/types": {
"version": "7.27.7", "version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.7.tgz", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.0.tgz",
"integrity": "sha512-8OLQgDScAOHXnAz2cV+RfzzNMipuLVBz2biuAJFMV9bfkNf393je3VM8CLkjQodW5+iWsSJdSgSWT6rsZoXHPw==", "integrity": "sha512-jYnje+JyZG5YThjHiF28oT4SIZLnYOcSBb6+SDaFIyzDVSkXQmQQYclJ2R+YxcdmK0AX6x1E5OQNtuh3jHDrUg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/helper-string-parser": "^7.27.1", "@babel/helper-string-parser": "^7.27.1",
@ -2499,6 +2502,29 @@
"deprecated": "Use @eslint/object-schema instead", "deprecated": "Use @eslint/object-schema instead",
"license": "BSD-3-Clause" "license": "BSD-3-Clause"
}, },
"node_modules/@isaacs/balanced-match": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz",
"integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/@isaacs/brace-expansion": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz",
"integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@isaacs/balanced-match": "^4.0.1"
},
"engines": {
"node": "20 || >=22"
}
},
"node_modules/@isaacs/cliui": { "node_modules/@isaacs/cliui": {
"version": "8.0.2", "version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
@ -2777,6 +2803,27 @@
} }
} }
}, },
"node_modules/@jest/reporters/node_modules/glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
"deprecated": "Glob versions prior to v9 are no longer supported",
"license": "ISC",
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.1.1",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
},
"engines": {
"node": "*"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@jest/reporters/node_modules/source-map": { "node_modules/@jest/reporters/node_modules/source-map": {
"version": "0.6.1", "version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
@ -6196,6 +6243,28 @@
"node": ">= 10" "node": ">= 10"
} }
}, },
"node_modules/cacache/node_modules/glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
"deprecated": "Glob versions prior to v9 are no longer supported",
"license": "ISC",
"optional": true,
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.1.1",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
},
"engines": {
"node": "*"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/cacache/node_modules/lru-cache": { "node_modules/cacache/node_modules/lru-cache": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
@ -9588,6 +9657,27 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/fork-ts-checker-webpack-plugin/node_modules/glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
"deprecated": "Glob versions prior to v9 are no longer supported",
"license": "ISC",
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.1.1",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
},
"engines": {
"node": "*"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/fork-ts-checker-webpack-plugin/node_modules/schema-utils": { "node_modules/fork-ts-checker-webpack-plugin/node_modules/schema-utils": {
"version": "2.7.0", "version": "2.7.0",
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.7.0.tgz", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.7.0.tgz",
@ -9952,21 +10042,24 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/glob": { "node_modules/glob": {
"version": "7.2.3", "version": "11.0.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "resolved": "https://registry.npmjs.org/glob/-/glob-11.0.3.tgz",
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", "integrity": "sha512-2Nim7dha1KVkaiF4q6Dj+ngPPMdfvLJEOpZk/jKiUAkqKebpGAWQXAq9z1xu9HKu5lWfqw/FASuccEjyznjPaA==",
"deprecated": "Glob versions prior to v9 are no longer supported", "dev": true,
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"fs.realpath": "^1.0.0", "foreground-child": "^3.3.1",
"inflight": "^1.0.4", "jackspeak": "^4.1.1",
"inherits": "2", "minimatch": "^10.0.3",
"minimatch": "^3.1.1", "minipass": "^7.1.2",
"once": "^1.3.0", "package-json-from-dist": "^1.0.0",
"path-is-absolute": "^1.0.0" "path-scurry": "^2.0.0"
},
"bin": {
"glob": "dist/esm/bin.mjs"
}, },
"engines": { "engines": {
"node": "*" "node": "20 || >=22"
}, },
"funding": { "funding": {
"url": "https://github.com/sponsors/isaacs" "url": "https://github.com/sponsors/isaacs"
@ -9990,6 +10083,75 @@
"integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==",
"license": "BSD-2-Clause" "license": "BSD-2-Clause"
}, },
"node_modules/glob/node_modules/jackspeak": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.1.1.tgz",
"integrity": "sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"@isaacs/cliui": "^8.0.2"
},
"engines": {
"node": "20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/glob/node_modules/lru-cache": {
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.1.0.tgz",
"integrity": "sha512-QIXZUBJUx+2zHUdQujWejBkcD9+cs94tLn0+YL8UrCh+D5sCXZ4c7LaEH48pNwRY3MLDgqUFyhlCyjJPf1WP0A==",
"dev": true,
"license": "ISC",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/glob/node_modules/minimatch": {
"version": "10.0.3",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.3.tgz",
"integrity": "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw==",
"dev": true,
"license": "ISC",
"dependencies": {
"@isaacs/brace-expansion": "^5.0.0"
},
"engines": {
"node": "20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/glob/node_modules/minipass": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
"integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/glob/node_modules/path-scurry": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.0.tgz",
"integrity": "sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"lru-cache": "^11.0.0",
"minipass": "^7.1.2"
},
"engines": {
"node": "20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/global-modules": { "node_modules/global-modules": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/global-modules/-/global-modules-2.0.0.tgz", "resolved": "https://registry.npmjs.org/global-modules/-/global-modules-2.0.0.tgz",
@ -11553,6 +11715,27 @@
} }
} }
}, },
"node_modules/jest-config/node_modules/glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
"deprecated": "Glob versions prior to v9 are no longer supported",
"license": "ISC",
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.1.1",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
},
"engines": {
"node": "*"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/jest-diff": { "node_modules/jest-diff": {
"version": "27.5.1", "version": "27.5.1",
"resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-27.5.1.tgz", "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-27.5.1.tgz",
@ -11881,6 +12064,27 @@
"node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
} }
}, },
"node_modules/jest-runtime/node_modules/glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
"deprecated": "Glob versions prior to v9 are no longer supported",
"license": "ISC",
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.1.1",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
},
"engines": {
"node": "*"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/jest-serializer": { "node_modules/jest-serializer": {
"version": "27.5.1", "version": "27.5.1",
"resolved": "https://registry.npmjs.org/jest-serializer/-/jest-serializer-27.5.1.tgz", "resolved": "https://registry.npmjs.org/jest-serializer/-/jest-serializer-27.5.1.tgz",
@ -13713,6 +13917,28 @@
"node": "^12.13.0 || ^14.15.0 || >=16.0.0" "node": "^12.13.0 || ^14.15.0 || >=16.0.0"
} }
}, },
"node_modules/node-gyp/node_modules/glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
"deprecated": "Glob versions prior to v9 are no longer supported",
"license": "ISC",
"optional": true,
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.1.1",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
},
"engines": {
"node": "*"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/node-gyp/node_modules/npmlog": { "node_modules/node-gyp/node_modules/npmlog": {
"version": "6.0.2", "version": "6.0.2",
"resolved": "https://registry.npmjs.org/npmlog/-/npmlog-6.0.2.tgz", "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-6.0.2.tgz",
@ -17084,6 +17310,27 @@
"url": "https://github.com/sponsors/isaacs" "url": "https://github.com/sponsors/isaacs"
} }
}, },
"node_modules/rimraf/node_modules/glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
"deprecated": "Glob versions prior to v9 are no longer supported",
"license": "ISC",
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.1.1",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
},
"engines": {
"node": "*"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/rollup": { "node_modules/rollup": {
"version": "2.79.2", "version": "2.79.2",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.2.tgz", "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.2.tgz",
@ -19087,6 +19334,27 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/test-exclude/node_modules/glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
"deprecated": "Glob versions prior to v9 are no longer supported",
"license": "ISC",
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.1.1",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
},
"engines": {
"node": "*"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/text-table": { "node_modules/text-table": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
@ -20353,6 +20621,27 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/workbox-build/node_modules/glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
"deprecated": "Glob versions prior to v9 are no longer supported",
"license": "ISC",
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.1.1",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
},
"engines": {
"node": "*"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/workbox-build/node_modules/json-schema-traverse": { "node_modules/workbox-build/node_modules/json-schema-traverse": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",

View File

@ -33,7 +33,7 @@
"multer": "^1.4.5-lts.2", "multer": "^1.4.5-lts.2",
"mysql2": "^3.14.1", "mysql2": "^3.14.1",
"node-cron": "^4.1.0", "node-cron": "^4.1.0",
"openai": "^4.97.0", "openai": "^4.104.0",
"pdf-parse": "^1.1.1", "pdf-parse": "^1.1.1",
"pdfjs-dist": "^3.11.174", "pdfjs-dist": "^3.11.174",
"pluralize": "^8.0.0", "pluralize": "^8.0.0",
@ -58,7 +58,9 @@
"start": "react-scripts start --host 0.0.0.0", "start": "react-scripts start --host 0.0.0.0",
"build": "react-scripts build", "build": "react-scripts build",
"test": "react-scripts test", "test": "react-scripts test",
"eject": "react-scripts eject" "eject": "react-scripts eject",
"gen:tools": "node scripts/genTools.cjs",
"postinstall": "npm run gen:tools"
}, },
"eslintConfig": { "eslintConfig": {
"extends": [ "extends": [
@ -84,10 +86,12 @@
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"devDependencies": { "devDependencies": {
"@babel/parser": "^7.28.0",
"@babel/plugin-proposal-private-property-in-object": "^7.21.11", "@babel/plugin-proposal-private-property-in-object": "^7.21.11",
"@tailwindcss/forms": "^0.5.10", "@tailwindcss/forms": "^0.5.10",
"@tailwindcss/typography": "^0.5.16", "@tailwindcss/typography": "^0.5.16",
"autoprefixer": "^10.4.21", "autoprefixer": "^10.4.21",
"glob": "^11.0.3",
"postcss": "^8.5.3", "postcss": "^8.5.3",
"tailwindcss": "^3.4.17" "tailwindcss": "^3.4.17"
} }

40
scripts/genTools.cjs Normal file
View File

@ -0,0 +1,40 @@
/* Generates:
* src/assets/botTools.json
* src/assets/pageToolMap.json
*
* It scans backend/server*.js for comment blocks like:
* /** @aiTool { "name": "...", "pages": ["CareerExplorer"], ... } *\/
*/
const fs = require('fs');
const glob = require('glob');
const { parse } = require('@babel/parser');
const files = glob.sync('backend/server*.js'); // adjust if server paths differ
const tools = [];
const pageToolMap = {};
for (const file of files) {
const code = fs.readFileSync(file, 'utf8');
const ast = parse(code, { sourceType: 'module', plugins: ['jsx'] });
(ast.comments || []).forEach(c => {
if (!c.value.includes('@aiTool')) return;
const meta = JSON.parse(c.value.replace(/^[\s\S]*@aiTool\s*/, '').trim());
tools.push({
name: meta.name,
description: meta.description,
parameters: meta.parameters,
});
(meta.pages || ['Global']).forEach(p =>
(pageToolMap[p] ||= []).push(meta.name)
);
});
}
fs.mkdirSync('src/assets', { recursive: true });
fs.writeFileSync('src/assets/botTools.json', JSON.stringify(tools, null, 2));
fs.writeFileSync('src/assets/pageToolMap.json', JSON.stringify(pageToolMap, null, 2));
console.log('✅ botTools.json & pageToolMap.json generated');

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useMemo } from 'react';
import { import {
Routes, Routes,
Route, Route,
@ -32,15 +32,43 @@ import RetirementPlanner from './components/RetirementPlanner.js';
import ResumeRewrite from './components/ResumeRewrite.js'; import ResumeRewrite from './components/ResumeRewrite.js';
import LoanRepaymentPage from './components/LoanRepaymentPage.js'; import LoanRepaymentPage from './components/LoanRepaymentPage.js';
import usePageContext from './utils/usePageContext.js'; import usePageContext from './utils/usePageContext.js';
import ChatDrawer from './components/ChatDrawer.js';
export const ProfileCtx = React.createContext(); export const ProfileCtx = React.createContext();
function App() { function App() {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
usePageContext(); const pageContext = usePageContext();
/* ------------------------------------------
ChatDrawer route-aware tool handlers
------------------------------------------ */
const uiToolHandlers = useMemo(() => {
if (pageContext === "CareerExplorer") {
return {
// __tool:addCareerToComparison:{"socCode":"15-2051","careerName":"Data Scientist"}
addCareerToComparison: ({ socCode, careerName }) => {
console.log('[dispatch]', socCode, careerName);
window.dispatchEvent(
new CustomEvent("add-career", { detail: { socCode, careerName } })
);
},
// __tool:openCareerModal:{"socCode":"15-2051"}
openCareerModal: ({ socCode }) => {
window.dispatchEvent(
new CustomEvent("open-career", { detail: { socCode } })
);
}
};
}
return {}; // every other page exposes no UI tools
}, [pageContext]);
// Auth states // Auth states
const [isAuthenticated, setIsAuthenticated] = useState(false); const [isAuthenticated, setIsAuthenticated] = useState(false);
@ -523,6 +551,11 @@ function App() {
</Routes> </Routes>
</main> </main>
<ChatDrawer
pageContext={pageContext}
uiToolHandlers={uiToolHandlers}
/>
{/* Session Handler (Optional) */} {/* Session Handler (Optional) */}
<SessionExpiredHandler /> <SessionExpiredHandler />
</div> </div>

130
src/assets/botTools.json Normal file
View File

@ -0,0 +1,130 @@
[
{
"name": "getDistanceInMiles",
"description": "Return driving distance and duration between the user ZIP and destination(s)",
"parameters": {
"type": "object",
"properties": {
"userZipcode": {
"type": "string"
},
"destinations": {
"type": "string",
"description": "Pipe-separated lat,lng pairs or addresses"
}
},
"required": [
"userZipcode",
"destinations"
]
}
},
{
"name": "getSchoolsForCIPs",
"description": "Return a list of schools whose CIP codes match the supplied prefixes in the given state",
"parameters": {
"type": "object",
"properties": {
"cipCodes": {
"type": "string",
"description": "Comma-separated CIP prefixes, e.g. \"1101,1103\""
},
"state": {
"type": "string",
"description": "Two-letter state abbreviation, e.g. \"GA\""
}
},
"required": [
"cipCodes",
"state"
]
}
},
{
"name": "addCareerToComparison",
"description": "Add the career with the given SOC code to the comparison table in Career Explorer",
"parameters": {
"type": "object",
"properties": {
"socCode": {
"type": "string",
"description": "Full O*NET SOC code, e.g. \"15-2051\""
}
},
"required": ["socCode"]
}
},
{
"name": "openCareerModal",
"description": "Open the career-details modal for the given SOC code in Career Explorer",
"parameters": {
"type": "object",
"properties": {
"socCode": {
"type": "string",
"description": "Full O*NET SOC code, e.g. \"15-2051\""
}
},
"required": ["socCode"]
}
},
{
"name": "getTuitionForCIPs",
"description": "Return in-state / out-state tuition rows for schools matching CIP prefixes in a given state",
"parameters": {
"type": "object",
"properties": {
"cipCodes": {
"type": "string",
"description": "Comma-separated prefixes, e.g. \"1101,1103\""
},
"state": {
"type": "string",
"description": "Two-letter state code, e.g. \"GA\""
}
},
"required": [
"cipCodes",
"state"
]
}
},
{
"name": "getEconomicProjections",
"description": "Return state and national employment projections for a SOC code",
"parameters": {
"type": "object",
"properties": {
"socCode": {
"type": "string"
},
"state": {
"type": "string",
"description": "Optional state abbreviation"
}
},
"required": [
"socCode"
]
}
},
{
"name": "getSalaryData",
"description": "Return residential area and national salary percentiles for a SOC code",
"parameters": {
"type": "object",
"properties": {
"socCode": {
"type": "string"
},
"area": {
"type": "string",
"description": "User's residential area"
}
},
"required": [
"socCode"
]
}
}
]

View File

@ -0,0 +1,17 @@
{
"EducationalProgramsPage": [
"getDistanceInMiles",
"getSchoolsForCIPs",
"getTuitionForCIPs"
],
"LoanRepayment": [
"getDistanceInMiles",
"getTuitionForCIPs"
],
"CareerExplorer": [
"getEconomicProjections",
"getSalaryData",
"addCareerToComparison",
"openCareerModal"
]
}

View File

@ -231,7 +231,6 @@ function CareerExplorer() {
} }
}; };
// -------------------------------------- // --------------------------------------
// On mount, load suggestions from cache // On mount, load suggestions from cache
// -------------------------------------- // --------------------------------------
@ -763,6 +762,49 @@ const handleSelectForEducation = (career) => {
return weightMap[priority][response] || 1; return weightMap[priority][response] || 1;
}; };
useEffect(() => {
/* ---------- add-to-comparison ---------- */
const onAdd = (e) => {
console.log('[onAdd] detail →', e.detail);
const { socCode, careerName } = e.detail || {};
if (!socCode) {
console.warn('[add-career] missing socCode aborting');
return;
}
// 1. see if the career is already in the filtered list
let career = filteredCareers.find((c) => c.code === socCode);
// 2. if not, make a stub so the list can still save
if (!career) {
career = {
code : socCode,
title: careerName || '(name unavailable)',
fit : 'Good',
};
}
// 3. push it into the comparison table
addCareerToList(career);
};
/* ---------- open-modal ---------- */
const onOpen = (e) => {
const { socCode } = e.detail || {};
if (!socCode) return;
const career = filteredCareers.find((c) => c.code === socCode);
if (career) handleCareerClick(career);
};
window.addEventListener('add-career', onAdd);
window.addEventListener('open-career', onOpen);
return () => {
window.removeEventListener('add-career', onAdd);
window.removeEventListener('open-career', onOpen);
};
}, [filteredCareers, addCareerToList, handleCareerClick]);
// ------------------------------------------------------ // ------------------------------------------------------
// Loading Overlay // Loading Overlay
// ------------------------------------------------------ // ------------------------------------------------------
@ -1017,11 +1059,6 @@ const explorerSnapshot = useMemo(() => {
defaultMeaning={modalData.defaultMeaning} defaultMeaning={modalData.defaultMeaning}
/> />
<ChatDrawer
pageContext="explorer"
snapshot={explorerSnapshot} // the JSON youre already building
/>
{selectedCareer && ( {selectedCareer && (
<CareerModal <CareerModal
career={selectedCareer} career={selectedCareer}
@ -1035,35 +1072,47 @@ const explorerSnapshot = useMemo(() => {
)} )}
<div className="mt-6 text-xs text-gray-500 border-t pt-2"> <div className="mt-6 text-xs text-gray-500 border-t pt-2">
Career results and details provided by This page includes information from&nbsp;
<a <a
href="https://www.onetcenter.org" href="https://www.onetcenter.org"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
> className="underline"
{' '} >
O*Net O*NET OnLine
</a> </a>
, in partnership with &nbsp;by the U.S. Department of Labor, Employment & Training Administration
<a (USDOL/ETA). Used under the&nbsp;
href="https://www.bls.gov" <a
target="_blank" href="https://creativecommons.org/licenses/by/4.0/"
rel="noopener noreferrer" target="_blank"
> rel="noopener noreferrer"
{' '} className="underline"
Bureau of Labor Statistics >
</a> CC&nbsp;BY&nbsp;4.0 license
and </a>
<a . **O*NET®** is a trademark of USDOL/ETA. Salary and employment data are
href="https://nces.ed.gov" enriched with resources from the&nbsp;
target="_blank" <a
rel="noopener noreferrer" href="https://www.bls.gov"
> target="_blank"
{' '} rel="noopener noreferrer"
NCES className="underline"
</a> >
. Bureau of Labor Statistics
</div> </a>
&nbsp;and program information from the&nbsp;
<a
href="https://nces.ed.gov"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
National Center for Education Statistics
</a>
.
</div>
</div> </div>
); );
} }

View File

@ -1,141 +1,196 @@
// ────────────────────────────────── 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 { Card, CardContent } from "./ui/card.js";
import { Button } from "./ui/button.js"; import { Button } from "./ui/button.js";
import { Input } from "./ui/input.js"; import { Input } from "./ui/input.js";
import { MessageCircle } from "lucide-react"; import { MessageCircle } from "lucide-react";
/** /* ---------------------------------------------------------------
* ChatDrawer Aptiva freetier assistant (pure **JavaScript** version) Streams from /api/chat/free and executes UI-tool callbacks
* ----------------------------------------------------------------*/
* Floating FAB slideout Sheet. export default function ChatDrawer({
* Streams SSE from `/api/chat/free`. pageContext = "Home",
* JWT is read from `localStorage('token')` (same key CareerExplorer uses). snapshot = {},
*/ uiToolHandlers = {} // e.g. { addCareerToComparison, openCareerModal }
export default function ChatDrawer({ pageContext = "explorer", snapshot = {} }) { }) {
// ──────────────────────────────────────────────────────────────── state /* state */
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [prompt, setPrompt] = useState(""); const [prompt, setPrompt] = useState("");
const [messages, setMessages] = useState([]); // { role, content } const [messages, setMessages] = useState([]); // { role, content }
const listRef = useRef(null); const listRef = useRef(null);
// ─────────────────────────────────────────────────────────── autoscroll console.log("CHATDRAWER-BUILD-TAG-2025-07-02");
/* auto-scroll */
useEffect(() => { useEffect(() => {
if (listRef.current) { listRef.current &&
listRef.current.scrollTop = listRef.current.scrollHeight; (listRef.current.scrollTop = listRef.current.scrollHeight);
}
}, [messages]); }, [messages]);
// ─────────────────────────────────────────────── helper: append chunk /* helper: stream-friendly append */
function appendAssistantChunk(chunk) { const pushAssistant = (chunk) =>
setMessages((prev) => { setMessages((prev) => {
const last = prev[prev.length - 1]; const last = prev.at(-1);
if (last && last.role === "assistant") { if (last?.role === "assistant") {
// mutate copy React will rerender because we return new array
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;
} }
// first assistant chunk → push new msg
return [...prev, { role: "assistant", content: chunk }]; return [...prev, { role: "assistant", content: chunk }];
}); });
}
// ───────────────────────────────────────────────────────── send prompt /* send prompt */
async function sendPrompt() { async function sendPrompt() {
const text = prompt.trim(); const text = prompt.trim();
if (!text) return; if (!text) return;
// optimistic user message
setMessages((m) => [...m, { role: "user", content: text }]); setMessages((m) => [...m, { role: "user", content: text }]);
setPrompt(""); setPrompt("");
const token = localStorage.getItem("token") || ""; const body = JSON.stringify({
prompt: text,
pageContext,
chatHistory: messages,
snapshot
});
try { try {
const token = localStorage.getItem("token") || "";
const headers = {
"Content-Type": "application/json",
Accept : "text/event-stream",
...(token ? { Authorization: `Bearer ${token}` } : {})
};
const resp = await fetch("/api/chat/free", { const resp = await fetch("/api/chat/free", {
method: "POST", method: "POST",
headers: { headers,
"Content-Type": "application/json", body
"Accept": "text/event-stream",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({
prompt: text,
pageContext,
chatHistory: messages,
snapshot,
}),
}); });
if (!resp.ok || !resp.body) throw new Error(`HTTP ${resp.status}`);
if (!resp.ok || !resp.body) { const reader = resp.body.getReader();
throw new Error(`Request failed ${resp.status}`);
}
// stream the response
const reader = resp.body.getReader();
const decoder = new TextDecoder(); const decoder = new TextDecoder();
let done = false; let buf = "";
while (!done) {
const { value, done: doneReading } = await reader.read(); while (true) {
done = doneReading; const { value, done } = await reader.read();
if (value) { if (done) break;
const chunk = decoder.decode(value); if (!value) continue;
appendAssistantChunk(chunk);
const chunk = decoder.decode(value);
buf += decoder.decode(value);
/* 1⃣ process every complete “__tool:” line in the buffer */
for (const lineRaw of chunk.split(/\n/)) { // ← NEW
const line = lineRaw.trim();
if (!line.startsWith("__tool:")) continue;
const firstColon = line.indexOf(":", 7);
const name = line.slice(7, firstColon).trim();
const argsJson = line.slice(firstColon + 1).trim();
let args = {};
try { args = JSON.parse(argsJson); } catch {/* keep {} */}
const fn = uiToolHandlers[name];
if (typeof fn === "function") {
try {
await fn(args);
pushAssistant(`\n${name} completed.\n`);
} catch (err) {
console.error("[uiTool handler]", err);
pushAssistant(`\nSorry couldnt complete ${name}.\n`);
}
} else {
console.warn("No uiToolHandler for", name);
pushAssistant(`\n(UI handler “${name}” isnt wired on this page.)\n`);
}
return; // finished processing this chunk
}
/* 2⃣ legacy JSON tool payload _________________________ */
let json;
try { json = JSON.parse(chunk); } catch {/* not JSON */ }
if (json && json.uiTool) {
const fn = uiToolHandlers[json.uiTool];
if (typeof fn === "function") {
try {
await fn(JSON.parse(json.args || "{}"));
pushAssistant(`\n${json.uiTool} completed.\n`);
} catch (err) {
console.error("[uiTool handler]", err);
pushAssistant(`\nSorry couldnt complete ${json.uiTool}.\n`);
}
} else {
console.warn("No uiToolHandler for", json.uiTool);
pushAssistant(
`\n(UI handler “${json.uiTool}” is not wired in the page.)\n`
);
}
} else {
/* plain assistant text */
pushAssistant(chunk);
} }
} }
} catch (err) { } catch (err) {
console.error("[ChatDrawer] error", err); console.error("[ChatDrawer] stream error", err);
appendAssistantChunk("Sorry something went wrong. Try again later."); pushAssistant("Sorry — something went wrong. Please try again later.");
} }
} }
// ────────────────────────────────────────────────────── key handler /* Enter submits */
function handleKeyDown(e) { const handleKeyDown = (e) => {
if (e.key === "Enter" && !e.shiftKey) { if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault(); e.preventDefault();
sendPrompt(); sendPrompt();
} }
} };
// ─────────────────────────────────────────────────────────── render /* UI */
return ( return (
<Sheet open={open} onOpenChange={setOpen}> <Sheet open={open} onOpenChange={setOpen}>
{/* Floating action button */} {/* floating button */}
<SheetTrigger> <SheetTrigger>
<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"
> >
<MessageCircle size={24} /> <MessageCircle size={24} />
</button> </button>
</SheetTrigger> </SheetTrigger>
{/* Drawer */} {/* 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]"
> >
{/* header */}
<div className="sticky top-0 z-10 border-b bg-white px-4 py-3 font-semibold"> <div className="sticky top-0 z-10 border-b bg-white px-4 py-3 font-semibold">
{pageContext === "explorer" ? "Career Explorer Guide" : "Programs Guide"} {pageContext}
</div> </div>
{/* messages list */} {/* transcript */}
<div ref={listRef} className="flex-1 space-y-4 overflow-y-auto px-4 py-2 text-sm"> <div
ref={listRef}
className="flex-1 space-y-4 overflow-y-auto px-4 py-2 text-sm"
>
{messages.map((m, i) => ( {messages.map((m, i) => (
<div <div
key={i} key={i}
className={m.role === "user" ? "text-right" : "text-left text-gray-800"} className={
m.role === "user" ? "text-right" : "text-left text-gray-800"
}
> >
{m.content} {m.content}
</div> </div>
))} ))}
</div> </div>
{/* input */} {/* prompt box */}
<div className="border-t p-4"> <div className="border-t p-4">
<form <form
onSubmit={(e) => { onSubmit={(e) => {

View File

@ -1,15 +1,24 @@
import { useEffect } from 'react'; // src/utils/usePageContext.js
import { useLocation } from 'react-router-dom'; import { useEffect, useState } from "react";
import { sendSystemMessage } from '../utils/chatApi.js'; // you already call this for Jess import { useLocation } from "react-router-dom";
export default function usePageContext () { /* route → page-key map */
const routeMap = [
{ test: p => p.startsWith("/career-explorer"), page: "CareerExplorer" },
{ test: p => p.startsWith("/educational-programs"), page: "EducationalProgramsPage" },
{ test: p => p.startsWith("/career-roadmap"), page: "CareerRoadmap" },
{ test: p => p.startsWith("/retirement"), page: "RetirementPlanner" },
{ test: p => p.startsWith("/resume-rewrite"), page: "ResumeRewrite" },
];
export default function usePageContext() {
const { pathname } = useLocation(); const { pathname } = useLocation();
const [page, setPage] = useState("Home");
useEffect(() => { useEffect(() => {
const page = pathname.split('/')[1] || 'Home'; const found = routeMap.find(r => r.test(pathname));
const featureFlags = window.__APTIVA_FLAGS__ || []; setPage(found ? found.page : "Home");
// invisible system message for the adaptive assistant
sendSystemMessage('page_context', { page, featureFlags });
}, [pathname]); }, [pathname]);
return page; // ← hook now RETURNS the page string
} }

Binary file not shown.