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 fs from 'fs';
|
||||
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 ---
|
||||
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}`);
|
||||
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
|
||||
const allowedOrigins = [
|
||||
'http://localhost:3000',
|
||||
@ -883,6 +897,7 @@ app.post('/api/ai-risk', async (req, res) => {
|
||||
jobDescription,
|
||||
tasks,
|
||||
riskLevel,
|
||||
vectorSearch,
|
||||
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
|
||||
**************************************************/
|
||||
|
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",
|
||||
"docx": "^9.5.0",
|
||||
"dotenv": "^16.4.7",
|
||||
"express-rate-limit": "^7.5.1",
|
||||
"file-saver": "^2.0.5",
|
||||
"fuse.js": "^7.1.0",
|
||||
"helmet": "^8.0.0",
|
||||
@ -9112,6 +9113,21 @@
|
||||
"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": {
|
||||
"version": "0.7.1",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz",
|
||||
|
@ -21,6 +21,7 @@
|
||||
"cra-template": "1.2.0",
|
||||
"docx": "^9.5.0",
|
||||
"dotenv": "^16.4.7",
|
||||
"express-rate-limit": "^7.5.1",
|
||||
"file-saver": "^2.0.5",
|
||||
"fuse.js": "^7.1.0",
|
||||
"helmet": "^8.0.0",
|
||||
|
@ -31,6 +31,7 @@ import OnboardingContainer from './components/PremiumOnboarding/OnboardingContai
|
||||
import RetirementPlanner from './components/RetirementPlanner.js';
|
||||
import ResumeRewrite from './components/ResumeRewrite.js';
|
||||
import LoanRepaymentPage from './components/LoanRepaymentPage.js';
|
||||
import usePageContext from './utils/usePageContext.js';
|
||||
|
||||
|
||||
|
||||
@ -39,6 +40,7 @@ export const ProfileCtx = React.createContext();
|
||||
function App() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
usePageContext();
|
||||
|
||||
// Auth states
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
|
@ -6,6 +6,7 @@ import CareerPrioritiesModal from './CareerPrioritiesModal.js';
|
||||
import CareerModal from './CareerModal.js';
|
||||
import InterestMeaningModal from './InterestMeaningModal.js';
|
||||
import CareerSearch from './CareerSearch.js';
|
||||
import ChatDrawer from './ChatDrawer.js';
|
||||
import { Button } from './ui/button.js';
|
||||
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
|
||||
// ------------------------------------------------------
|
||||
@ -984,6 +1017,11 @@ const handleSelectForEducation = (career) => {
|
||||
defaultMeaning={modalData.defaultMeaning}
|
||||
/>
|
||||
|
||||
<ChatDrawer
|
||||
pageContext="explorer"
|
||||
snapshot={explorerSnapshot} // the JSON you’re already building
|
||||
/>
|
||||
|
||||
{selectedCareer && (
|
||||
<CareerModal
|
||||
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
|
||||
module.exports = {
|
||||
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: {
|
||||
extend: {
|
||||
/* brand colours */
|
||||
|
BIN
user_profile.db
BIN
user_profile.db
Binary file not shown.
Loading…
Reference in New Issue
Block a user