196 lines
5.5 KiB
TypeScript
196 lines
5.5 KiB
TypeScript
import { createClient } from '@/lib/supabase/server'
|
|
import { streamChatResponse } from '@/lib/ai/chatbot/chain'
|
|
import { CHATBOT_CONFIG } from '@/lib/ai/chatbot/config'
|
|
import type { ChatMessage, ChatRequest, SourceReference } from '@/types/chat'
|
|
|
|
// Simple in-memory rate limiting (per user)
|
|
const rateLimitMap = new Map<string, { count: number; resetTime: number }>()
|
|
|
|
function checkRateLimit(userId: string): boolean {
|
|
const now = Date.now()
|
|
const limit = rateLimitMap.get(userId)
|
|
|
|
if (!limit || now > limit.resetTime) {
|
|
rateLimitMap.set(userId, {
|
|
count: 1,
|
|
resetTime: now + 60000,
|
|
})
|
|
return true
|
|
}
|
|
|
|
if (limit.count >= CHATBOT_CONFIG.rateLimitPerMinute) {
|
|
return false
|
|
}
|
|
|
|
limit.count++
|
|
return true
|
|
}
|
|
|
|
/**
|
|
* POST /api/chat/stream
|
|
* Streaming chat response via Server-Sent Events
|
|
*/
|
|
export async function POST(request: Request) {
|
|
const supabase = await createClient()
|
|
|
|
const {
|
|
data: { user },
|
|
} = await supabase.auth.getUser()
|
|
|
|
if (!user) {
|
|
return new Response(
|
|
JSON.stringify({ error: 'Unauthorized' }),
|
|
{ status: 401, headers: { 'Content-Type': 'application/json' } }
|
|
)
|
|
}
|
|
|
|
if (!checkRateLimit(user.id)) {
|
|
return new Response(
|
|
JSON.stringify({ error: 'Rate limit exceeded' }),
|
|
{ status: 429, headers: { 'Content-Type': 'application/json' } }
|
|
)
|
|
}
|
|
|
|
try {
|
|
const body: ChatRequest = await request.json()
|
|
const { message, session_id } = body
|
|
|
|
if (!message || typeof message !== 'string' || message.trim().length === 0) {
|
|
return new Response(
|
|
JSON.stringify({ error: 'Message is required' }),
|
|
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
|
)
|
|
}
|
|
|
|
let sessionId = session_id
|
|
|
|
// Create new session if not provided
|
|
if (!sessionId) {
|
|
const { data: newSession, error: sessionError } = await supabase
|
|
.from('chat_sessions')
|
|
.insert({
|
|
user_id: user.id,
|
|
title: message.slice(0, 100),
|
|
})
|
|
.select()
|
|
.single()
|
|
|
|
if (sessionError) {
|
|
return new Response(
|
|
JSON.stringify({ error: 'Failed to create session' }),
|
|
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
|
)
|
|
}
|
|
|
|
sessionId = newSession.id
|
|
} else {
|
|
// Verify session belongs to user
|
|
const { data: existingSession } = await supabase
|
|
.from('chat_sessions')
|
|
.select('id')
|
|
.eq('id', sessionId)
|
|
.eq('user_id', user.id)
|
|
.single()
|
|
|
|
if (!existingSession) {
|
|
return new Response(
|
|
JSON.stringify({ error: 'Session not found' }),
|
|
{ status: 404, headers: { 'Content-Type': 'application/json' } }
|
|
)
|
|
}
|
|
}
|
|
|
|
// Save user message
|
|
await supabase
|
|
.from('chat_messages')
|
|
.insert({
|
|
session_id: sessionId,
|
|
user_id: user.id,
|
|
role: 'user',
|
|
content: message.trim(),
|
|
sources: [],
|
|
})
|
|
|
|
// Get conversation history
|
|
const { data: history } = await supabase
|
|
.from('chat_messages')
|
|
.select('role, content')
|
|
.eq('session_id', sessionId)
|
|
.order('created_at', { ascending: true })
|
|
.limit(CHATBOT_CONFIG.maxHistoryMessages)
|
|
|
|
const conversationHistory = (history || []) as ChatMessage[]
|
|
|
|
// Create streaming response
|
|
const encoder = new TextEncoder()
|
|
let fullContent = ''
|
|
let sources: SourceReference[] = []
|
|
|
|
const stream = new ReadableStream({
|
|
async start(controller) {
|
|
try {
|
|
// Send session ID first
|
|
controller.enqueue(
|
|
encoder.encode(`data: ${JSON.stringify({ type: 'session', session_id: sessionId })}\n\n`)
|
|
)
|
|
|
|
// Stream the response
|
|
for await (const chunk of streamChatResponse(message.trim(), conversationHistory)) {
|
|
if (chunk.type === 'content') {
|
|
fullContent += chunk.data as string
|
|
controller.enqueue(
|
|
encoder.encode(`data: ${JSON.stringify({ type: 'content', content: chunk.data })}\n\n`)
|
|
)
|
|
} else if (chunk.type === 'sources') {
|
|
sources = chunk.data as SourceReference[]
|
|
controller.enqueue(
|
|
encoder.encode(`data: ${JSON.stringify({ type: 'sources', sources: chunk.data })}\n\n`)
|
|
)
|
|
}
|
|
}
|
|
|
|
// Save the complete assistant message
|
|
const { data: savedMessage } = await supabase
|
|
.from('chat_messages')
|
|
.insert({
|
|
session_id: sessionId,
|
|
user_id: user.id,
|
|
role: 'assistant',
|
|
content: fullContent,
|
|
sources,
|
|
})
|
|
.select()
|
|
.single()
|
|
|
|
// Send done signal with message ID
|
|
controller.enqueue(
|
|
encoder.encode(`data: ${JSON.stringify({ type: 'done', message_id: savedMessage?.id })}\n\n`)
|
|
)
|
|
|
|
controller.close()
|
|
} catch (error) {
|
|
console.error('Streaming error:', error)
|
|
controller.enqueue(
|
|
encoder.encode(`data: ${JSON.stringify({ type: 'error', error: 'Streaming failed' })}\n\n`)
|
|
)
|
|
controller.close()
|
|
}
|
|
},
|
|
})
|
|
|
|
return new Response(stream, {
|
|
headers: {
|
|
'Content-Type': 'text/event-stream',
|
|
'Cache-Control': 'no-cache',
|
|
'Connection': 'keep-alive',
|
|
},
|
|
})
|
|
} catch (err) {
|
|
console.error('Stream setup error:', err)
|
|
return new Response(
|
|
JSON.stringify({ error: 'Failed to setup stream' }),
|
|
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
|
)
|
|
}
|
|
}
|