From d1fe209d29960838a453c008a317f7ca90dd8bd9 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Tue, 8 Jul 2025 19:14:51 -0700 Subject: [PATCH] Big refactor --- apps/sim/app/api/copilot/chat/route.ts | 497 ------------ apps/sim/app/api/copilot/chats/[id]/route.ts | 217 ------ apps/sim/app/api/copilot/chats/route.ts | 176 ----- apps/sim/app/api/copilot/docs/route.ts | 257 +++++++ apps/sim/app/api/copilot/route.ts | 452 ++++++----- apps/sim/app/api/docs/ask/route.ts | 112 ++- .../components/copilot/copilot.tsx | 78 -- .../panel/components/copilot/copilot.tsx | 366 +++------ apps/sim/lib/copilot-api.ts | 298 +++++--- apps/sim/lib/copilot/config.ts | 237 ++++++ apps/sim/lib/copilot/service.ts | 716 ++++++++++++++++++ apps/sim/stores/copilot/index.ts | 8 + apps/sim/stores/copilot/store.ts | 558 ++++++++++---- apps/sim/stores/copilot/types.ts | 74 +- apps/sim/stores/copilot/utils.ts | 33 - 15 files changed, 2306 insertions(+), 1773 deletions(-) delete mode 100644 apps/sim/app/api/copilot/chat/route.ts delete mode 100644 apps/sim/app/api/copilot/chats/[id]/route.ts delete mode 100644 apps/sim/app/api/copilot/chats/route.ts create mode 100644 apps/sim/app/api/copilot/docs/route.ts delete mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/copilot/copilot.tsx create mode 100644 apps/sim/lib/copilot/config.ts create mode 100644 apps/sim/lib/copilot/service.ts create mode 100644 apps/sim/stores/copilot/index.ts delete mode 100644 apps/sim/stores/copilot/utils.ts diff --git a/apps/sim/app/api/copilot/chat/route.ts b/apps/sim/app/api/copilot/chat/route.ts deleted file mode 100644 index 7595c454c2..0000000000 --- a/apps/sim/app/api/copilot/chat/route.ts +++ /dev/null @@ -1,497 +0,0 @@ -import { and, eq } 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 { getRotatingApiKey } from '@/lib/utils' -import { db } from '@/db' -import { copilotChats } from '@/db/schema' -import { executeProviderRequest } from '@/providers' -import type { Message } from '@/providers/types' - -const logger = createLogger('CopilotChat') - -// Configuration for copilot chat -const COPILOT_CONFIG = { - defaultProvider: 'anthropic', - defaultModel: 'claude-3-7-sonnet-latest', - temperature: 0.1, - maxTokens: 4000, // Increased for more comprehensive documentation responses -} as const - -const CopilotChatSchema = z.object({ - message: z.string().min(1, 'Message is required'), - chatId: z.string().optional(), - workflowId: z.string().optional(), - createNewChat: z.boolean().optional().default(false), - stream: z.boolean().optional().default(false), -}) - -/** - * Generate a chat title using LLM based on the first user message - */ -async function generateChatTitle(userMessage: string): Promise { - try { - const apiKey = getRotatingApiKey('anthropic') - - const response = await executeProviderRequest('anthropic', { - model: 'claude-3-haiku-20240307', - 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, - }) - - 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' - } -} - -/** - * Generate chat response with tool calling support - */ -interface StreamingChatResponse { - stream: ReadableStream - citations: Array<{ - id: number - title: string - url: string - }> -} - -/** - * Extract citations from provider response that contains tool results - */ -function extractCitationsFromResponse(response: any): Array<{ - id: number - title: string - url: string -}> { - // Handle ReadableStream responses - if (response instanceof ReadableStream) { - return [] - } - - // Handle string responses - if (typeof response === 'string') { - return [] - } - - // Handle object responses - if (typeof response !== 'object' || !response) { - return [] - } - - // Check for tool results - if (!response.toolResults || !Array.isArray(response.toolResults)) { - return [] - } - - const docsSearchResult = response.toolResults.find( - (result: any) => result.sources && Array.isArray(result.sources) - ) - - if (!docsSearchResult || !docsSearchResult.sources) { - return [] - } - - return docsSearchResult.sources.map((source: any) => ({ - id: source.id, - title: source.title, - url: source.link, - })) -} - -async function generateChatResponse( - message: string, - conversationHistory: any[] = [], - stream = false, - requestId?: string -): Promise { - const apiKey = getRotatingApiKey('anthropic') - - // Build conversation context - const messages: Message[] = [] - - // Add conversation history - for (const msg of conversationHistory.slice(-10)) { - // Keep last 10 messages - messages.push({ - role: msg.role as 'user' | 'assistant' | 'system', - content: msg.content, - }) - } - - // Add current user message - messages.push({ - role: 'user', - content: message, - }) - - const systemPrompt = `You are a helpful AI assistant for Sim Studio, a powerful workflow automation platform. You can help users with questions about: - -- Creating and managing workflows -- Using different tools and blocks -- Understanding features and capabilities -- Troubleshooting issues -- Best practices - -You have access to the Sim Studio documentation through a search tool. Use it when users ask about Sim Studio features, tools, or functionality. - -WHEN TO SEARCH DOCUMENTATION: -- User asks about specific Sim Studio features or tools -- User needs help with workflows or blocks -- User has technical questions about the platform -- User asks "How do I..." questions about Sim Studio - -WHEN NOT TO SEARCH: -- Simple greetings or casual conversation -- General programming questions unrelated to Sim Studio -- Thank you messages or small talk - -CITATION FORMAT: -When you reference information from documentation sources, use this format: -- Use [1], [2], [3] etc. to cite sources -- Place citations at the end of sentences that reference specific information -- Each source should only be cited once in your response -- Continue your full response after adding citations - don't stop mid-answer - -IMPORTANT: Always provide complete, helpful responses. If you add citations, continue writing your full answer. Do not stop your response after adding a citation.` - - // Define the documentation search tool for the LLM - const tools = [ - { - id: 'docs_search_internal', - name: 'Search Documentation', - description: - 'Search Sim Studio documentation for information about features, tools, workflows, and functionality', - params: {}, - parameters: { - type: 'object', - properties: { - query: { - type: 'string', - description: 'The search query to find relevant documentation', - }, - topK: { - type: 'number', - description: 'Number of results to return (default: 5, max: 10)', - default: 5, - }, - }, - required: ['query'], - }, - }, - ] - - try { - // For streaming, we always make a non-streaming request first to handle tool calls - // Then we stream the final response if no tool calls were needed - const response = await executeProviderRequest('anthropic', { - model: COPILOT_CONFIG.defaultModel, - systemPrompt, - messages, - tools, - temperature: COPILOT_CONFIG.temperature, - maxTokens: COPILOT_CONFIG.maxTokens, - apiKey, - stream: false, // Always start with non-streaming to handle tool calls - }) - - // If this is a streaming request and we got a regular response, - // we need to create a streaming response from the content - if (stream && typeof response === 'object' && 'content' in response) { - const content = response.content || 'Sorry, I could not generate a response.' - - // Extract citations from the provider response for later use - const responseCitations = extractCitationsFromResponse(response) - - // Create a ReadableStream that emits the content in character chunks - const streamResponse = new ReadableStream({ - start(controller) { - // Use character-based streaming for more reliable transmission - const chunkSize = 8 // Stream 8 characters at a time for smooth experience - let index = 0 - - const pushNext = () => { - if (index < content.length) { - const chunk = content.slice(index, index + chunkSize) - controller.enqueue(new TextEncoder().encode(chunk)) - index += chunkSize - - // Add a small delay to simulate streaming - setTimeout(pushNext, 25) - } else { - controller.close() - } - } - - pushNext() - }, - }) - - // Store citations for later use in the main streaming handler - - ;(streamResponse as any)._citations = responseCitations - - return streamResponse - } - - // Handle regular response - if (typeof response === 'object' && 'content' in response) { - return response.content || 'Sorry, I could not generate a response.' - } - - return 'Sorry, I could not generate a response.' - } catch (error) { - logger.error('Failed to generate chat response:', error) - throw new Error( - `Failed to generate response: ${error instanceof Error ? error.message : 'Unknown error'}` - ) - } -} - -/** - * POST /api/copilot/chat - * Chat with the copilot using LLM with tool calling - */ -export async function POST(req: NextRequest) { - const requestId = crypto.randomUUID() - - try { - const body = await req.json() - const { message, chatId, workflowId, createNewChat, stream } = CopilotChatSchema.parse(body) - - const session = await getSession() - - logger.info(`[${requestId}] Copilot chat message: "${message}"`, { - chatId, - workflowId, - createNewChat, - stream, - }) - - // 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, - model: COPILOT_CONFIG.defaultModel, - messages: [], - }) - .returning() - - if (newChat) { - currentChat = newChat - conversationHistory = [] - } - } - - // Generate chat response - const response = await generateChatResponse(message, conversationHistory, stream, requestId) - - // Handle streaming response - if (response instanceof ReadableStream) { - logger.info(`[${requestId}] Returning streaming response`) - - const encoder = new TextEncoder() - // Extract citations from the stream object if available - const citations = (response as any)._citations || [] - - return new Response( - new ReadableStream({ - async start(controller) { - const reader = response.getReader() - let accumulatedResponse = '' - - // Send initial metadata - const metadata = { - type: 'metadata', - chatId: currentChat?.id, - citations: citations, - metadata: { - requestId, - message, - }, - } - controller.enqueue(encoder.encode(`data: ${JSON.stringify(metadata)}\n\n`)) - - try { - while (true) { - const { done, value } = await reader.read() - if (done) break - - const chunkText = new TextDecoder().decode(value) - accumulatedResponse += chunkText - - const contentChunk = { - type: 'content', - content: chunkText, - } - 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: message, - timestamp: new Date().toISOString(), - } - - const assistantMessage = { - id: crypto.randomUUID(), - role: 'assistant', - content: accumulatedResponse, - timestamp: new Date().toISOString(), - citations: citations.length > 0 ? citations : undefined, - } - - 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(message) - } - - 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`) - } - - controller.enqueue(encoder.encode(`data: {"type":"done"}\n\n`)) - } catch (error) { - logger.error(`[${requestId}] Streaming error:`, error) - const errorChunk = { - type: 'error', - error: 'Streaming failed', - } - controller.enqueue(encoder.encode(`data: ${JSON.stringify(errorChunk)}\n\n`)) - } finally { - controller.close() - } - }, - }), - { - headers: { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - Connection: 'keep-alive', - }, - } - ) - } - - // Save conversation to database for non-streaming response - if (currentChat && session?.user?.id) { - const userMessage = { - id: crypto.randomUUID(), - role: 'user', - content: message, - timestamp: new Date().toISOString(), - } - - // Extract citations from response if available - const citations = extractCitationsFromResponse(response) - - const assistantMessage = { - id: crypto.randomUUID(), - role: 'assistant', - content: - typeof response === 'string' - ? response - : (typeof response === 'object' && 'content' in response - ? response.content - : '[Error generating response]') || '[Error generating response]', - timestamp: new Date().toISOString(), - citations: citations.length > 0 ? citations : undefined, - } - - 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(message) - } - - 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`) - } - - logger.info(`[${requestId}] Chat response generated successfully`) - - return NextResponse.json({ - success: true, - response: - typeof response === 'string' - ? response - : (typeof response === 'object' && 'content' in response - ? response.content - : '[Error generating response]') || '[Error generating response]', - chatId: currentChat?.id, - citations: extractCitationsFromResponse(response), - metadata: { - requestId, - message, - }, - }) - } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } - - logger.error(`[${requestId}] Copilot chat error:`, error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } -} diff --git a/apps/sim/app/api/copilot/chats/[id]/route.ts b/apps/sim/app/api/copilot/chats/[id]/route.ts deleted file mode 100644 index f89098fca2..0000000000 --- a/apps/sim/app/api/copilot/chats/[id]/route.ts +++ /dev/null @@ -1,217 +0,0 @@ -import { and, eq } 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 { getRotatingApiKey } from '@/lib/utils' -import { db } from '@/db' -import { copilotChats } from '@/db/schema' -import { executeProviderRequest } from '@/providers' - -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 { - 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 - } -} diff --git a/apps/sim/app/api/copilot/chats/route.ts b/apps/sim/app/api/copilot/chats/route.ts deleted file mode 100644 index 69cbd700f8..0000000000 --- a/apps/sim/app/api/copilot/chats/route.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { and, desc, eq } 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 = Number.parseInt(searchParams.get('limit') || '50') - const offset = Number.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 }) - } -} diff --git a/apps/sim/app/api/copilot/docs/route.ts b/apps/sim/app/api/copilot/docs/route.ts new file mode 100644 index 0000000000..60f8d3502f --- /dev/null +++ b/apps/sim/app/api/copilot/docs/route.ts @@ -0,0 +1,257 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { getSession } from '@/lib/auth' +import { createLogger } from '@/lib/logs/console-logger' +import { + generateDocsResponse, + getChat, + createChat, + updateChat, + generateChatTitle, +} from '@/lib/copilot/service' + +const logger = createLogger('CopilotDocsAPI') + +// Schema for docs queries +const DocsQuerySchema = z.object({ + query: z.string().min(1, 'Query is required'), + topK: z.number().min(1).max(20).default(5), + provider: z.string().optional(), + model: z.string().optional(), + stream: z.boolean().optional().default(false), + chatId: z.string().optional(), + workflowId: z.string().optional(), + createNewChat: z.boolean().optional().default(false), +}) + +/** + * POST /api/copilot/docs + * Ask questions about documentation using RAG + */ +export async function POST(req: NextRequest) { + const requestId = crypto.randomUUID() + + try { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const body = await req.json() + const { query, topK, provider, model, stream, chatId, workflowId, createNewChat } = + DocsQuerySchema.parse(body) + + logger.info(`[${requestId}] Docs RAG query: "${query}"`, { + provider, + model, + topK, + chatId, + workflowId, + createNewChat, + userId: session.user.id, + }) + + // Handle chat context + let currentChat: any = null + let conversationHistory: any[] = [] + + if (chatId) { + // Load existing chat + currentChat = await getChat(chatId, session.user.id) + if (currentChat) { + conversationHistory = currentChat.messages + } + } else if (createNewChat && workflowId) { + // Create new chat + currentChat = await createChat(session.user.id, workflowId) + } + + // Generate docs response + const result = await generateDocsResponse(query, conversationHistory, { + topK, + provider, + model, + stream, + workflowId, + requestId, + }) + + if (stream && result.response instanceof ReadableStream) { + // Handle streaming response with docs sources + logger.info(`[${requestId}] Returning streaming docs response`) + + const encoder = new TextEncoder() + + return new Response( + new ReadableStream({ + async start(controller) { + const reader = (result.response as ReadableStream).getReader() + let accumulatedResponse = '' + + try { + // Send initial metadata including sources + const metadata = { + type: 'metadata', + chatId: currentChat?.id, + sources: result.sources, + citations: result.sources.map((source, index) => ({ + id: index + 1, + title: source.title, + url: source.url, + })), + metadata: { + requestId, + chunksFound: result.sources.length, + query, + topSimilarity: result.sources[0]?.similarity, + provider, + model, + }, + } + controller.enqueue(encoder.encode(`data: ${JSON.stringify(metadata)}\n\n`)) + + while (true) { + const { done, value } = await reader.read() + if (done) break + + const chunk = new TextDecoder().decode(value) + // Clean up any object serialization artifacts in streaming content + const cleanedChunk = chunk.replace(/\[object Object\],?/g, '') + accumulatedResponse += cleanedChunk + + const contentChunk = { + type: 'content', + content: cleanedChunk, + } + controller.enqueue(encoder.encode(`data: ${JSON.stringify(contentChunk)}\n\n`)) + } + + // Save conversation to database after streaming completes + if (currentChat) { + 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: result.sources.map((source, index) => ({ + id: index + 1, + title: source.title, + url: source.url, + })), + } + + 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 updateChat(currentChat.id, session.user.id, { + title: updatedTitle, + messages: updatedMessages, + }) + + logger.info(`[${requestId}] Updated chat ${currentChat.id} with new docs messages`) + } + + // Send completion marker + controller.enqueue(encoder.encode(`data: {"type":"done"}\n\n`)) + } catch (error) { + logger.error(`[${requestId}] Docs streaming error:`, error) + const errorChunk = { + type: 'error', + error: 'Streaming failed', + } + controller.enqueue(encoder.encode(`data: ${JSON.stringify(errorChunk)}\n\n`)) + } finally { + controller.close() + } + }, + }), + { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }, + } + ) + } + + // Handle non-streaming response + logger.info(`[${requestId}] Docs RAG response generated successfully`) + + // Save conversation to database if we have a chat + if (currentChat) { + const userMessage = { + id: crypto.randomUUID(), + role: 'user', + content: query, + timestamp: new Date().toISOString(), + } + + const assistantMessage = { + id: crypto.randomUUID(), + role: 'assistant', + content: typeof result.response === 'string' ? result.response : '[Streaming Response]', + timestamp: new Date().toISOString(), + citations: result.sources.map((source, index) => ({ + id: index + 1, + title: source.title, + url: source.url, + })), + } + + 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 updateChat(currentChat.id, session.user.id, { + title: updatedTitle, + messages: updatedMessages, + }) + + logger.info(`[${requestId}] Updated chat ${currentChat.id} with new docs messages`) + } + + return NextResponse.json({ + success: true, + response: result.response, + sources: result.sources, + chatId: currentChat?.id, + metadata: { + requestId, + chunksFound: result.sources.length, + query, + topSimilarity: result.sources[0]?.similarity, + provider, + model, + }, + }) + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: 'Invalid request data', details: error.errors }, + { status: 400 } + ) + } + + logger.error(`[${requestId}] Copilot docs error:`, error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} \ No newline at end of file diff --git a/apps/sim/app/api/copilot/route.ts b/apps/sim/app/api/copilot/route.ts index e9c2ad421c..132470aa82 100644 --- a/apps/sim/app/api/copilot/route.ts +++ b/apps/sim/app/api/copilot/route.ts @@ -1,214 +1,302 @@ -import { NextResponse } from 'next/server' -import { OpenAI } from 'openai' -import type { ChatCompletionMessageParam } from 'openai/resources/chat/completions' +import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' +import { getSession } from '@/lib/auth' import { createLogger } from '@/lib/logs/console-logger' +import { + sendMessage, + createChat, + getChat, + listChats, + deleteChat, + generateDocsResponse, + type CopilotMessage, +} from '@/lib/copilot/service' const logger = createLogger('CopilotAPI') -const MessageSchema = z.object({ - role: z.enum(['user', 'assistant', 'system']), - content: z.string(), +// Schema for sending messages +const SendMessageSchema = z.object({ + message: z.string().min(1, 'Message is required'), + chatId: z.string().optional(), + workflowId: z.string().optional(), + createNewChat: z.boolean().optional().default(false), + stream: z.boolean().optional().default(false), }) -const RequestSchema = z.object({ - messages: z.array(MessageSchema), - workflowState: z.object({ - blocks: z.record(z.any()), - edges: z.array(z.any()), - }), +// Schema for docs queries +const DocsQuerySchema = z.object({ + query: z.string().min(1, 'Query is required'), + topK: z.number().min(1).max(20).default(5), + provider: z.string().optional(), + model: z.string().optional(), + stream: z.boolean().optional().default(false), + chatId: z.string().optional(), + workflowId: z.string().optional(), + createNewChat: z.boolean().optional().default(false), }) -const workflowActions = { - addBlock: { - description: 'Add one new block to the workflow', - parameters: { - type: 'object', - required: ['type'], - properties: { - type: { - type: 'string', - enum: ['agent', 'api', 'condition', 'function', 'router'], - description: 'The type of block to add', - }, - name: { - type: 'string', - description: - 'Optional custom name for the block. Do not provide a name unless the user has specified it.', - }, - position: { - type: 'object', - description: - 'Optional position for the block. Do not provide a position unless the user has specified it.', - properties: { - x: { type: 'number' }, - y: { type: 'number' }, - }, - }, - }, - }, - }, - addEdge: { - description: 'Create a connection (edge) between two blocks', - parameters: { - type: 'object', - required: ['sourceId', 'targetId'], - properties: { - sourceId: { - type: 'string', - description: 'ID of the source block', - }, - targetId: { - type: 'string', - description: 'ID of the target block', - }, - sourceHandle: { - type: 'string', - description: 'Optional handle identifier for the source connection point', - }, - targetHandle: { - type: 'string', - description: 'Optional handle identifier for the target connection point', - }, - }, - }, - }, - removeBlock: { - description: 'Remove a block from the workflow', - parameters: { - type: 'object', - required: ['id'], - properties: { - id: { type: 'string', description: 'ID of the block to remove' }, - }, - }, - }, - removeEdge: { - description: 'Remove a connection (edge) between blocks', - parameters: { - type: 'object', - required: ['id'], - properties: { - id: { type: 'string', description: 'ID of the edge to remove' }, - }, - }, - }, -} +// Schema for creating chats +const CreateChatSchema = z.object({ + workflowId: z.string().min(1, 'Workflow ID is required'), + title: z.string().optional(), + initialMessage: z.string().optional(), +}) -// System prompt that references workflow state -const getSystemPrompt = (workflowState: any) => { - const blockCount = Object.keys(workflowState.blocks).length - const edgeCount = workflowState.edges.length +// Schema for listing chats +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), +}) - // Create a summary of existing blocks - const blockSummary = Object.values(workflowState.blocks) - .map((block: any) => `- ${block.type} block named "${block.name}" with id ${block.id}`) - .join('\n') - - // Create a summary of existing edges - const edgeSummary = workflowState.edges - .map((edge: any) => `- ${edge.source} -> ${edge.target} with id ${edge.id}`) - .join('\n') - - return `You are a workflow assistant that helps users modify their workflow by adding/removing blocks and connections. - -Current Workflow State: -${ - blockCount === 0 - ? 'The workflow is empty.' - : `${blockSummary} - -Connections: -${edgeCount === 0 ? 'No connections between blocks.' : edgeSummary}` -} - -When users request changes: -- Consider existing blocks when suggesting connections -- Provide clear feedback about what actions you've taken - -Use the following functions to modify the workflow: -1. Use the addBlock function to create a new block -2. Use the addEdge function to connect one block to another -3. Use the removeBlock function to remove a block -4. Use the removeEdge function to remove a connection - -Only use the provided functions and respond naturally to the user's requests.` -} - -export async function POST(request: Request) { - const requestId = crypto.randomUUID().slice(0, 8) +/** + * POST /api/copilot + * Send a message to the copilot + */ +export async function POST(req: NextRequest) { + const requestId = crypto.randomUUID() try { - // Validate API key - const apiKey = request.headers.get('X-OpenAI-Key') - if (!apiKey) { - return NextResponse.json({ error: 'OpenAI API key is required' }, { status: 401 }) + const body = await req.json() + const { message, chatId, workflowId, createNewChat, stream } = SendMessageSchema.parse(body) + + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - // Parse and validate request body - const body = await request.json() - const validatedData = RequestSchema.parse(body) - const { messages, workflowState } = validatedData - - // Initialize OpenAI client - const openai = new OpenAI({ apiKey }) - - // Create message history with workflow context - const messageHistory = [ - { role: 'system', content: getSystemPrompt(workflowState) }, - ...messages, - ] - - // Make OpenAI API call with workflow context - const completion = await openai.chat.completions.create({ - model: 'gpt-4o', - messages: messageHistory as ChatCompletionMessageParam[], - tools: Object.entries(workflowActions).map(([name, config]) => ({ - type: 'function', - function: { - name, - description: config.description, - parameters: config.parameters, - }, - })), - tool_choice: 'auto', + logger.info(`[${requestId}] Copilot message: "${message}"`, { + chatId, + workflowId, + createNewChat, + stream, + userId: session.user.id, }) - const message = completion.choices[0].message + // Send message using the service + const result = await sendMessage({ + message, + chatId, + workflowId, + createNewChat, + stream, + userId: session.user.id, + }) - // Process tool calls if present - if (message.tool_calls) { - logger.debug(`[${requestId}] Tool calls:`, { - toolCalls: message.tool_calls, - }) - const actions = message.tool_calls.map((call) => ({ - name: call.function.name, - parameters: JSON.parse(call.function.arguments), - })) + // Handle streaming response + if (result.response instanceof ReadableStream) { + logger.info(`[${requestId}] Returning streaming response`) - return NextResponse.json({ - message: message.content || "I've updated the workflow based on your request.", - actions, - }) + const encoder = new TextEncoder() + + return new Response( + new ReadableStream({ + async start(controller) { + const reader = (result.response as ReadableStream).getReader() + let accumulatedResponse = '' + + // Send initial metadata + const metadata = { + type: 'metadata', + chatId: result.chatId, + citations: result.citations || [], + metadata: { + requestId, + message, + }, + } + controller.enqueue(encoder.encode(`data: ${JSON.stringify(metadata)}\n\n`)) + + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + + const chunkText = new TextDecoder().decode(value) + accumulatedResponse += chunkText + + const contentChunk = { + type: 'content', + content: chunkText, + } + controller.enqueue(encoder.encode(`data: ${JSON.stringify(contentChunk)}\n\n`)) + } + + // Send completion signal + const completion = { + type: 'complete', + finalContent: accumulatedResponse, + } + controller.enqueue(encoder.encode(`data: ${JSON.stringify(completion)}\n\n`)) + controller.close() + } catch (error) { + logger.error(`[${requestId}] Streaming error:`, error) + const errorChunk = { + type: 'error', + error: 'Streaming failed', + } + controller.enqueue(encoder.encode(`data: ${JSON.stringify(errorChunk)}\n\n`)) + controller.close() + } + }, + }), + { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }, + } + ) } - // Return response with no actions + // Handle non-streaming response + logger.info(`[${requestId}] Chat response generated successfully`) + return NextResponse.json({ - message: - message.content || - "I'm not sure what changes to make to the workflow. Can you please provide more specific instructions?", + success: true, + response: result.response, + chatId: result.chatId, + citations: result.citations || [], + metadata: { + requestId, + message, + }, }) } catch (error) { - logger.error(`[${requestId}] Copilot API error:`, { error }) - - // Handle specific error types if (error instanceof z.ZodError) { return NextResponse.json( - { error: 'Invalid request format', details: error.errors }, + { error: 'Invalid request data', details: error.errors }, { status: 400 } ) } - return NextResponse.json({ error: 'Failed to process copilot message' }, { status: 500 }) + logger.error(`[${requestId}] Copilot error:`, error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} + +/** + * GET /api/copilot + * List chats or get a specific chat + */ +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 chatId = searchParams.get('chatId') + + // If chatId is provided, get specific chat + if (chatId) { + const chat = await getChat(chatId, session.user.id) + if (!chat) { + return NextResponse.json({ error: 'Chat not found' }, { status: 404 }) + } + + return NextResponse.json({ + success: true, + chat, + }) + } + + // Otherwise, list chats + const workflowId = searchParams.get('workflowId') + const limit = Number.parseInt(searchParams.get('limit') || '50') + const offset = Number.parseInt(searchParams.get('offset') || '0') + + if (!workflowId) { + return NextResponse.json( + { error: 'workflowId is required for listing chats' }, + { status: 400 } + ) + } + + const chats = await listChats(session.user.id, workflowId, { limit, offset }) + + return NextResponse.json({ + success: true, + chats, + }) + } catch (error) { + logger.error('Failed to handle GET request:', error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} + +/** + * PUT /api/copilot + * Create a new chat + */ +export async function PUT(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, initialMessage } = CreateChatSchema.parse(body) + + logger.info(`Creating new chat for user ${session.user.id}, workflow ${workflowId}`) + + const chat = await createChat(session.user.id, workflowId, { + title, + initialMessage, + }) + + logger.info(`Created chat ${chat.id} for user ${session.user.id}`) + + return NextResponse.json({ + success: true, + chat, + }) + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: 'Invalid request data', details: error.errors }, + { status: 400 } + ) + } + + logger.error('Failed to create chat:', error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} + +/** + * DELETE /api/copilot + * Delete a chat + */ +export async function DELETE(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 chatId = searchParams.get('chatId') + + if (!chatId) { + return NextResponse.json({ error: 'chatId is required' }, { status: 400 }) + } + + const success = await deleteChat(chatId, session.user.id) + + if (!success) { + return NextResponse.json({ error: 'Chat not found or access denied' }, { status: 404 }) + } + + return NextResponse.json({ + success: true, + message: 'Chat deleted successfully', + }) + } catch (error) { + logger.error('Failed to delete chat:', error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } } diff --git a/apps/sim/app/api/docs/ask/route.ts b/apps/sim/app/api/docs/ask/route.ts index 56245fe50d..7e2c048df8 100644 --- a/apps/sim/app/api/docs/ask/route.ts +++ b/apps/sim/app/api/docs/ask/route.ts @@ -2,29 +2,16 @@ import { and, eq, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { getSession } from '@/lib/auth' -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 { db } from '@/db' import { copilotChats, docsEmbeddings } from '@/db/schema' import { executeProviderRequest } from '@/providers' -import { getProviderDefaultModel } from '@/providers/models' +import { getApiKey } from '@/providers/utils' +import { getCopilotConfig, getCopilotModel } from '@/lib/copilot/config' const logger = createLogger('DocsRAG') -// Configuration for docs RAG -const DOCS_RAG_CONFIG = { - // Default provider for docs RAG - change this constant to switch providers - defaultProvider: 'anthropic', // Options: 'openai', 'anthropic', 'deepseek', 'google', 'xai', etc. - // Default model for docs RAG - will use provider's default if not specified - defaultModel: 'claude-3-7-sonnet-latest', // e.g., 'gpt-4o-mini', 'claude-3-5-sonnet-latest', 'deepseek-chat' - // Temperature for response generation - temperature: 0.1, - // Max tokens for response - maxTokens: 1000, -} as const - const DocsQuerySchema = z.object({ query: z.string().min(1, 'Query is required'), topK: z.number().min(1).max(20).default(10), @@ -42,10 +29,23 @@ const DocsQuerySchema = z.object({ */ async function generateChatTitle(userMessage: string): Promise { try { - const apiKey = getRotatingApiKey('anthropic') + const { provider, model } = getCopilotModel('title') + let apiKey: string + try { + // Use rotating key directly for hosted providers + if ((provider === 'openai' || provider === 'anthropic')) { + const { getRotatingApiKey } = require('@/lib/utils') + apiKey = getRotatingApiKey(provider) + } else { + apiKey = getApiKey(provider, model) + } + } catch (error) { + logger.error(`Failed to get API key for title generation (${provider} ${model}):`, error) + return 'New Chat' // Fallback if API key is not available + } - const response = await executeProviderRequest('anthropic', { - model: 'claude-3-haiku-20240307', // Use faster, cheaper model for title generation + const response = await executeProviderRequest(provider, { + model, 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}" @@ -119,29 +119,25 @@ async function generateResponse( stream = false, conversationHistory: any[] = [] ): Promise { - // Determine which provider and model to use - const selectedProvider = provider || DOCS_RAG_CONFIG.defaultProvider - const selectedModel = - model || DOCS_RAG_CONFIG.defaultModel || getProviderDefaultModel(selectedProvider) + const config = getCopilotConfig() + + // Determine which provider and model to use - allow overrides + const selectedProvider = provider || config.rag.defaultProvider + const selectedModel = model || config.rag.defaultModel - // Get API key for the selected provider + // Get API key using the provider utils let apiKey: string try { - if (selectedProvider === 'openai' || selectedProvider === 'azure-openai') { - apiKey = getRotatingApiKey('openai') - } else if (selectedProvider === 'anthropic') { - apiKey = getRotatingApiKey('anthropic') + // Use rotating key directly for hosted providers + if ((selectedProvider === 'openai' || selectedProvider === 'anthropic')) { + const { getRotatingApiKey } = require('@/lib/utils') + apiKey = getRotatingApiKey(selectedProvider) } else { - // For other providers, try to get from environment - const envKey = `${selectedProvider.toUpperCase().replace('-', '_')}_API_KEY` - apiKey = process.env[envKey] || '' - if (!apiKey) { - throw new Error(`API key not configured for provider: ${selectedProvider}`) - } + apiKey = getApiKey(selectedProvider, selectedModel) } } catch (error) { - logger.error(`Failed to get API key for provider ${selectedProvider}:`, error) - throw new Error(`API key not configured for provider: ${selectedProvider}`) + logger.error(`Failed to get API key for ${selectedProvider} ${selectedModel}:`, error) + throw new Error(`API key not configured for ${selectedProvider}. Please set up API keys for this provider or use a different one.`) } // Format chunks as context with numbered sources @@ -172,8 +168,8 @@ Content: ${chunkText}` let conversationContext = '' if (conversationHistory.length > 0) { conversationContext = '\n\nConversation History:\n' - conversationHistory.slice(-6).forEach((msg: any) => { - // Include last 6 messages for context + conversationHistory.slice(-config.general.maxConversationHistory).forEach((msg: any) => { + // Use config for conversation history limit const role = msg.role === 'user' ? 'Human' : 'Assistant' conversationContext += `${role}: ${msg.content}\n` }) @@ -216,15 +212,10 @@ ${context}` model: selectedModel, systemPrompt, context: userPrompt, - temperature: DOCS_RAG_CONFIG.temperature, - maxTokens: DOCS_RAG_CONFIG.maxTokens, + temperature: config.rag.temperature, + maxTokens: config.rag.maxTokens, apiKey, stream, - // Azure OpenAI specific parameters if needed - ...(selectedProvider === 'azure-openai' && { - azureEndpoint: env.AZURE_OPENAI_ENDPOINT, - azureApiVersion: env.AZURE_OPENAI_API_VERSION, - }), } const response = await executeProviderRequest(selectedProvider, providerRequest) @@ -275,15 +266,15 @@ export async function POST(req: NextRequest) { const { query, topK, provider, model, stream, chatId, workflowId, createNewChat } = DocsQuerySchema.parse(body) + const config = getCopilotConfig() + const ragConfig = getCopilotModel('rag') + // Get session for chat functionality const session = await getSession() logger.info(`[${requestId}] Docs RAG query: "${query}"`, { - provider: provider || DOCS_RAG_CONFIG.defaultProvider, - model: - model || - DOCS_RAG_CONFIG.defaultModel || - getProviderDefaultModel(provider || DOCS_RAG_CONFIG.defaultProvider), + provider: provider || ragConfig.provider, + model: model || ragConfig.model, topK, chatId, workflowId, @@ -314,7 +305,7 @@ export async function POST(req: NextRequest) { userId: session.user.id, workflowId, title: null, // Will be generated after first response - model: model || DOCS_RAG_CONFIG.defaultModel, + model: model || ragConfig.model, messages: [], }) .returning() @@ -347,11 +338,8 @@ export async function POST(req: NextRequest) { requestId, chunksFound: 0, query, - provider: provider || DOCS_RAG_CONFIG.defaultProvider, - model: - model || - DOCS_RAG_CONFIG.defaultModel || - getProviderDefaultModel(provider || DOCS_RAG_CONFIG.defaultProvider), + provider: provider || ragConfig.provider, + model: model || ragConfig.model, }, }) } @@ -398,11 +386,8 @@ export async function POST(req: NextRequest) { chunksFound: chunks.length, query, topSimilarity: sources[0]?.similarity, - provider: provider || DOCS_RAG_CONFIG.defaultProvider, - model: - model || - DOCS_RAG_CONFIG.defaultModel || - getProviderDefaultModel(provider || DOCS_RAG_CONFIG.defaultProvider), + provider: provider || ragConfig.provider, + model: model || ragConfig.model, }, } controller.enqueue(encoder.encode(`data: ${JSON.stringify(metadata)}\n\n`)) @@ -549,11 +534,8 @@ export async function POST(req: NextRequest) { chunksFound: chunks.length, query, topSimilarity: sources[0]?.similarity, - provider: provider || DOCS_RAG_CONFIG.defaultProvider, - model: - model || - DOCS_RAG_CONFIG.defaultModel || - getProviderDefaultModel(provider || DOCS_RAG_CONFIG.defaultProvider), + provider: provider || ragConfig.provider, + model: model || ragConfig.model, }, }) } catch (error) { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/copilot/copilot.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/copilot/copilot.tsx deleted file mode 100644 index 66c473c229..0000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/copilot/copilot.tsx +++ /dev/null @@ -1,78 +0,0 @@ -'use client' - -import { useState } from 'react' -import { MessageCircle, Send, X } from 'lucide-react' -import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { useCopilotStore } from '@/stores/copilot/store' - -export function Copilot() { - const { sendMessage } = useCopilotStore() - const [isOpen, setIsOpen] = useState(false) - const [message, setMessage] = useState('') - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault() - if (!message.trim()) return - - await sendMessage(message) - setMessage('') - } - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Enter') { - e.preventDefault() - handleSubmit(e as unknown as React.FormEvent) - } - } - - if (!isOpen) { - return ( - - - - - Open Chat - - ) - } - - return ( -
-
- - setMessage(e.target.value)} - onKeyDown={handleKeyDown} - placeholder='Type your message...' - className='flex-1 rounded-xl border-0 text-foreground text-sm placeholder:text-muted-foreground/50 focus-visible:ring-0 focus-visible:ring-offset-0' - /> - -
-
- ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/copilot.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/copilot.tsx index 1676d25559..ed238d72f7 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/copilot.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/copilot.tsx @@ -1,6 +1,6 @@ 'use client' -import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react' +import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef } from 'react' import { Bot, ChevronDown, @@ -20,16 +20,10 @@ import { } from '@/components/ui/dropdown-menu' import { Input } from '@/components/ui/input' import { ScrollArea } from '@/components/ui/scroll-area' -import { - type CopilotChat, - type CopilotMessage, - deleteChat, - getChat, - listChats, - sendStreamingMessage, -} from '@/lib/copilot-api' import { createLogger } from '@/lib/logs/console-logger' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' +import { useCopilotStore } from '@/stores/copilot/store' +import type { CopilotMessage } from '@/stores/copilot/types' import { CopilotModal } from './components/copilot-modal/copilot-modal' const logger = createLogger('Copilot') @@ -58,23 +52,36 @@ export const Copilot = forwardRef( }, ref ) => { - const [messages, setMessages] = useState([]) - const [input, setInput] = useState('') - const [isLoading, setIsLoading] = useState(false) - const [currentChat, setCurrentChat] = useState(null) - const [chats, setChats] = useState([]) - const [loadingChats, setLoadingChats] = useState(false) - const scrollAreaRef = useRef(null) const inputRef = useRef(null) + const scrollAreaRef = useRef(null) const { activeWorkflowId } = useWorkflowRegistry() + + // Use the new copilot store + const { + currentChat, + chats, + messages, + isLoading, + isLoadingChats, + isSendingMessage, + error, + workflowId, + setWorkflowId, + selectChat, + createNewChat, + deleteChat, + sendDocsMessage, + clearMessages, + clearError, + } = useCopilotStore() - // Load chats when workflow changes + // Sync workflow ID with store useEffect(() => { - if (activeWorkflowId) { - loadChats() + if (activeWorkflowId !== workflowId) { + setWorkflowId(activeWorkflowId) } - }, [activeWorkflowId]) + }, [activeWorkflowId, workflowId, setWorkflowId]) // Auto-scroll to bottom when new messages are added useEffect(() => { @@ -88,232 +95,56 @@ export const Copilot = forwardRef( } }, [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 + // Handle chat deletion 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) - } + await deleteChat(chatId) + logger.info('Chat deleted successfully') } catch (error) { logger.error('Error deleting chat:', error) } }, - [currentChat, startNewChat] + [deleteChat] ) + // Handle new chat creation + const handleStartNewChat = useCallback(() => { + clearMessages() + logger.info('Started new chat') + }, [clearMessages]) + // Expose functions to parent useImperativeHandle( ref, () => ({ - clearMessages: startNewChat, - startNewChat, + clearMessages: handleStartNewChat, + startNewChat: handleStartNewChat, }), - [startNewChat] + [handleStartNewChat] ) // Handle message submission const handleSubmit = useCallback( - async (e: React.FormEvent) => { + async (e: React.FormEvent, message?: string) => { e.preventDefault() - if (!input.trim() || isLoading || !activeWorkflowId) return + + const query = message || (inputRef.current?.value?.trim() || '') + if (!query || isSendingMessage || !activeWorkflowId) return - const query = input.trim() - setInput('') - setIsLoading(true) - - // Add user message immediately - const userMessage: CopilotMessage = { - id: crypto.randomUUID(), - role: 'user', - content: query, - timestamp: new Date().toISOString(), + // Clear input if using the form input + if (!message && inputRef.current) { + inputRef.current.value = '' } - // 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({ - message: query, - chatId: currentChat?.id, - workflowId: activeWorkflowId, - createNewChat: !currentChat, - }) - - if (result.success && result.stream) { - const reader = result.stream.getReader() - const decoder = new TextDecoder() - let accumulatedContent = '' - let newChatId: string | undefined - let responseCitations: Array<{ id: number; title: string; url: string }> = [] - let streamComplete = false - - while (true) { - const { done, value } = await reader.read() - if (done || streamComplete) 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') { - // Get chatId from metadata (for both new and existing chats) - if (data.chatId) { - newChatId = data.chatId - } - // Get citations from metadata - if (data.citations) { - responseCitations = data.citations - } - } else if (data.type === 'content') { - accumulatedContent += data.content - - // Update the streaming message with accumulated content and citations - setMessages((prev) => - prev.map((msg) => - msg.id === streamingMessage.id - ? { - ...msg, - content: accumulatedContent, - citations: - responseCitations.length > 0 ? responseCitations : undefined, - } - : msg - ) - ) - } else if (data.type === 'done') { - // Final update to ensure citations are applied - setMessages((prev) => - prev.map((msg) => - msg.id === streamingMessage.id - ? { - ...msg, - content: accumulatedContent, - citations: - responseCitations.length > 0 ? responseCitations : undefined, - } - : 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() - } - - // Mark stream as complete to exit outer loop - streamComplete = true - break - } 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 copilot chat response:', { - contentLength: accumulatedContent.length, - }) - } else { - throw new Error(result.error || 'Failed to send message') - } + await sendDocsMessage(query, { stream: true }) + logger.info('Sent docs query:', query) } catch (error) { - logger.error('Docs RAG error:', error) - - const errorMessage: CopilotMessage = { - id: streamingMessage.id, - role: 'assistant', - 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)) - } finally { - setIsLoading(false) + logger.error('Failed to send docs message:', error) } }, - [input, isLoading, activeWorkflowId, currentChat, loadChats] + [isSendingMessage, activeWorkflowId, sendDocsMessage] ) // Format timestamp for display @@ -343,7 +174,6 @@ export const Copilot = forwardRef( }) // Also replace standalone ↗ symbols with clickable citation links - // This handles cases where the LLM outputs ↗ directly if (citations && citations.length > 0) { let citationIndex = 0 processedContent = processedContent.replace(/↗/g, () => { @@ -356,34 +186,27 @@ export const Copilot = forwardRef( }) } - // Basic markdown processing for better formatting + // Basic markdown processing processedContent = processedContent - // Handle code blocks .replace( /```(\w+)?\n([\s\S]*?)```/g, '
$2
' ) - // Handle inline code .replace( /`([^`]+)`/g, '$1' ) - // Handle bold text .replace(/\*\*(.*?)\*\*/g, '$1') - // Handle italic text .replace(/\*(.*?)\*/g, '$1') - // Handle headers .replace(/^### (.*$)/gm, '

$1

') .replace(/^## (.*$)/gm, '

$1

') .replace(/^# (.*$)/gm, '

$1

') - // Handle unordered lists .replace(/^\* (.*$)/gm, '
  • • $1
  • ') .replace(/^- (.*$)/gm, '
  • • $1
  • ') - // Handle line breaks (reduce spacing) .replace(/\n\n+/g, '

    ') .replace(/\n/g, '
    ') - // Wrap in paragraph tags if not already wrapped + // Wrap in paragraph tags if needed if ( !processedContent.includes('

    ') && !processedContent.includes('

    ') && @@ -455,10 +278,8 @@ export const Copilot = forwardRef( // Handle modal message sending const handleModalSendMessage = useCallback( async (message: string) => { - // Create form event and call the main handler const mockEvent = { preventDefault: () => {} } as React.FormEvent - setInput(message) - await handleSubmit(mockEvent) + await handleSubmit(mockEvent, message) }, [handleSubmit] ) @@ -519,64 +340,81 @@ export const Copilot = forwardRef( + + {/* Error display */} + {error && ( +
    + {error} + +
    + )} - {/* Messages */} - + {/* Messages area */} + {messages.length === 0 ? ( -
    - -

    Welcome to Documentation Copilot

    -

    - Ask me anything about Sim Studio features, workflows, tools, or how to get - started. -

    -
    -
    Try asking:
    -
    -
    - "How do I create a workflow?" -
    -
    - "What tools are available?" -
    -
    - "How do I deploy my workflow?" +
    +
    + +
    +

    Welcome to Documentation Copilot

    +

    + Ask me anything about Sim Studio features, workflows, tools, or how to get + started. +

    +
    +
    +
    Try asking:
    +
    +
    + "How do I create a workflow?" +
    +
    + "What tools are available?" +
    +
    + "How do I deploy my workflow?" +
    ) : ( -
    {messages.map(renderMessage)}
    + messages.map(renderMessage) )} - {/* Input */} + {/* Input area */}
    setInput(e.target.value)} placeholder='Ask about Sim Studio documentation...' - disabled={isLoading} + disabled={isSendingMessage} className='flex-1' autoComplete='off' />