diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index 4096681..989d7c6 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -2,6 +2,8 @@ import { createGroq } from "@ai-sdk/groq" import { convertToModelMessages, streamText, type UIMessage } from "ai" import { NextResponse } from "next/server" import { createServerSupabaseClient } from '@/lib/database/supabase-server' +import { rateLimit } from '@/lib/utils/rate-limiter' +import { z } from 'zod' const GROQ_MODELS: Record = { "llama-3.3-70b-versatile": "llama-3.3-70b-versatile", @@ -10,19 +12,32 @@ const GROQ_MODELS: Record = { "mixtral-8x7b-32768": "mixtral-8x7b-32768", } -type ChatRole = "system" | "user" | "assistant" +const DEFAULT_MODEL = "llama-3.3-70b-versatile" +const MAX_MESSAGES = 24 +const MAX_MESSAGE_CHARS = 4000 +const MAX_TOTAL_CHARS = 16000 +const MAX_OUTPUT_TOKENS = 900 + +const chatRequestSchema = z.object({ + messages: z.array(z.unknown()).min(1).max(MAX_MESSAGES), + model: z.string().optional(), +}).strict() + +type ChatRole = "user" | "assistant" function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null } function isChatRole(role: unknown): role is ChatRole { - return role === "system" || role === "user" || role === "assistant" + return role === "user" || role === "assistant" } function normalizeChatMessages(messages: unknown): UIMessage[] { if (!Array.isArray(messages)) return [] + let totalChars = 0 + return messages.flatMap((message, index) => { if (!isRecord(message) || !isChatRole(message.role)) return [] if (message.id === "welcome") return [] @@ -42,7 +57,12 @@ function normalizeChatMessages(messages: unknown): UIMessage[] { ? [{ type: "text" as const, text: message.content }] : [] - const parts = textParts.length > 0 ? textParts : legacyContent + const parts = (textParts.length > 0 ? textParts : legacyContent) + .map((part) => ({ ...part, text: part.text.slice(0, MAX_MESSAGE_CHARS) })) + .filter((part) => { + totalChars += part.text.length + return totalChars <= MAX_TOTAL_CHARS + }) if (parts.length === 0) return [] @@ -58,57 +78,88 @@ function normalizeChatMessages(messages: unknown): UIMessage[] { export async function POST(req: Request) { try { - const { messages, model } = await req.json() - const normalizedMessages = normalizeChatMessages(messages) + const supabaseServer = await createServerSupabaseClient() + const { + data: { user }, + error: authError, + } = await supabaseServer.auth.getUser() + + if (authError || !user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const chatRateLimit = rateLimit({ + maxAttempts: 20, + windowMs: 60 * 1000, + lockoutDurationMs: 5 * 60 * 1000, + keyGenerator: () => `chat:${user.id}`, + }) + const rateLimitResult = await chatRateLimit(req) + + if (!rateLimitResult.allowed) { + return NextResponse.json( + { error: "Too many chat requests. Please try again later." }, + { status: 429 } + ) + } + + const json = await req.json().catch(() => null) + const parsed = chatRequestSchema.safeParse(json) + + if (!parsed.success) { + return NextResponse.json({ error: "Invalid chat request" }, { status: 400 }) + } + + const requestedModel = parsed.data.model || DEFAULT_MODEL + if (!Object.prototype.hasOwnProperty.call(GROQ_MODELS, requestedModel)) { + return NextResponse.json({ error: "Unsupported model" }, { status: 400 }) + } + + const normalizedMessages = normalizeChatMessages(parsed.data.messages) if (normalizedMessages.length === 0) { return NextResponse.json({ error: "Messages are required" }, { status: 400 }) } if (!process.env.GROQ_API_KEY) { + console.error("GROQ_API_KEY is not configured") return NextResponse.json( - { error: "GROQ_API_KEY is not configured. Add it to your .env.local file." }, + { error: "AI service is not configured" }, { status: 500 } ) } const groq = createGroq({ apiKey: process.env.GROQ_API_KEY }) - const modelId = GROQ_MODELS[model] ?? "llama-3.3-70b-versatile" - - // Authenticate and fetch context - const supabaseServer = await createServerSupabaseClient() - const { data: { user } } = await supabaseServer.auth.getUser() + const modelId = GROQ_MODELS[requestedModel] let contextStr = "" - if (user) { - const [todosRes, projectsRes] = await Promise.all([ - supabaseServer - .from('todos') - .select('title,status,priority') - .eq('user_id', user.id) - .eq('completed', false) - .order('updated_at', { ascending: false }) - .limit(8), - supabaseServer - .from('projects') - .select('title,status') - .eq('user_id', user.id) - .order('updated_at', { ascending: false }) - .limit(6) - ]) - - const todos = todosRes.data || [] - const projects = projectsRes.data || [] + const [todosRes, projectsRes] = await Promise.all([ + supabaseServer + .from('todos') + .select('title,status,priority') + .eq('user_id', user.id) + .eq('completed', false) + .order('updated_at', { ascending: false }) + .limit(8), + supabaseServer + .from('projects') + .select('title,status') + .eq('user_id', user.id) + .order('updated_at', { ascending: false }) + .limit(6) + ]) + + const todos = todosRes.data || [] + const projects = projectsRes.data || [] - if (todos.length > 0 || projects.length > 0) { - contextStr = `\n\nUSER CONTEXT:\nThe user currently has ${todos.length} active todos and ${projects.length} projects.` - if (todos.length > 0) { - contextStr += `\nActive Todos (Kanban):\n${todos.map(t => `- [${t.status}] ${t.title} (Priority: ${t.priority})`).join('\n')}` - } - if (projects.length > 0) { - contextStr += `\nRecent Projects:\n${projects.map(p => `- ${p.title} (${p.status})`).join('\n')}` - } - } + if (todos.length > 0 || projects.length > 0) { + contextStr = `\n\nUSER CONTEXT:\nThe user currently has ${todos.length} active todos and ${projects.length} projects.` + if (todos.length > 0) { + contextStr += `\nActive Todos (Kanban):\n${todos.map(t => `- [${t.status}] ${t.title} (Priority: ${t.priority})`).join('\n')}` + } + if (projects.length > 0) { + contextStr += `\nRecent Projects:\n${projects.map(p => `- ${p.title} (${p.status})`).join('\n')}` + } } const systemMessageContent = `You are the Lab68 AI developer assistant. You help the user with code generation, architecture, and task management. You exist to integrate seamlessly into a Cyberpunk/Brutalist workspace platform.${contextStr}` @@ -117,14 +168,23 @@ export async function POST(req: Request) { model: groq(modelId), system: systemMessageContent, messages: await convertToModelMessages(normalizedMessages), + maxOutputTokens: MAX_OUTPUT_TOKENS, + temperature: 0.4, + onFinish: ({ finishReason, usage }) => { + console.info("AI chat usage", { + userId: user.id, + model: modelId, + finishReason, + usage, + }) + }, }) return result.toUIMessageStreamResponse() } catch (error: unknown) { - const message = error instanceof Error ? error.message : "Failed to process request" console.error("Error in AI chat:", error) return NextResponse.json( - { error: message }, + { error: "Failed to process chat request" }, { status: 500 } ) } diff --git a/app/api/cron/supabase-keepalive/route.ts b/app/api/cron/supabase-keepalive/route.ts index 85368c6..e410b11 100644 --- a/app/api/cron/supabase-keepalive/route.ts +++ b/app/api/cron/supabase-keepalive/route.ts @@ -19,8 +19,7 @@ export async function GET(request: NextRequest) { ) } - const startedAt = Date.now() - const { error, count } = await supabase + const { error } = await supabase .from('profiles') .select('id', { count: 'exact', head: true }) .limit(1) @@ -33,10 +32,5 @@ export async function GET(request: NextRequest) { ) } - return NextResponse.json({ - ok: true, - checked: 'profiles', - count, - latencyMs: Date.now() - startedAt, - }) + return NextResponse.json({ ok: true }) } diff --git a/app/api/projects/route.ts b/app/api/projects/route.ts index db73343..4a91790 100644 --- a/app/api/projects/route.ts +++ b/app/api/projects/route.ts @@ -1,6 +1,5 @@ import { NextResponse } from "next/server" -import { createAdminClient } from "@/lib/database/supabase-admin" import { createServerSupabaseClient } from "@/lib/database/supabase-server" import { listAccessibleProjects, requireAuthenticatedUser } from "@/lib/server/project-access" @@ -13,9 +12,7 @@ export async function GET() { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) } - const adminClient = createAdminClient() - const dataSource = adminClient || sessionClient - const projects = await listAccessibleProjects(dataSource, user.id) + const projects = await listAccessibleProjects(sessionClient, user.id) return NextResponse.json({ projects }) } catch (error) { diff --git a/lib/middleware/types.ts b/lib/middleware/types.ts index 795060a..1bce6d7 100644 --- a/lib/middleware/types.ts +++ b/lib/middleware/types.ts @@ -38,5 +38,12 @@ export const defaultRouteConfig: RouteConfig = { protectedPaths: ['/dashboard'], authPaths: ['/login', '/signup'], staffPaths: ['/staff/dashboard'], - protectedApiPaths: ['/api/projects', '/api/chat'], + protectedApiPaths: [ + '/api/projects', + '/api/chat', + '/api/users', + '/api/auth/sessions', + '/api/auth/password', + '/api/auth/mfa', + ], }