121 lines
4.9 KiB
JavaScript
121 lines
4.9 KiB
JavaScript
// ============================================================
|
|
// chatController.js — AI assistant proxy (Google Gemini)
|
|
// ============================================================
|
|
// The browser NEVER calls Gemini directly and never sees the API
|
|
// key. The widget posts a message here; this controller forwards
|
|
// it to the Gemini free-tier API using the server-side
|
|
// GEMINI_API_KEY and returns the assistant's reply.
|
|
//
|
|
// Escalation: the model is instructed to append the marker
|
|
// [[ESCALATE]] when a human representative should take over
|
|
// (pricing negotiation, complaints, account/legal issues, or
|
|
// anything it cannot confidently answer). We strip the marker and
|
|
// surface an `escalate` flag so the UI can offer the Contact page.
|
|
// ============================================================
|
|
|
|
const axios = require('axios');
|
|
|
|
const GEMINI_MODEL = process.env.GEMINI_MODEL || 'gemini-2.5-flash';
|
|
const GEMINI_URL =
|
|
`https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODEL}:generateContent`;
|
|
|
|
// Persona / knowledge for the assistant.
|
|
const SYSTEM_PROMPT = `You are "Ava", the AI virtual assistant for AI-Solutions, a Sunderland-based
|
|
start-up that uses artificial intelligence to improve the digital employee experience. AI-Solutions
|
|
offers AI-powered virtual assistants, rapid affordable prototyping, and bespoke software solutions
|
|
that speed up design, engineering and innovation for industry clients.
|
|
|
|
Your job: answer visitor questions about the company, its services, past projects, events and how to
|
|
get in touch. Be concise (2-4 sentences), warm, and professional. Never invent specific prices,
|
|
contracts, or personal data.
|
|
|
|
Escalate to a human by ending your reply with the exact marker [[ESCALATE]] when: the visitor asks
|
|
for pricing/quotes, wants to book or schedule a demo, raises a complaint, asks something you cannot
|
|
answer confidently, or explicitly asks to speak to a person. When you escalate, briefly tell them a
|
|
representative will help and suggest the Contact Us form.`;
|
|
|
|
// Keyword fallback so escalation still works if the model forgets the marker.
|
|
const ESCALATION_HINTS = [
|
|
'human', 'representative', 'agent', 'real person', 'speak to someone',
|
|
'price', 'pricing', 'quote', 'cost', 'complaint', 'refund', 'book a demo',
|
|
'schedule a demo', 'sales',
|
|
];
|
|
|
|
function looksLikeEscalation(text) {
|
|
const t = (text || '').toLowerCase();
|
|
return ESCALATION_HINTS.some(k => t.includes(k));
|
|
}
|
|
|
|
// POST /api/chat body: { message, history?: [{role, text}] }
|
|
async function chat(req, res, next) {
|
|
try {
|
|
const message = (req.body.message || '').toString().trim();
|
|
if (!message) {
|
|
return res.status(400).json({ message: 'Please enter a message.' });
|
|
}
|
|
if (message.length > 1000) {
|
|
return res.status(400).json({ message: 'Message is too long (max 1000 characters).' });
|
|
}
|
|
|
|
// Graceful degradation when the key isn't configured.
|
|
if (!process.env.GEMINI_API_KEY) {
|
|
return res.json({
|
|
reply:
|
|
"I'm not fully online right now, but our team would love to help. " +
|
|
'Please use the Contact Us page and someone will get back to you shortly.',
|
|
escalate: true,
|
|
});
|
|
}
|
|
|
|
// Build conversation history (last 8 turns) in Gemini's format.
|
|
const history = Array.isArray(req.body.history) ? req.body.history.slice(-8) : [];
|
|
const contents = history
|
|
.filter(h => h && h.text)
|
|
.map(h => ({
|
|
role: h.role === 'assistant' || h.role === 'model' ? 'model' : 'user',
|
|
parts: [{ text: String(h.text).slice(0, 1000) }],
|
|
}));
|
|
contents.push({ role: 'user', parts: [{ text: message }] });
|
|
|
|
const { data } = await axios.post(
|
|
`${GEMINI_URL}?key=${process.env.GEMINI_API_KEY}`,
|
|
{
|
|
system_instruction: { parts: [{ text: SYSTEM_PROMPT }] },
|
|
contents,
|
|
generationConfig: { temperature: 0.6, maxOutputTokens: 400, topP: 0.9 },
|
|
},
|
|
{ headers: { 'Content-Type': 'application/json' }, timeout: 20000 }
|
|
);
|
|
|
|
let reply =
|
|
data?.candidates?.[0]?.content?.parts?.map(p => p.text).join(' ').trim() || '';
|
|
|
|
let escalate = false;
|
|
if (reply.includes('[[ESCALATE]]')) {
|
|
escalate = true;
|
|
reply = reply.replace(/\[\[ESCALATE\]\]/g, '').trim();
|
|
}
|
|
// Fallback signal from the user's own words.
|
|
if (!escalate && looksLikeEscalation(message)) escalate = true;
|
|
|
|
if (!reply) {
|
|
reply =
|
|
"I'm sorry, I didn't quite catch that. Could you rephrase, or reach our team via the Contact Us page?";
|
|
escalate = true;
|
|
}
|
|
|
|
return res.json({ reply, escalate });
|
|
} catch (err) {
|
|
// Don't leak provider errors to the client — degrade gracefully.
|
|
console.error('[Chat] Gemini error:', err.response?.data?.error?.message || err.message);
|
|
return res.json({
|
|
reply:
|
|
"I'm having trouble reaching my AI service right now. Our team can help directly — " +
|
|
'please use the Contact Us page and we will be in touch.',
|
|
escalate: true,
|
|
});
|
|
}
|
|
}
|
|
|
|
module.exports = { chat };
|