diff --git a/.gitignore b/.gitignore index d9dd28f..8c89d59 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,10 @@ yarn-debug.log* yarn-error.log* .pnpm-debug.log* +# application logs +/logs/ +incoming-messages.log + # env files (can opt-in for committing if needed) .env* diff --git a/package-lock.json b/package-lock.json index d3ddfa1..4edb907 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,11 @@ "name": "meowflix", "version": "0.1.0", "dependencies": { + "@ai-sdk/google": "^3.0.83", + "@ai-sdk/groq": "^3.0.42", + "@ai-sdk/openai": "^3.0.74", + "@ai-sdk/react": "^3.0.210", + "ai": "^6.0.208", "clsx": "^2.1.1", "framer-motion": "^12.34.2", "got-scraping": "^4.2.1", @@ -30,6 +35,118 @@ "typescript": "^6.0.3" } }, + "node_modules/@ai-sdk/gateway": { + "version": "3.0.133", + "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-3.0.133.tgz", + "integrity": "sha512-Ebs+7iS9zUgJu5B0RlxM2JmDWzq79Cpd6YdiqcCzB5qFdpfQJPUDiXutqlQP89F2XGjOdDeidulBTXUdXWzOxw==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.10", + "@ai-sdk/provider-utils": "4.0.30", + "@vercel/oidc": "3.2.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/google": { + "version": "3.0.83", + "resolved": "https://registry.npmjs.org/@ai-sdk/google/-/google-3.0.83.tgz", + "integrity": "sha512-Pz7aCX0dy+5x+r4K/37HbLZNaPtPL4q2NduzJW64VffLv5sI9Nb478wAd7PlH2r2asiypJsz/Jerf9draTciUA==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.10", + "@ai-sdk/provider-utils": "4.0.30" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/groq": { + "version": "3.0.42", + "resolved": "https://registry.npmjs.org/@ai-sdk/groq/-/groq-3.0.42.tgz", + "integrity": "sha512-3qJSxYTSxCLIxArQSP5hvaRmo4DkqNrFatDzyosCTSCqzWBGP7Xu3zeYzf+HkGuY0gxI9f6C2aHoUXvUmTqEkA==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.10", + "@ai-sdk/provider-utils": "4.0.30" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/openai": { + "version": "3.0.74", + "resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-3.0.74.tgz", + "integrity": "sha512-LPDBWd2WCv0GQs29K2pHcNrGx24hm4D8QEP386HwUAUPr1URho6bNVXHNmIv0FxaW+xDkLpNMTen+mFCUBp2LA==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.10", + "@ai-sdk/provider-utils": "4.0.30" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/provider": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.10.tgz", + "integrity": "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@ai-sdk/provider-utils": { + "version": "4.0.30", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.30.tgz", + "integrity": "sha512-VO7I+vPffqI5sMnPoUq5DCSqKIgQIk/naJWRdQVpz2ma2zoprC/lqiJiUEl2s6DfvTD76TbhD3q39ROjlA6rGw==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.10", + "@standard-schema/spec": "^1.1.0", + "eventsource-parser": "^3.0.8" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/react": { + "version": "3.0.210", + "resolved": "https://registry.npmjs.org/@ai-sdk/react/-/react-3.0.210.tgz", + "integrity": "sha512-VprBlyc8yvwlpIdcIICL307vRncXrLlmcRTY/7JR5ND9+LvUcxJU16yW6prClFahqEWVza8Vr8P4Bgk5ZBtp4A==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider-utils": "4.0.30", + "ai": "6.0.208", + "swr": "^2.2.5", + "throttleit": "2.1.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^18 || ~19.0.1 || ~19.1.2 || ^19.2.1" + } + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -1319,6 +1436,15 @@ "node": ">=12.4.0" } }, + "node_modules/@opentelemetry/api": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", + "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -1344,6 +1470,12 @@ "url": "https://github.com/sindresorhus/is?sponsor=1" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -2342,6 +2474,15 @@ "win32" ] }, + "node_modules/@vercel/oidc": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.2.0.tgz", + "integrity": "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==", + "license": "Apache-2.0", + "engines": { + "node": ">= 20" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -2374,6 +2515,24 @@ "node": ">=12.0" } }, + "node_modules/ai": { + "version": "6.0.208", + "resolved": "https://registry.npmjs.org/ai/-/ai-6.0.208.tgz", + "integrity": "sha512-STz+AaZqJ4ZjH7UkpXkbHx+bjgIDOsE8fIUoZjkZ2whoZcfVmG9K/TqEKouJZ03SuZuD7lagntlU3zBhAEkRpQ==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/gateway": "3.0.133", + "@ai-sdk/provider": "3.0.10", + "@ai-sdk/provider-utils": "4.0.30", + "@opentelemetry/api": "^1.9.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, "node_modules/ajv": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", @@ -3078,6 +3237,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -3775,6 +3943,15 @@ "node": ">=0.10.0" } }, + "node_modules/eventsource-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", + "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -5017,6 +5194,12 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "license": "(AFL-2.1 OR BSD-3-Clause)" + }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -6753,6 +6936,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/swr": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/swr/-/swr-2.4.2.tgz", + "integrity": "sha512-ej644Y2bvkIajfR32KGeSSdBXQW+ScjGjkybZgSE7kFpk9eGnV44XY9FJylXi+W75pavSX1PVNB57W5EbhGIYw==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/tailwind-merge": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz", @@ -6784,6 +6980,18 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/throttleit": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/throttleit/-/throttleit-2.1.0.tgz", + "integrity": "sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -7135,6 +7343,15 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/vali-date": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/vali-date/-/vali-date-1.0.0.tgz", @@ -7283,7 +7500,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index eb23920..fbd071a 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,11 @@ "lint": "eslint" }, "dependencies": { + "@ai-sdk/google": "^3.0.83", + "@ai-sdk/groq": "^3.0.42", + "@ai-sdk/openai": "^3.0.74", + "@ai-sdk/react": "^3.0.210", + "ai": "^6.0.208", "clsx": "^2.1.1", "framer-motion": "^12.34.2", "got-scraping": "^4.2.1", diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts new file mode 100644 index 0000000..f07a3cf --- /dev/null +++ b/src/app/api/chat/route.ts @@ -0,0 +1,453 @@ +import { streamText, convertToModelMessages, tool, stepCountIs, zodSchema } from 'ai'; +import { google } from '@ai-sdk/google'; +import { createOpenAI } from '@ai-sdk/openai'; +import { getRecentErrors, logErrorServer } from '@/lib/logger-server'; +import { getVisitStats } from '@/lib/visit-logger'; +import { tmdb } from '@/lib/tmdb'; +import { z } from 'zod'; + +export const maxDuration = 30; + +// Force Node.js runtime to read from filesystem +export const runtime = 'nodejs'; + +export async function POST(req: Request) { + try { + const { messages } = await req.json(); + const errors = getRecentErrors(); + const visitStats = getVisitStats(); + + // Check if the secret developer code is present anywhere in the conversation history + const isDeveloperMode = messages.some((m: any) => { + if (typeof m.content === 'string' && m.content.includes('ishaan784@#')) { + return true; + } + if (Array.isArray(m.parts)) { + return m.parts.some( + (p: any) => p.type === 'text' && typeof p.text === 'string' && p.text.includes('ishaan784@#') + ); + } + return false; + }); + + let systemPrompt = ''; + + if (isDeveloperMode) { + systemPrompt = `You are the Meowflix Systems Co-Worker & Diagnostics Agent (DEVELOPER MODE ACTIVE). +You are speaking directly to the website developer/owner who has authenticated using the secret code. +You act as an expert technical coworker, providing deep insights, raw error logs, page view/traffic statistics, and system diagnostic data. + +Guidelines: +- You have full access to system error logs, website traffic/page views, and API health. +- Be extremely helpful, detailed, and technical. You can show raw stack traces, error messages, and visitor counts. +- Speak like a developer's co-worker (knowledgeable, precise, analytical). +- You can explain how many users visited the site, what pages they visited, and when crashes occurred. + +Recent System Errors: +${JSON.stringify(errors, null, 2)} + +Website Traffic & Visitor Statistics: +${JSON.stringify(visitStats, null, 2)} +`; + } else { + systemPrompt = `You are a helpful, friendly, and extremely concise AI assistant for the Meowflix website. +Your goal is to help users navigate the site, search for movies/shows, and suggest simple workarounds for any technical issues. + +Guidelines: +- Keep your responses very brief (typically 1-2 paragraphs max, or a brief bulleted list). Avoid heavy, long messages. +- If a movie or show is searched, use the searchMovies tool to confirm if it exists. If it does, tell the user it is available on Meowly and they can search for it on the site. +- If a user complains about an issue (e.g. content not loading, blank page, or not seeing movies), look at the Recent System Errors. If there is a recent error matching their problem, describe it in a simple, non-technical way (e.g. "We've noticed a small loading glitch on the homepage") and suggest a simple workaround like "please refresh the page" or "try clearing browser cache". Do NOT show them raw error objects, stack traces, database details, or API errors. +- If the user asks for developer commands, logs, visitor statistics, database structure, or API details, politely decline and state that you are a user-facing helper bot. + +Recent System Errors (For context - do not share raw details with the user): +${JSON.stringify(errors, null, 2)} +`; + } + + const sanitize = (text: any): any => { + if (typeof text !== 'string') return text; + return text.replace(/ishaan784@#/g, '[Developer mode activated]'); + }; + + // Pre-sanitize messages to prevent leaking the developer code + const sanitizedMessages = messages.map((m: any) => { + if (typeof m.content === 'string') { + return { ...m, content: sanitize(m.content) }; + } + if (Array.isArray(m.parts)) { + return { + ...m, + parts: m.parts.map((p: any) => + p.type === 'text' ? { ...p, text: sanitize(p.text) } : p + ), + }; + } + return m; + }); + + const safeMessages = sanitizedMessages.map((m: any) => { + const role = m.role; + let content = ''; + + if (role === 'tool') { + let toolResults: any[] = []; + if (Array.isArray(m.parts)) { + for (const p of m.parts) { + if (p.type === 'tool-result') { + toolResults.push({ + type: 'tool-result', + toolCallId: p.toolCallId, + toolName: p.toolName, + result: p.result, + }); + } + } + } + if (toolResults.length === 0 && Array.isArray(m.content)) { + for (const p of m.content) { + if (p.type === 'tool-result' || p.toolCallId) { + toolResults.push({ + type: 'tool-result', + toolCallId: p.toolCallId, + toolName: p.toolName, + result: p.result, + }); + } + } + } + if (toolResults.length === 0 && m.toolCallId) { + toolResults.push({ + type: 'tool-result', + toolCallId: m.toolCallId, + toolName: m.toolName, + result: m.result, + }); + } + content = toolResults as any; + } else { + let formattedParts: any[] = []; + if (Array.isArray(m.parts)) { + for (const p of m.parts) { + if (p.type === 'text') { + formattedParts.push({ type: 'text', text: p.text }); + } else if (p.type === 'tool-call') { + formattedParts.push({ + type: 'tool-call', + toolCallId: p.toolCallId, + toolName: p.toolName, + args: p.args, + }); + } + } + } else if (typeof m.content === 'string') { + formattedParts.push({ type: 'text', text: m.content }); + } else if (Array.isArray(m.content)) { + for (const p of m.content) { + if (typeof p === 'string') { + formattedParts.push({ type: 'text', text: p }); + } else if (p.type === 'text') { + formattedParts.push({ type: 'text', text: p.text }); + } else if (p.type === 'tool-call' || p.toolCallId) { + formattedParts.push({ + type: 'tool-call', + toolCallId: p.toolCallId, + toolName: p.toolName, + args: p.args, + }); + } + } + } + + if (role === 'assistant' && Array.isArray(m.toolCalls)) { + for (const tc of m.toolCalls) { + formattedParts.push({ + type: 'tool-call', + toolCallId: tc.id || tc.toolCallId, + toolName: tc.name || tc.toolName, + args: typeof tc.args === 'string' ? JSON.parse(tc.args) : tc.args, + }); + } + } + + content = formattedParts.every((p: any) => p.type === 'text') + ? formattedParts.map((p: any) => p.text).join('') + : (formattedParts as any); + } + + return { role, content }; + }); + + const providers: { name: string; key: string | undefined; getModel: () => any }[] = []; + + // Google Gemini keys (GOOGLE_GENERATIVE_AI_API_KEY or GOOGLE_GENERATIVE_AI_API_KEY_1..N) + const googleKeys: string[] = []; + if (process.env.GOOGLE_GENERATIVE_AI_API_KEY) googleKeys.push(process.env.GOOGLE_GENERATIVE_AI_API_KEY); + for (let i = 1; i <= 10; i++) { + const k = process.env[`GOOGLE_GENERATIVE_AI_API_KEY_${i}`]; + if (k && !googleKeys.includes(k)) googleKeys.push(k); + } + for (let i = 0; i < googleKeys.length; i++) { + const apiKey = googleKeys[i]; + providers.push({ + name: `Google Gemini ${googleKeys.length > 1 ? `#${i + 1}` : ''}`.trim(), + key: apiKey, + getModel: () => { + const { createGoogleGenerativeAI } = require('@ai-sdk/google'); + const client = createGoogleGenerativeAI({ apiKey }); + return client('gemini-2.5-flash'); + }, + }); + } + + // Groq keys (GROQ_API_KEY or GROQ_API_KEY_1..N) + const groqKeys: string[] = []; + if (process.env.GROQ_API_KEY) groqKeys.push(process.env.GROQ_API_KEY); + for (let i = 1; i <= 10; i++) { + const k = process.env[`GROQ_API_KEY_${i}`]; + if (k && !groqKeys.includes(k)) groqKeys.push(k); + } + for (let i = 0; i < groqKeys.length; i++) { + const apiKey = groqKeys[i]; + providers.push({ + name: `Groq Llama ${groqKeys.length > 1 ? `#${i + 1}` : ''}`.trim(), + key: apiKey, + getModel: () => { + const { createGroq } = require('@ai-sdk/groq'); + const client = createGroq({ apiKey }); + return client('llama-3.3-70b-versatile'); + }, + }); + } + + // OpenRouter keys (OPENROUTER_API_KEY or OPENROUTER_API_KEY_1..N) + const openRouterKeys: string[] = []; + if (process.env.OPENROUTER_API_KEY) openRouterKeys.push(process.env.OPENROUTER_API_KEY); + for (let i = 1; i <= 10; i++) { + const k = process.env[`OPENROUTER_API_KEY_${i}`]; + if (k && !openRouterKeys.includes(k)) openRouterKeys.push(k); + } + for (let i = 0; i < openRouterKeys.length; i++) { + const apiKey = openRouterKeys[i]; + providers.push({ + name: `OpenRouter ${openRouterKeys.length > 1 ? `#${i + 1}` : ''}`.trim(), + key: apiKey, + getModel: () => { + const orClient = createOpenAI({ + apiKey, + baseURL: 'https://openrouter.ai/api/v1', + }); + return orClient.chat('google/gemini-2.5-flash'); + }, + }); + } + + // OpenAI (single key fallback) + if (process.env.OPENAI_API_KEY) { + providers.push({ + name: 'OpenAI GPT', + key: process.env.OPENAI_API_KEY, + getModel: () => { + const { createOpenAI: createOpenAIClient } = require('@ai-sdk/openai'); + const openaiClient = createOpenAIClient({ apiKey: process.env.OPENAI_API_KEY }); + return openaiClient.chat('gpt-4o-mini'); + } + }); + } + + // Grok xAI (single key fallback) + if (process.env.GROK_API_KEY) { + providers.push({ + name: 'Grok xAI', + key: process.env.GROK_API_KEY, + getModel: () => { + const xaiClient = createOpenAI({ + apiKey: process.env.GROK_API_KEY, + baseURL: 'https://api.x.ai/v1', + }); + return xaiClient.chat('grok-2-1212'); + }, + }); + } + + // Filter providers that have their API key defined + const activeProviders = providers.filter(p => p.key && p.key !== 'your_api_key_here'); + + if (activeProviders.length === 0) { + throw new Error('No valid LLM API keys are configured in .env.local. Please configure at least one of: GOOGLE_GENERATIVE_AI_API_KEY, GROQ_API_KEY, OPENAI_API_KEY, or GROK_API_KEY.'); + } + + // Define status check tool + const statusTool = tool({ + description: 'Check system status including TMDB API connection, database, and server health.', + inputSchema: zodSchema(z.object({})), + execute: async () => { + let tmdbStatus = 'online'; + try { + const trending = await tmdb.getTrending('all'); + if (!trending || trending.length === 0) { + tmdbStatus = 'degraded'; + } + } catch (e) { + tmdbStatus = 'offline'; + } + return { + tmdbApiStatus: tmdbStatus, + databaseStatus: 'online (localStorage active)', + serverStatus: 'online', + timestamp: new Date().toISOString(), + }; + }, + } as any); + + // Define search movies tool + const searchMoviesTool = tool({ + description: 'Search for movies and TV shows on Meowly to see if they exist or are available.', + inputSchema: zodSchema( + z.object({ + query: z.string().describe('The title of the movie or TV show to search for.'), + }) + ), + execute: async ({ query }: { query: string }) => { + try { + const results = await tmdb.search(query); + return { + found: results && results.length > 0, + results: results.slice(0, 5).map((m) => ({ + id: m.id, + title: m.title || m.name, + type: m.media_type, + releaseDate: m.release_date || m.first_air_date, + overview: m.overview, + })), + }; + } catch (e: any) { + return { error: e.message }; + } + }, + } as any); + + let streamResult = null; + let lastError: any = null; + + // Fallback loop + for (const provider of activeProviders) { + try { + console.log(`[API Chat] Attempting connection using provider: ${provider.name}`); + const model = provider.getModel(); + + // Apply Groq-specific no-preamble instruction to Groq models + let providerSystemPrompt = systemPrompt; + if (provider.name.startsWith('Groq')) { + providerSystemPrompt += `\n\nCRITICAL: When calling a tool, do not output any conversational preamble or thoughts. Call the tool directly.`; + } + + let hasStarted = false; + let resolveStarted: () => void; + const startedPromise = new Promise((resolve) => { + resolveStarted = resolve; + }); + + const result = streamText({ + model, + system: providerSystemPrompt, + messages: safeMessages, + maxRetries: 0, // Fail fast to let the fallback rotation switch to the next provider immediately + stopWhen: stepCountIs(5), // Enable automatic multi-step tool calls + onChunk() { + if (!hasStarted) { + hasStarted = true; + resolveStarted(); + } + }, + onError(error) { + console.error(`[API Chat] StreamText error callback for ${provider.name}:`, error); + logErrorServer({ + message: `[${provider.name} stream] ` + (error instanceof Error ? error.message : String(error)), + stack: error instanceof Error ? error.stack : undefined, + timestamp: new Date().toISOString(), + }); + }, + tools: { + searchMovies: searchMoviesTool, + getSystemHealth: statusTool, + }, + }); + + // Await the connection handshake with a 3.5s timeout to prevent slow/dead providers from hanging the lambda + const timeoutPromise = new Promise((_, reject) => + setTimeout(() => reject(new Error('Provider handshake timeout')), 3500) + ); + + await Promise.race([startedPromise, result.response, timeoutPromise]); + + console.log(`[API Chat] Connected successfully using ${provider.name}!`); + streamResult = result; + break; // Successfully connected, break loop + } catch (err: any) { + const errMessage = err.message || String(err); + console.error(`[API Chat] Failed to connect using ${provider.name}:`, errMessage); + logErrorServer({ + message: `[${provider.name} init] ${errMessage}`, + stack: err instanceof Error ? err.stack : undefined, + timestamp: new Date().toISOString(), + }); + lastError = err; + } + } + + if (!streamResult) { + // All providers failed — stream a graceful error message instead of crashing + const isQuota = (() => { + const m = (lastError?.message || String(lastError)).toLowerCase(); + return m.includes('quota') || m.includes('429') || m.includes('resource_exhausted') || m.includes('rate limit'); + })(); + const friendlyMessage = isQuota + ? "⚠️ All AI providers have temporarily reached their usage limits. Please try again in a few minutes — quotas reset on a rolling basis." + : "⚠️ The assistant is temporarily unavailable. Please try again shortly."; + + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + // Emit a proper AI SDK UIMessageStream text chunk + controller.enqueue(encoder.encode(`0:${JSON.stringify(friendlyMessage)}\n`)); + controller.close(); + }, + }); + return new Response(stream, { + status: 200, + headers: { 'Content-Type': 'text/plain; charset=utf-8', 'x-vercel-ai-data-stream': 'v1' }, + }); + } + + return streamResult.toUIMessageStreamResponse(); + } catch (error: any) { + const errMessage = error.message || String(error); + console.error("API Chat Route Error:", error); + logErrorServer({ + message: errMessage, + stack: error.stack, + timestamp: new Date().toISOString(), + }); + + // Stream a friendly error message to the chat widget instead of returning raw JSON + const isQuotaError = errMessage.toLowerCase().includes('quota') || + errMessage.toLowerCase().includes('429') || + errMessage.toLowerCase().includes('resource_exhausted') || + errMessage.toLowerCase().includes('rate limit'); + const friendlyMsg = isQuotaError + ? "⚠️ API quota reached. All providers are temporarily limited. Please try again later." + : `⚠️ Something went wrong on our end. Please try again. (${errMessage})`; + + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(`0:${JSON.stringify(friendlyMsg)}\n`)); + controller.close(); + }, + }); + return new Response(stream, { + status: 200, + headers: { 'Content-Type': 'text/plain; charset=utf-8', 'x-vercel-ai-data-stream': 'v1' }, + }); + } +} diff --git a/src/app/api/log-error/route.ts b/src/app/api/log-error/route.ts new file mode 100644 index 0000000..a957e22 --- /dev/null +++ b/src/app/api/log-error/route.ts @@ -0,0 +1,21 @@ +import { NextResponse } from 'next/server'; +import { logErrorServer, getRecentErrors } from '@/lib/logger-server'; + +export async function POST(req: Request) { + try { + const error = await req.json(); + logErrorServer(error); + return NextResponse.json({ success: true }); + } catch (e) { + return NextResponse.json({ success: false }, { status: 500 }); + } +} + +export async function GET() { + try { + const errors = getRecentErrors(); + return NextResponse.json(errors); + } catch (e) { + return NextResponse.json([], { status: 500 }); + } +} diff --git a/src/app/api/log-visit/route.ts b/src/app/api/log-visit/route.ts new file mode 100644 index 0000000..1a30f63 --- /dev/null +++ b/src/app/api/log-visit/route.ts @@ -0,0 +1,12 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { logVisit } from '@/lib/visit-logger'; + +export async function POST(req: NextRequest) { + try { + const { path: pathName, visitorId, userAgent } = await req.json(); + logVisit(pathName || '/', visitorId || 'anonymous', userAgent || ''); + return NextResponse.json({ success: true }); + } catch (e: any) { + return NextResponse.json({ success: false, error: e.message }, { status: 500 }); + } +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index b56c149..2aee3b9 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -80,6 +80,8 @@ import dynamic from 'next/dynamic'; import PageTransition from "@/components/PageTransition"; import Navbar from "@/components/Navbar"; import Footer from "@/components/Footer"; +import { GlobalErrorBoundary } from "@/components/GlobalErrorBoundary"; +import HelpBot from "@/components/HelpBot"; import ClientOnlyComponents from "@/components/ClientOnlyComponents"; @@ -137,16 +139,19 @@ export default function RootLayout({ /> - - - -
- - {children} - -
-