From 1b3b85f4c47c2360dbbafe598195e4d45f08d381 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Tue, 8 Jul 2025 20:08:34 -0700 Subject: [PATCH] Checkpoint --- apps/sim/app/api/copilot/route.ts | 113 +++++++++++++++- apps/sim/app/api/docs/ask/route.ts | 202 +---------------------------- apps/sim/lib/copilot-api.ts | 40 ++++++ apps/sim/lib/copilot/service.ts | 11 +- apps/sim/stores/copilot/store.ts | 34 +++++ apps/sim/stores/copilot/types.ts | 1 + 6 files changed, 198 insertions(+), 203 deletions(-) diff --git a/apps/sim/app/api/copilot/route.ts b/apps/sim/app/api/copilot/route.ts index 5519333c7f..c49a766b92 100644 --- a/apps/sim/app/api/copilot/route.ts +++ b/apps/sim/app/api/copilot/route.ts @@ -1,7 +1,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { getSession } from '@/lib/auth' -import { createChat, deleteChat, getChat, listChats, sendMessage } from '@/lib/copilot/service' +import { createChat, deleteChat, generateChatTitle, getChat, listChats, sendMessage, updateChat } from '@/lib/copilot/service' import { createLogger } from '@/lib/logs/console-logger' const logger = createLogger('CopilotAPI') @@ -34,6 +34,24 @@ const CreateChatSchema = z.object({ initialMessage: z.string().optional(), }) +// Schema for updating chats +const UpdateChatSchema = z.object({ + chatId: z.string().min(1, 'Chat ID is required'), + messages: z.array(z.object({ + id: z.string(), + role: z.enum(['user', 'assistant', 'system']), + content: z.string(), + timestamp: z.string(), + citations: z.array(z.object({ + id: z.number(), + title: z.string(), + url: z.string(), + similarity: z.number().optional(), + })).optional(), + })).optional(), + title: z.string().optional(), +}) + // Schema for listing chats const ListChatsSchema = z.object({ workflowId: z.string().min(1, 'Workflow ID is required'), @@ -102,6 +120,37 @@ export async function POST(req: NextRequest) { // Handle StreamingExecution (from providers with tool calls) logger.info(`[${requestId}] StreamingExecution detected`) streamToRead = (result.response as any).stream + + // Extract citations from StreamingExecution at API level + const execution = (result.response as any).execution + logger.info(`[${requestId}] Extracting citations from StreamingExecution`, { + hasExecution: !!execution, + hasToolResults: !!execution?.toolResults, + toolResultsLength: execution?.toolResults?.length || 0, + }) + + if (execution?.toolResults) { + for (const toolResult of execution.toolResults) { + logger.info(`[${requestId}] Processing tool result for citations`, { + hasResult: !!toolResult, + resultKeys: toolResult && typeof toolResult === 'object' ? Object.keys(toolResult) : [], + hasResultsArray: !!(toolResult && typeof toolResult === 'object' && toolResult.results), + }) + + if (toolResult && typeof toolResult === 'object' && toolResult.results) { + // Convert documentation search results to citations + const extractedCitations = toolResult.results.map((res: any, index: number) => ({ + id: index + 1, + title: res.title || 'Documentation', + url: res.url || '#', + similarity: res.similarity, + })) + result.citations = extractedCitations + logger.info(`[${requestId}] Extracted ${extractedCitations.length} citations from tool results:`, extractedCitations) + break // Use first set of results found + } + } + } } if (streamToRead) { @@ -287,6 +336,68 @@ export async function PUT(req: NextRequest) { } } +/** + * PATCH /api/copilot + * Update a chat with new messages + */ +export async function PATCH(req: NextRequest) { + try { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const body = await req.json() + const { chatId, messages, title } = UpdateChatSchema.parse(body) + + logger.info(`Updating chat ${chatId} for user ${session.user.id}`) + + // Get the current chat to check if it has a title + const existingChat = await getChat(chatId, session.user.id) + + let titleToUse = title + + // Generate title if chat doesn't have one and we have messages + if (!titleToUse && existingChat && !existingChat.title && messages && messages.length > 0) { + const firstUserMessage = messages.find(msg => msg.role === 'user') + if (firstUserMessage) { + logger.info('Generating LLM-based title for chat without title') + try { + titleToUse = await generateChatTitle(firstUserMessage.content) + logger.info(`Generated title: ${titleToUse}`) + } catch (error) { + logger.error('Failed to generate chat title:', error) + titleToUse = 'New Chat' + } + } + } + + const chat = await updateChat(chatId, session.user.id, { + messages, + title: titleToUse, + }) + + if (!chat) { + return NextResponse.json({ error: 'Chat not found or access denied' }, { status: 404 }) + } + + 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 update chat:', error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} + /** * DELETE /api/copilot * Delete a chat diff --git a/apps/sim/app/api/docs/ask/route.ts b/apps/sim/app/api/docs/ask/route.ts index 7ecea99372..496cab74f6 100644 --- a/apps/sim/app/api/docs/ask/route.ts +++ b/apps/sim/app/api/docs/ask/route.ts @@ -1,12 +1,11 @@ -import { and, eq, sql } from 'drizzle-orm' +import { sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' -import { getSession } from '@/lib/auth' import { getCopilotConfig, getCopilotModel } from '@/lib/copilot/config' import { createLogger } from '@/lib/logs/console-logger' import { generateEmbeddings } from '@/app/api/knowledge/utils' import { db } from '@/db' -import { copilotChats, docsEmbeddings } from '@/db/schema' +import { docsEmbeddings } from '@/db/schema' import { executeProviderRequest } from '@/providers' import { getApiKey } from '@/providers/utils' @@ -18,56 +17,9 @@ const DocsQuerySchema = z.object({ provider: z.string().optional(), // Allow override of provider per request model: z.string().optional(), // Allow override of model per request stream: z.boolean().optional().default(false), // Enable streaming responses - // Chat-related fields - chatId: z.string().optional(), // Existing chat ID for conversation - workflowId: z.string().optional(), // Required for new chats - createNewChat: z.boolean().optional().default(false), // Whether to create a new chat }) -/** - * Generate a chat title using LLM based on the first user message - */ -async function generateChatTitle(userMessage: string): Promise { - try { - 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(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}" - -Return only the title text, nothing else.`, - temperature: 0.3, - maxTokens: 50, - apiKey, - stream: false, - }) - - // Handle different response types - if (typeof response === 'object' && 'content' in response) { - return response.content?.trim() || 'New Chat' - } - - return 'New Chat' - } catch (error) { - logger.error('Failed to generate chat title:', error) - return 'New Chat' // Fallback title - } -} /** * Generate embedding for search query @@ -258,66 +210,24 @@ ${context}` /** * POST /api/docs/ask - * Ask questions about Sim Studio documentation using RAG + * Ask questions about Sim Studio documentation using RAG (no chat functionality) */ export async function POST(req: NextRequest) { const requestId = crypto.randomUUID() try { const body = await req.json() - const { query, topK, provider, model, stream, chatId, workflowId, createNewChat } = - DocsQuerySchema.parse(body) + const { query, topK, provider, model, stream } = 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 || ragConfig.provider, model: model || ragConfig.model, topK, - chatId, - workflowId, - createNewChat, }) - // Handle chat context - let currentChat: any = null - let conversationHistory: any[] = [] - - if (chatId && session?.user?.id) { - // Load existing chat - const [existingChat] = await db - .select() - .from(copilotChats) - .where(and(eq(copilotChats.id, chatId), eq(copilotChats.userId, session.user.id))) - .limit(1) - - if (existingChat) { - currentChat = existingChat - conversationHistory = Array.isArray(existingChat.messages) ? existingChat.messages : [] - } - } else if (createNewChat && workflowId && session?.user?.id) { - // Create new chat - const [newChat] = await db - .insert(copilotChats) - .values({ - userId: session.user.id, - workflowId, - title: null, // Will be generated after first response - model: model || ragConfig.model, - messages: [], - }) - .returning() - - if (newChat) { - currentChat = newChat - conversationHistory = [] - } - } - // Step 1: Generate embedding for the query logger.info(`[${requestId}] Generating query embedding...`) const queryEmbedding = await generateSearchEmbedding(query) @@ -348,14 +258,7 @@ export async function POST(req: NextRequest) { // Step 3: Generate response using LLM logger.info(`[${requestId}] Generating LLM response with ${chunks.length} chunks...`) - const response = await generateResponse( - query, - chunks, - provider, - model, - stream, - conversationHistory - ) + const response = await generateResponse(query, chunks, provider, model, stream, []) // Step 4: Format sources for response const sources = chunks.map((chunk) => ({ @@ -369,7 +272,6 @@ export async function POST(req: NextRequest) { if (response instanceof ReadableStream) { logger.info(`[${requestId}] Returning streaming response`) - // Create a new stream that includes metadata const encoder = new TextEncoder() const decoder = new TextDecoder() @@ -382,7 +284,6 @@ export async function POST(req: NextRequest) { const metadata = { type: 'metadata', sources, - chatId: currentChat?.id, // Include chat ID in metadata metadata: { requestId, chunksFound: chunks.length, @@ -394,21 +295,14 @@ export async function POST(req: NextRequest) { } controller.enqueue(encoder.encode(`data: ${JSON.stringify(metadata)}\n\n`)) - let accumulatedResponse = '' - try { while (true) { const { done, value } = await reader.read() if (done) break - // Forward the chunk with content type const chunkText = decoder.decode(value) - // Clean up any object serialization artifacts in streaming content const cleanedChunk = chunkText.replace(/\[object Object\],?/g, '') - // Accumulate the response content for database saving - accumulatedResponse += cleanedChunk - const contentChunk = { type: 'content', content: cleanedChunk, @@ -416,49 +310,6 @@ export async function POST(req: NextRequest) { controller.enqueue(encoder.encode(`data: ${JSON.stringify(contentChunk)}\n\n`)) } - // Save conversation to database after streaming completes - if (currentChat && session?.user?.id) { - const userMessage = { - id: crypto.randomUUID(), - role: 'user', - content: query, - timestamp: new Date().toISOString(), - } - - const assistantMessage = { - id: crypto.randomUUID(), - role: 'assistant', - content: accumulatedResponse, - timestamp: new Date().toISOString(), - citations: sources.map((source, index) => ({ - id: index + 1, - title: source.title, - url: source.link, - })), - } - - const updatedMessages = [...conversationHistory, userMessage, assistantMessage] - - // Generate title if this is the first message - let updatedTitle = currentChat.title - if (!updatedTitle && conversationHistory.length === 0) { - updatedTitle = await generateChatTitle(query) - } - - // Update the chat in database - await db - .update(copilotChats) - .set({ - title: updatedTitle, - messages: updatedMessages, - updatedAt: new Date(), - }) - .where(eq(copilotChats.id, currentChat.id)) - - logger.info(`[${requestId}] Updated chat ${currentChat.id} with new messages`) - } - - // Send end marker controller.enqueue(encoder.encode(`data: {"type":"done"}\n\n`)) } catch (error) { logger.error(`[${requestId}] Streaming error:`, error) @@ -484,53 +335,10 @@ export async function POST(req: NextRequest) { logger.info(`[${requestId}] RAG response generated successfully`) - // Save conversation to database if we have a chat - if (currentChat && session?.user?.id) { - const userMessage = { - id: crypto.randomUUID(), - role: 'user', - content: query, - timestamp: new Date().toISOString(), - } - - const assistantMessage = { - id: crypto.randomUUID(), - role: 'assistant', - content: typeof response === 'string' ? response : '[Streaming Response]', - timestamp: new Date().toISOString(), - citations: sources.map((source, index) => ({ - id: index + 1, - title: source.title, - url: source.link, - })), - } - - const updatedMessages = [...conversationHistory, userMessage, assistantMessage] - - // Generate title if this is the first message - let updatedTitle = currentChat.title - if (!updatedTitle && conversationHistory.length === 0) { - updatedTitle = await generateChatTitle(query) - } - - // Update the chat in database - await db - .update(copilotChats) - .set({ - title: updatedTitle, - messages: updatedMessages, - updatedAt: new Date(), - }) - .where(eq(copilotChats.id, currentChat.id)) - - logger.info(`[${requestId}] Updated chat ${currentChat.id} with new messages`) - } - return NextResponse.json({ success: true, response, sources, - chatId: currentChat?.id, // Include chat ID in response metadata: { requestId, chunksFound: chunks.length, diff --git a/apps/sim/lib/copilot-api.ts b/apps/sim/lib/copilot-api.ts index f8abae19a7..cf4fcd4c93 100644 --- a/apps/sim/lib/copilot-api.ts +++ b/apps/sim/lib/copilot-api.ts @@ -170,6 +170,46 @@ export async function getChat(chatId: string): Promise<{ } } +/** + * Update a chat with new messages + */ +export async function updateChatMessages( + chatId: string, + messages: CopilotMessage[] +): Promise<{ + success: boolean + chat?: CopilotChat + error?: string +}> { + try { + const response = await fetch(`/api/copilot`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + chatId, + messages, + }), + }) + + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'Failed to update chat') + } + + return { + success: true, + chat: data.chat, + } + } catch (error) { + logger.error('Failed to update chat messages:', error) + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + } + } +} + /** * Delete a chat */ diff --git a/apps/sim/lib/copilot/service.ts b/apps/sim/lib/copilot/service.ts index 9ed960dca3..5f1243b436 100644 --- a/apps/sim/lib/copilot/service.ts +++ b/apps/sim/lib/copilot/service.ts @@ -1,4 +1,4 @@ -import { and, eq } from 'drizzle-orm' +import { and, desc, eq } from 'drizzle-orm' import { createLogger } from '@/lib/logs/console-logger' import { db } from '@/db' import { copilotChats } from '@/db/schema' @@ -598,7 +598,7 @@ export async function listChats( .select() .from(copilotChats) .where(and(eq(copilotChats.userId, userId), eq(copilotChats.workflowId, workflowId))) - .orderBy(copilotChats.updatedAt) + .orderBy(desc(copilotChats.updatedAt)) .limit(limit) .offset(offset) @@ -753,8 +753,9 @@ export async function sendMessage(request: SendMessageRequest): Promise<{ } } - // If we have a chat, update it with the new messages - if (currentChat) { + // For non-streaming responses, save immediately + // For streaming responses, save will be handled by the API layer after stream completes + if (currentChat && typeof response === 'string') { const userMessage: CopilotMessage = { id: crypto.randomUUID(), role: 'user', @@ -765,7 +766,7 @@ export async function sendMessage(request: SendMessageRequest): Promise<{ const assistantMessage: CopilotMessage = { id: crypto.randomUUID(), role: 'assistant', - content: typeof response === 'string' ? response : '[Streaming Response]', + content: response, timestamp: new Date().toISOString(), citations: citations.length > 0 ? citations : undefined, } diff --git a/apps/sim/stores/copilot/store.ts b/apps/sim/stores/copilot/store.ts index bb88926d43..fcefb941be 100644 --- a/apps/sim/stores/copilot/store.ts +++ b/apps/sim/stores/copilot/store.ts @@ -9,6 +9,7 @@ import { listChats, sendStreamingDocsMessage, sendStreamingMessage, + updateChatMessages, } from '@/lib/copilot-api' import { createLogger } from '@/lib/logs/console-logger' import type { CopilotStore } from './types' @@ -434,6 +435,13 @@ export const useCopilotStore = create()( isSendingMessage: false, })) + // Save chat to database after streaming completes + const chatIdToSave = newChatId || get().currentChat?.id + if (chatIdToSave) { + console.log('[CopilotStore] Saving chat to database:', chatIdToSave) + await get().saveChatMessages(chatIdToSave) + } + // Handle new chat creation if (newChatId && !get().currentChat) { console.log('[CopilotStore] Reloading chats for new chat:', newChatId) @@ -481,6 +489,32 @@ export const useCopilotStore = create()( }) }, + // Save chat messages to database + saveChatMessages: async (chatId: string) => { + try { + const { messages, currentChat } = get() + + logger.info(`Saving ${messages.length} messages for chat ${chatId}`) + + // Let the API handle title generation if needed + const result = await updateChatMessages(chatId, messages) + + if (result.success && result.chat) { + // Update local state with the saved chat + set({ + currentChat: result.chat, + messages: result.chat.messages, + }) + + logger.info(`Successfully saved chat ${chatId} with ${result.chat.messages.length} messages`) + } else { + logger.error(`Failed to save chat ${chatId}:`, result.error) + } + } catch (error) { + logger.error(`Error saving chat ${chatId}:`, error) + } + }, + // Clear error state clearError: () => { set({ error: null }) diff --git a/apps/sim/stores/copilot/types.ts b/apps/sim/stores/copilot/types.ts index 083aa48911..a8ea7de42d 100644 --- a/apps/sim/stores/copilot/types.ts +++ b/apps/sim/stores/copilot/types.ts @@ -66,6 +66,7 @@ export interface CopilotActions { // Message handling sendMessage: (message: string, options?: { stream?: boolean }) => Promise sendDocsMessage: (query: string, options?: { stream?: boolean; topK?: number }) => Promise + saveChatMessages: (chatId: string) => Promise // Utility actions clearMessages: () => void