Coach can now create milestones and systemic prompts for Networking, Job Search, and INterview mode buttons.
This commit is contained in:
parent
bdec7c2d9a
commit
9b697e1d11
@ -8,7 +8,7 @@ import dotenv from 'dotenv';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import fs from 'fs/promises';
|
import fs from 'fs/promises';
|
||||||
import multer from 'multer';
|
import multer from 'multer';
|
||||||
import authFetch from '../src/utils/authFetch.js'; // Adjust path as needed
|
import fetch from "node-fetch";
|
||||||
import mammoth from 'mammoth';
|
import mammoth from 'mammoth';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
@ -27,10 +27,23 @@ 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 file
|
dotenv.config({ path: envPath }); // Load .env file
|
||||||
|
|
||||||
|
const apiBase = process.env.APTIVA_API_BASE || "http://localhost:5002/api";
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PREMIUM_PORT || 5002;
|
const PORT = process.env.PREMIUM_PORT || 5002;
|
||||||
const { getDocument } = pkg;
|
const { getDocument } = pkg;
|
||||||
|
|
||||||
|
function internalFetch(req, url, opts = {}) {
|
||||||
|
return fetch(url, {
|
||||||
|
...opts,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: req.headers?.authorization || "", // tolerate undefined
|
||||||
|
...(opts.headers || {})
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// 1) Create a MySQL pool using your environment variables
|
// 1) Create a MySQL pool using your environment variables
|
||||||
const pool = mysql.createPool({
|
const pool = mysql.createPool({
|
||||||
@ -493,6 +506,18 @@ app.post('/api/premium/ai/chat', authenticatePremiumUser, async (req, res) => {
|
|||||||
chatHistory = []
|
chatHistory = []
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
|
let existingTitles = [];
|
||||||
|
try {
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
`SELECT title, DATE_FORMAT(date,'%Y-%m-%d') AS d
|
||||||
|
FROM milestones
|
||||||
|
WHERE user_id = ? AND career_profile_id = ?`,
|
||||||
|
[req.id, scenarioRow.id]
|
||||||
|
);
|
||||||
|
existingTitles = rows.map(r => `${r.title.trim()}|${r.d}`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Could not fetch existing milestones =>", e);
|
||||||
|
}
|
||||||
// ------------------------------------------------
|
// ------------------------------------------------
|
||||||
// 1. Helper Functions
|
// 1. Helper Functions
|
||||||
// ------------------------------------------------
|
// ------------------------------------------------
|
||||||
@ -833,11 +858,52 @@ ${combinedStatusSituation}
|
|||||||
${summaryText}
|
${summaryText}
|
||||||
`.trim();
|
`.trim();
|
||||||
|
|
||||||
|
const systemPromptMilestoneFormat = `
|
||||||
|
WHEN the user wants a plan with milestones, tasks, and financial impacts:
|
||||||
|
RESPOND ONLY with valid JSON in this shape:
|
||||||
|
|
||||||
|
{
|
||||||
|
"milestones": [
|
||||||
|
{
|
||||||
|
"title": "string",
|
||||||
|
"date": "YYYY-MM-DD",
|
||||||
|
"description": "1 or 2 sentences",
|
||||||
|
"impacts": [
|
||||||
|
{
|
||||||
|
"impact_type": "cost" or "salary" or ...,
|
||||||
|
"direction": "add" or "subtract",
|
||||||
|
"amount": 100.00,
|
||||||
|
"start_date": "YYYY-MM-DD" (optional),
|
||||||
|
"end_date": "YYYY-MM-DD" (optional)
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"title": "string",
|
||||||
|
"description": "string",
|
||||||
|
"due_date": "YYYY-MM-DD"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
...
|
||||||
|
]
|
||||||
|
}
|
||||||
|
NO extra text or disclaimers if returning a plan. Only that JSON.
|
||||||
|
Otherwise, answer normally.
|
||||||
|
`.trim();
|
||||||
|
|
||||||
|
const avoidBlock = existingTitles.length
|
||||||
|
? "\nAVOID repeating any of these title|date combinations:\n" +
|
||||||
|
existingTitles.map(t => `- ${t}`).join("\n")
|
||||||
|
: "";
|
||||||
|
|
||||||
// Build up the final messages array
|
// Build up the final messages array
|
||||||
const messagesToSend = [
|
const messagesToSend = [
|
||||||
{ role: "system", content: systemPromptIntro },
|
{ role: "system", content: systemPromptIntro },
|
||||||
{ role: "system", content: systemPromptStatusSituation },
|
{ role: "system", content: systemPromptStatusSituation },
|
||||||
{ role: "system", content: systemPromptDetailedContext },
|
{ role: "system", content: systemPromptDetailedContext },
|
||||||
|
{ role: "system", content: systemPromptMilestoneFormat },
|
||||||
|
{ role: "system", content: systemPromptMilestoneFormat + avoidBlock }, // <-- merged
|
||||||
...chatHistory // includes user and assistant messages so far
|
...chatHistory // includes user and assistant messages so far
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -872,6 +938,11 @@ ${summaryText}
|
|||||||
// The AI plan is expected to have: planObj.milestones[]
|
// The AI plan is expected to have: planObj.milestones[]
|
||||||
if (planObj && Array.isArray(planObj.milestones)) {
|
if (planObj && Array.isArray(planObj.milestones)) {
|
||||||
for (const milestone of planObj.milestones) {
|
for (const milestone of planObj.milestones) {
|
||||||
|
const dupKey = `${(milestone.title || "").trim()}|${milestone.date}`;
|
||||||
|
if (existingTitles.includes(dupKey)) {
|
||||||
|
console.log("Skipping duplicate milestone:", dupKey);
|
||||||
|
continue; // do NOT insert
|
||||||
|
}
|
||||||
// Create the milestone
|
// Create the milestone
|
||||||
const milestoneBody = {
|
const milestoneBody = {
|
||||||
title: milestone.title,
|
title: milestone.title,
|
||||||
@ -884,65 +955,90 @@ ${summaryText}
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Call your existing milestone endpoint
|
// Call your existing milestone endpoint
|
||||||
const msRes = await authFetch("/api/premium/milestone", {
|
const msRes = await internalFetch(req, `${apiBase}/premium/milestone`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(milestoneBody)
|
body: JSON.stringify(milestoneBody)
|
||||||
});
|
});
|
||||||
const createdMs = await msRes.json();
|
const createdMs = await msRes.json();
|
||||||
|
|
||||||
// Figure out the new milestone ID
|
// Figure out the new milestone ID
|
||||||
let newMilestoneId = null;
|
let newMilestoneId = null;
|
||||||
if (Array.isArray(createdMs) && createdMs[0]) {
|
if (Array.isArray(createdMs) && createdMs[0]) {
|
||||||
newMilestoneId = createdMs[0].id;
|
newMilestoneId = createdMs[0].id;
|
||||||
} else if (createdMs.id) {
|
} else if (createdMs.id) {
|
||||||
newMilestoneId = createdMs.id;
|
newMilestoneId = createdMs.id;
|
||||||
}
|
|
||||||
|
|
||||||
// If we have a milestoneId, create tasks & impacts
|
|
||||||
if (newMilestoneId) {
|
|
||||||
// tasks
|
|
||||||
if (Array.isArray(milestone.tasks)) {
|
|
||||||
for (const t of milestone.tasks) {
|
|
||||||
const taskBody = {
|
|
||||||
milestone_id: newMilestoneId,
|
|
||||||
title: t.title,
|
|
||||||
description: t.description || "",
|
|
||||||
due_date: t.due_date || null
|
|
||||||
};
|
|
||||||
await authFetch("/api/premium/tasks", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify(taskBody)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// impacts
|
// If we have a milestoneId, create tasks & impacts
|
||||||
if (Array.isArray(milestone.impacts)) {
|
if (newMilestoneId) {
|
||||||
for (const imp of milestone.impacts) {
|
/* ---------- TASKS ---------- */
|
||||||
const impactBody = {
|
if (Array.isArray(milestone.tasks)) {
|
||||||
milestone_id: newMilestoneId,
|
for (const t of milestone.tasks) {
|
||||||
impact_type: imp.impact_type,
|
// tolerate plain-string tasks → convert to minimal object
|
||||||
direction: imp.direction,
|
const taskObj =
|
||||||
amount: imp.amount,
|
typeof t === "string"
|
||||||
start_date: imp.start_date || null,
|
? { title: t, description: "", due_date: null }
|
||||||
end_date: imp.end_date || null
|
: t;
|
||||||
};
|
|
||||||
await authFetch("/api/premium/milestone-impacts", {
|
if (!taskObj.title) continue; // skip invalid
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
const taskBody = {
|
||||||
body: JSON.stringify(impactBody)
|
milestone_id: newMilestoneId,
|
||||||
});
|
title: taskObj.title,
|
||||||
|
description: taskObj.description || "",
|
||||||
|
due_date: taskObj.due_date || null
|
||||||
|
};
|
||||||
|
|
||||||
|
await internalFetch(req, `${apiBase}/premium/tasks`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(taskBody)
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- IMPACTS ---------- */
|
||||||
|
if (Array.isArray(milestone.impacts)) {
|
||||||
|
for (const imp of milestone.impacts) {
|
||||||
|
// tolerate plain-string impacts
|
||||||
|
const impObj =
|
||||||
|
typeof imp === "string"
|
||||||
|
? {
|
||||||
|
impact_type: "note",
|
||||||
|
direction: "add",
|
||||||
|
amount: 0,
|
||||||
|
start_date: null,
|
||||||
|
end_date: null
|
||||||
|
}
|
||||||
|
: imp;
|
||||||
|
|
||||||
|
if (!impObj.impact_type) continue; // skip invalid
|
||||||
|
|
||||||
|
const impactBody = {
|
||||||
|
milestone_id: newMilestoneId,
|
||||||
|
impact_type: impObj.impact_type,
|
||||||
|
direction: impObj.direction,
|
||||||
|
amount: impObj.amount,
|
||||||
|
start_date: impObj.start_date || null,
|
||||||
|
end_date: impObj.end_date || null
|
||||||
|
};
|
||||||
|
|
||||||
|
await internalFetch(req, `${apiBase}/premium/milestone-impacts`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(impactBody)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Track the new milestone ---------- */
|
||||||
|
createdMilestonesData.push({
|
||||||
|
milestoneId: newMilestoneId,
|
||||||
|
title: milestone.title
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep track of the newly created milestone
|
|
||||||
createdMilestonesData.push({
|
|
||||||
milestoneId: newMilestoneId,
|
|
||||||
title: milestone.title
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we successfully created at least 1 milestone,
|
// If we successfully created at least 1 milestone,
|
||||||
|
@ -1,6 +1,59 @@
|
|||||||
import React, { useState, useEffect, useRef } from "react";
|
import React, { useState, useEffect, useRef } from "react";
|
||||||
import authFetch from "../utils/authFetch.js";
|
import authFetch from "../utils/authFetch.js";
|
||||||
|
|
||||||
|
const isoToday = new Date().toISOString().slice(0,10); // top-level helper
|
||||||
|
|
||||||
|
/* ----------------------------------------------
|
||||||
|
Hidden prompts for the quick-action buttons
|
||||||
|
---------------------------------------------- */
|
||||||
|
const QUICK_PROMPTS = {
|
||||||
|
networking: `
|
||||||
|
|
||||||
|
Return **ONLY** valid JSON:
|
||||||
|
TODAY = ${isoToday}
|
||||||
|
**Every milestone.date must be >= TODAY**
|
||||||
|
|
||||||
|
{
|
||||||
|
"milestones": [
|
||||||
|
{
|
||||||
|
"title": "<= 5 words",
|
||||||
|
"date": "YYYY-MM-DD",
|
||||||
|
"description": "1–2 sentences",
|
||||||
|
"impacts": [
|
||||||
|
{
|
||||||
|
"impact_type": "cost" | "salary" | "none",
|
||||||
|
"direction": "add" | "subtract",
|
||||||
|
"amount": 0,
|
||||||
|
"start_date": null,
|
||||||
|
"end_date": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"title": "string",
|
||||||
|
"description": "string",
|
||||||
|
"due_date": "YYYY-MM-DD"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
NO extra commentary. The JSON will be stored as milestones.`.trim(),
|
||||||
|
|
||||||
|
jobSearch: `
|
||||||
|
Return **ONLY** valid JSON in the **same structure** (title, date, description, impacts[{}], tasks[{}]) for a Job-Search roadmap. TODAY = ${isoToday}
|
||||||
|
**Every milestone.date must be >= TODAY** NO extra text.`.trim(),
|
||||||
|
|
||||||
|
interview: `
|
||||||
|
You are an expert interview coach.
|
||||||
|
Ask one behavioural or technical question, wait for the user's reply,
|
||||||
|
score 1-5, give constructive feedback, then ask the next question.
|
||||||
|
Stop after 5 questions or if the user types "quit interview".
|
||||||
|
Do NOT output milestones JSON.`.trim()
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
export default function CareerCoach({
|
export default function CareerCoach({
|
||||||
userProfile,
|
userProfile,
|
||||||
financialProfile,
|
financialProfile,
|
||||||
@ -9,39 +62,30 @@ export default function CareerCoach({
|
|||||||
onMilestonesCreated,
|
onMilestonesCreated,
|
||||||
onAiRiskFetched
|
onAiRiskFetched
|
||||||
}) {
|
}) {
|
||||||
|
/* -------------- state ---------------- */
|
||||||
const [messages, setMessages] = useState([]);
|
const [messages, setMessages] = useState([]);
|
||||||
const [input, setInput] = useState("");
|
const [input, setInput] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const chatContainerRef = useRef(null);
|
|
||||||
const [hasSentMessage, setHasSentMessage] = useState(false);
|
|
||||||
const [prevRiskLevel, setPrevRiskLevel] = useState(null);
|
|
||||||
|
|
||||||
// NEW: optional local state to hold the AI risk data if you want to show it somewhere
|
|
||||||
const [aiRisk, setAiRisk] = useState(null);
|
const [aiRisk, setAiRisk] = useState(null);
|
||||||
|
const chatRef = useRef(null);
|
||||||
|
|
||||||
|
/* -------------- scroll --------------- */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (chatContainerRef.current) {
|
if (chatRef.current) chatRef.current.scrollTop = chatRef.current.scrollHeight;
|
||||||
chatContainerRef.current.scrollTop = chatContainerRef.current.scrollHeight;
|
}, [messages]);
|
||||||
}
|
|
||||||
}, [messages, hasSentMessage]);
|
|
||||||
|
|
||||||
|
/* -------------- intro ---------------- */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!scenarioRow?.riskLevel) return;
|
if (!scenarioRow) return;
|
||||||
|
setMessages([generatePersonalizedIntro()]);
|
||||||
// If it hasn't changed, skip
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
if (scenarioRow.riskLevel === prevRiskLevel) return;
|
}, [scenarioRow?.id]);
|
||||||
|
|
||||||
setPrevRiskLevel(scenarioRow.riskLevel);
|
|
||||||
|
|
||||||
// Now generate the intro once
|
|
||||||
const introMessage = generatePersonalizedIntro();
|
|
||||||
setMessages([introMessage]);
|
|
||||||
}, [scenarioRow]);
|
|
||||||
|
|
||||||
|
/* ---------- helpers you already had ---------- */
|
||||||
function buildStatusSituationMessage(status, situation, careerName) {
|
function buildStatusSituationMessage(status, situation, careerName) {
|
||||||
|
/* (unchanged body) */
|
||||||
const sStatus = (status || "").toLowerCase();
|
const sStatus = (status || "").toLowerCase();
|
||||||
const sSituation = (situation || "").toLowerCase();
|
const sSituation = (situation || "").toLowerCase();
|
||||||
|
|
||||||
let nowPart = "";
|
let nowPart = "";
|
||||||
switch (sStatus) {
|
switch (sStatus) {
|
||||||
case "planned":
|
case "planned":
|
||||||
@ -55,9 +99,7 @@ export default function CareerCoach({
|
|||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
nowPart = `I don’t have a clear picture of your involvement with ${careerName}, but I’m here to help.`;
|
nowPart = `I don’t have a clear picture of your involvement with ${careerName}, but I’m here to help.`;
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let nextPart = "";
|
let nextPart = "";
|
||||||
switch (sSituation) {
|
switch (sSituation) {
|
||||||
case "planning":
|
case "planning":
|
||||||
@ -74,184 +116,152 @@ export default function CareerCoach({
|
|||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
nextPart = `I'm not entirely sure of your next direction, but we’ll keep your background in mind.`;
|
nextPart = `I'm not entirely sure of your next direction, but we’ll keep your background in mind.`;
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const combinedDescription = `${nowPart} ${nextPart}`.trim();
|
|
||||||
|
|
||||||
const friendlyNote = `
|
const friendlyNote = `
|
||||||
Feel free to use AptivaAI however it best suits you—there’s no "wrong" answer.
|
Feel free to use AptivaAI however it best suits you—there’s no "wrong" answer.
|
||||||
AptivaAI asks for some of your current situation so we can provide the best guidance on what you should do next to reach your goals.
|
AptivaAI asks for some of your current situation so we can provide the best guidance on what you should do next to reach your goals.
|
||||||
It's really about where you want to go from here (that's all you can control anyway).
|
It's really about where you want to go from here (that's all you can control anyway).
|
||||||
We can refine details anytime or just jump straight to what you're most interested in exploring now!
|
We can refine details anytime or just jump straight to what you're most interested in exploring now!`;
|
||||||
`.trim();
|
return `${nowPart} ${nextPart}\n${friendlyNote}`;
|
||||||
|
|
||||||
return `${combinedDescription}\n${friendlyNote}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const generatePersonalizedIntro = () => {
|
function generatePersonalizedIntro() {
|
||||||
|
/* (unchanged body) */
|
||||||
const careerName = scenarioRow?.career_name || "this career";
|
const careerName = scenarioRow?.career_name || "this career";
|
||||||
const goalsText = scenarioRow?.career_goals?.trim() || null;
|
const goalsText = scenarioRow?.career_goals?.trim() || null;
|
||||||
const riskLevel = scenarioRow?.riskLevel;
|
const riskLevel = scenarioRow?.riskLevel;
|
||||||
const riskReasoning = scenarioRow?.riskReasoning;
|
const riskReasoning = scenarioRow?.riskReasoning;
|
||||||
|
|
||||||
const userSituation = userProfile?.career_situation?.toLowerCase();
|
const userSituation = userProfile?.career_situation?.toLowerCase();
|
||||||
const userStatus = scenarioRow?.status?.toLowerCase();
|
const userStatus = scenarioRow?.status?.toLowerCase();
|
||||||
|
const combined = buildStatusSituationMessage(userStatus, userSituation, careerName);
|
||||||
|
|
||||||
const combinedMessage = buildStatusSituationMessage(
|
const intro = `
|
||||||
userStatus,
|
Hi! ${combined}<br/>
|
||||||
userSituation,
|
${goalsText ? `Your goals include:<br />${goalsText.split(/^\d+\.\s+/gm).filter(Boolean).map(g => `• ${g.trim()}`).join("<br />")}<br/>` : ""}
|
||||||
careerName
|
${riskLevel ? `Note: This role has a <strong>${riskLevel}</strong> automation risk over the next 10 years. ${riskReasoning}<br/>` : ""}
|
||||||
);
|
I'm here to support you with personalized coaching. What would you like to focus on today?`;
|
||||||
|
return { role: "assistant", content: intro };
|
||||||
|
}
|
||||||
|
|
||||||
const hasInterestAnswers = Boolean(userProfile?.interest_inventory_answers?.trim());
|
/* ------------ shared AI caller ------------- */
|
||||||
|
async function callAi(updatedHistory) {
|
||||||
const interestInventoryMessage = hasInterestAnswers
|
|
||||||
? `Since you've completed the Interest Inventory, I can offer more targeted suggestions based on your responses.`
|
|
||||||
: `If you complete the Interest Inventory, I’ll be able to offer more targeted suggestions based on your interests.`;
|
|
||||||
|
|
||||||
|
|
||||||
const riskMessage =
|
|
||||||
riskLevel && riskReasoning
|
|
||||||
? `Note: This role has a <strong>${riskLevel}</strong> automation risk over the next 10 years. ${riskReasoning}`
|
|
||||||
: "";
|
|
||||||
|
|
||||||
const goalsMessage = goalsText
|
|
||||||
? `Your goals include:<br />${goalsText
|
|
||||||
.split(/^\d+\.\s+/gm)
|
|
||||||
.filter(Boolean)
|
|
||||||
.map((goal) => `• ${goal.trim()}`)
|
|
||||||
.join("<br />")}`
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const missingProfileFields = [];
|
|
||||||
if (!scenarioRow?.career_name) missingProfileFields.push("career choice");
|
|
||||||
if (!goalsText) missingProfileFields.push("career goals");
|
|
||||||
if (!userSituation) missingProfileFields.push("career phase");
|
|
||||||
|
|
||||||
let advisoryMessage = "";
|
|
||||||
if (missingProfileFields.length > 0) {
|
|
||||||
advisoryMessage = `<em>If you provide ${
|
|
||||||
missingProfileFields.length > 1
|
|
||||||
? "a few more details"
|
|
||||||
: "this information"
|
|
||||||
}, I’ll be able to offer more tailored and precise advice.</em>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
role: "assistant",
|
|
||||||
content: `
|
|
||||||
Hi! ${combinedMessage}<br/>
|
|
||||||
${goalsMessage ? goalsMessage + "<br/>" : ""}
|
|
||||||
${interestInventoryMessage}<br/>
|
|
||||||
${riskMessage}<br/>
|
|
||||||
${advisoryMessage}<br/>
|
|
||||||
I'm here to support you with personalized coaching. What would you like to focus on today?
|
|
||||||
`,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSendMessage = async () => {
|
|
||||||
if (!input.trim() || loading) return;
|
|
||||||
|
|
||||||
const userMessage = { role: "user", content: input.trim() };
|
|
||||||
const updatedMessages = [...messages, userMessage];
|
|
||||||
|
|
||||||
setMessages(updatedMessages);
|
|
||||||
setInput("");
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const payload = {
|
const payload = {
|
||||||
userProfile,
|
userProfile,
|
||||||
financialProfile,
|
financialProfile,
|
||||||
scenarioRow,
|
scenarioRow,
|
||||||
collegeProfile,
|
collegeProfile,
|
||||||
chatHistory: updatedMessages,
|
chatHistory: updatedHistory
|
||||||
};
|
};
|
||||||
|
|
||||||
const res = await authFetch("/api/premium/ai/chat", {
|
const res = await authFetch("/api/premium/ai/chat", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload)
|
||||||
});
|
});
|
||||||
|
const { reply, aiRisk: riskData, createdMilestones = [] } = await res.json();
|
||||||
|
|
||||||
if (!res.ok) throw new Error("AI request failed");
|
// If GPT accidentally returned raw JSON, hide it from user
|
||||||
|
const isJson = reply.trim().startsWith("{") || reply.trim().startsWith("[");
|
||||||
|
const friendlyReply = isJson
|
||||||
|
? "✅ Got it! I added new milestones to your plan. Check your Milestones tab."
|
||||||
|
: reply;
|
||||||
|
|
||||||
// Here we destructure out aiRisk from the JSON
|
setMessages((prev) => [...prev, { role: "assistant", content: friendlyReply }]);
|
||||||
// so we can store it or display it in the frontend
|
|
||||||
const { reply, aiRisk: riskDataFromServer } = await res.json();
|
|
||||||
|
|
||||||
setMessages((prev) => [...prev, { role: "assistant", content: reply }]);
|
if (riskData && onAiRiskFetched) onAiRiskFetched(riskData);
|
||||||
|
if (createdMilestones.length && onMilestonesCreated)
|
||||||
// OPTIONAL: store or use the AI risk data
|
onMilestonesCreated(createdMilestones.length);
|
||||||
if (riskDataFromServer && onAiRiskFetched) {
|
|
||||||
onAiRiskFetched(riskDataFromServer);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
reply.includes("created those milestones") ||
|
|
||||||
reply.includes("milestones for you")
|
|
||||||
) {
|
|
||||||
onMilestonesCreated?.();
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("CareerCoach error:", err);
|
console.error(err);
|
||||||
setMessages((prev) => [
|
setMessages((prev) => [...prev, { role: "assistant", content: "Sorry, something went wrong." }]);
|
||||||
...prev,
|
|
||||||
{
|
|
||||||
role: "assistant",
|
|
||||||
content: "Sorry, something went wrong. Please try again.",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
const handleSubmit = (e) => {
|
/* ------------ normal send ------------- */
|
||||||
|
function handleSubmit(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleSendMessage();
|
if (!input.trim() || loading) return;
|
||||||
setHasSentMessage(true);
|
const userMsg = { role: "user", content: input.trim() };
|
||||||
};
|
const newHistory = [...messages, userMsg];
|
||||||
|
setMessages(newHistory);
|
||||||
|
setInput("");
|
||||||
|
callAi(newHistory);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------ quick-action buttons ------------- */
|
||||||
|
function triggerQuickAction(type) {
|
||||||
|
if (loading) return;
|
||||||
|
|
||||||
|
// 1. Add a visible note for user *without* showing the raw system prompt
|
||||||
|
const note = {
|
||||||
|
role: "assistant",
|
||||||
|
content:
|
||||||
|
type === "interview"
|
||||||
|
? "Starting mock interview! (answer each question and I’ll give feedback)"
|
||||||
|
: `Sure! Let me create a ${type === "networking" ? "Networking" : "Job-Search"} roadmap for you…`
|
||||||
|
};
|
||||||
|
const hiddenSystem = { role: "system", content: QUICK_PROMPTS[type] };
|
||||||
|
|
||||||
|
const updatedHistory = [...messages, note, hiddenSystem];
|
||||||
|
setMessages([...messages, note]); // show only the friendly note
|
||||||
|
callAi(updatedHistory);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------ render ------------- */
|
||||||
return (
|
return (
|
||||||
<div className="border rounded-lg shadow bg-white p-6 mb-6">
|
<div className="border rounded-lg shadow bg-white p-6 mb-6">
|
||||||
<h2 className="text-2xl font-semibold mb-4">Career Coach</h2>
|
<h2 className="text-2xl font-semibold mb-4">Career Coach</h2>
|
||||||
|
|
||||||
|
{/* Quick-action bar */}
|
||||||
|
<div className="flex flex-wrap gap-2 mb-3">
|
||||||
|
<button
|
||||||
|
onClick={() => triggerQuickAction("networking")}
|
||||||
|
disabled={loading}
|
||||||
|
className="bg-emerald-600 hover:bg-emerald-700 text-white px-3 py-1 rounded"
|
||||||
|
>
|
||||||
|
Networking Plan
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => triggerQuickAction("jobSearch")}
|
||||||
|
disabled={loading}
|
||||||
|
className="bg-indigo-600 hover:bg-indigo-700 text-white px-3 py-1 rounded"
|
||||||
|
>
|
||||||
|
Job-Search Plan
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => triggerQuickAction("interview")}
|
||||||
|
disabled={loading}
|
||||||
|
className="bg-orange-600 hover:bg-orange-700 text-white px-3 py-1 rounded"
|
||||||
|
>
|
||||||
|
Interview Help
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Chat area */}
|
||||||
<div
|
<div
|
||||||
ref={chatContainerRef}
|
ref={chatRef}
|
||||||
className="overflow-y-auto border rounded mb-4 space-y-2"
|
className="overflow-y-auto border rounded mb-4 space-y-2"
|
||||||
style={{
|
style={{ maxHeight: 320, minHeight: 200, padding: "1rem" }}
|
||||||
maxHeight: "320px",
|
|
||||||
minHeight: "200px",
|
|
||||||
padding: "1rem",
|
|
||||||
scrollBehavior: "smooth",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{messages.map((msg, i) => (
|
{messages.map((m, i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
className={`rounded p-2 ${
|
className={`rounded p-2 ${
|
||||||
msg.role === "user"
|
m.role === "user"
|
||||||
? "bg-blue-100 text-blue-800 self-end"
|
? "bg-blue-100 text-blue-800 self-end"
|
||||||
: "bg-gray-200 text-gray-800 self-start"
|
: "bg-gray-200 text-gray-800 self-start"
|
||||||
}`}
|
}`}
|
||||||
>
|
dangerouslySetInnerHTML={{ __html: m.content.replace(/\n/g, "<br/>") }}
|
||||||
<div
|
/>
|
||||||
dangerouslySetInnerHTML={{
|
|
||||||
__html: msg.content.replace(/\n/g, "<br />"),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
{loading && (
|
{loading && <div className="text-sm italic text-gray-500">Coach is typing…</div>}
|
||||||
<div className="text-sm text-gray-500 italic">Coach is typing...</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Optionally display AI risk info here if you'd like */}
|
{/* AI risk banner */}
|
||||||
{aiRisk && aiRisk.riskLevel && (
|
{aiRisk?.riskLevel && (
|
||||||
<div className="p-2 my-2 bg-yellow-100 text-yellow-900 rounded">
|
<div className="p-2 my-2 bg-yellow-100 text-yellow-900 rounded">
|
||||||
<strong>Automation Risk:</strong> {aiRisk.riskLevel}
|
<strong>Automation Risk:</strong> {aiRisk.riskLevel}
|
||||||
<br />
|
<br />
|
||||||
@ -259,23 +269,23 @@ const interestInventoryMessage = hasInterestAnswers
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="flex space-x-2">
|
{/* Input */}
|
||||||
|
<form onSubmit={handleSubmit} className="flex gap-2">
|
||||||
<input
|
<input
|
||||||
type="text"
|
|
||||||
className="flex-grow border rounded py-2 px-3"
|
|
||||||
value={input}
|
value={input}
|
||||||
placeholder="Ask your Career Coach..."
|
|
||||||
onChange={(e) => setInput(e.target.value)}
|
onChange={(e) => setInput(e.target.value)}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
|
placeholder="Ask your Career Coach…"
|
||||||
|
className="flex-grow border rounded py-2 px-3"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
className={`rounded px-4 py-2 ${
|
className={`rounded px-4 py-2 ${
|
||||||
loading
|
loading
|
||||||
? "bg-gray-300 text-gray-600 cursor-not-allowed"
|
? "bg-gray-300 text-gray-600 cursor-not-allowed"
|
||||||
: "bg-blue-500 hover:bg-blue-600 text-white"
|
: "bg-blue-500 hover:bg-blue-600 text-white"
|
||||||
}`}
|
}`}
|
||||||
disabled={loading}
|
|
||||||
>
|
>
|
||||||
Send
|
Send
|
||||||
</button>
|
</button>
|
||||||
|
Loading…
Reference in New Issue
Block a user