mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
Checkpoint
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<string> {
|
||||
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,
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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<CopilotStore>()(
|
||||
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<CopilotStore>()(
|
||||
})
|
||||
},
|
||||
|
||||
// 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 })
|
||||
|
||||
@@ -66,6 +66,7 @@ export interface CopilotActions {
|
||||
// Message handling
|
||||
sendMessage: (message: string, options?: { stream?: boolean }) => Promise<void>
|
||||
sendDocsMessage: (query: string, options?: { stream?: boolean; topK?: number }) => Promise<void>
|
||||
saveChatMessages: (chatId: string) => Promise<void>
|
||||
|
||||
// Utility actions
|
||||
clearMessages: () => void
|
||||
|
||||
Reference in New Issue
Block a user