Added small backend server to manage LLM chat & contact me form
This commit is contained in:
@@ -0,0 +1,149 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import nodemailer from 'nodemailer';
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
// Serve static files from Vite build
|
||||||
|
app.use(express.static(path.join(__dirname, 'dist')));
|
||||||
|
// Contact API endpoint
|
||||||
|
app.post('/api/contact', async (req, res) => {
|
||||||
|
const { name, organisation, email, subject, message } = req.body;
|
||||||
|
if (!name || !email || !subject || !message) {
|
||||||
|
return res.status(400).json({ success: false, message: 'All fields are required' });
|
||||||
|
}
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
if (!emailRegex.test(email)) {
|
||||||
|
return res.status(400).json({ success: false, message: 'Invalid email address' });
|
||||||
|
}
|
||||||
|
const transporter = nodemailer.createTransport({
|
||||||
|
host: process.env.SMTP_HOST,
|
||||||
|
port: Number(process.env.SMTP_PORT),
|
||||||
|
secure: true,
|
||||||
|
auth: {
|
||||||
|
user: process.env.SMTP_USER,
|
||||||
|
pass: process.env.SMTP_PASS,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const contactEmail = process.env.CONTACT_EMAIL || 'andy@charlwood.xyz';
|
||||||
|
try {
|
||||||
|
// Admin notification
|
||||||
|
await transporter.sendMail({
|
||||||
|
from: `"${name}" <${process.env.SMTP_USER}>`,
|
||||||
|
replyTo: email,
|
||||||
|
to: contactEmail,
|
||||||
|
subject: `Portfolio Referral: ${subject}`,
|
||||||
|
html: `
|
||||||
|
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||||
|
<h2 style="color: #00897B; border-bottom: 2px solid #00897B; padding-bottom: 10px;">
|
||||||
|
New Patient Referral
|
||||||
|
</h2>
|
||||||
|
<div style="background: #f5f5f5; padding: 20px; border-radius: 8px; margin: 20px 0;">
|
||||||
|
<p><strong>Referring Clinician:</strong> ${name}</p>
|
||||||
|
<p><strong>Organisation:</strong> ${organisation || 'Not specified'}</p>
|
||||||
|
<p><strong>Email:</strong> ${email}</p>
|
||||||
|
<p><strong>Subject:</strong> ${subject}</p>
|
||||||
|
</div>
|
||||||
|
<div style="padding: 20px 0;">
|
||||||
|
<h3 style="color: #333;">Clinical Details:</h3>
|
||||||
|
<p style="white-space: pre-wrap; line-height: 1.6;">${message}</p>
|
||||||
|
</div>
|
||||||
|
<hr style="border: none; border-top: 1px solid #ddd; margin: 20px 0;" />
|
||||||
|
<p style="color: #666; font-size: 12px;">
|
||||||
|
This message was sent from your portfolio contact form.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
// Auto-reply
|
||||||
|
await transporter.sendMail({
|
||||||
|
from: `"Andy Charlwood" <${process.env.SMTP_USER}>`,
|
||||||
|
to: email,
|
||||||
|
subject: 'Thanks for getting in touch!',
|
||||||
|
html: `
|
||||||
|
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||||
|
<h2 style="color: #00897B; border-bottom: 2px solid #00897B; padding-bottom: 10px;">
|
||||||
|
Thanks for your message, ${name}!
|
||||||
|
</h2>
|
||||||
|
<p style="line-height: 1.6;">
|
||||||
|
I've received your referral and will get back to you as soon as possible.
|
||||||
|
</p>
|
||||||
|
<div style="background: #f5f5f5; padding: 20px; border-radius: 8px; margin: 20px 0;">
|
||||||
|
<p><strong>Your message:</strong></p>
|
||||||
|
<p style="white-space: pre-wrap; color: #555;">${message}</p>
|
||||||
|
</div>
|
||||||
|
<p style="line-height: 1.6;">
|
||||||
|
Best regards,<br/>
|
||||||
|
<strong>Andy Charlwood</strong><br/>
|
||||||
|
Informatics Pharmacist · NHS Norfolk & Waveney ICB
|
||||||
|
</p>
|
||||||
|
<hr style="border: none; border-top: 1px solid #ddd; margin: 20px 0;" />
|
||||||
|
<p style="color: #666; font-size: 12px;">
|
||||||
|
This is an automated confirmation. Please do not reply to this email.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
return res.status(200).json({ success: true, message: 'Referral sent successfully!' });
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error('Email error:', error);
|
||||||
|
return res.status(500).json({ success: false, message: 'Failed to send referral. Please try again.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Chat proxy endpoint — keeps API key server-side
|
||||||
|
app.post('/api/chat', async (req, res) => {
|
||||||
|
const apiKey = process.env.OPEN_ROUTER_API_KEY;
|
||||||
|
if (!apiKey) {
|
||||||
|
return res.status(500).json({ error: 'LLM API key not configured' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await fetch('https://openrouter.ai/api/v1/chat/completions', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${apiKey}`,
|
||||||
|
'HTTP-Referer': req.headers.origin || req.headers.referer || '',
|
||||||
|
'X-Title': 'Andy Charlwood Portfolio',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(req.body),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
return res.status(response.status).json({ error: `LLM API error: ${response.status}` });
|
||||||
|
}
|
||||||
|
res.setHeader('Content-Type', 'text/event-stream');
|
||||||
|
res.setHeader('Cache-Control', 'no-cache');
|
||||||
|
res.setHeader('Connection', 'keep-alive');
|
||||||
|
const reader = response.body?.getReader();
|
||||||
|
if (!reader) {
|
||||||
|
return res.status(500).json({ error: 'No response body' });
|
||||||
|
}
|
||||||
|
const pump = async () => {
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done)
|
||||||
|
break;
|
||||||
|
res.write(value);
|
||||||
|
}
|
||||||
|
res.end();
|
||||||
|
};
|
||||||
|
await pump();
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error('Chat proxy error:', error);
|
||||||
|
if (!res.headersSent) {
|
||||||
|
return res.status(500).json({ error: 'Failed to proxy chat request' });
|
||||||
|
}
|
||||||
|
res.end();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// SPA fallback
|
||||||
|
app.get('*', (_req, res) => {
|
||||||
|
res.sendFile(path.join(__dirname, 'dist', 'index.html'));
|
||||||
|
});
|
||||||
|
const PORT = process.env.PORT || 3000;
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`Server running on port ${PORT}`);
|
||||||
|
});
|
||||||
@@ -104,6 +104,56 @@ app.post('/api/contact', async (req, res) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Chat proxy endpoint — keeps API key server-side
|
||||||
|
app.post('/api/chat', async (req, res) => {
|
||||||
|
const apiKey = process.env.OPEN_ROUTER_API_KEY
|
||||||
|
if (!apiKey) {
|
||||||
|
return res.status(500).json({ error: 'LLM API key not configured' })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('https://openrouter.ai/api/v1/chat/completions', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${apiKey}`,
|
||||||
|
'HTTP-Referer': req.headers.origin || req.headers.referer || '',
|
||||||
|
'X-Title': 'Andy Charlwood Portfolio',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(req.body),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return res.status(response.status).json({ error: `LLM API error: ${response.status}` })
|
||||||
|
}
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', 'text/event-stream')
|
||||||
|
res.setHeader('Cache-Control', 'no-cache')
|
||||||
|
res.setHeader('Connection', 'keep-alive')
|
||||||
|
|
||||||
|
const reader = response.body?.getReader()
|
||||||
|
if (!reader) {
|
||||||
|
return res.status(500).json({ error: 'No response body' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const pump = async () => {
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read()
|
||||||
|
if (done) break
|
||||||
|
res.write(value)
|
||||||
|
}
|
||||||
|
res.end()
|
||||||
|
}
|
||||||
|
await pump()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Chat proxy error:', error)
|
||||||
|
if (!res.headersSent) {
|
||||||
|
return res.status(500).json({ error: 'Failed to proxy chat request' })
|
||||||
|
}
|
||||||
|
res.end()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// SPA fallback
|
// SPA fallback
|
||||||
app.get('*', (_req, res) => {
|
app.get('*', (_req, res) => {
|
||||||
res.sendFile(path.join(__dirname, 'dist', 'index.html'))
|
res.sendFile(path.join(__dirname, 'dist', 'index.html'))
|
||||||
|
|||||||
+3
-19
@@ -8,14 +8,8 @@ export interface ChatMessage {
|
|||||||
export const LLM_MODEL = 'z-ai/glm-5'
|
export const LLM_MODEL = 'z-ai/glm-5'
|
||||||
export const LLM_DISPLAY_NAME = 'GLM-5'
|
export const LLM_DISPLAY_NAME = 'GLM-5'
|
||||||
|
|
||||||
const OPENROUTER_API_URL = 'https://openrouter.ai/api/v1/chat/completions'
|
|
||||||
|
|
||||||
function getApiKey(): string | undefined {
|
|
||||||
return import.meta.env.VITE_OPEN_ROUTER_API_KEY as string | undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isLLMAvailable(): boolean {
|
export function isLLMAvailable(): boolean {
|
||||||
return !!getApiKey()
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildSystemPrompt(): string {
|
export function buildSystemPrompt(): string {
|
||||||
@@ -44,22 +38,12 @@ function buildRequestBody(
|
|||||||
export async function* sendChatMessage(
|
export async function* sendChatMessage(
|
||||||
messages: ChatMessage[],
|
messages: ChatMessage[],
|
||||||
): AsyncGenerator<string> {
|
): AsyncGenerator<string> {
|
||||||
const apiKey = getApiKey()
|
|
||||||
if (!apiKey) {
|
|
||||||
throw new Error('LLM API key not configured')
|
|
||||||
}
|
|
||||||
|
|
||||||
const systemPrompt = buildSystemPrompt()
|
const systemPrompt = buildSystemPrompt()
|
||||||
const body = buildRequestBody(messages, systemPrompt)
|
const body = buildRequestBody(messages, systemPrompt)
|
||||||
|
|
||||||
const response = await fetch(OPENROUTER_API_URL, {
|
const response = await fetch('/api/chat', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: { 'Content-Type': 'application/json' },
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Authorization': `Bearer ${apiKey}`,
|
|
||||||
'HTTP-Referer': window.location.origin,
|
|
||||||
'X-Title': 'Andy Charlwood Portfolio',
|
|
||||||
},
|
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2020",
|
"target": "ES2020",
|
||||||
"module": "ESNext",
|
"module": "NodeNext",
|
||||||
"moduleResolution": "node",
|
"moduleResolution": "NodeNext",
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"outDir": "dist-server",
|
"outDir": "dist-server",
|
||||||
"rootDir": ".",
|
"rootDir": ".",
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true
|
||||||
"include": ["server.ts"]
|
|
||||||
},
|
},
|
||||||
"include": ["server.ts"]
|
"include": ["server.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,4 +9,9 @@ export default defineConfig({
|
|||||||
'@': path.resolve(__dirname, './src'),
|
'@': path.resolve(__dirname, './src'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': 'http://localhost:3000',
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user