import { NextRequest, NextResponse } from 'next/server'
import type { FileSystemItem } from '@/lib/types/database'
import { DEFAULT_MODEL, getModelTier } from '@/lib/config/models'
import { FileOperationsService } from '@/lib/services/file-operations'
import { ChatStreamHandler, type StreamMessage } from '@/lib/services/chat-stream-handler'
import { ContextBuilder } from '@/lib/utils/context-builder'
import { usageTracker } from '@/lib/services/usage-tracker'
export async function POST(req: NextRequest) {
try {
const { messages: initialUserMessages, model = DEFAULT_MODEL.name, bookId, includedFiles = [], userId, chatId } = await req.json()
console.log('Received includedFiles in API:', includedFiles)
if (!initialUserMessages || initialUserMessages.length === 0) {
return NextResponse.json({ error: 'No messages provided' }, { status: 400 })
}
// **CRITICAL FIX**: Check usage limits BEFORE processing the request
if (userId) {
try {
// Get user's subscription to determine limits
const { createServerSupabaseClient } = await import('@/lib/supabase')
const supabase = createServerSupabaseClient()
const { data: subscription } = await supabase
.from('subscriptions')
.select('price_id, status')
.eq('user_id', userId)
.eq('status', 'active')
.order('created_at', { ascending: false })
.limit(1)
.single()
// Default to free tier limits
const { PRICING_TIERS } = await import('@/lib/stripe')
let smartPromptsLimit = PRICING_TIERS.FREE.maxSmartPrompts
let fastPromptsLimit = PRICING_TIERS.FREE.hasUnlimitedFastPrompts ? 999999 : PRICING_TIERS.FREE.maxFastPrompts
// Set limits based on subscription
if (subscription?.price_id) {
const { getPlanByPriceId } = await import('@/lib/stripe')
const plan = getPlanByPriceId(subscription.price_id)
if (plan) {
smartPromptsLimit = plan.maxSmartPrompts
fastPromptsLimit = plan.hasUnlimitedFastPrompts ? 999999 : plan.maxFastPrompts
}
}
// Check if user can make this request
const limitCheckResult = await usageTracker.checkRequestLimits(
userId,
model,
smartPromptsLimit,
fastPromptsLimit
)
if (!limitCheckResult.canProceed) {
const modelTier = getModelTier(model)
const tierDisplay = modelTier === 'smart' ? '🧠 Smart AI' : '⚡ Fast AI'
return NextResponse.json({
error: `You've reached your monthly limit of ${limitCheckResult.limit} ${tierDisplay} requests. Current usage: ${limitCheckResult.currentUsage}/${limitCheckResult.limit}. Resets next month or upgrade your plan for more requests.`,
limitExceeded: true,
usageInfo: {
currentUsage: limitCheckResult.currentUsage,
limit: limitCheckResult.limit,
tier: modelTier
}
}, { status: 429 }) // 429 Too Many Requests
}
} catch (error) {
console.error('Error checking usage limits in chat API:', error)
// Continue with request if limit check fails (fail open for better UX)
// The tracking will still happen after the request
}
}
const lastUserMessage = initialUserMessages[initialUserMessages.length - 1]
const userQuery = lastUserMessage?.content || ''
let contextFiles: FileSystemItem[] = []
let bookContext = ''
// Check if request was aborted early
if (req.signal.aborted) {
return NextResponse.json({ error: 'Request cancelled' }, { status: 499 })
}
// Create server-side Supabase client once for all operations
const { createServerSupabaseClient } = await import('@/lib/supabase')
const serverSupabase = createServerSupabaseClient()
// Gather book context if bookId is provided
if (bookId && userQuery.trim()) {
try {
const fileService = new FileOperationsService(serverSupabase)
const mentionedFiles = ContextBuilder.extractFileMentions(userQuery)
// If there are included files, get their content first
if (includedFiles.length > 0) {
console.log('Processing included files:', includedFiles)
const includedFileItems = await Promise.all(
includedFiles.map(async (fileIdentifier: string) => {
console.log('Fetching file:', fileIdentifier)
// First try to get by ID (if it looks like a UUID)
let file = null
if (fileIdentifier.match(/^[a-f0-9-]{36}$/)) {
console.log('Treating as ID:', fileIdentifier, 'with bookId:', bookId)
try {
file = await fileService.readFile(bookId, fileIdentifier)
console.log('Successfully found file by ID:', file.name)
} catch (error: any) {
console.log('Failed to find file by ID:', error.message)
file = null
}
}
// If not found by ID, try by name
if (!file) {
console.log('Treating as name:', fileIdentifier)
try {
file = await fileService.getFileByName(bookId, fileIdentifier)
if (file) console.log('Successfully found file by name:', file.name)
} catch (error: any) {
console.log('Failed to find file by name:', error.message)
}
}
console.log('Final result - Found file:', file ? `${file.name} (ID: ${file.id})` : 'not found')
return file
})
)
contextFiles = contextFiles.concat(includedFileItems.filter(Boolean) as FileSystemItem[])
console.log('Context files after adding included files:', contextFiles.map(f => `${f.name} (ID: ${f.id})`))
}
// Then search for any other relevant files
const relevantFiles = await fileService.searchRelevantFiles(bookId, userQuery, mentionedFiles)
// Remove duplicates by ID before adding relevant files
const existingIds = new Set(contextFiles.map(f => f.id))
const uniqueRelevantFiles = relevantFiles.filter(f => !existingIds.has(f.id))
contextFiles = contextFiles.concat(uniqueRelevantFiles)
console.log('Final context files:', contextFiles.map(f => `${f.name} (ID: ${f.id})`))
const bookTitle = await fileService.getBookTitle(bookId)
bookContext = ContextBuilder.buildBookContext(contextFiles, bookTitle)
} catch (error) {
console.error('Error gathering book context:', error)
}
}
// Prepare messages for the chat handler
let conversationMessages: StreamMessage[] = initialUserMessages.map((msg: any) => ({
role: msg.type === 'user' ? 'user' : 'assistant',
content: msg.content
}))
// Add book context as system message if available
if (bookContext) {
conversationMessages.unshift({
role: 'system',
content: bookContext
})
}
// If there are included files, add information about them to help AI understand "this file" references
if (includedFiles.length > 0 && contextFiles.length > 0) {
const includedFilesList = contextFiles.slice(0, includedFiles.length) // First files are the included ones
const includedFilesInfo = includedFilesList.map(f => `"${f.name}" (ID: ${f.id})`).join(', ')
conversationMessages.unshift({
role: 'system',
content: `NOTE: The user has explicitly included these files in their request: ${includedFilesInfo}. When they refer to "this file" or "the file" they likely mean one of these included files. You can use the read_file tool with these exact IDs.`
})
}
// Create and handle the chat stream with server-side Supabase client
const chatHandler = new ChatStreamHandler(serverSupabase)
// **ENHANCED**: Add request timeout and abort signal handling
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => reject(new Error('Request timeout')), 45000) // 45 second timeout
})
const abortPromise = new Promise((_, reject) => {
req.signal.addEventListener('abort', () => {
reject(new Error('Request cancelled by client'))
})
})
const stream = await Promise.race([
chatHandler.handleChatStream(
conversationMessages,
model,
bookId,
contextFiles,
userId,
req.signal, // Pass the abort signal to the handler
chatId // Pass the chat ID for usage tracking
),
timeoutPromise,
abortPromise
]) as ReadableStream
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
},
})
} catch (error: any) {
// Handle client abort gracefully - this is expected when user clicks stop
if (error.message === 'Request cancelled by client' ||
error.name === 'AbortError' ||
error.message.includes('ResponseAborted') ||
error.message.includes('aborted') ||
error.message.includes('connection')) {
console.log('Chat request was cancelled by client - this is normal when stop is clicked')
return new Response('Request cancelled', { status: 499 })
}
console.error('Chat API error:', error)
return NextResponse.json({
error: 'Internal server error: ' + error.message
}, { status: 500 })
}
}