Convo update

This commit is contained in:
Siddharth Ganesan
2025-07-08 17:19:56 -07:00
parent 767b63c57d
commit d75751bbe6
9 changed files with 6303 additions and 274 deletions

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

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

View File

@@ -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,

View File

@@ -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>

View 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");

File diff suppressed because it is too large Load Diff

View File

@@ -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
}
]
}
}

View File

@@ -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
View 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',
}
}
}