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 }) } }