Some checks failed
Production Deployment (Zero Downtime) / deploy-production (push) Failing after 10m24s
- Add N8N_WEBHOOK_URL, N8N_SECRET_TOKEN, N8N_API_KEY to docker-compose.production.yml - Export environment variables in workflow before docker-compose up - Improve error logging in chat API for better debugging - Add better error handling in ChatWidget component - Create setup guide for n8n chat configuration
303 lines
11 KiB
TypeScript
303 lines
11 KiB
TypeScript
import { NextRequest, NextResponse } from "next/server";
|
|
import { decodeHtmlEntitiesServer } from "@/lib/html-decode";
|
|
|
|
export async function POST(request: NextRequest) {
|
|
let userMessage = "";
|
|
|
|
try {
|
|
// Rate limiting for n8n chat endpoint
|
|
const ip = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown';
|
|
const { checkRateLimit } = await import('@/lib/auth');
|
|
|
|
if (!checkRateLimit(ip, 20, 60000)) { // 20 requests per minute for chat
|
|
return NextResponse.json(
|
|
{ error: 'Rate limit exceeded. Please try again later.' },
|
|
{ status: 429 }
|
|
);
|
|
}
|
|
|
|
const json = await request.json();
|
|
userMessage = json.message;
|
|
const history = json.history || [];
|
|
|
|
if (!userMessage || typeof userMessage !== "string") {
|
|
return NextResponse.json(
|
|
{ error: "Message is required" },
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
|
|
// Call your n8n chat webhook
|
|
const n8nWebhookUrl = process.env.N8N_WEBHOOK_URL;
|
|
|
|
if (!n8nWebhookUrl || n8nWebhookUrl.trim() === '') {
|
|
console.error("N8N_WEBHOOK_URL not configured. Environment check:", {
|
|
hasUrl: !!process.env.N8N_WEBHOOK_URL,
|
|
urlValue: process.env.N8N_WEBHOOK_URL || '(empty)',
|
|
nodeEnv: process.env.NODE_ENV,
|
|
});
|
|
return NextResponse.json({
|
|
reply: getFallbackResponse(userMessage),
|
|
});
|
|
}
|
|
|
|
// Ensure URL doesn't have trailing slash before adding /webhook/chat
|
|
const baseUrl = n8nWebhookUrl.replace(/\/$/, '');
|
|
const webhookUrl = `${baseUrl}/webhook/chat`;
|
|
console.log(`Sending to n8n: ${webhookUrl}`, {
|
|
hasSecretToken: !!process.env.N8N_SECRET_TOKEN,
|
|
hasApiKey: !!process.env.N8N_API_KEY,
|
|
});
|
|
|
|
// Add timeout to prevent hanging requests
|
|
const controller = new AbortController();
|
|
const timeoutId = setTimeout(() => controller.abort(), 30000); // 30 second timeout
|
|
|
|
try {
|
|
const response = await fetch(webhookUrl, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
...(process.env.N8N_SECRET_TOKEN && {
|
|
Authorization: `Bearer ${process.env.N8N_SECRET_TOKEN}`,
|
|
}),
|
|
...(process.env.N8N_API_KEY && {
|
|
"X-API-Key": process.env.N8N_API_KEY,
|
|
}),
|
|
},
|
|
body: JSON.stringify({
|
|
message: userMessage,
|
|
history: history,
|
|
}),
|
|
signal: controller.signal,
|
|
});
|
|
|
|
clearTimeout(timeoutId);
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text().catch(() => 'Unknown error');
|
|
console.error(`n8n webhook failed with status: ${response.status}`, {
|
|
status: response.status,
|
|
statusText: response.statusText,
|
|
error: errorText,
|
|
webhookUrl: webhookUrl.replace(/\/\/[^:]+:[^@]+@/, '//***:***@'), // Hide credentials in logs
|
|
});
|
|
throw new Error(`n8n webhook failed: ${response.status} - ${errorText.substring(0, 200)}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
|
|
console.log("n8n response data (full):", JSON.stringify(data, null, 2));
|
|
console.log("n8n response data type:", typeof data);
|
|
console.log("n8n response is array:", Array.isArray(data));
|
|
|
|
// Try multiple ways to extract the reply
|
|
let reply: string | undefined = undefined;
|
|
|
|
// Direct fields
|
|
if (data.reply) reply = data.reply;
|
|
else if (data.message) reply = data.message;
|
|
else if (data.response) reply = data.response;
|
|
else if (data.text) reply = data.text;
|
|
else if (data.content) reply = data.content;
|
|
else if (data.answer) reply = data.answer;
|
|
else if (data.output) reply = data.output;
|
|
else if (data.result) reply = data.result;
|
|
|
|
// Array handling
|
|
else if (Array.isArray(data) && data.length > 0) {
|
|
const firstItem = data[0];
|
|
if (typeof firstItem === 'string') {
|
|
reply = firstItem;
|
|
} else if (typeof firstItem === 'object') {
|
|
reply = firstItem.reply || firstItem.message || firstItem.response ||
|
|
firstItem.text || firstItem.content || firstItem.answer ||
|
|
firstItem.output || firstItem.result;
|
|
}
|
|
}
|
|
|
|
// Nested structures (common in n8n)
|
|
else if (data && typeof data === "object") {
|
|
// Check nested data field
|
|
if (data.data) {
|
|
if (typeof data.data === 'string') {
|
|
reply = data.data;
|
|
} else if (typeof data.data === 'object') {
|
|
reply = data.data.reply || data.data.message || data.data.response ||
|
|
data.data.text || data.data.content || data.data.answer;
|
|
}
|
|
}
|
|
|
|
// Check nested json field
|
|
if (!reply && data.json) {
|
|
if (typeof data.json === 'string') {
|
|
reply = data.json;
|
|
} else if (typeof data.json === 'object') {
|
|
reply = data.json.reply || data.json.message || data.json.response ||
|
|
data.json.text || data.json.content || data.json.answer;
|
|
}
|
|
}
|
|
|
|
// Check items array (n8n often wraps in items)
|
|
if (!reply && Array.isArray(data.items) && data.items.length > 0) {
|
|
const firstItem = data.items[0];
|
|
if (typeof firstItem === 'string') {
|
|
reply = firstItem;
|
|
} else if (typeof firstItem === 'object') {
|
|
reply = firstItem.reply || firstItem.message || firstItem.response ||
|
|
firstItem.text || firstItem.content || firstItem.answer ||
|
|
firstItem.json?.reply || firstItem.json?.message;
|
|
}
|
|
}
|
|
|
|
// Last resort: if it's a single string value object, try to extract
|
|
if (!reply && Object.keys(data).length === 1) {
|
|
const value = Object.values(data)[0];
|
|
if (typeof value === 'string') {
|
|
reply = value;
|
|
}
|
|
}
|
|
|
|
// If still no reply but data exists, stringify it (for debugging)
|
|
if (!reply && Object.keys(data).length > 0) {
|
|
console.warn("n8n response structure not recognized, attempting to extract any string value");
|
|
// Try to find any string value in the object
|
|
const findStringValue = (obj: unknown): string | undefined => {
|
|
if (typeof obj === 'string' && obj.length > 0) return obj;
|
|
if (Array.isArray(obj) && obj.length > 0) {
|
|
return findStringValue(obj[0]);
|
|
}
|
|
if (obj && typeof obj === 'object' && obj !== null) {
|
|
const objRecord = obj as Record<string, unknown>;
|
|
for (const key of ['reply', 'message', 'response', 'text', 'content', 'answer', 'output', 'result']) {
|
|
if (objRecord[key] && typeof objRecord[key] === 'string') {
|
|
return objRecord[key] as string;
|
|
}
|
|
}
|
|
// Recursively search
|
|
for (const value of Object.values(objRecord)) {
|
|
const found = findStringValue(value);
|
|
if (found) return found;
|
|
}
|
|
}
|
|
return undefined;
|
|
};
|
|
reply = findStringValue(data);
|
|
}
|
|
}
|
|
|
|
if (!reply) {
|
|
console.error("n8n response missing reply field. Full response:", JSON.stringify(data, null, 2));
|
|
throw new Error("Invalid response format from n8n - no reply field found");
|
|
}
|
|
|
|
// Decode HTML entities in the reply
|
|
const decodedReply = decodeHtmlEntitiesServer(String(reply));
|
|
|
|
return NextResponse.json({
|
|
reply: decodedReply,
|
|
});
|
|
} catch (fetchError: unknown) {
|
|
clearTimeout(timeoutId);
|
|
|
|
if (fetchError instanceof Error && fetchError.name === 'AbortError') {
|
|
console.error("n8n webhook request timed out");
|
|
} else {
|
|
console.error("n8n webhook fetch error:", fetchError);
|
|
}
|
|
throw fetchError;
|
|
}
|
|
} catch (error: unknown) {
|
|
console.error("Chat API error:", error);
|
|
console.error("Error details:", {
|
|
message: error instanceof Error ? error.message : String(error),
|
|
stack: error instanceof Error ? error.stack : undefined,
|
|
n8nUrl: process.env.N8N_WEBHOOK_URL ? `configured (${process.env.N8N_WEBHOOK_URL})` : 'missing',
|
|
hasSecretToken: !!process.env.N8N_SECRET_TOKEN,
|
|
hasApiKey: !!process.env.N8N_API_KEY,
|
|
nodeEnv: process.env.NODE_ENV,
|
|
});
|
|
|
|
// Fallback to mock responses
|
|
// Now using the variable captured at the start
|
|
return NextResponse.json({ reply: getFallbackResponse(userMessage) });
|
|
}
|
|
}
|
|
|
|
function getFallbackResponse(message: string): string {
|
|
if (!message || typeof message !== "string") {
|
|
return "I'm having a bit of trouble understanding. Could you try asking again?";
|
|
}
|
|
|
|
const lowerMessage = message.toLowerCase();
|
|
|
|
if (
|
|
lowerMessage.includes("skill") ||
|
|
lowerMessage.includes("tech") ||
|
|
lowerMessage.includes("stack")
|
|
) {
|
|
return "I specialize in full-stack development with Next.js, React, and Flutter for mobile. On the DevOps side, I love working with Docker Swarm, Traefik, and CI/CD pipelines. Basically, if it involves code or servers, I'm interested!";
|
|
}
|
|
|
|
if (
|
|
lowerMessage.includes("project") ||
|
|
lowerMessage.includes("built") ||
|
|
lowerMessage.includes("work")
|
|
) {
|
|
return "One of my key projects is Clarity, a Flutter app designed to help people with dyslexia. I also maintain a comprehensive self-hosted infrastructure with Docker Swarm. You can check out more details in the Projects section!";
|
|
}
|
|
|
|
if (
|
|
lowerMessage.includes("contact") ||
|
|
lowerMessage.includes("email") ||
|
|
lowerMessage.includes("reach") ||
|
|
lowerMessage.includes("hire")
|
|
) {
|
|
return "The best way to reach me is through the contact form below or by emailing contact@dk0.dev. I'm always open to discussing new ideas, opportunities, or just chatting about tech!";
|
|
}
|
|
|
|
if (
|
|
lowerMessage.includes("location") ||
|
|
lowerMessage.includes("where") ||
|
|
lowerMessage.includes("live")
|
|
) {
|
|
return "I'm based in Osnabrück, Germany. It's a great place to be a student and work on tech projects!";
|
|
}
|
|
|
|
if (
|
|
lowerMessage.includes("hobby") ||
|
|
lowerMessage.includes("free time") ||
|
|
lowerMessage.includes("fun")
|
|
) {
|
|
return "When I'm not coding or tweaking my servers, I enjoy gaming, going for a jog, or experimenting with new tech. Fun fact: I still use pen and paper for my calendar, even though I automate everything else!";
|
|
}
|
|
|
|
if (
|
|
lowerMessage.includes("devops") ||
|
|
lowerMessage.includes("docker") ||
|
|
lowerMessage.includes("server") ||
|
|
lowerMessage.includes("hosting")
|
|
) {
|
|
return "I'm really into DevOps! I run my own infrastructure on IONOS and OVHcloud using Docker Swarm and Traefik. It allows me to host various services and game servers efficiently while learning a ton about system administration.";
|
|
}
|
|
|
|
if (
|
|
lowerMessage.includes("student") ||
|
|
lowerMessage.includes("study") ||
|
|
lowerMessage.includes("education")
|
|
) {
|
|
return "Yes, I'm currently a student in Osnabrück. I balance my studies with working on personal projects and managing my self-hosted infrastructure. It keeps me busy but I learn something new every day!";
|
|
}
|
|
|
|
if (
|
|
lowerMessage.includes("hello") ||
|
|
lowerMessage.includes("hi ") ||
|
|
lowerMessage.includes("hey")
|
|
) {
|
|
return "Hi there! I'm Dennis's AI assistant (currently in offline mode). How can I help you learn more about Dennis today?";
|
|
}
|
|
|
|
// Default response
|
|
return "That's an interesting question! I'm currently operating in fallback mode, so my knowledge is a bit limited right now. But I can tell you that Dennis is a full-stack developer and DevOps enthusiast who loves building with Next.js and Docker. Feel free to ask about his skills, projects, or how to contact him!";
|
|
}
|