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 fs from 'fs/promises';
|
||||
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 { fileURLToPath } from 'url';
|
||||
import jwt from 'jsonwebtoken';
|
||||
@ -27,10 +27,23 @@ const env = process.env.NODE_ENV?.trim() || 'development';
|
||||
const envPath = path.resolve(rootPath, `.env.${env}`);
|
||||
dotenv.config({ path: envPath }); // Load .env file
|
||||
|
||||
const apiBase = process.env.APTIVA_API_BASE || "http://localhost:5002/api";
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PREMIUM_PORT || 5002;
|
||||
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
|
||||
const pool = mysql.createPool({
|
||||
@ -493,6 +506,18 @@ app.post('/api/premium/ai/chat', authenticatePremiumUser, async (req, res) => {
|
||||
chatHistory = []
|
||||
} = 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
|
||||
// ------------------------------------------------
|
||||
@ -833,11 +858,52 @@ ${combinedStatusSituation}
|
||||
${summaryText}
|
||||
`.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
|
||||
const messagesToSend = [
|
||||
{ role: "system", content: systemPromptIntro },
|
||||
{ role: "system", content: systemPromptStatusSituation },
|
||||
{ role: "system", content: systemPromptDetailedContext },
|
||||
{ role: "system", content: systemPromptMilestoneFormat },
|
||||
{ role: "system", content: systemPromptMilestoneFormat + avoidBlock }, // <-- merged
|
||||
...chatHistory // includes user and assistant messages so far
|
||||
];
|
||||
|
||||
@ -872,6 +938,11 @@ ${summaryText}
|
||||
// The AI plan is expected to have: planObj.milestones[]
|
||||
if (planObj && Array.isArray(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
|
||||
const milestoneBody = {
|
||||
title: milestone.title,
|
||||
@ -884,65 +955,90 @@ ${summaryText}
|
||||
};
|
||||
|
||||
// Call your existing milestone endpoint
|
||||
const msRes = await authFetch("/api/premium/milestone", {
|
||||
const msRes = await internalFetch(req, `${apiBase}/premium/milestone`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(milestoneBody)
|
||||
});
|
||||
const createdMs = await msRes.json();
|
||||
|
||||
// Figure out the new milestone ID
|
||||
let newMilestoneId = null;
|
||||
if (Array.isArray(createdMs) && createdMs[0]) {
|
||||
newMilestoneId = createdMs[0].id;
|
||||
} else if (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)
|
||||
});
|
||||
}
|
||||
// Figure out the new milestone ID
|
||||
let newMilestoneId = null;
|
||||
if (Array.isArray(createdMs) && createdMs[0]) {
|
||||
newMilestoneId = createdMs[0].id;
|
||||
} else if (createdMs.id) {
|
||||
newMilestoneId = createdMs.id;
|
||||
}
|
||||
|
||||
// impacts
|
||||
if (Array.isArray(milestone.impacts)) {
|
||||
for (const imp of milestone.impacts) {
|
||||
const impactBody = {
|
||||
milestone_id: newMilestoneId,
|
||||
impact_type: imp.impact_type,
|
||||
direction: imp.direction,
|
||||
amount: imp.amount,
|
||||
start_date: imp.start_date || null,
|
||||
end_date: imp.end_date || null
|
||||
};
|
||||
await authFetch("/api/premium/milestone-impacts", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(impactBody)
|
||||
});
|
||||
// If we have a milestoneId, create tasks & impacts
|
||||
if (newMilestoneId) {
|
||||
/* ---------- TASKS ---------- */
|
||||
if (Array.isArray(milestone.tasks)) {
|
||||
for (const t of milestone.tasks) {
|
||||
// tolerate plain-string tasks → convert to minimal object
|
||||
const taskObj =
|
||||
typeof t === "string"
|
||||
? { title: t, description: "", due_date: null }
|
||||
: t;
|
||||
|
||||
if (!taskObj.title) continue; // skip invalid
|
||||
|
||||
const taskBody = {
|
||||
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,
|
||||
|
@ -1,6 +1,59 @@
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
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({
|
||||
userProfile,
|
||||
financialProfile,
|
||||
@ -9,39 +62,30 @@ export default function CareerCoach({
|
||||
onMilestonesCreated,
|
||||
onAiRiskFetched
|
||||
}) {
|
||||
/* -------------- state ---------------- */
|
||||
const [messages, setMessages] = useState([]);
|
||||
const [input, setInput] = useState("");
|
||||
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 chatRef = useRef(null);
|
||||
|
||||
/* -------------- scroll --------------- */
|
||||
useEffect(() => {
|
||||
if (chatContainerRef.current) {
|
||||
chatContainerRef.current.scrollTop = chatContainerRef.current.scrollHeight;
|
||||
}
|
||||
}, [messages, hasSentMessage]);
|
||||
if (chatRef.current) chatRef.current.scrollTop = chatRef.current.scrollHeight;
|
||||
}, [messages]);
|
||||
|
||||
/* -------------- intro ---------------- */
|
||||
useEffect(() => {
|
||||
if (!scenarioRow?.riskLevel) return;
|
||||
|
||||
// If it hasn't changed, skip
|
||||
if (scenarioRow.riskLevel === prevRiskLevel) return;
|
||||
|
||||
setPrevRiskLevel(scenarioRow.riskLevel);
|
||||
|
||||
// Now generate the intro once
|
||||
const introMessage = generatePersonalizedIntro();
|
||||
setMessages([introMessage]);
|
||||
}, [scenarioRow]);
|
||||
if (!scenarioRow) return;
|
||||
setMessages([generatePersonalizedIntro()]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [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":
|
||||
@ -55,9 +99,7 @@ export default function CareerCoach({
|
||||
break;
|
||||
default:
|
||||
nowPart = `I don’t have a clear picture of your involvement with ${careerName}, but I’m here to help.`;
|
||||
break;
|
||||
}
|
||||
|
||||
let nextPart = "";
|
||||
switch (sSituation) {
|
||||
case "planning":
|
||||
@ -74,184 +116,152 @@ export default function CareerCoach({
|
||||
break;
|
||||
default:
|
||||
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 = `
|
||||
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.
|
||||
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!
|
||||
`.trim();
|
||||
|
||||
return `${combinedDescription}\n${friendlyNote}`;
|
||||
We can refine details anytime or just jump straight to what you're most interested in exploring now!`;
|
||||
return `${nowPart} ${nextPart}\n${friendlyNote}`;
|
||||
}
|
||||
|
||||
const generatePersonalizedIntro = () => {
|
||||
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 userStatus = scenarioRow?.status?.toLowerCase();
|
||||
const combined = buildStatusSituationMessage(userStatus, userSituation, careerName);
|
||||
|
||||
const combinedMessage = 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 };
|
||||
}
|
||||
|
||||
const hasInterestAnswers = Boolean(userProfile?.interest_inventory_answers?.trim());
|
||||
|
||||
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("");
|
||||
/* ------------ shared AI caller ------------- */
|
||||
async function callAi(updatedHistory) {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
userProfile,
|
||||
financialProfile,
|
||||
scenarioRow,
|
||||
collegeProfile,
|
||||
chatHistory: updatedMessages,
|
||||
chatHistory: updatedHistory
|
||||
};
|
||||
|
||||
const res = await authFetch("/api/premium/ai/chat", {
|
||||
method: "POST",
|
||||
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
|
||||
// so we can store it or display it in the frontend
|
||||
const { reply, aiRisk: riskDataFromServer } = await res.json();
|
||||
setMessages((prev) => [...prev, { role: "assistant", content: friendlyReply }]);
|
||||
|
||||
setMessages((prev) => [...prev, { role: "assistant", content: reply }]);
|
||||
|
||||
// OPTIONAL: store or use the AI risk data
|
||||
if (riskDataFromServer && onAiRiskFetched) {
|
||||
onAiRiskFetched(riskDataFromServer);
|
||||
}
|
||||
|
||||
if (
|
||||
reply.includes("created those milestones") ||
|
||||
reply.includes("milestones for you")
|
||||
) {
|
||||
onMilestonesCreated?.();
|
||||
}
|
||||
if (riskData && onAiRiskFetched) onAiRiskFetched(riskData);
|
||||
if (createdMilestones.length && onMilestonesCreated)
|
||||
onMilestonesCreated(createdMilestones.length);
|
||||
} catch (err) {
|
||||
console.error("CareerCoach error:", err);
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
role: "assistant",
|
||||
content: "Sorry, something went wrong. Please try again.",
|
||||
},
|
||||
]);
|
||||
console.error(err);
|
||||
setMessages((prev) => [...prev, { role: "assistant", content: "Sorry, something went wrong." }]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
/* ------------ normal send ------------- */
|
||||
function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
handleSendMessage();
|
||||
setHasSentMessage(true);
|
||||
};
|
||||
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;
|
||||
|
||||
// 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 (
|
||||
<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 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
|
||||
ref={chatContainerRef}
|
||||
ref={chatRef}
|
||||
className="overflow-y-auto border rounded mb-4 space-y-2"
|
||||
style={{
|
||||
maxHeight: "320px",
|
||||
minHeight: "200px",
|
||||
padding: "1rem",
|
||||
scrollBehavior: "smooth",
|
||||
}}
|
||||
style={{ maxHeight: 320, minHeight: 200, padding: "1rem" }}
|
||||
>
|
||||
{messages.map((msg, i) => (
|
||||
{messages.map((m, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`rounded p-2 ${
|
||||
msg.role === "user"
|
||||
m.role === "user"
|
||||
? "bg-blue-100 text-blue-800 self-end"
|
||||
: "bg-gray-200 text-gray-800 self-start"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: msg.content.replace(/\n/g, "<br />"),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
dangerouslySetInnerHTML={{ __html: m.content.replace(/\n/g, "<br/>") }}
|
||||
/>
|
||||
))}
|
||||
{loading && (
|
||||
<div className="text-sm text-gray-500 italic">Coach is typing...</div>
|
||||
)}
|
||||
{loading && <div className="text-sm italic text-gray-500">Coach is typing…</div>}
|
||||
</div>
|
||||
|
||||
{/* Optionally display AI risk info here if you'd like */}
|
||||
{aiRisk && aiRisk.riskLevel && (
|
||||
{/* 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 />
|
||||
@ -259,23 +269,23 @@ const interestInventoryMessage = hasInterestAnswers
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex space-x-2">
|
||||
{/* Input */}
|
||||
<form onSubmit={handleSubmit} className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
className="flex-grow border rounded py-2 px-3"
|
||||
value={input}
|
||||
placeholder="Ask your Career Coach..."
|
||||
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"
|
||||
}`}
|
||||
disabled={loading}
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
|
Loading…
Reference in New Issue
Block a user