Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 101 additions & 41 deletions app/api/chat/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
"llama-3.3-70b-versatile": "llama-3.3-70b-versatile",
Expand All @@ -10,19 +12,32 @@ const GROQ_MODELS: Record<string, string> = {
"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<string, unknown> {
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 []
Expand All @@ -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 []

Expand All @@ -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}`
Expand All @@ -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 }
)
}
Expand Down
10 changes: 2 additions & 8 deletions app/api/cron/supabase-keepalive/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 })
}
5 changes: 1 addition & 4 deletions app/api/projects/route.ts
Original file line number Diff line number Diff line change
@@ -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"

Expand All @@ -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) {
Expand Down
9 changes: 8 additions & 1 deletion lib/middleware/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
],
}
Loading