mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-09 23:17:59 -05:00
Convo update
This commit is contained in:
233
apps/sim/app/api/copilot/chats/[id]/route.ts
Normal file
233
apps/sim/app/api/copilot/chats/[id]/route.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
import { eq, and } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { db } from '@/db'
|
||||
import { copilotChats } from '@/db/schema'
|
||||
import { executeProviderRequest } from '@/providers'
|
||||
import { getRotatingApiKey } from '@/lib/utils'
|
||||
|
||||
const logger = createLogger('CopilotChatAPI')
|
||||
|
||||
const UpdateChatSchema = z.object({
|
||||
title: z.string().optional(),
|
||||
messages: z.array(z.any()).optional(),
|
||||
model: z.string().optional(),
|
||||
})
|
||||
|
||||
const AddMessageSchema = z.object({
|
||||
message: z.object({
|
||||
role: z.enum(['user', 'assistant']),
|
||||
content: z.string(),
|
||||
timestamp: z.string().optional(),
|
||||
citations: z.array(z.any()).optional(),
|
||||
}),
|
||||
})
|
||||
|
||||
/**
|
||||
* GET /api/copilot/chats/[id]
|
||||
* Get a specific copilot chat
|
||||
*/
|
||||
export async function GET(req: NextRequest, { params }: { params: { id: string } }) {
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const chatId = params.id
|
||||
|
||||
logger.info(`Getting chat ${chatId} for user ${session.user.id}`)
|
||||
|
||||
const [chat] = await db
|
||||
.select()
|
||||
.from(copilotChats)
|
||||
.where(
|
||||
and(
|
||||
eq(copilotChats.id, chatId),
|
||||
eq(copilotChats.userId, session.user.id)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!chat) {
|
||||
return NextResponse.json({ error: 'Chat not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
chat: {
|
||||
id: chat.id,
|
||||
title: chat.title,
|
||||
model: chat.model,
|
||||
messages: chat.messages,
|
||||
createdAt: chat.createdAt,
|
||||
updatedAt: chat.updatedAt,
|
||||
messageCount: Array.isArray(chat.messages) ? chat.messages.length : 0,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to get copilot chat:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/copilot/chats/[id]
|
||||
* Update a copilot chat (add messages, update title, etc.)
|
||||
*/
|
||||
export async function PUT(req: NextRequest, { params }: { params: { id: string } }) {
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const chatId = params.id
|
||||
const body = await req.json()
|
||||
const { title, messages, model } = UpdateChatSchema.parse(body)
|
||||
|
||||
logger.info(`Updating chat ${chatId} for user ${session.user.id}`)
|
||||
|
||||
// First verify the chat exists and belongs to the user
|
||||
const [existingChat] = await db
|
||||
.select()
|
||||
.from(copilotChats)
|
||||
.where(
|
||||
and(
|
||||
eq(copilotChats.id, chatId),
|
||||
eq(copilotChats.userId, session.user.id)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!existingChat) {
|
||||
return NextResponse.json({ error: 'Chat not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Prepare update data
|
||||
const updateData: any = {
|
||||
updatedAt: new Date(),
|
||||
}
|
||||
|
||||
if (title !== undefined) updateData.title = title
|
||||
if (messages !== undefined) updateData.messages = messages
|
||||
if (model !== undefined) updateData.model = model
|
||||
|
||||
// Update the chat
|
||||
const [updatedChat] = await db
|
||||
.update(copilotChats)
|
||||
.set(updateData)
|
||||
.where(eq(copilotChats.id, chatId))
|
||||
.returning()
|
||||
|
||||
if (!updatedChat) {
|
||||
throw new Error('Failed to update chat')
|
||||
}
|
||||
|
||||
logger.info(`Updated chat ${chatId} for user ${session.user.id}`)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
chat: {
|
||||
id: updatedChat.id,
|
||||
title: updatedChat.title,
|
||||
model: updatedChat.model,
|
||||
messages: updatedChat.messages,
|
||||
createdAt: updatedChat.createdAt,
|
||||
updatedAt: updatedChat.updatedAt,
|
||||
messageCount: Array.isArray(updatedChat.messages) ? updatedChat.messages.length : 0,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid request data', details: error.errors },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.error('Failed to update copilot chat:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/copilot/chats/[id]
|
||||
* Delete a copilot chat
|
||||
*/
|
||||
export async function DELETE(req: NextRequest, { params }: { params: { id: string } }) {
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const chatId = params.id
|
||||
|
||||
logger.info(`Deleting chat ${chatId} for user ${session.user.id}`)
|
||||
|
||||
// First verify the chat exists and belongs to the user
|
||||
const [existingChat] = await db
|
||||
.select({ id: copilotChats.id })
|
||||
.from(copilotChats)
|
||||
.where(
|
||||
and(
|
||||
eq(copilotChats.id, chatId),
|
||||
eq(copilotChats.userId, session.user.id)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!existingChat) {
|
||||
return NextResponse.json({ error: 'Chat not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Delete the chat
|
||||
await db
|
||||
.delete(copilotChats)
|
||||
.where(eq(copilotChats.id, chatId))
|
||||
|
||||
logger.info(`Deleted chat ${chatId} for user ${session.user.id}`)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Chat deleted successfully',
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete copilot chat:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a chat title using LLM based on the first user message
|
||||
*/
|
||||
export async function generateChatTitle(userMessage: string): Promise<string> {
|
||||
try {
|
||||
const apiKey = getRotatingApiKey('anthropic')
|
||||
|
||||
const response = await executeProviderRequest('anthropic', {
|
||||
model: 'claude-3-haiku-20240307', // Use faster, cheaper model for title generation
|
||||
systemPrompt: 'You are a helpful assistant that generates concise, descriptive titles for chat conversations. Create a title that captures the main topic or question being discussed. Keep it under 50 characters and make it specific and clear.',
|
||||
context: `Generate a concise title for a conversation that starts with this user message: "${userMessage}"
|
||||
|
||||
Return only the title text, nothing else.`,
|
||||
temperature: 0.3,
|
||||
maxTokens: 50,
|
||||
apiKey,
|
||||
stream: false,
|
||||
})
|
||||
|
||||
// Handle different response types
|
||||
if (typeof response === 'object' && 'content' in response) {
|
||||
return response.content?.trim() || 'New Chat'
|
||||
}
|
||||
|
||||
return 'New Chat'
|
||||
} catch (error) {
|
||||
logger.error('Failed to generate chat title:', error)
|
||||
return 'New Chat' // Fallback title
|
||||
}
|
||||
}
|
||||
173
apps/sim/app/api/copilot/chats/route.ts
Normal file
173
apps/sim/app/api/copilot/chats/route.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { desc, eq, and } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { db } from '@/db'
|
||||
import { copilotChats } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('CopilotChatsAPI')
|
||||
|
||||
const CreateChatSchema = z.object({
|
||||
workflowId: z.string().min(1, 'Workflow ID is required'),
|
||||
title: z.string().optional(),
|
||||
model: z.string().optional().default('claude-3-7-sonnet-latest'),
|
||||
initialMessage: z.string().optional(), // Optional first user message
|
||||
})
|
||||
|
||||
const ListChatsSchema = z.object({
|
||||
workflowId: z.string().min(1, 'Workflow ID is required'),
|
||||
limit: z.number().min(1).max(100).optional().default(50),
|
||||
offset: z.number().min(0).optional().default(0),
|
||||
})
|
||||
|
||||
/**
|
||||
* GET /api/copilot/chats
|
||||
* List copilot chats for a user and workflow
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(req.url)
|
||||
const workflowId = searchParams.get('workflowId')
|
||||
const limit = parseInt(searchParams.get('limit') || '50')
|
||||
const offset = parseInt(searchParams.get('offset') || '0')
|
||||
|
||||
const { workflowId: validatedWorkflowId, limit: validatedLimit, offset: validatedOffset } =
|
||||
ListChatsSchema.parse({ workflowId, limit, offset })
|
||||
|
||||
logger.info(`Listing chats for user ${session.user.id}, workflow ${validatedWorkflowId}`)
|
||||
|
||||
const chats = await db
|
||||
.select({
|
||||
id: copilotChats.id,
|
||||
title: copilotChats.title,
|
||||
model: copilotChats.model,
|
||||
createdAt: copilotChats.createdAt,
|
||||
updatedAt: copilotChats.updatedAt,
|
||||
messageCount: copilotChats.messages, // We'll process this to get count
|
||||
})
|
||||
.from(copilotChats)
|
||||
.where(
|
||||
and(
|
||||
eq(copilotChats.userId, session.user.id),
|
||||
eq(copilotChats.workflowId, validatedWorkflowId)
|
||||
)
|
||||
)
|
||||
.orderBy(desc(copilotChats.updatedAt))
|
||||
.limit(validatedLimit)
|
||||
.offset(validatedOffset)
|
||||
|
||||
// Process the results to add message counts and clean up data
|
||||
const processedChats = chats.map(chat => ({
|
||||
id: chat.id,
|
||||
title: chat.title,
|
||||
model: chat.model,
|
||||
createdAt: chat.createdAt,
|
||||
updatedAt: chat.updatedAt,
|
||||
messageCount: Array.isArray(chat.messageCount) ? chat.messageCount.length : 0,
|
||||
}))
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
chats: processedChats,
|
||||
pagination: {
|
||||
limit: validatedLimit,
|
||||
offset: validatedOffset,
|
||||
total: processedChats.length,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid request parameters', details: error.errors },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.error('Failed to list copilot chats:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/copilot/chats
|
||||
* Create a new copilot chat
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await req.json()
|
||||
const { workflowId, title, model, initialMessage } = CreateChatSchema.parse(body)
|
||||
|
||||
logger.info(`Creating new chat for user ${session.user.id}, workflow ${workflowId}`)
|
||||
|
||||
// Prepare initial messages array
|
||||
const initialMessages = initialMessage
|
||||
? [
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
role: 'user',
|
||||
content: initialMessage,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
]
|
||||
: []
|
||||
|
||||
// Create the chat
|
||||
const [newChat] = await db
|
||||
.insert(copilotChats)
|
||||
.values({
|
||||
userId: session.user.id,
|
||||
workflowId,
|
||||
title: title || null, // Will be generated later if null
|
||||
model,
|
||||
messages: initialMessages,
|
||||
})
|
||||
.returning({
|
||||
id: copilotChats.id,
|
||||
title: copilotChats.title,
|
||||
model: copilotChats.model,
|
||||
messages: copilotChats.messages,
|
||||
createdAt: copilotChats.createdAt,
|
||||
updatedAt: copilotChats.updatedAt,
|
||||
})
|
||||
|
||||
if (!newChat) {
|
||||
throw new Error('Failed to create chat')
|
||||
}
|
||||
|
||||
logger.info(`Created chat ${newChat.id} for user ${session.user.id}`)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
chat: {
|
||||
id: newChat.id,
|
||||
title: newChat.title,
|
||||
model: newChat.model,
|
||||
messages: newChat.messages,
|
||||
createdAt: newChat.createdAt,
|
||||
updatedAt: newChat.updatedAt,
|
||||
messageCount: Array.isArray(newChat.messages) ? newChat.messages.length : 0,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid request data', details: error.errors },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.error('Failed to create copilot chat:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
import { sql } from 'drizzle-orm'
|
||||
import { sql, eq, and } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { env } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { getRotatingApiKey } from '@/lib/utils'
|
||||
import { generateEmbeddings } from '@/app/api/knowledge/utils'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { db } from '@/db'
|
||||
import { docsEmbeddings } from '@/db/schema'
|
||||
import { docsEmbeddings, copilotChats } from '@/db/schema'
|
||||
import { executeProviderRequest } from '@/providers'
|
||||
import { getProviderDefaultModel } from '@/providers/models'
|
||||
|
||||
@@ -30,8 +31,43 @@ const DocsQuerySchema = z.object({
|
||||
provider: z.string().optional(), // Allow override of provider per request
|
||||
model: z.string().optional(), // Allow override of model per request
|
||||
stream: z.boolean().optional().default(false), // Enable streaming responses
|
||||
// Chat-related fields
|
||||
chatId: z.string().optional(), // Existing chat ID for conversation
|
||||
workflowId: z.string().optional(), // Required for new chats
|
||||
createNewChat: z.boolean().optional().default(false), // Whether to create a new chat
|
||||
})
|
||||
|
||||
/**
|
||||
* Generate a chat title using LLM based on the first user message
|
||||
*/
|
||||
async function generateChatTitle(userMessage: string): Promise<string> {
|
||||
try {
|
||||
const apiKey = getRotatingApiKey('anthropic')
|
||||
|
||||
const response = await executeProviderRequest('anthropic', {
|
||||
model: 'claude-3-haiku-20240307', // Use faster, cheaper model for title generation
|
||||
systemPrompt: 'You are a helpful assistant that generates concise, descriptive titles for chat conversations. Create a title that captures the main topic or question being discussed. Keep it under 50 characters and make it specific and clear.',
|
||||
context: `Generate a concise title for a conversation that starts with this user message: "${userMessage}"
|
||||
|
||||
Return only the title text, nothing else.`,
|
||||
temperature: 0.3,
|
||||
maxTokens: 50,
|
||||
apiKey,
|
||||
stream: false,
|
||||
})
|
||||
|
||||
// Handle different response types
|
||||
if (typeof response === 'object' && 'content' in response) {
|
||||
return response.content?.trim() || 'New Chat'
|
||||
}
|
||||
|
||||
return 'New Chat'
|
||||
} catch (error) {
|
||||
logger.error('Failed to generate chat title:', error)
|
||||
return 'New Chat' // Fallback title
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate embedding for search query
|
||||
*/
|
||||
@@ -79,7 +115,8 @@ async function generateResponse(
|
||||
chunks: any[],
|
||||
provider?: string,
|
||||
model?: string,
|
||||
stream = false
|
||||
stream = false,
|
||||
conversationHistory: any[] = []
|
||||
): Promise<string | ReadableStream> {
|
||||
// Determine which provider and model to use
|
||||
const selectedProvider = provider || DOCS_RAG_CONFIG.defaultProvider
|
||||
@@ -130,7 +167,18 @@ Content: ${chunkText}`
|
||||
})
|
||||
.join('\n\n')
|
||||
|
||||
const systemPrompt = `You are a helpful assistant that answers questions about Sim Studio documentation.
|
||||
// Build conversation context if we have history
|
||||
let conversationContext = ''
|
||||
if (conversationHistory.length > 0) {
|
||||
conversationContext = '\n\nConversation History:\n'
|
||||
conversationHistory.slice(-6).forEach((msg: any) => { // Include last 6 messages for context
|
||||
const role = msg.role === 'user' ? 'Human' : 'Assistant'
|
||||
conversationContext += `${role}: ${msg.content}\n`
|
||||
})
|
||||
conversationContext += '\n'
|
||||
}
|
||||
|
||||
const systemPrompt = `You are a helpful assistant that answers questions about Sim Studio documentation. You are having a conversation with the user, so refer to the conversation history when relevant.
|
||||
|
||||
IMPORTANT: Use inline citations strategically and sparingly. When referencing information from the sources, include the citation number in curly braces like {cite:1}, {cite:2}, etc.
|
||||
|
||||
@@ -144,6 +192,7 @@ Citation Guidelines:
|
||||
|
||||
Content Guidelines:
|
||||
- Answer the user's question accurately using the provided documentation
|
||||
- Consider the conversation history and refer to previous messages when relevant
|
||||
- Format your response in clean, readable markdown
|
||||
- Use bullet points, code blocks, and headers where appropriate
|
||||
- If the question cannot be answered from the context, say so clearly
|
||||
@@ -153,7 +202,7 @@ Content Guidelines:
|
||||
|
||||
The sources are numbered [1] through [${chunks.length}] in the context below.`
|
||||
|
||||
const userPrompt = `Question: ${query}
|
||||
const userPrompt = `${conversationContext}Current Question: ${query}
|
||||
|
||||
Documentation Context:
|
||||
${context}`
|
||||
@@ -221,8 +270,11 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
try {
|
||||
const body = await req.json()
|
||||
const { query, topK, provider, model, stream } = DocsQuerySchema.parse(body)
|
||||
const { query, topK, provider, model, stream, chatId, workflowId, createNewChat } = DocsQuerySchema.parse(body)
|
||||
|
||||
// Get session for chat functionality
|
||||
const session = await getSession()
|
||||
|
||||
logger.info(`[${requestId}] Docs RAG query: "${query}"`, {
|
||||
provider: provider || DOCS_RAG_CONFIG.defaultProvider,
|
||||
model:
|
||||
@@ -230,8 +282,51 @@ export async function POST(req: NextRequest) {
|
||||
DOCS_RAG_CONFIG.defaultModel ||
|
||||
getProviderDefaultModel(provider || DOCS_RAG_CONFIG.defaultProvider),
|
||||
topK,
|
||||
chatId,
|
||||
workflowId,
|
||||
createNewChat,
|
||||
})
|
||||
|
||||
// Handle chat context
|
||||
let currentChat: any = null
|
||||
let conversationHistory: any[] = []
|
||||
|
||||
if (chatId && session?.user?.id) {
|
||||
// Load existing chat
|
||||
const [existingChat] = await db
|
||||
.select()
|
||||
.from(copilotChats)
|
||||
.where(
|
||||
and(
|
||||
eq(copilotChats.id, chatId),
|
||||
eq(copilotChats.userId, session.user.id)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (existingChat) {
|
||||
currentChat = existingChat
|
||||
conversationHistory = Array.isArray(existingChat.messages) ? existingChat.messages : []
|
||||
}
|
||||
} else if (createNewChat && workflowId && session?.user?.id) {
|
||||
// Create new chat
|
||||
const [newChat] = await db
|
||||
.insert(copilotChats)
|
||||
.values({
|
||||
userId: session.user.id,
|
||||
workflowId,
|
||||
title: null, // Will be generated after first response
|
||||
model: model || DOCS_RAG_CONFIG.defaultModel,
|
||||
messages: [],
|
||||
})
|
||||
.returning()
|
||||
|
||||
if (newChat) {
|
||||
currentChat = newChat
|
||||
conversationHistory = []
|
||||
}
|
||||
}
|
||||
|
||||
// Step 1: Generate embedding for the query
|
||||
logger.info(`[${requestId}] Generating query embedding...`)
|
||||
const queryEmbedding = await generateSearchEmbedding(query)
|
||||
@@ -265,7 +360,7 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
// Step 3: Generate response using LLM
|
||||
logger.info(`[${requestId}] Generating LLM response with ${chunks.length} chunks...`)
|
||||
const response = await generateResponse(query, chunks, provider, model, stream)
|
||||
const response = await generateResponse(query, chunks, provider, model, stream, conversationHistory)
|
||||
|
||||
// Step 4: Format sources for response
|
||||
const sources = chunks.map((chunk) => ({
|
||||
@@ -292,6 +387,7 @@ export async function POST(req: NextRequest) {
|
||||
const metadata = {
|
||||
type: 'metadata',
|
||||
sources,
|
||||
chatId: currentChat?.id, // Include chat ID in metadata
|
||||
metadata: {
|
||||
requestId,
|
||||
chunksFound: chunks.length,
|
||||
@@ -306,6 +402,8 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(metadata)}\n\n`))
|
||||
|
||||
let accumulatedResponse = ''
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
@@ -315,6 +413,10 @@ export async function POST(req: NextRequest) {
|
||||
const chunkText = decoder.decode(value)
|
||||
// Clean up any object serialization artifacts in streaming content
|
||||
const cleanedChunk = chunkText.replace(/\[object Object\],?/g, '')
|
||||
|
||||
// Accumulate the response content for database saving
|
||||
accumulatedResponse += cleanedChunk
|
||||
|
||||
const contentChunk = {
|
||||
type: 'content',
|
||||
content: cleanedChunk,
|
||||
@@ -322,6 +424,48 @@ export async function POST(req: NextRequest) {
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(contentChunk)}\n\n`))
|
||||
}
|
||||
|
||||
// Save conversation to database after streaming completes
|
||||
if (currentChat && session?.user?.id) {
|
||||
const userMessage = {
|
||||
id: crypto.randomUUID(),
|
||||
role: 'user',
|
||||
content: query,
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
|
||||
const assistantMessage = {
|
||||
id: crypto.randomUUID(),
|
||||
role: 'assistant',
|
||||
content: accumulatedResponse,
|
||||
timestamp: new Date().toISOString(),
|
||||
citations: sources.map((source, index) => ({
|
||||
id: index + 1,
|
||||
title: source.title,
|
||||
url: source.link,
|
||||
})),
|
||||
}
|
||||
|
||||
const updatedMessages = [...conversationHistory, userMessage, assistantMessage]
|
||||
|
||||
// Generate title if this is the first message
|
||||
let updatedTitle = currentChat.title
|
||||
if (!updatedTitle && conversationHistory.length === 0) {
|
||||
updatedTitle = await generateChatTitle(query)
|
||||
}
|
||||
|
||||
// Update the chat in database
|
||||
await db
|
||||
.update(copilotChats)
|
||||
.set({
|
||||
title: updatedTitle,
|
||||
messages: updatedMessages,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(copilotChats.id, currentChat.id))
|
||||
|
||||
logger.info(`[${requestId}] Updated chat ${currentChat.id} with new messages`)
|
||||
}
|
||||
|
||||
// Send end marker
|
||||
controller.enqueue(encoder.encode(`data: {"type":"done"}\n\n`))
|
||||
} catch (error) {
|
||||
@@ -348,10 +492,53 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
logger.info(`[${requestId}] RAG response generated successfully`)
|
||||
|
||||
// Save conversation to database if we have a chat
|
||||
if (currentChat && session?.user?.id) {
|
||||
const userMessage = {
|
||||
id: crypto.randomUUID(),
|
||||
role: 'user',
|
||||
content: query,
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
|
||||
const assistantMessage = {
|
||||
id: crypto.randomUUID(),
|
||||
role: 'assistant',
|
||||
content: typeof response === 'string' ? response : '[Streaming Response]',
|
||||
timestamp: new Date().toISOString(),
|
||||
citations: sources.map((source, index) => ({
|
||||
id: index + 1,
|
||||
title: source.title,
|
||||
url: source.link,
|
||||
})),
|
||||
}
|
||||
|
||||
const updatedMessages = [...conversationHistory, userMessage, assistantMessage]
|
||||
|
||||
// Generate title if this is the first message
|
||||
let updatedTitle = currentChat.title
|
||||
if (!updatedTitle && conversationHistory.length === 0) {
|
||||
updatedTitle = await generateChatTitle(query)
|
||||
}
|
||||
|
||||
// Update the chat in database
|
||||
await db
|
||||
.update(copilotChats)
|
||||
.set({
|
||||
title: updatedTitle,
|
||||
messages: updatedMessages,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(copilotChats.id, currentChat.id))
|
||||
|
||||
logger.info(`[${requestId}] Updated chat ${currentChat.id} with new messages`)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
response,
|
||||
sources,
|
||||
chatId: currentChat?.id, // Include chat ID in response
|
||||
metadata: {
|
||||
requestId,
|
||||
chunksFound: chunks.length,
|
||||
|
||||
@@ -1,12 +1,28 @@
|
||||
'use client'
|
||||
|
||||
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react'
|
||||
import { Bot, Loader2, Send, User } from 'lucide-react'
|
||||
import { Bot, ChevronDown, Loader2, MessageSquarePlus, MoreHorizontal, Send, Trash2, User } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { CopilotModal } from './components/copilot-modal/copilot-modal'
|
||||
import {
|
||||
listChats,
|
||||
getChat,
|
||||
deleteChat,
|
||||
sendStreamingMessage,
|
||||
type CopilotChat,
|
||||
type CopilotMessage
|
||||
} from '@/lib/copilot-api'
|
||||
|
||||
const logger = createLogger('Copilot')
|
||||
|
||||
@@ -20,21 +36,7 @@ interface CopilotProps {
|
||||
|
||||
interface CopilotRef {
|
||||
clearMessages: () => void
|
||||
}
|
||||
|
||||
interface Message {
|
||||
id: string
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
timestamp: Date
|
||||
sources?: {
|
||||
title: string
|
||||
document: string
|
||||
link: string
|
||||
similarity: number
|
||||
}[]
|
||||
isLoading?: boolean
|
||||
isStreaming?: boolean
|
||||
startNewChat: () => void
|
||||
}
|
||||
|
||||
export const Copilot = forwardRef<CopilotRef, CopilotProps>(
|
||||
@@ -48,23 +50,23 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const [messages, setMessages] = useState<Message[]>([])
|
||||
const [messages, setMessages] = useState<CopilotMessage[]>([])
|
||||
const [input, setInput] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [currentChat, setCurrentChat] = useState<CopilotChat | null>(null)
|
||||
const [chats, setChats] = useState<CopilotChat[]>([])
|
||||
const [loadingChats, setLoadingChats] = useState(false)
|
||||
const scrollAreaRef = useRef<HTMLDivElement>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const { activeWorkflowId } = useWorkflowRegistry()
|
||||
|
||||
// Expose clear function to parent
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
clearMessages: () => {
|
||||
setMessages([])
|
||||
logger.info('Copilot messages cleared')
|
||||
},
|
||||
}),
|
||||
[]
|
||||
)
|
||||
// Load chats when workflow changes
|
||||
useEffect(() => {
|
||||
if (activeWorkflowId) {
|
||||
loadChats()
|
||||
}
|
||||
}, [activeWorkflowId])
|
||||
|
||||
// Auto-scroll to bottom when new messages are added
|
||||
useEffect(() => {
|
||||
@@ -78,58 +80,125 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(
|
||||
}
|
||||
}, [messages])
|
||||
|
||||
// Load chats for current workflow
|
||||
const loadChats = useCallback(async () => {
|
||||
if (!activeWorkflowId) return
|
||||
|
||||
setLoadingChats(true)
|
||||
try {
|
||||
const result = await listChats(activeWorkflowId)
|
||||
if (result.success) {
|
||||
setChats(result.chats)
|
||||
// If no current chat and we have chats, select the most recent one
|
||||
if (!currentChat && result.chats.length > 0) {
|
||||
await selectChat(result.chats[0])
|
||||
}
|
||||
} else {
|
||||
logger.error('Failed to load chats:', result.error)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error loading chats:', error)
|
||||
} finally {
|
||||
setLoadingChats(false)
|
||||
}
|
||||
}, [activeWorkflowId, currentChat])
|
||||
|
||||
// Select a specific chat and load its messages
|
||||
const selectChat = useCallback(async (chat: CopilotChat) => {
|
||||
try {
|
||||
const result = await getChat(chat.id)
|
||||
if (result.success && result.chat) {
|
||||
setCurrentChat(result.chat)
|
||||
setMessages(result.chat.messages || [])
|
||||
logger.info(`Loaded chat: ${chat.title || 'Untitled'}`)
|
||||
} else {
|
||||
logger.error('Failed to load chat:', result.error)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error loading chat:', error)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Start a new chat
|
||||
const startNewChat = useCallback(() => {
|
||||
setCurrentChat(null)
|
||||
setMessages([])
|
||||
logger.info('Started new chat')
|
||||
}, [])
|
||||
|
||||
// Delete a chat
|
||||
const handleDeleteChat = useCallback(async (chatId: string) => {
|
||||
try {
|
||||
const result = await deleteChat(chatId)
|
||||
if (result.success) {
|
||||
setChats(prev => prev.filter(chat => chat.id !== chatId))
|
||||
if (currentChat?.id === chatId) {
|
||||
startNewChat()
|
||||
}
|
||||
logger.info('Chat deleted successfully')
|
||||
} else {
|
||||
logger.error('Failed to delete chat:', result.error)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error deleting chat:', error)
|
||||
}
|
||||
}, [currentChat, startNewChat])
|
||||
|
||||
// Expose functions to parent
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
clearMessages: startNewChat,
|
||||
startNewChat,
|
||||
}),
|
||||
[startNewChat]
|
||||
)
|
||||
|
||||
// Handle message submission
|
||||
const handleSubmit = useCallback(
|
||||
async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!input.trim() || isLoading) return
|
||||
if (!input.trim() || isLoading || !activeWorkflowId) return
|
||||
|
||||
const userMessage: Message = {
|
||||
id: crypto.randomUUID(),
|
||||
role: 'user',
|
||||
content: input.trim(),
|
||||
timestamp: new Date(),
|
||||
}
|
||||
|
||||
const streamingMessage: Message = {
|
||||
id: crypto.randomUUID(),
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
timestamp: new Date(),
|
||||
isStreaming: true,
|
||||
}
|
||||
|
||||
setMessages((prev) => [...prev, userMessage, streamingMessage])
|
||||
const query = input.trim()
|
||||
setInput('')
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
logger.info('Sending docs RAG query:', { query })
|
||||
// Add user message immediately
|
||||
const userMessage: CopilotMessage = {
|
||||
id: crypto.randomUUID(),
|
||||
role: 'user',
|
||||
content: query,
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
|
||||
const response = await fetch('/api/docs/ask', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
query,
|
||||
topK: 5,
|
||||
stream: true,
|
||||
}),
|
||||
// Add streaming placeholder
|
||||
const streamingMessage: CopilotMessage = {
|
||||
id: crypto.randomUUID(),
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
|
||||
setMessages((prev) => [...prev, userMessage, streamingMessage])
|
||||
|
||||
try {
|
||||
logger.info('Sending docs RAG query:', { query, chatId: currentChat?.id })
|
||||
|
||||
const result = await sendStreamingMessage({
|
||||
query,
|
||||
topK: 5,
|
||||
chatId: currentChat?.id,
|
||||
workflowId: activeWorkflowId,
|
||||
createNewChat: !currentChat,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${await response.text()}`)
|
||||
}
|
||||
|
||||
// Handle streaming response
|
||||
if (response.headers.get('content-type')?.includes('text/event-stream')) {
|
||||
const reader = response.body?.getReader()
|
||||
if (result.success && result.stream) {
|
||||
const reader = result.stream.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let accumulatedContent = ''
|
||||
let sources: any[] = []
|
||||
|
||||
if (!reader) {
|
||||
throw new Error('Failed to get response reader')
|
||||
}
|
||||
let newChatId: string | undefined
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
@@ -145,6 +214,10 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(
|
||||
|
||||
if (data.type === 'metadata') {
|
||||
sources = data.sources || []
|
||||
// Get chatId from metadata (for both new and existing chats)
|
||||
if (data.chatId) {
|
||||
newChatId = data.chatId
|
||||
}
|
||||
} else if (data.type === 'content') {
|
||||
accumulatedContent += data.content
|
||||
|
||||
@@ -152,19 +225,49 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(
|
||||
setMessages((prev) =>
|
||||
prev.map((msg) =>
|
||||
msg.id === streamingMessage.id
|
||||
? { ...msg, content: accumulatedContent, sources }
|
||||
? {
|
||||
...msg,
|
||||
content: accumulatedContent,
|
||||
citations: sources.map((source: any, index: number) => ({
|
||||
id: index + 1,
|
||||
title: source.title,
|
||||
url: source.link,
|
||||
}))
|
||||
}
|
||||
: msg
|
||||
)
|
||||
)
|
||||
} else if (data.type === 'done') {
|
||||
// Finish streaming
|
||||
// Finish streaming and reload chat if new chat was created
|
||||
setMessages((prev) =>
|
||||
prev.map((msg) =>
|
||||
msg.id === streamingMessage.id
|
||||
? { ...msg, isStreaming: false, sources }
|
||||
? {
|
||||
...msg,
|
||||
citations: sources.map((source: any, index: number) => ({
|
||||
id: index + 1,
|
||||
title: source.title,
|
||||
url: source.link,
|
||||
}))
|
||||
}
|
||||
: msg
|
||||
)
|
||||
)
|
||||
|
||||
// Update current chat state with the chatId from response
|
||||
if (newChatId && !currentChat) {
|
||||
// For new chats, create a temporary chat object and reload the full chat list
|
||||
setCurrentChat({
|
||||
id: newChatId,
|
||||
title: null,
|
||||
model: 'claude-3-7-sonnet-latest',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
messageCount: 2, // User + assistant message
|
||||
})
|
||||
// Reload chats in background to get the updated list
|
||||
loadChats()
|
||||
}
|
||||
} else if (data.type === 'error') {
|
||||
throw new Error(data.error || 'Streaming error')
|
||||
}
|
||||
@@ -180,30 +283,16 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(
|
||||
sourcesCount: sources.length,
|
||||
})
|
||||
} else {
|
||||
// Fallback to non-streaming response
|
||||
const data = await response.json()
|
||||
|
||||
const assistantMessage: Message = {
|
||||
id: streamingMessage.id,
|
||||
role: 'assistant',
|
||||
content: data.response || 'Sorry, I could not generate a response.',
|
||||
timestamp: new Date(),
|
||||
sources: data.sources || [],
|
||||
isStreaming: false,
|
||||
}
|
||||
|
||||
setMessages((prev) => prev.slice(0, -1).concat(assistantMessage))
|
||||
throw new Error(result.error || 'Failed to send message')
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Docs RAG error:', error)
|
||||
|
||||
const errorMessage: Message = {
|
||||
const errorMessage: CopilotMessage = {
|
||||
id: streamingMessage.id,
|
||||
role: 'assistant',
|
||||
content:
|
||||
'Sorry, I encountered an error while searching the documentation. Please try again.',
|
||||
timestamp: new Date(),
|
||||
isStreaming: false,
|
||||
content: 'Sorry, I encountered an error while searching the documentation. Please try again.',
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
|
||||
setMessages((prev) => prev.slice(0, -1).concat(errorMessage))
|
||||
@@ -211,26 +300,27 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(
|
||||
setIsLoading(false)
|
||||
}
|
||||
},
|
||||
[input, isLoading]
|
||||
[input, isLoading, activeWorkflowId, currentChat, loadChats]
|
||||
)
|
||||
|
||||
const formatTimestamp = (date: Date) => {
|
||||
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
// Format timestamp for display
|
||||
const formatTimestamp = (timestamp: string) => {
|
||||
return new Date(timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
// Function to render content with inline hyperlinked citations and basic markdown
|
||||
const renderContentWithCitations = (content: string, sources: Message['sources'] = []) => {
|
||||
const renderContentWithCitations = (content: string, citations: CopilotMessage['citations'] = []) => {
|
||||
if (!content) return content
|
||||
|
||||
let processedContent = content
|
||||
|
||||
// Replace {cite:1}, {cite:2}, etc. with clickable citation icons
|
||||
processedContent = processedContent.replace(/\{cite:(\d+)\}/g, (match, num) => {
|
||||
const sourceIndex = Number.parseInt(num) - 1
|
||||
const source = sources[sourceIndex]
|
||||
const citationIndex = Number.parseInt(num) - 1
|
||||
const citation = citations?.[citationIndex]
|
||||
|
||||
if (source) {
|
||||
return `<a href="${source.link}" target="_blank" rel="noopener noreferrer" class="inline-flex items-center ml-1 text-primary hover:text-primary/80 transition-colors text-sm" title="${source.title}">↗</a>`
|
||||
if (citation) {
|
||||
return `<a href="${citation.url}" target="_blank" rel="noopener noreferrer" class="inline-flex items-center ml-1 text-primary hover:text-primary/80 transition-colors text-sm" title="${citation.title}">↗</a>`
|
||||
}
|
||||
|
||||
return match
|
||||
@@ -276,29 +366,8 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(
|
||||
return processedContent
|
||||
}
|
||||
|
||||
const renderMessage = (message: Message) => {
|
||||
if (message.isStreaming && !message.content) {
|
||||
return (
|
||||
<div key={message.id} className='flex gap-3 p-4'>
|
||||
<div className='flex h-8 w-8 items-center justify-center rounded-full bg-primary'>
|
||||
<Bot className='h-4 w-4 text-primary-foreground' />
|
||||
</div>
|
||||
<div className='flex-1'>
|
||||
<div className='mb-2 flex items-center gap-2'>
|
||||
<span className='font-medium text-sm'>Copilot</span>
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
{formatTimestamp(message.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex items-center gap-2 text-muted-foreground'>
|
||||
<Loader2 className='h-4 w-4 animate-spin' />
|
||||
<span className='text-sm'>Searching documentation...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Render individual message
|
||||
const renderMessage = (message: CopilotMessage) => {
|
||||
return (
|
||||
<div key={message.id} className='group flex gap-3 p-4 hover:bg-muted/30'>
|
||||
<div
|
||||
@@ -320,12 +389,6 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
{formatTimestamp(message.timestamp)}
|
||||
</span>
|
||||
{message.isStreaming && (
|
||||
<div className='flex items-center gap-1'>
|
||||
<Loader2 className='h-3 w-3 animate-spin text-primary' />
|
||||
<span className='text-primary text-xs'>Responding...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Enhanced content rendering with inline citations */}
|
||||
@@ -333,14 +396,17 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(
|
||||
<div
|
||||
className='text-foreground text-sm leading-normal'
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: renderContentWithCitations(message.content, message.sources),
|
||||
__html: renderContentWithCitations(message.content, message.citations),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Streaming cursor */}
|
||||
{message.isStreaming && message.content && (
|
||||
<span className='ml-1 inline-block h-4 w-2 animate-pulse bg-primary' />
|
||||
{!message.content && (
|
||||
<div className='flex items-center gap-2 text-muted-foreground'>
|
||||
<Loader2 className='h-4 w-4 animate-spin' />
|
||||
<span className='text-sm'>Searching documentation...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -352,154 +418,93 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(
|
||||
id: msg.id,
|
||||
content: msg.content,
|
||||
type: msg.role as 'user' | 'assistant',
|
||||
timestamp: msg.timestamp,
|
||||
citations: msg.sources?.map((source, index) => ({
|
||||
id: index + 1,
|
||||
title: source.title,
|
||||
url: source.link,
|
||||
})),
|
||||
timestamp: new Date(msg.timestamp),
|
||||
citations: msg.citations,
|
||||
}))
|
||||
|
||||
// Handle modal message sending
|
||||
const handleModalSendMessage = useCallback(async (message: string) => {
|
||||
// Use the same handleSubmit logic but with the message parameter
|
||||
const userMessage: Message = {
|
||||
id: crypto.randomUUID(),
|
||||
role: 'user',
|
||||
content: message,
|
||||
timestamp: new Date(),
|
||||
}
|
||||
|
||||
const streamingMessage: Message = {
|
||||
id: crypto.randomUUID(),
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
timestamp: new Date(),
|
||||
isStreaming: true,
|
||||
}
|
||||
|
||||
setMessages((prev) => [...prev, userMessage, streamingMessage])
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
logger.info('Sending docs RAG query:', { query: message })
|
||||
|
||||
const response = await fetch('/api/docs/ask', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
query: message,
|
||||
topK: 5,
|
||||
stream: true,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${await response.text()}`)
|
||||
}
|
||||
|
||||
// Handle streaming response
|
||||
if (response.headers.get('content-type')?.includes('text/event-stream')) {
|
||||
const reader = response.body?.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let accumulatedContent = ''
|
||||
let sources: any[] = []
|
||||
|
||||
if (!reader) {
|
||||
throw new Error('Failed to get response reader')
|
||||
}
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
const chunk = decoder.decode(value, { stream: true })
|
||||
const lines = chunk.split('\n')
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
try {
|
||||
const data = JSON.parse(line.slice(6))
|
||||
|
||||
if (data.type === 'metadata') {
|
||||
sources = data.sources || []
|
||||
} else if (data.type === 'content') {
|
||||
accumulatedContent += data.content
|
||||
|
||||
// Update the streaming message with accumulated content
|
||||
setMessages((prev) =>
|
||||
prev.map((msg) =>
|
||||
msg.id === streamingMessage.id
|
||||
? { ...msg, content: accumulatedContent, sources }
|
||||
: msg
|
||||
)
|
||||
)
|
||||
} else if (data.type === 'done') {
|
||||
// Finish streaming
|
||||
setMessages((prev) =>
|
||||
prev.map((msg) =>
|
||||
msg.id === streamingMessage.id
|
||||
? { ...msg, isStreaming: false, sources }
|
||||
: msg
|
||||
)
|
||||
)
|
||||
} else if (data.type === 'error') {
|
||||
throw new Error(data.error || 'Streaming error')
|
||||
}
|
||||
} catch (parseError) {
|
||||
logger.warn('Failed to parse SSE data:', parseError)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('Received docs RAG response:', {
|
||||
contentLength: accumulatedContent.length,
|
||||
sourcesCount: sources.length,
|
||||
})
|
||||
} else {
|
||||
// Fallback to non-streaming response
|
||||
const data = await response.json()
|
||||
|
||||
const assistantMessage: Message = {
|
||||
id: streamingMessage.id,
|
||||
role: 'assistant',
|
||||
content: data.response || 'Sorry, I could not generate a response.',
|
||||
timestamp: new Date(),
|
||||
sources: data.sources || [],
|
||||
isStreaming: false,
|
||||
}
|
||||
|
||||
setMessages((prev) => prev.slice(0, -1).concat(assistantMessage))
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Docs RAG error:', error)
|
||||
|
||||
const errorMessage: Message = {
|
||||
id: streamingMessage.id,
|
||||
role: 'assistant',
|
||||
content:
|
||||
'Sorry, I encountered an error while searching the documentation. Please try again.',
|
||||
timestamp: new Date(),
|
||||
isStreaming: false,
|
||||
}
|
||||
|
||||
setMessages((prev) => prev.slice(0, -1).concat(errorMessage))
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [])
|
||||
// Create form event and call the main handler
|
||||
const mockEvent = { preventDefault: () => {} } as React.FormEvent
|
||||
setInput(message)
|
||||
await handleSubmit(mockEvent)
|
||||
}, [handleSubmit])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='flex h-full flex-col'>
|
||||
{/* Header */}
|
||||
{/* Header with Chat Dropdown */}
|
||||
<div className='border-b p-4'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Bot className='h-5 w-5 text-primary' />
|
||||
<div>
|
||||
<h3 className='font-medium text-sm'>Documentation Copilot</h3>
|
||||
<p className='text-muted-foreground text-xs'>Ask questions about Sim Studio</p>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Bot className='h-5 w-5 text-primary' />
|
||||
<div>
|
||||
<h3 className='font-medium text-sm'>Documentation Copilot</h3>
|
||||
<p className='text-muted-foreground text-xs'>Ask questions about Sim Studio</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chat Management */}
|
||||
<div className='flex items-center gap-2'>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={startNewChat}
|
||||
className='h-8'
|
||||
>
|
||||
<MessageSquarePlus className='h-4 w-4 mr-2' />
|
||||
New Chat
|
||||
</Button>
|
||||
|
||||
{chats.length > 0 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant='outline' size='sm' className='h-8'>
|
||||
{currentChat?.title || 'Select Chat'}
|
||||
<ChevronDown className='h-4 w-4 ml-2' />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end' className='w-64'>
|
||||
{chats.map((chat) => (
|
||||
<div key={chat.id} className='flex items-center'>
|
||||
<DropdownMenuItem
|
||||
onClick={() => selectChat(chat)}
|
||||
className='flex-1 cursor-pointer'
|
||||
>
|
||||
<div className='flex-1 min-w-0'>
|
||||
<div className='font-medium text-sm truncate'>
|
||||
{chat.title || 'Untitled Chat'}
|
||||
</div>
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
{chat.messageCount} messages • {new Date(chat.updatedAt).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-8 w-8 p-0 shrink-0'
|
||||
>
|
||||
<MoreHorizontal className='h-4 w-4' />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end'>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleDeleteChat(chat.id)}
|
||||
className='text-destructive cursor-pointer'
|
||||
>
|
||||
<Trash2 className='h-4 w-4 mr-2' />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
18
apps/sim/db/migrations/0052_fluffy_shinobi_shaw.sql
Normal file
18
apps/sim/db/migrations/0052_fluffy_shinobi_shaw.sql
Normal file
@@ -0,0 +1,18 @@
|
||||
CREATE TABLE "copilot_chats" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"workflow_id" text NOT NULL,
|
||||
"title" text,
|
||||
"messages" jsonb DEFAULT '[]' NOT NULL,
|
||||
"model" text DEFAULT 'claude-3-7-sonnet-latest' NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "copilot_chats" ADD CONSTRAINT "copilot_chats_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "copilot_chats" ADD CONSTRAINT "copilot_chats_workflow_id_workflow_id_fk" FOREIGN KEY ("workflow_id") REFERENCES "public"."workflow"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "copilot_chats_user_id_idx" ON "copilot_chats" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "copilot_chats_workflow_id_idx" ON "copilot_chats" USING btree ("workflow_id");--> statement-breakpoint
|
||||
CREATE INDEX "copilot_chats_user_workflow_idx" ON "copilot_chats" USING btree ("user_id","workflow_id");--> statement-breakpoint
|
||||
CREATE INDEX "copilot_chats_created_at_idx" ON "copilot_chats" USING btree ("created_at");--> statement-breakpoint
|
||||
CREATE INDEX "copilot_chats_updated_at_idx" ON "copilot_chats" USING btree ("updated_at");
|
||||
5085
apps/sim/db/migrations/meta/0052_snapshot.json
Normal file
5085
apps/sim/db/migrations/meta/0052_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -358,6 +358,13 @@
|
||||
"when": 1752014976338,
|
||||
"tag": "0051_typical_expediter",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 52,
|
||||
"version": "7",
|
||||
"when": 1752019053066,
|
||||
"tag": "0052_fluffy_shinobi_shaw",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -979,3 +979,31 @@ export const docsEmbeddings = pgTable(
|
||||
),
|
||||
})
|
||||
)
|
||||
|
||||
export const copilotChats = pgTable(
|
||||
'copilot_chats',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: 'cascade' }),
|
||||
workflowId: text('workflow_id')
|
||||
.notNull()
|
||||
.references(() => workflow.id, { onDelete: 'cascade' }),
|
||||
title: text('title'),
|
||||
messages: jsonb('messages').notNull().default('[]'),
|
||||
model: text('model').notNull().default('claude-3-7-sonnet-latest'),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at').notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
// Primary access patterns
|
||||
userIdIdx: index('copilot_chats_user_id_idx').on(table.userId),
|
||||
workflowIdIdx: index('copilot_chats_workflow_id_idx').on(table.workflowId),
|
||||
userWorkflowIdx: index('copilot_chats_user_workflow_idx').on(table.userId, table.workflowId),
|
||||
|
||||
// Ordering indexes
|
||||
createdAtIdx: index('copilot_chats_created_at_idx').on(table.createdAt),
|
||||
updatedAtIdx: index('copilot_chats_updated_at_idx').on(table.updatedAt),
|
||||
})
|
||||
)
|
||||
|
||||
293
apps/sim/lib/copilot-api.ts
Normal file
293
apps/sim/lib/copilot-api.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
|
||||
const logger = createLogger('CopilotAPI')
|
||||
|
||||
export interface CopilotChat {
|
||||
id: string
|
||||
title: string | null
|
||||
model: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
messageCount: number
|
||||
messages?: CopilotMessage[]
|
||||
}
|
||||
|
||||
export interface CopilotMessage {
|
||||
id: string
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
timestamp: string
|
||||
citations?: Array<{
|
||||
id: number
|
||||
title: string
|
||||
url: string
|
||||
}>
|
||||
}
|
||||
|
||||
export interface CreateChatRequest {
|
||||
workflowId: string
|
||||
title?: string
|
||||
model?: string
|
||||
initialMessage?: string
|
||||
}
|
||||
|
||||
export interface UpdateChatRequest {
|
||||
title?: string
|
||||
messages?: CopilotMessage[]
|
||||
model?: string
|
||||
}
|
||||
|
||||
export interface DocsQueryRequest {
|
||||
query: string
|
||||
topK?: number
|
||||
provider?: string
|
||||
model?: string
|
||||
stream?: boolean
|
||||
chatId?: string
|
||||
workflowId?: string
|
||||
createNewChat?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* List chats for a specific workflow
|
||||
*/
|
||||
export async function listChats(workflowId: string, limit = 50, offset = 0): Promise<{
|
||||
success: boolean
|
||||
chats: CopilotChat[]
|
||||
error?: string
|
||||
}> {
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
workflowId,
|
||||
limit: limit.toString(),
|
||||
offset: offset.toString(),
|
||||
})
|
||||
|
||||
const response = await fetch(`/api/copilot/chats?${params}`)
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to list chats')
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
chats: data.chats || [],
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to list chats:', error)
|
||||
return {
|
||||
success: false,
|
||||
chats: [],
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new chat
|
||||
*/
|
||||
export async function createChat(request: CreateChatRequest): Promise<{
|
||||
success: boolean
|
||||
chat?: CopilotChat
|
||||
error?: string
|
||||
}> {
|
||||
try {
|
||||
const response = await fetch('/api/copilot/chats', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(request),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to create chat')
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
chat: data.chat,
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to create chat:', error)
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific chat with full message history
|
||||
*/
|
||||
export async function getChat(chatId: string): Promise<{
|
||||
success: boolean
|
||||
chat?: CopilotChat
|
||||
error?: string
|
||||
}> {
|
||||
try {
|
||||
const response = await fetch(`/api/copilot/chats/${chatId}`)
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to get chat')
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
chat: data.chat,
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to get chat:', error)
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a chat
|
||||
*/
|
||||
export async function updateChat(chatId: string, request: UpdateChatRequest): Promise<{
|
||||
success: boolean
|
||||
chat?: CopilotChat
|
||||
error?: string
|
||||
}> {
|
||||
try {
|
||||
const response = await fetch(`/api/copilot/chats/${chatId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(request),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to update chat')
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
chat: data.chat,
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to update chat:', error)
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a chat
|
||||
*/
|
||||
export async function deleteChat(chatId: string): Promise<{
|
||||
success: boolean
|
||||
error?: string
|
||||
}> {
|
||||
try {
|
||||
const response = await fetch(`/api/copilot/chats/${chatId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to delete chat')
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete chat:', error)
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message using the docs RAG API with chat context
|
||||
*/
|
||||
export async function sendMessage(request: DocsQueryRequest): Promise<{
|
||||
success: boolean
|
||||
response?: string
|
||||
chatId?: string
|
||||
sources?: Array<{
|
||||
title: string
|
||||
document: string
|
||||
link: string
|
||||
similarity: number
|
||||
}>
|
||||
error?: string
|
||||
}> {
|
||||
try {
|
||||
const response = await fetch('/api/docs/ask', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(request),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to send message')
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
response: data.response,
|
||||
chatId: data.chatId,
|
||||
sources: data.sources,
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to send message:', error)
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a streaming message using the docs RAG API
|
||||
*/
|
||||
export async function sendStreamingMessage(request: DocsQueryRequest): Promise<{
|
||||
success: boolean
|
||||
stream?: ReadableStream
|
||||
chatId?: string
|
||||
error?: string
|
||||
}> {
|
||||
try {
|
||||
const response = await fetch('/api/docs/ask', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ...request, stream: true }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.error || 'Failed to send streaming message')
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error('No response body received')
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
stream: response.body,
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to send streaming message:', error)
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user