AI Support bot created.
This commit is contained in:
parent
4ca8d11156
commit
2160f20f93
14
backend/data/faqs.json
Normal file
14
backend/data/faqs.json
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"q": "How do I reset my password?",
|
||||||
|
"a": "Click **Forgot Password** on the sign-in screen. Enter your e-mail and follow the link we send."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "Why can’t I see any careers in the list?",
|
||||||
|
"a": "Make sure you’ve finished the Interest Inventory and set at least one career-priority filter."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "What browsers are supported?",
|
||||||
|
"a": "Chrome, Edge, Safari, and Firefox (latest two versions). Mobile Safari is fully supported."
|
||||||
|
}
|
||||||
|
]
|
@ -14,6 +14,12 @@ import { open } from 'sqlite';
|
|||||||
import sqlite3 from 'sqlite3';
|
import sqlite3 from 'sqlite3';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import readline from 'readline';
|
import readline from 'readline';
|
||||||
|
import chatFreeEndpoint from "./utils/chatFreeEndpoint.js";
|
||||||
|
import { OpenAI } from 'openai';
|
||||||
|
import rateLimit from 'express-rate-limit';
|
||||||
|
import authenticateUser from './utils/authenticateUser.js';
|
||||||
|
import { vectorSearch } from "./utils/vectorSearch.js";
|
||||||
|
|
||||||
|
|
||||||
// --- Basic file init ---
|
// --- Basic file init ---
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
@ -24,6 +30,14 @@ const env = process.env.NODE_ENV?.trim() || 'development';
|
|||||||
const envPath = path.resolve(rootPath, `.env.${env}`);
|
const envPath = path.resolve(rootPath, `.env.${env}`);
|
||||||
dotenv.config({ path: envPath }); // Load .env
|
dotenv.config({ path: envPath }); // Load .env
|
||||||
|
|
||||||
|
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
|
||||||
|
|
||||||
|
const chatLimiter = rateLimit({
|
||||||
|
windowMs: 60 * 60 * 1000,
|
||||||
|
max: 20,
|
||||||
|
keyGenerator: req => req.user?.id || req.ip
|
||||||
|
});
|
||||||
|
|
||||||
// Whitelist CORS
|
// Whitelist CORS
|
||||||
const allowedOrigins = [
|
const allowedOrigins = [
|
||||||
'http://localhost:3000',
|
'http://localhost:3000',
|
||||||
@ -883,6 +897,7 @@ app.post('/api/ai-risk', async (req, res) => {
|
|||||||
jobDescription,
|
jobDescription,
|
||||||
tasks,
|
tasks,
|
||||||
riskLevel,
|
riskLevel,
|
||||||
|
vectorSearch,
|
||||||
reasoning,
|
reasoning,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -893,6 +908,13 @@ app.post('/api/ai-risk', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
chatFreeEndpoint(app, {
|
||||||
|
openai,
|
||||||
|
authenticateUser, // or omit if you don’t need auth yet
|
||||||
|
chatLimiter,
|
||||||
|
userProfileDb
|
||||||
|
});
|
||||||
|
|
||||||
/**************************************************
|
/**************************************************
|
||||||
* Start the Express server
|
* Start the Express server
|
||||||
**************************************************/
|
**************************************************/
|
||||||
|
0
backend/user_profile.db
Normal file
0
backend/user_profile.db
Normal file
19
backend/utils/authenticateUser.js
Normal file
19
backend/utils/authenticateUser.js
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import jwt from "jsonwebtoken";
|
||||||
|
const SECRET_KEY = process.env.SECRET_KEY || "supersecurekey";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds `req.user = { id: <user_profile.id> }`
|
||||||
|
* If no or bad token ➜ 401.
|
||||||
|
*/
|
||||||
|
export default function authenticateUser(req, res, next) {
|
||||||
|
const token = req.headers.authorization?.split(" ")[1];
|
||||||
|
if (!token) return res.status(401).json({ error: "Authorization token required" });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { id } = jwt.verify(token, SECRET_KEY);
|
||||||
|
req.user = { id }; // attach the id for downstream use
|
||||||
|
next();
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(401).json({ error: "Invalid or expired token" });
|
||||||
|
}
|
||||||
|
}
|
171
backend/utils/chatFreeEndpoint.js
Normal file
171
backend/utils/chatFreeEndpoint.js
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
// utils/chatFreeEndpoint.js
|
||||||
|
import path from "path";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
import { vectorSearch } from "./vectorSearch.js";
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
const rootPath = path.resolve(__dirname, "..", ".."); // backend/
|
||||||
|
const FAQ_PATH = path.join(rootPath, "user_profile.db");
|
||||||
|
const FAQ_THRESHOLD = 0.80;
|
||||||
|
const HELP_TRIGGERS = ["help","error","bug","issue","login","password","support"];
|
||||||
|
|
||||||
|
/* ---------- helpers ---------- */
|
||||||
|
const classifyIntent = txt =>
|
||||||
|
HELP_TRIGGERS.some(k => txt.toLowerCase().includes(k)) ? "support" : "guide";
|
||||||
|
|
||||||
|
const buildContext = (user = {}, page = "", intent = "guide") => {
|
||||||
|
const { firstname = "User", career_situation = "planning" } = user;
|
||||||
|
const mode = intent === "guide" ? "Aptiva Guide" : "Aptiva Support";
|
||||||
|
return {
|
||||||
|
system:
|
||||||
|
`${mode} for page “${page}”. User: ${firstname}. Situation: ${career_situation}.` +
|
||||||
|
(intent === "support"
|
||||||
|
? " Resolve issues quickly and end with: “Let me know if that fixed it.”"
|
||||||
|
: "")
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------------------
|
||||||
|
FACTORY: registers POST /api/chat/free on the passed-in Express app
|
||||||
|
----------------------------------------------------------------------------- */
|
||||||
|
export default function chatFreeEndpoint(
|
||||||
|
app,
|
||||||
|
{
|
||||||
|
openai,
|
||||||
|
chatLimiter,
|
||||||
|
userProfileDb,
|
||||||
|
authenticateUser = (_req, _res, next) => next()
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
/* -------- support-intent tool handlers now in scope -------- */
|
||||||
|
const toolResolvers = {
|
||||||
|
async clearLocalCache() { return { status: "ok" }; },
|
||||||
|
|
||||||
|
async openTicket({ summary = "", user = {} }) {
|
||||||
|
try {
|
||||||
|
await userProfileDb.run(
|
||||||
|
`INSERT INTO support_tickets (user_id, summary, created_at)
|
||||||
|
VALUES (?,?,datetime('now'))`,
|
||||||
|
[user.id || 0, summary]
|
||||||
|
);
|
||||||
|
return { ticket: "created" };
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[openTicket] DB error:", err);
|
||||||
|
return { ticket: "error" };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async pingStatus() {
|
||||||
|
try {
|
||||||
|
await userProfileDb.get("SELECT 1");
|
||||||
|
return { status: "healthy" };
|
||||||
|
} catch {
|
||||||
|
return { status: "db_error" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const SUPPORT_TOOLS = [
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "clearLocalCache",
|
||||||
|
description: "Instruct front-end to purge its cache",
|
||||||
|
parameters: { type: "object", properties: {} }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "openTicket",
|
||||||
|
description: "Create a support ticket",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: { summary: { type: "string" } },
|
||||||
|
required: ["summary"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "pingStatus",
|
||||||
|
description: "Ping DB/health",
|
||||||
|
parameters: { type: "object", properties: {} }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
/* ----------------------------- ROUTE ----------------------------- */
|
||||||
|
app.post(
|
||||||
|
"/api/chat/free",
|
||||||
|
chatLimiter,
|
||||||
|
authenticateUser,
|
||||||
|
async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { prompt = "", chatHistory = [], pageContext = "" } = req.body || {};
|
||||||
|
if (!prompt.trim()) return res.status(400).json({ error: "Empty prompt" });
|
||||||
|
|
||||||
|
/* ---------- 0️⃣ FAQ fast-path ---------- */
|
||||||
|
let faqHit = null;
|
||||||
|
try {
|
||||||
|
const { data } = await openai.embeddings.create({
|
||||||
|
model: "text-embedding-3-small",
|
||||||
|
input: prompt
|
||||||
|
});
|
||||||
|
const hits = await vectorSearch(FAQ_PATH, data[0].embedding, 1);
|
||||||
|
if (hits.length && hits[0].score >= FAQ_THRESHOLD) faqHit = hits[0];
|
||||||
|
} catch { /* silently ignore if table/function missing */ }
|
||||||
|
|
||||||
|
if (faqHit) return res.json({ answer: faqHit.answer });
|
||||||
|
/* --------------------------------------- */
|
||||||
|
|
||||||
|
const intent = classifyIntent(prompt);
|
||||||
|
const { system } = buildContext(req.user || {}, pageContext, intent);
|
||||||
|
const tools = intent === "support" ? SUPPORT_TOOLS : [];
|
||||||
|
|
||||||
|
const messages = [
|
||||||
|
{ role:"system", content: system },
|
||||||
|
...chatHistory,
|
||||||
|
{ role:"user", content: prompt }
|
||||||
|
];
|
||||||
|
|
||||||
|
const chatStream = await openai.chat.completions.create({
|
||||||
|
model : "gpt-4o-mini",
|
||||||
|
stream : true,
|
||||||
|
messages,
|
||||||
|
tools,
|
||||||
|
tool_choice : tools.length ? "auto" : undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ---------- SSE headers ---------- */
|
||||||
|
res.setHeader("Content-Type", "text/event-stream");
|
||||||
|
res.setHeader("Cache-Control", "no-cache");
|
||||||
|
res.setHeader("Connection", "keep-alive");
|
||||||
|
|
||||||
|
for await (const part of chatStream) {
|
||||||
|
const delta = part.choices?.[0]?.delta || {};
|
||||||
|
const chunk = delta.content || "";
|
||||||
|
if (chunk) res.write(chunk); // ← plain text, no “data:” prefix
|
||||||
|
}
|
||||||
|
res.end();
|
||||||
|
|
||||||
|
/* ---------- tool calls ---------- */
|
||||||
|
chatStream.on("tool", async call => {
|
||||||
|
const fn = toolResolvers[call.name];
|
||||||
|
if (!fn) return;
|
||||||
|
try {
|
||||||
|
const args = JSON.parse(call.arguments || "{}");
|
||||||
|
await fn({ ...args, user: req.user || {} });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[tool resolver]", err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("/api/chat/free error:", err);
|
||||||
|
res.status(500).json({ error: "Internal server error" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
36
backend/utils/seedFaq.js
Normal file
36
backend/utils/seedFaq.js
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import { readFileSync } from "fs";
|
||||||
|
import { OpenAI } from "openai";
|
||||||
|
import sqlite3 from "sqlite3";
|
||||||
|
import { open } from "sqlite";
|
||||||
|
import dotenv from "dotenv";
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
// --- Basic file init ---
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
const rootPath = path.resolve(__dirname, "../..");
|
||||||
|
const env = process.env.NODE_ENV?.trim() || "development";
|
||||||
|
dotenv.config({ path: path.resolve(rootPath, `.env.${env}`) });
|
||||||
|
|
||||||
|
const faqPath = path.resolve(rootPath, "backend", "data", "faqs.json");
|
||||||
|
const faqs = JSON.parse(readFileSync(faqPath, "utf8"));
|
||||||
|
|
||||||
|
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
|
||||||
|
|
||||||
|
const dbPath = path.resolve(rootPath, "user_profile.db");
|
||||||
|
const db = await open({ filename: dbPath, driver: sqlite3.Database });
|
||||||
|
|
||||||
|
for (const { q, a } of faqs) {
|
||||||
|
const { data } = await openai.embeddings.create({
|
||||||
|
model: "text-embedding-3-small",
|
||||||
|
input: q
|
||||||
|
});
|
||||||
|
|
||||||
|
const buf = Buffer.from(new Float32Array(data[0].embedding).buffer);
|
||||||
|
await db.run(
|
||||||
|
`INSERT INTO faq_embeddings (question,answer,embedding) VALUES (?,?,?)`,
|
||||||
|
[q, a, buf]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
console.log("Seeded FAQ embeddings");
|
38
backend/utils/vectorSearch.js
Normal file
38
backend/utils/vectorSearch.js
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
// utils/vectorSearch.js
|
||||||
|
import sqlite3 from "sqlite3";
|
||||||
|
import { open } from "sqlite";
|
||||||
|
|
||||||
|
/* ---------- small helper ---------- */
|
||||||
|
function cosineSim(a, b) {
|
||||||
|
let dot = 0, na = 0, nb = 0;
|
||||||
|
for (let i = 0; i < a.length; i++) {
|
||||||
|
dot += a[i] * b[i];
|
||||||
|
na += a[i] * a[i];
|
||||||
|
nb += b[i] * b[i];
|
||||||
|
}
|
||||||
|
return na && nb ? dot / (Math.sqrt(na) * Math.sqrt(nb)) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JS-only vector search against faq_embeddings
|
||||||
|
* @param {string} dbPath absolute path to user_profile.db
|
||||||
|
* @param {number[]} queryEmbedding the embedding array from OpenAI
|
||||||
|
* @param {number} topK how many rows to return
|
||||||
|
*/
|
||||||
|
export async function vectorSearch(dbPath, queryEmbedding, topK = 3) {
|
||||||
|
const db = await open({ filename: dbPath, driver: sqlite3.Database });
|
||||||
|
const rows = await db.all(`SELECT id, question, answer, embedding FROM faq_embeddings`);
|
||||||
|
await db.close();
|
||||||
|
|
||||||
|
const scored = rows.map(r => {
|
||||||
|
// SQLite returns Buffer → turn it back into Float32Array
|
||||||
|
const vec = new Float32Array(r.embedding.buffer,
|
||||||
|
r.embedding.byteOffset,
|
||||||
|
r.embedding.length / 4);
|
||||||
|
return { ...r, score: cosineSim(queryEmbedding, vec) };
|
||||||
|
})
|
||||||
|
.sort((a, b) => b.score - a.score) // highest similarity first
|
||||||
|
.slice(0, topK);
|
||||||
|
|
||||||
|
return scored;
|
||||||
|
}
|
16
package-lock.json
generated
16
package-lock.json
generated
@ -26,6 +26,7 @@
|
|||||||
"cra-template": "1.2.0",
|
"cra-template": "1.2.0",
|
||||||
"docx": "^9.5.0",
|
"docx": "^9.5.0",
|
||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.4.7",
|
||||||
|
"express-rate-limit": "^7.5.1",
|
||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
"fuse.js": "^7.1.0",
|
"fuse.js": "^7.1.0",
|
||||||
"helmet": "^8.0.0",
|
"helmet": "^8.0.0",
|
||||||
@ -9112,6 +9113,21 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/express-rate-limit": {
|
||||||
|
"version": "7.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz",
|
||||||
|
"integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/express-rate-limit"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"express": ">= 4.11"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/express/node_modules/cookie": {
|
"node_modules/express/node_modules/cookie": {
|
||||||
"version": "0.7.1",
|
"version": "0.7.1",
|
||||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz",
|
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz",
|
||||||
|
@ -21,6 +21,7 @@
|
|||||||
"cra-template": "1.2.0",
|
"cra-template": "1.2.0",
|
||||||
"docx": "^9.5.0",
|
"docx": "^9.5.0",
|
||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.4.7",
|
||||||
|
"express-rate-limit": "^7.5.1",
|
||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
"fuse.js": "^7.1.0",
|
"fuse.js": "^7.1.0",
|
||||||
"helmet": "^8.0.0",
|
"helmet": "^8.0.0",
|
||||||
|
@ -31,6 +31,7 @@ import OnboardingContainer from './components/PremiumOnboarding/OnboardingContai
|
|||||||
import RetirementPlanner from './components/RetirementPlanner.js';
|
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';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -39,6 +40,7 @@ export const ProfileCtx = React.createContext();
|
|||||||
function App() {
|
function App() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
usePageContext();
|
||||||
|
|
||||||
// Auth states
|
// Auth states
|
||||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||||
|
@ -6,6 +6,7 @@ import CareerPrioritiesModal from './CareerPrioritiesModal.js';
|
|||||||
import CareerModal from './CareerModal.js';
|
import CareerModal from './CareerModal.js';
|
||||||
import InterestMeaningModal from './InterestMeaningModal.js';
|
import InterestMeaningModal from './InterestMeaningModal.js';
|
||||||
import CareerSearch from './CareerSearch.js';
|
import CareerSearch from './CareerSearch.js';
|
||||||
|
import ChatDrawer from './ChatDrawer.js';
|
||||||
import { Button } from './ui/button.js';
|
import { Button } from './ui/button.js';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
|
||||||
@ -784,6 +785,38 @@ const handleSelectForEducation = (career) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* ---------- Chat-assistant snapshot ---------- */
|
||||||
|
const explorerSnapshot = useMemo(() => {
|
||||||
|
// 1) RIASEC scores (if the interest inventory has been done)
|
||||||
|
const riasecScores = userProfile?.riasec_scores
|
||||||
|
? JSON.parse(userProfile.riasec_scores) // stored as JSON in DB
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// 2) User-selected career-priority weights, normalised to 0-1
|
||||||
|
const priorityWeights = priorities
|
||||||
|
? {
|
||||||
|
stability : priorityWeight('stability' , priorities.stability) / 5,
|
||||||
|
growth : priorityWeight('growth' , priorities.growth) / 5,
|
||||||
|
balance : priorityWeight('balance' , priorities.balance) / 5,
|
||||||
|
recognition : priorityWeight('recognition', priorities.recognition)/ 5,
|
||||||
|
interests : priorityWeight('interests' , priorities.interests) / 5,
|
||||||
|
mission : priorityWeight('meaning' , priorities.meaning) / 5,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// 3) Matrix of careers currently on-screen (or in the comparison list, pick one)
|
||||||
|
const careerMatrix = filteredCareers.map(c => ({
|
||||||
|
socCode : c.code,
|
||||||
|
careerName : c.title,
|
||||||
|
// quick-and-dirty match score; replace with your own if you’ve already got one
|
||||||
|
matchScore : typeof c.matchScore === 'number'
|
||||||
|
? c.matchScore
|
||||||
|
: (fitRatingMap[c.fit] || 0) * 20 // e.g. “Best” → 100, “Good” → 60
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { riasecScores, priorityWeights, careerMatrix };
|
||||||
|
}, [userProfile, priorities, filteredCareers]);
|
||||||
|
|
||||||
// ------------------------------------------------------
|
// ------------------------------------------------------
|
||||||
// Render
|
// Render
|
||||||
// ------------------------------------------------------
|
// ------------------------------------------------------
|
||||||
@ -984,6 +1017,11 @@ const handleSelectForEducation = (career) => {
|
|||||||
defaultMeaning={modalData.defaultMeaning}
|
defaultMeaning={modalData.defaultMeaning}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ChatDrawer
|
||||||
|
pageContext="explorer"
|
||||||
|
snapshot={explorerSnapshot} // the JSON you’re already building
|
||||||
|
/>
|
||||||
|
|
||||||
{selectedCareer && (
|
{selectedCareer && (
|
||||||
<CareerModal
|
<CareerModal
|
||||||
career={selectedCareer}
|
career={selectedCareer}
|
||||||
|
162
src/components/ChatDrawer.js
Normal file
162
src/components/ChatDrawer.js
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { Sheet, SheetTrigger, SheetContent } from "./ui/sheet.js";
|
||||||
|
import { Card, CardContent } from "./ui/card.js";
|
||||||
|
import { Button } from "./ui/button.js";
|
||||||
|
import { Input } from "./ui/input.js";
|
||||||
|
import { MessageCircle } from "lucide-react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ChatDrawer – Aptiva free‑tier assistant (pure **JavaScript** version)
|
||||||
|
*
|
||||||
|
* ▸ Floating FAB → slide‑out Sheet.
|
||||||
|
* ▸ Streams SSE from `/api/chat/free`.
|
||||||
|
* ▸ JWT is read from `localStorage('token')` (same key CareerExplorer uses).
|
||||||
|
*/
|
||||||
|
export default function ChatDrawer({ pageContext = "explorer", snapshot = {} }) {
|
||||||
|
// ──────────────────────────────────────────────────────────────── state
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [prompt, setPrompt] = useState("");
|
||||||
|
const [messages, setMessages] = useState([]); // { role, content }
|
||||||
|
const listRef = useRef(null);
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────── auto‑scroll
|
||||||
|
useEffect(() => {
|
||||||
|
if (listRef.current) {
|
||||||
|
listRef.current.scrollTop = listRef.current.scrollHeight;
|
||||||
|
}
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────── helper: append chunk
|
||||||
|
function appendAssistantChunk(chunk) {
|
||||||
|
setMessages((prev) => {
|
||||||
|
const last = prev[prev.length - 1];
|
||||||
|
if (last && last.role === "assistant") {
|
||||||
|
// mutate copy – React will re‑render because we return new array
|
||||||
|
const updated = [...prev];
|
||||||
|
updated[updated.length - 1] = {
|
||||||
|
...last,
|
||||||
|
content: last.content + chunk,
|
||||||
|
};
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
// first assistant chunk → push new msg
|
||||||
|
return [...prev, { role: "assistant", content: chunk }];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───────────────────────────────────────────────────────── send prompt
|
||||||
|
async function sendPrompt() {
|
||||||
|
const text = prompt.trim();
|
||||||
|
if (!text) return;
|
||||||
|
|
||||||
|
// optimistic user message
|
||||||
|
setMessages((m) => [...m, { role: "user", content: text }]);
|
||||||
|
setPrompt("");
|
||||||
|
|
||||||
|
const token = localStorage.getItem("token") || "";
|
||||||
|
try {
|
||||||
|
const resp = await fetch("/api/chat/free", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"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(`Request failed ${resp.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// stream the response
|
||||||
|
const reader = resp.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let done = false;
|
||||||
|
while (!done) {
|
||||||
|
const { value, done: doneReading } = await reader.read();
|
||||||
|
done = doneReading;
|
||||||
|
if (value) {
|
||||||
|
const chunk = decoder.decode(value);
|
||||||
|
appendAssistantChunk(chunk);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[ChatDrawer] error", err);
|
||||||
|
appendAssistantChunk("Sorry – something went wrong. Try again later.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────── key handler
|
||||||
|
function handleKeyDown(e) {
|
||||||
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
sendPrompt();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────── render
|
||||||
|
return (
|
||||||
|
<Sheet open={open} onOpenChange={setOpen}>
|
||||||
|
{/* Floating action button */}
|
||||||
|
<SheetTrigger>
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<MessageCircle size={24} />
|
||||||
|
</button>
|
||||||
|
</SheetTrigger>
|
||||||
|
|
||||||
|
{/* Drawer */}
|
||||||
|
<SheetContent
|
||||||
|
side="right"
|
||||||
|
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">
|
||||||
|
{pageContext === "explorer" ? "Career Explorer Guide" : "Programs Guide"}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* messages list */}
|
||||||
|
<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"}
|
||||||
|
>
|
||||||
|
{m.content}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* input */}
|
||||||
|
<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>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
92
src/components/ui/sheet.js
Normal file
92
src/components/ui/sheet.js
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
import { createContext, useContext, useState } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal <Sheet> implementation (slide‑in drawer) using plain React + Tailwind.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* <Sheet>
|
||||||
|
* <SheetTrigger>…</SheetTrigger>
|
||||||
|
* <SheetContent side="right">…</SheetContent>
|
||||||
|
* </Sheet>
|
||||||
|
*/
|
||||||
|
|
||||||
|
const SheetContext = createContext({ open: false, setOpen: () => {} });
|
||||||
|
|
||||||
|
export function Sheet({ open: controlled, onOpenChange, children }) {
|
||||||
|
const [uncontrolledOpen, setUncontrolledOpen] = useState(false);
|
||||||
|
const isControlled = controlled !== undefined;
|
||||||
|
const open = isControlled ? controlled : uncontrolledOpen;
|
||||||
|
const setOpen = isControlled ? onOpenChange : setUncontrolledOpen;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SheetContext.Provider value={{ open, setOpen }}>
|
||||||
|
{children}
|
||||||
|
</SheetContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SheetTrigger({ asChild, children, ...rest }) {
|
||||||
|
const { setOpen } = useContext(SheetContext);
|
||||||
|
const trigger = (
|
||||||
|
<button
|
||||||
|
{...rest}
|
||||||
|
onClick={(e) => {
|
||||||
|
rest.onClick?.(e);
|
||||||
|
setOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
return asChild ? children : trigger;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sheetRoot = (() => {
|
||||||
|
let root = document.getElementById("__sheet_root");
|
||||||
|
if (!root) {
|
||||||
|
root = document.createElement("div");
|
||||||
|
root.id = "__sheet_root";
|
||||||
|
document.body.appendChild(root);
|
||||||
|
}
|
||||||
|
return root;
|
||||||
|
})();
|
||||||
|
|
||||||
|
export function SheetContent({ side = "right", className = "", children }) {
|
||||||
|
const { open, setOpen } = useContext(SheetContext);
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
const translateMap = {
|
||||||
|
right: "translate-x-full",
|
||||||
|
left: "-translate-x-full",
|
||||||
|
top: "-translate-y-full",
|
||||||
|
bottom: "translate-y-full",
|
||||||
|
};
|
||||||
|
|
||||||
|
const positionMap = {
|
||||||
|
right: "right-0 top-0 h-full w-80 md:w-96",
|
||||||
|
left: "left-0 top-0 h-full w-80 md:w-96",
|
||||||
|
top: "top-0 left-0 w-full h-1/2",
|
||||||
|
bottom: "bottom-0 left-0 w-full h-1/2",
|
||||||
|
};
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div className="fixed inset-0 z-50">
|
||||||
|
{/* backdrop */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/50" onClick={() => setOpen(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* panel */}
|
||||||
|
<div
|
||||||
|
className={`absolute bg-white shadow-xl transition-transform duration-300 ${positionMap[side]} ${
|
||||||
|
open ? "translate-x-0 translate-y-0" : translateMap[side]
|
||||||
|
} ${className}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
sheetRoot
|
||||||
|
);
|
||||||
|
}
|
15
src/utils/usePageContext.js
Normal file
15
src/utils/usePageContext.js
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useLocation } from 'react-router-dom';
|
||||||
|
import { sendSystemMessage } from '../utils/chatApi.js'; // you already call this for Jess
|
||||||
|
|
||||||
|
export default function usePageContext () {
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const page = pathname.split('/')[1] || 'Home';
|
||||||
|
const featureFlags = window.__APTIVA_FLAGS__ || [];
|
||||||
|
|
||||||
|
// invisible system message for the adaptive assistant
|
||||||
|
sendSystemMessage('page_context', { page, featureFlags });
|
||||||
|
}, [pathname]);
|
||||||
|
}
|
@ -1,6 +1,14 @@
|
|||||||
// tailwind.config.js
|
// tailwind.config.js
|
||||||
module.exports = {
|
module.exports = {
|
||||||
content: ['./src/**/*.{js,jsx,ts,tsx}'],
|
content: ['./src/**/*.{js,jsx,ts,tsx}'],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
keyframes: {
|
||||||
|
"slide-in": { from: { transform: "translateX(100%)" }, to: { transform: "translateX(0)" } },
|
||||||
|
},
|
||||||
|
animation: { "slide-in": "slide-in 0.25s ease-out forwards" },
|
||||||
|
},
|
||||||
|
},
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
/* brand colours */
|
/* brand colours */
|
||||||
|
BIN
user_profile.db
BIN
user_profile.db
Binary file not shown.
Loading…
Reference in New Issue
Block a user