bookwiz.io / app / api / chat / route.ts
route.ts
Raw
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 })
  }
}