470 lines
16 KiB
JavaScript
470 lines
16 KiB
JavaScript
import React, { useState, useEffect, useRef } from "react";
|
||
import authFetch from "../utils/authFetch.js";
|
||
|
||
const isoToday = new Date().toISOString().slice(0,10); // top-level helper
|
||
|
||
function buildInterviewPrompt(careerName, jobDescription = "") {
|
||
return `
|
||
You are an expert interviewer for the role **${careerName}**.
|
||
Ask one challenging behavioural or technical question **specific to this career**,
|
||
wait for the candidate's reply, then:
|
||
|
||
• Score the answer 1–5
|
||
• Give concise feedback (1-2 sentences)
|
||
• Ask the next question (up to 5 total)
|
||
|
||
After 5 questions or if the user types "quit interview", end the session.
|
||
|
||
Do NOT output milestones JSON.`;
|
||
}
|
||
|
||
/* ----------------------------------------------
|
||
Hidden prompts for the quick-action buttons
|
||
---------------------------------------------- */
|
||
|
||
function planPrompt( _opts={}) {
|
||
return `
|
||
# ⛔️ DO NOT wrap in markdown, do NOT add back-ticks, do NOT add commentary.
|
||
# ⛔️ Respond with ONE thing only: the VALID JSON object below.
|
||
# ⛔️ The very first character of your reply MUST be “{”.
|
||
{
|
||
"milestones":[
|
||
{
|
||
"title":"", /* GPT must fill — make unique & action-oriented */
|
||
"date":"YYYY-MM-DD", /* ≤ 6 mo, ≥ ${isoToday} */
|
||
"description":"1-2 sentences",
|
||
"impacts":[],
|
||
"tasks":[ … ]
|
||
},
|
||
{
|
||
"title":"", /* GPT must fill — phase-2 name */
|
||
"date":"YYYY-MM-DD", /* 9-18 mo out */
|
||
"description":"1-2 sentences",
|
||
"impacts":[],
|
||
"tasks":[ … ]
|
||
}
|
||
]
|
||
}
|
||
/* Rules — read carefully:
|
||
• You must provide DISTINCT, action-oriented titles.
|
||
• Titles may NOT duplicate any existing milestone (case-insensitive).
|
||
• Include at least one concrete noun (event, cert, org, etc.).
|
||
*/
|
||
`.trim();
|
||
}
|
||
|
||
const QUICK_PROMPTS = {
|
||
networking: ({ careerName, goalsText }) => `
|
||
MODE : networking_plan
|
||
ROLE : ${careerName}
|
||
GOALS: ${goalsText || "N/A"}
|
||
|
||
Create three milestones that expand professional connections.
|
||
${planPrompt()}
|
||
`.trim(),
|
||
|
||
jobSearch: ({ careerName, goalsText }) => `
|
||
MODE : job_search_plan
|
||
ROLE : ${careerName}
|
||
GOALS: ${goalsText || "N/A"}
|
||
|
||
Draft three milestones that accelerate the job hunt.
|
||
${planPrompt()}
|
||
`.trim(),
|
||
|
||
aiGrowth: ({ careerName, skillsText = "N/A", goalsText = "N/A" }) => `
|
||
MODE: ai_growth
|
||
TODAY = ${isoToday}
|
||
|
||
ROLE : ${careerName}
|
||
SKILLS : ${skillsText}
|
||
GOALS : ${goalsText}
|
||
|
||
INSTRUCTIONS
|
||
1. List 2-3 role tasks already touched by AI.
|
||
– For each: how to *collaborate* with AI + one human-edge upskilling step.
|
||
2. Surface adjacent AI-created roles.
|
||
3. End with a 90-day action plan (bullets, dates ≥ TODAY).
|
||
|
||
${planPrompt({ label: "AI Growth" })}
|
||
`.trim(),
|
||
|
||
interview: `
|
||
MODE: interview
|
||
You are an expert interview coach.
|
||
Ask one behavioural or technical question, wait for the user's reply,
|
||
score 1-5, give concise 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({
|
||
userProfile,
|
||
financialProfile,
|
||
scenarioRow,
|
||
setScenarioRow,
|
||
careerProfileId,
|
||
collegeProfile,
|
||
onMilestonesCreated,
|
||
onAiRiskFetched
|
||
}) {
|
||
/* -------------- state ---------------- */
|
||
const [messages, setMessages] = useState([]);
|
||
const [input, setInput] = useState("");
|
||
const [loading, setLoading] = useState(false);
|
||
const [aiRisk, setAiRisk] = useState(null);
|
||
const chatRef = useRef(null);
|
||
const [showGoals , setShowGoals ] = useState(false);
|
||
const [draftGoals, setDraftGoals] = useState(scenarioRow?.career_goals || "");
|
||
const [saving , setSaving ] = useState(false);
|
||
const [threadId, setThreadId] = useState(null);
|
||
|
||
/* -------------- scroll --------------- */
|
||
useEffect(() => {
|
||
if (chatRef.current) chatRef.current.scrollTop = chatRef.current.scrollHeight;
|
||
}, [messages]);
|
||
|
||
useEffect(() => {
|
||
(async () => {
|
||
if (!careerProfileId) return;
|
||
|
||
// list threads for this profile
|
||
const r = await authFetch(
|
||
`/api/premium/coach/chat/threads?careerProfileId=${encodeURIComponent(careerProfileId)}`
|
||
);
|
||
|
||
if (!(r.ok && (r.headers.get('content-type') || '').includes('application/json'))) {
|
||
setThreadId(null); // coach offline; no network errors on mount
|
||
return;
|
||
}
|
||
|
||
const { threads = [] } = await r.json();
|
||
const existing = threads.find(Boolean);
|
||
if (!existing?.id) {
|
||
setThreadId(null); // no thread yet; lazy-create on first send
|
||
return;
|
||
}
|
||
|
||
const id = existing.id;
|
||
setThreadId(id);
|
||
|
||
// preload history
|
||
const r3 = await authFetch(
|
||
`/api/premium/coach/chat/threads/${id}?careerProfileId=${encodeURIComponent(careerProfileId)}`
|
||
);
|
||
if (r3.ok && (r3.headers.get('content-type') || '').includes('application/json')) {
|
||
const { messages: msgs = [] } = await r3.json();
|
||
setMessages(msgs);
|
||
}
|
||
})();
|
||
}, [careerProfileId]);
|
||
|
||
|
||
/* -------------- intro ---------------- */
|
||
useEffect(() => {
|
||
if (!scenarioRow) return;
|
||
setMessages(prev =>
|
||
prev.length ? prev // keep what we loaded
|
||
: [generatePersonalizedIntro()] );
|
||
}, [scenarioRow?.id]);
|
||
|
||
/* ---------- helpers you already had ---------- */
|
||
function buildStatusSituationMessage(status, situation, careerName) {
|
||
/* (unchanged body) */
|
||
const sStatus = (status || "").toLowerCase();
|
||
const sSituation = (situation || "").toLowerCase();
|
||
let nowPart = "";
|
||
switch (sStatus) {
|
||
case "planned":
|
||
nowPart = `It appears you’re looking ahead to a possible future as it pertains to ${careerName}.`;
|
||
break;
|
||
case "current":
|
||
nowPart = `It appears you’re currently working in a role as it pertains to ${careerName}.`;
|
||
break;
|
||
case "exploring":
|
||
nowPart = `It appears you’re exploring how ${careerName} might fit your plans.`;
|
||
break;
|
||
default:
|
||
nowPart = `I don’t have a clear picture of your involvement with ${careerName}, but I’m here to help.`;
|
||
}
|
||
let nextPart = "";
|
||
switch (sSituation) {
|
||
case "planning":
|
||
nextPart = `You're aiming to clarify your strategy for moving into this field.`;
|
||
break;
|
||
case "preparing":
|
||
nextPart = `You're actively developing the skills you need for new opportunities.`;
|
||
break;
|
||
case "enhancing":
|
||
nextPart = `You’d like to deepen or broaden your responsibilities.`;
|
||
break;
|
||
case "retirement":
|
||
nextPart = `You're considering how to transition toward retirement.`;
|
||
break;
|
||
default:
|
||
nextPart = `I'm not entirely sure of your next direction, but we’ll keep your background in mind.`;
|
||
}
|
||
const friendlyNote = `
|
||
Feel free to use AptivaAI however it best suits you—there’s no "wrong" answer.
|
||
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!`;
|
||
return `${nowPart} ${nextPart}\n${friendlyNote}`;
|
||
}
|
||
|
||
function generatePersonalizedIntro() {
|
||
/* (unchanged body) */
|
||
const careerName = scenarioRow?.career_name || "this career";
|
||
const goalsText = scenarioRow?.career_goals?.trim() || null;
|
||
const riskLevel = scenarioRow?.riskLevel;
|
||
const riskReasoning = scenarioRow?.riskReasoning;
|
||
const userSituation = userProfile?.career_situation?.toLowerCase();
|
||
const userStatus = scenarioRow?.status?.toLowerCase();
|
||
const combined = buildStatusSituationMessage(userStatus, userSituation, careerName);
|
||
|
||
const intro = `
|
||
Hi! ${combined}<br/>
|
||
${goalsText ? `Your goals include:<br />${goalsText.split(/^\d+\.\s+/gm).filter(Boolean).map(g => `• ${g.trim()}`).join("<br />")}<br/>` : ""}
|
||
${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 };
|
||
}
|
||
|
||
/* ------------ shared AI caller ------------- */
|
||
async function callAi(updatedHistory, opts = {}) {
|
||
setLoading(true);
|
||
try {
|
||
if (!threadId) throw new Error('thread not ready');
|
||
const context = { userProfile, financialProfile, scenarioRow, collegeProfile };
|
||
const r = await authFetch(`/api/premium/coach/chat/threads/${threadId}/messages`, {
|
||
method:'POST',
|
||
headers:{ 'Content-Type':'application/json' },
|
||
body: JSON.stringify({ content: updatedHistory.at(-1)?.content || '', context })
|
||
});
|
||
let reply = 'Sorry, something went wrong.';
|
||
if (r.ok && (r.headers.get('content-type')||'').includes('application/json')) {
|
||
const data = await r.json();
|
||
reply = (data?.reply || '').trim() || reply;
|
||
}
|
||
setMessages(prev => [...prev, { role:'assistant', content: reply }]);
|
||
} catch (e) {
|
||
console.error(e);
|
||
setMessages(prev => [...prev, { role:'assistant', content:'Sorry, something went wrong.' }]);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}
|
||
|
||
|
||
/* ------------ normal send ------------- */
|
||
function handleSubmit(e) {
|
||
e.preventDefault();
|
||
if (!input.trim() || loading) return;
|
||
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; // debounce
|
||
|
||
/* shared context */
|
||
const careerName = scenarioRow?.career_name || "your role";
|
||
const goalsText = scenarioRow?.career_goals || "";
|
||
const skillsText = userProfile?.key_skills || "";
|
||
const avoidList =
|
||
[...messages].reverse()
|
||
.find(m => m.role === "system" && m.content.startsWith("[CURRENT MILESTONES]"))
|
||
?.content.split("\n").slice(2).join("\n") || "";
|
||
|
||
/* 1) Mock-Interview (special flow) */
|
||
if (type === "interview") {
|
||
const desc = scenarioRow?.job_description || "";
|
||
const hiddenSystem = { role:"system", content: buildInterviewPrompt(careerName, desc) };
|
||
const note = { role:"assistant", content:`Starting mock interview on **${careerName}**. Answer each question and I'll give feedback!` };
|
||
const updated = [...messages, note, hiddenSystem];
|
||
setMessages([...messages, note]);
|
||
callAi(updated);
|
||
return;
|
||
}
|
||
|
||
/* 2) All other quick actions share the same pattern */
|
||
const note = {
|
||
role : "assistant",
|
||
content: {
|
||
networking: "Sure! Let me create a Networking roadmap for you…",
|
||
jobSearch : "Sure! Let me create a Job-Search roadmap for you…",
|
||
aiGrowth : "Sure! Let’s map out how you can *partner* with AI in this career…"
|
||
}[type] || "OK!"
|
||
};
|
||
|
||
const hiddenSystem = {
|
||
role : "system",
|
||
content: QUICK_PROMPTS[type]({
|
||
careerName, goalsText, skillsText, avoidList
|
||
})
|
||
};
|
||
|
||
const updated = [...messages, note, hiddenSystem];
|
||
setMessages([...messages, note]);
|
||
const needsContext = ["networking", "jobSearch", "aiGrowth"].includes(type);
|
||
callAi(updated, {forceContext: needsContext});
|
||
}
|
||
|
||
|
||
/* ------------ render ------------- */
|
||
return (
|
||
<div className="border rounded-lg shadow bg-white p-6 mb-6">
|
||
<h2 className="text-2xl font-semibold mb-4">Career Coach</h2>
|
||
|
||
{/* Quick-action bar */}
|
||
<div className="flex flex-wrap items-center 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>
|
||
<button
|
||
onClick={() => triggerQuickAction("aiGrowth")}
|
||
disabled={loading}
|
||
className="bg-teal-600 hover:bg-teal-700 text-white px-3 py-1 rounded"
|
||
>
|
||
Grow Career with AI
|
||
</button>
|
||
{/* pushes Edit Goals to the far right */}
|
||
<div className="flex-grow"></div>
|
||
|
||
<button
|
||
onClick={() => {
|
||
// always load the latest goals before showing the modal
|
||
setDraftGoals(scenarioRow?.career_goals || "");
|
||
setShowGoals(true);
|
||
}}
|
||
className="border border-gray-300 bg-blue-600 text-white px-3 py-1 rounded hover:bg-blue-300"
|
||
>
|
||
Edit Goals
|
||
</button>
|
||
</div>
|
||
|
||
|
||
{/* Chat area */}
|
||
<div
|
||
ref={chatRef}
|
||
className="overflow-y-auto border rounded mb-4 space-y-2"
|
||
style={{ maxHeight: 320, minHeight: 200, padding: "1rem" }}
|
||
>
|
||
{messages.map((m, i) => (
|
||
<div
|
||
key={i}
|
||
className={`rounded p-2 ${
|
||
m.role === "user"
|
||
? "bg-blue-100 text-blue-800 self-end"
|
||
: "bg-gray-200 text-gray-800 self-start"
|
||
}`}
|
||
dangerouslySetInnerHTML={{ __html: m.content.replace(/\n/g, "<br/>") }}
|
||
/>
|
||
))}
|
||
{loading && <div className="text-sm italic text-gray-500">Coach is typing…</div>}
|
||
</div>
|
||
|
||
{/* AI risk banner */}
|
||
{aiRisk?.riskLevel && (
|
||
<div className="p-2 my-2 bg-yellow-100 text-yellow-900 rounded">
|
||
<strong>Automation Risk:</strong> {aiRisk.riskLevel}
|
||
<br />
|
||
<em>{aiRisk.reasoning}</em>
|
||
</div>
|
||
)}
|
||
|
||
{/* Input */}
|
||
<form onSubmit={handleSubmit} className="flex gap-2">
|
||
<input
|
||
value={input}
|
||
onChange={(e) => setInput(e.target.value)}
|
||
disabled={loading}
|
||
placeholder="Ask your Career Coach…"
|
||
className="flex-grow border rounded py-2 px-3"
|
||
/>
|
||
<button
|
||
type="submit"
|
||
disabled={loading}
|
||
className={`rounded px-4 py-2 ${
|
||
loading
|
||
? "bg-gray-300 text-gray-600 cursor-not-allowed"
|
||
: "bg-blue-500 hover:bg-blue-600 text-white"
|
||
}`}
|
||
>
|
||
Send
|
||
</button>
|
||
</form>
|
||
|
||
{showGoals && (
|
||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50">
|
||
<div className="bg-white rounded-lg p-6 w-full max-w-md shadow">
|
||
<h3 className="text-xl font-semibold mb-3">Edit Your Career Goals</h3>
|
||
|
||
<textarea
|
||
value={draftGoals}
|
||
onChange={(e) => setDraftGoals(e.target.value)}
|
||
rows={5}
|
||
className="w-full border rounded p-2"
|
||
placeholder="Describe your short- and long-term goals…"
|
||
/>
|
||
|
||
<div className="mt-4 flex justify-end gap-2">
|
||
<button
|
||
className="px-4 py-2 rounded border"
|
||
onClick={() => setShowGoals(false)}
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
className="px-4 py-2 rounded bg-blue-600 text-white disabled:opacity-50"
|
||
disabled={saving}
|
||
onClick={async () => {
|
||
setSaving(true);
|
||
await authFetch(
|
||
`/api/premium/career-profile/${careerProfileId}/goals`,
|
||
{
|
||
method : 'PUT',
|
||
headers: { 'Content-Type':'application/json' },
|
||
body : JSON.stringify({ career_goals: draftGoals })
|
||
}
|
||
);
|
||
// lift new goals into parent state so Jess sees them
|
||
setScenarioRow((p) => ({ ...p, career_goals: draftGoals }));
|
||
setSaving(false);
|
||
setShowGoals(false);
|
||
}}
|
||
>
|
||
{saving ? 'Saving…' : 'Save'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
</div>
|
||
);
|
||
}
|