From acf2a6c3b630b0100607af411972d4b2e02e71fa Mon Sep 17 00:00:00 2001 From: Brunno Vanelli Date: Sat, 24 Jan 2026 13:20:21 +0100 Subject: [PATCH] feat: Integrate n8n endpoints too. --- app/api/generate-options/route.ts | 24 +---- app/api/plan/route.ts | 24 +---- app/api/refine/route.ts | 24 +---- env.example | 13 +++ lib/llm-client.ts | 157 ++++++++++++++++++++++++++++++ package-lock.json | 36 +++++++ 6 files changed, 221 insertions(+), 57 deletions(-) create mode 100644 lib/llm-client.ts diff --git a/app/api/generate-options/route.ts b/app/api/generate-options/route.ts index 7cd3479..f101235 100644 --- a/app/api/generate-options/route.ts +++ b/app/api/generate-options/route.ts @@ -1,10 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; -import { OpenRouter } from "@openrouter/sdk"; import { z } from "zod"; - -const openrouter = new OpenRouter({ - apiKey: process.env.OPENROUTER_API_KEY, -}); +import { chatCompletion, getFastModel } from "@/lib/llm-client"; // Schema for options response const OptionsResponseSchema = z.object({ @@ -53,27 +49,17 @@ ${questions.map((q: string, i: number) => `${i}. ${q}`).join("\n")} Generate 3-4 answer options for each question.`; - const completion = await openrouter.chat.send({ - model: "google/gemini-2.0-flash-lite-001", + const content = await chatCompletion({ messages: [ { role: "system", content: SYSTEM_PROMPT }, { role: "user", content: userMessage }, ], - responseFormat: { type: "json_object" }, + model: getFastModel(), + jsonMode: true, }); - const content = completion.choices?.[0]?.message?.content; - - if (!content) { - return NextResponse.json( - { error: "No response from LLM" }, - { status: 500 } - ); - } - // Parse and validate the response - const contentStr = typeof content === "string" ? content : JSON.stringify(content); - const parsed = JSON.parse(contentStr); + const parsed = JSON.parse(content); const validated = OptionsResponseSchema.parse(parsed); return NextResponse.json(validated); diff --git a/app/api/plan/route.ts b/app/api/plan/route.ts index 04fa57e..b391509 100644 --- a/app/api/plan/route.ts +++ b/app/api/plan/route.ts @@ -1,10 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; -import { OpenRouter } from "@openrouter/sdk"; import { z } from "zod"; - -const openrouter = new OpenRouter({ - apiKey: process.env.OPENROUTER_API_KEY, -}); +import { chatCompletion, getDefaultModel } from "@/lib/llm-client"; // Schema for clarifying questions response const ClarifyingResponseSchema = z.object({ @@ -95,27 +91,17 @@ export async function POST(request: NextRequest) { userMessage = `Original request: ${prompt}\n\nYou asked these questions:\n${questionTexts.map((q: string, i: number) => `${i + 1}. ${q}`).join("\n")}\n\nUser's answers:\n${userResponses.map((a: string, i: number) => `${i + 1}. ${a}`).join("\n")}\n\nNow generate the itinerary based on this information.`; } - const completion = await openrouter.chat.send({ - model: "openai/gpt-4o-mini", + const content = await chatCompletion({ messages: [ { role: "system", content: SYSTEM_PROMPT }, { role: "user", content: userMessage }, ], - responseFormat: { type: "json_object" }, + model: getDefaultModel(), + jsonMode: true, }); - const content = completion.choices?.[0]?.message?.content; - - if (!content) { - return NextResponse.json( - { error: "No response from LLM" }, - { status: 500 } - ); - } - // Parse and validate the response - const contentStr = typeof content === "string" ? content : JSON.stringify(content); - const parsed = JSON.parse(contentStr); + const parsed = JSON.parse(content); const validated = PlanResponseSchema.parse(parsed); return NextResponse.json(validated); diff --git a/app/api/refine/route.ts b/app/api/refine/route.ts index 122af1c..f1604cc 100644 --- a/app/api/refine/route.ts +++ b/app/api/refine/route.ts @@ -1,10 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; -import { OpenRouter } from "@openrouter/sdk"; import { z } from "zod"; - -const openrouter = new OpenRouter({ - apiKey: process.env.OPENROUTER_API_KEY, -}); +import { chatCompletion, getDefaultModel } from "@/lib/llm-client"; // Schema for refinement response const RefineResponseSchema = z.object({ @@ -108,29 +104,19 @@ ${day.activities.map((a) => ` - ${a.time}: ${a.name} - ${a.description}`).join( ) .join("\n\n")}`; - const completion = await openrouter.chat.send({ - model: "openai/gpt-4o-mini", + const content = await chatCompletion({ messages: [ { role: "system", content: SYSTEM_PROMPT }, { role: "user", content: `Here is my current trip:\n\n${itineraryContext}` }, ...conversationHistory, { role: "user", content: message }, ], - responseFormat: { type: "json_object" }, + model: getDefaultModel(), + jsonMode: true, }); - const content = completion.choices?.[0]?.message?.content; - - if (!content) { - return NextResponse.json( - { error: "No response from LLM" }, - { status: 500 } - ); - } - // Parse and validate the response - const contentStr = typeof content === "string" ? content : JSON.stringify(content); - const parsed = JSON.parse(contentStr); + const parsed = JSON.parse(content); // If there's an updatedItinerary, ensure all activities have required fields if (parsed.updatedItinerary && parsed.updatedItinerary.days) { diff --git a/env.example b/env.example index 4f3ab26..d483214 100644 --- a/env.example +++ b/env.example @@ -3,6 +3,19 @@ CONVEX_DEPLOYMENT= NEXT_PUBLIC_CONVEX_URL= +# LLM Backend: "openrouter" (default) or "n8n" +LLM_BACKEND=openrouter + +# Required if LLM_BACKEND=openrouter OPENROUTER_API_KEY= +# Required if LLM_BACKEND=n8n +N8N_WEBHOOK_URL= +# Auth Option 1: Basic Auth +N8N_USER= +N8N_PASSWORD= +# Auth Option 2: Header Auth (takes precedence over Basic Auth) +N8N_AUTH_HEADER_NAME= +N8N_AUTH_HEADER_VALUE= + NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN= \ No newline at end of file diff --git a/lib/llm-client.ts b/lib/llm-client.ts new file mode 100644 index 0000000..37cdc16 --- /dev/null +++ b/lib/llm-client.ts @@ -0,0 +1,157 @@ +import { OpenRouter } from "@openrouter/sdk"; + +export type LLMBackend = "openrouter" | "n8n"; + +interface ChatMessage { + role: "system" | "user" | "assistant"; + content: string; +} + +interface ChatCompletionOptions { + messages: ChatMessage[]; + model?: string; + jsonMode?: boolean; + sessionId?: string; +} + +interface N8nChatResponse { + output?: string | object; + response?: string | object; + text?: string | object; + message?: string | object; + [key: string]: unknown; +} + +function getBackend(): LLMBackend { + const backend = process.env.LLM_BACKEND || "openrouter"; + if (backend !== "openrouter" && backend !== "n8n") { + console.warn(`Unknown LLM_BACKEND "${backend}", falling back to openrouter`); + return "openrouter"; + } + return backend; +} + +function createOpenRouterClient(): OpenRouter { + return new OpenRouter({ + apiKey: process.env.OPENROUTER_API_KEY, + }); +} + +function getN8nAuthHeaders(): Record { + const user = process.env.N8N_USER; + const password = process.env.N8N_PASSWORD; + const headerAuthName = process.env.N8N_AUTH_HEADER_NAME; + const headerAuthValue = process.env.N8N_AUTH_HEADER_VALUE; + + const headers: Record = { + "Content-Type": "application/json", + }; + + // Option 1: Header Auth (custom header name + value) + if (headerAuthName && headerAuthValue) { + headers[headerAuthName] = headerAuthValue; + return headers; + } + + // Option 2: Basic Auth (username + password) + if (user && password) { + const credentialString = `${user}:${password}`; + const credentials = Buffer.from(credentialString).toString("base64"); + headers["Authorization"] = `Basic ${credentials}`; + } + + return headers; +} + +async function callN8nChatTrigger(options: ChatCompletionOptions): Promise { + const { messages, sessionId } = options; + + // Format messages into a single prompt for n8n Chat Trigger + // Include system message as context, then the conversation + const formattedMessages = messages.map((msg) => { + if (msg.role === "system") { + return `[System Instructions]\n${msg.content}\n[End System Instructions]`; + } + return `${msg.role === "user" ? "User" : "Assistant"}: ${msg.content}`; + }).join("\n\n"); + + const baseUrl = process.env.N8N_WEBHOOK_URL; + + if (!baseUrl) { + throw new Error("N8N_WEBHOOK_URL is not set. Set it in .env.local"); + } + + const response = await fetch(baseUrl, { + method: "POST", + headers: getN8nAuthHeaders(), + body: JSON.stringify({ + chatInput: formattedMessages, + sessionId: sessionId || `session-${Date.now()}`, + }), + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => "Unknown error"); + throw new Error(`n8n API error (${response.status}): ${errorText}`); + } + + const data: N8nChatResponse = await response.json(); + + // n8n Chat Trigger can return response in different fields + const content = data.output || data.response || data.text || data.message; + + if (!content) { + // If no known field, try to find any string value or stringify the whole response + const firstStringValue = Object.values(data).find((v) => typeof v === "string") as string | undefined; + if (firstStringValue) { + return firstStringValue; + } + throw new Error("No response content from n8n Chat Trigger"); + } + + // If content is already an object, stringify it + if (typeof content === "object") { + return JSON.stringify(content); + } + + return content; +} + +export async function chatCompletion(options: ChatCompletionOptions): Promise { + const backend = getBackend(); + const { messages, model, jsonMode = true } = options; + + if (backend === "n8n") { + // Use n8n Chat Trigger (direct HTTP call) + return callN8nChatTrigger(options); + } + + // Default: OpenRouter + if (!process.env.OPENROUTER_API_KEY) { + throw new Error("OPENROUTER_API_KEY is not set. Set it in .env.local or switch to LLM_BACKEND=n8n"); + } + + const client = createOpenRouterClient(); + + const completion = await client.chat.send({ + model: model || "openai/gpt-4o-mini", + messages, + ...(jsonMode && { responseFormat: { type: "json_object" } }), + }); + + const content = completion.choices?.[0]?.message?.content; + if (!content) { + throw new Error("No response from OpenRouter"); + } + return typeof content === "string" ? content : JSON.stringify(content); +} + +export function getDefaultModel(): string { + const backend = getBackend(); + return backend === "n8n" ? "gpt-4o-mini" : "openai/gpt-4o-mini"; +} + +export function getFastModel(): string { + const backend = getBackend(); + return backend === "n8n" ? "gpt-4o-mini" : "google/gemini-2.0-flash-lite-001"; +} diff --git a/package-lock.json b/package-lock.json index deb6742..4e54816 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "lucide-react": "^0.563.0", "mapbox-gl": "^3.0.1", "next": "16.1.4", + "openai": "^6.16.0", "react": "19.2.3", "react-dom": "19.2.3", "react-map-gl": "^7.1.7", @@ -81,6 +82,7 @@ "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", @@ -2515,6 +2517,7 @@ "integrity": "sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2525,6 +2528,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -2583,6 +2587,7 @@ "integrity": "sha512-nm3cvFN9SqZGXjmw5bZ6cGmvJSyJPn0wU9gHAZZHDnZl2wF9PhHv78Xf06E0MaNk4zLVHL8hb2/c32XvyJOLQg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.53.1", "@typescript-eslint/types": "8.53.1", @@ -3082,6 +3087,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3440,6 +3446,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -4139,6 +4146,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -4324,6 +4332,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -6051,6 +6060,7 @@ "resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-3.18.1.tgz", "integrity": "sha512-Izc8dee2zkmb6Pn9hXFbVioPRLXJz1OFUcrvri69MhFACPU4bhLyVmhEsD9AyW1qOAP0Yvhzm60v63xdMIHPPw==", "license": "SEE LICENSE IN LICENSE.txt", + "peer": true, "workspaces": [ "src/style-spec", "test/build/vite", @@ -6418,6 +6428,27 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/openai": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.16.0.tgz", + "integrity": "sha512-fZ1uBqjFUjXzbGc35fFtYKEOxd20kd9fDpFeqWtsOZWiubY8CZ1NAlXHW3iathaFvqmNtCWMIsosCuyeI7Joxg==", + "license": "Apache-2.0", + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -6687,6 +6718,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -6696,6 +6728,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -7541,6 +7574,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -7719,6 +7753,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -8023,6 +8058,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" }