Broad update across dashboard pages, components, extensions, and lib code. Includes ESLint config additions, onboarding flow redesign, settings page refactor, help page content expansion, dead code removal, and test mock fixes. Adds dev docs and public assets. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
635 lines
18 KiB
TypeScript
635 lines
18 KiB
TypeScript
import { NextResponse } from 'next/server'
|
|
import type { ApiRouteDefinition, ExtensionContext } from '@/lib/extensions/types'
|
|
import { generateChatResponse, streamRoutedResponse } from '@/extensions/general/ai-chat/chatbot/chain'
|
|
import { CHATBOT_CONFIG } from '@/extensions/general/ai-chat/chatbot/config'
|
|
import type { ChatMessage, ChatRequest, SourceReference, ArtifactSpec } from '@/types/chat'
|
|
|
|
// Distributed rate limiting backed by Supabase extension_data table
|
|
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
|
|
interface RateLimitState {
|
|
count: number
|
|
window_start: number
|
|
}
|
|
|
|
async function checkRateLimitDB(supabase: SupabaseClient, userId: string): Promise<boolean> {
|
|
const now = Date.now()
|
|
const WINDOW_MS = 60000
|
|
|
|
const { data } = await supabase
|
|
.from('extension_data')
|
|
.select('value')
|
|
.eq('user_id', userId)
|
|
.eq('extension_id', 'ai-chat')
|
|
.eq('key', 'rate_limit')
|
|
.single()
|
|
|
|
const state = data?.value as RateLimitState | null
|
|
|
|
if (!state || now - state.window_start > WINDOW_MS) {
|
|
// New window
|
|
await supabase.from('extension_data').upsert(
|
|
{
|
|
user_id: userId,
|
|
extension_id: 'ai-chat',
|
|
key: 'rate_limit',
|
|
value: { count: 1, window_start: now },
|
|
},
|
|
{ onConflict: 'user_id,extension_id,key' }
|
|
)
|
|
return true
|
|
}
|
|
|
|
if (state.count >= CHATBOT_CONFIG.rateLimitPerMinute) {
|
|
return false
|
|
}
|
|
|
|
// Increment count
|
|
await supabase.from('extension_data').upsert(
|
|
{
|
|
user_id: userId,
|
|
extension_id: 'ai-chat',
|
|
key: 'rate_limit',
|
|
value: { count: state.count + 1, window_start: state.window_start },
|
|
},
|
|
{ onConflict: 'user_id,extension_id,key' }
|
|
)
|
|
return true
|
|
}
|
|
|
|
// ============================================================
|
|
// POST / — Send a message and get a response
|
|
// ============================================================
|
|
|
|
async function handlePostChat(
|
|
request: Request,
|
|
ctx?: ExtensionContext
|
|
): Promise<Response> {
|
|
const userId = ctx!.userId
|
|
const { createClient } = await import('@/lib/supabase/server')
|
|
const supabase = await createClient()
|
|
|
|
// Rate limiting
|
|
if (!await checkRateLimitDB(supabase, userId)) {
|
|
return NextResponse.json(
|
|
{ error: 'Rate limit exceeded. Please wait a moment.' },
|
|
{ status: 429 }
|
|
)
|
|
}
|
|
|
|
try {
|
|
const body: ChatRequest = await request.json()
|
|
const { message, session_id } = body
|
|
|
|
if (!message || typeof message !== 'string' || message.trim().length === 0) {
|
|
return NextResponse.json(
|
|
{ error: 'Message is required' },
|
|
{ status: 400 }
|
|
)
|
|
}
|
|
|
|
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: userId,
|
|
title: message.slice(0, 100), // Use first 100 chars of message as title
|
|
})
|
|
.select()
|
|
.single()
|
|
|
|
if (sessionError) {
|
|
console.error('Error creating session:', sessionError)
|
|
return NextResponse.json(
|
|
{ error: 'Failed to create chat session' },
|
|
{ status: 500 }
|
|
)
|
|
}
|
|
|
|
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', userId)
|
|
.single()
|
|
|
|
if (!existingSession) {
|
|
return NextResponse.json(
|
|
{ error: 'Session not found' },
|
|
{ status: 404 }
|
|
)
|
|
}
|
|
}
|
|
|
|
// Save user message
|
|
const { error: userMsgError } = await supabase
|
|
.from('chat_messages')
|
|
.insert({
|
|
session_id: sessionId,
|
|
user_id: userId,
|
|
role: 'user',
|
|
content: message.trim(),
|
|
sources: [],
|
|
})
|
|
.select()
|
|
.single()
|
|
|
|
if (userMsgError) {
|
|
console.error('Error saving user message:', userMsgError)
|
|
return NextResponse.json(
|
|
{ error: 'Failed to save message' },
|
|
{ status: 500 }
|
|
)
|
|
}
|
|
|
|
// 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[]
|
|
|
|
// Generate AI response
|
|
const result = await generateChatResponse(message.trim(), conversationHistory, { supabase, userId })
|
|
|
|
// Save assistant message
|
|
const { data: assistantMessage, error: assistantMsgError } = await supabase
|
|
.from('chat_messages')
|
|
.insert({
|
|
session_id: sessionId,
|
|
user_id: userId,
|
|
role: 'assistant',
|
|
content: result.content,
|
|
sources: result.sources,
|
|
})
|
|
.select()
|
|
.single()
|
|
|
|
if (assistantMsgError) {
|
|
console.error('Error saving assistant message:', assistantMsgError)
|
|
return NextResponse.json(
|
|
{ error: 'Failed to save response' },
|
|
{ status: 500 }
|
|
)
|
|
}
|
|
|
|
return NextResponse.json({
|
|
message: assistantMessage,
|
|
session_id: sessionId,
|
|
})
|
|
} catch (err) {
|
|
console.error('Chat error:', err)
|
|
return NextResponse.json(
|
|
{ error: err instanceof Error ? err.message : 'Failed to process chat' },
|
|
{ status: 500 }
|
|
)
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// POST /stream — Streaming chat response via Server-Sent Events
|
|
// ============================================================
|
|
|
|
async function handlePostStream(
|
|
request: Request,
|
|
ctx?: ExtensionContext
|
|
): Promise<Response> {
|
|
const userId = ctx!.userId
|
|
const { createClient } = await import('@/lib/supabase/server')
|
|
const supabase = await createClient()
|
|
|
|
if (!await checkRateLimitDB(supabase, userId)) {
|
|
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: userId,
|
|
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', userId)
|
|
.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: userId,
|
|
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[] = []
|
|
let artifact: ArtifactSpec | null = null
|
|
|
|
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 routed response (handles knowledge, data, and hybrid)
|
|
for await (const event of streamRoutedResponse(
|
|
message.trim(),
|
|
conversationHistory,
|
|
supabase,
|
|
userId,
|
|
sessionId
|
|
)) {
|
|
if (event.type === 'content') {
|
|
fullContent += event.content
|
|
controller.enqueue(
|
|
encoder.encode(`data: ${JSON.stringify({ type: 'content', content: event.content })}\n\n`)
|
|
)
|
|
} else if (event.type === 'sources') {
|
|
sources = event.sources
|
|
controller.enqueue(
|
|
encoder.encode(`data: ${JSON.stringify({ type: 'sources', sources: event.sources })}\n\n`)
|
|
)
|
|
} else if (event.type === 'tool_start') {
|
|
controller.enqueue(
|
|
encoder.encode(`data: ${JSON.stringify({ type: 'tool_start', toolName: event.toolName })}\n\n`)
|
|
)
|
|
} else if (event.type === 'artifact') {
|
|
artifact = event.artifact
|
|
controller.enqueue(
|
|
encoder.encode(`data: ${JSON.stringify({ type: 'artifact', artifact: event.artifact })}\n\n`)
|
|
)
|
|
}
|
|
}
|
|
|
|
// Save the complete assistant message (including artifact)
|
|
const { data: savedMessage } = await supabase
|
|
.from('chat_messages')
|
|
.insert({
|
|
session_id: sessionId,
|
|
user_id: userId,
|
|
role: 'assistant',
|
|
content: fullContent,
|
|
sources,
|
|
...(artifact ? { artifact } : {}),
|
|
})
|
|
.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' } }
|
|
)
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// GET /sessions — List all chat sessions for the user
|
|
// ============================================================
|
|
|
|
async function handleGetSessions(
|
|
request: Request,
|
|
ctx?: ExtensionContext
|
|
): Promise<Response> {
|
|
const userId = ctx!.userId
|
|
const { createClient } = await import('@/lib/supabase/server')
|
|
const supabase = await createClient()
|
|
|
|
const { searchParams } = new URL(request.url)
|
|
const limit = parseInt(searchParams.get('limit') || '20')
|
|
const offset = parseInt(searchParams.get('offset') || '0')
|
|
|
|
const { data, error, count } = await supabase
|
|
.from('chat_sessions')
|
|
.select('*', { count: 'exact' })
|
|
.eq('user_id', userId)
|
|
.order('created_at', { ascending: false })
|
|
.range(offset, offset + limit - 1)
|
|
|
|
if (error) {
|
|
console.error('Error fetching sessions:', error)
|
|
return NextResponse.json(
|
|
{ error: 'Failed to fetch sessions' },
|
|
{ status: 500 }
|
|
)
|
|
}
|
|
|
|
return NextResponse.json({ data, count })
|
|
}
|
|
|
|
// ============================================================
|
|
// POST /sessions — Create a new chat session
|
|
// ============================================================
|
|
|
|
async function handlePostSession(
|
|
request: Request,
|
|
ctx?: ExtensionContext
|
|
): Promise<Response> {
|
|
const userId = ctx!.userId
|
|
const { createClient } = await import('@/lib/supabase/server')
|
|
const supabase = await createClient()
|
|
|
|
try {
|
|
const body = await request.json()
|
|
const { title } = body
|
|
|
|
const { data, error } = await supabase
|
|
.from('chat_sessions')
|
|
.insert({
|
|
user_id: userId,
|
|
title: title || null,
|
|
})
|
|
.select()
|
|
.single()
|
|
|
|
if (error) {
|
|
console.error('Error creating session:', error)
|
|
return NextResponse.json(
|
|
{ error: 'Failed to create session' },
|
|
{ status: 500 }
|
|
)
|
|
}
|
|
|
|
return NextResponse.json({ data })
|
|
} catch (err) {
|
|
return NextResponse.json(
|
|
{ error: err instanceof Error ? err.message : 'Failed to create session' },
|
|
{ status: 400 }
|
|
)
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// GET /sessions/:id — Get a single chat session with messages
|
|
// ============================================================
|
|
|
|
async function handleGetSession(
|
|
request: Request,
|
|
ctx?: ExtensionContext
|
|
): Promise<Response> {
|
|
const userId = ctx!.userId
|
|
const { createClient } = await import('@/lib/supabase/server')
|
|
const supabase = await createClient()
|
|
|
|
const { searchParams } = new URL(request.url)
|
|
const id = searchParams.get('_id')
|
|
|
|
if (!id) {
|
|
return NextResponse.json({ error: 'Session ID is required' }, { status: 400 })
|
|
}
|
|
|
|
// Get session with messages
|
|
const { data: session, error: sessionError } = await supabase
|
|
.from('chat_sessions')
|
|
.select('*')
|
|
.eq('id', id)
|
|
.eq('user_id', userId)
|
|
.single()
|
|
|
|
if (sessionError || !session) {
|
|
return NextResponse.json({ error: 'Session not found' }, { status: 404 })
|
|
}
|
|
|
|
// Get messages
|
|
const { data: messages, error: messagesError } = await supabase
|
|
.from('chat_messages')
|
|
.select('*')
|
|
.eq('session_id', id)
|
|
.order('created_at', { ascending: true })
|
|
|
|
if (messagesError) {
|
|
console.error('Error fetching messages:', messagesError)
|
|
return NextResponse.json(
|
|
{ error: 'Failed to fetch messages' },
|
|
{ status: 500 }
|
|
)
|
|
}
|
|
|
|
return NextResponse.json({
|
|
session,
|
|
messages: messages || [],
|
|
})
|
|
}
|
|
|
|
// ============================================================
|
|
// PATCH /sessions/:id — Update a chat session (e.g., rename)
|
|
// ============================================================
|
|
|
|
async function handlePatchSession(
|
|
request: Request,
|
|
ctx?: ExtensionContext
|
|
): Promise<Response> {
|
|
const userId = ctx!.userId
|
|
const { createClient } = await import('@/lib/supabase/server')
|
|
const supabase = await createClient()
|
|
|
|
const { searchParams } = new URL(request.url)
|
|
const id = searchParams.get('_id')
|
|
|
|
if (!id) {
|
|
return NextResponse.json({ error: 'Session ID is required' }, { status: 400 })
|
|
}
|
|
|
|
try {
|
|
const body = await request.json()
|
|
const { title } = body
|
|
|
|
const { data, error } = await supabase
|
|
.from('chat_sessions')
|
|
.update({ title })
|
|
.eq('id', id)
|
|
.eq('user_id', userId)
|
|
.select()
|
|
.single()
|
|
|
|
if (error) {
|
|
console.error('Error updating session:', error)
|
|
return NextResponse.json(
|
|
{ error: 'Failed to update session' },
|
|
{ status: 500 }
|
|
)
|
|
}
|
|
|
|
if (!data) {
|
|
return NextResponse.json({ error: 'Session not found' }, { status: 404 })
|
|
}
|
|
|
|
return NextResponse.json({ data })
|
|
} catch (err) {
|
|
return NextResponse.json(
|
|
{ error: err instanceof Error ? err.message : 'Failed to update session' },
|
|
{ status: 400 }
|
|
)
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// DELETE /sessions/:id — Delete a chat session and its messages
|
|
// ============================================================
|
|
|
|
async function handleDeleteSession(
|
|
request: Request,
|
|
ctx?: ExtensionContext
|
|
): Promise<Response> {
|
|
const userId = ctx!.userId
|
|
const { createClient } = await import('@/lib/supabase/server')
|
|
const supabase = await createClient()
|
|
|
|
const { searchParams } = new URL(request.url)
|
|
const id = searchParams.get('_id')
|
|
|
|
if (!id) {
|
|
return NextResponse.json({ error: 'Session ID is required' }, { status: 400 })
|
|
}
|
|
|
|
// Delete session (messages will cascade delete due to FK)
|
|
const { error } = await supabase
|
|
.from('chat_sessions')
|
|
.delete()
|
|
.eq('id', id)
|
|
.eq('user_id', userId)
|
|
|
|
if (error) {
|
|
console.error('Error deleting session:', error)
|
|
return NextResponse.json(
|
|
{ error: 'Failed to delete session' },
|
|
{ status: 500 }
|
|
)
|
|
}
|
|
|
|
return NextResponse.json({ success: true })
|
|
}
|
|
|
|
// ============================================================
|
|
// Route definitions
|
|
// ============================================================
|
|
|
|
export const aiChatApiRoutes: ApiRouteDefinition[] = [
|
|
{
|
|
method: 'POST',
|
|
path: '/',
|
|
handler: handlePostChat,
|
|
},
|
|
{
|
|
method: 'POST',
|
|
path: '/stream',
|
|
handler: handlePostStream,
|
|
},
|
|
{
|
|
method: 'GET',
|
|
path: '/sessions',
|
|
handler: handleGetSessions,
|
|
},
|
|
{
|
|
method: 'POST',
|
|
path: '/sessions',
|
|
handler: handlePostSession,
|
|
},
|
|
{
|
|
method: 'GET',
|
|
path: '/sessions/:id',
|
|
handler: handleGetSession,
|
|
},
|
|
{
|
|
method: 'PATCH',
|
|
path: '/sessions/:id',
|
|
handler: handlePatchSession,
|
|
},
|
|
{
|
|
method: 'DELETE',
|
|
path: '/sessions/:id',
|
|
handler: handleDeleteSession,
|
|
},
|
|
]
|