diff --git a/apps/sim/app/api/copilot/chat/route.ts b/apps/sim/app/api/copilot/chat/route.ts new file mode 100644 index 0000000000..109ca6eb17 --- /dev/null +++ b/apps/sim/app/api/copilot/chat/route.ts @@ -0,0 +1,488 @@ +import { eq, and } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { createLogger } from '@/lib/logs/console-logger' +import { getRotatingApiKey } from '@/lib/utils' +import { getSession } from '@/lib/auth' +import { db } from '@/db' +import { copilotChats } from '@/db/schema' +import { executeProviderRequest } from '@/providers' +import type { Message } from '@/providers/types' +import { executeTool } from '@/tools' + +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 +}> { + 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, but use it SELECTIVELY. + +WHEN TO SEARCH DOCUMENTATION: +- User asks "How do I create a workflow?" +- User asks about specific tools or blocks +- User needs help with Sim Studio features +- User has technical questions about the platform + +WHEN NOT TO SEARCH DOCUMENTATION: +- Simple greetings like "hi", "hello", "hey" +- General conversation like "how are you?" +- Thank you messages +- General programming questions unrelated to Sim Studio +- Small talk or casual conversation + +Guidelines: +- Be conversational and helpful +- For greetings and casual conversation, respond directly without searching +- Only use docs_search_internal when the user specifically needs information about Sim Studio features +- When you do search, synthesize the information and provide clear, actionable answers +- Be friendly and natural in your responses + +CITATION INSTRUCTIONS: +When you search documentation and reference information from the sources, use inline citations strategically and sparingly: +- Use citation markers like {cite:1}, {cite:2}, etc. to reference specific sources +- Cite each source only ONCE at the specific header or topic that relates to that source +- Place citations directly after the header or concept that the source specifically addresses +- If multiple sources support the same topic, cite them together like {cite:1}{cite:2}{cite:3} +- Do NOT repeatedly cite the same source throughout your response +- Only cite sources that you actually reference in your answer + +MAKE SURE YOU FULLY ANSWER THE USER'S QUESTION. +` + // 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 = typeof response === 'object' && 'citations' in response ? response.citations : + typeof response === 'object' && 'toolResults' in response ? extractCitationsFromResponse(response) : [] + + const assistantMessage = { + id: crypto.randomUUID(), + role: 'assistant', + content: typeof response === 'string' ? response : + 'content' in response ? response.content : '[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 : + 'content' in response ? response.content : '[Error generating response]', + chatId: currentChat?.id, + 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 }) + } +} \ No newline at end of file diff --git a/apps/sim/app/api/docs/search/route.ts b/apps/sim/app/api/docs/search/route.ts new file mode 100644 index 0000000000..61593ff92b --- /dev/null +++ b/apps/sim/app/api/docs/search/route.ts @@ -0,0 +1,141 @@ +import { sql } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { createLogger } from '@/lib/logs/console-logger' +import { generateEmbeddings } from '@/app/api/knowledge/utils' +import { db } from '@/db' +import { docsEmbeddings } from '@/db/schema' + +const logger = createLogger('DocsSearch') + +const DocsSearchSchema = z.object({ + query: z.string().min(1, 'Query is required'), + topK: z.number().min(1).max(10).default(5), +}) + +/** + * Generate embedding for search query + */ +async function generateSearchEmbedding(query: string): Promise { + try { + const embeddings = await generateEmbeddings([query]) + return embeddings[0] || [] + } catch (error) { + logger.error('Failed to generate search embedding:', error) + throw new Error('Failed to generate search embedding') + } +} + +/** + * Search docs embeddings using vector similarity + */ +async function searchDocs(queryEmbedding: number[], topK: number) { + try { + const results = await db + .select({ + chunkId: docsEmbeddings.chunkId, + chunkText: docsEmbeddings.chunkText, + sourceDocument: docsEmbeddings.sourceDocument, + sourceLink: docsEmbeddings.sourceLink, + headerText: docsEmbeddings.headerText, + headerLevel: docsEmbeddings.headerLevel, + similarity: sql`1 - (${docsEmbeddings.embedding} <=> ${JSON.stringify(queryEmbedding)}::vector)`, + }) + .from(docsEmbeddings) + .orderBy(sql`${docsEmbeddings.embedding} <=> ${JSON.stringify(queryEmbedding)}::vector`) + .limit(topK) + + return results + } catch (error) { + logger.error('Failed to search docs:', error) + throw new Error('Failed to search docs') + } +} + +/** + * POST /api/docs/search + * Search Sim Studio documentation using vector similarity + */ +export async function POST(req: NextRequest) { + const requestId = crypto.randomUUID() + + try { + const body = await req.json() + const { query, topK } = DocsSearchSchema.parse(body) + + logger.info(`[${requestId}] 🔍 DOCS SEARCH TOOL CALLED - Query: "${query}"`, { topK }) + + // Step 1: Generate embedding for the query + logger.info(`[${requestId}] Generating query embedding...`) + const queryEmbedding = await generateSearchEmbedding(query) + + if (queryEmbedding.length === 0) { + return NextResponse.json({ error: 'Failed to generate query embedding' }, { status: 500 }) + } + + // Step 2: Search for relevant docs chunks + logger.info(`[${requestId}] Searching docs for top ${topK} chunks...`) + const chunks = await searchDocs(queryEmbedding, topK) + + if (chunks.length === 0) { + return NextResponse.json({ + success: true, + response: "I couldn't find any relevant documentation for that query.", + sources: [], + metadata: { + requestId, + chunksFound: 0, + query, + }, + }) + } + + // Step 3: Format the response with context and sources + const context = chunks + .map((chunk, index) => { + const headerText = typeof chunk.headerText === 'string' ? chunk.headerText : String(chunk.headerText || 'Untitled Section') + const sourceDocument = typeof chunk.sourceDocument === 'string' ? chunk.sourceDocument : String(chunk.sourceDocument || 'Unknown Document') + const sourceLink = typeof chunk.sourceLink === 'string' ? chunk.sourceLink : String(chunk.sourceLink || '#') + const chunkText = typeof chunk.chunkText === 'string' ? chunk.chunkText : String(chunk.chunkText || '') + + return `[${index + 1}] ${headerText} +Document: ${sourceDocument} +URL: ${sourceLink} +Content: ${chunkText}` + }) + .join('\n\n') + + // Step 4: Format sources for response + const sources = chunks.map((chunk, index) => ({ + id: index + 1, + title: chunk.headerText, + document: chunk.sourceDocument, + link: chunk.sourceLink, + similarity: Math.round(chunk.similarity * 100) / 100, + })) + + logger.info(`[${requestId}] Found ${chunks.length} relevant chunks`) + + return NextResponse.json({ + success: true, + response: context, + sources, + metadata: { + requestId, + chunksFound: chunks.length, + query, + topSimilarity: sources[0]?.similarity, + }, + }) + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: 'Invalid request data', details: error.errors }, + { status: 400 } + ) + } + + logger.error(`[${requestId}] Docs search error:`, error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} \ No newline at end of file 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 0fab7439d0..30bc33127e 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 @@ -197,8 +197,7 @@ export const Copilot = forwardRef( logger.info('Sending docs RAG query:', { query, chatId: currentChat?.id }) const result = await sendStreamingMessage({ - query, - topK: 5, + message: query, chatId: currentChat?.id, workflowId: activeWorkflowId, createNewChat: !currentChat, @@ -208,8 +207,8 @@ export const Copilot = forwardRef( const reader = result.stream.getReader() const decoder = new TextDecoder() let accumulatedContent = '' - let sources: any[] = [] let newChatId: string | undefined + let responseCitations: Array<{id: number, title: string, url: string}> = [] while (true) { const { done, value } = await reader.read() @@ -224,47 +223,43 @@ export const Copilot = forwardRef( const data = JSON.parse(line.slice(6)) if (data.type === 'metadata') { - sources = data.sources || [] // 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 + // Update the streaming message with accumulated content and citations setMessages((prev) => prev.map((msg) => msg.id === streamingMessage.id ? { ...msg, content: accumulatedContent, - citations: sources.map((source: any, index: number) => ({ - id: index + 1, - title: source.title, - url: source.link, - })), + citations: responseCitations.length > 0 ? responseCitations : undefined, } : msg ) ) } else if (data.type === 'done') { - // Finish streaming and reload chat if new chat was created + // Final update to ensure citations are applied setMessages((prev) => prev.map((msg) => msg.id === streamingMessage.id ? { ...msg, - citations: sources.map((source: any, index: number) => ({ - id: index + 1, - title: source.title, - url: source.link, - })), + 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 @@ -289,9 +284,8 @@ export const Copilot = forwardRef( } } - logger.info('Received docs RAG response:', { + logger.info('Received copilot chat response:', { contentLength: accumulatedContent.length, - sourcesCount: sources.length, }) } else { throw new Error(result.error || 'Failed to send message') @@ -420,7 +414,7 @@ export const Copilot = forwardRef( {!message.content && (
- Searching documentation... + Thinking...
)} diff --git a/apps/sim/lib/copilot-api.ts b/apps/sim/lib/copilot-api.ts index 80c940172c..2c42d22436 100644 --- a/apps/sim/lib/copilot-api.ts +++ b/apps/sim/lib/copilot-api.ts @@ -262,16 +262,21 @@ export async function sendMessage(request: DocsQueryRequest): Promise<{ } /** - * Send a streaming message using the docs RAG API + * Send a streaming message using the new copilot chat API */ -export async function sendStreamingMessage(request: DocsQueryRequest): Promise<{ +export async function sendStreamingMessage(request: { + message: string + chatId?: string + workflowId?: string + createNewChat?: boolean +}): Promise<{ success: boolean stream?: ReadableStream chatId?: string error?: string }> { try { - const response = await fetch('/api/docs/ask', { + const response = await fetch('/api/copilot/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...request, stream: true }), diff --git a/apps/sim/tools/docs/search.ts b/apps/sim/tools/docs/search.ts new file mode 100644 index 0000000000..33f4c618e7 --- /dev/null +++ b/apps/sim/tools/docs/search.ts @@ -0,0 +1,73 @@ +import type { ToolConfig } from '../types' + +export interface DocsSearchParams { + query: string + topK?: number +} + +export interface DocsSearchResponse { + success: boolean + output: { + response: string + sources: Array<{ + title: string + document: string + link: string + similarity: number + }> + } + error?: string +} + +export const docsSearchTool: ToolConfig = { + id: 'docs_search_internal', + name: 'Search Documentation', + description: 'Search Sim Studio documentation using vector similarity search', + version: '1.0.0', + + params: { + query: { + type: 'string', + required: true, + description: 'The search query to find relevant documentation', + }, + topK: { + type: 'number', + required: false, + description: 'Number of results to return (default: 5, max: 10)', + }, + }, + + request: { + url: '/api/docs/search', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + query: params.query, + topK: params.topK || 5, + }), + isInternalRoute: true, + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'Failed to search documentation') + } + + return { + success: true, + output: { + response: data.response, + sources: data.sources || [], + }, + } + }, + + transformError: (error) => { + return error instanceof Error ? error.message : 'An error occurred while searching documentation' + }, +} \ No newline at end of file diff --git a/apps/sim/tools/utils.ts b/apps/sim/tools/utils.ts index 88d86494cd..989aab60fd 100644 --- a/apps/sim/tools/utils.ts +++ b/apps/sim/tools/utils.ts @@ -3,6 +3,7 @@ import { createLogger } from '@/lib/logs/console-logger' import { useCustomToolsStore } from '@/stores/custom-tools/store' import { useEnvironmentStore } from '@/stores/settings/environment/store' import { tools } from './registry' +import { docsSearchTool } from './docs/search' import type { TableRow, ToolConfig, ToolResponse } from './types' const logger = createLogger('ToolsUtils') @@ -267,8 +268,17 @@ export function createCustomToolRequestBody( } } +// Internal-only tools (not exposed to users in workflows) +const internalTools: Record = { + docs_search_internal: docsSearchTool, +} + // Get a tool by its ID export function getTool(toolId: string): ToolConfig | undefined { + // Check for internal tools first + const internalTool = internalTools[toolId] + if (internalTool) return internalTool + // Check for built-in tools const builtInTool = tools[toolId] if (builtInTool) return builtInTool @@ -302,6 +312,10 @@ export async function getToolAsync( toolId: string, workflowId?: string ): Promise { + // Check for internal tools first + const internalTool = internalTools[toolId] + if (internalTool) return internalTool + // Check for built-in tools const builtInTool = tools[toolId] if (builtInTool) return builtInTool